feat(nvim): improve tree and file switching, and implements links

This commit is contained in:
Pihkaal
2024-05-31 22:56:08 +02:00
parent 0c79ea457c
commit 96207863a9
12 changed files with 361 additions and 309 deletions

View File

@@ -3,35 +3,32 @@ import { Kitty } from "./components/Kitty";
import { AppProvider } from "./providers/AppProvider";
import { Music } from "./components/Music";
import { Nvim } from "./components/Nvim";
import { BrowserRouter } from "react-router-dom";
export default function App() {
const [loggedIn, setLoggedIn] = useState(false);
return (
<AppProvider>
<BrowserRouter>
<main className="h-screen w-screen overflow-hidden bg-[url(/wallpaper.jpg)] bg-cover">
{loggedIn ? (
<div className="flex h-full w-full flex-col">
<Kitty className="w-full flex-1 pb-1 pl-2 pr-2 pt-2">
<Nvim />
</Kitty>
<main className="h-screen w-screen overflow-hidden bg-[url(/wallpaper.jpg)] bg-cover">
{loggedIn ? (
<div className="flex h-full w-full flex-col">
<Kitty className="w-full flex-1 pb-1 pl-2 pr-2 pt-2">
<Nvim />
</Kitty>
<Music />
</div>
) : (
<div className="flex h-full items-center justify-center">
<button
className="rounded-md border border-black px-2 py-1 hover:border-2 hover:font-bold"
onClick={() => setLoggedIn(true)}
>
Log in
</button>
</div>
)}
</main>
</BrowserRouter>
<Music />
</div>
) : (
<div className="flex h-full items-center justify-center">
<button
className="rounded-md border border-black px-2 py-1 hover:border-2 hover:font-bold"
onClick={() => setLoggedIn(true)}
>
Log in
</button>
</div>
)}
</main>
</AppProvider>
);
}

View File

