Implement diff tab file tree from design doc

This commit is contained in:
Jinjing
2026-05-16 16:42:53 -07:00
parent 52e737e7db
commit 55bef8785a
7 changed files with 761 additions and 50 deletions
+102
View File
@@ -0,0 +1,102 @@
# Diff Tab File Tree
## Problem
Source Control has the only file-tree navigation for combined diffs today.
`SourceControl.tsx` builds and renders branch tree rows (`treeRootsByArea`/`visibleBranchTreeRows`, "Committed on Branch", "View all" at `openBranchAllDiffs`).
`CombinedDiffViewer` owns:
- entry selection (`entries` from snapshot/live fallback)
- section identity (`section.key`)
- virtualized rendering + incremental runtime diff loading (`getRuntimeGitDiff`, `getRuntimeGitBranchDiff`, `getRuntimeGitCommitDiff`)
It currently has no in-tab tree, so navigation in combined tabs is linear.
## Goal
Add a left file tree inside `CombinedDiffViewer` for combined uncommitted/branch/commit tabs.
Tree clicks must navigate existing virtualized sections and use existing section loading only.
## Non-goals
- Source Control list/tree redesign
- New git/diff backend APIs
- Replacing combined diff virtualization/scheduler/cache
- Persisting tree width/collapse state
## Design
1. Build tree from `entries` already chosen by `CombinedDiffViewer`.
- Do not read alternative store slices for tree data.
- Tree content must match section content exactly for that tab instance.
2. Reuse existing tree builders.
- Uncommitted: `buildGitStatusSourceControlTree` + `compactSourceControlTree` + `flattenSourceControlTree`
- Branch/commit: `buildSourceControlTree` + `compactSourceControlTree` + `flattenSourceControlTree`
3. Do not translate via tree node keys.
- Tree file keys are `${area}::${entry.path}` (`entry.path`, not normalized node path).
- Section keys are `${prefix}:${entry.path}` where prefix is:
- uncommitted: `entry.area`
- branch: `combined-branch`
- commit: `combined-commit`
- Build navigation target from `node.entry`, not by rewriting `node.key`.
4. Click behavior.
- Resolve section index from `section.key -> index` map rebuilt on `sections` change.
- If target section is collapsed, expand via existing `toggleSection(index)` (this already queues load on expand).
- Scroll with `virtualizer.scrollToIndex(index, { align: 'start' })`.
- Do not call loaders directly with new codepaths.
5. Layout.
- Render tree + diff in a horizontal split under existing toolbar/header area.
- Keep diff pane as the only vertical scroll container used by virtualizer.
- Follow `docs/STYLEGUIDE.md` tokens/primitives only.
## Correctness Constraints
- Preserve empty/error/conflict-skipped states and existing toolbar actions.
- Preserve runtime/SSH behavior by keeping current loader calls and `connectionId` wiring untouched.
- Preserve section cache semantics (`entrySignature`, `generationRef`, module-level caches).
## Invalidation and Concurrency
- Recompute section lookup map whenever `sections` changes.
- `toggleSection` mutates collapse state asynchronously; navigation should tolerate a short stale frame between expand and scroll.
- Rely on existing generation guard (`generationRef`) for stale async loads when entries change mid-flight.
- Keep tree collapse UI state local to the combined tab; do not couple to Source Control’s `collapsedTreeDirs` state.
- Combined diff caches are renderer-process local; no cross-window synchronization.
## Important Existing Behavior to Keep (and Document)
- Uncommitted tabs use `uncommittedEntriesSnapshot` when present; otherwise `getCombinedUncommittedEntries(...)` fallback.
- Commit tabs are snapshot-only (`commitEntriesSnapshot ?? []`).
- Branch tabs currently use `branchEntriesSnapshot` only when snapshot length is `> 0`, otherwise they fall back to live branch entries.
That last branch rule is a real consistency edge case: a tab opened with an empty snapshot can later show newly refreshed live entries. If stability is required, this should be fixed in implementation (use `undefined` vs defined-empty as the fallback gate), not papered over in tree code.
## Edge Cases
- Same path across staged/unstaged/untracked: keep area-specific section identity.
- Renames: navigate by current `path`; preserve `oldPath` for loader/open actions.
- Click during entry-set replacement: no-op safely if section index/key disappears.
- Tabs created before snapshot fields existed: tree must mirror current fallback filtering (including unresolved conflict exclusion).
## Feasibility
- There is no "load all rendered sections in one call" API in current runtime diff APIs.
- Tree navigation is a section jump aid over existing lazy per-section loading.
- Reusing Source Control tree builders is feasible; selection/open state must remain local to combined diff viewer.
## Rollout
1. Add `CombinedDiffFileTree` in `src/renderer/src/components/editor/`.
2. Feed it the already-resolved `entries` from `CombinedDiffViewer`.
3. Add mode-aware section-key mapping from row entry metadata.
4. Wire row click to `toggleSection` (if needed) + `scrollToIndex`.
5. Add focused tests for:
- area-disambiguated same-path uncommitted entries
- branch/commit key mapping
- collapsed target navigation
- branch empty-snapshot fallback behavior (documented current behavior or fixed behavior, whichever is chosen)
6. Run renderer typecheck/lint/tests.
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest'
import {
createCombinedDiffSectionIndexMap,
getCombinedDiffFileTreeNavigationIndex,
getCombinedDiffFileTreeSectionKey,
handleCombinedDiffFileTreeNavigation
} from './CombinedDiffFileTree'
import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types'
describe('CombinedDiffFileTree navigation mapping', () => {
it('disambiguates uncommitted entries with the same path by area', () => {
const staged: GitStatusEntry = { path: 'src/App.tsx', status: 'modified', area: 'staged' }
const unstaged: GitStatusEntry = { path: 'src/App.tsx', status: 'modified', area: 'unstaged' }
const sectionIndexByKey = createCombinedDiffSectionIndexMap([
{ key: 'unstaged:src/App.tsx' },
{ key: 'staged:src/App.tsx' }
])
expect(getCombinedDiffFileTreeSectionKey('uncommitted', unstaged)).toBe('unstaged:src/App.tsx')
expect(
getCombinedDiffFileTreeNavigationIndex({
mode: 'uncommitted',
entry: unstaged,
sectionIndexByKey
})
).toBe(0)
expect(
getCombinedDiffFileTreeNavigationIndex({
mode: 'uncommitted',
entry: staged,
sectionIndexByKey
})
).toBe(1)
})
it('maps branch and commit entries to combined section prefixes', () => {
const entry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'renamed' }
expect(getCombinedDiffFileTreeSectionKey('branch', entry)).toBe('combined-branch:src/view.ts')
expect(getCombinedDiffFileTreeSectionKey('commit', entry)).toBe('combined-commit:src/view.ts')
})
it('expands a collapsed target section and scrolls to its index', () => {
const entry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'modified' }
const toggleSection = vi.fn()
const scrollToIndex = vi.fn()
const index = handleCombinedDiffFileTreeNavigation({
mode: 'branch',
entry,
sections: [{ collapsed: false }, { collapsed: true }],
sectionIndexByKey: createCombinedDiffSectionIndexMap([
{ key: 'combined-branch:src/other.ts' },
{ key: 'combined-branch:src/view.ts' }
]),
toggleSection,
scrollToIndex
})
expect(index).toBe(1)
expect(toggleSection).toHaveBeenCalledWith(1)
expect(scrollToIndex).toHaveBeenCalledWith(1)
})
})
@@ -0,0 +1,372 @@
import React from 'react'
import { Check, ChevronDown, Filter, Folder, FolderOpen, Search } from 'lucide-react'
import { basename, dirname } from '@/lib/path'
import { cn } from '@/lib/utils'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
buildGitStatusSourceControlTree,
buildSourceControlTree,
compactSourceControlTree,
flattenSourceControlTree,
type SourceControlTreeNode
} from '@/components/right-sidebar/source-control-tree'
import { STATUS_COLORS, STATUS_LABELS } from '@/components/right-sidebar/status-display'
import type {
GitBranchChangeEntry,
GitFileStatus,
GitStagingArea,
GitStatusEntry
} from '../../../../shared/types'
import {
getCombinedDiffFileTreeSectionKey,
getEntryExtension,
getFilteredCombinedDiffFileTreeEntries,
isGitStatusEntry,
type CombinedDiffBranchTreeArea,
type CombinedDiffFileTreeEntry,
type CombinedDiffFileTreeMode
} from './combined-diff-file-tree-model'
export {
createCombinedDiffSectionIndexMap,
getCombinedDiffFileTreeNavigationIndex,
getCombinedDiffFileTreeSectionKey,
handleCombinedDiffFileTreeNavigation
} from './combined-diff-file-tree-model'
type CombinedDiffTreeNode = SourceControlTreeNode<
GitStatusEntry | GitBranchChangeEntry,
GitStagingArea | CombinedDiffBranchTreeArea
>
const COMBINED_DIFF_TREE_INDENT_PX = 12
const COMBINED_DIFF_TREE_DIRECTORY_PADDING_PX = 8
const COMBINED_DIFF_TREE_FILE_PADDING_PX = 20
const UNCOMMITTED_AREA_ORDER: readonly GitStagingArea[] = ['unstaged', 'staged', 'untracked']
const UNCOMMITTED_AREA_LABELS: Record<GitStagingArea, string> = {
unstaged: 'Changes',
staged: 'Staged Changes',
untracked: 'Untracked Files'
}
function buildUncommittedRows(
entries: readonly CombinedDiffFileTreeEntry[],
collapsedDirectoryKeys: ReadonlySet<string>
): { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] }[] {
return UNCOMMITTED_AREA_ORDER.map((area) => {
const areaEntries = entries.filter(
(entry): entry is GitStatusEntry => isGitStatusEntry(entry) && entry.area === area
)
if (areaEntries.length === 0) {
return null
}
const roots = compactSourceControlTree(buildGitStatusSourceControlTree(area, areaEntries))
return {
area,
label: UNCOMMITTED_AREA_LABELS[area],
rows: flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[]
}
}).filter(
(group): group is { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] } =>
Boolean(group)
)
}
function buildBranchRows(
mode: Extract<CombinedDiffFileTreeMode, 'branch' | 'commit'>,
entries: readonly CombinedDiffFileTreeEntry[],
collapsedDirectoryKeys: ReadonlySet<string>
): CombinedDiffTreeNode[] {
const branchEntries = entries.filter(
(entry): entry is GitBranchChangeEntry => !isGitStatusEntry(entry)
)
const area: CombinedDiffBranchTreeArea = mode === 'branch' ? 'combined-branch' : 'combined-commit'
const roots = compactSourceControlTree(buildSourceControlTree(area, branchEntries))
return flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[]
}
export function CombinedDiffFileTree({
mode,
entries,
sectionIndexByKey,
activeSectionKey,
viewedSectionKeys,
onNavigate
}: {
mode: CombinedDiffFileTreeMode
entries: readonly CombinedDiffFileTreeEntry[]
sectionIndexByKey: ReadonlyMap<string, number>
activeSectionKey: string | null
viewedSectionKeys: ReadonlySet<string>
onNavigate: (entry: CombinedDiffFileTreeEntry) => void
}): React.JSX.Element {
const [collapsedDirectoryKeys, setCollapsedDirectoryKeys] = React.useState<Set<string>>(
() => new Set()
)
const [query, setQuery] = React.useState('')
const [excludedExtensions, setExcludedExtensions] = React.useState<Set<string>>(() => new Set())
const [includeViewed, setIncludeViewed] = React.useState(true)
const toggleDirectory = React.useCallback((key: string) => {
setCollapsedDirectoryKeys((prev) => {
const next = new Set(prev)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}, [])
const availableExtensions = React.useMemo(
() => Array.from(new Set(entries.map(getEntryExtension))).sort(),
[entries]
)
const filteredEntries = React.useMemo(
() =>
getFilteredCombinedDiffFileTreeEntries({
entries,
mode,
query,
excludedExtensions,
includeViewed,
viewedSectionKeys
}),
[entries, excludedExtensions, includeViewed, mode, query, viewedSectionKeys]
)
const toggleExtension = React.useCallback((extension: string) => {
setExcludedExtensions((prev) => {
const next = new Set(prev)
if (next.has(extension)) {
next.delete(extension)
} else {
next.add(extension)
}
return next
})
}, [])
const resetFilters = React.useCallback(() => {
setQuery('')
setExcludedExtensions(new Set())
setIncludeViewed(true)
}, [])
const activeFilterCount =
excludedExtensions.size + (includeViewed ? 0 : 1) + (query.trim().length > 0 ? 1 : 0)
const uncommittedGroups = React.useMemo(
() =>
mode === 'uncommitted' ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys) : [],
[collapsedDirectoryKeys, filteredEntries, mode]
)
const branchRows = React.useMemo(
() =>
mode === 'branch' || mode === 'commit'
? buildBranchRows(mode, filteredEntries, collapsedDirectoryKeys)
: [],
[collapsedDirectoryKeys, filteredEntries, mode]
)
return (
<aside className="flex w-64 shrink-0 flex-col border-r border-border bg-background">
<div className="border-b border-border px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
Files
</div>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-2 py-2">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Filter files..."
className="h-8 pl-7 text-xs"
/>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Filter diff files"
className={cn(activeFilterCount > 0 && 'border-foreground/30 text-foreground')}
>
<Filter className="size-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" side="bottom" sideOffset={6} className="w-56 p-0">
<div className="border-b border-border px-3 py-2 text-xs font-semibold text-foreground">
File extensions
</div>
<div className="max-h-60 overflow-auto py-1 scrollbar-sleek">
{availableExtensions.map((extension) => {
const checked = !excludedExtensions.has(extension)
return (
<button
key={extension}
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={() => toggleExtension(extension)}
>
<Check
className={cn('size-3.5 shrink-0', checked ? 'opacity-100' : 'opacity-0')}
/>
<span className="min-w-0 flex-1 truncate">{extension}</span>
</button>
)
})}
</div>
<div className="border-t border-border py-1">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={() => setIncludeViewed((prev) => !prev)}
>
<Check
className={cn('size-3.5 shrink-0', includeViewed ? 'opacity-100' : 'opacity-0')}
/>
<span className="min-w-0 flex-1 truncate">Viewed files</span>
</button>
{activeFilterCount > 0 && (
<button
type="button"
className="w-full px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={resetFilters}
>
Reset filters
</button>
)}
</div>
</PopoverContent>
</Popover>
</div>
<div className="min-h-0 flex-1 overflow-auto py-1 scrollbar-sleek">
{filteredEntries.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
No files match the current filters.
</div>
) : mode === 'uncommitted' ? (
uncommittedGroups.map((group) => (
<div key={group.area} className="py-1">
<div className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{group.label}
</div>
{group.rows.map((node) => (
<CombinedDiffFileTreeRow
key={node.key}
node={node}
mode={mode}
activeSectionKey={activeSectionKey}
sectionIndexByKey={sectionIndexByKey}
isCollapsed={collapsedDirectoryKeys.has(node.key)}
onToggleDirectory={toggleDirectory}
onNavigate={onNavigate}
/>
))}
</div>
))
) : (
branchRows.map((node) => (
<CombinedDiffFileTreeRow
key={node.key}
node={node}
mode={mode}
activeSectionKey={activeSectionKey}
sectionIndexByKey={sectionIndexByKey}
isCollapsed={collapsedDirectoryKeys.has(node.key)}
onToggleDirectory={toggleDirectory}
onNavigate={onNavigate}
/>
))
)}
</div>
</aside>
)
}
function CombinedDiffFileTreeRow({
node,
mode,
activeSectionKey,
sectionIndexByKey,
isCollapsed,
onToggleDirectory,
onNavigate
}: {
node: CombinedDiffTreeNode
mode: CombinedDiffFileTreeMode
activeSectionKey: string | null
sectionIndexByKey: ReadonlyMap<string, number>
isCollapsed: boolean
onToggleDirectory: (key: string) => void
onNavigate: (entry: CombinedDiffFileTreeEntry) => void
}): React.JSX.Element {
if (node.type === 'directory') {
return (
<div
className="group relative flex w-full items-center gap-1 py-1 pr-3 text-xs text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
style={{
paddingLeft: `${node.depth * COMBINED_DIFF_TREE_INDENT_PX + COMBINED_DIFF_TREE_DIRECTORY_PADDING_PX}px`
}}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-1 text-left"
onClick={() => onToggleDirectory(node.key)}
aria-expanded={!isCollapsed}
>
<ChevronDown
className={cn('size-3 shrink-0 transition-transform', isCollapsed && '-rotate-90')}
/>
{isCollapsed ? (
<Folder className="size-3 shrink-0" />
) : (
<FolderOpen className="size-3 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate">{node.name}</span>
</button>
<span className="w-4 shrink-0 text-center text-[10px] font-bold tabular-nums text-muted-foreground/80">
{node.fileCount}
</span>
</div>
)
}
const sectionKey = getCombinedDiffFileTreeSectionKey(mode, node.entry)
const FileIcon = getFileTypeIcon(node.entry.path)
const fileName = basename(node.entry.path)
const parentDir = dirname(node.entry.path)
const dirPath = parentDir === '.' ? '' : parentDir
const status = node.entry.status as GitFileStatus
const disabled = !sectionIndexByKey.has(sectionKey)
return (
<button
type="button"
className={cn(
'group flex w-full min-w-0 cursor-pointer items-center gap-1 py-1 pr-3 text-left text-xs transition-colors hover:bg-accent/40 disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent',
activeSectionKey === sectionKey && 'bg-accent/60'
)}
style={{
paddingLeft: `${node.depth * COMBINED_DIFF_TREE_INDENT_PX + COMBINED_DIFF_TREE_FILE_PADDING_PX}px`
}}
disabled={disabled}
onClick={() => onNavigate(node.entry)}
>
<FileIcon className="size-3.5 shrink-0" style={{ color: STATUS_COLORS[status] }} />
<span className="min-w-0 flex-1 truncate">
<span className="text-foreground">{fileName}</span>
{dirPath && <span className="ml-1.5 text-[11px] text-muted-foreground">{dirPath}</span>}
</span>
<span
className="w-4 shrink-0 text-center text-[10px] font-bold"
style={{ color: STATUS_COLORS[status] }}
>
{STATUS_LABELS[status]}
</span>
</button>
)
}
@@ -50,7 +50,12 @@ import type {
import { Check, Copy, MessageSquare, Send, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { DiffSectionItem } from './DiffSectionItem'
import { getCombinedUncommittedEntries } from './combined-diff-entries'
import {
CombinedDiffFileTree,
createCombinedDiffSectionIndexMap,
handleCombinedDiffFileTreeNavigation
} from './CombinedDiffFileTree'
import { getCombinedBranchEntries, getCombinedUncommittedEntries } from './combined-diff-entries'
import { getDiffSectionEstimatedHeight, isIntrinsicHeightImageDiff } from './diff-section-layout'
import type { DiffSection } from './diff-section-types'
import { getInitialCombinedDiffSectionLoadIndices } from './combined-diff-initial-section-load'
@@ -225,17 +230,14 @@ export default function CombinedDiffViewer({
[snapshotEntries, gitStatusEntries, file.combinedAreaFilter]
)
const branchEntries = React.useMemo<GitBranchChangeEntry[]>(() => {
const snapshotEntries = file.branchEntriesSnapshot ?? []
if (snapshotEntries.length > 0) {
return snapshotEntries
}
return liveBranchEntries
return getCombinedBranchEntries(file.branchEntriesSnapshot, liveBranchEntries)
}, [file.branchEntriesSnapshot, liveBranchEntries])
const commitEntries = React.useMemo<GitBranchChangeEntry[]>(
() => file.commitEntriesSnapshot ?? [],
[file.commitEntriesSnapshot]
)
const entries = isBranchMode ? branchEntries : isCommitMode ? commitEntries : uncommittedEntries
const treeMode = isBranchMode ? 'branch' : isCommitMode ? 'commit' : 'uncommitted'
const entrySignature = React.useMemo(
() =>
JSON.stringify({
@@ -573,6 +575,34 @@ export default function CombinedDiffViewer({
loadSchedulerRef.current.request(index)
}
}, [])
const sectionIndexByKey = React.useMemo(
() => createCombinedDiffSectionIndexMap(sections),
[sections]
)
const [activeTreeSectionKey, setActiveTreeSectionKey] = useState<string | null>(null)
useEffect(() => {
setActiveTreeSectionKey(null)
}, [entrySignature])
const viewedSectionKeys = React.useMemo(
() => new Set(sections.filter((section) => !section.loading).map((section) => section.key)),
[sections]
)
const handleTreeNavigate = useCallback(
(entry: GitStatusEntry | GitBranchChangeEntry) => {
const navigatedIndex = handleCombinedDiffFileTreeNavigation({
mode: treeMode,
entry,
sections: sectionsRef.current,
sectionIndexByKey,
toggleSection,
scrollToIndex: (index) => virtualizer.scrollToIndex(index, { align: 'start' })
})
if (navigatedIndex !== null) {
setActiveTreeSectionKey(sectionsRef.current[navigatedIndex]?.key ?? null)
}
},
[sectionIndexByKey, toggleSection, treeMode, virtualizer]
)
const setAllSectionsCollapsed = useCallback((collapsed: boolean) => {
combinedDiffCollapsedPreference = collapsed
@@ -1065,48 +1095,60 @@ export default function CombinedDiffViewer({
</div>
{commitHeader}
<div ref={scrollContainerRef} className="flex-1 overflow-auto scrollbar-editor">
{skippedConflictNotice}
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const section = sections[virtualItem.index]
if (!section) {
return null
}
<div className="flex min-h-0 flex-1">
<CombinedDiffFileTree
mode={treeMode}
entries={entries}
sectionIndexByKey={sectionIndexByKey}
activeSectionKey={activeTreeSectionKey}
viewedSectionKeys={viewedSectionKeys}
onNavigate={handleTreeNavigate}
/>
<div ref={scrollContainerRef} className="min-w-0 flex-1 overflow-auto scrollbar-editor">
{skippedConflictNotice}
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const section = sections[virtualItem.index]
if (!section) {
return null
}
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full"
// Why: `top` preserves sticky file headers inside each row;
// transform-based virtualization creates a containing block
// that makes long-section headers feel jumpy while scrolling.
style={{ top: `${virtualItem.start}px` }}
>
<DiffSectionItem
section={section}
index={virtualItem.index}
isBranchMode={isBranchMode}
sideBySide={sideBySide}
isDark={isDark}
settings={settings}
sectionHeight={sectionHeights[virtualItem.index]}
worktreeId={file.worktreeId}
loadSection={loadSection}
retrySection={retrySection}
toggleSection={toggleSection}
openSection={openSection}
openSectionTitle={isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor'}
setSectionHeights={setSectionHeights}
setSections={setSections}
modifiedEditorsRef={modifiedEditorsRef}
handleSectionSaveRef={handleSectionSaveRef}
/>
</div>
)
})}
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full"
// Why: `top` preserves sticky file headers inside each row;
// transform-based virtualization creates a containing block
// that makes long-section headers feel jumpy while scrolling.
style={{ top: `${virtualItem.start}px` }}
>
<DiffSectionItem
section={section}
index={virtualItem.index}
isBranchMode={isBranchMode}
sideBySide={sideBySide}
isDark={isDark}
settings={settings}
sectionHeight={sectionHeights[virtualItem.index]}
worktreeId={file.worktreeId}
loadSection={loadSection}
retrySection={retrySection}
toggleSection={toggleSection}
openSection={openSection}
openSectionTitle={
isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor'
}
setSectionHeights={setSectionHeights}
setSections={setSections}
modifiedEditorsRef={modifiedEditorsRef}
handleSectionSaveRef={handleSectionSaveRef}
/>
</div>
)
})}
</div>
</div>
</div>
</div>
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { getCombinedUncommittedEntries } from './combined-diff-entries'
import type { GitStatusEntry } from '../../../../shared/types'
import { getCombinedBranchEntries, getCombinedUncommittedEntries } from './combined-diff-entries'
import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types'
describe('getCombinedUncommittedEntries', () => {
it('filters unresolved conflicts from live entries', () => {
@@ -44,3 +44,17 @@ describe('getCombinedUncommittedEntries', () => {
])
})
})
describe('getCombinedBranchEntries', () => {
it('uses an explicitly empty snapshot instead of falling back to live entries', () => {
const liveEntries: GitBranchChangeEntry[] = [{ path: 'src/live.ts', status: 'modified' }]
expect(getCombinedBranchEntries([], liveEntries)).toEqual([])
})
it('falls back to live entries when no snapshot exists', () => {
const liveEntries: GitBranchChangeEntry[] = [{ path: 'src/live.ts', status: 'modified' }]
expect(getCombinedBranchEntries(undefined, liveEntries)).toEqual(liveEntries)
})
})
@@ -1,5 +1,5 @@
import type { OpenFile } from '@/store/slices/editor'
import type { GitStatusEntry } from '../../../../shared/types'
import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types'
/**
* Fallback filtering for combined-diff tabs that were opened before the
@@ -20,3 +20,12 @@ export function getCombinedUncommittedEntries(
return entry.area !== 'untracked'
})
}
export function getCombinedBranchEntries(
snapshotEntries: readonly GitBranchChangeEntry[] | undefined,
liveEntries: readonly GitBranchChangeEntry[]
): GitBranchChangeEntry[] {
// Why: an explicitly empty tab snapshot should stay empty instead of drifting
// to later Source Control refreshes.
return [...(snapshotEntries ?? liveEntries)]
}
@@ -0,0 +1,109 @@
import { basename } from '@/lib/path'
import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types'
export type CombinedDiffFileTreeMode = 'uncommitted' | 'branch' | 'commit'
export type CombinedDiffFileTreeEntry = GitStatusEntry | GitBranchChangeEntry
export type CombinedDiffBranchTreeArea = 'combined-branch' | 'combined-commit'
export const NO_EXTENSION_KEY = '(no extension)'
export function getCombinedDiffFileTreeSectionKey(
mode: CombinedDiffFileTreeMode,
entry: CombinedDiffFileTreeEntry
): string {
if (mode === 'uncommitted' && 'area' in entry) {
return `${entry.area}:${entry.path}`
}
return `${mode === 'branch' ? 'combined-branch' : 'combined-commit'}:${entry.path}`
}
export function createCombinedDiffSectionIndexMap(
sections: readonly { key: string }[]
): Map<string, number> {
return new Map(sections.map((section, index) => [section.key, index]))
}
export function getCombinedDiffFileTreeNavigationIndex({
mode,
entry,
sectionIndexByKey
}: {
mode: CombinedDiffFileTreeMode
entry: CombinedDiffFileTreeEntry
sectionIndexByKey: ReadonlyMap<string, number>
}): number | null {
return sectionIndexByKey.get(getCombinedDiffFileTreeSectionKey(mode, entry)) ?? null
}
export function handleCombinedDiffFileTreeNavigation({
mode,
entry,
sections,
sectionIndexByKey,
toggleSection,
scrollToIndex
}: {
mode: CombinedDiffFileTreeMode
entry: CombinedDiffFileTreeEntry
sections: readonly { collapsed: boolean }[]
sectionIndexByKey: ReadonlyMap<string, number>
toggleSection: (index: number) => void
scrollToIndex: (index: number) => void
}): number | null {
const index = getCombinedDiffFileTreeNavigationIndex({ mode, entry, sectionIndexByKey })
if (index === null || !sections[index]) {
return null
}
if (sections[index].collapsed) {
toggleSection(index)
}
scrollToIndex(index)
return index
}
export function isGitStatusEntry(entry: CombinedDiffFileTreeEntry): entry is GitStatusEntry {
return 'area' in entry
}
export function getEntryExtension(entry: CombinedDiffFileTreeEntry): string {
const name = basename(entry.path)
const index = name.lastIndexOf('.')
if (index <= 0 || index === name.length - 1) {
return NO_EXTENSION_KEY
}
return name.slice(index).toLowerCase()
}
function getEntrySearchText(entry: CombinedDiffFileTreeEntry): string {
return [entry.path, entry.oldPath ?? '', entry.status, isGitStatusEntry(entry) ? entry.area : '']
.join(' ')
.toLowerCase()
}
export function getFilteredCombinedDiffFileTreeEntries({
entries,
mode,
query,
excludedExtensions,
includeViewed,
viewedSectionKeys
}: {
entries: readonly CombinedDiffFileTreeEntry[]
mode: CombinedDiffFileTreeMode
query: string
excludedExtensions: ReadonlySet<string>
includeViewed: boolean
viewedSectionKeys: ReadonlySet<string>
}): CombinedDiffFileTreeEntry[] {
const normalizedQuery = query.trim().toLowerCase()
return entries.filter((entry) => {
if (excludedExtensions.has(getEntryExtension(entry))) {
return false
}
if (!includeViewed && viewedSectionKeys.has(getCombinedDiffFileTreeSectionKey(mode, entry))) {
return false
}
return normalizedQuery.length === 0 || getEntrySearchText(entry).includes(normalizedQuery)
})
}