perf(renderer): avoid combined-diff tree rebuilds during progressive loads (#17643)

* perf(renderer): avoid combined-diff tree rebuilds during progressive loads

* fix(renderer): preserve collapsed combined-diff tree boundaries

* perf(renderer): skip unfiltered combined-diff flatten when hiding viewed files

* fix(renderer): keep reordered viewed keys in the combined-diff delta

The incremental viewedSectionKeys delta walked indices issuing a delete
then an add, so a key added at index i and deleted as the previous key at
a later index was silently dropped. Fall back to a full recompute when any
index's key differs; the progressive-load fast path (stable keys, flipping
loading state) is unchanged.
This commit is contained in:
Neil
2026-08-31 17:14:34 -07:00
committed by GitHub
parent f546f53a4e
commit fa0180dc61
9 changed files with 566 additions and 125 deletions
@@ -178,7 +178,7 @@ export default function CombinedDiffViewer({
registry,
requestSectionReload,
sectionIndexByKeyRef: treeNavigation.sectionIndexByKeyRef,
sections,
sectionEntries: entrySet.entries,
shouldAutoReloadFromGitStatus: entrySet.shouldAutoReloadFromGitStatus,
treeMode: entrySet.treeMode
})
@@ -45,6 +45,34 @@ function getEntrySearchText(entry: CombinedDiffFileTreeEntry): string {
.toLowerCase()
}
/**
* Applies filters that describe the file set itself. Viewed state is deliberately not included:
* section loading changes viewed flags without changing the tree's path structure.
*/
export function getCombinedDiffFileTreeEntriesMatchingStaticFilters({
entries,
query,
excludedExtensions
}: {
entries: readonly CombinedDiffFileTreeEntry[]
query: string
excludedExtensions: ReadonlySet<string>
}): readonly CombinedDiffFileTreeEntry[] {
if (isCombinedDiffFileTreeQueryTooLarge(query)) {
return []
}
const normalizedQuery = query.trim().toLowerCase()
if (normalizedQuery.length === 0 && excludedExtensions.size === 0) {
return entries
}
return entries.filter((entry) => {
if (excludedExtensions.has(getEntryExtension(entry))) {
return false
}
return normalizedQuery.length === 0 || getEntrySearchText(entry).includes(normalizedQuery)
})
}
export function getFilteredCombinedDiffFileTreeEntries({
entries,
mode,
@@ -60,19 +88,19 @@ export function getFilteredCombinedDiffFileTreeEntries({
includeViewed: boolean
viewedSectionKeys: ReadonlySet<string>
}): CombinedDiffFileTreeEntry[] {
if (isCombinedDiffFileTreeQueryTooLarge(query)) {
return []
const staticFilteredEntries = getCombinedDiffFileTreeEntriesMatchingStaticFilters({
entries,
query,
excludedExtensions
})
if (includeViewed) {
return [...staticFilteredEntries]
}
const trimmedQuery = query.trim()
const normalizedQuery = trimmedQuery.toLowerCase()
return entries.filter((entry) => {
if (excludedExtensions.has(getEntryExtension(entry))) {
return false
}
return staticFilteredEntries.filter((entry) => {
if (!includeViewed && viewedSectionKeys.has(getCombinedDiffFileTreeSectionKey(mode, entry))) {
return false
}
return normalizedQuery.length === 0 || getEntrySearchText(entry).includes(normalizedQuery)
return true
})
}
@@ -0,0 +1,179 @@
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type { GitStatusEntry, GitStagingArea } from '../../../../../../shared/git-status-types'
import {
buildGitStatusSourceControlTree,
buildSourceControlTree,
compactSourceControlTree,
flattenSourceControlTree,
type SourceControlTreeNode
} from '@/components/right-sidebar/source-control-tree'
import {
getCombinedDiffFileTreeSectionKey,
isGitStatusEntry,
type CombinedDiffBranchTreeArea,
type CombinedDiffFileTreeEntry,
type CombinedDiffFileTreeMode
} from '../resolve-changes/combined-diff-section-identity'
import type { CombinedDiffTreeNode } from './combined-diff-file-tree-row'
const UNCOMMITTED_AREA_ORDER: readonly GitStagingArea[] = ['unstaged', 'staged', 'untracked']
const UNCOMMITTED_AREA_LABELS: Record<GitStagingArea, string> = {
unstaged: 'Changes',
staged: 'Staged Changes',
untracked: 'Untracked Files'
}
export type CombinedDiffTreeGroup = {
area: GitStagingArea
label: string
roots: CombinedDiffTreeNode[]
}
export type CombinedDiffTreeVisibility = {
rows: CombinedDiffTreeNode[]
visibleFileCount: number
visibleFileCounts: ReadonlyMap<string, number>
}
export type { CombinedDiffTreeNode }
/** Build the uncommitted tree shape without volatile viewed/loading flags. */
export function buildCombinedDiffUncommittedTreeGroups(
entries: readonly CombinedDiffFileTreeEntry[]
): CombinedDiffTreeGroup[] {
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],
roots: roots as CombinedDiffTreeNode[]
}
}).filter((group): group is CombinedDiffTreeGroup => group !== null)
}
/** Build the committed tree shape without volatile viewed/loading flags. */
export function buildCombinedDiffBranchTreeRoots(
mode: Extract<CombinedDiffFileTreeMode, 'all' | 'branch' | 'commit'>,
entries: readonly CombinedDiffFileTreeEntry[]
): CombinedDiffTreeNode[] {
const branchEntries = entries.filter(
(entry): entry is GitBranchChangeEntry => !isGitStatusEntry(entry)
)
const area: CombinedDiffBranchTreeArea = mode === 'commit' ? 'combined-commit' : 'combined-branch'
const roots = compactSourceControlTree(buildSourceControlTree(area, [...branchEntries]))
return roots as CombinedDiffTreeNode[]
}
/** Flatten a stable tree shape; this is the path used when viewed files are included. */
export function flattenCombinedDiffTreeRoots(
roots: readonly CombinedDiffTreeNode[],
collapsedDirectoryKeys: ReadonlySet<string>
): CombinedDiffTreeNode[] {
return flattenSourceControlTree(
roots as SourceControlTreeNode<CombinedDiffFileTreeEntry, string>[],
collapsedDirectoryKeys
) as CombinedDiffTreeNode[]
}
/**
* Apply viewed state as a lightweight overlay. It filters and re-compacts the already-sorted tree
* in linear time, preserving the file-tree shape while avoiding a fresh path build and sort.
*/
export function getViewedCombinedDiffTreeVisibility({
roots,
collapsedDirectoryKeys,
mode,
viewedSectionKeys
}: {
roots: readonly CombinedDiffTreeNode[]
collapsedDirectoryKeys: ReadonlySet<string>
mode: CombinedDiffFileTreeMode
viewedSectionKeys: ReadonlySet<string>
}): CombinedDiffTreeVisibility {
const visibleFileCounts = new Map<string, number>()
const rows: CombinedDiffTreeNode[] = []
type VisibleTreeNode = {
source: CombinedDiffTreeNode
children: VisibleTreeNode[]
fileCount: number
}
const projectVisibleTree = (node: CombinedDiffTreeNode): VisibleTreeNode | null => {
if (node.type === 'file') {
return viewedSectionKeys.has(getCombinedDiffFileTreeSectionKey(mode, node.entry))
? null
: { source: node, children: [], fileCount: 1 }
}
const children = node.children
.map((child) => projectVisibleTree(child as CombinedDiffTreeNode))
.filter((child): child is VisibleTreeNode => child !== null)
if (children.length === 0) {
return null
}
return {
source: node,
children,
fileCount: children.reduce((count, child) => count + child.fileCount, 0)
}
}
const compactVisibleTree = (projected: VisibleTreeNode, depth: number): CombinedDiffTreeNode => {
if (projected.source.type === 'file') {
return { ...projected.source, depth }
}
const names = [projected.source.name]
let compacted = projected
// Keep a collapsed directory as a visible boundary; filtering must not compact it away and
// accidentally expose descendants that the user explicitly hid.
while (
!collapsedDirectoryKeys.has(compacted.source.key) &&
compacted.children.length === 1 &&
compacted.children[0]?.source.type === 'directory'
) {
compacted = compacted.children[0]
names.push(compacted.source.name)
}
const compactedSource = compacted.source
if (compactedSource.type !== 'directory') {
throw new Error('Combined diff directory projection lost its source node')
}
const node = {
...compactedSource,
name: names.join('/'),
depth,
fileCount: compacted.fileCount,
children: compacted.children.map((child) => compactVisibleTree(child, depth + 1))
} satisfies CombinedDiffTreeNode
visibleFileCounts.set(node.key, node.fileCount)
return node
}
const visit = (node: CombinedDiffTreeNode): void => {
rows.push(node)
if (node.type === 'directory' && !collapsedDirectoryKeys.has(node.key)) {
for (const child of node.children) {
visit(child)
}
}
}
let visibleFileCount = 0
for (const root of roots) {
const projected = projectVisibleTree(root)
if (!projected) {
continue
}
const compacted = compactVisibleTree(projected, 0)
visibleFileCount += projected.fileCount
visit(compacted)
}
return { rows, visibleFileCount, visibleFileCounts }
}
@@ -1,4 +1,4 @@
import { createElement } from 'react'
import { createElement, memo } from 'react'
import { ChevronDown, Folder, FolderOpen } from 'lucide-react'
import { STATUS_COLORS, STATUS_LABELS } from '@/components/right-sidebar/status-display'
import type { SourceControlTreeNode } from '@/components/right-sidebar/source-control-tree'
@@ -28,13 +28,14 @@ const COMBINED_DIFF_TREE_INDENT_PX = 12
const COMBINED_DIFF_TREE_DIRECTORY_PADDING_PX = 8
const COMBINED_DIFF_TREE_FILE_PADDING_PX = 20
export function CombinedDiffFileTreeRow({
export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({
node,
mode,
worktreePath,
activeSectionKey,
sectionIndexByKey,
isCollapsed,
visibleFileCount,
onToggleDirectory,
onNavigate
}: {
@@ -44,6 +45,7 @@ export function CombinedDiffFileTreeRow({
activeSectionKey: string | null
sectionIndexByKey: ReadonlyMap<string, number>
isCollapsed: boolean
visibleFileCount?: number
onToggleDirectory: (key: string) => void
onNavigate: (entry: CombinedDiffFileTreeEntry) => void
}): React.JSX.Element {
@@ -77,7 +79,7 @@ export function CombinedDiffFileTreeRow({
<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}
{visibleFileCount ?? node.fileCount}
</span>
</div>
)
@@ -132,4 +134,4 @@ export function CombinedDiffFileTreeRow({
</span>
</button>
)
}
})
@@ -10,10 +10,15 @@ import {
import {
COMBINED_DIFF_FILE_TREE_QUERY_MAX_BYTES,
getCombinedDiffBranchEntriesInTreeOrder,
getCombinedDiffFileTreeEntriesMatchingStaticFilters,
getFilteredCombinedDiffFileTreeEntries,
isCombinedDiffFileTreeQueryTooLarge,
isCombinedDiffSectionViewed
} from './combined-diff-file-tree-filter'
import {
buildCombinedDiffBranchTreeRoots,
getViewedCombinedDiffTreeVisibility
} from './combined-diff-file-tree-model'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
@@ -145,4 +150,69 @@ describe('CombinedDiffFileTree navigation mapping', () => {
})
).toEqual([])
})
it('retains the structural entry list when only viewed state can change', () => {
const entries: GitBranchChangeEntry[] = [
{ path: 'src/a.ts', status: 'modified' },
{ path: 'src/b.ts', status: 'modified' }
]
expect(
getCombinedDiffFileTreeEntriesMatchingStaticFilters({
entries,
query: '',
excludedExtensions: new Set()
})
).toBe(entries)
})
it('overlays viewed files while preserving filtered-tree compaction', () => {
const entries: GitBranchChangeEntry[] = [
{ path: 'src/a.ts', status: 'modified' },
{ path: 'src/nested/b.ts', status: 'modified' },
{ path: 'docs/readme.md', status: 'modified' }
]
const roots = buildCombinedDiffBranchTreeRoots('branch', entries)
const visibility = getViewedCombinedDiffTreeVisibility({
roots,
collapsedDirectoryKeys: new Set(),
mode: 'branch',
viewedSectionKeys: new Set(['combined-branch:src/a.ts', 'combined-branch:docs/readme.md'])
})
expect(
visibility.rows.filter((node) => node.type === 'file').map((node) => node.entry.path)
).toEqual(['src/nested/b.ts'])
expect(visibility.visibleFileCount).toBe(1)
const compactedDirectory = visibility.rows.find((node) => node.type === 'directory')
expect(compactedDirectory).toMatchObject({
path: 'src/nested',
name: 'src/nested',
fileCount: 1
})
expect(compactedDirectory && visibility.visibleFileCounts.get(compactedDirectory.key)).toBe(1)
})
it('preserves a collapsed directory boundary while filtering viewed siblings', () => {
const entries: GitBranchChangeEntry[] = [
{ path: 'src/a/one.ts', status: 'modified' },
{ path: 'src/b/two.ts', status: 'modified' }
]
const roots = buildCombinedDiffBranchTreeRoots('branch', entries)
const visibility = getViewedCombinedDiffTreeVisibility({
roots,
collapsedDirectoryKeys: new Set(['dir::combined-branch::src']),
mode: 'branch',
viewedSectionKeys: new Set(['combined-branch:src/b/two.ts'])
})
expect(visibility.rows).toEqual([
expect.objectContaining({
type: 'directory',
key: 'dir::combined-branch::src',
path: 'src'
})
])
expect(visibility.visibleFileCount).toBe(1)
})
})
@@ -5,70 +5,23 @@ 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
} from '@/components/right-sidebar/source-control-tree'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type { GitStagingArea, GitStatusEntry } from '../../../../../../shared/git-status-types'
import {
getEntryExtension,
getFilteredCombinedDiffFileTreeEntries
getCombinedDiffFileTreeEntriesMatchingStaticFilters,
getEntryExtension
} from './combined-diff-file-tree-filter'
import {
isGitStatusEntry,
type CombinedDiffBranchTreeArea,
type CombinedDiffFileTreeEntry,
type CombinedDiffFileTreeMode
import type {
CombinedDiffFileTreeEntry,
CombinedDiffFileTreeMode
} from '../resolve-changes/combined-diff-section-identity'
import { CombinedDiffFileTreeRow, type CombinedDiffTreeNode } from './combined-diff-file-tree-row'
import { CombinedDiffFileTreeRow } from './combined-diff-file-tree-row'
import { useCombinedDiffFileTreeResize } from './use-combined-diff-file-tree-resize'
import { translate } from '@/i18n/i18n'
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, 'all' | 'branch' | 'commit'>,
entries: readonly CombinedDiffFileTreeEntry[],
collapsedDirectoryKeys: ReadonlySet<string>
): CombinedDiffTreeNode[] {
const branchEntries = entries.filter(
(entry): entry is GitBranchChangeEntry => !isGitStatusEntry(entry)
)
const area: CombinedDiffBranchTreeArea = mode === 'commit' ? 'combined-commit' : 'combined-branch'
const roots = compactSourceControlTree(buildSourceControlTree(area, branchEntries))
return flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[]
}
import {
buildCombinedDiffBranchTreeRoots,
buildCombinedDiffUncommittedTreeGroups,
flattenCombinedDiffTreeRoots,
getViewedCombinedDiffTreeVisibility,
type CombinedDiffTreeNode
} from './combined-diff-file-tree-model'
export function CombinedDiffFileTree({
mode,
@@ -115,17 +68,16 @@ export function CombinedDiffFileTree({
() => Array.from(new Set(entries.map(getEntryExtension))).sort(),
[entries]
)
const filteredEntries = React.useMemo(
// Why: viewed/loading state changes for one section must not invalidate the path filter or tree
// construction. It is applied below as a visibility overlay.
const structurallyFilteredEntries = React.useMemo(
() =>
getFilteredCombinedDiffFileTreeEntries({
getCombinedDiffFileTreeEntriesMatchingStaticFilters({
entries,
mode,
query,
excludedExtensions,
includeViewed,
viewedSectionKeys
excludedExtensions
}),
[entries, excludedExtensions, includeViewed, mode, query, viewedSectionKeys]
[entries, excludedExtensions, query]
)
const toggleExtension = React.useCallback((extension: string) => {
setExcludedExtensions((prev) => {
@@ -146,20 +98,74 @@ export function CombinedDiffFileTree({
const activeFilterCount =
excludedExtensions.size + (includeViewed ? 0 : 1) + (query.trim().length > 0 ? 1 : 0)
const uncommittedGroups = React.useMemo(
const uncommittedTreeGroups = React.useMemo(
() =>
mode === 'all' || mode === 'uncommitted'
? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys)
? buildCombinedDiffUncommittedTreeGroups(structurallyFilteredEntries)
: [],
[collapsedDirectoryKeys, filteredEntries, mode]
[mode, structurallyFilteredEntries]
)
const branchRows = React.useMemo(
const branchTreeRoots = React.useMemo(
() =>
mode === 'all' || mode === 'branch' || mode === 'commit'
? buildBranchRows(mode, filteredEntries, collapsedDirectoryKeys)
? buildCombinedDiffBranchTreeRoots(mode, structurallyFilteredEntries)
: [],
[collapsedDirectoryKeys, filteredEntries, mode]
[mode, structurallyFilteredEntries]
)
// Why: the viewed overlay below replaces these rows entirely when viewed files are hidden, so
// flattening the unfiltered tree there is pure dead work.
const uncommittedRowsByArea = React.useMemo(() => {
const rowsByArea = new Map<string, CombinedDiffTreeNode[]>()
if (!includeViewed) {
return rowsByArea
}
for (const group of uncommittedTreeGroups) {
rowsByArea.set(group.area, flattenCombinedDiffTreeRoots(group.roots, collapsedDirectoryKeys))
}
return rowsByArea
}, [collapsedDirectoryKeys, includeViewed, uncommittedTreeGroups])
const branchRows = React.useMemo(
() =>
includeViewed ? flattenCombinedDiffTreeRoots(branchTreeRoots, collapsedDirectoryKeys) : [],
[branchTreeRoots, collapsedDirectoryKeys, includeViewed]
)
const uncommittedVisibleRowsByArea = React.useMemo(() => {
if (includeViewed) {
return null
}
const rowsByArea = new Map<string, ReturnType<typeof getViewedCombinedDiffTreeVisibility>>()
for (const group of uncommittedTreeGroups) {
rowsByArea.set(
group.area,
getViewedCombinedDiffTreeVisibility({
roots: group.roots,
collapsedDirectoryKeys,
mode,
viewedSectionKeys
})
)
}
return rowsByArea
}, [collapsedDirectoryKeys, includeViewed, mode, uncommittedTreeGroups, viewedSectionKeys])
const branchVisibleRows = React.useMemo(
() =>
includeViewed
? null
: getViewedCombinedDiffTreeVisibility({
roots: branchTreeRoots,
collapsedDirectoryKeys,
mode,
viewedSectionKeys
}),
[branchTreeRoots, collapsedDirectoryKeys, includeViewed, mode, viewedSectionKeys]
)
const visibleEntryCount = includeViewed
? structurallyFilteredEntries.length
: (branchVisibleRows?.visibleFileCount ?? 0) +
Array.from(uncommittedVisibleRowsByArea?.values() ?? []).reduce(
(count, visibility) => count + visibility.visibleFileCount,
0
)
if (collapsed) {
return null
@@ -278,7 +284,7 @@ export function CombinedDiffFileTree({
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto py-1 scrollbar-sleek">
{filteredEntries.length === 0 ? (
{visibleEntryCount === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
{translate(
'auto.components.editor.CombinedDiffFileTree.f984289373',
@@ -287,27 +293,40 @@ export function CombinedDiffFileTree({
</div>
) : mode === 'all' || 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}
{uncommittedTreeGroups.map((group) => {
const rows =
uncommittedVisibleRowsByArea?.get(group.area)?.rows ??
uncommittedRowsByArea.get(group.area) ??
[]
const visibleFileCounts = uncommittedVisibleRowsByArea?.get(
group.area
)?.visibleFileCounts
if (rows.length === 0) {
return null
}
return (
<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>
{rows.map((node) => (
<CombinedDiffFileTreeRow
key={node.key}
node={node}
mode={mode}
worktreePath={worktreePath}
activeSectionKey={activeSectionKey}
sectionIndexByKey={sectionIndexByKey}
isCollapsed={collapsedDirectoryKeys.has(node.key)}
visibleFileCount={visibleFileCounts?.get(node.key)}
onToggleDirectory={toggleDirectory}
onNavigate={onNavigate}
/>
))}
</div>
{group.rows.map((node) => (
<CombinedDiffFileTreeRow
key={node.key}
node={node}
mode={mode}
worktreePath={worktreePath}
activeSectionKey={activeSectionKey}
sectionIndexByKey={sectionIndexByKey}
isCollapsed={collapsedDirectoryKeys.has(node.key)}
onToggleDirectory={toggleDirectory}
onNavigate={onNavigate}
/>
))}
</div>
))}
{mode === 'all' && branchRows.length > 0 ? (
)
})}
{mode === 'all' && (branchVisibleRows?.rows ?? branchRows).length > 0 ? (
<div className="py-1">
<div className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate(
@@ -315,7 +334,7 @@ export function CombinedDiffFileTree({
'Committed on Branch'
)}
</div>
{branchRows.map((node) => (
{(branchVisibleRows?.rows ?? branchRows).map((node) => (
<CombinedDiffFileTreeRow
key={node.key}
node={node}
@@ -324,6 +343,7 @@ export function CombinedDiffFileTree({
activeSectionKey={activeSectionKey}
sectionIndexByKey={sectionIndexByKey}
isCollapsed={collapsedDirectoryKeys.has(node.key)}
visibleFileCount={branchVisibleRows?.visibleFileCounts.get(node.key)}
onToggleDirectory={toggleDirectory}
onNavigate={onNavigate}
/>
@@ -332,7 +352,7 @@ export function CombinedDiffFileTree({
) : null}
</>
) : (
branchRows.map((node) => (
(branchVisibleRows?.rows ?? branchRows).map((node) => (
<CombinedDiffFileTreeRow
key={node.key}
node={node}
@@ -341,6 +361,7 @@ export function CombinedDiffFileTree({
activeSectionKey={activeSectionKey}
sectionIndexByKey={sectionIndexByKey}
isCollapsed={collapsedDirectoryKeys.has(node.key)}
visibleFileCount={branchVisibleRows?.visibleFileCounts.get(node.key)}
onToggleDirectory={toggleDirectory}
onNavigate={onNavigate}
/>
@@ -0,0 +1,73 @@
// @vitest-environment happy-dom
import React from 'react'
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { DiffSection } from '../../diff-section-types'
import { useCombinedDiffTreeNavigation } from './use-combined-diff-tree-navigation'
function makeSection(key: string, viewed: boolean): DiffSection {
return {
key,
path: key,
status: 'modified',
originalContent: '',
modifiedContent: '',
collapsed: false,
loading: !viewed,
loadOnDemand: !viewed,
dirty: false,
diffResult: null,
largeDiffRenderLimit: null
}
}
function renderNavigation(sections: DiffSection[], entrySignature: string) {
const sectionsRef = { current: sections } as React.RefObject<DiffSection[]>
return renderHook(
(props: { sections: DiffSection[]; entrySignature: string }) => {
sectionsRef.current = props.sections
return useCombinedDiffTreeNavigation({
ensureSectionLoaded: vi.fn(),
entrySignature: props.entrySignature,
markDirectScrollInput: vi.fn(),
scrollToIndex: vi.fn(),
sections: props.sections,
sectionsRef,
toggleSection: vi.fn(),
treeMode: 'all'
})
},
{ initialProps: { sections, entrySignature } }
)
}
describe('useCombinedDiffTreeNavigation viewedSectionKeys', () => {
it('keeps every viewed key when sections are reordered under one entry signature', () => {
const a = makeSection('a', true)
const b = makeSection('b', true)
const view = renderNavigation([a, b], 'sig')
expect([...view.result.current.viewedSectionKeys].sort()).toEqual(['a', 'b'])
view.rerender({ sections: [b, a], entrySignature: 'sig' })
expect([...view.result.current.viewedSectionKeys].sort()).toEqual(['a', 'b'])
})
it('patches only the flipped section while keys stay in place', () => {
const a = makeSection('a', true)
const view = renderNavigation([a, makeSection('b', false)], 'sig')
expect([...view.result.current.viewedSectionKeys]).toEqual(['a'])
view.rerender({ sections: [a, makeSection('b', true)], entrySignature: 'sig' })
expect([...view.result.current.viewedSectionKeys].sort()).toEqual(['a', 'b'])
})
it('reuses the cached set when no section changed viewed state', () => {
const sections = [makeSection('a', true), makeSection('b', false)]
const view = renderNavigation(sections, 'sig')
const first = view.result.current.viewedSectionKeys
view.rerender({ sections: [...sections], entrySignature: 'sig' })
expect(view.result.current.viewedSectionKeys).toBe(first)
})
})
@@ -37,10 +37,33 @@ export function useCombinedDiffTreeNavigation({
toggleSection: (index: number) => void
treeMode: CombinedDiffFileTreeMode
}): CombinedDiffTreeNavigation {
const sectionIndexByKey = React.useMemo(
() => createCombinedDiffSectionIndexMap(sections),
[sections]
)
const sectionIndexCacheRef = useRef<{
entrySignature: string
sectionCount: number
map: Map<string, number>
keys: string[]
} | null>(null)
const sectionIndexByKey = React.useMemo(() => {
const previous = sectionIndexCacheRef.current
// Section content/loading updates preserve entry order and keys. The entry signature and
// count usually change when the navigable structure changes, but compare keys as a guard for
// same-sized/reused signatures (and to keep this cache correct if a caller rebuilds sections).
if (
previous?.entrySignature === entrySignature &&
previous.sectionCount === sections.length &&
sections.every((section, index) => previous.keys[index] === section.key)
) {
return previous.map
}
const map = createCombinedDiffSectionIndexMap(sections)
sectionIndexCacheRef.current = {
entrySignature,
sectionCount: sections.length,
map,
keys: sections.map((section) => section.key)
}
return map
}, [entrySignature, sections])
const sectionIndexByKeyRef = useRef<ReadonlyMap<string, number>>(sectionIndexByKey)
sectionIndexByKeyRef.current = sectionIndexByKey
@@ -54,15 +77,59 @@ export function useCombinedDiffTreeNavigation({
// Why: the tree highlight belongs to one entry set; reset now so it can't flash on another before an Effect would.
setActiveTreeSectionState({ entrySignature, key: null })
}
const viewedSectionKeys = React.useMemo(
() =>
new Set(
const viewedSectionCacheRef = useRef<{
entrySignature: string
sections: DiffSection[]
keys: Set<string>
} | null>(null)
const viewedSectionKeys = React.useMemo(() => {
const recomputeAllViewedKeys = (): Set<string> => {
const keys = new Set(
sections
.filter((section) => isCombinedDiffSectionViewed(section))
.map((section) => section.key)
),
[sections]
)
)
viewedSectionCacheRef.current = { entrySignature, sections, keys }
return keys
}
const previous = viewedSectionCacheRef.current
if (
previous === null ||
previous.entrySignature !== entrySignature ||
previous.sections.length !== sections.length
) {
return recomputeAllViewedKeys()
}
let keys = previous.keys
let copied = false
for (let index = 0; index < sections.length; index += 1) {
const previousSection = previous.sections[index]
const section = sections[index]
if (!previousSection || !section) {
continue
}
// Why: reordered keys can't be patched index by index — a later delete would drop an earlier add.
if (previousSection.key !== section.key) {
return recomputeAllViewedKeys()
}
const viewed = isCombinedDiffSectionViewed(section)
if (isCombinedDiffSectionViewed(previousSection) === viewed) {
continue
}
if (!copied) {
keys = new Set(previous.keys)
copied = true
}
if (viewed) {
keys.add(section.key)
} else {
keys.delete(section.key)
}
}
viewedSectionCacheRef.current = { entrySignature, sections, keys }
return keys
}, [entrySignature, sections])
const handleTreeNavigate = useCallback(
(entry: GitStatusEntry | GitBranchChangeEntry) => {
markDirectScrollInput()
@@ -1,7 +1,6 @@
import React, { useEffect, useRef } from 'react'
import type { OpenFile } from '@/store/slices/editor'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import type { DiffSection } from '../../diff-section-types'
import {
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
type EditorPathMutationTarget
@@ -21,7 +20,7 @@ export function useCombinedDiffSectionRevalidation({
registry,
requestSectionReload,
sectionIndexByKeyRef,
sections,
sectionEntries,
shouldAutoReloadFromGitStatus,
treeMode
}: {
@@ -30,7 +29,9 @@ export function useCombinedDiffSectionRevalidation({
registry: CombinedDiffSectionLoadRegistry
requestSectionReload: (index: number) => void
sectionIndexByKeyRef: React.RefObject<ReadonlyMap<string, number>>
sections: DiffSection[]
// The entry set is structurally stable while individual section content/loading state changes.
// Use it for the status signature so progressive loads do not rescan the section array.
sectionEntries: readonly { path: string }[]
shouldAutoReloadFromGitStatus: boolean
treeMode: CombinedDiffFileTreeMode
}): string {
@@ -39,8 +40,8 @@ export function useCombinedDiffSectionRevalidation({
if (!shouldAutoReloadFromGitStatus) {
return ''
}
return buildCombinedGitStatusSignature(sections, gitStatusEntries)
}, [gitStatusEntries, sections, shouldAutoReloadFromGitStatus])
return buildCombinedGitStatusSignature(sectionEntries, gitStatusEntries)
}, [gitStatusEntries, sectionEntries, shouldAutoReloadFromGitStatus])
const prevCombinedGitStatusSignatureRef = useRef<string | null>(null)
useEffect(() => {