@@ -1,3 +1,3 @@
export const NvimEditor = (props: { data: string }) => (
<div className="h-full">{props.data}</div>
export const NvimEditor = (props: { source: string | undefined }) => (
<div className="h-full">{props.source}</div>
);

View File

@@ -1,199 +0,0 @@
import { useApp } from "~/hooks/useApp";
import { CHAR_HEIGHT, CHAR_WIDTH } from "../Kitty";
import { type ReactNode, useEffect, useState, useCallback } from "react";
import { type InnerKittyProps } from "~/utils/types";
import { type Nvim } from ".";
import { useNavigate } from "react-router-dom";
type FileIcon = {
char: string;
color: string;
};
type File = {
name: string;
} & (
| { type: "file"; directory?: File & { type: "directory" } }
| { type: "directory"; files: Array<string>; folded: boolean }
);
const FILE_ICONS: Record<string, FileIcon> = {
md: {
char: " ",
color: "#89bafa",
},
asc: {
char: "󰷖 ",
color: "#f9e2af",
},
UNKNOWN: { char: "󰈚 ", color: "#f599ae" },
};
const sortFiles = (files: Array<File>) =>
files
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) =>
a.type === "directory" && b.type !== "directory"
? -1
: a.type !== "directory" && b.type === "directory"
? 1
: 0,
);
export const NvimTree = (props: InnerKittyProps<typeof Nvim>) => {
const { rootManifest, activeKitty } = useApp();
const navigate = useNavigate();
const [files, setFiles] = useState<Array<File>>(
sortFiles([
{
type: "directory",
name: "projects",
files: rootManifest.projects,
folded: true,
},
...rootManifest.files.map((name) => ({ type: "file" as const, name })),
]),
);
const [selected, setSelected] = useState(files.length - 1);
const handleOpen = (file: File) => {
if (file.type === "directory") {
file.folded = !file.folded;
} else {
let filePath = "";
if (file.directory) {
filePath += `${file.directory.name}/`;
}
navigate(`?view=${filePath}${file.name}`);
}
setFiles([...files]);
};
const tree: Array<ReactNode> = [];
let y = 0;
let selectedFile: File;
for (const fileOrDir of files) {
if (y === selected) selectedFile = fileOrDir;
if (fileOrDir.type === "directory") {
const dy = y;
tree.push(
<li
key={y}
className="text-[#a0b6ee]"
style={{ background: y === selected ? "#504651" : "" }}
onMouseDown={() => setSelected(dy)}
onDoubleClick={() => handleOpen(fileOrDir)}
>
{fileOrDir.folded ? (
<>
<span className="text-[#716471]"> </span>{" "}
</>
) : (
<> </>
)}
{fileOrDir.name}
</li>,
);
if (!fileOrDir.folded) {
for (let i = 0; i < fileOrDir.files.length; i++) {
y++;
if (y === selected)
selectedFile = {
type: "file",
name: fileOrDir.files[i],
directory: fileOrDir,
};
const icon = FILE_ICONS.UNKNOWN;
const fy = y;
tree.push(
<li
key={y}
style={{ background: y === selected ? "#504651" : "" }}
onMouseDown={() => setSelected(fy)}
onDoubleClick={() =>
handleOpen({
type: "file",
name: fileOrDir.files[i],
directory: fileOrDir,
})
}
>
{" "}
<span className="text-[#5b515b]">
{i === fileOrDir.files.length - 1 ? "└ " : "│ "}
</span>
<span style={{ color: icon.color }}>{`${icon.char}`}</span>
<span>{fileOrDir.files[i]}</span>
</li>,
);
}
}
} else {
const parts = fileOrDir.name.split(".");
let extension = parts[parts.length - 1];
if (!FILE_ICONS[extension]) extension = "UNKNOWN";
const icon = FILE_ICONS[extension];
const fy = y;
tree.push(
<li
key={y}
style={{ background: y === selected ? "#504651" : "" }}
onMouseDown={() => setSelected(fy)}
onDoubleClick={() => handleOpen(fileOrDir)}
>
{" "}
<span style={{ color: icon.color }}>{`${icon.char}`}</span>
{fileOrDir.name === "README.md" ? (
<span className="font-bold text-[#d8c5a1]">README.md</span>
) : (
<span>{fileOrDir.name}</span>
)}
</li>,
);
}
y++;
}
useEffect(() => {
const onScroll = (event: KeyboardEvent) => {
if (activeKitty !== props.id) return;
switch (event.key) {
case "ArrowUp":
setSelected((x) => Math.max(0, x - 1));
break;
case "ArrowDown":
setSelected((x) => Math.min(y - 1, x + 1));
break;
case "Enter":
handleOpen(selectedFile);
break;
}
};
window.addEventListener("keydown", onScroll);
return () => {
window.removeEventListener("keydown", onScroll);
};
});
return (
<div className="h-full select-none bg-[#0000001a]">
<ul
style={{
padding: `${CHAR_HEIGHT}px ${CHAR_WIDTH}px 0 ${CHAR_WIDTH * 2}px`,
}}
>
{tree}
</ul>
</div>
);
};

View File

@@ -0,0 +1,25 @@
import { Directory } from "~/utils/types";
export const NvimTreeDirectory = (props: {
directory: Directory;
y: number;
selected: boolean;
onSelect: (y: number) => void;
onOpen: (directory: Directory) => void;
}) => (
<li
className="text-[#a0b6ee]"
style={{ background: props.selected ? "#504651" : "" }}
onMouseDown={() => props.onSelect(props.y)}
onDoubleClick={() => props.onOpen(props.directory)}
>
{props.directory.opened ? (
<> </>
) : (
<>
<span className="text-[#716471]"> </span>{" "}
</>
)}
{props.directory.name}
</li>
);

View File

@@ -0,0 +1,41 @@
import { ICONS } from "~/utils/icons";
import { File } from "~/utils/types";
export const NvimTreeFile = (props: {
file: File;
y: number;
selected: boolean;
inDirectory: boolean | "last";
onSelect: (y: number) => void;
onOpen: (file: File) => void;
}) => {
let iconName = props.file.icon;
if (!iconName) {
const parts = props.file.name.split(".");
iconName = parts[parts.length - 1];
}
if (!ICONS[iconName]) iconName = "UNKNOWN";
const icon = ICONS[iconName];
return (
<li
style={{ background: props.selected ? "#504651" : "" }}
onMouseDown={() => props.onSelect(props.y)}
onDoubleClick={() => props.onOpen(props.file)}
>
{" "}
{props.inDirectory && (
<span className="text-[#5b515b]">
{props.inDirectory === "last" ? "└ " : "│ "}
</span>
)}
<span style={{ color: icon.color }}>{`${icon.char}`}</span>
{props.file.name === "README.md" ? (
<span className="font-bold text-[#d8c5a1]">README.md</span>
) : (
<span>{props.file.name}</span>
)}
</li>
);
};

