9.2 KB324 lines
Blame
1"use client";
2
3import { useState, useCallback, useEffect, useRef } from "react";
4import { usePathname } from "next/navigation";
5import Link from "next/link";
6import { FileIcon } from "@/app/components/file-icon";
7import { repos } from "@/lib/api";
8import { encodePath } from "@/lib/utils";
9
10interface TreeEntry {
11 name: string;
12 type: string;
13}
14
15function sortEntries(entries: TreeEntry[]) {
16 return [...entries].sort((a, b) => {
17 if (a.type === b.type) return a.name.localeCompare(b.name);
18 return a.type === "tree" ? -1 : 1;
19 });
20}
21
22function TreeNode({
23 entry,
24 path,
25 depth,
26 owner,
27 repo,
28 refName,
29 currentPath,
30 expandedDirs,
31 treeData,
32 loadingDirs,
33 onToggle,
34}: {
35 entry: TreeEntry;
36 path: string;
37 depth: number;
38 owner: string;
39 repo: string;
40 refName: string;
41 currentPath: string;
42 expandedDirs: Set<string>;
43 treeData: Record<string, TreeEntry[]>;
44 loadingDirs: Set<string>;
45 onToggle: (dirPath: string) => void;
46}) {
47 const isDir = entry.type === "tree";
48 const isExpanded = expandedDirs.has(path);
49 const isActive = currentPath === path;
50 const isLoading = loadingDirs.has(path);
51
52 if (isDir) {
53 return (
54 <>
55 <button
56 onClick={() => onToggle(path)}
57 className="flex items-center gap-1.5 w-full text-left py-1 pr-3 hover-row"
58 style={{
59 paddingLeft: `${depth * 16 + 8}px`,
60 border: "none",
61 background: isActive ? "var(--bg-hover)" : "transparent",
62 cursor: "pointer",
63 font: "inherit",
64 fontSize: "0.8125rem",
65 color: isActive ? "var(--text-primary)" : "var(--text-muted)",
66 }}
67 >
68 <span
69 className="text-xs"
70 style={{ width: 12, textAlign: "center", color: "var(--text-faint)", flexShrink: 0 }}
71 >
72 {isLoading ? "·" : isExpanded ? "▾" : "▸"}
73 </span>
74 <FileIcon type="tree" name={entry.name} size={14} />
75 <span className="truncate">{entry.name}</span>
76 </button>
77 {isExpanded && treeData[path] && (
78 sortEntries(treeData[path]).map((child) => (
79 <TreeNode
80 key={child.name}
81 entry={child}
82 path={`${path}/${child.name}`}
83 depth={depth + 1}
84 owner={owner}
85 repo={repo}
86 refName={refName}
87 currentPath={currentPath}
88 expandedDirs={expandedDirs}
89 treeData={treeData}
90 loadingDirs={loadingDirs}
91 onToggle={onToggle}
92 />
93 ))
94 )}
95 </>
96 );
97 }
98
99 return (
100 <Link
101 href={`/${owner}/${repo}/blob/${refName}/${encodePath(path)}`}
102 className="flex items-center gap-1.5 py-1 pr-3 hover-row"
103 style={{
104 paddingLeft: `${depth * 16 + 8 + 12 + 6}px`,
105 fontSize: "0.8125rem",
106 color: isActive ? "var(--text-primary)" : "var(--text-muted)",
107 background: isActive ? "var(--bg-hover)" : "transparent",
108 fontWeight: isActive ? 600 : 400,
109 }}
110 >
111 <FileIcon type="file" name={entry.name} size={14} />
112 <span className="truncate">{entry.name}</span>
113 </Link>
114 );
115}
116
117interface FileTreeSidebarProps {
118 owner: string;
119 repo: string;
120 refName: string;
121}
122
123export function FileTreeSidebar({ owner, repo, refName }: FileTreeSidebarProps) {
124 const pathname = usePathname();
125 const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set([""]));
126 const [treeData, setTreeData] = useState<Record<string, TreeEntry[]>>({});
127 const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
128
129 // Derive currentPath from URL
130 const prefix = `/${owner}/${repo}/`;
131 let currentPath = "";
132 if (pathname?.startsWith(prefix)) {
133 const rest = pathname.slice(prefix.length);
134 const match = rest.match(/^(?:blob|tree)\/[^/]+\/(.+)$/);
135 if (match) currentPath = match[1];
136 }
137
138 // Fetch root tree on mount
139 useEffect(() => {
140 repos.tree(owner, repo, refName).then((data) => {
141 setTreeData((prev) => ({ ...prev, "": data.entries as TreeEntry[] }));
142 }).catch(() => {});
143 }, [owner, repo, refName]);
144
145 // Auto-expand ancestor directories when navigating to a new file
146 useEffect(() => {
147 if (!currentPath) return;
148 const parts = currentPath.split("/");
149 const ancestorDirs: string[] = [];
150 for (let i = 0; i < parts.length - 1; i++) {
151 ancestorDirs.push(parts.slice(0, i + 1).join("/"));
152 }
153
154 // Fetch any ancestor dirs we don't have yet, then expand them
155 const missing = ancestorDirs.filter((d) => !treeData[d]);
156 if (missing.length > 0) {
157 Promise.all(
158 missing.map(async (dirPath) => {
159 try {
160 const data = await repos.tree(owner, repo, refName, dirPath);
161 return { dirPath, entries: data.entries as TreeEntry[] };
162 } catch {
163 return null;
164 }
165 })
166 ).then((results) => {
167 const newData: Record<string, TreeEntry[]> = {};
168 for (const r of results) {
169 if (r) newData[r.dirPath] = r.entries;
170 }
171 if (Object.keys(newData).length > 0) {
172 setTreeData((prev) => ({ ...prev, ...newData }));
173 }
174 setExpandedDirs((prev) => {
175 const next = new Set(prev);
176 next.add("");
177 for (const d of ancestorDirs) next.add(d);
178 return next;
179 });
180 });
181 } else {
182 setExpandedDirs((prev) => {
183 const next = new Set(prev);
184 next.add("");
185 for (const d of ancestorDirs) next.add(d);
186 return next;
187 });
188 }
189 }, [currentPath, owner, repo, refName]); // eslint-disable-line react-hooks/exhaustive-deps
190
191 const toggleDir = useCallback(
192 async (dirPath: string) => {
193 if (expandedDirs.has(dirPath)) {
194 setExpandedDirs((prev) => {
195 const next = new Set(prev);
196 next.delete(dirPath);
197 return next;
198 });
199 return;
200 }
201
202 if (!treeData[dirPath]) {
203 setLoadingDirs((prev) => {
204 const next = new Set(prev);
205 next.add(dirPath);
206 return next;
207 });
208 try {
209 const data = await repos.tree(owner, repo, refName, dirPath);
210 setTreeData((prev) => ({ ...prev, [dirPath]: data.entries as TreeEntry[] }));
211 } catch {
212 // silently fail
213 }
214 setLoadingDirs((prev) => {
215 const next = new Set(prev);
216 next.delete(dirPath);
217 return next;
218 });
219 }
220
221 setExpandedDirs((prev) => {
222 const next = new Set(prev);
223 next.add(dirPath);
224 return next;
225 });
226 },
227 [expandedDirs, treeData, owner, repo, refName]
228 );
229
230 const rootEntries = treeData[""] ?? [];
231 const isLoading = rootEntries.length === 0;
232
233 const [width, setWidth] = useState(260);
234 const isDragging = useRef(false);
235 const startX = useRef(0);
236 const startWidth = useRef(0);
237
238 useEffect(() => {
239 const onMouseMove = (e: MouseEvent) => {
240 if (!isDragging.current) return;
241 const newWidth = startWidth.current + (e.clientX - startX.current);
242 setWidth(Math.max(160, Math.min(480, newWidth)));
243 };
244 const onMouseUp = () => {
245 if (isDragging.current) {
246 isDragging.current = false;
247 document.body.style.cursor = "";
248 document.body.style.userSelect = "";
249 }
250 };
251 document.addEventListener("mousemove", onMouseMove);
252 document.addEventListener("mouseup", onMouseUp);
253 return () => {
254 document.removeEventListener("mousemove", onMouseMove);
255 document.removeEventListener("mouseup", onMouseUp);
256 };
257 }, []);
258
259 const onDragStart = useCallback((e: React.MouseEvent) => {
260 isDragging.current = true;
261 startX.current = e.clientX;
262 startWidth.current = width;
263 document.body.style.cursor = "col-resize";
264 document.body.style.userSelect = "none";
265 }, [width]);
266
267 return (
268 <div
269 className="shrink-0 hidden md:flex relative"
270 style={{ width, position: "sticky", top: 0, maxHeight: "calc(100vh - 120px)" }}
271 >
272 <nav
273 className="flex-1 overflow-y-auto py-2"
274 style={{ borderRight: "1px solid var(--border-subtle)" }}
275 >
276 {isLoading ? (
277 <div className="px-2 space-y-1">
278 {[62, 78, 55, 84, 70, 90, 58, 73].map((w, i) => (
279 <div
280 key={i}
281 className="skeleton"
282 style={{
283 height: 20,
284 borderRadius: 4,
285 width: `${w}%`,
286 }}
287 />
288 ))}
289 </div>
290 ) : (
291 sortEntries(rootEntries).map((entry) => (
292 <TreeNode
293 key={entry.name}
294 entry={entry}
295 path={entry.name}
296 depth={0}
297 owner={owner}
298 repo={repo}
299 refName={refName}
300 currentPath={currentPath}
301 expandedDirs={expandedDirs}
302 treeData={treeData}
303 loadingDirs={loadingDirs}
304 onToggle={toggleDir}
305 />
306 ))
307 )}
308 </nav>
309 <div
310 onMouseDown={onDragStart}
311 style={{
312 position: "absolute",
313 right: -2,
314 top: 0,
315 bottom: 0,
316 width: 5,
317 cursor: "col-resize",
318 zIndex: 10,
319 }}
320 />
321 </div>
322 );
323}
324