Skip to content

Commit cb8d658

Browse files
committed
fix(workspace): cap the changes list so one project cannot freeze the tab
A whole workspace folder registered as one project listed every untracked file it held - 33,274 files, a 7.6 MB response - and painting that many rows froze the app. The diff endpoint now caps the list at a thousand files (tracked changes first, so the cap bites the untracked tail) and reports totalFiles/truncated, the no-commits fallback included; the tab shows the held-back count, its rows are memoised with stable handlers so expanding one does not repaint the rest, and a single diff renders five hundred rows before offering the remainder.
1 parent f40ac76 commit cb8d658

16 files changed

Lines changed: 139 additions & 50 deletions

File tree

server/routes/git.js

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,21 @@ export function splitGitDiffPatches(diffOutput) {
323323
}).filter(({ path: filePath }) => filePath);
324324
}
325325

326+
/** How many files one response lists, however many the tree holds. */
327+
const DIFF_FILE_LIMIT = 1000;
328+
329+
/**
330+
* Caps the file list and reports the truth of what was held back. Exported
331+
* for tests.
332+
*/
333+
export function capDiffFiles(files, fileLimit = DIFF_FILE_LIMIT) {
334+
return {
335+
files: files.slice(0, fileLimit),
336+
totalFiles: files.length,
337+
truncated: files.length > fileLimit,
338+
};
339+
}
340+
326341
/**
327342
* Attaches patch text while enforcing the per-file and response-wide limits.
328343
*/
@@ -418,7 +433,9 @@ router.get('/diff', async (req, res) => {
418433
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: projectPath });
419434

420435
if (!hasCommits) {
421-
return res.json({ branch, hasCommits, files: buildNoCommitsDiffFiles(statusOutput) });
436+
// The cap applies here too: a never-committed workspace directory can
437+
// hold every file it contains.
438+
return res.json({ branch, hasCommits, ...capDiffFiles(buildNoCommitsDiffFiles(statusOutput)) });
422439
}
423440

