Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20134c1e58 | ||
|
|
65114165e7 | ||
|
|
e60f1ad066 | ||
|
|
ab7e127f70 | ||
|
|
24d71131e8 |
@@ -41,7 +41,7 @@ install: all
|
|||||||
install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \
|
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-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
|
||||||
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix libexec/devdesk-guard $(DESTDIR)$(LIBDIR)/
|
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)/
|
$(DESTDIR)$(LIBDIR)/
|
||||||
install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd
|
install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd
|
||||||
install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
|
install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
|
||||||
|
|||||||
@@ -51,12 +51,22 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
|||||||
- **`devdesk guests [ID|IP|NAME]`** lists Proxmox CTs/VMs with the node each runs on now, status, HA state and IPs. Claude is told to use it before acting, because HA guests move. Stopped guests keep the IP they had when last seen running.
|
- **`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.
|
- Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`. For the guest lookup, set `DEVDESK_PVE_NODES` to a few Proxmox nodes reachable by root SSH key. VM IPs need the QEMU guest agent.
|
||||||
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. The first changed files are listed; click one for its diff against HEAD. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log.
|
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. The first changed files are listed; click one for its diff against HEAD. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log.
|
||||||
- **Files** (Ctrl+Shift+E), laid out like VS Code: an explorer tree (git colours, gitignored entries dimmed) and an editor with tabs (a click opens a preview tab, a double-click keeps it), **Go to file** (Ctrl+P in the pane), markdown previews (sanitized, with relative images), images, and a Changes view of uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt. Tabs and open folders are remembered per session. Paths can't leave the session folder.
|
- **Files** (Ctrl+Shift+E), laid out like VS Code:
|
||||||
|
- **Explorer tree:** git colours, gitignored entries dimmed.
|
||||||
|
- **Mouse:** right-click menus (new file/folder, rename, delete, cut/copy/paste, duplicate, copy path, download, @Claude), Ctrl/Shift+click to select several, drag to move (Ctrl+drag copies). Drop files or whole folders from the desktop to upload them (up to 25 MB each), or drag a file onto the terminal to type `@path`.
|
||||||
|
- **Keyboard:** F2 renames in place, Delete trashes, Ctrl+C/X/V copy and paste files.
|
||||||
|
- **Safety:** delete goes to a 7-day trash with **Undo**; nothing is overwritten unless you choose Replace.
|
||||||
|
- **Editor:** tabs (a click opens a preview tab, a double-click keeps it; right-click for close variants; drag to reorder), **Go to file** (Ctrl+P in the pane), and **Search in files** (Ctrl+Shift+F: match case, whole word, regex). A status bar shows line/column, cursors, indentation, line endings and language. Multiple cursors (Alt+click), column select (Alt+drag), auto-closing brackets, Ctrl+G to go to a line.
|
||||||
|
- **Markdown preview:** GitHub-style headings with anchors and an outline, syntax-coloured code with Copy, callouts, task lists, front matter, images, and links that open files in DevDesk. **Split** shows the source and a live preview side by side.
|
||||||
|
- **Changes view:** uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt.
|
||||||
|
- **Remembered and contained:** tabs and open folders are remembered per session, and paths can't leave the session folder.
|
||||||
- **Editing:** text files are editable; ● marks unsaved tabs; Ctrl+S (or Save) writes atomically and keeps the file's mode and owner. If the file changed on disk since you opened it (Claude edited it), saving asks: Compare (a diff of disk vs yours), Overwrite or Cancel; a change on disk while you edit shows a banner instead of replacing your text. Unsaved edits are kept as drafts in the browser, so a reload or a session switch doesn't lose them. + creates a file (folders as needed). The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
|
- **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.
|
- **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).
|
- **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.
|
- **Search past conversations** (⋯ menu, palette): full text over every Claude conversation on the host; **Resume** it in a new session or **Fork** it.
|
||||||
- **Plan usage:** the 5-hour and weekly windows as meters in the footer, with when each resets (`↻ 14:30`). If the pace so far would use a window up before it resets, the meter shows `⚠ full ~13:50` instead. A toast warns at 90 %, and Matrix gets one message per window when you're away (`DEVDESK_USAGE_WARN`). Sessions also get a **PR** chip when their branch has a pull request.
|
- **Plan usage:** the 5-hour and weekly windows as meters in the footer, with when each resets (`↻ 14:30`). If the pace so far would use a window up before it resets, the meter shows `⚠ full ~13:50` instead. A toast warns at 90 %, and Matrix gets one message per window when you're away (`DEVDESK_USAGE_WARN`). Sessions also get a **PR** chip when their branch has a pull request.
|
||||||
|
- **Settings** (⋯ → Settings…): theme, terminal text size and interface size (per browser), desktop notifications, and **Remote Control per session**. With Remote Control on, a session also shows in the Claude app and at claude.ai/code, and Anthropic stores its transcript to sync devices (30 days, or 5 years if "Help improve Claude" is on). It's **off for new sessions** unless you change the default. Each running session has its own switch, which restarts that Claude on the same conversation when it's idle. Sessions that sync show an **RC** tag.
|
||||||
|
- **Delete a conversation:** Search past conversations → **Delete…** removes its local transcript (`~/.claude/projects`) for good; a copy synced by Remote Control is deleted at claude.ai/code.
|
||||||
- **Quiet phone at the desk:** while a tab shows a session, Remote Control holds back phone pushes for it (client-presence marker, expires ~2 min after you leave).
|
- **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.
|
- **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).
|
- **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).
|
||||||
@@ -71,7 +81,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 0 / 1 | Claude window / shell window |
|
||||||
| Ctrl-b s | Switch session (the URL follows) |
|
| Ctrl-b s | Switch session (the URL follows) |
|
||||||
| Ctrl-b d | Detach (the session keeps running) |
|
| 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+C / V | Copy selection / paste |
|
||||||
| Ctrl+Shift+P | Command palette (also the ⌕ button) |
|
| Ctrl+Shift+P | Command palette (also the ⌕ button) |
|
||||||
| Ctrl+Shift+E | Files pane (Esc: back, then close) |
|
| Ctrl+Shift+E | Files pane (Esc: back, then close) |
|
||||||
@@ -79,12 +93,12 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
|||||||
The **☀/◐** button switches light and dark mode (saved per browser, shared with the other web_template apps' `lt_theme` preference). Text size, desktop notifications (with a mute), the keyboard shortcuts sheet and fullscreen are in the **⋯** menu; everything is also in the command palette. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
|
The **☀/◐** button switches light and dark mode (saved per browser, shared with the other web_template apps' `lt_theme` preference). Text size, desktop notifications (with a mute), the keyboard shortcuts sheet and fullscreen are in the **⋯** menu; everything is also in the command palette. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_REMOTE_CONTROL=1`.
|
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_GUARD=0`.
|
||||||
- `/etc/devdesk/env` holds the packaged defaults; don't edit it. Its keys:
|
- `/etc/devdesk/env` holds the packaged defaults; don't edit it. Its keys:
|
||||||
- port, bind address, projects root and `DEVDESK_DEFAULT_DIR`
|
- port, bind address, projects root and `DEVDESK_DEFAULT_DIR`
|
||||||
- `DEVDESK_GITEA_URL` / `DEVDESK_GITEA_ALIASES` for the Repo/CI panel. Public repos need no token; for private ones put a `read:repository` token in `/etc/devdesk/gitea-token`
|
- `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`
|
- `CLAUDE_BIN` and `CLAUDE_ARGS`
|
||||||
- `DEVDESK_REMOTE_CONTROL=1`, which also starts each session with Claude Remote Control so it can be continued from the Claude app
|
- `DEVDESK_REMOTE_CONTROL=1`: the Remote Control default for new sessions until one is chosen in Settings (which then wins; each session can also choose its own)
|
||||||
- `DEVDESK_PUBLIC_URL`, `DEVDESK_MATRIX_WEBHOOK_FILE`, `DEVDESK_MATRIX_DELAY`, `DEVDESK_MATRIX_QUIET` and `DEVDESK_USAGE_WARN` for the Matrix notifications
|
- `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/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`.
|
- `/etc/devdesk/tmux.local.conf` is optional and is sourced after the packaged `tmux.conf`.
|
||||||
|
|||||||
+104
-5
@@ -15,7 +15,7 @@
|
|||||||
#
|
#
|
||||||
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --investigate SESSION UID |
|
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --investigate SESSION UID |
|
||||||
# --upload SESSION FILE BYTES | --new NAME [PROJECT] [--worktree] | --rename OLD NEW | --end NAME |
|
# --upload SESSION FILE BYTES | --new NAME [PROJECT] [--worktree] | --rename OLD NEW | --end NAME |
|
||||||
# --last-reply NAME | --existing NAME`
|
# --last-reply NAME | --unsplit NAME | --fs-* SESSION … | --existing NAME`
|
||||||
# through ttyd; those print JSON.
|
# through ttyd; those print JSON.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -29,6 +29,8 @@ set -euo pipefail
|
|||||||
. "$DEVDESK_LIBEXEC/devdesk-history.sh"
|
. "$DEVDESK_LIBEXEC/devdesk-history.sh"
|
||||||
# shellcheck source=libexec/devdesk-ops.sh
|
# shellcheck source=libexec/devdesk-ops.sh
|
||||||
. "$DEVDESK_LIBEXEC/devdesk-ops.sh"
|
. "$DEVDESK_LIBEXEC/devdesk-ops.sh"
|
||||||
|
# shellcheck source=libexec/devdesk-fsops.sh
|
||||||
|
. "$DEVDESK_LIBEXEC/devdesk-fsops.sh"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
sed -n '2,13s/^# \{0,1\}//p' "$0"
|
sed -n '2,13s/^# \{0,1\}//p' "$0"
|
||||||
@@ -78,6 +80,8 @@ cmd_list() {
|
|||||||
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
|
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
|
||||||
--argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \
|
--argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \
|
||||||
--argjson tags "$(per_session_json "$DEVDESK_STATE_DIR/tags")" \
|
--argjson tags "$(per_session_json "$DEVDESK_STATE_DIR/tags")" \
|
||||||
|
--arg rclive "$(dd_rc_live)" \
|
||||||
|
--argjson rcdef "$(dd_rc_default)" \
|
||||||
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
|
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
|
||||||
--arg root "$DEVDESK_PROJECTS_ROOT" '
|
--arg root "$DEVDESK_PROJECTS_ROOT" '
|
||||||
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
|
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
|
||||||
@@ -96,7 +100,10 @@ cmd_list() {
|
|||||||
activity: null, windows: 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)}))
|
worktree: (.value.worktree // null)}))
|
||||||
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // [])}),
|
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // []),
|
||||||
|
remote_control: (($known[.name].remote_control) as $own | if $own == null then $rcdef else $own end),
|
||||||
|
rc_active: (.name as $n | $rclive | split("\n") | any(.[]; . == $n))}),
|
||||||
|
remote_control_default: $rcdef,
|
||||||
projects: ([{name: $dflt, dir: $root}]
|
projects: ([{name: $dflt, dir: $root}]
|
||||||
+ [rows($proj)[] | {name: .[0], git: (.[1] == "1"), dir: (.[2:] | 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}$"))])}'
|
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
|
||||||
@@ -164,7 +171,7 @@ dd_worktree_add() {
|
|||||||
else
|
else
|
||||||
"${g[@]}" worktree add -q -b "$branch" "$wt" HEAD >&2 || dd_die "git worktree add failed"
|
"${g[@]}" worktree add -q -b "$branch" "$wt" HEAD >&2 || dd_die "git worktree add failed"
|
||||||
fi
|
fi
|
||||||
wt=$(realpath -e -- "$wt")
|
wt=$(realpath -e -- "$wt") || dd_die "git worktree add failed"
|
||||||
# shellcheck disable=SC2016 # jq variables
|
# shellcheck disable=SC2016 # jq variables
|
||||||
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {worktree: {path: $w, repo: $r, branch: $b}})' \
|
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"
|
--arg n "$name" --arg w "$wt" --arg r "$top" --arg b "$branch"
|
||||||
@@ -182,6 +189,97 @@ dd_worktree_end() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --unsplit NAME (devdesk#52): close the extra panes a split left in the session's
|
||||||
|
# windows. Each window keeps one pane: Claude's in the claude window, else the
|
||||||
|
# first. Prints {ok, closed}.
|
||||||
|
cmd_unsplit() {
|
||||||
|
[ $# -eq 1 ] || dd_die "usage: devdesk attach --unsplit NAME"
|
||||||
|
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
||||||
|
{ dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } || dd_die "session '$1' isn't running"
|
||||||
|
local win keep pane closed=0
|
||||||
|
while read -r win; do
|
||||||
|
keep=$(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id} #{pane_start_command}' | awk '/devdesk-claude-run/ { print $1; exit }')
|
||||||
|
[ -n "$keep" ] || keep=$(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id}' | head -n 1)
|
||||||
|
while read -r pane; do
|
||||||
|
[ "$pane" = "$keep" ] && continue
|
||||||
|
dd_tmux kill-pane -t "$pane" && closed=$((closed + 1))
|
||||||
|
done < <(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id}')
|
||||||
|
done < <(dd_tmux list-windows -t "=$1" -F '#{window_index}')
|
||||||
|
jq -cMn --argjson c "$closed" '{ok: true, closed: $c}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sessions whose running Claude has Remote Control on, read from the processes
|
||||||
|
# themselves (devdesk#61), one name per line.
|
||||||
|
dd_rc_live() {
|
||||||
|
local session window pid child
|
||||||
|
dd_server_up || return 0
|
||||||
|
while IFS=$'\t' read -r session window pid; do
|
||||||
|
[ "$window" = claude ] || continue
|
||||||
|
for child in $(pgrep -P "$pid" 2>/dev/null); do
|
||||||
|
if tr '\0' '\n' <"/proc/$child/cmdline" 2>/dev/null | grep -qx -- --remote-control; then
|
||||||
|
printf '%s\n' "$session"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done < <(dd_tmux list-panes -a -F $'#{session_name}\t#{window_name}\t#{pane_pid}' 2>/dev/null)
|
||||||
|
}
|
||||||
|
|
||||||
|
# The Claude process of a session's claude window, if any.
|
||||||
|
dd_claude_pid() {
|
||||||
|
local pid
|
||||||
|
pid=$(dd_tmux list-panes -t "=$1:claude" -F '#{pane_pid}' 2>/dev/null | head -n1)
|
||||||
|
[ -n "$pid" ] && pgrep -P "$pid" 2>/dev/null | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --remote-control NAME on|off (devdesk#61): the session's own choice. A running,
|
||||||
|
# idle Claude restarts on the same conversation right away; one that is working
|
||||||
|
# or waiting gets it at its next start. Prints {ok, remote_control, applied}.
|
||||||
|
cmd_remote_control() {
|
||||||
|
[ $# -eq 2 ] || dd_die "usage: devdesk attach --remote-control NAME on|off"
|
||||||
|
local name=$1 value applied=saved pid state
|
||||||
|
dd_valid_name "$name" || dd_die "invalid session name: $name"
|
||||||
|
case $2 in on) value=true ;; off) value=false ;; *) dd_die "on or off" ;; esac
|
||||||
|
{ session_exists "$name"; } || dd_die "no session named '$name'"
|
||||||
|
# shellcheck disable=SC2016 # jq variables
|
||||||
|
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {remote_control: ($v == "true")})' --arg n "$name" --arg v "$value"
|
||||||
|
if dd_server_up && dd_tmux has-session -t "=$name" 2>/dev/null && pid=$(dd_claude_pid "$name") && [ -n "$pid" ]; then
|
||||||
|
state=$(jq -rM '.state // empty' "$DEVDESK_STATUS_DIR/$name.json" 2>/dev/null) || state="" # no state yet: idle
|
||||||
|
if [ "$state" = working ] || [ "$state" = waiting ]; then
|
||||||
|
applied=next-start
|
||||||
|
else
|
||||||
|
mkdir -p "$DEVDESK_STATE_DIR/restart"
|
||||||
|
touch "$DEVDESK_STATE_DIR/restart/$name"
|
||||||
|
kill -TERM "$pid" 2>/dev/null || true
|
||||||
|
applied=now
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
jq -cMn --argjson v "$value" --arg a "$applied" '{ok: true, remote_control: $v, applied: $a}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remote Control default for new sessions: Settings, else DEVDESK_REMOTE_CONTROL. true/false.
|
||||||
|
dd_rc_default() {
|
||||||
|
local def
|
||||||
|
def=$(jq -rM '.remote_control_default | if . == null then empty else tostring end' "$DEVDESK_STATE_DIR/settings.json" 2>/dev/null)
|
||||||
|
if [ -n "$def" ]; then echo "$def"; elif [ "${DEVDESK_REMOTE_CONTROL:-0}" = 1 ]; then echo true; else echo false; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --settings [remote-control-default on|off] (devdesk#61): DevDesk-wide settings
|
||||||
|
# kept in $DEVDESK_STATE_DIR/settings.json; prints them.
|
||||||
|
cmd_settings() {
|
||||||
|
local file="$DEVDESK_STATE_DIR/settings.json" tmp
|
||||||
|
if [ $# -eq 2 ]; then
|
||||||
|
[ "$1" = remote-control-default ] || dd_die "unknown setting: $1"
|
||||||
|
case $2 in on | off) ;; *) dd_die "on or off" ;; esac
|
||||||
|
tmp=$(mktemp "$DEVDESK_STATE_DIR/.settings.XXXXXX")
|
||||||
|
jq -cM --argjson v "$([ "$2" = on ] && echo true || echo false)" '. + {remote_control_default: $v}' "$file" 2>/dev/null >"$tmp" ||
|
||||||
|
jq -cMn --argjson v "$([ "$2" = on ] && echo true || echo false)" '{remote_control_default: $v}' >"$tmp"
|
||||||
|
mv -f "$tmp" "$file"
|
||||||
|
elif [ $# -ne 0 ]; then
|
||||||
|
dd_die "usage: devdesk attach --settings [remote-control-default on|off]"
|
||||||
|
fi
|
||||||
|
jq -cMn --argjson d "$(dd_rc_default)" '{ok: true, remote_control_default: $d}'
|
||||||
|
}
|
||||||
|
|
||||||
cmd_rename() {
|
cmd_rename() {
|
||||||
[ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW"
|
[ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW"
|
||||||
local old=$1 new=$2 running=0
|
local old=$1 new=$2 running=0
|
||||||
@@ -359,14 +457,15 @@ cmd_attach() {
|
|||||||
dd_linger
|
dd_linger
|
||||||
return
|
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#--}
|
local action=${1#--}
|
||||||
shift
|
shift
|
||||||
"cmd_${action//-/_}" "$@"
|
"cmd_${action//-/_}" "$@"
|
||||||
dd_linger
|
dd_linger
|
||||||
return
|
return
|
||||||
;;
|
;;
|
||||||
--peek | --search | --last-reply)
|
--peek | --search | --last-reply | --unsplit | --remote-control | --settings | --delete-convo)
|
||||||
local action=${1#--}
|
local action=${1#--}
|
||||||
shift
|
shift
|
||||||
"cmd_${action//-/_}" "$@"
|
"cmd_${action//-/_}" "$@"
|
||||||
|
|||||||
Vendored
+79
@@ -1,3 +1,82 @@
|
|||||||
|
devdesk (0.12.0) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* Settings window, from the ⋯ menu or the palette (#61):
|
||||||
|
- Theme, terminal text size and interface size (per browser).
|
||||||
|
- Desktop notifications.
|
||||||
|
- Remote Control and privacy.
|
||||||
|
The ⋯ menu shrinks to five entries.
|
||||||
|
* Remote Control per session (#61):
|
||||||
|
- A default for new sessions and a switch for each running session.
|
||||||
|
- Switching an idle session restarts its Claude on the same
|
||||||
|
conversation; a busy one changes at its next start.
|
||||||
|
- Sessions that sync show an RC tag, read from the running process.
|
||||||
|
- Server side: --remote-control and --settings.
|
||||||
|
* Delete a conversation from Search past conversations (#61): its local
|
||||||
|
transcript and subagent files, for good (--delete-convo). Refused
|
||||||
|
while a running session uses it.
|
||||||
|
|
||||||
|
-- Jared Vititoe <jjvititoe1@gmail.com> Tue, 03 Nov 2026 21:25:00 -0500
|
||||||
|
|
||||||
|
devdesk (0.11.0) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* Explorer works like VS Code's (#54):
|
||||||
|
- Right-click menus for files, folders and empty space;
|
||||||
|
Ctrl/Shift+click selects several.
|
||||||
|
- Inline rename (F2) and inline new file/folder.
|
||||||
|
- Drag to move, Ctrl+drag to copy.
|
||||||
|
- Drop files or whole folders from the desktop to upload them, asking
|
||||||
|
Replace / Skip / Keep both on a clash.
|
||||||
|
- Cut/copy/paste files, duplicate, download, copy path, @Claude.
|
||||||
|
- Delete moves to a 7-day trash with Undo; nothing is overwritten
|
||||||
|
unless asked.
|
||||||
|
- Server side: new --fs-mkdir/-move/-copy/-delete/-restore/-put/
|
||||||
|
-download.
|
||||||
|
* Markdown preview rewritten (#55):
|
||||||
|
- GitHub-style headings with anchors and an outline.
|
||||||
|
- Highlighted code with Copy, callouts, task lists, front matter.
|
||||||
|
- Relative links open files in DevDesk.
|
||||||
|
- Split view with a live preview.
|
||||||
|
* Editor (#55): status bar (line/column, cursors, indentation, line
|
||||||
|
endings, language), multiple cursors, column select, auto-closing
|
||||||
|
brackets, Ctrl+G, and tab menus with drag-to-reorder.
|
||||||
|
* Search in files, Ctrl+Shift+F (#56): match case, whole word and regex,
|
||||||
|
via git grep or grep.
|
||||||
|
* Fixes:
|
||||||
|
- A nested path check could escape the session folder: set -e does not
|
||||||
|
reach into $(...).
|
||||||
|
- The tree could show a stale listing.
|
||||||
|
- Session actions now return the server's reply, so "Close split
|
||||||
|
panes" reports correctly.
|
||||||
|
|
||||||
|
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 22 Oct 2026 21:30:00 -0400
|
||||||
|
|
||||||
|
devdesk (0.10.4) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* The Sessions panel's list scrolls on its own (#53). There is no cap on
|
||||||
|
sessions; with many of them, Ops Alerts and Repo/CI stay in reach below
|
||||||
|
the list. The current session is scrolled into view when you switch.
|
||||||
|
|
||||||
|
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 22 Oct 2026 20:53:00 -0400
|
||||||
|
|
||||||
|
devdesk (0.10.3) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* No tmux right-click menus (#52). A stray click on one split a pane, or
|
||||||
|
could kill Claude's window. "Close split panes (keeps Claude)" in the
|
||||||
|
command palette, backed by `--unsplit`, removes a split that happened
|
||||||
|
anyway.
|
||||||
|
|
||||||
|
-- Jared Vititoe <jjvititoe1@gmail.com> Tue, 13 Oct 2026 09:00:00 -0400
|
||||||
|
|
||||||
|
devdesk (0.10.2) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* Selecting text in the terminal works like a normal terminal (#51). A
|
||||||
|
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
|
devdesk (0.10.1) bookworm; urgency=medium
|
||||||
|
|
||||||
* Everything in 0.10.0, which was never published: its CI test for the
|
* Everything in 0.10.0, which was never published: its CI test for the
|
||||||
|
|||||||
@@ -62,5 +62,7 @@ DEVDESK_USAGE_WARN=90
|
|||||||
# /etc/devdesk/guard.local, one "regex<TAB>reason" per line.
|
# /etc/devdesk/guard.local, one "regex<TAB>reason" per line.
|
||||||
DEVDESK_GUARD=1
|
DEVDESK_GUARD=1
|
||||||
|
|
||||||
# 1 = also enable Remote Control so sessions can be continued from the Claude app.
|
# Remote Control default for new sessions (1 = on: continue them from the Claude app;
|
||||||
|
# Anthropic then stores the transcript to sync devices). Settings in the web UI
|
||||||
|
# overrides this, and each session can choose its own (devdesk#61).
|
||||||
DEVDESK_REMOTE_CONTROL=0
|
DEVDESK_REMOTE_CONTROL=0
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ RESET=$'\033[0m'
|
|||||||
|
|
||||||
common_args() {
|
common_args() {
|
||||||
printf '%s\0' --settings "$SETTINGS"
|
printf '%s\0' --settings "$SETTINGS"
|
||||||
if [ "${DEVDESK_REMOTE_CONTROL:-0}" = "1" ]; then
|
# Per session since devdesk#61 (Settings → Remote Control).
|
||||||
|
if [ "$(dd_rc_wanted "$NAME")" = 1 ]; then
|
||||||
printf '%s\0' --remote-control "devdesk-$NAME"
|
printf '%s\0' --remote-control "devdesk-$NAME"
|
||||||
fi
|
fi
|
||||||
if [ ${#EXTRA[@]} -gt 0 ]; then
|
if [ ${#EXTRA[@]} -gt 0 ]; then
|
||||||
@@ -97,6 +98,15 @@ while :; do
|
|||||||
if [ "$mode" = new ]; then run_new; else run_resume; fi
|
if [ "$mode" = new ]; then run_new; else run_resume; fi
|
||||||
rc=$?
|
rc=$?
|
||||||
NAME=$(dd_current_session)
|
NAME=$(dd_current_session)
|
||||||
|
# Settings changed Remote Control: Claude was stopped to restart on the same
|
||||||
|
# conversation with the new setting (devdesk#61), no menu.
|
||||||
|
restart="$DEVDESK_STATE_DIR/restart/$NAME"
|
||||||
|
if [ -e "$restart" ]; then
|
||||||
|
rm -f "$restart"
|
||||||
|
printf '\n%s◈ Restarting Claude with Remote Control %s…%s\n' "$MUTED" "$([ "$(dd_rc_wanted "$NAME")" = 1 ] && echo on || echo off)" "$RESET"
|
||||||
|
mode=resume
|
||||||
|
continue
|
||||||
|
fi
|
||||||
while :; do
|
while :; do
|
||||||
menu "$rc"
|
menu "$rc"
|
||||||
IFS= read -rsn1 key || exit 0
|
IFS= read -rsn1 key || exit 0
|
||||||
|
|||||||
@@ -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]}'
|
||||||
|
}
|
||||||
@@ -115,3 +115,30 @@ cmd_presence() {
|
|||||||
setsid -f bash -c 'sleep 120; [ -n "$(find "$1" -newermt "-110 seconds" 2>/dev/null)" ] || rm -f "$1"' _ "$marker" \
|
setsid -f bash -c 'sleep 120; [ -n "$(find "$1" -newermt "-110 seconds" 2>/dev/null)" ] || rm -f "$1"' _ "$marker" \
|
||||||
</dev/null >/dev/null 2>&1 3>&- || true
|
</dev/null >/dev/null 2>&1 3>&- || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --delete-convo ID (devdesk#61): permanently delete a conversation's local files
|
||||||
|
# (the transcript ~/.claude/projects/<folder>/<id>.jsonl and its <id>/ folder of
|
||||||
|
# subagent transcripts and tool results). Refused while a running session uses
|
||||||
|
# it. Dormant sessions that would resume it forget it (they start fresh).
|
||||||
|
# Copies kept by Anthropic (Remote Control, the API) are not touched: those are
|
||||||
|
# deleted at claude.ai.
|
||||||
|
cmd_delete_convo() {
|
||||||
|
[ $# -eq 1 ] || dd_die "usage: devdesk attach --delete-convo ID"
|
||||||
|
local id=$1 file dir other
|
||||||
|
[[ $id =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || dd_die "bad conversation id: $id"
|
||||||
|
file=$(dd_convo_file "$id") || true
|
||||||
|
{ [ -n "$file" ] && [ -f "$file" ]; } || dd_die "no conversation $id"
|
||||||
|
case "$file" in "$(dd_projects_dir)"/*/"$id.jsonl") ;; *) dd_die "unexpected path: $file" ;; esac
|
||||||
|
for other in $(jq -rM --arg id "$id" '.sessions | to_entries[] | select(.value.claude_session_id == $id) | .key' "$DEVDESK_REGISTRY" 2>/dev/null); do
|
||||||
|
if dd_server_up && dd_tmux has-session -t "=$other" 2>/dev/null; then
|
||||||
|
dd_die "session $other is using this conversation: end it first"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
dir=${file%.jsonl}
|
||||||
|
rm -f -- "$file"
|
||||||
|
[ ! -d "$dir" ] || rm -rf -- "$dir"
|
||||||
|
# shellcheck disable=SC2016 # jq variables
|
||||||
|
dd_registry_update '.sessions |= with_entries(if .value.claude_session_id == $id then .value |= (del(.claude_session_id) | del(.claude_source)) else . end)' \
|
||||||
|
--arg id "$id"
|
||||||
|
jq -cMn --arg id "$id" '{ok: true, deleted: $id}'
|
||||||
|
}
|
||||||
|
|||||||
@@ -183,6 +183,18 @@ dd_registry_set_dir() {
|
|||||||
--arg n "$1" --arg d "$2" --arg t "$(date -Is)"
|
--arg n "$1" --arg d "$2" --arg t "$(date -Is)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Remote Control for session NAME (devdesk#61): its own choice if it made one,
|
||||||
|
# else the default set in Settings (state settings.json), else
|
||||||
|
# DEVDESK_REMOTE_CONTROL from the env files. Prints 1 or 0.
|
||||||
|
dd_rc_wanted() {
|
||||||
|
local own def
|
||||||
|
# (not dd_registry_get: its `// empty` would turn false into "unset")
|
||||||
|
own=$(jq -Mr --arg n "$1" '.sessions[$n].remote_control | if . == null then empty else tostring end' "$DEVDESK_REGISTRY" 2>/dev/null)
|
||||||
|
case $own in true) echo 1; return ;; false) echo 0; return ;; esac
|
||||||
|
def=$(jq -rM '.remote_control_default // empty' "$DEVDESK_STATE_DIR/settings.json" 2>/dev/null)
|
||||||
|
case $def in true) echo 1 ;; false) echo 0 ;; *) [ "${DEVDESK_REMOTE_CONTROL:-0}" = 1 ] && echo 1 || echo 0 ;; esac
|
||||||
|
}
|
||||||
|
|
||||||
dd_registry_set_claude() {
|
dd_registry_set_claude() {
|
||||||
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {claude_session_id: $id, claude_source: $src, updated: $t})' \
|
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {claude_session_id: $id, claude_source: $src, updated: $t})' \
|
||||||
--arg n "$1" --arg id "$2" --arg src "$3" --arg t "$(date -Is)"
|
--arg n "$1" --arg id "$2" --arg src "$3" --arg t "$(date -Is)"
|
||||||
|
|||||||
Generated
+1
@@ -7,6 +7,7 @@
|
|||||||
"name": "devdesk",
|
"name": "devdesk",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@codemirror/autocomplete": "6.20.3",
|
||||||
"@codemirror/commands": "6.11.1",
|
"@codemirror/commands": "6.11.1",
|
||||||
"@codemirror/lang-markdown": "6.5.2",
|
"@codemirror/lang-markdown": "6.5.2",
|
||||||
"@codemirror/language": "6.13.1",
|
"@codemirror/language": "6.13.1",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test": "node --test tests/*.test.mjs"
|
"test": "node --test tests/*.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@codemirror/autocomplete": "6.20.3",
|
||||||
"@codemirror/commands": "6.11.1",
|
"@codemirror/commands": "6.11.1",
|
||||||
"@codemirror/lang-markdown": "6.5.2",
|
"@codemirror/lang-markdown": "6.5.2",
|
||||||
"@codemirror/language": "6.13.1",
|
"@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 default-terminal "tmux-256color"
|
||||||
set -s terminal-features[90] "xterm-256color:RGB" # fixed index: re-sourcing must not append
|
set -s terminal-features[90] "xterm-256color:RGB" # fixed index: re-sourcing must not append
|
||||||
set -g history-limit 50000
|
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 allow-passthrough off # output may not bypass tmux (title/clipboard spoofing)
|
||||||
set -g renumber-windows on
|
set -g renumber-windows on
|
||||||
|
|
||||||
|
|||||||
+228
-1
@@ -25,7 +25,9 @@ setup() {
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION"
|
printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION"
|
||||||
[ -z "${FAKE_EXIT:-}" ] || exit "$FAKE_EXIT"
|
[ -z "${FAKE_EXIT:-}" ] || exit "$FAKE_EXIT"
|
||||||
exec sleep 300
|
# Keeps its arguments in /proc/PID/cmdline (Remote Control is read from there)
|
||||||
|
# and ends on SIGTERM, like Claude.
|
||||||
|
exec bash -c 'sleep 300 & wait' fake-claude "$@"
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$CLAUDE_BIN"
|
chmod +x "$CLAUDE_BIN"
|
||||||
}
|
}
|
||||||
@@ -1181,3 +1183,228 @@ JSONL
|
|||||||
[ "$(posts)" -eq 1 ]
|
[ "$(posts)" -eq 1 ]
|
||||||
[ "$(cat "$DEVDESK_STATE_DIR/usage-warned")" = 2000018000 ]
|
[ "$(cat "$DEVDESK_STATE_DIR/usage-warned")" = 2000018000 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "unsplit closes extra panes and keeps Claude's (#52)" {
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
tmux -S "$DEVDESK_SOCKET" split-window -h -t =alpha:claude
|
||||||
|
tmux -S "$DEVDESK_SOCKET" split-window -v -t =alpha:shell
|
||||||
|
run devdesk attach --unsplit alpha
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(jq -r .closed <<<"$output")" = 2 ]
|
||||||
|
[ "$(tmux -S "$DEVDESK_SOCKET" list-panes -t =alpha:claude -F '#{pane_start_command}')" = "$REPO/libexec/devdesk-claude-run" ]
|
||||||
|
[ "$(tmux -S "$DEVDESK_SOCKET" list-panes -t =alpha:shell | wc -l)" = 1 ]
|
||||||
|
run devdesk attach --unsplit alpha
|
||||||
|
[ "$(jq -r .closed <<<"$output")" = 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
fs_put() { # SESSION PATH MODE CONTENT-FILE
|
||||||
|
local size
|
||||||
|
size=$(stat -c %s "$4")
|
||||||
|
base64 -w0 "$4" | devdesk attach --fs-put "$1" "$2" "$size" "$3"
|
||||||
|
}
|
||||||
|
last_json() { tail -n1 <<<"$output"; }
|
||||||
|
|
||||||
|
@test "fs-mkdir, fs-move and fs-copy stay inside the folder and never overwrite (#54)" {
|
||||||
|
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
mkdir -p "$p/src" && printf 'a\n' >"$p/src/a.txt" && printf 'b\n' >"$p/b.txt"
|
||||||
|
ln -s "$TMP/outside" "$p/out"
|
||||||
|
ln -s b.txt "$p/link"
|
||||||
|
run devdesk attach --fs-mkdir alpha docs/guides
|
||||||
|
[ "$status" -eq 0 ] && [ -d "$p/docs/guides" ]
|
||||||
|
[ "$(last_json | jq -r .path)" = docs/guides ]
|
||||||
|
run devdesk attach --fs-mkdir alpha docs
|
||||||
|
[[ $output == *"already exists"* ]]
|
||||||
|
for bad in ../x /abs out/x 'a/../b' . '' docs/; do
|
||||||
|
run devdesk attach --fs-mkdir alpha "$bad"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
done
|
||||||
|
[ -z "$(ls -A "$TMP/outside")" ]
|
||||||
|
# out/x once escaped to /x: set -e doesn't reach into $(...) (#54)
|
||||||
|
[ ! -e /x ] && [ ! -e /abs ]
|
||||||
|
|
||||||
|
# rename a file, move a folder into another, never onto something
|
||||||
|
run devdesk attach --fs-move alpha src/a.txt src/renamed.txt
|
||||||
|
[ "$status" -eq 0 ] && [ -f "$p/src/renamed.txt" ] && [ ! -e "$p/src/a.txt" ]
|
||||||
|
run devdesk attach --fs-move alpha b.txt src/renamed.txt
|
||||||
|
[ "$status" -ne 0 ] && [[ $output == *"already exists"* ]]
|
||||||
|
[ "$(cat "$p/b.txt")" = b ]
|
||||||
|
run devdesk attach --fs-move alpha src docs/src
|
||||||
|
[ "$status" -eq 0 ] && [ -f "$p/docs/src/renamed.txt" ]
|
||||||
|
run devdesk attach --fs-move alpha docs docs/guides/docs
|
||||||
|
[ "$status" -ne 0 ] && [[ $output == *"inside itself"* ]]
|
||||||
|
run devdesk attach --fs-move alpha b.txt out/b.txt
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
run devdesk attach --fs-move alpha ../outside x
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
# a symlink moves as a link
|
||||||
|
run devdesk attach --fs-move alpha link link2
|
||||||
|
[ "$status" -eq 0 ] && [ -L "$p/link2" ] && [ -f "$p/b.txt" ]
|
||||||
|
# copy: folders recursively, missing parents created
|
||||||
|
run devdesk attach --fs-copy alpha docs/src new/place/src
|
||||||
|
[ "$status" -eq 0 ] && [ -f "$p/new/place/src/renamed.txt" ] && [ -f "$p/docs/src/renamed.txt" ]
|
||||||
|
run devdesk attach --fs-copy alpha b.txt docs/src/renamed.txt
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "fs-delete moves to the trash and fs-restore puts it back (#54)" {
|
||||||
|
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
mkdir -p "$p/dir/sub" && printf 'x\n' >"$p/dir/sub/x" && printf 'y\n' >"$p/y.txt"
|
||||||
|
ln -s "$TMP/outside" "$p/out"
|
||||||
|
touch "$TMP/outside/keep"
|
||||||
|
run devdesk attach --fs-delete alpha dir y.txt out
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
id=$(last_json | jq -r .trash)
|
||||||
|
[ "$(last_json | jq -c .paths)" = '["dir","y.txt","out"]' ]
|
||||||
|
[ ! -e "$p/dir" ] && [ ! -e "$p/y.txt" ] && [ ! -L "$p/out" ] && [ -f "$TMP/outside/keep" ]
|
||||||
|
# nothing deleted when one path is bad
|
||||||
|
printf 'z\n' >"$p/z.txt"
|
||||||
|
run devdesk attach --fs-delete alpha z.txt ../outside
|
||||||
|
[ "$status" -ne 0 ] && [ -f "$p/z.txt" ]
|
||||||
|
run devdesk attach --fs-delete alpha .
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
# restore; a path taken meanwhile is refused
|
||||||
|
printf 'new\n' >"$p/y.txt"
|
||||||
|
run devdesk attach --fs-restore alpha "$id"
|
||||||
|
[ "$status" -ne 0 ] && [[ $output == *"already exists: y.txt"* ]]
|
||||||
|
rm "$p/y.txt"
|
||||||
|
run devdesk attach --fs-restore alpha "$id"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(cat "$p/dir/sub/x" "$p/y.txt")" = "$(printf 'x\ny')" ] && [ -L "$p/out" ]
|
||||||
|
[ ! -e "$DEVDESK_STATE_DIR/trash/alpha/$id" ]
|
||||||
|
run devdesk attach --fs-restore alpha "../../etc"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "fs-put uploads any file into a folder; fs-download returns it (#54)" {
|
||||||
|
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
head -c 3000 /dev/urandom >"$TMP/bin"
|
||||||
|
run fs_put alpha assets/img/logo.bin new "$TMP/bin"
|
||||||
|
[ "$status" -eq 0 ] && cmp -s "$TMP/bin" "$p/assets/img/logo.bin"
|
||||||
|
[ "$(stat -c %a "$p/assets/img/logo.bin")" = 644 ]
|
||||||
|
run fs_put alpha assets/img/logo.bin new "$TMP/bin"
|
||||||
|
[ "$status" -ne 0 ] && [[ $output == *"already exists"* ]]
|
||||||
|
printf 'v2' >"$TMP/v2"
|
||||||
|
chmod 600 "$p/assets/img/logo.bin"
|
||||||
|
run fs_put alpha assets/img/logo.bin replace "$TMP/v2"
|
||||||
|
[ "$status" -eq 0 ] && [ "$(cat "$p/assets/img/logo.bin")" = v2 ] && [ "$(stat -c %a "$p/assets/img/logo.bin")" = 600 ]
|
||||||
|
: >"$TMP/empty"
|
||||||
|
run fs_put alpha empty.txt new "$TMP/empty"
|
||||||
|
[ "$status" -eq 0 ] && [ -f "$p/empty.txt" ] && [ ! -s "$p/empty.txt" ]
|
||||||
|
ln -s "$TMP/outside" "$p/out"
|
||||||
|
for bad in out/x ../x /x assets; do
|
||||||
|
run fs_put alpha "$bad" new "$TMP/v2"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
done
|
||||||
|
[ -z "$(ls -A "$TMP/outside")" ]
|
||||||
|
run fs_put alpha assets replace "$TMP/v2"
|
||||||
|
[ "$status" -ne 0 ] && [ -d "$p/assets" ]
|
||||||
|
# short stream: nothing left behind
|
||||||
|
run bash -c "printf aGVs | '$REPO/bin/devdesk' attach --fs-put alpha short.txt 6 new"
|
||||||
|
[ "$status" -ne 0 ] && [ ! -e "$p/short.txt" ] && [ -z "$(find "$p" -name '.*devdesk-*')" ]
|
||||||
|
# download: binary body comes back whole
|
||||||
|
run devdesk attach --fs-download alpha assets/img/logo.bin
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(sed -n '/DEVDESK-BLOB-BEGIN/{n;p}' <<<"$output" | base64 -d)" = v2 ]
|
||||||
|
head -c 3000 /dev/urandom >"$p/rand"
|
||||||
|
run devdesk attach --fs-download alpha rand
|
||||||
|
sed -n '/DEVDESK-BLOB-BEGIN/{n;p}' <<<"$output" | base64 -d | cmp -s - "$p/rand"
|
||||||
|
run devdesk attach --fs-download alpha out
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "fs-grep searches files, literally or by regex, git or not (#56)" {
|
||||||
|
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
mkdir -p "$p/src" "$p/node_modules/x"
|
||||||
|
printf 'const Answer = 42;\nlet b = "a:b";\n' >"$p/src/a.js"
|
||||||
|
printf 'answer here\n' >"$p/node_modules/x/i.js"
|
||||||
|
printf '\x00\x01answer' >"$p/bin.dat"
|
||||||
|
run devdesk attach --fs-grep alpha - 'Answer'
|
||||||
|
[ "$(jq -c '[.matches[] | [.p, .l, .t]]' <<<"$output")" = '[["src/a.js",1,"const Answer = 42;"]]' ]
|
||||||
|
run devdesk attach --fs-grep alpha i 'answer'
|
||||||
|
[ "$(jq -r '[.matches[].p] | join(",")' <<<"$output")" = src/a.js ]
|
||||||
|
run devdesk attach --fs-grep alpha - '"a:b"'
|
||||||
|
[ "$(jq -r '.matches[0].t' <<<"$output")" = 'let b = "a:b";' ]
|
||||||
|
run devdesk attach --fs-grep alpha r 'A[n]swer = [0-9]+'
|
||||||
|
[ "$(jq -r '.matches | length' <<<"$output")" = 1 ]
|
||||||
|
run devdesk attach --fs-grep alpha - '[n]'
|
||||||
|
[ "$(jq -r '.matches | length' <<<"$output")" = 0 ]
|
||||||
|
# in a repository: git grep, .gitignore honoured, untracked files included
|
||||||
|
git -C "$p" init -q
|
||||||
|
printf 'node_modules/\n' >"$p/.gitignore"
|
||||||
|
printf 'Answer again\n' >"$p/untracked.txt"
|
||||||
|
run devdesk attach --fs-grep alpha w 'Answer'
|
||||||
|
[ "$(jq -c '[.matches[] | [.p, .l]] | sort' <<<"$output")" = '[["src/a.js",1],["untracked.txt",1]]' ]
|
||||||
|
run devdesk attach --fs-grep alpha x 'Answer'
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
run devdesk attach --fs-grep alpha - ''
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "Remote Control is per session: Settings default, live state, restart when idle (#61)" {
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
run claude_args alpha
|
||||||
|
[[ $output != *remote-control* ]]
|
||||||
|
run devdesk attach --list
|
||||||
|
[ "$(jq -c '[.remote_control_default, (.sessions[] | select(.name=="alpha") | .remote_control, .rc_active)]' <<<"$output")" = '[false,false,false]' ]
|
||||||
|
|
||||||
|
# Default on for new sessions
|
||||||
|
run devdesk attach --settings remote-control-default on
|
||||||
|
[ "$(jq -r .remote_control_default <<<"$output")" = true ]
|
||||||
|
devdesk new beta
|
||||||
|
run claude_args beta
|
||||||
|
[[ $output == *"--remote-control devdesk-beta"* ]]
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
[ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="beta") | .rc_active')" = true ] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="beta") | .rc_active')" = true ]
|
||||||
|
|
||||||
|
# alpha (idle): switching restarts Claude with the flag, no exit menu
|
||||||
|
run devdesk attach --remote-control alpha on
|
||||||
|
[ "$(jq -r .applied <<<"$output")" = now ]
|
||||||
|
for _ in $(seq 1 50); do [ "$(wc -l <"$FAKE_LOG/alpha")" -ge 2 ] && break; sleep 0.1; done
|
||||||
|
[[ "$(tail -n1 "$FAKE_LOG/alpha")" == *"--remote-control devdesk-alpha"* ]]
|
||||||
|
[ ! -e "$DEVDESK_STATE_DIR/restart/alpha" ]
|
||||||
|
|
||||||
|
# working: saved for the next start, Claude untouched
|
||||||
|
mkdir -p "$DEVDESK_STATE_DIR/state"
|
||||||
|
printf '{"state":"working","since":1}' >"$DEVDESK_STATE_DIR/state/alpha.json"
|
||||||
|
run devdesk attach --remote-control alpha off
|
||||||
|
[ "$(jq -r .applied <<<"$output")" = next-start ]
|
||||||
|
sleep 0.5
|
||||||
|
[ "$(wc -l <"$FAKE_LOG/alpha")" -eq 2 ]
|
||||||
|
[ "$(jq -r .sessions.alpha.remote_control "$DEVDESK_STATE_DIR/registry.json")" = false ]
|
||||||
|
# an explicit off beats the default on
|
||||||
|
[ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="alpha") | .remote_control')" = false ]
|
||||||
|
|
||||||
|
run devdesk attach --remote-control nope on
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
run devdesk attach --remote-control alpha maybe
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "delete-convo removes a conversation's local files, never one in use (#61)" {
|
||||||
|
start_server
|
||||||
|
export CLAUDE_CONFIG_DIR="$TMP/claude"
|
||||||
|
id=11111111-2222-3333-4444-555555555555
|
||||||
|
other=99999999-2222-3333-4444-555555555555
|
||||||
|
d="$CLAUDE_CONFIG_DIR/projects/-x"
|
||||||
|
mkdir -p "$d/$id/subagents" "$d/$other"
|
||||||
|
echo '{}' >"$d/$id.jsonl" && echo '{}' >"$d/$id/subagents/a.jsonl" && echo '{}' >"$d/$other.jsonl"
|
||||||
|
devdesk new alpha
|
||||||
|
jq --arg id "$id" '.sessions.alpha.claude_session_id = $id | .sessions.dormant = {dir: "/tmp", claude_session_id: $id}' \
|
||||||
|
"$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
|
||||||
|
run devdesk attach --delete-convo "$id"
|
||||||
|
[ "$status" -ne 0 ] && [[ $output == *"session alpha is using"* ]] && [ -f "$d/$id.jsonl" ]
|
||||||
|
devdesk end alpha
|
||||||
|
run devdesk attach --delete-convo "$id"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ ! -e "$d/$id.jsonl" ] && [ ! -e "$d/$id" ] && [ -f "$d/$other.jsonl" ]
|
||||||
|
[ "$(jq -r '.sessions.dormant.claude_session_id // "none"' "$DEVDESK_STATE_DIR/registry.json")" = none ]
|
||||||
|
run devdesk attach --delete-convo "$id"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
run devdesk attach --delete-convo "../../etc/passwd"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|||||||
+155
-14
@@ -2,11 +2,16 @@
|
|||||||
// languages, a unified diff view, and markdown rendering. Built separately to
|
// languages, a unified diff view, and markdown rendering. Built separately to
|
||||||
// dist/assets/editor.js (window.DevDeskEditor) and loaded over the ttyd channel
|
// 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.
|
// 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 { EditorState } from '@codemirror/state';
|
||||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||||
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
|
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap, indentOnInput } from '@codemirror/language';
|
||||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
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 { markdownLanguage } from '@codemirror/lang-markdown';
|
||||||
import { unifiedMergeView } from '@codemirror/merge';
|
import { unifiedMergeView } from '@codemirror/merge';
|
||||||
import { tags as t } from '@lezer/highlight';
|
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 { lua } from '@codemirror/legacy-modes/mode/lua';
|
||||||
import { perl } from '@codemirror/legacy-modes/mode/perl';
|
import { perl } from '@codemirror/legacy-modes/mode/perl';
|
||||||
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
|
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
|
||||||
import { marked } from 'marked';
|
import { Marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
const MODES = {
|
const MODES = {
|
||||||
@@ -121,20 +126,58 @@ const theme = EditorView.theme({
|
|||||||
'.cm-deletedLineGutter': { backgroundColor: 'var(--accent-red)' },
|
'.cm-deletedLineGutter': { backgroundColor: 'var(--accent-red)' },
|
||||||
});
|
});
|
||||||
|
|
||||||
function baseExtensions(path, { readOnly = true, wrap = false } = {}) {
|
function baseExtensions(path, { readOnly = true, wrap = false, onCursor } = {}) {
|
||||||
return [
|
return [
|
||||||
lineNumbers(), foldGutter(), highlightSpecialChars(), highlightActiveLine(), highlightActiveLineGutter(),
|
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)),
|
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
|
// 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.
|
// and Ctrl+F work, typing doesn't; no on-screen keyboard on phones.
|
||||||
EditorState.readOnly.of(readOnly),
|
EditorState.readOnly.of(readOnly),
|
||||||
readOnly ? EditorView.contentAttributes.of({ inputmode: 'none' }) : [],
|
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 : [],
|
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.
|
// Put the cursor on a line (1-based) and scroll it to the middle.
|
||||||
function gotoLine(view, line) {
|
function gotoLine(view, line) {
|
||||||
const target = view.state.doc.line(Math.max(1, Math.min(line, view.state.doc.lines)));
|
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.
|
// Read-only view of a file.
|
||||||
export function viewer(parent, { doc, path, wrap }) {
|
export function viewer(parent, { doc, path, wrap, onCursor }) {
|
||||||
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
|
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap, onCursor }) }) });
|
||||||
return { view, goto: (line) => gotoLine(view, line), destroy: () => view.destroy() };
|
return { view, goto: (line) => gotoLine(view, line), destroy: () => view.destroy() };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editable file (devdesk#20): onChange(text) after every edit, onSave() on
|
// Editable file (devdesk#20): onChange(text) after every edit, onSave() on
|
||||||
// Ctrl/Cmd+S (which never reaches the browser or the terminal).
|
// 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({
|
const view = new EditorView({
|
||||||
parent,
|
parent,
|
||||||
state: EditorState.create({
|
state: EditorState.create({
|
||||||
doc,
|
doc,
|
||||||
extensions: [
|
extensions: [
|
||||||
keymap.of([{ key: 'Mod-s', preventDefault: true, run: () => { onSave?.(); return true; } }, indentWithTab]),
|
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) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) onChange?.(update.state.doc.toString());
|
if (update.docChanged) onChange?.(update.state.doc.toString());
|
||||||
}),
|
}),
|
||||||
@@ -167,6 +211,7 @@ export function editor(parent, { doc, path, wrap, onChange, onSave }) {
|
|||||||
return {
|
return {
|
||||||
view,
|
view,
|
||||||
goto: (line) => gotoLine(view, line),
|
goto: (line) => gotoLine(view, line),
|
||||||
|
info: () => cursorInfo(view.state),
|
||||||
text: () => view.state.doc.toString(),
|
text: () => view.state.doc.toString(),
|
||||||
// Replace the whole document (reload from disk) keeping the cursor roughly where it was.
|
// Replace the whole document (reload from disk) keeping the cursor roughly where it was.
|
||||||
setText: (text) => {
|
setText: (text) => {
|
||||||
@@ -189,7 +234,103 @@ export function diff(parent, { original, modified, path, wrap }) {
|
|||||||
return { view, destroy: () => view.destroy() };
|
return { view, destroy: () => view.destroy() };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Markdown to sanitized HTML (shown in a sandboxed iframe on top of this).
|
// ---------------------------------------------------------------- markdown (devdesk#55)
|
||||||
export function markdown(text) {
|
|
||||||
return DOMPurify.sanitize(marked.parse(text, { gfm: true, breaks: false }), { USE_PROFILES: { html: true } });
|
// 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;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -53,16 +53,7 @@
|
|||||||
<div class="dd-more">
|
<div class="dd-more">
|
||||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-more-toggle" id="more-toggle" aria-haspopup="menu" aria-expanded="false" title="Settings" aria-label="Settings">⋯</button>
|
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-more-toggle" id="more-toggle" aria-haspopup="menu" aria-expanded="false" title="Settings" aria-label="Settings">⋯</button>
|
||||||
<div class="dd-menu dd-more-menu" id="more-menu" role="menu" hidden>
|
<div class="dd-menu dd-more-menu" id="more-menu" role="menu" hidden>
|
||||||
<div class="dd-menu-row">
|
<button type="button" class="dd-menu-item" id="settings-menu" role="menuitem">Settings…</button>
|
||||||
<span>Text size</span>
|
|
||||||
<span class="dd-menu-inline">
|
|
||||||
<button type="button" class="dd-icon-btn" id="font-down" aria-label="Smaller text">A−</button>
|
|
||||||
<span id="font-size">14</span>
|
|
||||||
<button type="button" class="dd-icon-btn" id="font-up" aria-label="Larger text">A+</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="dd-menu-item" id="theme-menu" role="menuitem">Light mode</button>
|
|
||||||
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
|
|
||||||
<div class="dd-menu-row dd-usage-row" id="usage-row" hidden><span>Plan usage</span><span class="dd-usage" id="usage-menu"></span></div>
|
<div class="dd-menu-row dd-usage-row" id="usage-row" hidden><span>Plan usage</span><span class="dd-usage" id="usage-menu"></span></div>
|
||||||
<button type="button" class="dd-menu-item" id="history-menu" role="menuitem">Search past conversations</button>
|
<button type="button" class="dd-menu-item" id="history-menu" role="menuitem">Search past conversations</button>
|
||||||
<button type="button" class="dd-menu-item" id="palette-menu" role="menuitem">Command palette</button>
|
<button type="button" class="dd-menu-item" id="palette-menu" role="menuitem">Command palette</button>
|
||||||
@@ -109,8 +100,17 @@
|
|||||||
<div class="dd-explorer-views" role="tablist">
|
<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="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="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>
|
||||||
<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>
|
||||||
<div class="dd-editor-area"></div>
|
<div class="dd-editor-area"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
<span class="lt-footer-sep">│</span>
|
<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-hint"><span class="lt-footer-key">^b d</span>detach</span>
|
||||||
<span class="lt-footer-sep">│</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-sep">│</span>
|
||||||
<span class="lt-footer-hint"><span class="lt-footer-key">drop / paste</span>attach files</span>
|
<span class="lt-footer-hint"><span class="lt-footer-key">drop / paste</span>attach files</span>
|
||||||
<span class="lt-footer-sep">│</span>
|
<span class="lt-footer-sep">│</span>
|
||||||
|
|||||||
+26
-1
@@ -246,6 +246,24 @@ export function writeFile(session, path, bytes, base) {
|
|||||||
return limited(() => streamIn(['--fs-write', session, path, String(bytes.length), base], 'DEVDESK-WRITE-READY', new Blob([bytes])));
|
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
|
// 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
|
// `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).
|
// reply. Fails on 20 s of inactivity rather than a fixed total (devdesk#9).
|
||||||
@@ -379,7 +397,14 @@ export async function fetchRepo(session) {
|
|||||||
// rejects with devdesk's own error message.
|
// rejects with devdesk's own error message.
|
||||||
export async function sessionAction(action, ...args) {
|
export async function sessionAction(action, ...args) {
|
||||||
const output = stripAnsi(await runCommand([`--${action}`, ...args])).trim();
|
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';
|
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
|
||||||
throw new Error(message.replace(/^devdesk:\s*/, ''));
|
throw new Error(message.replace(/^devdesk:\s*/, ''));
|
||||||
}
|
}
|
||||||
|
|||||||
+1319
-138
File diff suppressed because it is too large
Load Diff
+23
-3
@@ -6,7 +6,7 @@ import { runCommand, sessionAction } from './connection.js';
|
|||||||
import { parseListOutput, slugify } from './protocol.js';
|
import { parseListOutput, slugify } from './protocol.js';
|
||||||
import { el } from './dom.js';
|
import { el } from './dom.js';
|
||||||
import { ago } from './sessions.js';
|
import { ago } from './sessions.js';
|
||||||
import { openDialog, toast } from './ui.js';
|
import { confirmDialog, openDialog, toast } from './ui.js';
|
||||||
|
|
||||||
const ROLE = { you: 'You', claude: 'Claude', command: 'Ran', output: 'Output' };
|
const ROLE = { you: 'You', claude: 'Claude', command: 'Ran', output: 'Output' };
|
||||||
|
|
||||||
@@ -82,9 +82,28 @@ export function openHistory(hooks) {
|
|||||||
el('button', {
|
el('button', {
|
||||||
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm',
|
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm',
|
||||||
onclick: () => navigator.clipboard?.writeText(`claude --resume ${r.id}`).then(() => toast.success('Copied the resume command')),
|
onclick: () => navigator.clipboard?.writeText(`claude --resume ${r.id}`).then(() => toast.success('Copied the resume command')),
|
||||||
}, 'Copy command'));
|
}, 'Copy command'),
|
||||||
|
live ? null : el('button', {
|
||||||
|
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm dd-danger-text', title: 'Delete this conversation from this machine for good',
|
||||||
|
onclick: async () => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: 'Delete this conversation?',
|
||||||
|
text: `“${r.title.slice(0, 80)}” (${day(r.started)}) is removed from this machine for good: its transcript and subagent files in ~/.claude/projects. It can't be resumed or found again. If its session had Remote Control on, a copy is also at claude.ai/code: delete it there too.`,
|
||||||
|
confirmLabel: 'Delete for good',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await sessionAction('delete-convo', r.id);
|
||||||
|
item.remove();
|
||||||
|
toast.success('Conversation deleted from this machine', 8000, [{ label: 'claude.ai/code', run: () => window.open('https://claude.ai/code', '_blank', 'noopener') }]);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, 'Delete…'));
|
||||||
fill();
|
fill();
|
||||||
return el('div', { class: 'dd-history-item' },
|
const item = el('div', { class: 'dd-history-item' },
|
||||||
el('div', { class: 'dd-history-title' }, r.title),
|
el('div', { class: 'dd-history-title' }, r.title),
|
||||||
el('div', { class: 'dd-history-meta' },
|
el('div', { class: 'dd-history-meta' },
|
||||||
[r.cwd, day(r.started), `updated ${ago(r.updated)}${ago(r.updated) === 'now' ? '' : ' ago'}`, `${r.hits} match${r.hits === 1 ? '' : 'es'}`, live ? `open in ${live}` : '']
|
[r.cwd, day(r.started), `updated ${ago(r.updated)}${ago(r.updated) === 'now' ? '' : ' ago'}`, `${r.hits} match${r.hits === 1 ? '' : 'es'}`, live ? `open in ${live}` : '']
|
||||||
@@ -92,6 +111,7 @@ export function openHistory(hooks) {
|
|||||||
...r.matches.map((m) => el('div', { class: `dd-history-snippet is-${m.role}` },
|
...r.matches.map((m) => el('div', { class: `dd-history-snippet is-${m.role}` },
|
||||||
el('span', { class: 'dd-history-role' }, ROLE[m.role] ?? m.role), el('span', {}, ...highlight(m.snippet, data.query)))),
|
el('span', { class: 'dd-history-role' }, ROLE[m.role] ?? m.role), el('span', {}, ...highlight(m.snippet, data.query)))),
|
||||||
actions);
|
actions);
|
||||||
|
return item;
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+82
-31
@@ -1,6 +1,6 @@
|
|||||||
/* global __DEVDESK_VERSION__ */
|
/* global __DEVDESK_VERSION__ */
|
||||||
import { THEMES, createTerminal, loadFonts } from './term.js';
|
import { THEMES, createTerminal, loadFonts } from './term.js';
|
||||||
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
|
import { getTheme, initTheme, onThemeChange, setTheme, toggleTheme } from './theme.js';
|
||||||
import {
|
import {
|
||||||
TtydConnection, fetchAlerts, fetchRepo, fetchToken, investigateAlert, lastReply, replyTo, sessionAction, uploadFile,
|
TtydConnection, fetchAlerts, fetchRepo, fetchToken, investigateAlert, lastReply, replyTo, sessionAction, uploadFile,
|
||||||
AuthExpiredError, UnauthorizedError,
|
AuthExpiredError, UnauthorizedError,
|
||||||
@@ -16,6 +16,7 @@ import { Inbox } from './inbox.js';
|
|||||||
import { FilesPane } from './files.js';
|
import { FilesPane } from './files.js';
|
||||||
import { registerPathLinks } from './links.js';
|
import { registerPathLinks } from './links.js';
|
||||||
import { openHistory } from './history.js';
|
import { openHistory } from './history.js';
|
||||||
|
import { applyUiScale, openSettings } from './settings.js';
|
||||||
import { el } from './dom.js';
|
import { el } from './dom.js';
|
||||||
import * as notify from './notify.js';
|
import * as notify from './notify.js';
|
||||||
|
|
||||||
@@ -55,7 +56,6 @@ const ui = {
|
|||||||
overlayActions: $('overlay-actions'),
|
overlayActions: $('overlay-actions'),
|
||||||
moreToggle: $('more-toggle'),
|
moreToggle: $('more-toggle'),
|
||||||
moreMenu: $('more-menu'),
|
moreMenu: $('more-menu'),
|
||||||
notifyButton: $('notify-toggle'),
|
|
||||||
sidebarButton: $('sidebar-toggle'),
|
sidebarButton: $('sidebar-toggle'),
|
||||||
backdrop: $('backdrop'),
|
backdrop: $('backdrop'),
|
||||||
};
|
};
|
||||||
@@ -267,14 +267,29 @@ function wireUploads() {
|
|||||||
$('drop-session').textContent = state.session || 'a session';
|
$('drop-session').textContent = state.session || 'a session';
|
||||||
overlay.hidden = false;
|
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) => {
|
frame.addEventListener('dragover', (event) => {
|
||||||
if (hasFiles(event)) event.preventDefault();
|
if (hasFiles(event)) event.preventDefault();
|
||||||
|
else if (fromExplorer(event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
frame.addEventListener('dragleave', () => {
|
frame.addEventListener('dragleave', () => {
|
||||||
depth = Math.max(0, depth - 1);
|
depth = Math.max(0, depth - 1);
|
||||||
if (!depth) overlay.hidden = true;
|
if (!depth) overlay.hidden = true;
|
||||||
});
|
});
|
||||||
frame.addEventListener('drop', (event) => {
|
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;
|
if (!hasFiles(event)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
depth = 0;
|
depth = 0;
|
||||||
@@ -284,7 +299,8 @@ function wireUploads() {
|
|||||||
// Pasting a screenshot / copied file: upload it instead of pasting text.
|
// Pasting a screenshot / copied file: upload it instead of pasting text.
|
||||||
document.addEventListener('paste', (event) => {
|
document.addEventListener('paste', (event) => {
|
||||||
const files = [...(event.clipboardData?.files ?? [])];
|
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.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
attachFiles(files);
|
attachFiles(files);
|
||||||
@@ -594,10 +610,41 @@ function setFontSize(size) {
|
|||||||
term.options.fontSize = clamped;
|
term.options.fontSize = clamped;
|
||||||
store.set('fontSize', clamped);
|
store.set('fontSize', clamped);
|
||||||
document.documentElement.style.setProperty('--dd-editor-font', `${clamped - 1}px`);
|
document.documentElement.style.setProperty('--dd-editor-font', `${clamped - 1}px`);
|
||||||
$('font-size').textContent = String(clamped);
|
|
||||||
fit.fit();
|
fit.fit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings (devdesk#61): appearance, notifications, Remote Control per session.
|
||||||
|
function showSettings() {
|
||||||
|
closeMenus();
|
||||||
|
openSettings({
|
||||||
|
getTheme,
|
||||||
|
setTheme,
|
||||||
|
fontSize: () => term.options.fontSize,
|
||||||
|
setFontSize,
|
||||||
|
uiScale: () => Number(store.get('uiScale', 1)) || 1,
|
||||||
|
setUiScale: (scale) => {
|
||||||
|
store.set('uiScale', scale);
|
||||||
|
applyUiScale(scale);
|
||||||
|
requestAnimationFrame(() => fit.fit());
|
||||||
|
},
|
||||||
|
notifications: () => {
|
||||||
|
const current = notify.notificationsState();
|
||||||
|
return {
|
||||||
|
label: {
|
||||||
|
unsupported: 'Not supported', denied: 'Blocked by the browser', default: 'Turn on',
|
||||||
|
granted: notify.isMuted() ? 'Muted: unmute' : 'On: mute',
|
||||||
|
}[current],
|
||||||
|
disabled: current === 'unsupported' || current === 'denied',
|
||||||
|
run: toggleNotifications,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
sessions: () => sessionsPanel?.data,
|
||||||
|
current: () => state.session,
|
||||||
|
refresh: () => sessionsPanel.reload(),
|
||||||
|
openHistory: showHistory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleNotifications() {
|
async function toggleNotifications() {
|
||||||
const current = notify.notificationsState();
|
const current = notify.notificationsState();
|
||||||
if (current === 'default') {
|
if (current === 'default') {
|
||||||
@@ -750,6 +797,16 @@ async function compact(session) {
|
|||||||
return undefined;
|
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() {
|
async function copyLink() {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.search = state.session ? `?arg=${encodeURIComponent(state.session)}` : '';
|
url.search = state.session ? `?arg=${encodeURIComponent(state.session)}` : '';
|
||||||
@@ -801,11 +858,13 @@ function buildCommands() {
|
|||||||
add({ id: 'win-shell', group: 'Terminal', icon: '1', label: 'Shell window', kbd: '^b 1', run: () => sendKeys('\x021') });
|
add({ id: '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: '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: '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) {
|
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: '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: '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({
|
add({
|
||||||
id: 'goto-file', group: 'Files', icon: '⌕', label: 'Go to file…', kbd: 'Ctrl+P in Files', keywords: 'open find quick search',
|
id: 'goto-file', group: 'Files', icon: '⌕', label: 'Go to file…', kbd: 'Ctrl+P in Files', keywords: 'open find quick search',
|
||||||
run: () => {
|
run: () => {
|
||||||
@@ -822,12 +881,13 @@ function buildCommands() {
|
|||||||
add({ id: 'font-up', group: 'View', icon: 'A+', label: 'Larger text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize + 1) });
|
add({ id: 'font-up', group: 'View', icon: 'A+', label: 'Larger text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize + 1) });
|
||||||
add({ id: 'font-down', group: 'View', icon: 'A−', label: 'Smaller text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize - 1) });
|
add({ id: 'font-down', group: 'View', icon: 'A−', label: 'Smaller text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize - 1) });
|
||||||
add({ id: 'font-reset', group: 'View', icon: 'A', label: 'Reset text size', keywords: 'font zoom', run: () => setFontSize(14) });
|
add({ id: 'font-reset', group: 'View', icon: 'A', label: 'Reset text size', keywords: 'font zoom', run: () => setFontSize(14) });
|
||||||
|
add({ id: 'settings', group: 'View', icon: '⚙', label: 'Settings…', keywords: 'preferences text size font theme remote control privacy notifications', run: showSettings });
|
||||||
add({ id: 'fullscreen', group: 'View', icon: '⛶', label: document.fullscreenElement ? 'Exit fullscreen' : 'Fullscreen', run: toggleFullscreen });
|
add({ id: 'fullscreen', group: 'View', icon: '⛶', label: document.fullscreenElement ? 'Exit fullscreen' : 'Fullscreen', run: toggleFullscreen });
|
||||||
if (notifyState !== 'unsupported') {
|
if (notifyState !== 'unsupported') {
|
||||||
add({
|
add({
|
||||||
id: 'notify', group: 'View', icon: '🔔', keywords: 'notifications alerts bell',
|
id: 'notify', group: 'View', icon: '🔔', keywords: 'notifications alerts bell',
|
||||||
label: { default: 'Enable desktop notifications', denied: 'Desktop notifications (blocked)', granted: notify.isMuted() ? 'Unmute desktop notifications' : 'Mute desktop notifications' }[notifyState],
|
label: { default: 'Enable desktop notifications', denied: 'Desktop notifications (blocked)', granted: notify.isMuted() ? 'Unmute desktop notifications' : 'Mute desktop notifications' }[notifyState],
|
||||||
run: () => toggleNotifications().then(renderNotifyButton),
|
run: () => toggleNotifications(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,7 +936,10 @@ const KEY_HELP = [
|
|||||||
['Ctrl+⇧ E', 'Files: browse, preview, diff, edit (Esc closes)'],
|
['Ctrl+⇧ E', 'Files: browse, preview, diff, edit (Esc closes)'],
|
||||||
['Ctrl+P', 'Files: go to file'],
|
['Ctrl+P', 'Files: go to file'],
|
||||||
['Ctrl+S', 'Files: save the open 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+⇧ C / V', 'Copy selection / paste'],
|
||||||
|
['Ctrl+⇧ F', 'Search in the session\'s files'],
|
||||||
['⇧ drag', 'Select text in the browser'],
|
['⇧ drag', 'Select text in the browser'],
|
||||||
['Drop or paste a file', 'Upload it and attach it for Claude'],
|
['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)'],
|
['Click a file path', 'Open it in Files (at the line, if given)'],
|
||||||
@@ -937,17 +1000,6 @@ async function toggleFullscreen() {
|
|||||||
navigator.keyboard?.lock?.().catch(() => {});
|
navigator.keyboard?.lock?.().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNotifyButton() {
|
|
||||||
const current = notify.notificationsState();
|
|
||||||
const labels = {
|
|
||||||
unsupported: 'Desktop notifications: unsupported',
|
|
||||||
denied: 'Desktop notifications: blocked by the browser',
|
|
||||||
default: 'Enable desktop notifications',
|
|
||||||
granted: notify.isMuted() ? 'Desktop notifications: muted (unmute)' : 'Desktop notifications: on (mute)',
|
|
||||||
};
|
|
||||||
ui.notifyButton.textContent = labels[current];
|
|
||||||
ui.notifyButton.disabled = current === 'unsupported' || current === 'denied';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSidebar(open) {
|
function setSidebar(open) {
|
||||||
if (open && files?.isOpen) files.close();
|
if (open && files?.isOpen) files.close();
|
||||||
@@ -997,12 +1049,7 @@ function wireKeybar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wireChrome() {
|
function wireChrome() {
|
||||||
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
|
$('settings-menu').addEventListener('click', showSettings);
|
||||||
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
|
|
||||||
ui.notifyButton.addEventListener('click', async () => {
|
|
||||||
await toggleNotifications();
|
|
||||||
renderNotifyButton();
|
|
||||||
});
|
|
||||||
$('fullscreen-toggle').addEventListener('click', () => {
|
$('fullscreen-toggle').addEventListener('click', () => {
|
||||||
closeMenus();
|
closeMenus();
|
||||||
toggleFullscreen();
|
toggleFullscreen();
|
||||||
@@ -1050,6 +1097,13 @@ function wireChrome() {
|
|||||||
if (state.session) files.toggle();
|
if (state.session) files.toggle();
|
||||||
return;
|
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') {
|
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -1057,14 +1111,7 @@ function wireChrome() {
|
|||||||
else if (!dialogOpen()) palette.open();
|
else if (!dialogOpen()) palette.open();
|
||||||
}
|
}
|
||||||
}, true);
|
}, true);
|
||||||
wireMenu(ui.moreToggle, ui.moreMenu, () => {
|
wireMenu(ui.moreToggle, ui.moreMenu, () => {});
|
||||||
renderNotifyButton();
|
|
||||||
$('theme-menu').textContent = getTheme() === 'light' ? 'Dark mode' : 'Light mode';
|
|
||||||
});
|
|
||||||
$('theme-menu').addEventListener('click', () => {
|
|
||||||
closeMenus();
|
|
||||||
toggleTheme();
|
|
||||||
});
|
|
||||||
inbox = new Inbox({
|
inbox = new Inbox({
|
||||||
button: $('inbox-toggle'),
|
button: $('inbox-toggle'),
|
||||||
badge: $('inbox-badge'),
|
badge: $('inbox-badge'),
|
||||||
@@ -1102,6 +1149,9 @@ function wireChrome() {
|
|||||||
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
|
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
|
||||||
document.addEventListener('keydown', (event) => {
|
document.addEventListener('keydown', (event) => {
|
||||||
if (event.key !== 'Escape' || dialogOpen()) return;
|
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
|
// Files pane: Esc goes back / closes, unless the terminal or CodeMirror's
|
||||||
// own search panel has the focus.
|
// own search panel has the focus.
|
||||||
if (files?.isOpen && !event.target.closest?.('.xterm, .cm-panels') && !menus.some(({ menu }) => !menu.hidden) && !inbox.isOpen) {
|
if (files?.isOpen && !event.target.closest?.('.xterm, .cm-panels') && !menus.some(({ menu }) => !menu.hidden) && !inbox.isOpen) {
|
||||||
@@ -1192,13 +1242,14 @@ async function boot() {
|
|||||||
initTheme();
|
initTheme();
|
||||||
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
|
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
|
||||||
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
|
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
|
||||||
$('font-size').textContent = String(fontSize);
|
applyUiScale(Number(store.get('uiScale', 1)) || 1);
|
||||||
await loadFonts(fontSize);
|
await loadFonts(fontSize);
|
||||||
|
|
||||||
({ term, fit } = createTerminal($('terminal'), {
|
({ term, fit } = createTerminal($('terminal'), {
|
||||||
fontSize,
|
fontSize,
|
||||||
theme: getTheme(),
|
theme: getTheme(),
|
||||||
onShiftEnter: () => state.conn?.sendInput('\n'),
|
onShiftEnter: () => state.conn?.sendInput('\n'),
|
||||||
|
onCopied: (text) => toast.success(`Copied ${text.length.toLocaleString()} character${text.length === 1 ? '' : 's'}`, 1500),
|
||||||
}));
|
}));
|
||||||
onThemeChange((theme) => {
|
onThemeChange((theme) => {
|
||||||
term.options.theme = THEMES[theme];
|
term.options.theme = THEMES[theme];
|
||||||
|
|||||||
@@ -321,6 +321,8 @@ export class SessionsPanel {
|
|||||||
el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
|
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: `lt-dot ${status.dot}` }),
|
||||||
el('span', { class: 'dd-session-name' }, s.name,
|
el('span', { class: 'dd-session-name' }, s.name,
|
||||||
|
// Remote Control on: synced to claude.ai and the Claude app (devdesk#61).
|
||||||
|
s.rc_active ? el('span', { class: 'dd-tag dd-rc', title: 'Remote Control is on: this session syncs to claude.ai and the Claude app. Change it in Settings.' }, 'RC') : null,
|
||||||
s.worktree ? el('span', { class: 'dd-tag dd-wt', title: `Own worktree on ${s.worktree.branch}\n${s.worktree.path}` }, `⑂ ${s.worktree.branch.replace(/^devdesk\//, '')}`) : null,
|
s.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,
|
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)
|
// The pull request of this branch (devdesk#25)
|
||||||
@@ -366,7 +368,15 @@ export class SessionsPanel {
|
|||||||
}, '×'))));
|
}, '×'))));
|
||||||
}
|
}
|
||||||
if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
|
if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
|
||||||
|
const scroll = this.list.scrollTop;
|
||||||
this.list.replaceChildren(...items);
|
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);
|
this.projectOptions(this.select);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Settings (devdesk#61): the things you set once and leave, in one window
|
||||||
|
// instead of the ⋯ menu. Appearance (theme, terminal text size, interface size)
|
||||||
|
// and notifications are per browser; Remote Control is per session, with a
|
||||||
|
// default for new sessions, kept on the server.
|
||||||
|
|
||||||
|
import { el } from './dom.js';
|
||||||
|
import { openDialog, toast } from './ui.js';
|
||||||
|
import { sessionAction } from './connection.js';
|
||||||
|
|
||||||
|
const SCALES = [['Small', 0.9], ['Normal', 1], ['Large', 1.12], ['Larger', 1.25]];
|
||||||
|
|
||||||
|
// Interface size: the page is sized in rem off the root font size (14px).
|
||||||
|
export function applyUiScale(scale) {
|
||||||
|
document.documentElement.style.fontSize = scale === 1 ? '' : `${Math.round(14 * scale * 10) / 10}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmented(label, options, current, onPick) {
|
||||||
|
const buttons = options.map(([text, value]) => el('button', {
|
||||||
|
type: 'button', class: 'dd-seg-btn', 'aria-pressed': String(value === current),
|
||||||
|
onclick: (event) => {
|
||||||
|
for (const b of buttons) b.setAttribute('aria-pressed', 'false');
|
||||||
|
event.currentTarget.setAttribute('aria-pressed', 'true');
|
||||||
|
onPick(value);
|
||||||
|
},
|
||||||
|
}, text));
|
||||||
|
return el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, label), el('span', { class: 'dd-seg', role: 'group', 'aria-label': label }, ...buttons));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(label, checked, onChange, hint) {
|
||||||
|
const input = el('input', { type: 'checkbox', role: 'switch', checked: checked ? true : null });
|
||||||
|
input.addEventListener('change', () => onChange(input.checked, input));
|
||||||
|
return el('label', { class: 'dd-setting dd-switch-row' },
|
||||||
|
el('span', { class: 'dd-setting-label' }, label, hint ? el('span', { class: 'dd-setting-hint' }, hint) : null),
|
||||||
|
el('span', { class: 'dd-switch' }, input, el('span', { class: 'dd-switch-track', 'aria-hidden': 'true' })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// hooks: getTheme(), setTheme(t), fontSize(), setFontSize(n), uiScale(), setUiScale(s),
|
||||||
|
// notifications() -> { label, disabled, run }, sessions() -> list data, current(),
|
||||||
|
// refresh(), openHistory()
|
||||||
|
export async function openSettings(hooks) {
|
||||||
|
const data = hooks.sessions();
|
||||||
|
const current = hooks.current();
|
||||||
|
const size = el('span', { class: 'dd-setting-value' }, String(hooks.fontSize()));
|
||||||
|
const setSize = (n) => {
|
||||||
|
hooks.setFontSize(n);
|
||||||
|
size.textContent = String(hooks.fontSize());
|
||||||
|
};
|
||||||
|
const notif = hooks.notifications();
|
||||||
|
const notifyButton = el('button', { type: 'button', class: 'lt-btn lt-btn-sm', disabled: notif.disabled ? true : null }, notif.label);
|
||||||
|
notifyButton.addEventListener('click', async () => {
|
||||||
|
await hooks.notifications().run();
|
||||||
|
const next = hooks.notifications();
|
||||||
|
notifyButton.textContent = next.label;
|
||||||
|
notifyButton.disabled = next.disabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remote Control, per session.
|
||||||
|
const running = (data?.sessions ?? []).filter((s) => s.running).sort((a, b) => (a.name === current ? -1 : b.name === current ? 1 : a.name.localeCompare(b.name)));
|
||||||
|
const rcRows = running.map((s) => toggle(s.name, s.remote_control, async (on, input) => {
|
||||||
|
input.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await sessionAction('remote-control', s.name, on ? 'on' : 'off');
|
||||||
|
const what = `Remote Control ${on ? 'on' : 'off'}`;
|
||||||
|
if (result?.applied === 'now') toast.success(`${s.name}: restarting Claude with ${what} (same conversation)`, 6000);
|
||||||
|
else if (result?.applied === 'next-start') toast.info(`${s.name} is busy: ${what} when Claude next starts`, 7000);
|
||||||
|
else toast.success(`${s.name}: ${what}`);
|
||||||
|
hooks.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
input.checked = !on;
|
||||||
|
toast.error(`${s.name}: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
input.disabled = false;
|
||||||
|
}
|
||||||
|
}, s.rc_active ? 'syncing to claude.ai now' : s.remote_control ? 'on at the next Claude start' : ''));
|
||||||
|
const defaultRow = toggle('New sessions', Boolean(data?.remote_control_default), async (on, input) => {
|
||||||
|
input.disabled = true;
|
||||||
|
try {
|
||||||
|
await sessionAction('settings', 'remote-control-default', on ? 'on' : 'off');
|
||||||
|
toast.success(`New sessions: Remote Control ${on ? 'on' : 'off'}`);
|
||||||
|
hooks.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
input.checked = !on;
|
||||||
|
toast.error(error.message);
|
||||||
|
} finally {
|
||||||
|
input.disabled = false;
|
||||||
|
}
|
||||||
|
}, 'sessions you create from now on');
|
||||||
|
|
||||||
|
const link = (href, text) => el('a', { href, target: '_blank', rel: 'noopener' }, text);
|
||||||
|
const body = el('div', { class: 'dd-settings' },
|
||||||
|
el('section', { class: 'dd-settings-group' },
|
||||||
|
el('h3', {}, 'Appearance'),
|
||||||
|
segmented('Theme', [['Dark', 'dark'], ['Light', 'light']], hooks.getTheme(), (t) => hooks.setTheme(t)),
|
||||||
|
el('div', { class: 'dd-setting' },
|
||||||
|
el('span', { class: 'dd-setting-label' }, 'Terminal text size'),
|
||||||
|
el('span', { class: 'dd-stepper' },
|
||||||
|
el('button', { type: 'button', class: 'dd-icon-btn', 'aria-label': 'Smaller terminal text', onclick: () => setSize(hooks.fontSize() - 1) }, 'A−'),
|
||||||
|
size,
|
||||||
|
el('button', { type: 'button', class: 'dd-icon-btn', 'aria-label': 'Larger terminal text', onclick: () => setSize(hooks.fontSize() + 1) }, 'A+'),
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: () => setSize(14) }, 'Reset'))),
|
||||||
|
segmented('Interface size', SCALES, hooks.uiScale(), (s) => hooks.setUiScale(s)),
|
||||||
|
el('p', { class: 'dd-setting-note' }, 'Appearance is remembered in this browser.')),
|
||||||
|
el('section', { class: 'dd-settings-group' },
|
||||||
|
el('h3', {}, 'Notifications'),
|
||||||
|
el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, 'Desktop notifications', el('span', { class: 'dd-setting-hint' }, 'when another session needs you')), notifyButton)),
|
||||||
|
el('section', { class: 'dd-settings-group' },
|
||||||
|
el('h3', {}, 'Remote Control & privacy'),
|
||||||
|
el('p', { class: 'dd-setting-note' },
|
||||||
|
'With Remote Control on, a session also appears in the Claude app and at claude.ai/code, and Anthropic stores its transcript to keep your devices in sync (30 days, or 5 years if "Help improve Claude" is on in ',
|
||||||
|
link('https://claude.ai/settings/data-privacy-controls', 'your privacy settings'), '). Turning it off stops syncing from then on; what was already synced stays until you delete it at ',
|
||||||
|
link('https://claude.ai/code', 'claude.ai/code'), '. Switching a running session restarts its Claude on the same conversation.'),
|
||||||
|
defaultRow,
|
||||||
|
running.length ? el('div', { class: 'dd-setting-sub' }, 'Running sessions') : null,
|
||||||
|
...rcRows),
|
||||||
|
el('section', { class: 'dd-settings-group' },
|
||||||
|
el('h3', {}, 'Conversations'),
|
||||||
|
el('p', { class: 'dd-setting-note' }, 'Every conversation is also kept on this machine in ~/.claude/projects (plaintext, 30 days). Delete one for good from Search past conversations.'),
|
||||||
|
el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, 'Find or delete a conversation'),
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-sm', 'data-close': 'history' }, 'Search past conversations…'))));
|
||||||
|
const choice = await openDialog({
|
||||||
|
title: 'Settings',
|
||||||
|
className: 'dd-settings-dialog',
|
||||||
|
body,
|
||||||
|
onOpen: () => {
|
||||||
|
body.querySelector('[data-close="history"]').addEventListener('click', () => {
|
||||||
|
body.closest('.lt-modal-overlay, .lt-modal')?.querySelector('[aria-label="Close"], .lt-modal-close')?.click();
|
||||||
|
setTimeout(() => hooks.openHistory(), 50);
|
||||||
|
});
|
||||||
|
return body.querySelector('button, input');
|
||||||
|
},
|
||||||
|
actions: [{ label: 'Done', primary: true }],
|
||||||
|
});
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
+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({
|
const term = new Terminal({
|
||||||
fontFamily: FONT_FAMILY,
|
fontFamily: FONT_FAMILY,
|
||||||
fontSize,
|
fontSize,
|
||||||
@@ -113,16 +113,28 @@ export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnt
|
|||||||
window.open(uri, '_blank', 'noopener');
|
window.open(uri, '_blank', 'noopener');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const copySelection = () => {
|
||||||
|
const text = term.getSelection();
|
||||||
|
navigator.clipboard?.writeText(text).then(() => onCopied?.(text), () => {});
|
||||||
|
};
|
||||||
|
|
||||||
term.attachCustomKeyEventHandler((event) => {
|
term.attachCustomKeyEventHandler((event) => {
|
||||||
// Shift+Enter: newline in Claude's prompt (Ctrl+J), not submit.
|
// 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.key === 'Enter' && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||||
if (event.type === 'keydown') onShiftEnter();
|
if (event.type === 'keydown') onShiftEnter();
|
||||||
return false;
|
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.
|
// Ctrl+Shift+C / Ctrl+Shift+V: browser clipboard, like desktop terminals.
|
||||||
if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && !event.altKey) {
|
if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && !event.altKey) {
|
||||||
if (event.code === 'KeyC' && term.hasSelection()) {
|
if (event.code === 'KeyC' && term.hasSelection()) {
|
||||||
navigator.clipboard?.writeText(term.getSelection()).catch(() => {});
|
copySelection();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (event.code === 'KeyV') {
|
if (event.code === 'KeyV') {
|
||||||
@@ -135,6 +147,35 @@ export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnt
|
|||||||
|
|
||||||
term.open(container);
|
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.
|
// Touch devices: the DOM renderer is more reliable at high DPR than WebGL.
|
||||||
if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit };
|
if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit };
|
||||||
try {
|
try {
|
||||||
|
|||||||
+86
-5
@@ -24,13 +24,21 @@ function dismiss(node) {
|
|||||||
setTimeout(() => node.remove(), 250);
|
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();
|
const box = toastContainer();
|
||||||
// At most four on screen: the oldest goes first.
|
// At most four on screen: the oldest goes first.
|
||||||
while (box.children.length >= 4) box.firstElementChild.remove();
|
while (box.children.length >= 4) box.firstElementChild.remove();
|
||||||
const node = el('div', { class: `lt-toast lt-toast-${type}`, role: type === 'error' ? 'alert' : 'status' },
|
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-icon', 'aria-hidden': 'true' }, `[${ICONS[type] || 'i'}]`),
|
||||||
el('span', { class: 'lt-toast-msg' }, message),
|
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('button', { type: 'button', class: 'lt-toast-close', 'aria-label': 'Dismiss', onclick: () => dismiss(node) }, '✕'),
|
||||||
el('div', { class: 'lt-toast-progress', style: `animation-duration:${duration}ms` }));
|
el('div', { class: 'lt-toast-progress', style: `animation-duration:${duration}ms` }));
|
||||||
box.append(node);
|
box.append(node);
|
||||||
@@ -39,10 +47,83 @@ export function toast(message, type = 'info', duration = 4000) {
|
|||||||
node.addEventListener('mouseleave', () => { node.dismissTimer = setTimeout(() => dismiss(node), 1500); });
|
node.addEventListener('mouseleave', () => { node.dismissTimer = setTimeout(() => dismiss(node), 1500); });
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success = (message, duration) => toast(message, 'success', duration);
|
toast.success = (message, duration, actions) => toast(message, 'success', duration, actions);
|
||||||
toast.error = (message, duration = 8000) => toast(message, 'error', duration);
|
toast.error = (message, duration = 8000, actions) => toast(message, 'error', duration, actions);
|
||||||
toast.warning = (message, duration) => toast(message, 'warning', duration);
|
toast.warning = (message, duration, actions) => toast(message, 'warning', duration, actions);
|
||||||
toast.info = (message, duration) => toast(message, 'info', duration);
|
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
|
// ---------------------------------------------------------------- dialogs
|
||||||
|
|
||||||
|
|||||||
+112
-2
@@ -239,6 +239,13 @@ body.dd-sidebar-open .dd-sidebar {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
margin-bottom: var(--space-sm);
|
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 {
|
.dd-session-item {
|
||||||
@@ -792,7 +799,7 @@ body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transpar
|
|||||||
.dd-explorer-resizer { flex: 0 0 4px; margin-left: -2px; margin-right: -2px; cursor: col-resize; z-index: 2; }
|
.dd-explorer-resizer { 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-resizer:hover { background: var(--accent-cyan); }
|
||||||
.dd-explorer-views { display: flex; border-bottom: 1px solid var(--border-dim); }
|
.dd-explorer-views { display: flex; border-bottom: 1px solid var(--border-dim); }
|
||||||
.dd-explorer-views button {
|
.dd-explorer-views button[role="tab"] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 6px 4px 5px;
|
padding: 6px 4px 5px;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -805,7 +812,7 @@ body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transpar
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
cursor: pointer;
|
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-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-files-count:not(:empty) { margin-left: 3px; padding: 0 5px; border-radius: 8px; background: var(--accent-amber-dim); color: var(--accent-amber); font-size: 0.6rem; }
|
||||||
.dd-explorer-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 0 12px; outline: none; }
|
.dd-explorer-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 0 12px; outline: none; }
|
||||||
@@ -1063,3 +1070,106 @@ body.dd-touch .dd-footer-usage { display: none; }
|
|||||||
.dd-repo-change-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.dd-repo-change-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-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; }
|
.dd-link-btn { padding: 0; border: 0; background: none; color: var(--accent-cyan); font: inherit; text-decoration: underline dotted; cursor: pointer; }
|
||||||
|
|
||||||
|
/* Toast actions (Undo) and right-click menus (devdesk#54) */
|
||||||
|
.lt-toast-action { flex: none; padding: 2px 8px; border: 1px solid currentColor; background: none; color: inherit; font: inherit; font-size: 0.72rem; cursor: pointer; }
|
||||||
|
.lt-toast-action:hover { background: var(--accent-cyan-dim); }
|
||||||
|
.dd-ctx-menu { position: fixed; top: 0; left: 0; z-index: 200; min-width: 220px; max-height: 80vh; }
|
||||||
|
.dd-ctx-menu .dd-menu-item { justify-content: space-between; padding: 5px 10px; }
|
||||||
|
.dd-ctx-menu .dd-menu-item.is-danger { color: var(--accent-red); }
|
||||||
|
.dd-ctx-kbd { margin-left: 24px; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.66rem; }
|
||||||
|
.dd-menu-sep { height: 1px; margin: 4px 6px; background: var(--border-dim); }
|
||||||
|
|
||||||
|
/* Explorer operations (devdesk#54) */
|
||||||
|
.dd-explorer-actions { display: flex; align-items: center; gap: 1px; padding: 0 4px; border-left: 1px solid var(--border-dim); }
|
||||||
|
.dd-explorer-actions .dd-icon-btn { width: 24px; height: 24px; padding: 0; font-size: 0.82rem; line-height: 1; }
|
||||||
|
.dd-files:not([data-view="explorer"]) .dd-explorer-actions { display: none; }
|
||||||
|
.dd-tree-row.is-selected { background: var(--accent-cyan-dim); }
|
||||||
|
.dd-tree-row.is-selected.is-active { box-shadow: inset 2px 0 0 var(--accent-cyan); }
|
||||||
|
.dd-explorer-body:focus-within .dd-tree-row.is-selected { background: rgba(0, 150, 220, 0.22); }
|
||||||
|
.dd-tree-row.is-cut { opacity: 0.45; }
|
||||||
|
.dd-tree-row.is-drop { background: var(--accent-orange-dim); }
|
||||||
|
.dd-tree-row.is-drop.is-dir { box-shadow: inset 0 0 0 1px var(--accent-orange); }
|
||||||
|
.dd-explorer-body.is-drop { background: var(--accent-orange-dim); box-shadow: inset 0 0 0 1px var(--accent-orange); }
|
||||||
|
.dd-tree-row.is-editing { cursor: default; background: var(--bg-tertiary); }
|
||||||
|
.dd-tree-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 1px solid var(--accent-cyan);
|
||||||
|
background: var(--bg-input, var(--bg-primary));
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.dd-upload-bar { display: flex; flex-direction: column; gap: 4px; padding: 6px 10px; border-top: 1px solid var(--border-dim); font-size: 0.68rem; color: var(--text-secondary); }
|
||||||
|
.dd-upload-bar[hidden] { display: none; }
|
||||||
|
.dd-upload-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dd-upload-meter { height: 3px; background: var(--bg-tertiary); }
|
||||||
|
.dd-upload-fill { display: block; width: 0; height: 100%; background: var(--accent-cyan); transition: width 0.15s linear; }
|
||||||
|
.dd-drag-ghost { position: fixed; top: -100px; left: -100px; padding: 3px 10px; background: var(--accent-cyan); color: var(--bg-primary); font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700; }
|
||||||
|
|
||||||
|
/* Markdown preview and split, editor status bar (devdesk#55) */
|
||||||
|
.dd-viewer-content.is-preview { overflow: hidden; }
|
||||||
|
.dd-md-host { flex: 1; min-height: 0; outline: none; }
|
||||||
|
.dd-viewer-content.is-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||||
|
.dd-split-editor { min-width: 0; height: 100%; overflow: hidden; border-right: 1px solid var(--border-dim); }
|
||||||
|
.dd-split-editor .cm-editor { height: 100%; }
|
||||||
|
.dd-split-preview { min-width: 0; }
|
||||||
|
.dd-editor-status { display: flex; align-items: center; gap: 14px; flex: none; height: 22px; padding: 0 10px; border-top: 1px solid var(--border-dim); background: var(--bg-secondary); color: var(--text-muted); font-family: var(--font-mono); font-size: 0.66rem; white-space: nowrap; overflow: hidden; }
|
||||||
|
.dd-status-pos { color: var(--text-secondary); }
|
||||||
|
.dd-status-sel { color: var(--accent-cyan); }
|
||||||
|
.dd-status-spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* Search in files (devdesk#56) */
|
||||||
|
.dd-search { display: flex; flex-direction: column; min-height: 100%; }
|
||||||
|
.dd-search-box { display: flex; gap: 2px; padding: 6px 8px; border-bottom: 1px solid var(--border-dim); }
|
||||||
|
.dd-search-input { flex: 1; min-width: 0; height: 26px; padding: 0 6px; font-family: var(--font-mono); font-size: 0.74rem; }
|
||||||
|
.dd-search-flag { flex: none; width: 26px; height: 26px; padding: 0; border: 1px solid transparent; background: none; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.7rem; cursor: pointer; }
|
||||||
|
.dd-search-flag:hover { color: var(--text-primary); }
|
||||||
|
.dd-search-flag[aria-pressed="true"] { border-color: var(--accent-cyan); background: var(--accent-cyan-dim); color: var(--accent-cyan); }
|
||||||
|
.dd-search-summary { padding: 4px 10px; color: var(--text-muted); font-size: 0.66rem; }
|
||||||
|
.dd-search-file { display: flex; align-items: center; gap: 6px; width: 100%; padding: 3px 8px; border: 0; background: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 0.74rem; text-align: left; cursor: pointer; }
|
||||||
|
.dd-search-file:hover { background: var(--bg-tertiary); }
|
||||||
|
.dd-search-file::before { content: '▾'; width: 10px; color: var(--text-muted); font-size: 0.66rem; }
|
||||||
|
.dd-search-file[aria-expanded="false"]::before { content: '▸'; }
|
||||||
|
.dd-search-file .dd-tree-name { flex: none; }
|
||||||
|
.dd-search-file .dd-change-dir { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dd-search-count { flex: none; padding: 0 6px; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.62rem; }
|
||||||
|
.dd-search-lines[hidden] { display: none; }
|
||||||
|
.dd-search-line { display: flex; gap: 8px; padding: 2px 8px 2px 30px; color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.72rem; white-space: nowrap; cursor: pointer; outline: none; }
|
||||||
|
.dd-search-line:hover, .dd-search-line:focus { background: var(--bg-tertiary); color: var(--text-primary); }
|
||||||
|
.dd-search-line:focus { box-shadow: inset 0 0 0 1px var(--accent-cyan); }
|
||||||
|
.dd-search-ln { flex: none; min-width: 2.5em; color: var(--text-muted); text-align: right; }
|
||||||
|
.dd-search-text { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.dd-search-text mark { background: var(--accent-amber-dim); color: var(--accent-amber); outline: 1px solid color-mix(in srgb, var(--accent-amber) 50%, transparent); }
|
||||||
|
|
||||||
|
/* Settings (devdesk#61) */
|
||||||
|
.dd-settings-dialog .lt-modal { width: min(620px, calc(100vw - 24px)); max-height: calc(100vh - 40px); }
|
||||||
|
.dd-settings-dialog .dd-dialog-body { max-height: calc(100vh - 170px); overflow-y: auto; }
|
||||||
|
.dd-settings { display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
.dd-settings-group h3 { margin: 0 0 8px; color: var(--accent-orange); font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||||
|
.dd-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 34px; padding: 4px 0; border-bottom: 1px solid var(--border-dim); }
|
||||||
|
.dd-setting-label { display: flex; flex-direction: column; color: var(--text-primary); font-size: 0.8rem; }
|
||||||
|
.dd-setting-hint { color: var(--text-muted); font-size: 0.68rem; }
|
||||||
|
.dd-setting-value { min-width: 2ch; text-align: center; font-family: var(--font-mono); }
|
||||||
|
.dd-setting-note { margin: 4px 0 8px; color: var(--text-secondary); font-size: 0.72rem; line-height: 1.5; }
|
||||||
|
.dd-setting-note a { color: var(--accent-cyan); }
|
||||||
|
.dd-setting-sub { margin-top: 10px; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.64rem; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||||
|
.dd-stepper { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.dd-seg { display: inline-flex; border: 1px solid var(--border-color); }
|
||||||
|
.dd-seg-btn { padding: 4px 10px; border: 0; border-right: 1px solid var(--border-color); background: none; color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.72rem; cursor: pointer; }
|
||||||
|
.dd-seg-btn:last-child { border-right: 0; }
|
||||||
|
.dd-seg-btn[aria-pressed="true"] { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
|
||||||
|
.dd-switch-row { cursor: pointer; }
|
||||||
|
.dd-switch { position: relative; flex: none; width: 36px; height: 20px; }
|
||||||
|
.dd-switch input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
|
||||||
|
.dd-switch-track { position: absolute; inset: 0; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-tertiary); transition: background 0.15s; }
|
||||||
|
.dd-switch-track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--text-muted); transition: transform 0.15s, background 0.15s; }
|
||||||
|
.dd-switch input:checked + .dd-switch-track { background: var(--accent-cyan-dim); border-color: var(--accent-cyan); }
|
||||||
|
.dd-switch input:checked + .dd-switch-track::after { transform: translateX(16px); background: var(--accent-cyan); }
|
||||||
|
.dd-switch input:focus-visible + .dd-switch-track { outline: 1px dashed var(--accent-cyan); outline-offset: 2px; }
|
||||||
|
.dd-switch input:disabled + .dd-switch-track { opacity: 0.5; }
|
||||||
|
.dd-rc { color: var(--accent-purple); border-color: color-mix(in srgb, var(--accent-purple) 50%, transparent); }
|
||||||
|
.dd-danger-text { color: var(--accent-red) !important; }
|
||||||
|
|||||||
Reference in New Issue
Block a user