fix: go to file in big non-git folders; linear reply handling
- find/git producers piped into head tolerate SIGPIPE, so hitting a cap no longer fails the command (and lets ttyd truncate its reply). - fs-find outside git skips build/cache folders and caps at 10000. - runCommand checks completion on the stream's tail instead of re-decoding the whole reply on every frame. Refs #37 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vendored
+11
@@ -1,3 +1,14 @@
|
|||||||
|
devdesk (0.7.2) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* Fix: Go to file failed in big non-git folders such as /root/code: the
|
||||||
|
file walk was cut off at its cap with SIGPIPE, which failed the command
|
||||||
|
and truncated its reply. Producers piped into a cap now tolerate it (also
|
||||||
|
for very large folders and git status). Outside git, Go to file skips
|
||||||
|
build/cache folders and stops at 10,000 files.
|
||||||
|
* Large side-channel replies no longer cost quadratic time in the browser.
|
||||||
|
|
||||||
|
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 01:50:50 -0400
|
||||||
|
|
||||||
devdesk (0.7.1) bookworm; urgency=medium
|
devdesk (0.7.1) bookworm; urgency=medium
|
||||||
|
|
||||||
* Files pane reworked like VS Code (#37):
|
* Files pane reworked like VS Code (#37):
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
: "${DEVDESK_FS_LIST_MAX:=2000}" # entries per directory listing
|
: "${DEVDESK_FS_LIST_MAX:=2000}" # entries per directory listing
|
||||||
: "${DEVDESK_ASSETS:=$DEVDESK_SHARE/assets}"
|
: "${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
|
# 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.
|
# binary files the viewer can't show). The client waits for the END marker.
|
||||||
DD_BLOB_BEGIN=DEVDESK-BLOB-BEGIN
|
DD_BLOB_BEGIN=DEVDESK-BLOB-BEGIN
|
||||||
@@ -81,7 +85,7 @@ cmd_fs_list() {
|
|||||||
[ -d "$dir" ] || dd_die "not a folder: $rel"
|
[ -d "$dir" ] || dd_die "not a folder: $rel"
|
||||||
tmp=$(mktemp)
|
tmp=$(mktemp)
|
||||||
# %y: type, %Y: type behind a symlink, %s: size, %T@: mtime, %f: name.
|
# %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 |
|
{ 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"
|
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
|
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)
|
ignored=$(tr '\0' '\n' <"$tmp" | cut -f5- | git --no-optional-locks -C "$dir" check-ignore --stdin 2>/dev/null || true)
|
||||||
@@ -98,16 +102,18 @@ cmd_fs_list() {
|
|||||||
|
|
||||||
# --fs-find SESSION: every file under the session folder, for "go to file".
|
# --fs-find SESSION: every file under the session folder, for "go to file".
|
||||||
# git ls-files in a repository (tracked + untracked, .gitignore honoured), else
|
# git ls-files in a repository (tracked + untracked, .gitignore honoured), else
|
||||||
# find without node_modules and .git; capped at DEVDESK_FS_FIND_MAX.
|
# find without node_modules, .git and build/cache folders; capped at
|
||||||
|
# DEVDESK_FS_FIND_MAX (10000).
|
||||||
cmd_fs_find() {
|
cmd_fs_find() {
|
||||||
[ $# -eq 1 ] || dd_die "usage: devdesk attach --fs-find SESSION"
|
[ $# -eq 1 ] || dd_die "usage: devdesk attach --fs-find SESSION"
|
||||||
local root max=${DEVDESK_FS_FIND_MAX:-20000}
|
local root max=${DEVDESK_FS_FIND_MAX:-10000}
|
||||||
root=$(dd_session_root "$1")
|
root=$(dd_session_root "$1")
|
||||||
if git --no-optional-locks -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
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
|
git --no-optional-locks -C "$root" ls-files -co --exclude-standard -z 2>/dev/null || true
|
||||||
else
|
else
|
||||||
(cd "$root" && find . -maxdepth 8 \( -name node_modules -o -name .git -o -name __pycache__ -o -name .venv \) -prune \
|
(cd "$root" && find . -maxdepth 8 \( -name node_modules -o -name .git -o -name __pycache__ -o -name .venv -o -name venv \
|
||||||
-o -type f -printf '%P\0' 2>/dev/null)
|
-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))" |
|
fi | head -z -n "$((max + 1))" |
|
||||||
jq -cMRs --argjson max "$max" --arg root "$root" \
|
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]}'
|
'(split("\u0000") | map(select(length > 0))) as $f | {root: $root, truncated: ($f | length > $max), files: $f[:$max]}'
|
||||||
@@ -142,9 +148,9 @@ cmd_git_status() {
|
|||||||
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
|
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
|
||||||
{
|
{
|
||||||
printf 'S\n'
|
printf 'S\n'
|
||||||
"${g[@]}" status --porcelain=v1 -z --untracked-files=all -- . 2>/dev/null | head -z -n 1000 | tr '\0' '\n'
|
{ "${g[@]}" status --porcelain=v1 -z --untracked-files=all -- . 2>/dev/null || true; } | head -z -n 1000 | tr '\0' '\n'
|
||||||
printf 'N\n'
|
printf 'N\n'
|
||||||
"${g[@]}" diff --numstat HEAD -- . 2>/dev/null | head -n 1000
|
{ "${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" \
|
} | jq -cMRs --arg root "$root" --arg top "$top" --arg prefix "$prefix" --arg branch "$branch" \
|
||||||
--rawfile e <(cat -- "$edits" 2>/dev/null || true) '
|
--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") | .[0] | ltrimstr("S\n") | split("\n") | map(select(length > 2))) as $st
|
||||||
|
|||||||
@@ -611,6 +611,12 @@ blob_body() {
|
|||||||
rm -rf "$p/.git"
|
rm -rf "$p/.git"
|
||||||
run devdesk attach --fs-find alpha
|
run devdesk attach --fs-find alpha
|
||||||
[ "$(jq -r '.files | sort | join(",")' <<<"$output")" = ".gitignore,debug.log,src/a.js" ]
|
[ "$(jq -r '.files | sort | join(",")' <<<"$output")" = ".gitignore,debug.log,src/a.js" ]
|
||||||
|
# Hitting the cap must not fail the command (SIGPIPE in the producer).
|
||||||
|
for i in $(seq 1 3000); do : >"$p/src/f$i.txt"; done
|
||||||
run env DEVDESK_FS_FIND_MAX=1 "$REPO/bin/devdesk" attach --fs-find alpha
|
run env DEVDESK_FS_FIND_MAX=1 "$REPO/bin/devdesk" attach --fs-find alpha
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
[ "$(jq -r '[(.files | length), .truncated] | @tsv' <<<"$output")" = "$(printf '1\ttrue')" ]
|
[ "$(jq -r '[(.files | length), .truncated] | @tsv' <<<"$output")" = "$(printf '1\ttrue')" ]
|
||||||
|
run env DEVDESK_FS_LIST_MAX=5 "$REPO/bin/devdesk" attach --fs-list alpha src
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(jq -r '[(.entries | length), .truncated] | @tsv' <<<"$output")" = "$(printf '5\ttrue')" ]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,13 +152,21 @@ async function runCommandNow(args, timeoutMs) {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
resolve(new TextDecoder().decode(concat(chunks)));
|
resolve(new TextDecoder().decode(concat(chunks)));
|
||||||
};
|
};
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let head = '';
|
||||||
|
let tail = '';
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
const { type, payload } = decodeServer(event.data);
|
const { type, payload } = decodeServer(event.data);
|
||||||
if (type !== Server.OUTPUT) return;
|
if (type !== Server.OUTPUT) return;
|
||||||
chunks.push(payload.slice());
|
chunks.push(payload.slice());
|
||||||
|
const piece = decoder.decode(payload, { stream: true });
|
||||||
|
if (head.length < 4096) head += piece;
|
||||||
|
tail = (tail + piece).slice(-64);
|
||||||
// Hang up as soon as the answer is complete: devdesk waits for that
|
// Hang up as soon as the answer is complete: devdesk waits for that
|
||||||
// before exiting, so ttyd can't drop its last output (devdesk#35).
|
// before exiting, so ttyd can't drop its last output (devdesk#35).
|
||||||
if (isComplete(new TextDecoder().decode(concat(chunks)))) {
|
// Replies are one JSON line (or a devdesk: error), so only a chunk that
|
||||||
|
// ends a line with "}" is worth a full parse; big replies stay linear.
|
||||||
|
if (/^devdesk: .*\n/m.test(head) || (/\}\s*\n\s*$/.test(tail) && isComplete(new TextDecoder().decode(concat(chunks))))) {
|
||||||
finish();
|
finish();
|
||||||
socket.close();
|
socket.close();
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -406,7 +406,7 @@ export class FilesPane {
|
|||||||
|
|
||||||
async loadFileList() {
|
async loadFileList() {
|
||||||
if (this.fileList && Date.now() - this.fileList.at < 60000 && this.fileList.session === this.session) return this.fileList.items;
|
if (this.fileList && Date.now() - this.fileList.at < 60000 && this.fileList.session === this.session) return this.fileList.items;
|
||||||
const list = parseListOutput(await runCommand(['--fs-find', this.session], 20000));
|
const list = parseListOutput(await runCommand(['--fs-find', this.session], 45000));
|
||||||
const items = list.files.map((path) => {
|
const items = list.files.map((path) => {
|
||||||
const name = base(path);
|
const name = base(path);
|
||||||
const [icon] = iconFor(name);
|
const [icon] = iconFor(name);
|
||||||
|
|||||||
Reference in New Issue
Block a user