Skip to content

Commit cd61a2b

Browse files
committed
diff: zed-style split viewer + changes panel
1 parent 3ccdf77 commit cd61a2b

27 files changed

+2044
-3654
lines changed

packages/desktop/package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
"copy:assets": "mkdirp dist/infrastructure/database/migrations dist/windows dist/assets && cp src/infrastructure/database/*.sql dist/infrastructure/database/ && cp src/infrastructure/database/migrations/*.sql dist/infrastructure/database/migrations/ && cp src/windows/*.html dist/windows/ && cp assets/snowtree-logo.png dist/assets/",
1111
"lint": "eslint src --ext .ts",
1212
"typecheck": "tsc --noEmit",
13-
"test": "vitest",
14-
"test:watch": "vitest --watch",
15-
"test:coverage": "vitest --coverage",
16-
"test:ui": "vitest --ui",
17-
"test:ci": "vitest run --coverage"
13+
"test": "node scripts/vitest-electron.mjs",
14+
"test:watch": "node scripts/vitest-electron.mjs --watch",
15+
"test:coverage": "node scripts/vitest-electron.mjs run --coverage",
16+
"test:ui": "node scripts/vitest-electron.mjs --ui",
17+
"test:ci": "node scripts/vitest-electron.mjs run --coverage"
1818
},
1919
"devDependencies": {
2020
"@electron/rebuild": "^4.0.1",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { spawn } from 'node:child_process';
2+
import { createRequire } from 'node:module';
3+
4+
const require = createRequire(import.meta.url);
5+
6+
const electronPath = require('electron');
7+
const vitestEntry = require.resolve('vitest/vitest.mjs');
8+
9+
const argv = process.argv.slice(2);
10+
11+
const child = spawn(electronPath, [vitestEntry, ...argv], {
12+
stdio: 'inherit',
13+
env: {
14+
...process.env,
15+
ELECTRON_RUN_AS_NODE: '1',
16+
},
17+
});
18+
19+
child.on('exit', (code, signal) => {
20+
if (typeof code === 'number') process.exit(code);
21+
if (signal) process.exit(1);
22+
process.exit(1);
23+
});
24+

packages/desktop/src/features/git/DiffManager.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ export class GitDiffManager {
190190
const { stdout: trackedDiff } = await this.runGit({
191191
sessionId,
192192
cwd: worktreePath,
193-
argv: ['git', 'diff', '--color=never', '--src-prefix=a/', '--dst-prefix=b/', 'HEAD'],
193+
argv: ['git', 'diff', '--color=never', '--unified=0', '--src-prefix=a/', '--dst-prefix=b/', 'HEAD'],
194194
timeoutMs: 120_000,
195195
meta: { source: 'gitDiff', operation: 'diff-working' },
196196
});
@@ -238,7 +238,7 @@ export class GitDiffManager {
238238
const { stdout: diff } = await this.runGit({
239239
sessionId,
240240
cwd: worktreePath,
241-
argv: ['git', 'diff', '--cached', '--color=never', '--src-prefix=a/', '--dst-prefix=b/', 'HEAD'],
241+
argv: ['git', 'diff', '--cached', '--color=never', '--unified=0', '--src-prefix=a/', '--dst-prefix=b/', 'HEAD'],
242242
timeoutMs: 120_000,
243243
meta: { source: 'gitDiff', operation: 'diff-working-staged' },
244244
});
@@ -270,7 +270,7 @@ export class GitDiffManager {
270270
const { stdout: diff } = await this.runGit({
271271
sessionId,
272272
cwd: worktreePath,
273-
argv: ['git', 'diff', '--color=never', '--src-prefix=a/', '--dst-prefix=b/'],
273+
argv: ['git', 'diff', '--color=never', '--unified=0', '--src-prefix=a/', '--dst-prefix=b/'],
274274
timeoutMs: 120_000,
275275
meta: { source: 'gitDiff', operation: 'diff-working-unstaged' },
276276
});
@@ -437,7 +437,7 @@ export class GitDiffManager {
437437
const { stdout: diff } = await this.runGit({
438438
sessionId,
439439
cwd: worktreePath,
440-
argv: ['git', 'diff', '--color=never', '--src-prefix=a/', '--dst-prefix=b/', `${fromCommit}..${to}`],
440+
argv: ['git', 'diff', '--color=never', '--unified=0', '--src-prefix=a/', '--dst-prefix=b/', `${fromCommit}..${to}`],
441441
timeoutMs: 120_000,
442442
meta: { source: 'gitDiff', operation: 'diff-commit', fromCommit, toCommit: to },
443443
});
@@ -470,7 +470,7 @@ export class GitDiffManager {
470470
const { stdout: diff } = await this.runGit({
471471
sessionId,
472472
cwd: worktreePath,
473-
argv: ['git', 'show', '--color=never', '--src-prefix=a/', '--dst-prefix=b/', '--format=', hash],
473+
argv: ['git', 'show', '--color=never', '--unified=0', '--src-prefix=a/', '--dst-prefix=b/', '--format=', hash],
474474
timeoutMs: 120_000,
475475
meta: { source: 'gitDiff', operation: 'show', commit: hash },
476476
});

packages/desktop/src/features/git/StagingManager.ts

Lines changed: 247 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,35 @@ export interface StageLinesOptions {
3434
targetLine: TargetLine;
3535
}
3636

37+
export interface StageHunkOptions {
38+
worktreePath: string;
39+
sessionId: string;
40+
filePath: string;
41+
isStaging: boolean;
42+
hunkHeader: string;
43+
}
44+
45+
export interface RestoreHunkOptions {
46+
worktreePath: string;
47+
sessionId: string;
48+
filePath: string;
49+
scope: 'staged' | 'unstaged';
50+
hunkHeader: string;
51+
}
52+
53+
export interface ChangeAllStageOptions {
54+
worktreePath: string;
55+
sessionId: string;
56+
stage: boolean;
57+
}
58+
59+
export interface ChangeFileStageOptions {
60+
worktreePath: string;
61+
sessionId: string;
62+
filePath: string;
63+
stage: boolean;
64+
}
65+
3766
export interface StageLinesResult {
3867
success: boolean;
3968
error?: string;
@@ -116,6 +145,162 @@ export class GitStagingManager {
116145
}
117146
}
118147

148+
/**
149+
* Stage or unstage a full hunk (block)
150+
*/
151+
async stageHunk(options: StageHunkOptions): Promise<StageLinesResult> {
152+
try {
153+
const scope = options.isStaging ? 'unstaged' : 'staged';
154+
const fullDiff = await this.getFileDiff(options.worktreePath, options.filePath, scope, options.sessionId);
155+
156+
if (fullDiff.includes('Binary files differ')) {
157+
return { success: false, error: 'Cannot stage hunks of binary files' };
158+
}
159+
160+
const hunks = this.parseDiffIntoHunks(fullDiff);
161+
if (hunks.length === 0) {
162+
return { success: false, error: 'No changes found in diff' };
163+
}
164+
165+
const normalizedHeader = options.hunkHeader.trim();
166+
const targetHunk = hunks.find((h) => h.header.trim() === normalizedHeader);
167+
if (!targetHunk) {
168+
return { success: false, error: 'Target hunk not found in diff' };
169+
}
170+
171+
const patch = this.generateHunkPatch(targetHunk, options.filePath);
172+
return await this.applyPatch(options.worktreePath, patch, options.isStaging, options.sessionId, {
173+
operation: options.isStaging ? 'stage-hunk' : 'unstage-hunk',
174+
});
175+
} catch (error) {
176+
return {
177+
success: false,
178+
error: error instanceof Error ? error.message : 'Unknown error occurred',
179+
};
180+
}
181+
}
182+
183+
/**
184+
* Restore (discard) a specific hunk in the working tree.
185+
*
186+
* For staged hunks we first unstage the hunk, then attempt to restore the same patch in
187+
* the working tree (best-effort; may fail if the working tree differs from the index).
188+
*/
189+
async restoreHunk(options: RestoreHunkOptions): Promise<StageLinesResult> {
190+
try {
191+
const fullDiffScope = options.scope === 'staged' ? 'staged' : 'unstaged';
192+
const fullDiff = await this.getFileDiff(options.worktreePath, options.filePath, fullDiffScope, options.sessionId);
193+
194+
if (fullDiff.includes('Binary files differ')) {
195+
return { success: false, error: 'Cannot restore hunks of binary files' };
196+
}
197+
198+
const hunks = this.parseDiffIntoHunks(fullDiff);
199+
if (hunks.length === 0) {
200+
return { success: false, error: 'No changes found in diff' };
201+
}
202+
203+
const normalizedHeader = options.hunkHeader.trim();
204+
const targetHunk = hunks.find((h) => h.header.trim() === normalizedHeader);
205+
if (!targetHunk) {
206+
return { success: false, error: 'Target hunk not found in diff' };
207+
}
208+
209+
const patch = this.generateHunkPatch(targetHunk, options.filePath);
210+
211+
if (options.scope === 'staged') {
212+
const unstage = await this.applyPatch(options.worktreePath, patch, false, options.sessionId, {
213+
operation: 'restore-hunk-unstage',
214+
});
215+
if (!unstage.success) return unstage;
216+
}
217+
218+
const worktreeRestore = await this.applyWorktreePatch(options.worktreePath, patch, true, options.sessionId, {
219+
operation: 'restore-hunk-worktree',
220+
});
221+
if (!worktreeRestore.success) {
222+
return worktreeRestore;
223+
}
224+
225+
return { success: true };
226+
} catch (error) {
227+
return {
228+
success: false,
229+
error: error instanceof Error ? error.message : 'Unknown error occurred',
230+
};
231+
}
232+
}
233+
234+
/**
235+
* Stage or unstage all changes.
236+
*/
237+
async changeAllStage(options: ChangeAllStageOptions): Promise<StageLinesResult> {
238+
try {
239+
const argv = options.stage
240+
? ['git', 'add', '--all']
241+
: ['git', 'reset'];
242+
243+
const result = await this.gitExecutor.run({
244+
sessionId: options.sessionId,
245+
cwd: options.worktreePath,
246+
argv,
247+
op: 'write',
248+
recordTimeline: true,
249+
meta: { source: 'gitStaging', operation: options.stage ? 'stage-all' : 'unstage-all' },
250+
});
251+
252+
if (result.exitCode !== 0) {
253+
return { success: false, error: result.stderr || 'git command failed' };
254+
}
255+
256+
this.statusManager.clearSessionCache(options.sessionId);
257+
return { success: true };
258+
} catch (error) {
259+
return {
260+
success: false,
261+
error: error instanceof Error ? error.message : 'Unknown error occurred',
262+
};
263+
}
264+
}
265+
266+
/**
267+
* Stage or unstage a single file.
268+
*
269+
* - Stage: `git add --all -- <file>`
270+
* - Unstage: `git reset -- <file>`
271+
*/
272+
async changeFileStage(options: ChangeFileStageOptions): Promise<StageLinesResult> {
273+
try {
274+
const filePath = options.filePath.trim();
275+
if (!filePath) return { success: false, error: 'File path is required' };
276+
277+
const argv = options.stage
278+
? ['git', 'add', '--all', '--', filePath]
279+
: ['git', 'reset', '--', filePath];
280+
281+
const result = await this.gitExecutor.run({
282+
sessionId: options.sessionId,
283+
cwd: options.worktreePath,
284+
argv,
285+
op: 'write',
286+
recordTimeline: true,
287+
meta: { source: 'gitStaging', operation: options.stage ? 'stage-file' : 'unstage-file', filePath },
288+
});
289+
290+
if (result.exitCode !== 0) {
291+
return { success: false, error: result.stderr || 'git command failed' };
292+
}
293+
294+
this.statusManager.clearSessionCache(options.sessionId);
295+
return { success: true };
296+
} catch (error) {
297+
return {
298+
success: false,
299+
error: error instanceof Error ? error.message : 'Unknown error occurred',
300+
};
301+
}
302+
}
303+
119304
/**
120305
* Get diff for a specific file and scope
121306
*/
@@ -127,8 +312,8 @@ export class GitStagingManager {
127312
): Promise<string> {
128313
const argv =
129314
scope === 'staged'
130-
? ['git', 'diff', '--cached', '--color=never', '--src-prefix=a/', '--dst-prefix=b/', 'HEAD', '--', filePath]
131-
: ['git', 'diff', '--color=never', '--src-prefix=a/', '--dst-prefix=b/', '--', filePath];
315+
? ['git', 'diff', '--cached', '--color=never', '--unified=0', '--src-prefix=a/', '--dst-prefix=b/', 'HEAD', '--', filePath]
316+
: ['git', 'diff', '--color=never', '--unified=0', '--src-prefix=a/', '--dst-prefix=b/', '--', filePath];
132317

133318
const result = await this.gitExecutor.run({
134319
sessionId,
@@ -336,14 +521,72 @@ export class GitStagingManager {
336521
return patch;
337522
}
338523

524+
private generateHunkPatch(hunk: Hunk, filePath: string): string {
525+
const patch = [
526+
`diff --git a/${filePath} b/${filePath}`,
527+
`--- a/${filePath}`,
528+
`+++ b/${filePath}`,
529+
hunk.header,
530+
...hunk.lines.map((l) => l.text),
531+
'',
532+
].join('\n');
533+
534+
return patch;
535+
}
536+
537+
/**
538+
* Apply patch to working tree using git apply (not --cached).
539+
*/
540+
private async applyWorktreePatch(
541+
worktreePath: string,
542+
patch: string,
543+
reverse: boolean,
544+
sessionId: string,
545+
meta?: { operation: string }
546+
): Promise<StageLinesResult> {
547+
const tempFile = path.join(os.tmpdir(), `snowtree-worktree-patch-${Date.now()}.patch`);
548+
549+
try {
550+
await fs.writeFile(tempFile, patch, 'utf8');
551+
552+
const argv = [
553+
'git',
554+
'apply',
555+
'--unidiff-zero',
556+
'--whitespace=nowarn',
557+
...(reverse ? ['-R'] : []),
558+
tempFile,
559+
];
560+
561+
const result = await this.gitExecutor.run({
562+
sessionId,
563+
cwd: worktreePath,
564+
argv,
565+
op: 'write',
566+
recordTimeline: true,
567+
meta: { source: 'gitStaging', operation: meta?.operation ?? (reverse ? 'apply-reverse' : 'apply') },
568+
});
569+
570+
if (result.exitCode !== 0) {
571+
return { success: false, error: result.stderr || 'git apply failed' };
572+
}
573+
574+
this.statusManager.clearSessionCache(sessionId);
575+
return { success: true };
576+
} finally {
577+
await fs.unlink(tempFile).catch(() => {});
578+
}
579+
}
580+
339581
/**
340582
* Apply patch using git apply --cached
341583
*/
342584
private async applyPatch(
343585
worktreePath: string,
344586
patch: string,
345587
isStaging: boolean,
346-
sessionId: string
588+
sessionId: string,
589+
meta?: { operation: string }
347590
): Promise<StageLinesResult> {
348591
// Write patch to temp file
349592
const tempFile = path.join(os.tmpdir(), `snowtree-patch-${Date.now()}.patch`);
@@ -370,7 +613,7 @@ export class GitStagingManager {
370613
recordTimeline: true,
371614
meta: {
372615
source: 'gitStaging',
373-
operation: isStaging ? 'stage-line' : 'unstage-line',
616+
operation: meta?.operation ?? (isStaging ? 'stage-line' : 'unstage-line'),
374617
},
375618
});
376619

0 commit comments

Comments
 (0)