Compare commits

..
4 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 20134c1e58 feat: v0.12.0 Settings window, per-session Remote Control, delete conversations
build-deb / Lint, test, package (push) Successful in 4m59s
build-deb / Deploy to CT 126 (push) Successful in 14s
build-deb / Notify on failure (push) Skipped
- Settings (⋯ → Settings…, palette): theme, terminal text size, interface
  size, notifications, Remote Control & privacy. The ⋯ menu shrinks.
- Remote Control per session: registry choice > Settings default >
  DEVDESK_REMOTE_CONTROL. Switching an idle session restarts its Claude
  on the same conversation; busy sessions change at the next start.
  rc_active comes from the running process; RC tag on sessions.
- --delete-convo: remove a conversation's local transcript and subagent
  files; refused while in use. Delete… in Search past conversations.
Closes #61

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-11-03 21:23:29 -05:00
jaredandClaude Opus 5.5 65114165e7 feat: v0.11.0 VS Code-like explorer, markdown preview, search in files
build-deb / Lint, test, package (push) Successful in 2m29s
build-deb / Deploy to CT 126 (push) Successful in 7s
build-deb / Notify on failure (push) Skipped
- Explorer: context menus, multi-select, inline rename/new, drag to move
  or copy, desktop drag-and-drop uploads (folders too), cut/copy/paste,
  duplicate, download, delete to a 7-day trash with Undo. Server side in
  libexec/devdesk-fsops.sh with bats tests. Closes #54
- Markdown preview in a shadow root: headings with anchors and outline,
  highlighted code with copy, callouts, task lists, front matter, links
  that open files, live split view. Editor status bar, multi-cursor,
  closeBrackets, Ctrl+G, tab menus and reordering. Closes #55
- Search in files (Ctrl+Shift+F) via git grep / grep. Closes #56
- Fix: nested $(...) path checks now fail hard (set -e does not reach
  them; an escape created /x in testing); newest folder listing wins;
  sessionAction returns the reply JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-22 21:25:43 -04:00
