Skip to content

Commit 8cba0d3

Browse files
committed
Expand project diffs to full-file view
1 parent a980804 commit 8cba0d3

File tree

3 files changed

+204
-16
lines changed

3 files changed

+204
-16
lines changed

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,42 @@ describe('DiffOverlay', () => {
116116
expect((API.sessions.getDiff as any).mock.calls.length).toBeGreaterThan(callsBefore);
117117
});
118118
});
119+
120+
it('loads file sources for project diff view (working/all)', async () => {
121+
(API.sessions.getDiff as any).mockImplementation((_sessionId: string, target: any) => {
122+
if (target?.kind === 'working' && target?.scope === 'all') {
123+
return Promise.resolve({
124+
success: true,
125+
data: {
126+
diff: 'diff --git a/tracked.txt b/tracked.txt\n--- a/tracked.txt\n+++ b/tracked.txt\n@@ -1,1 +1,1 @@\n-a\n+b\n',
127+
changedFiles: ['tracked.txt', 'new.txt'],
128+
workingTree: {
129+
staged: [],
130+
unstaged: [{ path: 'tracked.txt', type: 'modified', additions: 1, deletions: 1 }],
131+
untracked: [{ path: 'new.txt', type: 'added', additions: 1, deletions: 0 }],
132+
},
133+
},
134+
});
135+
}
136+
return Promise.resolve({ success: true, data: { diff: '' } });
137+
});
138+
139+
(API.sessions.getFileContent as any).mockResolvedValue({ success: true, data: { content: 'x\ny\n' } });
140+
141+
render(
142+
<DiffOverlay
143+
isOpen={true}
144+
sessionId="s1"
145+
filePath={null as any}
146+
target={{ kind: 'working', scope: 'all' } as any}
147+
onClose={vi.fn()}
148+
files={[]}
149+
/>
150+
);
151+
152+
await waitFor(() => {
153+
expect(API.sessions.getFileContent).toHaveBeenCalledWith('s1', expect.objectContaining({ filePath: 'tracked.txt', ref: 'HEAD' }));
154+
expect(API.sessions.getFileContent).toHaveBeenCalledWith('s1', expect.objectContaining({ filePath: 'new.txt', ref: 'WORKTREE' }));
155+
});
156+
});
119157
});

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

