mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +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:
@@ -600,6 +600,9 @@
|
||||
'Select a run to view its agents': '选择一次运行以查看其智能体',
|
||||
'Source type filter': '来源类型筛选',
|
||||
'Copy content': '复制内容',
|
||||
'Edit file': '编辑文件',
|
||||
'Unsaved changes': '未保存的更改',
|
||||
Saved: '已保存',
|
||||
'Export as JSON': '导出为 JSON',
|
||||
'Export as Markdown': '导出为 Markdown',
|
||||
'Mark all read': '全部标为已读',
|
||||
|
||||
@@ -422,11 +422,18 @@
|
||||
<div class="file-preview-header">
|
||||
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
|
||||
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">⎘</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeFilePreview()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-preview-body" id="filePreviewBody"></div>
|
||||
<div class="file-preview-editbar" id="filePreviewEditBar" hidden>
|
||||
<span class="file-preview-dirty" id="filePreviewDirty" hidden>Unsaved changes</span>
|
||||
<span class="file-preview-editbar-spacer"></span>
|
||||
<button class="file-preview-editbar-btn" onclick="app.cancelFilePreviewEdit()">Cancel</button>
|
||||
<button class="file-preview-editbar-btn file-preview-editbar-btn--save" id="filePreviewSaveBtn" onclick="app.saveFilePreviewEdit()" disabled>Save</button>
|
||||
</div>
|
||||
<div class="file-preview-footer" id="filePreviewFooter"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1871,6 +1871,33 @@ html.mobile-init .file-browser-panel {
|
||||
bottom: calc(44px + 2rem + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
/* File preview window: full screen on phones. --app-height tracks the visual
|
||||
viewport (KeyboardHandler), so the edit textarea + Save bar stay above the
|
||||
OS keyboard instead of hiding behind it. Footer/edit bar pad for the home
|
||||
indicator. */
|
||||
.file-preview-window {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
height: var(--app-height, 100vh);
|
||||
max-height: var(--app-height, 100vh);
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.file-preview-editbar {
|
||||
padding-bottom: calc(0.4rem + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
.file-preview-overlay .file-preview-footer {
|
||||
padding-bottom: calc(0.35rem + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
/* >=16px or iOS Safari auto-zooms the page on focus */
|
||||
.file-preview-body textarea.file-preview-editor {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Notification drawer - full width on mobile, includes safe area padding */
|
||||
.notification-drawer {
|
||||
width: 100%;
|
||||
|
||||
+185
-2
@@ -3200,6 +3200,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
if (!overlay || !bodyEl) return;
|
||||
|
||||
// Edit mode: reset any prior editor state whenever a preview (re)loads.
|
||||
this._resetFilePreviewEdit();
|
||||
|
||||
// Show overlay with loading state
|
||||
overlay.classList.add('visible');
|
||||
titleEl.textContent = filePath;
|
||||
@@ -3298,6 +3301,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
bodyEl.innerHTML = `<pre><code>${escapeHtml(data.content)}</code></pre>`;
|
||||
const truncNote = data.truncated ? ` (showing 500/${data.totalLines} lines)` : '';
|
||||
footerEl.textContent = `${data.totalLines} lines \u2022 ${this.formatFileSize(data.size)}${truncNote}`;
|
||||
// Edit affordance only when the server says an edit=1 re-fetch would
|
||||
// succeed (workspace text file inside the allowlist and size cap).
|
||||
if (data.editable) {
|
||||
this.filePreviewEditTarget = { sessionId, filePath };
|
||||
const editBtn = this.$('filePreviewEditBtn');
|
||||
if (editBtn) editBtn.hidden = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to preview file:', err);
|
||||
@@ -3306,6 +3316,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
closeFilePreview() {
|
||||
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
|
||||
this._resetFilePreviewEdit();
|
||||
const overlay = this.$('filePreviewOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
@@ -3313,6 +3325,172 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.filePreviewContent = '';
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_resetFilePreviewEdit() {
|
||||
this.filePreviewEdit = null;
|
||||
this.filePreviewEditTarget = null;
|
||||
const editBtn = this.$('filePreviewEditBtn');
|
||||
if (editBtn) editBtn.hidden = true;
|
||||
const editBar = this.$('filePreviewEditBar');
|
||||
if (editBar) editBar.hidden = true;
|
||||
const dirtyEl = this.$('filePreviewDirty');
|
||||
if (dirtyEl) dirtyEl.hidden = true;
|
||||
const saveBtn = this.$('filePreviewSaveBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Save';
|
||||
}
|
||||
},
|
||||
|
||||
async enterFilePreviewEdit() {
|
||||
const target = this.filePreviewEditTarget;
|
||||
if (!target || this.filePreviewEdit) return;
|
||||
const bodyEl = this.$('filePreviewBody');
|
||||
const footerEl = this.$('filePreviewFooter');
|
||||
if (!bodyEl) return;
|
||||
|
||||
// Always re-fetch with edit=1: the preview buffer may be line-truncated and
|
||||
// a truncated buffer must never become an edit buffer. Parse the envelope
|
||||
// even on non-ok responses so the specific refusal ("too large to edit
|
||||
// here") reaches the toast instead of a generic failure.
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sessions/${target.sessionId}/file-content?path=${encodeURIComponent(target.filePath)}&edit=1`
|
||||
);
|
||||
const result = await res.json().catch(() => null);
|
||||
if (!result || result.success !== true) {
|
||||
throw new Error(result?.error || `Failed to load file for editing (HTTP ${res.status})`);
|
||||
}
|
||||
data = result.data;
|
||||
} catch (err) {
|
||||
this.showToast(err.message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.filePreviewEdit = {
|
||||
sessionId: target.sessionId,
|
||||
filePath: target.filePath,
|
||||
baseHash: data.hash,
|
||||
eol: data.eol,
|
||||
original: data.content,
|
||||
dirty: false,
|
||||
saving: false,
|
||||
};
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'file-preview-editor';
|
||||
textarea.spellcheck = false;
|
||||
textarea.setAttribute('autocapitalize', 'off');
|
||||
textarea.setAttribute('autocorrect', 'off');
|
||||
textarea.setAttribute('autocomplete', 'off');
|
||||
textarea.wrap = 'off';
|
||||
textarea.value = data.content;
|
||||
textarea.addEventListener('input', () => this._onFilePreviewEditInput());
|
||||
bodyEl.innerHTML = '';
|
||||
bodyEl.appendChild(textarea);
|
||||
// Deliberately no autofocus: on phones that would pop the OS keyboard
|
||||
// before the user has scrolled to the line they want to change.
|
||||
|
||||
const editBtn = this.$('filePreviewEditBtn');
|
||||
if (editBtn) editBtn.hidden = true;
|
||||
const editBar = this.$('filePreviewEditBar');
|
||||
if (editBar) editBar.hidden = false;
|
||||
if (footerEl) {
|
||||
const eolNote = data.eol === 'crlf' ? ' • CRLF' : '';
|
||||
footerEl.textContent = `Editing • ${data.totalLines} lines • ${this.formatFileSize(data.size)}${eolNote}`;
|
||||
}
|
||||
},
|
||||
|
||||
_onFilePreviewEditInput() {
|
||||
const edit = this.filePreviewEdit;
|
||||
if (!edit) return;
|
||||
const textarea = this.$('filePreviewBody')?.querySelector('textarea.file-preview-editor');
|
||||
if (!textarea) return;
|
||||
edit.dirty = textarea.value !== edit.original;
|
||||
const dirtyEl = this.$('filePreviewDirty');
|
||||
if (dirtyEl) dirtyEl.hidden = !edit.dirty;
|
||||
const saveBtn = this.$('filePreviewSaveBtn');
|
||||
if (saveBtn) saveBtn.disabled = !edit.dirty || edit.saving;
|
||||
},
|
||||
|
||||
cancelFilePreviewEdit() {
|
||||
const edit = this.filePreviewEdit;
|
||||
if (!edit) return;
|
||||
if (edit.dirty && !confirm('Discard unsaved changes?')) return;
|
||||
const { sessionId, filePath } = edit;
|
||||
this._resetFilePreviewEdit();
|
||||
this.openFilePreview(filePath, sessionId);
|
||||
},
|
||||
|
||||
async saveFilePreviewEdit(force = false) {
|
||||
const edit = this.filePreviewEdit;
|
||||
if (!edit || edit.saving) return;
|
||||
const textarea = this.$('filePreviewBody')?.querySelector('textarea.file-preview-editor');
|
||||
if (!textarea) return;
|
||||
|
||||
edit.saving = true;
|
||||
const saveBtn = this.$('filePreviewSaveBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Saving…';
|
||||
}
|
||||
const restoreSaveState = () => {
|
||||
edit.saving = false;
|
||||
if (saveBtn) saveBtn.textContent = 'Save';
|
||||
this._onFilePreviewEditInput();
|
||||
};
|
||||
|
||||
let result = null;
|
||||
let status = 0;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${edit.sessionId}/file-content`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path: edit.filePath,
|
||||
content: textarea.value,
|
||||
baseHash: edit.baseHash,
|
||||
eol: edit.eol ?? undefined, // Zod .optional() rejects null
|
||||
force: force || undefined,
|
||||
}),
|
||||
});
|
||||
status = res.status;
|
||||
result = await res.json().catch(() => null);
|
||||
} catch (err) {
|
||||
restoreSaveState();
|
||||
this.showToast(`Save failed: ${err.message}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 409 || result?.errorCode === 'CONFLICT') {
|
||||
restoreSaveState();
|
||||
if (
|
||||
confirm(
|
||||
'File changed on disk since you loaded it.\nOK overwrites it with your version; Cancel keeps your draft open.'
|
||||
)
|
||||
) {
|
||||
this.saveFilePreviewEdit(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!result || result.success !== true) {
|
||||
restoreSaveState();
|
||||
this.showToast(`Save failed: ${result?.error || `HTTP ${status}`}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const { sessionId, filePath } = edit;
|
||||
this._resetFilePreviewEdit();
|
||||
this.showToast('Saved', 'success');
|
||||
// Re-open in read mode — re-fetching shows the truth on disk (including the
|
||||
// server-side EOL normalization) rather than trusting the local buffer.
|
||||
this.openFilePreview(filePath, sessionId);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Attachment Cards (detected documents/images)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -3749,8 +3927,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
copyFilePreviewContent() {
|
||||
if (this.filePreviewContent) {
|
||||
navigator.clipboard.writeText(this.filePreviewContent).then(() => {
|
||||
// While editing, copy the live editor buffer (not the stale preview text).
|
||||
const editTextarea = this.filePreviewEdit
|
||||
? this.$('filePreviewBody')?.querySelector('textarea.file-preview-editor')
|
||||
: null;
|
||||
const content = editTextarea ? editTextarea.value : this.filePreviewContent;
|
||||
if (content) {
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
this.showToast('Copied to clipboard', 'success');
|
||||
}).catch(() => {
|
||||
this.showToast('Failed to copy', 'error');
|
||||
|
||||
@@ -9430,6 +9430,84 @@ kbd {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- File Viewer edit mode (issue #212) ---- */
|
||||
|
||||
.file-preview-body textarea.file-preview-editor {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow: auto;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.file-preview-editbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-input);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-preview-editbar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-preview-editbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-preview-dirty {
|
||||
font-size: 0.7rem;
|
||||
color: var(--warning, #e5c07b);
|
||||
}
|
||||
|
||||
.file-preview-dirty::before {
|
||||
content: '\25CF ';
|
||||
}
|
||||
|
||||
.file-preview-editbar-btn {
|
||||
padding: 0.3rem 0.9rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-preview-editbar-btn:hover {
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.file-preview-editbar-btn--save {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.file-preview-editbar-btn--save:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.file-preview-editbar-btn--save:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ========== Log Viewer Windows (Floating) ========== */
|
||||
|
||||
.log-viewer-window {
|
||||
|
||||
Reference in New Issue
Block a user