fix: snapshot uncommitted entries in combined diff to survive commits (#652)

This commit is contained in:
Brennan Benson 2026-04-14 14:11:05 -07:00 committed by GitHub
parent e342b64e78
commit 033b8bcc31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 95 additions and 10 deletions

View File

@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button'
import type { OpenFile } from '@/store/slices/editor'
import type { GitBranchChangeEntry, GitDiffResult, GitStatusEntry } from '../../../../shared/types'
import { DiffSectionItem } from './DiffSectionItem'
import { getCombinedUncommittedEntries } from './combined-diff-entries'
type DiffSection = {
key: string
@ -79,18 +80,24 @@ export default function CombinedDiffViewer({ file }: { file: OpenFile }): React.
? file.branchCompare
: null
// Why: prefer the snapshot taken at tab-open time so a commit that changes
// gitStatusByWorktree does not rebuild all sections and lose loaded content.
// The snapshot is already area-filtered by openAllDiffs; conflict filtering
// is applied here via snapshotEntries. The live path (getCombinedUncommittedEntries)
// adds its own area + conflict filtering as a fallback for tabs opened before
// the snapshot field existed.
const snapshotEntries = React.useMemo(
() => file.uncommittedEntriesSnapshot?.filter((e) => e.conflictStatus !== 'unresolved'),
[file.uncommittedEntriesSnapshot]
)
const uncommittedEntries = React.useMemo(
() =>
(gitStatusByWorktree[file.worktreeId] ?? []).filter((entry) => {
if (entry.conflictStatus === 'unresolved') {
return false
}
if (file.combinedAreaFilter) {
return entry.area === file.combinedAreaFilter
}
return entry.area !== 'untracked'
}),
[file.worktreeId, file.combinedAreaFilter, gitStatusByWorktree]
snapshotEntries ??
getCombinedUncommittedEntries(
gitStatusByWorktree[file.worktreeId] ?? [],
file.combinedAreaFilter
),
[snapshotEntries, file.worktreeId, file.combinedAreaFilter, gitStatusByWorktree]
)
const branchEntries = React.useMemo<GitBranchChangeEntry[]>(() => {
const snapshotEntries = file.branchEntriesSnapshot ?? []

View File

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { getCombinedUncommittedEntries } from './combined-diff-entries'
import type { GitStatusEntry } from '../../../../shared/types'
describe('getCombinedUncommittedEntries', () => {
it('filters unresolved conflicts from live entries', () => {
const liveEntries: GitStatusEntry[] = [
{
path: 'src/conflict.ts',
status: 'modified',
area: 'unstaged',
conflictStatus: 'unresolved'
},
{ path: 'src/ok.ts', status: 'modified', area: 'unstaged' }
]
expect(getCombinedUncommittedEntries(liveEntries, undefined)).toEqual([
{ path: 'src/ok.ts', status: 'modified', area: 'unstaged' }
])
})
it('applies area filter when provided', () => {
const liveEntries: GitStatusEntry[] = [
{ path: 'src/staged.ts', status: 'modified', area: 'staged' },
{ path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' },
{ path: 'src/untracked.ts', status: 'untracked', area: 'untracked' }
]
expect(getCombinedUncommittedEntries(liveEntries, 'staged')).toEqual([
{ path: 'src/staged.ts', status: 'modified', area: 'staged' }
])
})
it('excludes untracked entries when no area filter is set', () => {
const liveEntries: GitStatusEntry[] = [
{ path: 'src/staged.ts', status: 'modified', area: 'staged' },
{ path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' },
{ path: 'src/untracked.ts', status: 'untracked', area: 'untracked' }
]
expect(getCombinedUncommittedEntries(liveEntries, undefined)).toEqual([
{ path: 'src/staged.ts', status: 'modified', area: 'staged' },
{ path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' }
])
})
})

View File

@ -0,0 +1,22 @@
import type { OpenFile } from '@/store/slices/editor'
import type { GitStatusEntry } from '../../../../shared/types'
/**
* Fallback filtering for combined-diff tabs that were opened before the
* snapshot field existed. When a snapshot is present the caller should use it
* directly (after filtering out unresolved conflicts) instead of calling this.
*/
export function getCombinedUncommittedEntries(
liveEntries: GitStatusEntry[],
areaFilter: OpenFile['combinedAreaFilter']
): GitStatusEntry[] {
return liveEntries.filter((entry) => {
if (entry.conflictStatus === 'unresolved') {
return false
}
if (areaFilter) {
return entry.area === areaFilter
}
return entry.area !== 'untracked'
})
}

View File

@ -89,6 +89,10 @@ export type OpenFile = {
combinedAlternate?: CombinedDiffAlternate
combinedAreaFilter?: string // filter combined diff to a specific area (e.g. 'staged', 'unstaged', 'untracked')
branchEntriesSnapshot?: GitBranchChangeEntry[]
/** Why: snapshot uncommitted entries at tab-open time so a subsequent commit
* does not yank entries out from under the combined diff, which would rebuild
* all sections and lose loaded content + scroll position. */
uncommittedEntriesSnapshot?: GitStatusEntry[]
conflict?: OpenConflictMetadata
skippedConflicts?: CombinedDiffSkippedConflict[]
conflictReview?: ConflictReviewState
@ -807,6 +811,10 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const skippedConflicts = relevantEntries
.filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind)
.map((entry) => ({ path: entry.path, conflictKind: entry.conflictKind! }))
// Why: snapshot the entry list at open time so a subsequent commit does
// not yank entries from under the combined diff view, which would rebuild
// all sections and lose loaded content + scroll position.
const uncommittedEntriesSnapshot = relevantEntries
const id = areaFilter
? `${worktreeId}::all-diffs::uncommitted::${areaFilter}`
: `${worktreeId}::all-diffs::uncommitted`
@ -822,6 +830,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
f.id === id
? {
...f,
uncommittedEntriesSnapshot,
combinedAlternate: alternate,
combinedAreaFilter: areaFilter,
skippedConflicts,
@ -845,6 +854,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
isDirty: false,
mode: 'diff',
diffSource: 'combined-uncommitted',
uncommittedEntriesSnapshot,
combinedAlternate: alternate,
combinedAreaFilter: areaFilter,
skippedConflicts,