Lines changed: 163 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const DiffOverlay: React.FC<DiffOverlayProps> = React.memo(({
4444
const [stagedDiff, setStagedDiff] = useState<string | null>(null);
4545
const [unstagedDiff, setUnstagedDiff] = useState<string | null>(null);
4646
const [fileSource, setFileSource] = useState<string | null>(null);
47+
const [fileSources, setFileSources] = useState<Record<string, string> | null>(null);
4748
const [loading, setLoading] = useState(false);
4849
const [error, setError] = useState<string | null>(null);
4950
const [copied, setCopied] = useState(false);
@@ -85,6 +86,7 @@ export const DiffOverlay: React.FC<DiffOverlayProps> = React.memo(({
8586
setStagedDiff(null);
8687
setUnstagedDiff(null);
8788
setFileSource(null);
89+
setFileSources(null);
8890
return;
8991
}
9092

@@ -93,6 +95,97 @@ export const DiffOverlay: React.FC<DiffOverlayProps> = React.memo(({
9395
setError(null);
9496

9597
try {
98+
const workingScope = target.kind === 'working' ? ((target as any).scope || 'all') : null;
99+
100+
if (target.kind === 'working') {
101+
// For working tree, always load all + staged + unstaged diffs so we can determine per-hunk status in one view (Zed-like).
102+
const [allRes, stagedRes, unstagedRes] = await Promise.all([
103+
withTimeout(API.sessions.getDiff(sessionId, { kind: 'working', scope: 'all' } as any), 15_000, 'Load diff'),
104+
withTimeout(API.sessions.getDiff(sessionId, { kind: 'working', scope: 'staged' } as any), 15_000, 'Load staged diff'),
105+
withTimeout(API.sessions.getDiff(sessionId, { kind: 'working', scope: 'unstaged' } as any), 15_000, 'Load unstaged diff'),
106+
]);
107+
108+
if (!allRes.success) throw new Error(allRes.error || 'Failed to load diff');
109+
if (!stagedRes.success) throw new Error(stagedRes.error || 'Failed to load staged diff');
110+
if (!unstagedRes.success) throw new Error(unstagedRes.error || 'Failed to load unstaged diff');
111+
112+
setDiff(allRes.data?.diff ?? '');
113+
setStagedDiff(stagedRes.data?.diff ?? '');
114+
setUnstagedDiff(unstagedRes.data?.diff ?? '');
115+
116+
// Single-file view: expand to full file using a best-effort file source.
117+
if (filePath) {
118+
setFileSources(null);
119+
const preferredRef = workingScope === 'untracked' ? 'WORKTREE' : 'HEAD';
120+
let sourceRes = await withTimeout(
121+
API.sessions.getFileContent(sessionId, { filePath, ref: preferredRef, maxBytes: 1024 * 1024 }),
122+
15_000,
123+
'Load file content'
124+
);
125+
if (!sourceRes.success && preferredRef !== 'WORKTREE') {
126+
sourceRes = await withTimeout(
127+
API.sessions.getFileContent(sessionId, { filePath, ref: 'WORKTREE', maxBytes: 1024 * 1024 }),
128+
15_000,
129+
'Load file content'
130+
);
131+
}
132+
setFileSource(sourceRes.success ? (sourceRes.data?.content ?? '') : null);
133+
} else {
134+
// Project diff view: expand each file to include unchanged lines between hunks (Zed-like).
135+
setFileSource(null);
136+
137+
const wt = (allRes.data as { workingTree?: unknown } | undefined)?.workingTree as
138+
| { staged: Array<{ path: string; isNew?: boolean }>; unstaged: Array<{ path: string; isNew?: boolean }>; untracked: Array<{ path: string; isNew?: boolean }> }
139+
| undefined;
140+
141+
const untracked = new Set<string>([
142+
...(wt?.untracked || []).map((f) => f.path),
143+
...(wt?.staged || []).filter((f) => Boolean((f as any).isNew)).map((f) => f.path),
144+
]);
145+
146+
const changed = Array.isArray((allRes.data as { changedFiles?: unknown } | undefined)?.changedFiles)
147+
? (((allRes.data as { changedFiles?: unknown }).changedFiles as unknown[]) || []).filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
148+
: [];
149+
150+
const maxFiles = 80;
151+
const targets = changed.slice(0, maxFiles);
152+
const results: Record<string, string> = {};
153+
154+
// Small concurrency pool to avoid UI stalls.
155+
const concurrency = 6;
156+
let cursor = 0;
157+
const workers = Array.from({ length: concurrency }).map(async () => {
158+
while (cursor < targets.length) {
159+
const idx = cursor++;
160+
const p = targets[idx];
161+
const prefer = untracked.has(p) ? 'WORKTREE' : 'HEAD';
162+
try {
163+
let r = await withTimeout(
164+
API.sessions.getFileContent(sessionId, { filePath: p, ref: prefer as any, maxBytes: 1024 * 1024 }),
165+
15_000,
166+
'Load file content'
167+
);
168+
if (!r.success && prefer !== 'WORKTREE') {
169+
r = await withTimeout(
170+
API.sessions.getFileContent(sessionId, { filePath: p, ref: 'WORKTREE', maxBytes: 1024 * 1024 }),
171+
15_000,
172+
'Load file content'
173+
);
174+
}
175+
if (r.success) {
176+
results[p] = r.data?.content ?? '';
177+
}
178+
} catch {
179+
// best-effort
180+
}
181+
}
182+
});
183+
await Promise.all(workers);
184+
setFileSources(Object.keys(results).length > 0 ? results : null);
185+
}
186+
return;
187+
}
188+
96189
if (target.kind === 'working' && filePath) {
97190
const [allRes, stagedRes, unstagedRes] = await Promise.all([
98191
withTimeout(API.sessions.getDiff(sessionId, { kind: 'working', scope: 'all' } as any), 15_000, 'Load diff'),
@@ -122,14 +215,15 @@ export const DiffOverlay: React.FC<DiffOverlayProps> = React.memo(({
122215
'Load file content'
123216
);
124217
}
125-
setFileSource(sourceRes.success ? (sourceRes.data?.content ?? '') : '');
218+
setFileSource(sourceRes.success ? (sourceRes.data?.content ?? '') : null);
126219
} else {
127220
const response = await withTimeout(API.sessions.getDiff(sessionId, target), 15_000, 'Load diff');
128221
if (response.success && response.data) {
129222
setDiff(response.data.diff ?? '');
130223
setStagedDiff(null);
131224
setUnstagedDiff(null);
132225
setFileSource(null);
226+
setFileSources(null);
133227
} else {
134228
const message = response.error || 'Failed to load diff';
135229
const isStaleCommit =
@@ -159,7 +253,9 @@ export const DiffOverlay: React.FC<DiffOverlayProps> = React.memo(({
159253
setError(null);
160254

161255
try {
162-
if (target.kind === 'working' && filePath) {
256+
const workingScope = target.kind === 'working' ? ((target as any).scope || 'all') : null;
257+
258+
if (target.kind === 'working') {
163259
const [allRes, stagedRes, unstagedRes] = await Promise.all([
164260
withTimeout(API.sessions.getDiff(sessionId, { kind: 'working', scope: 'all' } as any), 15_000, 'Load diff'),
165261
withTimeout(API.sessions.getDiff(sessionId, { kind: 'working', scope: 'staged' } as any), 15_000, 'Load staged diff'),
@@ -174,28 +270,81 @@ export const DiffOverlay: React.FC<DiffOverlayProps> = React.memo(({
174270
setStagedDiff(stagedRes.data?.diff ?? '');
175271
setUnstagedDiff(unstagedRes.data?.diff ?? '');
176272

177-
// Keep full-file rendering stable during refresh as well.
178-
const preferredRef = target.scope === 'untracked' ? 'WORKTREE' : 'HEAD';
179-
let sourceRes = await withTimeout(
180-
API.sessions.getFileContent(sessionId, { filePath, ref: preferredRef, maxBytes: 1024 * 1024 }),
181-
15_000,
182-
'Load file content'
183-
);
184-
if (!sourceRes.success && preferredRef !== 'WORKTREE') {
185-
sourceRes = await withTimeout(
186-
API.sessions.getFileContent(sessionId, { filePath, ref: 'WORKTREE', maxBytes: 1024 * 1024 }),
273+
if (filePath) {
274+
setFileSources(null);
275+
const preferredRef = workingScope === 'untracked' ? 'WORKTREE' : 'HEAD';
276+
let sourceRes = await withTimeout(
277+
API.sessions.getFileContent(sessionId, { filePath, ref: preferredRef, maxBytes: 1024 * 1024 }),
187278
15_000,
188279
'Load file content'
189280
);
281+
if (!sourceRes.success && preferredRef !== 'WORKTREE') {
282+
sourceRes = await withTimeout(
283+
API.sessions.getFileContent(sessionId, { filePath, ref: 'WORKTREE', maxBytes: 1024 * 1024 }),
284+
15_000,
285+
'Load file content'
286+
);
287+
}
288+
setFileSource(sourceRes.success ? (sourceRes.data?.content ?? '') : null);
289+
} else {
290+
setFileSource(null);
291+
292+
const wt = (allRes.data as { workingTree?: unknown } | undefined)?.workingTree as
293+
| { staged: Array<{ path: string; isNew?: boolean }>; unstaged: Array<{ path: string; isNew?: boolean }>; untracked: Array<{ path: string; isNew?: boolean }> }
294+
| undefined;
295+
296+
const untracked = new Set<string>([
297+
...(wt?.untracked || []).map((f) => f.path),
298+
...(wt?.staged || []).filter((f) => Boolean((f as any).isNew)).map((f) => f.path),
299+
]);
300+
301+
const changed = Array.isArray((allRes.data as { changedFiles?: unknown } | undefined)?.changedFiles)
302+
? (((allRes.data as { changedFiles?: unknown }).changedFiles as unknown[]) || []).filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
303+
: [];
304+
305+
const maxFiles = 80;
306+
const targets = changed.slice(0, maxFiles);
307+
const results: Record<string, string> = {};
308+
const concurrency = 6;
309+
let cursor = 0;
310+
const workers = Array.from({ length: concurrency }).map(async () => {
311+
while (cursor < targets.length) {
312+
const idx = cursor++;
313+
const p = targets[idx];
314+
const prefer = untracked.has(p) ? 'WORKTREE' : 'HEAD';
315+
try {
316+
let r = await withTimeout(
317+
API.sessions.getFileContent(sessionId, { filePath: p, ref: prefer as any, maxBytes: 1024 * 1024 }),
318+
15_000,
319+
'Load file content'
320+
);
321+
if (!r.success && prefer !== 'WORKTREE') {
322+
r = await withTimeout(
323+
API.sessions.getFileContent(sessionId, { filePath: p, ref: 'WORKTREE', maxBytes: 1024 * 1024 }),
324+
15_000,
325+
'Load file content'
326+
);
327+
}
328+
if (r.success) {
329+
results[p] = r.data?.content ?? '';
330+
}
331+
} catch {
332+
// best-effort
333+
}
334+
}
335+
});
336+
await Promise.all(workers);
337+
setFileSources(Object.keys(results).length > 0 ? results : null);
190338
}
191-
setFileSource(sourceRes.success ? (sourceRes.data?.content ?? '') : '');
339+
return;
192340
} else {
193341
const response = await withTimeout(API.sessions.getDiff(sessionId, target), 15_000, 'Load diff');
194342
if (response.success && response.data) {
195343
setDiff(response.data.diff ?? '');
196344
setStagedDiff(null);
197345
setUnstagedDiff(null);
198346
setFileSource(null);
347+
setFileSources(null);
199348
} else {
200349
const message = response.error || 'Failed to load diff';
201350
const isStaleCommit =
@@ -469,7 +618,7 @@ export const DiffOverlay: React.FC<DiffOverlayProps> = React.memo(({
469618
currentScope={target?.kind === 'working' ? (target.scope as any) : undefined}
470619
stagedDiff={stagedDiff ?? undefined}
471620
unstagedDiff={unstagedDiff ?? undefined}
472-
fileSources={filePath && fileSource != null ? { [filePath]: fileSource } : undefined}
621+
fileSources={filePath && fileSource != null ? { [filePath]: fileSource } : (fileSources ?? undefined)}
473622
fileOrder={viewerFiles.length > 0 ? viewerFiles.map((f) => f.path) : undefined}
474623
onChanged={handleRefresh}
475624
/>

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,9 @@ export const ZedDiffViewer: React.FC<{
184184

185185
return ordered.map((f) => {
186186
const path = toFilePath(f);
187-
const source = fileSources?.[path];
188-
const expandedHunks = source ? expandToFullFile(f.hunks || [], source) : normalizeHunks(f.hunks || []);
187+
const hasSource = Boolean(fileSources && Object.prototype.hasOwnProperty.call(fileSources, path));
188+
const source = hasSource ? (fileSources as Record<string, string>)[path] : undefined;
189+
const expandedHunks = hasSource ? expandToFullFile(f.hunks || [], source || '') : normalizeHunks(f.hunks || []);
189190

190191
const hunks = expandedHunks.map((hunk, idx) => {
191192
const sig = hunkSignature(hunk);

0 commit comments

Comments
 (0)