Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f82c13612d | ||
|
|
a92d10ace3 | ||
|
|
66321f4748 |
@@ -40,8 +40,8 @@ install: all
|
|||||||
install -m 0644 libexec/devdesk-lib.sh $(DESTDIR)$(LIBDIR)/devdesk-lib.sh
|
install -m 0644 libexec/devdesk-lib.sh $(DESTDIR)$(LIBDIR)/devdesk-lib.sh
|
||||||
install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \
|
install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \
|
||||||
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
|
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
|
||||||
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix $(DESTDIR)$(LIBDIR)/
|
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix libexec/devdesk-guard $(DESTDIR)$(LIBDIR)/
|
||||||
install -m 0644 libexec/devdesk-files.sh $(DESTDIR)$(LIBDIR)/devdesk-files.sh
|
install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh $(DESTDIR)$(LIBDIR)/
|
||||||
install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd
|
install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd
|
||||||
install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
|
install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
|
||||||
install -d $(DESTDIR)$(SHAREDIR)/assets
|
install -d $(DESTDIR)$(SHAREDIR)/assets
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
|||||||
- The sidebar shows it with context % and a context bar; the session chip's dot shows the current session's state.
|
- The sidebar shows it with context % and a context bar; the session chip's dot shows the current session's state.
|
||||||
- A "… needs you" chip jumps to other sessions that are waiting, and a desktop notification fires when one starts waiting.
|
- A "… needs you" chip jumps to other sessions that are waiting, and a desktop notification fires when one starts waiting.
|
||||||
- The **🔔 inbox** lists what needs you (waiting sessions, firing Grafana alerts) and what happened while you were away (a background session finished or started waiting, CI passed or failed); its badge counts them.
|
- The **🔔 inbox** lists what needs you (waiting sessions, firing Grafana alerts) and what happened while you were away (a background session finished or started waiting, CI passed or failed); its badge counts them.
|
||||||
|
- **Answer from the inbox:** a session waiting on a permission shows the exact command/file and **Allow / Deny** (the key is pressed only if that prompt is still on screen); other waits and finished sessions offer **Peek & reply**: the last exchange and a reply box.
|
||||||
- **Matrix "Claude needs you":** when a session has waited for `DEVDESK_MATRIX_DELAY` seconds (30) and no browser tab or SSH client is attached to it, `devdesk-notify-matrix` posts one message per wait, with a link, to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off).
|
- **Matrix "Claude needs you":** when a session has waited for `DEVDESK_MATRIX_DELAY` seconds (30) and no browser tab or SSH client is attached to it, `devdesk-notify-matrix` posts one message per wait, with a link, to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off).
|
||||||
- **File & image drop:** drag files onto the terminal or paste a screenshot.
|
- **File & image drop:** drag files onto the terminal or paste a screenshot.
|
||||||
- They're uploaded through ttyd to `/var/lib/devdesk/uploads/<session>/` (25 MB max, pruned after 7 days), and the path is pasted into Claude.
|
- They're uploaded through ttyd to `/var/lib/devdesk/uploads/<session>/` (25 MB max, pruned after 7 days), and the path is pasted into Claude.
|
||||||
@@ -46,7 +47,10 @@ 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.
|
- **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`.
|
- 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.
|
- **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.
|
||||||
|
- **Paths are links:** a file path Claude prints (`web/src/files.js`, `notes.md:12`) opens in Files when clicked, at that line if given.
|
||||||
|
- **Ops guard:** `devdesk-guard` (a PreToolUse hook) makes Claude ask before destructive commands (`pct destroy`, `zfs destroy`, Ceph pool/OSD removal, `rm -r /…`, `git push --force`, `reboot`…), even in auto mode. `DEVDESK_GUARD=0` turns it off; `/etc/devdesk/guard.local` adds rules (`regex<TAB>reason`). Bash commands are logged per session, and sessions are tagged with the hosts they touch (ssh targets, CT/VM ids).
|
||||||
- **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.
|
- **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).
|
- **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.
|
- **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer.
|
||||||
|
|||||||
+19
-2
@@ -19,6 +19,8 @@ set -euo pipefail
|
|||||||
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
|
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
|
||||||
# shellcheck source=libexec/devdesk-files.sh
|
# shellcheck source=libexec/devdesk-files.sh
|
||||||
. "$DEVDESK_LIBEXEC/devdesk-files.sh"
|
. "$DEVDESK_LIBEXEC/devdesk-files.sh"
|
||||||
|
# shellcheck source=libexec/devdesk-inbox.sh
|
||||||
|
. "$DEVDESK_LIBEXEC/devdesk-inbox.sh"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
sed -n '2,12s/^# \{0,1\}//p' "$0"
|
sed -n '2,12s/^# \{0,1\}//p' "$0"
|
||||||
@@ -67,6 +69,7 @@ cmd_list() {
|
|||||||
--arg proj "$(projects_tsv)" \
|
--arg proj "$(projects_tsv)" \
|
||||||
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
|
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
|
||||||
--argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \
|
--argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \
|
||||||
|
--argjson tags "$(per_session_json "$DEVDESK_STATE_DIR/tags")" \
|
||||||
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
|
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
|
||||||
--arg root "$DEVDESK_PROJECTS_ROOT" '
|
--arg root "$DEVDESK_PROJECTS_ROOT" '
|
||||||
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
|
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
|
||||||
@@ -83,7 +86,7 @@ cmd_list() {
|
|||||||
| map({name: .key, dir: .value.dir, running: false, attached: 0,
|
| map({name: .key, dir: .value.dir, running: false, attached: 0,
|
||||||
activity: null, windows: 0,
|
activity: null, windows: 0,
|
||||||
claude_session_id: (.value.claude_session_id // null)}))
|
claude_session_id: (.value.claude_session_id // null)}))
|
||||||
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null)}),
|
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // [])}),
|
||||||
projects: ([{name: $dflt, dir: $root}]
|
projects: ([{name: $dflt, dir: $root}]
|
||||||
+ [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))}
|
+ [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))}
|
||||||
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
|
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
|
||||||
@@ -318,13 +321,27 @@ cmd_attach() {
|
|||||||
dd_linger
|
dd_linger
|
||||||
return
|
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#--}
|
local action=${1#--}
|
||||||
shift
|
shift
|
||||||
"cmd_${action//-/_}" "$@"
|
"cmd_${action//-/_}" "$@"
|
||||||
dd_linger
|
dd_linger
|
||||||
return
|
return
|
||||||
;;
|
;;
|
||||||
|
--peek)
|
||||||
|
shift
|
||||||
|
cmd_peek "$@"
|
||||||
|
dd_linger
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
--answer | --reply)
|
||||||
|
local action=${1#--}
|
||||||
|
shift
|
||||||
|
"cmd_$action" "$@"
|
||||||
|
ok_json
|
||||||
|
dd_linger
|
||||||
|
return
|
||||||
|
;;
|
||||||
--new | --rename | --end)
|
--new | --rename | --end)
|
||||||
local action=${1#--}
|
local action=${1#--}
|
||||||
shift
|
shift
|
||||||
|
|||||||
Vendored
+69
@@ -1,3 +1,72 @@
|
|||||||
|
devdesk (0.8.2) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* Click file paths in the terminal (#21): a path Claude prints
|
||||||
|
(web/src/files.js, ./notes.md:12, /root/code/x/y.py:3) is a link when it
|
||||||
|
is a file of the session; it opens in Files, at that line if given.
|
||||||
|
* Alerts: "In a new session" (#18) opens (or reuses) alert-<name> in the
|
||||||
|
default folder and types the investigation prompt there once Claude is
|
||||||
|
up, without sending it.
|
||||||
|
* Ops trail and guard (#27):
|
||||||
|
- devdesk-guard, a PreToolUse hook for Bash, makes Claude ask before
|
||||||
|
commands that destroy things (pct/qm destroy, zfs/zpool destroy, Ceph
|
||||||
|
pool/OSD removal, rbd rm, wipefs/mkfs/dd to /dev, rm -r of / or a
|
||||||
|
top-level folder, git push --force, DROP TABLE, stopping devdesk-tmux,
|
||||||
|
reboot/poweroff…), also in auto mode. It never blocks or allows on its
|
||||||
|
own. DEVDESK_GUARD=0 turns it off; /etc/devdesk/guard.local adds rules
|
||||||
|
(regex<TAB>reason).
|
||||||
|
- Every Bash command is logged per session (last 500); sessions are
|
||||||
|
tagged with the hosts they work on (ssh targets, CT/VM ids), shown in
|
||||||
|
the session list, the switcher and the palette; Peek lists the last
|
||||||
|
commands.
|
||||||
|
* Sessions pick up the new hooks when Claude next starts (/exit, then ⏎).
|
||||||
|
|
||||||
|
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 20:36:32 -0400
|
||||||
|
|
||||||
|
devdesk (0.8.1) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* Answer sessions from the 🔔 inbox without switching to them:
|
||||||
|
- Allow / Deny (#22): a PermissionRequest hook records what Claude wants
|
||||||
|
to run (tool, command/file/URL, Claude's description); the inbox shows
|
||||||
|
it with Allow, Deny and Open. The key is pressed in that session's
|
||||||
|
Claude window only if the same wait is still on and the permission
|
||||||
|
prompt is on screen, so a stale button never types into Claude.
|
||||||
|
- Peek & reply (#23): the last thing you asked and Claude's latest answer
|
||||||
|
(from the session's transcript), and a reply box; the reply is pasted
|
||||||
|
as one bracketed paste and sent. Available for sessions that wait for
|
||||||
|
input and from "finished" entries.
|
||||||
|
- Matrix "needs you" messages include the command.
|
||||||
|
* New attach commands: --answer SESSION SINCE allow|deny, --peek SESSION,
|
||||||
|
--reply SESSION BYTES (text streamed like uploads).
|
||||||
|
* Sessions pick up the new PermissionRequest hook when Claude next starts
|
||||||
|
(/exit, then ⏎).
|
||||||
|
|
||||||
|
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 20:22:37 -0400
|
||||||
|
|
||||||
|
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
|
devdesk (0.7.3) bookworm; urgency=medium
|
||||||
|
|
||||||
* Fix (#38): links that mean "go to that session" no longer re-create one
|
* Fix (#38): links that mean "go to that session" no longer re-create one
|
||||||
|
|||||||
@@ -42,5 +42,10 @@ DEVDESK_PUBLIC_URL=https://dev.lotusguild.org
|
|||||||
DEVDESK_MATRIX_WEBHOOK_FILE=/etc/devdesk/matrix-webhook
|
DEVDESK_MATRIX_WEBHOOK_FILE=/etc/devdesk/matrix-webhook
|
||||||
DEVDESK_MATRIX_DELAY=30
|
DEVDESK_MATRIX_DELAY=30
|
||||||
|
|
||||||
|
# 0 turns off the guard that makes Claude ask before destructive commands
|
||||||
|
# (pct/zfs/ceph destroy, rm -r /…, force pushes, reboots). Extra rules:
|
||||||
|
# /etc/devdesk/guard.local, one "regex<TAB>reason" per line.
|
||||||
|
DEVDESK_GUARD=1
|
||||||
|
|
||||||
# 1 = also enable Remote Control so sessions can be continued from the Claude app.
|
# 1 = also enable Remote Control so sessions can be continued from the Claude app.
|
||||||
DEVDESK_REMOTE_CONTROL=0
|
DEVDESK_REMOTE_CONTROL=0
|
||||||
|
|||||||
@@ -190,6 +190,81 @@ cmd_git_show() {
|
|||||||
rm -f "$tmp"
|
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),
|
# --asset NAME: a lazily loaded part of the web UI (the file viewer's editor),
|
||||||
# shipped in $DEVDESK_ASSETS and cached by the browser.
|
# shipped in $DEVDESK_ASSETS and cached by the browser.
|
||||||
cmd_asset() {
|
cmd_asset() {
|
||||||
|
|||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Claude Code PreToolUse hook for Bash (wired in via claude-settings.json):
|
||||||
|
# commands that destroy things (containers, VMs, pools, disks, root-level
|
||||||
|
# folders, cluster members…) get permissionDecision "ask", so Claude has to get
|
||||||
|
# your OK even in auto mode (devdesk#27). It never blocks and never allows on
|
||||||
|
# its own; anything else passes through untouched.
|
||||||
|
#
|
||||||
|
# DEVDESK_GUARD=0 in /etc/devdesk/env.local turns it off. Extra rules go in
|
||||||
|
# /etc/devdesk/guard.local, one per line: an extended regex, a tab, a reason.
|
||||||
|
|
||||||
|
# shellcheck source=libexec/devdesk-lib.sh
|
||||||
|
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" 2>/dev/null || exit 0
|
||||||
|
[ "${DEVDESK_GUARD:-1}" != 0 ] || exit 0
|
||||||
|
|
||||||
|
command=$(jq -rM '.tool_input.command // empty' 2>/dev/null) || exit 0
|
||||||
|
[ -n "$command" ] || exit 0
|
||||||
|
|
||||||
|
# Built-in rules: extended regex <TAB> reason.
|
||||||
|
rules=$(
|
||||||
|
cat <<'RULES'
|
||||||
|
\bpct[[:space:]]+destroy\b destroys a Proxmox container
|
||||||
|
\bqm[[:space:]]+destroy\b destroys a Proxmox VM
|
||||||
|
\bpvecm[[:space:]]+delnode\b removes a node from the Proxmox cluster
|
||||||
|
\bpveceph[[:space:]]+(osd|pool|mon|mgr|mds|fs)[[:space:]]+destroy\b destroys Ceph components
|
||||||
|
\bceph[[:space:]]+osd[[:space:]]+(purge|destroy|rm|out)\b removes or takes out a Ceph OSD
|
||||||
|
\bceph[[:space:]]+osd[[:space:]]+pool[[:space:]]+(delete|rm)\b deletes a Ceph pool
|
||||||
|
\bceph[[:space:]]+(fs[[:space:]]+rm|mon[[:space:]]+remove|auth[[:space:]]+del)\b removes Ceph components
|
||||||
|
\brbd[[:space:]]+(rm|remove|purge)\b deletes RBD images
|
||||||
|
\bzfs[[:space:]]+destroy\b destroys a ZFS dataset or snapshot
|
||||||
|
\bzpool[[:space:]]+(destroy|labelclear)\b destroys a ZFS pool
|
||||||
|
\b(lvremove|vgremove|pvremove|wipefs|shred)\b wipes volumes or disks
|
||||||
|
\bmkfs(\.[a-z0-9]+)?\b formats a filesystem
|
||||||
|
\bsgdisk[[:space:]]+(-[a-zA-Z]*Z|--zap) wipes a partition table
|
||||||
|
\bdd\b[^|;&]*\bof=/dev/ writes raw to a device
|
||||||
|
\brm[[:space:]]+(-[^[:space:]]*[[:space:]]+)*-[^[:space:]]*[rR][^[:space:]]*[[:space:]]+(-[^[:space:]]*[[:space:]]+)*['"]?/([a-z0-9_.*-]+/?)?['"]?([[:space:];&|)]|$) recursively deletes / or a top-level folder
|
||||||
|
\bgit[[:space:]]+push\b[^|;&]*([[:space:]]-f\b|--force) force-pushes (rewrites remote history)
|
||||||
|
\b(DROP[[:space:]]+(DATABASE|SCHEMA|TABLE)|TRUNCATE[[:space:]]+TABLE)\b drops database objects
|
||||||
|
\bsystemctl[[:space:]]+(stop|restart|kill|disable|mask)[[:space:]]+devdesk-tmux ends every DevDesk session
|
||||||
|
(^|[;&|][[:space:]]*|\bssh[[:space:]]+[^[:space:]]+[[:space:]]+['"]?|\bsystemctl[[:space:]]+)(reboot|poweroff|halt|shutdown)\b reboots or shuts down a machine
|
||||||
|
\bkubectl[[:space:]]+delete\b deletes Kubernetes resources
|
||||||
|
\bdocker[[:space:]]+(system[[:space:]]+prune|volume[[:space:]]+(rm|prune))\b deletes Docker data
|
||||||
|
RULES
|
||||||
|
cat "${DEVDESK_GUARD_LOCAL:-/etc/devdesk/guard.local}" 2>/dev/null
|
||||||
|
)
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r pattern reason; do
|
||||||
|
if [ -z "$pattern" ] || [ "${pattern:0:1}" = "#" ]; then continue; fi
|
||||||
|
if grep -Eiq -- "$pattern" <<<"$command" 2>/dev/null; then
|
||||||
|
jq -cMn --arg r "DevDesk guard: this command ${reason:-matches a rule in guard.local}. Confirm before it runs." \
|
||||||
|
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: $r}}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done <<<"$rules"
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
|
||||||
|
# Answer a session without switching to it (devdesk#22, #23), sourced by
|
||||||
|
# bin/devdesk and reached through `devdesk attach --answer|--peek|--reply`.
|
||||||
|
|
||||||
|
dd_state_file() {
|
||||||
|
printf '%s/%s.json\n' "$DEVDESK_STATUS_DIR" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The Claude window's visible screen (not the scrollback).
|
||||||
|
dd_claude_screen() {
|
||||||
|
dd_tmux capture-pane -p -J -t "=$1:claude" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# --answer SESSION SINCE allow|deny: press the permission prompt's key in that
|
||||||
|
# session's Claude window. Only when the same wait is still on (SINCE) and the
|
||||||
|
# prompt is actually on screen, so a stale button never types into Claude.
|
||||||
|
cmd_answer() {
|
||||||
|
[ $# -eq 3 ] || dd_die "usage: devdesk attach --answer SESSION SINCE allow|deny"
|
||||||
|
local session=$1 since=$2 choice=$3 file
|
||||||
|
dd_valid_name "$session" || dd_die "invalid session name: $session"
|
||||||
|
[[ $since =~ ^[0-9]+$ ]] || dd_die "bad wait id: $since"
|
||||||
|
case $choice in allow | deny) ;; *) dd_die "answer allow or deny" ;; esac
|
||||||
|
if ! { dd_server_up && dd_tmux has-session -t "=$session" 2>/dev/null; }; then dd_die "session '$session' isn't running"; fi
|
||||||
|
file=$(dd_state_file "$session")
|
||||||
|
if [ "$(jq -rM '"\(.state) \(.since) \(.request != null)"' "$file" 2>/dev/null)" != "waiting $since true" ]; then
|
||||||
|
dd_die "nothing to answer: it was already answered"
|
||||||
|
fi
|
||||||
|
# Every permission prompt lists "1. Yes" first (Bash, edits, fetches, MCP).
|
||||||
|
if ! dd_claude_screen "$session" | tail -n 40 | grep -Eq '^[[:space:]]*(❯[[:space:]]*)?1\.[[:space:]]+Yes'; then
|
||||||
|
dd_die "the permission prompt isn't on screen any more"
|
||||||
|
fi
|
||||||
|
if [ "$choice" = allow ]; then
|
||||||
|
dd_tmux send-keys -t "=$session:claude" 1
|
||||||
|
else
|
||||||
|
dd_tmux send-keys -t "=$session:claude" Escape
|
||||||
|
fi
|
||||||
|
# A denial doesn't fire a hook; the turn ends there. An approval is followed
|
||||||
|
# by PostToolUse, which takes it from here.
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp "$DEVDESK_STATUS_DIR/.$session.XXXXXX") || return 0
|
||||||
|
if jq -cMn --arg s "$([ "$choice" = allow ] && echo working || echo idle)" --arg m "$([ "$choice" = allow ] && echo "Allowed from DevDesk" || echo "Denied from DevDesk")" \
|
||||||
|
'{state: $s, since: (now | floor), event: "DevDeskAnswer", message: $m}' >"$tmp"; then
|
||||||
|
mv -f "$tmp" "$file"
|
||||||
|
else
|
||||||
|
rm -f "$tmp"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# The session's conversation file, from the id the SessionStart hook recorded.
|
||||||
|
dd_transcript() {
|
||||||
|
local id
|
||||||
|
id=$(dd_registry_get "$1" claude_session_id)
|
||||||
|
[[ $id =~ ^[0-9a-f-]{36}$ ]] || return 1
|
||||||
|
compgen -G "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/*/$id.jsonl" | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --peek SESSION: what the session is doing and the last exchange, from the end
|
||||||
|
# of its transcript (the last assistant text and the last thing you asked).
|
||||||
|
cmd_peek() {
|
||||||
|
[ $# -eq 1 ] || dd_die "usage: devdesk attach --peek SESSION"
|
||||||
|
local session=$1 transcript status commands
|
||||||
|
dd_valid_name "$session" || dd_die "invalid session name: $session"
|
||||||
|
status=$(jq -cM . "$(dd_state_file "$session")" 2>/dev/null) || status=null
|
||||||
|
# The last commands this session ran (devdesk#27).
|
||||||
|
commands=$(tail -n 8 "$DEVDESK_STATE_DIR/commands/$session.jsonl" 2>/dev/null | jq -cMs '.' 2>/dev/null) || commands='[]'
|
||||||
|
[ -n "$commands" ] || commands='[]'
|
||||||
|
transcript=$(dd_transcript "$session") || transcript=""
|
||||||
|
if [ -z "$transcript" ] || [ ! -r "$transcript" ]; then
|
||||||
|
jq -cMn --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '{session: $s, status: $st, assistant: null, user: null, commands: $c}'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
tail -n 400 -- "$transcript" | jq -cMs --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '
|
||||||
|
def text: if type == "string" then . else ([.[]? | select(.type == "text") | .text] | join("\n")) end;
|
||||||
|
[.[] | select(.isSidechain != true and .isMeta != true)] as $all
|
||||||
|
| ([$all[] | select(.type == "assistant") | {text: (.message.content | text), at: .timestamp} | select(.text != "")] | last) as $a
|
||||||
|
| ([$all[] | select(.type == "user" and (.message.content | type == "string" or (any(.[]?; .type == "text"))))
|
||||||
|
| {text: (.message.content | text), at: .timestamp}
|
||||||
|
| select(.text != "" and (.text | startswith("<") | not))] | last) as $u
|
||||||
|
| {session: $s, status: $st,
|
||||||
|
assistant: (if $a then {text: ($a.text | .[-4000:]), at: $a.at, truncated: ($a.text | length > 4000)} else null end),
|
||||||
|
user: (if $u then {text: ($u.text | .[-600:]), at: $u.at} else null end), commands: $c}' 2>/dev/null ||
|
||||||
|
jq -cMn --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '{session: $s, status: $st, assistant: null, user: null, commands: $c}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --reply SESSION BYTES: type a message into the session's Claude prompt and
|
||||||
|
# send it. The text arrives as base64 after the READY line (like uploads) and is
|
||||||
|
# pasted as one bracketed paste, so newlines in it don't send it early.
|
||||||
|
cmd_reply() {
|
||||||
|
[ $# -eq 2 ] || dd_die "usage: devdesk attach --reply SESSION BYTES"
|
||||||
|
local session=$1 size=$2 file tmp b64len
|
||||||
|
dd_valid_name "$session" || dd_die "invalid session name: $session"
|
||||||
|
if ! [[ $size =~ ^[0-9]+$ ]] || [ "$size" -lt 1 ] || [ "$size" -gt 16384 ]; then dd_die "a reply is 1 to 16384 bytes"; fi
|
||||||
|
if ! { dd_server_up && dd_tmux has-session -t "=$session" 2>/dev/null; }; then dd_die "session '$session' isn't running"; fi
|
||||||
|
file=$(dd_state_file "$session")
|
||||||
|
if [ "$(jq -rM '"\(.state) \(.request != null)"' "$file" 2>/dev/null)" = "waiting true" ]; then
|
||||||
|
dd_die "Claude is asking for a permission: answer that first"
|
||||||
|
fi
|
||||||
|
tmp=$(mktemp)
|
||||||
|
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-REPLY-READY\r\n'
|
||||||
|
head -c "$b64len" | base64 -d >"$tmp" 2>/dev/null || true
|
||||||
|
stty sane 2>/dev/null || true
|
||||||
|
if [ "$(stat -c %s "$tmp")" != "$size" ]; then
|
||||||
|
rm -f "$tmp"
|
||||||
|
dd_die "reply incomplete"
|
||||||
|
fi
|
||||||
|
dd_tmux load-buffer -b devdesk-reply "$tmp" && dd_tmux paste-buffer -p -d -b devdesk-reply -t "=$session:claude" &&
|
||||||
|
sleep 0.2 && dd_tmux send-keys -t "=$session:claude" Enter
|
||||||
|
rm -f "$tmp"
|
||||||
|
trap - HUP INT TERM
|
||||||
|
}
|
||||||
+10
-4
@@ -132,7 +132,8 @@ dd_registry_set_claude() {
|
|||||||
|
|
||||||
dd_registry_remove() {
|
dd_registry_remove() {
|
||||||
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
|
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
|
||||||
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt"
|
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt" \
|
||||||
|
"$DEVDESK_STATE_DIR/commands/$1.jsonl" "$DEVDESK_STATE_DIR/tags/$1.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
dd_registry_rename() {
|
dd_registry_rename() {
|
||||||
@@ -144,9 +145,14 @@ dd_registry_rename() {
|
|||||||
mv -f "$d/$1.json" "$d/$2.json"
|
mv -f "$d/$1.json" "$d/$2.json"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -f "$DEVDESK_STATE_DIR/edits/$1.txt" ]; then
|
local f
|
||||||
mv -f "$DEVDESK_STATE_DIR/edits/$1.txt" "$DEVDESK_STATE_DIR/edits/$2.txt"
|
for f in "edits/%s.txt" "commands/%s.jsonl" "tags/%s.json"; do
|
||||||
fi
|
# shellcheck disable=SC2059 # the pattern is ours
|
||||||
|
if [ -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" ]; then
|
||||||
|
# shellcheck disable=SC2059
|
||||||
|
mv -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" "$DEVDESK_STATE_DIR/$(printf "$f" "$2")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# Project name (directory or its slug) -> absolute directory inside the projects root.
|
# Project name (directory or its slug) -> absolute directory inside the projects root.
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev
|
|||||||
[ "${attached:-0}" = 0 ] || exit 0
|
[ "${attached:-0}" = 0 ] || exit 0
|
||||||
|
|
||||||
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
|
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
|
||||||
|
detail=$(jq -rM '.request.detail // empty' "$state_file" 2>/dev/null)
|
||||||
|
[ -z "$detail" ] || message="$message: ${detail:0:300}"
|
||||||
url=""
|
url=""
|
||||||
# existing=1: if the session was ended by the time you tap, say so instead of
|
# existing=1: if the session was ended by the time you tap, say so instead of
|
||||||
# starting a new Claude there (devdesk#38).
|
# starting a new Claude there (devdesk#38).
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
# Claude Code hook (wired in via claude-settings.json) that tracks what Claude is
|
# Claude Code hook (wired in via claude-settings.json) that tracks what Claude is
|
||||||
# doing in each DevDesk session, for the sidebar's at-a-glance status:
|
# doing in each DevDesk session, for the sidebar's at-a-glance status:
|
||||||
# UserPromptSubmit, PostToolUse -> working
|
# UserPromptSubmit, PostToolUse -> working
|
||||||
|
# PermissionRequest -> waiting, with what Claude wants to run
|
||||||
|
# (tool + command/file/URL), for the inbox's
|
||||||
|
# Allow/Deny (devdesk#22)
|
||||||
# Notification -> waiting (Claude needs input or a permission)
|
# Notification -> waiting (Claude needs input or a permission)
|
||||||
# Stop, SessionStart -> idle
|
# Stop, SessionStart -> idle
|
||||||
# SessionEnd -> exited
|
# SessionEnd -> exited
|
||||||
@@ -33,9 +36,39 @@ PostToolUse:Edit | PostToolUse:Write | PostToolUse:MultiEdit | PostToolUse:Noteb
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
# Ops trail (devdesk#27): every Bash command, and the hosts a session works on
|
||||||
|
# (ssh targets, Proxmox CT/VM ids) as tags for the session list.
|
||||||
|
if [ "$event:$tool" = PostToolUse:Bash ]; then
|
||||||
|
cmd=$(jq -rM '.tool_input.command // empty' <<<"$input" 2>/dev/null)
|
||||||
|
if [ -n "$cmd" ]; then
|
||||||
|
base="${DEVDESK_STATE_DIR:-/var/lib/devdesk}"
|
||||||
|
mkdir -p "$base/commands" "$base/tags" 2>/dev/null
|
||||||
|
log="$base/commands/$session.jsonl"
|
||||||
|
jq -cMn --arg c "${cmd:0:500}" '{at: (now | floor), cmd: $c}' >>"$log" 2>/dev/null
|
||||||
|
if [ "$(wc -l <"$log" 2>/dev/null || echo 0)" -gt 600 ]; then
|
||||||
|
tail -n 500 "$log" >"$log.tmp" 2>/dev/null && mv -f "$log.tmp" "$log"
|
||||||
|
fi
|
||||||
|
hosts=$(
|
||||||
|
# ssh [options] [user@]host: the first word that isn't an option (or an option's value)
|
||||||
|
awk '{ for (i = 1; i <= NF; i++) if ($i == "ssh") { j = i + 1
|
||||||
|
while (j <= NF && $j ~ /^-/) { if ($j ~ /^-[bcDEeFIiJLlmOopQRSWw]$/) j++; j++ }
|
||||||
|
if (j <= NF) { h = $j; sub(/^[^@]*@/, "", h); gsub(/[^A-Za-z0-9_.-].*$/, "", h); if (h != "") print h } } }' <<<"$cmd"
|
||||||
|
grep -oE '\bpct[[:space:]]+(exec|enter|push|pull|config|set|start|stop|shutdown|reboot|status|snapshot|rollback|migrate)[[:space:]]+[0-9]{3,}' <<<"$cmd" | grep -oE '[0-9]+$' | sed 's/^/CT /'
|
||||||
|
grep -oE '\bqm[[:space:]]+(guest[[:space:]]+exec|terminal|config|set|start|stop|shutdown|reboot|status|snapshot|rollback|migrate)[[:space:]]+[0-9]{3,}' <<<"$cmd" | grep -oE '[0-9]+$' | sed 's/^/VM /'
|
||||||
|
)
|
||||||
|
if [ -n "$hosts" ]; then
|
||||||
|
tags="$base/tags/$session.json"
|
||||||
|
jq -cMn --rawfile new <(printf '%s\n' "$hosts") --slurpfile old <(cat "$tags" 2>/dev/null || echo '{}') '
|
||||||
|
($new | split("\n") | map(select(length > 0))) as $n
|
||||||
|
| {hosts: ($n + ($old[0].hosts // []) | reduce .[] as $h ([]; if index([$h]) then . else . + [$h] end) | .[:4]),
|
||||||
|
at: (now | floor)}' >"$tags.tmp" 2>/dev/null && mv -f "$tags.tmp" "$tags"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
case $event in
|
case $event in
|
||||||
UserPromptSubmit | PostToolUse) state=working ;;
|
UserPromptSubmit | PostToolUse) state=working ;;
|
||||||
Notification) state=waiting ;;
|
Notification | PermissionRequest) state=waiting ;;
|
||||||
Stop | SessionStart) state=idle ;;
|
Stop | SessionStart) state=idle ;;
|
||||||
SessionEnd) state=exited ;;
|
SessionEnd) state=exited ;;
|
||||||
*) exit 0 ;;
|
*) exit 0 ;;
|
||||||
@@ -47,19 +80,33 @@ file="$dir/$session.json"
|
|||||||
if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then
|
if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# What a permission prompt is about. tool_use_id is null in PermissionRequest,
|
||||||
|
# so a wait is identified by its `since` instead.
|
||||||
|
request=null
|
||||||
|
if [ "$event" = PermissionRequest ]; then
|
||||||
|
request=$(jq -cM '(.tool_input // {}) as $i | {tool: (.tool_name // "a tool"),
|
||||||
|
detail: (($i.command // $i.file_path // $i.notebook_path // $i.url // $i.pattern // $i.query // $i.path // ($i | tostring)) | tostring | .[0:600]),
|
||||||
|
description: ($i.description // null)}' <<<"$input" 2>/dev/null) || request=null
|
||||||
|
message="Claude wants to use ${tool:-a tool}"
|
||||||
|
fi
|
||||||
# A second Notification during the same wait updates the message but keeps
|
# A second Notification during the same wait updates the message but keeps
|
||||||
# `since`, so the wait stays one wait (one Matrix message at most).
|
# `since` (and what the wait is about), so the wait stays one wait (one Matrix
|
||||||
|
# message at most).
|
||||||
since=$(date +%s)
|
since=$(date +%s)
|
||||||
was_waiting=0
|
was_waiting=0
|
||||||
if [ "$state" = waiting ] && grep -q '"state":"waiting"' "$file" 2>/dev/null; then
|
if [ "$state" = waiting ] && grep -q '"state":"waiting"' "$file" 2>/dev/null; then
|
||||||
was_waiting=1
|
was_waiting=1
|
||||||
since=$(jq -rM '.since // empty' "$file" 2>/dev/null) || since=$(date +%s)
|
since=$(jq -rM '.since // empty' "$file" 2>/dev/null) || since=$(date +%s)
|
||||||
[[ $since =~ ^[0-9]+$ ]] || since=$(date +%s)
|
[[ $since =~ ^[0-9]+$ ]] || since=$(date +%s)
|
||||||
|
if [ "$request" = null ]; then
|
||||||
|
request=$(jq -cM '.request // null' "$file" 2>/dev/null) || request=null
|
||||||
|
[ "$request" = null ] || message=$(jq -rM '.message // empty' "$file" 2>/dev/null)
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
mkdir -p "$dir" 2>/dev/null || exit 0
|
mkdir -p "$dir" 2>/dev/null || exit 0
|
||||||
tmp=$(mktemp "$dir/.$session.XXXXXX" 2>/dev/null) || exit 0
|
tmp=$(mktemp "$dir/.$session.XXXXXX" 2>/dev/null) || exit 0
|
||||||
if jq -cMn --arg s "$state" --arg m "$message" --arg e "$event" --argjson t "$since" \
|
if jq -cMn --arg s "$state" --arg m "$message" --arg e "$event" --argjson t "$since" --argjson r "${request:-null}" \
|
||||||
'{state: $s, since: $t, event: $e, message: (if $m == "" then null else $m end)}' >"$tmp"; then
|
'{state: $s, since: $t, event: $e, message: (if $m == "" then null else $m end)} + (if $r then {request: $r} else {} end)' >"$tmp"; then
|
||||||
mv -f "$tmp" "$file"
|
mv -f "$tmp" "$file"
|
||||||
else
|
else
|
||||||
rm -f "$tmp"
|
rm -f "$tmp"
|
||||||
|
|||||||
@@ -73,6 +73,29 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"PermissionRequest": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "/usr/lib/devdesk/devdesk-guard",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"statusLine": {
|
"statusLine": {
|
||||||
|
|||||||
@@ -620,3 +620,168 @@ blob_body() {
|
|||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
[ "$(jq -r '[(.entries | length), .truncated] | @tsv' <<<"$output")" = "$(printf '5\ttrue')" ]
|
[ "$(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 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "permission requests are recorded and answered only while their prompt shows (#22)" {
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
claude_args alpha >/dev/null
|
||||||
|
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
|
||||||
|
hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
|
||||||
|
echo '{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x","description":"Remove x"}}' | hook
|
||||||
|
echo '{"hook_event_name":"Notification","message":"Claude needs your permission","notification_type":"permission_prompt"}' | hook
|
||||||
|
st="$DEVDESK_STATE_DIR/state/alpha.json"
|
||||||
|
[ "$(jq -r '[.state, .request.tool, .request.detail, .request.description, .message] | @tsv' "$st")" = \
|
||||||
|
"$(printf 'waiting\tBash\trm -rf /tmp/x\tRemove x\tClaude wants to use Bash')" ]
|
||||||
|
since=$(jq -r .since "$st")
|
||||||
|
# The fake Claude window shows no prompt: refused, nothing typed.
|
||||||
|
run devdesk attach --answer alpha "$since" allow
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"isn't on screen"* ]]
|
||||||
|
# Put a prompt on screen (what Claude draws), then allow.
|
||||||
|
tmux -S "$DEVDESK_SOCKET" respawn-window -k -t =alpha:claude "printf ' Do you want to proceed?\n ❯ 1. Yes\n 2. No\n'; read -r -n1 k; echo \"got:\$k\" > '$TMP/key'; sleep 30"
|
||||||
|
sleep 0.5
|
||||||
|
run devdesk attach --answer alpha 999 allow
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"already answered"* ]]
|
||||||
|
run devdesk attach --answer alpha "$since" allow
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
sleep 0.5
|
||||||
|
[ "$(cat "$TMP/key")" = "got:1" ]
|
||||||
|
[ "$(jq -r .state "$st")" = working ]
|
||||||
|
# Answered: a second click is refused.
|
||||||
|
run devdesk attach --answer alpha "$since" deny
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "peek shows the last exchange; reply pastes into the Claude window (#23)" {
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
claude_args alpha >/dev/null
|
||||||
|
export CLAUDE_CONFIG_DIR="$TMP/claude"
|
||||||
|
id=11111111-2222-3333-4444-555555555555
|
||||||
|
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-x"
|
||||||
|
cat >"$CLAUDE_CONFIG_DIR/projects/-x/$id.jsonl" <<'JSONL'
|
||||||
|
{"type":"user","message":{"role":"user","content":"Please upgrade PBS"},"timestamp":"2026-10-10T10:00:00Z"}
|
||||||
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{}}]},"timestamp":"2026-10-10T10:00:05Z"}
|
||||||
|
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]},"timestamp":"2026-10-10T10:00:06Z"}
|
||||||
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"PBS is on 4.1. Reboot now?"}]},"timestamp":"2026-10-10T10:00:09Z"}
|
||||||
|
JSONL
|
||||||
|
jq --arg id "$id" '.sessions.alpha.claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
|
||||||
|
run devdesk attach --peek alpha
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(jq -r '[.assistant.text, .user.text] | @tsv' <<<"$output")" = "$(printf 'PBS is on 4.1. Reboot now?\tPlease upgrade PBS')" ]
|
||||||
|
# reply: lands in the Claude window as one paste + Enter
|
||||||
|
tmux -S "$DEVDESK_SOCKET" respawn-window -k -t =alpha:claude "IFS= read -r line; printf '%s' \"\$line\" > '$TMP/got'; sleep 30"
|
||||||
|
sleep 0.3
|
||||||
|
printf 'yes, reboot it' >"$TMP/msg"
|
||||||
|
run bash -c "base64 -w0 '$TMP/msg' | '$REPO/bin/devdesk' attach --reply alpha 14"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
sleep 0.5
|
||||||
|
[ "$(cat "$TMP/got")" = "yes, reboot it" ]
|
||||||
|
# not while a permission prompt waits
|
||||||
|
mkdir -p "$DEVDESK_STATE_DIR/state"
|
||||||
|
printf '{"state":"waiting","since":1,"request":{"tool":"Bash"}}\n' >"$DEVDESK_STATE_DIR/state/alpha.json"
|
||||||
|
run bash -c "base64 -w0 '$TMP/msg' | '$REPO/bin/devdesk' attach --reply alpha 14"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"answer that first"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the guard asks before destructive commands and lets the rest through (#27)" {
|
||||||
|
guard() { jq -cn --arg c "$1" '{hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: {command: $c}}' | "$REPO/libexec/devdesk-guard"; }
|
||||||
|
asks=("pct destroy 105" "ssh root@large1 'pct destroy 105 --purge'" "qm destroy 210" "zfs destroy rpool/data/vm-105-disk-0"
|
||||||
|
"zpool destroy tank" "ceph osd pool delete rbd rbd --yes-i-really-really-mean-it" "rbd rm vm-105-disk-0"
|
||||||
|
"rm -rf /" "rm -rf /etc" "sudo rm -r -f /var/" "dd if=/dev/zero of=/dev/sdb bs=1M" "mkfs.ext4 /dev/sdb1" "wipefs -a /dev/sdc"
|
||||||
|
"git push --force origin main" "git push -f" "psql -c 'drop table users'" "systemctl restart devdesk-tmux"
|
||||||
|
"ssh pve1 reboot" "systemctl poweroff" "cd /tmp && shutdown -h now")
|
||||||
|
for c in "${asks[@]}"; do
|
||||||
|
run guard "$c"
|
||||||
|
[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" = ask ] || { echo "should ask: $c"; false; }
|
||||||
|
done
|
||||||
|
passes=("pct list" "pct exec 105 -- df -h" "zfs list" "rm -rf /tmp/build-cache" "rm -rf node_modules" "rm -rf ./dist"
|
||||||
|
"git push origin main" "dd if=a.img of=b.img" "echo reboot later" "systemctl restart devdesk" "ceph -s" "ls -la /")
|
||||||
|
for c in "${passes[@]}"; do
|
||||||
|
run guard "$c"
|
||||||
|
[ -z "$output" ] || { echo "should pass: $c -> $output"; false; }
|
||||||
|
done
|
||||||
|
# Local rules and the off switch
|
||||||
|
printf 'nuke-it\tis a local rule\n' >"$TMP/guard.local"
|
||||||
|
run env DEVDESK_GUARD_LOCAL="$TMP/guard.local" bash -c "jq -cn '{tool_input: {command: \"nuke-it now\"}}' | '$REPO/libexec/devdesk-guard'"
|
||||||
|
[[ $output == *"is a local rule"* ]]
|
||||||
|
run env DEVDESK_GUARD=0 bash -c "jq -cn '{tool_input: {command: \"pct destroy 1\"}}' | '$REPO/libexec/devdesk-guard'"
|
||||||
|
[ -z "$output" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "Bash commands are logged and tag the session with the hosts it works on (#27)" {
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
|
||||||
|
bash_cmd() { jq -cn --arg c "$1" '{hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: {command: $c}}' |
|
||||||
|
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
|
||||||
|
bash_cmd "ssh -o BatchMode=yes root@large1 'pct exec 167 -- systemctl status authelia'"
|
||||||
|
bash_cmd "ls -la"
|
||||||
|
bash_cmd "ssh pve2 qm status 210"
|
||||||
|
[ "$(wc -l <"$DEVDESK_STATE_DIR/commands/alpha.jsonl")" -eq 3 ]
|
||||||
|
run devdesk attach --list
|
||||||
|
[ "$(jq -r '.sessions[] | select(.name == "alpha") | .tags | join(",")' <<<"$output")" = "pve2,VM 210,large1,CT 167" ]
|
||||||
|
run devdesk attach --peek alpha
|
||||||
|
[ "$(jq -r '.commands | map(.cmd) | .[1]' <<<"$output")" = "ls -la" ]
|
||||||
|
devdesk rename alpha beta
|
||||||
|
[ -f "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ -f "$DEVDESK_STATE_DIR/tags/beta.json" ]
|
||||||
|
devdesk end beta
|
||||||
|
[ ! -e "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ ! -e "$DEVDESK_STATE_DIR/tags/beta.json" ]
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,3 +86,15 @@ test('untrusted text loses control characters before it is typed (#2)', () => {
|
|||||||
assert.equal(pasteSafe('plain text'), 'plain text');
|
assert.equal(pasteSafe('plain text'), 'plain text');
|
||||||
assert.equal(pasteSafe(null), '');
|
assert.equal(pasteSafe(null), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('file paths in terminal output are found, URLs and plain words are not (#21)', async () => {
|
||||||
|
const { findPaths } = await import('../web/src/links.js');
|
||||||
|
const paths = (text) => findPaths(text).map((p) => `${p.path}${p.line ? `:${p.line}` : ''}`);
|
||||||
|
assert.deepEqual(paths('● Update(web/src/files.js)'), ['web/src/files.js']);
|
||||||
|
assert.deepEqual(paths(' ⎿ Wrote 12 lines to notes/todo.md'), ['notes/todo.md']);
|
||||||
|
assert.deepEqual(paths('error at src/app.py:42:7 and ./README.md:3'), ['src/app.py:42', './README.md:3']);
|
||||||
|
assert.deepEqual(paths('see /root/code/devdesk/Makefile'), ['/root/code/devdesk/Makefile']);
|
||||||
|
assert.deepEqual(paths('open https://code.lotusguild.org/a/b.js now'), []);
|
||||||
|
// Candidates only: they become links only if they're files of the session.
|
||||||
|
assert.deepEqual(paths('just some words, v1.2 and e.g. stuff'), ['v1.2']);
|
||||||
|
});
|
||||||
|
|||||||
+39
-3
@@ -4,7 +4,7 @@
|
|||||||
// the first time the Files pane opens, so the page itself stays small.
|
// 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 { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars } from '@codemirror/view';
|
||||||
import { EditorState } from '@codemirror/state';
|
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 { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
|
||||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||||
import { markdownLanguage } from '@codemirror/lang-markdown';
|
import { markdownLanguage } from '@codemirror/lang-markdown';
|
||||||
@@ -135,10 +135,46 @@ function baseExtensions(path, { readOnly = true, wrap = false } = {}) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read-only (v1) view of a file.
|
// Put the cursor on a line (1-based) and scroll it to the middle.
|
||||||
|
function gotoLine(view, line) {
|
||||||
|
const target = view.state.doc.line(Math.max(1, Math.min(line, view.state.doc.lines)));
|
||||||
|
view.dispatch({ selection: { anchor: target.from, head: target.to }, effects: EditorView.scrollIntoView(target.from, { y: 'center' }) });
|
||||||
|
view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only view of a file.
|
||||||
export function viewer(parent, { doc, path, wrap }) {
|
export function viewer(parent, { doc, path, wrap }) {
|
||||||
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
|
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
|
||||||
return { view, destroy: () => view.destroy() };
|
return { view, goto: (line) => gotoLine(view, line), 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,
|
||||||
|
goto: (line) => gotoLine(view, line),
|
||||||
|
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.
|
// Unified diff of the HEAD version against the working copy.
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
<button type="button" class="dd-icon-btn dd-explorer-toggle" title="Show / hide the explorer" aria-label="Toggle explorer">☰</button>
|
<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>
|
<div class="dd-tabs" role="tablist" aria-label="Open files"></div>
|
||||||
<span class="dd-files-actions">
|
<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-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-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>
|
<button type="button" class="dd-icon-btn dd-files-close" title="Close (Esc)" aria-label="Close files">✕</button>
|
||||||
|
|||||||
+6
-1
@@ -60,5 +60,10 @@ export function renderAlerts(root, data) {
|
|||||||
el('span', { class: 'dd-muted dd-alert-age' }, age(a.since))),
|
el('span', { class: 'dd-muted dd-alert-age' }, age(a.since))),
|
||||||
a.instances?.length ? el('div', { class: 'dd-muted dd-alert-instances' }, a.instances.join(' · ')) : null,
|
a.instances?.length ? el('div', { class: 'dd-muted dd-alert-instances' }, a.instances.join(' · ')) : null,
|
||||||
a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null,
|
a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null,
|
||||||
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a) }, 'Ask Claude'))));
|
el('div', { class: 'dd-ask-row' },
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a), title: 'Type the prompt into the current session (not sent)' }, 'Ask Claude'),
|
||||||
|
el('button', {
|
||||||
|
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a),
|
||||||
|
'data-ask-session': `alert-${a.name}`, title: 'Open a separate ops session for this alert and type the prompt there (not sent)',
|
||||||
|
}, 'In a new session')))));
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-14
@@ -209,11 +209,43 @@ export function uploadFile(session, file, onProgress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function uploadNow(session, file, onProgress) {
|
async function uploadNow(session, file, onProgress) {
|
||||||
const token = await fetchToken();
|
|
||||||
const name = (file.name || 'pasted-image.png').slice(-100);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answer a session from the inbox (devdesk#22, #23).
|
||||||
|
export function answerPrompt(session, since, choice) {
|
||||||
|
return sessionAction('answer', session, String(since), choice);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function peekSession(session) {
|
||||||
|
return parseListOutput(await runCommand(['--peek', session], 15000));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replyTo(session, text) {
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
const result = await limited(() => streamIn(['--reply', session, String(bytes.length)], 'DEVDESK-REPLY-READY', new Blob([bytes])));
|
||||||
|
if (!result.ok) throw new Error('not sent');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
const slice = 3 * 16 * 1024; // multiple of 3: base64 of slices concatenates cleanly
|
||||||
return new Promise((resolve, reject) => {
|
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';
|
socket.binaryType = 'arraybuffer';
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let output = '';
|
let output = '';
|
||||||
@@ -224,15 +256,16 @@ async function uploadNow(session, file, onProgress) {
|
|||||||
idle = setTimeout(() => socket.close(), 20000);
|
idle = setTimeout(() => socket.close(), 20000);
|
||||||
};
|
};
|
||||||
const send = async () => {
|
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);
|
while (socket.bufferedAmount > 1 << 20) await sleep(20);
|
||||||
if (socket.readyState !== WebSocket.OPEN) return;
|
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)));
|
socket.send(encodeInput(toBase64(bytes)));
|
||||||
touch();
|
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();
|
touch();
|
||||||
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
|
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
@@ -240,28 +273,26 @@ async function uploadNow(session, file, onProgress) {
|
|||||||
const { type, payload } = decodeServer(event.data);
|
const { type, payload } = decodeServer(event.data);
|
||||||
if (type !== Server.OUTPUT) return;
|
if (type !== Server.OUTPUT) return;
|
||||||
output += decoder.decode(payload, { stream: true });
|
output += decoder.decode(payload, { stream: true });
|
||||||
if (!sending && output.includes('DEVDESK-UPLOAD-READY')) {
|
if (!sending && output.includes(ready)) {
|
||||||
sending = true;
|
sending = true;
|
||||||
send();
|
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.close(); // the reply is in; let devdesk exit (devdesk#35)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
socket.onerror = () => {};
|
socket.onerror = () => {};
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
clearTimeout(idle);
|
clearTimeout(idle);
|
||||||
const text = stripAnsi(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, ''));
|
const text = stripAnsi(output.includes(ready) ? after() : output);
|
||||||
try {
|
try {
|
||||||
const result = parseListOutput(text);
|
resolve(parseListOutput(text));
|
||||||
if (result.ok && result.path) {
|
return;
|
||||||
resolve(result.path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to the error message
|
// fall through to the error message
|
||||||
}
|
}
|
||||||
const message = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop();
|
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*/, '')));
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+377
-15
@@ -5,11 +5,11 @@
|
|||||||
// see what Claude edited, and jump to any file by name. Read-only for now.
|
// see what Claude edited, and jump to any file by name. Read-only for now.
|
||||||
// Everything goes through `devdesk attach --fs-*` over ttyd.
|
// 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 { parseListOutput } from './protocol.js';
|
||||||
import { el } from './dom.js';
|
import { el } from './dom.js';
|
||||||
import { ago } from './sessions.js';
|
import { ago } from './sessions.js';
|
||||||
import { CommandPalette, toast } from './ui.js';
|
import { CommandPalette, openDialog, toast } from './ui.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------- editor bundle
|
// ---------------------------------------------------------------- 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
|
// ---------------------------------------------------------------- icons
|
||||||
|
|
||||||
// A two-character badge per file type (VS Code's icon idea, in the terminal style).
|
// 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-refresh').addEventListener('click', () => this.refresh());
|
||||||
root.querySelector('.dd-files-close').addEventListener('click', () => this.close());
|
root.querySelector('.dd-files-close').addEventListener('click', () => this.close());
|
||||||
root.querySelector('.dd-files-find').addEventListener('click', () => this.quickOpen.open());
|
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()));
|
root.querySelector('.dd-explorer-toggle').addEventListener('click', () => this.setExplorer(!this.explorerShown()));
|
||||||
this.explorer.addEventListener('keydown', (event) => this.treeKey(event));
|
this.explorer.addEventListener('keydown', (event) => this.treeKey(event));
|
||||||
root.addEventListener('keydown', (event) => {
|
root.addEventListener('keydown', (event) => {
|
||||||
@@ -256,8 +278,15 @@ export class FilesPane {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.quickOpen.open();
|
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();
|
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.
|
// Narrow pane (phone, or dragged small): the explorer slides over the editor.
|
||||||
new ResizeObserver(() => root.classList.toggle('is-narrow', root.clientWidth < 680)).observe(root);
|
new ResizeObserver(() => root.classList.toggle('is-narrow', root.clientWidth < 680)).observe(root);
|
||||||
this.quickOpen = new CommandPalette(() => this.loadFileList(), {
|
this.quickOpen = new CommandPalette(() => this.loadFileList(), {
|
||||||
@@ -343,6 +372,7 @@ export class FilesPane {
|
|||||||
syncSession() {
|
syncSession() {
|
||||||
const session = this.hooks.session();
|
const session = this.hooks.session();
|
||||||
if (session === this.session) return false;
|
if (session === this.session) return false;
|
||||||
|
this.flushDrafts();
|
||||||
for (const v of this.views.values()) v.editor?.destroy();
|
for (const v of this.views.values()) v.editor?.destroy();
|
||||||
this.views.clear();
|
this.views.clear();
|
||||||
this.listings.clear();
|
this.listings.clear();
|
||||||
@@ -407,6 +437,7 @@ export class FilesPane {
|
|||||||
async loadFileList() {
|
async loadFileList() {
|
||||||
if (this.fileList && Date.now() - this.fileList.at < 60000 && this.fileList.session === this.session) return this.fileList.items;
|
if (this.fileList && Date.now() - this.fileList.at < 60000 && this.fileList.session === this.session) return this.fileList.items;
|
||||||
const list = parseListOutput(await runCommand(['--fs-find', this.session], 45000));
|
const list = parseListOutput(await runCommand(['--fs-find', this.session], 45000));
|
||||||
|
this.fileListRoot = list.root;
|
||||||
const items = list.files.map((path) => {
|
const items = list.files.map((path) => {
|
||||||
const name = base(path);
|
const name = base(path);
|
||||||
const [icon] = iconFor(name);
|
const [icon] = iconFor(name);
|
||||||
@@ -432,6 +463,9 @@ export class FilesPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async claudeTurnEnded() {
|
async claudeTurnEnded() {
|
||||||
|
// New files may exist now: refresh the list terminal links are checked against.
|
||||||
|
this.fileList = null;
|
||||||
|
this.knownFiles();
|
||||||
if (!this.isOpen) {
|
if (!this.isOpen) {
|
||||||
// Next time it opens it revalidates anyway; open tabs re-check on activation.
|
// Next time it opens it revalidates anyway; open tabs re-check on activation.
|
||||||
for (const v of this.views.values()) v.stale = true;
|
for (const v of this.views.values()) v.stale = true;
|
||||||
@@ -677,8 +711,9 @@ export class FilesPane {
|
|||||||
|
|
||||||
// Single click opens a preview tab (italic) that the next click replaces;
|
// Single click opens a preview tab (italic) that the next click replaces;
|
||||||
// double click, Enter or go-to-file keeps it.
|
// double click, Enter or go-to-file keeps it.
|
||||||
openFile(path, { mode, pin = false } = {}) {
|
openFile(path, { mode, pin = false, line } = {}) {
|
||||||
const wanted = mode ?? (isMarkdown(path) ? 'preview' : 'view');
|
// A line number means source: markdown opens as text, not as a preview.
|
||||||
|
const wanted = mode ?? (isMarkdown(path) && !line ? 'preview' : 'view');
|
||||||
const key = `${wanted}:${path}`;
|
const key = `${wanted}:${path}`;
|
||||||
let tab = this.tabs.find((t) => t.key === key) ?? this.tabs.find((t) => t.path === path && !mode);
|
let tab = this.tabs.find((t) => t.key === key) ?? this.tabs.find((t) => t.path === path && !mode);
|
||||||
if (!tab) {
|
if (!tab) {
|
||||||
@@ -689,12 +724,21 @@ export class FilesPane {
|
|||||||
this.tabs[preview] = tab;
|
this.tabs[preview] = tab;
|
||||||
} else {
|
} else {
|
||||||
this.tabs.push(tab);
|
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) {
|
} else if (pin) {
|
||||||
tab.pinned = true;
|
tab.pinned = true;
|
||||||
}
|
}
|
||||||
this.pushRecent(path);
|
this.pushRecent(path);
|
||||||
|
if (line) {
|
||||||
|
const loaded = this.views.get(tab.key)?.editor;
|
||||||
|
if (loaded?.goto) setTimeout(() => loaded.goto(line), 0);
|
||||||
|
else tab.line = line;
|
||||||
|
}
|
||||||
this.activate(tab.key);
|
this.activate(tab.key);
|
||||||
if (this.root.classList.contains('is-narrow')) this.setExplorer(false);
|
if (this.root.classList.contains('is-narrow')) this.setExplorer(false);
|
||||||
}
|
}
|
||||||
@@ -716,7 +760,34 @@ export class FilesPane {
|
|||||||
this.renderExplorer();
|
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);
|
const i = this.tabs.findIndex((t) => t.key === key);
|
||||||
if (i < 0) return;
|
if (i < 0) return;
|
||||||
this.tabs.splice(i, 1);
|
this.tabs.splice(i, 1);
|
||||||
@@ -731,6 +802,7 @@ export class FilesPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dropView(key) {
|
dropView(key) {
|
||||||
|
clearTimeout(this.views.get(key)?.draftTimer);
|
||||||
this.views.get(key)?.editor?.destroy();
|
this.views.get(key)?.editor?.destroy();
|
||||||
this.views.get(key)?.node.remove();
|
this.views.get(key)?.node.remove();
|
||||||
this.views.delete(key);
|
this.views.delete(key);
|
||||||
@@ -742,7 +814,7 @@ export class FilesPane {
|
|||||||
const change = this.changeFor(tab.path);
|
const change = this.changeFor(tab.path);
|
||||||
const status = change ? statusOf(change) : null;
|
const status = change ? statusOf(change) : null;
|
||||||
return el('div', {
|
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',
|
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'}`,
|
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),
|
onclick: () => this.activate(tab.key),
|
||||||
@@ -758,12 +830,12 @@ export class FilesPane {
|
|||||||
el('span', { class: 'dd-tab-name' }, name),
|
el('span', { class: 'dd-tab-name' }, name),
|
||||||
tab.mode === 'diff' ? el('span', { class: 'dd-tab-tag' }, 'diff') : null,
|
tab.mode === 'diff' ? el('span', { class: 'dd-tab-tag' }, 'diff') : null,
|
||||||
el('button', {
|
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) => {
|
onclick: (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.closeTab(tab.key);
|
this.closeTab(tab.key);
|
||||||
},
|
},
|
||||||
}, '×'));
|
}, this.views.get(tab.key)?.dirty ? '●' : '×'));
|
||||||
});
|
});
|
||||||
this.tabStrip.replaceChildren(...nodes);
|
this.tabStrip.replaceChildren(...nodes);
|
||||||
this.tabStrip.querySelector('.is-active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
this.tabStrip.querySelector('.is-active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
@@ -851,6 +923,10 @@ export class FilesPane {
|
|||||||
const [{ blob, original }, editor] = await Promise.all([this.fetchTab(tab), loadEditor().catch((error) => ({ error }))]);
|
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 (session !== this.session || this.views.get(key) !== view) return;
|
||||||
if (blob.header.sha && blob.header.sha === view.header?.sha && tab.mode !== 'diff') 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 scroller = view.node.querySelector('.cm-scroller');
|
||||||
const top = scroller?.scrollTop ?? 0;
|
const top = scroller?.scrollTop ?? 0;
|
||||||
view.editor?.destroy();
|
view.editor?.destroy();
|
||||||
@@ -863,7 +939,7 @@ export class FilesPane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bar(tab, header) {
|
bar(tab, header, view) {
|
||||||
const change = this.changeFor(tab.path);
|
const change = this.changeFor(tab.path);
|
||||||
const meta = header ? [
|
const meta = header ? [
|
||||||
size(header.size ?? 0),
|
size(header.size ?? 0),
|
||||||
@@ -876,6 +952,10 @@ export class FilesPane {
|
|||||||
const button = (label, run, extra = {}) => el('button', { type: 'button', class: 'dd-viewer-btn', onclick: run, ...extra }, label);
|
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 switchMode = (mode) => this.openFile(tab.path, { mode, pin: true });
|
||||||
const actions = [];
|
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 (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) {
|
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')));
|
actions.push(button(tab.mode === 'diff' ? 'File' : 'Diff', () => switchMode(tab.mode === 'diff' ? (isMarkdown(tab.path) ? 'preview' : 'view') : 'diff')));
|
||||||
@@ -897,7 +977,8 @@ export class FilesPane {
|
|||||||
return el('div', { class: 'dd-viewer-bar' },
|
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-where', title: [tab.path, meta].filter(Boolean).join('\n') },
|
||||||
el('span', { class: 'dd-viewer-path' }, tab.path),
|
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));
|
el('span', { class: 'dd-viewer-actions' }, ...actions));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,20 +998,301 @@ export class FilesPane {
|
|||||||
content.append(el('pre', { class: 'dd-plain' }, text(base64, header.utf8)));
|
content.append(el('pre', { class: 'dd-plain' }, text(base64, header.utf8)));
|
||||||
} else if (tab.mode === 'preview') {
|
} else if (tab.mode === 'preview') {
|
||||||
content.classList.add('is-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', {
|
const frame = el('iframe', {
|
||||||
class: 'dd-md-frame', title: `Preview of ${tab.path}`,
|
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);
|
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);
|
if (frame.isConnected) frame.srcdoc = previewDocument(html);
|
||||||
});
|
});
|
||||||
} else if (tab.mode === 'diff') {
|
} else if (tab.mode === 'diff') {
|
||||||
view.editor = editor.diff(content, { original: text(original?.base64), modified: text(base64), path: tab.path, wrap: this.wrap });
|
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 {
|
} 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();
|
||||||
|
this.gotoPending(tab, view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
view.node.replaceChildren(this.bar(tab, header, view), content);
|
||||||
|
this.gotoPending(tab, view);
|
||||||
|
}
|
||||||
|
|
||||||
|
gotoPending(tab, view) {
|
||||||
|
if (!tab.line || !view.editor?.goto) return;
|
||||||
|
const line = tab.line;
|
||||||
|
delete tab.line;
|
||||||
|
setTimeout(() => view.editor.goto(line), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ paths in the terminal (devdesk#21)
|
||||||
|
|
||||||
|
// The session's file list (cached by go-to-file). Loaded ahead of time (on
|
||||||
|
// connect and after Claude's turns): xterm asks for a line's links once and
|
||||||
|
// caches the answer, so it has to be ready before the first hover.
|
||||||
|
knownFiles() {
|
||||||
|
if (this.session !== this.hooks.session()) this.syncSession();
|
||||||
|
if (this.fileList?.session === this.session) {
|
||||||
|
if (!this.fileSet || this.fileSet.list !== this.fileList.items) {
|
||||||
|
this.fileSet = { list: this.fileList.items, set: new Set(this.fileList.items.map((x) => x.id)) };
|
||||||
|
}
|
||||||
|
return this.fileSet.set;
|
||||||
|
}
|
||||||
|
if (!this.loadingList && this.session) {
|
||||||
|
this.loadingList = true;
|
||||||
|
this.loadFileList().catch(() => {}).finally(() => { this.loadingList = false; });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path as printed in the terminal -> the session-relative path, if it's a
|
||||||
|
// file of this session (absolute paths inside the folder count too).
|
||||||
|
resolvePrinted(text) {
|
||||||
|
const known = this.knownFiles();
|
||||||
|
if (!known) return null;
|
||||||
|
let path = text.replace(/^\.\//, '');
|
||||||
|
const root = this.fileListRoot;
|
||||||
|
if (path.startsWith('/') && root && path.startsWith(`${root}/`)) path = path.slice(root.length + 1);
|
||||||
|
return known.has(path) ? path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 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.
|
// Theme switch: the editor follows CSS variables; previews need a re-render.
|
||||||
|
|||||||
+114
-4
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import { el } from './dom.js';
|
import { el } from './dom.js';
|
||||||
import { ago } from './sessions.js';
|
import { ago } from './sessions.js';
|
||||||
|
import { answerPrompt, peekSession, replyTo } from './connection.js';
|
||||||
|
import { toast } from './ui.js';
|
||||||
|
|
||||||
const KEY = 'devdesk.inbox';
|
const KEY = 'devdesk.inbox';
|
||||||
const MAX = 30;
|
const MAX = 30;
|
||||||
@@ -65,6 +67,8 @@ export class Inbox {
|
|||||||
if (open) this.panel.removeAttribute('aria-hidden');
|
if (open) this.panel.removeAttribute('aria-hidden');
|
||||||
else this.panel.setAttribute('aria-hidden', 'true');
|
else this.panel.setAttribute('aria-hidden', 'true');
|
||||||
this.button.setAttribute('aria-expanded', String(open));
|
this.button.setAttribute('aria-expanded', String(open));
|
||||||
|
if (!open) this.peeking = null;
|
||||||
|
this.panel.classList.toggle('is-peek', false);
|
||||||
if (open) this.render();
|
if (open) this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +117,88 @@ export class Inbox {
|
|||||||
if (this.isOpen) this.renderPanel();
|
if (this.isOpen) this.renderPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ answer from here
|
||||||
|
|
||||||
|
async answer(session, since, choice, button) {
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
await answerPrompt(session, since, choice);
|
||||||
|
toast.success(choice === 'allow' ? `Allowed in ${session}` : `Denied in ${session}`);
|
||||||
|
this.hooks.refresh?.();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`${session}: ${error.message}`);
|
||||||
|
this.hooks.refresh?.();
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peek: the last exchange of a session, and a reply box.
|
||||||
|
async peek(session) {
|
||||||
|
this.peeking = session;
|
||||||
|
this.panel.classList.add('is-peek');
|
||||||
|
const body = el('div', { class: 'dd-peek' }, el('div', { class: 'dd-notif-empty' }, 'Loading…'));
|
||||||
|
this.list.replaceChildren(body);
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await peekSession(session);
|
||||||
|
} catch (error) {
|
||||||
|
body.replaceChildren(el('div', { class: 'dd-notif-empty' }, error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.peeking !== session) return;
|
||||||
|
const input = el('textarea', { class: 'lt-input dd-peek-input', rows: '3', placeholder: `Reply to ${session}… (Ctrl+Enter sends)`, 'aria-label': `Reply to ${session}` });
|
||||||
|
const send = el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm' }, 'Send');
|
||||||
|
const submit = async () => {
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
send.disabled = true;
|
||||||
|
try {
|
||||||
|
await replyTo(session, text);
|
||||||
|
toast.success(`Sent to ${session}`);
|
||||||
|
this.peeking = null;
|
||||||
|
this.setOpen(false);
|
||||||
|
this.hooks.refresh?.();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`${session}: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
send.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
send.addEventListener('click', submit);
|
||||||
|
input.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
event.stopPropagation(); // keys belong to the reply box, not the terminal or shortcuts
|
||||||
|
});
|
||||||
|
const status = data.status;
|
||||||
|
const waitingRequest = status?.state === 'waiting' && status.request;
|
||||||
|
body.replaceChildren(
|
||||||
|
el('div', { class: 'dd-peek-head' },
|
||||||
|
el('button', { type: 'button', class: 'dd-peek-back', onclick: () => { this.peeking = null; this.panel.classList.remove('is-peek'); this.renderPanel(); } }, '←'),
|
||||||
|
el('span', { class: 'dd-peek-title' }, session),
|
||||||
|
el('span', { class: 'dd-peek-state' }, status?.state ? `${status.state}${status.since ? ` · ${ago(status.since)}` : ''}` : '')),
|
||||||
|
data.user ? el('div', { class: 'dd-peek-user' }, el('span', { class: 'dd-peek-label' }, 'You'), data.user.text) : null,
|
||||||
|
el('div', { class: 'dd-peek-assistant' },
|
||||||
|
el('span', { class: 'dd-peek-label' }, 'Claude'),
|
||||||
|
data.assistant ? `${data.assistant.truncated ? '…' : ''}${data.assistant.text}` : 'Nothing yet.'),
|
||||||
|
data.commands?.length ? el('div', { class: 'dd-peek-commands' },
|
||||||
|
el('span', { class: 'dd-peek-label' }, 'Recent commands'),
|
||||||
|
...data.commands.slice(-8).map((c) => el('div', { class: 'dd-peek-command', title: c.cmd },
|
||||||
|
el('span', { class: 'dd-peek-when' }, ago(c.at)), el('code', {}, c.cmd)))) : null,
|
||||||
|
waitingRequest
|
||||||
|
? el('div', { class: 'dd-notif-empty' }, 'Claude is asking for a permission: answer it from the list, or open the session.')
|
||||||
|
: el('div', { class: 'dd-peek-reply' }, input, el('div', { class: 'dd-peek-actions' },
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: () => { this.setOpen(false); this.hooks.openSession(session); } }, 'Open session'),
|
||||||
|
send)));
|
||||||
|
if (!waitingRequest) input.focus();
|
||||||
|
body.querySelector('.dd-peek-assistant').scrollTop = 1e9;
|
||||||
|
}
|
||||||
|
|
||||||
renderPanel() {
|
renderPanel() {
|
||||||
|
if (this.peeking) return; // the peek view stays until you go back
|
||||||
const close = (run) => () => {
|
const close = (run) => () => {
|
||||||
this.setOpen(false);
|
this.setOpen(false);
|
||||||
run();
|
run();
|
||||||
@@ -136,11 +221,31 @@ export class Inbox {
|
|||||||
if (this.waiting.length) {
|
if (this.waiting.length) {
|
||||||
nodes.push(el('div', { class: 'dd-notif-section' }, 'Needs you'));
|
nodes.push(el('div', { class: 'dd-notif-section' }, 'Needs you'));
|
||||||
for (const s of this.waiting) {
|
for (const s of this.waiting) {
|
||||||
nodes.push(item({
|
const request = s.status?.request;
|
||||||
|
const node = item({
|
||||||
dot: 'red', unread: true, title: `${s.name}: ${s.status?.message || 'Claude is waiting for your input'}`,
|
dot: 'red', unread: true, title: `${s.name}: ${s.status?.message || 'Claude is waiting for your input'}`,
|
||||||
meta: s.status?.since ? `waiting ${ago(s.status.since)}` : 'waiting',
|
meta: s.status?.since ? `waiting ${ago(s.status.since)}` : 'waiting',
|
||||||
onclick: close(() => this.hooks.openSession(s.name)),
|
onclick: close(() => this.hooks.openSession(s.name)),
|
||||||
}));
|
});
|
||||||
|
const stop = (run) => (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
run(event.currentTarget);
|
||||||
|
};
|
||||||
|
const body = node.querySelector('.lt-notif-item-body');
|
||||||
|
if (request) {
|
||||||
|
body.append(
|
||||||
|
request.description ? el('div', { class: 'dd-request-desc' }, request.description) : null,
|
||||||
|
el('code', { class: 'dd-request' }, request.detail || request.tool),
|
||||||
|
el('div', { class: 'dd-request-actions' },
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: stop((b) => this.answer(s.name, s.status.since, 'allow', b)) }, 'Allow'),
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-danger lt-btn-sm', onclick: stop((b) => this.answer(s.name, s.status.since, 'deny', b)) }, 'Deny'),
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: stop(() => { this.setOpen(false); this.hooks.openSession(s.name); }) }, 'Open')));
|
||||||
|
} else {
|
||||||
|
body.append(el('div', { class: 'dd-request-actions' },
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-sm', onclick: stop(() => this.peek(s.name)) }, 'Peek & reply'),
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: stop(() => { this.setOpen(false); this.hooks.openSession(s.name); }) }, 'Open')));
|
||||||
|
}
|
||||||
|
nodes.push(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.alerts.length) {
|
if (this.alerts.length) {
|
||||||
@@ -160,7 +265,7 @@ export class Inbox {
|
|||||||
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
|
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
|
||||||
// An entry about a session that has since ended must not bring it back (devdesk#38).
|
// 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);
|
const ended = !e.url && e.session && this.known && !this.known.has(e.session);
|
||||||
nodes.push(item({
|
const recentNode = item({
|
||||||
dot: e.read || ended ? 'read' : kind.tone, unread: !e.read && !ended, title: e.text,
|
dot: e.read || ended ? 'read' : kind.tone, unread: !e.read && !ended, title: e.text,
|
||||||
meta: `${when(e.at)}${ended ? ' · session ended' : ''}`,
|
meta: `${when(e.at)}${ended ? ' · session ended' : ''}`,
|
||||||
onclick: close(() => {
|
onclick: close(() => {
|
||||||
@@ -170,7 +275,12 @@ export class Inbox {
|
|||||||
else if (ended) this.hooks.ended?.(e.session);
|
else if (ended) this.hooks.ended?.(e.session);
|
||||||
else if (e.session) this.hooks.openSession(e.session);
|
else if (e.session) this.hooks.openSession(e.session);
|
||||||
}),
|
}),
|
||||||
}));
|
});
|
||||||
|
if (e.kind === 'finished' && !ended) {
|
||||||
|
recentNode.querySelector('.lt-notif-item-body').append(el('div', { class: 'dd-request-actions' },
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: (event) => { event.stopPropagation(); this.peek(e.session); } }, 'Peek & reply')));
|
||||||
|
}
|
||||||
|
nodes.push(recentNode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!nodes.length) nodes.push(el('div', { class: 'dd-notif-empty' }, 'Nothing needs you. Sessions that finish or wait while you\'re elsewhere show up here.'));
|
if (!nodes.length) nodes.push(el('div', { class: 'dd-notif-empty' }, 'Nothing needs you. Sessions that finish or wait while you\'re elsewhere show up here.'));
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// File paths in the terminal become links (devdesk#21): click one in Claude's
|
||||||
|
// output (web/src/files.js, ./notes.md:12, /root/code/x/y.py:3:9) to open it in
|
||||||
|
// the Files pane, at that line if there is one. Only paths that are files of
|
||||||
|
// the session become links, so words and URLs are left alone.
|
||||||
|
|
||||||
|
// A path: optional ./ ../ or /, segments, and either a slash or an extension;
|
||||||
|
// then an optional :line[:col].
|
||||||
|
const PATH_RE = /(?<![\w/.@~+-])((?:\.{1,2}\/|\/)?(?:[\w@+~-][\w.@+~-]*\/)*[\w@+~-][\w.@+~-]*(?:\.[A-Za-z0-9]{1,10})?)(?::(\d+)(?::(\d+))?)?/g;
|
||||||
|
const URL_RE = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi;
|
||||||
|
|
||||||
|
export function findPaths(text) {
|
||||||
|
const urls = [...text.matchAll(URL_RE)].map((m) => [m.index, m.index + m[0].length]);
|
||||||
|
const out = [];
|
||||||
|
for (const m of text.matchAll(PATH_RE)) {
|
||||||
|
const candidate = m[1];
|
||||||
|
if (!candidate.includes('/') && !/\.[A-Za-z0-9]{1,10}$/.test(candidate)) continue;
|
||||||
|
if (urls.some(([a, b]) => m.index >= a && m.index < b)) continue;
|
||||||
|
out.push({ index: m.index, length: m[0].length, path: candidate, line: m[2] ? Number(m[2]) : undefined });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPathLinks(term, { resolve, open }) {
|
||||||
|
term.registerLinkProvider({
|
||||||
|
provideLinks(y, callback) {
|
||||||
|
const line = term.buffer.active.getLine(y - 1);
|
||||||
|
if (!line) {
|
||||||
|
callback(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const links = [];
|
||||||
|
for (const found of findPaths(line.translateToString(true))) {
|
||||||
|
const path = resolve(found.path);
|
||||||
|
if (!path) continue;
|
||||||
|
links.push({
|
||||||
|
range: { start: { x: found.index + 1, y }, end: { x: found.index + found.length, y } },
|
||||||
|
text: path,
|
||||||
|
decorations: { underline: true, pointerCursor: true },
|
||||||
|
activate: () => open(path, found.line),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
callback(links.length ? links : undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+57
-6
@@ -4,7 +4,7 @@ import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
|
|||||||
import {
|
import {
|
||||||
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
|
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
|
||||||
} from './connection.js';
|
} from './connection.js';
|
||||||
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
|
import { parseTitle, pasteSafe, sessionFromSearch, slugify } from './protocol.js';
|
||||||
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
||||||
import { renderAlerts } from './alerts.js';
|
import { renderAlerts } from './alerts.js';
|
||||||
import { renderRepo } from './repo.js';
|
import { renderRepo } from './repo.js';
|
||||||
@@ -13,6 +13,7 @@ import { heardWithin, onShared, share } from './shared.js';
|
|||||||
import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
|
import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
|
||||||
import { Inbox } from './inbox.js';
|
import { Inbox } from './inbox.js';
|
||||||
import { FilesPane } from './files.js';
|
import { FilesPane } from './files.js';
|
||||||
|
import { registerPathLinks } from './links.js';
|
||||||
import { el } from './dom.js';
|
import { el } from './dom.js';
|
||||||
import * as notify from './notify.js';
|
import * as notify from './notify.js';
|
||||||
|
|
||||||
@@ -357,6 +358,7 @@ function connect(session, { existing = false, reconnect = false } = {}) {
|
|||||||
state.attempts = 0;
|
state.attempts = 0;
|
||||||
setWsState('connected', 'live');
|
setWsState('connected', 'live');
|
||||||
if (session) rememberSession(session);
|
if (session) rememberSession(session);
|
||||||
|
if (session) files?.knownFiles(); // so paths in the output are links from the first hover
|
||||||
refreshSoon(800); // opening ?arg=<new name> just created this session
|
refreshSoon(800); // opening ?arg=<new name> just created this session
|
||||||
refreshRepo();
|
refreshRepo();
|
||||||
fit.fit();
|
fit.fit();
|
||||||
@@ -548,7 +550,8 @@ function renderSwitcher() {
|
|||||||
title: s.dir || '', onclick: choose(() => connect(s.name, { existing: true })),
|
title: s.dir || '', onclick: choose(() => connect(s.name, { existing: true })),
|
||||||
},
|
},
|
||||||
el('span', { class: `lt-dot ${status.dot}` }),
|
el('span', { class: `lt-dot ${status.dot}` }),
|
||||||
el('span', { class: 'dd-switch-name' }, s.name),
|
el('span', { class: 'dd-switch-name' }, s.name,
|
||||||
|
s.tags?.length ? el('span', { class: 'dd-tags' }, ...s.tags.slice(0, 2).map((t) => el('span', { class: 'dd-tag' }, t))) : null),
|
||||||
el('span', { class: 'dd-switch-meta' }, `${status.label}${ctx}`));
|
el('span', { class: 'dd-switch-meta' }, `${status.label}${ctx}`));
|
||||||
});
|
});
|
||||||
if (!items.length) items.push(el('div', { class: 'dd-menu-note' }, sessionsPanel?.data ? 'No sessions yet.' : 'Loading…'));
|
if (!items.length) items.push(el('div', { class: 'dd-menu-note' }, sessionsPanel?.data ? 'No sessions yet.' : 'Loading…'));
|
||||||
@@ -589,6 +592,38 @@ async function toggleNotifications() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "In a new session" (devdesk#18): open (or reuse) a session for this alert,
|
||||||
|
// wait until its Claude is up (SessionStart sets it idle), then type the
|
||||||
|
// prompt without sending it.
|
||||||
|
let pendingAsk = null;
|
||||||
|
function askInSession(name, text) {
|
||||||
|
closeDrawerIfNarrow();
|
||||||
|
pendingAsk = { session: name, text, until: Date.now() + 60000 };
|
||||||
|
connect(name);
|
||||||
|
toast.info(`Opening ${name}; the prompt will be typed when Claude is ready`);
|
||||||
|
const tick = async () => {
|
||||||
|
if (!pendingAsk || pendingAsk.session !== name) return;
|
||||||
|
if (Date.now() > pendingAsk.until) {
|
||||||
|
toast.warning(`${name}: Claude didn't start in time; the prompt was not typed`);
|
||||||
|
pendingAsk = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sessionsPanel.reload();
|
||||||
|
const s = sessionsPanel.data?.sessions?.find((x) => x.name === name);
|
||||||
|
const ready = s && ['idle', 'waiting', 'working'].includes(sessionStatus(s).key);
|
||||||
|
if (ready && state.session === name && state.conn?.isOpen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
term.paste(pasteSafe(pendingAsk.text));
|
||||||
|
term.focus();
|
||||||
|
pendingAsk = null;
|
||||||
|
}, 800);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(tick, 1500);
|
||||||
|
};
|
||||||
|
setTimeout(tick, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- palette & help
|
// ---------------------------------------------------------------- palette & help
|
||||||
|
|
||||||
const sendKeys = (keys) => state.conn?.sendInput(keys);
|
const sendKeys = (keys) => state.conn?.sendInput(keys);
|
||||||
@@ -620,7 +655,8 @@ function buildCommands() {
|
|||||||
if (s.name === state.session) continue;
|
if (s.name === state.session) continue;
|
||||||
const status = sessionStatus(s);
|
const status = sessionStatus(s);
|
||||||
add({
|
add({
|
||||||
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir,
|
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: [status.label, ...(s.tags ?? []).slice(0, 2)].join(' · '),
|
||||||
|
keywords: `${s.dir} ${(s.tags ?? []).join(' ')}`,
|
||||||
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name, { existing: true }),
|
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name, { existing: true }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -712,10 +748,13 @@ const KEY_HELP = [
|
|||||||
]],
|
]],
|
||||||
['DevDesk', [
|
['DevDesk', [
|
||||||
['Ctrl+⇧ P', 'Command palette'],
|
['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'],
|
['Ctrl+⇧ C / V', 'Copy selection / paste'],
|
||||||
['⇧ drag', 'Select text in the browser'],
|
['⇧ drag', 'Select text in the browser'],
|
||||||
['Drop or paste a file', 'Upload it and attach it for Claude'],
|
['Drop or paste a file', 'Upload it and attach it for Claude'],
|
||||||
|
['Click a file path', 'Open it in Files (at the line, if given)'],
|
||||||
['Ctrl+W', 'Asks before closing; Chromium fullscreen keeps it for the terminal'],
|
['Ctrl+W', 'Asks before closing; Chromium fullscreen keeps it for the terminal'],
|
||||||
]],
|
]],
|
||||||
];
|
];
|
||||||
@@ -865,6 +904,13 @@ function wireChrome() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
$('files-toggle').addEventListener('click', () => files.toggle());
|
$('files-toggle').addEventListener('click', () => files.toggle());
|
||||||
|
registerPathLinks(term, {
|
||||||
|
resolve: (printed) => (state.session ? files.resolvePrinted(printed) : null),
|
||||||
|
open: (path, line) => {
|
||||||
|
if (!files.isOpen) files.open();
|
||||||
|
files.openFile(path, { pin: true, line });
|
||||||
|
},
|
||||||
|
});
|
||||||
wireResizer();
|
wireResizer();
|
||||||
palette = new CommandPalette(buildCommands);
|
palette = new CommandPalette(buildCommands);
|
||||||
$('cmd-toggle').addEventListener('click', () => palette.open());
|
$('cmd-toggle').addEventListener('click', () => palette.open());
|
||||||
@@ -898,6 +944,7 @@ function wireChrome() {
|
|||||||
hooks: {
|
hooks: {
|
||||||
openSession: (name) => connect(name, { existing: true }),
|
openSession: (name) => connect(name, { existing: true }),
|
||||||
ended: (name) => toast.info(`${name} has ended; its conversation is still in Claude's history`),
|
ended: (name) => toast.info(`${name} has ended; its conversation is still in Claude's history`),
|
||||||
|
refresh: () => sessionsPanel.reload(),
|
||||||
showAlerts: () => {
|
showAlerts: () => {
|
||||||
setSidebar(true);
|
setSidebar(true);
|
||||||
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
|
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||||
@@ -952,7 +999,7 @@ function wireChrome() {
|
|||||||
|
|
||||||
// Ctrl+W closes the tab before the terminal sees it; ask first.
|
// Ctrl+W closes the tab before the terminal sees it; ask first.
|
||||||
window.addEventListener('beforeunload', (event) => {
|
window.addEventListener('beforeunload', (event) => {
|
||||||
if (state.conn?.isOpen && state.session) {
|
if ((state.conn?.isOpen && state.session) || files?.hasDirty()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.returnValue = '';
|
event.returnValue = '';
|
||||||
}
|
}
|
||||||
@@ -1076,7 +1123,11 @@ async function boot() {
|
|||||||
onData: (_data, meta) => onSessionData(meta),
|
onData: (_data, meta) => onSessionData(meta),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
bindAskClaude($('sidebar'), (text) => {
|
bindAskClaude($('sidebar'), (text, session) => {
|
||||||
|
if (session) {
|
||||||
|
askInSession(slugify(session).slice(0, 48) || 'alert', text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Alert text is untrusted: no control characters (a newline would run it in a
|
// Alert text is untrusted: no control characters (a newline would run it in a
|
||||||
// shell window, CR would submit), and bracketed paste around it (devdesk#2).
|
// shell window, CR would submit), and bracketed paste around it (devdesk#2).
|
||||||
term.paste(pasteSafe(text));
|
term.paste(pasteSafe(text));
|
||||||
|
|||||||
+2
-1
@@ -285,7 +285,8 @@ export class SessionsPanel {
|
|||||||
return el('li', { class: `dd-session-item is-${status.key}${isCurrent ? ' is-current' : ''}` },
|
return el('li', { class: `dd-session-item is-${status.key}${isCurrent ? ' is-current' : ''}` },
|
||||||
el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
|
el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
|
||||||
el('span', { class: `lt-dot ${status.dot}` }),
|
el('span', { class: `lt-dot ${status.dot}` }),
|
||||||
el('span', { class: 'dd-session-name' }, s.name),
|
el('span', { class: 'dd-session-name' }, s.name,
|
||||||
|
s.tags?.length ? el('span', { class: 'dd-tags', title: 'Hosts this session worked on' }, ...s.tags.slice(0, 3).map((t) => el('span', { class: 'dd-tag' }, t))) : null),
|
||||||
el('span', { class: 'dd-session-meta' }, meta),
|
el('span', { class: 'dd-session-meta' }, meta),
|
||||||
pct != null ? el('span', { class: `dd-ctx${pct >= 90 ? ' is-full' : pct >= 70 ? ' is-high' : ''}`, 'aria-hidden': 'true' },
|
pct != null ? el('span', { class: `dd-ctx${pct >= 90 ? ' is-full' : pct >= 70 ? ' is-high' : ''}`, 'aria-hidden': 'true' },
|
||||||
el('span', { class: 'dd-ctx-fill', style: `width:${Math.min(100, pct)}%` })) : null),
|
el('span', { class: 'dd-ctx-fill', style: `width:${Math.min(100, pct)}%` })) : null),
|
||||||
|
|||||||
+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,
|
// 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
|
// danger }. Resolves with the chosen action's value (or `submit()`'s value when
|
||||||
// a form is submitted), or null when dismissed (✕, Esc, backdrop).
|
// 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) => {
|
return new Promise((resolve) => {
|
||||||
const opener = document.activeElement;
|
const opener = document.activeElement;
|
||||||
let done = false;
|
let done = false;
|
||||||
@@ -81,7 +81,7 @@ export function openDialog({ title, body, actions = [], danger = false, size = '
|
|||||||
if (button.type === 'submit') button.setAttribute('form', titleId);
|
if (button.type === 'submit') button.setAttribute('form', titleId);
|
||||||
}
|
}
|
||||||
form.id = 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${size ? ` lt-modal-${size}` : ''}` },
|
||||||
el('div', { class: `lt-modal-header${danger ? ' lt-modal-header--danger' : ''}` },
|
el('div', { class: `lt-modal-header${danger ? ' lt-modal-header--danger' : ''}` },
|
||||||
el('h2', { class: 'lt-modal-title', id: `${titleId}-title` }, title),
|
el('h2', { class: 'lt-modal-title', id: `${titleId}-title` }, title),
|
||||||
|
|||||||
+4
-3
@@ -1,11 +1,12 @@
|
|||||||
// "Ask Claude": any [data-ask-claude] button (Ops Alerts, Repo/CI) types its
|
// "Ask Claude": any [data-ask-claude] button (Ops Alerts, Repo/CI) types its
|
||||||
// prompt into the current session without pressing Enter, so you can review it
|
// prompt into a session without pressing Enter, so you can review it before
|
||||||
// before sending.
|
// sending: the current session, or (data-ask-session) a separate one, e.g. an
|
||||||
|
// ops session per alert (devdesk#18).
|
||||||
|
|
||||||
export function bindAskClaude(root, send) {
|
export function bindAskClaude(root, send) {
|
||||||
root.addEventListener('click', (event) => {
|
root.addEventListener('click', (event) => {
|
||||||
const button = event.target.closest('[data-ask-claude]');
|
const button = event.target.closest('[data-ask-claude]');
|
||||||
if (!button || button.disabled) return;
|
if (!button || button.disabled) return;
|
||||||
send(button.dataset.askClaude);
|
send(button.dataset.askClaude, button.dataset.askSession || null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -924,3 +924,65 @@ html[data-theme="light"] .dd-files.is-narrow .dd-explorer { box-shadow: 8px 0 24
|
|||||||
.dd-tree-row { min-height: 34px; }
|
.dd-tree-row { min-height: 34px; }
|
||||||
.dd-tab-close { opacity: 0.7; width: 28px; height: 28px; }
|
.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; }
|
||||||
|
|
||||||
|
/* ---- inbox: answer & peek (devdesk#22, #23) --------------------------------------------- */
|
||||||
|
.dd-inbox-panel.is-peek { width: min(460px, calc(100vw - 16px)); }
|
||||||
|
.dd-request-desc { margin-top: 4px; color: var(--text-secondary); font-size: 0.7rem; }
|
||||||
|
.dd-request {
|
||||||
|
display: block;
|
||||||
|
max-height: 7.5em;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
overflow: auto;
|
||||||
|
border-left: 2px solid var(--accent-amber);
|
||||||
|
background: var(--bg-terminal);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.dd-request-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||||
|
.dd-peek { display: flex; flex-direction: column; gap: 8px; padding: 8px 10px 10px; }
|
||||||
|
.dd-peek-head { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.dd-peek-back { padding: 0 6px; border: 1px solid var(--border-color); background: none; color: var(--accent-cyan); cursor: pointer; }
|
||||||
|
.dd-peek-title { flex: 1; color: var(--text-primary); font-weight: 700; font-size: 0.8rem; }
|
||||||
|
.dd-peek-state { color: var(--text-muted); font-size: 0.66rem; }
|
||||||
|
.dd-peek-label { display: block; margin-bottom: 2px; color: var(--accent-orange); font-size: 0.6rem; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||||
|
.dd-peek-user { padding: 6px 8px; border-left: 2px solid var(--border-color); color: var(--text-secondary); font-size: 0.72rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.dd-peek-assistant { max-height: 38vh; padding: 6px 8px; overflow: auto; border-left: 2px solid var(--accent-cyan); background: var(--bg-terminal); color: var(--text-primary); font-size: 0.74rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.dd-peek-input { width: 100%; min-height: 4.5em; resize: vertical; font-family: var(--font-mono); font-size: 0.76rem; }
|
||||||
|
.dd-peek-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
|
||||||
|
.dd-ask-row { display: flex; flex-wrap: wrap; gap: 6px; align-self: flex-start; margin-top: 2px; }
|
||||||
|
.dd-alert .dd-ask-row .dd-ask { margin-top: 0; }
|
||||||
|
|
||||||
|
/* ---- ops trail (devdesk#27) ------------------------------------------------------------- */
|
||||||
|
.dd-tags { display: inline-flex; flex-wrap: wrap; gap: 3px; margin-left: 6px; vertical-align: middle; }
|
||||||
|
.dd-tag { padding: 0 4px; border: 1px solid var(--accent-cyan-border); color: var(--accent-cyan); font-size: 0.58rem; font-weight: 400; letter-spacing: 0; }
|
||||||
|
.dd-peek-commands { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.dd-peek-command { display: flex; gap: 8px; min-width: 0; font-size: 0.68rem; }
|
||||||
|
.dd-peek-command code { overflow: hidden; color: var(--text-secondary); font-family: var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dd-peek-when { flex: 0 0 2.6em; color: var(--text-muted); text-align: right; }
|
||||||
|
|||||||
Reference in New Issue
Block a user