Files
tty7/docs
f6fa34a016 feat(ui): make the sidebar diff preview optional and bound its cost on large working trees (#247)
* feat(diff): make the sidebar diff preview optional and bound its cost

Clicking a sidebar row's `+N −N` opens the working-tree diff overlay. On a big
tree that could stall the window, and not everyone wants an in-app diff viewer
in the first place.

Two halves, matching the report.

The setting: `sidebar_diff_preview` (Settings → Window & Tabs, on by default,
persisted in `config.json`). Off, the branch and the counts stay exactly where
they are and read exactly the same; they lose only the pointer cursor and the
`toggle_diff_overlay` handler, so the press falls through to ordinary tab
activation. Both come off one value — `diff_click_cwd` — so they cannot get out
of step.

The performance work. All five of the reporter's hypotheses held up against
v26.7.6, and each fix is measured on a 300-file / 90 000-line / 4.5 MB diff
(release, macOS arm64):

1. The full diff was buffered before parsing — `git_status::git` uses
   `Command::output()`. Now streamed line by line through the new
   `git_status::git_lines` into an incremental `DiffParser`: peak transient
   buffer 4 552 060 bytes → 50 bytes, at ~1.7× the parse CPU (3.97 ms →
   6.76 ms) on the background thread, where it never touches a frame.

2. The snapshot was deep-cloned per holder inside `this.update`, i.e. on the
   UI thread. Now shared behind `Arc`: 2.41 ms → 11 ns per holder.

3. The element tree is not virtualized — confirmed, not cured. Rendering is not
   being redesigned here; instead the element count is bounded (see 4) and
   `MAX_RENDERED_FILES` caps the cards built at all, with a "… and N more" line
   for the tail.

4. Auto-collapse was per file, and counted only +/− while the rendered body
   also has context lines. Added `AUTO_COLLAPSE_TOTAL_LINES` over *retained*
   lines: sixty forty-line files, none individually large, went from 2400
   side-by-side rows to zero, under a summary saying the diff is too large to
   render efficiently and pointing at expanding individual files or `git diff`.

5. The Changes panel probed independently and kept its own snapshot. Both now
   go through `spawn_shared_diff_probe`, which dedupes by cwd and installs one
   `Arc` into every watcher; opening the overlay while the panel already shows
   that repo now paints from the panel's snapshot instead of re-probing.

Plus a repo-wide retention budget (`MAX_TOTAL_LINES`, `MAX_FILES_WITH_HUNKS`):
90 000 lines / 6.2 MiB of line text → 20 000 / 1.2 MiB. The `+N −N` totals
deliberately escape every cap — they are compared against `--numstat` to detect
staleness, so a capped total would disagree forever and re-probe in a loop.

Small diffs are untouched: a forty-file, twelve-lines-each tree is not
oversized and still opens expanded, asserted directly.

Not verified: anything requiring the GUI. No frame timings, no visual check of
the oversized banner or the settings row, and `AUTO_COLLAPSE_TOTAL_LINES` is a
judgement call anchored on row count rather than a measured frame budget.

Refs #239.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(diff): bound the untracked list and stop a moving default flipping toggles

Five findings from the review of the previous commit, all confirmed against the
source before acting.

The untracked list escaped every bound the previous commit added. `git ls-files
--others` reports the whole tree of anything not yet ignored, so a fresh clone
before `node_modules` / `target` / `.venv` reach `.gitignore` answers with tens
of thousands of paths — read through the buffering helper, retained without a
cap, ignored by `oversized()`, and rendered one non-virtualized row each. That
reaches the overlay without going through the diff at all, which is why the diff
budget never saw it. It is now streamed through `git_lines`, capped at
`MAX_UNTRACKED`, counted toward the oversized threshold, and rendered at most
`MAX_RENDERED_FILES` rows with a "… and N more" tail. The reported count stays
the true total via `untracked_total` — same split the diff side already makes
between what is retained and what is counted, because a count that shrank with
the budget would read as files having disappeared.

The overlay's expand state was an inversion set — "files flipped away from their
default" — which was correct while the default was per-file and stable. The
repo-wide `collapse_all` moves the default for every file at once, so any
refresh crossing the oversized threshold inverted every explicit choice
simultaneously: the two files the user had opened snapped shut and the rest
sprang open. It now stores absolute intent (`HashMap<String, bool>`), answered
before the default is even computed, so nothing about the snapshot can reach it.
Covered by a test that asserts an explicitly opened and an explicitly closed
file both survive a transition in either direction while an untouched file
follows the default.

The Changes panel dropped freshly landed snapshots. `install_diff_snapshot` only
wrote `right_panel.diff` when the panel was the one waiting, so a probe the
overlay started was discarded even when the panel sat on that exact repo — the
overlay rendered the new snapshot while the panel kept the old one, same window,
same repo. The wait (`diff_pending`) and the data (`diff_cwd`) are now claimed
separately: with probes deduped per cwd there is at most one in flight, so there
is no out-of-order overwrite to guard against.

The oversized banner reported `retained_lines()` as "diff lines", which after
the budget fires is what was kept, not what changed — it read "20000 diff lines"
directly under a header showing the exact +90000/-0. It now states
loaded-of-total and names which cap ate the difference, and lists each axis that
tripped the threshold so a big untracked list never reads as a claim that the
diff is big.

And the Changes panel deep-copied every untracked path String on every frame, on
the UI thread, for two `len()`/`is_empty()` reads — the same cost class the
`Arc` switch removed from the probe path.

995 tests pass, fmt clean, no new clippy warnings. The app was not built,
launched, or driven; visual acceptance is the owner's.

Refs #239.

* no-mistakes(review): cap Changes panel rows and fix oversized banner truncation notice

* no-mistakes(review): drive banner per-file truncation off parser flags

* no-mistakes(document): correct changelog cost count and overlay-trigger docs

* fix(diff): reconcile the diff-overlay work with the host-aware git refactor

The rebase onto main lands this change on top of the remote-workspace work
(#235, #242), which moved the git helpers into `tty7-core` and made every read
take the pane's `Host`. Adapting rather than papering over:

- `sidebar_diff_preview` moves to the core `Config`, where the struct now lives.
- The diff and untracked reads go through `git_status::git(host, cwd, ..)`.
- The shared probe, its in-flight set, and `install_diff_snapshot` key on
  (`HostId`, `PathBuf`) — the same path on two machines is two work trees — as
  does the Changes panel's `diff_pending`.
- `diff_click_cwd` became generic over what identifies a repo, so the setting
  gate did not need to learn about hosts.
- Main's newer card rounding reads `truncated`, which is an `Option` here now.

The streaming diff read is deliberately absent at this commit: `Host::git` is
buffered, so it is restored on top of a streaming host API in the next one.

Refs #239.

* feat(host): stream git reads whose size scales with the work tree

`Host::git` returns a fully-buffered `Output`, and the remote implementation
round-trips one over the wire. That is the right shape for the reads tty7 does
constantly — `rev-parse`, `symbolic-ref`, `--numstat` — all of which answer in
bytes. It is the wrong shape for `git diff HEAD`, whose output scales with the
work tree rather than with anything the UI can show: this repository's own
`git log -p -n 400` is 8.3 MB, and the diff overlay keeps a small fraction of it.

So `Host` grows a second entry point rather than changing the first. `git_lines`
delivers the same invocation a line at a time, and its **default implementation
buffers** — every host gets it for free, nothing that works today changes shape,
and it stays inside the "every git read funnels through the host" invariant
instead of becoming a way around it. Overriding it is an optimisation, never a
behaviour change: a test asserts the streamed and buffered reads yield identical
lines.

The local host reads straight off the pipe. The remote host adds
`ControlRequest::GitStream`, answered with `ControlEvent::GitChunk` pushes and a
terminating `GitEnd` carrying the exit status — the shape `WatchOpen` already
proved out.

Two protocol details worth the reader's attention:

The **client** picks the stream id, which is why the reply carries none. Ids
only need to be unique within a connection and a connection has one client, so
choosing it client-side lets the receiver be registered *before* the request
goes out. A server-assigned id arrives in the reply, leaving a window where a
chunk that overtook it reaches a client with no entry for that id and is dropped
under the unknown-id rule — silently losing the front of the diff. `WatchOpen`
needs a whole deferred-start mechanism to close that window; this does not.

The feature is **advertised and checked**, not assumed. A server predating
`GitStream` cannot decode the variant, and an undecodable frame ends the
connection — so sending it blind would not degrade, it would disconnect. Servers
advertise `git-stream`; a client that does not see it uses the buffered `Git`,
which is the path every remote pane used before this existed. Covered by a test
against a peer advertising only `control` and `host-rpc`.

Chunks carry newline-terminated line data batched to ~64 KiB, not verbatim
slices of stdout: the server reads through `git_lines` itself, so line content
survives exactly while `\r\n` and a missing final terminator are normalised
away. The only consumer is line-oriented. Framing per batch rather than per line
is what keeps a 90 000-line diff from becoming 90 000 frames.

Re-measured on the rebased code, against real git output, release build
(the previous figure was taken before the host refactor and no longer holds):

  buffered: 8 269 409 bytes resident, read 817 ms + parse 12 ms
  streamed: peak transient chunk 64 KiB, read+parse 557 ms

Lower peak memory *and* faster end to end — parsing now overlaps with git
producing output instead of waiting for all of it. The earlier synthetic
measurement showed streaming costing ~1.7x CPU; that was an artifact of reading
a warm page-cached file, where there was nothing to overlap with.

The app was not built, launched, or driven; visual acceptance is the owner's.

Refs #239.

* fix(host): make unsubscribing a watch take effect at the drop, not after it

CI's Windows job failed `watch_drop_unsubscribes`: an event for a file created
*after* the subscription was dropped still reached a consumer holding a clone of
the receiver.

Tearing the watcher down is not instantaneous. The OS backend runs its own
thread, and on Windows a `ReadDirectoryChangesW` completion can fire during
teardown, reach the event closure while `raw_tx` is still alive, and be
forwarded by a coalescer that has not yet noticed the disconnect. So "dropped"
meant "stops delivering shortly", which is not what the subscription promises —
and for a remote host it is the difference between releasing a server-side watch
and leaking one.

The handle now closes the delivery channel in its own `Drop`, before any of that
unwinds. Batches already queued stay readable — `close` stops sends, not
receives — which is the one thing a consumer racing its own drop may legitimately
still see, and exactly what the conformance test allows for.

Not this branch's bug: the change here is to git reads, not watches. But main is
flaky in the same family — it failed the sibling `watch_coalesces_within_window`
eleven hours ago and was hardened for that one — so this fixes the cause rather
than loosening the test.

Refs #239.

* refactor(host): make the streaming git read part of the protocol

Remote workspaces have never shipped a release, so there is no deployed server
to negotiate with. The `git-stream` feature flag, the `has_feature` check and
the buffered fallback behind it were all guarding against a peer that cannot
exist — dead code that would have to be maintained, and read by the next person
as evidence that older servers are out there.

`ControlRequest::GitStream` is simply part of the control protocol now. Buffered
`Host::git` stays exactly as it was, for the many reads that answer in bytes and
have no reason to stream.

The remote test that proved the fallback becomes one that proves the stream:
the peer serves `GitStream`, splits a line across two chunks, and the client
reassembles it — the case the reassembly exists for.

Refs #239.

* no-mistakes(review): fix remote git-stream deadlock, chunk encoding and stray docs

* no-mistakes(review): stop git-stream batch growing after a send failure

* no-mistakes(document): note git-stream protocol delta in remote-workspace contract doc

* fix(host): bound a git stream's wait, its lines, and its concurrency

Three ways the streaming git read could still hold or hang more than it
should, all found reviewing #239's implementation.

A stream is answered by pushes, so neither of the failure paths the rest
of the client relies on covers it: the request deadline was satisfied by
the immediate `Unit` reply, and keepalive watches the link, which stays
up while a server-side git wedges on a network filesystem. The reader
parked forever, on one of a small pool of blocking threads, and the diff
probe it belonged to never released its per-repo claim — so that
repository's overlay and Changes panel were stuck on "Loading…" for the
life of the process. `git_lines` now waits `GIT_STREAM_IDLE_TIMEOUT`
between chunks. Between, not across: a slow-but-alive read must be
allowed to take as long as it takes, which is why a total deadline would
be the wrong instrument. Draining moved to `drain_git_stream` so all
three exits are reachable from a test without waiting out two minutes.

"Incremental" bounded the number of allocations but not the size of any
one of them: a line is only complete at its newline, so a work tree with
a minified bundle rebuilt the whole-output peak inside `LineSplitter`,
on both ends of a remote link and in the server's outgoing batch. Lines
are now capped at `MAX_LINE`, and what is cut says so in the line itself
rather than silently shortening a rendered diff. That also bounds the
server batch, which makes `GIT_STREAM_CHUNK_MAX` a frame backstop rather
than the only thing standing between a bundle and a 32 MiB payload.

Finally, `GitStream` is the one request that spawns a thread outside the
bounded worker pool, so nothing counted them.  `MAX_CONCURRENT_GIT_STREAMS`
per connection now does, with the slot returned by a guard so a refusal,
a failed spawn and a panicking read all give it back — a leaked slot
would be a permanent refusal, not a transient one.

* fix(diff): stop the overlay re-walking the tree per frame, and re-probe a folded-in refresh

Two things the shared-probe work left on the render path.

The overlay asks six whole-snapshot questions while building its element
tree — oversized, totals, retained lines, budget fired, per-file cap
fired, untracked count — and each accessor walked `files` on its own,
`oversized` walking the hunks too. `files` is deliberately uncapped (only
hunks are), so that was six walks over a list whose length is the size of
the working tree, on the UI thread, on exactly the tree this module
exists to keep responsive. `DiffSnapshot::stats` answers all six in one
pass and the per-question accessors are gone, so nothing can drift from
it. Computed rather than stored, because the snapshot is built by hand
with `..Default::default()` throughout the tests and a cached count would
read as zero for every one of them.

Deduping probes per repository is what makes one `git diff` answer every
watcher, but a probe describes the tree as it was when it *started*. A
refresh triggered after that — a command finished, an agent turn ended —
folded into the running probe and was answered with a snapshot already
known to be stale, with nothing left to trigger another look: the
overlay's own re-check is gated on `loading`, which the landing clears,
and the `GitStatusCache` change that would have re-armed it has been
spent. A folded-in request is now remembered and re-issued when that
probe lands. It converges rather than loops, because a quiet tree never
sets the flag.

* fix(diff): bound the stream queue, and stop two thresholds answering the wrong question

Review follow-ups on the sidebar-diff branch. Five findings, four of them
about a bound that was claimed but not held.

The remote streaming read bounded both ends and not the middle. The reader
thread serves the whole connection, so it cannot wait on a slow consumer —
parking it there stalls every other reply and the keepalive with it — and an
unbounded queue was the price. That reassembles the whole diff in a channel,
which is the peak the buffered read was replaced to avoid, one container
further along. The queue is now bounded instead of back-pressured: each chunk
is charged to the stream's arrears, the drainer credits them back, and a
stream 32 MiB behind is cut loose with an error rather than served. Real
back-pressure would need credit-based flow control in the dialect; this is
not that, and says so.

`oversized` counted untracked paths on its file axis, and collapsing every
file body removes no untracked rows — that section has no bodies to fold. A
tree with an un-ignored node_modules and three edited files hid the three
cheap things, kept the expensive one, and told the reader their working tree
was too large to render. The untracked list is bounded where it is built:
MAX_UNTRACKED on retention, MAX_RENDERED_FILES on rows.

AUTO_COLLAPSE_TOTAL_LINES counted the context lines git prints around every
hunk — four to six retained per line actually changed — against a threshold
set as if it were reading `+N -N`. It fired on trees whose header said 400.
8000, compared against the 20000 the parser stops retaining at, since
collapsing everything is the heavier of the two interventions and should not
arrive first by much.

A probe that could not run produced an empty file list, which renders as
"Working tree clean" — a claim about the repository, made because a read
timed out. Newly reachable, too: a stream can be refused or go silent where a
buffered read could only arrive or error. DiffSnapshot::read_failed keeps the
two apart.

Also: StreamStop's doc comment had been glued onto StreamSlot, leaving the
enum undocumented and the guard described twice; the overlay header asked
totals() beside stats() rather than through it; and the watch-teardown fix
riding along on this branch was in neither the PR body nor the CHANGELOG.

Tests: the queue budget both ways (a stream that outruns it is cut loose, a
larger one that is drained is not), the untracked axis, an ordinary
context-heavy afternoon sized to fail against the old threshold and pass
against the new, and empty-because-broken against empty-because-clean. Each
was checked to fail against the behaviour it replaces. 1603 pass, 0 fail;
fmt clean; no new clippy warnings in the touched files.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:26:55 +08:00
..