| 1 | "use client"; |
| 2 | |
| 3 | import { useState, useCallback, useEffect, useRef } from "react"; |
| 4 | import { usePathname } from "next/navigation"; |
| 5 | import Link from "next/link"; |
| 6 | import { FileIcon } from "@/app/components/file-icon"; |
| 7 | import { repos } from "@/lib/api"; |
| 8 | import { encodePath } from "@/lib/utils"; |
| 9 | |
| 10 | interface TreeEntry { |
| 11 | name: string; |
| 12 | type: string; |
| 13 | } |
| 14 | |
| 15 | function 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 | |
| 22 | function 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 | |
| 117 | interface FileTreeSidebarProps { |
| 118 | owner: string; |
| 119 | repo: string; |
| 120 | refName: string; |
| 121 | } |
| 122 | |
| 123 | export 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 | |