Skip to content

Commit 5a1b464

Browse files
committed
Sync diff ordering and refresh Changes on external staging
1 parent 215dee5 commit 5a1b464

File tree

5 files changed

+122
-7
lines changed

5 files changed

+122
-7
lines changed

packages/ui/src/components/layout/DiffOverlay.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,7 @@ export const DiffOverlay: React.FC<DiffOverlayProps> = React.memo(({
470470
stagedDiff={stagedDiff ?? undefined}
471471
unstagedDiff={unstagedDiff ?? undefined}
472472
fileSources={filePath && fileSource != null ? { [filePath]: fileSource } : undefined}
473+
fileOrder={viewerFiles.length > 0 ? viewerFiles.map((f) => f.path) : undefined}
473474
onChanged={handleRefresh}
474475
/>
475476
) : (

packages/ui/src/components/layout/RightPanel.test.tsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
2-
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2+
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
33
import { RightPanel } from './RightPanel';
44
import type { RightPanelProps } from './types';
55
import { API } from '../../utils/api';
@@ -35,6 +35,11 @@ describe('RightPanel - Zed-style Changes list', () => {
3535

3636
beforeEach(() => {
3737
vi.clearAllMocks();
38+
(window as any).electronAPI = {
39+
events: {
40+
onGitStatusUpdated: vi.fn(),
41+
},
42+
};
3843
(API.sessions.getExecutions as any).mockResolvedValue({
3944
success: true,
4045
data: [
@@ -145,4 +150,50 @@ describe('RightPanel - Zed-style Changes list', () => {
145150
expect(screen.getByText(/Changes/i)).toBeInTheDocument();
146151
});
147152
});
153+
154+
it('queues a refresh when status updates during an in-flight fetch', async () => {
155+
const onGitStatusUpdated = (window as any).electronAPI.events.onGitStatusUpdated as any;
156+
let statusCb: ((data: any) => void) | null = null;
157+
onGitStatusUpdated.mockImplementation((fn: any) => {
158+
statusCb = fn;
159+
return () => {};
160+
});
161+
162+
let resolveDiff: ((value: any) => void) | null = null;
163+
(API.sessions.getDiff as any).mockImplementation(
164+
() =>
165+
new Promise((r) => {
166+
resolveDiff = r;
167+
})
168+
);
169+
170+
render(<RightPanel {...mockProps} />);
171+
172+
await waitFor(() => {
173+
expect(API.sessions.getDiff).toHaveBeenCalled();
174+
});
175+
176+
await act(async () => {
177+
statusCb?.({ sessionId: 'test-session', gitStatus: { state: 'modified' } });
178+
await new Promise((r) => setTimeout(r, 120));
179+
});
180+
181+
await act(async () => {
182+
resolveDiff?.({
183+
success: true,
184+
data: {
185+
workingTree: {
186+
staged: [{ path: 'staged1.ts', type: 'modified', additions: 5, deletions: 2 }],
187+
unstaged: [{ path: 'unstaged1.ts', type: 'modified', additions: 3, deletions: 1 }],
188+
untracked: [{ path: 'new.ts', type: 'added', additions: 10, deletions: 0 }],
189+
},
190+
},
191+
});
192+
await new Promise((r) => setTimeout(r, 20));
193+
});
194+
195+
await waitFor(() => {
196+
expect((API.sessions.getDiff as any).mock.calls.length).toBeGreaterThan(1);
197+
});
198+
});
148199
});

packages/ui/src/components/layout/RightPanel.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ export const RightPanel: React.FC<RightPanelProps> = React.memo(({
447447
const [isStageChanging, setIsStageChanging] = useState(false);
448448
const [error, setError] = useState<string | null>(null);
449449
const loadingRef = useRef(false);
450+
const pendingFilesRefreshRef = useRef(false);
450451
const requestIdRef = useRef(0);
451452
const historyRequestIdRef = useRef(0);
452453
const refreshTimerRef = useRef<number | null>(null);
@@ -549,7 +550,14 @@ export const RightPanel: React.FC<RightPanelProps> = React.memo(({
549550

550551
// Fetch files for the selected commit
551552
const fetchFiles = useCallback(async () => {
552-
if (loadingRef.current || !session.id || !selectedTarget) return;
553+
if (!session.id || !selectedTarget) return;
554+
555+
// If a refresh is requested while a previous fetch is in-flight (e.g. external IDE stages/unstages),
556+
// queue one more fetch to run after the current request settles.
557+
if (loadingRef.current) {
558+
pendingFilesRefreshRef.current = true;
559+
return;
560+
}
553561

554562
const requestId = ++requestIdRef.current;
555563
loadingRef.current = true;
@@ -647,6 +655,13 @@ export const RightPanel: React.FC<RightPanelProps> = React.memo(({
647655
setIsLoading(false);
648656
loadingRef.current = false;
649657
}
658+
659+
if (!loadingRef.current && pendingFilesRefreshRef.current) {
660+
pendingFilesRefreshRef.current = false;
661+
window.setTimeout(() => {
662+
void fetchFiles();
663+
}, 0);
664+
}
650665
}
651666
}, [session.id, selectedTarget, fetchCommits]);
652667

@@ -897,6 +912,12 @@ export const RightPanel: React.FC<RightPanelProps> = React.memo(({
897912
return merged;
898913
}, [trackedFiles, workingTree]);
899914

915+
const workingFilesForDiffOverlay = useMemo(() => {
916+
const tracked = trackedList.map((x) => x.file);
917+
const untracked = untrackedList.map((x) => x.file);
918+
return [...tracked, ...untracked];
919+
}, [trackedList, untrackedList]);
920+
900921
const stageAllState: TriState = useMemo(() => {
901922
if (!workingTree) return 'unchecked';
902923
const hasChanges = workingTree.staged.length + workingTree.unstaged.length + workingTree.untracked.length > 0;
@@ -1209,7 +1230,7 @@ export const RightPanel: React.FC<RightPanelProps> = React.memo(({
12091230
const stage = stageState !== 'checked';
12101231
void handleChangeFileStage(file.path, stage);
12111232
}}
1212-
onClick={() => handleWorkingFileClick('all', file, trackedList.map((x) => x.file))}
1233+
onClick={() => handleWorkingFileClick('all', file, workingFilesForDiffOverlay)}
12131234
isSelected={selectedFile === file.path && selectedFileScope === 'all'}
12141235
testId={`right-panel-file-tracked-${file.path}`}
12151236
/>
@@ -1237,7 +1258,7 @@ export const RightPanel: React.FC<RightPanelProps> = React.memo(({
12371258
const stage = stageState !== 'checked';
12381259
void handleChangeFileStage(file.path, stage);
12391260
}}
1240-
onClick={() => handleWorkingFileClick('untracked', file, untrackedList.map((x) => x.file))}
1261+
onClick={() => handleWorkingFileClick('untracked', file, workingFilesForDiffOverlay)}
12411262
isSelected={selectedFile === file.path && selectedFileScope === 'untracked'}
12421263
testId={`right-panel-file-untracked-${file.path}`}
12431264
/>

packages/ui/src/components/panels/diff/ZedDiffViewer.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ index 1234567..abcdefg 100644
2626
+b
2727
c`;
2828

29+
const SAMPLE_DIFF_TWO_FILES = `diff --git a/b.txt b/b.txt
30+
index 1234567..abcdefg 100644
31+
--- a/b.txt
32+
+++ b/b.txt
33+
@@ -1,1 +1,1 @@
34+
-x
35+
+y
36+
diff --git a/a.txt b/a.txt
37+
index 1234567..abcdefg 100644
38+
--- a/a.txt
39+
+++ b/a.txt
40+
@@ -1,1 +1,1 @@
41+
-x
42+
+y`;
43+
2944
describe('ZedDiffViewer', () => {
3045
it('renders viewer', () => {
3146
render(<ZedDiffViewer diff={SAMPLE_DIFF_TWO_HUNKS} />);
@@ -117,4 +132,11 @@ describe('ZedDiffViewer', () => {
117132
);
118133
expect(screen.getAllByTestId('diff-hunk-controls')).toHaveLength(2);
119134
});
135+
136+
it('orders files based on fileOrder when provided', () => {
137+
render(<ZedDiffViewer diff={SAMPLE_DIFF_TWO_FILES} fileOrder={['a.txt', 'b.txt']} />);
138+
const headers = screen.getAllByTestId('diff-file-header').map((el) => el.textContent);
139+
expect(headers[0]).toBe('a.txt');
140+
expect(headers[1]).toBe('b.txt');
141+
});
120142
});

packages/ui/src/components/panels/diff/ZedDiffViewer.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,9 @@ export const ZedDiffViewer: React.FC<{
9595
unstagedDiff?: string;
9696
fileSources?: Record<string, string>;
9797
scrollToFilePath?: string;
98+
fileOrder?: string[];
9899
onChanged?: () => void;
99-
}> = ({ diff, className, sessionId, currentScope, stagedDiff, unstagedDiff, fileSources, scrollToFilePath, onChanged }) => {
100+
}> = ({ diff, className, sessionId, currentScope, stagedDiff, unstagedDiff, fileSources, scrollToFilePath, fileOrder, onChanged }) => {
100101
const fileHeaderRefs = useRef<Map<string, HTMLElement>>(new Map());
101102

102103
const stagedHunkHeaderBySig = useMemo(() => {
@@ -136,8 +137,27 @@ export const ZedDiffViewer: React.FC<{
136137
const files = useMemo<FileModel[]>(() => {
137138
if (!diff || diff.trim() === '') return [];
138139
const parsed = parseDiff(diff, { nearbySequences: 'zip' });
139-
140-
return parsed.map((f) => {
140+
const ordered = (() => {
141+
const order = Array.isArray(fileOrder) ? fileOrder.map((s) => (typeof s === 'string' ? s.trim() : '')).filter(Boolean) : [];
142+
if (order.length === 0) return parsed;
143+
const idx = new Map<string, number>();
144+
order.forEach((p, i) => {
145+
if (!idx.has(p)) idx.set(p, i);
146+
});
147+
return parsed
148+
.map((f, originalIndex) => ({ f, originalIndex, path: toFilePath(f) }))
149+
.sort((a, b) => {
150+
const ai = idx.get(a.path);
151+
const bi = idx.get(b.path);
152+
if (ai != null && bi != null) return ai - bi;
153+
if (ai != null) return -1;
154+
if (bi != null) return 1;
155+
return a.originalIndex - b.originalIndex;
156+
})
157+
.map((x) => x.f);
158+
})();
159+
160+
return ordered.map((f) => {
141161
const path = toFilePath(f);
142162
const source = fileSources?.[path];
143163
const expandedHunks = source ? expandToFullFile(f.hunks || [], source) : normalizeHunks(f.hunks || []);

0 commit comments

Comments
 (0)