From 73f8525db26a95b7ba8799a9ef6f5d6442a01160 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 8 Oct 2026 01:16:37 -0400 Subject: [PATCH] feat: v0.7.0 files pane: browse, preview, diff, Claude's edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - devdesk attach --fs-list/--fs-read/--git-status/--git-show/--asset (libexec/devdesk-files.sh), confined to the session folder. - The state hook records files Claude edits (Edit/Write/MultiEdit/ NotebookEdit) for the Changes tab's "C" badge; rename/end carry it. - Files pane: folders, CodeMirror read-only view, sanitized markdown preview in a sandboxed iframe with inlined relative images, images, Changes tab with line counts and diff against HEAD, "→ Claude" @path, resizable split. - The editor bundle ships as /usr/share/devdesk/assets/editor.js, is fetched over ttyd on first use, verified against the page's SHA-256 and cached in IndexedDB. - Fix: the state hook's field split collapsed empty fields. Closes #19 Co-Authored-By: Claude Opus 5.5 --- Makefile | 7 +- README.md | 2 + bin/devdesk | 9 + debian/changelog | 24 ++ debian/copyright | 5 + libexec/devdesk-files.sh | 168 +++++++++++ libexec/devdesk-lib.sh | 5 +- libexec/devdesk-state-hook | 18 +- package-lock.json | 330 +++++++++++++++++++++- package.json | 19 +- tests/attach.bats | 103 +++++++ web/build.mjs | 28 +- web/editor/index.js | 159 +++++++++++ web/index.template.html | 18 ++ web/src/connection.js | 65 +++++ web/src/files.js | 552 +++++++++++++++++++++++++++++++++++++ web/src/main.js | 84 +++++- web/styles.css | 131 +++++++++ 18 files changed, 1717 insertions(+), 10 deletions(-) create mode 100644 libexec/devdesk-files.sh create mode 100644 web/editor/index.js create mode 100644 web/src/files.js diff --git a/Makefile b/Makefile index f97aa6c..bc129ba 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ SHAREDIR := $(PREFIX)/share/devdesk TTYD_ARCH ?= x86_64 SHELL_SCRIPTS := bin/devdesk $(wildcard libexec/devdesk-*) scripts/fetch-ttyd.sh debian/devdesk.postinst debian/devdesk.postrm -WEB_SOURCES := web/build.mjs web/index.template.html web/styles.css $(wildcard web/src/*.js) \ +WEB_SOURCES := web/build.mjs web/index.template.html web/styles.css $(wildcard web/src/*.js) $(wildcard web/editor/*.js) \ vendor/web_template/base.css debian/changelog .PHONY: all lint test install clean sync-template @@ -26,7 +26,7 @@ build/ttyd: vendor/ttyd/VERSION vendor/ttyd/SHA256SUMS scripts/fetch-ttyd.sh @touch $@ lint: node_modules/.package-lock.json - npx eslint --max-warnings 0 --ext .js,.mjs web/src web/build.mjs tests/protocol.test.mjs + npx eslint --max-warnings 0 --ext .js,.mjs web/src web/editor web/build.mjs tests/protocol.test.mjs shellcheck -x $(SHELL_SCRIPTS) jq empty share/claude-settings.json @@ -41,8 +41,11 @@ install: all install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \ libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \ libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix $(DESTDIR)$(LIBDIR)/ + install -m 0644 libexec/devdesk-files.sh $(DESTDIR)$(LIBDIR)/devdesk-files.sh install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/ + install -d $(DESTDIR)$(SHAREDIR)/assets + install -m 0644 dist/assets/editor.js $(DESTDIR)$(SHAREDIR)/assets/editor.js install -m 0644 etc/env $(DESTDIR)$(SYSCONFDIR)/devdesk/env clean: diff --git a/README.md b/README.md index ff9f02d..ea529fb 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ The tmux server runs in its own unit, so every Claude process lives in **that** - **Ask Claude** types an investigation prompt into the current session without sending it. - Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`. - **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log. +- **Files** (Ctrl+Shift+E): browse the session's folder beside the terminal, read files with syntax highlighting, preview markdown (sanitized, with its relative images) and images, and review uncommitted changes: git status with line counts, a **C** badge on files Claude edited, and a diff against HEAD. **→ Claude** types `@path` into the prompt. 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/.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. @@ -62,6 +63,7 @@ The tmux server runs in its own unit, so every Claude process lives in **that** | Shift+drag | Native browser selection (plain drag goes to tmux copy-mode, which copies via OSC 52) | | Ctrl+Shift+C / V | Copy selection / paste | | Ctrl+Shift+P | Command palette (also the ⌕ button) | +| Ctrl+Shift+E | Files pane (Esc: back, then close) | The **☀/◐** button switches light and dark mode (saved per browser, shared with the other web_template apps' `lt_theme` preference). Text size, desktop notifications (with a mute), the keyboard shortcuts sheet and fullscreen are in the **⋯** menu; everything is also in the command palette. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves. diff --git a/bin/devdesk b/bin/devdesk index 8eb3965..333ee72 100755 --- a/bin/devdesk +++ b/bin/devdesk @@ -17,6 +17,8 @@ set -euo pipefail # shellcheck source=libexec/devdesk-lib.sh . "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" +# shellcheck source=libexec/devdesk-files.sh +. "$DEVDESK_LIBEXEC/devdesk-files.sh" usage() { sed -n '2,12s/^# \{0,1\}//p' "$0" @@ -316,6 +318,13 @@ cmd_attach() { dd_linger return ;; + --fs-list | --fs-read | --git-status | --git-show | --asset) + local action=${1#--} + shift + "cmd_${action//-/_}" "$@" + dd_linger + return + ;; --new | --rename | --end) local action=${1#--} shift diff --git a/debian/changelog b/debian/changelog index 919d063..bd3164f 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,27 @@ +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 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 diff --git a/debian/copyright b/debian/copyright index ae7a195..f3ef4eb 100644 --- a/debian/copyright +++ b/debian/copyright @@ -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 diff --git a/libexec/devdesk-files.sh b/libexec/devdesk-files.sh new file mode 100644 index 0000000..d4bab39 --- /dev/null +++ b/libexec/devdesk-files.sh @@ -0,0 +1,168 @@ +# 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}" + +# 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. +cmd_fs_list() { + if [ $# -lt 1 ] || [ $# -gt 2 ]; then dd_die "usage: devdesk attach --fs-list SESSION [PATH]"; fi + local root dir rel=${2:-.} + root=$(dd_session_root "$1") + dir=$(dd_fs_path "$root" "$rel") + [ -d "$dir" ] || dd_die "not a folder: $rel" + # %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 | + head -z -n "$((DEVDESK_FS_LIST_MAX + 1))" | + jq -cMRs --arg root "$root" --arg rel "${dir#"$root"}" --argjson max "$DEVDESK_FS_LIST_MAX" ' + (split("\u0000") | map(select(length > 0) | split("\t") | + {t: .[0], to: .[1], s: (.[2] | tonumber), m: (.[3] | tonumber | floor), n: (.[4:] | join("\t"))})) 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)]))}' +} + +# --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 | head -z -n 1000 | tr '\0' '\n' + printf 'N\n' + "${g[@]}" diff --numstat HEAD -- . 2>/dev/null | 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" +} diff --git a/libexec/devdesk-lib.sh b/libexec/devdesk-lib.sh index 70bf60a..a03c415 100644 --- a/libexec/devdesk-lib.sh +++ b/libexec/devdesk-lib.sh @@ -132,7 +132,7 @@ dd_registry_set_claude() { dd_registry_remove() { dd_registry_update 'del(.sessions[$n])' --arg n "$1" - rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" + rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt" } dd_registry_rename() { @@ -144,6 +144,9 @@ dd_registry_rename() { mv -f "$d/$1.json" "$d/$2.json" fi done + if [ -f "$DEVDESK_STATE_DIR/edits/$1.txt" ]; then + mv -f "$DEVDESK_STATE_DIR/edits/$1.txt" "$DEVDESK_STATE_DIR/edits/$2.txt" + fi } # Project name (directory or its slug) -> absolute directory inside the projects root. diff --git a/libexec/devdesk-state-hook b/libexec/devdesk-state-hook index 8aa4eb5..18b56f6 100755 --- a/libexec/devdesk-state-hook +++ b/libexec/devdesk-state-hook @@ -16,7 +16,23 @@ outer=${TMUX:-} session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null) [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]] || exit 0 -IFS=$'\t' read -r event message < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\t\n]"; " "))] | @tsv' <<<"$input" 2>/dev/null) +# Unit separator, not tab: read collapses runs of whitespace delimiters, so an +# empty field would shift the ones after it. +IFS=$'\x1f' read -r event message tool file < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\u001f\n]"; " ")), + .tool_name // "", (.tool_input.file_path // .tool_input.notebook_path // "" | gsub("[\u001f\n]"; " "))] | join("\u001f")' <<<"$input" 2>/dev/null) + +# Files Claude edits, for the file viewer's "Claude" badge (devdesk#19). +case $event:$tool in +PostToolUse:Edit | PostToolUse:Write | PostToolUse:MultiEdit | PostToolUse:NotebookEdit) + if [[ $file == /* ]]; then + log="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/edits/$session.txt" + mkdir -p "${log%/*}" 2>/dev/null + if ! grep -qxF -- "$file" "$log" 2>/dev/null && [ "$(wc -l <"$log" 2>/dev/null || echo 0)" -lt 1000 ]; then + printf '%s\n' "$file" >>"$log" + fi + fi + ;; +esac case $event in UserPromptSubmit | PostToolUse) state=working ;; Notification) state=waiting ;; diff --git a/package-lock.json b/package-lock.json index 2325654..666adfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7ebc55c..c162f6e 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/tests/attach.bats b/tests/attach.bats index 6eaea3a..6f8ac59 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -491,3 +491,106 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; } run claude_args alpha [ "$output" = "-n alpha --settings $REPO/share/claude-settings.json --remote-control devdesk-alpha" ] } + +# Decode a blob reply (header line, base64 between markers) into $TMP/blob. +blob_body() { + sed -n '/^DEVDESK-BLOB-BEGIN$/,/^DEVDESK-BLOB-END$/p' <<<"$1" | sed '1d;$d' | base64 -d >"$TMP/blob" +} + +@test "fs-list lists one level, folders first, and stays inside the session folder (#19)" { + p="$DEVDESK_PROJECTS_ROOT/alpha" + mkdir -p "$p/src/deep" "$p/.git" + echo hi >"$p/README.md" + echo x >"$p/src/a.sh" + ln -s "$TMP/outside" "$p/escape-link" + ln -s src "$p/src-link" + run devdesk attach --fs-list alpha + [ "$status" -eq 0 ] + # Folders first (a symlink to a folder counts, even one that escapes: opening it fails). + [ "$(jq -r '[.entries[].n] | join(",")' <<<"$output")" = "escape-link,src,src-link,README.md" ] + run devdesk attach --fs-list alpha src + [ "$(jq -r '.dir + ":" + ([.entries[].n] | join(","))' <<<"$output")" = "src:deep,a.sh" ] + for bad in ../ /etc escape-link "src/../../alpha/../../outside"; do + run devdesk attach --fs-list alpha "$bad" + [ "$status" -ne 0 ] + done + run devdesk attach --fs-list alpha src-link + [ "$status" -eq 0 ] +} + +@test "fs-read returns text, images and refuses binaries, big files and escapes (#19)" { + p="$DEVDESK_PROJECTS_ROOT/alpha" + printf 'line 1\r\nüñí\n' >"$p/notes.md" + printf '\x89PNG\r\n\x1a\n\0\0\0' >"$p/pic.png" + head -c 100 /dev/zero >"$p/blob.bin" + head -c 3000 /dev/zero | tr '\0' a >"$p/big.txt" + run devdesk attach --fs-read alpha notes.md + [ "$status" -eq 0 ] + [ "$(head -n1 <<<"$output" | jq -r '[.path, .binary, .utf8, .body] | @tsv')" = "$(printf 'notes.md\tfalse\ttrue\ttrue')" ] + blob_body "$output" + cmp "$TMP/blob" "$p/notes.md" + run devdesk attach --fs-read alpha pic.png + [ "$(head -n1 <<<"$output" | jq -r '[.mime, .body] | @tsv')" = "$(printf 'image/png\ttrue')" ] + run devdesk attach --fs-read alpha blob.bin + [ "$(head -n1 <<<"$output" | jq -r '[.binary, .body] | @tsv')" = "$(printf 'true\tfalse')" ] + run env DEVDESK_FS_MAX=1000 "$REPO/bin/devdesk" attach --fs-read alpha big.txt + [ "$(head -n1 <<<"$output" | jq -r '.tooLarge')" = true ] + run devdesk attach --fs-read alpha ../alpha/../../outside + [ "$status" -ne 0 ] +} + +@test "git-status and git-show report changes, line counts and Claude's edits (#19)" { + p="$DEVDESK_PROJECTS_ROOT/alpha" + git -C "$p" init -q -b main + git -C "$p" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init + printf 'one\ntwo\n' >"$p/kept.txt" + printf 'old\n' >"$p/moved.txt" + git -C "$p" add . && git -C "$p" -c user.name=t -c user.email=t@t commit -q -m files + printf 'one\nTWO\nthree\n' >"$p/kept.txt" + git -C "$p" mv moved.txt renamed.txt + echo new >"$p/fresh.txt" + mkdir -p "$DEVDESK_STATE_DIR/edits" + printf '%s\n' "$p/kept.txt" "$p/notes-outside-git.txt" >"$DEVDESK_STATE_DIR/edits/alpha.txt" + run devdesk attach --git-status alpha + [ "$status" -eq 0 ] + [ "$(jq -r '.files | map("\(.x)\(.y) \(.path) +\(.add) -\(.del) \(.claude)") | sort | join("|")' <<<"$output")" = \ + " notes-outside-git.txt +null -null true| M kept.txt +2 -1 true|?? fresh.txt +null -null false|R renamed.txt +null -null false" ] + [ "$(jq -r '.files[] | select(.path == "renamed.txt") | .from' <<<"$output")" = moved.txt ] + run devdesk attach --git-show alpha kept.txt + blob_body "$output" + [ "$(cat "$TMP/blob")" = "$(printf 'one\ntwo')" ] + run devdesk attach --git-show alpha fresh.txt + [ "$(head -n1 <<<"$output" | jq -r '.size')" = 0 ] + run devdesk attach --git-status alpha + run env DEVDESK_PROJECTS_ROOT="$TMP/outside" "$REPO/bin/devdesk" attach --git-status alpha + [ "$status" -eq 0 ] +} + +@test "the state hook logs Claude's edits; rename moves and end removes the log (#19)" { + start_server + devdesk new alpha + pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}') + hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; } + echo '{"hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"/x/a.txt"}}' | hook + echo '{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"/x/a.txt"}}' | hook + echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"ls"}}' | hook + echo '{"hook_event_name":"PostToolUse","tool_name":"NotebookEdit","tool_input":{"notebook_path":"/x/n.ipynb"}}' | hook + [ "$(cat "$DEVDESK_STATE_DIR/edits/alpha.txt")" = "$(printf '/x/a.txt\n/x/n.ipynb')" ] + devdesk rename alpha beta + [ -f "$DEVDESK_STATE_DIR/edits/beta.txt" ] && [ ! -e "$DEVDESK_STATE_DIR/edits/alpha.txt" ] + devdesk end beta + [ ! -e "$DEVDESK_STATE_DIR/edits/beta.txt" ] +} + +@test "asset serves only named .js files from the assets folder (#19)" { + mkdir -p "$TMP/assets" + echo 'window.X = 1;' >"$TMP/assets/editor.js" + run env DEVDESK_ASSETS="$TMP/assets" "$REPO/bin/devdesk" attach --asset editor.js + [ "$status" -eq 0 ] + blob_body "$output" + cmp "$TMP/blob" "$TMP/assets/editor.js" + for bad in ../etc/passwd editor.css nope.js; do + run env DEVDESK_ASSETS="$TMP/assets" "$REPO/bin/devdesk" attach --asset "$bad" + [ "$status" -ne 0 ] + done +} diff --git a/web/build.mjs b/web/build.mjs index 714fc8b..67f5bac 100644 --- a/web/build.mjs +++ b/web/build.mjs @@ -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) => { diff --git a/web/editor/index.js b/web/editor/index.js new file mode 100644 index 0000000..f28eb67 --- /dev/null +++ b/web/editor/index.js @@ -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 } }); +} diff --git a/web/index.template.html b/web/index.template.html index 0df94fc..a3690cf 100644 --- a/web/index.template.html +++ b/web/index.template.html @@ -63,11 +63,13 @@ +
devdesk
+ @@ -88,6 +90,22 @@ + +