2026-10-08 01:16:37 -04:00
|
|
|
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
|
|
|
|
|
# File viewer commands (devdesk#19), sourced by bin/devdesk. Every path is
|
|
|
|
|
# relative to the session's folder and must stay inside it: no absolute paths,
|
|
|
|
|
# no newlines, no symlink escapes. Reached through `devdesk attach --fs-*`.
|
|
|
|
|
|
|
|
|
|
: "${DEVDESK_FS_MAX:=2097152}" # bytes the viewer opens
|
|
|
|
|
: "${DEVDESK_FS_LIST_MAX:=2000}" # entries per directory listing
|
|
|
|
|
: "${DEVDESK_ASSETS:=$DEVDESK_SHARE/assets}"
|
|
|
|
|
|
2026-10-08 01:50:50 -04:00
|
|
|
# Producers piped into `head` get SIGPIPE once the cap is reached; under
|
|
|
|
|
# pipefail that would fail the command and cut its reply short, so they are
|
|
|
|
|
# wrapped as `{ producer || true; }`.
|
|
|
|
|
|
2026-10-08 01:16:37 -04:00
|
|
|
# Blob replies: a JSON header line, then base64 between markers (no body for
|
|
|
|
|
# binary files the viewer can't show). The client waits for the END marker.
|
|
|
|
|
DD_BLOB_BEGIN=DEVDESK-BLOB-BEGIN
|
|
|
|
|
DD_BLOB_END=DEVDESK-BLOB-END
|
|
|
|
|
|
|
|
|
|
dd_session_root() {
|
|
|
|
|
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
|
|
|
|
realpath -e -- "$(dd_resolve_dir "$1")" 2>/dev/null || dd_die "the folder of session '$1' is missing"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# dd_fs_path ROOT REL -> the real path of REL inside ROOT, or die.
|
|
|
|
|
dd_fs_path() {
|
|
|
|
|
local root=$1 rel=${2:-.} p
|
|
|
|
|
case $rel in
|
|
|
|
|
/* | *$'\n'* | *$'\r'*) dd_die "bad path: $rel" ;;
|
|
|
|
|
esac
|
|
|
|
|
[ -n "$rel" ] || rel=.
|
|
|
|
|
p=$(realpath -e -- "$root/$rel" 2>/dev/null) || dd_die "no such file: $rel"
|
|
|
|
|
case "$p/" in
|
|
|
|
|
"$root"/*) printf '%s\n' "$p" ;;
|
|
|
|
|
*) dd_die "outside the session folder: $rel" ;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dd_mime() {
|
|
|
|
|
case ${1,,} in
|
|
|
|
|
*.png) echo image/png ;;
|
|
|
|
|
*.jpg | *.jpeg) echo image/jpeg ;;
|
|
|
|
|
*.gif) echo image/gif ;;
|
|
|
|
|
*.webp) echo image/webp ;;
|
|
|
|
|
*.svg) echo image/svg+xml ;;
|
|
|
|
|
*.ico) echo image/x-icon ;;
|
|
|
|
|
*.bmp) echo image/bmp ;;
|
|
|
|
|
*.pdf) echo application/pdf ;;
|
|
|
|
|
*) echo "" ;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# dd_blob FILE LABEL: header + body for one file on disk.
|
|
|
|
|
dd_blob() {
|
|
|
|
|
local f=$1 label=$2 size mtime sha mime binary=false utf8=true body=true
|
|
|
|
|
read -r size mtime < <(stat -c '%s %Y' -- "$f")
|
|
|
|
|
mime=$(dd_mime "$f")
|
|
|
|
|
if [ "$size" -gt "$DEVDESK_FS_MAX" ]; then
|
|
|
|
|
jq -cMn --arg p "$label" --argjson s "$size" --argjson m "$mtime" --argjson max "$DEVDESK_FS_MAX" \
|
|
|
|
|
'{path: $p, size: $s, mtime: $m, tooLarge: true, max: $max}'
|
|
|
|
|
return
|
|
|
|
|
fi
|
|
|
|
|
if head -c 8192 -- "$f" | grep -qaP '\x00'; then
|
|
|
|
|
binary=true
|
|
|
|
|
[ -n "$mime" ] || body=false
|
|
|
|
|
elif ! iconv -f UTF-8 -t UTF-8 -- "$f" >/dev/null 2>&1; then
|
|
|
|
|
utf8=false
|
|
|
|
|
fi
|
|
|
|
|
sha=$(sha256sum -- "$f" | cut -d' ' -f1)
|
|
|
|
|
jq -cMn --arg p "$label" --argjson s "$size" --argjson m "$mtime" --arg h "$sha" \
|
|
|
|
|
--argjson b "$binary" --argjson u "$utf8" --arg mime "$mime" --argjson body "$body" \
|
|
|
|
|
'{path: $p, size: $s, mtime: $m, sha: $h, binary: $b, utf8: $u, body: $body,
|
|
|
|
|
mime: (if $mime == "" then null else $mime end)}'
|
|
|
|
|
printf '%s\n' "$DD_BLOB_BEGIN"
|
|
|
|
|
if $body; then base64 -w0 -- "$f"; fi
|
|
|
|
|
printf '\n%s\n' "$DD_BLOB_END"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# --fs-list SESSION [REL]: one directory level, folders first, .git skipped.
|
2026-10-08 01:40:47 -04:00
|
|
|
# In a git repository, entries .gitignore covers are flagged (the UI dims them).
|
2026-10-08 01:16:37 -04:00
|
|
|
cmd_fs_list() {
|
|
|
|
|
if [ $# -lt 1 ] || [ $# -gt 2 ]; then dd_die "usage: devdesk attach --fs-list SESSION [PATH]"; fi
|
2026-10-08 01:40:47 -04:00
|
|
|
local root dir rel=${2:-.} tmp ignored=""
|
2026-10-08 01:16:37 -04:00
|
|
|
root=$(dd_session_root "$1")
|
|
|
|
|
dir=$(dd_fs_path "$root" "$rel")
|
|
|
|
|
[ -d "$dir" ] || dd_die "not a folder: $rel"
|
2026-10-08 01:40:47 -04:00
|
|
|
tmp=$(mktemp)
|
2026-10-08 01:16:37 -04:00
|
|
|
# %y: type, %Y: type behind a symlink, %s: size, %T@: mtime, %f: name.
|
2026-10-08 01:50:50 -04:00
|
|
|
{ find "$dir" -mindepth 1 -maxdepth 1 ! -name .git -printf '%y\t%Y\t%s\t%T@\t%f\0' 2>/dev/null || true; } |
|
2026-10-08 01:40:47 -04:00
|
|
|
head -z -n "$((DEVDESK_FS_LIST_MAX + 1))" >"$tmp"
|
|
|
|
|
if git --no-optional-locks -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
|
|
|
ignored=$(tr '\0' '\n' <"$tmp" | cut -f5- | git --no-optional-locks -C "$dir" check-ignore --stdin 2>/dev/null || true)
|
|
|
|
|
fi
|
|
|
|
|
jq -cMRs --arg root "$root" --arg rel "${dir#"$root"}" --argjson max "$DEVDESK_FS_LIST_MAX" --arg ign "$ignored" '
|
|
|
|
|
($ign | split("\n") | map(select(length > 0) | rtrimstr("/")) | INDEX(.)) as $ignored
|
|
|
|
|
| (split("\u0000") | map(select(length > 0) | split("\t") |
|
|
|
|
|
{t: .[0], to: .[1], s: (.[2] | tonumber), m: (.[3] | tonumber | floor), n: (.[4:] | join("\t"))}
|
|
|
|
|
| if $ignored[.n] then . + {ig: true} else . end)) as $all
|
|
|
|
|
| {root: $root, dir: ($rel | ltrimstr("/")), truncated: ($all | length > $max),
|
|
|
|
|
entries: ($all[:$max] | sort_by([(if .t == "d" or .to == "d" then 0 else 1 end), (.n | ascii_downcase)]))}' <"$tmp"
|
|
|
|
|
rm -f "$tmp"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# --fs-find SESSION: every file under the session folder, for "go to file".
|
|
|
|
|
# git ls-files in a repository (tracked + untracked, .gitignore honoured), else
|
2026-10-08 01:50:50 -04:00
|
|
|
# find without node_modules, .git and build/cache folders; capped at
|
|
|
|
|
# DEVDESK_FS_FIND_MAX (10000).
|
2026-10-08 01:40:47 -04:00
|
|
|
cmd_fs_find() {
|
|
|
|
|
[ $# -eq 1 ] || dd_die "usage: devdesk attach --fs-find SESSION"
|
2026-10-08 01:50:50 -04:00
|
|
|
local root max=${DEVDESK_FS_FIND_MAX:-10000}
|
2026-10-08 01:40:47 -04:00
|
|
|
root=$(dd_session_root "$1")
|
|
|
|
|
if git --no-optional-locks -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
2026-10-08 01:50:50 -04:00
|
|
|
git --no-optional-locks -C "$root" ls-files -co --exclude-standard -z 2>/dev/null || true
|
2026-10-08 01:40:47 -04:00
|
|
|
else
|
2026-10-08 01:50:50 -04:00
|
|
|
(cd "$root" && find . -maxdepth 8 \( -name node_modules -o -name .git -o -name __pycache__ -o -name .venv -o -name venv \
|
|
|
|
|
-o -name dist -o -name build -o -name target -o -name .cache -o -name .next -o -name coverage \) -prune \
|
|
|
|
|
-o -type f -printf '%P\0' 2>/dev/null) || true
|
2026-10-08 01:40:47 -04:00
|
|
|
fi | head -z -n "$((max + 1))" |
|
|
|
|
|
jq -cMRs --argjson max "$max" --arg root "$root" \
|
|
|
|
|
'(split("\u0000") | map(select(length > 0))) as $f | {root: $root, truncated: ($f | length > $max), files: $f[:$max]}'
|
2026-10-08 01:16:37 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# --fs-read SESSION REL
|
|
|
|
|
cmd_fs_read() {
|
|
|
|
|
[ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-read SESSION PATH"
|
|
|
|
|
local root f
|
|
|
|
|
root=$(dd_session_root "$1")
|
|
|
|
|
f=$(dd_fs_path "$root" "$2")
|
|
|
|
|
[ -f "$f" ] || dd_die "not a file: $2"
|
|
|
|
|
dd_blob "$f" "${f#"$root"/}"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# --git-status SESSION: changed files under the session folder, with line counts
|
|
|
|
|
# and whether Claude edited them (from devdesk-state-hook's edit log).
|
|
|
|
|
cmd_git_status() {
|
|
|
|
|
[ $# -eq 1 ] || dd_die "usage: devdesk attach --git-status SESSION"
|
|
|
|
|
local root g top prefix branch edits
|
|
|
|
|
root=$(dd_session_root "$1")
|
|
|
|
|
edits="$DEVDESK_STATE_DIR/edits/$1.txt"
|
|
|
|
|
g=(git --no-optional-locks -C "$root")
|
|
|
|
|
if ! top=$("${g[@]}" rev-parse --show-toplevel 2>/dev/null); then
|
|
|
|
|
# Not a repository: only what Claude edited.
|
|
|
|
|
jq -cMn --arg root "$root" --rawfile e <(cat -- "$edits" 2>/dev/null || true) '
|
|
|
|
|
{git: false, root: $root, files: [$e | split("\n")[] | select(length > 0) | select(startswith($root + "/"))
|
|
|
|
|
| {path: ltrimstr($root + "/"), x: " ", y: " ", claude: true}]}'
|
|
|
|
|
return
|
|
|
|
|
fi
|
|
|
|
|
prefix=$("${g[@]}" rev-parse --show-prefix)
|
|
|
|
|
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
|
|
|
|
|
{
|
|
|
|
|
printf 'S\n'
|
2026-10-08 01:50:50 -04:00
|
|
|
{ "${g[@]}" status --porcelain=v1 -z --untracked-files=all -- . 2>/dev/null || true; } | head -z -n 1000 | tr '\0' '\n'
|
2026-10-08 01:16:37 -04:00
|
|
|
printf 'N\n'
|
2026-10-08 01:50:50 -04:00
|
|
|
{ "${g[@]}" diff --numstat HEAD -- . 2>/dev/null || true; } | head -n 1000
|
2026-10-08 01:16:37 -04:00
|
|
|
} | jq -cMRs --arg root "$root" --arg top "$top" --arg prefix "$prefix" --arg branch "$branch" \
|
|
|
|
|
--rawfile e <(cat -- "$edits" 2>/dev/null || true) '
|
|
|
|
|
(split("\nN\n") | .[0] | ltrimstr("S\n") | split("\n") | map(select(length > 2))) as $st
|
|
|
|
|
| (split("\nN\n") | .[1] // "" | split("\n") | map(select(length > 0) | split("\t")
|
|
|
|
|
| {key: .[2:] | join("\t"), value: {add: (.[0] | tonumber? // null), del: (.[1] | tonumber? // null)}}) | from_entries) as $num
|
|
|
|
|
| ($e | split("\n") | map(select(length > 0))) as $claude
|
|
|
|
|
# porcelain -z: "XY path", and after a rename/copy entry the next item is the old path.
|
|
|
|
|
| reduce range(0; $st | length) as $i ({skip: false, files: []};
|
|
|
|
|
if .skip then .skip = false
|
|
|
|
|
else ($st[$i]) as $line | ($line[3:]) as $p
|
|
|
|
|
| .files += [{repoPath: $p, x: $line[0:1], y: $line[1:2],
|
|
|
|
|
from: (if ($line[0:1] | test("[RC]")) then $st[$i + 1] else null end)}]
|
|
|
|
|
| .skip = ($line[0:1] | test("[RC]"))
|
|
|
|
|
end)
|
|
|
|
|
| .files | map(. + {path: (if (.repoPath | startswith($prefix)) then .repoPath | ltrimstr($prefix) else null end),
|
|
|
|
|
add: $num[.repoPath].add, del: $num[.repoPath].del,
|
|
|
|
|
claude: (($top + "/" + .repoPath) as $abs | any($claude[]; . == $abs))}) as $files
|
|
|
|
|
| {git: true, root: $root, branch: $branch,
|
|
|
|
|
files: ($files + [$claude[] | select(startswith($root + "/")) | ltrimstr($root + "/") as $p
|
|
|
|
|
| select(any($files[]; .path == $p) | not)
|
|
|
|
|
| {path: $p, repoPath: null, x: " ", y: " ", claude: true}])}'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# --git-show SESSION REL: the HEAD version of a file, for diffs. An empty body
|
|
|
|
|
# when HEAD doesn't have it (new file).
|
|
|
|
|
cmd_git_show() {
|
|
|
|
|
[ $# -eq 2 ] || dd_die "usage: devdesk attach --git-show SESSION PATH"
|
|
|
|
|
local root g top prefix tmp rel=$2
|
|
|
|
|
root=$(dd_session_root "$1")
|
|
|
|
|
case $rel in /* | *$'\n'* | *..*) dd_die "bad path: $rel" ;; esac
|
|
|
|
|
g=(git --no-optional-locks -C "$root")
|
|
|
|
|
top=$("${g[@]}" rev-parse --show-toplevel 2>/dev/null) || dd_die "not a git repository"
|
|
|
|
|
prefix=$("${g[@]}" rev-parse --show-prefix)
|
|
|
|
|
tmp=$(mktemp)
|
|
|
|
|
"${g[@]}" show "HEAD:$prefix$rel" >"$tmp" 2>/dev/null || : >"$tmp"
|
|
|
|
|
dd_blob "$tmp" "$rel"
|
|
|
|
|
rm -f "$tmp"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# --asset NAME: a lazily loaded part of the web UI (the file viewer's editor),
|
|
|
|
|
# shipped in $DEVDESK_ASSETS and cached by the browser.
|
|
|
|
|
cmd_asset() {
|
|
|
|
|
if [ $# -ne 1 ] || ! [[ $1 =~ ^[a-z0-9-]+\.js$ ]]; then dd_die "usage: devdesk attach --asset NAME.js"; fi
|
|
|
|
|
[ -f "$DEVDESK_ASSETS/$1" ] || dd_die "no such asset: $1"
|
|
|
|
|
dd_blob "$DEVDESK_ASSETS/$1" "$1"
|
|
|
|
|
}
|