From 7f4b76cc2696a15164418c271a6bf5b1a31274bb Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 28 Aug 2026 22:41:33 +0900 Subject: [PATCH 01/42] fix(terminal): converge panes on the final resize --- docs/architecture/session.md | 17 ++- docs/architecture/terminal.md | 4 +- src/backend/hub.rs | 11 +- src/backend/mod.rs | 10 +- src/backend/pty.rs | 29 +++-- src/backend/pty_tests/lifecycle.rs | 9 ++ src/runtime/terminal/lifecycle.rs | 57 ++------- src/runtime/terminal/mod.rs | 20 ++- src/runtime/terminal/resize.rs | 88 +++++++++++++ .../terminal/tests/size_owner_tests.rs | 116 +++++++++++++++++- src/session/terminal/hub_helpers.rs | 18 +-- src/session/terminal/hub_layout.rs | 72 +++++++++-- src/session/terminal/hub_run.rs | 16 +-- src/session/terminal/mod.rs | 7 +- src/session/terminal/session.rs | 9 +- src/session/terminal/tests/backpressure.rs | 52 +++++++- src/session/terminal/tests/behavior.rs | 30 ++++- src/session/terminal/tests/size_owner.rs | 3 +- src/test_util.rs | 43 ++++++- 19 files changed, 489 insertions(+), 122 deletions(-) create mode 100644 src/runtime/terminal/resize.rs diff --git a/docs/architecture/session.md b/docs/architecture/session.md index 8e8b6b33..2ee23c4c 100644 --- a/docs/architecture/session.md +++ b/docs/architecture/session.md @@ -14,7 +14,7 @@ trait TerminalBackend { fn create_pane(&mut self, rows: u16, cols: u16, command: Option<&str>) -> Result<()>; fn destroy_pane(&mut self, id: PaneId); fn send_input(&mut self, id: PaneId, data: &[u8]) -> Result<()>; - fn resize(&mut self, id: PaneId, rows: u16, cols: u16); + fn resize(&mut self, id: PaneId, rows: u16, cols: u16) -> Result; fn reorder(&mut self, order: &[PaneId]); // 기본 no-op fn claim_size(&mut self); // 기본 no-op fn drain_events(&mut self) -> Vec; @@ -35,7 +35,9 @@ trait TerminalBackend { 알린다. 이벤트가 `requested`를 실어 **내가 연 pane만** 포커스를 가져간다 — 어느 pane을 보고 있는지는 클라이언트 각자의 일이다. 제목도 같은 규칙으로 큐에 대기했다 도착 시 붙는다. 2. **크기는 이 클라이언트가 정하는 것이 아닐 수 있다**(아래 "PTY 크기" 참고). `Resized`를 - 따라가고, 소유하지 않으면 `resize`를 보내지 않는다. + 따라가고, 소유하지 않으면 `resize`를 보내지 않는다. 로컬 `PtyBackend`는 성공 시 + `Applied`, 원격 `HubBackend`는 서버 확인이 남았다는 `Pending`을 반환한다. 호출 실패는 + `Result`로 전파되며 적용 성공처럼 에뮬레이터나 세션 상태에 기록하지 않는다. 3. **순서도 세션의 것이다.** `swap_active_with`는 `reorder` 요청이고, `panes`는 `Reordered`가 투영하는 서버 canonical order다. 4. VT 에뮬레이션은 어느 쪽이든 **클라이언트가 한다** — `PaneEmulator`가 소켓에서 온 바이트를 @@ -131,8 +133,15 @@ alternate screen을 쓰는 풀스크린 TUI를 나중에 다시 흘릴 방법은 클라이언트가 볼 수 없는 이유로도 일어난다(마지막 커넥션이 끊김, worker tick에서 유예 만료). 기록이 없으면 나중에 읽을 것이 증상뿐이다. - 비소유자의 resize는 버려지고 **실제 적용된 크기가 브로드캐스트된다** — 관전자의 에뮬레이터도 - 자식이 감는 곳에서 감아야 하기 때문이다. 소유자도 그것을 읽되("clamp됐다"를 그렇게 안다) - "내가 요청한 값" 기록은 유지한다. 그러지 않으면 매 프레임 같은 clamp를 다시 요청한다. + 자식이 감는 곳에서 감아야 하기 때문이다. 소유자는 `desired`(현재 레이아웃), `pending`(마지막 + 전송과 시각), `confirmed`(`Resized`로 확인한 실제 크기)를 분리한다. 늦은 이전 ACK가 에뮬레이터를 + 과거 폭으로 돌려도 `desired != confirmed`가 남아 최종 폭을 다시 요청하며, ACK가 오지 않으면 + 100 ms 뒤 재시도한다. 서버는 이미 같은 크기인 재시도에도 `Resized`를 답한다. +- **resize는 일반 terminal command queue에 넣지 않는다.** 입력과 create/close가 쓰는 bounded + queue가 가득 차도 창 드래그의 마지막 폭은 잃으면 안 되므로, hub가 connection·pane별 최신 값만 + 별도 보관해 worker tick에서 합성 처리한다. 중간 폭은 버려도 되지만 마지막 폭은 반드시 한 번 + 적용을 시도한다. `portable-pty`/ConPTY/TIOCSWINSZ resize가 실패하면 pane 상태와 mode emulator를 + 갱신하거나 `Resized`를 브로드캐스트하지 않는다. - 입력마다 소유권을 옮기는 대안은 기각했다 — 폰으로 잠깐 확인하는 제일 가벼운 행동이 전체 repaint를 유발하는 제일 비싼 행동이 된다. 부수 효과로 **비소유 클라이언트가 곧 관전자**여서 별도 관전 모드가 필요 없고, 영역과 그리드가 다르면 렌더 경로가 clamp로 처리한다. diff --git a/docs/architecture/terminal.md b/docs/architecture/terminal.md index 7706bc68..9dd9b834 100644 --- a/docs/architecture/terminal.md +++ b/docs/architecture/terminal.md @@ -56,7 +56,9 @@ - **Sizing invariant**: `ui::terminal_tab::visible_pane_cells`가 pane Rect의 단일 출처다. `render`가 매 프레임 여기서 그리고, `ui::terminal_content_areas` → `main_loop`의 `resize_visible_panes`도 같은 함수를 읽으므로 pane의 backend PTY + 에뮬레이터 크기가 그려진 셀과 정확히 일치한다. **새 - 호출 지점에서 pane 크기를 독립적으로 계산하지 말고 이 함수를 통과시킬 것.** + 호출 지점에서 pane 크기를 독립적으로 계산하지 말고 이 함수를 통과시킬 것.** 원격 backend에서는 + 요청 직후 에뮬레이터를 낙관적으로 바꾸지 않고 세션의 `Resized` 확인을 따라간다. 원하는 크기와 + 확인된 크기가 다르면 재요청하므로 빠른 연속 resize의 마지막 셀 크기로 수렴한다. - **Input/scroll scope는 그대로**: 키보드 입력, paste, prompt 로깅, 터미널 스크롤 (`TerminalState::active_pane_rows`가 페이지 크기)은 여러 pane이 그려져도 active pane만 겨냥한다. - **Accent는 "active pane"이 아니라 진짜 포커스를 뜻한다**: accent 색은 앱 전역에서 "이 영역이 diff --git a/src/backend/hub.rs b/src/backend/hub.rs index 797152e8..ce323bd8 100644 --- a/src/backend/hub.rs +++ b/src/backend/hub.rs @@ -9,7 +9,7 @@ //! VT emulation still happens here: the bytes are raw either way, so //! `PaneEmulator` reads them from a socket exactly as it read them from a PTY. -use super::{BackendEvent, PaneId, TerminalBackend}; +use super::{BackendEvent, PaneId, ResizeOutcome, TerminalBackend}; use crate::daemon::terminal_link::{TerminalLink, TerminalMessage}; use crate::session::terminal::frame::{ ClientMessage as HubClientMessage, ServerMessage as HubServerMessage, @@ -78,14 +78,13 @@ impl TerminalBackend for HubBackend { self.link.send(HubClientMessage::Input { pane: id, data }) } - fn resize(&mut self, id: PaneId, rows: u16, cols: u16) { - if let Err(err) = self.link.send(HubClientMessage::Resize { + fn resize(&mut self, id: PaneId, rows: u16, cols: u16) -> Result { + self.link.send(HubClientMessage::Resize { pane: id, rows, cols, - }) { - tracing::warn!(%err, pane = id, "could not resize a pane in the session"); - } + })?; + Ok(ResizeOutcome::Pending) } fn reorder(&mut self, order: &[PaneId]) { diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 1e1126e1..5bae526a 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -11,6 +11,14 @@ use anyhow::Result; pub type PaneId = u32; +/// Whether a successful resize call has already changed the PTY or only queued +/// a request whose eventual size will arrive as [`BackendEvent::Resized`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResizeOutcome { + Applied, + Pending, +} + #[derive(Debug)] pub enum BackendEvent { /// A pane now exists. Reported rather than returned from `create_pane` @@ -90,7 +98,7 @@ pub trait TerminalBackend { fn create_pane(&mut self, rows: u16, cols: u16, command: Option<&str>) -> Result<()>; fn destroy_pane(&mut self, id: PaneId); fn send_input(&mut self, id: PaneId, data: &[u8]) -> Result<()>; - fn resize(&mut self, id: PaneId, rows: u16, cols: u16); + fn resize(&mut self, id: PaneId, rows: u16, cols: u16) -> Result; fn drain_events(&mut self) -> Vec; /// Ask for the panes to be put in this order. diff --git a/src/backend/pty.rs b/src/backend/pty.rs index 2677fe80..057312a2 100644 --- a/src/backend/pty.rs +++ b/src/backend/pty.rs @@ -1,5 +1,5 @@ use super::slot::{PaneSlot, PaneSlots}; -use super::{BackendEvent, PaneId, TerminalBackend}; +use super::{BackendEvent, PaneId, ResizeOutcome, TerminalBackend}; use crate::config::ShellConfig; use crate::platform::threading::try_timed_join; use anyhow::Result; @@ -202,17 +202,22 @@ impl TerminalBackend for PtyBackend { Ok(()) } - fn resize(&mut self, id: PaneId, rows: u16, cols: u16) { - if let Some(pane) = self.panes.get_mut(&id) - && let Some(master) = pane.master.as_mut() - { - let _ = master.resize(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }); - } + fn resize(&mut self, id: PaneId, rows: u16, cols: u16) -> Result { + let pane = self + .panes + .get_mut(&id) + .ok_or_else(|| anyhow::anyhow!("pane {id} not found"))?; + let master = pane + .master + .as_mut() + .ok_or_else(|| anyhow::anyhow!("pane {id} PTY master already released"))?; + master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + })?; + Ok(ResizeOutcome::Applied) } fn drain_events(&mut self) -> Vec { diff --git a/src/backend/pty_tests/lifecycle.rs b/src/backend/pty_tests/lifecycle.rs index 2b484752..20b66206 100644 --- a/src/backend/pty_tests/lifecycle.rs +++ b/src/backend/pty_tests/lifecycle.rs @@ -9,6 +9,15 @@ fn pty_backend_create_and_destroy_pane() { assert!(!backend.panes.contains_key(&id)); } +#[test] +fn resizing_an_unknown_pane_is_reported() { + let mut backend = PtyBackend::new(".", ShellConfig::default()); + + let error = backend.resize(999, 24, 80).expect_err("unknown pane"); + + assert!(error.to_string().contains("pane 999 not found")); +} + #[test] fn a_pane_whose_shell_exits_reports_it() { let mut backend = PtyBackend::new(".", ShellConfig::default()); diff --git a/src/runtime/terminal/lifecycle.rs b/src/runtime/terminal/lifecycle.rs index d38f112c..b7f5d553 100644 --- a/src/runtime/terminal/lifecycle.rs +++ b/src/runtime/terminal/lifecycle.rs @@ -38,17 +38,7 @@ impl TerminalState { // this client asked for — or any it asked for. The emulator has // to wrap where the child does, so it follows. BackendEvent::Resized { pane, rows, cols } => { - let (rows, cols) = crate::runtime::emulator::effective_size(rows, cols); - if let Some(emulator) = self.emulators.get_mut(&pane) { - emulator.resize(rows, cols); - } - // Only when this client is not the one sizing. For the owner - // this map is "what I last asked for", and overwriting it - // with a clamped answer would make the next frame ask again, - // every frame. - if !self.owns_size { - self.last_content_size.insert(pane, (rows, cols)); - } + self.confirm_resize(pane, rows, cols); } // Only for a pane this client holds, like `Exited`: a marker // for a pane that is not on any of this client's tabs would @@ -72,6 +62,11 @@ impl TerminalState { // forget what was applied and let the next frame fit them. if owned && !self.owns_size { self.last_content_size.clear(); + } else if !owned && self.owns_size { + self.last_content_size = self.confirmed_content_size.clone(); + } + if owned != self.owns_size { + self.pending_content_size.clear(); } self.owns_size = owned; } @@ -191,6 +186,7 @@ impl TerminalState { self.emulators .insert(id, PaneEmulator::new(rows, cols, SCROLLBACK_LINES)); self.last_content_size.insert(id, (rows, cols)); + self.confirmed_content_size.insert(id, (rows, cols)); // The session's name first: a configured startup terminal is called the // same thing in every client, and this one did not ask for it and has no // title queued for it. Then this client's own queued title, then the @@ -222,46 +218,11 @@ impl TerminalState { } self.scroll.remove(&id); self.last_content_size.remove(&id); + self.confirmed_content_size.remove(&id); + self.pending_content_size.remove(&id); self.title_activity.remove(&id); } - /// Resize each listed pane's backend PTY and emulator to its own - /// (rows, cols), skipping a pane whose size didn't change. `layouts` - /// carries one entry per currently *visible* pane — panes scrolled out of - /// the split-view window are omitted and keep their `last_content_size` - /// until they become visible again. - /// - /// A client that does not own the sizing changes nothing here: the panes are - /// at the owner's size and its own emulators are already following - /// [`BackendEvent::Resized`](crate::backend::BackendEvent::Resized). Its - /// layout still records what it would have asked for, which is the size a - /// pane it opens is born at. - pub fn resize_visible_panes(&mut self, layouts: &[(PaneId, u16, u16)]) { - let active_id = self.active_pane_id(); - for &(id, rows, cols) in layouts { - // Shared minimum-grid clamp: PTY, emulator, and the recorded - // size must all agree, or the skip-if-unchanged check and the - // inner program's wrap width drift apart at degenerate layouts. - let (rows, cols) = crate::runtime::emulator::effective_size(rows, cols); - if Some(id) == active_id { - self.size = (rows, cols); - } - if !self.owns_size { - continue; - } - if self.last_content_size.get(&id) == Some(&(rows, cols)) { - continue; - } - if let Some(backend) = &mut self.backend { - backend.resize(id, rows, cols); - } - if let Some(emulator) = self.emulators.get_mut(&id) { - emulator.resize(rows, cols); - } - self.last_content_size.insert(id, (rows, cols)); - } - } - /// Ask the session for the sizing. The answer arrives as /// [`BackendEvent::SizeOwnership`], which is what actually flips /// [`owns_size`](Self::owns_size) and re-fits the panes. diff --git a/src/runtime/terminal/mod.rs b/src/runtime/terminal/mod.rs index b87f9239..d2100c13 100644 --- a/src/runtime/terminal/mod.rs +++ b/src/runtime/terminal/mod.rs @@ -1,12 +1,14 @@ use crate::backend::{PaneId, TerminalBackend}; use crate::runtime::emulator::PaneEmulator; use std::collections::HashMap; +use std::time::Instant; mod attention; mod escape; mod input; mod lifecycle; mod recovery; +mod resize; mod scroll; mod session_panes; mod state; @@ -105,10 +107,14 @@ pub struct TerminalState { pub size: (u16, u16), pub scroll: HashMap, pub fullscreen: TerminalFullscreen, - /// Last (rows, cols) applied to each pane's backend + emulator via - /// `resize_visible_panes`. Panes scrolled out of the visible window keep - /// whatever size they had when they were last visible. + /// Desired (rows, cols) for each pane while this client owns sizing; the + /// confirmed session size while it observes another owner. Panes scrolled + /// out of the visible window keep their last value. pub last_content_size: HashMap, + /// Last size confirmed as applied by the backend. + pub(crate) confirmed_content_size: HashMap, + /// Resize requests awaiting confirmation or a retry deadline. + pub(crate) pending_content_size: HashMap, /// Whether this client's layout is what sets the pane sizes. /// /// True unless a shared session says otherwise: a PTY has one size, so one @@ -146,6 +152,8 @@ impl TerminalState { scroll: HashMap::new(), fullscreen: TerminalFullscreen::Off, last_content_size: HashMap::new(), + confirmed_content_size: HashMap::new(), + pending_content_size: HashMap::new(), owns_size: true, recovery: HashMap::new(), visible_start: 0, @@ -162,5 +170,11 @@ impl TerminalState { } } +#[derive(Debug, Clone, Copy)] +pub(crate) struct PendingPaneResize { + size: (u16, u16), + attempted_at: Instant, +} + #[cfg(test)] mod tests; diff --git a/src/runtime/terminal/resize.rs b/src/runtime/terminal/resize.rs new file mode 100644 index 00000000..90fa98a0 --- /dev/null +++ b/src/runtime/terminal/resize.rs @@ -0,0 +1,88 @@ +use super::{PendingPaneResize, TerminalState}; +use crate::backend::{PaneId, ResizeOutcome}; +use std::time::{Duration, Instant}; + +const RESIZE_RETRY_INTERVAL: Duration = Duration::from_millis(100); + +impl TerminalState { + /// Fit each visible pane to its rendered cells. Remote backends confirm the + /// applied size asynchronously, so desired, pending, and confirmed geometry + /// remain distinct until a `Resized` event arrives. + pub fn resize_visible_panes(&mut self, layouts: &[(PaneId, u16, u16)]) { + self.resize_visible_panes_at(layouts, Instant::now()); + } + + pub(crate) fn resize_visible_panes_at(&mut self, layouts: &[(PaneId, u16, u16)], now: Instant) { + let active_id = self.active_pane_id(); + for &(id, rows, cols) in layouts { + let size = crate::runtime::emulator::effective_size(rows, cols); + if Some(id) == active_id { + self.size = size; + } + if !self.owns_size { + continue; + } + self.last_content_size.insert(id, size); + if self.confirmed_content_size.get(&id) == Some(&size) { + self.pending_content_size.remove(&id); + continue; + } + let retry_due = self.pending_content_size.get(&id).is_none_or(|pending| { + pending.size != size + || now.saturating_duration_since(pending.attempted_at) >= RESIZE_RETRY_INTERVAL + }); + if retry_due { + self.request_resize(id, size, now); + } + } + } + + fn request_resize(&mut self, id: PaneId, size: (u16, u16), now: Instant) { + let outcome = self + .backend + .as_mut() + .map(|backend| backend.resize(id, size.0, size.1)) + .unwrap_or(Ok(ResizeOutcome::Applied)); + match outcome { + Ok(ResizeOutcome::Applied) => { + if let Some(emulator) = self.emulators.get_mut(&id) { + emulator.resize(size.0, size.1); + } + self.confirmed_content_size.insert(id, size); + self.pending_content_size.remove(&id); + } + Ok(ResizeOutcome::Pending) => { + self.note_resize_attempt(id, size, now); + } + Err(err) => { + tracing::warn!(%err, pane = id, rows = size.0, cols = size.1, "could not resize a terminal pane"); + self.note_resize_attempt(id, size, now); + } + } + } + + fn note_resize_attempt(&mut self, id: PaneId, size: (u16, u16), now: Instant) { + self.pending_content_size.insert( + id, + PendingPaneResize { + size, + attempted_at: now, + }, + ); + } + + pub(super) fn confirm_resize(&mut self, pane: PaneId, rows: u16, cols: u16) { + let Some(emulator) = self.emulators.get_mut(&pane) else { + return; + }; + let size = crate::runtime::emulator::effective_size(rows, cols); + emulator.resize(size.0, size.1); + self.confirmed_content_size.insert(pane, size); + // A matching ACK completes the request; an older ACK also clears it so + // the desired/confirmed mismatch is retried immediately next frame. + self.pending_content_size.remove(&pane); + if !self.owns_size { + self.last_content_size.insert(pane, size); + } + } +} diff --git a/src/runtime/terminal/tests/size_owner_tests.rs b/src/runtime/terminal/tests/size_owner_tests.rs index 0551d187..e8981a34 100644 --- a/src/runtime/terminal/tests/size_owner_tests.rs +++ b/src/runtime/terminal/tests/size_owner_tests.rs @@ -4,7 +4,20 @@ //! render the grid they are given. These are the client's half of that. use super::common::state_with_event_queue; -use crate::backend::BackendEvent; +use crate::backend::{BackendEvent, ResizeOutcome}; +use crate::runtime::terminal::TerminalState; +use std::time::{Duration, Instant}; + +type EventQueue = std::rc::Rc>>; +type ResizeCalls = std::rc::Rc>>; + +fn state_with_pending_resize() -> (TerminalState, EventQueue, ResizeCalls) { + let backend = crate::test_util::FakeBackend::with_resize_outcome(ResizeOutcome::Pending); + let events = backend.pending_events.clone(); + let resized = backend.resized.clone(); + let state = TerminalState::new(Some(Box::new(backend)), false); + (state, events, resized) +} #[test] fn a_client_owns_its_sizes_until_a_session_says_otherwise() { @@ -64,10 +77,9 @@ fn a_spectator_follows_the_size_the_session_reports() { } #[test] -fn the_owner_follows_a_size_it_did_not_ask_for_without_asking_again() { - // Its request can come back clamped. The emulator has to follow the PTY, - // but the record of what was *asked for* must not, or every frame would - // re-send a size the hub will clamp the same way — forever. +fn the_owner_keeps_its_desired_size_separate_from_the_confirmed_size() { + // The emulator follows what the PTY reports, while the desired layout stays + // intact so an older acknowledgement cannot overwrite the final width. let (mut state, events) = state_with_event_queue(); state.create_pane_now().unwrap(); let pane = state.panes[0].id; @@ -115,3 +127,97 @@ fn taking_the_sizing_back_re_applies_this_client_layout() { state.resize_visible_panes(&[(pane, 24, 80)]); assert_eq!(state.last_content_size.get(&pane), Some(&(24, 80))); } + +#[test] +fn an_unconfirmed_resize_is_retried_after_the_deadline() { + let (mut state, _events, resized) = state_with_pending_resize(); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + let start = Instant::now(); + + state.resize_visible_panes_at(&[(pane, 30, 100)], start); + state.resize_visible_panes_at(&[(pane, 30, 100)], start + Duration::from_millis(99)); + assert_eq!(resized.borrow().len(), 1, "pending resize is not flooded"); + + state.resize_visible_panes_at(&[(pane, 30, 100)], start + Duration::from_millis(100)); + assert_eq!(resized.borrow().len(), 2, "an unanswered resize retries"); +} + +#[test] +fn a_late_ack_cannot_strand_the_emulator_at_an_old_width() { + let (mut state, events, resized) = state_with_pending_resize(); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + let start = Instant::now(); + + state.resize_visible_panes_at(&[(pane, 30, 100)], start); + state.resize_visible_panes_at(&[(pane, 40, 120)], start + Duration::from_millis(1)); + events.borrow_mut().push(BackendEvent::Resized { + pane, + rows: 30, + cols: 100, + }); + state.poll_at(start + Duration::from_millis(2)); + assert_eq!(state.screen_for_pane(pane).unwrap().size(), (30, 100)); + + state.resize_visible_panes_at(&[(pane, 40, 120)], start + Duration::from_millis(3)); + assert_eq!( + resized.borrow().last().copied(), + Some((pane, 40, 120)), + "desired and confirmed differ, so the latest width is requested again" + ); + assert_eq!(resized.borrow().len(), 3); + + events.borrow_mut().push(BackendEvent::Resized { + pane, + rows: 40, + cols: 120, + }); + state.poll_at(start + Duration::from_millis(4)); + state.resize_visible_panes_at(&[(pane, 40, 120)], start + Duration::from_secs(1)); + assert_eq!(state.screen_for_pane(pane).unwrap().size(), (40, 120)); + assert_eq!(resized.borrow().len(), 3, "confirmed size stays settled"); +} + +#[test] +fn a_failed_resize_is_not_recorded_as_applied() { + let mut backend = crate::test_util::FakeBackend::default(); + backend.resize_error = true; + let resized = backend.resized.clone(); + let mut state = TerminalState::new(Some(Box::new(backend)), false); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + let original = state.screen_for_pane(pane).unwrap().size(); + let start = Instant::now(); + + state.resize_visible_panes_at(&[(pane, 30, 100)], start); + + assert_eq!(state.screen_for_pane(pane).unwrap().size(), original); + assert_ne!(state.confirmed_content_size.get(&pane), Some(&(30, 100))); + assert!(state.pending_content_size.contains_key(&pane)); + state.resize_visible_panes_at(&[(pane, 30, 100)], start + Duration::from_millis(100)); + assert_eq!( + resized.borrow().len(), + 2, + "a failed resize remains retryable" + ); +} + +#[test] +fn an_ack_for_a_removed_pane_does_not_recreate_its_size_state() { + let (mut state, events) = state_with_event_queue(); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + state.remove_pane_state(pane); + state.panes.clear(); + events.borrow_mut().push(BackendEvent::Resized { + pane, + rows: 30, + cols: 100, + }); + + state.poll(); + + assert!(!state.confirmed_content_size.contains_key(&pane)); + assert!(!state.pending_content_size.contains_key(&pane)); +} diff --git a/src/session/terminal/hub_helpers.rs b/src/session/terminal/hub_helpers.rs index 596731cb..1cc4cfbd 100644 --- a/src/session/terminal/hub_helpers.rs +++ b/src/session/terminal/hub_helpers.rs @@ -32,14 +32,6 @@ pub enum Command { data: Vec, client: u64, }, - /// `client` rides along because a resize is only honoured from the client - /// that owns the sizing (see [`Shared::size_owner`]). - Resize { - pane: PaneId, - rows: u16, - cols: u16, - client: u64, - }, Close { pane: PaneId, }, @@ -59,6 +51,16 @@ pub enum Command { }, } +/// The newest size one connection wants for one pane. Resize traffic is kept +/// out of the bounded command queue: intermediate drag positions may collapse, +/// but the final position must remain available to the worker. +pub(super) struct PendingResize { + pub(super) pane: PaneId, + pub(super) rows: u16, + pub(super) cols: u16, + pub(super) client: u64, +} + /// One startup terminal: the command to run, at the size a client measured, under /// the name it was configured with. pub struct StartupPane { diff --git a/src/session/terminal/hub_layout.rs b/src/session/terminal/hub_layout.rs index 4ba50b53..94b43069 100644 --- a/src/session/terminal/hub_layout.rs +++ b/src/session/terminal/hub_layout.rs @@ -4,10 +4,51 @@ use super::TerminalHub; use super::frame::{ServerMessage, TerminalFrame}; -use super::hub_helpers::{broadcast_locked, canonical_order}; +use super::hub_helpers::{PendingResize, broadcast_locked, canonical_order}; use crate::backend::{PaneId, PtyBackend, TerminalBackend}; impl TerminalHub { + /// Keep only the newest requested size for this connection and pane. + pub(super) fn queue_resize( + &self, + pane: PaneId, + rows: u16, + cols: u16, + client: u64, + connection: u64, + ) { + // Validate before the ownership lookup, with neither lock held across + // the other: `connect` takes the hub state and then ownership. Besides + // dropping an ordinary close race, this bounds the latest-value map to + // live panes even when a client sends arbitrary ids. + if !self.pane_is_live(pane) || !self.owns_size(connection) { + return; + } + self.pending_resizes + .lock() + .expect("terminal resize queue poisoned") + .insert( + (connection, pane), + PendingResize { + pane, + rows, + cols, + client, + }, + ); + } + + pub(super) fn take_pending_resizes(&self) -> Vec { + std::mem::take( + &mut *self + .pending_resizes + .lock() + .expect("terminal resize queue poisoned"), + ) + .into_values() + .collect() + } + /// The size a pane's PTY is recorded as having, or `None` once the pane is /// gone. pub(super) fn pane_size(&self, pane: PaneId) -> Option<(u16, u16)> { @@ -32,11 +73,14 @@ impl TerminalHub { &self, backend: &mut PtyBackend, modes: &mut super::hub_modes::PaneModeTracker, - pane: PaneId, - rows: u16, - cols: u16, - client: u64, + resize: PendingResize, ) { + let PendingResize { + pane, + rows, + cols, + client, + } = resize; // Asked before the hub's lock, because the answer is the session's and // taking the two in the other order would invert the ordering `connect` // uses (hub lock, then ownership). @@ -54,20 +98,24 @@ impl TerminalHub { let Some(p) = state.panes.iter_mut().find(|p| p.id == pane) else { return; }; - if (p.rows, p.cols) == (rows, cols) { - return; + let changed = (p.rows, p.cols) != (rows, cols); + if changed { + if let Err(err) = backend.resize(pane, rows, cols) { + tracing::warn!(%err, pane, rows, cols, "could not resize a session PTY"); + return; + } + modes.resize(pane, rows, cols); + p.rows = rows; + p.cols = cols; } - backend.resize(pane, rows, cols); - modes.resize(pane, rows, cols); - p.rows = rows; - p.cols = cols; // The grid just reflowed, so a snapshot taken before it wraps where the // child no longer does. Refreshed into whichever record the pane is on // — the emulator's active grid is that screen. Skipped when the last // chunk ended mid-sequence (`at_boundary`): a snapshot anchored there // would splice into an open sequence on replay, and a stale-size screen // is the smaller harm — the next output refreshes it. - if modes.at_boundary(pane) + if changed + && modes.at_boundary(pane) && let Some(screen) = modes.snapshot(pane) { if p.modes.alt_screen { diff --git a/src/session/terminal/hub_run.rs b/src/session/terminal/hub_run.rs index adcb911e..98da49bd 100644 --- a/src/session/terminal/hub_run.rs +++ b/src/session/terminal/hub_run.rs @@ -70,14 +70,6 @@ impl TerminalHub { plugins.user_input(&backend, pane); let _ = backend.send_input(pane, &data); } - Command::Resize { - pane, - rows, - cols, - client, - } => { - self.resize_pane(&mut backend, &mut modes, pane, rows, cols, client); - } Command::Close { pane } if self.pane_is_live(pane) => { // Closed for good, unlike an exit: the slot goes with // the process, so there is nothing left to relaunch. @@ -104,6 +96,14 @@ impl TerminalHub { } } + // Resize is latest-value state, not a byte stream. Process the + // newest size after queued structural commands so a close that + // raced a drag wins, while a saturated input queue cannot discard + // the final geometry. + for resize in self.take_pending_resizes() { + self.resize_pane(&mut backend, &mut modes, resize); + } + // Alternate-screen panes whose screen this tick's output has moved on. // Snapshotted once at the end rather than per chunk: a busy program // sends many small chunks and serializing a grid for each of them diff --git a/src/session/terminal/mod.rs b/src/session/terminal/mod.rs index 0459f029..695966c2 100644 --- a/src/session/terminal/mod.rs +++ b/src/session/terminal/mod.rs @@ -47,7 +47,8 @@ pub use frame::{ClientMessage, PaneSize, TerminalFrame, encode_output}; pub use session::TerminalSession; use crate::session::size_owner::SizeOwnership; -use hub_helpers::{Command, Shared}; +use hub_helpers::{Command, PendingResize, Shared}; +use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, SyncSender}; use std::sync::{Arc, Mutex}; @@ -63,6 +64,9 @@ const DEFAULT_PANE_SIZE: PaneSize = PaneSize { rows: 24, cols: 80 }; pub struct TerminalHub { pub(super) commands: SyncSender, + /// Latest resize per connection and pane. Separate from `commands` so a + /// full input queue cannot discard the final width of a window drag. + pending_resizes: Mutex>, pub(super) state: Mutex, next_client_id: AtomicU64, stop: Arc, @@ -100,6 +104,7 @@ impl TerminalHub { let (commands, command_rx) = mpsc::sync_channel::(256); let hub = Arc::new(Self { commands, + pending_resizes: Mutex::new(BTreeMap::new()), state: Mutex::new(Shared { clients: Vec::new(), panes: Vec::new(), diff --git a/src/session/terminal/session.rs b/src/session/terminal/session.rs index a9127156..22176d46 100644 --- a/src/session/terminal/session.rs +++ b/src/session/terminal/session.rs @@ -77,12 +77,9 @@ impl TerminalSession { }, ClientMessage::Resize { pane, rows, cols } => { let size = PaneSize { rows, cols }.clamped(); - Command::Resize { - pane, - rows: size.rows, - cols: size.cols, - client: self.id, - } + self.hub + .queue_resize(pane, size.rows, size.cols, self.id, self.connection); + return; } ClientMessage::Close { pane } => Command::Close { pane }, ClientMessage::Reorder { order } => Command::Reorder { order }, diff --git a/src/session/terminal/tests/backpressure.rs b/src/session/terminal/tests/backpressure.rs index b5309193..db626747 100644 --- a/src/session/terminal/tests/backpressure.rs +++ b/src/session/terminal/tests/backpressure.rs @@ -1,6 +1,6 @@ //! What happens to a client that stops draining its queue. -use super::{attach_over_socket, created_pane, next_matching, spawn_hub}; +use super::{attach, attach_over_socket, created_pane, next_matching, spawn_hub}; use crate::session::terminal::CLIENT_QUEUE_DEPTH; use crate::session::terminal::frame::ClientMessage; use std::io::Read; @@ -92,3 +92,53 @@ fn an_evicted_client_still_releases_the_sizing_when_its_session_ends() { ); hub.stop(); } + +#[test] +fn the_final_resize_survives_a_full_command_queue() { + // Stop the worker so the ordinary command queue remains deterministically + // full. Resize has latest-value semantics and must stay independently + // writable even then; every intermediate drag position may collapse. + let dir = tempfile::TempDir::new().unwrap(); + let hub = spawn_hub(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); + hub.stop(); + let session = attach(&hub); + // The worker cleared its real panes while stopping; install one record so + // dispatch still exercises the production liveness boundary. + hub.register_pane(7, 24, 80, None, None); + for _ in 0..CLIENT_QUEUE_DEPTH + 8 { + session.dispatch(ClientMessage::Input { + pane: 7, + data: "x".to_string(), + }); + } + + for cols in 81..=120 { + session.dispatch(ClientMessage::Resize { + pane: 7, + rows: 30, + cols, + }); + } + + let pending = hub.take_pending_resizes(); + assert_eq!(pending.len(), 1, "intermediate sizes must be coalesced"); + assert_eq!((pending[0].rows, pending[0].cols), (30, 120)); +} + +#[test] +fn unknown_panes_do_not_grow_the_resize_queue() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = spawn_hub(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); + hub.stop(); + let session = attach(&hub); + + for pane in 1..=1_000 { + session.dispatch(ClientMessage::Resize { + pane, + rows: 30, + cols: 100, + }); + } + + assert!(hub.take_pending_resizes().is_empty()); +} diff --git a/src/session/terminal/tests/behavior.rs b/src/session/terminal/tests/behavior.rs index 4863c82c..ca38c9f4 100644 --- a/src/session/terminal/tests/behavior.rs +++ b/src/session/terminal/tests/behavior.rs @@ -174,9 +174,9 @@ fn a_replayed_pane_reports_the_size_it_was_last_resized_to() { // has landed. // // Retrying the connection instead would destroy what it waits for. - // Connecting takes the sizing (`window-size latest`), and a resize from a - // client that no longer owns it is dropped rather than queued - // (`hub_run.rs::apply_resize`). So a `connect` that beats the worker to this + // Connecting takes the sizing (`window-size latest`), and a pending resize + // from a client that no longer owns it is discarded by + // `hub_layout.rs::resize_pane`. So a `connect` that beats the worker to this // still-pending resize discards it for good, and no amount of retrying // brings the size the test is waiting for — it just spends the whole // deadline. Normally the worker wins that race, which is what made the @@ -201,6 +201,30 @@ fn a_replayed_pane_reports_the_size_it_was_last_resized_to() { hub.stop(); } +#[test] +fn retrying_an_already_applied_size_is_acknowledged() { + // A client retries when an acknowledgement is late. Even when the first + // request already landed, the retry must receive the current size or it + // remains pending forever. + let dir = tempfile::TempDir::new().unwrap(); + let hub = spawn_hub(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); + let session = attach(&hub); + session.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); + let pane = next_matching(&session, |f| created_pane(f).is_some()) + .and_then(|f| created_pane(&f)) + .expect("no created message"); + + session.dispatch(ClientMessage::Resize { + pane, + rows: 24, + cols: 80, + }); + + let ack = next_matching(&session, |f| resized_size(f).is_some()).and_then(|f| resized_size(&f)); + assert_eq!(ack, Some((24, 80))); + hub.stop(); +} + #[test] fn input_for_an_unknown_pane_is_ignored() { // A client racing a pane exit is normal traffic, not an error worth diff --git a/src/session/terminal/tests/size_owner.rs b/src/session/terminal/tests/size_owner.rs index 08d9ad54..479bf2d1 100644 --- a/src/session/terminal/tests/size_owner.rs +++ b/src/session/terminal/tests/size_owner.rs @@ -193,7 +193,8 @@ fn only_the_owner_resizes_the_pty_and_everyone_is_told_the_size() { let second = attach(&hub); assert!(verdict(&second)); - // Both ask, in this order, through the one command queue the hub drains. + // Both ask in this order. Resize has its own latest-value queue, but the + // ownership check still admits only the current owner's value. first.dispatch(ClientMessage::Resize { pane, rows: 40, diff --git a/src/test_util.rs b/src/test_util.rs index b4d23b15..0753c3b6 100644 --- a/src/test_util.rs +++ b/src/test_util.rs @@ -121,7 +121,6 @@ pub fn session_state( /// In-memory `TerminalBackend` for tests: spawns nothing, just records the /// command each `create_pane` was asked to run. -#[derive(Default)] pub struct FakeBackend { next_id: crate::backend::PaneId, pub launched: Vec>, @@ -131,6 +130,35 @@ pub struct FakeBackend { /// test can keep a clone and inject synthetic pane output/exit after the /// backend was boxed into `TerminalState`. pub pending_events: std::rc::Rc>>, + /// Resize calls, in order, for convergence tests. + pub resized: std::rc::Rc>>, + /// Whether resize is immediate (local PTY) or awaits a server event. + pub resize_outcome: crate::backend::ResizeOutcome, + /// Synthetic resize failure for error-path tests. + pub resize_error: bool, +} + +impl Default for FakeBackend { + fn default() -> Self { + Self { + next_id: 0, + launched: Vec::new(), + sent: Vec::new(), + pending_events: Default::default(), + resized: Default::default(), + resize_outcome: crate::backend::ResizeOutcome::Applied, + resize_error: false, + } + } +} + +impl FakeBackend { + pub fn with_resize_outcome(outcome: crate::backend::ResizeOutcome) -> Self { + Self { + resize_outcome: outcome, + ..Default::default() + } + } } impl crate::backend::TerminalBackend for FakeBackend { @@ -193,7 +221,18 @@ impl crate::backend::TerminalBackend for FakeBackend { Ok(()) } - fn resize(&mut self, _id: crate::backend::PaneId, _rows: u16, _cols: u16) {} + fn resize( + &mut self, + id: crate::backend::PaneId, + rows: u16, + cols: u16, + ) -> anyhow::Result { + self.resized.borrow_mut().push((id, rows, cols)); + if self.resize_error { + anyhow::bail!("synthetic resize failure"); + } + Ok(self.resize_outcome) + } fn drain_events(&mut self) -> Vec { std::mem::take(&mut *self.pending_events.borrow_mut()) From 61bd352400b0f76f7815b7b8457acd2e720ae029 Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 28 Aug 2026 23:03:10 +0900 Subject: [PATCH 02/42] refactor(comments): drop code-restating comments and trim verbose rationale --- docs/architecture/session.md | 8 +- src/backend/identity.rs | 43 ++---- src/backend/mod.rs | 23 ++-- src/backend/pty.rs | 95 +++++--------- src/backend/slot.rs | 22 ++-- src/daemon/clients.rs | 27 ++-- src/git/clone.rs | 23 ++-- src/git/clone/message.rs | 37 ++---- src/git/diff/commit_log.rs | 13 +- src/git/diff/diff_load.rs | 11 +- src/git/diff/file_load.rs | 19 ++- src/git/diff/types.rs | 7 +- src/git/mod.rs | 29 ++--- src/git/path/mod.rs | 72 +++++----- src/git/tree/mod.rs | 18 +-- src/runtime/emulator/snapshot.rs | 115 ++++++++-------- src/runtime/snapshot/worker.rs | 15 +-- src/runtime/snapshot_watch.rs | 20 ++- src/runtime/terminal/lifecycle.rs | 6 +- src/runtime/terminal/session_panes.rs | 24 ++-- src/runtime/tree_watch.rs | 6 +- src/session/catalog/mod.rs | 35 +++-- src/session/operations.rs | 123 +++++++----------- src/session/prefs/repo_view.rs | 15 +-- src/session/reload.rs | 45 +++---- src/session/runtime/mod.rs | 27 ++-- src/session/size_owner.rs | 32 ++--- src/session/terminal/frame.rs | 38 +++--- src/session/terminal/hub_connect.rs | 54 ++++---- src/session/terminal/hub_diag.rs | 16 +-- src/session/terminal/hub_helpers.rs | 112 +++++++--------- src/session/terminal/hub_layout.rs | 65 +++++---- src/session/terminal/hub_modes.rs | 48 +++---- src/session/terminal/hub_panes.rs | 44 +++---- src/session/terminal/hub_plugins.rs | 20 ++- src/session/terminal/hub_plugins_slots.rs | 23 ++-- src/session/terminal/hub_relaunch.rs | 11 +- src/session/terminal/hub_reload_hosts.rs | 24 ++-- src/session/terminal/hub_replay.rs | 37 +++--- src/session/terminal/hub_run.rs | 106 +++++++-------- src/session/terminal/hub_zoom.rs | 35 +++-- src/session/terminal/startup.rs | 7 +- src/session/terminal/startup_run.rs | 36 +++-- src/session/terminal/tests/backpressure.rs | 88 +++++++++++++ src/ui/diff_viewer/gutter.rs | 32 ++--- src/ui/hint_bar.rs | 28 ++-- src/ui/notice.rs | 63 ++++----- src/ui/project_tab/mod.rs | 25 ++-- src/ui/tree_view/mod.rs | 32 ++--- src/ui/wall_clock.rs | 39 +++--- src/web/common/conn.rs | 21 ++- src/web/common/sessions.rs | 68 ++++------ src/web/viewer/assets.rs | 57 +++----- src/web/viewer/clone_jobs.rs | 20 ++- src/web/viewer/dto/envelope.rs | 37 +++--- src/web/viewer/dto/status.rs | 22 ++-- src/web/viewer/limits.rs | 25 ++-- src/web/viewer/server/clone_routes.rs | 26 ++-- src/web/viewer/server/dispatch.rs | 20 ++- src/web/viewer/server/handlers/terminal.rs | 53 +++----- .../viewer/server/mutations/preferences.rs | 19 ++- src/web/viewer/server/preview.rs | 76 ++++------- src/web/viewer/server/routes.rs | 35 +++-- src/workspace/mod.rs | 8 +- src/workspace/path_tree.rs | 12 +- 65 files changed, 1059 insertions(+), 1333 deletions(-) diff --git a/docs/architecture/session.md b/docs/architecture/session.md index 2ee23c4c..576f810e 100644 --- a/docs/architecture/session.md +++ b/docs/architecture/session.md @@ -139,9 +139,11 @@ alternate screen을 쓰는 풀스크린 TUI를 나중에 다시 흘릴 방법은 100 ms 뒤 재시도한다. 서버는 이미 같은 크기인 재시도에도 `Resized`를 답한다. - **resize는 일반 terminal command queue에 넣지 않는다.** 입력과 create/close가 쓰는 bounded queue가 가득 차도 창 드래그의 마지막 폭은 잃으면 안 되므로, hub가 connection·pane별 최신 값만 - 별도 보관해 worker tick에서 합성 처리한다. 중간 폭은 버려도 되지만 마지막 폭은 반드시 한 번 - 적용을 시도한다. `portable-pty`/ConPTY/TIOCSWINSZ resize가 실패하면 pane 상태와 mode emulator를 - 갱신하거나 `Resized`를 브로드캐스트하지 않는다. + 별도 보관한다. worker는 일반 command를 64개 처리할 때마다 이를 합성 처리해 지속적인 입력에도 + resize가 굶지 않으며, 연결이 끝나면 그 connection의 보류 값을 제거해 재접속 churn에도 저장량을 + 붙은 connection·pane 수 안에 묶는다. 중간 폭은 버려도 되지만 마지막 폭은 반드시 한 번 적용을 + 시도한다. `portable-pty`/ConPTY/TIOCSWINSZ resize가 실패하면 pane 상태와 mode emulator를 갱신하거나 + `Resized`를 브로드캐스트하지 않는다. - 입력마다 소유권을 옮기는 대안은 기각했다 — 폰으로 잠깐 확인하는 제일 가벼운 행동이 전체 repaint를 유발하는 제일 비싼 행동이 된다. 부수 효과로 **비소유 클라이언트가 곧 관전자**여서 별도 관전 모드가 필요 없고, 영역과 그리드가 다르면 렌더 경로가 clamp로 처리한다. diff --git a/src/backend/identity.rs b/src/backend/identity.rs index c38f9595..e8deb8c1 100644 --- a/src/backend/identity.rs +++ b/src/backend/identity.rs @@ -12,19 +12,11 @@ pub const PANE_TOKEN_ENV: &str = "NIGHTCROW_PANE_TOKEN"; /// Env var naming the directory a hub's plugins put their runtime sockets in. /// -/// A plugin process belongs to one [`TerminalHub`](crate::session::terminal), -/// and a hub is per repository — so a session with six projects runs six of -/// each plugin. A plugin that picks one fixed socket path is therefore not -/// wrong about its own instance but about how many there are: the first binds, -/// the rest find the address taken and run without their socket, and a helper -/// inside a pane reaches whichever instance won rather than the one watching -/// it. -/// -/// Both sides derive this from the hub's working directory, so nothing has to -/// be handed from the plugin spawn to the pane spawn: they compute the same -/// directory from the same input. The pane's children inherit it exactly as -/// they inherit [`PANE_TOKEN_ENV`], which is what lets a provider's hook find -/// the instance that is watching the pane it runs in. +/// A hub is per repository, so a session with six projects runs six of each +/// plugin; a plugin that picks one fixed socket path would collide. Both sides +/// derive this from the hub's working directory, so the plugin spawn and the +/// pane spawn agree without either being told by the other — and a provider's +/// hook finds the instance watching the pane it runs in via inheritance. pub const PLUGIN_RUNTIME_DIR_ENV: &str = "NIGHTCROW_PLUGIN_RUNTIME_DIR"; /// The directory the plugins of the hub rooted at `cwd` use for their sockets. @@ -32,10 +24,9 @@ pub const PLUGIN_RUNTIME_DIR_ENV: &str = "NIGHTCROW_PLUGIN_RUNTIME_DIR"; /// `None` when there is nowhere to put one, which leaves a plugin on whatever /// default it had — degraded exactly as it is today rather than refused. /// -/// The hub's path is hashed rather than spelled out. AF_UNIX paths are capped +/// The hub's path is hashed rather than spelled out: AF_UNIX paths are capped /// near 107 bytes and a repository path can be most of that on its own, so a -/// fixed-width digest is what keeps the socket bindable; it also keeps a -/// directory name from carrying where someone's code lives. +/// fixed-width digest is what keeps the socket bindable. pub fn plugin_runtime_dir(cwd: &std::path::Path) -> Option { let base = match std::env::var_os("XDG_RUNTIME_DIR").filter(|d| !d.is_empty()) { Some(dir) => std::path::PathBuf::from(dir).join("nightcrow"), @@ -64,12 +55,10 @@ const TOKEN_BYTES: usize = 16; /// Opaque name for a pane slot, stable for as long as the slot exists. /// -/// [`PaneId`](super::PaneId) cannot serve this purpose outside the process that -/// owns the panes: it is a per-backend counter that restarts at 1 whenever a -/// backend is rebuilt, so the same number means different panes across two -/// runs. The token is random instead, and it deliberately outlives the process -/// occupying the slot — an observer tracking a slot keeps its state when the -/// slot's process is replaced. +/// [`PaneId`](super::PaneId) cannot serve this outside the owning process: it +/// is a per-backend counter that restarts whenever a backend is rebuilt. The +/// token is random instead and deliberately outlives the process occupying the +/// slot, so an observer tracking a slot keeps its state across a replacement. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub struct PaneToken(String); @@ -94,13 +83,9 @@ impl PaneToken { } } -/// Which spawn of a pane slot something refers to. -/// -/// Starts at [`FIRST_GENERATION`] and rises every time the slot's process is -/// replaced. An out-of-process observer decides what to do asynchronously, so -/// by the time it asks for something the process it watched may already be -/// gone; carrying the generation is what makes that detectable instead of -/// letting a decision about one process land on its successor. +/// Which spawn of a pane slot something refers to: an out-of-process observer +/// decides asynchronously, so carrying the generation makes acting on an +/// already-replaced process detectable. pub type PaneGeneration = u32; pub const FIRST_GENERATION: PaneGeneration = 1; diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 5bae526a..6fc407ef 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -42,21 +42,17 @@ pub enum BackendEvent { Exited { pane: PaneId, }, - /// The size a pane's PTY is now set to. - /// - /// Only a backend serving a shared session reports this, and it is not - /// necessarily what this side asked for: the size belongs to whichever - /// client owns the sizing. An emulator has to wrap where the child does. + /// The size a pane's PTY is now set to. Only a shared-session backend + /// reports this, and it is not necessarily what this side asked for: the + /// size belongs to whichever client owns the sizing. Resized { pane: PaneId, rows: u16, cols: u16, }, - /// The canonical order of the panes. - /// - /// Only a backend serving a shared session reports this: the order is part - /// of what the session owns. Ids this side does not know are ignored and - /// panes the order omits keep their place. + /// The canonical order of the panes. Only a shared-session backend reports + /// this: the order is part of what the session owns. Unknown ids are + /// ignored; panes the order omits keep their place. Reordered { order: Vec, }, @@ -68,11 +64,8 @@ pub enum BackendEvent { owned: bool, }, /// What a plugin driving `pane` reports about getting it running again. - /// - /// Only a backend serving a shared session reports this: the plugins run - /// beside the session's panes, not beside this client. - /// A plugin reported that this pane wants attention. Client-local from - /// here: it raises the project tab's unread marker. + /// Only a shared-session backend reports this: the plugins run beside the + /// session's panes, not beside this client. Attention { pane: PaneId, }, diff --git a/src/backend/pty.rs b/src/backend/pty.rs index 057312a2..527b2194 100644 --- a/src/backend/pty.rs +++ b/src/backend/pty.rs @@ -35,13 +35,11 @@ pub(super) enum PtyEvent { Exited, } -/// How far a pane has moved through its shutdown. -/// -/// The two end signals are not interchangeable. EOF on the master is final. -/// The child's death is not: on Windows `ClosePseudoConsole` only runs when -/// the master is dropped, so `read()` never returns EOF and the child's exit -/// is the *only* signal a pane gets. `Draining` holds the exit back until the -/// channel is dry. +/// How far a pane has moved through its shutdown. The two end signals are +/// not interchangeable: EOF on the master is final, but a child's death is +/// not — on Windows `ClosePseudoConsole` only runs when the master is dropped, +/// so `read()` never returns EOF and the child's exit is the *only* signal a +/// pane gets. `Draining` holds the exit back until the channel is dry. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum ExitPhase { /// No exit signal seen. @@ -53,9 +51,8 @@ pub(super) enum ExitPhase { } pub(super) struct PtyPane { - // master/writer are wrapped in Option so `Drop` can release them - // before joining the reader thread — the reader blocks in `read()` - // and only unblocks when both sides of the PTY are closed. + // Option wrapping so `Drop` releases master/writer before joining the + // reader thread, which only unblocks when both PTY sides close. pub(super) master: Option>, pub(super) writer: Option>, pub(super) killer: Box, @@ -69,17 +66,13 @@ impl Drop for PtyPane { fn drop(&mut self) { // Best-effort kill: the child may already be gone. let _ = self.killer.kill(); - // Drop writer/master so the reader's blocked `read()` returns EOF - // and the thread exits. Without this, joining the reader would - // hang. + // Drop writer/master so the reader's blocked `read()` returns EOF; + // without this, joining the reader would hang. self.writer.take(); self.master.take(); - // Bounded join so closing a pane cannot leave reader/wait threads - // alive holding fds against the (possibly killed) child. If a - // daemonized grandchild kept the slave fd open, the reader's - // `read()` won't return EOF; detach in that case rather than - // freezing the close. We're inside drop, so a panic in either - // thread is logged rather than propagated. + // Bounded join: if a daemonized grandchild kept the slave fd open the + // reader's `read()` never returns EOF — detach rather than freeze the + // close. Inside drop, so a thread panic is logged, not propagated. if let Some(h) = self.reader_handle.take() { try_timed_join(h, PTY_REAP_TIMEOUT); } @@ -90,17 +83,14 @@ impl Drop for PtyPane { } pub struct PtyBackend { - // BTreeMap (not HashMap) so per-frame event drain visits panes in - // PaneId order — IDs are monotonic, so this matches creation order - // and stays deterministic across runs. + // BTreeMap so per-frame drain visits panes in PaneId order — ids are + // monotonic, keeping the drain deterministic across runs. pub(super) panes: BTreeMap, - /// Slot bookkeeping — identity, launch, idle clock — kept beside `panes` - /// rather than inside `PtyPane` because a relaunch replaces the `PtyPane` - /// while the slot has to survive it. + /// Slot bookkeeping kept beside `panes` rather than inside `PtyPane` + /// because a relaunch replaces the pane while the slot survives it. pub(super) slots: PaneSlots, pub(super) next_id: PaneId, - // Each new pane spawns the shell here so its cwd matches the repo - // nightcrow is tracking. + // Spawned shell cwd must match the repo nightcrow tracks. pub(super) cwd: PathBuf, /// Panes created since the last drain, waiting to be reported. /// @@ -139,20 +129,15 @@ impl PtyBackend { self.panes.contains_key(&id) } - /// Let go of a pane's process while keeping its slot. - /// - /// Splitting this out of `destroy_pane` is what makes waiting for a reset - /// affordable: a wait can run for hours, and holding the dead child's fds - /// and threads open for that long to preserve the token would be pure - /// waste. The slot is small, and it is the only part a relaunch needs. + /// Let go of a pane's process while keeping its slot, so a wait can run + /// for hours without holding the dead child's fds and threads open just + /// to preserve its token. The slot is the only part a relaunch needs. pub fn release_process(&mut self, id: PaneId) { self.panes.remove(&id); } - /// Drop a slot for good, retiring its token. - /// - /// Called when nothing more is expected of the pane — the wait was - /// abandoned, the pane was closed, or the session is going away. + /// Drop a slot for good, retiring its token: the wait was abandoned, the + /// pane was closed, or the session is going away. pub fn retire_slot(&mut self, id: PaneId) { self.slots.remove(id); } @@ -165,8 +150,6 @@ impl TerminalBackend for PtyBackend { // way, and this backend simply knows the answer before it queues it. // `requested` is always true — nothing else can create a pane here. self.created.push(BackendEvent::Created { - // A local backend has no name to give: whoever opened the pane knows - // what it is for. title: None, pane: id, rows, @@ -177,11 +160,9 @@ impl TerminalBackend for PtyBackend { } fn destroy_pane(&mut self, id: PaneId) { - // Removing the pane drops it, which runs PtyPane::drop: kill, - // release master/writer, join reader/wait threads. self.panes.remove(&id); - // The slot goes with it, retiring its token. A relaunch keeps the slot - // by going through `relaunch_pane` instead of destroy-then-open. + // A relaunch keeps the slot by going through `relaunch_pane` instead + // of destroy-then-open. self.slots.remove(id); } @@ -227,18 +208,14 @@ impl TerminalBackend for PtyBackend { // silently dropped, and where Exited could be reported twice. // // The reader thread emits all Output messages, then a single Exited as - // the last message before its sender drops. The mpsc channel preserves - // send order, so any Output enqueued before Exited has already been - // surfaced by an earlier iteration of the outer try_recv loop — no - // separate post-Exited drain is needed. - // - // `ChildExited` carries no such ordering — it can overtake output the - // child already wrote — so it only moves the pane to `Draining`. + // the last message before its sender drops, so the mpsc order means no + // separate post-Exited drain is needed. `ChildExited` carries no such + // ordering — it can overtake output already written — so it only moves + // the pane to `Draining`. // - // Each pane is drained up to PER_PANE_DRAIN_BUDGET events to keep - // one noisy pane (e.g. `yes | head -100000`) from starving its - // siblings within a single frame; whatever is left lands on the - // next tick. + // Each pane is drained up to PER_PANE_DRAIN_BUDGET events so one noisy + // pane (e.g. `yes | head -100000`) cannot starve its siblings within a + // frame; the rest lands on the next tick. // Ahead of any output: a pane has to exist before bytes can be routed // to it, and both can be queued before the same drain. let mut events: Vec = std::mem::take(&mut self.created); @@ -261,7 +238,6 @@ impl TerminalBackend for PtyBackend { } } Ok(PtyEvent::Exited) => { - // EOF is final, so nothing is held back. if pane.exit != ExitPhase::Reported { pane.exit = ExitPhase::Reported; events.push(BackendEvent::Exited { pane: *id }); @@ -269,8 +245,8 @@ impl TerminalBackend for PtyBackend { break; } Err(_) => { - // Dry for now. For a dead child that is the cue to - // report — once the grace has let late output land. + // Dry: for a dead child past its drain grace, the cue + // to report. if let ExitPhase::Draining { since } = pane.exit && now.duration_since(since) >= EXIT_DRAIN_GRACE { @@ -287,9 +263,8 @@ impl TerminalBackend for PtyBackend { } } -// `PtyBackend` no longer needs an explicit Drop: `HashMap::drop` drops every -// pane, and `PtyPane::drop` handles kill+release+join. Leaving an empty -// Drop here would still work but would obscure that ownership. +// No explicit `Drop` on `PtyBackend` on purpose: the map drops every pane and +// `PtyPane::drop` handles kill+release+join — an empty Drop would obscure that. #[cfg(test)] #[path = "pty_tests.rs"] diff --git a/src/backend/slot.rs b/src/backend/slot.rs index 2296beaa..db119f25 100644 --- a/src/backend/slot.rs +++ b/src/backend/slot.rs @@ -89,23 +89,19 @@ const MAX_RESUME_ARGS: usize = 6; /// Longest single resume argument. Comfortably past a UUID or a session name. const MAX_RESUME_ARG_LEN: usize = 256; -/// Characters a resume argument may consist of. -/// -/// Deliberately narrower than "anything the shell can be made to swallow": the -/// argument is appended to a command line that a login shell parses, so a value -/// carrying a space, quote, backtick, `$`, or `;` is refused outright rather -/// than escaped differently by every supported shell. +/// Characters a resume argument may consist of. Deliberately narrower than +/// "anything the shell can be made to swallow": the argument lands on a command +/// line a login shell parses, so a value carrying a space, quote, backtick, +/// `$`, or `;` is refused outright rather than escaped per shell. fn is_safe_arg_char(c: char) -> bool { c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':' | '/' | '=' | '@' | '+') } -/// Build the command line for a relaunch. -/// -/// `allowed_flags` is the plugin's declared list from config. The first token -/// (flag or subcommand) and every option-like token must appear there. Values -/// following an approved control token remain provider data such as a session -/// id. This lets the core refuse an unapproved relaunch mode without knowing a -/// particular CLI's grammar. +/// Build the command line for a relaunch. The first token and every +/// option-like token must be in `allowed_flags` (the plugin's declared list +/// from config); values following an approved control token stay provider +/// data. This lets the core refuse an unapproved relaunch mode without +/// knowing a particular CLI's grammar. pub fn resume_command_line( base: Option<&str>, resume_args: &[String], diff --git a/src/daemon/clients.rs b/src/daemon/clients.rs index be88e63d..dd8e336c 100644 --- a/src/daemon/clients.rs +++ b/src/daemon/clients.rs @@ -123,20 +123,15 @@ impl AttachedClients { /// Send `frame` to every attached client, and count them told: nobody is /// left owed a set by a broadcast that just reached them. /// - /// The two are one act, under one lock hold, because a client that attaches - /// between them was *not* a recipient — clearing its flag afterwards would - /// leave it waiting for a set the watcher has already recorded as sent, and - /// with no further change to the session nothing would ever send one. A - /// client that attaches after this returns is not in the list, keeps its - /// flag, and is served on the next pass. + /// The two are one act, under one lock hold, because a client that + /// attaches between them was *not* a recipient — clearing its flag + /// afterwards would leave it waiting for a set the watcher has already + /// recorded as sent. A client that attaches after this returns is not in + /// the list, keeps its flag, and is served on the next pass. /// - /// The served set is the only thing every client is sent at once — a - /// repository's pane output goes per subscriber — so there is no broadcast - /// this does not settle. - /// - /// Never blocks: the lock is held while queueing, and a blocking send would - /// let one stalled client stop the session for all the others. A client whose - /// queue is full is cut off instead — for itself alone. + /// Never blocks: the lock is held while queueing, and a blocking send + /// would let one stalled client stop the session for all the others. A + /// client whose queue is full is cut off — for itself alone. pub fn broadcast(&self, frame: Frame) { let mut clients = self.inner.lock().expect("attached clients poisoned"); clients.retain_mut(|client| { @@ -145,9 +140,9 @@ impl AttachedClients { }); } - /// Note that `id` is waiting to be told the session's shape, for the watcher - /// to answer on its next pass. Unknown ids are ignored: the client detached - /// between asking and this. + /// Note that `id` is waiting to be told the session's shape, for the + /// watcher to answer on its next pass. Unknown ids are ignored: the client + /// detached between asking and this. pub fn owe_set(&self, id: u64) { let mut clients = self.inner.lock().expect("attached clients poisoned"); if let Some(client) = clients.iter_mut().find(|client| client.id == id) { diff --git a/src/git/clone.rs b/src/git/clone.rs index 34e59fb3..28167276 100644 --- a/src/git/clone.rs +++ b/src/git/clone.rs @@ -1,9 +1,8 @@ //! Clone a remote repository by delegating to the `git` binary. //! -//! libgit2 is not used: the vendored build carries no SSH transport, knows +//! libgit2 is not used: its vendored build carries no SSH transport, knows //! nothing of credential helpers or `insteadOf` rewrites, and cannot resolve -//! `git@host:path` remotes. Delegating to `git` inherits that whole stack. -//! Nothing here reads stdout — only exit status and stderr-on-failure. +//! `git@host:path` remotes — delegating to `git` inherits that whole stack. mod message; @@ -54,9 +53,8 @@ impl CloneUrlError { /// Accept `url` as a remote address and return the directory name a clone of it /// would create — the same name `git clone ` picks. /// -/// Accepted shapes are the [`ALLOWED_SCHEMES`] and scp-like `user@host:path`. -/// Everything else is rejected, so `ext::`, `file://`, and bare paths never -/// reach `git`. +/// Accepted shapes are the [`ALLOWED_SCHEMES`] and scp-like `user@host:path`; +/// everything else (`ext::`, `file://`, bare paths) is rejected. pub fn validate_clone_url(url: &str) -> Result { let url = url.trim(); if url.is_empty() { @@ -157,8 +155,7 @@ pub fn git_available() -> bool { /// The URL is an argv item behind `--`, never a shell word, so no quoting or /// escaping question arises; [`validate_clone_url`] has already ruled out the /// schemes that would make argv placement insufficient. On failure the error -/// carries the actionable part of git's stderr, which is what tells the user -/// "repository not found" or "permission denied". +/// carries the actionable part of git's stderr. pub fn run_clone(url: &str, dest: &Path) -> anyhow::Result<()> { let mut child = Command::new("git") // Without this a remote that wants credentials makes git open @@ -173,11 +170,11 @@ pub fn run_clone(url: &str, dest: &Path) -> anyhow::Result<()> { "GIT_SSH_COMMAND", "ssh -o ConnectTimeout=30 -o ServerAliveInterval=30 -o ServerAliveCountMax=4", ) - // A rate floor rather than a wall clock: a wall-clock bound cannot - // tell a large repository (legitimately many minutes) from a dead - // connection, while a floor at least scales with what is arriving. - // It is a policy threshold, not a liveness proof — a genuine transfer - // that sits under 1 KiB/s for 60 s is cut too, which is the trade. + // A rate floor rather than a wall clock: a wall-clock bound cannot tell + // a large repository (legitimately many minutes) from a dead connection, + // while a floor scales with what is arriving. It is a policy threshold, + // not a liveness proof — a genuine transfer under 1 KiB/s for 60 s is + // cut too, which is the trade. .arg("-c") .arg("http.lowSpeedLimit=1024") .arg("-c") diff --git a/src/git/clone/message.rs b/src/git/clone/message.rs index 942465c3..06c58dbe 100644 --- a/src/git/clone/message.rs +++ b/src/git/clone/message.rs @@ -1,9 +1,8 @@ //! Turn a failed `git clone`'s stderr into one line for the user. //! -//! Two things stand between that stream and something worth showing. A remote -//! controls it — `remote:` sidebands are printed verbatim — so it cannot be -//! collected unbounded. And git closes a failure with an advice block, so its -//! last line names no cause at all. +//! A remote controls that stream — `remote:` sidebands are printed verbatim — +//! so it cannot be collected unbounded, and git closes a failure with an advice +//! block whose last line names no cause at all. /// Most stderr kept from a failing clone. Only the tail is wanted anyway: the /// reason git gave up is at the end. @@ -48,22 +47,13 @@ pub(super) fn tail_of(mut reader: R) -> String { /// The actionable part of a failed clone's stderr, or `None` if there is none. /// -/// The last line is the wrong pick. An unreachable remote ends like this: -/// -/// ```text -/// ERROR: Repository not found. -/// fatal: Could not read from remote repository. -/// -/// Please make sure you have the correct access rights -/// and the repository exists. -/// ``` -/// -/// so taking the last line shows the tail of a wrapped piece of advice instead -/// of the reason. The reason is the last diagnostic line — and usually the line -/// before it as well, because `fatal: Could not read from remote repository.` is -/// only a wrapper around what the transport actually said ("Repository not -/// found.", "Permission denied (publickey)."). Both are kept and joined; -/// everything after them is dropped. +/// The last line is the wrong pick: an unreachable remote ends with a wrapped +/// piece of advice (`Please make sure you have the correct access rights …`) +/// instead of the reason. The reason is the last diagnostic line — and usually +/// the line before it as well, because `fatal: Could not read from remote +/// repository.` is only a wrapper around what the transport actually said +/// ("Repository not found.", "Permission denied (publickey)."). Both are kept +/// and joined; everything after them is dropped. pub(super) fn actionable(stderr: &str) -> Option { let lines: Vec<&str> = stderr .lines() @@ -71,10 +61,9 @@ pub(super) fn actionable(stderr: &str) -> Option { .filter(|line| !line.is_empty()) .collect(); let Some(last) = lines.iter().rposition(|line| is_diagnostic(line)) else { - // Nothing announced itself as a diagnostic. That is either a transport - // speaking for itself (`ssh: Could not resolve hostname …`) or a git - // whose wording this does not know, and its last line still beats - // saying nothing. + // Nothing announced itself as a diagnostic — a transport speaking for + // itself (`ssh: Could not resolve hostname …`) or a git whose wording + // this does not know. Its last line still beats saying nothing. return lines.last().map(|line| (*line).to_string()); }; let mut kept = Vec::with_capacity(2); diff --git a/src/git/diff/commit_log.rs b/src/git/diff/commit_log.rs index a2b0550e..5d036994 100644 --- a/src/git/diff/commit_log.rs +++ b/src/git/diff/commit_log.rs @@ -73,10 +73,9 @@ pub fn load_commit_log_from( /// Render a commit oid as the conventional 7-character abbreviated form. /// -/// Previously used `repo.find_object(...).short_id()`, which computes the -/// minimum unique prefix at O(log n) ODB lookups per commit. git's own default -/// `core.abbrev` is 7, so a fixed 7-char prefix matches the familiar form -/// while making this O(1). +/// A fixed 7-char prefix matches git's own default `core.abbrev`; the previous +/// `repo.find_object(...).short_id()` computed the minimum unique prefix at +/// O(log n) ODB lookups per commit, while this is O(1). pub(crate) fn short_oid(oid: Oid) -> String { let s = oid.to_string(); s.get(..7).unwrap_or(&s).to_string() @@ -103,9 +102,9 @@ pub fn head_commit_oid(repo: &Repository) -> Result> { pub(crate) fn is_empty_head(err: &git2::Error) -> bool { // libgit2 reports "reference 'refs/heads/' not found" for empty - // repos with a class of Reference but a generic error code, so we keep - // the message fallback. libgit2 does not localize internal messages, so - // the match is portable. + // repos with a class of Reference but a generic error code, so the message + // fallback stays; libgit2 does not localize internal messages, so the + // match is portable. let missing_head_reference = err.class() == git2::ErrorClass::Reference && err.message().contains("not found"); diff --git a/src/git/diff/diff_load.rs b/src/git/diff/diff_load.rs index 11b6a66c..2919219c 100644 --- a/src/git/diff/diff_load.rs +++ b/src/git/diff/diff_load.rs @@ -149,10 +149,9 @@ fn diff_options(pathspec: Option<&str>) -> DiffOptions { /// The two sides differ only in a rename git paired, which it does when the /// pathspec reached both halves — otherwise each half arrives as its own /// `Deleted` or `Added` delta holding that half's path on both sides. Prefix -/// matching is what reaches both, so this is not only the directory case: a -/// file replaced by a directory of the same name (`foo` becoming `foo/x`) -/// pairs under the pathspec `foo`, and there the old side is the only side -/// that equals what was asked for. +/// matching is what reaches both: a file replaced by a directory of the same +/// name (`foo` becoming `foo/x`) pairs under the pathspec `foo`, and there the +/// old side is the only side that equals what was asked for. fn delta_is_about(delta: &DiffDelta<'_>, wanted: &str) -> bool { [delta.new_file().path(), delta.old_file().path()] .into_iter() @@ -178,9 +177,9 @@ fn collect_hunks( ) -> Result> { let hunks: RefCell> = RefCell::new(Vec::new()); // Every callback is handed the delta it belongs to, so each one answers the - // `only` question for itself. Deciding once in `file_cb` and remembering it + // `only` question for itself; deciding once in `file_cb` and remembering it // would make the result depend on a callback order libgit2 is free to - // change, for nothing. + // change. let wanted = |delta: &DiffDelta<'_>| only.is_none_or(|only| delta_is_about(delta, only)); diff.foreach( diff --git a/src/git/diff/file_load.rs b/src/git/diff/file_load.rs index a4c2b685..a6f1bcea 100644 --- a/src/git/diff/file_load.rs +++ b/src/git/diff/file_load.rs @@ -1,8 +1,7 @@ //! Reading a file's whole contents — from the working tree, or from a commit. //! -//! Separate from `diff_load.rs`, which is about what changed. These answer the -//! other question a person asks of the same path: not "what moved" but "what -//! does it say", which is what the viewer switches to from a diff. +//! Separate from `diff_load.rs`, which is about what changed; these answer the +//! other question a person asks of the same path: "what does it say". use super::types::StatusKind; use anyhow::{Context, Result}; @@ -76,13 +75,12 @@ pub fn load_commit_file_blob( /// The file's contents as of `oid`. /// -/// Which side to read is decided here rather than taken from the caller. A path +/// Which side to read is decided here rather than taken from the caller: a path /// deleted in a commit is not in that commit's own tree — its content is in the /// parent's — and the repository already knows which case this is. -/// [`load_commit_file_blob`] is told instead, because the TUI has the status +/// [`load_commit_file_blob`] is told instead because the TUI has the status /// beside the row it is acting on; a request arriving over the wire has no such -/// thing to be trusted with, and asking for it would add an input to validate -/// for an answer that can simply be looked up. +/// thing to be trusted with. pub fn load_commit_file(repo: &Repository, oid: Oid, file_path: &str) -> Result { let commit = repo.find_commit(oid).context("failed to find commit")?; let path = std::path::Path::new(file_path); @@ -116,10 +114,9 @@ pub fn load_commit_file(repo: &Repository, oid: Oid, file_path: &str) -> Result< /// A blob as text, refusing one too large to show *before* it is loaded. /// /// The size comes from the object database's header rather than from the blob, -/// because reading the blob is what there is to avoid: a repository can hold an -/// object larger than this process should hold in memory, and finding that out -/// from `Blob::content()` means having already paid for it. The working-tree -/// path guards the same way, off the file's metadata. +/// because reading the blob is what there is to avoid: finding a too-large +/// object from `Blob::content()` means having already paid for it. The +/// working-tree path guards the same way, off the file's metadata. fn read_blob(repo: &Repository, oid: Oid) -> Result { let odb = repo.odb().context("failed to open the object database")?; let (size, _) = odb diff --git a/src/git/diff/types.rs b/src/git/diff/types.rs index 99f2ac42..e5abaf00 100644 --- a/src/git/diff/types.rs +++ b/src/git/diff/types.rs @@ -120,7 +120,7 @@ impl ChangedFile { /// Rendered display path. Non-rename borrows `path` with no allocation /// (the hot per-frame case); renames own the formatted `old -> new` string. /// Returns `Cow` so callers can slice it for horizontal scroll via - /// `char_offset` and measure it with `chars().count()`. + /// `char_offset`. pub fn display_path(&self) -> Cow<'_, str> { match &self.old_path { Some(old) => Cow::Owned(format!("{old} -> {}", self.path)), @@ -175,9 +175,8 @@ pub struct RepoSnapshot { pub files: Vec, pub tracking: Option, /// HEAD commit oid at snapshot time. `None` when HEAD is unborn (an empty - /// repository, an orphan checkout) or unreadable — a detached HEAD still - /// names a commit. Compared against `App::last_head_oid` to detect new - /// commits. + /// repository, an orphan checkout) or unreadable. Compared against + /// `App::last_head_oid` to detect new commits. pub head_oid: Option, /// Current branch shorthand (e.g. `main`). `None` for detached HEAD, /// unborn branch, or bare repo. diff --git a/src/git/mod.rs b/src/git/mod.rs index 257036d3..b1f74225 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -17,34 +17,29 @@ pub fn resolve_repo_path(path: impl AsRef) -> PathBuf { .and_then(|repo| repo.workdir().map(Path::to_path_buf)); let candidate = found.as_deref().unwrap_or(path); // Canonicalized whichever branch produced it, so one worktree has exactly - // one spelling. Project de-duplication compares these strings, and a - // second spelling opens a second tab on a repository already open. + // one spelling: project de-duplication compares these strings, and a second + // spelling opens a second tab on a repository already open. // // Applied to libgit2's answer too, rather than trusting it: what `workdir` // returns is platform-specific — a trailing separator, symlinks resolved on // some systems and not others, and on Windows the casing as it was asked - // for rather than as it is on disk, where `C:\Code` and `c:\code` are one - // directory. Making the guarantee ours costs one `stat` and does not depend - // on behaviour no test here can reach. + // for rather than as it is on disk. Making the guarantee ours costs one + // `stat` and does not depend on behaviour no test here can reach. // - // A path that cannot be canonicalized is returned as it came. That is - // almost always one that does not exist, which the caller has already - // rejected — but a directory the process cannot open would land here too, - // and for it the single-spelling guarantee is off. Opening the repository - // and letting git report what is wrong beats refusing to show it at all, - // and the cost of being wrong is the duplicate tab this exists to prevent, - // not anything lost. + // A path that cannot be canonicalized is returned as it came — almost + // always one that does not exist, which the caller has already rejected. + // For it the single-spelling guarantee is off, but opening the repository + // and letting git report what is wrong beats refusing to show it at all. crate::platform::paths::canonicalize_clean(candidate) .unwrap_or_else(|_| candidate.to_path_buf()) } /// Format a `git2::Error` from `Repository::discover` for user-facing display. /// -/// When the error is a "not a repository" / `NotFound` error of class -/// `Repository`, the internal libgit2 diagnostic (`; class=Repository (6); -/// code=NotFound (-3)`) is stripped — users cannot act on it. All other -/// errors preserve the full `error.to_string()` so the diagnostic is -/// available for debugging. +/// A "not a repository" / `NotFound` error of class `Repository` loses the +/// internal libgit2 diagnostic (`; class=Repository (6); code=NotFound (-3)`) — +/// users cannot act on it. All other errors keep the full `error.to_string()` +/// for debugging. pub fn format_discover_error(error: &git2::Error) -> String { if error.class() == git2::ErrorClass::Repository && error.code() == git2::ErrorCode::NotFound { error.message().to_string() diff --git a/src/git/path/mod.rs b/src/git/path/mod.rs index 6fe316f1..54797d79 100644 --- a/src/git/path/mod.rs +++ b/src/git/path/mod.rs @@ -1,7 +1,7 @@ //! Validation for repository-relative paths that reach the filesystem. //! -//! Every path that names a file inside a worktree goes through -//! [`resolve_in_workdir`] before being opened. The web surfaces route +//! Every path naming a file inside a worktree goes through +//! [`resolve_in_workdir`] before being opened: the web surfaces route //! caller-supplied strings to the same loaders, so the check lives at the //! filesystem boundary rather than at each call site. @@ -34,27 +34,25 @@ const HFS_IGNORABLE: [char; 16] = [ /// The name a filesystem will actually open, given the name that was asked for. /// -/// Three rewrites, each a documented way to name one file and be handed -/// another, and each defended by git too (`core.protectNTFS`, -/// `core.protectHFS`) — though not identically: git tests *every* colon- -/// delimited segment of a name and this tests the first. The difference is -/// unreachable, because a later segment names a stream hanging off the earlier -/// one rather than a directory, so `x:.git` is a stream on `x` and never git's -/// own directory. +/// Undoes the three documented ways to name one file and be handed another, +/// each also defended by git (`core.protectNTFS`, `core.protectHFS`): /// /// - everything from a `:` on is an NTFS alternate-stream suffix, and /// `.git::$INDEX_ALLOCATION` opens the directory `.git` /// - HFS+ drops the ignorable code points above, so `.git` is `.git` /// - Windows drops trailing dots and spaces, so `.git.` is `.git` /// +/// Only the first `:`-delimited segment is tested, unlike git which tests every +/// one — the difference is unreachable because a later segment names a stream +/// hanging off the earlier one (`x:.git` is a stream on `x`), never a directory. +/// /// Applied to every component before *any* rule judges it, so a rewritten name /// cannot slip past `..` either — on HFS+ a `.` with an ignorable between the /// dots is still the parent directory. fn effective_name(name: &str) -> String { // Only when something precedes it: a stream suffix hangs off a name, so a - // leading `:` is not one. Cutting there unconditionally left nothing to - // judge, and `:f.rs` — an ordinary file on Unix, unnameable on Windows — - // came back as a traversal. + // leading `:` is not one. Cutting there unconditionally reduced `:f.rs` — + // an ordinary file on Unix — to nothing and called it a traversal. let base = match name.split(':').next() { Some(before) if !before.is_empty() => before, _ => name, @@ -70,8 +68,8 @@ fn effective_name(name: &str) -> String { /// run on: case-insensitively (macOS, Windows), under every rewrite /// [`effective_name`] undoes, and including the 8.3 short name. /// -/// Every place that decides whether a name is git's own directory must use this -/// — a second, looser spelling of the rule is how a bypass gets in. +/// Every place that decides whether a name is git's own directory must use +/// this — a second, looser spelling of the rule is how a bypass gets in. pub fn is_git_dir_name(name: &str) -> bool { let name = effective_name(name); name.eq_ignore_ascii_case(GIT_DIR) || name.eq_ignore_ascii_case(GIT_SHORT_DIR) @@ -88,11 +86,8 @@ fn is_git_dir(part: &std::ffi::OsStr) -> bool { /// Windows reads `c:x` as drive `C:` plus `x`, but only at the start of a path — /// so as the second component of `src/c:x` it arrives here as one `Normal`. /// `PathBuf::push` then parses it again, finds the prefix, and *replaces the -/// whole buffer*, throwing away the worktree the walk had built up. Refusing it -/// keeps the component walk honest instead of leaning on the final containment -/// check to notice. -/// -/// A no-op on Unix, where a colon is an ordinary character in a name. +/// whole buffer*, throwing away the worktree the walk had built up. A no-op on +/// Unix, where a colon is an ordinary character in a name. fn is_a_path_of_its_own(part: &std::ffi::OsStr) -> bool { let mut components = Path::new(part).components(); !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() @@ -103,10 +98,8 @@ fn is_a_path_of_its_own(part: &std::ffi::OsStr) -> bool { /// /// `Path::components` judges the name as written, so `.. ` on Windows and /// `..` on HFS+ both arrive here as one `Normal` and the `..` arm below -/// never runs. The escape this module exists to stop would then be spelled with -/// one extra character. -/// -/// It costs a name like `...`, legal on Unix and unnameable on Windows anyway. +/// never runs. It costs a name like `...`, legal on Unix and unnameable on +/// Windows anyway. fn is_traversal_after_trimming(part: &std::ffi::OsStr) -> bool { part.to_str().is_some_and(|name| { let name = effective_name(name); @@ -116,12 +109,10 @@ fn is_traversal_after_trimming(part: &std::ffi::OsStr) -> bool { /// True when no path may contain `name` as a component. /// -/// The listing surfaces share this with the validator so that no row is offered -/// under a name the gate will refuse: the file tree used to show a `...` -/// directory that the gate then refused, and search silently dropped everything -/// under it. Names only — whether the thing behind the name can be opened is -/// [`resolve_in_workdir`]'s question, and it still refuses a symlink that is -/// listed. +/// The listing surfaces share this with the validator so no row is offered under +/// a name the gate will refuse (a `...` row that answered "not a plain relative +/// path" when clicked used to happen). Names only — whether the thing behind the +/// name can be opened is [`resolve_in_workdir`]'s question. pub fn is_refused_component(name: &str) -> bool { let part = std::ffi::OsStr::new(name); is_git_dir_name(name) || is_traversal_after_trimming(part) || is_a_path_of_its_own(part) @@ -131,8 +122,8 @@ pub fn is_refused_component(name: &str) -> bool { /// /// Unlike [`resolve_in_workdir`], this deliberately does not stat the path: /// a deleted file is absent from the current worktree but is still a valid -/// member of a historical commit diff. Callers must use this only with git's -/// object database, never before opening a worktree file. +/// member of a historical commit diff. Never use this before opening a +/// worktree file. pub fn validate_commit_path(relative: &str) -> Result<()> { if relative.is_empty() { return Err(anyhow!("empty path")); @@ -160,15 +151,14 @@ pub fn validate_commit_path(relative: &str) -> Result<()> { } /// Resolve `relative` against `workdir`, rejecting anything that could escape -/// the worktree or read git's internals. -/// -/// Rejects: absolute paths, `..` and other non-plain components, any component -/// naming the git directory (see [`is_git_dir`]), embedded NUL bytes, and -/// symlinks at *any* component — not just the final one. +/// the worktree or read git's internals: absolute paths, `..` and other +/// non-plain components, any component naming the git directory (see +/// [`is_git_dir`]), embedded NUL bytes, and symlinks at *any* component — not +/// just the final one. /// -/// The returned path is the canonicalized location and is guaranteed to sit -/// under the canonicalized `workdir`. A caller that opens it still races with -/// a concurrent rename of the worktree itself; that residual TOCTOU window is +/// The returned path is canonicalized and guaranteed to sit under the +/// canonicalized `workdir`. A caller that opens it still races with a +/// concurrent rename of the worktree itself; that residual TOCTOU window is /// accepted, since every surface reaching this function is already /// authenticated and local. pub fn resolve_in_workdir(workdir: &Path, relative: &str) -> Result { @@ -203,8 +193,8 @@ pub fn resolve_in_workdir(workdir: &Path, relative: &str) -> Result { // Not redundant, even though the walk rejected every link: `push` re-parses // each component, and one that carries a Windows prefix would replace the - // buffer outright rather than extend it. `is_a_path_of_its_own` refuses - // those up front, and this is what catches it if a spelling gets past. + // buffer outright rather than extend it — `is_a_path_of_its_own` refuses + // those up front, and this is the backstop. if !resolved.starts_with(&base) { return Err(anyhow!("path escapes the worktree: {relative}")); } diff --git a/src/git/tree/mod.rs b/src/git/tree/mod.rs index 12e06e03..f5204ad5 100644 --- a/src/git/tree/mod.rs +++ b/src/git/tree/mod.rs @@ -18,12 +18,12 @@ pub struct TreeEntry { } /// Read the immediate children of `rel_dir` (a repo-relative path; `""` is the -/// workdir root). Entries are filtered and returned sorted with directories -/// first, then case-sensitive alphabetical by name. +/// workdir root), filtered and sorted with directories first, then +/// case-sensitive alphabetical by name. /// /// `.git` is skipped at every level. Non-UTF-8 names are skipped because the -/// file-view loader keys on `&str` paths. Individual entries whose metadata -/// cannot be read are skipped rather than failing the whole listing. +/// file-view loader keys on `&str` paths. Entries whose metadata cannot be read +/// are skipped rather than failing the whole listing. pub fn read_children( repo: &Repository, workdir: &Path, @@ -63,11 +63,8 @@ pub fn read_children( // request can carry. Sharing the rule is the point — an exact // `== ".git"` here would still list `.GIT` on a case-insensitive // filesystem, and listing a `...` directory left a row that answered - // "not a plain relative path" when clicked. - // - // Only names. A symlink still gets a row and refuses to open, because - // that is the open gate's own rule and nothing about how this name is - // spelled. + // "not a plain relative path" when clicked. Only names: a symlink still + // gets a row and refuses to open. if crate::git::path::is_refused_component(&name) { continue; } @@ -97,8 +94,7 @@ pub fn read_children( Ok(out) } -/// One hit from [`search_tree`]: the full repo-relative path and whether the -/// entry is a directory. +/// One hit from [`search_tree`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TreeMatch { pub path: String, diff --git a/src/runtime/emulator/snapshot.rs b/src/runtime/emulator/snapshot.rs index 89143db3..f2402b6c 100644 --- a/src/runtime/emulator/snapshot.rs +++ b/src/runtime/emulator/snapshot.rs @@ -1,32 +1,27 @@ //! Turning an emulated screen back into the bytes that reproduce it. //! -//! A pane's byte ring is history, not a screen. For a program drawing on the -//! alternate screen the recorded bytes are cell updates against a screen the -//! reader does not have, so replaying them paints fragments — and a -//! normal-screen program that repaints in place hits the same wall from the -//! other side: its repaints rotate the byte-bounded ring until the bytes that -//! painted the rest of the screen are evicted. Either way the emulator the hub -//! already runs to follow the pane's modes is holding the screen those bytes -//! produced. This turns its grid back into the bytes that paint it. +//! A pane's byte ring is history, not a screen. For an alternate-screen +//! program the recorded bytes are cell updates against a screen the reader +//! does not have; a normal-screen program that repaints in place instead +//! rotates the byte-bounded ring until the bytes that painted the screen are +//! evicted. Either way the emulator already runs is holding the screen those +//! bytes produced, and this turns its grid back into bytes that paint it. //! -//! Written as an **absolute repaint**: the screen is cleared with default -//! attributes, every row is positioned by `CUP`, and each run of equal -//! attributes costs one `SGR` that begins with a reset. Nothing in the output -//! depends on where the receiving terminal's cursor was or which attributes it -//! had, so the same snapshot is correct for a client that has just opened a -//! blank terminal and for one being repainted. +//! Written as an **absolute repaint**: screen cleared, every row positioned by +//! `CUP`, each attribute run costing one reset-leading `SGR`. Nothing depends +//! on where the receiving terminal's cursor was or which attributes it had, so +//! the same snapshot is correct for a fresh terminal and for a repaint. //! -//! **What a snapshot does not carry.** Wrap bookkeeping: `WRAPLINE` on a row -//! that continued into the next, and `LEADING_WIDE_CHAR_SPACER` on the filler -//! left when a wide character did not fit the last column. Both describe how a -//! row came to look this way rather than how it looks, and an absolute repaint -//! places each row independently — so a row that wrapped arrives as two rows and -//! a later resize reflows it differently from the original. Nothing reads that -//! difference today: alternate-screen programs redraw on resize, and a -//! normal-screen pane's history is still replayed from its byte ring, which -//! keeps its wrapping intact — the snapshot stands in only for the screen -//! itself. Underline colour, hyperlinks (OSC 8) and the scrolling region -//! (DECSTBM) are not carried either. +//! **What a snapshot does not carry.** Wrap bookkeeping (`WRAPLINE`, +//! `LEADING_WIDE_CHAR_SPACER`): both describe how a row came to look this way +//! rather than how it looks, and an absolute repaint places each row +//! independently — so a wrapped row arrives as two rows and a later resize +//! reflows it differently. Nothing reads that difference today: +//! alternate-screen programs redraw on resize, and a normal-screen pane's +//! history is still replayed from its byte ring, which keeps its wrapping — +//! the snapshot stands in only for the screen itself. Underline colour, +//! hyperlinks (OSC 8) and the scrolling region (DECSTBM) are not carried +//! either. use super::EventProxy; use alacritty_terminal::grid::Dimensions; @@ -37,9 +32,9 @@ use alacritty_terminal::vte::ansi::{Color, NamedColor}; use std::fmt::Write as _; /// How a cell looks. The remaining flags describe the grid's own bookkeeping -/// (wide-char spacers, wrap continuation) rather than anything a terminal can be -/// told to enter, so they are masked out — and comparing what is left is what -/// lets a run of equal attributes cost one escape. +/// rather than anything a terminal can be told to enter, so they are masked +/// out — and comparing what is left is what lets a run of equal attributes +/// cost one escape. #[derive(PartialEq, Eq, Clone, Copy)] struct Pen { fg: Color, @@ -57,12 +52,12 @@ impl Pen { } } -/// Whether a cell is indistinguishable from one that was never written, so a run -/// of them at the end of a row can be erased instead of spelled out. +/// Whether a cell is indistinguishable from one that was never written, so a +/// run of them at the end of a row can be erased instead of spelled out. /// -/// Held to *every* attribute rather than just the background: a space carrying a -/// foreground colour looks the same but is not the same cell, and erasing it would -/// hand a client a screen that differs from the one it is replacing. +/// Held to *every* attribute, not just the background: a space carrying a +/// foreground colour looks the same but is not the same cell, and erasing it +/// would hand a client a screen that differs from the one it replaces. fn is_blank(cell: &Cell) -> bool { cell.c == ' ' && cell.zerowidth().is_none() && Pen::of(cell) == Pen::of(&Cell::default()) } @@ -81,27 +76,26 @@ fn rendered_flags() -> Flags { pub(super) fn screen_snapshot(term: &Term) -> Vec { let grid = term.grid(); let (rows, cols) = (grid.screen_lines(), grid.columns()); - // One escape and one glyph per cell is the floor; the slack covers each row's - // `CUP` and the attribute runs. Reserved up front because a snapshot of a large - // pane is taken on the worker's tick, where a dozen reallocations of a - // megabyte-long string is the whole cost. + // One escape and one glyph per cell is the floor; the slack covers each + // row's `CUP` and the attribute runs. Reserved up front because a + // snapshot of a large pane is taken on the worker's tick, where a dozen + // reallocations of a megabyte-long string is the whole cost. let mut out = String::with_capacity(rows * cols + rows * 16 + 32); out.push_str("\x1b[m\x1b[2J"); - // Carried across rows: `SGR` survives a `CUP`, so a run of equal attributes - // spanning a row boundary still costs one escape. + // Carried across rows: `SGR` survives a `CUP`, so a run of equal + // attributes spanning a row boundary still costs one escape. let mut pen: Option = None; for row in 0..rows { // Positioned rather than reached by a newline. Writing the last column - // of a row leaves the cursor pending-wrap, and this `CUP` is what - // cancels it — which is also why a full row of cells can never scroll - // the screen. Grid line 0 is the top of the live screen whatever the - // display offset is, so this does not depend on the emulator's scroll. + // of a row leaves the cursor pending-wrap, and this `CUP` cancels it — + // also why a full row of cells can never scroll the screen. Grid line + // 0 is the top of the live screen whatever the display offset is. let _ = write!(out, "\x1b[{};1H", row + 1); - // Everything past the last cell worth naming is erased rather than spelled - // out. On a screen that is mostly empty — which most screens are, and a - // large pane especially — this is the difference between a snapshot of a - // few kilobytes and one of several hundred. + // Everything past the last cell worth naming is erased rather than + // spelled out. Most screens are mostly empty, and this is the + // difference between a snapshot of a few kilobytes and several + // hundred. let last = (0..cols) .rev() .find(|&col| !is_blank(&grid[Point::new(Line(row as i32), Column(col))])); @@ -131,9 +125,9 @@ pub(super) fn screen_snapshot(term: &Term) -> Vec { out.extend(zerowidth); } } - // Only when the row was not written to its last column: there the cursor is - // left pending-wrap *on* that column, and erasing to the end of the line - // from there would wipe the cell just written. + // Only when the row was not written to its last column: there the + // cursor is left pending-wrap *on* that column, and erasing from there + // would wipe the cell just written. if last + 1 < cols { out.push_str(ERASE_TO_END_OF_ROW); pen = Some(Pen::of(&Cell::default())); @@ -153,17 +147,18 @@ pub(super) fn screen_snapshot(term: &Term) -> Vec { out.into_bytes() } -/// Erase the rest of the row to blank cells. The reset leads because `EL` erases -/// with the *current* background, and what it has to leave behind is the default -/// one — which is what makes the erased cells equal the cells they stand in for. +/// Erase the rest of the row to blank cells. The reset leads because `EL` +/// erases with the *current* background, and what it has to leave behind is +/// the default one — which is what makes the erased cells equal the cells +/// they stand in for. const ERASE_TO_END_OF_ROW: &str = "\x1b[m\x1b[K"; -/// One absolute `SGR`. Leads with `0` so the sequence states the whole pen rather -/// than a change from whatever the reader had. +/// One absolute `SGR`. Leads with `0` so the sequence states the whole pen +/// rather than a change from whatever the reader had. /// -/// Appended in place rather than returned: on a densely coloured screen this runs -/// once per cell, and building a string per call was measurably the cost of the -/// whole snapshot. +/// Appended in place rather than returned: on a densely coloured screen this +/// runs once per cell, and building a string per call was measurably the cost +/// of the whole snapshot. fn write_sgr(out: &mut String, pen: Pen) { out.push_str("\x1b[0"); for (flag, param) in [ @@ -189,8 +184,8 @@ fn write_sgr(out: &mut String, pen: Pen) { out.push('m'); } -/// The `SGR` parameter selecting `color`. The default is written as nothing at -/// all — every sequence starts from a reset, so it needs no saying. +/// The `SGR` parameter selecting `color`. The default is written as nothing — +/// every sequence starts from a reset, so it needs no saying. /// /// A named colour with no fixed palette slot (`Cursor`, `BrightForeground`, /// `DimForeground`) defers to the default for the same reason diff --git a/src/runtime/snapshot/worker.rs b/src/runtime/snapshot/worker.rs index 299b1525..a5e1773e 100644 --- a/src/runtime/snapshot/worker.rs +++ b/src/runtime/snapshot/worker.rs @@ -24,9 +24,8 @@ pub(super) struct Worker { /// The watches the worker holds, and what it has already tried to watch. /// /// What was tried is recorded rather than inferred from the handles: a refusal -/// leaves no handle, and re-deriving "not installed yet" from that would re-walk -/// the tree and log the same warning once a second. A failure is answered by -/// falling back to the interval and retried only when what is wanted changes. +/// leaves no handle, and re-deriving "not installed yet" from that would +/// re-walk the tree and log the same warning once a second. #[derive(Default)] struct Watches { tree: Option, @@ -217,15 +216,13 @@ impl Worker { self.deliver(msg) } - /// Hand a reading over, unless nobody is reading any more. `false` once the - /// receiver is gone. + /// Hand a reading over, unless nobody is reading any more. `false` once + /// the receiver is gone. /// /// Checked again here rather than only before the walk, which on a large /// tree takes long enough for the last client to leave. A reading nobody - /// waited for is worse than wasted: it sits in the channel until whoever - /// owns the receiver next drains it, and that is after the next client has - /// taken a fresher reading for itself and shown it. The older one then lands - /// on top. + /// waited for lands in the channel after the next client's fresher + /// reading — and then on top of it. fn deliver(&self, msg: SnapshotMsg) -> bool { if !self.awake.load(Ordering::Acquire) { return true; diff --git a/src/runtime/snapshot_watch.rs b/src/runtime/snapshot_watch.rs index 026f196e..da69e020 100644 --- a/src/runtime/snapshot_watch.rs +++ b/src/runtime/snapshot_watch.rs @@ -186,8 +186,7 @@ fn matters(repo: Option<&git2::Repository>, roots: &Roots, path: &Path) -> bool return true; }; // Build output is the loudest thing in a working tree and the one thing git - // has been told to disregard: a `cargo build` writes thousands of files that - // cannot appear in a status. Skipping them is what makes this worth having. + // has been told to disregard: skipping it is what makes this worth having. // // A tracked file inside an ignored directory (added with `-f`) is the case // this skips wrongly. The idle read is what still catches it. @@ -197,16 +196,13 @@ fn matters(repo: Option<&git2::Repository>, roots: &Roots, path: &Path) -> bool /// Whether a change at `inside` — a path relative to a git directory — could /// change what a status says. /// -/// **Top level only, on purpose.** A submodule keeps a git directory of its own -/// under `modules//`, and the same churn happens there, so extending the -/// rule to those is tempting. It cannot be done from the path: a submodule's -/// name is its path in the tree, slashes and all, so `modules/foo/objects/HEAD` -/// is the `HEAD` of a submodule at `foo/objects` and the objects directory of -/// one at `foo` — and there is no counting of components that tells them apart. -/// Guessing costs a real change dropped in one direction and nothing gained in -/// the other, while admitting them all costs at most one extra read per second -/// during a submodule fetch, which is what the reader cost before it watched -/// anything. +/// **Top level only, on purpose.** Extending the rule to submodules +/// (`modules//`) is tempting but cannot be done from the path: a +/// submodule's name is its path in the tree, so `modules/foo/objects/HEAD` is +/// ambiguous between the `HEAD` of a submodule at `foo/objects` and the objects +/// directory of one at `foo`. Guessing costs a real change dropped; admitting +/// them all costs at most one extra read per second during a submodule fetch, +/// which is what the reader cost before it watched anything. fn git_metadata_matters(inside: &Path) -> bool { // Objects and reflogs churn on every commit and every fetch, and neither // changes a status by itself — the index or ref update that comes with them diff --git a/src/runtime/terminal/lifecycle.rs b/src/runtime/terminal/lifecycle.rs index b7f5d553..f4104d63 100644 --- a/src/runtime/terminal/lifecycle.rs +++ b/src/runtime/terminal/lifecycle.rs @@ -108,7 +108,7 @@ impl TerminalState { /// Allocate a new backend pane and matching emulator. `command`, when /// present, is run in the pane's shell immediately; `label` sets the /// initial tab title (a program that emits OSC 0/2 can still override it - /// later). Both default sensibly when `None`. + /// later). pub fn create_pane_with( &mut self, command: Option<&str>, @@ -169,8 +169,8 @@ impl TerminalState { /// Take in a pane the backend reports. /// /// `requested` says whether this client asked: one it did takes the focus, - /// and one another client opened lands in the list without moving anybody's - /// cursor. + /// and one another client opened lands in the list without moving + /// anybody's cursor. fn adopt_pane( &mut self, id: PaneId, diff --git a/src/runtime/terminal/session_panes.rs b/src/runtime/terminal/session_panes.rs index 6b5cd9fb..b4544cc9 100644 --- a/src/runtime/terminal/session_panes.rs +++ b/src/runtime/terminal/session_panes.rs @@ -12,11 +12,9 @@ impl TerminalState { /// Ask for the active pane to be closed. Reports whether there was one to /// ask about; an empty list is a benign no-op. /// - /// A request, like a create. The pane goes when the session says it did - /// ([`BackendEvent::Exited`]), which is also how a pane someone else closed - /// arrives. Removing it here instead would show it gone while its process - /// kept running — and a close the session never carried out (a full command - /// queue drops one) would leave this client unable to see that pane again. + /// A request, like a create: the pane goes when the session says it did, + /// which is also how a pane someone else closed arrives. Removing it here + /// instead would show it gone while its process kept running. pub fn close_active(&mut self) -> bool { let Some(info) = self.panes.get(self.active) else { return false; @@ -60,15 +58,15 @@ impl TerminalState { /// Put the panes in the order the session gives. /// - /// Reconciled rather than applied blindly, because the client and the session - /// can disagree for a beat: an id this client has not adopted yet is skipped, - /// and a pane the order omits keeps its place at the end. Focus follows the - /// *pane* it was on rather than the slot — the point of a swap is to move a - /// pane while still looking at it. Per-pane state (emulators, scroll, sizes, - /// prompt buffers) is keyed by id, so none of it moves. + /// Reconciled rather than applied blindly, because the client and the + /// session can disagree for a beat: an id this client has not adopted yet + /// is skipped, and a pane the order omits keeps its place at the end. + /// Focus follows the *pane* it was on rather than the slot — the point of + /// a swap is to move a pane while still looking at it. /// - /// Test-only for a locally-backed state, which has no session to be told by; - /// [`swap_active_with`](Self::swap_active_with) is what asks in production. + /// Test-only for a locally-backed state, which has no session to be told + /// by; [`swap_active_with`](Self::swap_active_with) is what asks in + /// production. pub(crate) fn apply_order(&mut self, order: &[PaneId]) { let active_id = self.active_pane_id(); let mut taken: Vec = Vec::with_capacity(self.panes.len()); diff --git a/src/runtime/tree_watch.rs b/src/runtime/tree_watch.rs index 6d3879b6..6facd8bb 100644 --- a/src/runtime/tree_watch.rs +++ b/src/runtime/tree_watch.rs @@ -15,9 +15,8 @@ use std::sync::mpsc::{self, Receiver, TryRecvError}; use std::time::Duration; /// Coalescing window for filesystem events. Long enough to batch the burst a -/// single `git`/editor/agent operation produces into one refresh, short enough -/// to feel live. Sits between nvim-tree (50 ms) and gitui (2 s); broot uses -/// 500 ms. +/// single `git`/editor/agent operation produces into one refresh, short +/// enough to feel live. const DEBOUNCE: Duration = Duration::from_millis(300); /// Owns the debounced filesystem watcher and the set of currently watched @@ -30,7 +29,6 @@ const DEBOUNCE: Duration = Duration::from_millis(300); /// In tests (and when the watcher fails to start) `debouncer` is `None`: the /// receiver still exists so `App` polling is uniform, and watch/unwatch calls /// become no-ops. -/// What changed since the last poll. #[derive(Debug, Default, PartialEq, Eq)] pub struct TreeChanges { /// Repo-relative directories whose contents changed. A file event is diff --git a/src/session/catalog/mod.rs b/src/session/catalog/mod.rs index 01a0b128..16027863 100644 --- a/src/session/catalog/mod.rs +++ b/src/session/catalog/mod.rs @@ -6,17 +6,16 @@ //! tab does not renumber the others. //! //! Replacement is atomic and does no blocking work under the lock: the new list -//! is built, swapped in, and only then are the dropped runtimes stopped — a +//! is built and swapped in, and only then are the dropped runtimes stopped — a //! runtime shutdown joins a thread, and holding the catalog lock across that //! would stall every in-flight request. //! //! **Every path in here is the one `resolve_repo_path` produces**, normalised on -//! the way in rather than by each caller (see [`Catalog::normalized`]). Two -//! spellings of one worktree are two strings, and the whole catalog — the served -//! set, `hidden`, `order` — decides identity by comparing them, so a path that -//! arrived spelled differently opened a second tab on a repository already open. -//! Holding the invariant at the boundary is what keeps the next entry point from -//! having to remember. +//! the way in rather than by each caller. Two spellings of one worktree are two +//! strings, and the whole catalog decides identity by comparing them, so a path +//! that arrived spelled differently opened a second tab on a repository already +//! open. Holding the invariant at the boundary keeps every entry point from +//! having to remember it. use crate::session::StatusEncoder; use crate::session::runtime::RepoRuntime; @@ -75,10 +74,9 @@ impl Catalog { /// One worktree's single spelling: what `git` calls the working directory, /// or the canonical directory when there is no repository there. /// - /// Applied to everything entering the catalog. `add_path`'s caller resolves - /// too, and doing it twice costs one `discover` on a path a person just - /// asked for — cheap next to the alternative, which is this invariant - /// depending on every caller having remembered. + /// Applied to everything entering the catalog, even though `add_path`'s + /// caller resolves too — doing it twice costs one `discover`, cheap next + /// to this invariant depending on every caller having remembered. fn normalized(path: &str) -> String { crate::git::resolve_repo_path(Path::new(path)) .to_string_lossy() @@ -92,10 +90,9 @@ impl Catalog { let _mutation = self.mutation.lock().expect("catalog mutation poisoned"); { let mut base = self.base.lock().expect("catalog base poisoned"); - // The one entry point that took paths from outside untouched: a - // `--repo` argument and a workspace file hold whatever spelling was - // typed or last written, which is not necessarily what a client - // opening the same repository will send. + // The one entry point taking outside paths untouched: a `--repo` + // argument and a workspace file hold whatever spelling was typed or + // last written, which is not necessarily what a client sends. *base = paths.iter().map(|p| Self::normalized(p)).collect(); } self.rebuild(); @@ -141,10 +138,10 @@ impl Catalog { /// re-sync will not bring it back; `rebuild` then stops its runtime and /// terminals. /// - /// A close forgets the slot the repository held, `base` and `order` included. - /// Leaving it in either meant [`Catalog::add_path`] found the path already in - /// `union_paths` and never appended it, so re-opening put the tab back in the - /// middle of the strip rather than at the end where it was just asked for. + /// A close forgets the slot the repository held, `base` and `order` + /// included. Leaving it in either meant [`Catalog::add_path`] found the + /// path already in `union_paths` and never appended it, so re-opening put + /// the tab back in the middle of the strip rather than at the end. pub fn remove_path(&self, path: &str) { let path = &Self::normalized(path); let _mutation = self.mutation.lock().expect("catalog mutation poisoned"); diff --git a/src/session/operations.rs b/src/session/operations.rs index cb636f7d..a6b36e8e 100644 --- a/src/session/operations.rs +++ b/src/session/operations.rs @@ -1,12 +1,7 @@ -//! What the served set of repositories can be asked to do, independent of how -//! the asking arrived. -//! -//! Opening, closing, and reordering are session operations, not HTTP ones. The -//! browser reaches them over HTTP and an attaching client reaches them over the -//! daemon socket, and both must land on exactly the same state change — so the +//! Session operations independent of how the request arrived: the browser and +//! an attaching client must land on exactly the same state change, so the //! change lives here and each transport keeps only its own translation. -//! -//! Nothing here authenticates. Deciding who may ask is the transport's job. +//! Nothing here authenticates — deciding who may ask is the transport's job. use super::SessionState; use crate::session::catalog::{AddOutcome, RepoInfo}; @@ -29,10 +24,9 @@ pub enum CloseError { UnknownRepo, } -/// One repository as an attaching client sees it. -/// -/// Carries the absolute path, which the browser's `RepoDto` deliberately does -/// not: an attached client reads git from that path itself. +/// One repository as an attaching client sees it, with the absolute path the +/// browser's `RepoDto` deliberately omits: an attached client reads git from +/// that path itself. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionRepo { pub id: String, @@ -57,17 +51,16 @@ pub fn list_session_repos(state: &SessionState) -> Vec { /// Open `raw_path` and add it to the served catalog. /// -/// The path arrives from outside, so it is expanded, checked, and resolved to -/// the worktree root before the catalog ever sees it — two spellings of one -/// repository must collapse to a single entry. +/// Resolved to the worktree root before the catalog ever sees it: two +/// spellings of one repository must collapse to a single entry. pub fn open_repo(state: &SessionState, raw_path: &str) -> Result { let raw = raw_path.trim(); if raw.is_empty() { return Err(OpenError::EmptyPath); } let expanded = crate::platform::paths::expand_tilde(raw); - // is_dir() follows symlinks and is false for a missing path — either way it - // cannot be served. + // is_dir() follows symlinks and is false for a missing path — either way + // unservable. if !expanded.is_dir() { return Err(OpenError::NotADirectory); } @@ -80,10 +73,9 @@ pub fn open_repo(state: &SessionState, raw_path: &str) -> Result { - // Opening is also a statement about where the client wants to be, so - // it focuses. Every client follows the session's active project, and - // leaving the focus behind would put the tab someone just asked for - // in the background — on their own screen and everyone else's. + // Opening is also a statement about where the client wants to be; + // leaving the focus behind would background the tab someone just + // asked for, on their own screen and everyone else's. if let Some(entry) = state.catalog.get(&repo.id) { state.prefs.set_active_repo(entry.path.clone()); } @@ -124,7 +116,7 @@ fn active_repo_from(state: &SessionState, stored: Option<&str>) -> Option Result<(), CloseError> { let entry = state.catalog.get(id).ok_or(CloseError::UnknownRepo)?; @@ -139,9 +131,8 @@ pub fn accent(state: &SessionState) -> usize { /// Set the session's accent, returning what was stored. /// -/// Shared like the active project rather than kept per surface. An index past -/// the end of the cycle wraps rather than being refused, matching -/// `Accent::from_index`. +/// Shared like the active project. An index past the end of the cycle wraps +/// rather than being refused, matching `Accent::from_index`. pub fn set_accent(state: &SessionState, accent: usize) -> usize { state.prefs.set_accent(accent).accent } @@ -150,56 +141,40 @@ pub fn set_accent(state: &SessionState, accent: usize) -> usize { /// /// The catalog rebuild stops the closed repository's runtime and terminals. /// The updated set is not returned: each transport reads it back in its own -/// projection, and both must do so afterwards anyway since another client can -/// change the set in between. +/// projection afterwards anyway, since another client can change the set. pub fn close_repo(state: &SessionState, id: &str) -> Result<(), CloseError> { let entry = state.catalog.get(id).ok_or(CloseError::UnknownRepo)?; - // One read of the preference, and both the decision and the condition on - // the write are made from it. Reading it twice would leave a gap: a focus - // landing between them would be taken for the value this decided against, - // and then overwritten by a successor chosen before it existed. + // One read of the preference; both the decision and the write condition + // come from it. Reading twice leaves a gap a focus landing in between + // would be mistaken into. // // Not necessarily the closing path, which is why the condition is this - // rather than a comparison against it. Nothing may have been selected at - // all, in which case the project in front is the first served one and the - // preference is still empty; or it may name a project this session does not - // serve. The successor has to be recorded in both, and comparing against - // the closing path skipped them silently — the fallback answered correctly - // for as long as the successor stayed first, then handed the front to - // whatever had taken its place. + // rather than a comparison against it: the preference may be empty, or + // name a project this session does not serve. Comparing against the + // closing path skipped those silently — the fallback answered correctly + // only while the successor stayed first. let focus_before = state.prefs.get().active_repo; - // Read before the close, because it is a position in the set that is about - // to change, and only interesting when the tab being closed is the one in - // front — closing a background project must leave the focus where it is. + // Read before the close: it is a position in the set about to change, and + // only interesting when the closing tab is the one in front. let successor = (active_repo_from(state, focus_before.as_deref()).as_deref() == Some(id)) .then(|| successor_of(state, id)) .flatten(); state.catalog.remove_path(&entry.path); - // Said outright rather than left to `active_repo`'s fallback. That fallback - // answers "nothing has been focused yet, or what is on file is no longer - // served" with the first repository, which is right for a fresh session and - // wrong for a close: it sent everyone to the first tab from wherever they - // were. The TUI has picked the neighbour since it had tabs - // (`workspace::close_at`) and was overruled by this a beat later. + // Said outright rather than left to `active_repo`'s fallback, which sends + // a close to the first tab from wherever they were; the TUI has picked the + // neighbour since it had tabs (`workspace::close_at`). if let Some(path) = successor - // Still served. The successor was read from the set before the close, - // and another client can have closed it in between — recording a path - // nothing resolves would leave every surface on `active_repo`'s - // fallback, which is the first tab this exists to stop landing on. + // Still served — another client can have closed it in between, and + // recording a path nothing resolves would land everyone on the first + // tab fallback this exists to stop. && state.catalog.id_of_path(&path).is_some() { - // Only while the preference is still what it was when this decided. - // Compared inside the preference store's own locked write, so against - // another focus it is atomic; what it cannot see is the catalog, which - // has a lock of its own that this must not hold at the same time. - // - // What this guarantees is that *the close* does not overwrite a focus - // made meanwhile — not that such a focus survives. A browser that - // closed the tab then records where it landed, from the one place its - // selection settles (`useRepoPoll`), and that write is a client saying - // where it is rather than a close deciding for everyone. Last assertion - // wins there, as it does for every other switch. A TUI close asserts - // nothing after the fact, so for it this holds outright. + // Only while the preference is still what it was when this decided — + // atomic against another focus inside the preference store's locked + // write, but it cannot see the catalog, whose lock this must not hold + // at the same time. Guarantees *the close* does not overwrite a focus + // made meanwhile, not that such a focus survives: a browser records + // where it landed and last assertion wins, as for every other switch. state .prefs .set_active_repo_if(focus_before.as_deref(), path); @@ -209,16 +184,12 @@ pub fn close_repo(state: &SessionState, id: &str) -> Result<(), CloseError> { } /// The tab to put in front once `id` closes: the one after it, or the one -/// before when it is last. Its *path*, because that is what the preference -/// stores and the id is about to stop naming anything. -/// -/// The same rule browsers use, and the same one `workspace::close_at` already -/// applies on the TUI — so the answer this records is the one that client had -/// picked for itself, and adopting it moves nothing. +/// before when it is last. Its *path*, because the id is about to stop naming +/// anything. The same rule `workspace::close_at` applies on the TUI, so +/// adopting it moves nothing. /// -/// `None` when the set holds nothing else, which is the empty screen. Nothing -/// is written then: there is no tab to name, and the stale entry costs nothing -/// because it no longer resolves. +/// `None` when the set holds nothing else; nothing is written then, and the +/// stale entry costs nothing because it no longer resolves. fn successor_of(state: &SessionState, id: &str) -> Option { let served = state.catalog.id_paths(); let closing = served.iter().position(|(served_id, _)| served_id == id)?; @@ -231,8 +202,7 @@ fn successor_of(state: &SessionState, id: &str) -> Option { /// Reorder the catalog to `ids`. /// /// Ids that no longer name a repository are dropped rather than refused: the -/// only way to send one is to have raced a close on another client, and the -/// catalog canonicalizes the requested order against what is actually live. +/// only way to send one is to have raced a close on another client. pub fn reorder_repos(state: &SessionState, ids: &[String]) { let paths: Vec = ids .iter() @@ -244,9 +214,8 @@ pub fn reorder_repos(state: &SessionState, ids: &[String]) { /// Mirror the served set into the shared workspace file so the next launch /// starts with the same projects. No-op unless the server was started with -/// `persist` (headless `serve`); alongside the TUI, the TUI owns that file. The -/// existing per-repo view state and active tab are preserved; only the -/// open-repo list is rewritten. +/// `persist` (headless `serve`); alongside the TUI, the TUI owns that file. +/// Only the open-repo list is rewritten. fn persist_workspace(state: &SessionState) { if !state.persist { return; diff --git a/src/session/prefs/repo_view.rs b/src/session/prefs/repo_view.rs index 53b842e5..8b2f3466 100644 --- a/src/session/prefs/repo_view.rs +++ b/src/session/prefs/repo_view.rs @@ -1,21 +1,18 @@ //! What each project was last showing in the browser, so opening it again //! opens what was open. //! -//! The TUI has kept this per repository since it had a session file — mode, the -//! selected file, the tree's cursor and its expanded directories +//! The TUI has kept this per repository since it had a session file //! (`app::session_io`). This is the same thing for the viewer, and deliberately //! not the same *file*: `workspace.json` belongs to the TUI, which rewrites it -//! whole when it exits (`session::operations::persist_workspace` says so), so an -//! entry written here would go the next time a TUI ran. Kept in `viewer.json` -//! beside `maximized`, which is per-project for the same reason and keyed the -//! same way — by absolute path, because repo ids only live as long as the -//! process. +//! whole when it exits, so an entry written here would go the next time a TUI +//! ran. Kept in `viewer.json` beside `maximized`, keyed the same way — by +//! absolute path, because repo ids only live as long as the process. use serde::{Deserialize, Serialize}; /// How many projects' views to remember. Past this the oldest go. Matches the -/// TUI's `MAX_REMEMBERED` and `maximized`'s cap for the same reason: a file -/// that grows with every project ever glanced at. +/// TUI's `MAX_REMEMBERED` and `maximized`'s cap: a file that grows with every +/// project ever glanced at. pub const MAX_REMEMBERED_VIEWS: usize = 50; /// How many expanded directories one project may keep. A tree opened all the diff --git a/src/session/reload.rs b/src/session/reload.rs index 98c2fc9e..cff37207 100644 --- a/src/session/reload.rs +++ b/src/session/reload.rs @@ -1,22 +1,19 @@ //! Re-reading `config.toml` into a running session, independent of how the //! asking arrived. //! -//! Sits beside [`session`](super::session) and for the same reason: the browser -//! reaches this over HTTP and an attached terminal over the daemon socket, and -//! both must land on exactly the same state change. Neither transport -//! authenticates here — deciding who may ask is theirs. +//! Sits beside [`session`](super::session) for the same reason: the browser and +//! an attached terminal must land on exactly the same state change. Neither +//! transport authenticates here. //! //! **What a reload is, and what it is not.** It re-reads two tables and nothing //! else. `[[plugin]]` reaches even the repositories that are already open, -//! because a plugin is a child process and replacing one costs the session -//! nothing. `[[startup_command]]` reaches only the repositories opened -//! afterwards: a hub creates its startup panes once for its life, and the panes -//! a running repository already spent that list on are live children that no -//! file edit may replace. Everything else in the file is read once at startup -//! and still needs a restart. +//! because replacing a plugin child costs the session nothing. +//! `[[startup_command]]` reaches only repositories opened afterwards: a hub +//! creates its startup panes once for its life, and the live children a running +//! repository already spent that list on no file edit may replace. //! -//! **It does not half-apply.** The whole file is parsed and validated first, so a -//! typo anywhere leaves the session exactly as it was. +//! **It does not half-apply.** The whole file is parsed and validated first, so +//! a typo anywhere leaves the session exactly as it was. use super::SessionState; @@ -56,8 +53,7 @@ impl ReloadReport { /// being opened. /// /// Written here rather than in each client because both surfaces show the - /// same sentence — a toast in the browser, a notice in the TUI — and two - /// wordings of the same outcome would drift. + /// same sentence, and two wordings of the same outcome would drift. pub fn summary(&self) -> String { let panes = if self.startup_commands == 0 { "no startup panes configured".to_string() @@ -123,15 +119,15 @@ pub fn reload_config_at( .set_config_tables(&cfg.startup_commands, cfg.plugins.clone()) .map_err(ReloadError::Config)?; - // Then the repositories already open. Each hub is *asked* — the work happens - // on its own worker thread, which is the only thread allowed to touch a - // plugin child — so this returns before the children have finished being - // replaced. That is deliberate: waiting would mean blocking whoever asked on - // every repository's queue. + // Then the repositories already open. Each hub is *asked* — the work + // happens on its own worker thread, the only thread allowed to touch a + // plugin child — so this returns before the children have been replaced. + // Deliberate: waiting would block whoever asked on every repository's + // queue. // // A hub too far behind to take the request is counted rather than retried: - // its queue being full means its worker is wedged or being hammered, and - // neither blocking on it nor pretending it complied is honest. It keeps the + // a full queue means its worker is wedged or being hammered, and neither + // blocking on it nor pretending it complied is honest. It keeps the // plugins it had, and the report says so. let mut unreachable = 0; for entry in &entries { @@ -139,10 +135,9 @@ pub fn reload_config_at( continue; } unreachable += 1; - // Which repository, logged here rather than in the hub — the hub does not - // keep its own path, and the summary is one sentence for a person, too - // short to carry a list. The operator who reads "1 was too busy" finds - // the name here. + // Which repository, logged here: the hub does not keep its own path, + // and the summary is one sentence for a person, too short to carry a + // list. tracing::warn!( repo = %entry.path, "session: a repository's queue was full; its plugins were not re-applied" diff --git a/src/session/runtime/mod.rs b/src/session/runtime/mod.rs index fd2dfc13..d6233acf 100644 --- a/src/session/runtime/mod.rs +++ b/src/session/runtime/mod.rs @@ -139,10 +139,10 @@ impl RepoRuntime { /// status, so a fresh connection renders immediately instead of waiting for /// the next change. /// - /// The first subscriber also starts the watch, and is answered from a reading - /// taken here rather than from `latest` — while the watch was off, `latest` - /// is whatever was true when the last client left, which on a page opened - /// the next morning is not a stale detail but a wrong screen. + /// The first subscriber also starts the watch, and is answered from a + /// reading taken here rather than from `latest` — while the watch was off, + /// `latest` is whatever was true when the last client left, which is a + /// wrong screen rather than a stale detail. pub fn subscribe(self: &Arc) -> Subscription { let id = self.next_subscriber_id.fetch_add(1, Ordering::AcqRel); let slot = Arc::new(Mutex::new(None)); @@ -171,11 +171,11 @@ impl RepoRuntime { // Outside the lock, which publishing takes. self.read_and_publish(); } - // Whatever the publish did not leave here: a repository unchanged since - // the last client left publishes nothing, and this subscriber would - // render an empty page until something happened. Read before the slot is - // locked, never while — `publish` holds `latest` and reaches for slots, - // so taking them the other way round is the two halves of a deadlock. + // Whatever the publish did not leave here: a repository unchanged + // since the last client left publishes nothing, and this subscriber + // would render an empty page until something happened. Read before + // the slot is locked, never while — `publish` holds `latest` and + // reaches for slots, so the other order is a deadlock. let seed = self.latest(); let mut held = slot.lock().expect("subscriber slot poisoned"); if held.is_none() { @@ -193,14 +193,13 @@ impl RepoRuntime { fn unsubscribe(&self, id: u64) { // Under the same hold of the lock as the removal, for the reason given - // in `subscribe`: a watch decision taken after letting go of the list can - // be overtaken by one taken while holding it. + // in `subscribe`: a watch decision taken after letting go of the list + // can be overtaken by one taken while holding it. let mut subscribers = self.subscribers.lock().expect("subscribers poisoned"); subscribers.retain(|s| s.id != id); if subscribers.is_empty() { - // Nobody is reading, so stop walking the tree. What was published - // stays in `latest` for anything that asks over REST; the next - // subscriber replaces it with a reading before it is served (see + // Nobody is reading, so stop walking the tree. The next subscriber + // replaces `latest` with a reading before it is served (see // `subscribe`). self.watch.set_awake(false); } diff --git a/src/session/size_owner.rs b/src/session/size_owner.rs index 7767b02d..e3b29f3e 100644 --- a/src/session/size_owner.rs +++ b/src/session/size_owner.rs @@ -7,23 +7,19 @@ //! //! **Why this is the session's and not each hub's.** Which repository is in //! front is shared by the whole session, so "which screen is this session fitted -//! to" is one question, not one per repository. Asked per hub, it was re-answered -//! from scratch on every switch — a browser's terminal socket is tied to the -//! repository it shows, so moving tabs made every attached page reconnect at once -//! and the sizing fell to whichever handshake finished last. +//! to" is one question. Asked per hub, it was re-answered on every switch — +//! moving tabs made every attached page reconnect at once and the sizing fell +//! to whichever handshake finished last. //! //! **A viewer is not a connection.** A socket opens for reasons that are not a -//! person sitting down: a repository switch, a page reload, a network blip. So a -//! viewer names itself ([`ViewerId`]) and says outright whether it is newly -//! arrived; the session never infers it. Connections come and go beneath a -//! viewer without moving anything. +//! person sitting down: a repository switch, a page reload, a network blip. So +//! a viewer names itself ([`ViewerId`]) and says outright whether it is newly +//! arrived; connections come and go beneath a viewer without moving anything. //! -//! **Unowned means empty.** The sizing has no owner only while nobody is here: -//! there is no screen to fit, so the panes keep the size they have. The moment -//! a viewer is present, one of them owns it. A session with a person in it and -//! nobody sizing for them is not a state worth having — it renders their panes -//! at a departed screen's size and makes them press the fit button to undo it, -//! which is what a phone did every time it woke up. +//! **Unowned means empty.** The sizing has no owner only while nobody is here. +//! A session with a person in it and nobody sizing for them renders their panes +//! at a departed screen's size — the state a phone produced every time it woke +//! up. //! //! This file is the facade — locking, and the contract each caller sees. The //! rules themselves live with the state they read, in [`state`]. @@ -42,10 +38,10 @@ use state::Inner; /// How long the sizing is held for an owner that has no connection left. /// -/// Switching repositories closes one terminal socket and opens another, and for -/// the moment in between the owner is not connected to anything. Handing the -/// sizing away there and back again would re-fit every pane twice for a viewer -/// that never went anywhere. Only the *release* is delayed; nothing claims by +/// Switching repositories closes one terminal socket and opens another, and +/// for the moment in between the owner is connected to nothing. Handing the +/// sizing away there and back would re-fit every pane twice for a viewer that +/// never went anywhere. Only the *release* is delayed; nothing claims by /// waiting. pub const RELEASE_GRACE: Duration = Duration::from_secs(2); diff --git a/src/session/terminal/frame.rs b/src/session/terminal/frame.rs index 5d38a8e3..abf33710 100644 --- a/src/session/terminal/frame.rs +++ b/src/session/terminal/frame.rs @@ -106,23 +106,22 @@ impl PaneSize { #[serde(tag = "type", rename_all = "lowercase")] pub enum ServerMessage { /// A pane exists, along with the size its PTY is currently set to. The size - /// rides along because the client is not the only source of it: a pane - /// replayed to a reconnecting page, or one another device sized, already has - /// a size this client never chose. Without it the client must assume nothing - /// and send its own size on attach, costing the child a full repaint. + /// rides along because the client is not the only source of it — a pane + /// another device sized already has a size this client never chose. + /// Without it the client must send its own size on attach, costing the + /// child a full repaint. Created { pane: PaneId, rows: u16, cols: u16, /// Which client asked for this pane, in the id space of the connection - /// the frame is going out on. Each recipient compares it against its own - /// id on that connection. `None` means nobody there asked: a replayed - /// pane, one another client opened, or a startup terminal. + /// the frame is going out on; each recipient compares it against its + /// own. `None` means nobody there asked: a replayed pane, one another + /// client opened, or a startup terminal. #[serde(default, skip_serializing_if = "Option::is_none")] client: Option, /// What the session calls this pane, when it has a name of its own — a - /// startup terminal opened under a configured name. Absent for a pane a - /// client asked for, and for one nothing has named. + /// startup terminal opened under a configured name. #[serde(default, skip_serializing_if = "Option::is_none")] title: Option, }, @@ -138,17 +137,16 @@ pub enum ServerMessage { cols: u16, }, /// Who this client is, in the id space [`Created::client`] is stamped in. - /// Addressed, and the first thing a connection is told. A connection's id, - /// not a viewer's: minted per connection and a reconnect gets a new one. + /// A connection's id, not a viewer's: minted per connection and a + /// reconnect gets a new one. /// /// [`Created::client`]: Self::Created::client Hello { client: u64, /// How many `Created` frames the replay is about to deliver. Exact, /// because `connect` queues the whole replay under the hub's lock and - /// only registers the client afterwards. A client that knows the count - /// can lay its grid out for the panes it is *going* to have rather than - /// the ones it has so far. + /// only registers the client afterwards — a client that knows the + /// count can lay its grid out for the panes it is *going* to have. panes: usize, }, /// Whether *this* client is the one whose layout sets the pane sizes. @@ -182,13 +180,11 @@ pub enum ServerMessage { }, /// What a plugin reports about a pane it is nursing back, relayed verbatim. /// - /// Pane metadata rather than screen content: nothing here is drawn into a - /// terminal grid, and a client that ignores it renders exactly as before. - /// `state` is the plugin's own short label; the hub neither interprets it nor - /// keeps it, so this is a broadcast of the latest word and not a state - /// machine. The one label the hub itself sends is - /// [`RECOVERY_CANCELLED`](super::hub_recovery::RECOVERY_CANCELLED), which a - /// client treats as "there is nothing pending any more". + /// Pane metadata rather than screen content: a client that ignores it + /// renders exactly as before. `state` is the plugin's own short label; + /// the hub neither interprets it nor keeps it, so this is a broadcast of + /// the latest word and not a state machine. The one label the hub itself + /// sends is [`RECOVERY_CANCELLED`](super::hub_recovery::RECOVERY_CANCELLED). /// A plugin says this pane wants the person back. Carries no reason and no /// text: the client turns it into that project tab's unread marker, which /// says "something happened here" and nothing more. diff --git a/src/session/terminal/hub_connect.rs b/src/session/terminal/hub_connect.rs index 7a7b6d26..47dccb68 100644 --- a/src/session/terminal/hub_connect.rs +++ b/src/session/terminal/hub_connect.rs @@ -17,15 +17,10 @@ impl TerminalHub { /// /// Per live pane: a `Created`, the modes its program has set /// ([`PaneModes::prelude`](crate::runtime::emulator::PaneModes::prelude)), and - /// then that pane's screen — its recorded bytes anchored to the serialized - /// screen they build on, or for a program drawing on the alternate screen - /// that serialized screen and what is owed on top of it (see - /// [`replay_pane`]). Done under the state lock so this snapshot cannot - /// interleave with the worker's append-and-broadcast (see - /// [`Shared`](super::hub_helpers::Shared)); the client therefore receives every - /// pane's screen exactly once and in order ahead of the live stream. A fresh - /// hub (e.g. after a server restart) has no panes, so a reconnecting client - /// correctly comes back to an empty panel. + /// then that pane's screen (see [`replay_pane`]). Done under the state lock + /// so this snapshot cannot interleave with the worker's append-and-broadcast; + /// the client therefore receives every pane's screen exactly once and in + /// order ahead of the live stream. /// /// `viewer` names who this connection belongs to and `arriving` says whether /// a person just sat down at it — a page opening rather than a repository @@ -63,18 +58,16 @@ impl TerminalHub { let _ = tx.try_send(TerminalFrame::Control(json)); } if replaying { - // Ahead of the panes, though it names one of them. A client holds - // its outbound resize until the layout stops moving, and replaying - // several panes' histories can outlast that wait — so a page that - // learned the zoom last could settle on the grid, size every PTY to - // a cell, and then resize them all again when the zoom arrived. - // That is two SIGWINCH repaints for every client, which is the cost - // `Created` carrying its pane's size exists to avoid. + // Ahead of the panes, though it names one of them. Replaying + // several panes' histories can outlast a client's wait for the + // layout to stop moving, so a page that learned the zoom last + // would settle on the grid, size every PTY to a cell, and then + // resize them all again — two SIGWINCH repaints per client, the + // cost `Created` carrying its pane's size exists to avoid. // - // Safe in this order because the panel derives what it renders from - // the pane list it has: a zoom naming a pane not delivered yet - // simply does not apply until that pane arrives. Sent only when - // something is zoomed — nothing zoomed is where a client starts. + // Safe in this order: a zoom naming a pane not delivered yet does + // not apply until that pane arrives. Sent only when something is + // zoomed — nothing zoomed is where a client starts. if let Some(pane) = state.zoomed && let Ok(json) = serde_json::to_string(&ServerMessage::Zoomed { pane: Some(pane) }) { @@ -99,11 +92,9 @@ impl TerminalHub { "viewer: replaying a pane's record" ); if !replay_pane(&tx, pane) { - // The queue is this client's own and empty until now, and a - // whole replay of the largest panes allowed fits it (see - // `REPLAY_CHUNK_BYTES`) -- so this is a broken assumption - // rather than a busy moment, and the client is left showing a - // screen with a hole in it that nothing else would explain. + // The queue is this client's own and empty until now, and + // a whole replay of the largest panes allowed fits it — + // so this is a broken assumption, not a busy moment. tracing::warn!( pane = pane.id, client = id, @@ -177,11 +168,10 @@ impl TerminalHub { /// Unregister a session that is going away. /// /// `connection` comes from the session rather than from the client record, - /// because the record may already be gone: every eviction path removes it - /// the moment the client stops keeping up. Reading the registration out of - /// the list meant an evicted client never released the sizing — it stayed - /// present forever, so a viewer that had it kept it after its page had - /// closed and no other screen could take it back. + /// which may already be gone: every eviction path removes it the moment + /// the client stops keeping up. Reading the registration out of the list + /// meant an evicted client never released the sizing — no other screen + /// could take it back after its page had closed. pub(super) fn disconnect(&self, id: u64, connection: u64) { self.state .lock() @@ -193,5 +183,9 @@ impl TerminalHub { // — `leave` ignores a connection it does not know, which is the case // when this runs twice for one session. self.ownership.leave(connection, Instant::now()); + // After `leave`: `queue_resize` checks ownership while holding this + // queue's lock, so a racing request is either inserted before this + // purge or rejected after the connection is no longer registered. + self.discard_pending_resizes(connection); } } diff --git a/src/session/terminal/hub_diag.rs b/src/session/terminal/hub_diag.rs index c42ae78f..44652309 100644 --- a/src/session/terminal/hub_diag.rs +++ b/src/session/terminal/hub_diag.rs @@ -1,17 +1,15 @@ //! Recording where a pane's screen-clearing input came from. //! //! This exists because of a specific unexplained event: a pane running Claude -//! Code had its conversation cleared fourteen times in five seconds. Claude Code -//! runs `/clear` when it receives `Ctrl+L` twice within two seconds, and the -//! transcript showed the clears arriving as a shortcut rather than as typed +//! Code had its conversation cleared fourteen times in five seconds. Claude +//! Code runs `/clear` when it receives `Ctrl+L` twice within two seconds, and +//! the transcript showed the clears arriving as a shortcut rather than as typed //! input — so `0x0c` reached the pane about thirty times, at a machine-like //! cadence, and nobody knows what sent it. nightcrow itself does not: the only -//! bytes it synthesizes are scroll and mouse reports and a plugin's `continue`, -//! and that one is logged where it happens. That leaves a client's own input. -//! -//! So this notes the arrival and its shape, and the client says what produced it -//! (`ClientMessage::ClearKeyReport`, logged in `session.rs`). Between them, the -//! next occurrence names its source instead of being reconstructed afterwards. +//! bytes it synthesizes are scroll and mouse reports and a plugin's +//! `continue`, and that one is logged where it happens. That leaves a client's +//! own input, so this notes the arrival and its shape and the client says what +//! produced it (`ClientMessage::ClearKeyReport`, logged in `session.rs`). //! //! **No input content is logged, ever** — only the byte's count, how much else //! rode with it, and the timing. diff --git a/src/session/terminal/hub_helpers.rs b/src/session/terminal/hub_helpers.rs index 1cc4cfbd..411e0637 100644 --- a/src/session/terminal/hub_helpers.rs +++ b/src/session/terminal/hub_helpers.rs @@ -17,9 +17,8 @@ pub enum Command { command: Option, }, /// Every startup pane in one command, so queueing the set is all-or-nothing. - /// `reserved` is how many cap slots [`Shared::reserved`] is holding for this - /// batch, released as the panes take them. The reservation keeps other - /// clients' creates from taking slots the configured set already claimed. + /// `reserved` holds cap slots [`Shared::reserved`] is keeping for this + /// batch, so other clients' creates cannot take them first. CreateStartup { panes: Vec, client: u64, @@ -38,14 +37,13 @@ pub enum Command { Reorder { order: Vec, }, - /// Abandon a pane's pending relaunch. On the worker queue because carrying it - /// out needs the backend and the plugin bookkeeping, both worker-local. + /// On the worker queue because carrying it out needs the backend and the + /// plugin bookkeeping, both worker-local. CancelRecovery { pane: PaneId, }, - /// Bring this hub's plugin children in line with a re-read `[[plugin]]` - /// table. On the queue because every plugin host is worker-local — a plugin - /// can drive a pane's keyboard, so nothing outside the worker may touch one. + /// On the queue because every plugin host is worker-local — a plugin can + /// drive a pane's keyboard, so nothing outside the worker may touch one. ReloadPlugins { plugins: Vec, }, @@ -76,58 +74,51 @@ pub struct StartupPane { /// A live terminal and what a client that connects has to be given to see it. /// -/// Which record is the pane's screen depends on the mode its program is in, and -/// only one side is written at a time: +/// Replay composition, per mode (only one side is written at a time): /// -/// - **Normal screen** — `scrollback`, the raw bytes the pane has produced, with -/// `normal_screen` + `covered` marking a serialized screen partway through -/// them. The ring alone was the record once, but it is byte-bounded and a -/// program that repaints in place — a prompt box, a spinner, a status line — -/// rotates it without ever scrolling: after a long idle the bytes that painted -/// the top of the screen had been evicted, and a replay rebuilt only the -/// repeatedly-redrawn bottom. So replay is `scrollback[..covered]` (history), -/// then `normal_screen` (the screen as of that point, an absolute repaint), -/// then `scrollback[covered..]` — the front of the ring may be evicted freely -/// and the screen still arrives whole (see +/// - **Normal screen** — `scrollback` plus `normal_screen` + `covered`. The +/// ring alone was not enough: a program that repaints in place rotates it +/// without scrolling, so after a long idle the bytes painting the top of the +/// screen had been evicted and a replay rebuilt only the redrawn bottom. +/// Replay is `scrollback[..covered]`, then `normal_screen` (absolute +/// repaint), then `scrollback[covered..]` — the front of the ring may be +/// evicted freely and the screen still arrives whole (see /// [`replay_pane`](super::hub_replay::replay_pane)). /// - **Alternate screen** — `screen` + `since`. The raw bytes are cell updates -/// against a screen a new client does not have, so what is kept instead is the -/// screen itself, serialized (`hub_modes::PaneModeTracker::snapshot`). While a -/// program is on the alternate screen the normal-screen record is left frozen, -/// holding the screen it will be returned to. +/// against a screen a new client does not have, so the screen itself is +/// kept, serialized. The normal-screen record is left frozen while a +/// program is on the alternate screen. pub(super) struct PaneState { pub(super) id: PaneId, - /// What this pane goes by: the name the session gave a configured startup - /// terminal, and then whatever its program has titled itself since (OSC 0/2, - /// followed in [`hub_modes`](super::hub_modes)). Kept so a client that - /// connects later is told it too — the bytes that set it leave `scrollback` - /// within seconds, so nothing else could tell that client. + /// The name the session gave a configured startup terminal, then whatever + /// its program has titled itself since (OSC 0/2, followed in + /// [`hub_modes`](super::hub_modes)). Kept because the bytes that set it + /// leave `scrollback` within seconds — a later-connecting client could not + /// learn it any other way. pub(super) title: Option, pub(super) scrollback: VecDeque, - /// The pane's normal screen as of `covered` bytes into `scrollback`, - /// serialized the way `screen` is. Empty until the worker first takes one — - /// a ring that has never evicted rebuilds the screen on its own. + /// The pane's normal screen as of `covered` bytes into `scrollback`. + /// Empty until the worker first takes one — a ring that has never evicted + /// rebuilds the screen on its own. pub(super) normal_screen: Vec, - /// How many bytes at the front of `scrollback` `normal_screen` accounts for. - /// Only those may be evicted: they are history whose effect on the screen the - /// snapshot already carries. The bytes past the mark are what a replay - /// applies *on top of* the snapshot, and dropping any of them would hand a - /// connecting client a screen missing an update nothing would ever repair. + /// How many bytes at the front of `scrollback` `normal_screen` accounts + /// for. Only those may be evicted: dropping any byte past the mark would + /// hand a connecting client a screen missing an update nothing repairs. pub(super) covered: usize, - /// This pane's screen as of the last snapshot, empty unless its program is on - /// the alternate screen. + /// This pane's screen as of the last snapshot, empty unless its program is + /// on the alternate screen. pub(super) screen: Vec, - /// Bytes broadcast since `screen` was taken. A snapshot is refreshed once per - /// worker tick, so a client can connect between the broadcast of a chunk and - /// the refresh that accounts for it; replaying `screen` then `since` is what - /// makes the two add up to exactly what every other client has seen. + /// Bytes broadcast since `screen` was taken. A client can connect between + /// the broadcast of a chunk and the snapshot refresh that accounts for it; + /// replaying `screen` then `since` is what makes the two add up to exactly + /// what every other client has seen. /// /// **Never dropped, only superseded.** Terminal bytes cannot be skipped, so /// outgrowing [`limits::MAX_TERMINAL_SCROLLBACK_BYTES`] forces a fresh /// snapshot (which empties this) rather than evicting from the front. pub(super) since: VecDeque, - /// The size the PTY is currently set to, tracked so a connecting client - /// learns it and can skip a resize that would change nothing. + /// The size the PTY is currently set to, so a connecting client can skip a + /// resize that would change nothing. pub(super) rows: u16, pub(super) cols: u16, /// The terminal state the pane's program has established, kept because the @@ -136,16 +127,15 @@ pub(super) struct PaneState { pub(super) modes: PaneModes, } -/// Hub state shared between the worker thread (which mutates panes and -/// broadcasts) and connection threads (which register/unregister clients and -/// snapshot scrollback on connect). Held under one mutex so a connecting -/// client's replay is atomic with the worker's append-and-broadcast. +/// Hub state shared between the worker thread and connection threads. Held +/// under one mutex so a connecting client's replay is atomic with the worker's +/// append-and-broadcast. pub struct Shared { pub(super) clients: Vec, pub(super) panes: Vec, - /// Cap slots held for startup panes that are claimed but not created yet. - /// Counted against the same cap rather than exempt from it, so the ceiling - /// on real processes per repository stays what it says it is. + /// Cap slots held for startup panes that are claimed but not created yet, + /// counted against the same cap rather than exempt from it — otherwise the + /// ceiling on real processes per repository would not hold. pub(super) reserved: usize, /// The pane filling the panel, when one is (see [`hub_zoom`](super::hub_zoom)). /// Beside `panes` and under the same lock because the two have to agree. @@ -160,10 +150,8 @@ pub(super) fn broadcast_locked(clients: &mut Vec, frame: TerminalFrame) clients.retain(|client| match client.tx.try_send(frame.clone()) { Ok(()) => true, Err(TrySendError::Full(_)) => { - // At WARN, not DEBUG: this is the one place a client is disconnected - // against its will, and it answers by rebuilding every pane from the - // replay. A person watches that happen, so the default log level has - // to be able to say why it did. + // WARN, not DEBUG: the default log level must be able to say why a + // client was disconnected against its will. tracing::warn!(id = client.id, "viewer: terminal client too slow, dropping"); client.cut_off(); false @@ -201,15 +189,15 @@ pub(super) fn canonical_order(current: &[PaneId], requested: &[PaneId]) -> Vec

, covered: &mut usize, data: &[u8]) -> usize { buf.extend(data.iter().copied()); if buf.len() > limits::MAX_TERMINAL_SCROLLBACK_BYTES { + // Evict history only, never past the `covered` mark: past the cap only + // a fresh snapshot can bring the ring back under it (terminal bytes + // cannot be skipped). The caller weighs *how far* over (see the + // worker's crowded and desperate thresholds in [`hub_run`]). let excess = buf.len() - limits::MAX_TERMINAL_SCROLLBACK_BYTES; let evicted = excess.min(*covered); buf.drain(0..evicted); diff --git a/src/session/terminal/hub_layout.rs b/src/session/terminal/hub_layout.rs index 94b43069..2feff3d6 100644 --- a/src/session/terminal/hub_layout.rs +++ b/src/session/terminal/hub_layout.rs @@ -17,25 +17,38 @@ impl TerminalHub { client: u64, connection: u64, ) { - // Validate before the ownership lookup, with neither lock held across - // the other: `connect` takes the hub state and then ownership. Besides - // dropping an ordinary close race, this bounds the latest-value map to - // live panes even when a client sends arbitrary ids. - if !self.pane_is_live(pane) || !self.owns_size(connection) { + // Validate without holding the hub state lock: `connect` takes it before + // ownership, while this path takes the resize queue before ownership. + // Besides dropping an ordinary close race, this bounds the latest-value + // map to live panes even when a client sends arbitrary ids. + if !self.pane_is_live(pane) { return; } + let mut pending = self + .pending_resizes + .lock() + .expect("terminal resize queue poisoned"); + // Checked while holding the queue lock so `disconnect` cannot purge and + // then have this request inserted behind it. + if !self.owns_size(connection) { + return; + } + pending.insert( + (connection, pane), + PendingResize { + pane, + rows, + cols, + client, + }, + ); + } + + pub(super) fn discard_pending_resizes(&self, connection: u64) { self.pending_resizes .lock() .expect("terminal resize queue poisoned") - .insert( - (connection, pane), - PendingResize { - pane, - rows, - cols, - client, - }, - ); + .retain(|(queued_connection, _), _| *queued_connection != connection); } pub(super) fn take_pending_resizes(&self) -> Vec { @@ -63,11 +76,11 @@ impl TerminalHub { /// Resize a live pane's PTY at the sizing owner's request, record the size, /// and tell every client what it is. All under one lock, with the liveness /// check — `connect` reports each pane's size from this record and the - /// client caches it as "already applied"; a client that slipped between the - /// two would be told the old size for a PTY that has the new one, and would - /// then skip the resize that would have corrected it. - /// `modes` is resized with the PTY: the grid a connecting client's screen is - /// read from has to wrap where the child now does (see + /// client caches it as "already applied"; a client that slipped between + /// the two would be told the old size for a PTY that has the new one, and + /// would then skip the resize that would have corrected it. `modes` is + /// resized with the PTY: the grid a connecting client's screen is read + /// from has to wrap where the child now does (see /// [`hub_modes`](super::hub_modes)). pub(super) fn resize_pane( &self, @@ -137,15 +150,11 @@ impl TerminalHub { /// Reorder the live panes to match `order` and tell every client the /// result. /// - /// `order` is a full desired sequence of pane ids. It is reconciled - /// against what is actually live so a reorder is robust to races with - /// create/close: unknown ids are dropped and any live pane the request - /// omits (e.g. one another client created in the same beat) is kept, - /// appended in its current order (see [`canonical_order`]). The hub - /// converges on that one canonical order and broadcasts it, so the - /// sender and every other device end up with the same layout. Reordering - /// only restyles the grid — pane ids, scrollback, and the live PTYs are - /// untouched. A no-op reorder sends nothing. + /// `order` is reconciled against what is actually live so a reorder is + /// robust to races with create/close (see [`canonical_order`]). The hub + /// converges on that one canonical order and broadcasts it, so the sender + /// and every other device end up with the same layout. A no-op reorder + /// sends nothing. pub(super) fn reorder_panes(&self, order: Vec) { let mut state = self.state.lock().expect("terminal state poisoned"); let before: Vec = state.panes.iter().map(|p| p.id).collect(); diff --git a/src/session/terminal/hub_modes.rs b/src/session/terminal/hub_modes.rs index 3610920e..7489a8ed 100644 --- a/src/session/terminal/hub_modes.rs +++ b/src/session/terminal/hub_modes.rs @@ -1,41 +1,31 @@ //! What state each pane's program has put its terminal into, and what it calls //! itself. //! -//! A client that attaches to a pane mid-session is replayed a window of the -//! pane's output, and the bytes that set the pane's modes are almost never in it -//! — a program announces them once, at startup, and the ring has long since -//! evicted that. So the hub follows them here and hands a connecting client the -//! answer directly (see `PaneModes::prelude`). -//! -//! A window title has exactly that shape too, which is why it is followed here -//! rather than left to each client. A program sets it once with an OSC 0/2 that -//! is out of the ring within seconds, so a page that connected later, or -//! reconnected after a stall, had no way to learn it and fell back to a -//! positional label — the pane running an agent read `term 1` for the rest of -//! the session. +//! A client attaching mid-session is replayed a window of output that almost +//! never contains the bytes that set the pane's modes — a program announces +//! them once, at startup, and the ring has long since evicted that. The hub +//! follows them here and hands a connecting client the answer directly. A +//! window title has exactly that shape too, so it is followed here rather than +//! left to each client: a page that connected later had no way to learn it and +//! fell back to a positional label. //! //! Kept on the worker thread rather than in [`Shared`](super::Shared): a //! `PaneEmulator` holds `Rc`, so it is not `Send` and cannot live behind the -//! state mutex. What crosses the lock is what the worker writes into `PaneState` -//! after each chunk — the flag set, and whenever a pane's record asks for one -//! the serialized screen (see [`PaneModeTracker::snapshot`]). +//! state mutex. //! -//! **The grid is read, so resizes have to be followed.** These emulators used to -//! exist only to answer "which modes is this pane in", and their grids were -//! scratch space for the parser; now `snapshot` reads the cells, so a grid at the -//! wrong width would parse this pane's output wrapping where the child does not -//! and hand a connecting client a screen laid out differently from every other -//! client's. `resize` is what keeps it in step, and `hub_layout::resize_pane` is -//! the one place that has to call it. +//! **The grid is read, so resizes have to be followed.** These emulators used +//! to be scratch space for a mode parser; now `snapshot` reads the cells, so a +//! grid at the wrong width would wrap output where the child does not and hand +//! a connecting client a screen laid out differently from every other client's. +//! `hub_layout::resize_pane` is the one place that has to call `resize`. use crate::backend::PaneId; use crate::runtime::emulator::{PaneEmulator, PaneModes}; use std::collections::HashMap; use std::time::Instant; -/// Scrollback for the tracking emulators: none. A snapshot is the screen, not the -/// history behind it — the byte ring in `PaneState` is what carries history, and -/// paying for it twice per pane would buy nothing. +/// Scrollback for the tracking emulators: none. The byte ring in `PaneState` +/// carries history; paying for it twice per pane would buy nothing. const NO_HISTORY: usize = 0; /// What one chunk of a pane's output said about it. @@ -147,10 +137,10 @@ impl PaneModeTracker { } /// Whether this pane's output so far ends with every sequence closed — the - /// gate on anchoring a snapshot into its records. A chunk can end - /// mid-sequence, and a snapshot spliced in there would hand a reattaching - /// client the sequence's tail as ordinary input; the caller defers to the - /// next chunk that ends clean instead (see + /// gate on anchoring a snapshot into its records. A snapshot spliced in + /// mid-sequence would hand a reattaching client the sequence's tail as + /// ordinary input; the caller defers to the next chunk that ends clean + /// instead (see /// [`PaneEmulator::at_boundary`](crate::runtime::emulator::PaneEmulator::at_boundary)). /// A pane with no output yet is trivially at one. pub(super) fn at_boundary(&self, pane: PaneId) -> bool { diff --git a/src/session/terminal/hub_panes.rs b/src/session/terminal/hub_panes.rs index b37a6145..436bc59a 100644 --- a/src/session/terminal/hub_panes.rs +++ b/src/session/terminal/hub_panes.rs @@ -4,8 +4,7 @@ //! Every one of these pairs a change to `Shared` with the broadcast that //! announces it, under a single lock — that pairing is what keeps a client //! connecting mid-change from seeing a pane twice or not at all (see -//! [`Shared`](super::hub_helpers::Shared)). Split out of `hub_run.rs` so that -//! file is the worker loop and nothing else; the behaviour is unchanged. +//! [`Shared`](super::hub_helpers::Shared)). use super::TerminalHub; use super::frame::{ServerMessage, TerminalFrame}; @@ -39,9 +38,8 @@ impl TerminalHub { /// a client either sees this pane via `connect` or via this broadcast, never /// both and never neither. /// `client` is whoever asked for the pane, carried so that client alone can - /// treat it as the one it opened. `None` for a pane nobody asked for. - /// `title` is the name the session gives it, which only a configured startup - /// terminal has. + /// treat it as the one it opened. `title` is the name the session gives it, + /// which only a configured startup terminal has. pub(super) fn register_pane( &self, pane: PaneId, @@ -60,13 +58,10 @@ impl TerminalHub { .ok(); let mut state = self.state.lock().expect("terminal state poisoned"); // A pane nobody can see is not a terminal, so whatever was filling the - // panel gives way to the one about to open. - // - // Ahead of the announcement rather than after it, though both go out - // under this one lock. They are two frames, and a client renders between - // them: told about the pane while still zoomed past it, it spends that - // render with the new terminal hidden — and moves the keyboard onto the - // pane filling the panel instead of the one it just asked for. + // panel gives way to the one about to open. Ahead of the announcement: + // they are two frames and a client renders between them — told about + // the pane while still zoomed past it, it spends that render with the + // new terminal hidden and its keyboard on the wrong pane. clear_zoom_locked(&mut state); state.panes.push(PaneState { id: pane, @@ -86,23 +81,22 @@ impl TerminalHub { } /// Record output against the pane and broadcast it — under one lock, so a - /// concurrently connecting client cannot slip a replay between the record and - /// the broadcast and end up with the pane's screen missing this chunk or - /// carrying it twice. + /// concurrently connecting client cannot slip a replay between the record + /// and the broadcast and end up with the chunk missing or doubled. /// - /// Where the output is recorded depends on the mode the chunk leaves the pane - /// in (see [`PaneState`](super::hub_helpers::PaneState)). `screen` is the - /// serialized screen when the caller has one to hand over, which it takes - /// before locking — the emulator it comes from is not `Send`. + /// Where the output is recorded depends on the mode the chunk leaves the + /// pane in (see [`PaneState`](super::hub_helpers::PaneState)). `screen` is + /// the serialized screen when the caller has one to hand over, which it + /// takes before locking — the emulator it comes from is not `Send`. /// /// Returns how many recorded bytes a fresh snapshot would supersede — the - /// uncovered tail on the normal screen (see [`push_scrollback`]), `since` on - /// the alternate one. The worker reads the pane's appetite for a snapshot - /// off this count (crowded past the cap, desperate well past it). + /// uncovered tail on the normal screen (see [`push_scrollback`]), `since` + /// on the alternate one. The worker reads the pane's appetite for a + /// snapshot off this count (crowded past the cap, desperate well past it). /// - /// The clients already attached are not told the new title: they are being - /// handed the very bytes that set it, and each runs the emulator that reads - /// them. What this record is for is the client that is not here yet. + /// Attached clients are not told a new title: they are being handed the + /// very bytes that set it, and each runs the emulator that reads them. + /// This record is for the client that is not here yet. pub(super) fn record_and_broadcast( &self, pane: PaneId, diff --git a/src/session/terminal/hub_plugins.rs b/src/session/terminal/hub_plugins.rs index e6c21e33..ddbc484a 100644 --- a/src/session/terminal/hub_plugins.rs +++ b/src/session/terminal/hub_plugins.rs @@ -25,9 +25,9 @@ use std::time::Duration; pub(super) const PANE_IDLE_THRESHOLD: Duration = Duration::from_secs(10); /// Commands taken from any one plugin per loop iteration. One thread serves -/// every pane in the repository, so a plugin that writes without pause must not -/// be able to hold it. Eight per 8 ms tick is a thousand a second — far past -/// anything a legitimate plugin needs, and bounded. +/// every pane in the repository, so a plugin that writes without pause must +/// not be able to hold it. Eight per 8 ms tick is a thousand a second — far +/// past anything a legitimate plugin needs, and bounded. pub(super) const MAX_COMMANDS_PER_TICK: usize = 8; pub(super) struct Plugins { @@ -66,13 +66,12 @@ impl Plugins { /// could be given. /// /// Both conditions, because a host with no pane to watch is a child process - /// that can never be given anything to do. `watch_on_signal` is the second - /// way to satisfy the first: such a plugin's panes are the ones that will - /// speak to it, so it has to be running *before* any of them does — waiting - /// for an opt-in that will never come would make the switch mean nothing. + /// that can never be given anything to do. `watch_on_signal` satisfies the + /// second: such a plugin has to be running *before* any of its panes speak, + /// or waiting for an opt-in that never comes makes the switch mean nothing. /// A plugin that will not launch is logged and left out: its panes then - /// behave exactly like unwatched ones, so a broken plugin costs the operator - /// a warning rather than a terminal. + /// behave exactly like unwatched ones, so a broken plugin costs the + /// operator a warning rather than a terminal. pub(super) fn start(cwd: &str, configs: &[PluginConfig], startup: &[StartupCommand]) -> Self { let dir = crate::plugin::registry::default_plugins_dir() .inspect_err(|error| { @@ -128,8 +127,7 @@ impl Plugins { /// Hand `pane` to `plugin`, reporting whether it took. /// /// Refused when that plugin has no host: recording an association nothing - /// can act on would put the pane on the relaunch path — its slot kept alive - /// after an exit for a plugin that will never ask — for no benefit. + /// can act on would put the pane on the relaunch path for no benefit. pub(super) fn adopt(&mut self, pane: PaneId, plugin: &str) -> bool { // Recorded either way: what the pane asked for is a fact about the pane, // and a reload that later enables this plugin has no other way to learn diff --git a/src/session/terminal/hub_plugins_slots.rs b/src/session/terminal/hub_plugins_slots.rs index e44dcd5b..f6945abb 100644 --- a/src/session/terminal/hub_plugins_slots.rs +++ b/src/session/terminal/hub_plugins_slots.rs @@ -12,19 +12,18 @@ use std::time::{Duration, Instant}; /// How long an exited pane's slot is kept so a relaunch can still reuse its /// token. /// -/// This is a backstop against a plugin that died or lost interest, so it has to -/// outlast every wait a plugin may legitimately be in the middle of. Providers -/// quote windows in hours *and* in days — a weekly quota is a real case — so a -/// value picked around the five-hour window would silently throw the pane's -/// identity away days before the wait paid off, and the relaunch it was being -/// kept for would fail. Nine days clears the longest window a bundled plugin -/// will wait out (`nightcrow-recovery`'s own clamp is eight days) with slack for -/// a reset that lands late. +/// A backstop against a plugin that died or lost interest, so it has to +/// outlast every wait a plugin may legitimately be in the middle of. +/// Providers quote windows in hours *and* in days — a weekly quota is a real +/// case — so a value picked around the five-hour window would silently throw +/// the pane's identity away days before the wait paid off. Nine days clears +/// the longest window a bundled plugin will wait out +/// (`nightcrow-recovery`'s own clamp is eight days) with slack. /// -/// Holding it that long is cheap on purpose: a token, a generation and a command -/// string. The process, its fds and its threads were let go the moment it exited -/// (see [`PtyBackend::release_process`]), and closing the pane or stopping the -/// session retires the slot immediately either way. +/// Holding it that long is cheap on purpose: a token, a generation and a +/// command string. The process, its fds and its threads were let go the +/// moment it exited (see [`PtyBackend::release_process`]), and closing the +/// pane or stopping the session retires the slot immediately either way. pub(super) const PENDING_RELAUNCH_TTL: Duration = Duration::from_secs(9 * 24 * 60 * 60); /// Where a pane sat and what it looked like, captured before it is removed. diff --git a/src/session/terminal/hub_relaunch.rs b/src/session/terminal/hub_relaunch.rs index c7b0082d..d9fae9b5 100644 --- a/src/session/terminal/hub_relaunch.rs +++ b/src/session/terminal/hub_relaunch.rs @@ -247,13 +247,10 @@ fn log_plugin_line(plugin: &str, level: LogLevel, message: &str) { /// Log a refusal at the level that says whether anyone should look into it. /// /// A plugin decides asynchronously, so being late is ordinary traffic rather -/// than a fault: the pane moved on, is not quiet yet, or was claimed by another -/// plugin first. The rest mean the plugin asked for something it was never -/// allowed — a pane that is not its, an oversized or control-laden payload, a -/// flag the config does not list, a bare shell it wanted to relaunch, or more -/// attempts than the budget allows — and that is worth an operator's attention. -/// Matched exhaustively on purpose, so a new refusal has to be classified rather -/// than defaulting to silence. +/// than a fault. The rest mean the plugin asked for something it was never +/// allowed — that is worth an operator's attention. Matched exhaustively on +/// purpose, so a new refusal has to be classified rather than defaulting to +/// silence. fn log_refusal(plugin: &str, refused: &Refused) { let ordinary = match refused { Refused::UnknownPane { .. } diff --git a/src/session/terminal/hub_reload_hosts.rs b/src/session/terminal/hub_reload_hosts.rs index 0e9f5be9..c331aa33 100644 --- a/src/session/terminal/hub_reload_hosts.rs +++ b/src/session/terminal/hub_reload_hosts.rs @@ -27,16 +27,11 @@ impl Plugins { self.set_watch_on_signal(&cfg.name, cfg.watch_on_signal); self.launched.insert(cfg.name.clone(), cfg.clone()); self.hosts.insert(cfg.name.clone(), host); - // Every pane that opted into this plugin is handed to it, whether - // it was already owned — the child that knew about it is gone — or - // was never adopted because there was no host when it opened. The - // second case is what makes enabling a plugin mid-session useful: - // a pane created while it was off is still the pane its own - // configuration named. - // - // Only panes the hub still has. `titles` is that set, so a pane - // that has since exited is skipped rather than announced to a - // plugin that could do nothing about it. + // Every pane that opted into this plugin is handed to it, + // whether it was already owned — the child that knew about it is + // gone — or was never adopted because there was no host when it + // opened. The second case is what makes enabling a plugin + // mid-session useful. Only panes the hub still has (`titles`). let opted_in: Vec = self .intended .iter() @@ -93,12 +88,9 @@ impl Plugins { self.launched.remove(name); // Every hold this plugin had goes, replacement or not. A hold is a pane // whose process already exited, kept alive only so *that* plugin could - // relaunch it — and the successor is never told about it. It is handed - // back the panes the hub still has (see `start_host`), and an exited one - // is not among them, so its token dies with the child that was given it. - // Left in place the slot would sit out its whole window with nothing that - // could honour it, while every client counted down to a relaunch that was - // never coming. + // relaunch it — the successor is never told about it and cannot honour + // it. Left in place the slot would sit out its whole window while + // every client counted down to a relaunch that was never coming. self.retire_holds_of(backend, name, outcome); if replaced { // The live panes stay this plugin's, and are handed to the successor diff --git a/src/session/terminal/hub_replay.rs b/src/session/terminal/hub_replay.rs index b3eaa50d..16f69418 100644 --- a/src/session/terminal/hub_replay.rs +++ b/src/session/terminal/hub_replay.rs @@ -15,22 +15,20 @@ const LEAVE_ALT_SCREEN: &[u8] = b"\x1b[?1049l"; /// Largest payload one replay frame carries. /// /// **Frame boundaries mean nothing to a client.** It concatenates what arrives -/// into its emulator, whose parser is a state machine that spans writes, so a -/// sequence or a multi-byte character split across two frames is reassembled the -/// same as if it had come in one. Splitting is therefore free. +/// into its emulator, whose parser spans writes, so a sequence split across +/// two frames is reassembled the same as if it had come in one — splitting is +/// free. /// /// A single frame, on the other hand, does have a ceiling: the daemon socket /// refuses a payload over [`MAX_FRAME_BYTES`](crate::daemon::frame::MAX_FRAME_BYTES) -/// (4 MiB), and unlike the byte ring an alternate-screen pane's screen grows with -/// its area — a large pane covered in per-cell colour, which a truecolour image -/// renderer produces, reaches several megabytes. Sent whole it ended the attach -/// connection, and again on every reconnect, because the same screen was replayed -/// each time. Nobody transmits a screen as one indivisible message: VS Code's -/// replay is a list of entries, tmux writes to a passed file descriptor, and -/// mosh's datagrams cannot hold a screen at all. +/// (4 MiB), and an alternate-screen pane's screen grows with its area — a large +/// truecolour pane reaches several megabytes. Sent whole it ended the attach +/// connection, and again on every reconnect. Nobody transmits a screen as one +/// indivisible message: VS Code's replay is a list of entries, tmux writes to a +/// passed file descriptor, and mosh's datagrams cannot hold a screen at all. /// -/// 1 MiB stays well under that ceiling while keeping the frame count low enough -/// that a whole replay of the largest panes this hub allows fits in +/// 1 MiB stays well under that ceiling while keeping the frame count low +/// enough that a whole replay of the largest panes this hub allows fits in /// [`CLIENT_QUEUE_DEPTH`](super::CLIENT_QUEUE_DEPTH) — which is what makes it /// safe to queue the replay before the client is registered, with nothing else /// writing to that queue. @@ -104,16 +102,17 @@ pub(super) fn replay_pane(tx: &SyncSender, pane: &PaneState) -> b // Ahead of the screen: these are the modes the pane's program set once, at // startup, and no record of them survives in what follows. Without this a // reattaching client is a terminal the program never configured — mouse - // reporting off, arrows in the wrong encoding, paste unbracketed. It leads - // with `1049`, so it is also what puts the client on the buffer the program is - // drawing on before that buffer's contents arrive. + // reporting off, arrows in the wrong encoding, paste unbracketed. It + // leads with `1049`, so it is also what puts the client on the buffer the + // program is drawing on before that buffer's contents arrive. whole &= send_replay(tx, pane.id, &pane.modes.prelude()); let data: Vec = if pane.modes.alt_screen { // The screen, then everything broadcast since it was taken — the same - // bytes every client already attached has seen. (When an entry snapshot - // was deferred, `since` opens with the switch chunk itself, whose - // pre-switch text this client plays on the wrong buffer until the next - // paint covers it — the price of never splicing into an open sequence.) + // bytes every client already attached has seen. (When an entry + // snapshot was deferred, `since` opens with the switch chunk itself, + // whose pre-switch text plays on the wrong buffer until the next + // paint covers it — the price of never splicing into an open + // sequence.) let mut data = Vec::with_capacity(pane.screen.len() + pane.since.len()); data.extend_from_slice(&pane.screen); data.extend(pane.since.iter().copied()); diff --git a/src/session/terminal/hub_run.rs b/src/session/terminal/hub_run.rs index 98da49bd..be86e527 100644 --- a/src/session/terminal/hub_run.rs +++ b/src/session/terminal/hub_run.rs @@ -13,6 +13,7 @@ use std::thread; use std::time::{Duration, Instant}; const POLL_INTERVAL: Duration = Duration::from_millis(8); +const COMMANDS_BETWEEN_RESIZES: usize = 64; impl TerminalHub { pub(super) fn run(&self, cwd: &str, commands: Receiver, stop: Arc) { @@ -26,7 +27,15 @@ impl TerminalHub { let mut clears = ClearWatch::default(); while !stop.load(Ordering::Acquire) { + let mut commands_since_resize = 0; while let Ok(command) = commands.try_recv() { + if commands_since_resize == COMMANDS_BETWEEN_RESIZES { + for resize in self.take_pending_resizes() { + self.resize_pane(&mut backend, &mut modes, resize); + } + commands_since_resize = 0; + } + commands_since_resize += 1; match command { Command::Create { rows, @@ -96,17 +105,16 @@ impl TerminalHub { } } - // Resize is latest-value state, not a byte stream. Process the - // newest size after queued structural commands so a close that - // raced a drag wins, while a saturated input queue cannot discard - // the final geometry. + // Resize is latest-value state, not a byte stream. Also processed + // above after each command budget so a producer that continuously + // refills the bounded queue cannot starve the final geometry. for resize in self.take_pending_resizes() { self.resize_pane(&mut backend, &mut modes, resize); } - // Alternate-screen panes whose screen this tick's output has moved on. - // Snapshotted once at the end rather than per chunk: a busy program - // sends many small chunks and serializing a grid for each of them + // Alternate-screen panes whose screen this tick's output has moved + // on. Snapshotted once at the end rather than per chunk: a busy + // program sends many small chunks, and serializing a grid per chunk // would be the most expensive thing on this path. let mut restless: Vec = Vec::new(); for event in backend.drain_events() { @@ -127,29 +135,25 @@ impl TerminalHub { // Cut mid-sequence it is filed into `since` instead, and // the tick's restless pass takes the screen once the // stream closes; until then a connecting client replays - // `since` raw, whose pre-switch text lands on the wrong - // buffer for that moment — the paint that follows a - // switch covers it, and the retry replaces it. + // `since` raw, landing pre-switch text on the wrong + // buffer for that moment — the paint that follows covers + // it, and the retry replaces it. let screen = (alt && observed.alt_changed && modes.at_boundary(pane)) .then(|| modes.snapshot(pane)) .flatten(); let owed = self.record_and_broadcast(pane, data, observed, screen); - // Every snapshot below normally waits for a chunk that - // ends with its sequences closed and applied - // (`at_boundary`): the snapshot is spliced into the - // recorded stream on replay, and a seam inside a - // sequence hands a reattaching client its tail as - // ordinary input. A crowded record reports itself again - // with every next chunk, so a deferred snapshot retries - // until the stream is clean — and a desperate one has - // waited a whole extra ring for that, which no real - // sequence spans, so the stream is called broken and the - // records are bounded over a torn seam. Desperation - // overrides the sequence seam only: a grid missing a - // synchronized update's bytes (`screen_current`) must - // never be snapshotted, and needs no override — the - // update ends at the processor's own buffer cap if - // nothing else. + // Snapshots wait for a chunk that ends with its + // sequences closed (`at_boundary`): the snapshot is + // spliced into the recorded stream on replay, and a seam + // inside a sequence hands a reattaching client its tail + // as ordinary input. A crowded record retries with every + // next chunk; a desperate one has waited a whole extra + // ring, which no real sequence spans, so the records are + // bounded over a torn seam. Desperation overrides the + // sequence seam only — a grid missing a synchronized + // update's bytes (`screen_current`) must never be + // snapshotted, and needs no override: the update ends at + // the processor's own buffer cap if nothing else. let crowded = owed > limits::MAX_TERMINAL_SCROLLBACK_BYTES; let desperate = owed > 2 * limits::MAX_TERMINAL_SCROLLBACK_BYTES; let ready = @@ -166,23 +170,20 @@ impl TerminalHub { restless.push(pane); } } else if crowded && ready { - // The ring's uncovered tail has outgrown the cap, and - // it may not be evicted — a fresh snapshot moving the - // mark is the only way back under. Not per tick like - // the alternate screen's: between snapshots the tail - // keeps the replay exact on its own, so this costs a - // serialization once per ring's worth of output. + // Not per tick like the alternate screen's: between + // snapshots the tail keeps the replay exact on its + // own, so this costs a serialization once per + // ring's worth of output. if let Some(screen) = modes.snapshot(pane) { self.store_normal_screen(pane, screen); } } } - // Destroyed as well as forgotten. `PtyBackend` leaves pane - // removal to its caller (see its `drain_events`), so a pane - // that ended on its own — the user typed `exit`, or the - // command finished — would keep its entry, its PTY master, - // and its child handle for the hub's whole life. The cap - // counts live panes, not those, so open-and-exit in a loop + // Destroyed as well as forgotten: `PtyBackend` leaves pane + // removal to its caller, so a pane that ended on its own + // would otherwise keep its entry, its PTY master, and its + // child handle for the hub's whole life. The cap counts + // live panes, not those, so open-and-exit in a loop // accumulated descriptors with nothing to stop it. BackendEvent::Exited { pane } => { modes.forget(pane); @@ -205,11 +206,11 @@ impl TerminalHub { } } - // A program killed inside a synchronized update never closes it, and - // the pane it leaves behind never produces enough to close it at the - // processor's buffer cap either. Ended here on the clock, so a grid - // no byte will ever release stops holding the pane's modes and every - // snapshot taken from it. + // A program killed inside a synchronized update never closes it, + // and the pane it leaves behind never produces enough to close it + // at the processor's buffer cap either. Ended on the clock, so a + // grid no byte will ever release stops holding the pane's modes + // and every snapshot taken from it. for (pane, observed) in modes.settle_sync(Instant::now()) { let alt = observed.modes.alt_screen; self.store_settled(pane, observed); @@ -251,8 +252,8 @@ impl TerminalHub { } // Ahead of the panes: a plugin child is not one of `PtyBackend`'s panes, - // so this is the only place it is ever reaped, and telling it to stop - // before its panes disappear beneath it is the courteous order. + // so this is the only place it is ever reaped, and it must be told to + // stop before its panes disappear beneath it. plugins.shutdown(); let ids: Vec = self @@ -268,15 +269,14 @@ impl TerminalHub { } // Drop the pane records too: the hub struct can outlive its worker // behind an `Arc`, and a late `connect` must not replay these now-dead - // terminals. The zoom goes with them — it names one of these panes, and - // nothing may be left holding a name for a pane that is gone. + // terminals. The zoom goes with them — it names one of these panes. // - // Announced rather than dropped in silence, because `connect`'s guard - // against replaying them cannot be airtight: a connection that took the - // state lock first read `stop` before it was set, and by the time this - // runs it has already been handed every pane. Telling it here is what - // closes that window from the other side — the guard keeps the common - // case cheap, and this makes the outcome correct either way. + // Announced rather than dropped in silence: `connect`'s guard against + // replaying them cannot be airtight — a connection that took the state + // lock first read `stop` before it was set, and has already been handed + // every pane. This closes that window from the other side; the guard + // keeps the common case cheap and this makes the outcome correct + // either way. let mut state = self.state.lock().expect("terminal state poisoned"); let gone: Vec = state.panes.iter().map(|p| p.id).collect(); state.panes.clear(); diff --git a/src/session/terminal/hub_zoom.rs b/src/session/terminal/hub_zoom.rs index d83e95cc..3f3b19ff 100644 --- a/src/session/terminal/hub_zoom.rs +++ b/src/session/terminal/hub_zoom.rs @@ -1,28 +1,25 @@ //! Which pane fills the terminal panel. //! -//! **The repository's answer, not each page's.** The same reasoning as the pane -//! order (`hub_layout.rs`): every page attached to a repository shows the same -//! terminals, so "which one is filling the panel" is one question. Keeping it -//! per page instead is what the browser used to do, and it cost the state on -//! every reload — a zoom lived in one `useState` and nothing outside that page -//! had ever heard of it. +//! **The repository's answer, not each page's.** Every page attached to a +//! repository shows the same terminals, so "which one fills the panel" is one +//! question. Keeping it per page (what the browser used to do) lost the state +//! on every reload. //! -//! **An attached TUI is told and ignores it** (`backend/hub.rs`). It has a zoom -//! of its own that answers a different question: it follows the TUI's active -//! pane and takes the body from the diff viewer with it. The panes are shared -//! between the two; what fills a screen is that screen's. +//! **An attached TUI is told and ignores it** (`backend/hub.rs`). It has a +//! zoom of its own that answers a different question: it follows the TUI's +//! active pane and takes the body from the diff viewer with it. The panes are +//! shared between the two; what fills a screen is that screen's. //! //! **In the hub, and not on disk.** A zoom names a pane, and a pane is a child -//! process of this daemon: restarting it destroys the panes, so there is nothing -//! left for a stored zoom to point at. The panel-level maximize (files vs -//! terminal, `prefs/maximized.rs`) *is* stored, and the difference is exactly -//! this — what it names outlives the process. So a zoom survives a page reload -//! and a TUI restart, which is every case there is a pane to come back to. +//! process of this daemon: restarting it destroys the panes, so there is +//! nothing left for a stored zoom to point at. The panel-level maximize +//! (`prefs/maximized.rs`) *is* stored, and the difference is exactly this. //! -//! **A pane appearing or leaving ends it**, which is why the two functions here -//! are called from under the same lock that changes the pane list. A zoom that -//! outlived its pane would leave every client rendering an empty panel, and one -//! that survived a `create` would hide the terminal somebody just asked for. +//! **A pane appearing or leaving ends it**, which is why the two functions +//! here are called from under the same lock that changes the pane list. A +//! zoom that outlived its pane would leave every client rendering an empty +//! panel, and one that survived a `create` would hide the terminal somebody +//! just asked for. use super::TerminalHub; use super::frame::{ServerMessage, TerminalFrame}; diff --git a/src/session/terminal/startup.rs b/src/session/terminal/startup.rs index 7059ab5a..8d70e49e 100644 --- a/src/session/terminal/startup.rs +++ b/src/session/terminal/startup.rs @@ -55,10 +55,9 @@ impl TerminalHub { }) .collect(); - // Hold the free cap slots before the command is even queued. Another - // connection's handler thread can enqueue creates between here and the - // worker reaching this batch; the reservation stops them taking slots - // this set claimed. + // Hold the free cap slots before the command is even queued: another + // connection's handler thread can enqueue creates between here and + // the worker reaching this batch. let reserved = { let mut state = self.state.lock().expect("terminal state poisoned"); let free = limits::MAX_PTYS_PER_REPO.saturating_sub(state.panes.len() + state.reserved); diff --git a/src/session/terminal/startup_run.rs b/src/session/terminal/startup_run.rs index fd48fb2d..1393ffb2 100644 --- a/src/session/terminal/startup_run.rs +++ b/src/session/terminal/startup_run.rs @@ -36,23 +36,19 @@ impl TerminalHub { let mut held = reserved; let mut remaining = panes.into_iter().peekable(); while let Some(pane) = remaining.next() { - // Spend this pane's own reservation first, so the - // check below sees the slot it is about to take as - // free rather than as still held for itself. + // Spend this pane's own reservation first, so the cap check below + // sees the slot it is about to take as free rather than as still + // held for itself. The reservation decides who gets a slot, not + // how many exist — a set larger than what was free at claim time + // comes up short here rather than overrunning the ceiling. if held > 0 { self.release_reserved(1); held -= 1; } - // The cap still binds. The reservation decides who - // gets a slot, not how many exist — a set larger - // than what was free at claim time comes up short - // here rather than overrunning the ceiling. if !self.has_free_slot() { - // Name what did not start. The set is spent - // once claimed, so these will not run until - // the hub restarts — the user has to open them - // by hand, and cannot do that without knowing - // which ones they were. + // Name what did not start. The set is spent once claimed, so + // these will not run until the hub restarts, and the user + // cannot open them by hand without knowing which they were. let mut lost = vec![startup_label(&pane)]; lost.extend(remaining.map(|p| startup_label(&p))); self.send_error_to( @@ -62,11 +58,9 @@ impl TerminalHub { break; } match backend.open_pane(pane.size.rows, pane.size.cols, pane.command.as_deref()) { - // Registered as nobody's: the configured - // terminals belong to the session, not to - // whichever client happened to measure them - // first, so they must not pull that client's - // focus onto them. + // Registered as nobody's: the configured terminals belong to + // the session, not to whichever client happened to measure + // them first, so they must not pull that client's focus. Ok(id) => { self.register_pane( id, @@ -76,10 +70,10 @@ impl TerminalHub { pane.title.clone(), ); // Only here, and only from the pane's own configuration: - // this is the single place a pane ever becomes visible to a - // plugin. `adopt` refuses when the named plugin has no live - // host, so a pane whose plugin failed to launch stays an - // ordinary terminal. + // this is the single place a pane ever becomes visible to + // a plugin. `adopt` refuses when the named plugin has no + // live host, so a pane whose plugin failed to launch stays + // an ordinary terminal. if let Some(name) = pane.plugin.as_deref() && plugins.adopt(id, name) { diff --git a/src/session/terminal/tests/backpressure.rs b/src/session/terminal/tests/backpressure.rs index db626747..d0dcd181 100644 --- a/src/session/terminal/tests/backpressure.rs +++ b/src/session/terminal/tests/backpressure.rs @@ -3,7 +3,10 @@ use super::{attach, attach_over_socket, created_pane, next_matching, spawn_hub}; use crate::session::terminal::CLIENT_QUEUE_DEPTH; use crate::session::terminal::frame::ClientMessage; +use crate::session::terminal::hub_helpers::Command; use std::io::Read; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; #[test] fn a_client_that_stops_draining_has_its_connection_ended() { @@ -125,6 +128,68 @@ fn the_final_resize_survives_a_full_command_queue() { assert_eq!((pending[0].rows, pending[0].cols), (30, 120)); } +#[test] +fn resize_progresses_while_command_producers_stay_busy() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = spawn_hub(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); + let session = Arc::new(attach(&hub)); + session.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); + let pane = next_matching(&session, |frame| created_pane(frame).is_some()) + .and_then(|frame| created_pane(&frame)) + .expect("no created message"); + + let running = Arc::new(AtomicBool::new(true)); + let accepted = Arc::new(AtomicUsize::new(0)); + let producers: Vec<_> = (0..4) + .map(|_| { + let commands = hub.commands.clone(); + let running = Arc::clone(&running); + let accepted = Arc::clone(&accepted); + std::thread::spawn(move || { + while running.load(Ordering::Acquire) { + if commands + .try_send(Command::Reorder { order: vec![pane] }) + .is_ok() + { + accepted.fetch_add(1, Ordering::Relaxed); + } + } + }) + }) + .collect(); + let busy = super::wait_for(|| { + (accepted.load(Ordering::Relaxed) >= CLIENT_QUEUE_DEPTH * 4).then_some(()) + }) + .is_some(); + + session.dispatch(ClientMessage::Resize { + pane, + rows: 30, + cols: 120, + }); + let resized = super::wait_for(|| { + session + .next_frame(std::time::Duration::from_millis(20)) + .and_then(|frame| super::resized_size(&frame)) + .filter(|size| *size == (30, 120)) + }) + .is_some(); + + running.store(false, Ordering::Release); + for producer in producers { + producer.join().expect("command producer panicked"); + } + hub.stop(); + assert!( + busy, + "the producer must exercise a sustained command stream" + ); + assert!( + resized, + "continuous commands must not starve a pending resize" + ); +} + #[test] fn unknown_panes_do_not_grow_the_resize_queue() { let dir = tempfile::TempDir::new().unwrap(); @@ -142,3 +207,26 @@ fn unknown_panes_do_not_grow_the_resize_queue() { assert!(hub.take_pending_resizes().is_empty()); } + +#[test] +fn disconnected_connections_leave_no_pending_resizes() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = spawn_hub(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); + hub.stop(); + hub.register_pane(7, 24, 80, None, None); + + for cols in 81..=1_080 { + let session = attach(&hub); + session.dispatch(ClientMessage::Resize { + pane: 7, + rows: 30, + cols, + }); + drop(session); + } + + assert!( + hub.take_pending_resizes().is_empty(), + "reconnect churn must not retain entries for dead connections" + ); +} diff --git a/src/ui/diff_viewer/gutter.rs b/src/ui/diff_viewer/gutter.rs index d8527838..4a3962f4 100644 --- a/src/ui/diff_viewer/gutter.rs +++ b/src/ui/diff_viewer/gutter.rs @@ -6,8 +6,8 @@ use ratatui::{ widgets::{Paragraph, Wrap}, }; -/// Minimum digits reserved for one line-number column. Keeps the gutter from -/// twitching between a 1-digit and a 2-digit file. +/// Minimum digits reserved for one line-number column, so the gutter does not +/// twitch between a 1-digit and a 2-digit file. const MIN_LINENO_DIGITS: usize = 3; /// One padding space on each side of a number column: it lifts the digits off @@ -17,7 +17,7 @@ const LINENO_PAD: usize = 2; /// Single space separating the old and new columns of the unified gutter. const LINENO_GAP: usize = 1; -/// Digits needed to print `max_lineno`, floored at `MIN_LINENO_DIGITS`. +/// Digits needed to print `max_lineno`, floored at the minimum. pub(crate) fn digits_for(max_lineno: usize) -> usize { let digits = if max_lineno == 0 { 1 @@ -30,9 +30,8 @@ pub(crate) fn digits_for(max_lineno: usize) -> usize { /// Gutter digit count for a whole loaded diff: the widest line number that /// appears on either side of any hunk. Derived from the loaded hunks, never /// from the visible window, so scrolling cannot change the gutter width. -/// -/// Recomputed per frame instead of cached: it is one allocation-free pass over -/// the same lines `ensure_highlight_cache` already walks for its fingerprint. +/// Recomputed per frame instead of cached: it is one allocation-free pass +/// over the same lines `ensure_highlight_cache` already walks. pub(crate) fn lineno_digits(hunks: &[DiffHunk]) -> usize { let max = hunks .iter() @@ -75,17 +74,13 @@ fn lineno_text(no: Option) -> String { } /// Render a pinned gutter column and a horizontally scrollable body inside -/// `inner` (a `Block`'s inner area — draw the block yourself first). -/// -/// The two are separate `Paragraph`s: `Paragraph::scroll` shifts the whole -/// line, so a gutter span living in the body's paragraph would slide off the -/// left edge. Vertical scroll is instead expressed by *which* lines the caller -/// collected. -/// +/// `inner` (a `Block`'s inner area — draw the block yourself first). They are +/// separate `Paragraph`s because `Paragraph::scroll` shifts the whole line, so +/// a gutter span in the body's paragraph would slide off the left edge; +/// vertical scroll is instead expressed by *which* lines the caller collected. /// With `wrap` set that split is abandoned: a wrapped body line occupies -/// several screen rows while its gutter line still occupies one, which would -/// desynchronise every row below it. The number is folded into the body line -/// instead, where wrapping carries it along. +/// several screen rows while its gutter line still occupies one, so the number +/// is folded into the body line instead, where wrapping carries it along. pub(crate) fn render_gutter_and_body( frame: &mut Frame, inner: Rect, @@ -112,9 +107,8 @@ pub(crate) fn render_gutter_and_body( frame.render_widget(Paragraph::new(body).scroll((0, scroll_x)), cols[1]); } -/// Prepend each gutter line's spans to the body line it belongs to. The two -/// vectors are built in lockstep by the callers, so index `i` pairs row `i`; a -/// body row with no gutter entry simply keeps its own spans. +/// Prepend each gutter line's spans to the body line it belongs to; the two +/// vectors are built in lockstep by the callers, so index `i` pairs row `i`. fn merge_gutter_into_body<'a>(gutter: Vec>, body: Vec>) -> Vec> { let mut gutter = gutter.into_iter(); body.into_iter() diff --git a/src/ui/hint_bar.rs b/src/ui/hint_bar.rs index 1543927e..48a8a8e3 100644 --- a/src/ui/hint_bar.rs +++ b/src/ui/hint_bar.rs @@ -21,20 +21,17 @@ pub(crate) enum HintClick { /// Leader follow-ups a ` x` segment can name and still be clicked. const CLICKABLE_LEADER_KEYS: &str = "twflbo"; /// Keys a bare segment can name and still be clicked: the leader follow-ups as -/// they appear on the armed row (`t`, `w`, `s`, `z`, `c`, `o`, `x`, `p`, `u`, -/// `r`), plus the commands the focused panel handles unprefixed (`l`, `b`, `f`, -/// `v`, `/`, `n`). +/// they appear on the armed row, plus the commands the focused panel handles +/// unprefixed. const CLICKABLE_PLAIN_KEYS: &str = "twslbfoxpruvzcn/"; /// The click a hint segment's keyspec resolves to, or `None` for a segment -/// that is not clickable. -/// -/// The rule is the one `docs/keybindings.md` states: command hints dispatch, -/// navigation hints do not, and `q: detach` is held back so detaching stays a -/// deliberate two-key act. The keys are listed rather than derived because -/// nothing in the hint text tells a command apart from a navigation hint — -/// which means a command added to `hint_text` stays silently unclickable until -/// it is listed here. This list has already had that gap. +/// that is not clickable. Command hints dispatch, navigation hints do not, and +/// `q: detach` is held back so detaching stays a deliberate two-key act. The +/// keys are listed rather than derived because nothing in the hint text tells +/// a command apart from a navigation hint — a command added to `hint_text` +/// stays silently unclickable until it is listed here. This list has already +/// had that gap. pub(crate) fn segment_click(keyspec: &str) -> Option { let spec = keyspec.trim(); if spec == "" { @@ -107,8 +104,8 @@ pub(crate) fn render_hint_bar<'a>( width: u16, ) -> Paragraph<'a> { if chrome.repo_input.active { - // The input itself sits on the notice row, where the repo header was; - // this row carries the dialog's keys and its reports. + // The input itself sits on the notice row; this row carries the + // dialog's keys and its reports. return Paragraph::new(repo_dialog_hint_line( app.notice.as_ref(), chrome.repo_input, @@ -133,8 +130,7 @@ pub(crate) fn render_hint_bar<'a>( } if app.interaction.awaiting_swap_target { // The swap-target digits follow the same layout-aware mapping as the - // focus jumps: `1-8` while the terminal fills the body, `3-9,0` in - // the split view. + // focus jumps: `1-8` fullscreen, `3-9,0` in the split view. let digits = if app.terminal.fullscreen.fills_body() { "1-8" } else { @@ -174,7 +170,7 @@ pub(crate) fn empty_hint_click_at( x: u16, y: u16, ) -> Option { - // Gated like `hint_click_at`: with capture off the row renders plain, and + // Gated like `hint_click_at`: with capture off the row renders plain, but // a browser mouse event still reaches this path, so a label that does not // advertise itself as clickable must not act like one either. if !mouse_enabled { diff --git a/src/ui/notice.rs b/src/ui/notice.rs index ce3830ba..9a64e9de 100644 --- a/src/ui/notice.rs +++ b/src/ui/notice.rs @@ -17,9 +17,9 @@ pub(crate) fn render_notice_row<'a>( width: u16, ) -> Paragraph<'a> { // The open dialog takes the header's row whole: the header names the repo - // being left, the input names the one being opened. Notices and the Tab - // candidates follow the dialog down to the hint row for the duration - // (`repo_dialog_hint_line`), so nothing covers the path being typed. + // being left, the input names the one being opened. Notices follow the + // dialog down to the hint row (`repo_dialog_hint_line`) so nothing covers + // the path being typed. if repo_input.active { return Paragraph::new(crate::ui::repo_dialog::repo_input_line( repo_input, accent, width, @@ -33,16 +33,10 @@ pub(crate) fn render_notice_row<'a>( /// The row's content when something wants to claim it: a notice first, then /// the repo dialog's completion candidates. `None` leaves the row to the -/// caller's own fallback — the repo header on the notice row, the dialog's key -/// legend on the hint row, nothing on the empty screen. -/// -/// A notice outranks the candidates because it explains a rejected action, and -/// any edit (Tab included) clears it, so the two rarely compete for long. -/// -/// When a notice is present and a `repo_path` is available, the repo path is -/// shown on the same line alongside the notice text. If the combined width -/// exceeds the available space, the path is kept and the notice is truncated -/// with `…`. +/// caller's own fallback. A notice outranks the candidates because it explains +/// a rejected action, and any edit clears it, so the two rarely compete. +/// With a `repo_path` present the path is kept and the notice truncates +/// with `…` when the pair exceeds `width`. pub(crate) fn notice_or_candidates<'a>( notice: Option<&'a Notice>, repo_input: &RepoInput, @@ -69,7 +63,6 @@ pub(crate) fn notice_or_candidates<'a>( ])); } - // Truncate notice to fit alongside the path. let available = (width as usize).saturating_sub(path_width); // One column is enough for the ellipsis alone: a notice cut to // nothing must still say it was there, as `+N more` does. @@ -127,14 +120,12 @@ fn overflow_label(remaining: usize) -> String { format!("{CANDIDATE_GAP}+{remaining} more") } -/// Truncate `text` to fit within `max_width` columns, appending `…` when -/// truncation is needed. The ellipsis itself counts toward the width. +/// Truncate `text` to fit within `max_width` columns, appending `…` when cut. /// /// Width is summed per character, so a sequence whose width is not the sum of /// its parts — a variation selector, a combining mark — can come out a column -/// over. Measuring the string again after each character would make this row -/// quadratic in its own width on every frame, and what it buys is a column at -/// the end of a line the terminal clips anyway. +/// over. Re-measuring after each character would make this row quadratic in +/// its own width on every frame, for a column the terminal clips anyway. fn truncate_with_ellipsis(text: &str, max_width: usize) -> String { if Span::raw(text).width() <= max_width { return text.to_string(); @@ -159,21 +150,18 @@ fn truncate_with_ellipsis(text: &str, max_width: usize) -> String { result } -/// How much of the room left for the two names the branch may take when the -/// path wants it as well. The web footer splits it the same way -/// (`RepoShell.tsx`), so the same repository reads the same on both screens. +/// How much of the room left the branch may take when the path wants it as +/// well. The web footer splits it the same way (`RepoShell.tsx`), so the same +/// repository reads the same on both screens. const BRANCH_NAME_SHARE: usize = 2; /// The path and the branch as the row can hold them, cut with `…` rather than -/// pushed off the end. -/// -/// `budget` is what the counts after them have left. Both give way, because -/// those counts do not: a name allowed to keep its length would take the row -/// from `↑N ↓M` and the recovery chip, which are the part of this row that is -/// news. The branch is held to half of what there is so a long one does not -/// take the path's place entirely, and dropped altogether when half is nothing -/// — an ellipsis alone names no branch and still costs the column it is cut to -/// fit. +/// pushed off the end. Both give way before the counts behind them, because +/// those counts do not: a name at full length would take the row from `↑N ↓M` +/// and the recovery chip, the part of this row that is news. The branch is +/// held to half of `budget` so a long one does not take the path's place +/// entirely, and dropped when half is nothing — an ellipsis alone names no +/// branch. pub(crate) fn fit_names( path: &str, branch: Option<&str>, @@ -245,14 +233,11 @@ pub(crate) fn render_repo_header<'a>(app: &'a App, accent: Color, width: u16) -> Paragraph::new(Line::from(spans)) } -/// The full recovery report as one chip: which pane, the plugin's state, the -/// deadline as a local wall-clock time, the attempts spent, and the detail line. -/// -/// On this row rather than in a row or overlay of its own for the reason the -/// notices are: a row that appears and disappears resizes every open PTY. It is -/// the last chip, so an actual notice still covers the whole line — a rejected -/// action needs explaining more than a wait does. The pane it describes is the -/// one ` c` would cancel (see `TerminalState::recovery_focus`). +/// The full recovery report as one chip, on this row rather than a row of its +/// own for the reason the notices are: a row that appears and disappears +/// resizes every open PTY. It is the last chip, so an actual notice still +/// covers the whole line — a rejected action needs explaining more than a +/// wait does. The pane it describes is the one ` c` would cancel. fn recovery_chip(app: &App) -> Option { let (pane, report) = app.terminal.recovery_focus()?; let mut chip = format!(" pane {pane}: {}", report.state); diff --git a/src/ui/project_tab/mod.rs b/src/ui/project_tab/mod.rs index 28d89ead..03cada8a 100644 --- a/src/ui/project_tab/mod.rs +++ b/src/ui/project_tab/mod.rs @@ -16,21 +16,19 @@ use std::time::Duration; /// other is how they come to disagree. const TAB_TITLE_MAX_CHARS: usize = 14; -/// Width of a `+N` overflow marker. const MARKER_WIDTH: u16 = 4; const ATTENTION_GLYPH: char = '•'; const ATTENTION_BLINK_INTERVAL: Duration = Duration::from_secs(1); -/// Bright/dim phase for the unread marker. Only style changes between phases, -/// so the row and its pointer hit boxes never move while it blinks. +/// Only style changes between phases, so the row and its pointer hit boxes +/// never move while it blinks. pub(crate) fn blink_is_bright(elapsed: Duration) -> bool { (elapsed.as_millis() / ATTENTION_BLINK_INTERVAL.as_millis()).is_multiple_of(2) } -/// The name shown for a repo path — its final component. Goes through `Path` -/// rather than splitting on `/` so a Windows path (`C:\work\api`) yields -/// `api` too. +/// Goes through `Path` rather than splitting on `/` so a Windows path +/// (`C:\work\api`) yields `api` too. pub(crate) fn tab_label(repo_path: &str) -> String { let path = std::path::Path::new(repo_path); let name = path @@ -55,7 +53,7 @@ fn truncate(s: &str, max: usize) -> String { /// The full text of every tab, ignoring how many will fit. Every tab carries /// its `F#` legend because the F-key row addresses projects directly and -/// layout-independently. Projects past the tenth have no key, so they carry +/// layout-independently; projects past the tenth have no key, so they carry /// no legend rather than implying an unbound one. fn tab_texts(repo_paths: &[String], attention: &[bool]) -> Vec { repo_paths @@ -75,11 +73,10 @@ fn tab_texts(repo_paths: &[String], attention: &[bool]) -> Vec { } /// The run of tabs to draw in `width` cells, always containing `active`. -/// Ten tabs of repo names do not fit an 80-column row, and a `Paragraph` -/// would clip the tail — silently hiding later projects *and* the active-tab -/// highlight when the active one falls off the end. So the row scrolls -/// around the active tab, and what is dropped is replaced by a `+N` marker -/// whose width is reserved here before deciding what fits. +/// A `Paragraph` would silently clip the tail — hiding later projects *and* +/// the active-tab highlight when the active one falls off the end — so the +/// row scrolls around the active tab and drops what doesn't fit into `+N` +/// markers whose width is reserved here before deciding what fits. fn visible_window(widths: &[u16], width: u16, active: usize) -> std::ops::Range { let n = widths.len(); if n == 0 { @@ -165,7 +162,7 @@ fn tab_segments( /// Draw the tab row into `area`. A single project still renders its tab: the /// row is permanent (see `chrome_rows`), and showing which repo is open is -/// exactly what the row is for. `accent` marks the active tab. +/// exactly what the row is for. pub(crate) fn render( repo_paths: &[String], attention: &[bool], @@ -224,7 +221,7 @@ pub(crate) fn render( } /// The project index a click at screen cell `(x, y)` selects, or `None` off -/// the row or past the last tab. `area` is the tab row Rect. +/// the row or past the last tab. pub(crate) fn tab_at( repo_paths: &[String], attention: &[bool], diff --git a/src/ui/tree_view/mod.rs b/src/ui/tree_view/mod.rs index 02f76795..794a674b 100644 --- a/src/ui/tree_view/mod.rs +++ b/src/ui/tree_view/mod.rs @@ -8,7 +8,6 @@ use crate::ui::SearchQuery; use std::cell::Cell; use std::collections::{BTreeSet, HashMap, HashSet}; -/// One flattened, currently-visible tree row. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VisibleRow { pub path: String, @@ -18,8 +17,8 @@ pub struct VisibleRow { pub expanded: bool, } -/// One entry in the flat filename-search index. Built when search opens, -/// discarded when it closes. +/// Flat filename-search index entry, built when search opens and discarded +/// when it closes. #[derive(Debug, Clone)] pub(crate) struct TreeIndexEntry { pub path: String, @@ -50,9 +49,8 @@ pub struct TreeView { } impl TreeView { - /// Whether the search overlay is open with a non-empty query. An open - /// overlay with an empty query still shows the expansion view so the tree - /// does not explode before the user types. + /// An open overlay with an empty query still shows the expansion view so + /// the tree does not explode before the user types. pub fn search_filtering(&self) -> bool { self.search_active && !self.search_query.is_empty() } @@ -66,7 +64,6 @@ impl TreeView { self.row_width_cache.set(None); } - /// Recompute `show_set`/`match_count` from `index` and the current query. /// Each match contributes itself and every ancestor so the filtered view /// renders an unbroken path from the root to each hit. pub(crate) fn recompute_filter(&mut self) { @@ -102,7 +99,6 @@ impl TreeView { } } - /// Derive the flattened visible rows from the cache and expansion set. /// Only expanded, cached directories contribute children, so this never /// triggers I/O. While filtering, the row list is restricted to `show_set`. pub fn visible_rows(&self) -> Vec { @@ -115,8 +111,7 @@ impl TreeView { rows } - /// Filtered variant of `push_children`: include only `show_set` entries, - /// rendering every kept directory as expanded so the full path to each + /// Renders every kept directory as expanded so the full path to each /// match is visible. fn push_children_filtered(&self, dir: &str, depth: usize, rows: &mut Vec) { let Some(children) = self.cache.get(dir) else { @@ -168,16 +163,14 @@ impl TreeView { } } - /// Repo-relative path of the currently selected row, if any. Used to - /// persist/restore the cursor across sessions and refreshes. + /// Used to persist/restore the cursor across sessions and refreshes. pub fn selected_path(&self) -> Option { self.visible_rows() .get(self.selected) .map(|r| r.path.clone()) } - /// Clamp `selected` to the row count so a collapse or refresh can never - /// leave the cursor past the end. + /// So a collapse or refresh can never leave the cursor past the end. pub fn clamp_selection(&mut self, row_count: usize) { if row_count == 0 { self.selected = 0; @@ -187,16 +180,15 @@ impl TreeView { } } -/// Parent directory of a repo-relative path, or `None` for a top-level entry -/// (whose parent is the root, which has no selectable row). +/// Parent directory of a repo-relative path; `None` for a top-level entry, +/// whose parent is the root, which has no selectable row. pub fn parent_path(path: &str) -> Option<&str> { path.rfind('/').map(|i| &path[..i]) } -/// Whether `rel` is a safe, repo-internal relative path. Paths from normal -/// navigation always are, but a restored session is read from disk — a -/// hand-edited `tree_expanded` entry containing `..`, a leading `/`, or a -/// drive prefix would otherwise let the tree read outside the working tree. +/// Guards a restored session: it is read from disk, so a hand-edited +/// `tree_expanded` entry containing `..`, a leading `/`, or a drive prefix +/// would otherwise let the tree read outside the working tree. pub fn is_safe_rel_path(rel: &str) -> bool { use std::path::Component; !rel.is_empty() diff --git a/src/ui/wall_clock.rs b/src/ui/wall_clock.rs index ecce3e65..86d0d076 100644 --- a/src/ui/wall_clock.rs +++ b/src/ui/wall_clock.rs @@ -1,9 +1,6 @@ //! Turning a unix epoch second into the `HH:MM` a person reads off their own -//! clock. -//! -//! Hand-rolled because nightcrow has no date crate: adding `chrono`/`time` for -//! a handful of integers would buy a dependency and its transitive tree for two -//! format strings. +//! clock, hand-rolled because adding a date crate for two format strings +//! would buy a dependency and its transitive tree. #[cfg(any(not(any(unix, windows)), test))] const SECS_PER_MINUTE: i64 = 60; @@ -12,11 +9,10 @@ const SECS_PER_HOUR: i64 = 3_600; #[cfg(any(not(any(unix, windows)), test))] const SECS_PER_DAY: i64 = 86_400; -/// `HH:MM` in the machine's local zone, or `None` when the timestamp is one the -/// platform cannot place. -/// -/// `None` rather than a fallback on purpose: a wrong wall-clock time reads as -/// fact, and the caller is expected to show nothing instead. +/// `HH:MM` in the machine's local zone, or `None` when the timestamp is one +/// the platform cannot place. `None` rather than a fallback on purpose: a +/// wrong wall-clock time reads as fact, and the caller is expected to show +/// nothing instead. pub(crate) fn local_hour_minute(epoch: i64) -> Option { let t = local_parts(epoch)?; Some(format!("{:02}:{:02}", t.hour, t.minute)) @@ -48,9 +44,8 @@ pub(crate) struct DateTimeParts { fn local_parts(epoch: i64) -> Option { let seconds: libc::time_t = epoch.try_into().ok()?; let mut parts: libc::tm = unsafe { std::mem::zeroed() }; - // SAFETY: `seconds` is a live `time_t` and `parts` a live `tm` for the whole - // call; `localtime_r` reads the first and writes only into the second, and is - // the reentrant form precisely so it needs no shared state. + // SAFETY: `seconds` is a live `time_t` and `parts` a live `tm` for the + // whole call; `localtime_r` reads the first and writes only into the second. let filled = unsafe { libc::localtime_r(&seconds, &mut parts) }; if filled.is_null() { return None; @@ -75,8 +70,8 @@ fn local_parts(epoch: i64) -> Option { use windows_sys::Win32::Storage::FileSystem::FileTimeToLocalFileTime; use windows_sys::Win32::System::Time::FileTimeToSystemTime; - // Windows FILETIME counts 100-nanosecond intervals since 1601-01-01 UTC. - // Unix epoch is 1970-01-01. The offset is 11,644,473,600 seconds. + // Windows FILETIME counts 100-nanosecond intervals since 1601-01-01 UTC; + // Unix epoch is 1970-01-01. const EPOCH_OFFSET_SECS: u64 = 11_644_473_600; const HNS_PER_SEC: u64 = 10_000_000; @@ -97,7 +92,7 @@ fn local_parts(epoch: i64) -> Option { let mut st: SYSTEMTIME = unsafe { std::mem::zeroed() }; // SAFETY: local_ft and st are live stack variables; the functions write - // only into them and need no shared state. + // only into them. let ok = unsafe { FileTimeToLocalFileTime(&ft, &mut local_ft) != 0 && FileTimeToSystemTime(&local_ft, &mut st) != 0 @@ -116,18 +111,16 @@ fn local_parts(epoch: i64) -> Option { }) } -/// UTC on platforms with no `localtime_r`. The zone database is the OS's to -/// expose, and guessing an offset would be worse than being explicit about the -/// one this falls back to. +/// UTC on platforms with no `localtime_r`: guessing an offset would be worse +/// than being explicit about the one this falls back to. #[cfg(not(any(unix, windows)))] fn local_parts(epoch: i64) -> Option { utc_parts(epoch) } -/// `HH:MM` in UTC, which is what the epoch already counts. -/// -/// `epoch.rem_euclid` rather than `%` so a pre-1970 timestamp lands on the right -/// side of midnight instead of producing a negative hour. +/// `HH:MM` in UTC, which is what the epoch already counts. `rem_euclid` +/// rather than `%` so a pre-1970 timestamp lands on the right side of midnight +/// instead of producing a negative hour. #[cfg(any(not(any(unix, windows)), test))] fn utc_parts(epoch: i64) -> Option { let into_day = epoch.rem_euclid(SECS_PER_DAY); diff --git a/src/web/common/conn.rs b/src/web/common/conn.rs index ada7983c..4953d34e 100644 --- a/src/web/common/conn.rs +++ b/src/web/common/conn.rs @@ -25,9 +25,8 @@ pub const MAX_BODY_BYTES: usize = 64 * 1024; /// Per-read socket timeout while collecting the head. pub const HEAD_READ_TIMEOUT: Duration = Duration::from_secs(15); /// Wall-clock budget for the *whole* request. The socket timeout above only -/// bounds one `read`, and it re-arms on every byte — a client dribbling one -/// byte per timeout would otherwise hold a connection slot for days. This is -/// the deadline that actually ends it. +/// bounds one `read` and re-arms on every byte — a client dribbling one byte +/// per timeout would otherwise hold a connection slot for days. pub const REQUEST_DEADLINE: Duration = Duration::from_secs(30); /// Read the request head (up to CRLFCRLF) plus any declared body. Both @@ -107,13 +106,13 @@ pub fn origin_allowed(head: &RequestHead) -> bool { /// Whether the request's `Host` names an address this server should answer on. /// -/// [`origin_allowed`] only proves Origin and Host *agree*, which a DNS-rebound -/// attacker satisfies trivially: they control both. Rebinding `evil.example` to -/// 127.0.0.1 would otherwise give their page a same-origin position from which -/// to POST `/login` and read the reply. +/// [`origin_allowed`] only proves Origin and Host *agree*, which a +/// DNS-rebound attacker satisfies trivially: they control both. Rebinding +/// `evil.example` to 127.0.0.1 would otherwise give their page a same-origin +/// position from which to POST `/login` and read the reply. /// /// A loopback-bound server can only legitimately be addressed as loopback, so -/// any other Host is refused. When bound off-loopback the operator has taken +/// any other Host is refused. Bound off-loopback, the operator has taken /// responsibility for the network path, and the check would reject legitimate /// proxied hosts, so it does not apply. pub fn host_allowed(head: &RequestHead, bound_loopback: bool) -> bool { @@ -207,9 +206,9 @@ pub fn websocket_handshake( if stream.write_all(handshake.as_bytes()).is_err() { return None; } - // Cap frame and message size. tungstenite's defaults are 16 MiB / 64 MiB, - // which a client could pair with the terminal command queue to park - // gigabytes of pending input. Nothing either server accepts is large. + // Cap frame and message size: tungstenite's defaults (16 MiB / 64 MiB) + // paired with the terminal command queue could park gigabytes of pending + // input. Nothing either server accepts is large. let config = tungstenite::protocol::WebSocketConfig::default() .max_message_size(Some(MAX_WS_MESSAGE_BYTES)) .max_frame_size(Some(MAX_WS_MESSAGE_BYTES)); diff --git a/src/web/common/sessions.rs b/src/web/common/sessions.rs index eed96483..4defa6e3 100644 --- a/src/web/common/sessions.rs +++ b/src/web/common/sessions.rs @@ -1,28 +1,18 @@ //! Persistent session token store. Tokens are opaque 256-bit random strings -//! backed by a file so they survive daemon restarts. Each token carries the -//! expiry it was issued with, so a restarted server does not accept stale -//! tokens forever. +//! backed by a file so they survive daemon restarts, each carrying the expiry +//! it was issued with. The lifetime is handed to the store at construction: +//! how long a login lasts is the operator's call, and `None` means tokens +//! never expire on their own. //! -//! The lifetime is the store's, handed to it at construction rather than fixed -//! here: how long a login should last is the operator's call, and a store told -//! `None` issues tokens that never expire on their own. +//! Logout revokes a token server-side — clearing the cookie alone leaves a +//! leaked token usable until expiry. Expired tokens are swept on every write +//! and on load; nothing is scheduled, since the file only changes when someone +//! logs in, logs out, or presents an expired token. //! -//! Logout revokes a token server-side — clearing the cookie alone is not -//! enough because a leaked token would remain usable until expiry. Revocation -//! removes the token from both memory and the on-disk store. -//! -//! Expired tokens are forgotten opportunistically: every write sweeps the whole -//! set, and loading discards what has already run out. Nothing is scheduled — -//! the file only changes when someone logs in, logs out, or presents a token -//! that has expired, and those are the moments worth paying for the sweep. -//! -//! The file is written with owner-only permissions (0o600 on Unix); see -//! `platform::fs`. On Windows the permission call is a no-op (documented -//! there), so operators should place the state directory in a restricted -//! location. -//! -//! When `store_path` is `None` the store is in-memory only, matching the old -//! behaviour for tests and transient runs. +//! The file is written owner-only (0o600 on Unix; see `platform::fs`). On +//! Windows that call is a no-op (documented there), so operators should place +//! the state directory in a restricted location. With `store_path` `None` the +//! store is in-memory only, for tests and transient runs. use crate::platform; use anyhow::{Context, Result, anyhow}; @@ -86,10 +76,9 @@ impl SessionStore { ttl, }; // Write the shortened deadlines down now instead of leaving them to the - // next login. Until they are on disk the file still names the old ones, - // so a second restart would read them and measure a fresh lifetime from - // there — a tightened policy would never take hold on a session that - // restarts more often than the lifetime it is being held to. + // next login: until then the file still names the old ones, so a + // second restart would measure a fresh lifetime from there and a + // tightened policy would never take hold on a fast-restarting session. if shortened { store.persist(); } @@ -120,8 +109,7 @@ impl SessionStore { } /// Invalidate a token server-side. Clearing the cookie alone leaves a - /// leaked token usable until expiry, which makes logout a suggestion - /// rather than a revocation. + /// leaked token usable until expiry. pub fn revoke(&self, token: &str) { { let mut tokens = self.tokens.lock().expect("session store mutex poisoned"); @@ -165,16 +153,8 @@ impl SessionStore { let data = { let mut tokens = self.tokens.lock().expect("session store mutex poisoned"); // Swept here because this is the one place that already holds every - // token and is about to write them down. `is_valid` only reaches the - // token it was asked about, and a session nobody asks about again — - // the browser holding that cookie never came back — would otherwise - // sit in memory and on disk until the daemon restarts, so the file - // would claim sessions that cannot log anyone in. - // - // Ahead of the store having a file: a store without one (the - // fallback when the state directory cannot be opened) holds the same - // tokens in the same map, and is the one that cannot be fixed by a - // restart reading a swept file. + // token and is about to write them down; otherwise a token nobody + // asks about again would sit in memory and on disk until restart. sweep(&mut tokens, SystemTime::now()); serialize(&tokens) }; @@ -202,14 +182,10 @@ fn parse_expiry(field: &str) -> Option { /// Bring loaded expiries down to what the configured lifetime allows. /// -/// Only ever lowers. A token issued under a longer lifetime — or none at all — -/// should not outlive a policy the operator has since tightened, and tightening -/// it is the one edit that has to reach sessions already handed out. A token -/// already closer to running out keeps its own earlier deadline, so a restart -/// never extends anything. -/// -/// Reports whether anything moved, which is what tells the caller the file no -/// longer matches the tokens. +/// Only ever lowers: a token issued under a longer lifetime should not +/// outlive a policy the operator has since tightened, and a token already +/// closer to running out keeps its earlier deadline. Reports whether anything +/// moved, which is what tells the caller the file no longer matches the tokens. fn clamp(tokens: &mut HashMap, ttl: Option, now: SystemTime) -> bool { let Some(ceiling) = ttl.and_then(|ttl| now.checked_add(ttl)) else { return false; diff --git a/src/web/viewer/assets.rs b/src/web/viewer/assets.rs index cca37841..556f570a 100644 --- a/src/web/viewer/assets.rs +++ b/src/web/viewer/assets.rs @@ -62,17 +62,12 @@ fn plain_host(host: &str) -> bool { /// Serve a built asset, falling back to `index.html` so client-side routes and /// a bare `/` both load the app. /// -/// `host` is the request's `Host` header, which scopes the CSP's socket -/// sources (see [`csp`]). -/// -/// A miss is split by whether the request names a file. An extensionless path is -/// a client-side route and gets the app shell; a path that names a file (has an -/// extension) is a real asset miss and gets a 404. The shell fallback must not -/// cover the second case: handing `index.html` back for a missing `.svg`/`.js` -/// serves HTML under an image or module request, which then fails silently — -/// a stale embedded build made the header/splash crow render as a blank accent -/// tile exactly this way (`/crow-mono.svg` missing → HTML → the `` shows -/// nothing). A loud 404 surfaces the missing asset instead. +/// A miss is split by whether the request names a file. An extensionless path +/// is a client-side route and gets the app shell; a path naming a file is a +/// real asset miss and gets a 404. Handing `index.html` back for a missing +/// `.svg`/`.js` serves HTML under an image or module request, which fails +/// silently — a stale embedded build once rendered the header/splash as a +/// blank accent tile exactly this way. A loud 404 surfaces it instead. pub fn serve(path: &str, host: Option<&str>) -> Option> { let csp = csp(host); let headers = [ @@ -91,10 +86,9 @@ pub fn serve(path: &str, host: Option<&str>) -> Option> { } // `rust_embed` resolves names against the embedded map, so a `..` in the - // request simply misses; there is no filesystem lookup to escape. - // rust-embed carries the guessed type alongside the bytes, so the content - // type comes from the same lookup that found the file — they cannot - // disagree, which matters when the CSP refuses a mistyped script. + // request simply misses; there is no filesystem lookup to escape. The + // guessed mimetype travels with the bytes, so content type and file + // cannot disagree — which matters when the CSP refuses a mistyped script. if let Some(file) = Assets::get(candidate) { return Some(http::response( "200 OK", @@ -130,19 +124,14 @@ const BUILD_META: &str = "nightcrow-build"; /// The app shell, carrying the id of the build it is part of. /// -/// Stamped rather than left to the client to work out, because what the page -/// needs is the build **it** is running, and the only moment that is certain is -/// the one it is handed over. Inferring it from the first API response it -/// happens to get is wrong for a tab that sits on the login screen across a -/// rebuild: the build it adopts is then the new one, and it never learns it is -/// running the old. +/// Stamped rather than left to the client to work out: what the page needs is +/// the build **it** is running, and the only moment that is certain is the one +/// it is handed over. Inferring it from the first API response is wrong for a +/// tab sitting on the login screen across a rebuild. /// -/// The id names the stored file, not these bytes — the stamp is derived from -/// what it is stamped into, so it cannot also be part of it. /// One read, not two: a debug server reads `dist` from disk, so reading the /// bytes and then asking [`build_id`] again could stamp the build that landed -/// in between onto the document that preceded it — a page that would then -/// believe it was current for as long as it stayed open. +/// in between onto the document that preceded it. fn shell(headers: &[(&str, &str)]) -> Option> { let file = Assets::get(SHELL)?; let id = id_of(file.metadata.sha256_hash()); @@ -181,22 +170,18 @@ const BUILD_ID_BYTES: usize = 4; /// Names the built frontend this server is serving. /// /// The hash of `index.html`, because that file names the code: every chunk and -/// stylesheet Vite emits carries a content hash in its filename, so a change to -/// any of them changes a name in the shell. A page can compare what it was -/// served against what the server has now and offer a reload. -/// -/// What that leaves out is `public/`, which is copied under fixed names — an -/// icon or the manifest can change without moving this. Deliberately: what the -/// comparison is for is a page running code the server has replaced, and a file -/// nothing imports cannot put a page in that state. +/// stylesheet Vite emits carries a content hash in its filename, so a change +/// to any of them changes a name in the shell. What that leaves out is +/// `public/`, copied under fixed names — deliberately, since a file nothing +/// imports cannot put a page in the replaced-code state this exists to +/// report. /// /// Read per call rather than held: only a release build embeds `dist`, and a /// debug server reads it from disk — a rebuild under a running daemon is /// exactly the case this exists to report. /// -/// `None` when the shell is missing, which is a build that cannot load at all. -/// Saying nothing is the honest answer there; a placeholder would be a build id -/// that never changes. +/// `None` when the shell is missing: a build that cannot load at all, where +/// saying nothing beats a placeholder id that never changes. pub fn build_id() -> Option { Some(id_of(Assets::get(SHELL)?.metadata.sha256_hash())) } diff --git a/src/web/viewer/clone_jobs.rs b/src/web/viewer/clone_jobs.rs index 21c91bf9..c67ec1f6 100644 --- a/src/web/viewer/clone_jobs.rs +++ b/src/web/viewer/clone_jobs.rs @@ -49,12 +49,10 @@ impl CloneJobs { return None; } let id = self.next_id.fetch_add(1, Ordering::Relaxed) + 1; - // Evict before inserting so a long-lived server does not accumulate - // jobs. The *oldest* finished ones go first — dropping every finished - // job at once could take one a client had not read yet, which reads to - // that client as "your clone is gone" even though it succeeded. - // Running jobs are never evicted: their thread still holds the id and - // will write a result to it. + // Evict the *oldest finished* jobs first — dropping all at once could + // take one a client had not read yet, which reads as "your clone is + // gone" even though it succeeded. Running jobs are never evicted: + // their thread still holds the id and will write a result to it. if jobs.len() >= MAX_RETAINED_JOBS { let mut finished: Vec = jobs .iter() @@ -84,10 +82,8 @@ impl CloneJobs { self.lock().get(&id).cloned() } - /// The job currently running, if any. At most one exists by admission, so - /// a client that lost track of its id — a reloaded page, a second tab — - /// can ask what to follow instead of being told a clone is already - /// running with no way to watch it. + /// The job currently running, if any — at most one exists by admission, + /// so a client that lost track of its id can ask what to follow. pub fn running(&self) -> Option { self.lock() .iter() @@ -96,8 +92,8 @@ impl CloneJobs { } fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { - // A poisoned lock means a panic while holding it. The map is plain data - // with no invariant spanning the critical sections, so recovering keeps + // A poisoned lock means a panic while holding it, but the map is plain + // data with no invariant spanning critical sections — recovering keeps // clone tracking usable instead of taking the server down with it. self.jobs.lock().unwrap_or_else(|err| err.into_inner()) } diff --git a/src/web/viewer/dto/envelope.rs b/src/web/viewer/dto/envelope.rs index 86893f06..8e004433 100644 --- a/src/web/viewer/dto/envelope.rs +++ b/src/web/viewer/dto/envelope.rs @@ -95,11 +95,9 @@ pub struct HotConfigDto { /// What `GET /api/repos` answers: everything the client needs before it can /// render, in one response. /// -/// Named for what it carries rather than for its route. The route is about -/// repositories — `POST` opens one, `DELETE` closes one — but the `GET` grew -/// into the session's bootstrap, because a client that already polls it every -/// few seconds is the cheapest carrier for anything server-wide it must agree -/// with. +/// The `GET` grew into the session's bootstrap because a client that already +/// polls it every few seconds is the cheapest carrier for anything +/// server-wide it must agree with. /// /// Every field here belongs in `ViewerBootstrap` in `viewer-ui/src/api.ts` too. /// Renaming or retyping one without doing so fails the fixture contract test. @@ -117,19 +115,17 @@ pub struct ViewerBootstrapDto { /// see `prefs::ViewerPrefs`. pub upper_pct: u32, /// Id of the project a client last selected, so a reload lands there - /// instead of on the first tab. `None` when nothing has been selected yet - /// or the remembered project is not currently served. An id, not the path - /// `prefs.rs` stores: clients address repositories by id and never learn - /// the path. + /// instead of on the first tab. An id, not the path `prefs.rs` stores: + /// clients address repositories by id and never learn the path. pub active_repo: Option, /// Which panel each *currently served* project was left maximized in, by - /// id. Projects with no arrangement are absent, as are remembered ones this - /// session is not serving. + /// id. Projects with no arrangement are absent, as are remembered ones + /// this session is not serving. pub maximized: std::collections::HashMap, - /// What each *currently served* project was last showing, by id, so opening - /// one again opens what was open. Absent for a project nothing has been - /// looked at in, and for a remembered project this session is not serving — - /// which keeps its entry on file for when it is. + /// What each *currently served* project was last showing, by id, so + /// opening one again opens what was open. Absent for a project nothing + /// has been looked at in, and for a remembered project this session is + /// not serving — which keeps its entry on file for when it is. pub last_view: std::collections::HashMap, /// This server's wall clock, for dating [`super::ChangedFileDto::mtime`]. pub now_ms: u64, @@ -149,12 +145,11 @@ impl ViewerBootstrapDto { /// useful as facts about the response being built, not as arguments a /// caller could get wrong. /// - /// Takes the whole [`ViewerPrefs`] rather than the fields it needs: several - /// of them are `u32`, and a positional list of those is a pair of arguments - /// a call site can swap with nothing to catch it. `active_repo` stays - /// separate because what goes on the wire is the **id** resolved from - /// `prefs.active_repo`, which only the caller's catalog snapshot can supply. - /// `maximized` and `last_view` are separate for the same reason. + /// Takes the whole [`ViewerPrefs`] rather than the fields it needs: a + /// positional list of `u32`s is a pair of arguments a call site can swap + /// with nothing to catch it. `active_repo`, `maximized`, and `last_view` + /// stay separate because what goes on the wire is the id-resolved form + /// only the caller's catalog snapshot can supply. pub fn new( repos: Vec, hot: HotConfigDto, diff --git a/src/web/viewer/dto/status.rs b/src/web/viewer/dto/status.rs index 5e38c7e9..e3a30e44 100644 --- a/src/web/viewer/dto/status.rs +++ b/src/web/viewer/dto/status.rs @@ -53,15 +53,14 @@ pub struct ChangedFileDto { pub worktree: String, /// Worktree mtime as Unix milliseconds, for the client's "recently touched" /// highlight (the same signal the TUI's hot table carries). Absent when the - /// file could not be stat'd — or always, for a commit's file list, where the - /// working tree says nothing about the commit. + /// file could not be stat'd — or always, for a commit's file list, where + /// the working tree says nothing about the commit. /// /// An absolute instant, not an age: the status payload is deduplicated by - /// byteequality before it is pushed, so a field that moved every tick would - /// turn an idle repository into a permanent event stream. Because the - /// instant comes from this machine's clock and the browser may be running on - /// another device, the client corrects for the difference using the - /// `now_ms` that rides the repo poll (see [`server_now_millis`]). + /// byte-equality before it is pushed, so a field that moved every tick + /// would turn an idle repository into a permanent event stream. The client + /// corrects for clock skew against the `now_ms` riding the repo poll (see + /// [`server_now_millis`]). #[serde(skip_serializing_if = "Option::is_none")] pub mtime: Option, } @@ -103,12 +102,9 @@ fn unix_millis(t: SystemTime) -> Option { .map(|d| d.as_millis() as u64) } -/// The server's wall clock in Unix milliseconds — the reference the client dates -/// `mtime` against. `0` for a pre-epoch clock, which leaves the client on its own -/// clock rather than shifting it by a nonsense offset. -/// -/// Sent because `mtime` is an absolute instant produced by *this* machine while -/// the browser reading it may be another device entirely (see [`ChangedFile`]). +/// The server's wall clock in Unix milliseconds — the reference the client +/// dates `mtime` against. `0` for a pre-epoch clock, which leaves the client +/// on its own clock rather than shifting it by a nonsense offset. pub fn server_now_millis() -> u64 { unix_millis(SystemTime::now()).unwrap_or(0) } diff --git a/src/web/viewer/limits.rs b/src/web/viewer/limits.rs index 216df25b..6db9868c 100644 --- a/src/web/viewer/limits.rs +++ b/src/web/viewer/limits.rs @@ -8,12 +8,11 @@ /// Commits returned by one page of `/api/log`. Matches the TUI's /// `commit_log_page_size` default. pub const MAX_LOG_PAGE: usize = 100; -// `/api/log?skip=` deliberately has no ceiling. A ceiling here would look -// prudent and protect nothing: the skip feeds `Iterator::skip` on a revwalk, so -// a request walks at most `skip + page` or the whole history, whichever is -// smaller. An absurd skip costs what walking the repository costs and no more, -// while a ceiling would turn the deep end of a long history into a page the -// client can see exists and can never fetch. +// `/api/log?skip=` deliberately has no ceiling: the skip feeds +// `Iterator::skip` on a revwalk, so a request walks at most `skip + page` or +// the whole history, whichever is smaller — an absurd skip costs what walking +// the repository costs and no more, while a ceiling would make the deep end +// of a long history a page the client can see exists and can never fetch. /// Changed paths returned while drilling into one commit. pub const MAX_COMMIT_FILES: usize = 2_000; /// Entries returned for one directory level of `/api/tree`. @@ -30,14 +29,13 @@ pub const MAX_TREE_SEARCH_QUERY_BYTES: usize = 256; pub const MAX_STATUS_FILES: usize = 2_000; /// Bytes of diff text returned for one file. pub const MAX_DIFF_BYTES: usize = 1024 * 1024; -/// Lines of diff returned for one file, whichever ceiling is hit first. +/// Lines of diff returned for one file — whichever ceiling is hit first. pub const MAX_DIFF_LINES: usize = 20_000; /// Bytes of a single SSE payload. Status is conflated to the latest value, so /// this bounds one snapshot, not a backlog. pub const MAX_SSE_PAYLOAD_BYTES: usize = 1024 * 1024; -/// Live connections the viewer's accept loop will hold. Each one costs a -/// thread, so without a ceiling anything that can reach the port can exhaust -/// the process. +/// Live connections the viewer's accept loop will hold — each one costs a +/// thread. pub const MAX_VIEWER_CONNECTIONS: usize = 64; /// A list that may have been cut short, with the fact recorded. @@ -59,10 +57,9 @@ impl Capped { } } -/// Cut `text` to at most `max_bytes`, never splitting a UTF-8 character. The -/// cut walks back to the nearest boundary so a multi-byte character -/// straddling the limit is dropped whole rather than emitted as a broken -/// fragment. +/// Cut `text` to at most `max_bytes`, never splitting a UTF-8 character: the +/// cut walks back to the nearest boundary so a straddling multi-byte +/// character is dropped whole rather than emitted as a broken fragment. pub fn cap_text(text: &str, max_bytes: usize) -> (String, bool) { if text.len() <= max_bytes { return (text.to_string(), false); diff --git a/src/web/viewer/server/clone_routes.rs b/src/web/viewer/server/clone_routes.rs index d1dbd11f..bd0d6d25 100644 --- a/src/web/viewer/server/clone_routes.rs +++ b/src/web/viewer/server/clone_routes.rs @@ -76,8 +76,8 @@ pub(super) fn handle_clone(body: &str, state: &Arc) -> Vec { } let worker = Arc::clone(state); - // The closure takes the path, so keep one for the spawn-failure branch — - // the claimed directory must be released or it blocks a retry. + // Keep one path for the spawn-failure branch: the claimed directory must + // be released or it blocks a retry. let claimed = dest.clone(); if let Err(err) = std::thread::Builder::new() .name("nightcrow-viewer-clone".to_string()) @@ -100,18 +100,14 @@ fn run_and_record(state: &ViewerState, id: u64, url: &str, dest: PathBuf) { Ok(()) => CloneState::Done(crate::platform::paths::for_display(&dest).into_owned()), Err(err) => { // The destination was created here, so a failed clone would leave - // a directory behind that blocks a retry under the same name. - // Non-recursive on purpose: it cannot destroy content if - // something else has taken this path in the meantime. That means - // a failure git does not clean up after — it keeps the repository - // when only the checkout fails — leaves the directory in place. - // A visible leftover the user can delete beats deleting files - // that turned out not to be ours. + // a directory behind that blocks a retry. Non-recursive on + // purpose: it cannot destroy content if something else has taken + // this path in the meantime. A visible leftover the user can + // delete beats deleting files that turned out not to be ours. let _ = std::fs::remove_dir(&dest); - // git's message names the real problem ("repository not found", - // "permission denied"), which is exactly what the user must act on. - // It is the remote's words about a URL the user typed, not server - // internals, so it is shown rather than redacted. + // git's message names the real problem and is the remote's words + // about a URL the user typed, not server internals — shown, not + // redacted. tracing::info!(error = %err, "clone failed"); CloneState::Failed(err.to_string()) } @@ -124,9 +120,7 @@ fn run_and_record(state: &ViewerState, id: u64, url: &str, dest: PathBuf) { /// /// With no id the question is instead "what is running?", which is what a page /// that just loaded asks: the clone it should be following may have been -/// started by a tab that has since been reloaded or closed, and without this -/// that client could only see the 409 refusing a second clone, never the job -/// causing it. +/// started by a tab that has since been reloaded or closed. pub(super) fn handle_clone_status(head: &RequestHead, state: &ViewerState) -> Vec { let Some(raw) = head.query_param("job") else { return encode(serde_json::json!({ "job": state.clones.running() })); diff --git a/src/web/viewer/server/dispatch.rs b/src/web/viewer/server/dispatch.rs index 0a19f478..cda4676f 100644 --- a/src/web/viewer/server/dispatch.rs +++ b/src/web/viewer/server/dispatch.rs @@ -64,12 +64,11 @@ fn handle_connection(mut stream: TcpStream, state: Arc) { return; } ("GET", "/logout") => { - // A real logout is a top-level navigation (the header link, so - // `Sec-Fetch-Dest: document`). A framed request here is something - // embedded — the HTML preview's sandboxed frame navigating itself — - // trying to end the session out from under the person. Refuse it; - // absent metadata (an old client) still logs out, which is the - // safe direction for a control the person meant to reach. + // A real logout is a top-level navigation (the header link). A + // framed request here is something embedded — the HTML preview's + // sandboxed frame navigating itself — trying to end the session + // out from under the person. Refuse it; absent metadata (an old + // client) still logs out, which is the safe direction. if head.header("sec-fetch-dest") == Some("iframe") { let _ = stream.write_all(&text_response("403 Forbidden", "not from this context")); return; @@ -170,11 +169,10 @@ fn handle_connection(mut stream: TcpStream, state: Arc) { return; } - // Re-reading config.toml. POST for the same CSRF reasoning as the others, and - // the body is ignored: what is read is the file on this machine's disk, so this - // cannot be used to hand the session a configuration of the caller's own - // making. An authenticated user can already open a shell here, so re-reading a - // file they wrote stays within the same trust boundary. + // Re-reading config.toml. POST for the same CSRF reasoning as the others, + // and the body is ignored: what is read is the file on this machine's + // disk, so this cannot hand the session a configuration of the caller's + // own making. if head.method == "POST" && head.path == "/api/reload" { let _ = stream.write_all(&handle_reload_config(&state)); return; diff --git a/src/web/viewer/server/handlers/terminal.rs b/src/web/viewer/server/handlers/terminal.rs index 760fa501..be7ed936 100644 --- a/src/web/viewer/server/handlers/terminal.rs +++ b/src/web/viewer/server/handlers/terminal.rs @@ -15,13 +15,10 @@ const MAX_VIEWER_ID: usize = 64; /// itself. /// /// The page generates this once per tab and sends it on every socket, so its -/// connections can come and go without the session reading them as somebody new -/// sitting down. -/// -/// A boundary input, so it is held to what an id can be: a short run of plain -/// characters. An id that is missing or malformed gets one of its own rather -/// than a refusal -- the page still works, it simply behaves as it did before it -/// could name itself. +/// connections can come and go without the session reading them as somebody +/// new sitting down. A missing or malformed id gets one of its own rather +/// than a refusal — the page still works, it simply behaves as it did before +/// it could name itself. fn browser_viewer(head: &crate::web::common::http::RequestHead) -> ViewerId { let named = head.query_param("viewer").filter(|id| { !id.is_empty() @@ -47,18 +44,12 @@ fn anonymous_viewer() -> String { /// Whether a failed socket operation leaves the connection usable. /// -/// A timeout is not a departure. Both directions carry one -- reads poll at -/// [`TERM_POLL_TIMEOUT`] so the loop can service the other side, writes get -/// [`SSE_HEARTBEAT`] so a stalled reader cannot wedge this thread forever -- and -/// each surfaces as `WouldBlock` on macOS and `TimedOut` on Linux. -/// -/// The write side counting that as fatal is what made the panel rebuild itself -/// out of nowhere: a page that stopped reading for fifteen seconds -- a phone -/// asleep, a tunnel renegotiating, a handover between networks -- had its socket -/// closed under it, reconnected, and replayed every pane's history from scratch. -/// tungstenite draws the line in the same place: an `Io` error is fatal "except -/// for WouldBlock", and the frame that could not go out is held in its write -/// buffer for the next `write` or `flush` to finish. +/// A timeout is not a departure: it surfaces as `WouldBlock` on macOS and +/// `TimedOut` on Linux, and ending the connection there cost a page that +/// stopped reading for fifteen seconds (a phone asleep, a tunnel +/// renegotiating) every pane replayed from scratch. tungstenite draws the +/// same line: an `Io` error is fatal "except for WouldBlock", and the frame +/// that could not go out stays in its write buffer for the next flush. /// /// A client that has genuinely stopped keeping up is still cut off — by the /// hub, once its queue fills (`broadcast_locked`). That is where the cap @@ -113,22 +104,20 @@ pub(in crate::web::viewer::server) fn serve_terminal( return; }; // `claim` is the page saying a person just opened it, as opposed to a - // repository switch or a reconnect. Absent means no -- a socket that does not - // say it arrived must not take the sizing off whoever is looking. + // repository switch or a reconnect. Absent means no — a socket that does + // not say it arrived must not take the sizing off whoever is looking. let arriving = head.query_param("claim").as_deref() == Some("1"); // A second handle, kept by the hub only to end this connection if the page - // stops draining its queue: the loop below is then parked in `ws.read()` and - // nothing else would wake it. A clone that could not be made costs the hub - // that ability and nothing else. + // stops draining its queue: the loop below is then parked in `ws.read()` + // and nothing else would wake it. A clone that could not be made costs the + // hub that ability and nothing else. let evict_handle = match evict_handle { Ok(handle) => Some(handle), Err(err) => { - // Degrades to what this did before there was a handle at all: the - // client is dropped from the broadcast list but its socket stays - // open. Logged rather than passed over, because the page then holds - // a panel that has stopped updating and nothing else says so -- and - // because a clone that fails means descriptors are exhausted, which - // is worth knowing on its own. + // Degrades to no handle: the client is dropped from the broadcast + // list but its socket stays open. Logged because the page then + // holds a panel that has stopped updating and nothing else says + // so — and a failed clone means descriptors are exhausted. tracing::warn!(%err, "viewer: a terminal socket cannot be cut off if it stalls"); None } @@ -164,8 +153,8 @@ pub(in crate::web::viewer::server) fn serve_terminal( Ok(()) => unflushed = false, // Stop pulling from the hub while the socket will not take it, // so what is still queued backs up where the cap is: the hub's - // own queue, whose overflow is what disconnects a client that - // has really stopped keeping up. + // own queue, whose overflow disconnects a client that has + // really stopped keeping up. Err(err) if stalled_not_gone(&err) => { unflushed = true; break; diff --git a/src/web/viewer/server/mutations/preferences.rs b/src/web/viewer/server/mutations/preferences.rs index 1ef6193f..038ba808 100644 --- a/src/web/viewer/server/mutations/preferences.rs +++ b/src/web/viewer/server/mutations/preferences.rs @@ -115,9 +115,8 @@ pub(in crate::web::viewer::server) fn handle_set_prefs(body: &str, state: &Viewe }; // The project in front is shared, so a write that changes it re-points - // every open page — and two pages tugging it back and forth would show - // here as alternating switches. No-op writes (a page confirming what is - // already in front) stay silent. + // every open page — two pages tugging it back and forth would show here + // as alternating switches. No-op writes stay silent. if let Some(path) = &active_path { let before = state.session.prefs().get().active_repo; if before.as_deref() != Some(path.as_str()) { @@ -160,15 +159,13 @@ pub(in crate::web::viewer::server) fn handle_set_prefs(body: &str, state: &Viewe ) } -/// Turn a client's view into the form the prefs file keeps: its repo id becomes -/// the path that file is keyed by, and the names it carries have to be ones this -/// build knows. +/// Turn a client's view into the form the prefs file keeps: its repo id +/// becomes the path that file is keyed by, and the names it carries have to +/// be ones this build knows. /// -/// Paths are not checked here. They are checked where they are stored -/// (`prefs::repo_view`), which is the door the file itself also comes through — -/// a second check here would be a second place for the rule to drift. What is -/// answered instead is what the store cannot express: a project that is not -/// served, and a tab or a face this build has no name for. +/// Paths are not checked here — they are checked where they are stored +/// (`prefs::repo_view`), the door the file itself also comes through; a +/// second check here would be a second place for the rule to drift. fn resolve_view(request: ViewRequest, state: &ViewerState) -> Result> { let Some(entry) = state.session.catalog().get(&request.repo) else { return Err(json_error("400 Bad Request", "unknown repo")); diff --git a/src/web/viewer/server/preview.rs b/src/web/viewer/server/preview.rs index 97d7ced1..a9907de8 100644 --- a/src/web/viewer/server/preview.rs +++ b/src/web/viewer/server/preview.rs @@ -1,56 +1,35 @@ //! The HTML preview document: the one API response that is a repository file //! served as itself. //! -//! The file pane used to inline the file into a `srcdoc` frame, but a -//! local-scheme document inherits the embedder's CSP, whose `script-src -//! 'self'` refuses the inline scripts a self-contained page is made of — an -//! HTML slide deck rendered but never ran. Only a network response carries a -//! policy of its own, which is what this endpoint exists to attach. +//! A `srcdoc` frame inherits the embedder's CSP, whose `script-src 'self'` +//! refuses the inline scripts a self-contained page is made of — an HTML slide +//! deck rendered but never ran. Only a network response carries a policy of +//! its own, which is what this endpoint exists to attach. //! -//! What that policy opens, and what it keeps shut: +//! The policy's shape: `sandbox allow-scripts` gives the document an opaque +//! origin (no cookies, no app DOM/storage; its requests arrive unauthenticated +//! with `Origin: null`, which `origin_allowed` refuses before auth is even +//! consulted). `script-src 'unsafe-inline'` is the point of the endpoint, with +//! no host source beside it; `connect-src 'none'` closes fetch and WebSocket +//! outright; `frame-ancestors 'self'` keeps other origins from embedding it. +//! The iframe's own `sandbox="allow-scripts"` attribute intersects with the +//! header, so either one failing still leaves the other standing. //! -//! - **`sandbox allow-scripts`** gives the document an opaque origin even -//! though its URL is this server's. Scripts run, but the document is -//! nobody: no cookie jar, nothing of the app's DOM or storage, and every -//! request it makes arrives unauthenticated (`SameSite=Strict`) with -//! `Origin: null` — which `origin_allowed` refuses before auth is even -//! consulted, the terminal WebSocket included. -//! - **`script-src 'unsafe-inline'`** is the point of the endpoint: inline -//! scripts run. No host source stands beside it, so no script is fetched -//! from anywhere to run. -//! - **`connect-src 'none'`** closes fetch and WebSocket outright, so the -//! frame cannot phone any host — this server included. Subresources are -//! `data:` or refused (`default-src 'none'`), keeping the standing rule -//! that a preview never loads from another host. -//! - **`frame-ancestors 'self'`** keeps other origins from embedding it. -//! -//! The iframe that loads this keeps its own `sandbox="allow-scripts"` -//! attribute too: header and attribute intersect, so either one failing an -//! old browser or a future edit still leaves the other standing. -//! -//! One more belt for one more brace: a *top-level* navigation to this URL — a -//! pasted link, not an embed — is served the file as inert `text/plain`. This -//! closes the case a browser that ignored the CSP `sandbox` (none in a decade, -//! but the header is our only wall against it) would otherwise open: a -//! repository file executed as a *first-party* document with the session -//! cookie. The signal is `Sec-Fetch-Dest: document`, set by the browser on a -//! top-level navigation and unforgeable from script. -//! -//! It fails *open*: a request that carries no Fetch metadata is treated as an -//! embed and gets the executable document. That is deliberate, because browsers -//! send `Sec-Fetch` only from a potentially-trustworthy origin (HTTPS or -//! localhost) — so every plain-HTTP origin omits it, and the viewer reached -//! over a LAN or Tailscale address is exactly that. Failing closed there served -//! the raw source instead of the page on the whole mobile path. On that path -//! the CSP `sandbox` header stands alone — as it already does everywhere; this -//! gate only ever added a second wall where the metadata exists to raise it. +//! A *top-level* navigation to this URL — a pasted link — is served the file +//! as inert `text/plain`, signalled by the unforgeable `Sec-Fetch-Dest: +//! document`. Otherwise a browser ignoring CSP `sandbox` would execute a +//! repository file as a *first-party* document with the session cookie. It +//! fails *open*: browsers send `Sec-Fetch` only from a potentially-trustworthy +//! origin, so every plain-HTTP origin omits it and the viewer reached over a +//! LAN or Tailscale address is exactly that — failing closed there broke the +//! whole mobile path. On that path the CSP `sandbox` header stands alone, as +//! it already does everywhere. //! //! What no policy here closes: a script may navigate its own frame away — to -//! an external URL (carrying its own source, which its author already has) or -//! to a phishing page in the pane. That is inherent to allowing scripts, is -//! recorded as an accepted residual in `docs/architecture/web.md`, and is why -//! the boundary this file defends is "the frame cannot reach the *session*", -//! not "the frame cannot emit anything". +//! an external URL or a phishing page in the pane. That is inherent to +//! allowing scripts, is recorded as an accepted residual in +//! `docs/architecture/web.md`, and is why the boundary this file defends is +//! "the frame cannot reach the *session*", not "the frame cannot emit anything". /// See the module doc for why each directive is what it is. const PREVIEW_CSP: &str = "sandbox allow-scripts; \ @@ -92,9 +71,8 @@ pub(super) fn route( }) } -/// The file as `text/plain`, for an explicit top-level navigation. Nothing -/// executes: a browser that reached this by a top-level navigation sees the -/// source, not a first-party page running with the session's cookie. +/// The file as `text/plain` for an explicit top-level navigation. Nothing +/// executes: no first-party page running with the session's cookie. fn inert_response(source: &str) -> Vec { crate::web::common::http::response( "200 OK", diff --git a/src/web/viewer/server/routes.rs b/src/web/viewer/server/routes.rs index be5e3347..dae3d694 100644 --- a/src/web/viewer/server/routes.rs +++ b/src/web/viewer/server/routes.rs @@ -22,8 +22,7 @@ pub(super) fn route(head: &RequestHead, state: &ViewerState) -> Vec { // Everything server-wide the client must agree with rides this one // response rather than getting endpoints of its own: the client // already polls it every few seconds, so a setting changed here - // reaches every device within one interval, and `/api/status` — - // a hot, deduplicated stream — stays free of configuration. + // reaches every device within one interval. let prefs = state.session.prefs().get(); // The remembered project is resolved to an id per response rather // than stored as one, and from the same snapshot as the list it @@ -156,28 +155,27 @@ pub(super) fn route(head: &RequestHead, state: &ViewerState) -> Vec { "/api/preview" => super::preview::route(head, state), "/api/log" => with_repo(head, state, |entry| { let repo = open_repo(&entry.path)?; - // `from` pins the walk so a page fetched later continues the history - // the earlier pages described, even if commits landed meanwhile — - // and a terminal that commits sits right below this list. Absent on - // the first request, which is what establishes the anchor. + // `from` pins the walk so a page fetched later continues the + // history the earlier pages described, even if commits landed + // meanwhile — and a terminal that commits sits right below this + // list. Resolved once, and the walk is then given exactly this + // oid: asking the loader to fall back to HEAD itself would read + // the ref a second time, and a first commit landing between the + // two reads would return commits under an anchor of `None`, which + // the client reads as the end of the history. let skip = optional_count(head, "skip")?; - // Resolved once, and the walk is then given exactly this oid. Asking - // the loader to fall back to HEAD itself would read the ref a second - // time, and a first commit landing between the two reads would - // return commits under an anchor of `None` — which the client reads - // as the end of the history. let anchor = match optional_oid(head, "from")? { Some(oid) => Some(oid), None => diff::head_commit_oid(&repo)?, }; let commits = match anchor { - // One more than a page, so a full page can be told apart from a - // page that happens to end at the last commit. + // One more than a page, so a full page can be told apart from + // a page that happens to end at the last commit. Some(oid) => { diff::load_commit_log_from(&repo, Some(oid), skip, limits::MAX_LOG_PAGE + 1)? } - // No commit to walk from: an unborn HEAD, which is a repository - // with no history rather than an error. + // No commit to walk from: an unborn HEAD, which is a + // repository with no history rather than an error. None => Vec::new(), }; Ok(json_response( @@ -233,10 +231,9 @@ pub(super) fn route(head: &RequestHead, state: &ViewerState) -> Vec { } /// List the server sub-directories under `path` (home when absent) for the -/// folder picker. Directories only, hidden ones skipped; each is flagged when -/// it looks like a git worktree. Deliberately unconfined — the picker browses -/// the server to find a repo to open — but reachable only authenticated and at -/// the same trust as the terminal. +/// folder picker. Directories only, hidden ones skipped. Deliberately +/// unconfined — the picker browses the server to find a repo to open — but +/// reachable only authenticated and at the same trust as the terminal. fn browse(head: &RequestHead) -> Vec { let start = match head.query_param("path").filter(|p| !p.is_empty()) { Some(path) => std::path::PathBuf::from(path), diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index fa61eef3..9f841104 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -1,8 +1,7 @@ //! Per-repo state (`App`) held in a list, with the active tab index on top. //! Closing a tab drops the `App`, which tears down its worker and panes — no //! field-by-field reset to keep in sync. The list may be empty, so `active()` -//! yields an `Option` and the open-repo dialog lives here rather than on a -//! project. +//! yields an `Option`. mod accent; mod path_complete; @@ -125,7 +124,7 @@ impl Workspace { } /// The active project and the dialog together, borrowed from disjoint - /// fields so a frame can render both without a borrow-checker conflict. + /// fields so a frame can render both. pub fn render_parts(&mut self) -> (Option<&mut App>, &RepoInput) { (self.projects.get_mut(self.active), &self.repo_input) } @@ -272,8 +271,7 @@ impl Workspace { // release will route to the newly active project. Deliver it to the // pane that saw the press instead of dropping the record — that PTY // is still alive, and with no release it would sit in a drag or - // selection state, while a leftover record could pair with an - // unrelated release later. + // selection state. self.projects[self.active].release_pending_press_in_place(); self.active = index; self.acknowledge_active_attention(); diff --git a/src/workspace/path_tree.rs b/src/workspace/path_tree.rs index 4ce3fa7d..74c33e40 100644 --- a/src/workspace/path_tree.rs +++ b/src/workspace/path_tree.rs @@ -3,10 +3,9 @@ //! A flat row list, not a nested tree: expanding splices a directory's children //! in after it and collapsing removes the rows below it. The root moves: `←` on //! a collapsed depth-0 row re-roots to the parent. Directories only, and nothing -//! here writes — the browser fills the field, and the field's own Enter stays the -//! single place a repo is actually opened. It deliberately does not reuse -//! `git::tree`, which requires a `git2::Repository` and refuses paths outside a -//! worktree. +//! here writes — the field's own Enter stays the single place a repo is opened. +//! It deliberately does not reuse `git::tree`, which requires a +//! `git2::Repository` and refuses paths outside a worktree. use super::path_complete::{is_sep, read_dir_names, split_dir}; use crate::platform::paths::expand_tilde; @@ -193,8 +192,7 @@ impl PathTree { // Verify the user-notation parent against the real one instead of // trusting the text surgery: `~` has no expressible parent, and neither // does a bare Windows drive. Falling back to the absolute path is the - // one place the dialog rewrites the user's text, because their notation - // cannot name where they just asked to go. + // one place the dialog rewrites the user's text. self.root_text = parent_text(&self.root_text, self.sep) .filter(|t| canonicalizes_to(t, &parent)) .unwrap_or_else(|| parent.to_string_lossy().to_string()); @@ -247,7 +245,7 @@ fn list_rows(dir: &Path, depth: usize) -> Vec { fn parent_text(text: &str, sep: char) -> Option { let t = text.trim_end_matches(is_sep); if t.is_empty() { - // `""` is the cwd, whose parent is `..`. All-separators is the + // `""` is the cwd, whose parent is `..`; all-separators is the // filesystem root, which has no parent for `re_root` to reach. return text.is_empty().then(|| "..".to_string()); } From 2ef10a9a2598982253f1b0329ebfd73d1df90fb7 Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 28 Aug 2026 23:27:54 +0900 Subject: [PATCH 03/42] fix(terminal): reject stale taken resizes --- docs/architecture/session.md | 5 ++ src/session/terminal/hub_helpers.rs | 11 ++++ src/session/terminal/hub_layout.rs | 18 +++--- src/session/terminal/hub_run.rs | 13 ++--- src/session/terminal/size_owner.rs | 24 ++++---- src/session/terminal/tests/backpressure.rs | 67 +++------------------- src/session/terminal/tests/size_owner.rs | 41 +++++++++++++ 7 files changed, 92 insertions(+), 87 deletions(-) diff --git a/docs/architecture/session.md b/docs/architecture/session.md index 576f810e..76d9e6a3 100644 --- a/docs/architecture/session.md +++ b/docs/architecture/session.md @@ -278,6 +278,11 @@ DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이 클라이언트가 옛 크기의 화면을 받지 않게. 반대쪽 기록(alt 밑에 동결된 normal 스냅샷)은 에뮬레이터의 비활성 그리드라 읽을 수 없어 옛 크기로 남는다 — 복귀 후의 출력이 tail로 그 위에 얹히고, 다음 스냅샷 갱신이 마저 고친다. +- **꺼내 둔 resize도 연결 수명을 넘지 못한다.** latest-value map에서 worker가 값을 꺼낸 직후 + 소유자가 떠날 수 있으므로 요청은 원래 connection id를 함께 보존한다. 적용 시 hub state를 먼저 + 잠가 같은 client/connection 등록인지 확인하고, 그 lock 아래 session ownership을 다시 검사한다 + (`state → ownership`, `connect`와 같은 순서). 따라서 disconnect나 소유권 이전이 먼저 완료되면 + 이미 꺼낸 옛 요청도 새 소유자의 PTY에 적용하거나 ACK하지 않는다. - **화면 하나로는 부족하다: `screen` + `since`.** 스냅샷은 worker tick마다 갱신되므로 chunk가 broadcast된 뒤 그것이 스냅샷에 반영되기 전에 클라이언트가 붙을 수 있다. 그래서 스냅샷 이후 broadcast된 바이트를 옆에 함께 들고, replay는 `screen` 다음에 `since`를 보낸다 — 둘을 합치면 diff --git a/src/session/terminal/hub_helpers.rs b/src/session/terminal/hub_helpers.rs index 411e0637..553b3cd0 100644 --- a/src/session/terminal/hub_helpers.rs +++ b/src/session/terminal/hub_helpers.rs @@ -57,6 +57,17 @@ pub(super) struct PendingResize { pub(super) rows: u16, pub(super) cols: u16, pub(super) client: u64, + pub(super) connection: u64, +} + +const COMMANDS_BETWEEN_RESIZES: usize = 64; + +/// Whether a continuously ready command stream has reached the point where +/// pending geometry must run before this next command. +pub(super) fn resize_due_before_command(commands_since_resize: &mut usize) -> bool { + let due = *commands_since_resize == COMMANDS_BETWEEN_RESIZES; + *commands_since_resize = if due { 1 } else { *commands_since_resize + 1 }; + due } /// One startup terminal: the command to run, at the size a client measured, under diff --git a/src/session/terminal/hub_layout.rs b/src/session/terminal/hub_layout.rs index 2feff3d6..1ca69d76 100644 --- a/src/session/terminal/hub_layout.rs +++ b/src/session/terminal/hub_layout.rs @@ -40,6 +40,7 @@ impl TerminalHub { rows, cols, client, + connection, }, ); } @@ -93,19 +94,16 @@ impl TerminalHub { rows, cols, client, + connection, } = resize; - // Asked before the hub's lock, because the answer is the session's and - // taking the two in the other order would invert the ordering `connect` - // uses (hub lock, then ownership). - let Some(connection) = self.connection_of(client) else { - return; - }; - // Not this client's to set. Dropped rather than refused: a client can - // lose the sizing between laying out a frame and this arriving. - if !self.owns_size(connection) { + let mut state = self.state.lock().expect("terminal state poisoned"); + // The queue may already have handed this value to the worker when its + // connection departs. Validate its original registration and ownership + // together while holding state, in the same state -> ownership order as + // `connect`, so a replacement owner cannot inherit the stale request. + if !self.client_owns_size(&state, client, connection) { return; } - let mut state = self.state.lock().expect("terminal state poisoned"); // An unknown pane is ignored rather than errored: a client racing a // pane exit is normal. let Some(p) = state.panes.iter_mut().find(|p| p.id == pane) else { diff --git a/src/session/terminal/hub_run.rs b/src/session/terminal/hub_run.rs index be86e527..3126c42b 100644 --- a/src/session/terminal/hub_run.rs +++ b/src/session/terminal/hub_run.rs @@ -1,6 +1,6 @@ use super::frame::{ServerMessage, TerminalFrame}; use super::hub_diag::ClearWatch; -use super::hub_helpers::{Command, broadcast_locked}; +use super::hub_helpers::{Command, broadcast_locked, resize_due_before_command}; use super::hub_modes::PaneModeTracker; use super::hub_plugins::Plugins; use super::{DEFAULT_PANE_SIZE, TerminalHub}; @@ -13,7 +13,6 @@ use std::thread; use std::time::{Duration, Instant}; const POLL_INTERVAL: Duration = Duration::from_millis(8); -const COMMANDS_BETWEEN_RESIZES: usize = 64; impl TerminalHub { pub(super) fn run(&self, cwd: &str, commands: Receiver, stop: Arc) { @@ -29,13 +28,11 @@ impl TerminalHub { while !stop.load(Ordering::Acquire) { let mut commands_since_resize = 0; while let Ok(command) = commands.try_recv() { - if commands_since_resize == COMMANDS_BETWEEN_RESIZES { + if resize_due_before_command(&mut commands_since_resize) { for resize in self.take_pending_resizes() { self.resize_pane(&mut backend, &mut modes, resize); } - commands_since_resize = 0; } - commands_since_resize += 1; match command { Command::Create { rows, @@ -105,9 +102,9 @@ impl TerminalHub { } } - // Resize is latest-value state, not a byte stream. Also processed - // above after each command budget so a producer that continuously - // refills the bounded queue cannot starve the final geometry. + // Resize is latest-value state, not a byte stream. The interleave + // above also observes it after each command budget so a producer + // that continuously refills the queue cannot starve final geometry. for resize in self.take_pending_resizes() { self.resize_pane(&mut backend, &mut modes, resize); } diff --git a/src/session/terminal/size_owner.rs b/src/session/terminal/size_owner.rs index be778180..affd8c46 100644 --- a/src/session/terminal/size_owner.rs +++ b/src/session/terminal/size_owner.rs @@ -32,20 +32,24 @@ impl TerminalHub { self.ownership.owns(connection) } - /// This hub client's ownership registration, or `None` once it has gone. + /// Whether a queued request still belongs to this live hub client and that + /// connection still owns the sizing. /// - /// The two ids are separate on purpose (see [`Client::connection`]), and a - /// command carries the hub's — it was queued by a connection thread and the - /// worker reads it a tick later, by which time that connection may be gone. + /// Called with `state` locked so disconnect and ownership transfer cannot + /// split identity validation from authorization. This preserves the lock + /// order used by `connect`: hub state, then session ownership. /// /// [`Client::connection`]: super::session::Client::connection - pub(super) fn connection_of(&self, client: u64) -> Option { - self.state - .lock() - .expect("terminal state poisoned") + pub(super) fn client_owns_size( + &self, + state: &super::hub_helpers::Shared, + client: u64, + connection: u64, + ) -> bool { + state .clients .iter() - .find(|c| c.id == client) - .map(|c| c.connection) + .any(|c| c.id == client && c.connection == connection) + && self.owns_size(connection) } } diff --git a/src/session/terminal/tests/backpressure.rs b/src/session/terminal/tests/backpressure.rs index d0dcd181..1add9a99 100644 --- a/src/session/terminal/tests/backpressure.rs +++ b/src/session/terminal/tests/backpressure.rs @@ -3,10 +3,8 @@ use super::{attach, attach_over_socket, created_pane, next_matching, spawn_hub}; use crate::session::terminal::CLIENT_QUEUE_DEPTH; use crate::session::terminal::frame::ClientMessage; -use crate::session::terminal::hub_helpers::Command; +use crate::session::terminal::hub_helpers::resize_due_before_command; use std::io::Read; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; #[test] fn a_client_that_stops_draining_has_its_connection_ended() { @@ -129,64 +127,15 @@ fn the_final_resize_survives_a_full_command_queue() { } #[test] -fn resize_progresses_while_command_producers_stay_busy() { - let dir = tempfile::TempDir::new().unwrap(); - let hub = spawn_hub(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); - let session = Arc::new(attach(&hub)); - session.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); - let pane = next_matching(&session, |frame| created_pane(frame).is_some()) - .and_then(|frame| created_pane(&frame)) - .expect("no created message"); - - let running = Arc::new(AtomicBool::new(true)); - let accepted = Arc::new(AtomicUsize::new(0)); - let producers: Vec<_> = (0..4) - .map(|_| { - let commands = hub.commands.clone(); - let running = Arc::clone(&running); - let accepted = Arc::clone(&accepted); - std::thread::spawn(move || { - while running.load(Ordering::Acquire) { - if commands - .try_send(Command::Reorder { order: vec![pane] }) - .is_ok() - { - accepted.fetch_add(1, Ordering::Relaxed); - } - } - }) - }) - .collect(); - let busy = super::wait_for(|| { - (accepted.load(Ordering::Relaxed) >= CLIENT_QUEUE_DEPTH * 4).then_some(()) - }) - .is_some(); - - session.dispatch(ClientMessage::Resize { - pane, - rows: 30, - cols: 120, - }); - let resized = super::wait_for(|| { - session - .next_frame(std::time::Duration::from_millis(20)) - .and_then(|frame| super::resized_size(&frame)) - .filter(|size| *size == (30, 120)) - }) - .is_some(); - - running.store(false, Ordering::Release); - for producer in producers { - producer.join().expect("command producer panicked"); +fn the_sixty_fifth_ready_command_yields_to_a_resize() { + let mut commands_since_resize = 0; + for _ in 0..64 { + assert!(!resize_due_before_command(&mut commands_since_resize)); } - hub.stop(); - assert!( - busy, - "the producer must exercise a sustained command stream" - ); + assert!( - resized, - "continuous commands must not starve a pending resize" + resize_due_before_command(&mut commands_since_resize), + "a continuously non-empty queue must yield before its next command" ); } diff --git a/src/session/terminal/tests/size_owner.rs b/src/session/terminal/tests/size_owner.rs index 479bf2d1..e24bf045 100644 --- a/src/session/terminal/tests/size_owner.rs +++ b/src/session/terminal/tests/size_owner.rs @@ -5,8 +5,10 @@ //! is one value with one owner, and these are the rules for who holds it. use super::{attach, next_matching, resized_size, spawn_hub}; +use crate::backend::PtyBackend; use crate::config::ShellConfig; use crate::session::terminal::frame::{ClientMessage, PaneSize, TerminalFrame}; +use crate::session::terminal::hub_modes::PaneModeTracker; use crate::session::terminal::{TerminalHub, TerminalSession}; /// Whether a frame says this session owns the sizing. @@ -220,3 +222,42 @@ fn only_the_owner_resizes_the_pty_and_everyone_is_told_the_size() { assert_eq!(told, (30, 100)); hub.stop(); } + +#[test] +fn a_taken_resize_from_a_disconnected_owner_is_not_applied() { + let dir = tempfile::TempDir::new().unwrap(); + let cwd = dir.path().to_string_lossy(); + let hub = spawn_hub(&cwd, Vec::new(), Vec::new()); + hub.stop(); + let old_owner = attach(&hub); + assert!(verdict(&old_owner)); + hub.register_pane(7, 24, 80, None, None); + + old_owner.dispatch(ClientMessage::Resize { + pane: 7, + rows: 24, + cols: 80, + }); + let old_connection = old_owner.connection; + let stale = hub + .take_pending_resizes() + .pop() + .expect("the worker must have taken the old request"); + assert_eq!(stale.connection, old_connection); + drop(old_owner); + let new_owner = attach(&hub); + assert!(verdict(&new_owner)); + + let mut backend = PtyBackend::new(dir.path(), ShellConfig::default()); + let mut modes = PaneModeTracker::default(); + hub.resize_pane(&mut backend, &mut modes, stale); + + assert!( + new_owner + .next_frame(QUIET) + .as_ref() + .and_then(resized_size) + .is_none(), + "a replacement owner must not receive an ACK for a stale request" + ); +} From ee98d07277ac4feff452a320077528cb454360ec Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 28 Aug 2026 23:50:28 +0900 Subject: [PATCH 04/42] test(terminal): cover resize disconnect race --- src/session/terminal/hub_connect.rs | 25 +++++-- src/session/terminal/hub_layout.rs | 2 + src/session/terminal/mod.rs | 38 +++++++++++ src/session/terminal/tests/mod.rs | 1 + src/session/terminal/tests/size_owner.rs | 41 ------------ .../terminal/tests/size_owner_resize_race.rs | 65 +++++++++++++++++++ 6 files changed, 126 insertions(+), 46 deletions(-) create mode 100644 src/session/terminal/tests/size_owner_resize_race.rs diff --git a/src/session/terminal/hub_connect.rs b/src/session/terminal/hub_connect.rs index 47dccb68..d302a5be 100644 --- a/src/session/terminal/hub_connect.rs +++ b/src/session/terminal/hub_connect.rs @@ -173,11 +173,26 @@ impl TerminalHub { /// meant an evicted client never released the sizing — no other screen /// could take it back after its page had closed. pub(super) fn disconnect(&self, id: u64, connection: u64) { - self.state - .lock() - .expect("terminal state poisoned") - .clients - .retain(|c| c.id != id); + #[cfg(test)] + let mut state = match self.state.try_lock() { + Ok(state) => { + self.run_concurrency_test_hook( + super::ConcurrencyTestPoint::DisconnectStateAcquired, + ); + state + } + Err(std::sync::TryLockError::WouldBlock) => { + self.run_concurrency_test_hook( + super::ConcurrencyTestPoint::DisconnectStateContended, + ); + self.state.lock().expect("terminal state poisoned") + } + Err(std::sync::TryLockError::Poisoned(_)) => panic!("terminal state poisoned"), + }; + #[cfg(not(test))] + let mut state = self.state.lock().expect("terminal state poisoned"); + state.clients.retain(|c| c.id != id); + drop(state); // Off the hub's lock: what happens to the sizing is the session's // business, and it may have to tell clients on other hubs. Unconditional // — `leave` ignores a connection it does not know, which is the case diff --git a/src/session/terminal/hub_layout.rs b/src/session/terminal/hub_layout.rs index 1ca69d76..efaf5ab4 100644 --- a/src/session/terminal/hub_layout.rs +++ b/src/session/terminal/hub_layout.rs @@ -97,6 +97,8 @@ impl TerminalHub { connection, } = resize; let mut state = self.state.lock().expect("terminal state poisoned"); + #[cfg(test)] + self.run_concurrency_test_hook(super::ConcurrencyTestPoint::BeforeResizeValidation); // The queue may already have handed this value to the worker when its // connection departs. Validate its original registration and ownership // together while holding state, in the same state -> ownership order as diff --git a/src/session/terminal/mod.rs b/src/session/terminal/mod.rs index 695966c2..6e6a751f 100644 --- a/src/session/terminal/mod.rs +++ b/src/session/terminal/mod.rs @@ -54,6 +54,17 @@ use std::sync::mpsc::{self, SyncSender}; use std::sync::{Arc, Mutex}; use std::thread; +#[cfg(test)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ConcurrencyTestPoint { + BeforeResizeValidation, + DisconnectStateAcquired, + DisconnectStateContended, +} + +#[cfg(test)] +type ConcurrencyTestHook = Arc; + /// Output frames a client may fall behind by before it is dropped. pub(crate) const CLIENT_QUEUE_DEPTH: usize = 256; @@ -86,6 +97,8 @@ pub struct TerminalHub { /// Which screen the session's panes are fitted to — shared with every other /// hub because the answer is one per session, not one per repository. ownership: Arc, + #[cfg(test)] + concurrency_test_hook: Mutex>, } impl TerminalHub { @@ -119,6 +132,8 @@ impl TerminalHub { shell, started: AtomicBool::new(false), ownership, + #[cfg(test)] + concurrency_test_hook: Mutex::new(None), }); let worker_hub = Arc::clone(&hub); @@ -162,6 +177,29 @@ impl TerminalHub { .len() } + #[cfg(test)] + pub(super) fn set_concurrency_test_hook( + &self, + hook: impl Fn(ConcurrencyTestPoint) + Send + Sync + 'static, + ) { + *self + .concurrency_test_hook + .lock() + .expect("terminal concurrency test hook poisoned") = Some(Arc::new(hook)); + } + + #[cfg(test)] + pub(super) fn run_concurrency_test_hook(&self, point: ConcurrencyTestPoint) { + let hook = self + .concurrency_test_hook + .lock() + .expect("terminal concurrency test hook poisoned") + .clone(); + if let Some(hook) = hook { + hook(point); + } + } + pub fn stop(&self) { self.stop.store(true, Ordering::Release); let handle = self diff --git a/src/session/terminal/tests/mod.rs b/src/session/terminal/tests/mod.rs index a1c663ab..f84eeae4 100644 --- a/src/session/terminal/tests/mod.rs +++ b/src/session/terminal/tests/mod.rs @@ -19,6 +19,7 @@ mod screen_records; mod screen_replay; mod scrollback_depth; mod size_owner; +mod size_owner_resize_race; mod startup; mod wire; mod zoom; diff --git a/src/session/terminal/tests/size_owner.rs b/src/session/terminal/tests/size_owner.rs index e24bf045..479bf2d1 100644 --- a/src/session/terminal/tests/size_owner.rs +++ b/src/session/terminal/tests/size_owner.rs @@ -5,10 +5,8 @@ //! is one value with one owner, and these are the rules for who holds it. use super::{attach, next_matching, resized_size, spawn_hub}; -use crate::backend::PtyBackend; use crate::config::ShellConfig; use crate::session::terminal::frame::{ClientMessage, PaneSize, TerminalFrame}; -use crate::session::terminal::hub_modes::PaneModeTracker; use crate::session::terminal::{TerminalHub, TerminalSession}; /// Whether a frame says this session owns the sizing. @@ -222,42 +220,3 @@ fn only_the_owner_resizes_the_pty_and_everyone_is_told_the_size() { assert_eq!(told, (30, 100)); hub.stop(); } - -#[test] -fn a_taken_resize_from_a_disconnected_owner_is_not_applied() { - let dir = tempfile::TempDir::new().unwrap(); - let cwd = dir.path().to_string_lossy(); - let hub = spawn_hub(&cwd, Vec::new(), Vec::new()); - hub.stop(); - let old_owner = attach(&hub); - assert!(verdict(&old_owner)); - hub.register_pane(7, 24, 80, None, None); - - old_owner.dispatch(ClientMessage::Resize { - pane: 7, - rows: 24, - cols: 80, - }); - let old_connection = old_owner.connection; - let stale = hub - .take_pending_resizes() - .pop() - .expect("the worker must have taken the old request"); - assert_eq!(stale.connection, old_connection); - drop(old_owner); - let new_owner = attach(&hub); - assert!(verdict(&new_owner)); - - let mut backend = PtyBackend::new(dir.path(), ShellConfig::default()); - let mut modes = PaneModeTracker::default(); - hub.resize_pane(&mut backend, &mut modes, stale); - - assert!( - new_owner - .next_frame(QUIET) - .as_ref() - .and_then(resized_size) - .is_none(), - "a replacement owner must not receive an ACK for a stale request" - ); -} diff --git a/src/session/terminal/tests/size_owner_resize_race.rs b/src/session/terminal/tests/size_owner_resize_race.rs new file mode 100644 index 00000000..8915fdd7 --- /dev/null +++ b/src/session/terminal/tests/size_owner_resize_race.rs @@ -0,0 +1,65 @@ +use super::{SHELL_TEST_DEADLINE, next_matching, resized_size, spawn_hub}; +use crate::backend::PtyBackend; +use crate::config::ShellConfig; +use crate::session::size_owner::ViewerId; +use crate::session::terminal::ConcurrencyTestPoint; +use crate::session::terminal::frame::ClientMessage; +use crate::session::terminal::hub_modes::PaneModeTracker; +use std::sync::{Arc, Barrier, mpsc}; + +#[test] +fn a_taken_resize_linearizes_before_a_racing_disconnect() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = spawn_hub(&dir.path().to_string_lossy(), Vec::new(), Vec::new()); + hub.stop(); + let viewer = ViewerId::Browser("resize-race".to_string()); + let old_owner = hub.connect(viewer.clone(), true, None); + let observer = hub.connect(viewer, false, None); + hub.register_pane(7, 24, 80, None, None); + + old_owner.dispatch(ClientMessage::Resize { + pane: 7, + rows: 24, + cols: 80, + }); + let resize = hub + .take_pending_resizes() + .pop() + .expect("the worker must have taken the old request"); + + let (events_tx, events_rx) = mpsc::channel(); + let release_resize = Arc::new(Barrier::new(2)); + let hook_release = Arc::clone(&release_resize); + hub.set_concurrency_test_hook(move |point| { + let _ = events_tx.send(point); + if point == ConcurrencyTestPoint::BeforeResizeValidation { + hook_release.wait(); + } + }); + + let resizing_hub = Arc::clone(&hub); + let backend_dir = dir.path().to_path_buf(); + let resizing = std::thread::spawn(move || { + let mut backend = PtyBackend::new(&backend_dir, ShellConfig::default()); + let mut modes = PaneModeTracker::default(); + resizing_hub.resize_pane(&mut backend, &mut modes, resize); + }); + assert_eq!( + events_rx.recv_timeout(SHELL_TEST_DEADLINE).unwrap(), + ConcurrencyTestPoint::BeforeResizeValidation + ); + + let disconnecting = std::thread::spawn(move || drop(old_owner)); + assert_eq!( + events_rx.recv_timeout(SHELL_TEST_DEADLINE).unwrap(), + ConcurrencyTestPoint::DisconnectStateContended, + "disconnect must wait behind the resize's registration and ownership check" + ); + release_resize.wait(); + resizing.join().expect("resize thread panicked"); + disconnecting.join().expect("disconnect thread panicked"); + + let applied = next_matching(&observer, |frame| resized_size(frame).is_some()) + .and_then(|frame| resized_size(&frame)); + assert_eq!(applied, Some((24, 80))); +} From 0b3eaab7b9e198971f43b22eea641a92d3072bef Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 00:17:40 +0900 Subject: [PATCH 05/42] refactor(comments): keep only rationale comments in app session IO --- src/app/session_io.rs | 49 +++++++++++++++++-------------------------- 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/src/app/session_io.rs b/src/app/session_io.rs index 34688cfd..302cd357 100644 --- a/src/app/session_io.rs +++ b/src/app/session_io.rs @@ -38,17 +38,13 @@ impl App { } } - // Runs synchronously at startup (before the first snapshot) to stop the - // fresh-launch terminal focus from briefly drawing — and routing keystrokes - // — over a saved `FileList`/`DiffViewer` focus. Idempotent: `restore_session` - // re-applies it once the snapshot arrives, a no-op against the same state. + // Runs before the first snapshot so a fresh launch's terminal focus never + // briefly draws — or routes keystrokes — over a restored list/diff focus. + // Idempotent: `restore_session` re-applies it once the snapshot arrives. pub(crate) fn restore_pane_focus(&mut self, state: &SessionState) { - // Everything below that points *at a pane* — which one was active, the - // fullscreen panel, terminal focus — has nothing to point at until the - // session reports its panes, and this runs before that. Held so it can - // be applied for real when they arrive rather than quietly downgraded - // against an empty list; the rest (mode fullscreens, a focus elsewhere) - // takes effect now. + // Pane-pointing state (active pane, terminal fullscreen, terminal + // focus) means nothing until the session reports its panes, so hold it + // in `pending_terminal` rather than quietly downgrading it against an empty list. self.pending_terminal = self.terminal.panes.is_empty().then(|| state.clone()); self.terminal.active = state .active_pane @@ -85,12 +81,9 @@ impl App { } } - // Runs as soon as the session is loaded, not on the first snapshot. Almost - // none of it needs to wait: panes/focus/fullscreen need no data, and Log - // and Tree read what they need directly. Status mode's selection is the - // one exception, held in `pending_selection` until the changed files arrive - // — that deferral can't collide with user input: there's no way to pick a - // file out of a list that's still empty. + // Runs as soon as the session loads, not on the first snapshot: only + // Status's selection needs snapshot data, so it waits in + // `pending_selection` — an empty list can't collide with user input. pub fn restore_session(&mut self, state: &SessionState) { self.restore_pane_focus(state); @@ -139,11 +132,10 @@ impl App { // Restoring expansion mutates the cache/expanded set; drop the stale // row-width bound so horizontal scroll clamps to the restored rows. self.tree_view.row_width_cache.set(None); - // The session file is an on-disk boundary: drop any entry that isn't a - // safe repo-internal relative path so a hand-edited `..` or absolute - // path can't drive a directory read outside the working tree. - // `refresh_tree_cache` prunes any that no longer exist on disk, so a - // stale expansion can't surface a "tree error". + // The session file is an on-disk boundary: keep only safe + // repo-internal relative paths, so a hand-edited `..` or absolute path + // can't drive a directory read outside the working tree. + // (`refresh_tree_cache` prunes entries missing on disk.) self.tree_view.expanded = state .tree_expanded .iter() @@ -151,7 +143,6 @@ impl App { .cloned() .collect(); self.refresh_tree_cache(); - // Restore the cursor by path when it still resolves to a visible row. if let Some(path) = &state.tree_selected_path { let rows = self.tree_view.visible_rows(); if let Some(idx) = rows.iter().position(|r| &r.path == path) { @@ -164,10 +155,9 @@ impl App { } fn restore_log_session(&mut self, state: &SessionState) { - // A page worker launched before the restore (e.g. via `toggle_mode` - // earlier in this frame) would race against the fresh `set_commits` - // below: its reply would be matched by `loaded_count` and silently - // appended over the restored list. Cancel before mutating state. + // A page worker launched before the restore would race against the + // fresh `set_commits` below: its reply would be silently appended over + // the restored list. Cancel before mutating state. self.cancel_commit_log_page_fetch(); let page_size = self.pagination.page_size; let commits = match self.with_repo(|repo| load_commit_log(repo, page_size)) { @@ -203,10 +193,9 @@ impl App { let (oid, title) = match self.log_view.commits.get(self.log_view.selected) { Some(entry) => (entry.oid, entry.to_string()), None => { - // Saved drill-down pointed at a commit that's no longer in the - // loaded first page (history rewrite, force-push) — surface - // this so the user knows why they're back at the commit-level - // view instead of where they left off. + // Saved drill-down pointed at a commit no longer in the loaded + // first page (history rewrite, force-push) — surface why the + // user is back at the commit-level view, not where they left off. tracing::warn!( selected = self.log_view.selected, "drill-down restore: saved commit index is out of range" From 7306c9009d7dabcd39cd219ae46e7528e37642e4 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 00:17:45 +0900 Subject: [PATCH 06/42] refactor(comments): keep only rationale comments in app --- src/app/app_impl.rs | 24 ++++++++++-------------- src/app/auto_follow.rs | 7 +++---- src/app/commit_log_fetch.rs | 6 +++--- src/app/file_view_load.rs | 12 ++++-------- src/app/focus.rs | 2 -- src/app/tree.rs | 3 +-- 6 files changed, 21 insertions(+), 33 deletions(-) diff --git a/src/app/app_impl.rs b/src/app/app_impl.rs index 4fe20ab6..f2ab0453 100644 --- a/src/app/app_impl.rs +++ b/src/app/app_impl.rs @@ -47,10 +47,13 @@ impl App { } } - // NOT called for keys forwarded to a PTY: in a terminal pane every - // keystroke is passthrough, so dismissing on those would make a notice - // vanish the instant the user resumed typing. - // + // Only reached for keys nightcrow itself acts on (the dispatch gate + // excludes PTY passthrough), so typing in a terminal pane never blanks a + // notice. + pub fn dismiss_notice_on_app_input(&mut self) { + self.notice = None; + } + // Used when the press can no longer be paired with a real release (the // project is leaving the screen) but the PTY is still alive — dropping the // record would leave that program in a drag/selection state with no @@ -61,17 +64,10 @@ impl App { } } - pub fn dismiss_notice_on_app_input(&mut self) { - self.notice = None; - } - /// Build a project view on `repo_path`, with `backend` behind its terminal - /// panes. - /// - /// The backend comes from the caller because where the panes live is not - /// this type's decision: they belong to the session the daemon owns, and - /// only the client that connected to it can hand over the right end of that - /// connection. + /// panes. The backend comes from the caller: the panes belong to the + /// session the daemon owns, and only the client connected to it can hand + /// over the right end of that connection. pub fn new( repo_path: String, prompt_log: bool, diff --git a/src/app/auto_follow.rs b/src/app/auto_follow.rs index d9aa4878..464f10a0 100644 --- a/src/app/auto_follow.rs +++ b/src/app/auto_follow.rs @@ -53,10 +53,9 @@ impl App { let Some(&mtime) = self.status_view.hot_table.get(&file.path) else { continue; }; - // `duration_since` returns Err when `mtime > now` (clock skew on - // NFS, VMs, future-stamped files). Treating those as in-window - // would pin auto-follow to one bogus file forever; drop them - // entirely — recovery is automatic once the real clock catches up. + // Future mtimes (clock skew on NFS, VMs) would pin auto-follow to + // one bogus file forever; drop them — recovery is automatic once + // the real clock catches up. let Ok(age) = now.duration_since(mtime) else { continue; }; diff --git a/src/app/commit_log_fetch.rs b/src/app/commit_log_fetch.rs index 1721c4ba..68e5084b 100644 --- a/src/app/commit_log_fetch.rs +++ b/src/app/commit_log_fetch.rs @@ -98,9 +98,9 @@ impl App { match rx.try_recv() { Ok(msg) => { self.pagination.page_rx = None; - // Worker just sent → one statement from returning. A short - // timed join reaps the OS thread now; the timeout means a - // wedged worker still can't stall the frame. + // The worker just sent, so its next blocking point is gone; a + // short timed join reaps it now, and the timeout keeps a + // wedged worker from stalling the frame. if let Some(h) = self.pagination.handle.take() { try_timed_join(h, REAP_TIMEOUT); } diff --git a/src/app/file_view_load.rs b/src/app/file_view_load.rs index 2532518e..f7afe535 100644 --- a/src/app/file_view_load.rs +++ b/src/app/file_view_load.rs @@ -127,14 +127,10 @@ impl App { /// Step to the next display: unified → split → file → unified. /// - /// `v` and `s` each toggle one view against the unified default, which - /// leaves the third one undiscoverable unless you already know it exists. - /// One key that walks all three makes the set visible; the direct toggles - /// stay for jumping straight to a known view. - /// - /// The file step is skipped when there is nothing to open (no selection, or - /// a commit whose file cannot be resolved) rather than being a dead press — - /// the same gate `can_open_file_view` puts on `v`. + /// `v` and `s` each toggle one view against the unified default, leaving + /// the third undiscoverable; one key that walks all three makes the set + /// visible. The file step is skipped when there is nothing to open — the + /// same gate `can_open_file_view` puts on `v`. pub fn cycle_diff_view(&mut self) { // Tree mode's right pane is always the raw file preview, so there is // no cycle to walk — matching `v`/`s`. diff --git a/src/app/focus.rs b/src/app/focus.rs index 44ff1aa9..e2279a91 100644 --- a/src/app/focus.rs +++ b/src/app/focus.rs @@ -31,8 +31,6 @@ impl App { tracing::debug!(from = ?from, to = ?self.mode, "view mode toggled"); } - // Reuses cached commit pages when they still match the latest HEAD; - // otherwise refreshes in the background. fn enter_log_mode(&mut self) { self.mode = ViewMode::Log; self.log_view.reset_drill_down(); diff --git a/src/app/tree.rs b/src/app/tree.rs index 1913fb9e..9c1394f6 100644 --- a/src/app/tree.rs +++ b/src/app/tree.rs @@ -1,8 +1,7 @@ //! `App` methods for the read-only file-tree navigator (`ViewMode::Tree`). //! //! Directory I/O is synchronous on the UI thread (one level per expansion); -//! the git-status snapshot worker is never involved. Selecting a file row -//! loads its raw contents into the existing file-view pane. +//! the git-status snapshot worker is never involved. use super::{App, DiffPaneView, FileViewKey, FileViewState, NoticeKind, ViewMode}; use std::collections::BTreeSet; From 903db4e99ea4e54957a809d027cac19f50bceb2f Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 28 Aug 2026 23:55:53 +0900 Subject: [PATCH 07/42] refactor(comments): keep only rationale comments in ui --- src/ui/commit_list/mod.rs | 8 ++--- src/ui/commit_list/row.rs | 6 ++-- src/ui/diff_pane/highlight.rs | 11 ++++--- src/ui/diff_pane/mod.rs | 28 +++++++++--------- src/ui/diff_pane/pane_impl.rs | 22 +++++++------- src/ui/diff_pane/search.rs | 5 ++-- src/ui/diff_viewer/file_view.rs | 17 ++++------- src/ui/diff_viewer/gutter.rs | 6 ++-- src/ui/diff_viewer/mod.rs | 30 +++++++++---------- src/ui/diff_viewer/split_view.rs | 16 ++++------ src/ui/file_list.rs | 11 ++++--- src/ui/file_view.rs | 27 +++++++---------- src/ui/helpers.rs | 6 ++-- src/ui/hint_bar.rs | 15 +++++----- src/ui/hint_text.rs | 13 ++++---- src/ui/log_view/mod.rs | 2 +- src/ui/mod.rs | 25 +++++++--------- src/ui/notice.rs | 51 +++++++++++++------------------- src/ui/path_tree.rs | 9 ++---- src/ui/project_tab/mod.rs | 14 ++++----- src/ui/repo_dialog.rs | 11 +++---- src/ui/search.rs | 3 +- src/ui/splash.rs | 2 +- src/ui/status_view.rs | 2 +- src/ui/terminal_tab/cells.rs | 4 +-- src/ui/terminal_tab/layout.rs | 18 +++++------ src/ui/terminal_tab/mod.rs | 12 ++++---- src/ui/terminal_tab/recovery.rs | 30 +++++++------------ src/ui/terminal_tab/screen.rs | 13 ++++---- src/ui/terminal_tab/tab_bar.rs | 12 ++++---- src/ui/tree_list.rs | 21 ++++++------- src/ui/tree_view/mod.rs | 10 +++---- src/ui/wall_clock.rs | 7 ++--- 33 files changed, 195 insertions(+), 272 deletions(-) diff --git a/src/ui/commit_list/mod.rs b/src/ui/commit_list/mod.rs index b9ed1bf0..dbc8e568 100644 --- a/src/ui/commit_list/mod.rs +++ b/src/ui/commit_list/mod.rs @@ -161,11 +161,9 @@ fn render_file_list(frame: &mut Frame, app: &App, area: Rect, accent: Color) { } } -/// Char budget for the drill-down title inside `area`. Reserves two cells -/// for the surrounding border corners. The title is then measured in chars -/// (not display width), matching the trade-off documented on -/// `terminal_tab::truncate_tab_title`: ASCII summaries are the common case -/// and CJK titles render slightly under the visual budget. +/// Char budget for the drill-down title inside `area`, reserving two cells +/// for the border corners. Measured in chars (not display width), matching +/// the trade-off documented on `terminal_tab::truncate_tab_title`. fn title_budget(width: u16) -> usize { (width as usize).saturating_sub(2) } diff --git a/src/ui/commit_list/row.rs b/src/ui/commit_list/row.rs index 4ee210f0..dd268c90 100644 --- a/src/ui/commit_list/row.rs +++ b/src/ui/commit_list/row.rs @@ -15,7 +15,7 @@ const SECS_PER_YEAR: i64 = SECS_PER_DAY * 365; /// Terminal width at which the row switches to absolute time, full author, and /// untruncated ref chips. A width rule rather than the `list_fullscreen` flag: /// a wide monitor has the room outside fullscreen too, and it keeps the -/// decision to one threshold — the same shape as `diff_viewer::MIN_SPLIT_WIDTH`. +/// decision to one threshold. pub(super) const MIN_DETAIL_WIDTH: u16 = 120; const AUTHOR_WIDTH: usize = 10; @@ -128,8 +128,8 @@ pub(super) fn commit_row<'a>( spans.push(Span::styled(format!("{id} "), Style::default().fg(accent))); if wide { - // A timestamp the platform cannot place renders as blanks rather than a - // wrong date, keeping the column aligned. Same contract as `wall_clock`. + // A timestamp the platform cannot place renders as blanks rather than + // a wrong date, keeping the column aligned. let stamp = local_date_time(entry.time).unwrap_or_else(|| " ".repeat(16)); spans.push(Span::styled( format!("{stamp} "), diff --git a/src/ui/diff_pane/highlight.rs b/src/ui/diff_pane/highlight.rs index 5fa4ff2e..55419907 100644 --- a/src/ui/diff_pane/highlight.rs +++ b/src/ui/diff_pane/highlight.rs @@ -1,9 +1,8 @@ /// Syntect theme name used for both the diff and file-view highlight caches. pub const DIFF_THEME: &str = "base16-ocean.dark"; -/// One highlighted segment of a body line: foreground RGB + the text. Cached -/// so per-frame rendering does not re-run the syntect highlighter over the -/// whole document for state recovery. +/// One highlighted segment of a body line: foreground RGB + the text, cached +/// so per-frame rendering does not re-run the syntect highlighter. #[derive(Debug, Clone)] pub struct HighlightSegment { pub rgb: (u8, u8, u8), @@ -11,9 +10,9 @@ pub struct HighlightSegment { } /// Run a single line through the supplied syntect highlighter and convert the -/// result into `HighlightSegment`s. Falls back to a single grey segment on -/// highlighter error. Shared by `DiffPane` and `FileViewState` so both caches -/// build segments identically. +/// result into `HighlightSegment`s, falling back to a single grey segment on +/// error. Shared by `DiffPane` and `FileViewState` so both caches build +/// segments identically. pub(crate) fn highlight_line_segments( hl: &mut syntect::easy::HighlightLines, ss: &syntect::parsing::SyntaxSet, diff --git a/src/ui/diff_pane/mod.rs b/src/ui/diff_pane/mod.rs index 022b1ecf..005cda33 100644 --- a/src/ui/diff_pane/mod.rs +++ b/src/ui/diff_pane/mod.rs @@ -26,8 +26,8 @@ pub enum DiffPaneView { /// One row of the side-by-side layout. `Header` carries the hunk index whose /// `@@ ... @@` spans the full width; `Body` carries the (hunk, line) /// coordinates on each side, with `None` marking a blank padding cell. -/// Coordinates index into `DiffPane::hunks` (and `line_highlights`) so the -/// renderer reuses the prebuilt highlight cache without re-running syntect. +/// Coordinates index into `DiffPane::hunks` so the renderer reuses the +/// prebuilt highlight cache without re-running syntect. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SplitRow { Header(usize), @@ -42,29 +42,29 @@ pub enum SplitRow { #[derive(Default)] pub struct DiffPane { pub hunks: Vec, - /// Lowercased copy of each `DiffLine::content` aligned with `hunks`. - /// Built once per diff load so per-keystroke search does not re-lowercase. + /// Lowercased copy of each `DiffLine::content` aligned with `hunks`, + /// built once per diff load so per-keystroke search does not + /// re-lowercase. pub(crate) hunks_lines_lower: Vec>, - /// Cached syntect highlight output per body line, same shape as - /// `hunks_lines_lower`. Built once when hunks (or the active syntax) - /// change so the renderer skips the full-document state-recovery pass. + /// Cached syntect highlight output per body line. Built once when hunks + /// (or the active syntax) change so the renderer skips the full-document + /// state-recovery pass. pub line_highlights: Vec>>, /// Per-hunk syntax name at the time `line_highlights` was built. A commit /// diff can touch files of different types, each needing its own - /// highlighter state. Empty means the cache is unbuilt or invalidated. + /// highlighter state. pub cached_hunk_syntax: Vec, - /// Sum of `line.content.len()` across all hunk lines at cache build time. - /// Pairs with the shape check so a same-line-count hunk replacement still + /// Sum of `line.content.len()` across all hunk lines at cache build time; + /// pairs with the shape check so a same-line-count hunk replacement still /// invalidates the cache. pub(crate) cached_content_bytes: usize, pub scroll: usize, pub scroll_x: usize, /// Soft-wrap long lines instead of letting them run off the right edge. - /// /// Mutually exclusive with horizontal scrolling by construction, not by - /// choice: ratatui's `Paragraph` ignores its `scroll.x` once wrapping is on. - /// The split view ignores this entirely — halves that wrap to different - /// heights would stop lining up, which is the whole point of that layout. + /// choice: ratatui's `Paragraph` ignores its `scroll.x` once wrapping is + /// on. The split view ignores this entirely — halves that wrap to + /// different heights would stop lining up. pub wrap: bool, pub search: DiffSearch, pub view: DiffPaneView, diff --git a/src/ui/diff_pane/pane_impl.rs b/src/ui/diff_pane/pane_impl.rs index f9290cfc..14b7d473 100644 --- a/src/ui/diff_pane/pane_impl.rs +++ b/src/ui/diff_pane/pane_impl.rs @@ -38,8 +38,7 @@ impl DiffPane { /// Build the side-by-side row layout from the current hunks. Within each /// hunk, consecutive removed/added lines are paired index-by-index (the - /// shorter run padded with blank cells), and context lines are mirrored. - /// Cheap to recompute: it only walks line kinds and stores coordinates. + /// shorter run padded with blank cells) and context lines are mirrored. pub fn split_rows(&self) -> Vec { let mut rows = Vec::new(); for (hi, hunk) in self.hunks.iter().enumerate() { @@ -111,8 +110,8 @@ impl DiffPane { /// over precomputed strings. `scroll_to_match=true` jumps the viewport to /// the current cursor's match (after a keystroke); `false` keeps the /// viewport pinned and re-anchors `cursor` to the nearest match (a - /// content-only refresh, e.g. a background snapshot tick while a query is - /// active, so the next `n`/`p` does not jump unexpectedly). + /// content-only refresh, e.g. a background snapshot tick, so the next + /// `n`/`p` does not jump unexpectedly). pub fn recompute_matches(&mut self, scroll_to_match: bool) { self.search.matches.clear(); if self.search.query.is_empty() { @@ -213,11 +212,10 @@ impl DiffPane { } /// Ensure `line_highlights` matches the current `hunks`, resolving the - /// syntax separately for each hunk from its `file_path`. A commit diff - /// can touch files of different types — using a single syntax for the - /// whole diff would render everything as the first file's language (or - /// plain text). Rebuilds when the cache shape, content size, or any - /// per-hunk syntax diverges. + /// syntax separately for each hunk from its `file_path`: a commit diff + /// can touch files of different types, and a single syntax would render + /// everything as the first file's language. Rebuilds when the cache + /// shape, content size, or any per-hunk syntax diverges. pub fn ensure_highlight_cache( &mut self, ss: &syntect::parsing::SyntaxSet, @@ -251,9 +249,9 @@ impl DiffPane { use syntect::easy::HighlightLines; let theme = &ts.themes[DIFF_THEME]; - // Reset the highlighter state pair whenever the hunk's syntax - // changes — running a JS hunk through a Rust HighlightLines would - // mis-paint stateful multi-line constructs. + // Reset the highlighter state pair whenever the hunk's syntax changes + // — a JS hunk through a Rust HighlightLines would mis-paint stateful + // multi-line constructs. let mut hl_pair: Option<(HighlightLines<'_>, HighlightLines<'_>)> = None; let mut current_syntax_name = String::new(); diff --git a/src/ui/diff_pane/search.rs b/src/ui/diff_pane/search.rs index 79c5bbee..e1c1c903 100644 --- a/src/ui/diff_pane/search.rs +++ b/src/ui/diff_pane/search.rs @@ -58,9 +58,8 @@ impl DiffSearch { if self.matches.is_empty() { return None; } - // Defensive clamp: `recompute_matches(false)` re-anchors `cursor` to - // the nearest match, but a stale cursor can otherwise survive here - // through code paths that mutate `matches` without re-anchoring. + // Defensive clamp: a stale cursor can survive here through code paths + // that mutate `matches` without re-anchoring. if self.cursor >= self.matches.len() { self.cursor = 0; } else { diff --git a/src/ui/diff_viewer/file_view.rs b/src/ui/diff_viewer/file_view.rs index f5aa5ff7..588f5aa6 100644 --- a/src/ui/diff_viewer/file_view.rs +++ b/src/ui/diff_viewer/file_view.rs @@ -20,9 +20,6 @@ pub(crate) fn render_file_view( ) { let focused = app.focus == Focus::DiffViewer; let border_style = super::focused_border_style(focused, accent); - // file_view backs a single file by definition, so its key carries the - // path. Status overlays use the workdir path; commit overlays use the - // path inside the commit. let file_path: &str = match &app.diff.file_view.key { Some(crate::app::FileViewKey::Status(p)) => p.as_str(), Some(crate::app::FileViewKey::Commit { path, .. }) => path.as_str(), @@ -82,13 +79,12 @@ pub(crate) fn render_file_view( app.diff.file_view.ensure_highlight_cache(ss, ts, syntax); let fv = &app.diff.file_view; let total = fv.line_count(); - // Same floor as the diff gutters, so switching between `v` and the diff - // view does not shift the body's left edge. + // Same floor as the diff gutters, so switching between `v` and the + // diff view does not shift the body's left edge. let digits = super::gutter::digits_for(total); gutter_width = super::gutter::side_gutter_width(digits); - // Belt-and-braces: ensure_highlight_cache keeps line_highlights - // aligned with content.lines().count(), but if that invariant ever - // slips the slice below would panic. Clamp against the cache length. + // Belt-and-braces: if the highlight-cache invariant ever slips, the + // slice below would panic — clamp against the cache length. let max_scroll = total .saturating_sub(1) .min(fv.line_highlights.len().saturating_sub(1)); @@ -115,9 +111,8 @@ pub(crate) fn render_file_view( } else { Color::Reset }; - // The number lives in its own paragraph so horizontal scrolling - // cannot slide it off the left edge, which is what used to - // happen while it shared the body's paragraph. + // The number lives in its own paragraph so horizontal + // scrolling cannot slide it off the left edge. gutter_lines.push(Line::from(Span::styled( super::gutter::side_gutter_text(Some(line_no as u32), digits), Style::default().fg(Color::DarkGray).bg(bg), diff --git a/src/ui/diff_viewer/gutter.rs b/src/ui/diff_viewer/gutter.rs index 4a3962f4..4b627c1e 100644 --- a/src/ui/diff_viewer/gutter.rs +++ b/src/ui/diff_viewer/gutter.rs @@ -30,14 +30,12 @@ pub(crate) fn digits_for(max_lineno: usize) -> usize { /// Gutter digit count for a whole loaded diff: the widest line number that /// appears on either side of any hunk. Derived from the loaded hunks, never /// from the visible window, so scrolling cannot change the gutter width. -/// Recomputed per frame instead of cached: it is one allocation-free pass -/// over the same lines `ensure_highlight_cache` already walks. +/// Recomputed per frame instead of cached: one allocation-free pass over the +/// same lines `ensure_highlight_cache` already walks. pub(crate) fn lineno_digits(hunks: &[DiffHunk]) -> usize { let max = hunks .iter() .flat_map(|h| h.lines.iter()) - // `Option::max` picks the larger `Some`; both `None` only on fixtures - // and the synthetic binary hunk, which then fall back to the minimum. .filter_map(|l| l.old_lineno.max(l.new_lineno)) .max() .unwrap_or(0); diff --git a/src/ui/diff_viewer/mod.rs b/src/ui/diff_viewer/mod.rs index 9f3af1a5..7d27a82a 100644 --- a/src/ui/diff_viewer/mod.rs +++ b/src/ui/diff_viewer/mod.rs @@ -26,12 +26,8 @@ use title::unified_title; /// Minimum pane width (columns) for the side-by-side split layout. Below this /// each half is too narrow to read, so `Split` view falls back to the unified -/// diff renderer. -/// -/// Derived: 80 columns used to leave each half ~38 columns of code, and each -/// half now spends `side_gutter_width(MIN_LINENO_DIGITS)` = 5 of them on its -/// line-number gutter. Raising the threshold by both gutters keeps the same -/// readable code width per side rather than silently shrinking it. +/// renderer. Raised from 80 by both gutters to keep the readable code width +/// per side rather than silently shrinking it. const MIN_SPLIT_WIDTH: u16 = 90; pub(crate) fn rgb_to_color(rgb: (u8, u8, u8)) -> Color { @@ -76,8 +72,8 @@ pub fn render( let focused = app.focus == Focus::DiffViewer; let border_style = focused_border_style(focused, accent); - // Build the syntect highlight cache once per (hunks × per-hunk syntax) - // so the visible-window walk below stays bounded even on large diffs. + // Build the syntect highlight cache once per (hunks × per-hunk syntax) so + // the visible-window walk stays bounded even on large diffs. app.diff.ensure_highlight_cache(ss, ts); let current_match = app.diff.search.current_match(); @@ -96,7 +92,7 @@ pub fn render( // Gutter width is a property of the whole loaded diff, not of the visible // window, so the body's left edge stays put while scrolling. With no diff // loaded the pane holds only a placeholder message, which has no line to - // number — reserving the column there would just indent the message. + // number. let digits = lineno_digits(&app.diff.hunks); let gutter_width = if total_lines == 0 { 0 @@ -161,9 +157,9 @@ pub fn render( Style::default().fg(Color::DarkGray).bg(bg), )]; - // Read from the prebuilt highlight cache. Shape is guaranteed to - // match `hunks` after `ensure_highlight_cache`; treat any - // mismatch as a fallback path that just renders the raw text. + // Read from the prebuilt highlight cache; the shape is guaranteed + // to match `hunks` after `ensure_highlight_cache`, so a mismatch + // only hits the fallback that renders the raw text. if let Some(segs) = app.diff.line_highlights.get(hi).and_then(|hh| hh.get(li)) { for seg in segs { spans.push(Span::styled( @@ -205,10 +201,12 @@ pub fn render( "No diff for selected file" } } - // Tree mode renders the file overlay, not the unified diff, so - // this message is only reachable if the diff view is forced open - // with no file selected. - ViewMode::Tree => "Select a file to preview", + ViewMode::Tree => { + // Tree mode renders the file overlay, not the unified diff, so + // this message is only reachable when the diff view is forced + // open with no file selected. + "Select a file to preview" + } }; lines.push(Line::from(Span::styled( msg, diff --git a/src/ui/diff_viewer/split_view.rs b/src/ui/diff_viewer/split_view.rs index ac3bc863..61f172fc 100644 --- a/src/ui/diff_viewer/split_view.rs +++ b/src/ui/diff_viewer/split_view.rs @@ -27,11 +27,9 @@ pub(crate) fn render_split_view( let visible_height = (area.height as usize).saturating_sub(2); let max_scroll = rows.len().saturating_sub(1); let scroll_start = app.diff.scroll.min(max_scroll); - // Pin the shared scroll cursor to what this layout can actually show. The + // Pin the shared scroll cursor to what this layout can actually show: the // split layout is shorter than the unified flat-row count (paired changes - // collapse onto one row), and navigation clamps against the unified max — - // writing the clamped value back keeps `k`/pgup responsive immediately - // after bottoming out instead of unwinding phantom rows. + // collapse onto one row), and navigation clamps against the unified max. app.diff.scroll = scroll_start; let scroll_end = scroll_start.saturating_add(visible_height).min(rows.len()); @@ -96,8 +94,8 @@ pub(crate) fn render_split_view( left_gutter, left_lines, scroll_x, - // Wrapping is deliberately ignored here: halves that fold to different - // heights stop lining up, and lining up is what this layout is for. + // Wrapping is deliberately ignored here: halves that fold to + // different heights stop lining up, and lining up is the point. false, ); @@ -129,10 +127,8 @@ enum Side { /// Build one side's gutter and body `Line` for a split body row, as /// `(gutter, body)`. `None` (no counterpart line on this side) renders both as /// blank; otherwise the cell is styled by line kind and reuses the prebuilt -/// highlight cache, mirroring the unified renderer's per-line treatment. -/// -/// Both lines come from one lookup so they cannot disagree about which -/// `DiffLine` the row is showing. +/// highlight cache, mirroring the unified renderer. Both lines come from one +/// lookup so they cannot disagree about which `DiffLine` the row is showing. fn split_side_lines<'a>( app: &'a App, cell: Option<(usize, usize)>, diff --git a/src/ui/file_list.rs b/src/ui/file_list.rs index a9c73e0d..ef235fd2 100644 --- a/src/ui/file_list.rs +++ b/src/ui/file_list.rs @@ -64,9 +64,9 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let symbol = f.short_code(); let color = super::status_color(f.most_severe()); let scroll_x = app.status_view.file_scroll_x; - // Borrow `f.path` (which outlives the item list) in the common - // non-rename case so rendering stays allocation-free; only - // renames, whose `old -> new` display string is owned, allocate. + // Borrow `f.path` in the common non-rename case so rendering stays + // allocation-free; only renames, whose `old -> new` display string + // is owned, allocate. let path: std::borrow::Cow<'_, str> = match f.display_path() { std::borrow::Cow::Borrowed(_) => { std::borrow::Cow::Borrowed(super::char_offset(&f.path, scroll_x)) @@ -87,9 +87,8 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { }; // The status symbol keeps its change-status color across all hot - // stages so the change kind stays readable. Recency is conveyed by - // path styling only — no leading glyph — so transitions between - // stages don't shift the row width. + // stages so the change kind stays readable; recency is conveyed by + // path styling only, so stage transitions don't shift the row. let line = match stage { HotStage::Cool => Line::from(vec![ Span::styled(format!("{symbol} "), Style::default().fg(color)), diff --git a/src/ui/file_view.rs b/src/ui/file_view.rs index c74a217c..d7121665 100644 --- a/src/ui/file_view.rs +++ b/src/ui/file_view.rs @@ -19,21 +19,19 @@ pub struct FileViewState { pub scroll_x: usize, pub anchor_line: Option, pub error: Option, - /// Cached syntect highlight output, one entry per `content.lines()` line. - /// Built once per (content, syntax) so per-frame rendering only slices + /// Cached syntect highlight output, one entry per `content.lines()` line, + /// built once per (content, syntax) so per-frame rendering only slices /// the visible window. pub line_highlights: Vec>, /// Syntax name used to build `line_highlights`. `None` = unbuilt or /// invalidated. pub cached_syntax_name: Option, - /// Cached `content.lines().count()` populated on load. Avoids walking the - /// full file on every scroll keystroke (`max_scroll` is called from each - /// j/k/PgUp/PgDn handler). + /// Cached `content.lines().count()` populated on load; `max_scroll` is + /// called from every j/k/PgUp/PgDn keystroke, so walking the file per + /// keystroke is not viable. pub(crate) total_lines: usize, - /// Byte length of `content` at cache build time. Combined with - /// `total_lines` it lets `ensure_highlight_cache` notice in-place content - /// edits that keep the line count constant (line counts alone are too - /// coarse a fingerprint). + /// Byte length of `content` at cache build time. With `total_lines` it + /// detects in-place content edits that keep the line count constant. pub(crate) cached_content_len: usize, /// Lowercased copy of each `content` line. Built on demand by /// `ensure_lower_cache` so per-keystroke file search avoids re-lowercasing. @@ -48,9 +46,7 @@ impl FileViewState { /// Replace the rendered content, keeping `total_lines` and the highlight /// cache in lockstep with `content` so partial assignments at call sites /// can't leave them disagreeing (which would make `max_scroll` lie about - /// the legal scroll range). Also clamps `scroll` and drops any prior - /// error so an in-place reload never lands past the new file length or - /// keeps a "load failed" banner over fresh content. + /// the legal scroll range). pub fn set_content(&mut self, content: String) { self.total_lines = if content.is_empty() { 0 @@ -58,8 +54,6 @@ impl FileViewState { content.lines().count() }; self.content = content; - // Highlights are content-derived: stale entries would index past - // `total_lines` or render the previous file's colors. self.line_highlights.clear(); self.cached_syntax_name = None; self.cached_content_len = 0; @@ -80,9 +74,8 @@ impl FileViewState { self.scroll = self.scroll.saturating_add(n).min(self.max_scroll()); } - /// Ensure `lines_lower` is built for the current `content`. Called by - /// `DiffPane::recompute_matches` in File-view mode so per-keystroke search - /// only pays the lowercase cost once per file load. + /// Ensure `lines_lower` is built for the current `content`, so per- + /// keystroke search pays the lowercase cost once per file load. pub(crate) fn ensure_lower_cache(&mut self) { if self.lines_lower.len() == self.total_lines && !self.content.is_empty() { return; diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 848b876a..242fa963 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -37,9 +37,9 @@ pub(crate) fn status_color(status: StatusKind) -> Color { } } -/// Space-separated because the leader is a *sequence*, not a chord: `^F1` reads -/// as Ctrl+F1, and that misreading names a real binding — the bare F-keys -/// select project tabs. Matches how the hint bar already writes `^F t`. +/// Space-separated because the leader is a *sequence*, not a chord: `^F1` +/// reads as Ctrl+F1, and that misreading names a real binding — the bare +/// F-keys select project tabs. pub(crate) fn jump_legend(app: &App, digit: char) -> String { format!("{} {}", leader_label_of(app.interaction.leader), digit) } diff --git a/src/ui/hint_bar.rs b/src/ui/hint_bar.rs index 48a8a8e3..c08abe04 100644 --- a/src/ui/hint_bar.rs +++ b/src/ui/hint_bar.rs @@ -30,8 +30,7 @@ const CLICKABLE_PLAIN_KEYS: &str = "twslbfoxpruvzcn/"; /// `q: detach` is held back so detaching stays a deliberate two-key act. The /// keys are listed rather than derived because nothing in the hint text tells /// a command apart from a navigation hint — a command added to `hint_text` -/// stays silently unclickable until it is listed here. This list has already -/// had that gap. +/// stays silently unclickable until it is listed here. pub(crate) fn segment_click(keyspec: &str) -> Option { let spec = keyspec.trim(); if spec == "" { @@ -81,9 +80,9 @@ pub(crate) fn hint_spans(text: &str, leader: &str, mark_clickable: bool) -> Vec< .and_then(|(keyspec, _)| segment_click(keyspec)) .is_some(); if clickable { - // Invert the whole segment — the entire label is the click target. - // Leading whitespace stays plain so the chip doesn't start with a - // stray block. + // Invert the whole segment — the entire label is the click target + // — but keep leading whitespace plain so the chip doesn't start + // with a stray block. let label_start = rendered.len() - rendered.trim_start().len(); let (lead_ws, label) = rendered.split_at(label_start); if !lead_ws.is_empty() { @@ -193,9 +192,9 @@ pub(crate) fn empty_hint_click_at( let rendered = segment.replace("", leader_label); let width = Span::raw(rendered.as_str()).width() as u16; if x >= cursor && x < cursor + width { - // Same rules as `hint_click_at`: leading whitespace renders plain - // and so is not part of the target, and the key is the text before - // the colon. + // Same rules as `hint_spans`: leading whitespace renders plain and + // so is not part of the target, and the key is the text before the + // colon. let label_start = rendered.len() - rendered.trim_start().len(); let lead_width = Span::raw(&rendered[..label_start]).width() as u16; if x < cursor + lead_width { diff --git a/src/ui/hint_text.rs b/src/ui/hint_text.rs index 2010dcd7..8d89191d 100644 --- a/src/ui/hint_text.rs +++ b/src/ui/hint_text.rs @@ -7,8 +7,7 @@ pub(crate) const EMPTY_HINT_ARMED: &str = " o: open project | q: detach | esc: c pub(crate) fn prefix_armed_hint_text(app: &App) -> String { // While the terminal fills the body the digit row addresses panes - // directly (`1-8`); in the split view `1`/`2` focus the list/diff and - // `3-9,0` jump to panes. + // directly (`1-8`); in the split view `1`/`2` focus the list/diff. let digits = if app.terminal.fullscreen.fills_body() { "1-8: pane" } else { @@ -33,9 +32,7 @@ pub(crate) fn prefix_armed_hint_text(app: &App) -> String { } else { "" }; - // Only while a plugin actually has a recovery pending, which is rare — an - // always-present hint for it would spend a scarce row on a key that is - // usually inert. + // Only while a plugin actually has a recovery pending, which is rare. let cancel = if app.can_cancel_recovery() { "c: cancel recovery | " } else { @@ -55,9 +52,9 @@ pub(crate) fn prefix_armed_hint_text(app: &App) -> String { ) } -/// The hint literal (with `` placeholders) for the current -/// non-modal state. Single source for `render_hint_bar` and `hint_click_at`, -/// so the click hit-test always segments exactly the text on screen. +/// The hint literal (with `` placeholders) for the current non-modal +/// state. Single source for `render_hint_bar` and `hint_click_at`, so the +/// click hit-test always segments exactly the text on screen. pub(crate) fn normal_hint_literal(app: &App) -> &'static str { match app.terminal.fullscreen { // From Grid the next `f` zooms the active pane — but only when Zoom diff --git a/src/ui/log_view/mod.rs b/src/ui/log_view/mod.rs index 155ded23..79e9a0ab 100644 --- a/src/ui/log_view/mod.rs +++ b/src/ui/log_view/mod.rs @@ -24,7 +24,7 @@ pub struct LogView { /// The previous fetch returned fewer entries than requested. pub(crate) fully_loaded: bool, /// Commit-list incremental search. The cache holds indices into `commits` - /// whose summary matches the lowercased query. Recomputed only when + /// whose summary matches the lowercased query, recomputed only when /// commits or the query change. pub commit_search_query: SearchQuery, pub commit_search_active: bool, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 89a8238b..39f1a75a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -89,10 +89,9 @@ pub fn draw_empty( ), } - // Shares `render_notice_row`'s row assignment so the dialog looks the same - // wherever it opens: the input on this row, its reports and keys on the - // hint row. With no project there is no repo header to fall back to, so - // outside the dialog the row carries a notice or goes empty. + // Shares `render_notice_row`'s row assignment so the dialog looks the + // same wherever it opens: the input on this row, its reports and keys on + // the hint row. With no project there is no repo header to fall back to. let notice_line = if chrome.repo_input.active { repo_dialog::repo_input_line(chrome.repo_input, accent, rows.notice.width) } else { @@ -102,8 +101,7 @@ pub fn draw_empty( frame.render_widget(Paragraph::new(notice_line), rows.notice); // The armed prefix shows the same chip as the project screen: pressing - // the leader here has to look like it did something, or it reads as a - // dead key. + // the leader here has to look like it did something. let hint = if chrome.repo_input.active { repo_dialog::repo_dialog_hint_line(notice, chrome.repo_input, rows.hint.width) } else if prefix_armed { @@ -131,11 +129,10 @@ pub fn draw( layout: &LayoutConfig, accent: Color, ) { - // Chrome: the project tab row on top, the notice row (repo identity, or a - // notice covering it) and the hint bar below. The tab row and notice row - // are rendered here, before any layout branch, so neither is lost to a - // fullscreen view mode — a tab row that vanished in fullscreen would - // strand the user with no indication of which project they are in. + // Chrome: the project tab row on top, the notice row and the hint bar + // below. Both are rendered here, before any layout branch, so neither is + // lost to a fullscreen view mode — a tab row that vanished in fullscreen + // would strand the user with no indication of which project they are in. let rows = chrome_rows(frame.area()); let (body_area, notice_area, hint_area) = (rows.body, rows.notice, rows.hint); @@ -155,9 +152,9 @@ pub fn draw( notice_area, ); - // The browser owns the body while it is open, ahead of every view-mode and - // fullscreen branch: the dialog already holds all the keys, so whatever - // those branches would draw is inert and would only hide the browse. + // The browser owns the body while it is open, ahead of every view-mode + // and fullscreen branch: the dialog already holds all the keys, so + // whatever those branches would draw is inert and would only hide it. if let Some(tree) = tabs.repo_input.picker.as_ref() { path_tree::render(frame, tree, body_area, accent); frame.render_widget( diff --git a/src/ui/notice.rs b/src/ui/notice.rs index 9a64e9de..155714b2 100644 --- a/src/ui/notice.rs +++ b/src/ui/notice.rs @@ -16,10 +16,9 @@ pub(crate) fn render_notice_row<'a>( accent: Color, width: u16, ) -> Paragraph<'a> { - // The open dialog takes the header's row whole: the header names the repo - // being left, the input names the one being opened. Notices follow the - // dialog down to the hint row (`repo_dialog_hint_line`) so nothing covers - // the path being typed. + // The dialog owns this row wholesale: the header names the repo being + // left, the input names the one being opened. Notices follow the dialog + // down to the hint row so nothing covers the path being typed. if repo_input.active { return Paragraph::new(crate::ui::repo_dialog::repo_input_line( repo_input, accent, width, @@ -33,10 +32,8 @@ pub(crate) fn render_notice_row<'a>( /// The row's content when something wants to claim it: a notice first, then /// the repo dialog's completion candidates. `None` leaves the row to the -/// caller's own fallback. A notice outranks the candidates because it explains -/// a rejected action, and any edit clears it, so the two rarely compete. -/// With a `repo_path` present the path is kept and the notice truncates -/// with `…` when the pair exceeds `width`. +/// caller's own fallback. A notice outranks the candidates because it +/// explains a rejected action, and any edit clears it. pub(crate) fn notice_or_candidates<'a>( notice: Option<&'a Notice>, repo_input: &RepoInput, @@ -64,8 +61,8 @@ pub(crate) fn notice_or_candidates<'a>( } let available = (width as usize).saturating_sub(path_width); - // One column is enough for the ellipsis alone: a notice cut to - // nothing must still say it was there, as `+N more` does. + // A notice cut to nothing must still say it was there, as `+N + // more` does — hence the ellipsis alone when only one column fits. if available == 0 { return Some(Line::from(vec![Span::styled(path_str, path_style)])); } @@ -90,16 +87,15 @@ pub(crate) fn notice_or_candidates<'a>( } /// Fit as many candidate names as the row holds, reporting the rest as -/// `+N more`. The row is one line, so a long list has to be cut somewhere and -/// dropping the tail silently would read as "that is all there is". +/// `+N more`: dropping the tail silently would read as "that is all there is". fn candidate_line(candidates: &[String], width: u16) -> String { let width = width as usize; let mut line = String::new(); let mut shown = 0; for name in candidates { let next = format!("{}{name}", if shown == 0 { " " } else { CANDIDATE_GAP }); - // Reserve room for the count this name would push into the overflow, so - // the last name placed can never crowd out its own `+N more`. + // Reserve room for the `+N more` this name would push into, so the + // last name placed can never crowd out its own overflow label. let overflow = overflow_label(candidates.len() - shown - 1); if Span::raw(&line).width() + Span::raw(&next).width() + Span::raw(&overflow).width() > width @@ -121,11 +117,10 @@ fn overflow_label(remaining: usize) -> String { } /// Truncate `text` to fit within `max_width` columns, appending `…` when cut. -/// /// Width is summed per character, so a sequence whose width is not the sum of -/// its parts — a variation selector, a combining mark — can come out a column -/// over. Re-measuring after each character would make this row quadratic in -/// its own width on every frame, for a column the terminal clips anyway. +/// its parts (a variation selector, a combining mark) can come out a column +/// over — re-measuring per character would make this quadratic per frame for +/// a column the terminal clips anyway. fn truncate_with_ellipsis(text: &str, max_width: usize) -> String { if Span::raw(text).width() <= max_width { return text.to_string(); @@ -156,20 +151,17 @@ fn truncate_with_ellipsis(text: &str, max_width: usize) -> String { const BRANCH_NAME_SHARE: usize = 2; /// The path and the branch as the row can hold them, cut with `…` rather than -/// pushed off the end. Both give way before the counts behind them, because -/// those counts do not: a name at full length would take the row from `↑N ↓M` -/// and the recovery chip, the part of this row that is news. The branch is -/// held to half of `budget` so a long one does not take the path's place -/// entirely, and dropped when half is nothing — an ellipsis alone names no -/// branch. +/// pushed off the end. Both give way before the counts behind them, which are +/// the news on this row. The branch is held to half of `budget` so a long one +/// does not take the path's place entirely, and dropped when half is nothing — +/// an ellipsis alone names no branch. pub(crate) fn fit_names( path: &str, branch: Option<&str>, budget: usize, ) -> (String, Option) { - // Nothing left is nothing shown. `truncate_with_ellipsis` never returns - // less than the ellipsis, which on a row this full is a column taken from - // the chip it was making room for. + // Nothing left is nothing shown: the ellipsis `truncate_with_ellipsis` + // always returns would take a column from the chip it was making room for. if budget == 0 { return (String::new(), None); } @@ -194,8 +186,6 @@ pub(crate) fn render_repo_header<'a>(app: &'a App, accent: Color, width: u16) -> .filter(|t| t.ahead > 0 || t.behind > 0) .map(|t| format!(" ^{} v{} ", t.ahead, t.behind)); let chip = recovery_chip(app); - // The counts and the chip keep their room: each is short, and each says - // something no other row does. let kept: usize = [tracking.as_deref(), chip.as_deref()] .into_iter() .flatten() @@ -236,8 +226,7 @@ pub(crate) fn render_repo_header<'a>(app: &'a App, accent: Color, width: u16) -> /// The full recovery report as one chip, on this row rather than a row of its /// own for the reason the notices are: a row that appears and disappears /// resizes every open PTY. It is the last chip, so an actual notice still -/// covers the whole line — a rejected action needs explaining more than a -/// wait does. The pane it describes is the one ` c` would cancel. +/// covers the whole line. fn recovery_chip(app: &App) -> Option { let (pane, report) = app.terminal.recovery_focus()?; let mut chip = format!(" pane {pane}: {}", report.state); diff --git a/src/ui/path_tree.rs b/src/ui/path_tree.rs index f090e221..4e6d3810 100644 --- a/src/ui/path_tree.rs +++ b/src/ui/path_tree.rs @@ -1,9 +1,6 @@ -//! The repo dialog's directory browser, drawn over the whole body. -//! -//! Not a floating box: nothing in this crate floats — every surface takes a -//! layout area — and mouse capture is on by default, so an overlay would be the -//! first thing needing a hit region of its own. Taking the body avoids both, and -//! the path field stays visible on the notice row underneath. +//! The repo dialog's directory browser, drawn over the whole body rather +//! than floating: every surface in this crate takes a layout area, and mouse +//! capture is on by default, so an overlay would need a hit region of its own. use crate::ui::render_selectable_list; use crate::workspace::PathTree; diff --git a/src/ui/project_tab/mod.rs b/src/ui/project_tab/mod.rs index 03cada8a..9fde94d7 100644 --- a/src/ui/project_tab/mod.rs +++ b/src/ui/project_tab/mod.rs @@ -12,8 +12,7 @@ use std::time::Duration; /// Per-tab character budget for the project name. The viewer's tab row applies /// the same budget by the same rule (`viewer-ui/src/lib/tabLabel.ts`), so a -/// project is called the same thing on both screens — widening one without the -/// other is how they come to disagree. +/// project is called the same thing on both screens. const TAB_TITLE_MAX_CHARS: usize = 14; const MARKER_WIDTH: u16 = 4; @@ -74,9 +73,8 @@ fn tab_texts(repo_paths: &[String], attention: &[bool]) -> Vec { /// The run of tabs to draw in `width` cells, always containing `active`. /// A `Paragraph` would silently clip the tail — hiding later projects *and* -/// the active-tab highlight when the active one falls off the end — so the -/// row scrolls around the active tab and drops what doesn't fit into `+N` -/// markers whose width is reserved here before deciding what fits. +/// the active-tab highlight — so the row scrolls around the active tab and +/// drops what doesn't fit into `+N` markers whose width is reserved first. fn visible_window(widths: &[u16], width: u16, active: usize) -> std::ops::Range { let n = widths.len(); if n == 0 { @@ -93,7 +91,7 @@ fn visible_window(widths: &[u16], width: u16, active: usize) -> std::ops::Range< used.saturating_add(markers * MARKER_WIDTH) <= width }; - // Grow right first, then left. Right-first keeps the common case (active + // Grow right first, then left: right-first keeps the common case (active // near the front) showing the projects that follow it. loop { let mut grew = false; @@ -231,8 +229,8 @@ pub(crate) fn tab_at( y: u16, ) -> Option { // On a terminal too short for the full chrome, ratatui hands the fixed tab - // constraint a zero-height Rect and nothing is drawn. Without the size - // check a click on whatever *is* visible at that y would select tab 0. + // constraint a zero-height Rect and nothing is drawn; without the size + // check a click on whatever is visible at that y would select tab 0. if area.height == 0 || area.width == 0 || y != area.y || x < area.x { return None; } diff --git a/src/ui/repo_dialog.rs b/src/ui/repo_dialog.rs index f43201fb..07dae40b 100644 --- a/src/ui/repo_dialog.rs +++ b/src/ui/repo_dialog.rs @@ -11,13 +11,10 @@ use ratatui::{ /// The dialog's input line. Drawn on the notice row, in the repo header's /// place: the header names the repo being left, the input names the one being -/// opened, and only one of those is being decided right now. Owning a whole -/// row means the path never has to compete with the key legend, which sits on -/// the hint row below (`repo_dialog_hint_line`). -/// -/// A path longer than the row is shown from its tail behind a leading `…` — -/// the caret marks where typing lands, so it is the end that must survive. -/// `width` is the row's; 0 means "unknown", which keeps the whole path. +/// opened, and only one of those is being decided right now. A path longer +/// than the row is shown from its tail behind a leading `…` — the caret marks +/// where typing lands, so it is the end that must survive. `width` is the +/// row's; 0 means "unknown", which keeps the whole path. pub(crate) fn repo_input_line<'a>( repo_input: &'a RepoInput, accent: Color, diff --git a/src/ui/search.rs b/src/ui/search.rs index 36c72c23..13033a72 100644 --- a/src/ui/search.rs +++ b/src/ui/search.rs @@ -1,7 +1,6 @@ /// Search-input string paired with its lowercased form. Bundling the /// invariant into one type keeps callers honest: pushing or popping always -/// updates both halves in lockstep, and renderers/filters read the canonical -/// lower form through `lower()`. +/// updates both halves in lockstep. #[derive(Default, Clone, Debug)] pub struct SearchQuery { raw: String, diff --git a/src/ui/splash.rs b/src/ui/splash.rs index 6ef9249c..7e82c105 100644 --- a/src/ui/splash.rs +++ b/src/ui/splash.rs @@ -76,7 +76,7 @@ pub fn draw(frame: &mut Frame, state: &SplashState, accent: Color) { ]) .split(outer[1]); - // Logo — brighten as loading completes + // Brighten the logo as loading completes. let progress = state.progress(); let logo_style = if progress < 0.5 { Style::default().fg(accent).add_modifier(Modifier::DIM) diff --git a/src/ui/status_view.rs b/src/ui/status_view.rs index 97578830..8a447260 100644 --- a/src/ui/status_view.rs +++ b/src/ui/status_view.rs @@ -14,7 +14,7 @@ pub struct StatusView { /// Indices into `files` matching `search_query`. Recomputed only when /// `files` or the query changes (see `App::recompute_status_filter`). pub(crate) filter_cache: Vec, - /// Per-file mtime observed at the latest snapshot, keyed by `path`. Used + /// Per-file mtime observed at the latest snapshot, keyed by `path`, used /// by the agent-aware focus indicator to decide whether a file is "hot". /// Entries for paths missing from the latest snapshot are dropped each /// tick so the map stays bounded by the working-tree change count. diff --git a/src/ui/terminal_tab/cells.rs b/src/ui/terminal_tab/cells.rs index 713eb8a5..3b48b0e1 100644 --- a/src/ui/terminal_tab/cells.rs +++ b/src/ui/terminal_tab/cells.rs @@ -21,8 +21,8 @@ pub(crate) struct VisiblePaneCell { /// Lay out every currently visible pane inside `content_area` (the terminal /// body, below the tab row). Single source of truth for pane sizing: `render` /// draws from it and `visible_pane_content_areas` (used to resize each pane's -/// PTY) reads from it, so a pane's backend/emulator size always matches what's -/// actually drawn on screen. +/// PTY) reads from it, so a pane's backend/emulator size always matches what +/// is drawn on screen. pub(crate) fn visible_pane_cells(app: &App, content_area: Rect) -> Vec { let pane_count = app.terminal.panes.len(); let visible = visible_range( diff --git a/src/ui/terminal_tab/layout.rs b/src/ui/terminal_tab/layout.rs index 79e26bb9..afa0eaa1 100644 --- a/src/ui/terminal_tab/layout.rs +++ b/src/ui/terminal_tab/layout.rs @@ -22,9 +22,9 @@ pub(crate) const TAB_TITLE_MAX_CHARS: usize = 20; pub(crate) const JUMP_KEY_PANE_COUNT: usize = MAX_VISIBLE_FULLSCREEN; /// Truncate `title` to at most `max` characters, appending `…` when cut. -/// Char-based (not display-width) for simplicity: ASCII shell program names -/// are the common case and `chars().count()` is already correct there. CJK -/// titles render slightly under the visual budget, which is acceptable. +/// Char-based (not display-width): ASCII shell program names are the common +/// case, and CJK titles render slightly under the visual budget, which is +/// acceptable. pub(crate) fn truncate_tab_title(title: &str, max: usize) -> String { if title.chars().count() <= max { return title.to_string(); @@ -48,13 +48,11 @@ pub(crate) fn terminal_layout(area: Rect) -> Option<(Rect, Rect)> { Some((chunks[0], chunks[1])) } -/// Split `area` into `count` cells using a balanced grid: 1 pane fills the -/// area; 2 panes go side by side when `area` is wide, stacked otherwise; 3 -/// panes get a 2-column row plus a full-width remainder row; 4 is a 2x2 grid; -/// 5-6 use 3 columns; 7 uses a 4-then-3 row split; 8 is a 2x4 grid. Counts -/// beyond that (not expected given `MAX_VISIBLE_FULLSCREEN`) fall back to a -/// near-square grid. Every returned Rect has at least 1x1 size when `area` -/// is at least `count` cells large, so no cell silently disappears. +/// Split `area` into `count` cells using a balanced grid: 2 panes go side by +/// side when the area is wide, stacked otherwise; 3-8 panes get fixed layouts +/// (2x2, 3-col, 4-then-3, 2x4); counts beyond that fall back to a near-square +/// grid. Every returned Rect has at least 1x1 size when `area` is at least +/// `count` cells large, so no cell silently disappears. pub(crate) fn split_pane_areas(area: Rect, count: usize) -> Vec { if count == 0 || area.width == 0 || area.height == 0 { return Vec::new(); diff --git a/src/ui/terminal_tab/mod.rs b/src/ui/terminal_tab/mod.rs index 0b8d29c6..ff843029 100644 --- a/src/ui/terminal_tab/mod.rs +++ b/src/ui/terminal_tab/mod.rs @@ -36,9 +36,8 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { " Terminal " }; // The upper panes draw a `┌` corner that pushes their title text in by one - // column (`┌ ^F 1 Files`). This pane has no left border, so a border-styled - // `─` stands in for that corner — it keeps `Terminal` column-aligned with - // `^F 1 Files` / `^F 2 Diff` above and makes the line start flush at the edge. + // column. This pane has no left border, so a border-styled `─` stands in + // for that corner to keep `Terminal` column-aligned with the titles above. let title = Line::from(vec![Span::styled("─", border_style), Span::raw(label)]); let block = Block::default() .borders(TERMINAL_BORDERS) @@ -77,10 +76,9 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let i = visible.start + offset; let is_active = i == app.terminal.active; if cell.bordered { - // `accent` means "this is where your keystrokes go right now" — - // reserved for Focus::Terminal. Without real focus, the active - // pane must look identical to an inactive one (plain DarkGray) — - // any brighter treatment reads as focused when it isn't. + // `accent` means "this is where your keystrokes go right now" and + // is reserved for Focus::Terminal — without real focus the active + // pane must look identical to an inactive one. let pane_border_style = if is_active && focused { Style::default().fg(accent) } else { diff --git a/src/ui/terminal_tab/recovery.rs b/src/ui/terminal_tab/recovery.rs index 9a7a55df..c22b28e9 100644 --- a/src/ui/terminal_tab/recovery.rs +++ b/src/ui/terminal_tab/recovery.rs @@ -1,23 +1,17 @@ -//! The recovery marker a pane's tab label carries. -//! -//! Deliberately a *suffix on an existing label* rather than a row or an overlay: -//! adding or removing a layout row resizes every open PTY (see the Layout and -//! Notice Row sections of `docs/architecture.md`), and a badge that comes and -//! goes would do that every time a plugin changed its mind. The full report — -//! state, deadline, attempt and detail — is on the notice row; this is only the -//! "which pane" pointer, so it has to stay short. +//! The recovery marker a pane's tab label carries: a suffix on an existing +//! label rather than a row or an overlay, because adding or removing a layout +//! row resizes every open PTY (see `docs/architecture.md`). The full report +//! lives on the notice row; this is only the "which pane" pointer. use crate::app::App; use crate::runtime::terminal::PaneRecovery; use crate::ui::terminal_tab::layout::{TAB_TITLE_MAX_CHARS, truncate_tab_title}; use crate::ui::wall_clock::local_hour_minute; -/// Chars of the pane title kept when a marker rides along. -/// -/// Well under [`TAB_TITLE_MAX_CHARS`](super::layout::TAB_TITLE_MAX_CHARS): the -/// title is truncated to make room *before* the marker is appended, so a narrow -/// pane loses title characters rather than the marker. Losing the marker is the -/// one degradation that defeats the point of having it. +/// Chars of the pane title kept when a marker rides along — well under +/// [`TAB_TITLE_MAX_CHARS`](super::layout::TAB_TITLE_MAX_CHARS) so the title is +/// truncated before the marker is appended. Losing the marker would defeat +/// the point of having it. pub(crate) const RECOVERY_TITLE_MAX_CHARS: usize = 8; /// A wait with a known end. @@ -43,11 +37,9 @@ pub(crate) fn pane_label(app: &App, index: usize) -> String { } /// The marker for one pane's report: the deadline as a local wall-clock time -/// when there is one, and the attempt count when any have been spent. -/// -/// A report with neither is still marked, with the bare hourglass — a pane its -/// plugin is doing something about must be distinguishable from one it is not, -/// even when there is no number to show. +/// when there is one, and the attempt count when any have been spent. A +/// report with neither is still marked with the bare hourglass — a pane its +/// plugin is doing something about must be distinguishable from one it is not. pub(crate) fn recovery_marker(report: &PaneRecovery) -> String { let mut marker = String::new(); if let Some(at) = report.deadline_epoch.and_then(local_hour_minute) { diff --git a/src/ui/terminal_tab/screen.rs b/src/ui/terminal_tab/screen.rs index ba9057e9..634743a5 100644 --- a/src/ui/terminal_tab/screen.rs +++ b/src/ui/terminal_tab/screen.rs @@ -35,10 +35,9 @@ pub(crate) fn build_screen_lines( let mut style = Style::default(); let cell = match screen.cell(row, col) { Some(cell) => { - // Wide chars (e.g., Hangul) occupy two columns: the glyph - // lives on the first cell and a spacer fills the second. - // Emitting anything for the spacer would shift the row by one - // column. + // Wide chars occupy two columns: the glyph lives on the + // first cell and a spacer fills the second; emitting + // anything for the spacer would shift the row. if cell.is_wide_spacer() { continue; } @@ -86,10 +85,8 @@ pub(crate) fn screen_cursor_position(screen: &ScreenView<'_>, area: Rect) -> Opt } // Embedded CLIs such as Claude can leave DECTCEM hide-cursor mode enabled - // while still expecting an outer terminal host to expose the input point. - // For the focused terminal pane, keep the host cursor visible at the - // emulator's tracked cursor position instead of honoring the inner app's - // hide flag. + // while still expecting an outer terminal host to expose the input point, + // so keep the host cursor visible at the emulator's tracked position. let (row, col) = screen.cursor_position(); Some(Position::new( area.x.saturating_add(col.min(area.width.saturating_sub(1))), diff --git a/src/ui/terminal_tab/tab_bar.rs b/src/ui/terminal_tab/tab_bar.rs index bcbd5e61..fd16d651 100644 --- a/src/ui/terminal_tab/tab_bar.rs +++ b/src/ui/terminal_tab/tab_bar.rs @@ -68,13 +68,11 @@ pub(crate) fn tab_segments( segments.extend(app.terminal.panes[visible.clone()].iter().enumerate().map( |(offset, _pane)| { let i = visible.start + offset; - // Panes 0..=7 carry a jump key: ` 1..8` in fullscreen, - // ` 3..9,0` in the split view (the digit row is - // layout-aware). Panes past the 8th have no jump key, so they - // carry no hint to avoid implying an unbound shortcut. The bare - // F-keys are NOT advertised here: they select project tabs. - // Carries the recovery marker when the pane has one, so a pane its - // plugin is nursing back is visible without leaving the tab row. + // Panes 0..=7 carry a jump key; panes past the 8th carry no hint + // to avoid implying an unbound shortcut. The bare F-keys are NOT + // advertised here: they select project tabs. The label carries + // the recovery marker when the pane has one, so a pane its plugin + // is nursing back is visible without leaving the tab row. let title = pane_label(app, i); let label = if i < JUMP_KEY_PANE_COUNT { // Split view runs 3,4..9 then wraps to 0 for the eighth pane. diff --git a/src/ui/tree_list.rs b/src/ui/tree_list.rs index 95d899d3..7df8254d 100644 --- a/src/ui/tree_list.rs +++ b/src/ui/tree_list.rs @@ -1,8 +1,7 @@ //! Renderer for the read-only file-tree navigator pane (`ViewMode::Tree`). -//! Rows are derived from `TreeView::visible_rows`; each is indented by depth, -//! prefixed with an expansion marker for directories, and horizontally -//! scrollable via the shared `char_offset` helper (mirroring the file/commit -//! lists). +//! Rows come from `TreeView::visible_rows`, indented by depth, with an +//! expansion marker for directories and shared `char_offset` horizontal +//! scrolling. use crate::app::{App, Focus}; use ratatui::{ @@ -13,9 +12,9 @@ use ratatui::{ widgets::ListItem, }; -// VS Code-style chevrons rather than filled triangles: a thin right chevron -// when collapsed, a down chevron when expanded. Each marker is two columns -// wide (glyph + space), matching the file marker so names stay aligned. +// VS Code-style thin chevrons rather than filled triangles; each marker is +// two columns wide (glyph + space), matching the file marker so names stay +// aligned. const EXPANDED_MARKER: &str = "⌄ "; const COLLAPSED_MARKER: &str = "› "; const FILE_MARKER: &str = " "; @@ -25,7 +24,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let border_style = super::focused_border_style(focused, accent); // Reserve a bottom row for the search input whenever the overlay is open - // or a query is still showing, mirroring the status/commit list layout. + // or a query is still showing. let show_search = app.tree_view.search_active || !app.tree_view.search_query.is_empty(); let (list_area, search_area) = if show_search { let chunks = Layout::default() @@ -54,11 +53,9 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { FILE_MARKER }; let full = format!("{indent}{marker}{}", row.name); - // Scroll the whole rendered line (indent + marker + name) so long - // nested paths can be panned into view with ←/→ when focused. let shown = super::char_offset(&full, scroll_x).to_string(); - // Directories take the accent color (and bold) so the structure - // reads at a glance; files render in the default foreground. + // Directories take the accent color so the structure reads at a + // glance; files render in the default foreground. let style = if row.is_dir { Style::default().fg(accent).add_modifier(Modifier::BOLD) } else { diff --git a/src/ui/tree_view/mod.rs b/src/ui/tree_view/mod.rs index 794a674b..a85c0171 100644 --- a/src/ui/tree_view/mod.rs +++ b/src/ui/tree_view/mod.rs @@ -1,7 +1,6 @@ //! File-tree navigator state (`ViewMode::Tree`). The visible row list is -//! derived from a cache + expansion set so the two can never drift. All -//! directory I/O lives in `App`; this module is pure given a populated cache, -//! keeping the flattening logic unit-testable without a filesystem. +//! derived from a cache + expansion set so the two can never drift; all +//! directory I/O lives in `App`, keeping this module pure and unit-testable. use crate::git::tree::TreeEntry; use crate::ui::SearchQuery; @@ -28,7 +27,6 @@ pub(crate) struct TreeIndexEntry { #[derive(Default)] pub struct TreeView { pub selected: usize, - /// Horizontal scroll offset (chars). pub scroll_x: usize, /// Repo-relative expanded directory paths. The root (`""`) is implicitly /// expanded and never stored here. @@ -68,8 +66,8 @@ impl TreeView { /// renders an unbroken path from the root to each hit. pub(crate) fn recompute_filter(&mut self) { // Collect matches under an immutable borrow first, then mutate the - // show-set — `index` and `show_set` are disjoint fields but both - // borrow `self`, so they can't be touched in the same loop. + // show-set — `index` and `show_set` both borrow `self`, so they can't + // be touched in the same loop. let matches: Vec = { let q = self.search_query.lower(); if q.is_empty() { diff --git a/src/ui/wall_clock.rs b/src/ui/wall_clock.rs index 86d0d076..529dda01 100644 --- a/src/ui/wall_clock.rs +++ b/src/ui/wall_clock.rs @@ -62,16 +62,15 @@ fn local_parts(epoch: i64) -> Option { /// Windows: convert the epoch through `FileTimeToLocalFileTime` so the /// machine's current time-zone rules apply. Pre-1970 timestamps cannot be -/// represented as an unsigned `FILETIME` and return `None` — the caller -/// already handles that. +/// represented as an unsigned `FILETIME` and return `None`. #[cfg(windows)] fn local_parts(epoch: i64) -> Option { use windows_sys::Win32::Foundation::{FILETIME, SYSTEMTIME}; use windows_sys::Win32::Storage::FileSystem::FileTimeToLocalFileTime; use windows_sys::Win32::System::Time::FileTimeToSystemTime; - // Windows FILETIME counts 100-nanosecond intervals since 1601-01-01 UTC; - // Unix epoch is 1970-01-01. + // Windows FILETIME counts 100-ns intervals since 1601-01-01; Unix epoch + // is 1970-01-01. const EPOCH_OFFSET_SECS: u64 = 11_644_473_600; const HNS_PER_SEC: u64 = 10_000_000; From 456bef1783527076ff301f9684b75da2874a13ef Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 28 Aug 2026 23:46:32 +0900 Subject: [PATCH 08/42] refactor(comments): keep only rationale comments in runtime --- src/runtime/emulator/boundary.rs | 37 ++++++--------- src/runtime/emulator/mod.rs | 54 +++++++++------------- src/runtime/emulator/modes.rs | 42 ++++++++--------- src/runtime/emulator/sync.rs | 20 ++++----- src/runtime/emulator/view.rs | 6 +-- src/runtime/snapshot.rs | 75 +++++++++++++------------------ src/runtime/terminal/attention.rs | 4 +- src/runtime/terminal/escape.rs | 20 ++++----- src/runtime/terminal/input.rs | 9 ++-- src/runtime/terminal/mod.rs | 37 +++++++-------- src/runtime/terminal/recovery.rs | 19 ++++---- src/runtime/terminal/scroll.rs | 25 +++++------ src/runtime/terminal/state.rs | 9 ++-- src/runtime/terminal/sync.rs | 1 - 14 files changed, 148 insertions(+), 210 deletions(-) diff --git a/src/runtime/emulator/boundary.rs b/src/runtime/emulator/boundary.rs index df823867..89ed7d8d 100644 --- a/src/runtime/emulator/boundary.rs +++ b/src/runtime/emulator/boundary.rs @@ -1,28 +1,19 @@ //! Whether a pane's byte stream stands at a sequence boundary. //! -//! A snapshot is spliced *into* the recorded stream on replay: everything up to -//! its anchor, then the snapshot, then everything after (see -//! `session::terminal::hub_replay`). PTY reads land at arbitrary byte offsets, -//! so a chunk can end in the middle of an escape sequence or a multi-byte -//! character — anchoring there hands a reattaching client the sequence's tail -//! as ordinary input (`ESC [ 2` before the seam, a literal `J` printed onto the -//! fresh screen after it). The emulator's own parser knows it is mid-sequence, -//! but does not say so; this mirrors just enough of its state machine to answer -//! "is a sequence in flight", so the anchor can wait for a chunk that ends -//! clean. +//! A snapshot is spliced *into* the recorded stream on replay (see +//! `session::terminal::hub_replay`), and PTY reads land at arbitrary byte +//! offsets — anchoring inside an escape sequence or multi-byte character hands +//! a reattaching client the sequence's tail as ordinary input. The emulator's +//! own parser knows it is mid-sequence but does not say so, so this mirrors +//! just enough of its state machine to answer "is a sequence in flight". //! -//! Mirrors the parser's *abort* semantics as well as its progress — `CAN`, -//! `SUB` and a fresh `ESC` cancel whatever was open — so this cannot drift into -//! claiming a sequence that the real parser has already abandoned. -//! -//! Where the mirror and the parser disagree, they disagree in the safe -//! direction only: this may stay "open" after the parser has moved on (the raw -//! `0x9c` ST that ends a DCS is not followed, and neither are the DCS -//! sub-states it would need), which merely defers a snapshot. It never reports -//! a boundary the parser would not also be at. The cost of deferring is the -//! caller's to bound — see the desperation rule in -//! `session::terminal::hub_run` — because a cap *here* would be a lie told to -//! every caller at once. +//! The mirror tracks the parser's *abort* semantics (`CAN`, `SUB`, a fresh +//! `ESC`) as well as its progress, so it cannot drift into claiming a sequence +//! the real parser has abandoned. Where they disagree, they disagree safely: +//! this may stay "open" after the parser moved on, which only defers a +//! snapshot — it never reports a boundary the parser would not also be at. +//! The cost of deferring is the caller's to bound (see `session::terminal::hub_run`); +//! a cap *here* would be a lie told to every caller at once. #[derive(Clone, Copy, PartialEq, Eq)] enum State { @@ -99,7 +90,6 @@ impl StreamBoundary { 0x18 | 0x1a => Ground, 0x1b => Escape, 0x30..=0x7e => Ground, - // C0 controls execute without closing the sequence. _ => Escape, }, EscapeIntermediate => match byte { @@ -111,7 +101,6 @@ impl StreamBoundary { Csi => match byte { 0x40..=0x7e | 0x18 | 0x1a => Ground, 0x1b => Escape, - // Parameters, intermediates, embedded C0, and DEL. _ => Csi, }, Osc => match byte { diff --git a/src/runtime/emulator/mod.rs b/src/runtime/emulator/mod.rs index e63b63f1..38ed14c0 100644 --- a/src/runtime/emulator/mod.rs +++ b/src/runtime/emulator/mod.rs @@ -107,8 +107,8 @@ impl PaneEmulator { } } - /// Feed raw PTY output through the emulator, updating the screen state. - /// Returns the side effects (title change, terminal query responses). + /// Feed raw PTY output through the emulator; returns the side effects for + /// the caller to act on. pub fn process(&mut self, bytes: &[u8]) -> EmulatorEvents { self.boundary.feed(bytes); self.processor.advance(&mut self.term, bytes); @@ -125,8 +125,8 @@ impl PaneEmulator { } /// Resize the emulated screen, reflowing wrapped lines. Safe for any - /// size change, including one that cuts a wide character at the new - /// last column (the vt100 panic this module exists to avoid). + /// size change, including one that cuts a wide character at the new last + /// column — the vt100 panic this module exists to avoid. pub fn resize(&mut self, rows: u16, cols: u16) { self.term.resize(term_size(rows, cols)); } @@ -157,27 +157,21 @@ impl PaneEmulator { ScreenView { term: &self.term } } - /// The bytes that reproduce this screen on another terminal. - /// - /// What a client attaching to a pane is given in place of the recorded - /// bytes that cannot rebuild its screen: all of them for a program drawing - /// on the alternate screen, the evicted front of the ring for one on the - /// normal screen. See [`snapshot`] for what a snapshot does and does not - /// carry. + /// The bytes that reproduce this screen on another terminal — what a + /// client attaching to a pane is given in place of the recorded bytes, + /// which cannot rebuild the screen (see [`snapshot`] for what a snapshot + /// does and does not carry). pub fn screen_snapshot(&self) -> Vec { snapshot::screen_snapshot(&self.term) } /// Whether everything processed so far has reached the screen and ends with /// every escape sequence and multi-byte character closed. A screen snapshot - /// may only be anchored at such a point: it is spliced into the recorded - /// stream on replay, and a seam inside a sequence hands a reattaching - /// client the sequence's tail as ordinary input (see [`boundary`]). - /// - /// "Reached the screen" is [`screen_current`](Self::screen_current); the - /// closed-sequences half is the [`boundary`] tracker. They are separate - /// questions because a caller forcing a snapshot over a torn seam may still - /// never take one of a grid with bytes missing. + /// may only be anchored at such a point: a seam inside a sequence hands a + /// reattaching client the sequence's tail as ordinary input (see + /// [`boundary`]). The two halves answer separate questions — a caller + /// forcing a snapshot over a torn seam may still never take one of a grid + /// with bytes missing. pub fn at_boundary(&self) -> bool { self.screen_current() && self.boundary.at_boundary() } @@ -185,20 +179,17 @@ impl PaneEmulator { /// Whether the grid holds everything processed. False while a synchronized /// update (DEC 2026) is open: the processor buffers its bytes without /// applying them, so a snapshot taken then is missing bytes the record - /// would count as covered. An update ends with `ESU`, at the processor's - /// own buffer cap, or — for one the program never closed — when its owner - /// ticks [`settle_sync`](Self::settle_sync). + /// would count as covered. pub fn screen_current(&self) -> bool { self.processor.sync_bytes_count() == 0 } /// Which input, if any, a scroll request for this pane must be turned - /// into. Mouse reporting wins over `alternateScroll` because a program - /// that asked for wheel events wants them even on the alternate screen. - /// + /// into. Mouse reporting wins over `alternateScroll` (a program that asked + /// for wheel events wants them even on the alternate screen), but /// `MOUSE_MODE` alone is not enough: without `SGR_MOUSE` the program - /// expects the legacy X10 encoding, which cannot address columns past - /// 223. Such a pane falls back to `Scrollback`. + /// expects legacy X10 encoding, which cannot address columns past 223 — + /// such a pane falls back to `Scrollback`. pub fn scroll_sink(&self) -> ScrollSink { let mode = self.term.mode(); if mode.intersects(TermMode::MOUSE_MODE) && mode.contains(TermMode::SGR_MOUSE) { @@ -211,8 +202,8 @@ impl PaneEmulator { } /// Whether the program asked for mouse button reports in SGR form — - /// the gate for forwarding clicks. A click has no scrollback fallback, - /// it is either claimed by the program or dropped. + /// the gate for forwarding clicks, which have no scrollback fallback: + /// a click is either claimed by the program or dropped. pub fn wants_mouse_buttons(&self) -> bool { let mode = self.term.mode(); mode.intersects(TermMode::MOUSE_MODE) && mode.contains(TermMode::SGR_MOUSE) @@ -245,9 +236,8 @@ impl PaneEmulator { } } -/// Clamp a requested pane size to alacritty's supported minimum grid. -/// A 1-column grid makes wide-character reflow loop forever on resize. -/// +/// Clamp a requested pane size to alacritty's supported minimum grid — +/// a 1-column grid makes wide-character reflow loop forever on resize. /// `TerminalState` applies the same clamp to the backend PTY size and its /// `last_content_size` bookkeeping, so the PTY, the emulator grid, and the /// recorded size can never diverge at degenerate layouts. diff --git a/src/runtime/emulator/modes.rs b/src/runtime/emulator/modes.rs index ee288a28..b6b96bf8 100644 --- a/src/runtime/emulator/modes.rs +++ b/src/runtime/emulator/modes.rs @@ -2,26 +2,23 @@ //! them. Split from `mod.rs` so the alacritty-facing wrapper and this plain //! description of a pane's state stay separately readable. -/// The terminal modes a program sets once, at startup, and never repeats. -/// -/// A client that attaches later cannot learn these from the output it is -/// replayed: the bytes that set them are long gone from the pane's history. -/// Carried as plain flags so a caller outside this module can hold and compare -/// them, and turned back into the sequences that reproduce them by -/// [`PaneModes::prelude`]. +/// The terminal modes a program sets once, at startup, and never repeats — +/// a later-attaching client cannot learn them from replayed output. Carried +/// as plain flags so a caller outside this module can hold and compare them, +/// and turned back into sequences by [`PaneModes::prelude`]. /// /// [`Default`] is a *freshly opened* terminal rather than all-false: `25` -/// (visible cursor), `7` (autowrap) and `1007` (alternate scroll) are on until a -/// program turns them off. Pinned to what the emulator actually starts with, so -/// an emulator upgrade that changes its initial mode set fails there rather than -/// silently mis-describing a pane that has printed nothing yet. +/// (visible cursor), `7` (autowrap) and `1007` (alternate scroll) are on until +/// a program turns them off. Pinned to what the emulator actually starts with, +/// so an emulator upgrade that changes its initial mode set fails there rather +/// than silently mis-describing a pane that has printed nothing yet. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PaneModes { /// DECSET 1049: the program draws on the alternate screen (vim, htop, - /// Claude Code in fullscreen rendering). The one mode that decides whether a - /// pane's recorded history is worth replaying at all — an alternate-screen - /// program's transcript lives in its own memory, and what reached the pane - /// is incremental paint, not text. + /// Claude Code in fullscreen rendering). The one mode that decides whether + /// a pane's recorded history is worth replaying at all — an + /// alternate-screen program's transcript lives in its own memory, and what + /// reached the pane is incremental paint, not text. pub alt_screen: bool, /// DECSET 1: arrows send `ESC O A` rather than `ESC [ A`. pub app_cursor: bool, @@ -70,14 +67,13 @@ impl PaneModes { /// The sequences that put another terminal into this state. /// /// **Every** tracked mode is emitted, set or reset, rather than only those - /// differing from a fresh terminal: the receiver is xterm.js, whose defaults - /// are its own business and need not match this emulator's (`1007` already - /// differs). An absolute prelude cannot be wrong about them; a relative one - /// would silently leave a mode at whatever the other side happens to start - /// with. The cost is a hundred bytes once per pane per connection. - /// - /// `1049` leads: it switches buffers, and the rest must land in the buffer - /// the program is drawing on. + /// differing from a fresh terminal: the receiver is xterm.js, whose + /// defaults are its own business and need not match this emulator's. An + /// absolute prelude cannot be wrong about them; a relative one would + /// silently leave a mode at whatever the other side happens to start with. + /// The cost is a hundred bytes once per pane per connection. `1049` leads: + /// it switches buffers, and the rest must land in the buffer the program + /// is drawing on. pub fn prelude(&self) -> Vec { let modes = [ (1049, self.alt_screen), diff --git a/src/runtime/emulator/sync.rs b/src/runtime/emulator/sync.rs index edf64dfe..05211876 100644 --- a/src/runtime/emulator/sync.rs +++ b/src/runtime/emulator/sync.rs @@ -1,16 +1,11 @@ //! Ending a synchronized update (DEC 2026) the program never closed. //! -//! Between `BSU` and `ESU` the processor holds the update's bytes back from the -//! grid, and ends the update only on `ESU` or at its own 2 MiB buffer cap. A -//! program that dies mid-frame — a TUI killed on exit, or one re-execing itself -//! to update — sends neither, and the pane it leaves behind produces nothing -//! but a shell prompt afterwards, so the cap is never reached either: the grid -//! stops moving for good while the shell underneath still takes input. The -//! pane looks frozen and is not. -//! -//! vte's answer is the 150 ms timeout it arms on `BSU` and leaves for its -//! caller to honour (alacritty ticks it from its event loop). Every owner of a -//! `PaneEmulator` ticks it here. +//! Between `BSU` and `ESU` the processor holds the update's bytes back from +//! the grid and ends it only on `ESU` or at its own 2 MiB buffer cap — a +//! program killed mid-frame sends neither, so the pane looks frozen while the +//! shell underneath still takes input. vte arms the 150 ms timeout on `BSU` +//! and leaves it for its caller to honour; every owner of a `PaneEmulator` +//! ticks it here. use super::{EmulatorEvents, PaneEmulator}; use std::time::Instant; @@ -27,7 +22,8 @@ impl PaneEmulator { /// End an open synchronized update, applying to the grid the bytes it held /// back. Harmless when none is open, but callers gate on - /// [`sync_expired`](Self::sync_expired) so a live update is never cut short. + /// [`sync_expired`](Self::sync_expired) so a live update is never cut + /// short. pub fn settle_sync(&mut self) -> EmulatorEvents { self.processor.stop_sync(&mut self.term); self.take_events() diff --git a/src/runtime/emulator/view.rs b/src/runtime/emulator/view.rs index 5a582ee7..77facd0e 100644 --- a/src/runtime/emulator/view.rs +++ b/src/runtime/emulator/view.rs @@ -102,9 +102,9 @@ impl CellView<'_> { /// Map an emulator color to a ratatui color. Named standard/bright colors /// become the equivalent indexed color so the user's terminal palette -/// applies; default foreground/background become `Reset` for the same -/// reason. Dim named colors map to their base color — `CellView::dim` -/// carries the dim attribute separately. +/// applies; default foreground/background and colors with no fixed palette +/// slot become `Reset` for the same reason. Dim named colors map to their +/// base color — `CellView::dim` carries the dim attribute separately. pub(super) fn to_ratatui_color(color: Color) -> ratatui::style::Color { use ratatui::style::Color as C; match color { diff --git a/src/runtime/snapshot.rs b/src/runtime/snapshot.rs index b85fb17e..1cb4ea36 100644 --- a/src/runtime/snapshot.rs +++ b/src/runtime/snapshot.rs @@ -13,21 +13,19 @@ mod worker; use worker::Worker; /// Owns the receiver and wake channel for the background snapshot thread. -/// Dropping the struct signals the worker to exit and joins it, so a repo switch -/// cannot leave the old-repo worker holding a `git2::Repository` after the new -/// channel is in place. +/// Dropping the struct signals the worker to exit and joins it, so a repo +/// switch cannot leave the old-repo worker holding a `git2::Repository` after +/// the new channel is in place. pub struct SnapshotChannel { rx: Receiver, - /// Cleared to stop reading the tree without stopping the worker. - /// - /// A `git status` is not free and one runs per channel. A caller that knows - /// nobody is reading turns it off rather than paying for snapshots that go - /// straight in the bin. The filesystem watch goes with it. + /// Cleared to stop reading the tree without stopping the worker: a + /// `git status` is not free and one runs per channel, so a caller that + /// knows nobody is reading turns it off. The filesystem watch goes with it. awake: Arc, /// Whether the worker is being told about *every* place a change can come /// from, rather than looking on a timer. False while asleep, on a tree the - /// watcher could not install on, and — until the first read answers where the - /// git directory is — on a checkout that keeps it outside the work tree. + /// watcher could not install on, and — until the first read answers where + /// the git directory is — on a checkout that keeps it outside the work tree. /// /// Nothing in production reads this — a failed watch is reported where it /// happens, and the reader behaves correctly either way. It exists so the @@ -36,10 +34,9 @@ pub struct SnapshotChannel { #[cfg(test)] watching: Arc, /// Wakes the worker: filesystem events, resumption, and the stop on drop. - /// One channel for all three, so an idle repository costs no wake-ups beyond - /// the interval that guards against missed events. - /// - /// Held in an `Option` so `Drop` can release it before joining the worker. + /// One channel for all three, so an idle repository costs no wake-ups + /// beyond the interval that guards against missed events. Held in an + /// `Option` so `Drop` can release it before joining the worker. wake: Option>, // None in test fixtures that construct an inert channel via // `from_endpoints` (no real worker to join). @@ -51,19 +48,17 @@ pub struct SnapshotChannel { /// poll cost and never more. const MIN_READ_INTERVAL: Duration = Duration::from_millis(1000); -/// Longest gap between two reads while awake and watching. -/// -/// A watcher can miss an event, or install on part of a tree and fail on the -/// rest, and "stale until the user happens to change something else" is not a -/// state to leave a file list in. With no watcher at all this is not used: the -/// reader falls back to [`MIN_READ_INTERVAL`]. +/// Longest gap between two reads while awake and watching — a watcher can miss +/// an event, and "stale until the user happens to change something else" is +/// not a state to leave a file list in. With no watcher at all this is not +/// used: the reader falls back to [`MIN_READ_INTERVAL`]. const IDLE_READ_INTERVAL: Duration = Duration::from_secs(10); /// Reopen the cached `git2::Repository` handle every N reads so we observe /// out-of-band repo changes (e.g. `git gc`, packfile rewrites, worktree moves) /// that the cached handle would otherwise serve stale. Counted in reads rather -/// than in seconds now that reads follow changes — a repository nobody touches -/// is not read, and does not need reopening either. +/// than seconds now that reads follow changes — a repository nobody touches is +/// not read, and does not need reopening either. const REOPEN_REPO_EVERY_READS: u32 = 30; impl SnapshotChannel { @@ -74,11 +69,10 @@ impl SnapshotChannel { /// Start without reading, for an owner that knows nobody is looking yet. /// - /// Separate from `spawn` followed by `set_awake(false)`, which is a race the - /// worker can win: it reads before that clears, which walks a tree nobody - /// asked about and leaves the reading queued to be published after a later, - /// newer one. The daemon opens every repository in a session and the browser - /// subscribes to one of them, so this is the ordinary case. + /// Separate from `spawn` followed by `set_awake(false)`, which is a race + /// the worker can win: it reads before that clears, which walks a tree + /// nobody asked about and leaves the reading queued to be published after + /// a later, newer one. pub fn spawn_asleep(repo_path: &str) -> Self { Self::start(repo_path, false) } @@ -109,12 +103,10 @@ impl SnapshotChannel { } } - /// A handle for turning the reading on and off from another thread. - /// - /// Separate from the channel because the channel owns a receiver and cannot - /// be shared, while whoever decides that nobody is reading — a server - /// counting its subscribers — is on a different thread from the one draining - /// it. + /// A handle for turning the reading on and off from another thread — + /// separate from the channel because the channel owns a receiver and + /// cannot be shared, while whoever decides nobody is reading (a server + /// counting subscribers) is on a different thread from the one draining it. pub fn watch(&self) -> SnapshotWatch { SnapshotWatch { awake: Arc::clone(&self.awake), @@ -140,9 +132,9 @@ impl SnapshotChannel { self.rx.try_recv() } - /// Build a `SnapshotChannel` from an externally provided receiver. Lets - /// tests construct an inert channel (no worker thread, no watcher) so they - /// can inject snapshots directly instead of booting the background reader. + /// Build a `SnapshotChannel` from an externally provided receiver, so + /// tests can construct an inert channel (no worker thread, no watcher) + /// and inject snapshots directly. #[cfg(test)] pub(crate) fn from_endpoints(rx: Receiver) -> Self { Self { @@ -166,8 +158,8 @@ impl SnapshotWatch { pub fn set_awake(&self, awake: bool) { self.awake.store(awake, Ordering::Release); // Woken rather than left to the interval: resuming means a client is - // waiting to see this repository, and it must not sit behind a timer that - // exists for missed events. + // waiting to see this repository, and it must not sit behind a timer + // that exists for missed events. if let Some(wake) = &self.wake { let _ = wake.send(Wake::Changed(Vec::new())); } @@ -176,14 +168,11 @@ impl SnapshotWatch { impl Drop for SnapshotChannel { fn drop(&mut self) { - // Release the wake sender first: the worker's `recv_timeout` observes the - // stop immediately rather than sitting out the idle interval. + // Release the wake sender first: the worker's `recv_timeout` observes + // the stop immediately rather than sitting out the idle interval. if let Some(wake) = self.wake.take() { let _ = wake.send(Wake::Stop); } - // Wait for the worker to finish its current `load_snapshot` so a - // `change_repo` doesn't leave the old-repo worker running with a - // live `git2::Repository` after the new channel is installed. // Bounded join: a worker stuck inside libgit2 (corrupted packfile, // hung NFS) must not freeze app shutdown / repo switch. if let Some(h) = self.handle.take() { diff --git a/src/runtime/terminal/attention.rs b/src/runtime/terminal/attention.rs index 56175bcc..97a074d6 100644 --- a/src/runtime/terminal/attention.rs +++ b/src/runtime/terminal/attention.rs @@ -85,8 +85,8 @@ impl TerminalState { pub fn acknowledge_attention(&mut self) { self.unread_attention = false; // Activity already visible on this screen must not settle into a new - // unread event after the user switches away. If it keeps running in - // the background, later title changes start a fresh observation. + // unread event after the user switches away; later title changes + // start a fresh observation. self.title_activity.clear(); } } diff --git a/src/runtime/terminal/escape.rs b/src/runtime/terminal/escape.rs index 7b330dc1..1c40d0d8 100644 --- a/src/runtime/terminal/escape.rs +++ b/src/runtime/terminal/escape.rs @@ -35,8 +35,7 @@ fn consume_escape_sequence(chars: &mut std::iter::Peekable>) } Some('(') | Some(')') | Some('*') | Some('+') | Some('-') | Some('.') | Some('/') | Some('#') => { - // Charset designators / DEC private 2-byte escapes: - // ESC . Skip both. + // Charset designators / DEC private 2-byte escapes: skip both bytes. chars.next(); chars.next(); } @@ -50,12 +49,10 @@ fn consume_escape_sequence(chars: &mut std::iter::Peekable>) } /// CSI: consume parameter/intermediate bytes (0x20–0x3f), stop at the final -/// byte (0x40–0x7e). Break early on a control char so content that follows a -/// malformed sequence isn't accidentally eaten — and leave that control byte -/// in the iterator: eating it here would silently drop a `\n` or `\r` that -/// the outer pass needs to flush the prompt buffer. DEL (0x7f) is treated -/// per ECMA-48 as a no-op inside the sequence: consumed but does not stand -/// in for a final byte. +/// byte (0x40–0x7e). Break early on a control char and leave it in the +/// iterator: eating it here would silently drop a `\n`/`\r` the outer pass +/// needs to flush the prompt buffer. DEL (0x7f) is treated per ECMA-48 as a +/// no-op inside the sequence. fn consume_csi(chars: &mut std::iter::Peekable>) { while let Some(&c) = chars.peek() { if c < '\x20' { @@ -85,10 +82,9 @@ fn consume_osc(chars: &mut std::iter::Peekable>) { } } -/// SS3: ESC O . Used by xterm-style application keypad for arrow/ -/// function keys. Consume the next char only when it looks like a valid SS3 -/// final byte (0x40–0x7e) — a malformed `ESC O ` sequence used to swallow -/// the following ordinary char. +/// SS3: ESC O . Consume the next char only when it looks like a valid +/// SS3 final byte (0x40–0x7e) — a malformed `ESC O ` sequence used to +/// swallow the following ordinary char. fn consume_ss3(chars: &mut std::iter::Peekable>) { if let Some(&next) = chars.peek() && ('\x40'..='\x7e').contains(&next) diff --git a/src/runtime/terminal/input.rs b/src/runtime/terminal/input.rs index 269480e9..3a0a06e8 100644 --- a/src/runtime/terminal/input.rs +++ b/src/runtime/terminal/input.rs @@ -36,16 +36,15 @@ impl TerminalState { } } // 0x7f (DEL, sent by Backspace) and 0x08 (BS, sent by Ctrl+H) - // both remove the previous typed char. Without this branch the + // both remove the previous typed char; without this branch the // prompt log would accumulate typos the user already corrected. '\x7f' | '\x08' => { buf.pop(); } _ => { - // Cap to bound memory under degenerate "no-newline" producers - // (progress bars piped through cat, paste of a multi-MB - // string, etc.). Dropping further chars before the next flush - // is preferable to letting the buffer grow without limit. + // Cap to bound memory under degenerate "no-newline" + // producers (progress bars piped through cat, pastes of + // multi-MB strings); dropping chars beats unbounded growth. if buf.len() < PROMPT_BUFFER_MAX_BYTES { buf.push(ch); } diff --git a/src/runtime/terminal/mod.rs b/src/runtime/terminal/mod.rs index d2100c13..6beb9398 100644 --- a/src/runtime/terminal/mod.rs +++ b/src/runtime/terminal/mod.rs @@ -18,9 +18,8 @@ pub(crate) use escape::strip_escape_sequences; pub use recovery::PaneRecovery; /// Upper bound on a pane's in-flight prompt buffer before further chars are -/// dropped. Prevents unbounded growth when a program writes a stream of bytes -/// without ever sending `\r` / `\n` (progress bars, large pastes, `yes` piped -/// to cat). +/// dropped, so a program writing bytes without ever sending `\r`/`\n` +/// (progress bars, large pastes) cannot grow it without limit. const PROMPT_BUFFER_MAX_BYTES: usize = 4096; /// Scrollback line cap for every pane emulator. @@ -29,7 +28,7 @@ pub const SCROLLBACK_LINES: usize = 1000; /// Lines moved by a single line-scroll keypress (`Shift+Up`/`Shift+Down`). pub const SCROLL_LINE_STEP: usize = 3; -/// Lines one mouse wheel notch scrolls, by terminal convention. Used to +/// Lines one mouse wheel notch scrolls, by terminal convention — used to /// convert a line count into a notch count when a pane wants wheel events, /// and by the mouse handler as the line count of one captured wheel event. pub const WHEEL_LINES_PER_NOTCH: usize = 3; @@ -48,13 +47,9 @@ pub const MAX_VISIBLE_NORMAL: usize = 4; pub const MAX_VISIBLE_FULLSCREEN: usize = 8; /// Fullscreen state of the lower terminal panel. ` f` cycles through -/// `Off → Grid → Zoom → Off`. -/// - `Off`: normal split — top viewer above, terminal split-view below. -/// - `Grid`: terminal fills the body; up to `MAX_VISIBLE_FULLSCREEN` panes. -/// - `Zoom`: terminal fills the body showing only the active pane. -/// -/// `Grid` and `Zoom` are visually identical whenever `Grid` would show a -/// single pane, so the cycle skips `Zoom` in that case. +/// `Off → Grid → Zoom → Off`. `Grid` and `Zoom` are visually identical +/// whenever `Grid` would show a single pane, so the cycle skips `Zoom` in +/// that case (see [`TerminalState::zoom_distinct_from_grid`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum TerminalFullscreen { #[default] @@ -71,12 +66,11 @@ impl TerminalFullscreen { } } -/// Compute the visible pane-index window `[start, start+len)` for a split -/// grid capped at `max_visible` panes. `prev_start` is the previous window's -/// start (0 for a fresh terminal); the window is nudged the minimum amount -/// needed to keep `active` inside it, rather than re-centering every call. -/// Shared by `TerminalState::sync_visible_window` and `ui::terminal_tab` so -/// both always agree on what's visible. +/// Compute the visible pane-index window for a split grid capped at +/// `max_visible` panes. The window is nudged the minimum amount needed to +/// keep `active` inside it, rather than re-centering every call. Shared by +/// `TerminalState::sync_visible_window` and `ui::terminal_tab` so both always +/// agree on what's visible. pub(crate) fn visible_range( prev_start: usize, active: usize, @@ -120,11 +114,10 @@ pub struct TerminalState { /// True unless a shared session says otherwise: a PTY has one size, so one /// client decides it and the others render the grid they are given. pub owns_size: bool, - /// What each pane's plugin last reported about recovering it, for the panes - /// any has spoken about. Deliberately outlives a pane's process: the report - /// that matters most arrives while the pane is gone and its slot is held for - /// a relaunch. Cleared only by a `cancelled` report (see - /// [`recovery::RECOVERY_CANCELLED`]). + /// What each pane's plugin last reported about recovering it. Deliberately + /// outlives a pane's process: the report that matters most arrives while + /// the pane is gone and its slot is held for a relaunch. Cleared only by + /// a `cancelled` report (see [`recovery::RECOVERY_CANCELLED`]). pub(crate) recovery: HashMap, /// Index of the first pane in the visible split-view window. pub visible_start: usize, diff --git a/src/runtime/terminal/recovery.rs b/src/runtime/terminal/recovery.rs index 355451a0..6da3b1a3 100644 --- a/src/runtime/terminal/recovery.rs +++ b/src/runtime/terminal/recovery.rs @@ -61,14 +61,12 @@ impl TerminalState { self.recovery.get(&pane) } - /// The one report a person is looking at, and the one the cancel key acts on. - /// - /// The focused pane's own report comes first. Failing that, a report for a - /// pane this client no longer lists — a pane whose process has ended while - /// its slot is held for a relaunch. That pane cannot be focused, and it is - /// exactly the one someone would want to release, so it must still be - /// reachable. Lowest id wins so the display and the key can never disagree - /// about which one that is. + /// The one report a person is looking at, and the one the cancel key acts + /// on: the focused pane's own report first, failing that a report for a + /// pane this client no longer lists (its process ended while its slot is + /// held for a relaunch). That pane cannot be focused, and it is exactly + /// the one someone would want to release. Lowest id wins so the display + /// and the key can never disagree about which one that is. pub fn recovery_focus(&self) -> Option<(PaneId, &PaneRecovery)> { if let Some(pane) = self.active_pane_id() && let Some(report) = self.recovery.get(&pane) @@ -89,10 +87,9 @@ impl TerminalState { } /// Ask the session to give up on the recovery a person is looking at. - /// /// Nothing is cleared here: the entry goes when the session broadcasts - /// `cancelled`, which is also what tells every other client. Assuming it - /// locally would hide a cancellation the session refused. + /// `cancelled` (which tells every other client too) — assuming it locally + /// would hide a cancellation the session refused. pub fn cancel_recovery(&mut self) { let Some((pane, _)) = self.recovery_focus() else { return; diff --git a/src/runtime/terminal/scroll.rs b/src/runtime/terminal/scroll.rs index 300dc22f..8b05c878 100644 --- a/src/runtime/terminal/scroll.rs +++ b/src/runtime/terminal/scroll.rs @@ -17,11 +17,9 @@ impl TerminalState { /// Scroll pane `id` by `lines`, delivering the request wherever that /// pane's program expects it (see `ScrollSink`). `pointer` is the 1-based /// pane-local cell of a captured mouse wheel event, when there is one. - /// /// Only the `Scrollback` sink moves the emulator's view; the other two /// synthesize input, because a program that owns its viewport keeps its - /// transcript out of the emulator's grid entirely and scrolling the grid - /// would reveal nothing. + /// transcript out of the emulator's grid entirely. pub fn scroll_pane(&mut self, id: PaneId, up: bool, lines: usize, pointer: Option<(u16, u16)>) { if lines == 0 { return; @@ -36,10 +34,9 @@ impl TerminalState { ScrollSink::MouseWheel => { // A TUI may pick which of its regions to scroll from the // report's coordinates, so a captured wheel event passes the - // real pointer cell through. Keyboard scrolls have no - // pointer and report the pane's centre instead — the only - // cell guaranteed to be inside the transcript rather than on - // a border or input box. + // real pointer cell through. Keyboard scrolls have no pointer + // and report the pane's centre instead — the only cell + // guaranteed to be inside the transcript. let (col, row) = match pointer { Some(cell) => cell, None => { @@ -73,8 +70,7 @@ impl TerminalState { /// Forward a horizontal wheel notch to pane `id` as an SGR report at the /// pointer cell. Horizontal scrolling has no scrollback or arrow-key /// analog, so there is no sink dispatch: a pane whose program asked for - /// wheel reports receives the notch, every other pane silently drops it - /// (the same rule as `click_pane`). + /// wheel reports receives the notch, every other pane silently drops it. pub fn wheel_horizontal_pane(&mut self, id: PaneId, left: bool, col: u16, row: u16) { let Some(emulator) = self.emulators.get(&id) else { return; @@ -88,11 +84,10 @@ impl TerminalState { /// Forward a mouse button press or release to pane `id`, translated to an /// SGR report at 1-based pane-local `col`/`row`. Only a pane whose program - /// asked for SGR mouse reports receives anything: a click has no - /// scrollback fallback, so an unclaimed click is dropped — the same - /// silence rule that keeps scroll bytes out of plain shells. Returns - /// whether the report was sent, so the caller can pair a forwarded press - /// with its eventual release. + /// asked for SGR mouse reports receives anything: an unclaimed click is + /// dropped — the same silence rule that keeps scroll bytes out of plain + /// shells. Returns whether the report was sent, so the caller can pair a + /// forwarded press with its eventual release. pub fn click_pane( &mut self, id: PaneId, @@ -112,7 +107,7 @@ impl TerminalState { true } - /// Write straight to a pane's PTY. Bypasses `send_input` on purpose: + /// Write straight to a pane's PTY, bypassing `send_input` on purpose: /// input we synthesized on the user's behalf must not clear their scroll /// position or land in the prompt log, for the same reason the emulator's /// query replies in `poll` bypass it. diff --git a/src/runtime/terminal/state.rs b/src/runtime/terminal/state.rs index 63245045..1d04f1a9 100644 --- a/src/runtime/terminal/state.rs +++ b/src/runtime/terminal/state.rs @@ -18,9 +18,8 @@ impl TerminalState { } } - /// Whether `Zoom` would render differently from `Grid` — i.e. whether - /// `Grid` would show more than one pane. When false the two are - /// indistinguishable, so the fullscreen cycle skips `Zoom` and a pane + /// Whether `Zoom` would render differently from `Grid`. When false the two + /// are indistinguishable, so the fullscreen cycle skips `Zoom` and a pane /// close normalizes `Zoom` back to `Grid`. Guards against both a lone pane /// and a `max_visible_fullscreen` of 1, so no site has to assume the cap /// is ≥ 2. @@ -38,8 +37,8 @@ impl TerminalState { } /// Row count used for terminal-scroll paging: the active pane's own - /// content height when known, otherwise the default pane size. Callers - /// used to read `size` directly, which no longer tracks per-pane height. + /// content height when known, otherwise the default pane size (callers + /// used to read `size` directly, which no longer tracks per-pane height). pub fn active_pane_rows(&self) -> usize { self.active_pane_id() .map(|id| self.pane_size(id).0 as usize) diff --git a/src/runtime/terminal/sync.rs b/src/runtime/terminal/sync.rs index f7c78a79..1fd03caa 100644 --- a/src/runtime/terminal/sync.rs +++ b/src/runtime/terminal/sync.rs @@ -15,7 +15,6 @@ use super::TerminalState; impl TerminalState { /// Route what an emulator produced while processing: a window title to the /// pane's tab, and terminal query replies back to the program that asked. - /// /// Replies bypass [`send_input`](Self::send_input) on purpose: an /// emulator-generated answer must not clear the user's scroll position or /// land in the prompt log. From 328790e9a9f18d63b429fab18baf902049af92ed Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 28 Aug 2026 23:43:50 +0900 Subject: [PATCH 09/42] refactor(comments): keep only rationale comments in session --- src/session/catalog/views.rs | 20 ++++++++--------- src/session/prefs/mod.rs | 23 +++++++++----------- src/session/size_owner_audit.rs | 20 ++++++----------- src/session/size_owner_state.rs | 10 ++++----- src/session/terminal/hub_connect.rs | 25 +++++++++------------- src/session/terminal/hub_diag.rs | 19 ++++++----------- src/session/terminal/hub_modes.rs | 23 ++++++-------------- src/session/terminal/hub_panes.rs | 9 ++++---- src/session/terminal/hub_plugins.rs | 10 ++++----- src/session/terminal/hub_reload.rs | 30 +++++++++++--------------- src/session/terminal/hub_replay.rs | 23 +++++++------------- src/session/terminal/hub_zoom.rs | 33 +++++++++++------------------ src/session/terminal/size_owner.rs | 24 ++++++++------------- 13 files changed, 103 insertions(+), 166 deletions(-) diff --git a/src/session/catalog/views.rs b/src/session/catalog/views.rs index 48ddf38d..3d0f3c5f 100644 --- a/src/session/catalog/views.rs +++ b/src/session/catalog/views.rs @@ -42,11 +42,9 @@ impl Catalog { } /// The served list and, from that same snapshot, the id standing for - /// `remembered`. - /// - /// One lock for both, because a client renders them together: a repository - /// opened between two separate reads would yield an active id missing from - /// the list beside it. + /// `remembered` — one lock for both, because a client renders them together + /// and a repository opened between two separate reads would yield an active + /// id missing from the list beside it. pub fn list_with_active( &self, remembered: Option<&str>, @@ -61,9 +59,9 @@ impl Catalog { .find(|e| e.path == path) .map(|e| e.id.clone()) }); - // From the same snapshot for the same reason: a repository opened - // between two reads would be in the list with no arrangement beside it, - // or have one under an id the list does not carry. + // All from the same snapshot: a repository opened between two reads + // would be in the list with no arrangement beside it, or have one under + // an id the list does not carry. let arrangements = entries .iter() .filter_map(|e| { @@ -71,9 +69,9 @@ impl Catalog { .map(|panel| (e.id.clone(), panel)) }) .collect(); - // And the same again for what each was showing. A project the session - // is not serving keeps its entry on file — there is no id to name it by - // here, and it will want it back when it is opened. + // A project the session is not serving keeps its entry on file — there + // is no id to name it by here, and it will want it back when it is + // opened. let last_views = entries .iter() .filter_map(|e| { diff --git a/src/session/prefs/mod.rs b/src/session/prefs/mod.rs index 7c7597f4..dac37070 100644 --- a/src/session/prefs/mod.rs +++ b/src/session/prefs/mod.rs @@ -1,6 +1,5 @@ -//! Preferences that follow the user rather than the browser they arrived in. -//! -//! Stored in `~/.nightcrow/viewer.json`. The accent is the session's (shared +//! Preferences that follow the user rather than the browser they arrived in, +//! stored in `~/.nightcrow/viewer.json`. The accent is the session's (shared //! with an attached TUI); `sidebar_width` and `upper_pct` are the viewer's alone //! — the first has no TUI counterpart, and the second is deliberately not shared //! because a percentage means different things on a terminal vs a browser window. @@ -39,17 +38,15 @@ pub struct ViewerPrefs { pub accent: usize, /// File-sidebar width in CSS px, clamped to `[MIN, MAX]`. pub sidebar_width: u32, - /// Share of the vertical split given to the diff panel, in percent. - /// - /// The viewer's own, not the session's — unlike the accent. A percentage - /// means different things on a terminal vs a browser window, so sharing with - /// the TUI's `layout.upper_pct` was rejected. + /// Share of the vertical split given to the diff panel, in percent. The + /// viewer's own, not the session's — a percentage means different things on + /// a terminal vs a browser window, so sharing with the TUI's + /// `layout.upper_pct` was rejected. pub upper_pct: u32, - /// Absolute worktree path of the last-selected project. - /// - /// A **path**, not the repo id: ids only live as long as the process, so a - /// stored id would name nothing after a restart. The server translates; the - /// client never learns the path. `None` until a client selects a project. + /// Absolute worktree path of the last-selected project. A **path**, not the + /// repo id: ids only live as long as the process, so a stored id would name + /// nothing after a restart. The server translates; the client never learns + /// the path. `None` until a client selects a project. pub active_repo: Option, /// Which panel each project was left maximized in, most recently set first. pub maximized: Vec, diff --git a/src/session/size_owner_audit.rs b/src/session/size_owner_audit.rs index 340edf24..dfe98eef 100644 --- a/src/session/size_owner_audit.rs +++ b/src/session/size_owner_audit.rs @@ -1,16 +1,10 @@ -//! What the sizing did, written down. -//! -//! Which screen the PTYs are fitted to changes what *every* attached client -//! renders, and a client that loses it stays wrong until a person presses the -//! fit button. It also moves for reasons no client can observe — a viewer's -//! last connection going, a grace expiring on a worker tick — so with nothing -//! recorded there is only the symptom to read afterwards. A phone that kept -//! coming back a spectator had to be diagnosed by reasoning backwards from the -//! button, because none of this was written anywhere. -//! -//! INFO rather than DEBUG: these happen per page load and per repository -//! switch, not per frame, and the moment they are wanted is a report about -//! something that already happened — which is too late to raise the level. +//! What the sizing did, written down. It changes what *every* attached client +//! renders, it moves for reasons no client can observe (a last connection +//! going, a grace expiring on a worker tick), and a client that loses it stays +//! wrong until a person presses the fit button — so with nothing recorded there +//! is only the symptom to read afterwards. INFO rather than DEBUG: these happen +//! per page load, not per frame, and the moment they are wanted is a report +//! about something that already happened — too late to raise the level. use super::ViewerId; diff --git a/src/session/size_owner_state.rs b/src/session/size_owner_state.rs index a7befd22..f5826646 100644 --- a/src/session/size_owner_state.rs +++ b/src/session/size_owner_state.rs @@ -1,10 +1,8 @@ //! The bookkeeping behind [`SizeOwnership`](super::SizeOwnership), and every -//! rule that reads or writes it. -//! -//! Split from the facade so that all of it happens where the fields live: the -//! rules are a handful of interlocking conditions over who is present, who owns -//! the sizing and how long it has been unattended, and spreading them across a -//! module boundary would mean opening those fields up to reach them. +//! rule that reads or writes it. Split from the facade so the rules — a handful +//! of interlocking conditions over presence, ownership and idle time — live +//! where the fields are, rather than opening those fields across a module +//! boundary. use super::{RELEASE_GRACE, Registration, ViewerId, audit}; use crate::session::terminal::frame::{ServerMessage, TerminalFrame}; diff --git a/src/session/terminal/hub_connect.rs b/src/session/terminal/hub_connect.rs index d302a5be..74fc1f15 100644 --- a/src/session/terminal/hub_connect.rs +++ b/src/session/terminal/hub_connect.rs @@ -13,23 +13,18 @@ use std::time::Instant; impl TerminalHub { /// Register a client and put the current terminals in front of it before it - /// is eligible for broadcasts. - /// - /// Per live pane: a `Created`, the modes its program has set - /// ([`PaneModes::prelude`](crate::runtime::emulator::PaneModes::prelude)), and - /// then that pane's screen (see [`replay_pane`]). Done under the state lock - /// so this snapshot cannot interleave with the worker's append-and-broadcast; - /// the client therefore receives every pane's screen exactly once and in - /// order ahead of the live stream. + /// is eligible for broadcasts: per live pane, a `Created`, the modes its + /// program has set, and then that pane's screen (see [`replay_pane`]). Done + /// under the state lock so this snapshot cannot interleave with the worker's + /// append-and-broadcast — the client receives every pane's screen exactly + /// once, in order, ahead of the live stream. /// /// `viewer` names who this connection belongs to and `arriving` says whether - /// a person just sat down at it — a page opening rather than a repository - /// switch or a reconnect. Only the second takes the sizing; see - /// [`SizeOwnership`](crate::session::size_owner::SizeOwnership). - /// - /// `socket` is a handle on the connection to end if this client stops - /// keeping up, and `None` for one that has no socket here at all — see - /// [`Client::socket`](super::session::Client::socket). + /// a person just sat down at it; only the second takes the sizing (see + /// [`SizeOwnership`](crate::session::size_owner::SizeOwnership)). `socket` + /// is the handle used to end the connection if this client stops keeping up, + /// `None` for a client with no socket here (see + /// [`Client::socket`](super::session::Client::socket)). pub fn connect( self: &Arc, viewer: ViewerId, diff --git a/src/session/terminal/hub_diag.rs b/src/session/terminal/hub_diag.rs index 44652309..f82e61f2 100644 --- a/src/session/terminal/hub_diag.rs +++ b/src/session/terminal/hub_diag.rs @@ -1,15 +1,10 @@ -//! Recording where a pane's screen-clearing input came from. -//! -//! This exists because of a specific unexplained event: a pane running Claude -//! Code had its conversation cleared fourteen times in five seconds. Claude -//! Code runs `/clear` when it receives `Ctrl+L` twice within two seconds, and -//! the transcript showed the clears arriving as a shortcut rather than as typed -//! input — so `0x0c` reached the pane about thirty times, at a machine-like -//! cadence, and nobody knows what sent it. nightcrow itself does not: the only -//! bytes it synthesizes are scroll and mouse reports and a plugin's -//! `continue`, and that one is logged where it happens. That leaves a client's -//! own input, so this notes the arrival and its shape and the client says what -//! produced it (`ClientMessage::ClearKeyReport`, logged in `session.rs`). +//! Recording where a pane's screen-clearing input came from. This exists +//! because of a specific unexplained event — a pane's conversation cleared +//! fourteen times in five seconds, the clears arriving as `0x0c` at a +//! machine-like cadence nobody could attribute. nightcrow does not synthesize +//! that byte, which leaves a client's own input: so this notes the arrival and +//! its shape, and the client says what produced it +//! (`ClientMessage::ClearKeyReport`, logged in `session.rs`). //! //! **No input content is logged, ever** — only the byte's count, how much else //! rode with it, and the timing. diff --git a/src/session/terminal/hub_modes.rs b/src/session/terminal/hub_modes.rs index 7489a8ed..9acaad64 100644 --- a/src/session/terminal/hub_modes.rs +++ b/src/session/terminal/hub_modes.rs @@ -1,23 +1,14 @@ //! What state each pane's program has put its terminal into, and what it calls -//! itself. -//! -//! A client attaching mid-session is replayed a window of output that almost -//! never contains the bytes that set the pane's modes — a program announces -//! them once, at startup, and the ring has long since evicted that. The hub -//! follows them here and hands a connecting client the answer directly. A -//! window title has exactly that shape too, so it is followed here rather than -//! left to each client: a page that connected later had no way to learn it and -//! fell back to a positional label. +//! itself, followed here because a client attaching mid-session is replayed a +//! window of output that almost never contains the bytes that set them — a +//! program announces modes once, at startup, and the ring has long since +//! evicted that. Titles have the same shape, so they are followed here too. //! //! Kept on the worker thread rather than in [`Shared`](super::Shared): a //! `PaneEmulator` holds `Rc`, so it is not `Send` and cannot live behind the -//! state mutex. -//! -//! **The grid is read, so resizes have to be followed.** These emulators used -//! to be scratch space for a mode parser; now `snapshot` reads the cells, so a -//! grid at the wrong width would wrap output where the child does not and hand -//! a connecting client a screen laid out differently from every other client's. -//! `hub_layout::resize_pane` is the one place that has to call `resize`. +//! state mutex. The grid is read (for `snapshot`), so resizes have to be +//! followed — `hub_layout::resize_pane` is the one place that must call +//! `resize`. use crate::backend::PaneId; use crate::runtime::emulator::{PaneEmulator, PaneModes}; diff --git a/src/session/terminal/hub_panes.rs b/src/session/terminal/hub_panes.rs index 436bc59a..29e4dda1 100644 --- a/src/session/terminal/hub_panes.rs +++ b/src/session/terminal/hub_panes.rs @@ -1,9 +1,8 @@ //! The hub's pane records: adding one, appending to it, dropping it, and the -//! two questions the worker asks about the list before it acts. -//! -//! Every one of these pairs a change to `Shared` with the broadcast that -//! announces it, under a single lock — that pairing is what keeps a client -//! connecting mid-change from seeing a pane twice or not at all (see +//! two questions the worker asks about the list before it acts. Every one of +//! these pairs a change to `Shared` with the broadcast that announces it, under +//! a single lock — that pairing is what keeps a client connecting mid-change +//! from seeing a pane twice or not at all (see //! [`Shared`](super::hub_helpers::Shared)). use super::TerminalHub; diff --git a/src/session/terminal/hub_plugins.rs b/src/session/terminal/hub_plugins.rs index ddbc484a..e1f54f8a 100644 --- a/src/session/terminal/hub_plugins.rs +++ b/src/session/terminal/hub_plugins.rs @@ -1,10 +1,8 @@ //! The plugin side of a terminal worker: which panes a plugin may see, the -//! hosts watching them, and the slots being held open for a relaunch. -//! -//! Every field here is worker-local. A plugin can drive a pane's keyboard, so -//! none of this is reachable from a connection thread — the only way in is the -//! command queue the worker already drains, and the only way out is -//! [`crate::plugin::Guard`]. +//! hosts watching them, and the slots being held open for a relaunch. Every +//! field here is worker-local — a plugin can drive a pane's keyboard, so the +//! only way in is the command queue the worker already drains, and the only way +//! out is [`crate::plugin::Guard`]. //! //! A pane appears here only two ways: its `[[startup_command]]` named a plugin //! by hand, or a plugin asked for it by quoting the pane's own token and the diff --git a/src/session/terminal/hub_reload.rs b/src/session/terminal/hub_reload.rs index dea31188..6079e40f 100644 --- a/src/session/terminal/hub_reload.rs +++ b/src/session/terminal/hub_reload.rs @@ -1,24 +1,18 @@ -//! Re-applying the `[[plugin]]` table on a hub that is already running. -//! -//! A plugin is a child process rather than a pane, so unlike a startup terminal +//! Re-applying the `[[plugin]]` table on a hub that is already running. A +//! plugin is a child process rather than a pane, so unlike a startup terminal //! it can be replaced without costing the session anything a person was using. //! -//! Three rules the diff below is written around. -//! -//! **The opt-ins are this hub's, not the new file's.** A hub creates its startup -//! panes once for its life, so a `[[startup_command]]` added by the edit has no -//! pane here and will not get one. What decides is the list this hub was spawned -//! with ([`TerminalHub::startup_commands`]). -//! -//! **A plugin that is watching something stays.** Removing a pane's opt-in from -//! the file does not remove the pane, and silently un-watching a live agent -//! terminal is worse than keeping a host the file no longer asks for. Turning -//! `enabled` off is the way to say stop; that is honoured. +//! Three rules the diff below is written around: //! -//! **The guard is never rebuilt.** Its relaunch budget is keyed by a pane's -//! token, which is what bounds a plugin that answers every exit with another -//! relaunch. Rebuilding it here would hand out a fresh allowance on every -//! reload, so the ceiling would never be reached. +//! - **The opt-ins are this hub's, not the new file's.** A hub creates its +//! startup panes once for its life, so what decides is the list this hub was +//! spawned with. +//! - **A plugin that is watching something stays.** Silently un-watching a live +//! agent terminal is worse than keeping a host the file no longer asks for; +//! `enabled = false` is the way to say stop. +//! - **The guard is never rebuilt.** Its relaunch budget is keyed by a pane's +//! token; rebuilding it here would hand out a fresh allowance on every reload +//! and the ceiling would never be reached. use super::TerminalHub; use super::hub_helpers::Command; diff --git a/src/session/terminal/hub_replay.rs b/src/session/terminal/hub_replay.rs index 16f69418..7a441f9c 100644 --- a/src/session/terminal/hub_replay.rs +++ b/src/session/terminal/hub_replay.rs @@ -14,21 +14,14 @@ const LEAVE_ALT_SCREEN: &[u8] = b"\x1b[?1049l"; /// Largest payload one replay frame carries. /// -/// **Frame boundaries mean nothing to a client.** It concatenates what arrives -/// into its emulator, whose parser spans writes, so a sequence split across -/// two frames is reassembled the same as if it had come in one — splitting is -/// free. -/// -/// A single frame, on the other hand, does have a ceiling: the daemon socket -/// refuses a payload over [`MAX_FRAME_BYTES`](crate::daemon::frame::MAX_FRAME_BYTES) -/// (4 MiB), and an alternate-screen pane's screen grows with its area — a large -/// truecolour pane reaches several megabytes. Sent whole it ended the attach -/// connection, and again on every reconnect. Nobody transmits a screen as one -/// indivisible message: VS Code's replay is a list of entries, tmux writes to a -/// passed file descriptor, and mosh's datagrams cannot hold a screen at all. -/// -/// 1 MiB stays well under that ceiling while keeping the frame count low -/// enough that a whole replay of the largest panes this hub allows fits in +/// Frame boundaries mean nothing to a client (it concatenates into its +/// emulator, whose parser spans writes), so splitting is free — but a single +/// frame has a ceiling: the daemon socket refuses a payload over +/// [`MAX_FRAME_BYTES`](crate::daemon::frame::MAX_FRAME_BYTES) (4 MiB), and an +/// alternate-screen pane's screen grows with its area, so sending one whole +/// ended the attach connection on every reconnect. 1 MiB stays well under that +/// ceiling while keeping the frame count low enough that a whole replay of the +/// largest panes this hub allows fits in /// [`CLIENT_QUEUE_DEPTH`](super::CLIENT_QUEUE_DEPTH) — which is what makes it /// safe to queue the replay before the client is registered, with nothing else /// writing to that queue. diff --git a/src/session/terminal/hub_zoom.rs b/src/session/terminal/hub_zoom.rs index 3f3b19ff..cf1a8581 100644 --- a/src/session/terminal/hub_zoom.rs +++ b/src/session/terminal/hub_zoom.rs @@ -1,25 +1,16 @@ -//! Which pane fills the terminal panel. +//! Which pane fills the terminal panel — the repository's answer, not each +//! page's, because every page attached to a repository shows the same terminals +//! and per-page state was lost on every reload. An attached TUI is told and +//! ignores it: it has a zoom of its own that follows the TUI's active pane and +//! takes the body from the diff viewer with it. //! -//! **The repository's answer, not each page's.** Every page attached to a -//! repository shows the same terminals, so "which one fills the panel" is one -//! question. Keeping it per page (what the browser used to do) lost the state -//! on every reload. -//! -//! **An attached TUI is told and ignores it** (`backend/hub.rs`). It has a -//! zoom of its own that answers a different question: it follows the TUI's -//! active pane and takes the body from the diff viewer with it. The panes are -//! shared between the two; what fills a screen is that screen's. -//! -//! **In the hub, and not on disk.** A zoom names a pane, and a pane is a child -//! process of this daemon: restarting it destroys the panes, so there is -//! nothing left for a stored zoom to point at. The panel-level maximize -//! (`prefs/maximized.rs`) *is* stored, and the difference is exactly this. -//! -//! **A pane appearing or leaving ends it**, which is why the two functions -//! here are called from under the same lock that changes the pane list. A -//! zoom that outlived its pane would leave every client rendering an empty -//! panel, and one that survived a `create` would hide the terminal somebody -//! just asked for. +//! Kept in the hub, not on disk: a zoom names a pane, a pane is a child process +//! of this daemon, so a restart destroys what a stored zoom would point at +//! (the panel-level maximize in `prefs/maximized.rs` *is* stored, and the +//! difference is exactly this). A pane appearing or leaving ends it, which is +//! why the two functions here run under the same lock that changes the pane +//! list — a zoom that outlived its pane leaves every client an empty panel, and +//! one that survived a `create` hides the terminal somebody just asked for. use super::TerminalHub; use super::frame::{ServerMessage, TerminalFrame}; diff --git a/src/session/terminal/size_owner.rs b/src/session/terminal/size_owner.rs index affd8c46..b21356d0 100644 --- a/src/session/terminal/size_owner.rs +++ b/src/session/terminal/size_owner.rs @@ -1,10 +1,7 @@ -//! The hub's end of the session's size ownership. -//! -//! The rules and the state live one level up, in -//! [`crate::session::size_owner`]: which screen the panes are fitted to is -//! the session's answer, because every client shows the same repository. What is -//! left here is the two places a hub touches it — a client asking for the sizing, -//! and the worker letting a departed owner's grace run out. +//! The hub's end of the session's size ownership. The rules and the state live +//! one level up in [`crate::session::size_owner`]; what is left here is the two +//! places a hub touches it — a client asking for the sizing, and the worker +//! letting a departed owner's grace run out. use super::TerminalHub; @@ -32,14 +29,11 @@ impl TerminalHub { self.ownership.owns(connection) } - /// Whether a queued request still belongs to this live hub client and that - /// connection still owns the sizing. - /// - /// Called with `state` locked so disconnect and ownership transfer cannot - /// split identity validation from authorization. This preserves the lock - /// order used by `connect`: hub state, then session ownership. - /// - /// [`Client::connection`]: super::session::Client::connection + /// Whether a queued request still belongs to a live hub client whose + /// connection owns the sizing. Called with `state` locked so disconnect and + /// ownership transfer cannot split identity validation from authorization; + /// this preserves the lock order used by `connect` (hub state, then + /// session ownership). pub(super) fn client_owns_size( &self, state: &super::hub_helpers::Shared, From dbb9d23d21d6050e21bf97f8ba2a43f7b594492c Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 28 Aug 2026 23:46:21 +0900 Subject: [PATCH 10/42] refactor(comments): keep only rationale comments in web --- src/web/common/mod.rs | 8 +++--- src/web/common/sse.rs | 14 +++++----- src/web/viewer/clone_jobs.rs | 27 +++++++++---------- src/web/viewer/dto/mod.rs | 11 ++++---- src/web/viewer/highlight.rs | 8 +++--- src/web/viewer/mod.rs | 6 ++--- src/web/viewer/server/handlers/http.rs | 3 ++- src/web/viewer/server/handlers/repository.rs | 16 +++++------ src/web/viewer/server/handlers/sse.rs | 4 +-- src/web/viewer/server/mod.rs | 6 ++--- src/web/viewer/server/mutations/filesystem.rs | 12 ++++----- src/web/viewer/server/mutations/lookup.rs | 4 +-- src/web/viewer/server/mutations/reload.rs | 10 +++---- src/web/viewer/server/mutations/repository.rs | 5 ++-- src/web/viewer/server/routes.rs | 13 ++++----- 15 files changed, 67 insertions(+), 80 deletions(-) diff --git a/src/web/common/mod.rs b/src/web/common/mod.rs index be58247a..89852ad1 100644 --- a/src/web/common/mod.rs +++ b/src/web/common/mod.rs @@ -1,8 +1,6 @@ -//! Primitives shared by nightcrow's web servers. -//! -//! Everything here is independent of what a given server actually serves: it -//! knows about passwords, sessions, HTTP framing, and connection accounting, -//! but nothing about screen frames, git data, or terminals. +//! Primitives shared by nightcrow's web servers — passwords, sessions, HTTP +//! framing, connection accounting. Nothing here knows what a server actually +//! serves: no screen frames, git data, or terminals. pub mod auth; pub mod conn; diff --git a/src/web/common/sse.rs b/src/web/common/sse.rs index ba3d3d80..d13c7256 100644 --- a/src/web/common/sse.rs +++ b/src/web/common/sse.rs @@ -1,13 +1,11 @@ //! Server-sent events over a plain synchronous writer. //! -//! The ordinary response builder in [`super::http`] always emits a -//! `Content-Length` and `Connection: close`, which ends the connection after -//! one body — the opposite of what a live stream needs. An SSE response -//! instead keeps the socket open and appends events until one side gives up, -//! so it writes its own head and owns the connection from then on. -//! -//! Generic over [`Write`] so the framing is unit-testable against a buffer -//! and the same code drives a real `TcpStream`. +//! [`super::http`]'s response builder always emits `Content-Length` and +//! `Connection: close`, which ends the connection after one body — the +//! opposite of what a live stream needs. An SSE response instead keeps the +//! socket open and appends events until one side gives up, so it writes its +//! own head and owns the connection from then on. Generic over [`Write`] so +//! the framing is unit-testable against a buffer. //! use std::io::{self, Write}; diff --git a/src/web/viewer/clone_jobs.rs b/src/web/viewer/clone_jobs.rs index c67ec1f6..b083f176 100644 --- a/src/web/viewer/clone_jobs.rs +++ b/src/web/viewer/clone_jobs.rs @@ -1,12 +1,11 @@ //! Track in-flight clones so the request that starts one can return at once. //! -//! A clone runs for as long as the remote takes, which is far past what a -//! browser will hold a request open for — and on a phone the tab may be -//! suspended mid-transfer. So `POST /api/clone` starts a thread and answers -//! with an id, and the client polls `GET /api/clone?job=` until the job -//! reaches a terminal state. The thread outlives the request that spawned it: -//! nothing is cancelled by a client that walks away, matching how the -//! terminal hub keeps PTYs alive across disconnects. +//! A clone runs for as long as the remote takes — far past what a browser +//! will hold a request open for, and a phone tab may be suspended +//! mid-transfer. So `POST /api/clone` starts a thread and answers with an id, +//! and the client polls `GET /api/clone?job=` until the job reaches a +//! terminal state. The thread outlives the request that spawned it, matching +//! how the terminal hub keeps PTYs alive across disconnects. use std::collections::HashMap; use std::sync::Mutex; @@ -36,10 +35,10 @@ impl CloneJobs { /// Admit a new job and return its id, or `None` when one is already /// running. /// - /// Admission and insertion happen under one lock on purpose: checking - /// "is anything running?" from the caller and inserting afterwards is a - /// check-then-act race that lets parallel requests each see an idle - /// registry and every one of them spawn a clone. + /// Admission and insertion happen under one lock: checking "is anything + /// running?" and inserting afterwards is a check-then-act race that lets + /// parallel requests each see an idle registry and every one of them + /// spawn a clone. pub fn try_start(&self) -> Option { let mut jobs = self.lock(); if jobs @@ -92,9 +91,9 @@ impl CloneJobs { } fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { - // A poisoned lock means a panic while holding it, but the map is plain - // data with no invariant spanning critical sections — recovering keeps - // clone tracking usable instead of taking the server down with it. + // Recover from a poisoned lock: the map is plain data with no + // invariant spanning critical sections, and keeping clone tracking + // usable beats taking the server down with it. self.jobs.lock().unwrap_or_else(|err| err.into_inner()) } } diff --git a/src/web/viewer/dto/mod.rs b/src/web/viewer/dto/mod.rs index 161d94cd..3dd9b475 100644 --- a/src/web/viewer/dto/mod.rs +++ b/src/web/viewer/dto/mod.rs @@ -1,14 +1,13 @@ //! The viewer's wire format. //! //! Internal git types are never serialized directly — they carry TUI-only -//! fields (`search_lower`, `summary_lower`) and libgit2-shaped types like -//! `Oid`. Every payload below is an explicit whitelist built by hand, so -//! adding a field to an internal struct can never widen what a browser sees, -//! and renaming one breaks the build here instead of silently changing the API. +//! fields and libgit2-shaped types like `Oid`. Every payload is an explicit +//! whitelist built by hand, so adding a field to an internal struct can never +//! widen what a browser sees, and renaming one breaks the build instead of +//! silently changing the API. //! //! [`PROTOCOL_VERSION`] rides on every response so a cached page from an -//! older build can refuse to interpret a newer payload rather than misread -//! it. +//! older build can refuse a newer payload rather than misread it. mod diff; mod envelope; diff --git a/src/web/viewer/highlight.rs b/src/web/viewer/highlight.rs index 61339887..cfeee204 100644 --- a/src/web/viewer/highlight.rs +++ b/src/web/viewer/highlight.rs @@ -1,9 +1,9 @@ //! Server-side syntax highlighting for the viewer. //! -//! Reuses `syntect` + `two-face` — already dependencies, and the exact way the -//! TUI highlights — so the browser needs no highlighter of its own and the -//! colours match the terminal UI. Highlighting runs on the request thread; the -//! diff and file byte ceilings in [`super::limits`] bound the work. +//! Reuses `syntect` + `two-face` — already dependencies and the exact way the +//! TUI highlights — so the browser needs no highlighter and the colours match +//! the terminal UI. Runs on the request thread; the byte ceilings in +//! [`super::limits`] bound the work. use crate::web::viewer::dto::SpanDto; use std::sync::OnceLock; diff --git a/src/web/viewer/mod.rs b/src/web/viewer/mod.rs index 98f8db4f..c516763a 100644 --- a/src/web/viewer/mod.rs +++ b/src/web/viewer/mod.rs @@ -1,6 +1,6 @@ -//! Web viewer: a native browser UI for the git panel and terminals, served as -//! its own HTTP server, independent of the TUI. Nothing here touches `App`, -//! `ui`, or `input`, which lets the server run headless (`nightcrow serve`). +//! Web viewer: a browser UI for the git panel and terminals, served as its +//! own HTTP server. Nothing here touches `App`, `ui`, or `input`, which lets +//! the server run headless (`nightcrow serve`). pub mod assets; pub mod clone_jobs; diff --git a/src/web/viewer/server/handlers/http.rs b/src/web/viewer/server/handlers/http.rs index c58cdd92..6774c4ea 100644 --- a/src/web/viewer/server/handlers/http.rs +++ b/src/web/viewer/server/handlers/http.rs @@ -25,7 +25,8 @@ pub(in crate::web::viewer::server) fn optional_oid( } /// A non-negative count query parameter, defaulting to zero when absent. -/// Deliberately unbounded -- see the note beside [`crate::web::viewer::limits::MAX_LOG_PAGE`]. +/// Deliberately unbounded — see the note beside +/// [`crate::web::viewer::limits::MAX_LOG_PAGE`]. pub(in crate::web::viewer::server) fn optional_count( head: &crate::web::common::http::RequestHead, name: &str, diff --git a/src/web/viewer/server/handlers/repository.rs b/src/web/viewer/server/handlers/repository.rs index 34726635..e141e223 100644 --- a/src/web/viewer/server/handlers/repository.rs +++ b/src/web/viewer/server/handlers/repository.rs @@ -6,9 +6,8 @@ use crate::session::catalog::RepoEntry; use anyhow::{Context, Result}; /// Look the repository up, validate any `path` parameter, then run `body`. -/// -/// Validation happens here rather than in each handler so no route can forget -/// it. A traversal path is refused uniformly, and never echoed back. +/// Validation happens here rather than in each handler so no route can +/// forget it; a traversal path is refused uniformly and never echoed back. pub(in crate::web::viewer::server) fn with_repo( head: &crate::web::common::http::RequestHead, state: &ViewerState, @@ -39,12 +38,11 @@ pub(in crate::web::viewer::server) fn with_repo( /// stricter [`with_repo`] adds refusing symlinks and requiring the path to /// exist, which are what protect a file this process is about to open; git /// reads a symlink as a blob holding the target's name, never the target's -/// contents, and a path that is gone is exactly what a deletion diff is about. -/// -/// Named for what the path is *for* rather than where it came from. A commit's -/// file and a deleted worktree file need the same rule for the same reason — -/// neither is on disk to be resolved — and calling it "commit" sent the second -/// one to the gate that turned it into a 400. +/// contents, and a path that is gone is exactly what a deletion diff is +/// about. Named for what the path is *for* rather than where it came from: a +/// commit's file and a deleted worktree file need the same rule for the same +/// reason — neither is on disk to be resolved — and calling it "commit" sent +/// the second one to the gate that turned it into a 400. pub(in crate::web::viewer::server) fn with_repo_git_path( head: &crate::web::common::http::RequestHead, state: &ViewerState, diff --git a/src/web/viewer/server/handlers/sse.rs b/src/web/viewer/server/handlers/sse.rs index 5e7fb7ff..c834d915 100644 --- a/src/web/viewer/server/handlers/sse.rs +++ b/src/web/viewer/server/handlers/sse.rs @@ -31,8 +31,8 @@ pub(in crate::web::viewer::server) fn serve_events( break; } } - // Nothing changed: prove the socket is still alive. This is the - // only way a closed tab is discovered. + // Nothing changed: prove the socket is still alive — the only way + // a closed tab is discovered. None => { if sse.heartbeat().is_err() { break; diff --git a/src/web/viewer/server/mod.rs b/src/web/viewer/server/mod.rs index 6f87357e..31941b63 100644 --- a/src/web/viewer/server/mod.rs +++ b/src/web/viewer/server/mod.rs @@ -1,8 +1,8 @@ //! The viewer's HTTP server: authenticated routes over a shared session. //! -//! Request handling order is Host/Origin, static assets, authentication, -//! repository lookup, then path validation. Git and I/O details are redacted -//! before responses because they can contain absolute server paths. +//! Request order is Host/Origin, static assets, authentication, repository +//! lookup, then path validation. Git and I/O details are redacted from +//! responses because they can contain absolute server paths. mod clone_routes; mod dispatch; diff --git a/src/web/viewer/server/mutations/filesystem.rs b/src/web/viewer/server/mutations/filesystem.rs index 02df491a..f4ae0e83 100644 --- a/src/web/viewer/server/mutations/filesystem.rs +++ b/src/web/viewer/server/mutations/filesystem.rs @@ -5,17 +5,17 @@ use super::lookup::redact; struct MkdirRequest { /// The directory to create the new folder inside. path: String, - /// The new folder's name. Must be a single plain path segment. + /// Must be a single plain path segment. name: String, } /// Create a new folder inside a directory the picker is browsing. /// -/// The parent is confined only as much as `browse` is, but `name` is held to a -/// single plain segment: separators, `..`, a leading `.` (which also rules out -/// `.git`), and NUL are all rejected. Combined with canonicalizing the parent -/// first, the created folder can only ever land directly under the browsed -/// directory. +/// The parent is confined only as much as `browse` is, but `name` is held to +/// a single plain segment: separators, `..`, a leading `.` (which also rules +/// out `.git`), and NUL are all rejected. Combined with canonicalizing the +/// parent first, the created folder can only ever land directly under the +/// browsed directory. pub(in crate::web::viewer::server) fn handle_mkdir(body: &str) -> Vec { let request: MkdirRequest = match serde_json::from_str(body) { Ok(request) => request, diff --git a/src/web/viewer/server/mutations/lookup.rs b/src/web/viewer/server/mutations/lookup.rs index 1890916d..080338fd 100644 --- a/src/web/viewer/server/mutations/lookup.rs +++ b/src/web/viewer/server/mutations/lookup.rs @@ -20,8 +20,8 @@ pub(in crate::web::viewer::server) fn lookup_repo( .ok_or_else(|| json_error("404 Not Found", "unknown repository")) } -/// Map an internal error to a fixed public message, logging the detail. -/// Git and I/O errors may name absolute paths, symlink targets, and file sizes. +/// Map an internal error to a fixed public message, logging the detail: git +/// and I/O errors may name absolute paths, symlink targets, and file sizes. pub(in crate::web::viewer::server) fn redact(context: &str, err: &anyhow::Error) -> Vec { tracing::debug!(%err, context, "viewer: request failed"); json_error("400 Bad Request", "request could not be served") diff --git a/src/web/viewer/server/mutations/reload.rs b/src/web/viewer/server/mutations/reload.rs index 0377ea75..f0abea1a 100644 --- a/src/web/viewer/server/mutations/reload.rs +++ b/src/web/viewer/server/mutations/reload.rs @@ -1,12 +1,10 @@ use super::super::ViewerState; use super::super::http_util::json_error; -/// Re-read `config.toml` and report what was applied. -/// -/// The body is ignored and no configuration is accepted from the request: the -/// file on the server's disk is what is read. Deciding who may ask happened -/// before this — the route is behind the same session cookie as every other -/// mutation. +/// Re-read `config.toml` and report what was applied. The body is ignored — +/// the file on the server's disk is what is read, never anything from the +/// request. Deciding who may ask happened before this: the route sits behind +/// the same session cookie as every other mutation. pub(in crate::web::viewer::server) fn handle_reload_config(state: &ViewerState) -> Vec { match crate::session::reload::reload_config(state.session()) { Ok(report) => super::encode_response( diff --git a/src/web/viewer/server/mutations/repository.rs b/src/web/viewer/server/mutations/repository.rs index c6bfab5f..c83a0e7a 100644 --- a/src/web/viewer/server/mutations/repository.rs +++ b/src/web/viewer/server/mutations/repository.rs @@ -13,9 +13,8 @@ struct ReorderRequest { order: Vec, } -/// Open a repository from the browser and add it to the served catalog. -/// -/// The path is user-supplied but the response is public, so a bad path yields a +/// Open a repository from the browser and add it to the served catalog. The +/// path is user-supplied but the response is public, so a bad path yields a /// generic message. pub(in crate::web::viewer::server) fn handle_open_repo(body: &str, state: &ViewerState) -> Vec { let request: OpenRequest = match serde_json::from_str(body) { diff --git a/src/web/viewer/server/routes.rs b/src/web/viewer/server/routes.rs index dae3d694..79b7ab09 100644 --- a/src/web/viewer/server/routes.rs +++ b/src/web/viewer/server/routes.rs @@ -59,13 +59,10 @@ pub(super) fn route(head: &RequestHead, state: &ViewerState) -> Vec { } "/api/status" => with_repo(head, state, |entry| { // Served from the runtime's latest snapshot rather than a fresh git - // call *while it is watching*: it is already reading the tree every + // call while it is watching: the watch already reads the tree every // second, and this keeps a page refresh from queueing another walk. - // - // While nothing is subscribed the watch is off, and `latest` is - // whatever was true when the last client left — so this reads once - // rather than answering with it. That is the same walk the watch - // would have done, paid only when someone asks. + // With nothing subscribed the watch is off and `latest` is whatever + // was true when the last client left — so this reads once instead. if !entry.runtime.is_watching() { entry.runtime.refresh_now(); } @@ -161,8 +158,8 @@ pub(super) fn route(head: &RequestHead, state: &ViewerState) -> Vec { // list. Resolved once, and the walk is then given exactly this // oid: asking the loader to fall back to HEAD itself would read // the ref a second time, and a first commit landing between the - // two reads would return commits under an anchor of `None`, which - // the client reads as the end of the history. + // two reads would return commits under an anchor of `None`, + // which the client reads as the end of the history. let skip = optional_count(head, "skip")?; let anchor = match optional_oid(head, "from")? { Some(oid) => Some(oid), From efba7b5163608b0ee0d980bb88da0c957207c2c5 Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 28 Aug 2026 23:45:22 +0900 Subject: [PATCH 11/42] refactor(comments): keep only rationale comments in daemon and backend --- src/backend/hub.rs | 51 ++++++++++++--------------- src/backend/pty_spawn.rs | 53 ++++++++++++---------------- src/daemon/client.rs | 36 ++++++++----------- src/daemon/detach.rs | 34 ++++++++---------- src/daemon/frame.rs | 60 +++++++++++++------------------- src/daemon/lock.rs | 30 ++++------------ src/daemon/protocol.rs | 63 +++++++++++++-------------------- src/daemon/requests.rs | 69 +++++++++++++++---------------------- src/daemon/serve.rs | 63 +++++++++++++++------------------ src/daemon/socket.rs | 25 +++++--------- src/daemon/terminal_link.rs | 19 +++++----- src/daemon/terminals.rs | 57 +++++++++++------------------- src/daemon/watch.rs | 60 ++++++++++++-------------------- src/daemon/wire.rs | 20 +++++------ 14 files changed, 248 insertions(+), 392 deletions(-) diff --git a/src/backend/hub.rs b/src/backend/hub.rs index ce323bd8..f1c4f278 100644 --- a/src/backend/hub.rs +++ b/src/backend/hub.rs @@ -26,12 +26,10 @@ impl HubBackend { Self { link } } - /// Take the daemon up on its offer to size the startup terminals. - /// - /// Answered with no sizes at all, because this client has measured nothing: - /// the offer arrives on attach, before the first frame has laid out a single - /// pane. The hub opens them at its own default and the first layout corrects - /// it. + /// Take the daemon up on its offer to size the startup terminals — with no + /// sizes at all, because the offer arrives on attach, before the first + /// frame has laid out a single pane. The hub opens them at its own default + /// and the first layout corrects it. fn size_startup_panes(&self) { if let Err(err) = self .link @@ -43,10 +41,8 @@ impl HubBackend { } impl TerminalBackend for HubBackend { - /// `command` is refused: a pane in a shared session is a bare shell. - /// - /// The session's configured commands are run once by the daemon, for every - /// client, so there is no request here that would carry one — and the hub + /// `command` is refused: a pane in a shared session is a bare shell — the + /// session's configured commands are run once by the daemon, and the hub /// deliberately gives a client no way to ask for a pane running arbitrary /// text. fn create_pane(&mut self, rows: u16, cols: u16, command: Option<&str>) -> Result<()> { @@ -64,15 +60,14 @@ impl TerminalBackend for HubBackend { /// Everything a client sends a pane is UTF-8 by construction — key /// encodings, pasted text, and the emulator's own replies to terminal - /// queries are all either ASCII control bytes or encoded characters — so the - /// text-shaped `input` message the browser already uses carries them - /// losslessly. Anything else is a bug on this side rather than something to - /// widen the wire format for, and is reported as one. + /// queries are all either ASCII control bytes or encoded characters — so + /// the text-shaped `input` message the browser already uses carries them + /// losslessly. Anything else is a bug on this side rather than something + /// to widen the wire format for. fn send_input(&mut self, id: PaneId, data: &[u8]) -> Result<()> { let Ok(data) = String::from_utf8(data.to_vec()) else { - // The bytes themselves stay out of it: this is what the user typed, - // and the caller logs the error. The length is what identifies which - // encoding produced it. + // The bytes stay out of the message: the length identifies which + // encoding produced it, and the caller logs the error. bail!("pane {id} input is not valid UTF-8 ({} bytes)", data.len()); }; self.link.send(HubClientMessage::Input { pane: id, data }) @@ -161,20 +156,18 @@ impl TerminalBackend for HubBackend { deadline_epoch, attempt, }), - // An attached client already knows who it is — the daemon told - // it when it subscribed, and `rewrite_requester` restates every - // `created` in that id space before it gets here. This names the - // browser-side hub connection, which is one hop in. + // The daemon already rewrote every `created` into this + // client's id space, so a Hello here names the browser-side + // hub connection — nothing this client needs. TerminalMessage::Event(HubServerMessage::Hello { .. }) => {} - // Deliberately dropped: a browser's zoom is not this client's. - // The TUI has a zoom of its own that means something else — it - // follows *its* active pane and takes the body from the diff - // viewer with it (`TerminalFullscreen::Zoom`), so letting a page - // drive it would let someone at a browser hide a panel here. - // The panes are shared; what fills a screen is each screen's. + // Deliberately dropped: the TUI's zoom follows *its* active + // pane and takes the diff viewer with it, so letting a browser + // page drive it would let someone at a browser hide a panel + // here. The panes are shared; what fills a screen is each + // screen's. TerminalMessage::Event(HubServerMessage::Zoomed { .. }) => {} - // Refusals do not come this way — they are not about a pane, so - // the client keeps them on the queue that reaches its notices. + // Refusals are not about a pane; the client keeps them on the + // queue that reaches its notices. TerminalMessage::Event(HubServerMessage::Error { message }) => { tracing::warn!(%message, "unexpected terminal refusal on a pane inbox"); } diff --git a/src/backend/pty_spawn.rs b/src/backend/pty_spawn.rs index a807b62c..774bf911 100644 --- a/src/backend/pty_spawn.rs +++ b/src/backend/pty_spawn.rs @@ -12,12 +12,9 @@ use std::thread; use std::time::Instant; impl PtyBackend { - /// Open a pane and say which one it is. - /// - /// The trait reports panes as events, because a backend serving a shared - /// session cannot answer on the spot. This one can, and the terminal hub — - /// which owns a `PtyBackend` outright rather than through the trait — needs - /// the id to register the pane before anything else happens to it. + /// Open a pane and say which one it is. Returns the id directly — unlike + /// the trait — because the terminal hub owns a `PtyBackend` outright and + /// needs the id to register the pane before anything else happens to it. pub fn open_pane(&mut self, rows: u16, cols: u16, command: Option<&str>) -> Result { let identity = PaneIdentity::new()?; let launch = PaneLaunch { @@ -26,16 +23,11 @@ impl PtyBackend { self.spawn_pane(rows, cols, command, identity, launch) } - /// Replace an exited pane's process, keeping the slot it ran in. - /// - /// A new `PaneId` is unavoidable: ids are monotonic and every client treats - /// `Exited` as final for one. The slot's token is what carries over, so an - /// observer that has been tracking this pane keeps its place, and the - /// generation moves so decisions made about the old process cannot land on - /// the new one. - /// - /// The composed command line is checked before anything is torn down, so a - /// refused relaunch leaves the pane exactly as it was. + /// Replace an exited pane's process, keeping the slot it ran in. A new + /// `PaneId` is unavoidable: ids are monotonic and every client treats + /// `Exited` as final. The slot's token carries over, so an observer keeps + /// its place; the generation moves so decisions about the old process + /// cannot land on the new one. pub fn relaunch_pane( &mut self, id: PaneId, @@ -55,13 +47,14 @@ impl PtyBackend { identity.advance(); // Retire the old process first: two children writing one slot's PTY // would interleave, and the reader thread has to be let go before the - // replacement's is started. + // replacement's is started. The composed line was checked above, so a + // refused relaunch never tears anything down. self.panes.remove(&id); self.slots.remove(id); - // The retained launch stays the *original* invocation. Carrying the - // composed line forward instead would accumulate resume arguments on - // every further relaunch. + // The retained launch stays the *original* invocation; carrying the + // composed line forward would accumulate resume arguments on every + // further relaunch. self.spawn_pane(rows, cols, Some(line.as_str()), identity, launch) } @@ -73,8 +66,8 @@ impl PtyBackend { identity: PaneIdentity, launch: PaneLaunch, ) -> Result { - // Reserve the next id only after every fallible PTY/spawn step succeeds, - // so a failure here does not consume an id slot. + // Reserve the next id only after every fallible PTY/spawn step + // succeeds, so a failure here does not consume an id slot. let pty_system = NativePtySystem::default(); let pair = pty_system.openpty(PtySize { rows, @@ -85,11 +78,9 @@ impl PtyBackend { let shell = self.shell.resolved_program(); let mut cmd = CommandBuilder::new(&shell); - // A reserved startup command runs through the shell's configured args: - // the command text is passed as a single argv item, so the shell — - // not us — handles its quoting/word-splitting. This avoids the race - // of spawning a shell and later injecting `command\r`, and avoids any - // string interpolation into a wrapper on our side. + // A reserved startup command goes through the shell's configured args + // as a single argv item, so the shell handles its quoting. This avoids + // the race of spawning a shell and later injecting `command\r`. if let Some(command) = command { for arg in self.shell.command_args() { cmd.arg(arg); @@ -101,10 +92,10 @@ impl PtyBackend { // provider's own helper processes inherit it — that inheritance is what // lets an out-of-process observer name the pane an event came from. cmd.env(PANE_TOKEN_ENV, identity.token.as_str()); - // Alongside the token and inherited the same way: a provider's hook is - // told which pane it is in *and* where that pane's plugins listen. The - // plugin spawn derives this from the same hub path, so the two agree - // without either being told by the other. + // Alongside the token: a provider's hook is told which pane it is in + // *and* where that pane's plugins listen. The plugin spawn derives + // this from the same hub path, so the two agree without either being + // told by the other. if let Some(dir) = crate::backend::identity::plugin_runtime_dir(std::path::Path::new(&self.cwd)) { diff --git a/src/daemon/client.rs b/src/daemon/client.rs index 31f7a5ad..37c8b431 100644 --- a/src/daemon/client.rs +++ b/src/daemon/client.rs @@ -26,9 +26,8 @@ pub struct DaemonClient { incoming: Receiver, /// Terminal traffic, split per repository for the backends that drain it. terminals: Arc, - /// This connection's id at the daemon, from the handshake. Handed to each - /// repository's backend to tell a pane this client opened from one that - /// appeared because another client did. + /// This connection's id at the daemon, so backends can tell a pane this + /// client opened from one another client opened. client: u64, /// Cleared by the reader thread when the daemon goes away. A separate flag /// rather than the channel's disconnected state: reading that means calling @@ -65,10 +64,9 @@ impl DaemonClient { let incoming = read_routed(&mut reader, &terminals)? .context("the daemon closed the connection during the handshake")?; let Incoming::Control(message) = incoming else { - // Terminal traffic starts before the handshake answer, because - // the daemon subscribes this client's repositories the moment it - // connects. Already filed with the router by `read_routed`, - // which is where the panes it describes will be looked for. + // Terminal traffic starts before the handshake answer — the + // daemon subscribes this client the moment it connects — and + // `read_routed` has already filed it with the router. continue; }; match message { @@ -81,27 +79,24 @@ impl DaemonClient { } break client; } - // The daemon volunteers the repository set on attach, so it can - // arrive before the handshake answer. Kept rather than dropped: - // it is the state this client is about to render. + // The daemon volunteers the repository set on attach, which can + // arrive before the handshake answer. Kept: it is the state + // this client is about to render. other @ (ServerMessage::Repos { .. } | ServerMessage::Terminal { .. }) => { queued.push(other) } ServerMessage::Error { message } => bail!("daemon refused the attach: {message}"), - // Nobody has asked for a reload yet — this client has not - // finished attaching. Dropped rather than queued: it would be an - // answer to a request that was never made. + // Dropped: an answer to a request this client has not made yet. ServerMessage::Reloaded { .. } => { tracing::debug!("attach: a reload answer arrived before the handshake"); } } }; // Best-effort: macOS rejects the option on a socket whose peer has - // already gone, which is exactly the race of attaching as the daemon - // stops — and failing the attach over it would report a platform quirk - // instead of the plain fact that the daemon went away. A timeout left in - // place is harmless: the reader loop treats one as "still waiting" - // rather than as a disconnect. + // already gone — exactly the race of attaching as the daemon stops — + // and failing the attach over it would report a platform quirk instead + // of the plain fact that the daemon went away. A leftover timeout is + // harmless: the reader loop treats one as "still waiting". if let Err(err) = reader.set_read_timeout(None) { tracing::debug!(%err, "could not clear the handshake timeout"); } @@ -141,9 +136,8 @@ impl DaemonClient { ) } - /// Drop the terminal inboxes of repositories that are no longer open. Called - /// with each set the daemon reports, which is also when the tabs are - /// reconciled. + /// Drop the terminal inboxes of repositories that are no longer open. + /// Called with each set the daemon reports, when the tabs are reconciled. pub fn retain_repos(&self, open: &[String]) { self.terminals.retain(open); } diff --git a/src/daemon/detach.rs b/src/daemon/detach.rs index d47dc4a1..6c74169e 100644 --- a/src/daemon/detach.rs +++ b/src/daemon/detach.rs @@ -1,12 +1,10 @@ -//! Putting the daemon into the background. Re-exec rather than fork: by the -//! time this is decided the process has not started its worker threads yet, but -//! the pattern is the trap either way — `fork` in a threaded process gives the -//! child one thread and every lock in whatever state it was in. Spawning a fresh -//! copy of this binary has no such state to inherit. +//! Putting the daemon into the background. Re-exec rather than fork: `fork` in +//! a threaded process gives the child one thread and every lock in whatever +//! state it was in; spawning a fresh copy of this binary has no such state to +//! inherit. //! //! The child gets its own session (`setsid`), so closing the terminal that -//! started it does not send it SIGHUP along with the shell's other children. -//! That is the whole difference from `&`. +//! started it does not send it SIGHUP — the whole difference from `&`. use anyhow::{Context, Result}; use std::process::{Command, Stdio}; @@ -22,11 +20,10 @@ pub fn is_detached_child() -> bool { /// The rule the marker carries, split from reading it. /// -/// Reading the environment inside the rule made the test answer for the -/// machine it ran on: a suite started from inside a nightcrow pane inherits -/// the marker from the daemon that spawned the pane, and the foreground case -/// then failed while saying nothing about the rule. Presence is what counts — -/// the child is spawned with `"1"`, but an empty value is still a marker. +/// Split out because a suite started from inside a nightcrow pane inherits the +/// marker from the daemon that spawned the pane, and the foreground case then +/// failed while saying nothing about the rule. Presence is what counts — the +/// child is spawned with `"1"`, but an empty value is still a marker. fn marker_says_detached(marker: Option<&std::ffi::OsStr>) -> bool { marker.is_some() } @@ -54,11 +51,9 @@ fn child_args(args: impl Iterator) -> Vec(writer: &mut W, frame: &Frame) -> Result<()> { if frame.payload.len() > MAX_FRAME_BYTES { bail!( @@ -111,8 +101,8 @@ pub fn write_frame(writer: &mut W, frame: &Frame) -> Result<()> { ); } // Built as one buffer and written once: a header written separately can - // reach the peer as its own packet, and a writer that dies between the two - // leaves a header with no body for the reader to block on. + // reach the peer as its own packet, leaving a reader blocked on a body + // that never comes. let mut out = Vec::with_capacity(5 + frame.payload.len()); out.push(frame.kind as u8); out.extend_from_slice(&(frame.payload.len() as u32).to_be_bytes()); @@ -121,11 +111,9 @@ pub fn write_frame(writer: &mut W, frame: &Frame) -> Result<()> { Ok(()) } -/// Read one frame, or `None` at a clean end of stream. -/// -/// `None` means the peer closed between frames, which is how a client detaches; -/// an error means it closed *inside* one, which is a truncated message and not -/// something to resume from. +/// Read one frame, or `None` at a clean end of stream — which is how a client +/// detaches. An error means the peer closed *inside* a frame: a truncated +/// message, not something to resume from. pub fn read_frame(reader: &mut R) -> Result> { let mut header = [0u8; 5]; if !read_exact_or_eof(reader, &mut header)? { @@ -146,11 +134,9 @@ pub fn read_frame(reader: &mut R) -> Result> { } /// Fill `buf`, reporting whether the stream ended before the first byte. -/// -/// An end of stream part-way through is an error rather than a `false`: the -/// distinction the caller needs is "nothing more is coming" versus "a message -/// was cut in half", and collapsing them would let a truncated frame look like -/// a clean detach. +/// Part-way through is an error: "nothing more is coming" and "a message was +/// cut in half" must not collapse, or a truncated frame would look like a +/// clean detach. fn read_exact_or_eof(reader: &mut R, buf: &mut [u8]) -> Result { if buf.is_empty() { return Ok(true); diff --git a/src/daemon/lock.rs b/src/daemon/lock.rs index 91d5e60a..1547c2e1 100644 --- a/src/daemon/lock.rs +++ b/src/daemon/lock.rs @@ -1,14 +1,10 @@ //! The single-instance lock. Two daemons on one socket would each serve half //! the attaching clients, and the second to bind would displace the first. -//! Deciding which is running has to be exact, which rules out asking the socket: -//! a `connect` that succeeds does not prove a listener is alive — on macOS it -//! can succeed against a socket whose listener has closed, and the reset only -//! shows up on the next read. -//! -//! An advisory lock answers instead. The kernel holds it for as long as the -//! descriptor is open and releases it when the process ends — including a -//! `kill -9`, where no cleanup code of ours runs. So holding the lock means -//! "no other daemon is live" with no race and no timeout. +//! An advisory lock decides — not the socket, where a `connect` that succeeds +//! does not prove a listener is alive (macOS can succeed against a socket whose +//! listener has closed). The kernel holds the lock until the process ends — +//! including a `kill -9` — so holding it means "no other daemon is live" with +//! no race and no timeout. use anyhow::{Context, Result}; use std::fs::{File, OpenOptions, TryLockError}; @@ -71,9 +67,7 @@ pub(crate) enum Attempt { /// Read a lock failure. `Interrupted` earns its own arm because this process /// raises signals at itself — a stop signal is how the daemon is asked to shut -/// down. A signal landing on the thread inside the lock call returns EINTR, -/// which says nothing about who holds the lock; reported as a failure it would -/// refuse to start a daemon for no reason. +/// down — and EINTR says nothing about who holds the lock. /// /// std 가 EINTR 를 내부에서 재시도하는지는 문서화되어 있지 않다. /// 재시도한다면 이 arm 은 도달하지 않을 뿐 해가 없고, 재시도하지 @@ -81,10 +75,8 @@ pub(crate) enum Attempt { /// 기대는 대신 남겨 둔다. pub(crate) fn outcome_of(err: &TryLockError) -> Attempt { match err { - // 다른 daemon 이 쥐고 있다. 정상적인 부정 응답. TryLockError::WouldBlock => Attempt::Held, - // 시그널이 호출 중간에 도착해 락을 시도조차 못 했다. 누가 무엇을 - // 쥐고 있는지 아무 말도 하지 않으므로 다시 묻는 것만이 옳다. + // 시그널이 호출 중간에 도착해 락을 시도조차 못 했다. 다시 묻는 것만이 옳다. TryLockError::Error(err) if err.kind() == std::io::ErrorKind::Interrupted => { Attempt::Interrupted } @@ -93,14 +85,6 @@ pub(crate) fn outcome_of(err: &TryLockError) -> Attempt { } impl Drop for InstanceLock { - /// Release the lock before the descriptor closes. - /// - /// Closing does release it — but not synchronously. A lock on a freshly - /// opened descriptor a millisecond later can still see the lock held, - /// which showed up as a daemon refusing to start with "already running" - /// moments after the previous one had gone, roughly once in every few - /// hundred stop-and-start cycles. `unlock` releases before this returns, - /// so the next daemon's attempt cannot race the last one's exit. fn drop(&mut self) { // 닫힘만으로도 해제되지만 동기적이지 않다. 명시적 unlock 이 없으면 // 직전 daemon 이 사라진 직후의 재시작이 "이미 실행 중" 으로 거부되는 diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index ae53fa17..34ec02fe 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -38,21 +38,18 @@ pub enum ClientMessage { /// Paint the session in this accent, for every client and the browser. /// /// An index into the accent cycle rather than a "next" step: two clients - /// cycling at once would each advance from what they last saw and land - /// somewhere neither asked for. An index past the end wraps. + /// cycling at once would not agree on what "next" means. Wraps past the end. SetAccent { accent: usize, }, /// Re-read `config.toml` and apply the tables the session owns. /// - /// Carries nothing: the file is the request. Sending its contents would let - /// a client reconfigure the session from something it made up; this way the - /// daemon only acts on a file on its own disk that the user wrote. + /// Carries nothing: the daemon only acts on a file on its own disk that the + /// user wrote, never on contents a client could have made up. ReloadConfig, - /// Act on one repository's terminals. Carries the hub's own message rather - /// than a parallel set so the two definitions of "create a pane" cannot - /// drift. The repository rides along because one socket multiplexes every - /// open repository, where the browser opens a connection per repository. + /// Act on one repository's terminals. Carries the hub's own message so the + /// two definitions of "create a pane" cannot drift; the repository rides + /// along because one socket multiplexes every open repository. Terminal { repo: String, message: HubClientMessage, @@ -76,28 +73,23 @@ pub enum ServerMessage { client: u64, }, /// The repository set, sent in answer to a list, open, close, or reorder. - /// - /// Every mutation answers with the whole set rather than a delta: the set - /// is small, bounded by `MAX_PROJECTS`, and another client may have changed - /// it in between — a delta applied to a stale list silently diverges. + /// The whole set rather than a delta: another client may have changed it in + /// between, and a delta applied to a stale list silently diverges. Repos { repos: Vec, - /// The repository the session is focused on. `None` when nothing has - /// been focused yet. Carried with the set because the two change - /// together — opening a repository focuses it. + /// The focused repository, if any. Carried with the set because the + /// two change together — opening a repository focuses it. #[serde(default)] active: Option, - /// The accent the whole session paints in. Required, unlike `active`: - /// a default here would be a colour, and a daemon too old to send one - /// would have this client painting the session yellow and claiming that - /// was its choice. + /// The session's accent. Required, unlike `active`: a default would + /// misattribute an old daemon's silence as this client's choice. accent: usize, }, /// A request could not be carried out. The connection stays open: a refused /// request is an answer, not a protocol violation. Error { message: String }, /// A reload was carried out, described for the person who asked. Answered - /// to the asker alone — nothing a reload does is visible in what the other + /// to the asker alone: nothing a reload does is visible in what the other /// clients are looking at. Reloaded { /// One line for the client to show. Built by the session so a browser @@ -112,10 +104,9 @@ pub enum ServerMessage { }, } -/// One repository in the served set. Narrower than the browser's `RepoDto`: -/// an attaching client renders with the TUI's own widgets and reads git locally, -/// so it needs the identity and the path, not the display fields the web UI -/// derives. +/// One repository in the served set. Narrower than the browser's `RepoDto`: an +/// attaching client renders with the TUI's own widgets and reads git locally, +/// so the display fields the web UI derives would be dead weight. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RepoSummary { /// Opaque catalog id, stable for the daemon's lifetime. @@ -124,12 +115,10 @@ pub struct RepoSummary { pub path: String, } -/// Bytes a repository's pane produced, and who they belong to. -/// -/// Carried in a [`FrameKind::Terminal`](super::frame::FrameKind::Terminal) -/// frame rather than as JSON: PTY output is not guaranteed valid UTF-8 — a -/// multi-byte sequence is routinely split across reads — so encoding it as -/// text would corrupt it before any emulator saw it. +/// Bytes a repository's pane produced, and who they belong to. Carried in a +/// [`FrameKind::Terminal`](super::frame::FrameKind::Terminal) frame rather than +/// as JSON: PTY output is not guaranteed valid UTF-8 — a multi-byte sequence is +/// routinely split across reads — and a text encoding would corrupt it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TerminalOutput { pub repo: String, @@ -138,10 +127,8 @@ pub struct TerminalOutput { } impl TerminalOutput { - /// `[repo len][repo][pane id][bytes]`, with the id little-endian to match - /// the hub's own binary framing. - /// - /// Refuses repository ids longer than 255 bytes instead of truncating the + /// `[repo len][repo][pane id][bytes]`, little-endian to match the hub's own + /// binary framing. Refuses long repository ids rather than truncating the /// payload into a frame the receiver would misinterpret. pub fn encode(&self) -> Result> { let repo = self.repo.as_bytes(); @@ -161,10 +148,8 @@ impl TerminalOutput { } /// Read one back, or `None` when the header is truncated or its repository - /// id is not valid UTF-8. - /// - /// The daemon only encodes and the attaching client only decodes: output - /// travels one way. + /// id is not valid UTF-8. Only the attaching client decodes: output travels + /// one way. pub fn decode(bytes: &[u8]) -> Option { let (&len, rest) = bytes.split_first()?; let len = usize::from(len); diff --git a/src/daemon/requests.rs b/src/daemon/requests.rs index d24b7be5..d4881270 100644 --- a/src/daemon/requests.rs +++ b/src/daemon/requests.rs @@ -1,8 +1,7 @@ -//! Carrying out one attached client's requests. A request is either a question -//! — answered to the asker — or a change to the session, which is not answered -//! here at all: every client is looking at the same session, so the watcher -//! tells them all from one record of what they have been told. Refusals go to -//! the asker alone; a client must not flash an error for somebody else's typo. +//! Carrying out one attached client's requests. Refusals go to the asker +//! alone; a client must not flash an error for somebody else's typo. State +//! changes are not answered here at all — the watcher tells every client from +//! one record of what they have been told. use super::frame::{FrameKind, encode_server, read_frame}; use super::protocol::{ClientMessage, ServerMessage, version}; @@ -15,9 +14,8 @@ use std::sync::Arc; /// Read requests from one client until it detaches. pub(super) fn read_requests(mut stream: UnixStream, id: u64, session: &Session) -> Result<()> { while let Some(frame) = read_frame(&mut stream)? { - // Terminal frames arrive once panes are shared; until then a client has - // no pane to write to, and a frame kind with no handler is dropped - // rather than closing the connection over it. + // Terminal frames arrive only once panes are shared; a frame kind with + // no handler is dropped rather than closing the connection over it. if frame.kind != FrameKind::Control { tracing::debug!("daemon: ignoring a terminal frame before panes are shared"); continue; @@ -37,13 +35,10 @@ pub(super) fn read_requests(mut stream: UnixStream, id: u64, session: &Session) Ok(()) } -/// Carry out one request against the served set. -/// -/// A state change is not answered here at all: every attached client is looking -/// at the same session, and the one that asked has no more claim on the result -/// than the others — so the watcher tells them all, from one record of what they -/// have been told. Refusals are addressed to the asker alone, since a client -/// must not flash an error for somebody else's typo. +/// Carry out one request against the served set. A state change is not +/// answered here: the watcher tells every client, from one record of what they +/// have been told (see `watch::watch`). Refusals are addressed to the asker +/// alone. fn handle(message: ClientMessage, id: u64, session: &Session) { let state = &session.state; match message { @@ -55,19 +50,17 @@ fn handle(message: ClientMessage, id: u64, session: &Session) { client: id, } } else { - // Reported, not refused. The two ship in one binary, so a - // mismatch means two builds are running at once — worth saying - // plainly rather than failing with a decode error later. + // Reported, not refused: the two ship in one binary, so a + // mismatch means two builds are running at once. ServerMessage::Error { message: format!("client is {client}, daemon is {daemon}"), } }; session.clients.send_to(id, encode_reply(&reply)); } - // Answered to the asker alone — nothing changed, so there is nothing to - // tell the others — but not from here. The set is sent from one place so - // a client's frames arrive in the order the session changed (see - // `watch::watch`); this records that the asker is owed one and wakes it. + // Answered to the asker alone (nothing changed), but not from here — + // the set is sent from one place, in session-change order. This records + // that the asker is owed one and wakes the watcher. ClientMessage::ListRepos => { session.clients.owe_set(id); changed(session); @@ -90,10 +83,9 @@ fn handle(message: ClientMessage, id: u64, session: &Session) { if session::focus_repo(state, &repo).is_ok() { changed(session); } else { - // The only way to name a repository the session does not have is - // to have raced a close on another client. Answered rather than - // dropped, because the asker is waiting to see that tab come - // forward and never will. + // Only way to name an unknown repository is to have raced a + // close on another client. Answered because the asker is + // waiting to see that tab come forward. refuse(id, session, "unknown repository"); } } @@ -101,29 +93,23 @@ fn handle(message: ClientMessage, id: u64, session: &Session) { session::reorder_repos(state, &order); changed(session); } - // Not answered to the asker either, though it is the one thing here a - // client could paint locally without waiting. It waits with the rest: - // the accent is the session's, and a client that painted first would be - // the only one showing the new colour for a tick — the same flicker the - // tab switch is written to avoid. + // Waits with the rest rather than painting locally: the accent is the + // session's, and a client that painted first would flicker — the same + // flicker the tab switch is written to avoid. ClientMessage::SetAccent { accent } => { session::set_accent(state, accent); changed(session); } - // Answered to the asker alone, unlike a change to the served set. - // Nothing a reload does shows up in what the other clients are looking - // at — the startup list only reaches repositories opened later, and a - // plugin being replaced is a child process nobody is watching — so - // telling them would be a notice about something they did not do and - // cannot see. + // Answered to the asker alone: nothing a reload does shows up in what + // the other clients are looking at. ClientMessage::ReloadConfig => { let reply = match crate::session::reload::reload_config(state) { Ok(report) => ServerMessage::Reloaded { summary: report.summary(), }, - // The message names the offending key, which is the whole value - // of reporting it rather than saying the file was bad. Err(err) => ServerMessage::Error { + // The message names the offending key — that is the whole + // value of reporting it. message: err.to_string(), }, }; @@ -158,9 +144,8 @@ fn handle(message: ClientMessage, id: u64, session: &Session) { } } -/// Every arm that can have changed the session ends here, so the watcher looks -/// at once instead of on its next tick. Reading the session is still its job — -/// this only wakes it. +/// Wake the watcher so it reads the session at once instead of on its next +/// tick. Reading the session is still the watcher's job — this only wakes it. fn changed(session: &Session) { session.nudge.poke(); } diff --git a/src/daemon/serve.rs b/src/daemon/serve.rs index 3a92c96f..3120f322 100644 --- a/src/daemon/serve.rs +++ b/src/daemon/serve.rs @@ -1,12 +1,9 @@ -//! The daemon's accept loop: one attached client, two threads. A client gets a -//! reader and a writer because the daemon speaks unprompted — the session is -//! shared, so a repository opened in the browser has to reach an attached TUI -//! that never asked. The reader blocks on the socket; the writer drains that -//! client's queue. +//! The daemon's accept loop: one attached client, two threads. The daemon +//! speaks unprompted — the session is shared — so a client gets a reader +//! (blocked on the socket) and a writer (draining that client's queue). //! -//! Sized like the viewer's accept loop and for the same reason — a connection -//! costs threads — but with a much lower ceiling. Clients here are terminals a -//! person is sitting at, not browser tabs. +//! Threaded like the viewer's accept loop but with a lower ceiling: a client +//! here is a person at a terminal, not a browser tab. use anyhow::Context; @@ -32,9 +29,8 @@ pub struct Session { pub(super) state: Arc, pub(super) clients: Arc, /// Each attached client's terminal subscriptions. Kept here rather than on - /// the thread that reads that client's socket, because a repository can - /// appear for reasons that have nothing to do with any client's connection - /// — the browser opened it — and it has to start streaming for everyone. + /// the client's socket thread: a repository can appear without any client + /// asking (the browser opened it) and must start streaming for everyone. /// One lock per client, so following a change for one never delays /// another's keystrokes. pub(super) bridges: Mutex>>>, @@ -48,11 +44,10 @@ pub struct Session { impl Session { /// Bring every attached client's subscriptions in line with `repos`. - /// - /// Oldest client first, because subscribing is what takes a repository's - /// pane sizing (the hub gives it to the newest connection): in ascending id - /// order the newest client subscribes last, so a repository that has just - /// appeared is sized by the same client that sizes all the others. + /// Oldest client first: subscribing takes a repository's pane sizing (the + /// hub gives it to the newest connection), so in ascending id order the + /// newest client subscribes last and sizes a just-appeared repository the + /// same way it sizes all the others. fn follow_all(&self, repos: &[session::SessionRepo]) { let mut bridges: Vec<(u64, Arc>)> = self .bridges @@ -71,12 +66,11 @@ impl Session { } } -/// Serve attached clients until the process ends. -/// -/// Takes a *clone* of the listener rather than the [`DaemonSocket`]: the socket -/// owns the unlink and the instance lock, and this loop blocks in `accept` -/// forever, so a socket parked here would be freed by process exit — which runs -/// no destructor. The caller keeps it and drops it on the way out. +/// Serve attached clients until the process ends. Takes a *clone* of the +/// listener rather than the [`DaemonSocket`]: this loop blocks in `accept` +/// forever and process exit runs no destructor, so a socket parked here would +/// be freed without unlinking it or releasing the lock. The caller keeps the +/// socket and drops it on the way out. /// /// [`DaemonSocket`]: super::socket::DaemonSocket pub fn start( @@ -90,11 +84,10 @@ pub fn start( nudge: Arc::new(super::watch::Nudge::default()), shutdown_tx, }); - // The only thing that sends the served set, so there is one record of what - // clients have been told, one order they are told it in, and a change made - // through the browser reaches them at all. Started here, where it can be - // refused, rather than inside the accept loop: a session without a watcher - // serves clients that never learn what is open. + // The only sender of the served set, so clients are told it in one order + // and changes made through the browser reach them at all. Started outside + // the accept loop: a session without a watcher serves clients that never + // learn what is open. let watched = Arc::clone(&session); std::thread::Builder::new() .name("nightcrow-session-watch".into()) @@ -129,7 +122,7 @@ fn attach(stream: UnixStream, session: &Session) { return; }; // A third handle, so the set can end this connection if the client stops - // draining. Its own two are blocked in `read` and `write`. + // draining — the other two are blocked in `read` and `write`. let Ok(hangup) = stream.try_clone() else { tracing::debug!("daemon: could not split an attaching client's socket"); return; @@ -163,18 +156,16 @@ fn attach(stream: UnixStream, session: &Session) { } }); - // Subscribed before the set can reach this client, so the panes of every - // open repository are already streaming when it learns the repository - // exists. + // Subscribed before the watcher can reach this client, so every open + // repository's panes are already streaming when the client learns the + // repositories exist. bridges.lock().expect("client bridges poisoned").follow( &session::list_session_repos(&session.state), session.state.catalog(), ); - // The set itself is not sent from here. This client is registered as owed - // one (`AttachedClients::connect`) and the watcher answers, which is the - // whole of why a client's frames arrive in the order the session changed — - // see `watch::watch`. Woken rather than waited for: the poke is what stops - // this from sitting behind the tick. + // The set itself is sent by the watcher, to which this client is already + // registered as owed one — that is what keeps a client's frames in the + // order the session changed (see `watch::watch`). session.nudge.poke(); if let Err(err) = super::requests::read_requests(stream, id, session) { diff --git a/src/daemon/socket.rs b/src/daemon/socket.rs index b55e383a..eb9ef01b 100644 --- a/src/daemon/socket.rs +++ b/src/daemon/socket.rs @@ -1,11 +1,9 @@ //! The daemon's Unix socket: where it lives, who may open it, and what to do //! about one left behind by a process that is gone. //! -//! Authentication is the filesystem. The socket sits under the user's own +//! Authentication is the filesystem: the socket sits under the user's own //! `~/.nightcrow` at mode 0600, so reaching it already means being that user — -//! which is the same authority a client would need to run the shells the daemon -//! serves. That is why the attach path carries no password while the browser -//! path does: a TCP port is reachable by anyone who can route to it. +//! which is why the attach path carries no password while the browser path does. use super::lock::InstanceLock; use super::transport::UnixListener; @@ -37,13 +35,10 @@ pub struct DaemonSocket { impl DaemonSocket { /// Bind the socket, refusing to start beside a daemon that already runs. - /// - /// The lock decides, not the socket file. A socket outliving its process is - /// the normal case after a crash or a `kill -9`, and it is indistinguishable - /// from a live one by inspection — connecting to it can even succeed. So the - /// order is: take the lock, and only then treat whatever socket file is - /// there as debris, because holding the lock already proves no other daemon - /// is serving it. + /// The lock decides, not the socket file: a socket outliving its process + /// (crash, `kill -9`) is indistinguishable from a live one by inspection. + /// So: take the lock, and only then treat whatever socket file is there as + /// debris. pub fn bind(path: &Path) -> Result { let lock_path = lock_path_for(path); let Some(lock) = InstanceLock::acquire(&lock_path)? else { @@ -99,11 +94,9 @@ fn restrict_to_owner(path: &Path) -> Result<()> { { // Windows has no mode bits — the posture depends on the directory's // inherited ACL. %USERPROFILE%\.nightcrow's default ACL allows write - // only to owner and admins, so the practical posture holds. - // - // This dependency breaks if the socket path is placed outside the - // user profile. Explicit ACL setting is tracked as a separate task - // (docs/internal plan decision C). + // only to owner and admins, so the practical posture holds. This + // dependency breaks if the socket path is placed outside the user + // profile; explicit ACL setting is tracked separately. let _ = path; } Ok(()) diff --git a/src/daemon/terminal_link.rs b/src/daemon/terminal_link.rs index ac090698..ec3b2b2f 100644 --- a/src/daemon/terminal_link.rs +++ b/src/daemon/terminal_link.rs @@ -33,18 +33,15 @@ pub(crate) struct TerminalRouter { } impl TerminalRouter { - /// File one message under its repository. + /// File one message under its repository. The inbox is created on arrival + /// rather than when a backend registers: the daemon subscribes a client to + /// every open repository the moment it connects, so a pane and its + /// scrollback can be on the wire before the client has been told the + /// repository exists — and the replay happens only once, so dropping those + /// would orphan panes. /// - /// The inbox is created on arrival rather than when a backend registers, - /// because the daemon subscribes a client to every open repository the - /// moment it connects: a pane and its scrollback can be on the wire before - /// the client has been told the repository exists. Dropping those would - /// leave panes the client is never told about again — the replay happens - /// once. - /// - /// Unbounded for the same reason terminal output is never conflated: - /// dropping bytes corrupts a stream that cannot be re-read. What bounds it - /// is that an inbox nobody drains belongs to a repository this client has + /// Unbounded because dropping bytes corrupts a stream that cannot be + /// re-read; an inbox nobody drains belongs to a repository this client has /// not opened a tab for yet, which is the very next thing it does. pub(crate) fn deliver(&self, repo: &str, message: TerminalMessage) { self.inboxes diff --git a/src/daemon/terminals.rs b/src/daemon/terminals.rs index 2d967c67..7494db7e 100644 --- a/src/daemon/terminals.rs +++ b/src/daemon/terminals.rs @@ -1,10 +1,7 @@ //! Wiring one attached client to every open repository's terminals. The hubs -//! are the browser's too — one per repository, already fanning output out to -//! whoever has connected. An attaching client subscribes to all of them at once, -//! because it renders a tab per repository and a pane whose output it stopped -//! reading would fall behind its own scrollback. -//! -//! That costs a thread per client per repository. Bounded by +//! are the browser's too; an attaching client subscribes to all of them at +//! once, because a pane whose output it stopped reading would fall behind its +//! own scrollback. Costs a thread per client per repository, bounded by //! `MAX_ATTACHED_CLIENTS` × `MAX_PROJECTS`. use super::clients::AttachedClients; @@ -33,8 +30,8 @@ pub struct TerminalBridges { open: HashMap, /// Whether this client's first subscription has been made. Attaching is a /// person sitting down, and that is the one moment this client takes the - /// session's sizing. Every subscription after it follows a set that changed - /// — a repository opened in a browser is not an arrival here. + /// session's sizing; every subscription after it follows a set that + /// changed. arrived: bool, } @@ -69,11 +66,9 @@ impl TerminalBridges { }; let arriving = !self.arrived; let Some(bridge) = self.subscribe(&repo.id, arriving, &entry.terminals) else { - // Left out of `open`, and the arrival left unspent, so the next - // set this client is told about tries again. "Next set" is the - // limit: this is called on attach and when the repository set - // changes, so a repository that fails here shows in the client's - // tabs with no terminals until something else moves. + // Left out of `open` and the arrival left unspent, so the next + // set this client is told about retries. A repository that + // fails here shows in the tabs with no terminals until then. continue; }; self.arrived = true; @@ -97,12 +92,9 @@ impl TerminalBridges { hub: &Arc, ) -> Option { let stop = Arc::new(AtomicBool::new(false)); - // The thread first, and the subscription only once it exists. - // Subscribing registers this client with the session's size ownership - // and, on an arrival, takes the sizing off whoever had it. Done in the - // other order, a thread that failed to start left a subscription nobody - // reads — the sizing displaced, this client's one arrival spent, and - // the hub evicting a bridge it can never reach. + // Thread first, subscription only once it exists — the other order + // would leave a failed spawn having displaced the pane sizing and + // spent this client's one arrival on a subscription nobody reads. let (hand_over, take) = std::sync::mpsc::channel::>(); let worker = { let stop = Arc::clone(&stop); @@ -133,15 +125,10 @@ impl TerminalBridges { return None; } }; - // Connecting replays the panes and their scrollback before any live - // frame, so the thread above forwards a usable history first and the - // client's emulators start from the same place the browser's do. - // - // One viewer across every repository it subscribes to: this client is a - // single terminal showing one project at a time. Only the first of - // these subscriptions is an arrival — the rest follow a set that - // changed, and a repository opening elsewhere is not a person sitting - // down here. + // Connecting replays panes and scrollback before any live frame, so the + // client's emulators start where the browser's do. One viewer across + // every repository it subscribes to — this client is a single terminal + // showing one project at a time. let session = Arc::new(hub.connect(ViewerId::Attached(self.client), arriving, None)); let _ = hand_over.send(Arc::clone(&session)); Some(Bridge { @@ -153,12 +140,9 @@ impl TerminalBridges { } /// Turn one hub frame into a frame for this client, tagged with its repository. -/// -/// `hub_client` is this bridge's id at the hub and `attached` is the same -/// client's id on the attach socket. A pane the hub says `hub_client` asked for -/// is relayed as one `attached` asked for, so the client can recognise its own -/// pane by comparing against the id it was given at the handshake — it has no -/// way to know its per-repository hub ids. +/// `hub_client` (this bridge's id at the hub) is rewritten to `attached` (the +/// client's id on the attach socket), so the client can recognise its own panes +/// — it has no way to know its per-repository hub ids. fn tag(repo: &str, frame: TerminalFrame, hub_client: u64, attached: u64) -> Frame { match frame { TerminalFrame::Output { pane, data } => { @@ -182,9 +166,8 @@ fn tag(repo: &str, frame: TerminalFrame, hub_client: u64, attached: u64) -> Fram } } // Parsed and re-encoded rather than passed through as text: the client - // reads one message type, and a control frame smuggled through as an - // opaque string would make the repository tag unreadable without - // parsing it there instead. + // reads one message type, and an opaque string would make the + // repository tag unreadable without parsing it there instead. TerminalFrame::Control(json) => match serde_json::from_str(&json) { Ok(event) => encode_server( &ServerMessage::Terminal { diff --git a/src/daemon/watch.rs b/src/daemon/watch.rs index 7b9f331c..424f2a28 100644 --- a/src/daemon/watch.rs +++ b/src/daemon/watch.rs @@ -1,14 +1,9 @@ -//! Telling attached clients about changes nobody on their connection asked for. -//! The session has two front doors. A repository opened in the browser goes -//! through the HTTP handlers, and nothing on an attach socket is woken by it — -//! so a client that asks for nothing would sit on a tab list that quietly went -//! stale. -//! -//! This is a thread that re-reads the session on a tick and tells everyone when -//! it differs from what they were last told. Observing rather than being +//! Telling attached clients about changes nobody on their connection asked +//! for — a repository opened in the browser wakes nothing on an attach socket. +//! A thread that re-reads the session on a tick and tells everyone when it +//! differs from what they were last told. Observing rather than being //! notified, because a notification is something a mutation added later can -//! forget to send, and the failure then looks like this same bug again. The cost -//! is a comparison of a handful of small structs at a rate nobody can see. +//! forget to send, and the failure then looks like this same bug again. use super::clients::AttachedClients; use super::frame::encode_server; @@ -24,11 +19,9 @@ use std::time::Duration; /// change asked for on an attach socket wakes it immediately (see [`Nudge`]). const TICK: Duration = Duration::from_millis(150); -/// A way to tell the watcher not to wait out its tick. A client that just asked -/// for something is watching for it to happen, so the answer cannot sit behind a -/// poll interval. The change is still *read* from the session rather than passed -/// through here: this only says "look now", so a handler that forgets to poke -/// costs latency, never correctness. +/// A way to tell the watcher not to wait out its tick. The change is still +/// *read* from the session rather than passed through here: a handler that +/// forgets to poke costs latency, never correctness. #[derive(Default)] pub(super) struct Nudge { poked: Mutex, @@ -56,35 +49,26 @@ impl Nudge { } } -/// Watch `state` and tell attached clients the served set: everyone, when it — -/// or which repository the session is focused on, or the accent it is painted in -/// — changes, and whoever is still owed one otherwise. +/// Watch `state` and tell attached clients the served set — everyone, when it +/// (or the focus, or the accent) changes, and whoever is still owed one +/// otherwise. /// -/// **The only place a repository set is sent from.** A client that attaches, or -/// asks for the set outright, is marked as owed one and this is what answers; -/// neither sends its own. That is what makes the order a client sees the order -/// the session changed in — one producer per queue, so there is no pair of -/// frames whose order has to be argued about. Two producers, which is what this -/// replaced, could queue a newer frame ahead of an older one and leave a client -/// on state everyone else had moved off. +/// **The only place a repository set is sent from.** One producer per queue is +/// what makes the order a client sees the order the session changed in; two +/// producers could queue a newer frame ahead of an older one. /// -/// `follow` runs for every client before the set goes out, so a repository that -/// appeared is already streaming its terminals by the time a client is told the -/// tab exists. It runs on an accent change too, where it has nothing to do: it -/// skips repositories already followed, so the alternative — deciding here which -/// kind of change deserves it — would buy a walk over a handful of entries at -/// the price of a branch that can be wrong. The owed-only path does not need it: -/// those clients followed the set when they attached, and it has not changed. +/// `follow` runs for every client before the set goes out, so a repository +/// that appeared is already streaming its terminals by the time a client is +/// told the tab exists. pub(super) fn watch( state: Arc, clients: Arc, nudge: Arc, follow: impl Fn(&[session::SessionRepo]), ) { - // Seeded with the set as it stands, not with nothing: an attaching client is - // owed its own copy and gets one below, so opening with a broadcast would be - // a message that reports no change — and every other client would have to - // treat somebody else's arrival as news. + // Seeded with the set as it stands, not with nothing: an attaching client + // gets its own copy below, and opening with a broadcast would make every + // other client treat somebody else's arrival as news. let mut told = ( summarize(&session::list_session_repos(&state)), session::active_repo(&state), @@ -117,8 +101,8 @@ pub(super) fn watch( clients.broadcast(frame()); told = current; } else { - // Nothing changed, so this says the same thing again to whoever has - // not heard it yet: a client that just attached, or one that asked. + // Nothing changed: say the same thing again to whoever has not + // heard it yet — a client that just attached or asked. for id in clients.take_owed_sets() { clients.send_to(id, frame()); } diff --git a/src/daemon/wire.rs b/src/daemon/wire.rs index 381c2277..02626497 100644 --- a/src/daemon/wire.rs +++ b/src/daemon/wire.rs @@ -15,10 +15,9 @@ use std::io::Write; use std::sync::mpsc::Sender; use std::sync::{Arc, Mutex}; -/// The write half of an attach socket. -/// -/// Shared and locked because two kinds of caller send on it. A frame is written -/// under the lock, so two writers cannot interleave halves of one message. +/// The write half of an attach socket. Shared and locked because two kinds of +/// caller send on it; a frame is written under the lock, so two writers cannot +/// interleave halves of one message. pub(super) type Writer = Arc>; /// Write one request. Holds the connection lock for the whole frame. @@ -61,10 +60,9 @@ pub(super) fn pump( } // A read that timed out is not a disconnect. A quiet session is the // normal state, and the handshake's timeout can outlive the - // handshake — macOS refuses to clear the option once the peer has - // gone, so `connect` may hand this loop a socket that still has one. - // Inventing a disconnect out of an idle session is the one failure - // this whole shape exists to avoid. + // handshake (macOS refuses to clear the option once the peer has + // gone). Inventing a disconnect out of an idle session is the one + // failure this whole shape exists to avoid. Err(err) if timed_out(&err) => {} Err(err) => { tracing::warn!(%err, "daemon connection ended"); @@ -107,10 +105,8 @@ pub(super) fn read_routed( } let message: ServerMessage = serde_json::from_slice(&frame.payload).context("decoding a message from the daemon")?; - // A terminal event belongs to one repository's panes, so it goes to that - // repository's inbox rather than the general queue — except a refusal, which - // is not about a pane at all but about a request that was turned down, and - // has to reach the tab that shows notices. + // A terminal event belongs to one repository's inbox — except a refusal, + // which is not about a pane and has to reach the tab that shows notices. if let ServerMessage::Terminal { repo, event } = &message && !matches!(event, HubServerMessage::Error { .. }) { From feb0b5f56ed2f803b8b3e445ea6952981e2e3cc0 Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 28 Aug 2026 23:37:37 +0900 Subject: [PATCH 12/42] refactor(comments): keep only rationale comments in git and workspace --- src/git/diff/conflict.rs | 22 ++++++++------------ src/git/diff/refs.rs | 23 ++++++++------------ src/git/diff/snapshot.rs | 38 ++++++++++++++-------------------- src/workspace/accent.rs | 12 +++++------ src/workspace/path_complete.rs | 30 +++++++++++++-------------- src/workspace/persistence.rs | 24 +++++++++------------ src/workspace/repo_input.rs | 9 ++++---- src/workspace/repo_picker.rs | 2 +- 8 files changed, 70 insertions(+), 90 deletions(-) diff --git a/src/git/diff/conflict.rs b/src/git/diff/conflict.rs index 40ceee72..fc33595b 100644 --- a/src/git/diff/conflict.rs +++ b/src/git/diff/conflict.rs @@ -1,17 +1,14 @@ -//! Saying what a conflict is when there is nothing to diff. +//! Names for conflicts that have nothing to diff against HEAD. //! -//! A conflicted path with markers in it diffs against HEAD like any other -//! change. The rest do not: git leaves our version on disk for a modify/delete, -//! keeps ours for a binary clash, and a rename/rename leaves a file that never -//! differed from HEAD at all. Those answer with no hunks, which on screen is -//! indistinguishable from a file nobody touched — for a row the status list is -//! showing as unmerged. +//! Most unmerged shapes leave no hunks — on screen that reads as a file nobody +//! touched, for a row the status list shows as unmerged — so each gets a +//! synthetic hunk saying what the conflict is. use crate::git::diff::types::{DiffHunk, DiffLine, LineKind}; use git2::Repository; -/// How `path` is conflicted, in git's own words for the same shapes -/// (`git status` calls them the same thing), or `None` if it is not. +/// How `path` is conflicted, worded the way `git status` words the same +/// shapes, or `None` if it is not conflicted. fn describe(repo: &Repository, path: &str) -> Option<&'static str> { let index = repo.index().ok()?; let wanted = path.as_bytes(); @@ -45,10 +42,9 @@ fn describe(repo: &Repository, path: &str) -> Option<&'static str> { ) } -/// One synthetic hunk naming the conflict, shaped like the one a binary change -/// gets: a header and a single line belonging to neither side, so a reader — -/// and the viewer's "is this text?" check — treats it as something to read -/// rather than something to edit against line numbers. +/// One synthetic hunk naming the conflict, shaped like a binary change's: a +/// header plus a line belonging to neither side, so a reader — and the +/// viewer's "is this text?" check — reads it as text, not line-numbered edits. pub(super) fn summary_hunk(repo: &Repository, path: &str) -> Option { let description = describe(repo, path)?; Some(DiffHunk { diff --git a/src/git/diff/refs.rs b/src/git/diff/refs.rs index c20acb1b..43a22398 100644 --- a/src/git/diff/refs.rs +++ b/src/git/diff/refs.rs @@ -3,10 +3,9 @@ use git2::{Oid, Repository}; use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; -/// Upper bound on the oids collected per divergence side. A repository can -/// diverge from its upstream by an arbitrary number of commits, and the sets -/// exist only to mark rows the user can actually scroll to; the walk yields -/// newest-first, so the cap drops the far tail rather than the visible head. +/// Upper bound on oids collected per divergence side: the walk yields +/// newest-first, so capping drops the far tail, not the rows a user can +/// actually scroll to. const MAX_DIVERGENCE_OIDS: usize = 1_000; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -26,11 +25,9 @@ pub struct RefLabel { pub name: String, } -/// Everything the commit log needs to decorate rows: which refs point at which -/// commit, and which commits are ahead of / behind the upstream. -/// -/// Built from refs alone, so it stays valid until a ref moves. Callers rebuild -/// it when [`refs_fingerprint`] changes rather than per frame or per poll. +/// Decorations for the commit log: which refs point at which commit, and +/// which commits are ahead of / behind the upstream. Built from refs alone, so +/// callers rebuild it when [`refs_fingerprint`] changes rather than per frame. #[derive(Debug, Default)] pub struct LogDecorations { labels: HashMap>, @@ -57,7 +54,7 @@ impl LogDecorations { } } -/// Cheap summary of every ref's name and target, used to decide whether +/// Cheap summary of every ref's name and target, to decide whether /// [`load_log_decorations`] needs to run again. A fetch that advances /// `origin/dev` changes this even though HEAD did not move. pub fn refs_fingerprint(repo: &Repository) -> u64 { @@ -156,10 +153,8 @@ pub fn load_log_decorations(repo: &Repository) -> Result { }) } -/// Oids that exist on exactly one side of the HEAD/upstream split. -/// -/// `None` when HEAD is detached, unborn, or has no upstream — there is nothing -/// to diverge from, which is not an error. +/// Oids on exactly one side of the HEAD/upstream split. `None` when HEAD is +/// detached, unborn, or has no upstream — nothing to diverge from, not an error. fn divergence_oids(repo: &Repository) -> Option<(HashSet, HashSet)> { let head = repo.head().ok()?; if !head.is_branch() { diff --git a/src/git/diff/snapshot.rs b/src/git/diff/snapshot.rs index b88412a4..086fbca7 100644 --- a/src/git/diff/snapshot.rs +++ b/src/git/diff/snapshot.rs @@ -29,10 +29,9 @@ pub fn load_snapshot(repo: &Repository) -> Result { .statuses(Some(&mut opts)) .context("failed to get repository status")?; - // Keyed by effective (new-side) path so the file list stays in a stable - // sorted order across refreshes — selection restoration depends on that. - // Each git status entry already carries both X and Y bits, so there is no - // longer a first-wins collapse: one entry maps to one row. + // Keyed by effective (new-side) path: one entry maps to one row, and the + // stable sorted order across refreshes is what selection restoration + // depends on. let mut files = BTreeMap::new(); for entry in statuses.iter() { let Some((index, worktree)) = status_columns(entry.status()) else { @@ -69,16 +68,13 @@ pub fn load_snapshot(repo: &Repository) -> Result { } /// Map a git2 status bitset into separate index (X) and worktree (Y) columns. -/// Untracked and conflicted are reported as both-column sentinels so the -/// renderer can collapse them to `??` / `UU`. Returns `None` when neither -/// column carries a displayable change. +/// Untracked and conflicted are sentinels spanning both columns so the +/// renderer can show `??` / `UU`. `None` when neither column displays a change. fn status_columns(status: Status) -> Option<(StatusKind, StatusKind)> { - // Untracked: git renders `??` (both columns), not ` ?`. Only a *purely* - // untracked entry collapses to `??`. A combined state such as - // `INDEX_DELETED | WT_NEW` (staged deletion, then a fresh file recreated at - // the same path) keeps its index status so the staged change is not hidden; - // git itself emits two rows there, but our one-row-per-path model preserves - // the index side (`D `) rather than masking it as untracked. + // Only a *purely* untracked entry collapses to `??`. A combined state such + // as `INDEX_DELETED | WT_NEW` keeps its index status so the staged change + // is not hidden; git emits two rows there, but the one-row-per-path model + // preserves the index side instead of masking it as untracked. let index_bits = Status::INDEX_NEW | Status::INDEX_MODIFIED | Status::INDEX_DELETED @@ -116,8 +112,7 @@ fn status_columns(status: Status) -> Option<(StatusKind, StatusKind)> { } else if status.contains(Status::WT_TYPECHANGE) { StatusKind::TypeChanged } else if status.contains(Status::WT_UNREADABLE) { - // No standard git short code; keep it visible as a worktree change - // rather than dropping the row (preserves prior behavior). + // No standard short code exists; keep the row visible as a change. StatusKind::Modified } else { StatusKind::Unmodified @@ -129,9 +124,8 @@ fn status_columns(status: Status) -> Option<(StatusKind, StatusKind)> { Some((index, worktree)) } -/// Effective (new-side) path plus the old path for renames. The effective -/// path drives diff/file loading; `old_path` is display/search metadata only -/// and is omitted when it equals the effective path. +/// Effective (new-side) path plus the old path for renames. `old_path` is +/// display/search metadata only, omitted when it equals the effective path. fn paths_from_status_entry(entry: &StatusEntry<'_>) -> Option<(String, Option)> { let i2w = entry.index_to_workdir(); let h2i = entry.head_to_index(); @@ -144,10 +138,10 @@ fn paths_from_status_entry(entry: &StatusEntry<'_>) -> Option<(String, Option p` asks for. Derived here rather than by the - /// daemon so the request names a colour instead of a step — two clients - /// cycling at once would otherwise land somewhere neither asked for. + /// The index the next ` p` asks for. Derived here rather than by + /// the daemon so the request names a colour instead of a step — two + /// clients cycling at once would otherwise land somewhere neither asked + /// for. pub fn next_accent_index(&self) -> usize { (self.accent_idx + 1) % crate::config::Accent::ALL.len() } diff --git a/src/workspace/path_complete.rs b/src/workspace/path_complete.rs index 7b447ea0..cf5929fa 100644 --- a/src/workspace/path_complete.rs +++ b/src/workspace/path_complete.rs @@ -1,9 +1,7 @@ -//! Tab completion for the repo dialog's path field. -//! -//! One `read_dir` per Tab press, against the single directory the buffer names. -//! Directories only: the dialog opens a repo and a file can never be one. -//! The dialog is not a shell, so only what `confirm_repo_input` itself accepts -//! is understood here: `~`, `..`, and cwd-relative paths. No `$VAR`, no globs. +//! Tab completion for the repo dialog's path field: one `read_dir` per Tab +//! press, directories only. The dialog is not a shell, so only what +//! `confirm_repo_input` itself accepts is understood here — `~`, `..`, and +//! cwd-relative paths. No `$VAR`, no globs. use std::path::{MAIN_SEPARATOR, Path}; @@ -96,7 +94,7 @@ pub(crate) fn complete_dir_path(buf: &str) -> PathCompletion { .filter(|n| n.to_lowercase().starts_with(&lower)) .collect(); } - // `read_dir_names` already sorted, and filtering preserves order. + // `read_dir_names` is sorted and filtering preserves order. match matches.len() { 0 => unchanged(), @@ -107,13 +105,13 @@ pub(crate) fn complete_dir_path(buf: &str) -> PathCompletion { }, _ => { let common = longest_common_prefix(&matches); - // Extending also corrects casing, so this fires whenever the shared - // prefix reads differently from what was typed, not only when it is - // longer. + // Extending also corrects casing, so this fires whenever the + // shared prefix reads differently from what was typed, not only + // when it is longer. let extended = common != frag; - // Listing and extending are independent. While typing can still be - // narrowed by an extension the list would be noise — except on a - // directory boundary, where an empty fragment means "what is in + // Listing and extending are independent. While typing could still + // be narrowed by an extension the list would be noise — except on + // a directory boundary, where an empty fragment means "what is in // here?" and a silent extension answers nothing. let candidates = if extended && !frag.is_empty() { Vec::new() @@ -133,9 +131,9 @@ pub(crate) fn complete_dir_path(buf: &str) -> PathCompletion { } /// `file_type` comes free with the directory read on most platforms; only a -/// symlink costs the extra stat to see what it points at. Symlinked checkouts -/// are common enough that reporting them as non-directories would hide real -/// repos, so unlike the in-repo tree navigator this one follows them. +/// symlink costs an extra stat. Symlinked checkouts are common enough that +/// reporting them as non-directories would hide real repos, so this follows +/// them (unlike the in-repo tree navigator). fn is_dir_entry(entry: &std::fs::DirEntry) -> bool { match entry.file_type() { Ok(t) if t.is_symlink() => entry.path().is_dir(), diff --git a/src/workspace/persistence.rs b/src/workspace/persistence.rs index 26f7e24c..42c19035 100644 --- a/src/workspace/persistence.rs +++ b/src/workspace/persistence.rs @@ -18,11 +18,9 @@ pub struct SessionState { pub mode: Option, #[serde(default)] pub log_selected: usize, - // No accent here: it is the session's, not one repository's view state, and - // lives in `viewer.json` (see the boundary in `docs/architecture.md`). An - // `accent_idx` left over from before is ignored on read rather than - // migrated — one of several per-repo colours cannot answer what the - // session's colour is. + // No accent here: it belongs to the session, not one repository's view + // state, and lives in `viewer.json` (see `docs/architecture.md`). A stale + // `accent_idx` is ignored on read rather than migrated. #[serde(default)] pub log_drill_down: bool, #[serde(default)] @@ -47,13 +45,12 @@ pub struct RepoSession { /// bound as repos are opened over the years. pub const MAX_REMEMBERED: usize = 50; -/// Everything nightcrow remembers between runs: which repositories were open, -/// which tab was in front, and each repository's view state. +/// Everything nightcrow remembers between runs: open repositories, the active +/// tab, and each repository's view state. /// -/// One file, under the config directory rather than inside any repository. -/// No single repo owns the fact that three others were open beside it, and -/// keeping view state out of the repos means nightcrow never creates a -/// directory in a project it is only reading. +/// One file, under the config directory: no single repo owns the tab list, and +/// keeping state out of the repos means nightcrow never writes into a project +/// it is only reading. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct WorkspaceState { /// Absolute repo paths in tab order. @@ -105,9 +102,8 @@ fn load_workspace_at(path: &Path) -> Option { /// Record the open tabs. Called on exit, like the per-repo sessions, so a /// crash loses the tab list the same way it loses the rest of the session. /// -/// An empty list is written rather than skipped: closing every tab and -/// quitting is how a user asks for an empty screen next launch, and dropping -/// the write would resurrect the previous tabs instead. +/// An empty list is written rather than skipped: dropping the write would +/// resurrect the previous tabs instead of honoring "no tabs next launch". pub fn save_workspace(state: &WorkspaceState) { let Some(path) = workspace_path() else { return; diff --git a/src/workspace/repo_input.rs b/src/workspace/repo_input.rs index 990217b2..09b2901e 100644 --- a/src/workspace/repo_input.rs +++ b/src/workspace/repo_input.rs @@ -1,7 +1,7 @@ use super::Workspace; use crate::app::NoticeKind; -/// Outcome of confirming the dialog. The caller owns the workspace, so it does +/// Outcome of confirming the dialog. The caller owns the workspace and does /// the opening; this only hands back an accepted path. #[derive(Debug, PartialEq, Eq)] pub enum RepoInputResult { @@ -81,9 +81,10 @@ impl Workspace { self.repo_input.candidates = completed.candidates; } - /// Typing always extends the path, never replaces it. The prefill exists to - /// supply a shared prefix; wiping it on the first keystroke would throw that - /// away with nothing to undo it. Esc and Backspace discard. + /// Typing always extends the path, never replaces it — the prefill is + /// there to supply a shared prefix, and wiping it on the first keystroke + /// would throw that away with nothing to undo it. Esc and Backspace + /// discard. pub fn repo_input_push(&mut self, ch: char) { if self.repo_input.buf.len() + ch.len_utf8() > REPO_INPUT_MAX_BYTES { return; diff --git a/src/workspace/repo_picker.rs b/src/workspace/repo_picker.rs index a3745e9b..f11502f4 100644 --- a/src/workspace/repo_picker.rs +++ b/src/workspace/repo_picker.rs @@ -28,7 +28,7 @@ impl Workspace { } /// Take the selection into the field. Enter means the same thing on every - /// row — going anywhere the tree does not show is `←`'s job, not a row's. + /// row — navigating beyond what the tree shows is `←`'s job, not a row's. pub fn repo_input_pick(&mut self) { let Some(tree) = self.repo_input.picker.take() else { return; From a9a0035cd509f92b03d646ff922643317ec923fb Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 00:08:37 +0900 Subject: [PATCH 13/42] refactor(comments): trim plugin recovery and viewer UI comments to rationale --- plugins/nightcrow-recovery/src/helper.rs | 51 ++++++------- .../nightcrow-recovery/src/helper_delegate.rs | 32 +++----- .../src/helper_statusline.rs | 47 ++++++------ plugins/nightcrow-recovery/src/hooks_merge.rs | 18 ++--- plugins/nightcrow-recovery/src/ipc.rs | 45 +++++------- plugins/nightcrow-recovery/src/protocol.rs | 12 +-- .../nightcrow-recovery/src/provider/codex.rs | 17 ++--- .../src/provider/codex_pane.rs | 13 ++-- .../src/provider/codex_rollout.rs | 8 +- .../src/provider/codex_sessions.rs | 11 +-- .../nightcrow-recovery/src/provider/mod.rs | 18 ++--- .../src/provider/opencode.rs | 29 ++++---- .../src/provider/opencode_http.rs | 12 +-- plugins/nightcrow-recovery/src/runloop.rs | 6 +- .../nightcrow-recovery/src/runloop_adopt.rs | 41 +++++------ plugins/nightcrow-recovery/src/runloop_io.rs | 6 +- plugins/nightcrow-recovery/src/state.rs | 24 +++--- plugins/nightcrow-recovery/src/state_clock.rs | 4 +- .../nightcrow-recovery/src/state_resume.rs | 8 +- plugins/nightcrow-recovery/src/wait.rs | 29 +++----- viewer-ui/src/components/FilePane.tsx | 7 +- viewer-ui/src/hooks/terminal/usePaneFocus.ts | 39 ++++------ viewer-ui/src/hooks/terminal/usePaneSizes.ts | 22 +++--- .../src/hooks/terminal/useTerminalSocket.ts | 12 +-- .../src/hooks/terminal/useTerminalViews.ts | 73 +++++++------------ viewer-ui/src/hooks/useClone.ts | 54 ++++++-------- viewer-ui/src/hooks/useLog.ts | 4 +- viewer-ui/src/hooks/usePaneOpeners.ts | 41 +++++------ viewer-ui/src/hooks/useRepoPoll.ts | 4 +- viewer-ui/src/hooks/useRepoViewMemory.ts | 13 ++-- viewer-ui/src/lib/termKeys.ts | 44 +++++------ 31 files changed, 329 insertions(+), 415 deletions(-) diff --git a/plugins/nightcrow-recovery/src/helper.rs b/plugins/nightcrow-recovery/src/helper.rs index 9c2c94ab..60cf16c6 100644 --- a/plugins/nightcrow-recovery/src/helper.rs +++ b/plugins/nightcrow-recovery/src/helper.rs @@ -1,21 +1,16 @@ //! The two modes a provider CLI invokes, not a human. //! //! Both run inside a child of the provider's own process, on that process's -//! critical path: Claude Code runs the statusline command on a refresh interval -//! and the hook command as a turn ends. So both do the least possible work — -//! read one JSON object, forward a handful of whitelisted fields, exit — and -//! neither ever reports a failure to its caller. A recovery plugin that is not -//! running must look exactly like one that was never installed. +//! critical path. So both do the least possible work — read one JSON object, +//! forward a handful of whitelisted fields, exit — and neither ever reports a +//! failure to its caller: a recovery plugin that is not running must look +//! exactly like one that was never installed. //! -//! Whitelisting is the privacy boundary. A `StopFailure` payload names a -//! transcript file and carries a provider's own error prose; a statusline payload -//! carries whatever else the provider decided to include. Only the fields the -//! state machine actually reads cross the socket, so nothing else can be -//! accidentally logged, buffered, or written down later. -//! -//! The one thing that leaves this process whole is the statusline payload handed -//! to the command we displaced (see [`status_line`]) — and that command was being -//! given the same bytes by Claude Code before this plugin was installed. We +//! Whitelisting is the privacy boundary: only the fields the state machine +//! actually reads cross the socket, so nothing else can be logged, buffered, +//! or written down later. The one thing that leaves this process whole is the +//! statusline payload handed to the command we displaced (see +//! [`status_line`]) — the same bytes Claude Code was already giving it. We //! narrow what we keep; we do not narrow what someone else was already told. use crate::ipc::{IpcMessage, send, socket_path}; @@ -66,10 +61,10 @@ pub fn hook() -> ExitCode { /// Report that a turn ended, so the host can raise the pane's attention marker. /// -/// Sends no payload: `Stop` fires whatever the outcome, and which outcome it was -/// is not something the marker distinguishes. Reading stdin is still necessary — -/// Claude Code writes the hook payload there and a helper that never drained it -/// would leave the provider writing into a full pipe. +/// Sends no payload: `Stop` fires whatever the outcome, and which outcome it +/// was is not something the marker distinguishes. Reading stdin is still +/// necessary — a helper that never drained it would leave the provider +/// writing into a full pipe. pub fn turn_end() -> ExitCode { let _ = read_stdin_bytes(); if let Some(token) = pane_token() { @@ -87,8 +82,8 @@ pub fn turn_end() -> ExitCode { /// Forward the statusline's `rate_limits` and print a line — the line the user's /// own statusline command printed, whenever installing this plugin displaced one. -/// Claude Code's `statusLine` holds a single command, so the only way not to cost -/// the user their statusline is to run it from ours; see [`status_line`]. +/// `statusLine` holds a single command, so the only way not to cost the user +/// their statusline is to run it from ours; see [`status_line`]. pub fn statusline() -> ExitCode { let raw = read_stdin_bytes(); let displaced = status_line::displaced(); @@ -115,9 +110,10 @@ struct Refresh { } /// Decide both halves of a refresh without touching the socket or stdout, so -/// every way a displaced command can disappoint us stays testable — and so the -/// usage numbers are read out of the payload before anything is delegated, which -/// is what keeps a misbehaving statusline command from costing us them. +/// every way a displaced command can disappoint us stays testable — and so +/// the usage numbers are read out of the payload before anything is +/// delegated, which is what keeps a misbehaving statusline command from +/// costing us them. fn refresh(raw: &[u8], displaced: Option<&Value>, budget: Duration) -> Refresh { let rate_limits = parse_object(raw).and_then(rate_limits_of); let line = status_line::line(displaced, raw, rate_limits.as_ref(), budget); @@ -139,11 +135,10 @@ fn pane_token() -> Option { } /// Every byte the provider wrote, kept exactly as it wrote them. The statusline -/// helper hands these on to the command it displaced, and a re-serialised copy is -/// not the same thing: key order and number formatting are the provider's to -/// choose, and a command that was reading its input before we existed should not -/// find it rearranged now. A read that fails part-way keeps what did arrive — -/// unparseable, and treated as such below. +/// helper hands these on to the command it displaced, and a re-serialised copy +/// is not the same thing: key order and number formatting are the provider's to +/// choose. A read that fails part-way keeps what did arrive — unparseable, +/// and treated as such below. fn read_stdin_bytes() -> Vec { let mut raw = Vec::new(); let _ = std::io::stdin() diff --git a/plugins/nightcrow-recovery/src/helper_delegate.rs b/plugins/nightcrow-recovery/src/helper_delegate.rs index 622732ec..3af09cff 100644 --- a/plugins/nightcrow-recovery/src/helper_delegate.rs +++ b/plugins/nightcrow-recovery/src/helper_delegate.rs @@ -1,10 +1,9 @@ //! Running a statusline command that is not ours, on a budget. //! //! Split out of `helper_statusline.rs` so that file decides *which* line gets -//! printed and this one is the process plumbing under it. Everything here is -//! written for a caller that must not be made to wait and must not be made to -//! fail: the child is bounded, killed when it overruns, reaped on every path, and -//! any disappointment comes back as `None`. +//! printed and this one is the process plumbing under it. Written for a caller +//! that must not be made to wait or to fail: the child is bounded, killed when +//! it overruns, reaped on every path, and any disappointment comes back as `None`. use std::io::{Read, Write}; use std::process::{Child, Command, Stdio}; @@ -14,14 +13,10 @@ use std::time::{Duration, Instant}; /// A POSIX shell, resolved on `PATH`. Not `$SHELL`: an interactive shell would /// read the user's rc files on every single refresh. /// -/// Windows included, and deliberately so. The command being run here is one -/// Claude Code was running before this plugin displaced it, and Claude Code runs -/// a `statusLine` through a POSIX shell on every platform — its own documented -/// examples are `$(...)`, `jq` pipelines and `~` paths, and the ones people -/// actually have installed reach for `stty`, `awk` and MSYS-style `/c/...` -/// paths. Handing such a line to `cmd.exe` does not run it differently; it fails -/// to run it at all, and the user silently loses their statusline. Which shell -/// the *host's panes* use is a separate setting and not this decision. +/// Windows included, and deliberately so: Claude Code runs a `statusLine` +/// through a POSIX shell on every platform, so the line being run here is one +/// `cmd.exe` would not run at all — the user would silently lose their +/// statusline. Which shell the *host's panes* use is a separate setting. const SHELL: &str = "sh"; const SHELL_COMMAND_ARG: &str = "-c"; @@ -45,10 +40,9 @@ const EXIT_POLL: Duration = Duration::from_millis(2); /// /// Through the platform shell's command mode (`sh -c` or `cmd.exe /C`), not an /// argv we split ourselves: the provider documents that a `statusLine` command -/// "runs in a shell", and its own examples rely on it — a `~` path, a `jq` -/// pipeline, an inline `$(...)`. Re-splitting the string the user wrote would -/// quietly change what it means. This is the user's own configuration rather -/// than input from a stranger, but it is also not ours to reinterpret. +/// "runs in a shell", and its examples (`~` paths, `jq` pipelines, inline +/// `$(...)`) rely on it. Re-splitting the string would quietly change what it +/// means — it is the user's own configuration, not ours to reinterpret. pub(super) fn capture(command: &str, raw: &[u8], budget: Duration) -> Option { let deadline = Instant::now() + budget; let mut child = spawn_shell(command)?; @@ -126,10 +120,8 @@ fn shell_child(shell: &str, arg: &str, command: &str) -> std::io::Result /// Whether the child finished, and finished happily, before `deadline`. /// -/// Polled rather than waited on: `wait` has no timeout, and a command that closes -/// its stdout and then sleeps must not get to hold a refresh open. Stdout is -/// already at EOF by the time this is called, so the first look nearly always -/// finds the child gone. +/// Polled rather than waited on: `wait` has no timeout, and a command that +/// closes its stdout and then sleeps must not get to hold a refresh open. fn exited_well(child: &mut Child, deadline: Instant) -> bool { loop { match child.try_wait() { diff --git a/plugins/nightcrow-recovery/src/helper_statusline.rs b/plugins/nightcrow-recovery/src/helper_statusline.rs index a432e015..dc16b7a8 100644 --- a/plugins/nightcrow-recovery/src/helper_statusline.rs +++ b/plugins/nightcrow-recovery/src/helper_statusline.rs @@ -1,17 +1,17 @@ //! The one line Claude Code renders, and who gets to write it. //! //! Installing this plugin necessarily takes the user's statusline away: -//! `statusLine` in `settings.json` holds one command, not a list, so ours replaces -//! whatever was there. Chaining is the only way to give it back — install recorded -//! the value it displaced in a sidecar, and every refresh runs that command with -//! the very bytes Claude Code sent us and prints what it printed. This plugin's own -//! two-number line ([`render_statusline`]) stands in only when there is nothing to -//! chain to. Running the command itself is [`delegate`]'s job. +//! `statusLine` in `settings.json` holds one command, not a list, so ours +//! replaces whatever was there. Chaining is the only way to give it back — +//! install recorded the value it displaced in a sidecar, and every refresh +//! runs that command with the very bytes Claude Code sent us and prints what +//! it printed. This plugin's own two-number line ([`render_statusline`]) +//! stands in only when there is nothing to chain to. Running the command +//! itself is [`delegate`]'s job. //! -//! Nothing here fails upwards. A statusline that shows an error is worse than a -//! plain one, so a missing sidecar, a value we cannot execute, a spawn failure, a -//! non-zero exit, a wedged child and non-UTF-8 output all end in the same place: -//! our own line, printed as if no chaining had been attempted. +//! Nothing here fails upwards. A statusline that shows an error is worse than +//! a plain one, so every disappointment ends in the same place: our own line, +//! printed as if no chaining had been attempted. use crate::hooks::{SettingsPaths, displaced_statusline, is_ours}; use serde_json::{Map, Value}; @@ -27,15 +27,13 @@ const STATUSLINE_FALLBACK: &str = "nightcrow: watching"; /// How long a displaced statusline command may take before we give up on it. /// -/// Claude Code documents no timeout for a statusline: it debounces updates at 300ms -/// and cancels an in-flight script when the next update arrives, so the provider is -/// already the one deciding we took too long. This bound is for the other direction -/// — a command that never returns must not make this process immortal, and our own -/// line has to get printed either way. Two seconds is many times that debounce and -/// generous even for the `git`-shelling scripts the provider's own guidance calls -/// slow, while keeping a wedged child's cost finite. It is also inside the five -/// seconds this plugin asks Claude Code to allow its hook, the most patience -/// anything here claims of the provider. +/// Claude Code documents no timeout for a statusline and cancels an in-flight +/// script when the next update arrives, so the provider is already the one +/// deciding we took too long. This bound is for the other direction: a command +/// that never returns must not make this process immortal. Two seconds is +/// generous even for the `git`-shelling scripts the provider's own guidance +/// calls slow, and inside the five seconds this plugin asks Claude Code to +/// allow its hook — the most patience anything here claims of the provider. pub(super) const BUDGET: Duration = Duration::from_secs(2); const TYPE_KEY: &str = "type"; @@ -77,14 +75,11 @@ fn delegated(displaced: Option<&Value>, raw: &[u8], budget: Duration) -> Option< /// /// Install recorded that value verbatim, so this reads the shape the provider /// documents and the one this plugin itself writes — an object with `type` and -/// `command` — and also accepts a bare string, which costs nothing and is the -/// obvious hand-written form. A value with some other `type` is a statusline we do -/// not know how to run, and guessing at it is worse than standing in for it; so is -/// `null`, which is what install records when it displaced nothing at all. +/// `command` — and also accepts a bare string, which is the obvious +/// hand-written form. A value with some other `type` is a statusline we do not +/// know how to run, and guessing at it is worse than standing in for it. /// -/// The entry's other fields are Claude Code's to act on, not ours: `padding` and -/// `refreshInterval` describe how the provider treats a statusline, and the -/// provider is reading them off our entry now, not off this one. +/// The entry's other fields are Claude Code's to act on, not ours. fn command_of(value: &Value) -> Option<&str> { let command = match value { Value::String(command) => command.as_str(), diff --git a/plugins/nightcrow-recovery/src/hooks_merge.rs b/plugins/nightcrow-recovery/src/hooks_merge.rs index 10e41a38..0d78e3b2 100644 --- a/plugins/nightcrow-recovery/src/hooks_merge.rs +++ b/plugins/nightcrow-recovery/src/hooks_merge.rs @@ -45,16 +45,12 @@ const STATUSLINE_PADDING: u64 = 2; /// Quote a path for the POSIX shell these commands are run in. /// -/// Claude Code runs a hook and a `statusLine` through a shell, on every platform -/// — its own documented examples are shell one-liners, and the entries other -/// tools install here are `if [ -f '...' ]; then ...`. So a Windows path cannot -/// be written bare: the shell reads each backslash as an escape, so -/// `C:\Users\me\plugin` arrives as `C:Usersmeplugin` and is simply not found. -/// Single quotes suspend every interpretation the shell would otherwise make, -/// which covers spaces in the path as well. -/// -/// A single quote cannot appear inside single quotes, so an embedded one is -/// closed, escaped on its own, and reopened. +/// Claude Code runs a hook and a `statusLine` through a shell on every +/// platform, and that shell reads each backslash of a Windows path as an +/// escape — `C:\Users\me\plugin` arrives as `C:Usersmeplugin` and is simply +/// not found. Single quotes suspend every interpretation the shell would +/// otherwise make, spaces included; an embedded one is closed, escaped on its +/// own, and reopened. fn shell_quoted(path: &str) -> String { format!("'{}'", path.replace('\'', r"'\''")) } @@ -162,7 +158,6 @@ pub(crate) fn merge_into(settings: &mut Value, exe: &str) -> Result<(Vec Ok((changes, displaced)) } -/// Remove exactly what [`merge_into`] added, putting `restore` back as /// Put one command into one hook event's matcher group, creating whatever is /// missing and touching nothing else. fn merge_hook( @@ -201,6 +196,7 @@ fn merge_hook( Ok(()) } +/// Remove exactly what [`merge_into`] added, putting `restore` back as /// `statusLine` when it holds a value we recorded. Containers we empty are /// collapsed so the file returns to its original shape. pub(crate) fn strip_from(settings: &mut Value, restore: Option) -> Result> { diff --git a/plugins/nightcrow-recovery/src/ipc.rs b/plugins/nightcrow-recovery/src/ipc.rs index e5a82702..69734004 100644 --- a/plugins/nightcrow-recovery/src/ipc.rs +++ b/plugins/nightcrow-recovery/src/ipc.rs @@ -1,17 +1,16 @@ //! The private socket a provider's helper processes report through. //! -//! Claude Code invokes a hook command and a statusline command as children of -//! the `claude` process, which means they inherit its environment — including the -//! [`PANE_TOKEN_ENV`] value nightcrow injected when it spawned the pane. Those -//! children live for milliseconds and must not block their parent, so they do the -//! smallest possible thing: connect, write one line, exit. This module is that -//! line's format and both ends of the socket. +//! Claude Code invokes a hook and a statusline command as children of the +//! `claude` process, so they inherit the [`PANE_TOKEN_ENV`] value nightcrow +//! injected. Those children live for milliseconds and must not block their +//! parent: connect, write one line, exit. This module is that line's format +//! and both ends of the socket. //! -//! Trust posture: anything that can reach the socket can claim to be any pane, so -//! the socket is created 0600 inside a 0700 directory and every field of every -//! message is validated before it reaches the state machine. The token is a -//! correlation key, never an authorisation: the worst a forged message can do is -//! make this plugin ask the host for something, and the host judges that again. +//! Trust posture: anything that can reach the socket can claim to be any pane, +//! so the socket is created 0600 inside a 0700 directory and every field is +//! validated before it reaches the state machine. The token is a correlation +//! key, never an authorisation — the worst a forged message can do is make +//! this plugin ask the host for something, and the host judges that again. use crate::protocol::PaneToken; use crate::provider::{OutOfBand, SignalKind}; @@ -63,11 +62,10 @@ pub const MAX_IPC_LINE_BYTES: usize = 8 * 1024; /// that so a future widening does not need a change here. const MAX_TOKEN_LEN: usize = 64; -/// How long either end will block on the socket. -/// -/// The sender runs inside a provider's hook child, so it must give up quickly -/// rather than hold up someone's CLI; the receiver uses the same bound so one -/// stalled client cannot park the accept loop. +/// How long either end will block on the socket. The sender runs inside a +/// provider's hook child, so it must give up quickly rather than hold up +/// someone's CLI; the receiver uses the same bound so one stalled client +/// cannot park the accept loop. const IPC_TIMEOUT: Duration = Duration::from_millis(500); /// One report from a provider helper process. @@ -96,15 +94,12 @@ pub const RUNTIME_DIR_ENV: &str = "NIGHTCROW_PLUGIN_RUNTIME_DIR"; /// Where the socket lives. /// -/// The host's directory when it named one, because a plugin process belongs to -/// one hub and a hub is per repository: a session with several projects runs -/// several of this binary, and one fixed path would let only the first bind. -/// The rest would find the address taken and run without a socket, and a -/// helper inside a pane would reach whichever instance won rather than the one -/// watching it. -/// -/// Falling back to the old fixed location keeps this runnable by hand and under -/// a host too old to say — one instance, one socket, as before. +/// The host's directory when it named one: a hub is per repository, so a +/// session with several projects runs several of this binary and one fixed +/// path would let only the first bind — a helper inside a pane would then +/// reach whichever instance won rather than the one watching it. Falling back +/// to the old fixed location keeps this runnable by hand and under a host too +/// old to say. pub fn socket_path() -> Result { if let Some(dir) = std::env::var_os(RUNTIME_DIR_ENV).filter(|d| !d.is_empty()) { return Ok(PathBuf::from(dir).join(SOCKET_FILE)); diff --git a/plugins/nightcrow-recovery/src/protocol.rs b/plugins/nightcrow-recovery/src/protocol.rs index 7ed208ca..adffd075 100644 --- a/plugins/nightcrow-recovery/src/protocol.rs +++ b/plugins/nightcrow-recovery/src/protocol.rs @@ -1,10 +1,10 @@ //! The plugin's side of nightcrow's NDJSON plugin contract. //! //! Deliberately a standalone copy of the host's `src/plugin/protocol.rs` rather -//! than a shared crate: a plugin is built and shipped separately from the host, -//! so it is written against a *version* of the contract. [`PROTOCOL_VERSION`] -//! is what makes a mismatch loud instead of half-understood, and a copy is what -//! makes the version claim honest. +//! than a shared crate: a plugin is built and shipped separately from the +//! host, so it is written against a *version* of the contract. +//! [`PROTOCOL_VERSION`] is what makes a mismatch loud instead of +//! half-understood, and a copy is what makes the version claim honest. use serde::{Deserialize, Serialize}; @@ -30,8 +30,8 @@ pub type PaneToken = String; pub type PaneGeneration = u32; /// Env var carrying the pane token into the pane's child processes, and hence -/// into a provider CLI's hook and statusline helpers. That inheritance is how an -/// out-of-band signal is attributed to a pane; cwd cannot do it, because +/// into a provider CLI's hook and statusline helpers. That inheritance is how +/// an out-of-band signal is attributed to a pane; cwd cannot do it, because /// nightcrow allows several panes on one repository. pub const PANE_TOKEN_ENV: &str = "NIGHTCROW_PANE_TOKEN"; diff --git a/plugins/nightcrow-recovery/src/provider/codex.rs b/plugins/nightcrow-recovery/src/provider/codex.rs index c577df0c..9a963833 100644 --- a/plugins/nightcrow-recovery/src/provider/codex.rs +++ b/plugins/nightcrow-recovery/src/provider/codex.rs @@ -3,22 +3,21 @@ //! Codex has no hook, no statusline and no `status` subcommand, and it *exits* //! when the usage limit is hit — with exit code 1, indistinguishable from any //! other failure — so neither the exit code nor a still-running process can be -//! used as a signal. What codex does have is a per-session rollout file, and that -//! is the primary source here: [`Provider::poll`] tails the pane's rollout and +//! used as a signal. What codex has is a per-session rollout file, and that is +//! the primary source here: [`Provider::poll`] tails the pane's rollout and //! acts on the `turn_complete` record whose `error.codex_error_info` is -//! `usage_limit_exceeded`, taking the deadline from the most recent `token_count` -//! record. `EventMsg::Error` is not persisted to the rollout, so it is not looked -//! for. Terminal text is a documented fallback only, and a reset time is never -//! parsed out of it. +//! `usage_limit_exceeded`, taking the deadline from the most recent +//! `token_count` record. Terminal text is a documented fallback only, and a +//! reset time is never parsed out of it. //! //! Recovery is always a relaunch (`codex resume `), never typed //! input. `codex resume --last` is deliberately never used: nightcrow allows //! several codex panes on one repository, so "the last session" could belong to -//! another pane. Without an unambiguous session id this adapter holds. +//! another pane. //! //! Layout: `codex_pane.rs` holds the per-pane watching state, -//! `codex_sessions.rs` finds the pane's rollout file and `codex_rollout.rs` holds -//! the pure record grammar. This file holds only the `Provider` contract. +//! `codex_sessions.rs` finds the pane's rollout file and `codex_rollout.rs` +//! holds the pure record grammar. This file holds only the `Provider` contract. use super::{LimitEvent, PaneContext, Provider, ResumePlan}; use crate::protocol::PaneToken; diff --git a/plugins/nightcrow-recovery/src/provider/codex_pane.rs b/plugins/nightcrow-recovery/src/provider/codex_pane.rs index a370d3c8..87d087b2 100644 --- a/plugins/nightcrow-recovery/src/provider/codex_pane.rs +++ b/plugins/nightcrow-recovery/src/provider/codex_pane.rs @@ -1,6 +1,6 @@ -//! Per-pane, per-generation state for the codex adapter: which rollout file this -//! pane's session is writing, how far into it we have read, and the tail of -//! terminal output kept for the fallback needle match. +//! Per-pane, per-generation state for the codex adapter: which rollout file +//! this pane's session is writing, how far into it we have read, and the tail +//! of terminal output kept for the fallback needle match. //! //! Split out of `codex.rs` to keep both files inside the project's 300-line //! limit; `codex.rs` keeps the `Provider` contract and this file keeps the @@ -55,9 +55,10 @@ pub(super) struct PaneState { pending: Vec, session_id: Option, resets_at: Option, - /// Which window codex reported as reached. Parsed because the record is seen - /// only once, but kept out of `detail`, which carries `codex_error_info` - /// alone so no other provider-side string can widen what this plugin says. + /// Which window codex reported as reached. Parsed because the record is + /// seen only once, but kept out of `detail`, which carries + /// `codex_error_info` alone so no other provider-side string can widen + /// what this plugin says. reached_type: Option, output_tail: String, output_latched: bool, diff --git a/plugins/nightcrow-recovery/src/provider/codex_rollout.rs b/plugins/nightcrow-recovery/src/provider/codex_rollout.rs index b6940a42..9a83cdf4 100644 --- a/plugins/nightcrow-recovery/src/provider/codex_rollout.rs +++ b/plugins/nightcrow-recovery/src/provider/codex_rollout.rs @@ -4,10 +4,10 @@ //! filesystem, and so every file stays inside the project's 300-line limit. //! //! Every rollout line has the shape -//! `{"timestamp":..,"ordinal":N,"type":"","payload":{..}}`. Only three tags -//! matter to recovery; everything else — including tags added by a future codex -//! release — is ignored silently, because an adapter that fails on unknown -//! records would break on every upgrade. +//! `{"timestamp":..,"ordinal":N,"type":"","payload":{..}}`. Only three +//! tags matter to recovery; everything else — including tags added by a +//! future codex release — is ignored silently, because an adapter that fails +//! on unknown records would break on every upgrade. use crate::provider::reset_epoch_from_json; use serde_json::Value; diff --git a/plugins/nightcrow-recovery/src/provider/codex_sessions.rs b/plugins/nightcrow-recovery/src/provider/codex_sessions.rs index db3dfbe1..8a8e7e23 100644 --- a/plugins/nightcrow-recovery/src/provider/codex_sessions.rs +++ b/plugins/nightcrow-recovery/src/provider/codex_sessions.rs @@ -14,11 +14,12 @@ const MONTH_DAY_DIR_LEN: usize = 2; /// How many day directories are searched for the pane's session. /// -/// The directories are named in *local* time and this crate has no date library, -/// so instead of computing today's name the `sessions/` tree is listed and the -/// lexicographically greatest day directories are taken — zero-padded -/// `YYYY/MM/DD` sorts chronologically. Two of them, because a session started -/// before local midnight keeps writing into yesterday's directory. +/// The directories are named in *local* time and this crate has no date +/// library, so instead of computing today's name the `sessions/` tree is +/// listed and the lexicographically greatest day directories are taken — +/// zero-padded `YYYY/MM/DD` sorts chronologically. Two of them, because a +/// session started before local midnight keeps writing into yesterday's +/// directory. const CANDIDATE_DAY_DIRS: usize = 2; /// Rollout files in the newest day directories that were modified at or after diff --git a/plugins/nightcrow-recovery/src/provider/mod.rs b/plugins/nightcrow-recovery/src/provider/mod.rs index 0d1209e6..67f197b8 100644 --- a/plugins/nightcrow-recovery/src/provider/mod.rs +++ b/plugins/nightcrow-recovery/src/provider/mod.rs @@ -71,9 +71,8 @@ pub enum SignalKind { /// The `rate_limits` object from Claude Code's statusline payload. RateLimits, /// Claude Code's `Stop` hook: a turn ended, however it ended. Carries no - /// payload — the fact that it fired is the whole message — and never - /// reaches a provider, because wanting the person back is not a provider - /// question. + /// payload and never reaches a provider, because wanting the person back + /// is not a provider question. TurnEnd, } @@ -180,16 +179,15 @@ pub fn detect(command: Option<&str>) -> Option> { } /// Pick an adapter from a signal that arrived over the IPC socket, for a pane -/// whose command line says nothing — the shell somebody opened and then started -/// a provider CLI inside by hand. +/// whose command line says nothing — the shell somebody opened and then +/// started a provider CLI inside by hand. /// /// Sound because a [`SignalKind`] is minted by exactly one provider's helper: /// a `stop_failure` line can only have come from the Claude Code hook this -/// binary installed into Claude Code's own settings. The signal is therefore -/// evidence of what the pane is running, in a way terminal text never is — which -/// is why this is a lookup on the wire kind and deliberately not a second -/// sniffing path. A kind added later has to be classified here rather than -/// falling through to a guess. +/// binary installed. The signal is therefore evidence of what the pane is +/// running, in a way terminal text never is — which is why this is a lookup on +/// the wire kind and deliberately not a second sniffing path. A kind added +/// later has to be classified here rather than falling through to a guess. pub fn detect_from_signal(kind: SignalKind) -> Option> { match kind { SignalKind::StopFailure | SignalKind::RateLimits | SignalKind::TurnEnd => { diff --git a/plugins/nightcrow-recovery/src/provider/opencode.rs b/plugins/nightcrow-recovery/src/provider/opencode.rs index 76022285..b15933bf 100644 --- a/plugins/nightcrow-recovery/src/provider/opencode.rs +++ b/plugins/nightcrow-recovery/src/provider/opencode.rs @@ -1,12 +1,11 @@ //! OpenCode adapter — deliberately observe-only. //! -//! OpenCode retries a retryable API error *without bound*: there is no -//! max-attempt constant, the backoff starts at 2 s and doubles, and the 30 s cap -//! applies only when the response carried no `retry-after` header — with one the -//! cap is ~24.8 days. So "wait for the retries to run out" is a state this -//! adapter can never reach, and a pane in `retry` is hands off: no input, no -//! relaunch, no abort. It only reports, and only once the retry is demonstrably -//! over — the session went `idle`, or the process exited. +//! OpenCode retries a retryable API error *without bound*: the 30 s cap applies +//! only when the response carried no `retry-after` header — with one the cap is +//! ~24.8 days. So "wait for the retries to run out" is a state this adapter can +//! never reach, and a pane in `retry` is hands off: no input, no relaunch, no +//! abort. It only reports, and only once the retry is demonstrably over — the +//! session went `idle`, or the process exited. //! //! State comes from the local server's `GET /session/status`, which is //! first-class server state rather than screen scraping. Terminal text is not @@ -27,7 +26,8 @@ pub use http::{ pub const DEFAULT_PORT: u16 = 4096; /// Override for a user who always runs the server elsewhere. A `--port` on the -/// pane's own command line wins over it: that is the truth about *this* process. +/// pane's own command line wins over it: that is the truth about *this* +/// process. const PORT_ENV: &str = "NIGHTCROW_OPENCODE_PORT"; /// Snapshot of every session the server knows about. @@ -198,8 +198,8 @@ impl Provider for OpenCode { _now_epoch: i64, ) -> Option { // Intentionally blind: OpenCode's TUI retry format string is unverified, - // so any needle list here would be a guess, and a wrong guess parks a - // healthy pane. The status endpoint is authoritative; the screen is not. + // and a wrong needle guess parks a healthy pane. The status endpoint is + // authoritative; the screen is not. None } @@ -208,18 +208,17 @@ impl Provider for OpenCode { if self.fired { return None; } - // The process is gone, so the last thing we saw is final and there is - // nothing left on the server worth asking about. if self.exited { + // The process is gone, so the last thing we saw is final. return self.emit(now_epoch); } if !self.due(now_epoch) { return None; } self.last_poll = Some(now_epoch); - // No server, a non-200, or an unreadable body is ordinary — the user need - // not be running the server at all. Swallow it, and let the interval keep - // the next attempt from becoming a tight loop. + // No server, a non-200, or an unreadable body is ordinary — the user + // need not be running the server at all. Swallow it, and let the + // interval keep the next attempt from becoming a tight loop. let statuses = parse_status_body(&self.fetch_status().ok()?); if let Some(status) = statuses .iter() diff --git a/plugins/nightcrow-recovery/src/provider/opencode_http.rs b/plugins/nightcrow-recovery/src/provider/opencode_http.rs index 151c59e7..b2449643 100644 --- a/plugins/nightcrow-recovery/src/provider/opencode_http.rs +++ b/plugins/nightcrow-recovery/src/provider/opencode_http.rs @@ -125,10 +125,10 @@ fn status_kind(status: &Value) -> StatusKind { /// Resolve the ambiguous `next` field to an absolute unix time in **seconds**. /// /// Whether OpenCode reports an absolute epoch (in which unit) or a relative -/// delay is unverified, so all three readings are tried. The order is by safety -/// rather than by likelihood: absolute readings come first, because over-waiting -/// only costs time while firing early walks straight back into the limit. `None` -/// means "no deadline", which degrades to the machine's own bounded backoff. +/// delay is unverified, so all three readings are tried, ordered by safety +/// rather than by likelihood: over-waiting only costs time, while firing early +/// walks straight back into the limit. `None` means "no deadline", which +/// degrades to the machine's own bounded backoff. pub fn interpret_next(next: i64, now_epoch: i64) -> Option { // Zero or negative is "now" or a corrupt value; both would fire immediately, // so neither is accepted as a deadline. @@ -155,8 +155,8 @@ pub fn interpret_next(next: i64, now_epoch: i64) -> Option { /// /// Deliberately no transfer-encoding handling: a chunked answer comes back with /// its framing intact, [`parse_status_body`] then finds no statuses in it, and -/// the poll degrades to "nothing to report" — the same outcome as no server at -/// all. That is the right failure for an adapter that must never guess. +/// the poll degrades to "nothing to report" — the right failure for an adapter +/// that must never guess. pub fn http_get(port: u16, path: &str, timeout: Duration) -> anyhow::Result { anyhow::ensure!( is_safe_path(path), diff --git a/plugins/nightcrow-recovery/src/runloop.rs b/plugins/nightcrow-recovery/src/runloop.rs index 378b1e35..5a714632 100644 --- a/plugins/nightcrow-recovery/src/runloop.rs +++ b/plugins/nightcrow-recovery/src/runloop.rs @@ -249,9 +249,9 @@ fn on_signal( let (token, signal) = msg.into_signal(); return deliver_signal(panes, &token, &signal); } - // Not a pane we were told about — and that is the common case rather than the - // odd one. The token is proof the sender runs inside one of the host's panes, - // so ask for it; a token from another nightcrow session simply goes + // Not a pane we were told about — and that is the common case rather than + // the odd one. The token is proof the sender runs inside one of the host's + // panes, so ask for it; a token from another nightcrow session simply goes // unanswered. See [`Adoptions`] for why asking is bounded. if let Some(command) = adoptions.request(msg, Instant::now()) { emit(&command)?; diff --git a/plugins/nightcrow-recovery/src/runloop_adopt.rs b/plugins/nightcrow-recovery/src/runloop_adopt.rs index bd0752f9..b665c296 100644 --- a/plugins/nightcrow-recovery/src/runloop_adopt.rs +++ b/plugins/nightcrow-recovery/src/runloop_adopt.rs @@ -1,16 +1,16 @@ //! Asking the host for a pane it never named to us. //! -//! The dominant way a coding CLI gets started is by hand: the user opens a plain -//! shell and types `claude` into it. That pane's `[[startup_command]]` names no -//! plugin, so the host never mentions it — but the CLI's hook still reaches us -//! over the socket, carrying the token the host put in that pane's environment. -//! Presenting the token back is the whole request; the host decides whether to -//! honour it, and never tells us when it does not. +//! The dominant way a coding CLI gets started is by hand: the user opens a +//! plain shell and types `claude` into it. That pane's `[[startup_command]]` +//! names no plugin, so the host never mentions it — but the CLI's hook still +//! reaches us over the socket, carrying the token the host put in that pane's +//! environment. Presenting the token back is the whole request; the host +//! decides whether to honour it, and never tells us when it does not. //! -//! Everything here exists because of that silence. A refusal is indistinguishable -//! from a token belonging to another nightcrow session's pane, so a request that -//! goes unanswered must not be repeated in a tight loop and must not leave state -//! behind that grows with every stranger that knocks. +//! Everything here exists because of that silence: a refusal is +//! indistinguishable from a token belonging to another nightcrow session, so +//! an unanswered request must not be repeated in a tight loop and must not +//! leave state behind that grows with every stranger that knocks. use crate::ipc::IpcMessage; use crate::protocol::{PaneToken, PluginCommand, watch_pane}; @@ -32,20 +32,18 @@ const MAX_PENDING: usize = 8; /// The host answers in milliseconds or never, so this is not a retry interval — /// it is the rate at which a token that is not ours may cost us a command. /// Claude Code's statusline runs on every render, so without it a foreign pane -/// would have us writing a request several times a second, all refused and every -/// one of them counted against the host's per-tick command budget alongside the -/// requests that matter. Half a minute is far longer than any honoured request -/// takes and short enough that a pane which only just became ours is not shut -/// out for long. +/// would have us writing a refused request several times a second, each counted +/// against the host's per-tick command budget. Half a minute is far longer than +/// any honoured request takes and short enough that a pane which only just +/// became ours is not shut out for long. const REQUEST_COOLDOWN: Duration = Duration::from_secs(30); /// One outstanding request, and the signal that justified making it. struct Pending { /// Kept so the adapter still gets it. The signal arrives *before* the pane - /// does — it is the reason the pane arrives at all — and the host replays no - /// history to a pane it has just handed over, so dropping it would lose the - /// very limit being recovered from and leave the pane parked until the - /// provider happened to fail again. + /// does — it is the reason the pane arrives at all — and the host replays + /// no history to a pane it has just handed over, so dropping it would lose + /// the very limit being recovered from. signal: OutOfBand, asked_at: Instant, } @@ -91,9 +89,8 @@ impl Adoptions { /// again. /// /// Without it a handful of foreign tokens would hold every slot for the - /// process's whole life, and a pane that later became ours could not get a - /// request in. Giving up is safe: a pane that really is ours signals again, - /// and the held signal is stale by then anyway. + /// process's whole life. Giving up is safe: a pane that really is ours + /// signals again, and the held signal is stale by then anyway. pub(crate) fn prune(&mut self, now: Instant) { self.0 .retain(|_, p| now.saturating_duration_since(p.asked_at) < REQUEST_COOLDOWN); diff --git a/plugins/nightcrow-recovery/src/runloop_io.rs b/plugins/nightcrow-recovery/src/runloop_io.rs index fa647caa..2436d20d 100644 --- a/plugins/nightcrow-recovery/src/runloop_io.rs +++ b/plugins/nightcrow-recovery/src/runloop_io.rs @@ -1,8 +1,8 @@ //! The plugin's two ends of the host's NDJSON stream. //! -//! Split out of `runloop.rs` so that file is the loop's reasoning and this one is -//! its plumbing. Everything the plugin says leaves through [`emit`], called only -//! from the main thread, which is what keeps two half-written lines from +//! Split out of `runloop.rs` so that file is the loop's reasoning and this one +//! is its plumbing. Everything the plugin says leaves through [`emit`], called +//! only from the main thread, which is what keeps two half-written lines from //! interleaving on stdout. use crate::ipc::IpcMessage; diff --git a/plugins/nightcrow-recovery/src/state.rs b/plugins/nightcrow-recovery/src/state.rs index 844461fa..f445fa48 100644 --- a/plugins/nightcrow-recovery/src/state.rs +++ b/plugins/nightcrow-recovery/src/state.rs @@ -7,9 +7,9 @@ //! cases (a stale generation, a clock jump, an exhausted attempt budget) are //! ordinary unit tests rather than something only reproducible by waiting. //! -//! Safety posture: this machine never decides that a pane is alive or idle. It -//! only ever repeats back what the host told it, and it refuses to ask for input -//! unless the host has said both. The host judges every request again anyway. +//! Safety posture: this machine never decides that a pane is alive or idle; it +//! only repeats back what the host told it, and refuses to ask for input unless +//! the host has said both. The host judges every request again anyway. use crate::protocol::{PROTOCOL_VERSION, PaneGeneration, PaneToken, PluginCommand, PluginEvent}; use crate::provider::{LimitEvent, LimitKind}; @@ -136,10 +136,10 @@ impl PaneRecovery { } let mut out = Vec::new(); if generation > self.generation { - // A new spawn of the slot voids everything decided about the previous - // process. Landing in `Idle` either way; the only difference is that - // a relaunch we asked for counts as a resume that worked, while a - // respawn we did not ask for is a plain cancellation. + // A new spawn of the slot voids everything decided about the + // previous process. A relaunch we asked for counts as a resume + // that worked; a respawn we did not ask for is a plain cancellation. + // Either way the machine lands in `Idle`. self.generation = generation; self.alive = true; self.idle = false; @@ -168,7 +168,7 @@ impl PaneRecovery { } PluginEvent::PaneClosed { .. } | PluginEvent::UserInput { .. } => { // The slot is gone, or its human took it back. Either way this - // machine has no business acting on it again, and the attempt + // machine has no business acting on it again; the attempt // budget resets because the next episode is a fresh one. self.attempt = 0; out.extend(self.cancel()); @@ -219,10 +219,10 @@ impl PaneRecovery { return Vec::new(); } // The attempt budget is refunded only for an episode that had a real - // reset time to wait for. Those are bounded by the provider's own - // window, so refunding cannot spin. An episode with no known reset time - // keeps its count, which is what stops a pane that resumes cleanly and - // then immediately fails again from retrying forever. + // reset time to wait for — those are bounded by the provider's own + // window, so refunding cannot spin. An episode with no known reset + // time keeps its count, which is what stops a pane that resumes + // cleanly and then immediately fails again from retrying forever. if self.limit.as_ref().and_then(|l| l.resets_at).is_some() { self.attempt = 0; } diff --git a/plugins/nightcrow-recovery/src/state_clock.rs b/plugins/nightcrow-recovery/src/state_clock.rs index 517c2536..a5461773 100644 --- a/plugins/nightcrow-recovery/src/state_clock.rs +++ b/plugins/nightcrow-recovery/src/state_clock.rs @@ -1,8 +1,8 @@ //! Waiting: the half of the machine driven by the clock rather than by an event. //! //! Split out of `state.rs` for readability. Nothing here decides *what* to do -//! about a limit; it decides only how long to sit still first, and it is the one -//! place that can end a recovery by running out of attempts. +//! about a limit; it decides only how long to sit still first, and it is the +//! one place that can end a recovery by running out of attempts. use super::{MAX_RESUME_ATTEMPTS, PaneRecovery, RESUME_CONFIRM_SECS, RecoveryState}; use crate::protocol::PluginCommand; diff --git a/plugins/nightcrow-recovery/src/state_resume.rs b/plugins/nightcrow-recovery/src/state_resume.rs index 8d6697e0..b87400b6 100644 --- a/plugins/nightcrow-recovery/src/state_resume.rs +++ b/plugins/nightcrow-recovery/src/state_resume.rs @@ -3,10 +3,10 @@ //! Split out of `state.rs` to keep each file readable: `state.rs` owns time and //! transitions, this owns the one moment the plugin actually asks for something. //! -//! Everything here is written on the assumption that the host will refuse. A -//! refusal costs an attempt and nothing else, so the checks below exist to avoid -//! wasting attempts on requests that are obviously going to be rejected — not to -//! be the safety boundary. That boundary is the host's. +//! Everything here assumes the host will refuse: a refusal costs an attempt and +//! nothing else, so the checks below exist to avoid wasting attempts on +//! requests that are obviously going to be rejected — not to be the safety +//! boundary. That boundary is the host's. use super::{MAX_RESUME_ATTEMPTS, PaneRecovery, RecoveryState}; use crate::protocol::{MAX_INPUT_BYTES, PROTOCOL_VERSION, PluginCommand}; diff --git a/plugins/nightcrow-recovery/src/wait.rs b/plugins/nightcrow-recovery/src/wait.rs index d6699589..035c2c59 100644 --- a/plugins/nightcrow-recovery/src/wait.rs +++ b/plugins/nightcrow-recovery/src/wait.rs @@ -1,19 +1,15 @@ //! Waiting for a usage limit to reset, without trusting either clock alone. //! -//! A reset time arrives as an absolute unix second, which is the only form a -//! provider reports and the only form worth showing a human. But the wall clock -//! can be changed underneath a wait that lasts hours — an NTP correction, a -//! laptop returning from suspend, a user fixing their timezone — and a wait -//! driven purely by wall time would then either fire immediately (resuming into -//! a limit that has not cleared, burning an attempt) or never fire at all -//! (stranding the pane). +//! A reset time is an absolute unix second, but the wall clock can be changed +//! underneath a wait that lasts hours — an NTP correction, a laptop returning +//! from suspend. A wait driven purely by wall time would then fire early +//! (resuming into a limit that has not cleared, burning an attempt) or never +//! fire at all (stranding the pane). //! -//! So a wait keeps both: the absolute deadline, and a monotonic countdown of the -//! same length. Between two polls the two clocks must advance together; when -//! they disagree by more than [`JUMP_TOLERANCE_SECS`] the wall clock moved, and -//! the deadline is shifted by that amount so it stays fixed to the *new* clock. -//! The monotonic countdown then still has to elapse before the wait is over, so -//! a jump can neither shorten nor lengthen the real time spent waiting. +//! So a wait keeps both: the absolute deadline, and a monotonic countdown of +//! the same length. When the two disagree by more than [`JUMP_TOLERANCE_SECS`] +//! the wall clock moved, and the deadline is shifted by that amount so the +//! real time spent waiting can be neither shortened nor lengthened. use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -34,10 +30,9 @@ pub const MIN_WAIT_SECS: i64 = 15; /// Longest wait. Claude's longest documented window is seven days; eight days /// is past anything legitimate, so a deadline that would exceed it is clamped -/// rather than parking a pane indefinitely. -/// Must stay under the host's `PENDING_RELAUNCH_TTL` (nine days): the host -/// retires an exited pane's slot at that point, and a wait outlasting it would -/// end with nothing left to resume. +/// rather than parking a pane indefinitely. Must stay under the host's +/// `PENDING_RELAUNCH_TTL` (nine days): a wait outlasting that would end with +/// nothing left to resume. pub const MAX_WAIT_SECS: i64 = 8 * 24 * 60 * 60; /// Added to a reported reset time before resuming. diff --git a/viewer-ui/src/components/FilePane.tsx b/viewer-ui/src/components/FilePane.tsx index 9556ffd3..2c7ececc 100644 --- a/viewer-ui/src/components/FilePane.tsx +++ b/viewer-ui/src/components/FilePane.tsx @@ -108,10 +108,9 @@ export function FilePane({ top: container.scrollTop, // Sideways as far as this container owns it, which is the unified // view — its rows are `w-max` inside this scroller. A split column - // scrolls itself (`overflow-x-auto` per half), and that offset is not - // kept: restoring it means addressing each column across a remount, for - // a position that matters far less than how far down the reader had - // got. + // scrolls itself, and that offset is not kept: restoring it means + // addressing each column across a remount, for a position that + // matters far less than how far down the reader had got. left: container.scrollLeft, }; } diff --git a/viewer-ui/src/hooks/terminal/usePaneFocus.ts b/viewer-ui/src/hooks/terminal/usePaneFocus.ts index 8bcef820..da754b3a 100644 --- a/viewer-ui/src/hooks/terminal/usePaneFocus.ts +++ b/viewer-ui/src/hooks/terminal/usePaneFocus.ts @@ -41,12 +41,12 @@ interface UsePaneFocusArgs { * typed into; and the active pane keeps the actual DOM focus — not just when it * becomes active, but for as long as the layout lets it hold one. * - * Which pane the first rule picks is whichever this screen last had the keyboard - * on, kept in `lib/lastPane` — outside the page, because a reload is exactly - * when the answer is needed and a reload is what used to lose it. The socket - * restores it as its pane arrives; what is left to `focusOnAttach` here is the - * screen that has no such pane. Either way the pane it settles on is written - * back, so what is remembered always names a pane the session has. + * Which pane the first rule picks is whichever this screen last had the + * keyboard on, kept in `lib/lastPane` — outside the page, because a reload is + * exactly when the answer is needed and a reload is what used to lose it. The + * socket restores it as its pane arrives; what is left to `focusOnAttach` here + * is the screen that has no such pane. Either way the pane it settles on is + * written back, so what is remembered always names a pane the session has. * * The middle rule is the one that is easy to miss. A zoom no longer needs a * click on this page to happen — it is replayed on connect and set by other @@ -99,25 +99,18 @@ export function usePaneFocus({ // // Run on the same signals `useTerminalViews` opens panes on, not on `active` // alone, because two things other than a change of pane decide whether the - // active one holds the keyboard: - // - // - the xterm may not exist yet. Creation is deferred while the cell has no - // layout box, and panes arrive over the socket whether the panel is on - // screen or not, so `active` is routinely set before there is anything to - // focus. - // - hiding the panel takes the focus away. Below `md` the panel is - // `display: none` whenever another view is chosen, and an element that - // stops being rendered is blurred to the body. - // - // Either way `active` is unchanged when the panel comes back, so an effect - // keyed on it alone leaves a panel that draws output, accepts the on-screen - // key bar, and ignores the keyboard. This hook is declared after - // `useTerminalViews`, so a pane opened by the same reveal is already here. + // active one holds the keyboard: the xterm may not exist yet (creation is + // deferred while the cell has no layout box), and hiding the panel takes + // the focus away. Either way `active` is unchanged when the panel comes + // back, so an effect keyed on it alone leaves a panel that draws output, + // accepts the on-screen key bar, and ignores the keyboard. This hook is + // declared after `useTerminalViews`, so a pane opened by the same reveal is + // already here. // // What is asked on each of those signals is an edge — see `focusStep`. The - // pane the panel holds is remembered rather than re-derived, because the DOM - // cannot answer it: focus this page never had looks exactly like focus it had - // and lost. + // pane the panel holds is remembered rather than re-derived, because the + // DOM cannot answer it: focus this page never had looks exactly like focus + // it had and lost. const heldRef = useRef(null); useEffect(() => { const view = active === null ? undefined : viewsRef.current.get(active); diff --git a/viewer-ui/src/hooks/terminal/usePaneSizes.ts b/viewer-ui/src/hooks/terminal/usePaneSizes.ts index 5a91d646..b8002f25 100644 --- a/viewer-ui/src/hooks/terminal/usePaneSizes.ts +++ b/viewer-ui/src/hooks/terminal/usePaneSizes.ts @@ -11,12 +11,10 @@ import { * * A resize is not a cheap message: the child gets SIGWINCH and a full-screen * program answers it by repainting from scratch. The browser reaches its - * final geometry through several intermediate ones — a web font finishing, - * the grid re-splitting as another pane appears, a window animating, a - * breakpoint flipping — and forwarding each one makes the child redraw once - * per step. Waiting for the layout to settle sends the one size the user - * actually ended up with. Short enough to stay imperceptible while dragging - * a divider. */ + * final geometry through several intermediate ones, and forwarding each one + * makes the child redraw once per step. Waiting for the layout to settle + * sends the one size the user actually ended up with, while staying + * imperceptible during a divider drag. */ const SETTLE_MS = 60; interface UsePaneSizesArgs { @@ -109,12 +107,12 @@ export function usePaneSizes({ const body = bodyRefs.current.get(pane); if (!body || body.clientHeight === 0 || body.clientWidth === 0) continue; // Take the size the PTY actually has whenever this page's cells are not - // the answer: it is a spectator, or its layout has not resolved yet. Not - // merely "skip the fit" — an emulator at any other size renders this - // pane wrapping where the child does not. A pane opens at the PTY's size - // (`useTerminalViews`) and `resized` follows it from then on, so this is - // what covers the rest: a layout change here, and a page that has just - // lost the sizing while its panes are at its own fit. + // the answer: it is a spectator, or its layout has not resolved yet. + // Not merely "skip the fit" — an emulator at any other size renders + // this pane wrapping where the child does not. A pane opens at the + // PTY's size (`useTerminalViews`) and `resized` follows it from then + // on, so this covers the rest: a layout change here, and a page that + // has just lost the sizing while its panes are at its own fit. if (!ownsSize || layoutPending) { const pty = ptySizesRef.current.get(pane); if (pty) view.term.resize(pty.cols, pty.rows); diff --git a/viewer-ui/src/hooks/terminal/useTerminalSocket.ts b/viewer-ui/src/hooks/terminal/useTerminalSocket.ts index 5b9b81c0..56b63cf6 100644 --- a/viewer-ui/src/hooks/terminal/useTerminalSocket.ts +++ b/viewer-ui/src/hooks/terminal/useTerminalSocket.ts @@ -38,10 +38,10 @@ interface UseTerminalSocketArgs { /// /// A layout effect, not a passive one: the panel is not remounted per /// repository (it keeps the per-repo focus memory across switches), so the -/// render that switches project still commits the previous project's panes and -/// their xterm DOM. A passive effect may run after that has been painted, which -/// puts one frame of the old project's terminals on screen; a layout effect -/// clears them before the browser paints. +/// render that switches project still commits the previous project's panes. +/// A passive effect may run after that has been painted, putting one frame of +/// the old project's terminals on screen; a layout effect clears them before +/// the browser paints. export function useTerminalSocket({ repo, socketRef, @@ -121,8 +121,8 @@ export function useTerminalSocket({ // Only a page someone just opened takes the sizing, and only then is it // worth assuming rather than awaiting — starting as a spectator would // leave that page's panes unfitted for a round trip. A switch or a - // reconnect keeps whatever this page already had; the server confirms it - // either way. + // reconnect keeps whatever this page already had; the server confirms + // it either way. const arriving = takeClaim(); if (arriving) setOwnsSize(true); // Reports are keyed by pane id, which is repository-local. diff --git a/viewer-ui/src/hooks/terminal/useTerminalViews.ts b/viewer-ui/src/hooks/terminal/useTerminalViews.ts index a9cc9ea5..204d17cd 100644 --- a/viewer-ui/src/hooks/terminal/useTerminalViews.ts +++ b/viewer-ui/src/hooks/terminal/useTerminalViews.ts @@ -13,12 +13,10 @@ import { sendTerminalMessage, type PaneSize } from "../../api/terminal"; interface UseTerminalViewsArgs { panes: number[]; - // Reveal signals: opening xterm inside a hidden (display:none) cell caches a - // 0x0 character-cell measurement that fit() can never recover, so creation - // waits until the cell has size. Re-run on the layout changes that can reveal - // a cell — the container resizing (`size`), a zoom toggle (`zoomed`), or the - // arrangement changing under them (`mode`) — which mirrors the fit effect's - // own dependency set. + // Opening xterm inside a hidden (display:none) cell caches a 0x0 + // character-cell measurement that fit() can never recover, so creation waits + // until the cell has size. Re-run on the layout changes that can reveal a + // cell (`size`, `zoomed`, `mode`), mirroring the fit effect's dependency set. size: { w: number; h: number }; zoomed: number | null; mode: PaneViewMode; @@ -65,12 +63,9 @@ export function useTerminalViews({ cursorBlink: true, // Without this there is no way to select text on a Mac in a pane whose // program reads the mouse — which is most of what this panel is opened - // for. xterm turns its own selection off while a program is tracking - // the mouse, and offers one way back: a modifier that forces the drag - // to be a selection. Off a Mac that modifier is Shift and needs no - // option; on one it is Option, and only if this is set. So it stays - // off, drags reach the program, nothing is ever selected, and Cmd+C - // copies nothing — a copy that fails with no way to tell why. + // for. xterm offers one way back: a modifier that forces the drag to be + // a selection (Shift, or Option on a Mac only if this is set). Left + // unset, Cmd+C copies nothing and nothing explains why. macOptionClickForcesSelection: true, }); const fit = new FitAddon(); @@ -115,14 +110,12 @@ export function useTerminalViews({ } }); // A program in the pane asks for the clipboard this way, and it is the - // only path that reaches whoever is reading — the host's own clipboard is - // a different machine's whenever this panel is open from somewhere else. - // xterm drops the sequence unless something claims it, while the program - // reports a copy either way. See `lib/osc52.ts`. Answered synchronously - // with the work left running: a handler may return a promise, and the - // parser then holds the whole stream until it settles — which would stop - // the pane painting behind a clipboard permission prompt. Returning true - // claims the sequence so it is not also treated as unrecognised output. + // only path that reaches whoever is reading — the host's own clipboard + // is a different machine's whenever this panel is open from somewhere + // else. See `lib/osc52.ts`. Returning true claims the sequence so it is + // not also treated as unrecognised output; the handler runs async work + // beside the parse rather than inside it, which would hold the stream + // — and the pane's painting — behind a clipboard permission prompt. term.parser.registerOscHandler(OSC_CLIPBOARD, (payload) => { void receivePaneClipboard(payload).catch((error: unknown) => { // Dropping the promise is the point; dropping a rejection with it @@ -143,27 +136,21 @@ export function useTerminalViews({ // grid, and xterm opens at its own 80×24 default. Parsed at the default, // everything the program put outside that box is gone — and the fit that // runs after this sends nothing when it lands on the size the PTY already - // has, which is the usual outcome of returning to the same screen. So no - // resize reaches the child, nothing makes it repaint, and what was lost - // stays lost until it next draws by itself: a full-screen program sitting - // at a prompt leaves the pane blank for as long as it waits. + // has (the usual outcome of returning to the same screen), so what was + // lost stays lost until the program next draws by itself. // // The pane's grid *now*, even for output that has been queueing since an - // older one — a cell can stay sizeless long enough to be resized under it. - // Splitting the queue at each resize and parsing each part at its own grid - // is not the improvement it sounds like: `write` parses on a later task - // while `resize` applies at once, so honouring a boundary means waiting on - // a write callback, and an emulator written to across several tasks would - // have to be exclusive to that loop — which it cannot be. The fit runs in - // the very next effect and the socket keeps delivering, and both of them - // reach this terminal. + // older one — a cell can stay sizeless long enough to be resized under + // it. Splitting the queue at each resize is not the improvement it + // sounds like: `write` parses on a later task while `resize` applies at + // once, so honouring a boundary would need an emulator exclusive to one + // write loop, which it cannot be. // - // That is also the limit of what this line promises. It is the grid the - // replay is *handed to*, not one it is guaranteed to be read at: the fit - // can resize again before the parser has caught up. In the case this is - // here for — a screen returning to panes it already sized — the fit lands - // on the size the PTY already has and changes nothing, which is exactly - // why nothing else was going to correct the default either. + // That is also the limit of what this line promises — the grid the + // replay is *handed to*, not one it is guaranteed to be read at. In the + // case this is here for (a screen returning to panes it already sized) + // the fit lands on the size the PTY already has and changes nothing, + // which is exactly why nothing else was going to correct the default. const pty = ptySizesRef.current.get(pane); if (pty) term.resize(pty.cols, pty.rows); viewsRef.current.set(pane, { term, fit }); @@ -173,13 +160,9 @@ export function useTerminalViews({ for (const chunk of queued) term.write(chunk); pendingRef.current.delete(pane); // A replayed pane is history, and what a person wants from history is - // its end — the last thing the program said, not wherever the write - // happened to leave the viewport. - // - // Queued behind the replay rather than run after the loop: `write` - // parses on a later task, so scrolling here directly would run while - // the parser was still catching up and land on a buffer that had not - // finished growing. + // its end. Queued behind the replay rather than run after the loop: + // `write` parses on a later task, so scrolling here directly would + // land on a buffer that had not finished growing. term.write("", () => term.scrollToBottom()); } } diff --git a/viewer-ui/src/hooks/useClone.ts b/viewer-ui/src/hooks/useClone.ts index 2d1585f3..594bf9f1 100644 --- a/viewer-ui/src/hooks/useClone.ts +++ b/viewer-ui/src/hooks/useClone.ts @@ -23,13 +23,11 @@ const ATTACH_RETRY_MS = 2000; * The request that starts a clone returns immediately with a job id, because * the transfer outlives any request a browser will hold open. Polling — rather * than a stream — keeps this on the same self-healing footing as the rest of - * the viewer: a phone that suspends mid-clone simply resumes polling, and the - * clone itself never depended on the connection staying up. + * the viewer: a phone that suspends mid-clone simply resumes polling. * * Call this *above* the folder picker. The picker only chooses where the clone * lands; the job outlives the dialog, so an observer that unmounts with it - * would abandon a clone that is still running — no toast, no repository - * opened, and no way to reattach on reopening. + * would abandon a clone that is still running. * * `enabled` gates the attach on being signed in: the probe is an API call, and * asking before the session exists only earns a 401. @@ -61,11 +59,10 @@ export function useClone(onOpened: (repo: Repo) => void, enabled: boolean) { // both have to be told apart from a dropped request — retrying // either would spin forever with the form stuck on "Cloning…". if (isUnauthorized(err)) { - // The session ended under us — expiry, or a server restart. This - // is terminal, not a hiccup: retrying would spin at a request a - // second behind the login screen with the header stuck on - // "Cloning…". Signing back in flips `enabled` and the attach - // probe finds the job again if it is still running. + // The session ended under us. Terminal, not a hiccup: retrying + // would spin at a request a second behind the login screen with + // the header stuck on "Cloning…". Signing back in flips `enabled` + // and the attach probe finds the job again if it is still running. busyRef.current = false; // Same cancellation contract as the paths below. if (!cancelled.current) setBusy(false); @@ -123,17 +120,16 @@ export function useClone(onOpened: (repo: Repo) => void, enabled: boolean) { ); // Adopt a clone this page never started. The job id lives only in the tab - // that started it, so a reload — or a phone that dropped the tab mid- - // transfer — would otherwise leave the clone running with nobody watching, - // and the only sign of it would be the 409 refusing the next one. + // that started it, so a reload — or a phone that dropped the tab mid-transfer + // — would otherwise leave the clone running with nobody watching. const attach = useCallback( async (isStale: () => boolean = () => false) => { - // Retried, because a probe that fails is not an answer. "The next page - // load will find it" does not hold: the server reports only a *running* - // job, so a clone that finishes before then becomes invisible and its - // repository is never opened. A dropped probe is also exactly what the - // one after a failed start is up against — the blip that lost the start - // response tends to take the probe with it. + // Retried, because a probe that fails is not an answer: the server + // reports only a *running* job, so a clone that finishes before the + // next page load becomes invisible and its repository is never opened. + // A dropped probe is also exactly what the one after a failed start is + // up against — the blip that lost the start response tends to take the + // probe with it. for (let attempt = 0; attempt < ATTACH_ATTEMPTS; attempt++) { if (attempt > 0) { await new Promise((r) => setTimeout(r, ATTACH_RETRY_MS)); @@ -185,14 +181,12 @@ export function useClone(onOpened: (repo: Repo) => void, enabled: boolean) { busyRef.current = false; if (cancelled.current) return; // Only a refusal is a failure. The server starts the clone before it - // replies, so anything that goes wrong from the answer onwards — the - // connection dropping, a truncated body, a protocol mismatch on an - // otherwise fine 200 — leaves a clone that may well be running. Saying - // it failed sends the user to retry into a "folder already exists" - // they cannot account for. The probe below catches it if it is still - // going; this message is for the clone short enough to have finished - // first, whose id is gone because the server reports only a running - // job. + // replies, so anything that goes wrong from the answer onwards leaves + // a clone that may well be running — saying it failed sends the user + // to retry into a "folder already exists" they cannot account for. + // The probe below catches it if it is still going; this message is + // for the clone short enough to have finished first, whose id is gone + // because the server reports only a running job. const refused = err instanceof ApiError && err.status >= 400; toast.error( refused @@ -202,11 +196,9 @@ export function useClone(onOpened: (repo: Repo) => void, enabled: boolean) { setBusy(false); // Probe again. Holding `busyRef` across this request made the attach // probe skip whatever was already running, and a refusal is often - // *because* something is — that is what the 409 says. A request that - // failed on the way back rather than on the way out leaves a job - // running too. Either way this page would otherwise never learn of - // it: the effect's dependencies have not changed, so nothing looks - // again. + // *because* something is. Either way this page would otherwise never + // learn of it: the effect's dependencies have not changed, so nothing + // looks again. void attach(); } }, diff --git a/viewer-ui/src/hooks/useLog.ts b/viewer-ui/src/hooks/useLog.ts index 219ebc03..f0112daf 100644 --- a/viewer-ui/src/hooks/useLog.ts +++ b/viewer-ui/src/hooks/useLog.ts @@ -64,8 +64,8 @@ export function useLog({ // The head the last refresh was asked for. The walk can return history // *newer* than the status report that asked for it (the stream lags the // repository), leaving a standing disagreement no further fetch resolves — - // this mark keeps that from becoming a fetch loop: a head already asked and - // answered is not asked again. A failed ask clears it, so the retry can. + // this mark keeps that from becoming a fetch loop. A failed ask clears it, + // so the retry can. const askedHeadRef = useRef(undefined); const resetLog = useCallback(() => { logRequestRef.current += 1; diff --git a/viewer-ui/src/hooks/usePaneOpeners.ts b/viewer-ui/src/hooks/usePaneOpeners.ts index e9aa0c8d..e4730596 100644 --- a/viewer-ui/src/hooks/usePaneOpeners.ts +++ b/viewer-ui/src/hooks/usePaneOpeners.ts @@ -98,12 +98,10 @@ export function usePaneOpeners({ return isUnauthorized(err) ? handle(err) : undefined; } if (!options?.restoring) return handle(err); - // Nobody asked for this one, so it says nothing out loud — except an + // Nobody asked for this one, so it says nothing out loud except an // expired session, which the page has to know about however it found - // out. Why it failed is beyond telling anyway: the server answers a - // deleted path and a repository it could not read alike. Nothing is - // recorded either way; what a restore could not put back stays - // remembered, and stays worth trying next time. + // out. A restore that failed is not recorded: what it could not put + // back stays remembered, and stays worth trying next time. if (isUnauthorized(err)) handle(err); setPane({ kind: "empty" }); }); @@ -129,12 +127,10 @@ export function usePaneOpeners({ return isUnauthorized(err) ? handle(err) : undefined; } if (!options?.restoring) return handle(err); - // Nobody asked for this one, so it says nothing out loud — except an + // Nobody asked for this one, so it says nothing out loud except an // expired session, which the page has to know about however it found - // out. Why it failed is beyond telling anyway: the server answers a - // deleted path and a repository it could not read alike. Nothing is - // recorded either way; what a restore could not put back stays - // remembered, and stays worth trying next time. + // out. A restore that failed is not recorded: what it could not put + // back stays remembered, and stays worth trying next time. if (isUnauthorized(err)) handle(err); setPane({ kind: "empty" }); }); @@ -182,12 +178,10 @@ export function usePaneOpeners({ return isUnauthorized(err) ? handle(err) : undefined; } if (!options?.restoring) return handle(err); - // Nobody asked for this one, so it says nothing out loud — except an + // Nobody asked for this one, so it says nothing out loud except an // expired session, which the page has to know about however it found - // out. Why it failed is beyond telling anyway: the server answers a - // deleted path and a repository it could not read alike. Nothing is - // recorded either way; what a restore could not put back stays - // remembered, and stays worth trying next time. + // out. A restore that failed is not recorded: what it could not put + // back stays remembered, and stays worth trying next time. if (isUnauthorized(err)) handle(err); setPane({ kind: "empty" }); }); @@ -243,10 +237,10 @@ export function usePaneOpeners({ : wantFile ? api.commitFile(repo, source.oid, source.path) : api.commitFileDiff(repo, source.oid, source.path); - // Raw, not rendered. "Show me around this change" is a question about the - // source; a rendered page has no line to land on and does not answer it. - // Opening a file from the tree still starts rendered — that is a different - // question. + // Raw, not rendered. "Show me around this change" is a question about + // the source; a rendered page has no line to land on and does not + // answer it. Opening a file from the tree still starts rendered — that + // is a different question. if (wantFile) setPreviewRendered(false); fetched .then((value) => { @@ -263,10 +257,11 @@ export function usePaneOpeners({ kind: "diff", value: value as Diff, // Judged again, not carried back. The file can have gone — - // or turned into something with no text in it — while its own - // pane was on screen, and the status refresh that would have - // noticed only looks at diffs. Same two questions `openDiff` - // asks, so the answer cannot drift between the two ways in. + // or turned into something with no text in it — while its + // own pane was on screen, and the status refresh that would + // have noticed only looks at diffs. Same two questions + // `openDiff` asks, so the answer cannot drift between the + // two ways in. source: showsText(value as Diff) && (source.kind !== "workdir" || worktreeHas(source.path)) diff --git a/viewer-ui/src/hooks/useRepoPoll.ts b/viewer-ui/src/hooks/useRepoPoll.ts index 70926d4f..7e978531 100644 --- a/viewer-ui/src/hooks/useRepoPoll.ts +++ b/viewer-ui/src/hooks/useRepoPoll.ts @@ -180,8 +180,8 @@ export function useRepoPoll({ // a project some client is actually in. // // Decided here rather than inside the state updater: an updater can - // run for a render that never commits, and a mark left behind by - // one would suppress a real write later. The decision needs no + // run for a render that never commits, and a mark left behind by one + // would suppress a real write later. The decision needs no // `current` — a changed served value that is open wins in // `resolveActiveRepo` regardless of it. if (servedChanged && active_repo && ids.includes(active_repo)) { diff --git a/viewer-ui/src/hooks/useRepoViewMemory.ts b/viewer-ui/src/hooks/useRepoViewMemory.ts index 063d2f00..51fc3996 100644 --- a/viewer-ui/src/hooks/useRepoViewMemory.ts +++ b/viewer-ui/src/hooks/useRepoViewMemory.ts @@ -34,13 +34,12 @@ interface UseRepoViewMemoryArgs { * to date. * * **What is recorded comes from what was asked for, not from what is on - * screen.** The screen is an async picture of the request that made it: between - * a tap and the answer it shows the last thing, or nothing, and neither is what - * the person chose. Reading it means every write first has to decide whether - * the moment it is reading is a real one — a question about requests in flight, - * project switches, failures and re-renders, with no end to it. An action says - * what it means when it happens: `note` takes the choice itself, and what - * becomes of the pane afterwards changes nothing. + * screen.** The screen is an async picture of the request that made it, and + * reading it means every write first has to decide whether the moment it is + * reading is a real one — a question about requests in flight, project + * switches, failures and re-renders, with no end to it. An action says what it + * means when it happens: `note` takes the choice itself, and what becomes of + * the pane afterwards changes nothing. * * A restore therefore records nothing — it is this hook putting back what is * already stored — and neither does a failed one, so no server fault can erase diff --git a/viewer-ui/src/lib/termKeys.ts b/viewer-ui/src/lib/termKeys.ts index ec290841..2ca0ad3a 100644 --- a/viewer-ui/src/lib/termKeys.ts +++ b/viewer-ui/src/lib/termKeys.ts @@ -111,31 +111,23 @@ export interface CtrlLatchStep { /** * One step of "the armed Ctrl modifies the next thing typed". * - * Escape-led input is not that thing and leaves the latch armed. It reaches the - * same handler but comes from the program rather than from a person: a pane - * running tmux or vim has focus reporting on, so merely putting the keyboard - * back in it emits `ESC [ I`, and a mouse-tracking program reports every tap - * the same way. Spending the latch on those would disarm it with nobody having - * typed anything — and arming it is what puts the keyboard in the pane, so the - * report would arrive first. A hardware keyboard's arrows and Escape are - * escape-led too, and they carry their own bytes already. + * Escape-led input leaves the latch armed. It reaches the same handler but + * comes from the program rather than from a person — a pane running tmux or + * vim has focus reporting on, so merely putting the keyboard back in it emits + * `ESC [ I`, and a mouse-tracking program reports every tap. The rule is the + * whole escape-led prefix rather than the two reports it is here for, because + * each automatic reply missed from a narrower list would disarm the latch with + * nothing to show for it. What that costs is the other direction: a bracketed + * paste (`ESC [ 2 0 0 ~`) and a hardware Escape or arrow leave it armed when a + * person might have expected them to spend it. That way round is the one to be + * wrong in — the button stays lit, where a latch that died quietly is only + * discovered by the character it failed to modify. * * Everything else spends it, whether or not Ctrl has a byte for it: the person * typed, and if what they typed has no control form the mistake was the latch. * An unbracketed paste is spent this way too — xterm hands it over as ordinary * data, so a one-character paste is indistinguishable from typing that - * character and is modified like one. - * - * The rule is the whole prefix rather than the two reports it is here for, - * because every automatic reply a terminal makes is escape-led — the cursor - * position an application asks for, a device attributes answer — and each one - * missed from a narrower list disarms the latch with nothing to show for it. - * What that costs is the other direction: a bracketed paste (`ESC [ 2 0 0 ~`) - * and a hardware Escape or arrow leave it armed when a person might have - * expected them to spend it. That way round is the one to be wrong in — the - * button stays lit, so the state is on screen and one tap away from cleared, - * where a latch that died quietly is only discovered by the character it - * failed to modify. + * character. */ export function ctrlLatchStep(armed: boolean, typed: string): CtrlLatchStep { if (!armed) return { data: typed, armed: false }; @@ -157,12 +149,12 @@ export function parseKeyBarPref(raw: string | null): KeyBarPref | null { /** * Whether to show the bar on a screen nobody has chosen for yet. * - * A coarse pointer is the question that actually matters — "is what types here a - * pane of glass" — and it is the one a tablet answers differently from the - * desktop it is as wide as. Width alone would have left an iPad, which is wider - * than the `md` the bar used to hide at, with no Escape and no Ctrl-C. Width - * still decides for anything a pointer cannot: a phone in desktop mode, a - * browser that reports nothing. Same question the terminal font asks + * A coarse pointer is the question that actually matters — "is what types here + * a pane of glass" — and it is the one a tablet answers differently from the + * desktop it is as wide as. Width alone would have left an iPad, which is + * wider than the `md` the bar used to hide at, with no Escape and no Ctrl-C. + * Width still decides for anything a pointer cannot: a phone in desktop mode, + * a browser that reports nothing. Same question the terminal font asks * (`termFont.ts`). */ export function defaultKeyBarShown( From 5b6945325dae6ee53e7f256f2747abfdbecca474 Mon Sep 17 00:00:00 2001 From: whackur Date: Fri, 28 Aug 2026 23:20:31 +0900 Subject: [PATCH 14/42] refactor(comments): keep only rationale comments in application, plugin, config, input, platform, cli --- src/application/attach.rs | 36 ++++++++++----------- src/application/bootstrap.rs | 4 +-- src/application/event_loop.rs | 25 +++++++-------- src/application/input/burst.rs | 5 ++- src/application/input/dispatch.rs | 53 +++++++++++++------------------ src/application/input/handlers.rs | 8 ++--- src/application/input/mouse.rs | 10 +++--- src/application/input/paste.rs | 23 +++++++------- src/application/session_link.rs | 28 ++++++---------- src/application/splash.rs | 14 +++----- src/application/terminal_guard.rs | 23 ++++++-------- src/config.rs | 31 ++++++++---------- src/config/layout.rs | 26 +++++++-------- src/config/plugin.rs | 26 +++++++-------- src/config/web.rs | 11 +++---- src/input/encode.rs | 39 ++++++++++------------- src/input/routing.rs | 23 +++++++------- src/platform/logging.rs | 30 ++++++++--------- src/platform/signals.rs | 13 ++++---- src/plugin/guard.rs | 13 ++++---- src/plugin/guard_budget.rs | 7 ++-- src/plugin/guard_watch.rs | 32 ++++++++----------- src/plugin/host.rs | 17 +++++----- src/plugin/host_pump.rs | 10 +++--- 24 files changed, 224 insertions(+), 283 deletions(-) diff --git a/src/application/attach.rs b/src/application/attach.rs index 48a2509a..f11bba9f 100644 --- a/src/application/attach.rs +++ b/src/application/attach.rs @@ -55,17 +55,15 @@ pub(crate) fn run_attach() -> Result<()> { ws.set_remembered(stored.sessions); } - // Read from the session's file, not asked of the daemon. The set that - // carries the accent is sent by the watcher now, which does not race the - // handshake to get there first — and this screen draws before `main_loop`, - // the only thing that drains the connection. `[theme]` names what a session - // with no stored colour starts in. + // Read from the session's file, not asked of the daemon: the watcher that + // carries the accent does not race the handshake, and this screen draws + // before `main_loop`, the only thing draining the connection. `[theme]` + // names what a session with no stored colour starts in. let session_accent = crate::session::prefs::PrefsStore::load_seeded(cfg.theme.preset_index()) .get() .accent; - // The splash is not the only screen that draws before the daemon's first - // set arrives. Without this the first frames of the main view would come up - // in the default rather than the session's colour. + // The splash and the first frames both draw before the daemon's first set + // arrives; without this they would come up in the default colour. ws.set_accent_index(session_accent); if matches!( @@ -75,9 +73,9 @@ pub(crate) fn run_attach() -> Result<()> { tracing::info!("nightcrow detached during splash"); return Ok(()); } - // The view state is written whichever way the loop ends. Losing which file - // was selected because the daemon stopped would be a second insult, and this - // half of the session file is the client's own. + // The view state is written whichever way the loop ends: losing which file + // was selected because the daemon stopped would be a second insult, and + // this half of the session file is the client's own. let ended = main_loop( &mut terminal, &mut ws, @@ -96,16 +94,14 @@ pub(crate) fn run_attach() -> Result<()> { /// Write this client's view state back, leaving the tab list alone. /// /// The file has two halves and two owners: the daemon writes which -/// repositories are open and which is active, and a client writes what it had -/// selected and where it had scrolled. Read-modify-write rather than a whole -/// rewrite, so detaching cannot roll the session's tab list back to whatever -/// this client happened to be showing. +/// repositories are open and which is active; a client writes what it had +/// selected and where it had scrolled. Read-modify-write, so detaching cannot +/// roll the session's tab list back to whatever this client was showing. /// -/// The two can still race — a client detaching in the same instant a -/// repository is opened elsewhere can lose that open until the next change -/// rewrites it. That is the same self-correcting transient the viewer's -/// preference writes already accept, and closing it would mean putting a lock -/// around a file two processes touch seconds apart. +/// The two can still race — a client detaching as a repository is opened +/// elsewhere can lose that open until the next change rewrites it. The same +/// transient the viewer's preference writes accept; closing it would mean +/// locking a file two processes touch seconds apart. fn persist_view_state(ws: &Workspace) { let mut stored = crate::workspace::persistence::load_workspace().unwrap_or_default(); stored.sessions = ws.view_state(); diff --git a/src/application/bootstrap.rs b/src/application/bootstrap.rs index b0b73bcd..cca840b7 100644 --- a/src/application/bootstrap.rs +++ b/src/application/bootstrap.rs @@ -21,8 +21,8 @@ pub(crate) fn init_app( // Applied up front rather than on the first snapshot: only the Status // selection needs the changed-file list, and it waits in // `pending_selection` (see `App::restore_session`). The terminal half - // waits too, for the panes to arrive from the session, which replaces - // the fresh-launch default rather than fighting it. + // waits for the panes to arrive from the session, which replaces the + // fresh-launch default rather than fighting it. app.restore_session(&state); } app diff --git a/src/application/event_loop.rs b/src/application/event_loop.rs index 9fb291f7..3a6c71df 100644 --- a/src/application/event_loop.rs +++ b/src/application/event_loop.rs @@ -39,26 +39,24 @@ pub(crate) fn main_loop( } // Every project drains its queues, not just the visible one: the // snapshot worker and PTY reader produce into unbounded channels - // regardless of which tab is on screen. - // - // Only the active project *applies* its snapshot, though. A background - // snapshot waits in `pending_snapshot` until its tab is shown. + // regardless of which tab is on screen. Only the active project + // *applies* its snapshot, though — a background one waits in + // `pending_snapshot` until its tab is shown. let active = ws.active_index(); for (i, project) in ws.projects_mut().iter_mut().enumerate() { if i == active { project.poll_snapshot(); - // Applying a commit-log page can trigger a further prefetch and - // load a commit diff synchronously, so it stays with the - // snapshot as active-only work. + // Stays with the snapshot as active-only work: applying a + // commit-log page can trigger a further prefetch and load a + // commit diff synchronously. project.poll_commit_log_page_fetch(); } else { project.drain_snapshot(); } - // Both are cheap drains that must run everywhere: the tree watcher - // to keep OS filesystem events from piling up, the terminal to - // consume PTY output before the pipe fills and blocks the child. - // Acting on a watcher event is active-only; a hidden project - // records the event and refreshes when its tab comes forward. + // Cheap drains that must run everywhere: the tree watcher so OS + // filesystem events do not pile up, the terminal so PTY output is + // consumed before the pipe fills and blocks the child. Acting on a + // watcher event is active-only; a hidden project records the event. if i == active { project.poll_tree_watcher(); } else { @@ -86,8 +84,7 @@ pub(crate) fn main_loop( // Collected before the mutable borrow of the active project, since the // tab row names every project while the body renders only one. Bounded - // by `MAX_PROJECTS`, so the per-frame clone is a handful of short - // strings. + // by `MAX_PROJECTS`, so the per-frame clone is a handful of strings. let tab_paths: Vec = ws.projects().iter().map(|p| p.repo_path.clone()).collect(); let tab_attention: Vec = ws .projects() diff --git a/src/application/input/burst.rs b/src/application/input/burst.rs index b13ec968..b9dd3aff 100644 --- a/src/application/input/burst.rs +++ b/src/application/input/burst.rs @@ -48,9 +48,8 @@ pub(crate) fn classify(events: Vec) -> Vec { /// The payload this burst would paste, or `None` if it reads as typing. /// /// Narrow on purpose: a false positive submits typed keys as a block. Enter -/// hands the line off, so nothing typed can follow it within one burst — but -/// the Enter that *ends* a typed line has nothing after it, and that burst is -/// typing. What marks a paste is content the Enter did not submit. +/// hands the line off, so nothing typed can follow it within one burst — what +/// marks a paste is content the Enter did not submit. fn paste_text(events: &[Event]) -> Option { let mut text = String::new(); let mut enters = 0usize; diff --git a/src/application/input/dispatch.rs b/src/application/input/dispatch.rs index 0305fe8c..8ecd4383 100644 --- a/src/application/input/dispatch.rs +++ b/src/application/input/dispatch.rs @@ -44,20 +44,18 @@ pub(crate) struct ProjectContext<'a> { pub(crate) fn handle_key(app: &mut App, key: KeyEvent) -> KeyOutcome { // Crossterm emits Press/Repeat/Release for every keystroke on Windows - // and on terminals that negotiate the kitty keyboard protocol. - // Without this guard every keypress would be processed twice or more - // — visible as doubled search chars, the leader firing repeatedly, and - // Backspace popping past the buffer. + // and on kitty-protocol terminals; without this guard every keypress + // is processed two or more times — doubled search chars, the leader + // firing repeatedly, Backspace popping past the buffer. if key.kind != KeyEventKind::Press { return KeyOutcome::Continue; } // A key nightcrow acts on itself means the user has moved on, so the // notice row goes back to showing repo identity. Keys forwarded verbatim - // to a PTY are excluded: in a terminal pane every keystroke is - // passthrough, and dismissing on those would blank a notice the moment - // the user resumed typing. Runs before dispatch so an action that raises - // a *new* notice still leaves it standing. + // to a PTY are excluded — there, every keystroke is passthrough, and + // dismissing on those would blank a notice the moment typing resumed. + // Runs before dispatch so a new notice survives the same tick. if app.search_overlay_active() || app.interaction.prefix_armed || app.interaction.awaiting_swap_target @@ -68,30 +66,27 @@ pub(crate) fn handle_key(app: &mut App, key: KeyEvent) -> KeyOutcome { } // Modal overlays (repo-input dialog, both search bars) own every - // keystroke until dismissed. They are checked before any leader handling - // so a leader keypress while a search/repo dialog is open is typed/edited - // by the overlay rather than arming the prefix. + // keystroke until dismissed, and are checked before any leader handling + // so a leader press while one is open edits within the overlay rather + // than arming the prefix. if app.search_overlay_active() { // A prefix (or swap-target) could only be armed if an overlay opened - // out from under it; disarm both so neither indicator lingers behind a - // modal. + // out from under it; disarm both so neither indicator lingers. app.interaction.prefix_armed = false; app.interaction.awaiting_swap_target = false; - // Search overlays are handled inside the focus-local upper handler. handle_upper_key(app, key, Action::None); return KeyOutcome::Continue; } - // Swap-target mode is armed (` s`): this key is the digit naming - // the pane to swap the active pane with. Checked before the prefix so its - // dedicated follow-up handler owns the key. + // Swap-target mode is armed (` s`): this key names the pane to + // swap with. Checked before the prefix so its dedicated handler owns it. if app.interaction.awaiting_swap_target { return handle_swap_target_followup(app, key); } - // Prefix is armed: this key is the single follow-up. Resolve it three - // ways — Esc/Ctrl+C cancels, the leader again sends a literal leader to - // the PTY, a mapped key runs its action; any other key is consumed. + // Prefix is armed: this key is the single follow-up — Esc/Ctrl+C cancels, + // the leader again sends a literal leader to the PTY, a mapped key runs + // its action; anything else is consumed. if app.interaction.prefix_armed { return handle_prefix_followup(app, key); } @@ -133,8 +128,7 @@ pub(super) fn handle_global_action(app: &mut App, action: Action) -> Option { // Scoped by `can_close_pane` (terminal focus — the close target - // is invisible without it). The key is still consumed so it - // can't leak elsewhere. + // is invisible without it); the key is consumed either way. if app.can_close_pane() { app.close_active_pane(); } @@ -162,14 +156,11 @@ pub(super) fn handle_global_action(app: &mut App, action: Action) -> Option Some(KeyOutcome::Project(ProjectRequest::CycleAccent)), - // The config belongs to the session, so this asks too. What comes back is - // a notice rather than anything on screen: a reload replaces plugin - // children and the list future projects open with, neither of which this - // client is looking at. + // The config belongs to the session, so this asks too; what comes back + // is a notice rather than anything this client is looking at. Action::ReloadConfig => Some(KeyOutcome::Project(ProjectRequest::ReloadConfig)), Action::Redraw => Some(KeyOutcome::Redraw), Action::SwitchPane(n) => { @@ -177,8 +168,8 @@ pub(super) fn handle_global_action(app: &mut App, action: Action) -> Option { - // Scoped by `can_swap_panes` (terminal focus plus a second pane). - // The key is still consumed either way. + // Scoped by `can_swap_panes` (terminal focus plus a second pane); + // the key is consumed either way. if app.can_swap_panes() { app.interaction.begin_swap_target(); } diff --git a/src/application/input/handlers.rs b/src/application/input/handlers.rs index 906a743d..641c27d9 100644 --- a/src/application/input/handlers.rs +++ b/src/application/input/handlers.rs @@ -13,11 +13,9 @@ use crossterm::event::{KeyCode, KeyEvent}; pub(crate) fn handle_empty_key(ws: &mut Workspace, key: KeyEvent) -> KeyOutcome { if ws.prefix_armed() { ws.cancel_prefix(); - // ` ` sends a literal leader to the focused PTY on the project - // screen; here there is no pane to send it to, so it is consumed. - // Resolving it before the action table matters: with the default - // `ctrl+f` leader the follow-up would otherwise match `f` and toggle - // fullscreen. + // ` ` is consumed here: there is no pane to send a literal + // leader to, and with the default `ctrl+f` leader the follow-up would + // otherwise match `f` and toggle fullscreen. if ws.is_leader_key(key) { return KeyOutcome::Continue; } diff --git a/src/application/input/mouse.rs b/src/application/input/mouse.rs index 97158b32..2b4afb9e 100644 --- a/src/application/input/mouse.rs +++ b/src/application/input/mouse.rs @@ -61,8 +61,8 @@ pub(crate) fn dispatch_mouse( /// Route a captured mouse event to the pane under the pointer. Releases pair /// with the press's pane (not the pointer pane); wheel scrolls the pane under /// the pointer; a left press outside pane content can focus an upper panel, -/// jump via a tab/`+N` marker, or run a hint-bar shortcut. In swap mode a -/// left click names the swap target. Drag/motion reports are not forwarded. +/// jump via a tab/`+N` marker, or run a hint-bar shortcut. In swap mode a left +/// click names the swap target. Drag/motion reports are not forwarded. pub(crate) fn handle_mouse( app: &mut App, tabs: crate::ui::Chrome<'_>, @@ -185,9 +185,9 @@ fn dispatch_hint_click(app: &mut App, click: crate::ui::HintClick) -> KeyOutcome /// Deliver a button release to the pane that received the matching press. /// The release carries the *stored* press button, not crossterm's: legacy /// encodings don't identify the button on release (some report every `Up` as -/// `Left`), so trusting that would strand a right/middle press without its -/// release. The release cell is clamped into the pressed pane's current rect; -/// if that pane was closed or hidden since, the release is dropped. +/// `Left`), so trusting that would strand a right/middle press. The release +/// cell is clamped into the pressed pane's current rect; if that pane was +/// closed or hidden since, the release is dropped. fn release_pending_press( app: &mut App, screen: Rect, diff --git a/src/application/input/paste.rs b/src/application/input/paste.rs index 212ecbbb..a94149f4 100644 --- a/src/application/input/paste.rs +++ b/src/application/input/paste.rs @@ -21,17 +21,16 @@ pub(crate) fn dispatch_paste(ws: &mut Workspace, text: &str) { /// Route a bracketed-paste payload within one project. /// -/// Its search overlays accept the text after stripping control characters — -/// the same rule the typed-key handlers enforce. The terminal pane receives -/// the paste re-wrapped in `ESC [200~ ... ESC [201~` so the inner shell can +/// Search overlays accept the text after stripping control characters, the +/// same rule the typed-key handlers enforce. The terminal pane receives the +/// paste re-wrapped in `ESC [200~ ... ESC [201~` so the inner shell can /// distinguish multi-line paste from interactive input. `text` never carries /// the outer markers: crossterm strips them on Unix, and on Windows there are /// none — `input::burst` synthesises the event from keys. pub(crate) fn handle_paste(app: &mut App, text: &str) { - // A paste arriving while the prefix is armed would otherwise leave the - // PREFIX indicator stuck and make the next key resolve as a follow-up. - // Resolve the prefix first (tmux treats a non-command event as a cancel), - // then route the paste normally. + // A paste while the prefix is armed would leave the PREFIX indicator + // stuck and make the next key resolve as a follow-up; resolve the prefix + // first (tmux treats a non-command event as a cancel). app.interaction.prefix_armed = false; if app.focus == Focus::FileList && app.status_view.search_active { for ch in text.chars().filter(|c| !c.is_control()) { @@ -60,11 +59,11 @@ pub(crate) fn handle_paste(app: &mut App, text: &str) { return; } if app.focus == Focus::Terminal { - // Strip ESC (0x1b) and NUL (0x00) before forwarding: an embedded - // 0x1b can re-arm or cancel the bracketed-paste boundary the shell - // is parsing, and NUL is malformed for most line-buffered shells. - // Newlines, tabs, and other printable controls stay in — they are - // exactly what bracketed paste is meant to deliver atomically. + // Strip ESC and NUL before forwarding: an embedded 0x1b can re-arm or + // cancel the bracketed-paste boundary the shell is parsing, and NUL + // is malformed for most line-buffered shells. Newlines, tabs, and + // other printable controls stay — they are what bracketed paste + // delivers atomically. let sanitized: Vec = text .as_bytes() .iter() diff --git a/src/application/session_link.rs b/src/application/session_link.rs index 224adc13..fc24d7f9 100644 --- a/src/application/session_link.rs +++ b/src/application/session_link.rs @@ -1,10 +1,9 @@ //! The client's half of the shared tab list. //! -//! The daemon owns which repositories are open and in what order. This client -//! asks for a change and adopts whatever comes back — including changes another -//! client made. Which tab is in front is the daemon's too, so switching is a -//! request and every client follows the answer. What stays local is everything -//! *inside* a project — the view mode, the cursor, the scroll. +//! The daemon owns the tab list — which repositories are open, their order, +//! which is in front. This client asks and adopts whatever comes back, +//! including changes another client made; what stays local is everything +//! *inside* a project (view mode, cursor, scroll). use crate::application::bootstrap::init_app; use crate::application::input::dispatch::{ProjectContext, ProjectRequest}; @@ -22,7 +21,6 @@ impl SessionLink { Self { client } } - /// Take in everything the daemon has said since the last tick. pub(crate) fn sync(&mut self, ws: &mut Workspace, ctx: &ProjectContext) { for message in self.client.drain() { match message { @@ -66,7 +64,6 @@ impl SessionLink { } } - /// Carry out a tab request locally, or send it to the daemon. pub(crate) fn request(&mut self, ws: &mut Workspace, request: ProjectRequest) { let sent = match request { // Which project is in front is the session's, so this asks. Nothing @@ -114,7 +111,6 @@ impl SessionLink { } } - /// Whether the daemon is still there. pub(crate) fn is_connected(&self) -> bool { self.client.is_connected() } @@ -138,13 +134,10 @@ fn focus_repo(ws: &mut Workspace, repo: &str) -> bool { } } -/// Raise a terminal refusal on the tab it came from. -/// -/// By repository, not on the active tab: the client subscribes to every open -/// repository's terminals, so a refusal can be about one the user is not looking -/// at, and putting it on whatever tab is in front would name the wrong project. -/// A repository with no tab yet falls back to the active one rather than losing -/// the message. +/// Raise a terminal refusal on the tab it came from, not the active one: the +/// client subscribes to every open repository, so the refusal may be about a +/// tab the user is not looking at. A repository with no tab yet falls back to +/// the active one rather than losing the message. fn notify_repo(ws: &mut Workspace, repo: &str, message: String) { match ws .projects_mut() @@ -160,10 +153,9 @@ fn notify_repo(ws: &mut Workspace, repo: &str, message: String) { /// /// Membership first, then order, then the ids — a tab that stays open keeps its /// terminals, scroll, and selection, so reconciling in place matters more than -/// it would if this rebuilt from scratch. +/// rebuilding from scratch would. fn adopt(ws: &mut Workspace, ctx: &ProjectContext, repos: &[RepoSummary], client: &DaemonClient) { - // Closing first frees room under `MAX_PROJECTS` for what is being opened, - // so a set that swaps one repository for another fits in a single pass. + // Closing first frees room under `MAX_PROJECTS` for what is being opened. let wanted: Vec<&str> = repos.iter().map(|repo| repo.path.as_str()).collect(); let doomed: Vec = ws .projects() diff --git a/src/application/splash.rs b/src/application/splash.rs index 7cdcfbed..425da1bd 100644 --- a/src/application/splash.rs +++ b/src/application/splash.rs @@ -8,10 +8,9 @@ pub(crate) enum SplashOutcome { /// Run the splash until it times out or a key dismisses it. /// -/// `accent_idx` is the session's, read from its file rather than taken from the -/// daemon: the splash draws before this client has attached, so the broadcast -/// that carries the colour has not arrived yet. Reading it here is what keeps -/// the splash and the view a moment later from being two different colours. +/// `accent_idx` is the session's, read from its file rather than taken from +/// the daemon: the splash draws before this client has attached, so the +/// broadcast that carries the colour has not arrived yet. pub(crate) fn splash_loop( terminal: &mut TuiTerminal, accent_idx: usize, @@ -27,11 +26,8 @@ pub(crate) fn splash_loop( } if event::poll(std::time::Duration::from_millis(16))? { match event::read()? { - // Honour Esc so the user can abort during the splash instead - // of being forced to wait for it to clear and quit from the - // main view. (Leader-based quit needs a two-key sequence, so - // it isn't recognised on the one-shot splash screen.) Any - // other key dismisses the splash. + // Esc aborts during the splash (the leader needs two keys, so + // it is not recognised here); any other key dismisses it. Event::Key(k) if k.kind == KeyEventKind::Press => { if k.code == KeyCode::Esc { return Ok(SplashOutcome::Quit); diff --git a/src/application/terminal_guard.rs b/src/application/terminal_guard.rs index 32fa7af3..f850e6e8 100644 --- a/src/application/terminal_guard.rs +++ b/src/application/terminal_guard.rs @@ -44,16 +44,14 @@ pub(crate) struct TerminalGuard; impl TerminalGuard { pub(crate) fn enter(mouse: bool) -> Result { enable_raw_mode()?; - // EnableBracketedPaste makes crossterm surface paste as - // `Event::Paste(String)` instead of a flood of `Event::Key` chars — - // the latter would each be filtered as control chars by the search - // handler and silently drop newlines. Unix only — the Windows console - // has no paste record, so `input::burst` reassembles the flood. - // Ratatui positions every changed cell itself. Host-side autowrap is - // therefore both unnecessary and dangerous: writing the bottom-right - // cell can scroll the physical screen while Ratatui's back buffer still - // describes the pre-scroll frame, leaving duplicated rows and stale - // fragments on subsequent partial draws. + // DisableLineWrap: Ratatui positions every changed cell itself, so + // host-side autowrap is unnecessary and dangerous — writing the + // bottom-right cell can scroll the physical screen while Ratatui's + // back buffer still describes the pre-scroll frame, leaving duplicated + // rows on later partial draws. (EnableBracketedPaste, below, makes + // paste arrive as one `Event::Paste` instead of a key flood that + // search handlers would filter into silent data loss; Windows has no + // paste record, so `input::burst` reassembles it there.) if let Err(err) = execute!(io::stdout(), EnterAlternateScreen, DisableLineWrap) { restore_terminal(); return Err(err.into()); @@ -71,9 +69,8 @@ impl TerminalGuard { // prefer plain-drag selection can hand the mouse back entirely. if mouse && let Err(err) = execute!(io::stdout(), EnableMouseCapture) { // The enable may have partially reached the terminal even though - // the call errored (e.g. the write landed but a later flush - // failed), and no TerminalGuard exists yet to undo it on drop — - // send the disable explicitly; it is harmless when capture never + // the call errored, and no TerminalGuard exists yet to undo it on + // drop — send the disable explicitly; harmless if capture never // took effect. restore_terminal(); return Err(err.into()); diff --git a/src/config.rs b/src/config.rs index 43eb9c69..df65e58b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,8 +25,7 @@ pub use web::{WebViewerConfig, ensure_web_viewer_password}; /// range, so every startup pane is reachable by a direct key. pub const MAX_STARTUP_COMMANDS: usize = 8; -/// Upper bound on `[[plugin]]` entries. Tracks `MAX_STARTUP_COMMANDS` rather -/// than being independently generous. +/// Upper bound on `[[plugin]]` entries. Tracks `MAX_STARTUP_COMMANDS`. pub const MAX_PLUGINS: usize = 8; #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -144,24 +143,21 @@ pub fn validate_config(cfg: &Config) -> Result<()> { "log.commit_log_prefetch_threshold must be between 1 and log.commit_log_page_size" ); // `max_size_mb == 0` would make SizeRollingAppender rotate on every - // write (and even degenerate to creating a new file per write call), - // so disallow it. The upper bound is a sanity ceiling that still - // allows hours of trace logging at high volume. + // write (even degenerate to a new file per write call), so disallow it; + // the upper bound is a sanity ceiling. anyhow::ensure!( (1..=10_000).contains(&cfg.log.max_size_mb), "log.max_size_mb must be between 1 and 10000" ); // `max_days == 0` is the documented "keep forever" sentinel and is - // intentionally accepted; only the upper bound is sanity-checked so a - // typo in years-vs-days doesn't silently produce log retention that - // exceeds the host's life. + // intentionally accepted; only the upper bound is sanity-checked. anyhow::ensure!( cfg.log.max_days <= 3650, "log.max_days must be at most 3650 (10 years); 0 = keep forever" ); - // `0` is the "never expires" sentinel, as it is for `log.max_days`. The - // ceiling is 10 years: anything past it is a unit mix-up, and the value is - // multiplied into seconds, which is where an unbounded one would overflow. + // `0` is the "never expires" sentinel, as for `log.max_days`; the ceiling + // catches a unit mix-up, and the value is multiplied into seconds, where + // an unbounded one would overflow. anyhow::ensure!( cfg.web_viewer.session_ttl_hours <= 87_600, "web_viewer.session_ttl_hours must be at most 87600 (10 years); 0 = never expires" @@ -202,10 +198,10 @@ pub fn validate_config(cfg: &Config) -> Result<()> { } /// Merge config `[[startup_command]]` entries with CLI `--exec` commands into -/// the final ordered list of panes to open at launch. Config entries come -/// first, then CLI commands (labelled by their command text). The combined -/// count is held to `MAX_STARTUP_COMMANDS`, and empty `--exec` values are -/// rejected — config entries were already validated by `validate_config`. +/// the final ordered list of panes to open at launch: config entries first, +/// then CLI commands, the combined count held to `MAX_STARTUP_COMMANDS`. +/// Empty `--exec` values are rejected here; config entries were already +/// validated by `validate_config`. pub fn resolve_startup_commands(cfg: &Config, cli_exec: &[String]) -> Result> { merge_startup_commands(&cfg.startup_commands, cli_exec) } @@ -213,9 +209,8 @@ pub fn resolve_startup_commands(cfg: &Config, cli_exec: &[String]) -> Result`. +/// run inside tmux), the Ctrl chords an inner Claude Code pane reserves, +/// terminal flow control (`Ctrl+Q`/`Ctrl+S`), and shell signals +/// (`Ctrl+C/D/Z`). Its only collision is `Ctrl+F` as forward-char / +/// page-forward, which users almost always reach via arrow keys / PageDown; +/// when needed it stays reachable via ``. pub(super) const DEFAULT_LEADER: &str = "ctrl+f"; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -109,10 +109,11 @@ impl Default for InputConfig { } /// Parse a leader chord string (e.g. `"ctrl+b"`) into a `KeyEvent`. -/// Only `ctrl+` chords are accepted. The chord must be a key -/// that `encode_key` can turn into literal bytes (so `` can pass the -/// leader through to the PTY) and must NOT collide with a no-prefix reserved -/// key. F-keys, Shift+arrows, and Shift+PgUp/PgDn are reserved and rejected. +/// +/// Only `ctrl+` chords are accepted. The chord must be a key +/// `encode_key` can turn into literal bytes (so `` can pass the leader +/// through to the PTY) and must not collide with a reserved key; F-keys, +/// Shift+arrows, and Shift+PgUp/PgDn are reserved and rejected. pub fn parse_leader(spec: &str) -> Result { let normalized = spec.trim().to_ascii_lowercase(); let rest = normalized.strip_prefix("ctrl+").ok_or_else(|| { @@ -144,10 +145,9 @@ pub fn parse_leader(spec: &str) -> Result { and Ctrl+M as Enter, so this leader would never be recognized" ); // Restricting to letters guarantees `` literal pass-through works: - // `encode_key` maps Ctrl+A..Ctrl+Z to control bytes 1..26. Digits and - // punctuation (e.g. ctrl+1) have no single-control-byte encoding, so - // encode_key would send the literal char instead and the pass-through - // would break — hence they are rejected above. + // `encode_key` maps Ctrl+A..Ctrl+Z to control bytes 1..26, while digits + // and punctuation have no single-control-byte encoding, so the + // pass-through would break — hence they are rejected above. Ok(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)) } diff --git a/src/config/plugin.rs b/src/config/plugin.rs index f603a760..84871634 100644 --- a/src/config/plugin.rs +++ b/src/config/plugin.rs @@ -23,26 +23,26 @@ pub struct PluginConfig { /// Flags this plugin may append when relaunching a pane's command. /// /// Empty by default, which refuses every relaunch that passes a flag. The - /// core has no idea what any CLI's flags mean, so the decision of which - /// ones a plugin may add is the user's: a flag that is not listed here - /// cannot be smuggled into the pane's command line, which is what keeps a - /// plugin from quietly weakening a CLI's permission posture. + /// core has no idea what any CLI's flags mean, so the user decides: a flag + /// not listed here cannot be smuggled into the pane's command line, which + /// is what keeps a plugin from quietly weakening a CLI's permission + /// posture. #[serde(default)] pub allowed_resume_flags: Vec, /// Off unless explicitly turned on. #[serde(default)] pub enabled: bool, /// Whether this plugin may be given a pane no `[[startup_command]]` named - /// it in, when a process *inside* that pane reports to it quoting the pane's - /// own token. + /// it in, when a process *inside* that pane reports to it quoting the + /// pane's own token. /// - /// Off by default, so an existing config keeps the property that the opt-in - /// list is the whole of what a plugin can see. Turning it on trades that for - /// a narrower one: a pane is reachable once something running in it has - /// spoken to the plugin, which a plain shell never does. The token is what - /// makes the difference — it is random, per pane, and only in that pane's - /// child environment, so it cannot be guessed from outside and the plugin is - /// never told which panes exist. + /// Off by default, so an existing config keeps the property that the + /// opt-in list is the whole of what a plugin can see. Turning it on trades + /// that for a narrower one: a pane becomes reachable once something + /// running in it has spoken to the plugin, which a plain shell never does. + /// The token makes the difference — random, per pane, only in that pane's + /// child environment, so it cannot be guessed from outside and the plugin + /// is never told which panes exist. #[serde(default)] pub watch_on_signal: bool, } diff --git a/src/config/web.rs b/src/config/web.rs index 58e43fb7..7176d36a 100644 --- a/src/config/web.rs +++ b/src/config/web.rs @@ -80,13 +80,10 @@ pub fn generate_password() -> Result { .collect()) } -/// Ensure the viewer has a login credential, generating and persisting -/// one when the config has none. A no-op when a `password` or `hashed_password` -/// is already set. Otherwise a random password is generated, written back into -/// the config file at `path` (creating it if absent, preserving any existing -/// content and comments), and stored on `cfg` so the running instance uses it. -/// Returns the freshly generated password so the caller can surface it to the -/// user, or `None` when a credential already existed. +/// Ensure the viewer has a login credential, generating and persisting one +/// into the config file at `path` (format-preserving, comments kept) when it +/// has none. Returns the freshly generated password for the caller to surface, +/// or `None` when a credential already existed. pub fn ensure_web_viewer_password( cfg: &mut super::Config, path: &std::path::Path, diff --git a/src/input/encode.rs b/src/input/encode.rs index d14336f9..6ee4bcd1 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -11,10 +11,9 @@ pub fn encode_key(key: KeyEvent, app_cursor: bool) -> Option> { match key.code { KeyCode::Char(c) => { if ctrl && c.is_ascii() { - // Several Ctrl chords fall outside the contiguous - // `c.to_ascii_uppercase() - '@' < 32` range and need explicit - // xterm-convention mappings: Ctrl+Space → NUL (formula wraps - // because ' ' < '@'), Ctrl+/ → 0x1F (US), Ctrl+? → 0x7F (DEL). + // Ctrl chords outside the `letter - '@'` formula need explicit + // xterm-convention mappings: Ctrl+Space → NUL (the formula + // wraps because ' ' < '@'), Ctrl+/ → 0x1F (US), Ctrl+? → 0x7F. let b = match c { ' ' => Some(0x00), '/' => Some(0x1F), @@ -39,16 +38,13 @@ pub fn encode_key(key: KeyEvent, app_cursor: bool) -> Option> { let mut enc = [0u8; 4]; Some(c.encode_utf8(&mut enc).as_bytes().to_vec()) } - // Alt+Enter carries the Meta prefix like Alt+Char does. Terminal UIs - // read ESC+CR as "insert a newline, don't submit" — it is what Claude - // Code binds its newline to — so dropping the modifier here made the - // two indistinguishable and every Alt+Enter submitted instead. - // - // Ctrl+Enter is LF for the same reason. A terminal that cannot tell the - // chord from a bare Enter sends Ctrl+J (LF) for it and nightcrow never - // sees the modifier; one that can — the Windows console API, the kitty - // keyboard protocol — delivers `Enter + CONTROL`, and encoding that as - // CR submitted the line on exactly the platforms that report it. + // Alt+Enter carries the Meta prefix like Alt+Char does: terminal UIs + // read ESC+CR as "insert a newline, don't submit" (Claude Code binds + // its newline to it), so dropping the modifier made every Alt+Enter + // submit instead. Ctrl+Enter is LF for the same reason — a terminal + // that can report the modifier delivers `Enter + CONTROL`, and + // encoding that as CR would submit the line on exactly those + // platforms. KeyCode::Enter => { let byte = if ctrl { b'\n' } else { b'\r' }; Some(if alt { vec![0x1b, byte] } else { vec![byte] }) @@ -75,10 +71,9 @@ pub fn encode_key(key: KeyEvent, app_cursor: bool) -> Option> { /// Bit 6 (64) marks the button as a wheel rather than a click. const SGR_WHEEL_UP: u8 = 64; -/// Encode a mouse wheel notch as an SGR (1006) mouse report. `col`/`row` are -/// 1-based cell coordinates. A wheel notch has no release event, so a single -/// `M` (press) report is the whole sequence — unlike a click, which xterm -/// follows with an `m`. +/// Encode a mouse wheel notch as an SGR (1006) mouse report; `col`/`row` are +/// 1-based cells. A wheel notch has no release event, so a single `M` (press) +/// report is the whole sequence — unlike a click, which xterm follows with `m`. pub fn encode_wheel(up: bool, col: u16, row: u16) -> Vec { let button = if up { SGR_WHEEL_UP } else { SGR_WHEEL_UP + 1 }; format!("\x1b[<{button};{};{}M", col.max(1), row.max(1)).into_bytes() @@ -96,10 +91,10 @@ pub fn encode_wheel_horizontal(left: bool, col: u16, row: u16) -> Vec { format!("\x1b[<{button};{};{}M", col.max(1), row.max(1)).into_bytes() } -/// Encode a mouse button press or release as an SGR (1006) mouse report. -/// `col`/`row` are 1-based pane-local cell coordinates. SGR keeps the real -/// button code on release and marks it with a final `m` instead of `M` — -/// unlike legacy X10, which collapses every release to button 3. +/// Encode a mouse button press or release as an SGR (1006) mouse report with +/// 1-based pane-local cell coordinates. SGR keeps the real button code on +/// release and marks it with a final `m` instead of `M` — unlike legacy X10, +/// which collapses every release to button 3. pub fn encode_button(button: MouseButton, press: bool, col: u16, row: u16) -> Vec { let code: u8 = match button { MouseButton::Left => 0, diff --git a/src/input/routing.rs b/src/input/routing.rs index 6b209266..64e5d2c3 100644 --- a/src/input/routing.rs +++ b/src/input/routing.rs @@ -37,12 +37,12 @@ pub fn map_key(event: KeyEvent) -> Action { } } -/// Classify the single follow-up key pressed after the leader. The follow-up -/// is matched on the bare character regardless of modifiers so ` t` works -/// whether or not the user is still holding a modifier from the leader chord. -/// The digit row addresses whatever the body is showing: `1` = file list, -/// `2` = diff viewer, `3`..`9`,`0` = terminal panes `0`..`7`. The bare F-keys -/// are a separate axis (project tabs), so the two never collide. +/// Classify the leader follow-up key. Matched on the bare character +/// regardless of modifiers so ` t` works whether or not the user is still +/// holding a modifier from the leader chord. The digit row addresses whatever +/// the body is showing (`1` = file list, `2` = diff viewer, `3`..`9`,`0` = +/// panes `0`..`7`); the bare F-keys are a separate axis (project tabs), so +/// the two never collide. pub fn prefix_action(event: KeyEvent) -> Action { match event.code { KeyCode::Char(c) => match c.to_ascii_lowercase() { @@ -74,12 +74,11 @@ pub fn prefix_action(event: KeyEvent) -> Action { } /// Leader follow-up mapping while the terminal fills the body -/// (`TerminalFullscreen::fills_body`). The upper viewer is hidden, so the -/// digit row is repurposed: `1`..`8` address the (up to -/// `MAX_VISIBLE_FULLSCREEN` = 8) terminal panes `0`..`7` by natural -/// numbering instead of the list/diff focus jumps. `9`/`0` have no pane in -/// the 8-pane cap and are dropped rather than falling through to the -/// split-view bindings. Every non-digit chord behaves as in `prefix_action`. +/// (`TerminalFullscreen::fills_body`): the upper viewer is hidden, so the +/// digit row is repurposed onto panes `0`..`7` by natural numbering instead +/// of the list/diff focus jumps. `9`/`0` address no pane within the 8-pane +/// cap and are dropped rather than falling through to the split-view +/// bindings. Every non-digit chord behaves as in `prefix_action`. pub fn prefix_action_fullscreen(event: KeyEvent) -> Action { if let KeyCode::Char(c @ '0'..='9') = event.code { return match c { diff --git a/src/platform/logging.rs b/src/platform/logging.rs index 0588af2c..806050b2 100644 --- a/src/platform/logging.rs +++ b/src/platform/logging.rs @@ -92,16 +92,13 @@ pub fn init_logging(config: &LogConfig, repo_path: &str) -> Option { } /// Drop a self-ignoring `.gitignore` in the log directory so logs never -/// pollute the user's `git status` — the default `.nightcrow/logs` sits inside -/// the repo. +/// pollute the user's `git status` — the default `.nightcrow/logs` sits +/// inside the repo. /// -/// Only into a directory nightcrow owns, meaning one under `.nightcrow`. The -/// pattern is `*`, which has to ignore the ignore file itself to hide the -/// directory — harmless in our own folder, but writing that into a directory -/// the user pointed `[log] dir` at would make Git ignore everything untracked -/// there. A custom location is the user's to manage. -/// -/// Only written when missing: a user-edited file should not be clobbered. +/// Only into a directory nightcrow owns (one under `.nightcrow`): the `*` +/// pattern ignores the directory's every untracked file, which would be +/// wrong for a user-chosen `[log] dir` — that one is the user's to manage. +/// Only written when missing, so a user-edited file is not clobbered. fn write_log_gitignore(log_dir: &Path) { if !log_dir.components().any(|c| c.as_os_str() == NIGHTCROW_DIR) { return; @@ -135,10 +132,10 @@ fn cleanup_old_logs(log_dir: &Path, max_days: u32) { return; }; - // First pass: collect candidate files with mtimes so we can identify the - // newest one and preserve it. SizeRollingAppender resumes its highest - // existing index on startup, so the latest log file may itself be older - // than the cutoff — deleting it would lose the active session's tail. + // First pass: collect candidate files with mtimes so the newest one can + // be preserved — SizeRollingAppender resumes its highest existing index + // on startup, so the latest log file may itself be older than the + // cutoff, and deleting it would lose the active session's tail. let mut candidates: Vec<(PathBuf, SystemTime)> = Vec::new(); for entry in entries.flatten() { let path = entry.path(); @@ -160,12 +157,11 @@ fn cleanup_old_logs(log_dir: &Path, max_days: u32) { } /// Returns paths to delete from a list of candidate `(path, mtime)` entries. -/// Always preserves the newest entry, even if it is older than the cutoff — +/// Always preserves the newest entry, even if older than the cutoff — /// SizeRollingAppender resumes the highest-index file, so deleting it would /// drop the active session's tail. When two candidates share the maximum -/// mtime (1 s mtime granularity on FAT/exFAT, simultaneous touches, etc.), -/// only the first occurrence is preserved; the others remain eligible for -/// cleanup so a tie doesn't silently inflate disk usage. +/// mtime (1 s granularity on FAT/exFAT, simultaneous touches), only the first +/// is preserved; the others stay eligible so a tie doesn't inflate disk usage. fn expired_log_paths(candidates: &[(PathBuf, SystemTime)], cutoff: SystemTime) -> Vec<&PathBuf> { let newest_idx = candidates .iter() diff --git a/src/platform/signals.rs b/src/platform/signals.rs index 951104cb..114e6f81 100644 --- a/src/platform/signals.rs +++ b/src/platform/signals.rs @@ -94,13 +94,14 @@ mod imp { } } - /// Windows 는 시그널 대신 콘솔 제어 이벤트를 쓴다. 콜백을 채널로 옮겨 - /// register/wait 분리를 Unix 와 동일하게 유지한다 — 등록 시점부터 도착한 - /// 이벤트가 wait 까지 보관되어야 하고, 그게 이 계약의 요점이다. + /// Windows signals via console control events rather than POSIX signals; + /// the callback's event is moved onto a channel so register/wait split + /// stays the same contract as Unix — an event that arrives between + /// register and wait must be held for `wait`. /// - /// SIGTERM 대응물이 없다. Ctrl-C 와 Ctrl-Break 는 콘솔이 붙어 있을 때만 - /// 오고, `-d` 로 분리된 daemon 에는 콘솔이 없다 (detach.rs 참조). - /// 그쪽 종료 경로는 `nightcrow stop` 이다. + /// There is no SIGTERM counterpart: Ctrl-C and Ctrl-Break only exist with + /// a console attached, and a daemon detached with `-d` has none (see + /// detach.rs). Its stop path is `nightcrow stop`. pub(super) struct Watch(Receiver); impl Watch { diff --git a/src/plugin/guard.rs b/src/plugin/guard.rs index 8af45347..acf23bd6 100644 --- a/src/plugin/guard.rs +++ b/src/plugin/guard.rs @@ -114,7 +114,7 @@ impl Guard { /// Decide one command. Never panics. /// /// `facts` is what the caller knows about the pane `cmd`'s token resolves - /// to, or `None` if it resolves to nothing. `allowed_resume_flags` is the + /// to, or `None` if it resolves to nothing; `allowed_resume_flags` is the /// plugin's configured list. pub fn judge( &mut self, @@ -239,12 +239,11 @@ impl Guard { return Err(Refused::PaneStillRunning { pane }); } if facts.launch_command.is_none() { - // A bare shell. Putting a process back here would start the shell - // again, not whatever the person ran inside it, and the resume - // arguments would have nothing to attach to — so the pane's only - // recovery is the one typed into it while it is still alive. Checked - // before `resume_command_line`, which also refuses this, so the log - // says the pane was never relaunchable rather than blaming the args. + // A bare shell: the resume arguments would have nothing to attach + // to, so the pane's only recovery is one typed into it while + // alive. Checked before `resume_command_line` (which also refuses + // this) so the log says the pane was never relaunchable rather + // than blaming the args. return Err(Refused::NoLaunchCommand { pane }); } let command_line = resume_command_line( diff --git a/src/plugin/guard_budget.rs b/src/plugin/guard_budget.rs index 4d1cdafd..68fdb69b 100644 --- a/src/plugin/guard_budget.rs +++ b/src/plugin/guard_budget.rs @@ -101,10 +101,9 @@ impl Budgets { /// /// Only approvals spend, deliberately: the budget bounds what a plugin /// *does* to a pane, and a refused command did nothing. Charging refusals - /// would let noise — a stale generation the plugin could not have known - /// about, a flag config forbids — eat the budget a legitimate action needs, - /// losing the pane's one real attempt to a race. Spam is bounded elsewhere - /// and more cheaply: the outbound queue drops and every refusal is logged. + /// would let noise eat the allowance a legitimate action needs. Spam is + /// bounded elsewhere and more cheaply: the outbound queue drops and every + /// refusal is logged. pub(super) fn spend( &mut self, token: &PaneToken, diff --git a/src/plugin/guard_watch.rs b/src/plugin/guard_watch.rs index 83f3666c..6052eebd 100644 --- a/src/plugin/guard_watch.rs +++ b/src/plugin/guard_watch.rs @@ -1,30 +1,26 @@ //! Rule 12: when a plugin may be given a pane nobody handed it. //! -//! Every other rule in this layer starts from a pane the operator already -//! assigned. This one is the single place an assignment can be *created* at -//! runtime, so it is kept apart from the rest and reads as one list of -//! conditions rather than as a branch inside a larger judgement. +//! Every other rule starts from a pane the operator already assigned; this is +//! the single place an assignment can be *created* at runtime, so it is kept +//! apart and reads as one list of conditions. //! -//! What makes it safe is where the token came from. A pane token is random, is -//! minted per slot, and is put only into that pane's child environment, so a -//! process able to quote one is a process running inside that pane. The pane's -//! own occupant asking for a watcher is a different thing from a plugin -//! enumerating the session, and only the first is allowed here — nothing in this -//! file looks at a list of panes. -//! -//! Still not authority by itself: the operator's config switch has to be on, and -//! a pane already spoken for is not taken away from the plugin that has it. - +//! What makes it safe is where the token came from: a pane token is random, +//! minted per slot, and put only into that pane's child environment, so a +//! process able to quote one is running inside that pane. The pane's own +//! occupant asking for a watcher is allowed here; a plugin enumerating the +//! session is not — nothing in this file looks at a list of panes. Still not +//! authority by itself: the operator's config switch must be on, and a pane +//! already spoken for is not taken away. use super::guard::{Approved, PaneFacts}; use super::guard_refusal::Refused; use crate::backend::PaneToken; /// Decide one [`PluginCommand::WatchPane`](super::protocol::PluginCommand). /// -/// Takes no clock and charges no budget. Being given a pane is not something -/// done *to* the pane — it changes who is told about it, and every act that -/// follows is charged when it is asked for. Charging here would spend the very -/// allowance the recovery this unlocks is about to need. +/// Takes no clock and charges no budget: being given a pane changes who is +/// told about it, not the pane itself — every act that follows is charged +/// when it is asked for. Charging here would spend the very allowance the +/// recovery this unlocks is about to need. pub(super) fn judge_watch( token: &PaneToken, facts: Option<&PaneFacts>, diff --git a/src/plugin/host.rs b/src/plugin/host.rs index 0c0ad843..b1e6279e 100644 --- a/src/plugin/host.rs +++ b/src/plugin/host.rs @@ -87,10 +87,9 @@ impl PluginHost { /// Launch `cfg.command` and start pumping. /// /// Resolution order for the program: a `cfg.command` containing a path - /// separator is taken as a path and used as given; otherwise `plugin_dir` is - /// searched first, so an installed plugin wins over a same-named binary on - /// the user's `PATH`, and only if it is not there is the bare name handed to - /// the OS to resolve against `PATH`. + /// separator is used as given; otherwise `plugin_dir` is searched first + /// (an installed plugin wins over a same-named `PATH` binary), and only + /// then is the bare name handed to the OS. /// /// No pane token is passed in the environment. A plugin learns which panes /// exist only from the events it is sent, which is what keeps a plugin from @@ -117,9 +116,9 @@ impl PluginHost { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - // After `cfg.env`, so this is not something a config can point at - // another hub's socket: which hub a plugin belongs to is the host's to - // say. See `PLUGIN_RUNTIME_DIR_ENV`. + // After `cfg.env`: which hub a plugin belongs to is the host's to say, + // not something a config can point at another hub's socket. See + // `PLUGIN_RUNTIME_DIR_ENV`. if let Some(dir) = runtime_dir { command.env(crate::backend::identity::PLUGIN_RUNTIME_DIR_ENV, dir); } @@ -285,8 +284,8 @@ fn resolve_program(command: &str, plugin_dir: Option<&Path>) -> PathBuf { if candidate.is_file() { return candidate; } - // On Windows, an installed plugin is stored as `name.exe` but - // configured as `name`. Try the extension before falling back to PATH. + // On Windows an installed plugin is stored as `name.exe` but + // configured as `name`; try the extension before PATH. #[cfg(windows)] { let exe = dir.join(format!("{command}.exe")); diff --git a/src/plugin/host_pump.rs b/src/plugin/host_pump.rs index e26033e3..e7574b6b 100644 --- a/src/plugin/host_pump.rs +++ b/src/plugin/host_pump.rs @@ -129,11 +129,11 @@ fn pull_line(reader: &mut impl BufRead) -> std::io::Result { /// Consume bytes up to and including the next newline, keeping at most /// [`MAX_LINE_BYTES`] of them in `buf` (the newline is never kept). /// -/// Returns how many bytes the line actually spanned and whether a newline ended -/// it. The cap is applied while reading rather than left to -/// [`decode_command`]'s own length check: by the time that runs the host has -/// already allocated whatever the plugin chose to send, which is the thing worth -/// preventing. +/// Returns how many bytes the line actually spanned and whether a newline +/// ended it. The cap is applied while reading rather than left to +/// [`decode_command`]'s length check: by the time that runs the host has +/// already allocated whatever the plugin chose to send, which is the thing +/// worth preventing. fn read_capped(reader: &mut impl BufRead, buf: &mut Vec) -> std::io::Result<(usize, bool)> { let mut seen = 0; loop { From 50e3f3c3b2a651555d4d4c1118d306aa90d7d96a Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 00:48:32 +0900 Subject: [PATCH 15/42] refactor(session): separate catalog membership and runtime --- docs/architecture.md | 3 +- docs/architecture/session.md | 14 + src/session/catalog/catalog_ids.rs | 5 + src/session/catalog/catalog_runtime.rs | 111 ++++++++ .../catalog/catalog_tests/config_tables.rs | 53 ++++ src/session/catalog/config_tables.rs | 55 ++-- src/session/catalog/membership.rs | 138 ++++++++++ src/session/catalog/membership_tests.rs | 92 +++++++ src/session/catalog/mod.rs | 259 +++++++----------- src/session/catalog/ordering.rs | 49 +--- src/session/catalog/views.rs | 40 ++- 11 files changed, 565 insertions(+), 254 deletions(-) create mode 100644 src/session/catalog/catalog_runtime.rs create mode 100644 src/session/catalog/membership.rs create mode 100644 src/session/catalog/membership_tests.rs diff --git a/docs/architecture.md b/docs/architecture.md index b0765b1f..7d3a474b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -162,7 +162,8 @@ src/ │ # encode_wheel/button/arrow, CSI/SS3 helpers) ├── session/ # daemon-owned, transport-neutral shared session core │ ├── state.rs, operations.rs, reload.rs # ownership, mutations, live config reload -│ ├── catalog/ # opaque repo ids, atomic swap, ordering, config tables +│ ├── catalog/ # pure membership + live runtime reconciliation, +│ │ # opaque repo ids, ordering, config tables │ ├── runtime/ # SnapshotChannel drain + conflated status fan-out │ ├── terminal/ # TerminalHub, PtyBackend ownership, shared terminal frames │ ├── size_owner.rs # which client screen the session PTYs are fitted to diff --git a/docs/architecture/session.md b/docs/architecture/session.md index 76d9e6a3..706ed636 100644 --- a/docs/architecture/session.md +++ b/docs/architecture/session.md @@ -78,6 +78,20 @@ trait TerminalBackend { (`session/prefs`), 어느 표면에서 바꾸든 세션 전체가 따라온다 — 대신 프로젝트를 바꿔도 색은 그대로다. `[theme] name`은 아직 한 번도 색을 고르지 않은 세션의 시작색으로 남는다. +### Catalog는 membership과 runtime을 분리한다 (`session/catalog/`) + +저장소 집합을 결정하는 순수 상태(`CatalogMembership`)와 실제 status worker·terminal hub를 +소유하는 상태(`CatalogRuntime`)는 별개다. membership은 base·browser-added·hidden·order의 합집합과 +재사용하지 않는 id만 계산하고, runtime은 그 결과를 reconcile해 같은 path의 `Arc`를 +그대로 보존한다. 따라서 무관한 탭 변경은 기존 runtime과 SSE subscriber를 교체하지 않는다. + +둘 사이 변경은 `Catalog` façade의 transaction 하나로 직렬화한다. config table 교체도 같은 +transaction을 써서 동시에 열린 저장소는 교체 시점의 fan-out에 포함되거나 새 table로 spawn되는 +둘 중 하나이며, 어느 쪽에도 속하지 않는 틈이 없다. reconcile은 먼저 새 runtime snapshot을 +설치하고 retired entry를 값으로 돌려준다. worker `stop`과 join은 membership·runtime·transaction +lock을 모두 놓은 뒤 실행한다 — 닫히는 저장소 하나의 종료 지연이 조회나 다음 catalog mutation을 +막지 않게 하기 위해서다. + ### 데몬이 세션을 감시한다 (`daemon/watch.rs`) 세션에는 문이 둘이다 — 브라우저의 HTTP 핸들러와 attach 소켓 — 그래서 브라우저에서 연 저장소는 diff --git a/src/session/catalog/catalog_ids.rs b/src/session/catalog/catalog_ids.rs index 4c17e2b8..d2f53b03 100644 --- a/src/session/catalog/catalog_ids.rs +++ b/src/session/catalog/catalog_ids.rs @@ -45,6 +45,11 @@ pub(super) struct IdAssigner { by_path: HashMap, } +pub(super) struct Member { + pub(super) id: String, + pub(super) path: String, +} + impl IdAssigner { pub(super) fn id_for(&mut self, path: &str) -> String { if let Some(existing) = self.by_path.get(path) { diff --git a/src/session/catalog/catalog_runtime.rs b/src/session/catalog/catalog_runtime.rs new file mode 100644 index 00000000..76e099a2 --- /dev/null +++ b/src/session/catalog/catalog_runtime.rs @@ -0,0 +1,111 @@ +//! Live workers corresponding to the catalog's pure membership. + +use super::catalog_ids::{Member, RepoEntry}; +use super::{display_path, empty_status_payload, repo_name}; +use crate::session::StatusEncoder; +use crate::session::runtime::RepoRuntime; +use crate::session::terminal::TerminalHub; +use std::sync::Arc; + +pub(super) struct CatalogRuntime { + entries: Vec>, + startup_commands: Vec, + cli_startup: Vec, + plugins: Vec, + shell: crate::config::ShellConfig, + ownership: Arc, + status_encoder: StatusEncoder, +} + +impl Default for CatalogRuntime { + fn default() -> Self { + Self { + entries: Vec::new(), + startup_commands: Vec::new(), + cli_startup: Vec::new(), + plugins: Vec::new(), + shell: crate::config::ShellConfig::default(), + ownership: Arc::new(crate::session::size_owner::SizeOwnership::default()), + status_encoder: empty_status_payload, + } + } +} + +impl CatalogRuntime { + pub(super) fn configured( + startup_commands: Vec, + plugins: Vec, + cli_startup: Vec, + shell: crate::config::ShellConfig, + status_encoder: StatusEncoder, + ) -> Self { + Self { + startup_commands, + plugins, + cli_startup, + shell, + status_encoder, + ..Self::default() + } + } + + pub(super) fn reconcile(&mut self, members: Vec) -> Vec> { + let previous = std::mem::take(&mut self.entries); + let mut next = Vec::with_capacity(members.len()); + for member in members { + if let Some(existing) = previous.iter().find(|entry| entry.path == member.path) { + next.push(Arc::clone(existing)); + continue; + } + next.push(Arc::new(RepoEntry { + name: repo_name(&member.path), + display_path: display_path(&member.path), + runtime: RepoRuntime::spawn(&member.path, self.status_encoder), + terminals: TerminalHub::spawn( + &member.path, + self.startup_commands.clone(), + self.plugins.clone(), + self.shell.clone(), + Arc::clone(&self.ownership), + ), + id: member.id, + path: member.path, + })); + } + let retired = previous + .into_iter() + .filter(|old| !next.iter().any(|new| Arc::ptr_eq(new, old))) + .collect(); + self.entries = next; + retired + } + + pub(super) fn replace_config( + &mut self, + file_startup: &[crate::config::StartupCommand], + plugins: Vec, + ) -> anyhow::Result>> { + let merged = crate::config::merge_startup_commands(file_startup, &self.cli_startup)?; + self.startup_commands = merged; + self.plugins = plugins; + Ok(self.entries.clone()) + } + + pub(super) fn entries(&self) -> &[Arc] { + &self.entries + } + + pub(super) fn take_entries(&mut self) -> Vec> { + std::mem::take(&mut self.entries) + } + + #[cfg(test)] + pub(super) fn startup_commands(&self) -> Vec { + self.startup_commands.clone() + } + + #[cfg(test)] + pub(super) fn plugins(&self) -> Vec { + self.plugins.clone() + } +} diff --git a/src/session/catalog/catalog_tests/config_tables.rs b/src/session/catalog/catalog_tests/config_tables.rs index 50f5f0ec..ed13e7c1 100644 --- a/src/session/catalog/catalog_tests/config_tables.rs +++ b/src/session/catalog/catalog_tests/config_tables.rs @@ -106,3 +106,56 @@ fn a_repo_opened_after_a_swap_gets_the_new_startup_list() { catalog.shutdown(); drop((dir_a, dir_b)); } + +#[test] +fn concurrent_open_and_config_swap_cannot_miss_each_other() { + let (dir_a, a) = make_repo(); + let (dir_b, b) = make_repo(); + let catalog = Arc::new(Catalog::with_startup_and_plugins( + vec![startup("old")], + Vec::new(), + )); + catalog.set_paths(std::slice::from_ref(&a)); + let barrier = Arc::new(std::sync::Barrier::new(3)); + + let opening = { + let catalog = Arc::clone(&catalog); + let barrier = Arc::clone(&barrier); + let a = a.clone(); + let b = b.clone(); + std::thread::spawn(move || { + barrier.wait(); + catalog.set_paths(&[a, b]); + }) + }; + let swapping = { + let catalog = Arc::clone(&catalog); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + catalog + .set_config_tables(&[startup("new")], Vec::new()) + .expect("the merge fits the cap") + }) + }; + barrier.wait(); + opening.join().unwrap(); + let told = swapping.join().unwrap(); + + let b = crate::git::resolve_repo_path(std::path::Path::new(&b)) + .to_string_lossy() + .into_owned(); + let opened = catalog + .entries() + .into_iter() + .find(|entry| entry.path == b) + .expect("the concurrent open committed"); + let was_in_swap = told.iter().any(|entry| Arc::ptr_eq(entry, &opened)); + let spawned_from_new_table = opened.terminals.startup_commands() == [startup("new")]; + assert!( + was_in_swap || spawned_from_new_table, + "an opened repository must be told by the swap or spawn from its tables" + ); + catalog.shutdown(); + drop((dir_a, dir_b)); +} diff --git a/src/session/catalog/config_tables.rs b/src/session/catalog/config_tables.rs index e04d86e5..3d545973 100644 --- a/src/session/catalog/config_tables.rs +++ b/src/session/catalog/config_tables.rs @@ -6,7 +6,7 @@ //! the hubs spawned afterwards. use super::Catalog; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; impl Catalog { /// Like [`Catalog::new`], with startup terminals and their plugin table. @@ -30,9 +30,13 @@ impl Catalog { cli_startup: Vec, ) -> Self { Self { - startup_commands: Mutex::new(startup_commands), - plugins: Mutex::new(plugins), - cli_startup, + runtime: std::sync::Mutex::new(super::CatalogRuntime::configured( + startup_commands, + plugins, + cli_startup, + crate::config::ShellConfig::default(), + super::empty_status_payload, + )), ..Self::default() } } @@ -46,11 +50,13 @@ impl Catalog { status_encoder: crate::session::StatusEncoder, ) -> Self { Self { - startup_commands: Mutex::new(startup_commands), - plugins: Mutex::new(plugins), - cli_startup, - shell, - status_encoder: Some(status_encoder), + runtime: std::sync::Mutex::new(super::CatalogRuntime::configured( + startup_commands, + plugins, + cli_startup, + shell, + status_encoder, + )), ..Self::default() } } @@ -65,7 +71,8 @@ impl Catalog { /// already running is the caller's job (see [`crate::session::reload`]). /// The entries to tell are returned rather than fetched afterwards. /// - /// Taken under the mutation lock, the same one every rebuild holds. Without + /// Taken under the facade transaction, the same one every membership commit + /// holds. Without /// it a repository opened in the same beat could fall between the two halves: /// its hub reads the old tables while the swap is still to come, and the /// swap's snapshot is taken while its entry is still to be installed. @@ -74,35 +81,33 @@ impl Catalog { file_startup: &[crate::config::StartupCommand], plugins: Vec, ) -> anyhow::Result>> { - // Merged before any lock is taken, so a refusal leaves both tables - // exactly as they were. - let merged = crate::config::merge_startup_commands(file_startup, &self.cli_startup)?; - let _mutation = self.mutation.lock().expect("catalog mutation poisoned"); - *self - .startup_commands + let _transaction = self + .transaction .lock() - .expect("catalog startup poisoned") = merged; - *self.plugins.lock().expect("catalog plugins poisoned") = plugins; - Ok(self.entries()) + .expect("catalog transaction poisoned"); + self.runtime + .lock() + .expect("catalog runtime poisoned") + .replace_config(file_startup, plugins) } /// The `[[plugin]]` table as it stands, for the caller that has to tell the /// running hubs about it. #[cfg(test)] pub fn plugins(&self) -> Vec { - self.plugins + self.runtime .lock() - .expect("catalog plugins poisoned") - .clone() + .expect("catalog runtime poisoned") + .plugins() } /// The merged startup list as it stands — configured panes then `--exec` /// ones. What the next hub will be given. #[cfg(test)] pub fn startup_commands(&self) -> Vec { - self.startup_commands + self.runtime .lock() - .expect("catalog startup poisoned") - .clone() + .expect("catalog runtime poisoned") + .startup_commands() } } diff --git a/src/session/catalog/membership.rs b/src/session/catalog/membership.rs new file mode 100644 index 00000000..68b679c3 --- /dev/null +++ b/src/session/catalog/membership.rs @@ -0,0 +1,138 @@ +//! Pure bookkeeping for which repository paths belong to the session. + +use super::catalog_ids::{IdAssigner, Member}; + +#[derive(Default)] +pub(super) struct CatalogMembership { + ids: IdAssigner, + base: Vec, + added: Vec, + hidden: Vec, + order: Vec, +} + +pub(super) enum AddMembership { + Present(String), + TooMany, +} + +impl CatalogMembership { + pub(super) fn set_paths(&mut self, paths: Vec) { + self.base = paths; + } + + pub(super) fn add_path(&mut self, path: String, max: usize) -> AddMembership { + if let Some(member) = self + .members() + .into_iter() + .find(|member| member.path == path) + { + return AddMembership::Present(member.id); + } + + let was_hidden = self.hidden.iter().any(|hidden| hidden == &path); + let candidate_len = self.union_paths_with_visible(&path, was_hidden).len(); + if candidate_len > max { + return AddMembership::TooMany; + } + + if was_hidden { + self.hidden.retain(|hidden| hidden != &path); + // A close forgets the old slot. A later base refresh may have put + // the hidden path back, but an explicit reopen still belongs last. + self.base.retain(|base| base != &path); + } + if !self.added.iter().any(|added| added == &path) { + self.added.push(path.clone()); + } + AddMembership::Present(self.ids.id_for(&path)) + } + + pub(super) fn remove_path(&mut self, path: &str) { + for list in [&mut self.added, &mut self.base, &mut self.order] { + list.retain(|entry| entry != path); + } + if !self.hidden.iter().any(|hidden| hidden == path) { + self.hidden.push(path.to_string()); + } + } + + pub(super) fn reorder(&mut self, desired: &[String]) { + let served = self.union_paths(); + let mut next = Vec::with_capacity(served.len()); + for path in desired { + if served.contains(path) && !next.contains(path) { + next.push(path.clone()); + } + } + for path in served { + if !next.contains(&path) { + next.push(path); + } + } + self.order = next; + } + + pub(super) fn members(&mut self) -> Vec { + self.union_paths() + .into_iter() + .map(|path| Member { + id: self.ids.id_for(&path), + path, + }) + .collect() + } + + fn union_paths_with_visible(&self, path: &str, was_hidden: bool) -> Vec { + let mut base = self.base.clone(); + let mut added = self.added.clone(); + let hidden: Vec<_> = self + .hidden + .iter() + .filter(|hidden| !was_hidden || hidden.as_str() != path) + .cloned() + .collect(); + if was_hidden { + base.retain(|base| base != path); + } + if !added.iter().any(|added| added == path) { + added.push(path.to_string()); + } + union_paths(&base, &added, &hidden, &self.order) + } + + fn union_paths(&self) -> Vec { + union_paths(&self.base, &self.added, &self.hidden, &self.order) + } +} + +fn union_paths( + base: &[String], + added: &[String], + hidden: &[String], + order: &[String], +) -> Vec { + let mut natural = Vec::with_capacity(base.len() + added.len()); + for path in base.iter().chain(added) { + if hidden.contains(path) || natural.contains(path) { + continue; + } + natural.push(path.clone()); + } + let mut result = Vec::with_capacity(natural.len()); + for path in order { + if natural.contains(path) && !result.contains(path) { + result.push(path.clone()); + } + } + for path in natural { + if !result.contains(&path) { + result.push(path); + } + } + result +} + +#[cfg(test)] +#[path = "membership_tests.rs"] +mod tests; diff --git a/src/session/catalog/membership_tests.rs b/src/session/catalog/membership_tests.rs new file mode 100644 index 00000000..fb160010 --- /dev/null +++ b/src/session/catalog/membership_tests.rs @@ -0,0 +1,92 @@ +use super::*; + +fn paths(membership: &mut CatalogMembership) -> Vec { + membership + .members() + .into_iter() + .map(|member| member.path) + .collect() +} + +fn id_of(membership: &mut CatalogMembership, path: &str) -> String { + membership + .members() + .into_iter() + .find(|member| member.path == path) + .expect("path is served") + .id +} + +#[test] +fn base_and_browser_paths_form_a_stable_deduplicated_union() { + let mut membership = CatalogMembership::default(); + membership.set_paths(vec!["a".into(), "a".into(), "b".into()]); + assert!(matches!( + membership.add_path("c".into(), 3), + AddMembership::Present(_) + )); + + membership.set_paths(vec!["b".into(), "a".into()]); + + assert_eq!(paths(&mut membership), ["b", "a", "c"]); +} + +#[test] +fn ids_survive_removal_reopen_and_reorder() { + let mut membership = CatalogMembership::default(); + membership.set_paths(vec!["a".into(), "b".into()]); + let a_id = id_of(&mut membership, "a"); + let b_id = id_of(&mut membership, "b"); + + membership.reorder(&["b".into(), "a".into()]); + membership.remove_path("a"); + assert!(matches!( + membership.add_path("a".into(), 2), + AddMembership::Present(_) + )); + + assert_eq!(paths(&mut membership), ["b", "a"]); + assert_eq!(id_of(&mut membership, "a"), a_id); + assert_eq!(id_of(&mut membership, "b"), b_id); +} + +#[test] +fn hidden_paths_stay_closed_across_base_refresh_and_reopen_at_the_end() { + let mut membership = CatalogMembership::default(); + membership.set_paths(vec!["a".into(), "b".into(), "c".into()]); + membership.remove_path("b"); + + membership.set_paths(vec!["a".into(), "b".into(), "c".into()]); + assert_eq!(paths(&mut membership), ["a", "c"]); + assert!(matches!( + membership.add_path("b".into(), 3), + AddMembership::Present(_) + )); + assert_eq!(paths(&mut membership), ["a", "c", "b"]); +} + +#[test] +fn refused_reopen_does_not_clear_hidden_membership() { + let mut membership = CatalogMembership::default(); + membership.set_paths(vec!["a".into(), "b".into()]); + membership.remove_path("a"); + membership.set_paths(vec!["a".into(), "b".into()]); + + assert!(matches!( + membership.add_path("a".into(), 1), + AddMembership::TooMany + )); + membership.set_paths(vec!["a".into(), "b".into()]); + + assert_eq!(paths(&mut membership), ["b"]); +} + +#[test] +fn reorder_ignores_unknown_and_duplicate_paths() { + let mut membership = CatalogMembership::default(); + membership.set_paths(vec!["a".into(), "b".into(), "c".into()]); + + membership.reorder(&["c".into(), "unknown".into(), "c".into()]); + + assert_eq!(paths(&mut membership), ["c", "a", "b"]); +} diff --git a/src/session/catalog/mod.rs b/src/session/catalog/mod.rs index 16027863..a035c617 100644 --- a/src/session/catalog/mod.rs +++ b/src/session/catalog/mod.rs @@ -17,52 +17,36 @@ //! open. Holding the invariant at the boundary keeps every entry point from //! having to remember it. -use crate::session::StatusEncoder; -use crate::session::runtime::RepoRuntime; -use crate::session::terminal::TerminalHub; use std::path::Path; use std::sync::{Arc, Mutex}; mod catalog_ids; +mod catalog_runtime; mod config_tables; +mod membership; mod ordering; -use catalog_ids::IdAssigner; pub use catalog_ids::{AddOutcome, RepoEntry, RepoInfo}; +use catalog_runtime::CatalogRuntime; +use membership::{AddMembership, CatalogMembership}; -#[derive(Default)] pub struct Catalog { - mutation: Mutex<()>, - entries: Mutex>>, - ids: Mutex, - /// Repositories supplied by the CLI (`serve --repo`) or pushed from the TUI - /// workspace. Replaced wholesale by [`Catalog::set_paths`]. - base: Mutex>, - /// Repositories opened from the browser. Kept across `base` updates. - added: Mutex>, - /// Repositories closed from the browser. Subtracted from the served set so - /// a `base` re-sync does not resurrect a closed repo. - hidden: Mutex>, - order: Mutex>, - /// Commands each repository's terminal hub runs as startup terminals on the - /// first client connect. Behind a lock because a config reload replaces it; - /// only hubs spawned *after* the reload see the new list. - startup_commands: Mutex>, - /// The `--exec` panes the daemon was started with, appended after the - /// configured ones. Not behind a lock: these came from the command line. - cli_startup: Vec, - /// The `[[plugin]]` table, handed to every hub the catalog spawns. Replaced - /// by a reload; the hubs already running are told as well, because a plugin - /// is a child process and restarting one costs the session nothing. - plugins: Mutex>, - /// The shell every terminal pane is spawned with. Fixed for the session's - /// life: a config reload does not replace the shell of a running hub. - shell: crate::config::ShellConfig, - /// Which screen this session's panes are fitted to, shared by every hub. - /// One value for the session rather than one per repository — see - /// [`crate::session::size_owner`]. - ownership: Arc, - /// Surface-owned status representation cached by each repository runtime. - status_encoder: Option, + /// Serializes membership-to-runtime commits and config swaps. The two + /// subobjects have independent locks so read-only runtime snapshots do not + /// need the membership bookkeeping, but a mutation always crosses them as + /// one facade transaction. + transaction: Mutex<()>, + membership: Mutex, + runtime: Mutex, +} + +impl Default for Catalog { + fn default() -> Self { + Self { + transaction: Mutex::new(()), + membership: Mutex::new(CatalogMembership::default()), + runtime: Mutex::new(CatalogRuntime::default()), + } + } } impl Catalog { @@ -87,15 +71,8 @@ impl Catalog { /// open tabs. Browser-opened repositories ([`Catalog::add_path`]) survive /// this, so a workspace change does not close a tab a viewer opened. pub fn set_paths(&self, paths: &[String]) { - let _mutation = self.mutation.lock().expect("catalog mutation poisoned"); - { - let mut base = self.base.lock().expect("catalog base poisoned"); - // The one entry point taking outside paths untouched: a `--repo` - // argument and a workspace file hold whatever spelling was typed or - // last written, which is not necessarily what a client sends. - *base = paths.iter().map(|p| Self::normalized(p)).collect(); - } - self.rebuild(); + let paths = paths.iter().map(|path| Self::normalized(path)).collect(); + self.change_membership(|membership| membership.set_paths(paths)); } /// Add a repository opened from the browser, returning its identity. @@ -105,38 +82,36 @@ impl Catalog { /// served set is at `max`, so a client cannot spawn unbounded runtimes. pub fn add_path(&self, path: String, max: usize) -> AddOutcome { let path = Self::normalized(&path); - let _mutation = self.mutation.lock().expect("catalog mutation poisoned"); - // Opening a path clears any prior close, so a previously removed repo - // comes back rather than staying suppressed by `hidden`. - { - let mut hidden = self.hidden.lock().expect("catalog hidden poisoned"); - hidden.retain(|h| h != &path); - } - let union = self.union_paths(); - if !union.iter().any(|p| p == &path) { - if union.len() >= max { - return AddOutcome::TooMany; - } - { - let mut added = self.added.lock().expect("catalog added poisoned"); - if !added.iter().any(|p| p == &path) { - added.push(path.clone()); - } - } - } - self.rebuild(); - match self.info_for_path(&path) { - Some(info) => AddOutcome::Added(info), - // rebuild always creates the entry; this only trips if a concurrent - // set_paths raced it back out, which the caller can treat as full. - None => AddOutcome::TooMany, - } + let (outcome, retired) = { + let _transaction = self + .transaction + .lock() + .expect("catalog transaction poisoned"); + let mut membership = self.membership.lock().expect("catalog membership poisoned"); + let id = match membership.add_path(path, max) { + AddMembership::Present(id) => id, + AddMembership::TooMany => return AddOutcome::TooMany, + }; + let members = membership.members(); + drop(membership); + let mut runtime = self.runtime.lock().expect("catalog runtime poisoned"); + let retired = runtime.reconcile(members); + let info = runtime + .entries() + .iter() + .find(|entry| entry.id == id) + .expect("accepted membership is committed to the runtime") + .info(); + (AddOutcome::Added(info), retired) + }; + stop_entries(retired); + outcome } /// Close a repository opened or shown in the browser. Dropped from every /// list that decides the served set and remembered in `hidden` so a `base` - /// re-sync will not bring it back; `rebuild` then stops its runtime and - /// terminals. + /// re-sync will not bring it back; the facade transaction then retires its + /// runtime and terminals. /// /// A close forgets the slot the repository held, `base` and `order` /// included. Leaving it in either meant [`Catalog::add_path`] found the @@ -144,108 +119,60 @@ impl Catalog { /// the tab back in the middle of the strip rather than at the end. pub fn remove_path(&self, path: &str) { let path = &Self::normalized(path); - let _mutation = self.mutation.lock().expect("catalog mutation poisoned"); - for list in [&self.added, &self.base, &self.order] { - list.lock() - .expect("catalog path list poisoned") - .retain(|p| p != path); - } - { - let mut hidden = self.hidden.lock().expect("catalog hidden poisoned"); - if !hidden.iter().any(|h| h == path) { - hidden.push(path.to_string()); - } - } - self.rebuild(); + self.change_membership(|membership| membership.remove_path(path)); } - fn info_for_path(&self, path: &str) -> Option { - self.entries - .lock() - .expect("catalog poisoned") - .iter() - .find(|e| e.path == path) - .map(|e| e.info()) + fn change_membership(&self, change: impl FnOnce(&mut CatalogMembership)) { + self.change_membership_if(|membership| { + change(membership); + true + }); } - /// Reconcile the live entries to `union_paths()`. A path already present - /// keeps its entry — and therefore its runtime and every SSE subscriber - /// attached to it. Only genuinely new paths start a runtime, and only - /// genuinely removed ones stop. - fn rebuild(&self) { - let deduped = self.union_paths(); - // Read once, before the entries lock: every hub this pass spawns is - // given the same tables, so a reload landing mid-rebuild cannot leave two - // repositories opened in the same beat configured differently. - let startup = self - .startup_commands - .lock() - .expect("catalog startup poisoned") - .clone(); - let plugins = self - .plugins - .lock() - .expect("catalog plugins poisoned") - .clone(); - - let assigned: Vec<(String, String)> = { - let mut ids = self.ids.lock().expect("catalog ids poisoned"); - deduped - .iter() - .map(|path| (ids.id_for(path), path.clone())) - .collect() - }; - - let retired = { - let mut entries = self.entries.lock().expect("catalog poisoned"); - let previous = std::mem::take(&mut *entries); - - let mut next = Vec::with_capacity(assigned.len()); - for (id, path) in assigned { - match previous.iter().find(|e| e.path == path) { - Some(existing) => next.push(Arc::clone(existing)), - None => next.push(Arc::new(RepoEntry { - name: repo_name(&path), - display_path: display_path(&path), - runtime: RepoRuntime::spawn( - &path, - self.status_encoder.unwrap_or(empty_status_payload), - ), - terminals: TerminalHub::spawn( - &path, - startup.clone(), - plugins.clone(), - self.shell.clone(), - Arc::clone(&self.ownership), - ), - id, - path, - })), - } + fn change_membership_if(&self, change: impl FnOnce(&mut CatalogMembership) -> bool) -> bool { + let (changed, retired) = { + let _transaction = self + .transaction + .lock() + .expect("catalog transaction poisoned"); + let mut membership = self.membership.lock().expect("catalog membership poisoned"); + let changed = change(&mut membership); + if !changed { + return false; } - - let retired: Vec<_> = previous - .into_iter() - .filter(|old| !next.iter().any(|new| Arc::ptr_eq(new, old))) - .collect(); - *entries = next; - retired + let members = membership.members(); + drop(membership); + let retired = self + .runtime + .lock() + .expect("catalog runtime poisoned") + .reconcile(members); + (true, retired) }; - - // Outside the lock: stopping a runtime joins its thread. - for entry in retired { - entry.runtime.stop(); - entry.terminals.stop(); - } + stop_entries(retired); + changed } /// Stop every runtime. Called on server shutdown. pub fn shutdown(&self) { - let entries = std::mem::take(&mut *self.entries.lock().expect("catalog poisoned")); - for entry in entries { - entry.runtime.stop(); - entry.terminals.stop(); - } + let retired = { + let _transaction = self + .transaction + .lock() + .expect("catalog transaction poisoned"); + self.runtime + .lock() + .expect("catalog runtime poisoned") + .take_entries() + }; + stop_entries(retired); + } +} + +fn stop_entries(entries: Vec>) { + for entry in entries { + entry.runtime.stop(); + entry.terminals.stop(); } } diff --git a/src/session/catalog/ordering.rs b/src/session/catalog/ordering.rs index fd063010..d8b9982a 100644 --- a/src/session/catalog/ordering.rs +++ b/src/session/catalog/ordering.rs @@ -1,57 +1,10 @@ use super::Catalog; impl Catalog { - pub(super) fn union_paths(&self) -> Vec { - let natural = { - let base = self.base.lock().expect("catalog base poisoned"); - let added = self.added.lock().expect("catalog added poisoned"); - let hidden = self.hidden.lock().expect("catalog hidden poisoned"); - let mut natural = Vec::with_capacity(base.len() + added.len()); - for path in base.iter().chain(added.iter()) { - if hidden.iter().any(|h| h == path) || natural.contains(path) { - continue; - } - natural.push(path.clone()); - } - natural - }; - - let order = self.order.lock().expect("catalog order poisoned"); - if order.is_empty() { - return natural; - } - let mut result = Vec::with_capacity(natural.len()); - for path in order.iter() { - if natural.iter().any(|served| served == path) && !result.contains(path) { - result.push(path.clone()); - } - } - for path in natural { - if !result.contains(&path) { - result.push(path); - } - } - result - } - pub fn reorder(&self, desired: &[String]) { // Normalised like every other path entering the catalog, so an order // given in a different spelling still names the repositories it means. let desired: Vec = desired.iter().map(|p| Self::normalized(p)).collect(); - let _mutation = self.mutation.lock().expect("catalog mutation poisoned"); - let served = self.union_paths(); - let mut next = Vec::with_capacity(served.len()); - for path in &desired { - if served.iter().any(|served| served == path) && !next.contains(path) { - next.push(path.clone()); - } - } - for path in &served { - if !next.contains(path) { - next.push(path.clone()); - } - } - *self.order.lock().expect("catalog order poisoned") = next; - self.rebuild(); + self.change_membership(|membership| membership.reorder(&desired)); } } diff --git a/src/session/catalog/views.rs b/src/session/catalog/views.rs index 3d0f3c5f..9f975438 100644 --- a/src/session/catalog/views.rs +++ b/src/session/catalog/views.rs @@ -20,9 +20,10 @@ pub struct ServedView { impl Catalog { pub fn get(&self, id: &str) -> Option> { - self.entries + self.runtime .lock() - .expect("catalog poisoned") + .expect("catalog runtime poisoned") + .entries() .iter() .find(|e| e.id == id) .map(Arc::clone) @@ -32,10 +33,12 @@ impl Catalog { /// rather than a client-facing projection. /// /// A snapshot: the `Arc`s are cloned out and the lock released. + #[cfg(test)] pub fn entries(&self) -> Vec> { - self.entries + self.runtime .lock() - .expect("catalog poisoned") + .expect("catalog runtime poisoned") + .entries() .iter() .map(Arc::clone) .collect() @@ -51,7 +54,8 @@ impl Catalog { maximized: &[crate::session::prefs::RepoMaximized], views: &[crate::session::prefs::RepoView], ) -> ServedView { - let entries = self.entries.lock().expect("catalog poisoned"); + let runtime = self.runtime.lock().expect("catalog runtime poisoned"); + let entries = runtime.entries(); let list = entries.iter().map(|e| e.info()).collect(); let active = remembered.and_then(|path| { entries @@ -91,18 +95,20 @@ impl Catalog { /// served. The inverse of [`Catalog::get`], for the one caller that stores /// a repository across restarts (`prefs.rs`) and so cannot hold an id. pub fn id_of_path(&self, path: &str) -> Option { - self.entries + self.runtime .lock() - .expect("catalog poisoned") + .expect("catalog runtime poisoned") + .entries() .iter() .find(|e| e.path == path) .map(|e| e.id.clone()) } pub fn list(&self) -> Vec { - self.entries + self.runtime .lock() - .expect("catalog poisoned") + .expect("catalog runtime poisoned") + .entries() .iter() .map(|e| e.info()) .collect() @@ -116,9 +122,10 @@ impl Catalog { /// browser's own response builder reads this too, to turn a preference /// stored by path back into the ids it speaks. pub fn id_paths(&self) -> Vec<(String, String)> { - self.entries + self.runtime .lock() - .expect("catalog poisoned") + .expect("catalog runtime poisoned") + .entries() .iter() .map(|e| (e.id.clone(), e.path.clone())) .collect() @@ -127,9 +134,10 @@ impl Catalog { /// Absolute worktree paths of the served set, in order. Used to persist the /// open projects. pub fn paths(&self) -> Vec { - self.entries + self.runtime .lock() - .expect("catalog poisoned") + .expect("catalog runtime poisoned") + .entries() .iter() .map(|e| e.path.clone()) .collect() @@ -137,7 +145,11 @@ impl Catalog { #[cfg(test)] pub fn len(&self) -> usize { - self.entries.lock().expect("catalog poisoned").len() + self.runtime + .lock() + .expect("catalog runtime poisoned") + .entries() + .len() } #[cfg(test)] From 466af1e43d7bfd29a2500c52d592fca7c171c361 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 00:50:44 +0900 Subject: [PATCH 16/42] docs(session): align catalog concurrency contract --- docs/architecture/session.md | 4 ++-- src/session/catalog/config_tables.rs | 6 +++--- src/session/catalog/mod.rs | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/architecture/session.md b/docs/architecture/session.md index 706ed636..129fdd53 100644 --- a/docs/architecture/session.md +++ b/docs/architecture/session.md @@ -365,7 +365,7 @@ DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이 - **`[[startup_command]]` — 이후에 여는 프로젝트부터.** hub는 startup pane을 자기 수명에 **딱 한 번** 만든다(`started: AtomicBool`). 이미 열린 프로젝트가 그 목록에 쓴 pane은 살아 있는 자식이라 파일 편집을 근거로 교체할 수 있는 대상이 아니다. Catalog의 목록만 바뀌고 - (`catalog/config_tables.rs`) 그 뒤 `rebuild`가 띄우는 hub가 새 목록을 받는다. + (`catalog/config_tables.rs`) 그 뒤 runtime reconcile이 띄우는 hub가 새 목록을 받는다. - **나머지는 재시작이 필요하다**: `[web_viewer]`(리스너가 이미 바인드됨), `[log]`, 그리고 클라이언트 소유인 `[layout]`·`[input]`·`[tree]`·`[mouse]`. @@ -406,7 +406,7 @@ DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이 범위를 좁히는 것이 `spec_changed`의 진짜 값이다. - **동시 reload는 직렬화한다**(`SessionState::reload_lock`). 두 클라이언트가 동시에 누르면 세션의 저장소들이 서로 다른 파일을 전달받은 상태로 남을 수 있다. -- **reload와 프로젝트 열기의 경합은 Catalog의 mutation lock이 막는다.** 테이블 교체와 "알려줄 +- **reload와 프로젝트 열기의 경합은 Catalog의 façade transaction이 막는다.** 테이블 교체와 "알려줄 저장소 목록" 스냅샷을 **같은 락 안에서** 처리하고 그 목록을 호출자에게 돌려준다 (`set_config_tables`가 `Vec>`를 반환하는 이유). 없으면 같은 순간에 열린 저장소가 둘 사이로 빠져 열려 있는 내내 이전 `[[plugin]]` 테이블로 돈다. diff --git a/src/session/catalog/config_tables.rs b/src/session/catalog/config_tables.rs index 3d545973..42c0dbb5 100644 --- a/src/session/catalog/config_tables.rs +++ b/src/session/catalog/config_tables.rs @@ -71,9 +71,9 @@ impl Catalog { /// already running is the caller's job (see [`crate::session::reload`]). /// The entries to tell are returned rather than fetched afterwards. /// - /// Taken under the facade transaction, the same one every membership commit - /// holds. Without - /// it a repository opened in the same beat could fall between the two halves: + /// Taken under the facade transaction, the same one every membership + /// commit holds. Without it a repository opened in the same beat could + /// fall between the two halves: /// its hub reads the old tables while the swap is still to come, and the /// swap's snapshot is taken while its entry is still to be installed. pub fn set_config_tables( diff --git a/src/session/catalog/mod.rs b/src/session/catalog/mod.rs index a035c617..c04b2c42 100644 --- a/src/session/catalog/mod.rs +++ b/src/session/catalog/mod.rs @@ -5,10 +5,10 @@ //! Ids are stable for the process lifetime, so opening or closing an unrelated //! tab does not renumber the others. //! -//! Replacement is atomic and does no blocking work under the lock: the new list -//! is built and swapped in, and only then are the dropped runtimes stopped — a -//! runtime shutdown joins a thread, and holding the catalog lock across that -//! would stall every in-flight request. +//! Replacement is atomic: membership is committed to the live runtime snapshot +//! under the facade transaction. Dropped runtimes are returned from that commit +//! and stopped only after every catalog lock is released — shutdown joins a +//! thread and must not stall in-flight reads or the next mutation. //! //! **Every path in here is the one `resolve_repo_path` produces**, normalised on //! the way in rather than by each caller. Two spellings of one worktree are two From 4eb69c5a8ee6bce470324618def2be4edd7de4f2 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 00:52:44 +0900 Subject: [PATCH 17/42] refactor(log): separate commit log controller state --- src/app.rs | 4 +- src/app/app_impl.rs | 9 +- src/app/commit_log_apply.rs | 6 +- src/app/commit_log_fetch.rs | 52 +++++--- src/app/commit_log_pagination.rs | 69 ++++++++++- src/app/focus.rs | 4 +- src/app/session_io.rs | 5 +- src/app/snapshot_io.rs | 4 +- src/app/tests/commit_log.rs | 89 +++++--------- src/app/tests/head_change.rs | 18 +-- src/app/tests/helpers.rs | 2 +- src/app/tests/log_search.rs | 10 +- src/app/tests/mod.rs | 4 +- src/app/tests/mode_toggle.rs | 2 +- src/app/tests/tree_session.rs | 4 +- src/application/bootstrap.rs | 6 +- src/ui/log_view/drill_down.rs | 50 ++++++++ src/ui/log_view/list.rs | 89 ++++++++++++++ src/ui/log_view/mod.rs | 200 ++++++++----------------------- src/ui/log_view/tests.rs | 6 +- 20 files changed, 366 insertions(+), 267 deletions(-) create mode 100644 src/ui/log_view/drill_down.rs create mode 100644 src/ui/log_view/list.rs diff --git a/src/app.rs b/src/app.rs index ccdfee76..c34b4b03 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,7 +17,7 @@ mod terminal_ctrl; mod tree; mod tree_nav; -pub use crate::app::commit_log_pagination::CommitLogPagination; +pub use crate::app::commit_log_pagination::CommitLogController; pub use crate::runtime::snapshot::{SnapshotChannel, SnapshotMsg}; #[cfg(test)] pub use crate::runtime::terminal::PaneInfo; @@ -116,7 +116,7 @@ pub struct App { pub cfg_agent_indicator: crate::config::AgentIndicatorConfig, pub cfg_tree: crate::config::TreeConfig, // Drop impl joins the worker so `change_repo` can't leak the old-repo fetch. - pub pagination: CommitLogPagination, + pub commit_log_controller: CommitLogController, pub auto_follow: AutoFollow, // Mutually exclusive with `diff.fullscreen` and `terminal.fullscreen`. pub list_fullscreen: bool, diff --git a/src/app/app_impl.rs b/src/app/app_impl.rs index f2ab0453..f54fa344 100644 --- a/src/app/app_impl.rs +++ b/src/app/app_impl.rs @@ -107,10 +107,11 @@ impl App { repo_cache: None, cfg_agent_indicator: crate::config::AgentIndicatorConfig::default(), cfg_tree: crate::config::TreeConfig::default(), - pagination: crate::app::commit_log_pagination::CommitLogPagination::with_config( - crate::config::LogConfig::default().commit_log_page_size, - crate::config::LogConfig::default().commit_log_prefetch_threshold, - ), + commit_log_controller: + crate::app::commit_log_pagination::CommitLogController::with_config( + crate::config::LogConfig::default().commit_log_page_size, + crate::config::LogConfig::default().commit_log_prefetch_threshold, + ), auto_follow: AutoFollow::default(), list_fullscreen: false, branch_name: None, diff --git a/src/app/commit_log_apply.rs b/src/app/commit_log_apply.rs index 02a47656..9de30a68 100644 --- a/src/app/commit_log_apply.rs +++ b/src/app/commit_log_apply.rs @@ -5,6 +5,9 @@ use super::commit_log_fetch::{CommitLogFetchKind, CommitLogPageMsg}; impl App { pub(super) fn handle_commit_log_page_msg(&mut self, msg: CommitLogPageMsg) { + if msg.generation != self.commit_log_controller.generation() { + return; + } match msg.kind { CommitLogFetchKind::Tail => self.apply_tail_page(msg), CommitLogFetchKind::Refresh { @@ -119,7 +122,8 @@ impl App { self.log_view.commit_scroll_x = 0; // Anchor the head-oid sentinel so ingest_snapshot doesn't immediately // trigger another refresh. - self.pagination.last_head_oid = self.log_view.commits.first().map(|c| c.oid); + self.commit_log_controller + .set_last_head_oid(self.log_view.commits.first().map(|c| c.oid)); // Drill-down survives only if the commit it was opened on is still in // the (possibly extended) list. diff --git a/src/app/commit_log_fetch.rs b/src/app/commit_log_fetch.rs index 68e5084b..d7d87e5d 100644 --- a/src/app/commit_log_fetch.rs +++ b/src/app/commit_log_fetch.rs @@ -30,6 +30,7 @@ pub(crate) enum CommitLogFetchKind { // as a stale-result check before appending — if the loaded count changed // between spawn and reply, the page is dropped. pub(crate) struct CommitLogPageMsg { + pub generation: u64, pub kind: CommitLogFetchKind, pub skip: usize, pub page_size: usize, @@ -37,6 +38,26 @@ pub(crate) struct CommitLogPageMsg { } impl App { + pub fn configure_commit_log(&mut self, page_size: usize, prefetch_threshold: usize) { + self.commit_log_controller + .configure(page_size, prefetch_threshold); + } + + #[cfg(test)] + pub(crate) fn commit_log_fetch_pending(&self) -> bool { + self.commit_log_controller.fetch_pending() + } + + #[cfg(test)] + pub(crate) fn set_observed_head_for_test(&mut self, oid: Option) { + self.commit_log_controller.set_last_head_oid(oid); + } + + #[cfg(test)] + pub(crate) fn observed_head_for_test(&self) -> Option { + self.commit_log_controller.last_head_oid() + } + pub(crate) fn spawn_commit_log_page_fetch(&mut self, skip: usize) { if self.log_view.fully_loaded { return; @@ -70,46 +91,48 @@ impl App { // receiver-drop already signals the worker to exit at next send, and an // old handle mid-`load_commit_log_page` must not stall the frame). fn launch_commit_log_worker(&mut self, skip: usize, kind: CommitLogFetchKind) { - drop(self.pagination.page_rx.take()); - self.pagination.handle.take(); - let page_size = self.pagination.page_size; + drop(self.commit_log_controller.page_rx.take()); + self.commit_log_controller.handle.take(); + let page_size = self.commit_log_controller.page_size(); + let generation = self.commit_log_controller.next_generation(); let repo_path = self.repo_path.clone(); let (tx, rx) = mpsc::channel(); - self.pagination.page_rx = Some(rx); + self.commit_log_controller.page_rx = Some(rx); let handle = thread::spawn(move || { let result = match Repository::discover(&repo_path) { Ok(repo) => load_commit_log_page(&repo, skip, page_size).map_err(|e| e.to_string()), Err(e) => Err(crate::git::format_discover_error(&e).to_string()), }; let _ = tx.send(CommitLogPageMsg { + generation, kind, skip, page_size, result, }); }); - self.pagination.handle = Some(handle); + self.commit_log_controller.handle = Some(handle); } pub(crate) fn poll_commit_log_page_fetch(&mut self) { - let Some(rx) = self.pagination.page_rx.as_ref() else { + let Some(rx) = self.commit_log_controller.page_rx.as_ref() else { return; }; match rx.try_recv() { Ok(msg) => { - self.pagination.page_rx = None; + self.commit_log_controller.page_rx = None; // The worker just sent, so its next blocking point is gone; a // short timed join reaps it now, and the timeout keeps a // wedged worker from stalling the frame. - if let Some(h) = self.pagination.handle.take() { + if let Some(h) = self.commit_log_controller.handle.take() { try_timed_join(h, REAP_TIMEOUT); } self.handle_commit_log_page_msg(msg); } Err(mpsc::TryRecvError::Empty) => {} Err(mpsc::TryRecvError::Disconnected) => { - self.pagination.page_rx = None; - if let Some(h) = self.pagination.handle.take() { + self.commit_log_controller.page_rx = None; + if let Some(h) = self.commit_log_controller.handle.take() { try_timed_join(h, REAP_TIMEOUT); } self.log_view.clear_pending(); @@ -122,8 +145,9 @@ impl App { // worker's next `tx.send` to Err and the join completes in microseconds // in the common case; the timeout caps worst-case latency. pub(crate) fn cancel_commit_log_page_fetch(&mut self) { - drop(self.pagination.page_rx.take()); - if let Some(h) = self.pagination.handle.take() { + drop(self.commit_log_controller.page_rx.take()); + self.commit_log_controller.next_generation(); + if let Some(h) = self.commit_log_controller.handle.take() { try_timed_join(h, REAP_TIMEOUT); } self.log_view.clear_pending(); @@ -150,7 +174,7 @@ impl App { return; } let loaded = self.log_view.loaded_count; - let threshold = self.pagination.prefetch_threshold; + let threshold = self.commit_log_controller.prefetch_threshold(); if self.log_view.selected + threshold >= loaded { self.spawn_commit_log_page_fetch(loaded); } @@ -160,7 +184,7 @@ impl App { #[cfg(test)] pub(crate) fn flush_commit_log_fetch_for_test(&mut self, timeout: std::time::Duration) { let start = std::time::Instant::now(); - while self.log_view.pending_fetch { + while self.log_view.pending_fetch || self.commit_log_fetch_pending() { if start.elapsed() > timeout { panic!("commit log fetch did not complete within {:?}", timeout); } diff --git a/src/app/commit_log_pagination.rs b/src/app/commit_log_pagination.rs index f8627c90..9113ad3b 100644 --- a/src/app/commit_log_pagination.rs +++ b/src/app/commit_log_pagination.rs @@ -11,19 +11,20 @@ use super::commit_log_fetch::CommitLogPageMsg; // worker's `tx.send` fail, then the JoinHandle is awaited so `change_repo` // can't leak the old-repo worker. #[derive(Default)] -pub struct CommitLogPagination { - pub page_size: usize, - pub prefetch_threshold: usize, +pub struct CommitLogController { + page_size: usize, + prefetch_threshold: usize, pub(crate) page_rx: Option>, // `cancel_commit_log_page_fetch` deliberately does NOT join here: the UI // tick can't wait for a mid-`load_commit_log_page` worker. Receiver-drop // already makes the reply fail; detaching is safe (worst case: one extra // OS thread until it finishes). pub(crate) handle: Option>, - pub(crate) last_head_oid: Option, + last_head_oid: Option, + generation: u64, } -impl CommitLogPagination { +impl CommitLogController { // `..Default::default()` can't be used: the type implements `Drop`. pub fn with_config(page_size: usize, prefetch_threshold: usize) -> Self { Self { @@ -32,11 +33,41 @@ impl CommitLogPagination { page_rx: None, handle: None, last_head_oid: None, + generation: 0, } } + + pub fn configure(&mut self, page_size: usize, prefetch_threshold: usize) { + self.page_size = page_size; + self.prefetch_threshold = prefetch_threshold; + } + + pub(crate) fn page_size(&self) -> usize { + self.page_size + } + pub(crate) fn prefetch_threshold(&self) -> usize { + self.prefetch_threshold + } + pub(crate) fn last_head_oid(&self) -> Option { + self.last_head_oid + } + pub(crate) fn set_last_head_oid(&mut self, oid: Option) { + self.last_head_oid = oid; + } + pub(crate) fn next_generation(&mut self) -> u64 { + self.generation = self.generation.wrapping_add(1); + self.generation + } + pub(crate) fn generation(&self) -> u64 { + self.generation + } + #[cfg(test)] + pub(crate) fn fetch_pending(&self) -> bool { + self.page_rx.is_some() + } } -impl Drop for CommitLogPagination { +impl Drop for CommitLogController { fn drop(&mut self) { // Drop receiver first so the worker's next `tx.send` fails and the // loop exits; then bounded-join so a stuck libgit2 call can't freeze @@ -47,3 +78,29 @@ impl Drop for CommitLogPagination { } } } + +#[cfg(test)] +mod tests { + use super::CommitLogController; + + #[test] + fn generation_advances_for_each_request_and_cancel() { + let mut controller = CommitLogController::with_config(50, 10); + + let first = controller.next_generation(); + let second = controller.next_generation(); + + assert_ne!(first, second); + assert_eq!(controller.generation(), second); + } + + #[test] + fn configuration_is_exposed_without_worker_internals() { + let mut controller = CommitLogController::with_config(50, 10); + controller.configure(25, 5); + + assert_eq!(controller.page_size(), 25); + assert_eq!(controller.prefetch_threshold(), 5); + assert!(!controller.fetch_pending()); + } +} diff --git a/src/app/focus.rs b/src/app/focus.rs index e2279a91..afde07c2 100644 --- a/src/app/focus.rs +++ b/src/app/focus.rs @@ -40,8 +40,8 @@ impl App { // refresh the hidden commit list, so a HEAD change there must // invalidate the cache on the next entry. let cached_head = self.log_view.commits.first().map(|c| c.oid); - let cache_matches_head = - !self.log_view.commits.is_empty() && cached_head == self.pagination.last_head_oid; + let cache_matches_head = !self.log_view.commits.is_empty() + && cached_head == self.commit_log_controller.last_head_oid(); if !self.log_view.commits.is_empty() && !cache_matches_head { self.refresh_commit_log_after_head_change(); } else if self.log_view.commits.is_empty() { diff --git a/src/app/session_io.rs b/src/app/session_io.rs index 302cd357..342759d0 100644 --- a/src/app/session_io.rs +++ b/src/app/session_io.rs @@ -159,7 +159,7 @@ impl App { // fresh `set_commits` below: its reply would be silently appended over // the restored list. Cancel before mutating state. self.cancel_commit_log_page_fetch(); - let page_size = self.pagination.page_size; + let page_size = self.commit_log_controller.page_size(); let commits = match self.with_repo(|repo| load_commit_log(repo, page_size)) { Ok(c) => c, Err(e) => { @@ -174,7 +174,8 @@ impl App { .log_selected .min(self.log_view.commits.len().saturating_sub(1)); // Avoid a same-tick HEAD-change-trigger reload on the next snapshot. - self.pagination.last_head_oid = self.log_view.commits.first().map(|c| c.oid); + self.commit_log_controller + .set_last_head_oid(self.log_view.commits.first().map(|c| c.oid)); self.mode = ViewMode::Log; if state.log_drill_down { diff --git a/src/app/snapshot_io.rs b/src/app/snapshot_io.rs index 588e1248..018676e6 100644 --- a/src/app/snapshot_io.rs +++ b/src/app/snapshot_io.rs @@ -73,8 +73,8 @@ impl App { // Skip on the very first snapshot (prior == None) so initial loads // don't double-fetch the commit log on top of `toggle_mode`'s eager load. - let prior_head = self.pagination.last_head_oid; - self.pagination.last_head_oid = new_head; + let prior_head = self.commit_log_controller.last_head_oid(); + self.commit_log_controller.set_last_head_oid(new_head); if prior_head.is_some() && prior_head != new_head && self.mode == ViewMode::Log { self.refresh_commit_log_after_head_change(); } diff --git a/src/app/tests/commit_log.rs b/src/app/tests/commit_log.rs index 72f373df..fca0e874 100644 --- a/src/app/tests/commit_log.rs +++ b/src/app/tests/commit_log.rs @@ -13,8 +13,7 @@ fn fake_entry(time: i64) -> CommitEntry { pub(super) fn seed_log_app(entries: usize, page_size: usize, threshold: usize) -> App { let mut app = app_with_files(vec![]); app.mode = ViewMode::Log; - app.pagination.page_size = page_size; - app.pagination.prefetch_threshold = threshold; + app.configure_commit_log(page_size, threshold); let commits: Vec<_> = (0..entries).map(|i| fake_entry(i as i64)).collect(); app.log_view.set_commits(commits); app @@ -29,7 +28,7 @@ fn maybe_prefetch_no_ops_in_status_mode() { app.maybe_prefetch_commit_log(); assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); + assert!(!app.commit_log_fetch_pending()); } #[test] @@ -37,7 +36,7 @@ fn maybe_prefetch_no_ops_when_empty() { let mut app = seed_log_app(0, 5, 5); app.maybe_prefetch_commit_log(); assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); + assert!(!app.commit_log_fetch_pending()); } #[test] @@ -49,7 +48,7 @@ fn maybe_prefetch_no_ops_when_fully_loaded() { app.maybe_prefetch_commit_log(); assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); + assert!(!app.commit_log_fetch_pending()); } #[test] @@ -61,7 +60,7 @@ fn maybe_prefetch_no_ops_when_far_from_tail() { app.maybe_prefetch_commit_log(); assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); + assert!(!app.commit_log_fetch_pending()); } #[test] @@ -78,12 +77,11 @@ fn maybe_prefetch_triggers_when_near_tail() { app.maybe_prefetch_commit_log(); assert!(app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_some()); + assert!(app.commit_log_fetch_pending()); // Wait for the worker to land so its result doesn't leak into a // subsequent test scenario. - let rx = app.pagination.page_rx.take().unwrap(); - let _ = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); drop(dir); } @@ -98,65 +96,40 @@ fn maybe_prefetch_suppresses_duplicate_pending() { app.log_view.selected = 6; app.maybe_prefetch_commit_log(); - let first_rx_ptr = app.pagination.page_rx.as_ref().map(|r| r as *const _); - assert!(first_rx_ptr.is_some()); + assert!(app.commit_log_fetch_pending()); app.maybe_prefetch_commit_log(); - let second_rx_ptr = app.pagination.page_rx.as_ref().map(|r| r as *const _); - // The second call must reuse the same receiver — no second spawn. - assert_eq!(first_rx_ptr, second_rx_ptr); + assert!(app.commit_log_fetch_pending()); - let rx = app.pagination.page_rx.take().unwrap(); - let _ = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); drop(dir); } #[test] -fn poll_drains_matching_skip_into_commits() { - let mut app = seed_log_app(3, 5, 1); - app.log_view.pending_fetch = true; - let (tx, rx) = mpsc::channel(); - app.pagination.page_rx = Some(rx); - // Worker thinks the loaded tail was 3 when it ran; this matches. - tx.send(CommitLogPageMsg { - kind: CommitLogFetchKind::Tail, - skip: 3, - page_size: 5, - result: Ok(vec![fake_entry(3), fake_entry(4)]), - }) - .unwrap(); - - app.poll_commit_log_page_fetch(); - - assert_eq!(app.log_view.commits.len(), 5); - assert_eq!(app.log_view.loaded_count, 5); - // Page was shorter than page_size → end of history reached. - assert!(app.log_view.fully_loaded); - assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); +fn worker_reply_appends_matching_tail() { + let (_dir, path) = make_repo(); + run_git(&path, &["commit", "--allow-empty", "-m", "c0"]); + run_git(&path, &["commit", "--allow-empty", "-m", "c1"]); + let mut app = seed_log_app(0, 1, 1); + app.repo_path = path; + app.spawn_commit_log_page_fetch(0); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); + assert_eq!(app.log_view.commits.len(), 2); + assert_eq!(app.log_view.loaded_count, 2); } #[test] -fn poll_discards_stale_skip_result() { - let mut app = seed_log_app(3, 5, 1); - app.log_view.pending_fetch = true; - let (tx, rx) = mpsc::channel(); - app.pagination.page_rx = Some(rx); - // skip=2 doesn't match loaded_count=3 → discard (e.g. HEAD changed - // between spawn and reply, resetting pagination). - tx.send(CommitLogPageMsg { - kind: CommitLogFetchKind::Tail, - skip: 2, - page_size: 5, - result: Ok(vec![fake_entry(2), fake_entry(3)]), - }) - .unwrap(); - - app.poll_commit_log_page_fetch(); - - assert_eq!(app.log_view.commits.len(), 3); - assert!(!app.log_view.fully_loaded); - assert!(!app.log_view.pending_fetch); +fn worker_reply_discards_stale_tail() { + let (_dir, path) = make_repo(); + run_git(&path, &["commit", "--allow-empty", "-m", "c0"]); + let mut app = seed_log_app(1, 1, 1); + app.repo_path = path; + app.spawn_commit_log_page_fetch(1); + app.log_view + .set_commits(vec![fake_entry(9), fake_entry(10)]); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); + assert_eq!(app.log_view.commits.len(), 2); + assert_eq!(app.log_view.commits[0].summary, "c9"); } #[test] diff --git a/src/app/tests/head_change.rs b/src/app/tests/head_change.rs index 6a682e55..c6fa6906 100644 --- a/src/app/tests/head_change.rs +++ b/src/app/tests/head_change.rs @@ -31,7 +31,7 @@ fn head_change_in_log_mode_reloads_commit_list() { app.log_view .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); app.log_view.selected = 0; - app.pagination.last_head_oid = app.log_view.commits.first().map(|c| c.oid); + app.set_observed_head_for_test(app.log_view.commits.first().map(|c| c.oid)); assert_eq!(app.log_view.commits.len(), 2); // Make a new commit in the same repo (simulates the terminal pane @@ -67,7 +67,7 @@ fn head_change_in_status_mode_does_not_reload() { // refreshed even when HEAD moves. app.log_view .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); - app.pagination.last_head_oid = app.log_view.commits.first().map(|c| c.oid); + app.set_observed_head_for_test(app.log_view.commits.first().map(|c| c.oid)); assert_eq!(app.log_view.commits.len(), 1); assert_eq!(app.mode, ViewMode::Status); @@ -99,7 +99,7 @@ fn toggling_log_after_status_head_change_reloads_stale_cache() { app.mode = ViewMode::Status; app.log_view .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); - app.pagination.last_head_oid = app.log_view.commits.first().map(|c| c.oid); + app.set_observed_head_for_test(app.log_view.commits.first().map(|c| c.oid)); assert_eq!(app.log_view.commits[0].summary, "first"); run_git(&path, &["commit", "--allow-empty", "-m", "second"]); @@ -122,7 +122,7 @@ fn toggling_log_after_status_head_change_reloads_stale_cache() { assert_eq!(app.log_view.selected, 1); assert_eq!(app.log_view.commits[app.log_view.selected].summary, "first"); assert!(app.log_view.fully_loaded); - assert!(app.pagination.page_rx.is_none()); + assert!(!app.commit_log_fetch_pending()); } #[test] @@ -144,7 +144,7 @@ fn head_change_preserves_selected_commit_by_oid() { // Select the older commit at the bottom. app.log_view.selected = 1; let prior_oid = app.log_view.commits[1].oid; - app.pagination.last_head_oid = app.log_view.commits.first().map(|c| c.oid); + app.set_observed_head_for_test(app.log_view.commits.first().map(|c| c.oid)); run_git(&path, &["commit", "--allow-empty", "-m", "third"]); @@ -177,7 +177,7 @@ fn head_change_falls_back_to_top_when_prior_oid_gone() { app.log_view .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); app.log_view.selected = 0; - app.pagination.last_head_oid = app.log_view.commits.first().map(|c| c.oid); + app.set_observed_head_for_test(app.log_view.commits.first().map(|c| c.oid)); // Reset to before the second commit so the prior HEAD oid is gone, // then add a different commit on top. @@ -213,7 +213,7 @@ fn head_change_clears_drill_down_when_commit_gone() { .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); app.log_view.selected = 0; // 'doomed' commit at top app.log_view.drill_down = true; - app.pagination.last_head_oid = app.log_view.commits.first().map(|c| c.oid); + app.set_observed_head_for_test(app.log_view.commits.first().map(|c| c.oid)); // Drop the selected commit via reset, then advance HEAD with a new one. run_git(&path, &["reset", "--hard", "HEAD~1"]); @@ -244,7 +244,7 @@ fn initial_snapshot_does_not_trigger_commit_log_reload() { app.mode = ViewMode::Log; // No prior commits loaded; last_head_oid = None (default). assert!(app.log_view.commits.is_empty()); - assert!(app.pagination.last_head_oid.is_none()); + assert!(app.observed_head_for_test().is_none()); tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) .unwrap(); @@ -254,5 +254,5 @@ fn initial_snapshot_does_not_trigger_commit_log_reload() { // toggle_mode's / restore_log_session's job. We only refresh on // subsequent HEAD changes. assert!(app.log_view.commits.is_empty()); - assert!(app.pagination.last_head_oid.is_some()); + assert!(app.observed_head_for_test().is_some()); } diff --git a/src/app/tests/helpers.rs b/src/app/tests/helpers.rs index 2b79be19..314f6664 100644 --- a/src/app/tests/helpers.rs +++ b/src/app/tests/helpers.rs @@ -67,7 +67,7 @@ pub(crate) fn app_with_files(files: Vec<&str>) -> App { ..crate::config::AgentIndicatorConfig::default() }, cfg_tree: crate::config::TreeConfig::default(), - pagination: CommitLogPagination::with_config( + commit_log_controller: CommitLogController::with_config( crate::config::LogConfig::default().commit_log_page_size, crate::config::LogConfig::default().commit_log_prefetch_threshold, ), diff --git a/src/app/tests/log_search.rs b/src/app/tests/log_search.rs index 3ffd2277..599adbbe 100644 --- a/src/app/tests/log_search.rs +++ b/src/app/tests/log_search.rs @@ -49,7 +49,7 @@ fn maybe_prefetch_suppressed_while_commit_search_active() { app.maybe_prefetch_commit_log(); assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); + assert!(!app.commit_log_fetch_pending()); } #[test] @@ -71,10 +71,9 @@ fn cancel_log_search_resumes_prefetch() { // tail fetch can run now that the gate is lifted. app.cancel_log_search(); assert!(app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_some()); + assert!(app.commit_log_fetch_pending()); - let rx = app.pagination.page_rx.take().unwrap(); - let _ = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); drop(dir); } @@ -99,8 +98,7 @@ fn confirm_log_search_with_query_resumes_prefetch() { assert_eq!(app.log_view.commit_search_query.as_str(), "c"); assert!(app.log_view.pending_fetch); - let rx = app.pagination.page_rx.take().unwrap(); - let _ = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); drop(dir); } diff --git a/src/app/tests/mod.rs b/src/app/tests/mod.rs index 14a28990..132eba11 100644 --- a/src/app/tests/mod.rs +++ b/src/app/tests/mod.rs @@ -2,12 +2,11 @@ mod helpers; // `use super::*` re-exports app.rs's `use` declarations and public items // (App, AutoFollow, Focus, ViewMode, Notice, NoticeKind, DiffPaneView, -// FileViewKey, FileViewState, CommitLogPagination, SnapshotChannel, etc.) +// FileViewKey, FileViewState, CommitLogController, SnapshotChannel, etc.) // so every test submodule can pull them in with `use super::*;`. use super::diff_load::DiffApply; use super::strip_escape_sequences; use super::*; -use crate::app::commit_log_fetch::{CommitLogFetchKind, CommitLogPageMsg}; use crate::git::diff::{ChangedFile, CommitEntry, RepoSnapshot, StatusKind, load_commit_log}; use crate::runtime::snapshot::SnapshotMsg; use crate::runtime::terminal::{PaneInfo, SCROLLBACK_LINES, TerminalFullscreen}; @@ -15,7 +14,6 @@ use crate::test_util::{make_repo, open_repo, run_git}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::collections::HashMap; use std::path::Path; -use std::sync::mpsc; use std::time::{Duration, Instant, SystemTime}; mod auto_follow; diff --git a/src/app/tests/mode_toggle.rs b/src/app/tests/mode_toggle.rs index 0f085a5e..a61c4af0 100644 --- a/src/app/tests/mode_toggle.rs +++ b/src/app/tests/mode_toggle.rs @@ -3,7 +3,7 @@ use super::*; pub(super) fn seed_cached_commit_log(app: &mut App) { app.log_view.set_commits(vec![fake_entry(0)]); app.log_view.fully_loaded = true; - app.pagination.last_head_oid = app.log_view.commits.first().map(|c| c.oid); + app.set_observed_head_for_test(app.log_view.commits.first().map(|c| c.oid)); } fn fake_entry(time: i64) -> CommitEntry { diff --git a/src/app/tests/tree_session.rs b/src/app/tests/tree_session.rs index 85fca4dc..3c3d673f 100644 --- a/src/app/tests/tree_session.rs +++ b/src/app/tests/tree_session.rs @@ -221,12 +221,12 @@ fn entering_tree_cancels_in_flight_commit_log_fetch() { let (dir, path) = make_tree_repo(); let mut app = app_on(&path); app.spawn_commit_log_refresh_fetch(None, None); - assert!(app.pagination.page_rx.is_some(), "fetch should be pending"); + assert!(app.commit_log_fetch_pending(), "fetch should be pending"); app.enter_tree_mode(); assert!( - app.pagination.page_rx.is_none(), + !app.commit_log_fetch_pending(), "entering Tree mode must cancel the in-flight commit-log fetch" ); drop(dir); diff --git a/src/application/bootstrap.rs b/src/application/bootstrap.rs index cca840b7..e79d80e0 100644 --- a/src/application/bootstrap.rs +++ b/src/application/bootstrap.rs @@ -15,8 +15,10 @@ pub(crate) fn init_app( if cfg.tree.live_watch { app.tree_watch = crate::runtime::tree_watch::TreeWatcher::new(); } - app.pagination.page_size = cfg.log.commit_log_page_size; - app.pagination.prefetch_threshold = cfg.log.commit_log_prefetch_threshold; + app.configure_commit_log( + cfg.log.commit_log_page_size, + cfg.log.commit_log_prefetch_threshold, + ); if let Some(state) = saved_session { // Applied up front rather than on the first snapshot: only the Status // selection needs the changed-file list, and it waits in diff --git a/src/ui/log_view/drill_down.rs b/src/ui/log_view/drill_down.rs new file mode 100644 index 00000000..00a54b54 --- /dev/null +++ b/src/ui/log_view/drill_down.rs @@ -0,0 +1,50 @@ +use crate::git::diff::ChangedFile; +use crate::ui::SearchQuery; +use std::cell::Cell; + +#[derive(Default)] +pub struct CommitDrillDownState { + pub drill_down: bool, + pub commit_files: Vec, + pub file_selected: usize, + pub file_scroll_x: usize, + pub file_search_query: SearchQuery, + pub file_search_active: bool, + pub(crate) commit_files_filter_cache: Vec, + pub(crate) commit_files_width_cache: Cell>, +} + +impl CommitDrillDownState { + pub(crate) fn replace_files(&mut self, files: Vec) { + self.commit_files = files; + self.commit_files_width_cache.set(None); + self.recompute_filter(); + } + + pub(crate) fn reset(&mut self) { + self.drill_down = false; + self.commit_files.clear(); + self.commit_files_width_cache.set(None); + self.file_selected = 0; + self.file_scroll_x = 0; + self.file_search_active = false; + self.file_search_query.clear(); + self.commit_files_filter_cache.clear(); + } + + pub(crate) fn recompute_filter(&mut self) { + self.commit_files_filter_cache.clear(); + if self.file_search_query.is_empty() { + self.commit_files_filter_cache + .extend(0..self.commit_files.len()); + } else { + let query = self.file_search_query.lower(); + self.commit_files_filter_cache.extend( + self.commit_files + .iter() + .enumerate() + .filter_map(|(index, file)| file.search_lower.contains(query).then_some(index)), + ); + } + } +} diff --git a/src/ui/log_view/list.rs b/src/ui/log_view/list.rs new file mode 100644 index 00000000..e3885408 --- /dev/null +++ b/src/ui/log_view/list.rs @@ -0,0 +1,89 @@ +use crate::git::diff::CommitEntry; +use crate::ui::SearchQuery; +use std::cell::Cell; + +#[derive(Default)] +pub struct CommitListState { + pub commits: Vec, + pub selected: usize, + pub commit_scroll_x: usize, + pub commit_search_query: SearchQuery, + pub commit_search_active: bool, + pub(crate) commits_filter_cache: Vec, + pub(crate) commit_width_cache: Cell>, + pub(crate) loaded_count: usize, + pub(crate) pending_fetch: bool, + pub(crate) fully_loaded: bool, + pub(crate) drill: super::CommitDrillDownState, +} + +impl CommitListState { + pub(crate) fn replace(&mut self, commits: Vec) { + self.loaded_count = commits.len(); + self.commits = commits; + self.commit_width_cache.set(None); + self.pending_fetch = false; + self.fully_loaded = false; + self.recompute_filter(); + } + + pub(crate) fn replace_first_page(&mut self, page: Vec, page_size: usize) { + let fully_loaded = page.len() < page_size; + self.replace(page); + self.fully_loaded = fully_loaded; + } + + pub(crate) fn append_page(&mut self, mut page: Vec, page_size: usize) { + let received = page.len(); + self.commits.append(&mut page); + self.loaded_count = self.commits.len(); + if received > 0 { + self.commit_width_cache.set(None); + self.recompute_filter(); + } + self.pending_fetch = false; + self.fully_loaded |= received < page_size; + } + + pub(crate) fn mark_pending(&mut self) -> bool { + if self.pending_fetch { + false + } else { + self.pending_fetch = true; + true + } + } + pub(crate) fn clear_pending(&mut self) { + self.pending_fetch = false; + } + pub(crate) fn recompute_filter(&mut self) { + self.commits_filter_cache.clear(); + if self.commit_search_query.is_empty() { + self.commits_filter_cache.extend(0..self.commits.len()); + } else { + let query = self.commit_search_query.lower(); + self.commits_filter_cache + .extend( + self.commits + .iter() + .enumerate() + .filter_map(|(index, commit)| { + commit.summary_lower.contains(query).then_some(index) + }), + ); + } + } +} + +impl std::ops::Deref for CommitListState { + type Target = super::CommitDrillDownState; + fn deref(&self) -> &Self::Target { + &self.drill + } +} + +impl std::ops::DerefMut for CommitListState { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.drill + } +} diff --git a/src/ui/log_view/mod.rs b/src/ui/log_view/mod.rs index 79e9a0ab..ad7fb491 100644 --- a/src/ui/log_view/mod.rs +++ b/src/ui/log_view/mod.rs @@ -1,204 +1,108 @@ -use crate::git::diff::{ChangedFile, CommitEntry}; -use crate::ui::SearchQuery; -use std::cell::Cell; +mod drill_down; +mod list; + +pub use drill_down::CommitDrillDownState; +pub use list::CommitListState; #[derive(Default)] pub struct LogView { - pub commits: Vec, - pub selected: usize, + pub list: CommitListState, pub diff_title: String, - pub drill_down: bool, - pub commit_files: Vec, - pub file_selected: usize, - pub commit_scroll_x: usize, - pub file_scroll_x: usize, - /// Memoized longest-summary char width, keyed by `commits.len()`. - pub(crate) commit_width_cache: Cell>, - /// Memoized longest-path char width for `commit_files`. - pub(crate) commit_files_width_cache: Cell>, - /// Kept in lockstep with `commits.len()` so the worker channel can compare - /// against an expected `skip` and drop stale pages. - pub(crate) loaded_count: usize, - /// Guards against duplicate page-fetch requests. - pub(crate) pending_fetch: bool, - /// The previous fetch returned fewer entries than requested. - pub(crate) fully_loaded: bool, - /// Commit-list incremental search. The cache holds indices into `commits` - /// whose summary matches the lowercased query, recomputed only when - /// commits or the query change. - pub commit_search_query: SearchQuery, - pub commit_search_active: bool, - pub(crate) commits_filter_cache: Vec, - /// Drill-down file-list incremental search; indices reference - /// `commit_files`. - pub file_search_query: SearchQuery, - pub file_search_active: bool, - pub(crate) commit_files_filter_cache: Vec, } -impl LogView { - /// Replace `commits` and invalidate the summary-width cache. Also resets - /// pagination bookkeeping because `commits` is no longer the result of - /// the previous page sequence. - pub(crate) fn set_commits(&mut self, commits: Vec) { - self.loaded_count = commits.len(); - self.commits = commits; - self.commit_width_cache.set(None); - self.pending_fetch = false; - self.fully_loaded = false; - self.recompute_commit_filter(); +impl std::ops::Deref for LogView { + type Target = CommitListState; + fn deref(&self) -> &Self::Target { + &self.list } +} - /// Install a freshly-fetched first page. - pub(crate) fn set_commits_from_first_page(&mut self, page: Vec, page_size: usize) { - let fully_loaded = page.len() < page_size; - self.set_commits(page); - self.fully_loaded = fully_loaded; +impl std::ops::DerefMut for LogView { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.list } +} - /// Append a freshly-fetched page to the tail. - pub(crate) fn append_page(&mut self, mut page: Vec, page_size: usize) { - let received = page.len(); - if received > 0 { - self.commits.append(&mut page); - self.loaded_count = self.commits.len(); - self.commit_width_cache.set(None); - self.recompute_commit_filter(); - } - self.pending_fetch = false; - if received < page_size { - self.fully_loaded = true; - } +impl LogView { + pub(crate) fn set_commits(&mut self, commits: Vec) { + self.list.replace(commits); } - /// Mark a fetch as in flight. Returns `false` if one was already pending. - pub(crate) fn mark_pending(&mut self) -> bool { - if self.pending_fetch { - return false; - } - self.pending_fetch = true; - true + pub(crate) fn set_commits_from_first_page( + &mut self, + commits: Vec, + page_size: usize, + ) { + self.list.replace_first_page(commits, page_size); } - /// Clear the pending flag without appending a page. - pub(crate) fn clear_pending(&mut self) { - self.pending_fetch = false; + pub(crate) fn set_commit_files(&mut self, files: Vec) { + self.list.drill.replace_files(files); } - /// Replace `commit_files` and invalidate the file-width cache. - pub(crate) fn set_commit_files(&mut self, files: Vec) { - self.commit_files = files; - self.commit_files_width_cache.set(None); - self.recompute_file_filter(); + pub(crate) fn recompute_commit_filter(&mut self) { + self.list.recompute_filter(); } - /// Exit drill-down so the upper pane shows the commit list again. pub fn reset_drill_down(&mut self) { - self.drill_down = false; - self.commit_files.clear(); - self.commit_files_width_cache.set(None); - self.file_selected = 0; - self.file_scroll_x = 0; - // Drop file-list search state so a later drill-in does not carry the - // previous commit's query into the new view. - self.file_search_active = false; - self.file_search_query.clear(); - self.commit_files_filter_cache.clear(); - } - - /// Refresh `commits_filter_cache` from `commits` and the current query. - pub(crate) fn recompute_commit_filter(&mut self) { - self.commits_filter_cache.clear(); - if self.commit_search_query.is_empty() { - self.commits_filter_cache.extend(0..self.commits.len()); - return; - } - let q = self.commit_search_query.lower(); - for (i, c) in self.commits.iter().enumerate() { - if c.summary_lower.contains(q) { - self.commits_filter_cache.push(i); - } - } + self.list.drill.reset(); } - /// Refresh `commit_files_filter_cache` from `commit_files` and the - /// current query. - pub(crate) fn recompute_file_filter(&mut self) { - self.commit_files_filter_cache.clear(); - if self.file_search_query.is_empty() { - self.commit_files_filter_cache - .extend(0..self.commit_files.len()); - return; - } - let q = self.file_search_query.lower(); - for (i, f) in self.commit_files.iter().enumerate() { - if f.search_lower.contains(q) { - self.commit_files_filter_cache.push(i); - } - } + #[cfg(test)] + pub(crate) fn enter_drill_down(&mut self) { + self.list.drill.drill_down = true; } pub fn start_commit_search(&mut self) { - self.commit_search_active = true; + self.list.commit_search_active = true; } - - /// Exit the commit-list search bar and clear any active query. pub fn cancel_commit_search(&mut self) { - self.commit_search_active = false; - self.commit_search_query.clear(); - self.recompute_commit_filter(); + self.list.commit_search_active = false; + self.list.commit_search_query.clear(); + self.list.recompute_filter(); } - - /// Hide the commit-list search bar. Returns `true` when the query was - /// empty and the call collapsed to a cancel. pub fn confirm_commit_search(&mut self) -> bool { - if self.commit_search_query.is_empty() { + if self.list.commit_search_query.is_empty() { self.cancel_commit_search(); true } else { - self.commit_search_active = false; + self.list.commit_search_active = false; false } } - pub fn commit_search_push(&mut self, ch: char) { - self.commit_search_query.push(ch); - self.recompute_commit_filter(); + self.list.commit_search_query.push(ch); + self.list.recompute_filter(); } - pub fn commit_search_pop(&mut self) { - self.commit_search_query.pop(); - self.recompute_commit_filter(); + self.list.commit_search_query.pop(); + self.list.recompute_filter(); } pub fn start_file_search(&mut self) { - self.file_search_active = true; + self.list.drill.file_search_active = true; } - pub fn cancel_file_search(&mut self) { - self.file_search_active = false; - self.file_search_query.clear(); - self.recompute_file_filter(); + self.list.drill.file_search_active = false; + self.list.drill.file_search_query.clear(); + self.list.drill.recompute_filter(); } - pub fn confirm_file_search(&mut self) -> bool { - if self.file_search_query.is_empty() { + if self.list.drill.file_search_query.is_empty() { self.cancel_file_search(); true } else { - self.file_search_active = false; + self.list.drill.file_search_active = false; false } } - pub fn file_search_push(&mut self, ch: char) { - self.file_search_query.push(ch); - self.recompute_file_filter(); + self.list.drill.file_search_query.push(ch); + self.list.drill.recompute_filter(); } - pub fn file_search_pop(&mut self) { - self.file_search_query.pop(); - self.recompute_file_filter(); + self.list.drill.file_search_query.pop(); + self.list.drill.recompute_filter(); } } diff --git a/src/ui/log_view/tests.rs b/src/ui/log_view/tests.rs index 6dabb7bf..86946a39 100644 --- a/src/ui/log_view/tests.rs +++ b/src/ui/log_view/tests.rs @@ -171,10 +171,8 @@ fn set_commit_files_seeds_filter_cache_under_active_query() { #[test] fn reset_drill_down_clears_file_search_state() { - let mut lv = LogView { - drill_down: true, - ..Default::default() - }; + let mut lv = LogView::default(); + lv.enter_drill_down(); lv.set_commit_files(vec![ChangedFile::unstaged_only( "readme.md".into(), crate::git::diff::StatusKind::Modified, From 5d9dce0d3d1a18142e2571459b87ef04f763c8a7 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 00:56:00 +0900 Subject: [PATCH 18/42] perf(tui): skip idle redraws --- docs/architecture/ui.md | 19 ++++ src/app/commit_log_fetch.rs | 8 +- src/app/snapshot_io.rs | 12 +- src/app/terminal_ctrl.rs | 8 +- src/app/tree.rs | 10 +- src/application/event_loop.rs | 110 +++++++++++++------ src/application/mod.rs | 1 + src/application/redraw.rs | 146 +++++++++++++++++++++++++ src/application/session_link.rs | 8 +- src/runtime/terminal/attention.rs | 3 +- src/runtime/terminal/lifecycle.rs | 21 +++- src/runtime/terminal/sync.rs | 4 +- src/runtime/terminal/tests/activity.rs | 34 ++++++ src/runtime/terminal/tests/mod.rs | 1 + src/ui/helpers.rs | 7 +- src/ui/mod.rs | 4 +- 16 files changed, 338 insertions(+), 58 deletions(-) create mode 100644 src/application/redraw.rs create mode 100644 src/runtime/terminal/tests/activity.rs diff --git a/docs/architecture/ui.md b/docs/architecture/ui.md index 557c05ae..c1221c22 100644 --- a/docs/architecture/ui.md +++ b/docs/architecture/ui.md @@ -173,6 +173,25 @@ legend와 폭을 다툴 일이 없다. 다이얼로그의 키는 그 아래 hint - **로그 경로** — 로그 파일은 시작 시 한 번 열리므로 활성 탭을 따라갈 수 없다. 첫 `--repo`를, 그것도 없으면 작업 디렉토리를 고정 기준으로 삼는다. +## Polling and dirty frames + +The attached TUI still polls its input and runtime queues every 16 ms so PTY +output, snapshot results, tree watcher events, and log pages are noticed +promptly. Polling is not a frame clock: `application::redraw::RedrawState` +requests a draw only for a state-changing event, a terminal-size change, or a +visible attention/search-caret phase change. The first frame is always drawn; +an unchanged idle tick does no `Terminal::draw` call. Terminal polling reports +output, title, resize, pane lifecycle, recovery, delayed synchronized-update, +and settled-title activity so a redraw cannot depend only on keyboard input. + +` r` remains an explicit full repaint: it clears ratatui's front buffer +and marks the next loop dirty, covering terminal programs that left cells the +diff renderer cannot know about. Input events also request a frame before their +effects are observed, which keeps prefix/overlay/focus changes visible even +when a PTY echo is delayed. Resize events and direct size observations share +the same dirty path, so a missed crossterm resize notification cannot leave the +new geometry unpainted. + ## Notice Row 힌트 바 바로 위 한 행. 평상시에는 `ui::mod::render_repo_header`가 repo 경로(`~/...` 형식으로 diff --git a/src/app/commit_log_fetch.rs b/src/app/commit_log_fetch.rs index d7d87e5d..4c21ec96 100644 --- a/src/app/commit_log_fetch.rs +++ b/src/app/commit_log_fetch.rs @@ -114,9 +114,9 @@ impl App { self.commit_log_controller.handle = Some(handle); } - pub(crate) fn poll_commit_log_page_fetch(&mut self) { + pub(crate) fn poll_commit_log_page_fetch(&mut self) -> bool { let Some(rx) = self.commit_log_controller.page_rx.as_ref() else { - return; + return false; }; match rx.try_recv() { Ok(msg) => { @@ -128,14 +128,16 @@ impl App { try_timed_join(h, REAP_TIMEOUT); } self.handle_commit_log_page_msg(msg); + true } - Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Empty) => false, Err(mpsc::TryRecvError::Disconnected) => { self.commit_log_controller.page_rx = None; if let Some(h) = self.commit_log_controller.handle.take() { try_timed_join(h, REAP_TIMEOUT); } self.log_view.clear_pending(); + true } } } diff --git a/src/app/snapshot_io.rs b/src/app/snapshot_io.rs index 018676e6..e54d4274 100644 --- a/src/app/snapshot_io.rs +++ b/src/app/snapshot_io.rs @@ -7,17 +7,20 @@ impl App { // collapses to one. Applying is NOT done here: this half touches no git // state, so every project can run it every tick to keep its unbounded // channel from growing, regardless of which tab is shown. - pub fn drain_snapshot(&mut self) { + pub fn drain_snapshot(&mut self) -> bool { + let mut received = false; while let Ok(msg) = self.snapshot.try_recv() { + received = true; self.pending_snapshot = Some(msg); } + received } // Applying runs a full `refresh_diff`, so this is for the on-screen project // only — hidden projects' snapshots wait in `pending_snapshot` and apply on // the first tick after their tab comes forward. - pub fn poll_snapshot(&mut self) { - self.drain_snapshot(); + pub fn poll_snapshot(&mut self) -> bool { + let received = self.drain_snapshot(); match self.pending_snapshot.take() { Some(SnapshotMsg::Ok(snapshot, mtimes)) => { self.ingest_snapshot(snapshot, mtimes); @@ -29,8 +32,9 @@ impl App { // snapshot should still apply the saved selection. Saving must // not be blocked by it — see `session_to_save`, which merges. } - None => {} + None => return received, } + true } // Split out so tests can drive the merge/auto-follow logic with deterministic diff --git a/src/app/terminal_ctrl.rs b/src/app/terminal_ctrl.rs index 2208fed5..d04b4ac4 100644 --- a/src/app/terminal_ctrl.rs +++ b/src/app/terminal_ctrl.rs @@ -6,11 +6,13 @@ use super::{App, Focus, NoticeKind}; use crate::runtime::terminal::TerminalFullscreen; impl App { - pub fn poll_terminal(&mut self) { + pub fn poll_terminal(&mut self) -> bool { // `TerminalState::poll` only signals exited panes; re-clamping focus // and fullscreen when the active pane was one of them stays here. - if !self.terminal.poll().is_empty() { + let (exited, mut changed) = self.terminal.poll_with_activity(); + if !exited.is_empty() { self.clamp_active_pane_after_removal(); + changed = true; } // The panes arrived, so the terminal half of the session — which pane // was active, whether the panel was fullscreen, whether the input focus @@ -21,7 +23,9 @@ impl App { && let Some(state) = self.pending_terminal.take() { self.restore_pane_focus(&state); + changed = true; } + changed } pub fn open_new_pane(&mut self) { diff --git a/src/app/tree.rs b/src/app/tree.rs index 9c1394f6..8e02852f 100644 --- a/src/app/tree.rs +++ b/src/app/tree.rs @@ -124,10 +124,10 @@ impl App { // Cheap half: no directory reread, no preview. Every project runs this each // tick so OS events can't pile up behind a hidden tab; rereading waits for // that tab to come forward. - pub fn drain_tree_watcher(&mut self) { + pub fn drain_tree_watcher(&mut self) -> bool { let changes = self.tree_watch.drain_changed(); if changes.is_empty() { - return; + return false; } if changes.unknown { // Events may have been dropped — no directory set can be trusted @@ -135,18 +135,20 @@ impl App { self.tree_dirty_all = true; } self.tree_dirty.extend(changes.dirs); + true } // Only the project on screen does this — several repos rereading per tick // would stall the active tab. - pub fn poll_tree_watcher(&mut self) { + pub fn poll_tree_watcher(&mut self) -> bool { self.drain_tree_watcher(); if self.mode != ViewMode::Tree || (self.tree_dirty.is_empty() && !self.tree_dirty_all) { - return; + return false; } let all = std::mem::take(&mut self.tree_dirty_all); let dirs = std::mem::take(&mut self.tree_dirty); self.refresh_tree_preserving_cursor_scoped(if all { None } else { Some(&dirs) }); + true } pub fn exit_tree_to_status(&mut self) { diff --git a/src/application/event_loop.rs b/src/application/event_loop.rs index 3a6c71df..0d536305 100644 --- a/src/application/event_loop.rs +++ b/src/application/event_loop.rs @@ -2,6 +2,7 @@ pub(crate) use crate::application::input::dispatch::ProjectContext; use crate::application::input::dispatch::{KeyOutcome, dispatch_key}; use crate::application::input::mouse::dispatch_mouse; use crate::application::input::paste::dispatch_paste; +use crate::application::redraw::{RedrawCause, RedrawState}; use crate::application::session_link::SessionLink; use crate::application::terminal_guard::TuiTerminal; use crate::workspace::Workspace; @@ -21,10 +22,13 @@ pub(crate) fn main_loop( mut link: SessionLink, ) -> anyhow::Result<()> { let blink_started = std::time::Instant::now(); + let mut redraw = RedrawState::new(); loop { // Whoever owns the tab list gets the first word each tick: attached, // the set may have changed under this client since the last frame. - link.sync(ws, ctx); + if link.sync(ws, ctx) { + redraw.request(RedrawCause::Session); + } if !link.is_connected() { tracing::info!("daemon connection lost"); // Reported rather than returned quietly. Leaving on a lost @@ -45,11 +49,15 @@ pub(crate) fn main_loop( let active = ws.active_index(); for (i, project) in ws.projects_mut().iter_mut().enumerate() { if i == active { - project.poll_snapshot(); + if project.poll_snapshot() { + redraw.request(RedrawCause::Snapshot); + } // Stays with the snapshot as active-only work: applying a // commit-log page can trigger a further prefetch and load a // commit diff synchronously. - project.poll_commit_log_page_fetch(); + if project.poll_commit_log_page_fetch() { + redraw.request(RedrawCause::Log); + } } else { project.drain_snapshot(); } @@ -58,11 +66,15 @@ pub(crate) fn main_loop( // consumed before the pipe fills and blocks the child. Acting on a // watcher event is active-only; a hidden project records the event. if i == active { - project.poll_tree_watcher(); + if project.poll_tree_watcher() { + redraw.request(RedrawCause::Tree); + } } else { project.drain_tree_watcher(); } - project.poll_terminal(); + if project.poll_terminal() { + redraw.request(RedrawCause::Terminal); + } } // Project-tab attention is client-local and means "not seen on this // screen". The project in front has just consumed its terminal events, @@ -71,6 +83,7 @@ pub(crate) fn main_loop( let size = terminal.size()?; let screen = Rect::new(0, 0, size.width, size.height); + redraw.observe_screen(size.width, size.height); if let Some(app) = ws.active() { let layouts: Vec<(crate::backend::PaneId, u16, u16)> = crate::ui::terminal_content_areas(app, screen, &cfg.layout) @@ -91,6 +104,13 @@ pub(crate) fn main_loop( .iter() .map(|project| project.terminal.has_unread_attention()) .collect(); + let has_attention = tab_attention.iter().any(|attention| *attention); + let attention_bright = crate::ui::project_tab::blink_is_bright(blink_started.elapsed()); + redraw.observe_attention(has_attention, attention_bright); + let caret_active = ws + .active() + .is_some_and(crate::app::App::search_overlay_active); + redraw.observe_caret(caret_active, crate::ui::current_caret_lit()); let active_tab = ws.active_index(); let empty_notice = ws.empty_notice().cloned(); let prefix_armed = ws.prefix_armed(); @@ -99,45 +119,47 @@ pub(crate) fn main_loop( // the borrow the projects need. let accent = ws.current_accent(); - let (app_opt, repo_input) = ws.render_parts(); - let tabs = crate::ui::Chrome { - repo_paths: &tab_paths, - attention: &tab_attention, - attention_bright: crate::ui::project_tab::blink_is_bright(blink_started.elapsed()), - active: active_tab, - repo_input, - }; - terminal.draw(|frame| match app_opt { - Some(app) => { - crate::ui::draw(frame, app, tabs, ss, ts, &cfg.layout, accent); - } - None => crate::ui::draw_empty( - frame, - tabs, - empty_notice.as_ref(), - ctx.leader, - prefix_armed, - cfg.mouse.enabled, - accent, - ), - })?; + if redraw.take() { + let (app_opt, repo_input) = ws.render_parts(); + let tabs = crate::ui::Chrome { + repo_paths: &tab_paths, + attention: &tab_attention, + attention_bright, + active: active_tab, + repo_input, + }; + terminal.draw(|frame| match app_opt { + Some(app) => { + crate::ui::draw(frame, app, tabs, ss, ts, &cfg.layout, accent); + } + None => crate::ui::draw_empty( + frame, + tabs, + empty_notice.as_ref(), + ctx.leader, + prefix_armed, + cfg.mouse.enabled, + accent, + ), + })?; + } // `tabs` above borrows the workspace for the draw; input needs it // mutably, so rebuild the same view over a snapshot of the dialog. - // Only the buffer is copied, and only on frames that see an event. + // Only the buffer is copied here; the frame itself may be skipped when + // no state or visual clock phase changed. let repo_input = ws.repo_input.clone(); let tabs = crate::ui::Chrome { repo_paths: &tab_paths, attention: &tab_attention, - attention_bright: crate::ui::project_tab::blink_is_bright(blink_started.elapsed()), + attention_bright, active: active_tab, repo_input: &repo_input, }; - // 16 ms ≈ 60 fps. The previous 50 ms tick noticeably lagged PTY echo - // on every keystroke (typing felt sticky). `event::poll` performs an - // OS-level wait when nothing is happening, so the higher cap doesn't - // burn CPU at idle. + // 16 ms ≈ 60 fps is only the polling latency cap. Unlike the old frame + // clock, an idle tick does not draw; the wait lets asynchronous PTY and + // watcher results be noticed without keeping a terminal frame alive. if event::poll(Duration::from_millis(16))? { let first = event::read()?; // Unix gets a real `Event::Paste` from crossterm; Windows never @@ -153,23 +175,39 @@ pub(crate) fn main_loop( // Ratatui's next draw will pick up the new size from // `Frame::area()`. An explicit clear() here only adds a // visible flash on resize without improving correctness. - Event::Resize(_, _) => {} + Event::Resize(_, _) => redraw.request(RedrawCause::Resize), Event::Key(key) => { + let pressed = key.kind == crossterm::event::KeyEventKind::Press; + if pressed { + redraw.request(RedrawCause::Input); + } let outcome = dispatch_key(ws, key); + let force_redraw = matches!(outcome, KeyOutcome::Redraw); if apply_outcome(terminal, ws, &mut link, outcome)? { return Ok(()); } + if force_redraw { + redraw.request(RedrawCause::Redraw); + } + } + Event::Paste(text) => { + redraw.request(RedrawCause::Input); + dispatch_paste(ws, &text); } - Event::Paste(text) => dispatch_paste(ws, &text), Event::Mouse(mouse) => { + redraw.request(RedrawCause::Input); let screen = Rect::new(0, 0, size.width, size.height); let outcome = dispatch_mouse(ws, tabs, mouse, screen, &cfg.layout, cfg.mouse.enabled); + let force_redraw = matches!(outcome, KeyOutcome::Redraw); if apply_outcome(terminal, ws, &mut link, outcome)? { return Ok(()); } + if force_redraw { + redraw.request(RedrawCause::Redraw); + } } - _ => {} + _ => redraw.request(RedrawCause::Input), } } } diff --git a/src/application/mod.rs b/src/application/mod.rs index 246d381d..5ba73616 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod attach; pub(crate) mod bootstrap; pub(crate) mod event_loop; pub(crate) mod input; +pub(crate) mod redraw; pub(crate) mod session_link; pub(crate) mod splash; pub(crate) mod terminal_guard; diff --git a/src/application/redraw.rs b/src/application/redraw.rs new file mode 100644 index 00000000..84f1a49a --- /dev/null +++ b/src/application/redraw.rs @@ -0,0 +1,146 @@ +//! Dirty-frame accounting for the attached TUI. +//! +//! Polling remains frequent so terminal output and watcher events are picked +//! up promptly, but an unchanged model does not need another frame. The +//! event loop records state-changing inputs and queue results here, while the +//! two clocks record visual changes that happen without an input event. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RedrawCause { + Initial, + Terminal, + Input, + Resize, + Snapshot, + Tree, + Log, + AttentionBlink, + CaretBlink, + Session, + Redraw, +} + +#[derive(Debug, Default)] +pub(crate) struct RedrawState { + dirty: bool, + screen: Option<(u16, u16)>, + attention_phase: Option, + caret_phase: Option, +} + +impl RedrawState { + pub(crate) fn new() -> Self { + let mut state = Self::default(); + state.request(RedrawCause::Initial); + state + } + + pub(crate) fn request(&mut self, _cause: RedrawCause) { + self.dirty = true; + } + + pub(crate) fn observe_screen(&mut self, width: u16, height: u16) { + let screen = (width, height); + if self.screen != Some(screen) { + self.screen = Some(screen); + self.request(RedrawCause::Resize); + } + } + + pub(crate) fn observe_attention(&mut self, has_attention: bool, bright: bool) { + let phase = has_attention.then_some(bright); + if self.attention_phase != phase { + self.attention_phase = phase; + self.request(RedrawCause::AttentionBlink); + } + } + + pub(crate) fn observe_caret(&mut self, active: bool, lit: bool) { + let phase = active.then_some(lit); + if self.caret_phase != phase { + self.caret_phase = phase; + self.request(RedrawCause::CaretBlink); + } + } + + pub(crate) fn take(&mut self) -> bool { + std::mem::take(&mut self.dirty) + } + + #[cfg(test)] + pub(crate) fn is_dirty(&self) -> bool { + self.dirty + } +} + +#[cfg(test)] +mod tests { + use super::{RedrawCause, RedrawState}; + + #[test] + fn initial_state_draws_once_then_stays_clean() { + let mut state = RedrawState::new(); + + assert!(state.take()); + assert!(!state.take()); + } + + #[test] + fn every_external_cause_marks_the_next_frame_dirty() { + let causes = [ + RedrawCause::Terminal, + RedrawCause::Input, + RedrawCause::Resize, + RedrawCause::Snapshot, + RedrawCause::Tree, + RedrawCause::Log, + RedrawCause::Session, + RedrawCause::Redraw, + ]; + + let mut state = RedrawState::new(); + state.take(); + for cause in causes { + state.request(cause); + assert!(state.take(), "{cause:?} must repaint"); + assert!(!state.is_dirty()); + } + } + + #[test] + fn screen_change_is_dirty_but_same_size_is_idle() { + let mut state = RedrawState::new(); + state.take(); + + state.observe_screen(100, 40); + assert!(state.take()); + state.observe_screen(100, 40); + assert!(!state.take()); + state.observe_screen(101, 40); + assert!(state.take()); + } + + #[test] + fn attention_and_caret_only_repaint_when_their_visible_phase_changes() { + let mut state = RedrawState::new(); + state.take(); + + state.observe_attention(true, true); + assert!(state.take()); + state.observe_attention(true, true); + assert!(!state.take()); + state.observe_attention(true, false); + assert!(state.take()); + state.observe_attention(false, true); + assert!(state.take()); + + state.observe_caret(true, true); + assert!(state.take()); + state.observe_caret(true, true); + assert!(!state.take()); + state.observe_caret(true, false); + assert!(state.take()); + state.observe_caret(false, true); + assert!(state.take()); + } +} diff --git a/src/application/session_link.rs b/src/application/session_link.rs index fc24d7f9..266af5a3 100644 --- a/src/application/session_link.rs +++ b/src/application/session_link.rs @@ -21,7 +21,8 @@ impl SessionLink { Self { client } } - pub(crate) fn sync(&mut self, ws: &mut Workspace, ctx: &ProjectContext) { + pub(crate) fn sync(&mut self, ws: &mut Workspace, ctx: &ProjectContext) -> bool { + let mut changed = false; for message in self.client.drain() { match message { ServerMessage::Repos { @@ -39,16 +40,19 @@ impl SessionLink { // Adopted whether or not this client asked: the colour may // have been picked in a browser, or in another terminal. ws.set_accent_index(accent); + changed = true; } // A refusal this client asked for — a path that is not a // directory, or one repository too many. ServerMessage::Error { message } => { ws.raise_notice(crate::app::NoticeKind::Project, message); + changed = true; } // Shown where the refusal above is shown, because the two are the // same answer to the same request. ServerMessage::Reloaded { summary } => { ws.raise_notice(crate::app::NoticeKind::Session, summary); + changed = true; } // Answered during the handshake; a later one would mean the // daemon restarted under this client. @@ -58,10 +62,12 @@ impl SessionLink { ServerMessage::Terminal { repo, event } => { if let HubServerMessage::Error { message } = event { notify_repo(ws, &repo, message); + changed = true; } } } } + changed } pub(crate) fn request(&mut self, ws: &mut Workspace, request: ProjectRequest) { diff --git a/src/runtime/terminal/attention.rs b/src/runtime/terminal/attention.rs index 97a074d6..a5e60904 100644 --- a/src/runtime/terminal/attention.rs +++ b/src/runtime/terminal/attention.rs @@ -62,7 +62,7 @@ impl TerminalState { .or_insert_with(|| TitleActivity::new(now)); } - pub(super) fn settle_title_attention(&mut self, now: Instant) { + pub(super) fn settle_title_attention(&mut self, now: Instant) -> bool { let mut attention = false; self.title_activity.retain(|_, activity| { let Some(settled) = activity.settled_attention(now) else { @@ -72,6 +72,7 @@ impl TerminalState { false }); self.unread_attention |= attention; + attention } pub(crate) fn raise_attention(&mut self) { diff --git a/src/runtime/terminal/lifecycle.rs b/src/runtime/terminal/lifecycle.rs index f4104d63..3abcc4f6 100644 --- a/src/runtime/terminal/lifecycle.rs +++ b/src/runtime/terminal/lifecycle.rs @@ -6,17 +6,32 @@ impl TerminalState { /// Drain pending backend events into pane emulators and pane metadata. /// Returns the pane ids the backend signalled as exited so the caller /// can run cross-cutting cleanup (focus redirect, fullscreen reset). + #[cfg(test)] pub fn poll(&mut self) -> Vec { self.poll_at(std::time::Instant::now()) } + #[cfg(test)] pub(crate) fn poll_at(&mut self, now: std::time::Instant) -> Vec { + self.poll_at_with_activity(now).0 + } + + /// Drain terminal events and report whether anything can affect the next + /// rendered frame. The ordinary `poll` API remains exit-only for callers + /// that need just lifecycle cleanup; the TUI also needs output, title, + /// resize, and delayed synchronized-update activity. + pub(crate) fn poll_with_activity(&mut self) -> (Vec, bool) { + self.poll_at_with_activity(std::time::Instant::now()) + } + + pub(crate) fn poll_at_with_activity(&mut self, now: std::time::Instant) -> (Vec, bool) { let mut exited = Vec::new(); let events: Vec = self .backend .as_mut() .map(|b| b.drain_events()) .unwrap_or_default(); + let had_events = !events.is_empty(); for event in events { match event { @@ -95,9 +110,9 @@ impl TerminalState { } // After the drain, so an update this tick's own output closed is never // cut short by the clock. - self.settle_sync_updates(now); - self.settle_title_attention(now); - exited + let settled_sync = self.settle_sync_updates(now); + let settled_title = self.settle_title_attention(now); + (exited, had_events || settled_sync || settled_title) } /// Allocate a new bare interactive-shell pane. diff --git a/src/runtime/terminal/sync.rs b/src/runtime/terminal/sync.rs index 1fd03caa..f77671a6 100644 --- a/src/runtime/terminal/sync.rs +++ b/src/runtime/terminal/sync.rs @@ -50,13 +50,14 @@ impl TerminalState { /// End every synchronized update that has outlived its timeout as of /// `now`, applying the bytes it was holding back. - pub(super) fn settle_sync_updates(&mut self, now: Instant) { + pub(super) fn settle_sync_updates(&mut self, now: Instant) -> bool { let expired: Vec = self .emulators .iter() .filter(|(_, emulator)| emulator.sync_expired(now)) .map(|(id, _)| *id) .collect(); + let had_expired = !expired.is_empty(); for pane in expired { let Some(emulator) = self.emulators.get_mut(&pane) else { continue; @@ -64,5 +65,6 @@ impl TerminalState { let events = emulator.settle_sync(); self.apply_emulator_events(pane, events, now); } + had_expired } } diff --git a/src/runtime/terminal/tests/activity.rs b/src/runtime/terminal/tests/activity.rs new file mode 100644 index 00000000..df8e2585 --- /dev/null +++ b/src/runtime/terminal/tests/activity.rs @@ -0,0 +1,34 @@ +use super::common::state_with_event_queue; +use crate::backend::BackendEvent; +use std::time::Instant; + +#[test] +fn terminal_poll_activity_is_false_when_no_backend_event_arrives() { + let (mut state, _events) = state_with_event_queue(); + + let (_, changed) = state.poll_at_with_activity(Instant::now()); + + assert!(!changed, "an idle terminal must not request a frame"); +} + +#[test] +fn terminal_poll_activity_reports_output_and_resize_events() { + let (mut state, events) = state_with_event_queue(); + state.create_pane_now().unwrap(); + let pane = state.panes[0].id; + + events.borrow_mut().push(BackendEvent::Output { + pane, + data: b"output".to_vec(), + }); + let (_, output_changed) = state.poll_at_with_activity(Instant::now()); + assert!(output_changed, "PTY output must request a frame"); + + events.borrow_mut().push(BackendEvent::Resized { + pane, + rows: 24, + cols: 80, + }); + let (_, resize_changed) = state.poll_at_with_activity(Instant::now()); + assert!(resize_changed, "a confirmed resize must request a frame"); +} diff --git a/src/runtime/terminal/tests/mod.rs b/src/runtime/terminal/tests/mod.rs index 9de1038a..e844d4e7 100644 --- a/src/runtime/terminal/tests/mod.rs +++ b/src/runtime/terminal/tests/mod.rs @@ -1,5 +1,6 @@ use super::*; +mod activity; mod common; mod lifecycle_tests; mod poll_tests; diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 242fa963..582f66b4 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -81,11 +81,16 @@ pub(crate) const CARET_BLINK: Duration = Duration::from_millis(530); /// Whether the caret is in the lit half of its cycle. Driven by our own clock /// because `Modifier::SLOW_BLINK` is widely ignored (Windows conhost among -/// them); the event loop's unconditional 16 ms redraw is the frame clock. +/// them); the event loop observes this phase and repaints only on a change. pub(crate) fn caret_lit(elapsed: Duration) -> bool { (elapsed.as_millis() / (CARET_BLINK.as_millis() / 2)).is_multiple_of(2) } +/// Current caret phase for the event loop's dirty-frame clock. +pub(crate) fn current_caret_lit() -> bool { + caret_lit(blink_phase()) +} + /// One origin for all frames, so every caret blinks in step. fn blink_phase() -> Duration { static ORIGIN: OnceLock = OnceLock::new(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 39f1a75a..1f73295d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -28,8 +28,8 @@ mod wall_clock; pub(crate) use chrome::{Chrome, chrome_rows, main_content_constraints}; pub(crate) use helpers::{ - char_offset, focused_border_style, jump_legend, path_extension, render_search_bar, - render_selectable_list, status_color, + char_offset, current_caret_lit, focused_border_style, jump_legend, path_extension, + render_search_bar, render_selectable_list, status_color, }; pub(crate) use hint_bar::{ HintClick, empty_hint_click_at, hint_click_at, hint_spans, render_hint_bar, From 41904fb976c9eb81da393fcca7ad7ea94718c621 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 00:48:24 +0900 Subject: [PATCH 19/42] fix(attach): bound terminal inbox processing --- docs/architecture/session.md | 20 ++++ src/application/event_loop.rs | 4 +- src/backend/hub_tests.rs | 25 +++-- src/daemon/terminal_link.rs | 151 ++++++++++++++++++++++++++---- src/daemon/terminal_link_tests.rs | 137 ++++++++++++++++++++++++--- src/daemon/wire.rs | 44 ++++++++- 6 files changed, 336 insertions(+), 45 deletions(-) diff --git a/docs/architecture/session.md b/docs/architecture/session.md index 129fdd53..5f129665 100644 --- a/docs/architecture/session.md +++ b/docs/architecture/session.md @@ -162,6 +162,26 @@ alternate screen을 쓰는 풀스크린 TUI를 나중에 다시 흘릴 방법은 repaint를 유발하는 제일 비싼 행동이 된다. 부수 효과로 **비소유 클라이언트가 곧 관전자**여서 별도 관전 모드가 필요 없고, 영역과 그리드가 다르면 렌더 경로가 clamp로 처리한다. +### attach 터미널 수신은 유계 FIFO다 + +attach 소켓 하나가 모든 저장소의 터미널 출력을 받지만, 각 저장소의 에뮬레이터는 TUI 렌더 틱에서 +자기 inbox를 소비한다(`daemon/terminal_link.rs`). 소켓 reader가 렌더보다 빠르다는 이유로 메모리를 +무제한 늘리거나, 반대로 최신 출력만 남기는 것은 둘 다 허용되지 않는다. 터미널 바이트는 snapshot이 +아니라 순서 있는 스트림이므로 중간 한 바이트를 버리면 뒤의 모든 escape sequence 해석이 틀어진다. + +- **drain은 저장소별 FIFO prefix만 꺼낸다.** 한 틱에 최대 64개 메시지와 256 KiB를 처리한다. 첫 + 메시지가 256 KiB보다 큰 replay frame이면 그것 하나는 꺼내야 head가 영원히 막히지 않는다. 예산 + 뒤의 메시지는 다음 틱에 남고, 모든 열린 저장소가 틱마다 각자 drain하므로 시끄러운 저장소 하나가 + 다른 탭의 입력·출력과 화면 갱신을 굶기지 않는다. +- **`Exited`도 같은 FIFO에 있다.** 그 앞에 도착한 `Output`이 byte 예산에서 잘리면 종료는 다음 + 틱까지 기다린다. `TerminalState::poll`은 `Exited`에서 pane과 에뮬레이터를 제거하므로 순서를 + 건너뛰면 마지막 출력이 사라진다. +- **연결 전체에 256 MiB output byte 상한을 둔다.** 이는 데몬 쪽 연결 큐가 합법적으로 보낼 수 있는 + 256개의 1 MiB replay frame과 같은 크기다. 저장소를 닫거나 drain하면 그 몫을 즉시 돌려준다. + 다음 메시지 전체가 상한에 들어오지 않으면 일부를 잘라 넣거나 이후 메시지를 계속 받지 않고 reader가 + 연결을 끝낸다. 그러면 TUI는 연결 손실을 명시적으로 보고하고, 사용자가 다시 attach할 때 허브의 + screen+since replay가 일관된 상태부터 복구한다. 손실된 구간 위에서 계속 그리는 경로는 없다. + ### 상태는 시간이 아니라 변화에 따라 읽는다 (`runtime/snapshot_watch.rs`) `git status` 한 번은 측정값으로 파일 260개 저장소에서 3 ms, 1만 개에서 23 ms, 5만 개에서 diff --git a/src/application/event_loop.rs b/src/application/event_loop.rs index 0d536305..e0b8164e 100644 --- a/src/application/event_loop.rs +++ b/src/application/event_loop.rs @@ -42,8 +42,8 @@ pub(crate) fn main_loop( ); } // Every project drains its queues, not just the visible one: the - // snapshot worker and PTY reader produce into unbounded channels - // regardless of which tab is on screen. Only the active project + // snapshot worker and PTY reader keep producing regardless of which + // tab is on screen. Only the active project // *applies* its snapshot, though — a background one waits in // `pending_snapshot` until its tab is shown. let active = ws.active_index(); diff --git a/src/backend/hub_tests.rs b/src/backend/hub_tests.rs index fdad934b..b30e6c68 100644 --- a/src/backend/hub_tests.rs +++ b/src/backend/hub_tests.rs @@ -43,7 +43,9 @@ impl Wired { } fn deliver(&self, event: HubServerMessage) { - self.router.deliver(REPO, TerminalMessage::Event(event)); + self.router + .deliver(REPO, TerminalMessage::Event(event)) + .expect("terminal inbox accepts the event"); } } @@ -170,15 +172,18 @@ fn a_pane_another_client_opened_arrives_without_claiming_the_focus() { #[test] fn output_and_exits_come_through_as_they_are() { let mut wired = wired(); - wired.router.deliver( - REPO, - TerminalMessage::Output { - pane: 1, - // Not valid UTF-8: a multi-byte sequence split across reads is - // routine, and the emulator is what reassembles it. - data: vec![0xe2, 0x94], - }, - ); + wired + .router + .deliver( + REPO, + TerminalMessage::Output { + pane: 1, + // Not valid UTF-8: a multi-byte sequence split across reads is + // routine, and the emulator is what reassembles it. + data: vec![0xe2, 0x94], + }, + ) + .expect("terminal inbox accepts the output"); wired.deliver(HubServerMessage::Exited { pane: 1 }); let events = wired.backend.drain_events(); diff --git a/src/daemon/terminal_link.rs b/src/daemon/terminal_link.rs index ec3b2b2f..897c8035 100644 --- a/src/daemon/terminal_link.rs +++ b/src/daemon/terminal_link.rs @@ -14,8 +14,21 @@ use crate::session::terminal::frame::{ }; use anyhow::Result; use std::collections::{HashMap, VecDeque}; +use std::fmt; use std::sync::{Arc, Mutex}; +/// Terminal bytes one attach connection may have waiting in memory. +/// +/// The daemon's terminal queue can legally replay this much at once (256 +/// one-MiB frames). Keeping the same ceiling here means a valid largest replay +/// can land, while a client that cannot keep up eventually reconnects instead +/// of growing without bound. +pub(crate) const TERMINAL_INBOX_BYTES: usize = 256 * 1024 * 1024; + +/// Work one repository may hand to its emulator in one render tick. +const TERMINAL_DRAIN_MESSAGES: usize = 64; +const TERMINAL_DRAIN_BYTES: usize = 256 * 1024; + /// One thing the daemon said about a repository's terminals. #[derive(Debug)] pub(crate) enum TerminalMessage { @@ -25,11 +38,56 @@ pub(crate) enum TerminalMessage { Output { pane: PaneId, data: Vec }, } +impl TerminalMessage { + fn output_bytes(&self) -> usize { + match self { + Self::Output { data, .. } => data.len(), + Self::Event(_) => 0, + } + } +} + +#[derive(Debug)] +pub(crate) struct TerminalInboxOverflow { + queued: usize, + incoming: usize, + limit: usize, +} + +impl fmt::Display for TerminalInboxOverflow { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "terminal inbox has {} queued bytes and cannot accept {} more (limit {})", + self.queued, self.incoming, self.limit + ) + } +} + +impl std::error::Error for TerminalInboxOverflow {} + +#[derive(Debug, Default)] +struct RouterState { + inboxes: HashMap>, + queued_output_bytes: usize, + overflowed: bool, +} + /// Per-repository inboxes, filled by the connection's reader thread and drained /// by each repository's backend. -#[derive(Debug, Default)] +#[derive(Debug)] pub(crate) struct TerminalRouter { - inboxes: Mutex>>, + state: Mutex, + byte_limit: usize, +} + +impl Default for TerminalRouter { + fn default() -> Self { + Self { + state: Mutex::new(RouterState::default()), + byte_limit: TERMINAL_INBOX_BYTES, + } + } } impl TerminalRouter { @@ -40,34 +98,91 @@ impl TerminalRouter { /// repository exists — and the replay happens only once, so dropping those /// would orphan panes. /// - /// Unbounded because dropping bytes corrupts a stream that cannot be - /// re-read; an inbox nobody drains belongs to a repository this client has - /// not opened a tab for yet, which is the very next thing it does. - pub(crate) fn deliver(&self, repo: &str, message: TerminalMessage) { - self.inboxes - .lock() - .expect("terminal inboxes poisoned") + /// Bytes are never discarded from a live stream. If accepting the whole + /// message would cross the connection-wide ceiling, the router is poisoned + /// and the socket reader ends the connection. A later attach is replayed a + /// coherent stream by the session hub; continuing after a partial drop + /// could never be repaired. + pub(crate) fn deliver( + &self, + repo: &str, + message: TerminalMessage, + ) -> Result<(), TerminalInboxOverflow> { + let incoming = message.output_bytes(); + let mut state = self.state.lock().expect("terminal inboxes poisoned"); + let fits = state + .queued_output_bytes + .checked_add(incoming) + .is_some_and(|total| total <= self.byte_limit); + if state.overflowed || !fits { + state.overflowed = true; + return Err(TerminalInboxOverflow { + queued: state.queued_output_bytes, + incoming, + limit: self.byte_limit, + }); + } + state.queued_output_bytes += incoming; + state + .inboxes .entry(repo.to_string()) .or_default() .push_back(message); + Ok(()) } - /// Everything filed for `repo` since the last drain. + /// A bounded FIFO prefix filed for `repo` since the last drain. + /// + /// The byte allowance is soft for the first message: replay frames can be + /// larger than it, and refusing to take the head would wedge the queue. + /// Message count also bounds control-only traffic. Leaving the remainder + /// for the next render tick keeps one loud repository from monopolising the + /// UI while preserving output-before-exit order. pub(crate) fn drain(&self, repo: &str) -> Vec { - let mut inboxes = self.inboxes.lock().expect("terminal inboxes poisoned"); - match inboxes.get_mut(repo) { - Some(inbox) => inbox.drain(..).collect(), - None => Vec::new(), + let mut state = self.state.lock().expect("terminal inboxes poisoned"); + let Some(inbox) = state.inboxes.get_mut(repo) else { + return Vec::new(); + }; + let mut drained = Vec::new(); + let mut bytes = 0usize; + while drained.len() < TERMINAL_DRAIN_MESSAGES { + let Some(next) = inbox.front() else { break }; + let next_bytes = next.output_bytes(); + if !drained.is_empty() && bytes.saturating_add(next_bytes) > TERMINAL_DRAIN_BYTES { + break; + } + let message = inbox.pop_front().expect("front was present"); + bytes += next_bytes; + drained.push(message); } + state.queued_output_bytes -= bytes; + drained } /// Forget the inboxes of repositories that are no longer open, including /// any that were filed for a repository this client never got a tab for. pub(crate) fn retain(&self, open: &[String]) { - self.inboxes - .lock() - .expect("terminal inboxes poisoned") - .retain(|repo, _| open.iter().any(|id| id == repo)); + let mut state = self.state.lock().expect("terminal inboxes poisoned"); + let mut removed_bytes = 0usize; + state.inboxes.retain(|repo, inbox| { + let keep = open.iter().any(|id| id == repo); + if !keep { + removed_bytes += inbox + .iter() + .map(TerminalMessage::output_bytes) + .sum::(); + } + keep + }); + state.queued_output_bytes -= removed_bytes; + } + + #[cfg(test)] + pub(super) fn with_byte_limit(byte_limit: usize) -> Self { + Self { + state: Mutex::new(RouterState::default()), + byte_limit, + } } } diff --git a/src/daemon/terminal_link_tests.rs b/src/daemon/terminal_link_tests.rs index 1bec9784..8a31c7ab 100644 --- a/src/daemon/terminal_link_tests.rs +++ b/src/daemon/terminal_link_tests.rs @@ -27,14 +27,16 @@ fn traffic_that_arrives_before_a_repository_has_a_reader_is_kept() { // dropping it would lose those panes for good. let router = TerminalRouter::default(); - router.deliver("r1", created(1)); - router.deliver( - "r1", - TerminalMessage::Output { - pane: 1, - data: b"prompt$ ".to_vec(), - }, - ); + router.deliver("r1", created(1)).unwrap(); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: b"prompt$ ".to_vec(), + }, + ) + .unwrap(); let inbox = router.drain("r1"); assert_eq!(inbox.len(), 2); @@ -44,8 +46,8 @@ fn traffic_that_arrives_before_a_repository_has_a_reader_is_kept() { #[test] fn each_repository_drains_only_its_own_traffic() { let router = TerminalRouter::default(); - router.deliver("r1", created(1)); - router.deliver("r2", created(2)); + router.deliver("r1", created(1)).unwrap(); + router.deliver("r2", created(2)).unwrap(); let first = router.drain("r1"); assert_eq!(first.len(), 1); @@ -58,7 +60,7 @@ fn each_repository_drains_only_its_own_traffic() { #[test] fn a_drained_inbox_is_empty_until_more_arrives() { let router = TerminalRouter::default(); - router.deliver("r1", created(1)); + router.deliver("r1", created(1)).unwrap(); assert_eq!(router.drain("r1").len(), 1); assert!(router.drain("r1").is_empty()); @@ -72,11 +74,120 @@ fn a_drained_inbox_is_empty_until_more_arrives() { fn closing_a_repository_drops_what_was_queued_for_it() { // Its backend went with its tab, so nothing will ever drain this. let router = TerminalRouter::default(); - router.deliver("r1", created(1)); - router.deliver("gone", created(9)); + router.deliver("r1", created(1)).unwrap(); + router.deliver("gone", created(9)).unwrap(); router.retain(&["r1".to_string()]); assert_eq!(router.drain("r1").len(), 1); assert!(router.drain("gone").is_empty()); } + +#[test] +fn a_drain_takes_a_bounded_fifo_prefix() { + let router = TerminalRouter::default(); + for pane in 1..=TERMINAL_DRAIN_MESSAGES as PaneId + 1 { + router.deliver("r1", created(pane)).unwrap(); + } + + let first = router.drain("r1"); + assert_eq!(first.len(), TERMINAL_DRAIN_MESSAGES); + assert_eq!(pane_of(&first[0]), 1); + assert_eq!( + pane_of(first.last().expect("the bounded batch is not empty")), + TERMINAL_DRAIN_MESSAGES as PaneId + ); + let second = router.drain("r1"); + assert_eq!(second.len(), 1); + assert_eq!(pane_of(&second[0]), TERMINAL_DRAIN_MESSAGES as PaneId + 1); +} + +#[test] +fn an_oversized_head_advances_but_exit_waits_behind_its_output() { + let router = TerminalRouter::default(); + let output = vec![b'x'; TERMINAL_DRAIN_BYTES + 1]; + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: output.clone(), + }, + ) + .unwrap(); + router + .deliver( + "r1", + TerminalMessage::Event(HubServerMessage::Exited { pane: 1 }), + ) + .unwrap(); + + let first = router.drain("r1"); + assert!(matches!( + first.as_slice(), + [TerminalMessage::Output { pane: 1, data }] if data == &output + )); + assert!(matches!( + router.drain("r1").as_slice(), + [TerminalMessage::Event(HubServerMessage::Exited { pane: 1 })] + )); +} + +#[test] +fn crossing_the_byte_ceiling_poison_disconnects_instead_of_making_a_hole() { + let router = TerminalRouter::with_byte_limit(4); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: b"abcd".to_vec(), + }, + ) + .unwrap(); + + let overflow = router.deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: b"e".to_vec(), + }, + ); + assert!(overflow.is_err()); + assert!( + router.deliver("r1", created(2)).is_err(), + "after one rejected frame, accepting later traffic would create a stream hole" + ); + + let kept = router.drain("r1"); + assert!(matches!( + kept.as_slice(), + [TerminalMessage::Output { data, .. }] if data == b"abcd" + )); +} + +#[test] +fn closing_a_repository_returns_its_share_of_the_byte_allowance() { + let router = TerminalRouter::with_byte_limit(4); + router + .deliver( + "gone", + TerminalMessage::Output { + pane: 1, + data: b"abcd".to_vec(), + }, + ) + .unwrap(); + + router.retain(&[]); + + router + .deliver( + "open", + TerminalMessage::Output { + pane: 2, + data: b"wxyz".to_vec(), + }, + ) + .expect("discarding an unopened repository frees its queued bytes"); +} diff --git a/src/daemon/wire.rs b/src/daemon/wire.rs index 02626497..401cb6c3 100644 --- a/src/daemon/wire.rs +++ b/src/daemon/wire.rs @@ -100,7 +100,7 @@ pub(super) fn read_routed( pane: output.pane, data: output.data, }, - ); + )?; return Ok(Some(Incoming::Routed)); } let message: ServerMessage = @@ -110,8 +110,48 @@ pub(super) fn read_routed( if let ServerMessage::Terminal { repo, event } = &message && !matches!(event, HubServerMessage::Error { .. }) { - terminals.deliver(repo, TerminalMessage::Event(event.clone())); + terminals.deliver(repo, TerminalMessage::Event(event.clone()))?; return Ok(Some(Incoming::Routed)); } Ok(Some(Incoming::Control(message))) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::daemon::frame::Frame; + + fn send_output(stream: &mut UnixStream, data: &[u8]) { + let payload = TerminalOutput { + repo: "r1".to_string(), + pane: 1, + data: data.to_vec(), + } + .encode() + .unwrap(); + write_frame(stream, &Frame::terminal(payload)).unwrap(); + stream.flush().unwrap(); + } + + #[test] + fn an_inbox_overflow_ends_routing_instead_of_skipping_a_frame() { + let (mut receiving, mut sending) = UnixStream::pair().unwrap(); + let router = TerminalRouter::with_byte_limit(3); + send_output(&mut sending, b"abc"); + send_output(&mut sending, b"d"); + + assert!(matches!( + read_routed(&mut receiving, &router), + Ok(Some(Incoming::Routed)) + )); + let error = match read_routed(&mut receiving, &router) { + Err(error) => error, + Ok(_) => panic!("the stream must end at the rejected frame"), + }; + assert!(error.to_string().contains("terminal inbox"), "{error:#}"); + assert!(matches!( + router.drain("r1").as_slice(), + [TerminalMessage::Output { data, .. }] if data == b"abc" + )); + } +} From 6b8a2ce7ae6e4202b141d2679949697d767331ad Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 00:52:33 +0900 Subject: [PATCH 20/42] fix(attach): bound control-only inbox traffic --- docs/architecture/session.md | 6 +++-- src/daemon/terminal_link.rs | 43 +++++++++++++++++++++++++------ src/daemon/terminal_link_tests.rs | 11 ++++++++ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/architecture/session.md b/docs/architecture/session.md index 5f129665..38607b0a 100644 --- a/docs/architecture/session.md +++ b/docs/architecture/session.md @@ -176,8 +176,10 @@ attach 소켓 하나가 모든 저장소의 터미널 출력을 받지만, 각 - **`Exited`도 같은 FIFO에 있다.** 그 앞에 도착한 `Output`이 byte 예산에서 잘리면 종료는 다음 틱까지 기다린다. `TerminalState::poll`은 `Exited`에서 pane과 에뮬레이터를 제거하므로 순서를 건너뛰면 마지막 출력이 사라진다. -- **연결 전체에 256 MiB output byte 상한을 둔다.** 이는 데몬 쪽 연결 큐가 합법적으로 보낼 수 있는 - 256개의 1 MiB replay frame과 같은 크기다. 저장소를 닫거나 drain하면 그 몫을 즉시 돌려준다. +- **연결 전체에 256 MiB output + 4,096 message 상한을 둔다.** output allowance는 데몬 쪽 연결 + 큐가 합법적으로 보낼 수 있는 256개의 1 MiB replay frame과 같은 크기이고, 별도 message 상한은 + control event-only 폭주도 저장량을 무제한 키우지 못하게 한다. 저장소를 닫거나 drain하면 그 몫을 + 즉시 돌려준다. 다음 메시지 전체가 상한에 들어오지 않으면 일부를 잘라 넣거나 이후 메시지를 계속 받지 않고 reader가 연결을 끝낸다. 그러면 TUI는 연결 손실을 명시적으로 보고하고, 사용자가 다시 attach할 때 허브의 screen+since replay가 일관된 상태부터 복구한다. 손실된 구간 위에서 계속 그리는 경로는 없다. diff --git a/src/daemon/terminal_link.rs b/src/daemon/terminal_link.rs index 897c8035..8acdcbc4 100644 --- a/src/daemon/terminal_link.rs +++ b/src/daemon/terminal_link.rs @@ -28,6 +28,8 @@ pub(crate) const TERMINAL_INBOX_BYTES: usize = 256 * 1024 * 1024; /// Work one repository may hand to its emulator in one render tick. const TERMINAL_DRAIN_MESSAGES: usize = 64; const TERMINAL_DRAIN_BYTES: usize = 256 * 1024; +/// Messages one attach connection may retain, including control-only traffic. +const TERMINAL_INBOX_MESSAGES: usize = 4096; /// One thing the daemon said about a repository's terminals. #[derive(Debug)] @@ -52,14 +54,17 @@ pub(crate) struct TerminalInboxOverflow { queued: usize, incoming: usize, limit: usize, + messages: usize, + message_limit: usize, } impl fmt::Display for TerminalInboxOverflow { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "terminal inbox has {} queued bytes and cannot accept {} more (limit {})", - self.queued, self.incoming, self.limit + "terminal inbox capacity exceeded: {} queued output bytes plus {} incoming \ + (limit {}), {} messages (limit {})", + self.queued, self.incoming, self.limit, self.messages, self.message_limit ) } } @@ -70,6 +75,7 @@ impl std::error::Error for TerminalInboxOverflow {} struct RouterState { inboxes: HashMap>, queued_output_bytes: usize, + queued_messages: usize, overflowed: bool, } @@ -79,6 +85,7 @@ struct RouterState { pub(crate) struct TerminalRouter { state: Mutex, byte_limit: usize, + message_limit: usize, } impl Default for TerminalRouter { @@ -86,6 +93,7 @@ impl Default for TerminalRouter { Self { state: Mutex::new(RouterState::default()), byte_limit: TERMINAL_INBOX_BYTES, + message_limit: TERMINAL_INBOX_MESSAGES, } } } @@ -110,19 +118,23 @@ impl TerminalRouter { ) -> Result<(), TerminalInboxOverflow> { let incoming = message.output_bytes(); let mut state = self.state.lock().expect("terminal inboxes poisoned"); - let fits = state + let bytes_fit = state .queued_output_bytes .checked_add(incoming) .is_some_and(|total| total <= self.byte_limit); - if state.overflowed || !fits { + let messages_fit = state.queued_messages < self.message_limit; + if state.overflowed || !bytes_fit || !messages_fit { state.overflowed = true; return Err(TerminalInboxOverflow { queued: state.queued_output_bytes, incoming, limit: self.byte_limit, + messages: state.queued_messages, + message_limit: self.message_limit, }); } state.queued_output_bytes += incoming; + state.queued_messages += 1; state .inboxes .entry(repo.to_string()) @@ -144,18 +156,20 @@ impl TerminalRouter { return Vec::new(); }; let mut drained = Vec::new(); - let mut bytes = 0usize; + let mut output_bytes = 0usize; while drained.len() < TERMINAL_DRAIN_MESSAGES { let Some(next) = inbox.front() else { break }; let next_bytes = next.output_bytes(); - if !drained.is_empty() && bytes.saturating_add(next_bytes) > TERMINAL_DRAIN_BYTES { + if !drained.is_empty() && output_bytes.saturating_add(next_bytes) > TERMINAL_DRAIN_BYTES + { break; } let message = inbox.pop_front().expect("front was present"); - bytes += next_bytes; + output_bytes += next_bytes; drained.push(message); } - state.queued_output_bytes -= bytes; + state.queued_output_bytes -= output_bytes; + state.queued_messages -= drained.len(); drained } @@ -164,6 +178,7 @@ impl TerminalRouter { pub(crate) fn retain(&self, open: &[String]) { let mut state = self.state.lock().expect("terminal inboxes poisoned"); let mut removed_bytes = 0usize; + let mut removed_messages = 0usize; state.inboxes.retain(|repo, inbox| { let keep = open.iter().any(|id| id == repo); if !keep { @@ -171,10 +186,12 @@ impl TerminalRouter { .iter() .map(TerminalMessage::output_bytes) .sum::(); + removed_messages += inbox.len(); } keep }); state.queued_output_bytes -= removed_bytes; + state.queued_messages -= removed_messages; } #[cfg(test)] @@ -182,6 +199,16 @@ impl TerminalRouter { Self { state: Mutex::new(RouterState::default()), byte_limit, + message_limit: TERMINAL_INBOX_MESSAGES, + } + } + + #[cfg(test)] + fn with_limits(byte_limit: usize, message_limit: usize) -> Self { + Self { + state: Mutex::new(RouterState::default()), + byte_limit, + message_limit, } } } diff --git a/src/daemon/terminal_link_tests.rs b/src/daemon/terminal_link_tests.rs index 8a31c7ab..d916a02d 100644 --- a/src/daemon/terminal_link_tests.rs +++ b/src/daemon/terminal_link_tests.rs @@ -166,6 +166,17 @@ fn crossing_the_byte_ceiling_poison_disconnects_instead_of_making_a_hole() { )); } +#[test] +fn control_only_traffic_is_bounded_too() { + let router = TerminalRouter::with_limits(usize::MAX, 1); + router.deliver("r1", created(1)).unwrap(); + + assert!( + router.deliver("r1", created(2)).is_err(), + "control events consume memory even though they carry no PTY bytes" + ); +} + #[test] fn closing_a_repository_returns_its_share_of_the_byte_allowance() { let router = TerminalRouter::with_byte_limit(4); From fa1bb80f79630348cdd456ae38bbd81c6ae72719 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 00:53:53 +0900 Subject: [PATCH 21/42] test(attach): cover inbox fairness budgets --- src/daemon/terminal_link_tests.rs | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/daemon/terminal_link_tests.rs b/src/daemon/terminal_link_tests.rs index d916a02d..5791a7cf 100644 --- a/src/daemon/terminal_link_tests.rs +++ b/src/daemon/terminal_link_tests.rs @@ -102,6 +102,45 @@ fn a_drain_takes_a_bounded_fifo_prefix() { assert_eq!(pane_of(&second[0]), TERMINAL_DRAIN_MESSAGES as PaneId + 1); } +#[test] +fn a_busy_repository_does_not_spend_another_repositories_budget() { + let router = TerminalRouter::default(); + for pane in 1..=TERMINAL_DRAIN_MESSAGES as PaneId + 1 { + router.deliver("busy", created(pane)).unwrap(); + } + router.deliver("quiet", created(999)).unwrap(); + + assert_eq!(router.drain("busy").len(), TERMINAL_DRAIN_MESSAGES); + let quiet = router.drain("quiet"); + assert_eq!(quiet.len(), 1); + assert_eq!(pane_of(&quiet[0]), 999); +} + +#[test] +fn the_byte_budget_leaves_the_next_output_for_the_next_drain() { + let router = TerminalRouter::default(); + let chunk = vec![b'x'; TERMINAL_DRAIN_BYTES / 2]; + for pane in 1..=3 { + router + .deliver( + "r1", + TerminalMessage::Output { + pane, + data: chunk.clone(), + }, + ) + .unwrap(); + } + + let first = router.drain("r1"); + assert_eq!(first.len(), 2); + assert_eq!(pane_of(&first[0]), 1); + assert_eq!(pane_of(&first[1]), 2); + let second = router.drain("r1"); + assert_eq!(second.len(), 1); + assert_eq!(pane_of(&second[0]), 3); +} + #[test] fn an_oversized_head_advances_but_exit_waits_behind_its_output() { let router = TerminalRouter::default(); From 3432459d92f1a7ff02e2361ea2e25088ec565f8f Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 01:04:40 +0900 Subject: [PATCH 22/42] test(attach): measure terminal inbox burst --- src/daemon/terminal_link.rs | 6 +++ src/daemon/terminal_link_tests.rs | 73 +++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/daemon/terminal_link.rs b/src/daemon/terminal_link.rs index 8acdcbc4..505c967a 100644 --- a/src/daemon/terminal_link.rs +++ b/src/daemon/terminal_link.rs @@ -211,6 +211,12 @@ impl TerminalRouter { message_limit, } } + + #[cfg(test)] + fn queued_for_test(&self) -> (usize, usize) { + let state = self.state.lock().expect("terminal inboxes poisoned"); + (state.queued_output_bytes, state.queued_messages) + } } /// One repository's end of the shared connection. diff --git a/src/daemon/terminal_link_tests.rs b/src/daemon/terminal_link_tests.rs index 5791a7cf..a383f37b 100644 --- a/src/daemon/terminal_link_tests.rs +++ b/src/daemon/terminal_link_tests.rs @@ -241,3 +241,76 @@ fn closing_a_repository_returns_its_share_of_the_byte_allowance() { ) .expect("discarding an unopened repository frees its queued bytes"); } + +#[test] +#[ignore = "release-only terminal inbox load measurement"] +fn measure_one_mb_per_second_terminal_inbox_drain() { + const BYTES_PER_SECOND: usize = 1_000_000; + const FRAMES_PER_SECOND: usize = 60; + const OUTPUT_CHUNK_BYTES: usize = 1024; + const FRAME_BUDGET_NS: u128 = 1_000_000_000 / FRAMES_PER_SECOND as u128; + + let router = TerminalRouter::default(); + let mut produced_bytes = 0usize; + let mut drained_bytes = 0usize; + let mut peak_queued_bytes = 0usize; + let mut peak_queued_messages = 0usize; + let mut drain_calls = 0usize; + let mut total_drain_ns = 0u128; + let mut max_drain_ns = 0u128; + + for frame in 0..FRAMES_PER_SECOND { + let frame_bytes = BYTES_PER_SECOND / FRAMES_PER_SECOND + + usize::from(frame < BYTES_PER_SECOND % FRAMES_PER_SECOND); + let mut remaining = frame_bytes; + while remaining > 0 { + let chunk_bytes = remaining.min(OUTPUT_CHUNK_BYTES); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: vec![b'x'; chunk_bytes], + }, + ) + .unwrap(); + produced_bytes += chunk_bytes; + remaining -= chunk_bytes; + } + + let (queued_bytes, queued_messages) = router.queued_for_test(); + peak_queued_bytes = peak_queued_bytes.max(queued_bytes); + peak_queued_messages = peak_queued_messages.max(queued_messages); + + let started = std::time::Instant::now(); + let drained = router.drain("r1"); + let elapsed_ns = started.elapsed().as_nanos(); + drain_calls += 1; + total_drain_ns += elapsed_ns; + max_drain_ns = max_drain_ns.max(elapsed_ns); + drained_bytes += drained + .iter() + .map(TerminalMessage::output_bytes) + .sum::(); + } + + let (remaining_bytes, remaining_messages) = router.queued_for_test(); + let max_frame_budget_basis_points = max_drain_ns * 10_000 / FRAME_BUDGET_NS; + println!( + "1 MB/s terminal inbox over {FRAMES_PER_SECOND} simulated frames: \ + peak_queued_bytes={peak_queued_bytes} peak_queued_messages={peak_queued_messages} \ + drain_calls={drain_calls} total_drain_ns={total_drain_ns} \ + max_drain_ns={max_drain_ns} frame_budget_ns={FRAME_BUDGET_NS} \ + max_frame_budget_basis_points={max_frame_budget_basis_points}" + ); + + assert_eq!(produced_bytes, BYTES_PER_SECOND); + assert_eq!(drained_bytes, BYTES_PER_SECOND); + assert_eq!( + peak_queued_bytes, + BYTES_PER_SECOND.div_ceil(FRAMES_PER_SECOND) + ); + assert_eq!(peak_queued_messages, 17); + assert_eq!(drain_calls, FRAMES_PER_SECOND); + assert_eq!((remaining_bytes, remaining_messages), (0, 0)); +} From ad10866ba23bbb288f9d88325e8f0ad022fd3c61 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 00:46:28 +0900 Subject: [PATCH 23/42] refactor(viewer): split repository workspace boundaries --- viewer-ui/src/hooks/useAppViewModel.ts | 34 +- viewer-ui/src/hooks/useRepoData.ts | 102 ++++++ viewer-ui/src/hooks/useRepoPaneActions.ts | 54 +++ .../src/hooks/useRepoViewPersistence.test.ts | 99 +++++ viewer-ui/src/hooks/useRepoViewPersistence.ts | 91 +++++ viewer-ui/src/hooks/useRepoWorkspace.ts | 341 +++++------------- 6 files changed, 460 insertions(+), 261 deletions(-) create mode 100644 viewer-ui/src/hooks/useRepoData.ts create mode 100644 viewer-ui/src/hooks/useRepoPaneActions.ts create mode 100644 viewer-ui/src/hooks/useRepoViewPersistence.test.ts create mode 100644 viewer-ui/src/hooks/useRepoViewPersistence.ts diff --git a/viewer-ui/src/hooks/useAppViewModel.ts b/viewer-ui/src/hooks/useAppViewModel.ts index 40092e31..57c80e54 100644 --- a/viewer-ui/src/hooks/useAppViewModel.ts +++ b/viewer-ui/src/hooks/useAppViewModel.ts @@ -31,20 +31,26 @@ export function useAppViewModel() { ...layout.guards, }); const workspace = useRepoWorkspace({ - repo: tabs.repo, - repos: tabs.repos, - authed, - hot: tabs.hot, - clockSkewMs: tabs.clockSkewMs, - resumeTick, - handle, - shell: layout.shell, - viewKnown: layout.viewCovers(tabs.repo), - rememberedView: layout.viewOf(tabs.repo), - latestView: layout.rememberedViewFor, - rememberView: layout.rememberView, - maximizedPanelOf: layout.maximizedPanelOf, - setMaximizedFor: layout.setMaximizedFor, + project: { + repo: tabs.repo, + repos: tabs.repos, + authed, + hot: tabs.hot, + clockSkewMs: tabs.clockSkewMs, + resumeTick, + handle, + }, + view: { + known: layout.viewCovers(tabs.repo), + remembered: layout.viewOf(tabs.repo), + latest: layout.rememberedViewFor, + remember: layout.rememberView, + }, + layout: { + shell: layout.shell, + maximizedPanelOf: layout.maximizedPanelOf, + setMaximizedFor: layout.setMaximizedFor, + }, }); const { selectOpenedRepo, closeRepo } = useRepoActions({ diff --git a/viewer-ui/src/hooks/useRepoData.ts b/viewer-ui/src/hooks/useRepoData.ts new file mode 100644 index 00000000..c021c540 --- /dev/null +++ b/viewer-ui/src/hooks/useRepoData.ts @@ -0,0 +1,102 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { HotConfig } from "../api"; +import type { Pane, Tab } from "../types"; +import { useHotClock } from "./ui/useHotClock"; +import { useLog } from "./useLog"; +import { useStatus } from "./useStatus"; + +export interface RepoDataArgs { + repo: string | null; + authed: boolean | null; + hot: HotConfig | null; + clockSkewMs: number | null; + resumeTick: number; + handle: (error: unknown) => void; +} + +/** Repository data and screen state that are invalidated together on a switch. */ +export function useRepoData({ + repo, + authed, + hot, + clockSkewMs, + resumeTick, + handle, +}: RepoDataArgs) { + const [tab, setTab] = useState("status"); + const [filter, setFilter] = useState(""); + const [filterOpen, setFilterOpen] = useState(false); + const [pane, setPane] = useState({ kind: "empty" }); + const [shownRepo, setShownRepo] = useState(repo); + if (shownRepo !== repo) { + setShownRepo(repo); + setPane({ kind: "empty" }); + setTab("status"); + } + + const paneRequestRef = useRef(0); + const bumpPaneRequest = useCallback(() => { + paneRequestRef.current += 1; + }, []); + const clearPane = useCallback(() => setPane({ kind: "empty" }), []); + const { status } = useStatus({ + repo, + authed, + resumeTick, + tab, + pane, + setPane, + handle, + paneRequestRef, + }); + const hotWindowMs = hot?.enabled ? hot.window_secs * 1000 : 0; + const now = useHotClock(status?.files, hotWindowMs, clockSkewMs ?? 0); + const log = useLog({ + repo, + authed, + tab, + filter, + head: status ? (status.head ?? null) : undefined, + handle, + }); + + useLayoutEffect(() => { + bumpPaneRequest(); + log.setCommitDrillDown(null); + log.resetLog(); + }, [repo, bumpPaneRequest, log.setCommitDrillDown, log.resetLog]); + + const normalizedFilter = filter.toLowerCase(); + const files = useMemo( + () => + (status?.files ?? []).filter((file) => + file.path.toLowerCase().includes(normalizedFilter), + ), + [status?.files, normalizedFilter], + ); + const visibleCommitFiles = useMemo( + () => + (log.commitDrillDown?.files ?? []).filter( + (file) => + file.path.toLowerCase().includes(normalizedFilter) || + file.old_path?.toLowerCase().includes(normalizedFilter), + ), + [log.commitDrillDown?.files, normalizedFilter], + ); + const aheadOids = useMemo( + () => + new Set( + log.commits + .slice(0, status?.tracking?.ahead ?? 0) + .map((commit) => commit.oid), + ), + [log.commits, status?.tracking?.ahead], + ); + + return { + screen: { tab, setTab, filter, setFilter, filterOpen, setFilterOpen, pane, setPane }, + request: { paneRequestRef, bumpPaneRequest, clearPane }, + status: { value: status, files, now, hotWindowMs }, + log: { ...log, aheadOids, visibleCommitFiles }, + }; +} diff --git a/viewer-ui/src/hooks/useRepoPaneActions.ts b/viewer-ui/src/hooks/useRepoPaneActions.ts new file mode 100644 index 00000000..116e2ad3 --- /dev/null +++ b/viewer-ui/src/hooks/useRepoPaneActions.ts @@ -0,0 +1,54 @@ +import { useMemo, useRef, useState } from "react"; +import type { Status } from "../api"; +import type { MobileView, Pane } from "../types"; +import type { CommitDrillDown } from "./useLog"; +import { usePaneOpeners } from "./usePaneOpeners"; + +interface RepoPaneActionsArgs { + repo: string | null; + handle: (error: unknown) => void; + pane: Pane; + setPane: React.Dispatch>; + paneRequestRef: React.MutableRefObject; + setCommitDrillDown: (value: CommitDrillDown | null) => void; + status: Status | null; +} + +/** Pane request coordination and the UI state changed by those requests. */ +export function useRepoPaneActions({ + repo, + handle, + pane, + setPane, + paneRequestRef, + setCommitDrillDown, + status, +}: RepoPaneActionsArgs) { + const [mobileView, setMobileView] = useState("files"); + const [previewRendered, setPreviewRendered] = useState(true); + const statusRef = useRef(status); + statusRef.current = status; + const openers = usePaneOpeners({ + repo, + handle, + setPane, + paneRequestRef, + setCommitDrillDown, + setMobileView, + setPreviewRendered, + statusRef, + }); + + return useMemo( + () => ({ + openers, + pane, + setPane, + previewRendered, + setPreviewRendered, + mobileView, + setMobileView, + }), + [openers, pane, setPane, previewRendered, mobileView], + ); +} diff --git a/viewer-ui/src/hooks/useRepoViewPersistence.test.ts b/viewer-ui/src/hooks/useRepoViewPersistence.test.ts new file mode 100644 index 00000000..dd66979e --- /dev/null +++ b/viewer-ui/src/hooks/useRepoViewPersistence.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RepoView } from "../api"; +import { blankView, workdirFile } from "../lib/repoView"; +import type { UsePaneOpenersResult } from "./usePaneOpeners"; +import { useRepoViewPersistence } from "./useRepoViewPersistence"; + +afterEach(cleanup); + +function harness(known: boolean, remembered?: RepoView) { + const store: Record = remembered ? { r1: remembered } : {}; + const remember = vi.fn((repo: string | null, view: RepoView) => { + if (repo) store[repo] = view; + }); + const openers: UsePaneOpenersResult = { + openDiff: vi.fn(), + openFile: vi.fn(), + openCommit: vi.fn(), + openCommitFileDiff: vi.fn(), + openCommitFiles: vi.fn(async () => undefined), + showOtherFace: vi.fn(), + }; + const setTab = vi.fn(); + const clearPane = vi.fn(); + const rendered = renderHook( + (props: { known: boolean; remembered?: RepoView }) => + useRepoViewPersistence({ + repo: "r1", + known: props.known, + remembered: props.remembered, + latest: (repo) => store[repo], + remember, + setTab, + clearPane, + openers, + }), + { initialProps: { known, remembered } }, + ); + return { ...rendered, remember, openers, setTab, clearPane, store }; +} + +describe("useRepoViewPersistence grouped contract", () => { + it("복원은_opener를_호출하지만_다시_기록하지_않는다", () => { + const remembered = { + ...blankView(), + tab: "tree" as const, + file: workdirFile("src/a.ts", "diff"), + }; + const h = harness(true, remembered); + + expect(h.setTab).toHaveBeenCalledWith("tree"); + expect(h.openers.openDiff).toHaveBeenCalledWith("src/a.ts", { + restoring: true, + }); + expect(h.remember).not.toHaveBeenCalled(); + }); + + it("복원_응답_전의_선택은_보존하고_옛_기억_위에_합친다", () => { + const old = { + ...blankView(), + tab: "tree" as const, + tree_expanded: ["src"], + }; + const h = harness(false); + act(() => h.result.current.asked.openFile("README.md")); + h.store.r1 = old; + h.rerender({ known: true, remembered: old }); + + expect(h.openers.openFile).toHaveBeenCalledWith("README.md"); + expect(h.openers.openFile).not.toHaveBeenCalledWith(expect.anything(), { + restoring: true, + }); + expect(h.remember).toHaveBeenLastCalledWith("r1", { + ...old, + file: workdirFile("README.md", "source"), + }); + }); + + it("opener_탭_drilldown_tree_선택을_하나의_기록_경계로_보낸다", async () => { + const h = harness(true, blankView()); + act(() => h.result.current.chooseTab("log")); + act(() => h.result.current.asked.openDiff("a.ts")); + act(() => h.result.current.asked.openCommit("deadbeef")); + act(() => h.result.current.noteTree(["src", "src/lib"])); + act(() => h.result.current.forgetPane()); + + expect(h.setTab).toHaveBeenCalledWith("log"); + expect(h.openers.openDiff).toHaveBeenCalledWith("a.ts"); + expect(h.openers.openCommit).toHaveBeenCalledWith("deadbeef"); + expect(h.clearPane).toHaveBeenCalled(); + expect(h.store.r1).toEqual({ + tab: "log", + file: null, + tree_expanded: ["src", "src/lib"], + }); + }); +}); diff --git a/viewer-ui/src/hooks/useRepoViewPersistence.ts b/viewer-ui/src/hooks/useRepoViewPersistence.ts new file mode 100644 index 00000000..edf0d6b7 --- /dev/null +++ b/viewer-ui/src/hooks/useRepoViewPersistence.ts @@ -0,0 +1,91 @@ +import { useCallback, useMemo } from "react"; +import type { Commit, RepoView } from "../api"; +import { commitFile, workdirFile } from "../lib/repoView"; +import type { FileSource, Tab } from "../types"; +import type { UsePaneOpenersResult } from "./usePaneOpeners"; +import { useRepoViewMemory } from "./useRepoViewMemory"; + +interface RepoViewPersistenceArgs { + repo: string | null; + known: boolean; + remembered: RepoView | undefined; + latest: (repo: string) => RepoView | undefined; + remember: (repo: string | null, view: RepoView) => void; + setTab: React.Dispatch>; + clearPane: () => void; + openers: UsePaneOpenersResult; +} + +/** Record explicit choices while keeping restore traffic out of persistence. */ +export function useRepoViewPersistence({ + repo, + known, + remembered, + latest, + remember, + setTab, + clearPane, + openers, +}: RepoViewPersistenceArgs) { + const memory = useRepoViewMemory({ + repo, + known, + remembered, + latest, + remember, + setTab, + openDiff: openers.openDiff, + openFile: openers.openFile, + openCommitFileDiff: openers.openCommitFileDiff, + }); + const { noteFile, noteTab, noteTree } = memory; + const chooseTab = useCallback( + (next: Tab) => { + noteTab(next); + setTab(next); + }, + [noteTab, setTab], + ); + const forgetPane = useCallback(() => { + noteFile(null); + clearPane(); + }, [noteFile, clearPane]); + const asked = useMemo( + () => ({ + openDiff: (path: string) => { + noteFile(workdirFile(path, "diff")); + openers.openDiff(path); + }, + openFile: (path: string) => { + noteFile(workdirFile(path, "source")); + openers.openFile(path); + }, + openCommit: (oid: string) => { + noteFile(null); + openers.openCommit(oid); + }, + openCommitFiles: (commit: Commit) => { + noteFile(null); + return openers.openCommitFiles(commit); + }, + openCommitFileDiff: (oid: string, path: string) => { + noteFile(commitFile(oid, path, "diff")); + openers.openCommitFileDiff(oid, path); + }, + }), + [openers, noteFile], + ); + + const noteOtherFace = useCallback( + (source: FileSource, face: "source" | "diff") => { + noteFile( + source.kind === "commit" + ? commitFile(source.oid, source.path, face) + : workdirFile(source.path, face), + ); + }, + [noteFile], + ); + + return { ...memory, noteTree, chooseTab, forgetPane, asked, noteOtherFace }; +} diff --git a/viewer-ui/src/hooks/useRepoWorkspace.ts b/viewer-ui/src/hooks/useRepoWorkspace.ts index 756e7aec..29df404a 100644 --- a/viewer-ui/src/hooks/useRepoWorkspace.ts +++ b/viewer-ui/src/hooks/useRepoWorkspace.ts @@ -1,24 +1,14 @@ -import { - useCallback, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import type { Commit, HotConfig, Repo } from "../api"; -import type { Maximized, MobileView, Pane, Tab } from "../types"; -import { useHotClock } from "./ui/useHotClock"; +import { useCallback } from "react"; +import type { HotConfig, Repo, RepoView } from "../api"; +import { otherFace } from "../lib/otherFace"; +import type { Maximized } from "../types"; import { useDrillDownEviction } from "./useDrillDownEviction"; -import { useLog } from "./useLog"; -import { usePaneOpeners } from "./usePaneOpeners"; +import { useRepoData } from "./useRepoData"; +import { useRepoPaneActions } from "./useRepoPaneActions"; +import { useRepoViewPersistence } from "./useRepoViewPersistence"; import type { ShellLayout } from "./useShellLayout"; -import { useRepoViewMemory } from "./useRepoViewMemory"; -import { commitFile, workdirFile } from "../lib/repoView"; -import { otherFace } from "../lib/otherFace"; -import { useStatus } from "./useStatus"; -import type { RepoView } from "../api"; -interface UseRepoWorkspaceArgs { +export interface RepoProjectContract { repo: string | null; repos: Repo[]; authed: boolean | null; @@ -26,13 +16,17 @@ interface UseRepoWorkspaceArgs { clockSkewMs: number | null; resumeTick: number; handle: (error: unknown) => void; +} + +export interface RepoViewContract { + known: boolean; + remembered: RepoView | undefined; + latest: (repo: string) => RepoView | undefined; + remember: (repo: string | null, view: RepoView) => void; +} + +export interface RepoLayoutContract { shell: ShellLayout; - /** Whether the server has answered about this project yet. */ - viewKnown: boolean; - /** What this project was last showing, and where to record it now. */ - rememberedView: RepoView | undefined; - latestView: (repo: string) => RepoView | undefined; - rememberView: (repo: string | null, view: RepoView) => void; maximizedPanelOf: (repo: string | null) => Maximized; setMaximizedFor: ( repo: string | null, @@ -40,259 +34,112 @@ interface UseRepoWorkspaceArgs { ) => void; } -/** State and actions that belong to the repository currently on screen. */ -export function useRepoWorkspace({ - repo, - repos, - authed, - hot, - clockSkewMs, - resumeTick, - handle, - shell, - viewKnown, - rememberedView, - latestView, - rememberView, - maximizedPanelOf, - setMaximizedFor, -}: UseRepoWorkspaceArgs) { - const [tab, setTab] = useState("status"); - const [filter, setFilter] = useState(""); - const [filterOpen, setFilterOpen] = useState(false); - const [pane, setPane] = useState({ kind: "empty" }); - const [mobileView, setMobileView] = useState("files"); - const [previewRendered, setPreviewRendered] = useState(true); - // What the state above belongs to. A project change is applied *during this - // render* rather than from an effect: an effect leaves one render in which - // the pane and the tab are still the project just left, and everything - // reading them then — the view memory above all — has to be told to - // disbelieve what it is looking at. React re-renders with these before - // committing, so that render never happens. - const [shownRepo, setShownRepo] = useState(repo); - if (shownRepo !== repo) { - setShownRepo(repo); - setPane({ kind: "empty" }); - setTab("status"); - } - const paneRequestRef = useRef(0); - const bumpPaneRequest = useCallback(() => { - paneRequestRef.current += 1; - }, []); - const clearPane = useCallback(() => setPane({ kind: "empty" }), []); - - const { status } = useStatus({ - repo, - authed, - resumeTick, - tab, - pane, - setPane, - handle, - paneRequestRef, - }); - const hotWindowMs = hot?.enabled ? hot.window_secs * 1000 : 0; - const now = useHotClock(status?.files, hotWindowMs, clockSkewMs ?? 0); - const maximized = maximizedPanelOf(repo); - const setMaximized = useCallback( - (next: Maximized | ((previous: Maximized) => Maximized)) => - setMaximizedFor(repo, next), - [repo, setMaximizedFor], - ); +interface UseRepoWorkspaceArgs { + project: RepoProjectContract; + view: RepoViewContract; + layout: RepoLayoutContract; +} - // Three-valued on purpose: no status yet is not knowing, while a status - // without a head is knowing the server could not name one (unborn HEAD, or - // one it could not read) — the log must react to the second and hold still - // for the first. - const log = useLog({ - repo, - authed, - tab, - filter, - head: status ? (status.head ?? null) : undefined, - handle, - }); - // Read by the pane openers at the moment they act, so "does this still have a - // working copy" is answered from now rather than from whenever a callback was - // built. - const statusRef = useRef(status); - statusRef.current = status; - const openers = usePaneOpeners({ +/** Assemble the independently testable repository data, pane, and view seams. */ +export function useRepoWorkspace({ project, view, layout }: UseRepoWorkspaceArgs) { + const { repo, repos, authed, hot, clockSkewMs, resumeTick, handle } = project; + const data = useRepoData({ repo, authed, hot, clockSkewMs, resumeTick, handle }); + const paneActions = useRepoPaneActions({ repo, handle, - setPane, - paneRequestRef, - setCommitDrillDown: log.setCommitDrillDown, - setMobileView, - setPreviewRendered, - statusRef, + pane: data.screen.pane, + setPane: data.screen.setPane, + paneRequestRef: data.request.paneRequestRef, + setCommitDrillDown: data.log.setCommitDrillDown, + status: data.status.value, }); - - const memory = useRepoViewMemory({ + const persistence = useRepoViewPersistence({ repo, - known: viewKnown, - remembered: rememberedView, - latest: latestView, - remember: rememberView, - setTab, - openDiff: openers.openDiff, - openFile: openers.openFile, - openCommitFileDiff: openers.openCommitFileDiff, + known: view.known, + remembered: view.remembered, + latest: view.latest, + remember: view.remember, + setTab: data.screen.setTab, + clearPane: data.request.clearPane, + openers: paneActions.openers, }); - // Every way to change what this project is showing, each recording the choice - // it *is* rather than leaving the record to work it out from the screen - // afterwards (`useRepoViewMemory`). - const { noteFile, noteTab, noteTree } = memory; - const chooseTab = useCallback( - (next: Tab) => { - noteTab(next); - setTab(next); - }, - [noteTab], - ); - // Emptying the pane on purpose — out of a commit's file list — is a choice - // too, and the only one that is not an opener. - const forgetPane = useCallback(() => { - noteFile(null); - clearPane(); - }, [noteFile, clearPane]); - const asked = useMemo( - () => ({ - openDiff: (path: string) => { - noteFile(workdirFile(path, "diff")); - openers.openDiff(path); - }, - openFile: (path: string) => { - noteFile(workdirFile(path, "source")); - openers.openFile(path); - }, - // A whole commit's diff spans several files, so no single one names it. - openCommit: (oid: string) => { - noteFile(null); - openers.openCommit(oid); - }, - openCommitFiles: (commit: Commit) => { - noteFile(null); - return openers.openCommitFiles(commit); - }, - openCommitFileDiff: (oid: string, path: string) => { - noteFile(commitFile(oid, path, "diff")); - openers.openCommitFileDiff(oid, path); - }, - }), - [openers, noteFile], - ); - - // The rest of what a repository switch invalidates. The screen's own state is - // reset above, during the render; these belong to other hooks and to a ref, - // and none of them is read as "what this project is showing". - useLayoutEffect(() => { - bumpPaneRequest(); - log.setCommitDrillDown(null); - log.resetLog(); - }, [repo, bumpPaneRequest, log.setCommitDrillDown, log.resetLog]); - useDrillDownEviction( - log.commits, - log.commitDrillDown, - log.setCommitDrillDown, - bumpPaneRequest, - forgetPane, + data.log.commits, + data.log.commitDrillDown, + data.log.setCommitDrillDown, + data.request.bumpPaneRequest, + persistence.forgetPane, ); - const normalizedFilter = filter.toLowerCase(); - const files = useMemo( - () => - (status?.files ?? []).filter((file) => - file.path.toLowerCase().includes(normalizedFilter), - ), - [status?.files, normalizedFilter], - ); - const visibleCommitFiles = useMemo( - () => - (log.commitDrillDown?.files ?? []).filter( - (file) => - file.path.toLowerCase().includes(normalizedFilter) || - file.old_path?.toLowerCase().includes(normalizedFilter), - ), - [log.commitDrillDown?.files, normalizedFilter], + const maximized = layout.maximizedPanelOf(repo); + const setMaximized = useCallback( + (next: Maximized | ((previous: Maximized) => Maximized)) => + layout.setMaximizedFor(repo, next), + [repo, layout], ); - const aheadOids = useMemo( - () => - new Set( - log.commits - .slice(0, status?.tracking?.ahead ?? 0) - .map((commit) => commit.oid), - ), - [log.commits, status?.tracking?.ahead], + const showOtherFace = useCallback( + (fromHunk: number) => { + const pane = paneActions.pane; + const other = otherFace(pane); + if (other) { + persistence.noteOtherFace( + other.source, + other.want === "file" ? "source" : "diff", + ); + } + paneActions.openers.showOtherFace(pane, fromHunk); + }, + [paneActions, persistence], ); + return { - setPane, - setTab, - clearPane, + setPane: paneActions.setPane, + setTab: data.screen.setTab, + clearPane: data.request.clearPane, maximized, repoShell: repo ? { repository: { id: repo, current: repos.find((candidate) => candidate.id === repo), - status, + status: data.status.value, }, sidebar: { - tab, - filter, - setFilter, - filterOpen, - setFilterOpen, - files, - now, - hotWindowMs, - ...openers, - ...asked, - setTab: chooseTab, + tab: data.screen.tab, + filter: data.screen.filter, + setFilter: data.screen.setFilter, + filterOpen: data.screen.filterOpen, + setFilterOpen: data.screen.setFilterOpen, + files: data.status.files, + now: data.status.now, + hotWindowMs: data.status.hotWindowMs, + ...paneActions.openers, + ...persistence.asked, + setTab: persistence.chooseTab, authed, handle, - bumpPaneRequest, - ...log, - aheadOids, - visibleCommitFiles, - // The tree's half of the remembered view: the shape to put it back - // into, and where the shape it ends up in is reported back to. - restoreTree: rememberedView?.tree_expanded ?? [], - restoreKnown: viewKnown, - onTreeExpanded: noteTree, - clearPane: forgetPane, - touched: memory.touched, + bumpPaneRequest: data.request.bumpPaneRequest, + ...data.log, + restoreTree: view.remembered?.tree_expanded ?? [], + restoreKnown: view.known, + onTreeExpanded: persistence.noteTree, + clearPane: persistence.forgetPane, + touched: persistence.touched, }, filePane: { repo, - pane, - previewRendered, - setPreviewRendered, - // Bound to the pane here rather than in the component, which has no - // business knowing what a pane is made of. - showOtherFace: (fromHunk: number) => { - // The same file, its other face — which is what the pane's own - // source says, and what the opener is about to fetch. - const other = otherFace(pane); - if (other) { - const face = other.want === "file" ? "source" : "diff"; - noteFile( - other.source.kind === "commit" - ? commitFile(other.source.oid, other.source.path, face) - : workdirFile(other.source.path, face), - ); - } - openers.showOtherFace(pane, fromHunk); - }, + pane: paneActions.pane, + previewRendered: paneActions.previewRendered, + setPreviewRendered: paneActions.setPreviewRendered, + showOtherFace, }, layout: { - ...shell, + ...layout.shell, maximized, setMaximized, - mobileView, - setMobileView, + mobileView: paneActions.mobileView, + setMobileView: paneActions.setMobileView, }, } : null, From df807ad3e7f5778b42ddc135653790cffd900505 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 00:51:03 +0900 Subject: [PATCH 24/42] perf(viewer): retain unchanged repository snapshots --- viewer-ui/src/hooks/useRepoPoll.test.ts | 105 ++++++++++++++++++++++-- viewer-ui/src/hooks/useRepoPoll.ts | 10 ++- viewer-ui/src/lib/repoSnapshot.ts | 26 ++++++ 3 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 viewer-ui/src/lib/repoSnapshot.ts diff --git a/viewer-ui/src/hooks/useRepoPoll.test.ts b/viewer-ui/src/hooks/useRepoPoll.test.ts index 870eba7b..cd6969d6 100644 --- a/viewer-ui/src/hooks/useRepoPoll.test.ts +++ b/viewer-ui/src/hooks/useRepoPoll.test.ts @@ -10,8 +10,8 @@ // Rendered under StrictMode on purpose: it replays updaters and effects, the // way the batching this logic must survive does. -import { StrictMode, createElement } from "react"; -import { act, cleanup, renderHook } from "@testing-library/react"; +import { Profiler, StrictMode, createElement, memo } from "react"; +import { act, cleanup, render, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useRepoPoll, type UseRepoPollArgs } from "./useRepoPoll"; @@ -87,12 +87,14 @@ function args(): UseRepoPollArgs { }; } -function mount() { +function mount(onRender?: () => void, stable = args()) { // One args object for the hook's lifetime: fresh callbacks each render are // new dependencies for the polling effect, which would restart it per render // and turn `nextPoll` into something other than one timer-driven poll. - const stable = args(); - return renderHook(() => useRepoPoll(stable), { + return renderHook(() => { + onRender?.(); + return useRepoPoll(stable); + }, { wrapper: ({ children }) => createElement(StrictMode, null, children), }); } @@ -118,7 +120,6 @@ describe("useRepoPoll active-repo writes", () => { vi.useRealTimers(); vi.clearAllMocks(); }); - it("첫_폴이_연_프로젝트는_되쓰지_않는다", async () => { repos.mockResolvedValue(bootstrap("r2")); const { result } = mount(); @@ -206,3 +207,95 @@ describe("useRepoPoll active-repo writes", () => { expect(written()).toEqual(["r2", "r1"]); }); }); + +describe("useRepoPoll snapshot identity", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("동일한_bootstrap_10회는_repos와_hot_identity와_render를_유지한다", async () => { + repos.mockImplementation(async () => bootstrap("r2")); + let renders = 0; + let profileCommits = 0; + let latest: ReturnType | undefined; + const stable = args(); + const Probe = memo((_: Pick, "repos" | "hot">) => { + renders += 1; + return createElement( + Profiler, + { id: "eight-pane", onRender: () => { profileCommits += 1; } }, + ...Array.from({ length: 8 }, (_, index) => + createElement("div", { key: index }, `pane ${index + 1}`), + ), + ); + }); + function Harness() { + latest = useRepoPoll(stable); + return createElement(Probe, { repos: latest.repos, hot: latest.hot }); + } + render(createElement(Harness)); + await flush(); + const firstRepos = latest!.repos; + const firstHot = latest!.hot; + const settledRenders = renders; + const settledCommits = profileCommits; + for (let i = 0; i < 10; i += 1) await nextPoll(); + + expect(latest!.repos).toBe(firstRepos); + expect(latest!.hot).toBe(firstHot); + expect(renders).toBe(settledRenders); + expect(profileCommits).toBe(settledCommits); + }); + + it("hot이나_membership의_실제_변경은_해당_identity만_교체한다", async () => { + repos.mockResolvedValue(bootstrap("r2")); + const { result } = mount(); + await flush(); + const firstRepos = result.current.repos; + const firstHot = result.current.hot; + + repos.mockResolvedValue({ + ...bootstrap("r2"), + hot: { enabled: true, window_secs: 30 }, + }); + await nextPoll(); + expect(result.current.repos).toBe(firstRepos); + expect(result.current.hot).not.toBe(firstHot); + expect(result.current.hot).toEqual({ enabled: true, window_secs: 30 }); + const hotAfterChange = result.current.hot; + const changed = bootstrap("r2"); + changed.repos.push({ id: "r3", name: "three", display_path: "~/three" }); + changed.hot = hotAfterChange!; + repos.mockResolvedValue(changed); + await nextPoll(); + expect(result.current.repos).not.toBe(firstRepos); + expect(result.current.repos.map((item) => item.id)).toEqual(["r1", "r2", "r3"]); + expect(result.current.hot).toBe(hotAfterChange); + }); + + it("drag_중에는_로컬_순서를_지키면서_membership만_받는다", async () => { + const stable = args(); + stable.repoDraggingRef.current = true; + repos.mockResolvedValue(bootstrap("r2")); + const { result } = mount(undefined, stable); + await flush(); + act(() => + result.current.setRepos([result.current.repos[1], result.current.repos[0]]), + ); + + const changed = bootstrap("r2"); + changed.repos.push({ id: "r3", name: "three", display_path: "~/three" }); + repos.mockResolvedValue(changed); + await nextPoll(); + expect(result.current.repos.map((item) => item.id)).toEqual(["r2", "r1", "r3"]); + + stable.repoDraggingRef.current = false; + await nextPoll(); + expect(result.current.repos.map((item) => item.id)).toEqual(["r1", "r2", "r3"]); + }); +}); diff --git a/viewer-ui/src/hooks/useRepoPoll.ts b/viewer-ui/src/hooks/useRepoPoll.ts index 7e978531..560bb5b4 100644 --- a/viewer-ui/src/hooks/useRepoPoll.ts +++ b/viewer-ui/src/hooks/useRepoPoll.ts @@ -10,6 +10,7 @@ import { resolveActiveRepo } from "../lib/activeRepo"; import { nextClockOffset } from "../lib/hot"; import { createSerialWriter } from "../lib/serialWrite"; import { reconcileOrder } from "../lib/paneOrder"; +import { retainHot, retainRepos } from "../lib/repoSnapshot"; import { noteViewerBuild } from "../lib/viewerBuild"; import type { MaximizedByRepo, RepoViewByRepo } from "../api"; @@ -136,7 +137,7 @@ export function useRepoPoll({ // Not state: what it decides is whether this document is out of date, // which nothing here renders. See `lib/viewerBuild.ts`. noteViewerBuild(viewer_build); - setHot(hot); + setHot((current) => retainHot(current, hot)); setCanClone(can_clone); setClockSkewMs((held) => nextClockOffset(held, now_ms, Date.now())); if (accentWrites.current === writes) adoptAccent(accent); @@ -158,7 +159,7 @@ export function useRepoPoll({ !repoDraggingRef.current && !reorderPending ) { - setRepos(list); + setRepos((current) => retainRepos(current, list)); } else { setRepos((current) => { const ids = reconcileOrder( @@ -166,7 +167,10 @@ export function useRepoPoll({ current.map((item) => item.id), ); const byId = new Map(list.map((item) => [item.id, item])); - return ids.map((id) => byId.get(id)!).filter(Boolean); + return retainRepos( + current, + ids.map((id) => byId.get(id)!).filter(Boolean), + ); }); } const ids = list.map((r) => r.id); diff --git a/viewer-ui/src/lib/repoSnapshot.ts b/viewer-ui/src/lib/repoSnapshot.ts new file mode 100644 index 00000000..1fb7badc --- /dev/null +++ b/viewer-ui/src/lib/repoSnapshot.ts @@ -0,0 +1,26 @@ +import type { HotConfig, Repo } from "../api"; + +export function retainHot( + current: HotConfig | null, + incoming: HotConfig, +): HotConfig { + return current?.enabled === incoming.enabled && + current.window_secs === incoming.window_secs + ? current + : incoming; +} + +/** Keep the published list stable when a JSON poll only recreated its objects. */ +export function retainRepos(current: Repo[], incoming: Repo[]): Repo[] { + if (current.length !== incoming.length) return incoming; + return current.every((repo, index) => { + const next = incoming[index]; + return ( + repo.id === next.id && + repo.name === next.name && + repo.display_path === next.display_path + ); + }) + ? current + : incoming; +} From 97b8112e452d7f4384dd6c02def9bb5bc938b1ee Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 00:58:59 +0900 Subject: [PATCH 25/42] perf(viewer): virtualize large diff and file views --- docs/architecture/web.md | 9 + viewer-ui/src/components/DiffView.tsx | 25 ++- viewer-ui/src/components/FileLines.tsx | 26 +++ viewer-ui/src/components/FilePane.tsx | 74 ++++---- viewer-ui/src/components/LineNos.tsx | 5 +- viewer-ui/src/components/VirtualDiffView.tsx | 144 ++++++++++++++++ viewer-ui/src/components/VirtualFileLines.tsx | 28 ++++ .../src/components/virtualContent.test.tsx | 158 ++++++++++++++++++ viewer-ui/src/hooks/ui/useScrollViewport.ts | 38 +++++ viewer-ui/src/lib/virtualWindow.test.ts | 33 ++++ viewer-ui/src/lib/virtualWindow.ts | 35 ++++ 11 files changed, 539 insertions(+), 36 deletions(-) create mode 100644 viewer-ui/src/components/FileLines.tsx create mode 100644 viewer-ui/src/components/VirtualDiffView.tsx create mode 100644 viewer-ui/src/components/VirtualFileLines.tsx create mode 100644 viewer-ui/src/components/virtualContent.test.tsx create mode 100644 viewer-ui/src/hooks/ui/useScrollViewport.ts create mode 100644 viewer-ui/src/lib/virtualWindow.test.ts create mode 100644 viewer-ui/src/lib/virtualWindow.ts diff --git a/docs/architecture/web.md b/docs/architecture/web.md index 6a9fe4de..c9f96865 100644 --- a/docs/architecture/web.md +++ b/docs/architecture/web.md @@ -303,6 +303,15 @@ Vitest 쪽 권장이며, 결정적으로 `window.matchMedia`를 구현한다(jsd 방식으로 옮기면 된다. `@testing-library/react` 16은 `@testing-library/dom`을 peer로 요구해 함께 설치했다; `user-event`·`jest-dom`은 훅 테스트에 불필요해 컴포넌트 테스트를 시작할 때로 미뤘다. +**큰 diff와 raw file은 viewport만 DOM에 둔다**(`lib/virtualWindow.ts`, +`components/Virtual*`). 200행 이하는 브라우저의 native selection·find·접근성 트리를 그대로 얻도록 +기존 전체 DOM 경로를 쓰고, 그보다 크면 20px 고정 행과 앞뒤 12행 overscan으로 windowing한다. 전체 +높이는 spacer가 보존하므로 scrollbar와 저장한 `scrollTop`은 원본 행 수를 계속 나타낸다. 파일 anchor는 +같은 행 높이로 직접 계산하고, diff의 모든 렌더 행은 자기 `data-hunk`를 가져 header가 viewport 밖이어도 +whole-file 전환이 현재 hunk를 찾는다. Split은 넓은 화면에서 old/new 한 쌍을 같은 virtual row로 +렌더해 세로 정렬을 보존하고, 좁은 화면에서는 hunk별 old 전체 뒤에 new 전체가 오도록 별도 row model을 +쓴다. 20k fixture가 DOM 행 수와 initial/scroll/split 측정치를 계약 테스트로 고정한다. + **렌더 실패가 페이지를 가져가지 않게 한다**(`components/feedback/ErrorBoundary.tsx`, `lib/chunkError.ts`). boundary가 하나도 없으면 React는 어떤 렌더 에러에도 트리 전체를 unmount하고, 보는 사람 입장에서 그것은 **서버가 죽은 것과 구분되지 않는다.** 실제로 사라진 청크가 그 모양으로 diff --git a/viewer-ui/src/components/DiffView.tsx b/viewer-ui/src/components/DiffView.tsx index 84c3170a..9459c276 100644 --- a/viewer-ui/src/components/DiffView.tsx +++ b/viewer-ui/src/components/DiffView.tsx @@ -3,6 +3,8 @@ import { linenoDigits } from "../lib/gutter"; import { diffLineBg } from "../lib/utils"; import { LineNos } from "./LineNos"; import type { Diff, DiffLine } from "../api"; +import { VIRTUAL_THRESHOLD, type ScrollViewport } from "../lib/virtualWindow"; +import { VirtualDiffView } from "./VirtualDiffView"; function DiffLineContent({ line }: { line: DiffLine }) { return ( @@ -104,7 +106,28 @@ function SplitHunk({ lines, digits }: { lines: DiffLine[]; digits: number }) { ); } -export function DiffView({ diff, split }: { diff: Diff; split: boolean }) { +export function DiffView({ + diff, + split, + viewport = { scrollTop: 0, height: 600 }, +}: { + diff: Diff; + split: boolean; + viewport?: ScrollViewport; +}) { + const lineCount = diff.hunks.reduce((count, hunk) => count + hunk.lines.length, 0); + if (lineCount > VIRTUAL_THRESHOLD) { + return ( + <> + + {diff.truncated && ( +

+ Diff truncated — it exceeded the server's size ceiling. +

+ )} + + ); + } // One width for the whole diff, not per hunk: a gutter that resized at each // hunk boundary would step the code's left edge as you scrolled past one. const digits = linenoDigits(diff.hunks); diff --git a/viewer-ui/src/components/FileLines.tsx b/viewer-ui/src/components/FileLines.tsx new file mode 100644 index 00000000..5936e42d --- /dev/null +++ b/viewer-ui/src/components/FileLines.tsx @@ -0,0 +1,26 @@ +import type { Span } from "../api"; +import { digitsFor } from "../lib/gutter"; +import { LineNos } from "./LineNos"; + +/** Small files stay fully mounted so native selection and find remain exact. */ +export function FileLines({ lines }: { lines: Span[][] }) { + const digits = digitsFor(lines.length); + return ( +
+      {lines.map((line, index) => (
+        
+ + + {line.length === 0 + ? " " + : line.map((span, spanIndex) => ( + + {span.t} + + ))} + +
+ ))} +
+ ); +} diff --git a/viewer-ui/src/components/FilePane.tsx b/viewer-ui/src/components/FilePane.tsx index 2c7ececc..0317a662 100644 --- a/viewer-ui/src/components/FilePane.tsx +++ b/viewer-ui/src/components/FilePane.tsx @@ -1,8 +1,12 @@ import { Suspense, lazy, useEffect, useRef } from "react"; import { useDiffLayout } from "../lib/diffLayout"; import { fileViewSource, isHtmlPath, isPreviewablePath } from "../lib/fileView"; -import { digitsFor } from "../lib/gutter"; import { anchorWithin, hunkAtTop } from "../lib/diffAnchor"; +import { useScrollViewport } from "../hooks/ui/useScrollViewport"; +import { + lineScrollTop, + VIRTUAL_THRESHOLD, +} from "../lib/virtualWindow"; import { otherFace, sourceKey } from "../lib/otherFace"; import { MaximizeIcon, @@ -12,9 +16,10 @@ import { } from "./icons/layout"; import { DiffView } from "./DiffView"; import { ErrorBoundary } from "./feedback/ErrorBoundary"; -import { LineNos } from "./LineNos"; +import { FileLines } from "./FileLines"; +import { VirtualFileLines } from "./VirtualFileLines"; import { PathLabel } from "./PathLabel"; -import { api, type Span, type Status } from "../api"; +import { api, type Status } from "../api"; import type { FileSource, Pane } from "../types"; // Keep the markdown pipeline out of the initial chunk. @@ -25,30 +30,6 @@ const HtmlView = lazy(() => import("./content/Html").then((m) => ({ default: m.HtmlView })), ); -/// A file is numbered by its own lines, so the gutter carries one column and -/// no tint — the diff kinds have no meaning here. -function FileLines({ lines }: { lines: Span[][] }) { - const digits = digitsFor(lines.length); - return ( -
-      {lines.map((line, i) => (
-        
- - - {line.length === 0 - ? " " - : line.map((s, j) => ( - - {s.t} - - ))} - -
- ))} -
- ); -} - export interface FilePaneProps { /// The repository on screen. Part of what identifies a remembered scroll /// position: two projects can hold the same path, and this pane outlives a @@ -79,6 +60,7 @@ export function FilePane({ }: FilePaneProps) { const diffLayout = useDiffLayout(); const scroller = useRef(null); + const { viewport, refresh: refreshViewport } = useScrollViewport(scroller); const anchor = pane.kind === "file" ? pane.anchor : undefined; // Where the diff was left, so coming back from the file lands there. The two // faces share one scroller: without this the file's offset carries over and a @@ -98,9 +80,12 @@ export function FilePane({ const container = scroller.current; if (!container) return 0; const top = container.getBoundingClientRect().top; - const offsets = Array.from( + const rows = Array.from( container.querySelectorAll("[data-hunk]"), - (el) => el.getBoundingClientRect().top - top + container.scrollTop, + (el) => ({ + offset: el.getBoundingClientRect().top - top + container.scrollTop, + hunk: Number(el.dataset.hunk ?? 0), + }), ); if (pane.kind === "diff" && pane.source) { leftAt.current = { @@ -114,7 +99,9 @@ export function FilePane({ left: container.scrollLeft, }; } - return hunkAtTop(offsets, container.scrollTop); + const offsets = rows.map((row) => row.offset); + const renderedIndex = hunkAtTop(offsets, container.scrollTop); + return rows[renderedIndex]?.hunk ?? 0; }; // Put the anchored line at the top of the pane. Measured against the // scroller rather than `scrollIntoView`, which would also scroll whatever @@ -130,17 +117,24 @@ export function FilePane({ container.scrollTop = left.top; container.scrollLeft = left.left; leftAt.current = null; + refreshViewport(); } return; } if (anchor === undefined || pane.kind !== "file") return; const within = anchorWithin(anchor, pane.value.lines.length); if (within === null) return; + if (pane.value.lines.length > VIRTUAL_THRESHOLD) { + container.scrollTop = lineScrollTop(within); + refreshViewport(); + return; + } const line = container.querySelector(`[data-line="${within}"]`); if (!line) return; container.scrollTop += line.getBoundingClientRect().top - container.getBoundingClientRect().top; - }, [pane, anchor]); + refreshViewport(); + }, [pane, anchor, refreshViewport]); return (
@@ -216,7 +210,11 @@ export function FilePane({
-
+
{pane.kind === "empty" && (

{status === null ? "Loading…" : "Select a file or commit."} @@ -250,7 +248,11 @@ export function FilePane({ ) : ( - + pane.value.lines.length > VIRTUAL_THRESHOLD ? ( + + ) : ( + + ) )} {pane.value.truncated && (

@@ -260,7 +262,11 @@ export function FilePane({ )} {pane.kind === "diff" && ( - + )}

diff --git a/viewer-ui/src/components/LineNos.tsx b/viewer-ui/src/components/LineNos.tsx index 6c64e105..8049dbf0 100644 --- a/viewer-ui/src/components/LineNos.tsx +++ b/viewer-ui/src/components/LineNos.tsx @@ -15,6 +15,7 @@ export function LineNos({ nos, digits, tint = "", + stickyClass = "sticky left-0", }: { /// One entry per column; `undefined` leaves that column blank, which is what /// an added line's old side (and a removed line's new side) has to show. @@ -23,9 +24,11 @@ export function LineNos({ /// Row background to repeat over the opaque base, so the cell reads as part /// of its row rather than a notch of pane colour. tint?: string; + /** Split virtualization pins the new-side gutter at the second half. */ + stickyClass?: string; }) { return ( - + {nos.map((no, i) => ( diff --git a/viewer-ui/src/components/VirtualDiffView.tsx b/viewer-ui/src/components/VirtualDiffView.tsx new file mode 100644 index 00000000..99ee57e7 --- /dev/null +++ b/viewer-ui/src/components/VirtualDiffView.tsx @@ -0,0 +1,144 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Diff, DiffLine } from "../api"; +import { splitHunkRows } from "../lib/diffLayout"; +import { linenoDigits } from "../lib/gutter"; +import { diffLineBg } from "../lib/utils"; +import { virtualWindow, type ScrollViewport } from "../lib/virtualWindow"; +import { LineNos } from "./LineNos"; + +type Row = + | { kind: "header"; hunk: number; text: string } + | { kind: "unified"; hunk: number; line: DiffLine } + | { kind: "pair"; hunk: number; left: DiffLine | null; right: DiffLine | null } + | { kind: "side"; hunk: number; line: DiffLine | null; side: "old" | "new"; border: boolean }; + +function useWideSplit() { + const query = "(min-width: 768px)"; + const [wide, setWide] = useState(() => window.matchMedia(query).matches); + useEffect(() => { + const media = window.matchMedia(query); + const update = () => setWide(media.matches); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + return wide; +} + +function rowsFor(diff: Diff, split: boolean, wide: boolean): Row[] { + return diff.hunks.flatMap((hunk, hunkIndex) => { + const header: Row = { + kind: "header", + hunk: hunkIndex, + text: `${hunk.file_path ? `${hunk.file_path} ` : ""}${hunk.header}`, + }; + if (!split) { + return [ + header, + ...hunk.lines.map((line) => ({ + kind: "unified", + hunk: hunkIndex, + line, + })), + ]; + } + const pairs = splitHunkRows(hunk.lines); + if (wide) { + return [ + header, + ...pairs.map(({ left, right }) => ({ + kind: "pair", + hunk: hunkIndex, + left, + right, + })), + ]; + } + return [ + header, + ...pairs.map(({ left }) => ({ + kind: "side", + hunk: hunkIndex, + line: left, + side: "old", + border: false, + })), + ...pairs.map(({ right }, index) => ({ + kind: "side", + hunk: hunkIndex, + line: right, + side: "new", + border: index === 0, + })), + ]; + }); +} + +function Content({ line }: { line: DiffLine }) { + return ( + + {line.kind} + {line.spans.map((span, index) => ( + {span.t} + ))} + + ); +} + +function Cell({ line, side, digits, stickyClass }: { line: DiffLine | null; side: "old" | "new"; digits: number; stickyClass?: string }) { + const tint = line ? diffLineBg(line.kind) : "bg-ink-900/40"; + return ( +
+ + {line ? : } +
+ ); +} + +export function VirtualDiffView({ diff, split, viewport }: { diff: Diff; split: boolean; viewport: ScrollViewport }) { + const wide = useWideSplit(); + const rows = useMemo(() => rowsFor(diff, split, wide), [diff, split, wide]); + const digits = linenoDigits(diff.hunks); + const range = virtualWindow(rows.length, viewport.scrollTop, viewport.height); + return ( +
+