View File

@@ -0,0 +1,178 @@
import { useApp } from "~/hooks/useApp";
import { CHAR_HEIGHT, CHAR_WIDTH } from "../../Kitty";
import { type ReactNode, useEffect, useState } from "react";
import {
type File,
type InnerKittyProps,
type RootManifest,
type Directory,
} from "~/utils/types";
import { type Nvim } from "..";
import { NvimTreeDirectory } from "./NvimTreeDirectory";
import { NvimTreeFile } from "./NvimTreeFile";
import { promiseHooks } from "v8";
const sortFiles = (files: Array<File | Directory>) =>
files
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) =>
a.type === "directory" && b.type !== "directory"
? -1
: a.type !== "directory" && b.type === "directory"
? 1
: 0,
);
const manifestToTree = (manifest: RootManifest) =>
sortFiles([
{
type: "directory",
name: "links",
opened: false,
files: manifest.links.map((link) => ({
type: "link" as const,
...link,
})),
},
{
type: "directory",
name: "projects",
opened: false,
files: manifest.projects.map((project) => ({
type: "file" as const,
repo: project.name,
fileName: "README.md",
...project,
})),
},
...manifest.files.map((file) => ({
type: "file" as const,
name: file,
repo: "pihkaal",
fileName: file,
})),
]);
export const NvimTree = (
props: InnerKittyProps<typeof Nvim> & {
onOpen: (file: File) => void;
},
) => {
const { rootManifest, activeKitty } = useApp();
const [files, setFiles] = useState(manifestToTree(rootManifest));
const [selectedY, setSelectedY] = useState(0);
const tree: Array<ReactNode> = [];
let y = 0;
let selectedFile = files[0];
for (const file of files) {
if (selectedY === y) selectedFile = file;
if (file.type === "directory") {
tree.push(
<NvimTreeDirectory
key={y}
directory={file}
y={y}
selected={selectedY === y}
onSelect={setSelectedY}
onOpen={(directory) => {
directory.opened = !directory.opened;
setFiles([...files]);
}}
/>,
);
if (file.opened) {
file.files.forEach((childFile, i) => {
y++;
if (selectedY === y) selectedFile = childFile;
tree.push(
<NvimTreeFile
key={y}
file={childFile}
y={y}
inDirectory={i === file.files.length - 1 ? "last" : true}
selected={selectedY === y}
onSelect={setSelectedY}
onOpen={props.onOpen}
/>,
);
});
}
} else {
tree.push(
<NvimTreeFile
key={y}
file={file}
y={y}
inDirectory={false}
selected={selectedY === y}
onSelect={setSelectedY}
onOpen={props.onOpen}
/>,
);
}
y++;
}
useEffect(() => {
const onScroll = (event: KeyboardEvent) => {
if (activeKitty !== props.id) return;
switch (event.key) {
case "ArrowUp":
setSelectedY((x) => Math.max(0, x - 1));
break;
case "ArrowDown":
setSelectedY((x) => Math.min(y - 1, x + 1));
break;
case "Enter":
if (selectedFile.type === "directory") {
selectedFile.opened = !selectedFile.opened;
setFiles([...files]);
} else {
props.onOpen(selectedFile);
}
break;
}
};
window.addEventListener("keydown", onScroll);
return () => {
window.removeEventListener("keydown", onScroll);
};
});
return (
<div className="h-full select-none bg-[#0000001a]">
<ul
style={{
padding: `${CHAR_HEIGHT}px ${CHAR_WIDTH}px 0 ${CHAR_WIDTH * 2}px`,
}}
>
{tree}
</ul>
</div>
);
};
type FileIcon = {
char: string;
color: string;
};
const FILE_ICONS: Record<string, FileIcon> = {
md: {
char: " ",
color: "#89bafa",
},
asc: {
char: "󰷖 ",
color: "#f9e2af",
},
UNKNOWN: { char: "󰈚 ", color: "#f599ae" },
};

