feat(web): enhanced file panel — side-by-side diff, hide whitespace, inline review

Adds DiffSplitView component for side-by-side diff mode, whitespace-only
change filtering, inline review comments with thread/gutter cell UI, diff
preferences persistence, and write-file API support for in-browser editing.

Backend: hideWhitespace param on git diff endpoint, write_file route.
This commit is contained in:
2026-06-07 22:16:20 +00:00
parent c935687725
commit 31d8efe66a
15 changed files with 1247 additions and 47 deletions

View File

@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { realpath, stat, readdir, access } from 'node:fs/promises';
import { realpath, stat, readdir, access, writeFile, rename } from 'node:fs/promises';
import { basename, resolve, sep } from 'node:path';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
@@ -473,7 +473,7 @@ export function registerProjectRoutes(
// Always includes auto_mode (the dirty-state-derived mode) so the client can
// show a suggestion when a pinned mode diverges from what would be auto-selected.
// Returns { git_repo: false } when the path is not a git repository.
app.get<{ Params: { id: string }; Querystring: { mode?: string } }>(
app.get<{ Params: { id: string }; Querystring: { mode?: string; whitespace?: string } }>(
'/api/projects/:id/git/diff',
async (req, reply) => {
const { id } = req.params;
@@ -504,7 +504,8 @@ export function registerProjectRoutes(
rawMode === 'uncommitted' ? 'uncommitted' :
auto_mode; // no mode param → auto-select (FIX 1)
const result = await getGitDiff(projectRoot, mode);
const ignoreWhitespace = req.query.whitespace === '1';
const result = await getGitDiff(projectRoot, mode, ignoreWhitespace);
if (result === null) {
return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] };
}
@@ -541,6 +542,11 @@ export function registerProjectRoutes(
).min(1),
});
const WriteFileBody = z.object({
path: z.string().min(1),
content: z.string(),
});
// POST /api/projects/:id/git/stage — stage whole files
app.post<{ Params: { id: string } }>(
'/api/projects/:id/git/stage',
@@ -637,6 +643,38 @@ export function registerProjectRoutes(
},
);
// POST /api/projects/:id/write_file — write a file atomically
app.post<{ Params: { id: string } }>(
'/api/projects/:id/write_file',
async (req, reply) => {
const body = WriteFileBody.safeParse(req.body);
if (!body.success) { reply.code(400); return { error: body.error.message }; }
const { id } = req.params;
const projectPath = await selectProjectPath(sql, id);
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
let root: string;
try { root = await resolveProjectRoot(projectPath); }
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
const target = body.data.path.startsWith('/') ? body.data.path : resolve(root, body.data.path);
// Validate path stays within project root
const realTarget = await realpath(target).catch(() => target);
if (!realTarget.startsWith(root + sep) && realTarget !== root) {
reply.code(403);
return { error: 'path escapes project root' };
}
const tmp = target + '.tmp';
try {
await writeFile(tmp, body.data.content, 'utf-8');
await rename(tmp, target);
return { ok: true };
} catch (err) {
// Clean up tmp on failure
await access(tmp).then(() => rename(tmp, target + '.bak').catch(() => {})).catch(() => {});
throw err;
}
},
);
// GET /api/projects/:id/files
app.get<{ Params: { id: string } }>(
'/api/projects/:id/files',

View File

@@ -271,7 +271,9 @@ function buildNumstatMap(
async function getUncommittedDiff(
gitRoot: string,
inProgress: string | null,
ignoreWhitespace = false,
): Promise<GitDiffResult> {
const ws = ignoreWhitespace ? ['-w'] : [];
const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null;
const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] =
@@ -284,10 +286,10 @@ async function getUncommittedDiff(
: runGit(['diff', '--cached', '--name-status'], gitRoot),
runGit(['ls-files', '--others', '--exclude-standard'], gitRoot),
hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''),
hasCommits ? runGit(['diff', 'HEAD'], gitRoot) : Promise.resolve(''),
hasCommits ? runGit(['diff', ...ws, 'HEAD'], gitRoot) : Promise.resolve(''),
hasCommits
? runGit(['diff', '--cached', 'HEAD'], gitRoot)
: runGit(['diff', '--cached'], gitRoot),
? runGit(['diff', ...ws, '--cached', 'HEAD'], gitRoot)
: runGit(['diff', ...ws, '--cached'], gitRoot),
]);
const allChanged = parseNameStatus(nameStatusOut ?? '');
@@ -347,11 +349,13 @@ async function getCommittedDiff(
base: string,
label: string,
inProgress: string | null,
ignoreWhitespace = false,
): Promise<GitDiffResult> {
const ws = ignoreWhitespace ? ['-w'] : [];
const [nameStatusOut, numstatOut, diffOut] = await Promise.all([
runGit(['diff', '--name-status', base, 'HEAD'], gitRoot),
runGit(['diff', '--numstat', base, 'HEAD'], gitRoot),
runGit(['diff', base, 'HEAD'], gitRoot),
runGit(['diff', ...ws, base, 'HEAD'], gitRoot),
]);
const allChanged = parseNameStatus(nameStatusOut ?? '');
@@ -383,23 +387,23 @@ async function getCommittedDiff(
* the directory is not a git repository. On a null committed-mode base, falls
* back to uncommitted and labels the result accordingly.
*/
export async function getGitDiff(cwd: string, mode: GitDiffMode): Promise<GitDiffResult | null> {
export async function getGitDiff(cwd: string, mode: GitDiffMode, ignoreWhitespace?: boolean): Promise<GitDiffResult | null> {
const gitRoot = await resolveGitRoot(cwd);
if (!gitRoot) return null;
const inProgress = await detectInProgress(gitRoot);
if (mode === 'uncommitted') {
return getUncommittedDiff(gitRoot, inProgress);
return getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
}
const { base, label } = await resolveCommittedBase(gitRoot);
if (!base) {
// Fall back to uncommitted with a descriptive label
const result = await getUncommittedDiff(gitRoot, inProgress);
const result = await getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
return { ...result, base_label: label };
}
return getCommittedDiff(gitRoot, base, label, inProgress);
return getCommittedDiff(gitRoot, base, label, inProgress, ignoreWhitespace ?? false);
}
// ── Phase 2: Write helpers ─────────────────────────────────────────────────