Skip to content

Commit 3df9ba6

Browse files
committed
feat(workspace): a Changes tab reading the project's working tree
The workspace panel gains a third tab between Status and Browser: the project's changed files with status, rename pairs and +/- counts, each row expanding to its unified diff (one open at a time) with paired line numbers in the diff tokens the edit cards already use. Binary and oversized files say so instead of showing a diff; every row keeps its open-in-editor action. Git is only read while the tab is visible, like the Status tab. Untracked counts come from the patch itself, client-side. Locales updated in all ten languages.
1 parent 253cd21 commit 3df9ba6

20 files changed

Lines changed: 631 additions & 3 deletions
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
3+
import { api } from '../../../utils/api';
4+
5+
export type ProjectChange = {
6+
path: string;
7+
oldPath: string | null;
8+
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked';
9+
staged: boolean;
10+
additions: number;
11+
deletions: number;
12+
patch: string | null;
13+
binary: boolean;
14+
tooLarge: boolean;
15+
};
16+
17+
export type ProjectChanges = {
18+
branch: string | null;
19+
hasCommits: boolean;
20+
files: ProjectChange[];
21+
};
22+
23+
export type ProjectChangesState =
24+
| { kind: 'idle' }
25+
| { kind: 'loading' }
26+
| { kind: 'ready'; changes: ProjectChanges }
27+
| { kind: 'not-a-repository' }
28+
| { kind: 'unavailable' };
29+
30+
function readChanges(body: Record<string, unknown>): ProjectChanges {
31+
const branch = typeof body.branch === 'string' && body.branch.trim() ? body.branch.trim() : null;
32+
const files = Array.isArray(body.files) ? body.files.flatMap((value): ProjectChange[] => {
33+
if (!value || typeof value !== 'object') {
34+
return [];
35+
}
36+
const file = value as Record<string, unknown>;
37+
const status = file.status;
38+
if (
39+
typeof file.path !== 'string'
40+
|| !['added', 'modified', 'deleted', 'renamed', 'untracked'].includes(String(status))
41+
) {
42+
return [];
43+
}
44+
// The server counts a tracked file's lines with numstat but has no cheap
45+
// count for an untracked one; its patch is the whole file, countable here.
46+
const patch = typeof file.patch === 'string' ? file.patch : null;
47+
const counted = status === 'untracked' && patch
48+
? {
49+
additions: patch.split('\n').filter((line) => line.startsWith('+') && !line.startsWith('+++')).length,
50+
deletions: patch.split('\n').filter((line) => line.startsWith('-') && !line.startsWith('---')).length,
51+
}
52+
: {};
53+
return [{
54+
path: file.path,
55+
oldPath: typeof file.oldPath === 'string' ? file.oldPath : null,
56+
status: status as ProjectChange['status'],
57+
staged: file.staged === true,
58+
additions: typeof file.additions === 'number' ? file.additions : 0,
59+
deletions: typeof file.deletions === 'number' ? file.deletions : 0,
60+
...counted,
61+
patch,
62+
binary: file.binary === true,
63+
tooLarge: file.tooLarge === true,
64+
}];
65+
}) : [];
66+
67+
return { branch, hasCommits: body.hasCommits !== false, files };
68+
}
69+
70+
export function useProjectChanges(projectId: string | undefined, enabled: boolean) {
71+
const [state, setState] = useState<ProjectChangesState>({ kind: 'idle' });
72+
const requestRef = useRef(0);
73+
74+
const load = useCallback(async () => {
75+
if (!projectId) {
76+
setState({ kind: 'idle' });
77+
return;
78+
}
79+
80+
const requestId = ++requestRef.current;
81+
const publish = (next: ProjectChangesState) => {
82+
if (requestId === requestRef.current) {
83+
setState(next);
84+
}
85+
};
86+
87+
publish({ kind: 'loading' });
88+
try {
89+
const response = await api.get(`/git/diff?project=${encodeURIComponent(projectId)}`);
90+
const body = await response.json() as Record<string, unknown>;
91+
if (typeof body.error === 'string') {
92+
publish(/not a git repository/i.test(body.error) ? { kind: 'not-a-repository' } : { kind: 'unavailable' });
93+
return;
94+
}
95+
publish({ kind: 'ready', changes: readChanges(body) });
96+
} catch {
97+
publish({ kind: 'unavailable' });
98+
}
99+
}, [projectId]);
100+
101+
useEffect(() => {
102+
if (!enabled) {
103+
return;
104+
}
105+
void load();
106+
}, [enabled, load]);
107+
108+
return { state, refresh: load };
109+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
4+
import { parseUnifiedDiff } from './unifiedDiff';
5+
6+
test('parses hunk headers and assigns paired old and new line numbers', () => {
7+
const rows = parseUnifiedDiff([
8+
'diff --git a/example.ts b/example.ts',
9+
'index 1111111..2222222 100644',
10+
'--- a/example.ts',
11+
'+++ b/example.ts',
12+
'@@ -2,2 +2,3 @@ function example() {',
13+
' unchanged();',
14+
'-old();',
15+
'+new();',
16+
'+another();',
17+
].join('\n'));
18+
19+
assert.deepEqual(rows, [
20+
{ kind: 'hunk', content: '@@ -2,2 +2,3 @@ function example() {' },
21+
{ kind: 'context', content: 'unchanged();', oldLine: 2, newLine: 2 },
22+
{ kind: 'removed', content: 'old();', oldLine: 3, newLine: null },
23+
{ kind: 'added', content: 'new();', oldLine: null, newLine: 3 },
24+
{ kind: 'added', content: 'another();', oldLine: null, newLine: 4 },
25+
]);
26+
});
27+
28+
test('drops rename preambles and returns no rows for an empty patch', () => {
29+
assert.deepEqual(parseUnifiedDiff([
30+
'diff --git a/old-name.ts b/new-name.ts',
31+
'similarity index 100%',
32+
'rename from old-name.ts',
33+
'rename to new-name.ts',
34+
].join('\n')), []);
35+
assert.deepEqual(parseUnifiedDiff(''), []);
36+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export type UnifiedDiffRow =
2+
| { kind: 'hunk'; content: string }
3+
| { kind: 'context' | 'added' | 'removed'; content: string; oldLine: number | null; newLine: number | null };
4+
5+
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
6+
7+
/** Parses the displayable portion of a unified diff without interpreting file contents. */
8+
export function parseUnifiedDiff(patch: string): UnifiedDiffRow[] {
9+
const rows: UnifiedDiffRow[] = [];
10+
let oldLine = 0;
11+
let newLine = 0;
12+
let inHunk = false;
13+
14+
for (const line of patch.split('\n')) {
15+
const header = HUNK_HEADER.exec(line);
16+
if (header) {
17+
oldLine = Number(header[1]);
18+
newLine = Number(header[3]);
19+
inHunk = true;
20+
rows.push({ kind: 'hunk', content: line });
21+
continue;
22+
}
23+
if (!inHunk || line === '\\ No newline at end of file') {
24+
continue;
25+
}
26+
if (line.startsWith('+')) {
27+
rows.push({ kind: 'added', content: line.slice(1), oldLine: null, newLine });
28+
newLine += 1;
29+
} else if (line.startsWith('-')) {
30+
rows.push({ kind: 'removed', content: line.slice(1), oldLine, newLine: null });
31+
oldLine += 1;
32+
} else if (line.startsWith(' ')) {
33+
rows.push({ kind: 'context', content: line.slice(1), oldLine, newLine });
34+
oldLine += 1;
35+
newLine += 1;
36+
}
37+
}
38+
39+
return rows;
40+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useMemo } from 'react';
2+
3+
import { parseUnifiedDiff } from '../utils/unifiedDiff';
4+
5+
export type UnifiedDiffProps = {
6+
patch: string;
7+
};
8+
9+
export default function UnifiedDiff({ patch }: UnifiedDiffProps) {
10+
const rows = useMemo(() => parseUnifiedDiff(patch), [patch]);
11+
12+
return (
13+
<div className="overflow-x-auto border-t border-border/60 font-mono text-xs leading-[18px]">
14+
{rows.map((row, index) => {
15+
if (row.kind === 'hunk') {
16+
return <div key={index} className="px-2 text-muted-foreground">{row.content}</div>;
17+
}
18+
const appearance = row.kind === 'added'
19+
? { className: 'bg-diff-added text-diff-added-foreground', marker: '+' }
20+
: row.kind === 'removed'
21+
? { className: 'bg-diff-removed text-diff-removed-foreground', marker: '-' }
22+
: { className: 'text-muted-foreground', marker: ' ' };
23+
return (
24+
<div key={index} className={`flex min-w-0 ${appearance.className}`}>
25+
<span className="w-6 shrink-0 text-center select-none">{appearance.marker}</span>
26+
<span className="w-10 shrink-0 px-1 text-right text-muted-foreground/70 select-none">{row.oldLine ?? ''}</span>
27+
<span className="w-10 shrink-0 px-1 text-right text-muted-foreground/70 select-none">{row.newLine ?? ''}</span>
28+
<span className="min-w-0 flex-1 px-2 break-all whitespace-pre-wrap">{row.content}</span>
29+
</div>
30+
);
31+
})}
32+
</div>
33+
);
34+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
4+
import { createElement } from 'react';
5+
import { renderToStaticMarkup } from 'react-dom/server';
6+
7+
import type { ProjectChange } from '../hooks/useProjectChanges';
8+
9+
import WorkspaceChangesTab, { ChangeRow, type WorkspaceChangesTabProps } from './WorkspaceChangesTab';
10+
11+
const t = (key: string) => key;
12+
const changedFile: ProjectChange = {
13+
path: 'src/new-name.ts',
14+
oldPath: 'src/old-name.ts',
15+
status: 'renamed',
16+
staged: false,
17+
additions: 2,
18+
deletions: 1,
19+
patch: '@@ -1 +1,2 @@\n-old\n+new\n+another',
20+
binary: false,
21+
tooLarge: false,
22+
};
23+
24+
function render(overrides: Partial<WorkspaceChangesTabProps> = {}): string {
25+
return renderToStaticMarkup(createElement(WorkspaceChangesTab, {
26+
projectId: 'project-alpha',
27+
projectPath: '/work/alpha',
28+
projectName: 'Alpha Workspace',
29+
active: true,
30+
...overrides,
31+
}));
32+
}
33+
34+
test('renders the loading state and refresh control before a static render can load changes', () => {
35+
const html = render();
36+
37+
assert.match(html, /workspace\.changes\.loading/);
38+
assert.match(html, /aria-label="workspace\.changes\.refreshLabel"/);
39+
});
40+
41+
test('renders a file row with counts, rename, and expanded unified diff', () => {
42+
const html = renderToStaticMarkup(createElement(ChangeRow, {
43+
file: changedFile,
44+
expanded: true,
45+
onToggle: () => {},
46+
onOpenInEditor: () => {},
47+
t,
48+
}));
49+
50+
assert.match(html, /src\/old-name\.ts/);
51+
assert.match(html, /src\/new-name\.ts/);
52+
assert.match(html, /\+2/);
53+
assert.match(html, /-1/);
54+
assert.match(html, /old/);
55+
assert.match(html, /another/);
56+
});

0 commit comments

Comments
 (0)