diff --git a/README.md b/README.md index 5c6583a..743a6a8 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ The tmux server runs in its own unit, so every Claude process lives in **that** - **Ask Claude** types an investigation prompt into the current session without sending it. - Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`. - **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log. -- **Files** (Ctrl+Shift+E), laid out like VS Code: an explorer tree (git colours, gitignored entries dimmed) and an editor with tabs (a click opens a preview tab, a double-click keeps it), **Go to file** (Ctrl+P in the pane), markdown previews (sanitized, with relative images), images, and a Changes view of uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt. Tabs and open folders are remembered per session. Read-only for now. Paths can't leave the session folder. The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser. +- **Files** (Ctrl+Shift+E), laid out like VS Code: an explorer tree (git colours, gitignored entries dimmed) and an editor with tabs (a click opens a preview tab, a double-click keeps it), **Go to file** (Ctrl+P in the pane), markdown previews (sanitized, with relative images), images, and a Changes view of uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt. Tabs and open folders are remembered per session. Paths can't leave the session folder. + - **Editing:** text files are editable; ● marks unsaved tabs; Ctrl+S (or Save) writes atomically and keeps the file's mode and owner. If the file changed on disk since you opened it (Claude edited it), saving asks: Compare (a diff of disk vs yours), Overwrite or Cancel; a change on disk while you edit shows a banner instead of replacing your text. Unsaved edits are kept as drafts in the browser, so a reload or a session switch doesn't lose them. + creates a file (folders as needed). The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser. - **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use and cost to `/var/lib/devdesk/context/.json`. The Sessions panel shows it as a bar (cost in the tooltip), and Claude's own footer shows a one-line summary. - **Light mode:** the terminal switches palettes with the page. DevDesk starts Claude with its `dark-ansi` theme so Claude draws with the terminal's 16 colours, and the light palette maps bright white to ink; tmux and the status line use ANSI colours too. A session picks up Claude's theme the next time Claude starts in it (`/exit`, then ⏎ resumes the same conversation). - **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer. diff --git a/bin/devdesk b/bin/devdesk index ea0aea5..1f4d506 100755 --- a/bin/devdesk +++ b/bin/devdesk @@ -318,7 +318,7 @@ cmd_attach() { dd_linger return ;; - --fs-list | --fs-find | --fs-read | --git-status | --git-show | --asset) + --fs-list | --fs-find | --fs-read | --fs-write | --git-status | --git-show | --asset) local action=${1#--} shift "cmd_${action//-/_}" "$@" diff --git a/debian/changelog b/debian/changelog index af6ed07..c2b6f2c 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,28 @@ +devdesk (0.8.0) bookworm; urgency=medium + + * Edit and save files in the Files pane (#20): + - Text files open editable; ● marks unsaved tabs; Ctrl+S or Save writes + through `devdesk attach --fs-write`: a temp file in the same folder, + mode and owner kept, swapped in with mv. + - Conflict detection: the save carries the sha256 of the version you + opened; if Claude (or anyone) changed the file since, nothing is written + and you choose Compare (diff of disk vs yours), Overwrite or Cancel. + - A file that changes on disk while you edit it shows a banner (Compare, + Reload, Keep mine) instead of replacing your text; clean files still + reload in place. + - Unsaved edits are mirrored to the browser as drafts (flushed when the + page closes), so a reload, a crash or a session switch doesn't lose + them; a draft for a file that changed since offers Compare/Restore/ + Discard. + - Closing a tab with unsaved changes asks Save / Don't save / Cancel; + leaving the page warns. + - + New file (missing folders are created; never outside the session + folder, and never through a symlink out of it). + - Markdown previews show unsaved Source edits. Latin-1 files stay + read-only. + + -- Jared Vititoe Sat, 10 Oct 2026 20:09:49 -0400 + devdesk (0.7.3) bookworm; urgency=medium * Fix (#38): links that mean "go to that session" no longer re-create one diff --git a/libexec/devdesk-files.sh b/libexec/devdesk-files.sh index 0bec86e..6b05e85 100644 --- a/libexec/devdesk-files.sh +++ b/libexec/devdesk-files.sh @@ -190,6 +190,81 @@ cmd_git_show() { rm -f "$tmp" } +# --fs-write SESSION REL BYTES BASE (devdesk#20): save a file from the editor. +# The content arrives as base64 on stdin after the READY line (like uploads). +# BASE is the sha256 the editor loaded: if the file changed since, nothing is +# written and the reply is {conflict: true, sha: }. BASE "new" creates +# a file (and missing folders) and refuses to overwrite one. Writes go to a +# temp file in the same folder, keep the mode and owner, and land with mv. +cmd_fs_write() { + [ $# -eq 4 ] || dd_die "usage: devdesk attach --fs-write SESSION PATH BYTES BASESHA|new" + local root rel=$2 size=$3 base=$4 target dir name tmp b64len actual current sha mtime + root=$(dd_session_root "$1") + if ! [[ $size =~ ^[0-9]+$ ]] || [ "$size" -gt "$DEVDESK_FS_MAX" ]; then + dd_die "too large: the editor saves files up to $((DEVDESK_FS_MAX / 1024)) KiB" + fi + [[ $base =~ ^([0-9a-f]{64}|new)$ ]] || dd_die "bad base: $base" + case $rel in /* | *$'\n'* | *$'\r'* | '' | */ | . | ..) dd_die "bad path: $rel" ;; esac + name=${rel##*/} + if [ "$name" = . ] || [ "$name" = .. ]; then dd_die "bad path: $rel"; fi + if [ "$base" = new ]; then + if [ -e "$root/$rel" ] || [ -L "$root/$rel" ]; then dd_die "already exists: $rel"; fi + case $rel in */*) dir=$root/${rel%/*} ;; *) dir=$root ;; esac + # Missing folders are created, but never through a symlink out of the + # root: check the deepest folder that exists before creating any. + local existing=$dir + while [ ! -e "$existing" ]; do existing=${existing%/*}; done + existing=${existing#"$root"} + dd_fs_path "$root" "${existing#/}" >/dev/null + mkdir -p -- "$dir" || dd_die "cannot create folder for $rel" + dir=${dir#"$root"} + dir=$(dd_fs_path "$root" "${dir#/}") + target="$dir/$name" + else + target=$(dd_fs_path "$root" "$rel") + [ -f "$target" ] || dd_die "not a file: $rel" + dir=${target%/*} + fi + tmp=$(mktemp --tmpdir="$dir" ".${name}.devdesk-XXXXXX") || dd_die "cannot write in ${dir#"$root"/}" + trap 'rm -f "$tmp"' HUP INT TERM + # shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose + b64len=$(((size + 2) / 3 * 4)) + stty raw -echo 2>/dev/null || true + printf 'DEVDESK-WRITE-READY\r\n' + if [ "$b64len" -gt 0 ]; then + head -c "$b64len" | base64 -d >"$tmp" 2>/dev/null || true + fi + stty sane 2>/dev/null || true + actual=$(stat -c %s "$tmp" 2>/dev/null || echo 0) + if [ "$actual" != "$size" ]; then + rm -f "$tmp" + dd_die "save incomplete ($actual of $size bytes)" + fi + if [ "$base" = new ]; then + if [ -e "$target" ]; then + rm -f "$tmp" + dd_die "already exists: $rel" + fi + chmod 0644 "$tmp" + else + current=$(sha256sum -- "$target" | cut -d' ' -f1) + if [ "$current" != "$base" ]; then + rm -f "$tmp" + trap - HUP INT TERM + jq -cMn --arg p "$rel" --arg h "$current" '{ok: false, conflict: true, path: $p, sha: $h}' + return + fi + chmod --reference="$target" "$tmp" 2>/dev/null || true + chown --reference="$target" "$tmp" 2>/dev/null || true + fi + mv -f -- "$tmp" "$target" || { rm -f "$tmp"; dd_die "cannot replace $rel"; } + trap - HUP INT TERM + sha=$(sha256sum -- "$target" | cut -d' ' -f1) + mtime=$(stat -c %Y -- "$target") + jq -cMn --arg p "${target#"$root"/}" --arg h "$sha" --argjson s "$size" --argjson m "$mtime" \ + '{ok: true, path: $p, sha: $h, size: $s, mtime: $m}' +} + # --asset NAME: a lazily loaded part of the web UI (the file viewer's editor), # shipped in $DEVDESK_ASSETS and cached by the browser. cmd_asset() { diff --git a/tests/attach.bats b/tests/attach.bats index ae17acb..a628a84 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -620,3 +620,58 @@ blob_body() { [ "$status" -eq 0 ] [ "$(jq -r '[(.entries | length), .truncated] | @tsv' <<<"$output")" = "$(printf '5\ttrue')" ] } + +# Save through --fs-write the way the browser does: content as base64 on stdin. +fs_write() { # SESSION PATH BASE CONTENT-FILE + local size + size=$(stat -c %s "$4") + base64 -w0 "$4" | devdesk attach --fs-write "$1" "$2" "$size" "$3" +} + +@test "fs-write saves atomically, keeps the mode, and refuses stale bases (#20)" { + p="$DEVDESK_PROJECTS_ROOT/alpha" + printf 'v1\n' >"$p/conf.sh" + chmod 0750 "$p/conf.sh" + base=$(sha256sum "$p/conf.sh" | cut -d' ' -f1) + printf 'v2\n' >"$TMP/new" + run fs_write alpha conf.sh "$base" "$TMP/new" + [ "$status" -eq 0 ] + [ "$(tail -n1 <<<"$output" | jq -r '.ok')" = true ] + [ "$(cat "$p/conf.sh")" = v2 ] + [ "$(stat -c %a "$p/conf.sh")" = 750 ] + [ "$(tail -n1 <<<"$output" | jq -r '.sha')" = "$(sha256sum "$p/conf.sh" | cut -d' ' -f1)" ] + # Someone (Claude) changed it since: the old base no longer matches. + printf 'v3 from me\n' >"$TMP/mine" + run fs_write alpha conf.sh "$base" "$TMP/mine" + [ "$(tail -n1 <<<"$output" | jq -r '[.ok, .conflict] | @tsv')" = "$(printf 'false\ttrue')" ] + [ "$(cat "$p/conf.sh")" = v2 ] + # No temp files left behind + [ -z "$(find "$p" -name '*.devdesk-*')" ] + # Empty files are fine + : >"$TMP/empty" + base=$(sha256sum "$p/conf.sh" | cut -d' ' -f1) + run fs_write alpha conf.sh "$base" "$TMP/empty" + [ "$(tail -n1 <<<"$output" | jq -r '.ok')" = true ] + [ ! -s "$p/conf.sh" ] +} + +@test "fs-write creates new files (and folders) but never overwrites or escapes (#20)" { + p="$DEVDESK_PROJECTS_ROOT/alpha" + printf 'hello\n' >"$TMP/c" + run fs_write alpha docs/notes/new.md new "$TMP/c" + [ "$(tail -n1 <<<"$output" | jq -r '[.ok, .path] | @tsv')" = "$(printf 'true\tdocs/notes/new.md')" ] + [ "$(cat "$p/docs/notes/new.md")" = hello ] + run fs_write alpha docs/notes/new.md new "$TMP/c" + [ "$status" -ne 0 ] + [[ $output == *"already exists"* ]] + ln -s "$TMP/outside" "$p/out" + for bad in ../escape.txt /etc/x out/pwned.txt out/newdir/x.txt docs/ ""; do + run fs_write alpha "$bad" new "$TMP/c" + [ "$status" -ne 0 ] + done + [ ! -e "$TMP/outside/pwned.txt" ] && [ ! -e "$TMP/outside/newdir" ] && [ ! -e "$DEVDESK_PROJECTS_ROOT/escape.txt" ] + # A short stream fails without touching anything + run bash -c "printf aGVs | '$REPO/bin/devdesk' attach --fs-write alpha docs/notes/new.md 6 $(sha256sum "$p/docs/notes/new.md" | cut -d' ' -f1)" + [ "$status" -ne 0 ] + [ "$(cat "$p/docs/notes/new.md")" = hello ] +} diff --git a/web/editor/index.js b/web/editor/index.js index f28eb67..d1b4eee 100644 --- a/web/editor/index.js +++ b/web/editor/index.js @@ -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({ diff --git a/web/index.template.html b/web/index.template.html index c807c37..50ddf3f 100644 --- a/web/index.template.html +++ b/web/index.template.html @@ -96,6 +96,7 @@
+ diff --git a/web/src/connection.js b/web/src/connection.js index 0ee66f5..d910481 100644 --- a/web/src/connection.js +++ b/web/src/connection.js @@ -209,11 +209,28 @@ export function uploadFile(session, file, onProgress) { } async function uploadNow(session, file, onProgress) { - const token = await fetchToken(); const name = (file.name || 'pasted-image.png').slice(-100); + const result = await streamIn(['--upload', session, name, String(file.size)], 'DEVDESK-UPLOAD-READY', file, onProgress); + if (!result.ok || !result.path) throw new Error('upload failed'); + return result.path; +} + +// Save a file from the editor (devdesk#20): `--fs-write SESSION PATH BYTES BASE` +// with the content streamed after the ready line. Resolves the server's JSON: +// {ok: true, sha, size, mtime} or {ok: false, conflict: true, sha} when the file +// changed since `base` (a sha256, or "new" to create). +export function writeFile(session, path, bytes, base) { + return limited(() => streamIn(['--fs-write', session, path, String(bytes.length), base], 'DEVDESK-WRITE-READY', new Blob([bytes]))); +} + +// Run a devdesk command that reads base64 from the terminal after printing +// `ready`: stream `blob` in slices (never all in memory), then resolve the JSON +// reply. Fails on 20 s of inactivity rather than a fixed total (devdesk#9). +async function streamIn(args, ready, blob, onProgress) { + const token = await fetchToken(); const slice = 3 * 16 * 1024; // multiple of 3: base64 of slices concatenates cleanly return new Promise((resolve, reject) => { - const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(file.size)]), ['tty']); + const socket = new WebSocket(wsUrl(window.location, args), ['tty']); socket.binaryType = 'arraybuffer'; const decoder = new TextDecoder(); let output = ''; @@ -224,15 +241,16 @@ async function uploadNow(session, file, onProgress) { idle = setTimeout(() => socket.close(), 20000); }; const send = async () => { - for (let offset = 0; offset < file.size; offset += slice) { + for (let offset = 0; offset < blob.size; offset += slice) { while (socket.bufferedAmount > 1 << 20) await sleep(20); if (socket.readyState !== WebSocket.OPEN) return; - const bytes = new Uint8Array(await file.slice(offset, offset + slice).arrayBuffer()); + const bytes = new Uint8Array(await blob.slice(offset, offset + slice).arrayBuffer()); socket.send(encodeInput(toBase64(bytes))); touch(); - onProgress?.(Math.min(1, (offset + slice) / file.size)); + onProgress?.(Math.min(1, (offset + slice) / blob.size)); } }; + const after = () => output.slice(output.indexOf(ready) + ready.length); touch(); socket.onopen = () => socket.send(encodeInit(token, 240, 24)); socket.onmessage = (event) => { @@ -240,28 +258,26 @@ async function uploadNow(session, file, onProgress) { const { type, payload } = decodeServer(event.data); if (type !== Server.OUTPUT) return; output += decoder.decode(payload, { stream: true }); - if (!sending && output.includes('DEVDESK-UPLOAD-READY')) { + if (!sending && output.includes(ready)) { sending = true; send(); - } else if (sending && isComplete(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, ''))) { + } + if (sending && isComplete(after())) { socket.close(); // the reply is in; let devdesk exit (devdesk#35) } }; socket.onerror = () => {}; socket.onclose = () => { clearTimeout(idle); - const text = stripAnsi(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, '')); + const text = stripAnsi(output.includes(ready) ? after() : output); try { - const result = parseListOutput(text); - if (result.ok && result.path) { - resolve(result.path); - return; - } + resolve(parseListOutput(text)); + return; } catch { // fall through to the error message } const message = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop(); - reject(new Error((message || 'upload failed').replace(/^devdesk:\s*/, ''))); + reject(new Error((message || 'no reply').replace(/^devdesk:\s*/, ''))); }; }); } diff --git a/web/src/files.js b/web/src/files.js index 6ab8468..5587754 100644 --- a/web/src/files.js +++ b/web/src/files.js @@ -5,11 +5,11 @@ // see what Claude edited, and jump to any file by name. Read-only for now. // Everything goes through `devdesk attach --fs-*` over ttyd. -import { runBlob, runCommand } from './connection.js'; +import { runBlob, runCommand, writeFile } from './connection.js'; import { parseListOutput } from './protocol.js'; import { el } from './dom.js'; import { ago } from './sessions.js'; -import { CommandPalette, toast } from './ui.js'; +import { CommandPalette, openDialog, toast } from './ui.js'; // ---------------------------------------------------------------- editor bundle @@ -164,6 +164,27 @@ function writeJson(key, value) { } } +// Unsaved edits survive a reload, a session switch or a crash (devdesk#20): +// each dirty buffer is mirrored to localStorage with the sha it was based on. +const draftKey = (session, path) => `devdesk.draft.${session}:${path}`; + +function readDraft(session, path) { + try { + return JSON.parse(localStorage.getItem(draftKey(session, path))); + } catch { + return null; + } +} + +function writeDraft(session, path, draft) { + try { + if (draft) localStorage.setItem(draftKey(session, path), JSON.stringify(draft)); + else localStorage.removeItem(draftKey(session, path)); + } catch { + // Quota or private mode: the edit just isn't backed up. + } +} + // ---------------------------------------------------------------- icons // A two-character badge per file type (VS Code's icon idea, in the terminal style). @@ -249,6 +270,7 @@ export class FilesPane { root.querySelector('.dd-files-refresh').addEventListener('click', () => this.refresh()); root.querySelector('.dd-files-close').addEventListener('click', () => this.close()); root.querySelector('.dd-files-find').addEventListener('click', () => this.quickOpen.open()); + root.querySelector('.dd-files-new').addEventListener('click', () => this.newFile()); root.querySelector('.dd-explorer-toggle').addEventListener('click', () => this.setExplorer(!this.explorerShown())); this.explorer.addEventListener('keydown', (event) => this.treeKey(event)); root.addEventListener('keydown', (event) => { @@ -256,8 +278,15 @@ export class FilesPane { event.preventDefault(); this.quickOpen.open(); } + if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.code === 'KeyS') { + event.preventDefault(); + if (this.active) this.save(this.active); + } }); this.wireExplorerResize(); + // Leaving or reloading the page: write pending drafts now, not in 400 ms. + window.addEventListener('pagehide', () => this.flushDrafts()); + window.addEventListener('beforeunload', () => this.flushDrafts()); // Narrow pane (phone, or dragged small): the explorer slides over the editor. new ResizeObserver(() => root.classList.toggle('is-narrow', root.clientWidth < 680)).observe(root); this.quickOpen = new CommandPalette(() => this.loadFileList(), { @@ -343,6 +372,7 @@ export class FilesPane { syncSession() { const session = this.hooks.session(); if (session === this.session) return false; + this.flushDrafts(); for (const v of this.views.values()) v.editor?.destroy(); this.views.clear(); this.listings.clear(); @@ -689,7 +719,11 @@ export class FilesPane { this.tabs[preview] = tab; } else { this.tabs.push(tab); - while (this.tabs.length > 12) this.closeTab(this.tabs.find((t) => t.key !== key).key, { render: false }); + while (this.tabs.length > 12) { + const victim = this.tabs.find((t) => t.key !== key && !this.views.get(t.key)?.dirty); + if (!victim) break; + this.closeTab(victim.key, { render: false, force: true }); + } } } else if (pin) { tab.pinned = true; @@ -716,7 +750,34 @@ export class FilesPane { this.renderExplorer(); } - closeTab(key, { render = true } = {}) { + flushDrafts() { + for (const [key, view] of this.views) { + if (!view.editable || !view.draftTimer) continue; + clearTimeout(view.draftTimer); + view.draftTimer = 0; + const tab = this.tabs.find((t) => t.key === key); + if (tab) writeDraft(this.session, tab.path, view.dirty ? { text: view.editor.text(), base: view.base, at: Date.now() } : null); + } + } + + hasDirty() { + return [...this.views.values()].some((v) => v.dirty); + } + + async closeTab(key, { render = true, force = false } = {}) { + const view = this.views.get(key); + const tab = this.tabs.find((t) => t.key === key); + if (!force && view?.dirty && tab) { + const choice = await openDialog({ + title: `Save changes to ${base(tab.path)}?`, + size: 'sm', + body: el('p', { class: 'dd-dialog-text' }, `${tab.path} has unsaved changes.`), + actions: [{ label: 'Cancel', value: null }, { label: 'Don\'t save', value: 'discard' }, { label: 'Save', primary: true }], + }); + if (!choice) return; + if (choice === 'discard') writeDraft(this.session, tab.path, null); + else if (!(await this.save(key))) return; + } const i = this.tabs.findIndex((t) => t.key === key); if (i < 0) return; this.tabs.splice(i, 1); @@ -731,6 +792,7 @@ export class FilesPane { } dropView(key) { + clearTimeout(this.views.get(key)?.draftTimer); this.views.get(key)?.editor?.destroy(); this.views.get(key)?.node.remove(); this.views.delete(key); @@ -742,7 +804,7 @@ export class FilesPane { const change = this.changeFor(tab.path); const status = change ? statusOf(change) : null; return el('div', { - class: `dd-tab${tab.key === this.active ? ' is-active' : ''}${tab.pinned ? '' : ' is-preview'}${status ? ` git-${status[2]}` : ''}`, + class: `dd-tab${tab.key === this.active ? ' is-active' : ''}${tab.pinned ? '' : ' is-preview'}${status ? ` git-${status[2]}` : ''}${this.views.get(tab.key)?.dirty ? ' is-dirty' : ''}`, role: 'tab', 'aria-selected': String(tab.key === this.active), tabindex: tab.key === this.active ? '0' : '-1', title: `${tab.path}${tab.mode === 'diff' ? ' (diff against HEAD)' : ''}${tab.pinned ? '' : ' · preview: the next file replaces it; double-click to keep'}`, onclick: () => this.activate(tab.key), @@ -758,12 +820,12 @@ export class FilesPane { el('span', { class: 'dd-tab-name' }, name), tab.mode === 'diff' ? el('span', { class: 'dd-tab-tag' }, 'diff') : null, el('button', { - type: 'button', class: 'dd-tab-close', 'aria-label': `Close ${name}`, title: 'Close', + type: 'button', class: 'dd-tab-close', 'aria-label': `Close ${name}`, title: this.views.get(tab.key)?.dirty ? 'Unsaved changes: close…' : 'Close', onclick: (event) => { event.stopPropagation(); this.closeTab(tab.key); }, - }, '×')); + }, this.views.get(tab.key)?.dirty ? '●' : '×')); }); this.tabStrip.replaceChildren(...nodes); this.tabStrip.querySelector('.is-active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); @@ -851,6 +913,10 @@ export class FilesPane { const [{ blob, original }, editor] = await Promise.all([this.fetchTab(tab), loadEditor().catch((error) => ({ error }))]); if (session !== this.session || this.views.get(key) !== view) return; if (blob.header.sha && blob.header.sha === view.header?.sha && tab.mode !== 'diff') return; + if (view.dirty) { + if (blob.header.sha !== view.base) this.changedOnDisk(key); + return; + } const scroller = view.node.querySelector('.cm-scroller'); const top = scroller?.scrollTop ?? 0; view.editor?.destroy(); @@ -863,7 +929,7 @@ export class FilesPane { } } - bar(tab, header) { + bar(tab, header, view) { const change = this.changeFor(tab.path); const meta = header ? [ size(header.size ?? 0), @@ -876,6 +942,10 @@ export class FilesPane { const button = (label, run, extra = {}) => el('button', { type: 'button', class: 'dd-viewer-btn', onclick: run, ...extra }, label); const switchMode = (mode) => this.openFile(tab.path, { mode, pin: true }); const actions = []; + if (view?.editable) { + actions.push(button('Save', () => this.save(tab.key), { class: 'dd-viewer-btn is-primary dd-dirty-only', title: 'Save (Ctrl+S)' })); + actions.push(button('Revert', () => this.revert(tab.key), { class: 'dd-viewer-btn dd-dirty-only', title: 'Discard your changes' })); + } if (tab.mode !== 'diff' && isMarkdown(tab.path)) actions.push(button(tab.mode === 'preview' ? 'Source' : 'Preview', () => switchMode(tab.mode === 'preview' ? 'view' : 'preview'))); if (change && change.x !== '?' && change.y !== '?' && this.changes?.git) { actions.push(button(tab.mode === 'diff' ? 'File' : 'Diff', () => switchMode(tab.mode === 'diff' ? (isMarkdown(tab.path) ? 'preview' : 'view') : 'diff'))); @@ -897,7 +967,8 @@ export class FilesPane { return el('div', { class: 'dd-viewer-bar' }, el('span', { class: 'dd-viewer-where', title: [tab.path, meta].filter(Boolean).join('\n') }, el('span', { class: 'dd-viewer-path' }, tab.path), - meta ? el('span', { class: 'dd-viewer-meta' }, ` · ${meta}`) : null), + meta ? el('span', { class: 'dd-viewer-meta' }, ` · ${meta}`) : null, + view?.editable ? el('span', { class: 'dd-unsaved dd-dirty-only' }, ' · unsaved') : null), el('span', { class: 'dd-viewer-actions' }, ...actions)); } @@ -917,20 +988,261 @@ export class FilesPane { content.append(el('pre', { class: 'dd-plain' }, text(base64, header.utf8))); } else if (tab.mode === 'preview') { content.classList.add('is-preview'); + // Unsaved edits in the Source tab show in the preview too. + const source = this.views.get(`view:${tab.path}`); + const md = source?.dirty ? source.editor.text() : text(base64); const frame = el('iframe', { class: 'dd-md-frame', title: `Preview of ${tab.path}`, - sandbox: 'allow-popups allow-popups-to-escape-sandbox', srcdoc: previewDocument(editor.markdown(text(base64))), + sandbox: 'allow-popups allow-popups-to-escape-sandbox', srcdoc: previewDocument(editor.markdown(md)), }); content.append(frame); - inlineImages(editor.markdown(text(base64)), this.session, tab.path).then((html) => { + inlineImages(editor.markdown(md), this.session, tab.path).then((html) => { if (frame.isConnected) frame.srcdoc = previewDocument(html); }); } else if (tab.mode === 'diff') { view.editor = editor.diff(content, { original: text(original?.base64), modified: text(base64), path: tab.path, wrap: this.wrap }); + } else if (header.utf8 === false) { + // Not UTF-8: shown as Latin-1, read-only (saving would change the bytes). + view.editor = editor.viewer(content, { doc: text(base64, false), path: tab.path, wrap: this.wrap }); } else { - view.editor = editor.viewer(content, { doc: text(base64, header.utf8), path: tab.path, wrap: this.wrap }); + const disk = text(base64); + const draft = readDraft(this.session, tab.path); + let doc = disk; + let banner = null; + if (draft && draft.text !== disk) { + if (draft.base === header.sha) { + doc = draft.text; + toast.info(`Restored your unsaved changes to ${tab.path}`); + } else { + banner = draft; + } + } + view.editable = true; + view.saved = disk; + view.base = header.sha; + view.editor = editor.editor(content, { + doc, path: tab.path, wrap: this.wrap, + onChange: (now) => this.edited(tab.key, now), + onSave: () => this.save(tab.key), + }); + view.dirty = doc !== disk; + view.node.classList.toggle('is-dirty', view.dirty); + view.node.replaceChildren(this.bar(tab, header, view), content); + if (banner) this.oldDraft(tab.key, banner); + if (view.dirty) this.renderTabs(); + return; + } + view.node.replaceChildren(this.bar(tab, header, view), content); + } + + // ------------------------------------------------------------ editing (devdesk#20) + + edited(key, now) { + const view = this.views.get(key); + const tab = this.tabs.find((t) => t.key === key); + if (!view || !tab) return; + const dirty = now !== view.saved; + if (dirty && !tab.pinned) { + tab.pinned = true; // editing keeps a preview tab + this.saveState(); + } + clearTimeout(view.draftTimer); + view.draftTimer = setTimeout(() => writeDraft(this.session, tab.path, dirty ? { text: now, base: view.base, at: Date.now() } : null), 400); + if (dirty !== view.dirty) { + view.dirty = dirty; + view.node.classList.toggle('is-dirty', dirty); + this.renderTabs(); + } + } + + // Resolves true when saved. A conflict (the file changed on disk since it was + // opened) asks before overwriting. + async save(key, { base: forced } = {}) { + const view = this.views.get(key); + const tab = this.tabs.find((t) => t.key === key); + if (!view?.editable || !tab || view.saving) return false; + if (!view.dirty && !forced) return true; + const now = view.editor.text(); + view.saving = true; + try { + const result = await writeFile(this.session, tab.path, new TextEncoder().encode(now), forced ?? view.base ?? 'new'); + if (result.conflict) return this.conflict(key, result.sha); + if (!result.ok) throw new Error('not saved'); + view.saved = now; + view.base = result.sha; + view.header = { ...view.header, sha: result.sha, size: result.size, mtime: result.mtime }; + clearTimeout(view.draftTimer); + writeDraft(this.session, tab.path, null); + this.edited(key, view.editor.text()); + view.node.querySelector('.dd-viewer-banner')?.remove(); + toast.success(`Saved ${tab.path}`); + this.loadChanges(); + this.fileList = null; + return true; + } catch (error) { + toast.error(`Could not save ${tab.path}: ${error.message}`); + return false; + } finally { + view.saving = false; + } + } + + revert(key) { + const view = this.views.get(key); + const tab = this.tabs.find((t) => t.key === key); + if (!view?.editable || !tab) return; + clearTimeout(view.draftTimer); + writeDraft(this.session, tab.path, null); + view.editor.setText(view.saved); + view.node.querySelector('.dd-viewer-banner')?.remove(); + } + + async diskText(path) { + const { header, base64 } = await runBlob(['--fs-read', this.session, path]); + return { sha: header.sha, text: new TextDecoder().decode(decodeBase64(base64 || '')) }; + } + + async conflict(key, diskSha) { + const tab = this.tabs.find((t) => t.key === key); + const choice = await openDialog({ + title: `${base(tab.path)} changed on disk`, + size: 'sm', + body: el('p', { class: 'dd-dialog-text' }, `Someone (probably Claude) changed ${tab.path} after you opened it. Saving now would overwrite that.`), + actions: [{ label: 'Cancel', value: null }, { label: 'Compare', value: 'compare' }, { label: 'Overwrite', primary: true, danger: true }], + danger: true, + }); + if (choice === 'compare') return this.compare(key); + if (choice) return this.save(key, { base: diskSha }); + return false; + } + + // Disk version vs yours, side by side in a unified diff; pick one. + async compare(key) { + const view = this.views.get(key); + const tab = this.tabs.find((t) => t.key === key); + if (!view || !tab) return false; + let disk; + let editor; + try { + [disk, editor] = await Promise.all([this.diskText(tab.path), loadEditor()]); + } catch (error) { + toast.error(error.message); + return false; + } + const holder = el('div', { class: 'dd-compare' }); + let diffView = null; + const choice = await openDialog({ + title: `${tab.path}: disk (red) vs yours (green)`, + className: 'is-wide', + body: holder, + onOpen: () => { + diffView = editor.diff(holder, { original: disk.text, modified: view.editor.text(), path: tab.path, wrap: this.wrap }); + return holder.querySelector('.cm-content'); + }, + actions: [{ label: 'Close', value: null }, { label: 'Take the disk version', value: 'disk' }, { label: 'Save mine', primary: true }], + }); + diffView?.destroy(); + if (choice === 'disk') { + view.saved = disk.text; + view.base = disk.sha; + view.header = { ...view.header, sha: disk.sha }; + view.editor.setText(disk.text); + writeDraft(this.session, tab.path, null); + view.node.querySelector('.dd-viewer-banner')?.remove(); + return false; + } + if (choice) return this.save(key, { base: disk.sha }); + return false; + } + + banner(key, text, actions) { + const view = this.views.get(key); + if (!view) return; + view.node.querySelector('.dd-viewer-banner')?.remove(); + const node = el('div', { class: 'dd-viewer-banner', role: 'alert' }, el('span', {}, text), + ...actions.map(([label, run]) => el('button', { type: 'button', class: 'dd-viewer-btn', onclick: run }, label))); + view.node.querySelector('.dd-viewer-bar')?.after(node); + } + + // Claude changed a file you're editing: nothing is replaced until you choose. + changedOnDisk(key) { + this.banner(key, 'This file changed on disk while you were editing it.', [ + ['Compare', () => this.compare(key)], + ['Reload (discard mine)', () => { + this.revert(key); + this.dropView(key); + this.renderEditor(); + }], + ['Keep mine', () => this.views.get(key)?.node.querySelector('.dd-viewer-banner')?.remove()], + ]); + } + + // Unsaved changes from an earlier visit, but the file changed since. + oldDraft(key, draft) { + const view = this.views.get(key); + const tab = this.tabs.find((t) => t.key === key); + const age = ago(Math.floor(draft.at / 1000)); + this.banner(key, `You have unsaved changes from ${age === 'now' ? 'a moment ago' : `${age} ago`}, but the file changed on disk since.`, [ + ['Compare', async () => { + view.editor.setText(draft.text); + await this.compare(key); + }], + ['Restore mine', () => { + view.editor.setText(draft.text); + view.node.querySelector('.dd-viewer-banner')?.remove(); + }], + ['Discard', () => { + writeDraft(this.session, tab.path, null); + view.node.querySelector('.dd-viewer-banner')?.remove(); + }], + ]); + } + + // New file in the folder you're looking at (folders are created as needed). + async newFile() { + if (!this.session) return; + const activePath = this.tabs.find((t) => t.key === this.active)?.path; + const row = this.treeRows?.[this.focusIndex]; + const folder = row?.kind === 'dir' ? row.path : parent(row?.path ?? activePath ?? ''); + const input = el('input', { class: 'lt-input', value: folder ? `${folder}/` : '', spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-newfile-hint' }); + const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-newfile-hint' }, `Relative to ${this.listings.get('')?.root ?? 'the session folder'}. Missing folders are created.`); + const path = await openDialog({ + title: 'New file', + size: 'sm', + body: el('div', { class: 'dd-dialog-fields' }, el('label', { class: 'dd-field' }, el('span', {}, 'Path'), input), hint), + actions: [{ label: 'Cancel' }, { label: 'Create', primary: true }], + onOpen: () => { + input.setSelectionRange(input.value.length, input.value.length); + return input; + }, + submit: () => { + const value = input.value.trim().replace(/^\/+/, ''); + if (!value || value.endsWith('/') || value.split('/').some((part) => part === '..' || part === '.')) { + hint.textContent = 'Type a file name (no .. or trailing /).'; + hint.classList.add('is-error'); + return null; + } + return value; + }, + }); + if (!path) return; + try { + const result = await writeFile(this.session, path, new Uint8Array(0), 'new'); + if (!result.ok) throw new Error('not created'); + // Show it: re-list every folder above the new file and open them. + const parts = result.path.split('/').slice(0, -1); + for (let i = 0; i <= parts.length; i += 1) { + const dir = parts.slice(0, i).join('/'); + if (dir) this.expanded.add(dir); + this.listDir(dir); + } + this.saveState(); + this.fileList = null; + this.openFile(result.path, { mode: 'view', pin: true }); + toast.success(`Created ${result.path}`); + } catch (error) { + toast.error(`Could not create ${path}: ${error.message}`); } - view.node.replaceChildren(this.bar(tab, header), content); } // Theme switch: the editor follows CSS variables; previews need a re-render. diff --git a/web/src/main.js b/web/src/main.js index 29985d0..60b01e3 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -712,7 +712,9 @@ const KEY_HELP = [ ]], ['DevDesk', [ ['Ctrl+⇧ P', 'Command palette'], - ['Ctrl+⇧ E', 'Files: browse, preview, diff (Esc closes)'], + ['Ctrl+⇧ E', 'Files: browse, preview, diff, edit (Esc closes)'], + ['Ctrl+P', 'Files: go to file'], + ['Ctrl+S', 'Files: save the open file'], ['Ctrl+⇧ C / V', 'Copy selection / paste'], ['⇧ drag', 'Select text in the browser'], ['Drop or paste a file', 'Upload it and attach it for Claude'], @@ -952,7 +954,7 @@ function wireChrome() { // Ctrl+W closes the tab before the terminal sees it; ask first. window.addEventListener('beforeunload', (event) => { - if (state.conn?.isOpen && state.session) { + if ((state.conn?.isOpen && state.session) || files?.hasDirty()) { event.preventDefault(); event.returnValue = ''; } diff --git a/web/src/ui.js b/web/src/ui.js index 8826fee..433ed4f 100644 --- a/web/src/ui.js +++ b/web/src/ui.js @@ -52,7 +52,7 @@ export const dialogOpen = () => openDialogs > 0 || !!document.querySelector('.lt // A .lt-modal dialog. `body` is a node; `actions` are { label, value, primary, // danger }. Resolves with the chosen action's value (or `submit()`'s value when // a form is submitted), or null when dismissed (✕, Esc, backdrop). -export function openDialog({ title, body, actions = [], danger = false, size = '', onOpen, submit }) { +export function openDialog({ title, body, actions = [], danger = false, size = '', className = '', onOpen, submit }) { return new Promise((resolve) => { const opener = document.activeElement; let done = false; @@ -81,7 +81,7 @@ export function openDialog({ title, body, actions = [], danger = false, size = ' if (button.type === 'submit') button.setAttribute('form', titleId); } form.id = titleId; - const overlay = el('div', { class: 'lt-modal-overlay is-open dd-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': `${titleId}-title` }, + const overlay = el('div', { class: `lt-modal-overlay is-open dd-dialog${className ? ` ${className}` : ''}`, role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': `${titleId}-title` }, el('div', { class: `lt-modal${size ? ` lt-modal-${size}` : ''}` }, el('div', { class: `lt-modal-header${danger ? ' lt-modal-header--danger' : ''}` }, el('h2', { class: 'lt-modal-title', id: `${titleId}-title` }, title), diff --git a/web/styles.css b/web/styles.css index 2bf3868..8f58997 100644 --- a/web/styles.css +++ b/web/styles.css @@ -924,3 +924,26 @@ html[data-theme="light"] .dd-files.is-narrow .dd-explorer { box-shadow: 8px 0 24 .dd-tree-row { min-height: 34px; } .dd-tab-close { opacity: 0.7; width: 28px; height: 28px; } } + +/* ---- editing (devdesk#20) ------------------------------------------------------------- */ +.dd-tab-view:not(.is-dirty) .dd-dirty-only { display: none; } +.dd-unsaved { color: var(--accent-amber); } +.dd-tab.is-dirty .dd-tab-close { opacity: 1; color: var(--accent-amber); } +.dd-tab.is-dirty .dd-tab-close:hover { color: var(--accent-red); } +.dd-viewer-btn.is-primary { border-color: var(--accent-orange); color: var(--accent-orange); } +.dd-viewer-btn.is-primary:hover { background: var(--accent-orange-dim); } +.dd-viewer-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + padding: 5px 10px; + border-bottom: 1px solid var(--accent-amber-dim); + background: var(--accent-amber-dim); + color: var(--accent-amber); + font-size: 0.72rem; +} +.dd-dialog.is-wide .lt-modal { width: min(1100px, 94vw); max-height: 88vh; } +.dd-dialog.is-wide .lt-modal-body { padding: 0; } +.dd-compare { display: flex; height: min(64vh, 640px); } +.dd-compare > .cm-editor { flex: 1; min-width: 0; }