jaredandClaude Opus 5.5 e60f1ad066 fix: v0.10.4 session list scrolls on its own
build-deb / Lint, test, package (push) Successful in 3m13s
build-deb / Deploy to CT 126 (push) Successful in 13s
build-deb / Notify on failure (push) Skipped
No cap on sessions existed, but only the whole Panels column scrolled, so
many sessions pushed Ops Alerts and Repo/CI far down. The list now has its
own scroll area (46vh), keeps its position across polls and brings the
current session into view on switch. Closes #53

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-22 20:53:34 -04:00
jaredandClaude Opus 5.5 ab7e127f70 fix: v0.10.3 no tmux right-click menus; close split panes
build-deb / Deploy to CT 126 (push) Successful in 19s
build-deb / Lint, test, package (push) Successful in 3m9s
build-deb / Notify on failure (push) Skipped
Right-clicking a pane opened tmux's menu, whose Split entries left a stray
pane next to Claude (and whose Kill entries could end it). Unbind the
right-click menus; add --unsplit and a palette entry that closes extra
panes and keeps Claude's. Closes #52

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-13 01:48:04 -04:00
23 changed files with 2704 additions and 218 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ install: all
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-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix libexec/devdesk-guard $(DESTDIR)$(LIBDIR)/
install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh libexec/devdesk-history.sh libexec/devdesk-ops.sh \
install -m 0644 libexec/devdesk-files.sh libexec/devdesk-fsops.sh libexec/devdesk-inbox.sh libexec/devdesk-history.sh libexec/devdesk-ops.sh \
$(DESTDIR)$(LIBDIR)/
install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd
install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
+16 -3
View File
@@ -51,12 +51,22 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- **`devdesk guests [ID|IP|NAME]`** lists Proxmox CTs/VMs with the node each runs on now, status, HA state and IPs. Claude is told to use it before acting, because HA guests move. Stopped guests keep the IP they had when last seen running.
- Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`. For the guest lookup, set `DEVDESK_PVE_NODES` to a few Proxmox nodes reachable by root SSH key. VM IPs need the QEMU guest agent.
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. The first changed files are listed; click one for its diff against HEAD. 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. Paths can't leave the session folder.
- **Files** (Ctrl+Shift+E), laid out like VS Code:
- **Explorer tree:** git colours, gitignored entries dimmed.
- **Mouse:** right-click menus (new file/folder, rename, delete, cut/copy/paste, duplicate, copy path, download, @Claude), Ctrl/Shift+click to select several, drag to move (Ctrl+drag copies). Drop files or whole folders from the desktop to upload them (up to 25 MB each), or drag a file onto the terminal to type `@path`.
- **Keyboard:** F2 renames in place, Delete trashes, Ctrl+C/X/V copy and paste files.
- **Safety:** delete goes to a 7-day trash with **Undo**; nothing is overwritten unless you choose Replace.
- **Editor:** tabs (a click opens a preview tab, a double-click keeps it; right-click for close variants; drag to reorder), **Go to file** (Ctrl+P in the pane), and **Search in files** (Ctrl+Shift+F: match case, whole word, regex). A status bar shows line/column, cursors, indentation, line endings and language. Multiple cursors (Alt+click), column select (Alt+drag), auto-closing brackets, Ctrl+G to go to a line.
- **Markdown preview:** GitHub-style headings with anchors and an outline, syntax-coloured code with Copy, callouts, task lists, front matter, images, and links that open files in DevDesk. **Split** shows the source and a live preview side by side.
- **Changes view:** uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt.
- **Remembered and contained:** tabs and open folders are remembered per session, and 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).
- **Search past conversations** (⋯ menu, palette): full text over every Claude conversation on the host; **Resume** it in a new session or **Fork** it.
- **Plan usage:** the 5-hour and weekly windows as meters in the footer, with when each resets (`↻ 14:30`). If the pace so far would use a window up before it resets, the meter shows `⚠ full ~13:50` instead. A toast warns at 90 %, and Matrix gets one message per window when you're away (`DEVDESK_USAGE_WARN`). Sessions also get a **PR** chip when their branch has a pull request.
- **Settings** (⋯ → Settings…): theme, terminal text size and interface size (per browser), desktop notifications, and **Remote Control per session**. With Remote Control on, a session also shows in the Claude app and at claude.ai/code, and Anthropic stores its transcript to sync devices (30 days, or 5 years if "Help improve Claude" is on). It's **off for new sessions** unless you change the default. Each running session has its own switch, which restarts that Claude on the same conversation when it's idle. Sessions that sync show an **RC** tag.
- **Delete a conversation:** Search past conversations → **Delete…** removes its local transcript (`~/.claude/projects`) for good; a copy synced by Remote Control is deleted at claude.ai/code.
- **Quiet phone at the desk:** while a tab shows a session, Remote Control holds back phone pushes for it (client-presence marker, expires ~2 min after you leave).
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use, plan usage and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar and Claude's own footer shows a one-line summary. On a Pro/Max plan that summary shows 5-hour plan usage; the dollar figure Claude reports there is an API-price estimate, not a bill, so it appears only for API-key billing.
- **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).
@@ -73,6 +83,9 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
| Ctrl-b d | Detach (the session keeps running) |
| Drag | Selects text; it stays highlighted and is copied when you let go. Double-click selects a word, triple-click a line. Clicks on the tmux status line still go to tmux. |
| Ctrl+C | Copies the selection when there is one; otherwise interrupts as usual |
| Ctrl+Shift+F | Search in the session's files |
| F2 / Delete / Ctrl+C·X·V (explorer) | Rename, move to trash, copy/cut/paste files |
| Palette → Close split panes | Removes extra tmux panes (a stray `^b %` split) and keeps Claude. tmux's right-click menus are off. |
| Ctrl+Shift+C / V | Copy selection / paste |
| Ctrl+Shift+P | Command palette (also the ⌕ button) |
| Ctrl+Shift+E | Files pane (Esc: back, then close) |
@@ -80,12 +93,12 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
The **☀/◐** button switches light and dark mode (saved per browser, shared with the other web_template apps' `lt_theme` preference). Text size, desktop notifications (with a mute), the keyboard shortcuts sheet and fullscreen are in the **⋯** menu; everything is also in the command palette. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
## Configuration
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_REMOTE_CONTROL=1`.
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_GUARD=0`.
- `/etc/devdesk/env` holds the packaged defaults; don't edit it. Its keys:
- port, bind address, projects root and `DEVDESK_DEFAULT_DIR`
- `DEVDESK_GITEA_URL` / `DEVDESK_GITEA_ALIASES` for the Repo/CI panel. Public repos need no token; for private ones put a `read:repository` token in `/etc/devdesk/gitea-token`
- `CLAUDE_BIN` and `CLAUDE_ARGS`
- `DEVDESK_REMOTE_CONTROL=1`, which also starts each session with Claude Remote Control so it can be continued from the Claude app
- `DEVDESK_REMOTE_CONTROL=1`: the Remote Control default for new sessions until one is chosen in Settings (which then wins; each session can also choose its own)
- `DEVDESK_PUBLIC_URL`, `DEVDESK_MATRIX_WEBHOOK_FILE`, `DEVDESK_MATRIX_DELAY`, `DEVDESK_MATRIX_QUIET` and `DEVDESK_USAGE_WARN` for the Matrix notifications
- `/etc/devdesk/secret.env` is generated on install. It holds the ttyd credential that NPM must inject; see [docs/DEPLOY.md](docs/DEPLOY.md).
- `/etc/devdesk/tmux.local.conf` is optional and is sourced after the packaged `tmux.conf`.
+104 -5
View File
@@ -15,7 +15,7 @@
#
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --investigate SESSION UID |
# --upload SESSION FILE BYTES | --new NAME [PROJECT] [--worktree] | --rename OLD NEW | --end NAME |
# --last-reply NAME | --existing NAME`
# --last-reply NAME | --unsplit NAME | --fs-* SESSION … | --existing NAME`
# through ttyd; those print JSON.
set -euo pipefail
@@ -29,6 +29,8 @@ set -euo pipefail
. "$DEVDESK_LIBEXEC/devdesk-history.sh"
# shellcheck source=libexec/devdesk-ops.sh
. "$DEVDESK_LIBEXEC/devdesk-ops.sh"
# shellcheck source=libexec/devdesk-fsops.sh
. "$DEVDESK_LIBEXEC/devdesk-fsops.sh"
usage() {
sed -n '2,13s/^# \{0,1\}//p' "$0"
@@ -78,6 +80,8 @@ cmd_list() {
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
--argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \
--argjson tags "$(per_session_json "$DEVDESK_STATE_DIR/tags")" \
--arg rclive "$(dd_rc_live)" \
--argjson rcdef "$(dd_rc_default)" \
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
--arg root "$DEVDESK_PROJECTS_ROOT" '
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
@@ -96,7 +100,10 @@ cmd_list() {
activity: null, windows: 0,
claude_session_id: (.value.claude_session_id // null),
worktree: (.value.worktree // null)}))
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // [])}),
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // []),
remote_control: (($known[.name].remote_control) as $own | if $own == null then $rcdef else $own end),
rc_active: (.name as $n | $rclive | split("\n") | any(.[]; . == $n))}),
remote_control_default: $rcdef,
projects: ([{name: $dflt, dir: $root}]
+ [rows($proj)[] | {name: .[0], git: (.[1] == "1"), dir: (.[2:] | join("\t"))}
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
@@ -164,7 +171,7 @@ dd_worktree_add() {
else
"${g[@]}" worktree add -q -b "$branch" "$wt" HEAD >&2 || dd_die "git worktree add failed"
fi
wt=$(realpath -e -- "$wt")
wt=$(realpath -e -- "$wt") || dd_die "git worktree add failed"
# shellcheck disable=SC2016 # jq variables
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {worktree: {path: $w, repo: $r, branch: $b}})' \
--arg n "$name" --arg w "$wt" --arg r "$top" --arg b "$branch"
@@ -182,6 +189,97 @@ dd_worktree_end() {
fi
}
# --unsplit NAME (devdesk#52): close the extra panes a split left in the session's
# windows. Each window keeps one pane: Claude's in the claude window, else the
# first. Prints {ok, closed}.
cmd_unsplit() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --unsplit NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
{ dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } || dd_die "session '$1' isn't running"
local win keep pane closed=0
while read -r win; do
keep=$(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id} #{pane_start_command}' | awk '/devdesk-claude-run/ { print $1; exit }')
[ -n "$keep" ] || keep=$(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id}' | head -n 1)
while read -r pane; do
[ "$pane" = "$keep" ] && continue
dd_tmux kill-pane -t "$pane" && closed=$((closed + 1))
done < <(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id}')
done < <(dd_tmux list-windows -t "=$1" -F '#{window_index}')
jq -cMn --argjson c "$closed" '{ok: true, closed: $c}'
}
# Sessions whose running Claude has Remote Control on, read from the processes
# themselves (devdesk#61), one name per line.
dd_rc_live() {
local session window pid child
dd_server_up || return 0
while IFS=$'\t' read -r session window pid; do
[ "$window" = claude ] || continue
for child in $(pgrep -P "$pid" 2>/dev/null); do
if tr '\0' '\n' <"/proc/$child/cmdline" 2>/dev/null | grep -qx -- --remote-control; then
printf '%s\n' "$session"
break
fi
done
done < <(dd_tmux list-panes -a -F $'#{session_name}\t#{window_name}\t#{pane_pid}' 2>/dev/null)
}
# The Claude process of a session's claude window, if any.
dd_claude_pid() {
local pid
pid=$(dd_tmux list-panes -t "=$1:claude" -F '#{pane_pid}' 2>/dev/null | head -n1)
[ -n "$pid" ] && pgrep -P "$pid" 2>/dev/null | head -n1
}
# --remote-control NAME on|off (devdesk#61): the session's own choice. A running,
# idle Claude restarts on the same conversation right away; one that is working
# or waiting gets it at its next start. Prints {ok, remote_control, applied}.
cmd_remote_control() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --remote-control NAME on|off"
local name=$1 value applied=saved pid state
dd_valid_name "$name" || dd_die "invalid session name: $name"
case $2 in on) value=true ;; off) value=false ;; *) dd_die "on or off" ;; esac
{ session_exists "$name"; } || dd_die "no session named '$name'"
# shellcheck disable=SC2016 # jq variables
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {remote_control: ($v == "true")})' --arg n "$name" --arg v "$value"
if dd_server_up && dd_tmux has-session -t "=$name" 2>/dev/null && pid=$(dd_claude_pid "$name") && [ -n "$pid" ]; then
state=$(jq -rM '.state // empty' "$DEVDESK_STATUS_DIR/$name.json" 2>/dev/null) || state="" # no state yet: idle
if [ "$state" = working ] || [ "$state" = waiting ]; then
applied=next-start
else
mkdir -p "$DEVDESK_STATE_DIR/restart"
touch "$DEVDESK_STATE_DIR/restart/$name"
kill -TERM "$pid" 2>/dev/null || true
applied=now
fi
fi
jq -cMn --argjson v "$value" --arg a "$applied" '{ok: true, remote_control: $v, applied: $a}'
}
# Remote Control default for new sessions: Settings, else DEVDESK_REMOTE_CONTROL. true/false.
dd_rc_default() {
local def
def=$(jq -rM '.remote_control_default | if . == null then empty else tostring end' "$DEVDESK_STATE_DIR/settings.json" 2>/dev/null)
if [ -n "$def" ]; then echo "$def"; elif [ "${DEVDESK_REMOTE_CONTROL:-0}" = 1 ]; then echo true; else echo false; fi
}
# --settings [remote-control-default on|off] (devdesk#61): DevDesk-wide settings
# kept in $DEVDESK_STATE_DIR/settings.json; prints them.
cmd_settings() {
local file="$DEVDESK_STATE_DIR/settings.json" tmp
if [ $# -eq 2 ]; then
[ "$1" = remote-control-default ] || dd_die "unknown setting: $1"
case $2 in on | off) ;; *) dd_die "on or off" ;; esac
tmp=$(mktemp "$DEVDESK_STATE_DIR/.settings.XXXXXX")
jq -cM --argjson v "$([ "$2" = on ] && echo true || echo false)" '. + {remote_control_default: $v}' "$file" 2>/dev/null >"$tmp" ||
jq -cMn --argjson v "$([ "$2" = on ] && echo true || echo false)" '{remote_control_default: $v}' >"$tmp"
mv -f "$tmp" "$file"
elif [ $# -ne 0 ]; then
dd_die "usage: devdesk attach --settings [remote-control-default on|off]"
fi
jq -cMn --argjson d "$(dd_rc_default)" '{ok: true, remote_control_default: $d}'
}
cmd_rename() {
[ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW"
local old=$1 new=$2 running=0
@@ -359,14 +457,15 @@ cmd_attach() {
dd_linger
return
;;
--fs-list | --fs-find | --fs-read | --fs-write | --git-status | --git-show | --asset)
--fs-list | --fs-find | --fs-read | --fs-write | --git-status | --git-show | --asset | \
--fs-mkdir | --fs-move | --fs-copy | --fs-delete | --fs-restore | --fs-put | --fs-download | --fs-grep)
local action=${1#--}
shift
"cmd_${action//-/_}" "$@"
dd_linger
return
;;
--peek | --search | --last-reply)
--peek | --search | --last-reply | --unsplit | --remote-control | --settings | --delete-convo)
local action=${1#--}
shift
"cmd_${action//-/_}" "$@"
+69
View File
@@ -1,3 +1,72 @@
devdesk (0.12.0) bookworm; urgency=medium
* Settings window, from the ⋯ menu or the palette (#61):
- Theme, terminal text size and interface size (per browser).
- Desktop notifications.
- Remote Control and privacy.
The ⋯ menu shrinks to five entries.
* Remote Control per session (#61):
- A default for new sessions and a switch for each running session.
- Switching an idle session restarts its Claude on the same
conversation; a busy one changes at its next start.
- Sessions that sync show an RC tag, read from the running process.
- Server side: --remote-control and --settings.
* Delete a conversation from Search past conversations (#61): its local
transcript and subagent files, for good (--delete-convo). Refused
while a running session uses it.
-- Jared Vititoe <jjvititoe1@gmail.com> Tue, 03 Nov 2026 21:25:00 -0500
devdesk (0.11.0) bookworm; urgency=medium
* Explorer works like VS Code's (#54):
- Right-click menus for files, folders and empty space;
Ctrl/Shift+click selects several.
- Inline rename (F2) and inline new file/folder.
- Drag to move, Ctrl+drag to copy.
- Drop files or whole folders from the desktop to upload them, asking
Replace / Skip / Keep both on a clash.
- Cut/copy/paste files, duplicate, download, copy path, @Claude.
- Delete moves to a 7-day trash with Undo; nothing is overwritten
unless asked.
- Server side: new --fs-mkdir/-move/-copy/-delete/-restore/-put/
-download.
* Markdown preview rewritten (#55):
- GitHub-style headings with anchors and an outline.
- Highlighted code with Copy, callouts, task lists, front matter.
- Relative links open files in DevDesk.
- Split view with a live preview.
* Editor (#55): status bar (line/column, cursors, indentation, line
endings, language), multiple cursors, column select, auto-closing
brackets, Ctrl+G, and tab menus with drag-to-reorder.
* Search in files, Ctrl+Shift+F (#56): match case, whole word and regex,
via git grep or grep.
* Fixes:
- A nested path check could escape the session folder: set -e does not
reach into $(...).
- The tree could show a stale listing.
- Session actions now return the server's reply, so "Close split
panes" reports correctly.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 22 Oct 2026 21:30:00 -0400
devdesk (0.10.4) bookworm; urgency=medium
* The Sessions panel's list scrolls on its own (#53). There is no cap on
sessions; with many of them, Ops Alerts and Repo/CI stay in reach below
the list. The current session is scrolled into view when you switch.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 22 Oct 2026 20:53:00 -0400
devdesk (0.10.3) bookworm; urgency=medium
* No tmux right-click menus (#52). A stray click on one split a pane, or
could kill Claude's window. "Close split panes (keeps Claude)" in the
command palette, backed by `--unsplit`, removes a split that happened
anyway.
-- Jared Vititoe <jjvititoe1@gmail.com> Tue, 13 Oct 2026 09:00:00 -0400
devdesk (0.10.2) bookworm; urgency=medium
* Selecting text in the terminal works like a normal terminal (#51). A
+3 -1
View File
@@ -62,5 +62,7 @@ DEVDESK_USAGE_WARN=90
# /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.
# Remote Control default for new sessions (1 = on: continue them from the Claude app;
# Anthropic then stores the transcript to sync devices). Settings in the web UI
# overrides this, and each session can choose its own (devdesk#61).
DEVDESK_REMOTE_CONTROL=0
+11 -1
View File
@@ -27,7 +27,8 @@ RESET=$'\033[0m'
common_args() {
printf '%s\0' --settings "$SETTINGS"
if [ "${DEVDESK_REMOTE_CONTROL:-0}" = "1" ]; then
# Per session since devdesk#61 (Settings → Remote Control).
if [ "$(dd_rc_wanted "$NAME")" = 1 ]; then
printf '%s\0' --remote-control "devdesk-$NAME"
fi
if [ ${#EXTRA[@]} -gt 0 ]; then
@@ -97,6 +98,15 @@ while :; do
if [ "$mode" = new ]; then run_new; else run_resume; fi
rc=$?
NAME=$(dd_current_session)
# Settings changed Remote Control: Claude was stopped to restart on the same
# conversation with the new setting (devdesk#61), no menu.
restart="$DEVDESK_STATE_DIR/restart/$NAME"
if [ -e "$restart" ]; then
rm -f "$restart"
printf '\n%s◈ Restarting Claude with Remote Control %s…%s\n' "$MUTED" "$([ "$(dd_rc_wanted "$NAME")" = 1 ] && echo on || echo off)" "$RESET"
mode=resume
continue
fi
while :; do
menu "$rc"
IFS= read -rsn1 key || exit 0
+269
View File
@@ -0,0 +1,269 @@
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
# Explorer file operations (devdesk#54), sourced by bin/devdesk and reached through
# `devdesk attach --fs-mkdir|--fs-move|--fs-copy|--fs-delete|--fs-restore|--fs-put|--fs-download`.
# Same rules as devdesk-files.sh: every path is relative to the session's folder
# and stays inside it. Entries are handled as themselves (a symlink is moved or
# deleted as a link, never followed), nothing is ever overwritten unless asked,
# and deleting moves to a per-session trash that can be restored for 7 days.
#
# set -e does not reach into $(...): every helper whose result is captured is
# followed by `|| exit 1`, or a failed check would carry on with an empty path.
: "${DEVDESK_TRASH_DAYS:=7}"
# A name the explorer may create: not empty, no slash, not . or .., no newline.
dd_fs_valid_name() {
case $1 in '' | . | .. | */* | *$'\n'* | *$'\r'*) return 1 ;; esac
return 0
}
# dd_fs_inside ROOT ABS: dies unless ABS is ROOT or inside it (a second guard,
# whatever the callers checked).
dd_fs_inside() {
{ [ -n "$1" ] && [ -n "$2" ]; } || dd_die "internal error: empty path"
case "$2/" in "$1"/*) ;; *) dd_die "outside the session folder: $2" ;; esac
}
# dd_fs_split REL -> sets DD_PARENT (relative, may be empty) and DD_NAME, or dies.
dd_fs_split() {
local rel=$1 part
case $rel in /* | *$'\n'* | *$'\r'* | '' | */) dd_die "bad path: $rel" ;; esac
local IFS=/
for part in $rel; do
{ [ -n "$part" ] && [ "$part" != . ] && [ "$part" != .. ]; } || dd_die "bad path: $rel"
done
DD_NAME=${rel##*/}
if [[ $rel == */* ]]; then DD_PARENT=${rel%/*}; else DD_PARENT=""; fi
}
# dd_fs_entry ROOT REL -> the absolute path of the entry itself (its folder
# resolved, the last component not followed), which must exist and isn't ROOT.
dd_fs_entry() {
local root=$1 dir
dd_fs_split "$2"
dir=$(dd_fs_path "$root" "${DD_PARENT:-.}") || exit 1
[ -d "$dir" ] || dd_die "not a folder: $DD_PARENT"
{ [ -e "$dir/$DD_NAME" ] || [ -L "$dir/$DD_NAME" ]; } || dd_die "no such file: $2"
dd_fs_inside "$root" "$dir"
printf '%s/%s\n' "$dir" "$DD_NAME"
}
# dd_fs_mkdirs ROOT RELDIR -> creates the missing folders of RELDIR one level at
# a time, each checked to be a real folder inside ROOT (never through a symlink
# out of it), and prints the absolute folder.
dd_fs_mkdirs() {
local root=$1 rel=$2 cur part
cur=$root
[ -n "$rel" ] || { printf '%s\n' "$root"; return; }
local IFS=/
for part in $rel; do
dd_fs_valid_name "$part" || dd_die "bad folder name: $part"
if [ ! -e "$cur/$part" ] && [ ! -L "$cur/$part" ]; then
mkdir -- "$cur/$part" || dd_die "cannot create folder: $part"
fi
cur=$(realpath -e -- "$cur/$part" 2>/dev/null) || dd_die "cannot use folder: $part"
case "$cur/" in "$root"/*) ;; *) dd_die "outside the session folder: $rel" ;; esac
[ -d "$cur" ] || dd_die "not a folder: $part"
done
printf '%s\n' "$cur"
}
# dd_fs_dest ROOT REL -> absolute path for a new entry REL (folders above it are
# created); dies if something is already there.
dd_fs_dest() {
local root=$1 dir
dd_fs_split "$2"
dir=$(dd_fs_mkdirs "$root" "$DD_PARENT") || exit 1
if [ -e "$dir/$DD_NAME" ] || [ -L "$dir/$DD_NAME" ]; then dd_die "already exists: $2"; fi
dd_fs_inside "$root" "$dir"
printf '%s/%s\n' "$dir" "$DD_NAME"
}
# --fs-mkdir SESSION REL: a new folder (and any missing ones above it).
cmd_fs_mkdir() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-mkdir SESSION PATH"
local root dest
root=$(dd_session_root "$1") || exit 1
dest=$(dd_fs_dest "$root" "$2") || exit 1
mkdir -- "$dest" || dd_die "cannot create folder: $2"
jq -cMn --arg p "${dest#"$root"/}" '{ok: true, path: $p}'
}
# --fs-move SESSION FROM TO / --fs-copy SESSION FROM TO: TO is the full new path.
# Never onto something that exists, never a folder into itself.
dd_fs_transfer() {
local op=$1 session=$2 from=$3 to=$4 root src dest
root=$(dd_session_root "$session") || exit 1
src=$(dd_fs_entry "$root" "$from") || exit 1
dest=$(dd_fs_dest "$root" "$to") || exit 1
case "$dest/" in "$src"/*) dd_die "cannot put a folder inside itself: $to" ;; esac
if [ "$op" = move ]; then
mv -T -n -- "$src" "$dest" 2>/dev/null || dd_die "cannot move $from"
if [ -e "$src" ] || [ -L "$src" ]; then dd_die "cannot move $from"; fi
else
cp -a -T -n -- "$src" "$dest" 2>/dev/null || { rm -rf -- "$dest"; dd_die "cannot copy $from"; }
fi
jq -cMn --arg f "${src#"$root"/}" --arg t "${dest#"$root"/}" '{ok: true, from: $f, path: $t}'
}
cmd_fs_move() {
[ $# -eq 3 ] || dd_die "usage: devdesk attach --fs-move SESSION FROM TO"
dd_fs_transfer move "$@"
}
cmd_fs_copy() {
[ $# -eq 3 ] || dd_die "usage: devdesk attach --fs-copy SESSION FROM TO"
dd_fs_transfer copy "$@"
}
dd_trash_dir() {
printf '%s/trash/%s\n' "$DEVDESK_STATE_DIR" "$1"
}
# --fs-delete SESSION REL...: moves the entries to the session's trash
# ($DEVDESK_STATE_DIR/trash/SESSION/ID, kept DEVDESK_TRASH_DAYS days) and prints
# {ok, trash: ID, paths} for --fs-restore.
cmd_fs_delete() {
[ $# -ge 2 ] || dd_die "usage: devdesk attach --fs-delete SESSION PATH..."
local session=$1 root trash id rel src i=0 entries=()
shift
root=$(dd_session_root "$session") || exit 1
# Check every path before moving any.
for rel in "$@"; do
src=$(dd_fs_entry "$root" "$rel") || exit 1
entries+=("$src")
done
trash=$(dd_trash_dir "$session") || exit 1
mkdir -p -- "$trash" && chmod 700 "$DEVDESK_STATE_DIR/trash" "$trash" 2>/dev/null
find "$trash" -mindepth 1 -maxdepth 1 -mtime +"$DEVDESK_TRASH_DAYS" -exec rm -rf -- {} + 2>/dev/null || true
id=$(date +%Y%m%d-%H%M%S)-$RANDOM
mkdir -- "$trash/$id" || dd_die "cannot use the trash"
local done_paths=()
for src in "${entries[@]}"; do
mv -T -- "$src" "$trash/$id/$i" 2>/dev/null || break
done_paths+=("${src#"$root"/}")
i=$((i + 1))
done
printf '%s\0' "${done_paths[@]}" | jq -cMRs --arg root "$root" '{root: $root, paths: (split("\u0000") | map(select(length > 0)))}' >"$trash/$id/manifest.json"
[ "${#done_paths[@]}" -eq "$#" ] || dd_die "deleted ${#done_paths[@]} of $#; the rest is still there (trash $id)"
jq -cMn --arg id "$id" --slurpfile m "$trash/$id/manifest.json" '{ok: true, trash: $id, paths: $m[0].paths}'
}
# --fs-restore SESSION ID: puts a deletion back where it was (if nothing has
# taken those paths since); prints {ok, paths}.
cmd_fs_restore() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-restore SESSION ID"
local session=$1 id=$2 root dir rel i=0 restored=() dest
[[ $id =~ ^[0-9]{8}-[0-9]{6}-[0-9]+$ ]] || dd_die "bad trash id: $id"
root=$(dd_session_root "$session") || exit 1
dir="$(dd_trash_dir "$session")/$id"
[ -f "$dir/manifest.json" ] || dd_die "nothing to restore (emptied after $DEVDESK_TRASH_DAYS days?)"
[ "$(jq -r .root "$dir/manifest.json")" = "$root" ] || dd_die "that deletion was in another folder"
while IFS= read -r -d '' rel; do
if [ -e "$dir/$i" ] || [ -L "$dir/$i" ]; then
dest=$(dd_fs_dest "$root" "$rel") || exit 1
mv -T -- "$dir/$i" "$dest" || dd_die "cannot restore $rel"
restored+=("$rel")
fi
i=$((i + 1))
done < <(jq -j '.paths[] | ., "\u0000"' "$dir/manifest.json")
rm -rf -- "$dir"
printf '%s\0' "${restored[@]}" | jq -cMRs '{ok: true, paths: (split("\u0000") | map(select(length > 0)))}'
}
# --fs-put SESSION REL BYTES new|replace: upload any file (up to
# DEVDESK_UPLOAD_MAX) to REL, creating missing folders. "new" refuses to replace
# anything; "replace" overwrites a regular file. The content arrives as base64
# after the READY line, into a temp file next to the target, moved into place.
cmd_fs_put() {
[ $# -eq 4 ] || dd_die "usage: devdesk attach --fs-put SESSION PATH BYTES new|replace"
local root rel=$2 size=$3 mode=$4 dir target tmp b64len actual
root=$(dd_session_root "$1") || exit 1
{ [[ $size =~ ^[0-9]+$ ]] && [ "$size" -le "$DEVDESK_UPLOAD_MAX" ]; } ||
dd_die "larger than $((DEVDESK_UPLOAD_MAX / 1048576)) MB"
case $mode in new | replace) ;; *) dd_die "mode is new or replace" ;; esac
dd_fs_split "$rel"
dir=$(dd_fs_mkdirs "$root" "$DD_PARENT") || exit 1
dd_fs_inside "$root" "$dir"
target="$dir/$DD_NAME"
if [ -e "$target" ] || [ -L "$target" ]; then
[ "$mode" = replace ] || dd_die "already exists: $rel"
{ [ -f "$target" ] && [ ! -L "$target" ]; } || dd_die "not a regular file: $rel"
fi
tmp=$(mktemp --tmpdir="$dir" ".${DD_NAME:0:100}.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-PUT-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 "upload incomplete ($actual of $size bytes)"
fi
if [ -f "$target" ]; then
chmod --reference="$target" "$tmp" 2>/dev/null || true
chown --reference="$target" "$tmp" 2>/dev/null || true
mv -f -- "$tmp" "$target" || { rm -f "$tmp"; dd_die "cannot replace $rel"; }
else
chmod 0644 "$tmp"
# -n: something created meanwhile is not replaced.
mv -n -T -- "$tmp" "$target" 2>/dev/null || true
if [ -e "$tmp" ]; then
rm -f "$tmp"
dd_die "already exists: $rel"
fi
fi
trap - HUP INT TERM
jq -cMn --arg p "${target#"$root"/}" --argjson s "$size" '{ok: true, path: $p, size: $s}'
}
# --fs-download SESSION REL: any regular file up to DEVDESK_UPLOAD_MAX, as a blob
# with its body whatever the type (the browser saves it).
cmd_fs_download() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-download SESSION PATH"
local root f size
root=$(dd_session_root "$1") || exit 1
f=$(dd_fs_path "$root" "$2") || exit 1
[ -f "$f" ] || dd_die "not a file: $2"
size=$(stat -c %s -- "$f")
[ "$size" -le "$DEVDESK_UPLOAD_MAX" ] || dd_die "larger than $((DEVDESK_UPLOAD_MAX / 1048576)) MB"
jq -cMn --arg p "${f#"$root"/}" --argjson s "$size" '{path: $p, size: $s, body: true, download: true}'
printf '%s\n' "$DD_BLOB_BEGIN"
base64 -w0 -- "$f"
printf '\n%s\n' "$DD_BLOB_END"
}
# --fs-grep SESSION FLAGS QUERY (devdesk#56): search the session's files. FLAGS
# is "-" or letters: i (ignore case), r (regular expression, else literal),
# w (whole word). git grep in a repository (tracked + untracked, .gitignore
# honoured), else grep without node_modules/.git/build folders. Binary files are
# skipped; at most DEVDESK_GREP_MAX (2000) lines, each cut to 400 characters.
cmd_fs_grep() {
[ $# -eq 3 ] || dd_die "usage: devdesk attach --fs-grep SESSION FLAGS QUERY"
local root flags=$2 query=$3 max=${DEVDESK_GREP_MAX:-2000} opts=()
root=$(dd_session_root "$1") || exit 1
[[ $flags =~ ^(-|[irw]+)$ ]] || dd_die "flags are i, r and w"
{ [ -n "$query" ] && [ "${#query}" -le 500 ]; } || dd_die "search for 1 to 500 characters"
[[ $flags == *i* ]] && opts+=(-i)
[[ $flags == *w* ]] && opts+=(-w)
if [[ $flags == *r* ]]; then opts+=(-E); else opts+=(-F); fi
{
if git --no-optional-locks -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
(cd "$root" && timeout 20 git --no-optional-locks grep -n -I -z --untracked --exclude-standard "${opts[@]}" -e "$query" -- . 2>/dev/null) || true
else
(cd "$root" && timeout 20 grep -r -n -I -Z "${opts[@]}" \
--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=__pycache__ --exclude-dir=.venv --exclude-dir=venv \
--exclude-dir=dist --exclude-dir=build --exclude-dir=target --exclude-dir=.cache --exclude-dir=.next \
--exclude-dir=coverage --exclude-dir=.worktrees -e "$query" . 2>/dev/null) || true
fi
} | head -n "$((max + 1))" |
jq -cMRs --arg root "$root" --argjson max "$max" '
# git grep -z: path NUL line NUL text; grep -Z: path NUL line:text
[split("\n")[] | select(length > 0) | split("\u0000") | select(length >= 2)
| if length >= 3 then {p: .[0], l: (.[1] | tonumber? // 0), t: (.[2:] | join(" "))}
else (.[1] | index(":")) as $i | select($i != null)
| {p: .[0], l: (.[1][:$i] | tonumber? // 0), t: .[1][($i + 1):]} end
| {p: (.p | ltrimstr("./")), l, t: (.t | .[:400])}] as $m
| {root: $root, truncated: ($m | length > $max), matches: $m[:$max]}'
}
+27
View File
@@ -115,3 +115,30 @@ cmd_presence() {
setsid -f bash -c 'sleep 120; [ -n "$(find "$1" -newermt "-110 seconds" 2>/dev/null)" ] || rm -f "$1"' _ "$marker" \
</dev/null >/dev/null 2>&1 3>&- || true
}
# --delete-convo ID (devdesk#61): permanently delete a conversation's local files
# (the transcript ~/.claude/projects/<folder>/<id>.jsonl and its <id>/ folder of
# subagent transcripts and tool results). Refused while a running session uses
# it. Dormant sessions that would resume it forget it (they start fresh).
# Copies kept by Anthropic (Remote Control, the API) are not touched: those are
# deleted at claude.ai.
cmd_delete_convo() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --delete-convo ID"
local id=$1 file dir other
[[ $id =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || dd_die "bad conversation id: $id"
file=$(dd_convo_file "$id") || true
{ [ -n "$file" ] && [ -f "$file" ]; } || dd_die "no conversation $id"
case "$file" in "$(dd_projects_dir)"/*/"$id.jsonl") ;; *) dd_die "unexpected path: $file" ;; esac
for other in $(jq -rM --arg id "$id" '.sessions | to_entries[] | select(.value.claude_session_id == $id) | .key' "$DEVDESK_REGISTRY" 2>/dev/null); do
if dd_server_up && dd_tmux has-session -t "=$other" 2>/dev/null; then
dd_die "session $other is using this conversation: end it first"
fi
done
dir=${file%.jsonl}
rm -f -- "$file"
[ ! -d "$dir" ] || rm -rf -- "$dir"
# shellcheck disable=SC2016 # jq variables
dd_registry_update '.sessions |= with_entries(if .value.claude_session_id == $id then .value |= (del(.claude_session_id) | del(.claude_source)) else . end)' \
--arg id "$id"
jq -cMn --arg id "$id" '{ok: true, deleted: $id}'
}
+12
View File
@@ -183,6 +183,18 @@ dd_registry_set_dir() {
--arg n "$1" --arg d "$2" --arg t "$(date -Is)"
}
# Remote Control for session NAME (devdesk#61): its own choice if it made one,
# else the default set in Settings (state settings.json), else
# DEVDESK_REMOTE_CONTROL from the env files. Prints 1 or 0.
dd_rc_wanted() {
local own def
# (not dd_registry_get: its `// empty` would turn false into "unset")
own=$(jq -Mr --arg n "$1" '.sessions[$n].remote_control | if . == null then empty else tostring end' "$DEVDESK_REGISTRY" 2>/dev/null)
case $own in true) echo 1; return ;; false) echo 0; return ;; esac
def=$(jq -rM '.remote_control_default // empty' "$DEVDESK_STATE_DIR/settings.json" 2>/dev/null)
case $def in true) echo 1 ;; false) echo 0 ;; *) [ "${DEVDESK_REMOTE_CONTROL:-0}" = 1 ] && echo 1 || echo 0 ;; esac
}
dd_registry_set_claude() {
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {claude_session_id: $id, claude_source: $src, updated: $t})' \
--arg n "$1" --arg id "$2" --arg src "$3" --arg t "$(date -Is)"
+1
View File
@@ -7,6 +7,7 @@
"name": "devdesk",
"license": "MIT",
"devDependencies": {
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.1",
"@codemirror/lang-markdown": "6.5.2",
"@codemirror/language": "6.13.1",
+1
View File
@@ -13,6 +13,7 @@
"test": "node --test tests/*.test.mjs"
},
"devDependencies": {
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.1",
"@codemirror/lang-markdown": "6.5.2",
"@codemirror/language": "6.13.1",
+7 -1
View File
@@ -8,7 +8,13 @@ set -s set-clipboard on # tmux copy-mode -> OSC 52 -> browser cli
set -s default-terminal "tmux-256color"
set -s terminal-features[90] "xterm-256color:RGB" # fixed index: re-sourcing must not append
set -g history-limit 50000
set -g mouse on # wheel scrolls history; Shift+drag selects natively
set -g mouse on # wheel scrolls history; a drag selects in the browser (devdesk#51)
# No right-click menus (devdesk#52): one stray click on them splits a pane or
# kills a window (Claude's). Splits and kills stay on the keyboard (^b % ^b x).
unbind -n MouseDown3Pane
unbind -n M-MouseDown3Pane
unbind -n MouseDown3Status
unbind -n MouseDown3StatusLeft
set -g allow-passthrough off # output may not bypass tmux (title/clipboard spoofing)
set -g renumber-windows on
+228 -1
View File
@@ -25,7 +25,9 @@ setup() {
#!/bin/bash
printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION"
[ -z "${FAKE_EXIT:-}" ] || exit "$FAKE_EXIT"
exec sleep 300
# Keeps its arguments in /proc/PID/cmdline (Remote Control is read from there)
# and ends on SIGTERM, like Claude.
exec bash -c 'sleep 300 & wait' fake-claude "$@"
EOF
chmod +x "$CLAUDE_BIN"
}
@@ -1181,3 +1183,228 @@ JSONL
[ "$(posts)" -eq 1 ]
[ "$(cat "$DEVDESK_STATE_DIR/usage-warned")" = 2000018000 ]
}
@test "unsplit closes extra panes and keeps Claude's (#52)" {
start_server
devdesk new alpha
tmux -S "$DEVDESK_SOCKET" split-window -h -t =alpha:claude
tmux -S "$DEVDESK_SOCKET" split-window -v -t =alpha:shell
run devdesk attach --unsplit alpha
[ "$status" -eq 0 ]
[ "$(jq -r .closed <<<"$output")" = 2 ]
[ "$(tmux -S "$DEVDESK_SOCKET" list-panes -t =alpha:claude -F '#{pane_start_command}')" = "$REPO/libexec/devdesk-claude-run" ]
[ "$(tmux -S "$DEVDESK_SOCKET" list-panes -t =alpha:shell | wc -l)" = 1 ]
run devdesk attach --unsplit alpha
[ "$(jq -r .closed <<<"$output")" = 0 ]
}
fs_put() { # SESSION PATH MODE CONTENT-FILE
local size
size=$(stat -c %s "$4")
base64 -w0 "$4" | devdesk attach --fs-put "$1" "$2" "$size" "$3"
}
last_json() { tail -n1 <<<"$output"; }
@test "fs-mkdir, fs-move and fs-copy stay inside the folder and never overwrite (#54)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
mkdir -p "$p/src" && printf 'a\n' >"$p/src/a.txt" && printf 'b\n' >"$p/b.txt"
ln -s "$TMP/outside" "$p/out"
ln -s b.txt "$p/link"
run devdesk attach --fs-mkdir alpha docs/guides
[ "$status" -eq 0 ] && [ -d "$p/docs/guides" ]
[ "$(last_json | jq -r .path)" = docs/guides ]
run devdesk attach --fs-mkdir alpha docs
[[ $output == *"already exists"* ]]
for bad in ../x /abs out/x 'a/../b' . '' docs/; do
run devdesk attach --fs-mkdir alpha "$bad"
[ "$status" -ne 0 ]
done
[ -z "$(ls -A "$TMP/outside")" ]
# out/x once escaped to /x: set -e doesn't reach into $(...) (#54)
[ ! -e /x ] && [ ! -e /abs ]
# rename a file, move a folder into another, never onto something
run devdesk attach --fs-move alpha src/a.txt src/renamed.txt
[ "$status" -eq 0 ] && [ -f "$p/src/renamed.txt" ] && [ ! -e "$p/src/a.txt" ]
run devdesk attach --fs-move alpha b.txt src/renamed.txt
[ "$status" -ne 0 ] && [[ $output == *"already exists"* ]]
[ "$(cat "$p/b.txt")" = b ]
run devdesk attach --fs-move alpha src docs/src
[ "$status" -eq 0 ] && [ -f "$p/docs/src/renamed.txt" ]
run devdesk attach --fs-move alpha docs docs/guides/docs
[ "$status" -ne 0 ] && [[ $output == *"inside itself"* ]]
run devdesk attach --fs-move alpha b.txt out/b.txt
[ "$status" -ne 0 ]
run devdesk attach --fs-move alpha ../outside x
[ "$status" -ne 0 ]
# a symlink moves as a link
run devdesk attach --fs-move alpha link link2
[ "$status" -eq 0 ] && [ -L "$p/link2" ] && [ -f "$p/b.txt" ]
# copy: folders recursively, missing parents created
run devdesk attach --fs-copy alpha docs/src new/place/src
[ "$status" -eq 0 ] && [ -f "$p/new/place/src/renamed.txt" ] && [ -f "$p/docs/src/renamed.txt" ]
run devdesk attach --fs-copy alpha b.txt docs/src/renamed.txt
[ "$status" -ne 0 ]
}
@test "fs-delete moves to the trash and fs-restore puts it back (#54)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
mkdir -p "$p/dir/sub" && printf 'x\n' >"$p/dir/sub/x" && printf 'y\n' >"$p/y.txt"
ln -s "$TMP/outside" "$p/out"
touch "$TMP/outside/keep"
run devdesk attach --fs-delete alpha dir y.txt out
[ "$status" -eq 0 ]
id=$(last_json | jq -r .trash)
[ "$(last_json | jq -c .paths)" = '["dir","y.txt","out"]' ]
[ ! -e "$p/dir" ] && [ ! -e "$p/y.txt" ] && [ ! -L "$p/out" ] && [ -f "$TMP/outside/keep" ]
# nothing deleted when one path is bad
printf 'z\n' >"$p/z.txt"
run devdesk attach --fs-delete alpha z.txt ../outside
[ "$status" -ne 0 ] && [ -f "$p/z.txt" ]
run devdesk attach --fs-delete alpha .
[ "$status" -ne 0 ]
# restore; a path taken meanwhile is refused
printf 'new\n' >"$p/y.txt"
run devdesk attach --fs-restore alpha "$id"
[ "$status" -ne 0 ] && [[ $output == *"already exists: y.txt"* ]]
rm "$p/y.txt"
run devdesk attach --fs-restore alpha "$id"
[ "$status" -eq 0 ]
[ "$(cat "$p/dir/sub/x" "$p/y.txt")" = "$(printf 'x\ny')" ] && [ -L "$p/out" ]
[ ! -e "$DEVDESK_STATE_DIR/trash/alpha/$id" ]
run devdesk attach --fs-restore alpha "../../etc"
[ "$status" -ne 0 ]
}
@test "fs-put uploads any file into a folder; fs-download returns it (#54)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
head -c 3000 /dev/urandom >"$TMP/bin"
run fs_put alpha assets/img/logo.bin new "$TMP/bin"
[ "$status" -eq 0 ] && cmp -s "$TMP/bin" "$p/assets/img/logo.bin"
[ "$(stat -c %a "$p/assets/img/logo.bin")" = 644 ]
run fs_put alpha assets/img/logo.bin new "$TMP/bin"
[ "$status" -ne 0 ] && [[ $output == *"already exists"* ]]
printf 'v2' >"$TMP/v2"
chmod 600 "$p/assets/img/logo.bin"
run fs_put alpha assets/img/logo.bin replace "$TMP/v2"
[ "$status" -eq 0 ] && [ "$(cat "$p/assets/img/logo.bin")" = v2 ] && [ "$(stat -c %a "$p/assets/img/logo.bin")" = 600 ]
: >"$TMP/empty"
run fs_put alpha empty.txt new "$TMP/empty"
[ "$status" -eq 0 ] && [ -f "$p/empty.txt" ] && [ ! -s "$p/empty.txt" ]
ln -s "$TMP/outside" "$p/out"
for bad in out/x ../x /x assets; do
run fs_put alpha "$bad" new "$TMP/v2"
[ "$status" -ne 0 ]
done
[ -z "$(ls -A "$TMP/outside")" ]
run fs_put alpha assets replace "$TMP/v2"
[ "$status" -ne 0 ] && [ -d "$p/assets" ]
# short stream: nothing left behind
run bash -c "printf aGVs | '$REPO/bin/devdesk' attach --fs-put alpha short.txt 6 new"
[ "$status" -ne 0 ] && [ ! -e "$p/short.txt" ] && [ -z "$(find "$p" -name '.*devdesk-*')" ]
# download: binary body comes back whole
run devdesk attach --fs-download alpha assets/img/logo.bin
[ "$status" -eq 0 ]
[ "$(sed -n '/DEVDESK-BLOB-BEGIN/{n;p}' <<<"$output" | base64 -d)" = v2 ]
head -c 3000 /dev/urandom >"$p/rand"
run devdesk attach --fs-download alpha rand
sed -n '/DEVDESK-BLOB-BEGIN/{n;p}' <<<"$output" | base64 -d | cmp -s - "$p/rand"
run devdesk attach --fs-download alpha out
[ "$status" -ne 0 ]
}
@test "fs-grep searches files, literally or by regex, git or not (#56)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
mkdir -p "$p/src" "$p/node_modules/x"
printf 'const Answer = 42;\nlet b = "a:b";\n' >"$p/src/a.js"
printf 'answer here\n' >"$p/node_modules/x/i.js"
printf '\x00\x01answer' >"$p/bin.dat"
run devdesk attach --fs-grep alpha - 'Answer'
[ "$(jq -c '[.matches[] | [.p, .l, .t]]' <<<"$output")" = '[["src/a.js",1,"const Answer = 42;"]]' ]
run devdesk attach --fs-grep alpha i 'answer'
[ "$(jq -r '[.matches[].p] | join(",")' <<<"$output")" = src/a.js ]
run devdesk attach --fs-grep alpha - '"a:b"'
[ "$(jq -r '.matches[0].t' <<<"$output")" = 'let b = "a:b";' ]
run devdesk attach --fs-grep alpha r 'A[n]swer = [0-9]+'
[ "$(jq -r '.matches | length' <<<"$output")" = 1 ]
run devdesk attach --fs-grep alpha - '[n]'
[ "$(jq -r '.matches | length' <<<"$output")" = 0 ]
# in a repository: git grep, .gitignore honoured, untracked files included
git -C "$p" init -q
printf 'node_modules/\n' >"$p/.gitignore"
printf 'Answer again\n' >"$p/untracked.txt"
run devdesk attach --fs-grep alpha w 'Answer'
[ "$(jq -c '[.matches[] | [.p, .l]] | sort' <<<"$output")" = '[["src/a.js",1],["untracked.txt",1]]' ]
run devdesk attach --fs-grep alpha x 'Answer'
[ "$status" -ne 0 ]
run devdesk attach --fs-grep alpha - ''
[ "$status" -ne 0 ]
}
@test "Remote Control is per session: Settings default, live state, restart when idle (#61)" {
start_server
devdesk new alpha
run claude_args alpha
[[ $output != *remote-control* ]]
run devdesk attach --list
[ "$(jq -c '[.remote_control_default, (.sessions[] | select(.name=="alpha") | .remote_control, .rc_active)]' <<<"$output")" = '[false,false,false]' ]
# Default on for new sessions
run devdesk attach --settings remote-control-default on
[ "$(jq -r .remote_control_default <<<"$output")" = true ]
devdesk new beta
run claude_args beta
[[ $output == *"--remote-control devdesk-beta"* ]]
for _ in $(seq 1 30); do
[ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="beta") | .rc_active')" = true ] && break
sleep 0.1
done
[ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="beta") | .rc_active')" = true ]
# alpha (idle): switching restarts Claude with the flag, no exit menu
run devdesk attach --remote-control alpha on
[ "$(jq -r .applied <<<"$output")" = now ]
for _ in $(seq 1 50); do [ "$(wc -l <"$FAKE_LOG/alpha")" -ge 2 ] && break; sleep 0.1; done
[[ "$(tail -n1 "$FAKE_LOG/alpha")" == *"--remote-control devdesk-alpha"* ]]
[ ! -e "$DEVDESK_STATE_DIR/restart/alpha" ]
# working: saved for the next start, Claude untouched
mkdir -p "$DEVDESK_STATE_DIR/state"
printf '{"state":"working","since":1}' >"$DEVDESK_STATE_DIR/state/alpha.json"
run devdesk attach --remote-control alpha off
[ "$(jq -r .applied <<<"$output")" = next-start ]
sleep 0.5
[ "$(wc -l <"$FAKE_LOG/alpha")" -eq 2 ]
[ "$(jq -r .sessions.alpha.remote_control "$DEVDESK_STATE_DIR/registry.json")" = false ]
# an explicit off beats the default on
[ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="alpha") | .remote_control')" = false ]
run devdesk attach --remote-control nope on
[ "$status" -ne 0 ]
run devdesk attach --remote-control alpha maybe
[ "$status" -ne 0 ]
}
@test "delete-convo removes a conversation's local files, never one in use (#61)" {
start_server
export CLAUDE_CONFIG_DIR="$TMP/claude"
id=11111111-2222-3333-4444-555555555555
other=99999999-2222-3333-4444-555555555555
d="$CLAUDE_CONFIG_DIR/projects/-x"
mkdir -p "$d/$id/subagents" "$d/$other"
echo '{}' >"$d/$id.jsonl" && echo '{}' >"$d/$id/subagents/a.jsonl" && echo '{}' >"$d/$other.jsonl"
devdesk new alpha
jq --arg id "$id" '.sessions.alpha.claude_session_id = $id | .sessions.dormant = {dir: "/tmp", claude_session_id: $id}' \
"$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
run devdesk attach --delete-convo "$id"
[ "$status" -ne 0 ] && [[ $output == *"session alpha is using"* ]] && [ -f "$d/$id.jsonl" ]
devdesk end alpha
run devdesk attach --delete-convo "$id"
[ "$status" -eq 0 ]
[ ! -e "$d/$id.jsonl" ] && [ ! -e "$d/$id" ] && [ -f "$d/$other.jsonl" ]
[ "$(jq -r '.sessions.dormant.claude_session_id // "none"' "$DEVDESK_STATE_DIR/registry.json")" = none ]
run devdesk attach --delete-convo "$id"
[ "$status" -ne 0 ]
run devdesk attach --delete-convo "../../etc/passwd"
[ "$status" -ne 0 ]
}
+155 -14
View File
@@ -2,11 +2,16 @@
// languages, a unified diff view, and markdown rendering. Built separately to
// dist/assets/editor.js (window.DevDeskEditor) and loaded over the ttyd channel
// 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,
rectangularSelection, crosshairCursor, dropCursor,
} from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap, indentOnInput } from '@codemirror/language';
import { search, searchKeymap, highlightSelectionMatches, gotoLine as gotoLineDialog } from '@codemirror/search';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { highlightTree, classHighlighter } from '@lezer/highlight';
import { markdownLanguage } from '@codemirror/lang-markdown';
import { unifiedMergeView } from '@codemirror/merge';
import { tags as t } from '@lezer/highlight';
@@ -27,7 +32,7 @@ import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { lua } from '@codemirror/legacy-modes/mode/lua';
import { perl } from '@codemirror/legacy-modes/mode/perl';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { marked } from 'marked';
import { Marked } from 'marked';
import DOMPurify from 'dompurify';
const MODES = {
@@ -121,20 +126,58 @@ const theme = EditorView.theme({
'.cm-deletedLineGutter': { backgroundColor: 'var(--accent-red)' },
});
function baseExtensions(path, { readOnly = true, wrap = false } = {}) {
function baseExtensions(path, { readOnly = true, wrap = false, onCursor } = {}) {
return [
lineNumbers(), foldGutter(), highlightSpecialChars(), highlightActiveLine(), highlightActiveLineGutter(),
drawSelection(), history(), search({ top: true }), highlightSelectionMatches(), bracketMatching(),
drawSelection(), dropCursor(), history(), search({ top: true }), highlightSelectionMatches(), bracketMatching(),
// VS Code habits (devdesk#55): Alt+click adds a cursor, Alt+drag selects a
// column, brackets and quotes close themselves, Ctrl+G goes to a line.
EditorState.allowMultipleSelections.of(true), rectangularSelection(), crosshairCursor(),
readOnly ? [] : [closeBrackets(), indentOnInput()],
syntaxHighlighting(highlight), theme, languageExtension(languageFor(path)),
onCursor ? EditorView.updateListener.of((update) => {
if (update.selectionSet || update.docChanged || update.focusChanged) onCursor(cursorInfo(update.state));
}) : [],
// Read-only document in an editable view: the caret, selection, arrow keys
// and Ctrl+F work, typing doesn't; no on-screen keyboard on phones.
EditorState.readOnly.of(readOnly),
readOnly ? EditorView.contentAttributes.of({ inputmode: 'none' }) : [],
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap]),
keymap.of([
{ key: 'Mod-g', run: gotoLineDialog, preventDefault: true },
...(readOnly ? [] : closeBracketsKeymap), ...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap,
]),
wrap ? EditorView.lineWrapping : [],
];
}
// Where the cursor is, for the status bar: line, column, selections, selected
// characters; plus the document's line endings and indentation.
function cursorInfo(state) {
const main = state.selection.main;
const line = state.doc.lineAt(main.head);
let selected = 0;
for (const r of state.selection.ranges) selected += r.to - r.from;
return { line: line.number, col: main.head - line.from + 1, cursors: state.selection.ranges.length, selected, lines: state.doc.lines };
}
// Indentation a file uses, guessed from its first indented lines: "Tabs" or "Spaces: N".
export function detectIndent(text) {
let tabs = 0;
const widths = {};
for (const line of text.split('\n', 2000)) {
if (line.startsWith('\t')) tabs += 1;
else {
const m = /^( +)\S/.exec(line);
if (m) widths[m[1].length] = (widths[m[1].length] ?? 0) + 1;
}
}
const spaced = Object.values(widths).reduce((a, b) => a + b, 0);
if (tabs > spaced) return 'Tabs';
if (!spaced) return null;
const unit = [2, 4, 8, 3].find((w) => Object.keys(widths).every((k) => Number(k) % w === 0)) ?? 4;
return `Spaces: ${unit}`;
}
// 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)));
@@ -143,21 +186,22 @@ function gotoLine(view, line) {
}
// Read-only view of a file.
export function viewer(parent, { doc, path, wrap }) {
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
export function viewer(parent, { doc, path, wrap, onCursor }) {
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap, onCursor }) }) });
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 }) {
export function editor(parent, { doc, path, wrap, onChange, onSave, onCursor, onScroll }) {
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 }),
baseExtensions(path, { readOnly: false, wrap, onCursor }),
onScroll ? EditorView.domEventHandlers({ scroll: (event, v) => { onScroll(v.scrollDOM); } }) : [],
EditorView.updateListener.of((update) => {
if (update.docChanged) onChange?.(update.state.doc.toString());
}),
@@ -167,6 +211,7 @@ export function editor(parent, { doc, path, wrap, onChange, onSave }) {
return {
view,
goto: (line) => gotoLine(view, line),
info: () => cursorInfo(view.state),
text: () => view.state.doc.toString(),
// Replace the whole document (reload from disk) keeping the cursor roughly where it was.
setText: (text) => {
@@ -189,7 +234,103 @@ export function diff(parent, { original, modified, path, wrap }) {
return { view, destroy: () => view.destroy() };
}
// Markdown to sanitized HTML (shown in a sandboxed iframe on top of this).
export function markdown(text) {
return DOMPurify.sanitize(marked.parse(text, { gfm: true, breaks: false }), { USE_PROFILES: { html: true } });
// ---------------------------------------------------------------- markdown (devdesk#55)
// Fence names and aliases -> a language for highlighting.
const FENCE = {
sh: 'shell', bash: 'shell', zsh: 'shell', shell: 'shell', console: 'shell', shellsession: 'shell', env: 'shell', dotenv: 'shell',
py: 'python', python: 'python', js: 'javascript', javascript: 'javascript', jsx: 'javascript', mjs: 'javascript', node: 'javascript',
ts: 'typescript', typescript: 'typescript', tsx: 'typescript', json: 'json', jsonc: 'json', json5: 'json',
yml: 'yaml', yaml: 'yaml', toml: 'toml', ini: 'properties', conf: 'properties', cfg: 'properties', properties: 'properties', systemd: 'properties',
nginx: 'nginx', diff: 'diff', patch: 'diff', css: 'css', scss: 'css', html: 'html', xml: 'xml', svg: 'xml',
go: 'go', golang: 'go', rust: 'rust', rs: 'rust', sql: 'sql', dockerfile: 'dockerfile', docker: 'dockerfile',
lua: 'lua', perl: 'perl', pl: 'perl', ruby: 'ruby', rb: 'ruby', md: 'markdown', markdown: 'markdown',
};
const parsers = new Map();
function parserFor(lang) {
const name = FENCE[(lang || '').toLowerCase()];
if (!name) return null;
if (!parsers.has(name)) parsers.set(name, name === 'markdown' ? markdownLanguage.parser : StreamLanguage.define(MODES[name]).parser);
return parsers.get(name);
}
const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
// Code as HTML with tok-* classes (CodeMirror's own parsers, no extra library).
export function highlightToHtml(code, lang) {
const parser = parserFor(lang);
if (!parser || code.length > 200000) return escapeHtml(code);
let out = '';
let pos = 0;
try {
highlightTree(parser.parse(code), classHighlighter, (from, to, classes) => {
if (from > pos) out += escapeHtml(code.slice(pos, from));
out += `<span class="${classes}">${escapeHtml(code.slice(from, to))}</span>`;
pos = to;
});
} catch {
return escapeHtml(code);
}
return out + escapeHtml(code.slice(pos));
}
// GitHub's heading ids: lowercase, punctuation dropped, spaces to dashes, and
// "-1", "-2" for repeats.
function slugger() {
const seen = new Map();
return (text) => {
const base = text.toLowerCase().trim().replace(/<[^>]*>/g, '').replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-') || 'section';
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
return n ? `${base}-${n}` : base;
};
}
// Leading YAML front matter (--- … ---) as simple key/value pairs.
function frontMatter(text) {
const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
if (!m) return { body: text, meta: null };
const meta = [];
for (const line of m[1].split(/\r?\n/)) {
const kv = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(line);
if (kv) meta.push([kv[1], kv[2].replace(/^["']|["']$/g, '')]);
else if (meta.length && /^\s+\S/.test(line)) meta[meta.length - 1][1] += ` ${line.trim()}`;
}
return { body: text.slice(m[0].length), meta };
}
// Markdown to sanitized HTML for the preview (devdesk#55): GitHub-flavoured,
// heading ids, highlighted code blocks; returns { html, headings, meta }. No
// scripts, styles, forms, frames or inline style survive DOMPurify.
export function renderMarkdown(text) {
const { body, meta } = frontMatter(text);
const slug = slugger();
const headings = [];
const md = new Marked({
gfm: true,
breaks: false,
renderer: {
heading({ tokens, depth, text: raw }) {
const inner = this.parser.parseInline(tokens);
const id = slug(raw);
headings.push({ depth, id, text: inner.replace(/<[^>]*>/g, '') });
return `<h${depth} id="${id}"><a class="dd-anchor" href="#${id}" aria-hidden="true">#</a>${inner}</h${depth}>\n`;
},
code({ text: code, lang }) {
const name = (lang || '').split(/\s+/)[0];
return `<pre class="dd-code" data-lang="${escapeHtml(name)}"><code>${highlightToHtml(code.replace(/\n$/, ''), name)}</code></pre>\n`;
},
},
});
const html = DOMPurify.sanitize(md.parse(body), {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'form', 'iframe', 'object', 'embed', 'base', 'meta', 'link', 'textarea', 'select', 'button'],
FORBID_ATTR: ['style'],
});
return { html, headings, meta };
}
// Kept for older callers: just the HTML.
export function markdown(text) {
return renderMarkdown(text).html;
}
+11 -11
View File
@@ -53,16 +53,7 @@
<div class="dd-more">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-more-toggle" id="more-toggle" aria-haspopup="menu" aria-expanded="false" title="Settings" aria-label="Settings">⋯</button>
<div class="dd-menu dd-more-menu" id="more-menu" role="menu" hidden>
<div class="dd-menu-row">
<span>Text size</span>
<span class="dd-menu-inline">
<button type="button" class="dd-icon-btn" id="font-down" aria-label="Smaller text">A−</button>
<span id="font-size">14</span>
<button type="button" class="dd-icon-btn" id="font-up" aria-label="Larger text">A+</button>
</span>
</div>
<button type="button" class="dd-menu-item" id="theme-menu" role="menuitem">Light mode</button>
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
<button type="button" class="dd-menu-item" id="settings-menu" role="menuitem">Settings…</button>
<div class="dd-menu-row dd-usage-row" id="usage-row" hidden><span>Plan usage</span><span class="dd-usage" id="usage-menu"></span></div>
<button type="button" class="dd-menu-item" id="history-menu" role="menuitem">Search past conversations</button>
<button type="button" class="dd-menu-item" id="palette-menu" role="menuitem">Command palette</button>
@@ -109,8 +100,17 @@
<div class="dd-explorer-views" role="tablist">
<button type="button" role="tab" data-files-view="explorer" aria-selected="true">Explorer</button>
<button type="button" role="tab" data-files-view="changes" aria-selected="false">Changes <span class="dd-files-count"></span></button>
<button type="button" role="tab" data-files-view="search" aria-selected="false" title="Search in files (Ctrl+Shift+F)">Search</button>
<span class="dd-explorer-actions">
<button type="button" class="dd-icon-btn" data-explorer-act="new-file" title="New file" aria-label="New file">🗋</button>
<button type="button" class="dd-icon-btn" data-explorer-act="new-folder" title="New folder" aria-label="New folder">🗀</button>
<button type="button" class="dd-icon-btn" data-explorer-act="upload" title="Upload files (or drop them on a folder)" aria-label="Upload files">⇪</button>
<button type="button" class="dd-icon-btn" data-explorer-act="collapse" title="Collapse all folders" aria-label="Collapse all folders">⊟</button>
</span>
</div>
<div class="dd-explorer-body" role="tree" aria-label="Files"></div>
<div class="dd-explorer-body" role="tree" aria-label="Files" aria-multiselectable="true"></div>
<div class="dd-upload-bar" hidden><span class="dd-upload-text"></span><span class="dd-upload-meter"><span class="dd-upload-fill"></span></span></div>
<input type="file" class="dd-upload-input" multiple hidden>
</div>
<div class="dd-editor-area"></div>
</div>
+26 -1
View File
@@ -246,6 +246,24 @@ export function writeFile(session, path, bytes, base) {
return limited(() => streamIn(['--fs-write', session, path, String(bytes.length), base], 'DEVDESK-WRITE-READY', new Blob([bytes])));
}
// Explorer uploads (devdesk#54): any file (up to 25 MB) to PATH in the session's
// folder, folders created as needed. mode "new" refuses to replace; "replace"
// overwrites. Resolves {ok, path, size}; rejects "already exists: …" etc.
export function putFile(session, path, file, mode = 'new', onProgress) {
if (file.size > UPLOAD_MAX) return Promise.reject(new Error('larger than 25 MB'));
return limited(() => streamIn(['--fs-put', session, path, String(file.size), mode], 'DEVDESK-PUT-READY', file, onProgress));
}
// A file of the session as a Blob, for the browser to save (up to 25 MB).
export async function downloadFile(session, path) {
const { header, base64 } = await runBlob(['--fs-download', session, path], 180000);
if (base64 === null || base64 === undefined) throw new Error('no content');
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
return { name: (header.path || path).split('/').pop(), blob: 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).
@@ -379,7 +397,14 @@ export async function fetchRepo(session) {
// rejects with devdesk's own error message.
export async function sessionAction(action, ...args) {
const output = stripAnsi(await runCommand([`--${action}`, ...args])).trim();
if (/"ok"\s*:\s*true/.test(output)) return;
// Success: the reply's JSON ({ok: true, …}) for callers that need its fields.
if (/"ok"\s*:\s*true/.test(output)) {
try {
return parseListOutput(output);
} catch {
return { ok: true };
}
}
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
throw new Error(message.replace(/^devdesk:\s*/, ''));
}
+1319 -138
View File
File diff suppressed because it is too large Load Diff
+23 -3
View File
@@ -6,7 +6,7 @@ import { runCommand, sessionAction } from './connection.js';
import { parseListOutput, slugify } from './protocol.js';
import { el } from './dom.js';
import { ago } from './sessions.js';
import { openDialog, toast } from './ui.js';
import { confirmDialog, openDialog, toast } from './ui.js';
const ROLE = { you: 'You', claude: 'Claude', command: 'Ran', output: 'Output' };
@@ -82,9 +82,28 @@ export function openHistory(hooks) {
el('button', {
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm',
onclick: () => navigator.clipboard?.writeText(`claude --resume ${r.id}`).then(() => toast.success('Copied the resume command')),
}, 'Copy command'));
}, 'Copy command'),
live ? null : el('button', {
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm dd-danger-text', title: 'Delete this conversation from this machine for good',
onclick: async () => {
const ok = await confirmDialog({
title: 'Delete this conversation?',
text: `“${r.title.slice(0, 80)}” (${day(r.started)}) is removed from this machine for good: its transcript and subagent files in ~/.claude/projects. It can't be resumed or found again. If its session had Remote Control on, a copy is also at claude.ai/code: delete it there too.`,
confirmLabel: 'Delete for good',
danger: true,
});
if (!ok) return;
try {
await sessionAction('delete-convo', r.id);
item.remove();
toast.success('Conversation deleted from this machine', 8000, [{ label: 'claude.ai/code', run: () => window.open('https://claude.ai/code', '_blank', 'noopener') }]);
} catch (error) {
toast.error(error.message);
}
},
}, 'Delete…'));
fill();
return el('div', { class: 'dd-history-item' },
const item = el('div', { class: 'dd-history-item' },
el('div', { class: 'dd-history-title' }, r.title),
el('div', { class: 'dd-history-meta' },
[r.cwd, day(r.started), `updated ${ago(r.updated)}${ago(r.updated) === 'now' ? '' : ' ago'}`, `${r.hits} match${r.hits === 1 ? '' : 'es'}`, live ? `open in ${live}` : '']
@@ -92,6 +111,7 @@ export function openHistory(hooks) {
...r.matches.map((m) => el('div', { class: `dd-history-snippet is-${m.role}` },
el('span', { class: 'dd-history-role' }, ROLE[m.role] ?? m.role), el('span', {}, ...highlight(m.snippet, data.query)))),
actions);
return item;
}));
};
+79 -31
View File
@@ -1,6 +1,6 @@
/* global __DEVDESK_VERSION__ */
import { THEMES, createTerminal, loadFonts } from './term.js';
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
import { getTheme, initTheme, onThemeChange, setTheme, toggleTheme } from './theme.js';
import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, investigateAlert, lastReply, replyTo, sessionAction, uploadFile,
AuthExpiredError, UnauthorizedError,
@@ -16,6 +16,7 @@ import { Inbox } from './inbox.js';
import { FilesPane } from './files.js';
import { registerPathLinks } from './links.js';
import { openHistory } from './history.js';
import { applyUiScale, openSettings } from './settings.js';
import { el } from './dom.js';
import * as notify from './notify.js';
@@ -55,7 +56,6 @@ const ui = {
overlayActions: $('overlay-actions'),
moreToggle: $('more-toggle'),
moreMenu: $('more-menu'),
notifyButton: $('notify-toggle'),
sidebarButton: $('sidebar-toggle'),
backdrop: $('backdrop'),
};
@@ -267,14 +267,29 @@ function wireUploads() {
$('drop-session').textContent = state.session || 'a session';
overlay.hidden = false;
});
// Files dragged from the explorer: @path mentions for Claude (devdesk#54).
const fromExplorer = (event) => [...(event.dataTransfer?.types ?? [])].includes('application/x-devdesk-paths');
frame.addEventListener('dragover', (event) => {
if (hasFiles(event)) event.preventDefault();
else if (fromExplorer(event)) {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
}
});
frame.addEventListener('dragleave', () => {
depth = Math.max(0, depth - 1);
if (!depth) overlay.hidden = true;
});
frame.addEventListener('drop', (event) => {
if (!hasFiles(event) && fromExplorer(event)) {
event.preventDefault();
const text = event.dataTransfer.getData('text/plain');
if (text && state.conn?.isOpen) {
term.paste(text);
term.focus();
}
return;
}
if (!hasFiles(event)) return;
event.preventDefault();
depth = 0;
@@ -284,7 +299,8 @@ function wireUploads() {
// Pasting a screenshot / copied file: upload it instead of pasting text.
document.addEventListener('paste', (event) => {
const files = [...(event.clipboardData?.files ?? [])];
if (!files.length) return;
// In the Files pane, pasted files go into the explorer's folder (devdesk#54).
if (!files.length || event.target.closest?.('#files')) return;
event.preventDefault();
event.stopPropagation();
attachFiles(files);
@@ -594,10 +610,41 @@ function setFontSize(size) {
term.options.fontSize = clamped;
store.set('fontSize', clamped);
document.documentElement.style.setProperty('--dd-editor-font', `${clamped - 1}px`);
$('font-size').textContent = String(clamped);
fit.fit();
}
// Settings (devdesk#61): appearance, notifications, Remote Control per session.
function showSettings() {
closeMenus();
openSettings({
getTheme,
setTheme,
fontSize: () => term.options.fontSize,
setFontSize,
uiScale: () => Number(store.get('uiScale', 1)) || 1,
setUiScale: (scale) => {
store.set('uiScale', scale);
applyUiScale(scale);
requestAnimationFrame(() => fit.fit());
},
notifications: () => {
const current = notify.notificationsState();
return {
label: {
unsupported: 'Not supported', denied: 'Blocked by the browser', default: 'Turn on',
granted: notify.isMuted() ? 'Muted: unmute' : 'On: mute',
}[current],
disabled: current === 'unsupported' || current === 'denied',
run: toggleNotifications,
};
},
sessions: () => sessionsPanel?.data,
current: () => state.session,
refresh: () => sessionsPanel.reload(),
openHistory: showHistory,
});
}
async function toggleNotifications() {
const current = notify.notificationsState();
if (current === 'default') {
@@ -750,6 +797,16 @@ async function compact(session) {
return undefined;
}
// A stray split (devdesk#52): close every extra pane, keeping Claude's.
async function unsplit(session) {
try {
const result = await sessionAction('unsplit', session);
toast.success(result?.closed ? `Closed ${result.closed} pane${result.closed === 1 ? '' : 's'}` : 'No split panes');
} catch (error) {
toast.error(error.message);
}
}
async function copyLink() {
const url = new URL(window.location.href);
url.search = state.session ? `?arg=${encodeURIComponent(state.session)}` : '';
@@ -801,11 +858,13 @@ function buildCommands() {
add({ id: 'win-shell', group: 'Terminal', icon: '1', label: 'Shell window', kbd: '^b 1', run: () => sendKeys('\x021') });
add({ id: 'scroll', group: 'Terminal', icon: '⇞', label: 'Scroll back (tmux copy mode, q to leave)', kbd: '^b [', keywords: 'history scrollback', run: () => sendKeys('\x02[') });
add({ id: 'detach', group: 'Terminal', icon: '⏏', label: 'Detach (the session keeps running)', kbd: '^b d', run: () => sendKeys('\x02d') });
add({ id: 'unsplit', group: 'Terminal', icon: '▣', label: 'Close split panes (keeps Claude)', keywords: 'pane split remove side by side layout', run: () => unsplit(state.session) });
}
if (state.session) {
add({ id: 'files', group: 'Files', icon: '🗀', label: files.isOpen ? 'Close files' : 'Browse files', kbd: 'Ctrl+⇧E', keywords: 'explorer folder read open view', run: () => files.toggle('files') });
add({ id: 'changes', group: 'Files', icon: '±', label: 'Review changes (git diff)', keywords: 'git status diff claude edits', run: () => files.open('changes') });
add({ id: 'search-files', group: 'Files', icon: '⌕', label: 'Search in files', kbd: 'Ctrl+⇧F', keywords: 'grep find text content', run: () => files.openSearch() });
add({
id: 'goto-file', group: 'Files', icon: '⌕', label: 'Go to file…', kbd: 'Ctrl+P in Files', keywords: 'open find quick search',
run: () => {
@@ -822,12 +881,13 @@ function buildCommands() {
add({ id: 'font-up', group: 'View', icon: 'A+', label: 'Larger text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize + 1) });
add({ id: 'font-down', group: 'View', icon: 'A−', label: 'Smaller text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize - 1) });
add({ id: 'font-reset', group: 'View', icon: 'A', label: 'Reset text size', keywords: 'font zoom', run: () => setFontSize(14) });
add({ id: 'settings', group: 'View', icon: '⚙', label: 'Settings…', keywords: 'preferences text size font theme remote control privacy notifications', run: showSettings });
add({ id: 'fullscreen', group: 'View', icon: '⛶', label: document.fullscreenElement ? 'Exit fullscreen' : 'Fullscreen', run: toggleFullscreen });
if (notifyState !== 'unsupported') {
add({
id: 'notify', group: 'View', icon: '🔔', keywords: 'notifications alerts bell',
label: { default: 'Enable desktop notifications', denied: 'Desktop notifications (blocked)', granted: notify.isMuted() ? 'Unmute desktop notifications' : 'Mute desktop notifications' }[notifyState],
run: () => toggleNotifications().then(renderNotifyButton),
run: () => toggleNotifications(),
});
}
@@ -879,6 +939,7 @@ const KEY_HELP = [
['Drag', 'Select text; it is copied when you let go'],
['Ctrl+C', 'Copy the selection (interrupts only when nothing is selected)'],
['Ctrl+⇧ C / V', 'Copy selection / paste'],
['Ctrl+⇧ F', 'Search in the session\'s files'],
['⇧ drag', 'Select text in the browser'],
['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)'],
@@ -939,17 +1000,6 @@ async function toggleFullscreen() {
navigator.keyboard?.lock?.().catch(() => {});
}
function renderNotifyButton() {
const current = notify.notificationsState();
const labels = {
unsupported: 'Desktop notifications: unsupported',
denied: 'Desktop notifications: blocked by the browser',
default: 'Enable desktop notifications',
granted: notify.isMuted() ? 'Desktop notifications: muted (unmute)' : 'Desktop notifications: on (mute)',
};
ui.notifyButton.textContent = labels[current];
ui.notifyButton.disabled = current === 'unsupported' || current === 'denied';
}
function setSidebar(open) {
if (open && files?.isOpen) files.close();
@@ -999,12 +1049,7 @@ function wireKeybar() {
}
function wireChrome() {
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
ui.notifyButton.addEventListener('click', async () => {
await toggleNotifications();
renderNotifyButton();
});
$('settings-menu').addEventListener('click', showSettings);
$('fullscreen-toggle').addEventListener('click', () => {
closeMenus();
toggleFullscreen();
@@ -1052,6 +1097,13 @@ function wireChrome() {
if (state.session) files.toggle();
return;
}
// Ctrl+Shift+F: search in the session's files (devdesk#56).
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyF') {
event.preventDefault();
event.stopPropagation();
if (state.session) files.openSearch();
return;
}
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') {
event.preventDefault();
event.stopPropagation();
@@ -1059,14 +1111,7 @@ function wireChrome() {
else if (!dialogOpen()) palette.open();
}
}, true);
wireMenu(ui.moreToggle, ui.moreMenu, () => {
renderNotifyButton();
$('theme-menu').textContent = getTheme() === 'light' ? 'Dark mode' : 'Light mode';
});
$('theme-menu').addEventListener('click', () => {
closeMenus();
toggleTheme();
});
wireMenu(ui.moreToggle, ui.moreMenu, () => {});
inbox = new Inbox({
button: $('inbox-toggle'),
badge: $('inbox-badge'),
@@ -1104,6 +1149,9 @@ function wireChrome() {
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || dialogOpen()) return;
// A right-click menu, the explorer's name box, or an explorer selection /
// cut to clear take their own Esc first (devdesk#54).
if (event.target.closest?.('.dd-ctx-menu, .dd-tree-input') || files?.wantsEscape(event)) return;
// Files pane: Esc goes back / closes, unless the terminal or CodeMirror's
// own search panel has the focus.
if (files?.isOpen && !event.target.closest?.('.xterm, .cm-panels') && !menus.some(({ menu }) => !menu.hidden) && !inbox.isOpen) {
@@ -1194,7 +1242,7 @@ async function boot() {
initTheme();
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
$('font-size').textContent = String(fontSize);
applyUiScale(Number(store.get('uiScale', 1)) || 1);
await loadFonts(fontSize);
({ term, fit } = createTerminal($('terminal'), {
+10
View File
@@ -321,6 +321,8 @@ export class SessionsPanel {
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: 'dd-session-name' }, s.name,
// Remote Control on: synced to claude.ai and the Claude app (devdesk#61).
s.rc_active ? el('span', { class: 'dd-tag dd-rc', title: 'Remote Control is on: this session syncs to claude.ai and the Claude app. Change it in Settings.' }, 'RC') : null,
s.worktree ? el('span', { class: 'dd-tag dd-wt', title: `Own worktree on ${s.worktree.branch}\n${s.worktree.path}` }, `⑂ ${s.worktree.branch.replace(/^devdesk\//, '')}`) : null,
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,
// The pull request of this branch (devdesk#25)
@@ -366,7 +368,15 @@ export class SessionsPanel {
}, '×'))));
}
if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
const scroll = this.list.scrollTop;
this.list.replaceChildren(...items);
// Keep the scroll position across polls; bring the current session into view
// when it changes.
this.list.scrollTop = scroll;
if (this.current !== this.shownCurrent) {
this.shownCurrent = this.current;
this.list.querySelector('.is-current')?.scrollIntoView({ block: 'nearest' });
}
this.projectOptions(this.select);
}
+134
View File
@@ -0,0 +1,134 @@
// Settings (devdesk#61): the things you set once and leave, in one window
// instead of the ⋯ menu. Appearance (theme, terminal text size, interface size)
// and notifications are per browser; Remote Control is per session, with a
// default for new sessions, kept on the server.
import { el } from './dom.js';
import { openDialog, toast } from './ui.js';
import { sessionAction } from './connection.js';
const SCALES = [['Small', 0.9], ['Normal', 1], ['Large', 1.12], ['Larger', 1.25]];
// Interface size: the page is sized in rem off the root font size (14px).
export function applyUiScale(scale) {
document.documentElement.style.fontSize = scale === 1 ? '' : `${Math.round(14 * scale * 10) / 10}px`;
}
function segmented(label, options, current, onPick) {
const buttons = options.map(([text, value]) => el('button', {
type: 'button', class: 'dd-seg-btn', 'aria-pressed': String(value === current),
onclick: (event) => {
for (const b of buttons) b.setAttribute('aria-pressed', 'false');
event.currentTarget.setAttribute('aria-pressed', 'true');
onPick(value);
},
}, text));
return el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, label), el('span', { class: 'dd-seg', role: 'group', 'aria-label': label }, ...buttons));
}
function toggle(label, checked, onChange, hint) {
const input = el('input', { type: 'checkbox', role: 'switch', checked: checked ? true : null });
input.addEventListener('change', () => onChange(input.checked, input));
return el('label', { class: 'dd-setting dd-switch-row' },
el('span', { class: 'dd-setting-label' }, label, hint ? el('span', { class: 'dd-setting-hint' }, hint) : null),
el('span', { class: 'dd-switch' }, input, el('span', { class: 'dd-switch-track', 'aria-hidden': 'true' })));
}
// hooks: getTheme(), setTheme(t), fontSize(), setFontSize(n), uiScale(), setUiScale(s),
// notifications() -> { label, disabled, run }, sessions() -> list data, current(),
// refresh(), openHistory()
export async function openSettings(hooks) {
const data = hooks.sessions();
const current = hooks.current();
const size = el('span', { class: 'dd-setting-value' }, String(hooks.fontSize()));
const setSize = (n) => {
hooks.setFontSize(n);
size.textContent = String(hooks.fontSize());
};
const notif = hooks.notifications();
const notifyButton = el('button', { type: 'button', class: 'lt-btn lt-btn-sm', disabled: notif.disabled ? true : null }, notif.label);
notifyButton.addEventListener('click', async () => {
await hooks.notifications().run();
const next = hooks.notifications();
notifyButton.textContent = next.label;
notifyButton.disabled = next.disabled;
});
// Remote Control, per session.
const running = (data?.sessions ?? []).filter((s) => s.running).sort((a, b) => (a.name === current ? -1 : b.name === current ? 1 : a.name.localeCompare(b.name)));
const rcRows = running.map((s) => toggle(s.name, s.remote_control, async (on, input) => {
input.disabled = true;
try {
const result = await sessionAction('remote-control', s.name, on ? 'on' : 'off');
const what = `Remote Control ${on ? 'on' : 'off'}`;
if (result?.applied === 'now') toast.success(`${s.name}: restarting Claude with ${what} (same conversation)`, 6000);
else if (result?.applied === 'next-start') toast.info(`${s.name} is busy: ${what} when Claude next starts`, 7000);
else toast.success(`${s.name}: ${what}`);
hooks.refresh();
} catch (error) {
input.checked = !on;
toast.error(`${s.name}: ${error.message}`);
} finally {
input.disabled = false;
}
}, s.rc_active ? 'syncing to claude.ai now' : s.remote_control ? 'on at the next Claude start' : ''));
const defaultRow = toggle('New sessions', Boolean(data?.remote_control_default), async (on, input) => {
input.disabled = true;
try {
await sessionAction('settings', 'remote-control-default', on ? 'on' : 'off');
toast.success(`New sessions: Remote Control ${on ? 'on' : 'off'}`);
hooks.refresh();
} catch (error) {
input.checked = !on;
toast.error(error.message);
} finally {
input.disabled = false;
}
}, 'sessions you create from now on');
const link = (href, text) => el('a', { href, target: '_blank', rel: 'noopener' }, text);
const body = el('div', { class: 'dd-settings' },
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Appearance'),
segmented('Theme', [['Dark', 'dark'], ['Light', 'light']], hooks.getTheme(), (t) => hooks.setTheme(t)),
el('div', { class: 'dd-setting' },
el('span', { class: 'dd-setting-label' }, 'Terminal text size'),
el('span', { class: 'dd-stepper' },
el('button', { type: 'button', class: 'dd-icon-btn', 'aria-label': 'Smaller terminal text', onclick: () => setSize(hooks.fontSize() - 1) }, 'A−'),
size,
el('button', { type: 'button', class: 'dd-icon-btn', 'aria-label': 'Larger terminal text', onclick: () => setSize(hooks.fontSize() + 1) }, 'A+'),
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: () => setSize(14) }, 'Reset'))),
segmented('Interface size', SCALES, hooks.uiScale(), (s) => hooks.setUiScale(s)),
el('p', { class: 'dd-setting-note' }, 'Appearance is remembered in this browser.')),
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Notifications'),
el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, 'Desktop notifications', el('span', { class: 'dd-setting-hint' }, 'when another session needs you')), notifyButton)),
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Remote Control & privacy'),
el('p', { class: 'dd-setting-note' },
'With Remote Control on, a session also appears in the Claude app and at claude.ai/code, and Anthropic stores its transcript to keep your devices in sync (30 days, or 5 years if "Help improve Claude" is on in ',
link('https://claude.ai/settings/data-privacy-controls', 'your privacy settings'), '). Turning it off stops syncing from then on; what was already synced stays until you delete it at ',
link('https://claude.ai/code', 'claude.ai/code'), '. Switching a running session restarts its Claude on the same conversation.'),
defaultRow,
running.length ? el('div', { class: 'dd-setting-sub' }, 'Running sessions') : null,
...rcRows),
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Conversations'),
el('p', { class: 'dd-setting-note' }, 'Every conversation is also kept on this machine in ~/.claude/projects (plaintext, 30 days). Delete one for good from Search past conversations.'),
el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, 'Find or delete a conversation'),
el('button', { type: 'button', class: 'lt-btn lt-btn-sm', 'data-close': 'history' }, 'Search past conversations…'))));
const choice = await openDialog({
title: 'Settings',
className: 'dd-settings-dialog',
body,
onOpen: () => {
body.querySelector('[data-close="history"]').addEventListener('click', () => {
body.closest('.lt-modal-overlay, .lt-modal')?.querySelector('[aria-label="Close"], .lt-modal-close')?.click();
setTimeout(() => hooks.openHistory(), 50);
});
return body.querySelector('button, input');
},
actions: [{ label: 'Done', primary: true }],
});
return choice;
}
+86 -5
View File
@@ -24,13 +24,21 @@ function dismiss(node) {
setTimeout(() => node.remove(), 250);
}
export function toast(message, type = 'info', duration = 4000) {
// `actions`: [{ label, run }] buttons (e.g. Undo); clicking one dismisses the toast.
export function toast(message, type = 'info', duration = 4000, actions = []) {
const box = toastContainer();
// At most four on screen: the oldest goes first.
while (box.children.length >= 4) box.firstElementChild.remove();
const node = el('div', { class: `lt-toast lt-toast-${type}`, role: type === 'error' ? 'alert' : 'status' },
el('span', { class: 'lt-toast-icon', 'aria-hidden': 'true' }, `[${ICONS[type] || 'i'}]`),
el('span', { class: 'lt-toast-msg' }, message),
...actions.map(({ label, run }) => el('button', {
type: 'button', class: 'lt-toast-action',
onclick: () => {
dismiss(node);
run();
},
}, label)),
el('button', { type: 'button', class: 'lt-toast-close', 'aria-label': 'Dismiss', onclick: () => dismiss(node) }, '✕'),
el('div', { class: 'lt-toast-progress', style: `animation-duration:${duration}ms` }));
box.append(node);
@@ -39,10 +47,83 @@ export function toast(message, type = 'info', duration = 4000) {
node.addEventListener('mouseleave', () => { node.dismissTimer = setTimeout(() => dismiss(node), 1500); });
}
toast.success = (message, duration) => toast(message, 'success', duration);
toast.error = (message, duration = 8000) => toast(message, 'error', duration);
toast.warning = (message, duration) => toast(message, 'warning', duration);
toast.info = (message, duration) => toast(message, 'info', duration);
toast.success = (message, duration, actions) => toast(message, 'success', duration, actions);
toast.error = (message, duration = 8000, actions) => toast(message, 'error', duration, actions);
toast.warning = (message, duration, actions) => toast(message, 'warning', duration, actions);
toast.info = (message, duration, actions) => toast(message, 'info', duration, actions);
// ---------------------------------------------------------------- context menu
// A right-click menu at (x, y) (devdesk#54). items: { label, kbd, run, disabled,
// danger } or 'sep'. Arrow keys move, Enter runs, Esc / a click elsewhere /
// scrolling / resizing closes it. Returns the close function.
let closeOpenMenu = null;
export function contextMenu(x, y, items) {
closeOpenMenu?.();
const opener = document.activeElement;
const buttons = [];
const menu = el('div', { class: 'dd-menu dd-ctx-menu', role: 'menu' }, ...items.filter(Boolean).map((item) => {
if (item === 'sep') return el('div', { class: 'dd-menu-sep', role: 'separator' });
const button = el('button', {
type: 'button', role: 'menuitem', class: `dd-menu-item${item.danger ? ' is-danger' : ''}`, disabled: item.disabled ? true : null,
onclick: () => {
close(false);
item.run();
},
}, el('span', { class: 'dd-ctx-label' }, item.label), item.kbd ? el('kbd', { class: 'dd-ctx-kbd' }, item.kbd) : null);
if (!item.disabled) buttons.push(button);
return button;
}));
const close = (refocus = true) => {
if (!menu.isConnected) return;
menu.remove();
closeOpenMenu = null;
document.removeEventListener('pointerdown', outside, true);
window.removeEventListener('blur', onBlur);
window.removeEventListener('resize', onBlur);
document.removeEventListener('scroll', onBlur, true);
if (refocus && opener?.isConnected) opener.focus?.();
};
const outside = (event) => {
if (!menu.contains(event.target)) close(false);
};
const opened = Date.now();
const onBlur = (event) => {
// Scrolling closes it, but not the layout settling right after it opened.
if (event?.type === 'scroll' && (menu.contains(event.target) || Date.now() - opened < 400)) return;
close(false);
};
menu.addEventListener('keydown', (event) => {
const i = buttons.indexOf(document.activeElement);
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const next = buttons[(i + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length];
next?.focus();
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus();
} else if (event.key === 'Escape' || event.key === 'Tab') {
event.preventDefault();
event.stopPropagation();
close();
}
});
menu.addEventListener('contextmenu', (event) => event.preventDefault());
document.body.append(menu);
// Keep it on screen.
const { width, height } = menu.getBoundingClientRect();
menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - width - 4))}px`;
menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - height - 4))}px`;
buttons[0]?.focus();
setTimeout(() => {
document.addEventListener('pointerdown', outside, true);
window.addEventListener('blur', onBlur);
window.addEventListener('resize', onBlur);
document.addEventListener('scroll', onBlur, true);
}, 0);
closeOpenMenu = close;
return close;
}
// ---------------------------------------------------------------- dialogs
+112 -2
View File
@@ -239,6 +239,13 @@ body.dd-sidebar-open .dd-sidebar {
flex-direction: column;
gap: 2px;
margin-bottom: var(--space-sm);
/* Any number of sessions: the list scrolls on its own so the panels below stay
in reach (devdesk#53). */
max-height: 46vh;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--bg-tertiary) transparent;
}
.dd-session-item {
@@ -792,7 +799,7 @@ body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transpar
.dd-explorer-resizer { flex: 0 0 4px; margin-left: -2px; margin-right: -2px; cursor: col-resize; z-index: 2; }
.dd-explorer-resizer:hover { background: var(--accent-cyan); }
.dd-explorer-views { display: flex; border-bottom: 1px solid var(--border-dim); }
.dd-explorer-views button {
.dd-explorer-views button[role="tab"] {
flex: 1;
padding: 6px 4px 5px;
border: 0;
@@ -805,7 +812,7 @@ body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transpar
text-transform: uppercase;
cursor: pointer;
}
.dd-explorer-views button:hover { color: var(--text-primary); }
.dd-explorer-views button[role="tab"]:hover { color: var(--text-primary); }
.dd-explorer-views button[aria-selected="true"] { color: var(--accent-orange); border-bottom-color: var(--accent-orange); }
.dd-files-count:not(:empty) { margin-left: 3px; padding: 0 5px; border-radius: 8px; background: var(--accent-amber-dim); color: var(--accent-amber); font-size: 0.6rem; }
.dd-explorer-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 0 12px; outline: none; }
@@ -1063,3 +1070,106 @@ body.dd-touch .dd-footer-usage { display: none; }
.dd-repo-change-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-repo-change-stat { flex: none; color: var(--text-muted); }
.dd-link-btn { padding: 0; border: 0; background: none; color: var(--accent-cyan); font: inherit; text-decoration: underline dotted; cursor: pointer; }
/* Toast actions (Undo) and right-click menus (devdesk#54) */
.lt-toast-action { flex: none; padding: 2px 8px; border: 1px solid currentColor; background: none; color: inherit; font: inherit; font-size: 0.72rem; cursor: pointer; }
.lt-toast-action:hover { background: var(--accent-cyan-dim); }
.dd-ctx-menu { position: fixed; top: 0; left: 0; z-index: 200; min-width: 220px; max-height: 80vh; }
.dd-ctx-menu .dd-menu-item { justify-content: space-between; padding: 5px 10px; }
.dd-ctx-menu .dd-menu-item.is-danger { color: var(--accent-red); }
.dd-ctx-kbd { margin-left: 24px; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.66rem; }
.dd-menu-sep { height: 1px; margin: 4px 6px; background: var(--border-dim); }
/* Explorer operations (devdesk#54) */
.dd-explorer-actions { display: flex; align-items: center; gap: 1px; padding: 0 4px; border-left: 1px solid var(--border-dim); }
.dd-explorer-actions .dd-icon-btn { width: 24px; height: 24px; padding: 0; font-size: 0.82rem; line-height: 1; }
.dd-files:not([data-view="explorer"]) .dd-explorer-actions { display: none; }
.dd-tree-row.is-selected { background: var(--accent-cyan-dim); }
.dd-tree-row.is-selected.is-active { box-shadow: inset 2px 0 0 var(--accent-cyan); }
.dd-explorer-body:focus-within .dd-tree-row.is-selected { background: rgba(0, 150, 220, 0.22); }
.dd-tree-row.is-cut { opacity: 0.45; }
.dd-tree-row.is-drop { background: var(--accent-orange-dim); }
.dd-tree-row.is-drop.is-dir { box-shadow: inset 0 0 0 1px var(--accent-orange); }
.dd-explorer-body.is-drop { background: var(--accent-orange-dim); box-shadow: inset 0 0 0 1px var(--accent-orange); }
.dd-tree-row.is-editing { cursor: default; background: var(--bg-tertiary); }
.dd-tree-input {
flex: 1;
min-width: 0;
height: 20px;
padding: 0 4px;
border: 1px solid var(--accent-cyan);
background: var(--bg-input, var(--bg-primary));
color: var(--text-primary);
font: inherit;
outline: none;
}
.dd-upload-bar { display: flex; flex-direction: column; gap: 4px; padding: 6px 10px; border-top: 1px solid var(--border-dim); font-size: 0.68rem; color: var(--text-secondary); }
.dd-upload-bar[hidden] { display: none; }
.dd-upload-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-upload-meter { height: 3px; background: var(--bg-tertiary); }
.dd-upload-fill { display: block; width: 0; height: 100%; background: var(--accent-cyan); transition: width 0.15s linear; }
.dd-drag-ghost { position: fixed; top: -100px; left: -100px; padding: 3px 10px; background: var(--accent-cyan); color: var(--bg-primary); font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700; }
/* Markdown preview and split, editor status bar (devdesk#55) */
.dd-viewer-content.is-preview { overflow: hidden; }
.dd-md-host { flex: 1; min-height: 0; outline: none; }
.dd-viewer-content.is-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.dd-split-editor { min-width: 0; height: 100%; overflow: hidden; border-right: 1px solid var(--border-dim); }
.dd-split-editor .cm-editor { height: 100%; }
.dd-split-preview { min-width: 0; }
.dd-editor-status { display: flex; align-items: center; gap: 14px; flex: none; height: 22px; padding: 0 10px; border-top: 1px solid var(--border-dim); background: var(--bg-secondary); color: var(--text-muted); font-family: var(--font-mono); font-size: 0.66rem; white-space: nowrap; overflow: hidden; }
.dd-status-pos { color: var(--text-secondary); }
.dd-status-sel { color: var(--accent-cyan); }
.dd-status-spacer { flex: 1; }
/* Search in files (devdesk#56) */
.dd-search { display: flex; flex-direction: column; min-height: 100%; }
.dd-search-box { display: flex; gap: 2px; padding: 6px 8px; border-bottom: 1px solid var(--border-dim); }
.dd-search-input { flex: 1; min-width: 0; height: 26px; padding: 0 6px; font-family: var(--font-mono); font-size: 0.74rem; }
.dd-search-flag { flex: none; width: 26px; height: 26px; padding: 0; border: 1px solid transparent; background: none; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.7rem; cursor: pointer; }
.dd-search-flag:hover { color: var(--text-primary); }
.dd-search-flag[aria-pressed="true"] { border-color: var(--accent-cyan); background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.dd-search-summary { padding: 4px 10px; color: var(--text-muted); font-size: 0.66rem; }
.dd-search-file { display: flex; align-items: center; gap: 6px; width: 100%; padding: 3px 8px; border: 0; background: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 0.74rem; text-align: left; cursor: pointer; }
.dd-search-file:hover { background: var(--bg-tertiary); }
.dd-search-file::before { content: '▾'; width: 10px; color: var(--text-muted); font-size: 0.66rem; }
.dd-search-file[aria-expanded="false"]::before { content: '▸'; }
.dd-search-file .dd-tree-name { flex: none; }
.dd-search-file .dd-change-dir { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-search-count { flex: none; padding: 0 6px; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.62rem; }
.dd-search-lines[hidden] { display: none; }
.dd-search-line { display: flex; gap: 8px; padding: 2px 8px 2px 30px; color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.72rem; white-space: nowrap; cursor: pointer; outline: none; }
.dd-search-line:hover, .dd-search-line:focus { background: var(--bg-tertiary); color: var(--text-primary); }
.dd-search-line:focus { box-shadow: inset 0 0 0 1px var(--accent-cyan); }
.dd-search-ln { flex: none; min-width: 2.5em; color: var(--text-muted); text-align: right; }
.dd-search-text { overflow: hidden; text-overflow: ellipsis; }
.dd-search-text mark { background: var(--accent-amber-dim); color: var(--accent-amber); outline: 1px solid color-mix(in srgb, var(--accent-amber) 50%, transparent); }
/* Settings (devdesk#61) */
.dd-settings-dialog .lt-modal { width: min(620px, calc(100vw - 24px)); max-height: calc(100vh - 40px); }
.dd-settings-dialog .dd-dialog-body { max-height: calc(100vh - 170px); overflow-y: auto; }
.dd-settings { display: flex; flex-direction: column; gap: 18px; }
.dd-settings-group h3 { margin: 0 0 8px; color: var(--accent-orange); font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.12em; text-transform: uppercase; }
.dd-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 34px; padding: 4px 0; border-bottom: 1px solid var(--border-dim); }
.dd-setting-label { display: flex; flex-direction: column; color: var(--text-primary); font-size: 0.8rem; }
.dd-setting-hint { color: var(--text-muted); font-size: 0.68rem; }
.dd-setting-value { min-width: 2ch; text-align: center; font-family: var(--font-mono); }
.dd-setting-note { margin: 4px 0 8px; color: var(--text-secondary); font-size: 0.72rem; line-height: 1.5; }
.dd-setting-note a { color: var(--accent-cyan); }
.dd-setting-sub { margin-top: 10px; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.64rem; letter-spacing: 0.08em; text-transform: uppercase; }
.dd-stepper { display: inline-flex; align-items: center; gap: 6px; }
.dd-seg { display: inline-flex; border: 1px solid var(--border-color); }
.dd-seg-btn { padding: 4px 10px; border: 0; border-right: 1px solid var(--border-color); background: none; color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.72rem; cursor: pointer; }
.dd-seg-btn:last-child { border-right: 0; }
.dd-seg-btn[aria-pressed="true"] { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.dd-switch-row { cursor: pointer; }
.dd-switch { position: relative; flex: none; width: 36px; height: 20px; }
.dd-switch input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
.dd-switch-track { position: absolute; inset: 0; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-tertiary); transition: background 0.15s; }
.dd-switch-track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--text-muted); transition: transform 0.15s, background 0.15s; }
.dd-switch input:checked + .dd-switch-track { background: var(--accent-cyan-dim); border-color: var(--accent-cyan); }
.dd-switch input:checked + .dd-switch-track::after { transform: translateX(16px); background: var(--accent-cyan); }
.dd-switch input:focus-visible + .dd-switch-track { outline: 1px dashed var(--accent-cyan); outline-offset: 2px; }
.dd-switch input:disabled + .dd-switch-track { opacity: 0.5; }
.dd-rc { color: var(--accent-purple); border-color: color-mix(in srgb, var(--accent-purple) 50%, transparent); }
.dd-danger-text { color: var(--accent-red) !important; }