Reconcile source control selection before commit

This commit is contained in:
Neil
2026-05-31 05:58:12 -07:00
parent 1b9a82fdde
commit def91fa369
2 changed files with 72 additions and 14 deletions
@@ -3,6 +3,7 @@ import type { GitStatusEntry } from '../../../../shared/types'
import {
getSelectionRangeKeys,
reconcileSelectionKeys,
reconcileSourceControlSelectionState,
type FlatEntry
} from './useSourceControlSelection'
@@ -36,6 +37,40 @@ describe('reconcileSelectionKeys', () => {
})
})
describe('reconcileSourceControlSelectionState', () => {
it('keeps selected keys and anchor identity when all keys are still visible', () => {
const flatEntries = [
makeEntry('unstaged::a.ts', 'unstaged', 'a.ts'),
makeEntry('staged::b.ts', 'staged', 'b.ts')
]
const selectedKeys = new Set(['unstaged::a.ts'])
const result = reconcileSourceControlSelectionState({
selectedKeys,
anchorKey: 'unstaged::a.ts',
flatEntries
})
expect(result.selectedKeys).toBe(selectedKeys)
expect(result.anchorKey).toBe('unstaged::a.ts')
})
it('prunes stale selected keys and clears stale range anchors', () => {
const flatEntries = [makeEntry('staged::b.ts', 'staged', 'b.ts')]
expect(
reconcileSourceControlSelectionState({
selectedKeys: new Set(['unstaged::a.ts', 'staged::b.ts']),
anchorKey: 'unstaged::a.ts',
flatEntries
})
).toEqual({
selectedKeys: new Set(['staged::b.ts']),
anchorKey: null
})
})
})
describe('getSelectionRangeKeys', () => {
it('selects an inclusive range across visible sections', () => {
const flatEntries = [
@@ -28,6 +28,30 @@ export function reconcileSelectionKeys(
return nextSelected
}
export function reconcileSourceControlSelectionState(args: {
selectedKeys: ReadonlySet<string>
anchorKey: string | null
flatEntries: FlatEntry[]
}): { selectedKeys: ReadonlySet<string>; anchorKey: string | null } {
const { anchorKey, flatEntries, selectedKeys } = args
const validKeys = new Set(flatEntries.map((e) => e.key))
const nextSelected = new Set<string>()
let selectedChanged = false
for (const key of selectedKeys) {
if (validKeys.has(key)) {
nextSelected.add(key)
} else {
selectedChanged = true
}
}
return {
selectedKeys: selectedChanged ? nextSelected : selectedKeys,
anchorKey: anchorKey && !validKeys.has(anchorKey) ? null : anchorKey
}
}
export function getSelectionRangeKeys(
flatEntries: FlatEntry[],
anchorKey: string | null,
@@ -87,20 +111,19 @@ export function useSourceControlSelection({
shouldOpenAsSplitRef.current = shouldOpenAsSplit
}, [shouldOpenAsSplit])
// Clear stale selections if entries disappear
useEffect(() => {
const validKeys = new Set(flatEntries.map((e) => e.key))
const nextSelected = reconcileSelectionKeys(selectedKeys, flatEntries)
const changed = nextSelected.size !== selectedKeys.size
if (changed) {
setSelectedKeys(nextSelected)
}
if (anchorKey && !validKeys.has(anchorKey)) {
setAnchorKey(null)
}
}, [flatEntries, selectedKeys, anchorKey])
const reconciledSelection = reconcileSourceControlSelectionState({
selectedKeys,
anchorKey,
flatEntries
})
if (reconciledSelection.selectedKeys !== selectedKeys) {
// Why: visible source-control rows can disappear after filtering, staging,
// or status refresh; prune stale bulk-action keys before children see them.
setSelectedKeys(new Set(reconciledSelection.selectedKeys))
}
if (reconciledSelection.anchorKey !== anchorKey) {
setAnchorKey(reconciledSelection.anchorKey)
}
const handleSelect = useCallback((e: React.MouseEvent, key: string, entry: GitStatusEntry) => {
if (shouldOpenAsSplitRef.current?.(e)) {