forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | /** |
| 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 | ||
| 14 | import { useCallback, useEffect, useState } from "react"; | |
| 15 | import { | |
| 16 | getIconForDirectoryPath, | |
| 17 | getIconForFilePath, | |
| 18 | getIconUrlByName, | |
| 19 | isMaterialIconName, | |
| 20 | } from "vscode-material-icons"; | |
| 21 | import { type FileEntry, listFiles } from "../api"; | |
| 22 | import { openEditor, openPreview, useWorkspace } from "../workspace"; | |
| 23 | ||
| 24 | const ICONS_URL = "/material-icons"; | |
| 25 | ||
| 26 | export 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 | ||
| 65 | function 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). */ | |
| 85 | function 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 | ||
| 98 | function 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 | ||
| 137 | function 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 | } |