nandi/oripublic Fork 0
ca7e020cbb6442f047e0002eb9831ba29f8a17af
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
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
/**
 * FileTree lists the workspace files VS Code style: Material icons, chevrons
 * on directories, indent guides, and the previewed file highlighted.
 * Directories load lazily. Clicking a file opens it in the preview tab;
 * double-clicking opens it in the editor.
 *
 * Icons are the Material Icon Theme SVGs, served from /material-icons
 * (copied into public/ by ui/scripts/copy-icons.mjs).
 *
 * @example
 * <FileTree />
 */

import { useCallback, useEffect, useState } from "react";
import {
	getIconForDirectoryPath,
	getIconForFilePath,
	getIconUrlByName,
	isMaterialIconName,
} from "vscode-material-icons";
import { type FileEntry, listFiles } from "../api";
import { openEditor, openPreview, useWorkspace } from "../workspace";

const ICONS_URL = "/material-icons";

export function FileTree() {
	const [entries, setEntries] = useState<FileEntry[] | null>(null);
	const [error, setError] = useState<string | null>(null);

	const refresh = useCallback(() => {
		listFiles()
			.then((result) => {
				setEntries(result.entries);
				setError(null);
			})
			.catch((cause: Error) => setError(cause.message));
	}, []);

	useEffect(() => {
		refresh();
	}, [refresh]);

	return (
		<nav className="filetree" aria-label="workspace files">
			<div className="filetree-header">
				<span>Files</span>
				<button
					type="button"
					className="filetree-refresh"
					onClick={refresh}
					title="Refresh"
				>
					
				</button>
			</div>
			{error && <p className="filetree-error">{error}</p>}
			{entries === null && !error && (
				<p className="filetree-loading">loading</p>
			)}
			{entries && <EntryList entries={entries} depth={0} />}
		</nav>
	);
}

function EntryList({
	entries,
	depth,
}: { entries: FileEntry[]; depth: number }) {
	return (
		<ul className={`filetree-list${depth > 0 ? " filetree-list-nested" : ""}`}>
			{entries.map((entry) => (
				<li key={entry.path}>
					{entry.isDir ? (
						<DirectoryNode entry={entry} depth={depth} />
					) : (
						<FileNode entry={entry} depth={depth} />
					)}
				</li>
			))}
		</ul>
	);
}

/** materialIconUrl resolves the Material icon for an entry (open-folder aware). */
function materialIconUrl(entry: FileEntry, expanded: boolean): string {
	let name = entry.isDir
		? getIconForDirectoryPath(entry.name)
		: getIconForFilePath(entry.name);
	if (entry.isDir && expanded) {
		const openName = `${name}-open`;
		if (isMaterialIconName(openName)) {
			name = openName;
		}
	}
	return getIconUrlByName(name, ICONS_URL);
}

function DirectoryNode({ entry, depth }: { entry: FileEntry; depth: number }) {
	const [children, setChildren] = useState<FileEntry[] | null>(null);
	const [expanded, setExpanded] = useState(false);

	const toggle = () => {
		const next = !expanded;
		setExpanded(next);
		if (next && children === null) {
			listFiles(entry.path)
				.then((result) => setChildren(result.entries))
				.catch(() => setChildren([]));
		}
	};

	return (
		<>
			<button
				type="button"
				className="filetree-entry filetree-dir"
				style={{ paddingLeft: `${depth * 0.75}rem` }}
				onClick={toggle}
			>
				<span className="filetree-chevron" aria-hidden>
					{expanded ? "⌄" : "›"}
				</span>
				<img
					className="filetree-icon"
					src={materialIconUrl(entry, expanded)}
					alt=""
				/>
				<span className="filetree-name">{entry.name}</span>
			</button>
			{expanded && children && (
				<EntryList entries={children} depth={depth + 1} />
			)}
		</>
	);
}

function FileNode({ entry, depth }: { entry: FileEntry; depth: number }) {
	const active = useWorkspace(
		(workspace) =>
			workspace.previewPath === entry.path ||
			workspace.editorPath === entry.path,
	);
	return (
		<button
			type="button"
			className={`filetree-entry filetree-file${active ? " filetree-active" : ""}`}
			style={{ paddingLeft: `${depth * 0.75}rem` }}
			title="Click: preview — double-click: edit"
			onClick={() => openPreview(entry.path)}
			onDoubleClick={() => openEditor(entry.path)}
		>
			<span className="filetree-chevron" aria-hidden />
			<img
				className="filetree-icon"
				src={materialIconUrl(entry, false)}
				alt=""
			/>
			<span className="filetree-name">{entry.name}</span>
		</button>
	);
}