feat: v0.7.0 files pane: browse, preview, diff, Claude's edits
- devdesk attach --fs-list/--fs-read/--git-status/--git-show/--asset (libexec/devdesk-files.sh), confined to the session folder. - The state hook records files Claude edits (Edit/Write/MultiEdit/ NotebookEdit) for the Changes tab's "C" badge; rename/end carry it. - Files pane: folders, CodeMirror read-only view, sanitized markdown preview in a sandboxed iframe with inlined relative images, images, Changes tab with line counts and diff against HEAD, "→ Claude" @path, resizable split. - The editor bundle ships as /usr/share/devdesk/assets/editor.js, is fetched over ttyd on first use, verified against the page's SHA-256 and cached in IndexedDB. - Fix: the state hook's field split collapsed empty fields. Closes #19 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
# 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}"
|
||||
|
||||
# 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.
|
||||
cmd_fs_list() {
|
||||
if [ $# -lt 1 ] || [ $# -gt 2 ]; then dd_die "usage: devdesk attach --fs-list SESSION [PATH]"; fi
|
||||
local root dir rel=${2:-.}
|
||||
root=$(dd_session_root "$1")
|
||||
dir=$(dd_fs_path "$root" "$rel")
|
||||
[ -d "$dir" ] || dd_die "not a folder: $rel"
|
||||
# %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 |
|
||||
head -z -n "$((DEVDESK_FS_LIST_MAX + 1))" |
|
||||
jq -cMRs --arg root "$root" --arg rel "${dir#"$root"}" --argjson max "$DEVDESK_FS_LIST_MAX" '
|
||||
(split("\u0000") | map(select(length > 0) | split("\t") |
|
||||
{t: .[0], to: .[1], s: (.[2] | tonumber), m: (.[3] | tonumber | floor), n: (.[4:] | join("\t"))})) 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)]))}'
|
||||
}
|
||||
|
||||
# --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 | head -z -n 1000 | tr '\0' '\n'
|
||||
printf 'N\n'
|
||||
"${g[@]}" diff --numstat HEAD -- . 2>/dev/null | 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"
|
||||
}
|
||||
Reference in New Issue
Block a user