feat: v0.11.0 VS Code-like explorer, markdown preview, search in files
- Explorer: context menus, multi-select, inline rename/new, drag to move or copy, desktop drag-and-drop uploads (folders too), cut/copy/paste, duplicate, download, delete to a 7-day trash with Undo. Server side in libexec/devdesk-fsops.sh with bats tests. Closes #54 - Markdown preview in a shadow root: headings with anchors and outline, highlighted code with copy, callouts, task lists, front matter, links that open files, live split view. Editor status bar, multi-cursor, closeBrackets, Ctrl+G, tab menus and reordering. Closes #55 - Search in files (Ctrl+Shift+F) via git grep / grep. Closes #56 - Fix: nested $(...) path checks now fail hard (set -e does not reach them; an escape created /x in testing); newest folder listing wins; sessionAction returns the reply JSON. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
This commit is contained in:
@@ -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,7 +51,15 @@ 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).
|
||||||
@@ -73,6 +81,8 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
|||||||
| Ctrl-b d | Detach (the session keeps running) |
|
| Ctrl-b d | Detach (the session keeps running) |
|
||||||
| 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. |
|
| 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+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. |
|
| 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) |
|
||||||
|
|||||||
+6
-3
@@ -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 | --unsplit 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"
|
||||||
@@ -164,7 +166,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"
|
||||||
@@ -378,7 +380,8 @@ 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//-/_}" "$@"
|
||||||
|
|||||||
Vendored
+33
@@ -1,3 +1,36 @@
|
|||||||
|
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
|
devdesk (0.10.4) bookworm; urgency=medium
|
||||||
|
|
||||||
* The Sessions panel's list scrolls on its own (#53). There is no cap on
|
* The Sessions panel's list scrolls on its own (#53). There is no cap on
|
||||||
|
|||||||
@@ -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]}'
|
||||||
|
}
|
||||||
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",
|
||||||
|
|||||||
@@ -1195,3 +1195,146 @@ JSONL
|
|||||||
run devdesk attach --unsplit alpha
|
run devdesk attach --unsplit alpha
|
||||||
[ "$(jq -r .closed <<<"$output")" = 0 ]
|
[ "$(jq -r .closed <<<"$output")" = 0 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fs_put() { # SESSION PATH MODE CONTENT-FILE
|
||||||
|
local size
|
||||||
|
size=$(stat -c %s "$4")
|
||||||
|
base64 -w0 "$4" | devdesk attach --fs-put "$1" "$2" "$size" "$3"
|
||||||
|
}
|
||||||
|
last_json() { tail -n1 <<<"$output"; }
|
||||||
|
|
||||||
|
@test "fs-mkdir, fs-move and fs-copy stay inside the folder and never overwrite (#54)" {
|
||||||
|
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
mkdir -p "$p/src" && printf 'a\n' >"$p/src/a.txt" && printf 'b\n' >"$p/b.txt"
|
||||||
|
ln -s "$TMP/outside" "$p/out"
|
||||||
|
ln -s b.txt "$p/link"
|
||||||
|
run devdesk attach --fs-mkdir alpha docs/guides
|
||||||
|
[ "$status" -eq 0 ] && [ -d "$p/docs/guides" ]
|
||||||
|
[ "$(last_json | jq -r .path)" = docs/guides ]
|
||||||
|
run devdesk attach --fs-mkdir alpha docs
|
||||||
|
[[ $output == *"already exists"* ]]
|
||||||
|
for bad in ../x /abs out/x 'a/../b' . '' docs/; do
|
||||||
|
run devdesk attach --fs-mkdir alpha "$bad"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
done
|
||||||
|
[ -z "$(ls -A "$TMP/outside")" ]
|
||||||
|
# out/x once escaped to /x: set -e doesn't reach into $(...) (#54)
|
||||||
|
[ ! -e /x ] && [ ! -e /abs ]
|
||||||
|
|
||||||
|
# rename a file, move a folder into another, never onto something
|
||||||
|
run devdesk attach --fs-move alpha src/a.txt src/renamed.txt
|
||||||
|
[ "$status" -eq 0 ] && [ -f "$p/src/renamed.txt" ] && [ ! -e "$p/src/a.txt" ]
|
||||||
|
run devdesk attach --fs-move alpha b.txt src/renamed.txt
|
||||||
|
[ "$status" -ne 0 ] && [[ $output == *"already exists"* ]]
|
||||||
|
[ "$(cat "$p/b.txt")" = b ]
|
||||||
|
run devdesk attach --fs-move alpha src docs/src
|
||||||
|
[ "$status" -eq 0 ] && [ -f "$p/docs/src/renamed.txt" ]
|
||||||
|
run devdesk attach --fs-move alpha docs docs/guides/docs
|
||||||
|
[ "$status" -ne 0 ] && [[ $output == *"inside itself"* ]]
|
||||||
|
run devdesk attach --fs-move alpha b.txt out/b.txt
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
run devdesk attach --fs-move alpha ../outside x
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
# a symlink moves as a link
|
||||||
|
run devdesk attach --fs-move alpha link link2
|
||||||
|
[ "$status" -eq 0 ] && [ -L "$p/link2" ] && [ -f "$p/b.txt" ]
|
||||||
|
# copy: folders recursively, missing parents created
|
||||||
|
run devdesk attach --fs-copy alpha docs/src new/place/src
|
||||||
|
[ "$status" -eq 0 ] && [ -f "$p/new/place/src/renamed.txt" ] && [ -f "$p/docs/src/renamed.txt" ]
|
||||||
|
run devdesk attach --fs-copy alpha b.txt docs/src/renamed.txt
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "fs-delete moves to the trash and fs-restore puts it back (#54)" {
|
||||||
|
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
mkdir -p "$p/dir/sub" && printf 'x\n' >"$p/dir/sub/x" && printf 'y\n' >"$p/y.txt"
|
||||||
|
ln -s "$TMP/outside" "$p/out"
|
||||||
|
touch "$TMP/outside/keep"
|
||||||
|
run devdesk attach --fs-delete alpha dir y.txt out
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
id=$(last_json | jq -r .trash)
|
||||||
|
[ "$(last_json | jq -c .paths)" = '["dir","y.txt","out"]' ]
|
||||||
|
[ ! -e "$p/dir" ] && [ ! -e "$p/y.txt" ] && [ ! -L "$p/out" ] && [ -f "$TMP/outside/keep" ]
|
||||||
|
# nothing deleted when one path is bad
|
||||||
|
printf 'z\n' >"$p/z.txt"
|
||||||
|
run devdesk attach --fs-delete alpha z.txt ../outside
|
||||||
|
[ "$status" -ne 0 ] && [ -f "$p/z.txt" ]
|
||||||
|
run devdesk attach --fs-delete alpha .
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
# restore; a path taken meanwhile is refused
|
||||||
|
printf 'new\n' >"$p/y.txt"
|
||||||
|
run devdesk attach --fs-restore alpha "$id"
|
||||||
|
[ "$status" -ne 0 ] && [[ $output == *"already exists: y.txt"* ]]
|
||||||
|
rm "$p/y.txt"
|
||||||
|
run devdesk attach --fs-restore alpha "$id"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(cat "$p/dir/sub/x" "$p/y.txt")" = "$(printf 'x\ny')" ] && [ -L "$p/out" ]
|
||||||
|
[ ! -e "$DEVDESK_STATE_DIR/trash/alpha/$id" ]
|
||||||
|
run devdesk attach --fs-restore alpha "../../etc"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "fs-put uploads any file into a folder; fs-download returns it (#54)" {
|
||||||
|
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
head -c 3000 /dev/urandom >"$TMP/bin"
|
||||||
|
run fs_put alpha assets/img/logo.bin new "$TMP/bin"
|
||||||
|
[ "$status" -eq 0 ] && cmp -s "$TMP/bin" "$p/assets/img/logo.bin"
|
||||||
|
[ "$(stat -c %a "$p/assets/img/logo.bin")" = 644 ]
|
||||||
|
run fs_put alpha assets/img/logo.bin new "$TMP/bin"
|
||||||
|
[ "$status" -ne 0 ] && [[ $output == *"already exists"* ]]
|
||||||
|
printf 'v2' >"$TMP/v2"
|
||||||
|
chmod 600 "$p/assets/img/logo.bin"
|
||||||
|
run fs_put alpha assets/img/logo.bin replace "$TMP/v2"
|
||||||
|
[ "$status" -eq 0 ] && [ "$(cat "$p/assets/img/logo.bin")" = v2 ] && [ "$(stat -c %a "$p/assets/img/logo.bin")" = 600 ]
|
||||||
|
: >"$TMP/empty"
|
||||||
|
run fs_put alpha empty.txt new "$TMP/empty"
|
||||||
|
[ "$status" -eq 0 ] && [ -f "$p/empty.txt" ] && [ ! -s "$p/empty.txt" ]
|
||||||
|
ln -s "$TMP/outside" "$p/out"
|
||||||
|
for bad in out/x ../x /x assets; do
|
||||||
|
run fs_put alpha "$bad" new "$TMP/v2"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
done
|
||||||
|
[ -z "$(ls -A "$TMP/outside")" ]
|
||||||
|
run fs_put alpha assets replace "$TMP/v2"
|
||||||
|
[ "$status" -ne 0 ] && [ -d "$p/assets" ]
|
||||||
|
# short stream: nothing left behind
|
||||||
|
run bash -c "printf aGVs | '$REPO/bin/devdesk' attach --fs-put alpha short.txt 6 new"
|
||||||
|
[ "$status" -ne 0 ] && [ ! -e "$p/short.txt" ] && [ -z "$(find "$p" -name '.*devdesk-*')" ]
|
||||||
|
# download: binary body comes back whole
|
||||||
|
run devdesk attach --fs-download alpha assets/img/logo.bin
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(sed -n '/DEVDESK-BLOB-BEGIN/{n;p}' <<<"$output" | base64 -d)" = v2 ]
|
||||||
|
head -c 3000 /dev/urandom >"$p/rand"
|
||||||
|
run devdesk attach --fs-download alpha rand
|
||||||
|
sed -n '/DEVDESK-BLOB-BEGIN/{n;p}' <<<"$output" | base64 -d | cmp -s - "$p/rand"
|
||||||
|
run devdesk attach --fs-download alpha out
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "fs-grep searches files, literally or by regex, git or not (#56)" {
|
||||||
|
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
mkdir -p "$p/src" "$p/node_modules/x"
|
||||||
|
printf 'const Answer = 42;\nlet b = "a:b";\n' >"$p/src/a.js"
|
||||||
|
printf 'answer here\n' >"$p/node_modules/x/i.js"
|
||||||
|
printf '\x00\x01answer' >"$p/bin.dat"
|
||||||
|
run devdesk attach --fs-grep alpha - 'Answer'
|
||||||
|
[ "$(jq -c '[.matches[] | [.p, .l, .t]]' <<<"$output")" = '[["src/a.js",1,"const Answer = 42;"]]' ]
|
||||||
|
run devdesk attach --fs-grep alpha i 'answer'
|
||||||
|
[ "$(jq -r '[.matches[].p] | join(",")' <<<"$output")" = src/a.js ]
|
||||||
|
run devdesk attach --fs-grep alpha - '"a:b"'
|
||||||
|
[ "$(jq -r '.matches[0].t' <<<"$output")" = 'let b = "a:b";' ]
|
||||||
|
run devdesk attach --fs-grep alpha r 'A[n]swer = [0-9]+'
|
||||||
|
[ "$(jq -r '.matches | length' <<<"$output")" = 1 ]
|
||||||
|
run devdesk attach --fs-grep alpha - '[n]'
|
||||||
|
[ "$(jq -r '.matches | length' <<<"$output")" = 0 ]
|
||||||
|
# in a repository: git grep, .gitignore honoured, untracked files included
|
||||||
|
git -C "$p" init -q
|
||||||
|
printf 'node_modules/\n' >"$p/.gitignore"
|
||||||
|
printf 'Answer again\n' >"$p/untracked.txt"
|
||||||
|
run devdesk attach --fs-grep alpha w 'Answer'
|
||||||
|
[ "$(jq -c '[.matches[] | [.p, .l]] | sort' <<<"$output")" = '[["src/a.js",1],["untracked.txt",1]]' ]
|
||||||
|
run devdesk attach --fs-grep alpha x 'Answer'
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
run devdesk attach --fs-grep alpha - ''
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|||||||
+155
-14
@@ -2,11 +2,16 @@
|
|||||||
// languages, a unified diff view, and markdown rendering. Built separately to
|
// 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;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -109,8 +109,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>
|
||||||
|
|||||||
+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
+29
-1
@@ -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);
|
||||||
@@ -817,6 +833,7 @@ function buildCommands() {
|
|||||||
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: () => {
|
||||||
@@ -890,6 +907,7 @@ const KEY_HELP = [
|
|||||||
['Drag', 'Select text; it is copied when you let go'],
|
['Drag', 'Select text; it is copied when you let go'],
|
||||||
['Ctrl+C', 'Copy the selection (interrupts only when nothing is selected)'],
|
['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)'],
|
||||||
@@ -1063,6 +1081,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();
|
||||||
@@ -1115,6 +1140,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) {
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
|
|||||||
+76
-2
@@ -799,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;
|
||||||
@@ -812,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; }
|
||||||
@@ -1070,3 +1070,77 @@ 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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user