View File

@@ -4,59 +4,23 @@ import { NvimEditor } from "./NvimEditor";
import { NvimInput } from "./NvimInput";
import { NvimStatusBar } from "./NvimStatusBar";
import { NvimTree } from "./NvimTree";
import { useLocation, useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import axios from "axios";
const fetchData = async (
branch: string,
repo: string,
file: string,
): Promise<string | null> => {
try {
const response = await axios.get<string>(
`https://raw.githubusercontent.com/pihkaal/${repo}/${branch}/${file}`,
);
return response.data;
} catch {
return null;
}
};
import { useState } from "react";
import { File } from "~/utils/types";
export const Nvim = (_props: {}) => {
const kitty = useKitty();
const location = useLocation();
const navigate = useNavigate();
const [data, setData] = useState<string>();
const [activeFile, setActiveFile] = useState<string>();
useEffect(() => {
const params = new URLSearchParams(location.search);
const view = params.get("view");
if (!view) {
navigate("?view=README.md");
return;
const handleOpenFile = (file: File) => {
if (file.type === "link") {
window.open(file.url, "_blank")?.focus();
} else {
setActiveFile(
`https://raw.githubusercontent.com/pihkaal/${file.repo}/main/${file.fileName}`,
);
}
const path = view.split("/");
if (path.length === 1) {
path.splice(0, 0, "pihkaal");
}
const repo = path[0]!;
const file = path[1]!;
void (async () => {
const data =
(await fetchData("main", repo, file)) ??
(await fetchData("dev", repo, file));
if (!data) {
navigate("?view=README.md");
return;
}
setData(data);
})();
}, [location, navigate]);
};
return (
<div
@@ -69,10 +33,10 @@ export const Nvim = (_props: {}) => {
{kitty && (
<>
<div style={{ gridArea: "1 / 1 / 1 / 2" }}>
<NvimTree {...kitty} />
<NvimTree {...kitty} onOpen={handleOpenFile} />
</div>
<div style={{ gridArea: "1 / 2 / 1 / 3", overflow: "scroll" }}>
<NvimEditor data={data ?? ""} />
<NvimEditor source={activeFile} />
</div>
<div style={{ gridArea: "2 / 1 / 2 / 3" }}>
<NvimStatusBar

View File

@@ -7,7 +7,28 @@ export const AppProvider = (props: { children?: ReactNode }) => {
const [activeKitty, setActiveKitty] = useState(":r0:");
const [rootManifest, setRootManifest] = useState<RootManifest>({
files: ["README.md", "pubkey.asc"],
projects: ["me", "tlock"],
projects: [
{
name: "me",
icon: "ts",
},
{
name: "tlock",
icon: "rs",
},
],
links: [
{
name: "github",
url: "https://github.com/pihkaal",
icon: "github",
},
{
name: "instagram",
url: "https://instagram.com/pihkaal",
icon: "instagram",
},
],
});
useEffect(() => {

29
src/utils/icons.ts Normal file
View File

@@ -0,0 +1,29 @@
import { Icon } from "./types";
export const ICONS: Record<string, Icon> = {
md: {
char: " ",
color: "#89bafa",
},
asc: {
char: "󰷖 ",
color: "#f9e2af",
},
ts: {
char: " ",
color: "#4d86a2",
},
rs: {
char: " ",
color: "#be8f78",
},
instagram: {
char: " ",
color: "#e1306c",
},
github: {
char: "󰊤 ",
color: "#ffffff",
},
UNKNOWN: { char: "󰈚 ", color: "#f599ae" },
};

View File

@@ -9,5 +9,36 @@ export type InnerKittyProps<T extends (...args: any[]) => any> = Prettify<
export type RootManifest = {
files: Array<string>;
projects: Array<string>;
projects: Array<{
name: string;
icon: string;
}>;
links: Array<{
name: string;
url: string;
icon: string;
}>;
};
export type Icon = {
char: string;
color: string;
};
export type File = {
name: string;
} & (
| {
type: "link";
url: string;
icon: string;
}
| { type: "file"; repo: string; fileName: string; icon?: string }
);
export type Directory = {
name: string;
type: "directory";
files: Array<File>;
opened: boolean;
};