feat: v0.8.0 edit and save files with conflict detection
build-deb / Lint, test, package (push) Successful in 1m57s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped

- devdesk attach --fs-write SESSION PATH BYTES BASESHA|new: streamed like
  uploads, temp file in the same folder, sha re-check (conflict reply instead
  of overwriting), mode/owner kept, atomic mv; new files with folders, never
  outside the session folder or through a symlink out of it.
- Editor: editable CodeMirror for UTF-8 text, dirty tracking, Ctrl+S,
  Save/Revert, conflict dialog with a disk-vs-yours diff, changed-on-disk
  banner, drafts in localStorage (flushed on pagehide), close/leave guards,
  New file.
- Upload streaming factored into streamIn(), shared by uploads and saves.

Closes #20

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 20:10:45 -04:00
co-authored by Claude Opus 5.5
parent 927920c162
commit 66321f4748
12 changed files with 573 additions and 35 deletions
+30 -2
View File
@@ -4,7 +4,7 @@
// the first time the Files pane opens, so the page itself stays small.
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { markdownLanguage } from '@codemirror/lang-markdown';
@@ -135,12 +135,40 @@ function baseExtensions(path, { readOnly = true, wrap = false } = {}) {
];
}
// Read-only (v1) view of a file.
// Read-only view of a file.
export function viewer(parent, { doc, path, wrap }) {
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
return { view, destroy: () => view.destroy() };
}
// Editable file (devdesk#20): onChange(text) after every edit, onSave() on
// Ctrl/Cmd+S (which never reaches the browser or the terminal).
export function editor(parent, { doc, path, wrap, onChange, onSave }) {
const view = new EditorView({
parent,
state: EditorState.create({
doc,
extensions: [
keymap.of([{ key: 'Mod-s', preventDefault: true, run: () => { onSave?.(); return true; } }, indentWithTab]),
baseExtensions(path, { readOnly: false, wrap }),
EditorView.updateListener.of((update) => {
if (update.docChanged) onChange?.(update.state.doc.toString());
}),
],
}),
});
return {
view,
text: () => view.state.doc.toString(),
// Replace the whole document (reload from disk) keeping the cursor roughly where it was.
setText: (text) => {
const pos = Math.min(view.state.selection.main.head, text.length);
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text }, selection: { anchor: pos } });
},
destroy: () => view.destroy(),
};
}
// Unified diff of the HEAD version against the working copy.
export function diff(parent, { original, modified, path, wrap }) {
const view = new EditorView({