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 bab661b5..929dd551 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1184,6 +1184,10 @@ dependencies = [ "vt100", ] +[[package]] +name = "nebula-fuzzy" +version = "0.30.0" + [[package]] name = "nebula-tui" version = "0.30.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 c8672843..bc15eee9 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 0329c149..2de29933 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -24,14 +24,19 @@ pub const SWEEP_FRAME: std::time::Duration = std::time::Duration::from_millis(10 /// Only running and needs-feedback rows sweep for as long as they last. pub const ONE_SHOT_SWEEP: std::time::Duration = std::time::Duration::from_secs(5); -/// 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`. @@ -861,6 +866,29 @@ 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 file list folded into a directory tree (`Ctrl+t`, see /// `diff_tree`); `None` is the flat list. While it is up the cursor is /// the tree's — `selected` and `matches` stay current underneath, so @@ -868,7 +896,61 @@ pub struct DiffView { pub tree: Option, } +/// 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, @@ -890,6 +972,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(), tree: None, }; view.apply_filter(); @@ -989,6 +1077,32 @@ impl DiffView { window_start(self.cursor(), height) } + /// Whether the cursor is still where opening the modal put it: the top + /// of the flat list, the tree's home row. A reader who has moved keeps + /// the file they are on when a fresh listing lands (`git_diff:: + /// fill_view`); one who has not gets what a fresh open gives. + pub fn at_home(&self) -> bool { + match &self.tree { + Some(tree) => tree.selected == tree.home_row(), + None => self.selected == 0, + } + } + + /// The next file down from the cursor in whichever list is showing — + /// what the DIFF VIEWER reads ahead, `↓` being the key it is walked + /// with. Tree directories are stepped over: they have no diff to read. + pub fn file_after_cursor(&self) -> Option<&DiffFile> { + match &self.tree { + Some(tree) => tree + .rows + .iter() + .skip(tree.selected + 1) + .find_map(|row| tree.file_of[row.node]) + .and_then(|file| self.files.get(file)), + None => self.files.get(self.matches.get(self.selected + 1)?.file), + } + } + /// Recompute the rows from `filter` and send the cursor home — the top /// row, or in the tree the best match (its first file when the filter /// is empty); true when that moved it off the row it was on (the caller @@ -1123,6 +1237,11 @@ impl DiffView { /// row has no mark of its own to toggle. 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 { @@ -1198,6 +1317,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 { @@ -1212,11 +1336,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 { @@ -1271,6 +1412,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 { @@ -1286,6 +1435,9 @@ impl GrepView { selected: 0, area: Rect::default(), list_area: Rect::default(), + jobs: None, + waiting: None, + cancel: crate::view_jobs::Cancel::default(), } } @@ -1294,12 +1446,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; @@ -1719,9 +1915,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 @@ -2303,6 +2514,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 { @@ -2318,6 +2537,8 @@ impl AttachedTerm { painted: false, booting: false, next_seq: 0, + history_dropped: false, + pending_scroll: None, } } @@ -2334,6 +2555,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 @@ -2356,13 +2578,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]) { @@ -2382,22 +2620,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); @@ -3035,6 +3277,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, @@ -3193,6 +3454,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, @@ -3663,23 +3928,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/diff_tree.rs b/crates/nebula-tui/src/diff_tree.rs index ab405d9b..556b3a9f 100644 --- a/crates/nebula-tui/src/diff_tree.rs +++ b/crates/nebula-tui/src/diff_tree.rs @@ -113,7 +113,7 @@ impl DiffTree { /// first, so the top row is nearly always one, and this modal is for /// reading diffs, not folder summaries. The top row when every file is /// folded away. - fn home_row(&self) -> usize { + pub(crate) fn home_row(&self) -> usize { self.best_row .or_else(|| { self.rows diff --git a/crates/nebula-tui/src/event_loop.rs b/crates/nebula-tui/src/event_loop.rs index d90c99a6..7acf9631 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, @@ -333,6 +338,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 { @@ -342,9 +350,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); } @@ -486,13 +498,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 => { @@ -515,7 +537,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. @@ -580,6 +604,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"); @@ -590,6 +620,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() { @@ -686,10 +719,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; } @@ -702,11 +732,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. @@ -1698,14 +1751,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); @@ -1997,9 +2063,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() => { @@ -2038,6 +2113,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(); @@ -2874,13 +2971,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()), } } @@ -2941,15 +3045,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, @@ -3130,59 +3228,89 @@ 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; - } - 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); - // After the marks: the tree opens on the first unreviewed file too. - if app.diff_tree { - view.toggle_tree(); + 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; + if app.diff_tree { + view.toggle_tree(); + } + // 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.replace_files(files.clone()); + crate::git_diff::load_selected_diff(&mut view); + } + app.overlay = Some(Overlay::Diff(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); + 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); + // After the marks: the tree opens on the first unreviewed file too. + if app.diff_tree && view.toggle_tree() { + crate::git_diff::load_selected_diff(&mut view); } - view.recompute_matches(); + app.overlay = Some(Overlay::Diff(view)); } /// Fuzzy file finder over every tracked + untracked file of the selected @@ -3200,12 +3328,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 @@ -3214,6 +3366,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; }; @@ -3227,7 +3388,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 @@ -3484,10 +3766,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 @@ -5895,28 +6174,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| { @@ -5926,13 +6186,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 @@ -6057,16 +6311,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 @@ -7155,10 +7406,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 { @@ -7169,6 +7421,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) { @@ -7656,12 +7935,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 @@ -7747,13 +8042,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"))] { @@ -7761,6 +8052,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); @@ -8577,6 +8893,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; } @@ -8908,10 +9227,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 @@ -9020,6 +9346,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, @@ -9775,6 +10104,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 @@ -15301,7 +15713,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(), }, ); @@ -18339,13 +18752,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 { @@ -18354,8 +18770,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, @@ -18366,19 +18782,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() { @@ -24872,7 +25442,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(); @@ -24903,16 +25475,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), @@ -24926,9 +25490,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 1b2343f8..7af33fbf 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}; @@ -180,10 +180,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 27c47efc..8cc39ff6 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,23 +246,191 @@ 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.at_home() || 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(); + // The tree folds the fresh list the same way, keeping what the reader + // folded; both lists then send the cursor home. + if let Some(tree) = &view.tree { + view.tree = Some(tree.rebuilt(&view.files, &view.filter)); + } + view.selected = 0; + // Home is the first unreviewed file — the flat list sinks the ✓ ones — + // and the tree lands on it too. + let home = view + .matches + .first() + .map(|m| view.files[m.file].path.clone()); + let kept = before + .as_ref() + .filter(|_| moved) + .is_some_and(|was| view.select_path(&was.path)); + if let (false, Some(path)) = (kept, home) { + view.select_path(&path); + } + // 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 directory row of the tree -/// list has no diff of its own: the pane lists what changed under it. +/// git about, and the text is already in hand. A directory row of the +/// tree list has no diff of its own: the pane lists what changed under +/// it. +/// +/// 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 { + let summary = view.dir_summary().unwrap_or_default(); + view.show_diff(None, summary, 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, _) => view.dir_summary().unwrap_or_default(), + .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 + .file_after_cursor() + .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)] @@ -266,6 +438,134 @@ 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 tree list lands a fresh listing the same way the flat one does: + /// folded over the new files, the reader's folds kept, an unmoved + /// reader on the first unreviewed file and a moved one on theirs. + #[test] + fn the_fresh_list_lands_in_the_tree_the_same_way() { + let mut view = opened_on(&["src/a.rs", "src/b.rs"]); + view.toggle_tree(); + fill_view(&mut view, listing(&["src/a.rs", "src/b.rs"], &["src/a.rs"])); + assert!(view.tree.is_some(), "still the tree"); + assert_eq!(selected(&view), Some("src/b.rs"), "the first unreviewed"); + + // The reader is on b.rs; a file that turned up since joins the tree + // under them. + fill_view(&mut view, listing(&["new.rs", "src/a.rs", "src/b.rs"], &[])); + assert_eq!(selected(&view), Some("src/b.rs")); + assert!(view.select_path("new.rs"), "a row of its own now"); + } + + /// The read-ahead walks the list that is showing: in the tree that + /// means the next file down, directories stepped over. + #[test] + fn the_row_read_ahead_is_the_next_file_of_the_list_on_screen() { + let mut view = opened_on(&["src/a.rs", "src/b.rs"]); + assert_eq!( + view.file_after_cursor().map(|f| f.path.as_str()), + Some("src/b.rs") + ); + + view.toggle_tree(); + // Up onto the `src/` row: the file after it is still a.rs. + view.select_path("src"); + assert_eq!( + view.file_after_cursor().map(|f| f.path.as_str()), + Some("src/a.rs") + ); + view.select_path("src/b.rs"); + assert_eq!(view.file_after_cursor(), None, "the end of the list"); + } + + /// 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 f17e1e7e..2af88733 100644 --- a/crates/nebula-tui/src/lib.rs +++ b/crates/nebula-tui/src/lib.rs @@ -25,6 +25,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; @@ -41,6 +42,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..aa985442 --- /dev/null +++ b/crates/nebula-tui/src/perf.rs @@ -0,0 +1,268 @@ +//! 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: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 +//! 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", + } +} + +/// 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 => { + 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 { + // 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:?}")), + } + 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", + } +} + +#[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") + ); + } +} diff --git a/crates/nebula-tui/src/tree_browser.rs b/crates/nebula-tui/src/tree_browser.rs index 92a29678..2315537a 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(); @@ -289,10 +391,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 @@ -307,26 +415,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. @@ -440,11 +579,12 @@ pub(crate) fn visible_rows( let mut name_positions: Vec> = vec![Vec::new(); nodes.len()]; let mut scores: Vec> = vec![None; nodes.len()]; let mut match_count = 0; + let mut matcher = crate::fuzzy::Matcher::new(filter); for i in 0..nodes.len() { if nodes[i].is_dir { continue; } - let Some(m) = crate::fuzzy::fuzzy_match(filter, &nodes[i].path) else { + let Some(m) = matcher.matches(&nodes[i].path) else { continue; }; match_count += 1; diff --git a/crates/nebula-tui/src/ui.rs b/crates/nebula-tui/src/ui.rs index 2e3578cb..512853a5 100644 --- a/crates/nebula-tui/src/ui.rs +++ b/crates/nebula-tui/src/ui.rs @@ -1447,7 +1447,9 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { // Left: changed-file list — flat paths, or the directory tree // (`Ctrl+t`); 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()) @@ -1474,7 +1476,9 @@ fn draw_overlay(f: &mut Frame, app: &mut App) { } let list_inner = below_first_row(files_inner); - if view.row_count() == 0 { + if view.listing.is_some() && view.files.is_empty() { + empty_list_row(f, list_inner, "reading changes…", th); + } else if view.row_count() == 0 { empty_list_row(f, list_inner, NO_MATCHES, th); } let start = view.window_start(list_inner.height as usize); @@ -1593,9 +1597,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), @@ -1607,7 +1616,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. @@ -1732,7 +1741,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!( @@ -1751,7 +1764,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); @@ -1779,6 +1794,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) ", @@ -1812,7 +1829,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 @@ -2055,7 +2072,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!( @@ -2074,7 +2093,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 860633f2..de9955f8 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