# 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}" # 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; }`. # 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. # In a git repository, entries .gitignore covers are flagged (the UI dims them). cmd_fs_list() { if [ $# -lt 1 ] || [ $# -gt 2 ]; then dd_die "usage: devdesk attach --fs-list SESSION [PATH]"; fi local root dir rel=${2:-.} tmp ignored="" root=$(dd_session_root "$1") dir=$(dd_fs_path "$root" "$rel") [ -d "$dir" ] || dd_die "not a folder: $rel" tmp=$(mktemp) # %y: type, %Y: type behind a symlink, %s: size, %T@: mtime, %f: name. { find "$dir" -mindepth 1 -maxdepth 1 ! -name .git -printf '%y\t%Y\t%s\t%T@\t%f\0' 2>/dev/null || true; } | 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 # find without node_modules, .git and build/cache folders; capped at # DEVDESK_FS_FIND_MAX (10000). cmd_fs_find() { [ $# -eq 1 ] || dd_die "usage: devdesk attach --fs-find SESSION" local root max=${DEVDESK_FS_FIND_MAX:-10000} root=$(dd_session_root "$1") if git --no-optional-locks -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then git --no-optional-locks -C "$root" ls-files -co --exclude-standard -z 2>/dev/null || true else (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 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]}' } # --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' { "${g[@]}" status --porcelain=v1 -z --untracked-files=all -- . 2>/dev/null || true; } | head -z -n 1000 | tr '\0' '\n' printf 'N\n' { "${g[@]}" diff --numstat HEAD -- . 2>/dev/null || true; } | head -n 1000 } | 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" }