Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66321f4748 | ||
|
|
927920c162 |
@@ -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/<session>.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.
|
||||
|
||||
+1
-1
@@ -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//-/_}" "$@"
|
||||
|
||||
Vendored
+36
@@ -1,3 +1,39 @@
|
||||
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 <jjvititoe1@gmail.com> 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
|
||||
that was ended. The 🔔 inbox, the session switcher, palette switches, the
|
||||
"needs you" chip and Matrix links (now ?arg=NAME&existing=1) open sessions
|
||||
existing-only; an ended one shows "NAME has ended" with an explicit
|
||||
"Start NAME again". Inbox entries for ended sessions are marked as such,
|
||||
and entries older than 7 days are dropped.
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 19:57:52 -0400
|
||||
|
||||
devdesk (0.7.2) bookworm; urgency=medium
|
||||
|
||||
* Fix: Go to file failed in big non-git folders such as /root/code: the
|
||||
|
||||
@@ -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: <current>}. 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() {
|
||||
|
||||
@@ -28,7 +28,9 @@ attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev
|
||||
|
||||
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
|
||||
url=""
|
||||
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session"
|
||||
# existing=1: if the session was ended by the time you tap, say so instead of
|
||||
# starting a new Claude there (devdesk#38).
|
||||
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session&existing=1"
|
||||
body=$(jq -cMn --arg s "$session" --arg m "$message" --arg u "$url" --arg h "$(hostname)" \
|
||||
'{session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h,
|
||||
text: ("Claude needs you in " + $s + ": " + $m + (if $u == "" then "" else " — " + $u end))}')
|
||||
|
||||
+56
-1
@@ -310,7 +310,7 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
|
||||
sleep 2.5
|
||||
[ "$(posts)" -eq 1 ]
|
||||
run jq -r '[.session, .url, (.text | test("Claude needs you in alpha"))] | @tsv' "$TMP/posts"
|
||||
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha\ttrue')" ]
|
||||
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha&existing=1\ttrue')" ]
|
||||
|
||||
# answered within the delay: nothing is posted
|
||||
echo '{"hook_event_name":"PostToolUse"}' | hook
|
||||
@@ -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 ]
|
||||
}
|
||||
|
||||
+30
-2
@@ -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({
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
<button type="button" class="dd-icon-btn dd-explorer-toggle" title="Show / hide the explorer" aria-label="Toggle explorer">☰</button>
|
||||
<div class="dd-tabs" role="tablist" aria-label="Open files"></div>
|
||||
<span class="dd-files-actions">
|
||||
<button type="button" class="dd-icon-btn dd-files-new" title="New file" aria-label="New file">+</button>
|
||||
<button type="button" class="dd-icon-btn dd-files-find" title="Go to file (Ctrl+P)" aria-label="Go to file">⌕</button>
|
||||
<button type="button" class="dd-icon-btn dd-files-refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||
<button type="button" class="dd-icon-btn dd-files-close" title="Close (Esc)" aria-label="Close files">✕</button>
|
||||
|
||||
+30
-14
@@ -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*/, '')));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
+325
-13
@@ -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.
|
||||
|
||||
+11
-4
@@ -8,11 +8,12 @@ import { ago } from './sessions.js';
|
||||
|
||||
const KEY = 'devdesk.inbox';
|
||||
const MAX = 30;
|
||||
const MAX_AGE = 7 * 86400 * 1000;
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(KEY) || '[]');
|
||||
return Array.isArray(list) ? list : [];
|
||||
return Array.isArray(list) ? list.filter((e) => Date.now() - e.at < MAX_AGE) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -45,6 +46,7 @@ export class Inbox {
|
||||
this.events = load();
|
||||
this.waiting = [];
|
||||
this.alerts = [];
|
||||
this.known = null; // session names that exist, once the list has loaded
|
||||
this.list = panel.querySelector('.lt-notif-panel-list');
|
||||
panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead());
|
||||
window.addEventListener('storage', (event) => {
|
||||
@@ -78,9 +80,10 @@ export class Inbox {
|
||||
}
|
||||
|
||||
// Live state: other sessions waiting on you, firing alerts.
|
||||
setLive({ waiting, alerts }) {
|
||||
setLive({ waiting, alerts, sessions }) {
|
||||
if (waiting) this.waiting = waiting;
|
||||
if (alerts) this.alerts = alerts;
|
||||
if (sessions) this.known = new Set(sessions);
|
||||
this.render();
|
||||
}
|
||||
|
||||
@@ -91,7 +94,7 @@ export class Inbox {
|
||||
}
|
||||
|
||||
unread() {
|
||||
return this.events.filter((e) => !e.read && e.kind !== 'waiting').length;
|
||||
return this.events.filter((e) => !e.read && e.kind !== 'waiting' && !(this.known && e.session && !e.url && !this.known.has(e.session))).length;
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -155,12 +158,16 @@ export class Inbox {
|
||||
nodes.push(el('div', { class: 'dd-notif-section' }, 'Recent'));
|
||||
for (const e of recent.slice(0, 12)) {
|
||||
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
|
||||
// An entry about a session that has since ended must not bring it back (devdesk#38).
|
||||
const ended = !e.url && e.session && this.known && !this.known.has(e.session);
|
||||
nodes.push(item({
|
||||
dot: e.read ? 'read' : kind.tone, unread: !e.read, title: e.text, meta: when(e.at),
|
||||
dot: e.read || ended ? 'read' : kind.tone, unread: !e.read && !ended, title: e.text,
|
||||
meta: `${when(e.at)}${ended ? ' · session ended' : ''}`,
|
||||
onclick: close(() => {
|
||||
this.events = this.events.map((x) => (x.id === e.id ? { ...x, read: true } : x));
|
||||
save(this.events);
|
||||
if (e.url) window.open(e.url, '_blank', 'noopener');
|
||||
else if (ended) this.hooks.ended?.(e.session);
|
||||
else if (e.session) this.hooks.openSession(e.session);
|
||||
}),
|
||||
}));
|
||||
|
||||
+46
-12
@@ -58,6 +58,8 @@ const ui = {
|
||||
};
|
||||
|
||||
const urlSession = sessionFromSearch(window.location.search);
|
||||
// Notification links (Matrix) add existing=1: open, never re-create (devdesk#38).
|
||||
const urlExisting = new URLSearchParams(window.location.search).get('existing') === '1';
|
||||
const state = {
|
||||
session: urlSession, // null = picker
|
||||
conn: null,
|
||||
@@ -169,7 +171,11 @@ function recordTransitions(sessions) {
|
||||
}
|
||||
|
||||
function renderPanelsBadge() {
|
||||
inbox?.setLive({ waiting: waitingOthers, alerts: lastAlerts?.alerts ?? [] });
|
||||
inbox?.setLive({
|
||||
waiting: waitingOthers,
|
||||
alerts: lastAlerts?.alerts ?? [],
|
||||
sessions: sessionsPanel?.data ? sessionsPanel.data.sessions.map((x) => x.name) : null,
|
||||
});
|
||||
}
|
||||
|
||||
function onSessionData({ fetched = false } = {}) {
|
||||
@@ -316,14 +322,19 @@ function updateUrl(session) {
|
||||
const url = new URL(window.location.href);
|
||||
if (session) url.searchParams.set('arg', session);
|
||||
else url.searchParams.delete('arg');
|
||||
url.searchParams.delete('existing');
|
||||
if (url.href !== window.location.href) window.history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- connection
|
||||
|
||||
// reconnect: automatic re-attach after a drop. It must not re-create a session
|
||||
// that was ended or renamed meanwhile, so it asks for --existing (devdesk#4).
|
||||
function connect(session, { reconnect = false } = {}) {
|
||||
// existing: go to a session that should already exist (reconnects, the
|
||||
// switcher, inbox entries, notification links). It must not re-create one that
|
||||
// was ended or renamed meanwhile, so it asks for --existing (devdesk#4, #38).
|
||||
// Creating is explicit: a typed name, the New dialog, the picker, recents.
|
||||
function connect(session, { existing = false, reconnect = false } = {}) {
|
||||
const existingOnly = Boolean(session) && (existing || reconnect);
|
||||
state.existingOnly = existingOnly;
|
||||
clearTimeout(state.retryTimer);
|
||||
state.conn?.dispose();
|
||||
state.session = session;
|
||||
@@ -339,7 +350,7 @@ function connect(session, { reconnect = false } = {}) {
|
||||
term.reset();
|
||||
|
||||
const conn = new TtydConnection({
|
||||
arg: session && reconnect ? ['--existing', session] : session,
|
||||
arg: existingOnly ? ['--existing', session] : session,
|
||||
size: () => ({ cols: term.cols, rows: term.rows }),
|
||||
onOpen: () => {
|
||||
state.openedAt = Date.now();
|
||||
@@ -373,6 +384,25 @@ function onClosed(code) {
|
||||
// few seconds of opening means the session could not start (the reason is
|
||||
// printed in the terminal), so don't retry forever.
|
||||
const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000;
|
||||
if (failedFast && state.existingOnly) {
|
||||
// Most likely ended or renamed since the link was made: say so, and only
|
||||
// start it again when asked.
|
||||
const name = state.session;
|
||||
sessionsPanel?.reload().then(() => {
|
||||
const known = sessionsPanel.data?.sessions?.some((x) => x.name === name);
|
||||
if (known) {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
setWsState('disconnected', 'ended');
|
||||
showOverlay({
|
||||
title: `${name} has ended`,
|
||||
text: `The session "${name}" no longer exists: it was ended or renamed. Its conversation is still in Claude's history.`,
|
||||
actions: [pickerAction, { label: `Start ${name} again`, run: () => connect(name) }],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (failedFast) {
|
||||
showOverlay({
|
||||
title: 'Session did not start',
|
||||
@@ -515,7 +545,7 @@ function renderSwitcher() {
|
||||
return el('button', {
|
||||
type: 'button', role: 'menuitem',
|
||||
class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`,
|
||||
title: s.dir || '', onclick: choose(() => connect(s.name)),
|
||||
title: s.dir || '', onclick: choose(() => connect(s.name, { existing: true })),
|
||||
},
|
||||
el('span', { class: `lt-dot ${status.dot}` }),
|
||||
el('span', { class: 'dd-switch-name' }, s.name),
|
||||
@@ -591,7 +621,7 @@ function buildCommands() {
|
||||
const status = sessionStatus(s);
|
||||
add({
|
||||
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir,
|
||||
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name),
|
||||
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name, { existing: true }),
|
||||
});
|
||||
}
|
||||
add({ id: 'new', group: 'Sessions', icon: '+', label: 'New session…', keywords: 'create open project', run: () => sessionsPanel.newDialog() });
|
||||
@@ -682,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'],
|
||||
@@ -866,7 +898,8 @@ function wireChrome() {
|
||||
badge: $('inbox-badge'),
|
||||
panel: $('inbox-panel'),
|
||||
hooks: {
|
||||
openSession: (name) => connect(name),
|
||||
openSession: (name) => connect(name, { existing: true }),
|
||||
ended: (name) => toast.info(`${name} has ended; its conversation is still in Claude's history`),
|
||||
showAlerts: () => {
|
||||
setSidebar(true);
|
||||
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
@@ -921,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 = '';
|
||||
}
|
||||
@@ -957,7 +990,7 @@ function wireChrome() {
|
||||
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo();
|
||||
}, 60 * 1000);
|
||||
ui.attention.addEventListener('click', () => {
|
||||
if (ui.attention.dataset.target) connect(ui.attention.dataset.target);
|
||||
if (ui.attention.dataset.target) connect(ui.attention.dataset.target, { existing: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1057,7 +1090,8 @@ async function boot() {
|
||||
wireUploads();
|
||||
refreshAlerts();
|
||||
await sessionsPanel.refresh();
|
||||
connect(urlSession ?? defaultSession(sessionsPanel.data));
|
||||
if (urlSession) connect(urlSession, { existing: urlExisting });
|
||||
else connect(defaultSession(sessionsPanel.data), { existing: true });
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
+2
-2
@@ -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),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user