diff --git a/Makefile b/Makefile index 3010c4c..4463f05 100644 --- a/Makefile +++ b/Makefile @@ -41,7 +41,7 @@ install: all install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \ libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \ libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix libexec/devdesk-guard $(DESTDIR)$(LIBDIR)/ - install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh libexec/devdesk-history.sh libexec/devdesk-ops.sh \ + install -m 0644 libexec/devdesk-files.sh libexec/devdesk-fsops.sh libexec/devdesk-inbox.sh libexec/devdesk-history.sh libexec/devdesk-ops.sh \ $(DESTDIR)$(LIBDIR)/ install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/ diff --git a/README.md b/README.md index 796128b..9c26aae 100644 --- a/README.md +++ b/README.md @@ -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. - 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. -- **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. - **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 (`regexreason`). 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) | | Drag | Selects text; it stays highlighted and is copied when you let go. Double-click selects a word, triple-click a line. Clicks on the tmux status line still go to tmux. | | Ctrl+C | Copies the selection when there is one; otherwise interrupts as usual | +| Ctrl+Shift+F | Search in the session's files | +| F2 / Delete / Ctrl+C·X·V (explorer) | Rename, move to trash, copy/cut/paste files | | Palette → Close split panes | Removes extra tmux panes (a stray `^b %` split) and keeps Claude. tmux's right-click menus are off. | | Ctrl+Shift+C / V | Copy selection / paste | | Ctrl+Shift+P | Command palette (also the ⌕ button) | diff --git a/bin/devdesk b/bin/devdesk index ed3ee43..4659881 100755 --- a/bin/devdesk +++ b/bin/devdesk @@ -15,7 +15,7 @@ # # 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 | -# --last-reply NAME | --unsplit NAME | --existing NAME` +# --last-reply NAME | --unsplit NAME | --fs-* SESSION … | --existing NAME` # through ttyd; those print JSON. set -euo pipefail @@ -29,6 +29,8 @@ set -euo pipefail . "$DEVDESK_LIBEXEC/devdesk-history.sh" # shellcheck source=libexec/devdesk-ops.sh . "$DEVDESK_LIBEXEC/devdesk-ops.sh" +# shellcheck source=libexec/devdesk-fsops.sh +. "$DEVDESK_LIBEXEC/devdesk-fsops.sh" usage() { sed -n '2,13s/^# \{0,1\}//p' "$0" @@ -164,7 +166,7 @@ dd_worktree_add() { else "${g[@]}" worktree add -q -b "$branch" "$wt" HEAD >&2 || dd_die "git worktree add failed" fi - wt=$(realpath -e -- "$wt") + wt=$(realpath -e -- "$wt") || dd_die "git worktree add failed" # shellcheck disable=SC2016 # jq variables dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {worktree: {path: $w, repo: $r, branch: $b}})' \ --arg n "$name" --arg w "$wt" --arg r "$top" --arg b "$branch" @@ -378,7 +380,8 @@ cmd_attach() { dd_linger return ;; - --fs-list | --fs-find | --fs-read | --fs-write | --git-status | --git-show | --asset) + --fs-list | --fs-find | --fs-read | --fs-write | --git-status | --git-show | --asset | \ + --fs-mkdir | --fs-move | --fs-copy | --fs-delete | --fs-restore | --fs-put | --fs-download | --fs-grep) local action=${1#--} shift "cmd_${action//-/_}" "$@" diff --git a/debian/changelog b/debian/changelog index acfb206..bca9f2d 100644 --- a/debian/changelog +++ b/debian/changelog @@ -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 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 diff --git a/libexec/devdesk-fsops.sh b/libexec/devdesk-fsops.sh new file mode 100644 index 0000000..baa86f1 --- /dev/null +++ b/libexec/devdesk-fsops.sh @@ -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]}' +} diff --git a/package-lock.json b/package-lock.json index 666adfa..f65e9ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "devdesk", "license": "MIT", "devDependencies": { + "@codemirror/autocomplete": "6.20.3", "@codemirror/commands": "6.11.1", "@codemirror/lang-markdown": "6.5.2", "@codemirror/language": "6.13.1", diff --git a/package.json b/package.json index c162f6e..c7ac2f9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "node --test tests/*.test.mjs" }, "devDependencies": { + "@codemirror/autocomplete": "6.20.3", "@codemirror/commands": "6.11.1", "@codemirror/lang-markdown": "6.5.2", "@codemirror/language": "6.13.1", diff --git a/tests/attach.bats b/tests/attach.bats index 2ab8d05..7e827d9 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -1195,3 +1195,146 @@ JSONL 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 ] +} diff --git a/web/editor/index.js b/web/editor/index.js index 3fc5a78..41ec66c 100644 --- a/web/editor/index.js +++ b/web/editor/index.js @@ -2,11 +2,16 @@ // languages, a unified diff view, and markdown rendering. Built separately to // dist/assets/editor.js (window.DevDeskEditor) and loaded over the ttyd channel // the first time the Files pane opens, so the page itself stays small. -import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars } from '@codemirror/view'; +import { + EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars, + rectangularSelection, crosshairCursor, dropCursor, +} from '@codemirror/view'; import { EditorState } from '@codemirror/state'; import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; -import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language'; -import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap, indentOnInput } from '@codemirror/language'; +import { search, searchKeymap, highlightSelectionMatches, gotoLine as gotoLineDialog } from '@codemirror/search'; +import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; +import { highlightTree, classHighlighter } from '@lezer/highlight'; import { markdownLanguage } from '@codemirror/lang-markdown'; import { unifiedMergeView } from '@codemirror/merge'; import { tags as t } from '@lezer/highlight'; @@ -27,7 +32,7 @@ import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'; import { lua } from '@codemirror/legacy-modes/mode/lua'; import { perl } from '@codemirror/legacy-modes/mode/perl'; import { ruby } from '@codemirror/legacy-modes/mode/ruby'; -import { marked } from 'marked'; +import { Marked } from 'marked'; import DOMPurify from 'dompurify'; const MODES = { @@ -121,20 +126,58 @@ const theme = EditorView.theme({ '.cm-deletedLineGutter': { backgroundColor: 'var(--accent-red)' }, }); -function baseExtensions(path, { readOnly = true, wrap = false } = {}) { +function baseExtensions(path, { readOnly = true, wrap = false, onCursor } = {}) { return [ lineNumbers(), foldGutter(), highlightSpecialChars(), highlightActiveLine(), highlightActiveLineGutter(), - drawSelection(), history(), search({ top: true }), highlightSelectionMatches(), bracketMatching(), + drawSelection(), dropCursor(), history(), search({ top: true }), highlightSelectionMatches(), bracketMatching(), + // VS Code habits (devdesk#55): Alt+click adds a cursor, Alt+drag selects a + // column, brackets and quotes close themselves, Ctrl+G goes to a line. + EditorState.allowMultipleSelections.of(true), rectangularSelection(), crosshairCursor(), + readOnly ? [] : [closeBrackets(), indentOnInput()], syntaxHighlighting(highlight), theme, languageExtension(languageFor(path)), + onCursor ? EditorView.updateListener.of((update) => { + if (update.selectionSet || update.docChanged || update.focusChanged) onCursor(cursorInfo(update.state)); + }) : [], // Read-only document in an editable view: the caret, selection, arrow keys // and Ctrl+F work, typing doesn't; no on-screen keyboard on phones. EditorState.readOnly.of(readOnly), readOnly ? EditorView.contentAttributes.of({ inputmode: 'none' }) : [], - keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap]), + keymap.of([ + { key: 'Mod-g', run: gotoLineDialog, preventDefault: true }, + ...(readOnly ? [] : closeBracketsKeymap), ...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap, + ]), wrap ? EditorView.lineWrapping : [], ]; } +// Where the cursor is, for the status bar: line, column, selections, selected +// characters; plus the document's line endings and indentation. +function cursorInfo(state) { + const main = state.selection.main; + const line = state.doc.lineAt(main.head); + let selected = 0; + for (const r of state.selection.ranges) selected += r.to - r.from; + return { line: line.number, col: main.head - line.from + 1, cursors: state.selection.ranges.length, selected, lines: state.doc.lines }; +} + +// Indentation a file uses, guessed from its first indented lines: "Tabs" or "Spaces: N". +export function detectIndent(text) { + let tabs = 0; + const widths = {}; + for (const line of text.split('\n', 2000)) { + if (line.startsWith('\t')) tabs += 1; + else { + const m = /^( +)\S/.exec(line); + if (m) widths[m[1].length] = (widths[m[1].length] ?? 0) + 1; + } + } + const spaced = Object.values(widths).reduce((a, b) => a + b, 0); + if (tabs > spaced) return 'Tabs'; + if (!spaced) return null; + const unit = [2, 4, 8, 3].find((w) => Object.keys(widths).every((k) => Number(k) % w === 0)) ?? 4; + return `Spaces: ${unit}`; +} + // Put the cursor on a line (1-based) and scroll it to the middle. function gotoLine(view, line) { const target = view.state.doc.line(Math.max(1, Math.min(line, view.state.doc.lines))); @@ -143,21 +186,22 @@ function gotoLine(view, line) { } // Read-only view of a file. -export function viewer(parent, { doc, path, wrap }) { - const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) }); +export function viewer(parent, { doc, path, wrap, onCursor }) { + const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap, onCursor }) }) }); return { view, goto: (line) => gotoLine(view, line), destroy: () => view.destroy() }; } // Editable file (devdesk#20): onChange(text) after every edit, onSave() on // Ctrl/Cmd+S (which never reaches the browser or the terminal). -export function editor(parent, { doc, path, wrap, onChange, onSave }) { +export function editor(parent, { doc, path, wrap, onChange, onSave, onCursor, onScroll }) { const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: [ keymap.of([{ key: 'Mod-s', preventDefault: true, run: () => { onSave?.(); return true; } }, indentWithTab]), - baseExtensions(path, { readOnly: false, wrap }), + baseExtensions(path, { readOnly: false, wrap, onCursor }), + onScroll ? EditorView.domEventHandlers({ scroll: (event, v) => { onScroll(v.scrollDOM); } }) : [], EditorView.updateListener.of((update) => { if (update.docChanged) onChange?.(update.state.doc.toString()); }), @@ -167,6 +211,7 @@ export function editor(parent, { doc, path, wrap, onChange, onSave }) { return { view, goto: (line) => gotoLine(view, line), + info: () => cursorInfo(view.state), text: () => view.state.doc.toString(), // Replace the whole document (reload from disk) keeping the cursor roughly where it was. setText: (text) => { @@ -189,7 +234,103 @@ export function diff(parent, { original, modified, path, wrap }) { return { view, destroy: () => view.destroy() }; } -// Markdown to sanitized HTML (shown in a sandboxed iframe on top of this). -export function markdown(text) { - return DOMPurify.sanitize(marked.parse(text, { gfm: true, breaks: false }), { USE_PROFILES: { html: true } }); +// ---------------------------------------------------------------- markdown (devdesk#55) + +// Fence names and aliases -> a language for highlighting. +const FENCE = { + sh: 'shell', bash: 'shell', zsh: 'shell', shell: 'shell', console: 'shell', shellsession: 'shell', env: 'shell', dotenv: 'shell', + py: 'python', python: 'python', js: 'javascript', javascript: 'javascript', jsx: 'javascript', mjs: 'javascript', node: 'javascript', + ts: 'typescript', typescript: 'typescript', tsx: 'typescript', json: 'json', jsonc: 'json', json5: 'json', + yml: 'yaml', yaml: 'yaml', toml: 'toml', ini: 'properties', conf: 'properties', cfg: 'properties', properties: 'properties', systemd: 'properties', + nginx: 'nginx', diff: 'diff', patch: 'diff', css: 'css', scss: 'css', html: 'html', xml: 'xml', svg: 'xml', + go: 'go', golang: 'go', rust: 'rust', rs: 'rust', sql: 'sql', dockerfile: 'dockerfile', docker: 'dockerfile', + lua: 'lua', perl: 'perl', pl: 'perl', ruby: 'ruby', rb: 'ruby', md: 'markdown', markdown: 'markdown', +}; +const parsers = new Map(); +function parserFor(lang) { + const name = FENCE[(lang || '').toLowerCase()]; + if (!name) return null; + if (!parsers.has(name)) parsers.set(name, name === 'markdown' ? markdownLanguage.parser : StreamLanguage.define(MODES[name]).parser); + return parsers.get(name); +} + +const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + +// Code as HTML with tok-* classes (CodeMirror's own parsers, no extra library). +export function highlightToHtml(code, lang) { + const parser = parserFor(lang); + if (!parser || code.length > 200000) return escapeHtml(code); + let out = ''; + let pos = 0; + try { + highlightTree(parser.parse(code), classHighlighter, (from, to, classes) => { + if (from > pos) out += escapeHtml(code.slice(pos, from)); + out += `${escapeHtml(code.slice(from, to))}`; + 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 `${inner}\n`; + }, + code({ text: code, lang }) { + const name = (lang || '').split(/\s+/)[0]; + return `
${highlightToHtml(code.replace(/\n$/, ''), name)}
\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; } diff --git a/web/index.template.html b/web/index.template.html index ab541fe..b5d968c 100644 --- a/web/index.template.html +++ b/web/index.template.html @@ -109,8 +109,17 @@
+ + + + + + +
-
+
+ +
diff --git a/web/src/connection.js b/web/src/connection.js index bf42971..369a44e 100644 --- a/web/src/connection.js +++ b/web/src/connection.js @@ -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]))); } +// Explorer uploads (devdesk#54): any file (up to 25 MB) to PATH in the session's +// folder, folders created as needed. mode "new" refuses to replace; "replace" +// overwrites. Resolves {ok, path, size}; rejects "already exists: …" etc. +export function putFile(session, path, file, mode = 'new', onProgress) { + if (file.size > UPLOAD_MAX) return Promise.reject(new Error('larger than 25 MB')); + return limited(() => streamIn(['--fs-put', session, path, String(file.size), mode], 'DEVDESK-PUT-READY', file, onProgress)); +} + +// A file of the session as a Blob, for the browser to save (up to 25 MB). +export async function downloadFile(session, path) { + const { header, base64 } = await runBlob(['--fs-download', session, path], 180000); + if (base64 === null || base64 === undefined) throw new Error('no content'); + const bin = atob(base64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i); + return { name: (header.path || path).split('/').pop(), blob: new Blob([bytes]) }; +} + // Run a devdesk command that reads base64 from the terminal after printing // `ready`: stream `blob` in slices (never all in memory), then resolve the JSON // reply. Fails on 20 s of inactivity rather than a fixed total (devdesk#9). @@ -379,7 +397,14 @@ export async function fetchRepo(session) { // rejects with devdesk's own error message. export async function sessionAction(action, ...args) { const output = stripAnsi(await runCommand([`--${action}`, ...args])).trim(); - if (/"ok"\s*:\s*true/.test(output)) return; + // Success: the reply's JSON ({ok: true, …}) for callers that need its fields. + if (/"ok"\s*:\s*true/.test(output)) { + try { + return parseListOutput(output); + } catch { + return { ok: true }; + } + } const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response'; throw new Error(message.replace(/^devdesk:\s*/, '')); } diff --git a/web/src/files.js b/web/src/files.js index b60e58f..466032e 100644 --- a/web/src/files.js +++ b/web/src/files.js @@ -2,14 +2,16 @@ // Files pane (devdesk#19, #37), laid out like VS Code: an explorer tree (or the // git changes list) beside an editor area with tabs. Read the session's files // with syntax highlighting, preview markdown and images, diff against HEAD, -// see what Claude edited, and jump to any file by name. Read-only for now. +// see what Claude edited, and jump to any file by name. Files are editable +// (devdesk#20); the explorer creates, renames, moves, copies, deletes (to a trash, +// with Undo), uploads and downloads with the mouse and the keyboard (devdesk#54). // Everything goes through `devdesk attach --fs-*` over ttyd. -import { runBlob, runCommand, writeFile } from './connection.js'; +import { downloadFile, putFile, runBlob, runCommand, sessionAction, writeFile } from './connection.js'; import { parseListOutput } from './protocol.js'; import { el } from './dom.js'; import { ago } from './sessions.js'; -import { CommandPalette, openDialog, toast } from './ui.js'; +import { CommandPalette, confirmDialog, contextMenu, openDialog, toast } from './ui.js'; // ---------------------------------------------------------------- editor bundle @@ -87,6 +89,37 @@ export function loadEditor() { const size = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`); const parent = (path) => path.split('/').slice(0, -1).join('/'); +const join = (dir, name) => (dir ? `${dir}/${name}` : name); +const within = (path, dir) => path === dir || path.startsWith(`${dir}/`); +const DRAG_TYPE = 'application/x-devdesk-paths'; +// "name.ext" -> "name copy.ext", "name copy 2.ext", … +function copyName(name, n) { + const dot = name.lastIndexOf('.'); + const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']; + return `${stem} copy${n > 1 ? ` ${n}` : ''}${ext}`; +} +// "name.ext" -> "name (1).ext" for uploads that keep both. +function numbered(name, n) { + const dot = name.lastIndexOf('.'); + const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']; + return `${stem} (${n})${ext}`; +} +// A name typed in the explorer: optional folders, never . or .. or empty parts. +function validRelative(value, allowSlash) { + const v = value.trim(); + if (!v || v.startsWith('/') || v.endsWith('/')) return null; + if (!allowSlash && v.includes('/')) return null; + if (v.split('/').some((part) => !part || part === '.' || part === '..')) return null; + return v; +} +function saveBlob(name, blob) { + const url = URL.createObjectURL(blob); + const a = el('a', { href: url, download: name }); + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60000); +} const isMarkdown = (path) => /\.(md|markdown|mdx)$/i.test(path); const STATUS = { @@ -98,51 +131,84 @@ function statusOf(f) { return STATUS[code] ?? STATUS.M; } -// Markdown preview document: sanitized HTML in a sandboxed iframe (no scripts, -// no same-origin), styled from the page's current TDS colours. -function previewDocument(html) { - const css = getComputedStyle(document.documentElement); - const v = (name) => css.getPropertyValue(name).trim(); - return `${html}`; -} +// ---------------------------------------------------------------- markdown preview (devdesk#55) -// Relative in a markdown file (e.g. docs/screenshot.png) can't load from -// the sandboxed preview: fetch up to 10 of them over ttyd and inline them. -async function inlineImages(html, session, mdPath) { - const doc = new DOMParser().parseFromString(`${html}`, 'text/html'); - const images = [...doc.querySelectorAll('img[src]')] - .filter((img) => !/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(img.getAttribute('src'))).slice(0, 10); - if (!images.length) return html; - const base = parent(mdPath); - await Promise.all(images.map(async (img) => { - const parts = []; - for (const part of `${base}/${decodeURIComponent(img.getAttribute('src').split(/[?#]/)[0])}`.split('/')) { - if (part === '..') parts.pop(); - else if (part && part !== '.') parts.push(part); - } - try { - const { header, base64 } = await runBlob(['--fs-read', session, parts.join('/')]); - if (header.mime?.startsWith('image/') && base64) img.setAttribute('src', `data:${header.mime};base64,${base64}`); - } catch { - // missing image: leave the broken link - } - })); - return doc.body.innerHTML; +// The preview renders in a shadow root: DOMPurify (in the editor bundle) keeps +// scripts, styles, forms and frames out, the shadow keeps the page's CSS out, and +// the page's TDS colour variables still reach it, so light/dark switches live. +const MD_CSS = ` +:host { display: block; height: 100%; overflow: auto; background: var(--bg-terminal); container-type: inline-size; } +.layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 28px; max-width: 1100px; margin: 0 auto; padding: 22px 32px 60px; } +@container (min-width: 760px) { .layout.has-outline { grid-template-columns: minmax(0, 1fr) 180px; } .outline { display: block; } } +.md { min-width: 0; max-width: 860px; color: var(--text-primary); font: 15px/1.7 system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif; overflow-wrap: anywhere; } +.md > :first-child { margin-top: 0; } +h1, h2, h3, h4, h5, h6 { position: relative; margin: 1.6em 0 0.6em; line-height: 1.3; color: var(--text-primary); font-weight: 650; scroll-margin-top: 12px; } +h1 { font-size: 1.9em; padding-bottom: 0.3em; border-bottom: 1px solid var(--border-color); } +h2 { font-size: 1.45em; padding-bottom: 0.25em; border-bottom: 1px solid var(--border-dim); } +h3 { font-size: 1.2em; } h4 { font-size: 1.05em; } h5, h6 { font-size: 0.95em; color: var(--text-secondary); } +.dd-anchor { position: absolute; left: -1.1em; padding-right: 0.3em; color: var(--text-muted); text-decoration: none; opacity: 0; font-weight: 400; } +h1:hover .dd-anchor, h2:hover .dd-anchor, h3:hover .dd-anchor, h4:hover .dd-anchor, .dd-anchor:focus { opacity: 1; } +p, ul, ol, table, pre, blockquote, details, .alert { margin: 0 0 1em; } +a { color: var(--accent-cyan); text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 40%, transparent); text-underline-offset: 2px; } +a:hover { color: var(--accent-orange); } +ul, ol { padding-left: 1.8em; } li + li { margin-top: 0.25em; } li > p { margin: 0.4em 0; } +li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.4em; } +input[type="checkbox"] { margin: 0 0.5em 0 0; vertical-align: -0.1em; accent-color: var(--accent-green); } +strong { font-weight: 650; } hr { height: 0; margin: 1.8em 0; border: 0; border-top: 1px solid var(--border-color); } +img { max-width: 100%; height: auto; } img[data-broken] { outline: 1px dashed var(--accent-red); } +code, kbd, samp { font-family: var(--font-mono); font-size: 0.86em; } +:not(pre) > code { padding: 0.15em 0.4em; background: var(--bg-tertiary); color: var(--accent-amber); border-radius: 3px; } +kbd { padding: 0.1em 0.45em; border: 1px solid var(--border-color); border-bottom-width: 2px; border-radius: 3px; background: var(--bg-secondary); } +pre.dd-code { position: relative; padding: 14px 16px; overflow: auto; background: var(--bg-secondary); border: 1px solid var(--border-dim); border-radius: 4px; line-height: 1.5; } +pre.dd-code code { color: var(--text-primary); font-size: 13px; white-space: pre; } +pre.dd-code[data-lang]:not([data-lang=""])::before { content: attr(data-lang); position: absolute; top: 4px; right: 44px; color: var(--text-muted); font: 11px var(--font-mono); text-transform: lowercase; } +.copy { position: absolute; top: 4px; right: 4px; padding: 2px 7px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-secondary); font: 11px var(--font-mono); cursor: pointer; opacity: 0; } +pre.dd-code:hover .copy, .copy:focus { opacity: 1; } +.copy:hover { color: var(--accent-cyan); border-color: var(--accent-cyan); } +blockquote { padding: 0 1em; color: var(--text-secondary); border-left: 4px solid var(--border-color); } +table { display: block; width: max-content; max-width: 100%; overflow: auto; border-collapse: collapse; } +th, td { padding: 6px 13px; border: 1px solid var(--border-color); } +th { background: var(--bg-secondary); font-weight: 650; } tr:nth-child(2n) td { background: color-mix(in srgb, var(--bg-secondary) 45%, transparent); } +details { padding: 6px 12px; border: 1px solid var(--border-dim); } summary { cursor: pointer; font-weight: 600; } +.alert { padding: 8px 16px; border-left: 4px solid var(--c); background: color-mix(in srgb, var(--c) 9%, transparent); } +.alert > :last-child { margin-bottom: 0; } +.alert-title { display: block; margin-bottom: 4px; color: var(--c); font-weight: 650; font-size: 0.92em; } +.alert-note { --c: var(--accent-cyan); } .alert-tip { --c: var(--accent-green); } .alert-important { --c: var(--accent-purple); } +.alert-warning { --c: var(--accent-amber); } .alert-caution { --c: var(--accent-red); } +.meta { margin: 0 0 1.4em; font-size: 0.85em; } .meta table { width: auto; } .meta th { text-align: left; color: var(--text-secondary); font-weight: 500; } +.outline { display: none; position: sticky; top: 12px; align-self: start; max-height: calc(100vh - 160px); overflow: auto; padding-left: 12px; border-left: 1px solid var(--border-dim); font: 12px/1.5 system-ui, sans-serif; } +.outline-title { margin-bottom: 6px; color: var(--text-muted); font: 11px var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase; } +.outline a { display: block; padding: 2px 0; color: var(--text-secondary); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.outline a:hover, .outline a.is-current { color: var(--accent-cyan); } +.outline .d3 { padding-left: 12px; } .outline .d4 { padding-left: 24px; } +.flash { animation: flash 1.4s ease-out; } @keyframes flash { from { background: var(--accent-amber-dim); } to { background: transparent; } } +.tok-keyword, .tok-operator { color: var(--accent-orange); } .tok-string, .tok-string2 { color: var(--accent-green); } +.tok-comment, .tok-meta { color: var(--text-muted); font-style: italic; } .tok-number, .tok-bool, .tok-atom { color: var(--accent-purple); } +.tok-typeName, .tok-className, .tok-namespace { color: var(--accent-amber); } +.tok-variableName.tok-definition, .tok-propertyName, .tok-attributeName, .tok-function { color: var(--accent-cyan); } +.tok-tagName { color: var(--accent-orange); } .tok-heading { color: var(--accent-cyan); font-weight: bold; } .tok-link, .tok-url { color: var(--accent-cyan); } +.tok-inserted { color: var(--accent-green); } .tok-deleted { color: var(--accent-red); } .tok-invalid { color: var(--accent-red); } +@media print { .outline, .copy, .dd-anchor { display: none !important; } } +`; + +const ALERTS = { NOTE: 'Note', TIP: 'Tip', IMPORTANT: 'Important', WARNING: 'Warning', CAUTION: 'Caution' }; + +const escapeText = (text) => String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + +const LANG_NAMES = { + markdown: 'Markdown', shell: 'Shell', python: 'Python', javascript: 'JavaScript', typescript: 'TypeScript', json: 'JSON', + yaml: 'YAML', toml: 'TOML', properties: 'INI', nginx: 'nginx', diff: 'Diff', css: 'CSS', xml: 'XML', html: 'HTML', + go: 'Go', rust: 'Rust', sql: 'SQL', dockerfile: 'Dockerfile', lua: 'Lua', perl: 'Perl', ruby: 'Ruby', +}; + +// Resolve a link or image path in a markdown file against its folder. +function resolveRelative(mdPath, href) { + const parts = []; + for (const part of `${parent(mdPath)}/${href}`.split('/')) { + if (part === '..') parts.pop(); + else if (part && part !== '.') parts.push(part); + } + return parts.join('/'); } // ---------------------------------------------------------------- prefs @@ -251,6 +317,7 @@ export class FilesPane { this.session = null; this.listings = new Map(); // dir -> listing (per session; cleared on switch) this.loadingDirs = new Set(); + this.listSeq = new Map(); // dir -> number of the latest listing request this.changes = null; this.tabs = []; // { path, mode, pinned } this.active = null; @@ -260,6 +327,11 @@ export class FilesPane { this.focusIndex = 0; this.wrap = readJson('wrap', false); this.fileList = null; // go-to-file cache { at, files } + this.selected = new Set(); // explorer selection (devdesk#54) + this.anchor = null; // shift-click range start + this.clip = null; // { op: 'copy' | 'cut', paths, session } + this.editing = null; // { kind: 'rename' | 'new-file' | 'new-folder', path?, dir? } + this.dropDir = null; // folder under a drag this.explorer = root.querySelector('.dd-explorer-body'); this.tabStrip = root.querySelector('.dd-tabs'); @@ -270,9 +342,13 @@ export class FilesPane { root.querySelector('.dd-files-refresh').addEventListener('click', () => this.refresh()); root.querySelector('.dd-files-close').addEventListener('click', () => this.close()); root.querySelector('.dd-files-find').addEventListener('click', () => this.quickOpen.open()); - root.querySelector('.dd-files-new').addEventListener('click', () => this.newFile()); + root.querySelector('.dd-files-new').addEventListener('click', () => { + this.open('files'); + this.startNew('new-file'); + }); root.querySelector('.dd-explorer-toggle').addEventListener('click', () => this.setExplorer(!this.explorerShown())); this.explorer.addEventListener('keydown', (event) => this.treeKey(event)); + this.wireExplorerOps(); root.addEventListener('keydown', (event) => { if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.code === 'KeyP') { event.preventDefault(); @@ -352,6 +428,11 @@ export class FilesPane { else this.open(view); } + // Esc in the explorer first drops a multi-selection or a cut, like VS Code. + wantsEscape(event) { + return this.isOpen && this.explorer.contains(event.target) && (this.selected.size > 1 || Boolean(this.clip)); + } + escape() { if (this.root.classList.contains('is-narrow') && this.explorerShown() && this.active) this.setExplorer(false); else this.close(); @@ -365,7 +446,7 @@ export class FilesPane { if (!this.session) return; writeJson(this.stateKey(), { expanded: [...this.expanded], view: this.view, active: this.active, - tabs: this.tabs.filter((t) => t.pinned || t.key === this.active).map(({ path, mode }) => ({ path, mode })), + tabs: this.tabs.filter((t) => t.pinned || t.key === this.active).map(({ path, mode, split }) => ({ path, mode, split })), }); } @@ -396,16 +477,21 @@ export class FilesPane { // ------------------------------------------------------------ data + // The newest request for a folder wins: a listing that was already on its way + // when the folder changed (a rename, a move, an upload) is dropped, never shown + // over the fresh one (devdesk#54). async listDir(dir) { - if (this.loadingDirs.has(dir)) return; const session = this.session; + const seq = (this.listSeq.get(dir) ?? 0) + 1; + this.listSeq.set(dir, seq); + const current = () => session === this.session && this.listSeq.get(dir) === seq; this.loadingDirs.add(dir); try { const listing = parseListOutput(await runCommand(['--fs-list', session, dir || '.'], 15000)); - if (session !== this.session) return; + if (!current()) return; this.listings.set(dir, listing); } catch (error) { - if (session !== this.session) return; + if (!current()) return; if (dir) { this.expanded.delete(dir); toast.error(`${dir}: ${error.message}`); @@ -413,9 +499,9 @@ export class FilesPane { this.listings.set('', { error: error.message, entries: [] }); } } finally { - this.loadingDirs.delete(dir); + if (this.listSeq.get(dir) === seq) this.loadingDirs.delete(dir); } - if (session === this.session) this.renderExplorer(); + if (current()) this.renderExplorer(); } async loadChanges() { @@ -509,6 +595,7 @@ export class FilesPane { } render() { + this.root.dataset.view = this.view; for (const b of this.viewButtons) b.setAttribute('aria-selected', String(b.dataset.filesView === this.view)); this.renderExplorer(); this.renderTabs(); @@ -517,10 +604,13 @@ export class FilesPane { // ------------------------------------------------------------ explorer - // Visible rows: the root listing plus every expanded folder's children. + // Visible rows: the root listing plus every expanded folder's children, and + // the name box of a file or folder being created (at the top of its folder). rows() { const out = []; + const creating = this.editing && this.editing.kind !== 'rename' ? this.editing : null; const walk = (dir, depth) => { + if (creating?.dir === dir) out.push({ kind: 'new', depth, dir, folder: creating.kind === 'new-folder' }); const listing = this.listings.get(dir); if (!listing) { out.push({ kind: 'loading', depth, dir }); @@ -547,6 +637,10 @@ export class FilesPane { this.renderChangesList(); return; } + if (this.view === 'search') { + this.renderSearch(); + return; + } const root = this.listings.get(''); if (root?.error) { this.explorer.replaceChildren(el('div', { class: 'dd-files-msg is-error' }, root.error)); @@ -554,12 +648,19 @@ export class FilesPane { } const activePath = this.tabs.find((t) => t.key === this.active)?.path; const changed = new Map((this.changes?.files ?? []).filter((f) => f.path).map((f) => [f.path, f])); + const cut = this.clip?.op === 'cut' && this.clip.session === this.session ? this.clip.paths : []; this.treeRows = this.rows(); this.focusIndex = Math.min(this.focusIndex, Math.max(0, this.treeRows.length - 1)); + let input = null; const nodes = this.treeRows.map((row, i) => { const indent = { style: `padding-left:${8 + row.depth * 14}px` }; if (row.kind === 'loading') return el('div', { class: 'dd-tree-row is-note', ...indent }, 'Loading…'); if (row.kind === 'note') return el('div', { class: 'dd-tree-row is-note', ...indent }, row.text); + if (row.kind === 'new') { + input = this.nameBox('', row.folder ? 'folder name (a/b for nested)' : 'file name (a/b.txt for nested)'); + return el('div', { class: 'dd-tree-row is-editing', ...indent }, + el('span', { class: `dd-ficon tone-${row.folder ? 'dir' : 'muted'}`, 'aria-hidden': 'true' }, row.folder ? '▸' : '·'), input); + } const { entry, path } = row; const isDir = row.kind === 'dir'; const open = isDir && this.expanded.has(path); @@ -567,32 +668,59 @@ export class FilesPane { const dirty = isDir && [...changed.keys()].some((p) => p.startsWith(`${path}/`)); const [icon, tone] = isDir ? [open ? '▾' : '▸', 'dir'] : iconFor(entry.n); const status = change ? statusOf(change) : null; + const renaming = this.editing?.kind === 'rename' && this.editing.path === path; + if (renaming) input = this.nameBox(entry.n, 'new name'); + const selected = this.selected.has(path); return el('div', { - class: `dd-tree-row${isDir ? ' is-dir' : ''}${entry.ig ? ' is-ignored' : ''}${path === activePath ? ' is-active' : ''}${status ? ` git-${status[2]}` : ''}${dirty ? ' git-dirty' : ''}`, - role: 'treeitem', 'aria-expanded': isDir ? String(open) : null, 'aria-level': String(row.depth + 1), - tabindex: i === this.focusIndex ? '0' : '-1', 'data-index': String(i), + class: `dd-tree-row${isDir ? ' is-dir' : ''}${entry.ig ? ' is-ignored' : ''}${path === activePath ? ' is-active' : ''}${selected ? ' is-selected' : ''}${cut.some((c) => within(path, c)) ? ' is-cut' : ''}${path === this.dropDir ? ' is-drop' : ''}${renaming ? ' is-editing' : ''}${status ? ` git-${status[2]}` : ''}${dirty ? ' git-dirty' : ''}`, + role: 'treeitem', 'aria-expanded': isDir ? String(open) : null, 'aria-level': String(row.depth + 1), 'aria-selected': String(selected), + tabindex: i === this.focusIndex ? '0' : '-1', 'data-index': String(i), 'data-path': path, draggable: renaming ? null : 'true', title: `${path}${entry.t === 'l' ? ' (symlink)' : ''}${status ? ` · ${status[1]}` : ''}${change?.claude ? ' · edited by Claude' : ''}`, ...indent, - onclick: () => { + onclick: (event) => { + if (renaming) return; this.focusIndex = i; + const plain = this.selectRow(path, event); + if (!plain) return; if (isDir) this.toggleDir(path); else this.openFile(path); }, ondblclick: () => { - if (!isDir) this.pin(path); + if (!isDir && !renaming) this.pin(path); }, }, el('span', { class: `dd-ficon tone-${tone}`, 'aria-hidden': 'true' }, icon), - el('span', { class: 'dd-tree-name' }, entry.n), + renaming ? input : el('span', { class: 'dd-tree-name' }, entry.n), change?.claude ? el('span', { class: 'dd-claude-chip', title: 'Claude edited this file' }, 'C') : null, status ? el('span', { class: `dd-file-status is-${status[2]}` }, status[0]) : null, dirty ? el('span', { class: 'dd-dirty-dot', title: 'contains changes' }) : null); }); - if (!nodes.length) nodes.push(el('div', { class: 'dd-files-msg' }, 'Empty folder.')); + if (!nodes.length) nodes.push(el('div', { class: 'dd-files-msg' }, 'Empty folder. Drop files here to upload them.')); const hadFocus = this.explorer.contains(document.activeElement); + const scroll = this.explorer.scrollTop; + // A re-render while you type keeps the caret and selection of the name box. + const old = this.explorer.querySelector('.dd-tree-input'); + const caret = old ? [old.selectionStart, old.selectionEnd, old.selectionDirection] : null; this.explorer.setAttribute('role', 'tree'); + this.explorer.classList.toggle('is-drop', this.dropDir === ''); this.explorer.replaceChildren(...nodes); - if (hadFocus) this.focusTree(); + this.explorer.scrollTop = scroll; + if (input) { + input.focus(); + if (!this.editing.shown) { + // First show: select the name without its extension, like VS Code. + this.editing.shown = true; + const dot = input.value.lastIndexOf('.'); + input.setSelectionRange(0, this.editing.kind === 'rename' && dot > 0 ? dot : input.value.length); + input.scrollIntoView({ block: 'nearest' }); + } else if (caret) { + input.setSelectionRange(...caret); + } else { + input.setSelectionRange(input.value.length, input.value.length); + } + } else if (hadFocus) { + this.focusTree(); + } } toggleDir(path, open = !this.expanded.has(path)) { @@ -612,38 +740,75 @@ export class FilesPane { row?.focus(); } - // ↑/↓ move, → open folder / step in, ← close folder / step out, Enter/Space open. + // ↑/↓ move (⇧ extends the selection), → open folder / step in, ← close folder / + // step out, Enter/Space open; F2 rename, Delete to the trash, Ctrl+C/X/V copy, + // cut and paste files, Ctrl+A select all (devdesk#54). treeKey(event) { - if (this.view !== 'explorer' || !this.treeRows?.length) return; + if (this.view !== 'explorer' || !this.treeRows?.length || this.editing) return; const row = this.treeRows[this.focusIndex]; + const mod = event.ctrlKey || event.metaKey; const move = (i) => { this.focusIndex = Math.max(0, Math.min(this.treeRows.length - 1, i)); - for (const node of this.explorer.querySelectorAll('[data-index]')) node.tabIndex = Number(node.dataset.index) === this.focusIndex ? 0 : -1; + const to = this.treeRows[this.focusIndex]; + if (to?.path !== undefined && (to.kind === 'file' || to.kind === 'dir')) { + if (event.shiftKey && this.anchor !== null) this.selectRange(to.path); + else { + this.selected = new Set([to.path]); + this.anchor = to.path; + } + } + this.renderExplorer(); this.focusTree(); }; - switch (event.key) { - case 'ArrowDown': move(this.focusIndex + 1); break; - case 'ArrowUp': move(this.focusIndex - 1); break; - case 'Home': move(0); break; - case 'End': move(this.treeRows.length - 1); break; - case 'ArrowRight': + const targets = () => this.selectionFor(row?.path); + switch (true) { + case event.key === 'ArrowDown': move(this.focusIndex + 1); break; + case event.key === 'ArrowUp': move(this.focusIndex - 1); break; + case event.key === 'Home': move(0); break; + case event.key === 'End': move(this.treeRows.length - 1); break; + case event.key === 'ArrowRight': if (row?.kind === 'dir') { if (!this.expanded.has(row.path)) this.toggleDir(row.path, true); else move(this.focusIndex + 1); } break; - case 'ArrowLeft': + case event.key === 'ArrowLeft': if (row?.kind === 'dir' && this.expanded.has(row.path)) this.toggleDir(row.path, false); - else if (row?.path?.includes('/')) { - const up = row.path.split('/').slice(0, -1).join('/'); - move(this.treeRows.findIndex((r) => r.path === up)); - } + else if (row?.path?.includes('/')) move(this.treeRows.findIndex((r) => r.path === parent(row.path))); break; - case 'Enter': - case ' ': + case event.key === 'Enter' && !mod: + case event.key === ' ': if (row?.kind === 'dir') this.toggleDir(row.path); else if (row?.kind === 'file') this.openFile(row.path, { pin: event.key === 'Enter' }); break; + case event.key === 'F2': + if (row?.path) this.startRename(row.path); + break; + case event.key === 'Delete' || (event.key === 'Backspace' && event.metaKey): + if (targets().length) this.deletePaths(targets()); + break; + case mod && !event.shiftKey && event.code === 'KeyC': + if (targets().length) this.setClip('copy', targets()); + break; + case mod && !event.shiftKey && event.code === 'KeyX': + if (targets().length) this.setClip('cut', targets()); + break; + case mod && !event.shiftKey && event.code === 'KeyV': + // No files of ours on the clipboard: let the paste event bring the + // browser's (files copied on the desktop upload into the folder). + if (!this.clip || this.clip.session !== this.session) return; + this.pasteClip(this.targetDir(row)); + break; + case mod && !event.shiftKey && event.code === 'KeyA': + this.selected = new Set(this.treeRows.filter((r) => r.kind === 'file' || r.kind === 'dir').map((r) => r.path)); + this.renderExplorer(); + break; + case event.key === 'Escape' && (this.selected.size > 1 || this.clip): + this.selected = new Set(row?.path ? [row.path] : []); + this.clip = null; + this.renderExplorer(); + event.stopPropagation(); // the pane stays open; the next Esc closes it + break; default: return; } @@ -707,11 +872,835 @@ export class FilesPane { this.explorer.replaceChildren(head, ...rows); } + // ------------------------------------------------------------ search in files (devdesk#56) + + // Ctrl+Shift+F: the Search view, with the editor's selection as the query. + openSearch() { + const selection = window.getSelection?.().toString() || this.activeSelection(); + this.open('search'); + const panel = this.searchPanel(); + if (selection && !selection.includes('\n')) { + panel.input.value = selection.slice(0, 200); + this.runSearch(); + } + panel.input.focus(); + panel.input.select(); + } + + activeSelection() { + const editor = this.views.get(this.active)?.editor?.view; + if (!editor) return ''; + const { from, to } = editor.state.selection.main; + return editor.state.sliceDoc(from, to); + } + + searchPanel() { + if (this.searchUi) return this.searchUi; + const input = el('input', { class: 'lt-input dd-search-input', placeholder: 'Search files', spellcheck: 'false', autocomplete: 'off', 'aria-label': 'Search in files' }); + const toggle = (flag, label, title) => el('button', { + type: 'button', class: 'dd-search-flag', 'aria-pressed': 'false', title, 'data-flag': flag, + onclick: (event) => { + const b = event.currentTarget; + b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true')); + this.runSearch(); + input.focus(); + }, + }, label); + const flags = [toggle('c', 'Aa', 'Match case'), toggle('w', 'ab', 'Whole word'), toggle('r', '.*', 'Regular expression')]; + const summary = el('div', { class: 'dd-search-summary', role: 'status' }); + const results = el('div', { class: 'dd-search-results' }); + const node = el('div', { class: 'dd-search' }, el('div', { class: 'dd-search-box' }, input, ...flags), summary, results); + input.addEventListener('input', () => { + clearTimeout(this.searchTimer); + this.searchTimer = setTimeout(() => this.runSearch(), 350); + }); + input.addEventListener('keydown', (event) => { + event.stopPropagation(); + if (event.key === 'Enter') { + clearTimeout(this.searchTimer); + this.runSearch(); + } else if (event.key === 'Escape') { + if (input.value) input.value = ''; + else this.close(); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + results.querySelector('.dd-search-line')?.focus(); + } + }); + results.addEventListener('keydown', (event) => { + const lines = [...results.querySelectorAll('.dd-search-line')]; + const i = lines.indexOf(document.activeElement); + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + const next = lines[i + (event.key === 'ArrowDown' ? 1 : -1)]; + if (next) next.focus(); + else if (event.key === 'ArrowUp') input.focus(); + } + }); + this.searchUi = { node, input, flags, summary, results }; + return this.searchUi; + } + + renderSearch() { + const panel = this.searchPanel(); + this.explorer.setAttribute('role', 'region'); + if (!this.explorer.contains(panel.node)) this.explorer.replaceChildren(panel.node); + } + + async runSearch() { + const panel = this.searchPanel(); + const query = panel.input.value; + const pressed = (f) => panel.flags.find((b) => b.dataset.flag === f).getAttribute('aria-pressed') === 'true'; + const flags = `${pressed('c') ? '' : 'i'}${pressed('w') ? 'w' : ''}${pressed('r') ? 'r' : ''}` || '-'; + const seq = (this.searchSeq ?? 0) + 1; + this.searchSeq = seq; + if (!query.trim() || !this.session) { + panel.summary.textContent = ''; + panel.results.replaceChildren(); + return; + } + let matcher; + try { + const source = pressed('r') ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + matcher = new RegExp(pressed('w') ? `\\b(?:${source})\\b` : source, pressed('c') ? 'g' : 'gi'); + } catch { + panel.summary.textContent = 'Not a valid regular expression'; + return; + } + panel.summary.textContent = 'Searching…'; + let result; + try { + result = parseListOutput(await runCommand(['--fs-grep', this.session, flags, query], 30000)); + } catch (error) { + if (seq === this.searchSeq) panel.summary.textContent = error.message; + return; + } + if (seq !== this.searchSeq) return; + const byFile = new Map(); + for (const m of result.matches) { + if (!byFile.has(m.p)) byFile.set(m.p, []); + byFile.get(m.p).push(m); + } + panel.summary.textContent = result.matches.length + ? `${result.matches.length}${result.truncated ? '+' : ''} result${result.matches.length === 1 ? '' : 's'} in ${byFile.size} file${byFile.size === 1 ? '' : 's'}${result.truncated ? ' (first 2000 lines)' : ''}` + : 'No results'; + // The matched parts of a line, highlighted. + const highlight = (text) => { + const out = []; + let last = 0; + matcher.lastIndex = 0; + for (const m of text.matchAll(matcher)) { + if (!m[0]) break; + out.push(text.slice(last, m.index), el('mark', {}, m[0])); + last = m.index + m[0].length; + } + out.push(text.slice(last)); + return out; + }; + const groups = []; + for (const [path, matches] of byFile) { + const name = base(path); + const [icon, tone] = iconFor(name); + const lines = el('div', { class: 'dd-search-lines' }, ...matches.map((m) => el('div', { + class: 'dd-search-line', tabindex: '0', role: 'button', title: `${path}:${m.l}`, + onclick: () => this.openFile(path, { mode: 'view', line: m.l }), + onkeydown: (event) => { + if (event.key === 'Enter') this.openFile(path, { mode: 'view', line: m.l, pin: true }); + }, + }, el('span', { class: 'dd-search-ln' }, String(m.l)), el('span', { class: 'dd-search-text' }, ...highlight(m.t.trimStart()))))); + const head = el('button', { + type: 'button', class: 'dd-search-file', 'aria-expanded': 'true', title: path, + onclick: (event) => { + const open = event.currentTarget.getAttribute('aria-expanded') !== 'true'; + event.currentTarget.setAttribute('aria-expanded', String(open)); + lines.hidden = !open; + }, + }, el('span', { class: `dd-ficon tone-${tone}`, 'aria-hidden': 'true' }, icon), el('span', { class: 'dd-tree-name' }, name), + el('span', { class: 'dd-change-dir' }, parent(path)), el('span', { class: 'dd-search-count' }, String(matches.length))); + groups.push(el('div', { class: 'dd-search-group' }, head, lines)); + } + panel.results.replaceChildren(...groups); + } + + // ------------------------------------------------------------ explorer operations (devdesk#54) + + wireExplorerOps() { + const acts = { + 'new-file': () => this.startNew('new-file'), + 'new-folder': () => this.startNew('new-folder'), + upload: () => this.pickUpload(this.targetDir()), + collapse: () => { + this.expanded.clear(); + this.saveState(); + this.renderExplorer(); + }, + }; + for (const button of this.root.querySelectorAll('[data-explorer-act]')) { + button.addEventListener('click', () => { + if (this.view !== 'explorer') this.setView('explorer'); + acts[button.dataset.explorerAct](); + }); + } + this.uploadInput = this.root.querySelector('.dd-upload-input'); + this.uploadInput.addEventListener('change', () => { + const files = [...this.uploadInput.files]; + this.uploadInput.value = ''; + if (files.length) this.uploadTo(this.uploadDir ?? '', files.map((file) => ({ file, rel: file.name }))); + }); + this.uploadBar = this.root.querySelector('.dd-upload-bar'); + this.explorer.addEventListener('contextmenu', (event) => this.explorerMenu(event)); + this.explorer.addEventListener('dragstart', (event) => this.dragStart(event)); + this.explorer.addEventListener('dragover', (event) => this.dragOver(event)); + this.explorer.addEventListener('dragleave', (event) => { + if (!this.explorer.contains(event.relatedTarget)) this.setDropDir(null); + }); + this.explorer.addEventListener('drop', (event) => this.drop(event)); + this.explorer.addEventListener('dragend', () => this.setDropDir(null)); + // Files dropped anywhere else in the pane go to the folder in focus; nothing + // dropped on the page may make the browser navigate away from DevDesk. + this.root.addEventListener('dragover', (event) => { + if ([...(event.dataTransfer?.types ?? [])].includes('Files')) event.preventDefault(); + }); + this.root.addEventListener('drop', (event) => { + if (event.defaultPrevented || !this.session || ![...(event.dataTransfer?.types ?? [])].includes('Files')) return; + event.preventDefault(); + this.collectDrop(event.dataTransfer).then((items) => this.uploadTo(this.targetDir(), items)); + }); + for (const type of ['dragover', 'drop']) { + window.addEventListener(type, (event) => { + if ([...(event.dataTransfer?.types ?? [])].includes('Files')) event.preventDefault(); + }); + } + // Pasting files copied on the desktop (Ctrl+V in the explorer) uploads them. + this.root.addEventListener('paste', (event) => { + if (!this.session || event.target.closest?.('.cm-editor, input, textarea')) return; + const files = [...(event.clipboardData?.files ?? [])]; + if (!files.length) return; + event.preventDefault(); + this.uploadTo(this.targetDir(), files.map((file) => ({ file, rel: file.name || 'pasted.png' }))); + }); + } + + // Click with modifiers: Ctrl/⌘ toggles, Shift extends from the anchor. Returns + // true for a plain click (which also opens the file / toggles the folder). + selectRow(path, event) { + if (event.shiftKey && this.anchor !== null) { + this.selectRange(path); + this.renderExplorer(); + return false; + } + if (event.ctrlKey || event.metaKey) { + if (this.selected.has(path)) this.selected.delete(path); + else this.selected.add(path); + this.anchor = path; + this.renderExplorer(); + return false; + } + this.selected = new Set([path]); + this.anchor = path; + return true; + } + + selectRange(path) { + const visible = this.treeRows.filter((r) => r.kind === 'file' || r.kind === 'dir').map((r) => r.path); + const a = visible.indexOf(this.anchor); + const b = visible.indexOf(path); + if (a < 0 || b < 0) { + this.selected = new Set([path]); + this.anchor = path; + return; + } + this.selected = new Set(visible.slice(Math.min(a, b), Math.max(a, b) + 1)); + } + + // What an action on PATH applies to: the whole selection when PATH is in it + // (in tree order, without entries inside a selected folder), else PATH alone. + selectionFor(path) { + if (path === undefined || path === null) return [...this.selected]; + if (!this.selected.has(path)) return [path]; + const order = (this.treeRows ?? []).map((r) => r.path); + const paths = [...this.selected].sort((x, y) => order.indexOf(x) - order.indexOf(y)); + return paths.filter((p) => !paths.some((q) => q !== p && within(p, q))); + } + + isDirPath(path) { + const listing = this.listings.get(parent(path)); + const e = listing?.entries.find((x) => x.n === base(path)); + return Boolean(e && (e.t === 'd' || e.to === 'd')); + } + + // The folder new things go in: the focused row's folder (itself if a folder). + targetDir(row = this.treeRows?.[this.focusIndex]) { + if (!row || row.path === undefined) return ''; + return row.kind === 'dir' ? row.path : parent(row.path); + } + + // The inline name box. The tree may re-render while you type (a folder listing + // arrives): what you typed lives in this.editing.value, and a box that was + // replaced by a re-render neither commits nor cancels. + nameBox(initial, placeholder) { + const edit = this.editing; + const input = el('input', { + class: 'dd-tree-input', value: edit.value ?? initial, placeholder, spellcheck: 'false', autocomplete: 'off', 'aria-label': placeholder, + }); + input.addEventListener('input', () => { edit.value = input.value; }); + let finished = false; + const finish = (commit) => { + if (finished || this.editing !== edit) return; + finished = true; + if (commit) this.commitEdit(input.value); + else this.cancelEdit(); + }; + input.addEventListener('keydown', (event) => { + event.stopPropagation(); + if (event.key === 'Enter') { + event.preventDefault(); + finish(true); + } else if (event.key === 'Escape') { + event.preventDefault(); + finish(false); + } + }); + input.addEventListener('blur', () => setTimeout(() => { + if (input.isConnected) finish(input.value.trim() !== '' && input.value !== initial); + }, 0)); + for (const type of ['click', 'dblclick', 'mousedown', 'dragstart']) input.addEventListener(type, (event) => event.stopPropagation()); + return input; + } + + startNew(kind, dir = this.targetDir()) { + if (!this.session) return; + if (this.view !== 'explorer') this.setView('explorer'); + if (dir && !this.expanded.has(dir)) this.toggleDir(dir, true); + this.editing = { kind, dir }; + this.renderExplorer(); + } + + startRename(path) { + this.editing = { kind: 'rename', path }; + this.renderExplorer(); + } + + cancelEdit() { + this.editing = null; + this.renderExplorer(); + this.focusTree(); + } + + async commitEdit(value) { + const edit = this.editing; + this.editing = null; + if (!edit) return; + const name = validRelative(value, edit.kind !== 'rename'); + if (!name) { + if (value.trim()) toast.warning(`Not a valid name: ${value}`); + this.renderExplorer(); + return; + } + const session = this.session; + try { + if (edit.kind === 'rename') { + const to = join(parent(edit.path), name); + if (to === edit.path) return; + await sessionAction('fs-move', session, edit.path, to); + this.afterMove(edit.path, to); + this.selected = new Set([to]); + this.anchor = to; + await this.refreshDirs([parent(edit.path)]); + this.focusPath(to); + } else { + const path = join(edit.dir, name); + if (edit.kind === 'new-folder') { + await sessionAction('fs-mkdir', session, path); + } else { + const result = await writeFile(session, path, new Uint8Array(0), 'new'); + if (!result.ok) throw new Error('not created'); + } + const dirs = []; + for (let i = 0; i < path.split('/').length; i += 1) dirs.push(path.split('/').slice(0, i).join('/')); + for (const d of dirs) if (d) this.expanded.add(d); + if (edit.kind === 'new-folder') this.expanded.add(path); + this.saveState(); + this.selected = new Set([path]); + this.anchor = path; + await this.refreshDirs(dirs); + this.focusPath(path); + if (edit.kind === 'new-file') this.openFile(path, { mode: 'view', pin: true }); + } + this.fileList = null; + this.loadChanges(); + } catch (error) { + toast.error(error.message); + this.renderExplorer(); + } + } + + focusPath(path) { + const i = this.treeRows?.findIndex((r) => r.path === path) ?? -1; + if (i >= 0) { + this.focusIndex = i; + this.renderExplorer(); + this.focusTree(); + this.explorer.querySelector(`[data-index="${i}"]`)?.scrollIntoView({ block: 'nearest' }); + } + } + + // Re-list folders (and render once they're in). + async refreshDirs(dirs) { + await Promise.all([...new Set(dirs)].filter((d) => d === '' || this.expanded.has(d) || this.listings.has(d)).map((d) => this.listDir(d))); + } + + async deletePaths(paths) { + if (!paths.length || !this.session) return; + const dirty = this.tabs.filter((t) => paths.some((p) => within(t.path, p)) && this.views.get(t.key)?.dirty); + const what = paths.length === 1 ? `'${paths[0]}'` : `${paths.length} items`; + const ok = await confirmDialog({ + title: `Delete ${what}?`, + text: `${paths.length === 1 ? 'It goes' : 'They go'} to DevDesk's trash for 7 days; Undo in the next message puts ${paths.length === 1 ? 'it' : 'them'} back.${dirty.length ? ` ${dirty.length} open file${dirty.length === 1 ? ' has' : 's have'} unsaved changes, which are lost.` : ''}`, + confirmLabel: 'Move to trash', + danger: true, + }); + if (!ok) return; + const session = this.session; + try { + const result = await sessionAction('fs-delete', session, ...paths); + this.afterDelete(result.paths ?? paths); + await this.refreshDirs(paths.map(parent)); + this.loadChanges(); + this.fileList = null; + toast.success(`Deleted ${result.paths.length === 1 ? result.paths[0] : `${result.paths.length} items`}`, 12000, [{ + label: 'Undo', + run: async () => { + try { + const back = await sessionAction('fs-restore', session, result.trash); + if (session === this.session) { + await this.refreshDirs(back.paths.map(parent)); + this.loadChanges(); + this.fileList = null; + } + toast.success(`Restored ${back.paths.length === 1 ? back.paths[0] : `${back.paths.length} items`}`); + } catch (error) { + toast.error(`Could not restore: ${error.message}`); + } + }, + }]); + } catch (error) { + toast.error(error.message); + this.refreshDirs(paths.map(parent)); + } + } + + setClip(op, paths) { + this.clip = { op, paths: [...paths], session: this.session }; + this.renderExplorer(); + toast.info(`${op === 'cut' ? 'Cut' : 'Copied'} ${paths.length === 1 ? paths[0] : `${paths.length} items`}: paste into a folder with Ctrl+V`, 3000); + } + + async pasteClip(dir) { + if (!this.clip || this.clip.session !== this.session) return; + const { op, paths } = this.clip; + const done = await this.transfer(paths, dir, op === 'copy'); + if (op === 'cut' && done) this.clip = null; + this.renderExplorer(); + } + + // Move or copy PATHS into folder DIR. A copy into the same folder gets a + // "copy" name; a move never replaces anything. Resolves true when all went. + async transfer(paths, dir, copy) { + const session = this.session; + const moved = []; + let failed = 0; + for (const from of paths) { + if (!copy && parent(from) === dir) continue; + if (within(dir, from)) { + toast.warning(`Can't put ${from} inside itself`); + failed += 1; + continue; + } + let to = join(dir, base(from)); + try { + if (copy) { + for (let n = 1; ; n += 1) { + try { + await sessionAction('fs-copy', session, from, to); + break; + } catch (error) { + if (!/already exists/.test(error.message) || n > 20) throw error; + to = join(dir, copyName(base(from), n)); + } + } + } else { + await sessionAction('fs-move', session, from, to); + this.afterMove(from, to); + } + moved.push(to); + } catch (error) { + failed += 1; + toast.error(`${from}: ${error.message}`); + } + } + if (dir) this.expanded.add(dir); + this.saveState(); + await this.refreshDirs([dir, ...(copy ? [] : paths.map(parent))]); + if (moved.length) { + this.selected = new Set(moved); + this.anchor = moved[0]; + this.renderExplorer(); + this.loadChanges(); + this.fileList = null; + toast.success(`${copy ? 'Copied' : 'Moved'} ${moved.length === 1 ? `to ${moved[0]}` : `${moved.length} items to ${dir || 'the top folder'}`}`, 3000); + } + return failed === 0; + } + + async duplicate(path) { + await this.transfer([path], parent(path), true); + } + + // A file or folder moved: tabs, drafts, open folders and recents follow it. + afterMove(from, to) { + const map = (p) => (within(p, from) ? to + p.slice(from.length) : p); + for (const tab of this.tabs) { + if (!within(tab.path, from)) continue; + const oldKey = tab.key; + const oldPath = tab.path; + tab.path = map(tab.path); + tab.key = `${tab.mode}:${tab.path}`; + const view = this.views.get(oldKey); + if (view) { + this.views.delete(oldKey); + this.views.set(tab.key, view); + view.stale = true; + } + const draft = readDraft(this.session, oldPath); + if (draft) { + writeDraft(this.session, tab.path, draft); + writeDraft(this.session, oldPath, null); + } + if (this.active === oldKey) this.active = tab.key; + } + this.expanded = new Set([...this.expanded].map(map)); + this.selected = new Set([...this.selected].map(map)); + for (const dir of [...this.listings.keys()]) if (within(dir, from)) this.listings.delete(dir); + writeJson(`recent.${this.session}`, this.recent().map(map)); + this.saveState(); + this.renderTabs(); + for (const [key, view] of this.views) { + if (key === this.active && view.stale) this.reloadTab(key, { quiet: true }); + } + } + + // Deleted: their tabs close (unsaved edits were confirmed away), folders forget them. + afterDelete(paths) { + for (const tab of [...this.tabs]) { + if (!paths.some((p) => within(tab.path, p))) continue; + writeDraft(this.session, tab.path, null); + this.closeTab(tab.key, { render: false, force: true }); + } + this.expanded = new Set([...this.expanded].filter((d) => !paths.some((p) => within(d, p)))); + this.selected = new Set(); + for (const dir of [...this.listings.keys()]) if (paths.some((p) => within(dir, p))) this.listings.delete(dir); + writeJson(`recent.${this.session}`, this.recent().filter((r) => !paths.some((p) => within(r, p)))); + this.saveState(); + this.renderTabs(); + this.renderEditor(); + } + + async downloadPaths(paths) { + const files = paths.filter((p) => !this.isDirPath(p)); + if (files.length < paths.length) toast.info('Folders can\'t be downloaded yet; their files can.'); + for (const path of files) { + try { + toast.info(`Downloading ${path}…`, 2000); + const { name, blob } = await downloadFile(this.session, path); + saveBlob(name, blob); + } catch (error) { + toast.error(`${path}: ${error.message}`); + } + } + } + + copyPaths(paths, absolute) { + const root = this.listings.get('')?.root ?? ''; + const text = paths.map((p) => (absolute && root ? `${root}/${p}` : p)).join('\n'); + navigator.clipboard?.writeText(text).then(() => toast.success(paths.length === 1 ? 'Path copied' : `${paths.length} paths copied`), () => toast.error('Clipboard refused')); + } + + mention(paths) { + this.hooks.paste(`${paths.map((p) => `@${p}`).join(' ')} `); + toast.info(`Mentioned ${paths.length === 1 ? `@${paths[0]}` : `${paths.length} paths`} in the prompt`); + } + + pickUpload(dir) { + this.uploadDir = dir; + this.uploadInput.click(); + } + + // Files (and whole folders) dropped from the desktop: [{ file, rel }], rel + // keeping the dropped folder structure. + async collectDrop(dataTransfer) { + const entries = [...(dataTransfer.items ?? [])].map((item) => item.webkitGetAsEntry?.()).filter(Boolean); + if (!entries.length) return [...dataTransfer.files].map((file) => ({ file, rel: file.name })); + const out = []; + const walk = async (entry, prefix) => { + if (out.length >= 2000) return; + if (entry.isFile) { + const file = await new Promise((resolve, reject) => entry.file(resolve, reject)).catch(() => null); + if (file) out.push({ file, rel: prefix + entry.name }); + } else if (entry.isDirectory) { + const reader = entry.createReader(); + for (;;) { + const batch = await new Promise((resolve) => reader.readEntries(resolve, () => resolve([]))); + if (!batch.length) break; + for (const child of batch) await walk(child, `${prefix}${entry.name}/`); + } + } + }; + for (const entry of entries) await walk(entry, ''); + if (out.length >= 2000) toast.warning('Only the first 2000 files are uploaded'); + return out; + } + + // Upload ITEMS into folder DIR, one at a time, with progress. Asks once what + // to do when a name is taken (replace, skip or keep both). + async uploadTo(dir, items) { + if (!items.length || !this.session) return; + if (this.uploading) { + toast.warning('An upload is already running'); + return; + } + const session = this.session; + this.uploading = true; + const text = this.uploadBar.querySelector('.dd-upload-text'); + const fill = this.uploadBar.querySelector('.dd-upload-fill'); + const total = items.reduce((n, i) => n + i.file.size, 0) || 1; + let sent = 0; + let policy = null; // replace | skip | keep, once "for all" is ticked + const done = []; + let skipped = 0; + this.uploadBar.hidden = false; + try { + for (const [n, item] of items.entries()) { + let path = join(dir, item.rel); + let mode = 'new'; + for (let attempt = 1; ; attempt += 1) { + try { + text.textContent = `Uploading ${n + 1}/${items.length}: ${item.rel}`; + await putFile(session, path, item.file, mode, (p) => { fill.style.width = `${Math.round(((sent + p * item.file.size) / total) * 100)}%`; }); + done.push(path); + break; + } catch (error) { + if (!/already exists/.test(error.message) || attempt > 20) throw new Error(`${item.rel}: ${error.message}`); + let choice = policy; + if (!choice) { + const all = el('input', { type: 'checkbox' }); + const answer = await openDialog({ + title: `${path} already exists`, + size: 'sm', + body: el('div', { class: 'dd-dialog-fields' }, + el('p', { class: 'dd-dialog-text' }, 'Replace it with the one you are uploading?'), + items.length > 1 ? el('label', { class: 'dd-check' }, all, el('span', {}, 'Do this for the rest too')) : null), + actions: [{ label: 'Stop', value: null }, { label: 'Skip', value: 'skip' }, { label: 'Keep both', value: 'keep' }, { label: 'Replace', value: 'replace', primary: true, danger: true }], + }); + if (!answer) throw new Error('stopped'); + choice = answer; + if (all.checked) policy = answer; + } + if (choice === 'skip') { + skipped += 1; + break; + } + if (choice === 'replace') mode = 'replace'; + else path = join(parent(join(dir, item.rel)), numbered(base(item.rel), attempt)); + } + } + sent += item.file.size; + } + } catch (error) { + if (error.message !== 'stopped') toast.error(`Upload stopped: ${error.message}`); + } finally { + this.uploading = false; + this.uploadBar.hidden = true; + fill.style.width = '0'; + } + if (session !== this.session) return; + const dirs = new Set([dir]); + for (const path of done) { + const parts = path.split('/'); + for (let i = 1; i < parts.length; i += 1) dirs.add(parts.slice(0, i).join('/')); + } + if (dir) this.expanded.add(dir); + this.saveState(); + await this.refreshDirs([...dirs]); + if (done.length) { + this.selected = new Set(done.filter((p) => parent(p) === dir).length ? done.filter((p) => parent(p) === dir) : done.slice(0, 1)); + this.renderExplorer(); + this.fileList = null; + this.loadChanges(); + toast.success(`Uploaded ${done.length === 1 ? done[0] : `${done.length} files to ${dir || 'the top folder'}`}${skipped ? ` (${skipped} skipped)` : ''}`); + } + } + + // ---- drag and drop + + dragStart(event) { + const row = event.target.closest?.('[data-path]'); + if (!row || this.editing) return; + const path = row.dataset.path; + if (!this.selected.has(path)) { + this.selected = new Set([path]); + this.anchor = path; + for (const node of this.explorer.querySelectorAll('.is-selected')) node.classList.remove('is-selected'); + row.classList.add('is-selected'); + } + const paths = this.selectionFor(path); + this.dragging = paths; + event.dataTransfer.effectAllowed = 'copyMove'; + event.dataTransfer.setData(DRAG_TYPE, JSON.stringify({ session: this.session, paths })); + // Dropped on the terminal (or anywhere text goes): @path mentions for Claude. + event.dataTransfer.setData('text/plain', `${paths.map((p) => `@${p}`).join(' ')} `); + if (paths.length > 1) { + const ghost = el('div', { class: 'dd-drag-ghost' }, `${paths.length} items`); + document.body.append(ghost); + event.dataTransfer.setDragImage(ghost, -10, -10); + setTimeout(() => ghost.remove(), 0); + } + } + + // The folder a drop at EVENT would land in, or null when it can't. + dropTarget(event) { + const row = event.target.closest?.('[data-index]'); + const r = row ? this.treeRows?.[Number(row.dataset.index)] : null; + const dir = r && r.path !== undefined ? (r.kind === 'dir' ? r.path : parent(r.path)) : (r?.dir ?? ''); + const types = [...(event.dataTransfer?.types ?? [])]; + if (types.includes(DRAG_TYPE) && this.dragging) { + const copy = event.ctrlKey || event.altKey || event.metaKey; + if (this.dragging.some((p) => within(dir, p))) return null; + if (!copy && this.dragging.every((p) => parent(p) === dir)) return null; + return { dir, copy, internal: true, row: r }; + } + if (types.includes('Files')) return { dir, copy: true, internal: false, row: r }; + return null; + } + + dragOver(event) { + const target = this.dropTarget(event); + if (!target) { + if ([...(event.dataTransfer?.types ?? [])].includes('Files')) event.preventDefault(); + this.setDropDir(null); + return; + } + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = target.copy ? 'copy' : 'move'; + this.setDropDir(target.dir); + // Hovering a closed folder opens it after a moment, like VS Code. + const hover = target.row?.kind === 'dir' && !this.expanded.has(target.row.path) ? target.row.path : null; + if (hover !== this.hoverDir) { + clearTimeout(this.hoverTimer); + this.hoverDir = hover; + if (hover) this.hoverTimer = setTimeout(() => this.toggleDir(hover, true), 700); + } + } + + // Highlight the drop folder without re-rendering the tree on every dragover. + setDropDir(dir) { + if (dir === this.dropDir) return; + this.dropDir = dir; + if (dir === null) { + clearTimeout(this.hoverTimer); + this.hoverDir = null; + } + for (const node of this.explorer.querySelectorAll('.is-drop')) node.classList.remove('is-drop'); + this.explorer.classList.toggle('is-drop', dir === ''); + if (dir) { + for (const node of this.explorer.querySelectorAll('[data-path]')) { + if (within(node.dataset.path, dir)) node.classList.add('is-drop'); + } + } + } + + async drop(event) { + const target = this.dropTarget(event); + this.setDropDir(null); + if (!target) return; + event.preventDefault(); + event.stopPropagation(); + if (target.internal) { + const { session, paths } = JSON.parse(event.dataTransfer.getData(DRAG_TYPE) || '{}'); + this.dragging = null; + if (session !== this.session || !paths?.length) return; + await this.transfer(paths, target.dir, target.copy); + } else { + const items = await this.collectDrop(event.dataTransfer); + await this.uploadTo(target.dir, items); + } + } + + // ---- right-click menu + + explorerMenu(event) { + if (this.view !== 'explorer' || !this.session) return; + event.preventDefault(); + const rowNode = event.target.closest?.('[data-index]'); + const row = rowNode ? this.treeRows[Number(rowNode.dataset.index)] : null; + const path = row?.path; + if (path !== undefined && row.kind !== 'new') { + this.focusIndex = Number(rowNode.dataset.index); + if (!this.selected.has(path)) { + this.selected = new Set([path]); + this.anchor = path; + for (const node of this.explorer.querySelectorAll('.is-selected')) node.classList.remove('is-selected'); + rowNode.classList.add('is-selected'); + } + } + const paths = path !== undefined && row.kind !== 'new' ? this.selectionFor(path) : []; + const dir = row && path !== undefined ? this.targetDir(row) : ''; + const one = paths.length === 1 ? paths[0] : null; + const isDir = row?.kind === 'dir'; + const canPaste = Boolean(this.clip && this.clip.session === this.session); + const items = []; + if (one && !isDir) { + items.push({ label: 'Open', run: () => this.openFile(one, { pin: true }) }); + if (isMarkdown(one)) items.push({ label: 'Open preview', run: () => this.openFile(one, { mode: 'preview', pin: true }) }); + const change = this.changeFor(one); + if (change && change.x !== '?' && change.y !== '?' && this.changes?.git) items.push({ label: 'Open changes (diff)', run: () => this.openFile(one, { mode: 'diff', pin: true }) }); + items.push('sep'); + } + if (paths.length <= 1) { + items.push({ label: 'New file…', run: () => this.startNew('new-file', dir) }); + items.push({ label: 'New folder…', run: () => this.startNew('new-folder', dir) }); + items.push({ label: `Upload files to ${dir || 'the top folder'}…`, run: () => this.pickUpload(dir) }); + items.push('sep'); + } + if (paths.length) { + items.push({ label: 'Cut', kbd: 'Ctrl+X', run: () => this.setClip('cut', paths) }); + items.push({ label: 'Copy', kbd: 'Ctrl+C', run: () => this.setClip('copy', paths) }); + } + items.push({ label: canPaste ? `Paste ${this.clip.paths.length === 1 ? base(this.clip.paths[0]) : `${this.clip.paths.length} items`}` : 'Paste', kbd: 'Ctrl+V', disabled: !canPaste, run: () => this.pasteClip(dir) }); + if (one) items.push({ label: 'Duplicate', run: () => this.duplicate(one) }); + if (paths.length) { + items.push('sep'); + items.push({ label: paths.length === 1 ? 'Copy path' : 'Copy paths', run: () => this.copyPaths(paths, true) }); + items.push({ label: paths.length === 1 ? 'Copy relative path' : 'Copy relative paths', run: () => this.copyPaths(paths, false) }); + items.push({ label: 'Mention to Claude (@path)', run: () => this.mention(paths) }); + if (paths.some((p) => !this.isDirPath(p))) items.push({ label: paths.length === 1 ? 'Download' : 'Download files', run: () => this.downloadPaths(paths) }); + items.push('sep'); + if (one) items.push({ label: 'Rename…', kbd: 'F2', run: () => this.startRename(one) }); + items.push({ label: paths.length === 1 ? 'Delete' : `Delete ${paths.length} items`, kbd: 'Del', danger: true, run: () => this.deletePaths(paths) }); + } else { + items.push('sep'); + items.push({ label: 'Refresh', run: () => this.refresh() }); + items.push({ label: 'Collapse all folders', run: () => { this.expanded.clear(); this.saveState(); this.renderExplorer(); } }); + } + contextMenu(event.clientX, event.clientY, items); + } + // ------------------------------------------------------------ tabs // Single click opens a preview tab (italic) that the next click replaces; // double click, Enter or go-to-file keeps it. - openFile(path, { mode, pin = false, line } = {}) { + openFile(path, { mode, pin = false, line, anchor } = {}) { // A line number means source: markdown opens as text, not as a preview. const wanted = mode ?? (isMarkdown(path) && !line ? 'preview' : 'view'); const key = `${wanted}:${path}`; @@ -739,6 +1728,12 @@ export class FilesPane { if (loaded?.goto) setTimeout(() => loaded.goto(line), 0); else tab.line = line; } + if (anchor) { + // A link to file.md#section: scroll the preview there once it's shown. + const host = this.views.get(tab.key)?.preview; + if (host?.shadowRoot) setTimeout(() => this.scrollPreview(host.shadowRoot, anchor), 0); + else tab.anchor = anchor; + } this.activate(tab.key); if (this.root.classList.contains('is-narrow')) this.setExplorer(false); } @@ -822,6 +1817,36 @@ export class FilesPane { onauxclick: (event) => { if (event.button === 1) this.closeTab(tab.key); }, + oncontextmenu: (event) => { + event.preventDefault(); + this.tabMenu(event, tab); + }, + // Drag a tab along the strip to reorder (devdesk#55). + draggable: 'true', + ondragstart: (event) => { + this.draggingTab = tab.key; + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', `@${tab.path} `); + event.dataTransfer.setData(DRAG_TYPE, JSON.stringify({ session: this.session, paths: [tab.path], tab: true })); + }, + ondragover: (event) => { + if (!this.draggingTab || this.draggingTab === tab.key) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, + ondrop: (event) => { + if (!this.draggingTab) return; + event.preventDefault(); + const from = this.tabs.findIndex((t) => t.key === this.draggingTab); + const to = this.tabs.findIndex((t) => t.key === tab.key); + this.draggingTab = null; + if (from < 0 || to < 0 || from === to) return; + const [moved] = this.tabs.splice(from, 1); + this.tabs.splice(to, 0, moved); + this.saveState(); + this.renderTabs(); + }, + ondragend: () => { this.draggingTab = null; }, onkeydown: (event) => { if (event.key === 'Enter') this.activate(tab.key); }, @@ -841,6 +1866,31 @@ export class FilesPane { this.tabStrip.querySelector('.is-active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } + // Right-click on a tab: close variants, paths, reveal, download. + tabMenu(event, tab) { + const i = this.tabs.indexOf(tab); + const closeMany = async (list) => { + for (const t of list) await this.closeTab(t.key, { render: false }); + this.renderTabs(); + this.renderEditor(); + this.renderExplorer(); + }; + contextMenu(event.clientX, event.clientY, [ + { label: 'Close', run: () => this.closeTab(tab.key) }, + { label: 'Close others', disabled: this.tabs.length < 2, run: () => closeMany(this.tabs.filter((t) => t !== tab)) }, + { label: 'Close to the right', disabled: i === this.tabs.length - 1, run: () => closeMany(this.tabs.slice(i + 1)) }, + { label: 'Close saved', run: () => closeMany(this.tabs.filter((t) => !this.views.get(t.key)?.dirty)) }, + { label: 'Close all', run: () => closeMany([...this.tabs]) }, + 'sep', + tab.pinned ? null : { label: 'Keep open', run: () => this.pin(tab.path) }, + { label: 'Copy path', run: () => this.copyPaths([tab.path], true) }, + { label: 'Copy relative path', run: () => this.copyPaths([tab.path], false) }, + { label: 'Reveal in explorer', run: () => this.reveal(tab.path) }, + { label: 'Mention to Claude (@path)', run: () => this.mention([tab.path]) }, + { label: 'Download', run: () => this.downloadPaths([tab.path]) }, + ]); + } + // ------------------------------------------------------------ editor renderEditor() { @@ -957,6 +2007,9 @@ export class FilesPane { actions.push(button('Revert', () => this.revert(tab.key), { class: 'dd-viewer-btn dd-dirty-only', title: 'Discard your changes' })); } if (tab.mode !== 'diff' && isMarkdown(tab.path)) actions.push(button(tab.mode === 'preview' ? 'Source' : 'Preview', () => switchMode(tab.mode === 'preview' ? 'view' : 'preview'))); + if (tab.mode === 'view' && isMarkdown(tab.path) && view?.editable) { + actions.push(button(tab.split ? 'Unsplit' : 'Split', () => this.setSplit(tab, view, !tab.split), { class: 'dd-viewer-btn dd-split-btn', title: 'Source and a live preview side by side' })); + } if (change && change.x !== '?' && change.y !== '?' && this.changes?.git) { actions.push(button(tab.mode === 'diff' ? 'File' : 'Diff', () => switchMode(tab.mode === 'diff' ? (isMarkdown(tab.path) ? 'preview' : 'view') : 'diff'))); } @@ -1001,19 +2054,28 @@ export class FilesPane { // Unsaved edits in the Source tab show in the preview too. const source = this.views.get(`view:${tab.path}`); const md = source?.dirty ? source.editor.text() : text(base64); - const frame = el('iframe', { - class: 'dd-md-frame', title: `Preview of ${tab.path}`, - sandbox: 'allow-popups allow-popups-to-escape-sandbox', srcdoc: previewDocument(editor.markdown(md)), - }); - content.append(frame); - inlineImages(editor.markdown(md), this.session, tab.path).then((html) => { - if (frame.isConnected) frame.srcdoc = previewDocument(html); - }); + const host = el('div', { class: 'dd-md-host', tabindex: '0', 'aria-label': `Preview of ${tab.path}` }); + if (tab.anchor) { + host.dataset.anchor = tab.anchor; + delete tab.anchor; + } + content.append(host); + this.renderPreview(host, editor, md, tab.path); + view.preview = host; + const top = view.previewTop; + if (top) requestAnimationFrame(() => { host.scrollTop = top; }); + host.addEventListener('scroll', () => { view.previewTop = host.scrollTop; }, { passive: true }); } else if (tab.mode === 'diff') { view.editor = editor.diff(content, { original: text(original?.base64), modified: text(base64), path: tab.path, wrap: this.wrap }); } else if (header.utf8 === false) { // Not UTF-8: shown as Latin-1, read-only (saving would change the bytes). - view.editor = editor.viewer(content, { doc: text(base64, false), path: tab.path, wrap: this.wrap }); + view.editor = editor.viewer(content, { + doc: text(base64, false), path: tab.path, wrap: this.wrap, onCursor: (info) => this.statusBar(view, info), + }); + view.status = this.statusNode(tab, text(base64, false), editor, 'Latin-1 · read-only'); + view.node.replaceChildren(this.bar(tab, header, view), content, view.status); + this.gotoPending(tab, view); + return; } else { const disk = text(base64); const draft = readDraft(this.session, tab.path); @@ -1030,14 +2092,23 @@ export class FilesPane { view.editable = true; view.saved = disk; view.base = header.sha; + view.content = content; + view.editorApi = editor; view.editor = editor.editor(content, { doc, path: tab.path, wrap: this.wrap, - onChange: (now) => this.edited(tab.key, now), + onChange: (now) => { + this.edited(tab.key, now); + if (view.preview) this.schedulePreview(view, tab); + }, onSave: () => this.save(tab.key), + onCursor: (info) => this.statusBar(view, info), + onScroll: (dom) => this.syncScroll(view, dom), }); view.dirty = doc !== disk; view.node.classList.toggle('is-dirty', view.dirty); - view.node.replaceChildren(this.bar(tab, header, view), content); + view.status = this.statusNode(tab, doc, editor, 'UTF-8'); + view.node.replaceChildren(this.bar(tab, header, view), content, view.status); + if (tab.split && isMarkdown(tab.path)) this.setSplit(tab, view, true); if (banner) this.oldDraft(tab.key, banner); if (view.dirty) this.renderTabs(); this.gotoPending(tab, view); @@ -1047,6 +2118,163 @@ export class FilesPane { this.gotoPending(tab, view); } + // ---- markdown preview (devdesk#55) + + // Render markdown TEXT (of the file at MDPATH) into HOST's shadow root. + renderPreview(host, editor, text, mdPath) { + const shadow = host.shadowRoot ?? host.attachShadow({ mode: 'open' }); + const { html, headings, meta } = editor.renderMarkdown(text); + const outline = headings.filter((h) => h.depth <= 4); + const metaHtml = meta?.length ? `
Front matter${meta.map(([k, v]) => ``).join('')}
${escapeText(k)}${escapeText(v)}
` : ''; + shadow.innerHTML = `
${metaHtml}${html}
${outline.length >= 3 ? `` : ''}
`; + // GitHub callouts: > [!NOTE] … > [!CAUTION]. + for (const quote of shadow.querySelectorAll('blockquote')) { + const first = quote.firstElementChild; + const m = first?.tagName === 'P' && /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i.exec(first.textContent); + if (!m) continue; + const kind = m[1].toUpperCase(); + const textNode = [...first.childNodes].find((n) => n.nodeType === Node.TEXT_NODE && /\[!/.test(n.textContent)); + if (textNode) textNode.textContent = textNode.textContent.replace(/^\s*\[![A-Za-z]+\]\s*/, ''); + if (first.firstChild?.nodeName === 'BR') first.firstChild.remove(); + if (!first.textContent.trim()) first.remove(); + const box = el('div', { class: `alert alert-${kind.toLowerCase()}` }, el('span', { class: 'alert-title' }, ALERTS[kind]), ...quote.childNodes); + quote.replaceWith(box); + } + // Code blocks get a copy button. + for (const pre of shadow.querySelectorAll('pre.dd-code')) { + pre.append(el('button', { + type: 'button', class: 'copy', title: 'Copy', + onclick: () => navigator.clipboard?.writeText(pre.querySelector('code').textContent).then(() => toast.success('Code copied', 1500), () => toast.error('Clipboard refused')), + }, 'Copy')); + } + // Links: #anchors scroll here, repo paths open in DevDesk, the web in a new tab. + if (!host.dataset.wired) { + host.dataset.wired = '1'; + shadow.addEventListener('click', (event) => { + const a = event.composedPath().find((n) => n.tagName === 'A'); + if (!a) return; + const href = a.getAttribute('href') ?? ''; + event.preventDefault(); + if (href.startsWith('#')) { + this.scrollPreview(shadow, decodeURIComponent(href.slice(1))); + } else if (/^(https?:|mailto:)/i.test(href)) { + window.open(href, '_blank', 'noopener'); + } else if (href && !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('//')) { + const [file, anchor] = href.split('#'); + const path = resolveRelative(host.dataset.path, decodeURIComponent(file)); + if (!path) return; + if (path === host.dataset.path) this.scrollPreview(shadow, anchor); + else this.openLinked(path, anchor); + } + }); + } + host.dataset.path = mdPath; + this.inlinePreviewImages(shadow, mdPath); + if (host.dataset.anchor) { + const anchor = host.dataset.anchor; + delete host.dataset.anchor; + requestAnimationFrame(() => this.scrollPreview(shadow, anchor)); + } + } + + scrollPreview(shadow, id) { + if (!id) return; + const target = shadow.getElementById(id) ?? shadow.getElementById(decodeURIComponent(id).toLowerCase()); + if (!target) return; + target.scrollIntoView({ block: 'start', behavior: 'smooth' }); + target.classList.remove('flash'); + void target.offsetWidth; + target.classList.add('flash'); + } + + // A link from a markdown file to another file of the session. + async openLinked(path, anchor) { + if (this.isDirPath(path)) { + this.reveal(`${path}/`.replace(/\/$/, '')); + return; + } + this.openFile(path, { pin: true, anchor }); + } + + // Relative (docs/screenshot.png): fetched over ttyd and inlined (30 at most). + async inlinePreviewImages(shadow, mdPath) { + const images = [...shadow.querySelectorAll('img[src]')].filter((img) => !/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(img.getAttribute('src'))).slice(0, 30); + await Promise.all(images.map(async (img) => { + const path = resolveRelative(mdPath, decodeURIComponent(img.getAttribute('src').split(/[?#]/)[0])); + try { + const { header, base64 } = await runBlob(['--fs-read', this.session, path]); + if (header.mime?.startsWith('image/') && base64) img.setAttribute('src', `data:${header.mime};base64,${base64}`); + else img.dataset.broken = '1'; + } catch { + img.dataset.broken = '1'; + } + })); + } + + // Split: the source and a live preview side by side (markdown, devdesk#55). + setSplit(tab, view, on) { + tab.split = on; + this.saveState(); + const dom = view.editor?.view?.dom; + if (!dom || !view.content) return; + if (on) { + const left = el('div', { class: 'dd-split-editor' }); + const right = el('div', { class: 'dd-md-host dd-split-preview', tabindex: '0', 'aria-label': `Preview of ${tab.path}` }); + left.append(dom); + view.content.classList.add('is-split'); + view.content.replaceChildren(left, right); + view.preview = right; + this.renderPreview(right, view.editorApi, view.editor.text(), tab.path); + } else { + view.content.classList.remove('is-split'); + view.content.replaceChildren(dom); + view.preview = null; + } + view.editor.view.requestMeasure(); + view.node.querySelector('.dd-split-btn')?.replaceChildren(on ? 'Unsplit' : 'Split'); + } + + schedulePreview(view, tab) { + clearTimeout(view.previewTimer); + view.previewTimer = setTimeout(() => { + if (!view.preview?.isConnected) return; + const top = view.preview.scrollTop; + this.renderPreview(view.preview, view.editorApi, view.editor.text(), tab.path); + view.preview.scrollTop = top; + }, 250); + } + + // Scrolling the source scrolls the preview to the same place (by proportion). + syncScroll(view, dom) { + if (!view.preview) return; + const ratio = dom.scrollTop / Math.max(1, dom.scrollHeight - dom.clientHeight); + view.preview.scrollTop = ratio * (view.preview.scrollHeight - view.preview.clientHeight); + } + + // ---- editor status bar (devdesk#55) + + statusNode(tab, text, editor, encoding) { + const node = el('div', { class: 'dd-editor-status', role: 'status' }, + el('span', { class: 'dd-status-pos' }), + el('span', { class: 'dd-status-sel' }), + el('span', { class: 'dd-status-spacer' }), + el('span', {}, editor.detectIndent?.(text) ?? ''), + el('span', {}, /\r\n/.test(text.slice(0, 20000)) ? 'CRLF' : 'LF'), + el('span', {}, encoding), + el('span', {}, LANG_NAMES[editor.languageFor?.(tab.path)] ?? 'Plain text')); + return node; + } + + statusBar(view, info) { + const node = view.status; + if (!node) return; + node.querySelector('.dd-status-pos').textContent = `Ln ${info.line}, Col ${info.col}`; + node.querySelector('.dd-status-sel').textContent = [ + info.cursors > 1 ? `${info.cursors} cursors` : '', + info.selected ? `${info.selected} selected` : '', + ].filter(Boolean).join(' · '); + } + gotoPending(tab, view) { if (!tab.line || !view.editor?.goto) return; const line = tab.line; @@ -1248,53 +2476,6 @@ export class FilesPane { ]); } - // New file in the folder you're looking at (folders are created as needed). - async newFile() { - if (!this.session) return; - const activePath = this.tabs.find((t) => t.key === this.active)?.path; - const row = this.treeRows?.[this.focusIndex]; - const folder = row?.kind === 'dir' ? row.path : parent(row?.path ?? activePath ?? ''); - const input = el('input', { class: 'lt-input', value: folder ? `${folder}/` : '', spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-newfile-hint' }); - const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-newfile-hint' }, `Relative to ${this.listings.get('')?.root ?? 'the session folder'}. Missing folders are created.`); - const path = await openDialog({ - title: 'New file', - size: 'sm', - body: el('div', { class: 'dd-dialog-fields' }, el('label', { class: 'dd-field' }, el('span', {}, 'Path'), input), hint), - actions: [{ label: 'Cancel' }, { label: 'Create', primary: true }], - onOpen: () => { - input.setSelectionRange(input.value.length, input.value.length); - return input; - }, - submit: () => { - const value = input.value.trim().replace(/^\/+/, ''); - if (!value || value.endsWith('/') || value.split('/').some((part) => part === '..' || part === '.')) { - hint.textContent = 'Type a file name (no .. or trailing /).'; - hint.classList.add('is-error'); - return null; - } - return value; - }, - }); - if (!path) return; - try { - const result = await writeFile(this.session, path, new Uint8Array(0), 'new'); - if (!result.ok) throw new Error('not created'); - // Show it: re-list every folder above the new file and open them. - const parts = result.path.split('/').slice(0, -1); - for (let i = 0; i <= parts.length; i += 1) { - const dir = parts.slice(0, i).join('/'); - if (dir) this.expanded.add(dir); - this.listDir(dir); - } - this.saveState(); - this.fileList = null; - this.openFile(result.path, { mode: 'view', pin: true }); - toast.success(`Created ${result.path}`); - } catch (error) { - toast.error(`Could not create ${path}: ${error.message}`); - } - } - // Theme switch: the editor follows CSS variables; previews need a re-render. themeChanged() { for (const tab of this.tabs) { diff --git a/web/src/main.js b/web/src/main.js index cc975cf..9105450 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -267,14 +267,29 @@ function wireUploads() { $('drop-session').textContent = state.session || 'a session'; overlay.hidden = false; }); + // Files dragged from the explorer: @path mentions for Claude (devdesk#54). + const fromExplorer = (event) => [...(event.dataTransfer?.types ?? [])].includes('application/x-devdesk-paths'); frame.addEventListener('dragover', (event) => { if (hasFiles(event)) event.preventDefault(); + else if (fromExplorer(event)) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + } }); frame.addEventListener('dragleave', () => { depth = Math.max(0, depth - 1); if (!depth) overlay.hidden = true; }); frame.addEventListener('drop', (event) => { + if (!hasFiles(event) && fromExplorer(event)) { + event.preventDefault(); + const text = event.dataTransfer.getData('text/plain'); + if (text && state.conn?.isOpen) { + term.paste(text); + term.focus(); + } + return; + } if (!hasFiles(event)) return; event.preventDefault(); depth = 0; @@ -284,7 +299,8 @@ function wireUploads() { // Pasting a screenshot / copied file: upload it instead of pasting text. document.addEventListener('paste', (event) => { const files = [...(event.clipboardData?.files ?? [])]; - if (!files.length) return; + // In the Files pane, pasted files go into the explorer's folder (devdesk#54). + if (!files.length || event.target.closest?.('#files')) return; event.preventDefault(); event.stopPropagation(); attachFiles(files); @@ -817,6 +833,7 @@ function buildCommands() { if (state.session) { add({ id: 'files', group: 'Files', icon: '🗀', label: files.isOpen ? 'Close files' : 'Browse files', kbd: 'Ctrl+⇧E', keywords: 'explorer folder read open view', run: () => files.toggle('files') }); add({ id: 'changes', group: 'Files', icon: '±', label: 'Review changes (git diff)', keywords: 'git status diff claude edits', run: () => files.open('changes') }); + add({ id: 'search-files', group: 'Files', icon: '⌕', label: 'Search in files', kbd: 'Ctrl+⇧F', keywords: 'grep find text content', run: () => files.openSearch() }); add({ id: 'goto-file', group: 'Files', icon: '⌕', label: 'Go to file…', kbd: 'Ctrl+P in Files', keywords: 'open find quick search', run: () => { @@ -890,6 +907,7 @@ const KEY_HELP = [ ['Drag', 'Select text; it is copied when you let go'], ['Ctrl+C', 'Copy the selection (interrupts only when nothing is selected)'], ['Ctrl+⇧ C / V', 'Copy selection / paste'], + ['Ctrl+⇧ F', 'Search in the session\'s files'], ['⇧ drag', 'Select text in the browser'], ['Drop or paste a file', 'Upload it and attach it for Claude'], ['Click a file path', 'Open it in Files (at the line, if given)'], @@ -1063,6 +1081,13 @@ function wireChrome() { if (state.session) files.toggle(); return; } + // Ctrl+Shift+F: search in the session's files (devdesk#56). + if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyF') { + event.preventDefault(); + event.stopPropagation(); + if (state.session) files.openSearch(); + return; + } if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') { event.preventDefault(); event.stopPropagation(); @@ -1115,6 +1140,9 @@ function wireChrome() { // Esc closes menus and the narrow-screen drawer before the terminal sees it. document.addEventListener('keydown', (event) => { if (event.key !== 'Escape' || dialogOpen()) return; + // A right-click menu, the explorer's name box, or an explorer selection / + // cut to clear take their own Esc first (devdesk#54). + if (event.target.closest?.('.dd-ctx-menu, .dd-tree-input') || files?.wantsEscape(event)) return; // Files pane: Esc goes back / closes, unless the terminal or CodeMirror's // own search panel has the focus. if (files?.isOpen && !event.target.closest?.('.xterm, .cm-panels') && !menus.some(({ menu }) => !menu.hidden) && !inbox.isOpen) { diff --git a/web/src/ui.js b/web/src/ui.js index 433ed4f..af539f2 100644 --- a/web/src/ui.js +++ b/web/src/ui.js @@ -24,13 +24,21 @@ function dismiss(node) { setTimeout(() => node.remove(), 250); } -export function toast(message, type = 'info', duration = 4000) { +// `actions`: [{ label, run }] buttons (e.g. Undo); clicking one dismisses the toast. +export function toast(message, type = 'info', duration = 4000, actions = []) { const box = toastContainer(); // At most four on screen: the oldest goes first. while (box.children.length >= 4) box.firstElementChild.remove(); const node = el('div', { class: `lt-toast lt-toast-${type}`, role: type === 'error' ? 'alert' : 'status' }, el('span', { class: 'lt-toast-icon', 'aria-hidden': 'true' }, `[${ICONS[type] || 'i'}]`), el('span', { class: 'lt-toast-msg' }, message), + ...actions.map(({ label, run }) => el('button', { + type: 'button', class: 'lt-toast-action', + onclick: () => { + dismiss(node); + run(); + }, + }, label)), el('button', { type: 'button', class: 'lt-toast-close', 'aria-label': 'Dismiss', onclick: () => dismiss(node) }, '✕'), el('div', { class: 'lt-toast-progress', style: `animation-duration:${duration}ms` })); box.append(node); @@ -39,10 +47,83 @@ export function toast(message, type = 'info', duration = 4000) { node.addEventListener('mouseleave', () => { node.dismissTimer = setTimeout(() => dismiss(node), 1500); }); } -toast.success = (message, duration) => toast(message, 'success', duration); -toast.error = (message, duration = 8000) => toast(message, 'error', duration); -toast.warning = (message, duration) => toast(message, 'warning', duration); -toast.info = (message, duration) => toast(message, 'info', duration); +toast.success = (message, duration, actions) => toast(message, 'success', duration, actions); +toast.error = (message, duration = 8000, actions) => toast(message, 'error', duration, actions); +toast.warning = (message, duration, actions) => toast(message, 'warning', duration, actions); +toast.info = (message, duration, actions) => toast(message, 'info', duration, actions); + +// ---------------------------------------------------------------- context menu + +// A right-click menu at (x, y) (devdesk#54). items: { label, kbd, run, disabled, +// danger } or 'sep'. Arrow keys move, Enter runs, Esc / a click elsewhere / +// scrolling / resizing closes it. Returns the close function. +let closeOpenMenu = null; +export function contextMenu(x, y, items) { + closeOpenMenu?.(); + const opener = document.activeElement; + const buttons = []; + const menu = el('div', { class: 'dd-menu dd-ctx-menu', role: 'menu' }, ...items.filter(Boolean).map((item) => { + if (item === 'sep') return el('div', { class: 'dd-menu-sep', role: 'separator' }); + const button = el('button', { + type: 'button', role: 'menuitem', class: `dd-menu-item${item.danger ? ' is-danger' : ''}`, disabled: item.disabled ? true : null, + onclick: () => { + close(false); + item.run(); + }, + }, el('span', { class: 'dd-ctx-label' }, item.label), item.kbd ? el('kbd', { class: 'dd-ctx-kbd' }, item.kbd) : null); + if (!item.disabled) buttons.push(button); + return button; + })); + const close = (refocus = true) => { + if (!menu.isConnected) return; + menu.remove(); + closeOpenMenu = null; + document.removeEventListener('pointerdown', outside, true); + window.removeEventListener('blur', onBlur); + window.removeEventListener('resize', onBlur); + document.removeEventListener('scroll', onBlur, true); + if (refocus && opener?.isConnected) opener.focus?.(); + }; + const outside = (event) => { + if (!menu.contains(event.target)) close(false); + }; + const opened = Date.now(); + const onBlur = (event) => { + // Scrolling closes it, but not the layout settling right after it opened. + if (event?.type === 'scroll' && (menu.contains(event.target) || Date.now() - opened < 400)) return; + close(false); + }; + menu.addEventListener('keydown', (event) => { + const i = buttons.indexOf(document.activeElement); + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + const next = buttons[(i + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length]; + next?.focus(); + } else if (event.key === 'Home' || event.key === 'End') { + event.preventDefault(); + buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus(); + } else if (event.key === 'Escape' || event.key === 'Tab') { + event.preventDefault(); + event.stopPropagation(); + close(); + } + }); + menu.addEventListener('contextmenu', (event) => event.preventDefault()); + document.body.append(menu); + // Keep it on screen. + const { width, height } = menu.getBoundingClientRect(); + menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - width - 4))}px`; + menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - height - 4))}px`; + buttons[0]?.focus(); + setTimeout(() => { + document.addEventListener('pointerdown', outside, true); + window.addEventListener('blur', onBlur); + window.addEventListener('resize', onBlur); + document.addEventListener('scroll', onBlur, true); + }, 0); + closeOpenMenu = close; + return close; +} // ---------------------------------------------------------------- dialogs diff --git a/web/styles.css b/web/styles.css index 6a4c603..0e98779 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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:hover { background: var(--accent-cyan); } .dd-explorer-views { display: flex; border-bottom: 1px solid var(--border-dim); } -.dd-explorer-views button { +.dd-explorer-views button[role="tab"] { flex: 1; padding: 6px 4px 5px; border: 0; @@ -812,7 +812,7 @@ body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transpar text-transform: uppercase; cursor: pointer; } -.dd-explorer-views button:hover { color: var(--text-primary); } +.dd-explorer-views button[role="tab"]:hover { color: var(--text-primary); } .dd-explorer-views button[aria-selected="true"] { color: var(--accent-orange); border-bottom-color: var(--accent-orange); } .dd-files-count:not(:empty) { margin-left: 3px; padding: 0 5px; border-radius: 8px; background: var(--accent-amber-dim); color: var(--accent-amber); font-size: 0.6rem; } .dd-explorer-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 0 12px; outline: none; } @@ -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-stat { flex: none; color: var(--text-muted); } .dd-link-btn { padding: 0; border: 0; background: none; color: var(--accent-cyan); font: inherit; text-decoration: underline dotted; cursor: pointer; } + +/* Toast actions (Undo) and right-click menus (devdesk#54) */ +.lt-toast-action { flex: none; padding: 2px 8px; border: 1px solid currentColor; background: none; color: inherit; font: inherit; font-size: 0.72rem; cursor: pointer; } +.lt-toast-action:hover { background: var(--accent-cyan-dim); } +.dd-ctx-menu { position: fixed; top: 0; left: 0; z-index: 200; min-width: 220px; max-height: 80vh; } +.dd-ctx-menu .dd-menu-item { justify-content: space-between; padding: 5px 10px; } +.dd-ctx-menu .dd-menu-item.is-danger { color: var(--accent-red); } +.dd-ctx-kbd { margin-left: 24px; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.66rem; } +.dd-menu-sep { height: 1px; margin: 4px 6px; background: var(--border-dim); } + +/* Explorer operations (devdesk#54) */ +.dd-explorer-actions { display: flex; align-items: center; gap: 1px; padding: 0 4px; border-left: 1px solid var(--border-dim); } +.dd-explorer-actions .dd-icon-btn { width: 24px; height: 24px; padding: 0; font-size: 0.82rem; line-height: 1; } +.dd-files:not([data-view="explorer"]) .dd-explorer-actions { display: none; } +.dd-tree-row.is-selected { background: var(--accent-cyan-dim); } +.dd-tree-row.is-selected.is-active { box-shadow: inset 2px 0 0 var(--accent-cyan); } +.dd-explorer-body:focus-within .dd-tree-row.is-selected { background: rgba(0, 150, 220, 0.22); } +.dd-tree-row.is-cut { opacity: 0.45; } +.dd-tree-row.is-drop { background: var(--accent-orange-dim); } +.dd-tree-row.is-drop.is-dir { box-shadow: inset 0 0 0 1px var(--accent-orange); } +.dd-explorer-body.is-drop { background: var(--accent-orange-dim); box-shadow: inset 0 0 0 1px var(--accent-orange); } +.dd-tree-row.is-editing { cursor: default; background: var(--bg-tertiary); } +.dd-tree-input { + flex: 1; + min-width: 0; + height: 20px; + padding: 0 4px; + border: 1px solid var(--accent-cyan); + background: var(--bg-input, var(--bg-primary)); + color: var(--text-primary); + font: inherit; + outline: none; +} +.dd-upload-bar { display: flex; flex-direction: column; gap: 4px; padding: 6px 10px; border-top: 1px solid var(--border-dim); font-size: 0.68rem; color: var(--text-secondary); } +.dd-upload-bar[hidden] { display: none; } +.dd-upload-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dd-upload-meter { height: 3px; background: var(--bg-tertiary); } +.dd-upload-fill { display: block; width: 0; height: 100%; background: var(--accent-cyan); transition: width 0.15s linear; } +.dd-drag-ghost { position: fixed; top: -100px; left: -100px; padding: 3px 10px; background: var(--accent-cyan); color: var(--bg-primary); font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700; } + +/* Markdown preview and split, editor status bar (devdesk#55) */ +.dd-viewer-content.is-preview { overflow: hidden; } +.dd-md-host { flex: 1; min-height: 0; outline: none; } +.dd-viewer-content.is-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +.dd-split-editor { min-width: 0; height: 100%; overflow: hidden; border-right: 1px solid var(--border-dim); } +.dd-split-editor .cm-editor { height: 100%; } +.dd-split-preview { min-width: 0; } +.dd-editor-status { display: flex; align-items: center; gap: 14px; flex: none; height: 22px; padding: 0 10px; border-top: 1px solid var(--border-dim); background: var(--bg-secondary); color: var(--text-muted); font-family: var(--font-mono); font-size: 0.66rem; white-space: nowrap; overflow: hidden; } +.dd-status-pos { color: var(--text-secondary); } +.dd-status-sel { color: var(--accent-cyan); } +.dd-status-spacer { flex: 1; } + +/* Search in files (devdesk#56) */ +.dd-search { display: flex; flex-direction: column; min-height: 100%; } +.dd-search-box { display: flex; gap: 2px; padding: 6px 8px; border-bottom: 1px solid var(--border-dim); } +.dd-search-input { flex: 1; min-width: 0; height: 26px; padding: 0 6px; font-family: var(--font-mono); font-size: 0.74rem; } +.dd-search-flag { flex: none; width: 26px; height: 26px; padding: 0; border: 1px solid transparent; background: none; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.7rem; cursor: pointer; } +.dd-search-flag:hover { color: var(--text-primary); } +.dd-search-flag[aria-pressed="true"] { border-color: var(--accent-cyan); background: var(--accent-cyan-dim); color: var(--accent-cyan); } +.dd-search-summary { padding: 4px 10px; color: var(--text-muted); font-size: 0.66rem; } +.dd-search-file { display: flex; align-items: center; gap: 6px; width: 100%; padding: 3px 8px; border: 0; background: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 0.74rem; text-align: left; cursor: pointer; } +.dd-search-file:hover { background: var(--bg-tertiary); } +.dd-search-file::before { content: '▾'; width: 10px; color: var(--text-muted); font-size: 0.66rem; } +.dd-search-file[aria-expanded="false"]::before { content: '▸'; } +.dd-search-file .dd-tree-name { flex: none; } +.dd-search-file .dd-change-dir { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dd-search-count { flex: none; padding: 0 6px; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.62rem; } +.dd-search-lines[hidden] { display: none; } +.dd-search-line { display: flex; gap: 8px; padding: 2px 8px 2px 30px; color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.72rem; white-space: nowrap; cursor: pointer; outline: none; } +.dd-search-line:hover, .dd-search-line:focus { background: var(--bg-tertiary); color: var(--text-primary); } +.dd-search-line:focus { box-shadow: inset 0 0 0 1px var(--accent-cyan); } +.dd-search-ln { flex: none; min-width: 2.5em; color: var(--text-muted); text-align: right; } +.dd-search-text { overflow: hidden; text-overflow: ellipsis; } +.dd-search-text mark { background: var(--accent-amber-dim); color: var(--accent-amber); outline: 1px solid color-mix(in srgb, var(--accent-amber) 50%, transparent); }