web/app/components/file-tree-sidebar.tsxblame
View source
4dfd09b1"use client";
4dfd09b2
4dfd09b3import { useState, useCallback, useEffect, useRef } from "react";
4dfd09b4import { usePathname } from "next/navigation";
4dfd09b5import Link from "next/link";
4dfd09b6import { FileIcon } from "@/app/components/file-icon";
4dfd09b7import { repos } from "@/lib/api";
d744b828import { encodePath } from "@/lib/utils";
4dfd09b9
4dfd09b10interface TreeEntry {
4dfd09b11 name: string;
4dfd09b12 type: string;
4dfd09b13}
4dfd09b14
4dfd09b15function sortEntries(entries: TreeEntry[]) {
4dfd09b16 return [...entries].sort((a, b) => {
4dfd09b17 if (a.type === b.type) return a.name.localeCompare(b.name);
4dfd09b18 return a.type === "tree" ? -1 : 1;
4dfd09b19 });
4dfd09b20}
4dfd09b21
4dfd09b22function TreeNode({
4dfd09b23 entry,
4dfd09b24 path,
4dfd09b25 depth,
4dfd09b26 owner,
4dfd09b27 repo,
4dfd09b28 refName,
4dfd09b29 currentPath,
4dfd09b30 expandedDirs,
4dfd09b31 treeData,
4dfd09b32 loadingDirs,
4dfd09b33 onToggle,
4dfd09b34}: {
4dfd09b35 entry: TreeEntry;
4dfd09b36 path: string;
4dfd09b37 depth: number;
4dfd09b38 owner: string;
4dfd09b39 repo: string;
4dfd09b40 refName: string;
4dfd09b41 currentPath: string;
4dfd09b42 expandedDirs: Set<string>;
4dfd09b43 treeData: Record<string, TreeEntry[]>;
4dfd09b44 loadingDirs: Set<string>;
4dfd09b45 onToggle: (dirPath: string) => void;
4dfd09b46}) {
4dfd09b47 const isDir = entry.type === "tree";
4dfd09b48 const isExpanded = expandedDirs.has(path);
4dfd09b49 const isActive = currentPath === path;
4dfd09b50 const isLoading = loadingDirs.has(path);
4dfd09b51
4dfd09b52 if (isDir) {
4dfd09b53 return (
4dfd09b54 <>
4dfd09b55 <button
4dfd09b56 onClick={() => onToggle(path)}
4dfd09b57 className="flex items-center gap-1.5 w-full text-left py-1 pr-3 hover-row"
4dfd09b58 style={{
4dfd09b59 paddingLeft: `${depth * 16 + 8}px`,
4dfd09b60 border: "none",
4dfd09b61 background: isActive ? "var(--bg-hover)" : "transparent",
4dfd09b62 cursor: "pointer",
4dfd09b63 font: "inherit",
4dfd09b64 fontSize: "0.8125rem",
4dfd09b65 color: isActive ? "var(--text-primary)" : "var(--text-muted)",
4dfd09b66 }}
4dfd09b67 >
4dfd09b68 <span
4dfd09b69 className="text-xs"
4dfd09b70 style={{ width: 12, textAlign: "center", color: "var(--text-faint)", flexShrink: 0 }}
4dfd09b71 >
4dfd09b72 {isLoading ? "·" : isExpanded ? "▾" : "▸"}
4dfd09b73 </span>
4dfd09b74 <FileIcon type="tree" name={entry.name} size={14} />
4dfd09b75 <span className="truncate">{entry.name}</span>
4dfd09b76 </button>
4dfd09b77 {isExpanded && treeData[path] && (
4dfd09b78 sortEntries(treeData[path]).map((child) => (
4dfd09b79 <TreeNode
4dfd09b80 key={child.name}
4dfd09b81 entry={child}
4dfd09b82 path={`${path}/${child.name}`}
4dfd09b83 depth={depth + 1}
4dfd09b84 owner={owner}
4dfd09b85 repo={repo}
4dfd09b86 refName={refName}
4dfd09b87 currentPath={currentPath}
4dfd09b88 expandedDirs={expandedDirs}
4dfd09b89 treeData={treeData}
4dfd09b90 loadingDirs={loadingDirs}
4dfd09b91 onToggle={onToggle}
4dfd09b92 />
4dfd09b93 ))
4dfd09b94 )}
4dfd09b95 </>
4dfd09b96 );
4dfd09b97 }
4dfd09b98
4dfd09b99 return (
4dfd09b100 <Link
d744b82101 href={`/${owner}/${repo}/blob/${refName}/${encodePath(path)}`}
4dfd09b102 className="flex items-center gap-1.5 py-1 pr-3 hover-row"
4dfd09b103 style={{
4dfd09b104 paddingLeft: `${depth * 16 + 8 + 12 + 6}px`,
4dfd09b105 fontSize: "0.8125rem",
4dfd09b106 color: isActive ? "var(--text-primary)" : "var(--text-muted)",
4dfd09b107 background: isActive ? "var(--bg-hover)" : "transparent",
4dfd09b108 fontWeight: isActive ? 600 : 400,
4dfd09b109 }}
4dfd09b110 >
4dfd09b111 <FileIcon type="file" name={entry.name} size={14} />
4dfd09b112 <span className="truncate">{entry.name}</span>
4dfd09b113 </Link>
4dfd09b114 );
4dfd09b115}
4dfd09b116
4dfd09b117interface FileTreeSidebarProps {
4dfd09b118 owner: string;
4dfd09b119 repo: string;
4dfd09b120 refName: string;
4dfd09b121}
4dfd09b122
4dfd09b123export function FileTreeSidebar({ owner, repo, refName }: FileTreeSidebarProps) {
4dfd09b124 const pathname = usePathname();
4dfd09b125 const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set([""]));
4dfd09b126 const [treeData, setTreeData] = useState<Record<string, TreeEntry[]>>({});
4dfd09b127 const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
4dfd09b128
4dfd09b129 // Derive currentPath from URL
4dfd09b130 const prefix = `/${owner}/${repo}/`;
4dfd09b131 let currentPath = "";
4dfd09b132 if (pathname?.startsWith(prefix)) {
4dfd09b133 const rest = pathname.slice(prefix.length);
4dfd09b134 const match = rest.match(/^(?:blob|tree)\/[^/]+\/(.+)$/);
4dfd09b135 if (match) currentPath = match[1];
4dfd09b136 }
4dfd09b137
4dfd09b138 // Fetch root tree on mount
4dfd09b139 useEffect(() => {
4dfd09b140 repos.tree(owner, repo, refName).then((data) => {
4dfd09b141 setTreeData((prev) => ({ ...prev, "": data.entries as TreeEntry[] }));
4dfd09b142 }).catch(() => {});
4dfd09b143 }, [owner, repo, refName]);
4dfd09b144
4dfd09b145 // Auto-expand ancestor directories when navigating to a new file
4dfd09b146 useEffect(() => {
4dfd09b147 if (!currentPath) return;
4dfd09b148 const parts = currentPath.split("/");
4dfd09b149 const ancestorDirs: string[] = [];
4dfd09b150 for (let i = 0; i < parts.length - 1; i++) {
4dfd09b151 ancestorDirs.push(parts.slice(0, i + 1).join("/"));
4dfd09b152 }
4dfd09b153
4dfd09b154 // Fetch any ancestor dirs we don't have yet, then expand them
4dfd09b155 const missing = ancestorDirs.filter((d) => !treeData[d]);
4dfd09b156 if (missing.length > 0) {
4dfd09b157 Promise.all(
4dfd09b158 missing.map(async (dirPath) => {
4dfd09b159 try {
4dfd09b160 const data = await repos.tree(owner, repo, refName, dirPath);
4dfd09b161 return { dirPath, entries: data.entries as TreeEntry[] };
4dfd09b162 } catch {
4dfd09b163 return null;
4dfd09b164 }
4dfd09b165 })
4dfd09b166 ).then((results) => {
4dfd09b167 const newData: Record<string, TreeEntry[]> = {};
4dfd09b168 for (const r of results) {
4dfd09b169 if (r) newData[r.dirPath] = r.entries;
4dfd09b170 }
4dfd09b171 if (Object.keys(newData).length > 0) {
4dfd09b172 setTreeData((prev) => ({ ...prev, ...newData }));
4dfd09b173 }
4dfd09b174 setExpandedDirs((prev) => {
4dfd09b175 const next = new Set(prev);
4dfd09b176 next.add("");
4dfd09b177 for (const d of ancestorDirs) next.add(d);
4dfd09b178 return next;
4dfd09b179 });
4dfd09b180 });
4dfd09b181 } else {
4dfd09b182 setExpandedDirs((prev) => {
4dfd09b183 const next = new Set(prev);
4dfd09b184 next.add("");
4dfd09b185 for (const d of ancestorDirs) next.add(d);
4dfd09b186 return next;
4dfd09b187 });
4dfd09b188 }
4dfd09b189 }, [currentPath, owner, repo, refName]); // eslint-disable-line react-hooks/exhaustive-deps
4dfd09b190
4dfd09b191 const toggleDir = useCallback(
4dfd09b192 async (dirPath: string) => {
4dfd09b193 if (expandedDirs.has(dirPath)) {
4dfd09b194 setExpandedDirs((prev) => {
4dfd09b195 const next = new Set(prev);
4dfd09b196 next.delete(dirPath);
4dfd09b197 return next;
4dfd09b198 });
4dfd09b199 return;
4dfd09b200 }
4dfd09b201
4dfd09b202 if (!treeData[dirPath]) {
4dfd09b203 setLoadingDirs((prev) => {
4dfd09b204 const next = new Set(prev);
4dfd09b205 next.add(dirPath);
4dfd09b206 return next;
4dfd09b207 });
4dfd09b208 try {
4dfd09b209 const data = await repos.tree(owner, repo, refName, dirPath);
4dfd09b210 setTreeData((prev) => ({ ...prev, [dirPath]: data.entries as TreeEntry[] }));
4dfd09b211 } catch {
4dfd09b212 // silently fail
4dfd09b213 }
4dfd09b214 setLoadingDirs((prev) => {
4dfd09b215 const next = new Set(prev);
4dfd09b216 next.delete(dirPath);
4dfd09b217 return next;
4dfd09b218 });
4dfd09b219 }
4dfd09b220
4dfd09b221 setExpandedDirs((prev) => {
4dfd09b222 const next = new Set(prev);
4dfd09b223 next.add(dirPath);
4dfd09b224 return next;
4dfd09b225 });
4dfd09b226 },
4dfd09b227 [expandedDirs, treeData, owner, repo, refName]
4dfd09b228 );
4dfd09b229
4dfd09b230 const rootEntries = treeData[""] ?? [];
4dfd09b231 const isLoading = rootEntries.length === 0;
4dfd09b232
4dfd09b233 const [width, setWidth] = useState(260);
4dfd09b234 const isDragging = useRef(false);
4dfd09b235 const startX = useRef(0);
4dfd09b236 const startWidth = useRef(0);
4dfd09b237
4dfd09b238 useEffect(() => {
4dfd09b239 const onMouseMove = (e: MouseEvent) => {
4dfd09b240 if (!isDragging.current) return;
4dfd09b241 const newWidth = startWidth.current + (e.clientX - startX.current);
4dfd09b242 setWidth(Math.max(160, Math.min(480, newWidth)));
4dfd09b243 };
4dfd09b244 const onMouseUp = () => {
4dfd09b245 if (isDragging.current) {
4dfd09b246 isDragging.current = false;
4dfd09b247 document.body.style.cursor = "";
4dfd09b248 document.body.style.userSelect = "";
4dfd09b249 }
4dfd09b250 };
4dfd09b251 document.addEventListener("mousemove", onMouseMove);
4dfd09b252 document.addEventListener("mouseup", onMouseUp);
4dfd09b253 return () => {
4dfd09b254 document.removeEventListener("mousemove", onMouseMove);
4dfd09b255 document.removeEventListener("mouseup", onMouseUp);
4dfd09b256 };
4dfd09b257 }, []);
4dfd09b258
4dfd09b259 const onDragStart = useCallback((e: React.MouseEvent) => {
4dfd09b260 isDragging.current = true;
4dfd09b261 startX.current = e.clientX;
4dfd09b262 startWidth.current = width;
4dfd09b263 document.body.style.cursor = "col-resize";
4dfd09b264 document.body.style.userSelect = "none";
4dfd09b265 }, [width]);
4dfd09b266
4dfd09b267 return (
4dfd09b268 <div
4dfd09b269 className="shrink-0 hidden md:flex relative"
4dfd09b270 style={{ width, position: "sticky", top: 0, maxHeight: "calc(100vh - 120px)" }}
4dfd09b271 >
4dfd09b272 <nav
4dfd09b273 className="flex-1 overflow-y-auto py-2"
4dfd09b274 style={{ borderRight: "1px solid var(--border-subtle)" }}
4dfd09b275 >
4dfd09b276 {isLoading ? (
4dfd09b277 <div className="px-2 space-y-1">
d744b82278 {[62, 78, 55, 84, 70, 90, 58, 73].map((w, i) => (
4dfd09b279 <div
4dfd09b280 key={i}
4dfd09b281 className="skeleton"
4dfd09b282 style={{
4dfd09b283 height: 20,
4dfd09b284 borderRadius: 4,
d744b82285 width: `${w}%`,
4dfd09b286 }}
4dfd09b287 />
4dfd09b288 ))}
4dfd09b289 </div>
4dfd09b290 ) : (
4dfd09b291 sortEntries(rootEntries).map((entry) => (
4dfd09b292 <TreeNode
4dfd09b293 key={entry.name}
4dfd09b294 entry={entry}
4dfd09b295 path={entry.name}
4dfd09b296 depth={0}
4dfd09b297 owner={owner}
4dfd09b298 repo={repo}
4dfd09b299 refName={refName}
4dfd09b300 currentPath={currentPath}
4dfd09b301 expandedDirs={expandedDirs}
4dfd09b302 treeData={treeData}
4dfd09b303 loadingDirs={loadingDirs}
4dfd09b304 onToggle={toggleDir}
4dfd09b305 />
4dfd09b306 ))
4dfd09b307 )}
4dfd09b308 </nav>
4dfd09b309 <div
4dfd09b310 onMouseDown={onDragStart}
4dfd09b311 style={{
4dfd09b312 position: "absolute",
4dfd09b313 right: -2,
4dfd09b314 top: 0,
4dfd09b315 bottom: 0,
4dfd09b316 width: 5,
4dfd09b317 cursor: "col-resize",
4dfd09b318 zIndex: 10,
4dfd09b319 }}
4dfd09b320 />
4dfd09b321 </div>
4dfd09b322 );
4dfd09b323}