From e94066af68c60d05b0ac8da5249ce09094be85f5 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Thu, 17 Sep 2026 11:22:02 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Every=20key=20paints=20within=20a=20frame:?= =?UTF-8?q?=20a=20typed=20character=20echoes=20in=202=20ms,=20session=20sw?= =?UTF-8?q?itches=20are=20instant,=20and=20git-backed=20views=20never=20fr?= =?UTF-8?q?eeze=20the=20UI=20=E2=80=94=20measured,=20with=20memory=20flat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The INPUT LATENCY PROBE (`NEBULA_PERF_LOG`) and `make perf`: the real TUI driven through 123 scripted steps in a private tmux, printing per step how long the key's handler held the loop, how long the key waited for its frame, how long the screen took to settle, the echo time in a locked pane, and the peak RSS of the TUI and the DAEMON. `PERF_REPO=` measures against a large checkout; `report.py BEFORE AFTER` compares two runs. - FRAME PACING is a token bucket, not a 16 ms tick: a key's frame and its answer's follow 2 ms apart, sustained PTY output still paints at 60 fps. A key that only goes to the PTY paints no frame of its own, and the DAEMON flushes PTY output that breaks a silence at once instead of holding it 5 ms. Echo in a locked pane: 19.8 → 2.3 ms release, 24.9 → 3.5 ms dev. - BACKGROUND READS (`view_jobs.rs`): the DIFF VIEWER, the FILE FINDER, its grep view, the TREE BROWSER and the FILE TABS open on the keypress and fill in when git and the disk answer; what is typed meanwhile is kept. `git grep` is debounced, streamed and killed at the result cap. `open`, `pbcopy` and the footer's `ps` no longer run on the loop. On a ten-thousand-file checkout `g` went from 262 ms of frozen UI to a list in 1.4 ms, and grep from 370 ms frozen per keystroke to none. - The DIFF VIEWER opens on the changed-files badge's last `git status`, reads the row after the cursor ahead and keeps up to 2 MB of diffs while it is open; only the visible lines are styled. - OPTIMISTIC UPDATES (`event_loop/optimistic.rs`): rename, archive, unarchive, delete and close-terminal change the row on the keypress by way of the event the DAEMON is about to broadcast; an Error puts it back. - The pane's screen cache keeps six screens inside half the memory two used to be allowed: a screen over budget lets its scrollback go, and scrolling up replays the ring once. Session, worktree and workspace switches settle in 1.3–1.7 ms where they took 19–22. - `nebula-fuzzy`: the list filters' matcher in a crate of its own so dev builds optimise it, without per-candidate allocations — 27 → 5 ms a keystroke over ten thousand paths in the build `make dev` runs. - `SendCloudMessage` runs off the DAEMON's request loop. Two e2e flows follow the NEW SESSION PICKER into its task box (they failed on main). The shot harness can slow any git subcommand, with scenes for the two new loading states. Peak RSS over the scenario: TUI 55.6 → 54.0 MB, DAEMON 21.7 → 19.7 MB. Co-Authored-By: Claude Fable 5.1 --- ARCHITECTURE.md | 3 + Cargo.lock | 5 + Cargo.toml | 12 +- Makefile | 11 +- crates/nebula-core/src/mem.rs | 5 +- crates/nebula-daemon/src/pty/mod.rs | 58 +- crates/nebula-daemon/src/server.rs | 22 +- crates/nebula-fuzzy/Cargo.toml | 10 + crates/nebula-fuzzy/src/lib.rs | 427 +++++++++ crates/nebula-tui/Cargo.toml | 1 + crates/nebula-tui/src/app.rs | 326 ++++++- crates/nebula-tui/src/event_loop.rs | 858 +++++++++++++++--- crates/nebula-tui/src/event_loop/activate.rs | 9 +- .../nebula-tui/src/event_loop/optimistic.rs | 450 +++++++++ crates/nebula-tui/src/event_loop/pacing.rs | 182 ++++ crates/nebula-tui/src/file_tabs.rs | 111 ++- crates/nebula-tui/src/fuzzy.rs | 306 +------ crates/nebula-tui/src/git_diff.rs | 273 +++++- crates/nebula-tui/src/grep_search.rs | 85 +- crates/nebula-tui/src/lib.rs | 2 + crates/nebula-tui/src/perf.rs | 214 +++++ crates/nebula-tui/src/tree_browser.rs | 180 +++- crates/nebula-tui/src/ui.rs | 37 +- crates/nebula-tui/src/view_jobs.rs | 166 ++++ crates/nebula/tests/e2e_tui.rs | 17 +- docs/how-it-works.md | 35 +- scripts/perf/report.py | 148 +++ scripts/perf/run.sh | 123 +++ scripts/perf/scenario.steps | 127 +++ scripts/shot/bin/git | 6 +- scripts/shot/scenes/diff-reading.keys | 1 + scripts/shot/scenes/diff-reading.setup.sh | 5 + scripts/shot/scenes/finder-listing.keys | 2 + scripts/shot/scenes/finder-listing.setup.sh | 4 + vendor/vt100/src/grid.rs | 14 + vendor/vt100/src/screen.rs | 15 + 36 files changed, 3680 insertions(+), 570 deletions(-) create mode 100644 crates/nebula-fuzzy/Cargo.toml create mode 100644 crates/nebula-fuzzy/src/lib.rs create mode 100644 crates/nebula-tui/src/event_loop/optimistic.rs create mode 100644 crates/nebula-tui/src/event_loop/pacing.rs create mode 100644 crates/nebula-tui/src/perf.rs create mode 100644 crates/nebula-tui/src/view_jobs.rs create mode 100644 scripts/perf/report.py create mode 100755 scripts/perf/run.sh create mode 100644 scripts/perf/scenario.steps create mode 100644 scripts/shot/scenes/diff-reading.keys create mode 100644 scripts/shot/scenes/diff-reading.setup.sh create mode 100644 scripts/shot/scenes/finder-listing.keys create mode 100644 scripts/shot/scenes/finder-listing.setup.sh diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8062b7c4..6d3ee1fa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,9 +83,12 @@ The ISSUES MODAL (`i`) is the same idea for GitHub issues: the TUI lists the sel | `nebula-core` | Shared protocol, entities, IDs, paths, codec | | `nebula-daemon` | PTYs, SQLite, git, hook receiver, status engine | | `nebula-tui` | ratatui UI, keyboard/mouse, attach/scrollback | +| `nebula-fuzzy` | The list filters' matcher — dependency-free, and a crate of its own only so dev builds optimise it (`[profile.dev.package.nebula-fuzzy]`) | The TUI also has extras on top of the multiplexer: git diff viewer, grep, the root checkout's branch switcher, a vim-like terminal overlay, fuzzy finders — those are client-side. The daemon is the source of truth for sessions and the tree. **Input is not action.** In `nebula-tui` a key arm and a mouse arm only translate: they say *which row* (`list_hit::row_at` for the pointer) and then call the one function that says *what choosing it does* — `event_loop::activate` for the panels and the modals `event_loop` owns, a modal's own `activate_selected` or `Cmd` executor (`run_settings_cmd`, `file_tabs::run`, `preset_overlays::activate_selected`) for the rest, `select_clicked_row` / `select_*_row` for moving a cursor, `context_menu_items` for `m` and the right button alike. Nothing that closes a modal, sends a request, spawns a process or moves a cursor lives inline in `handle_mouse` or in a key arm beside a twin that does the same. The rule exists because its absence shipped bugs: a click in a pull request's preset picker launched a plain session into the ROOT WORKTREE while Enter launched the PR SESSION, a click in the file finder skipped the markdown reader Enter had learned, and a right-click moved the cursor without the pane. The `INPUT PARITY` tests in `event_loop.rs` build the same app twice, choose a row once by key and once by pointer, and compare everything observable. +**A key handler never blocks.** `nebula-tui`'s event loop is one task: while a handler runs nothing paints, no PTY output is parsed and no other key is read. So a handler does bookkeeping and nothing else — anything that spawns a process, reads a file of unknown size or waits on the network is a BACKGROUND READ (`view_jobs.rs`: the worktree views' git and disk, keyed by ticket so a late answer nobody is waiting for is dropped) or one of the per-feature channels `main_loop` owns (`gh`, the BRANCH SWITCHER, issues), and a view built without a handle — every unit test — reads inline through the same parsers. What the DAEMON will confirm is shown first (`event_loop/optimistic.rs`, `event_loop/placeholder.rs`) and rolled back on Error. `event_loop/pacing.rs` decides when the loop may paint. The INPUT LATENCY PROBE (`perf.rs`, `NEBULA_PERF_LOG`) and `make perf` are how a change to any of it is judged: handler, paint, settle and echo per scripted step, with peak RSS beside them, because holding more to feel faster is not a trade this codebase makes — the pane's screen cache got quicker by holding less. + **Mental model:** tmux, but the “windows” are agent CLIs bound to git worktrees, and the sidebar is a mission-control view of which agents are working, waiting, or dead. diff --git a/Cargo.lock b/Cargo.lock index 64adde08..b0193b02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1184,6 +1184,10 @@ dependencies = [ "vt100", ] +[[package]] +name = "nebula-fuzzy" +version = "0.29.0" + [[package]] name = "nebula-tui" version = "0.29.0" @@ -1193,6 +1197,7 @@ dependencies = [ "crossterm", "futures", "nebula-core", + "nebula-fuzzy", "portable-pty", "pulldown-cmark", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index c087773c..6930c14f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/nebula-core", "crates/nebula-daemon", "crates/nebula-tui", "crates/nebula"] +members = ["crates/nebula-core", "crates/nebula-daemon", "crates/nebula-fuzzy", "crates/nebula-tui", "crates/nebula"] exclude = ["vendor/vt100"] # Vendored vt100 with one patch: rows scrolled out of a top-anchored DECSTBM @@ -19,6 +19,7 @@ license = "MIT" nebula-core = { path = "crates/nebula-core" } nebula-daemon = { path = "crates/nebula-daemon" } nebula-tui = { path = "crates/nebula-tui" } +nebula-fuzzy = { path = "crates/nebula-fuzzy" } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -41,6 +42,15 @@ directories = "6" [profile.dev.package."*"] opt-level = 3 +# …and the one workspace crate that is a hot loop and nothing else: the +# fuzzy matcher runs over every path of a checkout on every keystroke of +# the FILE FINDER. Unoptimised that is 13–27 ms a character over ten +# thousand paths, felt as a filter that lags the typing; it is a few +# hundred lines that rarely change, so optimising it costs the edit-build +# loop nothing. +[profile.dev.package.nebula-fuzzy] +opt-level = 3 + [profile.release] lto = "thin" # Keep the symbol table (drop only debug info) so the crash-log panic diff --git a/Makefile b/Makefile index 9947baab..0b3f0936 100644 --- a/Makefile +++ b/Makefile @@ -52,7 +52,7 @@ DEV_ENV = NEBULA_RUNTIME_DIR=$(DEV_RUNTIME) NEBULA_DATA_DIR=$(DEV_DATA) \ $(if $(AGENT),NEBULA_AGENT_CMD=$(AGENT)) .DEFAULT_GOAL := help -.PHONY: help dev browser dev-prep dev-seed dev-reset dev-ls dev-stop build install kill prune cycle check fmt lint test ci clean shot +.PHONY: help dev browser dev-prep dev-seed dev-reset dev-ls dev-stop build install kill prune cycle check fmt lint test ci clean shot perf help: ## Show this help @grep -hE '^[a-z][a-z-]*:.*?## ' $(MAKEFILE_LIST) \ @@ -126,6 +126,15 @@ dev-reset: dev-stop ## Wipe this checkout's dev data; the next `make dev` re-see shot: ## Screenshot the debug TUI with demo data (SCENE=open-prs KEYS="…") scripts/shot/shot.sh $(SCENE) +# The LATENCY HARNESS: the same isolation as `make shot`, against a clone of this repository, with the +# INPUT LATENCY PROBE on (NEBULA_PERF_LOG). Drives scripts/perf/scenario.steps — every panel, modal and +# verb — and prints per step how long the key held the loop, how long it waited for its frame, how long +# the screen took to settle, and the TUI's and daemon's peak RSS. `make perf BIN=target/release/nebula` +# measures the release build; `python3 scripts/perf/report.py BEFORE AFTER` compares two runs. +perf: ## Measure input latency per action in the debug TUI (BIN=… OUT=… PERF_DUMP=1) + cargo build -q + scripts/perf/run.sh + # Slots accumulate: a worktree you deleted leaves its DB behind under # ~/.nebula-dev. This lists every one with its daemon's state, so you can see # what is still running and `rm -rf` what is not. diff --git a/crates/nebula-core/src/mem.rs b/crates/nebula-core/src/mem.rs index 95ebcbfb..13b2c49c 100644 --- a/crates/nebula-core/src/mem.rs +++ b/crates/nebula-core/src/mem.rs @@ -1,6 +1,7 @@ //! Small memory probes shared by the daemon and the TUI client. All of them -//! shell out (macOS has no /proc) and only run on the metrics modal's slow -//! poll, never on a hot path. +//! shell out (macOS has no /proc), so none of them belongs on an event +//! loop: the daemon runs its sweep on the blocking pool, and the TUI reads +//! its own RSS there too — the footer's readout asks every five seconds. /// Resident set size of one process, bytes. pub fn process_rss_bytes(pid: u32) -> Option { diff --git a/crates/nebula-daemon/src/pty/mod.rs b/crates/nebula-daemon/src/pty/mod.rs index c424127d..28b2860a 100644 --- a/crates/nebula-daemon/src/pty/mod.rs +++ b/crates/nebula-daemon/src/pty/mod.rs @@ -20,9 +20,12 @@ use tokio::sync::{broadcast, mpsc}; const RING_CAPACITY: usize = 1024 * 1024; /// Flush coalesced output at this size… const COALESCE_BYTES: usize = 8 * 1024; -/// …or this long after the first pending byte, whichever comes first. A hard +/// …or this long after the previous flush, whichever comes first. A hard /// deadline (not a quiet-gap timer): a child streaming continuously in small /// chunks must still flush on time, or output arrives in laggy 8KB lumps. +/// Counted from the last flush rather than from the first pending byte, so +/// output that breaks a silence — the echo of a typed character, a prompt +/// redrawn after Enter — is not held at all (`flush_deadline`). const COALESCE_HOLD: std::time::Duration = std::time::Duration::from_millis(5); /// Reader thread → pump channel bound; blocking_send gives natural /// backpressure against a fire-hosing child. @@ -521,6 +524,25 @@ fn spawn_reader_thread( .expect("spawn pty reader thread"); } +/// When output that arrived at `now` has to be on its way to the clients. +/// The hold exists to turn a stream of small writes into fewer, larger +/// events — so it is spent only while there is a stream: a flush less than +/// [`COALESCE_HOLD`] ago means more is likely right behind, and the bytes +/// wait out the rest of that window. After a quiet spell they go at once. +/// That is every keystroke's echo: held, it reached the TUI 5 ms late on +/// every character typed into a pane, which the INPUT LATENCY PROBE put at +/// half of what the key took to show. A stream still flushes at most once +/// per hold, exactly as before; the one extra event is at its head. +fn flush_deadline( + now: tokio::time::Instant, + last_flush: Option, +) -> tokio::time::Instant { + match last_flush { + Some(last) if now < last + COALESCE_HOLD => last + COALESCE_HOLD, + _ => now, + } +} + /// Drains the reader channel: append to the ring (always — detach is free), /// coalesce bursts, broadcast to whoever is attached. async fn pump(session: Arc, mut rx: mpsc::Receiver) { @@ -568,6 +590,7 @@ async fn pump(session: Arc, mut rx: mpsc::Receiver) { } }; + let mut last_flush: Option = None; 'outer: loop { if pending.is_empty() { match rx.recv().await { @@ -580,10 +603,14 @@ async fn pump(session: Arc, mut rx: mpsc::Receiver) { } } // Coalesce until the deadline or the size cap; the deadline is fixed - // at the first pending byte so continuous streams still flush on time. - let deadline = tokio::time::Instant::now() + COALESCE_HOLD; + // when the first pending byte arrives so continuous streams still + // flush on time. Biased toward the channel: a deadline that has + // already passed (the quiet-spell case) still takes along whatever + // the reader has queued, so one write read in two pieces is one event. + let deadline = flush_deadline(tokio::time::Instant::now(), last_flush); while pending.len() < COALESCE_BYTES { tokio::select! { + biased; msg = rx.recv() => match msg { Some(ReaderMsg::Data(d)) => pending.extend_from_slice(&d), Some(ReaderMsg::Eof { exit_code }) => { @@ -600,6 +627,7 @@ async fn pump(session: Arc, mut rx: mpsc::Receiver) { } } flush(&session, &mut pending); + last_flush = Some(tokio::time::Instant::now()); } tracing::info!(session = ?session.sref, "pty pump ended"); } @@ -609,6 +637,30 @@ mod tests { use super::*; use nebula_core::AgentId; + /// Output that breaks a silence is not held: a typed character's echo + /// leaves the DAEMON the moment it is read. + #[test] + fn output_after_a_quiet_spell_is_flushed_at_once() { + let now = tokio::time::Instant::now(); + assert_eq!(flush_deadline(now, None), now, "the session's first bytes"); + let long_ago = now - COALESCE_HOLD * 10; + assert_eq!(flush_deadline(now, Some(long_ago)), now); + assert_eq!(flush_deadline(now, Some(now - COALESCE_HOLD)), now); + } + + /// A stream is still coalesced: bytes arriving inside the hold of the + /// last flush wait for that hold to end, so the event rate under + /// sustained output is what it was — one flush per hold at most. + #[test] + fn output_inside_the_hold_waits_for_it_to_end() { + let now = tokio::time::Instant::now(); + let just_flushed = now - std::time::Duration::from_millis(1); + assert_eq!( + flush_deadline(now, Some(just_flushed)), + just_flushed + COALESCE_HOLD + ); + } + fn echo_session() -> Arc { PtySession::spawn( SessionRef::Agent(AgentId::generate()), diff --git a/crates/nebula-daemon/src/server.rs b/crates/nebula-daemon/src/server.rs index 6c8ed646..cc1bbcb7 100644 --- a/crates/nebula-daemon/src/server.rs +++ b/crates/nebula-daemon/src/server.rs @@ -562,12 +562,22 @@ async fn handle_client(daemon: Arc, stream: UnixStream) -> Result<()> { message, } => { tracing::info!(agent = %id, bytes = message.len(), "send to cloud session"); - reply_done( - &out_tx, - req_id, - daemon.send_cloud_message(&id, &message).await, - ) - .await; + // `claude -p … --cloud` is a login shell and a network + // round trip — seconds. Off the request loop, like the + // worktree ops above: run inline, every keystroke and + // every session switch on this connection waited for + // it, and the pane the user went back to typing in + // looked hung until the message was sent. + let daemon = daemon.clone(); + let out_tx = out_tx.clone(); + tokio::spawn(async move { + reply_done( + &out_tx, + req_id, + daemon.send_cloud_message(&id, &message).await, + ) + .await; + }); } ClientRequest::CreateTerminal { req_id, diff --git a/crates/nebula-fuzzy/Cargo.toml b/crates/nebula-fuzzy/Cargo.toml new file mode 100644 index 00000000..fa583c38 --- /dev/null +++ b/crates/nebula-fuzzy/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "nebula-fuzzy" +version.workspace = true +edition.workspace = true +license.workspace = true + +# No dependencies, on purpose: this crate exists so that dev builds can +# optimise it (see `[profile.dev.package.nebula-fuzzy]` in the workspace +# manifest) without recompiling anything else at opt-level 3. +[dependencies] diff --git a/crates/nebula-fuzzy/src/lib.rs b/crates/nebula-fuzzy/src/lib.rs new file mode 100644 index 00000000..f4b8c648 --- /dev/null +++ b/crates/nebula-fuzzy/src/lib.rs @@ -0,0 +1,427 @@ +//! Minimal fzf-style fuzzy matcher for nebula's list filters (re-exported as +//! `nebula_tui::fuzzy`). A crate of its own only so a dev build can compile +//! it at opt-level 3 — see the workspace manifest. +//! +//! Greedy leftmost subsequence match, case-insensitive. Scoring favors +//! consecutive runs and matches that start a path segment or word, which is +//! enough to float `src/server.rs` above `crates/serde_helpers.rs` for the +//! query "srv" without pulling in a matcher crate. +//! +//! Whitespace in a query splits it into independent terms, all of which must +//! match somewhere in the candidate, in any order (fzf's extended-search AND). +//! That is what lets `neb #10` find `nebula/#10 Credit Codex…` — a single +//! subsequence pass would demand a literal space between `neb` and `#10`. + +/// A successful match: the score (higher is better) and the ascending char +/// indices of `candidate` that matched, for highlighting. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FuzzyMatch { + pub score: i32, + pub positions: Vec, +} + +const CONSECUTIVE_BONUS: i32 = 8; +const BOUNDARY_BONUS: i32 = 6; + +/// Chars that start a new "word" in a path for the boundary bonus. +fn is_boundary(prev: Option) -> bool { + match prev { + None => true, + Some(c) => matches!(c, '/' | '\\' | '_' | '-' | '.' | ' '), + } +} + +/// Case-insensitive match of `query` inside `candidate`. +/// +/// The query is split on whitespace; every term must match `candidate` as a +/// subsequence, but the terms are matched independently and may appear in any +/// order. Returns None when some term never matches. An empty (or all +/// whitespace) query matches everything with score 0 and no positions. +/// +/// Each term runs one greedy pass from each occurrence of its first char and +/// keeps the best score, so "serv" prefers the `server` filename over a +/// scattered s…e…r…v through the directory prefix. +pub fn fuzzy_match(query: &str, candidate: &str) -> Option { + Matcher::new(query).matches(candidate) +} + +/// One query, prepared once, matched against many candidates: what a list +/// filter does on every keystroke. The FILE FINDER ranks every path of the +/// checkout per character typed — ten thousand of them, on a large one — +/// and matching each from scratch lower-cased the query, collected the +/// candidate's chars into a fresh buffer and allocated a position list per +/// starting point tried: 30 ms a keystroke in the build `make dev` runs, +/// felt as a finder that lags the typing. Here the terms are lower-cased +/// once, the candidate's chars go into one buffer kept across calls, and +/// starts are compared by score alone — positions are collected once, for +/// the start that won. +pub struct Matcher { + /// The query's whitespace-separated terms, lower-cased. + terms: Vec>, + /// The candidate in hand, lower-cased; reused between candidates. + cand: Vec, +} + +impl Matcher { + pub fn new(query: &str) -> Self { + Self { + terms: query + .split_whitespace() + .map(|term| term.chars().map(|c| c.to_ascii_lowercase()).collect()) + .collect(), + cand: Vec::new(), + } + } + + /// [`fuzzy_match`] of this matcher's query against `candidate`. + pub fn matches(&mut self, candidate: &str) -> Option { + // Most candidates match nothing — a filter is typed to narrow a + // list — so say so before filling the buffer: one walk of the + // candidate per term, nothing allocated. + if !self + .terms + .iter() + .all(|term| has_subsequence(term, candidate)) + { + return None; + } + self.cand.clear(); + self.cand + .extend(candidate.chars().map(|c| c.to_ascii_lowercase())); + let mut score = 0i32; + let mut positions: Vec = Vec::new(); + for term in &self.terms { + let m = match_term(term, &self.cand)?; + score += m.score; + positions.extend(m.positions); + } + // Terms match independently, so their spans can overlap and arrive + // out of order; highlighting wants one ascending, deduplicated run. + if self.terms.len() > 1 { + positions.sort_unstable(); + positions.dedup(); + } + Some(FuzzyMatch { score, positions }) + } +} + +/// Do `term`'s chars (lower-cased) appear in `candidate` in order, +/// case-insensitively? The necessary condition for [`match_term`] to find +/// anything, without its buffers. +fn has_subsequence(term: &[char], candidate: &str) -> bool { + // An ASCII term — nearly every one — can be looked for in the bytes: + // no byte of a multi-byte char equals an ASCII one, so it is the same + // question. A plain indexed loop, because this is the inner loop of + // every list filter and the build `make dev` runs does not optimise + // this crate: the iterator form below was 20 ms over ten thousand + // paths there, this is 3. + if term.iter().all(char::is_ascii) { + let bytes = candidate.as_bytes(); + let mut want = 0; + let mut i = 0; + while want < term.len() && i < bytes.len() { + if bytes[i].to_ascii_lowercase() == term[want] as u8 { + want += 1; + } + i += 1; + } + return want == term.len(); + } + let mut wanted = term.iter().peekable(); + for c in candidate.chars() { + match wanted.peek() { + None => return true, + Some(w) if **w == c.to_ascii_lowercase() => { + wanted.next(); + } + Some(_) => {} + } + } + wanted.peek().is_none() +} + +/// Best subsequence match of one whitespace-free `term` anywhere in `cand` +/// (both already lower-cased): the highest-scoring greedy pass, the +/// leftmost of equals. +fn match_term(term: &[char], cand: &[char]) -> Option { + if term.is_empty() { + return Some(FuzzyMatch { + score: 0, + positions: Vec::new(), + }); + } + let mut best: Option<(i32, usize)> = None; + for start in 0..cand.len() { + if cand[start] != term[0] { + continue; + } + // A failed greedy pass from here also fails from every later start + // (its chars are a subset), so the first miss ends the search. + let Some(score) = greedy_from(term, cand, start, None) else { + break; + }; + if best.is_none_or(|(b, _)| score > b) { + best = Some((score, start)); + } + } + let (score, start) = best?; + let mut positions = Vec::with_capacity(term.len()); + greedy_from(term, cand, start, Some(&mut positions)); + Some(FuzzyMatch { score, positions }) +} + +/// One greedy leftmost pass over `cand[start..]`: its score, and — when +/// asked — the positions it matched. +fn greedy_from( + query: &[char], + cand: &[char], + start: usize, + mut positions: Option<&mut Vec>, +) -> Option { + let mut score = 0i32; + let mut qi = 0; + let mut prev_matched = false; + for i in start..cand.len() { + if cand[i] == query[qi] { + score += 1; + if prev_matched { + score += CONSECUTIVE_BONUS; + } + if is_boundary((i > 0).then(|| cand[i - 1])) { + score += BOUNDARY_BONUS; + } + if let Some(positions) = positions.as_deref_mut() { + positions.push(i); + } + prev_matched = true; + qi += 1; + if qi == query.len() { + return Some(score); + } + } else { + prev_matched = false; + } + } + None +} + +/// Rank `candidates` against `query`: matching indices best-first, each with +/// its matched char positions. Score-sorted, ties broken by shorter text +/// then original order; an empty query keeps every candidate in original +/// order with no positions. +pub fn rank<'a, I>(query: &str, candidates: I) -> Vec<(usize, Vec)> +where + I: IntoIterator, +{ + // Whitespace-only counts as empty: every candidate scores 0, and sorting + // that by length would shuffle the list for a query that says nothing. + if query.split_whitespace().next().is_none() { + return candidates + .into_iter() + .enumerate() + .map(|(i, _)| (i, Vec::new())) + .collect(); + } + rank_by(query, candidates, |i, text| (text.chars().count(), i)) +} + +/// [`rank`] with the caller's own tiebreak: equal scores sort by ascending +/// `key(index, text)`, and an empty (or all-whitespace) query lists every +/// candidate in key order with no positions. For a list that has an order +/// of its own — the `/` PALETTE's attention order — the key keeps that +/// order wherever the score has nothing to say. +pub fn rank_by<'a, I, K>( + query: &str, + candidates: I, + key: impl Fn(usize, &str) -> K, +) -> Vec<(usize, Vec)> +where + I: IntoIterator, + K: Ord, +{ + if query.split_whitespace().next().is_none() { + let mut all: Vec<(K, usize)> = candidates + .into_iter() + .enumerate() + .map(|(i, text)| (key(i, text), i)) + .collect(); + all.sort_by(|a, b| a.0.cmp(&b.0)); + return all.into_iter().map(|(_, i)| (i, Vec::new())).collect(); + } + let mut matcher = Matcher::new(query); + let mut scored: Vec<(i32, K, usize, Vec)> = candidates + .into_iter() + .enumerate() + .filter_map(|(i, text)| { + matcher + .matches(text) + .map(|m| (m.score, key(i, text), i, m.positions)) + }) + .collect(); + // Stable, so original order is the final fallback under an equal key. + scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + scored.into_iter().map(|(_, _, i, p)| (i, p)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The reject pass must never disagree with the scorer it guards: a + /// candidate it turns away is one the scorer would have, and the other + /// way round. + #[test] + fn the_reject_pass_agrees_with_the_scorer() { + let candidates = [ + "src/server.rs", + "crates/serde_helpers.rs", + "README.md", + "Ünïcode/Päth.RS", + "a", + "", + "nebula/#10 Credit Codex", + ]; + let terms = [ + "srv", "SRV", "rs", "md", "x", "a", "#10", "päth", "zzz", "serverr", + ]; + for candidate in candidates { + let cand: Vec = candidate.chars().map(|c| c.to_ascii_lowercase()).collect(); + for term in terms { + let lowered: Vec = term.chars().map(|c| c.to_ascii_lowercase()).collect(); + assert_eq!( + has_subsequence(&lowered, candidate), + match_term(&lowered, &cand).is_some(), + "{term:?} in {candidate:?}" + ); + } + } + } + + #[test] + fn empty_query_matches_everything() { + let m = fuzzy_match("", "anything").unwrap(); + assert_eq!(m.score, 0); + assert!(m.positions.is_empty()); + } + + #[test] + fn subsequence_matches_and_reports_positions() { + // Ties keep the leftmost start ("src…" here scores the same as the + // start at "server"). + let m = fuzzy_match("srv", "src/server.rs").unwrap(); + assert_eq!(m.positions, vec![0, 1, 7]); + } + + #[test] + fn best_start_prefers_the_filename_run() { + // Greedy from the leftmost 's' would scatter across "src/"; the + // best-of-starts pass lands on the consecutive "serv" in "server". + let m = fuzzy_match("serv", "src/server.rs").unwrap(); + assert_eq!(m.positions, vec![4, 5, 6, 7]); + } + + #[test] + fn missing_char_fails() { + assert!(fuzzy_match("xyz", "src/server.rs").is_none()); + assert!(fuzzy_match("abc", "ab").is_none()); + } + + #[test] + fn match_is_case_insensitive() { + assert!(fuzzy_match("READ", "readme.md").is_some()); + assert!(fuzzy_match("read", "README.md").is_some()); + } + + #[test] + fn consecutive_run_beats_scattered_match() { + let run = fuzzy_match("serv", "src/server.rs").unwrap(); + let scattered = fuzzy_match("serv", "s_e_r_v.rs").unwrap(); + assert!(run.score > scattered.score, "{run:?} vs {scattered:?}"); + } + + #[test] + fn segment_start_beats_mid_word() { + let boundary = fuzzy_match("ui", "src/ui.rs").unwrap(); + let mid = fuzzy_match("ui", "build.rs").unwrap(); + assert!(boundary.score > mid.score, "{boundary:?} vs {mid:?}"); + } + + #[test] + fn space_separated_terms_match_independently() { + // The reported case: one subsequence pass wants a literal space + // between "neb" and "#10", which the PR row does not have. + let m = fuzzy_match( + "neb #10", + "nebula/#10 Credit Codex and Cursor in the README", + ) + .unwrap(); + assert_eq!(m.positions, vec![0, 1, 2, 7, 8, 9]); + } + + #[test] + fn terms_may_appear_in_any_order() { + assert!(fuzzy_match("#10 neb", "nebula/#10 Credit Codex").is_some()); + assert!(fuzzy_match("requests show", "nebula/main/Show Open Pull Requests").is_some()); + } + + #[test] + fn every_term_must_match() { + assert!(fuzzy_match("neb #11", "nebula/#10 Credit Codex").is_none()); + assert!(fuzzy_match("neb zzz", "nebula/#10 Credit Codex").is_none()); + } + + #[test] + fn positions_are_ascending_and_deduped_across_overlapping_terms() { + // "ne" and "neb" both land on the same leading chars. + let m = fuzzy_match("ne neb", "nebula/main").unwrap(); + assert_eq!(m.positions, vec![0, 1, 2]); + } + + #[test] + fn whitespace_only_query_matches_everything_in_order() { + let m = fuzzy_match(" ", "anything").unwrap(); + assert_eq!(m.score, 0); + assert!(m.positions.is_empty()); + let ranked = rank(" ", vec!["a-longer-one", "ab"]); + assert_eq!(ranked, vec![(0, vec![]), (1, vec![])]); + } + + #[test] + fn trailing_space_behaves_like_the_bare_term() { + assert_eq!( + fuzzy_match("serv ", "src/server.rs"), + fuzzy_match("serv", "src/server.rs") + ); + } + + #[test] + fn multi_term_ranking_floats_the_row_that_matches_both() { + let rows = vec![ + "nebula/main/Show Open Pull Requests", + "nebula/worktree-readme-tweak/Readme Tweak Pull Request", + "nebula/#10 Credit Codex and Cursor in the README tagline", + ]; + let ranked = rank("neb #10", rows.clone()); + assert_eq!(ranked.len(), 1, "only the #10 row has both terms"); + assert_eq!(ranked[0].0, 2); + } + + #[test] + fn rank_by_lists_an_empty_query_in_key_order_and_breaks_ties_by_key() { + // Empty query: pure key order, no positions. + let ranked = rank_by("", vec!["b", "a", "c"], |i, _| [2usize, 0, 1][i]); + assert_eq!( + ranked, + vec![(1, vec![]), (2, vec![]), (0, vec![])], + "key order, not original order" + ); + // Equal scores: the key decides, not the text length. + let ranked = rank_by("main", vec!["demo/main", "demo/main/agent-1"], |i, _| { + [1usize, 0][i] + }); + assert_eq!(ranked[0].0, 1, "the longer row wins on key"); + // A better score still beats a better key. + let ranked = rank_by("read", vec!["feat/unread", "feat/read"], |i, _| i); + assert_eq!(ranked[0].0, 1, "the boundary match outranks the key"); + } +} diff --git a/crates/nebula-tui/Cargo.toml b/crates/nebula-tui/Cargo.toml index f99f97ad..79dd812a 100644 --- a/crates/nebula-tui/Cargo.toml +++ b/crates/nebula-tui/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] nebula-core = { workspace = true } +nebula-fuzzy = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } rmp-serde = { workspace = true } diff --git a/crates/nebula-tui/src/app.rs b/crates/nebula-tui/src/app.rs index 0665b998..d3e8d7d1 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -16,14 +16,19 @@ use std::path::PathBuf; /// size of [`App::sweep_phase`] (one text cell per frame). pub const SWEEP_FRAME: std::time::Duration = std::time::Duration::from_millis(100); -/// How many recently shown sessions keep their screen ([`App::term_cache`]). -/// Two covers the flip between a pair of worktrees and a three-way rotation; -/// each entry is a whole `vt100` parser, so this is not a number to grow. -pub const TERM_CACHE_MAX: usize = 2; -/// The largest screen worth keeping, in grid cells (32 bytes each, so this -/// is about 12 MB). An alt-screen CLI is a screen's worth; a shell whose -/// 10 000-line scrollback has filled is tens of megabytes, and re-parsing -/// that on the way back is cheaper than holding it. +/// How many recently shown sessions keep their screen ([`App::term_cache`]): +/// enough for a rotation through the sessions of a couple of worktrees. +/// What bounds the memory is [`TERM_CACHE_CELLS`], not this. +pub const TERM_CACHE_MAX: usize = 6; +/// The most the kept screens may hold between them, in grid cells (32 bytes +/// each, so about 12 MB — half of what two entries of that size each used to +/// be allowed). An alt-screen CLI is a screen's worth, 200 KB; a shell whose +/// 10 000-line scrollback has filled is tens of megabytes on its own, and +/// used never to be kept at all — every return to it re-parsed the whole +/// ring, 33 ms of blank pane under the INPUT LATENCY PROBE. Now a screen +/// that does not fit is kept WITHOUT its history ([`AttachedTerm:: +/// drop_history`]): the return paints on the keypress like any other, and +/// the history is replayed if the user scrolls up into it. pub const TERM_CACHE_CELLS: usize = 400_000; /// Wall-clock epoch ms, comparable to the daemon's `status_changed_at`. @@ -853,9 +858,86 @@ pub struct DiffView { /// HEAD OID the marks are scoped to (empty on an unborn HEAD). A moved /// HEAD — commit, checkout — resets the worktree's marks on next open. pub head_key: String, + /// BACKGROUND READS: with it, the file list and every file's diff are + /// read off the loop (`git_diff::load_selected_diff`); without (a view + /// built by a test, a pull request's prefetched view), inline. + pub jobs: Option, + /// This view's own ticket, carried by every diff it asks for, so text + /// read for an earlier modal never lands in this one's cache. + pub id: u64, + /// The `git status` this view opened ahead of, by ticket: the list is + /// empty and says `reading changes…` until the listing lands. + pub listing: Option, + /// The selected file's diff in flight, by ticket. `diff` keeps the text + /// it had meanwhile (`view_jobs::STALE_GRACE`) — see `shown`. + pub waiting: Option, + /// The file `diff` is the diff of. Differs from the selected file while + /// that one's read is in flight, which is when a reviewed ✓ — a + /// fingerprint of the text on screen — must not be taken. + pub shown: Option, + /// Diffs read while this modal has been open, newest last: walking + /// back onto a file shows it on the keypress (and re-reads it behind, + /// so an agent's edit meanwhile still shows up), and the row after the + /// cursor is read ahead. Bounded by [`DIFF_CACHE_BYTES`]; gone with + /// the modal. + pub cache: Vec<(String, std::sync::Arc)>, } +/// The most diff text a DIFF VIEWER keeps beyond the one on screen. Two +/// megabytes is a few hundred ordinary files' worth, and an entry over +/// [`DIFF_CACHE_ENTRY_MAX`] is never kept: re-reading one huge diff is +/// cheaper than holding it. +pub const DIFF_CACHE_BYTES: usize = 2 * 1024 * 1024; +pub const DIFF_CACHE_ENTRY_MAX: usize = 512 * 1024; + impl DiffView { + /// A view up before its file list is: `g` opens this at once and + /// `event_loop::land_view_answer` fills it when `git status` answers. + pub fn opening( + root: PathBuf, + branch: String, + jobs: crate::view_jobs::Jobs, + listing: u64, + ) -> Self { + let mut view = Self::new(root, branch, Vec::new(), true); + view.jobs = Some(jobs); + view.listing = Some(listing); + view + } + + /// The cached diff of `path`, if this modal has read it. + pub fn cached(&self, path: &str) -> Option> { + self.cache + .iter() + .find(|(p, _)| p == path) + .map(|(_, text)| text.clone()) + } + + /// Keep `diff` as the diff of `path`, dropping the oldest entries to + /// stay inside the budget. + pub fn cache_put(&mut self, path: &str, diff: &str) { + self.cache.retain(|(p, _)| p != path); + if diff.len() > DIFF_CACHE_ENTRY_MAX { + return; + } + self.cache.push((path.to_string(), diff.into())); + let mut held: usize = self.cache.iter().map(|(_, text)| text.len()).sum(); + while held > DIFF_CACHE_BYTES && self.cache.len() > 1 { + held -= self.cache.remove(0).1.len(); + } + } + + /// Put `diff` on screen as the diff of `path`. `keep_scroll` is a + /// re-read of the file already showing: the reader's place is kept. + pub fn show_diff(&mut self, path: Option<&str>, diff: String, keep_scroll: bool) { + self.diff_line_count = diff.lines().count(); + self.diff = diff; + self.shown = path.map(str::to_string); + if !keep_scroll { + self.scroll = 0; + } + } + pub fn new(root: PathBuf, branch: String, files: Vec, head_ok: bool) -> Self { let mut view = Self { root, @@ -877,6 +959,12 @@ impl DiffView { pr_url: None, reviewed: HashMap::new(), head_key: String::new(), + jobs: None, + id: crate::view_jobs::ticket(), + listing: None, + waiting: None, + shown: None, + cache: Vec::new(), }; view.apply_filter(); view @@ -960,6 +1048,11 @@ impl DiffView { /// diff; it persists `reviewed` either way). pub fn toggle_reviewed(&mut self) -> Option { let path = self.selected_file()?.path.clone(); + // The mark is a fingerprint of the diff that was read — not of + // whatever the pane still holds while this file's is in flight. + if self.waiting.is_some() && self.shown.as_deref() != Some(path.as_str()) { + return None; + } let before = self.matches.get(self.selected).map(|m| m.file); let unmarked = self.reviewed.remove(&path).is_some(); if !unmarked { @@ -1026,6 +1119,11 @@ pub struct FileFinder { /// Screen rect of the result rows (query row excluded), written back /// during draw so clicks can hit-test rows. pub list_area: Rect, + /// The `git ls-files` this finder opened ahead of, by ticket + /// (`view_jobs`): the list is empty and says `listing files…` until + /// [`FileFinder::set_files`]. None once the listing is in hand — and + /// always, for a finder built with its files. + pub listing: Option, } impl FileFinder { @@ -1040,11 +1138,28 @@ impl FileFinder { selected: 0, area: Rect::default(), list_area: Rect::default(), + listing: None, }; finder.apply_filter(); finder } + /// A finder up before its listing is: `f` opens this at once, and + /// `set_files` fills it when `git ls-files` answers. What is typed + /// meanwhile is kept, and narrows the list the moment there is one. + pub fn opening(root: PathBuf, branch: String, editor: String, listing: u64) -> Self { + let mut finder = Self::new(root, branch, editor, Vec::new()); + finder.listing = Some(listing); + finder + } + + /// The listing landed: rank it by whatever the query holds by now. + pub fn set_files(&mut self, files: Vec) { + self.files = files; + self.listing = None; + self.apply_filter(); + } + /// First visible row of the result list's stateless follow-window for a /// list of `height` rows. pub fn window_start(&self, height: usize) -> usize { @@ -1099,6 +1214,14 @@ pub struct GrepView { /// Screen rect of the result rows (query row excluded), written back /// during draw so clicks can hit-test rows. pub list_area: Rect, + /// BACKGROUND READS: with it, a search runs off the loop and lands in + /// [`GrepView::land`]; without (a view built by a test), inline. + pub jobs: Option, + /// The search in flight, by ticket. The hits on screen meanwhile are the + /// previous query's — the title says `searching…`. + pub waiting: Option, + /// Stops the search in flight when the query moves on. + pub cancel: crate::view_jobs::Cancel, } impl GrepView { @@ -1114,6 +1237,9 @@ impl GrepView { selected: 0, area: Rect::default(), list_area: Rect::default(), + jobs: None, + waiting: None, + cancel: crate::view_jobs::Cancel::default(), } } @@ -1122,12 +1248,56 @@ impl GrepView { pub fn run_search(&mut self) { self.selected = 0; self.error = None; + // Whatever was being searched for is no longer the query. + self.cancel.cancel(); + self.waiting = None; if self.query.chars().count() < crate::grep_search::MIN_QUERY_LEN { self.hits.clear(); self.truncated = false; return; } - match crate::grep_search::search(&self.root, &self.query) { + let Some(jobs) = &self.jobs else { + let result = crate::grep_search::search(&self.root, &self.query); + self.show(result); + return; + }; + let ticket = crate::view_jobs::ticket(); + self.waiting = Some(ticket); + self.cancel = crate::view_jobs::Cancel::default(); + let (root, query, cancel) = ( + self.root.clone(), + self.query.to_string(), + self.cancel.clone(), + ); + jobs.run(move || { + // The next character, typed at speed, cancels this before git + // is ever started. + std::thread::sleep(crate::view_jobs::GREP_DEBOUNCE); + if cancel.is_cancelled() { + return None; + } + let result = crate::grep_search::search_streaming(&root, &query, &cancel)?; + Some(crate::view_jobs::Answer::Grep { ticket, result }) + }); + } + + /// A background search's answer: shown when it is the one being waited + /// on, dropped when the query has moved on since. + pub fn land( + &mut self, + ticket: u64, + result: Result<(Vec, bool), String>, + ) { + if self.waiting != Some(ticket) { + return; + } + self.waiting = None; + self.show(result); + } + + fn show(&mut self, result: Result<(Vec, bool), String>) { + self.selected = 0; + match result { Ok((hits, truncated)) => { self.hits = hits; self.truncated = truncated; @@ -1547,9 +1717,24 @@ pub enum PendingIntent { OpenCreatedWorkspace, /// Worktree removed optimistically; restore these rows on Error. DeleteWorktree(WorktreeRollback), + /// A row renamed, archived, unarchived or deleted on the keypress + /// (`event_loop::optimistic`): put it back on Error. + Undo(Undo), None, } +/// A row as it was before an OPTIMISTIC UPDATE changed it. +#[derive(Debug, Clone)] +pub enum Undo { + /// Renamed or (un)archived in place: this is the row to show again. + Restore(Box), + /// Deleted: the row, and the index it held in its `tree` list. + Reinsert { + index: usize, + entity: Box, + }, +} + impl PendingIntent { /// Will this request's Ack move a cursor, the pane or FOCUS onto what /// it created? Those are the follows a manual move cancels @@ -2097,6 +2282,14 @@ pub struct AttachedTerm { /// comes back as a gap-free delta onto the screen it left rather than a /// megabyte replay into a fresh parser (see [`App::term_cache`]). pub next_seq: u64, + /// The scrollback was let go while this screen sat in + /// [`App::term_cache`]: what is on screen is exact, what is above it is + /// gone. Scrolling up asks the DAEMON for the whole ring again + /// (`event_loop::rehydrate_history`), which rebuilds both. + pub history_dropped: bool, + /// The scroll offset to land on once that replay has rebuilt the + /// history: the notch that asked for it. + pub pending_scroll: Option, } impl AttachedTerm { @@ -2112,6 +2305,8 @@ impl AttachedTerm { painted: false, booting: false, next_seq: 0, + history_dropped: false, + pending_scroll: None, } } @@ -2128,6 +2323,7 @@ impl AttachedTerm { self.painted = false; self.booting = false; self.next_seq = 0; + self.history_dropped = false; } /// Apply a ring replay. One that continues exactly where this parser @@ -2150,13 +2346,29 @@ impl AttachedTerm { if !self.painted { self.booting = true; } + // A replay asked for to get the history back lands the reader + // where the scroll that asked for it was headed. + if let Some(scroll) = self.pending_scroll.take() { + if rebuilt { + self.set_scroll(scroll); + } + } rebuilt } - /// Apply live output that follows what the parser holds. + /// Apply live output that follows what the parser holds. Bytes a replay + /// already covered are skipped: a second Attach of the session on + /// screen (the history replay) can cross a frame the first one's + /// forwarder had already queued, and parsing those bytes twice would + /// garble the screen the replay just rebuilt. pub fn apply_output(&mut self, seq: u64, data: &[u8]) { - self.next_seq = seq + data.len() as u64; - self.feed(data); + let end = seq + data.len() as u64; + if end <= self.next_seq { + return; + } + let covered = self.next_seq.saturating_sub(seq) as usize; + self.next_seq = end; + self.feed(&data[covered.min(data.len())..]); } fn feed(&mut self, data: &[u8]) { @@ -2176,22 +2388,26 @@ impl AttachedTerm { } } - /// Rough footprint in grid cells: the visible grid plus every - /// scrollback row of the screen in use. `vt100` doesn't expose the - /// scrollback length, but its offset setter clamps to it. On the - /// alternate screen (a CLI in full-screen mode) this sees only that - /// screen's own, empty scrollback, so a shell with a long history - /// parked inside a full-screen program is under-counted — the one way - /// a cached screen can exceed [`TERM_CACHE_CELLS`]. - pub fn estimated_cells(&mut self) -> usize { - let screen = self.parser.screen_mut(); - let keep = screen.scrollback(); - screen.set_scrollback(usize::MAX); - let lines = screen.scrollback(); - screen.set_scrollback(keep); + /// Footprint in grid cells: the visible grid plus every scrollback row + /// the primary screen holds — also while a full-screen program is up + /// over it, which the offset-clamping trick this used to be could not + /// see (the vendored `vt100` now says, `Screen::scrollback_rows`). + pub fn estimated_cells(&self) -> usize { + let lines = self.parser.screen().scrollback_rows(); (lines + self.rows as usize) * self.cols as usize } + /// Let the scrollback go, keeping the screen: what a kept screen that + /// is over budget does instead of not being kept. + pub fn drop_history(&mut self) { + if self.parser.screen().scrollback_rows() == 0 { + return; + } + self.set_scroll(0); + self.parser.screen_mut().clear_scrollback(); + self.history_dropped = true; + } + pub fn set_scroll(&mut self, scroll: usize) { self.scroll = scroll; self.parser.screen_mut().set_scrollback(scroll); @@ -2816,6 +3032,25 @@ pub struct App { /// startup like `pr_diff_tx`, so the modal's own handlers can start a /// fetch. `None` in the unit tests, which then never spawn one. pub issues_tx: Option>, + /// BACKGROUND READS for the worktree views (`view_jobs`): the DIFF + /// VIEWER, the FILE FINDER, its grep view and the TREE BROWSER are + /// handed a clone when they open, and their git and disk reads land + /// through the main loop instead of holding it. None with no loop + /// running (unit tests), where those views read inline. + pub view_jobs: Option, + /// A `g` on a checkout the changed-files badge called clean: the ticket + /// of the `git status` checking that, and the checkout (path, branch) + /// to open the DIFF VIEWER on if git disagrees. + pub diff_probe: Option<(u64, PathBuf, String)>, + /// The changed files the badge's last `git status` listed, and the + /// checkout they are in (`event_loop::keep_changed_files`): what `g` + /// opens the DIFF VIEWER on while its own `git status` runs. One + /// checkout's worth, capped, replaced by every poll. + pub changed_files: Option<(WorktreeId, Vec)>, + /// Rows deleted here ahead of the DAEMON's answer + /// (`event_loop::optimistic`): an upsert of one of them is a straggler + /// from before the delete, and is ignored rather than shown. + pub deleting: std::collections::HashSet, /// The BRANCH SWITCHER's answer channel, listing cache and fetch /// throttle — what outlives the modal. pub branch_switch: crate::branch_switch::Shared, @@ -2972,6 +3207,10 @@ impl App { issue_comment_inflight: std::collections::HashSet::new(), pending_issue_detail: None, issues_tx: None, + view_jobs: None, + diff_probe: None, + changed_files: None, + deleting: std::collections::HashSet::new(), branch_switch: Default::default(), last_metrics: None, client_rss_bytes: 0, @@ -3395,23 +3634,46 @@ impl App { } /// Put a screen the pane is leaving aside for a quick return, when it - /// is worth keeping: a real session that has painted, is still live - /// and fits the budget (see [`App::term_cache`]). The cache is - /// most-recent-first and bounded; whatever it already held for this - /// session is replaced. - pub fn stash_term(&mut self, mut term: AttachedTerm) { + /// is worth keeping: a real session that has painted and is still live + /// (see [`App::term_cache`]). The cache is most-recent-first and + /// bounded twice — [`TERM_CACHE_MAX`] screens, [`TERM_CACHE_CELLS`] + /// between them; whatever it already held for this session is + /// replaced. Over the cell budget, histories go before screens do, + /// oldest first: a screen without its scrollback is a fiftieth of the + /// size and still paints the return on the keypress. + pub fn stash_term(&mut self, term: AttachedTerm) { self.term_cache.retain(|t| t.sref != term.sref); let keep = term.painted && !term.exited && !self.is_placeholder_session(&term.sref) - && self.session_is_live(&term.sref) - && term.estimated_cells() <= TERM_CACHE_CELLS; + && self.session_is_live(&term.sref); if !keep { return; } + let mut term = term; + // Left before the history it asked back had landed: still without + // it, and no longer asking. + if term.pending_scroll.take().is_some() { + term.history_dropped = true; + } self.term_cache.insert(0, term); self.term_cache.truncate(TERM_CACHE_MAX); self.prune_term_cache(); + let held = |cache: &[AttachedTerm]| -> usize { + cache.iter().map(AttachedTerm::estimated_cells).sum() + }; + while held(&self.term_cache) > TERM_CACHE_CELLS { + let oldest_with_history = self + .term_cache + .iter() + .rposition(|t| t.parser.screen().scrollback_rows() > 0); + match oldest_with_history { + Some(i) => self.term_cache[i].drop_history(), + None => { + self.term_cache.pop(); + } + } + } } /// The kept screen for `sref`, if there is one and its session is diff --git a/crates/nebula-tui/src/event_loop.rs b/crates/nebula-tui/src/event_loop.rs index 74a0c4ab..32abfb52 100644 --- a/crates/nebula-tui/src/event_loop.rs +++ b/crates/nebula-tui/src/event_loop.rs @@ -32,6 +32,8 @@ mod activate; mod alerts; mod focus_walk; mod host_terminal; +mod optimistic; +mod pacing; mod placeholder; mod quick_launch; use focus_walk::{ @@ -93,10 +95,6 @@ const NO_SESSIONS_TO_JUMP: &str = "no sessions to jump to"; /// Flash for an action an archived agent refuses until it's unarchived. const AGENT_ARCHIVED: &str = "agent is archived — unarchive first (u)"; -/// Redraw cap (~60fps). Output bursts coalesce into one frame; input events -/// are still handled immediately between frames. -const FRAME_INTERVAL: Duration = Duration::from_millis(16); - /// How often the worktree panel's changed-file badge re-reads `git status` /// for the selected checkout, so agent edits surface without a keypress. const GIT_POLL: Duration = Duration::from_secs(2); @@ -277,11 +275,13 @@ async fn main_loop( // splitter swaps the cursor once instead of on every motion event. let mut pointer_sent = PointerShape::default(); let mut next_draw = tokio::time::Instant::now(); + // When the loop may paint again (FRAME PACING): back to back for a key + // and its answer, 60 fps under sustained output. + let mut pacer = pacing::FramePacer::new(next_draw); let mut next_git_poll = tokio::time::Instant::now(); // The changed-file badge's `git status`, run off the loop; the count // lands here and in `app.git_changes`. - let (git_tx, mut git_rx) = - tokio::sync::mpsc::unbounded_channel::<(WorktreeId, Option)>(); + let (git_tx, mut git_rx) = tokio::sync::mpsc::unbounded_channel::(); // Pull-request lookups run off the loop (they hit the network); answers // come back here and land in `app.pull_requests`. let (pr_tx, mut pr_rx) = tokio::sync::mpsc::unbounded_channel::<(WorktreeId, Lookup)>(); @@ -314,6 +314,11 @@ async fn main_loop( let (branch_tx, mut branch_rx) = tokio::sync::mpsc::unbounded_channel::(); app.branch_switch.tx = Some(branch_tx); + // BACKGROUND READS for the worktree views: the git and the disk behind + // `g`, `f`, `F` and `b` run on the blocking pool and land here. + let (views_tx, mut views_rx) = + tokio::sync::mpsc::unbounded_channel::(); + app.view_jobs = Some(crate::view_jobs::Jobs::new(views_tx)); // A newer nebula published on GitHub, probed off the loop at start and // then on a slow beat (`update_check::interval`; the e2e tests turn it // off). Only a newer version ever arrives, so the footer's indicator, @@ -331,6 +336,9 @@ async fn main_loop( // spawns (VimEvent generations keep them apart). let (vim_tx, mut vim_rx) = tokio::sync::mpsc::unbounded_channel::(); app.vim_tx = Some(vim_tx); + // The INPUT LATENCY PROBE (`NEBULA_PERF_LOG`); None outside a + // measurement run. + let mut perf = crate::perf::Perf::from_env(); loop { if app.dirty && tokio::time::Instant::now() >= next_draw { @@ -340,9 +348,13 @@ async fn main_loop( if app.git_changes_stale() { request_git_changes(&mut app, &git_tx); } + let began = std::time::Instant::now(); draw_frame(terminal, &mut app)?; + if let Some(perf) = &mut perf { + perf.frame(began, &app); + } app.dirty = false; - next_draw = tokio::time::Instant::now() + FRAME_INTERVAL; + next_draw = pacer.drew(tokio::time::Instant::now(), began.elapsed()); sync_pty_size(&mut app, &mut out); sync_vim_size(&mut app); } @@ -480,13 +492,23 @@ async fn main_loop( if matches!(event, Event::Resize(..)) { on_host_resize(terminal)?; } + let probe = perf + .as_ref() + .and_then(|_| crate::perf::label(&event)) + .map(|label| (label, std::time::Instant::now())); handle_terminal_event(&mut app, event, &mut out); + if let (Some(perf), Some((label, arrived))) = (&mut perf, probe) { + perf.input(label, arrived, &app); + } } Some(Err(_)) | None => app.should_quit = true, }, ev = channels.rx.recv() => match ev { Some(server_event) => { log_server_event(&server_event); + if let Some(perf) = &mut perf { + perf.server(crate::perf::server_name(&server_event)); + } handle_server_event(&mut app, server_event, &mut out); } None => { @@ -509,7 +531,9 @@ async fn main_loop( } answer = git_rx.recv() => { // Never None: `git_tx` lives as long as the loop. - if let Some((worktree, count)) = answer { + if let Some((worktree, files)) = answer { + let count = files.as_ref().map(Vec::len); + keep_changed_files(&mut app, &worktree, files); land_git_changes(&mut app, worktree, count); // The selection moved on while this one was being read: // ask for where it is now, rather than wait a poll. @@ -574,6 +598,12 @@ async fn main_loop( crate::branch_switch::land_answer(&mut app, answer); } } + answer = views_rx.recv() => { + // Never None: `app.view_jobs` keeps a sender alive. + if let Some(answer) = answer { + land_view_answer(&mut app, answer); + } + } } if app.focus != focus_before { tracing::debug!(from = ?focus_before, to = ?app.focus, "focus changed"); @@ -584,6 +614,9 @@ async fn main_loop( // (burst coalescing for PTY output). while let Ok(ev) = channels.rx.try_recv() { log_server_event(&ev); + if let Some(perf) = &mut perf { + perf.server(crate::perf::server_name(&ev)); + } handle_server_event(&mut app, ev, &mut out); } while let Ok(ev) = vim_rx.try_recv() { @@ -680,10 +713,7 @@ async fn main_loop( /// feel instant — and on every poll, a hitch under the user's typing. /// Skipped while one is in flight (a repaint must never stack processes); /// the answer arrives on `git_tx` and lands in `land_git_changes`. -fn request_git_changes( - app: &mut App, - git_tx: &tokio::sync::mpsc::UnboundedSender<(WorktreeId, Option)>, -) { +fn request_git_changes(app: &mut App, git_tx: &tokio::sync::mpsc::UnboundedSender) { if app.git_changes_inflight.is_some() { return; } @@ -696,11 +726,34 @@ fn request_git_changes( app.git_changes_inflight = Some(id.clone()); let git_tx = git_tx.clone(); tokio::task::spawn_blocking(move || { - let count = crate::git_diff::changed_files(&path).ok().map(|f| f.len()); - let _ = git_tx.send((id, count)); + let _ = git_tx.send((id, crate::git_diff::changed_files(&path).ok())); }); } +/// What the badge's `git status` found in a checkout; None when git could +/// not say. +type ChangedFiles = (WorktreeId, Option>); + +/// The most changed files worth holding on to between polls for `g` to open +/// on: a couple of hundred kilobytes of paths at the outside. A checkout +/// with more than this changed waits for its own `git status` instead. +const CHANGED_FILES_KEEP: usize = 2000; + +/// Keep the list the badge's count was taken from. The poll has already +/// paid for it, every two seconds, for the checkout the user is in — which +/// is the checkout `g` opens the DIFF VIEWER on, and the `git status` that +/// `g` would otherwise wait for before it can show a single file +/// (`open_diff_view`). +fn keep_changed_files( + app: &mut App, + worktree: &WorktreeId, + files: Option>, +) { + app.changed_files = files + .filter(|files| files.len() <= CHANGED_FILES_KEEP) + .map(|files| (worktree.clone(), files)); +} + /// Record a checkout's changed-file count. Stored whichever checkout it is /// for — `App::selected_worktree_changes` shows it only while that one is /// selected — and a value change redraws. @@ -1668,14 +1721,27 @@ fn note_focus_change(app: &mut App) { /// Fire one memory reading for the metrics modal: sample this client's own /// RSS now (the daemon can't see us), ask the daemon for itself plus every /// session's process tree. The reply arrives as `ServerEvent::Metrics`. +/// +/// The client's own reading is a `ps`, and this runs on the footer's beat — +/// every five seconds for as long as the TUI is up — so the `ps` runs off +/// the loop and lands like any other BACKGROUND READ, rather than stall +/// whatever key arrives during it. fn request_metrics(app: &mut App, out: &mut Vec) { - app.client_rss_bytes = nebula_core::mem::process_rss_bytes(std::process::id()).unwrap_or(0); - if let Some(Overlay::Metrics(view)) = &mut app.overlay { - view.client_rss_bytes = app.client_rss_bytes; + let own_rss = || nebula_core::mem::process_rss_bytes(std::process::id()).unwrap_or(0); + match app.view_jobs.clone() { + Some(jobs) => jobs.run(move || Some(crate::view_jobs::Answer::ClientRss(own_rss()))), + None => land_client_rss(app, own_rss()), } send(app, out, |req_id| ClientRequest::GetMetrics { req_id }); } +fn land_client_rss(app: &mut App, bytes: u64) { + app.client_rss_bytes = bytes; + if let Some(Overlay::Metrics(view)) = &mut app.overlay { + view.client_rss_bytes = bytes; + } +} + /// Queue a request that wants no follow-up when its Ack lands. fn send(app: &mut App, out: &mut Vec, make: impl FnOnce(u64) -> ClientRequest) { send_with(app, out, PendingIntent::None, make); @@ -1965,9 +2031,18 @@ fn dispatch_terminal_event(app: &mut App, event: Event, out: &mut Vec { + let typing = typing_into_pane(app); app.flash = None; handle_key(app, key, out); - app.dirty = true; + // A key that only went to the PTY changed nothing here: what + // it does shows up as the PTY's answer, a couple of + // milliseconds on. Painting an identical frame for it first + // put that answer's frame a draw and a FRAME PACING gap + // behind — 8 ms from key to echo under the INPUT LATENCY + // PROBE instead of 3 — on every character typed at an agent. + if !(typing && typing_into_pane(app)) { + app.dirty = true; + } } Event::Mouse(mouse) => handle_mouse(app, mouse, out), Event::Paste(text) if app.vim.is_some() => { @@ -2006,6 +2081,28 @@ fn dispatch_terminal_event(app: &mut App, event: Event, out: &mut Vec bool { + app.vim.is_none() + && app.overlay.is_none() + && app.focus == Focus::Terminal + && app.term_locked + && !app.splash_active() + && app.flash.is_none() + && app.term_selection.is_none() + && app.key_combo.is_none() + && !app.pane_shows_placeholder() + && app + .term + .as_ref() + .is_some_and(|t| !t.exited && t.scroll == 0) +} + /// `text` wrapped in the bracketed-paste markers, ready for a PTY. fn bracketed(text: &str) -> Vec { let mut data = PASTE_START.to_vec(); @@ -2842,13 +2939,20 @@ fn open_repo_in_browser(app: &mut App) { app.flash = Some(SELECT_CONTEXT_FIRST.into()); return; }; - match crate::remote::repo_url(&root) { - // Not open_link: this is a repo page, never a PR row to mark read. - Ok(url) if open_url(&url) => { - app.flash = Some(format!("opened {}", crate::app::pretty_url(&url))) + // Not open_link: this is a repo page, never a PR row to mark read. + let open = move || match crate::remote::repo_url(&root) { + Ok(url) if open_url(&url) => format!("opened {}", crate::app::pretty_url(&url)), + Ok(url) => format!("couldn't open {url}"), + Err(msg) => msg, + }; + // Which page it is takes a `git remote get-url` to know: asked off the + // loop, with the outcome flashed when it lands. + match app.view_jobs.clone() { + Some(jobs) => { + app.flash = Some("opening the repository's page…".into()); + jobs.run(move || Some(crate::view_jobs::Answer::Flash(open()))); } - Ok(url) => app.flash = Some(format!("couldn't open {url}")), - Err(msg) => app.flash = Some(msg), + None => app.flash = Some(open()), } } @@ -2909,15 +3013,9 @@ fn open_in_app(bundle: &std::path::Path, path: &std::path::Path) -> bool { if cfg!(test) { return true; } - use std::process::{Command, Stdio}; - Command::new("open") - .arg("-a") - .arg(bundle) - .arg(path) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok_and(|status| status.success()) + let mut open = std::process::Command::new("open"); + open.arg("-a").arg(bundle).arg(path); + spawn_and_reap(open, "open in app") } /// `r` on the Worktrees panel: start the selected checkout's RUN COMMAND, @@ -3098,55 +3196,83 @@ fn load_worktree_files( Some((files, editor)) } +/// `g`: the DIFF VIEWER on the selected checkout. The modal is up on this +/// keypress and its file list lands when `git status` answers +/// (`land_view_answer`) — it is that `git status`, the HEAD lookup and a +/// `git diff` per reviewed ✓ mark that the key used to wait on. The +/// reviewed marks `git_diff::read_listing` restores drop any that no +/// longer apply: `load_marks` already returns nothing when HEAD moved (a +/// commit resets the whole worktree), and a mark whose file left the change +/// list or whose diff text changed since it was approved is pruned — then +/// the pruned set is written back. Restored marks sink to the bottom, so +/// the modal opens on the first unreviewed file. +/// +/// A checkout the changed-files badge already knows to be clean is told so +/// on the spot instead of being shown a modal that closes again; the badge +/// can be two seconds behind an agent, so git is still asked, and the +/// modal opens after all if it disagrees (`App::diff_probe`). fn open_diff_view(app: &mut App) { let Some((path, branch)) = selected_checkout(app) else { return; }; - let files = match crate::git_diff::changed_files(&path) { - Ok(files) => files, - Err(msg) => { - app.flash = Some(msg); - return; + let Some(jobs) = app.view_jobs.clone() else { + // No loop to land an answer on (unit tests): read inline. + match crate::git_diff::read_listing(&path) { + Ok(listing) => show_diff_listing(app, path, branch, listing), + Err(msg) => app.flash = Some(msg), } + return; }; - if files.is_empty() { + let ticket = crate::view_jobs::ticket(); + let selected = app.selected_worktree().map(|w| w.id.clone()); + let known_clean = + matches!(&app.git_changes, Some((id, Some(0))) if Some(id) == selected.as_ref()); + if known_clean { app.flash = Some(format!("no changes in {branch}")); - return; + app.diff_probe = Some((ticket, path.clone(), branch)); + } else { + let mut view = DiffView::opening(path.clone(), branch, jobs.clone(), ticket); + view.files_width = app.diff_files_width; + // The badge's last `git status` — two seconds old at most — is the + // list to open on: the files are up on this keypress and the first + // diff is being read while the `git status` below checks them, not + // after it. `fill_view` reconciles the two when that lands. + let polled = app + .changed_files + .as_ref() + .filter(|(id, files)| Some(id) == selected.as_ref() && !files.is_empty()); + if let Some((_, files)) = polled { + view.files = files.clone(); + view.apply_filter(); + crate::git_diff::load_selected_diff(&mut view); + } + app.overlay = Some(Overlay::Diff(view)); } - let head = crate::git_diff::head_oid(&path); - let head_ok = head.is_some(); - let mut view = DiffView::new(path, branch, files, head_ok); - view.head_key = head.unwrap_or_default(); - view.files_width = app.diff_files_width; - restore_reviewed_marks(&mut view); - crate::git_diff::load_selected_diff(&mut view); - app.overlay = Some(Overlay::Diff(view)); + jobs.run(move || { + Some(crate::view_jobs::Answer::DiffListing { + ticket, + result: crate::git_diff::read_listing(&path), + }) + }); } -/// Restore the worktree's reviewed ✓ marks into `view.reviewed`, dropping -/// any that no longer apply: `load_marks` already returns nothing when HEAD -/// moved (a commit resets the whole worktree), and a mark whose file left -/// the change list or whose diff text changed since it was approved is -/// pruned here — then the pruned set is written back. Restored marks sink -/// to the bottom, so the modal opens on the first unreviewed file. -fn restore_reviewed_marks(view: &mut DiffView) { - let stored = crate::review::load_marks(&view.root, &view.head_key); - if stored.is_empty() { +/// Open the DIFF VIEWER on a listing already in hand — or say there is +/// nothing to show. +fn show_diff_listing( + app: &mut App, + path: std::path::PathBuf, + branch: String, + listing: crate::view_jobs::DiffListing, +) { + if listing.files.is_empty() { + app.flash = Some(format!("no changes in {branch}")); return; } - view.reviewed = view - .files - .iter() - .filter_map(|file| { - let mark = *stored.get(&file.path)?; - let diff = crate::git_diff::diff_for(&view.root, file, view.head_ok); - (crate::review::fingerprint(&diff) == mark).then(|| (file.path.clone(), mark)) - }) - .collect(); - if view.reviewed.len() != stored.len() { - crate::review::store_marks(&view.root, &view.head_key, &view.reviewed); - } - view.recompute_matches(); + let mut view = DiffView::new(path, branch, Vec::new(), true); + view.jobs = app.view_jobs.clone(); + view.files_width = app.diff_files_width; + crate::git_diff::fill_view(&mut view, listing); + app.overlay = Some(Overlay::Diff(view)); } /// Fuzzy file finder over every tracked + untracked file of the selected @@ -3164,12 +3290,36 @@ fn open_file_finder(app: &mut App) { let Some((path, branch)) = selected_checkout(app) else { return; }; + // The modal is up on this keypress, taking what is typed; the list + // lands when `git ls-files` answers (`land_view_answer`). + if let Some(jobs) = app.view_jobs.clone() { + let editor = crate::config::Config::load().editor_command(); + let ticket = request_worktree_files(&jobs, &path); + app.overlay = Some(Overlay::Files(FileFinder::opening( + path, branch, editor, ticket, + ))); + return; + } let Some((files, editor)) = load_worktree_files(app, &path, &branch) else { return; }; app.overlay = Some(Overlay::Files(FileFinder::new(path, branch, editor, files))); } +/// Ask for a checkout's file listing off the loop; the ticket is what the +/// modal opened ahead of it waits on. +fn request_worktree_files(jobs: &crate::view_jobs::Jobs, path: &std::path::Path) -> u64 { + let ticket = crate::view_jobs::ticket(); + let root = path.to_path_buf(); + jobs.run(move || { + Some(crate::view_jobs::Answer::Files { + ticket, + result: crate::git_diff::list_files(&root), + }) + }); + ticket +} + /// Tree browser (`b`): full file tree of the selected worktree with a /// content preview, filterable by file name. Same shell as `open_diff_view`: /// flash instead of opening when there's no worktree, the path is gone, or @@ -3178,6 +3328,15 @@ fn open_tree_browser(app: &mut App) { let Some((path, branch)) = selected_checkout(app) else { return; }; + // Up on this keypress; the tree lands when `git ls-files` answers. + if let Some(jobs) = app.view_jobs.clone() { + let editor = crate::config::Config::load().editor_command(); + let ticket = request_worktree_files(&jobs, &path); + app.overlay = Some(Overlay::Tree(TreeBrowser::opening( + path, branch, editor, jobs, ticket, + ))); + return; + } let Some((files, editor)) = load_worktree_files(app, &path, &branch) else { return; }; @@ -3191,7 +3350,128 @@ fn open_grep_view(app: &mut App) { return; }; let editor = crate::config::Config::load().editor_command(); - app.overlay = Some(Overlay::Grep(GrepView::new(path, branch, editor))); + let mut view = GrepView::new(path, branch, editor); + view.jobs = app.view_jobs.clone(); + app.overlay = Some(Overlay::Grep(view)); +} + +/// A BACKGROUND READ came back (`view_jobs`): hand it to the view that +/// asked, if that view is still the one on screen. Every answer carries +/// the ticket its view is waiting on, so one that outlived its modal — or +/// its query, or its cursor — is dropped by the view itself. +fn land_view_answer(app: &mut App, answer: crate::view_jobs::Answer) { + use crate::view_jobs::Answer; + match answer { + Answer::Grep { ticket, result } => { + if let Some(Overlay::Grep(view)) = &mut app.overlay { + view.land(ticket, result); + } + } + Answer::Files { ticket, result } => land_worktree_files(app, ticket, result), + Answer::DiffListing { ticket, result } => land_diff_listing(app, ticket, result), + Answer::DiffText { + view: id, + ticket, + path, + diff, + prefetch, + } => { + if let Some(Overlay::Diff(view)) = &mut app.overlay { + crate::git_diff::land_diff(view, id, ticket, &path, diff, prefetch); + } + } + Answer::Preview { ticket, preview } => match &mut app.overlay { + Some(Overlay::Tree(view)) => view.land_preview(ticket, *preview), + Some(Overlay::FileTabs(view)) => view.land_preview(ticket, *preview), + _ => {} + }, + Answer::ClipboardViaTerminal { payload, flash } => { + app.pending_clipboard = Some(payload); + app.flash = Some(flash); + } + Answer::Flash(message) => app.flash = Some(message), + Answer::ClientRss(bytes) => land_client_rss(app, bytes), + Answer::Slow { ticket } => match &mut app.overlay { + Some(Overlay::Diff(view)) => crate::git_diff::diff_slow(view, ticket), + Some(Overlay::Tree(view)) => view.preview_slow(ticket), + Some(Overlay::FileTabs(view)) => view.preview_slow(ticket), + _ => {} + }, + } + app.dirty = true; +} + +/// `git ls-files` came back for the FILE FINDER or the TREE BROWSER that +/// opened ahead of it. A checkout with nothing to list, or one git could +/// not list, closes the modal with the reason — what `f` and `b` used to +/// say instead of opening. +fn land_worktree_files(app: &mut App, ticket: u64, result: Result, String>) { + let branch = match &app.overlay { + Some(Overlay::Files(finder)) if finder.listing == Some(ticket) => finder.branch.clone(), + Some(Overlay::Tree(view)) if view.listing == Some(ticket) => view.branch.clone(), + _ => return, + }; + let files = match result { + Ok(files) if !files.is_empty() => files, + Ok(_) => { + app.overlay = None; + app.flash = Some(format!("no files in {branch}")); + return; + } + Err(msg) => { + app.overlay = None; + app.flash = Some(msg); + return; + } + }; + match &mut app.overlay { + Some(Overlay::Files(finder)) => finder.set_files(files), + Some(Overlay::Tree(view)) => view.set_files(files), + _ => {} + } +} + +/// `git status` came back for a `g`: fill the DIFF VIEWER that opened ahead +/// of it — or close it, saying why, when there is nothing to show — or, for +/// the checkout that was told "no changes" off the badge, open it after all +/// when git found some and nothing else has taken the screen since. +fn land_diff_listing( + app: &mut App, + ticket: u64, + result: Result, +) { + let probe = match &app.diff_probe { + Some((probed, ..)) if *probed == ticket => app.diff_probe.take(), + _ => None, + }; + if let Some((_, path, branch)) = probe { + if let Ok(listing) = result { + if !listing.files.is_empty() && app.overlay.is_none() && app.vim.is_none() { + app.flash = None; + show_diff_listing(app, path, branch, listing); + } + } + return; + } + let branch = match &app.overlay { + Some(Overlay::Diff(view)) if view.listing == Some(ticket) => view.branch.clone(), + _ => return, + }; + match result { + Ok(listing) if !listing.files.is_empty() => { + if let Some(Overlay::Diff(view)) = &mut app.overlay { + crate::git_diff::fill_view(view, listing); + } + } + Ok(_) => { + app.overlay = None; + app.flash = Some(format!("no changes in {branch}")); + } + Err(msg) => { + app.overlay = None; + app.flash = Some(msg); + } + } } /// Enter on a grep hit: spawn the editor at `path:line` inside the modal @@ -3448,10 +3728,7 @@ fn archive_agent(app: &mut App, id: AgentId, out: &mut Vec) { /// dialog's Enter with it on. fn archive_agent_now(app: &mut App, id: AgentId, out: &mut Vec) { detach_if_attached(app, &SessionRef::Agent(id.clone()), out); - send(app, out, |req_id| ClientRequest::ArchiveAgent { - req_id, - id, - }); + optimistic::set_archived(app, id, true, out); } /// The confirm before an agent is archived, when the setting asks for @@ -5843,28 +6120,9 @@ fn submit_prompt(app: &mut App, prompt: PromptDialog, out: &mut Vec { - send(app, out, |req_id| ClientRequest::RenameAgent { - req_id, - id, - name: value, - }); - } - PromptKind::RenameTerminal { id } => { - send(app, out, |req_id| ClientRequest::RenameTerminal { - req_id, - id, - name: value, - }); - } - PromptKind::RenameProject { id } => { - let req_id = app.alloc_req_id(PendingIntent::None); - out.push(ClientRequest::RenameProject { - req_id, - id, - name: value, - }); - } + PromptKind::RenameAgent { id } => optimistic::rename_agent(app, id, value, out), + PromptKind::RenameTerminal { id } => optimistic::rename_terminal(app, id, value, out), + PromptKind::RenameProject { id } => optimistic::rename_project(app, id, value, out), PromptKind::NewWorkspace => { // Created from the switcher: open it as soon as the Ack lands. send_with(app, out, PendingIntent::OpenCreatedWorkspace, |req_id| { @@ -5874,13 +6132,7 @@ fn submit_prompt(app: &mut App, prompt: PromptDialog, out: &mut Vec { - send(app, out, |req_id| ClientRequest::RenameWorkspace { - req_id, - id, - name: value, - }); - } + PromptKind::RenameWorkspace { id } => optimistic::rename_workspace(app, id, value, out), PromptKind::SettingText { kind, project } => { // Same path as a toggled row (`apply_setting_at`): write the // file, adopt it live, and land back on the overlay — with the @@ -6005,16 +6257,13 @@ fn run_pending_action(app: &mut App, action: PendingAction, out: &mut Vec) { detach_if_attached(app, &SessionRef::Agent(id.clone()), out); - send(app, out, |req_id| ClientRequest::DeleteAgent { req_id, id }); + optimistic::delete_agent(app, id, out); } /// Close a terminal tab, detaching the pane first if it's showing it. fn close_terminal(app: &mut App, id: TerminalId, out: &mut Vec) { detach_if_attached(app, &SessionRef::Terminal(id.clone()), out); - send(app, out, |req_id| ClientRequest::CloseTerminal { - req_id, - id, - }); + optimistic::close_terminal(app, id, out); } /// Delete a worktree optimistically: drop its rows now (the daemon deletes @@ -7103,10 +7352,11 @@ fn send_attach(app: &mut App, sref: SessionRef, out: &mut Vec) { // starts exactly there (`AttachedTerm::apply_scrollback` appends it // onto the screen), or with the whole ring when that point has fallen // off, which rebuilds the screen as a first attach would. + // …unless the whole ring is the point: a history being brought back. let from_seq = app .term .as_ref() - .filter(|t| t.sref == sref && t.painted) + .filter(|t| t.sref == sref && t.painted && t.pending_scroll.is_none()) .map(|t| t.next_seq); app.attached_sref = Some(sref.clone()); out.push(ClientRequest::Attach { @@ -7117,6 +7367,33 @@ fn send_attach(app: &mut App, sref: SessionRef, out: &mut Vec) { }); } +/// The user scrolled up in a pane whose history was let go while its screen +/// sat in the cache (`AttachedTerm::drop_history`): ask the DAEMON for the +/// whole ring again. The replay rebuilds the screen and everything above +/// it, and lands the reader on `scroll` — the notch that asked. One wheel +/// notch late, once per return, is what the instant return costs. +fn rehydrate_history(app: &mut App, scroll: usize, out: &mut Vec) { + let Some(term) = &mut app.term else { + return; + }; + if !term.history_dropped { + return; + } + term.history_dropped = false; + term.pending_scroll = Some(scroll); + let sref = term.sref.clone(); + // Let go first, so the forwarder of the attachment being replaced is + // gone before the replay that supersedes it is sent. + if app.attached_sref.as_ref() == Some(&sref) { + app.attached_sref = None; + out.push(ClientRequest::Detach { + session: sref.clone(), + }); + } + app.pending_attach = None; + send_attach(app, sref, out); +} + /// Send the armed attach now — the selection settled, or something needs /// the session live this instant (a keystroke about to be forwarded). fn fire_pending_attach(app: &mut App, out: &mut Vec) { @@ -7604,12 +7881,28 @@ fn copy_and_flash(app: &mut App, text: &str, label: &str) { app.flash = Some(label.to_string()); return; } + let via_terminal = format!("{label} (via terminal)"); + // `pbcopy` is a process to start and a pasteboard server to reach — + // ten to twenty milliseconds the loop used to spend on mouse-up. It + // all but never fails here, so the flash says so now, and the rare + // failure falls back to the terminal's OSC 52 when it is known. + if let (false, Some(jobs)) = (app.is_remote, app.view_jobs.clone()) { + app.flash = Some(label.to_string()); + let text = text.to_string(); + jobs.run(move || { + (!copy_to_clipboard(&text)).then(|| crate::view_jobs::Answer::ClipboardViaTerminal { + payload: base64_encode(text.as_bytes()), + flash: via_terminal, + }) + }); + return; + } if !app.is_remote && copy_to_clipboard(text) { app.flash = Some(label.to_string()); return; } app.pending_clipboard = Some(base64_encode(text.as_bytes())); - app.flash = Some(format!("{label} (via terminal)")); + app.flash = Some(via_terminal); } /// Base64 (RFC 4648, padded) for OSC 52 payloads — one call site does not @@ -7695,13 +7988,9 @@ pub(crate) fn open_url(url: &str) -> bool { } #[cfg(target_os = "macos")] { - use std::process::{Command, Stdio}; - Command::new("open") - .arg(url) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok_and(|status| status.success()) + let mut open = std::process::Command::new("open"); + open.arg(url); + spawn_and_reap(open, "open url") } #[cfg(not(target_os = "macos"))] { @@ -7709,6 +7998,31 @@ pub(crate) fn open_url(url: &str) -> bool { } } +/// Start `command` and let it finish on its own: true once it is running. +/// `open` spends 50 to 150 ms talking to LaunchServices before it exits, +/// and the key that asked for a browser tab used to spend them with it — +/// the loop frozen, the flash unpainted. Whether it then succeeds is not +/// something the keypress can wait to learn; a failure is logged (the +/// `spawn_open_command` rule), and the reaper thread is what keeps the +/// child from lingering as a zombie. +fn spawn_and_reap(mut command: std::process::Command, what: &'static str) -> bool { + use std::process::Stdio; + let child = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + let Ok(mut child) = child else { + return false; + }; + std::thread::spawn(move || match child.wait() { + Ok(status) if !status.success() => tracing::warn!(what, %status, "open failed"), + Err(err) => tracing::warn!(what, %err, "open not reaped"), + Ok(_) => {} + }); + true +} + /// Two clicks on the same cell within this window make a double-click. const DOUBLE_CLICK: Duration = Duration::from_millis(400); @@ -8524,6 +8838,9 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, out: &mut Vec) term.scroll.saturating_sub(TERM_WHEEL_LINES) }; term.set_scroll(new_scroll); + if up { + rehydrate_history(app, new_scroll, out); + } } app.dirty = true; } @@ -8855,10 +9172,17 @@ fn handle_server_event(app: &mut App, event: ServerEvent, out: &mut Vec optimistic::settled(app, undo), _ => {} } app.dirty = true; } + // A straggler for a row deleted here a moment ago: the DAEMON sent + // it before it got to the delete, and the row stays down. + ServerEvent::EntityUpserted { entity } if optimistic::is_deleting(app, &entity) => { + tracing::debug!(?entity, "upsert of a row being deleted — ignored"); + } ServerEvent::EntityUpserted { entity } => { // A checkout cut for a PR SESSION takes over its stand-in row // first: the snapshot below names rows by id, and the @@ -8967,6 +9291,9 @@ fn handle_server_event(app: &mut App, event: ServerEvent, out: &mut Vec { restore_worktree_rows(app, rollback) } + // A rename, an archive or a delete shown on the keypress + // and then refused: the row goes back to what it was. + Some(PendingIntent::Undo(undo)) => optimistic::undo(app, undo, out), Some(PendingIntent::AttachCreatedWithCloudRetry { kind, task: text, @@ -9722,6 +10049,89 @@ mod tests { assert_eq!(names, ["agent-1", "agent-2"]); } + fn key(code: KeyCode, mods: KeyModifiers) -> Event { + Event::Key(KeyEvent::new(code, mods)) + } + + fn locked_pane_app() -> App { + let mut app = App::new(); + seed_tree(&mut app); + app.term = Some(AttachedTerm::new( + SessionRef::Agent(AgentId("a1".into())), + 40, + 10, + )); + app.focus = Focus::Terminal; + app.term_locked = true; + app.dirty = false; + app + } + + /// TYPING ECHO: a key that only goes to the PTY leaves the screen as it + /// was, so it asks for no frame — the frame that matters is the one + /// the PTY's answer asks for a moment later, and an identical one + /// painted first only stood in its way. + #[test] + fn a_key_that_only_goes_to_the_pty_asks_for_no_frame() { + let mut app = locked_pane_app(); + let mut out = Vec::new(); + handle_terminal_event( + &mut app, + key(KeyCode::Char('x'), KeyModifiers::NONE), + &mut out, + ); + assert!( + matches!(out.as_slice(), [ClientRequest::Input { .. }]), + "the key went to the PTY: {out:?}" + ); + assert!(!app.dirty, "and changed nothing on screen"); + } + + /// …but whatever a forwarded key takes down is a change, and is + /// painted: a flash, a selection highlight, a scrolled-back view. + #[test] + fn a_forwarded_key_that_clears_something_still_paints() { + let mut out = Vec::new(); + + let mut app = locked_pane_app(); + app.flash = Some("copied".into()); + handle_terminal_event( + &mut app, + key(KeyCode::Char('x'), KeyModifiers::NONE), + &mut out, + ); + assert!(app.dirty && app.flash.is_none(), "the flash came down"); + + let mut app = locked_pane_app(); + if let Some(term) = &mut app.term { + term.parser.process(&b"line\r\n".repeat(40)); + term.set_scroll(3); + assert!(term.scroll > 0); + } + handle_terminal_event( + &mut app, + key(KeyCode::Char('x'), KeyModifiers::NONE), + &mut out, + ); + assert!(app.dirty, "typing left the scrollback for the live edge"); + assert_eq!(app.term.as_ref().map(|t| t.scroll), Some(0)); + } + + /// The hatch out of the pane is not a forwarded key: FOCUS moves, and + /// that is a frame. + #[test] + fn the_unlock_key_in_a_locked_pane_paints() { + let mut app = locked_pane_app(); + let mut out = Vec::new(); + handle_terminal_event( + &mut app, + key(KeyCode::Char('q'), KeyModifiers::CONTROL), + &mut out, + ); + assert!(out.is_empty(), "nothing went to the PTY"); + assert!(!app.term_locked && app.dirty); + } + /// A turn that stops to ask in the pane the user is locked into typing /// at, terminal window focused, is already in front of them: nothing /// is queued. Previewing that pane from a panel (unlocked) still @@ -14873,7 +15283,8 @@ diff --git a/src/c.rs b/src/c.rs &mut app, ServerEvent::Output { session: sref, - seq: 27, + // Where the replay ended: bytes before it are the replay's. + seq: 30, data: b"!\r\nline2".to_vec(), }, ); @@ -17911,13 +18322,16 @@ diff --git a/src/c.rs b/src/c.rs ); } - /// The cache holds the last two screens shown, most recent first; an - /// older one is re-parsed on the way back like a first visit. + /// The cache holds the last few screens shown, most recent first; one + /// older than that is re-parsed on the way back like a first visit. #[test] - fn the_screen_cache_keeps_the_last_two_sessions() { + fn the_screen_cache_keeps_the_last_few_sessions() { let mut app = App::new(); seed_tree(&mut app); - for id in ["a2", "a3", "a4"] { + let ids: Vec = (1..=crate::app::TERM_CACHE_MAX + 3) + .map(|n| format!("a{n}")) + .collect(); + for id in &ids[1..] { hse( &mut app, ServerEvent::EntityUpserted { @@ -17926,8 +18340,8 @@ diff --git a/src/c.rs b/src/c.rs ); } let mut out = Vec::new(); - for id in ["a1", "a2", "a3", "a4"] { - let sref = SessionRef::Agent(AgentId(id.into())); + for id in &ids { + let sref = SessionRef::Agent(AgentId(id.clone())); attach_now(&mut app, sref.clone(), &mut out); hse( &mut app, @@ -17938,19 +18352,173 @@ diff --git a/src/c.rs b/src/c.rs }, ); } + // Everything but the one in the pane has been stashed; the newest + // TERM_CACHE_MAX of those are what is left, newest first. + let kept: Vec = ids[..ids.len() - 1] + .iter() + .rev() + .take(crate::app::TERM_CACHE_MAX) + .map(|id| SessionRef::Agent(AgentId(id.clone()))) + .collect(); assert_eq!( app.term_cache .iter() .map(|t| t.sref.clone()) .collect::>(), - [ - SessionRef::Agent(AgentId("a3".into())), - SessionRef::Agent(AgentId("a2".into())) - ], - "two kept, newest first; a1 was evicted" + kept, + "the newest few, newest first; the oldest were evicted" + ); + } + + /// A pane with `lines` lines of shell history above a prompt, attached + /// and painted, as the DAEMON's replay leaves it. + fn pane_with_history(app: &mut App, sref: &SessionRef, lines: usize) { + let mut out = Vec::new(); + attach_now(app, sref.clone(), &mut out); + let mut data = Vec::new(); + for n in 0..lines { + data.extend_from_slice(format!("line {n}\r\n").as_bytes()); + } + data.extend_from_slice(b"$ "); + hse( + app, + ServerEvent::Scrollback { + session: sref.clone(), + base_seq: 0, + data, + }, ); } + /// A screen whose history is over the budget used not to be kept, so + /// every return to a long-running shell re-parsed its whole ring. It is + /// kept now, without the history: the return paints what was on screen + /// on the keypress and asks only for what it missed. + #[test] + fn a_long_history_is_let_go_and_the_screen_still_returns_at_once() { + let mut app = App::new(); + seed_tree(&mut app); + hse( + &mut app, + ServerEvent::EntityUpserted { + entity: agent_entity("a2", "w1", "agent-2", false), + }, + ); + let a1 = SessionRef::Agent(AgentId("a1".into())); + let a2 = SessionRef::Agent(AgentId("a2".into())); + // Enough rows that the grid alone is over the whole budget. + let cols = pane_size(&app).0 as usize; + let lines = crate::app::TERM_CACHE_CELLS / cols + 50; + pane_with_history(&mut app, &a1, lines); + let seen = app.term.as_ref().expect("pane").next_seq; + + let mut out = Vec::new(); + attach_now(&mut app, a2.clone(), &mut out); + let kept = app.term_cache.first().expect("a1's screen is kept"); + assert!(kept.history_dropped, "without its history"); + assert_eq!(kept.parser.screen().scrollback_rows(), 0); + assert!( + kept.estimated_cells() <= crate::app::TERM_CACHE_CELLS, + "which is what fits it in the budget" + ); + + out.clear(); + attach_now(&mut app, a1.clone(), &mut out); + let pane = app.term.as_ref().expect("pane"); + assert!(pane.painted, "the screen is up on this frame"); + assert!( + pane.parser.screen().contents().contains("$ "), + "as it was left: {:?}", + pane.parser.screen().contents() + ); + assert!( + matches!( + out.last(), + Some(ClientRequest::Attach { from_seq: Some(n), .. }) if *n == seen + ), + "and only what it missed is asked for: {out:?}" + ); + } + + /// Scrolling up in a pane whose history was let go brings it back: the + /// attachment is dropped and re-made for the whole ring, and the replay + /// lands the reader on the notch that asked. + #[test] + fn scrolling_up_replays_a_history_that_was_let_go() { + let mut app = App::new(); + seed_tree(&mut app); + let a1 = SessionRef::Agent(AgentId("a1".into())); + pane_with_history(&mut app, &a1, 200); + let end = app.term.as_ref().expect("pane").next_seq; + app.term.as_mut().expect("pane").drop_history(); + assert!(app.term.as_ref().expect("pane").history_dropped); + + let mut out = Vec::new(); + rehydrate_history(&mut app, 3, &mut out); + assert!( + matches!( + out.as_slice(), + [ + ClientRequest::Detach { session }, + ClientRequest::Attach { from_seq: None, .. } + ] if *session == a1 + ), + "let go, then the whole ring: {out:?}" + ); + + // The whole ring again, as the DAEMON replays it. + let mut data = Vec::new(); + for n in 0..200 { + data.extend_from_slice(format!("line {n}\r\n").as_bytes()); + } + data.extend_from_slice(b"$ "); + assert_eq!(data.len() as u64, end); + hse( + &mut app, + ServerEvent::Scrollback { + session: a1.clone(), + base_seq: 0, + data, + }, + ); + let pane = app.term.as_ref().expect("pane"); + assert!(!pane.history_dropped && pane.pending_scroll.is_none()); + assert!( + pane.parser.screen().scrollback_rows() > 100, + "history is back" + ); + assert_eq!( + pane.scroll, 3, + "and the reader is where the wheel was headed" + ); + + // A second notch is an ordinary scroll: nothing more is asked. + out.clear(); + rehydrate_history(&mut app, 6, &mut out); + assert!(out.is_empty(), "{out:?}"); + } + + /// Output the replay already covered is not parsed a second time: a + /// frame the replaced attachment's forwarder had queued can cross the + /// replay that supersedes it. + #[test] + fn output_a_replay_covered_is_skipped() { + let sref = SessionRef::Agent(AgentId("a1".into())); + let mut term = AttachedTerm::new(sref, 40, 10); + term.apply_scrollback(0, b"hello world"); + // Wholly covered. + term.apply_output(6, b"world"); + // Half covered: only the tail is new. + term.apply_output(9, b"ld!"); + assert_eq!(term.next_seq, 12); + assert!( + term.parser.screen().contents().starts_with("hello world!"), + "{:?}", + term.parser.screen().contents() + ); + assert!(!term.parser.screen().contents().contains("worldworld")); + } + /// A session that leaves the tree takes its kept screen with it. #[test] fn a_removed_session_leaves_the_screen_cache() { @@ -24444,7 +25012,9 @@ diff --git a/src/c.rs b/src/c.rs /// Archiving the selected session lands the cursor on the next row AND /// attaches it — the pane must show the newly highlighted session, not - /// stay blank after the archive's detach. + /// stay blank after the archive's detach. All of it on the keypress + /// (an OPTIMISTIC UPDATE): the DAEMON's upsert, when it lands, finds + /// the row already archived and changes nothing. #[test] fn archiving_selected_agent_previews_the_next_row() { let mut app = App::new(); @@ -24475,16 +25045,8 @@ diff --git a/src/c.rs b/src/c.rs "a requests the archive: {out:?}" ); - // The daemon's upsert flips the archived flag; the row leaves the - // list, the cursor lands on agent-2, and agent-2 gets shown. - out.clear(); - handle_server_event( - &mut app, - ServerEvent::EntityUpserted { - entity: agent_entity("a1", "w1", "agent-1", true), - }, - &mut out, - ); + // The row has left the list already: the cursor is on agent-2, and + // agent-2 is what the pane shows. let a2 = SessionRef::Agent(AgentId("a2".into())); assert_eq!( app.selected_session().map(|a| a.name), @@ -24498,9 +25060,21 @@ diff --git a/src/c.rs b/src/c.rs ); assert_eq!( app.term.as_ref().map(|t| t.sref.clone()), - Some(a2), + Some(a2.clone()), "the pane shows the newly highlighted session" ); + + // The daemon's upsert says what the screen already does. + out.clear(); + handle_server_event( + &mut app, + ServerEvent::EntityUpserted { + entity: agent_entity("a1", "w1", "agent-1", true), + }, + &mut out, + ); + assert!(out.is_empty(), "nothing left to do: {out:?}"); + assert_eq!(app.term.as_ref().map(|t| t.sref.clone()), Some(a2)); } /// With `confirm_on_archive` on, `a` asks first: nothing is sent until diff --git a/crates/nebula-tui/src/event_loop/activate.rs b/crates/nebula-tui/src/event_loop/activate.rs index 31a11b73..b3a28b11 100644 --- a/crates/nebula-tui/src/event_loop/activate.rs +++ b/crates/nebula-tui/src/event_loop/activate.rs @@ -21,8 +21,8 @@ //! its own is the smell to look for in review. use super::{ - attach_now, jump_to_target, open_link, open_session, run_menu_action, send, Landing, - SettingsCmd, WORKTREE_STILL_CREATING, + attach_now, jump_to_target, open_link, open_session, run_menu_action, Landing, SettingsCmd, + WORKTREE_STILL_CREATING, }; use crate::app::{App, ConfirmDialog, DiffView, Focus, Overlay, PendingAction}; use nebula_core::{AgentId, ClientRequest, SessionRef, WorktreeId}; @@ -145,10 +145,7 @@ pub(super) fn attach(app: &mut App, sref: SessionRef, out: &mut Vec) { - send(app, out, |req_id| ClientRequest::UnarchiveAgent { - req_id, - id, - }); + super::optimistic::set_archived(app, id, false, out); } /// Ask before a checkout is deleted from disk — `d` on its row, **Delete diff --git a/crates/nebula-tui/src/event_loop/optimistic.rs b/crates/nebula-tui/src/event_loop/optimistic.rs new file mode 100644 index 00000000..fc8933c0 --- /dev/null +++ b/crates/nebula-tui/src/event_loop/optimistic.rs @@ -0,0 +1,450 @@ +//! OPTIMISTIC UPDATES for the verbs that change a row in place — rename, +//! archive, unarchive, delete a session, close a terminal. +//! +//! Each of them used to send its request and wait for the DAEMON to say the +//! row had changed. That answer is quick but not free — the INPUT LATENCY +//! PROBE put a delete's and an archive's at 13 ms, since the DAEMON sweeps +//! the process tree it is about to kill before it answers — and it is only +//! as quick as the DAEMON is idle: behind a `git worktree add`, a prewarm +//! sweep or a busy disk, the row the user just deleted sat there, still +//! selectable, until the answer came. +//! +//! Here the row changes on the keypress. The change is made by handing +//! [`handle_server_event`] the very event the DAEMON is about to broadcast +//! — the same upsert, the same removal — so cursors, the pane, the PALETTE +//! and every count move exactly as they will when the real one lands, which +//! then changes nothing. The request rides a `PendingIntent::Undo` holding +//! the row as it was: an Error puts it back (and flashes why, like every +//! other refusal), an Ack drops it. + +use super::{handle_server_event, send_with}; +use crate::app::{App, PendingIntent, Undo}; +use nebula_core::{ + AgentId, ClientRequest, Entity, EntityId, ProjectId, ServerEvent, TerminalId, WorkspaceId, +}; + +/// Show `entity` as the DAEMON will have it, and send `make`'s request with +/// `before` — the row as it is now — to put back if it is refused. +fn upsert( + app: &mut App, + before: Entity, + entity: Entity, + out: &mut Vec, + make: impl FnOnce(u64) -> ClientRequest, +) { + handle_server_event(app, ServerEvent::EntityUpserted { entity }, out); + let undo = PendingIntent::Undo(Undo::Restore(Box::new(before))); + send_with(app, out, undo, make); +} + +/// Take the row `id` down, and send `make`'s request with the row and where +/// it sat to put back if it is refused. Until the DAEMON answers, an upsert +/// of that row — one already on its way when the delete was asked for — is +/// ignored (`App::deleting`), so the row cannot flicker back. +fn remove( + app: &mut App, + id: EntityId, + index: usize, + entity: Entity, + out: &mut Vec, + make: impl FnOnce(u64) -> ClientRequest, +) { + app.deleting.insert(id.clone()); + handle_server_event(app, ServerEvent::EntityRemoved { id }, out); + let undo = PendingIntent::Undo(Undo::Reinsert { + index, + entity: Box::new(entity), + }); + send_with(app, out, undo, make); +} + +pub(super) fn rename_agent(app: &mut App, id: AgentId, name: String, out: &mut Vec) { + let make = |req_id| ClientRequest::RenameAgent { + req_id, + id: id.clone(), + name: name.clone(), + }; + // An empty name is the DAEMON's to refuse, in its own words. + match app.tree.agents.iter().find(|a| a.id == id) { + Some(before) if !name.trim().is_empty() => { + let mut after = before.clone(); + after.name = name.trim().to_string(); + upsert( + app, + Entity::Agent(before.clone()), + Entity::Agent(after), + out, + make, + ); + } + _ => send_with(app, out, PendingIntent::None, make), + } +} + +pub(super) fn rename_terminal( + app: &mut App, + id: TerminalId, + name: String, + out: &mut Vec, +) { + let make = |req_id| ClientRequest::RenameTerminal { + req_id, + id: id.clone(), + name: name.clone(), + }; + match app.tree.terminals.iter().find(|t| t.id == id) { + Some(before) if !name.trim().is_empty() => { + let mut after = before.clone(); + after.name = name.trim().to_string(); + upsert( + app, + Entity::Terminal(before.clone()), + Entity::Terminal(after), + out, + make, + ); + } + _ => send_with(app, out, PendingIntent::None, make), + } +} + +/// An empty name puts the row back on its folder's — the DAEMON's rule, +/// applied here the same way. +pub(super) fn rename_project( + app: &mut App, + id: ProjectId, + name: String, + out: &mut Vec, +) { + let make = |req_id| ClientRequest::RenameProject { + req_id, + id: id.clone(), + name: name.clone(), + }; + match app.tree.projects.iter().find(|p| p.id == id) { + Some(before) => { + let mut after = before.clone(); + after.name = match name.trim() { + "" => nebula_core::Project::folder_name(&before.repo_path), + typed => typed.to_string(), + }; + upsert( + app, + Entity::Project(before.clone()), + Entity::Project(after), + out, + make, + ); + } + None => send_with(app, out, PendingIntent::None, make), + } +} + +/// A name another workspace holds is refused by the DAEMON, and comes back. +pub(super) fn rename_workspace( + app: &mut App, + id: WorkspaceId, + name: String, + out: &mut Vec, +) { + let make = |req_id| ClientRequest::RenameWorkspace { + req_id, + id: id.clone(), + name: name.clone(), + }; + match app.tree.workspaces.iter().find(|w| w.id == id) { + Some(before) if !name.trim().is_empty() => { + let mut after = before.clone(); + after.name = name.trim().to_string(); + upsert( + app, + Entity::Workspace(before.clone()), + Entity::Workspace(after), + out, + make, + ); + } + _ => send_with(app, out, PendingIntent::None, make), + } +} + +/// Archive (`archived`) or unarchive an agent. An archived agent's process +/// is killed by the DAEMON, so its row goes down as not alive. +pub(super) fn set_archived( + app: &mut App, + id: AgentId, + archived: bool, + out: &mut Vec, +) { + let make = |req_id| match archived { + true => ClientRequest::ArchiveAgent { + req_id, + id: id.clone(), + }, + false => ClientRequest::UnarchiveAgent { + req_id, + id: id.clone(), + }, + }; + match app.tree.agents.iter().find(|a| a.id == id) { + Some(before) if before.archived != archived => { + let mut after = before.clone(); + after.archived = archived; + if archived { + after.alive = false; + after.archived_at = crate::app::now_ms(); + } + upsert( + app, + Entity::Agent(before.clone()), + Entity::Agent(after), + out, + make, + ); + } + _ => send_with(app, out, PendingIntent::None, make), + } +} + +pub(super) fn delete_agent(app: &mut App, id: AgentId, out: &mut Vec) { + let make = |req_id| ClientRequest::DeleteAgent { + req_id, + id: id.clone(), + }; + match app.tree.agents.iter().position(|a| a.id == id) { + Some(index) => { + let row = Entity::Agent(app.tree.agents[index].clone()); + remove(app, EntityId::Agent(id.clone()), index, row, out, make); + } + None => send_with(app, out, PendingIntent::None, make), + } +} + +pub(super) fn close_terminal(app: &mut App, id: TerminalId, out: &mut Vec) { + let make = |req_id| ClientRequest::CloseTerminal { + req_id, + id: id.clone(), + }; + match app.tree.terminals.iter().position(|t| t.id == id) { + Some(index) => { + let row = Entity::Terminal(app.tree.terminals[index].clone()); + remove(app, EntityId::Terminal(id.clone()), index, row, out, make); + } + None => send_with(app, out, PendingIntent::None, make), + } +} + +fn id_of(entity: &Entity) -> EntityId { + match entity { + Entity::Workspace(w) => EntityId::Workspace(w.id.clone()), + Entity::Project(p) => EntityId::Project(p.id.clone()), + Entity::Worktree(w) => EntityId::Worktree(w.id.clone()), + Entity::Agent(a) => EntityId::Agent(a.id.clone()), + Entity::Terminal(t) => EntityId::Terminal(t.id.clone()), + Entity::Link(l) => EntityId::Link(l.id.clone()), + } +} + +/// Is this upsert for a row that was deleted here and whose delete the +/// DAEMON has not answered yet? Then it was on its way before the delete +/// was, and showing it would bring the row back for a frame. +pub(super) fn is_deleting(app: &App, entity: &Entity) -> bool { + !app.deleting.is_empty() && app.deleting.contains(&id_of(entity)) +} + +/// The DAEMON did it: the row as it was is no longer needed. +pub(super) fn settled(app: &mut App, undo: Undo) { + if let Undo::Reinsert { entity, .. } = undo { + app.deleting.remove(&id_of(&entity)); + } +} + +/// The DAEMON refused: the row goes back to what — and where — it was. +pub(super) fn undo(app: &mut App, undo: Undo, out: &mut Vec) { + let entity = match undo { + Undo::Restore(entity) => *entity, + Undo::Reinsert { index, entity } => { + app.deleting.remove(&id_of(&entity)); + // In its old place, so the list reads as it did; the upsert + // below then finds the row and only refreshes it. + match &*entity { + Entity::Agent(a) => { + let at = index.min(app.tree.agents.len()); + app.tree.agents.insert(at, a.clone()); + } + Entity::Terminal(t) => { + let at = index.min(app.tree.terminals.len()); + app.tree.terminals.insert(at, t.clone()); + } + _ => {} + } + *entity + } + }; + handle_server_event(app, ServerEvent::EntityUpserted { entity }, out); +} + +#[cfg(test)] +mod tests { + use super::super::tests::{hse, seed_tree}; + use super::*; + use crate::app::Focus; + + fn req_id_of(out: &[ClientRequest]) -> u64 { + out.iter() + .find_map(|r| match r { + ClientRequest::RenameAgent { req_id, .. } + | ClientRequest::ArchiveAgent { req_id, .. } + | ClientRequest::UnarchiveAgent { req_id, .. } + | ClientRequest::DeleteAgent { req_id, .. } => Some(*req_id), + _ => None, + }) + .expect("the request went out") + } + + fn agent_name(app: &App, id: &str) -> Option { + app.tree + .agents + .iter() + .find(|a| a.id.0 == id) + .map(|a| a.name.clone()) + } + + /// A rename shows on the keypress, the DAEMON's refusal puts the old + /// name back, and its say-so is what the footer reads. + #[test] + fn a_rename_shows_at_once_and_a_refusal_puts_the_name_back() { + let mut app = App::new(); + seed_tree(&mut app); // p1 / w1(main) / a1 + let was = agent_name(&app, "a1").expect("a1 is seeded"); + let mut out = Vec::new(); + + rename_agent( + &mut app, + AgentId("a1".into()), + " fix login ".into(), + &mut out, + ); + assert_eq!( + agent_name(&app, "a1").as_deref(), + Some("fix login"), + "the new name, trimmed as the DAEMON will trim it, before any answer" + ); + + hse( + &mut app, + ServerEvent::Error { + req_id: Some(req_id_of(&out)), + message: "name is taken".into(), + }, + ); + assert_eq!(agent_name(&app, "a1"), Some(was), "the refusal undid it"); + assert_eq!(app.flash.as_deref(), Some("name is taken")); + } + + /// An empty name is refused by the DAEMON, in its words: nothing is + /// shown for it, and the request still goes out to be refused. + #[test] + fn an_empty_rename_changes_nothing_here() { + let mut app = App::new(); + seed_tree(&mut app); + let was = agent_name(&app, "a1"); + let mut out = Vec::new(); + rename_agent(&mut app, AgentId("a1".into()), " ".into(), &mut out); + assert_eq!(agent_name(&app, "a1"), was); + assert!(matches!( + out.as_slice(), + [ClientRequest::RenameAgent { .. }] + )); + } + + /// A deleted session's row is gone on the keypress; an upsert of it that + /// was already on its way does not bring it back; the Ack ends that. + #[test] + fn a_deleted_row_stays_down_until_the_daemon_answers() { + let mut app = App::new(); + seed_tree(&mut app); + app.focus = Focus::Sessions; + let straggler = Entity::Agent(app.tree.agents[0].clone()); + let mut out = Vec::new(); + + delete_agent(&mut app, AgentId("a1".into()), &mut out); + assert!(agent_name(&app, "a1").is_none(), "gone before any answer"); + + hse( + &mut app, + ServerEvent::EntityUpserted { + entity: straggler.clone(), + }, + ); + assert!( + agent_name(&app, "a1").is_none(), + "an upsert sent before the delete was handled is not a resurrection" + ); + + hse( + &mut app, + ServerEvent::Ack { + req_id: req_id_of(&out), + created: None, + }, + ); + assert!(app.deleting.is_empty(), "the Ack ends the wait"); + } + + /// A delete the DAEMON refuses puts the row back where it was. + #[test] + fn a_refused_delete_puts_the_row_back_in_its_place() { + let mut app = App::new(); + seed_tree(&mut app); + let mut second = app.tree.agents[0].clone(); + second.id = AgentId("a2".into()); + second.name = "agent-2".into(); + app.tree.agents.push(second); + let order = + |app: &App| -> Vec { app.tree.agents.iter().map(|a| a.id.0.clone()).collect() }; + let before = order(&app); + let mut out = Vec::new(); + + delete_agent(&mut app, AgentId("a1".into()), &mut out); + assert_eq!(order(&app), vec!["a2".to_string()]); + + hse( + &mut app, + ServerEvent::Error { + req_id: Some(req_id_of(&out)), + message: "database is locked".into(), + }, + ); + assert_eq!(order(&app), before, "back, and first again"); + assert!(app.deleting.is_empty()); + } + + /// Unarchive is the archive's mirror: the row is back among the live + /// sessions on the keypress. + #[test] + fn archive_and_unarchive_flip_the_row_at_once() { + let mut app = App::new(); + seed_tree(&mut app); + let archived = |app: &App| app.tree.agents[0].archived; + let mut out = Vec::new(); + + set_archived(&mut app, AgentId("a1".into()), true, &mut out); + assert!(archived(&app) && !app.tree.agents[0].alive); + set_archived(&mut app, AgentId("a1".into()), false, &mut out); + assert!(!archived(&app)); + let sent: Vec<&str> = out + .iter() + .filter_map(|r| match r { + ClientRequest::ArchiveAgent { .. } => Some("archive"), + ClientRequest::UnarchiveAgent { .. } => Some("unarchive"), + _ => None, + }) + .collect(); + assert_eq!( + sent, + ["archive", "unarchive"], + "both still asked of the DAEMON" + ); + } +} diff --git a/crates/nebula-tui/src/event_loop/pacing.rs b/crates/nebula-tui/src/event_loop/pacing.rs new file mode 100644 index 00000000..4244ba54 --- /dev/null +++ b/crates/nebula-tui/src/event_loop/pacing.rs @@ -0,0 +1,182 @@ +//! FRAME PACING: when the loop may paint again. +//! +//! A flat "one frame per 16 ms" cap is right for a PTY streaming output — +//! bursts coalesce, the CPU stays idle — and wrong for a keypress. The key +//! paints its own frame at once, and the thing it asked for (a PTY echo, +//! the DAEMON's Ack, a replay) lands two to five milliseconds later, just +//! inside that frame's 16 ms shadow: measured with the INPUT LATENCY PROBE, +//! a typed character took 23 ms to show in a locked pane, 20 of them spent +//! waiting out the cap, and a rename, an archive and a new terminal each +//! painted their answer a whole interval late the same way. +//! +//! So the cap is a token bucket rather than a metronome: [`BURST`] frames +//! may follow each other [`MIN_GAP`] apart, and a token comes back every +//! [`FRAME_INTERVAL`]. An idle app — which is what a keypress finds — +//! has a full bucket, so the key's frame and its answer's frame go out +//! back to back; sustained output drains the bucket and paints at exactly +//! the old 60 fps, so the streaming cost is what it was plus `BURST - 1` +//! frames at the head of each burst. + +use std::time::Duration; +use tokio::time::Instant; + +/// Steady-state redraw cap (~60 fps): how often a spent token comes back. +pub(super) const FRAME_INTERVAL: Duration = Duration::from_millis(16); + +/// Frames an idle loop may paint back to back: the key's own, its +/// answer's, and one for an answer that arrives in two parts (an Ack, then +/// the replay it set off). +const BURST: u32 = 3; + +/// The least time between two frames of a burst — long enough that the +/// events of one answer (Ack, upsert and Scrollback leave the DAEMON as +/// three writes) are all in hand before the frame that shows them, short +/// enough to be invisible. +const MIN_GAP: Duration = Duration::from_millis(2); + +pub(super) struct FramePacer { + tokens: u32, + /// When the token being earned started accruing. + since: Instant, +} + +impl FramePacer { + pub(super) fn new(now: Instant) -> Self { + Self { + tokens: BURST, + since: now, + } + } + + fn refill(&mut self, now: Instant) { + if self.tokens >= BURST { + self.since = now; + return; + } + let earned = (now.saturating_duration_since(self.since).as_micros() + / FRAME_INTERVAL.as_micros()) as u32; + if earned == 0 { + return; + } + self.tokens = (self.tokens + earned).min(BURST); + self.since = if self.tokens >= BURST { + now + } else { + self.since + FRAME_INTERVAL * earned + }; + } + + /// A frame was just painted, its draw taking `took` and finishing at + /// `now`: when the next may be. Never sooner than the draw itself took, + /// so a frame that is slow to paint (a huge window, an unoptimised + /// build) leaves the loop at least half its time for everything else. + pub(super) fn drew(&mut self, now: Instant, took: Duration) -> Instant { + self.refill(now); + self.tokens = self.tokens.saturating_sub(1); + let gap = now + took.max(MIN_GAP); + if self.tokens > 0 { + gap + } else { + gap.max(self.since + FRAME_INTERVAL) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ms(n: u64) -> Duration { + Duration::from_millis(n) + } + + /// The case the bucket exists for: a key's frame, then its answer's a + /// few milliseconds later, with no 16 ms wait between them. + #[test] + fn an_idle_loop_paints_a_key_and_its_answer_back_to_back() { + let t0 = Instant::now(); + let mut pacer = FramePacer::new(t0); + let next = pacer.drew(t0, ms(1)); + assert_eq!(next, t0 + MIN_GAP, "the answer's frame waits only the gap"); + let next = pacer.drew(t0 + ms(4), ms(1)); + assert_eq!( + next, + t0 + ms(4) + MIN_GAP, + "and so does a two-part answer's" + ); + } + + /// Sustained output must cost what it always has: once the burst is + /// spent, frames are a full interval apart. + #[test] + fn sustained_output_settles_at_the_frame_interval() { + let t0 = Instant::now(); + let mut pacer = FramePacer::new(t0); + let mut now = t0; + let mut painted = Vec::new(); + // Always dirty: paint the moment the pacer allows. + for _ in 0..40 { + painted.push(now); + now = pacer.drew(now, ms(1)); + } + let tail: Vec = painted[BURST as usize..] + .windows(2) + .map(|w| w[1] - w[0]) + .collect(); + assert!( + tail.iter().all(|gap| *gap == FRAME_INTERVAL), + "after the burst every frame is one interval apart: {tail:?}" + ); + let span = *painted.last().unwrap() - t0; + let budget = FRAME_INTERVAL * (painted.len() as u32 - BURST); + assert!( + span >= budget, + "40 frames took {span:?}, under the {budget:?} a 60 fps cap allows" + ); + } + + /// The bucket refills while nothing paints, so the next keypress after + /// a streaming burst gets its back-to-back frames again. + #[test] + fn a_quiet_spell_refills_the_burst() { + let t0 = Instant::now(); + let mut pacer = FramePacer::new(t0); + let mut now = t0; + for _ in 0..10 { + now = pacer.drew(now, ms(1)); + } + let later = now + FRAME_INTERVAL * BURST; + assert_eq!(pacer.drew(later, ms(1)), later + MIN_GAP); + assert_eq!(pacer.drew(later + ms(3), ms(1)), later + ms(3) + MIN_GAP); + } + + /// A frame that comes late does not reset the clock on the token being + /// earned: the cadence stays on the interval's grid, so output that + /// stutters still averages 60 fps rather than drifting under it. + #[test] + fn a_partly_earned_token_keeps_accruing() { + let t0 = Instant::now(); + let mut pacer = FramePacer::new(t0); + let mut now = t0; + for _ in 0..BURST { + now = pacer.drew(now, ms(1)); + } + assert_eq!( + now, + t0 + FRAME_INTERVAL, + "drained: the next token's arrival" + ); + // Painted 4 ms after it was allowed to. + let next = pacer.drew(now + ms(4), ms(1)); + assert_eq!(next, t0 + FRAME_INTERVAL * 2, "the 4 ms still count"); + } + + /// A draw slower than the gap sets the gap: the loop is never painting + /// more than half the time. + #[test] + fn a_slow_draw_is_followed_by_as_long_a_pause() { + let t0 = Instant::now(); + let mut pacer = FramePacer::new(t0); + assert_eq!(pacer.drew(t0 + ms(30), ms(30)), t0 + ms(60)); + } +} diff --git a/crates/nebula-tui/src/file_tabs.rs b/crates/nebula-tui/src/file_tabs.rs index dc2340bf..ac4fc685 100644 --- a/crates/nebula-tui/src/file_tabs.rs +++ b/crates/nebula-tui/src/file_tabs.rs @@ -17,9 +17,9 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, use ratatui::layout::{Position, Rect}; use crate::app::{App, Overlay}; -use crate::markdown::{self, Rendered}; +use crate::markdown::Rendered; use crate::syntax::{Highlighter, TokenKind}; -use crate::tree_browser::read_preview; +use crate::tree_browser::Preview; /// Widest a tab label gets; a handful have to fit side by side, and the /// tail of a path is the part that tells files apart. @@ -80,11 +80,31 @@ pub struct FileTabsView { /// The preview pane's inner rect, written back during draw; the /// embedded editor spawns and renders at this size. pub body_area: Rect, + /// BACKGROUND READS (`view_jobs`): with it, a tab's file is read and + /// highlighted off the loop — the TREE BROWSER's rule, and its reader; + /// without (a view built by a test), inline. + pub jobs: Option, + /// The preview in flight, by ticket; the pane keeps the last tab's + /// meanwhile (`view_jobs::STALE_GRACE`). + pub waiting: Option, + /// Stops the read in flight when the tab changes again. + pub cancel: crate::view_jobs::Cancel, } impl FileTabsView { /// Tabs on `paths` in the order given, the first one previewed. pub fn new(root: PathBuf, editor: String, paths: Vec) -> Self { + Self::with_jobs(root, editor, paths, None) + } + + /// [`FileTabsView::new`], reading its files off the loop when there + /// are BACKGROUND READS to read them with. + pub fn with_jobs( + root: PathBuf, + editor: String, + paths: Vec, + jobs: Option, + ) -> Self { let tabs = paths .into_iter() .map(|path| FileTab { @@ -110,6 +130,9 @@ impl FileTabsView { area: Rect::default(), tab_hits: Vec::new(), body_area: Rect::default(), + jobs, + waiting: None, + cancel: crate::view_jobs::Cancel::default(), }; view.load_preview(); view @@ -133,28 +156,57 @@ impl FileTabsView { } /// Re-read the focused tab's file into the preview, scrolled to the top. + /// Off the loop when this view has BACKGROUND READS: the pane keeps + /// what it showed until the read lands ([`FileTabsView::land_preview`]) + /// or is slow ([`FileTabsView::preview_slow`]). pub fn load_preview(&mut self) { - self.scroll = 0; - let (text, is_file) = match self.selected() { - Some(tab) => match read_preview(&tab.path) { - Ok(text) => (text, true), - Err(message) => (message, false), - }, - None => ("(no files)".to_string(), false), + self.cancel.cancel(); + self.waiting = None; + let Some(path) = self.selected().map(|tab| tab.path.clone()) else { + self.set_preview(placeholder("(no files)")); + return; }; - let mut hl = match self.selected() { - Some(tab) if is_file => Highlighter::for_path(&tab.path.to_string_lossy()), - _ => Highlighter::plain(), + let Some(jobs) = self.jobs.clone() else { + self.set_preview(read_tab(&path, None).unwrap_or_default()); + return; }; - self.preview_is_file = is_file; - self.markdown = is_file - && self - .selected() - .is_some_and(|t| markdown::is_markdown_path(&t.path.to_string_lossy())); + let ticket = crate::view_jobs::ticket(); + self.waiting = Some(ticket); + self.cancel = crate::view_jobs::Cancel::default(); + let cancel = self.cancel.clone(); + jobs.run_with_grace(ticket, move || { + Some(crate::view_jobs::Answer::Preview { + ticket, + preview: Box::new(read_tab(&path, Some(&cancel))?), + }) + }); + } + + fn set_preview(&mut self, preview: Preview) { + self.scroll = 0; + self.preview_is_file = preview.is_file; + self.markdown = preview.markdown; self.rendered = None; - self.preview_lines = text.lines().map(|l| hl.line(l)).collect(); - self.preview_line_count = self.preview_lines.len(); - self.preview_text = text; + self.preview_line_count = preview.lines.len(); + self.preview_lines = preview.lines; + self.preview_text = preview.text; + } + + /// A background read came back: shown when it is the tab being waited + /// on, dropped when the strip has moved on since. + pub fn land_preview(&mut self, ticket: u64, preview: Preview) { + if self.waiting == Some(ticket) { + self.waiting = None; + self.set_preview(preview); + } + } + + /// The read in flight has outlasted the grace the last tab's text was + /// kept for: say so rather than leave one file under another's label. + pub fn preview_slow(&mut self, ticket: u64) { + if self.waiting == Some(ticket) { + self.set_preview(placeholder("loading…")); + } } /// The preview is the rendered markdown page rather than the source. @@ -193,6 +245,22 @@ impl FileTabsView { } } +/// A tab's file, read and highlighted — the TREE BROWSER's reader, on an +/// absolute path. +fn read_tab(path: &Path, cancel: Option<&crate::view_jobs::Cancel>) -> Option { + crate::tree_browser::file_preview(Path::new(""), &path.to_string_lossy(), cancel) +} + +fn placeholder(text: &str) -> Preview { + let mut hl = Highlighter::plain(); + Preview { + lines: text.lines().map(|l| hl.line(l)).collect(), + text: text.to_string(), + is_file: false, + markdown: false, + } +} + /// The strip's name for a file: its path under the checkout when it is /// there, else just the file name — cut from the front when long, since /// the tail is what tells `a/README.md` from `b/README.md`. @@ -225,7 +293,8 @@ pub(crate) fn open(app: &mut App, root: PathBuf, paths: Vec) { vim.embedded = false; } let editor = crate::config::Config::load().editor_command(); - app.overlay = Some(Overlay::FileTabs(FileTabsView::new(root, editor, paths))); + let view = FileTabsView::with_jobs(root, editor, paths, app.view_jobs.clone()); + app.overlay = Some(Overlay::FileTabs(view)); app.dirty = true; } diff --git a/crates/nebula-tui/src/fuzzy.rs b/crates/nebula-tui/src/fuzzy.rs index 3cb9e96a..6f926d61 100644 --- a/crates/nebula-tui/src/fuzzy.rs +++ b/crates/nebula-tui/src/fuzzy.rs @@ -1,299 +1,7 @@ -//! Minimal fzf-style fuzzy matcher for the diff-view file filter. -//! -//! Greedy leftmost subsequence match, case-insensitive. Scoring favors -//! consecutive runs and matches that start a path segment or word, which is -//! enough to float `src/server.rs` above `crates/serde_helpers.rs` for the -//! query "srv" without pulling in a matcher crate. -//! -//! Whitespace in a query splits it into independent terms, all of which must -//! match somewhere in the candidate, in any order (fzf's extended-search AND). -//! That is what lets `neb #10` find `nebula/#10 Credit Codex…` — a single -//! subsequence pass would demand a literal space between `neb` and `#10`. - -/// A successful match: the score (higher is better) and the ascending char -/// indices of `candidate` that matched, for highlighting. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FuzzyMatch { - pub score: i32, - pub positions: Vec, -} - -const CONSECUTIVE_BONUS: i32 = 8; -const BOUNDARY_BONUS: i32 = 6; - -/// Chars that start a new "word" in a path for the boundary bonus. -fn is_boundary(prev: Option) -> bool { - match prev { - None => true, - Some(c) => matches!(c, '/' | '\\' | '_' | '-' | '.' | ' '), - } -} - -/// Case-insensitive match of `query` inside `candidate`. -/// -/// The query is split on whitespace; every term must match `candidate` as a -/// subsequence, but the terms are matched independently and may appear in any -/// order. Returns None when some term never matches. An empty (or all -/// whitespace) query matches everything with score 0 and no positions. -/// -/// Each term runs one greedy pass from each occurrence of its first char and -/// keeps the best score, so "serv" prefers the `server` filename over a -/// scattered s…e…r…v through the directory prefix. -pub fn fuzzy_match(query: &str, candidate: &str) -> Option { - let cand: Vec = candidate.chars().collect(); - let mut score = 0i32; - let mut positions: Vec = Vec::new(); - for term in query.split_whitespace() { - let term: Vec = term.chars().map(|c| c.to_ascii_lowercase()).collect(); - let m = match_term(&term, &cand)?; - score += m.score; - positions.extend(m.positions); - } - // Terms match independently, so their spans can overlap and arrive out of - // order; highlighting wants one ascending, deduplicated run. - positions.sort_unstable(); - positions.dedup(); - Some(FuzzyMatch { score, positions }) -} - -/// Best subsequence match of one whitespace-free `term` (already lowercased) -/// anywhere in `cand`. -fn match_term(term: &[char], cand: &[char]) -> Option { - if term.is_empty() { - return Some(FuzzyMatch { - score: 0, - positions: Vec::new(), - }); - } - let mut best: Option = None; - for start in 0..cand.len() { - if cand[start].to_ascii_lowercase() != term[0] { - continue; - } - // A failed greedy pass from here also fails from every later start - // (its chars are a subset), so the first miss ends the search. - let Some(m) = greedy_from(term, cand, start) else { - break; - }; - if best.as_ref().is_none_or(|b| m.score > b.score) { - best = Some(m); - } - } - best -} - -/// One greedy leftmost pass over `cand[start..]`. -fn greedy_from(query: &[char], cand: &[char], start: usize) -> Option { - let mut positions = Vec::with_capacity(query.len()); - let mut score = 0i32; - let mut qi = 0; - let mut prev_matched = false; - for i in start..cand.len() { - if cand[i].to_ascii_lowercase() == query[qi] { - score += 1; - if prev_matched { - score += CONSECUTIVE_BONUS; - } - if is_boundary((i > 0).then(|| cand[i - 1])) { - score += BOUNDARY_BONUS; - } - positions.push(i); - prev_matched = true; - qi += 1; - if qi == query.len() { - return Some(FuzzyMatch { score, positions }); - } - } else { - prev_matched = false; - } - } - None -} - -/// Rank `candidates` against `query`: matching indices best-first, each with -/// its matched char positions. Score-sorted, ties broken by shorter text -/// then original order; an empty query keeps every candidate in original -/// order with no positions. -pub fn rank<'a, I>(query: &str, candidates: I) -> Vec<(usize, Vec)> -where - I: IntoIterator, -{ - // Whitespace-only counts as empty: every candidate scores 0, and sorting - // that by length would shuffle the list for a query that says nothing. - if query.split_whitespace().next().is_none() { - return candidates - .into_iter() - .enumerate() - .map(|(i, _)| (i, Vec::new())) - .collect(); - } - rank_by(query, candidates, |i, text| (text.chars().count(), i)) -} - -/// [`rank`] with the caller's own tiebreak: equal scores sort by ascending -/// `key(index, text)`, and an empty (or all-whitespace) query lists every -/// candidate in key order with no positions. For a list that has an order -/// of its own — the `/` PALETTE's attention order — the key keeps that -/// order wherever the score has nothing to say. -pub fn rank_by<'a, I, K>( - query: &str, - candidates: I, - key: impl Fn(usize, &str) -> K, -) -> Vec<(usize, Vec)> -where - I: IntoIterator, - K: Ord, -{ - if query.split_whitespace().next().is_none() { - let mut all: Vec<(K, usize)> = candidates - .into_iter() - .enumerate() - .map(|(i, text)| (key(i, text), i)) - .collect(); - all.sort_by(|a, b| a.0.cmp(&b.0)); - return all.into_iter().map(|(_, i)| (i, Vec::new())).collect(); - } - let mut scored: Vec<(i32, K, usize, Vec)> = candidates - .into_iter() - .enumerate() - .filter_map(|(i, text)| { - fuzzy_match(query, text).map(|m| (m.score, key(i, text), i, m.positions)) - }) - .collect(); - // Stable, so original order is the final fallback under an equal key. - scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); - scored.into_iter().map(|(_, _, i, p)| (i, p)).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_query_matches_everything() { - let m = fuzzy_match("", "anything").unwrap(); - assert_eq!(m.score, 0); - assert!(m.positions.is_empty()); - } - - #[test] - fn subsequence_matches_and_reports_positions() { - // Ties keep the leftmost start ("src…" here scores the same as the - // start at "server"). - let m = fuzzy_match("srv", "src/server.rs").unwrap(); - assert_eq!(m.positions, vec![0, 1, 7]); - } - - #[test] - fn best_start_prefers_the_filename_run() { - // Greedy from the leftmost 's' would scatter across "src/"; the - // best-of-starts pass lands on the consecutive "serv" in "server". - let m = fuzzy_match("serv", "src/server.rs").unwrap(); - assert_eq!(m.positions, vec![4, 5, 6, 7]); - } - - #[test] - fn missing_char_fails() { - assert!(fuzzy_match("xyz", "src/server.rs").is_none()); - assert!(fuzzy_match("abc", "ab").is_none()); - } - - #[test] - fn match_is_case_insensitive() { - assert!(fuzzy_match("READ", "readme.md").is_some()); - assert!(fuzzy_match("read", "README.md").is_some()); - } - - #[test] - fn consecutive_run_beats_scattered_match() { - let run = fuzzy_match("serv", "src/server.rs").unwrap(); - let scattered = fuzzy_match("serv", "s_e_r_v.rs").unwrap(); - assert!(run.score > scattered.score, "{run:?} vs {scattered:?}"); - } - - #[test] - fn segment_start_beats_mid_word() { - let boundary = fuzzy_match("ui", "src/ui.rs").unwrap(); - let mid = fuzzy_match("ui", "build.rs").unwrap(); - assert!(boundary.score > mid.score, "{boundary:?} vs {mid:?}"); - } - - #[test] - fn space_separated_terms_match_independently() { - // The reported case: one subsequence pass wants a literal space - // between "neb" and "#10", which the PR row does not have. - let m = fuzzy_match( - "neb #10", - "nebula/#10 Credit Codex and Cursor in the README", - ) - .unwrap(); - assert_eq!(m.positions, vec![0, 1, 2, 7, 8, 9]); - } - - #[test] - fn terms_may_appear_in_any_order() { - assert!(fuzzy_match("#10 neb", "nebula/#10 Credit Codex").is_some()); - assert!(fuzzy_match("requests show", "nebula/main/Show Open Pull Requests").is_some()); - } - - #[test] - fn every_term_must_match() { - assert!(fuzzy_match("neb #11", "nebula/#10 Credit Codex").is_none()); - assert!(fuzzy_match("neb zzz", "nebula/#10 Credit Codex").is_none()); - } - - #[test] - fn positions_are_ascending_and_deduped_across_overlapping_terms() { - // "ne" and "neb" both land on the same leading chars. - let m = fuzzy_match("ne neb", "nebula/main").unwrap(); - assert_eq!(m.positions, vec![0, 1, 2]); - } - - #[test] - fn whitespace_only_query_matches_everything_in_order() { - let m = fuzzy_match(" ", "anything").unwrap(); - assert_eq!(m.score, 0); - assert!(m.positions.is_empty()); - let ranked = rank(" ", vec!["a-longer-one", "ab"]); - assert_eq!(ranked, vec![(0, vec![]), (1, vec![])]); - } - - #[test] - fn trailing_space_behaves_like_the_bare_term() { - assert_eq!( - fuzzy_match("serv ", "src/server.rs"), - fuzzy_match("serv", "src/server.rs") - ); - } - - #[test] - fn multi_term_ranking_floats_the_row_that_matches_both() { - let rows = vec![ - "nebula/main/Show Open Pull Requests", - "nebula/worktree-readme-tweak/Readme Tweak Pull Request", - "nebula/#10 Credit Codex and Cursor in the README tagline", - ]; - let ranked = rank("neb #10", rows.clone()); - assert_eq!(ranked.len(), 1, "only the #10 row has both terms"); - assert_eq!(ranked[0].0, 2); - } - - #[test] - fn rank_by_lists_an_empty_query_in_key_order_and_breaks_ties_by_key() { - // Empty query: pure key order, no positions. - let ranked = rank_by("", vec!["b", "a", "c"], |i, _| [2usize, 0, 1][i]); - assert_eq!( - ranked, - vec![(1, vec![]), (2, vec![]), (0, vec![])], - "key order, not original order" - ); - // Equal scores: the key decides, not the text length. - let ranked = rank_by("main", vec!["demo/main", "demo/main/agent-1"], |i, _| { - [1usize, 0][i] - }); - assert_eq!(ranked[0].0, 1, "the longer row wins on key"); - // A better score still beats a better key. - let ranked = rank_by("read", vec!["feat/unread", "feat/read"], |i, _| i); - assert_eq!(ranked[0].0, 1, "the boundary match outranks the key"); - } -} +//! The fzf-style matcher behind every list filter — the FILE FINDER, the +//! TREE BROWSER, the DIFF VIEWER's file list, the `/` PALETTE, the BRANCH +//! SWITCHER. It lives in the `nebula-fuzzy` crate so that a dev build can +//! compile it optimised: it is the one loop in the TUI that runs over every +//! path of a checkout on every keystroke, and at this crate's opt-level 0 +//! that was 13 to 27 ms a character over ten thousand paths. +pub use nebula_fuzzy::*; diff --git a/crates/nebula-tui/src/git_diff.rs b/crates/nebula-tui/src/git_diff.rs index c936ac2a..cb4aff55 100644 --- a/crates/nebula-tui/src/git_diff.rs +++ b/crates/nebula-tui/src/git_diff.rs @@ -1,8 +1,12 @@ //! Git status/diff readers for the diff modal. //! -//! Synchronous `std::process` on purpose (the `pbcopy` precedent in -//! event_loop.rs): these run only on key events — opening the modal or -//! switching files — and per-file diffs are fast. +//! The readers are synchronous `std::process`; who calls them is what +//! changed. They used to run inside the key handler — opening the modal, +//! switching files — and the loop waited: 47 ms to open on a small checkout +//! and 11 ms per file walked under the INPUT LATENCY PROBE, 80 ms to over +//! a second for the `git status` alone on a large one. A view with +//! BACKGROUND READS (`view_jobs`) runs them on the blocking pool instead; +//! one without — every view a test builds — still calls them inline. use crate::app::DiffView; use std::path::Path; @@ -242,22 +246,180 @@ pub fn cap_lines(text: &str, max: usize, already_cut: bool) -> String { out } +/// Everything opening the DIFF VIEWER reads: the changed files, HEAD, and +/// the reviewed ✓ marks that still apply — each stored mark checked against +/// its file's diff as it is now, one `git diff` per mark, and the pruned +/// set written back. Off the loop for a view with BACKGROUND READS. +pub fn read_listing(root: &Path) -> Result { + let files = changed_files(root)?; + let head = head_oid(root); + let head_key = head.clone().unwrap_or_default(); + let stored = crate::review::load_marks(root, &head_key); + let reviewed: std::collections::HashMap = files + .iter() + .filter_map(|file| { + let mark = *stored.get(&file.path)?; + let diff = diff_for(root, file, head.is_some()); + (crate::review::fingerprint(&diff) == mark).then(|| (file.path.clone(), mark)) + }) + .collect(); + if reviewed.len() != stored.len() { + crate::review::store_marks(root, &head_key, &reviewed); + } + Ok(crate::view_jobs::DiffListing { + files, + head, + reviewed, + }) +} + +/// Put a listing into the view that was opened ahead of it (or built for +/// it): the files, HEAD, the marks — narrowed by whatever the filter holds +/// by now, reviewed files sunk, so the modal lands on the first unreviewed +/// file — and that file's diff. +/// +/// A view opened on the badge's list (`event_loop::open_diff_view`) already +/// shows files, and maybe a reader who has moved among them: they stay on +/// the file they are on, wherever the fresh list puts it, and its diff is +/// read again only if it is no longer the same entry. A reader who has not +/// moved gets what a fresh open gives — the first unreviewed file. +pub fn fill_view(view: &mut DiffView, listing: crate::view_jobs::DiffListing) { + let moved = view.selected != 0 || view.scroll != 0; + let before = view.selected_file().cloned(); + let head_ok = listing.head.is_some(); + let head_changed = !view.files.is_empty() && view.head_ok != head_ok; + view.head_ok = head_ok; + view.head_key = listing.head.unwrap_or_default(); + view.files = listing.files; + view.reviewed = listing.reviewed; + view.listing = None; + view.recompute_matches(); + view.selected = before + .as_ref() + .filter(|_| moved) + .and_then(|was| { + view.matches + .iter() + .position(|m| view.files[m.file].path == was.path) + }) + .unwrap_or(0); + // Diffs read against the wrong idea of HEAD (the badge's list cannot + // say whether there is one) are not worth keeping. + if head_changed { + view.cache.clear(); + } + if head_changed || view.selected_file() != before.as_ref() { + load_selected_diff(view); + } +} + /// Reload `view.diff` for the currently selected file and reset the scroll. /// A view whose diffs were fetched whole (a pull request) reads them out of /// `prefetched` instead of shelling out — there is no local commit to ask /// git about, and the text is already in hand. +/// +/// A view with BACKGROUND READS never waits on git here. A file this modal +/// has read before is on screen on this keypress, out of `DiffView::cache`, +/// and re-read behind it — an agent may have edited it since — with the +/// reader's place kept if the text changed. One it has not keeps the last +/// file's text up for `view_jobs::STALE_GRACE` while git runs, which is +/// longer than a diff takes; past that the pane says `loading…`. Either way +/// the answer comes back through [`land_diff`]. pub fn load_selected_diff(view: &mut DiffView) { - let diff = match (view.selected_file(), &view.prefetched) { - (Some(file), Some(chunks)) => chunks + view.waiting = None; + let Some(file) = view.selected_file().cloned() else { + view.show_diff(None, String::new(), false); + return; + }; + if let Some(chunks) = &view.prefetched { + let diff = chunks .get(&file.path) .cloned() - .unwrap_or_else(|| "(no diff for this file)".to_string()), - (Some(file), None) => diff_for(&view.root, file, view.head_ok), - (None, _) => String::new(), + .unwrap_or_else(|| "(no diff for this file)".to_string()); + view.show_diff(Some(&file.path), diff, false); + return; + } + let Some(jobs) = view.jobs.clone() else { + let diff = diff_for(&view.root, &file, view.head_ok); + view.show_diff(Some(&file.path), diff, false); + return; + }; + if let Some(text) = view.cached(&file.path) { + view.show_diff(Some(&file.path), text.to_string(), false); + } + let ticket = crate::view_jobs::ticket(); + view.waiting = Some(ticket); + request_diff(view, &jobs, file, ticket, false); +} + +fn request_diff( + view: &DiffView, + jobs: &crate::view_jobs::Jobs, + file: DiffFile, + ticket: u64, + prefetch: bool, +) { + let (root, head_ok, id) = (view.root.clone(), view.head_ok, view.id); + let work = move || { + Some(crate::view_jobs::Answer::DiffText { + view: id, + ticket, + diff: diff_for(&root, &file, head_ok), + path: file.path, + prefetch, + }) }; - view.diff_line_count = diff.lines().count(); - view.diff = diff; - view.scroll = 0; + if prefetch { + jobs.run(work); + } else { + jobs.run_with_grace(ticket, work); + } +} + +/// A background `git diff` came back. It is kept either way — it is the +/// freshest text there is for that file — and shown when it is the one the +/// cursor is waiting on. Then the row after the cursor is read ahead, once: +/// `↓` is the key this modal is walked with, and its next press finds the +/// text in hand. +pub fn land_diff( + view: &mut DiffView, + id: u64, + ticket: u64, + path: &str, + diff: String, + prefetch: bool, +) { + if id != view.id { + return; + } + view.cache_put(path, &diff); + if prefetch || view.waiting != Some(ticket) { + return; + } + view.waiting = None; + let same_file = view.shown.as_deref() == Some(path); + if !(same_file && view.diff == diff) { + view.show_diff(Some(path), diff, same_file); + } + let next = view + .matches + .get(view.selected + 1) + .and_then(|m| view.files.get(m.file)) + .filter(|file| view.cached(&file.path).is_none()) + .cloned(); + if let (Some(file), Some(jobs)) = (next, view.jobs.clone()) { + request_diff(view, &jobs, file, crate::view_jobs::ticket(), true); + } +} + +/// The diff in flight has outlasted the grace the last file's text was kept +/// for: say so, rather than leave one file's diff under another's name. +pub fn diff_slow(view: &mut DiffView, ticket: u64) { + if view.waiting == Some(ticket) + && view.shown.as_deref() != view.selected_file().map(|f| f.path.as_str()) + { + view.show_diff(None, "loading…".to_string(), false); + } } #[cfg(test)] @@ -265,6 +427,95 @@ mod tests { use super::*; use std::path::PathBuf; + fn modified(path: &str) -> DiffFile { + DiffFile { + path: path.into(), + orig_path: None, + xy: [' ', 'M'], + } + } + + fn listing(paths: &[&str], reviewed: &[&str]) -> crate::view_jobs::DiffListing { + crate::view_jobs::DiffListing { + files: paths.iter().map(|p| modified(p)).collect(), + head: Some("abc123".into()), + reviewed: reviewed.iter().map(|p| (p.to_string(), 1)).collect(), + } + } + + /// A view opened on the badge's list, as `g` leaves it: files up, the + /// `git status` that checks them still out. + fn opened_on(paths: &[&str]) -> DiffView { + // Not a repository: a diff read here is git's refusal, which is all + // these tests need it to be. + let mut view = DiffView::new( + std::env::temp_dir().join("nebula-no-such-checkout"), + "main".into(), + paths.iter().map(|p| modified(p)).collect(), + true, + ); + view.listing = Some(7); + view + } + + fn selected(view: &DiffView) -> Option<&str> { + view.selected_file().map(|f| f.path.as_str()) + } + + /// The fresh list lands under a reader who has moved: they stay on the + /// file they were reading, wherever it now sits. + #[test] + fn a_reader_who_moved_stays_on_their_file_when_the_fresh_list_lands() { + let mut view = opened_on(&["a.rs", "b.rs", "c.rs"]); + view.select(1); + view.show_diff(Some("b.rs"), "the diff of b".into(), false); + + fill_view(&mut view, listing(&["new.rs", "a.rs", "b.rs", "c.rs"], &[])); + assert_eq!(view.listing, None); + assert_eq!(selected(&view), Some("b.rs"), "now the third row"); + assert_eq!(view.diff, "the diff of b", "and it was not read again"); + } + + /// …and if that file is no longer changed, they are put on the first. + #[test] + fn a_file_that_left_the_list_hands_the_cursor_to_the_top() { + let mut view = opened_on(&["a.rs", "b.rs", "c.rs"]); + view.select(1); + fill_view(&mut view, listing(&["a.rs", "c.rs"], &[])); + assert_eq!(selected(&view), Some("a.rs")); + } + + /// A reader who has not moved gets what a fresh open gives: reviewed ✓ + /// files sunk, the cursor on the first that is not. + #[test] + fn an_unmoved_reader_lands_on_the_first_unreviewed_file() { + let mut view = opened_on(&["a.rs", "b.rs"]); + fill_view(&mut view, listing(&["a.rs", "b.rs"], &["a.rs"])); + assert_eq!(selected(&view), Some("b.rs")); + assert_eq!(view.head_key, "abc123"); + } + + /// The cache holds what the budget allows, newest kept, and never an + /// entry that is most of the budget by itself. + #[test] + fn the_diff_cache_stays_inside_its_budget() { + let mut view = opened_on(&["a.rs"]); + let chunk = "x".repeat(crate::app::DIFF_CACHE_ENTRY_MAX); + for n in 0..8 { + view.cache_put(&format!("f{n}.rs"), &chunk); + } + let held: usize = view.cache.iter().map(|(_, text)| text.len()).sum(); + assert!(held <= crate::app::DIFF_CACHE_BYTES, "{held} bytes held"); + assert!(view.cached("f7.rs").is_some(), "the newest is kept"); + assert!(view.cached("f0.rs").is_none(), "the oldest went first"); + + view.cache_put("huge.rs", &"y".repeat(crate::app::DIFF_CACHE_ENTRY_MAX + 1)); + assert!( + view.cached("huge.rs").is_none(), + "too big to be worth holding" + ); + } + #[test] fn cap_lines_keeps_the_head_and_marks_what_it_dropped() { assert_eq!(cap_lines("a\nb\nc", 5, false), "a\nb\nc"); diff --git a/crates/nebula-tui/src/grep_search.rs b/crates/nebula-tui/src/grep_search.rs index bf4e6374..6cc1862b 100644 --- a/crates/nebula-tui/src/grep_search.rs +++ b/crates/nebula-tui/src/grep_search.rs @@ -1,7 +1,10 @@ //! `git grep` runner for the find-in-files overlay. //! -//! Synchronous `std::process` on purpose (the git_diff.rs precedent): a -//! search runs only on key events, and `git grep` over a checkout is fast. +//! `search` runs the grep to its end and is what a view with no BACKGROUND +//! READS handle calls inline (the unit tests). The live modal calls +//! `search_streaming` off the loop (`view_jobs`): `git grep` over a +//! ten-thousand-file checkout is 200 ms, and it used to be spent inside the +//! key handler once per character typed. use crate::ui::truncate; use std::path::Path; @@ -25,17 +28,85 @@ pub struct GrepHit { pub text: String, } -/// Fixed-string smart-case search over the checkout (tracked + untracked, -/// gitignore respected, binaries skipped). Returns `(hits, truncated)`; -/// `Err` is a user-facing message shown inside the overlay. -pub fn search(root: &Path, query: &str) -> Result<(Vec, bool), String> { +/// The `git grep` arguments for `query`: fixed-string, smart-case, tracked +/// + untracked, gitignore respected, binaries skipped. +fn grep_args(query: &str) -> Vec<&str> { let mut args = vec!["grep", "-z", "-n", "-I", "--untracked", "--no-color", "-F"]; // Smart case: literal case only when the query has an uppercase char. if !query.chars().any(char::is_uppercase) { args.push("-i"); } args.extend(["-e", query, "--", "."]); - let output = crate::git_diff::run_git(root, &args)?; + args +} + +/// [`search`] for the live modal, run off the loop: the output is read as +/// git writes it and git is killed at the result cap — a two-letter query +/// in a big checkout matches a hundred thousand lines, and `search` reads +/// every one of them to keep two hundred — or as soon as `cancel` says the +/// query has moved on. `None` is that cancel: there is no answer to land. +pub fn search_streaming( + root: &Path, + query: &str, + cancel: &crate::view_jobs::Cancel, +) -> Option, bool), String>> { + use std::io::{BufRead, Read}; + let mut child = match crate::git_diff::git_command(root) + .args(grep_args(query)) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(e) => return Some(Err(format!("failed to run git: {e}"))), + }; + let mut hits = Vec::new(); + let mut truncated = false; + let mut reader = std::io::BufReader::new(child.stdout.take()?); + let mut record = Vec::new(); + loop { + record.clear(); + match reader.read_until(b'\n', &mut record) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + if cancel.is_cancelled() { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + let (mut parsed, _) = parse_grep_z(&record); + if parsed.is_empty() { + continue; + } + if hits.len() >= MAX_RESULTS { + truncated = true; + break; + } + hits.append(&mut parsed); + } + if truncated { + let _ = child.kill(); + let _ = child.wait(); + return Some(Ok((hits, true))); + } + let mut stderr = String::new(); + if let Some(mut pipe) = child.stderr.take() { + let _ = pipe.read_to_string(&mut stderr); + } + // git grep exits 1 for "no matches" — only >= 2 is an error. + match child.wait().ok().and_then(|status| status.code()) { + Some(0) | Some(1) => Some(Ok((hits, false))), + _ => Some(Err(format!("git grep failed: {}", stderr.trim()))), + } +} + +/// Fixed-string smart-case search over the checkout (tracked + untracked, +/// gitignore respected, binaries skipped). Returns `(hits, truncated)`; +/// `Err` is a user-facing message shown inside the overlay. +pub fn search(root: &Path, query: &str) -> Result<(Vec, bool), String> { + let output = crate::git_diff::run_git(root, &grep_args(query))?; // git grep exits 1 for "no matches" — only >= 2 is an error. match output.status.code() { Some(0) => Ok(parse_grep_z(&output.stdout)), diff --git a/crates/nebula-tui/src/lib.rs b/crates/nebula-tui/src/lib.rs index b8c38248..2639c95a 100644 --- a/crates/nebula-tui/src/lib.rs +++ b/crates/nebula-tui/src/lib.rs @@ -24,6 +24,7 @@ pub(crate) mod list_hit; pub mod markdown; pub mod overlay_close; pub mod palette; +pub mod perf; pub mod pr_cache; pub mod pr_preview; pub mod pr_row; @@ -40,6 +41,7 @@ pub mod theme; pub mod tree_browser; pub mod ui; pub mod update_check; +pub mod view_jobs; pub mod vim_term; use anyhow::Result; diff --git a/crates/nebula-tui/src/perf.rs b/crates/nebula-tui/src/perf.rs new file mode 100644 index 00000000..e6189a3c --- /dev/null +++ b/crates/nebula-tui/src/perf.rs @@ -0,0 +1,214 @@ +//! The INPUT LATENCY PROBE: `NEBULA_PERF_LOG=` makes the TUI write one +//! JSON line per input event, per painted frame and per daemon event, so +//! "does this key feel instant" is a number rather than an impression. +//! +//! * `input` — what arrived (`key:g`, `mouse:down`), how long its handler +//! held the loop (`handler_us`), and where that left the app (overlay, +//! FOCUS). A handler that shells out to git shows up here. +//! * `frame` — how long the draw took (`draw_us`), what it showed (overlay +//! and whether it is still waiting on a background read — `busy` — the +//! pane's session and whether it has a screen yet), and every input +//! waiting on it with its arrival-to-paint time (`latency_us`): the figure +//! the user feels. +//! * `server` — a daemon event's arrival, so an action that completes +//! off the loop (an attach's replay, a create's Ack) can be timed from +//! the key that asked for it to the frame that showed it. +//! +//! Off — the variable unset, as it always is outside a measurement run — +//! it is one `Option` check per event. `scripts/perf/` drives and reads it. + +use crate::app::{App, Overlay}; +use crossterm::event::{Event, KeyCode, KeyModifiers, MouseEventKind}; +use std::io::Write; +use std::time::Instant; + +pub struct Perf { + out: std::io::BufWriter, + /// Wall-clock µs `t: 0` stands for. + epoch_us: u128, + /// Inputs handled since the last frame: label and arrival. + waiting: Vec<(String, Instant)>, +} + +impl Perf { + /// The probe, when `NEBULA_PERF_LOG` names a file that can be created. + pub fn from_env() -> Option { + let path = std::env::var_os("NEBULA_PERF_LOG")?; + let file = std::fs::File::create(path).ok()?; + let mut out = std::io::BufWriter::new(file); + // The wall clock `t: 0` stands for, so a driver's own timeline (when + // it pressed what) lines up with this one. + let epoch_us = wall_us(); + let _ = writeln!(out, r#"{{"k":"start","epoch_us":{epoch_us}}}"#); + Some(Self { + out, + epoch_us, + waiting: Vec::new(), + }) + } + + /// `at` on the run's timeline. Read off the wall clock, not `Instant`: + /// a driver stamps its own steps with the wall clock, and on macOS the + /// two drift apart by more than a millisecond every second. + fn micros(&self, at: Instant) -> u128 { + wall_us().saturating_sub(at.elapsed().as_micros() + self.epoch_us) + } + + /// An input event whose handler ran from `arrived` until now. + pub fn input(&mut self, label: String, arrived: Instant, app: &App) { + let handler_us = arrived.elapsed().as_micros(); + let _ = writeln!( + self.out, + r#"{{"k":"input","t":{},"ev":{:?},"handler_us":{},"overlay":"{}","focus":"{:?}"}}"#, + self.micros(arrived), + label, + handler_us, + overlay_name(app), + app.focus, + ); + self.waiting.push((label, arrived)); + } + + /// A frame whose draw ran from `began` until now. + pub fn frame(&mut self, began: Instant, app: &App) { + let done = Instant::now(); + let inputs: Vec = self + .waiting + .drain(..) + .map(|(label, arrived)| { + format!( + r#"{{"ev":{:?},"latency_us":{}}}"#, + label, + done.duration_since(arrived).as_micros() + ) + }) + .collect(); + let (pane, painted, booting) = match &app.term { + Some(t) => (format!("{:?}", t.sref), t.painted, t.booting), + None => ("none".to_string(), false, false), + }; + let _ = writeln!( + self.out, + r#"{{"k":"frame","t":{},"draw_us":{},"overlay":"{}","focus":"{:?}","pane":{:?},"painted":{},"booting":{},"busy":{},"inputs":[{}]}}"#, + self.micros(began), + done.duration_since(began).as_micros(), + overlay_name(app), + app.focus, + pane, + painted, + booting, + overlay_busy(app), + inputs.join(","), + ); + // A run is read after the fact, often from a TUI that was killed + // rather than quit: every frame is on disk once it is drawn. + let _ = self.out.flush(); + } + + /// A daemon event, by name, as it arrives. + pub fn server(&mut self, name: &str) { + let _ = writeln!( + self.out, + r#"{{"k":"server","t":{},"ev":"{}"}}"#, + self.micros(Instant::now()), + name + ); + } +} + +fn wall_us() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_micros()) +} + +/// Is the overlay on screen still waiting on a BACKGROUND READ to show what +/// was asked for? A frame that is, has not settled. +fn overlay_busy(app: &App) -> bool { + match &app.overlay { + Some(Overlay::Diff(v)) => { + (v.listing.is_some() && v.files.is_empty()) + || (v.waiting.is_some() + && v.shown.as_deref() != v.selected_file().map(|f| f.path.as_str())) + } + Some(Overlay::Files(v)) => v.listing.is_some(), + Some(Overlay::Tree(v)) => v.listing.is_some() || v.waiting.is_some(), + Some(Overlay::Grep(v)) => v.waiting.is_some(), + _ => false, + } +} + +fn overlay_name(app: &App) -> &'static str { + if app.vim.is_some() { + return "Editor"; + } + match &app.overlay { + None => "none", + Some(Overlay::Menu(_)) => "Menu", + Some(Overlay::Confirm(_)) => "Confirm", + Some(Overlay::Prompt(_)) => "Prompt", + Some(Overlay::Help(_)) => "Help", + Some(Overlay::Settings(_)) => "Settings", + Some(Overlay::Diff(_)) => "Diff", + Some(Overlay::Palette(_)) => "Palette", + Some(Overlay::Files(_)) => "Files", + Some(Overlay::Grep(_)) => "Grep", + Some(Overlay::Tree(_)) => "Tree", + Some(Overlay::FileTabs(_)) => "FileTabs", + Some(Overlay::Metrics(_)) => "Metrics", + Some(Overlay::Hosts(_)) => "Hosts", + Some(Overlay::AgentPresets(_)) => "AgentPresets", + Some(Overlay::AgentPresetEditor(_)) => "AgentPresetEditor", + Some(Overlay::Issues(_)) => "Issues", + Some(Overlay::BranchSwitch(_)) => "BranchSwitch", + } +} + +/// `key:g`, `key:C-d`, `key:S-Tab`, `mouse:down`, `paste`, … — None for +/// the events nobody waits on (pointer motion, focus reports, releases). +pub fn label(event: &Event) -> Option { + match event { + Event::Key(key) if key.kind != crossterm::event::KeyEventKind::Release => { + let mut s = String::from("key:"); + for (bit, tag) in [ + (KeyModifiers::CONTROL, "C-"), + (KeyModifiers::ALT, "M-"), + (KeyModifiers::SUPER, "D-"), + ] { + if key.modifiers.contains(bit) { + s.push_str(tag); + } + } + match key.code { + KeyCode::Char(c) => s.push(c), + KeyCode::BackTab => s.push_str("S-Tab"), + other => s.push_str(&format!("{other:?}")), + } + Some(s) + } + Event::Mouse(mouse) => match mouse.kind { + MouseEventKind::Down(_) => Some("mouse:down".into()), + MouseEventKind::Up(_) => Some("mouse:up".into()), + MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => Some("mouse:wheel".into()), + _ => None, + }, + Event::Paste(_) => Some("paste".into()), + _ => None, + } +} + +/// A daemon event's variant name, for [`Perf::server`]. +pub fn server_name(ev: &nebula_core::protocol::ServerEvent) -> &'static str { + use nebula_core::protocol::ServerEvent as E; + match ev { + E::Scrollback { .. } => "Scrollback", + E::Output { .. } => "Output", + E::Ack { .. } => "Ack", + E::Error { .. } => "Error", + E::EntityUpserted { .. } => "EntityUpserted", + E::EntityRemoved { .. } => "EntityRemoved", + E::StatusChanged { .. } => "StatusChanged", + E::Snapshot { .. } => "Snapshot", + _ => "other", + } +} diff --git a/crates/nebula-tui/src/tree_browser.rs b/crates/nebula-tui/src/tree_browser.rs index eee21cf4..d9cba4eb 100644 --- a/crates/nebula-tui/src/tree_browser.rs +++ b/crates/nebula-tui/src/tree_browser.rs @@ -32,6 +32,59 @@ pub struct TreeNode { pub depth: usize, } +/// What the preview pane shows for one node, ready to draw: the text, its +/// syntax-highlighted lines, and what kind of thing it is. Reading a file +/// and highlighting it is the slow half of walking the tree — 56 ms for a +/// megabyte of Rust under the INPUT LATENCY PROBE — so a browser with +/// BACKGROUND READS builds this off the loop ([`file_preview`]). +#[derive(Debug, Clone, Default)] +pub struct Preview { + pub text: String, + pub lines: Vec>, + /// Real file contents (earns a line-number gutter), as opposed to a + /// directory listing or a placeholder message. + pub is_file: bool, + pub markdown: bool, +} + +impl Preview { + /// Unhighlighted text: a directory listing, an error, a placeholder. + fn plain(text: String) -> Self { + let mut hl = Highlighter::plain(); + Self { + lines: text.lines().map(|l| hl.line(l)).collect(), + text, + is_file: false, + markdown: false, + } + } +} + +/// The preview of the file at `root/path`: read (capped, binary-guarded) +/// and highlighted. Never fails — an unreadable file previews as the +/// reason. `None` only when `cancel` fired between the read and the +/// highlight: the cursor has moved on and nobody is waiting. +pub(crate) fn file_preview( + root: &std::path::Path, + path: &str, + cancel: Option<&crate::view_jobs::Cancel>, +) -> Option { + let text = match read_preview(&root.join(path)) { + Ok(text) => text, + Err(message) => return Some(Preview::plain(message)), + }; + if cancel.is_some_and(|c| c.is_cancelled()) { + return None; + } + let mut hl = Highlighter::for_path(path); + Some(Preview { + lines: text.lines().map(|l| hl.line(l)).collect(), + text, + is_file: true, + markdown: markdown::is_markdown_path(path), + }) +} + /// One visible row: a node index plus the char positions of the node's /// `name` the filter matched, for highlighting. #[derive(Debug, Clone)] @@ -108,9 +161,54 @@ pub struct TreeBrowser { /// In-progress drag of the tree/preview border: `boundary_x - grab /// column` at mouse-down (the `SplitterDrag::grab_offset` pattern). pub files_drag: Option, + /// BACKGROUND READS: with it, a file's preview is read and highlighted + /// off the loop and lands in [`TreeBrowser::land_preview`]; without (a + /// browser built by a test), inline. + pub jobs: Option, + /// The `git ls-files` this browser opened ahead of, by ticket: the + /// tree is empty and says so until [`TreeBrowser::set_files`]. + pub listing: Option, + /// The preview in flight, by ticket. The pane keeps the last node's + /// preview meanwhile (`view_jobs::STALE_GRACE`). + pub waiting: Option, + /// Stops the preview in flight when the cursor moves on. + pub cancel: crate::view_jobs::Cancel, } impl TreeBrowser { + /// A browser up before its listing is: `b` opens this at once and + /// `set_files` fills it when `git ls-files` answers. What is typed + /// into the filter meanwhile is kept and applied to the tree it gets. + pub fn opening( + root: PathBuf, + branch: String, + editor: String, + jobs: crate::view_jobs::Jobs, + listing: u64, + ) -> Self { + let mut browser = Self::new(root, branch, editor, Vec::new()); + browser.jobs = Some(jobs); + browser.listing = Some(listing); + browser + } + + /// The listing landed: build the tree, narrow it by whatever the filter + /// holds by now, and preview where the cursor ends up. + pub fn set_files(&mut self, files: Vec) { + let (nodes, top, file_count) = build_nodes(&files); + self.expanded = vec![false; nodes.len()]; + self.nodes = nodes; + self.top = top; + self.file_count = file_count; + self.listing = None; + self.rebuild_rows(); + self.selected = self + .best_row + .unwrap_or(0) + .min(self.rows.len().saturating_sub(1)); + self.load_preview(); + } + pub fn new(root: PathBuf, branch: String, editor: String, files: Vec) -> Self { let (nodes, top, file_count) = build_nodes(&files); let expanded = vec![false; nodes.len()]; @@ -141,6 +239,10 @@ impl TreeBrowser { area: Rect::default(), files_width: DEFAULT_DIFF_FILES_W, files_drag: None, + jobs: None, + listing: None, + waiting: None, + cancel: crate::view_jobs::Cancel::default(), }; browser.rebuild_rows(); browser.load_preview(); @@ -304,11 +406,12 @@ impl TreeBrowser { let mut name_positions: Vec> = vec![Vec::new(); self.nodes.len()]; let mut scores: Vec> = vec![None; self.nodes.len()]; self.match_count = 0; + let mut matcher = crate::fuzzy::Matcher::new(&self.filter); for i in 0..self.nodes.len() { if self.nodes[i].is_dir { continue; } - let Some(m) = crate::fuzzy::fuzzy_match(&self.filter, &self.nodes[i].path) else { + let Some(m) = matcher.matches(&self.nodes[i].path) else { continue; }; self.match_count += 1; @@ -361,10 +464,16 @@ impl TreeBrowser { /// Reload the preview for the current selection and reset the scroll. /// Never fails: errors become the displayed text (the `diff_for` rule). /// Real file contents get syntax-highlighted; directory listings and - /// placeholder messages stay plain. + /// placeholder messages stay plain. A directory's listing is built on + /// the spot; a file's is read off the loop when there are BACKGROUND + /// READS to read it with, the pane holding what it showed until the + /// read lands ([`TreeBrowser::land_preview`]) or is slow + /// ([`TreeBrowser::preview_slow`]). pub fn load_preview(&mut self) { - self.scroll = 0; - let (text, highlight_path) = match self.selected_node() { + // Whatever was being read is no longer under the cursor. + self.cancel.cancel(); + self.waiting = None; + let preview = match self.selected_node() { Some(n) if n.is_dir => { let listing = n .children @@ -379,26 +488,57 @@ impl TreeBrowser { }) .collect::>() .join("\n"); - (listing, None) + Preview::plain(listing) } - Some(n) => match read_preview(&self.root.join(&n.path)) { - Ok(text) => (text, Some(n.path.clone())), - Err(message) => (message, None), + Some(n) => match self.jobs.clone() { + Some(jobs) => { + let path = n.path.clone(); + let ticket = crate::view_jobs::ticket(); + self.waiting = Some(ticket); + self.cancel = crate::view_jobs::Cancel::default(); + let (root, cancel) = (self.root.clone(), self.cancel.clone()); + jobs.run_with_grace(ticket, move || { + let preview = file_preview(&root, &path, Some(&cancel))?; + Some(crate::view_jobs::Answer::Preview { + ticket, + preview: Box::new(preview), + }) + }); + return; + } + None => file_preview(&self.root, &n.path, None).unwrap_or_default(), }, - None => (String::new(), None), + None => Preview::default(), }; - let mut hl = match &highlight_path { - Some(path) => Highlighter::for_path(path), - None => Highlighter::plain(), - }; - self.preview_is_file = highlight_path.is_some(); - self.markdown = highlight_path - .as_deref() - .is_some_and(markdown::is_markdown_path); + self.set_preview(preview); + } + + fn set_preview(&mut self, preview: Preview) { + self.scroll = 0; + self.preview_is_file = preview.is_file; + self.markdown = preview.markdown; self.rendered = None; - self.preview_lines = text.lines().map(|l| hl.line(l)).collect(); - self.preview_line_count = self.preview_lines.len(); - self.preview = text; + self.preview_line_count = preview.lines.len(); + self.preview_lines = preview.lines; + self.preview = preview.text; + } + + /// A background preview came back: shown when it is the one the cursor + /// is waiting on, dropped when the cursor has moved on since. + pub fn land_preview(&mut self, ticket: u64, preview: Preview) { + if self.waiting == Some(ticket) { + self.waiting = None; + self.set_preview(preview); + } + } + + /// The preview in flight has outlasted the grace the last node's + /// preview was kept for: say so rather than leave one file's text + /// under another's name. Still waiting — the read lands over this. + pub fn preview_slow(&mut self, ticket: u64) { + if self.waiting == Some(ticket) { + self.set_preview(Preview::plain("loading…".to_string())); + } } /// The preview is the rendered markdown page rather than the source. diff --git a/crates/nebula-tui/src/ui.rs b/crates/nebula-tui/src/ui.rs index 826aa993..ebd244ce 100644 --- a/crates/nebula-tui/src/ui.rs +++ b/crates/nebula-tui/src/ui.rs @@ -1446,7 +1446,9 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { // Left: changed-file list; a stateless follow-window keeps the // selected row visible. - let mut files_title = if view.filter.is_empty() { + let mut files_title = if view.listing.is_some() && view.files.is_empty() { + "Files (…)".to_string() + } else if view.filter.is_empty() { format!("Files ({})", view.files.len()) } else { format!("Files ({}/{})", view.matches.len(), view.files.len()) @@ -1465,7 +1467,9 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { } let list_inner = below_first_row(files_inner); - if view.matches.is_empty() { + if view.listing.is_some() && view.files.is_empty() { + empty_list_row(f, list_inner, "reading changes…", th); + } else if view.matches.is_empty() { empty_list_row(f, list_inner, NO_MATCHES, th); } let start = view.window_start(list_inner.height as usize); @@ -1536,9 +1540,14 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { ); } f.render_widget(block, diff_a); + // Only the rows in view are styled: a diff runs to 20 000 + // lines, and building a `Line` for each of them on every frame + // was most of what scrolling a large one cost. let lines: Vec = view .diff .lines() + .skip(scroll as usize) + .take(diff_inner.height as usize) .map(|l| { let style = match classify_diff_line(l) { DiffLineKind::Add => Style::default().fg(th.ok), @@ -1550,7 +1559,7 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { Line::from(Span::styled(l.to_string(), style)) }) .collect(); - f.render_widget(Paragraph::new(lines).scroll((scroll, 0)), diff_inner); + f.render_widget(Paragraph::new(lines), diff_inner); // Write-back (draw works on a clone): page size for key paging, // scroll re-clamped so resizes never strand the view. @@ -1673,7 +1682,11 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { } Overlay::Files(finder) => { let area = centered_rect(f.area(), FILES_SIZE.0, FILES_SIZE.1); - let title = if finder.query.is_empty() { + // No count to show until the listing lands: `(0/0)` reads as + // "no files", which is not what is known yet. + let title = if finder.listing.is_some() { + format!(" Find file — {} (listing…) ", finder.branch) + } else if finder.query.is_empty() { format!(" Find file — {} ({}) ", finder.branch, finder.files.len()) } else { format!( @@ -1692,7 +1705,9 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { } let list_inner = below_first_row(inner); - if finder.matches.is_empty() { + if finder.listing.is_some() { + empty_list_row(f, list_inner, "listing files…", th); + } else if finder.matches.is_empty() { empty_list_row(f, list_inner, NO_MATCHES, th); } let start = finder.window_start(list_inner.height as usize); @@ -1720,6 +1735,8 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { let area = centered_rect_pct(f.area(), GREP_MODAL_PCT.0, GREP_MODAL_PCT.1); let title = if view.query.chars().count() < crate::grep_search::MIN_QUERY_LEN { format!(" Find in files — {} ", view.branch) + } else if view.waiting.is_some() { + format!(" Find in files — {} (searching…) ", view.branch) } else if view.truncated { format!( " Find in files — {} ({}+ hits) ", @@ -1753,7 +1770,7 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { ), Style::default().fg(th.dim), )) - } else if view.hits.is_empty() { + } else if view.hits.is_empty() && view.waiting.is_none() { Some(Span::styled(NO_MATCHES, Style::default().fg(th.dim))) } else { None @@ -1996,7 +2013,9 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { // Left: the file tree; a stateless follow-window keeps the // selected row visible. - let tree_title = if view.filter.is_empty() { + let tree_title = if view.listing.is_some() { + format!("Tree — {} (listing…)", view.branch) + } else if view.filter.is_empty() { format!("Tree — {} ({})", view.branch, view.file_count) } else { format!( @@ -2015,7 +2034,9 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { } let list_inner = below_first_row(tree_inner); - if view.rows.is_empty() { + if view.listing.is_some() { + empty_list_row(f, list_inner, "listing files…", th); + } else if view.rows.is_empty() { empty_list_row(f, list_inner, NO_MATCHES, th); } let start = view.window_start(list_inner.height as usize); diff --git a/crates/nebula-tui/src/view_jobs.rs b/crates/nebula-tui/src/view_jobs.rs new file mode 100644 index 00000000..d0ce18d2 --- /dev/null +++ b/crates/nebula-tui/src/view_jobs.rs @@ -0,0 +1,166 @@ +//! BACKGROUND READS for the worktree views — the DIFF VIEWER (`g`), the FILE +//! FINDER (`f`), its grep view (`F`) and the TREE BROWSER (`b`). +//! +//! Every one of them is git and the disk: `git status -uall` to list what +//! changed, `git ls-files` for the finder and the tree, a `git diff` per +//! file walked past, a `git grep` per character typed, a file read and +//! highlighted per tree row. They used to run inside the key handler, on +//! the grounds that they are fast — and on a small checkout they are: 10 to +//! 50 ms each under the INPUT LATENCY PROBE. On a ten-thousand-file +//! checkout the same `git status` is 80 ms warm and over a second cold, and +//! `git grep` is 200 ms per keystroke, all of it with the whole UI frozen: +//! no paint, no PTY output, no next key. +//! +//! So the views ask and the answer lands: a view that holds a [`Jobs`] +//! handle runs its read on the blocking pool and is handed the result by +//! the main loop (`event_loop::land_view_answer`), keyed by a [`ticket`] +//! so an answer nobody is waiting for any more — the query moved on, the +//! cursor left the file, the modal closed — is dropped. A view without a +//! handle (every unit test that builds one directly, and nothing else) +//! reads inline exactly as before, so the two paths share every parser. +//! +//! Nothing here holds memory between reads beyond what the view already +//! showed; the one cache — the DIFF VIEWER's — is bounded and dies with +//! the modal (`DiffView::cache`). + +use crate::git_diff::DiffFile; +use crate::grep_search::GrepHit; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +/// How long a view keeps showing what it had while the replacement loads. +/// A read that lands inside it swaps the content in place — no blank +/// frame between two files that each take a few milliseconds; one that +/// does not is told so ([`Answer::Slow`]) and says "loading…" instead of +/// leaving the last file's text under the next file's name. +pub const STALE_GRACE: Duration = Duration::from_millis(60); + +/// How long the grep view waits for the next character before it spends a +/// `git grep` on the query: a word typed at speed searches once, for the +/// word. +pub const GREP_DEBOUNCE: Duration = Duration::from_millis(40); + +/// The changed-file list behind the DIFF VIEWER, with everything else its +/// opening used to read inline. +#[derive(Debug)] +pub struct DiffListing { + pub files: Vec, + /// HEAD's OID; None on an unborn HEAD. + pub head: Option, + /// The reviewed ✓ marks that still apply — each stored mark checked + /// against the file's diff as it is now. + pub reviewed: HashMap, +} + +#[derive(Debug)] +pub enum Answer { + Grep { + ticket: u64, + result: Result<(Vec, bool), String>, + }, + /// `git ls-files`, for the FILE FINDER and the TREE BROWSER. + Files { + ticket: u64, + result: Result, String>, + }, + DiffListing { + ticket: u64, + result: Result, + }, + /// One file's diff text. `prefetch` is the row after the cursor, read + /// ahead: it goes into the view's cache and is not shown. + DiffText { + /// The `DiffView::id` that asked. + view: u64, + ticket: u64, + path: String, + diff: String, + prefetch: bool, + }, + Preview { + ticket: u64, + preview: Box, + }, + /// [`STALE_GRACE`] is up on `ticket`. + Slow { ticket: u64 }, + /// The system clipboard could not be written off the loop: hand the + /// copy to the terminal instead (OSC 52), and say so. + ClipboardViaTerminal { payload: String, flash: String }, + /// This process's resident set, for the footer's memory readout and the + /// memory modal: a `ps`, on a five-second beat. + ClientRss(u64), + /// The outcome of something a key started and did not wait for, in the + /// footer's words (`Shift+G`: which page was opened, or why not). + Flash(String), +} + +/// A view's way onto the blocking pool and back. Cheap to clone — the +/// views are cloned every frame. +#[derive(Debug, Clone)] +pub struct Jobs { + tx: tokio::sync::mpsc::UnboundedSender, +} + +/// A fresh ticket. One counter for every view, so an answer can never be +/// mistaken for another modal's. +pub fn ticket() -> u64 { + static NEXT: AtomicU64 = AtomicU64::new(1); + NEXT.fetch_add(1, Ordering::Relaxed) +} + +/// Set when the view stops waiting on a read, so a job that has not +/// started its git yet never does, and one that is streaming stops. +#[derive(Debug, Clone, Default)] +pub struct Cancel(Arc); + +impl Cancel { + pub fn cancel(&self) { + self.0.store(true, Ordering::Relaxed); + } + + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} + +impl Jobs { + pub fn new(tx: tokio::sync::mpsc::UnboundedSender) -> Self { + Self { tx } + } + + /// Run `work` on the blocking pool and land what it returns. Must be + /// called from inside the runtime — which is where every key handler + /// runs; the unit tests that have no runtime have no `Jobs` either. + pub fn run(&self, work: impl FnOnce() -> Option + Send + 'static) { + let tx = self.tx.clone(); + tokio::task::spawn_blocking(move || { + if let Some(answer) = work() { + let _ = tx.send(answer); + } + }); + } + + /// [`Jobs::run`], with [`Answer::Slow`] landing first when the work + /// outlasts [`STALE_GRACE`]. + pub fn run_with_grace( + &self, + ticket: u64, + work: impl FnOnce() -> Option + Send + 'static, + ) { + let done = Cancel::default(); + let (tx, finished) = (self.tx.clone(), done.clone()); + tokio::spawn(async move { + tokio::time::sleep(STALE_GRACE).await; + if !finished.is_cancelled() { + let _ = tx.send(Answer::Slow { ticket }); + } + }); + self.run(move || { + let answer = work(); + done.cancel(); + answer + }); + } +} diff --git a/crates/nebula/tests/e2e_tui.rs b/crates/nebula/tests/e2e_tui.rs index 28d2fff9..f684db85 100644 --- a/crates/nebula/tests/e2e_tui.rs +++ b/crates/nebula/tests/e2e_tui.rs @@ -47,6 +47,9 @@ const FOOTER_TERMINAL_FOCUSED: &str = "Enter: type into terminal"; /// Terminal pane input-locked: keys forward to the PTY. The footer spells /// chords the compact way `KeyChord::display` does — `^q`, not `Ctrl+q`. const FOOTER_TERMINAL_LOCKED: &str = "^q: panels"; +/// The bottom border of the task box the NEW SESSION PICKER ends in. The box +/// and the picker share the title `New session`; only the box says this. +const TASK_BOX_HINT: &str = "Enter launch"; struct TuiHarness { writer: Box, @@ -489,10 +492,11 @@ fn tui_projects_worktrees_agents_navigation() { tui.send(b"n"); tui.wait_for_text("New session"); // Claude/Codex/Cursor/Terminal picker tui.send(ENTER); // pick the default (Claude) - tui.wait_for_gone("New session"); - tui.wait_for_text("New agent"); - tui.send(ENTER); // empty input falls back to "agent-1" - tui.wait_for_gone("New agent"); + // The picker ends in the task box — also titled `New session`, so its + // own hint line is what tells the two apart. + tui.wait_for_text(TASK_BOX_HINT); + tui.send(ENTER); // empty: no first prompt, the name falls back to "agent-1" + tui.wait_for_gone(TASK_BOX_HINT); tui.wait_for_text("agent-1"); // now provably the sessions-panel row tui.wait_for_text(FOOTER_TERMINAL_LOCKED); // auto-attach locks input @@ -619,10 +623,9 @@ fn nebula_open_from_inside_a_session_raises_the_file_tabs() { tui.send(b"n"); tui.wait_for_text("New session"); tui.send(ENTER); - tui.wait_for_gone("New session"); - tui.wait_for_text("New agent"); + tui.wait_for_text(TASK_BOX_HINT); tui.send(ENTER); - tui.wait_for_gone("New agent"); + tui.wait_for_gone(TASK_BOX_HINT); tui.wait_for_text("agent-1"); tui.wait_for_text(FOOTER_TERMINAL_LOCKED); diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 6cfb3868..72ec10cc 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -10,8 +10,39 @@ one on its own. Moving the cursor onto a live session — a row in the Sessions panel, or a worktree, project or workspace switch that brings one back — attaches it on the keypress; only a session the idle reaper took waits a moment, so that walking past its row doesn't boot a CLI. The screens of the - last two sessions shown are kept, so returning to one paints on the same frame and fetches only the - bytes it missed instead of replaying the whole ring. + last six sessions shown are kept, so returning to one paints on the same frame and fetches only the + bytes it missed instead of replaying the whole ring. Between them they may hold about 12 MB of grid; + past that the oldest give up their scrollback and keep only the screen (a fiftieth of the size), and + scrolling up in a pane that came back that way replays its ring once to get the history back. +- **A key never waits on git, the disk or the DAEMON.** Everything a keypress can start that takes + longer than a frame runs off the event loop and lands when it is done. The DIFF VIEWER (`g`), the + FILE FINDER (`f`), its grep view (`F`) and the TREE BROWSER (`b`) open on the keypress and fill in + when `git status` / `git ls-files` answer — what is typed meanwhile is kept and applied — and a + file's diff, a search and a preview are read on the blocking pool: the pane keeps what it showed + for up to 60 ms, which is longer than a read takes, and says `loading…` past that. The DIFF VIEWER + opens on the list the changed-files badge's last `git status` found (two seconds old at most; its own + `git status` still runs, and the reader keeps their place when it lands), so the first diff is being + read while the list is checked rather than after — on a ten-thousand-file checkout `g` went from + 260 ms of frozen UI to a list in 2 ms and a diff in 60. It reads the row after the cursor ahead and + keeps what it has read (2 MB at most, gone with the modal), so `↓` paints the next diff on the + keypress and re-reads it behind. List filters rank with `nebula-fuzzy`, a crate of its own only so + that a dev build compiles it optimised: 27 ms a keystroke over ten thousand paths became 5. `git grep` waits 40 ms for the + next character, streams, and is killed at 200 hits or when the query moves on. A browser `open` and + a clipboard `pbcopy` are started and left to finish. Rename, archive, unarchive, delete and close + are OPTIMISTIC UPDATES: the row changes on the keypress, by way of the same upsert or removal the + DAEMON is about to broadcast, and an Error puts it back and says why. FRAME PACING is a token + bucket rather than a fixed 16 ms tick — three frames may go out 2 ms apart, a token comes back + every 16 ms — so a key's frame and its answer's follow each other, while sustained PTY output still + paints at 60 fps; a key that only goes to the PTY paints nothing of its own; and the DAEMON flushes + PTY output that breaks a silence at once instead of holding it 5 ms to coalesce. A typed character + echoes in 2 ms in a release build (3.5 ms in a debug one), where it was 20 (25). +- **…and that is measured, not felt.** `NEBULA_PERF_LOG=` turns on the INPUT LATENCY PROBE: one + JSON line per input (how long its handler held the loop), per frame (draw time, what it showed, and + how long each input waited for it) and per DAEMON event. `make perf` drives the real TUI through + every panel, modal and verb inside a private tmux — isolated daemon, a clone of this repository as + the checkout, a stand-in agent with a full 1 MB ring — and prints handler / paint / settle / echo + per step plus the peak RSS of the TUI and the DAEMON; `python3 scripts/perf/report.py BEFORE AFTER` + compares two runs. A change to anything on a key path is judged by that table. - **Every pane is the same truecolor terminal.** A session paints nebula's own grid, not the terminal nebula runs in, so the daemon tells each child `TERM=xterm-256color` and `COLORTERM=truecolor` and drops any `NO_COLOR` / `FORCE_COLOR` it inherited. An agent launch runs through your login shell diff --git a/scripts/perf/report.py b/scripts/perf/report.py new file mode 100644 index 00000000..aaabc54a --- /dev/null +++ b/scripts/perf/report.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Reads a LATENCY HARNESS run (`perf.jsonl` from the TUI's probe + `steps.tsv` from run.sh) and prints +one row per scripted step: + + handler the longest any of the step's input handlers held the event loop — nothing else (no paint, + no PTY output, no other key) happens while it runs + paint the longest any of the step's inputs waited from arrival to the end of the frame that + showed it: what the user feels as "did the key register" + settle from the step's first input to the first frame that already shows what the step ends on + (same overlay, same session in the pane, its screen painted, nothing still loading): what + the user feels as "is it there yet" + echo in a locked pane: from the key to the end of the first frame after the PTY answered it — + the full trip through the daemon and back + frames frames painted during the step, and the slowest draw among them + +Above the table, `startup` is from the driver launching the binary to its first frame, and to the first +frame that had the daemon's snapshot in hand. + +All in milliseconds. `report.py A B` prints two runs side by side (before / after). +""" +import json +import sys +from pathlib import Path + + +def load(run): + run = Path(run) + events, epoch = [], 0 + for line in (run / "perf.jsonl").read_text().splitlines(): + try: + ev = json.loads(line) + except ValueError: + continue # a line cut short by a kill + if ev["k"] == "start": + epoch = ev["epoch_us"] + else: + events.append(ev) + steps = [] + for line in (run / "steps.tsv").read_text().splitlines(): + at, label, key = (line.split("\t") + ["", ""])[:3] + steps.append((int(at) - epoch, label, key)) + return events, steps + + +def state(frame): + return (frame["overlay"], frame["pane"], frame["painted"], frame["booting"], frame.get("busy", False)) + + +def startup(run): + """(launch → first frame, launch → first frame after the Snapshot), in µs.""" + events, steps = load(run) + launch = next((at for at, label, _ in steps if label == "launch"), None) + frames = [e for e in events if e["k"] == "frame"] + if launch is None or not frames: + return None, None + first = frames[0]["t"] + frames[0]["draw_us"] - launch + snap = next((e["t"] for e in events if e["k"] == "server" and e["ev"] == "Snapshot"), None) + full = next((f["t"] + f["draw_us"] - launch for f in frames if snap is not None and f["t"] >= snap), None) + return first, full + + +def memory(run): + """Peak RSS in MB of (the TUI, the daemon) over the run, from run.sh's twice-a-second samples.""" + path = Path(run) / "rss.tsv" + if not path.exists(): + return None, None + tui, daemon = [], [] + for line in path.read_text().splitlines(): + a, _, b = line.partition("\t") + if a.isdigit(): + tui.append(int(a)) + if b.isdigit(): + daemon.append(int(b)) + peak = lambda xs: max(xs) / 1024 if xs else None + return peak(tui), peak(daemon) + + +def rows(run): + events, steps = load(run) + steps = [s for s in steps if s[1] != "launch"] + out = [] + for (start, label, key), (end, _, _) in zip(steps, steps[1:]): + mine = [e for e in events if start <= e["t"] < end] + inputs = [e for e in mine if e["k"] == "input"] + frames = [e for e in mine if e["k"] == "frame"] + if not inputs: + out.append((label, key, None, None, None, len(frames), 0, None)) + continue + echo = None + if inputs[-1]["focus"] == "Terminal" and len(inputs) == 1: + t0 = inputs[0]["t"] + answered = next((e["t"] for e in mine if e["k"] == "server" and e["ev"] == "Output" and e["t"] >= t0), None) + if answered is not None: + echo = next((f["t"] + f["draw_us"] - t0 for f in frames if f["t"] >= answered), None) + handler = max(e["handler_us"] for e in inputs) + # A button's release changes nothing on screen; the press is what the user is waiting on. + paint = max((i["latency_us"] for f in frames for i in f["inputs"] if i["ev"] != "mouse:up"), default=None) + first = inputs[0]["t"] + after = [f for f in frames if f["t"] >= first] + settle = None + if after: + final = state(after[-1]) + # The first frame from which the state never leaves `final` again. + idx = len(after) - 1 + while idx > 0 and state(after[idx - 1]) == final: + idx -= 1 + f = after[idx] + settle = f["t"] + f["draw_us"] - first + draw = max((f["draw_us"] for f in frames), default=0) + out.append((label, key, handler, paint, settle, len(frames), draw, echo)) + return out + + +def ms(us): + return " -" if us is None else f"{us / 1000:6.1f}" + + +def main(argv): + runs = [rows(a) for a in argv] + for a in argv: + first, full = startup(a) + print(f"startup ({Path(a).name}): first frame {ms(first).strip()} ms, with the daemon's snapshot {ms(full).strip()} ms") + tui, daemon = memory(a) + if tui is not None: + print(f"memory ({Path(a).name}): peak RSS — TUI {tui:.1f} MB, daemon {daemon or 0:.1f} MB") + if len(runs) == 1: + print(f"{'step':<34}{'key':<14}{'handler':>8}{'paint':>8}{'settle':>8}{'echo':>8}{'frames':>8}{'max draw':>10}") + for label, key, handler, paint, settle, n, draw, echo in runs[0]: + print(f"{label:<34}{key[:12]:<14}{ms(handler):>8}{ms(paint):>8}{ms(settle):>8}{ms(echo):>8}{n:>8}{ms(draw):>10}") + worst = [r for r in runs[0] if r[3] is not None] + if worst: + paints = sorted(r[3] for r in worst) + print(f"\npaint p50 {ms(paints[len(paints) // 2])} ms max {ms(paints[-1])} ms " + f"steps over 16 ms: {sum(p > 16000 for p in paints)}/{len(paints)}") + return 0 + before, after = runs[0], runs[1] + print(f"{'step':<34}{'key':<12}{'handler before':>15}{'after':>8}{'paint before':>14}{'after':>8}{'settle before':>15}{'after':>8}") + after_by = {a[0]: a for a in after} + for b in before: + a = after_by.get(b[0]) + if a is None: + continue + print(f"{b[0]:<34}{b[1][:10]:<12}{ms(b[2]):>15}{ms(a[2]):>8}{ms(b[3]):>14}{ms(a[3]):>8}{ms(b[4]):>15}{ms(a[4]):>8}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/perf/run.sh b/scripts/perf/run.sh new file mode 100755 index 00000000..7665b314 --- /dev/null +++ b/scripts/perf/run.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# LATENCY HARNESS — `scripts/perf/run.sh [scenario]` (default: scripts/perf/scenario.steps). +# +# Drives the real TUI through a scripted session inside a private tmux, against an isolated daemon and a +# realistically sized checkout (a local clone of this repository: a few hundred files, a vendored crate, +# dirty files, three worktrees), with the INPUT LATENCY PROBE on (`NEBULA_PERF_LOG`, crates/nebula-tui/ +# src/perf.rs). `report.py` then prints, per step, how long the key's handler held the loop, how long the +# key waited for its frame, and how long the screen took to settle. Never touches the real daemon. +# +# BIN=target/release/nebula scripts/perf/run.sh measure another build (default target/debug/nebula, +# the build `make dev` runs) +# PERF_DUMP=1 scripts/perf/run.sh also save the screen after every step (debugging a +# scenario that went astray) +# OUT=/some/dir scripts/perf/run.sh where the log, the report and the dumps go +# PERF_REPO=~/src/big scripts/perf/run.sh clone that repository as the checkout instead of +# this one (it is only read): what `g`, `f`, `b` and +# `F` cost where git has real work to do +# python3 scripts/perf/report.py BEFORE AFTER two runs' OUT dirs, side by side +# +# A scenario line is `labelkey`: a tmux key name (`j`, `Enter`, `C-d`, `BTab`), or `text:…` typed +# literally in one go. Lines starting with `#` are comments. One line is one step; steps are STEP_SECS +# apart (default 0.7), which is what lets the report tell them apart. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +SCENARIO="${1:-$HERE/scenario.steps}" +COLS="${COLS:-190}"; ROWS="${ROWS:-50}" +BIN="${BIN:-$REPO/target/debug/nebula}"; case "$BIN" in /*) ;; *) BIN="$REPO/$BIN";; esac +ID="$$" +RUNTIME="/tmp/nperf-$ID" # short on purpose (SUN_LEN) +WORK="${TMPDIR:-/tmp}/nebula-perf/$ID"; mkdir -p "$WORK" "$RUNTIME"; chmod 700 "$RUNTIME" +# The results outlive the run; the checkout it was measured against does not. `target/` is git-ignored +# and `make clean` takes it, so that is where they go unless OUT says otherwise. +OUT="${OUT:-$REPO/target/perf/$(date +%Y%m%d-%H%M%S)}"; mkdir -p "$OUT" +TMUX="tmux -L nperf-$ID" +cleanup() { + $TMUX kill-server 2>/dev/null || true + if [ -f "$RUNTIME/daemon.pid" ]; then kill "$(cat "$RUNTIME/daemon.pid")" 2>/dev/null || true; fi + # The clone, its worktrees and the daemon's data dir: hundreds of megabytes a run, and a dozen runs + # of leaving them behind is a full disk. + rm -rf "$RUNTIME" "$WORK" +} +trap cleanup EXIT +[ -x "$BIN" ] || { echo "perf: no binary at $BIN — build it first" >&2; exit 1; } +"$BIN" --version >/dev/null # pay the cold-exec stall here + +# --- a checkout big enough that git costs what it costs in real use --- +DEMO="$WORK/demo" +git clone -q --local "${PERF_REPO:-$REPO}" "$DEMO" +git -C "$DEMO" checkout -q -B main +git -C "$DEMO" worktree add -q -b feature-x "$WORK/demo-worktrees/feature-x" main +git -C "$DEMO" worktree add -q -b wheel-one-line "$WORK/demo-worktrees/wheel-one-line" main +# Work in progress in the main checkout: a dozen edited files and a few new ones. +n=0 +for f in $(git -C "$DEMO" ls-files | grep -E '\.(rs|ts|tsx|js|jsx|py|go|md)$' | grep -v ' ' | head -12); do + n=$((n + 1)); printf '\n// perf edit %d\n' "$n" >> "$DEMO/$f" +done +for i in 1 2 3; do printf 'scratch %d\n' "$i" > "$DEMO/scratch-$i.txt"; done + +# --- stand-ins: `gh` answers from the shot fixtures, `open` opens nothing, agents print and idle --- +mkdir -p "$WORK/bin" "$WORK/data" +ln -s "$REPO/scripts/shot/bin/gh" "$WORK/bin/gh" +# What `open` costs on a Mac before it returns (LaunchServices round trip), without a browser tab +# per run. +printf '#!/bin/sh\nsleep 0.08\nexit 0\n' > "$WORK/bin/open"; chmod +x "$WORK/bin/open" +# An agent with a full ring: ~1 MB of colored output, then idle — the replay an attach has to parse. +cat > "$WORK/bin/agent" <<'AGENT' +#!/bin/sh +i=0 +while [ "$i" -lt 12000 ]; do + printf '\033[3%dm%05d\033[0m the quick brown fox jumps over the lazy dog, again and again and again\r\n' $((i % 7 + 1)) "$i" + i=$((i + 1)) +done +exec /bin/cat +AGENT +chmod +x "$WORK/bin/agent" +printf '{"prewarm_agents":false,"prewarm_sessions":false}\n' > "$WORK/data/config.json" + +export NEBULA_RUNTIME_DIR="$RUNTIME" NEBULA_DATA_DIR="$WORK/data" NEBULA_AGENT_CMD="$WORK/bin/agent" \ + NEBULA_UPDATE_CHECK_SECS=0 NEBULA_GH_FIXTURES="$REPO/scripts/shot/fixtures" \ + PATH="$WORK/bin:$PATH" TERM=xterm-256color NEBULA_PERF_LOG="$OUT/perf.jsonl" +"$BIN" add "$DEMO" >/dev/null # registers the PROJECT (spawns the daemon) +# A second, small project, so the Projects panel has somewhere to go. +git init -q -b main "$WORK/tiny" +git -C "$WORK/tiny" -c user.name=perf -c user.email=perf@example.invalid commit -q --allow-empty -m tiny +"$BIN" add "$WORK/tiny" >/dev/null + +now_us() { python3 -c 'import time; print(int(time.time() * 1e6))'; } +printf '%s\tlaunch\t\n' "$(now_us)" > "$OUT/steps.tsv" +$TMUX new-session -d -x "$COLS" -y "$ROWS" "$BIN" +sleep "${PERF_BOOT_SECS:-4}" # first paint + the first GIT POLL answers + +# Memory, twice a second for the whole run: the TUI's RSS and the daemon's (its sessions are the stand-in +# agent, so this is nebula's own footprint). A faster UI that holds more is not a win; the report prints +# the peak of each beside the latencies. +TUI_PID="$($TMUX display-message -p '#{pane_pid}')" +( while kill -0 "$TUI_PID" 2>/dev/null; do + d="$(cat "$RUNTIME/daemon.pid" 2>/dev/null || true)" + printf '%s\t%s\n' "$(ps -o rss= -p "$TUI_PID" | tr -d ' ')" "$( [ -n "$d" ] && ps -o rss= -p "$d" | tr -d ' ')" >> "$OUT/rss.tsv" + sleep 0.5 + done ) & +step=0 +while IFS=$'\t' read -r label key; do + case "$label" in ''|'#'*) continue;; esac + step=$((step + 1)) + printf '%s\t%s\t%s\n' "$(now_us)" "$label" "$key" >> "$OUT/steps.tsv" + case "$key" in + text:*) $TMUX send-keys -l "${key#text:}";; + wait:*) sleep "${key#wait:}";; + click:*) c="${key#click:}" # click:, — 1-based cells, SGR press + release + $TMUX send-keys -l "$(printf '\033[<0;%d;%dM\033[<0;%d;%dm' "${c%,*}" "${c#*,}" "${c%,*}" "${c#*,}")";; + wheel:*) c="${key#wheel:}" # wheel:, — one notch up (into the scrollback) + $TMUX send-keys -l "$(printf '\033[<64;%d;%dM' "${c%,*}" "${c#*,}")";; + *) $TMUX send-keys "$key";; + esac + sleep "${STEP_SECS:-0.7}" + if [ -n "${PERF_DUMP:-}" ]; then $TMUX capture-pane -pN > "$OUT/$(printf '%02d' "$step")-${label// /_}.txt"; fi +done < "$SCENARIO" +printf '%s\tend\t\n' "$(now_us)" >> "$OUT/steps.tsv" +$TMUX send-keys C-q; sleep 0.3; $TMUX send-keys q; sleep 0.3; $TMUX send-keys y; sleep 0.5 + +python3 "$HERE/report.py" "$OUT" | tee "$OUT/report.txt" +echo "perf: $OUT" diff --git a/scripts/perf/scenario.steps b/scripts/perf/scenario.steps new file mode 100644 index 00000000..ebe606cd --- /dev/null +++ b/scripts/perf/scenario.steps @@ -0,0 +1,127 @@ +# labelkey — see run.sh. The demo starts with one project (three checkouts, main dirty) and no +# sessions, FOCUS on the Projects panel. +focus worktrees l +worktree down j +worktree up k +focus sessions l +new session picker n +picker: take harness Enter +task box: launch empty Enter +leave pane C-q +new session picker (2) n +picker: take harness (2) Enter +task box: launch empty (2) Enter +leave pane (2) C-q +new terminal t +leave pane (3) C-q +session up k +session up (2) k +session down j +session down (2) j +attach (Enter) Enter +type a key in pane a +type a key in pane (2) b +type a key in pane (3) c +wheel up in pane wheel:120,20 +wheel up in pane (2) wheel:120,20 +leave pane (4) C-q +click session row click:50,12 +click other session row click:50,9 +click worktree row click:25,20 +click worktree row back click:25,9 +open diff g +diff: next file Down +diff: next file (2) Down +diff: prev file Up +diff: filter text:ui +diff: clear filter Escape +diff: close Escape +open file finder f +finder: type text:event +finder: down Down +finder: clear Escape +finder: close Escape +open tree browser b +tree: down Down +tree: down (2) Down +tree: filter text:perf +tree: clear Escape +tree: filter to a 1 MB file text:tui/src/event_loop.rs +tree: onto the file Down +tree: onto the file (2) Down +tree: clear (2) Escape +tree: close Escape +open grep F +grep: type text:ATTACH_DEBOUNCE +grep: clear Escape +grep: close Escape +open palette / +palette: type text:feat +palette: clear Escape +palette: close Escape +open settings s +settings: next tab Tab +settings: down j +settings: close Escape +open help ? +help: close Escape +open workspace picker w +workspace picker: close Escape +open issues i +issues: down j +issues: close Escape +open branch switcher c +branch switcher: type text:fea +branch switcher: clear Escape +branch switcher: close Escape +open metrics M +metrics: close Escape +open presets e +presets: close Escape +open quick prompt p +quick prompt: type text:fix the login redirect +quick prompt: cancel Escape +context menu m +context menu: close Escape +toggle projects panel P +toggle projects panel back P +toggle workspaces bar W +toggle workspaces bar back W +fullscreen pane z +leave fullscreen C-q +next attention ] +leave pane (5) C-q +prev attention [ +leave pane (6) C-q +rename session r +rename: type text:x +rename: submit Enter +archive session a +show archived A +hide archived A +delete session d +delete: confirm y +open repo in browser G +focus worktrees (2) h +new worktree prompt n +new worktree: type text:perf branch +new worktree: submit Enter +wait for worktree wait:3 +worktree down to new j +worktree up again k +quick prompt new worktree p +quick prompt nw: type text:do the thing +quick prompt nw: launch Enter +wait for launch wait:3 +focus projects h +project down j +project up k +add project prompt o +add project: cancel Escape +workspace picker w +workspace picker: new n +workspace picker: name text:second +workspace picker: create Enter +back to workspace 1 1 +to workspace 2 2 +back to workspace 1 (2) 1 diff --git a/scripts/shot/bin/git b/scripts/shot/bin/git index b50073c5..9ec546e2 100755 --- a/scripts/shot/bin/git +++ b/scripts/shot/bin/git @@ -2,7 +2,9 @@ # A stand-in `git` for the SCREENSHOT HARNESS: the real git, except that `worktree add` first sleeps # NEBULA_SHOT_SLOW_GIT_SECS seconds when that is set — the window in which a QUICK PROMPT's stand-in # WORKTREE and SESSION rows are on screen, which a real `git worktree add` on the demo repo closes in -# milliseconds. Unset (every other scene), it is exactly git. +# milliseconds. NEBULA_SHOT_SLOW_GIT_MATCH names another subcommand to slow instead (`ls-files`, +# `status`): the window in which a BACKGROUND READ's modal is up ahead of its listing. Unset (every +# other scene), it is exactly git. set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" real="" @@ -13,6 +15,6 @@ for d in "${dirs[@]}"; do done [ -n "$real" ] || { echo "shot git: no real git on PATH" >&2; exit 1; } if [ -n "${NEBULA_SHOT_SLOW_GIT_SECS:-}" ]; then - case " $* " in *" worktree add "*) sleep "$NEBULA_SHOT_SLOW_GIT_SECS";; esac + case " $* " in *" ${NEBULA_SHOT_SLOW_GIT_MATCH:-worktree add} "*) sleep "$NEBULA_SHOT_SLOW_GIT_SECS";; esac fi exec "$real" "$@" diff --git a/scripts/shot/scenes/diff-reading.keys b/scripts/shot/scenes/diff-reading.keys new file mode 100644 index 00000000..01058d84 --- /dev/null +++ b/scripts/shot/scenes/diff-reading.keys @@ -0,0 +1 @@ +g diff --git a/scripts/shot/scenes/diff-reading.setup.sh b/scripts/shot/scenes/diff-reading.setup.sh new file mode 100644 index 00000000..a58952f6 --- /dev/null +++ b/scripts/shot/scenes/diff-reading.setup.sh @@ -0,0 +1,5 @@ +# The DIFF VIEWER up ahead of its file list: `git status` is held (scripts/shot/bin/git), which also +# keeps the changed-files badge from ever having a list for `g` to open on — so the shot is the +# first-open state, `reading changes…`, that a cold `git status` on a large checkout shows. +export NEBULA_SHOT_SLOW_GIT_SECS=8 NEBULA_SHOT_SLOW_GIT_MATCH=status +printf 'work in progress\n' > "$DEMO/notes.txt" diff --git a/scripts/shot/scenes/finder-listing.keys b/scripts/shot/scenes/finder-listing.keys new file mode 100644 index 00000000..62286c36 --- /dev/null +++ b/scripts/shot/scenes/finder-listing.keys @@ -0,0 +1,2 @@ +f +read diff --git a/scripts/shot/scenes/finder-listing.setup.sh b/scripts/shot/scenes/finder-listing.setup.sh new file mode 100644 index 00000000..cc686258 --- /dev/null +++ b/scripts/shot/scenes/finder-listing.setup.sh @@ -0,0 +1,4 @@ +# The FILE FINDER up ahead of its listing: `git ls-files` is held for a few seconds (scripts/shot/bin/git) +# so the shot catches what a large checkout shows for a moment — the modal open on the keypress, the +# query typed meanwhile kept, `listing files…` where the rows will be. +export NEBULA_SHOT_SLOW_GIT_SECS=8 NEBULA_SHOT_SLOW_GIT_MATCH=ls-files diff --git a/vendor/vt100/src/grid.rs b/vendor/vt100/src/grid.rs index 6dfcfb8a..870ef93e 100644 --- a/vendor/vt100/src/grid.rs +++ b/vendor/vt100/src/grid.rs @@ -195,6 +195,20 @@ impl Grid { self.scrollback_offset } + // NEBULA PATCH: how many rows the scrollback holds, and a way to let + // them go. The TUI keeps the screens of recently shown sessions for an + // instant return; a long history is tens of megabytes of cells it does + // not need for that, and drops — a full replay brings it back if the + // user scrolls. + pub fn scrollback_rows(&self) -> usize { + self.scrollback.len() + } + + pub fn clear_scrollback(&mut self) { + self.scrollback = std::collections::VecDeque::new(); + self.scrollback_offset = 0; + } + pub fn set_scrollback(&mut self, rows: usize) { self.scrollback_offset = rows.min(self.scrollback.len()); } diff --git a/vendor/vt100/src/screen.rs b/vendor/vt100/src/screen.rs index 7dfec97d..fbce7203 100644 --- a/vendor/vt100/src/screen.rs +++ b/vendor/vt100/src/screen.rs @@ -123,6 +123,21 @@ impl Screen { self.grid().scrollback() } + /// NEBULA PATCH: rows held in the primary screen's scrollback — + /// whichever screen is showing, since a full-screen program parked + /// over a long shell history still holds that history. + #[must_use] + pub fn scrollback_rows(&self) -> usize { + self.grid.scrollback_rows() + } + + /// NEBULA PATCH: drop the primary screen's scrollback and release its + /// memory. The visible screen, the cursor and every mode are untouched, + /// so output parsed afterwards lands exactly as it would have. + pub fn clear_scrollback(&mut self) { + self.grid.clear_scrollback(); + } + /// Returns the text contents of the terminal. /// /// This will not include any formatting information, and will be in plain From 1f3664f2a7f2840416b8109ed4e77c0bc6d9c187 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Thu, 17 Sep 2026 11:23:45 -0400 Subject: [PATCH 2/2] The latency probe never logs a typed character MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NEBULA_PERF_LOG` times keys; it does not record them. A plain or shifted character is `key:char` in the log — it is as likely typed at an agent, a shell or a password prompt as at a panel, the rule the KEY COMBO DISPLAY already keeps — while chords and named keys (`key:C-q`, `key:Enter`) still say which command they were. The report reads step labels from the driver, so nothing it prints changes. Co-Authored-By: Claude Fable 5.1 --- crates/nebula-tui/src/perf.rs | 60 +++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/nebula-tui/src/perf.rs b/crates/nebula-tui/src/perf.rs index e6189a3c..aa985442 100644 --- a/crates/nebula-tui/src/perf.rs +++ b/crates/nebula-tui/src/perf.rs @@ -2,7 +2,8 @@ //! JSON line per input event, per painted frame and per daemon event, so //! "does this key feel instant" is a number rather than an impression. //! -//! * `input` — what arrived (`key:g`, `mouse:down`), how long its handler +//! * `input` — what arrived (`key:char`, `key:C-q`, `mouse:down` — never the +//! character typed), how long its handler //! held the loop (`handler_us`), and where that left the app (overlay, //! FOCUS). A handler that shells out to git shows up here. //! * `frame` — how long the draw took (`draw_us`), what it showed (overlay @@ -164,8 +165,14 @@ fn overlay_name(app: &App) -> &'static str { } } -/// `key:g`, `key:C-d`, `key:S-Tab`, `mouse:down`, `paste`, … — None for -/// the events nobody waits on (pointer motion, focus reports, releases). +/// The modifiers that make a character key a command rather than text. +const CHORD: KeyModifiers = KeyModifiers::CONTROL + .union(KeyModifiers::ALT) + .union(KeyModifiers::SUPER); + +/// `key:char`, `key:C-d`, `key:S-Tab`, `key:Enter`, `mouse:down`, `paste`, … +/// — None for the events nobody waits on (pointer motion, focus reports, +/// releases). What was typed is never in it: see the `Char` arm. pub fn label(event: &Event) -> Option { match event { Event::Key(key) if key.kind != crossterm::event::KeyEventKind::Release => { @@ -180,6 +187,11 @@ pub fn label(event: &Event) -> Option { } } match key.code { + // Never the character itself. A plain key is as likely typed + // at an agent, a shell or a password prompt as at a panel, + // and a log is not where that belongs — the rule the KEY + // COMBO DISPLAY keeps. A chord is a command, and is named. + KeyCode::Char(_) if !key.modifiers.intersects(CHORD) => s.push_str("char"), KeyCode::Char(c) => s.push(c), KeyCode::BackTab => s.push_str("S-Tab"), other => s.push_str(&format!("{other:?}")), @@ -212,3 +224,45 @@ pub fn server_name(ev: &nebula_core::protocol::ServerEvent) -> &'static str { _ => "other", } } + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyEvent; + + fn key(code: KeyCode, mods: KeyModifiers) -> Event { + Event::Key(KeyEvent::new(code, mods)) + } + + /// The probe times keys; it does not record them. A run left on by + /// accident must not turn into a keylog of what was typed at an agent. + #[test] + fn a_typed_character_is_never_in_the_log() { + for c in ['a', 'Z', '7', '!', ' '] { + for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] { + assert_eq!( + label(&key(KeyCode::Char(c), mods)).as_deref(), + Some("key:char"), + "{c:?} with {mods:?}" + ); + } + } + } + + /// Chords and named keys are commands, and say which. + #[test] + fn chords_and_named_keys_are_named() { + assert_eq!( + label(&key(KeyCode::Char('q'), KeyModifiers::CONTROL)).as_deref(), + Some("key:C-q") + ); + assert_eq!( + label(&key(KeyCode::Enter, KeyModifiers::NONE)).as_deref(), + Some("key:Enter") + ); + assert_eq!( + label(&key(KeyCode::BackTab, KeyModifiers::SHIFT)).as_deref(), + Some("key:S-Tab") + ); + } +}