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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
9af0de0056
commit
e16097b05d
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user