Compare commits

...
5 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 927920c162 fix: old links never re-create ended sessions
build-deb / Lint, test, package (push) Successful in 1m56s
build-deb / Deploy to CT 126 (push) Successful in 24s
build-deb / Notify on failure (push) Skipped
Inbox entries, the switcher, palette switches, the attention chip and Matrix
links (?arg=NAME&existing=1) attach with --existing; an ended session gets an
explanation and an explicit "Start again". Inbox entries for ended sessions
can't resurrect them; entries expire after 7 days.

Closes #38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:57:52 -04:00
jaredandClaude Opus 5.5 dee1843986 fix: go to file in big non-git folders; linear reply handling
build-deb / Lint, test, package (push) Successful in 3m0s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- find/git producers piped into head tolerate SIGPIPE, so hitting a cap no
  longer fails the command (and lets ttyd truncate its reply).
- fs-find outside git skips build/cache folders and caps at 10000.
- runCommand checks completion on the stream's tail instead of re-decoding
  the whole reply on every frame.

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:50:50 -04:00
jaredandClaude Opus 5.5 e345bf4640 feat: files pane like VS Code: tree, tabs, go to file
build-deb / Lint, test, package (push) Successful in 2m28s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- Explorer tree with inline expand/collapse and keyboard navigation, git
  colours, change dots, dimmed gitignored entries (fs-list now flags them),
  file-type badges, resizable explorer.
- Editor tabs: preview tab semantics, instant switching with scroll kept,
  in-place reload when Claude changes an open file.
- Go to file: devdesk attach --fs-find (git ls-files or capped find) and a
  fuzzy-ranked palette (CommandPalette gained custom search/async lists).
- Per-session memory of folders and tabs; empty state with recents.

Closes #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:40:47 -04:00
jaredandClaude Opus 5.5 73f8525db2 feat: v0.7.0 files pane: browse, preview, diff, Claude's edits
build-deb / Lint, test, package (push) Successful in 2m38s
build-deb / Deploy to CT 126 (push) Successful in 5s
build-deb / Notify on failure (push) Skipped
- devdesk attach --fs-list/--fs-read/--git-status/--git-show/--asset
  (libexec/devdesk-files.sh), confined to the session folder.
- The state hook records files Claude edits (Edit/Write/MultiEdit/
  NotebookEdit) for the Changes tab's "C" badge; rename/end carry it.
- Files pane: folders, CodeMirror read-only view, sanitized markdown preview
  in a sandboxed iframe with inlined relative images, images, Changes tab with
  line counts and diff against HEAD, "→ Claude" @path, resizable split.
- The editor bundle ships as /usr/share/devdesk/assets/editor.js, is fetched
  over ttyd on first use, verified against the page's SHA-256 and cached in
  IndexedDB.
- Fix: the state hook's field split collapsed empty fields.

