diff --git a/src/main/worktree.ts b/src/main/worktree.ts index 47aadcb2..e0f9b82b 100644 --- a/src/main/worktree.ts +++ b/src/main/worktree.ts @@ -18,6 +18,7 @@ import { detectInProgressOp } from './git-ops-state' import { cachedGitRead } from './git-poll-cache' import { runGitRead, type GitPriority } from './git-limiter' import type { Worktree } from '../shared/state/worktrees' +import { destOf } from '../shared/git-paths' const execFileAsync = promisify(execFile) @@ -693,13 +694,6 @@ function logGitOp( ) } -/** Renamed entries from `git status --porcelain` are stored as - * "old -> new" — match numstat by the destination path. */ -function destOf(p: string): string { - const idx = p.indexOf(' -> ') - return idx >= 0 ? p.slice(idx + 4) : p -} - function applyCounts(file: ChangedFile, counts: NumstatCounts | undefined): void { if (!counts) return file.additions = counts.additions diff --git a/src/renderer/components/AllFilesPanel.tsx b/src/renderer/components/AllFilesPanel.tsx index ae97cf05..1d23f1a2 100644 --- a/src/renderer/components/AllFilesPanel.tsx +++ b/src/renderer/components/AllFilesPanel.tsx @@ -1,63 +1,29 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { RefreshCw, AtSign, Code2, ChevronRight, Folder, FolderOpen, FileText } from 'lucide-react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { RefreshCw, AtSign, Code2, FolderOpen, FileText } from 'lucide-react' import { Tooltip } from './Tooltip' import { RightPanel } from './RightPanel' +import { FileListViewToggle, TreeBranch, fileTreeIndent } from './FileTree' +import { useFileListView } from '../hooks/useFileListView' import { useActiveBackend, useSettings } from '../store' import { useBackend } from '../backend' import { bindingToString, formatBindingGlyphs, resolveHotkeys } from '../hotkeys' import type { ChangedFile } from '../types' +import { buildTree, sortChildren, type TreeNode } from '../file-tree' import { CHANGED_STATUS_COLOR, CHANGED_STATUS_LABEL, useChangedFilesSet, } from '../hooks/useChangedFilesSet' +/** List view renders every row with no virtualization, so cap it. */ +const LIST_VIEW_LIMIT = 1000 + interface AllFilesPanelProps { worktreePath: string | null onOpenFile: (filePath: string) => void onSendToAgent?: (text: string) => void } -interface TreeNode { - name: string - path: string - children?: Map - isFile: boolean -} - -function buildTree(files: string[]): TreeNode { - const root: TreeNode = { name: '', path: '', children: new Map(), isFile: false } - for (const file of files) { - const parts = file.split('/') - let node = root - for (let i = 0; i < parts.length; i++) { - const part = parts[i] - const isLast = i === parts.length - 1 - if (!node.children) node.children = new Map() - let child = node.children.get(part) - if (!child) { - child = { - name: part, - path: parts.slice(0, i + 1).join('/'), - isFile: isLast, - children: isLast ? undefined : new Map() - } - node.children.set(part, child) - } - node = child - } - } - return root -} - -function sortChildren(node: TreeNode): TreeNode[] { - if (!node.children) return [] - return Array.from(node.children.values()).sort((a, b) => { - if (a.isFile !== b.isFile) return a.isFile ? 1 : -1 - return a.name.localeCompare(b.name) - }) -} - /** When the user has a filter typed, surface changed files above * unchanged ones within the filtered list. Stable tiebreak by the * original alphabetical order from sortChildren. */ @@ -98,6 +64,19 @@ export function AllFilesPanel({ }, [settings.hotkeys]) const changed = useChangedFilesSet(worktreePath) + // FileRow is memoized but the parent hands us fresh callbacks on most + // renders; route them through refs so row props stay stable. + const onOpenFileRef = useRef(onOpenFile) + const onSendToAgentRef = useRef(onSendToAgent) + useEffect(() => { + onOpenFileRef.current = onOpenFile + onSendToAgentRef.current = onSendToAgent + }) + const openFile = useCallback((path: string) => onOpenFileRef.current(path), []) + const sendToAgent = useCallback((text: string) => onSendToAgentRef.current?.(text), []) + const rowSendToAgent = onSendToAgent ? sendToAgent : undefined + const [view, toggleView] = useFileListView('all-files', 'tree') + const refresh = useCallback(async () => { if (!worktreePath) return try { @@ -121,16 +100,32 @@ export function AllFilesPanel({ refresh() }, [refresh]) + const sortedFiles = useMemo( + () => (view === 'list' ? [...files].sort((a, b) => a.localeCompare(b)) : files), + [files, view] + ) + const filtered = useMemo(() => { - if (!filter.trim()) return files + if (!filter.trim()) return sortedFiles const q = filter.toLowerCase() - return files.filter((f) => f.toLowerCase().includes(q)) - }, [files, filter]) - - const tree = useMemo(() => buildTree(filtered), [filtered]) + return sortedFiles.filter((f) => f.toLowerCase().includes(q)) + }, [sortedFiles, filter]) const filtering = filter.trim().length > 0 + const tree = useMemo(() => (view === 'tree' ? buildTree(filtered) : null), [filtered, view]) + + // Changed files refresh on nearly every edit; only depend on them while + // filtering, when they reorder the list. + const changedFirstBy = filtering ? changed.byPath : null + const listed = useMemo(() => { + if (view !== 'list') return [] + if (!changedFirstBy || changedFirstBy.size === 0) return filtered.slice(0, LIST_VIEW_LIMIT) + const changedFiles = filtered.filter((f) => changedFirstBy.has(f)) + const rest = filtered.filter((f) => !changedFirstBy.has(f)) + return [...changedFiles, ...rest].slice(0, LIST_VIEW_LIMIT) + }, [filtered, view, changedFirstBy]) + const toggle = useCallback((path: string) => { setExpanded((prev) => { const next = new Set(prev) @@ -142,6 +137,7 @@ export function AllFilesPanel({ const actions = ( <> + {worktreePath && ( + + ) +} + +/** Left padding for a file row at `depth`, aligned past the dir chevron. */ +export function fileTreeIndent(depth: number): number { + return 8 + depth * 12 + 10 +} + +interface TreeBranchProps { + node: TreeNode + depth: number + isOpen: (path: string) => boolean + onToggle: (path: string) => void + renderFile: (node: TreeNode, depth: number) => ReactNode + sortChildren?: (node: TreeNode) => TreeNode[] + hasChangedDescendant?: (path: string) => boolean +} + +export function TreeBranch({ + node, + depth, + isOpen, + onToggle, + renderFile, + sortChildren = sortByName, + hasChangedDescendant +}: TreeBranchProps): JSX.Element { + return ( + <> + {sortChildren(node).map((child) => { + if (child.isFile) { + return {renderFile(child, depth)} + } + const open = isOpen(child.path) + return ( +
+ onToggle(child.path)} + hasChangedDescendant={hasChangedDescendant?.(child.path) ?? false} + /> + {open && ( + + )} +
+ ) + })} + + ) +} + +function DirRow({ + name, + depth, + open, + onToggle, + hasChangedDescendant +}: { + name: string + depth: number + open: boolean + onToggle: () => void + hasChangedDescendant: boolean +}): JSX.Element { + return ( +
+ + {open ? ( + + ) : ( + + )} + {name} + {hasChangedDescendant && ( +
+ ) +} diff --git a/src/renderer/file-tree.test.ts b/src/renderer/file-tree.test.ts new file mode 100644 index 00000000..045714a2 --- /dev/null +++ b/src/renderer/file-tree.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { buildTree, sortChildren, type TreeNode } from './file-tree' + +function shape(node: TreeNode): unknown { + return node.isFile ? node.path : { [node.name]: sortChildren(node).map(shape) } +} + +describe('buildTree', () => { + it('nests files under their directories', () => { + const root = buildTree(['src/a.ts', 'src/lib/b.ts', 'README.md']) + expect(sortChildren(root).map(shape)).toEqual([ + { src: [{ lib: ['src/lib/b.ts'] }, 'src/a.ts'] }, + 'README.md' + ]) + }) + + it('records full paths on dirs', () => { + const root = buildTree(['a/b/c.ts']) + const a = root.children!.get('a/')! + expect(a.path).toBe('a') + expect(a.isFile).toBe(false) + expect(a.children!.get('b/')!.path).toBe('a/b') + }) + + it('keeps a file and a dir that share a name', () => { + for (const files of [['foo', 'foo/bar'], ['foo/bar', 'foo']]) { + const root = buildTree(files) + expect(sortChildren(root).map(shape)).toEqual([{ foo: ['foo/bar'] }, 'foo']) + } + }) + + it('returns an empty root for no files', () => { + expect(sortChildren(buildTree([]))).toEqual([]) + }) +}) + +describe('sortChildren', () => { + it('sorts dirs before files, then alphabetically', () => { + const root = buildTree(['z.ts', 'b/x.ts', 'a.ts', 'a/y.ts']) + expect(sortChildren(root).map((c) => c.name)).toEqual(['a', 'b', 'a.ts', 'z.ts']) + }) + + it('returns nothing for a file node', () => { + const root = buildTree(['a.ts']) + expect(sortChildren(root.children!.get('a.ts')!)).toEqual([]) + }) +}) diff --git a/src/renderer/file-tree.ts b/src/renderer/file-tree.ts new file mode 100644 index 00000000..4187c7ae --- /dev/null +++ b/src/renderer/file-tree.ts @@ -0,0 +1,45 @@ +export type FileListView = 'list' | 'tree' + +export interface TreeNode { + name: string + path: string + /** Keyed by name, with a trailing "/" on dirs. */ + children?: Map + isFile: boolean +} + +/** Dirs get a trailing "/" in their key so a file and a dir can share a + * name — e.g. a deleted file `foo` alongside a new `foo/bar` in a diff. */ +export function buildTree(files: string[]): TreeNode { + const root: TreeNode = { name: '', path: '', children: new Map(), isFile: false } + for (const file of files) { + const parts = file.split('/') + let node = root + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + const isLast = i === parts.length - 1 + if (!node.children) node.children = new Map() + const key = isLast ? part : `${part}/` + let child = node.children.get(key) + if (!child) { + child = { + name: part, + path: parts.slice(0, i + 1).join('/'), + isFile: isLast, + children: isLast ? undefined : new Map() + } + node.children.set(key, child) + } + node = child + } + } + return root +} + +export function sortChildren(node: TreeNode): TreeNode[] { + if (!node.children) return [] + return Array.from(node.children.values()).sort((a, b) => { + if (a.isFile !== b.isFile) return a.isFile ? 1 : -1 + return a.name.localeCompare(b.name) + }) +} diff --git a/src/renderer/hooks/useFileListView.ts b/src/renderer/hooks/useFileListView.ts new file mode 100644 index 00000000..eb968c62 --- /dev/null +++ b/src/renderer/hooks/useFileListView.ts @@ -0,0 +1,29 @@ +import { useCallback, useEffect, useState } from 'react' +import type { FileListView } from '../file-tree' + +const STORAGE_PREFIX = 'file-list-view:' + +/** Per-client list/tree preference for a file list, persisted like + * RightPanel's collapse state. */ +export function useFileListView(id: string, fallback: FileListView): [FileListView, () => void] { + const [view, setView] = useState(() => { + try { + const saved = localStorage.getItem(STORAGE_PREFIX + id) + if (saved === 'list' || saved === 'tree') return saved + } catch { + /* ignore */ + } + return fallback + }) + + useEffect(() => { + try { + localStorage.setItem(STORAGE_PREFIX + id, view) + } catch { + /* ignore */ + } + }, [id, view]) + + const toggle = useCallback(() => setView((v) => (v === 'list' ? 'tree' : 'list')), []) + return [view, toggle] +} diff --git a/src/shared/git-paths.ts b/src/shared/git-paths.ts new file mode 100644 index 00000000..9ed1c67b --- /dev/null +++ b/src/shared/git-paths.ts @@ -0,0 +1,6 @@ +/** Renamed entries from `git status --porcelain` are stored as + * "old -> new"; returns the destination path. */ +export function destOf(p: string): string { + const idx = p.indexOf(' -> ') + return idx >= 0 ? p.slice(idx + 4) : p +}