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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user