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.
409 lines
16 KiB
TypeScript
409 lines
16 KiB
TypeScript
import { readFile, writeFile, unlink, mkdir, rename, realpath } from 'node:fs/promises';
|
||
import { dirname, join, basename } from 'node:path';
|
||
import { randomBytes } from 'node:crypto';
|
||
import type { Sql } from '../db.js';
|
||
import { resolveWritePath } from './write_guard.js';
|
||
import { locateMatch } from './fuzzy-match.js';
|
||
|
||
/**
|
||
* Write a file atomically: stage to a sibling temp file, then rename over the
|
||
* target. rename(2) on the same filesystem is atomic, so a crash mid-write can
|
||
* never leave a half-written (truncated/corrupt) source file — readers see
|
||
* either the old content or the complete new content. The temp lives in the same
|
||
* directory to guarantee a same-filesystem rename.
|
||
*
|
||
* Symlinks: a plain writeFile FOLLOWS a symlink and writes through to its target;
|
||
* a bare rename would REPLACE the link with a regular file. We realpath an
|
||
* existing target first so the rename lands on the real file and the link
|
||
* survives — preserving the prior follow-through behavior. A missing target
|
||
* (create, or a broken link) just writes the literal path.
|
||
*/
|
||
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
|
||
let target = filePath;
|
||
try {
|
||
target = await realpath(filePath);
|
||
} catch {
|
||
// ENOENT (new file) or broken link — write the literal path.
|
||
}
|
||
const tmp = join(dirname(target), `.${basename(target)}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`);
|
||
await writeFile(tmp, content, 'utf8');
|
||
try {
|
||
await rename(tmp, target);
|
||
} catch (err) {
|
||
await unlink(tmp).catch(() => {});
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/** Detect a file's dominant line ending so an edit can preserve it. */
|
||
function detectEol(text: string): '\r\n' | '\n' {
|
||
return text.includes('\r\n') ? '\r\n' : '\n';
|
||
}
|
||
|
||
/**
|
||
* Serialize the read-modify-write of a single file so two concurrent applies
|
||
* (e.g. two chat tabs sharing one worktree, or a Bypass write racing an
|
||
* apply_pending) can't lose an update. In-process keying is sufficient —
|
||
* BooCoder is a single Fastify process. One Map entry per distinct path.
|
||
*/
|
||
const fileLocks = new Map<string, Promise<void>>();
|
||
async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
||
const prev = fileLocks.get(filePath) ?? Promise.resolve();
|
||
let release!: () => void;
|
||
const current = new Promise<void>((r) => { release = r; });
|
||
fileLocks.set(filePath, prev.then(() => current));
|
||
await prev.catch(() => {});
|
||
try {
|
||
return await fn();
|
||
} finally {
|
||
release();
|
||
}
|
||
}
|
||
|
||
// --- Edit-apply planning (pure, unit-tested) ---------------------------------
|
||
|
||
/**
|
||
* Decision for applying one queued edit to a file's current content. Pulled out
|
||
* of `applyOne` so the splice — the part that actually corrupted files — is pure
|
||
* and testable without a DB or filesystem. Mirrors how opencode/cline/qwen keep
|
||
* their matchers fail-closed and idempotent.
|
||
*/
|
||
export type EditPlan =
|
||
| { kind: 'apply'; updated: string }
|
||
| { kind: 'noop'; reason: 'identical' | 'already-applied' }
|
||
| { kind: 'ambiguous'; count: number }
|
||
| { kind: 'not_found' };
|
||
|
||
/**
|
||
* Decide how (or whether) to apply an `old → new` edit to `content`.
|
||
*
|
||
* Idempotency is the whole point here: a queued edit can legitimately be
|
||
* re-applied (a local model re-emits the same tool call; a turn is retried; the
|
||
* same change sits in the queue twice). A naive splice stamps the new text again
|
||
* each time — the 2–3× block duplication. Two guards make re-application a no-op:
|
||
*
|
||
* - already-applied (anchored insert): when `new` is `old` + an appended block
|
||
* (`old="anchor"`, `new="anchor\n<block>"`), `old` still matches uniquely after
|
||
* the first apply, so a second apply would duplicate `<block>`. If the full
|
||
* `new` text is already present at the match site, the edit is already applied.
|
||
* - already-applied (old gone): if `old` can't be located but `new` is already
|
||
* in the file, the change landed on a prior pass — treat as a no-op, not an error.
|
||
* - identical: the splice would not change the file.
|
||
*
|
||
* Anything ambiguous or genuinely absent fails CLOSED so the caller surfaces a
|
||
* correctable error instead of writing a guess.
|
||
*/
|
||
export function planEdit(content: string, oldStr: string, newStr: string): EditPlan {
|
||
const match = locateMatch(content, oldStr);
|
||
|
||
if (match.kind === 'ambiguous') return { kind: 'ambiguous', count: match.count };
|
||
|
||
if (match.kind === 'not_found') {
|
||
if (newStr.length > 0 && content.includes(newStr)) {
|
||
return { kind: 'noop', reason: 'already-applied' };
|
||
}
|
||
return { kind: 'not_found' };
|
||
}
|
||
|
||
const updated = content.slice(0, match.start) + newStr + content.slice(match.end);
|
||
// No-change splice first (covers old === new), then the anchored re-stamp guard:
|
||
// the full replacement already sits at the match site (re-emitted anchored insert).
|
||
if (updated === content) return { kind: 'noop', reason: 'identical' };
|
||
if (content.slice(match.start, match.start + newStr.length) === newStr) {
|
||
return { kind: 'noop', reason: 'already-applied' };
|
||
}
|
||
return { kind: 'apply', updated };
|
||
}
|
||
|
||
// --- Types -------------------------------------------------------------------
|
||
|
||
export interface PendingChange {
|
||
id: string;
|
||
session_id: string;
|
||
task_id: string | null;
|
||
file_path: string;
|
||
operation: 'create' | 'edit' | 'delete';
|
||
diff: string;
|
||
status: 'pending' | 'applied' | 'rejected' | 'reverted';
|
||
// v2.6 Phase 1-UX: which agent staged this change (DiffPanel attribution).
|
||
// Native boocode write tools stamp 'boocode'; the manual RightRail create path
|
||
// passes null (renders as "manual"). NULL on legacy rows queued pre-v2.6.
|
||
agent: string | null;
|
||
created_at: string;
|
||
}
|
||
|
||
export interface ApplyResult {
|
||
id: string;
|
||
file_path: string;
|
||
operation: string;
|
||
success: boolean;
|
||
error?: string;
|
||
}
|
||
|
||
// --- Queue functions ---------------------------------------------------------
|
||
|
||
export async function queueEdit(
|
||
sql: Sql,
|
||
sessionId: string,
|
||
taskId: string | null,
|
||
filePath: string,
|
||
oldString: string,
|
||
newString: string,
|
||
projectRoot: string,
|
||
// v2.6 Phase 1-UX: attribution. Defaults to 'boocode' because the only callers
|
||
// that omit it are the native write tools (edit_file/create_file/delete_file).
|
||
// Pass null explicitly for the manual RightRail create path.
|
||
agent: string | null = 'boocode',
|
||
): Promise<PendingChange> {
|
||
const resolved = resolveWritePath(projectRoot, filePath);
|
||
const diff = JSON.stringify({ old: oldString, new: newString });
|
||
|
||
// Idempotent queue: collapse an identical edit that is still pending. Local
|
||
// quantized models re-emit the same edit_file call within a turn, and a retried
|
||
// turn re-queues — each duplicate row would apply and stamp another copy. One
|
||
// pending row per (session, file, operation, diff) is enough.
|
||
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'edit', diff);
|
||
if (existing) return existing;
|
||
|
||
const [row] = await sql<PendingChange[]>`
|
||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent})
|
||
RETURNING *
|
||
`;
|
||
return row!;
|
||
}
|
||
|
||
/** Return an identical still-pending change for this (session, file, op, diff),
|
||
* or undefined. Used to keep the queue idempotent against re-emitted edits. */
|
||
async function findPendingDuplicate(
|
||
sql: Sql,
|
||
sessionId: string,
|
||
resolvedPath: string,
|
||
operation: 'create' | 'edit' | 'delete',
|
||
diff: string,
|
||
): Promise<PendingChange | undefined> {
|
||
const [row] = await sql<PendingChange[]>`
|
||
SELECT * FROM pending_changes
|
||
WHERE session_id = ${sessionId}
|
||
AND file_path = ${resolvedPath}
|
||
AND operation = ${operation}
|
||
AND diff = ${diff}
|
||
AND status = 'pending'
|
||
ORDER BY created_at ASC
|
||
LIMIT 1
|
||
`;
|
||
return row;
|
||
}
|
||
|
||
export async function queueCreate(
|
||
sql: Sql,
|
||
sessionId: string,
|
||
taskId: string | null,
|
||
filePath: string,
|
||
content: string,
|
||
projectRoot: string,
|
||
// See queueEdit: defaults to 'boocode' for the native write tools; the manual
|
||
// RightRail create route passes null.
|
||
agent: string | null = 'boocode',
|
||
): Promise<PendingChange> {
|
||
const resolved = resolveWritePath(projectRoot, filePath);
|
||
|
||
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'create', content);
|
||
if (existing) return existing;
|
||
|
||
const [row] = await sql<PendingChange[]>`
|
||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent})
|
||
RETURNING *
|
||
`;
|
||
return row!;
|
||
}
|
||
|
||
export async function queueDelete(
|
||
sql: Sql,
|
||
sessionId: string,
|
||
taskId: string | null,
|
||
filePath: string,
|
||
projectRoot: string,
|
||
// See queueEdit: defaults to 'boocode' for the native write tools.
|
||
agent: string | null = 'boocode',
|
||
): Promise<PendingChange> {
|
||
const resolved = resolveWritePath(projectRoot, filePath);
|
||
|
||
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'delete', '');
|
||
if (existing) return existing;
|
||
|
||
const [row] = await sql<PendingChange[]>`
|
||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent})
|
||
RETURNING *
|
||
`;
|
||
return row!;
|
||
}
|
||
|
||
// --- Apply functions ---------------------------------------------------------
|
||
|
||
export async function applyOne(
|
||
sql: Sql,
|
||
changeId: string,
|
||
projectRoot: string,
|
||
): Promise<ApplyResult> {
|
||
const [change] = await sql<PendingChange[]>`
|
||
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'pending'
|
||
`;
|
||
if (!change) {
|
||
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not pending' };
|
||
}
|
||
|
||
try {
|
||
return await withFileLock(change.file_path, async () => {
|
||
// Re-validate path in case projectRoot has shifted
|
||
resolveWritePath(projectRoot, change.file_path);
|
||
|
||
switch (change.operation) {
|
||
case 'create': {
|
||
await mkdir(dirname(change.file_path), { recursive: true });
|
||
await writeFileAtomic(change.file_path, change.diff);
|
||
break;
|
||
}
|
||
case 'edit': {
|
||
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||
const raw = await readFile(change.file_path, 'utf8');
|
||
// Normalize to LF for matching, then write back in the file's native EOL
|
||
// so an LF-emitting model doesn't leave a CRLF file with mixed endings.
|
||
const eol = detectEol(raw);
|
||
const toLf = (t: string) => t.replaceAll('\r\n', '\n');
|
||
const plan = planEdit(toLf(raw), toLf(oldStr), toLf(newStr));
|
||
if (plan.kind === 'ambiguous') {
|
||
throw new Error(
|
||
`old_string matches ${plan.count} locations — add surrounding context to disambiguate`,
|
||
);
|
||
}
|
||
if (plan.kind === 'not_found') {
|
||
throw new Error(
|
||
'old_string not found in file (even fuzzily) — file may have changed since the edit was queued',
|
||
);
|
||
}
|
||
if (plan.kind === 'apply') {
|
||
const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated;
|
||
await writeFileAtomic(change.file_path, out);
|
||
} else {
|
||
// noop: the edit is already applied (re-emitted / retried) or a no-change.
|
||
// Mark it applied without rewriting so it can't stamp a duplicate.
|
||
console.log(`[pending] edit ${change.file_path} is a no-op (${plan.reason}) — not rewriting`);
|
||
}
|
||
break;
|
||
}
|
||
case 'delete': {
|
||
// Stash current content in diff for potential rewind
|
||
try {
|
||
const existing = await readFile(change.file_path, 'utf8');
|
||
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
|
||
} catch {
|
||
// File may already be gone — proceed with status update
|
||
}
|
||
await unlink(change.file_path);
|
||
break;
|
||
}
|
||
}
|
||
|
||
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
|
||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||
});
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
||
}
|
||
}
|
||
|
||
export async function applyAll(
|
||
sql: Sql,
|
||
sessionId: string,
|
||
projectRoot: string,
|
||
): Promise<ApplyResult[]> {
|
||
const pending = await sql<PendingChange[]>`
|
||
SELECT * FROM pending_changes
|
||
WHERE session_id = ${sessionId} AND status = 'pending'
|
||
ORDER BY created_at ASC
|
||
`;
|
||
const results: ApplyResult[] = [];
|
||
for (const change of pending) {
|
||
results.push(await applyOne(sql, change.id, projectRoot));
|
||
}
|
||
return results;
|
||
}
|
||
|
||
// --- Reject functions --------------------------------------------------------
|
||
|
||
export async function rejectOne(sql: Sql, changeId: string): Promise<void> {
|
||
await sql`UPDATE pending_changes SET status = 'rejected' WHERE id = ${changeId} AND status = 'pending'`;
|
||
}
|
||
|
||
// --- Rewind functions --------------------------------------------------------
|
||
|
||
export async function rewindOne(
|
||
sql: Sql,
|
||
changeId: string,
|
||
projectRoot: string,
|
||
): Promise<ApplyResult> {
|
||
const [change] = await sql<PendingChange[]>`
|
||
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'applied'
|
||
`;
|
||
if (!change) {
|
||
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not applied' };
|
||
}
|
||
|
||
try {
|
||
resolveWritePath(projectRoot, change.file_path);
|
||
|
||
switch (change.operation) {
|
||
case 'create': {
|
||
// Reverse a create: delete the file
|
||
await unlink(change.file_path);
|
||
break;
|
||
}
|
||
case 'edit': {
|
||
// Reverse an edit: swap old and new
|
||
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||
const content = await readFile(change.file_path, 'utf8');
|
||
const match = locateMatch(content, newStr);
|
||
if (match.kind === 'ambiguous') {
|
||
throw new Error(
|
||
`new_string matches ${match.count} locations — cannot rewind; add surrounding context to disambiguate`,
|
||
);
|
||
}
|
||
if (match.kind === 'not_found') {
|
||
throw new Error(
|
||
'new_string not found in file (even fuzzily) — cannot rewind; file may have been modified since apply',
|
||
);
|
||
}
|
||
const reverted = content.slice(0, match.start) + oldStr + content.slice(match.end);
|
||
await writeFileAtomic(change.file_path, reverted);
|
||
break;
|
||
}
|
||
case 'delete': {
|
||
// Reverse a delete: recreate the file (diff holds the original content stashed at apply time)
|
||
await mkdir(dirname(change.file_path), { recursive: true });
|
||
await writeFileAtomic(change.file_path, change.diff);
|
||
break;
|
||
}
|
||
}
|
||
|
||
await sql`UPDATE pending_changes SET status = 'reverted' WHERE id = ${changeId}`;
|
||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
||
}
|
||
}
|
||
|
||
// --- Query functions ---------------------------------------------------------
|
||
|
||
export async function listPending(sql: Sql, sessionId: string): Promise<PendingChange[]> {
|
||
return sql<PendingChange[]>`
|
||
SELECT * FROM pending_changes
|
||
WHERE session_id = ${sessionId} AND status = 'pending'
|
||
ORDER BY created_at ASC
|
||
`;
|
||
}
|