mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
feat(file-viewer): edit mode for text files (edit + save in the viewer)
Closes #212. The file-preview overlay can now edit workspace text files in place, phone-first: agent writes a file, you review it in the viewer, tweak two lines, save, tell the agent to continue. Backend (file-routes.ts, policy in src/config/file-editing.ts): - GET file-content?edit=1: read-for-edit that never truncates (a truncated buffer must never become an edit buffer), 512KB cap (413 over it), and returns the sha256 hash + detected EOL the client echoes back on save. - PUT /api/sessions/:id/file-content: edit-in-place only, with no O_CREAT anywhere in the handler. Confinement matches the read path (realpath + workspace boundary + ownership via findSessionOrFail), plus sensitive-path and attachment-guard blocklists, a .git subtree deny, and an extension allowlist (svg and env deliberately excluded). Optimistic concurrency via baseHash: mismatch is a 409 unless force. Writes are wx-temp + fchmod + fsync + rename, closing the validate-then-write TOCTOU window. - Corruption guards: NUL sniff + UTF-8 round-trip compare (refuses binary and latin-1), and server-side EOL re-application so a textarea's LF normalization cannot rewrite every line of a CRLF file. - Plain reads gain an additive editable flag the UI keys the button off. Frontend (panels-ui.js + overlay markup/styles): - Edit button on editable text previews; textarea editor with Save/Cancel, dirty indicator, discard-confirm on cancel/close, and a conflict dialog that offers overwrite (force) when the file changed on disk mid-edit. - Phone: full-bleed window sized by --app-height so the editor and Save bar track the OS keyboard; 16px editor font (iOS zoom guard); no autofocus. - zh-CN strings for the new chrome. Tests: pure policy unit tests plus a route suite that deliberately does NOT mock node:fs. It runs against a real temp workspace so symlink escapes, write-through of in-workspace symlinks, mode preservation, CRLF round-trip, 409/force, and the no-create property are exercised for real. Also verified end to end on an isolated beta instance: 39-check curl matrix, Playwright desktop flow (real clicks and typing, bytes asserted on disk, live conflict with an external rewrite), and a 393px phone profile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @fileoverview File Viewer edit-mode policy (issue #212).
|
||||
*
|
||||
* Pure, IO-free policy for which workspace files the in-viewer editor may read
|
||||
* for editing and write back. Consumed by the `edit=1` branch of
|
||||
* `GET /api/sessions/:id/file-content` and by `PUT /api/sessions/:id/file-content`
|
||||
* in `src/web/routes/file-routes.ts`.
|
||||
*
|
||||
* Design (docs/file-viewer-edit-plan.md):
|
||||
* - ALLOWLIST of text extensions/basenames, not a blocklist — matching the
|
||||
* attachment-guard precedent. `svg` and `env` are deliberately absent: svg is
|
||||
* treated as untrusted on the read side, and `.env` is sensitive-path blocked
|
||||
* anyway; excluding them here keeps a single obvious refusal.
|
||||
* - The `.git/` subtree is denied outright: `.git/hooks/*` is code execution and
|
||||
* a corrupted index looks unrecoverable to a user who wanted to fix a typo.
|
||||
* - EOL helpers exist because a browser <textarea> normalizes to LF; the server
|
||||
* re-applies the file's original ending so a two-line edit of a CRLF file does
|
||||
* not become a whole-file diff. Mixed-EOL files normalize to the dominant
|
||||
* style (documented lossy edge).
|
||||
*/
|
||||
|
||||
/** Hard cap for edit-mode reads AND writes (bytes of file content). */
|
||||
export const MAX_EDITABLE_BYTES = 512 * 1024;
|
||||
|
||||
/** Lowercase extensions (no dot) the editor will open and save. */
|
||||
export const EDITABLE_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
// JS/TS ecosystem
|
||||
'ts',
|
||||
'tsx',
|
||||
'js',
|
||||
'jsx',
|
||||
'mjs',
|
||||
'cjs',
|
||||
'json',
|
||||
'jsonc',
|
||||
// Docs / plain text
|
||||
'md',
|
||||
'mdx',
|
||||
'txt',
|
||||
'rst',
|
||||
'adoc',
|
||||
// Web
|
||||
'css',
|
||||
'scss',
|
||||
'less',
|
||||
'html',
|
||||
'htm',
|
||||
'xml',
|
||||
// Config
|
||||
'yml',
|
||||
'yaml',
|
||||
'toml',
|
||||
'ini',
|
||||
'cfg',
|
||||
'conf',
|
||||
'properties',
|
||||
// Shell
|
||||
'sh',
|
||||
'bash',
|
||||
'zsh',
|
||||
'fish',
|
||||
// Languages
|
||||
'py',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'java',
|
||||
'kt',
|
||||
'swift',
|
||||
'c',
|
||||
'h',
|
||||
'cpp',
|
||||
'hpp',
|
||||
'cc',
|
||||
'cs',
|
||||
'php',
|
||||
'sql',
|
||||
'graphql',
|
||||
'proto',
|
||||
'lua',
|
||||
'pl',
|
||||
'r',
|
||||
'jl',
|
||||
'tf',
|
||||
'gradle',
|
||||
// Data / misc text
|
||||
'csv',
|
||||
'tsv',
|
||||
'log',
|
||||
'diff',
|
||||
'patch',
|
||||
]);
|
||||
|
||||
/** Extensionless (or dot-led) file names that are still editable text. */
|
||||
export const EDITABLE_BASENAMES: ReadonlySet<string> = new Set([
|
||||
'dockerfile',
|
||||
'makefile',
|
||||
'license',
|
||||
'readme',
|
||||
'changelog',
|
||||
'authors',
|
||||
'codeowners',
|
||||
'procfile',
|
||||
'.gitignore',
|
||||
'.gitattributes',
|
||||
'.dockerignore',
|
||||
'.prettierignore',
|
||||
'.prettierrc',
|
||||
'.editorconfig',
|
||||
'.nvmrc',
|
||||
'.npmrc',
|
||||
'.eslintignore',
|
||||
]);
|
||||
|
||||
/** Whether a file name (basename only) is eligible for in-viewer editing. */
|
||||
export function isEditableFileName(fileName: string): boolean {
|
||||
const lower = fileName.toLowerCase();
|
||||
if (EDITABLE_BASENAMES.has(lower)) return true;
|
||||
const dot = lower.lastIndexOf('.');
|
||||
// No extension (or a bare dotfile like `.bashrc`): only the basename list applies.
|
||||
if (dot <= 0) return false;
|
||||
return EDITABLE_EXTENSIONS.has(lower.slice(dot + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a workspace-relative path is denied for editing regardless of its
|
||||
* extension. Currently: anything inside a `.git` directory at any depth.
|
||||
*/
|
||||
export function isDeniedEditRelativePath(relativePath: string): boolean {
|
||||
return relativePath.split('/').some((segment) => segment === '.git');
|
||||
}
|
||||
|
||||
export type FileEol = 'lf' | 'crlf';
|
||||
|
||||
/** Dominant line-ending style of a text buffer (LF when tied or single-line). */
|
||||
export function detectEol(text: string): FileEol {
|
||||
let crlf = 0;
|
||||
let lf = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 10) {
|
||||
if (i > 0 && text.charCodeAt(i - 1) === 13) crlf++;
|
||||
else lf++;
|
||||
}
|
||||
}
|
||||
return crlf > lf ? 'crlf' : 'lf';
|
||||
}
|
||||
|
||||
/** Normalize every line ending in `text` to the requested style. */
|
||||
export function applyEol(text: string, eol: FileEol): string {
|
||||
const normalized = text.replace(/\r\n/g, '\n');
|
||||
return eol === 'crlf' ? normalized.replace(/\n/g, '\r\n') : normalized;
|
||||
}
|
||||
Reference in New Issue
Block a user