Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65114165e7 | ||
|
|
e60f1ad066 | ||
|
|
ab7e127f70 | ||
|
|
24d71131e8 | ||
|
|
3a0b099f02 | ||
|
|
a2edec966a |
@@ -41,7 +41,7 @@ install: all
|
||||
install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \
|
||||
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
|
||||
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix libexec/devdesk-guard $(DESTDIR)$(LIBDIR)/
|
||||
install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh libexec/devdesk-history.sh libexec/devdesk-ops.sh \
|
||||
install -m 0644 libexec/devdesk-files.sh libexec/devdesk-fsops.sh libexec/devdesk-inbox.sh libexec/devdesk-history.sh libexec/devdesk-ops.sh \
|
||||
$(DESTDIR)$(LIBDIR)/
|
||||
install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd
|
||||
install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
|
||||
|
||||
@@ -31,15 +31,17 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
||||
- 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.
|
||||
- **Own git worktree:** for a git project, tick **own git worktree** (or `devdesk new NAME PROJECT --worktree`). The session gets its own checkout in `$ROOT/.worktrees/PROJECT--NAME` on branch `devdesk/NAME`, so two Claudes on one repo never edit the same files. Ending it removes the worktree only if nothing is uncommitted, and the branch only if it's merged.
|
||||
- **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.
|
||||
- **Session status:** Claude hooks (`devdesk-state-hook`) mark each session **working**, **waiting on you** (with Claude's message) or **idle**.
|
||||
- **Session status:** Claude hooks (`devdesk-state-hook`) mark each session **working**, **waiting on you** (with Claude's message) or **idle**. Claude's idle reminder a minute after a turn ends doesn't count as waiting. While Claude works, the session chip and the tab title show how long the turn has run (`⏳ 3m`).
|
||||
- **Session buttons:** ⧉ copies Claude's whole last reply as Markdown. ⇲ **Compact** appears when the context is 80 % full and Claude is idle, and sends `/compact`.
|
||||
- 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.
|
||||
- **Answer from the inbox:** a session waiting on a permission shows the exact command/file and **Allow / Deny** (the key is pressed only if that prompt is still on screen); other waits and finished sessions offer **Peek & reply**: the last exchange and a reply box.
|
||||
- **Matrix "Claude needs you":** when a session has waited for `DEVDESK_MATRIX_DELAY` seconds (30) and no browser tab or SSH client is attached to it, `devdesk-notify-matrix` posts one message per wait, with a link, to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off).
|
||||
- **Matrix "Claude needs you":** when a session has waited on a real request (a permission prompt or a question) for `DEVDESK_MATRIX_DELAY` seconds (60) and no DevDesk tab has been in view for about 2 minutes, `devdesk-notify-matrix` posts what Claude asks ("alpha: Claude wants to run Bash (Restart nginx)", the command, a link). The post goes to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off). Each session posts at most once per `DEVDESK_MATRIX_QUIET` seconds (600).
|
||||
- **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.
|
||||
@@ -48,18 +50,26 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
||||
- Guests are matched by the alert's `instance` IP (or host name), else by name in the job or alert ("by name"). Aggregate rules with no labels (`count(up == 0)`) get a hint to run the inner query instead.
|
||||
- **`devdesk guests [ID|IP|NAME]`** lists Proxmox CTs/VMs with the node each runs on now, status, HA state and IPs. Claude is told to use it before acting, because HA guests move. Stopped guests keep the IP they had when last seen running.
|
||||
- Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`. For the guest lookup, set `DEVDESK_PVE_NODES` to a few Proxmox nodes reachable by root SSH key. VM IPs need the QEMU guest agent.
|
||||
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log.
|
||||
- **Files** (Ctrl+Shift+E), laid out like VS Code: an explorer tree (git colours, gitignored entries dimmed) and an editor with tabs (a click opens a preview tab, a double-click keeps it), **Go to file** (Ctrl+P in the pane), markdown previews (sanitized, with relative images), images, and a Changes view of uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt. Tabs and open folders are remembered per session. Paths can't leave the session folder.
|
||||
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. The first changed files are listed; click one for its diff against HEAD. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log.
|
||||
- **Files** (Ctrl+Shift+E), laid out like VS Code:
|
||||
- **Explorer tree:** git colours, gitignored entries dimmed.
|
||||
- **Mouse:** right-click menus (new file/folder, rename, delete, cut/copy/paste, duplicate, copy path, download, @Claude), Ctrl/Shift+click to select several, drag to move (Ctrl+drag copies). Drop files or whole folders from the desktop to upload them (up to 25 MB each), or drag a file onto the terminal to type `@path`.
|
||||
- **Keyboard:** F2 renames in place, Delete trashes, Ctrl+C/X/V copy and paste files.
|
||||
- **Safety:** delete goes to a 7-day trash with **Undo**; nothing is overwritten unless you choose Replace.
|
||||
- **Editor:** tabs (a click opens a preview tab, a double-click keeps it; right-click for close variants; drag to reorder), **Go to file** (Ctrl+P in the pane), and **Search in files** (Ctrl+Shift+F: match case, whole word, regex). A status bar shows line/column, cursors, indentation, line endings and language. Multiple cursors (Alt+click), column select (Alt+drag), auto-closing brackets, Ctrl+G to go to a line.
|
||||
- **Markdown preview:** GitHub-style headings with anchors and an outline, syntax-coloured code with Copy, callouts, task lists, front matter, images, and links that open files in DevDesk. **Split** shows the source and a live preview side by side.
|
||||
- **Changes view:** uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt.
|
||||
- **Remembered and contained:** tabs and open folders are remembered per session, and paths can't leave the session folder.
|
||||
- **Editing:** text files are editable; ● marks unsaved tabs; Ctrl+S (or Save) writes atomically and keeps the file's mode and owner. If the file changed on disk since you opened it (Claude edited it), saving asks: Compare (a diff of disk vs yours), Overwrite or Cancel; a change on disk while you edit shows a banner instead of replacing your text. Unsaved edits are kept as drafts in the browser, so a reload or a session switch doesn't lose them. + creates a file (folders as needed). The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
|
||||
- **Paths are links:** a file path Claude prints (`web/src/files.js`, `notes.md:12`) opens in Files when clicked, at that line if given.
|
||||
- **Ops guard:** `devdesk-guard` (a PreToolUse hook) makes Claude ask before destructive commands (`pct destroy`, `zfs destroy`, Ceph pool/OSD removal, `rm -r /…`, `git push --force`, `reboot`…), even in auto mode. `DEVDESK_GUARD=0` turns it off; `/etc/devdesk/guard.local` adds rules (`regex<TAB>reason`). Bash commands are logged per session, and sessions are tagged with the hosts they touch (ssh targets, CT/VM ids).
|
||||
- **Search past conversations** (⋯ menu, palette): full text over every Claude conversation on the host; **Resume** it in a new session or **Fork** it.
|
||||
- **Plan usage:** the 5-hour and weekly windows as meters in the footer (warning at 90 %); a **PR** chip on sessions whose branch has a pull request.
|
||||
- **Plan usage:** the 5-hour and weekly windows as meters in the footer, with when each resets (`↻ 14:30`). If the pace so far would use a window up before it resets, the meter shows `⚠ full ~13:50` instead. A toast warns at 90 %, and Matrix gets one message per window when you're away (`DEVDESK_USAGE_WARN`). Sessions also get a **PR** chip when their branch has a pull request.
|
||||
- **Quiet phone at the desk:** while a tab shows a session, Remote Control holds back phone pushes for it (client-presence marker, expires ~2 min after you leave).
|
||||
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use, plan usage and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar and Claude's own footer shows a one-line summary. On a Pro/Max plan that summary shows 5-hour plan usage; the dollar figure Claude reports there is an API-price estimate, not a bill, so it appears only for API-key billing.
|
||||
- **Light mode:** the terminal switches palettes with the page. DevDesk starts Claude with its `dark-ansi` theme so Claude draws with the terminal's 16 colours, and the light palette maps bright white to ink; tmux and the status line use ANSI colours too. A session picks up Claude's theme the next time Claude starts in it (`/exit`, then ⏎ resumes the same conversation).
|
||||
- **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`.
|
||||
- **SSH:** `devdesk attach <name>`, `devdesk new <name> [project] [--worktree]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`, `devdesk repo <name>`, `devdesk alerts`.
|
||||
|
||||
## Keys
|
||||
| Key | Action |
|
||||
@@ -69,7 +79,11 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
||||
| Ctrl-b 0 / 1 | Claude window / shell window |
|
||||
| Ctrl-b s | Switch session (the URL follows) |
|
||||
| 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) |
|
||||
| Drag | Selects text; it stays highlighted and is copied when you let go. Double-click selects a word, triple-click a line. Clicks on the tmux status line still go to tmux. |
|
||||
| Ctrl+C | Copies the selection when there is one; otherwise interrupts as usual |
|
||||
| Ctrl+Shift+F | Search in the session's files |
|
||||
| F2 / Delete / Ctrl+C·X·V (explorer) | Rename, move to trash, copy/cut/paste files |
|
||||
| Palette → Close split panes | Removes extra tmux panes (a stray `^b %` split) and keeps Claude. tmux's right-click menus are off. |
|
||||
| Ctrl+Shift+C / V | Copy selection / paste |
|
||||
| Ctrl+Shift+P | Command palette (also the ⌕ button) |
|
||||
| Ctrl+Shift+E | Files pane (Esc: back, then close) |
|
||||
@@ -83,7 +97,7 @@ The **☀/◐** button switches light and dark mode (saved per browser, shared w
|
||||
- `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
|
||||
- `DEVDESK_PUBLIC_URL`, `DEVDESK_MATRIX_WEBHOOK_FILE`, `DEVDESK_MATRIX_DELAY`, `DEVDESK_MATRIX_QUIET` and `DEVDESK_USAGE_WARN` for the Matrix notifications
|
||||
- `/etc/devdesk/secret.env` is generated on install. It holds the ttyd credential that NPM must inject; see [docs/DEPLOY.md](docs/DEPLOY.md).
|
||||
- `/etc/devdesk/tmux.local.conf` is optional and is sourced after the packaged `tmux.conf`.
|
||||
|
||||
|
||||
+91
-13
@@ -2,7 +2,9 @@
|
||||
# devdesk — persistent Claude Code sessions in tmux, shared by the browser UI and SSH.
|
||||
#
|
||||
# devdesk attach [NAME] attach to session NAME, creating it if needed; no NAME opens the picker
|
||||
# devdesk new NAME [PROJECT] create session NAME (detached) in PROJECT's folder, or the default folder
|
||||
# devdesk new NAME [PROJECT] [--worktree]
|
||||
# create session NAME (detached) in PROJECT's folder, or the default
|
||||
# folder; --worktree gives it its own git worktree on branch devdesk/NAME
|
||||
# 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)
|
||||
@@ -12,7 +14,8 @@
|
||||
# devdesk pick interactive picker
|
||||
#
|
||||
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --investigate SESSION UID |
|
||||
# --upload SESSION FILE BYTES | --new NAME [PROJECT] | --rename OLD NEW | --end NAME | --existing NAME`
|
||||
# --upload SESSION FILE BYTES | --new NAME [PROJECT] [--worktree] | --rename OLD NEW | --end NAME |
|
||||
# --last-reply NAME | --unsplit NAME | --fs-* SESSION … | --existing NAME`
|
||||
# through ttyd; those print JSON.
|
||||
set -euo pipefail
|
||||
|
||||
@@ -26,6 +29,8 @@ set -euo pipefail
|
||||
. "$DEVDESK_LIBEXEC/devdesk-history.sh"
|
||||
# shellcheck source=libexec/devdesk-ops.sh
|
||||
. "$DEVDESK_LIBEXEC/devdesk-ops.sh"
|
||||
# shellcheck source=libexec/devdesk-fsops.sh
|
||||
. "$DEVDESK_LIBEXEC/devdesk-fsops.sh"
|
||||
|
||||
usage() {
|
||||
sed -n '2,13s/^# \{0,1\}//p' "$0"
|
||||
@@ -49,7 +54,7 @@ projects_tsv() {
|
||||
# Only offer directories that attach would accept (no symlinks out of the root).
|
||||
real=$(realpath -e -- "$d" 2>/dev/null) || continue
|
||||
case "$real/" in "$root"/*) ;; *) continue ;; esac
|
||||
printf '%s\t%s\n' "$(dd_slugify "${d##*/}")" "$d"
|
||||
printf '%s\t%s\t%s\n' "$(dd_slugify "${d##*/}")" "$([ -e "$d/.git" ] && echo 1 || echo 0)" "$d"
|
||||
done
|
||||
}
|
||||
|
||||
@@ -85,15 +90,17 @@ cmd_list() {
|
||||
| ($live | map(.name)) as $liveNames
|
||||
| {default: $dflt, root: $root,
|
||||
sessions: (
|
||||
($live | map(. + {claude_session_id: ($known[.name].claude_session_id // null)}))
|
||||
($live | map(. + {claude_session_id: ($known[.name].claude_session_id // null),
|
||||
worktree: ($known[.name].worktree // null)}))
|
||||
+ ($known | to_entries
|
||||
| map(select(.key as $k | $liveNames | index($k) | not))
|
||||
| map({name: .key, dir: .value.dir, running: false, attached: 0,
|
||||
activity: null, windows: 0,
|
||||
claude_session_id: (.value.claude_session_id // null)}))
|
||||
claude_session_id: (.value.claude_session_id // null),
|
||||
worktree: (.value.worktree // null)}))
|
||||
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // [])}),
|
||||
projects: ([{name: $dflt, dir: $root}]
|
||||
+ [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))}
|
||||
+ [rows($proj)[] | {name: .[0], git: (.[1] == "1"), dir: (.[2:] | join("\t"))}
|
||||
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
|
||||
}
|
||||
|
||||
@@ -121,7 +128,12 @@ create_detached() {
|
||||
}
|
||||
|
||||
cmd_new() {
|
||||
{ [ $# -ge 1 ] && [ $# -le 2 ]; } || dd_die "usage: devdesk new NAME [PROJECT]"
|
||||
local worktree=0 a args=()
|
||||
for a in "$@"; do
|
||||
if [ "$a" = --worktree ]; then worktree=1; else args+=("$a"); fi
|
||||
done
|
||||
set -- "${args[@]}"
|
||||
{ [ $# -ge 1 ] && [ $# -le 2 ]; } || dd_die "usage: devdesk new NAME [PROJECT] [--worktree]"
|
||||
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
||||
local dir
|
||||
if [ $# -eq 2 ]; then
|
||||
@@ -129,12 +141,68 @@ cmd_new() {
|
||||
dir=$(dd_project_dir "$2") || dd_die "no project '$2' under $DEVDESK_PROJECTS_ROOT"
|
||||
! session_exists "$1" || dd_die "a session named '$1' already exists"
|
||||
else
|
||||
[ "$worktree" = 0 ] || dd_die "--worktree needs a project"
|
||||
dir=$(dd_resolve_dir "$1")
|
||||
fi
|
||||
[ "$worktree" = 0 ] || dir=$(dd_worktree_add "$1" "$2" "$dir")
|
||||
dd_ensure_server
|
||||
create_detached "$1" "$dir"
|
||||
}
|
||||
|
||||
# A git worktree of PROJECT for session NAME (devdesk#44), so two Claudes on one
|
||||
# repository never edit the same checkout. It lives in $ROOT/.worktrees (hidden
|
||||
# from the project list) on branch devdesk/NAME, made from the project's HEAD or
|
||||
# reused if that branch exists. Prints the folder the session starts in.
|
||||
dd_worktree_add() {
|
||||
local name=$1 project=$2 dir=$3 top prefix wt branch="devdesk/$1" g
|
||||
top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || dd_die "'$project' is not a git repository"
|
||||
prefix=$(git -C "$dir" rev-parse --show-prefix)
|
||||
wt="$DEVDESK_PROJECTS_ROOT/.worktrees/$project--$name"
|
||||
[ ! -e "$wt" ] || dd_die "$wt already exists"
|
||||
mkdir -p "$DEVDESK_PROJECTS_ROOT/.worktrees"
|
||||
g=(git -C "$top")
|
||||
if "${g[@]}" show-ref -q --verify "refs/heads/$branch"; then
|
||||
"${g[@]}" worktree add -q "$wt" "$branch" >&2 || dd_die "git worktree add failed"
|
||||
else
|
||||
"${g[@]}" worktree add -q -b "$branch" "$wt" HEAD >&2 || dd_die "git worktree add failed"
|
||||
fi
|
||||
wt=$(realpath -e -- "$wt") || dd_die "git worktree add failed"
|
||||
# shellcheck disable=SC2016 # jq variables
|
||||
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {worktree: {path: $w, repo: $r, branch: $b}})' \
|
||||
--arg n "$name" --arg w "$wt" --arg r "$top" --arg b "$branch"
|
||||
printf '%s\n' "$wt${prefix:+/${prefix%/}}"
|
||||
}
|
||||
|
||||
# Ending a worktree session removes the worktree when nothing in it is uncommitted,
|
||||
# and its branch only when that is merged (git branch -d). Otherwise both stay.
|
||||
dd_worktree_end() {
|
||||
local wt=$1 repo=$2 branch=$3
|
||||
[ -d "$wt" ] || return 0
|
||||
if [ -z "$(git -C "$wt" status --porcelain 2>/dev/null)" ]; then
|
||||
git -C "$repo" worktree remove "$wt" 2>/dev/null || return 0
|
||||
git -C "$repo" branch -d "$branch" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
# --unsplit NAME (devdesk#52): close the extra panes a split left in the session's
|
||||
# windows. Each window keeps one pane: Claude's in the claude window, else the
|
||||
# first. Prints {ok, closed}.
|
||||
cmd_unsplit() {
|
||||
[ $# -eq 1 ] || dd_die "usage: devdesk attach --unsplit NAME"
|
||||
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
||||
{ dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } || dd_die "session '$1' isn't running"
|
||||
local win keep pane closed=0
|
||||
while read -r win; do
|
||||
keep=$(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id} #{pane_start_command}' | awk '/devdesk-claude-run/ { print $1; exit }')
|
||||
[ -n "$keep" ] || keep=$(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id}' | head -n 1)
|
||||
while read -r pane; do
|
||||
[ "$pane" = "$keep" ] && continue
|
||||
dd_tmux kill-pane -t "$pane" && closed=$((closed + 1))
|
||||
done < <(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id}')
|
||||
done < <(dd_tmux list-windows -t "=$1" -F '#{window_index}')
|
||||
jq -cMn --argjson c "$closed" '{ok: true, closed: $c}'
|
||||
}
|
||||
|
||||
cmd_rename() {
|
||||
[ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW"
|
||||
local old=$1 new=$2 running=0
|
||||
@@ -160,8 +228,14 @@ rename_locked() {
|
||||
cmd_end() {
|
||||
[ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
|
||||
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
||||
local wt
|
||||
wt=$(jq -rM --arg n "$1" '.sessions[$n].worktree // empty | [.path, .repo, .branch] | @tsv' "$DEVDESK_REGISTRY" 2>/dev/null) || wt=""
|
||||
# Forget first: killing the session SIGHUPs us when run from inside it.
|
||||
dd_registry_remove "$1"
|
||||
if [ -n "$wt" ]; then
|
||||
IFS=$'\t' read -r -a wt <<<"$wt"
|
||||
dd_worktree_end "${wt[@]}"
|
||||
fi
|
||||
rm -rf "${DEVDESK_STATE_DIR:?}/uploads/$1"
|
||||
if dd_server_up; then
|
||||
dd_tmux kill-session -t "=$1" 2>/dev/null || true
|
||||
@@ -196,7 +270,7 @@ gitea_slug() {
|
||||
cmd_repo() {
|
||||
[ $# -eq 1 ] || dd_die "usage: devdesk repo NAME"
|
||||
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
|
||||
local dir g branch sha="" dirty changes ahead=null behind=null upstream=false remote slug="" ci=null prs=null
|
||||
dir=$(dd_resolve_dir "$1")
|
||||
# --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.
|
||||
@@ -208,6 +282,9 @@ cmd_repo() {
|
||||
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
|
||||
sha=$("${g[@]}" rev-parse -q --verify HEAD 2>/dev/null || true)
|
||||
dirty=$("${g[@]}" status --porcelain 2>/dev/null | wc -l)
|
||||
# The first changed files, for the panel to open as diffs (devdesk#46).
|
||||
changes='[]'
|
||||
[ "$dirty" = 0 ] || changes=$(cmd_git_status "$1" 2>/dev/null | jq -cM '.files[:8] // []' 2>/dev/null) || changes='[]'
|
||||
if "${g[@]}" rev-parse -q --verify '@{u}' >/dev/null 2>&1; then
|
||||
upstream=true
|
||||
read -r behind ahead < <("${g[@]}" rev-list --left-right --count '@{u}...HEAD')
|
||||
@@ -222,10 +299,10 @@ cmd_repo() {
|
||||
prs=$(gitea_get "/repos/$slug/pulls?state=open&limit=50" | jq -M 'length' 2>/dev/null) || prs=null
|
||||
fi
|
||||
jq -cMn --arg s "$1" --arg dir "$dir" --arg branch "$branch" --arg sha "${sha:0:7}" \
|
||||
--argjson dirty "$dirty" --argjson ahead "$ahead" --argjson behind "$behind" \
|
||||
--argjson dirty "$dirty" --argjson changes "${changes:-[]}" --argjson ahead "$ahead" --argjson behind "$behind" \
|
||||
--argjson upstream "$upstream" --arg remote "$remote" --arg slug "$slug" \
|
||||
--arg web "$DEVDESK_GITEA_URL" --argjson ci "${ci:-null}" --argjson prs "${prs:-null}" '
|
||||
{session: $s, dir: $dir, git: true, branch: $branch, sha: $sha, dirty: $dirty,
|
||||
{session: $s, dir: $dir, git: true, branch: $branch, sha: $sha, dirty: $dirty, changes: $changes,
|
||||
ahead: $ahead, behind: $behind, upstream: $upstream, remote: $remote,
|
||||
repo: (if $slug == "" then null else $slug end),
|
||||
url: (if $slug == "" then null else "\($web)/\($slug)" end),
|
||||
@@ -303,17 +380,18 @@ cmd_attach() {
|
||||
dd_linger
|
||||
return
|
||||
;;
|
||||
--fs-list | --fs-find | --fs-read | --fs-write | --git-status | --git-show | --asset)
|
||||
--fs-list | --fs-find | --fs-read | --fs-write | --git-status | --git-show | --asset | \
|
||||
--fs-mkdir | --fs-move | --fs-copy | --fs-delete | --fs-restore | --fs-put | --fs-download | --fs-grep)
|
||||
local action=${1#--}
|
||||
shift
|
||||
"cmd_${action//-/_}" "$@"
|
||||
dd_linger
|
||||
return
|
||||
;;
|
||||
--peek | --search)
|
||||
--peek | --search | --last-reply | --unsplit)
|
||||
local action=${1#--}
|
||||
shift
|
||||
"cmd_$action" "$@"
|
||||
"cmd_${action//-/_}" "$@"
|
||||
dd_linger
|
||||
return
|
||||
;;
|
||||
|
||||
Vendored
+94
@@ -1,3 +1,97 @@
|
||||
devdesk (0.11.0) bookworm; urgency=medium
|
||||
|
||||
* Explorer works like VS Code's (#54):
|
||||
- Right-click menus for files, folders and empty space;
|
||||
Ctrl/Shift+click selects several.
|
||||
- Inline rename (F2) and inline new file/folder.
|
||||
- Drag to move, Ctrl+drag to copy.
|
||||
- Drop files or whole folders from the desktop to upload them, asking
|
||||
Replace / Skip / Keep both on a clash.
|
||||
- Cut/copy/paste files, duplicate, download, copy path, @Claude.
|
||||
- Delete moves to a 7-day trash with Undo; nothing is overwritten
|
||||
unless asked.
|
||||
- Server side: new --fs-mkdir/-move/-copy/-delete/-restore/-put/
|
||||
-download.
|
||||
* Markdown preview rewritten (#55):
|
||||
- GitHub-style headings with anchors and an outline.
|
||||
- Highlighted code with Copy, callouts, task lists, front matter.
|
||||
- Relative links open files in DevDesk.
|
||||
- Split view with a live preview.
|
||||
* Editor (#55): status bar (line/column, cursors, indentation, line
|
||||
endings, language), multiple cursors, column select, auto-closing
|
||||
brackets, Ctrl+G, and tab menus with drag-to-reorder.
|
||||
* Search in files, Ctrl+Shift+F (#56): match case, whole word and regex,
|
||||
via git grep or grep.
|
||||
* Fixes:
|
||||
- A nested path check could escape the session folder: set -e does not
|
||||
reach into $(...).
|
||||
- The tree could show a stale listing.
|
||||
- Session actions now return the server's reply, so "Close split
|
||||
panes" reports correctly.
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 22 Oct 2026 21:30:00 -0400
|
||||
|
||||
devdesk (0.10.4) bookworm; urgency=medium
|
||||
|
||||
* The Sessions panel's list scrolls on its own (#53). There is no cap on
|
||||
sessions; with many of them, Ops Alerts and Repo/CI stay in reach below
|
||||
the list. The current session is scrolled into view when you switch.
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 22 Oct 2026 20:53:00 -0400
|
||||
|
||||
devdesk (0.10.3) bookworm; urgency=medium
|
||||
|
||||
* No tmux right-click menus (#52). A stray click on one split a pane, or
|
||||
could kill Claude's window. "Close split panes (keeps Claude)" in the
|
||||
command palette, backed by `--unsplit`, removes a split that happened
|
||||
anyway.
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Tue, 13 Oct 2026 09:00:00 -0400
|
||||
|
||||
devdesk (0.10.2) bookworm; urgency=medium
|
||||
|
||||
* Selecting text in the terminal works like a normal terminal (#51). A
|
||||
drag is a browser selection that stays highlighted and is copied when
|
||||
you let go, with a short toast. Ctrl+C copies the selection and
|
||||
interrupts only when nothing is selected. The wheel still scrolls tmux
|
||||
history, and the tmux status line still takes clicks.
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Mon, 12 Oct 2026 16:36:00 -0400
|
||||
|
||||
devdesk (0.10.1) bookworm; urgency=medium
|
||||
|
||||
* Everything in 0.10.0, which was never published: its CI test for the
|
||||
usage ping depended on attaching a tmux client.
|
||||
* The usage ping counts you as away the same way "Claude needs you" does:
|
||||
no DevDesk tab in view for ~2 minutes. A forgotten background tab no
|
||||
longer silences it.
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Mon, 12 Oct 2026 01:45:00 -0400
|
||||
|
||||
devdesk (0.10.0) bookworm; urgency=medium
|
||||
|
||||
* Matrix "Claude needs you" is quieter and says what is asked (#50):
|
||||
- Claude's idle reminder after a turn no longer counts as a wait: no red
|
||||
session, no inbox entry and no Matrix post.
|
||||
- Posts only when no DevDesk tab has been in view for ~2 minutes, after
|
||||
60 s, and at most once per session per 10 minutes
|
||||
(DEVDESK_MATRIX_QUIET).
|
||||
- The message names the tool, its description and the command.
|
||||
* Own git worktree per session (#44): `devdesk new NAME PROJECT
|
||||
--worktree` or a checkbox in the New session form. It uses branch
|
||||
devdesk/NAME in $ROOT/.worktrees. Ending the session removes the
|
||||
worktree if it is clean, and the branch if it is merged.
|
||||
* Plan meters show when each window resets. If the pace so far would use
|
||||
a window up before then, they warn instead. Matrix gets one message per
|
||||
5-hour window past DEVDESK_USAGE_WARN (90%) when no tab is in view (#45).
|
||||
* Repo panel lists the changed files; a click opens the diff (#46).
|
||||
* Copy Claude's last reply as Markdown: a session button and a palette
|
||||
entry, backed by `--last-reply` (#47).
|
||||
* Turn timer in the session chip, the tab title and session cards (#48).
|
||||
* Compact button when the context is 80% full and Claude is idle (#49).
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 23:30:00 -0400
|
||||
|
||||
devdesk (0.9.5) bookworm; urgency=medium
|
||||
|
||||
* Context use is shown in tokens as well as percent (#43): Claude's
|
||||
|
||||
@@ -46,9 +46,16 @@ 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.
|
||||
# Only real requests (a permission prompt, a question), never Claude's idle
|
||||
# reminder, and never while a DevDesk tab is in view.
|
||||
# DEVDESK_MATRIX_DELAY: seconds the wait must last before posting.
|
||||
# DEVDESK_MATRIX_QUIET: at most one message per session in this many seconds.
|
||||
DEVDESK_MATRIX_WEBHOOK_FILE=/etc/devdesk/matrix-webhook
|
||||
DEVDESK_MATRIX_DELAY=30
|
||||
DEVDESK_MATRIX_DELAY=60
|
||||
DEVDESK_MATRIX_QUIET=600
|
||||
# DEVDESK_USAGE_WARN: post to the same room once per 5-hour window when plan usage
|
||||
# passes this percent while no DevDesk tab is in view (devdesk#45).
|
||||
DEVDESK_USAGE_WARN=90
|
||||
|
||||
# 0 turns off the guard that makes Claude ask before destructive commands
|
||||
# (pct/zfs/ceph destroy, rm -r /…, force pushes, reboots). Extra rules:
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
|
||||
# Explorer file operations (devdesk#54), sourced by bin/devdesk and reached through
|
||||
# `devdesk attach --fs-mkdir|--fs-move|--fs-copy|--fs-delete|--fs-restore|--fs-put|--fs-download`.
|
||||
# Same rules as devdesk-files.sh: every path is relative to the session's folder
|
||||
# and stays inside it. Entries are handled as themselves (a symlink is moved or
|
||||
# deleted as a link, never followed), nothing is ever overwritten unless asked,
|
||||
# and deleting moves to a per-session trash that can be restored for 7 days.
|
||||
#
|
||||
# set -e does not reach into $(...): every helper whose result is captured is
|
||||
# followed by `|| exit 1`, or a failed check would carry on with an empty path.
|
||||
|
||||
: "${DEVDESK_TRASH_DAYS:=7}"
|
||||
|
||||
# A name the explorer may create: not empty, no slash, not . or .., no newline.
|
||||
dd_fs_valid_name() {
|
||||
case $1 in '' | . | .. | */* | *$'\n'* | *$'\r'*) return 1 ;; esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# dd_fs_inside ROOT ABS: dies unless ABS is ROOT or inside it (a second guard,
|
||||
# whatever the callers checked).
|
||||
dd_fs_inside() {
|
||||
{ [ -n "$1" ] && [ -n "$2" ]; } || dd_die "internal error: empty path"
|
||||
case "$2/" in "$1"/*) ;; *) dd_die "outside the session folder: $2" ;; esac
|
||||
}
|
||||
|
||||
# dd_fs_split REL -> sets DD_PARENT (relative, may be empty) and DD_NAME, or dies.
|
||||
dd_fs_split() {
|
||||
local rel=$1 part
|
||||
case $rel in /* | *$'\n'* | *$'\r'* | '' | */) dd_die "bad path: $rel" ;; esac
|
||||
local IFS=/
|
||||
for part in $rel; do
|
||||
{ [ -n "$part" ] && [ "$part" != . ] && [ "$part" != .. ]; } || dd_die "bad path: $rel"
|
||||
done
|
||||
DD_NAME=${rel##*/}
|
||||
if [[ $rel == */* ]]; then DD_PARENT=${rel%/*}; else DD_PARENT=""; fi
|
||||
}
|
||||
|
||||
# dd_fs_entry ROOT REL -> the absolute path of the entry itself (its folder
|
||||
# resolved, the last component not followed), which must exist and isn't ROOT.
|
||||
dd_fs_entry() {
|
||||
local root=$1 dir
|
||||
dd_fs_split "$2"
|
||||
dir=$(dd_fs_path "$root" "${DD_PARENT:-.}") || exit 1
|
||||
[ -d "$dir" ] || dd_die "not a folder: $DD_PARENT"
|
||||
{ [ -e "$dir/$DD_NAME" ] || [ -L "$dir/$DD_NAME" ]; } || dd_die "no such file: $2"
|
||||
dd_fs_inside "$root" "$dir"
|
||||
printf '%s/%s\n' "$dir" "$DD_NAME"
|
||||
}
|
||||
|
||||
# dd_fs_mkdirs ROOT RELDIR -> creates the missing folders of RELDIR one level at
|
||||
# a time, each checked to be a real folder inside ROOT (never through a symlink
|
||||
# out of it), and prints the absolute folder.
|
||||
dd_fs_mkdirs() {
|
||||
local root=$1 rel=$2 cur part
|
||||
cur=$root
|
||||
[ -n "$rel" ] || { printf '%s\n' "$root"; return; }
|
||||
local IFS=/
|
||||
for part in $rel; do
|
||||
dd_fs_valid_name "$part" || dd_die "bad folder name: $part"
|
||||
if [ ! -e "$cur/$part" ] && [ ! -L "$cur/$part" ]; then
|
||||
mkdir -- "$cur/$part" || dd_die "cannot create folder: $part"
|
||||
fi
|
||||
cur=$(realpath -e -- "$cur/$part" 2>/dev/null) || dd_die "cannot use folder: $part"
|
||||
case "$cur/" in "$root"/*) ;; *) dd_die "outside the session folder: $rel" ;; esac
|
||||
[ -d "$cur" ] || dd_die "not a folder: $part"
|
||||
done
|
||||
printf '%s\n' "$cur"
|
||||
}
|
||||
|
||||
# dd_fs_dest ROOT REL -> absolute path for a new entry REL (folders above it are
|
||||
# created); dies if something is already there.
|
||||
dd_fs_dest() {
|
||||
local root=$1 dir
|
||||
dd_fs_split "$2"
|
||||
dir=$(dd_fs_mkdirs "$root" "$DD_PARENT") || exit 1
|
||||
if [ -e "$dir/$DD_NAME" ] || [ -L "$dir/$DD_NAME" ]; then dd_die "already exists: $2"; fi
|
||||
dd_fs_inside "$root" "$dir"
|
||||
printf '%s/%s\n' "$dir" "$DD_NAME"
|
||||
}
|
||||
|
||||
# --fs-mkdir SESSION REL: a new folder (and any missing ones above it).
|
||||
cmd_fs_mkdir() {
|
||||
[ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-mkdir SESSION PATH"
|
||||
local root dest
|
||||
root=$(dd_session_root "$1") || exit 1
|
||||
dest=$(dd_fs_dest "$root" "$2") || exit 1
|
||||
mkdir -- "$dest" || dd_die "cannot create folder: $2"
|
||||
jq -cMn --arg p "${dest#"$root"/}" '{ok: true, path: $p}'
|
||||
}
|
||||
|
||||
# --fs-move SESSION FROM TO / --fs-copy SESSION FROM TO: TO is the full new path.
|
||||
# Never onto something that exists, never a folder into itself.
|
||||
dd_fs_transfer() {
|
||||
local op=$1 session=$2 from=$3 to=$4 root src dest
|
||||
root=$(dd_session_root "$session") || exit 1
|
||||
src=$(dd_fs_entry "$root" "$from") || exit 1
|
||||
dest=$(dd_fs_dest "$root" "$to") || exit 1
|
||||
case "$dest/" in "$src"/*) dd_die "cannot put a folder inside itself: $to" ;; esac
|
||||
if [ "$op" = move ]; then
|
||||
mv -T -n -- "$src" "$dest" 2>/dev/null || dd_die "cannot move $from"
|
||||
if [ -e "$src" ] || [ -L "$src" ]; then dd_die "cannot move $from"; fi
|
||||
else
|
||||
cp -a -T -n -- "$src" "$dest" 2>/dev/null || { rm -rf -- "$dest"; dd_die "cannot copy $from"; }
|
||||
fi
|
||||
jq -cMn --arg f "${src#"$root"/}" --arg t "${dest#"$root"/}" '{ok: true, from: $f, path: $t}'
|
||||
}
|
||||
cmd_fs_move() {
|
||||
[ $# -eq 3 ] || dd_die "usage: devdesk attach --fs-move SESSION FROM TO"
|
||||
dd_fs_transfer move "$@"
|
||||
}
|
||||
cmd_fs_copy() {
|
||||
[ $# -eq 3 ] || dd_die "usage: devdesk attach --fs-copy SESSION FROM TO"
|
||||
dd_fs_transfer copy "$@"
|
||||
}
|
||||
|
||||
dd_trash_dir() {
|
||||
printf '%s/trash/%s\n' "$DEVDESK_STATE_DIR" "$1"
|
||||
}
|
||||
|
||||
# --fs-delete SESSION REL...: moves the entries to the session's trash
|
||||
# ($DEVDESK_STATE_DIR/trash/SESSION/ID, kept DEVDESK_TRASH_DAYS days) and prints
|
||||
# {ok, trash: ID, paths} for --fs-restore.
|
||||
cmd_fs_delete() {
|
||||
[ $# -ge 2 ] || dd_die "usage: devdesk attach --fs-delete SESSION PATH..."
|
||||
local session=$1 root trash id rel src i=0 entries=()
|
||||
shift
|
||||
root=$(dd_session_root "$session") || exit 1
|
||||
# Check every path before moving any.
|
||||
for rel in "$@"; do
|
||||
src=$(dd_fs_entry "$root" "$rel") || exit 1
|
||||
entries+=("$src")
|
||||
done
|
||||
trash=$(dd_trash_dir "$session") || exit 1
|
||||
mkdir -p -- "$trash" && chmod 700 "$DEVDESK_STATE_DIR/trash" "$trash" 2>/dev/null
|
||||
find "$trash" -mindepth 1 -maxdepth 1 -mtime +"$DEVDESK_TRASH_DAYS" -exec rm -rf -- {} + 2>/dev/null || true
|
||||
id=$(date +%Y%m%d-%H%M%S)-$RANDOM
|
||||
mkdir -- "$trash/$id" || dd_die "cannot use the trash"
|
||||
local done_paths=()
|
||||
for src in "${entries[@]}"; do
|
||||
mv -T -- "$src" "$trash/$id/$i" 2>/dev/null || break
|
||||
done_paths+=("${src#"$root"/}")
|
||||
i=$((i + 1))
|
||||
done
|
||||
printf '%s\0' "${done_paths[@]}" | jq -cMRs --arg root "$root" '{root: $root, paths: (split("\u0000") | map(select(length > 0)))}' >"$trash/$id/manifest.json"
|
||||
[ "${#done_paths[@]}" -eq "$#" ] || dd_die "deleted ${#done_paths[@]} of $#; the rest is still there (trash $id)"
|
||||
jq -cMn --arg id "$id" --slurpfile m "$trash/$id/manifest.json" '{ok: true, trash: $id, paths: $m[0].paths}'
|
||||
}
|
||||
|
||||
# --fs-restore SESSION ID: puts a deletion back where it was (if nothing has
|
||||
# taken those paths since); prints {ok, paths}.
|
||||
cmd_fs_restore() {
|
||||
[ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-restore SESSION ID"
|
||||
local session=$1 id=$2 root dir rel i=0 restored=() dest
|
||||
[[ $id =~ ^[0-9]{8}-[0-9]{6}-[0-9]+$ ]] || dd_die "bad trash id: $id"
|
||||
root=$(dd_session_root "$session") || exit 1
|
||||
dir="$(dd_trash_dir "$session")/$id"
|
||||
[ -f "$dir/manifest.json" ] || dd_die "nothing to restore (emptied after $DEVDESK_TRASH_DAYS days?)"
|
||||
[ "$(jq -r .root "$dir/manifest.json")" = "$root" ] || dd_die "that deletion was in another folder"
|
||||
while IFS= read -r -d '' rel; do
|
||||
if [ -e "$dir/$i" ] || [ -L "$dir/$i" ]; then
|
||||
dest=$(dd_fs_dest "$root" "$rel") || exit 1
|
||||
mv -T -- "$dir/$i" "$dest" || dd_die "cannot restore $rel"
|
||||
restored+=("$rel")
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done < <(jq -j '.paths[] | ., "\u0000"' "$dir/manifest.json")
|
||||
rm -rf -- "$dir"
|
||||
printf '%s\0' "${restored[@]}" | jq -cMRs '{ok: true, paths: (split("\u0000") | map(select(length > 0)))}'
|
||||
}
|
||||
|
||||
# --fs-put SESSION REL BYTES new|replace: upload any file (up to
|
||||
# DEVDESK_UPLOAD_MAX) to REL, creating missing folders. "new" refuses to replace
|
||||
# anything; "replace" overwrites a regular file. The content arrives as base64
|
||||
# after the READY line, into a temp file next to the target, moved into place.
|
||||
cmd_fs_put() {
|
||||
[ $# -eq 4 ] || dd_die "usage: devdesk attach --fs-put SESSION PATH BYTES new|replace"
|
||||
local root rel=$2 size=$3 mode=$4 dir target tmp b64len actual
|
||||
root=$(dd_session_root "$1") || exit 1
|
||||
{ [[ $size =~ ^[0-9]+$ ]] && [ "$size" -le "$DEVDESK_UPLOAD_MAX" ]; } ||
|
||||
dd_die "larger than $((DEVDESK_UPLOAD_MAX / 1048576)) MB"
|
||||
case $mode in new | replace) ;; *) dd_die "mode is new or replace" ;; esac
|
||||
dd_fs_split "$rel"
|
||||
dir=$(dd_fs_mkdirs "$root" "$DD_PARENT") || exit 1
|
||||
dd_fs_inside "$root" "$dir"
|
||||
target="$dir/$DD_NAME"
|
||||
if [ -e "$target" ] || [ -L "$target" ]; then
|
||||
[ "$mode" = replace ] || dd_die "already exists: $rel"
|
||||
{ [ -f "$target" ] && [ ! -L "$target" ]; } || dd_die "not a regular file: $rel"
|
||||
fi
|
||||
tmp=$(mktemp --tmpdir="$dir" ".${DD_NAME:0:100}.devdesk-XXXXXX") || dd_die "cannot write in ${dir#"$root"/}"
|
||||
trap 'rm -f "$tmp"' HUP INT TERM
|
||||
# shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose
|
||||
b64len=$(((size + 2) / 3 * 4))
|
||||
stty raw -echo 2>/dev/null || true
|
||||
printf 'DEVDESK-PUT-READY\r\n'
|
||||
if [ "$b64len" -gt 0 ]; then head -c "$b64len" | base64 -d >"$tmp" 2>/dev/null || true; fi
|
||||
stty sane 2>/dev/null || true
|
||||
actual=$(stat -c %s "$tmp" 2>/dev/null || echo 0)
|
||||
if [ "$actual" != "$size" ]; then
|
||||
rm -f "$tmp"
|
||||
dd_die "upload incomplete ($actual of $size bytes)"
|
||||
fi
|
||||
if [ -f "$target" ]; then
|
||||
chmod --reference="$target" "$tmp" 2>/dev/null || true
|
||||
chown --reference="$target" "$tmp" 2>/dev/null || true
|
||||
mv -f -- "$tmp" "$target" || { rm -f "$tmp"; dd_die "cannot replace $rel"; }
|
||||
else
|
||||
chmod 0644 "$tmp"
|
||||
# -n: something created meanwhile is not replaced.
|
||||
mv -n -T -- "$tmp" "$target" 2>/dev/null || true
|
||||
if [ -e "$tmp" ]; then
|
||||
rm -f "$tmp"
|
||||
dd_die "already exists: $rel"
|
||||
fi
|
||||
fi
|
||||
trap - HUP INT TERM
|
||||
jq -cMn --arg p "${target#"$root"/}" --argjson s "$size" '{ok: true, path: $p, size: $s}'
|
||||
}
|
||||
|
||||
# --fs-download SESSION REL: any regular file up to DEVDESK_UPLOAD_MAX, as a blob
|
||||
# with its body whatever the type (the browser saves it).
|
||||
cmd_fs_download() {
|
||||
[ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-download SESSION PATH"
|
||||
local root f size
|
||||
root=$(dd_session_root "$1") || exit 1
|
||||
f=$(dd_fs_path "$root" "$2") || exit 1
|
||||
[ -f "$f" ] || dd_die "not a file: $2"
|
||||
size=$(stat -c %s -- "$f")
|
||||
[ "$size" -le "$DEVDESK_UPLOAD_MAX" ] || dd_die "larger than $((DEVDESK_UPLOAD_MAX / 1048576)) MB"
|
||||
jq -cMn --arg p "${f#"$root"/}" --argjson s "$size" '{path: $p, size: $s, body: true, download: true}'
|
||||
printf '%s\n' "$DD_BLOB_BEGIN"
|
||||
base64 -w0 -- "$f"
|
||||
printf '\n%s\n' "$DD_BLOB_END"
|
||||
}
|
||||
|
||||
# --fs-grep SESSION FLAGS QUERY (devdesk#56): search the session's files. FLAGS
|
||||
# is "-" or letters: i (ignore case), r (regular expression, else literal),
|
||||
# w (whole word). git grep in a repository (tracked + untracked, .gitignore
|
||||
# honoured), else grep without node_modules/.git/build folders. Binary files are
|
||||
# skipped; at most DEVDESK_GREP_MAX (2000) lines, each cut to 400 characters.
|
||||
cmd_fs_grep() {
|
||||
[ $# -eq 3 ] || dd_die "usage: devdesk attach --fs-grep SESSION FLAGS QUERY"
|
||||
local root flags=$2 query=$3 max=${DEVDESK_GREP_MAX:-2000} opts=()
|
||||
root=$(dd_session_root "$1") || exit 1
|
||||
[[ $flags =~ ^(-|[irw]+)$ ]] || dd_die "flags are i, r and w"
|
||||
{ [ -n "$query" ] && [ "${#query}" -le 500 ]; } || dd_die "search for 1 to 500 characters"
|
||||
[[ $flags == *i* ]] && opts+=(-i)
|
||||
[[ $flags == *w* ]] && opts+=(-w)
|
||||
if [[ $flags == *r* ]]; then opts+=(-E); else opts+=(-F); fi
|
||||
{
|
||||
if git --no-optional-locks -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
(cd "$root" && timeout 20 git --no-optional-locks grep -n -I -z --untracked --exclude-standard "${opts[@]}" -e "$query" -- . 2>/dev/null) || true
|
||||
else
|
||||
(cd "$root" && timeout 20 grep -r -n -I -Z "${opts[@]}" \
|
||||
--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=__pycache__ --exclude-dir=.venv --exclude-dir=venv \
|
||||
--exclude-dir=dist --exclude-dir=build --exclude-dir=target --exclude-dir=.cache --exclude-dir=.next \
|
||||
--exclude-dir=coverage --exclude-dir=.worktrees -e "$query" . 2>/dev/null) || true
|
||||
fi
|
||||
} | head -n "$((max + 1))" |
|
||||
jq -cMRs --arg root "$root" --argjson max "$max" '
|
||||
# git grep -z: path NUL line NUL text; grep -Z: path NUL line:text
|
||||
[split("\n")[] | select(length > 0) | split("\u0000") | select(length >= 2)
|
||||
| if length >= 3 then {p: .[0], l: (.[1] | tonumber? // 0), t: (.[2:] | join(" "))}
|
||||
else (.[1] | index(":")) as $i | select($i != null)
|
||||
| {p: .[0], l: (.[1][:$i] | tonumber? // 0), t: .[1][($i + 1):]} end
|
||||
| {p: (.p | ltrimstr("./")), l, t: (.t | .[:400])}] as $m
|
||||
| {root: $root, truncated: ($m | length > $max), matches: $m[:$max]}'
|
||||
}
|
||||
@@ -111,3 +111,24 @@ cmd_reply() {
|
||||
rm -f "$tmp"
|
||||
trap - HUP INT TERM
|
||||
}
|
||||
|
||||
# --last-reply SESSION (devdesk#47): Claude's whole answer to your last message,
|
||||
# every text part of it (between tool calls), as the Markdown Claude wrote.
|
||||
cmd_last_reply() {
|
||||
[ $# -eq 1 ] || dd_die "usage: devdesk attach --last-reply SESSION"
|
||||
local session=$1 transcript
|
||||
dd_valid_name "$session" || dd_die "invalid session name: $session"
|
||||
transcript=$(dd_transcript "$session") || transcript=""
|
||||
{ [ -n "$transcript" ] && [ -r "$transcript" ]; } || dd_die "no conversation recorded for '$session' yet"
|
||||
tail -n 3000 -- "$transcript" | jq -cMs --arg s "$session" '
|
||||
def text: if type == "string" then . else ([.[]? | select(.type == "text") | .text] | join("\n")) end;
|
||||
[.[] | select(.isSidechain != true and .isMeta != true)] as $all
|
||||
# Your last message: a user entry with text of its own, not a tool result.
|
||||
| ([range(0; $all | length) | select($all[.] as $e | $e.type == "user"
|
||||
and ($e.message.content | type == "string" or any(.[]?; .type == "text"))
|
||||
and ($e.message.content | text | startswith("<") | not))] | last // -1) as $u
|
||||
| [$all[($u + 1):][] | select(.type == "assistant") | .message.content | text | select(length > 0)] as $parts
|
||||
| if ($parts | length) == 0 then {session: $s, text: null}
|
||||
else {session: $s, text: ($parts | join("\n\n")), at: ([$all[] | select(.type == "assistant")] | last | .timestamp)} end' 2>/dev/null ||
|
||||
dd_die "could not read the conversation of '$session'"
|
||||
}
|
||||
|
||||
@@ -58,7 +58,9 @@ dd_load_env
|
||||
: "${DEVDESK_UPLOAD_MAX:=26214400}"
|
||||
: "${DEVDESK_PUBLIC_URL:=}"
|
||||
: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}"
|
||||
: "${DEVDESK_MATRIX_DELAY:=30}"
|
||||
: "${DEVDESK_MATRIX_DELAY:=60}"
|
||||
: "${DEVDESK_MATRIX_QUIET:=600}"
|
||||
: "${DEVDESK_USAGE_WARN:=90}"
|
||||
: "${DEVDESK_PRESENCE_DIR:=/run/devdesk/presence}"
|
||||
|
||||
DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
|
||||
|
||||
@@ -1,15 +1,48 @@
|
||||
#!/bin/bash
|
||||
# devdesk-notify-matrix SESSION SINCE
|
||||
# devdesk-notify-matrix --usage PCT RESETS_AT
|
||||
#
|
||||
# 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).
|
||||
# After DEVDESK_MATRIX_DELAY seconds, if that same wait is still on, no DevDesk
|
||||
# tab is in view, and this session hasn't posted in DEVDESK_MATRIX_QUIET seconds,
|
||||
# post what Claude is asking to the hookshot webhook in DEVDESK_MATRIX_WEBHOOK_FILE
|
||||
# (devdesk#28, #50).
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck source=libexec/devdesk-lib.sh
|
||||
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
|
||||
|
||||
# Post BODY (JSON with a .text) to the hookshot webhook. The URL is a secret
|
||||
# (anyone holding it can post to the room): it goes to curl on stdin, never on
|
||||
# the command line.
|
||||
post() {
|
||||
local webhook
|
||||
# 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 "$1" >/dev/null
|
||||
}
|
||||
|
||||
# devdesk-notify-matrix --usage PCT RESETS_AT (devdesk#45): started by the status
|
||||
# line when the 5-hour plan window passes DEVDESK_USAGE_WARN percent. Once per
|
||||
# window, and only when no DevDesk tab has been in view for ~2 minutes (presence
|
||||
# markers; you're away. A tab in view shows its own warning).
|
||||
if [ "${1:-}" = --usage ]; then
|
||||
pct=${2:-} resets=${3:-}
|
||||
[[ $pct =~ ^[0-9]+$ && $resets =~ ^[0-9]+$ ]] || exit 0
|
||||
marker="$DEVDESK_STATE_DIR/usage-warned"
|
||||
exec 9>>"$marker.lock"
|
||||
flock -n 9 || exit 0
|
||||
[ "$(cat "$marker" 2>/dev/null)" != "$resets" ] || exit 0
|
||||
printf '%s\n' "$resets" >"$marker"
|
||||
[ -r "$DEVDESK_MATRIX_WEBHOOK_FILE" ] || exit 0
|
||||
[ -z "$(find "$DEVDESK_PRESENCE_DIR" -type f 2>/dev/null | head -n 1)" ] || exit 0
|
||||
at=$(date -d "@$resets" '+%H:%M %Z')
|
||||
post "$(jq -cMn --arg p "$pct" --arg at "$at" --arg h "$(hostname)" \
|
||||
'{kind: "usage", host: $h, text: "Claude plan usage is at \($p)% of the 5-hour window; it resets at \($at)."}')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
session=${1:-}
|
||||
since=${2:-}
|
||||
dd_valid_name "$session" || exit 0
|
||||
@@ -21,25 +54,29 @@ 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
|
||||
# You're at DevDesk if any tab has shown a session in the last ~2 minutes
|
||||
# (presence markers, devdesk#26): the browser tells you itself (devdesk#50).
|
||||
[ -z "$(find "$DEVDESK_PRESENCE_DIR" -type f 2>/dev/null | head -n 1)" ] || exit 0
|
||||
# The idle reminder isn't a request (the state hook drops it; old states may have it).
|
||||
message=$(jq -rM '.message // ""' "$state_file" 2>/dev/null)
|
||||
request=$(jq -cM '.request // null' "$state_file" 2>/dev/null) || request=null
|
||||
if [ "$request" = null ] && { [ -z "$message" ] || [[ $message == *"waiting for your input"* ]]; }; then exit 0; fi
|
||||
# At most one message per session per DEVDESK_MATRIX_QUIET seconds.
|
||||
sent="$DEVDESK_STATE_DIR/matrix-sent/$session"
|
||||
mkdir -p "${sent%/*}"
|
||||
if [ -n "$(find "$sent" -newermt "-$DEVDESK_MATRIX_QUIET seconds" 2>/dev/null)" ]; then exit 0; fi
|
||||
touch "$sent"
|
||||
|
||||
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
|
||||
detail=$(jq -rM '.request.detail // empty' "$state_file" 2>/dev/null)
|
||||
[ -z "$detail" ] || message="$message: ${detail:0:300}"
|
||||
url=""
|
||||
# existing=1: if the session was ended by the time you tap, say so instead of
|
||||
# starting a new Claude there (devdesk#38).
|
||||
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session&existing=1"
|
||||
body=$(jq -cMn --arg s "$session" --arg m "$message" --arg u "$url" --arg h "$(hostname)" \
|
||||
'{session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h,
|
||||
text: ("Claude needs you in " + $s + ": " + $m + (if $u == "" then "" else " — " + $u end))}')
|
||||
# What exactly it wants: "alpha: Claude wants to run Bash (Remove x)" and the command.
|
||||
body=$(jq -cMn --arg s "$session" --arg m "$message" --argjson r "$request" --arg u "$url" --arg h "$(hostname)" '
|
||||
(if $r then "\($s): Claude wants to run \($r.tool)\(if $r.description then " (\($r.description))" else "" end)"
|
||||
else "\($s): \($m)" end) as $head
|
||||
| ($r.detail // null | if . then .[0:300] else null end) as $d
|
||||
| {session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h,
|
||||
text: ($head + (if $d then "\n" + $d else "" end) + (if $u == "" then "" else "\nAnswer: " + $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
|
||||
post "$body"
|
||||
|
||||
@@ -21,8 +21,17 @@ session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 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)
|
||||
IFS=$'\x1f' read -r event message tool file ntype < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\u001f\n]"; " ")),
|
||||
.tool_name // "", (.tool_input.file_path // .tool_input.notebook_path // "" | gsub("[\u001f\n]"; " ")),
|
||||
.notification_type // ""] | join("\u001f")' <<<"$input" 2>/dev/null)
|
||||
|
||||
# Claude's idle reminder ("Claude is waiting for your input", about a minute
|
||||
# after a turn ends) is not a request: the turn is simply done. It must not
|
||||
# turn the session red or reach Matrix (devdesk#50).
|
||||
if [ "$event" = Notification ] &&
|
||||
{ [ "$ntype" = idle_prompt ] || { [ -z "$ntype" ] && [[ $message == *"waiting for your input"* ]]; }; }; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Files Claude edits, for the file viewer's "Claude" badge (devdesk#19).
|
||||
case $event:$tool in
|
||||
|
||||
@@ -43,6 +43,17 @@ if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Plan usage past DEVDESK_USAGE_WARN: ping Matrix if you're away (devdesk#45).
|
||||
# The marker holds the window already warned about, so this stays one read.
|
||||
if [[ $session != "" ]] && read -r pct resets < <(jq -rM '.rate_limits.five_hour
|
||||
| select(.used_percentage != null and .resets_at != null)
|
||||
| "\(.used_percentage | floor) \(.resets_at)"' <<<"$input" 2>/dev/null) &&
|
||||
[ "$pct" -ge "${DEVDESK_USAGE_WARN:-90}" ] &&
|
||||
[ "$(cat "${DEVDESK_STATE_DIR:-/var/lib/devdesk}/usage-warned" 2>/dev/null)" != "$resets" ]; then
|
||||
setsid -f "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-notify-matrix" --usage "$pct" "$resets" \
|
||||
</dev/null >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Footer line: session · model · context % and tokens · plan use. ANSI colour numbers, not
|
||||
# RGB, so the browser's dark or light palette applies (devdesk#31). Claude sends
|
||||
# rate_limits only to Pro/Max subscribers; there .cost is an API-price estimate,
|
||||
|
||||
Generated
+1
@@ -7,6 +7,7 @@
|
||||
"name": "devdesk",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-markdown": "6.5.2",
|
||||
"@codemirror/language": "6.13.1",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-markdown": "6.5.2",
|
||||
"@codemirror/language": "6.13.1",
|
||||
|
||||
+7
-1
@@ -8,7 +8,13 @@ set -s set-clipboard on # tmux copy-mode -> OSC 52 -> browser cli
|
||||
set -s default-terminal "tmux-256color"
|
||||
set -s terminal-features[90] "xterm-256color:RGB" # fixed index: re-sourcing must not append
|
||||
set -g history-limit 50000
|
||||
set -g mouse on # wheel scrolls history; Shift+drag selects natively
|
||||
set -g mouse on # wheel scrolls history; a drag selects in the browser (devdesk#51)
|
||||
# No right-click menus (devdesk#52): one stray click on them splits a pane or
|
||||
# kills a window (Claude's). Splits and kills stay on the keyboard (^b % ^b x).
|
||||
unbind -n MouseDown3Pane
|
||||
unbind -n M-MouseDown3Pane
|
||||
unbind -n MouseDown3Status
|
||||
unbind -n MouseDown3StatusLeft
|
||||
set -g allow-passthrough off # output may not bypass tmux (title/clipboard spoofing)
|
||||
set -g renumber-windows on
|
||||
|
||||
|
||||
+280
-10
@@ -14,6 +14,7 @@ setup() {
|
||||
export DEVDESK_SHARE="$REPO/share"
|
||||
export DEVDESK_NO_SYSTEMD=1
|
||||
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room
|
||||
export DEVDESK_PRESENCE_DIR="$TMP/presence" # never the live tabs
|
||||
export FAKE_LOG="$TMP/claude-args"
|
||||
export CLAUDE_BIN="$TMP/fake-claude"
|
||||
export CLAUDE_CONFIG_DIR="$TMP/claude" # never the real conversation history
|
||||
@@ -30,6 +31,7 @@ EOF
|
||||
}
|
||||
|
||||
teardown() {
|
||||
[ -z "${RECEIVER:-}" ] || kill "$RECEIVER" 2>/dev/null || true
|
||||
tmux -S "$DEVDESK_SOCKET" kill-server 2>/dev/null || true
|
||||
rm -rf "$TMP"
|
||||
}
|
||||
@@ -310,21 +312,28 @@ PY
|
||||
|
||||
posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
|
||||
|
||||
@test "a wait nobody is attached to is posted to Matrix once (#28)" {
|
||||
@test "a real wait is posted to Matrix once, saying what Claude asks (#28, #50)" {
|
||||
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"; }
|
||||
st="$DEVDESK_STATE_DIR/state/alpha.json"
|
||||
|
||||
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | hook
|
||||
# a second notification in the same wait: no second message
|
||||
# Claude's idle reminder after a turn is not a request: no red, no post.
|
||||
echo '{"hook_event_name":"Stop"}' | hook
|
||||
echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input","notification_type":"idle_prompt"}' | hook
|
||||
echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input"}' | hook
|
||||
[ "$(jq -r .state "$st")" = idle ]
|
||||
|
||||
echo '{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"systemctl restart nginx","description":"Restart nginx"}}' | hook
|
||||
# a second notification in the same wait: no second message
|
||||
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash","notification_type":"permission_prompt"}' | 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&existing=1\ttrue')" ]
|
||||
run jq -r '.text' "$TMP/posts"
|
||||
[ "$output" = "$(printf 'alpha: Claude wants to run Bash (Restart nginx)\nsystemctl restart nginx\nAnswer: https://dev.example.org/?arg=alpha&existing=1')" ]
|
||||
|
||||
# answered within the delay: nothing is posted
|
||||
echo '{"hook_event_name":"PostToolUse"}' | hook
|
||||
@@ -333,12 +342,18 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
|
||||
sleep 2
|
||||
[ "$(posts)" -eq 1 ]
|
||||
|
||||
# no webhook file: feature off
|
||||
rm "$TMP/webhook"
|
||||
echo '{"hook_event_name":"Notification","message":"again"}' | hook
|
||||
sleep 2
|
||||
# another real wait soon after: quiet period, still one message
|
||||
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Edit"}' | hook
|
||||
sleep 2.5
|
||||
[ "$(posts)" -eq 1 ]
|
||||
echo '{"hook_event_name":"PostToolUse"}' | hook
|
||||
|
||||
# past the quiet period, but a DevDesk tab is in view: nothing
|
||||
rm -rf "$DEVDESK_STATE_DIR/matrix-sent"
|
||||
mkdir -p "$DEVDESK_PRESENCE_DIR" && touch "$DEVDESK_PRESENCE_DIR/1"
|
||||
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Edit"}' | hook
|
||||
sleep 2.5
|
||||
[ "$(posts)" -eq 1 ]
|
||||
kill "$RECEIVER"
|
||||
}
|
||||
|
||||
@test "under ttyd, one-shot replies wait for the browser to hang up (#35)" {
|
||||
@@ -1068,3 +1083,258 @@ fake_grafana() {
|
||||
run devdesk attach --investigate 'bad name' u-ok
|
||||
[[ $output == *"invalid session name"* ]]
|
||||
}
|
||||
|
||||
@test "last-reply returns every text part of the answer to your last message (#47)" {
|
||||
start_server
|
||||
devdesk new alpha
|
||||
export CLAUDE_CONFIG_DIR="$TMP/claude"
|
||||
id=11111111-2222-3333-4444-555555555555
|
||||
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-x"
|
||||
cat >"$CLAUDE_CONFIG_DIR/projects/-x/$id.jsonl" <<'JSONL'
|
||||
{"type":"user","message":{"role":"user","content":"First question"},"timestamp":"2026-10-10T09:00:00Z"}
|
||||
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Old answer"}]},"timestamp":"2026-10-10T09:00:01Z"}
|
||||
{"type":"user","message":{"role":"user","content":"Please upgrade PBS"},"timestamp":"2026-10-10T10:00:00Z"}
|
||||
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Checking the version."}]},"timestamp":"2026-10-10T10:00:04Z"}
|
||||
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{}}]},"timestamp":"2026-10-10T10:00:05Z"}
|
||||
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]},"timestamp":"2026-10-10T10:00:06Z"}
|
||||
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"## Done\n\nPBS is on **4.1**."}]},"timestamp":"2026-10-10T10:00:09Z"}
|
||||
JSONL
|
||||
run devdesk attach --last-reply alpha
|
||||
[ "$status" -ne 0 ] # no conversation recorded yet
|
||||
jq --arg id "$id" '.sessions.alpha.claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
|
||||
run devdesk attach --last-reply alpha
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(jq -r .text <<<"$output")" = "$(printf 'Checking the version.\n\n## Done\n\nPBS is on **4.1**.')" ]
|
||||
[ "$(jq -r .at <<<"$output")" = 2026-10-10T10:00:09Z ]
|
||||
}
|
||||
|
||||
@test "worktree sessions get their own checkout; ending removes it only when clean (#44)" {
|
||||
start_server
|
||||
repo="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||
git -C "$repo" init -q -b main
|
||||
git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init
|
||||
mkdir -p "$DEVDESK_PROJECTS_ROOT/plain"
|
||||
run devdesk new solo --worktree
|
||||
[ "$status" -ne 0 ]
|
||||
run devdesk new p1 plain --worktree
|
||||
[ "$status" -ne 0 ]
|
||||
[[ $output == *"not a git repository"* ]]
|
||||
|
||||
devdesk new wt1 alpha --worktree
|
||||
wt="$DEVDESK_PROJECTS_ROOT/.worktrees/alpha--wt1"
|
||||
[ "$(git -C "$wt" symbolic-ref --short HEAD)" = devdesk/wt1 ]
|
||||
run tmux -S "$DEVDESK_SOCKET" display -p -t =wt1:claude '#{pane_current_path}'
|
||||
[ "$output" = "$wt" ]
|
||||
run devdesk attach --list
|
||||
[ "$(jq -r '.sessions[] | select(.name=="wt1") | .worktree.branch' <<<"$output")" = devdesk/wt1 ]
|
||||
[ "$(jq -r '.projects[] | select(.name=="alpha") | .git' <<<"$output")" = true ]
|
||||
[ "$(jq -r '.projects[] | select(.name=="plain") | .git' <<<"$output")" = false ]
|
||||
[ "$(jq -r '[.projects[].name] | index(".worktrees")' <<<"$output")" = null ]
|
||||
# clean and merged: worktree and branch go
|
||||
devdesk end wt1
|
||||
[ ! -e "$wt" ]
|
||||
run git -C "$repo" show-ref --verify refs/heads/devdesk/wt1
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# uncommitted work: everything stays
|
||||
devdesk new wt2 alpha --worktree
|
||||
wt="$DEVDESK_PROJECTS_ROOT/.worktrees/alpha--wt2"
|
||||
echo work >"$wt/file"
|
||||
devdesk end wt2
|
||||
[ -f "$wt/file" ]
|
||||
git -C "$repo" show-ref -q --verify refs/heads/devdesk/wt2
|
||||
}
|
||||
|
||||
@test "repo lists the first changed files for the panel (#46)" {
|
||||
export DEVDESK_GITEA_URL=http://127.0.0.1:9
|
||||
repo="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||
git -C "$repo" init -q -b main
|
||||
printf 'a\n' >"$repo/tracked"
|
||||
git -C "$repo" add tracked
|
||||
git -C "$repo" -c user.name=t -c user.email=t@t commit -q -m init
|
||||
printf 'a\nb\n' >"$repo/tracked"
|
||||
touch "$repo/new-file"
|
||||
run devdesk attach --repo alpha
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(jq -c '[.changes[] | [.path, .x, .y, .add]]' <<<"$output")" = '[["tracked"," ","M",1],["new-file","?","?",null]]' ]
|
||||
}
|
||||
|
||||
@test "plan usage past the threshold pings Matrix once per window, only when away (#45)" {
|
||||
start_server
|
||||
start_receiver
|
||||
devdesk new alpha
|
||||
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
|
||||
sl() { echo "{\"model\":{\"display_name\":\"Opus\"},\"context_window\":{},\"rate_limits\":{\"five_hour\":{\"used_percentage\":$1,\"resets_at\":$2}}}" |
|
||||
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-statusline" >/dev/null; }
|
||||
sl 50 2000000000
|
||||
sl 91 2000000000
|
||||
sl 93 2000000000
|
||||
for _ in $(seq 1 30); do [ "$(posts)" -ge 1 ] && break; sleep 0.1; done
|
||||
sleep 0.5
|
||||
[ "$(posts)" -eq 1 ]
|
||||
[[ "$(jq -r .text "$TMP/posts")" == *"91% of the 5-hour window"* ]]
|
||||
# next window, but a DevDesk tab is in view: no post, and that window is done
|
||||
mkdir -p "$DEVDESK_PRESENCE_DIR" && touch "$DEVDESK_PRESENCE_DIR/1"
|
||||
sl 95 2000018000
|
||||
for _ in $(seq 1 30); do [ "$(cat "$DEVDESK_STATE_DIR/usage-warned")" = 2000018000 ] && break; sleep 0.1; done
|
||||
sleep 0.5
|
||||
[ "$(posts)" -eq 1 ]
|
||||
[ "$(cat "$DEVDESK_STATE_DIR/usage-warned")" = 2000018000 ]
|
||||
}
|
||||
|
||||
@test "unsplit closes extra panes and keeps Claude's (#52)" {
|
||||
start_server
|
||||
devdesk new alpha
|
||||
tmux -S "$DEVDESK_SOCKET" split-window -h -t =alpha:claude
|
||||
tmux -S "$DEVDESK_SOCKET" split-window -v -t =alpha:shell
|
||||
run devdesk attach --unsplit alpha
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(jq -r .closed <<<"$output")" = 2 ]
|
||||
[ "$(tmux -S "$DEVDESK_SOCKET" list-panes -t =alpha:claude -F '#{pane_start_command}')" = "$REPO/libexec/devdesk-claude-run" ]
|
||||
[ "$(tmux -S "$DEVDESK_SOCKET" list-panes -t =alpha:shell | wc -l)" = 1 ]
|
||||
run devdesk attach --unsplit alpha
|
||||
[ "$(jq -r .closed <<<"$output")" = 0 ]
|
||||
}
|
||||
|
||||
fs_put() { # SESSION PATH MODE CONTENT-FILE
|
||||
local size
|
||||
size=$(stat -c %s "$4")
|
||||
base64 -w0 "$4" | devdesk attach --fs-put "$1" "$2" "$size" "$3"
|
||||
}
|
||||
last_json() { tail -n1 <<<"$output"; }
|
||||
|
||||
@test "fs-mkdir, fs-move and fs-copy stay inside the folder and never overwrite (#54)" {
|
||||
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||
mkdir -p "$p/src" && printf 'a\n' >"$p/src/a.txt" && printf 'b\n' >"$p/b.txt"
|
||||
ln -s "$TMP/outside" "$p/out"
|
||||
ln -s b.txt "$p/link"
|
||||
run devdesk attach --fs-mkdir alpha docs/guides
|
||||
[ "$status" -eq 0 ] && [ -d "$p/docs/guides" ]
|
||||
[ "$(last_json | jq -r .path)" = docs/guides ]
|
||||
run devdesk attach --fs-mkdir alpha docs
|
||||
[[ $output == *"already exists"* ]]
|
||||
for bad in ../x /abs out/x 'a/../b' . '' docs/; do
|
||||
run devdesk attach --fs-mkdir alpha "$bad"
|
||||
[ "$status" -ne 0 ]
|
||||
done
|
||||
[ -z "$(ls -A "$TMP/outside")" ]
|
||||
# out/x once escaped to /x: set -e doesn't reach into $(...) (#54)
|
||||
[ ! -e /x ] && [ ! -e /abs ]
|
||||
|
||||
# rename a file, move a folder into another, never onto something
|
||||
run devdesk attach --fs-move alpha src/a.txt src/renamed.txt
|
||||
[ "$status" -eq 0 ] && [ -f "$p/src/renamed.txt" ] && [ ! -e "$p/src/a.txt" ]
|
||||
run devdesk attach --fs-move alpha b.txt src/renamed.txt
|
||||
[ "$status" -ne 0 ] && [[ $output == *"already exists"* ]]
|
||||
[ "$(cat "$p/b.txt")" = b ]
|
||||
run devdesk attach --fs-move alpha src docs/src
|
||||
[ "$status" -eq 0 ] && [ -f "$p/docs/src/renamed.txt" ]
|
||||
run devdesk attach --fs-move alpha docs docs/guides/docs
|
||||
[ "$status" -ne 0 ] && [[ $output == *"inside itself"* ]]
|
||||
run devdesk attach --fs-move alpha b.txt out/b.txt
|
||||
[ "$status" -ne 0 ]
|
||||
run devdesk attach --fs-move alpha ../outside x
|
||||
[ "$status" -ne 0 ]
|
||||
# a symlink moves as a link
|
||||
run devdesk attach --fs-move alpha link link2
|
||||
[ "$status" -eq 0 ] && [ -L "$p/link2" ] && [ -f "$p/b.txt" ]
|
||||
# copy: folders recursively, missing parents created
|
||||
run devdesk attach --fs-copy alpha docs/src new/place/src
|
||||
[ "$status" -eq 0 ] && [ -f "$p/new/place/src/renamed.txt" ] && [ -f "$p/docs/src/renamed.txt" ]
|
||||
run devdesk attach --fs-copy alpha b.txt docs/src/renamed.txt
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "fs-delete moves to the trash and fs-restore puts it back (#54)" {
|
||||
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||
mkdir -p "$p/dir/sub" && printf 'x\n' >"$p/dir/sub/x" && printf 'y\n' >"$p/y.txt"
|
||||
ln -s "$TMP/outside" "$p/out"
|
||||
touch "$TMP/outside/keep"
|
||||
run devdesk attach --fs-delete alpha dir y.txt out
|
||||
[ "$status" -eq 0 ]
|
||||
id=$(last_json | jq -r .trash)
|
||||
[ "$(last_json | jq -c .paths)" = '["dir","y.txt","out"]' ]
|
||||
[ ! -e "$p/dir" ] && [ ! -e "$p/y.txt" ] && [ ! -L "$p/out" ] && [ -f "$TMP/outside/keep" ]
|
||||
# nothing deleted when one path is bad
|
||||
printf 'z\n' >"$p/z.txt"
|
||||
run devdesk attach --fs-delete alpha z.txt ../outside
|
||||
[ "$status" -ne 0 ] && [ -f "$p/z.txt" ]
|
||||
run devdesk attach --fs-delete alpha .
|
||||
[ "$status" -ne 0 ]
|
||||
# restore; a path taken meanwhile is refused
|
||||
printf 'new\n' >"$p/y.txt"
|
||||
run devdesk attach --fs-restore alpha "$id"
|
||||
[ "$status" -ne 0 ] && [[ $output == *"already exists: y.txt"* ]]
|
||||
rm "$p/y.txt"
|
||||
run devdesk attach --fs-restore alpha "$id"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(cat "$p/dir/sub/x" "$p/y.txt")" = "$(printf 'x\ny')" ] && [ -L "$p/out" ]
|
||||
[ ! -e "$DEVDESK_STATE_DIR/trash/alpha/$id" ]
|
||||
run devdesk attach --fs-restore alpha "../../etc"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "fs-put uploads any file into a folder; fs-download returns it (#54)" {
|
||||
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||
head -c 3000 /dev/urandom >"$TMP/bin"
|
||||
run fs_put alpha assets/img/logo.bin new "$TMP/bin"
|
||||
[ "$status" -eq 0 ] && cmp -s "$TMP/bin" "$p/assets/img/logo.bin"
|
||||
[ "$(stat -c %a "$p/assets/img/logo.bin")" = 644 ]
|
||||
run fs_put alpha assets/img/logo.bin new "$TMP/bin"
|
||||
[ "$status" -ne 0 ] && [[ $output == *"already exists"* ]]
|
||||
printf 'v2' >"$TMP/v2"
|
||||
chmod 600 "$p/assets/img/logo.bin"
|
||||
run fs_put alpha assets/img/logo.bin replace "$TMP/v2"
|
||||
[ "$status" -eq 0 ] && [ "$(cat "$p/assets/img/logo.bin")" = v2 ] && [ "$(stat -c %a "$p/assets/img/logo.bin")" = 600 ]
|
||||
: >"$TMP/empty"
|
||||
run fs_put alpha empty.txt new "$TMP/empty"
|
||||
[ "$status" -eq 0 ] && [ -f "$p/empty.txt" ] && [ ! -s "$p/empty.txt" ]
|
||||
ln -s "$TMP/outside" "$p/out"
|
||||
for bad in out/x ../x /x assets; do
|
||||
run fs_put alpha "$bad" new "$TMP/v2"
|
||||
[ "$status" -ne 0 ]
|
||||
done
|
||||
[ -z "$(ls -A "$TMP/outside")" ]
|
||||
run fs_put alpha assets replace "$TMP/v2"
|
||||
[ "$status" -ne 0 ] && [ -d "$p/assets" ]
|
||||
# short stream: nothing left behind
|
||||
run bash -c "printf aGVs | '$REPO/bin/devdesk' attach --fs-put alpha short.txt 6 new"
|
||||
[ "$status" -ne 0 ] && [ ! -e "$p/short.txt" ] && [ -z "$(find "$p" -name '.*devdesk-*')" ]
|
||||
# download: binary body comes back whole
|
||||
run devdesk attach --fs-download alpha assets/img/logo.bin
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(sed -n '/DEVDESK-BLOB-BEGIN/{n;p}' <<<"$output" | base64 -d)" = v2 ]
|
||||
head -c 3000 /dev/urandom >"$p/rand"
|
||||
run devdesk attach --fs-download alpha rand
|
||||
sed -n '/DEVDESK-BLOB-BEGIN/{n;p}' <<<"$output" | base64 -d | cmp -s - "$p/rand"
|
||||
run devdesk attach --fs-download alpha out
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "fs-grep searches files, literally or by regex, git or not (#56)" {
|
||||
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||
mkdir -p "$p/src" "$p/node_modules/x"
|
||||
printf 'const Answer = 42;\nlet b = "a:b";\n' >"$p/src/a.js"
|
||||
printf 'answer here\n' >"$p/node_modules/x/i.js"
|
||||
printf '\x00\x01answer' >"$p/bin.dat"
|
||||
run devdesk attach --fs-grep alpha - 'Answer'
|
||||
[ "$(jq -c '[.matches[] | [.p, .l, .t]]' <<<"$output")" = '[["src/a.js",1,"const Answer = 42;"]]' ]
|
||||
run devdesk attach --fs-grep alpha i 'answer'
|
||||
[ "$(jq -r '[.matches[].p] | join(",")' <<<"$output")" = src/a.js ]
|
||||
run devdesk attach --fs-grep alpha - '"a:b"'
|
||||
[ "$(jq -r '.matches[0].t' <<<"$output")" = 'let b = "a:b";' ]
|
||||
run devdesk attach --fs-grep alpha r 'A[n]swer = [0-9]+'
|
||||
[ "$(jq -r '.matches | length' <<<"$output")" = 1 ]
|
||||
run devdesk attach --fs-grep alpha - '[n]'
|
||||
[ "$(jq -r '.matches | length' <<<"$output")" = 0 ]
|
||||
# in a repository: git grep, .gitignore honoured, untracked files included
|
||||
git -C "$p" init -q
|
||||
printf 'node_modules/\n' >"$p/.gitignore"
|
||||
printf 'Answer again\n' >"$p/untracked.txt"
|
||||
run devdesk attach --fs-grep alpha w 'Answer'
|
||||
[ "$(jq -c '[.matches[] | [.p, .l]] | sort' <<<"$output")" = '[["src/a.js",1],["untracked.txt",1]]' ]
|
||||
run devdesk attach --fs-grep alpha x 'Answer'
|
||||
[ "$status" -ne 0 ]
|
||||
run devdesk attach --fs-grep alpha - ''
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
+155
-14
@@ -2,11 +2,16 @@
|
||||
// languages, a unified diff view, and markdown rendering. Built separately to
|
||||
// dist/assets/editor.js (window.DevDeskEditor) and loaded over the ttyd channel
|
||||
// the first time the Files pane opens, so the page itself stays small.
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars } from '@codemirror/view';
|
||||
import {
|
||||
EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars,
|
||||
rectangularSelection, crosshairCursor, dropCursor,
|
||||
} from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
|
||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap, indentOnInput } from '@codemirror/language';
|
||||
import { search, searchKeymap, highlightSelectionMatches, gotoLine as gotoLineDialog } from '@codemirror/search';
|
||||
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||
import { highlightTree, classHighlighter } from '@lezer/highlight';
|
||||
import { markdownLanguage } from '@codemirror/lang-markdown';
|
||||
import { unifiedMergeView } from '@codemirror/merge';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
@@ -27,7 +32,7 @@ import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { lua } from '@codemirror/legacy-modes/mode/lua';
|
||||
import { perl } from '@codemirror/legacy-modes/mode/perl';
|
||||
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
|
||||
import { marked } from 'marked';
|
||||
import { Marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
const MODES = {
|
||||
@@ -121,20 +126,58 @@ const theme = EditorView.theme({
|
||||
'.cm-deletedLineGutter': { backgroundColor: 'var(--accent-red)' },
|
||||
});
|
||||
|
||||
function baseExtensions(path, { readOnly = true, wrap = false } = {}) {
|
||||
function baseExtensions(path, { readOnly = true, wrap = false, onCursor } = {}) {
|
||||
return [
|
||||
lineNumbers(), foldGutter(), highlightSpecialChars(), highlightActiveLine(), highlightActiveLineGutter(),
|
||||
drawSelection(), history(), search({ top: true }), highlightSelectionMatches(), bracketMatching(),
|
||||
drawSelection(), dropCursor(), history(), search({ top: true }), highlightSelectionMatches(), bracketMatching(),
|
||||
// VS Code habits (devdesk#55): Alt+click adds a cursor, Alt+drag selects a
|
||||
// column, brackets and quotes close themselves, Ctrl+G goes to a line.
|
||||
EditorState.allowMultipleSelections.of(true), rectangularSelection(), crosshairCursor(),
|
||||
readOnly ? [] : [closeBrackets(), indentOnInput()],
|
||||
syntaxHighlighting(highlight), theme, languageExtension(languageFor(path)),
|
||||
onCursor ? EditorView.updateListener.of((update) => {
|
||||
if (update.selectionSet || update.docChanged || update.focusChanged) onCursor(cursorInfo(update.state));
|
||||
}) : [],
|
||||
// Read-only document in an editable view: the caret, selection, arrow keys
|
||||
// and Ctrl+F work, typing doesn't; no on-screen keyboard on phones.
|
||||
EditorState.readOnly.of(readOnly),
|
||||
readOnly ? EditorView.contentAttributes.of({ inputmode: 'none' }) : [],
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap]),
|
||||
keymap.of([
|
||||
{ key: 'Mod-g', run: gotoLineDialog, preventDefault: true },
|
||||
...(readOnly ? [] : closeBracketsKeymap), ...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap,
|
||||
]),
|
||||
wrap ? EditorView.lineWrapping : [],
|
||||
];
|
||||
}
|
||||
|
||||
// Where the cursor is, for the status bar: line, column, selections, selected
|
||||
// characters; plus the document's line endings and indentation.
|
||||
function cursorInfo(state) {
|
||||
const main = state.selection.main;
|
||||
const line = state.doc.lineAt(main.head);
|
||||
let selected = 0;
|
||||
for (const r of state.selection.ranges) selected += r.to - r.from;
|
||||
return { line: line.number, col: main.head - line.from + 1, cursors: state.selection.ranges.length, selected, lines: state.doc.lines };
|
||||
}
|
||||
|
||||
// Indentation a file uses, guessed from its first indented lines: "Tabs" or "Spaces: N".
|
||||
export function detectIndent(text) {
|
||||
let tabs = 0;
|
||||
const widths = {};
|
||||
for (const line of text.split('\n', 2000)) {
|
||||
if (line.startsWith('\t')) tabs += 1;
|
||||
else {
|
||||
const m = /^( +)\S/.exec(line);
|
||||
if (m) widths[m[1].length] = (widths[m[1].length] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
const spaced = Object.values(widths).reduce((a, b) => a + b, 0);
|
||||
if (tabs > spaced) return 'Tabs';
|
||||
if (!spaced) return null;
|
||||
const unit = [2, 4, 8, 3].find((w) => Object.keys(widths).every((k) => Number(k) % w === 0)) ?? 4;
|
||||
return `Spaces: ${unit}`;
|
||||
}
|
||||
|
||||
// Put the cursor on a line (1-based) and scroll it to the middle.
|
||||
function gotoLine(view, line) {
|
||||
const target = view.state.doc.line(Math.max(1, Math.min(line, view.state.doc.lines)));
|
||||
@@ -143,21 +186,22 @@ function gotoLine(view, line) {
|
||||
}
|
||||
|
||||
// Read-only view of a file.
|
||||
export function viewer(parent, { doc, path, wrap }) {
|
||||
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
|
||||
export function viewer(parent, { doc, path, wrap, onCursor }) {
|
||||
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap, onCursor }) }) });
|
||||
return { view, goto: (line) => gotoLine(view, line), destroy: () => view.destroy() };
|
||||
}
|
||||
|
||||
// Editable file (devdesk#20): onChange(text) after every edit, onSave() on
|
||||
// Ctrl/Cmd+S (which never reaches the browser or the terminal).
|
||||
export function editor(parent, { doc, path, wrap, onChange, onSave }) {
|
||||
export function editor(parent, { doc, path, wrap, onChange, onSave, onCursor, onScroll }) {
|
||||
const view = new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [
|
||||
keymap.of([{ key: 'Mod-s', preventDefault: true, run: () => { onSave?.(); return true; } }, indentWithTab]),
|
||||
baseExtensions(path, { readOnly: false, wrap }),
|
||||
baseExtensions(path, { readOnly: false, wrap, onCursor }),
|
||||
onScroll ? EditorView.domEventHandlers({ scroll: (event, v) => { onScroll(v.scrollDOM); } }) : [],
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) onChange?.(update.state.doc.toString());
|
||||
}),
|
||||
@@ -167,6 +211,7 @@ export function editor(parent, { doc, path, wrap, onChange, onSave }) {
|
||||
return {
|
||||
view,
|
||||
goto: (line) => gotoLine(view, line),
|
||||
info: () => cursorInfo(view.state),
|
||||
text: () => view.state.doc.toString(),
|
||||
// Replace the whole document (reload from disk) keeping the cursor roughly where it was.
|
||||
setText: (text) => {
|
||||
@@ -189,7 +234,103 @@ export function diff(parent, { original, modified, path, wrap }) {
|
||||
return { view, destroy: () => view.destroy() };
|
||||
}
|
||||
|
||||
// Markdown to sanitized HTML (shown in a sandboxed iframe on top of this).
|
||||
export function markdown(text) {
|
||||
return DOMPurify.sanitize(marked.parse(text, { gfm: true, breaks: false }), { USE_PROFILES: { html: true } });
|
||||
// ---------------------------------------------------------------- markdown (devdesk#55)
|
||||
|
||||
// Fence names and aliases -> a language for highlighting.
|
||||
const FENCE = {
|
||||
sh: 'shell', bash: 'shell', zsh: 'shell', shell: 'shell', console: 'shell', shellsession: 'shell', env: 'shell', dotenv: 'shell',
|
||||
py: 'python', python: 'python', js: 'javascript', javascript: 'javascript', jsx: 'javascript', mjs: 'javascript', node: 'javascript',
|
||||
ts: 'typescript', typescript: 'typescript', tsx: 'typescript', json: 'json', jsonc: 'json', json5: 'json',
|
||||
yml: 'yaml', yaml: 'yaml', toml: 'toml', ini: 'properties', conf: 'properties', cfg: 'properties', properties: 'properties', systemd: 'properties',
|
||||
nginx: 'nginx', diff: 'diff', patch: 'diff', css: 'css', scss: 'css', html: 'html', xml: 'xml', svg: 'xml',
|
||||
go: 'go', golang: 'go', rust: 'rust', rs: 'rust', sql: 'sql', dockerfile: 'dockerfile', docker: 'dockerfile',
|
||||
lua: 'lua', perl: 'perl', pl: 'perl', ruby: 'ruby', rb: 'ruby', md: 'markdown', markdown: 'markdown',
|
||||
};
|
||||
const parsers = new Map();
|
||||
function parserFor(lang) {
|
||||
const name = FENCE[(lang || '').toLowerCase()];
|
||||
if (!name) return null;
|
||||
if (!parsers.has(name)) parsers.set(name, name === 'markdown' ? markdownLanguage.parser : StreamLanguage.define(MODES[name]).parser);
|
||||
return parsers.get(name);
|
||||
}
|
||||
|
||||
const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
|
||||
// Code as HTML with tok-* classes (CodeMirror's own parsers, no extra library).
|
||||
export function highlightToHtml(code, lang) {
|
||||
const parser = parserFor(lang);
|
||||
if (!parser || code.length > 200000) return escapeHtml(code);
|
||||
let out = '';
|
||||
let pos = 0;
|
||||
try {
|
||||
highlightTree(parser.parse(code), classHighlighter, (from, to, classes) => {
|
||||
if (from > pos) out += escapeHtml(code.slice(pos, from));
|
||||
out += `<span class="${classes}">${escapeHtml(code.slice(from, to))}</span>`;
|
||||
pos = to;
|
||||
});
|
||||
} catch {
|
||||
return escapeHtml(code);
|
||||
}
|
||||
return out + escapeHtml(code.slice(pos));
|
||||
}
|
||||
|
||||
// GitHub's heading ids: lowercase, punctuation dropped, spaces to dashes, and
|
||||
// "-1", "-2" for repeats.
|
||||
function slugger() {
|
||||
const seen = new Map();
|
||||
return (text) => {
|
||||
const base = text.toLowerCase().trim().replace(/<[^>]*>/g, '').replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-') || 'section';
|
||||
const n = seen.get(base) ?? 0;
|
||||
seen.set(base, n + 1);
|
||||
return n ? `${base}-${n}` : base;
|
||||
};
|
||||
}
|
||||
|
||||
// Leading YAML front matter (--- … ---) as simple key/value pairs.
|
||||
function frontMatter(text) {
|
||||
const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
|
||||
if (!m) return { body: text, meta: null };
|
||||
const meta = [];
|
||||
for (const line of m[1].split(/\r?\n/)) {
|
||||
const kv = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(line);
|
||||
if (kv) meta.push([kv[1], kv[2].replace(/^["']|["']$/g, '')]);
|
||||
else if (meta.length && /^\s+\S/.test(line)) meta[meta.length - 1][1] += ` ${line.trim()}`;
|
||||
}
|
||||
return { body: text.slice(m[0].length), meta };
|
||||
}
|
||||
|
||||
// Markdown to sanitized HTML for the preview (devdesk#55): GitHub-flavoured,
|
||||
// heading ids, highlighted code blocks; returns { html, headings, meta }. No
|
||||
// scripts, styles, forms, frames or inline style survive DOMPurify.
|
||||
export function renderMarkdown(text) {
|
||||
const { body, meta } = frontMatter(text);
|
||||
const slug = slugger();
|
||||
const headings = [];
|
||||
const md = new Marked({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
renderer: {
|
||||
heading({ tokens, depth, text: raw }) {
|
||||
const inner = this.parser.parseInline(tokens);
|
||||
const id = slug(raw);
|
||||
headings.push({ depth, id, text: inner.replace(/<[^>]*>/g, '') });
|
||||
return `<h${depth} id="${id}"><a class="dd-anchor" href="#${id}" aria-hidden="true">#</a>${inner}</h${depth}>\n`;
|
||||
},
|
||||
code({ text: code, lang }) {
|
||||
const name = (lang || '').split(/\s+/)[0];
|
||||
return `<pre class="dd-code" data-lang="${escapeHtml(name)}"><code>${highlightToHtml(code.replace(/\n$/, ''), name)}</code></pre>\n`;
|
||||
},
|
||||
},
|
||||
});
|
||||
const html = DOMPurify.sanitize(md.parse(body), {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ['style', 'form', 'iframe', 'object', 'embed', 'base', 'meta', 'link', 'textarea', 'select', 'button'],
|
||||
FORBID_ATTR: ['style'],
|
||||
});
|
||||
return { html, headings, meta };
|
||||
}
|
||||
|
||||
// Kept for older callers: just the HTML.
|
||||
export function markdown(text) {
|
||||
return renderMarkdown(text).html;
|
||||
}
|
||||
|
||||
+14
-3
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
<div class="dd-session">
|
||||
<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>
|
||||
<span class="dd-state-dot" aria-hidden="true"></span><span class="dd-session-label" id="session-name">—</span><span class="dd-turn-timer" id="turn-timer" hidden></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>
|
||||
@@ -109,8 +109,17 @@
|
||||
<div class="dd-explorer-views" role="tablist">
|
||||
<button type="button" role="tab" data-files-view="explorer" aria-selected="true">Explorer</button>
|
||||
<button type="button" role="tab" data-files-view="changes" aria-selected="false">Changes <span class="dd-files-count"></span></button>
|
||||
<button type="button" role="tab" data-files-view="search" aria-selected="false" title="Search in files (Ctrl+Shift+F)">Search</button>
|
||||
<span class="dd-explorer-actions">
|
||||
<button type="button" class="dd-icon-btn" data-explorer-act="new-file" title="New file" aria-label="New file">🗋</button>
|
||||
<button type="button" class="dd-icon-btn" data-explorer-act="new-folder" title="New folder" aria-label="New folder">🗀</button>
|
||||
<button type="button" class="dd-icon-btn" data-explorer-act="upload" title="Upload files (or drop them on a folder)" aria-label="Upload files">⇪</button>
|
||||
<button type="button" class="dd-icon-btn" data-explorer-act="collapse" title="Collapse all folders" aria-label="Collapse all folders">⊟</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="dd-explorer-body" role="tree" aria-label="Files"></div>
|
||||
<div class="dd-explorer-body" role="tree" aria-label="Files" aria-multiselectable="true"></div>
|
||||
<div class="dd-upload-bar" hidden><span class="dd-upload-text"></span><span class="dd-upload-meter"><span class="dd-upload-fill"></span></span></div>
|
||||
<input type="file" class="dd-upload-input" multiple hidden>
|
||||
</div>
|
||||
<div class="dd-editor-area"></div>
|
||||
</div>
|
||||
@@ -131,6 +140,7 @@
|
||||
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>
|
||||
<label class="dd-check" id="new-session-worktree-label" hidden title="A separate checkout on branch devdesk/NAME, so this Claude never edits the same files as another session"><input type="checkbox" id="new-session-worktree"> own git worktree</label>
|
||||
</form>
|
||||
<ul class="dd-session-list" id="sessions-list"></ul>
|
||||
<span class="dd-muted dd-status" id="sessions-status" role="status"></span>
|
||||
@@ -155,6 +165,7 @@
|
||||
<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>
|
||||
<ul class="dd-repo-changes" hidden></ul>
|
||||
<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>
|
||||
@@ -174,7 +185,7 @@
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<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-hint"><span class="lt-footer-key">drag</span>copy</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>
|
||||
|
||||
+34
-1
@@ -224,6 +224,14 @@ export async function peekSession(session) {
|
||||
return parseListOutput(await runCommand(['--peek', session], 15000));
|
||||
}
|
||||
|
||||
// Claude's whole last answer, as Markdown (devdesk#47): {session, text, at}.
|
||||
export async function lastReply(session) {
|
||||
const output = stripAnsi(await runCommand(['--last-reply', session], 15000)).trim();
|
||||
if (output.includes('"session"')) return parseListOutput(output);
|
||||
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
|
||||
throw new Error(message.replace(/^devdesk:\s*/, ''));
|
||||
}
|
||||
|
||||
export async function replyTo(session, text) {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const result = await limited(() => streamIn(['--reply', session, String(bytes.length)], 'DEVDESK-REPLY-READY', new Blob([bytes])));
|
||||
@@ -238,6 +246,24 @@ export function writeFile(session, path, bytes, base) {
|
||||
return limited(() => streamIn(['--fs-write', session, path, String(bytes.length), base], 'DEVDESK-WRITE-READY', new Blob([bytes])));
|
||||
}
|
||||
|
||||
// Explorer uploads (devdesk#54): any file (up to 25 MB) to PATH in the session's
|
||||
// folder, folders created as needed. mode "new" refuses to replace; "replace"
|
||||
// overwrites. Resolves {ok, path, size}; rejects "already exists: …" etc.
|
||||
export function putFile(session, path, file, mode = 'new', onProgress) {
|
||||
if (file.size > UPLOAD_MAX) return Promise.reject(new Error('larger than 25 MB'));
|
||||
return limited(() => streamIn(['--fs-put', session, path, String(file.size), mode], 'DEVDESK-PUT-READY', file, onProgress));
|
||||
}
|
||||
|
||||
// A file of the session as a Blob, for the browser to save (up to 25 MB).
|
||||
export async function downloadFile(session, path) {
|
||||
const { header, base64 } = await runBlob(['--fs-download', session, path], 180000);
|
||||
if (base64 === null || base64 === undefined) throw new Error('no content');
|
||||
const bin = atob(base64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
|
||||
return { name: (header.path || path).split('/').pop(), blob: new Blob([bytes]) };
|
||||
}
|
||||
|
||||
// Run a devdesk command that reads base64 from the terminal after printing
|
||||
// `ready`: stream `blob` in slices (never all in memory), then resolve the JSON
|
||||
// reply. Fails on 20 s of inactivity rather than a fixed total (devdesk#9).
|
||||
@@ -371,7 +397,14 @@ export async function fetchRepo(session) {
|
||||
// rejects with devdesk's own error message.
|
||||
export async function sessionAction(action, ...args) {
|
||||
const output = stripAnsi(await runCommand([`--${action}`, ...args])).trim();
|
||||
if (/"ok"\s*:\s*true/.test(output)) return;
|
||||
// Success: the reply's JSON ({ok: true, …}) for callers that need its fields.
|
||||
if (/"ok"\s*:\s*true/.test(output)) {
|
||||
try {
|
||||
return parseListOutput(output);
|
||||
} catch {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
|
||||
throw new Error(message.replace(/^devdesk:\s*/, ''));
|
||||
}
|
||||
|
||||
+1319
-138
File diff suppressed because it is too large
Load Diff
+128
-9
@@ -2,10 +2,11 @@
|
||||
import { THEMES, createTerminal, loadFonts } from './term.js';
|
||||
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
|
||||
import {
|
||||
TtydConnection, fetchAlerts, fetchRepo, fetchToken, investigateAlert, sessionAction, uploadFile, AuthExpiredError, UnauthorizedError,
|
||||
TtydConnection, fetchAlerts, fetchRepo, fetchToken, investigateAlert, lastReply, replyTo, sessionAction, uploadFile,
|
||||
AuthExpiredError, UnauthorizedError,
|
||||
} from './connection.js';
|
||||
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
|
||||
import { SessionsPanel, ctxLabel, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
||||
import { SessionsPanel, ctxLabel, elapsed, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
||||
import { renderAlerts } from './alerts.js';
|
||||
import { renderRepo } from './repo.js';
|
||||
import { bindAskClaude } from './widgets.js';
|
||||
@@ -125,10 +126,17 @@ function renderSessionChip() {
|
||||
const status = current ? sessionStatus(current) : null;
|
||||
ui.sessionName.textContent = state.session || 'picker';
|
||||
ui.sessionChip.dataset.state = status?.key ?? '';
|
||||
// How long the current turn has been running (devdesk#48).
|
||||
const turn = status?.key === 'working' && current.status?.since ? elapsed(current.status.since) : '';
|
||||
const timer = $('turn-timer');
|
||||
timer.hidden = !turn;
|
||||
timer.textContent = turn;
|
||||
notify.setStatusPrefix(turn ? `⏳ ${turn} · ` : status?.key === 'waiting' ? '❗ ' : '');
|
||||
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)}%` : '',
|
||||
turn ? `working on this turn for ${turn}` : '',
|
||||
current?.context?.used_pct != null ? ctxLabel(current.context).replace(/^ctx/, 'context') : '',
|
||||
'Click to switch session',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
@@ -259,14 +267,29 @@ function wireUploads() {
|
||||
$('drop-session').textContent = state.session || 'a session';
|
||||
overlay.hidden = false;
|
||||
});
|
||||
// Files dragged from the explorer: @path mentions for Claude (devdesk#54).
|
||||
const fromExplorer = (event) => [...(event.dataTransfer?.types ?? [])].includes('application/x-devdesk-paths');
|
||||
frame.addEventListener('dragover', (event) => {
|
||||
if (hasFiles(event)) event.preventDefault();
|
||||
else if (fromExplorer(event)) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
});
|
||||
frame.addEventListener('dragleave', () => {
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (!depth) overlay.hidden = true;
|
||||
});
|
||||
frame.addEventListener('drop', (event) => {
|
||||
if (!hasFiles(event) && fromExplorer(event)) {
|
||||
event.preventDefault();
|
||||
const text = event.dataTransfer.getData('text/plain');
|
||||
if (text && state.conn?.isOpen) {
|
||||
term.paste(text);
|
||||
term.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
depth = 0;
|
||||
@@ -276,7 +299,8 @@ function wireUploads() {
|
||||
// Pasting a screenshot / copied file: upload it instead of pasting text.
|
||||
document.addEventListener('paste', (event) => {
|
||||
const files = [...(event.clipboardData?.files ?? [])];
|
||||
if (!files.length) return;
|
||||
// In the Files pane, pasted files go into the explorer's folder (devdesk#54).
|
||||
if (!files.length || event.target.closest?.('#files')) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
attachFiles(files);
|
||||
@@ -285,6 +309,14 @@ function wireUploads() {
|
||||
|
||||
// Repo / CI panel for the current session; notifies when HEAD's CI finishes.
|
||||
let lastCi = { key: '', state: '' };
|
||||
// Repo panel → diffs in the file pane (devdesk#46).
|
||||
const repoHooks = {
|
||||
openChanges: () => files.open('changes'),
|
||||
openDiff: (path, diffable) => {
|
||||
files.open('changes');
|
||||
files.openFile(path, { mode: diffable ? 'diff' : undefined, pin: true });
|
||||
},
|
||||
};
|
||||
let repoBusy = false;
|
||||
async function refreshRepo() {
|
||||
const session = state.session;
|
||||
@@ -298,7 +330,7 @@ async function refreshRepo() {
|
||||
try {
|
||||
const repo = await fetchRepo(session);
|
||||
if (session !== state.session) return;
|
||||
renderRepo($('widget-repo'), repo);
|
||||
renderRepo($('widget-repo'), repo, repoHooks);
|
||||
lastRepo = repo;
|
||||
const key = `${session}@${repo.sha}`;
|
||||
const ci = repo.ci?.total ? repo.ci.state : '';
|
||||
@@ -646,15 +678,28 @@ function renderUsage() {
|
||||
return;
|
||||
}
|
||||
const at = (epoch) => (epoch ? new Date(epoch * 1000).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' }) : '?');
|
||||
const meter = (label, w) => {
|
||||
const clock = (epoch) => new Date(epoch * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
const day = (epoch) => new Date(epoch * 1000).toLocaleDateString(undefined, { weekday: 'short' });
|
||||
// devdesk#45: when it resets, and when it fills at the pace of the window so
|
||||
// far (straight line from the window's start), if that is before the reset.
|
||||
const meter = (label, w, span, short) => {
|
||||
if (!w || w.pct === null || w.pct === undefined) return null;
|
||||
const pct = Math.round(w.pct);
|
||||
return el('span', { class: `dd-meter${pct >= 95 ? ' is-full' : pct >= 80 ? ' is-high' : ''}`, title: `${label}: ${pct}% used, resets ${at(w.resets_at)}` },
|
||||
const now = Date.now() / 1000;
|
||||
const start = w.resets_at ? w.resets_at - span : null;
|
||||
const used = start ? now - start : 0;
|
||||
const full = start && w.pct >= 5 && w.pct < 100 && used >= 600 ? now + ((100 - w.pct) * used) / w.pct : null;
|
||||
const early = full && full < w.resets_at ? full : null;
|
||||
const title = [`${label}: ${pct}% used, resets ${at(w.resets_at)}`,
|
||||
early ? `At this pace it is used up around ${at(early)}, before it resets` : start && pct < 100 ? 'At this pace it lasts until it resets' : ''].filter(Boolean).join('\n');
|
||||
return el('span', { class: `dd-meter${pct >= 95 ? ' is-full' : pct >= 80 || early ? ' is-high' : ''}`, title },
|
||||
el('span', { class: 'dd-meter-label' }, label),
|
||||
el('span', { class: 'dd-meter-bar' }, el('span', { class: 'dd-meter-fill', style: `width:${Math.min(100, pct)}%` })),
|
||||
`${pct}%`);
|
||||
`${pct}%`,
|
||||
early ? el('span', { class: 'dd-meter-pace' }, `⚠ full ~${short(early)}`)
|
||||
: w.resets_at ? el('span', { class: 'dd-meter-reset' }, `↻ ${short(w.resets_at)}`) : null);
|
||||
};
|
||||
const parts = () => [meter('5h', latest.five_hour), meter('week', latest.seven_day)].filter(Boolean);
|
||||
const parts = () => [meter('5h', latest.five_hour, 5 * 3600, clock), meter('week', latest.seven_day, 7 * 86400, day)].filter(Boolean);
|
||||
footer.replaceChildren(...parts());
|
||||
$('usage-menu').replaceChildren(...parts());
|
||||
footer.hidden = false;
|
||||
@@ -683,6 +728,54 @@ function presenceTick() {
|
||||
|
||||
const sendKeys = (keys) => state.conn?.sendInput(keys);
|
||||
|
||||
// Claude's whole last answer as Markdown (devdesk#47). The clipboard write starts
|
||||
// inside the click (Safari needs that) and gets its text when the server answers.
|
||||
async function copyReply(session) {
|
||||
const text = lastReply(session).then((r) => {
|
||||
if (!r.text) throw new Error(`${session}: Claude hasn't answered anything yet`);
|
||||
return r.text;
|
||||
});
|
||||
try {
|
||||
if (window.ClipboardItem && navigator.clipboard?.write) {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'text/plain': text.then((t) => new Blob([t], { type: 'text/plain' })) })]);
|
||||
} else {
|
||||
await navigator.clipboard.writeText(await text);
|
||||
}
|
||||
const t = await text;
|
||||
toast.success(`Copied ${session}'s last reply (${t.length.toLocaleString()} characters)`);
|
||||
} catch (error) {
|
||||
const reason = await text.then(() => null, (e) => e);
|
||||
toast.error(reason ? reason.message : `Copy failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// /compact in a session whose context is filling up (devdesk#49). Only when Claude
|
||||
// is idle: mid-turn or at a permission prompt the keys would land in the wrong place.
|
||||
async function compact(session) {
|
||||
const s = sessionsPanel?.data?.sessions?.find((x) => x.name === session);
|
||||
const key = s ? sessionStatus(s).key : 'idle';
|
||||
if (key === 'working') return toast.warning(`${session} is working: compact once the turn ends`);
|
||||
if (key === 'waiting') return toast.warning(`${session} is waiting on you: answer it first`);
|
||||
try {
|
||||
await replyTo(session, '/compact');
|
||||
toast.success(`Compacting ${session}'s context`);
|
||||
sessionsPanel.reload();
|
||||
} catch (error) {
|
||||
toast.error(`${session}: ${error.message}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// A stray split (devdesk#52): close every extra pane, keeping Claude's.
|
||||
async function unsplit(session) {
|
||||
try {
|
||||
const result = await sessionAction('unsplit', session);
|
||||
toast.success(result?.closed ? `Closed ${result.closed} pane${result.closed === 1 ? '' : 's'}` : 'No split panes');
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
const url = new URL(window.location.href);
|
||||
url.search = state.session ? `?arg=${encodeURIComponent(state.session)}` : '';
|
||||
@@ -723,6 +816,8 @@ function buildCommands() {
|
||||
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: 'copy-reply', group: 'Sessions', icon: '⧉', label: 'Copy Claude\'s last reply (Markdown)', keywords: 'clipboard answer response markdown', run: () => copyReply(state.session) });
|
||||
if (current?.running) add({ id: 'compact', group: 'Sessions', icon: '⇲', label: `Compact the context (/compact)${current.context?.used_pct != null ? ` · ${Math.floor(current.context.used_pct)}% full` : ''}`, keywords: 'context summarize shrink', run: () => compact(state.session) });
|
||||
}
|
||||
add({ id: 'picker', group: 'Sessions', icon: '≡', label: 'Open the session picker', keywords: 'fzf list', run: () => connect(null) });
|
||||
add({ id: 'history', group: 'Sessions', icon: '⌕', label: 'Search past conversations…', keywords: 'history transcript resume fork find old', run: showHistory });
|
||||
@@ -732,11 +827,13 @@ function buildCommands() {
|
||||
add({ id: 'win-shell', group: 'Terminal', icon: '1', label: 'Shell window', kbd: '^b 1', run: () => sendKeys('\x021') });
|
||||
add({ id: 'scroll', group: 'Terminal', icon: '⇞', label: 'Scroll back (tmux copy mode, q to leave)', kbd: '^b [', keywords: 'history scrollback', run: () => sendKeys('\x02[') });
|
||||
add({ id: 'detach', group: 'Terminal', icon: '⏏', label: 'Detach (the session keeps running)', kbd: '^b d', run: () => sendKeys('\x02d') });
|
||||
add({ id: 'unsplit', group: 'Terminal', icon: '▣', label: 'Close split panes (keeps Claude)', keywords: 'pane split remove side by side layout', run: () => unsplit(state.session) });
|
||||
}
|
||||
|
||||
if (state.session) {
|
||||
add({ id: 'files', group: 'Files', icon: '🗀', label: files.isOpen ? 'Close files' : 'Browse files', kbd: 'Ctrl+⇧E', keywords: 'explorer folder read open view', run: () => files.toggle('files') });
|
||||
add({ id: 'changes', group: 'Files', icon: '±', label: 'Review changes (git diff)', keywords: 'git status diff claude edits', run: () => files.open('changes') });
|
||||
add({ id: 'search-files', group: 'Files', icon: '⌕', label: 'Search in files', kbd: 'Ctrl+⇧F', keywords: 'grep find text content', run: () => files.openSearch() });
|
||||
add({
|
||||
id: 'goto-file', group: 'Files', icon: '⌕', label: 'Go to file…', kbd: 'Ctrl+P in Files', keywords: 'open find quick search',
|
||||
run: () => {
|
||||
@@ -807,7 +904,10 @@ const KEY_HELP = [
|
||||
['Ctrl+⇧ E', 'Files: browse, preview, diff, edit (Esc closes)'],
|
||||
['Ctrl+P', 'Files: go to file'],
|
||||
['Ctrl+S', 'Files: save the open file'],
|
||||
['Drag', 'Select text; it is copied when you let go'],
|
||||
['Ctrl+C', 'Copy the selection (interrupts only when nothing is selected)'],
|
||||
['Ctrl+⇧ C / V', 'Copy selection / paste'],
|
||||
['Ctrl+⇧ F', 'Search in the session\'s files'],
|
||||
['⇧ drag', 'Select text in the browser'],
|
||||
['Drop or paste a file', 'Upload it and attach it for Claude'],
|
||||
['Click a file path', 'Open it in Files (at the line, if given)'],
|
||||
@@ -981,6 +1081,13 @@ function wireChrome() {
|
||||
if (state.session) files.toggle();
|
||||
return;
|
||||
}
|
||||
// Ctrl+Shift+F: search in the session's files (devdesk#56).
|
||||
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyF') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (state.session) files.openSearch();
|
||||
return;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -1033,6 +1140,9 @@ function wireChrome() {
|
||||
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape' || dialogOpen()) return;
|
||||
// A right-click menu, the explorer's name box, or an explorer selection /
|
||||
// cut to clear take their own Esc first (devdesk#54).
|
||||
if (event.target.closest?.('.dd-ctx-menu, .dd-tree-input') || files?.wantsEscape(event)) return;
|
||||
// Files pane: Esc goes back / closes, unless the terminal or CodeMirror's
|
||||
// own search panel has the focus.
|
||||
if (files?.isOpen && !event.target.closest?.('.xterm, .cm-panels') && !menus.some(({ menu }) => !menu.hidden) && !inbox.isOpen) {
|
||||
@@ -1084,6 +1194,11 @@ function wireChrome() {
|
||||
// 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));
|
||||
// The turn timer and the usage pace move between polls.
|
||||
setInterval(() => {
|
||||
renderSessionChip();
|
||||
renderUsage();
|
||||
}, 10 * 1000);
|
||||
setInterval(() => {
|
||||
if (!heardWithin('sessions', 16 * 1000)) sessionsPanel.refresh();
|
||||
}, 15 * 1000);
|
||||
@@ -1125,6 +1240,7 @@ async function boot() {
|
||||
fontSize,
|
||||
theme: getTheme(),
|
||||
onShiftEnter: () => state.conn?.sendInput('\n'),
|
||||
onCopied: (text) => toast.success(`Copied ${text.length.toLocaleString()} character${text.length === 1 ? '' : 's'}`, 1500),
|
||||
}));
|
||||
onThemeChange((theme) => {
|
||||
term.options.theme = THEMES[theme];
|
||||
@@ -1161,6 +1277,7 @@ async function boot() {
|
||||
form: $('new-session-form'),
|
||||
nameInput: $('new-session-name'),
|
||||
select: $('project-select'),
|
||||
worktree: $('new-session-worktree'),
|
||||
refreshButton: $('sessions-refresh'),
|
||||
newToggle: $('new-session-toggle'),
|
||||
status: $('sessions-status'),
|
||||
@@ -1170,6 +1287,8 @@ async function boot() {
|
||||
connect(name);
|
||||
},
|
||||
onEnding: () => { state.endingCurrent = true; },
|
||||
onCompact: (name) => compact(name),
|
||||
onCopyReply: (name) => copyReply(name),
|
||||
onEnded: (name) => {
|
||||
if (name !== state.session) state.endingCurrent = false;
|
||||
},
|
||||
|
||||
+9
-1
@@ -6,13 +6,14 @@ import { FAVICON, FAVICON_BADGE } from './icon.js';
|
||||
|
||||
let unread = 0;
|
||||
let baseTitle = document.title;
|
||||
let statusPrefix = '';
|
||||
|
||||
function attentive() {
|
||||
return !document.hidden && document.hasFocus();
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.title = unread ? `(${unread}) ${baseTitle}` : baseTitle;
|
||||
document.title = `${unread ? `(${unread}) ` : ''}${statusPrefix}${baseTitle}`;
|
||||
const link = document.getElementById('favicon');
|
||||
if (link) link.href = unread ? FAVICON_BADGE : FAVICON;
|
||||
}
|
||||
@@ -22,6 +23,13 @@ export function setBaseTitle(title) {
|
||||
render();
|
||||
}
|
||||
|
||||
// "⏳ 3m · " while Claude works on a turn in this tab's session (devdesk#48).
|
||||
export function setStatusPrefix(prefix) {
|
||||
if (prefix === statusPrefix) return;
|
||||
statusPrefix = prefix;
|
||||
render();
|
||||
}
|
||||
|
||||
export function notificationsState() {
|
||||
if (!('Notification' in window)) return 'unsupported';
|
||||
return Notification.permission; // granted | denied | default
|
||||
|
||||
+46
-2
@@ -18,13 +18,16 @@ function link(href, text) {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function renderRepo(root, repo) {
|
||||
// hooks: openDiff(path, diffable), openChanges() (devdesk#46)
|
||||
export function renderRepo(root, repo, hooks = {}) {
|
||||
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;
|
||||
const changes = root.querySelector('.dd-repo-changes');
|
||||
changes.hidden = true;
|
||||
|
||||
// No repository: one line instead of a grid of dashes (devdesk#17).
|
||||
if (!repo || !repo.git) {
|
||||
@@ -41,7 +44,48 @@ export function renderRepo(root, repo) {
|
||||
? [repo.ahead ? `↑${repo.ahead}` : '', repo.behind ? `↓${repo.behind}` : ''].filter(Boolean).join(' ') || 'in sync'
|
||||
: 'no upstream';
|
||||
set('branch', `${repo.branch} @ ${repo.sha || '—'} · ${sync}`);
|
||||
set('dirty', repo.dirty ? `${repo.dirty} changed` : 'clean');
|
||||
if (repo.dirty) {
|
||||
const all = document.createElement('button');
|
||||
all.type = 'button';
|
||||
all.className = 'dd-link-btn';
|
||||
all.textContent = `${repo.dirty} changed`;
|
||||
all.title = 'Review all changes';
|
||||
all.addEventListener('click', () => hooks.openChanges?.());
|
||||
set('dirty', all);
|
||||
} else {
|
||||
set('dirty', 'clean');
|
||||
}
|
||||
// The first changed files: click one for its diff against HEAD.
|
||||
const files = (repo.changes ?? []).filter((f) => f.path !== null && f.path !== undefined);
|
||||
if (files.length) {
|
||||
changes.hidden = false;
|
||||
changes.replaceChildren(...files.map((f) => {
|
||||
const diffable = f.x !== '?' && f.y !== '?';
|
||||
const code = f.x === '?' ? 'new' : (f.x !== ' ' ? f.x : f.y);
|
||||
const li = document.createElement('li');
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'dd-repo-change';
|
||||
b.title = `${f.path}${diffable ? ' · diff against HEAD' : ' · new file'}${f.claude ? ' · edited by Claude' : ''}`;
|
||||
const name = document.createElement('span');
|
||||
name.className = 'dd-repo-change-name';
|
||||
name.textContent = f.path;
|
||||
const stat = document.createElement('span');
|
||||
stat.className = 'dd-repo-change-stat';
|
||||
stat.textContent = [f.add ? `+${f.add}` : '', f.del ? `−${f.del}` : '', code === 'new' ? 'new' : code.trim()]
|
||||
.filter(Boolean).join(' ');
|
||||
b.append(name, stat);
|
||||
b.addEventListener('click', () => hooks.openDiff?.(f.path, diffable));
|
||||
li.append(b);
|
||||
return li;
|
||||
}));
|
||||
if (repo.dirty > files.length) {
|
||||
const more = document.createElement('li');
|
||||
more.className = 'dd-muted';
|
||||
more.textContent = `+${repo.dirty - files.length} more`;
|
||||
changes.append(more);
|
||||
}
|
||||
}
|
||||
|
||||
const ci = field('ci');
|
||||
const [label, cls] = repo.ci?.total ? (CI[repo.ci.state] || [repo.ci.state, 'lt-status-pending'])
|
||||
|
||||
+50
-11
@@ -46,6 +46,14 @@ function renameRecent(from, to) {
|
||||
writeRecents(readRecents().map((r) => (r.name === from ? { ...r, name: to } : r)));
|
||||
}
|
||||
|
||||
// How long a turn has been running: "45s", "3m", "1h 05m" (devdesk#48).
|
||||
export function elapsed(sinceSeconds) {
|
||||
const s = Math.max(0, Math.floor(Date.now() / 1000 - sinceSeconds));
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
||||
return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
|
||||
}
|
||||
|
||||
// "ctx 34% · 346k/1M": context use as a share of the window and in tokens.
|
||||
const tokens = (n) => (n >= 1e6 ? `${+(n / 1e6).toFixed(n % 1e6 ? 1 : 0)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
|
||||
export function ctxLabel(c) {
|
||||
@@ -79,9 +87,11 @@ export function sessionStatus(s) {
|
||||
}
|
||||
|
||||
export class SessionsPanel {
|
||||
// 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 });
|
||||
// hooks: onOpen(name), onEnding(name), onEnded(name), onEndAborted(), onRenamed(from, to), onData(data),
|
||||
// onCompact(name), onCopyReply(name)
|
||||
constructor({ list, form, nameInput, select, worktree, refreshButton, newToggle, status, hooks }) {
|
||||
Object.assign(this, { list, form, nameInput, select, worktree, newToggle, status, hooks });
|
||||
select.addEventListener('change', () => this.worktreeOption(select, worktree));
|
||||
this.current = null;
|
||||
this.data = null;
|
||||
this.pending = null;
|
||||
@@ -153,6 +163,14 @@ export class SessionsPanel {
|
||||
}
|
||||
}
|
||||
|
||||
// The worktree choice only makes sense for a project that is a git repository (devdesk#44).
|
||||
worktreeOption(select, box) {
|
||||
if (!box) return;
|
||||
const git = Boolean(this.data?.projects?.find((p) => p.name === select.value && p.git && select.value));
|
||||
box.closest('label').hidden = !git;
|
||||
if (!git) box.checked = false;
|
||||
}
|
||||
|
||||
async createFromForm() {
|
||||
const project = this.select.value;
|
||||
const name = slugify(this.nameInput.value) || project;
|
||||
@@ -161,16 +179,17 @@ export class SessionsPanel {
|
||||
this.nameInput.focus();
|
||||
return;
|
||||
}
|
||||
if (!(await this.create(name, project))) return;
|
||||
if (!(await this.create(name, project, this.worktree?.checked))) return;
|
||||
this.nameInput.value = '';
|
||||
this.select.value = '';
|
||||
this.worktreeOption(this.select, this.worktree);
|
||||
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;
|
||||
async create(name, project, worktree = false) {
|
||||
if (project && !(await this.act('new', name, project, ...(worktree ? ['--worktree'] : [])))) return false;
|
||||
this.hooks.onOpen(name);
|
||||
return true;
|
||||
}
|
||||
@@ -191,14 +210,19 @@ export class SessionsPanel {
|
||||
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.');
|
||||
const worktree = el('input', { type: 'checkbox' });
|
||||
const worktreeLabel = el('label', { class: 'dd-check', hidden: '' }, worktree,
|
||||
el('span', {}, 'Own git worktree: a separate checkout on branch devdesk/NAME, so this Claude never edits the same files as another session'));
|
||||
project.addEventListener('change', () => {
|
||||
if (!name.value.trim()) name.placeholder = project.value || 'e.g. pbs-upgrade';
|
||||
this.worktreeOption(project, worktree);
|
||||
});
|
||||
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),
|
||||
worktreeLabel,
|
||||
hint),
|
||||
actions: [{ label: 'Cancel' }, { label: 'Open', primary: true }],
|
||||
submit: () => {
|
||||
@@ -209,10 +233,10 @@ export class SessionsPanel {
|
||||
name.focus();
|
||||
return null;
|
||||
}
|
||||
return { name: value, project: project.value };
|
||||
return { name: value, project: project.value, worktree: worktree.checked };
|
||||
},
|
||||
});
|
||||
if (result) await this.create(result.name, result.project);
|
||||
if (result) await this.create(result.name, result.project, result.worktree);
|
||||
}
|
||||
|
||||
async rename(name) {
|
||||
@@ -254,9 +278,10 @@ export class SessionsPanel {
|
||||
async end(session) {
|
||||
const ok = await confirmDialog({
|
||||
title: session.running ? `End ${session.name}?` : `Forget ${session.name}?`,
|
||||
text: session.running
|
||||
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.',
|
||||
: 'This dormant session is removed from the list. Its conversation stays in Claude\'s history.')
|
||||
+ (session.worktree ? ` Its worktree is removed if nothing in it is uncommitted; branch ${session.worktree.branch} is deleted only if it is merged.` : ''),
|
||||
confirmLabel: session.running ? 'End session' : 'Forget',
|
||||
danger: true,
|
||||
});
|
||||
@@ -276,7 +301,8 @@ export class SessionsPanel {
|
||||
const isCurrent = s.name === this.current;
|
||||
const status = sessionStatus(s);
|
||||
const ctx = ctxLabel(s.context);
|
||||
const since = s.status?.since ? ago(s.status.since) : ago(s.activity);
|
||||
const since = status.key === 'working' && s.status?.since ? elapsed(s.status.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
|
||||
? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, model, s.attached ? `${s.attached} open` : '']
|
||||
@@ -295,6 +321,7 @@ export class SessionsPanel {
|
||||
el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
|
||||
el('span', { class: `lt-dot ${status.dot}` }),
|
||||
el('span', { class: 'dd-session-name' }, s.name,
|
||||
s.worktree ? el('span', { class: 'dd-tag dd-wt', title: `Own worktree on ${s.worktree.branch}\n${s.worktree.path}` }, `⑂ ${s.worktree.branch.replace(/^devdesk\//, '')}`) : null,
|
||||
s.tags?.length ? el('span', { class: 'dd-tags', title: 'Hosts this session worked on' }, ...s.tags.slice(0, 3).map((t) => el('span', { class: 'dd-tag' }, t))) : null,
|
||||
// The pull request of this branch (devdesk#25)
|
||||
s.context?.pr?.number ? el('a', {
|
||||
@@ -306,6 +333,10 @@ export class SessionsPanel {
|
||||
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' },
|
||||
// Context nearly full and Claude idle: /compact from here (devdesk#49).
|
||||
s.running && pct >= 80 && status.key === 'idle'
|
||||
? iconButton(`Compact (context ${Math.floor(pct)}% full)`, '⇲', () => this.hooks.onCompact?.(s.name), 'dd-compact') : null,
|
||||
s.running || s.claude_session_id ? iconButton('Copy Claude\'s last reply', '⧉', () => this.hooks.onCopyReply?.(s.name)) : null,
|
||||
iconButton('Rename', '✎', () => this.rename(s.name)),
|
||||
el('a', {
|
||||
class: 'dd-icon-btn', href: `?arg=${encodeURIComponent(s.name)}`, target: '_blank', rel: 'noopener',
|
||||
@@ -335,7 +366,15 @@ export class SessionsPanel {
|
||||
}, '×'))));
|
||||
}
|
||||
if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
|
||||
const scroll = this.list.scrollTop;
|
||||
this.list.replaceChildren(...items);
|
||||
// Keep the scroll position across polls; bring the current session into view
|
||||
// when it changes.
|
||||
this.list.scrollTop = scroll;
|
||||
if (this.current !== this.shownCurrent) {
|
||||
this.shownCurrent = this.current;
|
||||
this.list.querySelector('.is-current')?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
this.projectOptions(this.select);
|
||||
}
|
||||
|
||||
+43
-2
@@ -90,7 +90,7 @@ export async function loadFonts(size) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter }) {
|
||||
export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter, onCopied }) {
|
||||
const term = new Terminal({
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize,
|
||||
@@ -113,16 +113,28 @@ export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnt
|
||||
window.open(uri, '_blank', 'noopener');
|
||||
}));
|
||||
|
||||
const copySelection = () => {
|
||||
const text = term.getSelection();
|
||||
navigator.clipboard?.writeText(text).then(() => onCopied?.(text), () => {});
|
||||
};
|
||||
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
// Shift+Enter: newline in Claude's prompt (Ctrl+J), not submit.
|
||||
if (event.key === 'Enter' && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||
if (event.type === 'keydown') onShiftEnter();
|
||||
return false;
|
||||
}
|
||||
// Ctrl+C with text selected copies it instead of interrupting (devdesk#51).
|
||||
if (event.type === 'keydown' && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey
|
||||
&& event.code === 'KeyC' && term.hasSelection()) {
|
||||
copySelection();
|
||||
term.clearSelection();
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Shift+C / Ctrl+Shift+V: browser clipboard, like desktop terminals.
|
||||
if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && !event.altKey) {
|
||||
if (event.code === 'KeyC' && term.hasSelection()) {
|
||||
navigator.clipboard?.writeText(term.getSelection()).catch(() => {});
|
||||
copySelection();
|
||||
return false;
|
||||
}
|
||||
if (event.code === 'KeyV') {
|
||||
@@ -135,6 +147,35 @@ export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnt
|
||||
|
||||
term.open(container);
|
||||
|
||||
// devdesk#51: tmux runs with the mouse on (wheel scrolls its history), so a
|
||||
// drag would select inside tmux, which copies and then drops the highlight on
|
||||
// release. A left-button drag in the terminal is made a browser selection
|
||||
// instead (what Shift+drag does): it stays highlighted and is copied on
|
||||
// release. Clicks on tmux's status line (the window tabs) still go to tmux.
|
||||
const screen = () => container.querySelector('.xterm-screen');
|
||||
container.addEventListener('mousedown', (event) => {
|
||||
if (event.button !== 0 || event.shiftKey || event.ddForced || term.modes.mouseTrackingMode === 'none') return;
|
||||
const rect = screen()?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const row = Math.floor(((event.clientY - rect.top) / rect.height) * term.rows);
|
||||
if (row >= term.rows - 1) return; // status line
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const forced = new MouseEvent('mousedown', {
|
||||
bubbles: true, cancelable: true, view: window, detail: event.detail, button: 0, buttons: event.buttons,
|
||||
clientX: event.clientX, clientY: event.clientY, screenX: event.screenX, screenY: event.screenY,
|
||||
shiftKey: true, ctrlKey: event.ctrlKey, altKey: event.altKey, metaKey: event.metaKey,
|
||||
});
|
||||
forced.ddForced = true;
|
||||
event.target.dispatchEvent(forced);
|
||||
}, true);
|
||||
// Copy when the drag (or double/triple click) ends with something selected.
|
||||
container.addEventListener('mouseup', () => {
|
||||
setTimeout(() => {
|
||||
if (term.hasSelection() && term.getSelection().trim()) copySelection();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Touch devices: the DOM renderer is more reliable at high DPR than WebGL.
|
||||
if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit };
|
||||
try {
|
||||
|
||||
+86
-5
@@ -24,13 +24,21 @@ function dismiss(node) {
|
||||
setTimeout(() => node.remove(), 250);
|
||||
}
|
||||
|
||||
export function toast(message, type = 'info', duration = 4000) {
|
||||
// `actions`: [{ label, run }] buttons (e.g. Undo); clicking one dismisses the toast.
|
||||
export function toast(message, type = 'info', duration = 4000, actions = []) {
|
||||
const box = toastContainer();
|
||||
// At most four on screen: the oldest goes first.
|
||||
while (box.children.length >= 4) box.firstElementChild.remove();
|
||||
const node = el('div', { class: `lt-toast lt-toast-${type}`, role: type === 'error' ? 'alert' : 'status' },
|
||||
el('span', { class: 'lt-toast-icon', 'aria-hidden': 'true' }, `[${ICONS[type] || 'i'}]`),
|
||||
el('span', { class: 'lt-toast-msg' }, message),
|
||||
...actions.map(({ label, run }) => el('button', {
|
||||
type: 'button', class: 'lt-toast-action',
|
||||
onclick: () => {
|
||||
dismiss(node);
|
||||
run();
|
||||
},
|
||||
}, label)),
|
||||
el('button', { type: 'button', class: 'lt-toast-close', 'aria-label': 'Dismiss', onclick: () => dismiss(node) }, '✕'),
|
||||
el('div', { class: 'lt-toast-progress', style: `animation-duration:${duration}ms` }));
|
||||
box.append(node);
|
||||
@@ -39,10 +47,83 @@ export function toast(message, type = 'info', duration = 4000) {
|
||||
node.addEventListener('mouseleave', () => { node.dismissTimer = setTimeout(() => dismiss(node), 1500); });
|
||||
}
|
||||
|
||||
toast.success = (message, duration) => toast(message, 'success', duration);
|
||||
toast.error = (message, duration = 8000) => toast(message, 'error', duration);
|
||||
toast.warning = (message, duration) => toast(message, 'warning', duration);
|
||||
toast.info = (message, duration) => toast(message, 'info', duration);
|
||||
toast.success = (message, duration, actions) => toast(message, 'success', duration, actions);
|
||||
toast.error = (message, duration = 8000, actions) => toast(message, 'error', duration, actions);
|
||||
toast.warning = (message, duration, actions) => toast(message, 'warning', duration, actions);
|
||||
toast.info = (message, duration, actions) => toast(message, 'info', duration, actions);
|
||||
|
||||
// ---------------------------------------------------------------- context menu
|
||||
|
||||
// A right-click menu at (x, y) (devdesk#54). items: { label, kbd, run, disabled,
|
||||
// danger } or 'sep'. Arrow keys move, Enter runs, Esc / a click elsewhere /
|
||||
// scrolling / resizing closes it. Returns the close function.
|
||||
let closeOpenMenu = null;
|
||||
export function contextMenu(x, y, items) {
|
||||
closeOpenMenu?.();
|
||||
const opener = document.activeElement;
|
||||
const buttons = [];
|
||||
const menu = el('div', { class: 'dd-menu dd-ctx-menu', role: 'menu' }, ...items.filter(Boolean).map((item) => {
|
||||
if (item === 'sep') return el('div', { class: 'dd-menu-sep', role: 'separator' });
|
||||
const button = el('button', {
|
||||
type: 'button', role: 'menuitem', class: `dd-menu-item${item.danger ? ' is-danger' : ''}`, disabled: item.disabled ? true : null,
|
||||
onclick: () => {
|
||||
close(false);
|
||||
item.run();
|
||||
},
|
||||
}, el('span', { class: 'dd-ctx-label' }, item.label), item.kbd ? el('kbd', { class: 'dd-ctx-kbd' }, item.kbd) : null);
|
||||
if (!item.disabled) buttons.push(button);
|
||||
return button;
|
||||
}));
|
||||
const close = (refocus = true) => {
|
||||
if (!menu.isConnected) return;
|
||||
menu.remove();
|
||||
closeOpenMenu = null;
|
||||
document.removeEventListener('pointerdown', outside, true);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
window.removeEventListener('resize', onBlur);
|
||||
document.removeEventListener('scroll', onBlur, true);
|
||||
if (refocus && opener?.isConnected) opener.focus?.();
|
||||
};
|
||||
const outside = (event) => {
|
||||
if (!menu.contains(event.target)) close(false);
|
||||
};
|
||||
const opened = Date.now();
|
||||
const onBlur = (event) => {
|
||||
// Scrolling closes it, but not the layout settling right after it opened.
|
||||
if (event?.type === 'scroll' && (menu.contains(event.target) || Date.now() - opened < 400)) return;
|
||||
close(false);
|
||||
};
|
||||
menu.addEventListener('keydown', (event) => {
|
||||
const i = buttons.indexOf(document.activeElement);
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const next = buttons[(i + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length];
|
||||
next?.focus();
|
||||
} else if (event.key === 'Home' || event.key === 'End') {
|
||||
event.preventDefault();
|
||||
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus();
|
||||
} else if (event.key === 'Escape' || event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close();
|
||||
}
|
||||
});
|
||||
menu.addEventListener('contextmenu', (event) => event.preventDefault());
|
||||
document.body.append(menu);
|
||||
// Keep it on screen.
|
||||
const { width, height } = menu.getBoundingClientRect();
|
||||
menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - width - 4))}px`;
|
||||
menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - height - 4))}px`;
|
||||
buttons[0]?.focus();
|
||||
setTimeout(() => {
|
||||
document.addEventListener('pointerdown', outside, true);
|
||||
window.addEventListener('blur', onBlur);
|
||||
window.addEventListener('resize', onBlur);
|
||||
document.addEventListener('scroll', onBlur, true);
|
||||
}, 0);
|
||||
closeOpenMenu = close;
|
||||
return close;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- dialogs
|
||||
|
||||
|
||||
+112
-6
@@ -239,6 +239,13 @@ body.dd-sidebar-open .dd-sidebar {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: var(--space-sm);
|
||||
/* Any number of sessions: the list scrolls on its own so the panels below stay
|
||||
in reach (devdesk#53). */
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--bg-tertiary) transparent;
|
||||
}
|
||||
|
||||
.dd-session-item {
|
||||
@@ -375,6 +382,7 @@ body.dd-sidebar-open .dd-sidebar {
|
||||
}
|
||||
.dd-new-session .lt-input { grid-column: 1 / -1; font-size: 0.74rem; }
|
||||
.dd-new-session .lt-select { min-width: 0; font-size: 0.72rem; }
|
||||
.dd-new-session .dd-check { grid-column: 1 / -1; }
|
||||
|
||||
.dd-status.is-error { color: var(--accent-red); }
|
||||
|
||||
@@ -497,13 +505,17 @@ body.dd-sidebar-open .dd-sidebar {
|
||||
.dd-footer .lt-footer-hint { font-size: 0.9rem; }
|
||||
.dd-footer .lt-footer-sep { font-size: 0.9rem; }
|
||||
/* Narrower windows keep the usage meters and drop the least-used hints. */
|
||||
@media (max-width: 1400px) {
|
||||
@media (max-width: 1700px) {
|
||||
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 13),
|
||||
.dd-footer .lt-footer-sep:nth-child(n + 12) { display: none; }
|
||||
}
|
||||
@media (max-width: 1150px) {
|
||||
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 9),
|
||||
.dd-footer .lt-footer-sep:nth-child(n + 8) { display: none; }
|
||||
@media (max-width: 1500px) {
|
||||
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 11),
|
||||
.dd-footer .lt-footer-sep:nth-child(n + 10) { display: none; }
|
||||
}
|
||||
@media (max-width: 1250px) {
|
||||
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 7),
|
||||
.dd-footer .lt-footer-sep:nth-child(n + 6) { display: none; }
|
||||
}
|
||||
|
||||
.dd-keybar { display: flex; gap: 4px; width: 100%; overflow-x: auto; scrollbar-width: none; }
|
||||
@@ -787,7 +799,7 @@ body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transpar
|
||||
.dd-explorer-resizer { flex: 0 0 4px; margin-left: -2px; margin-right: -2px; cursor: col-resize; z-index: 2; }
|
||||
.dd-explorer-resizer:hover { background: var(--accent-cyan); }
|
||||
.dd-explorer-views { display: flex; border-bottom: 1px solid var(--border-dim); }
|
||||
.dd-explorer-views button {
|
||||
.dd-explorer-views button[role="tab"] {
|
||||
flex: 1;
|
||||
padding: 6px 4px 5px;
|
||||
border: 0;
|
||||
@@ -800,7 +812,7 @@ body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transpar
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dd-explorer-views button:hover { color: var(--text-primary); }
|
||||
.dd-explorer-views button[role="tab"]:hover { color: var(--text-primary); }
|
||||
.dd-explorer-views button[aria-selected="true"] { color: var(--accent-orange); border-bottom-color: var(--accent-orange); }
|
||||
.dd-files-count:not(:empty) { margin-left: 3px; padding: 0 5px; border-radius: 8px; background: var(--accent-amber-dim); color: var(--accent-amber); font-size: 0.6rem; }
|
||||
.dd-explorer-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 0 12px; outline: none; }
|
||||
@@ -1026,6 +1038,16 @@ html[data-theme="light"] .dd-files.is-narrow .dd-explorer { box-shadow: 8px 0 24
|
||||
.dd-footer-usage .dd-meter { gap: 7px; color: var(--text-primary); font-family: var(--font-mono); font-size: 1rem; font-weight: 600; }
|
||||
.dd-footer-usage .dd-meter-label { font-weight: 400; }
|
||||
.dd-footer-usage .dd-meter-bar { width: 80px; height: 8px; }
|
||||
.dd-meter-reset { color: var(--text-muted); font-weight: 400; }
|
||||
.dd-meter-pace { color: var(--accent-amber); }
|
||||
/* Turn timer in the session chip (devdesk#48) */
|
||||
.dd-turn-timer { margin-left: 6px; color: var(--accent-amber); font-family: var(--font-mono); font-size: 0.85em; }
|
||||
.dd-turn-timer[hidden] { display: none; }
|
||||
.dd-session-actions .dd-compact { color: var(--accent-amber); }
|
||||
.dd-wt { color: var(--accent-cyan); }
|
||||
.dd-check { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 0.74rem; cursor: pointer; }
|
||||
.dd-check[hidden] { display: none; }
|
||||
.dd-check input { accent-color: var(--accent-cyan); }
|
||||
.dd-meter-label { color: var(--text-muted); }
|
||||
.dd-meter-bar { width: 42px; height: 4px; background: var(--bg-tertiary); }
|
||||
.dd-meter-fill { display: block; height: 100%; background: var(--accent-cyan); }
|
||||
@@ -1038,3 +1060,87 @@ body.dd-touch .dd-footer-usage { display: none; }
|
||||
.dd-pr { margin-left: 6px; padding: 0 4px; border: 1px solid var(--border-color); color: var(--text-secondary); font-size: 0.58rem; font-weight: 400; text-decoration: none; vertical-align: middle; }
|
||||
.dd-pr.is-approved { border-color: var(--accent-green-border); color: var(--accent-green); }
|
||||
.dd-pr.is-changes_requested { border-color: rgba(255, 45, 85, 0.4); color: var(--accent-red); }
|
||||
|
||||
/* Repo panel: changed files open as diffs (devdesk#46) */
|
||||
.dd-repo-changes { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||
.dd-repo-changes[hidden] { display: none; }
|
||||
.dd-repo-changes .dd-muted { font-size: 0.68rem; padding: 2px 6px; }
|
||||
.dd-repo-change { display: flex; width: 100%; gap: 8px; align-items: baseline; padding: 3px 6px; border: 0; background: none; color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.72rem; text-align: left; cursor: pointer; }
|
||||
.dd-repo-change:hover, .dd-repo-change:focus-visible { background: var(--bg-tertiary); color: var(--accent-cyan); }
|
||||
.dd-repo-change-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dd-repo-change-stat { flex: none; color: var(--text-muted); }
|
||||
.dd-link-btn { padding: 0; border: 0; background: none; color: var(--accent-cyan); font: inherit; text-decoration: underline dotted; cursor: pointer; }
|
||||
|
||||
/* Toast actions (Undo) and right-click menus (devdesk#54) */
|
||||
.lt-toast-action { flex: none; padding: 2px 8px; border: 1px solid currentColor; background: none; color: inherit; font: inherit; font-size: 0.72rem; cursor: pointer; }
|
||||
.lt-toast-action:hover { background: var(--accent-cyan-dim); }
|
||||
.dd-ctx-menu { position: fixed; top: 0; left: 0; z-index: 200; min-width: 220px; max-height: 80vh; }
|
||||
.dd-ctx-menu .dd-menu-item { justify-content: space-between; padding: 5px 10px; }
|
||||
.dd-ctx-menu .dd-menu-item.is-danger { color: var(--accent-red); }
|
||||
.dd-ctx-kbd { margin-left: 24px; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.66rem; }
|
||||
.dd-menu-sep { height: 1px; margin: 4px 6px; background: var(--border-dim); }
|
||||
|
||||
/* Explorer operations (devdesk#54) */
|
||||
.dd-explorer-actions { display: flex; align-items: center; gap: 1px; padding: 0 4px; border-left: 1px solid var(--border-dim); }
|
||||
.dd-explorer-actions .dd-icon-btn { width: 24px; height: 24px; padding: 0; font-size: 0.82rem; line-height: 1; }
|
||||
.dd-files:not([data-view="explorer"]) .dd-explorer-actions { display: none; }
|
||||
.dd-tree-row.is-selected { background: var(--accent-cyan-dim); }
|
||||
.dd-tree-row.is-selected.is-active { box-shadow: inset 2px 0 0 var(--accent-cyan); }
|
||||
.dd-explorer-body:focus-within .dd-tree-row.is-selected { background: rgba(0, 150, 220, 0.22); }
|
||||
.dd-tree-row.is-cut { opacity: 0.45; }
|
||||
.dd-tree-row.is-drop { background: var(--accent-orange-dim); }
|
||||
.dd-tree-row.is-drop.is-dir { box-shadow: inset 0 0 0 1px var(--accent-orange); }
|
||||
.dd-explorer-body.is-drop { background: var(--accent-orange-dim); box-shadow: inset 0 0 0 1px var(--accent-orange); }
|
||||
.dd-tree-row.is-editing { cursor: default; background: var(--bg-tertiary); }
|
||||
.dd-tree-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--accent-cyan);
|
||||
background: var(--bg-input, var(--bg-primary));
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.dd-upload-bar { display: flex; flex-direction: column; gap: 4px; padding: 6px 10px; border-top: 1px solid var(--border-dim); font-size: 0.68rem; color: var(--text-secondary); }
|
||||
.dd-upload-bar[hidden] { display: none; }
|
||||
.dd-upload-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dd-upload-meter { height: 3px; background: var(--bg-tertiary); }
|
||||
.dd-upload-fill { display: block; width: 0; height: 100%; background: var(--accent-cyan); transition: width 0.15s linear; }
|
||||
.dd-drag-ghost { position: fixed; top: -100px; left: -100px; padding: 3px 10px; background: var(--accent-cyan); color: var(--bg-primary); font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700; }
|
||||
|
||||
/* Markdown preview and split, editor status bar (devdesk#55) */
|
||||
.dd-viewer-content.is-preview { overflow: hidden; }
|
||||
.dd-md-host { flex: 1; min-height: 0; outline: none; }
|
||||
.dd-viewer-content.is-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
.dd-split-editor { min-width: 0; height: 100%; overflow: hidden; border-right: 1px solid var(--border-dim); }
|
||||
.dd-split-editor .cm-editor { height: 100%; }
|
||||
.dd-split-preview { min-width: 0; }
|
||||
.dd-editor-status { display: flex; align-items: center; gap: 14px; flex: none; height: 22px; padding: 0 10px; border-top: 1px solid var(--border-dim); background: var(--bg-secondary); color: var(--text-muted); font-family: var(--font-mono); font-size: 0.66rem; white-space: nowrap; overflow: hidden; }
|
||||
.dd-status-pos { color: var(--text-secondary); }
|
||||
.dd-status-sel { color: var(--accent-cyan); }
|
||||
.dd-status-spacer { flex: 1; }
|
||||
|
||||
/* Search in files (devdesk#56) */
|
||||
.dd-search { display: flex; flex-direction: column; min-height: 100%; }
|
||||
.dd-search-box { display: flex; gap: 2px; padding: 6px 8px; border-bottom: 1px solid var(--border-dim); }
|
||||
.dd-search-input { flex: 1; min-width: 0; height: 26px; padding: 0 6px; font-family: var(--font-mono); font-size: 0.74rem; }
|
||||
.dd-search-flag { flex: none; width: 26px; height: 26px; padding: 0; border: 1px solid transparent; background: none; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.7rem; cursor: pointer; }
|
||||
.dd-search-flag:hover { color: var(--text-primary); }
|
||||
.dd-search-flag[aria-pressed="true"] { border-color: var(--accent-cyan); background: var(--accent-cyan-dim); color: var(--accent-cyan); }
|
||||
.dd-search-summary { padding: 4px 10px; color: var(--text-muted); font-size: 0.66rem; }
|
||||
.dd-search-file { display: flex; align-items: center; gap: 6px; width: 100%; padding: 3px 8px; border: 0; background: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 0.74rem; text-align: left; cursor: pointer; }
|
||||
.dd-search-file:hover { background: var(--bg-tertiary); }
|
||||
.dd-search-file::before { content: '▾'; width: 10px; color: var(--text-muted); font-size: 0.66rem; }
|
||||
.dd-search-file[aria-expanded="false"]::before { content: '▸'; }
|
||||
.dd-search-file .dd-tree-name { flex: none; }
|
||||
.dd-search-file .dd-change-dir { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dd-search-count { flex: none; padding: 0 6px; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.62rem; }
|
||||
.dd-search-lines[hidden] { display: none; }
|
||||
.dd-search-line { display: flex; gap: 8px; padding: 2px 8px 2px 30px; color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.72rem; white-space: nowrap; cursor: pointer; outline: none; }
|
||||
.dd-search-line:hover, .dd-search-line:focus { background: var(--bg-tertiary); color: var(--text-primary); }
|
||||
.dd-search-line:focus { box-shadow: inset 0 0 0 1px var(--accent-cyan); }
|
||||
.dd-search-ln { flex: none; min-width: 2.5em; color: var(--text-muted); text-align: right; }
|
||||
.dd-search-text { overflow: hidden; text-overflow: ellipsis; }
|
||||
.dd-search-text mark { background: var(--accent-amber-dim); color: var(--accent-amber); outline: 1px solid color-mix(in srgb, var(--accent-amber) 50%, transparent); }
|
||||
|
||||
Reference in New Issue
Block a user