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