Files
tty7/.github/scripts/check-host-boundary.sh
T
l0ng-ai bf5149bea0 fix(editor): stop losing edits, share buffers, add file strip, quick open and go to line (#984)
* fix(host): save local files atomically via a temp file and rename

LocalHost::write_file truncated the target in place, so a crash, a full
disk or a killed process mid-save destroyed the user's file. It now
writes a hidden sibling temp file, syncs it, keeps the old file's mode
and renames it over the target, removing the temp file on any error.

It still writes in place where a rename would change something visible:
a non-regular target (symlink, directory, FIFO), a read-only file, and on
Unix a hard-linked file or one owned by another user, or when the temp
file cannot be created (e.g. a read-only directory).

* feat(editor): add editor_text for encodings, line endings, indentation and EditorConfig

A pure module the code editor will use when loading and saving files:
decode detects BOMs, binary files, UTF-8, GB18030 and a lossless
Windows-1252 fallback and normalises CRLF; encode restores the exact
bytes and names the first unrepresentable character; detect_indent
infers tabs or a 2/4/8 space width with language defaults; and
editorconfig_for resolves .editorconfig sections with save-time rules.

* feat(editor): share buffers across tabs, guard unsaved work, add a file strip

- One buffer per file per window; tabs list which buffers they show. The
  same file open in two tabs is no longer two diverging copies.
- Closing a tab, its last pane, the window, or quitting asks about unsaved
  files (Save / Cancel / Discard) instead of dropping them. Bulk closes skip
  tabs with unsaved files; a tab that vanishes any other way hands its
  unsaved buffers to the tab in front.
- File tree rename/delete now retarget or flag the open buffer, so a save
  no longer recreates the old path.
- Saves check the file's mtime first and ask before overwriting a change
  made elsewhere; this is the only detection SFTP buffers get.
- Dirty is a comparison with the saved text, so undoing back clears it.
- Reloads replace only the changed span as an ordinary edit, keeping undo.
- Load/save go through editor_text: encoding, BOM and CRLF round-trip,
  indentation is detected, .editorconfig is honoured.
- Header shows a strip of open files; New File, Save As (native panel
  locally, a path bar remotely), Go to Line (Ctrl+G), and the status bar
  shows indentation, encoding and a clickable line ending.
- Open files are remembered per tab across restarts.

* feat(search): quick open a file by name from a Files tab

Search Everywhere gains a Files tab that finds any file in the active
tab's project by fuzzy name and opens it in the built-in editor, with
`name:line[:col]` jumping to that spot. The list comes from one walk of
the project through the host (Host::search with an empty query), so it
works the same on local, SSH and WSL workspaces and skips what the tree
hides: dotfiles, .git and gitignored paths. The walk is capped at 50k
entries / 20k directories, kept between openings and revalidated in the
background each time the search opens.

Files join the All tab once a query finds them. Go to File... is bound to
Cmd+O on macOS (Cmd+P is already Search Everywhere) and ships unbound
elsewhere, where every obvious chord is taken or owed to the shell.

* chore(editor): allowlist the editor session file, tidy lints

* fix(editor): keep restored file order, drop stale close waits, carry files through tab merges

- Background arrivals (restore, merge, rescue) append to the strip in order
  instead of inserting beside the active file, which reversed them.
- A cancelled Save As, a dismissed path bar, or a dropped buffer cancels any
  close that was waiting on that save.
- Merging a tab into another carries its open files along.
- A shell exiting closes its tab without a prompt it could not honour;
  unsaved buffers move to the tab in front.
- Tabs rebuilt under the same id (server restart) restore their files.

* fix(host): only fall back to an in-place write when the rename is refused

On Windows every failure of the atomic save fell back to fs::write,
including a failure while staging the temp file. A full disk would then
truncate the original in place, the very loss the temp file prevents.
Staging errors now return as-is; only a refused rename (a file held open
elsewhere) takes the in-place path.
2026-09-28 00:32:18 +08:00

193 lines
7.9 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# The GUI must not touch the filesystem or git directly.
#
# Once a workspace can live on a remote machine, a path held by `ui::` or
# `terminal::` is not necessarily a path on *this* box, and `std::path`'s
# fs-backed APIs quietly answer for the wrong machine:
# `canonicalize` walks the local filesystem, `is_absolute` says `false` for
# `/home/me` on Windows, `read_dir` lists the client's disk. Everything that may
# be looking at a workspace path has to go through `ui::host_ops` / the `Host`
# trait, which routes to the local disk or the far side as appropriate.
#
# This script enforces that. It is deliberately *not* the raw grep from the
# contract: run bare, that grep reports 42 hits on a clean tree — not one of them
# git, all of them modules whose paths are local by construction — and a guard
# that always fails is a guard everyone learns to ignore. Two things fix it:
#
# 1. Test bodies are cut off properly. The contract's `grep -v '#\[cfg(test)\]'`
# only removes the attribute line itself, leaving the whole test module
# behind it in scope; here the scan stops at the `#[cfg(test)] mod …` that
# opens the trailing test region.
# 2. An explicit allowlist, keyed on (file, pattern) rather than whole files, so
# a module exempted for its `.is_absolute()` still trips on a new
# `std::fs::`. Every entry carries the reason its paths cannot be remote.
#
# Adding an entry is a deliberate act: if the path could ever be a workspace
# path, the answer is `Host`, not an allowlist line.
#
# Usage: bash .github/scripts/check-host-boundary.sh
# Exit 0 = clean, 1 = violations (printed to stderr).
set -euo pipefail
cd "$(dirname "$0")/../.."
ROOTS=(src/ui src/terminal)
# The forbidden constructs, as extended-regex alternatives.
PATTERN='std::fs::|Command::new\("git"\)|\.canonicalize\(\)|\.is_absolute\(\)'
# Allowlist entries are `<path>|<literal pattern>`, one per line. `<path>|*`
# exempts a file wholesale (used only for the boundary module itself). The
# pattern side is matched as a plain substring of the offending line.
ALLOW=$(
cat <<'EOF'
# The host boundary itself: every routed filesystem call lands here by design.
src/ui/host_ops.rs|*
# Themes and presets are app-owned files under the local config dir
# (`themes_dir()`), never a workspace path — a remote workspace does not carry
# the user's color schemes with it.
src/ui/presets.rs|std::fs::
src/ui/presets.rs|.is_absolute()
src/ui/app.rs|std::fs::create_dir_all
# The editor's record of which files each tab had open is app state under the
# local config dir, like the themes above — not a file in any workspace.
src/ui/editor_session.rs|std::fs::read
# Reading a private key off *this* machine to hash it into a keychain account
# (`core::keychain`). The key is the client's credential; the far side never
# sees the file, only the resulting auth.
src/ui/ssh_prompt.rs|std::fs::read
src/ui/ssh_connect.rs|std::fs::read
src/ui/settings.rs|std::fs::read
# The host editor asking which of those keys is on this machine before it offers
# a passphrase box for one — the same client-side key, never a workspace path.
src/ui/settings.rs|std::fs::metadata
# Shell history lives in the local user's home (`~/.zsh_history` &co.) and backs
# this app's own history search. A remote pane's history is the remote shell's
# business, read over the wire, not through here.
src/terminal/history.rs|std::fs::
# Completion is already remote-aware: a remote pane is signalled by `cwd: None`,
# which disables exactly these local-filesystem candidate sources in favour of
# `remote_path_request` / `remote_path_candidates`. The `$PATH` scan is likewise
# this machine's `$PATH`, deliberately withheld from remote panes.
src/terminal/completion.rs|std::fs::read_dir
src/terminal/completion.rs|.is_absolute()
# Bundled completion specs shipped in `assets/completions`, resolved from the
# app bundle / dev manifest dir. Program data, not user or workspace data.
src/terminal/signature.rs|std::fs::read_to_string
# Opening a path scraped out of terminal output resolves it against the local
# cwd — a local-pane affordance; `resolve_existing_path` declines when there is
# no local cwd.
src/terminal/search.rs|.is_absolute()
# Clipboard-image paste stages the bytes into the OS temp dir so an agent TUI
# can be handed a path. Always `std::env::temp_dir()` on this machine.
src/terminal/view.rs|std::fs::create_dir_all
src/terminal/view.rs|std::fs::write
# Asking whether a file would be *launched* rather than shown before handing it
# to the desktop opener. Only reachable behind `host_id.is_local()` — a path on
# another machine takes the earlier arm and never gets here.
src/ui/code_editor.rs|std::fs::metadata
# The source side of a file drop. What the desktop hands over is by
# construction a path on the desktop's own machine, so reading it is a local
# read even when the tree being dropped on is remote — the destination side of
# that copy goes through `Host`, and the one `std::fs::copy` that touches a
# destination sits inside a branch already gated on `host.id().is_local()`.
src/ui/file_copy.rs|std::fs::
EOF
)
# Where the trailing `#[cfg(test)] mod …` starts, if any. Line numbers are
# preserved because only the tail is dropped.
body_of() {
local file=$1 cut
# `attr` starts unset, which awk reads as 0 — so a file whose *first* line
# is `mod something` used to match `attr == NR - 1` and cut the body at
# line 0, leaving `head -n -1` to error out and the file to be scanned as
# empty. Two files opened that way, and the guard was blind to both.
cut=$(awk '
BEGIN { attr = -1 }
/^#\[cfg\(test\)\]$/ { attr = NR }
/^mod [A-Za-z_]/ { if (attr == NR - 1) { print NR - 1; exit } }
' "$file")
if [ -n "$cut" ]; then
head -n "$((cut - 1))" "$file"
else
cat "$file"
fi
}
allowed() {
local file=$1 line=$2 entry path pat
while IFS= read -r entry; do
case "$entry" in '' | '#'*) continue ;; esac
path=${entry%%|*}
pat=${entry#*|}
[ "$path" = "$file" ] || continue
if [ "$pat" = '*' ] || [ "${line#*"$pat"}" != "$line" ]; then
return 0
fi
done <<<"$ALLOW"
return 1
}
# A guard that scans nothing reports success, which is the one failure mode worse
# than a noisy guard. Fail loudly if a root has moved out from under us.
for root in "${ROOTS[@]}"; do
if [ ! -d "$root" ]; then
echo "check-host-boundary: scan root '$root' does not exist (moved? renamed?)" >&2
exit 2
fi
done
violations=0
scanned=0
while IFS= read -r file; do
scanned=$((scanned + 1))
while IFS= read -r hit; do
lineno=${hit%%:*}
text=${hit#*:}
# Prose, not code.
case "$(printf '%s' "$text" | sed 's/^[[:space:]]*//')" in '//'*) continue ;; esac
if allowed "$file" "$text"; then
continue
fi
printf '%s:%s:%s\n' "$file" "$lineno" "$text" >&2
violations=$((violations + 1))
done < <(body_of "$file" | grep -nE "$PATTERN" || true)
done < <(find "${ROOTS[@]}" -name '*.rs' | sort)
if [ "$scanned" -lt 10 ]; then
echo "check-host-boundary: only $scanned files scanned — the roots look wrong" >&2
exit 2
fi
if [ "$violations" -ne 0 ]; then
cat >&2 <<'EOF'
--------------------------------------------------------------------------------
The GUI reached the filesystem/git directly.
A path in `ui::` or `terminal::` may belong to a remote workspace, where these
calls answer for the wrong machine. Route it through `ui::host_ops` / `Host`
instead (`Host::read_dir`, `join`, `is_absolute`, `canonicalize`, …).
If the path genuinely cannot be remote — app config, bundled assets, the local
temp dir — add it to the allowlist in this script with the reason why.
--------------------------------------------------------------------------------
EOF
exit 1
fi
echo "host boundary clean: $scanned files in ${ROOTS[*]}, no direct fs/git calls"