424441
const [{ stdout: numstatOutput }, { stdout: diffOutput }] = await Promise.all([
@@ -474,11 +491,11 @@ router.get('/diff', async (req, res) => {
474491
...untrackedFiles,
475492
];
476493

477-
res.json({
478-
branch,
479-
hasCommits,
480-
files: attachDiffPatches(files, patches),
481-
});
494+
// A whole workspace registered as one project can list tens of thousands
495+
// of untracked files; the list is capped so one project cannot freeze the
496+
// tab, and the response says what was held back. Tracked changes come
497+
// first in `files`, so the cap bites the untracked tail.
498+
res.json({ branch, hasCommits, ...capDiffFiles(attachDiffPatches(files, patches)) });
482499
} catch (error) {
483500
console.error('Git diff error:', error);
484501
res.json({

server/routes/git.test.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import test from 'node:test';
44
import {
55
attachDiffPatches,
66
buildNoCommitsDiffFiles,
7+
capDiffFiles,
78
parseGitLogWithStats,
89
parseGitNumstatOutput,
910
parseGitStatusOutput,
@@ -221,3 +222,12 @@ test('parseGitLogWithStats parses commits with parents, refs, and shortstat line
221222
test('parseGitLogWithStats handles empty output', () => {
222223
assert.deepEqual(parseGitLogWithStats(''), []);
223224
});
225+
226+
test('capDiffFiles caps the list and reports what was held back', () => {
227+
const files = Array.from({ length: 1002 }, (_, index) => ({ path: `f${index}` }));
228+
const capped = capDiffFiles(files, 1000);
229+
assert.equal(capped.files.length, 1000);
230+
assert.equal(capped.totalFiles, 1002);
231+
assert.equal(capped.truncated, true);
232+
assert.deepEqual(capDiffFiles(files.slice(0, 10)).truncated, false);
233+
});

src/components/workspace/hooks/useProjectChanges.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ export type ProjectChanges = {
1818
branch: string | null;
1919
hasCommits: boolean;
2020
files: ProjectChange[];
21+
/** The list was capped server-side; how many exist in all. */
22+
totalFiles: number;
23+
truncated: boolean;
2124
};
2225

2326
export type ProjectChangesState =
@@ -64,7 +67,13 @@ function readChanges(body: Record<string, unknown>): ProjectChanges {
6467
}];
6568
}) : [];
6669

67-
return { branch, hasCommits: body.hasCommits !== false, files };
70+
return {
71+
branch,
72+
hasCommits: body.hasCommits !== false,
73+
files,
74+
totalFiles: typeof body.totalFiles === 'number' ? body.totalFiles : files.length,
75+
truncated: body.truncated === true,
76+
};
6877
}
6978

7079
export function useProjectChanges(projectId: string | undefined, enabled: boolean) {

src/components/workspace/view/UnifiedDiff.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo } from 'react';
1+
import { useMemo, useState } from 'react';
22
import { useTranslation } from 'react-i18next';
33

44
import { parseUnifiedDiff, type UnifiedDiffRow } from '../utils/unifiedDiff';
@@ -23,11 +23,17 @@ export default function UnifiedDiff({ patch, onLineComment }: UnifiedDiffProps)
2323
return <UnifiedDiffRows rows={rows} onLineComment={onLineComment} />;
2424
}
2525

26+
/** Rows rendered before the remainder hides behind a reveal; a generated
27+
* file's patch can be thousands of lines and the tab must not paint them. */
28+
const ROW_LIMIT = 500;
29+
2630
export function UnifiedDiffRows({ rows, onLineComment }: { rows: UnifiedDiffRow[]; onLineComment?: (row: DiffCommentRow) => void }) {
2731
const { t } = useTranslation();
32+
const [showAll, setShowAll] = useState(false);
33+
const visible = showAll || rows.length <= ROW_LIMIT ? rows : rows.slice(0, ROW_LIMIT);
2834
return (
2935
<div className="overflow-x-auto border-t border-border/60 font-mono text-xs leading-[18px]">
30-
{rows.map((row, index) => {
36+
{visible.map((row, index) => {
3137
if (row.kind === 'hunk') {
3238
return <div key={index} className="px-2 text-muted-foreground">{row.content}</div>;
3339
}
@@ -56,6 +62,15 @@ export function UnifiedDiffRows({ rows, onLineComment }: { rows: UnifiedDiffRow[
5662
</div>
5763
);
5864
})}
65+
{rows.length > ROW_LIMIT && !showAll && (
66+
<button
67+
type="button"
68+
onClick={() => setShowAll(true)}
69+
className="block w-full px-2 py-1 text-left font-sans text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
70+
>
71+
{t('workspace.changes.moreLines', { count: rows.length - ROW_LIMIT })}
72+
</button>
73+
)}
5974
</div>
6075
);
6176
}

src/components/workspace/view/WorkspaceChangesTab.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ test('renders the loading state and refresh control before a static render can l
4747
test('renders a file row with counts, rename, and expanded unified diff', () => {
4848
const html = renderToStaticMarkup(createElement(ChangeRow, {
4949
file: changedFile,
50-
expanded: true,
51-
onToggle: () => {},
50+
openPath: changedFile.path,
51+
onSetOpenPath: () => {},
5252
onOpenInEditor: () => {},
5353
t,
5454
}));
@@ -64,8 +64,8 @@ test('renders a file row with counts, rename, and expanded unified diff', () =>
6464
test('a row with an insert target offers a line comment and sends the formatted draft', () => {
6565
const html = renderToStaticMarkup(createElement(ChangeRow, {
6666
file: changedFile,
67-
expanded: true,
68-
onToggle: () => {},
67+
openPath: changedFile.path,
68+
onSetOpenPath: () => {},
6969
onOpenInEditor: () => {},
7070
onComposerInsert: () => {},
7171
t,
@@ -74,7 +74,7 @@ test('a row with an insert target offers a line comment and sends the formatted
7474

7575
// Without an insert target the offer is absent.
7676
const bare = renderToStaticMarkup(createElement(ChangeRow, {
77-
file: changedFile, expanded: true, onToggle: () => {}, onOpenInEditor: () => {}, t,
77+
file: changedFile, openPath: changedFile.path, onSetOpenPath: () => {}, onOpenInEditor: () => {}, t,
7878
}));
7979
assert.doesNotMatch(bare, /comment\.add/);
8080
});

src/components/workspace/view/WorkspaceChangesTab.tsx

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ExternalLink, RefreshCw } from 'lucide-react';
2-
import { useState } from 'react';
2+
import { memo, useCallback, useState } from 'react';
33
import { useTranslation } from 'react-i18next';
44

55
import { api } from '../../../utils/api';
@@ -42,13 +42,13 @@ export default function WorkspaceChangesTab({
4242
const { files: lastTurnFiles, refresh: refreshLastTurn } = useLastTurnChanges(sessionId, active && scope === 'lastTurn');
4343
const [openPath, setOpenPath] = useState<string | null>(null);
4444

45-
const openInEditor = (path: string) => {
45+
const openInEditor = useCallback((path: string) => {
4646
if (!projectPath) {
4747
return;
4848
}
4949
const absolutePath = path.startsWith('/') ? path : `${projectPath.replace(/\/$/, '')}/${path}`;
5050
void api.system.openFile(absolutePath);
51-
};
51+
}, [projectPath]);
5252

5353
const refresh = () => {
5454
if (scope === 'workingTree') {
@@ -109,9 +109,10 @@ export default function WorkspaceChangesTab({
109109
<LastTurnChangeRow
110110
key={`${index}:${file.path}`}
111111
file={file}
112-
expanded={openPath === `${index}:${file.path}`}
113-
onToggle={() => setOpenPath((current) => current === `${index}:${file.path}` ? null : `${index}:${file.path}`)}
114-
onOpenInEditor={() => openInEditor(file.path)}
112+
rowKey={`${index}:${file.path}`}
113+
openPath={openPath}
114+
onSetOpenPath={setOpenPath}
115+
onOpenInEditor={openInEditor}
115116
onComposerInsert={onComposerInsert}
116117
t={t}
117118
/>
@@ -120,8 +121,11 @@ export default function WorkspaceChangesTab({
120121
) : state.kind === 'ready' ? (
121122
<>
122123
<div className="truncate border-b border-border/60 px-2.5 py-1.5 text-muted-foreground">
123-
{state.changes.branch ?? projectName ?? '—'} · {t('workspace.changes.files', { count: state.changes.files.length })}
124+
{state.changes.branch ?? projectName ?? '—'} · {t('workspace.changes.files', { count: state.changes.truncated ? state.changes.totalFiles : state.changes.files.length })}
124125
</div>
126+
{state.changes.truncated && (
127+
<p className="border-b border-border/60 px-2.5 py-1.5 text-muted-foreground">{t('workspace.changes.truncated', { shown: state.changes.files.length, total: state.changes.totalFiles })}</p>
128+
)}
125129
{!state.changes.hasCommits && (
126130
<p className="border-b border-border/60 px-2.5 py-1.5 text-muted-foreground">{t('workspace.changes.noCommits')}</p>
127131
)}
@@ -135,9 +139,9 @@ export default function WorkspaceChangesTab({
135139
<ChangeRow
136140
key={file.path}
137141
file={file}
138-
expanded={openPath === file.path}
139-
onToggle={() => setOpenPath((current) => current === file.path ? null : file.path)}
140-
onOpenInEditor={() => openInEditor(file.path)}
142+
openPath={openPath}
143+
onSetOpenPath={setOpenPath}
144+
onOpenInEditor={openInEditor}
141145
onComposerInsert={onComposerInsert}
142146
t={t}
143147
/>
@@ -157,22 +161,24 @@ export default function WorkspaceChangesTab({
157161
);
158162
}
159163

160-
export function ChangeRow({
164+
export const ChangeRow = memo(function ChangeRow({
161165
file,
162-
expanded,
163-
onToggle,
166+
openPath,
167+
onSetOpenPath,
164168
onOpenInEditor,
165169
onComposerInsert,
166170
t,
167171
}: {
168172
file: ProjectChange;
169-
expanded: boolean;
170-
onToggle: () => void;
171-
onOpenInEditor: () => void;
173+
openPath: string | null;
174+
onSetOpenPath: (path: string | null) => void;
175+
onOpenInEditor: (path: string) => void;
172176
onComposerInsert?: (text: string) => void;
173177
t: (key: string, options?: Record<string, unknown>) => string;
174178
}) {
175179
const [commentRow, setCommentRow] = useState<DiffCommentRow | null>(null);
180+
const expanded = openPath === file.path;
181+
const onToggle = () => onSetOpenPath(expanded ? null : file.path);
176182
const appearance = statusAppearance[file.status];
177183
return (
178184
<div className="border-b border-border/60 last:border-b-0">
@@ -194,7 +200,7 @@ export function ChangeRow({
194200
</button>
195201
<button
196202
type="button"
197-
onClick={onOpenInEditor}
203+
onClick={() => onOpenInEditor(file.path)}
198204
title={t('workspace.changes.openInEditor')}
199205
aria-label={t('workspace.changes.openInEditor')}
200206
className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
@@ -220,7 +226,7 @@ export function ChangeRow({
220226
)}
221227
</div>
222228
);
223-
}
229+
});
224230

225231
const lastTurnAppearance: Record<LastTurnFile['kind'], { label: string; className: string }> = {
226232
edit: { label: 'E', className: 'bg-muted text-muted-foreground' },
@@ -229,22 +235,26 @@ const lastTurnAppearance: Record<LastTurnFile['kind'], { label: string; classNam
229235
move: { label: 'M', className: 'bg-diff-added text-diff-added-foreground' },
230236
};
231237

232-
function LastTurnChangeRow({
238+
const LastTurnChangeRow = memo(function LastTurnChangeRow({
233239
file,
234-
expanded,
235-
onToggle,
240+
rowKey,
241+
openPath,
242+
onSetOpenPath,
236243
onOpenInEditor,
237244
onComposerInsert,
238245
t,
239246
}: {
240247
file: LastTurnFile;
241-
expanded: boolean;
242-
onToggle: () => void;
243-
onOpenInEditor: () => void;
248+
rowKey: string;
249+
openPath: string | null;
250+
onSetOpenPath: (path: string | null) => void;
251+
onOpenInEditor: (path: string) => void;
244252
onComposerInsert?: (text: string) => void;
245253
t: (key: string, options?: Record<string, unknown>) => string;
246254
}) {
247255
const [commentRow, setCommentRow] = useState<DiffCommentRow | null>(null);
256+
const expanded = openPath === rowKey;
257+
const onToggle = () => onSetOpenPath(expanded ? null : rowKey);
248258
const appearance = lastTurnAppearance[file.kind];
249259
return (
250260
<div className="border-b border-border/60 last:border-b-0">
@@ -255,7 +265,7 @@ function LastTurnChangeRow({
255265
{file.oldPath ? <>{file.oldPath} <span className="text-muted-foreground">{t('workspace.changes.renameArrow')}</span> {file.path}</> : file.path}
256266
</span>
257267
</button>
258-
<button type="button" onClick={onOpenInEditor} title={t('workspace.changes.openInEditor')} aria-label={t('workspace.changes.openInEditor')} className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground">
268+
<button type="button" onClick={() => onOpenInEditor(file.path)} title={t('workspace.changes.openInEditor')} aria-label={t('workspace.changes.openInEditor')} className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground">
259269
<ExternalLink className="h-3 w-3" />
260270
</button>
261271
</div>
@@ -275,7 +285,7 @@ function LastTurnChangeRow({
275285
)}
276286
</div>
277287
);
278-
}
288+
});
279289

280290

281291
const COMMENT_MARKER = { added: '+', removed: '-', context: ' ' } as const;

src/i18n/locales/de/common.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@
9696
"placeholder": "Kommentar zu dieser Zeile…",
9797
"send": "Kommentar an den Chat senden",
9898
"cancel": "Kommentar verwerfen"
99-
}
99+
},
100+
"truncated": "Zeige {{shown}} von {{total}} Dateien; die Liste wurde gekürzt.",
101+
"moreLines_one": "{{count}} weitere Zeile anzeigen",
102+
"moreLines_other": "{{count}} weitere Zeilen anzeigen"
100103
},
101104
"browser": {
102105
"address": "Webadresse",

src/i18n/locales/en/common.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@
9696
"placeholder": "Comment on this line…",
9797
"send": "Send comment to chat",
9898
"cancel": "Discard comment"
99-
}
99+
},
100+
"truncated": "Showing {{shown}} of {{total}} files; the list is capped.",
101+
"moreLines_one": "Show {{count}} more line",
102+
"moreLines_other": "Show {{count}} more lines"
100103
},
101104
"browser": {
102105
"address": "Web address",

src/i18n/locales/fr/common.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@
9696
"placeholder": "Commentaire sur cette ligne…",
9797
"send": "Envoyer le commentaire au chat",
9898
"cancel": "Abandonner le commentaire"
99-
}
99+
},
100+
"truncated": "Affichage de {{shown}} fichiers sur {{total}} ; la liste est tronquée.",
101+
"moreLines_one": "Afficher {{count}} ligne de plus",
102+
"moreLines_other": "Afficher {{count}} lignes de plus"
100103
},
101104
"browser": {
102105
"address": "Adresse Internet",

src/i18n/locales/it/common.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@
9696
"placeholder": "Commento a questa riga…",
9797
"send": "Invia il commento alla chat",
9898
"cancel": "Scarta il commento"
99-
}
99+
},
100+
"truncated": "Mostrati {{shown}} file su {{total}}; l’elenco è limitato.",
101+
"moreLines_one": "Mostra {{count}} altra riga",
102+
"moreLines_other": "Mostra {{count}} altre righe"
100103
},
101104
"browser": {
102105
"address": "Indirizzo web",

0 commit comments

Comments
 (0)