feat: v0.11.0 VS Code-like explorer, markdown preview, search in files
build-deb / Lint, test, package (push) Successful in 2m29s
build-deb / Deploy to CT 126 (push) Successful in 7s
build-deb / Notify on failure (push) Skipped

- Explorer: context menus, multi-select, inline rename/new, drag to move
  or copy, desktop drag-and-drop uploads (folders too), cut/copy/paste,
  duplicate, download, delete to a 7-day trash with Undo. Server side in
  libexec/devdesk-fsops.sh with bats tests. Closes #54
- Markdown preview in a shadow root: headings with anchors and outline,
  highlighted code with copy, callouts, task lists, front matter, links
  that open files, live split view. Editor status bar, multi-cursor,
  closeBrackets, Ctrl+G, tab menus and reordering. Closes #55
- Search in files (Ctrl+Shift+F) via git grep / grep. Closes #56
- Fix: nested $(...) path checks now fail hard (set -e does not reach
  them; an escape created /x in testing); newest folder listing wins;
  sessionAction returns the reply JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
This commit is contained in:
2026-10-22 21:25:43 -04:00
co-authored by Claude Opus 5.5
parent e60f1ad066
commit 65114165e7
15 changed files with 2166 additions and 167 deletions
+269
View File
@@ -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]}'
}