Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dee1843986 | ||
|
|
e345bf4640 | ||
|
|
73f8525db2 | ||
|
|
390ce61f5b | ||
|
|
6e109aec36 | ||
|
|
05057f695d | ||
|
|
0d86f89957 | ||
|
|
be9b026a01 | ||
|
|
7aa624bc13 | ||
|
|
364bd9b66f | ||
|
|
03e4a49e78 |
@@ -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
|
||||
|
||||
@@ -40,9 +40,12 @@ install: all
|
||||
install -m 0644 libexec/devdesk-lib.sh $(DESTDIR)$(LIBDIR)/devdesk-lib.sh
|
||||
install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \
|
||||
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
|
||||
libexec/devdesk-deploy $(DESTDIR)$(LIBDIR)/
|
||||
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:
|
||||
|
||||
@@ -28,13 +28,29 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
||||
- `main` is the projects root.
|
||||
- **Opening one:**
|
||||
- `https://dev.lotusguild.org/?arg=<name>` opens or creates that session.
|
||||
- With no `?arg=`, an fzf picker opens, listing running (●), dormant (○) and new (+) sessions.
|
||||
- The **Sessions** panel's form takes any name plus an optional project folder.
|
||||
- With no `?arg=`, DevDesk reattaches the session you used last in this browser (or the most recently active one). The fzf picker, listing running (●), dormant (○) and new (+) sessions, is under the session chip's menu.
|
||||
- **Session chip** (header): click to switch sessions; its dot shows Claude's state.
|
||||
- **+ New** in the Sessions panel takes any name plus an optional project folder.
|
||||
- **Rename / end:** the ✎ and × buttons on each session in the panel. Renaming keeps the session, its running Claude and its recorded conversation. Ending stops Claude; the conversation stays in Claude's history.
|
||||
- **Windows:** each session has a `claude` window (0) and a `shell` window (1).
|
||||
- **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.
|
||||
- **Claude Context panel:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use, cost and edits to `/var/lib/devdesk/context/<session>.json`. The panel shows the current session's numbers, and Claude's own footer shows a one-line summary. Sessions started before 0.2.0 need Claude restarted (`/exit`, then ⏎ in the menu) before they report.
|
||||
- **SSH:** `devdesk attach <name>`, `devdesk new <name> [project]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`.
|
||||
- **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.
|
||||
- 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.
|
||||
- For images, Claude attaches them itself.
|
||||
- **Ops Alerts panel:** firing and pending Grafana alert rules, each with a link to the rule.
|
||||
- **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.
|
||||
- **SSH:** `devdesk attach <name>`, `devdesk new <name> [project]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`, `devdesk repo <name>`, `devdesk alerts`.
|
||||
|
||||
## Keys
|
||||
| Key | Action |
|
||||
@@ -46,8 +62,10 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
||||
| Ctrl-b d | Detach (the session keeps running) |
|
||||
| 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) |
|
||||
|
||||
Use the **Full** button for fullscreen. In Chromium it also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
|
||||
The **☀/◐** button switches light and dark mode (saved per browser, shared with the other web_template apps' `lt_theme` preference). Text size, desktop notifications (with a mute), the keyboard shortcuts sheet and fullscreen are in the **⋯** menu; everything is also in the command palette. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
|
||||
|
||||
## Configuration
|
||||
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_REMOTE_CONTROL=1`.
|
||||
@@ -56,6 +74,7 @@ Use the **Full** button for fullscreen. In Chromium it also captures Ctrl+W/Ctrl
|
||||
- `DEVDESK_GITEA_URL` / `DEVDESK_GITEA_ALIASES` for the Repo/CI panel. Public repos need no token; for private ones put a `read:repository` token in `/etc/devdesk/gitea-token`
|
||||
- `CLAUDE_BIN` and `CLAUDE_ARGS`
|
||||
- `DEVDESK_REMOTE_CONTROL=1`, which also starts each session with Claude Remote Control so it can be continued from the Claude app
|
||||
- `DEVDESK_PUBLIC_URL`, `DEVDESK_MATRIX_WEBHOOK_FILE` and `DEVDESK_MATRIX_DELAY` for the Matrix notifications
|
||||
- `/etc/devdesk/secret.env` is generated on install. It holds the ttyd credential that NPM must inject; see [docs/DEPLOY.md](docs/DEPLOY.md).
|
||||
- `/etc/devdesk/tmux.local.conf` is optional and is sourced after the packaged `tmux.conf`.
|
||||
|
||||
@@ -74,5 +93,4 @@ CI (`.gitea/workflows/build-deb.yml`):
|
||||
- A `v*` tag also publishes to `https://code.lotusguild.org/api/packages/LotusGuild/debian`, creates a release, and auto-deploys to CT 126. A signed webhook runs `devdesk-deploy`, which upgrades the package; see [docs/DEPLOY.md](docs/DEPLOY.md).
|
||||
|
||||
## Roadmap
|
||||
- **Next:** Matrix notifications when you're away (needs the Ci-Alert hookshot URL).
|
||||
- **v2:** Ops Alerts (Grafana) with "Ask Claude" prompts, and file drop.
|
||||
Tracked in [Gitea issues](https://code.lotusguild.org/LotusGuild/devdesk/issues) by milestone: v0.6.0 file viewer, v0.7.0 editor + inbox, backlog.
|
||||
|
||||
+133
-22
@@ -6,24 +6,29 @@
|
||||
# devdesk rename OLD NEW rename a session (running or dormant)
|
||||
# devdesk end NAME stop session NAME and forget it
|
||||
# devdesk repo NAME git + Gitea CI status of the session's folder (JSON)
|
||||
# devdesk alerts firing/pending Grafana alerts (JSON)
|
||||
# devdesk ls list sessions
|
||||
# devdesk pick interactive picker
|
||||
#
|
||||
# The web UI calls `devdesk attach --list | --repo NAME | --new NAME [PROJECT] | --rename OLD NEW | --end NAME`
|
||||
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --upload SESSION FILE BYTES |
|
||||
# --new NAME [PROJECT] | --rename OLD NEW | --end NAME | --existing NAME`
|
||||
# through ttyd; those print JSON.
|
||||
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,11s/^# \{0,1\}//p' "$0"
|
||||
sed -n '2,12s/^# \{0,1\}//p' "$0"
|
||||
}
|
||||
|
||||
# Tab-separated running sessions: name, dir, attached clients, last activity, windows.
|
||||
running_tsv() {
|
||||
dd_server_up || return 0
|
||||
dd_tmux list-sessions -F '#{session_name} #{session_path} #{session_attached} #{session_activity} #{session_windows}' 2>/dev/null || true
|
||||
# Path last: it may contain anything (tabs included) and is re-joined below.
|
||||
dd_tmux list-sessions -F '#{session_name} #{session_attached} #{session_activity} #{session_windows} #{session_path}' 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Tab-separated top-level project directories: slug, dir.
|
||||
@@ -41,11 +46,12 @@ projects_tsv() {
|
||||
done
|
||||
}
|
||||
|
||||
# {"<session>": {model, used_pct, ...}} from the status-line snapshots.
|
||||
context_json() {
|
||||
local f
|
||||
if [ -d "$DEVDESK_CONTEXT_DIR" ]; then
|
||||
for f in "$DEVDESK_CONTEXT_DIR"/*.json; do
|
||||
# {"<session>": {...}} merged from the per-session JSON files in DIR
|
||||
# (status-line snapshots, hook states).
|
||||
per_session_json() {
|
||||
local dir=$1 f
|
||||
if [ -d "$dir" ]; then
|
||||
for f in "$dir"/*.json; do
|
||||
[ -f "$f" ] || continue
|
||||
jq -cM --arg n "$(basename "$f" .json)" '{($n): .}' "$f" 2>/dev/null || true
|
||||
done
|
||||
@@ -59,14 +65,15 @@ cmd_list() {
|
||||
--argjson reg "$reg" \
|
||||
--arg run "$(running_tsv)" \
|
||||
--arg proj "$(projects_tsv)" \
|
||||
--argjson ctx "$(context_json)" \
|
||||
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
|
||||
--argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \
|
||||
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
|
||||
--arg root "$DEVDESK_PROJECTS_ROOT" '
|
||||
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
|
||||
($reg.sessions // {}) as $known
|
||||
| [rows($run)[] | {name: .[0], dir: .[1], running: true,
|
||||
attached: (.[2] | tonumber), activity: (.[3] | tonumber),
|
||||
windows: (.[4] | tonumber)}] as $live
|
||||
| [rows($run)[] | {name: .[0], dir: (.[4:] | join("\t")), running: true,
|
||||
attached: (.[1] | tonumber? // 0), activity: (.[2] | tonumber? // 0),
|
||||
windows: (.[3] | tonumber? // 0)}] as $live
|
||||
| ($live | map(.name)) as $liveNames
|
||||
| {default: $dflt, root: $root,
|
||||
sessions: (
|
||||
@@ -76,9 +83,9 @@ cmd_list() {
|
||||
| map({name: .key, dir: .value.dir, running: false, attached: 0,
|
||||
activity: null, windows: 0,
|
||||
claude_session_id: (.value.claude_session_id // null)}))
|
||||
) | map(. + {context: ($ctx[.name] // null)}),
|
||||
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null)}),
|
||||
projects: ([{name: $dflt, dir: $root}]
|
||||
+ [rows($proj)[] | {name: .[0], dir: .[1]}
|
||||
+ [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))}
|
||||
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
|
||||
}
|
||||
|
||||
@@ -139,19 +146,23 @@ cmd_rename() {
|
||||
cmd_end() {
|
||||
[ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
|
||||
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
||||
# Forget first: killing the session SIGHUPs us when run from inside it.
|
||||
dd_registry_remove "$1"
|
||||
rm -rf "${DEVDESK_STATE_DIR:?}/uploads/$1"
|
||||
if dd_server_up; then
|
||||
dd_tmux kill-session -t "=$1" 2>/dev/null || true
|
||||
fi
|
||||
dd_registry_remove "$1"
|
||||
}
|
||||
|
||||
# GET $DEVDESK_GITEA_URL/api/v1<path>; token optional (private repos only).
|
||||
gitea_get() {
|
||||
local auth=()
|
||||
if [ -r "$DEVDESK_GITEA_TOKEN_FILE" ]; then
|
||||
auth=(-H "Authorization: token $(tr -d '[:space:]' <"$DEVDESK_GITEA_TOKEN_FILE")")
|
||||
fi
|
||||
curl -fsS --max-time 4 "${auth[@]}" "$DEVDESK_GITEA_URL/api/v1$1" 2>/dev/null
|
||||
# The token goes in via a curl config on stdin, never on the command line (ps).
|
||||
{
|
||||
if [ -r "$DEVDESK_GITEA_TOKEN_FILE" ]; then
|
||||
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token
|
||||
printf 'header = "Authorization: token %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GITEA_TOKEN_FILE")"
|
||||
fi
|
||||
} | curl -fsS --max-time 4 -K - "$DEVDESK_GITEA_URL/api/v1$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# owner/repo when REMOTE points at our Gitea (https or ssh, any configured host), else nothing.
|
||||
@@ -173,7 +184,9 @@ cmd_repo() {
|
||||
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
||||
local dir g branch sha="" dirty ahead=null behind=null upstream=false remote slug="" ci=null prs=null
|
||||
dir=$(dd_resolve_dir "$1")
|
||||
g=(git -c safe.directory='*' -C "$dir")
|
||||
# --no-optional-locks: never take .git/index.lock (Claude's own git would collide).
|
||||
# No safe.directory override: repos owned by other users stay untrusted.
|
||||
g=(git --no-optional-locks -C "$dir")
|
||||
if ! "${g[@]}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
jq -cMn --arg s "$1" --arg dir "$dir" '{session: $s, dir: $dir, git: false}'
|
||||
return
|
||||
@@ -185,7 +198,7 @@ cmd_repo() {
|
||||
upstream=true
|
||||
read -r behind ahead < <("${g[@]}" rev-list --left-right --count '@{u}...HEAD')
|
||||
fi
|
||||
remote=$("${g[@]}" remote get-url origin 2>/dev/null || true)
|
||||
remote=$("${g[@]}" remote get-url origin 2>/dev/null | sed -E 's#^([a-z+]+://)[^/@]*@#\1#' || true)
|
||||
[ -n "$remote" ] && slug=$(gitea_slug "$remote")
|
||||
if [ -n "$slug" ] && [ -n "$sha" ]; then
|
||||
ci=$(gitea_get "/repos/$slug/commits/$sha/status" |
|
||||
@@ -206,6 +219,73 @@ cmd_repo() {
|
||||
prs: $prs}'
|
||||
}
|
||||
|
||||
# Firing and pending Grafana alert rules, most severe first.
|
||||
cmd_alerts() {
|
||||
if [ -z "$DEVDESK_GRAFANA_URL" ]; then
|
||||
jq -cMn '{configured: false, alerts: []}'
|
||||
return
|
||||
fi
|
||||
local raw
|
||||
if ! raw=$({
|
||||
if [ -r "$DEVDESK_GRAFANA_TOKEN_FILE" ]; then
|
||||
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token
|
||||
printf 'header = "Authorization: Bearer %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GRAFANA_TOKEN_FILE")"
|
||||
fi
|
||||
} | curl -fsS --max-time 6 -K - "$DEVDESK_GRAFANA_URL/api/prometheus/grafana/api/v1/rules" 2>&1); then
|
||||
jq -cMn --arg e "${raw:-no response}" '{configured: true, error: $e, alerts: []}'
|
||||
return
|
||||
fi
|
||||
jq -cM --arg pub "$DEVDESK_GRAFANA_PUBLIC_URL" '
|
||||
def num: (tonumber? // .) | tostring;
|
||||
{configured: true, checked: (now | floor), alerts: [
|
||||
.data.groups[] | .name as $group | .file as $folder | .rules[]
|
||||
| select(.state == "firing" or .state == "pending")
|
||||
| . as $r
|
||||
| ([.alerts[]? | select(.state == "Alerting" or .state == "Pending")]) as $active
|
||||
| {name, state, severity: (.labels.severity // null), group: $group, folder: $folder,
|
||||
since: (.activeAt // $active[0].activeAt // null),
|
||||
summary: ((.annotations.summary // .annotations.description // "")
|
||||
| gsub("\\{\\{ *\\$value *\\}\\}"; ($active[0].value // "?" | num))),
|
||||
instances: [$active[] | .labels
|
||||
| del(.__alert_rule_uid__, .__grafana_origin__, .alertname, .grafana_folder, .__name__, .severity)
|
||||
| to_entries | map("\(.key)=\(.value)") | join(" ")
|
||||
| select(. != "")],
|
||||
url: "\($pub)/alerting/grafana/\(.uid)/view"}
|
||||
] | sort_by(if .severity == "critical" then 0 elif .severity == "warning" then 1 else 2 end)}' <<<"$raw"
|
||||
}
|
||||
|
||||
# Receive a file for SESSION over the terminal: the browser waits for the ready
|
||||
# line, then sends exactly ceil(BYTES/3)*4 bytes of base64 as input.
|
||||
cmd_upload() {
|
||||
[ $# -eq 3 ] || dd_die "usage: devdesk attach --upload SESSION FILENAME BYTES"
|
||||
local session=$1 name size=$3 dir dest b64len actual
|
||||
dd_valid_name "$session" || dd_die "invalid session name: $session"
|
||||
{ [[ $size =~ ^[0-9]+$ ]] && [ "$size" -gt 0 ] && [ "$size" -le "$DEVDESK_UPLOAD_MAX" ]; } ||
|
||||
dd_die "file is empty or larger than $((DEVDESK_UPLOAD_MAX / 1048576)) MB"
|
||||
name=$(dd_safe_filename "$2")
|
||||
dir="$DEVDESK_STATE_DIR/uploads/$session"
|
||||
mkdir -p "$dir"
|
||||
find "$DEVDESK_STATE_DIR/uploads" -type f -mtime +7 -delete 2>/dev/null || true
|
||||
# Unique even for same-second uploads of the same name.
|
||||
dest=$(mktemp --tmpdir="$dir" --suffix="-$name" "$(date +%Y%m%d-%H%M%S)-XXXX")
|
||||
# A dropped connection SIGHUPs us mid-transfer: never leave a partial file.
|
||||
trap 'rm -f "$dest"' HUP INT TERM
|
||||
# shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose
|
||||
b64len=$(((size + 2) / 3 * 4))
|
||||
stty raw -echo 2>/dev/null || true
|
||||
printf 'DEVDESK-UPLOAD-READY\r\n'
|
||||
head -c "$b64len" | base64 -d >"$dest" 2>/dev/null || true
|
||||
stty sane 2>/dev/null || true
|
||||
actual=$(stat -c %s "$dest" 2>/dev/null || echo 0)
|
||||
if [ "$actual" != "$size" ]; then
|
||||
rm -f "$dest"
|
||||
dd_die "upload incomplete ($actual of $size bytes)"
|
||||
fi
|
||||
trap - HUP INT TERM
|
||||
chmod 0644 "$dest"
|
||||
jq -cMn --arg p "$dest" '{ok: true, path: $p}'
|
||||
}
|
||||
|
||||
ok_json() {
|
||||
printf '{"ok":true}\n'
|
||||
}
|
||||
@@ -218,11 +298,31 @@ cmd_attach() {
|
||||
--list)
|
||||
[ $# -eq 1 ] || dd_die "--list takes no arguments"
|
||||
cmd_list
|
||||
dd_linger
|
||||
return
|
||||
;;
|
||||
--repo)
|
||||
shift
|
||||
cmd_repo "$@"
|
||||
dd_linger
|
||||
return
|
||||
;;
|
||||
--alerts)
|
||||
cmd_alerts
|
||||
dd_linger
|
||||
return
|
||||
;;
|
||||
--upload)
|
||||
shift
|
||||
cmd_upload "$@"
|
||||
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)
|
||||
@@ -230,8 +330,18 @@ cmd_attach() {
|
||||
shift
|
||||
"cmd_$action" "$@"
|
||||
ok_json
|
||||
dd_linger
|
||||
return
|
||||
;;
|
||||
--existing)
|
||||
# Reconnects: never re-create a session that was ended or renamed meanwhile.
|
||||
shift
|
||||
[ $# -eq 1 ] || dd_die "attach --existing takes exactly one session name"
|
||||
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
||||
if ! { dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } && [ -z "$(dd_registry_get "$1" dir)" ]; then
|
||||
dd_die "session '$1' no longer exists (it was ended or renamed)"
|
||||
fi
|
||||
;;
|
||||
-*) dd_die "unknown option: $1" ;;
|
||||
esac
|
||||
[ $# -eq 1 ] || dd_die "attach takes exactly one session name"
|
||||
@@ -263,6 +373,7 @@ attach | a) cmd_attach "$@" ;;
|
||||
new) cmd_new "$@" ;;
|
||||
rename | mv) cmd_rename "$@" ;;
|
||||
repo) cmd_repo "$@" ;;
|
||||
alerts) cmd_alerts ;;
|
||||
ls | list) cmd_ls ;;
|
||||
--list) cmd_list ;;
|
||||
end) cmd_end "$@" ;;
|
||||
|
||||
Vendored
+157
@@ -1,3 +1,160 @@
|
||||
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
|
||||
header, shared lt_theme preference (OS setting until you choose), applied
|
||||
before first paint and synced across tabs. The terminal has a light
|
||||
palette; Claude runs with its dark-ansi theme so its colours come from
|
||||
that palette, and the light palette maps bright white to ink, so Claude's
|
||||
text, scrollback included, stays readable in both modes. tmux and the
|
||||
statusLine use ANSI colours so they follow the theme too. Sessions pick
|
||||
up Claude's theme the next time Claude starts in them.
|
||||
* Command palette (#32): ⌕ button or Ctrl+Shift+P. Switch, create, rename
|
||||
and end sessions, Claude/Shell window, scroll back, detach, theme, panels,
|
||||
text size, fullscreen, notifications, copy link, open the repo / PRs / CI
|
||||
run, show alerts, keyboard help.
|
||||
* Dialogs instead of browser prompt/confirm for rename, end and new
|
||||
session (name check inline, project folder picker), and a keyboard
|
||||
shortcuts sheet in the ⋯ menu, the palette and the footer (#33).
|
||||
* Toasts for results: created, renamed, ended, uploads, link copied, CI
|
||||
finished, errors (#34).
|
||||
* Fix: renames, ends and new sessions often reported "no response" although
|
||||
they worked; ttyd dropped the reply of commands that exit right after
|
||||
printing. Replies now wait for the browser to hang up (#35).
|
||||
* Fix: the Sessions panel showed stale names right after a rename or end.
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 00:32:11 -0400
|
||||
|
||||
devdesk (0.5.1) bookworm; urgency=medium
|
||||
|
||||
* A session created by opening its URL shows up in the Sessions panel and
|
||||
the switcher right away instead of at the next poll.
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 23:42:01 -0400
|
||||
|
||||
devdesk (0.5.0) bookworm; urgency=medium
|
||||
|
||||
* Security (from the 2026-10-07 review):
|
||||
- ttyd :7681 is firewalled to NPM in CT 126 and its credential was
|
||||
rotated (#1).
|
||||
- "Ask Claude" strips control characters from Grafana text and pastes it
|
||||
as bracketed paste, so alert text can never run a command (#2).
|
||||
- OSC 52 clipboard is write-only: terminal output can no longer read the
|
||||
browser clipboard; tmux allow-passthrough is off (#7).
|
||||
- Gitea/Grafana tokens reach curl on stdin, never on its command line;
|
||||
remote URLs lose any userinfo (#11).
|
||||
* Fixes:
|
||||
- Ending the session you are in no longer leaves a ghost entry (#3).
|
||||
- Reconnecting after a drop never re-creates a session that was ended or
|
||||
renamed meanwhile (`attach --existing`) (#4).
|
||||
- Repo panel uses git --no-optional-locks and drops the safe.directory
|
||||
override (#5); uploads get unique names and are cleaned up on hangup
|
||||
(#6); uploads are size-checked first and streamed in slices with an
|
||||
idle timeout (#9); paths with tabs no longer break the session list
|
||||
(#10).
|
||||
- ttyd allows 32 clients; side-channel calls run two at a time and one
|
||||
tab polls for all open tabs (#8).
|
||||
* Header: the SSO chip is gone ("Sign in again" appears only when the
|
||||
session expires); the session chip shows Claude's state as a dot and
|
||||
opens a session switcher; LIVE is a dot; text size, notifications (with
|
||||
mute) and fullscreen move into a ⋯ menu; the Panels button shows a badge
|
||||
for waiting sessions and alerts (#13, #16).
|
||||
* A bare URL reattaches your last session instead of opening the picker
|
||||
(#16).
|
||||
* Notifications when *other* sessions start waiting, and alerts are polled
|
||||
with the panels closed (#15).
|
||||
* Sidebar: the Claude Context panel is folded into a context bar in each
|
||||
session row; Repo/CI collapses for non-git folders and offers "Ask Claude
|
||||
to fix CI"; the new-session form hides behind "+ New"; more legible
|
||||
labels (#17).
|
||||
* Phone: key bar (Esc, Tab, ⇧Tab, ^C, arrows, ⏎, Claude/Shell), drawer
|
||||
with backdrop, layout that follows the on-screen keyboard, DOM renderer
|
||||
on touch devices, no clock in the tmux status line (#14).
|
||||
* Matrix "Claude needs you": a wait that lasts 30 s with nobody attached
|
||||
posts one message with a link to the Devdesk hookshot webhook (#28).
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 23:34:48 -0400
|
||||
|
||||
devdesk (0.4.0) bookworm; urgency=medium
|
||||
|
||||
* Session status at a glance: Claude hooks record whether each session is
|
||||
working, waiting on you (with Claude's message) or idle. The sidebar shows
|
||||
it with context %, the header shows the current session's state, and a
|
||||
pulsing "needs you" chip jumps to sessions waiting for input.
|
||||
* 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, which
|
||||
attaches images itself.
|
||||
* Ops Alerts panel is live: firing/pending Grafana alert rules with
|
||||
severity, instances, summary and a link, plus an "Ask Claude" button that
|
||||
types an investigation prompt into the current session.
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 19:24:15 -0400
|
||||
|
||||
devdesk (0.3.1) bookworm; urgency=medium
|
||||
|
||||
* Auto-deploy: tagged releases trigger CT 126's webhook listener (house HMAC
|
||||
|
||||
Vendored
+5
@@ -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
|
||||
|
||||
+20
-2
@@ -59,8 +59,26 @@ A `v*` tag makes CI publish the package, then send a signed webhook (the house H
|
||||
3. Run `systemctl restart webhook`.
|
||||
4. Store the same secret as the devdesk repo's Actions secret `WEBHOOK_SECRET`.
|
||||
|
||||
## 6. Optional hardening
|
||||
Enable the Proxmox firewall on CT 126 (`firewall=1` on net0) and allow tcp/7681 only from 10.10.10.27. The same rule suits code-server on :8680.
|
||||
## 6. Firewall (required)
|
||||
The ttyd credential is the only thing between the LAN and a root shell, so only NPM may reach :7681. CT 126 ships this in pve-infra (`containers/126-codingenvironment/etc/nftables.conf`, installed by its `deploy.sh`):
|
||||
|
||||
```
|
||||
table inet devdesk {
|
||||
chain input {
|
||||
type filter hook input priority filter - 10; policy accept;
|
||||
tcp dport 7681 ip saddr { 127.0.0.1, 10.10.10.27 } accept
|
||||
tcp dport 7681 ip6 saddr ::1 accept
|
||||
tcp dport 7681 counter reject with tcp reset
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Check from another LAN host: `curl -m3 http://10.10.10.58:7681/` must fail with "connection refused". To rotate the credential: `rm /etc/devdesk/secret.env && dpkg-reconfigure devdesk`, put the new `Authorization` value in NPM proxy host 58's Advanced tab, then `systemctl restart devdesk`.
|
||||
|
||||
## 7. Matrix "Claude needs you" (optional)
|
||||
1. Deploy a hookshot transform named `Devdesk` (`hookshot/devdesk.js` in the matrix repo; pushing it to `main` auto-deploys it on LXC 151). Look up the hook UUID as described in that repo's README.
|
||||
2. `umask 077; echo https://matrix.lotusguild.org/webhook/<uuid> > /etc/devdesk/matrix-webhook`.
|
||||
3. Nothing to restart: the next wait that lasts `DEVDESK_MATRIX_DELAY` seconds with nobody attached posts a message.
|
||||
|
||||
## Operations
|
||||
| Task | Command |
|
||||
|
||||
@@ -23,9 +23,24 @@ DEVDESK_DEFAULT_DIR=
|
||||
DEVDESK_GITEA_URL=https://code.lotusguild.org
|
||||
DEVDESK_GITEA_ALIASES=10.10.10.63
|
||||
|
||||
# Grafana for the Ops Alerts panel (firing/pending alert rules). A Viewer
|
||||
# service-account token goes in DEVDESK_GRAFANA_TOKEN_FILE (mode 600).
|
||||
DEVDESK_GRAFANA_URL=http://10.10.10.49:3000
|
||||
DEVDESK_GRAFANA_PUBLIC_URL=https://dashboard.lotusguild.org
|
||||
DEVDESK_GRAFANA_TOKEN_FILE=/etc/devdesk/grafana-token
|
||||
|
||||
# Claude Code binary and extra arguments for every session.
|
||||
CLAUDE_BIN=/root/.local/bin/claude
|
||||
CLAUDE_ARGS=
|
||||
|
||||
# Where DevDesk is reached in a browser; used for links in Matrix notifications.
|
||||
DEVDESK_PUBLIC_URL=https://dev.lotusguild.org
|
||||
|
||||
# "Claude needs you" in Matrix when a session waits and no tab or SSH client is
|
||||
# attached: put a hookshot webhook URL in this file (mode 600). No file = off.
|
||||
# DEVDESK_MATRIX_DELAY: seconds the wait must last before posting.
|
||||
DEVDESK_MATRIX_WEBHOOK_FILE=/etc/devdesk/matrix-webhook
|
||||
DEVDESK_MATRIX_DELAY=30
|
||||
|
||||
# 1 = also enable Remote Control so sessions can be continued from the Claude app.
|
||||
DEVDESK_REMOTE_CONTROL=0
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
+37
-3
@@ -30,13 +30,32 @@ dd_load_env
|
||||
: "${DEVDESK_GITEA_URL:=https://code.lotusguild.org}"
|
||||
: "${DEVDESK_GITEA_ALIASES:=10.10.10.63}"
|
||||
: "${DEVDESK_GITEA_TOKEN_FILE:=/etc/devdesk/gitea-token}"
|
||||
: "${DEVDESK_GRAFANA_URL:=}"
|
||||
: "${DEVDESK_GRAFANA_PUBLIC_URL:=$DEVDESK_GRAFANA_URL}"
|
||||
: "${DEVDESK_GRAFANA_TOKEN_FILE:=/etc/devdesk/grafana-token}"
|
||||
: "${DEVDESK_UPLOAD_MAX:=26214400}"
|
||||
: "${DEVDESK_PUBLIC_URL:=}"
|
||||
: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}"
|
||||
: "${DEVDESK_MATRIX_DELAY:=30}"
|
||||
|
||||
DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
|
||||
DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context"
|
||||
DEVDESK_STATUS_DIR="$DEVDESK_STATE_DIR/state"
|
||||
DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$'
|
||||
|
||||
# Under ttyd, a command that exits right after printing sometimes loses its last
|
||||
# output: ttyd sees the exit and closes the WebSocket before reading the pty
|
||||
# (devdesk#35). One-shot replies and errors therefore wait, at most
|
||||
# DEVDESK_LINGER seconds, for the browser to hang up once it has the answer
|
||||
# (ttyd then sends SIGHUP). devdesk-serve sets DEVDESK_VIA_TTYD=1.
|
||||
dd_linger() {
|
||||
[ "${DEVDESK_VIA_TTYD:-}" = 1 ] && [ -t 0 ] || return 0
|
||||
read -r -t "${DEVDESK_LINGER:-1}" _ 2>/dev/null || true
|
||||
}
|
||||
|
||||
dd_die() {
|
||||
printf 'devdesk: %s\n' "$*" >&2
|
||||
dd_linger
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -113,14 +132,20 @@ dd_registry_set_claude() {
|
||||
|
||||
dd_registry_remove() {
|
||||
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
|
||||
rm -f "$DEVDESK_CONTEXT_DIR/$1.json"
|
||||
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt"
|
||||
}
|
||||
|
||||
dd_registry_rename() {
|
||||
dd_registry_update 'if .sessions[$old] then .sessions[$new] = .sessions[$old] | del(.sessions[$old]) else . end' \
|
||||
--arg old "$1" --arg new "$2"
|
||||
if [ -f "$DEVDESK_CONTEXT_DIR/$1.json" ]; then
|
||||
mv -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_CONTEXT_DIR/$2.json"
|
||||
local d
|
||||
for d in "$DEVDESK_CONTEXT_DIR" "$DEVDESK_STATUS_DIR"; do
|
||||
if [ -f "$d/$1.json" ]; then
|
||||
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
|
||||
}
|
||||
|
||||
@@ -147,6 +172,15 @@ dd_project_dir() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Upload filename -> safe basename ([A-Za-z0-9._-], no leading dots, max 100 chars).
|
||||
dd_safe_filename() {
|
||||
local n=${1##*/}
|
||||
n=${n//[^A-Za-z0-9._-]/_}
|
||||
while [[ $n == .* ]]; do n=${n:1}; done
|
||||
n=${n:0:100}
|
||||
printf '%s' "${n:-upload}"
|
||||
}
|
||||
|
||||
# Session name -> working directory. Sessions don't need a project: the
|
||||
# registry wins, then a project of the same name, then DEVDESK_DEFAULT_DIR.
|
||||
dd_resolve_dir() {
|
||||
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# devdesk-notify-matrix SESSION SINCE
|
||||
#
|
||||
# Started (detached) by devdesk-state-hook when a session starts waiting on you.
|
||||
# After DEVDESK_MATRIX_DELAY seconds, if that same wait is still on and nobody is
|
||||
# attached to the session (no browser tab, no SSH client), post one "Claude needs
|
||||
# you" message to the hookshot webhook in DEVDESK_MATRIX_WEBHOOK_FILE (devdesk#28).
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck source=libexec/devdesk-lib.sh
|
||||
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
|
||||
|
||||
session=${1:-}
|
||||
since=${2:-}
|
||||
dd_valid_name "$session" || exit 0
|
||||
[[ $since =~ ^[0-9]+$ ]] || exit 0
|
||||
[ -r "$DEVDESK_MATRIX_WEBHOOK_FILE" ] || exit 0
|
||||
|
||||
sleep "$DEVDESK_MATRIX_DELAY"
|
||||
|
||||
state_file="$DEVDESK_STATUS_DIR/$session.json"
|
||||
# Still the same wait? (Answering it moves the state on; a new wait has its own notifier.)
|
||||
[ "$(jq -rM '"\(.state) \(.since)"' "$state_file" 2>/dev/null)" = "waiting $since" ] || exit 0
|
||||
# Anyone looking at it already?
|
||||
attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null |
|
||||
awk -v s="$session" '$1 == s { print $2 }')
|
||||
[ "${attached:-0}" = 0 ] || exit 0
|
||||
|
||||
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"
|
||||
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))}')
|
||||
|
||||
# The webhook URL is a secret (anyone holding it can post to the room): pass it
|
||||
# to curl on stdin, never on the command line.
|
||||
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the URL
|
||||
webhook=$(tr -d '[:space:]"\\' <"$DEVDESK_MATRIX_WEBHOOK_FILE")
|
||||
printf 'url = "%s"\n' "$webhook" |
|
||||
curl -fsS --max-time 10 --retry 2 -K - -H 'Content-Type: application/json' --data-binary "$body" >/dev/null
|
||||
@@ -13,7 +13,7 @@ args=(
|
||||
--writable # ttyd >= 1.7.4 is read-only by default
|
||||
--url-arg # ?arg=<session> becomes `devdesk attach <session>`
|
||||
--check-origin # refuse cross-site WebSocket connections
|
||||
--max-clients "${DEVDESK_MAX_CLIENTS:-8}"
|
||||
--max-clients "${DEVDESK_MAX_CLIENTS:-32}"
|
||||
--credential "$DEVDESK_CREDENTIAL" # injected by NPM as an Authorization header
|
||||
--index "${DEVDESK_INDEX:-/usr/share/devdesk/index.html}"
|
||||
--debug "${DEVDESK_TTYD_LOG_LEVEL:-3}" # errors+warnings only: notice level would log the credential
|
||||
@@ -22,4 +22,7 @@ if [ -n "${DEVDESK_BIND:-}" ]; then
|
||||
args+=(--interface "$DEVDESK_BIND")
|
||||
fi
|
||||
|
||||
# Tells `devdesk attach` it talks to a browser through ttyd (see dd_linger).
|
||||
export DEVDESK_VIA_TTYD=1
|
||||
|
||||
exec "${DEVDESK_TTYD:-/usr/lib/devdesk/bin/ttyd}" "${args[@]}" "${DEVDESK_BIN:-/usr/bin/devdesk}" attach
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
# Claude Code hook (wired in via claude-settings.json) that tracks what Claude is
|
||||
# doing in each DevDesk session, for the sidebar's at-a-glance status:
|
||||
# UserPromptSubmit, PostToolUse -> working
|
||||
# Notification -> waiting (Claude needs input or a permission)
|
||||
# Stop, SessionStart -> idle
|
||||
# SessionEnd -> exited
|
||||
# Fires on every tool call, so it stays tiny and only writes when the state changes.
|
||||
# Entering "waiting" also starts devdesk-notify-matrix, which posts to Matrix if
|
||||
# the wait lasts and nobody is attached (devdesk#28).
|
||||
|
||||
input=$(cat)
|
||||
|
||||
outer=${TMUX:-}
|
||||
[ "${outer%%,*}" = "${DEVDESK_SOCKET:-/run/devdesk/tmux.sock}" ] && [ -n "${TMUX_PANE:-}" ] || exit 0
|
||||
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
|
||||
|
||||
# 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 ;;
|
||||
Stop | SessionStart) state=idle ;;
|
||||
SessionEnd) state=exited ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
dir="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/state"
|
||||
file="$dir/$session.json"
|
||||
# Unchanged state (the common case during tool runs): nothing to write.
|
||||
if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then
|
||||
exit 0
|
||||
fi
|
||||
# A second Notification during the same wait updates the message but keeps
|
||||
# `since`, so the wait stays one wait (one Matrix message at most).
|
||||
since=$(date +%s)
|
||||
was_waiting=0
|
||||
if [ "$state" = waiting ] && grep -q '"state":"waiting"' "$file" 2>/dev/null; then
|
||||
was_waiting=1
|
||||
since=$(jq -rM '.since // empty' "$file" 2>/dev/null) || since=$(date +%s)
|
||||
[[ $since =~ ^[0-9]+$ ]] || since=$(date +%s)
|
||||
fi
|
||||
mkdir -p "$dir" 2>/dev/null || exit 0
|
||||
tmp=$(mktemp "$dir/.$session.XXXXXX" 2>/dev/null) || exit 0
|
||||
if jq -cMn --arg s "$state" --arg m "$message" --arg e "$event" --argjson t "$since" \
|
||||
'{state: $s, since: $t, event: $e, message: (if $m == "" then null else $m end)}' >"$tmp"; then
|
||||
mv -f "$tmp" "$file"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$state" = waiting ] && [ "$was_waiting" = 0 ]; then
|
||||
setsid -f "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-notify-matrix" "$session" "$since" \
|
||||
</dev/null >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 0
|
||||
@@ -38,12 +38,13 @@ if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Footer line, in TDS colours: session · model · context % · cost
|
||||
# Footer line: session · model · context % · cost. ANSI colour numbers, not RGB,
|
||||
# so the browser's dark or light palette applies (devdesk#31).
|
||||
jq -rM --arg s "$session" '
|
||||
def c($rgb; $t): "\u001b[38;2;\($rgb)m\($t)\u001b[0m";
|
||||
def c($sgr; $t): "\u001b[\($sgr)m\($t)\u001b[0m";
|
||||
def pct: if . == null then "—" else "\(. | floor)%" end;
|
||||
[ (if $s != "" then c("255;107;0"; "◈ \($s)") else empty end),
|
||||
c("0;212;255"; .model.display_name // "Claude"),
|
||||
c("127;163;191"; "ctx \(.context_window.used_percentage | pct)"),
|
||||
c("62;96;122"; "$\((.cost.total_cost_usd // 0) * 100 | round / 100)")
|
||||
] | join(c("62;96;122"; " · "))' <<<"$input" 2>/dev/null || true
|
||||
[ (if $s != "" then c("33"; "◈ \($s)") else empty end),
|
||||
c("36"; .model.display_name // "Claude"),
|
||||
c("37"; "ctx \(.context_window.used_percentage | pct)"),
|
||||
c("37"; "$\((.cost.total_cost_usd // 0) * 100 | round / 100)")
|
||||
] | join(c("90"; " · "))' <<<"$input" 2>/dev/null || true
|
||||
|
||||
Generated
+329
-1
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"preferredNotifChannel": "terminal_bell",
|
||||
"theme": "dark-ansi",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
@@ -8,6 +9,67 @@
|
||||
"type": "command",
|
||||
"command": "/usr/lib/devdesk/devdesk-session-hook",
|
||||
"timeout": 10
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+15
-12
@@ -6,10 +6,10 @@ set -s escape-time 10 # Esc reaches Claude (interrupt) immediat
|
||||
set -s focus-events on
|
||||
set -s set-clipboard on # tmux copy-mode -> OSC 52 -> browser clipboard
|
||||
set -s default-terminal "tmux-256color"
|
||||
set -as terminal-features ",xterm-256color:RGB"
|
||||
set -s terminal-features[90] "xterm-256color:RGB" # fixed index: re-sourcing must not append
|
||||
set -g history-limit 50000
|
||||
set -g mouse on # wheel scrolls history; Shift+drag selects natively
|
||||
set -g allow-passthrough on
|
||||
set -g allow-passthrough off # output may not bypass tmux (title/clipboard spoofing)
|
||||
set -g renumber-windows on
|
||||
|
||||
# --- bells reach the browser (Claude's terminal_bell notifications) ----------
|
||||
@@ -24,18 +24,21 @@ set -g set-titles-string "devdesk:#S|#T"
|
||||
# --- every new session gets a spare shell next to Claude ---------------------
|
||||
set-hook -g session-created "new-window -d -n shell"
|
||||
|
||||
# --- LotusGuild TDS colours ---------------------------------------------------
|
||||
# --- colours ---------------------------------------------------------------------
|
||||
# ANSI colour names, not hex: the browser maps them through its dark or light
|
||||
# palette, so the status bar follows DevDesk's theme (devdesk#31). black is the
|
||||
# bar, white muted text, cyan the accent, yellow the session name.
|
||||
set -g status-position bottom
|
||||
set -g status-style "bg=#060c14,fg=#7fa3bf"
|
||||
set -g status-left "#[fg=#FF6B00,bold] #S #[fg=#3e607a,nobold]│ "
|
||||
set -g status-style "bg=black,fg=white"
|
||||
set -g status-left "#[fg=yellow,bold] #S #[fg=brightblack,nobold]│ "
|
||||
set -g status-left-length 40
|
||||
set -g status-right "#[fg=#3e607a]devdesk #[fg=#00D4FF]%H:%M "
|
||||
set -g status-right ""
|
||||
set -g window-status-format " #I:#W "
|
||||
set -g window-status-current-format "#[fg=#030508,bg=#00D4FF,bold] #I:#W "
|
||||
set -g window-status-bell-style "fg=#FFB300,bold"
|
||||
set -g message-style "bg=#0d1520,fg=#00D4FF"
|
||||
set -g mode-style "bg=#00D4FF,fg=#030508"
|
||||
set -g pane-border-style "fg=#0d1520"
|
||||
set -g pane-active-border-style "fg=#00D4FF"
|
||||
set -g window-status-current-format "#[fg=cyan,reverse,bold] #I:#W "
|
||||
set -g window-status-bell-style "fg=yellow,bold"
|
||||
set -g message-style "bg=black,fg=cyan"
|
||||
set -g mode-style "fg=cyan,reverse"
|
||||
set -g pane-border-style "fg=brightblack"
|
||||
set -g pane-active-border-style "fg=cyan"
|
||||
|
||||
source-file -q /etc/devdesk/tmux.local.conf
|
||||
|
||||
@@ -12,6 +12,7 @@ setup() {
|
||||
export DEVDESK_LIBEXEC="$REPO/libexec"
|
||||
export DEVDESK_SHARE="$REPO/share"
|
||||
export DEVDESK_NO_SYSTEMD=1
|
||||
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room
|
||||
export FAKE_LOG="$TMP/claude-args"
|
||||
export CLAUDE_BIN="$TMP/fake-claude"
|
||||
export PATH="$REPO/bin:$PATH"
|
||||
@@ -246,6 +247,183 @@ claude_args() {
|
||||
[ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha/ops" ]
|
||||
}
|
||||
|
||||
@test "state hook tracks working, waiting and idle per session" {
|
||||
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"; }
|
||||
state() { jq -r '.sessions[] | select(.name=="alpha") | .status.state // "none"' <<<"$(devdesk attach --list)"; }
|
||||
echo '{"hook_event_name":"UserPromptSubmit","prompt":"hi"}' | hook
|
||||
[ "$(state)" = working ]
|
||||
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | hook
|
||||
[ "$(state)" = waiting ]
|
||||
[ "$(jq -r '.message' "$DEVDESK_STATE_DIR/state/alpha.json")" = "Claude needs your permission to use Bash" ]
|
||||
echo '{"hook_event_name":"PostToolUse","tool_name":"Bash"}' | hook
|
||||
[ "$(state)" = working ]
|
||||
echo '{"hook_event_name":"Stop"}' | hook
|
||||
[ "$(state)" = idle ]
|
||||
echo '{"hook_event_name":"PreCompact"}' | hook
|
||||
[ "$(state)" = idle ]
|
||||
devdesk rename alpha beta
|
||||
[ -f "$DEVDESK_STATE_DIR/state/beta.json" ] && [ ! -e "$DEVDESK_STATE_DIR/state/alpha.json" ]
|
||||
devdesk end beta
|
||||
[ ! -e "$DEVDESK_STATE_DIR/state/beta.json" ]
|
||||
}
|
||||
|
||||
# Local stand-in for the hookshot webhook: appends each POST body to $TMP/posts.
|
||||
start_receiver() {
|
||||
python3 - "$TMP" <<'PY' &
|
||||
import http.server, sys
|
||||
tmp = sys.argv[1]
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
body = self.rfile.read(int(self.headers['Content-Length']))
|
||||
with open(f'{tmp}/posts', 'ab') as f:
|
||||
f.write(body + b'\n')
|
||||
self.send_response(200); self.end_headers(); self.wfile.write(b'{"ok":true}')
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
srv = http.server.HTTPServer(('127.0.0.1', 0), H)
|
||||
open(f'{tmp}/port', 'w').write(str(srv.server_port))
|
||||
srv.serve_forever()
|
||||
PY
|
||||
RECEIVER=$!
|
||||
for _ in $(seq 1 50); do [ -s "$TMP/port" ] && break; sleep 0.1; done
|
||||
printf 'http://127.0.0.1:%s/webhook/test\n' "$(cat "$TMP/port")" >"$TMP/webhook"
|
||||
chmod 600 "$TMP/webhook"
|
||||
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/webhook"
|
||||
}
|
||||
|
||||
posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
|
||||
|
||||
@test "a wait nobody is attached to is posted to Matrix once (#28)" {
|
||||
start_server
|
||||
start_receiver
|
||||
export DEVDESK_MATRIX_DELAY=1 DEVDESK_PUBLIC_URL=https://dev.example.org
|
||||
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":"Notification","message":"Claude needs your permission to use Bash"}' | hook
|
||||
# a second notification in the same wait: no second message
|
||||
echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input"}' | hook
|
||||
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')" ]
|
||||
|
||||
# answered within the delay: nothing is posted
|
||||
echo '{"hook_event_name":"PostToolUse"}' | hook
|
||||
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Edit"}' | hook
|
||||
echo '{"hook_event_name":"PostToolUse"}' | hook
|
||||
sleep 2
|
||||
[ "$(posts)" -eq 1 ]
|
||||
|
||||
# no webhook file: feature off
|
||||
rm "$TMP/webhook"
|
||||
echo '{"hook_event_name":"Notification","message":"again"}' | hook
|
||||
sleep 2
|
||||
[ "$(posts)" -eq 1 ]
|
||||
kill "$RECEIVER"
|
||||
}
|
||||
|
||||
@test "under ttyd, one-shot replies wait for the browser to hang up (#35)" {
|
||||
start_server
|
||||
# No tty (CLI, tests): no waiting.
|
||||
start=$(date +%s%N)
|
||||
DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=2 devdesk attach --list >/dev/null
|
||||
[ $(( ($(date +%s%N) - start) / 1000000 )) -lt 1500 ]
|
||||
# On a tty, as under ttyd: the reply is printed, then it waits. script's
|
||||
# stdin stays open (like a browser that hasn't hung up); a closed stdin
|
||||
# would hand the command EOF at once.
|
||||
start=$(date +%s%N)
|
||||
run bash -c "sleep 2 | script -qec \"DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.6 '$REPO/bin/devdesk' attach --list\" /dev/null"
|
||||
elapsed=$(( ($(date +%s%N) - start) / 1000000 ))
|
||||
[[ $output == *'"sessions"'* ]]
|
||||
[ "$elapsed" -ge 500 ]
|
||||
# Errors are printed before the wait, too.
|
||||
run bash -c "sleep 1 | script -qec \"DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.3 '$REPO/bin/devdesk' attach --rename nosuch x\" /dev/null"
|
||||
[[ $output == *"no session named"* ]]
|
||||
}
|
||||
|
||||
@test "alerts reports unconfigured and unreachable Grafana cleanly" {
|
||||
run env DEVDESK_GRAFANA_URL= devdesk alerts
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(jq -r '.configured' <<<"$output")" = false ]
|
||||
run env DEVDESK_GRAFANA_URL=http://127.0.0.1:9 devdesk attach --alerts
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(jq -r '.configured' <<<"$output")" = true ]
|
||||
[ "$(jq -r '.error | length > 0' <<<"$output")" = true ]
|
||||
}
|
||||
|
||||
@test "upload stores the exact bytes under a sanitised name" {
|
||||
head -c 50001 /dev/urandom >"$TMP/in.bin"
|
||||
run bash -c "base64 -w0 '$TMP/in.bin' | devdesk attach --upload alpha '../../etc/My Shot (1).png' 50001"
|
||||
[ "$status" -eq 0 ]
|
||||
path=$(tail -n1 <<<"$output" | jq -r .path)
|
||||
[[ $path == "$DEVDESK_STATE_DIR/uploads/alpha/"*-My_Shot__1_.png ]]
|
||||
cmp "$TMP/in.bin" "$path"
|
||||
run bash -c "printf short | devdesk attach --upload alpha x.bin 100"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ $output == *"upload incomplete"* ]]
|
||||
run devdesk attach --upload alpha x.bin 999999999999
|
||||
[ "$status" -ne 0 ]
|
||||
run devdesk attach --upload ../x x.bin 10
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "end run from inside the session leaves no ghost registry entry (#3)" {
|
||||
start_server
|
||||
devdesk new alpha
|
||||
tmux -S "$DEVDESK_SOCKET" send-keys -t =alpha:shell "$REPO/bin/devdesk end alpha" Enter
|
||||
for _ in $(seq 1 50); do
|
||||
tmux -S "$DEVDESK_SOCKET" has-session -t =alpha 2>/dev/null || break
|
||||
sleep 0.1
|
||||
done
|
||||
run tmux -S "$DEVDESK_SOCKET" has-session -t =alpha
|
||||
[ "$status" -ne 0 ]
|
||||
[ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ]
|
||||
}
|
||||
|
||||
@test "attach --existing refuses sessions that were ended or renamed (#4)" {
|
||||
start_server
|
||||
run devdesk attach --existing ghost
|
||||
[ "$status" -ne 0 ]
|
||||
[[ $output == *"no longer exists"* ]]
|
||||
run devdesk attach --existing ../x
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "same-second uploads of the same name get distinct files (#6)" {
|
||||
printf 'one' | base64 -w0 >"$TMP/a.b64"
|
||||
printf 'two' | base64 -w0 >"$TMP/b.b64"
|
||||
p1=$(devdesk attach --upload alpha image.png 3 <"$TMP/a.b64" | tail -n1 | jq -r .path)
|
||||
p2=$(devdesk attach --upload alpha image.png 3 <"$TMP/b.b64" | tail -n1 | jq -r .path)
|
||||
[ "$p1" != "$p2" ]
|
||||
[ "$(cat "$p1")" = one ]
|
||||
[ "$(cat "$p2")" = two ]
|
||||
[[ $p1 == *-image.png ]]
|
||||
}
|
||||
|
||||
@test "a tab in a project path does not break --list (#10)" {
|
||||
mkdir -p "$DEVDESK_PROJECTS_ROOT/odd name"
|
||||
start_server
|
||||
run devdesk attach --list
|
||||
[ "$status" -eq 0 ]
|
||||
jq -e '.projects | map(.dir) | any(endswith("odd\tname"))' <<<"$output" >/dev/null
|
||||
}
|
||||
|
||||
@test "repo hides credentials embedded in the remote URL (#11)" {
|
||||
export DEVDESK_GITEA_URL=http://127.0.0.1:9
|
||||
repo="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||
git -C "$repo" init -q -b main
|
||||
git -C "$repo" remote add origin https://jared:hunter2@example.com/x/y.git
|
||||
run devdesk repo alpha
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(jq -r .remote <<<"$output")" = https://example.com/x/y.git ]
|
||||
[[ $output != *hunter2* ]]
|
||||
}
|
||||
|
||||
@test "web actions run through attach and answer with JSON" {
|
||||
start_server
|
||||
run devdesk attach --new web1 alpha
|
||||
@@ -313,3 +491,132 @@ claude_args() {
|
||||
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')" ]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
Client, FlowControl, decodeServer, encodeBinaryInput, encodeInit, encodeInput, encodeResize,
|
||||
parseListOutput, parseTitle, sessionFromSearch, slugify, stripAnsi, tokenUrl, wsUrl,
|
||||
parseListOutput, parseTitle, pasteSafe, sessionFromSearch, slugify, stripAnsi, tokenUrl, wsUrl,
|
||||
} from '../web/src/protocol.js';
|
||||
|
||||
const text = (bytes) => new TextDecoder().decode(bytes);
|
||||
@@ -80,3 +80,9 @@ test('flow control pauses after highWater pending writes and resumes below lowWa
|
||||
assert.equal(flow.release(), null); // 1
|
||||
assert.equal(flow.release(), Client.RESUME); // 0
|
||||
});
|
||||
|
||||
test('untrusted text loses control characters before it is typed (#2)', () => {
|
||||
assert.equal(pasteSafe('ok\nrm -rf /\r\x1b[31mred\x07'), 'ok rm -rf / [31mred ');
|
||||
assert.equal(pasteSafe('plain text'), 'plain text');
|
||||
assert.equal(pasteSafe(null), '');
|
||||
});
|
||||
|
||||
+27
-1
@@ -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) => {
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
+107
-54
@@ -2,12 +2,21 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta name="theme-color" content="#030508">
|
||||
<meta name="generator" content="devdesk">
|
||||
<title>DevDesk</title>
|
||||
<link rel="icon" id="favicon" href="__FAVICON__">
|
||||
<script>
|
||||
// Theme before first paint (same key as web_template's lt.theme), no dark flash.
|
||||
try {
|
||||
var t = localStorage.getItem('lt_theme');
|
||||
if (t !== 'light' && t !== 'dark') t = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.style.colorScheme = t;
|
||||
} catch (e) { /* storage blocked: stay dark */ }
|
||||
</script>
|
||||
<!--STYLES-->
|
||||
</head>
|
||||
<body class="dd-app">
|
||||
@@ -16,31 +25,60 @@
|
||||
<div class="lt-header-left">
|
||||
<div class="lt-brand">
|
||||
<span class="lt-brand-title">DevDesk</span>
|
||||
<span class="lt-brand-subtitle">Claude Code workspace</span>
|
||||
</div>
|
||||
<div class="dd-session">
|
||||
<span class="lt-chip lt-chip-info" id="session-chip">—</span>
|
||||
<button type="button" class="dd-session-chip" id="session-chip" data-state="" aria-haspopup="menu" aria-expanded="false" title="Switch session">
|
||||
<span class="dd-state-dot" aria-hidden="true"></span><span class="dd-session-label" id="session-name">—</span><span class="dd-caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<span class="dd-pane-title" id="pane-title"></span>
|
||||
<div class="dd-menu dd-switcher" id="switcher" role="menu" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-header-right">
|
||||
<span class="lt-ws-status" id="ws-status" data-state="connecting" role="status">
|
||||
<button type="button" class="lt-chip dd-attention" id="attention-chip" title="Sessions waiting for you"></button>
|
||||
<button type="button" class="lt-btn lt-btn-danger lt-btn-sm dd-reauth" id="reauth" hidden title="Your Authelia session expired. Claude keeps running; sign in again to keep using the panels.">Sign in again</button>
|
||||
<span class="lt-ws-status dd-ws" id="ws-status" data-state="connecting" role="status">
|
||||
<span class="lt-dot"></span><span id="ws-label">connecting</span>
|
||||
</span>
|
||||
<button type="button" class="lt-chip dd-sso" id="sso-badge" data-state="unknown" title="Signed in through Authelia">SSO</button>
|
||||
<div class="lt-btn-group dd-tools">
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="font-down" title="Smaller text" aria-label="Smaller text">A−</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="font-up" title="Larger text" aria-label="Larger text">A+</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="notify-toggle" title="Desktop notification when Claude needs you">Alerts off</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="fullscreen-toggle" title="Fullscreen (also captures browser shortcuts in Chromium)">Full</button>
|
||||
<button type="button" class="lt-btn lt-btn-sm" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false">Panels</button>
|
||||
<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"> ⌃⇧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>
|
||||
<div class="dd-menu-row">
|
||||
<span>Text size</span>
|
||||
<span class="dd-menu-inline">
|
||||
<button type="button" class="dd-icon-btn" id="font-down" aria-label="Smaller text">A−</button>
|
||||
<span id="font-size">14</span>
|
||||
<button type="button" class="dd-icon-btn" id="font-up" aria-label="Larger text">A+</button>
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" class="dd-menu-item" id="theme-menu" role="menuitem">Light mode</button>
|
||||
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
|
||||
<button type="button" class="dd-menu-item" id="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-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>
|
||||
|
||||
<main class="dd-main">
|
||||
<section class="lt-frame dd-terminal-frame" aria-label="Terminal">
|
||||
<div id="terminal" class="dd-terminal"></div>
|
||||
<div id="drop-overlay" class="dd-drop-overlay" hidden><span>Drop to attach to <b id="drop-session">this session</b></span></div>
|
||||
<div id="upload-status" class="dd-upload-status" role="status" hidden></div>
|
||||
<div id="overlay" class="dd-overlay" data-tone="info" hidden>
|
||||
<div class="lt-frame dd-overlay-card" role="alertdialog" aria-labelledby="overlay-title" aria-describedby="overlay-text">
|
||||
<div class="lt-section-header" id="overlay-title">Disconnected</div>
|
||||
@@ -52,77 +90,77 @@
|
||||
</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">
|
||||
<div class="lt-section-header">Sessions
|
||||
<button type="button" class="dd-header-action" id="sessions-refresh" title="Refresh" aria-label="Refresh sessions">↻</button>
|
||||
<span class="dd-header-actions">
|
||||
<button type="button" class="dd-header-action" id="new-session-toggle" aria-expanded="false" title="New session">+ New</button>
|
||||
<button type="button" class="dd-header-action" id="sessions-refresh" title="Refresh" aria-label="Refresh sessions">↻</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="lt-section-body">
|
||||
<ul class="dd-session-list" id="sessions-list"></ul>
|
||||
<form class="dd-new-session" id="new-session-form" autocomplete="off">
|
||||
<input class="lt-input" id="new-session-name" maxlength="64" placeholder="New session, e.g. pbs-upgrade"
|
||||
<form class="dd-new-session" id="new-session-form" autocomplete="off" hidden>
|
||||
<input class="lt-input" id="new-session-name" maxlength="64" placeholder="Name, e.g. pbs-upgrade"
|
||||
aria-label="New session name" spellcheck="false">
|
||||
<select class="lt-select" id="project-select" aria-label="Start the new session in a project folder (optional)"></select>
|
||||
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">Open</button>
|
||||
</form>
|
||||
<div class="dd-widget-foot">
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="open-picker">Picker</button>
|
||||
<span class="dd-muted dd-status" id="sessions-status" role="status"></span>
|
||||
</div>
|
||||
<ul class="dd-session-list" id="sessions-list"></ul>
|
||||
<span class="dd-muted dd-status" id="sessions-status" role="status"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lt-frame dd-widget" id="widget-context" data-state="empty">
|
||||
<div class="lt-section-header">Claude Context</div>
|
||||
<section class="lt-frame dd-widget" id="widget-alerts" data-state="empty">
|
||||
<div class="lt-section-header">Ops Alerts <span class="lt-badge lt-badge-sm dd-alert-count"></span></div>
|
||||
<div class="lt-section-body">
|
||||
<div class="lt-progress lt-progress--cyan" role="progressbar" aria-label="Context window used" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
||||
<div class="lt-progress-bar" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="lt-kv-grid dd-context-grid">
|
||||
<span class="lt-kv-key">Context</span><span class="lt-kv-val" data-field="used">—</span>
|
||||
<span class="lt-kv-key">Model</span><span class="lt-kv-val" data-field="model">—</span>
|
||||
<span class="lt-kv-key">Cost</span><span class="lt-kv-val" data-field="cost">—</span>
|
||||
<span class="lt-kv-key">Edits</span><span class="lt-kv-val" data-field="lines">—</span>
|
||||
<span class="lt-kv-key">Updated</span><span class="lt-kv-val" data-field="updated">—</span>
|
||||
</div>
|
||||
<p class="dd-muted dd-stub-note dd-context-note"></p>
|
||||
<ul class="dd-alert-list"><li class="dd-empty">Loading…</li></ul>
|
||||
<p class="dd-muted dd-stub-note dd-alerts-note"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lt-frame dd-widget" id="widget-repo" data-state="empty">
|
||||
<div class="lt-section-header">Repo / CI</div>
|
||||
<div class="lt-section-body">
|
||||
<div class="lt-kv-grid">
|
||||
<div class="lt-kv-grid dd-repo-grid">
|
||||
<span class="lt-kv-key">Repo</span><span class="lt-kv-val" data-field="repo">—</span>
|
||||
<span class="lt-kv-key">Branch</span><span class="lt-kv-val" data-field="branch">—</span>
|
||||
<span class="lt-kv-key">Tree</span><span class="lt-kv-val" data-field="dirty">—</span>
|
||||
<span class="lt-kv-key">CI · HEAD</span><span class="lt-kv-val"><span class="lt-status lt-status-pending" data-field="ci">no data</span></span>
|
||||
<span class="lt-kv-key">CI · HEAD</span><span class="lt-kv-val"><span class="lt-status" data-field="ci">—</span></span>
|
||||
<span class="lt-kv-key">Open PRs</span><span class="lt-kv-val" data-field="prs">—</span>
|
||||
</div>
|
||||
<p class="dd-muted dd-stub-note dd-repo-note"></p>
|
||||
<p class="dd-muted dd-repo-note"></p>
|
||||
<button type="button" class="lt-btn lt-btn-sm dd-ask dd-fix-ci" hidden>Ask Claude to fix CI</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lt-frame dd-widget" id="widget-alerts" data-state="stub">
|
||||
<div class="lt-section-header">Ops Alerts <span class="lt-badge lt-badge-sm dd-soon">v2</span></div>
|
||||
<div class="lt-section-body">
|
||||
<ul class="dd-alert-list">
|
||||
<li class="dd-alert">
|
||||
<span class="lt-dot lt-dot-idle"></span>
|
||||
<span class="dd-alert-text">No alert source connected</span>
|
||||
<button type="button" class="lt-btn lt-btn-sm dd-ask" disabled
|
||||
data-ask-claude="Investigate the firing alert: ">Ask Claude</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="dd-muted dd-stub-note">Firing Grafana alerts, each with an “Ask Claude” prompt.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<footer class="lt-footer dd-footer">
|
||||
<div class="lt-footer-hints">
|
||||
<div class="lt-footer-hints dd-hints">
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">⇧⏎</span>newline</span>
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">Esc</span>interrupt</span>
|
||||
@@ -134,8 +172,23 @@
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">^b d</span>detach</span>
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">⇧drag</span>select</span>
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">drop / paste</span>attach files</span>
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">^⇧P</span>commands</span>
|
||||
<button type="button" class="dd-footer-help" id="footer-help">all keys</button>
|
||||
</div>
|
||||
<div class="dd-keybar" id="keybar" role="toolbar" aria-label="Terminal keys" hidden>
|
||||
<button type="button" data-keys="esc">Esc</button>
|
||||
<button type="button" data-keys="tab">Tab</button>
|
||||
<button type="button" data-keys="stab">⇧Tab</button>
|
||||
<button type="button" data-keys="ctrlc">^C</button>
|
||||
<button type="button" data-keys="up">↑</button>
|
||||
<button type="button" data-keys="down">↓</button>
|
||||
<button type="button" data-keys="enter">⏎</button>
|
||||
<button type="button" data-keys="claude">Claude</button>
|
||||
<button type="button" data-keys="shell">Shell</button>
|
||||
</div>
|
||||
<span class="dd-muted" id="version">devdesk</span>
|
||||
</footer>
|
||||
|
||||
<!--SCRIPT-->
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Ops Alerts panel: firing/pending Grafana alert rules (`devdesk attach --alerts`),
|
||||
// each with an "Ask Claude" button that types an investigation prompt into the
|
||||
// current session (without pressing Enter).
|
||||
|
||||
import { el } from './dom.js';
|
||||
|
||||
const SEVERITY = {
|
||||
critical: 'lt-chip-critical',
|
||||
warning: 'lt-chip-warn',
|
||||
};
|
||||
|
||||
function age(iso) {
|
||||
const t = Date.parse(iso || '');
|
||||
if (Number.isNaN(t)) return '';
|
||||
const s = Math.max(0, (Date.now() - t) / 1000);
|
||||
if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
||||
return `${Math.floor(s / 86400)}d`;
|
||||
}
|
||||
|
||||
export function investigatePrompt(alert) {
|
||||
const where = alert.instances?.length ? ` on ${alert.instances.join('; ')}` : '';
|
||||
return `Investigate the ${alert.state} Grafana alert "${alert.name}"`
|
||||
+ ` (${alert.severity || 'no severity'}, ${alert.folder} / ${alert.group}${where}, since ${alert.since}).`
|
||||
+ ` Summary: ${alert.summary || 'n/a'}. Use the grafana MCP server to look at the rule and the related`
|
||||
+ ' metrics, find the cause, and tell me what you would change before changing anything.';
|
||||
}
|
||||
|
||||
export function renderAlerts(root, data) {
|
||||
const list = root.querySelector('.dd-alert-list');
|
||||
const note = root.querySelector('.dd-alerts-note');
|
||||
const count = root.querySelector('.dd-alert-count');
|
||||
const alerts = data?.alerts ?? [];
|
||||
count.textContent = alerts.length ? String(alerts.length) : '';
|
||||
count.className = `lt-badge lt-badge-sm dd-alert-count${alerts.some((a) => a.severity === 'critical') ? ' lt-badge-p1' : alerts.length ? ' lt-badge-p2' : ''}`;
|
||||
|
||||
if (!data) {
|
||||
root.dataset.state = 'empty';
|
||||
list.replaceChildren(el('li', { class: 'dd-empty' }, 'Loading…'));
|
||||
note.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!data.configured) {
|
||||
root.dataset.state = 'empty';
|
||||
list.replaceChildren(el('li', { class: 'dd-empty' }, 'Grafana is not configured.'));
|
||||
note.textContent = 'Set DEVDESK_GRAFANA_URL and a token file in /etc/devdesk/env.local.';
|
||||
return;
|
||||
}
|
||||
root.dataset.state = 'live';
|
||||
note.textContent = data.error ? `Grafana unreachable: ${data.error}` : '';
|
||||
if (!alerts.length) {
|
||||
list.replaceChildren(el('li', { class: 'dd-all-clear' },
|
||||
el('span', { class: 'lt-dot lt-dot-up' }), data.error ? 'No data' : 'All clear: nothing firing'));
|
||||
return;
|
||||
}
|
||||
list.replaceChildren(...alerts.map((a) => el('li', { class: `dd-alert is-${a.state}` },
|
||||
el('div', { class: 'dd-alert-head' },
|
||||
el('span', { class: `lt-chip ${SEVERITY[a.severity] || 'lt-chip-info'}` }, a.state === 'pending' ? 'pending' : (a.severity || 'alert')),
|
||||
el('a', { class: 'dd-alert-name', href: a.url, target: '_blank', rel: 'noopener', title: `${a.folder} / ${a.group}` }, a.name),
|
||||
el('span', { class: 'dd-muted dd-alert-age' }, age(a.since))),
|
||||
a.instances?.length ? el('div', { class: 'dd-muted dd-alert-instances' }, a.instances.join(' · ')) : null,
|
||||
a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null,
|
||||
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a) }, 'Ask Claude'))));
|
||||
}
|
||||
+215
-8
@@ -12,11 +12,17 @@ export async function fetchToken() {
|
||||
const res = await fetch(tokenUrl(window.location), {
|
||||
cache: 'no-store', credentials: 'same-origin', redirect: 'manual',
|
||||
});
|
||||
if (res.type === 'opaqueredirect') throw new AuthExpiredError('SSO session expired');
|
||||
if (res.type === 'opaqueredirect') {
|
||||
// Any request can discover an expired Authelia session; the header shows
|
||||
// "Sign in again" when this fires (devdesk#13).
|
||||
window.dispatchEvent(new CustomEvent('devdesk:auth', { detail: 'expired' }));
|
||||
throw new AuthExpiredError('SSO session expired');
|
||||
}
|
||||
if (res.status === 401 || res.status === 403 || res.status === 407) {
|
||||
throw new UnauthorizedError(`HTTP ${res.status}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`token request failed: HTTP ${res.status}`);
|
||||
window.dispatchEvent(new CustomEvent('devdesk:auth', { detail: 'ok' }));
|
||||
const body = await res.json();
|
||||
return body.token || '';
|
||||
}
|
||||
@@ -94,9 +100,41 @@ export class TtydConnection {
|
||||
}
|
||||
}
|
||||
|
||||
// Side-channel commands each occupy a ttyd client slot and a server process:
|
||||
// run at most two at a time (devdesk#8).
|
||||
let active = 0;
|
||||
const waiting = [];
|
||||
async function limited(task) {
|
||||
if (active >= 2) await new Promise((resolve) => waiting.push(resolve));
|
||||
active += 1;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
active -= 1;
|
||||
waiting.shift()?.();
|
||||
}
|
||||
}
|
||||
|
||||
// Run a short-lived `devdesk attach <args…>` and return its output as text.
|
||||
// ttyd is the only server-side endpoint, so session actions travel this way.
|
||||
export async function runCommand(args, timeoutMs = 8000) {
|
||||
export function runCommand(args, timeoutMs = 8000) {
|
||||
return limited(() => runCommandNow(args, timeoutMs));
|
||||
}
|
||||
|
||||
// A full JSON reply, or devdesk's one-line error.
|
||||
function isComplete(text) {
|
||||
const clean = stripAnsi(text);
|
||||
if (/^devdesk: .*\n/m.test(clean)) return true;
|
||||
if (!/\}\s*$/.test(clean)) return false;
|
||||
try {
|
||||
parseListOutput(clean);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommandNow(args, timeoutMs) {
|
||||
const token = await fetchToken();
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
|
||||
@@ -107,15 +145,34 @@ export async function runCommand(args, timeoutMs = 8000) {
|
||||
reject(new Error('devdesk did not answer'));
|
||||
}, timeoutMs);
|
||||
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
|
||||
socket.onmessage = (event) => {
|
||||
const { type, payload } = decodeServer(event.data);
|
||||
if (type === Server.OUTPUT) chunks.push(payload.slice());
|
||||
};
|
||||
socket.onerror = () => {};
|
||||
socket.onclose = () => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
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).
|
||||
// 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();
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {};
|
||||
socket.onclose = finish;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,6 +181,156 @@ export async function fetchSessionList() {
|
||||
return parseListOutput(await runCommand([LIST_ARG]));
|
||||
}
|
||||
|
||||
// Firing/pending Grafana alerts (`devdesk attach --alerts`).
|
||||
export async function fetchAlerts() {
|
||||
return parseListOutput(await runCommand(['--alerts']));
|
||||
}
|
||||
|
||||
function toBase64(bytes) {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const UPLOAD_MAX = 25 * 1024 * 1024;
|
||||
|
||||
// Upload a file into the session's upload folder through ttyd: wait for the
|
||||
// server's ready line, stream the file as base64 input in slices (never the
|
||||
// whole file in memory), read back {ok, path}. Fails on 20 s of inactivity
|
||||
// rather than a fixed total, so slow links still work (devdesk#9).
|
||||
export function uploadFile(session, file, onProgress) {
|
||||
if (!file.size) return Promise.reject(new Error('file is empty'));
|
||||
if (file.size > UPLOAD_MAX) return Promise.reject(new Error('larger than 25 MB'));
|
||||
return limited(() => uploadNow(session, file, onProgress));
|
||||
}
|
||||
|
||||
async function uploadNow(session, file, onProgress) {
|
||||
const token = await fetchToken();
|
||||
const name = (file.name || 'pasted-image.png').slice(-100);
|
||||
const slice = 3 * 16 * 1024; // multiple of 3: base64 of slices concatenates cleanly
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(file.size)]), ['tty']);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
const decoder = new TextDecoder();
|
||||
let output = '';
|
||||
let sending = false;
|
||||
let idle = 0;
|
||||
const touch = () => {
|
||||
clearTimeout(idle);
|
||||
idle = setTimeout(() => socket.close(), 20000);
|
||||
};
|
||||
const send = async () => {
|
||||
for (let offset = 0; offset < file.size; offset += slice) {
|
||||
while (socket.bufferedAmount > 1 << 20) await sleep(20);
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
const bytes = new Uint8Array(await file.slice(offset, offset + slice).arrayBuffer());
|
||||
socket.send(encodeInput(toBase64(bytes)));
|
||||
touch();
|
||||
onProgress?.(Math.min(1, (offset + slice) / file.size));
|
||||
}
|
||||
};
|
||||
touch();
|
||||
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
|
||||
socket.onmessage = (event) => {
|
||||
touch();
|
||||
const { type, payload } = decodeServer(event.data);
|
||||
if (type !== Server.OUTPUT) return;
|
||||
output += decoder.decode(payload, { stream: true });
|
||||
if (!sending && output.includes('DEVDESK-UPLOAD-READY')) {
|
||||
sending = true;
|
||||
send();
|
||||
} else if (sending && isComplete(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, ''))) {
|
||||
socket.close(); // the reply is in; let devdesk exit (devdesk#35)
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {};
|
||||
socket.onclose = () => {
|
||||
clearTimeout(idle);
|
||||
const text = stripAnsi(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, ''));
|
||||
try {
|
||||
const result = parseListOutput(text);
|
||||
if (result.ok && result.path) {
|
||||
resolve(result.path);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the error message
|
||||
}
|
||||
const message = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop();
|
||||
reject(new Error((message || 'upload failed').replace(/^devdesk:\s*/, '')));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 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]));
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// Claude Context panel: the current session's model, context-window use, cost
|
||||
// and edits, from the snapshot devdesk-statusline writes on every Claude update.
|
||||
|
||||
import { ago } from './sessions.js';
|
||||
|
||||
function compact(tokens) {
|
||||
if (tokens == null) return '—';
|
||||
if (tokens >= 1e6) return `${+(tokens / 1e6).toFixed(tokens % 1e6 ? 1 : 0)}M`;
|
||||
if (tokens >= 1e3) return `${Math.round(tokens / 1e3)}k`;
|
||||
return String(tokens);
|
||||
}
|
||||
|
||||
export function renderContext(root, session, context) {
|
||||
const set = (field, text) => {
|
||||
const node = root.querySelector(`[data-field="${field}"]`);
|
||||
if (node) node.textContent = text;
|
||||
};
|
||||
const bar = root.querySelector('.lt-progress');
|
||||
const fill = root.querySelector('.lt-progress-bar');
|
||||
const note = root.querySelector('.dd-context-note');
|
||||
|
||||
if (!context) {
|
||||
root.dataset.state = 'empty';
|
||||
fill.style.width = '0%';
|
||||
bar.setAttribute('aria-valuenow', '0');
|
||||
for (const f of ['used', 'model', 'cost', 'lines', 'updated']) set(f, '—');
|
||||
note.textContent = session
|
||||
? 'No data yet: it appears once Claude has redrawn its status line in this session.'
|
||||
: 'Open a session to see its context.';
|
||||
return;
|
||||
}
|
||||
|
||||
const pct = context.used_pct;
|
||||
root.dataset.state = 'live';
|
||||
fill.style.width = `${Math.min(100, pct ?? 0)}%`;
|
||||
bar.setAttribute('aria-valuenow', String(Math.round(pct ?? 0)));
|
||||
bar.classList.remove('lt-progress--cyan', 'lt-progress--red');
|
||||
if (pct == null || pct < 70) bar.classList.add('lt-progress--cyan');
|
||||
else if (pct >= 90) bar.classList.add('lt-progress--red');
|
||||
|
||||
set('used', pct == null
|
||||
? `— / ${compact(context.window)}`
|
||||
: `${compact(context.used_tokens)} / ${compact(context.window)} (${pct.toFixed(pct < 10 ? 1 : 0)}%)`);
|
||||
set('model', [context.model, context.effort].filter(Boolean).join(' · ') || '—');
|
||||
set('cost', context.cost_usd == null ? '—' : `$${context.cost_usd.toFixed(2)}`);
|
||||
set('lines', `+${context.lines_added ?? 0} −${context.lines_removed ?? 0}`);
|
||||
set('updated', context.updated ? `${ago(context.updated)} ago`.replace('now ago', 'just now') : '—');
|
||||
note.textContent = pct != null && pct >= 80
|
||||
? 'Context is filling up; consider /compact or a new conversation.'
|
||||
: '';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Tiny DOM builder: el('a', { class, href, onclick }, ...children). Attributes
|
||||
// that are null/undefined/false are skipped; on* keys become event listeners.
|
||||
export function el(tag, attrs = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (value === undefined || value === null || value === false) continue;
|
||||
if (key === 'class') node.className = value;
|
||||
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
|
||||
else node.setAttribute(key, value === true ? '' : value);
|
||||
}
|
||||
node.append(...children.filter((c) => c !== null && c !== undefined));
|
||||
return node;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// 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;
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(KEY) || '[]');
|
||||
return Array.isArray(list) ? list : [];
|
||||
} 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.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 }) {
|
||||
if (waiting) this.waiting = waiting;
|
||||
if (alerts) this.alerts = alerts;
|
||||
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').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' };
|
||||
nodes.push(item({
|
||||
dot: e.read ? 'read' : kind.tone, unread: !e.read, title: e.text, meta: when(e.at),
|
||||
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 (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);
|
||||
}
|
||||
}
|
||||
+694
-72
@@ -1,11 +1,19 @@
|
||||
/* global __DEVDESK_VERSION__ */
|
||||
import { createTerminal, loadFonts } from './term.js';
|
||||
import { TtydConnection, fetchRepo, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js';
|
||||
import { parseTitle, sessionFromSearch } from './protocol.js';
|
||||
import { SessionsPanel, rememberSession } from './sessions.js';
|
||||
import { renderContext } from './context.js';
|
||||
import { THEMES, createTerminal, loadFonts } from './term.js';
|
||||
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
|
||||
import {
|
||||
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
|
||||
} from './connection.js';
|
||||
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
|
||||
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
||||
import { renderAlerts } from './alerts.js';
|
||||
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';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
@@ -32,19 +40,26 @@ const ui = {
|
||||
body: document.body,
|
||||
wsStatus: $('ws-status'),
|
||||
wsLabel: $('ws-label'),
|
||||
sso: $('sso-badge'),
|
||||
reauth: $('reauth'),
|
||||
sessionChip: $('session-chip'),
|
||||
sessionName: $('session-name'),
|
||||
switcher: $('switcher'),
|
||||
attention: $('attention-chip'),
|
||||
paneTitle: $('pane-title'),
|
||||
overlay: $('overlay'),
|
||||
overlayTitle: $('overlay-title'),
|
||||
overlayText: $('overlay-text'),
|
||||
overlayActions: $('overlay-actions'),
|
||||
moreToggle: $('more-toggle'),
|
||||
moreMenu: $('more-menu'),
|
||||
notifyButton: $('notify-toggle'),
|
||||
sidebarButton: $('sidebar-toggle'),
|
||||
backdrop: $('backdrop'),
|
||||
};
|
||||
|
||||
const urlSession = sessionFromSearch(window.location.search);
|
||||
const state = {
|
||||
session: sessionFromSearch(window.location.search), // null = picker
|
||||
session: urlSession, // null = picker
|
||||
conn: null,
|
||||
attempts: 0,
|
||||
retryTimer: null,
|
||||
@@ -53,23 +68,26 @@ const state = {
|
||||
lastActivity: Date.now(),
|
||||
};
|
||||
|
||||
const touch = window.matchMedia('(pointer: coarse)').matches;
|
||||
const isNarrow = () => !window.matchMedia('(min-width: 1100px)').matches;
|
||||
|
||||
let term;
|
||||
let fit;
|
||||
let sessionsPanel;
|
||||
let palette;
|
||||
let inbox;
|
||||
let files;
|
||||
let lastRepo = null;
|
||||
let lastAlerts = null;
|
||||
let waitingOthers = [];
|
||||
let paneTitleRaw = '';
|
||||
|
||||
// ---------------------------------------------------------------- status UI
|
||||
|
||||
function setWsState(stateName, label) {
|
||||
ui.wsStatus.dataset.state = stateName;
|
||||
ui.wsLabel.textContent = label;
|
||||
}
|
||||
|
||||
function setSso(stateName) {
|
||||
ui.sso.dataset.state = stateName;
|
||||
ui.sso.textContent = stateName === 'expired' ? 'SSO expired' : 'SSO';
|
||||
ui.sso.title = stateName === 'expired'
|
||||
? 'Authelia session expired. Click to sign in again.'
|
||||
: 'Signed in through Authelia';
|
||||
ui.wsStatus.title = label;
|
||||
}
|
||||
|
||||
function showOverlay({ title, text, actions = [], tone = 'info' }) {
|
||||
@@ -91,10 +109,168 @@ function hideOverlay() {
|
||||
ui.overlay.hidden = true;
|
||||
}
|
||||
|
||||
function showContext() {
|
||||
// Pane title (Claude's own title) only when it says more than the session name.
|
||||
function renderPaneTitle() {
|
||||
const bare = paneTitleRaw.replace(/^[^\p{L}\p{N}]+/u, '').trim();
|
||||
ui.paneTitle.textContent = bare && bare !== state.session ? paneTitleRaw : '';
|
||||
}
|
||||
|
||||
function renderSessionChip() {
|
||||
const data = sessionsPanel?.data;
|
||||
const current = data?.sessions?.find((s) => s.name === state.session);
|
||||
renderContext($('widget-context'), state.session, current?.context ?? null);
|
||||
const status = current ? sessionStatus(current) : null;
|
||||
ui.sessionName.textContent = state.session || 'picker';
|
||||
ui.sessionChip.dataset.state = status?.key ?? '';
|
||||
ui.sessionChip.title = [
|
||||
state.session ? `Session ${state.session}${status ? ` · ${status.label}` : ''}` : 'Session picker',
|
||||
current?.status?.message,
|
||||
current?.context?.used_pct != null ? `context ${Math.floor(current.context.used_pct)}%` : '',
|
||||
'Click to switch session',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
// Other sessions waiting on you: header chip, a notification when one starts
|
||||
// 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');
|
||||
for (const s of waitingOthers) {
|
||||
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');
|
||||
ui.attention.dataset.target = waitingOthers[0]?.name || '';
|
||||
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() {
|
||||
inbox?.setLive({ waiting: waitingOthers, alerts: lastAlerts?.alerts ?? [] });
|
||||
}
|
||||
|
||||
function onSessionData({ fetched = false } = {}) {
|
||||
renderSessionChip();
|
||||
renderAttention(fetched);
|
||||
if (!ui.switcher.hidden) renderSwitcher();
|
||||
}
|
||||
|
||||
function showAlerts(data) {
|
||||
lastAlerts = data;
|
||||
renderAlerts($('widget-alerts'), data);
|
||||
renderPanelsBadge();
|
||||
}
|
||||
|
||||
let alertsBusy = false;
|
||||
async function refreshAlerts() {
|
||||
if (alertsBusy) return;
|
||||
alertsBusy = true;
|
||||
try {
|
||||
showAlerts(await fetchAlerts());
|
||||
share('alerts', lastAlerts);
|
||||
} catch {
|
||||
// Keep the last rendering; the next refresh retries.
|
||||
} finally {
|
||||
alertsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- file drop / paste
|
||||
|
||||
const uploadUi = $('upload-status');
|
||||
let uploadHideTimer = 0;
|
||||
function uploadMessage(text, isError = false, sticky = false) {
|
||||
clearTimeout(uploadHideTimer);
|
||||
uploadUi.hidden = false;
|
||||
uploadUi.textContent = text;
|
||||
uploadUi.classList.toggle('is-error', isError);
|
||||
if (!sticky) uploadHideTimer = setTimeout(() => { uploadUi.hidden = true; }, isError ? 8000 : 4000);
|
||||
}
|
||||
|
||||
async function attachFiles(files) {
|
||||
if (!files.length) return;
|
||||
if (!state.session || !state.conn?.isOpen) {
|
||||
toast.warning('Open a session first: files are attached to the current session.');
|
||||
return;
|
||||
}
|
||||
const session = state.session;
|
||||
const paths = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const path = await uploadFile(session, file, (p) =>
|
||||
uploadMessage(`Uploading ${file.name || 'image'}… ${Math.round(p * 100)}%`, false, true));
|
||||
paths.push(path);
|
||||
} catch (error) {
|
||||
uploadUi.hidden = true;
|
||||
toast.error(`${file.name || 'file'}: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
uploadUi.hidden = true;
|
||||
if (session !== state.session) return;
|
||||
// A pasted image path makes Claude attach the image itself.
|
||||
term.paste(`${paths.join(' ')} `);
|
||||
term.focus();
|
||||
toast.success(paths.length === 1 ? `Attached ${paths[0].split('/').pop()}` : `Attached ${paths.length} files`);
|
||||
}
|
||||
|
||||
function wireUploads() {
|
||||
const frame = document.querySelector('.dd-terminal-frame');
|
||||
const overlay = $('drop-overlay');
|
||||
let depth = 0;
|
||||
const hasFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files');
|
||||
frame.addEventListener('dragenter', (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
depth += 1;
|
||||
$('drop-session').textContent = state.session || 'a session';
|
||||
overlay.hidden = false;
|
||||
});
|
||||
frame.addEventListener('dragover', (event) => {
|
||||
if (hasFiles(event)) event.preventDefault();
|
||||
});
|
||||
frame.addEventListener('dragleave', () => {
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (!depth) overlay.hidden = true;
|
||||
});
|
||||
frame.addEventListener('drop', (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
depth = 0;
|
||||
overlay.hidden = true;
|
||||
attachFiles([...event.dataTransfer.files]);
|
||||
});
|
||||
// Pasting a screenshot / copied file: upload it instead of pasting text.
|
||||
document.addEventListener('paste', (event) => {
|
||||
const files = [...(event.clipboardData?.files ?? [])];
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
attachFiles(files);
|
||||
}, true);
|
||||
}
|
||||
|
||||
// Repo / CI panel for the current session; notifies when HEAD's CI finishes.
|
||||
@@ -103,6 +279,7 @@ let repoBusy = false;
|
||||
async function refreshRepo() {
|
||||
const session = state.session;
|
||||
if (!session) {
|
||||
lastRepo = null;
|
||||
renderRepo($('widget-repo'), null);
|
||||
return;
|
||||
}
|
||||
@@ -112,10 +289,14 @@ async function refreshRepo() {
|
||||
const repo = await fetchRepo(session);
|
||||
if (session !== state.session) return;
|
||||
renderRepo($('widget-repo'), repo);
|
||||
lastRepo = repo;
|
||||
const key = `${session}@${repo.sha}`;
|
||||
const ci = repo.ci?.total ? repo.ci.state : '';
|
||||
if (key === lastCi.key && lastCi.state === 'pending' && ['success', 'failure', 'error'].includes(ci)) {
|
||||
notify.ring(session, `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`);
|
||||
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 {
|
||||
@@ -140,28 +321,32 @@ function updateUrl(session) {
|
||||
|
||||
// ---------------------------------------------------------------- connection
|
||||
|
||||
function connect(session) {
|
||||
// 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 } = {}) {
|
||||
clearTimeout(state.retryTimer);
|
||||
state.conn?.dispose();
|
||||
state.session = session;
|
||||
updateUrl(session);
|
||||
sessionsPanel?.setCurrent(session);
|
||||
showContext();
|
||||
ui.sessionChip.textContent = session || 'picker';
|
||||
paneTitleRaw = '';
|
||||
renderPaneTitle();
|
||||
onSessionData();
|
||||
files?.sessionChanged();
|
||||
hideOverlay();
|
||||
setWsState('connecting', 'connecting');
|
||||
state.openedAt = 0;
|
||||
term.reset();
|
||||
|
||||
const conn = new TtydConnection({
|
||||
arg: session,
|
||||
arg: session && reconnect ? ['--existing', session] : session,
|
||||
size: () => ({ cols: term.cols, rows: term.rows }),
|
||||
onOpen: () => {
|
||||
state.openedAt = Date.now();
|
||||
state.attempts = 0;
|
||||
setWsState('connected', 'live');
|
||||
setSso('ok');
|
||||
if (session) rememberSession(session);
|
||||
refreshSoon(800); // opening ?arg=<new name> just created this session
|
||||
refreshRepo();
|
||||
fit.fit();
|
||||
term.focus();
|
||||
@@ -215,12 +400,11 @@ function onClosed(code) {
|
||||
|
||||
function onFailure(error) {
|
||||
if (error instanceof AuthExpiredError) {
|
||||
setSso('expired');
|
||||
setWsState('disconnected', 'signed out');
|
||||
showOverlay({
|
||||
title: 'Sign-in expired',
|
||||
text: 'Your Authelia session has expired. Claude keeps running on the server; sign in again to reattach.',
|
||||
actions: [{ label: 'Re-authenticate', primary: true, run: () => window.location.reload() }],
|
||||
actions: [{ label: 'Sign in again', primary: true, run: () => window.location.reload() }],
|
||||
tone: 'warn',
|
||||
});
|
||||
return;
|
||||
@@ -247,13 +431,13 @@ function scheduleReconnect() {
|
||||
showOverlay({
|
||||
title: 'Reconnecting',
|
||||
text: `Connection lost; Claude keeps running on the server. Retrying in ${seconds}s (attempt ${state.attempts}).`,
|
||||
actions: [{ label: 'Retry now', primary: true, run: () => connect(state.session) }, pickerAction],
|
||||
actions: [{ label: 'Retry now', primary: true, run: () => connect(state.session, { reconnect: true }) }, pickerAction],
|
||||
});
|
||||
state.retryTimer = setTimeout(() => connect(state.session), delay);
|
||||
state.retryTimer = setTimeout(() => connect(state.session, { reconnect: true }), delay);
|
||||
}
|
||||
|
||||
function retrySoon() {
|
||||
if (state.retryTimer && !state.conn?.isOpen) connect(state.session);
|
||||
if (state.retryTimer && !state.conn?.isOpen) connect(state.session, { reconnect: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- titles & bell
|
||||
@@ -265,12 +449,14 @@ function applyTitle() {
|
||||
titleFrame = 0;
|
||||
const parsed = parseTitle(pendingTitle);
|
||||
if (!parsed) {
|
||||
ui.paneTitle.textContent = pendingTitle;
|
||||
paneTitleRaw = pendingTitle;
|
||||
renderPaneTitle();
|
||||
return;
|
||||
}
|
||||
if (parsed.session === 'picker') {
|
||||
ui.sessionChip.textContent = 'picker';
|
||||
ui.paneTitle.textContent = 'choose a session';
|
||||
ui.sessionName.textContent = 'picker';
|
||||
paneTitleRaw = 'choose a session';
|
||||
renderPaneTitle();
|
||||
notify.setBaseTitle('DevDesk · picker');
|
||||
return;
|
||||
}
|
||||
@@ -280,61 +466,459 @@ function applyTitle() {
|
||||
updateUrl(parsed.session);
|
||||
rememberSession(parsed.session);
|
||||
sessionsPanel.setCurrent(parsed.session);
|
||||
showContext();
|
||||
onSessionData();
|
||||
refreshRepo();
|
||||
files?.sessionChanged();
|
||||
}
|
||||
ui.sessionChip.textContent = parsed.session;
|
||||
ui.paneTitle.textContent = parsed.pane;
|
||||
paneTitleRaw = parsed.pane;
|
||||
renderPaneTitle();
|
||||
notify.setBaseTitle(`${parsed.session} · DevDesk`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- menus
|
||||
|
||||
const menus = [];
|
||||
function wireMenu(button, menu, onOpen) {
|
||||
menus.push({ button, menu });
|
||||
button.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const open = menu.hidden;
|
||||
closeMenus();
|
||||
if (open) {
|
||||
onOpen?.();
|
||||
menu.hidden = false;
|
||||
button.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
menu.addEventListener('click', (event) => event.stopPropagation());
|
||||
}
|
||||
|
||||
function closeMenus() {
|
||||
for (const { button, menu } of menus) {
|
||||
menu.hidden = true;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
if (inbox?.isOpen) inbox.setOpen(false);
|
||||
}
|
||||
|
||||
// Session switcher on the session chip (devdesk#16).
|
||||
function renderSwitcher() {
|
||||
const sessions = [...(sessionsPanel?.data?.sessions ?? [])].sort((a, b) =>
|
||||
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name));
|
||||
const choose = (run) => () => {
|
||||
closeMenus();
|
||||
run();
|
||||
};
|
||||
const items = sessions.map((s) => {
|
||||
const status = sessionStatus(s);
|
||||
const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : '';
|
||||
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)),
|
||||
},
|
||||
el('span', { class: `lt-dot ${status.dot}` }),
|
||||
el('span', { class: 'dd-switch-name' }, s.name),
|
||||
el('span', { class: 'dd-switch-meta' }, `${status.label}${ctx}`));
|
||||
});
|
||||
if (!items.length) items.push(el('div', { class: 'dd-menu-note' }, sessionsPanel?.data ? 'No sessions yet.' : 'Loading…'));
|
||||
ui.switcher.replaceChildren(
|
||||
...items,
|
||||
el('div', { class: 'dd-menu-sep' }),
|
||||
el('button', {
|
||||
type: 'button', role: 'menuitem', class: 'dd-menu-item',
|
||||
onclick: choose(() => sessionsPanel.newDialog()),
|
||||
}, '+ New session…'),
|
||||
el('button', { type: 'button', role: 'menuitem', class: 'dd-menu-item', onclick: choose(() => connect(null)) }, 'Open picker'),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- chrome controls
|
||||
|
||||
function setFontSize(size) {
|
||||
if (!Number.isFinite(size)) return;
|
||||
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();
|
||||
}
|
||||
|
||||
function renderNotifyButton() {
|
||||
const labels = { granted: 'Alerts on', denied: 'Alerts blocked', default: 'Alerts off', unsupported: 'No alerts' };
|
||||
async function toggleNotifications() {
|
||||
const current = notify.notificationsState();
|
||||
if (current === 'default') {
|
||||
const result = await notify.requestNotifications();
|
||||
if (result === 'granted') toast.success('Desktop notifications on');
|
||||
else if (result === 'denied') toast.warning('The browser blocked notifications for this site');
|
||||
} else if (current === 'granted') {
|
||||
notify.setMuted(!notify.isMuted());
|
||||
toast.info(notify.isMuted() ? 'Desktop notifications muted in this browser' : 'Desktop notifications unmuted');
|
||||
} else {
|
||||
toast.warning(current === 'denied' ? 'Notifications are blocked in the browser\'s site settings' : 'This browser has no notifications');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- palette & help
|
||||
|
||||
const sendKeys = (keys) => state.conn?.sendInput(keys);
|
||||
|
||||
async function copyLink() {
|
||||
const url = new URL(window.location.href);
|
||||
url.search = state.session ? `?arg=${encodeURIComponent(state.session)}` : '';
|
||||
try {
|
||||
await navigator.clipboard.writeText(url.href);
|
||||
toast.success('Link copied');
|
||||
} catch {
|
||||
toast.error('Could not copy: the browser refused clipboard access');
|
||||
}
|
||||
}
|
||||
|
||||
const openUrl = (url) => window.open(url, '_blank', 'noopener');
|
||||
|
||||
// Commands for the palette (devdesk#32), rebuilt on every open.
|
||||
function buildCommands() {
|
||||
const sessions = [...(sessionsPanel?.data?.sessions ?? [])].sort((a, b) =>
|
||||
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name));
|
||||
const current = sessions.find((s) => s.name === state.session);
|
||||
const panelsOpen = ui.body.classList.contains('dd-sidebar-open');
|
||||
const notifyState = notify.notificationsState();
|
||||
const commands = [];
|
||||
const add = (command) => commands.push(command);
|
||||
|
||||
for (const s of sessions) {
|
||||
if (s.name === state.session) continue;
|
||||
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),
|
||||
});
|
||||
}
|
||||
add({ id: 'new', group: 'Sessions', icon: '+', label: 'New session…', keywords: 'create open project', run: () => sessionsPanel.newDialog() });
|
||||
if (state.session) {
|
||||
add({ id: 'rename', group: 'Sessions', icon: '✎', label: `Rename ${state.session}…`, run: () => sessionsPanel.rename(state.session) });
|
||||
add({
|
||||
id: 'end', group: 'Sessions', icon: '×', label: `End ${state.session}…`, keywords: 'close kill stop',
|
||||
run: () => sessionsPanel.end(current ?? { name: state.session, running: true }),
|
||||
});
|
||||
add({ id: 'copy-link', group: 'Sessions', icon: '⧉', label: 'Copy link to this session', keywords: 'url share', run: copyLink });
|
||||
}
|
||||
add({ id: 'picker', group: 'Sessions', icon: '≡', label: 'Open the session picker', keywords: 'fzf list', run: () => connect(null) });
|
||||
|
||||
if (state.session && state.conn?.isOpen) {
|
||||
add({ id: 'win-claude', group: 'Terminal', icon: '0', label: 'Claude window', kbd: '^b 0', run: () => sendKeys('\x020') });
|
||||
add({ id: 'win-shell', group: 'Terminal', icon: '1', label: 'Shell window', kbd: '^b 1', run: () => sendKeys('\x021') });
|
||||
add({ id: 'scroll', group: 'Terminal', icon: '⇞', label: 'Scroll back (tmux copy mode, q to leave)', kbd: '^b [', keywords: 'history scrollback', run: () => sendKeys('\x02[') });
|
||||
add({ id: 'detach', group: 'Terminal', icon: '⏏', label: 'Detach (the session keeps running)', kbd: '^b d', run: () => sendKeys('\x02d') });
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
add({ id: 'panels', group: 'View', icon: '▤', label: panelsOpen ? 'Hide panels' : 'Show panels', keywords: 'sidebar', run: () => setSidebar(!panelsOpen) });
|
||||
add({ id: 'font-up', group: 'View', icon: 'A+', label: 'Larger text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize + 1) });
|
||||
add({ id: 'font-down', group: 'View', icon: 'A−', label: 'Smaller text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize - 1) });
|
||||
add({ id: 'font-reset', group: 'View', icon: 'A', label: 'Reset text size', keywords: 'font zoom', run: () => setFontSize(14) });
|
||||
add({ id: 'fullscreen', group: 'View', icon: '⛶', label: document.fullscreenElement ? 'Exit fullscreen' : 'Fullscreen', run: toggleFullscreen });
|
||||
if (notifyState !== 'unsupported') {
|
||||
add({
|
||||
id: 'notify', group: 'View', icon: '🔔', keywords: 'notifications alerts bell',
|
||||
label: { default: 'Enable desktop notifications', denied: 'Desktop notifications (blocked)', granted: notify.isMuted() ? 'Unmute desktop notifications' : 'Mute desktop notifications' }[notifyState],
|
||||
run: () => toggleNotifications().then(renderNotifyButton),
|
||||
});
|
||||
}
|
||||
|
||||
if (lastRepo?.url) {
|
||||
add({ id: 'open-repo', group: 'Links', icon: '↗', label: `Open ${lastRepo.repo} in Gitea`, run: () => openUrl(lastRepo.url) });
|
||||
add({ id: 'open-prs', group: 'Links', icon: '↗', label: 'Open pull requests', run: () => openUrl(`${lastRepo.url}/pulls`) });
|
||||
if (lastRepo.ci?.url) add({ id: 'open-ci', group: 'Links', icon: '↗', label: `Open CI run (${lastRepo.ci.state})`, run: () => openUrl(lastRepo.ci.url) });
|
||||
}
|
||||
if (lastAlerts?.alerts?.length) {
|
||||
add({
|
||||
id: 'alerts', group: 'Links', icon: '!', label: `Show ${lastAlerts.alerts.length} ops alert(s)`, keywords: 'grafana',
|
||||
run: () => {
|
||||
setSidebar(true);
|
||||
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
},
|
||||
});
|
||||
}
|
||||
add({ id: 'help', group: 'Help', icon: '?', label: 'Keyboard shortcuts', keywords: 'keys help', run: showHelp });
|
||||
|
||||
// After a command, typing goes back to the terminal unless it opened a dialog.
|
||||
return commands.map((c) => ({
|
||||
...c,
|
||||
run: () => {
|
||||
c.run();
|
||||
if (!dialogOpen()) term.focus();
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const KEY_HELP = [
|
||||
['Claude', [
|
||||
['⇧ Enter', 'New line in the prompt'],
|
||||
['Esc', 'Interrupt Claude'],
|
||||
['⇧ Tab', 'Cycle mode (auto-accept, plan…)'],
|
||||
['↑ / ↓', 'Previous / next prompt'],
|
||||
['Ctrl+C', 'Cancel; twice to quit (the menu offers resume)'],
|
||||
]],
|
||||
['Windows & sessions (tmux)', [
|
||||
['Ctrl-b 0 / 1', 'Claude window / shell window'],
|
||||
['Ctrl-b s', 'Switch session (the URL follows)'],
|
||||
['Ctrl-b [', 'Scroll back; q to leave'],
|
||||
['Ctrl-b d', 'Detach (the session keeps running)'],
|
||||
]],
|
||||
['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'],
|
||||
['Ctrl+W', 'Asks before closing; Chromium fullscreen keeps it for the terminal'],
|
||||
]],
|
||||
];
|
||||
|
||||
function showHelp() {
|
||||
const body = el('div', { class: 'dd-keys' }, ...KEY_HELP.map(([title, rows]) => el('section', {},
|
||||
el('h3', { class: 'dd-keys-title' }, title),
|
||||
el('dl', { class: 'dd-keys-list' }, ...rows.flatMap(([keys, what]) => [el('dt', {}, el('kbd', {}, keys)), el('dd', {}, what)])))));
|
||||
if (touch) body.append(el('p', { class: 'dd-dialog-hint' }, 'On a phone, the key bar under the terminal sends Esc, Tab, ⇧Tab, Ctrl+C, arrows, Enter and the window switches.'));
|
||||
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();
|
||||
return;
|
||||
}
|
||||
await document.documentElement.requestFullscreen();
|
||||
// Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen.
|
||||
navigator.keyboard?.lock?.().catch(() => {});
|
||||
}
|
||||
|
||||
function renderNotifyButton() {
|
||||
const current = notify.notificationsState();
|
||||
const labels = {
|
||||
unsupported: 'Desktop notifications: unsupported',
|
||||
denied: 'Desktop notifications: blocked by the browser',
|
||||
default: 'Enable desktop notifications',
|
||||
granted: notify.isMuted() ? 'Desktop notifications: muted (unmute)' : 'Desktop notifications: on (mute)',
|
||||
};
|
||||
ui.notifyButton.textContent = labels[current];
|
||||
ui.notifyButton.dataset.state = current;
|
||||
ui.notifyButton.disabled = current === 'unsupported' || current === 'denied';
|
||||
}
|
||||
|
||||
function setSidebar(open) {
|
||||
if (open && files?.isOpen) files.close();
|
||||
ui.body.classList.toggle('dd-sidebar-open', open);
|
||||
ui.sidebarButton.setAttribute('aria-expanded', String(open));
|
||||
store.set('sidebar', open ? 'open' : 'closed');
|
||||
if (open) sessionsPanel?.refresh();
|
||||
ui.backdrop.hidden = !(open && isNarrow());
|
||||
if (!isNarrow()) store.set('sidebar', open ? 'open' : 'closed');
|
||||
if (open) {
|
||||
sessionsPanel?.refresh();
|
||||
refreshAlerts();
|
||||
refreshRepo();
|
||||
}
|
||||
renderPanelsBadge();
|
||||
}
|
||||
|
||||
function closeDrawerIfNarrow() {
|
||||
if (isNarrow() && ui.body.classList.contains('dd-sidebar-open')) setSidebar(false);
|
||||
}
|
||||
|
||||
// Phone key bar: keys an on-screen keyboard can't send (devdesk#14).
|
||||
const KEYS = {
|
||||
esc: '\x1b', tab: '\t', stab: '\x1b[Z', ctrlc: '\x03', up: '\x1b[A', down: '\x1b[B', enter: '\r',
|
||||
claude: '\x020', shell: '\x021', // tmux prefix (C-b) + window number
|
||||
};
|
||||
|
||||
function wireKeybar() {
|
||||
if (!touch) return;
|
||||
ui.body.classList.add('dd-touch');
|
||||
const bar = $('keybar');
|
||||
bar.hidden = false;
|
||||
// Don't steal focus from the terminal (that would close the on-screen keyboard).
|
||||
bar.addEventListener('pointerdown', (event) => event.preventDefault());
|
||||
bar.addEventListener('click', (event) => {
|
||||
const key = event.target.closest('[data-keys]')?.dataset.keys;
|
||||
if (key && KEYS[key]) state.conn?.sendInput(KEYS[key]);
|
||||
});
|
||||
// Keep the terminal above the on-screen keyboard.
|
||||
const vv = window.visualViewport;
|
||||
if (vv) {
|
||||
const update = () => {
|
||||
const hidden = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
|
||||
document.documentElement.style.setProperty('--dd-kb', `${hidden}px`);
|
||||
};
|
||||
vv.addEventListener('resize', update);
|
||||
vv.addEventListener('scroll', update);
|
||||
}
|
||||
}
|
||||
|
||||
function wireChrome() {
|
||||
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
|
||||
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
|
||||
ui.notifyButton.addEventListener('click', async () => {
|
||||
await notify.requestNotifications();
|
||||
await toggleNotifications();
|
||||
renderNotifyButton();
|
||||
});
|
||||
renderNotifyButton();
|
||||
$('fullscreen-toggle').addEventListener('click', async () => {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
$('fullscreen-toggle').addEventListener('click', () => {
|
||||
closeMenus();
|
||||
toggleFullscreen();
|
||||
});
|
||||
$('help-open').addEventListener('click', () => {
|
||||
closeMenus();
|
||||
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;
|
||||
}
|
||||
await document.documentElement.requestFullscreen();
|
||||
// Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen.
|
||||
navigator.keyboard?.lock?.().catch(() => {});
|
||||
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (palette.isOpen) palette.close();
|
||||
else if (!dialogOpen()) palette.open();
|
||||
}
|
||||
}, true);
|
||||
wireMenu(ui.moreToggle, ui.moreMenu, () => {
|
||||
renderNotifyButton();
|
||||
$('theme-menu').textContent = getTheme() === 'light' ? 'Dark mode' : 'Light mode';
|
||||
});
|
||||
ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open')));
|
||||
const wide = window.matchMedia('(min-width: 1100px)').matches;
|
||||
setSidebar(store.get('sidebar', wide ? 'open' : 'closed') === 'open');
|
||||
$('theme-menu').addEventListener('click', () => {
|
||||
closeMenus();
|
||||
toggleTheme();
|
||||
});
|
||||
inbox = new Inbox({
|
||||
button: $('inbox-toggle'),
|
||||
badge: $('inbox-badge'),
|
||||
panel: $('inbox-panel'),
|
||||
hooks: {
|
||||
openSession: (name) => connect(name),
|
||||
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();
|
||||
});
|
||||
document.addEventListener('click', closeMenus);
|
||||
|
||||
ui.sso.addEventListener('click', () => {
|
||||
if (ui.sso.dataset.state === 'expired') window.location.reload();
|
||||
ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open')));
|
||||
ui.backdrop.addEventListener('click', () => setSidebar(false));
|
||||
setSidebar(!isNarrow() && store.get('sidebar', 'open') === 'open');
|
||||
window.matchMedia('(min-width: 1100px)').addEventListener('change', () => {
|
||||
ui.backdrop.hidden = !(ui.body.classList.contains('dd-sidebar-open') && isNarrow());
|
||||
});
|
||||
|
||||
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape' || dialogOpen()) return;
|
||||
// Files pane: Esc goes back / closes, unless the terminal or CodeMirror's
|
||||
// own search panel has the focus.
|
||||
if (files?.isOpen && !event.target.closest?.('.xterm, .cm-panels') && !menus.some(({ menu }) => !menu.hidden) && !inbox.isOpen) {
|
||||
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();
|
||||
event.stopPropagation();
|
||||
closeMenus();
|
||||
if (drawerOpen) setSidebar(false);
|
||||
}, true);
|
||||
|
||||
// "Sign in again" appears only when a request finds the SSO session expired (devdesk#13).
|
||||
window.addEventListener('devdesk:auth', (event) => {
|
||||
ui.reauth.hidden = event.detail !== 'expired';
|
||||
});
|
||||
ui.reauth.addEventListener('click', () => window.location.reload());
|
||||
|
||||
// Ctrl+W closes the tab before the terminal sees it; ask first.
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (state.conn?.isOpen && state.session) {
|
||||
@@ -347,36 +931,66 @@ function wireChrome() {
|
||||
document.addEventListener(type, () => { state.lastActivity = Date.now(); }, { capture: true, passive: true });
|
||||
}
|
||||
// While you're active, touch the Authelia gate so its inactivity timer doesn't lapse.
|
||||
setInterval(async () => {
|
||||
setInterval(() => {
|
||||
if (Date.now() - state.lastActivity > 10 * 60 * 1000) return;
|
||||
try {
|
||||
await fetchToken();
|
||||
setSso('ok');
|
||||
} catch (error) {
|
||||
if (error instanceof AuthExpiredError) setSso('expired');
|
||||
}
|
||||
fetchToken().catch(() => {});
|
||||
}, 3 * 60 * 1000);
|
||||
|
||||
window.addEventListener('online', retrySoon);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) retrySoon();
|
||||
});
|
||||
// Session states feed the header chip and the notifications for other sessions,
|
||||
// so poll even in a background tab (browsers throttle that to about once a
|
||||
// minute) — unless another tab just shared a fresh list (devdesk#8, #15).
|
||||
onShared('sessions', (data) => sessionsPanel.accept(data));
|
||||
setInterval(() => {
|
||||
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) sessionsPanel.refresh();
|
||||
if (!heardWithin('sessions', 16 * 1000)) sessionsPanel.refresh();
|
||||
}, 15 * 1000);
|
||||
// Alerts feed the Panels badge too.
|
||||
onShared('alerts', showAlerts);
|
||||
setInterval(() => {
|
||||
if (!heardWithin('alerts', 62 * 1000)) refreshAlerts();
|
||||
}, 60 * 1000);
|
||||
// Repo status: on connect, after Claude's turns, and once a minute (devdesk#5, #8).
|
||||
setInterval(() => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// A bare URL (no ?arg=) reattaches the session you used last, or the most
|
||||
// recently active one; the picker is only the fallback (devdesk#16).
|
||||
function defaultSession(data) {
|
||||
const sessions = data?.sessions ?? [];
|
||||
const known = new Set(sessions.map((s) => s.name));
|
||||
const recent = recentNames().find((name) => known.has(name));
|
||||
if (recent) return recent;
|
||||
const running = sessions.filter((s) => s.running).sort((a, b) => (b.activity || 0) - (a.activity || 0));
|
||||
return running[0]?.name ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- boot
|
||||
|
||||
async function boot() {
|
||||
initTheme();
|
||||
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
|
||||
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
|
||||
$('font-size').textContent = String(fontSize);
|
||||
await loadFonts(fontSize);
|
||||
|
||||
({ term, fit } = createTerminal($('terminal'), {
|
||||
fontSize,
|
||||
theme: getTheme(),
|
||||
onShiftEnter: () => state.conn?.sendInput('\n'),
|
||||
}));
|
||||
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));
|
||||
term.onResize(({ cols, rows }) => state.conn?.resize(cols, rows));
|
||||
@@ -385,8 +999,10 @@ async function boot() {
|
||||
if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle);
|
||||
});
|
||||
term.onBell(() => {
|
||||
notify.ring(state.session, ui.paneTitle.textContent);
|
||||
refreshSoon(); // Claude finished a turn: context numbers just changed
|
||||
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;
|
||||
@@ -406,11 +1022,13 @@ async function boot() {
|
||||
nameInput: $('new-session-name'),
|
||||
select: $('project-select'),
|
||||
refreshButton: $('sessions-refresh'),
|
||||
pickerButton: $('open-picker'),
|
||||
newToggle: $('new-session-toggle'),
|
||||
status: $('sessions-status'),
|
||||
hooks: {
|
||||
onOpen: (name) => connect(name),
|
||||
onPicker: () => connect(null),
|
||||
onOpen: (name) => {
|
||||
closeDrawerIfNarrow();
|
||||
connect(name);
|
||||
},
|
||||
onEnding: () => { state.endingCurrent = true; },
|
||||
onEnded: (name) => {
|
||||
if (name !== state.session) state.endingCurrent = false;
|
||||
@@ -421,21 +1039,25 @@ async function boot() {
|
||||
state.session = to;
|
||||
updateUrl(to);
|
||||
rememberSession(to);
|
||||
ui.sessionChip.textContent = to;
|
||||
sessionsPanel.setCurrent(to);
|
||||
renderSessionChip();
|
||||
},
|
||||
onData: () => {
|
||||
showContext();
|
||||
refreshRepo();
|
||||
},
|
||||
onData: (_data, meta) => onSessionData(meta),
|
||||
},
|
||||
});
|
||||
bindAskClaude($('sidebar'), (text) => {
|
||||
state.conn?.sendInput(text);
|
||||
// Alert text is untrusted: no control characters (a newline would run it in a
|
||||
// shell window, CR would submit), and bracketed paste around it (devdesk#2).
|
||||
term.paste(pasteSafe(text));
|
||||
closeDrawerIfNarrow();
|
||||
term.focus();
|
||||
});
|
||||
wireChrome();
|
||||
connect(state.session);
|
||||
wireKeybar();
|
||||
wireUploads();
|
||||
refreshAlerts();
|
||||
await sessionsPanel.refresh();
|
||||
connect(urlSession ?? defaultSession(sessionsPanel.data));
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
+19
-1
@@ -32,11 +32,29 @@ export async function requestNotifications() {
|
||||
return Notification.requestPermission();
|
||||
}
|
||||
|
||||
const MUTE_KEY = 'devdesk.notify.muted';
|
||||
|
||||
export function isMuted() {
|
||||
try {
|
||||
return localStorage.getItem(MUTE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setMuted(muted) {
|
||||
try {
|
||||
localStorage.setItem(MUTE_KEY, muted ? '1' : '0');
|
||||
} catch {
|
||||
// Private mode: the mute just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
export function ring(session, detail) {
|
||||
if (attentive()) return;
|
||||
unread += 1;
|
||||
render();
|
||||
if (notificationsState() === 'granted') {
|
||||
if (notificationsState() === 'granted' && !isMuted()) {
|
||||
const note = new Notification(`Claude · ${session || 'devdesk'}`, {
|
||||
body: detail || 'Claude needs your attention',
|
||||
tag: `devdesk-${session}`,
|
||||
|
||||
@@ -67,6 +67,14 @@ export function slugify(text) {
|
||||
.replace(/^[-_]+/, '').replace(/-+$/, '').slice(0, 64);
|
||||
}
|
||||
|
||||
// Untrusted text (e.g. Grafana alert annotations) typed into a terminal: drop
|
||||
// every control character so it can't run a command (newline in a shell),
|
||||
// submit early (CR) or inject escape sequences (devdesk#2).
|
||||
export function pasteSafe(text) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return String(text ?? '').replace(/[\x00-\x1f\x7f]+/g, ' ');
|
||||
}
|
||||
|
||||
export function stripAnsi(text) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '');
|
||||
|
||||
+16
-11
@@ -21,21 +21,21 @@ function link(href, text) {
|
||||
export function renderRepo(root, repo) {
|
||||
const field = (name) => root.querySelector(`[data-field="${name}"]`);
|
||||
const set = (name, value) => field(name).replaceChildren(value);
|
||||
const grid = root.querySelector('.dd-repo-grid');
|
||||
const note = root.querySelector('.dd-repo-note');
|
||||
const fix = root.querySelector('.dd-fix-ci');
|
||||
fix.hidden = true;
|
||||
|
||||
// No repository: one line instead of a grid of dashes (devdesk#17).
|
||||
if (!repo || !repo.git) {
|
||||
root.dataset.state = 'empty';
|
||||
for (const f of ['repo', 'branch', 'dirty', 'prs']) set(f, '—');
|
||||
const ci = field('ci');
|
||||
ci.className = 'lt-status lt-status-pending';
|
||||
ci.textContent = 'no data';
|
||||
note.textContent = repo
|
||||
? `${repo.dir} is not a git repository.`
|
||||
: 'Open a session to see its repository.';
|
||||
grid.hidden = true;
|
||||
note.textContent = repo ? `Not a git repository: ${repo.dir}` : 'Open a session to see its repository.';
|
||||
return;
|
||||
}
|
||||
|
||||
root.dataset.state = 'live';
|
||||
grid.hidden = false;
|
||||
set('repo', repo.url ? link(repo.url, repo.repo) : (repo.remote || 'no remote'));
|
||||
const sync = repo.upstream
|
||||
? [repo.ahead ? `↑${repo.ahead}` : '', repo.behind ? `↓${repo.behind}` : ''].filter(Boolean).join(' ') || 'in sync'
|
||||
@@ -45,12 +45,17 @@ export function renderRepo(root, repo) {
|
||||
|
||||
const ci = field('ci');
|
||||
const [label, cls] = repo.ci?.total ? (CI[repo.ci.state] || [repo.ci.state, 'lt-status-pending'])
|
||||
: [repo.ahead ? 'not pushed' : 'no CI', 'lt-status-pending'];
|
||||
: [repo.ahead ? 'not pushed' : 'no CI', ''];
|
||||
ci.className = `lt-status ${cls}`;
|
||||
ci.replaceChildren(repo.ci?.url ? link(repo.ci.url, label) : label);
|
||||
|
||||
set('prs', repo.url && repo.prs != null
|
||||
? link(`${repo.url}/pulls`, `${repo.prs} open`)
|
||||
: '—');
|
||||
set('prs', repo.url && repo.prs != null ? link(`${repo.url}/pulls`, `${repo.prs} open`) : '—');
|
||||
note.textContent = repo.repo ? '' : 'Not a Gitea repository; CI status unavailable.';
|
||||
|
||||
if (repo.ci?.total && ['failure', 'error'].includes(repo.ci.state)) {
|
||||
fix.hidden = false;
|
||||
fix.dataset.askClaude = `CI is failing for ${repo.repo} on ${repo.branch} at ${repo.sha}`
|
||||
+ ` (${repo.ci.url || 'see Gitea Actions'}). Use the gitea MCP server to read the failed job's log,`
|
||||
+ ' find the cause, and fix it. Tell me the plan before pushing anything.';
|
||||
}
|
||||
}
|
||||
|
||||
+171
-55
@@ -3,6 +3,11 @@
|
||||
|
||||
import { fetchSessionList, sessionAction } from './connection.js';
|
||||
import { slugify } from './protocol.js';
|
||||
import { share } from './shared.js';
|
||||
import { el } from './dom.js';
|
||||
import { confirmDialog, openDialog, toast } from './ui.js';
|
||||
|
||||
export { el };
|
||||
|
||||
const RECENTS_KEY = 'devdesk.recents';
|
||||
const MAX_RECENTS = 12;
|
||||
@@ -24,6 +29,11 @@ function writeRecents(recents) {
|
||||
}
|
||||
}
|
||||
|
||||
// Session names this browser used most recently, newest first.
|
||||
export function recentNames() {
|
||||
return readRecents().map((r) => r.name);
|
||||
}
|
||||
|
||||
export function rememberSession(name) {
|
||||
if (name) writeRecents([{ name, at: Date.now() }, ...readRecents().filter((r) => r.name !== name)]);
|
||||
}
|
||||
@@ -45,33 +55,42 @@ export function ago(epochSeconds) {
|
||||
return `${Math.floor(s / 86400)}d`;
|
||||
}
|
||||
|
||||
export function el(tag, attrs = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (value === undefined || value === null || value === false) continue;
|
||||
if (key === 'class') node.className = value;
|
||||
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
|
||||
else node.setAttribute(key, value === true ? '' : value);
|
||||
|
||||
// What Claude is doing in a session, from the devdesk-state-hook snapshot.
|
||||
// Returns { key, label, dot } for display; key is one of
|
||||
// waiting | working | idle | exited | running | dormant.
|
||||
export function sessionStatus(s) {
|
||||
if (!s?.running) return { key: 'dormant', label: 'dormant', dot: 'lt-dot-idle' };
|
||||
switch (s.status?.state) {
|
||||
case 'waiting': return { key: 'waiting', label: 'needs you', dot: 'lt-dot-down' };
|
||||
case 'working': return { key: 'working', label: 'working', dot: 'lt-dot-warn' };
|
||||
case 'idle': return { key: 'idle', label: 'idle', dot: 'lt-dot-up' };
|
||||
case 'exited': return { key: 'exited', label: 'Claude exited', dot: 'lt-dot-idle' };
|
||||
default: return { key: 'running', label: 'running', dot: 'lt-dot-up' };
|
||||
}
|
||||
node.append(...children.filter((c) => c !== null && c !== undefined));
|
||||
return node;
|
||||
}
|
||||
|
||||
export class SessionsPanel {
|
||||
// hooks: onOpen(name), onPicker(), onEnded(name), onRenamed(from, to), onData(data)
|
||||
constructor({ list, form, nameInput, select, refreshButton, pickerButton, status, hooks }) {
|
||||
Object.assign(this, { list, nameInput, select, status, hooks });
|
||||
// hooks: onOpen(name), onEnding(name), onEnded(name), onEndAborted(), onRenamed(from, to), onData(data)
|
||||
constructor({ list, form, nameInput, select, refreshButton, newToggle, status, hooks }) {
|
||||
Object.assign(this, { list, form, nameInput, select, newToggle, status, hooks });
|
||||
this.current = null;
|
||||
this.data = null;
|
||||
this.busy = false;
|
||||
this.pending = null;
|
||||
refreshButton.addEventListener('click', () => this.refresh());
|
||||
pickerButton.addEventListener('click', () => hooks.onPicker());
|
||||
newToggle.addEventListener('click', () => this.toggleForm());
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
this.createFromForm();
|
||||
});
|
||||
}
|
||||
|
||||
toggleForm(open = this.form.hidden) {
|
||||
this.form.hidden = !open;
|
||||
this.newToggle.setAttribute('aria-expanded', String(open));
|
||||
if (open) this.nameInput.focus();
|
||||
}
|
||||
|
||||
setCurrent(name) {
|
||||
this.current = name;
|
||||
if (this.data) this.render();
|
||||
@@ -82,29 +101,46 @@ export class SessionsPanel {
|
||||
this.status.classList.toggle('is-error', isError);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (this.busy) return;
|
||||
this.busy = true;
|
||||
// Concurrent callers share the request in flight (boot awaits the first list).
|
||||
refresh() {
|
||||
this.pending ??= this.load().finally(() => { this.pending = null; });
|
||||
return this.pending;
|
||||
}
|
||||
|
||||
// After an action: a poll already in flight may predate it, so fetch again.
|
||||
async reload() {
|
||||
await this.pending;
|
||||
return this.refresh();
|
||||
}
|
||||
|
||||
async load() {
|
||||
let fetched = false;
|
||||
try {
|
||||
this.data = await fetchSessionList();
|
||||
fetched = true;
|
||||
share('sessions', this.data);
|
||||
if (!this.status.classList.contains('is-error')) this.setStatus('');
|
||||
} catch (err) {
|
||||
this.setStatus(`list unavailable: ${err.message}`, true);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
this.render();
|
||||
this.hooks.onData?.(this.data);
|
||||
this.hooks.onData?.(this.data, { fetched });
|
||||
}
|
||||
|
||||
async act(label, action, ...args) {
|
||||
this.setStatus(`${label}…`);
|
||||
// A list another tab fetched (devdesk#8).
|
||||
accept(data) {
|
||||
this.data = data;
|
||||
this.render();
|
||||
this.hooks.onData?.(this.data, { fetched: false });
|
||||
}
|
||||
|
||||
// Side-channel action; failures become an error toast (devdesk#34).
|
||||
async act(action, ...args) {
|
||||
try {
|
||||
await sessionAction(action, ...args);
|
||||
this.setStatus('');
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.setStatus(err.message, true);
|
||||
toast.error(err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -113,42 +149,116 @@ export class SessionsPanel {
|
||||
const project = this.select.value;
|
||||
const name = slugify(this.nameInput.value) || project;
|
||||
if (!name) {
|
||||
this.setStatus('Type a session name or pick a project.', true);
|
||||
toast.warning('Type a session name or pick a project.');
|
||||
this.nameInput.focus();
|
||||
return;
|
||||
}
|
||||
// With a project the session starts in that folder; without one, in the default folder.
|
||||
if (project && !(await this.act('creating', 'new', name, project))) return;
|
||||
if (!(await this.create(name, project))) return;
|
||||
this.nameInput.value = '';
|
||||
this.select.value = '';
|
||||
this.toggleForm(false);
|
||||
}
|
||||
|
||||
// With a project the session starts in that folder; without one, opening it
|
||||
// creates it in the default folder (or its own project folder, by name).
|
||||
async create(name, project) {
|
||||
if (project && !(await this.act('new', name, project))) return false;
|
||||
this.hooks.onOpen(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
projectOptions(select) {
|
||||
const projects = this.data?.projects ?? [];
|
||||
const chosen = select.value;
|
||||
select.replaceChildren(
|
||||
el('option', { value: '' }, `No project (${this.data?.root ?? 'default folder'})`),
|
||||
...projects.filter((p) => p.name !== this.data?.default)
|
||||
.map((p) => el('option', { value: p.name, title: p.dir }, p.name)));
|
||||
select.value = chosen;
|
||||
}
|
||||
|
||||
// "+ New session…" from the switcher or the command palette (devdesk#33).
|
||||
async newDialog() {
|
||||
const name = el('input', { class: 'lt-input', maxlength: '64', placeholder: 'e.g. pbs-upgrade', spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-new-hint' });
|
||||
const project = el('select', { class: 'lt-select' });
|
||||
this.projectOptions(project);
|
||||
const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-new-hint' }, 'Letters, numbers, - and _. Any name works; pick a project to start in its folder.');
|
||||
project.addEventListener('change', () => {
|
||||
if (!name.value.trim()) name.placeholder = project.value || 'e.g. pbs-upgrade';
|
||||
});
|
||||
const result = await openDialog({
|
||||
title: 'New session',
|
||||
body: el('div', { class: 'dd-dialog-fields' },
|
||||
el('label', { class: 'dd-field' }, el('span', {}, 'Name'), name),
|
||||
el('label', { class: 'dd-field' }, el('span', {}, 'Project folder'), project),
|
||||
hint),
|
||||
actions: [{ label: 'Cancel' }, { label: 'Open', primary: true }],
|
||||
submit: () => {
|
||||
const value = slugify(name.value) || project.value;
|
||||
if (!value) {
|
||||
hint.textContent = 'Type a name or pick a project.';
|
||||
hint.classList.add('is-error');
|
||||
name.focus();
|
||||
return null;
|
||||
}
|
||||
return { name: value, project: project.value };
|
||||
},
|
||||
});
|
||||
if (result) await this.create(result.name, result.project);
|
||||
}
|
||||
|
||||
async rename(name) {
|
||||
const answer = window.prompt(`Rename session "${name}" to:`, name);
|
||||
if (answer === null) return;
|
||||
const next = slugify(answer);
|
||||
if (!next || next === name) {
|
||||
if (!next) this.setStatus('Names use letters, numbers, - and _.', true);
|
||||
return;
|
||||
}
|
||||
if (await this.act('renaming', 'rename', name, next)) {
|
||||
const input = el('input', { class: 'lt-input', maxlength: '64', value: name, spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-rename-hint' });
|
||||
const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-rename-hint' }, 'Claude keeps running; the conversation moves with the session.');
|
||||
const next = await openDialog({
|
||||
title: `Rename ${name}`,
|
||||
size: 'sm',
|
||||
body: el('div', { class: 'dd-dialog-fields' }, el('label', { class: 'dd-field' }, el('span', {}, 'New name'), input), hint),
|
||||
actions: [{ label: 'Cancel' }, { label: 'Rename', primary: true }],
|
||||
onOpen: () => {
|
||||
input.select();
|
||||
return input;
|
||||
},
|
||||
submit: () => {
|
||||
const value = slugify(input.value);
|
||||
if (!value) {
|
||||
hint.textContent = 'Names use letters, numbers, - and _.';
|
||||
hint.classList.add('is-error');
|
||||
return null;
|
||||
}
|
||||
if (value !== name && this.data?.sessions?.some((x) => x.name === value)) {
|
||||
hint.textContent = `A session called ${value} already exists.`;
|
||||
hint.classList.add('is-error');
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
if (!next || next === name) return;
|
||||
if (await this.act('rename', name, next)) {
|
||||
renameRecent(name, next);
|
||||
this.hooks.onRenamed(name, next);
|
||||
await this.refresh();
|
||||
toast.success(`Renamed ${name} → ${next}`);
|
||||
await this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async end(session) {
|
||||
const question = session.running
|
||||
? `End session "${session.name}"?\n\nClaude in this session stops. The conversation stays in Claude's history (claude --resume).`
|
||||
: `Forget dormant session "${session.name}"?`;
|
||||
if (!window.confirm(question)) return;
|
||||
const ok = await confirmDialog({
|
||||
title: session.running ? `End ${session.name}?` : `Forget ${session.name}?`,
|
||||
text: session.running
|
||||
? 'Claude in this session stops and the session closes. The conversation stays in Claude\'s history (claude --resume).'
|
||||
: 'This dormant session is removed from the list. Its conversation stays in Claude\'s history.',
|
||||
confirmLabel: session.running ? 'End session' : 'Forget',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
if (session.name === this.current) this.hooks.onEnding?.(session.name);
|
||||
if (await this.act(session.running ? 'ending' : 'forgetting', 'end', session.name)) {
|
||||
if (await this.act('end', session.name)) {
|
||||
forgetRecent(session.name);
|
||||
this.hooks.onEnded(session.name);
|
||||
await this.refresh();
|
||||
toast.success(session.running ? `Ended ${session.name}` : `Forgot ${session.name}`);
|
||||
await this.reload();
|
||||
} else if (session.name === this.current) {
|
||||
this.hooks.onEndAborted?.();
|
||||
}
|
||||
@@ -156,17 +266,29 @@ export class SessionsPanel {
|
||||
|
||||
renderSession(s) {
|
||||
const isCurrent = s.name === this.current;
|
||||
const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : '';
|
||||
const status = sessionStatus(s);
|
||||
const ctx = s.context?.used_pct != null ? `ctx ${Math.floor(s.context.used_pct)}%` : '';
|
||||
const since = s.status?.since ? ago(s.status.since) : ago(s.activity);
|
||||
const model = isCurrent && s.context?.model ? s.context.model.replace(/^Claude\s+/i, '') : '';
|
||||
const meta = s.running
|
||||
? `${s.attached} attached · ${ago(s.activity)}${ctx}`
|
||||
? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, model, s.attached ? `${s.attached} open` : '']
|
||||
.filter(Boolean).join(' · ')
|
||||
: 'dormant · resumes Claude';
|
||||
const pct = s.context?.used_pct;
|
||||
const title = [
|
||||
s.dir,
|
||||
status.key === 'waiting' && s.status?.message,
|
||||
s.context?.model && `${s.context.model}${s.context.cost_usd != null ? ` · $${s.context.cost_usd.toFixed(2)}` : ''}`,
|
||||
].filter(Boolean).join('\n');
|
||||
const iconButton = (label, glyph, onclick, extra = '') =>
|
||||
el('button', { class: `dd-icon-btn ${extra}`, type: 'button', title: label, 'aria-label': `${label} ${s.name}`, onclick }, glyph);
|
||||
return el('li', { class: `dd-session-item${isCurrent ? ' is-current' : ''}${s.running ? '' : ' is-dormant'}` },
|
||||
el('button', { class: 'dd-session-open', type: 'button', title: s.dir || '', onclick: () => this.hooks.onOpen(s.name) },
|
||||
el('span', { class: `lt-dot ${s.running ? 'lt-dot-up' : 'lt-dot-idle'}` }),
|
||||
return el('li', { class: `dd-session-item is-${status.key}${isCurrent ? ' is-current' : ''}` },
|
||||
el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
|
||||
el('span', { class: `lt-dot ${status.dot}` }),
|
||||
el('span', { class: 'dd-session-name' }, s.name),
|
||||
el('span', { class: 'dd-session-meta' }, meta)),
|
||||
el('span', { class: 'dd-session-meta' }, meta),
|
||||
pct != null ? el('span', { class: `dd-ctx${pct >= 90 ? ' is-full' : pct >= 70 ? ' is-high' : ''}`, 'aria-hidden': 'true' },
|
||||
el('span', { class: 'dd-ctx-fill', style: `width:${Math.min(100, pct)}%` })) : null),
|
||||
el('span', { class: 'dd-session-actions' },
|
||||
iconButton('Rename', '✎', () => this.rename(s.name)),
|
||||
el('a', {
|
||||
@@ -199,12 +321,6 @@ export class SessionsPanel {
|
||||
if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
|
||||
this.list.replaceChildren(...items);
|
||||
|
||||
const projects = this.data?.projects ?? [];
|
||||
const chosen = this.select.value;
|
||||
this.select.replaceChildren(
|
||||
el('option', { value: '' }, `No project (${this.data?.root ?? 'default folder'})`),
|
||||
...projects.filter((p) => p.name !== this.data?.default)
|
||||
.map((p) => el('option', { value: p.name, title: p.dir }, p.name)));
|
||||
this.select.value = chosen;
|
||||
this.projectOptions(this.select);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// One tab polls for every open DevDesk tab (devdesk#8): whoever fetches the
|
||||
// session list or the alerts broadcasts the result, and a tab that heard a fresh
|
||||
// result recently skips its own poll. Each poll costs a ttyd client and a PTY.
|
||||
|
||||
const channel = typeof BroadcastChannel === 'function' ? new BroadcastChannel('devdesk') : null;
|
||||
const heard = new Map();
|
||||
const handlers = new Map();
|
||||
|
||||
channel?.addEventListener('message', (event) => {
|
||||
const { key, data } = event.data ?? {};
|
||||
const handler = handlers.get(key);
|
||||
if (!handler) return;
|
||||
heard.set(key, Date.now());
|
||||
handler(data);
|
||||
});
|
||||
|
||||
export function onShared(key, handler) {
|
||||
handlers.set(key, handler);
|
||||
}
|
||||
|
||||
export function share(key, data) {
|
||||
try {
|
||||
channel?.postMessage({ key, data });
|
||||
} catch {
|
||||
// Not cloneable or channel closed: the other tabs just poll themselves.
|
||||
}
|
||||
}
|
||||
|
||||
export function heardWithin(key, ms) {
|
||||
return Date.now() - (heard.get(key) ?? 0) < ms;
|
||||
}
|
||||
+75
-28
@@ -3,32 +3,77 @@ import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
||||
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||
import { Base64, ClipboardAddon } from '@xterm/addon-clipboard';
|
||||
|
||||
// LotusGuild TDS tokens (web_template base.css :root) mapped onto the ANSI palette.
|
||||
export const THEME = Object.freeze({
|
||||
background: '#010304',
|
||||
foreground: '#c4d9ee',
|
||||
cursor: '#FF6B00',
|
||||
cursorAccent: '#010304',
|
||||
selectionBackground: 'rgba(0, 212, 255, 0.30)',
|
||||
selectionInactiveBackground: 'rgba(0, 212, 255, 0.15)',
|
||||
black: '#0d1520',
|
||||
red: '#FF2D55',
|
||||
green: '#00FF88',
|
||||
yellow: '#FFB300',
|
||||
blue: '#3A8DFF',
|
||||
magenta: '#BF5FFF',
|
||||
cyan: '#00D4FF',
|
||||
white: '#c4d9ee',
|
||||
brightBlack: '#3e607a',
|
||||
brightRed: '#FF5C7A',
|
||||
brightGreen: '#33FFAA',
|
||||
brightYellow: '#FFD700',
|
||||
brightBlue: '#6AA8FF',
|
||||
brightMagenta: '#D08CFF',
|
||||
brightCyan: '#33DFFF',
|
||||
brightWhite: '#FFFFFF',
|
||||
// Terminal output (tmux copy-mode, Claude) may *write* the browser clipboard
|
||||
// via OSC 52, but must never read it: anything printed could exfiltrate it (devdesk#7).
|
||||
const writeOnlyClipboard = {
|
||||
readText: () => '',
|
||||
writeText: (selection, text) => {
|
||||
if (selection === 'c') return navigator.clipboard?.writeText(text).catch(() => {});
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
// Terminal palettes from the LotusGuild TDS tokens (devdesk#31).
|
||||
//
|
||||
// DevDesk runs Claude with its `dark-ansi` theme, so Claude draws with these 16
|
||||
// colours instead of fixed RGB: body text is bright white, secondary text is
|
||||
// white, the band behind your messages is bright black, and text on coloured
|
||||
// backgrounds is black. Claude's theme is per process and printed text keeps its
|
||||
// colours, so instead of switching Claude, the light palette inverts the grey
|
||||
// ramp: bright white is ink, white is muted ink, bright black a light band and
|
||||
// black the page. Anything written for dark terminals, Claude's scrollback
|
||||
// included, stays readable, and toggling is instant.
|
||||
export const THEMES = Object.freeze({
|
||||
dark: Object.freeze({
|
||||
background: '#010304',
|
||||
foreground: '#c4d9ee',
|
||||
cursor: '#FF6B00',
|
||||
cursorAccent: '#010304',
|
||||
selectionBackground: 'rgba(0, 212, 255, 0.30)',
|
||||
selectionInactiveBackground: 'rgba(0, 212, 255, 0.15)',
|
||||
black: '#0d1520',
|
||||
red: '#FF2D55',
|
||||
green: '#00FF88',
|
||||
yellow: '#FFB300',
|
||||
blue: '#3A8DFF',
|
||||
magenta: '#BF5FFF',
|
||||
cyan: '#00D4FF',
|
||||
white: '#9fb6cc',
|
||||
brightBlack: '#3e607a',
|
||||
brightRed: '#FF5C7A',
|
||||
brightGreen: '#33FFAA',
|
||||
brightYellow: '#FFD700',
|
||||
brightBlue: '#6AA8FF',
|
||||
brightMagenta: '#D08CFF',
|
||||
brightCyan: '#33DFFF',
|
||||
brightWhite: '#FFFFFF',
|
||||
}),
|
||||
light: Object.freeze({
|
||||
background: '#f4f6fa',
|
||||
foreground: '#16202e',
|
||||
cursor: '#c44e00',
|
||||
cursorAccent: '#f4f6fa',
|
||||
selectionBackground: 'rgba(0, 98, 184, 0.22)',
|
||||
selectionInactiveBackground: 'rgba(0, 98, 184, 0.12)',
|
||||
black: '#eef1f6',
|
||||
red: '#b5001f',
|
||||
green: '#006d35',
|
||||
yellow: '#8a5a00',
|
||||
blue: '#1f4fbf',
|
||||
magenta: '#6b2fb8',
|
||||
cyan: '#006b86',
|
||||
white: '#556578',
|
||||
brightBlack: '#c9d1dc',
|
||||
brightRed: '#cc1f3d',
|
||||
brightGreen: '#00803e',
|
||||
brightYellow: '#9a6400',
|
||||
brightBlue: '#0062b8',
|
||||
brightMagenta: '#7e3fd0',
|
||||
brightCyan: '#007f9e',
|
||||
brightWhite: '#16202e',
|
||||
}),
|
||||
});
|
||||
|
||||
export const FONT_FAMILY = '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace';
|
||||
@@ -45,7 +90,7 @@ export async function loadFonts(size) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createTerminal(container, { fontSize, onShiftEnter }) {
|
||||
export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter }) {
|
||||
const term = new Terminal({
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize,
|
||||
@@ -55,14 +100,14 @@ export function createTerminal(container, { fontSize, onShiftEnter }) {
|
||||
allowProposedApi: true, // unicode11
|
||||
macOptionIsMeta: true,
|
||||
drawBoldTextInBrightColors: false,
|
||||
theme: THEME,
|
||||
theme: THEMES[theme],
|
||||
});
|
||||
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.loadAddon(new Unicode11Addon());
|
||||
term.unicode.activeVersion = '11';
|
||||
term.loadAddon(new ClipboardAddon()); // OSC 52 from tmux copy-mode / Claude
|
||||
term.loadAddon(new ClipboardAddon(new Base64(), writeOnlyClipboard));
|
||||
term.loadAddon(new WebLinksAddon((event, uri) => {
|
||||
event.preventDefault();
|
||||
window.open(uri, '_blank', 'noopener');
|
||||
@@ -90,6 +135,8 @@ export function createTerminal(container, { fontSize, onShiftEnter }) {
|
||||
|
||||
term.open(container);
|
||||
|
||||
// Touch devices: the DOM renderer is more reliable at high DPR than WebGL.
|
||||
if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit };
|
||||
try {
|
||||
const webgl = new WebglAddon();
|
||||
webgl.onContextLoss(() => webgl.dispose());
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Dark / light theme, interchangeable with web_template's lt.theme (devdesk#31):
|
||||
// same localStorage key (lt_theme), same data-theme attribute on <html>, the OS
|
||||
// preference while nothing is saved, and tabs follow each other via `storage`.
|
||||
// The inline script in index.template.html applies it before first paint.
|
||||
|
||||
const KEY = 'lt_theme';
|
||||
const prefersLight = window.matchMedia('(prefers-color-scheme: light)');
|
||||
const listeners = new Set();
|
||||
|
||||
function saved() {
|
||||
try {
|
||||
const value = localStorage.getItem(KEY);
|
||||
return value === 'light' || value === 'dark' ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
function apply(theme) {
|
||||
const root = document.documentElement;
|
||||
root.setAttribute('data-theme', theme);
|
||||
root.style.colorScheme = theme;
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme === 'light' ? '#edf0f5' : '#030508');
|
||||
const next = theme === 'light' ? 'dark' : 'light';
|
||||
for (const button of document.querySelectorAll('.lt-theme-btn')) {
|
||||
button.textContent = theme === 'light' ? '◐' : '☀';
|
||||
button.setAttribute('aria-label', `Switch to ${next} mode`);
|
||||
button.title = `Switch to ${next} mode`;
|
||||
}
|
||||
for (const listener of listeners) listener(theme);
|
||||
}
|
||||
|
||||
export function setTheme(theme) {
|
||||
try {
|
||||
localStorage.setItem(KEY, theme);
|
||||
} catch {
|
||||
// Private mode: the choice lasts until the tab closes.
|
||||
}
|
||||
apply(theme);
|
||||
}
|
||||
|
||||
export function toggleTheme() {
|
||||
setTheme(getTheme() === 'light' ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
export function onThemeChange(listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
apply(saved() ?? (prefersLight.matches ? 'light' : 'dark'));
|
||||
prefersLight.addEventListener('change', (event) => {
|
||||
if (!saved()) apply(event.matches ? 'light' : 'dark');
|
||||
});
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === KEY) apply(saved() ?? (prefersLight.matches ? 'light' : 'dark'));
|
||||
});
|
||||
}
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
// House UI pieces built on web_template's base.css classes, without base.js
|
||||
// (its global key handler swallows Esc, which interrupts Claude, and every toast
|
||||
// beeps): toasts (devdesk#34), dialogs (#33) and the command palette (#32).
|
||||
|
||||
import { el } from './dom.js';
|
||||
|
||||
// ---------------------------------------------------------------- toasts
|
||||
|
||||
const ICONS = { success: '✓', error: '✗', warning: '!', info: 'i' };
|
||||
|
||||
function toastContainer() {
|
||||
let box = document.getElementById('lt-toast-container');
|
||||
if (!box) {
|
||||
box = el('div', { id: 'lt-toast-container', 'aria-live': 'polite' });
|
||||
document.body.append(box);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function dismiss(node) {
|
||||
if (!node.isConnected) return;
|
||||
clearTimeout(node.dismissTimer);
|
||||
node.classList.add('is-leaving');
|
||||
setTimeout(() => node.remove(), 250);
|
||||
}
|
||||
|
||||
export function toast(message, type = 'info', duration = 4000) {
|
||||
const box = toastContainer();
|
||||
// At most four on screen: the oldest goes first.
|
||||
while (box.children.length >= 4) box.firstElementChild.remove();
|
||||
const node = el('div', { class: `lt-toast lt-toast-${type}`, role: type === 'error' ? 'alert' : 'status' },
|
||||
el('span', { class: 'lt-toast-icon', 'aria-hidden': 'true' }, `[${ICONS[type] || 'i'}]`),
|
||||
el('span', { class: 'lt-toast-msg' }, message),
|
||||
el('button', { type: 'button', class: 'lt-toast-close', 'aria-label': 'Dismiss', onclick: () => dismiss(node) }, '✕'),
|
||||
el('div', { class: 'lt-toast-progress', style: `animation-duration:${duration}ms` }));
|
||||
box.append(node);
|
||||
node.dismissTimer = setTimeout(() => dismiss(node), duration);
|
||||
node.addEventListener('mouseenter', () => clearTimeout(node.dismissTimer));
|
||||
node.addEventListener('mouseleave', () => { node.dismissTimer = setTimeout(() => dismiss(node), 1500); });
|
||||
}
|
||||
|
||||
toast.success = (message, duration) => toast(message, 'success', duration);
|
||||
toast.error = (message, duration = 8000) => toast(message, 'error', duration);
|
||||
toast.warning = (message, duration) => toast(message, 'warning', duration);
|
||||
toast.info = (message, duration) => toast(message, 'info', duration);
|
||||
|
||||
// ---------------------------------------------------------------- dialogs
|
||||
|
||||
let openDialogs = 0;
|
||||
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
|
||||
// a form is submitted), or null when dismissed (✕, Esc, backdrop).
|
||||
export function openDialog({ title, body, actions = [], danger = false, size = '', onOpen, submit }) {
|
||||
return new Promise((resolve) => {
|
||||
const opener = document.activeElement;
|
||||
let done = false;
|
||||
const close = (value) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
openDialogs -= 1;
|
||||
overlay.remove();
|
||||
if (opener?.isConnected) opener.focus();
|
||||
resolve(value);
|
||||
};
|
||||
const titleId = `dd-dialog-${Date.now()}`;
|
||||
const form = el('form', { class: 'lt-modal-body dd-dialog-body', novalidate: '' }, body);
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const value = submit ? submit() : true;
|
||||
if (value !== undefined && value !== null && value !== false) close(value);
|
||||
});
|
||||
const buttons = actions.map((action) => el('button', {
|
||||
type: action.primary ? 'submit' : 'button',
|
||||
class: `lt-btn lt-btn-sm${action.primary ? (action.danger ? ' lt-btn-danger' : ' lt-btn-primary') : ' lt-btn-ghost'}`,
|
||||
onclick: action.primary ? null : () => close(action.value ?? null),
|
||||
}, action.label));
|
||||
// The primary button submits the form, so Enter in a field does the same.
|
||||
for (const button of buttons) {
|
||||
if (button.type === 'submit') button.setAttribute('form', titleId);
|
||||
}
|
||||
form.id = titleId;
|
||||
const overlay = el('div', { class: 'lt-modal-overlay is-open dd-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': `${titleId}-title` },
|
||||
el('div', { class: `lt-modal${size ? ` lt-modal-${size}` : ''}` },
|
||||
el('div', { class: `lt-modal-header${danger ? ' lt-modal-header--danger' : ''}` },
|
||||
el('h2', { class: 'lt-modal-title', id: `${titleId}-title` }, title),
|
||||
el('button', { type: 'button', class: 'lt-modal-close', 'aria-label': 'Close', onclick: () => close(null) }, '✕')),
|
||||
form,
|
||||
buttons.length ? el('div', { class: 'lt-modal-footer' }, ...buttons) : null));
|
||||
overlay.addEventListener('mousedown', (event) => {
|
||||
if (event.target === overlay) close(null);
|
||||
});
|
||||
overlay.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close(null);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Tab') trapFocus(overlay, event);
|
||||
});
|
||||
openDialogs += 1;
|
||||
document.body.append(overlay);
|
||||
(onOpen?.(overlay) ?? overlay.querySelector('input, select, textarea, .lt-btn-primary, .lt-btn-danger, .lt-modal-close'))?.focus?.();
|
||||
});
|
||||
}
|
||||
|
||||
function trapFocus(root, event) {
|
||||
const items = [...root.querySelectorAll('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])')]
|
||||
.filter((node) => !node.disabled && node.offsetParent !== null);
|
||||
if (!items.length) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmDialog({ title, text, confirmLabel = 'OK', danger = false }) {
|
||||
return openDialog({
|
||||
title,
|
||||
danger,
|
||||
size: 'sm',
|
||||
body: el('p', { class: 'dd-dialog-text' }, text),
|
||||
actions: [{ label: 'Cancel', value: false }, { label: confirmLabel, primary: true, danger }],
|
||||
}).then(Boolean);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- command palette
|
||||
|
||||
const RECENT_KEY = 'lt_cmd_recent';
|
||||
|
||||
function readRecent() {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function pushRecent(id) {
|
||||
try {
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify([id, ...readRecent().filter((x) => x !== id)].slice(0, 5)));
|
||||
} catch {
|
||||
// Private mode: no recents.
|
||||
}
|
||||
}
|
||||
|
||||
// Commands: { id, label, group, icon?, hint?, kbd?, keywords?, run }. `getCommands`
|
||||
// (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, 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: 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: `${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' },
|
||||
el('span', {}, el('kbd', {}, '↑'), el('kbd', {}, '↓'), ' move'),
|
||||
el('span', {}, el('kbd', {}, '⏎'), ' run'),
|
||||
el('span', {}, el('kbd', {}, 'Esc'), ' close'))));
|
||||
document.body.append(this.overlay);
|
||||
this.input.addEventListener('input', () => this.render());
|
||||
this.input.addEventListener('keydown', (event) => this.onKey(event));
|
||||
this.overlay.addEventListener('mousedown', (event) => {
|
||||
if (event.target === this.overlay) this.close();
|
||||
});
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return this.overlay.classList.contains('is-open');
|
||||
}
|
||||
|
||||
async open(query = '') {
|
||||
this.opener = document.activeElement;
|
||||
this.overlay.classList.add('is-open');
|
||||
this.input.value = query;
|
||||
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 } = {}) {
|
||||
if (!this.isOpen) return;
|
||||
this.overlay.classList.remove('is-open');
|
||||
if (restoreFocus && this.opener?.isConnected) this.opener.focus();
|
||||
}
|
||||
|
||||
onKey(event) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.move(event.key === 'ArrowDown' ? 1 : -1);
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.run(this.visible[this.selected]);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.close();
|
||||
} else if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
move(step) {
|
||||
if (!this.visible.length) return;
|
||||
this.selected = (this.selected + step + this.visible.length) % this.visible.length;
|
||||
this.highlight();
|
||||
}
|
||||
|
||||
highlight() {
|
||||
const items = this.results.querySelectorAll('.lt-cmd-item');
|
||||
items.forEach((item, i) => {
|
||||
item.classList.toggle('is-selected', i === this.selected);
|
||||
item.setAttribute('aria-selected', String(i === this.selected));
|
||||
});
|
||||
const current = items[this.selected];
|
||||
if (current) {
|
||||
current.scrollIntoView({ block: 'nearest' });
|
||||
this.input.setAttribute('aria-activedescendant', current.id);
|
||||
}
|
||||
}
|
||||
|
||||
run(command) {
|
||||
if (!command) return;
|
||||
pushRecent(command.id);
|
||||
// The command decides where focus goes (usually the terminal).
|
||||
this.close({ restoreFocus: false });
|
||||
command.run();
|
||||
}
|
||||
|
||||
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();
|
||||
return words.every((w) => text.includes(w));
|
||||
});
|
||||
const groups = new Map();
|
||||
if (!words.length) {
|
||||
const recent = readRecent().map((id) => matches.find((c) => c.id === id)).filter(Boolean).slice(0, 3);
|
||||
if (recent.length) groups.set('Recent', recent);
|
||||
}
|
||||
for (const command of matches) {
|
||||
if (!groups.has(command.group)) groups.set(command.group, []);
|
||||
groups.get(command.group).push(command);
|
||||
}
|
||||
this.visible = [...groups.values()].flat();
|
||||
this.selected = 0;
|
||||
if (!this.visible.length) {
|
||||
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) {
|
||||
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: `${this.options.id}-item-${i}`, role: 'option',
|
||||
onclick: () => this.run(command),
|
||||
onmousemove: () => {
|
||||
if (this.selected !== i) {
|
||||
this.selected = i;
|
||||
this.highlight();
|
||||
}
|
||||
},
|
||||
},
|
||||
el('span', { class: 'lt-cmd-item-icon', 'aria-hidden': 'true' }, command.icon || '◦'),
|
||||
el('span', { class: 'lt-cmd-item-label' }, command.label),
|
||||
command.hint ? el('span', { class: 'dd-cmd-hint' }, command.hint) : null,
|
||||
command.kbd ? el('span', { class: 'lt-cmd-item-kbd' }, command.kbd) : null));
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
this.results.replaceChildren(...nodes);
|
||||
this.highlight();
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
// Sidebar widgets. In v1 only Sessions is live; Repo/CI and Claude Context get
|
||||
// data from devdesk-api in v1.1, Ops Alerts in v2. The "Ask Claude" pattern is
|
||||
// already wired: any [data-ask-claude] button types its prompt into the current
|
||||
// session without pressing Enter, so you can review it before sending.
|
||||
// "Ask Claude": any [data-ask-claude] button (Ops Alerts, Repo/CI) types its
|
||||
// prompt into the current session without pressing Enter, so you can review it
|
||||
// before sending.
|
||||
|
||||
export function bindAskClaude(root, send) {
|
||||
root.addEventListener('click', (event) => {
|
||||
|
||||
+597
-41
@@ -37,9 +37,12 @@ body.dd-app::after {
|
||||
}
|
||||
|
||||
.dd-header .lt-header-left {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
.dd-header .lt-header-right { flex: 0 0 auto; }
|
||||
.dd-session { flex: 0 1 auto; }
|
||||
|
||||
.dd-session {
|
||||
display: flex;
|
||||
@@ -57,27 +60,103 @@ body.dd-app::after {
|
||||
max-width: 38vw;
|
||||
}
|
||||
|
||||
.dd-sso {
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
.dd-session-chip {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 34vw;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--accent-cyan-border);
|
||||
background: var(--accent-cyan-dim);
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.dd-sso[data-state="ok"] { color: var(--accent-green); background: var(--accent-green-dim); }
|
||||
.dd-sso[data-state="unknown"] { color: var(--text-muted); }
|
||||
.dd-sso[data-state="expired"] {
|
||||
color: var(--accent-red);
|
||||
background: var(--accent-red-dim);
|
||||
text-shadow: var(--glow-red);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dd-session-chip:hover,
|
||||
.dd-session-chip[aria-expanded="true"] { border-color: var(--accent-cyan); box-shadow: var(--box-glow-cyan); }
|
||||
.dd-session-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dd-caret { color: var(--text-muted); font-size: 0.7rem; }
|
||||
/* Current session's Claude state as a dot on the chip (devdesk#13). */
|
||||
.dd-state-dot { flex: 0 0 auto; width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); }
|
||||
.dd-session-chip[data-state="working"] .dd-state-dot { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber); animation: lt-pulse 1.2s ease-in-out infinite; }
|
||||
.dd-session-chip[data-state="waiting"] { border-color: rgba(255, 45, 85, 0.6); color: var(--accent-red); background: var(--accent-red-dim); }
|
||||
.dd-session-chip[data-state="waiting"] .dd-state-dot { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red); animation: lt-pulse 1.2s ease-in-out infinite; }
|
||||
.dd-session-chip[data-state="idle"] .dd-state-dot { background: var(--accent-green); }
|
||||
|
||||
.dd-tools .lt-btn[data-state="granted"] { color: var(--accent-green); border-color: var(--accent-green-border); }
|
||||
.dd-tools .lt-btn[data-state="denied"] { color: var(--accent-red); }
|
||||
/* Connection status: a dot; the word only when it isn't "live". */
|
||||
.dd-ws[data-state="connected"] #ws-label { display: none; }
|
||||
|
||||
.dd-panels { position: relative; clip-path: none; } /* the corner cut hid the active border */
|
||||
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
|
||||
.dd-attention,
|
||||
.dd-session-chip .dd-state-dot { animation: none !important; }
|
||||
}
|
||||
/* base.css draws keys in --text-dim (~1.5:1 on this background). */
|
||||
.dd-sidebar .lt-kv-key { color: var(--text-secondary); font-size: 0.7rem; }
|
||||
#sidebar-toggle[aria-expanded="true"] {
|
||||
color: var(--accent-orange);
|
||||
border-color: var(--accent-orange-border);
|
||||
}
|
||||
|
||||
|
||||
/* ---- menus (session switcher, ⋯) ----------------------------------------------- */
|
||||
.dd-session,
|
||||
.dd-more { position: relative; }
|
||||
.dd-more-toggle { font-size: 1rem; line-height: 1; padding-inline: 8px; }
|
||||
.dd-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 40;
|
||||
min-width: 240px;
|
||||
max-width: min(360px, calc(100vw - 16px));
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--accent-cyan-border);
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7), var(--box-glow-cyan);
|
||||
}
|
||||
.dd-menu[hidden] { display: none; }
|
||||
.dd-switcher { left: 0; }
|
||||
.dd-more-menu { right: 0; }
|
||||
.dd-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.74rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dd-menu-item:hover:not(:disabled),
|
||||
.dd-menu-item:focus-visible { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
|
||||
.dd-menu-item:disabled { color: var(--text-muted); cursor: default; }
|
||||
.dd-menu-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.dd-menu-inline { display: inline-flex; align-items: center; gap: 4px; color: var(--text-primary); }
|
||||
.dd-menu-sep { height: 1px; margin: 4px 0; background: var(--border-dim); }
|
||||
.dd-menu-note { padding: 6px 8px; color: var(--text-muted); font-size: 0.72rem; }
|
||||
.dd-menu-foot { padding: 6px 8px 2px; border-top: 1px solid var(--border-dim); margin-top: 4px; color: var(--text-muted); font-size: 0.62rem; }
|
||||
.dd-switch-item.is-current { border-color: var(--accent-orange-border); background: var(--accent-orange-dim); }
|
||||
.dd-switch-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; }
|
||||
.dd-switch-meta { color: var(--text-muted); font-size: 0.64rem; white-space: nowrap; }
|
||||
.dd-switch-item.is-waiting .dd-switch-meta { color: var(--accent-red); }
|
||||
.dd-switch-item.is-working .dd-switch-meta { color: var(--accent-amber); }
|
||||
|
||||
/* ---- layout ---------------------------------------------------------------- */
|
||||
.dd-main {
|
||||
position: fixed;
|
||||
@@ -134,14 +213,8 @@ body.dd-sidebar-open .dd-sidebar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.dd-soon {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.dd-header-actions { margin-left: auto; display: inline-flex; gap: var(--space-sm); }
|
||||
.dd-header-action {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--accent-cyan);
|
||||
@@ -153,7 +226,7 @@ body.dd-sidebar-open .dd-sidebar {
|
||||
|
||||
.dd-muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.dd-stub-note {
|
||||
@@ -193,7 +266,7 @@ body.dd-sidebar-open .dd-sidebar {
|
||||
text-align: left;
|
||||
}
|
||||
.dd-session-open:hover { background: var(--accent-cyan-dim); }
|
||||
.dd-session-open .lt-dot { grid-row: 1 / span 2; }
|
||||
.dd-session-open .lt-dot { grid-row: 1 / span 3; }
|
||||
|
||||
.dd-session-name {
|
||||
font-size: 0.78rem;
|
||||
@@ -204,13 +277,48 @@ body.dd-sidebar-open .dd-sidebar {
|
||||
}
|
||||
|
||||
.dd-session-meta {
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Context-window fill under each session (devdesk#17). */
|
||||
.dd-ctx {
|
||||
grid-column: 2;
|
||||
height: 2px;
|
||||
margin-top: 3px;
|
||||
background: var(--bg-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
.dd-ctx-fill { display: block; height: 100%; background: var(--accent-cyan); }
|
||||
.dd-ctx.is-high .dd-ctx-fill { background: var(--accent-amber); }
|
||||
.dd-ctx.is-full .dd-ctx-fill { background: var(--accent-red); }
|
||||
|
||||
.dd-session-item.is-recent .dd-session-name { color: var(--text-secondary); font-weight: 400; }
|
||||
|
||||
.dd-session-item.is-dormant .dd-session-name { color: var(--text-secondary); }
|
||||
.dd-session-item.is-waiting { border-color: rgba(255, 45, 85, 0.45); background: var(--accent-red-dim); }
|
||||
.dd-session-item.is-waiting .dd-session-meta { color: var(--accent-red); }
|
||||
.dd-session-item.is-working .dd-session-meta { color: var(--accent-amber); }
|
||||
|
||||
/* base.css sets display on .lt-btn/.lt-chip, which beats the [hidden] attribute. */
|
||||
.dd-header [hidden] { display: none !important; }
|
||||
|
||||
/* Header chip: other sessions waiting on you. */
|
||||
.dd-attention {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
max-width: 22vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent-red);
|
||||
background: var(--accent-red-dim);
|
||||
animation: lt-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.dd-attention.is-active { display: inline-flex; }
|
||||
|
||||
.dd-session-actions {
|
||||
display: flex;
|
||||
@@ -258,6 +366,7 @@ body.dd-sidebar-open .dd-sidebar {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.dd-new-session[hidden] { display: none; }
|
||||
.dd-new-session {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
@@ -275,24 +384,65 @@ body.dd-sidebar-open .dd-sidebar {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* ---- stub widgets ------------------------------------------------------------ */
|
||||
/* ---- ops alerts --------------------------------------------------------------- */
|
||||
.dd-alert-count { margin-left: auto; }
|
||||
.dd-alert-count:empty { display: none; }
|
||||
.dd-alert-list { display: flex; flex-direction: column; gap: var(--space-sm); }
|
||||
.dd-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-dim);
|
||||
border-left: 2px solid var(--accent-amber);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.dd-alert-text { flex: 1 1 auto; }
|
||||
.dd-alert:has(.lt-chip-critical) { border-left-color: var(--accent-red); }
|
||||
.dd-alert-head { display: flex; align-items: center; gap: var(--space-sm); min-width: 0; }
|
||||
.dd-alert-name { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dd-alert-age { margin-left: auto; white-space: nowrap; }
|
||||
.dd-alert-instances { overflow-wrap: anywhere; }
|
||||
.dd-alert-summary { color: var(--text-secondary); }
|
||||
.dd-alert .dd-ask { align-self: flex-start; margin-top: 2px; }
|
||||
.dd-all-clear { display: flex; align-items: center; gap: var(--space-sm); color: var(--accent-green); font-size: 0.74rem; }
|
||||
|
||||
.dd-context-grid { margin-top: var(--space-sm); }
|
||||
[data-state="stub"] .lt-section-body { opacity: 0.75; }
|
||||
.dd-repo-grid[hidden] { display: none; }
|
||||
.dd-repo-note:empty { display: none; }
|
||||
.dd-fix-ci { margin-top: var(--space-sm); }
|
||||
.dd-fix-ci[hidden] { display: none; }
|
||||
|
||||
[data-state="stub"] .lt-section-body,
|
||||
#widget-context[data-state="empty"] .lt-kv-grid,
|
||||
#widget-repo[data-state="empty"] .lt-kv-grid { opacity: 0.6; }
|
||||
|
||||
.lt-status a { color: inherit; text-shadow: none; }
|
||||
.dd-widget .lt-kv-val { overflow-wrap: anywhere; }
|
||||
/* ---- file drop / upload ------------------------------------------------------ */
|
||||
.dd-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px dashed var(--accent-cyan);
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
color: var(--accent-cyan);
|
||||
font-size: 0.9rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dd-drop-overlay[hidden] { display: none; }
|
||||
.dd-upload-status {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 16px;
|
||||
z-index: 6;
|
||||
max-width: 70%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--accent-cyan-border);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
box-shadow: var(--box-glow-cyan);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dd-upload-status[hidden] { display: none; }
|
||||
.dd-upload-status.is-error { border-color: rgba(255, 45, 85, 0.5); color: var(--accent-red); }
|
||||
|
||||
/* ---- overlay ----------------------------------------------------------------- */
|
||||
.dd-overlay {
|
||||
@@ -341,6 +491,33 @@ body.dd-sidebar-open .dd-sidebar {
|
||||
|
||||
.dd-footer .lt-footer-key { margin-right: 4px; }
|
||||
|
||||
.dd-keybar { display: flex; gap: 4px; width: 100%; overflow-x: auto; scrollbar-width: none; }
|
||||
.dd-keybar[hidden] { display: none; }
|
||||
.dd-keybar button {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.dd-keybar button:active { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
|
||||
|
||||
/* Touch: the key bar replaces the shortcut hints; the page shrinks above the
|
||||
on-screen keyboard (--dd-kb is set from visualViewport). */
|
||||
body.dd-touch { --dd-footer-height: 44px; }
|
||||
body.dd-touch .dd-hints { display: none; }
|
||||
body.dd-touch .dd-main { bottom: calc(var(--dd-footer-height) + var(--dd-kb, 0px) + env(safe-area-inset-bottom, 0px)); }
|
||||
body.dd-touch .dd-footer {
|
||||
bottom: var(--dd-kb, 0px);
|
||||
height: calc(var(--dd-footer-height) + env(safe-area-inset-bottom, 0px));
|
||||
padding: 4px 4px calc(4px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* ---- small screens ----------------------------------------------------------- */
|
||||
@media (max-width: 1099px) {
|
||||
.dd-sidebar {
|
||||
@@ -348,7 +525,7 @@ body.dd-sidebar-open .dd-sidebar {
|
||||
top: var(--dd-gap);
|
||||
right: var(--dd-gap);
|
||||
bottom: var(--dd-gap);
|
||||
width: min(var(--dd-sidebar-width), calc(100vw - 2 * var(--dd-gap)));
|
||||
width: min(var(--dd-sidebar-width), calc(100vw - 56px)); /* leave a strip to tap out */
|
||||
z-index: 20;
|
||||
background-color: var(--bg-secondary);
|
||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.6);
|
||||
@@ -357,14 +534,393 @@ body.dd-sidebar-open .dd-sidebar {
|
||||
.dd-pane-title { display: none; }
|
||||
}
|
||||
|
||||
.dd-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 15;
|
||||
background: rgba(1, 3, 4, 0.55);
|
||||
}
|
||||
.dd-backdrop[hidden] { display: none; }
|
||||
|
||||
@media (max-width: 719px) {
|
||||
:root { --dd-gap: 4px; }
|
||||
.lt-brand-subtitle,
|
||||
#font-down,
|
||||
#font-up,
|
||||
#fullscreen-toggle,
|
||||
.dd-sso { display: none; }
|
||||
.lt-brand { display: none; }
|
||||
.dd-session-chip { max-width: 46vw; }
|
||||
/* Phones: the red Panels badge and the switcher carry "needs you" instead. */
|
||||
.dd-attention.is-active { display: none; }
|
||||
.dd-header { padding: 0 var(--space-sm); gap: var(--space-sm); }
|
||||
.dd-header .lt-header-left { gap: var(--space-sm); }
|
||||
/* base.css hides the WS indicator on phones; keep it as a bare dot. */
|
||||
.dd-header .dd-ws { display: inline-flex; padding: 4px; }
|
||||
.dd-header .dd-ws #ws-label { display: none; }
|
||||
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 5),
|
||||
.dd-footer .lt-footer-sep:nth-child(n + 4) { display: none; }
|
||||
}
|
||||
|
||||
/* ---- light theme (devdesk#31) ---------------------------------------------------
|
||||
base.css restyles every lt- component; these cover DevDesk's own pieces. */
|
||||
html[data-theme="light"] .dd-sidebar,
|
||||
html[data-theme="light"] .dd-footer { background-image: none; }
|
||||
html[data-theme="light"] .dd-overlay { background: rgba(237, 240, 245, 0.82); }
|
||||
html[data-theme="light"] .dd-backdrop { background: rgba(17, 24, 39, 0.28); }
|
||||
html[data-theme="light"] .dd-menu { box-shadow: 0 8px 28px rgba(50, 80, 130, 0.22), var(--box-glow-cyan); }
|
||||
html[data-theme="light"] .dd-drop-overlay { background: var(--accent-cyan-dim); }
|
||||
html[data-theme="light"] .dd-upload-status { box-shadow: 0 4px 16px rgba(50, 80, 130, 0.18); }
|
||||
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-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) {
|
||||
html[data-theme="light"] .dd-sidebar { box-shadow: -8px 0 28px rgba(50, 80, 130, 0.22); }
|
||||
}
|
||||
|
||||
.dd-header .lt-theme-btn { flex: 0 0 auto; }
|
||||
|
||||
/* ---- command palette, dialogs, toasts (devdesk#32-#34) ---------------------------- */
|
||||
.dd-cmd-btn { font-size: 0.8rem; letter-spacing: 0.02em; }
|
||||
.dd-cmd-kbd { font-size: 0.62rem; opacity: 0.7; }
|
||||
.lt-cmd-item.is-selected .dd-cmd-hint { color: inherit; }
|
||||
.dd-cmd-hint { color: var(--text-muted); font-size: 0.66rem; white-space: nowrap; }
|
||||
html[data-theme="light"] .lt-cmd-item.is-selected { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
|
||||
.lt-cmd-results { max-height: min(420px, 60vh); }
|
||||
.lt-cmd-footer span { display: inline-flex; align-items: center; gap: 2px; }
|
||||
|
||||
.dd-dialog-body { display: flex; flex-direction: column; gap: var(--space-sm); }
|
||||
.dd-dialog-fields { display: flex; flex-direction: column; gap: var(--space-md); }
|
||||
.dd-field { display: flex; flex-direction: column; gap: 4px; font-size: 0.72rem; color: var(--text-secondary); }
|
||||
.dd-field .lt-input,
|
||||
.dd-field .lt-select { width: 100%; font-size: 0.8rem; }
|
||||
.dd-dialog-text { color: var(--text-secondary); font-size: 0.8rem; line-height: 1.5; }
|
||||
.dd-dialog-hint { color: var(--text-muted); font-size: 0.7rem; }
|
||||
.dd-dialog-hint.is-error { color: var(--accent-red); }
|
||||
|
||||
.dd-keys { display: grid; gap: var(--space-lg); }
|
||||
.dd-keys-title {
|
||||
margin: 0 0 var(--space-sm);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
.dd-keys-list { display: grid; grid-template-columns: 10.5rem 1fr; gap: 6px var(--space-md); margin: 0; font-size: 0.76rem; }
|
||||
.dd-keys-list dt { white-space: nowrap; }
|
||||
.dd-keys-list dd { margin: 0; color: var(--text-secondary); }
|
||||
.dd-keys kbd {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.dd-footer-help {
|
||||
margin-left: var(--space-sm);
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: none;
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.64rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dd-footer-help:hover { border-color: var(--accent-cyan); }
|
||||
|
||||
/* Toasts sit above the footer (and the phone key bar); names keep their case. */
|
||||
#lt-toast-container {
|
||||
right: var(--space-md);
|
||||
bottom: calc(var(--dd-footer-height) + var(--dd-kb, 0px) + env(safe-area-inset-bottom, 0px) + var(--space-md));
|
||||
max-width: calc(100vw - 2 * var(--space-md));
|
||||
}
|
||||
.lt-toast {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 0.74rem;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.lt-toast.is-leaving { opacity: 0; }
|
||||
.lt-toast-msg { flex: 1; overflow-wrap: anywhere; }
|
||||
.lt-toast-icon { flex: 0 0 auto; }
|
||||
.lt-toast-close {
|
||||
flex: 0 0 auto;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.lt-toast-close:hover { opacity: 1; }
|
||||
|
||||
@media (max-width: 719px) {
|
||||
.dd-cmd-kbd { display: none; }
|
||||
#lt-toast-container { left: var(--space-sm); right: var(--space-sm); }
|
||||
.lt-toast { min-width: 0; max-width: none; }
|
||||
.dd-keys-list { grid-template-columns: 1fr; gap: 2px; }
|
||||
.dd-keys-list dd { margin-bottom: var(--space-sm); }
|
||||
}
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user