nandi/oripublic Fork 0
5d476ff5f3dc3b7db3afaccaedc7d9661981449e
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

FileTree.tsx · 161 lines · 4.1 KBTypeScript Blame HistoryRaw
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday1/**
2 * FileTree lists the workspace files VS Code style: Material icons, chevrons
3 * on directories, indent guides, and the previewed file highlighted.
4 * Directories load lazily. Clicking a file opens it in the preview tab;
5 * double-clicking opens it in the editor.
6 *
7 * Icons are the Material Icon Theme SVGs, served from /material-icons
8 * (copied into public/ by ui/scripts/copy-icons.mjs).
9 *
10 * @example
11 * <FileTree />
12 */
13
14import { useCallback, useEffect, useState } from "react";
15import {
16 getIconForDirectoryPath,
17 getIconForFilePath,
18 getIconUrlByName,
19 isMaterialIconName,
20} from "vscode-material-icons";
21import { type FileEntry, listFiles } from "../api";
22import { openEditor, openPreview, useWorkspace } from "../workspace";
23
24const ICONS_URL = "/material-icons";
25
26export function FileTree() {
27 const [entries, setEntries] = useState<FileEntry[] | null>(null);
28 const [error, setError] = useState<string | null>(null);
29
30 const refresh = useCallback(() => {
31 listFiles()
32 .then((result) => {
33 setEntries(result.entries);
34 setError(null);
35 })
36 .catch((cause: Error) => setError(cause.message));
37 }, []);
38
39 useEffect(() => {
40 refresh();
41 }, [refresh]);
42
43 return (
44 <nav className="filetree" aria-label="workspace files">
45 <div className="filetree-header">
46 <span>Files</span>
47 <button
48 type="button"
49 className="filetree-refresh"
50 onClick={refresh}
51 title="Refresh"
52 >
53
54 </button>
55 </div>
56 {error && <p className="filetree-error">{error}</p>}
57 {entries === null && !error && (
58 <p className="filetree-loading">loading</p>
59 )}
60 {entries && <EntryList entries={entries} depth={0} />}
61 </nav>
62 );
63}
64
65function EntryList({
66 entries,
67 depth,
68}: { entries: FileEntry[]; depth: number }) {
69 return (
70 <ul className={`filetree-list${depth > 0 ? " filetree-list-nested" : ""}`}>
71 {entries.map((entry) => (
72 <li key={entry.path}>
73 {entry.isDir ? (
74 <DirectoryNode entry={entry} depth={depth} />
75 ) : (
76 <FileNode entry={entry} depth={depth} />
77 )}
78 </li>
79 ))}
80 </ul>
81 );
82}
83
84/** materialIconUrl resolves the Material icon for an entry (open-folder aware). */
85function materialIconUrl(entry: FileEntry, expanded: boolean): string {
86 let name = entry.isDir
87 ? getIconForDirectoryPath(entry.name)
88 : getIconForFilePath(entry.name);
89 if (entry.isDir && expanded) {
90 const openName = `${name}-open`;
91 if (isMaterialIconName(openName)) {
92 name = openName;
93 }
94 }
95 return getIconUrlByName(name, ICONS_URL);
96}
97
98function DirectoryNode({ entry, depth }: { entry: FileEntry; depth: number }) {
99 const [children, setChildren] = useState<FileEntry[] | null>(null);
100 const [expanded, setExpanded] = useState(false);
101
102 const toggle = () => {
103 const next = !expanded;
104 setExpanded(next);
105 if (next && children === null) {
106 listFiles(entry.path)
107 .then((result) => setChildren(result.entries))
108 .catch(() => setChildren([]));
109 }
110 };
111
112 return (
113 <>
114 <button
115 type="button"
116 className="filetree-entry filetree-dir"
117 style={{ paddingLeft: `${depth * 0.75}rem` }}
118 onClick={toggle}
119 >
120 <span className="filetree-chevron" aria-hidden>
121 {expanded ? "⌄" : "›"}
122 </span>
123 <img
124 className="filetree-icon"
125 src={materialIconUrl(entry, expanded)}
126 alt=""
127 />
128 <span className="filetree-name">{entry.name}</span>
129 </button>
130 {expanded && children && (
131 <EntryList entries={children} depth={depth + 1} />
132 )}
133 </>
134 );
135}
136
137function FileNode({ entry, depth }: { entry: FileEntry; depth: number }) {
138 const active = useWorkspace(
139 (workspace) =>
140 workspace.previewPath === entry.path ||
141 workspace.editorPath === entry.path,
142 );
143 return (
144 <button
145 type="button"
146 className={`filetree-entry filetree-file${active ? " filetree-active" : ""}`}
147 style={{ paddingLeft: `${depth * 0.75}rem` }}
148 title="Click: preview — double-click: edit"
149 onClick={() => openPreview(entry.path)}
150 onDoubleClick={() => openEditor(entry.path)}
151 >
152 <span className="filetree-chevron" aria-hidden />
153 <img
154 className="filetree-icon"
155 src={materialIconUrl(entry, false)}
156 alt=""
157 />
158 <span className="filetree-name">{entry.name}</span>
159 </button>
160 );
161}