Closes #19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:16:37 -04:00
jaredandClaude Opus 5.5 390ce61f5b feat: inbox bell for waiting sessions, alerts and missed events
build-deb / Lint, test, package (push) Successful in 3m7s
build-deb / Deploy to CT 126 (push) Successful in 2s
build-deb / Notify on failure (push) Skipped
Closes #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:51:01 -04:00
21 changed files with 2716 additions and 65 deletions
+5 -2
View File
@@ -8,7 +8,7 @@ SHAREDIR := $(PREFIX)/share/devdesk
TTYD_ARCH ?= x86_64
SHELL_SCRIPTS := bin/devdesk $(wildcard libexec/devdesk-*) scripts/fetch-ttyd.sh debian/devdesk.postinst debian/devdesk.postrm
WEB_SOURCES := web/build.mjs web/index.template.html web/styles.css $(wildcard web/src/*.js) \
WEB_SOURCES := web/build.mjs web/index.template.html web/styles.css $(wildcard web/src/*.js) $(wildcard web/editor/*.js) \
vendor/web_template/base.css debian/changelog
.PHONY: all lint test install clean sync-template
@@ -26,7 +26,7 @@ build/ttyd: vendor/ttyd/VERSION vendor/ttyd/SHA256SUMS scripts/fetch-ttyd.sh
@touch $@
lint: node_modules/.package-lock.json
npx eslint --max-warnings 0 --ext .js,.mjs web/src web/build.mjs tests/protocol.test.mjs
npx eslint --max-warnings 0 --ext .js,.mjs web/src web/editor web/build.mjs tests/protocol.test.mjs
shellcheck -x $(SHELL_SCRIPTS)
jq empty share/claude-settings.json
@@ -41,8 +41,11 @@ 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 $(DESTDIR)$(LIBDIR)/
install -m 0644 libexec/devdesk-files.sh $(DESTDIR)$(LIBDIR)/devdesk-files.sh
install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd
install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
install -d $(DESTDIR)$(SHAREDIR)/assets
install -m 0644 dist/assets/editor.js $(DESTDIR)$(SHAREDIR)/assets/editor.js
install -m 0644 etc/env $(DESTDIR)$(SYSCONFDIR)/devdesk/env
clean:
+4 -1
View File
@@ -36,7 +36,8 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- **Conversation tracking:** a Claude `SessionStart` hook records each session's conversation ID in `/var/lib/devdesk/registry.json`. After a reboot the session resumes *that* conversation, even when other Claude instances, such as code-server, use the same directory.
- **Session status:** Claude hooks (`devdesk-state-hook`) mark each session **working**, **waiting on you** (with Claude's message) or **idle**.
- The sidebar shows it with context % and a context bar; the session chip's dot shows the current session's state.
- A "… needs you" chip jumps to other sessions that are waiting, and a desktop notification fires when one starts waiting. With the panels closed, the **Panels** button shows a count of waiting sessions and Grafana alerts.
- A "… needs you" chip jumps to other sessions that are waiting, and a desktop notification fires when one starts waiting.
- The **🔔 inbox** lists what needs you (waiting sessions, firing Grafana alerts) and what happened while you were away (a background session finished or started waiting, CI passed or failed); its badge counts them.
- **Matrix "Claude needs you":** when a session has waited for `DEVDESK_MATRIX_DELAY` seconds (30) and no browser tab or SSH client is attached to it, `devdesk-notify-matrix` posts one message per wait, with a link, to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off).
- **File & image drop:** drag files onto the terminal or paste a screenshot.
- They're uploaded through ttyd to `/var/lib/devdesk/uploads/<session>/` (25 MB max, pruned after 7 days), and the path is pasted into Claude.
@@ -45,6 +46,7 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- **Ask Claude** types an investigation prompt into the current session without sending it.
- Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`.
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log.
- **Files** (Ctrl+Shift+E), laid out like VS Code: an explorer tree (git colours, gitignored entries dimmed) and an editor with tabs (a click opens a preview tab, a double-click keeps it), **Go to file** (Ctrl+P in the pane), markdown previews (sanitized, with relative images), images, and a Changes view of uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt. Tabs and open folders are remembered per session. Read-only for now. Paths can't leave the session folder. The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar (cost in the tooltip), and Claude's own footer shows a one-line summary.
- **Light mode:** the terminal switches palettes with the page. DevDesk starts Claude with its `dark-ansi` theme so Claude draws with the terminal's 16 colours, and the light palette maps bright white to ink; tmux and the status line use ANSI colours too. A session picks up Claude's theme the next time Claude starts in it (`/exit`, then ⏎ resumes the same conversation).
- **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer.
@@ -61,6 +63,7 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
| Shift+drag | Native browser selection (plain drag goes to tmux copy-mode, which copies via OSC 52) |
| Ctrl+Shift+C / V | Copy selection / paste |
| Ctrl+Shift+P | Command palette (also the ⌕ button) |
| Ctrl+Shift+E | Files pane (Esc: back, then close) |
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.
+9
View File
@@ -17,6 +17,8 @@ set -euo pipefail
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
# shellcheck source=libexec/devdesk-files.sh
. "$DEVDESK_LIBEXEC/devdesk-files.sh"
usage() {
sed -n '2,12s/^# \{0,1\}//p' "$0"
@@ -316,6 +318,13 @@ cmd_attach() {
dd_linger
return
;;
--fs-list | --fs-find | --fs-read | --git-status | --git-show | --asset)
local action=${1#--}
shift
"cmd_${action//-/_}" "$@"
dd_linger
return
;;
--new | --rename | --end)
local action=${1#--}
shift
+76
View File
@@ -1,3 +1,79 @@
devdesk (0.7.3) bookworm; urgency=medium
* Fix (#38): links that mean "go to that session" no longer re-create one
that was ended. The 🔔 inbox, the session switcher, palette switches, the
"needs you" chip and Matrix links (now ?arg=NAME&existing=1) open sessions
existing-only; an ended one shows "NAME has ended" with an explicit
"Start NAME again". Inbox entries for ended sessions are marked as such,
and entries older than 7 days are dropped.
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 19:57:52 -0400
devdesk (0.7.2) bookworm; urgency=medium
* Fix: Go to file failed in big non-git folders such as /root/code: the
file walk was cut off at its cap with SIGPIPE, which failed the command
and truncated its reply. Producers piped into a cap now tolerate it (also
for very large folders and git status). Outside git, Go to file skips
build/cache folders and stops at 10,000 files.
* Large side-channel replies no longer cost quadratic time in the browser.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 01:50:50 -0400
devdesk (0.7.1) bookworm; urgency=medium
* Files pane reworked like VS Code (#37):
- Explorer tree beside the editor: folders expand in place (also with
the keyboard: arrows, Enter), the open file is highlighted and can be
revealed, git colours on names, change dots on folders, gitignored
entries dimmed, file-type badges. A drag handle sizes the explorer.
- Tabs: a click opens a preview tab (italic) that the next click
replaces; double-click or Enter keeps it. Switching tabs is instant and
keeps the scroll position. Files Claude changes reload in place.
- Go to file (Ctrl+P in the pane, the ⌕ button, the palette): fuzzy
search over git ls-files, or a capped find outside git.
- Explorer and Changes are two views of the left column; diffs open as
tabs.
- Expanded folders, open tabs and the active tab are remembered per
session; an empty editor offers recent files and the change count.
- Narrow panes and phones: the explorer slides over the editor.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 01:40:47 -0400
devdesk (0.7.0) bookworm; urgency=medium
* Files pane (#19): a Files button (Ctrl+Shift+E) next to Panels opens the
session's folder beside the terminal (a drag-resizable split on wide
screens, an overlay on narrow ones and phones).
- Browse folders; read files with syntax highlighting (CodeMirror,
read-only, Ctrl+F search, line wrap toggle), markdown rendered as a
sanitized preview with its relative images, images shown inline.
- Changes tab: uncommitted git changes with line counts and a "C" badge
on files Claude edited (recorded by the state hook from Edit/Write);
diff against HEAD for any changed file. Non-git sessions list the files
Claude edited there.
- "→ Claude" types @path into the prompt; Copy path.
- After Claude's turn the list refreshes, and an open file that changed
on disk offers a reload.
- Paths stay inside the session folder (no absolute paths, no symlink
escapes). Read-only for now; editing is v0.8.0.
- The editor (~600 KB) is not in the page: it ships in
/usr/share/devdesk/assets, is fetched over ttyd the first time Files
opens, checked against the page's SHA-256 and cached in the browser.
* Fix: the state hook mis-read hook fields when Claude's message was empty.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 01:16:26 -0400
devdesk (0.6.1) bookworm; urgency=medium
* Inbox bell (#36): sessions waiting on you, firing alerts, and what
happened while you were away (a background session finished or started
waiting, CI passed or failed). Click to jump there; shared by all tabs.
Replaces the count on the Panels button.
* Phone header: theme in the ⋯ menu, Panels as an icon, so the bell fits.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 00:50:56 -0400
devdesk (0.6.0) bookworm; urgency=medium
* Light mode, like the other web_template apps (#31): the ☀/◐ button in the
+5
View File
@@ -35,6 +35,11 @@ Comment: Bundled into index.html at build time:
2014-2016 SourceLair Private Company, 2012-2013 Christopher Jeffrey);
JetBrains Mono via @fontsource/jetbrains-mono (OFL-1.1,
Copyright 2020 The JetBrains Mono Project Authors).
Bundled into assets/editor.js (the file viewer) at build time:
CodeMirror 6 and @lezer/highlight (MIT, Copyright 2018-2021 Marijn Haverbeke
and others); marked (MIT, Copyright 2018+ MarkedJS, 2011-2018 Christopher
Jeffrey); DOMPurify (Apache-2.0 or MPL-2.0 at the recipient's choice, here
Apache-2.0, Copyright 2025 Dr.-Ing. Mario Heiderich, Cure53).
License: MIT
Permission is hereby granted, free of charge, to any person obtaining a copy
+199
View File
@@ -0,0 +1,199 @@
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
# File viewer commands (devdesk#19), sourced by bin/devdesk. Every path is
# relative to the session's folder and must stay inside it: no absolute paths,
# no newlines, no symlink escapes. Reached through `devdesk attach --fs-*`.
: "${DEVDESK_FS_MAX:=2097152}" # bytes the viewer opens
: "${DEVDESK_FS_LIST_MAX:=2000}" # entries per directory listing
: "${DEVDESK_ASSETS:=$DEVDESK_SHARE/assets}"
# Producers piped into `head` get SIGPIPE once the cap is reached; under
# pipefail that would fail the command and cut its reply short, so they are
# wrapped as `{ producer || true; }`.
# Blob replies: a JSON header line, then base64 between markers (no body for
# binary files the viewer can't show). The client waits for the END marker.
DD_BLOB_BEGIN=DEVDESK-BLOB-BEGIN
DD_BLOB_END=DEVDESK-BLOB-END
dd_session_root() {
dd_valid_name "$1" || dd_die "invalid session name: $1"
realpath -e -- "$(dd_resolve_dir "$1")" 2>/dev/null || dd_die "the folder of session '$1' is missing"
}
# dd_fs_path ROOT REL -> the real path of REL inside ROOT, or die.
dd_fs_path() {
local root=$1 rel=${2:-.} p
case $rel in
/* | *$'\n'* | *$'\r'*) dd_die "bad path: $rel" ;;
esac
[ -n "$rel" ] || rel=.
p=$(realpath -e -- "$root/$rel" 2>/dev/null) || dd_die "no such file: $rel"
case "$p/" in
"$root"/*) printf '%s\n' "$p" ;;
*) dd_die "outside the session folder: $rel" ;;
esac
}
dd_mime() {
case ${1,,} in
*.png) echo image/png ;;
*.jpg | *.jpeg) echo image/jpeg ;;
*.gif) echo image/gif ;;
*.webp) echo image/webp ;;
*.svg) echo image/svg+xml ;;
*.ico) echo image/x-icon ;;
*.bmp) echo image/bmp ;;
*.pdf) echo application/pdf ;;
*) echo "" ;;
esac
}
# dd_blob FILE LABEL: header + body for one file on disk.
dd_blob() {
local f=$1 label=$2 size mtime sha mime binary=false utf8=true body=true
read -r size mtime < <(stat -c '%s %Y' -- "$f")
mime=$(dd_mime "$f")
if [ "$size" -gt "$DEVDESK_FS_MAX" ]; then
jq -cMn --arg p "$label" --argjson s "$size" --argjson m "$mtime" --argjson max "$DEVDESK_FS_MAX" \
'{path: $p, size: $s, mtime: $m, tooLarge: true, max: $max}'
return
fi
if head -c 8192 -- "$f" | grep -qaP '\x00'; then
binary=true
[ -n "$mime" ] || body=false
elif ! iconv -f UTF-8 -t UTF-8 -- "$f" >/dev/null 2>&1; then
utf8=false
fi
sha=$(sha256sum -- "$f" | cut -d' ' -f1)
jq -cMn --arg p "$label" --argjson s "$size" --argjson m "$mtime" --arg h "$sha" \
--argjson b "$binary" --argjson u "$utf8" --arg mime "$mime" --argjson body "$body" \
'{path: $p, size: $s, mtime: $m, sha: $h, binary: $b, utf8: $u, body: $body,
mime: (if $mime == "" then null else $mime end)}'
printf '%s\n' "$DD_BLOB_BEGIN"
if $body; then base64 -w0 -- "$f"; fi
printf '\n%s\n' "$DD_BLOB_END"
}
# --fs-list SESSION [REL]: one directory level, folders first, .git skipped.
# In a git repository, entries .gitignore covers are flagged (the UI dims them).
cmd_fs_list() {
if [ $# -lt 1 ] || [ $# -gt 2 ]; then dd_die "usage: devdesk attach --fs-list SESSION [PATH]"; fi
local root dir rel=${2:-.} tmp ignored=""
root=$(dd_session_root "$1")
dir=$(dd_fs_path "$root" "$rel")
[ -d "$dir" ] || dd_die "not a folder: $rel"
tmp=$(mktemp)
# %y: type, %Y: type behind a symlink, %s: size, %T@: mtime, %f: name.
{ find "$dir" -mindepth 1 -maxdepth 1 ! -name .git -printf '%y\t%Y\t%s\t%T@\t%f\0' 2>/dev/null || true; } |
head -z -n "$((DEVDESK_FS_LIST_MAX + 1))" >"$tmp"
if git --no-optional-locks -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
ignored=$(tr '\0' '\n' <"$tmp" | cut -f5- | git --no-optional-locks -C "$dir" check-ignore --stdin 2>/dev/null || true)
fi
jq -cMRs --arg root "$root" --arg rel "${dir#"$root"}" --argjson max "$DEVDESK_FS_LIST_MAX" --arg ign "$ignored" '
($ign | split("\n") | map(select(length > 0) | rtrimstr("/")) | INDEX(.)) as $ignored
| (split("\u0000") | map(select(length > 0) | split("\t") |
{t: .[0], to: .[1], s: (.[2] | tonumber), m: (.[3] | tonumber | floor), n: (.[4:] | join("\t"))}
| if $ignored[.n] then . + {ig: true} else . end)) as $all
| {root: $root, dir: ($rel | ltrimstr("/")), truncated: ($all | length > $max),
entries: ($all[:$max] | sort_by([(if .t == "d" or .to == "d" then 0 else 1 end), (.n | ascii_downcase)]))}' <"$tmp"
rm -f "$tmp"
}
# --fs-find SESSION: every file under the session folder, for "go to file".
# git ls-files in a repository (tracked + untracked, .gitignore honoured), else
# find without node_modules, .git and build/cache folders; capped at
# DEVDESK_FS_FIND_MAX (10000).
cmd_fs_find() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --fs-find SESSION"
local root max=${DEVDESK_FS_FIND_MAX:-10000}
root=$(dd_session_root "$1")
if git --no-optional-locks -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git --no-optional-locks -C "$root" ls-files -co --exclude-standard -z 2>/dev/null || true
else
(cd "$root" && find . -maxdepth 8 \( -name node_modules -o -name .git -o -name __pycache__ -o -name .venv -o -name venv \
-o -name dist -o -name build -o -name target -o -name .cache -o -name .next -o -name coverage \) -prune \
-o -type f -printf '%P\0' 2>/dev/null) || true
fi | head -z -n "$((max + 1))" |
jq -cMRs --argjson max "$max" --arg root "$root" \
'(split("\u0000") | map(select(length > 0))) as $f | {root: $root, truncated: ($f | length > $max), files: $f[:$max]}'
}
# --fs-read SESSION REL
cmd_fs_read() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-read SESSION PATH"
local root f
root=$(dd_session_root "$1")
f=$(dd_fs_path "$root" "$2")
[ -f "$f" ] || dd_die "not a file: $2"
dd_blob "$f" "${f#"$root"/}"
}
# --git-status SESSION: changed files under the session folder, with line counts
# and whether Claude edited them (from devdesk-state-hook's edit log).
cmd_git_status() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --git-status SESSION"
local root g top prefix branch edits
root=$(dd_session_root "$1")
edits="$DEVDESK_STATE_DIR/edits/$1.txt"
g=(git --no-optional-locks -C "$root")
if ! top=$("${g[@]}" rev-parse --show-toplevel 2>/dev/null); then
# Not a repository: only what Claude edited.
jq -cMn --arg root "$root" --rawfile e <(cat -- "$edits" 2>/dev/null || true) '
{git: false, root: $root, files: [$e | split("\n")[] | select(length > 0) | select(startswith($root + "/"))
| {path: ltrimstr($root + "/"), x: " ", y: " ", claude: true}]}'
return
fi
prefix=$("${g[@]}" rev-parse --show-prefix)
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
{
printf 'S\n'
{ "${g[@]}" status --porcelain=v1 -z --untracked-files=all -- . 2>/dev/null || true; } | head -z -n 1000 | tr '\0' '\n'
printf 'N\n'
{ "${g[@]}" diff --numstat HEAD -- . 2>/dev/null || true; } | head -n 1000
} | jq -cMRs --arg root "$root" --arg top "$top" --arg prefix "$prefix" --arg branch "$branch" \
--rawfile e <(cat -- "$edits" 2>/dev/null || true) '
(split("\nN\n") | .[0] | ltrimstr("S\n") | split("\n") | map(select(length > 2))) as $st
| (split("\nN\n") | .[1] // "" | split("\n") | map(select(length > 0) | split("\t")
| {key: .[2:] | join("\t"), value: {add: (.[0] | tonumber? // null), del: (.[1] | tonumber? // null)}}) | from_entries) as $num
| ($e | split("\n") | map(select(length > 0))) as $claude
# porcelain -z: "XY path", and after a rename/copy entry the next item is the old path.
| reduce range(0; $st | length) as $i ({skip: false, files: []};
if .skip then .skip = false
else ($st[$i]) as $line | ($line[3:]) as $p
| .files += [{repoPath: $p, x: $line[0:1], y: $line[1:2],
from: (if ($line[0:1] | test("[RC]")) then $st[$i + 1] else null end)}]
| .skip = ($line[0:1] | test("[RC]"))
end)
| .files | map(. + {path: (if (.repoPath | startswith($prefix)) then .repoPath | ltrimstr($prefix) else null end),
add: $num[.repoPath].add, del: $num[.repoPath].del,
claude: (($top + "/" + .repoPath) as $abs | any($claude[]; . == $abs))}) as $files
| {git: true, root: $root, branch: $branch,
files: ($files + [$claude[] | select(startswith($root + "/")) | ltrimstr($root + "/") as $p
| select(any($files[]; .path == $p) | not)
| {path: $p, repoPath: null, x: " ", y: " ", claude: true}])}'
}
# --git-show SESSION REL: the HEAD version of a file, for diffs. An empty body
# when HEAD doesn't have it (new file).
cmd_git_show() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --git-show SESSION PATH"
local root g top prefix tmp rel=$2
root=$(dd_session_root "$1")
case $rel in /* | *$'\n'* | *..*) dd_die "bad path: $rel" ;; esac
g=(git --no-optional-locks -C "$root")
top=$("${g[@]}" rev-parse --show-toplevel 2>/dev/null) || dd_die "not a git repository"
prefix=$("${g[@]}" rev-parse --show-prefix)
tmp=$(mktemp)
"${g[@]}" show "HEAD:$prefix$rel" >"$tmp" 2>/dev/null || : >"$tmp"
dd_blob "$tmp" "$rel"
rm -f "$tmp"
}
# --asset NAME: a lazily loaded part of the web UI (the file viewer's editor),
# shipped in $DEVDESK_ASSETS and cached by the browser.
cmd_asset() {
if [ $# -ne 1 ] || ! [[ $1 =~ ^[a-z0-9-]+\.js$ ]]; then dd_die "usage: devdesk attach --asset NAME.js"; fi
[ -f "$DEVDESK_ASSETS/$1" ] || dd_die "no such asset: $1"
dd_blob "$DEVDESK_ASSETS/$1" "$1"
}
+4 -1
View File
@@ -132,7 +132,7 @@ dd_registry_set_claude() {
dd_registry_remove() {
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json"
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt"
}
dd_registry_rename() {
@@ -144,6 +144,9 @@ dd_registry_rename() {
mv -f "$d/$1.json" "$d/$2.json"
fi
done
if [ -f "$DEVDESK_STATE_DIR/edits/$1.txt" ]; then
mv -f "$DEVDESK_STATE_DIR/edits/$1.txt" "$DEVDESK_STATE_DIR/edits/$2.txt"
fi
}
# Project name (directory or its slug) -> absolute directory inside the projects root.
+3 -1
View File
@@ -28,7 +28,9 @@ attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
url=""
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session"
# existing=1: if the session was ended by the time you tap, say so instead of
# starting a new Claude there (devdesk#38).
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session&existing=1"
body=$(jq -cMn --arg s "$session" --arg m "$message" --arg u "$url" --arg h "$(hostname)" \
'{session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h,
text: ("Claude needs you in " + $s + ": " + $m + (if $u == "" then "" else " — " + $u end))}')
+17 -1
View File
@@ -16,7 +16,23 @@ outer=${TMUX:-}
session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
[[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]] || exit 0
IFS=$'\t' read -r event message < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\t\n]"; " "))] | @tsv' <<<"$input" 2>/dev/null)
# Unit separator, not tab: read collapses runs of whitespace delimiters, so an
# empty field would shift the ones after it.
IFS=$'\x1f' read -r event message tool file < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\u001f\n]"; " ")),
.tool_name // "", (.tool_input.file_path // .tool_input.notebook_path // "" | gsub("[\u001f\n]"; " "))] | join("\u001f")' <<<"$input" 2>/dev/null)
# Files Claude edits, for the file viewer's "Claude" badge (devdesk#19).
case $event:$tool in
PostToolUse:Edit | PostToolUse:Write | PostToolUse:MultiEdit | PostToolUse:NotebookEdit)
if [[ $file == /* ]]; then
log="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/edits/$session.txt"
mkdir -p "${log%/*}" 2>/dev/null
if ! grep -qxF -- "$file" "$log" 2>/dev/null && [ "$(wc -l <"$log" 2>/dev/null || echo 0)" -lt 1000 ]; then
printf '%s\n' "$file" >>"$log"
fi
fi
;;
esac
case $event in
UserPromptSubmit | PostToolUse) state=working ;;
Notification) state=waiting ;;
+329 -1
View File
@@ -7,20 +7,215 @@
"name": "devdesk",
"license": "MIT",
"devDependencies": {
"@codemirror/commands": "6.11.1",
"@codemirror/lang-markdown": "6.5.2",
"@codemirror/language": "6.13.1",
"@codemirror/legacy-modes": "6.5.5",
"@codemirror/merge": "6.12.2",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.6",
"@codemirror/view": "6.43.14",
"@fontsource/jetbrains-mono": "5.3.0",
"@lezer/highlight": "1.2.5",
"@xterm/addon-clipboard": "0.2.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-unicode11": "0.9.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/xterm": "6.0.0",
"dompurify": "3.4.16",
"esbuild": "0.28.2",
"eslint": "8.57.1"
"eslint": "8.57.1",
"marked": "18.1.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.12",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz",
"integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/streamparser": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.5",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.5.tgz",
"integrity": "sha512-9ltT214oMb3Y1w5eAZMSigzrjzZnMI2Wa3Feq73rU/i4C56MkwrpfswfQYOr28C5pXr6dWwspNTMyeHXlzWMxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/streamparser": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/merge": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/highlight": "^1.0.0",
"style-mod": "^4.1.0"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.6",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/streamparser": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
"dev": true,
"license": "MIT"
},
"node_modules/@codemirror/view": {
"version": "6.43.14",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -574,6 +769,87 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@lezer/common": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
"integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.8.0.tgz",
"integrity": "sha512-X6w3A2NpyhdtGqTWRJB8gR4QDXBaFMbTOs780ZdZ15KKCfBXOQTKcB3Vr+W6tqJHVlealt53tLmHRvOqPTQgYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -612,6 +888,14 @@
"node": ">= 8"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/@ungap/structured-clone": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
@@ -812,6 +1096,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -865,6 +1156,16 @@
"node": ">=6.0.0"
}
},
"node_modules/dompurify": {
"version": "3.4.16",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"dev": true,
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -1423,6 +1724,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/marked": {
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.1.0.tgz",
"integrity": "sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==",
"dev": true,
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -1705,6 +2019,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
"dev": true,
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -1761,6 +2082,13 @@
"punycode": "^2.1.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+16 -3
View File
@@ -4,21 +4,34 @@
"description": "Persistent browser terminal for Claude Code (ttyd + tmux), LotusGuild TDS styling",
"license": "MIT",
"type": "module",
"engines": { "node": ">=18" },
"engines": {
"node": ">=18"
},
"scripts": {
"build": "node web/build.mjs",
"lint": "eslint --ext .js,.mjs web/src web/build.mjs tests/protocol.test.mjs",
"lint": "eslint --ext .js,.mjs web/src web/editor web/build.mjs tests/protocol.test.mjs",
"test": "node --test tests/*.test.mjs"
},
"devDependencies": {
"@codemirror/commands": "6.11.1",
"@codemirror/lang-markdown": "6.5.2",
"@codemirror/language": "6.13.1",
"@codemirror/legacy-modes": "6.5.5",
"@codemirror/merge": "6.12.2",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.6",
"@codemirror/view": "6.43.14",
"@fontsource/jetbrains-mono": "5.3.0",
"@lezer/highlight": "1.2.5",
"@xterm/addon-clipboard": "0.2.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-unicode11": "0.9.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/xterm": "6.0.0",
"dompurify": "3.4.16",
"esbuild": "0.28.2",
"eslint": "8.57.1"
"eslint": "8.57.1",
"marked": "18.1.0"
}
}
+130 -1
View File
@@ -310,7 +310,7 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
sleep 2.5
[ "$(posts)" -eq 1 ]
run jq -r '[.session, .url, (.text | test("Claude needs you in alpha"))] | @tsv' "$TMP/posts"
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha\ttrue')" ]
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha&existing=1\ttrue')" ]
# answered within the delay: nothing is posted
echo '{"hook_event_name":"PostToolUse"}' | hook
@@ -491,3 +491,132 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
run claude_args alpha
[ "$output" = "-n alpha --settings $REPO/share/claude-settings.json --remote-control devdesk-alpha" ]
}
# Decode a blob reply (header line, base64 between markers) into $TMP/blob.
blob_body() {
sed -n '/^DEVDESK-BLOB-BEGIN$/,/^DEVDESK-BLOB-END$/p' <<<"$1" | sed '1d;$d' | base64 -d >"$TMP/blob"
}
@test "fs-list lists one level, folders first, and stays inside the session folder (#19)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
mkdir -p "$p/src/deep" "$p/.git"
echo hi >"$p/README.md"
echo x >"$p/src/a.sh"
ln -s "$TMP/outside" "$p/escape-link"
ln -s src "$p/src-link"
run devdesk attach --fs-list alpha
[ "$status" -eq 0 ]
# Folders first (a symlink to a folder counts, even one that escapes: opening it fails).
[ "$(jq -r '[.entries[].n] | join(",")' <<<"$output")" = "escape-link,src,src-link,README.md" ]
run devdesk attach --fs-list alpha src
[ "$(jq -r '.dir + ":" + ([.entries[].n] | join(","))' <<<"$output")" = "src:deep,a.sh" ]
for bad in ../ /etc escape-link "src/../../alpha/../../outside"; do
run devdesk attach --fs-list alpha "$bad"
[ "$status" -ne 0 ]
done
run devdesk attach --fs-list alpha src-link
[ "$status" -eq 0 ]
}
@test "fs-read returns text, images and refuses binaries, big files and escapes (#19)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
printf 'line 1\r\nüñí\n' >"$p/notes.md"
printf '\x89PNG\r\n\x1a\n\0\0\0' >"$p/pic.png"
head -c 100 /dev/zero >"$p/blob.bin"
head -c 3000 /dev/zero | tr '\0' a >"$p/big.txt"
run devdesk attach --fs-read alpha notes.md
[ "$status" -eq 0 ]
[ "$(head -n1 <<<"$output" | jq -r '[.path, .binary, .utf8, .body] | @tsv')" = "$(printf 'notes.md\tfalse\ttrue\ttrue')" ]
blob_body "$output"
cmp "$TMP/blob" "$p/notes.md"
run devdesk attach --fs-read alpha pic.png
[ "$(head -n1 <<<"$output" | jq -r '[.mime, .body] | @tsv')" = "$(printf 'image/png\ttrue')" ]
run devdesk attach --fs-read alpha blob.bin
[ "$(head -n1 <<<"$output" | jq -r '[.binary, .body] | @tsv')" = "$(printf 'true\tfalse')" ]
run env DEVDESK_FS_MAX=1000 "$REPO/bin/devdesk" attach --fs-read alpha big.txt
[ "$(head -n1 <<<"$output" | jq -r '.tooLarge')" = true ]
run devdesk attach --fs-read alpha ../alpha/../../outside
[ "$status" -ne 0 ]
}
@test "git-status and git-show report changes, line counts and Claude's edits (#19)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$p" init -q -b main
git -C "$p" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init
printf 'one\ntwo\n' >"$p/kept.txt"
printf 'old\n' >"$p/moved.txt"
git -C "$p" add . && git -C "$p" -c user.name=t -c user.email=t@t commit -q -m files
printf 'one\nTWO\nthree\n' >"$p/kept.txt"
git -C "$p" mv moved.txt renamed.txt
echo new >"$p/fresh.txt"
mkdir -p "$DEVDESK_STATE_DIR/edits"
printf '%s\n' "$p/kept.txt" "$p/notes-outside-git.txt" >"$DEVDESK_STATE_DIR/edits/alpha.txt"
run devdesk attach --git-status alpha
[ "$status" -eq 0 ]
[ "$(jq -r '.files | map("\(.x)\(.y) \(.path) +\(.add) -\(.del) \(.claude)") | sort | join("|")' <<<"$output")" = \
" notes-outside-git.txt +null -null true| M kept.txt +2 -1 true|?? fresh.txt +null -null false|R renamed.txt +null -null false" ]
[ "$(jq -r '.files[] | select(.path == "renamed.txt") | .from' <<<"$output")" = moved.txt ]
run devdesk attach --git-show alpha kept.txt
blob_body "$output"
[ "$(cat "$TMP/blob")" = "$(printf 'one\ntwo')" ]
run devdesk attach --git-show alpha fresh.txt
[ "$(head -n1 <<<"$output" | jq -r '.size')" = 0 ]
run devdesk attach --git-status alpha
run env DEVDESK_PROJECTS_ROOT="$TMP/outside" "$REPO/bin/devdesk" attach --git-status alpha
[ "$status" -eq 0 ]
}
@test "the state hook logs Claude's edits; rename moves and end removes the log (#19)" {
start_server
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
echo '{"hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"/x/a.txt"}}' | hook
echo '{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"/x/a.txt"}}' | hook
echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"ls"}}' | hook
echo '{"hook_event_name":"PostToolUse","tool_name":"NotebookEdit","tool_input":{"notebook_path":"/x/n.ipynb"}}' | hook
[ "$(cat "$DEVDESK_STATE_DIR/edits/alpha.txt")" = "$(printf '/x/a.txt\n/x/n.ipynb')" ]
devdesk rename alpha beta
[ -f "$DEVDESK_STATE_DIR/edits/beta.txt" ] && [ ! -e "$DEVDESK_STATE_DIR/edits/alpha.txt" ]
devdesk end beta
[ ! -e "$DEVDESK_STATE_DIR/edits/beta.txt" ]
}
@test "asset serves only named .js files from the assets folder (#19)" {
mkdir -p "$TMP/assets"
echo 'window.X = 1;' >"$TMP/assets/editor.js"
run env DEVDESK_ASSETS="$TMP/assets" "$REPO/bin/devdesk" attach --asset editor.js
[ "$status" -eq 0 ]
blob_body "$output"
cmp "$TMP/blob" "$TMP/assets/editor.js"
for bad in ../etc/passwd editor.css nope.js; do
run env DEVDESK_ASSETS="$TMP/assets" "$REPO/bin/devdesk" attach --asset "$bad"
[ "$status" -ne 0 ]
done
}
@test "fs-list flags gitignored entries; fs-find lists files for go-to-file (#37)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$p" init -q -b main
mkdir -p "$p/src" "$p/build" "$p/node_modules/x"
printf 'build/\n*.log\n' >"$p/.gitignore"
echo a >"$p/src/a.js"
echo b >"$p/debug.log"
echo c >"$p/node_modules/x/i.js"
run devdesk attach --fs-list alpha
[ "$(jq -r '[.entries[] | select(.ig) | .n] | join(",")' <<<"$output")" = "build,debug.log" ]
run devdesk attach --fs-find alpha
[ "$(jq -r '.files | sort | join(",")' <<<"$output")" = ".gitignore,node_modules/x/i.js,src/a.js" ]
# Outside git: find, without node_modules
rm -rf "$p/.git"
run devdesk attach --fs-find alpha
[ "$(jq -r '.files | sort | join(",")' <<<"$output")" = ".gitignore,debug.log,src/a.js" ]
# Hitting the cap must not fail the command (SIGPIPE in the producer).
for i in $(seq 1 3000); do : >"$p/src/f$i.txt"; done
run env DEVDESK_FS_FIND_MAX=1 "$REPO/bin/devdesk" attach --fs-find alpha
[ "$status" -eq 0 ]
[ "$(jq -r '[(.files | length), .truncated] | @tsv' <<<"$output")" = "$(printf '1\ttrue')" ]
run env DEVDESK_FS_LIST_MAX=5 "$REPO/bin/devdesk" attach --fs-list alpha src
[ "$status" -eq 0 ]
[ "$(jq -r '[(.entries | length), .truncated] | @tsv' <<<"$output")" = "$(printf '5\ttrue')" ]
}
+27 -1
View File
@@ -2,6 +2,7 @@
// file and nothing else), with CSS, JS, fonts and the favicon all inlined.
import { build, transform } from 'esbuild';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -33,8 +34,29 @@ async function fontFaces(weights) {
return faces.join('\n');
}
// The file viewer's editor (devdesk#19): its own IIFE, served over the ttyd
// channel by `devdesk attach --asset editor.js` and cached by the browser. The
// page carries its SHA-256, which is both the integrity check and the cache key.
async function editorBundle() {
const out = await build({
entryPoints: [at('web', 'editor', 'index.js')],
bundle: true,
minify: true,
format: 'iife',
globalName: 'DevDeskEditor',
target: ['es2020'],
write: false,
legalComments: 'eof',
});
const code = out.outputFiles[0].text;
await mkdir(at('dist', 'assets'), { recursive: true });
await writeFile(at('dist', 'assets', 'editor.js'), code);
return { sha: createHash('sha256').update(code).digest('hex'), size: Buffer.byteLength(code) };
}
async function main() {
const ver = await version();
const editor = await editorBundle();
const js = await build({
entryPoints: [at('web', 'src', 'main.js')],
@@ -44,7 +66,10 @@ async function main() {
target: ['es2020'],
write: false,
legalComments: 'eof',
define: { __DEVDESK_VERSION__: JSON.stringify(ver) },
define: {
__DEVDESK_VERSION__: JSON.stringify(ver),
__EDITOR_SHA__: JSON.stringify(editor.sha),
},
});
const css = await transform([
@@ -67,6 +92,7 @@ async function main() {
await mkdir(at('dist'), { recursive: true });
await writeFile(at('dist', 'index.html'), html);
console.log(`dist/index.html ${(html.length / 1024).toFixed(0)} KiB (devdesk ${ver})`);
console.log(`dist/assets/editor.js ${(editor.size / 1024).toFixed(0)} KiB sha256 ${editor.sha.slice(0, 12)}`);
}
main().catch((err) => {
+159
View File
@@ -0,0 +1,159 @@
// The file viewer's editor bundle (devdesk#19): CodeMirror 6 with stream-mode
// 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 { EditorState } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { markdownLanguage } from '@codemirror/lang-markdown';
import { unifiedMergeView } from '@codemirror/merge';
import { tags as t } from '@lezer/highlight';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { python } from '@codemirror/legacy-modes/mode/python';
import { javascript, json, typescript } from '@codemirror/legacy-modes/mode/javascript';
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { properties } from '@codemirror/legacy-modes/mode/properties';
import { nginx } from '@codemirror/legacy-modes/mode/nginx';
import { diff as diffMode } from '@codemirror/legacy-modes/mode/diff';
import { css } from '@codemirror/legacy-modes/mode/css';
import { xml, html } from '@codemirror/legacy-modes/mode/xml';
import { go } from '@codemirror/legacy-modes/mode/go';
import { rust } from '@codemirror/legacy-modes/mode/rust';
import { standardSQL } from '@codemirror/legacy-modes/mode/sql';
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 DOMPurify from 'dompurify';
const MODES = {
shell, python, javascript, json, typescript, yaml, toml, properties, nginx, diff: diffMode, css, xml, html,
go, rust, sql: standardSQL, dockerfile: dockerFile, lua, perl, ruby,
};
const BY_EXT = {
md: 'markdown', markdown: 'markdown', mdx: 'markdown',
sh: 'shell', bash: 'shell', zsh: 'shell', ksh: 'shell', env: 'shell', bats: 'shell',
py: 'python', pyi: 'python',
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
ts: 'typescript', tsx: 'typescript', mts: 'typescript',
json: 'json', jsonc: 'json', webmanifest: 'json',
yml: 'yaml', yaml: 'yaml',
toml: 'toml',
ini: 'properties', cfg: 'properties', conf: 'properties', properties: 'properties',
service: 'properties', timer: 'properties', socket: 'properties', mount: 'properties', network: 'properties',
diff: 'diff', patch: 'diff',
css: 'css', scss: 'css',
xml: 'xml', svg: 'xml', plist: 'xml',
html: 'html', htm: 'html', php: 'html', j2: 'html', jinja: 'html',
go: 'go', rs: 'rust', sql: 'sql', lua: 'lua', pl: 'perl', pm: 'perl', rb: 'ruby',
};
// Language for a path: extension, a few well-known names, else none.
export function languageFor(path) {
const name = path.split('/').pop().toLowerCase();
if (name === 'dockerfile' || name.startsWith('dockerfile.') || name.endsWith('.dockerfile')) return 'dockerfile';
if (name === 'makefile' || name === 'gnumakefile') return 'shell';
if (/(^|\/)nginx\//.test(path) || name === 'nginx.conf') return 'nginx';
if (['.bashrc', '.profile', '.bash_profile', '.zshrc', 'pkgbuild'].includes(name)) return 'shell';
if (name.startsWith('.env')) return 'shell';
const ext = name.includes('.') ? name.split('.').pop() : '';
return BY_EXT[ext] ?? null;
}
function languageExtension(lang) {
if (lang === 'markdown') return markdownLanguage;
if (lang && MODES[lang]) return StreamLanguage.define(MODES[lang]);
return [];
}
// Colours come from the page's TDS variables, so dark/light mode just works.
const highlight = HighlightStyle.define([
{ tag: [t.keyword, t.controlKeyword, t.operatorKeyword, t.modifier], color: 'var(--accent-orange)' },
{ tag: [t.string, t.special(t.string), t.regexp], color: 'var(--accent-green)' },
{ tag: [t.comment, t.lineComment, t.blockComment, t.meta], color: 'var(--text-muted)', fontStyle: 'italic' },
{ tag: [t.number, t.bool, t.atom, t.null], color: 'var(--accent-purple)' },
{ tag: [t.typeName, t.className, t.namespace], color: 'var(--accent-amber)' },
{ tag: [t.function(t.variableName), t.function(t.propertyName), t.definition(t.variableName)], color: 'var(--accent-cyan)' },
{ tag: [t.propertyName, t.attributeName], color: 'var(--accent-cyan)' },
{ tag: [t.tagName], color: 'var(--accent-orange)' },
{ tag: [t.heading, t.heading1, t.heading2, t.heading3], color: 'var(--accent-cyan)', fontWeight: 'bold' },
{ tag: t.strong, fontWeight: 'bold' },
{ tag: t.emphasis, fontStyle: 'italic' },
{ tag: t.strikethrough, textDecoration: 'line-through' },
{ tag: [t.link, t.url], color: 'var(--accent-cyan)', textDecoration: 'underline' },
{ tag: [t.monospace], color: 'var(--accent-amber)' },
{ tag: [t.quote], color: 'var(--text-secondary)', fontStyle: 'italic' },
{ tag: t.inserted, color: 'var(--accent-green)' },
{ tag: t.deleted, color: 'var(--accent-red)' },
{ tag: t.invalid, color: 'var(--accent-red)' },
]);
const theme = EditorView.theme({
'&': { height: '100%', color: 'var(--text-primary)', backgroundColor: 'var(--bg-terminal)', fontSize: 'var(--dd-editor-font, 13px)' },
'.cm-scroller': { fontFamily: 'var(--font-mono)', lineHeight: '1.5' },
'.cm-content': { caretColor: 'var(--accent-orange)' },
'.cm-cursor': { borderLeftColor: 'var(--accent-orange)' },
'.cm-gutters': { backgroundColor: 'var(--bg-secondary)', color: 'var(--text-dim)', border: 'none', borderRight: '1px solid var(--border-dim)' },
'.cm-activeLine': { backgroundColor: 'var(--accent-cyan-dim)' },
'.cm-activeLineGutter': { backgroundColor: 'var(--accent-cyan-dim)', color: 'var(--accent-cyan)' },
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(0, 150, 220, 0.28) !important' },
'.cm-searchMatch': { backgroundColor: 'var(--accent-amber-dim)', outline: '1px solid var(--accent-amber)' },
'.cm-searchMatch.cm-searchMatch-selected': { backgroundColor: 'var(--accent-orange-dim)' },
'.cm-selectionMatch': { backgroundColor: 'var(--accent-cyan-dim)' },
'.cm-panels': { backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)', borderColor: 'var(--border-color)' },
'.cm-panel input, .cm-panel button': { fontFamily: 'var(--font-mono)', fontSize: '12px' },
'.cm-textfield': { backgroundColor: 'var(--bg-input)', color: 'var(--text-primary)', border: '1px solid var(--border-color)' },
'.cm-button': { backgroundImage: 'none', backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-color)' },
'.cm-foldGutter span': { color: 'var(--text-dim)' },
// unified diff
'.cm-changedLine': { backgroundColor: 'var(--accent-green-dim) !important' },
'.cm-changedText': { backgroundColor: 'rgba(0, 200, 120, 0.22) !important' },
'.cm-deletedChunk': { backgroundColor: 'var(--accent-red-dim) !important' },
'.cm-deletedChunk .cm-deletedText, .cm-deletedChunk del': { backgroundColor: 'rgba(255, 45, 85, 0.22)', textDecoration: 'none' },
'.cm-insertedLine': { backgroundColor: 'var(--accent-green-dim)' },
'.cm-changeGutter': { width: '3px' },
'.cm-changedLineGutter': { backgroundColor: 'var(--accent-green)' },
'.cm-deletedLineGutter': { backgroundColor: 'var(--accent-red)' },
});
function baseExtensions(path, { readOnly = true, wrap = false } = {}) {
return [
lineNumbers(), foldGutter(), highlightSpecialChars(), highlightActiveLine(), highlightActiveLineGutter(),
drawSelection(), history(), search({ top: true }), highlightSelectionMatches(), bracketMatching(),
syntaxHighlighting(highlight), theme, languageExtension(languageFor(path)),
// 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]),
wrap ? EditorView.lineWrapping : [],
];
}
// Read-only (v1) view of a file.
export function viewer(parent, { doc, path, wrap }) {
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
return { view, destroy: () => view.destroy() };
}
// Unified diff of the HEAD version against the working copy.
export function diff(parent, { original, modified, path, wrap }) {
const view = new EditorView({
parent,
state: EditorState.create({
doc: modified,
extensions: [baseExtensions(path, { wrap }), unifiedMergeView({ original, mergeControls: false, highlightChanges: true, gutter: true })],
}),
});
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 } });
}
+35 -1
View File
@@ -42,6 +42,14 @@
</span>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-cmd-btn" id="cmd-toggle" title="Command palette (Ctrl+Shift+P)" aria-label="Open command palette">⌕<span class="dd-cmd-kbd">&nbsp;⌃⇧P</span></button>
<button type="button" class="lt-theme-btn" id="theme-toggle" aria-label="Switch to light mode" title="Switch to light mode">☀</button>
<div class="lt-notif-dropdown-wrap dd-inbox">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-bell-btn" id="inbox-toggle" aria-haspopup="dialog" aria-expanded="false" aria-controls="inbox-panel" aria-label="Inbox" title="Inbox">🔔</button>
<span class="lt-notif-badge" id="inbox-badge" data-count="0" aria-hidden="true"></span>
<div class="lt-notif-panel dd-inbox-panel" id="inbox-panel" role="dialog" aria-label="Inbox" aria-hidden="true">
<div class="lt-notif-panel-header"><span>Inbox</span><button type="button" class="lt-notif-panel-clear">Mark all read</button></div>
<div class="lt-notif-panel-list"></div>
</div>
</div>
<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>
@@ -53,13 +61,16 @@
<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="palette-menu" role="menuitem">Command palette</button>
<button type="button" class="dd-menu-item" id="help-open" role="menuitem">Keyboard shortcuts</button>
<button type="button" class="dd-menu-item" id="fullscreen-toggle" role="menuitem" title="In Chromium, fullscreen also captures Ctrl+W/Ctrl+T for the terminal">Fullscreen</button>
<div class="dd-menu-foot" id="version">devdesk</div>
</div>
</div>
<button type="button" class="lt-btn lt-btn-sm dd-panels" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false">Panels<span class="dd-badge" id="panels-badge" hidden></span></button>
<button type="button" class="lt-btn lt-btn-sm dd-files-toggle" id="files-toggle" aria-controls="files" aria-expanded="false" title="Files: browse, preview and diff this session's folder (Ctrl+Shift+E)"><span class="dd-panels-label">Files</span><span class="dd-panels-icon" aria-hidden="true">🗀</span></button>
<button type="button" class="lt-btn lt-btn-sm dd-panels" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false"><span class="dd-panels-label">Panels</span><span class="dd-panels-icon" aria-hidden="true">▤</span></button>
</div>
</header>
@@ -79,6 +90,29 @@
</div>
</section>
<section class="lt-frame dd-files" id="files" aria-label="Files" hidden>
<div class="dd-files-resizer" id="files-resizer" role="separator" aria-orientation="vertical" aria-label="Resize the files pane" tabindex="0"></div>
<div class="dd-files-head">
<button type="button" class="dd-icon-btn dd-explorer-toggle" title="Show / hide the explorer" aria-label="Toggle explorer">☰</button>
<div class="dd-tabs" role="tablist" aria-label="Open files"></div>
<span class="dd-files-actions">
<button type="button" class="dd-icon-btn dd-files-find" title="Go to file (Ctrl+P)" aria-label="Go to file">⌕</button>
<button type="button" class="dd-icon-btn dd-files-refresh" title="Refresh" aria-label="Refresh">↻</button>
<button type="button" class="dd-icon-btn dd-files-close" title="Close (Esc)" aria-label="Close files">✕</button>
</span>
</div>
<div class="dd-files-main">
<div class="dd-explorer">
<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>
</div>
<div class="dd-explorer-body" role="tree" aria-label="Files"></div>
</div>
<div class="dd-editor-area"></div>
</div>
</section>
<div class="dd-backdrop" id="backdrop" hidden></div>
<aside id="sidebar" class="dd-sidebar" aria-label="Panels">
<section class="lt-frame dd-widget" id="widget-sessions" data-state="live">
+74 -1
View File
@@ -152,13 +152,21 @@ async function runCommandNow(args, timeoutMs) {
clearTimeout(timer);
resolve(new TextDecoder().decode(concat(chunks)));
};
const decoder = new TextDecoder();
let head = '';
let tail = '';
socket.onmessage = (event) => {
const { type, payload } = decodeServer(event.data);
if (type !== Server.OUTPUT) return;
chunks.push(payload.slice());
const piece = decoder.decode(payload, { stream: true });
if (head.length < 4096) head += piece;
tail = (tail + piece).slice(-64);
// Hang up as soon as the answer is complete: devdesk waits for that
// before exiting, so ttyd can't drop its last output (devdesk#35).
if (isComplete(new TextDecoder().decode(concat(chunks)))) {
// Replies are one JSON line (or a devdesk: error), so only a chunk that
// ends a line with "}" is worth a full parse; big replies stay linear.
if (/^devdesk: .*\n/m.test(head) || (/\}\s*\n\s*$/.test(tail) && isComplete(new TextDecoder().decode(concat(chunks))))) {
finish();
socket.close();
}
@@ -258,6 +266,71 @@ async function uploadNow(session, file, onProgress) {
});
}
// Blob replies (`--fs-read`, `--git-show`, `--asset`): a JSON header line, then
// base64 between DEVDESK-BLOB-BEGIN/END. Resolves { header, base64 }, base64
// being null when the file has no body (too large, binary). Only the head and
// the tail of the stream are decoded while it arrives, so megabytes stay cheap.
export function runBlob(args, timeoutMs = 30000) {
return limited(() => runBlobNow(args, timeoutMs));
}
async function runBlobNow(args, timeoutMs) {
const token = await fetchToken();
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
socket.binaryType = 'arraybuffer';
const chunks = [];
const decoder = new TextDecoder();
let head = '';
let tail = '';
let settled = false;
const done = (fn, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.close();
fn(value);
};
const timer = setTimeout(() => done(reject, new Error('devdesk did not answer in time')), timeoutMs);
const finish = () => {
const text = stripAnsi(new TextDecoder().decode(concat(chunks)));
const error = /^devdesk: (.*)$/m.exec(text);
const line = text.split(/\r?\n/).find((l) => l.startsWith('{'));
if (!line) {
done(reject, new Error(error ? error[1] : 'no reply'));
return;
}
let header;
try {
header = JSON.parse(line);
} catch {
done(reject, new Error('unreadable reply'));
return;
}
const body = /DEVDESK-BLOB-BEGIN\r?\n([\s\S]*?)\r?\nDEVDESK-BLOB-END/.exec(text);
done(resolve, { header, base64: body ? body[1].replace(/\s+/g, '') : null });
};
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => {
const { type, payload } = decodeServer(event.data);
if (type !== Server.OUTPUT) return;
chunks.push(payload.slice());
const piece = decoder.decode(payload, { stream: true });
if (head.length < 4096) head += piece;
tail = (tail + piece).slice(-256);
const firstLine = /^\s*(\{.*\})\r?$/m.exec(head);
if (tail.includes('DEVDESK-BLOB-END') || /^devdesk: .*\n/m.test(head)
|| (firstLine && !head.includes('DEVDESK-BLOB-BEGIN') && /"tooLarge":true/.test(firstLine[1]))) {
finish();
}
};
socket.onerror = () => {};
socket.onclose = () => {
if (!settled) finish();
};
});
}
// Git + Gitea CI status for a session's folder (`devdesk attach --repo NAME`).
export async function fetchRepo(session) {
return parseListOutput(await runCommand(['--repo', session]));
+943
View File
@@ -0,0 +1,943 @@
/* global __EDITOR_SHA__ */
// Files pane (devdesk#19, #37), laid out like VS Code: an explorer tree (or the
// git changes list) beside an editor area with tabs. Read the session's files
// with syntax highlighting, preview markdown and images, diff against HEAD,
// see what Claude edited, and jump to any file by name. Read-only for now.
// Everything goes through `devdesk attach --fs-*` over ttyd.
import { runBlob, runCommand } from './connection.js';
import { parseListOutput } from './protocol.js';
import { el } from './dom.js';
import { ago } from './sessions.js';
import { CommandPalette, toast } from './ui.js';
// ---------------------------------------------------------------- editor bundle
const DB = 'devdesk';
const STORE = 'assets';
function idb(mode, run) {
return new Promise((resolve, reject) => {
const open = indexedDB.open(DB, 1);
open.onupgradeneeded = () => open.result.createObjectStore(STORE);
open.onerror = () => reject(open.error);
open.onsuccess = () => {
const tx = open.result.transaction(STORE, mode);
const request = run(tx.objectStore(STORE));
tx.oncomplete = () => {
open.result.close();
resolve(request?.result);
};
tx.onerror = () => reject(tx.error);
};
});
}
async function sha256(text) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
function decodeBase64(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
return bytes;
}
// The editor (~600 KB) is not in the page: it's fetched over ttyd the first time
// the pane opens, checked against the hash this page was built with, cached in
// IndexedDB under that hash, and run.
let editorLoading = null;
export function loadEditor() {
editorLoading ??= (async () => {
if (window.DevDeskEditor) return window.DevDeskEditor;
let code = null;
try {
code = await idb('readonly', (store) => store.get(__EDITOR_SHA__));
} catch {
// No IndexedDB (private mode): fetch every time.
}
if (!code) {
const { base64 } = await runBlob(['--asset', 'editor.js'], 60000);
if (!base64) throw new Error('the editor bundle is missing on the server');
code = new TextDecoder().decode(decodeBase64(base64));
if (crypto.subtle && (await sha256(code)) !== __EDITOR_SHA__) {
throw new Error('the server has a different DevDesk version: reload the page');
}
idb('readwrite', (store) => {
store.clear();
store.put(code, __EDITOR_SHA__);
}).catch(() => {});
}
const script = document.createElement('script');
script.textContent = code;
document.head.append(script);
script.remove();
if (!window.DevDeskEditor) throw new Error('the editor failed to start');
return window.DevDeskEditor;
})().catch((error) => {
editorLoading = null;
throw error;
});
return editorLoading;
}
// ---------------------------------------------------------------- helpers
const size = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`);
const parent = (path) => path.split('/').slice(0, -1).join('/');
const isMarkdown = (path) => /\.(md|markdown|mdx)$/i.test(path);
const STATUS = {
M: ['M', 'modified', 'amber'], A: ['A', 'added', 'green'], D: ['D', 'deleted', 'red'], R: ['R', 'renamed', 'cyan'],
C: ['C', 'copied', 'cyan'], U: ['U', 'conflict', 'red'], '?': ['N', 'new', 'green'], ' ': ['·', 'edited by Claude', 'cyan'],
};
function statusOf(f) {
const code = f.x === '?' ? '?' : f.x !== ' ' ? f.x : f.y;
return STATUS[code] ?? STATUS.M;
}
// Markdown preview document: sanitized HTML in a sandboxed iframe (no scripts,
// no same-origin), styled from the page's current TDS colours.
function previewDocument(html) {
const css = getComputedStyle(document.documentElement);
const v = (name) => css.getPropertyValue(name).trim();
return `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
:root { color-scheme: ${document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}; }
body { margin: 0; padding: 18px 22px 40px; background: ${v('--bg-terminal')}; color: ${v('--text-primary')};
font: 14px/1.65 ${v('--font-mono') || 'monospace'}; overflow-wrap: anywhere; }
h1, h2, h3, h4 { color: ${v('--accent-cyan')}; line-height: 1.3; margin: 1.4em 0 0.5em; }
h1 { font-size: 1.45em; border-bottom: 1px solid ${v('--border-color')}; padding-bottom: 0.3em; }
h2 { font-size: 1.2em; border-bottom: 1px solid ${v('--border-dim')}; padding-bottom: 0.2em; }
a { color: ${v('--accent-orange')}; }
code { background: ${v('--bg-tertiary')}; padding: 1px 5px; color: ${v('--accent-amber')}; font-family: inherit; }
pre { background: ${v('--bg-secondary')}; border: 1px solid ${v('--border-dim')}; padding: 10px 12px; overflow-x: auto; }
pre code { background: none; padding: 0; color: ${v('--text-primary')}; }
blockquote { margin: 0.8em 0; padding: 2px 14px; border-left: 3px solid ${v('--accent-cyan')}; color: ${v('--text-secondary')}; }
table { border-collapse: collapse; margin: 0.8em 0; } th, td { border: 1px solid ${v('--border-color')}; padding: 4px 10px; }
th { background: ${v('--bg-secondary')}; } hr { border: 0; border-top: 1px solid ${v('--border-color')}; }
img { max-width: 100%; } li { margin: 0.2em 0; } input[type=checkbox] { margin-right: 6px; }
</style></head><body>${html}</body></html>`;
}
// Relative <img> in a markdown file (e.g. docs/screenshot.png) can't load from
// the sandboxed preview: fetch up to 10 of them over ttyd and inline them.
async function inlineImages(html, session, mdPath) {
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
const images = [...doc.querySelectorAll('img[src]')]
.filter((img) => !/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(img.getAttribute('src'))).slice(0, 10);
if (!images.length) return html;
const base = parent(mdPath);
await Promise.all(images.map(async (img) => {
const parts = [];
for (const part of `${base}/${decodeURIComponent(img.getAttribute('src').split(/[?#]/)[0])}`.split('/')) {
if (part === '..') parts.pop();
else if (part && part !== '.') parts.push(part);
}
try {
const { header, base64 } = await runBlob(['--fs-read', session, parts.join('/')]);
if (header.mime?.startsWith('image/') && base64) img.setAttribute('src', `data:${header.mime};base64,${base64}`);
} catch {
// missing image: leave the broken link
}
}));
return doc.body.innerHTML;
}
// ---------------------------------------------------------------- prefs
function readJson(key, fallback) {
try {
const value = JSON.parse(localStorage.getItem(`devdesk.files.${key}`));
return value ?? fallback;
} catch {
return fallback;
}
}
function writeJson(key, value) {
try {
localStorage.setItem(`devdesk.files.${key}`, JSON.stringify(value));
} catch {
// Private mode: the state lasts until the tab closes.
}
}
// ---------------------------------------------------------------- icons
// A two-character badge per file type (VS Code's icon idea, in the terminal style).
const ICONS = [
[/\.(md|markdown|mdx)$/i, 'M↓', 'cyan'],
[/\.(js|mjs|cjs|jsx)$/i, 'JS', 'amber'],
[/\.(ts|tsx|mts)$/i, 'TS', 'blue'],
[/\.(json|jsonc|webmanifest)$/i, '{}', 'amber'],
[/\.(py|pyi)$/i, 'PY', 'blue'],
[/\.(sh|bash|zsh|bats|env)$|^\.env/i, '$_', 'green'],
[/\.(ya?ml)$/i, 'YM', 'purple'],
[/\.(toml|ini|cfg|conf|properties)$/i, '⚙', 'muted'],
[/\.(service|timer|socket|mount)$/i, 'SD', 'muted'],
[/\.(html?|xml|svg|j2|jinja|php)$/i, '<>', 'orange'],
[/\.(css|scss)$/i, '#', 'blue'],
[/\.(png|jpe?g|gif|webp|ico|bmp)$/i, '▣', 'purple'],
[/\.(go)$/i, 'GO', 'cyan'],
[/\.(rs)$/i, 'RS', 'orange'],
[/\.(sql)$/i, 'DB', 'amber'],
[/\.(lock)$|-lock\.json$/i, '≡', 'muted'],
[/^(dockerfile|.*\.dockerfile)$/i, 'DK', 'cyan'],
[/^(makefile|gnumakefile)$/i, 'MK', 'orange'],
[/^(license|copying)(\..*)?$/i, '©', 'muted'],
[/^\.git/i, '±', 'muted'],
[/\.(diff|patch)$/i, '±', 'green'],
[/\.(txt|log)$/i, '≡', 'muted'],
];
function iconFor(name) {
for (const [re, text, tone] of ICONS) if (re.test(name)) return [text, tone];
return ['·', 'muted'];
}
const base = (path) => path.split('/').pop();
// Fuzzy "go to file": every query character in order; consecutive runs, the
// file name, and starts of segments score higher; shorter paths break ties.
export function fuzzyScore(path, query) {
if (!query) return 1;
const p = path.toLowerCase();
const q = query.toLowerCase().replace(/\s+/g, '');
const nameStart = p.lastIndexOf('/') + 1;
let score = 0;
let last = -1;
let run = 0;
for (const ch of q) {
const i = p.indexOf(ch, last + 1);
if (i < 0) return null;
run = i === last + 1 ? run + 1 : 0;
score += 1 + run * 3 + (i >= nameStart ? 4 : 0) + (i === 0 || p[i - 1] === '/' || p[i - 1] === '.' || p[i - 1] === '-' || p[i - 1] === '_' ? 3 : 0);
last = i;
}
if (p.slice(nameStart).includes(q)) score += 20;
return score - path.length / 100;
}
// ---------------------------------------------------------------- pane
export class FilesPane {
// hooks: session() -> name, paste(text), onToggle(open), focusTerminal()
constructor(root, hooks) {
this.root = root;
this.hooks = hooks;
this.session = null;
this.listings = new Map(); // dir -> listing (per session; cleared on switch)
this.loadingDirs = new Set();
this.changes = null;
this.tabs = []; // { path, mode, pinned }
this.active = null;
this.views = new Map(); // tab key -> { node, editor, header, base64, stale }
this.view = 'explorer';
this.expanded = new Set();
this.focusIndex = 0;
this.wrap = readJson('wrap', false);
this.fileList = null; // go-to-file cache { at, files }
this.explorer = root.querySelector('.dd-explorer-body');
this.tabStrip = root.querySelector('.dd-tabs');
this.editorArea = root.querySelector('.dd-editor-area');
this.count = root.querySelector('.dd-files-count');
this.viewButtons = [...root.querySelectorAll('[data-files-view]')];
for (const button of this.viewButtons) button.addEventListener('click', () => this.setView(button.dataset.filesView));
root.querySelector('.dd-files-refresh').addEventListener('click', () => this.refresh());
root.querySelector('.dd-files-close').addEventListener('click', () => this.close());
root.querySelector('.dd-files-find').addEventListener('click', () => this.quickOpen.open());
root.querySelector('.dd-explorer-toggle').addEventListener('click', () => this.setExplorer(!this.explorerShown()));
this.explorer.addEventListener('keydown', (event) => this.treeKey(event));
root.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.code === 'KeyP') {
event.preventDefault();
this.quickOpen.open();
}
});
this.wireExplorerResize();
// Narrow pane (phone, or dragged small): the explorer slides over the editor.
new ResizeObserver(() => root.classList.toggle('is-narrow', root.clientWidth < 680)).observe(root);
this.quickOpen = new CommandPalette(() => this.loadFileList(), {
id: 'dd-goto', placeholder: 'Go to file… (type part of a name)',
search: (files, query) => (query
? files.map((c) => ({ c, s: fuzzyScore(c.id, query) })).filter((x) => x.s !== null).sort((a, b) => b.s - a.s).slice(0, 60).map((x) => x.c)
: [...this.recent().map((p) => files.find((c) => c.id === p)).filter(Boolean), ...files.slice(0, 40)].slice(0, 40)),
});
}
get isOpen() {
return !this.root.hidden;
}
// Drag the explorer's right edge; the width is remembered per browser.
wireExplorerResize() {
const handle = el('div', { class: 'dd-explorer-resizer', role: 'separator', 'aria-orientation': 'vertical', 'aria-label': 'Resize the explorer' });
this.root.querySelector('.dd-explorer').after(handle);
const apply = (px) => {
const width = Math.round(Math.max(160, Math.min(this.root.clientWidth * 0.6, px)));
this.root.style.setProperty('--dd-explorer-w', `${width}px`);
return width;
};
const saved = readJson('explorerWidth', 0);
if (saved) apply(saved);
handle.addEventListener('pointerdown', (event) => {
event.preventDefault();
handle.setPointerCapture(event.pointerId);
document.body.classList.add('dd-resizing');
const left = this.root.querySelector('.dd-explorer').getBoundingClientRect().left;
const move = (e) => apply(e.clientX - left);
const up = () => {
document.body.classList.remove('dd-resizing');
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
writeJson('explorerWidth', this.root.querySelector('.dd-explorer').getBoundingClientRect().width);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
});
}
// ------------------------------------------------------------ open / close / session
open(view) {
this.root.hidden = false;
this.hooks.onToggle(true);
this.syncSession();
if (view) this.setView(view === 'files' ? 'explorer' : view);
else this.render();
this.revalidate();
loadEditor().catch(() => {}); // warm up
}
close() {
this.root.hidden = true;
this.hooks.onToggle(false);
this.hooks.focusTerminal();
}
toggle(view) {
if (this.isOpen && (!view || (view === 'changes') === (this.view === 'changes'))) this.close();
else this.open(view);
}
escape() {
if (this.root.classList.contains('is-narrow') && this.explorerShown() && this.active) this.setExplorer(false);
else this.close();
}
stateKey() {
return `state.${this.session}`;
}
saveState() {
if (!this.session) return;
writeJson(this.stateKey(), {
expanded: [...this.expanded], view: this.view, active: this.active,
tabs: this.tabs.filter((t) => t.pinned || t.key === this.active).map(({ path, mode }) => ({ path, mode })),
});
}
syncSession() {
const session = this.hooks.session();
if (session === this.session) return false;
for (const v of this.views.values()) v.editor?.destroy();
this.views.clear();
this.listings.clear();
this.changes = null;
this.fileList = null;
this.session = session;
const saved = session ? readJson(this.stateKey(), {}) : {};
this.expanded = new Set(saved.expanded ?? []);
this.view = saved.view ?? 'explorer';
this.tabs = (saved.tabs ?? []).map((t) => ({ ...t, key: `${t.mode}:${t.path}`, pinned: true }));
this.active = this.tabs.some((t) => t.key === saved.active) ? saved.active : (this.tabs[0]?.key ?? null);
return true;
}
sessionChanged() {
if (this.isOpen && this.syncSession()) {
this.render();
this.revalidate();
}
}
// ------------------------------------------------------------ data
async listDir(dir) {
if (this.loadingDirs.has(dir)) return;
const session = this.session;
this.loadingDirs.add(dir);
try {
const listing = parseListOutput(await runCommand(['--fs-list', session, dir || '.'], 15000));
if (session !== this.session) return;
this.listings.set(dir, listing);
} catch (error) {
if (session !== this.session) return;
if (dir) {
this.expanded.delete(dir);
toast.error(`${dir}: ${error.message}`);
} else {
this.listings.set('', { error: error.message, entries: [] });
}
} finally {
this.loadingDirs.delete(dir);
}
if (session === this.session) this.renderExplorer();
}
async loadChanges() {
const session = this.session;
try {
const changes = parseListOutput(await runCommand(['--git-status', session], 15000));
if (session !== this.session) return;
this.changes = changes;
} catch {
return;
}
const n = this.changes.files?.length ?? 0;
this.count.textContent = n ? String(n) : '';
this.renderExplorer();
this.renderTabs();
if (!this.active) this.renderEditor();
}
async loadFileList() {
if (this.fileList && Date.now() - this.fileList.at < 60000 && this.fileList.session === this.session) return this.fileList.items;
const list = parseListOutput(await runCommand(['--fs-find', this.session], 45000));
const items = list.files.map((path) => {
const name = base(path);
const [icon] = iconFor(name);
return { id: path, label: name, hint: path.includes('/') ? path.slice(0, path.length - name.length - 1) : '', icon, group: 'Files', run: () => this.openFile(path, { pin: true }) };
});
this.fileList = { at: Date.now(), session: this.session, items };
if (list.truncated) toast.info(`Go to file searches the first ${list.files.length} files`);
return items;
}
// Refresh what's visible: the tree's open folders, git status, the active file.
revalidate() {
if (!this.session) return;
this.listDir('');
for (const dir of this.expanded) this.listDir(dir);
this.loadChanges();
}
refresh() {
this.fileList = null;
this.revalidate();
if (this.active) this.reloadTab(this.active, { quiet: false });
}
async claudeTurnEnded() {
if (!this.isOpen) {
// Next time it opens it revalidates anyway; open tabs re-check on activation.
for (const v of this.views.values()) v.stale = true;
return;
}
this.fileList = null;
this.revalidate();
for (const [key, v] of this.views) if (key !== this.active) v.stale = true;
if (this.active) this.reloadTab(this.active, { quiet: true });
}
changeFor(path) {
return this.changes?.files?.find((f) => f.path === path) ?? null;
}
recent() {
return readJson(`recent.${this.session}`, []);
}
pushRecent(path) {
writeJson(`recent.${this.session}`, [path, ...this.recent().filter((p) => p !== path)].slice(0, 8));
}
// ------------------------------------------------------------ layout
explorerShown() {
return !this.root.classList.contains('explorer-hidden');
}
setExplorer(show) {
this.root.classList.toggle('explorer-hidden', !show);
if (show) this.focusTree();
}
setView(view) {
this.view = view;
this.saveState();
this.setExplorer(true);
this.render();
if (view === 'changes') this.loadChanges();
}
render() {
for (const b of this.viewButtons) b.setAttribute('aria-selected', String(b.dataset.filesView === this.view));
this.renderExplorer();
this.renderTabs();
this.renderEditor();
}
// ------------------------------------------------------------ explorer
// Visible rows: the root listing plus every expanded folder's children.
rows() {
const out = [];
const walk = (dir, depth) => {
const listing = this.listings.get(dir);
if (!listing) {
out.push({ kind: 'loading', depth, dir });
return;
}
for (const e of listing.entries) {
const path = dir ? `${dir}/${e.n}` : e.n;
const isDir = e.t === 'd' || e.to === 'd';
out.push({ kind: isDir ? 'dir' : 'file', depth, path, entry: e });
if (isDir && this.expanded.has(path)) walk(path, depth + 1);
}
if (listing.truncated) out.push({ kind: 'note', depth, text: `first ${listing.entries.length} entries only` });
};
walk('', 0);
return out;
}
renderExplorer() {
if (!this.session) {
this.explorer.replaceChildren(el('div', { class: 'dd-files-msg' }, 'Open a session to browse its files.'));
return;
}
if (this.view === 'changes') {
this.renderChangesList();
return;
}
const root = this.listings.get('');
if (root?.error) {
this.explorer.replaceChildren(el('div', { class: 'dd-files-msg is-error' }, root.error));
return;
}
const activePath = this.tabs.find((t) => t.key === this.active)?.path;
const changed = new Map((this.changes?.files ?? []).filter((f) => f.path).map((f) => [f.path, f]));
this.treeRows = this.rows();
this.focusIndex = Math.min(this.focusIndex, Math.max(0, this.treeRows.length - 1));
const nodes = this.treeRows.map((row, i) => {
const indent = { style: `padding-left:${8 + row.depth * 14}px` };
if (row.kind === 'loading') return el('div', { class: 'dd-tree-row is-note', ...indent }, 'Loading…');
if (row.kind === 'note') return el('div', { class: 'dd-tree-row is-note', ...indent }, row.text);
const { entry, path } = row;
const isDir = row.kind === 'dir';
const open = isDir && this.expanded.has(path);
const change = isDir ? null : changed.get(path);
const dirty = isDir && [...changed.keys()].some((p) => p.startsWith(`${path}/`));
const [icon, tone] = isDir ? [open ? '▾' : '▸', 'dir'] : iconFor(entry.n);
const status = change ? statusOf(change) : null;
return el('div', {
class: `dd-tree-row${isDir ? ' is-dir' : ''}${entry.ig ? ' is-ignored' : ''}${path === activePath ? ' is-active' : ''}${status ? ` git-${status[2]}` : ''}${dirty ? ' git-dirty' : ''}`,
role: 'treeitem', 'aria-expanded': isDir ? String(open) : null, 'aria-level': String(row.depth + 1),
tabindex: i === this.focusIndex ? '0' : '-1', 'data-index': String(i),
title: `${path}${entry.t === 'l' ? ' (symlink)' : ''}${status ? ` · ${status[1]}` : ''}${change?.claude ? ' · edited by Claude' : ''}`,
...indent,
onclick: () => {
this.focusIndex = i;
if (isDir) this.toggleDir(path);
else this.openFile(path);
},
ondblclick: () => {
if (!isDir) this.pin(path);
},
},
el('span', { class: `dd-ficon tone-${tone}`, 'aria-hidden': 'true' }, icon),
el('span', { class: 'dd-tree-name' }, entry.n),
change?.claude ? el('span', { class: 'dd-claude-chip', title: 'Claude edited this file' }, 'C') : null,
status ? el('span', { class: `dd-file-status is-${status[2]}` }, status[0]) : null,
dirty ? el('span', { class: 'dd-dirty-dot', title: 'contains changes' }) : null);
});
if (!nodes.length) nodes.push(el('div', { class: 'dd-files-msg' }, 'Empty folder.'));
const hadFocus = this.explorer.contains(document.activeElement);
this.explorer.setAttribute('role', 'tree');
this.explorer.replaceChildren(...nodes);
if (hadFocus) this.focusTree();
}
toggleDir(path, open = !this.expanded.has(path)) {
if (open) {
this.expanded.add(path);
if (!this.listings.has(path)) this.listDir(path);
else this.listDir(path); // revalidate in the background, show the cached one now
} else {
this.expanded.delete(path);
}
this.saveState();
this.renderExplorer();
}
focusTree() {
const row = this.explorer.querySelector(`[data-index="${this.focusIndex}"]`) ?? this.explorer.querySelector('[data-index]');
row?.focus();
}
// ↑/↓ move, → open folder / step in, ← close folder / step out, Enter/Space open.
treeKey(event) {
if (this.view !== 'explorer' || !this.treeRows?.length) return;
const row = this.treeRows[this.focusIndex];
const move = (i) => {
this.focusIndex = Math.max(0, Math.min(this.treeRows.length - 1, i));
for (const node of this.explorer.querySelectorAll('[data-index]')) node.tabIndex = Number(node.dataset.index) === this.focusIndex ? 0 : -1;
this.focusTree();
};
switch (event.key) {
case 'ArrowDown': move(this.focusIndex + 1); break;
case 'ArrowUp': move(this.focusIndex - 1); break;
case 'Home': move(0); break;
case 'End': move(this.treeRows.length - 1); break;
case 'ArrowRight':
if (row?.kind === 'dir') {
if (!this.expanded.has(row.path)) this.toggleDir(row.path, true);
else move(this.focusIndex + 1);
}
break;
case 'ArrowLeft':
if (row?.kind === 'dir' && this.expanded.has(row.path)) this.toggleDir(row.path, false);
else if (row?.path?.includes('/')) {
const up = row.path.split('/').slice(0, -1).join('/');
move(this.treeRows.findIndex((r) => r.path === up));
}
break;
case 'Enter':
case ' ':
if (row?.kind === 'dir') this.toggleDir(row.path);
else if (row?.kind === 'file') this.openFile(row.path, { pin: event.key === 'Enter' });
break;
default:
return;
}
event.preventDefault();
}
// Expand the folders above a file and scroll it into view.
reveal(path) {
const parts = path.split('/');
let changed = false;
for (let i = 1; i < parts.length; i += 1) {
const dir = parts.slice(0, i).join('/');
if (!this.expanded.has(dir)) {
this.expanded.add(dir);
changed = true;
if (!this.listings.has(dir)) this.listDir(dir);
}
}
if (changed) this.saveState();
if (this.view !== 'explorer') this.setView('explorer');
else this.renderExplorer();
this.explorer.querySelector('.is-active')?.scrollIntoView({ block: 'nearest' });
}
renderChangesList() {
if (!this.changes) {
this.explorer.replaceChildren(el('div', { class: 'dd-files-msg' }, 'Loading…'));
return;
}
const { files = [], git, branch } = this.changes;
const head = el('div', { class: 'dd-changes-head' }, git ? `${branch} · ${files.length} uncommitted` : 'Not a git repository · files Claude edited');
if (!files.length) {
this.explorer.replaceChildren(head, el('div', { class: 'dd-files-msg' }, git ? 'No uncommitted changes.' : 'Claude hasn\'t edited files here yet.'));
return;
}
const activePath = this.tabs.find((t) => t.key === this.active)?.path;
const rows = files.map((f) => {
const [letter, label, tone] = statusOf(f);
const path = f.path ?? f.repoPath;
const name = base(path);
const dir = path.slice(0, path.length - name.length).replace(/\/$/, '');
const openable = f.path !== null && f.path !== undefined;
const canDiff = git && f.x !== '?' && f.y !== '?';
return el('div', {
class: `dd-tree-row dd-change-row git-${tone}${path === activePath ? ' is-active' : ''}${openable ? '' : ' is-disabled'}`,
tabindex: '0', role: 'button',
title: `${path} · ${label}${f.from ? ` from ${f.from}` : ''}${f.claude ? ' · edited by Claude' : ''}${openable ? '' : ' (outside this folder)'}`,
onclick: () => openable && this.openFile(f.path, { mode: canDiff ? 'diff' : undefined }),
onkeydown: (event) => {
if (event.key === 'Enter' && openable) this.openFile(f.path, { mode: canDiff ? 'diff' : undefined, pin: true });
},
},
el('span', { class: `dd-ficon tone-${iconFor(name)[1]}`, 'aria-hidden': 'true' }, iconFor(name)[0]),
el('span', { class: 'dd-tree-name' }, name, dir ? el('span', { class: 'dd-change-dir' }, ` ${dir}`) : null),
f.claude ? el('span', { class: 'dd-claude-chip', title: 'Claude edited this file' }, 'C') : null,
f.add !== null && f.add !== undefined ? el('span', { class: 'dd-add' }, `+${f.add}`) : null,
f.del !== null && f.del !== undefined ? el('span', { class: 'dd-del' }, `−${f.del}`) : null,
el('span', { class: `dd-file-status is-${tone}` }, letter));
});
this.explorer.setAttribute('role', 'list');
this.explorer.replaceChildren(head, ...rows);
}
// ------------------------------------------------------------ tabs
// Single click opens a preview tab (italic) that the next click replaces;
// double click, Enter or go-to-file keeps it.
openFile(path, { mode, pin = false } = {}) {
const wanted = mode ?? (isMarkdown(path) ? 'preview' : 'view');
const key = `${wanted}:${path}`;
let tab = this.tabs.find((t) => t.key === key) ?? this.tabs.find((t) => t.path === path && !mode);
if (!tab) {
const preview = this.tabs.findIndex((t) => !t.pinned);
tab = { key, path, mode: wanted, pinned: pin };
if (preview >= 0 && !pin) {
this.dropView(this.tabs[preview].key);
this.tabs[preview] = tab;
} else {
this.tabs.push(tab);
while (this.tabs.length > 12) this.closeTab(this.tabs.find((t) => t.key !== key).key, { render: false });
}
} else if (pin) {
tab.pinned = true;
}
this.pushRecent(path);
this.activate(tab.key);
if (this.root.classList.contains('is-narrow')) this.setExplorer(false);
}
pin(path) {
const tab = this.tabs.find((t) => t.path === path);
if (tab) {
tab.pinned = true;
this.saveState();
this.renderTabs();
}
}
activate(key) {
this.active = key;
this.saveState();
this.renderTabs();
this.renderEditor();
this.renderExplorer();
}
closeTab(key, { render = true } = {}) {
const i = this.tabs.findIndex((t) => t.key === key);
if (i < 0) return;
this.tabs.splice(i, 1);
this.dropView(key);
if (this.active === key) this.active = (this.tabs[i] ?? this.tabs[i - 1])?.key ?? null;
this.saveState();
if (render) {
this.renderTabs();
this.renderEditor();
this.renderExplorer();
}
}
dropView(key) {
this.views.get(key)?.editor?.destroy();
this.views.get(key)?.node.remove();
this.views.delete(key);
}
renderTabs() {
const nodes = this.tabs.map((tab) => {
const name = base(tab.path);
const change = this.changeFor(tab.path);
const status = change ? statusOf(change) : null;
return el('div', {
class: `dd-tab${tab.key === this.active ? ' is-active' : ''}${tab.pinned ? '' : ' is-preview'}${status ? ` git-${status[2]}` : ''}`,
role: 'tab', 'aria-selected': String(tab.key === this.active), tabindex: tab.key === this.active ? '0' : '-1',
title: `${tab.path}${tab.mode === 'diff' ? ' (diff against HEAD)' : ''}${tab.pinned ? '' : ' · preview: the next file replaces it; double-click to keep'}`,
onclick: () => this.activate(tab.key),
ondblclick: () => this.pin(tab.path),
onauxclick: (event) => {
if (event.button === 1) this.closeTab(tab.key);
},
onkeydown: (event) => {
if (event.key === 'Enter') this.activate(tab.key);
},
},
el('span', { class: `dd-ficon tone-${iconFor(name)[1]}`, 'aria-hidden': 'true' }, iconFor(name)[0]),
el('span', { class: 'dd-tab-name' }, name),
tab.mode === 'diff' ? el('span', { class: 'dd-tab-tag' }, 'diff') : null,
el('button', {
type: 'button', class: 'dd-tab-close', 'aria-label': `Close ${name}`, title: 'Close',
onclick: (event) => {
event.stopPropagation();
this.closeTab(tab.key);
},
}, '×'));
});
this.tabStrip.replaceChildren(...nodes);
this.tabStrip.querySelector('.is-active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
// ------------------------------------------------------------ editor
renderEditor() {
for (const [key, v] of this.views) v.node.hidden = key !== this.active;
let empty = this.editorArea.querySelector('.dd-editor-empty');
if (!this.active) {
if (!empty) {
empty = el('div', { class: 'dd-editor-empty' });
this.editorArea.append(empty);
}
empty.hidden = false;
this.renderEmpty(empty);
return;
}
if (empty) empty.hidden = true;
const view = this.views.get(this.active);
if (!view) this.loadTab(this.active);
else if (view.stale) this.reloadTab(this.active, { quiet: true });
}
renderEmpty(node) {
const recent = this.recent();
const n = this.changes?.files?.length ?? 0;
node.replaceChildren(...[
el('div', { class: 'dd-empty-title' }, this.session ? `No file open in ${this.session}` : 'No session'),
el('div', { class: 'dd-empty-actions' },
el('button', { type: 'button', class: 'dd-viewer-btn', onclick: () => this.quickOpen.open() }, 'Go to file… Ctrl+P'),
n ? el('button', { type: 'button', class: 'dd-viewer-btn', onclick: () => this.setView('changes') }, `Review ${n} change${n === 1 ? '' : 's'}`) : null),
recent.length ? el('div', { class: 'dd-empty-recent' },
el('div', { class: 'dd-empty-label' }, 'Recent'),
...recent.map((path) => el('button', { type: 'button', class: 'dd-recent', title: path, onclick: () => this.openFile(path, { pin: true }) },
el('span', { class: `dd-ficon tone-${iconFor(base(path))[1]}` }, iconFor(base(path))[0]),
el('span', { class: 'dd-recent-name' }, base(path)),
el('span', { class: 'dd-change-dir' }, path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '')))) : null,
el('div', { class: 'dd-empty-hint' }, 'Click a file for a preview tab, double-click to keep it open. Esc returns to the terminal.'),
].filter(Boolean));
}
async fetchTab(tab) {
const change = this.changeFor(tab.path);
const deleted = change && (change.x === 'D' || change.y === 'D');
const [blob, original] = await Promise.all([
deleted ? Promise.resolve({ header: { path: tab.path, size: 0, utf8: true, body: true }, base64: '' }) : runBlob(['--fs-read', this.session, tab.path]),
tab.mode === 'diff' ? runBlob(['--git-show', this.session, tab.path]) : Promise.resolve(null),
]);
return { blob, original };
}
async loadTab(key) {
const tab = this.tabs.find((t) => t.key === key);
if (!tab) return;
const node = el('div', { class: 'dd-tab-view' }, el('div', { class: 'dd-files-msg' }, 'Loading…'));
this.editorArea.append(node);
const view = { node, editor: null, header: null, stale: false };
this.views.set(key, view);
node.hidden = key !== this.active;
const session = this.session;
try {
const [{ blob, original }, editor] = await Promise.all([this.fetchTab(tab), loadEditor().catch((error) => ({ error }))]);
if (session !== this.session || this.views.get(key) !== view) return;
this.fill(view, tab, blob, original, editor);
} catch (error) {
if (this.views.get(key) !== view) return;
node.replaceChildren(this.bar(tab, null), el('div', { class: 'dd-files-msg is-error' }, error.message));
}
}
// Claude changed files: re-read; if the content differs, swap it in place
// and keep the scroll position (read-only, so nothing of yours is lost).
async reloadTab(key, { quiet }) {
const tab = this.tabs.find((t) => t.key === key);
const view = this.views.get(key);
if (!tab || !view) {
if (tab) this.loadTab(key);
return;
}
view.stale = false;
try {
const session = this.session;
const [{ blob, original }, editor] = await Promise.all([this.fetchTab(tab), loadEditor().catch((error) => ({ error }))]);
if (session !== this.session || this.views.get(key) !== view) return;
if (blob.header.sha && blob.header.sha === view.header?.sha && tab.mode !== 'diff') return;
const scroller = view.node.querySelector('.cm-scroller');
const top = scroller?.scrollTop ?? 0;
view.editor?.destroy();
this.fill(view, tab, blob, original, editor);
const next = view.node.querySelector('.cm-scroller');
if (next) next.scrollTop = top;
if (quiet && view.header?.sha !== blob.header.sha) view.node.querySelector('.dd-viewer-meta')?.classList.add('is-flash');
} catch {
// gone or unreadable: keep what's shown
}
}
bar(tab, header) {
const change = this.changeFor(tab.path);
const meta = header ? [
size(header.size ?? 0),
header.mtime ? `changed ${ago(header.mtime)}` : '',
header.utf8 === false ? 'Latin-1' : '',
tab.mode === 'diff' ? 'diff against HEAD' : '',
change ? statusOf(change)[1] : '',
change?.claude ? 'edited by Claude' : '',
].filter(Boolean).join(' · ') : '';
const button = (label, run, extra = {}) => el('button', { type: 'button', class: 'dd-viewer-btn', onclick: run, ...extra }, label);
const switchMode = (mode) => this.openFile(tab.path, { mode, pin: true });
const actions = [];
if (tab.mode !== 'diff' && isMarkdown(tab.path)) actions.push(button(tab.mode === 'preview' ? 'Source' : 'Preview', () => switchMode(tab.mode === 'preview' ? 'view' : 'preview')));
if (change && change.x !== '?' && change.y !== '?' && this.changes?.git) {
actions.push(button(tab.mode === 'diff' ? 'File' : 'Diff', () => switchMode(tab.mode === 'diff' ? (isMarkdown(tab.path) ? 'preview' : 'view') : 'diff')));
}
if (tab.mode !== 'preview') {
actions.push(button(this.wrap ? 'No wrap' : 'Wrap', () => {
this.wrap = !this.wrap;
writeJson('wrap', this.wrap);
for (const key of [...this.views.keys()]) this.dropView(key);
this.renderEditor();
}, { title: 'Wrap long lines' }));
}
actions.push(button('Reveal', () => this.reveal(tab.path), { title: 'Show in the explorer' }));
actions.push(button('Copy', () => navigator.clipboard?.writeText(tab.path).then(() => toast.success('Path copied'), () => toast.error('Clipboard refused')), { title: 'Copy the path' }));
actions.push(button('@Claude', () => {
this.hooks.paste(`@${tab.path} `);
toast.info(`Mentioned @${tab.path} in the prompt`);
}, { title: 'Type @path into the current prompt so Claude reads the file' }));
return el('div', { class: 'dd-viewer-bar' },
el('span', { class: 'dd-viewer-where', title: [tab.path, meta].filter(Boolean).join('\n') },
el('span', { class: 'dd-viewer-path' }, tab.path),
meta ? el('span', { class: 'dd-viewer-meta' }, ` · ${meta}`) : null),
el('span', { class: 'dd-viewer-actions' }, ...actions));
}
fill(view, tab, blob, original, editor) {
const { header, base64 } = blob;
view.header = header;
const text = (b64, utf8 = true) => new TextDecoder(utf8 ? 'utf-8' : 'latin1').decode(decodeBase64(b64 || ''));
const content = el('div', { class: 'dd-viewer-content' });
if (header.tooLarge) {
content.append(el('div', { class: 'dd-files-msg' }, `This file is ${size(header.size)}; the viewer opens files up to ${size(header.max)}.`));
} else if (header.mime?.startsWith('image/') && base64) {
content.classList.add('is-image');
content.append(el('img', { src: `data:${header.mime};base64,${base64}`, alt: tab.path }));
} else if (header.binary || !header.body) {
content.append(el('div', { class: 'dd-files-msg' }, `Binary file (${size(header.size)}).`));
} else if (editor?.error) {
content.append(el('pre', { class: 'dd-plain' }, text(base64, header.utf8)));
} else if (tab.mode === 'preview') {
content.classList.add('is-preview');
const frame = el('iframe', {
class: 'dd-md-frame', title: `Preview of ${tab.path}`,
sandbox: 'allow-popups allow-popups-to-escape-sandbox', srcdoc: previewDocument(editor.markdown(text(base64))),
});
content.append(frame);
inlineImages(editor.markdown(text(base64)), this.session, tab.path).then((html) => {
if (frame.isConnected) frame.srcdoc = previewDocument(html);
});
} else if (tab.mode === 'diff') {
view.editor = editor.diff(content, { original: text(original?.base64), modified: text(base64), path: tab.path, wrap: this.wrap });
} else {
view.editor = editor.viewer(content, { doc: text(base64, header.utf8), path: tab.path, wrap: this.wrap });
}
view.node.replaceChildren(this.bar(tab, header), content);
}
// Theme switch: the editor follows CSS variables; previews need a re-render.
themeChanged() {
for (const tab of this.tabs) {
if (tab.mode === 'preview') this.dropView(tab.key);
}
if (this.isOpen) this.renderEditor();
}
}
+179
View File
@@ -0,0 +1,179 @@
// The bell (house .lt-notif-* pattern): what needs you right now (waiting
// sessions, firing Grafana alerts) plus recent events you may have missed
// (a background session finished or started waiting, CI passed or failed).
// Events live in localStorage, so every tab shows the same inbox.
import { el } from './dom.js';
import { ago } from './sessions.js';
const KEY = 'devdesk.inbox';
const MAX = 30;
const MAX_AGE = 7 * 86400 * 1000;
function load() {
try {
const list = JSON.parse(localStorage.getItem(KEY) || '[]');
return Array.isArray(list) ? list.filter((e) => Date.now() - e.at < MAX_AGE) : [];
} catch {
return [];
}
}
function save(events) {
try {
localStorage.setItem(KEY, JSON.stringify(events.slice(0, MAX)));
} catch {
// Private mode: the inbox lasts as long as the tab.
}
}
const when = (ms) => {
const a = ago(Math.floor(ms / 1000));
return a === 'now' ? 'just now' : `${a} ago`;
};
const KINDS = {
waiting: { icon: '!', tone: 'red' },
finished: { icon: '✓', tone: 'green' },
'ci-pass': { icon: '✓', tone: 'green' },
'ci-fail': { icon: '✗', tone: 'red' },
};
export class Inbox {
// hooks: openSession(name), showAlerts()
constructor({ button, badge, panel, hooks }) {
Object.assign(this, { button, badge, panel, hooks });
this.events = load();
this.waiting = [];
this.alerts = [];
this.known = null; // session names that exist, once the list has loaded
this.list = panel.querySelector('.lt-notif-panel-list');
panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead());
window.addEventListener('storage', (event) => {
if (event.key === KEY) {
this.events = load();
this.render();
}
});
}
get isOpen() {
return !this.panel.hasAttribute('aria-hidden');
}
setOpen(open) {
if (open) this.panel.removeAttribute('aria-hidden');
else this.panel.setAttribute('aria-hidden', 'true');
this.button.setAttribute('aria-expanded', String(open));
if (open) this.render();
}
// Something happened. The same kind for the same session within a minute is
// one event (several tabs, quick flaps).
record({ kind, session, text, url }) {
const now = Date.now();
if (this.events.some((e) => e.kind === kind && e.session === session && now - e.at < 60000)) return;
this.events.unshift({ id: `${now}-${Math.random().toString(36).slice(2, 7)}`, kind, session, text, url, at: now, read: false });
this.events = this.events.slice(0, MAX);
save(this.events);
this.render();
}
// Live state: other sessions waiting on you, firing alerts.
setLive({ waiting, alerts, sessions }) {
if (waiting) this.waiting = waiting;
if (alerts) this.alerts = alerts;
if (sessions) this.known = new Set(sessions);
this.render();
}
markAllRead() {
this.events = this.events.map((e) => ({ ...e, read: true }));
save(this.events);
this.render();
}
unread() {
return this.events.filter((e) => !e.read && e.kind !== 'waiting' && !(this.known && e.session && !e.url && !this.known.has(e.session))).length;
}
render() {
const count = this.waiting.length + this.alerts.length + this.unread();
const urgent = this.waiting.length > 0 || this.alerts.some((a) => a.severity === 'critical');
this.badge.textContent = count ? String(count) : '';
this.badge.dataset.count = String(count);
this.badge.classList.toggle('is-calm', count > 0 && !urgent);
this.button.title = count
? [
this.waiting.length && `${this.waiting.length} session(s) need you`,
this.alerts.length && `${this.alerts.length} alert(s) firing`,
this.unread() && `${this.unread()} new event(s)`,
].filter(Boolean).join(' · ')
: 'Inbox: nothing new';
if (this.isOpen) this.renderPanel();
}
renderPanel() {
const close = (run) => () => {
this.setOpen(false);
run();
};
const item = ({ dot, title, meta, unread, onclick }) => el('div', {
class: `lt-notif-item${unread ? ' lt-notif-item--unread' : ''}`, role: 'button', tabindex: '0', onclick,
onkeydown: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onclick();
}
},
},
el('span', { class: `lt-notif-dot dd-notif-${dot}` }),
el('div', { class: 'lt-notif-item-body' },
el('div', { class: 'lt-notif-item-title' }, title),
meta ? el('div', { class: 'lt-notif-item-time' }, meta) : null));
const nodes = [];
if (this.waiting.length) {
nodes.push(el('div', { class: 'dd-notif-section' }, 'Needs you'));
for (const s of this.waiting) {
nodes.push(item({
dot: 'red', unread: true, title: `${s.name}: ${s.status?.message || 'Claude is waiting for your input'}`,
meta: s.status?.since ? `waiting ${ago(s.status.since)}` : 'waiting',
onclick: close(() => this.hooks.openSession(s.name)),
}));
}
}
if (this.alerts.length) {
nodes.push(el('div', { class: 'dd-notif-section' }, 'Ops alerts'));
for (const a of this.alerts.slice(0, 5)) {
nodes.push(item({
dot: a.severity === 'critical' ? 'red' : 'amber', title: `${a.severity ? `${a.severity}: ` : ''}${a.name}`,
meta: [a.state, a.summary].filter(Boolean).join(' · '), onclick: close(() => this.hooks.showAlerts()),
}));
}
if (this.alerts.length > 5) nodes.push(el('div', { class: 'dd-notif-more' }, `+${this.alerts.length - 5} more in the Ops Alerts panel`));
}
const recent = this.events.filter((e) => e.kind !== 'waiting' || !this.waiting.some((s) => s.name === e.session));
if (recent.length) {
nodes.push(el('div', { class: 'dd-notif-section' }, 'Recent'));
for (const e of recent.slice(0, 12)) {
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
// An entry about a session that has since ended must not bring it back (devdesk#38).
const ended = !e.url && e.session && this.known && !this.known.has(e.session);
nodes.push(item({
dot: e.read || ended ? 'read' : kind.tone, unread: !e.read && !ended, title: e.text,
meta: `${when(e.at)}${ended ? ' · session ended' : ''}`,
onclick: close(() => {
this.events = this.events.map((x) => (x.id === e.id ? { ...x, read: true } : x));
save(this.events);
if (e.url) window.open(e.url, '_blank', 'noopener');
else if (ended) this.hooks.ended?.(e.session);
else if (e.session) this.hooks.openSession(e.session);
}),
}));
}
}
if (!nodes.length) nodes.push(el('div', { class: 'dd-notif-empty' }, 'Nothing needs you. Sessions that finish or wait while you\'re elsewhere show up here.'));
this.list.replaceChildren(...nodes);
}
}
+184 -22
View File
@@ -11,6 +11,8 @@ import { renderRepo } from './repo.js';
import { bindAskClaude } from './widgets.js';
import { heardWithin, onShared, share } from './shared.js';
import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
import { Inbox } from './inbox.js';
import { FilesPane } from './files.js';
import { el } from './dom.js';
import * as notify from './notify.js';
@@ -52,11 +54,12 @@ const ui = {
moreMenu: $('more-menu'),
notifyButton: $('notify-toggle'),
sidebarButton: $('sidebar-toggle'),
panelsBadge: $('panels-badge'),
backdrop: $('backdrop'),
};
const urlSession = sessionFromSearch(window.location.search);
// Notification links (Matrix) add existing=1: open, never re-create (devdesk#38).
const urlExisting = new URLSearchParams(window.location.search).get('existing') === '1';
const state = {
session: urlSession, // null = picker
conn: null,
@@ -74,6 +77,8 @@ let term;
let fit;
let sessionsPanel;
let palette;
let inbox;
let files;
let lastRepo = null;
let lastAlerts = null;
let waitingOthers = [];
@@ -130,6 +135,7 @@ function renderSessionChip() {
// waiting (devdesk#15), and the Panels badge. Only the tab that fetched the list
// notifies, so several open tabs don't ring once each.
let prevWaiting = new Set();
const prevStatus = new Map();
function renderAttention(fetched) {
const data = sessionsPanel?.data;
waitingOthers = (data?.sessions ?? []).filter((s) => s.name !== state.session && sessionStatus(s).key === 'waiting');
@@ -137,6 +143,7 @@ function renderAttention(fetched) {
if (fetched && !prevWaiting.has(s.name)) notify.ring(s.name, s.status?.message || 'Claude needs your input');
}
prevWaiting = new Set(waitingOthers.map((s) => s.name));
if (fetched) recordTransitions(data?.sessions ?? []);
ui.attention.classList.toggle('is-active', waitingOthers.length > 0);
ui.attention.textContent = waitingOthers.length === 1 ? `${waitingOthers[0].name} needs you` : `${waitingOthers.length} need you`;
ui.attention.title = waitingOthers.map((s) => `${s.name}: ${s.status?.message || 'waiting'}`).join('\n');
@@ -144,17 +151,31 @@ function renderAttention(fetched) {
renderPanelsBadge();
}
// Inbox events for what happened while you were elsewhere: a session (other than
// the one you're looking at) finished a turn or started waiting. Only the tab
// that fetched the list records them; the inbox is shared via localStorage.
function recordTransitions(sessions) {
for (const s of sessions) {
const key = sessionStatus(s).key;
const prev = prevStatus.get(s.name);
prevStatus.set(s.name, key);
if (!prev || prev === key) continue;
const away = s.name !== state.session || document.hidden;
if (!away) continue;
if (key === 'waiting') {
inbox.record({ kind: 'waiting', session: s.name, text: `${s.name} needs you: ${s.status?.message || 'Claude is waiting for your input'}` });
} else if (prev === 'working' && key === 'idle') {
inbox.record({ kind: 'finished', session: s.name, text: `${s.name} finished` });
}
}
}
function renderPanelsBadge() {
const alerts = lastAlerts?.alerts ?? [];
const count = waitingOthers.length + alerts.length;
const urgent = waitingOthers.length > 0 || alerts.some((a) => a.severity === 'critical');
ui.panelsBadge.hidden = count === 0 || ui.body.classList.contains('dd-sidebar-open');
ui.panelsBadge.textContent = String(count);
ui.panelsBadge.classList.toggle('is-urgent', urgent);
ui.panelsBadge.title = [
waitingOthers.length ? `${waitingOthers.length} session(s) need you` : '',
alerts.length ? `${alerts.length} Grafana alert(s)` : '',
].filter(Boolean).join(' · ');
inbox?.setLive({
waiting: waitingOthers,
alerts: lastAlerts?.alerts ?? [],
sessions: sessionsPanel?.data ? sessionsPanel.data.sessions.map((x) => x.name) : null,
});
}
function onSessionData({ fetched = false } = {}) {
@@ -281,6 +302,7 @@ async function refreshRepo() {
const text = `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`;
notify.ring(session, text);
toast[ci === 'success' ? 'success' : 'error'](text);
inbox.record({ kind: ci === 'success' ? 'ci-pass' : 'ci-fail', session, text: `${session}: ${text}`, url: repo.ci?.url });
}
lastCi = { key, state: ci };
} catch {
@@ -300,14 +322,19 @@ function updateUrl(session) {
const url = new URL(window.location.href);
if (session) url.searchParams.set('arg', session);
else url.searchParams.delete('arg');
url.searchParams.delete('existing');
if (url.href !== window.location.href) window.history.replaceState(null, '', url);
}
// ---------------------------------------------------------------- connection
// reconnect: automatic re-attach after a drop. It must not re-create a session
// that was ended or renamed meanwhile, so it asks for --existing (devdesk#4).
function connect(session, { reconnect = false } = {}) {
// existing: go to a session that should already exist (reconnects, the
// switcher, inbox entries, notification links). It must not re-create one that
// was ended or renamed meanwhile, so it asks for --existing (devdesk#4, #38).
// Creating is explicit: a typed name, the New dialog, the picker, recents.
function connect(session, { existing = false, reconnect = false } = {}) {
const existingOnly = Boolean(session) && (existing || reconnect);
state.existingOnly = existingOnly;
clearTimeout(state.retryTimer);
state.conn?.dispose();
state.session = session;
@@ -316,13 +343,14 @@ function connect(session, { reconnect = false } = {}) {
paneTitleRaw = '';
renderPaneTitle();
onSessionData();
files?.sessionChanged();
hideOverlay();
setWsState('connecting', 'connecting');
state.openedAt = 0;
term.reset();
const conn = new TtydConnection({
arg: session && reconnect ? ['--existing', session] : session,
arg: existingOnly ? ['--existing', session] : session,
size: () => ({ cols: term.cols, rows: term.rows }),
onOpen: () => {
state.openedAt = Date.now();
@@ -356,6 +384,25 @@ function onClosed(code) {
// few seconds of opening means the session could not start (the reason is
// printed in the terminal), so don't retry forever.
const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000;
if (failedFast && state.existingOnly) {
// Most likely ended or renamed since the link was made: say so, and only
// start it again when asked.
const name = state.session;
sessionsPanel?.reload().then(() => {
const known = sessionsPanel.data?.sessions?.some((x) => x.name === name);
if (known) {
scheduleReconnect();
return;
}
setWsState('disconnected', 'ended');
showOverlay({
title: `${name} has ended`,
text: `The session "${name}" no longer exists: it was ended or renamed. Its conversation is still in Claude's history.`,
actions: [pickerAction, { label: `Start ${name} again`, run: () => connect(name) }],
});
});
return;
}
if (failedFast) {
showOverlay({
title: 'Session did not start',
@@ -451,6 +498,7 @@ function applyTitle() {
sessionsPanel.setCurrent(parsed.session);
onSessionData();
refreshRepo();
files?.sessionChanged();
}
paneTitleRaw = parsed.pane;
renderPaneTitle();
@@ -480,6 +528,7 @@ function closeMenus() {
menu.hidden = true;
button.setAttribute('aria-expanded', 'false');
}
if (inbox?.isOpen) inbox.setOpen(false);
}
// Session switcher on the session chip (devdesk#16).
@@ -496,7 +545,7 @@ function renderSwitcher() {
return el('button', {
type: 'button', role: 'menuitem',
class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`,
title: s.dir || '', onclick: choose(() => connect(s.name)),
title: s.dir || '', onclick: choose(() => connect(s.name, { existing: true })),
},
el('span', { class: `lt-dot ${status.dot}` }),
el('span', { class: 'dd-switch-name' }, s.name),
@@ -521,6 +570,7 @@ function setFontSize(size) {
const clamped = Math.min(24, Math.max(10, 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();
}
@@ -571,7 +621,7 @@ function buildCommands() {
const status = sessionStatus(s);
add({
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir,
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name),
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name, { existing: true }),
});
}
add({ id: 'new', group: 'Sessions', icon: '+', label: 'New session…', keywords: 'create open project', run: () => sessionsPanel.newDialog() });
@@ -592,6 +642,17 @@ function buildCommands() {
add({ id: 'detach', group: 'Terminal', icon: '⏏', label: 'Detach (the session keeps running)', kbd: '^b d', run: () => sendKeys('\x02d') });
}
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: 'goto-file', group: 'Files', icon: '⌕', label: 'Go to file…', kbd: 'Ctrl+P in Files', keywords: 'open find quick search',
run: () => {
files.open();
files.quickOpen.open();
},
});
}
add({
id: 'theme', group: 'View', icon: getTheme() === 'light' ? '◐' : '☀', keywords: 'theme light dark mode colour color',
label: getTheme() === 'light' ? 'Switch to dark mode' : 'Switch to light mode', run: toggleTheme,
@@ -651,6 +712,7 @@ const KEY_HELP = [
]],
['DevDesk', [
['Ctrl+⇧ P', 'Command palette'],
['Ctrl+⇧ E', 'Files: browse, preview, diff (Esc closes)'],
['Ctrl+⇧ C / V', 'Copy selection / paste'],
['⇧ drag', 'Select text in the browser'],
['Drop or paste a file', 'Upload it and attach it for Claude'],
@@ -666,6 +728,41 @@ function showHelp() {
openDialog({ title: 'Keyboard shortcuts', body, actions: [{ label: 'Close', primary: true }] }).then(() => term.focus());
}
// Drag the files pane's left edge (or use ←/→ on it). The terminal keeps at
// least ~60 columns; the width is remembered per browser.
function wireResizer() {
const main = document.querySelector('.dd-main');
const handle = $('files-resizer');
const apply = (px) => {
const max = main.clientWidth - 520;
const width = Math.round(Math.max(340, Math.min(max, px)));
main.style.setProperty('--dd-files-w', `${width}px`);
return width;
};
const saved = Number(store.get('filesWidth', 0));
if (saved) apply(saved);
handle.addEventListener('pointerdown', (event) => {
event.preventDefault();
handle.setPointerCapture(event.pointerId);
ui.body.classList.add('dd-resizing');
const move = (e) => apply(main.getBoundingClientRect().right - e.clientX);
const up = () => {
ui.body.classList.remove('dd-resizing');
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
store.set('filesWidth', $('files').getBoundingClientRect().width);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
});
handle.addEventListener('keydown', (event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
const width = apply($('files').getBoundingClientRect().width + (event.key === 'ArrowLeft' ? 40 : -40));
store.set('filesWidth', width);
});
}
async function toggleFullscreen() {
if (document.fullscreenElement) {
await document.exitFullscreen();
@@ -689,6 +786,7 @@ function renderNotifyButton() {
}
function setSidebar(open) {
if (open && files?.isOpen) files.close();
ui.body.classList.toggle('dd-sidebar-open', open);
ui.sidebarButton.setAttribute('aria-expanded', String(open));
ui.backdrop.hidden = !(open && isNarrow());
@@ -750,10 +848,34 @@ function wireChrome() {
showHelp();
});
$('footer-help').addEventListener('click', showHelp);
$('palette-menu').addEventListener('click', () => {
closeMenus();
palette.open();
});
files = new FilesPane($('files'), {
session: () => state.session,
paste: (text) => {
term.paste(text);
},
focusTerminal: () => term.focus(),
onToggle: (open) => {
ui.body.classList.toggle('dd-files-open', open);
$('files-toggle').setAttribute('aria-expanded', String(open));
if (open && ui.body.classList.contains('dd-sidebar-open')) setSidebar(false);
},
});
$('files-toggle').addEventListener('click', () => files.toggle());
wireResizer();
palette = new CommandPalette(buildCommands);
$('cmd-toggle').addEventListener('click', () => palette.open());
// Ctrl+Shift+P (⌘⇧P): Ctrl+K belongs to the terminal (kill-line in Claude and bash).
document.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyE') {
event.preventDefault();
event.stopPropagation();
if (state.session) files.toggle();
return;
}
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') {
event.preventDefault();
event.stopPropagation();
@@ -761,7 +883,34 @@ function wireChrome() {
else if (!dialogOpen()) palette.open();
}
}, true);
wireMenu(ui.moreToggle, ui.moreMenu, renderNotifyButton);
wireMenu(ui.moreToggle, ui.moreMenu, () => {
renderNotifyButton();
$('theme-menu').textContent = getTheme() === 'light' ? 'Dark mode' : 'Light mode';
});
$('theme-menu').addEventListener('click', () => {
closeMenus();
toggleTheme();
});
inbox = new Inbox({
button: $('inbox-toggle'),
badge: $('inbox-badge'),
panel: $('inbox-panel'),
hooks: {
openSession: (name) => connect(name, { existing: true }),
ended: (name) => toast.info(`${name} has ended; its conversation is still in Claude's history`),
showAlerts: () => {
setSidebar(true);
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
},
},
});
$('inbox-toggle').addEventListener('click', (event) => {
event.stopPropagation();
const open = !inbox.isOpen;
closeMenus();
inbox.setOpen(open);
});
$('inbox-panel').addEventListener('click', (event) => event.stopPropagation());
wireMenu(ui.sessionChip, ui.switcher, () => {
renderSwitcher();
sessionsPanel.refresh();
@@ -778,7 +927,15 @@ 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;
const menuOpen = menus.some(({ menu }) => !menu.hidden);
// 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) {
event.preventDefault();
event.stopPropagation();
files.escape();
return;
}
const menuOpen = menus.some(({ menu }) => !menu.hidden) || inbox.isOpen;
const drawerOpen = isNarrow() && ui.body.classList.contains('dd-sidebar-open');
if (!menuOpen && !drawerOpen) return;
event.preventDefault();
@@ -831,7 +988,7 @@ function wireChrome() {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo();
}, 60 * 1000);
ui.attention.addEventListener('click', () => {
if (ui.attention.dataset.target) connect(ui.attention.dataset.target);
if (ui.attention.dataset.target) connect(ui.attention.dataset.target, { existing: true });
});
}
@@ -860,7 +1017,10 @@ async function boot() {
theme: getTheme(),
onShiftEnter: () => state.conn?.sendInput('\n'),
}));
onThemeChange((theme) => { term.options.theme = THEMES[theme]; });
onThemeChange((theme) => {
term.options.theme = THEMES[theme];
files?.themeChanged();
});
$('theme-toggle').addEventListener('click', toggleTheme);
term.onData((data) => state.conn?.sendInput(data));
term.onBinary((data) => state.conn?.sendBinary(data));
@@ -873,6 +1033,7 @@ async function boot() {
notify.ring(state.session, ui.paneTitle.textContent || 'Claude needs your attention');
refreshSoon(); // Claude finished a turn: states and context just changed
setTimeout(refreshRepo, 2000); // …and maybe committed or pushed
setTimeout(() => files?.claudeTurnEnded(), 1200); // …or edited files
});
let fitFrame = 0;
@@ -927,7 +1088,8 @@ async function boot() {
wireUploads();
refreshAlerts();
await sessionsPanel.refresh();
connect(urlSession ?? defaultSession(sessionsPanel.data));
if (urlSession) connect(urlSession, { existing: urlExisting });
else connect(defaultSession(sessionsPanel.data), { existing: true });
}
boot();
+58 -14
View File
@@ -47,7 +47,7 @@ toast.info = (message, duration) => toast(message, 'info', duration);
// ---------------------------------------------------------------- dialogs
let openDialogs = 0;
export const dialogOpen = () => openDialogs > 0 || document.getElementById('lt-cmd-overlay')?.classList.contains('is-open');
export const dialogOpen = () => openDialogs > 0 || !!document.querySelector('.lt-cmd-overlay.is-open');
// A .lt-modal dialog. `body` is a node; `actions` are { label, value, primary,
// danger }. Resolves with the chosen action's value (or `submit()`'s value when
@@ -153,22 +153,26 @@ function pushRecent(id) {
}
// Commands: { id, label, group, icon?, hint?, kbd?, keywords?, run }. `getCommands`
// is called on every open, so the list reflects the current sessions and state.
// (may be async) is called on every open, so the list reflects the current state.
// Options: id (DOM prefix), placeholder, search(commands, query) -> ranked list
// (replaces the default word filter, recents and grouping; used by Go to file).
export class CommandPalette {
constructor(getCommands) {
constructor(getCommands, options = {}) {
this.options = { id: 'lt-cmd', placeholder: 'Type a command or a session name…', ...options };
const id = this.options.id;
this.getCommands = getCommands;
this.commands = [];
this.visible = [];
this.selected = 0;
this.opener = null;
this.input = el('input', {
class: 'lt-cmd-input', type: 'text', placeholder: 'Type a command or a session name…',
autocomplete: 'off', spellcheck: 'false', 'aria-label': 'Command', role: 'combobox',
'aria-controls': 'dd-cmd-results', 'aria-expanded': 'true',
class: 'lt-cmd-input', type: 'text', placeholder: this.options.placeholder,
autocomplete: 'off', spellcheck: 'false', 'aria-label': this.options.placeholder, role: 'combobox',
'aria-controls': `${id}-results`, 'aria-expanded': 'true',
});
this.results = el('div', { class: 'lt-cmd-results', id: 'dd-cmd-results', role: 'listbox' });
this.overlay = el('div', { class: 'lt-cmd-overlay', id: 'lt-cmd-overlay', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Command palette' },
el('div', { class: 'lt-cmd-palette', id: 'lt-cmd-palette' },
this.results = el('div', { class: 'lt-cmd-results', id: `${id}-results`, role: 'listbox' });
this.overlay = el('div', { class: 'lt-cmd-overlay', id: `${id}-overlay`, role: 'dialog', 'aria-modal': 'true', 'aria-label': this.options.placeholder },
el('div', { class: 'lt-cmd-palette', id: `${id}-palette` },
el('div', { class: 'lt-cmd-input-wrap' }, el('span', { class: 'lt-cmd-prompt', 'aria-hidden': 'true' }, '>'), this.input),
this.results,
el('div', { class: 'lt-cmd-footer' },
@@ -187,13 +191,29 @@ export class CommandPalette {
return this.overlay.classList.contains('is-open');
}
open(query = '') {
async open(query = '') {
this.opener = document.activeElement;
this.commands = this.getCommands();
this.overlay.classList.add('is-open');
this.input.value = query;
this.render();
this.input.focus();
const pending = this.getCommands();
if (pending instanceof Promise) {
this.commands = [];
this.loading = true;
this.render();
try {
this.commands = await pending;
} catch (error) {
this.commands = [];
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, error.message));
return;
} finally {
this.loading = false;
}
} else {
this.commands = pending;
}
if (this.isOpen) this.render();
}
close({ restoreFocus = true } = {}) {
@@ -246,6 +266,15 @@ export class CommandPalette {
}
render() {
if (this.loading) {
this.visible = [];
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, 'Loading…'));
return;
}
if (this.options.search) {
this.renderList(this.options.search(this.commands, this.input.value.trim()));
return;
}
const words = this.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const matches = this.commands.filter((c) => {
const text = `${c.label} ${c.group} ${c.keywords ?? ''} ${c.hint ?? ''}`.toLowerCase();
@@ -266,14 +295,29 @@ export class CommandPalette {
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, `No command matches “${this.input.value.trim()}”`));
return;
}
this.renderGroups(groups);
}
// A ranked list without group headers (custom search).
renderList(list) {
this.visible = list;
this.selected = 0;
if (!list.length) {
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, this.input.value.trim() ? `Nothing matches “${this.input.value.trim()}”` : 'Nothing here'));
return;
}
this.renderGroups(new Map([['', list]]));
}
renderGroups(groups) {
let index = 0;
const nodes = [];
for (const [group, commands] of groups) {
nodes.push(el('div', { class: 'lt-cmd-group-label', role: 'presentation' }, group));
if (group) nodes.push(el('div', { class: 'lt-cmd-group-label', role: 'presentation' }, group));
for (const command of commands) {
const i = index;
nodes.push(el('div', {
class: 'lt-cmd-item', id: `dd-cmd-${i}`, role: 'option',
class: 'lt-cmd-item', id: `${this.options.id}-item-${i}`, role: 'option',
onclick: () => this.run(command),
onmousemove: () => {
if (this.selected !== i) {
+260 -15
View File
@@ -100,20 +100,7 @@ body.dd-app::after {
color: var(--accent-orange);
border-color: var(--accent-orange-border);
}
.dd-badge {
margin-left: 6px;
min-width: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--accent-amber);
color: var(--bg-primary);
font-size: 0.62rem;
font-weight: 700;
line-height: 16px;
text-align: center;
}
.dd-badge.is-urgent { background: var(--accent-red); box-shadow: 0 0 8px var(--accent-red); }
.dd-badge[hidden] { display: none; }
/* ---- menus (session switcher, ⋯) ----------------------------------------------- */
.dd-session,
@@ -582,7 +569,6 @@ html[data-theme="light"] .dd-upload-status { box-shadow: 0 4px 16px rgba(50, 80,
html[data-theme="light"] .dd-session-item.is-waiting,
html[data-theme="light"] .dd-session-chip[data-state="waiting"],
html[data-theme="light"] .dd-upload-status.is-error { border-color: rgba(181, 0, 31, 0.35); }
html[data-theme="light"] .dd-badge { color: #ffffff; }
html[data-theme="light"] .dd-session-chip[data-state="working"] .dd-state-dot,
html[data-theme="light"] .dd-session-chip[data-state="waiting"] .dd-state-dot { box-shadow: none; }
@media (max-width: 1099px) {
@@ -679,3 +665,262 @@ html[data-theme="light"] .lt-cmd-item.is-selected { background: var(--accent-cya
/* One highlight only: the row under a resting pointer isn't "selected" until the
pointer moves (mousemove updates the selection). */
.lt-cmd-item:hover:not(.is-selected) { background: none; color: inherit; }
/* ---- inbox (bell) ---------------------------------------------------------------- */
.dd-inbox .lt-notif-bell-btn { font-size: 0.9rem; line-height: 1; padding-inline: 8px; }
.dd-inbox .lt-notif-badge { top: -5px; right: -5px; }
.dd-inbox .lt-notif-badge.is-calm { background: var(--accent-amber); animation: none; }
.dd-inbox-panel { width: min(360px, calc(100vw - 16px)); z-index: 40; }
.dd-inbox-panel .lt-notif-panel-list { max-height: min(460px, calc(100vh - 140px)); }
.dd-notif-section {
padding: 6px 0.75rem 4px;
font-size: 0.62rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-orange);
border-bottom: 1px solid var(--border-dim);
background: var(--bg-primary);
}
.dd-notif-red { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red); }
.dd-notif-amber { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber); }
.dd-notif-green { background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green); }
.dd-notif-cyan { background: var(--accent-cyan); }
.dd-notif-read { background: var(--border-color); box-shadow: none; }
html[data-theme="light"] .lt-notif-dot { box-shadow: none; }
.dd-notif-empty,
.dd-notif-more { padding: 0.75rem; color: var(--text-muted); font-size: 0.72rem; line-height: 1.5; }
.dd-panels-icon { display: none; }
@media (max-width: 719px) {
/* Phone header: theme lives in the ⋯ menu; Panels becomes an icon. */
.dd-header .lt-theme-btn { display: none; }
.dd-inbox .lt-notif-badge { top: 1px; right: 1px; }
.dd-panels-label { display: none; }
.dd-panels-icon { display: inline; font-size: 1rem; }
.dd-inbox-panel { position: fixed; top: calc(var(--header-height) + 4px); right: 8px; left: 8px; width: auto; }
}
/* ---- files pane (devdesk#19, #37): explorer + tabs + editor ---------------------- */
.dd-files {
position: relative;
display: flex;
flex-direction: column;
flex: 0 0 var(--dd-files-w, 52%);
min-width: 0;
margin: 0;
background: var(--bg-terminal);
--dd-explorer-w: 240px;
}
.dd-files[hidden] { display: none; }
body.dd-resizing { cursor: col-resize; user-select: none; }
body.dd-resizing .dd-terminal,
body.dd-resizing .dd-md-frame { pointer-events: none; }
.dd-files-resizer {
position: absolute;
top: 0;
bottom: 0;
left: calc(-1 * var(--dd-gap) - 2px);
width: calc(var(--dd-gap) + 6px);
cursor: col-resize;
z-index: 3;
}
.dd-files-resizer:hover,
.dd-files-resizer:focus-visible,
body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transparent 40%, var(--accent-cyan) 40%, var(--accent-cyan) 60%, transparent 60%); outline: none; }
#files-toggle[aria-expanded="true"] { color: var(--accent-orange); border-color: var(--accent-orange-border); }
/* head: explorer toggle | tabs | actions */
.dd-files-head { display: flex; align-items: stretch; gap: 2px; min-height: 34px; padding: 0 4px; border-bottom: 1px solid var(--border-dim); background: var(--bg-secondary); }
.dd-files-head .dd-icon-btn { align-self: center; }
.dd-files-actions { display: flex; align-items: center; gap: 2px; margin-left: auto; }
.dd-tabs { display: flex; flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: thin; }
.dd-tab {
display: flex;
align-items: center;
gap: 6px;
max-width: 220px;
padding: 0 6px 0 10px;
border-right: 1px solid var(--border-dim);
border-top: 2px solid transparent;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.74rem;
white-space: nowrap;
cursor: pointer;
user-select: none;
}
.dd-tab:hover { color: var(--text-primary); background: var(--bg-tertiary); }
.dd-tab.is-active { color: var(--text-primary); background: var(--bg-terminal); border-top-color: var(--accent-orange); }
.dd-tab.is-preview .dd-tab-name { font-style: italic; }
.dd-tab-name { overflow: hidden; text-overflow: ellipsis; }
.dd-tab-tag { padding: 0 4px; border: 1px solid var(--border-color); color: var(--text-muted); font-size: 0.6rem; }
.dd-tab-close { width: 18px; height: 18px; padding: 0; border: 0; background: none; color: inherit; opacity: 0; font-size: 0.9rem; line-height: 1; cursor: pointer; }
.dd-tab:hover .dd-tab-close,
.dd-tab.is-active .dd-tab-close,
.dd-tab-close:focus-visible { opacity: 0.7; }
.dd-tab-close:hover { opacity: 1; color: var(--accent-red); }
.dd-tab.git-amber .dd-tab-name { color: var(--accent-amber); }
.dd-tab.git-green .dd-tab-name { color: var(--accent-green); }
.dd-tab.git-red .dd-tab-name { color: var(--accent-red); text-decoration: line-through; }
/* main: explorer column | editor */
.dd-files-main { position: relative; display: flex; flex: 1; min-height: 0; }
.dd-explorer { display: flex; flex-direction: column; flex: 0 0 var(--dd-explorer-w); min-width: 0; border-right: 1px solid var(--border-dim); background: var(--bg-secondary); }
.dd-files.explorer-hidden .dd-explorer,
.dd-files.explorer-hidden .dd-explorer-resizer,
.dd-files.is-narrow .dd-explorer-resizer { display: none; }
.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 {
flex: 1;
padding: 6px 4px 5px;
border: 0;
border-bottom: 2px solid transparent;
background: none;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.64rem;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
}
.dd-explorer-views button: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; }
.dd-tree-row {
display: flex;
align-items: center;
gap: 6px;
min-height: 24px;
padding-right: 8px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.76rem;
white-space: nowrap;
cursor: pointer;
user-select: none;
}
.dd-tree-row:hover { background: var(--bg-tertiary); }
.dd-tree-row:focus-visible { outline: 1px solid var(--accent-cyan); outline-offset: -1px; }
.dd-tree-row.is-active { background: var(--accent-cyan-dim); box-shadow: inset 2px 0 0 var(--accent-cyan); }
.dd-tree-row.is-ignored { opacity: 0.5; }
.dd-tree-row.is-note { color: var(--text-muted); font-size: 0.7rem; cursor: default; }
.dd-tree-row.is-disabled { opacity: 0.5; cursor: default; }
.dd-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.dd-tree-row.git-amber .dd-tree-name { color: var(--accent-amber); }
.dd-tree-row.git-green .dd-tree-name { color: var(--accent-green); }
.dd-tree-row.git-red .dd-tree-name { color: var(--accent-red); text-decoration: line-through; }
.dd-tree-row.git-cyan .dd-tree-name { color: var(--accent-cyan); }
.dd-tree-row.git-dirty > .dd-tree-name { color: var(--accent-amber); }
.dd-dirty-dot { flex: 0 0 6px; height: 6px; border-radius: 50%; background: var(--accent-amber); }
.dd-change-dir { color: var(--text-muted); font-size: 0.66rem; }
.dd-changes-head { padding: 6px 10px; color: var(--text-muted); font-size: 0.68rem; border-bottom: 1px solid var(--border-dim); }
/* file-type badges */
.dd-ficon {
flex: 0 0 18px;
font-family: var(--font-mono);
font-size: 0.56rem;
font-weight: 700;
letter-spacing: -0.04em;
text-align: center;
line-height: 1;
}
.dd-ficon.tone-dir { color: var(--text-muted); font-size: 0.7rem; }
.dd-ficon.tone-cyan { color: var(--accent-cyan); }
.dd-ficon.tone-amber { color: var(--accent-amber); }
.dd-ficon.tone-blue { color: #5b9cff; }
.dd-ficon.tone-green { color: var(--accent-green); }
.dd-ficon.tone-purple { color: var(--accent-purple); }
.dd-ficon.tone-orange { color: var(--accent-orange); }
.dd-ficon.tone-muted { color: var(--text-muted); }
html[data-theme="light"] .dd-ficon.tone-blue { color: #1f4fbf; }
.dd-file-status { flex: 0 0 1.1em; font-weight: 700; text-align: center; font-size: 0.7rem; }
.dd-file-status.is-amber { color: var(--accent-amber); }
.dd-file-status.is-green { color: var(--accent-green); }
.dd-file-status.is-red { color: var(--accent-red); }
.dd-file-status.is-cyan { color: var(--accent-cyan); }
.dd-claude-chip { padding: 0 4px; border: 1px solid var(--accent-orange-border); color: var(--accent-orange); font-size: 0.58rem; font-weight: 700; }
.dd-add { color: var(--accent-green); font-size: 0.66rem; }
.dd-del { color: var(--accent-red); font-size: 0.66rem; }
.dd-files-msg { padding: 14px; color: var(--text-muted); font-size: 0.76rem; line-height: 1.5; }
.dd-files-msg.is-error { color: var(--accent-red); }
/* editor area */
.dd-editor-area { position: relative; display: flex; flex: 1; min-width: 0; flex-direction: column; }
.dd-tab-view { display: flex; flex: 1; min-height: 0; flex-direction: column; }
.dd-tab-view[hidden] { display: none; }
.dd-viewer-bar { display: flex; align-items: center; gap: 8px; min-height: 30px; padding: 3px 8px; border-bottom: 1px solid var(--border-dim); }
.dd-viewer-where { flex: 1; min-width: 0; overflow: hidden; font-size: 0.7rem; text-overflow: ellipsis; white-space: nowrap; }
.dd-viewer-path { color: var(--text-secondary); }
.dd-viewer-meta { color: var(--text-muted); font-size: 0.66rem; transition: color 0.6s; }
.dd-viewer-meta.is-flash { color: var(--accent-amber); }
.dd-viewer-actions { display: flex; flex: 0 0 auto; gap: 4px; }
.dd-viewer-btn {
padding: 2px 8px;
border: 1px solid var(--border-color);
background: none;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 0.68rem;
white-space: nowrap;
cursor: pointer;
}
.dd-viewer-btn:hover { border-color: var(--accent-cyan); background: var(--accent-cyan-dim); }
.dd-viewer-content { display: flex; flex: 1; min-height: 0; flex-direction: column; overflow: hidden; }
.dd-viewer-content > .cm-editor { flex: 1; min-height: 0; }
.dd-viewer-content .cm-editor.cm-focused { outline: none; }
.dd-viewer-content.is-image {
align-items: center;
justify-content: center;
overflow: auto;
padding: 12px;
background: repeating-conic-gradient(var(--bg-secondary) 0 25%, var(--bg-terminal) 0 50%) 0 0 / 20px 20px;
}
.dd-viewer-content.is-image img { max-width: 100%; height: auto; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); }
.dd-md-frame { flex: 1; width: 100%; border: 0; background: var(--bg-terminal); }
.dd-plain { flex: 1; margin: 0; padding: 10px; overflow: auto; font-size: 0.76rem; white-space: pre; }
/* empty editor */
.dd-editor-empty { display: flex; flex: 1; flex-direction: column; gap: 14px; padding: 28px 24px; overflow: auto; }
.dd-editor-empty[hidden] { display: none; }
.dd-empty-title { color: var(--text-secondary); font-size: 0.82rem; }
.dd-empty-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.dd-empty-actions .dd-viewer-btn { padding: 5px 12px; font-size: 0.72rem; }
.dd-empty-label { margin-bottom: 4px; color: var(--accent-orange); font-size: 0.62rem; letter-spacing: 0.12em; text-transform: uppercase; }
.dd-recent { display: flex; align-items: center; gap: 6px; width: 100%; padding: 3px 4px; border: 0; background: none; color: var(--accent-cyan); font-family: var(--font-mono); font-size: 0.76rem; text-align: left; cursor: pointer; }
.dd-recent:hover { background: var(--accent-cyan-dim); }
.dd-recent-name { white-space: nowrap; }
.dd-empty-hint { color: var(--text-muted); font-size: 0.68rem; }
/* Narrow pane (phone, or dragged small): the explorer slides over the editor
and closes when a file opens; ☰ brings it back. */
.dd-files.is-narrow .dd-explorer {
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 4;
width: min(300px, 85%);
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.45);
}
html[data-theme="light"] .dd-files.is-narrow .dd-explorer { box-shadow: 8px 0 24px rgba(50, 80, 130, 0.25); }
@media (max-width: 1099px) {
/* Narrow window: the pane covers the terminal instead of squeezing it
(squeezing would resize tmux for every client). */
.dd-files { position: absolute; inset: var(--dd-gap); z-index: 18; flex: none; }
.dd-files-resizer { display: none; }
}
@media (max-width: 719px) {
.dd-cmd-btn { display: none; }
.dd-tab { max-width: 160px; }
}
@media (pointer: coarse) {
.dd-tree-row { min-height: 34px; }
.dd-tab-close { opacity: 0.7; width: 28px; height: 28px; }
}