diff --git a/src/components/ChangedFilesList.tsx b/src/components/ChangedFilesList.tsx index 31b8bb59..ec04e0b3 100644 --- a/src/components/ChangedFilesList.tsx +++ b/src/components/ChangedFilesList.tsx @@ -1,9 +1,10 @@ -import { createSignal, createMemo, createEffect, onCleanup, For, Show } from 'solid-js'; +import { createSignal, createMemo, createEffect, onCleanup, batch, Index, Show } from 'solid-js'; import { invoke } from '../lib/ipc'; import { IPC } from '../../electron/ipc/channels'; import { theme } from '../lib/theme'; import { sf } from '../lib/fontScale'; import { getStatusColor } from '../lib/status-colors'; +import { buildFileTree, flattenVisibleTree } from '../lib/file-tree'; import type { ChangedFile } from '../ipc/types'; interface ChangedFilesListProps { @@ -22,36 +23,104 @@ interface ChangedFilesListProps { export function ChangedFilesList(props: ChangedFilesListProps) { const [files, setFiles] = createSignal([]); const [selectedIndex, setSelectedIndex] = createSignal(-1); + const [collapsed, setCollapsed] = createSignal>(new Set()); const rowRefs: HTMLDivElement[] = []; + const tree = createMemo(() => buildFileTree(files())); + const visibleRows = createMemo(() => flattenVisibleTree(tree(), collapsed())); + + function toggleDir(path: string) { + const isCollapsing = !collapsed().has(path); + const rows = visibleRows(); + const dirIdx = isCollapsing ? rows.findIndex((r) => r.node.path === path) : -1; + + batch(() => { + // When collapsing, snap selection to the directory if selected item is a child + if (dirIdx >= 0) { + const dirDepth = rows[dirIdx].depth; + const sel = selectedIndex(); + let subtreeEnd = rows.length; + for (let j = dirIdx + 1; j < rows.length; j++) { + if (rows[j].depth <= dirDepth) { + subtreeEnd = j; + break; + } + } + if (sel > dirIdx && sel < subtreeEnd) { + setSelectedIndex(dirIdx); + } + } + + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }); + } + // Scroll selected item into view reactively createEffect(() => { const idx = selectedIndex(); if (idx >= 0) rowRefs[idx]?.scrollIntoView({ block: 'nearest', behavior: 'instant' }); }); - // Trim stale refs and clamp selection when file list shrinks + // Trim stale refs and clamp selection when visible rows change createEffect(() => { - const len = files().length; + const len = visibleRows().length; rowRefs.length = len; setSelectedIndex((i) => (i >= len ? len - 1 : i)); }); function handleKeyDown(e: KeyboardEvent) { - const list = files(); - if (list.length === 0) return; + const rows = visibleRows(); + if (rows.length === 0) return; + const idx = selectedIndex(); if (e.key === 'ArrowDown') { e.preventDefault(); - setSelectedIndex((i) => Math.min(list.length - 1, i + 1)); + setSelectedIndex((i) => Math.min(rows.length - 1, i + 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((i) => Math.max(0, i - 1)); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + if (idx >= 0 && idx < rows.length) { + const row = rows[idx]; + if (row.isDir && collapsed().has(row.node.path)) { + toggleDir(row.node.path); + } else if (row.isDir && idx + 1 < rows.length) { + // Already expanded — move to first child + setSelectedIndex(idx + 1); + } + } + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + if (idx >= 0 && idx < rows.length) { + const row = rows[idx]; + if (row.isDir && !collapsed().has(row.node.path)) { + // Collapse this directory + toggleDir(row.node.path); + } else if (row.depth > 0) { + // Move to parent directory + for (let j = idx - 1; j >= 0; j--) { + if (rows[j].isDir && rows[j].depth === row.depth - 1) { + setSelectedIndex(j); + break; + } + } + } + } } else if (e.key === 'Enter') { e.preventDefault(); - const idx = selectedIndex(); - if (idx >= 0 && idx < list.length) { - props.onFileClick?.(list[idx]); + if (idx >= 0 && idx < rows.length) { + const row = rows[idx]; + if (row.isDir) { + toggleDir(row.node.path); + } else if (row.node.file) { + props.onFileClick?.(row.node.file); + } } } } @@ -119,45 +188,6 @@ export function ChangedFilesList(props: ChangedFilesListProps) { const totalRemoved = createMemo(() => files().reduce((s, f) => s + f.lines_removed, 0)); const uncommittedCount = createMemo(() => files().filter((f) => !f.committed).length); - /** For each file, compute the display filename and an optional disambiguating directory. */ - const fileDisplays = createMemo(() => { - const list = files(); - - // Count how many times each filename appears - const nameCounts = new Map(); - const parsed = list.map((f) => { - const sep = f.path.lastIndexOf('/'); - const name = sep >= 0 ? f.path.slice(sep + 1) : f.path; - const dir = sep >= 0 ? f.path.slice(0, sep) : ''; - nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1); - return { name, dir, fullPath: f.path }; - }); - - // For duplicates, find the shortest disambiguating parent suffix - return parsed.map((p) => { - if ((nameCounts.get(p.name) ?? 0) <= 1 || !p.dir) { - return { name: p.name, disambig: '', fullPath: p.fullPath }; - } - // Find all entries with the same filename - const siblings = parsed.filter((s) => s.name === p.name && s.fullPath !== p.fullPath); - const parts = p.dir.split('/'); - // Walk from the immediate parent upward until unique - for (let depth = 1; depth <= parts.length; depth++) { - const suffix = parts.slice(parts.length - depth).join('/'); - const isUnique = siblings.every((s) => { - const sParts = s.dir.split('/'); - const sSuffix = sParts.slice(sParts.length - depth).join('/'); - return sSuffix !== suffix; - }); - if (isUnique) { - return { name: p.name, disambig: suffix + '/', fullPath: p.fullPath }; - } - } - // Fallback: show full directory - return { name: p.name, disambig: p.dir + '/', fullPath: p.fullPath }; - }); - }); - return (
- - {(file, i) => ( + + {(row, i) => (
(rowRefs[i()] = el)} + ref={(el) => (rowRefs[i] = el)} class="file-row" style={{ display: 'flex', 'align-items': 'center', gap: '6px', padding: '2px 8px', + 'padding-left': `${8 + row().depth * 10}px`, 'white-space': 'nowrap', - cursor: props.onFileClick ? 'pointer' : 'default', + cursor: 'pointer', 'border-radius': '3px', - opacity: file.committed ? '0.45' : '1', - background: selectedIndex() === i() ? theme.bgHover : 'transparent', + opacity: !row().isDir && row().node.file?.committed ? '0.45' : '1', + background: selectedIndex() === i ? theme.bgHover : 'transparent', }} onClick={() => { - setSelectedIndex(i()); - props.onFileClick?.(file); + setSelectedIndex(i); + const r = row(); + if (r.isDir) { + toggleDir(r.node.path); + } else if (r.node.file) { + props.onFileClick?.(r.node.file); + } }} > - - {file.status} - - - {fileDisplays()[i()].name} - - - {fileDisplays()[i()].disambig} + {row().isDir ? ( + <> + + {collapsed().has(row().node.path) ? '\u25B8' : '\u25BE'} + + + {row().node.name}/ + + + {row().node.fileCount} + + 0 || row().node.linesRemoved > 0}> + + +{row().node.linesAdded} + + + -{row().node.linesRemoved} + + + + ) : ( + <> + + {row().node.file?.status} + + + {row().node.name} - - - 0 || file.lines_removed > 0}> - - +{file.lines_added} - - - -{file.lines_removed} - - + 0 || + (row().node.file?.lines_removed ?? 0) > 0 + } + > + + +{row().node.file?.lines_added} + + + -{row().node.file?.lines_removed} + + + + )}
)} -
+
0}>
; + file?: ChangedFile; + } + + const root: RawNode = { children: new Map() }; + + for (const file of files) { + const parts = file.path.split('/'); + let current = root; + for (let i = 0; i < parts.length - 1; i++) { + let next = current.children.get(parts[i]); + if (!next) { + next = { children: new Map() }; + current.children.set(parts[i], next); + } + current = next; + } + const fileName = parts[parts.length - 1]; + current.children.set(fileName, { children: new Map(), file }); + } + + function convert(node: RawNode, parentPath: string): FileTreeNode[] { + const entries = [...node.children.entries()].sort(([aName, a], [bName, b]) => { + const aIsDir = !a.file; + const bIsDir = !b.file; + if (aIsDir !== bIsDir) return aIsDir ? -1 : 1; + return aName.localeCompare(bName); + }); + + const result: FileTreeNode[] = []; + + for (const [name, child] of entries) { + const fullPath = parentPath ? `${parentPath}/${name}` : name; + + if (child.file) { + result.push({ + name, + path: fullPath, + children: [], + file: child.file, + linesAdded: child.file.lines_added, + linesRemoved: child.file.lines_removed, + fileCount: 1, + }); + } else { + // Compact single-child directory chains + let cur = child; + let compactedName = name; + let compactedPath = fullPath; + + while (cur.children.size === 1) { + const entry = cur.children.entries().next(); + if (entry.done) break; + const [onlyName, onlyChild] = entry.value; + if (onlyChild.file) break; // stop before a file leaf + compactedName += '/' + onlyName; + compactedPath += '/' + onlyName; + cur = onlyChild; + } + + const children = convert(cur, compactedPath); + let linesAdded = 0, + linesRemoved = 0, + fileCount = 0; + for (const c of children) { + linesAdded += c.linesAdded; + linesRemoved += c.linesRemoved; + fileCount += c.fileCount; + } + result.push({ + name: compactedName, + path: compactedPath, + children, + linesAdded, + linesRemoved, + fileCount, + }); + } + } + + return result; + } + + return convert(root, ''); +} + +/** + * Flatten the tree into a list of visible rows based on which directories are collapsed. + * Directories not in `collapsed` are expanded (i.e. default = all expanded). + */ +export function flattenVisibleTree( + nodes: FileTreeNode[], + collapsed: Set, + depth = 0, +): FlatTreeRow[] { + const result: FlatTreeRow[] = []; + for (const node of nodes) { + const isDir = node.children.length > 0; + result.push({ node, depth, isDir }); + if (isDir && !collapsed.has(node.path)) { + for (const row of flattenVisibleTree(node.children, collapsed, depth + 1)) { + result.push(row); + } + } + } + return result; +}