feat: Redesign git changes to split stage/unstaged files. (#1359)

* feat: Redesign git changes to split stage/unstaged files.

Signed-off-by: Paolo Insogna <paolo@cowtech.it>

* fixup

Signed-off-by: Paolo Insogna <paolo@cowtech.it>

* fixup

Signed-off-by: Paolo Insogna <paolo@cowtech.it>

* refactor: streamline git changes panel

* fix: label staged and working diff tabs

* fix: isolate staged and working diff files

* fix: scope staged and working diff updates

* fix: scope git row revert to working changes

---------

Signed-off-by: Paolo Insogna <paolo@cowtech.it>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Paolo Insogna
2026-05-24 00:49:38 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 9af0de0056
commit e16097b05d
42 changed files with 2987 additions and 923 deletions
+162 -3
View File
@@ -9,6 +9,7 @@ type Deferred<T> = {
};
type GitAPI = Parameters<ReturnType<typeof useGitStore.getState>['fetchStatus']>[1];
type DirectoryGitState = NonNullable<ReturnType<ReturnType<typeof useGitStore.getState>['getDirectoryState']>>;
const createDeferred = <T>(): Deferred<T> => {
let resolve!: (value: T) => void;
@@ -20,16 +21,44 @@ const createDeferred = <T>(): Deferred<T> => {
return { promise, resolve, reject };
};
const createStatus = (diffStats?: GitStatus['diffStats']): GitStatus => ({
const createStatus = (diffStats?: GitStatus['diffStats'], files: GitStatus['files'] = []): GitStatus => ({
current: 'main',
tracking: null,
ahead: 0,
behind: 0,
files: [],
isClean: true,
files,
isClean: files.length === 0,
diffStats,
});
const createDirectoryState = (status: GitStatus): DirectoryGitState => ({
isGitRepo: true,
status,
branches: null,
log: null,
identity: null,
diffCache: new Map(),
indexRevision: 0,
lastRepoCheckAt: 0,
lastStatusFetch: 0,
lastStatusChange: 0,
lastLogFetch: 0,
lastBranchesFetch: 0,
lastIdentityFetch: 0,
logMaxCount: 25,
isLoadingStatus: false,
isLoadingLog: false,
isLoadingBranches: false,
isLoadingIdentity: false,
});
const setDirectoryStatus = (status: GitStatus) => {
useGitStore.setState({
directories: new Map([['/repo', createDirectoryState(status)]]),
activeDirectory: '/repo',
});
};
const createGitApi = (getGitStatus: GitAPI['getGitStatus']): GitAPI => ({
checkIsGitRepository: async () => true,
getGitStatus,
@@ -91,4 +120,134 @@ describe('useGitStore', () => {
const [fullResult, lightResult] = await Promise.all([fullPromise, lightPromise]);
expect(lightResult).toBe(fullResult);
});
test('optimistically stages modified files and preserves untouched file references', () => {
const target = { path: 'src/index.ts', index: ' ', working_dir: 'M' };
const untouched = { path: 'README.md', index: ' ', working_dir: 'M' };
const initialStatus = createStatus(undefined, [target, untouched]);
setDirectoryStatus(initialStatus);
const previousStatus = useGitStore.getState().moveStatusPathsOptimistically('/repo', ['src/index.ts'], 'stage');
const status = useGitStore.getState().getDirectoryState('/repo')?.status;
const state = useGitStore.getState().getDirectoryState('/repo');
expect(previousStatus).toBe(initialStatus);
expect(status?.files).toEqual([
{ path: 'src/index.ts', index: 'M', working_dir: ' ' },
untouched,
]);
expect(status?.files[1]).toBe(untouched);
expect(state?.indexRevision).toBe(1);
});
test('optimistically stages untracked files as added files', () => {
setDirectoryStatus(createStatus(undefined, [
{ path: 'new-file.ts', index: '?', working_dir: '?' },
]));
useGitStore.getState().moveStatusPathsOptimistically('/repo', ['new-file.ts'], 'stage');
const status = useGitStore.getState().getDirectoryState('/repo')?.status;
expect(status?.files).toEqual([
{ path: 'new-file.ts', index: 'A', working_dir: ' ' },
]);
});
test('optimistically unstages staged files', () => {
setDirectoryStatus(createStatus(undefined, [
{ path: 'src/index.ts', index: 'M', working_dir: ' ' },
]));
useGitStore.getState().moveStatusPathsOptimistically('/repo', ['src/index.ts'], 'unstage');
const status = useGitStore.getState().getDirectoryState('/repo')?.status;
expect(status?.files).toEqual([
{ path: 'src/index.ts', index: ' ', working_dir: 'M' },
]);
});
test('optimistically unstages staged added files back to untracked files', () => {
setDirectoryStatus(createStatus(undefined, [
{ path: 'new-file.ts', index: 'A', working_dir: ' ' },
]));
useGitStore.getState().moveStatusPathsOptimistically('/repo', ['new-file.ts'], 'unstage');
const status = useGitStore.getState().getDirectoryState('/repo')?.status;
expect(status?.files).toEqual([
{ path: 'new-file.ts', index: ' ', working_dir: '?' },
]);
});
test('keeps conflicted files unchanged during optimistic moves', () => {
const conflicted = { path: 'conflict.ts', index: 'U', working_dir: 'U' };
setDirectoryStatus(createStatus(undefined, [conflicted]));
useGitStore.getState().moveStatusPathsOptimistically('/repo', ['conflict.ts'], 'stage');
const status = useGitStore.getState().getDirectoryState('/repo')?.status;
expect(status?.files).toEqual([conflicted]);
expect(status?.files[0]).toBe(conflicted);
});
test('preserves diff stats during optimistic moves', () => {
const diffStats = { 'src/index.ts': { insertions: 2, deletions: 1 } };
setDirectoryStatus(createStatus(diffStats, [
{ path: 'src/index.ts', index: ' ', working_dir: 'M' },
]));
useGitStore.getState().moveStatusPathsOptimistically('/repo', ['src/index.ts'], 'stage');
const status = useGitStore.getState().getDirectoryState('/repo')?.status;
expect(status?.diffStats).toBe(diffStats);
});
test('does nothing when optimistic move has no matching path', () => {
const initialStatus = createStatus(undefined, [
{ path: 'src/index.ts', index: ' ', working_dir: 'M' },
]);
setDirectoryStatus(initialStatus);
const previousStatus = useGitStore.getState().moveStatusPathsOptimistically('/repo', ['missing.ts'], 'stage');
expect(previousStatus).toBe(initialStatus);
expect(useGitStore.getState().getDirectoryState('/repo')?.status).toBe(initialStatus);
expect(useGitStore.getState().getDirectoryState('/repo')?.indexRevision).toBe(0);
});
test('does nothing without status for optimistic moves', () => {
useGitStore.setState({
directories: new Map([['/repo', { ...createDirectoryState(createStatus()), status: null }]]),
activeDirectory: '/repo',
});
const previousStatus = useGitStore.getState().moveStatusPathsOptimistically('/repo', ['src/index.ts'], 'stage');
expect(previousStatus).toBeNull();
expect(useGitStore.getState().getDirectoryState('/repo')?.status).toBeNull();
});
test('removes entries that become clean during optimistic moves', () => {
setDirectoryStatus(createStatus(undefined, [
{ path: 'clean.ts', index: ' ', working_dir: ' ' },
]));
useGitStore.getState().moveStatusPathsOptimistically('/repo', ['clean.ts'], 'stage');
const status = useGitStore.getState().getDirectoryState('/repo')?.status;
expect(status?.files).toEqual([]);
expect(status?.isClean).toBe(true);
});
test('restores previous status for optimistic rollback', () => {
const initialStatus = createStatus(undefined, [
{ path: 'src/index.ts', index: ' ', working_dir: 'M' },
]);
setDirectoryStatus(initialStatus);
const previousStatus = useGitStore.getState().moveStatusPathsOptimistically('/repo', ['src/index.ts'], 'stage');
useGitStore.getState().restoreStatus('/repo', previousStatus);
expect(useGitStore.getState().getDirectoryState('/repo')?.status).toBe(initialStatus);
});
});
+162
View File
@@ -31,6 +31,7 @@ interface DirectoryGitState {
log: GitLogResponse | null;
identity: GitIdentitySummary | null;
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>;
indexRevision: number;
lastRepoCheckAt: number;
lastStatusFetch: number;
lastStatusChange: number;
@@ -61,6 +62,9 @@ interface GitStore {
ensureStatus: (directory: string, git: GitAPI) => Promise<void>;
ensureAll: (directory: string, git: GitAPI) => Promise<void>;
moveStatusPathsOptimistically: (directory: string, paths: string[], direction: 'stage' | 'unstage') => GitStatus | null;
restoreStatus: (directory: string, status: GitStatus | null) => void;
bumpIndexRevision: (directory: string) => void;
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number; isBinary?: boolean } | null;
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string; isBinary?: boolean }) => void;
@@ -122,6 +126,7 @@ const createEmptyDirectoryState = (): DirectoryGitState => ({
log: null,
identity: null,
diffCache: new Map(),
indexRevision: 0,
lastRepoCheckAt: 0,
lastStatusFetch: 0,
lastStatusChange: 0,
@@ -278,6 +283,72 @@ const getChangedFilePaths = (oldStatus: GitStatus | null, newStatus: GitStatus |
return changed;
};
const hasIndexStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | null): boolean => {
if (!oldStatus && !newStatus) return false;
if (!oldStatus || !newStatus) return true;
const oldFiles = oldStatus.files ?? [];
const newFiles = newStatus.files ?? [];
const normalizeIndexStatus = (value?: string | null): string => {
const trimmed = value?.trim() ?? '';
return trimmed === '?' ? '' : trimmed;
};
const oldIndexByPath = new Map(oldFiles.map((file) => [file.path, normalizeIndexStatus(file.index)] as const));
const newIndexByPath = new Map(newFiles.map((file) => [file.path, normalizeIndexStatus(file.index)] as const));
const paths = new Set<string>([...oldIndexByPath.keys(), ...newIndexByPath.keys()]);
for (const path of paths) {
if ((oldIndexByPath.get(path) ?? '') !== (newIndexByPath.get(path) ?? '')) {
return true;
}
}
return false;
};
const isBlankStatusCode = (value?: string | null): boolean => !value || value.trim().length === 0;
const isConflictStatusCode = (value?: string | null): boolean => (value || '').trim() === 'U';
const toStagedStatusFile = (file: GitStatus['files'][number]): GitStatus['files'][number] => {
const index = (file.index || '').trim();
const workingDir = (file.working_dir || '').trim();
if (isConflictStatusCode(index) || isConflictStatusCode(workingDir)) {
return file;
}
const nextIndex = index === '?' || workingDir === '?'
? 'A'
: index || workingDir || ' ';
return {
...file,
index: nextIndex,
working_dir: ' ',
};
};
const toUnstagedStatusFile = (file: GitStatus['files'][number]): GitStatus['files'][number] => {
const index = (file.index || '').trim();
const workingDir = (file.working_dir || '').trim();
if (isConflictStatusCode(index) || isConflictStatusCode(workingDir)) {
return file;
}
const nextWorkingDir = workingDir || (index === 'A' || index === '?' ? '?' : index) || ' ';
return {
...file,
index: ' ',
working_dir: nextWorkingDir,
};
};
const isCleanStatusFile = (file: GitStatus['files'][number]): boolean =>
isBlankStatusCode(file.index) && isBlankStatusCode(file.working_dir);
export const useGitStore = create<GitStore>()(
devtools(
(set, get) => ({
@@ -369,6 +440,7 @@ export const useGitStore = create<GitStore>()(
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
const changedPaths = getChangedFilePaths(currentDirState.status, newStatus);
const indexStatusChanged = hasIndexStatusChanged(currentDirState.status, newStatus);
const oldPaths = new Set((currentDirState.status?.files ?? []).map((f) => f.path));
const newPaths = new Set((newStatus.files ?? []).map((f) => f.path));
@@ -402,6 +474,7 @@ export const useGitStore = create<GitStore>()(
isGitRepo: true,
status: mergedStatus,
diffCache: nextDiffCache,
indexRevision: indexStatusChanged ? currentDirState.indexRevision + 1 : currentDirState.indexRevision,
lastRepoCheckAt: shouldProbeRepository ? now : currentDirState.lastRepoCheckAt,
lastStatusFetch: Date.now(),
lastStatusChange: hasFileContentChange ? Date.now() : currentDirState.lastStatusChange,
@@ -445,6 +518,95 @@ export const useGitStore = create<GitStore>()(
}
},
moveStatusPathsOptimistically: (directory, paths, direction) => {
const normalizedPaths = new Set(paths.map((path) => path.trim()).filter(Boolean));
if (normalizedPaths.size === 0) {
return null;
}
const { directories } = get();
const dirState = directories.get(directory);
const previousStatus = dirState?.status ?? null;
if (!dirState || !previousStatus) {
return previousStatus;
}
let didChange = false;
const nextFiles: GitStatus['files'] = [];
for (const file of previousStatus.files) {
if (!normalizedPaths.has(file.path)) {
nextFiles.push(file);
continue;
}
const nextFile = direction === 'stage'
? toStagedStatusFile(file)
: toUnstagedStatusFile(file);
if (nextFile !== file) {
didChange = true;
}
if (!isCleanStatusFile(nextFile)) {
nextFiles.push(nextFile);
} else {
didChange = true;
}
}
if (!didChange) {
return previousStatus;
}
const nextDirectories = new Map(directories);
nextDirectories.set(directory, {
...dirState,
status: {
...previousStatus,
files: nextFiles,
isClean: nextFiles.length === 0,
},
indexRevision: dirState.indexRevision + 1,
lastStatusChange: Date.now(),
});
set({ directories: nextDirectories });
return previousStatus;
},
restoreStatus: (directory, status) => {
const { directories } = get();
const dirState = directories.get(directory);
if (!dirState) {
return;
}
const nextDirectories = new Map(directories);
nextDirectories.set(directory, {
...dirState,
status,
indexRevision: dirState.indexRevision + 1,
lastStatusChange: Date.now(),
});
set({ directories: nextDirectories });
},
bumpIndexRevision: (directory) => {
const { directories } = get();
const dirState = directories.get(directory);
if (!dirState) {
return;
}
const nextDirectories = new Map(directories);
nextDirectories.set(directory, {
...dirState,
indexRevision: dirState.indexRevision + 1,
});
set({ directories: nextDirectories });
},
fetchBranches: async (directory, git) => {
{
const newDirectories = new Map(get().directories);
+23 -11
View File
@@ -25,6 +25,7 @@ type ContextPanelTab = {
dedupeKey: string;
label: string | null;
readOnly: boolean;
stagedDiff: boolean;
touchedAt: number;
};
@@ -34,6 +35,7 @@ type ContextPanelTabDescriptor = {
dedupeKey?: string | null;
label?: string | null;
readOnly?: boolean;
stagedDiff?: boolean;
};
type ContextPanelDirectoryState = {
@@ -204,6 +206,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa
dedupeKey,
label: normalizeContextTabLabel(descriptor.label),
readOnly: descriptor.readOnly === true,
stagedDiff: descriptor.stagedDiff === true,
touchedAt: Date.now(),
};
};
@@ -243,6 +246,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
dedupeKey?: unknown;
label?: unknown;
readOnly?: unknown;
stagedDiff?: unknown;
touchedAt?: unknown;
};
@@ -269,6 +273,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
dedupeKey,
label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null),
readOnly: candidate.readOnly === true,
stagedDiff: candidate.stagedDiff === true,
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
? candidate.touchedAt
: Date.now(),
@@ -327,6 +332,7 @@ const upsertContextPanelTab = (
targetPath: nextTab.targetPath,
dedupeKey: nextTab.dedupeKey,
label: nextTab.label,
stagedDiff: nextTab.stagedDiff,
touchedAt: Date.now(),
}
: tab));
@@ -504,6 +510,7 @@ interface UIStore {
mainTabGuard: MainTabGuard | null;
sidebarOpenBeforeFullscreenTab: boolean | null;
pendingDiffFile: string | null;
pendingDiffStaged: boolean;
pendingFileNavigation: PendingFileNavigation | null;
pendingFileFocusPath: string | null;
isMobile: boolean;
@@ -611,7 +618,7 @@ interface UIStore {
setRightSidebarWidth: (width: number) => void;
setRightSidebarTab: (tab: RightSidebarTab) => void;
openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor) => void;
openContextDiff: (directory: string, filePath: string) => void;
openContextDiff: (directory: string, filePath: string, staged?: boolean) => void;
openContextFile: (directory: string, filePath: string) => void;
openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void;
openContextOverview: (directory: string) => void;
@@ -635,10 +642,10 @@ interface UIStore {
setSessionDropdownOpen: (open: boolean) => void;
setActiveMainTab: (tab: MainTab) => void;
setMainTabGuard: (guard: MainTabGuard | null) => void;
setPendingDiffFile: (filePath: string | null) => void;
setPendingDiffFile: (filePath: string | null, staged?: boolean) => void;
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
setPendingFileFocusPath: (path: string | null) => void;
navigateToDiff: (filePath: string) => void;
navigateToDiff: (filePath: string, staged?: boolean) => void;
consumePendingDiffFile: () => string | null;
setIsMobile: (isMobile: boolean) => void;
toggleCommandPalette: () => void;
@@ -772,6 +779,7 @@ export const useUIStore = create<UIStore>()(
mainTabGuard: null,
sidebarOpenBeforeFullscreenTab: null,
pendingDiffFile: null,
pendingDiffStaged: false,
pendingFileNavigation: null,
pendingFileFocusPath: null,
isMobile: false,
@@ -975,15 +983,19 @@ export const useUIStore = create<UIStore>()(
});
},
openContextDiff: (directory, filePath) => {
openContextDiff: (directory, filePath, staged = false) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedFilePath = (filePath || '').trim();
if (!normalizedDirectory || !normalizedFilePath) {
return;
}
get().openContextPanelTab(normalizedDirectory, { mode: 'diff', targetPath: normalizedFilePath });
get().setPendingDiffFile(normalizedFilePath);
get().openContextPanelTab(normalizedDirectory, {
mode: 'diff',
targetPath: normalizedFilePath,
dedupeKey: staged ? 'staged' : null,
stagedDiff: staged,
});
},
openContextFile: (directory, filePath) => {
@@ -1334,8 +1346,8 @@ export const useUIStore = create<UIStore>()(
set({ activeMainTab: tab });
},
setPendingDiffFile: (filePath) => {
set({ pendingDiffFile: filePath });
setPendingDiffFile: (filePath, staged = false) => {
set({ pendingDiffFile: filePath, pendingDiffStaged: filePath ? staged : false });
},
setPendingFileNavigation: (navigation) => {
@@ -1346,18 +1358,18 @@ export const useUIStore = create<UIStore>()(
set({ pendingFileFocusPath: path });
},
navigateToDiff: (filePath) => {
navigateToDiff: (filePath, staged = false) => {
const guard = get().mainTabGuard;
if (guard && !guard('diff')) {
return;
}
set({ pendingDiffFile: filePath, activeMainTab: 'diff' });
set({ pendingDiffFile: filePath, pendingDiffStaged: staged, activeMainTab: 'diff' });
},
consumePendingDiffFile: () => {
const { pendingDiffFile } = get();
if (pendingDiffFile) {
set({ pendingDiffFile: null });
set({ pendingDiffFile: null, pendingDiffStaged: false });
}
return pendingDiffFile;
},