From 4e5be418e27346bca49c2dba1d3c5d1aa36393db Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 25 Jul 2026 00:46:13 +0900 Subject: [PATCH 01/15] refactor: split workspace tests into tests/ submodules --- src/workspace/mod.rs | 311 +----------------------- src/workspace/tests/common.rs | 35 +++ src/workspace/tests/mod.rs | 5 + src/workspace/tests/repo_input_tests.rs | 127 ++++++++++ src/workspace/tests/workspace_tests.rs | 152 ++++++++++++ 5 files changed, 320 insertions(+), 310 deletions(-) create mode 100644 src/workspace/tests/common.rs create mode 100644 src/workspace/tests/mod.rs create mode 100644 src/workspace/tests/repo_input_tests.rs create mode 100644 src/workspace/tests/workspace_tests.rs diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 1e9e77c..2028b62 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -290,313 +290,4 @@ impl Workspace { } #[cfg(test)] -mod tests { - use super::*; - use crate::app::tests::app_with_files; - use crossterm::event::{KeyCode, KeyModifiers}; - - fn test_leader() -> KeyEvent { - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL) - } - - /// A workspace holding projects distinguished by `repo_path`. - fn workspace_on(paths: &[&str]) -> Workspace { - let mut ws = Workspace::new(test_leader()); - for p in paths { - assert!(ws.add(project_at(p))); - } - ws - } - - /// A project distinguishable from its siblings by `repo_path`, which is - /// what the tab row labels and `index_of_repo` match on. - fn project_at(path: &str) -> App { - let mut app = app_with_files(vec!["a.rs"]); - app.repo_path = path.to_string(); - app - } - - fn workspace_from(project: App) -> Workspace { - let mut ws = Workspace::new(test_leader()); - ws.add(project); - ws - } - - fn paths(ws: &Workspace) -> Vec<&str> { - ws.projects().iter().map(|p| p.repo_path.as_str()).collect() - } - - #[test] - fn first_typed_char_replaces_the_prefilled_repo_path() { - let mut ws = workspace_on(&["/repos/current"]); - ws.start_repo_input(); - assert_eq!(ws.repo_input.buf, "/repos/current"); - - for c in "/tmp".chars() { - ws.repo_input_push(c); - } - - assert_eq!( - ws.repo_input.buf, "/tmp", - "typing over an untouched prefill must replace it, not append" - ); - } - - #[test] - fn backspace_leaves_prefill_mode_without_dropping_the_path() { - let mut ws = workspace_on(&["/repos/current"]); - ws.start_repo_input(); - - ws.repo_input_pop(); - assert_eq!(ws.repo_input.buf, "/repos/curren"); - ws.repo_input_push('t'); - assert_eq!( - ws.repo_input.buf, "/repos/current", - "after Backspace, typing must append to the surviving text" - ); - } - - #[test] - fn accepting_the_prefill_appends_instead_of_replacing() { - let mut ws = workspace_on(&["/repos/current/"]); - ws.start_repo_input(); - - ws.repo_input_accept_prefill(); - for c in "src".chars() { - ws.repo_input_push(c); - } - - assert_eq!(ws.repo_input.buf, "/repos/current/src"); - } - - #[test] - fn confirming_a_tilde_path_opens_the_home_relative_directory() { - // The dialog never passes through a shell, so an unexpanded `~` would - // be rejected as "no such directory". - let home = dirs::home_dir().expect("a home directory"); - let mut ws = workspace_on(&["/repos/current"]); - ws.start_repo_input(); - for c in "~".chars() { - ws.repo_input_push(c); - } - - let result = ws.confirm_repo_input(); - - assert_eq!( - result, - RepoInputResult::Open( - crate::git::resolve_repo_path(&home) - .to_string_lossy() - .to_string() - ) - ); - } - - #[test] - fn reopening_the_dialog_re_arms_the_prefill() { - let mut ws = workspace_on(&["/repos/current"]); - ws.start_repo_input(); - ws.repo_input_push('x'); - ws.cancel_repo_input(); - - ws.start_repo_input(); - ws.repo_input_push('y'); - assert_eq!(ws.repo_input.buf, "y"); - } - - #[test] - fn 빈_workspace는_활성_프로젝트가_없다() { - let ws = Workspace::new(test_leader()); - - assert!(ws.active().is_none()); - assert!(ws.projects().is_empty()); - } - - #[test] - fn 닫은_프로젝트_기억은_상한을_넘지_않는다() { - // A long-lived process that opens and closes many repositories would - // otherwise hold every session it had ever seen — and rescan that - // history on every save. - let mut ws = Workspace::new(test_leader()); - for i in 0..MAX_REMEMBERED + 5 { - ws.add(project_at(&format!("/w/p{i}"))); - ws.close_active(); - } - - assert_eq!(ws.remembered.len(), MAX_REMEMBERED); - // The most recently closed survives; the oldest is gone. - assert!( - ws.session_for(&format!("/w/p{}", MAX_REMEMBERED + 4)) - .is_some() - ); - assert!(ws.session_for("/w/p0").is_none()); - } - - #[test] - fn 마지막_탭을_닫으면_빈_상태가_된다() { - // Quitting is no longer the only way out of the last project: the - // empty screen is a real state nightcrow also starts in. - let mut ws = workspace_on(&["/a"]); - - assert!(ws.close_active()); - - assert!(ws.active().is_none()); - assert!(!ws.close_active(), "nothing left to close"); - } - - #[test] - fn 프로젝트가_없으면_다이얼로그가_빈_상태로_열린다() { - let mut ws = Workspace::new(test_leader()); - - ws.start_repo_input(); - - assert!(ws.repo_input.active); - assert_eq!(ws.repo_input.buf, "", "no project to prefill from"); - } - - #[test] - fn 프로젝트를_열면_빈_화면_공지가_사라진다() { - // Otherwise a stale rejection would reappear the moment the last tab - // was closed again, long after it stopped being true. - let mut ws = Workspace::new(test_leader()); - ws.raise_notice(NoticeKind::RepoInput, "no such directory"); - - ws.add(project_at("/a")); - ws.close_active(); - - assert!(ws.active().is_none(), "back to the empty screen"); - assert!(ws.empty_notice().is_none()); - } - - #[test] - fn 프로젝트가_없으면_공지가_workspace에_남는다() { - let mut ws = Workspace::new(test_leader()); - - ws.raise_notice(NoticeKind::RepoInput, "no such directory"); - - assert_eq!( - ws.empty_notice().map(|n| n.text.as_str()), - Some("no such directory") - ); - ws.clear_notice(NoticeKind::RepoInput); - assert!(ws.empty_notice().is_none()); - } - - #[test] - fn 새_workspace는_프로젝트_하나를_활성으로_갖는다() { - let ws = workspace_from(app_with_files(vec!["a.rs"])); - - assert_eq!(ws.projects().len(), 1); - assert_eq!(ws.active().unwrap().repo_path, "."); - } - - #[test] - fn 프로젝트를_추가하면_끝에_붙고_활성이_된다() { - let mut ws = workspace_from(project_at("/a")); - - assert!(ws.add(project_at("/b"))); - - assert_eq!(paths(&ws), vec!["/a", "/b"]); - assert_eq!(ws.active().unwrap().repo_path, "/b"); - } - - #[test] - fn 상한에_도달하면_추가를_거부하고_활성을_유지한다() { - let mut ws = workspace_from(project_at("/p0")); - for i in 1..MAX_PROJECTS { - assert!(ws.add(project_at(&format!("/p{i}")))); - } - assert_eq!(ws.projects().len(), MAX_PROJECTS); - let active_before = ws.active().unwrap().repo_path.clone(); - - assert!(!ws.add(project_at("/overflow"))); - - assert_eq!(ws.projects().len(), MAX_PROJECTS); - assert_eq!(ws.active().unwrap().repo_path, active_before); - assert!(ws.index_of_repo("/overflow").is_none()); - } - - #[test] - fn 가운데_탭을_닫으면_뒤_탭이_활성이_된다() { - let mut ws = workspace_from(project_at("/a")); - ws.add(project_at("/b")); - ws.add(project_at("/c")); - ws.switch(1); - - assert!(ws.close_active()); - - assert_eq!(paths(&ws), vec!["/a", "/c"]); - assert_eq!(ws.active().unwrap().repo_path, "/c"); - } - - #[test] - fn 마지막_탭을_닫으면_앞_탭이_활성이_된다() { - let mut ws = workspace_from(project_at("/a")); - ws.add(project_at("/b")); - - assert!(ws.close_active()); - - assert_eq!(paths(&ws), vec!["/a"]); - assert_eq!(ws.active().unwrap().repo_path, "/a"); - } - - #[test] - fn 프로젝트를_추가해도_이전_프로젝트의_press가_버려진다() { - // `add` makes the new project active, so the outgoing project's press - // is released in place — same reasoning as `switch`. - let mut ws = workspace_on(&["/a"]); - ws.active_mut().unwrap().pending_mouse_press = - Some((1, crossterm::event::MouseButton::Left, 1, 1)); - - ws.add(project_at("/b")); - - assert!(ws.projects()[0].pending_mouse_press.is_none()); - } - - #[test] - fn 전환하면_이전_프로젝트의_대기중인_마우스_press가_버려진다() { - let mut ws = workspace_from(project_at("/a")); - ws.add(project_at("/b")); - ws.switch(0); - ws.active_mut().unwrap().pending_mouse_press = - Some((1, crossterm::event::MouseButton::Left, 1, 1)); - - ws.switch(1); - - // /a's press can never be paired now, so it is released where it - // happened rather than left to match an unrelated release later. - assert!(ws.projects()[0].pending_mouse_press.is_none()); - } - - #[test] - fn 같은_인덱스로_전환하면_대기중인_press를_유지한다() { - // A no-op switch must not disturb an in-flight press/release pair. - let mut ws = workspace_from(project_at("/a")); - let press = Some((1, crossterm::event::MouseButton::Left, 1, 1)); - ws.active_mut().unwrap().pending_mouse_press = press; - - ws.switch(0); - - assert_eq!(ws.active().unwrap().pending_mouse_press, press); - } - - #[test] - fn 범위를_벗어난_전환은_활성을_바꾸지_않는다() { - let mut ws = workspace_from(project_at("/a")); - ws.add(project_at("/b")); - - ws.switch(9); - - assert_eq!(ws.active().unwrap().repo_path, "/b"); - } - - #[test] - fn 열린_저장소는_경로로_찾을_수_있고_없으면_none이다() { - let mut ws = workspace_from(project_at("/a")); - ws.add(project_at("/b")); - - assert_eq!(ws.index_of_repo("/a"), Some(0)); - assert_eq!(ws.index_of_repo("/b"), Some(1)); - assert_eq!(ws.index_of_repo("/nope"), None); - } -} +mod tests; diff --git a/src/workspace/tests/common.rs b/src/workspace/tests/common.rs new file mode 100644 index 0000000..8f7416b --- /dev/null +++ b/src/workspace/tests/common.rs @@ -0,0 +1,35 @@ +use crate::app::tests::app_with_files; +use crate::app::App; +use crate::workspace::Workspace; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +pub(super) fn test_leader() -> KeyEvent { + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL) +} + +/// A workspace holding projects distinguished by `repo_path`. +pub(super) fn workspace_on(paths: &[&str]) -> Workspace { + let mut ws = Workspace::new(test_leader()); + for p in paths { + assert!(ws.add(project_at(p))); + } + ws +} + +/// A project distinguishable from its siblings by `repo_path`, which is +/// what the tab row labels and `index_of_repo` match on. +pub(super) fn project_at(path: &str) -> App { + let mut app = app_with_files(vec!["a.rs"]); + app.repo_path = path.to_string(); + app +} + +pub(super) fn workspace_from(project: App) -> Workspace { + let mut ws = Workspace::new(test_leader()); + ws.add(project); + ws +} + +pub(super) fn paths(ws: &Workspace) -> Vec<&str> { + ws.projects().iter().map(|p| p.repo_path.as_str()).collect() +} \ No newline at end of file diff --git a/src/workspace/tests/mod.rs b/src/workspace/tests/mod.rs new file mode 100644 index 0000000..b6745b9 --- /dev/null +++ b/src/workspace/tests/mod.rs @@ -0,0 +1,5 @@ +use super::*; + +mod common; +mod repo_input_tests; +mod workspace_tests; \ No newline at end of file diff --git a/src/workspace/tests/repo_input_tests.rs b/src/workspace/tests/repo_input_tests.rs new file mode 100644 index 0000000..f92cc9b --- /dev/null +++ b/src/workspace/tests/repo_input_tests.rs @@ -0,0 +1,127 @@ +use super::common::*; +use super::*; +use crate::app::tests::app_with_files; + +#[test] +fn first_typed_char_replaces_the_prefilled_repo_path() { + let mut ws = workspace_on(&["/repos/current"]); + ws.start_repo_input(); + assert_eq!(ws.repo_input.buf, "/repos/current"); + + for c in "/tmp".chars() { + ws.repo_input_push(c); + } + + assert_eq!( + ws.repo_input.buf, "/tmp", + "typing over an untouched prefill must replace it, not append" + ); +} + +#[test] +fn backspace_leaves_prefill_mode_without_dropping_the_path() { + let mut ws = workspace_on(&["/repos/current"]); + ws.start_repo_input(); + + ws.repo_input_pop(); + assert_eq!(ws.repo_input.buf, "/repos/curren"); + ws.repo_input_push('t'); + assert_eq!( + ws.repo_input.buf, "/repos/current", + "after Backspace, typing must append to the surviving text" + ); +} + +#[test] +fn accepting_the_prefill_appends_instead_of_replacing() { + let mut ws = workspace_on(&["/repos/current/"]); + ws.start_repo_input(); + + ws.repo_input_accept_prefill(); + for c in "src".chars() { + ws.repo_input_push(c); + } + + assert_eq!(ws.repo_input.buf, "/repos/current/src"); +} + +#[test] +fn confirming_a_tilde_path_opens_the_home_relative_directory() { + // The dialog never passes through a shell, so an unexpanded `~` would + // be rejected as "no such directory". + let home = dirs::home_dir().expect("a home directory"); + let mut ws = workspace_on(&["/repos/current"]); + ws.start_repo_input(); + for c in "~".chars() { + ws.repo_input_push(c); + } + + let result = ws.confirm_repo_input(); + + assert_eq!( + result, + RepoInputResult::Open( + crate::git::resolve_repo_path(&home) + .to_string_lossy() + .to_string() + ) + ); +} + +#[test] +fn reopening_the_dialog_re_arms_the_prefill() { + let mut ws = workspace_on(&["/repos/current"]); + ws.start_repo_input(); + ws.repo_input_push('x'); + ws.cancel_repo_input(); + + ws.start_repo_input(); + ws.repo_input_push('y'); + assert_eq!(ws.repo_input.buf, "y"); +} + +#[test] +fn 프로젝트가_없으면_다이얼로그가_빈_상태로_열린다() { + let mut ws = Workspace::new(test_leader()); + + ws.start_repo_input(); + + assert!(ws.repo_input.active); + assert_eq!(ws.repo_input.buf, "", "no project to prefill from"); +} + +#[test] +fn 프로젝트를_열면_빈_화면_공지가_사라진다() { + // Otherwise a stale rejection would reappear the moment the last tab + // was closed again, long after it stopped being true. + let mut ws = Workspace::new(test_leader()); + ws.raise_notice(NoticeKind::RepoInput, "no such directory"); + + ws.add(project_at("/a")); + ws.close_active(); + + assert!(ws.active().is_none(), "back to the empty screen"); + assert!(ws.empty_notice().is_none()); +} + +#[test] +fn 프로젝트가_없으면_공지가_workspace에_남는다() { + let mut ws = Workspace::new(test_leader()); + + ws.raise_notice(NoticeKind::RepoInput, "no such directory"); + + assert_eq!( + ws.empty_notice().map(|n| n.text.as_str()), + Some("no such directory") + ); + ws.clear_notice(NoticeKind::RepoInput); + assert!(ws.empty_notice().is_none()); +} + +#[test] +fn 새_workspace는_프로젝트_하나를_활성으로_갖는다() { + let ws = workspace_from(app_with_files(vec!["a.rs"])); + + assert_eq!(ws.projects().len(), 1); + assert_eq!(ws.active().unwrap().repo_path, "."); +} \ No newline at end of file diff --git a/src/workspace/tests/workspace_tests.rs b/src/workspace/tests/workspace_tests.rs new file mode 100644 index 0000000..2e713cb --- /dev/null +++ b/src/workspace/tests/workspace_tests.rs @@ -0,0 +1,152 @@ +use super::common::*; +use super::*; + +#[test] +fn 빈_workspace는_활성_프로젝트가_없다() { + let ws = Workspace::new(test_leader()); + + assert!(ws.active().is_none()); + assert!(ws.projects().is_empty()); +} + +#[test] +fn 닫은_프로젝트_기억은_상한을_넘지_않는다() { + // A long-lived process that opens and closes many repositories would + // otherwise hold every session it had ever seen — and rescan that + // history on every save. + let mut ws = Workspace::new(test_leader()); + for i in 0..MAX_REMEMBERED + 5 { + ws.add(project_at(&format!("/w/p{i}"))); + ws.close_active(); + } + + assert_eq!(ws.remembered.len(), MAX_REMEMBERED); + // The most recently closed survives; the oldest is gone. + assert!( + ws.session_for(&format!("/w/p{}", MAX_REMEMBERED + 4)) + .is_some() + ); + assert!(ws.session_for("/w/p0").is_none()); +} + +#[test] +fn 마지막_탭을_닫으면_빈_상태가_된다() { + // Quitting is no longer the only way out of the last project: the + // empty screen is a real state nightcrow also starts in. + let mut ws = workspace_on(&["/a"]); + + assert!(ws.close_active()); + + assert!(ws.active().is_none()); + assert!(!ws.close_active(), "nothing left to close"); +} + +#[test] +fn 프로젝트를_추가하면_끝에_붙고_활성이_된다() { + let mut ws = workspace_from(project_at("/a")); + + assert!(ws.add(project_at("/b"))); + + assert_eq!(paths(&ws), vec!["/a", "/b"]); + assert_eq!(ws.active().unwrap().repo_path, "/b"); +} + +#[test] +fn 상한에_도달하면_추가를_거부하고_활성을_유지한다() { + let mut ws = workspace_from(project_at("/p0")); + for i in 1..MAX_PROJECTS { + assert!(ws.add(project_at(&format!("/p{i}")))); + } + assert_eq!(ws.projects().len(), MAX_PROJECTS); + let active_before = ws.active().unwrap().repo_path.clone(); + + assert!(!ws.add(project_at("/overflow"))); + + assert_eq!(ws.projects().len(), MAX_PROJECTS); + assert_eq!(ws.active().unwrap().repo_path, active_before); + assert!(ws.index_of_repo("/overflow").is_none()); +} + +#[test] +fn 가운데_탭을_닫으면_뒤_탭이_활성이_된다() { + let mut ws = workspace_from(project_at("/a")); + ws.add(project_at("/b")); + ws.add(project_at("/c")); + ws.switch(1); + + assert!(ws.close_active()); + + assert_eq!(paths(&ws), vec!["/a", "/c"]); + assert_eq!(ws.active().unwrap().repo_path, "/c"); +} + +#[test] +fn 마지막_탭을_닫으면_앞_탭이_활성이_된다() { + let mut ws = workspace_from(project_at("/a")); + ws.add(project_at("/b")); + + assert!(ws.close_active()); + + assert_eq!(paths(&ws), vec!["/a"]); + assert_eq!(ws.active().unwrap().repo_path, "/a"); +} + +#[test] +fn 프로젝트를_추가해도_이전_프로젝트의_press가_버려진다() { + // `add` makes the new project active, so the outgoing project's press + // is released in place — same reasoning as `switch`. + let mut ws = workspace_on(&["/a"]); + ws.active_mut().unwrap().pending_mouse_press = + Some((1, crossterm::event::MouseButton::Left, 1, 1)); + + ws.add(project_at("/b")); + + assert!(ws.projects()[0].pending_mouse_press.is_none()); +} + +#[test] +fn 전환하면_이전_프로젝트의_대기중인_마우스_press가_버려진다() { + let mut ws = workspace_from(project_at("/a")); + ws.add(project_at("/b")); + ws.switch(0); + ws.active_mut().unwrap().pending_mouse_press = + Some((1, crossterm::event::MouseButton::Left, 1, 1)); + + ws.switch(1); + + // /a's press can never be paired now, so it is released where it + // happened rather than left to match an unrelated release later. + assert!(ws.projects()[0].pending_mouse_press.is_none()); +} + +#[test] +fn 같은_인덱스로_전환하면_대기중인_press를_유지한다() { + // A no-op switch must not disturb an in-flight press/release pair. + let mut ws = workspace_from(project_at("/a")); + let press = Some((1, crossterm::event::MouseButton::Left, 1, 1)); + ws.active_mut().unwrap().pending_mouse_press = press; + + ws.switch(0); + + assert_eq!(ws.active().unwrap().pending_mouse_press, press); +} + +#[test] +fn 범위를_벗어난_전환은_활성을_바꾸지_않는다() { + let mut ws = workspace_from(project_at("/a")); + ws.add(project_at("/b")); + + ws.switch(9); + + assert_eq!(ws.active().unwrap().repo_path, "/b"); +} + +#[test] +fn 열린_저장소는_경로로_찾을_수_있고_없으면_none이다() { + let mut ws = workspace_from(project_at("/a")); + ws.add(project_at("/b")); + + assert_eq!(ws.index_of_repo("/a"), Some(0)); + assert_eq!(ws.index_of_repo("/b"), Some(1)); + assert_eq!(ws.index_of_repo("/nope"), None); +} \ No newline at end of file From 6aa40166b1a1ef73128a7c55c6d04e3c63e291b9 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 25 Jul 2026 00:48:00 +0900 Subject: [PATCH 02/15] refactor: split emulator into view + tests submodules --- src/runtime/emulator.rs | 589 ------------------ src/runtime/emulator/mod.rs | 218 +++++++ src/runtime/emulator/tests.rs | 245 ++++++++ src/runtime/emulator/view.rs | 134 ++++ .../viewer/{terminal.rs => terminal/mod.rs} | 0 5 files changed, 597 insertions(+), 589 deletions(-) delete mode 100644 src/runtime/emulator.rs create mode 100644 src/runtime/emulator/mod.rs create mode 100644 src/runtime/emulator/tests.rs create mode 100644 src/runtime/emulator/view.rs rename src/web/viewer/{terminal.rs => terminal/mod.rs} (100%) diff --git a/src/runtime/emulator.rs b/src/runtime/emulator.rs deleted file mode 100644 index 64d7b3e..0000000 --- a/src/runtime/emulator.rs +++ /dev/null @@ -1,589 +0,0 @@ -//! alacritty_terminal-backed per-pane terminal emulator. -//! -//! Wraps `Term` + the ANSI `Processor` + an event proxy behind the narrow -//! contract the rest of nightcrow needs — feed PTY bytes, resize, scroll, -//! query cells/cursor/modes — so no module outside this file touches -//! alacritty internals except through `ScreenView`/`CellView`. This replaced -//! the vt100 crate, whose resize path panicked when a wide character was -//! truncated at the last column (vt100-rust issue #28) and whose upstream -//! had stalled on that class of boundary bugs. - -use std::cell::RefCell; -use std::rc::Rc; - -use alacritty_terminal::event::{Event, EventListener}; -use alacritty_terminal::grid::{Dimensions, Scroll}; -use alacritty_terminal::index::{Column, Line, Point}; -use alacritty_terminal::term::cell::{Cell, Flags}; -use alacritty_terminal::term::test::TermSize; -use alacritty_terminal::term::{Config, MIN_COLUMNS, MIN_SCREEN_LINES, Term, TermMode}; -use alacritty_terminal::vte::ansi::{Color, NamedColor, Processor}; - -/// Side effects surfaced by `PaneEmulator::process` for the caller to act on. -#[derive(Default)] -pub struct EmulatorEvents { - /// Most recent OSC 0/2 window title in the processed chunk, already - /// stripped of control characters and surrounding whitespace. `None` - /// when the chunk set no (non-empty) title. - pub title: Option, - /// Terminal query responses (DA, DSR, ...) the emulator produced while - /// processing. Must be written back to the pane's PTY so programs that - /// interrogate their terminal (vim, tmux, ...) receive an answer. - pub pty_writes: Vec, -} - -/// Where a scroll request for a pane must be delivered. A program that owns -/// its viewport keeps its transcript in its own memory, not in the emulator's -/// scrollback, so scrolling the grid would move nothing; the scroll has to -/// reach the program as input instead. Which input it expects is announced by -/// the modes the program itself enabled. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScrollSink { - /// The program tracks the mouse and reports in SGR (1006) form: send it - /// wheel events. Claude Code lands here. - MouseWheel, - /// The program is on the alternate screen and left xterm's - /// `alternateScroll` (1007) enabled: send it arrow keys. `less`, `man`. - ArrowKeys, - /// Nothing claimed the scroll, so the emulator's own scrollback owns it. - /// Interactive shells land here — the default, and the only branch that - /// writes nothing to the PTY. A shell would echo an unbound escape - /// sequence straight into its prompt, so this branch must stay silent. - Scrollback, -} - -#[derive(Default)] -struct ProxyState { - title: Option, - pty_writes: Vec, -} - -/// Event sink handed to `Term`. `EventListener::send_event` only gets -/// `&self`, so the collected state lives behind `Rc`; the emulator -/// keeps a second handle and drains it after each `process` call. -#[derive(Clone, Default)] -struct EventProxy(Rc>); - -impl EventListener for EventProxy { - fn send_event(&self, event: Event) { - match event { - Event::Title(title) => { - let cleaned: String = title.chars().filter(|c| !c.is_control()).collect(); - let trimmed = cleaned.trim(); - if !trimmed.is_empty() { - self.0.borrow_mut().title = Some(trimmed.to_string()); - } - } - Event::PtyWrite(text) => { - self.0 - .borrow_mut() - .pty_writes - .extend_from_slice(text.as_bytes()); - } - // Clipboard, bell, damage and child-process events are not part - // of nightcrow's pane contract; drop them. - _ => {} - } - } -} - -pub struct PaneEmulator { - term: Term, - processor: Processor, - proxy: EventProxy, -} - -impl PaneEmulator { - /// Create an emulator with a `rows` x `cols` screen (clamped to the - /// minimum grid alacritty supports) and `scrollback_lines` of history. - pub fn new(rows: u16, cols: u16, scrollback_lines: usize) -> Self { - let config = Config { - scrolling_history: scrollback_lines, - ..Config::default() - }; - let proxy = EventProxy::default(); - let term = Term::new(config, &term_size(rows, cols), proxy.clone()); - Self { - term, - processor: Processor::new(), - proxy, - } - } - - /// Feed raw PTY output through the emulator, updating the screen state. - /// Returns the side effects (title change, terminal query responses) - /// the caller must apply. - pub fn process(&mut self, bytes: &[u8]) -> EmulatorEvents { - self.processor.advance(&mut self.term, bytes); - let mut state = self.proxy.0.borrow_mut(); - EmulatorEvents { - title: state.title.take(), - pty_writes: std::mem::take(&mut state.pty_writes), - } - } - - /// 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). - pub fn resize(&mut self, rows: u16, cols: u16) { - self.term.resize(term_size(rows, cols)); - } - - /// Set the absolute scrollback offset (0 = live bottom, larger = older) - /// and return the offset actually applied after clamping to the - /// available history. - pub fn set_scroll_offset(&mut self, offset: usize) -> usize { - let current = self.term.grid().display_offset(); - let delta = i64::try_from(offset) - .unwrap_or(i64::MAX) - .saturating_sub(current as i64) - .clamp(i32::MIN as i64, i32::MAX as i64) as i32; - self.term.scroll_display(Scroll::Delta(delta)); - self.term.grid().display_offset() - } - - /// Current scrollback offset (0 = live bottom). Production code tracks - /// the applied offset through `set_scroll_offset`'s return value; this - /// read-back exists for tests asserting emulator state directly. - #[cfg(test)] - pub fn scroll_offset(&self) -> usize { - self.term.grid().display_offset() - } - - /// Read-only view of the screen as currently scrolled. - pub fn view(&self) -> ScreenView<'_> { - ScreenView { term: &self.term } - } - - /// Which input, if any, a scroll request for this pane must be turned - /// into. See `ScrollSink`. Mouse reporting wins over `alternateScroll` - /// because a program that asked for wheel events wants them even on the - /// alternate screen — that is also the order xterm resolves them in. - /// - /// `MOUSE_MODE` alone is not enough: without `SGR_MOUSE` the program - /// expects the legacy X10 encoding, which cannot address columns past - /// 223. Rather than emit a second encoding for a case no modern TUI - /// uses, 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) { - ScrollSink::MouseWheel - } else if mode.contains(TermMode::ALT_SCREEN) && mode.contains(TermMode::ALTERNATE_SCROLL) { - ScrollSink::ArrowKeys - } else { - ScrollSink::Scrollback - } - } - - /// Whether the program asked for mouse button reports in SGR form — - /// the gate for forwarding clicks. The mode set is the same one that - /// routes wheel scrolls to `ScrollSink::MouseWheel`, but it is a - /// separate predicate because the meaning differs: a click has no - /// scrollback fallback, it 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) - } - - /// Whether the program enabled DECCKM (application cursor keys), which - /// changes the arrow-key encoding from `ESC [ A` to `ESC O A`. - pub fn app_cursor(&self) -> bool { - self.term.mode().contains(TermMode::APP_CURSOR) - } -} - -/// Clamp a requested pane size to alacritty's supported minimum grid. -/// `Term` expects its embedder to enforce `MIN_COLUMNS`/`MIN_SCREEN_LINES` -/// (the alacritty app clamps its window the same way); in particular 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. -pub fn effective_size(rows: u16, cols: u16) -> (u16, u16) { - ( - rows.max(MIN_SCREEN_LINES as u16), - cols.max(MIN_COLUMNS as u16), - ) -} - -fn term_size(rows: u16, cols: u16) -> TermSize { - let (rows, cols) = effective_size(rows, cols); - TermSize::new(usize::from(cols), usize::from(rows)) -} - -/// Read-only query surface over an emulated screen. Rows/columns are -/// viewport coordinates: `(0, 0)` is the top-left of what should be drawn, -/// already accounting for the current scrollback offset. -pub struct ScreenView<'a> { - term: &'a Term, -} - -impl<'a> ScreenView<'a> { - /// (rows, cols) of the emulated screen. - pub fn size(&self) -> (u16, u16) { - let grid = self.term.grid(); - (grid.screen_lines() as u16, grid.columns() as u16) - } - - /// Cell at a viewport position, or `None` when out of bounds. - pub fn cell(&self, row: u16, col: u16) -> Option> { - let grid = self.term.grid(); - if usize::from(row) >= grid.screen_lines() || usize::from(col) >= grid.columns() { - return None; - } - // Viewport row 0 maps to grid line -display_offset: scrolling back - // shifts the view into negative (history) line indices. - let line = Line(i32::from(row) - grid.display_offset() as i32); - let point = Point::new(line, Column(usize::from(col))); - Some(CellView { cell: &grid[point] }) - } - - /// Live cursor position as (row, col), independent of the scrollback - /// offset and of the program's DECTCEM show/hide state — nightcrow - /// always exposes the input point of the focused pane. - pub fn cursor_position(&self) -> (u16, u16) { - let point = self.term.grid().cursor.point; - (point.line.0.max(0) as u16, point.column.0 as u16) - } - - /// Whether the running program enabled bracketed paste (DECSET 2004). - pub fn bracketed_paste(&self) -> bool { - self.term.mode().contains(TermMode::BRACKETED_PASTE) - } -} - -/// One screen cell. Wide characters occupy two columns: the glyph lives on -/// the first cell and the second reports `is_wide_spacer()`. -pub struct CellView<'a> { - cell: &'a Cell, -} - -impl CellView<'_> { - /// Whether this cell is the spacer half of a wide character (or the - /// filler before a wide character that wrapped). Emitting text for it - /// would shift the rest of the row by one column. - pub fn is_wide_spacer(&self) -> bool { - self.cell - .flags - .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) - } - - /// Append the cell's visible contents (base char plus any zero-width - /// combining chars) to `out`. - pub fn append_contents(&self, out: &mut String) { - out.push(self.cell.c); - if let Some(zerowidth) = self.cell.zerowidth() { - out.extend(zerowidth); - } - } - - pub fn fg(&self) -> ratatui::style::Color { - to_ratatui_color(self.cell.fg) - } - - pub fn bg(&self) -> ratatui::style::Color { - to_ratatui_color(self.cell.bg) - } - - pub fn bold(&self) -> bool { - self.cell.flags.contains(Flags::BOLD) - } - - pub fn italic(&self) -> bool { - self.cell.flags.contains(Flags::ITALIC) - } - - pub fn underline(&self) -> bool { - self.cell.flags.contains(Flags::UNDERLINE) - } - - pub fn inverse(&self) -> bool { - self.cell.flags.contains(Flags::INVERSE) - } - - pub fn dim(&self) -> bool { - self.cell.flags.contains(Flags::DIM) - } -} - -/// 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. -fn to_ratatui_color(color: Color) -> ratatui::style::Color { - use ratatui::style::Color as C; - match color { - Color::Spec(rgb) => C::Rgb(rgb.r, rgb.g, rgb.b), - Color::Indexed(i) => C::Indexed(i), - Color::Named(named) => { - let idx = named as usize; - if idx < 16 { - C::Indexed(idx as u8) - } else { - match named { - NamedColor::DimBlack => C::Indexed(0), - NamedColor::DimRed => C::Indexed(1), - NamedColor::DimGreen => C::Indexed(2), - NamedColor::DimYellow => C::Indexed(3), - NamedColor::DimBlue => C::Indexed(4), - NamedColor::DimMagenta => C::Indexed(5), - NamedColor::DimCyan => C::Indexed(6), - NamedColor::DimWhite => C::Indexed(7), - // Foreground, Background, Cursor, BrightForeground, - // DimForeground: no fixed palette slot — defer to the - // host terminal's defaults. - _ => C::Reset, - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn contents(view: &ScreenView<'_>, row: u16, col: u16) -> String { - let mut out = String::new(); - view.cell(row, col).unwrap().append_contents(&mut out); - out - } - - #[test] - fn process_writes_text_and_tracks_cursor() { - let mut emu = PaneEmulator::new(3, 10, 0); - emu.process(b"hi"); - - assert_eq!(contents(&emu.view(), 0, 0), "h"); - assert_eq!(contents(&emu.view(), 0, 1), "i"); - assert_eq!(emu.view().cursor_position(), (0, 2)); - } - - #[test] - fn osc_title_is_captured_and_cleaned() { - let mut emu = PaneEmulator::new(3, 20, 0); - // Embedded control bytes and surrounding whitespace must not leak - // into the tab label; OSC 0 (icon + title) works like OSC 2. - let events = emu.process(b"\x1b]0; cargo\t test \x07"); - assert_eq!(events.title.as_deref(), Some("cargo test")); - - // An empty (or whitespace-only) title must not override the current one. - let events = emu.process(b"\x1b]2; \x1b\\"); - assert_eq!(events.title, None); - } - - #[test] - fn title_is_none_when_chunk_sets_no_title() { - let mut emu = PaneEmulator::new(3, 20, 0); - let events = emu.process(b"plain output"); - assert_eq!(events.title, None); - } - - #[test] - fn cursor_position_report_produces_pty_write() { - let mut emu = PaneEmulator::new(5, 20, 0); - // DSR 6: the program asks where the cursor is; the emulator must - // answer through the PTY (vt100 silently dropped such queries). - let events = emu.process(b"\x1b[2;3H\x1b[6n"); - assert_eq!(events.pty_writes, b"\x1b[2;3R"); - } - - #[test] - fn bracketed_paste_mode_follows_decset() { - let mut emu = PaneEmulator::new(3, 10, 0); - assert!(!emu.view().bracketed_paste()); - emu.process(b"\x1b[?2004h"); - assert!(emu.view().bracketed_paste()); - emu.process(b"\x1b[?2004l"); - assert!(!emu.view().bracketed_paste()); - } - - #[test] - fn wide_char_occupies_two_columns_with_spacer() { - let mut emu = PaneEmulator::new(3, 10, 0); - emu.process("가".as_bytes()); - - let view = emu.view(); - assert_eq!(contents(&view, 0, 0), "가"); - assert!(!view.cell(0, 0).unwrap().is_wide_spacer()); - assert!(view.cell(0, 1).unwrap().is_wide_spacer()); - } - - #[test] - fn shrink_through_wide_char_then_erase_does_not_panic() { - // Regression for the crash that motivated this module: vt100 - // panicked (row.rs clear_wide index out of bounds) when a resize - // truncated a wide character at the last column and the program - // then issued Erase Display. See vt100-rust issue #28. - let mut emu = PaneEmulator::new(5, 20, 0); - emu.process("가나다라마바사아자차".as_bytes()); - emu.resize(5, 19); - emu.process(b"\x1b[1;1H\x1b[J"); - - // Survival is the contract; the screen must also report the new size. - assert_eq!(emu.view().size(), (5, 19)); - } - - #[test] - fn every_shrink_width_survives_wide_char_erase() { - for cols in 1..20u16 { - let mut emu = PaneEmulator::new(5, 20, 0); - emu.process("가나다라마바사아자차".as_bytes()); - emu.resize(5, cols); - emu.process(b"\x1b[1;1H\x1b[J"); - } - } - - #[test] - fn scroll_offset_is_clamped_to_history() { - let mut emu = PaneEmulator::new(3, 10, 5); - // 3-row screen + 8 lines written = 5 lines of history (within cap). - for i in 0..8 { - emu.process(format!("line{i}\r\n").as_bytes()); - } - let applied = emu.set_scroll_offset(9999); - assert_eq!(applied, 5); - assert_eq!(emu.set_scroll_offset(0), 0); - } - - #[test] - fn scrolled_view_shows_history_lines() { - let mut emu = PaneEmulator::new(3, 10, 100); - for i in 0..10 { - emu.process(format!("line{i}\r\n").as_bytes()); - } - emu.set_scroll_offset(2); - // Live top row would be line8; scrolled back 2 it must show line6. - let view = emu.view(); - let row: String = (0..5).map(|c| contents(&view, 0, c)).collect(); - assert_eq!(row, "line6"); - } - - #[test] - fn scroll_sink_defaults_to_scrollback_for_a_plain_shell() { - let emu = PaneEmulator::new(3, 10, 100); - assert_eq!(emu.scroll_sink(), ScrollSink::Scrollback); - } - - #[test] - fn scroll_sink_is_mouse_wheel_when_program_reports_sgr_mouse() { - let mut emu = PaneEmulator::new(3, 10, 100); - // The exact mode set Claude Code emits on startup. - emu.process(b"\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h"); - assert_eq!(emu.scroll_sink(), ScrollSink::MouseWheel); - } - - #[test] - fn scroll_sink_ignores_mouse_reporting_without_sgr_encoding() { - let mut emu = PaneEmulator::new(3, 10, 100); - // X10-encoded mouse reporting: we have no encoder for it, so the - // pane must not be handed wheel bytes it cannot parse. - emu.process(b"\x1b[?1000h"); - assert_eq!(emu.scroll_sink(), ScrollSink::Scrollback); - } - - #[test] - fn wants_mouse_buttons_when_program_reports_sgr_mouse() { - let mut emu = PaneEmulator::new(3, 10, 100); - // The exact mode set Claude Code emits on startup. - emu.process(b"\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h"); - assert!(emu.wants_mouse_buttons()); - } - - #[test] - fn wants_mouse_buttons_rejects_shell_and_x10_only_panes() { - let mut emu = PaneEmulator::new(3, 10, 100); - // A plain shell never claims the mouse: no click byte may reach it. - assert!(!emu.wants_mouse_buttons()); - // X10-encoded reporting without SGR: we have no encoder for it. - emu.process(b"\x1b[?1000h"); - assert!(!emu.wants_mouse_buttons()); - } - - #[test] - fn scroll_sink_is_arrow_keys_on_alternate_screen() { - let mut emu = PaneEmulator::new(3, 10, 100); - emu.process(b"\x1b[?1049h"); - assert_eq!(emu.scroll_sink(), ScrollSink::ArrowKeys); - } - - #[test] - fn scroll_sink_falls_back_when_alternate_scroll_is_disabled() { - let mut emu = PaneEmulator::new(3, 10, 100); - emu.process(b"\x1b[?1049h\x1b[?1007l"); - assert_eq!(emu.scroll_sink(), ScrollSink::Scrollback); - } - - #[test] - fn scroll_sink_prefers_mouse_wheel_over_alternate_screen() { - let mut emu = PaneEmulator::new(3, 10, 100); - emu.process(b"\x1b[?1049h\x1b[?1000h\x1b[?1006h"); - assert_eq!(emu.scroll_sink(), ScrollSink::MouseWheel); - } - - #[test] - fn alternate_screen_keeps_no_scrollback() { - // The reason `ScrollSink` exists: alacritty gives the alternate grid - // zero history, so a scroll offset there can never leave 0 and the - // grid has nothing to reveal. - let mut emu = PaneEmulator::new(3, 10, 100); - emu.process(b"\x1b[?1049h"); - for i in 0..20 { - emu.process(format!("line{i}\r\n").as_bytes()); - } - assert_eq!(emu.set_scroll_offset(999), 0); - } - - #[test] - fn app_cursor_follows_decckm() { - let mut emu = PaneEmulator::new(3, 10, 0); - assert!(!emu.app_cursor()); - emu.process(b"\x1b[?1h"); - assert!(emu.app_cursor()); - emu.process(b"\x1b[?1l"); - assert!(!emu.app_cursor()); - } - - #[test] - fn zero_size_is_clamped_to_minimum_grid() { - // alacritty's documented minimum is 1 line x 2 columns; anything - // smaller (a 1-column grid especially) breaks wide-char reflow. - let mut emu = PaneEmulator::new(0, 0, 0); - assert_eq!(emu.view().size(), (1, 2)); - emu.resize(0, 0); - assert_eq!(emu.view().size(), (1, 2)); - emu.process(b"x"); // must not panic on the minimal grid - } - - #[test] - fn named_colors_map_to_indexed_and_defaults_to_reset() { - use ratatui::style::Color as C; - assert_eq!( - to_ratatui_color(Color::Named(NamedColor::Red)), - C::Indexed(1) - ); - assert_eq!( - to_ratatui_color(Color::Named(NamedColor::BrightWhite)), - C::Indexed(15) - ); - assert_eq!( - to_ratatui_color(Color::Named(NamedColor::DimBlue)), - C::Indexed(4) - ); - assert_eq!( - to_ratatui_color(Color::Named(NamedColor::Foreground)), - C::Reset - ); - assert_eq!(to_ratatui_color(Color::Indexed(42)), C::Indexed(42)); - assert_eq!( - to_ratatui_color(Color::Spec(alacritty_terminal::vte::ansi::Rgb { - r: 1, - g: 2, - b: 3 - })), - C::Rgb(1, 2, 3) - ); - } -} diff --git a/src/runtime/emulator/mod.rs b/src/runtime/emulator/mod.rs new file mode 100644 index 0000000..ba9ea95 --- /dev/null +++ b/src/runtime/emulator/mod.rs @@ -0,0 +1,218 @@ +//! alacritty_terminal-backed per-pane terminal emulator. +//! +//! Wraps `Term` + the ANSI `Processor` + an event proxy behind the narrow +//! contract the rest of nightcrow needs — feed PTY bytes, resize, scroll, +//! query cells/cursor/modes — so no module outside this file touches +//! alacritty internals except through `ScreenView`/`CellView`. This replaced +//! the vt100 crate, whose resize path panicked when a wide character was +//! truncated at the last column (vt100-rust issue #28) and whose upstream +//! had stalled on that class of boundary bugs. + +use std::cell::RefCell; +use std::rc::Rc; + +use alacritty_terminal::event::{Event, EventListener}; +use alacritty_terminal::grid::{Dimensions, Scroll}; +use alacritty_terminal::term::test::TermSize; +use alacritty_terminal::term::{Config, MIN_COLUMNS, MIN_SCREEN_LINES, Term, TermMode}; +use alacritty_terminal::vte::ansi::Processor; + +/// Side effects surfaced by `PaneEmulator::process` for the caller to act on. +#[derive(Default)] +pub struct EmulatorEvents { + /// Most recent OSC 0/2 window title in the processed chunk, already + /// stripped of control characters and surrounding whitespace. `None` + /// when the chunk set no (non-empty) title. + pub title: Option, + /// Terminal query responses (DA, DSR, ...) the emulator produced while + /// processing. Must be written back to the pane's PTY so programs that + /// interrogate their terminal (vim, tmux, ...) receive an answer. + pub pty_writes: Vec, +} + +/// Where a scroll request for a pane must be delivered. A program that owns +/// its viewport keeps its transcript in its own memory, not in the emulator's +/// scrollback, so scrolling the grid would move nothing; the scroll has to +/// reach the program as input instead. Which input it expects is announced by +/// the modes the program itself enabled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScrollSink { + /// The program tracks the mouse and reports in SGR (1006) form: send it + /// wheel events. Claude Code lands here. + MouseWheel, + /// The program is on the alternate screen and left xterm's + /// `alternateScroll` (1007) enabled: send it arrow keys. `less`, `man`. + ArrowKeys, + /// Nothing claimed the scroll, so the emulator's own scrollback owns it. + /// Interactive shells land here — the default, and the only branch that + /// writes nothing to the PTY. A shell would echo an unbound escape + /// sequence straight into its prompt, so this branch must stay silent. + Scrollback, +} + +#[derive(Default)] +struct ProxyState { + title: Option, + pty_writes: Vec, +} + +/// Event sink handed to `Term`. `EventListener::send_event` only gets +/// `&self`, so the collected state lives behind `Rc`; the emulator +/// keeps a second handle and drains it after each `process` call. +#[derive(Clone, Default)] +pub(super) struct EventProxy(Rc>); + +impl EventListener for EventProxy { + fn send_event(&self, event: Event) { + match event { + Event::Title(title) => { + let cleaned: String = title.chars().filter(|c| !c.is_control()).collect(); + let trimmed = cleaned.trim(); + if !trimmed.is_empty() { + self.0.borrow_mut().title = Some(trimmed.to_string()); + } + } + Event::PtyWrite(text) => { + self.0 + .borrow_mut() + .pty_writes + .extend_from_slice(text.as_bytes()); + } + // Clipboard, bell, damage and child-process events are not part + // of nightcrow's pane contract; drop them. + _ => {} + } + } +} + +pub struct PaneEmulator { + term: Term, + processor: Processor, + proxy: EventProxy, +} + +impl PaneEmulator { + /// Create an emulator with a `rows` x `cols` screen (clamped to the + /// minimum grid alacritty supports) and `scrollback_lines` of history. + pub fn new(rows: u16, cols: u16, scrollback_lines: usize) -> Self { + let config = Config { + scrolling_history: scrollback_lines, + ..Config::default() + }; + let proxy = EventProxy::default(); + let term = Term::new(config, &term_size(rows, cols), proxy.clone()); + Self { + term, + processor: Processor::new(), + proxy, + } + } + + /// Feed raw PTY output through the emulator, updating the screen state. + /// Returns the side effects (title change, terminal query responses) + /// the caller must apply. + pub fn process(&mut self, bytes: &[u8]) -> EmulatorEvents { + self.processor.advance(&mut self.term, bytes); + let mut state = self.proxy.0.borrow_mut(); + EmulatorEvents { + title: state.title.take(), + pty_writes: std::mem::take(&mut state.pty_writes), + } + } + + /// 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). + pub fn resize(&mut self, rows: u16, cols: u16) { + self.term.resize(term_size(rows, cols)); + } + + /// Set the absolute scrollback offset (0 = live bottom, larger = older) + /// and return the offset actually applied after clamping to the + /// available history. + pub fn set_scroll_offset(&mut self, offset: usize) -> usize { + let current = self.term.grid().display_offset(); + let delta = i64::try_from(offset) + .unwrap_or(i64::MAX) + .saturating_sub(current as i64) + .clamp(i32::MIN as i64, i32::MAX as i64) as i32; + self.term.scroll_display(Scroll::Delta(delta)); + self.term.grid().display_offset() + } + + /// Current scrollback offset (0 = live bottom). Production code tracks + /// the applied offset through `set_scroll_offset`'s return value; this + /// read-back exists for tests asserting emulator state directly. + #[cfg(test)] + pub fn scroll_offset(&self) -> usize { + self.term.grid().display_offset() + } + + /// Read-only view of the screen as currently scrolled. + pub fn view(&self) -> ScreenView<'_> { + ScreenView { term: &self.term } + } + + /// Which input, if any, a scroll request for this pane must be turned + /// into. See `ScrollSink`. Mouse reporting wins over `alternateScroll` + /// because a program that asked for wheel events wants them even on the + /// alternate screen — that is also the order xterm resolves them in. + /// + /// `MOUSE_MODE` alone is not enough: without `SGR_MOUSE` the program + /// expects the legacy X10 encoding, which cannot address columns past + /// 223. Rather than emit a second encoding for a case no modern TUI + /// uses, 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) { + ScrollSink::MouseWheel + } else if mode.contains(TermMode::ALT_SCREEN) && mode.contains(TermMode::ALTERNATE_SCROLL) { + ScrollSink::ArrowKeys + } else { + ScrollSink::Scrollback + } + } + + /// Whether the program asked for mouse button reports in SGR form — + /// the gate for forwarding clicks. The mode set is the same one that + /// routes wheel scrolls to `ScrollSink::MouseWheel`, but it is a + /// separate predicate because the meaning differs: a click has no + /// scrollback fallback, it 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) + } + + /// Whether the program enabled DECCKM (application cursor keys), which + /// changes the arrow-key encoding from `ESC [ A` to `ESC O A`. + pub fn app_cursor(&self) -> bool { + self.term.mode().contains(TermMode::APP_CURSOR) + } +} + +/// Clamp a requested pane size to alacritty's supported minimum grid. +/// `Term` expects its embedder to enforce `MIN_COLUMNS`/`MIN_SCREEN_LINES` +/// (the alacritty app clamps its window the same way); in particular 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. +pub fn effective_size(rows: u16, cols: u16) -> (u16, u16) { + ( + rows.max(MIN_SCREEN_LINES as u16), + cols.max(MIN_COLUMNS as u16), + ) +} + +fn term_size(rows: u16, cols: u16) -> TermSize { + let (rows, cols) = effective_size(rows, cols); + TermSize::new(usize::from(cols), usize::from(rows)) +} + +mod view; + +pub use view::{CellView, ScreenView}; + +#[cfg(test)] +mod tests; diff --git a/src/runtime/emulator/tests.rs b/src/runtime/emulator/tests.rs new file mode 100644 index 0000000..012fa17 --- /dev/null +++ b/src/runtime/emulator/tests.rs @@ -0,0 +1,245 @@ +use super::view::to_ratatui_color; +use super::*; +use alacritty_terminal::vte::ansi::{Color, NamedColor}; + +fn contents(view: &ScreenView<'_>, row: u16, col: u16) -> String { + let mut out = String::new(); + view.cell(row, col).unwrap().append_contents(&mut out); + out +} + +#[test] +fn process_writes_text_and_tracks_cursor() { + let mut emu = PaneEmulator::new(3, 10, 0); + emu.process(b"hi"); + + assert_eq!(contents(&emu.view(), 0, 0), "h"); + assert_eq!(contents(&emu.view(), 0, 1), "i"); + assert_eq!(emu.view().cursor_position(), (0, 2)); +} + +#[test] +fn osc_title_is_captured_and_cleaned() { + let mut emu = PaneEmulator::new(3, 20, 0); + // Embedded control bytes and surrounding whitespace must not leak + // into the tab label; OSC 0 (icon + title) works like OSC 2. + let events = emu.process(b"\x1b]0; cargo\t test \x07"); + assert_eq!(events.title.as_deref(), Some("cargo test")); + + // An empty (or whitespace-only) title must not override the current one. + let events = emu.process(b"\x1b]2; \x1b\\"); + assert_eq!(events.title, None); +} + +#[test] +fn title_is_none_when_chunk_sets_no_title() { + let mut emu = PaneEmulator::new(3, 20, 0); + let events = emu.process(b"plain output"); + assert_eq!(events.title, None); +} + +#[test] +fn cursor_position_report_produces_pty_write() { + let mut emu = PaneEmulator::new(5, 20, 0); + // DSR 6: the program asks where the cursor is; the emulator must + // answer through the PTY (vt100 silently dropped such queries). + let events = emu.process(b"\x1b[2;3H\x1b[6n"); + assert_eq!(events.pty_writes, b"\x1b[2;3R"); +} + +#[test] +fn bracketed_paste_mode_follows_decset() { + let mut emu = PaneEmulator::new(3, 10, 0); + assert!(!emu.view().bracketed_paste()); + emu.process(b"\x1b[?2004h"); + assert!(emu.view().bracketed_paste()); + emu.process(b"\x1b[?2004l"); + assert!(!emu.view().bracketed_paste()); +} + +#[test] +fn wide_char_occupies_two_columns_with_spacer() { + let mut emu = PaneEmulator::new(3, 10, 0); + emu.process("가".as_bytes()); + + let view = emu.view(); + assert_eq!(contents(&view, 0, 0), "가"); + assert!(!view.cell(0, 0).unwrap().is_wide_spacer()); + assert!(view.cell(0, 1).unwrap().is_wide_spacer()); +} + +#[test] +fn shrink_through_wide_char_then_erase_does_not_panic() { + // Regression for the crash that motivated this module: vt100 + // panicked (row.rs clear_wide index out of bounds) when a resize + // truncated a wide character at the last column and the program + // then issued Erase Display. See vt100-rust issue #28. + let mut emu = PaneEmulator::new(5, 20, 0); + emu.process("가나다라마바사아자차".as_bytes()); + emu.resize(5, 19); + emu.process(b"\x1b[1;1H\x1b[J"); + + // Survival is the contract; the screen must also report the new size. + assert_eq!(emu.view().size(), (5, 19)); +} + +#[test] +fn every_shrink_width_survives_wide_char_erase() { + for cols in 1..20u16 { + let mut emu = PaneEmulator::new(5, 20, 0); + emu.process("가나다라마바사아자차".as_bytes()); + emu.resize(5, cols); + emu.process(b"\x1b[1;1H\x1b[J"); + } +} + +#[test] +fn scroll_offset_is_clamped_to_history() { + let mut emu = PaneEmulator::new(3, 10, 5); + // 3-row screen + 8 lines written = 5 lines of history (within cap). + for i in 0..8 { + emu.process(format!("line{i}\r\n").as_bytes()); + } + let applied = emu.set_scroll_offset(9999); + assert_eq!(applied, 5); + assert_eq!(emu.set_scroll_offset(0), 0); +} + +#[test] +fn scrolled_view_shows_history_lines() { + let mut emu = PaneEmulator::new(3, 10, 100); + for i in 0..10 { + emu.process(format!("line{i}\r\n").as_bytes()); + } + emu.set_scroll_offset(2); + // Live top row would be line8; scrolled back 2 it must show line6. + let view = emu.view(); + let row: String = (0..5).map(|c| contents(&view, 0, c)).collect(); + assert_eq!(row, "line6"); +} + +#[test] +fn scroll_sink_defaults_to_scrollback_for_a_plain_shell() { + let emu = PaneEmulator::new(3, 10, 100); + assert_eq!(emu.scroll_sink(), ScrollSink::Scrollback); +} + +#[test] +fn scroll_sink_is_mouse_wheel_when_program_reports_sgr_mouse() { + let mut emu = PaneEmulator::new(3, 10, 100); + // The exact mode set Claude Code emits on startup. + emu.process(b"\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h"); + assert_eq!(emu.scroll_sink(), ScrollSink::MouseWheel); +} + +#[test] +fn scroll_sink_ignores_mouse_reporting_without_sgr_encoding() { + let mut emu = PaneEmulator::new(3, 10, 100); + // X10-encoded mouse reporting: we have no encoder for it, so the + // pane must not be handed wheel bytes it cannot parse. + emu.process(b"\x1b[?1000h"); + assert_eq!(emu.scroll_sink(), ScrollSink::Scrollback); +} + +#[test] +fn wants_mouse_buttons_when_program_reports_sgr_mouse() { + let mut emu = PaneEmulator::new(3, 10, 100); + // The exact mode set Claude Code emits on startup. + emu.process(b"\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h"); + assert!(emu.wants_mouse_buttons()); +} + +#[test] +fn wants_mouse_buttons_rejects_shell_and_x10_only_panes() { + let mut emu = PaneEmulator::new(3, 10, 100); + // A plain shell never claims the mouse: no click byte may reach it. + assert!(!emu.wants_mouse_buttons()); + // X10-encoded reporting without SGR: we have no encoder for it. + emu.process(b"\x1b[?1000h"); + assert!(!emu.wants_mouse_buttons()); +} + +#[test] +fn scroll_sink_is_arrow_keys_on_alternate_screen() { + let mut emu = PaneEmulator::new(3, 10, 100); + emu.process(b"\x1b[?1049h"); + assert_eq!(emu.scroll_sink(), ScrollSink::ArrowKeys); +} + +#[test] +fn scroll_sink_falls_back_when_alternate_scroll_is_disabled() { + let mut emu = PaneEmulator::new(3, 10, 100); + emu.process(b"\x1b[?1049h\x1b[?1007l"); + assert_eq!(emu.scroll_sink(), ScrollSink::Scrollback); +} + +#[test] +fn scroll_sink_prefers_mouse_wheel_over_alternate_screen() { + let mut emu = PaneEmulator::new(3, 10, 100); + emu.process(b"\x1b[?1049h\x1b[?1000h\x1b[?1006h"); + assert_eq!(emu.scroll_sink(), ScrollSink::MouseWheel); +} + +#[test] +fn alternate_screen_keeps_no_scrollback() { + // The reason `ScrollSink` exists: alacritty gives the alternate grid + // zero history, so a scroll offset there can never leave 0 and the + // grid has nothing to reveal. + let mut emu = PaneEmulator::new(3, 10, 100); + emu.process(b"\x1b[?1049h"); + for i in 0..20 { + emu.process(format!("line{i}\r\n").as_bytes()); + } + assert_eq!(emu.set_scroll_offset(999), 0); +} + +#[test] +fn app_cursor_follows_decckm() { + let mut emu = PaneEmulator::new(3, 10, 0); + assert!(!emu.app_cursor()); + emu.process(b"\x1b[?1h"); + assert!(emu.app_cursor()); + emu.process(b"\x1b[?1l"); + assert!(!emu.app_cursor()); +} + +#[test] +fn zero_size_is_clamped_to_minimum_grid() { + // alacritty's documented minimum is 1 line x 2 columns; anything + // smaller (a 1-column grid especially) breaks wide-char reflow. + let mut emu = PaneEmulator::new(0, 0, 0); + assert_eq!(emu.view().size(), (1, 2)); + emu.resize(0, 0); + assert_eq!(emu.view().size(), (1, 2)); + emu.process(b"x"); // must not panic on the minimal grid +} + +#[test] +fn named_colors_map_to_indexed_and_defaults_to_reset() { + use ratatui::style::Color as C; + assert_eq!( + to_ratatui_color(Color::Named(NamedColor::Red)), + C::Indexed(1) + ); + assert_eq!( + to_ratatui_color(Color::Named(NamedColor::BrightWhite)), + C::Indexed(15) + ); + assert_eq!( + to_ratatui_color(Color::Named(NamedColor::DimBlue)), + C::Indexed(4) + ); + assert_eq!( + to_ratatui_color(Color::Named(NamedColor::Foreground)), + C::Reset + ); + assert_eq!(to_ratatui_color(Color::Indexed(42)), C::Indexed(42)); + assert_eq!( + to_ratatui_color(Color::Spec(alacritty_terminal::vte::ansi::Rgb { + r: 1, + g: 2, + b: 3 + })), + C::Rgb(1, 2, 3) + ); +} \ No newline at end of file diff --git a/src/runtime/emulator/view.rs b/src/runtime/emulator/view.rs new file mode 100644 index 0000000..913ab11 --- /dev/null +++ b/src/runtime/emulator/view.rs @@ -0,0 +1,134 @@ +use super::EventProxy; +use alacritty_terminal::index::{Column, Line, Point}; +use alacritty_terminal::term::cell::{Cell, Flags}; +use alacritty_terminal::term::{Term, TermMode}; +use alacritty_terminal::vte::ansi::{Color, NamedColor}; + +/// Read-only query surface over an emulated screen. Rows/columns are +/// viewport coordinates: `(0, 0)` is the top-left of what should be drawn, +/// already accounting for the current scrollback offset. +pub struct ScreenView<'a> { + pub(super) term: &'a Term, +} + +impl<'a> ScreenView<'a> { + /// (rows, cols) of the emulated screen. + pub fn size(&self) -> (u16, u16) { + let grid = self.term.grid(); + (grid.screen_lines() as u16, grid.columns() as u16) + } + + /// Cell at a viewport position, or `None` when out of bounds. + pub fn cell(&self, row: u16, col: u16) -> Option> { + let grid = self.term.grid(); + if usize::from(row) >= grid.screen_lines() || usize::from(col) >= grid.columns() { + return None; + } + // Viewport row 0 maps to grid line -display_offset: scrolling back + // shifts the view into negative (history) line indices. + let line = Line(i32::from(row) - grid.display_offset() as i32); + let point = Point::new(line, Column(usize::from(col))); + Some(CellView { cell: &grid[point] }) + } + + /// Live cursor position as (row, col), independent of the scrollback + /// offset and of the program's DECTCEM show/hide state — nightcrow + /// always exposes the input point of the focused pane. + pub fn cursor_position(&self) -> (u16, u16) { + let point = self.term.grid().cursor.point; + (point.line.0.max(0) as u16, point.column.0 as u16) + } + + /// Whether the running program enabled bracketed paste (DECSET 2004). + pub fn bracketed_paste(&self) -> bool { + self.term.mode().contains(TermMode::BRACKETED_PASTE) + } +} + +/// One screen cell. Wide characters occupy two columns: the glyph lives on +/// the first cell and the second reports `is_wide_spacer()`. +pub struct CellView<'a> { + cell: &'a Cell, +} + +impl CellView<'_> { + /// Whether this cell is the spacer half of a wide character (or the + /// filler before a wide character that wrapped). Emitting text for it + /// would shift the rest of the row by one column. + pub fn is_wide_spacer(&self) -> bool { + self.cell + .flags + .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) + } + + /// Append the cell's visible contents (base char plus any zero-width + /// combining chars) to `out`. + pub fn append_contents(&self, out: &mut String) { + out.push(self.cell.c); + if let Some(zerowidth) = self.cell.zerowidth() { + out.extend(zerowidth); + } + } + + pub fn fg(&self) -> ratatui::style::Color { + to_ratatui_color(self.cell.fg) + } + + pub fn bg(&self) -> ratatui::style::Color { + to_ratatui_color(self.cell.bg) + } + + pub fn bold(&self) -> bool { + self.cell.flags.contains(Flags::BOLD) + } + + pub fn italic(&self) -> bool { + self.cell.flags.contains(Flags::ITALIC) + } + + pub fn underline(&self) -> bool { + self.cell.flags.contains(Flags::UNDERLINE) + } + + pub fn inverse(&self) -> bool { + self.cell.flags.contains(Flags::INVERSE) + } + + pub fn dim(&self) -> bool { + self.cell.flags.contains(Flags::DIM) + } +} + +/// 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. +pub(super) fn to_ratatui_color(color: Color) -> ratatui::style::Color { + use ratatui::style::Color as C; + match color { + Color::Spec(rgb) => C::Rgb(rgb.r, rgb.g, rgb.b), + Color::Indexed(i) => C::Indexed(i), + Color::Named(named) => { + let idx = named as usize; + if idx < 16 { + C::Indexed(idx as u8) + } else { + match named { + NamedColor::DimBlack => C::Indexed(0), + NamedColor::DimRed => C::Indexed(1), + NamedColor::DimGreen => C::Indexed(2), + NamedColor::DimYellow => C::Indexed(3), + NamedColor::DimBlue => C::Indexed(4), + NamedColor::DimMagenta => C::Indexed(5), + NamedColor::DimCyan => C::Indexed(6), + NamedColor::DimWhite => C::Indexed(7), + // Foreground, Background, Cursor, BrightForeground, + // DimForeground: no fixed palette slot — defer to the + // host terminal's defaults. + _ => C::Reset, + } + } + } + } +} \ No newline at end of file diff --git a/src/web/viewer/terminal.rs b/src/web/viewer/terminal/mod.rs similarity index 100% rename from src/web/viewer/terminal.rs rename to src/web/viewer/terminal/mod.rs From 902da78c2d1e88b1dc6b2a40a3d54e33bd83ee86 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 25 Jul 2026 00:49:22 +0900 Subject: [PATCH 03/15] refactor: split web protocol into decode + tests submodules --- src/web/protocol.rs | 584 ------------------------------------- src/web/protocol/decode.rs | 218 ++++++++++++++ src/web/protocol/mod.rs | 91 ++++++ src/web/protocol/tests.rs | 281 ++++++++++++++++++ 4 files changed, 590 insertions(+), 584 deletions(-) delete mode 100644 src/web/protocol.rs create mode 100644 src/web/protocol/decode.rs create mode 100644 src/web/protocol/mod.rs create mode 100644 src/web/protocol/tests.rs diff --git a/src/web/protocol.rs b/src/web/protocol.rs deleted file mode 100644 index 39fc44e..0000000 --- a/src/web/protocol.rs +++ /dev/null @@ -1,584 +0,0 @@ -//! Wire protocol for the web mirror: server→browser screen frames and -//! browser→server input events. -//! -//! **Output** re-uses ratatui's own `CrosstermBackend` to turn a `Buffer` -//! (full frame) or a `Buffer`→`Buffer` diff (incremental) into ANSI. The bytes -//! are therefore byte-identical to what the local terminal receives, and each -//! encoded chunk self-terminates with a style reset (crossterm's `draw` -//! appends one), so chunks concatenate cleanly on a single xterm.js instance. -//! -//! **Input** decodes a small JSON envelope (`{"t":"key"|"mouse"|"paste",…}`) -//! into a crossterm `KeyEvent`/`MouseEvent`/paste string, so browser input runs -//! through the exact same `handle_key`/`handle_mouse`/`handle_paste` routing as -//! local input — a web action can never diverge from the equivalent keypress. - -use anyhow::{Result, bail}; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; -use ratatui::backend::{Backend, CrosstermBackend}; -use ratatui::buffer::Buffer; -use ratatui::layout::Position; -use serde::Deserialize; - -/// A decoded browser input event, already lowered to the crossterm types the -/// local input path consumes. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum WebInputEvent { - Key(KeyEvent), - Mouse(MouseEvent), - Paste(String), -} - -/// Encode a full repaint of `current` for a freshly connected client. -/// -/// Clears the screen first (so a reconnecting xterm.js drops any stale -/// content), then paints every non-blank cell. Blank default cells are omitted -/// because a cleared terminal already shows them. -pub fn encode_full_frame(current: &Buffer) -> Vec { - let blank = Buffer::empty(*current.area()); - let updates = blank.diff(current); - let mut out = Vec::new(); - // Hide the cursor for the duration of the repaint so it doesn't visibly - // chase the painted cells; `encode_cursor` re-shows it at the right spot. - out.extend_from_slice(b"\x1b[?25l"); - { - let mut backend = CrosstermBackend::new(&mut out); - // `clear` + `draw` both write ANSI into the Vec; flush on a Vec is a - // no-op and neither can fail on an in-memory writer. - let _ = backend.clear(); - let _ = backend.draw(updates.into_iter()); - } - out -} - -/// Encode the incremental update needed to bring a client from `previous` to -/// `current`. Returns an empty Vec when nothing changed (caller skips the send) -/// or when the two buffers have different dimensions — a size change is not a -/// cell-level diff and must be handled by re-sending a full frame instead. -pub fn encode_update(previous: &Buffer, current: &Buffer) -> Vec { - if previous.area() != current.area() { - return Vec::new(); - } - let updates = previous.diff(current); - if updates.is_empty() { - return Vec::new(); - } - let mut out = Vec::new(); - { - let mut backend = CrosstermBackend::new(&mut out); - let _ = backend.draw(updates.into_iter()); - } - out -} - -/// Encode the trailing cursor state for a frame chunk. -/// -/// The cell buffer carries no cursor — ratatui applies it to the local terminal -/// directly — so every chunk sent to a browser ends with an explicit park: -/// either a move to `cursor` plus show, or a hide when the frame has no cursor -/// (terminal panel unfocused, scrolled back, or not rendered at all). Without -/// this the browser would keep the cursor wherever the last painted cell left -/// it. Coordinates are absolute screen cells; ANSI is 1-based, the buffer 0. -pub fn encode_cursor(cursor: Option) -> Vec { - match cursor { - Some(p) => format!("\x1b[{};{}H\x1b[?25h", p.y as u32 + 1, p.x as u32 + 1).into_bytes(), - None => b"\x1b[?25l".to_vec(), - } -} - -/// JSON envelope sent by the browser. The `t` tag selects the variant; unknown -/// tags fail to deserialize and are reported as an error by `decode_input`. -#[derive(Debug, Deserialize)] -#[serde(tag = "t", rename_all = "lowercase")] -enum Wire { - Key { - /// The browser `KeyboardEvent.key` string (e.g. "a", "Enter", "F5"). - key: String, - #[serde(default)] - ctrl: bool, - #[serde(default)] - alt: bool, - #[serde(default)] - shift: bool, - #[serde(default)] - meta: bool, - }, - Mouse { - /// "down" | "up" | "move" | "wheel". - kind: String, - #[serde(default)] - button: Option, - /// 0-based absolute screen cell coordinates. - col: u16, - row: u16, - /// Wheel direction for `kind == "wheel"`: "up"|"down"|"left"|"right". - #[serde(default)] - dir: Option, - #[serde(default)] - ctrl: bool, - #[serde(default)] - alt: bool, - #[serde(default)] - shift: bool, - }, - Paste { - data: String, - }, -} - -/// Decode one JSON input message into a `WebInputEvent`. -/// -/// Returns `Ok(None)` for a well-formed message that carries no actionable -/// event — e.g. a modifier-only keypress ("Shift") or an unmappable wheel -/// direction — so the caller can silently drop it. Returns `Err` only for -/// malformed JSON or an unknown message type, which signals a misbehaving or -/// hostile client. -pub fn decode_input(json: &str) -> Result> { - let wire: Wire = serde_json::from_str(json) - .map_err(|e| anyhow::anyhow!("invalid web input message: {e}"))?; - Ok(match wire { - Wire::Key { - key, - ctrl, - alt, - shift, - meta, - } => decode_key(&key, ctrl, alt, shift, meta).map(WebInputEvent::Key), - Wire::Mouse { - kind, - button, - col, - row, - dir, - ctrl, - alt, - shift, - } => decode_mouse( - &kind, - button.as_deref(), - col, - row, - dir.as_deref(), - ctrl, - alt, - shift, - ) - .map(WebInputEvent::Mouse), - Wire::Paste { data } => Some(WebInputEvent::Paste(data)), - }) -} - -fn modifiers(ctrl: bool, alt: bool, shift: bool, meta: bool) -> KeyModifiers { - let mut m = KeyModifiers::NONE; - if ctrl { - m |= KeyModifiers::CONTROL; - } - if alt { - m |= KeyModifiers::ALT; - } - if shift { - m |= KeyModifiers::SHIFT; - } - if meta { - m |= KeyModifiers::SUPER; - } - m -} - -/// Map a browser `KeyboardEvent.key` + modifier flags to a crossterm -/// `KeyEvent`. Returns `None` for modifier-only or unidentified keys. -fn decode_key(key: &str, ctrl: bool, alt: bool, shift: bool, meta: bool) -> Option { - let mut mods = modifiers(ctrl, alt, shift, meta); - let code = match key { - "Enter" => KeyCode::Enter, - // Shift+Tab is a distinct key at the terminal level (crossterm reports - // BackTab, not Tab+Shift); mirror that so it encodes to ESC[Z. - "Tab" if shift => { - mods.remove(KeyModifiers::SHIFT); - KeyCode::BackTab - } - "Tab" => KeyCode::Tab, - "Backspace" => KeyCode::Backspace, - "Escape" | "Esc" => KeyCode::Esc, - "ArrowUp" => KeyCode::Up, - "ArrowDown" => KeyCode::Down, - "ArrowLeft" => KeyCode::Left, - "ArrowRight" => KeyCode::Right, - "Home" => KeyCode::Home, - "End" => KeyCode::End, - "PageUp" => KeyCode::PageUp, - "PageDown" => KeyCode::PageDown, - "Insert" => KeyCode::Insert, - "Delete" => KeyCode::Delete, - " " => KeyCode::Char(' '), - _ => { - if let Some(n) = key - .strip_prefix('F') - .and_then(|d| (!d.is_empty()).then(|| d.parse::().ok()).flatten()) - { - if (1..=24).contains(&n) { - return Some(KeyEvent::new(KeyCode::F(n), mods)); - } - return None; - } - // A single Unicode scalar is a printable character. Multi-char key - // names ("Shift", "Control", "Dead", "Unidentified", …) are not - // actionable input and are dropped. - let mut chars = key.chars(); - match (chars.next(), chars.next()) { - (Some(c), None) => { - // A printable glyph already encodes its shifted form, so - // drop SHIFT to match how crossterm reports typed chars. - mods.remove(KeyModifiers::SHIFT); - KeyCode::Char(c) - } - _ => return None, - } - } - }; - Some(KeyEvent::new(code, mods)) -} - -fn mouse_button(button: Option<&str>) -> MouseButton { - match button { - Some("right") => MouseButton::Right, - Some("middle") => MouseButton::Middle, - // Default to the left button: press/release messages always name a - // button in practice, and left is the only one the hint/tab/pane click - // paths act on. - _ => MouseButton::Left, - } -} - -/// Map a browser mouse message to a crossterm `MouseEvent`. Returns `None` for -/// an unknown kind or an unmappable wheel direction. -#[allow(clippy::too_many_arguments)] -fn decode_mouse( - kind: &str, - button: Option<&str>, - col: u16, - row: u16, - dir: Option<&str>, - ctrl: bool, - alt: bool, - shift: bool, -) -> Option { - let event_kind = match kind { - "down" => MouseEventKind::Down(mouse_button(button)), - "up" => MouseEventKind::Up(mouse_button(button)), - "move" => match button { - Some(b) if b != "none" => MouseEventKind::Drag(mouse_button(button)), - _ => MouseEventKind::Moved, - }, - "wheel" => match dir { - Some("up") => MouseEventKind::ScrollUp, - Some("down") => MouseEventKind::ScrollDown, - Some("left") => MouseEventKind::ScrollLeft, - Some("right") => MouseEventKind::ScrollRight, - _ => return None, - }, - _ => return None, - }; - Some(MouseEvent { - kind: event_kind, - column: col, - row, - modifiers: modifiers(ctrl, alt, shift, false), - }) -} - -/// Reject a payload larger than this before parsing, so a hostile client can't -/// force a huge allocation. Generous enough for any real paste. -pub const MAX_INPUT_MESSAGE_BYTES: usize = 1 << 20; - -/// Guard used by the server before calling `decode_input`: an oversized frame -/// is refused outright. -pub fn ensure_input_size(len: usize) -> Result<()> { - if len > MAX_INPUT_MESSAGE_BYTES { - bail!("web input message of {len} bytes exceeds the {MAX_INPUT_MESSAGE_BYTES}-byte cap"); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use ratatui::layout::Rect; - use ratatui::style::{Color, Style}; - - fn buf(w: u16, h: u16) -> Buffer { - Buffer::empty(Rect::new(0, 0, w, h)) - } - - #[test] - fn full_frame_clears_then_paints_content() { - let mut b = buf(10, 2); - b.set_string(0, 0, "hi", Style::default()); - let bytes = encode_full_frame(&b); - // Clears first (ESC[2J) so a reconnecting client drops stale content, - // and the painted text is present. - assert!( - bytes.windows(4).any(|w| w == b"\x1b[2J"), - "full frame must clear" - ); - assert!( - bytes.windows(2).any(|w| w == b"hi"), - "full frame must paint the cell content" - ); - } - - #[test] - fn cursor_at_a_cell_moves_and_shows_in_one_based_coords() { - let bytes = encode_cursor(Some(Position::new(3, 7))); - assert_eq!(bytes, b"\x1b[8;4H\x1b[?25h".to_vec()); - } - - #[test] - fn absent_cursor_hides_it() { - assert_eq!(encode_cursor(None), b"\x1b[?25l".to_vec()); - } - - #[test] - fn update_emits_only_changed_cells() { - let mut prev = buf(10, 1); - prev.set_string(0, 0, "cat", Style::default()); - let mut next = prev.clone(); - next.set_string(0, 0, "car", Style::default()); - - let bytes = encode_update(&prev, &next); - let text = String::from_utf8_lossy(&bytes); - // Only the third column changed ('t' -> 'r'); the update must carry the - // new glyph but not repaint the unchanged prefix. - assert!(text.contains('r'), "changed glyph must be present"); - assert!( - !text.contains("car"), - "unchanged prefix must not be repainted" - ); - } - - #[test] - fn update_is_empty_when_nothing_changed() { - let b = { - let mut b = buf(4, 1); - b.set_string(0, 0, "same", Style::default()); - b - }; - assert!( - encode_update(&b, &b.clone()).is_empty(), - "an identical frame produces no bytes" - ); - } - - #[test] - fn update_is_empty_on_size_change() { - let prev = buf(4, 1); - let next = buf(6, 1); - assert!( - encode_update(&prev, &next).is_empty(), - "a size change is not a cell diff; caller must send a full frame" - ); - } - - #[test] - fn full_frame_carries_color_styling() { - let mut b = buf(4, 1); - b.set_string(0, 0, "x", Style::default().fg(Color::Red)); - let bytes = encode_full_frame(&b); - // Crossterm encodes a red foreground via an SGR sequence; assert some - // SGR + the glyph made it through (exact code is crossterm's concern). - assert!( - bytes.contains(&0x1b), - "styled output must contain escape codes" - ); - assert!(bytes.contains(&b'x')); - } - - #[test] - fn decode_plain_char_key() { - let ev = decode_input(r#"{"t":"key","key":"a"}"#).unwrap().unwrap(); - assert_eq!( - ev, - WebInputEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)) - ); - } - - #[test] - fn decode_ctrl_f_matches_default_leader() { - // The default leader is Ctrl+F; a browser ctrl+f must decode to the - // identical KeyEvent so the leader arms from the web too. - let ev = decode_input(r#"{"t":"key","key":"f","ctrl":true}"#) - .unwrap() - .unwrap(); - assert_eq!( - ev, - WebInputEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)) - ); - } - - #[test] - fn decode_uppercase_char_drops_shift() { - // The glyph already encodes shift; SHIFT is dropped to match crossterm. - let ev = decode_input(r#"{"t":"key","key":"A","shift":true}"#) - .unwrap() - .unwrap(); - assert_eq!( - ev, - WebInputEvent::Key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE)) - ); - } - - #[test] - fn decode_named_keys() { - let cases = [ - (r#"{"t":"key","key":"Enter"}"#, KeyCode::Enter), - (r#"{"t":"key","key":"Escape"}"#, KeyCode::Esc), - (r#"{"t":"key","key":"ArrowUp"}"#, KeyCode::Up), - (r#"{"t":"key","key":"PageDown"}"#, KeyCode::PageDown), - (r#"{"t":"key","key":"F5"}"#, KeyCode::F(5)), - (r#"{"t":"key","key":" "}"#, KeyCode::Char(' ')), - ]; - for (json, code) in cases { - let ev = decode_input(json).unwrap().unwrap(); - assert_eq!( - ev, - WebInputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)), - "for {json}" - ); - } - } - - #[test] - fn decode_shift_arrow_preserves_shift_for_reserved_scroll_keys() { - // Shift+Arrow is a reserved nightcrow key (terminal scroll / focus - // cycle); the SHIFT modifier must survive on non-char keys. - let ev = decode_input(r#"{"t":"key","key":"ArrowUp","shift":true}"#) - .unwrap() - .unwrap(); - assert_eq!( - ev, - WebInputEvent::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT)) - ); - } - - #[test] - fn decode_shift_tab_becomes_backtab() { - let ev = decode_input(r#"{"t":"key","key":"Tab","shift":true}"#) - .unwrap() - .unwrap(); - assert_eq!( - ev, - WebInputEvent::Key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE)) - ); - } - - #[test] - fn decode_letter_f_is_a_char_not_a_function_key() { - let ev = decode_input(r#"{"t":"key","key":"F"}"#).unwrap().unwrap(); - assert_eq!( - ev, - WebInputEvent::Key(KeyEvent::new(KeyCode::Char('F'), KeyModifiers::NONE)) - ); - } - - #[test] - fn decode_modifier_only_key_is_dropped() { - assert!( - decode_input(r#"{"t":"key","key":"Shift"}"#) - .unwrap() - .is_none() - ); - assert!( - decode_input(r#"{"t":"key","key":"Control"}"#) - .unwrap() - .is_none() - ); - assert!( - decode_input(r#"{"t":"key","key":"Dead"}"#) - .unwrap() - .is_none() - ); - } - - #[test] - fn decode_out_of_range_function_key_is_dropped() { - assert!( - decode_input(r#"{"t":"key","key":"F99"}"#) - .unwrap() - .is_none() - ); - } - - #[test] - fn decode_mouse_down_and_wheel_and_up() { - let down = decode_input(r#"{"t":"mouse","kind":"down","button":"left","col":3,"row":4}"#) - .unwrap() - .unwrap(); - assert_eq!( - down, - WebInputEvent::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 3, - row: 4, - modifiers: KeyModifiers::NONE, - }) - ); - - let wheel = decode_input(r#"{"t":"mouse","kind":"wheel","dir":"up","col":1,"row":1}"#) - .unwrap() - .unwrap(); - assert_eq!( - wheel, - WebInputEvent::Mouse(MouseEvent { - kind: MouseEventKind::ScrollUp, - column: 1, - row: 1, - modifiers: KeyModifiers::NONE, - }) - ); - - let up = decode_input(r#"{"t":"mouse","kind":"up","button":"right","col":2,"row":2}"#) - .unwrap() - .unwrap(); - assert_eq!( - up, - WebInputEvent::Mouse(MouseEvent { - kind: MouseEventKind::Up(MouseButton::Right), - column: 2, - row: 2, - modifiers: KeyModifiers::NONE, - }) - ); - } - - #[test] - fn decode_wheel_without_direction_is_dropped() { - assert!( - decode_input(r#"{"t":"mouse","kind":"wheel","col":1,"row":1}"#) - .unwrap() - .is_none() - ); - } - - #[test] - fn decode_paste() { - let ev = decode_input(r#"{"t":"paste","data":"hello\nworld"}"#) - .unwrap() - .unwrap(); - assert_eq!(ev, WebInputEvent::Paste("hello\nworld".to_string())); - } - - #[test] - fn decode_rejects_malformed_and_unknown() { - assert!(decode_input("not json").is_err()); - assert!(decode_input(r#"{"t":"explode"}"#).is_err()); - assert!( - decode_input(r#"{"t":"key"}"#).is_err(), - "missing required field" - ); - } - - #[test] - fn input_size_guard_rejects_oversized() { - assert!(ensure_input_size(MAX_INPUT_MESSAGE_BYTES).is_ok()); - assert!(ensure_input_size(MAX_INPUT_MESSAGE_BYTES + 1).is_err()); - } -} diff --git a/src/web/protocol/decode.rs b/src/web/protocol/decode.rs new file mode 100644 index 0000000..5d79916 --- /dev/null +++ b/src/web/protocol/decode.rs @@ -0,0 +1,218 @@ +use super::WebInputEvent; +use anyhow::{Result, bail}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use serde::Deserialize; + +/// JSON envelope sent by the browser. The `t` tag selects the variant; unknown +/// tags fail to deserialize and are reported as an error by `decode_input`. +#[derive(Debug, Deserialize)] +#[serde(tag = "t", rename_all = "lowercase")] +enum Wire { + Key { + /// The browser `KeyboardEvent.key` string (e.g. "a", "Enter", "F5"). + key: String, + #[serde(default)] + ctrl: bool, + #[serde(default)] + alt: bool, + #[serde(default)] + shift: bool, + #[serde(default)] + meta: bool, + }, + Mouse { + /// "down" | "up" | "move" | "wheel". + kind: String, + #[serde(default)] + button: Option, + /// 0-based absolute screen cell coordinates. + col: u16, + row: u16, + /// Wheel direction for `kind == "wheel"`: "up"|"down"|"left"|"right". + #[serde(default)] + dir: Option, + #[serde(default)] + ctrl: bool, + #[serde(default)] + alt: bool, + #[serde(default)] + shift: bool, + }, + Paste { + data: String, + }, +} + +/// Decode one JSON input message into a `WebInputEvent`. +/// +/// Returns `Ok(None)` for a well-formed message that carries no actionable +/// event — e.g. a modifier-only keypress ("Shift") or an unmappable wheel +/// direction — so the caller can silently drop it. Returns `Err` only for +/// malformed JSON or an unknown message type, which signals a misbehaving or +/// hostile client. +pub fn decode_input(json: &str) -> Result> { + let wire: Wire = serde_json::from_str(json) + .map_err(|e| anyhow::anyhow!("invalid web input message: {e}"))?; + Ok(match wire { + Wire::Key { + key, + ctrl, + alt, + shift, + meta, + } => decode_key(&key, ctrl, alt, shift, meta).map(WebInputEvent::Key), + Wire::Mouse { + kind, + button, + col, + row, + dir, + ctrl, + alt, + shift, + } => decode_mouse( + &kind, + button.as_deref(), + col, + row, + dir.as_deref(), + ctrl, + alt, + shift, + ) + .map(WebInputEvent::Mouse), + Wire::Paste { data } => Some(WebInputEvent::Paste(data)), + }) +} + +fn modifiers(ctrl: bool, alt: bool, shift: bool, meta: bool) -> KeyModifiers { + let mut m = KeyModifiers::NONE; + if ctrl { + m |= KeyModifiers::CONTROL; + } + if alt { + m |= KeyModifiers::ALT; + } + if shift { + m |= KeyModifiers::SHIFT; + } + if meta { + m |= KeyModifiers::SUPER; + } + m +} + +/// Map a browser `KeyboardEvent.key` + modifier flags to a crossterm +/// `KeyEvent`. Returns `None` for modifier-only or unidentified keys. +fn decode_key(key: &str, ctrl: bool, alt: bool, shift: bool, meta: bool) -> Option { + let mut mods = modifiers(ctrl, alt, shift, meta); + let code = match key { + "Enter" => KeyCode::Enter, + // Shift+Tab is a distinct key at the terminal level (crossterm reports + // BackTab, not Tab+Shift); mirror that so it encodes to ESC[Z. + "Tab" if shift => { + mods.remove(KeyModifiers::SHIFT); + KeyCode::BackTab + } + "Tab" => KeyCode::Tab, + "Backspace" => KeyCode::Backspace, + "Escape" | "Esc" => KeyCode::Esc, + "ArrowUp" => KeyCode::Up, + "ArrowDown" => KeyCode::Down, + "ArrowLeft" => KeyCode::Left, + "ArrowRight" => KeyCode::Right, + "Home" => KeyCode::Home, + "End" => KeyCode::End, + "PageUp" => KeyCode::PageUp, + "PageDown" => KeyCode::PageDown, + "Insert" => KeyCode::Insert, + "Delete" => KeyCode::Delete, + " " => KeyCode::Char(' '), + _ => { + if let Some(n) = key + .strip_prefix('F') + .and_then(|d| (!d.is_empty()).then(|| d.parse::().ok()).flatten()) + { + if (1..=24).contains(&n) { + return Some(KeyEvent::new(KeyCode::F(n), mods)); + } + return None; + } + // A single Unicode scalar is a printable character. Multi-char key + // names ("Shift", "Control", "Dead", "Unidentified", …) are not + // actionable input and are dropped. + let mut chars = key.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) => { + // A printable glyph already encodes its shifted form, so + // drop SHIFT to match how crossterm reports typed chars. + mods.remove(KeyModifiers::SHIFT); + KeyCode::Char(c) + } + _ => return None, + } + } + }; + Some(KeyEvent::new(code, mods)) +} + +fn mouse_button(button: Option<&str>) -> MouseButton { + match button { + Some("right") => MouseButton::Right, + Some("middle") => MouseButton::Middle, + // Default to the left button: press/release messages always name a + // button in practice, and left is the only one the hint/tab/pane click + // paths act on. + _ => MouseButton::Left, + } +} + +/// Map a browser mouse message to a crossterm `MouseEvent`. Returns `None` for +/// an unknown kind or an unmappable wheel direction. +#[allow(clippy::too_many_arguments)] +fn decode_mouse( + kind: &str, + button: Option<&str>, + col: u16, + row: u16, + dir: Option<&str>, + ctrl: bool, + alt: bool, + shift: bool, +) -> Option { + let event_kind = match kind { + "down" => MouseEventKind::Down(mouse_button(button)), + "up" => MouseEventKind::Up(mouse_button(button)), + "move" => match button { + Some(b) if b != "none" => MouseEventKind::Drag(mouse_button(button)), + _ => MouseEventKind::Moved, + }, + "wheel" => match dir { + Some("up") => MouseEventKind::ScrollUp, + Some("down") => MouseEventKind::ScrollDown, + Some("left") => MouseEventKind::ScrollLeft, + Some("right") => MouseEventKind::ScrollRight, + _ => return None, + }, + _ => return None, + }; + Some(MouseEvent { + kind: event_kind, + column: col, + row, + modifiers: modifiers(ctrl, alt, shift, false), + }) +} + +/// Reject a payload larger than this before parsing, so a hostile client can't +/// force a huge allocation. Generous enough for any real paste. +pub const MAX_INPUT_MESSAGE_BYTES: usize = 1 << 20; + +/// Guard used by the server before calling `decode_input`: an oversized frame +/// is refused outright. +pub fn ensure_input_size(len: usize) -> Result<()> { + if len > MAX_INPUT_MESSAGE_BYTES { + bail!("web input message of {len} bytes exceeds the {MAX_INPUT_MESSAGE_BYTES}-byte cap"); + } + Ok(()) +} \ No newline at end of file diff --git a/src/web/protocol/mod.rs b/src/web/protocol/mod.rs new file mode 100644 index 0000000..e79bff5 --- /dev/null +++ b/src/web/protocol/mod.rs @@ -0,0 +1,91 @@ +//! Wire protocol for the web mirror: server→browser screen frames and +//! browser→server input events. +//! +//! **Output** re-uses ratatui's own `CrosstermBackend` to turn a `Buffer` +//! (full frame) or a `Buffer`→`Buffer` diff (incremental) into ANSI. The bytes +//! are therefore byte-identical to what the local terminal receives, and each +//! encoded chunk self-terminates with a style reset (crossterm's `draw` +//! appends one), so chunks concatenate cleanly on a single xterm.js instance. +//! +//! **Input** decodes a small JSON envelope (`{"t":"key"|"mouse"|"paste",…}`) +//! into a crossterm `KeyEvent`/`MouseEvent`/paste string, so browser input runs +//! through the exact same `handle_key`/`handle_mouse`/`handle_paste` routing as +//! local input — a web action can never diverge from the equivalent keypress. + +use anyhow::Result; +use ratatui::backend::{Backend, CrosstermBackend}; +use ratatui::buffer::Buffer; +use ratatui::layout::Position; + +/// A decoded browser input event, already lowered to the crossterm types the +/// local input path consumes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WebInputEvent { + Key(KeyEvent), + Mouse(MouseEvent), + Paste(String), +} + +/// Encode a full repaint of `current` for a freshly connected client. +/// +/// Clears the screen first (so a reconnecting xterm.js drops any stale +/// content), then paints every non-blank cell. Blank default cells are omitted +/// because a cleared terminal already shows them. +pub fn encode_full_frame(current: &Buffer) -> Vec { + let blank = Buffer::empty(*current.area()); + let updates = blank.diff(current); + let mut out = Vec::new(); + // Hide the cursor for the duration of the repaint so it doesn't visibly + // chase the painted cells; `encode_cursor` re-shows it at the right spot. + out.extend_from_slice(b"\x1b[?25l"); + { + let mut backend = CrosstermBackend::new(&mut out); + // `clear` + `draw` both write ANSI into the Vec; flush on a Vec is a + // no-op and neither can fail on an in-memory writer. + let _ = backend.clear(); + let _ = backend.draw(updates.into_iter()); + } + out +} + +/// Encode the incremental update needed to bring a client from `previous` to +/// `current`. Returns an empty Vec when nothing changed (caller skips the send) +/// or when the two buffers have different dimensions — a size change is not a +/// cell-level diff and must be handled by re-sending a full frame instead. +pub fn encode_update(previous: &Buffer, current: &Buffer) -> Vec { + if previous.area() != current.area() { + return Vec::new(); + } + let updates = previous.diff(current); + if updates.is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + { + let mut backend = CrosstermBackend::new(&mut out); + let _ = backend.draw(updates.into_iter()); + } + out +} + +/// Encode the trailing cursor state for a frame chunk. +/// +/// The cell buffer carries no cursor — ratatui applies it to the local terminal +/// directly — so every chunk sent to a browser ends with an explicit park: +/// either a move to `cursor` plus show, or a hide when the frame has no cursor +/// (terminal panel unfocused, scrolled back, or not rendered at all). Without +/// this the browser would keep the cursor wherever the last painted cell left +/// it. Coordinates are absolute screen cells; ANSI is 1-based, the buffer 0. +pub fn encode_cursor(cursor: Option) -> Vec { + match cursor { + Some(p) => format!("\x1b[{};{}H\x1b[?25h", p.y as u32 + 1, p.x as u32 + 1).into_bytes(), + None => b"\x1b[?25l".to_vec(), + } +} + +mod decode; + +pub use decode::{MAX_INPUT_MESSAGE_BYTES, decode_input, ensure_input_size}; + +#[cfg(test)] +mod tests; diff --git a/src/web/protocol/tests.rs b/src/web/protocol/tests.rs new file mode 100644 index 0000000..63f5c49 --- /dev/null +++ b/src/web/protocol/tests.rs @@ -0,0 +1,281 @@ +use super::*; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; + +fn buf(w: u16, h: u16) -> Buffer { + Buffer::empty(Rect::new(0, 0, w, h)) +} + +#[test] +fn full_frame_clears_then_paints_content() { + let mut b = buf(10, 2); + b.set_string(0, 0, "hi", Style::default()); + let bytes = encode_full_frame(&b); + // Clears first (ESC[2J) so a reconnecting client drops stale content, + // and the painted text is present. + assert!( + bytes.windows(4).any(|w| w == b"\x1b[2J"), + "full frame must clear" + ); + assert!( + bytes.windows(2).any(|w| w == b"hi"), + "full frame must paint the cell content" + ); +} + +#[test] +fn cursor_at_a_cell_moves_and_shows_in_one_based_coords() { + let bytes = encode_cursor(Some(Position::new(3, 7))); + assert_eq!(bytes, b"\x1b[8;4H\x1b[?25h".to_vec()); +} + +#[test] +fn absent_cursor_hides_it() { + assert_eq!(encode_cursor(None), b"\x1b[?25l".to_vec()); +} + +#[test] +fn update_emits_only_changed_cells() { + let mut prev = buf(10, 1); + prev.set_string(0, 0, "cat", Style::default()); + let mut next = prev.clone(); + next.set_string(0, 0, "car", Style::default()); + + let bytes = encode_update(&prev, &next); + let text = String::from_utf8_lossy(&bytes); + // Only the third column changed ('t' -> 'r'); the update must carry the + // new glyph but not repaint the unchanged prefix. + assert!(text.contains('r'), "changed glyph must be present"); + assert!( + !text.contains("car"), + "unchanged prefix must not be repainted" + ); +} + +#[test] +fn update_is_empty_when_nothing_changed() { + let b = { + let mut b = buf(4, 1); + b.set_string(0, 0, "same", Style::default()); + b + }; + assert!( + encode_update(&b, &b.clone()).is_empty(), + "an identical frame produces no bytes" + ); +} + +#[test] +fn update_is_empty_on_size_change() { + let prev = buf(4, 1); + let next = buf(6, 1); + assert!( + encode_update(&prev, &next).is_empty(), + "a size change is not a cell diff; caller must send a full frame" + ); +} + +#[test] +fn full_frame_carries_color_styling() { + let mut b = buf(4, 1); + b.set_string(0, 0, "x", Style::default().fg(Color::Red)); + let bytes = encode_full_frame(&b); + // Crossterm encodes a red foreground via an SGR sequence; assert some + // SGR + the glyph made it through (exact code is crossterm's concern). + assert!( + bytes.contains(&0x1b), + "styled output must contain escape codes" + ); + assert!(bytes.contains(&b'x')); +} + +#[test] +fn decode_plain_char_key() { + let ev = decode_input(r#"{"t":"key","key":"a"}"#).unwrap().unwrap(); + assert_eq!( + ev, + WebInputEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)) + ); +} + +#[test] +fn decode_ctrl_f_matches_default_leader() { + // The default leader is Ctrl+F; a browser ctrl+f must decode to the + // identical KeyEvent so the leader arms from the web too. + let ev = decode_input(r#"{"t":"key","key":"f","ctrl":true}"#) + .unwrap() + .unwrap(); + assert_eq!( + ev, + WebInputEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)) + ); +} + +#[test] +fn decode_uppercase_char_drops_shift() { + // The glyph already encodes shift; SHIFT is dropped to match crossterm. + let ev = decode_input(r#"{"t":"key","key":"A","shift":true}"#) + .unwrap() + .unwrap(); + assert_eq!( + ev, + WebInputEvent::Key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE)) + ); +} + +#[test] +fn decode_named_keys() { + let cases = [ + (r#"{"t":"key","key":"Enter"}"#, KeyCode::Enter), + (r#"{"t":"key","key":"Escape"}"#, KeyCode::Esc), + (r#"{"t":"key","key":"ArrowUp"}"#, KeyCode::Up), + (r#"{"t":"key","key":"PageDown"}"#, KeyCode::PageDown), + (r#"{"t":"key","key":"F5"}"#, KeyCode::F(5)), + (r#"{"t":"key","key":" "}"#, KeyCode::Char(' ')), + ]; + for (json, code) in cases { + let ev = decode_input(json).unwrap().unwrap(); + assert_eq!( + ev, + WebInputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)), + "for {json}" + ); + } +} + +#[test] +fn decode_shift_arrow_preserves_shift_for_reserved_scroll_keys() { + // Shift+Arrow is a reserved nightcrow key (terminal scroll / focus + // cycle); the SHIFT modifier must survive on non-char keys. + let ev = decode_input(r#"{"t":"key","key":"ArrowUp","shift":true}"#) + .unwrap() + .unwrap(); + assert_eq!( + ev, + WebInputEvent::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT)) + ); +} + +#[test] +fn decode_shift_tab_becomes_backtab() { + let ev = decode_input(r#"{"t":"key","key":"Tab","shift":true}"#) + .unwrap() + .unwrap(); + assert_eq!( + ev, + WebInputEvent::Key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE)) + ); +} + +#[test] +fn decode_letter_f_is_a_char_not_a_function_key() { + let ev = decode_input(r#"{"t":"key","key":"F"}"#).unwrap().unwrap(); + assert_eq!( + ev, + WebInputEvent::Key(KeyEvent::new(KeyCode::Char('F'), KeyModifiers::NONE)) + ); +} + +#[test] +fn decode_modifier_only_key_is_dropped() { + assert!( + decode_input(r#"{"t":"key","key":"Shift"}"#) + .unwrap() + .is_none() + ); + assert!( + decode_input(r#"{"t":"key","key":"Control"}"#) + .unwrap() + .is_none() + ); + assert!( + decode_input(r#"{"t":"key","key":"Dead"}"#) + .unwrap() + .is_none() + ); +} + +#[test] +fn decode_out_of_range_function_key_is_dropped() { + assert!( + decode_input(r#"{"t":"key","key":"F99"}"#) + .unwrap() + .is_none() + ); +} + +#[test] +fn decode_mouse_down_and_wheel_and_up() { + let down = decode_input(r#"{"t":"mouse","kind":"down","button":"left","col":3,"row":4}"#) + .unwrap() + .unwrap(); + assert_eq!( + down, + WebInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 3, + row: 4, + modifiers: KeyModifiers::NONE, + }) + ); + + let wheel = decode_input(r#"{"t":"mouse","kind":"wheel","dir":"up","col":1,"row":1}"#) + .unwrap() + .unwrap(); + assert_eq!( + wheel, + WebInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::ScrollUp, + column: 1, + row: 1, + modifiers: KeyModifiers::NONE, + }) + ); + + let up = decode_input(r#"{"t":"mouse","kind":"up","button":"right","col":2,"row":2}"#) + .unwrap() + .unwrap(); + assert_eq!( + up, + WebInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Right), + column: 2, + row: 2, + modifiers: KeyModifiers::NONE, + }) + ); +} + +#[test] +fn decode_wheel_without_direction_is_dropped() { + assert!( + decode_input(r#"{"t":"mouse","kind":"wheel","col":1,"row":1}"#) + .unwrap() + .is_none() + ); +} + +#[test] +fn decode_paste() { + let ev = decode_input(r#"{"t":"paste","data":"hello\nworld"}"#) + .unwrap() + .unwrap(); + assert_eq!(ev, WebInputEvent::Paste("hello\nworld".to_string())); +} + +#[test] +fn decode_rejects_malformed_and_unknown() { + assert!(decode_input("not json").is_err()); + assert!(decode_input(r#"{"t":"explode"}"#).is_err()); + assert!( + decode_input(r#"{"t":"key"}"#).is_err(), + "missing required field" + ); +} + +#[test] +fn input_size_guard_rejects_oversized() { + assert!(ensure_input_size(MAX_INPUT_MESSAGE_BYTES).is_ok()); + assert!(ensure_input_size(MAX_INPUT_MESSAGE_BYTES + 1).is_err()); +} \ No newline at end of file From 736fa7d844ed78976d4561eed781133ef932b9b8 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 25 Jul 2026 00:51:29 +0900 Subject: [PATCH 04/15] refactor: split input into routing/encode/tests submodules --- src/input/encode.rs | 188 ++++++++ src/input/mod.rs | 765 +------------------------------ src/input/routing.rs | 111 +++++ src/input/tests/common.rs | 9 + src/input/tests/encode_tests.rs | 154 +++++++ src/input/tests/mod.rs | 5 + src/input/tests/routing_tests.rs | 300 ++++++++++++ 7 files changed, 772 insertions(+), 760 deletions(-) create mode 100644 src/input/encode.rs create mode 100644 src/input/routing.rs create mode 100644 src/input/tests/common.rs create mode 100644 src/input/tests/encode_tests.rs create mode 100644 src/input/tests/mod.rs create mode 100644 src/input/tests/routing_tests.rs diff --git a/src/input/encode.rs b/src/input/encode.rs new file mode 100644 index 0000000..a3d5b61 --- /dev/null +++ b/src/input/encode.rs @@ -0,0 +1,188 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton}; + +/// Encode a crossterm KeyEvent as VT100/ANSI bytes for terminal pass-through. +pub fn encode_key(key: KeyEvent) -> Option> { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + + 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): screen/tmux/emacs/less use this + // Ctrl+? → 0x7F (DEL): xterm convention + let b = match c { + ' ' => Some(0x00), + '/' => Some(0x1F), + '?' => Some(0x7F), + _ => { + let v = (c.to_ascii_uppercase() as u8).wrapping_sub(b'@'); + (v < 32).then_some(v) + } + }; + if let Some(b) = b { + // Ctrl+Alt+Char encodes as ESC + control byte (matches + // readline / Emacs expectations). Without the prefix + // programs like Emacs would see plain Ctrl+Char. + return Some(if alt { vec![0x1b, b] } else { vec![b] }); + } + } + if alt { + let mut bytes = vec![0x1b]; + let mut enc = [0u8; 4]; + bytes.extend_from_slice(c.encode_utf8(&mut enc).as_bytes()); + return Some(bytes); + } + let mut enc = [0u8; 4]; + Some(c.encode_utf8(&mut enc).as_bytes().to_vec()) + } + KeyCode::Enter => Some(vec![b'\r']), + KeyCode::Backspace => Some(vec![0x7f]), + KeyCode::Delete => Some(csi_tilde(3, key.modifiers)), + KeyCode::Esc => Some(vec![0x1b]), + KeyCode::Tab => Some(vec![b'\t']), + KeyCode::BackTab => Some(b"\x1b[Z".to_vec()), + KeyCode::Up => Some(csi_cursor(b'A', key.modifiers)), + KeyCode::Down => Some(csi_cursor(b'B', key.modifiers)), + KeyCode::Right => Some(csi_cursor(b'C', key.modifiers)), + KeyCode::Left => Some(csi_cursor(b'D', key.modifiers)), + KeyCode::Home => Some(csi_cursor(b'H', key.modifiers)), + KeyCode::End => Some(csi_cursor(b'F', key.modifiers)), + KeyCode::PageUp => Some(csi_tilde(5, key.modifiers)), + KeyCode::PageDown => Some(csi_tilde(6, key.modifiers)), + KeyCode::F(n) => encode_function_key(n, key.modifiers), + _ => None, + } +} + +/// SGR (1006) mouse button code for a wheel-up event. Wheel-down is one more. +/// 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; the pane's centre is a good choice, since a TUI +/// may route the event by which of its regions the pointer sits over. +/// +/// 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`. +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() +} + +/// Encode a horizontal wheel notch as an SGR (1006) mouse report: button 66 +/// is wheel-left, 67 wheel-right. `col`/`row` are 1-based pane-local cells. +/// Horizontal wheel has no scrollback or arrow-key analog, so — unlike the +/// vertical encoder — this only ever targets a pane that claimed the mouse. +pub fn encode_wheel_horizontal(left: bool, col: u16, row: u16) -> Vec { + let button: u8 = if left { + SGR_WHEEL_UP + 2 + } else { + SGR_WHEEL_UP + 3 + }; + 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 the legacy X10 encoding, which collapses every release to +/// button 3 and could not tell the program which button went up. +pub fn encode_button(button: MouseButton, press: bool, col: u16, row: u16) -> Vec { + let code: u8 = match button { + MouseButton::Left => 0, + MouseButton::Middle => 1, + MouseButton::Right => 2, + }; + let final_byte = if press { 'M' } else { 'm' }; + format!("\x1b[<{code};{};{}{final_byte}", col.max(1), row.max(1)).into_bytes() +} + +/// Encode a bare Up/Down arrow. `app_cursor` selects the SS3 form (`ESC O A`) +/// that DECCKM-enabled programs expect over the default CSI form (`ESC [ A`). +pub fn encode_arrow(up: bool, app_cursor: bool) -> Vec { + let final_byte = if up { b'A' } else { b'B' }; + let introducer = if app_cursor { b'O' } else { b'[' }; + vec![0x1b, introducer, final_byte] +} + +/// xterm modifier parameter for CSI sequences: `1 + (shift=1 | alt=2 | ctrl=4 | +/// meta=8)`. Returns `None` when no modifier is held, signalling that the +/// legacy unparametrized escape sequence should be used instead. +fn xterm_modifier_param(mods: KeyModifiers) -> Option { + let mut bits = 0u8; + if mods.contains(KeyModifiers::SHIFT) { + bits |= 1; + } + if mods.contains(KeyModifiers::ALT) { + bits |= 2; + } + if mods.contains(KeyModifiers::CONTROL) { + bits |= 4; + } + if mods.intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META) { + bits |= 8; + } + (bits != 0).then_some(bits + 1) +} + +/// Encode a cursor/edit key of the `ESC [ ` family, inserting the +/// `1;` parameters when a modifier is held so the PTY program sees e.g. +/// `Ctrl+Up` (`ESC[1;5A`) instead of a bare `Up`. +fn csi_cursor(final_byte: u8, mods: KeyModifiers) -> Vec { + match xterm_modifier_param(mods) { + Some(m) => { + let mut bytes = format!("\x1b[1;{m}").into_bytes(); + bytes.push(final_byte); + bytes + } + None => vec![0x1b, b'[', final_byte], + } +} + +/// Encode a `ESC [ ~` edit key (PageUp/PageDown/Delete), adding the +/// `;` parameter when a modifier is held. +fn csi_tilde(n: u8, mods: KeyModifiers) -> Vec { + match xterm_modifier_param(mods) { + Some(m) => format!("\x1b[{n};{m}~").into_bytes(), + None => format!("\x1b[{n}~").into_bytes(), + } +} + +/// Encode an F-key. F1–F4 use the SS3 form (`ESC O P..S`) when unmodified and +/// the CSI form (`ESC[1;P..S`) when modified; F5–F12 use the tilde form. +fn encode_function_key(n: u8, mods: KeyModifiers) -> Option> { + let param = xterm_modifier_param(mods); + let seq = match n { + 1..=4 => { + let final_byte = b"PQRS"[(n - 1) as usize]; + match param { + Some(m) => { + let mut bytes = format!("\x1b[1;{m}").into_bytes(); + bytes.push(final_byte); + bytes + } + None => vec![0x1b, b'O', final_byte], + } + } + 5..=12 => { + let code = match n { + 5 => 15, + 6 => 17, + 7 => 18, + 8 => 19, + 9 => 20, + 10 => 21, + 11 => 23, + 12 => 24, + _ => unreachable!(), + }; + csi_tilde(code, mods) + } + _ => return None, + }; + Some(seq) +} \ No newline at end of file diff --git a/src/input/mod.rs b/src/input/mod.rs index e63b270..7d082ba 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,5 +1,3 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton}; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Action { Quit, @@ -36,764 +34,11 @@ pub enum Action { None, } -/// Classify a key with NO leader prefix in play. App commands are no longer -/// reachable here (they moved behind the leader — see `prefix_action`); only -/// the modifier-required reserved keys and the bare navigation keys remain. -/// -/// Reserved no-prefix keys are safe global shortcuts because they cannot be -/// confused with prompt text: F-keys are distinct across terminals, and the -/// Shift+arrow / Shift+PgUp/PgDn chords carry a modifier. -pub fn map_key(event: KeyEvent) -> Action { - // Match reserved chords on their EXACT modifier set so any extra modifier - // falls through to the PTY: Shift+arrow must be shift-only (not - // Ctrl+Shift+arrow), and the bare F-keys / arrows must carry no modifier at - // all — including Super/Hyper/Meta, so e.g. Super+F3 passes straight - // through instead of triggering a focus jump. - let shift_only = event.modifiers == KeyModifiers::SHIFT; - let no_mods = event.modifiers.is_empty(); - - match event.code { - KeyCode::Left if shift_only => Action::CycleBackward, - KeyCode::Right if shift_only => Action::CycleForward, - KeyCode::Up if shift_only => Action::TermScrollLineUp, - KeyCode::Down if shift_only => Action::TermScrollLineDown, - KeyCode::PageUp if shift_only => Action::TermScrollUp, - KeyCode::PageDown if shift_only => Action::TermScrollDown, - // F-keys are universally distinct across terminals (no kitty protocol - // dependency), so they own the one jump that has no other single-key - // route: `F1`..`F10` select project tabs `0`..`9`. Panes and the - // list/diff focus stay on the leader digits, which are layout-aware - // (see `prefix_action` / `prefix_action_fullscreen`); project tabs are - // deliberately NOT layout-aware, so the same F-key reaches the same - // project in split view and in fullscreen alike. - KeyCode::F(n @ 1..=10) if no_mods => Action::SwitchProject(n as usize - 1), - KeyCode::Up if no_mods => Action::Up, - KeyCode::Down if no_mods => Action::Down, - KeyCode::PageUp if no_mods => Action::PageUp, - KeyCode::PageDown if no_mods => Action::PageDown, - // j/k are intentionally NOT mapped here so they remain plain - // characters when Focus::Terminal forwards them to the PTY. The - // upper-pane handler interprets them as navigation explicitly via - // `is_vim_navigation_key`. - _ => Action::None, - } -} - -/// Classify the single follow-up key pressed after the leader. Returns the -/// app `Action` the leader chord maps to, or `Action::None` for an unmapped -/// follow-up (which the dispatcher consumes and drops). -/// -/// 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 entirely — they select project tabs — so -/// the two never collide and neither needs to leave room for the other. -pub fn prefix_action(event: KeyEvent) -> Action { - match event.code { - KeyCode::Char(c) => match c.to_ascii_lowercase() { - 't' => Action::NewPane, - 'w' => Action::ClosePane, - 'l' => Action::ToggleLogView, - 'b' => Action::ToggleTreeView, - 'f' => Action::ToggleFullscreen, - 's' => Action::SwapPanePrompt, - 'o' => Action::OpenProject, - 'x' => Action::CloseProject, - 'p' => Action::CycleTheme, - 'r' => Action::Redraw, - 'q' => Action::Quit, - '1' => Action::FocusList, - '2' => Action::FocusDiff, - d @ '3'..='9' => Action::SwitchPane(d as usize - '3' as usize), - '0' => Action::SwitchPane(7), - _ => Action::None, - }, - _ => Action::None, - } -} - -/// Leader follow-up mapping used 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 that would only make sense with the viewer on screen. `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 exactly as in -/// `prefix_action`, so `f` (exit fullscreen), `t`, `w`, `s`, etc. are unchanged. -pub fn prefix_action_fullscreen(event: KeyEvent) -> Action { - if let KeyCode::Char(c @ '0'..='9') = event.code { - return match c { - '1'..='8' => Action::SwitchPane(c as usize - '1' as usize), - _ => Action::None, - }; - } - prefix_action(event) -} - -/// Returns `Some(Action::Up | Action::Down)` for the vim-style j/k navigation -/// keys (no modifiers), and `None` otherwise. Used by upper-pane handlers so -/// that terminal pass-through is unaffected by changes in `map_key`. -pub fn vim_navigation_action(key: KeyEvent) -> Option { - if !key.modifiers.is_empty() { - return None; - } - match key.code { - KeyCode::Char('k') => Some(Action::Up), - KeyCode::Char('j') => Some(Action::Down), - _ => None, - } -} - -/// Encode a crossterm KeyEvent as VT100/ANSI bytes for terminal pass-through. -pub fn encode_key(key: KeyEvent) -> Option> { - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - let alt = key.modifiers.contains(KeyModifiers::ALT); - - 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): screen/tmux/emacs/less use this - // Ctrl+? → 0x7F (DEL): xterm convention - let b = match c { - ' ' => Some(0x00), - '/' => Some(0x1F), - '?' => Some(0x7F), - _ => { - let v = (c.to_ascii_uppercase() as u8).wrapping_sub(b'@'); - (v < 32).then_some(v) - } - }; - if let Some(b) = b { - // Ctrl+Alt+Char encodes as ESC + control byte (matches - // readline / Emacs expectations). Without the prefix - // programs like Emacs would see plain Ctrl+Char. - return Some(if alt { vec![0x1b, b] } else { vec![b] }); - } - } - if alt { - let mut bytes = vec![0x1b]; - let mut enc = [0u8; 4]; - bytes.extend_from_slice(c.encode_utf8(&mut enc).as_bytes()); - return Some(bytes); - } - let mut enc = [0u8; 4]; - Some(c.encode_utf8(&mut enc).as_bytes().to_vec()) - } - KeyCode::Enter => Some(vec![b'\r']), - KeyCode::Backspace => Some(vec![0x7f]), - KeyCode::Delete => Some(csi_tilde(3, key.modifiers)), - KeyCode::Esc => Some(vec![0x1b]), - KeyCode::Tab => Some(vec![b'\t']), - KeyCode::BackTab => Some(b"\x1b[Z".to_vec()), - KeyCode::Up => Some(csi_cursor(b'A', key.modifiers)), - KeyCode::Down => Some(csi_cursor(b'B', key.modifiers)), - KeyCode::Right => Some(csi_cursor(b'C', key.modifiers)), - KeyCode::Left => Some(csi_cursor(b'D', key.modifiers)), - KeyCode::Home => Some(csi_cursor(b'H', key.modifiers)), - KeyCode::End => Some(csi_cursor(b'F', key.modifiers)), - KeyCode::PageUp => Some(csi_tilde(5, key.modifiers)), - KeyCode::PageDown => Some(csi_tilde(6, key.modifiers)), - KeyCode::F(n) => encode_function_key(n, key.modifiers), - _ => None, - } -} - -/// SGR (1006) mouse button code for a wheel-up event. Wheel-down is one more. -/// 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; the pane's centre is a good choice, since a TUI -/// may route the event by which of its regions the pointer sits over. -/// -/// 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`. -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() -} - -/// Encode a horizontal wheel notch as an SGR (1006) mouse report: button 66 -/// is wheel-left, 67 wheel-right. `col`/`row` are 1-based pane-local cells. -/// Horizontal wheel has no scrollback or arrow-key analog, so — unlike the -/// vertical encoder — this only ever targets a pane that claimed the mouse. -pub fn encode_wheel_horizontal(left: bool, col: u16, row: u16) -> Vec { - let button: u8 = if left { - SGR_WHEEL_UP + 2 - } else { - SGR_WHEEL_UP + 3 - }; - 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 the legacy X10 encoding, which collapses every release to -/// button 3 and could not tell the program which button went up. -pub fn encode_button(button: MouseButton, press: bool, col: u16, row: u16) -> Vec { - let code: u8 = match button { - MouseButton::Left => 0, - MouseButton::Middle => 1, - MouseButton::Right => 2, - }; - let final_byte = if press { 'M' } else { 'm' }; - format!("\x1b[<{code};{};{}{final_byte}", col.max(1), row.max(1)).into_bytes() -} - -/// Encode a bare Up/Down arrow. `app_cursor` selects the SS3 form (`ESC O A`) -/// that DECCKM-enabled programs expect over the default CSI form (`ESC [ A`). -pub fn encode_arrow(up: bool, app_cursor: bool) -> Vec { - let final_byte = if up { b'A' } else { b'B' }; - let introducer = if app_cursor { b'O' } else { b'[' }; - vec![0x1b, introducer, final_byte] -} - -/// xterm modifier parameter for CSI sequences: `1 + (shift=1 | alt=2 | ctrl=4 | -/// meta=8)`. Returns `None` when no modifier is held, signalling that the -/// legacy unparametrized escape sequence should be used instead. -fn xterm_modifier_param(mods: KeyModifiers) -> Option { - let mut bits = 0u8; - if mods.contains(KeyModifiers::SHIFT) { - bits |= 1; - } - if mods.contains(KeyModifiers::ALT) { - bits |= 2; - } - if mods.contains(KeyModifiers::CONTROL) { - bits |= 4; - } - if mods.intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META) { - bits |= 8; - } - (bits != 0).then_some(bits + 1) -} - -/// Encode a cursor/edit key of the `ESC [ ` family, inserting the -/// `1;` parameters when a modifier is held so the PTY program sees e.g. -/// `Ctrl+Up` (`ESC[1;5A`) instead of a bare `Up`. -fn csi_cursor(final_byte: u8, mods: KeyModifiers) -> Vec { - match xterm_modifier_param(mods) { - Some(m) => { - let mut bytes = format!("\x1b[1;{m}").into_bytes(); - bytes.push(final_byte); - bytes - } - None => vec![0x1b, b'[', final_byte], - } -} - -/// Encode a `ESC [ ~` edit key (PageUp/PageDown/Delete), adding the -/// `;` parameter when a modifier is held. -fn csi_tilde(n: u8, mods: KeyModifiers) -> Vec { - match xterm_modifier_param(mods) { - Some(m) => format!("\x1b[{n};{m}~").into_bytes(), - None => format!("\x1b[{n}~").into_bytes(), - } -} +mod encode; +mod routing; -/// Encode an F-key. F1–F4 use the SS3 form (`ESC O P..S`) when unmodified and -/// the CSI form (`ESC[1;P..S`) when modified; F5–F12 use the tilde form. -fn encode_function_key(n: u8, mods: KeyModifiers) -> Option> { - let param = xterm_modifier_param(mods); - let seq = match n { - 1..=4 => { - let final_byte = b"PQRS"[(n - 1) as usize]; - match param { - Some(m) => { - let mut bytes = format!("\x1b[1;{m}").into_bytes(); - bytes.push(final_byte); - bytes - } - None => vec![0x1b, b'O', final_byte], - } - } - 5..=12 => { - let code = match n { - 5 => 15, - 6 => 17, - 7 => 18, - 8 => 19, - 9 => 20, - 10 => 21, - 11 => 23, - 12 => 24, - _ => unreachable!(), - }; - csi_tilde(code, mods) - } - _ => return None, - }; - Some(seq) -} +pub use encode::{encode_arrow, encode_button, encode_key, encode_wheel, encode_wheel_horizontal}; +pub use routing::{map_key, prefix_action, prefix_action_fullscreen, vim_navigation_action}; #[cfg(test)] -mod tests { - use super::*; - - /// The README, read at compile time. A missing or moved file is a build - /// error rather than a test that silently stops checking anything. - const README: &str = include_str!("../../README.md"); - - const PREFIX_TOKEN: &str = "` "; - - /// Every leader command, derived from `prefix_action` itself by probing - /// the candidate keys. Deriving rather than listing is the point: a - /// hard-coded set cannot notice a command that was just added to the - /// match arm, which is exactly the drift these tests exist to catch. - fn leader_commands() -> Vec { - ('a'..='z') - .chain('0'..='9') - .filter(|&c| { - let e = key(KeyCode::Char(c)); - prefix_action(e) != Action::None || prefix_action_fullscreen(e) != Action::None - }) - .collect() - } - - /// Every ` c` the README spells out, with ranges expanded. - /// - /// ` 3`…` 9` documents seven keys, not two — the interior - /// ones never appear on their own, so without expanding them a removed - /// ` 5` would leave the README claiming a key that does nothing. - fn documented_prefix_keys() -> Vec { - let mut found = Vec::new(); - let mut idx = 0; - while let Some(offset) = README[idx..].find(PREFIX_TOKEN) { - let start = idx + offset + PREFIX_TOKEN.len(); - idx = start; - let rest = &README[start..]; - let mut chars = rest.chars(); - let (Some(c), Some('`')) = (chars.next(), chars.next()) else { - // `` alone, or prose rather than a key. - continue; - }; - found.push(c); - let after = &rest[c.len_utf8() + '`'.len_utf8()..]; - if let Some(tail) = after.strip_prefix('…') - && let Some(upper) = tail.strip_prefix(PREFIX_TOKEN) - && let Some(end) = upper.chars().next() - && c.is_ascii() - && end.is_ascii() - && c < end - { - found.extend((c as u8 + 1..end as u8).map(char::from)); - } - } - found.sort_unstable(); - found.dedup(); - found - } - - /// Doc drift is silent: a renamed command leaves the old key sitting in a - /// table nobody re-reads. These two tests make the README answerable to - /// `prefix_action` in both directions. - #[test] - fn every_leader_command_is_documented() { - let documented = documented_prefix_keys(); - let commands = leader_commands(); - assert!(!commands.is_empty(), "probing found no leader commands"); - for c in commands { - assert!( - documented.contains(&c), - "` {c}` works but the README never mentions it" - ); - } - } - - /// Note the limit: a key is accepted if *either* layout maps it, because - /// the parser reads keys out of prose and cannot tell which table row - /// documented them. So a digit dropped from only the split view still - /// passes here. What this does catch is a documented key that does nothing - /// at all — including the interior of a range, thanks to the expansion. - #[test] - fn every_documented_leader_key_still_works() { - for c in documented_prefix_keys() { - let e = key(KeyCode::Char(c)); - assert!( - prefix_action(e) != Action::None || prefix_action_fullscreen(e) != Action::None, - "the README documents ` {c}`, which maps to nothing" - ); - } - } - - fn key(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::NONE) - } - - fn ctrl(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::CONTROL) - } - - #[test] - fn single_ctrl_keys_are_no_longer_app_commands() { - // The leader redesign removed bare Ctrl app shortcuts: these now pass - // through to the PTY (Action::None) so the running program receives - // them as control bytes. - for c in ['q', 't', 'w', 'o', 'f', 'l', 'p'] { - assert_eq!( - map_key(ctrl(KeyCode::Char(c))), - Action::None, - "ctrl+{c} must no longer be a no-prefix app command" - ); - } - // Plain 'q' must pass through (terminal apps like less/vim use it). - assert_ne!(map_key(key(KeyCode::Char('q'))), Action::Quit); - } - - #[test] - fn prefix_dispatch_maps_app_commands() { - assert_eq!(prefix_action(key(KeyCode::Char('t'))), Action::NewPane); - assert_eq!(prefix_action(key(KeyCode::Char('w'))), Action::ClosePane); - assert_eq!( - prefix_action(key(KeyCode::Char('l'))), - Action::ToggleLogView - ); - assert_eq!( - prefix_action(key(KeyCode::Char('b'))), - Action::ToggleTreeView - ); - assert_eq!( - prefix_action(key(KeyCode::Char('f'))), - Action::ToggleFullscreen - ); - assert_eq!(prefix_action(key(KeyCode::Char('o'))), Action::OpenProject); - assert_eq!(prefix_action(key(KeyCode::Char('p'))), Action::CycleTheme); - assert_eq!(prefix_action(key(KeyCode::Char('r'))), Action::Redraw); - assert_eq!(prefix_action(key(KeyCode::Char('q'))), Action::Quit); - assert_eq!( - prefix_action(key(KeyCode::Char('s'))), - Action::SwapPanePrompt - ); - } - - #[test] - fn prefix_dispatch_maps_digits_to_focus_and_panes() { - // Digits mirror the no-prefix F-keys one-for-one: 1=F1 (file list), - // 2=F2 (diff viewer), 3..9,0=F3..F10 (terminal panes 0..7). - assert_eq!(prefix_action(key(KeyCode::Char('1'))), Action::FocusList); - assert_eq!(prefix_action(key(KeyCode::Char('2'))), Action::FocusDiff); - assert_eq!( - prefix_action(key(KeyCode::Char('3'))), - Action::SwitchPane(0) - ); - assert_eq!( - prefix_action(key(KeyCode::Char('9'))), - Action::SwitchPane(6) - ); - // 0 mirrors F10 (the 8th pane) since digits only go up to 9. - assert_eq!( - prefix_action(key(KeyCode::Char('0'))), - Action::SwitchPane(7) - ); - } - - #[test] - fn fullscreen_prefix_maps_digits_one_through_eight_to_panes() { - // With the upper viewer hidden the whole digit row addresses panes by - // natural numbering: 1..8 -> panes 0..7. - for d in 1..=8u8 { - let c = char::from(b'0' + d); - assert_eq!( - prefix_action_fullscreen(key(KeyCode::Char(c))), - Action::SwitchPane((d - 1) as usize), - " {c} in fullscreen must jump to pane {}", - d - 1 - ); - } - } - - #[test] - fn fullscreen_prefix_drops_nine_and_zero() { - // Only 8 panes have a jump key, so 9/0 must not fall through to the - // split-view list/diff/pane bindings. - assert_eq!( - prefix_action_fullscreen(key(KeyCode::Char('9'))), - Action::None - ); - assert_eq!( - prefix_action_fullscreen(key(KeyCode::Char('0'))), - Action::None - ); - } - - #[test] - fn fullscreen_prefix_leaves_non_digit_chords_unchanged() { - // Non-digit chords defer to `prefix_action`, so `f`/`t`/`w`/`s` and the - // rest keep their meaning while the terminal is fullscreen. - for (c, expected) in [ - ('f', Action::ToggleFullscreen), - ('t', Action::NewPane), - ('w', Action::ClosePane), - ('s', Action::SwapPanePrompt), - ('q', Action::Quit), - ] { - assert_eq!(prefix_action_fullscreen(key(KeyCode::Char(c))), expected); - } - } - - #[test] - fn prefix_dispatch_ignores_modifiers_on_follow_up() { - // A leftover Ctrl from the leader chord must not break the follow-up. - assert_eq!(prefix_action(ctrl(KeyCode::Char('t'))), Action::NewPane); - } - - #[test] - fn prefix_dispatch_unmapped_key_is_none() { - assert_eq!(prefix_action(key(KeyCode::Char('z'))), Action::None); - assert_eq!(prefix_action(key(KeyCode::Esc)), Action::None); - } - - #[test] - fn maps_navigation_shortcuts() { - assert_eq!(map_key(key(KeyCode::Up)), Action::Up); - assert_eq!(map_key(key(KeyCode::Down)), Action::Down); - assert_eq!(map_key(key(KeyCode::PageUp)), Action::PageUp); - assert_eq!(map_key(key(KeyCode::PageDown)), Action::PageDown); - // j/k are no longer remapped to Up/Down by map_key — they must - // pass through as Action::None so terminal focus can forward them - // verbatim to the PTY. - assert_eq!(map_key(key(KeyCode::Char('k'))), Action::None); - assert_eq!(map_key(key(KeyCode::Char('j'))), Action::None); - } - - #[test] - fn reserved_keys_require_exact_modifiers() { - use KeyModifiers as M; - let with = |code, mods| map_key(KeyEvent::new(code, mods)); - - // Shift-only arrows are reserved. - assert_eq!(with(KeyCode::Left, M::SHIFT), Action::CycleBackward); - // Extra modifiers fall through to the PTY. - assert_eq!(with(KeyCode::Left, M::SHIFT | M::CONTROL), Action::None); - assert_eq!(with(KeyCode::Right, M::SHIFT | M::ALT), Action::None); - // F-keys are reserved only without modifiers. - assert_eq!(with(KeyCode::F(3), M::NONE), Action::SwitchProject(2)); - assert_eq!(with(KeyCode::F(3), M::ALT), Action::None); - assert_eq!(with(KeyCode::F(1), M::CONTROL), Action::None); - // Bare navigation keys with a modifier pass through too. - assert_eq!(with(KeyCode::Up, M::CONTROL), Action::None); - assert_eq!(with(KeyCode::Up, M::NONE), Action::Up); - // Super/Hyper/Meta count as modifiers and must not be ignored. - assert_eq!(with(KeyCode::F(3), M::SUPER), Action::None); - assert_eq!(with(KeyCode::Left, M::SHIFT | M::SUPER), Action::None); - } - - #[test] - fn encode_wheel_emits_sgr_press_reports() { - assert_eq!(encode_wheel(true, 40, 12), b"\x1b[<64;40;12M".to_vec()); - assert_eq!(encode_wheel(false, 40, 12), b"\x1b[<65;40;12M".to_vec()); - } - - #[test] - fn encode_wheel_clamps_coordinates_to_one_based_origin() { - // SGR coordinates start at 1; a degenerate 0-sized pane must not - // produce a `0` that a TUI would read as out of range. - assert_eq!(encode_wheel(true, 0, 0), b"\x1b[<64;1;1M".to_vec()); - } - - #[test] - fn encode_wheel_horizontal_uses_sgr_buttons_66_and_67() { - assert_eq!( - encode_wheel_horizontal(true, 5, 3), - b"\x1b[<66;5;3M".to_vec() - ); - assert_eq!( - encode_wheel_horizontal(false, 0, 0), - b"\x1b[<67;1;1M".to_vec() - ); - } - - #[test] - fn encode_button_reports_press_and_release_with_real_button_code() { - assert_eq!( - encode_button(MouseButton::Left, true, 5, 3), - b"\x1b[<0;5;3M".to_vec() - ); - assert_eq!( - encode_button(MouseButton::Left, false, 5, 3), - b"\x1b[<0;5;3m".to_vec() - ); - assert_eq!( - encode_button(MouseButton::Middle, true, 1, 1), - b"\x1b[<1;1;1M".to_vec() - ); - assert_eq!( - encode_button(MouseButton::Right, false, 80, 24), - b"\x1b[<2;80;24m".to_vec() - ); - } - - #[test] - fn encode_button_clamps_coordinates_to_one_based_origin() { - assert_eq!( - encode_button(MouseButton::Left, true, 0, 0), - b"\x1b[<0;1;1M".to_vec() - ); - } - - #[test] - fn encode_arrow_follows_application_cursor_mode() { - assert_eq!(encode_arrow(true, false), b"\x1b[A".to_vec()); - assert_eq!(encode_arrow(false, false), b"\x1b[B".to_vec()); - assert_eq!(encode_arrow(true, true), b"\x1bOA".to_vec()); - assert_eq!(encode_arrow(false, true), b"\x1bOB".to_vec()); - } - - #[test] - fn encode_key_emits_xterm_modifier_sequences() { - use KeyModifiers as M; - let enc = |code, mods| encode_key(KeyEvent::new(code, mods)).unwrap(); - - // Unmodified cursor/F-keys keep their legacy sequences. - assert_eq!(enc(KeyCode::Up, M::NONE), b"\x1b[A"); - assert_eq!(enc(KeyCode::F(3), M::NONE), b"\x1bOR"); - assert_eq!(enc(KeyCode::F(5), M::NONE), b"\x1b[15~"); - assert_eq!(enc(KeyCode::PageUp, M::NONE), b"\x1b[5~"); - - // Modified keys carry the xterm `1;` parameter (ctrl=5, shift=2, - // alt=3). - assert_eq!(enc(KeyCode::Up, M::CONTROL), b"\x1b[1;5A"); - assert_eq!(enc(KeyCode::Up, M::SHIFT), b"\x1b[1;2A"); - assert_eq!(enc(KeyCode::Left, M::ALT), b"\x1b[1;3D"); - assert_eq!(enc(KeyCode::F(3), M::ALT), b"\x1b[1;3R"); - assert_eq!(enc(KeyCode::F(5), M::CONTROL), b"\x1b[15;5~"); - assert_eq!(enc(KeyCode::PageUp, M::CONTROL), b"\x1b[5;5~"); - assert_eq!(enc(KeyCode::Delete, M::SHIFT), b"\x1b[3;2~"); - } - - #[test] - fn vim_navigation_for_j_k() { - assert_eq!( - vim_navigation_action(key(KeyCode::Char('k'))), - Some(Action::Up) - ); - assert_eq!( - vim_navigation_action(key(KeyCode::Char('j'))), - Some(Action::Down) - ); - // Modifiers must disable the vim mapping (e.g. Ctrl-J / Shift-K). - assert_eq!(vim_navigation_action(ctrl(KeyCode::Char('j'))), None); - assert_eq!(vim_navigation_action(key(KeyCode::Char('h'))), None); - } - - #[test] - fn maps_cycle_pane_shortcuts() { - let shift_right = KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT); - let shift_left = KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT); - assert_eq!(map_key(shift_right), Action::CycleForward); - assert_eq!(map_key(shift_left), Action::CycleBackward); - } - - #[test] - fn maps_terminal_scroll_shortcuts() { - let shift_pgup = KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT); - let shift_pgdn = KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT); - let shift_up = KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT); - let shift_down = KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT); - assert_eq!(map_key(shift_pgup), Action::TermScrollUp); - assert_eq!(map_key(shift_pgdn), Action::TermScrollDown); - assert_eq!(map_key(shift_up), Action::TermScrollLineUp); - assert_eq!(map_key(shift_down), Action::TermScrollLineDown); - // Plain up/down must not trigger terminal scroll. - assert_ne!(map_key(key(KeyCode::Up)), Action::TermScrollLineUp); - assert_ne!(map_key(key(KeyCode::Down)), Action::TermScrollLineDown); - } - - #[test] - fn f_keys_select_project_tabs() { - // F1..=F10 select project tabs 0..=9 — the whole row, with no gap for - // list/diff focus, which lives on the leader digits instead. - for n in 1..=10u8 { - assert_eq!( - map_key(key(KeyCode::F(n))), - Action::SwitchProject((n - 1) as usize), - "F{n} must select project tab {}", - n - 1 - ); - } - } - - #[test] - fn f_keys_select_the_same_project_regardless_of_layout() { - // Panes and list/diff focus are layout-aware (see - // `prefix_action_fullscreen`), but project tabs deliberately are not: - // there is one mapping, so the same F-key reaches the same project - // whether or not the terminal fills the body. - assert_eq!(map_key(key(KeyCode::F(1))), Action::SwitchProject(0)); - assert_eq!(map_key(key(KeyCode::F(8))), Action::SwitchProject(7)); - } - - #[test] - fn encode_printable_char() { - assert_eq!(encode_key(key(KeyCode::Char('a'))), Some(b"a".to_vec())); - } - - #[test] - fn encode_ctrl_c_as_etx() { - assert_eq!(encode_key(ctrl(KeyCode::Char('c'))), Some(vec![0x03])); - } - - #[test] - fn encode_ctrl_non_ascii_does_not_truncate_to_control_byte() { - assert_eq!( - encode_key(ctrl(KeyCode::Char('ŀ'))), - Some("ŀ".as_bytes().to_vec()) - ); - } - - #[test] - fn encode_arrow_keys() { - assert_eq!(encode_key(key(KeyCode::Up)), Some(b"\x1b[A".to_vec())); - assert_eq!(encode_key(key(KeyCode::Down)), Some(b"\x1b[B".to_vec())); - } - - #[test] - fn encode_enter_as_cr() { - assert_eq!(encode_key(key(KeyCode::Enter)), Some(vec![b'\r'])); - } - - #[test] - fn encode_ctrl_space_as_nul() { - // xterm convention: Ctrl+Space → NUL. The generic `c - '@'` formula - // wraps for space (0x20 < 0x40), so this case needs special handling. - assert_eq!(encode_key(ctrl(KeyCode::Char(' '))), Some(vec![0x00])); - } - - #[test] - fn encode_ctrl_slash_as_us() { - // Ctrl+/ is conventionally 0x1F (US) on xterm; vim/less/emacs - // bindings depend on it. Without the explicit mapping the slash - // fell through as a literal '/' character. - assert_eq!(encode_key(ctrl(KeyCode::Char('/'))), Some(vec![0x1F])); - } - - #[test] - fn encode_ctrl_question_as_del() { - // Ctrl+? is conventionally DEL (0x7F). - assert_eq!(encode_key(ctrl(KeyCode::Char('?'))), Some(vec![0x7F])); - } - - #[test] - fn encode_ctrl_right_bracket_via_formula() { - // Sanity check: the `c.to_ascii_uppercase() - '@'` formula already - // covered Ctrl+]. Pin it so a future refactor of the special-case - // table doesn't accidentally regress it. - assert_eq!(encode_key(ctrl(KeyCode::Char(']'))), Some(vec![0x1D])); - } - - #[test] - fn encode_ctrl_alt_char_prefixes_esc_to_control_byte() { - // readline / Emacs convention: Ctrl+Alt+Char → ESC + control byte. - let key = KeyEvent::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - ); - assert_eq!(encode_key(key), Some(vec![0x1b, 0x03])); - } -} +mod tests; \ No newline at end of file diff --git a/src/input/routing.rs b/src/input/routing.rs new file mode 100644 index 0000000..461905c --- /dev/null +++ b/src/input/routing.rs @@ -0,0 +1,111 @@ +use crate::input::Action; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Classify a key with NO leader prefix in play. App commands are no longer +/// reachable here (they moved behind the leader — see `prefix_action`); only +/// the modifier-required reserved keys and the bare navigation keys remain. +/// +/// Reserved no-prefix keys are safe global shortcuts because they cannot be +/// confused with prompt text: F-keys are distinct across terminals, and the +/// Shift+arrow / Shift+PgUp/PgDn chords carry a modifier. +pub fn map_key(event: KeyEvent) -> Action { + // Match reserved chords on their EXACT modifier set so any extra modifier + // falls through to the PTY: Shift+arrow must be shift-only (not + // Ctrl+Shift+arrow), and the bare F-keys / arrows must carry no modifier at + // all — including Super/Hyper/Meta, so e.g. Super+F3 passes straight + // through instead of triggering a focus jump. + let shift_only = event.modifiers == KeyModifiers::SHIFT; + let no_mods = event.modifiers.is_empty(); + + match event.code { + KeyCode::Left if shift_only => Action::CycleBackward, + KeyCode::Right if shift_only => Action::CycleForward, + KeyCode::Up if shift_only => Action::TermScrollLineUp, + KeyCode::Down if shift_only => Action::TermScrollLineDown, + KeyCode::PageUp if shift_only => Action::TermScrollUp, + KeyCode::PageDown if shift_only => Action::TermScrollDown, + // F-keys are universally distinct across terminals (no kitty protocol + // dependency), so they own the one jump that has no other single-key + // route: `F1`..`F10` select project tabs `0`..`9`. Panes and the + // list/diff focus stay on the leader digits, which are layout-aware + // (see `prefix_action` / `prefix_action_fullscreen`); project tabs are + // deliberately NOT layout-aware, so the same F-key reaches the same + // project in split view and in fullscreen alike. + KeyCode::F(n @ 1..=10) if no_mods => Action::SwitchProject(n as usize - 1), + KeyCode::Up if no_mods => Action::Up, + KeyCode::Down if no_mods => Action::Down, + KeyCode::PageUp if no_mods => Action::PageUp, + KeyCode::PageDown if no_mods => Action::PageDown, + // j/k are intentionally NOT mapped here so they remain plain + // characters when Focus::Terminal forwards them to the PTY. The + // upper-pane handler interprets them as navigation explicitly via + // `is_vim_navigation_key`. + _ => Action::None, + } +} + +/// Classify the single follow-up key pressed after the leader. Returns the +/// app `Action` the leader chord maps to, or `Action::None` for an unmapped +/// follow-up (which the dispatcher consumes and drops). +/// +/// 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 entirely — they select project tabs — so +/// the two never collide and neither needs to leave room for the other. +pub fn prefix_action(event: KeyEvent) -> Action { + match event.code { + KeyCode::Char(c) => match c.to_ascii_lowercase() { + 't' => Action::NewPane, + 'w' => Action::ClosePane, + 'l' => Action::ToggleLogView, + 'b' => Action::ToggleTreeView, + 'f' => Action::ToggleFullscreen, + 's' => Action::SwapPanePrompt, + 'o' => Action::OpenProject, + 'x' => Action::CloseProject, + 'p' => Action::CycleTheme, + 'r' => Action::Redraw, + 'q' => Action::Quit, + '1' => Action::FocusList, + '2' => Action::FocusDiff, + d @ '3'..='9' => Action::SwitchPane(d as usize - '3' as usize), + '0' => Action::SwitchPane(7), + _ => Action::None, + }, + _ => Action::None, + } +} + +/// Leader follow-up mapping used 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 that would only make sense with the viewer on screen. `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 exactly as in +/// `prefix_action`, so `f` (exit fullscreen), `t`, `w`, `s`, etc. are unchanged. +pub fn prefix_action_fullscreen(event: KeyEvent) -> Action { + if let KeyCode::Char(c @ '0'..='9') = event.code { + return match c { + '1'..='8' => Action::SwitchPane(c as usize - '1' as usize), + _ => Action::None, + }; + } + prefix_action(event) +} + +/// Returns `Some(Action::Up | Action::Down)` for the vim-style j/k navigation +/// keys (no modifiers), and `None` otherwise. Used by upper-pane handlers so +/// that terminal pass-through is unaffected by changes in `map_key`. +pub fn vim_navigation_action(key: KeyEvent) -> Option { + if !key.modifiers.is_empty() { + return None; + } + match key.code { + KeyCode::Char('k') => Some(Action::Up), + KeyCode::Char('j') => Some(Action::Down), + _ => None, + } +} \ No newline at end of file diff --git a/src/input/tests/common.rs b/src/input/tests/common.rs new file mode 100644 index 0000000..54a2b30 --- /dev/null +++ b/src/input/tests/common.rs @@ -0,0 +1,9 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +pub(super) fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +pub(super) fn ctrl(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::CONTROL) +} \ No newline at end of file diff --git a/src/input/tests/encode_tests.rs b/src/input/tests/encode_tests.rs new file mode 100644 index 0000000..64b895a --- /dev/null +++ b/src/input/tests/encode_tests.rs @@ -0,0 +1,154 @@ +use super::common::{ctrl, key}; +use super::*; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton}; + +#[test] +fn encode_wheel_emits_sgr_press_reports() { + assert_eq!(encode_wheel(true, 40, 12), b"\x1b[<64;40;12M".to_vec()); + assert_eq!(encode_wheel(false, 40, 12), b"\x1b[<65;40;12M".to_vec()); +} + +#[test] +fn encode_wheel_clamps_coordinates_to_one_based_origin() { + // SGR coordinates start at 1; a degenerate 0-sized pane must not + // produce a `0` that a TUI would read as out of range. + assert_eq!(encode_wheel(true, 0, 0), b"\x1b[<64;1;1M".to_vec()); +} + +#[test] +fn encode_wheel_horizontal_uses_sgr_buttons_66_and_67() { + assert_eq!( + encode_wheel_horizontal(true, 5, 3), + b"\x1b[<66;5;3M".to_vec() + ); + assert_eq!( + encode_wheel_horizontal(false, 0, 0), + b"\x1b[<67;1;1M".to_vec() + ); +} + +#[test] +fn encode_button_reports_press_and_release_with_real_button_code() { + assert_eq!( + encode_button(MouseButton::Left, true, 5, 3), + b"\x1b[<0;5;3M".to_vec() + ); + assert_eq!( + encode_button(MouseButton::Left, false, 5, 3), + b"\x1b[<0;5;3m".to_vec() + ); + assert_eq!( + encode_button(MouseButton::Middle, true, 1, 1), + b"\x1b[<1;1;1M".to_vec() + ); + assert_eq!( + encode_button(MouseButton::Right, false, 80, 24), + b"\x1b[<2;80;24m".to_vec() + ); +} + +#[test] +fn encode_button_clamps_coordinates_to_one_based_origin() { + assert_eq!( + encode_button(MouseButton::Left, true, 0, 0), + b"\x1b[<0;1;1M".to_vec() + ); +} + +#[test] +fn encode_arrow_follows_application_cursor_mode() { + assert_eq!(encode_arrow(true, false), b"\x1b[A".to_vec()); + assert_eq!(encode_arrow(false, false), b"\x1b[B".to_vec()); + assert_eq!(encode_arrow(true, true), b"\x1bOA".to_vec()); + assert_eq!(encode_arrow(false, true), b"\x1bOB".to_vec()); +} + +#[test] +fn encode_key_emits_xterm_modifier_sequences() { + use KeyModifiers as M; + let enc = |code, mods| encode_key(KeyEvent::new(code, mods)).unwrap(); + + // Unmodified cursor/F-keys keep their legacy sequences. + assert_eq!(enc(KeyCode::Up, M::NONE), b"\x1b[A"); + assert_eq!(enc(KeyCode::F(3), M::NONE), b"\x1bOR"); + assert_eq!(enc(KeyCode::F(5), M::NONE), b"\x1b[15~"); + assert_eq!(enc(KeyCode::PageUp, M::NONE), b"\x1b[5~"); + + // Modified keys carry the xterm `1;` parameter (ctrl=5, shift=2, + // alt=3). + assert_eq!(enc(KeyCode::Up, M::CONTROL), b"\x1b[1;5A"); + assert_eq!(enc(KeyCode::Up, M::SHIFT), b"\x1b[1;2A"); + assert_eq!(enc(KeyCode::Left, M::ALT), b"\x1b[1;3D"); + assert_eq!(enc(KeyCode::F(3), M::ALT), b"\x1b[1;3R"); + assert_eq!(enc(KeyCode::F(5), M::CONTROL), b"\x1b[15;5~"); + assert_eq!(enc(KeyCode::PageUp, M::CONTROL), b"\x1b[5;5~"); + assert_eq!(enc(KeyCode::Delete, M::SHIFT), b"\x1b[3;2~"); +} + +#[test] +fn encode_printable_char() { + assert_eq!(encode_key(key(KeyCode::Char('a'))), Some(b"a".to_vec())); +} + +#[test] +fn encode_ctrl_c_as_etx() { + assert_eq!(encode_key(ctrl(KeyCode::Char('c'))), Some(vec![0x03])); +} + +#[test] +fn encode_ctrl_non_ascii_does_not_truncate_to_control_byte() { + assert_eq!( + encode_key(ctrl(KeyCode::Char('ŀ'))), + Some("ŀ".as_bytes().to_vec()) + ); +} + +#[test] +fn encode_arrow_keys() { + assert_eq!(encode_key(key(KeyCode::Up)), Some(b"\x1b[A".to_vec())); + assert_eq!(encode_key(key(KeyCode::Down)), Some(b"\x1b[B".to_vec())); +} + +#[test] +fn encode_enter_as_cr() { + assert_eq!(encode_key(key(KeyCode::Enter)), Some(vec![b'\r'])); +} + +#[test] +fn encode_ctrl_space_as_nul() { + // xterm convention: Ctrl+Space → NUL. The generic `c - '@'` formula + // wraps for space (0x20 < 0x40), so this case needs special handling. + assert_eq!(encode_key(ctrl(KeyCode::Char(' '))), Some(vec![0x00])); +} + +#[test] +fn encode_ctrl_slash_as_us() { + // Ctrl+/ is conventionally 0x1F (US) on xterm; vim/less/emacs + // bindings depend on it. Without the explicit mapping the slash + // fell through as a literal '/' character. + assert_eq!(encode_key(ctrl(KeyCode::Char('/'))), Some(vec![0x1F])); +} + +#[test] +fn encode_ctrl_question_as_del() { + // Ctrl+? is conventionally DEL (0x7F). + assert_eq!(encode_key(ctrl(KeyCode::Char('?'))), Some(vec![0x7F])); +} + +#[test] +fn encode_ctrl_right_bracket_via_formula() { + // Sanity check: the `c.to_ascii_uppercase() - '@'` formula already + // covered Ctrl+]. Pin it so a future refactor of the special-case + // table doesn't accidentally regress it. + assert_eq!(encode_key(ctrl(KeyCode::Char(']'))), Some(vec![0x1D])); +} + +#[test] +fn encode_ctrl_alt_char_prefixes_esc_to_control_byte() { + // readline / Emacs convention: Ctrl+Alt+Char → ESC + control byte. + let key = KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ); + assert_eq!(encode_key(key), Some(vec![0x1b, 0x03])); +} \ No newline at end of file diff --git a/src/input/tests/mod.rs b/src/input/tests/mod.rs new file mode 100644 index 0000000..a3ac318 --- /dev/null +++ b/src/input/tests/mod.rs @@ -0,0 +1,5 @@ +use super::*; + +mod common; +mod encode_tests; +mod routing_tests; \ No newline at end of file diff --git a/src/input/tests/routing_tests.rs b/src/input/tests/routing_tests.rs new file mode 100644 index 0000000..81c1487 --- /dev/null +++ b/src/input/tests/routing_tests.rs @@ -0,0 +1,300 @@ +use super::common::{ctrl, key}; +use super::*; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +/// The README, read at compile time. A missing or moved file is a build +/// error rather than a test that silently stops checking anything. +const README: &str = include_str!("../../../README.md"); + +const PREFIX_TOKEN: &str = "` "; + +/// Every leader command, derived from `prefix_action` itself by probing +/// the candidate keys. Deriving rather than listing is the point: a +/// hard-coded set cannot notice a command that was just added to the +/// match arm, which is exactly the drift these tests exist to catch. +fn leader_commands() -> Vec { + ('a'..='z') + .chain('0'..='9') + .filter(|&c| { + let e = key(KeyCode::Char(c)); + prefix_action(e) != Action::None || prefix_action_fullscreen(e) != Action::None + }) + .collect() +} + +/// Every ` c` the README spells out, with ranges expanded. +/// +/// ` 3`…` 9` documents seven keys, not two — the interior +/// ones never appear on their own, so without expanding them a removed +/// ` 5` would leave the README claiming a key that does nothing. +fn documented_prefix_keys() -> Vec { + let mut found = Vec::new(); + let mut idx = 0; + while let Some(offset) = README[idx..].find(PREFIX_TOKEN) { + let start = idx + offset + PREFIX_TOKEN.len(); + idx = start; + let rest = &README[start..]; + let mut chars = rest.chars(); + let (Some(c), Some('`')) = (chars.next(), chars.next()) else { + // `` alone, or prose rather than a key. + continue; + }; + found.push(c); + let after = &rest[c.len_utf8() + '`'.len_utf8()..]; + if let Some(tail) = after.strip_prefix('…') + && let Some(upper) = tail.strip_prefix(PREFIX_TOKEN) + && let Some(end) = upper.chars().next() + && c.is_ascii() + && end.is_ascii() + && c < end + { + found.extend((c as u8 + 1..end as u8).map(char::from)); + } + } + found.sort_unstable(); + found.dedup(); + found +} + +/// Doc drift is silent: a renamed command leaves the old key sitting in a +/// table nobody re-reads. These two tests make the README answerable to +/// `prefix_action` in both directions. +#[test] +fn every_leader_command_is_documented() { + let documented = documented_prefix_keys(); + let commands = leader_commands(); + assert!(!commands.is_empty(), "probing found no leader commands"); + for c in commands { + assert!( + documented.contains(&c), + "` {c}` works but the README never mentions it" + ); + } +} + +/// Note the limit: a key is accepted if *either* layout maps it, because +/// the parser reads keys out of prose and cannot tell which table row +/// documented them. So a digit dropped from only the split view still +/// passes here. What this does catch is a documented key that does nothing +/// at all — including the interior of a range, thanks to the expansion. +#[test] +fn every_documented_leader_key_still_works() { + for c in documented_prefix_keys() { + let e = key(KeyCode::Char(c)); + assert!( + prefix_action(e) != Action::None || prefix_action_fullscreen(e) != Action::None, + "the README documents ` {c}`, which maps to nothing" + ); + } +} + +#[test] +fn single_ctrl_keys_are_no_longer_app_commands() { + // The leader redesign removed bare Ctrl app shortcuts: these now pass + // through to the PTY (Action::None) so the running program receives + // them as control bytes. + for c in ['q', 't', 'w', 'o', 'f', 'l', 'p'] { + assert_eq!( + map_key(ctrl(KeyCode::Char(c))), + Action::None, + "ctrl+{c} must no longer be a no-prefix app command" + ); + } + // Plain 'q' must pass through (terminal apps like less/vim use it). + assert_ne!(map_key(key(KeyCode::Char('q'))), Action::Quit); +} + +#[test] +fn prefix_dispatch_maps_app_commands() { + assert_eq!(prefix_action(key(KeyCode::Char('t'))), Action::NewPane); + assert_eq!(prefix_action(key(KeyCode::Char('w'))), Action::ClosePane); + assert_eq!( + prefix_action(key(KeyCode::Char('l'))), + Action::ToggleLogView + ); + assert_eq!( + prefix_action(key(KeyCode::Char('b'))), + Action::ToggleTreeView + ); + assert_eq!( + prefix_action(key(KeyCode::Char('f'))), + Action::ToggleFullscreen + ); + assert_eq!(prefix_action(key(KeyCode::Char('o'))), Action::OpenProject); + assert_eq!(prefix_action(key(KeyCode::Char('p'))), Action::CycleTheme); + assert_eq!(prefix_action(key(KeyCode::Char('r'))), Action::Redraw); + assert_eq!(prefix_action(key(KeyCode::Char('q'))), Action::Quit); + assert_eq!( + prefix_action(key(KeyCode::Char('s'))), + Action::SwapPanePrompt + ); +} + +#[test] +fn prefix_dispatch_maps_digits_to_focus_and_panes() { + // Digits mirror the no-prefix F-keys one-for-one: 1=F1 (file list), + // 2=F2 (diff viewer), 3..9,0=F3..F10 (terminal panes 0..7). + assert_eq!(prefix_action(key(KeyCode::Char('1'))), Action::FocusList); + assert_eq!(prefix_action(key(KeyCode::Char('2'))), Action::FocusDiff); + assert_eq!( + prefix_action(key(KeyCode::Char('3'))), + Action::SwitchPane(0) + ); + assert_eq!( + prefix_action(key(KeyCode::Char('9'))), + Action::SwitchPane(6) + ); + // 0 mirrors F10 (the 8th pane) since digits only go up to 9. + assert_eq!( + prefix_action(key(KeyCode::Char('0'))), + Action::SwitchPane(7) + ); +} + +#[test] +fn fullscreen_prefix_maps_digits_one_through_eight_to_panes() { + // With the upper viewer hidden the whole digit row addresses panes by + // natural numbering: 1..8 -> panes 0..7. + for d in 1..=8u8 { + let c = char::from(b'0' + d); + assert_eq!( + prefix_action_fullscreen(key(KeyCode::Char(c))), + Action::SwitchPane((d - 1) as usize), + " {c} in fullscreen must jump to pane {}", + d - 1 + ); + } +} + +#[test] +fn fullscreen_prefix_drops_nine_and_zero() { + // Only 8 panes have a jump key, so 9/0 must not fall through to the + // split-view list/diff/pane bindings. + assert_eq!( + prefix_action_fullscreen(key(KeyCode::Char('9'))), + Action::None + ); + assert_eq!( + prefix_action_fullscreen(key(KeyCode::Char('0'))), + Action::None + ); +} + +#[test] +fn fullscreen_prefix_leaves_non_digit_chords_unchanged() { + // Non-digit chords defer to `prefix_action`, so `f`/`t`/`w`/`s` and the + // rest keep their meaning while the terminal is fullscreen. + for (c, expected) in [ + ('f', Action::ToggleFullscreen), + ('t', Action::NewPane), + ('w', Action::ClosePane), + ('s', Action::SwapPanePrompt), + ('q', Action::Quit), + ] { + assert_eq!(prefix_action_fullscreen(key(KeyCode::Char(c))), expected); + } +} + +#[test] +fn prefix_dispatch_ignores_modifiers_on_follow_up() { + // A leftover Ctrl from the leader chord must not break the follow-up. + assert_eq!(prefix_action(ctrl(KeyCode::Char('t'))), Action::NewPane); +} + +#[test] +fn prefix_dispatch_unmapped_key_is_none() { + assert_eq!(prefix_action(key(KeyCode::Char('z'))), Action::None); + assert_eq!(prefix_action(key(KeyCode::Esc)), Action::None); +} + +#[test] +fn maps_navigation_shortcuts() { + assert_eq!(map_key(key(KeyCode::Up)), Action::Up); + assert_eq!(map_key(key(KeyCode::Down)), Action::Down); + assert_eq!(map_key(key(KeyCode::PageUp)), Action::PageUp); + assert_eq!(map_key(key(KeyCode::PageDown)), Action::PageDown); + // j/k are no longer remapped to Up/Down by map_key — they must + // pass through as Action::None so terminal focus can forward them + // verbatim to the PTY. + assert_eq!(map_key(key(KeyCode::Char('k'))), Action::None); + assert_eq!(map_key(key(KeyCode::Char('j'))), Action::None); +} + +#[test] +fn reserved_keys_require_exact_modifiers() { + use KeyModifiers as M; + let with = |code, mods| map_key(KeyEvent::new(code, mods)); + + // Shift-only arrows are reserved. + assert_eq!(with(KeyCode::Left, M::SHIFT), Action::CycleBackward); + // Extra modifiers fall through to the PTY. + assert_eq!(with(KeyCode::Left, M::SHIFT | M::CONTROL), Action::None); + assert_eq!(with(KeyCode::Right, M::SHIFT | M::ALT), Action::None); + // F-keys are reserved only without modifiers. + assert_eq!(with(KeyCode::F(3), M::NONE), Action::SwitchProject(2)); + assert_eq!(with(KeyCode::F(3), M::ALT), Action::None); + assert_eq!(with(KeyCode::F(1), M::CONTROL), Action::None); + // Bare navigation keys with a modifier pass through too. + assert_eq!(with(KeyCode::Up, M::CONTROL), Action::None); + assert_eq!(with(KeyCode::Up, M::NONE), Action::Up); + // Super/Hyper/Meta count as modifiers and must not be ignored. + assert_eq!(with(KeyCode::F(3), M::SUPER), Action::None); + assert_eq!(with(KeyCode::Left, M::SHIFT | M::SUPER), Action::None); +} + +#[test] +fn vim_navigation_for_j_k() { + assert_eq!( + vim_navigation_action(key(KeyCode::Char('k'))), + Some(Action::Up) + ); + assert_eq!( + vim_navigation_action(key(KeyCode::Char('j'))), + Some(Action::Down) + ); + // Modifiers must disable the vim mapping (e.g. Ctrl-J / Shift-K). + assert_eq!(vim_navigation_action(ctrl(KeyCode::Char('j'))), None); + assert_eq!(vim_navigation_action(key(KeyCode::Char('h'))), None); +} + +#[test] +fn maps_cycle_pane_shortcuts() { + let shift_right = KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT); + let shift_left = KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT); + assert_eq!(map_key(shift_right), Action::CycleForward); + assert_eq!(map_key(shift_left), Action::CycleBackward); +} + +#[test] +fn maps_terminal_scroll_shortcuts() { + let shift_pgup = KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT); + let shift_pgdn = KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT); + let shift_up = KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT); + let shift_down = KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT); + assert_eq!(map_key(shift_pgup), Action::TermScrollUp); + assert_eq!(map_key(shift_pgdn), Action::TermScrollDown); + assert_eq!(map_key(shift_up), Action::TermScrollLineUp); + assert_eq!(map_key(shift_down), Action::TermScrollLineDown); + // Plain up/down must not trigger terminal scroll. + assert_ne!(map_key(key(KeyCode::Up)), Action::TermScrollLineUp); + assert_ne!(map_key(key(KeyCode::Down)), Action::TermScrollLineDown); +} + +#[test] +fn f_keys_select_project_tabs_regardless_of_layout() { + // F1..=F10 select project tabs 0..=9 — the whole row, with no gap for + // list/diff focus, which lives on the leader digits instead. Panes and + // list/diff focus are layout-aware (see `prefix_action_fullscreen`), but + // project tabs deliberately are not: there is one mapping, so the same + // F-key reaches the same project whether or not the terminal fills the + // body. + for n in 1..=10u8 { + assert_eq!( + map_key(key(KeyCode::F(n))), + Action::SwitchProject((n - 1) as usize), + "F{n} must select project tab {}", + n - 1 + ); + } + assert_eq!(map_key(key(KeyCode::F(1))), Action::SwitchProject(0)); + assert_eq!(map_key(key(KeyCode::F(8))), Action::SwitchProject(7)); +} \ No newline at end of file From 9af5d3e9e4f5248d201d2852c37612c0cf4b6e40 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 25 Jul 2026 00:54:56 +0900 Subject: [PATCH 05/15] refactor: split web server into server/ submodules --- src/web/{server.rs => server/mod.rs} | 0 src/web/viewer/{dto.rs => dto/mod.rs} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/web/{server.rs => server/mod.rs} (100%) rename src/web/viewer/{dto.rs => dto/mod.rs} (100%) diff --git a/src/web/server.rs b/src/web/server/mod.rs similarity index 100% rename from src/web/server.rs rename to src/web/server/mod.rs diff --git a/src/web/viewer/dto.rs b/src/web/viewer/dto/mod.rs similarity index 100% rename from src/web/viewer/dto.rs rename to src/web/viewer/dto/mod.rs From d5d0200820b2150f309e75acc898d0f8ab41846b Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 25 Jul 2026 00:56:10 +0900 Subject: [PATCH 06/15] refactor: split web server into accept/http_routes/ws/tests submodules --- src/web/server/accept.rs | 67 ++++ src/web/server/http_routes.rs | 137 ++++++++ src/web/server/mod.rs | 571 ++-------------------------------- src/web/server/tests.rs | 258 +++++++++++++++ src/web/server/ws.rs | 107 +++++++ 5 files changed, 593 insertions(+), 547 deletions(-) create mode 100644 src/web/server/accept.rs create mode 100644 src/web/server/http_routes.rs create mode 100644 src/web/server/tests.rs create mode 100644 src/web/server/ws.rs diff --git a/src/web/server/accept.rs b/src/web/server/accept.rs new file mode 100644 index 0000000..92b1114 --- /dev/null +++ b/src/web/server/accept.rs @@ -0,0 +1,67 @@ +use crate::web::common::auth::{Auth, RateLimiter, SessionStore}; +use crate::web::common::conn::ConnectionSlot; +use crate::web::protocol::WebInputEvent; +use std::net::TcpListener; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, AtomicUsize}; +use std::sync::mpsc::Sender; +use std::thread; + +use super::MAX_CONNECTIONS; +use crate::web::server::http_routes::handle_connection; + +/// State shared between the accept/handler threads. Holds no `App` reference. +pub(super) struct Shared { + pub(super) clients: Mutex>, + pub(super) input_tx: Sender, + pub(super) auth: Auth, + pub(super) sessions: SessionStore, + pub(super) limiter: RateLimiter, + pub(super) next_id: AtomicU64, + /// Connections currently held by a handler thread, capped at + /// [`MAX_CONNECTIONS`]. Owned through [`ConnectionSlot`]. + pub(super) connections: Arc, +} + +/// A message queued for one client's handler thread to write to its socket. +pub(super) enum ClientMsg { + /// Grid dimensions changed (or first frame): tell the browser to resize its + /// terminal to match before the repaint. Sent as a JSON text frame. + Resize { cols: u16, rows: u16 }, + /// Encoded ANSI screen bytes. Sent as a binary frame. + Frame(Vec), +} + +/// A connected browser, as seen by the main loop's broadcast. +pub(super) struct ClientHandle { + pub(super) id: u64, + /// Screen updates are pushed here; the client's handler thread writes them + /// to the socket. + pub(super) tx: Sender, + /// Set when the client must receive a full repaint on the next frame + /// (fresh connection or a grid resize). + pub(super) needs_full: bool, +} + +pub(super) fn accept_loop(listener: TcpListener, shared: Arc) { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + // Refuse over the cap by closing the socket here rather than writing a + // 503 from the accept loop: a write to a stalled client would block + // every other connection behind it. + let Some(slot) = ConnectionSlot::acquire(&shared.connections, MAX_CONNECTIONS) else { + tracing::debug!(cap = MAX_CONNECTIONS, "web: refusing connection over cap"); + continue; + }; + let shared = Arc::clone(&shared); + // One handler thread per connection. A failed spawn drops the closure, + // and with it the slot; the accept loop keeps serving others. + let _ = thread::Builder::new() + .name("nightcrow-web-conn".into()) + .spawn(move || { + let _slot = slot; + handle_connection(stream, shared) + }); + } +} \ No newline at end of file diff --git a/src/web/server/http_routes.rs b/src/web/server/http_routes.rs new file mode 100644 index 0000000..eeb880d --- /dev/null +++ b/src/web/server/http_routes.rs @@ -0,0 +1,137 @@ +use crate::web::common::auth::SESSION_COOKIE; +use crate::web::common::conn; +use crate::web::common::http::{self, RequestHead}; +use crate::web::frontend; +use std::io::Write; +use std::net::TcpStream; +use std::sync::Arc; +use std::time::Instant; + +use super::accept::Shared; +use super::ws::serve_websocket; + +fn handle_connection(mut stream: TcpStream, shared: Arc) { + let (head, body) = match conn::read_request(&mut stream) { + Ok(v) => v, + Err(err) => { + tracing::debug!(%err, "web: dropping malformed request"); + return; + } + }; + + let authed = is_authenticated(&head, &shared); + + if head.path == "/ws" && head.is_websocket_upgrade() { + if !authed { + let _ = stream.write_all(&http::response( + "401 Unauthorized", + "text/plain; charset=utf-8", + &[], + b"authentication required", + )); + return; + } + // Defense-in-depth against cross-site WebSocket hijacking: reject a + // browser upgrade whose Origin is not this server. SameSite=Strict + // already keeps the session cookie off cross-site requests, so a + // hijack fails auth anyway; this refuses it outright. A missing Origin + // (native, non-browser clients) is allowed — such a client cannot + // carry a victim's cookie. + if !conn::origin_allowed(&head) { + let _ = stream.write_all(&http::response( + "403 Forbidden", + "text/plain; charset=utf-8", + &[], + b"cross-origin websocket rejected", + )); + return; + } + serve_websocket(stream, &head, shared); + return; + } + + let response = route_http(&head, &body, &shared); + let _ = stream.write_all(&response); +} + +fn is_authenticated(head: &RequestHead, shared: &Shared) -> bool { + head.cookie(SESSION_COOKIE) + .is_some_and(|token| shared.sessions.is_valid(token)) +} + +fn route_http(head: &RequestHead, body: &str, shared: &Shared) -> Vec { + match (head.method.as_str(), head.path.as_str()) { + ("GET", "/") => { + if is_authenticated(head, shared) { + http::html("200 OK", frontend::APP_HTML) + } else { + http::html("200 OK", &frontend::login_page(None)) + } + } + ("POST", "/login") => handle_login(body, shared), + ("GET", "/logout") => { + let clear = format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"); + http::redirect("/", &[("Set-Cookie", &clear)]) + } + // Public vendored renderer assets (MIT xterm.js); no secrets. + ("GET", "/vendor/xterm.js") => http::response( + "200 OK", + "application/javascript; charset=utf-8", + &[], + frontend::XTERM_JS.as_bytes(), + ), + ("GET", "/vendor/xterm.css") => http::response( + "200 OK", + "text/css; charset=utf-8", + &[], + frontend::XTERM_CSS.as_bytes(), + ), + // The favicon; public, no secrets. + ("GET", "/crow.svg") => http::response( + "200 OK", + "image/svg+xml; charset=utf-8", + &[], + frontend::CROW_SVG.as_bytes(), + ), + _ => http::html("404 Not Found", "

404 Not Found

"), + } +} + +fn handle_login(body: &str, shared: &Shared) -> Vec { + if !shared.limiter.check_and_record(Instant::now()) { + return http::response( + "429 Too Many Requests", + "text/html; charset=utf-8", + &[], + frontend::login_page(Some("Too many attempts — wait a minute and try again.")) + .as_bytes(), + ); + } + + let fields = http::parse_form(body); + let password = http::form_field(&fields, "password").unwrap_or(""); + if !shared.auth.verify(password) { + return http::response( + "401 Unauthorized", + "text/html; charset=utf-8", + &[], + frontend::login_page(Some("Incorrect password.")).as_bytes(), + ); + } + + match shared.sessions.issue() { + Ok(token) => { + let cookie = format!("{SESSION_COOKIE}={token}; HttpOnly; SameSite=Strict; Path=/"); + http::redirect("/", &[("Set-Cookie", &cookie)]) + } + Err(err) => { + tracing::error!(%err, "web: failed to mint session token"); + http::response( + "500 Internal Server Error", + "text/html; charset=utf-8", + &[], + b"

internal error

", + ) + } + } +} \ No newline at end of file diff --git a/src/web/server/mod.rs b/src/web/server/mod.rs index 3f1a21a..3148527 100644 --- a/src/web/server/mod.rs +++ b/src/web/server/mod.rs @@ -6,64 +6,30 @@ //! encoded screen frames flow out over a per-client channel. The `App` is never //! shared across threads; only bytes and decoded input events cross the boundary. -use crate::web::common::auth::{Auth, RateLimiter, SESSION_COOKIE, SessionStore}; -use crate::web::common::conn::{self, ConnectionSlot}; -use crate::web::common::http::{self, RequestHead}; -use crate::web::frontend; +mod accept; +mod http_routes; +mod ws; + +use accept::Shared; +use crate::web::common::auth::{Auth, RateLimiter, SessionStore}; use crate::web::protocol::{self, WebInputEvent}; use anyhow::{Context, Result}; use ratatui::buffer::Buffer; use ratatui::layout::Position; -use std::io::Write; -use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; +use std::net::{IpAddr, SocketAddr, TcpListener}; use std::sync::Arc; -use std::sync::Mutex; -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; -use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::atomic::{AtomicU64, AtomicUsize}; +use std::sync::mpsc::{self, Receiver}; use std::thread; -use std::time::{Duration, Instant}; -use tungstenite::{Message, WebSocket}; +use std::time::Duration; /// Poll interval for the per-client loop: bounds added output latency while /// letting the same thread service both socket reads and queued writes. -const WS_POLL_TIMEOUT: Duration = Duration::from_millis(10); +pub(super) const WS_POLL_TIMEOUT: Duration = Duration::from_millis(10); /// Live connections allowed at once. Each one costs a thread, so an unbounded /// accept loop lets anything that can reach the port exhaust the process. /// A browser session needs a handful; this leaves room for several of them. -const MAX_CONNECTIONS: usize = 64; - -/// State shared between the accept/handler threads. Holds no `App` reference. -struct Shared { - clients: Mutex>, - input_tx: Sender, - auth: Auth, - sessions: SessionStore, - limiter: RateLimiter, - next_id: AtomicU64, - /// Connections currently held by a handler thread, capped at - /// [`MAX_CONNECTIONS`]. Owned through [`ConnectionSlot`]. - connections: Arc, -} - -/// A message queued for one client's handler thread to write to its socket. -enum ClientMsg { - /// Grid dimensions changed (or first frame): tell the browser to resize its - /// terminal to match before the repaint. Sent as a JSON text frame. - Resize { cols: u16, rows: u16 }, - /// Encoded ANSI screen bytes. Sent as a binary frame. - Frame(Vec), -} - -/// A connected browser, as seen by the main loop's broadcast. -struct ClientHandle { - id: u64, - /// Screen updates are pushed here; the client's handler thread writes them - /// to the socket. - tx: Sender, - /// Set when the client must receive a full repaint on the next frame - /// (fresh connection or a grid resize). - needs_full: bool, -} +pub(super) const MAX_CONNECTIONS: usize = 64; /// Handle owned by the main loop. Drop stops nothing (threads live until the /// process exits, which is the intended lifetime), but it is the sole surface @@ -109,7 +75,7 @@ impl WebServer { let (input_tx, input_rx) = mpsc::channel(); let shared = Arc::new(Shared { - clients: Mutex::new(Vec::new()), + clients: std::sync::Mutex::new(Vec::new()), input_tx, auth, sessions: SessionStore::new(), @@ -121,7 +87,7 @@ impl WebServer { let accept_shared = Arc::clone(&shared); thread::Builder::new() .name("nightcrow-web-accept".into()) - .spawn(move || accept_loop(listener, accept_shared)) + .spawn(move || accept::accept_loop(listener, accept_shared)) .context("spawning web accept thread")?; Ok(Self { @@ -211,10 +177,16 @@ impl WebServer { // the right cells. client .tx - .send(ClientMsg::Resize { cols, rows }) - .and_then(|()| client.tx.send(ClientMsg::Frame(bytes.clone()))) + .send(accept::ClientMsg::Resize { cols, rows }) + .and_then(|()| { + client + .tx + .send(accept::ClientMsg::Frame(bytes.clone())) + }) } else if let Some(bytes) = update_bytes.as_ref() { - client.tx.send(ClientMsg::Frame(bytes.clone())) + client + .tx + .send(accept::ClientMsg::Frame(bytes.clone())) } else { Ok(()) }; @@ -231,500 +203,5 @@ impl WebServer { } } -fn accept_loop(listener: TcpListener, shared: Arc) { - for stream in listener.incoming() { - let Ok(stream) = stream else { continue }; - // Refuse over the cap by closing the socket here rather than writing a - // 503 from the accept loop: a write to a stalled client would block - // every other connection behind it. - let Some(slot) = ConnectionSlot::acquire(&shared.connections, MAX_CONNECTIONS) else { - tracing::debug!(cap = MAX_CONNECTIONS, "web: refusing connection over cap"); - continue; - }; - let shared = Arc::clone(&shared); - // One handler thread per connection. A failed spawn drops the closure, - // and with it the slot; the accept loop keeps serving others. - let _ = thread::Builder::new() - .name("nightcrow-web-conn".into()) - .spawn(move || { - let _slot = slot; - handle_connection(stream, shared) - }); - } -} - -fn handle_connection(mut stream: TcpStream, shared: Arc) { - let (head, body) = match conn::read_request(&mut stream) { - Ok(v) => v, - Err(err) => { - tracing::debug!(%err, "web: dropping malformed request"); - return; - } - }; - - let authed = is_authenticated(&head, &shared); - - if head.path == "/ws" && head.is_websocket_upgrade() { - if !authed { - let _ = stream.write_all(&http::response( - "401 Unauthorized", - "text/plain; charset=utf-8", - &[], - b"authentication required", - )); - return; - } - // Defense-in-depth against cross-site WebSocket hijacking: reject a - // browser upgrade whose Origin is not this server. SameSite=Strict - // already keeps the session cookie off cross-site requests, so a - // hijack fails auth anyway; this refuses it outright. A missing Origin - // (native, non-browser clients) is allowed — such a client cannot - // carry a victim's cookie. - if !conn::origin_allowed(&head) { - let _ = stream.write_all(&http::response( - "403 Forbidden", - "text/plain; charset=utf-8", - &[], - b"cross-origin websocket rejected", - )); - return; - } - serve_websocket(stream, &head, shared); - return; - } - - let response = route_http(&head, &body, &shared); - let _ = stream.write_all(&response); -} - -fn is_authenticated(head: &RequestHead, shared: &Shared) -> bool { - head.cookie(SESSION_COOKIE) - .is_some_and(|token| shared.sessions.is_valid(token)) -} - -fn route_http(head: &RequestHead, body: &str, shared: &Shared) -> Vec { - match (head.method.as_str(), head.path.as_str()) { - ("GET", "/") => { - if is_authenticated(head, shared) { - http::html("200 OK", frontend::APP_HTML) - } else { - http::html("200 OK", &frontend::login_page(None)) - } - } - ("POST", "/login") => handle_login(body, shared), - ("GET", "/logout") => { - let clear = format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"); - http::redirect("/", &[("Set-Cookie", &clear)]) - } - // Public vendored renderer assets (MIT xterm.js); no secrets. - ("GET", "/vendor/xterm.js") => http::response( - "200 OK", - "application/javascript; charset=utf-8", - &[], - frontend::XTERM_JS.as_bytes(), - ), - ("GET", "/vendor/xterm.css") => http::response( - "200 OK", - "text/css; charset=utf-8", - &[], - frontend::XTERM_CSS.as_bytes(), - ), - // The favicon; public, no secrets. - ("GET", "/crow.svg") => http::response( - "200 OK", - "image/svg+xml; charset=utf-8", - &[], - frontend::CROW_SVG.as_bytes(), - ), - _ => http::html("404 Not Found", "

404 Not Found

"), - } -} - -fn handle_login(body: &str, shared: &Shared) -> Vec { - if !shared.limiter.check_and_record(Instant::now()) { - return http::response( - "429 Too Many Requests", - "text/html; charset=utf-8", - &[], - frontend::login_page(Some("Too many attempts — wait a minute and try again.")) - .as_bytes(), - ); - } - - let fields = http::parse_form(body); - let password = http::form_field(&fields, "password").unwrap_or(""); - if !shared.auth.verify(password) { - return http::response( - "401 Unauthorized", - "text/html; charset=utf-8", - &[], - frontend::login_page(Some("Incorrect password.")).as_bytes(), - ); - } - - match shared.sessions.issue() { - Ok(token) => { - let cookie = format!("{SESSION_COOKIE}={token}; HttpOnly; SameSite=Strict; Path=/"); - http::redirect("/", &[("Set-Cookie", &cookie)]) - } - Err(err) => { - tracing::error!(%err, "web: failed to mint session token"); - http::response( - "500 Internal Server Error", - "text/html; charset=utf-8", - &[], - b"

internal error

", - ) - } - } -} - -/// Complete the WebSocket handshake manually (the request head was already -/// consumed for routing/auth), then run the per-client read/write loop. -fn serve_websocket(stream: TcpStream, head: &RequestHead, shared: Arc) { - if let Some(ws) = conn::websocket_handshake(stream, head) { - run_client(ws, shared); - } -} - -fn run_client(mut ws: WebSocket, shared: Arc) { - let (tx, rx) = mpsc::channel::(); - let id = shared.next_id.fetch_add(1, Ordering::Relaxed); - if let Ok(mut clients) = shared.clients.lock() { - clients.push(ClientHandle { - id, - tx, - needs_full: true, - }); - } - // A read timeout turns the blocking read into a poll so the same thread can - // also flush queued output frames. - ws.get_ref().set_read_timeout(Some(WS_POLL_TIMEOUT)).ok(); - - loop { - if !pump_writes(&mut ws, &rx) { - break; - } - if !pump_read(&mut ws, &shared) { - break; - } - } - - if let Ok(mut clients) = shared.clients.lock() { - clients.retain(|c| c.id != id); - } -} - -/// Drain and send any queued output messages. Returns false on a write error -/// (client gone). -fn pump_writes(ws: &mut WebSocket, rx: &Receiver) -> bool { - while let Ok(msg) = rx.try_recv() { - let written = match msg { - ClientMsg::Resize { cols, rows } => ws.write(Message::text(format!( - r#"{{"t":"resize","cols":{cols},"rows":{rows}}}"# - ))), - ClientMsg::Frame(bytes) => ws.write(Message::binary(bytes)), - }; - if written.is_err() { - return false; - } - } - ws.flush().is_ok() -} - -/// Read at most one input message. Returns false when the connection should -/// close; a read timeout (no data yet) returns true so the loop continues. -fn pump_read(ws: &mut WebSocket, shared: &Shared) -> bool { - match ws.read() { - Ok(msg) => { - if msg.is_close() { - return false; - } - if msg.is_text() || msg.is_binary() { - let data = msg.into_data(); - if let Ok(text) = std::str::from_utf8(data.as_ref()) { - dispatch_input(text, shared); - } - } - true - } - Err(tungstenite::Error::Io(e)) - if matches!( - e.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut - ) => - { - // Poll timeout: no message this round. - true - } - Err(_) => false, - } -} - -fn dispatch_input(text: &str, shared: &Shared) { - if protocol::ensure_input_size(text.len()).is_err() { - tracing::debug!("web: dropping oversized input message"); - return; - } - match protocol::decode_input(text) { - Ok(Some(event)) => { - let _ = shared.input_tx.send(event); - } - Ok(None) => {} - Err(err) => tracing::debug!(%err, "web: dropping undecodable input"), - } -} - #[cfg(test)] -mod tests { - use super::*; - use crate::config::WebMirrorConfig; - use ratatui::layout::Rect; - use ratatui::style::Style; - use std::io::Read; - use tungstenite::client::IntoClientRequest; - - fn test_config(password: &str) -> WebMirrorConfig { - WebMirrorConfig { - enabled: true, - bind: "127.0.0.1".into(), - // Port 0 asks the OS for a free ephemeral port. - port: 0, - password: Some(password.into()), - hashed_password: None, - } - } - - /// Send a raw HTTP request and read the full response (server closes the - /// connection after each response). - fn http_request(addr: SocketAddr, raw: &str) -> String { - let mut stream = TcpStream::connect(addr).unwrap(); - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - stream.write_all(raw.as_bytes()).unwrap(); - let mut buf = Vec::new(); - // Reads until the server closes the socket (Connection: close). - let _ = stream.read_to_end(&mut buf); - String::from_utf8_lossy(&buf).into_owned() - } - - fn form_post(body: &str) -> String { - format!( - "POST /login HTTP/1.1\r\nHost: x\r\n\ - Content-Type: application/x-www-form-urlencoded\r\n\ - Content-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ) - } - - fn session_token(response: &str) -> Option { - for line in response.lines() { - if let Some(value) = line.strip_prefix("Set-Cookie: ") - && let Some(rest) = value.strip_prefix(&format!("{SESSION_COOKIE}=")) - { - let token = rest.split(';').next()?.trim(); - if !token.is_empty() { - return Some(token.to_string()); - } - } - } - None - } - - #[test] - fn login_flow_issues_session_and_gates_the_app_page() { - let server = WebServer::start_from_config(&test_config("swordfish")).unwrap(); - let addr = server.addr(); - - // Unauthenticated GET / serves the login page, not the app. - let anon = http_request( - addr, - "GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", - ); - assert!(anon.contains("Sign in"), "login page expected"); - assert!( - !anon.contains("/vendor/xterm.js"), - "the terminal app must be gated behind auth" - ); - - // Wrong password is rejected. - let bad = http_request(addr, &form_post("password=nope")); - assert!(bad.starts_with("HTTP/1.1 401"), "wrong password must 401"); - - // Correct password issues a session cookie via a redirect. - let ok = http_request(addr, &form_post("password=swordfish")); - assert!( - ok.starts_with("HTTP/1.1 303"), - "correct password must redirect" - ); - let token = session_token(&ok).expect("a session cookie"); - - // The cookie unlocks the app page. - let app = http_request( - addr, - &format!( - "GET / HTTP/1.1\r\nHost: x\r\nCookie: {SESSION_COOKIE}={token}\r\nConnection: close\r\n\r\n" - ), - ); - assert!( - app.contains("/vendor/xterm.js"), - "authenticated GET / serves the terminal app" - ); - } - - #[test] - fn serves_vendored_renderer_assets() { - let server = WebServer::start_from_config(&test_config("pw")).unwrap(); - let addr = server.addr(); - let js = http_request( - addr, - "GET /vendor/xterm.js HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", - ); - assert!(js.starts_with("HTTP/1.1 200")); - assert!(js.contains("application/javascript")); - } - - #[test] - fn serves_the_favicon_without_auth() { - let server = WebServer::start_from_config(&test_config("pw")).unwrap(); - let addr = server.addr(); - // The login page references /crow.svg, so it must load before sign-in. - let svg = http_request( - addr, - "GET /crow.svg HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", - ); - assert!(svg.starts_with("HTTP/1.1 200")); - assert!(svg.contains("image/svg+xml")); - assert!(svg.contains(" { - let text = msg.into_text().unwrap(); - assert!( - text.contains("\"t\":\"resize\"") - && text.contains("\"cols\":8") - && text.contains("\"rows\":1"), - "resize control message must carry the grid size, got: {text}" - ); - resize_seen = true; - } - Ok(msg) if msg.is_binary() => { - frame = Some(msg.into_data()); - break; - } - Ok(_) => {} - Err(tungstenite::Error::Io(e)) - if matches!( - e.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut - ) => {} - Err(e) => panic!("ws read failed: {e}"), - } - thread::sleep(Duration::from_millis(20)); - } - assert!( - resize_seen, - "a new client must receive a resize message first" - ); - let frame = frame.expect("a broadcast frame within the retry budget"); - assert!( - frame.windows(5).any(|w| w == b"hello"), - "the mirrored frame must carry the painted text" - ); - let cursor_tail = protocol::encode_cursor(Some(Position::new(2, 0))); - assert!( - frame.ends_with(&cursor_tail), - "the frame must end by parking the cursor where the draw left it" - ); - - // Input sent from the browser reaches the main loop's drain. - ws.write(Message::text(r#"{"t":"key","key":"a"}"#)).unwrap(); - ws.flush().unwrap(); - let mut input = Vec::new(); - for _ in 0..100 { - input = server.drain_input(); - if !input.is_empty() { - break; - } - thread::sleep(Duration::from_millis(20)); - } - assert_eq!( - input.len(), - 1, - "the keypress must be delivered exactly once" - ); - assert!(matches!(input[0], WebInputEvent::Key(_))); - } -} +mod tests; \ No newline at end of file diff --git a/src/web/server/tests.rs b/src/web/server/tests.rs new file mode 100644 index 0000000..eab4382 --- /dev/null +++ b/src/web/server/tests.rs @@ -0,0 +1,258 @@ +use super::*; +use crate::config::WebMirrorConfig; +use crate::web::common::auth::SESSION_COOKIE; +use ratatui::layout::{Position, Rect}; +use ratatui::style::Style; +use std::io::Read; +use std::io::Write; +use std::net::SocketAddr; +use std::net::TcpStream; +use std::thread; +use std::time::Duration; +use tungstenite::client::IntoClientRequest; +use tungstenite::Message; + + +fn test_config(password: &str) -> WebMirrorConfig { + WebMirrorConfig { + enabled: true, + bind: "127.0.0.1".into(), + // Port 0 asks the OS for a free ephemeral port. + port: 0, + password: Some(password.into()), + hashed_password: None, + } +} + +/// Send a raw HTTP request and read the full response (server closes the +/// connection after each response). +fn http_request(addr: SocketAddr, raw: &str) -> String { + let mut stream = TcpStream::connect(addr).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream.write_all(raw.as_bytes()).unwrap(); + let mut buf = Vec::new(); + // Reads until the server closes the socket (Connection: close). + let _ = stream.read_to_end(&mut buf); + String::from_utf8_lossy(&buf).into_owned() +} + +fn form_post(body: &str) -> String { + format!( + "POST /login HTTP/1.1\r\nHost: x\r\n\ + Content-Type: application/x-www-form-urlencoded\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) +} + +fn session_token(response: &str) -> Option { + for line in response.lines() { + if let Some(value) = line.strip_prefix("Set-Cookie: ") + && let Some(rest) = value.strip_prefix(&format!("{SESSION_COOKIE}=")) + { + let token = rest.split(';').next()?.trim(); + if !token.is_empty() { + return Some(token.to_string()); + } + } + } + None +} + +#[test] +fn login_flow_issues_session_and_gates_the_app_page() { + let server = WebServer::start_from_config(&test_config("swordfish")).unwrap(); + let addr = server.addr(); + + // Unauthenticated GET / serves the login page, not the app. + let anon = http_request( + addr, + "GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", + ); + assert!(anon.contains("Sign in"), "login page expected"); + assert!( + !anon.contains("/vendor/xterm.js"), + "the terminal app must be gated behind auth" + ); + + // Wrong password is rejected. + let bad = http_request(addr, &form_post("password=nope")); + assert!(bad.starts_with("HTTP/1.1 401"), "wrong password must 401"); + + // Correct password issues a session cookie via a redirect. + let ok = http_request(addr, &form_post("password=swordfish")); + assert!( + ok.starts_with("HTTP/1.1 303"), + "correct password must redirect" + ); + let token = session_token(&ok).expect("a session cookie"); + + // The cookie unlocks the app page. + let app = http_request( + addr, + &format!( + "GET / HTTP/1.1\r\nHost: x\r\nCookie: {SESSION_COOKIE}={token}\r\nConnection: close\r\n\r\n" + ), + ); + assert!( + app.contains("/vendor/xterm.js"), + "authenticated GET / serves the terminal app" + ); +} + +#[test] +fn serves_vendored_renderer_assets() { + let server = WebServer::start_from_config(&test_config("pw")).unwrap(); + let addr = server.addr(); + let js = http_request( + addr, + "GET /vendor/xterm.js HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", + ); + assert!(js.starts_with("HTTP/1.1 200")); + assert!(js.contains("application/javascript")); +} + +#[test] +fn serves_the_favicon_without_auth() { + let server = WebServer::start_from_config(&test_config("pw")).unwrap(); + let addr = server.addr(); + // The login page references /crow.svg, so it must load before sign-in. + let svg = http_request( + addr, + "GET /crow.svg HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", + ); + assert!(svg.starts_with("HTTP/1.1 200")); + assert!(svg.contains("image/svg+xml")); + assert!(svg.contains(" { + let text = msg.into_text().unwrap(); + assert!( + text.contains("\"t\":\"resize\"") + && text.contains("\"cols\":8") + && text.contains("\"rows\":1"), + "resize control message must carry the grid size, got: {text}" + ); + resize_seen = true; + } + Ok(msg) if msg.is_binary() => { + frame = Some(msg.into_data()); + break; + } + Ok(_) => {} + Err(tungstenite::Error::Io(e)) + if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => {} + Err(e) => panic!("ws read failed: {e}"), + } + thread::sleep(Duration::from_millis(20)); + } + assert!( + resize_seen, + "a new client must receive a resize message first" + ); + let frame = frame.expect("a broadcast frame within the retry budget"); + assert!( + frame.windows(5).any(|w| w == b"hello"), + "the mirrored frame must carry the painted text" + ); + let cursor_tail = protocol::encode_cursor(Some(Position::new(2, 0))); + assert!( + frame.ends_with(&cursor_tail), + "the frame must end by parking the cursor where the draw left it" + ); + + // Input sent from the browser reaches the main loop's drain. + ws.write(Message::text(r#"{"t":"key","key":"a"}"#)).unwrap(); + ws.flush().unwrap(); + let mut input = Vec::new(); + for _ in 0..100 { + input = server.drain_input(); + if !input.is_empty() { + break; + } + thread::sleep(Duration::from_millis(20)); + } + assert_eq!( + input.len(), + 1, + "the keypress must be delivered exactly once" + ); + assert!(matches!(input[0], WebInputEvent::Key(_))); +} diff --git a/src/web/server/ws.rs b/src/web/server/ws.rs new file mode 100644 index 0000000..70f9890 --- /dev/null +++ b/src/web/server/ws.rs @@ -0,0 +1,107 @@ +use crate::web::common::conn; +use crate::web::common::http::RequestHead; +use crate::web::protocol; +use std::net::TcpStream; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::sync::mpsc::Receiver; +use tungstenite::{Message, WebSocket}; + +use super::accept::{ClientHandle, ClientMsg, Shared}; +use super::WS_POLL_TIMEOUT; + +/// Complete the WebSocket handshake manually (the request head was already +/// consumed for routing/auth), then run the per-client read/write loop. +pub(super) fn serve_websocket(stream: TcpStream, head: &RequestHead, shared: Arc) { + if let Some(ws) = conn::websocket_handshake(stream, head) { + run_client(ws, shared); + } +} + +fn run_client(mut ws: WebSocket, shared: Arc) { + let (tx, rx) = std::sync::mpsc::channel::(); + let id = shared.next_id.fetch_add(1, Ordering::Relaxed); + if let Ok(mut clients) = shared.clients.lock() { + clients.push(ClientHandle { + id, + tx, + needs_full: true, + }); + } + // A read timeout turns the blocking read into a poll so the same thread can + // also flush queued output frames. + ws.get_ref().set_read_timeout(Some(WS_POLL_TIMEOUT)).ok(); + + loop { + if !pump_writes(&mut ws, &rx) { + break; + } + if !pump_read(&mut ws, &shared) { + break; + } + } + + if let Ok(mut clients) = shared.clients.lock() { + clients.retain(|c| c.id != id); + } +} + +/// Drain and send any queued output messages. Returns false on a write error +/// (client gone). +fn pump_writes(ws: &mut WebSocket, rx: &Receiver) -> bool { + while let Ok(msg) = rx.try_recv() { + let written = match msg { + ClientMsg::Resize { cols, rows } => ws.write(Message::text(format!( + r#"{{"t":"resize","cols":{cols},"rows":{rows}}}"# + ))), + ClientMsg::Frame(bytes) => ws.write(Message::binary(bytes)), + }; + if written.is_err() { + return false; + } + } + ws.flush().is_ok() +} + +/// Read at most one input message. Returns false when the connection should +/// close; a read timeout (no data yet) returns true so the loop continues. +fn pump_read(ws: &mut WebSocket, shared: &Shared) -> bool { + match ws.read() { + Ok(msg) => { + if msg.is_close() { + return false; + } + if msg.is_text() || msg.is_binary() { + let data = msg.into_data(); + if let Ok(text) = std::str::from_utf8(data.as_ref()) { + dispatch_input(text, shared); + } + } + true + } + Err(tungstenite::Error::Io(e)) + if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + // Poll timeout: no message this round. + true + } + Err(_) => false, + } +} + +fn dispatch_input(text: &str, shared: &Shared) { + if protocol::ensure_input_size(text.len()).is_err() { + tracing::debug!("web: dropping oversized input message"); + return; + } + match protocol::decode_input(text) { + Ok(Some(event)) => { + let _ = shared.input_tx.send(event); + } + Ok(None) => {} + Err(err) => tracing::debug!(%err, "web: dropping undecodable input"), + } +} \ No newline at end of file From cf7443ae60a31db4f548281e754d071305d6c840 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 25 Jul 2026 00:56:29 +0900 Subject: [PATCH 07/15] refactor: split terminal into terminal/ submodules --- src/runtime/{terminal.rs => terminal/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/runtime/{terminal.rs => terminal/mod.rs} (100%) diff --git a/src/runtime/terminal.rs b/src/runtime/terminal/mod.rs similarity index 100% rename from src/runtime/terminal.rs rename to src/runtime/terminal/mod.rs From ad128522f42d94b85a18da3f39c9da1b4e530736 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 25 Jul 2026 01:02:48 +0900 Subject: [PATCH 08/15] refactor: split terminal into state/scroll/lifecycle/escape/tests submodules --- src/runtime/terminal/escape.rs | 98 ++++++++ src/runtime/terminal/lifecycle.rs | 235 ++++++++++++++++++ src/runtime/terminal/scroll.rs | 169 +++++++++++++ src/runtime/terminal/state.rs | 80 ++++++ src/runtime/terminal/tests/common.rs | 56 +++++ src/runtime/terminal/tests/lifecycle_tests.rs | 186 ++++++++++++++ src/runtime/terminal/tests/mod.rs | 7 + src/runtime/terminal/tests/poll_tests.rs | 84 +++++++ src/runtime/terminal/tests/scroll_tests.rs | 185 ++++++++++++++ src/runtime/terminal/tests/state_tests.rs | 104 ++++++++ src/web/viewer/{server.rs => server/mod.rs} | 0 11 files changed, 1204 insertions(+) create mode 100644 src/runtime/terminal/escape.rs create mode 100644 src/runtime/terminal/lifecycle.rs create mode 100644 src/runtime/terminal/scroll.rs create mode 100644 src/runtime/terminal/state.rs create mode 100644 src/runtime/terminal/tests/common.rs create mode 100644 src/runtime/terminal/tests/lifecycle_tests.rs create mode 100644 src/runtime/terminal/tests/mod.rs create mode 100644 src/runtime/terminal/tests/poll_tests.rs create mode 100644 src/runtime/terminal/tests/scroll_tests.rs create mode 100644 src/runtime/terminal/tests/state_tests.rs rename src/web/viewer/{server.rs => server/mod.rs} (100%) diff --git a/src/runtime/terminal/escape.rs b/src/runtime/terminal/escape.rs new file mode 100644 index 0000000..0f48c5d --- /dev/null +++ b/src/runtime/terminal/escape.rs @@ -0,0 +1,98 @@ +pub(crate) fn strip_escape_sequences(data: &[u8]) -> String { + let text = String::from_utf8_lossy(data); + let mut result = String::new(); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '\x1b' => consume_escape_sequence(&mut chars), + // \r, \n, and the line-editing controls (BS, DEL) are forwarded + // so `buffer_prompt_input` can flush on newlines and pop on + // backspace; every other control byte is dropped. + '\r' | '\n' | '\x08' | '\x7f' => result.push(ch), + c if !c.is_control() => result.push(c), + _ => {} + } + } + result +} + +/// Consume the body of an ESC-introduced control sequence. Called with the +/// leading ESC already taken; advances `chars` past the sequence's terminator +/// (or leaves the iterator alone for a bare ESC). +fn consume_escape_sequence(chars: &mut std::iter::Peekable>) { + match chars.peek().copied() { + Some('[') => { + chars.next(); + consume_csi(chars); + } + Some(']') => { + chars.next(); + consume_osc(chars); + } + Some('O') => { + chars.next(); + consume_ss3(chars); + } + Some('(') | Some(')') | Some('*') | Some('+') | Some('-') | Some('.') | Some('/') + | Some('#') => { + // Charset designators / DEC private 2-byte escapes: + // ESC . Skip both. + chars.next(); + chars.next(); + } + _ => { + // Drop the bare ESC and let the next iteration process whatever + // follows as ordinary input. Consuming an extra byte here would + // silently swallow user keystrokes that happened to land right + // after a stray Esc. + } + } +} + +/// 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. +fn consume_csi(chars: &mut std::iter::Peekable>) { + while let Some(&c) = chars.peek() { + if c < '\x20' { + return; + } + chars.next(); + if c == '\x7f' { + continue; + } + if ('\x40'..='\x7e').contains(&c) { + return; + } + } +} + +/// OSC: skip until BEL (0x07) or ST (ESC \). +fn consume_osc(chars: &mut std::iter::Peekable>) { + loop { + match chars.next() { + None | Some('\x07') => break, + Some('\x1b') if chars.peek() == Some(&'\\') => { + chars.next(); + break; + } + _ => {} + } + } +} + +/// 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. +fn consume_ss3(chars: &mut std::iter::Peekable>) { + if let Some(&next) = chars.peek() + && ('\x40'..='\x7e').contains(&next) + { + chars.next(); + } +} \ No newline at end of file diff --git a/src/runtime/terminal/lifecycle.rs b/src/runtime/terminal/lifecycle.rs new file mode 100644 index 0000000..c7738d5 --- /dev/null +++ b/src/runtime/terminal/lifecycle.rs @@ -0,0 +1,235 @@ +use crate::backend::{BackendEvent, PaneId}; +use crate::runtime::emulator::PaneEmulator; +use crate::runtime::terminal::{ + PaneInfo, PROMPT_BUFFER_MAX_BYTES, SCROLLBACK_LINES, TerminalState, strip_escape_sequences, +}; + +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) + /// that depends on state outside this struct. + pub fn poll(&mut self) -> Vec { + let mut exited = Vec::new(); + let events: Vec = self + .backend + .as_mut() + .map(|b| b.drain_events()) + .unwrap_or_default(); + + for event in events { + match event { + BackendEvent::Output { pane, data } => { + let Some(emulator) = self.emulators.get_mut(&pane) else { + continue; + }; + let events = emulator.process(&data); + if let Some(title) = events.title + && let Some(info) = self.panes.iter_mut().find(|p| p.id == pane) + { + info.title = title; + } + // Terminal query responses (DA, DSR, ...) go back to the + // program that asked. Bypasses `send_input` on purpose: + // an emulator-generated reply must not clear the user's + // scroll position or land in the prompt log. + if !events.pty_writes.is_empty() + && let Some(backend) = &mut self.backend + && let Err(e) = backend.send_input(pane, &events.pty_writes) + { + tracing::warn!("failed to send terminal reply to pane {pane}: {e}"); + } + } + BackendEvent::Exited { pane } => { + // Single source of truth for pane removal: `drain_events` + // no longer touches the backend's pane map, so we drive + // the teardown here. `destroy_pane` is idempotent against + // a pane that `close_active` already removed. + if let Some(backend) = &mut self.backend { + backend.destroy_pane(pane); + } + self.remove_pane_state(pane); + self.panes.retain(|p| p.id != pane); + exited.push(pane); + } + } + } + exited + } + + /// Allocate a new bare interactive-shell pane. Thin wrapper over + /// `create_pane_with` for the common "open an empty terminal" path. + pub fn create_pane(&mut self) -> anyhow::Result<()> { + self.create_pane_with(None, None) + } + + /// 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`. The caller is expected to + /// surface any error to the user. + pub fn create_pane_with( + &mut self, + command: Option<&str>, + label: Option<&str>, + ) -> anyhow::Result<()> { + // Seed the new pane with the active pane's current content size so it + // starts roughly right-sized inside the split grid; the next frame's + // `resize_visible_panes` corrects it to the actual cell Rect once the + // pane count (and therefore the grid) has changed. + let (rows, cols) = self + .active_pane_id() + .map(|id| self.pane_size(id)) + .unwrap_or(self.size); + let (rows, cols) = crate::runtime::emulator::effective_size(rows, cols); + let backend = self + .backend + .as_mut() + .ok_or_else(|| anyhow::anyhow!("no terminal backend available"))?; + + let id = backend.create_pane(rows, cols, command)?; + self.emulators + .insert(id, PaneEmulator::new(rows, cols, SCROLLBACK_LINES)); + self.last_content_size.insert(id, (rows, cols)); + // Title precedence: explicit label → command text → default shell N. + let title = match (label, command) { + (Some(l), _) if !l.trim().is_empty() => l.trim().to_string(), + (_, Some(c)) if !c.trim().is_empty() => c.trim().to_string(), + _ => format!("shell {}", self.panes.len() + 1), + }; + self.panes.push(PaneInfo { id, title }); + self.active = self.panes.len() - 1; + self.sync_visible_window(); + tracing::info!(pane = id, "terminal pane opened"); + Ok(()) + } + + /// Remove the currently active pane. Returns `true` when a pane was + /// removed so the caller can re-clamp dependent state (focus, + /// fullscreen). Returns `false` for an empty list — a benign no-op + /// the caller can ignore. + pub fn close_active(&mut self) -> bool { + let Some(info) = self.panes.get(self.active) else { + return false; + }; + let id = info.id; + tracing::info!(pane = id, "terminal pane closed"); + if let Some(backend) = &mut self.backend { + backend.destroy_pane(id); + } + self.remove_pane_state(id); + self.panes.remove(self.active); + true + } + + /// Swap the active pane with the pane at `idx`, moving focus so it follows + /// the active pane to its new slot (`active` becomes `idx`). Returns `true` + /// when the swap happened, `false` for an out-of-range `idx` or a self-swap + /// (both benign no-ops). Only the ordered `panes` Vec changes — all + /// per-pane state (parsers, scroll, sizes, prompt buffers, backend) is keyed + /// by `PaneId`, so reordering leaves it untouched. + pub fn swap_active_with(&mut self, idx: usize) -> bool { + if idx >= self.panes.len() || idx == self.active { + return false; + } + self.panes.swap(self.active, idx); + self.active = idx; + self.sync_visible_window(); + true + } + + pub(super) fn remove_pane_state(&mut self, id: PaneId) { + self.emulators.remove(&id); + // Flush any unterminated prompt input so we don't lose the line the + // user was composing when the pane closes. + if let Some(buf) = self.prompt_bufs.remove(&id) + && !buf.is_empty() + { + tracing::info!(target: "prompt", pane = id, text = %buf); + } + self.scroll.remove(&id); + self.last_content_size.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. + 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.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)); + } + } + + pub fn send_input(&mut self, data: &[u8]) { + let Some(info) = self.panes.get(self.active) else { + return; + }; + let id = info.id; + self.scroll.remove(&id); + if let Some(backend) = &mut self.backend + && let Err(e) = backend.send_input(id, data) + { + tracing::warn!("failed to send terminal input to pane {id}: {e}"); + } + if self.prompt_log_enabled { + self.buffer_prompt_input(id, data); + } + } + + pub(super) fn buffer_prompt_input(&mut self, pane_id: PaneId, data: &[u8]) { + let text = strip_escape_sequences(data); + let buf = self.prompt_bufs.entry(pane_id).or_default(); + for ch in text.chars() { + match ch { + '\r' | '\n' => { + if !buf.is_empty() { + tracing::info!(target: "prompt", pane = pane_id, text = %buf); + buf.clear(); + } + } + // 0x7f (DEL, sent by Backspace) and 0x08 (BS, sent by Ctrl+H) + // 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. + if buf.len() < PROMPT_BUFFER_MAX_BYTES { + buf.push(ch); + } + } + } + } + } + + /// Byte payloads recorded by an underlying `FakeBackend`, for tests that + /// assert exact PTY pass-through. `None` when the backend is not a + /// `FakeBackend` (e.g. production `PtyBackend` or no backend). + #[cfg(test)] + pub(crate) fn fake_backend_sent(&self) -> Option>> { + self.backend.as_ref().and_then(|b| b.test_sent_payloads()) + } +} \ No newline at end of file diff --git a/src/runtime/terminal/scroll.rs b/src/runtime/terminal/scroll.rs new file mode 100644 index 0000000..8421aec --- /dev/null +++ b/src/runtime/terminal/scroll.rs @@ -0,0 +1,169 @@ +use crate::backend::PaneId; +use crate::input::{encode_arrow, encode_button, encode_wheel, encode_wheel_horizontal}; +use crate::runtime::emulator::ScrollSink; +use crossterm::event::MouseButton; + +use super::{TerminalState, WHEEL_LINES_PER_NOTCH}; + +impl TerminalState { + /// Scroll the active pane by `lines`. See `scroll_pane`. + pub fn scroll_active(&mut self, up: bool, lines: usize) { + let Some(id) = self.active_pane_id() else { + return; + }; + self.scroll_pane(id, up, lines, None); + } + + /// 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. + pub fn scroll_pane(&mut self, id: PaneId, up: bool, lines: usize, pointer: Option<(u16, u16)>) { + if lines == 0 { + return; + } + let Some(emulator) = self.emulators.get(&id) else { + return; + }; + let sink = emulator.scroll_sink(); + let app_cursor = emulator.app_cursor(); + + match sink { + 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. + let (col, row) = match pointer { + Some(cell) => cell, + None => { + let (rows, cols) = self.pane_size(id); + (cols / 2 + 1, rows / 2 + 1) + } + }; + let notch = encode_wheel(up, col, row); + let notches = lines.div_ceil(WHEEL_LINES_PER_NOTCH); + let payload = notch.repeat(notches); + self.write_pty(id, &payload); + } + ScrollSink::ArrowKeys => { + let payload = encode_arrow(up, app_cursor).repeat(lines); + self.write_pty(id, &payload); + } + ScrollSink::Scrollback => { + if up { + self.scroll_up(id, lines); + } else { + self.scroll_down(id, lines); + } + // A wheel event can target a non-active pane, which the + // per-frame `sync_scroll` (active pane only) never reaches — + // apply the offset here so the view moves immediately. + self.sync_scroll_pane(id); + } + } + } + + /// 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`). + pub fn wheel_horizontal_pane(&mut self, id: PaneId, left: bool, col: u16, row: u16) { + let Some(emulator) = self.emulators.get(&id) else { + return; + }; + if emulator.scroll_sink() != ScrollSink::MouseWheel { + return; + } + let payload = encode_wheel_horizontal(left, col, row); + self.write_pty(id, &payload); + } + + /// 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. + pub fn click_pane( + &mut self, + id: PaneId, + button: MouseButton, + press: bool, + col: u16, + row: u16, + ) -> bool { + let Some(emulator) = self.emulators.get(&id) else { + return false; + }; + if !emulator.wants_mouse_buttons() { + return false; + } + let payload = encode_button(button, press, col, row); + self.write_pty(id, &payload); + true + } + + /// Write straight to a pane's PTY. Bypasses `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. + pub(super) fn write_pty(&mut self, id: PaneId, data: &[u8]) { + if let Some(backend) = &mut self.backend + && let Err(e) = backend.send_input(id, data) + { + tracing::warn!("failed to send synthesized scroll to pane {id}: {e}"); + } + } + + pub(super) fn scroll_up(&mut self, id: PaneId, lines: usize) { + if lines == 0 { + return; + } + let offset = self.scroll.entry(id).or_insert(0); + *offset = offset.saturating_add(lines); + } + + pub(super) fn scroll_down(&mut self, id: PaneId, lines: usize) { + if lines == 0 { + return; + } + if let Some(entry) = self.scroll.get_mut(&id) { + *entry = entry.saturating_sub(lines); + if *entry == 0 { + self.scroll.remove(&id); + } + } + } + + pub fn sync_scroll(&mut self) { + let Some(id) = self.active_pane_id() else { + return; + }; + self.sync_scroll_pane(id); + } + + pub(super) fn sync_scroll_pane(&mut self, id: PaneId) { + let offset = self.scroll.get(&id).copied().unwrap_or(0); + let actual = match self.emulators.get_mut(&id) { + // The emulator clamps the offset to the actual scrollback + // buffer size internally, so we can pass the full request + // through and read back what was applied. + Some(emulator) => emulator.set_scroll_offset(offset), + None => return, + }; + if actual == 0 { + self.scroll.remove(&id); + } else { + self.scroll.insert(id, actual); + } + } +} \ No newline at end of file diff --git a/src/runtime/terminal/state.rs b/src/runtime/terminal/state.rs new file mode 100644 index 0000000..25bb4ae --- /dev/null +++ b/src/runtime/terminal/state.rs @@ -0,0 +1,80 @@ +use crate::backend::PaneId; +use crate::runtime::emulator::{PaneEmulator, ScreenView}; + +use super::{TerminalFullscreen, TerminalState, visible_range}; + +impl TerminalState { + pub fn active_pane_id(&self) -> Option { + self.panes.get(self.active).map(|p| p.id) + } + + /// Maximum number of panes shown at once in the current fullscreen state. + /// `Zoom` caps at 1 so the shared grid path renders only the active pane. + pub fn max_visible(&self) -> usize { + match self.fullscreen { + TerminalFullscreen::Off => self.max_visible_normal, + TerminalFullscreen::Grid => self.max_visible_fullscreen, + TerminalFullscreen::Zoom => 1, + } + } + + /// 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 + /// 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. + pub fn zoom_distinct_from_grid(&self) -> bool { + self.max_visible_fullscreen.min(self.panes.len()) > 1 + } + + /// Last known content size for `id`, falling back to the default pane + /// size for a pane that hasn't been through a layout resize yet. + pub fn pane_size(&self, id: PaneId) -> (u16, u16) { + self.last_content_size + .get(&id) + .copied() + .unwrap_or(self.size) + } + + /// 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. + pub fn active_pane_rows(&self) -> usize { + self.active_pane_id() + .map(|id| self.pane_size(id).0 as usize) + .unwrap_or(self.size.0 as usize) + } + + /// Re-clamp `visible_start` against the current active pane and pane + /// count. Must be called after anything that changes `active` or + /// `panes.len()` (focus jumps, pane create/close, session restore) so + /// the split-view window always contains the active pane. + pub fn sync_visible_window(&mut self) { + let range = visible_range( + self.visible_start, + self.active, + self.panes.len(), + self.max_visible(), + ); + self.visible_start = range.start; + } + + /// Screen for a specific pane, independent of which pane is currently + /// active — the split-view renderer draws every visible pane, not just + /// the focused one. + pub fn screen_for_pane(&self, id: PaneId) -> Option> { + self.emulators.get(&id).map(PaneEmulator::view) + } + + pub fn active_screen(&self) -> Option> { + let id = self.active_pane_id()?; + self.screen_for_pane(id) + } + + pub fn is_scrolled(&self) -> bool { + self.active_pane_id() + .and_then(|id| self.scroll.get(&id)) + .is_some_and(|&v| v > 0) + } +} \ No newline at end of file diff --git a/src/runtime/terminal/tests/common.rs b/src/runtime/terminal/tests/common.rs new file mode 100644 index 0000000..3e5ca4c --- /dev/null +++ b/src/runtime/terminal/tests/common.rs @@ -0,0 +1,56 @@ +use crate::backend::{BackendEvent, PaneId}; +use crate::runtime::terminal::TerminalState; + +/// A state with a FakeBackend plus the shared handle for injecting +/// synthetic backend events into the next `poll` call. +pub(super) fn state_with_event_queue() -> ( + TerminalState, + std::rc::Rc>>, +) { + let backend = crate::test_util::FakeBackend::default(); + let events = backend.pending_events.clone(); + (TerminalState::new(Some(Box::new(backend)), false), events) +} + +/// A single 10x40 pane whose program has already emitted `modes`, with +/// the payloads recorded during setup discarded so a test sees only what +/// the scroll itself wrote. The pane's centre is therefore column 21, +/// row 6. +pub(super) fn state_with_pane_in_modes(modes: &[u8]) -> (TerminalState, PaneId) { + let (mut state, events) = state_with_event_queue(); + state.create_pane().unwrap(); + let id = state.panes[0].id; + state.resize_visible_panes(&[(id, 10, 40)]); + events.borrow_mut().push(BackendEvent::Output { + pane: id, + data: modes.to_vec(), + }); + state.poll(); + if let Some(backend) = &mut state.backend { + backend.send_input(id, b"").ok(); + } + (state, id) +} + +/// Plain shell output taller than the 10-row test pane, so lines actually +/// scroll off the top and land in the emulator's scrollback. Without +/// overflow there is no history and nothing to scroll into. +pub(super) fn shell_output_past_one_screen() -> Vec { + (0..20).fold(Vec::new(), |mut out, i| { + out.extend_from_slice(format!("line{i}\r\n").as_bytes()); + out + }) +} + +/// Payloads written to the PTY after `state_with_pane_in_modes` set up +/// the pane, i.e. everything past its trailing empty marker payload. +pub(super) fn payloads_after_setup(state: &TerminalState) -> Vec> { + let sent = state.fake_backend_sent().unwrap(); + let marker = sent.iter().rposition(|p| p.is_empty()).unwrap(); + sent[marker + 1..].to_vec() +} + +pub(super) fn state_with_fake() -> TerminalState { + let backend = Box::new(crate::test_util::FakeBackend::default()); + TerminalState::new(Some(backend), false) +} \ No newline at end of file diff --git a/src/runtime/terminal/tests/lifecycle_tests.rs b/src/runtime/terminal/tests/lifecycle_tests.rs new file mode 100644 index 0000000..4ae605f --- /dev/null +++ b/src/runtime/terminal/tests/lifecycle_tests.rs @@ -0,0 +1,186 @@ +use super::common::*; +use super::*; + +#[test] +fn create_pane_defaults_to_shell_label_and_no_command() { + let mut state = state_with_fake(); + state.create_pane().unwrap(); + assert_eq!(state.panes.len(), 1); + assert_eq!(state.panes[0].title, "shell 1"); +} + +#[test] +fn create_pane_with_label_sets_title() { + let mut state = state_with_fake(); + state + .create_pane_with(Some("claude --foo"), Some("Claude")) + .unwrap(); + assert_eq!(state.panes[0].title, "Claude"); +} + +#[test] +fn create_pane_with_falls_back_to_command_text() { + let mut state = state_with_fake(); + state.create_pane_with(Some("cargo test"), None).unwrap(); + assert_eq!(state.panes[0].title, "cargo test"); +} + +#[test] +fn create_pane_with_appends_and_focuses_new_pane() { + let mut state = state_with_fake(); + state.create_pane_with(Some("echo hi"), Some("E")).unwrap(); + state.create_pane().unwrap(); + assert_eq!(state.panes.len(), 2); + assert_eq!(state.panes[1].title, "shell 2"); + assert_eq!(state.active, 1); +} + +#[test] +fn swap_active_with_exchanges_panes_and_follows_focus() { + let mut state = state_with_fake(); + state.create_pane_with(None, Some("A")).unwrap(); + state.create_pane_with(None, Some("B")).unwrap(); + state.create_pane_with(None, Some("C")).unwrap(); + state.active = 0; // focus pane "A" + let a_id = state.panes[0].id; + let c_id = state.panes[2].id; + + assert!(state.swap_active_with(2)); + + // "A" and "C" exchanged slots; focus followed "A" to slot 2. + assert_eq!(state.panes[0].id, c_id); + assert_eq!(state.panes[2].id, a_id); + assert_eq!(state.panes[0].title, "C"); + assert_eq!(state.panes[2].title, "A"); + assert_eq!(state.active, 2); +} + +#[test] +fn swap_active_with_out_of_range_is_noop() { + let mut state = state_with_fake(); + state.create_pane_with(None, Some("A")).unwrap(); + state.create_pane_with(None, Some("B")).unwrap(); + state.active = 0; + + assert!(!state.swap_active_with(5)); + assert_eq!(state.active, 0); + assert_eq!(state.panes[0].title, "A"); + assert_eq!(state.panes[1].title, "B"); +} + +#[test] +fn swap_active_with_self_is_noop() { + let mut state = state_with_fake(); + state.create_pane_with(None, Some("A")).unwrap(); + state.create_pane_with(None, Some("B")).unwrap(); + state.active = 1; + + assert!(!state.swap_active_with(1)); + assert_eq!(state.active, 1); + assert_eq!(state.panes[1].title, "B"); +} + +#[test] +fn swap_active_with_preserves_per_pane_state() { + let mut state = state_with_fake(); + state.create_pane_with(None, Some("A")).unwrap(); + state.create_pane_with(None, Some("B")).unwrap(); + state.active = 0; + let a_id = state.panes[0].id; + // Seed scroll/size state keyed by the moving pane's id. + state.scroll.insert(a_id, 7); + state.last_content_size.insert(a_id, (10, 40)); + + assert!(state.swap_active_with(1)); + + // Per-pane state is id-keyed, so it survives the reorder unchanged. + assert_eq!(state.scroll.get(&a_id), Some(&7)); + assert_eq!(state.last_content_size.get(&a_id), Some(&(10, 40))); + assert_eq!(state.panes[1].id, a_id); +} + +#[test] +fn pane_size_falls_back_to_default_before_any_resize() { + let mut state = state_with_fake(); + state.create_pane().unwrap(); + let id = state.panes[0].id; + assert_eq!(state.pane_size(id), state.size); +} + +#[test] +fn resize_visible_panes_updates_parser_and_last_content_size() { + let mut state = state_with_fake(); + state.create_pane().unwrap(); + let id = state.panes[0].id; + + state.resize_visible_panes(&[(id, 12, 60)]); + + assert_eq!(state.screen_for_pane(id).unwrap().size(), (12, 60)); + assert_eq!(state.last_content_size.get(&id), Some(&(12, 60))); +} + +#[test] +fn resize_visible_panes_clamps_zero_to_minimum_grid() { + let mut state = state_with_fake(); + state.create_pane().unwrap(); + let id = state.panes[0].id; + + state.resize_visible_panes(&[(id, 0, 0)]); + + // The recorded size must match the emulator's minimum grid (1x2), + // not a raw 1x1 clamp — PTY, emulator, and bookkeeping stay in sync. + assert_eq!(state.last_content_size.get(&id), Some(&(1, 2))); + assert_eq!(state.screen_for_pane(id).unwrap().size(), (1, 2)); +} + +#[test] +fn resize_visible_panes_ignores_panes_not_listed() { + let mut state = state_with_fake(); + state.create_pane().unwrap(); + let hidden_id = state.panes[0].id; + let hidden_size_at_creation = state.pane_size(hidden_id); + state.create_pane().unwrap(); + let visible_id = state.panes[1].id; + + state.resize_visible_panes(&[(visible_id, 15, 70)]); + + // The hidden pane keeps whatever size it had before this call — it + // wasn't in the `layouts` list, so `resize_visible_panes` must not + // touch it. + assert_eq!( + state.last_content_size.get(&hidden_id), + Some(&hidden_size_at_creation) + ); + assert_eq!(state.last_content_size.get(&visible_id), Some(&(15, 70))); +} + +#[test] +fn new_pane_seeds_size_from_active_pane_last_content_size() { + let mut state = state_with_fake(); + state.create_pane().unwrap(); + let first_id = state.panes[0].id; + state.resize_visible_panes(&[(first_id, 18, 65)]); + + state.create_pane().unwrap(); + let second_id = state.panes[1].id; + + assert_eq!(state.screen_for_pane(second_id).unwrap().size(), (18, 65)); +} + +#[test] +fn screen_for_pane_none_for_unknown_id() { + let state = state_with_fake(); + assert!(state.screen_for_pane(999).is_none()); +} + +#[test] +fn closing_pane_drops_its_last_content_size() { + let mut state = state_with_fake(); + state.create_pane().unwrap(); + let id = state.panes[0].id; + state.resize_visible_panes(&[(id, 10, 40)]); + + state.close_active(); + + assert!(!state.last_content_size.contains_key(&id)); +} \ No newline at end of file diff --git a/src/runtime/terminal/tests/mod.rs b/src/runtime/terminal/tests/mod.rs new file mode 100644 index 0000000..7be92fe --- /dev/null +++ b/src/runtime/terminal/tests/mod.rs @@ -0,0 +1,7 @@ +use super::*; + +mod common; +mod lifecycle_tests; +mod poll_tests; +mod scroll_tests; +mod state_tests; \ No newline at end of file diff --git a/src/runtime/terminal/tests/poll_tests.rs b/src/runtime/terminal/tests/poll_tests.rs new file mode 100644 index 0000000..bc85329 --- /dev/null +++ b/src/runtime/terminal/tests/poll_tests.rs @@ -0,0 +1,84 @@ +use super::common::*; +use super::*; +use crate::backend::BackendEvent; + +#[test] +fn poll_applies_osc_title_to_pane() { + let (mut state, events) = state_with_event_queue(); + state.create_pane().unwrap(); + let id = state.panes[0].id; + + events.borrow_mut().push(BackendEvent::Output { + pane: id, + data: b"\x1b]2;claude\x07".to_vec(), + }); + state.poll(); + + assert_eq!(state.panes[0].title, "claude"); +} + +#[test] +fn poll_keeps_title_when_output_sets_none() { + let (mut state, events) = state_with_event_queue(); + state.create_pane_with(None, Some("shell")).unwrap(); + let id = state.panes[0].id; + + events.borrow_mut().push(BackendEvent::Output { + pane: id, + data: b"plain output\x1b]2;\x07".to_vec(), + }); + state.poll(); + + // Plain output (and an empty OSC title) must not clobber the label. + assert_eq!(state.panes[0].title, "shell"); +} + +#[test] +fn poll_forwards_terminal_query_reply_to_pty() { + let (mut state, events) = state_with_event_queue(); + state.create_pane().unwrap(); + let id = state.panes[0].id; + + // DSR 6 — the program asks for the cursor position; the emulator's + // reply must reach the backend PTY. + events.borrow_mut().push(BackendEvent::Output { + pane: id, + data: b"\x1b[6n".to_vec(), + }); + state.poll(); + + let sent = state.fake_backend_sent().unwrap(); + assert_eq!(sent, vec![b"\x1b[1;1R".to_vec()]); +} + +#[test] +fn consume_csi_skips_del_byte_per_ecma48() { + // ESC [ 3 1 DEL m sgr — the DEL must be ignored without terminating + // the sequence early. The trailing 'm' is the real final byte; the + // following "ok" should survive intact. + let out = strip_escape_sequences(b"\x1b[31\x7fmok"); + assert_eq!(out, "ok"); +} + +#[test] +fn strip_escape_sequences_preserves_newline_after_malformed_csi() { + // A CSI body interrupted by a control byte must leave that byte for + // the outer pass so prompt-buffer flush on `\n` still fires. + let out = strip_escape_sequences(b"\x1b[31\ndone\n"); + assert_eq!(out, "\ndone\n"); +} + +#[test] +fn later_title_replaces_earlier_within_one_poll() { + let (mut state, events) = state_with_event_queue(); + state.create_pane().unwrap(); + let id = state.panes[0].id; + + events.borrow_mut().push(BackendEvent::Output { + pane: id, + data: b"\x1b]2;first\x07\x1b]2;second\x07".to_vec(), + }); + state.poll(); + + assert_eq!(state.panes[0].title, "second"); +} \ No newline at end of file diff --git a/src/runtime/terminal/tests/scroll_tests.rs b/src/runtime/terminal/tests/scroll_tests.rs new file mode 100644 index 0000000..b05e58a --- /dev/null +++ b/src/runtime/terminal/tests/scroll_tests.rs @@ -0,0 +1,185 @@ +use super::common::*; +use super::*; +use crate::backend::BackendEvent; +use crossterm::event::MouseButton; + +#[test] +fn scroll_active_sends_wheel_notches_to_a_mouse_reporting_pane() { + // Claude Code's startup mode set. Six lines is two wheel notches. + let (mut state, _) = state_with_pane_in_modes(b"\x1b[?1000h\x1b[?1002h\x1b[?1006h"); + + state.scroll_active(true, 6); + + assert_eq!( + payloads_after_setup(&state), + vec![b"\x1b[<64;21;6M\x1b[<64;21;6M".to_vec()] + ); + assert!( + state.scroll.is_empty(), + "a wheel-driven pane must not move the emulator's own view" + ); +} + +#[test] +fn scroll_active_rounds_a_partial_notch_up() { + let (mut state, _) = state_with_pane_in_modes(b"\x1b[?1000h\x1b[?1006h"); + + // One line still has to move the pane; it must not round down to zero + // notches and silently do nothing. + state.scroll_active(false, 1); + + assert_eq!( + payloads_after_setup(&state), + vec![b"\x1b[<65;21;6M".to_vec()] + ); +} + +#[test] +fn scroll_active_sends_arrow_keys_on_the_alternate_screen() { + let (mut state, _) = state_with_pane_in_modes(b"\x1b[?1049h"); + + state.scroll_active(true, 3); + + assert_eq!( + payloads_after_setup(&state), + vec![b"\x1b[A\x1b[A\x1b[A".to_vec()] + ); +} + +#[test] +fn scroll_active_uses_application_arrow_keys_when_decckm_is_set() { + let (mut state, _) = state_with_pane_in_modes(b"\x1b[?1049h\x1b[?1h"); + + state.scroll_active(false, 2); + + assert_eq!(payloads_after_setup(&state), vec![b"\x1bOB\x1bOB".to_vec()]); +} + +#[test] +fn scroll_active_scrolls_the_emulator_for_a_plain_shell() { + let (mut state, id) = state_with_pane_in_modes(&shell_output_past_one_screen()); + + state.scroll_active(true, 3); + state.sync_scroll(); + + assert_eq!(state.scroll.get(&id).copied(), Some(3)); + assert!( + payloads_after_setup(&state).is_empty(), + "a shell echoes unbound escape sequences into its prompt, so the \ + scrollback branch must write nothing to the PTY" + ); +} + +#[test] +fn scroll_active_down_unwinds_the_emulator_offset_for_a_plain_shell() { + let (mut state, id) = state_with_pane_in_modes(&shell_output_past_one_screen()); + state.scroll_active(true, 3); + + state.scroll_active(false, 3); + + assert!(!state.scroll.contains_key(&id)); + assert!(payloads_after_setup(&state).is_empty()); +} + +#[test] +fn scroll_active_ignores_a_zero_line_request() { + let (mut state, _) = state_with_pane_in_modes(b"\x1b[?1000h\x1b[?1006h"); + + state.scroll_active(true, 0); + + assert!(payloads_after_setup(&state).is_empty()); +} + +#[test] +fn scroll_pane_moves_a_non_active_panes_view_immediately() { + let (mut state, events) = state_with_event_queue(); + state.create_pane().unwrap(); + state.create_pane().unwrap(); + let first = state.panes[0].id; + state.resize_visible_panes(&[(first, 10, 40)]); + events.borrow_mut().push(BackendEvent::Output { + pane: first, + data: shell_output_past_one_screen(), + }); + state.poll(); + assert_ne!( + state.active_pane_id(), + Some(first), + "test needs the scrolled pane to be non-active" + ); + + state.scroll_pane(first, true, 3, None); + + assert_eq!(state.scroll.get(&first).copied(), Some(3)); + assert_eq!( + state.emulators.get(&first).unwrap().scroll_offset(), + 3, + "the per-frame sync only reaches the active pane, so scroll_pane \ + must apply the offset itself" + ); +} + +#[test] +fn click_pane_forwards_sgr_press_and_release_to_a_mouse_reporting_pane() { + let (mut state, id) = state_with_pane_in_modes(b"\x1b[?1000h\x1b[?1002h\x1b[?1006h"); + + assert!(state.click_pane(id, MouseButton::Left, true, 5, 3)); + assert!(state.click_pane(id, MouseButton::Left, false, 5, 3)); + + assert_eq!( + payloads_after_setup(&state), + vec![b"\x1b[<0;5;3M".to_vec(), b"\x1b[<0;5;3m".to_vec()] + ); +} + +#[test] +fn click_pane_stays_silent_for_a_pane_that_never_claimed_the_mouse() { + let (mut state, id) = state_with_pane_in_modes(&shell_output_past_one_screen()); + + assert!(!state.click_pane(id, MouseButton::Left, true, 5, 3)); + assert!(!state.click_pane(id, MouseButton::Right, false, 5, 3)); + + assert!( + payloads_after_setup(&state).is_empty(), + "a shell echoes unbound escape sequences into its prompt, so an \ + unclaimed click must write nothing to the PTY" + ); +} + +#[test] +fn wheel_horizontal_pane_forwards_only_to_a_wheel_reporting_pane() { + let (mut state, id) = state_with_pane_in_modes(b"\x1b[?1000h\x1b[?1006h"); + + state.wheel_horizontal_pane(id, true, 5, 2); + state.wheel_horizontal_pane(id, false, 5, 2); + + assert_eq!( + payloads_after_setup(&state), + vec![b"\x1b[<66;5;2M".to_vec(), b"\x1b[<67;5;2M".to_vec()] + ); +} + +#[test] +fn wheel_horizontal_pane_stays_silent_for_a_plain_shell() { + let (mut state, id) = state_with_pane_in_modes(&shell_output_past_one_screen()); + + state.wheel_horizontal_pane(id, true, 5, 2); + + assert!( + payloads_after_setup(&state).is_empty(), + "horizontal wheel has no scrollback fallback, so an unclaimed \ + notch must write nothing to the PTY" + ); +} + +#[test] +fn scroll_pane_reports_the_pointer_cell_when_given_one() { + let (mut state, id) = state_with_pane_in_modes(b"\x1b[?1000h\x1b[?1006h"); + + state.scroll_pane(id, true, 3, Some((5, 2))); + + assert_eq!( + payloads_after_setup(&state), + vec![b"\x1b[<64;5;2M".to_vec()] + ); +} \ No newline at end of file diff --git a/src/runtime/terminal/tests/state_tests.rs b/src/runtime/terminal/tests/state_tests.rs new file mode 100644 index 0000000..6fde21a --- /dev/null +++ b/src/runtime/terminal/tests/state_tests.rs @@ -0,0 +1,104 @@ +use super::common::*; +use super::*; + +#[test] +fn max_visible_switches_with_fullscreen() { + let mut state = state_with_fake(); + state.max_visible_normal = 4; + state.max_visible_fullscreen = 7; + assert_eq!(state.max_visible(), 4); + state.fullscreen = TerminalFullscreen::Grid; + assert_eq!(state.max_visible(), 7); + state.fullscreen = TerminalFullscreen::Zoom; + assert_eq!(state.max_visible(), 1); +} + +#[test] +fn visible_range_shows_everything_under_the_cap() { + assert_eq!(visible_range(0, 0, 3, 4), 0..3); +} + +#[test] +fn visible_range_keeps_active_inside_a_capped_window() { + // 7 panes, window of 4, active is the last pane: window must end at 7. + assert_eq!(visible_range(0, 6, 7, 4), 3..7); +} + +#[test] +fn visible_range_moves_start_forward_only_as_far_as_needed() { + // Previously showing [2,6). Active moves to 6 (just past the window): + // start should shift by exactly 1, not jump to re-center. + assert_eq!(visible_range(2, 6, 7, 4), 3..7); +} + +#[test] +fn visible_range_moves_start_backward_when_active_precedes_window() { + // Previously showing [3,7). Active jumps back to 0. + assert_eq!(visible_range(3, 0, 7, 4), 0..4); +} + +#[test] +fn visible_range_empty_when_no_panes() { + assert_eq!(visible_range(0, 0, 0, 4), 0..0); +} + +#[test] +fn sync_visible_window_follows_active_when_panes_exceed_max_visible() { + let mut state = state_with_fake(); + state.max_visible_normal = 4; + for i in 0..7 { + state + .create_pane_with(None, Some(&format!("P{i}"))) + .unwrap(); + } + // Each create_pane_with call makes the new pane active and syncs the + // window, so after 7 panes the last one (index 6) must be visible. + assert_eq!(state.active, 6); + assert!(state.visible_start <= 6 && state.visible_start + 4 > 6); +} + +#[test] +fn sync_visible_window_clamps_after_pane_count_shrinks() { + let mut state = state_with_fake(); + state.max_visible_normal = 4; + for i in 0..7 { + state + .create_pane_with(None, Some(&format!("P{i}"))) + .unwrap(); + } + // Window is currently sliding near the end; drop back to a single + // pane and re-sync — start must fall back inside [0, 0]. + state.panes.truncate(1); + state.active = 0; + state.sync_visible_window(); + assert_eq!(state.visible_start, 0); +} + +#[test] +fn active_pane_rows_uses_pane_specific_size() { + let mut state = state_with_fake(); + state.create_pane().unwrap(); + let id = state.panes[0].id; + state.resize_visible_panes(&[(id, 33, 90)]); + assert_eq!(state.active_pane_rows(), 33); +} + +#[test] +fn resize_visible_panes_keeps_default_size_in_sync_with_active_pane() { + let mut state = state_with_fake(); + state.create_pane().unwrap(); + let first_id = state.panes[0].id; + state.create_pane().unwrap(); + let second_id = state.panes[1].id; + state.active = 1; + + state.resize_visible_panes(&[(first_id, 10, 40), (second_id, 12, 50)]); + + assert_eq!(state.size, (12, 50)); +} + +#[test] +fn active_pane_rows_falls_back_to_default_with_no_panes() { + let state = state_with_fake(); + assert_eq!(state.active_pane_rows(), state.size.0 as usize); +} \ No newline at end of file diff --git a/src/web/viewer/server.rs b/src/web/viewer/server/mod.rs similarity index 100% rename from src/web/viewer/server.rs rename to src/web/viewer/server/mod.rs From 40b18e4bbefca0ffee6cd1909cfe8866850ba8f2 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 25 Jul 2026 01:03:31 +0900 Subject: [PATCH 09/15] refactor: trim terminal mod.rs to struct/constants/re-exports --- src/runtime/terminal/mod.rs | 1183 +---------------------------------- 1 file changed, 11 insertions(+), 1172 deletions(-) diff --git a/src/runtime/terminal/mod.rs b/src/runtime/terminal/mod.rs index e94c302..8859f35 100644 --- a/src/runtime/terminal/mod.rs +++ b/src/runtime/terminal/mod.rs @@ -1,9 +1,14 @@ -use crate::backend::{BackendEvent, PaneId, TerminalBackend}; -use crate::input::{encode_arrow, encode_button, encode_wheel, encode_wheel_horizontal}; -use crate::runtime::emulator::{PaneEmulator, ScreenView, ScrollSink}; -use crossterm::event::MouseButton; +use crate::backend::{PaneId, TerminalBackend}; +use crate::runtime::emulator::PaneEmulator; use std::collections::HashMap; +mod escape; +mod lifecycle; +mod scroll; +mod state; + +pub(crate) use escape::strip_escape_sequences; + /// 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 @@ -112,474 +117,11 @@ pub struct TerminalState { pub max_visible_fullscreen: usize, pub(crate) emulators: HashMap, pub(crate) prompt_bufs: HashMap, - prompt_log_enabled: bool, + pub(super) prompt_log_enabled: bool, pub(crate) backend: Option>, } impl TerminalState { - pub fn active_pane_id(&self) -> Option { - self.panes.get(self.active).map(|p| p.id) - } - - /// Maximum number of panes shown at once in the current fullscreen state. - /// `Zoom` caps at 1 so the shared grid path renders only the active pane. - pub fn max_visible(&self) -> usize { - match self.fullscreen { - TerminalFullscreen::Off => self.max_visible_normal, - TerminalFullscreen::Grid => self.max_visible_fullscreen, - TerminalFullscreen::Zoom => 1, - } - } - - /// 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 - /// 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. - pub fn zoom_distinct_from_grid(&self) -> bool { - self.max_visible_fullscreen.min(self.panes.len()) > 1 - } - - /// Last known content size for `id`, falling back to the default pane - /// size for a pane that hasn't been through a layout resize yet. - pub fn pane_size(&self, id: PaneId) -> (u16, u16) { - self.last_content_size - .get(&id) - .copied() - .unwrap_or(self.size) - } - - /// 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. - pub fn active_pane_rows(&self) -> usize { - self.active_pane_id() - .map(|id| self.pane_size(id).0 as usize) - .unwrap_or(self.size.0 as usize) - } - - /// Re-clamp `visible_start` against the current active pane and pane - /// count. Must be called after anything that changes `active` or - /// `panes.len()` (focus jumps, pane create/close, session restore) so - /// the split-view window always contains the active pane. - pub fn sync_visible_window(&mut self) { - let range = visible_range( - self.visible_start, - self.active, - self.panes.len(), - self.max_visible(), - ); - self.visible_start = range.start; - } - - /// Scroll the active pane by `lines`. See `scroll_pane`. - pub fn scroll_active(&mut self, up: bool, lines: usize) { - let Some(id) = self.active_pane_id() else { - return; - }; - self.scroll_pane(id, up, lines, None); - } - - /// 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. - pub fn scroll_pane(&mut self, id: PaneId, up: bool, lines: usize, pointer: Option<(u16, u16)>) { - if lines == 0 { - return; - } - let Some(emulator) = self.emulators.get(&id) else { - return; - }; - let sink = emulator.scroll_sink(); - let app_cursor = emulator.app_cursor(); - - match sink { - 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. - let (col, row) = match pointer { - Some(cell) => cell, - None => { - let (rows, cols) = self.pane_size(id); - (cols / 2 + 1, rows / 2 + 1) - } - }; - let notch = encode_wheel(up, col, row); - let notches = lines.div_ceil(WHEEL_LINES_PER_NOTCH); - let payload = notch.repeat(notches); - self.write_pty(id, &payload); - } - ScrollSink::ArrowKeys => { - let payload = encode_arrow(up, app_cursor).repeat(lines); - self.write_pty(id, &payload); - } - ScrollSink::Scrollback => { - if up { - self.scroll_up(id, lines); - } else { - self.scroll_down(id, lines); - } - // A wheel event can target a non-active pane, which the - // per-frame `sync_scroll` (active pane only) never reaches — - // apply the offset here so the view moves immediately. - self.sync_scroll_pane(id); - } - } - } - - /// 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`). - pub fn wheel_horizontal_pane(&mut self, id: PaneId, left: bool, col: u16, row: u16) { - let Some(emulator) = self.emulators.get(&id) else { - return; - }; - if emulator.scroll_sink() != ScrollSink::MouseWheel { - return; - } - let payload = encode_wheel_horizontal(left, col, row); - self.write_pty(id, &payload); - } - - /// 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. - pub fn click_pane( - &mut self, - id: PaneId, - button: MouseButton, - press: bool, - col: u16, - row: u16, - ) -> bool { - let Some(emulator) = self.emulators.get(&id) else { - return false; - }; - if !emulator.wants_mouse_buttons() { - return false; - } - let payload = encode_button(button, press, col, row); - self.write_pty(id, &payload); - true - } - - /// Write straight to a pane's PTY. Bypasses `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. - fn write_pty(&mut self, id: PaneId, data: &[u8]) { - if let Some(backend) = &mut self.backend - && let Err(e) = backend.send_input(id, data) - { - tracing::warn!("failed to send synthesized scroll to pane {id}: {e}"); - } - } - - fn scroll_up(&mut self, id: PaneId, lines: usize) { - if lines == 0 { - return; - } - let offset = self.scroll.entry(id).or_insert(0); - *offset = offset.saturating_add(lines); - } - - fn scroll_down(&mut self, id: PaneId, lines: usize) { - if lines == 0 { - return; - } - if let Some(entry) = self.scroll.get_mut(&id) { - *entry = entry.saturating_sub(lines); - if *entry == 0 { - self.scroll.remove(&id); - } - } - } - - pub fn is_scrolled(&self) -> bool { - self.active_pane_id() - .and_then(|id| self.scroll.get(&id)) - .is_some_and(|&v| v > 0) - } - - pub fn sync_scroll(&mut self) { - let Some(id) = self.active_pane_id() else { - return; - }; - self.sync_scroll_pane(id); - } - - fn sync_scroll_pane(&mut self, id: PaneId) { - let offset = self.scroll.get(&id).copied().unwrap_or(0); - let actual = match self.emulators.get_mut(&id) { - // The emulator clamps the offset to the actual scrollback - // buffer size internally, so we can pass the full request - // through and read back what was applied. - Some(emulator) => emulator.set_scroll_offset(offset), - None => return, - }; - if actual == 0 { - self.scroll.remove(&id); - } else { - self.scroll.insert(id, actual); - } - } - - fn buffer_prompt_input(&mut self, pane_id: PaneId, data: &[u8]) { - let text = strip_escape_sequences(data); - let buf = self.prompt_bufs.entry(pane_id).or_default(); - for ch in text.chars() { - match ch { - '\r' | '\n' => { - if !buf.is_empty() { - tracing::info!(target: "prompt", pane = pane_id, text = %buf); - buf.clear(); - } - } - // 0x7f (DEL, sent by Backspace) and 0x08 (BS, sent by Ctrl+H) - // 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. - if buf.len() < PROMPT_BUFFER_MAX_BYTES { - buf.push(ch); - } - } - } - } - } - - /// 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. - 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.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)); - } - } - - /// Byte payloads recorded by an underlying `FakeBackend`, for tests that - /// assert exact PTY pass-through. `None` when the backend is not a - /// `FakeBackend` (e.g. production `PtyBackend` or no backend). - #[cfg(test)] - pub(crate) fn fake_backend_sent(&self) -> Option>> { - self.backend.as_ref().and_then(|b| b.test_sent_payloads()) - } - - pub fn send_input(&mut self, data: &[u8]) { - let Some(info) = self.panes.get(self.active) else { - return; - }; - let id = info.id; - self.scroll.remove(&id); - if let Some(backend) = &mut self.backend - && let Err(e) = backend.send_input(id, data) - { - tracing::warn!("failed to send terminal input to pane {id}: {e}"); - } - if self.prompt_log_enabled { - self.buffer_prompt_input(id, data); - } - } - - /// 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) - /// that depends on state outside this struct. - pub fn poll(&mut self) -> Vec { - let mut exited = Vec::new(); - let events: Vec = self - .backend - .as_mut() - .map(|b| b.drain_events()) - .unwrap_or_default(); - - for event in events { - match event { - BackendEvent::Output { pane, data } => { - let Some(emulator) = self.emulators.get_mut(&pane) else { - continue; - }; - let events = emulator.process(&data); - if let Some(title) = events.title - && let Some(info) = self.panes.iter_mut().find(|p| p.id == pane) - { - info.title = title; - } - // Terminal query responses (DA, DSR, ...) go back to the - // program that asked. Bypasses `send_input` on purpose: - // an emulator-generated reply must not clear the user's - // scroll position or land in the prompt log. - if !events.pty_writes.is_empty() - && let Some(backend) = &mut self.backend - && let Err(e) = backend.send_input(pane, &events.pty_writes) - { - tracing::warn!("failed to send terminal reply to pane {pane}: {e}"); - } - } - BackendEvent::Exited { pane } => { - // Single source of truth for pane removal: `drain_events` - // no longer touches the backend's pane map, so we drive - // the teardown here. `destroy_pane` is idempotent against - // a pane that `close_active` already removed. - if let Some(backend) = &mut self.backend { - backend.destroy_pane(pane); - } - self.remove_pane_state(pane); - self.panes.retain(|p| p.id != pane); - exited.push(pane); - } - } - } - exited - } - - /// Allocate a new bare interactive-shell pane. Thin wrapper over - /// `create_pane_with` for the common "open an empty terminal" path. - pub fn create_pane(&mut self) -> anyhow::Result<()> { - self.create_pane_with(None, None) - } - - /// 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`. The caller is expected to - /// surface any error to the user. - pub fn create_pane_with( - &mut self, - command: Option<&str>, - label: Option<&str>, - ) -> anyhow::Result<()> { - // Seed the new pane with the active pane's current content size so it - // starts roughly right-sized inside the split grid; the next frame's - // `resize_visible_panes` corrects it to the actual cell Rect once the - // pane count (and therefore the grid) has changed. - let (rows, cols) = self - .active_pane_id() - .map(|id| self.pane_size(id)) - .unwrap_or(self.size); - let (rows, cols) = crate::runtime::emulator::effective_size(rows, cols); - let backend = self - .backend - .as_mut() - .ok_or_else(|| anyhow::anyhow!("no terminal backend available"))?; - - let id = backend.create_pane(rows, cols, command)?; - self.emulators - .insert(id, PaneEmulator::new(rows, cols, SCROLLBACK_LINES)); - self.last_content_size.insert(id, (rows, cols)); - // Title precedence: explicit label → command text → default shell N. - let title = match (label, command) { - (Some(l), _) if !l.trim().is_empty() => l.trim().to_string(), - (_, Some(c)) if !c.trim().is_empty() => c.trim().to_string(), - _ => format!("shell {}", self.panes.len() + 1), - }; - self.panes.push(PaneInfo { id, title }); - self.active = self.panes.len() - 1; - self.sync_visible_window(); - tracing::info!(pane = id, "terminal pane opened"); - Ok(()) - } - - /// Remove the currently active pane. Returns `true` when a pane was - /// removed so the caller can re-clamp dependent state (focus, - /// fullscreen). Returns `false` for an empty list — a benign no-op - /// the caller can ignore. - pub fn close_active(&mut self) -> bool { - let Some(info) = self.panes.get(self.active) else { - return false; - }; - let id = info.id; - tracing::info!(pane = id, "terminal pane closed"); - if let Some(backend) = &mut self.backend { - backend.destroy_pane(id); - } - self.remove_pane_state(id); - self.panes.remove(self.active); - true - } - - /// Swap the active pane with the pane at `idx`, moving focus so it follows - /// the active pane to its new slot (`active` becomes `idx`). Returns `true` - /// when the swap happened, `false` for an out-of-range `idx` or a self-swap - /// (both benign no-ops). Only the ordered `panes` Vec changes — all - /// per-pane state (parsers, scroll, sizes, prompt buffers, backend) is keyed - /// by `PaneId`, so reordering leaves it untouched. - pub fn swap_active_with(&mut self, idx: usize) -> bool { - if idx >= self.panes.len() || idx == self.active { - return false; - } - self.panes.swap(self.active, idx); - self.active = idx; - self.sync_visible_window(); - true - } - - /// Screen for a specific pane, independent of which pane is currently - /// active — the split-view renderer draws every visible pane, not just - /// the focused one. - pub fn screen_for_pane(&self, id: PaneId) -> Option> { - self.emulators.get(&id).map(PaneEmulator::view) - } - - pub fn active_screen(&self) -> Option> { - let id = self.active_pane_id()?; - self.screen_for_pane(id) - } - - fn remove_pane_state(&mut self, id: PaneId) { - self.emulators.remove(&id); - // Flush any unterminated prompt input so we don't lose the line the - // user was composing when the pane closes. - if let Some(buf) = self.prompt_bufs.remove(&id) - && !buf.is_empty() - { - tracing::info!(target: "prompt", pane = id, text = %buf); - } - self.scroll.remove(&id); - self.last_content_size.remove(&id); - } - pub fn new(backend: Option>, prompt_log_enabled: bool) -> Self { Self { panes: Vec::new(), @@ -599,708 +141,5 @@ impl TerminalState { } } -pub(crate) fn strip_escape_sequences(data: &[u8]) -> String { - let text = String::from_utf8_lossy(data); - let mut result = String::new(); - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - match ch { - '\x1b' => consume_escape_sequence(&mut chars), - // \r, \n, and the line-editing controls (BS, DEL) are forwarded - // so `buffer_prompt_input` can flush on newlines and pop on - // backspace; every other control byte is dropped. - '\r' | '\n' | '\x08' | '\x7f' => result.push(ch), - c if !c.is_control() => result.push(c), - _ => {} - } - } - result -} - -/// Consume the body of an ESC-introduced control sequence. Called with the -/// leading ESC already taken; advances `chars` past the sequence's terminator -/// (or leaves the iterator alone for a bare ESC). -fn consume_escape_sequence(chars: &mut std::iter::Peekable>) { - match chars.peek().copied() { - Some('[') => { - chars.next(); - consume_csi(chars); - } - Some(']') => { - chars.next(); - consume_osc(chars); - } - Some('O') => { - chars.next(); - consume_ss3(chars); - } - Some('(') | Some(')') | Some('*') | Some('+') | Some('-') | Some('.') | Some('/') - | Some('#') => { - // Charset designators / DEC private 2-byte escapes: - // ESC . Skip both. - chars.next(); - chars.next(); - } - _ => { - // Drop the bare ESC and let the next iteration process whatever - // follows as ordinary input. Consuming an extra byte here would - // silently swallow user keystrokes that happened to land right - // after a stray Esc. - } - } -} - -/// 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. -fn consume_csi(chars: &mut std::iter::Peekable>) { - while let Some(&c) = chars.peek() { - if c < '\x20' { - return; - } - chars.next(); - if c == '\x7f' { - continue; - } - if ('\x40'..='\x7e').contains(&c) { - return; - } - } -} - -/// OSC: skip until BEL (0x07) or ST (ESC \). -fn consume_osc(chars: &mut std::iter::Peekable>) { - loop { - match chars.next() { - None | Some('\x07') => break, - Some('\x1b') if chars.peek() == Some(&'\\') => { - chars.next(); - break; - } - _ => {} - } - } -} - -/// 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. -fn consume_ss3(chars: &mut std::iter::Peekable>) { - if let Some(&next) = chars.peek() - && ('\x40'..='\x7e').contains(&next) - { - chars.next(); - } -} - #[cfg(test)] -mod tests { - use super::*; - - /// A state with a FakeBackend plus the shared handle for injecting - /// synthetic backend events into the next `poll` call. - fn state_with_event_queue() -> ( - TerminalState, - std::rc::Rc>>, - ) { - let backend = crate::test_util::FakeBackend::default(); - let events = backend.pending_events.clone(); - (TerminalState::new(Some(Box::new(backend)), false), events) - } - - /// A single 10x40 pane whose program has already emitted `modes`, with - /// the payloads recorded during setup discarded so a test sees only what - /// the scroll itself wrote. The pane's centre is therefore column 21, - /// row 6. - fn state_with_pane_in_modes(modes: &[u8]) -> (TerminalState, PaneId) { - let (mut state, events) = state_with_event_queue(); - state.create_pane().unwrap(); - let id = state.panes[0].id; - state.resize_visible_panes(&[(id, 10, 40)]); - events.borrow_mut().push(BackendEvent::Output { - pane: id, - data: modes.to_vec(), - }); - state.poll(); - if let Some(backend) = &mut state.backend { - backend.send_input(id, b"").ok(); - } - (state, id) - } - - /// Plain shell output taller than the 10-row test pane, so lines actually - /// scroll off the top and land in the emulator's scrollback. Without - /// overflow there is no history and nothing to scroll into. - fn shell_output_past_one_screen() -> Vec { - (0..20).fold(Vec::new(), |mut out, i| { - out.extend_from_slice(format!("line{i}\r\n").as_bytes()); - out - }) - } - - /// Payloads written to the PTY after `state_with_pane_in_modes` set up - /// the pane, i.e. everything past its trailing empty marker payload. - fn payloads_after_setup(state: &TerminalState) -> Vec> { - let sent = state.fake_backend_sent().unwrap(); - let marker = sent.iter().rposition(|p| p.is_empty()).unwrap(); - sent[marker + 1..].to_vec() - } - - #[test] - fn scroll_active_sends_wheel_notches_to_a_mouse_reporting_pane() { - // Claude Code's startup mode set. Six lines is two wheel notches. - let (mut state, _) = state_with_pane_in_modes(b"\x1b[?1000h\x1b[?1002h\x1b[?1006h"); - - state.scroll_active(true, 6); - - assert_eq!( - payloads_after_setup(&state), - vec![b"\x1b[<64;21;6M\x1b[<64;21;6M".to_vec()] - ); - assert!( - state.scroll.is_empty(), - "a wheel-driven pane must not move the emulator's own view" - ); - } - - #[test] - fn scroll_active_rounds_a_partial_notch_up() { - let (mut state, _) = state_with_pane_in_modes(b"\x1b[?1000h\x1b[?1006h"); - - // One line still has to move the pane; it must not round down to zero - // notches and silently do nothing. - state.scroll_active(false, 1); - - assert_eq!( - payloads_after_setup(&state), - vec![b"\x1b[<65;21;6M".to_vec()] - ); - } - - #[test] - fn scroll_active_sends_arrow_keys_on_the_alternate_screen() { - let (mut state, _) = state_with_pane_in_modes(b"\x1b[?1049h"); - - state.scroll_active(true, 3); - - assert_eq!( - payloads_after_setup(&state), - vec![b"\x1b[A\x1b[A\x1b[A".to_vec()] - ); - } - - #[test] - fn scroll_active_uses_application_arrow_keys_when_decckm_is_set() { - let (mut state, _) = state_with_pane_in_modes(b"\x1b[?1049h\x1b[?1h"); - - state.scroll_active(false, 2); - - assert_eq!(payloads_after_setup(&state), vec![b"\x1bOB\x1bOB".to_vec()]); - } - - #[test] - fn scroll_active_scrolls_the_emulator_for_a_plain_shell() { - let (mut state, id) = state_with_pane_in_modes(&shell_output_past_one_screen()); - - state.scroll_active(true, 3); - state.sync_scroll(); - - assert_eq!(state.scroll.get(&id).copied(), Some(3)); - assert!( - payloads_after_setup(&state).is_empty(), - "a shell echoes unbound escape sequences into its prompt, so the \ - scrollback branch must write nothing to the PTY" - ); - } - - #[test] - fn scroll_active_down_unwinds_the_emulator_offset_for_a_plain_shell() { - let (mut state, id) = state_with_pane_in_modes(&shell_output_past_one_screen()); - state.scroll_active(true, 3); - - state.scroll_active(false, 3); - - assert!(!state.scroll.contains_key(&id)); - assert!(payloads_after_setup(&state).is_empty()); - } - - #[test] - fn scroll_active_ignores_a_zero_line_request() { - let (mut state, _) = state_with_pane_in_modes(b"\x1b[?1000h\x1b[?1006h"); - - state.scroll_active(true, 0); - - assert!(payloads_after_setup(&state).is_empty()); - } - - #[test] - fn scroll_pane_moves_a_non_active_panes_view_immediately() { - let (mut state, events) = state_with_event_queue(); - state.create_pane().unwrap(); - state.create_pane().unwrap(); - let first = state.panes[0].id; - state.resize_visible_panes(&[(first, 10, 40)]); - events.borrow_mut().push(BackendEvent::Output { - pane: first, - data: shell_output_past_one_screen(), - }); - state.poll(); - assert_ne!( - state.active_pane_id(), - Some(first), - "test needs the scrolled pane to be non-active" - ); - - state.scroll_pane(first, true, 3, None); - - assert_eq!(state.scroll.get(&first).copied(), Some(3)); - assert_eq!( - state.emulators.get(&first).unwrap().scroll_offset(), - 3, - "the per-frame sync only reaches the active pane, so scroll_pane \ - must apply the offset itself" - ); - } - - #[test] - fn click_pane_forwards_sgr_press_and_release_to_a_mouse_reporting_pane() { - let (mut state, id) = state_with_pane_in_modes(b"\x1b[?1000h\x1b[?1002h\x1b[?1006h"); - - assert!(state.click_pane(id, MouseButton::Left, true, 5, 3)); - assert!(state.click_pane(id, MouseButton::Left, false, 5, 3)); - - assert_eq!( - payloads_after_setup(&state), - vec![b"\x1b[<0;5;3M".to_vec(), b"\x1b[<0;5;3m".to_vec()] - ); - } - - #[test] - fn click_pane_stays_silent_for_a_pane_that_never_claimed_the_mouse() { - let (mut state, id) = state_with_pane_in_modes(&shell_output_past_one_screen()); - - assert!(!state.click_pane(id, MouseButton::Left, true, 5, 3)); - assert!(!state.click_pane(id, MouseButton::Right, false, 5, 3)); - - assert!( - payloads_after_setup(&state).is_empty(), - "a shell echoes unbound escape sequences into its prompt, so an \ - unclaimed click must write nothing to the PTY" - ); - } - - #[test] - fn wheel_horizontal_pane_forwards_only_to_a_wheel_reporting_pane() { - let (mut state, id) = state_with_pane_in_modes(b"\x1b[?1000h\x1b[?1006h"); - - state.wheel_horizontal_pane(id, true, 5, 2); - state.wheel_horizontal_pane(id, false, 5, 2); - - assert_eq!( - payloads_after_setup(&state), - vec![b"\x1b[<66;5;2M".to_vec(), b"\x1b[<67;5;2M".to_vec()] - ); - } - - #[test] - fn wheel_horizontal_pane_stays_silent_for_a_plain_shell() { - let (mut state, id) = state_with_pane_in_modes(&shell_output_past_one_screen()); - - state.wheel_horizontal_pane(id, true, 5, 2); - - assert!( - payloads_after_setup(&state).is_empty(), - "horizontal wheel has no scrollback fallback, so an unclaimed \ - notch must write nothing to the PTY" - ); - } - - #[test] - fn scroll_pane_reports_the_pointer_cell_when_given_one() { - let (mut state, id) = state_with_pane_in_modes(b"\x1b[?1000h\x1b[?1006h"); - - state.scroll_pane(id, true, 3, Some((5, 2))); - - assert_eq!( - payloads_after_setup(&state), - vec![b"\x1b[<64;5;2M".to_vec()] - ); - } - - #[test] - fn poll_applies_osc_title_to_pane() { - let (mut state, events) = state_with_event_queue(); - state.create_pane().unwrap(); - let id = state.panes[0].id; - - events.borrow_mut().push(BackendEvent::Output { - pane: id, - data: b"\x1b]2;claude\x07".to_vec(), - }); - state.poll(); - - assert_eq!(state.panes[0].title, "claude"); - } - - #[test] - fn poll_keeps_title_when_output_sets_none() { - let (mut state, events) = state_with_event_queue(); - state.create_pane_with(None, Some("shell")).unwrap(); - let id = state.panes[0].id; - - events.borrow_mut().push(BackendEvent::Output { - pane: id, - data: b"plain output\x1b]2;\x07".to_vec(), - }); - state.poll(); - - // Plain output (and an empty OSC title) must not clobber the label. - assert_eq!(state.panes[0].title, "shell"); - } - - #[test] - fn poll_forwards_terminal_query_reply_to_pty() { - let (mut state, events) = state_with_event_queue(); - state.create_pane().unwrap(); - let id = state.panes[0].id; - - // DSR 6 — the program asks for the cursor position; the emulator's - // reply must reach the backend PTY. - events.borrow_mut().push(BackendEvent::Output { - pane: id, - data: b"\x1b[6n".to_vec(), - }); - state.poll(); - - let sent = state.fake_backend_sent().unwrap(); - assert_eq!(sent, vec![b"\x1b[1;1R".to_vec()]); - } - - #[test] - fn consume_csi_skips_del_byte_per_ecma48() { - // ESC [ 3 1 DEL m sgr — the DEL must be ignored without terminating - // the sequence early. The trailing 'm' is the real final byte; the - // following "ok" should survive intact. - let out = strip_escape_sequences(b"\x1b[31\x7fmok"); - assert_eq!(out, "ok"); - } - - #[test] - fn strip_escape_sequences_preserves_newline_after_malformed_csi() { - // A CSI body interrupted by a control byte must leave that byte for - // the outer pass so prompt-buffer flush on `\n` still fires. - let out = strip_escape_sequences(b"\x1b[31\ndone\n"); - assert_eq!(out, "\ndone\n"); - } - - #[test] - fn later_title_replaces_earlier_within_one_poll() { - let (mut state, events) = state_with_event_queue(); - state.create_pane().unwrap(); - let id = state.panes[0].id; - - events.borrow_mut().push(BackendEvent::Output { - pane: id, - data: b"\x1b]2;first\x07\x1b]2;second\x07".to_vec(), - }); - state.poll(); - - assert_eq!(state.panes[0].title, "second"); - } - - fn state_with_fake() -> TerminalState { - let backend = Box::new(crate::test_util::FakeBackend::default()); - TerminalState::new(Some(backend), false) - } - - #[test] - fn create_pane_defaults_to_shell_label_and_no_command() { - let mut state = state_with_fake(); - state.create_pane().unwrap(); - assert_eq!(state.panes.len(), 1); - assert_eq!(state.panes[0].title, "shell 1"); - } - - #[test] - fn create_pane_with_label_sets_title() { - let mut state = state_with_fake(); - state - .create_pane_with(Some("claude --foo"), Some("Claude")) - .unwrap(); - assert_eq!(state.panes[0].title, "Claude"); - } - - #[test] - fn create_pane_with_falls_back_to_command_text() { - let mut state = state_with_fake(); - state.create_pane_with(Some("cargo test"), None).unwrap(); - assert_eq!(state.panes[0].title, "cargo test"); - } - - #[test] - fn create_pane_with_appends_and_focuses_new_pane() { - let mut state = state_with_fake(); - state.create_pane_with(Some("echo hi"), Some("E")).unwrap(); - state.create_pane().unwrap(); - assert_eq!(state.panes.len(), 2); - assert_eq!(state.panes[1].title, "shell 2"); - assert_eq!(state.active, 1); - } - - #[test] - fn swap_active_with_exchanges_panes_and_follows_focus() { - let mut state = state_with_fake(); - state.create_pane_with(None, Some("A")).unwrap(); - state.create_pane_with(None, Some("B")).unwrap(); - state.create_pane_with(None, Some("C")).unwrap(); - state.active = 0; // focus pane "A" - let a_id = state.panes[0].id; - let c_id = state.panes[2].id; - - assert!(state.swap_active_with(2)); - - // "A" and "C" exchanged slots; focus followed "A" to slot 2. - assert_eq!(state.panes[0].id, c_id); - assert_eq!(state.panes[2].id, a_id); - assert_eq!(state.panes[0].title, "C"); - assert_eq!(state.panes[2].title, "A"); - assert_eq!(state.active, 2); - } - - #[test] - fn swap_active_with_out_of_range_is_noop() { - let mut state = state_with_fake(); - state.create_pane_with(None, Some("A")).unwrap(); - state.create_pane_with(None, Some("B")).unwrap(); - state.active = 0; - - assert!(!state.swap_active_with(5)); - assert_eq!(state.active, 0); - assert_eq!(state.panes[0].title, "A"); - assert_eq!(state.panes[1].title, "B"); - } - - #[test] - fn swap_active_with_self_is_noop() { - let mut state = state_with_fake(); - state.create_pane_with(None, Some("A")).unwrap(); - state.create_pane_with(None, Some("B")).unwrap(); - state.active = 1; - - assert!(!state.swap_active_with(1)); - assert_eq!(state.active, 1); - assert_eq!(state.panes[1].title, "B"); - } - - #[test] - fn swap_active_with_preserves_per_pane_state() { - let mut state = state_with_fake(); - state.create_pane_with(None, Some("A")).unwrap(); - state.create_pane_with(None, Some("B")).unwrap(); - state.active = 0; - let a_id = state.panes[0].id; - // Seed scroll/size state keyed by the moving pane's id. - state.scroll.insert(a_id, 7); - state.last_content_size.insert(a_id, (10, 40)); - - assert!(state.swap_active_with(1)); - - // Per-pane state is id-keyed, so it survives the reorder unchanged. - assert_eq!(state.scroll.get(&a_id), Some(&7)); - assert_eq!(state.last_content_size.get(&a_id), Some(&(10, 40))); - assert_eq!(state.panes[1].id, a_id); - } - - #[test] - fn pane_size_falls_back_to_default_before_any_resize() { - let mut state = state_with_fake(); - state.create_pane().unwrap(); - let id = state.panes[0].id; - assert_eq!(state.pane_size(id), state.size); - } - - #[test] - fn resize_visible_panes_updates_parser_and_last_content_size() { - let mut state = state_with_fake(); - state.create_pane().unwrap(); - let id = state.panes[0].id; - - state.resize_visible_panes(&[(id, 12, 60)]); - - assert_eq!(state.screen_for_pane(id).unwrap().size(), (12, 60)); - assert_eq!(state.last_content_size.get(&id), Some(&(12, 60))); - } - - #[test] - fn resize_visible_panes_clamps_zero_to_minimum_grid() { - let mut state = state_with_fake(); - state.create_pane().unwrap(); - let id = state.panes[0].id; - - state.resize_visible_panes(&[(id, 0, 0)]); - - // The recorded size must match the emulator's minimum grid (1x2), - // not a raw 1x1 clamp — PTY, emulator, and bookkeeping stay in sync. - assert_eq!(state.last_content_size.get(&id), Some(&(1, 2))); - assert_eq!(state.screen_for_pane(id).unwrap().size(), (1, 2)); - } - - #[test] - fn resize_visible_panes_ignores_panes_not_listed() { - let mut state = state_with_fake(); - state.create_pane().unwrap(); - let hidden_id = state.panes[0].id; - let hidden_size_at_creation = state.pane_size(hidden_id); - state.create_pane().unwrap(); - let visible_id = state.panes[1].id; - - state.resize_visible_panes(&[(visible_id, 15, 70)]); - - // The hidden pane keeps whatever size it had before this call — it - // wasn't in the `layouts` list, so `resize_visible_panes` must not - // touch it. - assert_eq!( - state.last_content_size.get(&hidden_id), - Some(&hidden_size_at_creation) - ); - assert_eq!(state.last_content_size.get(&visible_id), Some(&(15, 70))); - } - - #[test] - fn new_pane_seeds_size_from_active_pane_last_content_size() { - let mut state = state_with_fake(); - state.create_pane().unwrap(); - let first_id = state.panes[0].id; - state.resize_visible_panes(&[(first_id, 18, 65)]); - - state.create_pane().unwrap(); - let second_id = state.panes[1].id; - - assert_eq!(state.screen_for_pane(second_id).unwrap().size(), (18, 65)); - } - - #[test] - fn screen_for_pane_none_for_unknown_id() { - let state = state_with_fake(); - assert!(state.screen_for_pane(999).is_none()); - } - - #[test] - fn closing_pane_drops_its_last_content_size() { - let mut state = state_with_fake(); - state.create_pane().unwrap(); - let id = state.panes[0].id; - state.resize_visible_panes(&[(id, 10, 40)]); - - state.close_active(); - - assert!(!state.last_content_size.contains_key(&id)); - } - - #[test] - fn max_visible_switches_with_fullscreen() { - let mut state = state_with_fake(); - state.max_visible_normal = 4; - state.max_visible_fullscreen = 7; - assert_eq!(state.max_visible(), 4); - state.fullscreen = TerminalFullscreen::Grid; - assert_eq!(state.max_visible(), 7); - state.fullscreen = TerminalFullscreen::Zoom; - assert_eq!(state.max_visible(), 1); - } - - #[test] - fn visible_range_shows_everything_under_the_cap() { - assert_eq!(visible_range(0, 0, 3, 4), 0..3); - } - - #[test] - fn visible_range_keeps_active_inside_a_capped_window() { - // 7 panes, window of 4, active is the last pane: window must end at 7. - assert_eq!(visible_range(0, 6, 7, 4), 3..7); - } - - #[test] - fn visible_range_moves_start_forward_only_as_far_as_needed() { - // Previously showing [2,6). Active moves to 6 (just past the window): - // start should shift by exactly 1, not jump to re-center. - assert_eq!(visible_range(2, 6, 7, 4), 3..7); - } - - #[test] - fn visible_range_moves_start_backward_when_active_precedes_window() { - // Previously showing [3,7). Active jumps back to 0. - assert_eq!(visible_range(3, 0, 7, 4), 0..4); - } - - #[test] - fn visible_range_empty_when_no_panes() { - assert_eq!(visible_range(0, 0, 0, 4), 0..0); - } - - #[test] - fn sync_visible_window_follows_active_when_panes_exceed_max_visible() { - let mut state = state_with_fake(); - state.max_visible_normal = 4; - for i in 0..7 { - state - .create_pane_with(None, Some(&format!("P{i}"))) - .unwrap(); - } - // Each create_pane_with call makes the new pane active and syncs the - // window, so after 7 panes the last one (index 6) must be visible. - assert_eq!(state.active, 6); - assert!(state.visible_start <= 6 && state.visible_start + 4 > 6); - } - - #[test] - fn sync_visible_window_clamps_after_pane_count_shrinks() { - let mut state = state_with_fake(); - state.max_visible_normal = 4; - for i in 0..7 { - state - .create_pane_with(None, Some(&format!("P{i}"))) - .unwrap(); - } - // Window is currently sliding near the end; drop back to a single - // pane and re-sync — start must fall back inside [0, 0]. - state.panes.truncate(1); - state.active = 0; - state.sync_visible_window(); - assert_eq!(state.visible_start, 0); - } - - #[test] - fn active_pane_rows_uses_pane_specific_size() { - let mut state = state_with_fake(); - state.create_pane().unwrap(); - let id = state.panes[0].id; - state.resize_visible_panes(&[(id, 33, 90)]); - assert_eq!(state.active_pane_rows(), 33); - } - - #[test] - fn resize_visible_panes_keeps_default_size_in_sync_with_active_pane() { - let mut state = state_with_fake(); - state.create_pane().unwrap(); - let first_id = state.panes[0].id; - state.create_pane().unwrap(); - let second_id = state.panes[1].id; - state.active = 1; - - state.resize_visible_panes(&[(first_id, 10, 40), (second_id, 12, 50)]); - - assert_eq!(state.size, (12, 50)); - } - - #[test] - fn active_pane_rows_falls_back_to_default_with_no_panes() { - let state = state_with_fake(); - assert_eq!(state.active_pane_rows(), state.size.0 as usize); - } -} +mod tests; \ No newline at end of file From 242b76cc0127fa9e2955e48804724e84f7238146 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 25 Jul 2026 01:11:49 +0900 Subject: [PATCH 10/15] fix: restore Dimensions import in emulator view and KeyEvent/MouseEvent in protocol mod --- src/runtime/emulator/mod.rs | 2 +- src/runtime/emulator/view.rs | 1 + src/web/protocol/mod.rs | 1 + src/web/server/http_routes.rs | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/runtime/emulator/mod.rs b/src/runtime/emulator/mod.rs index ba9ea95..c10a8b3 100644 --- a/src/runtime/emulator/mod.rs +++ b/src/runtime/emulator/mod.rs @@ -12,7 +12,7 @@ use std::cell::RefCell; use std::rc::Rc; use alacritty_terminal::event::{Event, EventListener}; -use alacritty_terminal::grid::{Dimensions, Scroll}; +use alacritty_terminal::grid::Scroll; use alacritty_terminal::term::test::TermSize; use alacritty_terminal::term::{Config, MIN_COLUMNS, MIN_SCREEN_LINES, Term, TermMode}; use alacritty_terminal::vte::ansi::Processor; diff --git a/src/runtime/emulator/view.rs b/src/runtime/emulator/view.rs index 913ab11..d388858 100644 --- a/src/runtime/emulator/view.rs +++ b/src/runtime/emulator/view.rs @@ -1,4 +1,5 @@ use super::EventProxy; +use alacritty_terminal::grid::Dimensions; use alacritty_terminal::index::{Column, Line, Point}; use alacritty_terminal::term::cell::{Cell, Flags}; use alacritty_terminal::term::{Term, TermMode}; diff --git a/src/web/protocol/mod.rs b/src/web/protocol/mod.rs index e79bff5..6ab4563 100644 --- a/src/web/protocol/mod.rs +++ b/src/web/protocol/mod.rs @@ -13,6 +13,7 @@ //! local input — a web action can never diverge from the equivalent keypress. use anyhow::Result; +use crossterm::event::{KeyEvent, MouseEvent}; use ratatui::backend::{Backend, CrosstermBackend}; use ratatui::buffer::Buffer; use ratatui::layout::Position; diff --git a/src/web/server/http_routes.rs b/src/web/server/http_routes.rs index eeb880d..5172bfe 100644 --- a/src/web/server/http_routes.rs +++ b/src/web/server/http_routes.rs @@ -10,7 +10,7 @@ use std::time::Instant; use super::accept::Shared; use super::ws::serve_websocket; -fn handle_connection(mut stream: TcpStream, shared: Arc) { +pub(super) fn handle_connection(mut stream: TcpStream, shared: Arc) { let (head, body) = match conn::read_request(&mut stream) { Ok(v) => v, Err(err) => { From b69ea58f0b5fd21543d43487ffc2ef47bf21a629 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 25 Jul 2026 06:11:20 +0900 Subject: [PATCH 11/15] refactor: enforce 300-LOC limit across the codebase Split every source file exceeding 300 lines into focused submodules and move inline #[cfg(test)] blocks into dedicated test files. The LOC rule (200 = code smell, 300 = hard cap) is recorded in .claude/rules/guardrails.md. Module splits (non-test logic, tests moved to sibling files): - main.rs -> cli, event_loop, key_dispatch, key_handlers, mouse, paste, splash, app_init, main_tests/ - app.rs -> app/app_impl, app/log_nav, app/scroll, app/tree_nav, app/file_view_load, app/commit_log_pagination, app/commit_log_apply - config.rs -> config/{layout,log,panels,web} - git/diff.rs -> git/diff/{types,snapshot,diff_load,commit_log} - git/{path,tree}.rs -> git/{path,tree}/ directories - runtime/terminal.rs -> runtime/terminal/{state,scroll,lifecycle,escape} - runtime/emulator.rs -> runtime/emulator/{mod,view} - input/mod.rs -> input/{routing,encode} - web/server.rs -> web/server/{accept,http_routes,ws} - web/protocol.rs -> web/protocol/{mod,decode} - web/viewer/server.rs -> web/viewer/server/{dispatch,mutations,routes,handlers,http_util} - web/viewer/{dto,terminal,catalog,runtime}.rs -> directories - ui/mod.rs -> ui/{helpers,chrome,notice,hint_text,hint_bar,hit_test} - ui/{diff_pane,diff_viewer,terminal_tab,project_tab,log_view,tree_view}.rs -> directories Also: CLAUDE.md -> AGENTS.md (symlink kept for compatibility), docs/architecture.md module tree updated to match the new layout. --- AGENTS.md | 17 + docs/architecture.md | 113 +- src/app.rs | 3568 +---------------- src/app/app_impl.rs | 192 + src/app/commit_log_apply.rs | 155 + src/app/commit_log_fetch.rs | 214 +- src/app/commit_log_pagination.rs | 68 + src/app/diff_load.rs | 176 +- src/app/file_view_load.rs | 172 + src/app/log_nav.rs | 294 ++ src/app/navigation.rs | 375 +- src/app/scroll.rs | 83 + src/app/tests/auto_follow.rs | 225 ++ src/app/tests/clamp_pane.rs | 33 + src/app/tests/commit_log.rs | 270 ++ src/app/tests/diff_file_view.rs | 131 + src/app/tests/fullscreen.rs | 278 ++ src/app/tests/head_change.rs | 257 ++ src/app/tests/helpers.rs | 115 + src/app/tests/leader_notice.rs | 55 + src/app/tests/log_drill.rs | 22 + src/app/tests/log_search.rs | 132 + src/app/tests/mod.rs | 46 + src/app/tests/mode_toggle.rs | 68 + src/app/tests/pane.rs | 121 + src/app/tests/scroll_misc.rs | 100 + src/app/tests/session_restore.rs | 295 ++ src/app/tests/snapshot.rs | 111 + src/app/tests/snapshot_refresh.rs | 158 + src/app/tests/status_diff.rs | 115 + src/app/tests/strip_escape.rs | 44 + src/app/tests/terminal_init.rs | 70 + src/app/tests/terminal_scrollback.rs | 50 + src/app/tests/tree.rs | 271 ++ src/app/tests/tree_session.rs | 275 ++ src/app/tests/tree_watcher.rs | 154 + src/app/tree.rs | 244 +- src/app/tree_nav.rs | 241 ++ src/app_init.rs | 35 + src/backend/pty.rs | 92 +- src/backend/pty_tests.rs | 88 + src/cli.rs | 285 ++ src/config.rs | 1363 +------ src/config/layout.rs | 174 + src/config/log.rs | 76 + src/config/panels.rs | 75 + src/config/tests/config_core.rs | 117 + src/config/tests/input.rs | 79 + src/config/tests/log.rs | 146 + src/config/tests/mod.rs | 8 + src/config/tests/panels.rs | 45 + src/config/tests/startup.rs | 81 + src/config/tests/theme.rs | 43 + src/config/tests/tree.rs | 44 + src/config/tests/web.rs | 238 ++ src/config/web.rs | 227 ++ src/event_loop.rs | 284 ++ src/git/diff.rs | 1552 +------ src/git/diff/commit_log.rs | 122 + src/git/diff/diff_load.rs | 267 ++ src/git/diff/snapshot.rs | 193 + src/git/diff/tests/commit_log.rs | 206 + src/git/diff/tests/diff_load.rs | 276 ++ src/git/diff/tests/mod.rs | 3 + src/git/diff/tests/snapshot.rs | 269 ++ src/git/diff/types.rs | 220 + src/git/path.rs | 307 -- src/git/path/mod.rs | 116 + src/git/path/tests.rs | 191 + src/git/tree.rs | 459 --- src/git/tree/mod.rs | 184 + src/git/tree/tests.rs | 275 ++ src/key_dispatch.rs | 300 ++ src/key_handlers.rs | 288 ++ src/logging.rs | 156 +- src/logging_tests.rs | 152 + src/main.rs | 3328 +-------------- src/main_tests/helpers.rs | 121 + src/main_tests/mod.rs | 12 + src/main_tests/mouse.rs | 234 ++ src/main_tests/mouse_clicks.rs | 251 ++ src/main_tests/mouse_empty.rs | 203 + src/main_tests/mouse_release.rs | 126 + src/main_tests/paste.rs | 87 + src/main_tests/prefix.rs | 283 ++ src/main_tests/prefix_digits.rs | 113 + src/main_tests/search.rs | 96 + src/main_tests/swap.rs | 114 + src/main_tests/terminal.rs | 38 + src/main_tests/workspace.rs | 118 + src/mouse.rs | 267 ++ src/paste.rs | 90 + src/runtime/terminal/tests/lifecycle_tests.rs | 1 - src/runtime/terminal/tests/scroll_tests.rs | 1 - src/runtime/tree_watch.rs | 94 +- src/runtime/tree_watch_tests.rs | 90 + src/splash.rs | 50 + src/ui/chrome.rs | 42 + src/ui/diff_pane.rs | 783 ---- src/ui/diff_pane/highlight.rs | 45 + src/ui/diff_pane/mod.rs | 81 + src/ui/diff_pane/pane_impl.rs | 298 ++ src/ui/diff_pane/search.rs | 110 + src/ui/diff_pane/split.rs | 34 + src/ui/diff_pane/tests/mod.rs | 227 ++ src/ui/diff_viewer.rs | 543 --- src/ui/diff_viewer/file_view.rs | 145 + src/ui/diff_viewer/mod.rs | 266 ++ src/ui/diff_viewer/split_view.rs | 156 + src/ui/helpers.rs | 103 + src/ui/hint_bar.rs | 249 ++ src/ui/hint_text.rs | 170 + src/ui/hit_test.rs | 102 + src/ui/{log_view.rs => log_view/mod.rs} | 195 +- src/ui/log_view/tests.rs | 192 + src/ui/mod.rs | 2005 +-------- src/ui/notice.rs | 52 + src/ui/{project_tab.rs => project_tab/mod.rs} | 185 +- src/ui/project_tab/tests.rs | 181 + src/ui/terminal_tab.rs | 987 ----- src/ui/terminal_tab/cells.rs | 72 + src/ui/terminal_tab/layout.rs | 124 + src/ui/terminal_tab/mod.rs | 108 + src/ui/terminal_tab/screen.rs | 118 + src/ui/terminal_tab/tab_bar.rs | 147 + src/ui/terminal_tab/tests/layout_tests.rs | 156 + src/ui/terminal_tab/tests/mod.rs | 32 + src/ui/terminal_tab/tests/render_tests.rs | 136 + src/ui/terminal_tab/tests/tab_tests.rs | 177 + src/ui/tests/chrome_tests.rs | 185 + src/ui/tests/common.rs | 157 + src/ui/tests/hint_armed_tests.rs | 177 + src/ui/tests/hint_click_tests.rs | 197 + src/ui/tests/hint_diff_tests.rs | 100 + src/ui/tests/hint_legend_tests.rs | 135 + src/ui/tests/hit_test_tests.rs | 123 + src/ui/tests/mod.rs | 8 + src/ui/tests/notice_tests.rs | 81 + src/ui/{tree_view.rs => tree_view/mod.rs} | 207 +- src/ui/tree_view/tests.rs | 204 + src/web/common/http.rs | 119 +- src/web/common/http_tests.rs | 115 + src/web/protocol/mod.rs | 5 +- src/web/viewer/catalog/catalog_ids.rs | 56 + src/web/viewer/catalog/catalog_tests.rs | 224 ++ src/web/viewer/{catalog.rs => catalog/mod.rs} | 286 +- src/web/viewer/dto/diff.rs | 117 + src/web/viewer/dto/envelope.rs | 86 + src/web/viewer/dto/log.rs | 82 + src/web/viewer/dto/mod.rs | 945 +---- src/web/viewer/dto/status.rs | 156 + src/web/viewer/dto/tests/fixture.rs | 190 + src/web/viewer/dto/tests/mod.rs | 252 ++ src/web/viewer/dto/tree.rs | 64 + src/web/viewer/{runtime.rs => runtime/mod.rs} | 217 +- src/web/viewer/runtime/runtime_tests.rs | 214 + src/web/viewer/server/dispatch.rs | 170 + src/web/viewer/server/handlers.rs | 235 ++ src/web/viewer/server/http_util.rs | 28 + src/web/viewer/server/mod.rs | 2097 +--------- src/web/viewer/server/mutations.rs | 209 + src/web/viewer/server/routes.rs | 254 ++ src/web/viewer/server/tests/auth.rs | 254 ++ src/web/viewer/server/tests/commit_routes.rs | 144 + src/web/viewer/server/tests/mod.rs | 272 ++ src/web/viewer/server/tests/prefs.rs | 210 + src/web/viewer/server/tests/routes.rs | 230 ++ src/web/viewer/server/tests/terminals.rs | 129 + src/web/viewer/terminal/frame.rs | 60 + src/web/viewer/terminal/hub_helpers.rs | 89 + src/web/viewer/terminal/hub_run.rs | 203 + src/web/viewer/terminal/mod.rs | 774 +--- src/web/viewer/terminal/session.rs | 63 + src/web/viewer/terminal/tests/behavior.rs | 213 + src/web/viewer/terminal/tests/mod.rs | 167 + 175 files changed, 21676 insertions(+), 21109 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/app/app_impl.rs create mode 100644 src/app/commit_log_apply.rs create mode 100644 src/app/commit_log_pagination.rs create mode 100644 src/app/file_view_load.rs create mode 100644 src/app/log_nav.rs create mode 100644 src/app/scroll.rs create mode 100644 src/app/tests/auto_follow.rs create mode 100644 src/app/tests/clamp_pane.rs create mode 100644 src/app/tests/commit_log.rs create mode 100644 src/app/tests/diff_file_view.rs create mode 100644 src/app/tests/fullscreen.rs create mode 100644 src/app/tests/head_change.rs create mode 100644 src/app/tests/helpers.rs create mode 100644 src/app/tests/leader_notice.rs create mode 100644 src/app/tests/log_drill.rs create mode 100644 src/app/tests/log_search.rs create mode 100644 src/app/tests/mod.rs create mode 100644 src/app/tests/mode_toggle.rs create mode 100644 src/app/tests/pane.rs create mode 100644 src/app/tests/scroll_misc.rs create mode 100644 src/app/tests/session_restore.rs create mode 100644 src/app/tests/snapshot.rs create mode 100644 src/app/tests/snapshot_refresh.rs create mode 100644 src/app/tests/status_diff.rs create mode 100644 src/app/tests/strip_escape.rs create mode 100644 src/app/tests/terminal_init.rs create mode 100644 src/app/tests/terminal_scrollback.rs create mode 100644 src/app/tests/tree.rs create mode 100644 src/app/tests/tree_session.rs create mode 100644 src/app/tests/tree_watcher.rs create mode 100644 src/app/tree_nav.rs create mode 100644 src/app_init.rs create mode 100644 src/backend/pty_tests.rs create mode 100644 src/cli.rs create mode 100644 src/config/layout.rs create mode 100644 src/config/log.rs create mode 100644 src/config/panels.rs create mode 100644 src/config/tests/config_core.rs create mode 100644 src/config/tests/input.rs create mode 100644 src/config/tests/log.rs create mode 100644 src/config/tests/mod.rs create mode 100644 src/config/tests/panels.rs create mode 100644 src/config/tests/startup.rs create mode 100644 src/config/tests/theme.rs create mode 100644 src/config/tests/tree.rs create mode 100644 src/config/tests/web.rs create mode 100644 src/config/web.rs create mode 100644 src/event_loop.rs create mode 100644 src/git/diff/commit_log.rs create mode 100644 src/git/diff/diff_load.rs create mode 100644 src/git/diff/snapshot.rs create mode 100644 src/git/diff/tests/commit_log.rs create mode 100644 src/git/diff/tests/diff_load.rs create mode 100644 src/git/diff/tests/mod.rs create mode 100644 src/git/diff/tests/snapshot.rs create mode 100644 src/git/diff/types.rs delete mode 100644 src/git/path.rs create mode 100644 src/git/path/mod.rs create mode 100644 src/git/path/tests.rs delete mode 100644 src/git/tree.rs create mode 100644 src/git/tree/mod.rs create mode 100644 src/git/tree/tests.rs create mode 100644 src/key_dispatch.rs create mode 100644 src/key_handlers.rs create mode 100644 src/logging_tests.rs create mode 100644 src/main_tests/helpers.rs create mode 100644 src/main_tests/mod.rs create mode 100644 src/main_tests/mouse.rs create mode 100644 src/main_tests/mouse_clicks.rs create mode 100644 src/main_tests/mouse_empty.rs create mode 100644 src/main_tests/mouse_release.rs create mode 100644 src/main_tests/paste.rs create mode 100644 src/main_tests/prefix.rs create mode 100644 src/main_tests/prefix_digits.rs create mode 100644 src/main_tests/search.rs create mode 100644 src/main_tests/swap.rs create mode 100644 src/main_tests/terminal.rs create mode 100644 src/main_tests/workspace.rs create mode 100644 src/mouse.rs create mode 100644 src/paste.rs create mode 100644 src/runtime/tree_watch_tests.rs create mode 100644 src/splash.rs create mode 100644 src/ui/chrome.rs delete mode 100644 src/ui/diff_pane.rs create mode 100644 src/ui/diff_pane/highlight.rs create mode 100644 src/ui/diff_pane/mod.rs create mode 100644 src/ui/diff_pane/pane_impl.rs create mode 100644 src/ui/diff_pane/search.rs create mode 100644 src/ui/diff_pane/split.rs create mode 100644 src/ui/diff_pane/tests/mod.rs delete mode 100644 src/ui/diff_viewer.rs create mode 100644 src/ui/diff_viewer/file_view.rs create mode 100644 src/ui/diff_viewer/mod.rs create mode 100644 src/ui/diff_viewer/split_view.rs create mode 100644 src/ui/helpers.rs create mode 100644 src/ui/hint_bar.rs create mode 100644 src/ui/hint_text.rs create mode 100644 src/ui/hit_test.rs rename src/ui/{log_view.rs => log_view/mod.rs} (59%) create mode 100644 src/ui/log_view/tests.rs create mode 100644 src/ui/notice.rs rename src/ui/{project_tab.rs => project_tab/mod.rs} (55%) create mode 100644 src/ui/project_tab/tests.rs delete mode 100644 src/ui/terminal_tab.rs create mode 100644 src/ui/terminal_tab/cells.rs create mode 100644 src/ui/terminal_tab/layout.rs create mode 100644 src/ui/terminal_tab/mod.rs create mode 100644 src/ui/terminal_tab/screen.rs create mode 100644 src/ui/terminal_tab/tab_bar.rs create mode 100644 src/ui/terminal_tab/tests/layout_tests.rs create mode 100644 src/ui/terminal_tab/tests/mod.rs create mode 100644 src/ui/terminal_tab/tests/render_tests.rs create mode 100644 src/ui/terminal_tab/tests/tab_tests.rs create mode 100644 src/ui/tests/chrome_tests.rs create mode 100644 src/ui/tests/common.rs create mode 100644 src/ui/tests/hint_armed_tests.rs create mode 100644 src/ui/tests/hint_click_tests.rs create mode 100644 src/ui/tests/hint_diff_tests.rs create mode 100644 src/ui/tests/hint_legend_tests.rs create mode 100644 src/ui/tests/hit_test_tests.rs create mode 100644 src/ui/tests/mod.rs create mode 100644 src/ui/tests/notice_tests.rs rename src/ui/{tree_view.rs => tree_view/mod.rs} (59%) create mode 100644 src/ui/tree_view/tests.rs create mode 100644 src/web/common/http_tests.rs create mode 100644 src/web/viewer/catalog/catalog_ids.rs create mode 100644 src/web/viewer/catalog/catalog_tests.rs rename src/web/viewer/{catalog.rs => catalog/mod.rs} (50%) create mode 100644 src/web/viewer/dto/diff.rs create mode 100644 src/web/viewer/dto/envelope.rs create mode 100644 src/web/viewer/dto/log.rs create mode 100644 src/web/viewer/dto/status.rs create mode 100644 src/web/viewer/dto/tests/fixture.rs create mode 100644 src/web/viewer/dto/tests/mod.rs create mode 100644 src/web/viewer/dto/tree.rs rename src/web/viewer/{runtime.rs => runtime/mod.rs} (58%) create mode 100644 src/web/viewer/runtime/runtime_tests.rs create mode 100644 src/web/viewer/server/dispatch.rs create mode 100644 src/web/viewer/server/handlers.rs create mode 100644 src/web/viewer/server/http_util.rs create mode 100644 src/web/viewer/server/mutations.rs create mode 100644 src/web/viewer/server/routes.rs create mode 100644 src/web/viewer/server/tests/auth.rs create mode 100644 src/web/viewer/server/tests/commit_routes.rs create mode 100644 src/web/viewer/server/tests/mod.rs create mode 100644 src/web/viewer/server/tests/prefs.rs create mode 100644 src/web/viewer/server/tests/routes.rs create mode 100644 src/web/viewer/server/tests/terminals.rs create mode 100644 src/web/viewer/terminal/frame.rs create mode 100644 src/web/viewer/terminal/hub_helpers.rs create mode 100644 src/web/viewer/terminal/hub_run.rs create mode 100644 src/web/viewer/terminal/session.rs create mode 100644 src/web/viewer/terminal/tests/behavior.rs create mode 100644 src/web/viewer/terminal/tests/mod.rs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..45f37e7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# nightcrow + +Agent-adjacent Rust TUI: 상단은 git diff/commit log 뷰어, 하단은 split-view 멀티 터미널 패널. +자세한 설계는 `docs/architecture.md`, 사용법은 `README.md`를 참고한다. + +## 구조 + +- `.claude/rules/` — 개발 규칙 (코드 품질, 테스트, 보안, 커밋, 문서, 의존성, 토큰 절약). 모든 작업에 항상 적용된다. +- `.claude/skills/` — `/plan`, `/self-review`, `/security-review` + +## 개발 흐름 + +1. **Plan** — 변경이 단순하지 않으면(3개 이상 파일 수정 또는 설계 판단 필요) `/plan`으로 요구사항/대안/구현 계획을 정리하고 사용자와 정렬한 뒤 구현한다. 단순한 버그 수정·설정 변경은 바로 구현한다. +2. **Implement** — `docs/architecture.md`의 설계 제약을 따른다. 구현이 문서와 어긋나면 문서를 먼저 갱신하거나 구현을 조정한다. +3. **Verify** — `cargo build`, `cargo test`, `cargo clippy --all-targets --all-features -- -D warnings`가 통과해야 한다. `.githooks/pre-commit`(`git config core.hooksPath .githooks`로 활성화)이 커밋 전 동일 게이트를 실행한다. +4. **Review** — 구현 완료 후 `/self-review`로 자체 점검하고, 인증/보안/공개 API 등 민감한 변경이면 `/security-review`도 실행한다. 즉시 반영 항목은 코드에 반영하고, 사용자 판단이 필요한 항목은 보고한다. +5. **Commit** — `commits.md`의 단위/메시지 규칙을 따른다. push는 사용자가 직접 결정한다. \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index c49dc0c..8d0cea0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,62 +51,109 @@ below for the layout and resize rules. ## Module Structure +모든 소스 파일은 300줄 이하(LOC 규칙, `.claude/rules/guardrails.md` 참고). 테스트는 +`#[cfg(test)] mod tests;`로 별도 파일에 분리한다. + ``` src/ -├── main.rs # CLI args, entry point, panic-safe TerminalGuard -├── app.rs # App struct + integration tests; impl blocks split into app/ +├── main.rs # entry point, TerminalGuard, run() +├── app_init.rs # single-project App construction + startup commands +├── cli.rs # Cli/Commands, run_init/run_serve, web surface bootstrap +├── event_loop.rs # main_loop: poll/render/broadcast/input drain +├── key_dispatch.rs # handle_key, prefix follow-up, global action, KeyOutcome +├── key_handlers.rs # ViewMode-specific key handlers (upper/terminal/overlay) +├── mouse.rs # mouse dispatch, click/scroll/swap-target routing +├── paste.rs # paste dispatch (terminal/search/dialog) +├── splash.rs # first-run splash overlay loop +├── logging.rs # tracing-based file logger (rotation + retention) +├── session.rs # workspace + per-repo state (~/.nightcrow/workspace.json) +├── util.rs # shared low-level helpers (try_timed_join) +├── test_util.rs # #[cfg(test)] git fixture helpers shared across modules +├── app.rs # App struct + type defs (NoticeKind/ViewMode/Focus/AutoFollow) ├── app/ +│ ├── app_impl.rs # App core methods: new, notice, prefix/swap state │ ├── auto_follow.rs # idle-driven jump to freshest hot file │ ├── commit_log_fetch.rs # background commit-log page fetcher (worker thread + poll) -│ ├── diff_load.rs # diff + file-view loaders, apply_diff_result, refresh_diff +│ ├── commit_log_pagination.rs # CommitLogPagination struct + Drop +│ ├── commit_log_apply.rs # apply_tail_page, apply_refresh_page +│ ├── diff_load.rs # diff loaders, apply_diff_result, refresh_diff +│ ├── file_view_load.rs # file-view loaders, toggle, commit diff loading │ ├── focus.rs # focus jumps, cycling, fullscreen toggles -│ ├── navigation.rs # selection, j/k, filtered status, log drill-in/out +│ ├── navigation.rs # status-mode selection, j/k, filtered status +│ ├── log_nav.rs # log-mode search, drill-in/out, cursor movement +│ ├── scroll.rs # upper-panel horizontal scroll helpers │ ├── session_io.rs # save/restore session state │ ├── snapshot_io.rs # poll_snapshot: drain SnapshotChannel, detect HEAD change │ ├── terminal_ctrl.rs # poll_terminal, open/close/swap pane, scroll, fullscreen -│ └── tree.rs # tree-navigator App methods: lazy expand, filename search, watcher wiring -├── config.rs # config.toml parsing (layout, theme, log, agent_indicator, -│ # input leader, mouse, tree, startup_command) + init template -├── logging.rs # tracing-based file logger (rotation + retention) -├── session.rs # workspace + per-repo state (~/.nightcrow/workspace.json) -├── util.rs # shared low-level helpers (try_timed_join) -├── test_util.rs # #[cfg(test)] git fixture helpers shared across modules +│ ├── tree.rs # tree-navigator: mode entry, cache, watcher wiring +│ ├── tree_nav.rs # tree cursor, expand/collapse, search +│ └── tests/ # integration tests split by feature area +├── config.rs # config.toml root: Config, load/validate/init, pub use re-exports +├── config/ +│ ├── layout.rs # LayoutConfig, ThemeConfig, Accent, InputConfig, parse_leader +│ ├── log.rs # LogConfig, LogRotation, LogLevel +│ ├── panels.rs # AgentIndicatorConfig, TreeConfig, MouseConfig +│ ├── web.rs # WebMirrorConfig, WebViewerConfig, password bootstrap +│ └── tests/ # config tests split by section ├── workspace/ │ ├── mod.rs # Workspace: open projects (Vec) + active index, │ │ # process-level repo dialog/notice -│ └── repo_input.rs # o repo-input modal state +│ ├── repo_input.rs # o repo-input modal state +│ └── tests/ # workspace + repo_input tests ├── runtime/ │ ├── mod.rs │ ├── snapshot.rs # SnapshotChannel: background git status/log worker -│ ├── emulator.rs # PaneEmulator/ScreenView: alacritty_terminal wrapper -│ ├── terminal.rs # TerminalState (panes, emulators, scroll, title routing) -│ └── tree_watch.rs # notify-based watcher for expanded tree directories +│ ├── tree_watch.rs # notify-based watcher for expanded tree directories +│ ├── emulator/ +│ │ ├── mod.rs # PaneEmulator: alacritty_terminal wrapper, ScrollSink +│ │ └── view.rs # ScreenView/CellView: grid read access, color mapping +│ └── terminal/ +│ ├── mod.rs # TerminalState struct, constants, PaneInfo, TerminalFullscreen +│ ├── state.rs # accessors: active_pane_id, max_visible, sync_visible_window +│ ├── scroll.rs # scroll_active, scroll_pane, click_pane, sync_scroll +│ ├── lifecycle.rs # poll, create/close/swap pane, resize, send_input +│ └── escape.rs # strip_escape_sequences + consume_* helpers ├── ui/ -│ ├── mod.rs # root layout (upper/lower split + notice row + hint bar, -│ │ # mouse hit-testing: pane_at/tab_click_at/hint_click_at) +│ ├── mod.rs # root layout: draw, draw_empty, pub use re-exports +│ ├── chrome.rs # ChromeRows, chrome_rows, Chrome, main_content_constraints +│ ├── helpers.rs # shared widget/style helpers (status_color, char_offset, etc.) +│ ├── notice.rs # notice row + repo header rendering +│ ├── hint_text.rs # hint literal constants, normal_hint_literal, prefix_armed_hint_text +│ ├── hint_bar.rs # hint bar render, segment_click, HintClick, hint_click_at +│ ├── hit_test.rs # pane_at, tab_click_at, upper_panel_at, terminal_content_areas │ ├── status_view.rs # status-mode state (file filter, search query/cache) │ ├── log_view.rs # log-mode state (commits, drill-down, file selection) │ ├── tree_view.rs # tree-mode state (child cache, expanded set, search index) │ ├── file_list.rs # upper-left: changed files with hot-stage coloring │ ├── commit_list.rs # upper-left (log view): commit list with ahead marker │ ├── tree_list.rs # upper-left (tree view): indented directory-tree rows -│ ├── diff_pane.rs # DiffPane: hunks, scroll, search, file_view sub-state -│ ├── diff_viewer.rs # upper-right: diff widget; toggleable file preview │ ├── file_view.rs # full-file preview state (content, scroll, syntect cache) │ ├── search.rs # SearchQuery newtype (query + lowercased form in lockstep) -│ ├── terminal_tab.rs # lower: terminal pane grid + tab bar widget -│ └── splash.rs # first-run splash overlay +│ ├── splash.rs # first-run splash overlay +│ ├── diff_pane/ # DiffPane: hunks, scroll, search, file_view sub-state +│ ├── diff_viewer/ # upper-right: diff widget; toggleable file preview +│ ├── terminal_tab/ # lower: terminal pane grid + tab bar widget +│ ├── project_tab/ # project tab row rendering + click targets +│ └── tests/ # ui integration tests (chrome, hint, hit-test, notice) ├── backend/ │ ├── mod.rs # TerminalBackend trait + BackendEvent │ └── pty.rs # PtyBackend (portable-pty, the only backend) ├── git/ │ ├── mod.rs -│ ├── diff.rs # git2 snapshot/diff loaders + tracking status -│ ├── path.rs # repo-relative path validation before any filesystem read -│ └── tree.rs # lazy read-only directory listing (gitignore filter, symlink guard) +│ ├── diff.rs # module root: pub use re-exports, MAX_FILE_VIEW_BYTES +│ ├── diff/ +│ │ ├── types.rs # StatusKind, ChangedFile, DiffHunk, CommitEntry, RepoSnapshot +│ │ ├── snapshot.rs # load_snapshot, status_columns, path extraction +│ │ ├── diff_load.rs # load_file_diff, load_commit_diff, collect_hunks +│ │ └── commit_log.rs # load_commit_log, load_commit_log_from, head_commit_oid +│ ├── path/ +│ │ └── mod.rs # repo-relative path validation before any filesystem read +│ └── tree/ +│ └── mod.rs # lazy read-only directory listing (gitignore filter, symlink guard) ├── input/ -│ └── mod.rs # keyboard routing: map_key (no-prefix reserved keys), -│ # prefix_action (leader follow-up dispatch), encode_key, vim-style j/k +│ ├── mod.rs # Action enum, pub use re-exports +│ ├── routing.rs # map_key, prefix_action, prefix_action_fullscreen, vim j/k +│ └── encode.rs # encode_key, encode_wheel/button/arrow, CSI/SS3 helpers └── web/ # optional browser mirror ([web_mirror] enabled) — see "Web Mirror" ├── mod.rs # module root ├── common/ # server-agnostic primitives (no frames, git, or terminals) @@ -117,16 +164,16 @@ src/ │ └── conn.rs # ConnectionSlot: accept-loop connection accounting ├── viewer/ # native web viewer ([web_viewer] / `serve`) — see "Web Viewer" │ ├── limits.rs # ceilings: log page, tree entries, diff bytes/lines, PTYs - │ ├── dto.rs # whitelisted wire types + PROTOCOL_VERSION envelope - │ ├── catalog.rs # opaque repo ids, atomic swap, per-repo entries - │ ├── runtime.rs # per-repo thread: SnapshotChannel drain + conflated SSE fan-out - │ ├── terminal.rs # per-repo TerminalHub owning its own PtyBackend + │ ├── dto/ # whitelisted wire types + PROTOCOL_VERSION envelope + │ ├── catalog/ # opaque repo ids, atomic swap, per-repo entries + │ ├── runtime/ # per-repo thread: SnapshotChannel drain + conflated SSE fan-out + │ ├── terminal/ # per-repo TerminalHub owning its own PtyBackend │ ├── highlight.rs # syntect/two-face highlight spans for diff + file payloads │ ├── prefs.rs # ~/.nightcrow/viewer.json: accent + sidebar width shared across devices - │ ├── server.rs # HTTP routes, SSE, /ws/term + │ ├── server/ # HTTP routes, SSE, /ws/term │ └── assets.rs # rust-embed of viewer-ui/dist + CSP - ├── protocol.rs # Buffer→ANSI frame encode, JSON→crossterm input decode - ├── server.rs # sync accept/connection threads, broadcast, WS upgrade + ├── protocol/ # Buffer→ANSI frame encode, JSON→crossterm input decode + ├── server/ # sync accept/connection threads, broadcast, WS upgrade ├── frontend.rs # embedded page assets └── frontend/ # login.html, app.html, vendor/xterm.{js,css} ``` diff --git a/src/app.rs b/src/app.rs index 61f66bf..5904a13 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,16 +1,22 @@ -use crate::backend::{PtyBackend, TerminalBackend}; use crate::git::diff::{ChangedFile, RepoSnapshot, TrackingStatus}; mod auto_follow; +mod app_impl; +mod commit_log_apply; mod commit_log_fetch; +mod commit_log_pagination; mod diff_load; +mod file_view_load; mod focus; +mod log_nav; mod navigation; +mod scroll; mod session_io; mod snapshot_io; mod terminal_ctrl; mod tree; +mod tree_nav; -pub use crate::app::commit_log_fetch::CommitLogPagination; +pub use crate::app::commit_log_pagination::CommitLogPagination; pub use crate::runtime::snapshot::{SnapshotChannel, SnapshotMsg}; #[cfg(test)] pub use crate::runtime::terminal::PaneInfo; @@ -23,12 +29,8 @@ pub use crate::ui::log_view::LogView; pub use crate::ui::status_view::StatusView; pub use crate::ui::tree_view::TreeView; use crossterm::event::{KeyEvent, KeyModifiers}; -#[cfg(test)] -pub(crate) use diff_load::DiffApply; use std::time::Instant; -#[cfg(test)] -use crate::runtime::terminal::SCROLLBACK_LINES; pub(crate) const LIST_PAGE_SIZE: usize = 10; pub(crate) const DIFF_PAGE_SIZE: usize = 20; @@ -52,21 +54,6 @@ pub enum NoticeKind { Project, } -impl NoticeKind { - /// Prefix shown before the message, or `None` when the message already - /// reads on its own (a repo-input rejection, a session-restore note, or a - /// refused project action names its own subject). - pub fn label(self) -> Option<&'static str> { - match self { - Self::Git => Some("git error"), - Self::Diff => Some("diff error"), - Self::Terminal => Some("terminal error"), - Self::Tree => Some("tree error"), - Self::Session | Self::RepoInput | Self::Project => None, - } - } -} - /// A message shown in the chrome's notice row until its kind expires. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Notice { @@ -86,23 +73,6 @@ pub fn leader_label_of(leader: KeyEvent) -> String { } } -impl Notice { - pub fn new(kind: NoticeKind, text: impl Into) -> Self { - Self { - kind, - text: text.into(), - } - } - - /// The single line to render, label included when the kind carries one. - pub fn line(&self) -> String { - match self.kind.label() { - Some(label) => format!("{label}: {}", self.text), - None => self.text.clone(), - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)] pub enum ViewMode { #[default] @@ -236,3525 +206,5 @@ pub struct App { pub mouse_enabled: bool, } -impl App { - /// Raise a notice, replacing whatever was showing. The chrome has one - /// notice row, so the newest problem wins. - pub fn raise_notice(&mut self, kind: NoticeKind, text: impl Into) { - self.notice = Some(Notice::new(kind, text)); - } - - /// Drop the current notice if it was raised by `kind`. Called from the - /// success path of each subsystem, so a resolved problem stops being - /// reported without clobbering an unrelated one that arrived since. - pub fn clear_notice(&mut self, kind: NoticeKind) { - if self.notice.as_ref().is_some_and(|n| n.kind == kind) { - self.notice = None; - } - } - - /// Drop the current notice because the user acted on the app itself. - /// - /// Deliberately *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 — the same - /// effectively-invisible failure this row exists to prevent. - /// Deliver a pending press's release to the pane that saw it, at the cell - /// the press landed on. - /// - /// 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 instead would leave that program in a drag or selection - /// state with no release ever coming. - pub fn release_pending_press_in_place(&mut self) { - if let Some((id, button, col, row)) = self.pending_mouse_press.take() { - self.terminal.click_pane(id, button, false, col, row); - } - } - - pub fn dismiss_notice_on_app_input(&mut self) { - self.notice = None; - } - - pub fn new( - repo_path: String, - prompt_log: bool, - startup_commands: &[crate::config::StartupCommand], - leader: KeyEvent, - ) -> Self { - let snapshot = SnapshotChannel::spawn(&repo_path); - - let backend: Box = Box::new(PtyBackend::new(&repo_path)); - - let mut app = App { - mode: ViewMode::Status, - status_view: StatusView::default(), - diff: DiffPane::default(), - focus: Focus::FileList, - notice: None, - repo_path, - log_view: LogView::default(), - tree_view: TreeView::default(), - terminal: TerminalState::new(Some(backend), prompt_log), - accent_idx: 0, - tracking: None, - snapshot, - pending_snapshot: None, - // Start disabled; `main` upgrades to a live watcher after the parsed - // `[tree] live_watch` config is applied, so a `false` setting never - // spawns an OS watcher. - tree_watch: crate::runtime::tree_watch::TreeWatcher::disabled(), - tree_dirty: Default::default(), - tree_dirty_all: false, - pending_selection: None, - repo_cache: None, - cfg_agent_indicator: crate::config::AgentIndicatorConfig::default(), - cfg_tree: crate::config::TreeConfig::default(), - pagination: CommitLogPagination::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, - leader, - prefix_armed: false, - awaiting_swap_target: false, - pending_mouse_press: None, - mouse_enabled: true, - }; - - app.ensure_initial_terminal(startup_commands); - tracing::info!(repo = %app.repo_path, "nightcrow started"); - app - } - - /// True while the leader has been pressed and we await the follow-up key. - /// Drives the hint bar's `PREFIX` indicator. - pub fn prefix_armed(&self) -> bool { - self.prefix_armed - } - - /// Arm the prefix: the next key will be interpreted as an app command. - pub fn arm_prefix(&mut self) { - self.prefix_armed = true; - } - - /// Disarm the prefix, returning to normal pass-through routing. - pub fn cancel_prefix(&mut self) { - self.prefix_armed = false; - } - - /// Whether one of this project's search bars owns input right now. The - /// repo dialog is process-level, so the full modal test lives on - /// `Workspace::overlay_active`; both feed the key and mouse handlers so a - /// click can never reach behind a modal that swallows keystrokes. - pub fn search_overlay_active(&self) -> bool { - self.status_view.search_active - || self.tree_view.search_active - || self.diff.search.active - || self.log_view.commit_search_active - || self.log_view.file_search_active - } - - /// True while ` s` armed pane-swap mode and we await the target - /// digit. Drives the hint bar's `SWAP` indicator. - pub fn awaiting_swap_target(&self) -> bool { - self.awaiting_swap_target - } - - /// Arm pane-swap mode: the next digit picks the pane to swap with the - /// active pane. Clears the prefix so the two follow-up states never overlap. - pub fn begin_swap_target(&mut self) { - self.prefix_armed = false; - self.awaiting_swap_target = true; - } - - /// Disarm pane-swap mode without acting. - pub fn cancel_swap_target(&mut self) { - self.awaiting_swap_target = false; - } - - /// Caret-notation label for the configured leader chord, e.g. `^F` for - /// `Ctrl+F`. Leaders are always ctrl chords (see `config::parse_leader`), - /// so the control character maps cleanly to `^`; any non-ctrl key - /// falls back to printing its raw character. - pub fn leader_label(&self) -> String { - leader_label_of(self.leader) - } - - /// True when `key` matches the configured leader chord. Any modifier beyond - /// the leader's own (Alt, Shift, Super, Hyper, Meta — enhanced keyboard - /// protocols report the latter three) makes it a different chord that passes - /// straight through to the PTY instead of being swallowed, so we compare the - /// full modifier set exactly. - pub fn is_leader_key(&self, key: KeyEvent) -> bool { - key.code == self.leader.code && key.modifiers == self.leader.modifiers - } -} - #[cfg(test)] -pub(crate) mod tests { - use super::*; - use crate::git::diff::{ - CommitEntry, DiffHunk, DiffLine, LineKind, StatusKind, load_commit_log, - }; - use crate::runtime::terminal::TerminalFullscreen; - use crate::test_util::{make_repo, open_repo, run_git}; - use crossterm::event::KeyCode; - use std::collections::HashMap; - use std::path::Path; - use std::sync::mpsc; - use std::time::{Duration, SystemTime}; - - /// Build an inert SnapshotChannel for tests: real receiver, real stop - /// sender, but no worker thread driving the receiver. - /// - /// Drops `_stop_rx` immediately on purpose: the only contract of `_stop_tx` - /// is "dropped → worker observes disconnect". Since there is no worker - /// here, nothing waits on either side, and dropping `_stop_rx` upfront - /// keeps the helper's tuple shape minimal. If a future test ever spawns - /// a real worker against this channel, it must keep `_stop_rx` alive. - pub(crate) fn dummy_snapshot_channel() -> (SnapshotChannel, std::sync::mpsc::Sender) - { - let (tx, rx) = mpsc::channel::(); - let (stop_tx, _stop_rx) = mpsc::sync_channel::<()>(0); - (SnapshotChannel::from_endpoints(rx, stop_tx), tx) - } - - /// Inert tree watcher plus its event sender. Tests that drive the - /// watcher-triggered refresh keep the `Sender` to inject synthetic events; - /// most tests drop it (a closed channel simply never reports a change). - pub(crate) fn dummy_tree_watcher() -> ( - crate::runtime::tree_watch::TreeWatcher, - std::sync::mpsc::Sender, - ) { - let (tx, rx) = mpsc::channel(); - ( - crate::runtime::tree_watch::TreeWatcher::from_receiver(rx), - tx, - ) - } - - pub(crate) fn app_with_files(files: Vec<&str>) -> App { - let (snapshot, _tx) = dummy_snapshot_channel(); - let (tree_watch, _tw_tx) = dummy_tree_watcher(); - let mut status_view = StatusView { - files: files - .into_iter() - .map(|path| ChangedFile::unstaged_only(path.to_string(), StatusKind::Modified)) - .collect(), - ..Default::default() - }; - status_view.recompute_filter(); - App { - mode: ViewMode::Status, - status_view, - diff: DiffPane::default(), - focus: Focus::FileList, - notice: None, - repo_path: ".".to_string(), - log_view: LogView::default(), - tree_view: TreeView::default(), - terminal: TerminalState::new(None, false), - accent_idx: 0, - tracking: None, - snapshot, - pending_snapshot: None, - tree_watch, - tree_dirty: Default::default(), - tree_dirty_all: false, - pending_selection: None, - repo_cache: None, - cfg_agent_indicator: crate::config::AgentIndicatorConfig { - auto_follow: true, - ..crate::config::AgentIndicatorConfig::default() - }, - cfg_tree: crate::config::TreeConfig::default(), - pagination: CommitLogPagination::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, - leader: KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), - prefix_armed: false, - awaiting_swap_target: false, - pending_mouse_press: None, - mouse_enabled: true, - } - } - - fn context_hunk(lines: &[&str]) -> DiffHunk { - DiffHunk { - header: "@@ -1 +1 @@".to_string(), - lines: lines - .iter() - .map(|content| DiffLine { - kind: LineKind::Context, - content: (*content).to_string(), - }) - .collect(), - file_path: None, - } - } - - pub(crate) fn app_with_fake_backend() -> App { - let mut app = app_with_files(vec!["a.rs"]); - let backend = Box::new(crate::test_util::FakeBackend::default()); - app.terminal = TerminalState::new(Some(backend), false); - app - } - - #[test] - fn leader_label_renders_ctrl_chord_as_caret_uppercase() { - let mut app = app_with_files(vec!["a.rs"]); - // Default leader is Ctrl+F. - assert_eq!(app.leader_label(), "^F"); - app.leader = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL); - assert_eq!(app.leader_label(), "^B"); - } - - #[test] - fn leader_label_without_ctrl_prints_raw_char() { - let mut app = app_with_files(vec!["a.rs"]); - app.leader = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE); - assert_eq!(app.leader_label(), "x"); - } - - #[test] - fn ensure_initial_terminal_opens_single_empty_pane_without_commands() { - let mut app = app_with_fake_backend(); - app.ensure_initial_terminal(&[]); - assert_eq!(app.terminal.panes.len(), 1); - assert_eq!(app.terminal.panes[0].title, "shell 1"); - } - - #[test] - fn ensure_initial_terminal_focuses_first_pane_on_fresh_launch() { - let mut app = app_with_fake_backend(); - // Helpers construct with FileList focus; a fresh launch must hand the - // first pane the input focus so keystrokes reach the terminal program. - assert_eq!(app.focus, Focus::FileList); - app.ensure_initial_terminal(&[]); - assert_eq!(app.focus, Focus::Terminal); - assert_eq!(app.terminal.active, 0); - } - - #[test] - fn restore_session_overrides_fresh_launch_terminal_focus() { - let mut app = app_with_fake_backend(); - app.ensure_initial_terminal(&[]); - assert_eq!(app.focus, Focus::Terminal); - // A restored session must win over the fresh-launch terminal focus. - app.restore_session(&crate::session::SessionState { - focus: Some(Focus::FileList), - ..Default::default() - }); - assert_eq!(app.focus, Focus::FileList); - } - - #[test] - fn restore_pane_focus_wins_immediately_without_snapshot() { - // The synchronous focus restore (run at startup before the first - // snapshot) must already override the fresh-launch terminal focus, so - // no keystroke is ever routed to the terminal on a FileList restart. - let mut app = app_with_fake_backend(); - app.ensure_initial_terminal(&[]); - assert_eq!(app.focus, Focus::Terminal); - app.restore_pane_focus(&crate::session::SessionState { - focus: Some(Focus::FileList), - ..Default::default() - }); - assert_eq!(app.focus, Focus::FileList); - } - - #[test] - fn ensure_initial_terminal_opens_one_pane_per_startup_command() { - use crate::config::StartupCommand; - let mut app = app_with_fake_backend(); - let commands = vec![ - StartupCommand { - name: Some("Claude".into()), - command: "claude".into(), - }, - StartupCommand { - name: None, - command: "cargo test".into(), - }, - ]; - app.ensure_initial_terminal(&commands); - assert_eq!(app.terminal.panes.len(), 2); - assert_eq!(app.terminal.panes[0].title, "Claude"); - assert_eq!(app.terminal.panes[1].title, "cargo test"); - // Focus clamps to the first reserved pane. - assert_eq!(app.terminal.active, 0); - } - - #[test] - fn selection_clamps_when_file_list_shrinks() { - let mut app = app_with_files(vec!["a.rs", "b.rs", "c.rs"]); - app.status_view.selected = 2; - app.status_view.files = vec![ChangedFile::unstaged_only( - "a.rs".to_string(), - StatusKind::Modified, - )]; - - let selected_path = app.restore_selection(Some("c.rs")); - - assert_eq!(selected_path.as_deref(), Some("a.rs")); - assert_eq!(app.status_view.selected, 0); - } - - #[test] - fn selection_prefers_same_path_after_refresh() { - let mut app = app_with_files(vec!["a.rs", "b.rs", "c.rs"]); - app.status_view.selected = 1; - app.status_view.files = vec![ - ChangedFile::unstaged_only("a.rs".to_string(), StatusKind::Modified), - ChangedFile::unstaged_only("c.rs".to_string(), StatusKind::Modified), - ChangedFile::unstaged_only("b.rs".to_string(), StatusKind::Modified), - ]; - - let selected_path = app.restore_selection(Some("b.rs")); - - assert_eq!(selected_path.as_deref(), Some("b.rs")); - assert_eq!(app.status_view.selected, 2); - } - - #[test] - fn diff_scroll_saturates_on_page_up() { - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::DiffViewer; - app.diff.scroll = 3; - - app.page_up(); - - assert_eq!(app.diff.scroll, 0); - } - - #[test] - fn diff_scroll_clamps_at_last_line_on_select_down() { - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::DiffViewer; - // 1 hunk = header + 1 content line = 2 total lines, max_scroll = 1 - app.diff.hunks = vec![context_hunk(&["x"])]; - app.diff.scroll = 1; // already at max - - app.select_down(); - - assert_eq!(app.diff.scroll, 1, "scroll must not exceed last line index"); - } - - #[test] - fn diff_scroll_clamps_at_last_line_on_page_down() { - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::DiffViewer; - app.diff.hunks = vec![context_hunk(&["x"])]; - app.diff.scroll = 0; - - app.page_down(); // +20, but max is 1 - - assert_eq!(app.diff.scroll, 1); - } - - #[test] - fn diff_scroll_handles_large_restored_offset() { - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::DiffViewer; - app.diff.hunks = vec![context_hunk(&["x"])]; - app.diff.scroll = usize::MAX; - - app.select_down(); - - assert_eq!(app.diff.scroll, 1); - } - - #[test] - fn diff_match_refresh_can_preserve_manual_scroll() { - let mut app = app_with_files(vec!["a.rs"]); - app.diff.hunks = vec![context_hunk(&["needle"])]; - app.diff.search.query.set("needle"); - app.diff.scroll = 7; - - app.diff.recompute_matches(false); - - assert_eq!(app.diff.search.matches, vec![1]); - assert_eq!(app.diff.scroll, 7); - } - - #[test] - fn diff_search_input_scrolls_to_first_match() { - let mut app = app_with_files(vec!["a.rs"]); - app.diff.hunks = vec![context_hunk(&["alpha", "needle"])]; - - app.diff.search_push('n'); - - assert_eq!(app.diff.search.matches, vec![2]); - assert_eq!(app.diff.scroll, 2); - } - - #[test] - fn status_search_with_no_matches_clears_stale_diff() { - let mut app = app_with_files(vec!["a.rs"]); - app.diff.hunks = vec![context_hunk(&["stale"])]; - - app.search_push('z'); - - assert!(app.filtered_indices().is_empty()); - assert!(app.diff.hunks.is_empty()); - } - - #[test] - fn terminal_scrollback_uses_full_buffer() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - app.terminal.active = 0; - app.terminal.size = (3, 10); - - let mut emulator = crate::runtime::emulator::PaneEmulator::new(3, 10, SCROLLBACK_LINES); - emulator.process(b"1\r\n2\r\n3\r\n4\r\n5\r\n6\r\n7\r\n8\r\n9\r\n"); - app.terminal.emulators.insert(1, emulator); - // Request scrolling well past screen height; the emulator supports - // arbitrary offsets up to the buffered line count. - app.terminal.scroll.insert(1, 6); - - app.terminal.sync_scroll(); - - let actual = app.terminal.emulators.get(&1).unwrap().scroll_offset(); - assert_eq!(actual, 6); - assert_eq!(app.terminal.scroll.get(&1).copied(), Some(6)); - } - - #[test] - fn terminal_scrollback_clamps_to_buffered_rows() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - app.terminal.active = 0; - app.terminal.size = (3, 10); - - let mut emulator = crate::runtime::emulator::PaneEmulator::new(3, 10, SCROLLBACK_LINES); - // Only a handful of buffered rows exist; an outsized request must - // clamp to whatever the emulator actually has, never panic. - emulator.process(b"1\r\n2\r\n3\r\n4\r\n5\r\n"); - app.terminal.emulators.insert(1, emulator); - app.terminal.scroll.insert(1, 999); - - app.terminal.sync_scroll(); - - let stored = app.terminal.scroll.get(&1).copied().unwrap_or(0); - let actual = app.terminal.emulators.get(&1).unwrap().scroll_offset(); - assert_eq!(stored, actual); - assert!(actual < 999); - } - - #[test] - fn switch_pane_moves_focus_to_terminal() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![ - PaneInfo { - id: 1, - title: "shell 1".into(), - }, - PaneInfo { - id: 2, - title: "shell 2".into(), - }, - ]; - assert_eq!(app.focus, Focus::FileList); - app.switch_pane(1); - assert_eq!(app.focus, Focus::Terminal); - assert_eq!(app.terminal.active, 1); - } - - #[test] - fn open_new_pane_moves_focus_to_new_terminal() { - let mut app = app_with_fake_backend(); - assert_eq!(app.focus, Focus::FileList); - - app.open_new_pane(); - - assert_eq!(app.terminal.panes.len(), 1); - assert_eq!(app.focus, Focus::Terminal); - assert_eq!(app.terminal.active, 0); - } - - #[test] - fn open_new_pane_exits_competing_fullscreen() { - let mut app = app_with_fake_backend(); - app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); - - app.open_new_pane(); - - assert_eq!(app.focus, Focus::Terminal); - assert!(!app.diff.fullscreen); - assert!(!app.list_fullscreen); - } - - /// Contract for the close/swap availability predicates shared by the key - /// gates (`main::handle_global_action`) and the armed hint row: close - /// needs terminal focus, swap additionally needs a second pane. - #[test] - fn pane_action_predicates_follow_focus_and_pane_count() { - let mut app = app_with_fake_backend(); - app.terminal.create_pane().unwrap(); - app.terminal.create_pane().unwrap(); - assert!( - !app.can_close_pane() && !app.can_swap_panes(), - "neither close nor swap may act without terminal focus" - ); - - app.focus = Focus::Terminal; - assert!(app.can_close_pane()); - assert!(app.can_swap_panes()); - - app.close_active_pane(); - assert!( - app.can_close_pane(), - "close still acts on the last remaining pane" - ); - assert!( - !app.can_swap_panes(), - "swap needs a second pane to exchange with" - ); - } - - #[test] - fn switch_pane_ignores_out_of_range() { - let mut app = app_with_files(vec![]); - app.switch_pane(5); - assert_eq!(app.terminal.active, 0); - } - - #[test] - fn switch_pane_slides_visible_window_to_include_hidden_pane() { - let mut app = app_with_files(vec![]); - app.terminal.max_visible_normal = 4; - app.terminal.panes = (0..7) - .map(|i| PaneInfo { - id: i + 1, - title: format!("shell {}", i + 1), - }) - .collect(); - - // Jumping straight to the last pane (index 6, beyond the default - // [0,4) window) must slide the window to include it. - app.switch_pane(6); - - assert_eq!(app.terminal.active, 6); - assert!(app.terminal.visible_start <= 6 && app.terminal.visible_start + 4 > 6); - } - - #[test] - fn closing_pane_reclamps_visible_window() { - let mut app = app_with_fake_backend(); - app.terminal.max_visible_normal = 4; - for i in 0..7 { - app.terminal - .create_pane_with(None, Some(&format!("P{i}"))) - .unwrap(); - } - assert_eq!(app.terminal.active, 6); - - // Close panes back down to a single one; the visible window must - // shrink back to contain only the remaining pane. - for _ in 0..6 { - app.close_active_pane(); - } - - assert_eq!(app.terminal.panes.len(), 1); - assert_eq!(app.terminal.active, 0); - assert_eq!(app.terminal.visible_start, 0); - } - - #[test] - fn focus_list_jumps_and_exits_competing_fullscreens() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - app.focus = Focus::Terminal; - app.toggle_terminal_fullscreen(); - assert!(app.terminal.fullscreen.fills_body()); - - app.focus_list(); - - assert_eq!(app.focus, Focus::FileList); - assert!(!app.terminal.fullscreen.fills_body()); - assert!(!app.diff.fullscreen); - } - - #[test] - fn focus_diff_jumps_and_exits_competing_fullscreens() { - let mut app = app_with_files(vec![]); - app.toggle_list_fullscreen(); - assert!(app.list_fullscreen); - - app.focus_diff(); - - assert_eq!(app.focus, Focus::DiffViewer); - assert!(!app.list_fullscreen); - assert!(!app.terminal.fullscreen.fills_body()); - } - - #[test] - fn switch_pane_exits_diff_fullscreen() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); - - app.switch_pane(0); - - assert!(!app.diff.fullscreen); - assert_eq!(app.focus, Focus::Terminal); - assert_eq!(app.terminal.active, 0); - } - - #[test] - fn toggle_fullscreen_switches_focus_to_terminal() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - assert_eq!(app.focus, Focus::FileList); - - app.toggle_terminal_fullscreen(); - - assert!(app.terminal.fullscreen.fills_body()); - assert_eq!(app.focus, Focus::Terminal); - } - - #[test] - fn toggle_fullscreen_noop_with_no_panes() { - let mut app = app_with_files(vec![]); - assert!(app.terminal.panes.is_empty()); - - app.toggle_terminal_fullscreen(); - - assert!(!app.terminal.fullscreen.fills_body()); - } - - #[test] - fn toggle_terminal_fullscreen_cycles_off_grid_zoom_off_with_multiple_panes() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![ - PaneInfo { - id: 1, - title: "a".into(), - }, - PaneInfo { - id: 2, - title: "b".into(), - }, - ]; - app.focus = Focus::Terminal; - assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Off); - - app.toggle_terminal_fullscreen(); - assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Grid); - - app.toggle_terminal_fullscreen(); - assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Zoom); - // Zoom pins the visible window to exactly the active pane. - assert_eq!(app.terminal.max_visible(), 1); - - app.toggle_terminal_fullscreen(); - assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Off); - } - - #[test] - fn closing_pane_normalizes_zoom_to_grid_when_one_pane_remains() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![ - PaneInfo { - id: 1, - title: "a".into(), - }, - PaneInfo { - id: 2, - title: "b".into(), - }, - ]; - app.focus = Focus::Terminal; - app.toggle_terminal_fullscreen(); // Grid - app.toggle_terminal_fullscreen(); // Zoom - assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Zoom); - - // One pane left: Zoom is indistinguishable from Grid, so it normalizes. - app.terminal.panes.pop(); - app.clamp_active_pane_after_removal(); - - assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Grid); - } - - #[test] - fn toggle_terminal_fullscreen_skips_zoom_with_single_pane() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - app.focus = Focus::Terminal; - - app.toggle_terminal_fullscreen(); - assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Grid); - - // With a lone pane Grid and Zoom look identical, so the cycle collapses - // straight back to Off rather than stopping at an indistinguishable Zoom. - app.toggle_terminal_fullscreen(); - assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Off); - } - - #[test] - fn toggle_terminal_fullscreen_skips_zoom_when_grid_cap_is_one() { - // Even with multiple panes, a fullscreen grid capped at 1 shows a - // single pane, so Grid and Zoom are indistinguishable and Zoom is - // skipped — the skip must not assume `max_visible_fullscreen >= 2`. - let mut app = app_with_files(vec![]); - app.terminal.max_visible_fullscreen = 1; - app.terminal.panes = vec![ - PaneInfo { - id: 1, - title: "a".into(), - }, - PaneInfo { - id: 2, - title: "b".into(), - }, - ]; - app.focus = Focus::Terminal; - - app.toggle_terminal_fullscreen(); - assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Grid); - - app.toggle_terminal_fullscreen(); - assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Off); - } - - #[test] - fn toggle_diff_fullscreen_sets_flag_and_focuses_diff_viewer() { - let mut app = app_with_files(vec![]); - assert_eq!(app.focus, Focus::FileList); - - app.toggle_diff_fullscreen(); - - assert!(app.diff.fullscreen); - assert_eq!(app.focus, Focus::DiffViewer); - - app.toggle_diff_fullscreen(); - - assert!(!app.diff.fullscreen); - // Exiting zoom leaves focus on DiffViewer (no reason to bounce back). - assert_eq!(app.focus, Focus::DiffViewer); - } - - #[test] - fn toggle_diff_fullscreen_exits_terminal_fullscreen() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - app.toggle_terminal_fullscreen(); - assert!(app.terminal.fullscreen.fills_body()); - - app.toggle_diff_fullscreen(); - - assert!(app.diff.fullscreen); - assert!(!app.terminal.fullscreen.fills_body()); - assert_eq!(app.focus, Focus::DiffViewer); - } - - #[test] - fn toggle_terminal_fullscreen_exits_diff_fullscreen() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); - - app.toggle_terminal_fullscreen(); - - assert!(app.terminal.fullscreen.fills_body()); - assert!(!app.diff.fullscreen); - assert_eq!(app.focus, Focus::Terminal); - } - - #[test] - fn cycle_focus_is_noop_in_diff_fullscreen() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - app.toggle_diff_fullscreen(); - assert_eq!(app.focus, Focus::DiffViewer); - - app.cycle_focus_forward(); - assert_eq!(app.focus, Focus::DiffViewer); - - app.cycle_focus_backward(); - assert_eq!(app.focus, Focus::DiffViewer); - } - - #[test] - fn cycle_focus_forward_through_terminal_panes_slides_visible_window() { - let mut app = app_with_files(vec![]); - app.terminal.max_visible_normal = 4; - app.terminal.panes = (0..7) - .map(|i| PaneInfo { - id: i + 1, - title: format!("shell {}", i + 1), - }) - .collect(); - app.focus = Focus::DiffViewer; - - // DiffViewer -> Terminal(0), then step forward through every pane. - for expected_active in 0..7 { - app.cycle_focus_forward(); - assert_eq!(app.focus, Focus::Terminal); - assert_eq!(app.terminal.active, expected_active); - assert!( - app.terminal.visible_start <= expected_active - && app.terminal.visible_start + 4 > expected_active, - "active {expected_active} not inside visible window starting at {}", - app.terminal.visible_start - ); - } - } - - #[test] - fn toggle_list_fullscreen_sets_flag_and_focuses_file_list() { - let mut app = app_with_files(vec![]); - app.focus = Focus::DiffViewer; - assert!(!app.list_fullscreen); - - app.toggle_list_fullscreen(); - - assert!(app.list_fullscreen); - assert_eq!(app.focus, Focus::FileList); - - app.toggle_list_fullscreen(); - - assert!(!app.list_fullscreen); - // Exiting list zoom leaves focus on FileList (matches diff zoom semantics). - assert_eq!(app.focus, Focus::FileList); - } - - #[test] - fn toggle_list_fullscreen_exits_diff_fullscreen() { - let mut app = app_with_files(vec![]); - app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); - - app.toggle_list_fullscreen(); - - assert!(app.list_fullscreen); - assert!(!app.diff.fullscreen); - assert_eq!(app.focus, Focus::FileList); - } - - /// Seed one cached commit whose oid matches `last_head_oid` and mark the - /// log fully loaded, so toggling into Log mode reuses the cache without - /// spawning a background fetch. - 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); - } - - #[test] - fn toggle_mode_from_terminal_fullscreen_reveals_file_list() { - let mut app = app_with_files(vec![]); - seed_cached_commit_log(&mut app); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - app.toggle_terminal_fullscreen(); - assert!(app.terminal.fullscreen.fills_body()); - - app.toggle_mode(); - - assert_eq!(app.mode, ViewMode::Log); - assert!(!app.terminal.fullscreen.fills_body()); - assert!(!app.diff.fullscreen); - assert_eq!(app.focus, Focus::FileList); - } - - #[test] - fn toggle_mode_from_diff_fullscreen_reveals_file_list() { - let mut app = app_with_files(vec![]); - seed_cached_commit_log(&mut app); - app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); - - app.toggle_mode(); - - assert_eq!(app.mode, ViewMode::Log); - assert!(!app.diff.fullscreen); - assert!(!app.terminal.fullscreen.fills_body()); - assert_eq!(app.focus, Focus::FileList); - } - - #[test] - fn toggle_mode_in_split_layout_keeps_focus() { - let mut app = app_with_files(vec![]); - seed_cached_commit_log(&mut app); - app.focus = Focus::DiffViewer; - - app.toggle_mode(); - - assert_eq!(app.mode, ViewMode::Log); - assert_eq!(app.focus, Focus::DiffViewer); - - app.toggle_mode(); - - assert_eq!(app.mode, ViewMode::Status); - assert_eq!(app.focus, Focus::DiffViewer); - } - - // ── Tree mode (read-only file-tree navigator) ───────────────── - - /// A temp repo with `src/main.rs` and `README.md` at the root, plus an app - /// pointed at it. The app uses an inert snapshot channel (no worker). - fn make_tree_repo() -> (tempfile::TempDir, String) { - let (dir, path) = make_repo(); - let root = Path::new(&path); - std::fs::create_dir(root.join("src")).unwrap(); - std::fs::write(root.join("src").join("main.rs"), "fn main() {}\n").unwrap(); - std::fs::write(root.join("README.md"), "# hi\n").unwrap(); - (dir, path) - } - - fn app_on(path: &str) -> App { - let mut app = app_with_files(vec![]); - app.repo_path = path.to_string(); - app - } - - fn tree_index_of(app: &App, path: &str) -> usize { - app.tree_view - .visible_rows() - .iter() - .position(|r| r.path == path) - .unwrap_or_else(|| panic!("{path} not visible in tree")) - } - - #[test] - fn enter_tree_mode_loads_root_and_shows_file_overlay() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - - app.enter_tree_mode(); - - assert_eq!(app.mode, ViewMode::Tree); - let rows = app.tree_view.visible_rows(); - // Directories sort first: src/ before README.md. - assert_eq!(rows[0].path, "src"); - assert!(rows[0].is_dir); - assert!(rows.iter().any(|r| r.path == "README.md")); - // The right pane is always the file overlay in Tree mode. - assert_eq!(app.diff.view, DiffPaneView::File); - drop(dir); - } - - #[test] - fn tree_expand_reveals_children_and_collapse_hides_them() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.enter_tree_mode(); - - app.tree_view.selected = tree_index_of(&app, "src"); - app.tree_expand(); - assert!( - app.tree_view - .visible_rows() - .iter() - .any(|r| r.path == "src/main.rs"), - "expanding src must reveal its child" - ); - - // Cursor is back on the (still-selected) src row; collapsing hides it. - app.tree_view.selected = tree_index_of(&app, "src"); - app.tree_collapse(); - assert!( - !app.tree_view - .visible_rows() - .iter() - .any(|r| r.path == "src/main.rs"), - "collapsing src must hide its child" - ); - drop(dir); - } - - #[test] - fn selecting_tree_file_loads_raw_contents_into_file_view() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.enter_tree_mode(); - - app.tree_view.selected = tree_index_of(&app, "README.md"); - app.preview_tree_selected(); - - assert_eq!(app.diff.view, DiffPaneView::File); - assert_eq!( - app.diff.file_view.key, - Some(FileViewKey::Status("README.md".to_string())) - ); - assert_eq!(app.diff.file_view.content, "# hi\n"); - drop(dir); - } - - #[test] - fn tree_collapse_on_expanded_child_steps_to_parent() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.enter_tree_mode(); - app.tree_view.selected = tree_index_of(&app, "src"); - app.tree_expand(); - - // Sit on the child file, then collapse: the cursor walks up to `src`. - app.tree_view.selected = tree_index_of(&app, "src/main.rs"); - app.tree_collapse(); - - assert_eq!( - app.tree_view.selected_path().as_deref(), - Some("src"), - "Left on a child should move selection to its parent dir" - ); - drop(dir); - } - - #[test] - fn tree_search_finds_file_in_unexpanded_dir() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.enter_tree_mode(); - // `src` starts collapsed, so its child is not in the normal view. - assert!(!app.tree_view.expanded.contains("src")); - - app.start_tree_search(); - for ch in "main".chars() { - app.tree_search_push(ch); - } - - // The match is revealed through its ancestor chain even though `src` - // was never manually expanded. - let rows = app.tree_view.visible_rows(); - assert!(rows.iter().any(|r| r.path == "src/main.rs")); - assert!(rows.iter().any(|r| r.path == "src")); - assert!(!rows.iter().any(|r| r.path == "README.md")); - // Cursor lands on the matching file, not the connecting `src` dir. - assert_eq!( - app.tree_view.selected_path().as_deref(), - Some("src/main.rs") - ); - // Filtering must not mutate the real expansion set. - assert!(!app.tree_view.expanded.contains("src")); - drop(dir); - } - - #[test] - fn confirm_tree_search_reveals_match_in_normal_view() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.enter_tree_mode(); - - app.start_tree_search(); - for ch in "main".chars() { - app.tree_search_push(ch); - } - app.confirm_tree_search(); - - // Overlay closed, query cleared, and `src` is now genuinely expanded so - // the chosen file stays visible with the cursor on it. - assert!(!app.tree_view.search_active); - assert!(app.tree_view.search_query.is_empty()); - assert!(app.tree_view.expanded.contains("src")); - assert_eq!( - app.tree_view.selected_path().as_deref(), - Some("src/main.rs") - ); - drop(dir); - } - - #[test] - fn cancel_tree_search_leaves_expansion_untouched() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.enter_tree_mode(); - - app.start_tree_search(); - for ch in "main".chars() { - app.tree_search_push(ch); - } - app.cancel_tree_search(); - - assert!(!app.tree_view.search_active); - assert!(app.tree_view.search_query.is_empty()); - // Esc must not expand anything; the view returns to its prior state. - assert!(!app.tree_view.expanded.contains("src")); - assert!( - !app.tree_view - .visible_rows() - .iter() - .any(|r| r.path == "src/main.rs") - ); - drop(dir); - } - - #[test] - fn toggle_tree_mode_round_trips_status_and_tree() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - assert_eq!(app.mode, ViewMode::Status); - - app.toggle_tree_mode(); - assert_eq!(app.mode, ViewMode::Tree); - - app.toggle_tree_mode(); - assert_eq!(app.mode, ViewMode::Status); - drop(dir); - } - - #[test] - fn enter_tree_mode_picks_up_dir_created_after_first_entry() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - // First entry caches the root listing (no `docs/` yet). - app.enter_tree_mode(); - assert!( - !app.tree_view - .visible_rows() - .iter() - .any(|r| r.path == "docs"), - "docs should not exist before it is created" - ); - - // Create a directory on disk while away from Tree mode. - app.exit_tree_to_status(); - std::fs::create_dir(Path::new(&path).join("docs")).unwrap(); - std::fs::write(Path::new(&path).join("docs").join("guide.md"), "x").unwrap(); - - // Re-entering must re-read the root and surface the new directory. - app.enter_tree_mode(); - assert!( - app.tree_view - .visible_rows() - .iter() - .any(|r| r.path == "docs"), - "re-entering Tree mode must reflect the newly created directory" - ); - drop(dir); - } - - #[test] - fn enter_tree_mode_reflects_moved_dir_without_error() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - // Cache the root with `src/` present, expanded. - app.enter_tree_mode(); - app.tree_view.selected = tree_index_of(&app, "src"); - app.tree_expand(); - assert!(app.tree_view.expanded.contains("src")); - - // Move `src/` to `lib/` on disk while away from Tree mode. - app.exit_tree_to_status(); - std::fs::rename(Path::new(&path).join("src"), Path::new(&path).join("lib")).unwrap(); - - app.enter_tree_mode(); - let rows = app.tree_view.visible_rows(); - assert!( - !rows.iter().any(|r| r.path == "src"), - "the moved-away directory must disappear from its old location" - ); - assert!( - rows.iter().any(|r| r.path == "lib"), - "the directory must appear at its new location" - ); - // The stale `src` expansion is pruned (it no longer exists), so no - // failing re-read leaks a "tree error" into the status bar. - assert!(!app.tree_view.expanded.contains("src")); - assert!( - !app.notice - .as_ref() - .is_some_and(|n| n.kind == NoticeKind::Tree), - "a vanished directory must not surface a tree error: {:?}", - app.notice - ); - drop(dir); - } - - #[test] - fn refresh_tree_cache_keeps_expansion_for_surviving_dirs() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.enter_tree_mode(); - app.tree_view.selected = tree_index_of(&app, "src"); - app.tree_expand(); - assert!( - app.tree_view - .visible_rows() - .iter() - .any(|r| r.path == "src/main.rs"), - "src should be expanded before the refresh" - ); - - app.refresh_tree_cache(); - - assert!(app.tree_view.expanded.contains("src")); - assert!( - app.tree_view - .visible_rows() - .iter() - .any(|r| r.path == "src/main.rs"), - "a surviving directory keeps its expansion and re-read children" - ); - drop(dir); - } - - #[test] - fn enter_tree_mode_keeps_cursor_on_same_path_when_rows_shift() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.enter_tree_mode(); - // Park the cursor on README.md. - app.tree_view.selected = tree_index_of(&app, "README.md"); - - // Insert a directory that sorts ahead of everything, shifting README.md - // down by one row. - app.exit_tree_to_status(); - std::fs::create_dir(Path::new(&path).join("aaa")).unwrap(); - - app.enter_tree_mode(); - // The cursor must follow README.md, not stay on its old index (which now - // points at a different row). - assert_eq!( - app.tree_view.selected_path().as_deref(), - Some("README.md"), - "cursor must track its path across the row-set shift" - ); - drop(dir); - } - - #[test] - fn poll_tree_watcher_refreshes_tree_on_event_in_tree_mode() { - use crate::runtime::tree_watch::TreeWatcher; - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - // Swap in a watcher we can feed synthetic events into. - let (tx, rx) = std::sync::mpsc::channel(); - app.tree_watch = TreeWatcher::from_receiver(rx); - app.enter_tree_mode(); - assert!( - !app.tree_view - .visible_rows() - .iter() - .any(|r| r.path == "docs") - ); - - // A folder appears on disk, then the watcher fires with its path. - std::fs::create_dir(Path::new(&path).join("docs")).unwrap(); - tx.send(Ok(vec![notify_debouncer_mini::DebouncedEvent { - path: Path::new(&path).join("docs"), - kind: notify_debouncer_mini::DebouncedEventKind::Any, - }])) - .unwrap(); - app.poll_tree_watcher(); - - assert!( - app.tree_view - .visible_rows() - .iter() - .any(|r| r.path == "docs"), - "a watcher event in Tree mode must re-read and surface the new dir" - ); - drop(dir); - } - - #[test] - fn poll_tree_watcher_ignores_events_outside_tree_mode() { - use crate::runtime::tree_watch::TreeWatcher; - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - let (tx, rx) = std::sync::mpsc::channel(); - app.tree_watch = TreeWatcher::from_receiver(rx); - // Never enter Tree mode. - assert_eq!(app.mode, ViewMode::Status); - - std::fs::create_dir(Path::new(&path).join("docs")).unwrap(); - tx.send(Ok(Vec::new())).unwrap(); - app.poll_tree_watcher(); - - // The event is drained but must not build/touch the tree off-screen. - assert_eq!(app.mode, ViewMode::Status); - assert!(app.tree_view.cache.is_empty()); - drop(dir); - } - - #[test] - fn leaving_tree_for_log_clears_watches() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.enter_tree_mode(); - assert!( - app.tree_watch.watched_count() > 0, - "entering Tree mode watches at least the root" - ); - - app.toggle_mode(); // Tree -> Log via l - assert_eq!(app.mode, ViewMode::Log); - assert_eq!( - app.tree_watch.watched_count(), - 0, - "leaving Tree for Log must drop all watches" - ); - drop(dir); - } - - #[test] - fn leaving_tree_for_status_clears_watches() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.enter_tree_mode(); - assert!(app.tree_watch.watched_count() > 0); - - app.exit_tree_to_status(); - assert_eq!(app.mode, ViewMode::Status); - assert_eq!(app.tree_watch.watched_count(), 0); - drop(dir); - } - - #[test] - fn toggle_mode_from_tree_enters_log_view() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.enter_tree_mode(); - - // ` l` from Tree goes to Log (not back to Status). - app.toggle_mode(); - assert_eq!(app.mode, ViewMode::Log); - drop(dir); - } - - #[test] - fn drain_snapshot_empties_the_queue_without_applying_it() { - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec!["old.rs"]) - }; - let send = |files: Vec<&str>| { - SnapshotMsg::Ok( - RepoSnapshot { - files: files - .into_iter() - .map(|p| ChangedFile::unstaged_only(p.to_string(), StatusKind::Modified)) - .collect(), - tracking: None, - head_oid: None, - branch_name: None, - }, - HashMap::new(), - ) - }; - tx.send(send(vec!["first.rs"])).unwrap(); - tx.send(send(vec!["second.rs"])).unwrap(); - - app.drain_snapshot(); - - // The queue is empty (so a hidden project's channel cannot grow), but - // no git work ran: the view still shows the pre-snapshot file list. - assert!(app.snapshot.try_recv().is_err(), "queue must be drained"); - assert_eq!(app.status_view.files[0].path, "old.rs"); - assert!(app.pending_snapshot.is_some(), "the tail is held for later"); - - // Applying it later yields the *last* snapshot, not the first. - app.poll_snapshot(); - assert_eq!(app.status_view.files[0].path, "second.rs"); - assert!(app.pending_snapshot.is_none(), "pending is consumed"); - } - - #[test] - fn a_change_in_a_collapsed_directory_updates_search_results() { - // The filtered view spans the whole tree, so a file created under a - // directory the user never expanded still changes the results. The - // watch set follows the index while a search is open, which is what - // makes the event arrive at all. - use crate::runtime::tree_watch::TreeWatcher; - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - let (tx, rx) = std::sync::mpsc::channel(); - app.tree_watch = TreeWatcher::from_receiver(rx); - app.enter_tree_mode(); - app.start_tree_search(); - for c in "main".chars() { - app.tree_search_push(c); - } - let before = app.tree_view.match_count; - assert!( - !app.tree_view.expanded.contains("src"), - "src stays collapsed — the point of the test" - ); - - std::fs::write(Path::new(&path).join("src").join("main_two.rs"), "\n").unwrap(); - tx.send(Ok(vec![notify_debouncer_mini::DebouncedEvent { - path: Path::new(&path).join("src").join("main_two.rs"), - kind: notify_debouncer_mini::DebouncedEventKind::Any, - }])) - .unwrap(); - app.poll_tree_watcher(); - - assert_eq!(app.tree_view.match_count, before + 1); - drop(dir); - } - - #[test] - fn a_watcher_refresh_updates_active_search_results() { - // The filtered view renders from the search index, so refreshing only - // the directory cache left a new file out of the results and the match - // count stale until the query changed. - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.enter_tree_mode(); - app.start_tree_search(); - for c in "main".chars() { - app.tree_search_push(c); - } - let before = app.tree_view.match_count; - - std::fs::write(Path::new(&path).join("src").join("main_two.rs"), "\n").unwrap(); - app.refresh_tree_preserving_cursor(); - - assert_eq!( - app.tree_view.match_count, - before + 1, - "a file created while the search is open must join the results" - ); - drop(dir); - } - - #[test] - fn a_saved_mode_lands_immediately_and_survives_being_changed() { - // The restore used to wait for the first snapshot and then overwrite - // whatever the user had picked in between. Now the mode is applied on - // the spot, so a later change is simply the newer choice. - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - - app.restore_session(&crate::session::SessionState { - mode: Some(ViewMode::Tree), - ..Default::default() - }); - assert_eq!(app.mode, ViewMode::Tree, "applied without a snapshot"); - - app.toggle_mode(); - let chosen = app.mode; - tx.send(SnapshotMsg::Ok( - RepoSnapshot { - files: Vec::new(), - tracking: None, - head_oid: None, - branch_name: None, - }, - HashMap::new(), - )) - .unwrap(); - app.poll_snapshot(); - - assert_eq!(app.mode, chosen, "the snapshot must not undo the choice"); - } - - #[test] - fn a_saved_selection_is_restored_by_the_first_snapshot() { - // The one part that has to wait: it names a file the changed-file list - // has not delivered yet. It rides the ordinary path-preservation code. - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - - app.restore_session(&crate::session::SessionState { - selected_file: Some("b.rs".to_string()), - ..Default::default() - }); - assert!(app.pending_selection.is_some(), "held until the list lands"); - - tx.send(SnapshotMsg::Ok( - RepoSnapshot { - files: ["a.rs", "b.rs"] - .iter() - .map(|p| ChangedFile::unstaged_only(p.to_string(), StatusKind::Modified)) - .collect(), - tracking: None, - head_oid: None, - branch_name: None, - }, - HashMap::new(), - )) - .unwrap(); - app.poll_snapshot(); - - assert_eq!(app.status_view.files[app.status_view.selected].path, "b.rs"); - assert!(app.pending_selection.is_none(), "consumed"); - } - - #[test] - fn a_hidden_tree_change_is_remembered_until_the_tab_is_shown() { - // Rereading directories is the expensive half; a hidden project only - // records that it must, so filesystem churn elsewhere cannot stall the - // active tab. - let mut app = app_with_files(vec!["a.rs"]); - app.mode = ViewMode::Tree; - app.tree_dirty.insert("src".to_string()); - - // Draining with no new event leaves the flag standing, so the refresh - // still happens once this project becomes the active one. - app.drain_tree_watcher(); - assert!( - !app.tree_dirty.is_empty(), - "a pending refresh survives a drain" - ); - - app.poll_tree_watcher(); - assert!(app.tree_dirty.is_empty(), "the active project consumes it"); - } - - #[test] - fn tree_preview_survives_status_snapshot() { - let (dir, path) = make_tree_repo(); - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_on(&path) - }; - app.enter_tree_mode(); - app.tree_view.selected = tree_index_of(&app, "README.md"); - app.preview_tree_selected(); - let content_before = app.diff.file_view.content.clone(); - - // A git-status snapshot arrives (e.g. file changed in a terminal pane). - tx.send(SnapshotMsg::Ok( - RepoSnapshot { - files: Vec::new(), - tracking: None, - head_oid: None, - branch_name: None, - }, - HashMap::new(), - )) - .unwrap(); - app.poll_snapshot(); - - // Tree mode and its preview must be untouched by the snapshot ingest. - assert_eq!(app.mode, ViewMode::Tree); - assert_eq!(app.diff.view, DiffPaneView::File); - assert_eq!(app.diff.file_view.content, content_before); - drop(dir); - } - - #[test] - fn restoring_tree_session_clears_lingering_status_search() { - // Reachable case: `/` opens status search before the first snapshot, - // then a pending session restores Tree mode. The stale search overlay - // must be cleared so Tree keystrokes aren't captured by the search - // handler. - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.start_search(); - app.search_push('x'); - assert!(app.status_view.search_active); - - app.restore_session(&crate::session::SessionState { - mode: Some(ViewMode::Tree), - ..Default::default() - }); - - assert_eq!(app.mode, ViewMode::Tree); - assert!( - !app.status_view.search_active, - "restoring Tree mode must clear a lingering status search overlay" - ); - assert!(app.status_view.search_query.is_empty()); - drop(dir); - } - - #[test] - fn entering_tree_mode_clears_lingering_status_search() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.start_search(); - app.search_push('x'); - assert!(app.status_view.search_active); - - app.enter_tree_mode(); - - assert!(!app.status_view.search_active); - assert!(app.status_view.search_query.is_empty()); - drop(dir); - } - - #[test] - fn restore_tree_session_ignores_unsafe_expanded_paths() { - // A corrupted/hand-edited session must not be able to drive the tree - // to read directories outside the working tree. - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - - app.restore_session(&crate::session::SessionState { - mode: Some(ViewMode::Tree), - tree_expanded: vec![ - "../../..".to_string(), - "/etc".to_string(), - "src".to_string(), - ], - ..Default::default() - }); - - assert_eq!(app.mode, ViewMode::Tree); - // Only the safe, real directory was expanded/cached. - assert!(app.tree_view.expanded.contains("src")); - assert!(!app.tree_view.expanded.contains("../../..")); - assert!(!app.tree_view.expanded.contains("/etc")); - assert!(!app.tree_view.cache.contains_key("/etc")); - drop(dir); - } - - #[test] - fn restore_tree_session_prunes_expansion_gone_since_save() { - // A directory that was expanded when the session was saved may have been - // moved/deleted before the next launch. Restore must drop it rather than - // re-reading a now-missing path and leaking a "tree error". - let (dir, path) = make_tree_repo(); - std::fs::rename(Path::new(&path).join("src"), Path::new(&path).join("lib")).unwrap(); - let mut app = app_on(&path); - - app.restore_session(&crate::session::SessionState { - mode: Some(ViewMode::Tree), - tree_expanded: vec!["src".to_string()], - tree_selected_path: Some("src".to_string()), - ..Default::default() - }); - - assert_eq!(app.mode, ViewMode::Tree); - // `src` no longer exists on disk, so it must not be kept as expanded... - assert!(!app.tree_view.expanded.contains("src")); - // ...and the moved-to directory is visible at the root. - assert!(app.tree_view.visible_rows().iter().any(|r| r.path == "lib")); - assert!( - !app.notice - .as_ref() - .is_some_and(|n| n.kind == NoticeKind::Tree), - "a vanished restored expansion must not surface a tree error: {:?}", - app.notice - ); - drop(dir); - } - - #[test] - fn entering_tree_cancels_in_flight_commit_log_fetch() { - // A page fetch spawned in Log mode must be torn down on Tree entry so - // its reply can't load a commit diff over the Tree file preview. - 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"); - - app.enter_tree_mode(); - - assert!( - app.pagination.page_rx.is_none(), - "entering Tree mode must cancel the in-flight commit-log fetch" - ); - drop(dir); - } - - #[test] - fn tree_mode_diff_file_and_split_toggles_are_noops() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.enter_tree_mode(); - assert_eq!(app.diff.view, DiffPaneView::File); - - // `v` and `s` must not flip the right pane away from the file preview. - app.toggle_diff_file_view(); - assert_eq!(app.diff.view, DiffPaneView::File); - app.toggle_diff_split_view(); - assert_eq!(app.diff.view, DiffPaneView::File); - drop(dir); - } - - #[test] - fn tree_session_round_trips_mode_expansion_and_selection() { - let (dir, path) = make_tree_repo(); - let mut app = app_on(&path); - app.enter_tree_mode(); - app.tree_view.selected = tree_index_of(&app, "src"); - app.tree_expand(); - app.tree_view.selected = tree_index_of(&app, "src/main.rs"); - - let state = app.save_session(); - assert_eq!(state.mode, Some(ViewMode::Tree)); - assert!(state.tree_expanded.contains(&"src".to_string())); - assert_eq!(state.tree_selected_path.as_deref(), Some("src/main.rs")); - - let mut other = app_on(&path); - other.restore_session(&state); - assert_eq!(other.mode, ViewMode::Tree); - assert!(other.tree_view.expanded.contains("src")); - assert_eq!( - other.tree_view.selected_path().as_deref(), - Some("src/main.rs") - ); - // The restored selection previews the file, not a diff. - assert_eq!(other.diff.view, DiffPaneView::File); - assert_eq!(other.diff.file_view.content, "fn main() {}\n"); - drop(dir); - } - - #[test] - fn toggle_mode_in_list_fullscreen_keeps_list_fullscreen() { - let mut app = app_with_files(vec![]); - seed_cached_commit_log(&mut app); - app.toggle_list_fullscreen(); - assert!(app.list_fullscreen); - - app.toggle_mode(); - - assert_eq!(app.mode, ViewMode::Log); - assert!(app.list_fullscreen); - assert_eq!(app.focus, Focus::FileList); - } - - #[test] - fn toggle_list_fullscreen_exits_terminal_fullscreen() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - app.toggle_terminal_fullscreen(); - assert!(app.terminal.fullscreen.fills_body()); - - app.toggle_list_fullscreen(); - - assert!(app.list_fullscreen); - assert!(!app.terminal.fullscreen.fills_body()); - assert_eq!(app.focus, Focus::FileList); - } - - #[test] - fn toggle_diff_fullscreen_exits_list_fullscreen() { - let mut app = app_with_files(vec![]); - app.toggle_list_fullscreen(); - assert!(app.list_fullscreen); - - app.toggle_diff_fullscreen(); - - assert!(app.diff.fullscreen); - assert!(!app.list_fullscreen); - assert_eq!(app.focus, Focus::DiffViewer); - } - - #[test] - fn toggle_terminal_fullscreen_exits_list_fullscreen() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - app.toggle_list_fullscreen(); - assert!(app.list_fullscreen); - - app.toggle_terminal_fullscreen(); - - assert!(app.terminal.fullscreen.fills_body()); - assert!(!app.list_fullscreen); - assert_eq!(app.focus, Focus::Terminal); - } - - #[test] - fn cycle_focus_is_noop_in_list_fullscreen() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - app.toggle_list_fullscreen(); - assert_eq!(app.focus, Focus::FileList); - - app.cycle_focus_forward(); - assert_eq!(app.focus, Focus::FileList); - - app.cycle_focus_backward(); - assert_eq!(app.focus, Focus::FileList); - } - - #[test] - fn switch_pane_exits_list_fullscreen() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - app.toggle_list_fullscreen(); - assert!(app.list_fullscreen); - - app.switch_pane(0); - - assert!(!app.list_fullscreen); - assert_eq!(app.focus, Focus::Terminal); - } - - #[test] - fn save_session_round_trips_list_fullscreen() { - let mut app = app_with_files(vec![]); - app.toggle_list_fullscreen(); - assert!(app.list_fullscreen); - - let state = app.save_session(); - assert!(state.list_fullscreen); - - let mut other = app_with_files(vec![]); - other.restore_session(&state); - assert!(other.list_fullscreen); - assert_eq!(other.focus, Focus::FileList); - } - - #[test] - fn restore_session_list_fullscreen_forces_filelist_focus() { - let mut app = app_with_files(vec![]); - - app.restore_session(&crate::session::SessionState { - focus: Some(Focus::DiffViewer), - list_fullscreen: true, - ..Default::default() - }); - - assert!(app.list_fullscreen); - assert_eq!(app.focus, Focus::FileList); - } - - #[test] - fn restore_session_prefers_terminal_fullscreen_over_list_fullscreen() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - - app.restore_session(&crate::session::SessionState { - focus: Some(Focus::FileList), - terminal_fullscreen: true, - list_fullscreen: true, - ..Default::default() - }); - - assert!(app.terminal.fullscreen.fills_body()); - assert!(!app.list_fullscreen); - assert_eq!(app.focus, Focus::Terminal); - } - - #[test] - fn close_last_pane_exits_fullscreen() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - app.terminal.fullscreen = TerminalFullscreen::Grid; - app.focus = Focus::Terminal; - app.terminal.scroll.insert(1, 3); - app.terminal.prompt_bufs.insert(1, "cargo test".to_string()); - app.terminal - .emulators - .insert(1, crate::runtime::emulator::PaneEmulator::new(3, 10, 0)); - - app.close_active_pane(); - - assert!(!app.terminal.fullscreen.fills_body()); - assert_eq!(app.focus, Focus::DiffViewer); - assert!(!app.terminal.scroll.contains_key(&1)); - assert!(!app.terminal.prompt_bufs.contains_key(&1)); - assert!(!app.terminal.emulators.contains_key(&1)); - } - - #[test] - fn restore_session_restores_active_pane_even_when_focus_is_not_terminal() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![ - PaneInfo { - id: 1, - title: "shell 1".into(), - }, - PaneInfo { - id: 2, - title: "shell 2".into(), - }, - ]; - - app.restore_session(&crate::session::SessionState { - focus: Some(Focus::FileList), - active_pane: 1, - ..Default::default() - }); - - assert_eq!(app.focus, Focus::FileList); - assert_eq!(app.terminal.active, 1); - } - - #[test] - fn restore_session_fullscreen_forces_terminal_focus() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - - app.restore_session(&crate::session::SessionState { - focus: Some(Focus::FileList), - terminal_fullscreen: true, - ..Default::default() - }); - - assert!(app.terminal.fullscreen.fills_body()); - assert_eq!(app.focus, Focus::Terminal); - } - - #[test] - fn restore_session_diff_fullscreen_forces_diff_focus() { - let mut app = app_with_files(vec![]); - - app.restore_session(&crate::session::SessionState { - focus: Some(Focus::FileList), - diff_fullscreen: true, - ..Default::default() - }); - - assert!(app.diff.fullscreen); - assert_eq!(app.focus, Focus::DiffViewer); - } - - #[test] - fn restore_session_prefers_terminal_fullscreen_over_diff_fullscreen() { - let mut app = app_with_files(vec![]); - app.terminal.panes = vec![PaneInfo { - id: 1, - title: "shell".into(), - }]; - - app.restore_session(&crate::session::SessionState { - focus: Some(Focus::FileList), - terminal_fullscreen: true, - diff_fullscreen: true, - ..Default::default() - }); - - assert!(app.terminal.fullscreen.fills_body()); - assert!(!app.diff.fullscreen); - assert_eq!(app.focus, Focus::Terminal); - } - - #[test] - fn save_session_round_trips_diff_fullscreen() { - let mut app = app_with_files(vec![]); - app.toggle_diff_fullscreen(); - assert!(app.diff.fullscreen); - - let state = app.save_session(); - assert!(state.diff_fullscreen); - - let mut other = app_with_files(vec![]); - other.restore_session(&state); - assert!(other.diff.fullscreen); - assert_eq!(other.focus, Focus::DiffViewer); - } - - #[test] - fn restore_session_normalizes_accent_index() { - let mut app = app_with_files(vec![]); - - app.restore_session(&crate::session::SessionState { - accent_idx: usize::MAX, - ..Default::default() - }); - - assert_eq!( - app.accent_idx, - usize::MAX % crate::config::Accent::ALL.len() - ); - } - - #[test] - fn restore_session_keeps_log_scroll_after_loading_commit_diff() { - let (_dir, path) = make_repo(); - let file_path = Path::new(&path).join("a.rs"); - std::fs::write( - &file_path, - "fn main() {\n println!(\"one\");\n println!(\"two\");\n}\n", - ) - .unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - - let mut app = app_with_files(vec![]); - app.repo_path = path; - - app.restore_session(&crate::session::SessionState { - mode: Some(ViewMode::Log), - scroll: 2, - ..Default::default() - }); - - assert_eq!(app.mode, ViewMode::Log); - assert!(!app.diff.hunks.is_empty()); - assert_eq!(app.diff.scroll, 2); - } - - #[test] - fn log_drill_in_clears_stale_diff_for_empty_commit() { - let (_dir, path) = make_repo(); - run_git(&path, &["commit", "--allow-empty", "-m", "empty"]); - - let mut app = app_with_files(vec![]); - app.repo_path = path.clone(); - app.mode = ViewMode::Log; - app.log_view - .set_commits(load_commit_log(&open_repo(&path), 1).unwrap()); - app.diff.hunks = vec![context_hunk(&["stale"])]; - app.log_view.diff_title = "stale".to_string(); - - app.log_drill_in(); - - assert!(app.log_view.drill_down); - assert!(app.log_view.commit_files.is_empty()); - assert!(app.diff.hunks.is_empty()); - assert!(app.log_view.diff_title.contains("empty")); - } - - /// Helper: build a snapshot tied to the given repo so HEAD-change detection - /// has a real oid to compare against. The snapshot itself is otherwise - /// empty — we only care about `head_oid` in these tests. - fn snapshot_with_head(repo_path: &str) -> RepoSnapshot { - let head = open_repo(repo_path).head().ok().and_then(|h| h.target()); - RepoSnapshot { - files: Vec::new(), - tracking: None, - head_oid: head, - branch_name: None, - } - } - - #[test] - fn head_change_in_log_mode_reloads_commit_list() { - let (_dir, path) = make_repo(); - run_git(&path, &["commit", "--allow-empty", "-m", "first"]); - run_git(&path, &["commit", "--allow-empty", "-m", "second"]); - - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - app.repo_path = path.clone(); - app.mode = ViewMode::Log; - 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); - assert_eq!(app.log_view.commits.len(), 2); - - // Make a new commit in the same repo (simulates the terminal pane - // running `git commit`). - run_git(&path, &["commit", "--allow-empty", "-m", "third"]); - - tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) - .unwrap(); - app.poll_snapshot(); - app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); - - assert_eq!( - app.log_view.commits.len(), - 3, - "commit list should auto-refresh on HEAD change" - ); - assert_eq!(app.log_view.commits[0].summary, "third"); - } - - #[test] - fn head_change_in_status_mode_does_not_reload() { - let (_dir, path) = make_repo(); - run_git(&path, &["commit", "--allow-empty", "-m", "first"]); - - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - app.repo_path = path.clone(); - // Pre-load a stale 1-entry list; in Status mode it must NOT be - // 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); - assert_eq!(app.log_view.commits.len(), 1); - assert_eq!(app.mode, ViewMode::Status); - - run_git(&path, &["commit", "--allow-empty", "-m", "second"]); - - tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) - .unwrap(); - app.poll_snapshot(); - - assert_eq!( - app.log_view.commits.len(), - 1, - "Status mode must not eagerly refresh the (hidden) commit list" - ); - } - - #[test] - fn toggling_log_after_status_head_change_reloads_stale_cache() { - let (_dir, path) = make_repo(); - run_git(&path, &["commit", "--allow-empty", "-m", "first"]); - - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - app.repo_path = path.clone(); - 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); - assert_eq!(app.log_view.commits[0].summary, "first"); - - run_git(&path, &["commit", "--allow-empty", "-m", "second"]); - tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) - .unwrap(); - app.poll_snapshot(); - - // Status mode leaves the hidden list untouched, but records the new - // HEAD. Entering Log mode must notice the mismatch and reconcile page 0 - // rather than reusing the stale cached page as-is. - assert_eq!(app.log_view.commits.len(), 1); - assert_eq!(app.log_view.commits[0].summary, "first"); - - app.toggle_mode(); - app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); - - assert_eq!(app.mode, ViewMode::Log); - assert_eq!(app.log_view.commits.len(), 2); - assert_eq!(app.log_view.commits[0].summary, "second"); - 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()); - } - - #[test] - fn head_change_preserves_selected_commit_by_oid() { - let (_dir, path) = make_repo(); - run_git(&path, &["commit", "--allow-empty", "-m", "first"]); - run_git(&path, &["commit", "--allow-empty", "-m", "second"]); - - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - app.repo_path = path.clone(); - app.mode = ViewMode::Log; - app.log_view - .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); - // 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); - - run_git(&path, &["commit", "--allow-empty", "-m", "third"]); - - tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) - .unwrap(); - app.poll_snapshot(); - app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); - - // The 'first' commit now sits at index 2 because a new commit is - // prepended. Selection must follow it by oid, not by index. - assert_eq!(app.log_view.commits.len(), 3); - assert_eq!(app.log_view.selected, 2); - assert_eq!(app.log_view.commits[app.log_view.selected].oid, prior_oid); - } - - #[test] - fn head_change_falls_back_to_top_when_prior_oid_gone() { - let (_dir, path) = make_repo(); - run_git(&path, &["commit", "--allow-empty", "-m", "first"]); - run_git(&path, &["commit", "--allow-empty", "-m", "second"]); - - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - app.repo_path = path.clone(); - app.mode = ViewMode::Log; - 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); - - // Reset to before the second commit so the prior HEAD oid is gone, - // then add a different commit on top. - run_git(&path, &["reset", "--hard", "HEAD~1"]); - run_git(&path, &["commit", "--allow-empty", "-m", "other"]); - - tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) - .unwrap(); - app.poll_snapshot(); - app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); - - // The original selected commit ('second') no longer exists; selection - // must fall back to the newest (index 0). - assert_eq!(app.log_view.selected, 0); - assert_eq!(app.log_view.commits[0].summary, "other"); - } - - #[test] - fn head_change_clears_drill_down_when_commit_gone() { - let (_dir, path) = make_repo(); - run_git(&path, &["commit", "--allow-empty", "-m", "first"]); - run_git(&path, &["commit", "--allow-empty", "-m", "doomed"]); - - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - app.repo_path = path.clone(); - app.mode = ViewMode::Log; - app.log_view - .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); - - // Drop the selected commit via reset, then advance HEAD with a new one. - run_git(&path, &["reset", "--hard", "HEAD~1"]); - run_git(&path, &["commit", "--allow-empty", "-m", "replacement"]); - - tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) - .unwrap(); - app.poll_snapshot(); - app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); - - // The drill-down's commit oid is gone, so drill-down must collapse - // and the view drops back to the commit-level diff. - assert!(!app.log_view.drill_down); - } - - #[test] - fn initial_snapshot_does_not_trigger_commit_log_reload() { - let (_dir, path) = make_repo(); - run_git(&path, &["commit", "--allow-empty", "-m", "first"]); - - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - app.repo_path = path.clone(); - 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()); - - tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) - .unwrap(); - app.poll_snapshot(); - - // First snapshot must NOT eagerly fetch the commit log — that's - // 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()); - } - - /// Expiry is keyed on the notice's kind. The previous design matched the - /// message text, which meant a kind with no matching arm — terminal, tree, - /// and session all qualified — was never cleared at all. - #[test] - fn clear_notice_only_drops_the_matching_kind() { - let mut app = app_with_files(vec![]); - - app.raise_notice(NoticeKind::Tree, "boom"); - app.clear_notice(NoticeKind::Git); - assert_eq!( - app.notice, - Some(Notice::new(NoticeKind::Tree, "boom")), - "an unrelated subsystem's success must not drop another's notice" - ); - - app.clear_notice(NoticeKind::Tree); - assert_eq!(app.notice, None); - } - - #[test] - fn raising_a_notice_replaces_the_previous_one() { - let mut app = app_with_files(vec![]); - app.raise_notice(NoticeKind::Tree, "first"); - app.raise_notice(NoticeKind::Git, "second"); - assert_eq!(app.notice, Some(Notice::new(NoticeKind::Git, "second"))); - } - - #[test] - fn notice_line_prefixes_only_the_kinds_that_carry_a_label() { - assert_eq!( - Notice::new(NoticeKind::Git, "not a repo").line(), - "git error: not a repo" - ); - assert_eq!( - Notice::new(NoticeKind::RepoInput, "no such directory").line(), - "no such directory" - ); - } - - /// Backspace is the "edit this path" gesture — the sub-directory case - /// depends on the prefill surviving it. - /// →/End keeps the prefill and appends, which is what the sub-directory - /// case needs: Backspace would eat the trailing separator first. - #[test] - fn successful_snapshot_preserves_terminal_status() { - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - notice: Some(Notice::new(NoticeKind::Terminal, "backend unavailable")), - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - - tx.send(SnapshotMsg::Ok( - RepoSnapshot { - files: Vec::new(), - tracking: None, - head_oid: None, - branch_name: None, - }, - HashMap::new(), - )) - .unwrap(); - app.poll_snapshot(); - - assert_eq!( - app.notice, - Some(Notice::new(NoticeKind::Terminal, "backend unavailable")) - ); - } - - #[test] - fn successful_snapshot_clears_git_status() { - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - notice: Some(Notice::new(NoticeKind::Git, "not a repo")), - snapshot, - pending_snapshot: None, - ..app_with_files(vec![]) - }; - - tx.send(SnapshotMsg::Ok( - RepoSnapshot { - files: Vec::new(), - tracking: None, - head_oid: None, - branch_name: None, - }, - HashMap::new(), - )) - .unwrap(); - app.poll_snapshot(); - - assert_eq!(app.notice, None); - } - - #[test] - fn snapshot_refresh_clamps_selection_to_active_filter() { - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec!["bar.rs"]) - }; - app.status_view.search_query.set("bar"); - app.status_view.recompute_filter(); - - tx.send(SnapshotMsg::Ok( - RepoSnapshot { - files: vec![ - ChangedFile::unstaged_only("aaa.rs".to_string(), StatusKind::Modified), - ChangedFile::unstaged_only("bar2.rs".to_string(), StatusKind::Modified), - ], - tracking: None, - head_oid: None, - branch_name: None, - }, - HashMap::new(), - )) - .unwrap(); - app.poll_snapshot(); - - assert_eq!(app.filtered_indices(), &[1]); - assert_eq!(app.status_view.selected, 1); - assert_eq!( - app.status_view.files[app.status_view.selected].path, - "bar2.rs" - ); - } - - #[test] - fn snapshot_invalidates_path_width_cache_on_same_length_rename() { - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec!["short.rs"]) - }; - // Prime the width cache by reading the right-scroll bound once. - app.file_scroll_right(); - // Rename to a longer path while keeping the file count constant. - // Length-keyed invalidation alone would miss this; the cache must - // clear on every `set_files` assignment. - tx.send(SnapshotMsg::Ok( - RepoSnapshot { - files: vec![ChangedFile::unstaged_only( - "a_much_longer_renamed_path.rs".to_string(), - StatusKind::Modified, - )], - tracking: None, - head_oid: None, - branch_name: None, - }, - HashMap::new(), - )) - .unwrap(); - app.poll_snapshot(); - // Drive enough right-scrolls to reach the new max; if the cache were - // stale we would clamp at the old (shorter) bound. - for _ in 0..20 { - app.file_scroll_right(); - } - assert!(app.status_view.file_scroll_x >= "short.rs".chars().count()); - } - - #[test] - fn snapshot_refresh_with_no_filter_matches_clears_stale_diff() { - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec!["bar.rs"]) - }; - app.status_view.search_query.set("bar"); - app.status_view.recompute_filter(); - app.diff.hunks = vec![context_hunk(&["stale"])]; - - tx.send(SnapshotMsg::Ok( - RepoSnapshot { - files: vec![ChangedFile::unstaged_only( - "aaa.rs".to_string(), - StatusKind::Modified, - )], - tracking: None, - head_oid: None, - branch_name: None, - }, - HashMap::new(), - )) - .unwrap(); - app.poll_snapshot(); - - assert!(app.filtered_indices().is_empty()); - assert!(app.diff.hunks.is_empty()); - } - - #[test] - fn move_selected_in_filter_resets_horizontal_scroll() { - let mut app = app_with_files(vec!["a.rs", "b.rs"]); - app.status_view.file_scroll_x = 12; - app.move_selected_in_filter(1); - assert_eq!(app.status_view.selected, 1); - assert_eq!(app.status_view.file_scroll_x, 0); - } - - #[test] - fn log_select_down_resets_commit_scroll() { - let mut app = app_with_files(vec![]); - app.mode = ViewMode::Log; - // Seed through `set_commits` so the search filter cache is built; - // log navigation walks the filter cache (empty query → 0..len), - // which matches the production code path. - app.log_view.set_commits(vec![ - CommitEntry::new( - git2::Oid::ZERO_SHA1, - "0000000".into(), - "first".into(), - "T".into(), - 0, - ), - CommitEntry::new( - git2::Oid::ZERO_SHA1, - "1111111".into(), - "second".into(), - "T".into(), - 0, - ), - ]); - app.log_view.commit_scroll_x = 9; - app.log_select_down(); - assert_eq!(app.log_view.selected, 1); - assert_eq!(app.log_view.commit_scroll_x, 0); - } - - #[test] - fn log_file_select_down_resets_file_scroll() { - let mut app = app_with_files(vec![]); - app.mode = ViewMode::Log; - app.log_view.drill_down = true; - app.log_view.set_commits(vec![CommitEntry::new( - git2::Oid::ZERO_SHA1, - "0000000".into(), - "first".into(), - "T".into(), - 0, - )]); - app.log_view.set_commit_files(vec![ - ChangedFile::unstaged_only("x.rs".into(), StatusKind::Modified), - ChangedFile::unstaged_only("y.rs".into(), StatusKind::Modified), - ]); - app.log_view.file_scroll_x = 7; - app.log_file_select_down(); - assert_eq!(app.log_view.file_selected, 1); - assert_eq!(app.log_view.file_scroll_x, 0); - } - - #[test] - fn diff_scroll_routes_to_file_view_when_in_file_mode() { - let mut app = app_with_files(vec![]); - app.diff.scroll_x = 12; - app.diff.file_view.scroll_x = 4; - app.diff.view = DiffPaneView::File; - - app.diff.scroll_right(); - assert_eq!(app.diff.scroll_x, 12, "diff scroll_x must not change"); - assert_eq!(app.diff.file_view.scroll_x, 8); - - app.diff.scroll_left(); - assert_eq!(app.diff.file_view.scroll_x, 4); - - app.diff.view = DiffPaneView::Diff; - app.diff.scroll_right(); - assert_eq!(app.diff.scroll_x, 16); - assert_eq!( - app.diff.file_view.scroll_x, 4, - "file_view scroll_x must not change in diff mode" - ); - } - - #[test] - fn selected_filtered_status_file_returns_none_outside_filter() { - let mut app = app_with_files(vec!["alpha.rs", "bravo.rs", "charlie.rs"]); - app.status_view.search_query.set("alpha"); - app.status_view.recompute_filter(); - // Filter only matches index 0; selecting index 2 must return None. - app.status_view.selected = 2; - assert!(app.selected_filtered_status_file().is_none()); - - app.status_view.selected = 0; - assert_eq!( - app.selected_filtered_status_file().map(|f| f.path.as_str()), - Some("alpha.rs") - ); - } - - #[test] - fn strip_escape_sequences_preserves_user_keystroke_after_bare_esc() { - // ESC followed by an ordinary character was previously consumed; the - // letter must now survive so user input echoed via PTY isn't lost. - let out = super::strip_escape_sequences(b"\x1bA"); - assert_eq!(out, "A"); - } - - #[test] - fn strip_escape_sequences_drops_csi_and_ss3() { - // CSI (cursor key), SS3 (alternate keypad), and charset designation - // must all be stripped fully without leaving final bytes behind. - let out = super::strip_escape_sequences(b"hi\x1b[31mRED\x1b[0m\x1bOA\x1b(Bend"); - assert_eq!(out, "hiREDend"); - } - - #[test] - fn strip_escape_sequences_keeps_text_after_malformed_ss3() { - // ESC O followed by a control byte is not a valid SS3 sequence. The - // old implementation unconditionally consumed two chars after ESC, - // swallowing the newline (and any subsequent text relying on it). - let out = super::strip_escape_sequences(b"\x1bO\nhello"); - assert_eq!(out, "\nhello"); - } - - #[test] - fn strip_escape_sequences_drops_osc_until_terminator() { - let bel = super::strip_escape_sequences(b"\x1b]0;title\x07ok"); - assert_eq!(bel, "ok"); - let st = super::strip_escape_sequences(b"\x1b]0;title\x1b\\ok"); - assert_eq!(st, "ok"); - } - - #[test] - fn strip_escape_sequences_preserves_backspace_and_del() { - // BS (0x08) and DEL (0x7f) survive stripping so `buffer_prompt_input` - // can replay them as `buf.pop()` instead of logging chars the user - // already corrected. - let out = super::strip_escape_sequences(b"ab\x7fcd\x08e"); - assert_eq!(out, "ab\x7fcd\x08e"); - } - - #[test] - fn keep_scroll_clamps_when_new_diff_is_shorter() { - let mut app = app_with_files(vec!["a.rs"]); - // Seed a long diff and put scroll near the bottom. - app.diff.hunks = vec![ - context_hunk(&["l1", "l2", "l3", "l4", "l5"]), - context_hunk(&["l6", "l7", "l8"]), - ]; - app.diff.scroll = app.diff.max_scroll(); - let prev_scroll = app.diff.scroll; - assert!(prev_scroll > 1); - - // Apply a much shorter diff with KeepScroll; scroll must clamp. - let shorter = vec![context_hunk(&["only"])]; - app.apply_diff_result(Ok(shorter), DiffApply::KeepScroll(prev_scroll)); - assert!( - app.diff.scroll <= app.diff.max_scroll(), - "scroll {} exceeded max {}", - app.diff.scroll, - app.diff.max_scroll() - ); - } - - #[test] - fn toggle_diff_file_view_ignores_selection_outside_filter() { - let mut app = app_with_files(vec!["alpha.rs", "bravo.rs"]); - app.status_view.search_query.set("alpha"); - app.status_view.recompute_filter(); - // selected points outside the filter — toggle must refuse to open - // a file view rather than loading the hidden entry. - app.status_view.selected = 1; - app.toggle_diff_file_view(); - assert_eq!(app.diff.view, DiffPaneView::Diff); - assert!(app.diff.file_view.key.is_none()); - } - - #[test] - fn toggle_diff_split_view_round_trips_and_overrides_file_view() { - let mut app = app_with_files(vec!["a.rs"]); - - // Diff → Split → Diff. - app.toggle_diff_split_view(); - assert_eq!(app.diff.view, DiffPaneView::Split); - app.toggle_diff_split_view(); - assert_eq!(app.diff.view, DiffPaneView::Diff); - - // From the file overlay, the split toggle switches straight to Split - // rather than back to the unified diff. - app.diff.view = DiffPaneView::File; - app.toggle_diff_split_view(); - assert_eq!(app.diff.view, DiffPaneView::Split); - } - - /// Helper: build a populated FileViewState so tests can assert that - /// downstream operations either preserve or invalidate it without - /// going through the disk-reading `load_file_view` path. - fn seeded_file_view(path: &str) -> FileViewState { - FileViewState { - key: Some(FileViewKey::Status(path.to_string())), - content: "one\ntwo\nthree\n".to_string(), - scroll: 1, - scroll_x: 4, - total_lines: 3, - ..Default::default() - } - } - - #[test] - fn keep_scroll_preserves_open_file_view() { - let mut app = app_with_files(vec!["a.rs"]); - app.diff.hunks = vec![context_hunk(&["l1", "l2"])]; - app.diff.scroll = 1; - app.diff.file_view = seeded_file_view("a.rs"); - app.diff.view = DiffPaneView::File; - - // Same file refresh through KeepScroll must leave the file view - // alone — only Reset paths should invalidate it. - let fresh = vec![context_hunk(&["l1", "l2", "l3"])]; - app.apply_diff_result(Ok(fresh), DiffApply::KeepScroll(app.diff.scroll)); - - assert_eq!(app.diff.view, DiffPaneView::File); - assert_eq!( - app.diff.file_view.key, - Some(FileViewKey::Status("a.rs".into())) - ); - assert_eq!(app.diff.file_view.scroll, 1); - assert_eq!(app.diff.file_view.scroll_x, 4); - } - - #[test] - fn clear_diff_state_invalidates_open_file_view() { - let mut app = app_with_files(vec!["a.rs"]); - app.diff.hunks = vec![context_hunk(&["l1"])]; - app.diff.file_view = seeded_file_view("a.rs"); - app.diff.view = DiffPaneView::File; - - // toggle_mode and other reset paths route through clear_diff_state - // — that single call must wipe the file view to its default. - app.clear_diff_state(); - - assert_eq!(app.diff.view, DiffPaneView::Diff); - assert!(app.diff.file_view.key.is_none()); - assert!(app.diff.file_view.content.is_empty()); - assert_eq!(app.diff.file_view.scroll, 0); - assert_eq!(app.diff.file_view.scroll_x, 0); - } - - #[test] - fn snapshot_refresh_with_no_filter_matches_clears_file_view() { - let (snapshot, tx) = dummy_snapshot_channel(); - let mut app = App { - snapshot, - pending_snapshot: None, - ..app_with_files(vec!["bar.rs"]) - }; - app.status_view.search_query.set("bar"); - app.status_view.recompute_filter(); - app.diff.hunks = vec![context_hunk(&["stale"])]; - app.diff.file_view = seeded_file_view("bar.rs"); - app.diff.view = DiffPaneView::File; - - tx.send(SnapshotMsg::Ok( - RepoSnapshot { - files: vec![ChangedFile::unstaged_only( - "aaa.rs".to_string(), - StatusKind::Modified, - )], - tracking: None, - head_oid: None, - branch_name: None, - }, - HashMap::new(), - )) - .unwrap(); - app.poll_snapshot(); - - // No filter matches the new snapshot, so the diff and file view - // both need to drop their stale handles on the gone path. - assert!(app.filtered_indices().is_empty()); - assert!(app.diff.hunks.is_empty()); - assert_eq!(app.diff.view, DiffPaneView::Diff); - assert!(app.diff.file_view.key.is_none()); - } - - fn snapshot_with(paths: &[&str]) -> RepoSnapshot { - RepoSnapshot { - files: paths - .iter() - .map(|p| ChangedFile::unstaged_only((*p).to_string(), StatusKind::Modified)) - .collect(), - tracking: None, - head_oid: None, - branch_name: None, - } - } - - #[test] - fn ingest_snapshot_populates_hot_table_from_mtimes() { - let mut app = app_with_files(vec![]); - let snap = snapshot_with(&["a.rs", "b.rs"]); - let now = SystemTime::now(); - let mtimes = HashMap::from([ - ("a.rs".to_string(), now), - ("b.rs".to_string(), now - Duration::from_secs(5)), - ]); - - app.ingest_snapshot(snap, mtimes); - - assert_eq!(app.status_view.hot_table.len(), 2); - assert!(app.status_view.hot_table.contains_key("a.rs")); - assert!(app.status_view.hot_table.contains_key("b.rs")); - } - - #[test] - fn merge_hot_table_drops_paths_missing_from_new_snapshot() { - let mut app = app_with_files(vec![]); - let now = SystemTime::now(); - - app.ingest_snapshot( - snapshot_with(&["a.rs"]), - HashMap::from([("a.rs".to_string(), now)]), - ); - assert!(app.status_view.hot_table.contains_key("a.rs")); - - app.ingest_snapshot(snapshot_with(&["b.rs"]), HashMap::new()); - assert!(!app.status_view.hot_table.contains_key("a.rs")); - assert!(!app.status_view.hot_table.contains_key("b.rs")); - } - - #[test] - fn merge_hot_table_replaces_only_when_newer() { - let mut app = app_with_files(vec![]); - let old = SystemTime::UNIX_EPOCH + Duration::from_secs(100); - let newer = SystemTime::UNIX_EPOCH + Duration::from_secs(200); - - app.ingest_snapshot( - snapshot_with(&["a.rs"]), - HashMap::from([("a.rs".to_string(), newer)]), - ); - app.ingest_snapshot( - snapshot_with(&["a.rs"]), - HashMap::from([("a.rs".to_string(), old)]), - ); - - // The earlier mtime must not overwrite the newer observation; a - // rename-from-stash scenario can resurrect older mtimes for the - // same path and would otherwise demote a fresh edit to cool. - assert_eq!(app.status_view.hot_table.get("a.rs"), Some(&newer)); - } - - #[test] - fn auto_follow_selects_freshest_hot_file_when_idle() { - let mut app = app_with_files(vec!["a.rs", "b.rs"]); - app.status_view.selected = 0; - let now = SystemTime::now(); - - app.ingest_snapshot( - snapshot_with(&["a.rs", "b.rs"]), - HashMap::from([ - ("a.rs".to_string(), now - Duration::from_secs(5)), - ("b.rs".to_string(), now), - ]), - ); - - // b.rs is fresher and the user is idle (last_manual_nav_at = None), - // so selection must move from a.rs to b.rs. - assert_eq!(app.status_view.selected, 1); - assert_eq!(app.auto_follow.followed_path.as_deref(), Some("b.rs")); - } - - #[test] - fn auto_follow_skipped_when_user_recently_navigated() { - let mut app = app_with_files(vec!["a.rs", "b.rs"]); - app.status_view.selected = 0; - app.auto_follow.last_manual_nav_at = Some(Instant::now()); - let now = SystemTime::now(); - - app.ingest_snapshot( - snapshot_with(&["a.rs", "b.rs"]), - HashMap::from([("b.rs".to_string(), now)]), - ); - - assert_eq!(app.status_view.selected, 0); - assert!(app.auto_follow.followed_path.is_none()); - } - - #[test] - fn auto_follow_skipped_when_focus_not_filelist() { - let mut app = app_with_files(vec!["a.rs", "b.rs"]); - app.focus = Focus::DiffViewer; - app.status_view.selected = 0; - let now = SystemTime::now(); - - app.ingest_snapshot( - snapshot_with(&["a.rs", "b.rs"]), - HashMap::from([("b.rs".to_string(), now)]), - ); - - assert_eq!(app.status_view.selected, 0); - assert!(app.auto_follow.followed_path.is_none()); - } - - #[test] - fn auto_follow_skipped_when_disabled_in_config() { - let mut app = app_with_files(vec!["a.rs", "b.rs"]); - app.cfg_agent_indicator.auto_follow = false; - app.status_view.selected = 0; - let now = SystemTime::now(); - - app.ingest_snapshot( - snapshot_with(&["a.rs", "b.rs"]), - HashMap::from([("b.rs".to_string(), now)]), - ); - - assert_eq!(app.status_view.selected, 0); - } - - #[test] - fn auto_follow_skipped_when_freshest_is_already_selected() { - let mut app = app_with_files(vec!["a.rs", "b.rs"]); - app.status_view.selected = 1; - let now = SystemTime::now(); - - app.ingest_snapshot( - snapshot_with(&["a.rs", "b.rs"]), - HashMap::from([("b.rs".to_string(), now)]), - ); - - // Selection already points to b.rs — no need to steer or arm the - // "already followed here" guard. - assert_eq!(app.status_view.selected, 1); - assert!(app.auto_follow.followed_path.is_none()); - } - - #[test] - fn select_down_marks_user_active_when_focus_is_filelist() { - let mut app = app_with_files(vec!["a.rs", "b.rs"]); - app.focus = Focus::FileList; - app.auto_follow.followed_path = Some("a.rs".to_string()); - - app.select_down(); - - assert!(app.auto_follow.last_manual_nav_at.is_some()); - assert!(app.auto_follow.followed_path.is_none()); - } - - #[test] - fn select_down_does_not_mark_when_focus_is_diff() { - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::DiffViewer; - - app.select_down(); - - assert!(app.auto_follow.last_manual_nav_at.is_none()); - } - - #[test] - fn auto_follow_respects_search_filter() { - let mut app = app_with_files(vec!["alpha.rs", "beta.rs"]); - app.status_view.search_query.set("alpha"); - app.status_view.recompute_filter(); - app.status_view.selected = 0; // alpha.rs (the only filtered entry) - let now = SystemTime::now(); - - app.ingest_snapshot( - snapshot_with(&["alpha.rs", "beta.rs"]), - HashMap::from([ - ("alpha.rs".to_string(), now - Duration::from_secs(5)), - ("beta.rs".to_string(), now), - ]), - ); - - // beta.rs is fresher but filtered out, so auto-follow must not - // jump to a row the user cannot even see. - assert_eq!(app.status_view.selected, 0); - } - - #[test] - fn auto_follow_excludes_future_mtime_clock_skew() { - // Regression for 962bde2: a file with mtime ahead of `now` (NFS - // clock skew, files copied from a host with a wrong clock) used - // to pin auto-follow forever because the inflated timestamp - // beat every other candidate's `mtime > bm` comparison. - // Future-stamped files must be excluded from consideration. - let mut app = app_with_files(vec!["bogus.rs", "real.rs"]); - app.status_view.selected = 0; - let now = SystemTime::now(); - - app.ingest_snapshot( - snapshot_with(&["bogus.rs", "real.rs"]), - HashMap::from([ - ("bogus.rs".to_string(), now + Duration::from_secs(3600)), - ("real.rs".to_string(), now - Duration::from_secs(2)), - ]), - ); - - // real.rs (the only candidate with a sane timestamp) must win, - // and bogus.rs must not be recorded as the steered path. - let real_idx = app - .status_view - .files - .iter() - .position(|f| f.path == "real.rs") - .expect("real.rs must be in the file list"); - assert_eq!(app.status_view.selected, real_idx); - assert_eq!(app.auto_follow.followed_path.as_deref(), Some("real.rs")); - } - - #[test] - fn clamp_active_pane_preserves_non_terminal_focus_on_last_exit() { - // Regression for 56ced5f: when the last terminal pane self-exits - // (Ctrl+D in the only shell), focus that wasn't on Terminal must - // stay put. Previously the clamp unconditionally redirected to - // DiffViewer, yanking focus away from a user reading the diff. - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::FileList; - // No panes registered — simulate "last pane exited" path. - app.terminal.panes.clear(); - - app.clamp_active_pane_after_removal(); - - assert_eq!(app.focus, Focus::FileList); - assert_eq!(app.terminal.active, 0); - assert!(!app.terminal.fullscreen.fills_body()); - } - - #[test] - fn clamp_active_pane_redirects_when_focus_was_terminal() { - // Symmetric case: if focus *was* Terminal and the last pane - // exits, we need to redirect to a non-terminal pane (DiffViewer) - // so the user can still drive the UI. - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::Terminal; - app.terminal.panes.clear(); - - app.clamp_active_pane_after_removal(); - - assert_eq!(app.focus, Focus::DiffViewer); - } - - // --------------------------------------------------------------- - // Commit log pagination - // --------------------------------------------------------------- - - use crate::app::commit_log_fetch::{CommitLogFetchKind, CommitLogPageMsg}; - - fn fake_entry(time: i64) -> CommitEntry { - CommitEntry::new( - git2::Oid::ZERO_SHA1, - "deadbee".to_string(), - format!("c{time}"), - "T".to_string(), - time, - ) - } - - 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; - let commits: Vec<_> = (0..entries).map(|i| fake_entry(i as i64)).collect(); - app.log_view.set_commits(commits); - app - } - - #[test] - fn maybe_prefetch_no_ops_in_status_mode() { - let mut app = seed_log_app(10, 5, 5); - app.mode = ViewMode::Status; - app.log_view.selected = 9; - - app.maybe_prefetch_commit_log(); - - assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); - } - - #[test] - 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()); - } - - #[test] - fn maybe_prefetch_no_ops_when_fully_loaded() { - let mut app = seed_log_app(10, 5, 5); - app.log_view.fully_loaded = true; - app.log_view.selected = 9; - - app.maybe_prefetch_commit_log(); - - assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); - } - - #[test] - fn maybe_prefetch_no_ops_when_far_from_tail() { - // 10 loaded, threshold 3 — selected at 5 is 5 rows from tail, no prefetch. - let mut app = seed_log_app(10, 5, 3); - app.log_view.selected = 5; - - app.maybe_prefetch_commit_log(); - - assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); - } - - #[test] - fn maybe_prefetch_triggers_when_near_tail() { - // 10 loaded, threshold 5 — selected at 6 is within 4 rows of the tail. - let (dir, path) = make_repo(); - std::fs::write(Path::new(&path).join("a"), "x").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "c"]); - let mut app = seed_log_app(10, 5, 5); - app.repo_path = path.clone(); - app.log_view.selected = 6; - - app.maybe_prefetch_commit_log(); - - assert!(app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_some()); - - // 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(); - drop(dir); - } - - #[test] - fn maybe_prefetch_suppresses_duplicate_pending() { - let (dir, path) = make_repo(); - std::fs::write(Path::new(&path).join("a"), "x").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "c"]); - let mut app = seed_log_app(10, 5, 5); - app.repo_path = path.clone(); - 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()); - - 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); - - let rx = app.pagination.page_rx.take().unwrap(); - let _ = rx.recv_timeout(Duration::from_secs(2)).unwrap(); - 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()); - } - - #[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); - } - - #[test] - fn refresh_after_head_change_prepends_new_commit() { - let (dir, path) = make_repo(); - std::fs::write(Path::new(&path).join("a"), "1").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "c0"]); - std::fs::write(Path::new(&path).join("a"), "2").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "c1"]); - - let mut app = app_with_files(vec![]); - app.repo_path = path.clone(); - app.mode = ViewMode::Log; - // Simulate having loaded the commit list when c0 was still HEAD. - app.log_view - .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()[1..].to_vec()); - let prior_oid = app.log_view.commits.first().unwrap().oid; - assert_eq!(app.log_view.commits.len(), 1); - app.log_view.selected = 0; - - app.refresh_commit_log_after_head_change(); - app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); - - // The fresh c1 commit is prepended; selection shifts so the user keeps - // looking at c0. - assert_eq!(app.log_view.commits.len(), 2); - assert_eq!(app.log_view.commits[1].oid, prior_oid); - assert_eq!(app.log_view.selected, 1); - drop(dir); - } - - #[test] - fn refresh_after_head_change_keeps_merged_side_branch_commits() { - let (dir, path) = make_repo(); - std::fs::write(Path::new(&path).join("base"), "0").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "c0"]); - - run_git(&path, &["checkout", "-b", "feature"]); - std::fs::write(Path::new(&path).join("feature"), "feature").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "feature"]); - - run_git(&path, &["checkout", "-"]); - std::fs::write(Path::new(&path).join("main"), "main").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "c1"]); - - let mut app = app_with_files(vec![]); - app.repo_path = path.clone(); - app.mode = ViewMode::Log; - app.log_view - .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); - assert_eq!(app.log_view.commits.len(), 2); - assert_eq!(app.log_view.commits[0].summary, "c1"); - - run_git( - &path, - &["merge", "--no-ff", "feature", "-m", "merge feature"], - ); - - app.refresh_commit_log_after_head_change(); - app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); - - let summaries: Vec<_> = app - .log_view - .commits - .iter() - .map(|c| c.summary.as_str()) - .collect(); - assert!( - summaries.contains(&"feature"), - "merged side-branch commit was dropped: {summaries:?}" - ); - assert_eq!(app.log_view.commits.len(), 4); - drop(dir); - } - - #[test] - fn refresh_after_head_change_resets_on_divergence() { - let (dir, path) = make_repo(); - std::fs::write(Path::new(&path).join("a"), "1").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "c0"]); - - let mut app = app_with_files(vec![]); - app.repo_path = path.clone(); - app.mode = ViewMode::Log; - // Pretend a prior list whose head no longer exists in the repo — - // simulates rebase/reset/branch switch that drops the old chain. - let ghost_oid = git2::Oid::from_str("0123456789abcdef0123456789abcdef01234567").unwrap(); - app.log_view.set_commits(vec![CommitEntry::new( - ghost_oid, - "012345".to_string(), - "vanished".to_string(), - "T".to_string(), - 0, - )]); - app.log_view.selected = 0; - - app.refresh_commit_log_after_head_change(); - app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); - - // c0 from the actual repo replaces the ghost entry. - assert_eq!(app.log_view.commits.len(), 1); - assert_ne!(app.log_view.commits[0].oid, ghost_oid); - assert_eq!(app.log_view.selected, 0); - drop(dir); - } - - // --------------------------------------------------------------- - // Log search - // --------------------------------------------------------------- - - fn named_commit(summary: &str) -> CommitEntry { - CommitEntry::new( - git2::Oid::ZERO_SHA1, - "deadbee".to_string(), - summary.to_string(), - "T".to_string(), - 0, - ) - } - - #[test] - fn commit_search_filters_summaries_and_clamps_selection() { - let mut app = app_with_files(vec![]); - app.mode = ViewMode::Log; - app.log_view.set_commits(vec![ - named_commit("feat: search bar"), - named_commit("docs: readme"), - named_commit("fix: another search edge case"), - ]); - app.log_view.selected = 1; - - app.start_log_search(); - app.log_search_push('s'); - app.log_search_push('e'); - app.log_search_push('a'); - app.log_search_push('r'); - app.log_search_push('c'); - app.log_search_push('h'); - - // "docs: readme" no longer matches → selection snaps to first match. - assert_eq!(app.log_commit_filtered_indices(), &[0, 2]); - assert_eq!(app.log_view.selected, 0); - - app.cancel_log_search(); - assert_eq!(app.log_commit_filtered_indices(), &[0, 1, 2]); - assert!(app.log_view.commit_search_query.is_empty()); - } - - #[test] - fn maybe_prefetch_suppressed_while_commit_search_active() { - // 10 loaded, threshold 5 — selected at 6 would normally spawn a fetch. - let mut app = seed_log_app(10, 5, 5); - app.log_view.selected = 6; - app.log_view.commit_search_active = true; - - app.maybe_prefetch_commit_log(); - - assert!(!app.log_view.pending_fetch); - assert!(app.pagination.page_rx.is_none()); - } - - #[test] - fn cancel_log_search_resumes_prefetch() { - let (dir, path) = make_repo(); - std::fs::write(Path::new(&path).join("a"), "x").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "c"]); - - let mut app = seed_log_app(10, 5, 5); - app.repo_path = path.clone(); - app.log_view.selected = 6; - // Open the search bar so the prefetch gate is engaged. - app.start_log_search(); - app.maybe_prefetch_commit_log(); - assert!(!app.log_view.pending_fetch); - - // Cancelling the search must re-call maybe_prefetch so the deferred - // 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()); - - let rx = app.pagination.page_rx.take().unwrap(); - let _ = rx.recv_timeout(Duration::from_secs(2)).unwrap(); - drop(dir); - } - - #[test] - fn confirm_log_search_with_query_resumes_prefetch() { - // Confirming (Enter) hides the bar but keeps the query — prefetch - // must resume on the way out, mirroring the cancel path. - let (dir, path) = make_repo(); - std::fs::write(Path::new(&path).join("a"), "x").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "c"]); - - let mut app = seed_log_app(10, 5, 5); - app.repo_path = path.clone(); - app.log_view.selected = 6; - app.start_log_search(); - app.log_search_push('c'); // every fake summary matches. - assert!(!app.log_view.pending_fetch); - - app.confirm_log_search(); - assert!(!app.log_view.commit_search_active); - 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(); - drop(dir); - } - - #[test] - fn drilldown_file_search_filters_paths_and_clamps_selection() { - let mut app = app_with_files(vec![]); - app.mode = ViewMode::Log; - app.log_view.drill_down = true; - app.log_view.set_commit_files(vec![ - ChangedFile::unstaged_only("src/lib.rs".into(), StatusKind::Modified), - ChangedFile::unstaged_only("README.md".into(), StatusKind::Modified), - ChangedFile::unstaged_only("src/main.rs".into(), StatusKind::Modified), - ]); - app.log_view.file_selected = 1; - - app.start_log_search(); - app.log_search_push('s'); - app.log_search_push('r'); - app.log_search_push('c'); - - // README.md drops out → selection snaps to first matching path. - assert_eq!(app.log_file_filtered_indices(), &[0, 2]); - assert_eq!(app.log_view.file_selected, 0); - assert!(app.log_view.file_search_active); - - app.cancel_log_search(); - assert_eq!(app.log_file_filtered_indices(), &[0, 1, 2]); - assert!(app.log_view.file_search_query.is_empty()); - } -} +pub(crate) mod tests; diff --git a/src/app/app_impl.rs b/src/app/app_impl.rs new file mode 100644 index 0000000..c6063e1 --- /dev/null +++ b/src/app/app_impl.rs @@ -0,0 +1,192 @@ +use crate::app::{App, AutoFollow, Notice, NoticeKind, ViewMode, Focus}; +use crate::backend::{PtyBackend, TerminalBackend}; +use crate::runtime::snapshot::SnapshotChannel; +use crossterm::event::KeyEvent; + +impl NoticeKind { + /// Prefix shown before the message, or `None` when the message already + /// reads on its own (a repo-input rejection, a session-restore note, or a + /// refused project action names its own subject). + pub fn label(self) -> Option<&'static str> { + match self { + Self::Git => Some("git error"), + Self::Diff => Some("diff error"), + Self::Terminal => Some("terminal error"), + Self::Tree => Some("tree error"), + Self::Session | Self::RepoInput | Self::Project => None, + } + } +} + +impl Notice { + pub fn new(kind: NoticeKind, text: impl Into) -> Self { + Self { + kind, + text: text.into(), + } + } + + /// The single line to render, label included when the kind carries one. + pub fn line(&self) -> String { + match self.kind.label() { + Some(label) => format!("{label}: {}", self.text), + None => self.text.clone(), + } + } +} + +impl App { + /// Raise a notice, replacing whatever was showing. The chrome has one + /// notice row, so the newest problem wins. + pub fn raise_notice(&mut self, kind: NoticeKind, text: impl Into) { + self.notice = Some(Notice::new(kind, text)); + } + + /// Drop the current notice if it was raised by `kind`. Called from the + /// success path of each subsystem, so a resolved problem stops being + /// reported without clobbering an unrelated one that arrived since. + pub fn clear_notice(&mut self, kind: NoticeKind) { + if self.notice.as_ref().is_some_and(|n| n.kind == kind) { + self.notice = None; + } + } + + /// Drop the current notice because the user acted on the app itself. + /// + /// Deliberately *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 — the same + /// effectively-invisible failure this row exists to prevent. + /// Deliver a pending press's release to the pane that saw it, at the cell + /// the press landed on. + /// + /// 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 instead would leave that program in a drag or selection + /// state with no release ever coming. + pub fn release_pending_press_in_place(&mut self) { + if let Some((id, button, col, row)) = self.pending_mouse_press.take() { + self.terminal.click_pane(id, button, false, col, row); + } + } + + pub fn dismiss_notice_on_app_input(&mut self) { + self.notice = None; + } + + pub fn new( + repo_path: String, + prompt_log: bool, + startup_commands: &[crate::config::StartupCommand], + leader: KeyEvent, + ) -> Self { + let snapshot = SnapshotChannel::spawn(&repo_path); + + let backend: Box = Box::new(PtyBackend::new(&repo_path)); + + let mut app = App { + mode: ViewMode::Status, + status_view: crate::ui::status_view::StatusView::default(), + diff: crate::ui::diff_pane::DiffPane::default(), + focus: Focus::FileList, + notice: None, + repo_path, + log_view: crate::ui::log_view::LogView::default(), + tree_view: crate::ui::tree_view::TreeView::default(), + terminal: crate::runtime::terminal::TerminalState::new(Some(backend), prompt_log), + accent_idx: 0, + tracking: None, + snapshot, + pending_snapshot: None, + // Start disabled; `main` upgrades to a live watcher after the parsed + // `[tree] live_watch` config is applied, so a `false` setting never + // spawns an OS watcher. + tree_watch: crate::runtime::tree_watch::TreeWatcher::disabled(), + tree_dirty: Default::default(), + tree_dirty_all: false, + pending_selection: None, + 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, + ), + auto_follow: AutoFollow::default(), + list_fullscreen: false, + branch_name: None, + leader, + prefix_armed: false, + awaiting_swap_target: false, + pending_mouse_press: None, + mouse_enabled: true, + }; + + app.ensure_initial_terminal(startup_commands); + tracing::info!(repo = %app.repo_path, "nightcrow started"); + app + } + + /// True while the leader has been pressed and we await the follow-up key. + /// Drives the hint bar's `PREFIX` indicator. + pub fn prefix_armed(&self) -> bool { + self.prefix_armed + } + + /// Arm the prefix: the next key will be interpreted as an app command. + pub fn arm_prefix(&mut self) { + self.prefix_armed = true; + } + + /// Disarm the prefix, returning to normal pass-through routing. + pub fn cancel_prefix(&mut self) { + self.prefix_armed = false; + } + + /// Whether one of this project's search bars owns input right now. The + /// repo dialog is process-level, so the full modal test lives on + /// `Workspace::overlay_active`; both feed the key and mouse handlers so a + /// click can never reach behind a modal that swallows keystrokes. + pub fn search_overlay_active(&self) -> bool { + self.status_view.search_active + || self.tree_view.search_active + || self.diff.search.active + || self.log_view.commit_search_active + || self.log_view.file_search_active + } + + /// True while ` s` armed pane-swap mode and we await the target + /// digit. Drives the hint bar's `SWAP` indicator. + pub fn awaiting_swap_target(&self) -> bool { + self.awaiting_swap_target + } + + /// Arm pane-swap mode: the next digit picks the pane to swap with the + /// active pane. Clears the prefix so the two follow-up states never overlap. + pub fn begin_swap_target(&mut self) { + self.prefix_armed = false; + self.awaiting_swap_target = true; + } + + /// Disarm pane-swap mode without acting. + pub fn cancel_swap_target(&mut self) { + self.awaiting_swap_target = false; + } + + /// Caret-notation label for the configured leader chord, e.g. `^F` for + /// `Ctrl+F`. Leaders are always ctrl chords (see `config::parse_leader`), + /// so the control character maps cleanly to `^`; any non-ctrl key + /// falls back to printing its raw character. + pub fn leader_label(&self) -> String { + crate::app::leader_label_of(self.leader) + } + + /// True when `key` matches the configured leader chord. Any modifier beyond + /// the leader's own (Alt, Shift, Super, Hyper, Meta — enhanced keyboard + /// protocols report the latter three) makes it a different chord that passes + /// straight through to the PTY instead of being swallowed, so we compare the + /// full modifier set exactly. + pub fn is_leader_key(&self, key: KeyEvent) -> bool { + key.code == self.leader.code && key.modifiers == self.leader.modifiers + } +} \ No newline at end of file diff --git a/src/app/commit_log_apply.rs b/src/app/commit_log_apply.rs new file mode 100644 index 0000000..f11d139 --- /dev/null +++ b/src/app/commit_log_apply.rs @@ -0,0 +1,155 @@ +use git2::Oid; + +use super::App; +use super::commit_log_fetch::{CommitLogFetchKind, CommitLogPageMsg}; + +impl App { + pub(super) fn handle_commit_log_page_msg(&mut self, msg: CommitLogPageMsg) { + match msg.kind { + CommitLogFetchKind::Tail => self.apply_tail_page(msg), + CommitLogFetchKind::Refresh { + prior_selected_oid, + prior_head_oid, + } => self.apply_refresh_page(msg, prior_selected_oid, prior_head_oid), + } + } + + fn apply_tail_page(&mut self, msg: CommitLogPageMsg) { + // Stale-result check: the worker was launched with `skip` equal + // to the loaded count at the time. If the count has changed + // (HEAD refresh resetting pagination, repo switch landing + // before this reply, etc.), the page no longer concatenates + // safely onto the current list. + if msg.skip != self.log_view.loaded_count { + self.log_view.clear_pending(); + return; + } + match msg.result { + Ok(page) => { + self.log_view.append_page(page, msg.page_size); + // Chain another fetch immediately if the user is still + // sitting near the new tail; otherwise the next + // selection move would have to wait a tick. + self.maybe_prefetch_commit_log(); + } + Err(e) => { + tracing::warn!(error = %e, "commit log page fetch failed"); + self.log_view.clear_pending(); + } + } + } + + /// Apply a fresh page-0 fetch as a refresh: either prepend new head + /// commits onto the cached tail (fast-forward), or replace the list + /// outright (divergence, initial entry). Mirrors the merge that was + /// previously inline in `refresh_commit_log_after_head_change`, now + /// driven off a captured snapshot of the pre-spawn state so the + /// load itself can run on a worker thread. + fn apply_refresh_page( + &mut self, + msg: CommitLogPageMsg, + prior_selected_oid: Option, + prior_head_oid: Option, + ) { + let page_size = msg.page_size; + let page = match msg.result { + Ok(p) => p, + Err(e) => { + tracing::warn!(error = %e, "commit log refresh fetch failed"); + self.log_view.clear_pending(); + return; + } + }; + + // If the previous head still appears in the freshly fetched first + // page and the fresh tail lines up with the cached list, treat the + // change as a fast-forward / simple new commit: prepend the newer + // entries onto the existing list so all accumulated pages stay valid. + // A merge can interleave side-branch commits after the old head; in + // that case cached pages are no longer a contiguous prefix of the + // new revwalk, so reset to the freshly loaded first page instead. + let prepend_idx = prior_head_oid.and_then(|oid| page.iter().position(|c| c.oid == oid)); + let page_is_short = page.len() < page_size; + let can_prepend = prepend_idx.is_some_and(|idx| { + let fresh_tail = &page[idx..]; + !self.log_view.commits.is_empty() + && fresh_tail.len() <= self.log_view.commits.len() + && fresh_tail + .iter() + .zip(self.log_view.commits.iter()) + .all(|(fresh, cached)| fresh.oid == cached.oid) + }); + if let Some(idx) = prepend_idx + && can_prepend + { + let mut new_head_commits: Vec<_> = page.into_iter().take(idx).collect(); + let n_new = new_head_commits.len(); + new_head_commits.append(&mut self.log_view.commits); + self.log_view.commits = new_head_commits; + self.log_view.loaded_count = self.log_view.commits.len(); + // `page_is_short` only describes the freshly fetched first page; + // it doesn't account for cached later pages. Preserve prior + // completion state and only promote to fully_loaded when the + // new revwalk demonstrably fits within one page. + if page_is_short && self.log_view.commits.len() <= page_size { + self.log_view.fully_loaded = true; + } + self.log_view.commit_width_cache.set(None); + // Prepend bypasses `set_commits`, so the filter cache must be + // refreshed manually so an active search query still resolves + // against the newly merged head commits. + self.log_view.recompute_commit_filter(); + self.log_view.clear_pending(); + // Slide the selection so the user keeps looking at the same + // commit even though new entries appeared above it. + if let Some(prior_oid) = prior_selected_oid + && let Some(pos) = self + .log_view + .commits + .iter() + .position(|c| c.oid == prior_oid) + { + self.log_view.selected = pos; + } else { + // `prior_selected_oid` was Some, so the cached list contained + // that oid. If the position lookup fails despite the list + // being a prefix of the new one — corruption, or a race we + // haven't accounted for — clamp to the new bounds so a + // downstream `commits.get(selected)` lands on the tail + // instead of returning None and clearing the diff pane. + self.log_view.selected = self + .log_view + .selected + .saturating_add(n_new) + .min(self.log_view.commits.len().saturating_sub(1)); + } + } else { + self.log_view.set_commits_from_first_page(page, page_size); + self.log_view.selected = prior_selected_oid + .and_then(|oid| self.log_view.commits.iter().position(|c| c.oid == oid)) + .unwrap_or(0); + } + self.log_view.commit_scroll_x = 0; + // Anchor the head-oid sentinel to whatever we just loaded so + // ingest_snapshot doesn't immediately trigger another refresh. + self.pagination.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. Otherwise drop back to the + // commit-level diff. + if self.log_view.drill_down + && prior_selected_oid + .is_none_or(|oid| !self.log_view.commits.iter().any(|c| c.oid == oid)) + { + self.log_view.reset_drill_down(); + } + + if self.log_view.drill_down { + self.load_file_diff_for_log_file_selected(); + } else { + self.load_commit_diff_for_selected(); + } + + self.maybe_prefetch_commit_log(); + } +} \ No newline at end of file diff --git a/src/app/commit_log_fetch.rs b/src/app/commit_log_fetch.rs index 7c96c15..471f4f7 100644 --- a/src/app/commit_log_fetch.rs +++ b/src/app/commit_log_fetch.rs @@ -6,8 +6,8 @@ //! [`App::poll_commit_log_page_fetch`] each tick and appends or discards //! the page. -use std::sync::mpsc::{self, Receiver}; -use std::thread::{self, JoinHandle}; +use std::sync::mpsc; +use std::thread; use crate::util::{REAP_TIMEOUT, try_timed_join}; @@ -45,65 +45,6 @@ pub(crate) struct CommitLogPageMsg { pub result: Result, String>, } -/// Owns the commit-log pagination state. Lifted off `App` so the page -/// worker's lifecycle (receiver + JoinHandle) lives in one place and so -/// the related config knobs and HEAD anchor don't sprawl across `App`'s -/// top-level fields. The Drop impl mirrors `SnapshotChannel` / `PtyPane`: -/// dropping `page_rx` makes the worker's `tx.send` fail at the next reply, -/// then the JoinHandle is awaited so a `change_repo` cannot leak the -/// old-repo worker into the new view. -#[derive(Default)] -pub struct CommitLogPagination { - /// Commits loaded per page. Sourced from `LogConfig::commit_log_page_size`. - pub page_size: usize, - /// Prefetch begins when the cursor is within this many rows of the - /// loaded tail. Sourced from `LogConfig::commit_log_prefetch_threshold`. - pub prefetch_threshold: usize, - /// Receiver for the in-flight worker. `Some` while a fetch is pending; - /// cleared once drained or cancelled. - pub(crate) page_rx: Option>, - /// JoinHandle for the in-flight worker, awaited on `Drop` so the - /// channel-close → tx.send-error → thread-exit sequence completes - /// before `Pagination` itself goes away. `cancel_commit_log_page_fetch` - /// deliberately does *not* join here: the UI tick can't afford to wait - /// for a worker that's mid-`load_commit_log_page`. The receiver-drop - /// already makes the worker's reply fail, so detaching the handle is - /// safe — the worst case is one extra OS thread until it finishes. - handle: Option>, - /// HEAD oid carried in the most recent snapshot. `ingest_snapshot` - /// compares this against the new snapshot's head to decide whether - /// to trigger `refresh_commit_log_after_head_change`. - pub(crate) last_head_oid: Option, -} - -impl CommitLogPagination { - /// Construct with the config-derived knobs and otherwise default state. - /// Used by `App::new` and the test fixture — `..Default::default()` - /// can't be used here because the type implements `Drop`. - pub fn with_config(page_size: usize, prefetch_threshold: usize) -> Self { - Self { - page_size, - prefetch_threshold, - page_rx: None, - handle: None, - last_head_oid: None, - } - } -} - -impl Drop for CommitLogPagination { - fn drop(&mut self) { - // Drop the receiver first so the worker's next `tx.send` fails - // and the loop exits; then await the thread with a bounded - // timeout — a worker stuck mid-`load_commit_log_page` on libgit2 - // must not freeze app shutdown / repo switch. - drop(self.page_rx.take()); - if let Some(h) = self.handle.take() { - try_timed_join(h, REAP_TIMEOUT); - } - } -} - impl App { /// Spawn a background worker that fetches the next page of the /// commit log starting at `skip`. Returns immediately. If a fetch @@ -245,155 +186,6 @@ impl App { } } - fn handle_commit_log_page_msg(&mut self, msg: CommitLogPageMsg) { - match msg.kind { - CommitLogFetchKind::Tail => self.apply_tail_page(msg), - CommitLogFetchKind::Refresh { - prior_selected_oid, - prior_head_oid, - } => self.apply_refresh_page(msg, prior_selected_oid, prior_head_oid), - } - } - - fn apply_tail_page(&mut self, msg: CommitLogPageMsg) { - // Stale-result check: the worker was launched with `skip` equal - // to the loaded count at the time. If the count has changed - // (HEAD refresh resetting pagination, repo switch landing - // before this reply, etc.), the page no longer concatenates - // safely onto the current list. - if msg.skip != self.log_view.loaded_count { - self.log_view.clear_pending(); - return; - } - match msg.result { - Ok(page) => { - self.log_view.append_page(page, msg.page_size); - // Chain another fetch immediately if the user is still - // sitting near the new tail; otherwise the next - // selection move would have to wait a tick. - self.maybe_prefetch_commit_log(); - } - Err(e) => { - tracing::warn!(error = %e, "commit log page fetch failed"); - self.log_view.clear_pending(); - } - } - } - - /// Apply a fresh page-0 fetch as a refresh: either prepend new head - /// commits onto the cached tail (fast-forward), or replace the list - /// outright (divergence, initial entry). Mirrors the merge that was - /// previously inline in `refresh_commit_log_after_head_change`, now - /// driven off a captured snapshot of the pre-spawn state so the - /// load itself can run on a worker thread. - fn apply_refresh_page( - &mut self, - msg: CommitLogPageMsg, - prior_selected_oid: Option, - prior_head_oid: Option, - ) { - let page_size = msg.page_size; - let page = match msg.result { - Ok(p) => p, - Err(e) => { - tracing::warn!(error = %e, "commit log refresh fetch failed"); - self.log_view.clear_pending(); - return; - } - }; - - // If the previous head still appears in the freshly fetched first - // page and the fresh tail lines up with the cached list, treat the - // change as a fast-forward / simple new commit: prepend the newer - // entries onto the existing list so all accumulated pages stay valid. - // A merge can interleave side-branch commits after the old head; in - // that case cached pages are no longer a contiguous prefix of the - // new revwalk, so reset to the freshly loaded first page instead. - let prepend_idx = prior_head_oid.and_then(|oid| page.iter().position(|c| c.oid == oid)); - let page_is_short = page.len() < page_size; - let can_prepend = prepend_idx.is_some_and(|idx| { - let fresh_tail = &page[idx..]; - !self.log_view.commits.is_empty() - && fresh_tail.len() <= self.log_view.commits.len() - && fresh_tail - .iter() - .zip(self.log_view.commits.iter()) - .all(|(fresh, cached)| fresh.oid == cached.oid) - }); - if let Some(idx) = prepend_idx - && can_prepend - { - let mut new_head_commits: Vec<_> = page.into_iter().take(idx).collect(); - let n_new = new_head_commits.len(); - new_head_commits.append(&mut self.log_view.commits); - self.log_view.commits = new_head_commits; - self.log_view.loaded_count = self.log_view.commits.len(); - // `page_is_short` only describes the freshly fetched first page; - // it doesn't account for cached later pages. Preserve prior - // completion state and only promote to fully_loaded when the - // new revwalk demonstrably fits within one page. - if page_is_short && self.log_view.commits.len() <= page_size { - self.log_view.fully_loaded = true; - } - self.log_view.commit_width_cache.set(None); - // Prepend bypasses `set_commits`, so the filter cache must be - // refreshed manually so an active search query still resolves - // against the newly merged head commits. - self.log_view.recompute_commit_filter(); - self.log_view.clear_pending(); - // Slide the selection so the user keeps looking at the same - // commit even though new entries appeared above it. - if let Some(prior_oid) = prior_selected_oid - && let Some(pos) = self - .log_view - .commits - .iter() - .position(|c| c.oid == prior_oid) - { - self.log_view.selected = pos; - } else { - // `prior_selected_oid` was Some, so the cached list contained - // that oid. If the position lookup fails despite the list - // being a prefix of the new one — corruption, or a race we - // haven't accounted for — clamp to the new bounds so a - // downstream `commits.get(selected)` lands on the tail - // instead of returning None and clearing the diff pane. - self.log_view.selected = self - .log_view - .selected - .saturating_add(n_new) - .min(self.log_view.commits.len().saturating_sub(1)); - } - } else { - self.log_view.set_commits_from_first_page(page, page_size); - self.log_view.selected = prior_selected_oid - .and_then(|oid| self.log_view.commits.iter().position(|c| c.oid == oid)) - .unwrap_or(0); - } - self.log_view.commit_scroll_x = 0; - // Anchor the head-oid sentinel to whatever we just loaded so - // ingest_snapshot doesn't immediately trigger another refresh. - self.pagination.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. Otherwise drop back to the - // commit-level diff. - if self.log_view.drill_down - && prior_selected_oid - .is_none_or(|oid| !self.log_view.commits.iter().any(|c| c.oid == oid)) - { - self.log_view.reset_drill_down(); - } - - if self.log_view.drill_down { - self.load_file_diff_for_log_file_selected(); - } else { - self.load_commit_diff_for_selected(); - } - - self.maybe_prefetch_commit_log(); - } - /// Block until any pending commit-log fetch has been drained and /// applied. Test-only — production code polls each tick via the /// main loop and never needs to wait. @@ -408,4 +200,4 @@ impl App { self.poll_commit_log_page_fetch(); } } -} +} \ No newline at end of file diff --git a/src/app/commit_log_pagination.rs b/src/app/commit_log_pagination.rs new file mode 100644 index 0000000..8848d7b --- /dev/null +++ b/src/app/commit_log_pagination.rs @@ -0,0 +1,68 @@ +//! Background commit-log page fetcher pagination state. +//! +//! Lifted off `App` so the page worker's lifecycle (receiver + JoinHandle) +//! lives in one place and the related config knobs and HEAD anchor don't +//! sprawl across `App`'s top-level fields. + +use std::sync::mpsc::Receiver; +use std::thread::JoinHandle; + +use crate::util::{REAP_TIMEOUT, try_timed_join}; + +use super::commit_log_fetch::CommitLogPageMsg; + +/// Owns the commit-log pagination state. The Drop impl mirrors +/// `SnapshotChannel` / `PtyPane`: dropping `page_rx` makes the worker's +/// `tx.send` fail at the next reply, then the JoinHandle is awaited so a +/// `change_repo` cannot leak the old-repo worker into the new view. +#[derive(Default)] +pub struct CommitLogPagination { + /// Commits loaded per page. Sourced from `LogConfig::commit_log_page_size`. + pub page_size: usize, + /// Prefetch begins when the cursor is within this many rows of the + /// loaded tail. Sourced from `LogConfig::commit_log_prefetch_threshold`. + pub prefetch_threshold: usize, + /// Receiver for the in-flight worker. `Some` while a fetch is pending; + /// cleared once drained or cancelled. + pub(crate) page_rx: Option>, + /// JoinHandle for the in-flight worker, awaited on `Drop` so the + /// channel-close → tx.send-error → thread-exit sequence completes + /// before `Pagination` itself goes away. `cancel_commit_log_page_fetch` + /// deliberately does *not* join here: the UI tick can't afford to wait + /// for a worker that's mid-`load_commit_log_page`. The receiver-drop + /// already makes the worker's reply fail, so detaching the handle is + /// safe — the worst case is one extra OS thread until it finishes. + pub(crate) handle: Option>, + /// HEAD oid carried in the most recent snapshot. `ingest_snapshot` + /// compares this against the new snapshot's head to decide whether + /// to trigger `refresh_commit_log_after_head_change`. + pub(crate) last_head_oid: Option, +} + +impl CommitLogPagination { + /// Construct with the config-derived knobs and otherwise default state. + /// Used by `App::new` and the test fixture — `..Default::default()` + /// can't be used here because the type implements `Drop`. + pub fn with_config(page_size: usize, prefetch_threshold: usize) -> Self { + Self { + page_size, + prefetch_threshold, + page_rx: None, + handle: None, + last_head_oid: None, + } + } +} + +impl Drop for CommitLogPagination { + fn drop(&mut self) { + // Drop the receiver first so the worker's next `tx.send` fails + // and the loop exits; then await the thread with a bounded + // timeout — a worker stuck mid-`load_commit_log_page` on libgit2 + // must not freeze app shutdown / repo switch. + drop(self.page_rx.take()); + if let Some(h) = self.handle.take() { + try_timed_join(h, REAP_TIMEOUT); + } + } +} \ No newline at end of file diff --git a/src/app/diff_load.rs b/src/app/diff_load.rs index 5d0803b..54e2d2a 100644 --- a/src/app/diff_load.rs +++ b/src/app/diff_load.rs @@ -1,8 +1,5 @@ -use super::{App, DiffPaneView, FileViewKey, FileViewState, NoticeKind, ViewMode}; -use crate::git::diff::{ - DiffHunk, load_commit_diff, load_commit_file_blob, load_commit_file_diff, load_file_diff, - load_workdir_file, parse_hunk_new_start, -}; +use super::{App, DiffPaneView, FileViewState, NoticeKind, ViewMode}; +use crate::git::diff::{DiffHunk, load_file_diff, parse_hunk_new_start}; /// Post-load behaviour for `apply_diff_result`. Replaces the prior 3-flag /// signature where the combination of `reset_scroll` and `keep_scroll` was @@ -165,46 +162,6 @@ impl App { self.diff.file_view = FileViewState::default(); } - pub(crate) fn current_file_view_key(&self) -> Option { - match self.mode { - ViewMode::Status => { - let path = self.selected_filtered_status_file()?.path.clone(); - Some(FileViewKey::Status(path)) - } - ViewMode::Tree => { - let row = self - .tree_view - .visible_rows() - .into_iter() - .nth(self.tree_view.selected)?; - if row.is_dir { - return None; - } - // Tree files reuse the workdir-file key — the source is the - // same `load_workdir_file` loader as the status preview. - Some(FileViewKey::Status(row.path)) - } - ViewMode::Log => { - if !self.log_view.drill_down { - return None; - } - let oid = self.log_view.commits.get(self.log_view.selected)?.oid; - let file = self - .log_view - .commit_files - .get(self.log_view.file_selected)?; - Some(FileViewKey::Commit { - oid, - path: file.path.clone(), - // Commit deltas carry their single status in the index - // column; `load_commit_file_blob` only needs the Deleted - // case to read from the parent tree. - status: file.index, - }) - } - } - } - /// Pick the new-side starting line of the hunk currently visible at the /// top of the diff viewport. Walks the flat hunk layout (one header row + /// body rows per hunk) and returns the most recent hunk whose header was @@ -226,133 +183,6 @@ impl App { chosen } - pub(crate) fn load_file_view(&mut self, key: FileViewKey) { - let result = match &key { - FileViewKey::Status(path) => self.with_repo(|repo| load_workdir_file(repo, path)), - FileViewKey::Commit { - oid, path, status, .. - } => { - let oid = *oid; - let status = *status; - self.with_repo(|repo| load_commit_file_blob(repo, oid, path, status)) - } - }; - let anchor = self.anchor_for_current_diff(); - let mut fv = FileViewState { - key: Some(key), - anchor_line: anchor, - ..Default::default() - }; - match result { - Ok(content) => { - fv.set_content(content); - // Initial scroll: 2 lines of context above the hunk's new-side - // start line, converted from 1-based to 0-based. Clamp against - // `max_scroll` so a stale anchor past the current file length - // (file truncated since the diff was computed) doesn't open - // the file view on a blank region the user has to page back from. - let initial = anchor - .map(|n| n.saturating_sub(1).saturating_sub(2)) - .unwrap_or(0); - fv.scroll = initial.min(fv.max_scroll()); - } - Err(e) => { - fv.error = Some(e.to_string()); - } - } - self.diff.file_view = fv; - } - - /// Whether `v` currently has a file to open. Mirrors the gates in - /// `toggle_diff_file_view` — Tree mode never toggles, and elsewhere the - /// key must resolve (log view needs a drill-down file selection) — so - /// the hint bar only advertises `v: view file` when a press would act. - pub(crate) fn can_open_file_view(&self) -> bool { - self.mode != ViewMode::Tree && self.current_file_view_key().is_some() - } - - pub fn toggle_diff_file_view(&mut self) { - // Tree mode's right pane is always the raw file preview; the diff and - // split views have no meaning there, so `v`/`s` are no-ops. - if self.mode == ViewMode::Tree { - return; - } - if self.diff.view == DiffPaneView::File { - self.diff.search.clear(); - self.diff.view = DiffPaneView::Diff; - return; - } - let Some(key) = self.current_file_view_key() else { - return; - }; - if self.diff.file_view.key.as_ref() != Some(&key) { - self.load_file_view(key); - } - self.diff.search.clear(); - self.diff.view = DiffPaneView::File; - } - - /// Toggle the side-by-side split view on or off. From any other view - /// (unified diff or file overlay) this switches into `Split`; pressing it - /// again returns to the unified diff. - pub fn toggle_diff_split_view(&mut self) { - if self.mode == ViewMode::Tree { - return; - } - self.diff.view = if self.diff.view == DiffPaneView::Split { - DiffPaneView::Diff - } else { - DiffPaneView::Split - }; - } - - pub(crate) fn load_commit_diff_for_selected(&mut self) { - let (oid, title) = match self.log_view.commits.get(self.log_view.selected) { - Some(entry) => (entry.oid, entry.to_string()), - None => { - self.clear_diff_state(); - self.log_view.diff_title.clear(); - return; - } - }; - let result = self.with_repo(|repo| load_commit_diff(repo, oid)); - if let Err(e) = &result { - tracing::warn!(error = %e, "failed to load commit diff"); - self.raise_notice(NoticeKind::Diff, e.to_string()); - } - self.apply_diff_result(result, DiffApply::ResetWithTitle(&title)); - } - - pub(crate) fn load_file_diff_for_log_file_selected(&mut self) { - let Some((oid, short_id, commit_title)) = self - .log_view - .commits - .get(self.log_view.selected) - .map(|c| (c.oid, c.short_id.clone(), c.to_string())) - else { - self.clear_diff_state(); - self.log_view.diff_title.clear(); - return; - }; - let Some(path) = self - .log_view - .commit_files - .get(self.log_view.file_selected) - .map(|f| f.path.clone()) - else { - self.clear_diff_state(); - self.log_view.diff_title = commit_title; - return; - }; - let title = format!("{short_id} {path}"); - let result = self.with_repo(|repo| load_commit_file_diff(repo, oid, &path)); - if let Err(e) = &result { - tracing::warn!(error = %e, file = %path, "failed to load commit file diff"); - self.raise_notice(NoticeKind::Diff, e.to_string()); - } - self.apply_diff_result(result, DiffApply::ResetWithTitle(&title)); - } - /// Reload the Log view's commit list after the snapshot worker detected a /// HEAD oid change (new commit via the terminal pane, external push, /// amend, branch switch). Captures the current selection/head oids and @@ -374,4 +204,4 @@ impl App { self.cancel_commit_log_page_fetch(); self.spawn_commit_log_refresh_fetch(prior_selected_oid, prior_head_oid); } -} +} \ No newline at end of file diff --git a/src/app/file_view_load.rs b/src/app/file_view_load.rs new file mode 100644 index 0000000..af01b48 --- /dev/null +++ b/src/app/file_view_load.rs @@ -0,0 +1,172 @@ +use super::{App, DiffPaneView, FileViewKey, FileViewState, NoticeKind, ViewMode}; +use super::diff_load::DiffApply; +use crate::git::diff::{load_commit_diff, load_commit_file_blob, load_commit_file_diff, load_workdir_file}; + +impl App { + pub(crate) fn current_file_view_key(&self) -> Option { + match self.mode { + ViewMode::Status => { + let path = self.selected_filtered_status_file()?.path.clone(); + Some(FileViewKey::Status(path)) + } + ViewMode::Tree => { + let row = self + .tree_view + .visible_rows() + .into_iter() + .nth(self.tree_view.selected)?; + if row.is_dir { + return None; + } + // Tree files reuse the workdir-file key — the source is the + // same `load_workdir_file` loader as the status preview. + Some(FileViewKey::Status(row.path)) + } + ViewMode::Log => { + if !self.log_view.drill_down { + return None; + } + let oid = self.log_view.commits.get(self.log_view.selected)?.oid; + let file = self + .log_view + .commit_files + .get(self.log_view.file_selected)?; + Some(FileViewKey::Commit { + oid, + path: file.path.clone(), + // Commit deltas carry their single status in the index + // column; `load_commit_file_blob` only needs the Deleted + // case to read from the parent tree. + status: file.index, + }) + } + } + } + + pub(crate) fn load_file_view(&mut self, key: FileViewKey) { + let result = match &key { + FileViewKey::Status(path) => self.with_repo(|repo| load_workdir_file(repo, path)), + FileViewKey::Commit { + oid, path, status, .. + } => { + let oid = *oid; + let status = *status; + self.with_repo(|repo| load_commit_file_blob(repo, oid, path, status)) + } + }; + let anchor = self.anchor_for_current_diff(); + let mut fv = FileViewState { + key: Some(key), + anchor_line: anchor, + ..Default::default() + }; + match result { + Ok(content) => { + fv.set_content(content); + // Initial scroll: 2 lines of context above the hunk's new-side + // start line, converted from 1-based to 0-based. Clamp against + // `max_scroll` so a stale anchor past the current file length + // (file truncated since the diff was computed) doesn't open + // the file view on a blank region the user has to page back from. + let initial = anchor + .map(|n| n.saturating_sub(1).saturating_sub(2)) + .unwrap_or(0); + fv.scroll = initial.min(fv.max_scroll()); + } + Err(e) => { + fv.error = Some(e.to_string()); + } + } + self.diff.file_view = fv; + } + + /// Whether `v` currently has a file to open. Mirrors the gates in + /// `toggle_diff_file_view` — Tree mode never toggles, and elsewhere the + /// key must resolve (log view needs a drill-down file selection) — so + /// the hint bar only advertises `v: view file` when a press would act. + pub(crate) fn can_open_file_view(&self) -> bool { + self.mode != ViewMode::Tree && self.current_file_view_key().is_some() + } + + pub fn toggle_diff_file_view(&mut self) { + // Tree mode's right pane is always the raw file preview; the diff and + // split views have no meaning there, so `v`/`s` are no-ops. + if self.mode == ViewMode::Tree { + return; + } + if self.diff.view == DiffPaneView::File { + self.diff.search.clear(); + self.diff.view = DiffPaneView::Diff; + return; + } + let Some(key) = self.current_file_view_key() else { + return; + }; + if self.diff.file_view.key.as_ref() != Some(&key) { + self.load_file_view(key); + } + self.diff.search.clear(); + self.diff.view = DiffPaneView::File; + } + + /// Toggle the side-by-side split view on or off. From any other view + /// (unified diff or file overlay) this switches into `Split`; pressing it + /// again returns to the unified diff. + pub fn toggle_diff_split_view(&mut self) { + if self.mode == ViewMode::Tree { + return; + } + self.diff.view = if self.diff.view == DiffPaneView::Split { + DiffPaneView::Diff + } else { + DiffPaneView::Split + }; + } + + pub(crate) fn load_commit_diff_for_selected(&mut self) { + let (oid, title) = match self.log_view.commits.get(self.log_view.selected) { + Some(entry) => (entry.oid, entry.to_string()), + None => { + self.clear_diff_state(); + self.log_view.diff_title.clear(); + return; + } + }; + let result = self.with_repo(|repo| load_commit_diff(repo, oid)); + if let Err(e) = &result { + tracing::warn!(error = %e, "failed to load commit diff"); + self.raise_notice(NoticeKind::Diff, e.to_string()); + } + self.apply_diff_result(result, DiffApply::ResetWithTitle(&title)); + } + + pub(crate) fn load_file_diff_for_log_file_selected(&mut self) { + let Some((oid, short_id, commit_title)) = self + .log_view + .commits + .get(self.log_view.selected) + .map(|c| (c.oid, c.short_id.clone(), c.to_string())) + else { + self.clear_diff_state(); + self.log_view.diff_title.clear(); + return; + }; + let Some(path) = self + .log_view + .commit_files + .get(self.log_view.file_selected) + .map(|f| f.path.clone()) + else { + self.clear_diff_state(); + self.log_view.diff_title = commit_title; + return; + }; + let title = format!("{short_id} {path}"); + let result = self.with_repo(|repo| load_commit_file_diff(repo, oid, &path)); + if let Err(e) = &result { + tracing::warn!(error = %e, file = %path, "failed to load commit file diff"); + self.raise_notice(NoticeKind::Diff, e.to_string()); + } + self.apply_diff_result(result, DiffApply::ResetWithTitle(&title)); + } +} \ No newline at end of file diff --git a/src/app/log_nav.rs b/src/app/log_nav.rs new file mode 100644 index 0000000..acac0c4 --- /dev/null +++ b/src/app/log_nav.rs @@ -0,0 +1,294 @@ +use super::{App, LIST_PAGE_SIZE, ViewMode}; +use crate::git::diff::load_commit_files; + +impl App { + /// Indices into `log_view.commits` that match the active commit search. + /// `0..len` when no query is set (mirrors `filtered_indices`). + pub fn log_commit_filtered_indices(&self) -> &[usize] { + &self.log_view.commits_filter_cache + } + + /// Indices into `log_view.commit_files` that match the active drill-down + /// file search. + pub fn log_file_filtered_indices(&self) -> &[usize] { + &self.log_view.commit_files_filter_cache + } + + /// Snap `log_view.selected` into the current commit filter. If the current + /// selection still matches, leave it; otherwise jump to the first match. + /// Returns whether selection changed so the caller can decide whether to + /// reload the diff. + fn sync_log_commit_selection_to_filter(&mut self) -> bool { + let target = { + let indices = self.log_commit_filtered_indices(); + if indices.is_empty() { + return false; + } + if indices.contains(&self.log_view.selected) { + self.log_view.selected + } else { + indices[0] + } + }; + if target == self.log_view.selected { + false + } else { + self.log_view.selected = target; + self.log_view.commit_scroll_x = 0; + true + } + } + + /// Same as `sync_log_commit_selection_to_filter` but for the drill-down + /// file list. + fn sync_log_file_selection_to_filter(&mut self) -> bool { + let target = { + let indices = self.log_file_filtered_indices(); + if indices.is_empty() { + return false; + } + if indices.contains(&self.log_view.file_selected) { + self.log_view.file_selected + } else { + indices[0] + } + }; + if target == self.log_view.file_selected { + false + } else { + self.log_view.file_selected = target; + self.log_view.file_scroll_x = 0; + true + } + } + + fn refresh_commit_diff_after_filter_change(&mut self) { + let selection_changed = self.sync_log_commit_selection_to_filter(); + if self.log_commit_filtered_indices().is_empty() { + self.clear_diff_state(); + } else if selection_changed || self.diff.hunks.is_empty() { + self.load_commit_diff_for_selected(); + } + } + + fn refresh_file_diff_after_filter_change(&mut self) { + let selection_changed = self.sync_log_file_selection_to_filter(); + if self.log_file_filtered_indices().is_empty() { + self.clear_diff_state(); + } else if selection_changed || self.diff.hunks.is_empty() { + self.load_file_diff_for_log_file_selected(); + } + } + + /// Open the `/` search bar in the active Log sub-view (commit list or + /// drill-down file list). The dispatch matches the visible upper pane. + pub fn start_log_search(&mut self) { + if self.log_view.drill_down { + self.log_view.start_file_search(); + } else { + self.log_view.start_commit_search(); + } + } + + pub fn cancel_log_search(&mut self) { + if self.log_view.drill_down { + self.log_view.cancel_file_search(); + self.refresh_file_diff_after_filter_change(); + } else { + self.log_view.cancel_commit_search(); + self.refresh_commit_diff_after_filter_change(); + // Search ended → prefetch may have been pending; resume if the + // selection now sits near the loaded tail. + self.maybe_prefetch_commit_log(); + } + } + + pub fn confirm_log_search(&mut self) { + if self.log_view.drill_down { + if self.log_view.confirm_file_search() { + self.refresh_file_diff_after_filter_change(); + } + } else { + if self.log_view.confirm_commit_search() { + self.refresh_commit_diff_after_filter_change(); + } + // Resume tail prefetch regardless of whether the query was empty: + // confirm hides the search bar in both branches, so the gate in + // `maybe_prefetch_commit_log` no longer applies. + self.maybe_prefetch_commit_log(); + } + } + + pub fn log_search_push(&mut self, ch: char) { + if self.log_view.drill_down { + self.log_view.file_search_push(ch); + self.refresh_file_diff_after_filter_change(); + } else { + self.log_view.commit_search_push(ch); + self.refresh_commit_diff_after_filter_change(); + } + } + + pub fn log_search_pop(&mut self) { + if self.log_view.drill_down { + self.log_view.file_search_pop(); + self.refresh_file_diff_after_filter_change(); + } else { + self.log_view.commit_search_pop(); + self.refresh_commit_diff_after_filter_change(); + } + } + + /// Dispatches a navigation action to the appropriate log list (commit or file). + /// Returns `true` if the action was handled (i.e. we are in Log mode). + pub(super) fn navigate_log_list(&mut self, commit_nav: fn(&mut Self), file_nav: fn(&mut Self)) -> bool { + if self.mode != ViewMode::Log { + return false; + } + if self.log_view.drill_down { + file_nav(self); + } else { + commit_nav(self); + } + true + } + + pub fn log_drill_in(&mut self) { + let (oid, title) = match self.log_view.commits.get(self.log_view.selected) { + Some(entry) => (entry.oid, entry.to_string()), + None => return, + }; + match self.with_repo(|repo| load_commit_files(repo, oid)) { + Ok(files) => { + self.log_view.set_commit_files(files); + self.log_view.file_selected = 0; + self.log_view.drill_down = true; + if self.log_view.commit_files.is_empty() { + self.clear_diff_state(); + self.log_view.diff_title = title; + } else { + self.load_file_diff_for_log_file_selected(); + } + } + Err(e) => { + tracing::warn!(error = %e, "failed to load commit files"); + } + } + } + + pub fn log_drill_out(&mut self) { + self.log_view.reset_drill_down(); + self.load_commit_diff_for_selected(); + } + + pub fn log_file_select_up(&mut self) { + if self.move_log_file_in_filter(-1) { + self.load_file_diff_for_log_file_selected(); + } + } + + pub fn log_file_select_down(&mut self) { + if self.move_log_file_in_filter(1) { + self.load_file_diff_for_log_file_selected(); + } + } + + pub fn log_file_page_up(&mut self) { + if self.move_log_file_in_filter(-(LIST_PAGE_SIZE as isize)) { + self.load_file_diff_for_log_file_selected(); + } + } + + pub fn log_file_page_down(&mut self) { + if self.move_log_file_in_filter(LIST_PAGE_SIZE as isize) { + self.load_file_diff_for_log_file_selected(); + } + } + + pub fn log_select_up(&mut self) { + if self.move_log_commit_in_filter(-1) { + self.log_view.commit_scroll_x = 0; + self.load_commit_diff_for_selected(); + } + } + + pub fn log_select_down(&mut self) { + if self.move_log_commit_in_filter(1) { + self.log_view.commit_scroll_x = 0; + self.load_commit_diff_for_selected(); + } + self.maybe_prefetch_commit_log(); + } + + pub fn log_page_up(&mut self) { + if self.move_log_commit_in_filter(-(LIST_PAGE_SIZE as isize)) { + self.log_view.commit_scroll_x = 0; + self.load_commit_diff_for_selected(); + } + } + + pub fn log_page_down(&mut self) { + if self.move_log_commit_in_filter(LIST_PAGE_SIZE as isize) { + self.log_view.commit_scroll_x = 0; + self.load_commit_diff_for_selected(); + } + self.maybe_prefetch_commit_log(); + } + + /// Step the commit-list cursor by `delta` positions within the active + /// commit search filter. When the filter holds every commit (empty + /// query), this is identical to the previous `cursor_up`/`cursor_down` + /// behaviour because the cache is built as `0..len`. Returns whether the + /// selection actually moved so callers can decide to reload the diff. + pub(crate) fn move_log_commit_in_filter(&mut self, delta: isize) -> bool { + let resolved = { + let indices = self.log_commit_filtered_indices(); + if indices.is_empty() { + return false; + } + let pos = indices.iter().position(|&i| i == self.log_view.selected); + let new_pos = match pos { + Some(p) => { + let last = indices.len() as isize - 1; + (p as isize + delta).clamp(0, last) as usize + } + None => 0, + }; + indices[new_pos] + }; + if resolved == self.log_view.selected { + false + } else { + self.log_view.selected = resolved; + true + } + } + + /// Same as `move_log_commit_in_filter` but for the drill-down file list. + pub(crate) fn move_log_file_in_filter(&mut self, delta: isize) -> bool { + let resolved = { + let indices = self.log_file_filtered_indices(); + if indices.is_empty() { + return false; + } + let pos = indices + .iter() + .position(|&i| i == self.log_view.file_selected); + let new_pos = match pos { + Some(p) => { + let last = indices.len() as isize - 1; + (p as isize + delta).clamp(0, last) as usize + } + None => 0, + }; + indices[new_pos] + }; + if resolved == self.log_view.file_selected { + false + } else { + self.log_view.file_selected = resolved; + self.log_view.file_scroll_x = 0; + true + } + } +} \ No newline at end of file diff --git a/src/app/navigation.rs b/src/app/navigation.rs index 9d94bab..c534388 100644 --- a/src/app/navigation.rs +++ b/src/app/navigation.rs @@ -1,6 +1,4 @@ use super::{App, ChangedFile, DIFF_PAGE_SIZE, DiffPaneView, Focus, LIST_PAGE_SIZE, ViewMode}; -use crate::git::diff::load_commit_files; -use std::cell::Cell; impl App { pub(crate) fn restore_selection(&mut self, previous_path: Option<&str>) -> Option { @@ -59,85 +57,6 @@ impl App { self.refresh_status_diff_after_filter_change(); } - pub fn file_scroll_left(&mut self) { - let target = self.upper_scroll_x_mut(); - *target = target.saturating_sub(4); - } - - pub fn file_scroll_right(&mut self) { - let max = self.upper_scroll_x_max(); - let target = self.upper_scroll_x_mut(); - *target = target.saturating_add(4).min(max); - } - - fn upper_scroll_x_mut(&mut self) -> &mut usize { - match self.mode { - ViewMode::Status => &mut self.status_view.file_scroll_x, - ViewMode::Tree => &mut self.tree_view.scroll_x, - ViewMode::Log if self.log_view.drill_down => &mut self.log_view.file_scroll_x, - ViewMode::Log => &mut self.log_view.commit_scroll_x, - } - } - - fn upper_scroll_x_max(&self) -> usize { - // Cap at the longest visible entry's char width so we don't drift past - // the last column of any rendered row. Each branch consults a - // length-keyed `Cell` cache so repeated keystrokes don't re-walk the - // full list (and re-count chars per item) every press. - fn cached_max<'a, T: 'a>( - cache: &Cell>, - items: &'a [T], - width_of: impl Fn(&'a T) -> usize, - ) -> usize { - let len = items.len(); - if let Some((cached_len, cached_max)) = cache.get() - && cached_len == len - { - return cached_max; - } - let max = items.iter().map(width_of).max().unwrap_or(0); - cache.set(Some((len, max))); - max - } - match self.mode { - ViewMode::Status => cached_max( - &self.status_view.path_width_cache, - &self.status_view.files, - |f| f.display_path().chars().count(), - ), - // Tree rows are derived (not a stored slice), so cache the max by - // visible-row count directly rather than via `cached_max`. Width = - // indent (depth*2) + 2-char dir/file marker + name char count. - ViewMode::Tree => { - let rows = self.tree_view.visible_rows(); - let len = rows.len(); - if let Some((cached_len, cached_max)) = self.tree_view.row_width_cache.get() - && cached_len == len - { - cached_max - } else { - let max = rows - .iter() - .map(|r| r.depth * 2 + 2 + r.name.chars().count()) - .max() - .unwrap_or(0); - self.tree_view.row_width_cache.set(Some((len, max))); - max - } - } - ViewMode::Log if self.log_view.drill_down => cached_max( - &self.log_view.commit_files_width_cache, - &self.log_view.commit_files, - |f| f.display_path().chars().count(), - ), - ViewMode::Log => cached_max( - &self.log_view.commit_width_cache, - &self.log_view.commits, - |c| c.summary.chars().count(), - ), - } - } - pub(crate) fn selected_filtered_status_path(&self) -> Option { self.selected_filtered_status_file().map(|f| f.path.clone()) } @@ -192,157 +111,6 @@ impl App { } } - /// Indices into `log_view.commits` that match the active commit search. - /// `0..len` when no query is set (mirrors `filtered_indices`). - pub fn log_commit_filtered_indices(&self) -> &[usize] { - &self.log_view.commits_filter_cache - } - - /// Indices into `log_view.commit_files` that match the active drill-down - /// file search. - pub fn log_file_filtered_indices(&self) -> &[usize] { - &self.log_view.commit_files_filter_cache - } - - /// Snap `log_view.selected` into the current commit filter. If the current - /// selection still matches, leave it; otherwise jump to the first match. - /// Returns whether selection changed so the caller can decide whether to - /// reload the diff. - fn sync_log_commit_selection_to_filter(&mut self) -> bool { - let target = { - let indices = self.log_commit_filtered_indices(); - if indices.is_empty() { - return false; - } - if indices.contains(&self.log_view.selected) { - self.log_view.selected - } else { - indices[0] - } - }; - if target == self.log_view.selected { - false - } else { - self.log_view.selected = target; - self.log_view.commit_scroll_x = 0; - true - } - } - - /// Same as `sync_log_commit_selection_to_filter` but for the drill-down - /// file list. - fn sync_log_file_selection_to_filter(&mut self) -> bool { - let target = { - let indices = self.log_file_filtered_indices(); - if indices.is_empty() { - return false; - } - if indices.contains(&self.log_view.file_selected) { - self.log_view.file_selected - } else { - indices[0] - } - }; - if target == self.log_view.file_selected { - false - } else { - self.log_view.file_selected = target; - self.log_view.file_scroll_x = 0; - true - } - } - - fn refresh_commit_diff_after_filter_change(&mut self) { - let selection_changed = self.sync_log_commit_selection_to_filter(); - if self.log_commit_filtered_indices().is_empty() { - self.clear_diff_state(); - } else if selection_changed || self.diff.hunks.is_empty() { - self.load_commit_diff_for_selected(); - } - } - - fn refresh_file_diff_after_filter_change(&mut self) { - let selection_changed = self.sync_log_file_selection_to_filter(); - if self.log_file_filtered_indices().is_empty() { - self.clear_diff_state(); - } else if selection_changed || self.diff.hunks.is_empty() { - self.load_file_diff_for_log_file_selected(); - } - } - - /// Open the `/` search bar in the active Log sub-view (commit list or - /// drill-down file list). The dispatch matches the visible upper pane. - pub fn start_log_search(&mut self) { - if self.log_view.drill_down { - self.log_view.start_file_search(); - } else { - self.log_view.start_commit_search(); - } - } - - pub fn cancel_log_search(&mut self) { - if self.log_view.drill_down { - self.log_view.cancel_file_search(); - self.refresh_file_diff_after_filter_change(); - } else { - self.log_view.cancel_commit_search(); - self.refresh_commit_diff_after_filter_change(); - // Search ended → prefetch may have been pending; resume if the - // selection now sits near the loaded tail. - self.maybe_prefetch_commit_log(); - } - } - - pub fn confirm_log_search(&mut self) { - if self.log_view.drill_down { - if self.log_view.confirm_file_search() { - self.refresh_file_diff_after_filter_change(); - } - } else { - if self.log_view.confirm_commit_search() { - self.refresh_commit_diff_after_filter_change(); - } - // Resume tail prefetch regardless of whether the query was empty: - // confirm hides the search bar in both branches, so the gate in - // `maybe_prefetch_commit_log` no longer applies. - self.maybe_prefetch_commit_log(); - } - } - - pub fn log_search_push(&mut self, ch: char) { - if self.log_view.drill_down { - self.log_view.file_search_push(ch); - self.refresh_file_diff_after_filter_change(); - } else { - self.log_view.commit_search_push(ch); - self.refresh_commit_diff_after_filter_change(); - } - } - - pub fn log_search_pop(&mut self) { - if self.log_view.drill_down { - self.log_view.file_search_pop(); - self.refresh_file_diff_after_filter_change(); - } else { - self.log_view.commit_search_pop(); - self.refresh_commit_diff_after_filter_change(); - } - } - - /// Dispatches a navigation action to the appropriate log list (commit or file). - /// Returns `true` if the action was handled (i.e. we are in Log mode). - fn navigate_log_list(&mut self, commit_nav: fn(&mut Self), file_nav: fn(&mut Self)) -> bool { - if self.mode != ViewMode::Log { - return false; - } - if self.log_view.drill_down { - file_nav(self); - } else { - commit_nav(self); - } - true - } - /// Move `selected` by `delta` positions within the active filter view. /// Handles both empty-query (full file list) and non-empty (filtered subset) /// cases uniformly. @@ -479,145 +247,4 @@ impl App { Focus::Terminal => {} } } - - // ── Log view ────────────────────────────────────────────────── - - pub fn log_drill_in(&mut self) { - let (oid, title) = match self.log_view.commits.get(self.log_view.selected) { - Some(entry) => (entry.oid, entry.to_string()), - None => return, - }; - match self.with_repo(|repo| load_commit_files(repo, oid)) { - Ok(files) => { - self.log_view.set_commit_files(files); - self.log_view.file_selected = 0; - self.log_view.drill_down = true; - if self.log_view.commit_files.is_empty() { - self.clear_diff_state(); - self.log_view.diff_title = title; - } else { - self.load_file_diff_for_log_file_selected(); - } - } - Err(e) => { - tracing::warn!(error = %e, "failed to load commit files"); - } - } - } - - pub fn log_drill_out(&mut self) { - self.log_view.reset_drill_down(); - self.load_commit_diff_for_selected(); - } - - pub fn log_file_select_up(&mut self) { - if self.move_log_file_in_filter(-1) { - self.load_file_diff_for_log_file_selected(); - } - } - - pub fn log_file_select_down(&mut self) { - if self.move_log_file_in_filter(1) { - self.load_file_diff_for_log_file_selected(); - } - } - - pub fn log_file_page_up(&mut self) { - if self.move_log_file_in_filter(-(LIST_PAGE_SIZE as isize)) { - self.load_file_diff_for_log_file_selected(); - } - } - - pub fn log_file_page_down(&mut self) { - if self.move_log_file_in_filter(LIST_PAGE_SIZE as isize) { - self.load_file_diff_for_log_file_selected(); - } - } - - pub fn log_select_up(&mut self) { - if self.move_log_commit_in_filter(-1) { - self.log_view.commit_scroll_x = 0; - self.load_commit_diff_for_selected(); - } - } - - pub fn log_select_down(&mut self) { - if self.move_log_commit_in_filter(1) { - self.log_view.commit_scroll_x = 0; - self.load_commit_diff_for_selected(); - } - self.maybe_prefetch_commit_log(); - } - - pub fn log_page_up(&mut self) { - if self.move_log_commit_in_filter(-(LIST_PAGE_SIZE as isize)) { - self.log_view.commit_scroll_x = 0; - self.load_commit_diff_for_selected(); - } - } - - pub fn log_page_down(&mut self) { - if self.move_log_commit_in_filter(LIST_PAGE_SIZE as isize) { - self.log_view.commit_scroll_x = 0; - self.load_commit_diff_for_selected(); - } - self.maybe_prefetch_commit_log(); - } - - /// Step the commit-list cursor by `delta` positions within the active - /// commit search filter. When the filter holds every commit (empty - /// query), this is identical to the previous `cursor_up`/`cursor_down` - /// behaviour because the cache is built as `0..len`. Returns whether the - /// selection actually moved so callers can decide to reload the diff. - pub(crate) fn move_log_commit_in_filter(&mut self, delta: isize) -> bool { - let resolved = { - let indices = self.log_commit_filtered_indices(); - if indices.is_empty() { - return false; - } - let pos = indices.iter().position(|&i| i == self.log_view.selected); - let new_pos = match pos { - Some(p) => { - let last = indices.len() as isize - 1; - (p as isize + delta).clamp(0, last) as usize - } - None => 0, - }; - indices[new_pos] - }; - if resolved == self.log_view.selected { - false - } else { - self.log_view.selected = resolved; - true - } - } - - /// Same as `move_log_commit_in_filter` but for the drill-down file list. - pub(crate) fn move_log_file_in_filter(&mut self, delta: isize) -> bool { - let resolved = { - let indices = self.log_file_filtered_indices(); - if indices.is_empty() { - return false; - } - let pos = indices - .iter() - .position(|&i| i == self.log_view.file_selected); - let new_pos = match pos { - Some(p) => { - let last = indices.len() as isize - 1; - (p as isize + delta).clamp(0, last) as usize - } - None => 0, - }; - indices[new_pos] - }; - if resolved == self.log_view.file_selected { - false - } else { - self.log_view.file_selected = resolved; - self.log_view.file_scroll_x = 0; - true - } - } -} +} \ No newline at end of file diff --git a/src/app/scroll.rs b/src/app/scroll.rs new file mode 100644 index 0000000..79258b8 --- /dev/null +++ b/src/app/scroll.rs @@ -0,0 +1,83 @@ +use super::{App, ViewMode}; +use std::cell::Cell; + +impl App { + pub fn file_scroll_left(&mut self) { + let target = self.upper_scroll_x_mut(); + *target = target.saturating_sub(4); + } + + pub fn file_scroll_right(&mut self) { + let max = self.upper_scroll_x_max(); + let target = self.upper_scroll_x_mut(); + *target = target.saturating_add(4).min(max); + } + + pub(crate) fn upper_scroll_x_mut(&mut self) -> &mut usize { + match self.mode { + ViewMode::Status => &mut self.status_view.file_scroll_x, + ViewMode::Tree => &mut self.tree_view.scroll_x, + ViewMode::Log if self.log_view.drill_down => &mut self.log_view.file_scroll_x, + ViewMode::Log => &mut self.log_view.commit_scroll_x, + } + } + + fn upper_scroll_x_max(&self) -> usize { + // Cap at the longest visible entry's char width so we don't drift past + // the last column of any rendered row. Each branch consults a + // length-keyed `Cell` cache so repeated keystrokes don't re-walk the + // full list (and re-count chars per item) every press. + fn cached_max<'a, T: 'a>( + cache: &Cell>, + items: &'a [T], + width_of: impl Fn(&'a T) -> usize, + ) -> usize { + let len = items.len(); + if let Some((cached_len, cached_max)) = cache.get() + && cached_len == len + { + return cached_max; + } + let max = items.iter().map(width_of).max().unwrap_or(0); + cache.set(Some((len, max))); + max + } + match self.mode { + ViewMode::Status => cached_max( + &self.status_view.path_width_cache, + &self.status_view.files, + |f| f.display_path().chars().count(), + ), + // Tree rows are derived (not a stored slice), so cache the max by + // visible-row count directly rather than via `cached_max`. Width = + // indent (depth*2) + 2-char dir/file marker + name char count. + ViewMode::Tree => { + let rows = self.tree_view.visible_rows(); + let len = rows.len(); + if let Some((cached_len, cached_max)) = self.tree_view.row_width_cache.get() + && cached_len == len + { + cached_max + } else { + let max = rows + .iter() + .map(|r| r.depth * 2 + 2 + r.name.chars().count()) + .max() + .unwrap_or(0); + self.tree_view.row_width_cache.set(Some((len, max))); + max + } + } + ViewMode::Log if self.log_view.drill_down => cached_max( + &self.log_view.commit_files_width_cache, + &self.log_view.commit_files, + |f| f.display_path().chars().count(), + ), + ViewMode::Log => cached_max( + &self.log_view.commit_width_cache, + &self.log_view.commits, + |c| c.summary.chars().count(), + ), + } + } +} \ No newline at end of file diff --git a/src/app/tests/auto_follow.rs b/src/app/tests/auto_follow.rs new file mode 100644 index 0000000..feaa1a5 --- /dev/null +++ b/src/app/tests/auto_follow.rs @@ -0,0 +1,225 @@ +use super::*; + +fn snapshot_with(paths: &[&str]) -> RepoSnapshot { + RepoSnapshot { + files: paths + .iter() + .map(|p| ChangedFile::unstaged_only((*p).to_string(), StatusKind::Modified)) + .collect(), + tracking: None, + head_oid: None, + branch_name: None, + } +} + +#[test] +fn ingest_snapshot_populates_hot_table_from_mtimes() { + let mut app = app_with_files(vec![]); + let snap = snapshot_with(&["a.rs", "b.rs"]); + let now = SystemTime::now(); + let mtimes = HashMap::from([ + ("a.rs".to_string(), now), + ("b.rs".to_string(), now - Duration::from_secs(5)), + ]); + + app.ingest_snapshot(snap, mtimes); + + assert_eq!(app.status_view.hot_table.len(), 2); + assert!(app.status_view.hot_table.contains_key("a.rs")); + assert!(app.status_view.hot_table.contains_key("b.rs")); +} + +#[test] +fn merge_hot_table_drops_paths_missing_from_new_snapshot() { + let mut app = app_with_files(vec![]); + let now = SystemTime::now(); + + app.ingest_snapshot( + snapshot_with(&["a.rs"]), + HashMap::from([("a.rs".to_string(), now)]), + ); + assert!(app.status_view.hot_table.contains_key("a.rs")); + + app.ingest_snapshot(snapshot_with(&["b.rs"]), HashMap::new()); + assert!(!app.status_view.hot_table.contains_key("a.rs")); + assert!(!app.status_view.hot_table.contains_key("b.rs")); +} + +#[test] +fn merge_hot_table_replaces_only_when_newer() { + let mut app = app_with_files(vec![]); + let old = SystemTime::UNIX_EPOCH + Duration::from_secs(100); + let newer = SystemTime::UNIX_EPOCH + Duration::from_secs(200); + + app.ingest_snapshot( + snapshot_with(&["a.rs"]), + HashMap::from([("a.rs".to_string(), newer)]), + ); + app.ingest_snapshot( + snapshot_with(&["a.rs"]), + HashMap::from([("a.rs".to_string(), old)]), + ); + + // The earlier mtime must not overwrite the newer observation; a + // rename-from-stash scenario can resurrect older mtimes for the + // same path and would otherwise demote a fresh edit to cool. + assert_eq!(app.status_view.hot_table.get("a.rs"), Some(&newer)); +} + +#[test] +fn auto_follow_selects_freshest_hot_file_when_idle() { + let mut app = app_with_files(vec!["a.rs", "b.rs"]); + app.status_view.selected = 0; + let now = SystemTime::now(); + + app.ingest_snapshot( + snapshot_with(&["a.rs", "b.rs"]), + HashMap::from([ + ("a.rs".to_string(), now - Duration::from_secs(5)), + ("b.rs".to_string(), now), + ]), + ); + + // b.rs is fresher and the user is idle (last_manual_nav_at = None), + // so selection must move from a.rs to b.rs. + assert_eq!(app.status_view.selected, 1); + assert_eq!(app.auto_follow.followed_path.as_deref(), Some("b.rs")); +} + +#[test] +fn auto_follow_skipped_when_user_recently_navigated() { + let mut app = app_with_files(vec!["a.rs", "b.rs"]); + app.status_view.selected = 0; + app.auto_follow.last_manual_nav_at = Some(Instant::now()); + let now = SystemTime::now(); + + app.ingest_snapshot( + snapshot_with(&["a.rs", "b.rs"]), + HashMap::from([("b.rs".to_string(), now)]), + ); + + assert_eq!(app.status_view.selected, 0); + assert!(app.auto_follow.followed_path.is_none()); +} + +#[test] +fn auto_follow_skipped_when_focus_not_filelist() { + let mut app = app_with_files(vec!["a.rs", "b.rs"]); + app.focus = Focus::DiffViewer; + app.status_view.selected = 0; + let now = SystemTime::now(); + + app.ingest_snapshot( + snapshot_with(&["a.rs", "b.rs"]), + HashMap::from([("b.rs".to_string(), now)]), + ); + + assert_eq!(app.status_view.selected, 0); + assert!(app.auto_follow.followed_path.is_none()); +} + +#[test] +fn auto_follow_skipped_when_disabled_in_config() { + let mut app = app_with_files(vec!["a.rs", "b.rs"]); + app.cfg_agent_indicator.auto_follow = false; + app.status_view.selected = 0; + let now = SystemTime::now(); + + app.ingest_snapshot( + snapshot_with(&["a.rs", "b.rs"]), + HashMap::from([("b.rs".to_string(), now)]), + ); + + assert_eq!(app.status_view.selected, 0); +} + +#[test] +fn auto_follow_skipped_when_freshest_is_already_selected() { + let mut app = app_with_files(vec!["a.rs", "b.rs"]); + app.status_view.selected = 1; + let now = SystemTime::now(); + + app.ingest_snapshot( + snapshot_with(&["a.rs", "b.rs"]), + HashMap::from([("b.rs".to_string(), now)]), + ); + + // Selection already points to b.rs — no need to steer or arm the + // "already followed here" guard. + assert_eq!(app.status_view.selected, 1); + assert!(app.auto_follow.followed_path.is_none()); +} + +#[test] +fn select_down_marks_user_active_when_focus_is_filelist() { + let mut app = app_with_files(vec!["a.rs", "b.rs"]); + app.focus = Focus::FileList; + app.auto_follow.followed_path = Some("a.rs".to_string()); + + app.select_down(); + + assert!(app.auto_follow.last_manual_nav_at.is_some()); + assert!(app.auto_follow.followed_path.is_none()); +} + +#[test] +fn select_down_does_not_mark_when_focus_is_diff() { + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::DiffViewer; + + app.select_down(); + + assert!(app.auto_follow.last_manual_nav_at.is_none()); +} + +#[test] +fn auto_follow_respects_search_filter() { + let mut app = app_with_files(vec!["alpha.rs", "beta.rs"]); + app.status_view.search_query.set("alpha"); + app.status_view.recompute_filter(); + app.status_view.selected = 0; // alpha.rs (the only filtered entry) + let now = SystemTime::now(); + + app.ingest_snapshot( + snapshot_with(&["alpha.rs", "beta.rs"]), + HashMap::from([ + ("alpha.rs".to_string(), now - Duration::from_secs(5)), + ("beta.rs".to_string(), now), + ]), + ); + + // beta.rs is fresher but filtered out, so auto-follow must not + // jump to a row the user cannot even see. + assert_eq!(app.status_view.selected, 0); +} + +#[test] +fn auto_follow_excludes_future_mtime_clock_skew() { + // Regression for 962bde2: a file with mtime ahead of `now` (NFS + // clock skew, files copied from a host with a wrong clock) used + // to pin auto-follow forever because the inflated timestamp + // beat every other candidate's `mtime > bm` comparison. + // Future-stamped files must be excluded from consideration. + let mut app = app_with_files(vec!["bogus.rs", "real.rs"]); + app.status_view.selected = 0; + let now = SystemTime::now(); + + app.ingest_snapshot( + snapshot_with(&["bogus.rs", "real.rs"]), + HashMap::from([ + ("bogus.rs".to_string(), now + Duration::from_secs(3600)), + ("real.rs".to_string(), now - Duration::from_secs(2)), + ]), + ); + + // real.rs (the only candidate with a sane timestamp) must win, + // and bogus.rs must not be recorded as the steered path. + let real_idx = app + .status_view + .files + .iter() + .position(|f| f.path == "real.rs") + .expect("real.rs must be in the file list"); + assert_eq!(app.status_view.selected, real_idx); + assert_eq!(app.auto_follow.followed_path.as_deref(), Some("real.rs")); +} \ No newline at end of file diff --git a/src/app/tests/clamp_pane.rs b/src/app/tests/clamp_pane.rs new file mode 100644 index 0000000..7e732b0 --- /dev/null +++ b/src/app/tests/clamp_pane.rs @@ -0,0 +1,33 @@ +use super::*; + +#[test] +fn clamp_active_pane_preserves_non_terminal_focus_on_last_exit() { + // Regression for 56ced5f: when the last terminal pane self-exits + // (Ctrl+D in the only shell), focus that wasn't on Terminal must + // stay put. Previously the clamp unconditionally redirected to + // DiffViewer, yanking focus away from a user reading the diff. + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::FileList; + // No panes registered — simulate "last pane exited" path. + app.terminal.panes.clear(); + + app.clamp_active_pane_after_removal(); + + assert_eq!(app.focus, Focus::FileList); + assert_eq!(app.terminal.active, 0); + assert!(!app.terminal.fullscreen.fills_body()); +} + +#[test] +fn clamp_active_pane_redirects_when_focus_was_terminal() { + // Symmetric case: if focus *was* Terminal and the last pane + // exits, we need to redirect to a non-terminal pane (DiffViewer) + // so the user can still drive the UI. + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::Terminal; + app.terminal.panes.clear(); + + app.clamp_active_pane_after_removal(); + + assert_eq!(app.focus, Focus::DiffViewer); +} \ No newline at end of file diff --git a/src/app/tests/commit_log.rs b/src/app/tests/commit_log.rs new file mode 100644 index 0000000..537f9bd --- /dev/null +++ b/src/app/tests/commit_log.rs @@ -0,0 +1,270 @@ +use super::*; + +fn fake_entry(time: i64) -> CommitEntry { + CommitEntry::new( + git2::Oid::ZERO_SHA1, + "deadbee".to_string(), + format!("c{time}"), + "T".to_string(), + time, + ) +} + +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; + let commits: Vec<_> = (0..entries).map(|i| fake_entry(i as i64)).collect(); + app.log_view.set_commits(commits); + app +} + +#[test] +fn maybe_prefetch_no_ops_in_status_mode() { + let mut app = seed_log_app(10, 5, 5); + app.mode = ViewMode::Status; + app.log_view.selected = 9; + + app.maybe_prefetch_commit_log(); + + assert!(!app.log_view.pending_fetch); + assert!(app.pagination.page_rx.is_none()); +} + +#[test] +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()); +} + +#[test] +fn maybe_prefetch_no_ops_when_fully_loaded() { + let mut app = seed_log_app(10, 5, 5); + app.log_view.fully_loaded = true; + app.log_view.selected = 9; + + app.maybe_prefetch_commit_log(); + + assert!(!app.log_view.pending_fetch); + assert!(app.pagination.page_rx.is_none()); +} + +#[test] +fn maybe_prefetch_no_ops_when_far_from_tail() { + // 10 loaded, threshold 3 — selected at 5 is 5 rows from tail, no prefetch. + let mut app = seed_log_app(10, 5, 3); + app.log_view.selected = 5; + + app.maybe_prefetch_commit_log(); + + assert!(!app.log_view.pending_fetch); + assert!(app.pagination.page_rx.is_none()); +} + +#[test] +fn maybe_prefetch_triggers_when_near_tail() { + // 10 loaded, threshold 5 — selected at 6 is within 4 rows of the tail. + let (dir, path) = make_repo(); + std::fs::write(Path::new(&path).join("a"), "x").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "c"]); + let mut app = seed_log_app(10, 5, 5); + app.repo_path = path.clone(); + app.log_view.selected = 6; + + app.maybe_prefetch_commit_log(); + + assert!(app.log_view.pending_fetch); + assert!(app.pagination.page_rx.is_some()); + + // 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(); + drop(dir); +} + +#[test] +fn maybe_prefetch_suppresses_duplicate_pending() { + let (dir, path) = make_repo(); + std::fs::write(Path::new(&path).join("a"), "x").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "c"]); + let mut app = seed_log_app(10, 5, 5); + app.repo_path = path.clone(); + 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()); + + 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); + + let rx = app.pagination.page_rx.take().unwrap(); + let _ = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + 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()); +} + +#[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); +} + +#[test] +fn refresh_after_head_change_prepends_new_commit() { + let (dir, path) = make_repo(); + std::fs::write(Path::new(&path).join("a"), "1").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "c0"]); + std::fs::write(Path::new(&path).join("a"), "2").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "c1"]); + + let mut app = app_with_files(vec![]); + app.repo_path = path.clone(); + app.mode = ViewMode::Log; + // Simulate having loaded the commit list when c0 was still HEAD. + app.log_view + .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()[1..].to_vec()); + let prior_oid = app.log_view.commits.first().unwrap().oid; + assert_eq!(app.log_view.commits.len(), 1); + app.log_view.selected = 0; + + app.refresh_commit_log_after_head_change(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); + + // The fresh c1 commit is prepended; selection shifts so the user keeps + // looking at c0. + assert_eq!(app.log_view.commits.len(), 2); + assert_eq!(app.log_view.commits[1].oid, prior_oid); + assert_eq!(app.log_view.selected, 1); + drop(dir); +} + +#[test] +fn refresh_after_head_change_keeps_merged_side_branch_commits() { + let (dir, path) = make_repo(); + std::fs::write(Path::new(&path).join("base"), "0").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "c0"]); + + run_git(&path, &["checkout", "-b", "feature"]); + std::fs::write(Path::new(&path).join("feature"), "feature").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "feature"]); + + run_git(&path, &["checkout", "-"]); + std::fs::write(Path::new(&path).join("main"), "main").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "c1"]); + + let mut app = app_with_files(vec![]); + app.repo_path = path.clone(); + app.mode = ViewMode::Log; + app.log_view + .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); + assert_eq!(app.log_view.commits.len(), 2); + assert_eq!(app.log_view.commits[0].summary, "c1"); + + run_git( + &path, + &["merge", "--no-ff", "feature", "-m", "merge feature"], + ); + + app.refresh_commit_log_after_head_change(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); + + let summaries: Vec<_> = app + .log_view + .commits + .iter() + .map(|c| c.summary.as_str()) + .collect(); + assert!( + summaries.contains(&"feature"), + "merged side-branch commit was dropped: {summaries:?}" + ); + assert_eq!(app.log_view.commits.len(), 4); + drop(dir); +} + +#[test] +fn refresh_after_head_change_resets_on_divergence() { + let (dir, path) = make_repo(); + std::fs::write(Path::new(&path).join("a"), "1").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "c0"]); + + let mut app = app_with_files(vec![]); + app.repo_path = path.clone(); + app.mode = ViewMode::Log; + // Pretend a prior list whose head no longer exists in the repo — + // simulates rebase/reset/branch switch that drops the old chain. + let ghost_oid = git2::Oid::from_str("0123456789abcdef0123456789abcdef01234567").unwrap(); + app.log_view.set_commits(vec![CommitEntry::new( + ghost_oid, + "012345".to_string(), + "vanished".to_string(), + "T".to_string(), + 0, + )]); + app.log_view.selected = 0; + + app.refresh_commit_log_after_head_change(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); + + // c0 from the actual repo replaces the ghost entry. + assert_eq!(app.log_view.commits.len(), 1); + assert_ne!(app.log_view.commits[0].oid, ghost_oid); + assert_eq!(app.log_view.selected, 0); + drop(dir); +} \ No newline at end of file diff --git a/src/app/tests/diff_file_view.rs b/src/app/tests/diff_file_view.rs new file mode 100644 index 0000000..9082aa3 --- /dev/null +++ b/src/app/tests/diff_file_view.rs @@ -0,0 +1,131 @@ +use super::*; + +#[test] +fn keep_scroll_clamps_when_new_diff_is_shorter() { + let mut app = app_with_files(vec!["a.rs"]); + // Seed a long diff and put scroll near the bottom. + app.diff.hunks = vec![ + context_hunk(&["l1", "l2", "l3", "l4", "l5"]), + context_hunk(&["l6", "l7", "l8"]), + ]; + app.diff.scroll = app.diff.max_scroll(); + let prev_scroll = app.diff.scroll; + assert!(prev_scroll > 1); + + // Apply a much shorter diff with KeepScroll; scroll must clamp. + let shorter = vec![context_hunk(&["only"])]; + app.apply_diff_result(Ok(shorter), DiffApply::KeepScroll(prev_scroll)); + assert!( + app.diff.scroll <= app.diff.max_scroll(), + "scroll {} exceeded max {}", + app.diff.scroll, + app.diff.max_scroll() + ); +} + +#[test] +fn toggle_diff_file_view_ignores_selection_outside_filter() { + let mut app = app_with_files(vec!["alpha.rs", "bravo.rs"]); + app.status_view.search_query.set("alpha"); + app.status_view.recompute_filter(); + // selected points outside the filter — toggle must refuse to open + // a file view rather than loading the hidden entry. + app.status_view.selected = 1; + app.toggle_diff_file_view(); + assert_eq!(app.diff.view, DiffPaneView::Diff); + assert!(app.diff.file_view.key.is_none()); +} + +#[test] +fn toggle_diff_split_view_round_trips_and_overrides_file_view() { + let mut app = app_with_files(vec!["a.rs"]); + + // Diff → Split → Diff. + app.toggle_diff_split_view(); + assert_eq!(app.diff.view, DiffPaneView::Split); + app.toggle_diff_split_view(); + assert_eq!(app.diff.view, DiffPaneView::Diff); + + // From the file overlay, the split toggle switches straight to Split + // rather than back to the unified diff. + app.diff.view = DiffPaneView::File; + app.toggle_diff_split_view(); + assert_eq!(app.diff.view, DiffPaneView::Split); +} + +#[test] +fn keep_scroll_preserves_open_file_view() { + let mut app = app_with_files(vec!["a.rs"]); + app.diff.hunks = vec![context_hunk(&["l1", "l2"])]; + app.diff.scroll = 1; + app.diff.file_view = seeded_file_view("a.rs"); + app.diff.view = DiffPaneView::File; + + // Same file refresh through KeepScroll must leave the file view + // alone — only Reset paths should invalidate it. + let fresh = vec![context_hunk(&["l1", "l2", "l3"])]; + app.apply_diff_result(Ok(fresh), DiffApply::KeepScroll(app.diff.scroll)); + + assert_eq!(app.diff.view, DiffPaneView::File); + assert_eq!( + app.diff.file_view.key, + Some(FileViewKey::Status("a.rs".into())) + ); + assert_eq!(app.diff.file_view.scroll, 1); + assert_eq!(app.diff.file_view.scroll_x, 4); +} + +#[test] +fn clear_diff_state_invalidates_open_file_view() { + let mut app = app_with_files(vec!["a.rs"]); + app.diff.hunks = vec![context_hunk(&["l1"])]; + app.diff.file_view = seeded_file_view("a.rs"); + app.diff.view = DiffPaneView::File; + + // toggle_mode and other reset paths route through clear_diff_state + // — that single call must wipe the file view to its default. + app.clear_diff_state(); + + assert_eq!(app.diff.view, DiffPaneView::Diff); + assert!(app.diff.file_view.key.is_none()); + assert!(app.diff.file_view.content.is_empty()); + assert_eq!(app.diff.file_view.scroll, 0); + assert_eq!(app.diff.file_view.scroll_x, 0); +} + +#[test] +fn snapshot_refresh_with_no_filter_matches_clears_file_view() { + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_with_files(vec!["bar.rs"]) + }; + app.status_view.search_query.set("bar"); + app.status_view.recompute_filter(); + app.diff.hunks = vec![context_hunk(&["stale"])]; + app.diff.file_view = seeded_file_view("bar.rs"); + app.diff.view = DiffPaneView::File; + + tx.send(SnapshotMsg::Ok( + RepoSnapshot { + files: vec![ChangedFile::unstaged_only( + "aaa.rs".to_string(), + StatusKind::Modified, + )], + tracking: None, + head_oid: None, + branch_name: None, + }, + HashMap::new(), + )) + .unwrap(); + app.poll_snapshot(); + + // No filter matches the new snapshot, so the diff and file view + // both need to drop their stale handles on the gone path. + assert!(app.filtered_indices().is_empty()); + assert!(app.diff.hunks.is_empty()); + assert_eq!(app.diff.view, DiffPaneView::Diff); + assert!(app.diff.file_view.key.is_none()); +} \ No newline at end of file diff --git a/src/app/tests/fullscreen.rs b/src/app/tests/fullscreen.rs new file mode 100644 index 0000000..4308846 --- /dev/null +++ b/src/app/tests/fullscreen.rs @@ -0,0 +1,278 @@ +use super::*; + +#[test] +fn focus_list_jumps_and_exits_competing_fullscreens() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + app.focus = Focus::Terminal; + app.toggle_terminal_fullscreen(); + assert!(app.terminal.fullscreen.fills_body()); + + app.focus_list(); + + assert_eq!(app.focus, Focus::FileList); + assert!(!app.terminal.fullscreen.fills_body()); + assert!(!app.diff.fullscreen); +} + +#[test] +fn focus_diff_jumps_and_exits_competing_fullscreens() { + let mut app = app_with_files(vec![]); + app.toggle_list_fullscreen(); + assert!(app.list_fullscreen); + + app.focus_diff(); + + assert_eq!(app.focus, Focus::DiffViewer); + assert!(!app.list_fullscreen); + assert!(!app.terminal.fullscreen.fills_body()); +} + +#[test] +fn switch_pane_exits_diff_fullscreen() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + app.toggle_diff_fullscreen(); + assert!(app.diff.fullscreen); + + app.switch_pane(0); + + assert!(!app.diff.fullscreen); + assert_eq!(app.focus, Focus::Terminal); + assert_eq!(app.terminal.active, 0); +} + +#[test] +fn toggle_fullscreen_switches_focus_to_terminal() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + assert_eq!(app.focus, Focus::FileList); + + app.toggle_terminal_fullscreen(); + + assert!(app.terminal.fullscreen.fills_body()); + assert_eq!(app.focus, Focus::Terminal); +} + +#[test] +fn toggle_fullscreen_noop_with_no_panes() { + let mut app = app_with_files(vec![]); + assert!(app.terminal.panes.is_empty()); + + app.toggle_terminal_fullscreen(); + + assert!(!app.terminal.fullscreen.fills_body()); +} + +#[test] +fn toggle_terminal_fullscreen_cycles_off_grid_zoom_off_with_multiple_panes() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![ + PaneInfo { id: 1, title: "a".into() }, + PaneInfo { id: 2, title: "b".into() }, + ]; + app.focus = Focus::Terminal; + assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Off); + + app.toggle_terminal_fullscreen(); + assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Grid); + + app.toggle_terminal_fullscreen(); + assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Zoom); + // Zoom pins the visible window to exactly the active pane. + assert_eq!(app.terminal.max_visible(), 1); + + app.toggle_terminal_fullscreen(); + assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Off); +} + +#[test] +fn closing_pane_normalizes_zoom_to_grid_when_one_pane_remains() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![ + PaneInfo { id: 1, title: "a".into() }, + PaneInfo { id: 2, title: "b".into() }, + ]; + app.focus = Focus::Terminal; + app.toggle_terminal_fullscreen(); // Grid + app.toggle_terminal_fullscreen(); // Zoom + assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Zoom); + + // One pane left: Zoom is indistinguishable from Grid, so it normalizes. + app.terminal.panes.pop(); + app.clamp_active_pane_after_removal(); + + assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Grid); +} + +#[test] +fn toggle_terminal_fullscreen_skips_zoom_with_single_pane() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + app.focus = Focus::Terminal; + + app.toggle_terminal_fullscreen(); + assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Grid); + + // With a lone pane Grid and Zoom look identical, so the cycle collapses + // straight back to Off rather than stopping at an indistinguishable Zoom. + app.toggle_terminal_fullscreen(); + assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Off); +} + +#[test] +fn toggle_terminal_fullscreen_skips_zoom_when_grid_cap_is_one() { + // Even with multiple panes, a fullscreen grid capped at 1 shows a + // single pane, so Grid and Zoom are indistinguishable and Zoom is + // skipped — the skip must not assume `max_visible_fullscreen >= 2`. + let mut app = app_with_files(vec![]); + app.terminal.max_visible_fullscreen = 1; + app.terminal.panes = vec![ + PaneInfo { id: 1, title: "a".into() }, + PaneInfo { id: 2, title: "b".into() }, + ]; + app.focus = Focus::Terminal; + + app.toggle_terminal_fullscreen(); + assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Grid); + + app.toggle_terminal_fullscreen(); + assert_eq!(app.terminal.fullscreen, TerminalFullscreen::Off); +} + +#[test] +fn toggle_diff_fullscreen_sets_flag_and_focuses_diff_viewer() { + let mut app = app_with_files(vec![]); + assert_eq!(app.focus, Focus::FileList); + + app.toggle_diff_fullscreen(); + + assert!(app.diff.fullscreen); + assert_eq!(app.focus, Focus::DiffViewer); + + app.toggle_diff_fullscreen(); + + assert!(!app.diff.fullscreen); + // Exiting zoom leaves focus on DiffViewer (no reason to bounce back). + assert_eq!(app.focus, Focus::DiffViewer); +} + +#[test] +fn toggle_diff_fullscreen_exits_terminal_fullscreen() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + app.toggle_terminal_fullscreen(); + assert!(app.terminal.fullscreen.fills_body()); + + app.toggle_diff_fullscreen(); + + assert!(app.diff.fullscreen); + assert!(!app.terminal.fullscreen.fills_body()); + assert_eq!(app.focus, Focus::DiffViewer); +} + +#[test] +fn toggle_terminal_fullscreen_exits_diff_fullscreen() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + app.toggle_diff_fullscreen(); + assert!(app.diff.fullscreen); + + app.toggle_terminal_fullscreen(); + + assert!(app.terminal.fullscreen.fills_body()); + assert!(!app.diff.fullscreen); + assert_eq!(app.focus, Focus::Terminal); +} + +#[test] +fn cycle_focus_is_noop_in_diff_fullscreen() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + app.toggle_diff_fullscreen(); + assert_eq!(app.focus, Focus::DiffViewer); + + app.cycle_focus_forward(); + assert_eq!(app.focus, Focus::DiffViewer); + + app.cycle_focus_backward(); + assert_eq!(app.focus, Focus::DiffViewer); +} + +#[test] +fn cycle_focus_forward_through_terminal_panes_slides_visible_window() { + let mut app = app_with_files(vec![]); + app.terminal.max_visible_normal = 4; + app.terminal.panes = (0..7) + .map(|i| PaneInfo { + id: i + 1, + title: format!("shell {}", i + 1), + }) + .collect(); + app.focus = Focus::DiffViewer; + + // DiffViewer -> Terminal(0), then step forward through every pane. + for expected_active in 0..7 { + app.cycle_focus_forward(); + assert_eq!(app.focus, Focus::Terminal); + assert_eq!(app.terminal.active, expected_active); + assert!( + app.terminal.visible_start <= expected_active + && app.terminal.visible_start + 4 > expected_active, + "active {expected_active} not inside visible window starting at {}", + app.terminal.visible_start + ); + } +} + +#[test] +fn toggle_list_fullscreen_sets_flag_and_focuses_file_list() { + let mut app = app_with_files(vec![]); + app.focus = Focus::DiffViewer; + assert!(!app.list_fullscreen); + + app.toggle_list_fullscreen(); + + assert!(app.list_fullscreen); + assert_eq!(app.focus, Focus::FileList); + + app.toggle_list_fullscreen(); + + assert!(!app.list_fullscreen); + // Exiting list zoom leaves focus on FileList (matches diff zoom semantics). + assert_eq!(app.focus, Focus::FileList); +} + +#[test] +fn toggle_list_fullscreen_exits_diff_fullscreen() { + let mut app = app_with_files(vec![]); + app.toggle_diff_fullscreen(); + assert!(app.diff.fullscreen); + + app.toggle_list_fullscreen(); + + assert!(app.list_fullscreen); + assert!(!app.diff.fullscreen); + assert_eq!(app.focus, Focus::FileList); +} \ No newline at end of file diff --git a/src/app/tests/head_change.rs b/src/app/tests/head_change.rs new file mode 100644 index 0000000..8498cf6 --- /dev/null +++ b/src/app/tests/head_change.rs @@ -0,0 +1,257 @@ +use super::*; + +/// Helper: build a snapshot tied to the given repo so HEAD-change detection +/// has a real oid to compare against. The snapshot itself is otherwise +/// empty — we only care about `head_oid` in these tests. +fn snapshot_with_head(repo_path: &str) -> RepoSnapshot { + let head = open_repo(repo_path).head().ok().and_then(|h| h.target()); + RepoSnapshot { + files: Vec::new(), + tracking: None, + head_oid: head, + branch_name: None, + } +} + +#[test] +fn head_change_in_log_mode_reloads_commit_list() { + let (_dir, path) = make_repo(); + run_git(&path, &["commit", "--allow-empty", "-m", "first"]); + run_git(&path, &["commit", "--allow-empty", "-m", "second"]); + + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_with_files(vec![]) + }; + app.repo_path = path.clone(); + app.mode = ViewMode::Log; + 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); + assert_eq!(app.log_view.commits.len(), 2); + + // Make a new commit in the same repo (simulates the terminal pane + // running `git commit`). + run_git(&path, &["commit", "--allow-empty", "-m", "third"]); + + tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) + .unwrap(); + app.poll_snapshot(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); + + assert_eq!( + app.log_view.commits.len(), + 3, + "commit list should auto-refresh on HEAD change" + ); + assert_eq!(app.log_view.commits[0].summary, "third"); +} + +#[test] +fn head_change_in_status_mode_does_not_reload() { + let (_dir, path) = make_repo(); + run_git(&path, &["commit", "--allow-empty", "-m", "first"]); + + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_with_files(vec![]) + }; + app.repo_path = path.clone(); + // Pre-load a stale 1-entry list; in Status mode it must NOT be + // 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); + assert_eq!(app.log_view.commits.len(), 1); + assert_eq!(app.mode, ViewMode::Status); + + run_git(&path, &["commit", "--allow-empty", "-m", "second"]); + + tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) + .unwrap(); + app.poll_snapshot(); + + assert_eq!( + app.log_view.commits.len(), + 1, + "Status mode must not eagerly refresh the (hidden) commit list" + ); +} + +#[test] +fn toggling_log_after_status_head_change_reloads_stale_cache() { + let (_dir, path) = make_repo(); + run_git(&path, &["commit", "--allow-empty", "-m", "first"]); + + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_with_files(vec![]) + }; + app.repo_path = path.clone(); + 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); + assert_eq!(app.log_view.commits[0].summary, "first"); + + run_git(&path, &["commit", "--allow-empty", "-m", "second"]); + tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) + .unwrap(); + app.poll_snapshot(); + + // Status mode leaves the hidden list untouched, but records the new + // HEAD. Entering Log mode must notice the mismatch and reconcile page 0 + // rather than reusing the stale cached page as-is. + assert_eq!(app.log_view.commits.len(), 1); + assert_eq!(app.log_view.commits[0].summary, "first"); + + app.toggle_mode(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); + + assert_eq!(app.mode, ViewMode::Log); + assert_eq!(app.log_view.commits.len(), 2); + assert_eq!(app.log_view.commits[0].summary, "second"); + 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()); +} + +#[test] +fn head_change_preserves_selected_commit_by_oid() { + let (_dir, path) = make_repo(); + run_git(&path, &["commit", "--allow-empty", "-m", "first"]); + run_git(&path, &["commit", "--allow-empty", "-m", "second"]); + + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_with_files(vec![]) + }; + app.repo_path = path.clone(); + app.mode = ViewMode::Log; + app.log_view + .set_commits(load_commit_log(&open_repo(&path), 500).unwrap()); + // 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); + + run_git(&path, &["commit", "--allow-empty", "-m", "third"]); + + tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) + .unwrap(); + app.poll_snapshot(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); + + // The 'first' commit now sits at index 2 because a new commit is + // prepended. Selection must follow it by oid, not by index. + assert_eq!(app.log_view.commits.len(), 3); + assert_eq!(app.log_view.selected, 2); + assert_eq!(app.log_view.commits[app.log_view.selected].oid, prior_oid); +} + +#[test] +fn head_change_falls_back_to_top_when_prior_oid_gone() { + let (_dir, path) = make_repo(); + run_git(&path, &["commit", "--allow-empty", "-m", "first"]); + run_git(&path, &["commit", "--allow-empty", "-m", "second"]); + + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_with_files(vec![]) + }; + app.repo_path = path.clone(); + app.mode = ViewMode::Log; + 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); + + // Reset to before the second commit so the prior HEAD oid is gone, + // then add a different commit on top. + run_git(&path, &["reset", "--hard", "HEAD~1"]); + run_git(&path, &["commit", "--allow-empty", "-m", "other"]); + + tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) + .unwrap(); + app.poll_snapshot(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); + + // The original selected commit ('second') no longer exists; selection + // must fall back to the newest (index 0). + assert_eq!(app.log_view.selected, 0); + assert_eq!(app.log_view.commits[0].summary, "other"); +} + +#[test] +fn head_change_clears_drill_down_when_commit_gone() { + let (_dir, path) = make_repo(); + run_git(&path, &["commit", "--allow-empty", "-m", "first"]); + run_git(&path, &["commit", "--allow-empty", "-m", "doomed"]); + + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_with_files(vec![]) + }; + app.repo_path = path.clone(); + app.mode = ViewMode::Log; + app.log_view + .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); + + // Drop the selected commit via reset, then advance HEAD with a new one. + run_git(&path, &["reset", "--hard", "HEAD~1"]); + run_git(&path, &["commit", "--allow-empty", "-m", "replacement"]); + + tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) + .unwrap(); + app.poll_snapshot(); + app.flush_commit_log_fetch_for_test(Duration::from_secs(2)); + + // The drill-down's commit oid is gone, so drill-down must collapse + // and the view drops back to the commit-level diff. + assert!(!app.log_view.drill_down); +} + +#[test] +fn initial_snapshot_does_not_trigger_commit_log_reload() { + let (_dir, path) = make_repo(); + run_git(&path, &["commit", "--allow-empty", "-m", "first"]); + + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_with_files(vec![]) + }; + app.repo_path = path.clone(); + 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()); + + tx.send(SnapshotMsg::Ok(snapshot_with_head(&path), HashMap::new())) + .unwrap(); + app.poll_snapshot(); + + // First snapshot must NOT eagerly fetch the commit log — that's + // 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()); +} \ No newline at end of file diff --git a/src/app/tests/helpers.rs b/src/app/tests/helpers.rs new file mode 100644 index 0000000..74b20af --- /dev/null +++ b/src/app/tests/helpers.rs @@ -0,0 +1,115 @@ +use super::*; +use crate::git::diff::{ChangedFile, DiffHunk, DiffLine, LineKind, StatusKind}; +use crate::runtime::snapshot::SnapshotMsg; +use crossterm::event::{KeyCode, KeyModifiers}; +use std::sync::mpsc; + +/// Build an inert SnapshotChannel for tests: real receiver, real stop +/// sender, but no worker thread driving the receiver. +/// +/// Drops `_stop_rx` immediately on purpose: the only contract of `_stop_tx` +/// is "dropped → worker observes disconnect". Since there is no worker +/// here, nothing waits on either side, and dropping `_stop_rx` upfront +/// keeps the helper's tuple shape minimal. If a future test ever spawns +/// a real worker against this channel, it must keep `_stop_rx` alive. +pub(crate) fn dummy_snapshot_channel() -> (SnapshotChannel, mpsc::Sender) { + let (tx, rx) = mpsc::channel::(); + let (stop_tx, _stop_rx) = mpsc::sync_channel::<()>(0); + (SnapshotChannel::from_endpoints(rx, stop_tx), tx) +} + +/// Inert tree watcher plus its event sender. Tests that drive the +/// watcher-triggered refresh keep the `Sender` to inject synthetic events; +/// most tests drop it (a closed channel simply never reports a change). +pub(crate) fn dummy_tree_watcher() -> ( + crate::runtime::tree_watch::TreeWatcher, + mpsc::Sender, +) { + let (tx, rx) = mpsc::channel(); + (crate::runtime::tree_watch::TreeWatcher::from_receiver(rx), tx) +} + +pub(crate) fn app_with_files(files: Vec<&str>) -> App { + let (snapshot, _tx) = dummy_snapshot_channel(); + let (tree_watch, _tw_tx) = dummy_tree_watcher(); + let mut status_view = StatusView { + files: files + .into_iter() + .map(|path| ChangedFile::unstaged_only(path.to_string(), StatusKind::Modified)) + .collect(), + ..Default::default() + }; + status_view.recompute_filter(); + App { + mode: ViewMode::Status, + status_view, + diff: DiffPane::default(), + focus: Focus::FileList, + notice: None, + repo_path: ".".to_string(), + log_view: LogView::default(), + tree_view: TreeView::default(), + terminal: TerminalState::new(None, false), + accent_idx: 0, + tracking: None, + snapshot, + pending_snapshot: None, + tree_watch, + tree_dirty: Default::default(), + tree_dirty_all: false, + pending_selection: None, + repo_cache: None, + cfg_agent_indicator: crate::config::AgentIndicatorConfig { + auto_follow: true, + ..crate::config::AgentIndicatorConfig::default() + }, + cfg_tree: crate::config::TreeConfig::default(), + pagination: CommitLogPagination::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, + leader: KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), + prefix_armed: false, + awaiting_swap_target: false, + pending_mouse_press: None, + mouse_enabled: true, + } +} + +pub(crate) fn context_hunk(lines: &[&str]) -> DiffHunk { + DiffHunk { + header: "@@ -1 +1 @@".to_string(), + lines: lines + .iter() + .map(|content| DiffLine { + kind: LineKind::Context, + content: (*content).to_string(), + }) + .collect(), + file_path: None, + } +} + +pub(crate) fn app_with_fake_backend() -> App { + let mut app = app_with_files(vec!["a.rs"]); + let backend = Box::new(crate::test_util::FakeBackend::default()); + app.terminal = TerminalState::new(Some(backend), false); + app +} + +/// Helper: build a populated FileViewState so tests can assert that +/// downstream operations either preserve or invalidate it without +/// going through the disk-reading `load_file_view` path. +pub(crate) fn seeded_file_view(path: &str) -> FileViewState { + FileViewState { + key: Some(FileViewKey::Status(path.to_string())), + content: "one\ntwo\nthree\n".to_string(), + scroll: 1, + scroll_x: 4, + total_lines: 3, + ..Default::default() + } +} \ No newline at end of file diff --git a/src/app/tests/leader_notice.rs b/src/app/tests/leader_notice.rs new file mode 100644 index 0000000..b8e7079 --- /dev/null +++ b/src/app/tests/leader_notice.rs @@ -0,0 +1,55 @@ +use super::*; + +#[test] +fn leader_label_renders_ctrl_chord_as_caret_uppercase() { + let mut app = app_with_files(vec!["a.rs"]); + assert_eq!(app.leader_label(), "^F"); + app.leader = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL); + assert_eq!(app.leader_label(), "^B"); +} + +#[test] +fn leader_label_without_ctrl_prints_raw_char() { + let mut app = app_with_files(vec!["a.rs"]); + app.leader = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE); + assert_eq!(app.leader_label(), "x"); +} + +/// Expiry is keyed on the notice's kind. The previous design matched the +/// message text, which meant a kind with no matching arm — terminal, tree, +/// and session all qualified — was never cleared at all. +#[test] +fn clear_notice_only_drops_the_matching_kind() { + let mut app = app_with_files(vec![]); + + app.raise_notice(NoticeKind::Tree, "boom"); + app.clear_notice(NoticeKind::Git); + assert_eq!( + app.notice, + Some(Notice::new(NoticeKind::Tree, "boom")), + "an unrelated subsystem's success must not drop another's notice" + ); + + app.clear_notice(NoticeKind::Tree); + assert_eq!(app.notice, None); +} + +#[test] +fn raising_a_notice_replaces_the_previous_one() { + let mut app = app_with_files(vec![]); + app.raise_notice(NoticeKind::Tree, "first"); + app.raise_notice(NoticeKind::Git, "second"); + assert_eq!(app.notice, Some(Notice::new(NoticeKind::Git, "second"))); +} + +#[test] +fn notice_line_prefixes_only_the_kinds_that_carry_a_label() { + assert_eq!( + Notice::new(NoticeKind::Git, "not a repo").line(), + "git error: not a repo" + ); + assert_eq!( + Notice::new(NoticeKind::RepoInput, "no such directory").line(), + "no such directory" + ); +} \ No newline at end of file diff --git a/src/app/tests/log_drill.rs b/src/app/tests/log_drill.rs new file mode 100644 index 0000000..0b61f2d --- /dev/null +++ b/src/app/tests/log_drill.rs @@ -0,0 +1,22 @@ +use super::*; + +#[test] +fn log_drill_in_clears_stale_diff_for_empty_commit() { + let (_dir, path) = make_repo(); + run_git(&path, &["commit", "--allow-empty", "-m", "empty"]); + + let mut app = app_with_files(vec![]); + app.repo_path = path.clone(); + app.mode = ViewMode::Log; + app.log_view + .set_commits(load_commit_log(&open_repo(&path), 1).unwrap()); + app.diff.hunks = vec![context_hunk(&["stale"])]; + app.log_view.diff_title = "stale".to_string(); + + app.log_drill_in(); + + assert!(app.log_view.drill_down); + assert!(app.log_view.commit_files.is_empty()); + assert!(app.diff.hunks.is_empty()); + assert!(app.log_view.diff_title.contains("empty")); +} \ No newline at end of file diff --git a/src/app/tests/log_search.rs b/src/app/tests/log_search.rs new file mode 100644 index 0000000..d023853 --- /dev/null +++ b/src/app/tests/log_search.rs @@ -0,0 +1,132 @@ +use super::*; +use super::commit_log::seed_log_app; + +fn named_commit(summary: &str) -> CommitEntry { + CommitEntry::new( + git2::Oid::ZERO_SHA1, + "deadbee".to_string(), + summary.to_string(), + "T".to_string(), + 0, + ) +} + +#[test] +fn commit_search_filters_summaries_and_clamps_selection() { + let mut app = app_with_files(vec![]); + app.mode = ViewMode::Log; + app.log_view.set_commits(vec![ + named_commit("feat: search bar"), + named_commit("docs: readme"), + named_commit("fix: another search edge case"), + ]); + app.log_view.selected = 1; + + app.start_log_search(); + app.log_search_push('s'); + app.log_search_push('e'); + app.log_search_push('a'); + app.log_search_push('r'); + app.log_search_push('c'); + app.log_search_push('h'); + + // "docs: readme" no longer matches → selection snaps to first match. + assert_eq!(app.log_commit_filtered_indices(), &[0, 2]); + assert_eq!(app.log_view.selected, 0); + + app.cancel_log_search(); + assert_eq!(app.log_commit_filtered_indices(), &[0, 1, 2]); + assert!(app.log_view.commit_search_query.is_empty()); +} + +#[test] +fn maybe_prefetch_suppressed_while_commit_search_active() { + // 10 loaded, threshold 5 — selected at 6 would normally spawn a fetch. + let mut app = seed_log_app(10, 5, 5); + app.log_view.selected = 6; + app.log_view.commit_search_active = true; + + app.maybe_prefetch_commit_log(); + + assert!(!app.log_view.pending_fetch); + assert!(app.pagination.page_rx.is_none()); +} + +#[test] +fn cancel_log_search_resumes_prefetch() { + let (dir, path) = make_repo(); + std::fs::write(Path::new(&path).join("a"), "x").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "c"]); + + let mut app = seed_log_app(10, 5, 5); + app.repo_path = path.clone(); + app.log_view.selected = 6; + // Open the search bar so the prefetch gate is engaged. + app.start_log_search(); + app.maybe_prefetch_commit_log(); + assert!(!app.log_view.pending_fetch); + + // Cancelling the search must re-call maybe_prefetch so the deferred + // 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()); + + let rx = app.pagination.page_rx.take().unwrap(); + let _ = rx.recv_timeout(Duration::from_secs(2)).unwrap(); + drop(dir); +} + +#[test] +fn confirm_log_search_with_query_resumes_prefetch() { + // Confirming (Enter) hides the bar but keeps the query — prefetch + // must resume on the way out, mirroring the cancel path. + let (dir, path) = make_repo(); + std::fs::write(Path::new(&path).join("a"), "x").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "c"]); + + let mut app = seed_log_app(10, 5, 5); + app.repo_path = path.clone(); + app.log_view.selected = 6; + app.start_log_search(); + app.log_search_push('c'); // every fake summary matches. + assert!(!app.log_view.pending_fetch); + + app.confirm_log_search(); + assert!(!app.log_view.commit_search_active); + 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(); + drop(dir); +} + +#[test] +fn drilldown_file_search_filters_paths_and_clamps_selection() { + let mut app = app_with_files(vec![]); + app.mode = ViewMode::Log; + app.log_view.drill_down = true; + app.log_view.set_commit_files(vec![ + ChangedFile::unstaged_only("src/lib.rs".into(), StatusKind::Modified), + ChangedFile::unstaged_only("README.md".into(), StatusKind::Modified), + ChangedFile::unstaged_only("src/main.rs".into(), StatusKind::Modified), + ]); + app.log_view.file_selected = 1; + + app.start_log_search(); + app.log_search_push('s'); + app.log_search_push('r'); + app.log_search_push('c'); + + // README.md drops out → selection snaps to first matching path. + assert_eq!(app.log_file_filtered_indices(), &[0, 2]); + assert_eq!(app.log_view.file_selected, 0); + assert!(app.log_view.file_search_active); + + app.cancel_log_search(); + assert_eq!(app.log_file_filtered_indices(), &[0, 1, 2]); + assert!(app.log_view.file_search_query.is_empty()); +} \ No newline at end of file diff --git a/src/app/tests/mod.rs b/src/app/tests/mod.rs new file mode 100644 index 0000000..beec435 --- /dev/null +++ b/src/app/tests/mod.rs @@ -0,0 +1,46 @@ +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.) +// so every test submodule can pull them in with `use super::*;`. +use super::*; +use crate::git::diff::{ + ChangedFile, CommitEntry, RepoSnapshot, StatusKind, load_commit_log, +}; +use crate::runtime::snapshot::SnapshotMsg; +use crate::runtime::terminal::{PaneInfo, TerminalFullscreen, SCROLLBACK_LINES}; +use crate::test_util::{make_repo, open_repo, run_git}; +use crate::app::commit_log_fetch::{CommitLogFetchKind, CommitLogPageMsg}; +use super::diff_load::DiffApply; +use super::strip_escape_sequences; +use crossterm::event::{KeyCode, KeyModifiers}; +use std::collections::HashMap; +use std::path::Path; +use std::sync::mpsc; +use std::time::{Duration, Instant, SystemTime}; + +mod auto_follow; +mod clamp_pane; +mod commit_log; +mod diff_file_view; +mod fullscreen; +mod head_change; +mod leader_notice; +mod log_drill; +mod log_search; +mod mode_toggle; +mod pane; +mod scroll_misc; +mod session_restore; +mod snapshot; +mod snapshot_refresh; +mod status_diff; +mod strip_escape; +mod terminal_init; +mod terminal_scrollback; +mod tree; +mod tree_session; +mod tree_watcher; + +pub(crate) use helpers::*; \ No newline at end of file diff --git a/src/app/tests/mode_toggle.rs b/src/app/tests/mode_toggle.rs new file mode 100644 index 0000000..eac71de --- /dev/null +++ b/src/app/tests/mode_toggle.rs @@ -0,0 +1,68 @@ +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); +} + +fn fake_entry(time: i64) -> CommitEntry { + CommitEntry::new( + git2::Oid::ZERO_SHA1, + "deadbee".to_string(), + format!("c{time}"), + "T".to_string(), + time, + ) +} + +#[test] +fn toggle_mode_from_terminal_fullscreen_reveals_file_list() { + let mut app = app_with_files(vec![]); + seed_cached_commit_log(&mut app); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + app.toggle_terminal_fullscreen(); + assert!(app.terminal.fullscreen.fills_body()); + + app.toggle_mode(); + + assert_eq!(app.mode, ViewMode::Log); + assert!(!app.terminal.fullscreen.fills_body()); + assert!(!app.diff.fullscreen); + assert_eq!(app.focus, Focus::FileList); +} + +#[test] +fn toggle_mode_from_diff_fullscreen_reveals_file_list() { + let mut app = app_with_files(vec![]); + seed_cached_commit_log(&mut app); + app.toggle_diff_fullscreen(); + assert!(app.diff.fullscreen); + + app.toggle_mode(); + + assert_eq!(app.mode, ViewMode::Log); + assert!(!app.diff.fullscreen); + assert!(!app.terminal.fullscreen.fills_body()); + assert_eq!(app.focus, Focus::FileList); +} + +#[test] +fn toggle_mode_in_split_layout_keeps_focus() { + let mut app = app_with_files(vec![]); + seed_cached_commit_log(&mut app); + app.focus = Focus::DiffViewer; + + app.toggle_mode(); + + assert_eq!(app.mode, ViewMode::Log); + assert_eq!(app.focus, Focus::DiffViewer); + + app.toggle_mode(); + + assert_eq!(app.mode, ViewMode::Status); + assert_eq!(app.focus, Focus::DiffViewer); +} \ No newline at end of file diff --git a/src/app/tests/pane.rs b/src/app/tests/pane.rs new file mode 100644 index 0000000..0738c4f --- /dev/null +++ b/src/app/tests/pane.rs @@ -0,0 +1,121 @@ +use super::*; + +#[test] +fn switch_pane_moves_focus_to_terminal() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![ + PaneInfo { + id: 1, + title: "shell 1".into(), + }, + PaneInfo { + id: 2, + title: "shell 2".into(), + }, + ]; + assert_eq!(app.focus, Focus::FileList); + app.switch_pane(1); + assert_eq!(app.focus, Focus::Terminal); + assert_eq!(app.terminal.active, 1); +} + +#[test] +fn open_new_pane_moves_focus_to_new_terminal() { + let mut app = app_with_fake_backend(); + assert_eq!(app.focus, Focus::FileList); + + app.open_new_pane(); + + assert_eq!(app.terminal.panes.len(), 1); + assert_eq!(app.focus, Focus::Terminal); + assert_eq!(app.terminal.active, 0); +} + +#[test] +fn open_new_pane_exits_competing_fullscreen() { + let mut app = app_with_fake_backend(); + app.toggle_diff_fullscreen(); + assert!(app.diff.fullscreen); + + app.open_new_pane(); + + assert_eq!(app.focus, Focus::Terminal); + assert!(!app.diff.fullscreen); + assert!(!app.list_fullscreen); +} + +/// Contract for the close/swap availability predicates shared by the key +/// gates (`main::handle_global_action`) and the armed hint row: close +/// needs terminal focus, swap additionally needs a second pane. +#[test] +fn pane_action_predicates_follow_focus_and_pane_count() { + let mut app = app_with_fake_backend(); + app.terminal.create_pane().unwrap(); + app.terminal.create_pane().unwrap(); + assert!( + !app.can_close_pane() && !app.can_swap_panes(), + "neither close nor swap may act without terminal focus" + ); + + app.focus = Focus::Terminal; + assert!(app.can_close_pane()); + assert!(app.can_swap_panes()); + + app.close_active_pane(); + assert!( + app.can_close_pane(), + "close still acts on the last remaining pane" + ); + assert!( + !app.can_swap_panes(), + "swap needs a second pane to exchange with" + ); +} + +#[test] +fn switch_pane_ignores_out_of_range() { + let mut app = app_with_files(vec![]); + app.switch_pane(5); + assert_eq!(app.terminal.active, 0); +} + +#[test] +fn switch_pane_slides_visible_window_to_include_hidden_pane() { + let mut app = app_with_files(vec![]); + app.terminal.max_visible_normal = 4; + app.terminal.panes = (0..7) + .map(|i| PaneInfo { + id: i + 1, + title: format!("shell {}", i + 1), + }) + .collect(); + + // Jumping straight to the last pane (index 6, beyond the default + // [0,4) window) must slide the window to include it. + app.switch_pane(6); + + assert_eq!(app.terminal.active, 6); + assert!(app.terminal.visible_start <= 6 && app.terminal.visible_start + 4 > 6); +} + +#[test] +fn closing_pane_reclamps_visible_window() { + let mut app = app_with_fake_backend(); + app.terminal.max_visible_normal = 4; + for i in 0..7 { + app.terminal + .create_pane_with(None, Some(&format!("P{i}"))) + .unwrap(); + } + assert_eq!(app.terminal.active, 6); + + // Close panes back down to a single one; the visible window must + // shrink back to contain only the remaining pane. + for _ in 0..6 { + app.close_active_pane(); + } + + assert_eq!(app.terminal.panes.len(), 1); + assert_eq!(app.terminal.active, 0); + assert_eq!(app.terminal.visible_start, 0); +} \ No newline at end of file diff --git a/src/app/tests/scroll_misc.rs b/src/app/tests/scroll_misc.rs new file mode 100644 index 0000000..5046602 --- /dev/null +++ b/src/app/tests/scroll_misc.rs @@ -0,0 +1,100 @@ +use super::*; + +#[test] +fn move_selected_in_filter_resets_horizontal_scroll() { + let mut app = app_with_files(vec!["a.rs", "b.rs"]); + app.status_view.file_scroll_x = 12; + app.move_selected_in_filter(1); + assert_eq!(app.status_view.selected, 1); + assert_eq!(app.status_view.file_scroll_x, 0); +} + +#[test] +fn log_select_down_resets_commit_scroll() { + let mut app = app_with_files(vec![]); + app.mode = ViewMode::Log; + // Seed through `set_commits` so the search filter cache is built; + // log navigation walks the filter cache (empty query → 0..len), + // which matches the production code path. + app.log_view.set_commits(vec![ + CommitEntry::new( + git2::Oid::ZERO_SHA1, + "0000000".into(), + "first".into(), + "T".into(), + 0, + ), + CommitEntry::new( + git2::Oid::ZERO_SHA1, + "1111111".into(), + "second".into(), + "T".into(), + 0, + ), + ]); + app.log_view.commit_scroll_x = 9; + app.log_select_down(); + assert_eq!(app.log_view.selected, 1); + assert_eq!(app.log_view.commit_scroll_x, 0); +} + +#[test] +fn log_file_select_down_resets_file_scroll() { + let mut app = app_with_files(vec![]); + app.mode = ViewMode::Log; + app.log_view.drill_down = true; + app.log_view.set_commits(vec![CommitEntry::new( + git2::Oid::ZERO_SHA1, + "0000000".into(), + "first".into(), + "T".into(), + 0, + )]); + app.log_view.set_commit_files(vec![ + ChangedFile::unstaged_only("x.rs".into(), StatusKind::Modified), + ChangedFile::unstaged_only("y.rs".into(), StatusKind::Modified), + ]); + app.log_view.file_scroll_x = 7; + app.log_file_select_down(); + assert_eq!(app.log_view.file_selected, 1); + assert_eq!(app.log_view.file_scroll_x, 0); +} + +#[test] +fn diff_scroll_routes_to_file_view_when_in_file_mode() { + let mut app = app_with_files(vec![]); + app.diff.scroll_x = 12; + app.diff.file_view.scroll_x = 4; + app.diff.view = DiffPaneView::File; + + app.diff.scroll_right(); + assert_eq!(app.diff.scroll_x, 12, "diff scroll_x must not change"); + assert_eq!(app.diff.file_view.scroll_x, 8); + + app.diff.scroll_left(); + assert_eq!(app.diff.file_view.scroll_x, 4); + + app.diff.view = DiffPaneView::Diff; + app.diff.scroll_right(); + assert_eq!(app.diff.scroll_x, 16); + assert_eq!( + app.diff.file_view.scroll_x, 4, + "file_view scroll_x must not change in diff mode" + ); +} + +#[test] +fn selected_filtered_status_file_returns_none_outside_filter() { + let mut app = app_with_files(vec!["alpha.rs", "bravo.rs", "charlie.rs"]); + app.status_view.search_query.set("alpha"); + app.status_view.recompute_filter(); + // Filter only matches index 0; selecting index 2 must return None. + app.status_view.selected = 2; + assert!(app.selected_filtered_status_file().is_none()); + + app.status_view.selected = 0; + assert_eq!( + app.selected_filtered_status_file().map(|f| f.path.as_str()), + Some("alpha.rs") + ); +} \ No newline at end of file diff --git a/src/app/tests/session_restore.rs b/src/app/tests/session_restore.rs new file mode 100644 index 0000000..4e8663c --- /dev/null +++ b/src/app/tests/session_restore.rs @@ -0,0 +1,295 @@ +use super::*; +use super::mode_toggle::seed_cached_commit_log; + +#[test] +fn toggle_mode_in_list_fullscreen_keeps_list_fullscreen() { + let mut app = app_with_files(vec![]); + seed_cached_commit_log(&mut app); + app.toggle_list_fullscreen(); + assert!(app.list_fullscreen); + + app.toggle_mode(); + + assert_eq!(app.mode, ViewMode::Log); + assert!(app.list_fullscreen); + assert_eq!(app.focus, Focus::FileList); +} + +#[test] +fn toggle_list_fullscreen_exits_terminal_fullscreen() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + app.toggle_terminal_fullscreen(); + assert!(app.terminal.fullscreen.fills_body()); + + app.toggle_list_fullscreen(); + + assert!(app.list_fullscreen); + assert!(!app.terminal.fullscreen.fills_body()); + assert_eq!(app.focus, Focus::FileList); +} + +#[test] +fn toggle_diff_fullscreen_exits_list_fullscreen() { + let mut app = app_with_files(vec![]); + app.toggle_list_fullscreen(); + assert!(app.list_fullscreen); + + app.toggle_diff_fullscreen(); + + assert!(app.diff.fullscreen); + assert!(!app.list_fullscreen); + assert_eq!(app.focus, Focus::DiffViewer); +} + +#[test] +fn toggle_terminal_fullscreen_exits_list_fullscreen() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + app.toggle_list_fullscreen(); + assert!(app.list_fullscreen); + + app.toggle_terminal_fullscreen(); + + assert!(app.terminal.fullscreen.fills_body()); + assert!(!app.list_fullscreen); + assert_eq!(app.focus, Focus::Terminal); +} + +#[test] +fn cycle_focus_is_noop_in_list_fullscreen() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + app.toggle_list_fullscreen(); + assert_eq!(app.focus, Focus::FileList); + + app.cycle_focus_forward(); + assert_eq!(app.focus, Focus::FileList); + + app.cycle_focus_backward(); + assert_eq!(app.focus, Focus::FileList); +} + +#[test] +fn switch_pane_exits_list_fullscreen() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + app.toggle_list_fullscreen(); + assert!(app.list_fullscreen); + + app.switch_pane(0); + + assert!(!app.list_fullscreen); + assert_eq!(app.focus, Focus::Terminal); +} + +#[test] +fn save_session_round_trips_list_fullscreen() { + let mut app = app_with_files(vec![]); + app.toggle_list_fullscreen(); + assert!(app.list_fullscreen); + + let state = app.save_session(); + assert!(state.list_fullscreen); + + let mut other = app_with_files(vec![]); + other.restore_session(&state); + assert!(other.list_fullscreen); + assert_eq!(other.focus, Focus::FileList); +} + +#[test] +fn restore_session_list_fullscreen_forces_filelist_focus() { + let mut app = app_with_files(vec![]); + + app.restore_session(&crate::session::SessionState { + focus: Some(Focus::DiffViewer), + list_fullscreen: true, + ..Default::default() + }); + + assert!(app.list_fullscreen); + assert_eq!(app.focus, Focus::FileList); +} + +#[test] +fn restore_session_prefers_terminal_fullscreen_over_list_fullscreen() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + + app.restore_session(&crate::session::SessionState { + focus: Some(Focus::FileList), + terminal_fullscreen: true, + list_fullscreen: true, + ..Default::default() + }); + + assert!(app.terminal.fullscreen.fills_body()); + assert!(!app.list_fullscreen); + assert_eq!(app.focus, Focus::Terminal); +} + +#[test] +fn close_last_pane_exits_fullscreen() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + app.terminal.fullscreen = TerminalFullscreen::Grid; + app.focus = Focus::Terminal; + app.terminal.scroll.insert(1, 3); + app.terminal.prompt_bufs.insert(1, "cargo test".to_string()); + app.terminal + .emulators + .insert(1, crate::runtime::emulator::PaneEmulator::new(3, 10, 0)); + + app.close_active_pane(); + + assert!(!app.terminal.fullscreen.fills_body()); + assert_eq!(app.focus, Focus::DiffViewer); + assert!(!app.terminal.scroll.contains_key(&1)); + assert!(!app.terminal.prompt_bufs.contains_key(&1)); + assert!(!app.terminal.emulators.contains_key(&1)); +} + +#[test] +fn restore_session_restores_active_pane_even_when_focus_is_not_terminal() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![ + PaneInfo { id: 1, title: "shell 1".into() }, + PaneInfo { id: 2, title: "shell 2".into() }, + ]; + + app.restore_session(&crate::session::SessionState { + focus: Some(Focus::FileList), + active_pane: 1, + ..Default::default() + }); + + assert_eq!(app.focus, Focus::FileList); + assert_eq!(app.terminal.active, 1); +} + +#[test] +fn restore_session_fullscreen_forces_terminal_focus() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + + app.restore_session(&crate::session::SessionState { + focus: Some(Focus::FileList), + terminal_fullscreen: true, + ..Default::default() + }); + + assert!(app.terminal.fullscreen.fills_body()); + assert_eq!(app.focus, Focus::Terminal); +} + +#[test] +fn restore_session_diff_fullscreen_forces_diff_focus() { + let mut app = app_with_files(vec![]); + + app.restore_session(&crate::session::SessionState { + focus: Some(Focus::FileList), + diff_fullscreen: true, + ..Default::default() + }); + + assert!(app.diff.fullscreen); + assert_eq!(app.focus, Focus::DiffViewer); +} + +#[test] +fn restore_session_prefers_terminal_fullscreen_over_diff_fullscreen() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + + app.restore_session(&crate::session::SessionState { + focus: Some(Focus::FileList), + terminal_fullscreen: true, + diff_fullscreen: true, + ..Default::default() + }); + + assert!(app.terminal.fullscreen.fills_body()); + assert!(!app.diff.fullscreen); + assert_eq!(app.focus, Focus::Terminal); +} + +#[test] +fn save_session_round_trips_diff_fullscreen() { + let mut app = app_with_files(vec![]); + app.toggle_diff_fullscreen(); + assert!(app.diff.fullscreen); + + let state = app.save_session(); + assert!(state.diff_fullscreen); + + let mut other = app_with_files(vec![]); + other.restore_session(&state); + assert!(other.diff.fullscreen); + assert_eq!(other.focus, Focus::DiffViewer); +} + +#[test] +fn restore_session_normalizes_accent_index() { + let mut app = app_with_files(vec![]); + + app.restore_session(&crate::session::SessionState { + accent_idx: usize::MAX, + ..Default::default() + }); + + assert_eq!( + app.accent_idx, + usize::MAX % crate::config::Accent::ALL.len() + ); +} + +#[test] +fn restore_session_keeps_log_scroll_after_loading_commit_diff() { + let (_dir, path) = make_repo(); + let file_path = Path::new(&path).join("a.rs"); + std::fs::write( + &file_path, + "fn main() {\n println!(\"one\");\n println!(\"two\");\n}\n", + ) + .unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + + let mut app = app_with_files(vec![]); + app.repo_path = path; + + app.restore_session(&crate::session::SessionState { + mode: Some(ViewMode::Log), + scroll: 2, + ..Default::default() + }); + + assert_eq!(app.mode, ViewMode::Log); + assert!(!app.diff.hunks.is_empty()); + assert_eq!(app.diff.scroll, 2); +} \ No newline at end of file diff --git a/src/app/tests/snapshot.rs b/src/app/tests/snapshot.rs new file mode 100644 index 0000000..7055720 --- /dev/null +++ b/src/app/tests/snapshot.rs @@ -0,0 +1,111 @@ +use super::*; + +#[test] +fn drain_snapshot_empties_the_queue_without_applying_it() { + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_with_files(vec!["old.rs"]) + }; + let send = |files: Vec<&str>| { + SnapshotMsg::Ok( + RepoSnapshot { + files: files + .into_iter() + .map(|p| ChangedFile::unstaged_only(p.to_string(), StatusKind::Modified)) + .collect(), + tracking: None, + head_oid: None, + branch_name: None, + }, + HashMap::new(), + ) + }; + tx.send(send(vec!["first.rs"])).unwrap(); + tx.send(send(vec!["second.rs"])).unwrap(); + + app.drain_snapshot(); + + // The queue is empty (so a hidden project's channel cannot grow), but + // no git work ran: the view still shows the pre-snapshot file list. + assert!(app.snapshot.try_recv().is_err(), "queue must be drained"); + assert_eq!(app.status_view.files[0].path, "old.rs"); + assert!(app.pending_snapshot.is_some(), "the tail is held for later"); + + // Applying it later yields the *last* snapshot, not the first. + app.poll_snapshot(); + assert_eq!(app.status_view.files[0].path, "second.rs"); + assert!(app.pending_snapshot.is_none(), "pending is consumed"); +} + +#[test] +fn a_saved_mode_lands_immediately_and_survives_being_changed() { + // The restore used to wait for the first snapshot and then overwrite + // whatever the user had picked in between. Now the mode is applied on + // the spot, so a later change is simply the newer choice. + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_with_files(vec![]) + }; + + app.restore_session(&crate::session::SessionState { + mode: Some(ViewMode::Tree), + ..Default::default() + }); + assert_eq!(app.mode, ViewMode::Tree, "applied without a snapshot"); + + app.toggle_mode(); + let chosen = app.mode; + tx.send(SnapshotMsg::Ok( + RepoSnapshot { + files: Vec::new(), + tracking: None, + head_oid: None, + branch_name: None, + }, + HashMap::new(), + )) + .unwrap(); + app.poll_snapshot(); + + assert_eq!(app.mode, chosen, "the snapshot must not undo the choice"); +} + +#[test] +fn a_saved_selection_is_restored_by_the_first_snapshot() { + // The one part that has to wait: it names a file the changed-file list + // has not delivered yet. It rides the ordinary path-preservation code. + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_with_files(vec![]) + }; + + app.restore_session(&crate::session::SessionState { + selected_file: Some("b.rs".to_string()), + ..Default::default() + }); + assert!(app.pending_selection.is_some(), "held until the list lands"); + + tx.send(SnapshotMsg::Ok( + RepoSnapshot { + files: ["a.rs", "b.rs"] + .iter() + .map(|p| ChangedFile::unstaged_only(p.to_string(), StatusKind::Modified)) + .collect(), + tracking: None, + head_oid: None, + branch_name: None, + }, + HashMap::new(), + )) + .unwrap(); + app.poll_snapshot(); + + assert_eq!(app.status_view.files[app.status_view.selected].path, "b.rs"); + assert!(app.pending_selection.is_none(), "consumed"); +} \ No newline at end of file diff --git a/src/app/tests/snapshot_refresh.rs b/src/app/tests/snapshot_refresh.rs new file mode 100644 index 0000000..37eca64 --- /dev/null +++ b/src/app/tests/snapshot_refresh.rs @@ -0,0 +1,158 @@ +use super::*; + +/// Backspace is the "edit this path" gesture — the sub-directory case +/// depends on the prefill surviving it. +/// →/End keeps the prefill and appends, which is what the sub-directory +/// case needs: Backspace would eat the trailing separator first. +#[test] +fn successful_snapshot_preserves_terminal_status() { + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + notice: Some(Notice::new(NoticeKind::Terminal, "backend unavailable")), + snapshot, + pending_snapshot: None, + ..app_with_files(vec![]) + }; + + tx.send(SnapshotMsg::Ok( + RepoSnapshot { + files: Vec::new(), + tracking: None, + head_oid: None, + branch_name: None, + }, + HashMap::new(), + )) + .unwrap(); + app.poll_snapshot(); + + assert_eq!( + app.notice, + Some(Notice::new(NoticeKind::Terminal, "backend unavailable")) + ); +} + +#[test] +fn successful_snapshot_clears_git_status() { + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + notice: Some(Notice::new(NoticeKind::Git, "not a repo")), + snapshot, + pending_snapshot: None, + ..app_with_files(vec![]) + }; + + tx.send(SnapshotMsg::Ok( + RepoSnapshot { + files: Vec::new(), + tracking: None, + head_oid: None, + branch_name: None, + }, + HashMap::new(), + )) + .unwrap(); + app.poll_snapshot(); + + assert_eq!(app.notice, None); +} + +#[test] +fn snapshot_refresh_clamps_selection_to_active_filter() { + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_with_files(vec!["bar.rs"]) + }; + app.status_view.search_query.set("bar"); + app.status_view.recompute_filter(); + + tx.send(SnapshotMsg::Ok( + RepoSnapshot { + files: vec![ + ChangedFile::unstaged_only("aaa.rs".to_string(), StatusKind::Modified), + ChangedFile::unstaged_only("bar2.rs".to_string(), StatusKind::Modified), + ], + tracking: None, + head_oid: None, + branch_name: None, + }, + HashMap::new(), + )) + .unwrap(); + app.poll_snapshot(); + + assert_eq!(app.filtered_indices(), &[1]); + assert_eq!(app.status_view.selected, 1); + assert_eq!( + app.status_view.files[app.status_view.selected].path, + "bar2.rs" + ); +} + +#[test] +fn snapshot_invalidates_path_width_cache_on_same_length_rename() { + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_with_files(vec!["short.rs"]) + }; + // Prime the width cache by reading the right-scroll bound once. + app.file_scroll_right(); + // Rename to a longer path while keeping the file count constant. + // Length-keyed invalidation alone would miss this; the cache must + // clear on every `set_files` assignment. + tx.send(SnapshotMsg::Ok( + RepoSnapshot { + files: vec![ChangedFile::unstaged_only( + "a_much_longer_renamed_path.rs".to_string(), + StatusKind::Modified, + )], + tracking: None, + head_oid: None, + branch_name: None, + }, + HashMap::new(), + )) + .unwrap(); + app.poll_snapshot(); + // Drive enough right-scrolls to reach the new max; if the cache were + // stale we would clamp at the old (shorter) bound. + for _ in 0..20 { + app.file_scroll_right(); + } + assert!(app.status_view.file_scroll_x >= "short.rs".chars().count()); +} + +#[test] +fn snapshot_refresh_with_no_filter_matches_clears_stale_diff() { + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_with_files(vec!["bar.rs"]) + }; + app.status_view.search_query.set("bar"); + app.status_view.recompute_filter(); + app.diff.hunks = vec![context_hunk(&["stale"])]; + + tx.send(SnapshotMsg::Ok( + RepoSnapshot { + files: vec![ChangedFile::unstaged_only( + "aaa.rs".to_string(), + StatusKind::Modified, + )], + tracking: None, + head_oid: None, + branch_name: None, + }, + HashMap::new(), + )) + .unwrap(); + app.poll_snapshot(); + + assert!(app.filtered_indices().is_empty()); + assert!(app.diff.hunks.is_empty()); +} \ No newline at end of file diff --git a/src/app/tests/status_diff.rs b/src/app/tests/status_diff.rs new file mode 100644 index 0000000..888f65d --- /dev/null +++ b/src/app/tests/status_diff.rs @@ -0,0 +1,115 @@ +use super::*; + +#[test] +fn selection_clamps_when_file_list_shrinks() { + let mut app = app_with_files(vec!["a.rs", "b.rs", "c.rs"]); + app.status_view.selected = 2; + app.status_view.files = vec![ChangedFile::unstaged_only( + "a.rs".to_string(), + StatusKind::Modified, + )]; + + let selected_path = app.restore_selection(Some("c.rs")); + + assert_eq!(selected_path.as_deref(), Some("a.rs")); + assert_eq!(app.status_view.selected, 0); +} + +#[test] +fn selection_prefers_same_path_after_refresh() { + let mut app = app_with_files(vec!["a.rs", "b.rs", "c.rs"]); + app.status_view.selected = 1; + app.status_view.files = vec![ + ChangedFile::unstaged_only("a.rs".to_string(), StatusKind::Modified), + ChangedFile::unstaged_only("c.rs".to_string(), StatusKind::Modified), + ChangedFile::unstaged_only("b.rs".to_string(), StatusKind::Modified), + ]; + + let selected_path = app.restore_selection(Some("b.rs")); + + assert_eq!(selected_path.as_deref(), Some("b.rs")); + assert_eq!(app.status_view.selected, 2); +} + +#[test] +fn diff_scroll_saturates_on_page_up() { + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::DiffViewer; + app.diff.scroll = 3; + + app.page_up(); + + assert_eq!(app.diff.scroll, 0); +} + +#[test] +fn diff_scroll_clamps_at_last_line_on_select_down() { + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::DiffViewer; + // 1 hunk = header + 1 content line = 2 total lines, max_scroll = 1 + app.diff.hunks = vec![context_hunk(&["x"])]; + app.diff.scroll = 1; // already at max + + app.select_down(); + + assert_eq!(app.diff.scroll, 1, "scroll must not exceed last line index"); +} + +#[test] +fn diff_scroll_clamps_at_last_line_on_page_down() { + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::DiffViewer; + app.diff.hunks = vec![context_hunk(&["x"])]; + app.diff.scroll = 0; + + app.page_down(); // +20, but max is 1 + + assert_eq!(app.diff.scroll, 1); +} + +#[test] +fn diff_scroll_handles_large_restored_offset() { + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::DiffViewer; + app.diff.hunks = vec![context_hunk(&["x"])]; + app.diff.scroll = usize::MAX; + + app.select_down(); + + assert_eq!(app.diff.scroll, 1); +} + +#[test] +fn diff_match_refresh_can_preserve_manual_scroll() { + let mut app = app_with_files(vec!["a.rs"]); + app.diff.hunks = vec![context_hunk(&["needle"])]; + app.diff.search.query.set("needle"); + app.diff.scroll = 7; + + app.diff.recompute_matches(false); + + assert_eq!(app.diff.search.matches, vec![1]); + assert_eq!(app.diff.scroll, 7); +} + +#[test] +fn diff_search_input_scrolls_to_first_match() { + let mut app = app_with_files(vec!["a.rs"]); + app.diff.hunks = vec![context_hunk(&["alpha", "needle"])]; + + app.diff.search_push('n'); + + assert_eq!(app.diff.search.matches, vec![2]); + assert_eq!(app.diff.scroll, 2); +} + +#[test] +fn status_search_with_no_matches_clears_stale_diff() { + let mut app = app_with_files(vec!["a.rs"]); + app.diff.hunks = vec![context_hunk(&["stale"])]; + + app.search_push('z'); + + assert!(app.filtered_indices().is_empty()); + assert!(app.diff.hunks.is_empty()); +} \ No newline at end of file diff --git a/src/app/tests/strip_escape.rs b/src/app/tests/strip_escape.rs new file mode 100644 index 0000000..3a325c2 --- /dev/null +++ b/src/app/tests/strip_escape.rs @@ -0,0 +1,44 @@ +// strip_escape_sequences is re-exported by app.rs and reaches this module +// through `super::` (mod.rs pulls it in via `use super::strip_escape_sequences`). + +#[test] +fn strip_escape_sequences_preserves_user_keystroke_after_bare_esc() { + // ESC followed by an ordinary character was previously consumed; the + // letter must now survive so user input echoed via PTY isn't lost. + let out = super::strip_escape_sequences(b"\x1bA"); + assert_eq!(out, "A"); +} + +#[test] +fn strip_escape_sequences_drops_csi_and_ss3() { + // CSI (cursor key), SS3 (alternate keypad), and charset designation + // must all be stripped fully without leaving final bytes behind. + let out = super::strip_escape_sequences(b"hi\x1b[31mRED\x1b[0m\x1bOA\x1b(Bend"); + assert_eq!(out, "hiREDend"); +} + +#[test] +fn strip_escape_sequences_keeps_text_after_malformed_ss3() { + // ESC O followed by a control byte is not a valid SS3 sequence. The + // old implementation unconditionally consumed two chars after ESC, + // swallowing the newline (and any subsequent text relying on it). + let out = super::strip_escape_sequences(b"\x1bO\nhello"); + assert_eq!(out, "\nhello"); +} + +#[test] +fn strip_escape_sequences_drops_osc_until_terminator() { + let bel = super::strip_escape_sequences(b"\x1b]0;title\x07ok"); + assert_eq!(bel, "ok"); + let st = super::strip_escape_sequences(b"\x1b]0;title\x1b\\ok"); + assert_eq!(st, "ok"); +} + +#[test] +fn strip_escape_sequences_preserves_backspace_and_del() { + // BS (0x08) and DEL (0x7f) survive stripping so `buffer_prompt_input` + // can replay them as `buf.pop()` instead of logging chars the user + // already corrected. + let out = super::strip_escape_sequences(b"ab\x7fcd\x08e"); + assert_eq!(out, "ab\x7fcd\x08e"); +} \ No newline at end of file diff --git a/src/app/tests/terminal_init.rs b/src/app/tests/terminal_init.rs new file mode 100644 index 0000000..56649a9 --- /dev/null +++ b/src/app/tests/terminal_init.rs @@ -0,0 +1,70 @@ +use super::*; + +#[test] +fn ensure_initial_terminal_opens_single_empty_pane_without_commands() { + let mut app = app_with_fake_backend(); + app.ensure_initial_terminal(&[]); + assert_eq!(app.terminal.panes.len(), 1); + assert_eq!(app.terminal.panes[0].title, "shell 1"); +} + +#[test] +fn ensure_initial_terminal_focuses_first_pane_on_fresh_launch() { + let mut app = app_with_fake_backend(); + // Helpers construct with FileList focus; a fresh launch must hand the + // first pane the input focus so keystrokes reach the terminal program. + assert_eq!(app.focus, Focus::FileList); + app.ensure_initial_terminal(&[]); + assert_eq!(app.focus, Focus::Terminal); + assert_eq!(app.terminal.active, 0); +} + +#[test] +fn restore_session_overrides_fresh_launch_terminal_focus() { + let mut app = app_with_fake_backend(); + app.ensure_initial_terminal(&[]); + assert_eq!(app.focus, Focus::Terminal); + // A restored session must win over the fresh-launch terminal focus. + app.restore_session(&crate::session::SessionState { + focus: Some(Focus::FileList), + ..Default::default() + }); + assert_eq!(app.focus, Focus::FileList); +} + +#[test] +fn restore_pane_focus_wins_immediately_without_snapshot() { + // The synchronous focus restore (run at startup before the first + // snapshot) must already override the fresh-launch terminal focus, so + // no keystroke is ever routed to the terminal on a FileList restart. + let mut app = app_with_fake_backend(); + app.ensure_initial_terminal(&[]); + assert_eq!(app.focus, Focus::Terminal); + app.restore_pane_focus(&crate::session::SessionState { + focus: Some(Focus::FileList), + ..Default::default() + }); + assert_eq!(app.focus, Focus::FileList); +} + +#[test] +fn ensure_initial_terminal_opens_one_pane_per_startup_command() { + use crate::config::StartupCommand; + let mut app = app_with_fake_backend(); + let commands = vec![ + StartupCommand { + name: Some("Claude".into()), + command: "claude".into(), + }, + StartupCommand { + name: None, + command: "cargo test".into(), + }, + ]; + app.ensure_initial_terminal(&commands); + assert_eq!(app.terminal.panes.len(), 2); + assert_eq!(app.terminal.panes[0].title, "Claude"); + assert_eq!(app.terminal.panes[1].title, "cargo test"); + // Focus clamps to the first reserved pane. + assert_eq!(app.terminal.active, 0); +} \ No newline at end of file diff --git a/src/app/tests/terminal_scrollback.rs b/src/app/tests/terminal_scrollback.rs new file mode 100644 index 0000000..ba9fa6a --- /dev/null +++ b/src/app/tests/terminal_scrollback.rs @@ -0,0 +1,50 @@ +use super::*; + +#[test] +fn terminal_scrollback_uses_full_buffer() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + app.terminal.active = 0; + app.terminal.size = (3, 10); + + let mut emulator = crate::runtime::emulator::PaneEmulator::new(3, 10, SCROLLBACK_LINES); + emulator.process(b"1\r\n2\r\n3\r\n4\r\n5\r\n6\r\n7\r\n8\r\n9\r\n"); + app.terminal.emulators.insert(1, emulator); + // Request scrolling well past screen height; the emulator supports + // arbitrary offsets up to the buffered line count. + app.terminal.scroll.insert(1, 6); + + app.terminal.sync_scroll(); + + let actual = app.terminal.emulators.get(&1).unwrap().scroll_offset(); + assert_eq!(actual, 6); + assert_eq!(app.terminal.scroll.get(&1).copied(), Some(6)); +} + +#[test] +fn terminal_scrollback_clamps_to_buffered_rows() { + let mut app = app_with_files(vec![]); + app.terminal.panes = vec![PaneInfo { + id: 1, + title: "shell".into(), + }]; + app.terminal.active = 0; + app.terminal.size = (3, 10); + + let mut emulator = crate::runtime::emulator::PaneEmulator::new(3, 10, SCROLLBACK_LINES); + // Only a handful of buffered rows exist; an outsized request must + // clamp to whatever the emulator actually has, never panic. + emulator.process(b"1\r\n2\r\n3\r\n4\r\n5\r\n"); + app.terminal.emulators.insert(1, emulator); + app.terminal.scroll.insert(1, 999); + + app.terminal.sync_scroll(); + + let stored = app.terminal.scroll.get(&1).copied().unwrap_or(0); + let actual = app.terminal.emulators.get(&1).unwrap().scroll_offset(); + assert_eq!(stored, actual); + assert!(actual < 999); +} \ No newline at end of file diff --git a/src/app/tests/tree.rs b/src/app/tests/tree.rs new file mode 100644 index 0000000..a09a276 --- /dev/null +++ b/src/app/tests/tree.rs @@ -0,0 +1,271 @@ +use super::*; + +/// A temp repo with `src/main.rs` and `README.md` at the root, plus an app +/// pointed at it. The app uses an inert snapshot channel (no worker). +pub(crate) fn make_tree_repo() -> (tempfile::TempDir, String) { + let (dir, path) = make_repo(); + let root = Path::new(&path); + std::fs::create_dir(root.join("src")).unwrap(); + std::fs::write(root.join("src").join("main.rs"), "fn main() {}\n").unwrap(); + std::fs::write(root.join("README.md"), "# hi\n").unwrap(); + (dir, path) +} + +pub(crate) fn app_on(path: &str) -> App { + let mut app = app_with_files(vec![]); + app.repo_path = path.to_string(); + app +} + +pub(crate) fn tree_index_of(app: &App, path: &str) -> usize { + app.tree_view + .visible_rows() + .iter() + .position(|r| r.path == path) + .unwrap_or_else(|| panic!("{path} not visible in tree")) +} + +#[test] +fn enter_tree_mode_loads_root_and_shows_file_overlay() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + + app.enter_tree_mode(); + + assert_eq!(app.mode, ViewMode::Tree); + let rows = app.tree_view.visible_rows(); + // Directories sort first: src/ before README.md. + assert_eq!(rows[0].path, "src"); + assert!(rows[0].is_dir); + assert!(rows.iter().any(|r| r.path == "README.md")); + // The right pane is always the file overlay in Tree mode. + assert_eq!(app.diff.view, DiffPaneView::File); + drop(dir); +} + +#[test] +fn tree_expand_reveals_children_and_collapse_hides_them() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + + app.tree_view.selected = tree_index_of(&app, "src"); + app.tree_expand(); + assert!( + app.tree_view + .visible_rows() + .iter() + .any(|r| r.path == "src/main.rs"), + "expanding src must reveal its child" + ); + + // Cursor is back on the (still-selected) src row; collapsing hides it. + app.tree_view.selected = tree_index_of(&app, "src"); + app.tree_collapse(); + assert!( + !app.tree_view + .visible_rows() + .iter() + .any(|r| r.path == "src/main.rs"), + "collapsing src must hide its child" + ); + drop(dir); +} + +#[test] +fn selecting_tree_file_loads_raw_contents_into_file_view() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + + app.tree_view.selected = tree_index_of(&app, "README.md"); + app.preview_tree_selected(); + + assert_eq!(app.diff.view, DiffPaneView::File); + assert_eq!( + app.diff.file_view.key, + Some(FileViewKey::Status("README.md".to_string())) + ); + assert_eq!(app.diff.file_view.content, "# hi\n"); + drop(dir); +} + +#[test] +fn tree_collapse_on_expanded_child_steps_to_parent() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + app.tree_view.selected = tree_index_of(&app, "src"); + app.tree_expand(); + + // Sit on the child file, then collapse: the cursor walks up to `src`. + app.tree_view.selected = tree_index_of(&app, "src/main.rs"); + app.tree_collapse(); + + assert_eq!( + app.tree_view.selected_path().as_deref(), + Some("src"), + "Left on a child should move selection to its parent dir" + ); + drop(dir); +} + +#[test] +fn tree_search_finds_file_in_unexpanded_dir() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + // `src` starts collapsed, so its child is not in the normal view. + assert!(!app.tree_view.expanded.contains("src")); + + app.start_tree_search(); + for ch in "main".chars() { + app.tree_search_push(ch); + } + + // The match is revealed through its ancestor chain even though `src` + // was never manually expanded. + let rows = app.tree_view.visible_rows(); + assert!(rows.iter().any(|r| r.path == "src/main.rs")); + assert!(rows.iter().any(|r| r.path == "src")); + assert!(!rows.iter().any(|r| r.path == "README.md")); + // Cursor lands on the matching file, not the connecting `src` dir. + assert_eq!( + app.tree_view.selected_path().as_deref(), + Some("src/main.rs") + ); + // Filtering must not mutate the real expansion set. + assert!(!app.tree_view.expanded.contains("src")); + drop(dir); +} + +#[test] +fn confirm_tree_search_reveals_match_in_normal_view() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + + app.start_tree_search(); + for ch in "main".chars() { + app.tree_search_push(ch); + } + app.confirm_tree_search(); + + // Overlay closed, query cleared, and `src` is now genuinely expanded so + // the chosen file stays visible with the cursor on it. + assert!(!app.tree_view.search_active); + assert!(app.tree_view.search_query.is_empty()); + assert!(app.tree_view.expanded.contains("src")); + assert_eq!( + app.tree_view.selected_path().as_deref(), + Some("src/main.rs") + ); + drop(dir); +} + +#[test] +fn cancel_tree_search_leaves_expansion_untouched() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + + app.start_tree_search(); + for ch in "main".chars() { + app.tree_search_push(ch); + } + app.cancel_tree_search(); + + assert!(!app.tree_view.search_active); + assert!(app.tree_view.search_query.is_empty()); + // Esc must not expand anything; the view returns to its prior state. + assert!(!app.tree_view.expanded.contains("src")); + assert!( + !app.tree_view + .visible_rows() + .iter() + .any(|r| r.path == "src/main.rs") + ); + drop(dir); +} + +#[test] +fn toggle_tree_mode_round_trips_status_and_tree() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + assert_eq!(app.mode, ViewMode::Status); + + app.toggle_tree_mode(); + assert_eq!(app.mode, ViewMode::Tree); + + app.toggle_tree_mode(); + assert_eq!(app.mode, ViewMode::Status); + drop(dir); +} + +#[test] +fn enter_tree_mode_picks_up_dir_created_after_first_entry() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + // First entry caches the root listing (no `docs/` yet). + app.enter_tree_mode(); + assert!( + !app.tree_view + .visible_rows() + .iter() + .any(|r| r.path == "docs"), + "docs should not exist before it is created" + ); + + // Create a directory on disk while away from Tree mode. + app.exit_tree_to_status(); + std::fs::create_dir(Path::new(&path).join("docs")).unwrap(); + std::fs::write(Path::new(&path).join("docs").join("guide.md"), "x").unwrap(); + + // Re-entering must re-read the root and surface the new directory. + app.enter_tree_mode(); + assert!( + app.tree_view + .visible_rows() + .iter() + .any(|r| r.path == "docs"), + "re-entering Tree mode must reflect the newly created directory" + ); + drop(dir); +} + +#[test] +fn enter_tree_mode_reflects_moved_dir_without_error() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + // Cache the root with `src/` present, expanded. + app.enter_tree_mode(); + app.tree_view.selected = tree_index_of(&app, "src"); + app.tree_expand(); + assert!(app.tree_view.expanded.contains("src")); + + // Move `src/` to `lib/` on disk while away from Tree mode. + app.exit_tree_to_status(); + std::fs::rename(Path::new(&path).join("src"), Path::new(&path).join("lib")).unwrap(); + + app.enter_tree_mode(); + let rows = app.tree_view.visible_rows(); + assert!( + !rows.iter().any(|r| r.path == "src"), + "the moved-away directory must disappear from its old location" + ); + assert!( + rows.iter().any(|r| r.path == "lib"), + "the directory must appear at its new location" + ); + // The stale `src` expansion is pruned (it no longer exists), so no + // failing re-read leaks a "tree error" into the status bar. + assert!(!app.tree_view.expanded.contains("src")); + assert!( + !app.notice + .as_ref() + .is_some_and(|n| n.kind == NoticeKind::Tree), + "a vanished directory must not surface a tree error: {:?}", + app.notice + ); + drop(dir); +} \ No newline at end of file diff --git a/src/app/tests/tree_session.rs b/src/app/tests/tree_session.rs new file mode 100644 index 0000000..9a0020b --- /dev/null +++ b/src/app/tests/tree_session.rs @@ -0,0 +1,275 @@ +use super::*; +use super::tree::{app_on, make_tree_repo, tree_index_of}; + +#[test] +fn a_change_in_a_collapsed_directory_updates_search_results() { + // The filtered view spans the whole tree, so a file created under a + // directory the user never expanded still changes the results. The + // watch set follows the index while a search is open, which is what + // makes the event arrive at all. + use crate::runtime::tree_watch::TreeWatcher; + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + let (tx, rx) = std::sync::mpsc::channel(); + app.tree_watch = TreeWatcher::from_receiver(rx); + app.enter_tree_mode(); + app.start_tree_search(); + for c in "main".chars() { + app.tree_search_push(c); + } + let before = app.tree_view.match_count; + assert!( + !app.tree_view.expanded.contains("src"), + "src stays collapsed — the point of the test" + ); + + std::fs::write(Path::new(&path).join("src").join("main_two.rs"), "\n").unwrap(); + tx.send(Ok(vec![notify_debouncer_mini::DebouncedEvent { + path: Path::new(&path).join("src").join("main_two.rs"), + kind: notify_debouncer_mini::DebouncedEventKind::Any, + }])) + .unwrap(); + app.poll_tree_watcher(); + + assert_eq!(app.tree_view.match_count, before + 1); + drop(dir); +} + +#[test] +fn a_watcher_refresh_updates_active_search_results() { + // The filtered view renders from the search index, so refreshing only + // the directory cache left a new file out of the results and the match + // count stale until the query changed. + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + app.start_tree_search(); + for c in "main".chars() { + app.tree_search_push(c); + } + let before = app.tree_view.match_count; + + std::fs::write(Path::new(&path).join("src").join("main_two.rs"), "\n").unwrap(); + app.refresh_tree_preserving_cursor(); + + assert_eq!( + app.tree_view.match_count, + before + 1, + "a file created while the search is open must join the results" + ); + drop(dir); +} + +#[test] +fn a_hidden_tree_change_is_remembered_until_the_tab_is_shown() { + // Rereading directories is the expensive half; a hidden project only + // records that it must, so filesystem churn elsewhere cannot stall the + // active tab. + let mut app = app_with_files(vec!["a.rs"]); + app.mode = ViewMode::Tree; + app.tree_dirty.insert("src".to_string()); + + // Draining with no new event leaves the flag standing, so the refresh + // still happens once this project becomes the active one. + app.drain_tree_watcher(); + assert!( + !app.tree_dirty.is_empty(), + "a pending refresh survives a drain" + ); + + app.poll_tree_watcher(); + assert!(app.tree_dirty.is_empty(), "the active project consumes it"); +} + +#[test] +fn tree_preview_survives_status_snapshot() { + let (dir, path) = make_tree_repo(); + let (snapshot, tx) = dummy_snapshot_channel(); + let mut app = App { + snapshot, + pending_snapshot: None, + ..app_on(&path) + }; + app.enter_tree_mode(); + app.tree_view.selected = tree_index_of(&app, "README.md"); + app.preview_tree_selected(); + let content_before = app.diff.file_view.content.clone(); + + // A git-status snapshot arrives (e.g. file changed in a terminal pane). + tx.send(SnapshotMsg::Ok( + RepoSnapshot { + files: Vec::new(), + tracking: None, + head_oid: None, + branch_name: None, + }, + HashMap::new(), + )) + .unwrap(); + app.poll_snapshot(); + + // Tree mode and its preview must be untouched by the snapshot ingest. + assert_eq!(app.mode, ViewMode::Tree); + assert_eq!(app.diff.view, DiffPaneView::File); + assert_eq!(app.diff.file_view.content, content_before); + drop(dir); +} + +#[test] +fn restoring_tree_session_clears_lingering_status_search() { + // Reachable case: `/` opens status search before the first snapshot, + // then a pending session restores Tree mode. The stale search overlay + // must be cleared so Tree keystrokes aren't captured by the search + // handler. + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.start_search(); + app.search_push('x'); + assert!(app.status_view.search_active); + + app.restore_session(&crate::session::SessionState { + mode: Some(ViewMode::Tree), + ..Default::default() + }); + + assert_eq!(app.mode, ViewMode::Tree); + assert!( + !app.status_view.search_active, + "restoring Tree mode must clear a lingering status search overlay" + ); + assert!(app.status_view.search_query.is_empty()); + drop(dir); +} + +#[test] +fn entering_tree_mode_clears_lingering_status_search() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.start_search(); + app.search_push('x'); + assert!(app.status_view.search_active); + + app.enter_tree_mode(); + + assert!(!app.status_view.search_active); + assert!(app.status_view.search_query.is_empty()); + drop(dir); +} + +#[test] +fn restore_tree_session_ignores_unsafe_expanded_paths() { + // A corrupted/hand-edited session must not be able to drive the tree + // to read directories outside the working tree. + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + + app.restore_session(&crate::session::SessionState { + mode: Some(ViewMode::Tree), + tree_expanded: vec![ + "../../..".to_string(), + "/etc".to_string(), + "src".to_string(), + ], + ..Default::default() + }); + + assert_eq!(app.mode, ViewMode::Tree); + // Only the safe, real directory was expanded/cached. + assert!(app.tree_view.expanded.contains("src")); + assert!(!app.tree_view.expanded.contains("../../..")); + assert!(!app.tree_view.expanded.contains("/etc")); + assert!(!app.tree_view.cache.contains_key("/etc")); + drop(dir); +} + +#[test] +fn restore_tree_session_prunes_expansion_gone_since_save() { + // A directory that was expanded when the session was saved may have been + // moved/deleted before the next launch. Restore must drop it rather than + // re-reading a now-missing path and leaking a "tree error". + let (dir, path) = make_tree_repo(); + std::fs::rename(Path::new(&path).join("src"), Path::new(&path).join("lib")).unwrap(); + let mut app = app_on(&path); + + app.restore_session(&crate::session::SessionState { + mode: Some(ViewMode::Tree), + tree_expanded: vec!["src".to_string()], + tree_selected_path: Some("src".to_string()), + ..Default::default() + }); + + assert_eq!(app.mode, ViewMode::Tree); + // `src` no longer exists on disk, so it must not be kept as expanded... + assert!(!app.tree_view.expanded.contains("src")); + // ...and the moved-to directory is visible at the root. + assert!(app.tree_view.visible_rows().iter().any(|r| r.path == "lib")); + assert!( + !app.notice + .as_ref() + .is_some_and(|n| n.kind == NoticeKind::Tree), + "a vanished restored expansion must not surface a tree error: {:?}", + app.notice + ); + drop(dir); +} + +#[test] +fn entering_tree_cancels_in_flight_commit_log_fetch() { + // A page fetch spawned in Log mode must be torn down on Tree entry so + // its reply can't load a commit diff over the Tree file preview. + 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"); + + app.enter_tree_mode(); + + assert!( + app.pagination.page_rx.is_none(), + "entering Tree mode must cancel the in-flight commit-log fetch" + ); + drop(dir); +} + +#[test] +fn tree_mode_diff_file_and_split_toggles_are_noops() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + assert_eq!(app.diff.view, DiffPaneView::File); + + // `v` and `s` must not flip the right pane away from the file preview. + app.toggle_diff_file_view(); + assert_eq!(app.diff.view, DiffPaneView::File); + app.toggle_diff_split_view(); + assert_eq!(app.diff.view, DiffPaneView::File); + drop(dir); +} + +#[test] +fn tree_session_round_trips_mode_expansion_and_selection() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + app.tree_view.selected = tree_index_of(&app, "src"); + app.tree_expand(); + app.tree_view.selected = tree_index_of(&app, "src/main.rs"); + + let state = app.save_session(); + assert_eq!(state.mode, Some(ViewMode::Tree)); + assert!(state.tree_expanded.contains(&"src".to_string())); + assert_eq!(state.tree_selected_path.as_deref(), Some("src/main.rs")); + + let mut other = app_on(&path); + other.restore_session(&state); + assert_eq!(other.mode, ViewMode::Tree); + assert!(other.tree_view.expanded.contains("src")); + assert_eq!( + other.tree_view.selected_path().as_deref(), + Some("src/main.rs") + ); + // The restored selection previews the file, not a diff. + assert_eq!(other.diff.view, DiffPaneView::File); + assert_eq!(other.diff.file_view.content, "fn main() {}\n"); + drop(dir); +} \ No newline at end of file diff --git a/src/app/tests/tree_watcher.rs b/src/app/tests/tree_watcher.rs new file mode 100644 index 0000000..a4f601c --- /dev/null +++ b/src/app/tests/tree_watcher.rs @@ -0,0 +1,154 @@ +use super::*; +use super::tree::{app_on, make_tree_repo, tree_index_of}; + +#[test] +fn refresh_tree_cache_keeps_expansion_for_surviving_dirs() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + app.tree_view.selected = tree_index_of(&app, "src"); + app.tree_expand(); + assert!( + app.tree_view + .visible_rows() + .iter() + .any(|r| r.path == "src/main.rs"), + "src should be expanded before the refresh" + ); + + app.refresh_tree_cache(); + + assert!(app.tree_view.expanded.contains("src")); + assert!( + app.tree_view + .visible_rows() + .iter() + .any(|r| r.path == "src/main.rs"), + "a surviving directory keeps its expansion and re-read children" + ); + drop(dir); +} + +#[test] +fn enter_tree_mode_keeps_cursor_on_same_path_when_rows_shift() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + // Park the cursor on README.md. + app.tree_view.selected = tree_index_of(&app, "README.md"); + + // Insert a directory that sorts ahead of everything, shifting README.md + // down by one row. + app.exit_tree_to_status(); + std::fs::create_dir(Path::new(&path).join("aaa")).unwrap(); + + app.enter_tree_mode(); + // The cursor must follow README.md, not stay on its old index (which now + // points at a different row). + assert_eq!( + app.tree_view.selected_path().as_deref(), + Some("README.md"), + "cursor must track its path across the row-set shift" + ); + drop(dir); +} + +#[test] +fn poll_tree_watcher_refreshes_tree_on_event_in_tree_mode() { + use crate::runtime::tree_watch::TreeWatcher; + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + // Swap in a watcher we can feed synthetic events into. + let (tx, rx) = std::sync::mpsc::channel(); + app.tree_watch = TreeWatcher::from_receiver(rx); + app.enter_tree_mode(); + assert!( + !app.tree_view + .visible_rows() + .iter() + .any(|r| r.path == "docs") + ); + + // A folder appears on disk, then the watcher fires with its path. + std::fs::create_dir(Path::new(&path).join("docs")).unwrap(); + tx.send(Ok(vec![notify_debouncer_mini::DebouncedEvent { + path: Path::new(&path).join("docs"), + kind: notify_debouncer_mini::DebouncedEventKind::Any, + }])) + .unwrap(); + app.poll_tree_watcher(); + + assert!( + app.tree_view + .visible_rows() + .iter() + .any(|r| r.path == "docs"), + "a watcher event in Tree mode must re-read and surface the new dir" + ); + drop(dir); +} + +#[test] +fn poll_tree_watcher_ignores_events_outside_tree_mode() { + use crate::runtime::tree_watch::TreeWatcher; + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + let (tx, rx) = std::sync::mpsc::channel(); + app.tree_watch = TreeWatcher::from_receiver(rx); + // Never enter Tree mode. + assert_eq!(app.mode, ViewMode::Status); + + std::fs::create_dir(Path::new(&path).join("docs")).unwrap(); + tx.send(Ok(Vec::new())).unwrap(); + app.poll_tree_watcher(); + + // The event is drained but must not build/touch the tree off-screen. + assert_eq!(app.mode, ViewMode::Status); + assert!(app.tree_view.cache.is_empty()); + drop(dir); +} + +#[test] +fn leaving_tree_for_log_clears_watches() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + assert!( + app.tree_watch.watched_count() > 0, + "entering Tree mode watches at least the root" + ); + + app.toggle_mode(); // Tree -> Log via l + assert_eq!(app.mode, ViewMode::Log); + assert_eq!( + app.tree_watch.watched_count(), + 0, + "leaving Tree for Log must drop all watches" + ); + drop(dir); +} + +#[test] +fn leaving_tree_for_status_clears_watches() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + assert!(app.tree_watch.watched_count() > 0); + + app.exit_tree_to_status(); + assert_eq!(app.mode, ViewMode::Status); + assert_eq!(app.tree_watch.watched_count(), 0); + drop(dir); +} + +#[test] +fn toggle_mode_from_tree_enters_log_view() { + let (dir, path) = make_tree_repo(); + let mut app = app_on(&path); + app.enter_tree_mode(); + + // ` l` from Tree goes to Log (not back to Status). + app.toggle_mode(); + assert_eq!(app.mode, ViewMode::Log); + drop(dir); +} \ No newline at end of file diff --git a/src/app/tree.rs b/src/app/tree.rs index 05d1369..dd56674 100644 --- a/src/app/tree.rs +++ b/src/app/tree.rs @@ -7,8 +7,7 @@ //! surface used by the status/commit file preview, so no new render path is //! introduced. -use super::{App, DiffPaneView, FileViewKey, FileViewState, LIST_PAGE_SIZE, NoticeKind, ViewMode}; -use crate::ui::tree_view::{TreeIndexEntry, parent_path}; +use super::{App, DiffPaneView, FileViewKey, FileViewState, NoticeKind, ViewMode}; use std::collections::BTreeSet; impl App { @@ -114,7 +113,7 @@ impl App { dirs.sort_by_key(|p| p.matches('/').count()); let mut kept = BTreeSet::new(); for dir in dirs { - let parent = parent_path(&dir).unwrap_or(""); + let parent = crate::ui::tree_view::parent_path(&dir).unwrap_or(""); let name = dir.rsplit('/').next().unwrap_or(&dir); let still_a_dir = self .tree_view @@ -274,241 +273,4 @@ impl App { } } } - - /// Move the tree cursor by `delta` rows within the visible list, clamping - /// at both ends, and preview the new row. - fn move_tree_selection(&mut self, delta: isize) { - let len = self.tree_view.visible_rows().len(); - if len == 0 { - self.tree_view.selected = 0; - return; - } - let last = len as isize - 1; - let current = self.tree_view.selected.min(len - 1) as isize; - let new = (current + delta).clamp(0, last) as usize; - if new != self.tree_view.selected { - self.tree_view.selected = new; - self.tree_view.scroll_x = 0; - self.preview_tree_selected(); - } - } - - pub fn tree_select_up(&mut self) { - self.move_tree_selection(-1); - } - - pub fn tree_select_down(&mut self) { - self.move_tree_selection(1); - } - - pub fn tree_page_up(&mut self) { - self.move_tree_selection(-(LIST_PAGE_SIZE as isize)); - } - - pub fn tree_page_down(&mut self) { - self.move_tree_selection(LIST_PAGE_SIZE as isize); - } - - /// Expand the selected directory row (lazily reading its children). No-op - /// on file rows, already-expanded directories, or expansion past the - /// configured `max_depth`. - pub fn tree_expand(&mut self) { - let selected = self.tree_view.selected; - let Some(row) = self.tree_view.visible_rows().into_iter().nth(selected) else { - return; - }; - if !row.is_dir || self.tree_view.expanded.contains(&row.path) { - return; - } - if row.depth + 1 > self.cfg_tree.max_depth { - return; - } - self.ensure_tree_children(&row.path); - self.tree_view.expanded.insert(row.path); - // Visible rows changed: a same-row-count expand/collapse elsewhere - // could otherwise reuse a stale horizontal-scroll width bound. - self.tree_view.row_width_cache.set(None); - // A newly expanded directory becomes visible — start watching it. - self.sync_tree_watches(); - } - - /// Collapse the selected directory if expanded; otherwise move the cursor - /// up to its parent directory row (so repeated `Left` walks back out). - pub fn tree_collapse(&mut self) { - let rows = self.tree_view.visible_rows(); - let Some(row) = rows.get(self.tree_view.selected) else { - return; - }; - if row.is_dir && self.tree_view.expanded.contains(&row.path) { - let path = row.path.clone(); - // Drop the directory and every descendant from the expanded set so - // re-expanding it later starts collapsed rather than restoring a - // deep open subtree the user explicitly closed. - let prefix = format!("{path}/"); - self.tree_view - .expanded - .retain(|p| p != &path && !p.starts_with(&prefix)); - self.tree_view.row_width_cache.set(None); - // The collapsed subtree is no longer visible — stop watching it. - self.sync_tree_watches(); - return; - } - if let Some(parent) = parent_path(&row.path) { - let parent = parent.to_string(); - if let Some(idx) = rows.iter().position(|r| r.path == parent) { - self.tree_view.selected = idx; - self.tree_view.scroll_x = 0; - self.preview_tree_selected(); - } - } - } - - /// Enter toggles a directory open/closed; on a file row it (re)loads the - /// preview, mirroring selection behaviour. - pub fn tree_toggle(&mut self) { - let selected = self.tree_view.selected; - let Some(row) = self.tree_view.visible_rows().into_iter().nth(selected) else { - return; - }; - if row.is_dir { - if self.tree_view.expanded.contains(&row.path) { - self.tree_collapse(); - } else { - self.tree_expand(); - } - } else { - self.preview_tree_selected(); - } - } - - /// Open the filename-search overlay: walk the whole tree once to build the - /// search index, then keep showing the (still unfiltered) view until the - /// user types a query. - pub fn start_tree_search(&mut self) { - self.build_tree_index(); - self.tree_view.search_active = true; - self.tree_view.search_query.clear(); - self.tree_view.recompute_filter(); - // The results now span the whole tree, so the watch set has to as well - // — a file created in a directory the user never expanded still - // changes them. `sync_tree_watches` reads `search_active`, so this - // must come after it is set. - self.sync_tree_watches(); - } - - pub fn tree_search_push(&mut self, ch: char) { - self.tree_view.search_query.push(ch); - self.tree_view.recompute_filter(); - self.reset_tree_selection_after_filter(); - } - - pub fn tree_search_pop(&mut self) { - self.tree_view.search_query.pop(); - self.tree_view.recompute_filter(); - self.reset_tree_selection_after_filter(); - } - - /// Close the overlay without changing the expansion state; the cursor stays - /// on whatever row maps into the now-unfiltered view. - pub fn cancel_tree_search(&mut self) { - self.tree_view.cancel_search(); - // Back to watching only what is expanded: the wider set existed for - // the filtered view and would otherwise hold descriptors for the whole - // tree until Tree mode was left. - self.sync_tree_watches(); - let row_count = self.tree_view.visible_rows().len(); - self.tree_view.clamp_selection(row_count); - self.preview_tree_selected(); - } - - /// Confirm the current selection: reveal it in the normal expansion-based - /// view by expanding all of its ancestor directories, close the overlay, - /// and move the cursor onto that path. An empty query collapses to a cancel. - pub fn confirm_tree_search(&mut self) { - if self.tree_view.search_query.is_empty() { - self.cancel_tree_search(); - return; - } - let target = self.tree_view.selected_path(); - if let Some(path) = &target { - // Expand every ancestor so the chosen path is visible once - // filtering ends. The path itself (if a directory) is left - // collapsed — the user opens it explicitly. - let mut p = parent_path(path); - while let Some(parent) = p { - self.tree_view.expanded.insert(parent.to_string()); - p = parent_path(parent); - } - } - self.tree_view.cancel_search(); - self.sync_tree_watches(); - if let Some(path) = target { - let rows = self.tree_view.visible_rows(); - if let Some(idx) = rows.iter().position(|r| r.path == path) { - self.tree_view.selected = idx; - } - } - self.tree_view.scroll_x = 0; - let row_count = self.tree_view.visible_rows().len(); - self.tree_view.clamp_selection(row_count); - self.preview_tree_selected(); - } - - /// After a query change the row set shifts, so pin the cursor to the first - /// *matching* row (skipping the ancestor directories pulled in only to - /// connect the path) and re-preview it. Falls back to the first row when - /// nothing matches the basename directly. - fn reset_tree_selection_after_filter(&mut self) { - self.tree_view.scroll_x = 0; - let rows = self.tree_view.visible_rows(); - if rows.is_empty() { - self.tree_view.selected = 0; - self.preview_tree_selected(); - return; - } - let q = self.tree_view.search_query.lower(); - let first_match = rows - .iter() - .position(|r| r.name.to_lowercase().contains(q)) - .unwrap_or(0); - self.tree_view.selected = first_match; - self.preview_tree_selected(); - } - - /// Walk the entire tree once (depth-capped at `max_depth`, gitignore applied - /// via the same guarded reader used for lazy expansion), populating the - /// per-directory cache and a flat search index. Synchronous on the UI thread - /// like the per-level reads — one keystroke triggers it, then all filtering - /// is in-memory. - pub(crate) fn build_tree_index(&mut self) { - self.ensure_tree_root(); - let max_depth = self.cfg_tree.max_depth; - let mut index = Vec::new(); - // (dir, depth-of-its-children): the root's children sit at depth 0. - let mut stack = vec![(String::new(), 0usize)]; - while let Some((dir, depth)) = stack.pop() { - self.ensure_tree_children(&dir); - let children = match self.tree_view.cache.get(&dir) { - Some(c) => c.clone(), - None => continue, - }; - for entry in children { - let path = if dir.is_empty() { - entry.name.clone() - } else { - format!("{dir}/{}", entry.name) - }; - index.push(TreeIndexEntry { - name_lower: entry.name.to_lowercase(), - path: path.clone(), - }); - // Descend only while the next level stays within max_depth, - // mirroring the expand guard (`depth + 1 > max_depth` blocks). - if entry.is_dir && depth < max_depth { - stack.push((path, depth + 1)); - } - } - } - self.tree_view.index = index; - } -} +} \ No newline at end of file diff --git a/src/app/tree_nav.rs b/src/app/tree_nav.rs new file mode 100644 index 0000000..885b280 --- /dev/null +++ b/src/app/tree_nav.rs @@ -0,0 +1,241 @@ +use super::{App, LIST_PAGE_SIZE}; +use crate::ui::tree_view::{TreeIndexEntry, parent_path}; + +impl App { + /// Move the tree cursor by `delta` rows within the visible list, clamping + /// at both ends, and preview the new row. + fn move_tree_selection(&mut self, delta: isize) { + let len = self.tree_view.visible_rows().len(); + if len == 0 { + self.tree_view.selected = 0; + return; + } + let last = len as isize - 1; + let current = self.tree_view.selected.min(len - 1) as isize; + let new = (current + delta).clamp(0, last) as usize; + if new != self.tree_view.selected { + self.tree_view.selected = new; + self.tree_view.scroll_x = 0; + self.preview_tree_selected(); + } + } + + pub fn tree_select_up(&mut self) { + self.move_tree_selection(-1); + } + + pub fn tree_select_down(&mut self) { + self.move_tree_selection(1); + } + + pub fn tree_page_up(&mut self) { + self.move_tree_selection(-(LIST_PAGE_SIZE as isize)); + } + + pub fn tree_page_down(&mut self) { + self.move_tree_selection(LIST_PAGE_SIZE as isize); + } + + /// Expand the selected directory row (lazily reading its children). No-op + /// on file rows, already-expanded directories, or expansion past the + /// configured `max_depth`. + pub fn tree_expand(&mut self) { + let selected = self.tree_view.selected; + let Some(row) = self.tree_view.visible_rows().into_iter().nth(selected) else { + return; + }; + if !row.is_dir || self.tree_view.expanded.contains(&row.path) { + return; + } + if row.depth + 1 > self.cfg_tree.max_depth { + return; + } + self.ensure_tree_children(&row.path); + self.tree_view.expanded.insert(row.path); + // Visible rows changed: a same-row-count expand/collapse elsewhere + // could otherwise reuse a stale horizontal-scroll width bound. + self.tree_view.row_width_cache.set(None); + // A newly expanded directory becomes visible — start watching it. + self.sync_tree_watches(); + } + + /// Collapse the selected directory if expanded; otherwise move the cursor + /// up to its parent directory row (so repeated `Left` walks back out). + pub fn tree_collapse(&mut self) { + let rows = self.tree_view.visible_rows(); + let Some(row) = rows.get(self.tree_view.selected) else { + return; + }; + if row.is_dir && self.tree_view.expanded.contains(&row.path) { + let path = row.path.clone(); + // Drop the directory and every descendant from the expanded set so + // re-expanding it later starts collapsed rather than restoring a + // deep open subtree the user explicitly closed. + let prefix = format!("{path}/"); + self.tree_view + .expanded + .retain(|p| p != &path && !p.starts_with(&prefix)); + self.tree_view.row_width_cache.set(None); + // The collapsed subtree is no longer visible — stop watching it. + self.sync_tree_watches(); + return; + } + if let Some(parent) = parent_path(&row.path) { + let parent = parent.to_string(); + if let Some(idx) = rows.iter().position(|r| r.path == parent) { + self.tree_view.selected = idx; + self.tree_view.scroll_x = 0; + self.preview_tree_selected(); + } + } + } + + /// Enter toggles a directory open/closed; on a file row it (re)loads the + /// preview, mirroring selection behaviour. + pub fn tree_toggle(&mut self) { + let selected = self.tree_view.selected; + let Some(row) = self.tree_view.visible_rows().into_iter().nth(selected) else { + return; + }; + if row.is_dir { + if self.tree_view.expanded.contains(&row.path) { + self.tree_collapse(); + } else { + self.tree_expand(); + } + } else { + self.preview_tree_selected(); + } + } + + /// Open the filename-search overlay: walk the whole tree once to build the + /// search index, then keep showing the (still unfiltered) view until the + /// user types a query. + pub fn start_tree_search(&mut self) { + self.build_tree_index(); + self.tree_view.search_active = true; + self.tree_view.search_query.clear(); + self.tree_view.recompute_filter(); + // The results now span the whole tree, so the watch set has to as well + // — a file created in a directory the user never expanded still + // changes them. `sync_tree_watches` reads `search_active`, so this + // must come after it is set. + self.sync_tree_watches(); + } + + pub fn tree_search_push(&mut self, ch: char) { + self.tree_view.search_query.push(ch); + self.tree_view.recompute_filter(); + self.reset_tree_selection_after_filter(); + } + + pub fn tree_search_pop(&mut self) { + self.tree_view.search_query.pop(); + self.tree_view.recompute_filter(); + self.reset_tree_selection_after_filter(); + } + + /// Close the overlay without changing the expansion state; the cursor stays + /// on whatever row maps into the now-unfiltered view. + pub fn cancel_tree_search(&mut self) { + self.tree_view.cancel_search(); + // Back to watching only what is expanded: the wider set existed for + // the filtered view and would otherwise hold descriptors for the whole + // tree until Tree mode was left. + self.sync_tree_watches(); + let row_count = self.tree_view.visible_rows().len(); + self.tree_view.clamp_selection(row_count); + self.preview_tree_selected(); + } + + /// Confirm the current selection: reveal it in the normal expansion-based + /// view by expanding all of its ancestor directories, close the overlay, + /// and move the cursor onto that path. An empty query collapses to a cancel. + pub fn confirm_tree_search(&mut self) { + if self.tree_view.search_query.is_empty() { + self.cancel_tree_search(); + return; + } + let target = self.tree_view.selected_path(); + if let Some(path) = &target { + // Expand every ancestor so the chosen path is visible once + // filtering ends. The path itself (if a directory) is left + // collapsed — the user opens it explicitly. + let mut p = parent_path(path); + while let Some(parent) = p { + self.tree_view.expanded.insert(parent.to_string()); + p = parent_path(parent); + } + } + self.tree_view.cancel_search(); + self.sync_tree_watches(); + if let Some(path) = target { + let rows = self.tree_view.visible_rows(); + if let Some(idx) = rows.iter().position(|r| r.path == path) { + self.tree_view.selected = idx; + } + } + self.tree_view.scroll_x = 0; + let row_count = self.tree_view.visible_rows().len(); + self.tree_view.clamp_selection(row_count); + self.preview_tree_selected(); + } + + /// After a query change the row set shifts, so pin the cursor to the first + /// *matching* row (skipping the ancestor directories pulled in only to + /// connect the path) and re-preview it. Falls back to the first row when + /// nothing matches the basename directly. + fn reset_tree_selection_after_filter(&mut self) { + self.tree_view.scroll_x = 0; + let rows = self.tree_view.visible_rows(); + if rows.is_empty() { + self.tree_view.selected = 0; + self.preview_tree_selected(); + return; + } + let q = self.tree_view.search_query.lower(); + let first_match = rows + .iter() + .position(|r| r.name.to_lowercase().contains(q)) + .unwrap_or(0); + self.tree_view.selected = first_match; + self.preview_tree_selected(); + } + + /// Walk the entire tree once (depth-capped at `max_depth`, gitignore applied + /// via the same guarded reader used for lazy expansion), populating the + /// per-directory cache and a flat search index. Synchronous on the UI thread + /// like the per-level reads — one keystroke triggers it, then all filtering + /// is in-memory. + pub(crate) fn build_tree_index(&mut self) { + self.ensure_tree_root(); + let max_depth = self.cfg_tree.max_depth; + let mut index = Vec::new(); + // (dir, depth-of-its-children): the root's children sit at depth 0. + let mut stack = vec![(String::new(), 0usize)]; + while let Some((dir, depth)) = stack.pop() { + self.ensure_tree_children(&dir); + let children = match self.tree_view.cache.get(&dir) { + Some(c) => c.clone(), + None => continue, + }; + for entry in children { + let path = if dir.is_empty() { + entry.name.clone() + } else { + format!("{dir}/{}", entry.name) + }; + index.push(TreeIndexEntry { + name_lower: entry.name.to_lowercase(), + path: path.clone(), + }); + // Descend only while the next level stays within max_depth, + // mirroring the expand guard (`depth + 1 > max_depth` blocks). + if entry.is_dir && depth < max_depth { + stack.push((path, depth + 1)); + } + } + } + self.tree_view.index = index; + } +} \ No newline at end of file diff --git a/src/app_init.rs b/src/app_init.rs new file mode 100644 index 0000000..75f4e60 --- /dev/null +++ b/src/app_init.rs @@ -0,0 +1,35 @@ +use crate::app::App; +use crate::session::SessionState; + +pub(crate) fn init_app( + repo_path: &str, + cfg: &crate::config::Config, + startup_commands: &[crate::config::StartupCommand], + leader: crossterm::event::KeyEvent, + saved_session: Option, +) -> App { + let mut app = App::new( + repo_path.to_string(), + cfg.log.prompt_log, + startup_commands, + leader, + ); + app.set_accent_index(cfg.theme.preset_index()); + app.cfg_agent_indicator = cfg.agent_indicator.clone(); + app.cfg_tree = cfg.tree.clone(); + app.mouse_enabled = cfg.mouse.enabled; + 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; + 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 + // `pending_selection` (see `App::restore_session`). Restoring here also + // keeps the fresh-launch terminal focus set by `ensure_initial_terminal` + // from drawing — or routing keystrokes — over the saved focus. + app.restore_session(&state); + } + app +} \ No newline at end of file diff --git a/src/backend/pty.rs b/src/backend/pty.rs index 1c30f8f..3f800b4 100644 --- a/src/backend/pty.rs +++ b/src/backend/pty.rs @@ -240,93 +240,5 @@ impl TerminalBackend for PtyBackend { // Drop here would still work but would obscure that ownership. #[cfg(test)] -mod tests { - use super::*; - use std::time::{Duration, Instant}; - - #[test] - fn pty_backend_create_and_destroy_pane() { - let mut backend = PtyBackend::new("."); - let id = backend - .create_pane(24, 80, None) - .expect("create_pane failed"); - assert_eq!(id, 1); - backend.destroy_pane(id); - assert!(!backend.panes.contains_key(&id)); - } - - /// Deadline for the real-PTY tests below. They spawn the user's actual - /// `$SHELL` (an interactive zsh sources the full rc chain), and cargo - /// runs tests in parallel, so several shells can be initializing at - /// once — under load a 3 s budget was measurably flaky (~2/25 runs). - /// A generous bound only delays the failure verdict; passing runs - /// still finish as soon as the events arrive. - const PTY_TEST_DEADLINE: Duration = Duration::from_secs(15); - - #[test] - fn pty_backend_drains_output_before_exit_event() { - let mut backend = PtyBackend::new("."); - let id = backend - .create_pane(24, 80, None) - .expect("create_pane failed"); - - backend - .send_input(id, b"printf nightcrow-pty-output; exit\n") - .expect("send_input failed"); - - let deadline = Instant::now() + PTY_TEST_DEADLINE; - let mut output = Vec::new(); - let mut saw_exit = false; - while Instant::now() < deadline { - for event in backend.drain_events() { - match event { - BackendEvent::Output { data, .. } => output.extend(data), - BackendEvent::Exited { pane } if pane == id => saw_exit = true, - BackendEvent::Exited { .. } => {} - } - } - if saw_exit { - break; - } - thread::sleep(Duration::from_millis(10)); - } - - assert!(saw_exit, "PTY did not exit before timeout"); - assert!( - String::from_utf8_lossy(&output).contains("nightcrow-pty-output"), - "PTY output was not drained before exit" - ); - } - - #[test] - fn pty_backend_runs_startup_command() { - let mut backend = PtyBackend::new("."); - // The command runs itself on launch — no input is sent. `exit` keeps - // the test bounded by ending the shell after the command prints. - let id = backend - .create_pane(24, 80, Some("printf nightcrow-startup-ran; exit")) - .expect("create_pane failed"); - - let deadline = Instant::now() + PTY_TEST_DEADLINE; - let mut output = Vec::new(); - let mut saw_exit = false; - while Instant::now() < deadline { - for event in backend.drain_events() { - match event { - BackendEvent::Output { data, .. } => output.extend(data), - BackendEvent::Exited { pane } if pane == id => saw_exit = true, - BackendEvent::Exited { .. } => {} - } - } - if saw_exit { - break; - } - thread::sleep(Duration::from_millis(10)); - } - - assert!( - String::from_utf8_lossy(&output).contains("nightcrow-startup-ran"), - "startup command did not run automatically" - ); - } -} +#[path = "pty_tests.rs"] +mod tests; diff --git a/src/backend/pty_tests.rs b/src/backend/pty_tests.rs new file mode 100644 index 0000000..86a5b6f --- /dev/null +++ b/src/backend/pty_tests.rs @@ -0,0 +1,88 @@ +use super::*; +use std::time::{Duration, Instant}; + +#[test] +fn pty_backend_create_and_destroy_pane() { + let mut backend = PtyBackend::new("."); + let id = backend + .create_pane(24, 80, None) + .expect("create_pane failed"); + assert_eq!(id, 1); + backend.destroy_pane(id); + assert!(!backend.panes.contains_key(&id)); +} + +/// Deadline for the real-PTY tests below. They spawn the user's actual +/// `$SHELL` (an interactive zsh sources the full rc chain), and cargo +/// runs tests in parallel, so several shells can be initializing at +/// once — under load a 3 s budget was measurably flaky (~2/25 runs). +/// A generous bound only delays the failure verdict; passing runs +/// still finish as soon as the events arrive. +const PTY_TEST_DEADLINE: Duration = Duration::from_secs(15); + +#[test] +fn pty_backend_drains_output_before_exit_event() { + let mut backend = PtyBackend::new("."); + let id = backend + .create_pane(24, 80, None) + .expect("create_pane failed"); + + backend + .send_input(id, b"printf nightcrow-pty-output; exit\n") + .expect("send_input failed"); + + let deadline = Instant::now() + PTY_TEST_DEADLINE; + let mut output = Vec::new(); + let mut saw_exit = false; + while Instant::now() < deadline { + for event in backend.drain_events() { + match event { + BackendEvent::Output { data, .. } => output.extend(data), + BackendEvent::Exited { pane } if pane == id => saw_exit = true, + BackendEvent::Exited { .. } => {} + } + } + if saw_exit { + break; + } + thread::sleep(Duration::from_millis(10)); + } + + assert!(saw_exit, "PTY did not exit before timeout"); + assert!( + String::from_utf8_lossy(&output).contains("nightcrow-pty-output"), + "PTY output was not drained before exit" + ); +} + +#[test] +fn pty_backend_runs_startup_command() { + let mut backend = PtyBackend::new("."); + // The command runs itself on launch — no input is sent. `exit` keeps + // the test bounded by ending the shell after the command prints. + let id = backend + .create_pane(24, 80, Some("printf nightcrow-startup-ran; exit")) + .expect("create_pane failed"); + + let deadline = Instant::now() + PTY_TEST_DEADLINE; + let mut output = Vec::new(); + let mut saw_exit = false; + while Instant::now() < deadline { + for event in backend.drain_events() { + match event { + BackendEvent::Output { data, .. } => output.extend(data), + BackendEvent::Exited { pane } if pane == id => saw_exit = true, + BackendEvent::Exited { .. } => {} + } + } + if saw_exit { + break; + } + thread::sleep(Duration::from_millis(10)); + } + + assert!( + String::from_utf8_lossy(&output).contains("nightcrow-startup-ran"), + "startup command did not run automatically" + ); +} \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..7a1ec8f --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,285 @@ +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; + +/// nightcrow — TUI for Agentic Coding +/// +/// Opens a git diff viewer (top) and multi-terminal panes (bottom) +/// in the current directory. +#[derive(Parser)] +#[command(version, about, long_about = None)] +pub(crate) struct Cli { + /// Open this repository in a project tab. Repeatable — each --repo adds + /// a tab. With none, nightcrow starts with no project open. + #[arg(short, long)] + pub(crate) repo: Vec, + + /// Open a terminal pane running this command at startup. Repeatable; + /// each --exec adds one pane after any config [[startup_command]] panes. + #[arg(long = "exec", value_name = "COMMAND")] + pub(crate) exec: Vec, + + #[command(subcommand)] + pub(crate) command: Option, +} + +#[derive(Subcommand)] +pub(crate) enum Commands { + /// Write a starter config file to ~/.nightcrow/config.toml + Init { + /// Overwrite the config file if it already exists + #[arg(long)] + force: bool, + }, + /// Serve the web viewer without starting the TUI. + /// + /// Runs in the foreground until interrupted. Unlike the TUI's optional + /// viewer, this needs no terminal — the repositories come from --repo. + /// --repo is optional: with none, the viewer starts on an empty catalog, + /// the same state the TUI starts in when launched without a repository. + Serve { + /// Repository to serve. Repeatable. Optional — omit to start empty. + #[arg(short, long)] + repo: Vec, + /// Override the configured port. + #[arg(long)] + port: Option, + /// Override the configured bind address. `0.0.0.0` exposes the server + /// — and the shells it serves — to the whole network over plain HTTP. + #[arg(long)] + bind: Option, + }, +} + +/// The optional browser surfaces, which start and stop together with the app. +/// +/// Grouped because they are always passed as a pair and are the same kind of +/// thing: an independently-failable server the TUI does not depend on. +pub(crate) struct WebSurfaces { + pub(crate) mirror: Option, + pub(crate) viewer: Option, +} + +/// Start the viewer alongside the TUI when `[web_viewer] enabled` is set. +/// +/// Like the mirror, a bind failure only disables the viewer with a warning — +/// the local TUI is the primary interface and must still come up. +pub(crate) fn start_viewer_if_enabled( + cfg: &mut crate::config::Config, + repo_paths: &[String], +) -> Result> { + if !cfg.web_viewer.enabled { + return Ok(None); + } + let path = crate::config::config_file_path()?; + if let Some(password) = crate::config::ensure_web_viewer_password(cfg, &path)? { + eprintln!( + "nightcrow: generated a web viewer password and saved it to {}:", + path.display() + ); + eprintln!(" {password}"); + } + // The viewer runs the same configured startup terminals as the TUI (in its + // own, independent PTYs), or one bare shell when none are configured. + let startup = cfg + .startup_commands + .iter() + .map(|sc| sc.command.clone()) + .collect(); + // Alongside the TUI the viewer does not persist: the TUI owns the workspace + // file and the catalog already follows its tabs. + match crate::web::viewer::server::ViewerServer::start_from_config( + &cfg.web_viewer, + &cfg.agent_indicator, + repo_paths, + false, + startup, + ) { + Ok(server) => { + eprintln!("nightcrow: web viewer serving at http://{}/", server.addr()); + Ok(Some(server)) + } + Err(err) => { + eprintln!("nightcrow: web viewer disabled — {err:#}"); + Ok(None) + } + } +} + +/// Serve the viewer headlessly until interrupted. +/// +/// The starting catalog comes from `--repo` plus the remembered workspace — +/// either may be empty, which starts the viewer on an empty catalog just like +/// the TUI does. From there the browser owns the set: the viewer's own open +/// and close routes add and drop repositories, and `persist` writes the result +/// back to the workspace file since no TUI is doing it. +pub(crate) fn run_serve( + repos: Vec, + port: Option, + bind: Option, +) -> Result<()> { + let mut cfg = crate::config::load_config()?; + if let Some(port) = port { + cfg.web_viewer.port = port; + } + if let Some(bind) = bind { + cfg.web_viewer.bind = bind; + } + // `serve` is an explicit request, so the config toggle is not consulted — + // the user already said what they want by running this. + cfg.web_viewer.enabled = true; + + let path = crate::config::config_file_path()?; + if let Some(password) = crate::config::ensure_web_viewer_password(&mut cfg, &path)? { + eprintln!( + "nightcrow: generated a web viewer password and saved it to {}:", + path.display() + ); + eprintln!(" {password}"); + } + + let mut paths = resolve_serve_repos(&repos)?; + // Unify with the TUI/mirror: restore the previously-open projects so the + // viewer does not start blank each launch. Explicit --repo comes first and + // wins; remembered repos that still exist fill in after, de-duplicated. + if let Some(ws) = crate::session::load_workspace() { + for repo in ws.repos { + if std::path::Path::new(&repo).is_dir() && !paths.contains(&repo) { + paths.push(repo); + } + } + } + let startup = cfg + .startup_commands + .iter() + .map(|sc| sc.command.clone()) + .collect(); + let server = crate::web::viewer::server::ViewerServer::start_from_config( + &cfg.web_viewer, + &cfg.agent_indicator, + &paths, + true, + startup, + )?; + if paths.is_empty() { + // An empty catalog is a legitimate state — the same one the TUI starts + // in when launched with no repository. The viewer shows its + // no-repository state and can still be reached; the page's folder + // picker is the way in from there. + eprintln!( + "nightcrow: web viewer serving an empty catalog (no --repo given) at http://{}/", + server.addr() + ); + } else { + eprintln!( + "nightcrow: web viewer serving {} repositor{} at http://{}/", + paths.len(), + if paths.len() == 1 { "y" } else { "ies" }, + server.addr() + ); + } + if !server.addr().ip().is_loopback() { + // Worth saying out loud: this is not the default, it carries shells, + // and there is no TLS to fall back on. + eprintln!( + "nightcrow: WARNING bound to {} — repository contents and interactive", + server.addr().ip() + ); + eprintln!("nightcrow: shells are reachable from the network over plain HTTP."); + } + eprintln!("nightcrow: press Ctrl-C to stop"); + + // The accept loop owns its own threads; park this one until interrupted. + loop { + std::thread::park(); + } +} + +/// Canonicalize and de-duplicate the `--repo` list for `serve`. +/// +/// Two spellings of one worktree must collapse to one catalog entry, or the +/// browser shows the same repository twice under different ids. +fn resolve_serve_repos(repos: &[std::path::PathBuf]) -> Result> { + let mut out: Vec = Vec::new(); + for repo in repos { + let expanded = crate::util::expand_tilde(repo); + if !expanded.exists() { + anyhow::bail!("no such directory: {}", expanded.display()); + } + let resolved = crate::git::resolve_repo_path(&expanded) + .to_string_lossy() + .into_owned(); + if !out.contains(&resolved) { + out.push(resolved); + } + } + Ok(out) +} + +/// Bootstrap the web login credential and start the mirror server when enabled. +/// +/// Runs before the alternate screen so a generated password and any bind error +/// surface as plain stderr. A bind failure disables the web mirror with a +/// warning rather than aborting the whole app — the local TUI still runs. +pub(crate) fn start_web_if_enabled(cfg: &mut crate::config::Config) -> Result> { + if !cfg.web_mirror.enabled { + return Ok(None); + } + let path = crate::config::config_file_path()?; + if let Some(password) = crate::config::ensure_web_mirror_password(cfg, &path)? { + eprintln!( + "nightcrow web: generated a login password and saved it to {}:", + path.display() + ); + eprintln!(" {password}"); + } + match crate::web::WebServer::start_from_config(&cfg.web_mirror) { + Ok(server) => { + eprintln!("nightcrow web: mirror serving at http://{}/", server.addr()); + Ok(Some(server)) + } + Err(err) => { + eprintln!("nightcrow web: mirror disabled — {err:#}"); + Ok(None) + } + } +} + +pub(crate) fn run_init(force: bool) -> Result<()> { + match crate::config::init_config(force)? { + crate::config::InitOutcome::Created(path) => { + println!("Created starter config at {}", path.display()); + println!("Edit it to reserve startup commands, panel layout, theme, and more."); + } + crate::config::InitOutcome::AlreadyExists(path) => { + println!( + "Config already exists at {} — left untouched (pass --force to overwrite).", + path.display() + ); + } + } + Ok(()) +} + +/// Resolve `--repo` paths to resolved strings, used by `main` before the TUI. +pub(crate) fn resolve_repo_paths(repos: Vec) -> Result, anyhow::Error> { + let mut out = Vec::with_capacity(repos.len()); + for p in repos { + out.push( + crate::git::resolve_repo_path(crate::util::expand_tilde(p)) + .to_string_lossy() + .to_string(), + ); + } + Ok(out) +} + +/// Pick the log anchor path from the resolved repo list or the cwd. +pub(crate) fn log_anchor_for(repo_paths: &[String]) -> Result { + match repo_paths.first() { + Some(path) => Ok(path.clone()), + None => Ok(std::env::current_dir() + .context("cannot determine current directory")? + .to_string_lossy() + .to_string()), + } +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index c71c5ef..1885db0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,25 @@ use anyhow::{Context, Result}; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +mod layout; +mod log; +mod panels; +mod web; + +pub use layout::{ + Accent, InputConfig, LayoutConfig, StartupCommand, ThemeConfig, parse_leader, +}; +pub use log::{LogConfig, LogRotation}; +#[cfg(test)] +pub use log::LogLevel; +pub use panels::{AgentIndicatorConfig, MouseConfig, TreeConfig}; +pub use web::{ + WebMirrorConfig, WebViewerConfig, ensure_web_mirror_password, ensure_web_viewer_password, +}; +#[cfg(test)] +pub use web::generate_password; + /// Upper bound on the number of `[[startup_command]]` + `--exec` panes opened /// at launch. The value matches the `F3`..`F10` / ` 3`..`9`,`0` jump-key /// range, so every startup pane is reachable by a direct key: `F1`/`F2` reach @@ -31,569 +48,6 @@ pub struct Config { pub startup_commands: Vec, } -/// Default leader chord literal. `Ctrl+F` is a one-handed left-hand chord that -/// avoids tmux's own `Ctrl+B` prefix (so nightcrow can run inside tmux) AND the -/// Ctrl chords that an inner Claude Code pane reserves (`Ctrl+G` = external -/// editor, plus `Ctrl+O/R/S/T/L/…`). It also dodges terminal flow control -/// (`Ctrl+Q`/`Ctrl+S` = XON/XOFF) and the shell signals `Ctrl+C/D/Z`. Its only -/// collision is `Ctrl+F` as forward-char (readline) / page-forward (vim), which -/// users almost always reach via the arrow keys / PageDown instead; when needed -/// it stays reachable via ``. -const DEFAULT_LEADER: &str = "ctrl+f"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct MouseConfig { - /// Capture the mouse so clicks reach mouse-aware pane programs and wheel - /// scrolls move the pane under the pointer. While captured, the outer - /// terminal only performs its own text selection with Shift held — the - /// standard override every major terminal honors. Set to `false` to give - /// the mouse back to the outer terminal entirely (plain-drag selection, - /// no click forwarding). - pub enabled: bool, -} - -impl Default for MouseConfig { - fn default() -> Self { - Self { enabled: true } - } -} - -/// Default TCP port for the web mirror server. -const DEFAULT_WEB_PORT: u16 = 8090; -/// Viewer default. Adjacent to the mirror's but distinct: both can run at once. -const DEFAULT_WEB_VIEWER_PORT: u16 = 8091; -/// Table name for the mirror's settings. Named for what it is, matching -/// `[web_viewer]`; `[web]` alone did not say which web surface it meant. -const WEB_MIRROR_TABLE: &str = "web_mirror"; -const WEB_VIEWER_TABLE: &str = "web_viewer"; -/// Default bind address: loopback only. Exposing the server on a routable -/// address is a deliberate opt-in because it grants live control of a shell. -const DEFAULT_WEB_BIND: &str = "127.0.0.1"; -/// Length (characters) of an auto-generated web password. -const GENERATED_PASSWORD_LEN: usize = 24; -/// Alphabet for generated passwords: alphanumeric minus visually ambiguous -/// glyphs (0/O, 1/l/I). All chars are TOML-safe, so the persisted value never -/// needs escaping when written as a basic `"..."` string. -const PASSWORD_ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"; - -/// Web mirror server: serve a live, controllable view of this nightcrow over -/// HTTP so a browser and the local terminal drive the same session. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct WebMirrorConfig { - /// Enable the web mirror. Off by default — turning it on exposes live - /// view+control of this nightcrow over the network, so it is opt-in. - pub enabled: bool, - /// Address to bind. Defaults to loopback (`127.0.0.1`); set to `0.0.0.0` - /// only deliberately, and prefer an SSH tunnel / reverse proxy for remote - /// access since the server speaks plain HTTP (no built-in TLS). - pub bind: String, - /// TCP port for the web server. - pub port: u16, - /// Plaintext login password. When the web server is enabled and neither - /// this nor `hashed_password` is set, a random password is generated and - /// written back here so it survives restarts and stays readable. - pub password: Option, - /// Optional Argon2 PHC hash — an alternative to storing `password` in - /// plaintext. Takes precedence over `password` when both are present. - pub hashed_password: Option, -} - -impl Default for WebMirrorConfig { - fn default() -> Self { - Self { - enabled: false, - bind: DEFAULT_WEB_BIND.to_string(), - port: DEFAULT_WEB_PORT, - password: None, - hashed_password: None, - } - } -} - -impl WebMirrorConfig { - /// Whether a login credential is already configured (either form). - pub fn has_credential(&self) -> bool { - self.hashed_password.is_some() || self.password.as_deref().is_some_and(|p| !p.is_empty()) - } -} - -/// The native web viewer (`[web_viewer]`). Independent of the mirror: its own -/// port, cookie, and credential, so enabling one does not expose the other. -#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] -#[serde(default)] -pub struct WebViewerConfig { - /// Enable the viewer alongside the TUI. Off by default — it exposes both - /// repository contents and interactive terminals. - pub enabled: bool, - /// Address to bind. Loopback by default; the server speaks plain HTTP, so - /// remote access belongs behind an SSH tunnel or reverse proxy. - pub bind: String, - pub port: u16, - /// Plaintext login password, generated and written back on first enable. - pub password: Option, - /// Optional Argon2 PHC hash. Takes precedence over `password`. - pub hashed_password: Option, -} - -impl Default for WebViewerConfig { - fn default() -> Self { - Self { - enabled: false, - bind: DEFAULT_WEB_BIND.to_string(), - port: DEFAULT_WEB_VIEWER_PORT, - password: None, - hashed_password: None, - } - } -} - -impl WebViewerConfig { - pub fn has_credential(&self) -> bool { - self.hashed_password.is_some() || self.password.as_deref().is_some_and(|p| !p.is_empty()) - } -} - -/// Generate a random, human-readable password from the OS RNG. -/// -/// Uses a 55-character unambiguous alphabet. The modulo reduction introduces a -/// negligible bias (256 mod 55) that is immaterial for a locally-scoped dev -/// credential; `getrandom` is the same OS entropy source Argon2 salts use. -pub fn generate_password() -> Result { - let mut bytes = [0u8; GENERATED_PASSWORD_LEN]; - getrandom::fill(&mut bytes) - .map_err(|e| anyhow::anyhow!("OS RNG unavailable for web password generation: {e}"))?; - Ok(bytes - .iter() - .map(|b| PASSWORD_ALPHABET[usize::from(*b) % PASSWORD_ALPHABET.len()] as char) - .collect()) -} - -/// Ensure the enabled web server 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. -pub fn ensure_web_mirror_password( - cfg: &mut Config, - path: &std::path::Path, -) -> Result> { - if cfg.web_mirror.has_credential() { - return Ok(None); - } - let password = generate_password()?; - persist_password(path, WEB_MIRROR_TABLE, &password) - .with_context(|| format!("persisting generated web password to {}", path.display()))?; - cfg.web_mirror.password = Some(password.clone()); - Ok(Some(password)) -} - -/// Same bootstrap for the viewer's own `[web_viewer]` credential. -/// -/// The viewer gets a *separate* password rather than sharing the mirror's: the -/// two servers already run on separate ports with separate cookies, and one -/// credential granting both would make that separation cosmetic. -pub fn ensure_web_viewer_password( - cfg: &mut Config, - path: &std::path::Path, -) -> Result> { - if cfg.web_viewer.has_credential() { - return Ok(None); - } - let password = generate_password()?; - persist_password(path, WEB_VIEWER_TABLE, &password).with_context(|| { - format!( - "persisting generated web viewer password to {}", - path.display() - ) - })?; - cfg.web_viewer.password = Some(password.clone()); - Ok(Some(password)) -} - -/// Write `password` into the `[{table}]` table of the TOML file at `path`. -/// -/// Preserves the rest of the file (including comments) by inserting a single -/// `password = "..."` line: right after an existing `[{table}]` header, or as a -/// new appended table when none exists. The parent directory is created if -/// needed and the file is written user-only (0600 on Unix) since it holds a -/// secret. `password` must contain only TOML-safe characters (the generator's -/// alphabet guarantees this), so it is emitted as a basic string unescaped. -fn persist_password(path: &std::path::Path, table: &str, password: &str) -> Result<()> { - let existing = if path.exists() { - std::fs::read_to_string(path) - .with_context(|| format!("reading config file {}", path.display()))? - } else { - String::new() - }; - let updated = insert_password(&existing, table, password); - - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("creating config directory {}", parent.display()))?; - } - std::fs::write(path, &updated) - .with_context(|| format!("writing config file {}", path.display()))?; - restrict_permissions(path); - Ok(()) -} - -/// Pure text transform behind `persist_web_password`, isolated for testing. -/// Inserts `password = "..."` under the first `[{table}]` header, or appends a -/// new table when the source has none. -fn insert_password(source: &str, table: &str, password: &str) -> String { - let line = format!("password = \"{password}\""); - if let Some(insert_at) = table_header_line_end(source, table) { - let mut out = String::with_capacity(source.len() + line.len() + 1); - out.push_str(&source[..insert_at]); - out.push('\n'); - out.push_str(&line); - out.push_str(&source[insert_at..]); - out - } else { - let mut out = String::with_capacity(source.len() + line.len() + 16); - out.push_str(source); - if !out.is_empty() && !out.ends_with('\n') { - out.push('\n'); - } - if !out.is_empty() { - out.push('\n'); - } - out.push_str(&format!("[{table}]\n")); - out.push_str(&line); - out.push('\n'); - out - } -} - -/// Byte offset of the end of the first line that is exactly `[{table}]` -/// (ignoring surrounding whitespace), or `None` when no such header exists. -/// Comment lines (`# [web]`) are not headers and are skipped. -fn table_header_line_end(source: &str, table: &str) -> Option { - let mut offset = 0; - for line in source.split_inclusive('\n') { - if line.trim() == format!("[{table}]") { - // Point at the newline (or end of source) that terminates the - // header line so the insert lands on the following line. - return Some(offset + line.trim_end_matches('\n').len()); - } - offset += line.len(); - } - None -} - -/// Best-effort tighten of a secret-bearing file to owner-only permissions. -/// Failure is non-fatal: on platforms without Unix permissions this is a no-op, -/// and a permission error should not stop the server from starting. -fn restrict_permissions(path: &std::path::Path) { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); - } - #[cfg(not(unix))] - { - let _ = path; - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct InputConfig { - /// The leader (prefix) chord. Every nightcrow app command is reached by - /// pressing this key, then a follow-up key (tmux-style). Accepts a single - /// `ctrl+` chord; the parser rejects anything that doubles as a - /// no-prefix reserved key (F1..F10, Shift+arrows, Shift+PgUp/PgDn). - pub leader: String, -} - -impl Default for InputConfig { - fn default() -> Self { - Self { - leader: DEFAULT_LEADER.to_string(), - } - } -} - -/// 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. -pub fn parse_leader(spec: &str) -> Result { - let normalized = spec.trim().to_ascii_lowercase(); - let rest = normalized.strip_prefix("ctrl+").ok_or_else(|| { - anyhow::anyhow!( - "input.leader \"{spec}\" must be a ctrl chord like \"ctrl+b\" \ - (only ctrl+ leaders are supported)" - ) - })?; - let mut chars = rest.chars(); - let (Some(c), None) = (chars.next(), chars.next()) else { - anyhow::ensure!( - false, - "input.leader \"{spec}\" must name exactly one ascii character after ctrl+" - ); - unreachable!() - }; - anyhow::ensure!( - c.is_ascii_alphabetic(), - "input.leader \"{spec}\" must use an ascii letter after ctrl+ \ - (e.g. ctrl+b; ctrl+1, ctrl+-, ctrl+space are not allowed)" - ); - // Terminals send Ctrl+I as Tab (0x09) and Ctrl+M as Enter/CR (0x0d), so - // crossterm surfaces those as KeyCode::Tab / KeyCode::Enter — never the - // Char('i')/Char('m') + CONTROL event that is_leader_key looks for. Such a - // leader could be armed but never recognized, so reject it up front. - anyhow::ensure!( - !matches!(c, 'i' | 'm'), - "input.leader \"{spec}\" is not usable: terminals deliver Ctrl+I as Tab \ - 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 via the xterm - // convention. 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. - Ok(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)) -} - -/// A single reserved startup command. `name` labels the pane's tab; when -/// absent the command text is used as the label. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct StartupCommand { - /// Optional tab label. Falls back to `command` when omitted. - pub name: Option, - /// Shell command run in the pane immediately on launch. - pub command: String, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Accent { - #[default] - Yellow, - Cyan, - Green, - Magenta, - Blue, -} - -// Compile-time guard: a future refactor must not shrink `Accent::ALL` to -// empty. `from_index` would otherwise rely on a runtime fallback we'd -// rather not exercise. `const` items don't accept `_` inside an `impl` -// block, so this lives at module scope. -const _: () = assert!(!Accent::ALL.is_empty(), "Accent::ALL must be non-empty"); - -impl Accent { - // Variant declaration order MUST match this slice so accent_idx values - // persisted in pre-existing session.json files keep mapping to the same - // color after the strong-enum migration. - pub const ALL: &'static [Accent] = &[ - Accent::Yellow, - Accent::Cyan, - Accent::Green, - Accent::Magenta, - Accent::Blue, - ]; - - pub fn color(self) -> ratatui::style::Color { - use ratatui::style::Color::*; - match self { - Accent::Yellow => Yellow, - Accent::Green => Green, - Accent::Cyan => Cyan, - Accent::Magenta => Magenta, - Accent::Blue => Blue, - } - } - - pub fn index(self) -> usize { - // Fall back to 0 when a variant is missing from `ALL` — should be - // unreachable, but a runtime panic on a UI helper is worse than a - // silently miscoloured tile. The roundtrip test pins the invariant. - Self::ALL.iter().position(|&a| a == self).unwrap_or(0) - } - - pub fn from_index(idx: usize) -> Accent { - // The compile-time guard above keeps `len > 0`, so `% len` is sound. - // `get(...).copied()` is the same value as direct indexing here; the - // form matches the explicit non-panicking pattern used for `index`. - Self::ALL - .get(idx % Self::ALL.len()) - .copied() - .unwrap_or(Accent::Yellow) - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct ThemeConfig { - /// Accent color preset. - pub name: Accent, -} - -impl ThemeConfig { - pub fn preset_index(&self) -> usize { - self.name.index() - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum LogRotation { - #[default] - Daily, - Hourly, - Size, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum LogLevel { - Error, - Warn, - #[default] - Info, - Debug, - Trace, -} - -impl LogLevel { - pub fn as_str(self) -> &'static str { - match self { - LogLevel::Error => "error", - LogLevel::Warn => "warn", - LogLevel::Info => "info", - LogLevel::Debug => "debug", - LogLevel::Trace => "trace", - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct LogConfig { - /// Enable file-based logging - pub enabled: bool, - /// Log directory — relative paths are resolved from the repo root - pub dir: String, - /// Rotation policy - pub rotation: LogRotation, - /// Maximum file size in MB before rotating (used when rotation = Size) - pub max_size_mb: u64, - /// Delete log files older than this many days (0 = keep forever) - pub max_days: u32, - /// Opt-in: record terminal prompt input line by line - pub prompt_log: bool, - /// Minimum log level - pub level: LogLevel, - /// Number of commits loaded per commit-log page. Must lie in 50..=500. - /// The default (100) is the sweet spot for the async refresh path: small - /// enough that the background worker returns in well under a frame, big - /// enough that scrolling rarely outruns the prefetch threshold. - pub commit_log_page_size: usize, - /// Trigger a background prefetch once the selection is within this many - /// rows of the loaded tail. Must be in 1..=page_size. - pub commit_log_prefetch_threshold: usize, -} - -impl Default for LogConfig { - fn default() -> Self { - Self { - enabled: true, - dir: ".nightcrow/logs".to_string(), - rotation: LogRotation::default(), - max_size_mb: 10, - max_days: 7, - prompt_log: false, - level: LogLevel::default(), - commit_log_page_size: 100, - commit_log_prefetch_threshold: 25, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct LayoutConfig { - /// Percentage of vertical space for the upper (diff) panel (1–99) - pub upper_pct: u16, - /// Percentage of horizontal space for the file list within the upper panel (1–99) - pub file_list_pct: u16, -} - -impl Default for LayoutConfig { - fn default() -> Self { - Self { - upper_pct: 55, - file_list_pct: 25, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct AgentIndicatorConfig { - /// Show the "recently touched" marker next to files in the status panel. - pub enabled: bool, - /// Seconds within which a file is considered hot after its mtime. - /// Must be >= 3 so the bright→normal fade transition stays observable. - pub hot_window_secs: u64, - /// When idle (no manual navigation for >=2s), move selection to the - /// freshest hot file. Opt-in: set to `true` so the file list follows - /// whichever file was most recently touched on disk — useful when an - /// agent, build script, or external process is editing files in a - /// neighbouring pane. - pub auto_follow: bool, -} - -impl Default for AgentIndicatorConfig { - fn default() -> Self { - Self { - enabled: true, - hot_window_secs: 15, - auto_follow: false, - } - } -} - -/// Configuration for the read-only file-tree navigator (`ViewMode::Tree`). -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct TreeConfig { - /// Hide paths matched by `.gitignore` (e.g. `target/`, `node_modules/`). - /// On by default so the tree doesn't explode into build artifacts; set to - /// `false` to browse every file on disk. - pub respect_gitignore: bool, - /// Maximum directory depth the navigator will expand into. A guard against - /// pathologically deep trees; expansion past this depth is a no-op. Must be - /// in 1..=1024. - pub max_depth: usize, - /// Watch expanded directories for filesystem changes and refresh the tree - /// live while Tree mode is open. On by default; only the visible (expanded) - /// directories are watched, non-recursively. Set to `false` to fall back to - /// refreshing only on Tree-mode entry — useful on very large trees or - /// filesystems where native watching is costly or unsupported. - pub live_watch: bool, -} - -impl Default for TreeConfig { - fn default() -> Self { - Self { - respect_gitignore: true, - max_depth: 64, - live_watch: true, - } - } -} - fn default_config_path() -> Option { dirs::home_dir().map(|h| h.join(".nightcrow").join("config.toml")) } @@ -632,7 +86,7 @@ pub fn init_config(force: bool) -> Result { /// Path-explicit core of `init_config` (no `$HOME` lookup) so the write/skip /// behaviour is unit-testable against a temp directory. -fn write_config_template(path: &std::path::Path, force: bool) -> Result { +pub(super) fn write_config_template(path: &std::path::Path, force: bool) -> Result { if path.exists() && !force { return Ok(InitOutcome::AlreadyExists(path.to_path_buf())); } @@ -659,7 +113,7 @@ pub fn load_config() -> Result { Ok(cfg) } -fn validate_config(cfg: &Config) -> Result<()> { +pub fn validate_config(cfg: &Config) -> Result<()> { anyhow::ensure!( cfg.layout.upper_pct >= 1 && cfg.layout.upper_pct <= 99, "layout.upper_pct must be between 1 and 99" @@ -762,779 +216,4 @@ pub fn resolve_startup_commands(cfg: &Config, cli_exec: &[String]) -> Result assert_eq!(p, path), - InitOutcome::AlreadyExists(_) => panic!("expected Created on a fresh path"), - } - let written = std::fs::read_to_string(&path).unwrap(); - assert_eq!(written, EXAMPLE_CONFIG); - } - - #[test] - fn write_config_template_preserves_existing_without_force() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write(&path, "# user edits\n").unwrap(); - match write_config_template(&path, false).unwrap() { - InitOutcome::AlreadyExists(p) => assert_eq!(p, path), - InitOutcome::Created(_) => panic!("must not overwrite an existing file"), - } - // The user's content survives untouched. - assert_eq!(std::fs::read_to_string(&path).unwrap(), "# user edits\n"); - } - - #[test] - fn write_config_template_overwrites_with_force() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write(&path, "# stale\n").unwrap(); - match write_config_template(&path, true).unwrap() { - InitOutcome::Created(p) => assert_eq!(p, path), - InitOutcome::AlreadyExists(_) => panic!("force should rewrite the file"), - } - assert_eq!(std::fs::read_to_string(&path).unwrap(), EXAMPLE_CONFIG); - } - - #[test] - fn parse_toml_overrides() { - let toml = r#" -[layout] -upper_pct = 60 -file_list_pct = 30 -"#; - let cfg: Config = toml::from_str(toml).unwrap(); - assert_eq!(cfg.layout.upper_pct, 60); - assert_eq!(cfg.layout.file_list_pct, 30); - } - - #[test] - fn validation_rejects_out_of_range() { - let mut cfg = Config::default(); - cfg.layout.upper_pct = 0; - assert!(validate_config(&cfg).is_err()); - cfg.layout.upper_pct = 100; - assert!(validate_config(&cfg).is_err()); - } - - #[test] - fn parse_rejects_invalid_log_rotation() { - let toml = r#" -[log] -rotation = "weekly" -"#; - assert!(toml::from_str::(toml).is_err()); - } - - #[test] - fn parse_rejects_invalid_log_level() { - let toml = r#" -[log] -level = "verbose" -"#; - assert!(toml::from_str::(toml).is_err()); - } - - #[test] - fn parse_accepts_all_valid_rotations() { - for rotation in &["daily", "hourly", "size"] { - let toml = format!("[log]\nrotation = \"{rotation}\"\n"); - assert!( - toml::from_str::(&toml).is_ok(), - "rotation={rotation} should parse" - ); - } - } - - #[test] - fn parse_accepts_all_valid_levels() { - for level in &["error", "warn", "info", "debug", "trace"] { - let toml = format!("[log]\nlevel = \"{level}\"\n"); - assert!( - toml::from_str::(&toml).is_ok(), - "level={level} should parse" - ); - } - } - - #[test] - fn log_config_defaults_are_sane() { - let cfg = LogConfig::default(); - assert!(cfg.enabled); - assert!(!cfg.prompt_log); - assert_eq!(cfg.rotation, LogRotation::Daily); - assert_eq!(cfg.level, LogLevel::Info); - assert_eq!(cfg.max_days, 7); - assert_eq!(cfg.commit_log_page_size, 100); - assert_eq!(cfg.commit_log_prefetch_threshold, 25); - } - - #[test] - fn commit_log_pagination_parses_from_toml() { - let toml = r#" -[log] -commit_log_page_size = 400 -commit_log_prefetch_threshold = 80 -"#; - let cfg: Config = toml::from_str(toml).unwrap(); - assert_eq!(cfg.log.commit_log_page_size, 400); - assert_eq!(cfg.log.commit_log_prefetch_threshold, 80); - validate_config(&cfg).unwrap(); - } - - #[test] - fn commit_log_page_size_validation_rejects_out_of_range() { - let mut cfg = Config::default(); - cfg.log.commit_log_page_size = 49; - assert!(validate_config(&cfg).is_err()); - cfg.log.commit_log_page_size = 501; - assert!(validate_config(&cfg).is_err()); - } - - #[test] - fn commit_log_prefetch_threshold_validation_rejects_zero() { - let mut cfg = Config::default(); - cfg.log.commit_log_prefetch_threshold = 0; - assert!(validate_config(&cfg).is_err()); - } - - #[test] - fn commit_log_prefetch_threshold_validation_rejects_above_page_size() { - let mut cfg = Config::default(); - cfg.log.commit_log_page_size = 300; - cfg.log.commit_log_prefetch_threshold = 301; - assert!(validate_config(&cfg).is_err()); - } - - #[test] - fn theme_default_matches_documented_preset() { - let cfg = ThemeConfig::default(); - - assert_eq!(cfg.name, Accent::Yellow); - assert_eq!(cfg.preset_index(), 0); - } - - #[test] - fn accent_index_from_index_roundtrip_for_every_variant() { - // Pin the ALL slice against the enum: a missing entry would make - // `index()` return 0 silently, miscolouring a real variant as the - // default. Iterate every variant via a match so a future variant - // addition forces this test to be updated. - let all = [ - Accent::Yellow, - Accent::Cyan, - Accent::Green, - Accent::Magenta, - Accent::Blue, - ]; - for a in all { - let idx = a.index(); - assert!(idx < Accent::ALL.len(), "{a:?} index {idx} out of range"); - assert_eq!(Accent::from_index(idx), a, "roundtrip failed for {a:?}"); - } - // And confirm the canonical slice length stays in sync. - assert_eq!(Accent::ALL.len(), all.len()); - } - - #[test] - fn log_max_size_mb_validation_rejects_zero_and_huge() { - let mut cfg = Config::default(); - cfg.log.max_size_mb = 0; - assert!(validate_config(&cfg).is_err()); - cfg.log.max_size_mb = 10_001; - assert!(validate_config(&cfg).is_err()); - } - - #[test] - fn log_max_size_mb_validation_accepts_in_range() { - let mut cfg = Config::default(); - cfg.log.max_size_mb = 1; - assert!(validate_config(&cfg).is_ok()); - cfg.log.max_size_mb = 10_000; - assert!(validate_config(&cfg).is_ok()); - } - - #[test] - fn log_max_days_validation_accepts_zero_as_keep_forever_sentinel() { - let mut cfg = Config::default(); - cfg.log.max_days = 0; - assert!(validate_config(&cfg).is_ok()); - } - - #[test] - fn log_max_days_validation_rejects_unreasonable_horizon() { - let mut cfg = Config::default(); - cfg.log.max_days = 3651; - assert!(validate_config(&cfg).is_err()); - } - - #[test] - fn accent_from_index_wraps_out_of_range() { - // Defensive: a stale session.json with a huge accent_idx must not - // panic — `from_index` wraps via `%`. The compile-time guard above - // keeps `ALL` non-empty so `% len` is sound. - assert_eq!( - Accent::from_index(usize::MAX), - Accent::from_index(usize::MAX % Accent::ALL.len()) - ); - assert_eq!(Accent::from_index(Accent::ALL.len()), Accent::from_index(0)); - } - - #[test] - fn agent_indicator_defaults_are_sane() { - let cfg = AgentIndicatorConfig::default(); - assert!(cfg.enabled); - assert!(!cfg.auto_follow); - assert_eq!(cfg.hot_window_secs, 15); - } - - #[test] - fn agent_indicator_parses_from_toml() { - let toml = r#" -[agent_indicator] -enabled = false -hot_window_secs = 30 -auto_follow = false -"#; - let cfg: Config = toml::from_str(toml).unwrap(); - assert!(!cfg.agent_indicator.enabled); - assert!(!cfg.agent_indicator.auto_follow); - assert_eq!(cfg.agent_indicator.hot_window_secs, 30); - } - - #[test] - fn mouse_capture_defaults_on_and_parses_from_toml() { - assert!(Config::default().mouse.enabled); - - let cfg: Config = toml::from_str("[mouse]\nenabled = false\n").unwrap(); - assert!(!cfg.mouse.enabled); - } - - #[test] - fn agent_indicator_validation_rejects_too_small_window() { - let mut cfg = Config::default(); - cfg.agent_indicator.hot_window_secs = 2; - assert!(validate_config(&cfg).is_err()); - } - - #[test] - fn agent_indicator_validation_rejects_too_large_window() { - let mut cfg = Config::default(); - cfg.agent_indicator.hot_window_secs = 3601; - assert!(validate_config(&cfg).is_err()); - } - - #[test] - fn startup_commands_default_to_empty() { - let cfg = Config::default(); - assert!(cfg.startup_commands.is_empty()); - // A config without the table also defaults to empty and validates. - let cfg: Config = toml::from_str("[layout]\nupper_pct = 50\n").unwrap(); - assert!(cfg.startup_commands.is_empty()); - validate_config(&cfg).unwrap(); - } - - #[test] - fn startup_commands_parse_array_of_tables() { - let toml = r#" -[[startup_command]] -name = "Claude" -command = "claude" - -[[startup_command]] -command = "cargo test" -"#; - let cfg: Config = toml::from_str(toml).unwrap(); - assert_eq!(cfg.startup_commands.len(), 2); - assert_eq!(cfg.startup_commands[0].name.as_deref(), Some("Claude")); - assert_eq!(cfg.startup_commands[0].command, "claude"); - assert_eq!(cfg.startup_commands[1].name, None); - assert_eq!(cfg.startup_commands[1].command, "cargo test"); - validate_config(&cfg).unwrap(); - } - - #[test] - fn startup_command_validation_rejects_empty_command() { - let mut cfg = Config::default(); - cfg.startup_commands.push(StartupCommand { - name: Some("blank".into()), - command: " ".into(), - }); - assert!(validate_config(&cfg).is_err()); - } - - #[test] - fn resolve_startup_commands_appends_cli_exec_after_config() { - let mut cfg = Config::default(); - cfg.startup_commands.push(StartupCommand { - name: Some("Claude".into()), - command: "claude".into(), - }); - let resolved = - resolve_startup_commands(&cfg, &["codex".to_string(), "vim".to_string()]).unwrap(); - assert_eq!(resolved.len(), 3); - assert_eq!(resolved[0].command, "claude"); - assert_eq!(resolved[0].name.as_deref(), Some("Claude")); - // CLI entries carry no name and are ordered after config entries. - assert_eq!(resolved[1].command, "codex"); - assert_eq!(resolved[1].name, None); - assert_eq!(resolved[2].command, "vim"); - } - - #[test] - fn resolve_startup_commands_empty_when_nothing_configured() { - let resolved = resolve_startup_commands(&Config::default(), &[]).unwrap(); - assert!(resolved.is_empty()); - } - - #[test] - fn resolve_startup_commands_rejects_empty_exec() { - let resolved = resolve_startup_commands(&Config::default(), &[" ".to_string()]); - assert!(resolved.is_err()); - } - - #[test] - fn resolve_startup_commands_caps_combined_total() { - let mut cfg = Config::default(); - for i in 0..4 { - cfg.startup_commands.push(StartupCommand { - name: None, - command: format!("echo {i}"), - }); - } - // 4 config + 5 CLI = 9 > MAX_STARTUP_COMMANDS (8). - let cli: Vec = (0..5).map(|i| format!("run {i}")).collect(); - assert!(resolve_startup_commands(&cfg, &cli).is_err()); - // 4 config + 4 CLI = 8 is exactly the cap. - let cli: Vec = (0..4).map(|i| format!("run {i}")).collect(); - assert_eq!( - resolve_startup_commands(&cfg, &cli).unwrap().len(), - MAX_STARTUP_COMMANDS - ); - } - - #[test] - fn startup_command_validation_rejects_too_many() { - let mut cfg = Config::default(); - for i in 0..(MAX_STARTUP_COMMANDS + 1) { - cfg.startup_commands.push(StartupCommand { - name: None, - command: format!("echo {i}"), - }); - } - assert!(validate_config(&cfg).is_err()); - } - - #[test] - fn startup_command_validation_accepts_max() { - let mut cfg = Config::default(); - for i in 0..MAX_STARTUP_COMMANDS { - cfg.startup_commands.push(StartupCommand { - name: None, - command: format!("echo {i}"), - }); - } - assert!(validate_config(&cfg).is_ok()); - } - - #[test] - fn input_leader_defaults_to_ctrl_f() { - let cfg = Config::default(); - assert_eq!(cfg.input.leader, "ctrl+f"); - let leader = parse_leader(&cfg.input.leader).unwrap(); - assert_eq!(leader.code, KeyCode::Char('f')); - assert!(leader.modifiers.contains(KeyModifiers::CONTROL)); - } - - #[test] - fn parse_leader_rejects_unencodable_ctrl_chords() { - // Digits and punctuation have no single control-byte encoding, so they - // would break `` literal pass-through and must be rejected. - for spec in ["ctrl+1", "ctrl+-", "ctrl+/", "ctrl+@"] { - assert!( - parse_leader(spec).is_err(), - "{spec} must be rejected as a leader" - ); - } - } - - #[test] - fn parse_leader_rejects_terminal_alias_chords() { - // Ctrl+I == Tab and Ctrl+M == Enter at the byte level, so crossterm - // never reports them as Char('i')/Char('m') and the leader would be - // unrecognizable. - assert!(parse_leader("ctrl+i").is_err(), "ctrl+i aliases Tab"); - assert!(parse_leader("ctrl+m").is_err(), "ctrl+m aliases Enter"); - // Neighboring letters remain valid. - assert!(parse_leader("ctrl+j").is_ok()); - assert!(parse_leader("ctrl+n").is_ok()); - } - - #[test] - fn parse_leader_rejects_non_ctrl_and_multichar() { - assert!(parse_leader("g").is_err(), "bare key is not a ctrl chord"); - assert!(parse_leader("ctrl+ab").is_err(), "leader is a single key"); - } - - #[test] - fn input_leader_parses_from_toml() { - let toml = r#" -[input] -leader = "ctrl+a" -"#; - let cfg: Config = toml::from_str(toml).unwrap(); - assert_eq!(cfg.input.leader, "ctrl+a"); - validate_config(&cfg).unwrap(); - let leader = parse_leader(&cfg.input.leader).unwrap(); - assert_eq!(leader.code, KeyCode::Char('a')); - } - - #[test] - fn parse_leader_accepts_uppercase_and_whitespace() { - let leader = parse_leader(" CTRL+B ").unwrap(); - assert_eq!(leader.code, KeyCode::Char('b')); - assert!(leader.modifiers.contains(KeyModifiers::CONTROL)); - } - - #[test] - fn parse_leader_rejects_non_ctrl_chords() { - assert!(parse_leader("b").is_err()); - assert!(parse_leader("alt+b").is_err()); - assert!(parse_leader("shift+b").is_err()); - } - - #[test] - fn parse_leader_rejects_reserved_and_multichar_keys() { - // F-keys, named keys, and multi-char specs are not single ctrl+ascii - // chords, so they fail the ctrl+ prefix / single-char gates. - assert!(parse_leader("ctrl+f1").is_err()); - assert!(parse_leader("f1").is_err()); - assert!(parse_leader("ctrl+pageup").is_err()); - assert!(parse_leader("ctrl+").is_err()); - assert!(parse_leader("ctrl+ ").is_err()); - } - - #[test] - fn validate_rejects_bad_leader() { - let mut cfg = Config::default(); - cfg.input.leader = "f1".to_string(); - assert!(validate_config(&cfg).is_err()); - } - - #[test] - fn tree_config_defaults_are_sane() { - let cfg = TreeConfig::default(); - assert!(cfg.respect_gitignore); - assert_eq!(cfg.max_depth, 64); - assert!(cfg.live_watch, "live watching is on by default"); - } - - #[test] - fn tree_config_parses_from_toml() { - let toml = r#" -[tree] -respect_gitignore = false -max_depth = 12 -live_watch = false -"#; - let cfg: Config = toml::from_str(toml).unwrap(); - assert!(!cfg.tree.respect_gitignore); - assert_eq!(cfg.tree.max_depth, 12); - assert!(!cfg.tree.live_watch); - validate_config(&cfg).unwrap(); - } - - #[test] - fn tree_max_depth_validation_rejects_out_of_range() { - let mut cfg = Config::default(); - cfg.tree.max_depth = 0; - assert!(validate_config(&cfg).is_err()); - cfg.tree.max_depth = 1025; - assert!(validate_config(&cfg).is_err()); - } - - #[test] - fn config_without_tree_table_defaults() { - // A pre-existing config file with no [tree] table must still parse and - // validate, falling back to defaults. - let cfg: Config = toml::from_str("[layout]\nupper_pct = 50\n").unwrap(); - assert!(cfg.tree.respect_gitignore); - assert_eq!(cfg.tree.max_depth, 64); - assert!(cfg.tree.live_watch); - validate_config(&cfg).unwrap(); - } - - #[test] - fn web_config_defaults_are_off_and_loopback() { - let cfg = WebMirrorConfig::default(); - assert!(!cfg.enabled); - assert_eq!(cfg.bind, "127.0.0.1"); - assert_eq!(cfg.port, 8090); - assert!(cfg.password.is_none()); - assert!(cfg.hashed_password.is_none()); - assert!(!cfg.has_credential()); - } - - #[test] - fn web_mirror_config_parses_from_toml() { - let toml = r#" -[web_mirror] -enabled = true -bind = "0.0.0.0" -port = 9000 -password = "hunter2" -"#; - let cfg: Config = toml::from_str(toml).unwrap(); - assert!(cfg.web_mirror.enabled); - assert_eq!(cfg.web_mirror.bind, "0.0.0.0"); - assert_eq!(cfg.web_mirror.port, 9000); - assert_eq!(cfg.web_mirror.password.as_deref(), Some("hunter2")); - assert!(cfg.web_mirror.has_credential()); - validate_config(&cfg).unwrap(); - } - - #[test] - fn config_without_web_table_defaults() { - // A pre-existing config file with no [web_mirror] table must still parse and - // validate, falling back to the disabled default. - let cfg: Config = toml::from_str("[layout]\nupper_pct = 50\n").unwrap(); - assert!(!cfg.web_mirror.enabled); - assert_eq!(cfg.web_mirror.port, 8090); - validate_config(&cfg).unwrap(); - } - - #[test] - fn web_has_credential_treats_empty_password_as_missing() { - let empty = WebMirrorConfig { - password: Some(String::new()), - ..WebMirrorConfig::default() - }; - assert!( - !empty.has_credential(), - "an empty password is not a credential" - ); - let with_pw = WebMirrorConfig { - password: Some("x".into()), - ..WebMirrorConfig::default() - }; - assert!(with_pw.has_credential()); - let with_hash = WebMirrorConfig { - hashed_password: Some("$argon2id$...".into()), - ..WebMirrorConfig::default() - }; - assert!(with_hash.has_credential()); - } - - #[test] - fn web_validation_rejects_port_zero_when_enabled() { - let mut cfg = Config::default(); - cfg.web_mirror.enabled = true; - cfg.web_mirror.port = 0; - assert!(validate_config(&cfg).is_err()); - } - - #[test] - fn web_validation_rejects_bad_bind_when_enabled() { - let mut cfg = Config::default(); - cfg.web_mirror.enabled = true; - cfg.web_mirror.bind = "not-an-ip".into(); - assert!(validate_config(&cfg).is_err()); - } - - #[test] - fn web_validation_ignores_bind_and_port_when_disabled() { - // A disabled web section is never acted on, so its fields are not - // range-checked — a stale/garbage value must not block startup. - let mut cfg = Config::default(); - cfg.web_mirror.enabled = false; - cfg.web_mirror.port = 0; - cfg.web_mirror.bind = "not-an-ip".into(); - assert!(validate_config(&cfg).is_ok()); - } - - #[test] - fn generate_password_has_expected_length_and_alphabet() { - let pw = generate_password().unwrap(); - assert_eq!(pw.chars().count(), GENERATED_PASSWORD_LEN); - assert!( - pw.bytes().all(|b| PASSWORD_ALPHABET.contains(&b)), - "generated password must only use the unambiguous TOML-safe alphabet" - ); - // Two draws should differ with overwhelming probability. - assert_ne!(pw, generate_password().unwrap()); - } - - #[test] - fn insert_password_adds_line_under_existing_header() { - let source = "[web_mirror]\nenabled = true\nport = 8090\n"; - let out = insert_password(source, WEB_MIRROR_TABLE, "secret"); - assert_eq!( - out, "[web_mirror]\npassword = \"secret\"\nenabled = true\nport = 8090\n", - "the password line must land right after the [web_mirror] header" - ); - // The result round-trips and exposes the password. - let cfg: Config = toml::from_str(&out).unwrap(); - assert_eq!(cfg.web_mirror.password.as_deref(), Some("secret")); - } - - #[test] - fn insert_password_appends_table_when_absent() { - let source = "[layout]\nupper_pct = 55\n"; - let out = insert_password(source, WEB_MIRROR_TABLE, "secret"); - assert!(out.starts_with(source)); - assert!(out.contains("\n[web_mirror]\npassword = \"secret\"\n")); - let cfg: Config = toml::from_str(&out).unwrap(); - assert_eq!(cfg.web_mirror.password.as_deref(), Some("secret")); - } - - #[test] - fn insert_password_appends_table_into_empty_source() { - let out = insert_password("", WEB_MIRROR_TABLE, "secret"); - assert_eq!(out, "[web_mirror]\npassword = \"secret\"\n"); - let cfg: Config = toml::from_str(&out).unwrap(); - assert_eq!(cfg.web_mirror.password.as_deref(), Some("secret")); - } - - #[test] - fn the_web_mirror_table_configures_the_mirror() { - let cfg: Config = toml::from_str("[web_mirror]\nenabled = true\nport = 8100\n").unwrap(); - - assert!(cfg.web_mirror.enabled); - assert_eq!(cfg.web_mirror.port, 8100); - } - - #[test] - fn insert_password_targets_the_named_table() { - // The viewer's credential must land under [web_viewer], not [web] — - // writing it to the wrong table would silently give the mirror a - // second password and leave the viewer without one. - let source = "[web_mirror]\nport = 8090\n"; - - let out = insert_password(source, WEB_VIEWER_TABLE, "vsecret"); - - assert!( - out.contains("[web_viewer]\npassword = \"vsecret\""), - "got: {out}" - ); - let web_table = out.split("[web_viewer]").next().unwrap(); - assert!( - !web_table.contains("vsecret"), - "the viewer password leaked into [web_mirror]: {out}" - ); - } - - #[test] - fn insert_password_finds_an_existing_viewer_table() { - let source = "[web_mirror]\nport = 8090\n\n[web_viewer]\nport = 8091\n"; - - let out = insert_password(source, WEB_VIEWER_TABLE, "vsecret"); - - let viewer = out.split("[web_viewer]").nth(1).unwrap(); - assert!(viewer.contains("password = \"vsecret\""), "got: {out}"); - assert_eq!(out.matches("[web_viewer]").count(), 1, "no duplicate table"); - } - - #[test] - fn insert_password_ignores_commented_header() { - // A `# [web]` comment is not a real table header, so the password must - // be appended as a new table rather than inserted under the comment. - let source = "# [web] example\nfoo = 1\n"; - let out = insert_password(source, WEB_MIRROR_TABLE, "secret"); - assert!(out.contains("\n[web_mirror]\npassword = \"secret\"\n")); - } - - #[test] - fn ensure_web_password_is_noop_when_credential_present() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join("config.toml"); - let mut cfg = Config::default(); - cfg.web_mirror.enabled = true; - cfg.web_mirror.password = Some("preset".into()); - let generated = ensure_web_mirror_password(&mut cfg, &path).unwrap(); - assert!( - generated.is_none(), - "an existing credential must not be replaced" - ); - assert!( - !path.exists(), - "no file should be written when a password exists" - ); - assert_eq!(cfg.web_mirror.password.as_deref(), Some("preset")); - } - - #[test] - fn ensure_web_password_generates_persists_and_sets() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join("nested").join("config.toml"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "[web_mirror]\nenabled = true\n").unwrap(); - - let mut cfg = Config::default(); - cfg.web_mirror.enabled = true; - let generated = ensure_web_mirror_password(&mut cfg, &path).unwrap(); - - let pw = generated.expect("a password must be generated when none is set"); - assert_eq!(cfg.web_mirror.password.as_deref(), Some(pw.as_str())); - // The persisted file now parses back to the same password. - let reparsed: Config = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(reparsed.web_mirror.password.as_deref(), Some(pw.as_str())); - } - - #[test] - fn ensure_web_password_creates_file_when_absent() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join("config.toml"); - let mut cfg = Config::default(); - cfg.web_mirror.enabled = true; - - let pw = ensure_web_mirror_password(&mut cfg, &path) - .unwrap() - .unwrap(); - - assert!(path.exists()); - let reparsed: Config = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(reparsed.web_mirror.password.as_deref(), Some(pw.as_str())); - } - - #[test] - fn log_config_parses_from_toml() { - let toml = r#" -[log] -enabled = false -prompt_log = true -rotation = "size" -max_size_mb = 5 -max_days = 14 -level = "debug" -dir = "/tmp/logs" -"#; - let cfg: Config = toml::from_str(toml).unwrap(); - assert!(!cfg.log.enabled); - assert!(cfg.log.prompt_log); - assert_eq!(cfg.log.rotation, LogRotation::Size); - assert_eq!(cfg.log.max_size_mb, 5); - assert_eq!(cfg.log.max_days, 14); - assert_eq!(cfg.log.level, LogLevel::Debug); - assert_eq!(cfg.log.dir, "/tmp/logs"); - } -} +mod tests; \ No newline at end of file diff --git a/src/config/layout.rs b/src/config/layout.rs new file mode 100644 index 0000000..41902ad --- /dev/null +++ b/src/config/layout.rs @@ -0,0 +1,174 @@ +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use serde::{Deserialize, Serialize}; + +/// Default leader chord literal. `Ctrl+F` is a one-handed left-hand chord that +/// avoids tmux's own `Ctrl+B` prefix (so nightcrow can run inside tmux) AND the +/// Ctrl chords that an inner Claude Code pane reserves (`Ctrl+G` = external +/// editor, plus `Ctrl+O/R/S/T/L/…`). It also dodges terminal flow control +/// (`Ctrl+Q`/`Ctrl+S` = XON/XOFF) and the shell signals `Ctrl+C/D/Z`. Its only +/// collision is `Ctrl+F` as forward-char (readline) / page-forward (vim), which +/// users almost always reach via the arrow keys / PageDown instead; when needed +/// it stays reachable via ``. +pub(super) const DEFAULT_LEADER: &str = "ctrl+f"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct LayoutConfig { + /// Percentage of vertical space for the upper (diff) panel (1–99) + pub upper_pct: u16, + /// Percentage of horizontal space for the file list within the upper panel (1–99) + pub file_list_pct: u16, +} + +impl Default for LayoutConfig { + fn default() -> Self { + Self { + upper_pct: 55, + file_list_pct: 25, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Accent { + #[default] + Yellow, + Cyan, + Green, + Magenta, + Blue, +} + +// Compile-time guard: a future refactor must not shrink `Accent::ALL` to +// empty. `from_index` would otherwise rely on a runtime fallback we'd +// rather not exercise. `const` items don't accept `_` inside an `impl` +// block, so this lives at module scope. +const _: () = assert!(!Accent::ALL.is_empty(), "Accent::ALL must be non-empty"); + +impl Accent { + // Variant declaration order MUST match this slice so accent_idx values + // persisted in pre-existing session.json files keep mapping to the same + // color after the strong-enum migration. + pub const ALL: &'static [Accent] = &[ + Accent::Yellow, + Accent::Cyan, + Accent::Green, + Accent::Magenta, + Accent::Blue, + ]; + + pub fn color(self) -> ratatui::style::Color { + use ratatui::style::Color::*; + match self { + Accent::Yellow => Yellow, + Accent::Green => Green, + Accent::Cyan => Cyan, + Accent::Magenta => Magenta, + Accent::Blue => Blue, + } + } + + pub fn index(self) -> usize { + // Fall back to 0 when a variant is missing from `ALL` — should be + // unreachable, but a runtime panic on a UI helper is worse than a + // silently miscoloured tile. The roundtrip test pins the invariant. + Self::ALL.iter().position(|&a| a == self).unwrap_or(0) + } + + pub fn from_index(idx: usize) -> Accent { + // The compile-time guard above keeps `len > 0`, so `% len` is sound. + // `get(...).copied()` is the same value as direct indexing here; the + // form matches the explicit non-panicking pattern used for `index`. + Self::ALL + .get(idx % Self::ALL.len()) + .copied() + .unwrap_or(Accent::Yellow) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ThemeConfig { + /// Accent color preset. + pub name: Accent, +} + +impl ThemeConfig { + pub fn preset_index(&self) -> usize { + self.name.index() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct InputConfig { + /// The leader (prefix) chord. Every nightcrow app command is reached by + /// pressing this key, then a follow-up key (tmux-style). Accepts a single + /// `ctrl+` chord; the parser rejects anything that doubles as a + /// no-prefix reserved key (F1..F10, Shift+arrows, Shift+PgUp/PgDn). + pub leader: String, +} + +impl Default for InputConfig { + fn default() -> Self { + Self { + leader: DEFAULT_LEADER.to_string(), + } + } +} + +/// 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. +pub fn parse_leader(spec: &str) -> Result { + let normalized = spec.trim().to_ascii_lowercase(); + let rest = normalized.strip_prefix("ctrl+").ok_or_else(|| { + anyhow::anyhow!( + "input.leader \"{spec}\" must be a ctrl chord like \"ctrl+b\" \ + (only ctrl+ leaders are supported)" + ) + })?; + let mut chars = rest.chars(); + let (Some(c), None) = (chars.next(), chars.next()) else { + anyhow::ensure!( + false, + "input.leader \"{spec}\" must name exactly one ascii character after ctrl+" + ); + unreachable!() + }; + anyhow::ensure!( + c.is_ascii_alphabetic(), + "input.leader \"{spec}\" must use an ascii letter after ctrl+ \ + (e.g. ctrl+b; ctrl+1, ctrl+-, ctrl+space are not allowed)" + ); + // Terminals send Ctrl+I as Tab (0x09) and Ctrl+M as Enter/CR (0x0d), so + // crossterm surfaces those as KeyCode::Tab / KeyCode::Enter — never the + // Char('i')/Char('m') + CONTROL event that is_leader_key looks for. Such a + // leader could be armed but never recognized, so reject it up front. + anyhow::ensure!( + !matches!(c, 'i' | 'm'), + "input.leader \"{spec}\" is not usable: terminals deliver Ctrl+I as Tab \ + 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 via the xterm + // convention. 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. + Ok(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)) +} + +/// A single reserved startup command. `name` labels the pane's tab; when +/// absent the command text is used as the label. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StartupCommand { + /// Optional tab label. Falls back to `command` when omitted. + pub name: Option, + /// Shell command run in the pane immediately on launch. + pub command: String, +} \ No newline at end of file diff --git a/src/config/log.rs b/src/config/log.rs new file mode 100644 index 0000000..b4e9d43 --- /dev/null +++ b/src/config/log.rs @@ -0,0 +1,76 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LogRotation { + #[default] + Daily, + Hourly, + Size, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { + Error, + Warn, + #[default] + Info, + Debug, + Trace, +} + +impl LogLevel { + pub fn as_str(self) -> &'static str { + match self { + LogLevel::Error => "error", + LogLevel::Warn => "warn", + LogLevel::Info => "info", + LogLevel::Debug => "debug", + LogLevel::Trace => "trace", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct LogConfig { + /// Enable file-based logging + pub enabled: bool, + /// Log directory — relative paths are resolved from the repo root + pub dir: String, + /// Rotation policy + pub rotation: LogRotation, + /// Maximum file size in MB before rotating (used when rotation = Size) + pub max_size_mb: u64, + /// Delete log files older than this many days (0 = keep forever) + pub max_days: u32, + /// Opt-in: record terminal prompt input line by line + pub prompt_log: bool, + /// Minimum log level + pub level: LogLevel, + /// Number of commits loaded per commit-log page. Must lie in 50..=500. + /// The default (100) is the sweet spot for the async refresh path: small + /// enough that the background worker returns in well under a frame, big + /// enough that scrolling rarely outruns the prefetch threshold. + pub commit_log_page_size: usize, + /// Trigger a background prefetch once the selection is within this many + /// rows of the loaded tail. Must be in 1..=page_size. + pub commit_log_prefetch_threshold: usize, +} + +impl Default for LogConfig { + fn default() -> Self { + Self { + enabled: true, + dir: ".nightcrow/logs".to_string(), + rotation: LogRotation::default(), + max_size_mb: 10, + max_days: 7, + prompt_log: false, + level: LogLevel::default(), + commit_log_page_size: 100, + commit_log_prefetch_threshold: 25, + } + } +} \ No newline at end of file diff --git a/src/config/panels.rs b/src/config/panels.rs new file mode 100644 index 0000000..0678e83 --- /dev/null +++ b/src/config/panels.rs @@ -0,0 +1,75 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AgentIndicatorConfig { + /// Show the "recently touched" marker next to files in the status panel. + pub enabled: bool, + /// Seconds within which a file is considered hot after its mtime. + /// Must be >= 3 so the bright→normal fade transition stays observable. + pub hot_window_secs: u64, + /// When idle (no manual navigation for >=2s), move selection to the + /// freshest hot file. Opt-in: set to `true` so the file list follows + /// whichever file was most recently touched on disk — useful when an + /// agent, build script, or external process is editing files in a + /// neighbouring pane. + pub auto_follow: bool, +} + +impl Default for AgentIndicatorConfig { + fn default() -> Self { + Self { + enabled: true, + hot_window_secs: 15, + auto_follow: false, + } + } +} + +/// Configuration for the read-only file-tree navigator (`ViewMode::Tree`). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct TreeConfig { + /// Hide paths matched by `.gitignore` (e.g. `target/`, `node_modules/`). + /// On by default so the tree doesn't explode into build artifacts; set to + /// `false` to browse every file on disk. + pub respect_gitignore: bool, + /// Maximum directory depth the navigator will expand into. A guard against + /// pathologically deep trees; expansion past this depth is a no-op. Must be + /// in 1..=1024. + pub max_depth: usize, + /// Watch expanded directories for filesystem changes and refresh the tree + /// live while Tree mode is open. On by default; only the visible (expanded) + /// directories are watched, non-recursively. Set to `false` to fall back to + /// refreshing only on Tree-mode entry — useful on very large trees or + /// filesystems where native watching is costly or unsupported. + pub live_watch: bool, +} + +impl Default for TreeConfig { + fn default() -> Self { + Self { + respect_gitignore: true, + max_depth: 64, + live_watch: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct MouseConfig { + /// Capture the mouse so clicks reach mouse-aware pane programs and wheel + /// scrolls move the pane under the pointer. While captured, the outer + /// terminal only performs its own text selection with Shift held — the + /// standard override every major terminal honors. Set to `false` to give + /// the mouse back to the outer terminal entirely (plain-drag selection, + /// no click forwarding). + pub enabled: bool, +} + +impl Default for MouseConfig { + fn default() -> Self { + Self { enabled: true } + } +} \ No newline at end of file diff --git a/src/config/tests/config_core.rs b/src/config/tests/config_core.rs new file mode 100644 index 0000000..019899c --- /dev/null +++ b/src/config/tests/config_core.rs @@ -0,0 +1,117 @@ +use crate::config::{ + Config, InitOutcome, EXAMPLE_CONFIG, MAX_STARTUP_COMMANDS, StartupCommand, validate_config, + write_config_template, +}; + +#[test] +fn default_config_is_valid() { + validate_config(&Config::default()).unwrap(); +} + +#[test] +fn example_config_parses_and_validates() { + // Guards the shipped config.example.toml against drift: it must parse + // into Config and pass the same validation as a real user file. This is + // the exact text `nightcrow init` writes, so the guard covers both. + let cfg: Config = toml::from_str(EXAMPLE_CONFIG).expect("config.example.toml should parse"); + validate_config(&cfg).expect("config.example.toml should validate"); +} + +#[test] +fn write_config_template_creates_file_and_parent_dir() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("nested").join("config.toml"); + match write_config_template(&path, false).unwrap() { + InitOutcome::Created(p) => assert_eq!(p, path), + InitOutcome::AlreadyExists(_) => panic!("expected Created on a fresh path"), + } + let written = std::fs::read_to_string(&path).unwrap(); + assert_eq!(written, EXAMPLE_CONFIG); +} + +#[test] +fn write_config_template_preserves_existing_without_force() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "# user edits\n").unwrap(); + match write_config_template(&path, false).unwrap() { + InitOutcome::AlreadyExists(p) => assert_eq!(p, path), + InitOutcome::Created(_) => panic!("must not overwrite an existing file"), + } + // The user's content survives untouched. + assert_eq!(std::fs::read_to_string(&path).unwrap(), "# user edits\n"); +} + +#[test] +fn write_config_template_overwrites_with_force() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "# stale\n").unwrap(); + match write_config_template(&path, true).unwrap() { + InitOutcome::Created(p) => assert_eq!(p, path), + InitOutcome::AlreadyExists(_) => panic!("force should rewrite the file"), + } + assert_eq!(std::fs::read_to_string(&path).unwrap(), EXAMPLE_CONFIG); +} + +#[test] +fn parse_toml_overrides() { + let toml = r#" +[layout] +upper_pct = 60 +file_list_pct = 30 +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert_eq!(cfg.layout.upper_pct, 60); + assert_eq!(cfg.layout.file_list_pct, 30); +} + +#[test] +fn validation_rejects_out_of_range() { + let mut cfg = Config::default(); + cfg.layout.upper_pct = 0; + assert!(validate_config(&cfg).is_err()); + cfg.layout.upper_pct = 100; + assert!(validate_config(&cfg).is_err()); +} + +#[test] +fn startup_command_validation_rejects_empty_command() { + let mut cfg = Config::default(); + cfg.startup_commands.push(StartupCommand { + name: Some("blank".into()), + command: " ".into(), + }); + assert!(validate_config(&cfg).is_err()); +} + +#[test] +fn startup_command_validation_rejects_too_many() { + let mut cfg = Config::default(); + for i in 0..(MAX_STARTUP_COMMANDS + 1) { + cfg.startup_commands.push(StartupCommand { + name: None, + command: format!("echo {i}"), + }); + } + assert!(validate_config(&cfg).is_err()); +} + +#[test] +fn startup_command_validation_accepts_max() { + let mut cfg = Config::default(); + for i in 0..MAX_STARTUP_COMMANDS { + cfg.startup_commands.push(StartupCommand { + name: None, + command: format!("echo {i}"), + }); + } + assert!(validate_config(&cfg).is_ok()); +} + +#[test] +fn validate_rejects_bad_leader() { + let mut cfg = Config::default(); + cfg.input.leader = "f1".to_string(); + assert!(validate_config(&cfg).is_err()); +} \ No newline at end of file diff --git a/src/config/tests/input.rs b/src/config/tests/input.rs new file mode 100644 index 0000000..e78b556 --- /dev/null +++ b/src/config/tests/input.rs @@ -0,0 +1,79 @@ +use crate::config::{Config, parse_leader}; +use crossterm::event::{KeyCode, KeyModifiers}; + +#[test] +fn input_leader_defaults_to_ctrl_f() { + let cfg = Config::default(); + assert_eq!(cfg.input.leader, "ctrl+f"); + let leader = parse_leader(&cfg.input.leader).unwrap(); + assert_eq!(leader.code, KeyCode::Char('f')); + assert!(leader.modifiers.contains(KeyModifiers::CONTROL)); +} + +#[test] +fn parse_leader_rejects_unencodable_ctrl_chords() { + // Digits and punctuation have no single control-byte encoding, so they + // would break `` literal pass-through and must be rejected. + for spec in ["ctrl+1", "ctrl+-", "ctrl+/", "ctrl+@"] { + assert!( + parse_leader(spec).is_err(), + "{spec} must be rejected as a leader" + ); + } +} + +#[test] +fn parse_leader_rejects_terminal_alias_chords() { + // Ctrl+I == Tab and Ctrl+M == Enter at the byte level, so crossterm + // never reports them as Char('i')/Char('m') and the leader would be + // unrecognizable. + assert!(parse_leader("ctrl+i").is_err(), "ctrl+i aliases Tab"); + assert!(parse_leader("ctrl+m").is_err(), "ctrl+m aliases Enter"); + // Neighboring letters remain valid. + assert!(parse_leader("ctrl+j").is_ok()); + assert!(parse_leader("ctrl+n").is_ok()); +} + +#[test] +fn parse_leader_rejects_non_ctrl_and_multichar() { + assert!(parse_leader("g").is_err(), "bare key is not a ctrl chord"); + assert!(parse_leader("ctrl+ab").is_err(), "leader is a single key"); +} + +#[test] +fn input_leader_parses_from_toml() { + let toml = r#" +[input] +leader = "ctrl+a" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert_eq!(cfg.input.leader, "ctrl+a"); + crate::config::validate_config(&cfg).unwrap(); + let leader = parse_leader(&cfg.input.leader).unwrap(); + assert_eq!(leader.code, KeyCode::Char('a')); +} + +#[test] +fn parse_leader_accepts_uppercase_and_whitespace() { + let leader = parse_leader(" CTRL+B ").unwrap(); + assert_eq!(leader.code, KeyCode::Char('b')); + assert!(leader.modifiers.contains(KeyModifiers::CONTROL)); +} + +#[test] +fn parse_leader_rejects_non_ctrl_chords() { + assert!(parse_leader("b").is_err()); + assert!(parse_leader("alt+b").is_err()); + assert!(parse_leader("shift+b").is_err()); +} + +#[test] +fn parse_leader_rejects_reserved_and_multichar_keys() { + // F-keys, named keys, and multi-char specs are not single ctrl+ascii + // chords, so they fail the ctrl+ prefix / single-char gates. + assert!(parse_leader("ctrl+f1").is_err()); + assert!(parse_leader("f1").is_err()); + assert!(parse_leader("ctrl+pageup").is_err()); + assert!(parse_leader("ctrl+").is_err()); + assert!(parse_leader("ctrl+ ").is_err()); +} \ No newline at end of file diff --git a/src/config/tests/log.rs b/src/config/tests/log.rs new file mode 100644 index 0000000..b3a3117 --- /dev/null +++ b/src/config/tests/log.rs @@ -0,0 +1,146 @@ +use crate::config::{ + Config, LogConfig, LogLevel, LogRotation, validate_config, +}; + +#[test] +fn parse_rejects_invalid_log_rotation() { + let toml = r#" +[log] +rotation = "weekly" +"#; + assert!(toml::from_str::(toml).is_err()); +} + +#[test] +fn parse_rejects_invalid_log_level() { + let toml = r#" +[log] +level = "verbose" +"#; + assert!(toml::from_str::(toml).is_err()); +} + +#[test] +fn parse_accepts_all_valid_rotations() { + for rotation in &["daily", "hourly", "size"] { + let toml = format!("[log]\nrotation = \"{rotation}\"\n"); + assert!( + toml::from_str::(&toml).is_ok(), + "rotation={rotation} should parse" + ); + } +} + +#[test] +fn parse_accepts_all_valid_levels() { + for level in &["error", "warn", "info", "debug", "trace"] { + let toml = format!("[log]\nlevel = \"{level}\"\n"); + assert!( + toml::from_str::(&toml).is_ok(), + "level={level} should parse" + ); + } +} + +#[test] +fn log_config_defaults_are_sane() { + let cfg = LogConfig::default(); + assert!(cfg.enabled); + assert!(!cfg.prompt_log); + assert_eq!(cfg.rotation, LogRotation::Daily); + assert_eq!(cfg.level, LogLevel::Info); + assert_eq!(cfg.max_days, 7); + assert_eq!(cfg.commit_log_page_size, 100); + assert_eq!(cfg.commit_log_prefetch_threshold, 25); +} + +#[test] +fn commit_log_pagination_parses_from_toml() { + let toml = r#" +[log] +commit_log_page_size = 400 +commit_log_prefetch_threshold = 80 +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert_eq!(cfg.log.commit_log_page_size, 400); + assert_eq!(cfg.log.commit_log_prefetch_threshold, 80); + validate_config(&cfg).unwrap(); +} + +#[test] +fn commit_log_page_size_validation_rejects_out_of_range() { + let mut cfg = Config::default(); + cfg.log.commit_log_page_size = 49; + assert!(validate_config(&cfg).is_err()); + cfg.log.commit_log_page_size = 501; + assert!(validate_config(&cfg).is_err()); +} + +#[test] +fn commit_log_prefetch_threshold_validation_rejects_zero() { + let mut cfg = Config::default(); + cfg.log.commit_log_prefetch_threshold = 0; + assert!(validate_config(&cfg).is_err()); +} + +#[test] +fn commit_log_prefetch_threshold_validation_rejects_above_page_size() { + let mut cfg = Config::default(); + cfg.log.commit_log_page_size = 300; + cfg.log.commit_log_prefetch_threshold = 301; + assert!(validate_config(&cfg).is_err()); +} + +#[test] +fn log_max_size_mb_validation_rejects_zero_and_huge() { + let mut cfg = Config::default(); + cfg.log.max_size_mb = 0; + assert!(validate_config(&cfg).is_err()); + cfg.log.max_size_mb = 10_001; + assert!(validate_config(&cfg).is_err()); +} + +#[test] +fn log_max_size_mb_validation_accepts_in_range() { + let mut cfg = Config::default(); + cfg.log.max_size_mb = 1; + assert!(validate_config(&cfg).is_ok()); + cfg.log.max_size_mb = 10_000; + assert!(validate_config(&cfg).is_ok()); +} + +#[test] +fn log_max_days_validation_accepts_zero_as_keep_forever_sentinel() { + let mut cfg = Config::default(); + cfg.log.max_days = 0; + assert!(validate_config(&cfg).is_ok()); +} + +#[test] +fn log_max_days_validation_rejects_unreasonable_horizon() { + let mut cfg = Config::default(); + cfg.log.max_days = 3651; + assert!(validate_config(&cfg).is_err()); +} + +#[test] +fn log_config_parses_from_toml() { + let toml = r#" +[log] +enabled = false +prompt_log = true +rotation = "size" +max_size_mb = 5 +max_days = 14 +level = "debug" +dir = "/tmp/logs" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert!(!cfg.log.enabled); + assert!(cfg.log.prompt_log); + assert_eq!(cfg.log.rotation, LogRotation::Size); + assert_eq!(cfg.log.max_size_mb, 5); + assert_eq!(cfg.log.max_days, 14); + assert_eq!(cfg.log.level, LogLevel::Debug); + assert_eq!(cfg.log.dir, "/tmp/logs"); +} \ No newline at end of file diff --git a/src/config/tests/mod.rs b/src/config/tests/mod.rs new file mode 100644 index 0000000..59574f2 --- /dev/null +++ b/src/config/tests/mod.rs @@ -0,0 +1,8 @@ +mod config_core; +mod input; +mod log; +mod panels; +mod startup; +mod theme; +mod tree; +mod web; \ No newline at end of file diff --git a/src/config/tests/panels.rs b/src/config/tests/panels.rs new file mode 100644 index 0000000..315ea74 --- /dev/null +++ b/src/config/tests/panels.rs @@ -0,0 +1,45 @@ +use crate::config::{AgentIndicatorConfig, Config, validate_config}; + +#[test] +fn agent_indicator_defaults_are_sane() { + let cfg = AgentIndicatorConfig::default(); + assert!(cfg.enabled); + assert!(!cfg.auto_follow); + assert_eq!(cfg.hot_window_secs, 15); +} + +#[test] +fn agent_indicator_parses_from_toml() { + let toml = r#" +[agent_indicator] +enabled = false +hot_window_secs = 30 +auto_follow = false +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert!(!cfg.agent_indicator.enabled); + assert!(!cfg.agent_indicator.auto_follow); + assert_eq!(cfg.agent_indicator.hot_window_secs, 30); +} + +#[test] +fn mouse_capture_defaults_on_and_parses_from_toml() { + assert!(Config::default().mouse.enabled); + + let cfg: Config = toml::from_str("[mouse]\nenabled = false\n").unwrap(); + assert!(!cfg.mouse.enabled); +} + +#[test] +fn agent_indicator_validation_rejects_too_small_window() { + let mut cfg = Config::default(); + cfg.agent_indicator.hot_window_secs = 2; + assert!(validate_config(&cfg).is_err()); +} + +#[test] +fn agent_indicator_validation_rejects_too_large_window() { + let mut cfg = Config::default(); + cfg.agent_indicator.hot_window_secs = 3601; + assert!(validate_config(&cfg).is_err()); +} \ No newline at end of file diff --git a/src/config/tests/startup.rs b/src/config/tests/startup.rs new file mode 100644 index 0000000..9e9dfb3 --- /dev/null +++ b/src/config/tests/startup.rs @@ -0,0 +1,81 @@ +use crate::config::{ + Config, MAX_STARTUP_COMMANDS, StartupCommand, resolve_startup_commands, validate_config, +}; + +#[test] +fn startup_commands_default_to_empty() { + let cfg = Config::default(); + assert!(cfg.startup_commands.is_empty()); + // A config without the table also defaults to empty and validates. + let cfg: Config = toml::from_str("[layout]\nupper_pct = 50\n").unwrap(); + assert!(cfg.startup_commands.is_empty()); + validate_config(&cfg).unwrap(); +} + +#[test] +fn startup_commands_parse_array_of_tables() { + let toml = r#" +[[startup_command]] +name = "Claude" +command = "claude" + +[[startup_command]] +command = "cargo test" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert_eq!(cfg.startup_commands.len(), 2); + assert_eq!(cfg.startup_commands[0].name.as_deref(), Some("Claude")); + assert_eq!(cfg.startup_commands[0].command, "claude"); + assert_eq!(cfg.startup_commands[1].name, None); + assert_eq!(cfg.startup_commands[1].command, "cargo test"); + validate_config(&cfg).unwrap(); +} + +#[test] +fn resolve_startup_commands_appends_cli_exec_after_config() { + let mut cfg = Config::default(); + cfg.startup_commands.push(StartupCommand { + name: Some("Claude".into()), + command: "claude".into(), + }); + let resolved = resolve_startup_commands(&cfg, &["codex".to_string(), "vim".to_string()]).unwrap(); + assert_eq!(resolved.len(), 3); + assert_eq!(resolved[0].command, "claude"); + assert_eq!(resolved[0].name.as_deref(), Some("Claude")); + // CLI entries carry no name and are ordered after config entries. + assert_eq!(resolved[1].command, "codex"); + assert_eq!(resolved[1].name, None); + assert_eq!(resolved[2].command, "vim"); +} + +#[test] +fn resolve_startup_commands_empty_when_nothing_configured() { + let resolved = resolve_startup_commands(&Config::default(), &[]).unwrap(); + assert!(resolved.is_empty()); +} + +#[test] +fn resolve_startup_commands_rejects_empty_exec() { + let resolved = resolve_startup_commands(&Config::default(), &[" ".to_string()]); + assert!(resolved.is_err()); +} + +#[test] +fn resolve_startup_commands_caps_combined_total() { + let mut cfg = Config::default(); + for i in 0..4 { + cfg.startup_commands.push(StartupCommand { + name: None, + command: format!("echo {i}"), + }); + } + // 4 config + 5 CLI = 9 > MAX_STARTUP_COMMANDS (8). + let cli: Vec = (0..5).map(|i| format!("run {i}")).collect(); + assert!(resolve_startup_commands(&cfg, &cli).is_err()); + // 4 config + 4 CLI = 8 is exactly the cap. + let cli: Vec = (0..4).map(|i| format!("run {i}")).collect(); + assert_eq!( + resolve_startup_commands(&cfg, &cli).unwrap().len(), + MAX_STARTUP_COMMANDS + ); +} \ No newline at end of file diff --git a/src/config/tests/theme.rs b/src/config/tests/theme.rs new file mode 100644 index 0000000..4cc84f8 --- /dev/null +++ b/src/config/tests/theme.rs @@ -0,0 +1,43 @@ +use crate::config::{Accent, ThemeConfig}; + +#[test] +fn theme_default_matches_documented_preset() { + let cfg = ThemeConfig::default(); + + assert_eq!(cfg.name, Accent::Yellow); + assert_eq!(cfg.preset_index(), 0); +} + +#[test] +fn accent_index_from_index_roundtrip_for_every_variant() { + // Pin the ALL slice against the enum: a missing entry would make + // `index()` return 0 silently, miscolouring a real variant as the + // default. Iterate every variant via a match so a future variant + // addition forces this test to be updated. + let all = [ + Accent::Yellow, + Accent::Cyan, + Accent::Green, + Accent::Magenta, + Accent::Blue, + ]; + for a in all { + let idx = a.index(); + assert!(idx < Accent::ALL.len(), "{a:?} index {idx} out of range"); + assert_eq!(Accent::from_index(idx), a, "roundtrip failed for {a:?}"); + } + // And confirm the canonical slice length stays in sync. + assert_eq!(Accent::ALL.len(), all.len()); +} + +#[test] +fn accent_from_index_wraps_out_of_range() { + // Defensive: a stale session.json with a huge accent_idx must not + // panic — `from_index` wraps via `%`. The compile-time guard above + // keeps `ALL` non-empty so `% len` is sound. + assert_eq!( + Accent::from_index(usize::MAX), + Accent::from_index(usize::MAX % Accent::ALL.len()) + ); + assert_eq!(Accent::from_index(Accent::ALL.len()), Accent::from_index(0)); +} \ No newline at end of file diff --git a/src/config/tests/tree.rs b/src/config/tests/tree.rs new file mode 100644 index 0000000..e33be35 --- /dev/null +++ b/src/config/tests/tree.rs @@ -0,0 +1,44 @@ +use crate::config::{Config, TreeConfig, validate_config}; + +#[test] +fn tree_config_defaults_are_sane() { + let cfg = TreeConfig::default(); + assert!(cfg.respect_gitignore); + assert_eq!(cfg.max_depth, 64); + assert!(cfg.live_watch, "live watching is on by default"); +} + +#[test] +fn tree_config_parses_from_toml() { + let toml = r#" +[tree] +respect_gitignore = false +max_depth = 12 +live_watch = false +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert!(!cfg.tree.respect_gitignore); + assert_eq!(cfg.tree.max_depth, 12); + assert!(!cfg.tree.live_watch); + validate_config(&cfg).unwrap(); +} + +#[test] +fn tree_max_depth_validation_rejects_out_of_range() { + let mut cfg = Config::default(); + cfg.tree.max_depth = 0; + assert!(validate_config(&cfg).is_err()); + cfg.tree.max_depth = 1025; + assert!(validate_config(&cfg).is_err()); +} + +#[test] +fn config_without_tree_table_defaults() { + // A pre-existing config file with no [tree] table must still parse and + // validate, falling back to defaults. + let cfg: Config = toml::from_str("[layout]\nupper_pct = 50\n").unwrap(); + assert!(cfg.tree.respect_gitignore); + assert_eq!(cfg.tree.max_depth, 64); + assert!(cfg.tree.live_watch); + validate_config(&cfg).unwrap(); +} \ No newline at end of file diff --git a/src/config/tests/web.rs b/src/config/tests/web.rs new file mode 100644 index 0000000..a876096 --- /dev/null +++ b/src/config/tests/web.rs @@ -0,0 +1,238 @@ +use crate::config::{ + Config, WebMirrorConfig, ensure_web_mirror_password, generate_password, validate_config, +}; +use crate::config::web::{ + GENERATED_PASSWORD_LEN, PASSWORD_ALPHABET, WEB_MIRROR_TABLE, WEB_VIEWER_TABLE, insert_password, +}; + +#[test] +fn web_config_defaults_are_off_and_loopback() { + let cfg = WebMirrorConfig::default(); + assert!(!cfg.enabled); + assert_eq!(cfg.bind, "127.0.0.1"); + assert_eq!(cfg.port, 8090); + assert!(cfg.password.is_none()); + assert!(cfg.hashed_password.is_none()); + assert!(!cfg.has_credential()); +} + +#[test] +fn web_mirror_config_parses_from_toml() { + let toml = r#" +[web_mirror] +enabled = true +bind = "0.0.0.0" +port = 9000 +password = "hunter2" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert!(cfg.web_mirror.enabled); + assert_eq!(cfg.web_mirror.bind, "0.0.0.0"); + assert_eq!(cfg.web_mirror.port, 9000); + assert_eq!(cfg.web_mirror.password.as_deref(), Some("hunter2")); + assert!(cfg.web_mirror.has_credential()); + validate_config(&cfg).unwrap(); +} + +#[test] +fn config_without_web_table_defaults() { + // A pre-existing config file with no [web_mirror] table must still parse and + // validate, falling back to the disabled default. + let cfg: Config = toml::from_str("[layout]\nupper_pct = 50\n").unwrap(); + assert!(!cfg.web_mirror.enabled); + assert_eq!(cfg.web_mirror.port, 8090); + validate_config(&cfg).unwrap(); +} + +#[test] +fn web_has_credential_treats_empty_password_as_missing() { + let empty = WebMirrorConfig { + password: Some(String::new()), + ..WebMirrorConfig::default() + }; + assert!( + !empty.has_credential(), + "an empty password is not a credential" + ); + let with_pw = WebMirrorConfig { + password: Some("x".into()), + ..WebMirrorConfig::default() + }; + assert!(with_pw.has_credential()); + let with_hash = WebMirrorConfig { + hashed_password: Some("$argon2id$...".into()), + ..WebMirrorConfig::default() + }; + assert!(with_hash.has_credential()); +} + +#[test] +fn web_validation_rejects_port_zero_when_enabled() { + let mut cfg = Config::default(); + cfg.web_mirror.enabled = true; + cfg.web_mirror.port = 0; + assert!(validate_config(&cfg).is_err()); +} + +#[test] +fn web_validation_rejects_bad_bind_when_enabled() { + let mut cfg = Config::default(); + cfg.web_mirror.enabled = true; + cfg.web_mirror.bind = "not-an-ip".into(); + assert!(validate_config(&cfg).is_err()); +} + +#[test] +fn web_validation_ignores_bind_and_port_when_disabled() { + // A disabled web section is never acted on, so its fields are not + // range-checked — a stale/garbage value must not block startup. + let mut cfg = Config::default(); + cfg.web_mirror.enabled = false; + cfg.web_mirror.port = 0; + cfg.web_mirror.bind = "not-an-ip".into(); + assert!(validate_config(&cfg).is_ok()); +} + +#[test] +fn generate_password_has_expected_length_and_alphabet() { + let pw = generate_password().unwrap(); + assert_eq!(pw.chars().count(), GENERATED_PASSWORD_LEN); + assert!( + pw.bytes().all(|b| PASSWORD_ALPHABET.contains(&b)), + "generated password must only use the unambiguous TOML-safe alphabet" + ); + // Two draws should differ with overwhelming probability. + assert_ne!(pw, generate_password().unwrap()); +} + +#[test] +fn insert_password_adds_line_under_existing_header() { + let source = "[web_mirror]\nenabled = true\nport = 8090\n"; + let out = insert_password(source, WEB_MIRROR_TABLE, "secret"); + assert_eq!( + out, "[web_mirror]\npassword = \"secret\"\nenabled = true\nport = 8090\n", + "the password line must land right after the [web_mirror] header" + ); + // The result round-trips and exposes the password. + let cfg: Config = toml::from_str(&out).unwrap(); + assert_eq!(cfg.web_mirror.password.as_deref(), Some("secret")); +} + +#[test] +fn insert_password_appends_table_when_absent() { + let source = "[layout]\nupper_pct = 55\n"; + let out = insert_password(source, WEB_MIRROR_TABLE, "secret"); + assert!(out.starts_with(source)); + assert!(out.contains("\n[web_mirror]\npassword = \"secret\"\n")); + let cfg: Config = toml::from_str(&out).unwrap(); + assert_eq!(cfg.web_mirror.password.as_deref(), Some("secret")); +} + +#[test] +fn insert_password_appends_table_into_empty_source() { + let out = insert_password("", WEB_MIRROR_TABLE, "secret"); + assert_eq!(out, "[web_mirror]\npassword = \"secret\"\n"); + let cfg: Config = toml::from_str(&out).unwrap(); + assert_eq!(cfg.web_mirror.password.as_deref(), Some("secret")); +} + +#[test] +fn the_web_mirror_table_configures_the_mirror() { + let cfg: Config = toml::from_str("[web_mirror]\nenabled = true\nport = 8100\n").unwrap(); + + assert!(cfg.web_mirror.enabled); + assert_eq!(cfg.web_mirror.port, 8100); +} + +#[test] +fn insert_password_targets_the_named_table() { + // The viewer's credential must land under [web_viewer], not [web] — + // writing it to the wrong table would silently give the mirror a + // second password and leave the viewer without one. + let source = "[web_mirror]\nport = 8090\n"; + + let out = insert_password(source, WEB_VIEWER_TABLE, "vsecret"); + + assert!( + out.contains("[web_viewer]\npassword = \"vsecret\""), + "got: {out}" + ); + let web_table = out.split("[web_viewer]").next().unwrap(); + assert!( + !web_table.contains("vsecret"), + "the viewer password leaked into [web_mirror]: {out}" + ); +} + +#[test] +fn insert_password_finds_an_existing_viewer_table() { + let source = "[web_mirror]\nport = 8090\n\n[web_viewer]\nport = 8091\n"; + + let out = insert_password(source, WEB_VIEWER_TABLE, "vsecret"); + + let viewer = out.split("[web_viewer]").nth(1).unwrap(); + assert!(viewer.contains("password = \"vsecret\""), "got: {out}"); + assert_eq!(out.matches("[web_viewer]").count(), 1, "no duplicate table"); +} + +#[test] +fn insert_password_ignores_commented_header() { + // A `# [web]` comment is not a real table header, so the password must + // be appended as a new table rather than inserted under the comment. + let source = "# [web] example\nfoo = 1\n"; + let out = insert_password(source, WEB_MIRROR_TABLE, "secret"); + assert!(out.contains("\n[web_mirror]\npassword = \"secret\"\n")); +} + +#[test] +fn ensure_web_password_is_noop_when_credential_present() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("config.toml"); + let mut cfg = Config::default(); + cfg.web_mirror.enabled = true; + cfg.web_mirror.password = Some("preset".into()); + let generated = ensure_web_mirror_password(&mut cfg, &path).unwrap(); + assert!( + generated.is_none(), + "an existing credential must not be replaced" + ); + assert!( + !path.exists(), + "no file should be written when a password exists" + ); + assert_eq!(cfg.web_mirror.password.as_deref(), Some("preset")); +} + +#[test] +fn ensure_web_password_generates_persists_and_sets() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("nested").join("config.toml"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "[web_mirror]\nenabled = true\n").unwrap(); + + let mut cfg = Config::default(); + cfg.web_mirror.enabled = true; + let generated = ensure_web_mirror_password(&mut cfg, &path).unwrap(); + + let pw = generated.expect("a password must be generated when none is set"); + assert_eq!(cfg.web_mirror.password.as_deref(), Some(pw.as_str())); + // The persisted file now parses back to the same password. + let reparsed: Config = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(reparsed.web_mirror.password.as_deref(), Some(pw.as_str())); +} + +#[test] +fn ensure_web_password_creates_file_when_absent() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("config.toml"); + let mut cfg = Config::default(); + cfg.web_mirror.enabled = true; + + let pw = ensure_web_mirror_password(&mut cfg, &path) + .unwrap() + .unwrap(); + + assert!(path.exists()); + let reparsed: Config = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(reparsed.web_mirror.password.as_deref(), Some(pw.as_str())); +} \ No newline at end of file diff --git a/src/config/web.rs b/src/config/web.rs new file mode 100644 index 0000000..d327dc4 --- /dev/null +++ b/src/config/web.rs @@ -0,0 +1,227 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Default TCP port for the web mirror server. +const DEFAULT_WEB_PORT: u16 = 8090; +/// Viewer default. Adjacent to the mirror's but distinct: both can run at once. +const DEFAULT_WEB_VIEWER_PORT: u16 = 8091; +/// Table name for the mirror's settings. Named for what it is, matching +/// `[web_viewer]`; `[web]` alone did not say which web surface it meant. +pub(super) const WEB_MIRROR_TABLE: &str = "web_mirror"; +pub(super) const WEB_VIEWER_TABLE: &str = "web_viewer"; +/// Default bind address: loopback only. Exposing the server on a routable +/// address is a deliberate opt-in because it grants live control of a shell. +const DEFAULT_WEB_BIND: &str = "127.0.0.1"; +/// Length (characters) of an auto-generated web password. +pub(super) const GENERATED_PASSWORD_LEN: usize = 24; +/// Alphabet for generated passwords: alphanumeric minus visually ambiguous +/// glyphs (0/O, 1/l/I). All chars are TOML-safe, so the persisted value never +/// needs escaping when written as a basic `"..."` string. +pub(super) const PASSWORD_ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +/// Web mirror server: serve a live, controllable view of this nightcrow over +/// HTTP so a browser and the local terminal drive the same session. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct WebMirrorConfig { + /// Enable the web mirror. Off by default — turning it on exposes live + /// view+control of this nightcrow over the network, so it is opt-in. + pub enabled: bool, + /// Address to bind. Defaults to loopback (`127.0.0.1`); set to `0.0.0.0` + /// only deliberately, and prefer an SSH tunnel / reverse proxy for remote + /// access since the server speaks plain HTTP (no built-in TLS). + pub bind: String, + /// TCP port for the web server. + pub port: u16, + /// Plaintext login password. When the web server is enabled and neither + /// this nor `hashed_password` is set, a random password is generated and + /// written back here so it survives restarts and stays readable. + pub password: Option, + /// Optional Argon2 PHC hash — an alternative to storing `password` in + /// plaintext. Takes precedence over `password` when both are present. + pub hashed_password: Option, +} + +impl Default for WebMirrorConfig { + fn default() -> Self { + Self { + enabled: false, + bind: DEFAULT_WEB_BIND.to_string(), + port: DEFAULT_WEB_PORT, + password: None, + hashed_password: None, + } + } +} + +impl WebMirrorConfig { + /// Whether a login credential is already configured (either form). + pub fn has_credential(&self) -> bool { + self.hashed_password.is_some() || self.password.as_deref().is_some_and(|p| !p.is_empty()) + } +} + +/// The native web viewer (`[web_viewer]`). Independent of the mirror: its own +/// port, cookie, and credential, so enabling one does not expose the other. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[serde(default)] +pub struct WebViewerConfig { + /// Enable the viewer alongside the TUI. Off by default — it exposes both + /// repository contents and interactive terminals. + pub enabled: bool, + /// Address to bind. Loopback by default; the server speaks plain HTTP, so + /// remote access belongs behind an SSH tunnel or reverse proxy. + pub bind: String, + pub port: u16, + /// Plaintext login password, generated and written back on first enable. + pub password: Option, + /// Optional Argon2 PHC hash. Takes precedence over `password`. + pub hashed_password: Option, +} + +impl Default for WebViewerConfig { + fn default() -> Self { + Self { + enabled: false, + bind: DEFAULT_WEB_BIND.to_string(), + port: DEFAULT_WEB_VIEWER_PORT, + password: None, + hashed_password: None, + } + } +} + +impl WebViewerConfig { + pub fn has_credential(&self) -> bool { + self.hashed_password.is_some() || self.password.as_deref().is_some_and(|p| !p.is_empty()) + } +} + +/// Generate a random, human-readable password from the OS RNG. +/// +/// Uses a 55-character unambiguous alphabet. The modulo reduction introduces a +/// negligible bias (256 mod 55) that is immaterial for a locally-scoped dev +/// credential; `getrandom` is the same OS entropy source Argon2 salts use. +pub fn generate_password() -> Result { + let mut bytes = [0u8; GENERATED_PASSWORD_LEN]; + getrandom::fill(&mut bytes) + .map_err(|e| anyhow::anyhow!("OS RNG unavailable for web password generation: {e}"))?; + Ok(bytes + .iter() + .map(|b| PASSWORD_ALPHABET[usize::from(*b) % PASSWORD_ALPHABET.len()] as char) + .collect()) +} + +/// Ensure the enabled web server 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. +pub fn ensure_web_mirror_password( + cfg: &mut super::Config, + path: &std::path::Path, +) -> Result> { + if cfg.web_mirror.has_credential() { + return Ok(None); + } + let password = generate_password()?; + persist_password(path, WEB_MIRROR_TABLE, &password) + .with_context(|| format!("persisting generated web password to {}", path.display()))?; + cfg.web_mirror.password = Some(password.clone()); + Ok(Some(password)) +} + +/// Same bootstrap for the viewer's own `[web_viewer]` credential. +/// +/// The viewer gets a *separate* password rather than sharing the mirror's: the +/// two servers already run on separate ports with separate cookies, and one +/// credential granting both would make that separation cosmetic. +pub fn ensure_web_viewer_password( + cfg: &mut super::Config, + path: &std::path::Path, +) -> Result> { + if cfg.web_viewer.has_credential() { + return Ok(None); + } + let password = generate_password()?; + persist_password(path, WEB_VIEWER_TABLE, &password).with_context(|| { + format!( + "persisting generated web viewer password to {}", + path.display() + ) + })?; + cfg.web_viewer.password = Some(password.clone()); + Ok(Some(password)) +} + +/// Write `password` into the `[{table}]` table of the TOML file at `path`. +fn persist_password(path: &std::path::Path, table: &str, password: &str) -> Result<()> { + let existing = if path.exists() { + std::fs::read_to_string(path) + .with_context(|| format!("reading config file {}", path.display()))? + } else { + String::new() + }; + let updated = insert_password(&existing, table, password); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating config directory {}", parent.display()))?; + } + std::fs::write(path, &updated) + .with_context(|| format!("writing config file {}", path.display()))?; + restrict_permissions(path); + Ok(()) +} + +/// Pure text transform behind `persist_web_password`, isolated for testing. +pub(super) fn insert_password(source: &str, table: &str, password: &str) -> String { + let line = format!("password = \"{password}\""); + if let Some(insert_at) = table_header_line_end(source, table) { + let mut out = String::with_capacity(source.len() + line.len() + 1); + out.push_str(&source[..insert_at]); + out.push('\n'); + out.push_str(&line); + out.push_str(&source[insert_at..]); + out + } else { + let mut out = String::with_capacity(source.len() + line.len() + 16); + out.push_str(source); + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&format!("[{table}]\n")); + out.push_str(&line); + out.push('\n'); + out + } +} + +fn table_header_line_end(source: &str, table: &str) -> Option { + let mut offset = 0; + for line in source.split_inclusive('\n') { + if line.trim() == format!("[{table}]") { + return Some(offset + line.trim_end_matches('\n').len()); + } + offset += line.len(); + } + None +} + +fn restrict_permissions(path: &std::path::Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + #[cfg(not(unix))] + { + let _ = path; + } +} \ No newline at end of file diff --git a/src/event_loop.rs b/src/event_loop.rs new file mode 100644 index 0000000..16d1183 --- /dev/null +++ b/src/event_loop.rs @@ -0,0 +1,284 @@ +use crate::cli::WebSurfaces; +use crate::key_dispatch::{KeyOutcome, ProjectRequest, dispatch_key}; +pub(crate) use crate::key_dispatch::ProjectContext; +use crate::mouse::dispatch_mouse; +use crate::paste::dispatch_paste; +use crate::workspace::Workspace; +use crossterm::event::{self, Event}; +use ratatui::{Terminal, backend::CrosstermBackend}; +use ratatui::layout::Rect; +use std::io; +use std::time::Duration; +use syntect::highlighting::ThemeSet; +use syntect::parsing::SyntaxSet; + +pub(crate) fn main_loop( + terminal: &mut Terminal>, + ws: &mut Workspace, + ss: &SyntaxSet, + ts: &ThemeSet, + cfg: &crate::config::Config, + ctx: &ProjectContext, + surfaces: WebSurfaces, +) -> anyhow::Result<()> { + let WebSurfaces { + mirror: mut web_server, + viewer, + } = surfaces; + // Signature of the repository set last handed to the viewer. The catalog + // only needs updating when a tab opens or closes, not every frame. + let mut served_repos: Vec = Vec::new(); + loop { + if let Some(viewer) = viewer.as_ref() { + let current: Vec = ws.projects().iter().map(|p| p.repo_path.clone()).collect(); + if current != served_repos { + viewer.set_repos(¤t); + served_repos = current; + } + } + // 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, so skipping the background + // ones would let them grow until the user switched back. + // + // Only the active project *applies* its snapshot, though. That runs a + // full `refresh_diff`, and doing it for every open project would put + // several repositories' git diffs on the UI thread every tick. A + // background snapshot waits in `pending_snapshot` until its tab is + // shown (see `App::drain_snapshot`). + 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. A hidden project's in-flight + // fetch is capped at one by `CommitLogPagination`, so its reply + // can wait in the channel without growing. + 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 rereads directories and previews a + // file, so like the snapshot that is active-only; a hidden project + // records the event and refreshes when its tab comes forward. + if i == active { + project.poll_tree_watcher(); + } else { + project.drain_tree_watcher(); + } + project.poll_terminal(); + } + + let size = terminal.size()?; + let screen = Rect::new(0, 0, 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) + .into_iter() + .map(|(id, area)| (id, area.height, area.width)) + .collect(); + let app = ws.active_mut().expect("active project checked above"); + app.terminal.resize_visible_panes(&layouts); + app.terminal.sync_scroll(); + } + + // 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. + let tab_paths: Vec = ws.projects().iter().map(|p| p.repo_path.clone()).collect(); + let active_tab = ws.active_index(); + let empty_notice = ws.empty_notice().cloned(); + let prefix_armed = ws.prefix_armed(); + let fallback_accent = crate::config::Accent::from_index(cfg.theme.preset_index()).color(); + + let (app_opt, repo_input) = ws.render_parts(); + let tabs = crate::ui::Chrome { + repo_paths: &tab_paths, + active: active_tab, + repo_input, + }; + let accent = app_opt + .as_ref() + .map(|app| app.current_accent()) + .unwrap_or(fallback_accent); + let mut cursor = None; + let completed = terminal.draw(|frame| { + cursor = 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, + ); + None + } + }; + })?; + + // Mirror the freshly composited frame to any connected browsers. Use the + // buffer returned by `draw` — after it swaps buffers, `current_buffer_mut` + // points at the next (reset) frame, not the one just rendered. The local + // terminal stays the authority for the grid size; the web view renders + // the exact same cells. + if let Some(server) = web_server.as_mut() { + server.broadcast(completed.buffer, cursor); + } + + // `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. + let repo_input = ws.repo_input.clone(); + let tabs = crate::ui::Chrome { + repo_paths: &tab_paths, + 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. + if event::poll(Duration::from_millis(16))? { + match event::read()? { + // 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::Key(key) => { + let outcome = dispatch_key(ws, key); + if apply_outcome(terminal, ws, ctx, outcome)? { + return Ok(()); + } + } + Event::Paste(text) => dispatch_paste(ws, &text), + Event::Mouse(mouse) => { + let screen = Rect::new(0, 0, size.width, size.height); + let outcome = + dispatch_mouse(ws, tabs, mouse, screen, &cfg.layout, cfg.mouse.enabled); + if apply_outcome(terminal, ws, ctx, outcome)? { + return Ok(()); + } + } + _ => {} + } + } + + // Browser input runs through the exact same handlers as local input, so + // a web action can never diverge from the equivalent local keypress. + if let Some(server) = web_server.as_ref() { + let screen = Rect::new(0, 0, size.width, size.height); + for event in server.drain_input() { + // Rebuilt per event, not reused from the frame: an earlier + // event in this batch (or the local input above) may have + // opened, closed, or switched a project, and a tab hit-test + // against the stale row would select the wrong one. + let tab_paths: Vec = + ws.projects().iter().map(|p| p.repo_path.clone()).collect(); + let active_tab = ws.active_index(); + let repo_input = ws.repo_input.clone(); + let tabs = crate::ui::Chrome { + repo_paths: &tab_paths, + active: active_tab, + repo_input: &repo_input, + }; + let outcome = + dispatch_web_event(ws, tabs, event, screen, &cfg.layout, cfg.mouse.enabled); + if apply_outcome(terminal, ws, ctx, outcome)? { + return Ok(()); + } + } + } + } +} + +/// Route a decoded browser input event through the same handlers as local +/// input. Keeps web and terminal control behaviourally identical. +fn dispatch_web_event( + ws: &mut Workspace, + tabs: crate::ui::Chrome<'_>, + event: crate::web::protocol::WebInputEvent, + screen: Rect, + layout: &crate::config::LayoutConfig, + mouse_enabled: bool, +) -> KeyOutcome { + use crate::web::protocol::WebInputEvent; + match event { + WebInputEvent::Key(key) => dispatch_key(ws, key), + WebInputEvent::Mouse(mouse) => { + dispatch_mouse(ws, tabs, mouse, screen, layout, mouse_enabled) + } + WebInputEvent::Paste(text) => { + dispatch_paste(ws, &text); + KeyOutcome::Continue + } + } +} + +/// Carry out a handler's outcome. Returns `true` when the app should quit. +pub(crate) fn apply_outcome( + terminal: &mut Terminal>, + ws: &mut Workspace, + ctx: &ProjectContext, + outcome: KeyOutcome, +) -> anyhow::Result { + match outcome { + KeyOutcome::Quit => return Ok(true), + KeyOutcome::Redraw => terminal.clear()?, + KeyOutcome::Continue => {} + KeyOutcome::Project(request) => apply_project_request(ws, ctx, request), + } + Ok(false) +} + +/// Carry out a workspace-level request produced by a key or click. +/// +/// Refusals land on the notice row rather than being dropped: a keypress that +/// appears to do nothing reads as a bug. +pub(crate) fn apply_project_request( + ws: &mut Workspace, + ctx: &ProjectContext, + request: ProjectRequest, +) { + match request { + ProjectRequest::Switch(idx) => ws.switch(idx), + ProjectRequest::OpenDialog => ws.start_repo_input(), + ProjectRequest::Close => { + // `close_active` carries the project's view state into the + // remembered set; writing here means a crash later cannot lose it. + if ws.close_active() { + crate::session::save_workspace(&ws.to_persisted()); + } + } + ProjectRequest::Open(repo_path) => { + // Focus rather than duplicate: two tabs on one workdir would show + // identical git state while racing each other's snapshot workers. + if let Some(idx) = ws.index_of_repo(&repo_path) { + ws.switch(idx); + return; + } + // Checked before building: `init_app` spawns a PTY backend and runs + // the configured startup commands, so constructing a project only + // to have `add` refuse it would leave those processes behind. + if ws.is_full() { + ws.raise_notice( + crate::app::NoticeKind::Project, + format!("cannot open more than {} projects", crate::workspace::MAX_PROJECTS), + ); + return; + } + let saved = ws.session_for(&repo_path).cloned(); + let project = crate::app_init::init_app(&repo_path, ctx.cfg, ctx.startup_commands, ctx.leader, saved); + ws.add(project); + } + } +} \ No newline at end of file diff --git a/src/git/diff.rs b/src/git/diff.rs index bde8dbd..56aa467 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -1,1534 +1,24 @@ -use anyhow::{Context, Result}; -use git2::{ - Branch, Diff, DiffDelta, DiffOptions, Oid, Repository, Status, StatusEntry, StatusOptions, -}; -use std::borrow::Cow; -use std::cell::RefCell; -use std::collections::BTreeMap; - -/// State of a single git status column. `index` (X) compares HEAD with the -/// staged tree, `worktree` (Y) compares the staged tree with the working -/// directory. Either column can be `Unmodified` — that is what the old -/// single-status `ChangeStatus` could not express. Mirrors the codes used by -/// `git status --short`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StatusKind { - Unmodified, - Added, - Modified, - Deleted, - Renamed, - TypeChanged, - Untracked, - Unmerged, -} - -impl StatusKind { - /// The Git short status character for this column. `Unmodified` is a space - /// so a single-sided change renders as ` M` / `M `. - fn code_char(self) -> char { - match self { - Self::Unmodified => ' ', - Self::Added => 'A', - Self::Modified => 'M', - Self::Deleted => 'D', - Self::Renamed => 'R', - Self::TypeChanged => 'T', - Self::Untracked => '?', - Self::Unmerged => 'U', - } - } - - /// Severity rank used to pick a single color for the two-character code. - /// Higher wins: unmerged > deleted > renamed > added > modified > - /// typechanged > untracked > unmodified (see plan Resolved Decisions #3). - fn severity(self) -> u8 { - match self { - Self::Unmerged => 7, - Self::Deleted => 6, - Self::Renamed => 5, - Self::Added => 4, - Self::Modified => 3, - Self::TypeChanged => 2, - Self::Untracked => 1, - Self::Unmodified => 0, - } - } -} - -#[derive(Debug, Clone)] -pub struct ChangedFile { - /// New/effective path. Used for diff loading, file preview, hot-file - /// tracking, and selection restoration. - pub path: String, - /// Old path for renames (display/search metadata only). `None` otherwise. - pub old_path: Option, - /// Index column (X): HEAD vs staged. - pub index: StatusKind, - /// Working-tree column (Y): staged vs working directory. - pub worktree: StatusKind, - /// Pre-computed lowercase search text. For renames it contains both old and - /// new paths so either side matches the `contains` filter; otherwise it is - /// the lowercased `path`. Set on construction so the file-list filter - /// doesn't lowercase on every keystroke. - pub search_lower: String, -} - -impl ChangedFile { - /// Build from explicit status columns (status snapshot path). - pub fn from_status_columns( - path: String, - old_path: Option, - index: StatusKind, - worktree: StatusKind, - ) -> Self { - let search_lower = match &old_path { - Some(old) => format!("{old} {path}").to_lowercase(), - None => path.to_lowercase(), - }; - Self { - path, - old_path, - index, - worktree, - search_lower, - } - } - - /// Build from a commit delta: the single delta status lives in the index - /// column and the worktree column is `Unmodified`, so commit drill-down - /// rows render `M `, `A `, `D `, `R `. - pub fn from_commit_delta(path: String, old_path: Option, kind: StatusKind) -> Self { - Self::from_status_columns(path, old_path, kind, StatusKind::Unmodified) - } - - /// Two-character Git short status code (`XY`). Untracked is special-cased - /// to `??` and conflicts to `UU` to match git rather than emitting ` ?` - /// from a blank index plus untracked worktree. - pub fn short_code(&self) -> String { - if self.index == StatusKind::Untracked || self.worktree == StatusKind::Untracked { - return "??".to_string(); - } - if self.index == StatusKind::Unmerged || self.worktree == StatusKind::Unmerged { - return "UU".to_string(); - } - let mut code = String::with_capacity(2); - code.push(self.index.code_char()); - code.push(self.worktree.code_char()); - code - } - - /// The more severe of the two columns, used to pick the row color. - pub fn most_severe(&self) -> StatusKind { - if self.index.severity() >= self.worktree.severity() { - self.index - } else { - self.worktree - } - } - - /// 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()`. - pub fn display_path(&self) -> Cow<'_, str> { - match &self.old_path { - Some(old) => Cow::Owned(format!("{old} -> {}", self.path)), - None => Cow::Borrowed(&self.path), - } - } - - /// Test-only convenience: an unstaged change of `kind` at `path` - /// (` X` column blank). Production code uses the explicit constructors. - #[cfg(test)] - pub(crate) fn unstaged_only(path: String, kind: StatusKind) -> Self { - Self::from_status_columns(path, None, StatusKind::Unmodified, kind) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LineKind { - Added, - Removed, - Context, -} - -#[derive(Debug, Clone)] -pub struct DiffLine { - pub kind: LineKind, - pub content: String, -} - -#[derive(Debug, Clone)] -pub struct DiffHunk { - pub header: String, - pub lines: Vec, - /// File this hunk belongs to. `Some` for hunks emitted by the diff - /// collectors below; `None` for hand-built fixtures in tests where the - /// path is irrelevant. Used by the renderer to pick a per-hunk syntax - /// in commit diffs (one commit can touch multiple file types). - pub file_path: Option, -} - -#[derive(Debug, Clone)] -pub struct TrackingStatus { - pub ahead: usize, - pub behind: usize, -} - -#[derive(Debug, Clone)] -pub struct RepoSnapshot { - pub files: Vec, - pub tracking: Option, - /// HEAD commit oid at the moment the snapshot was taken. `None` for - /// empty or detached repositories with no resolvable HEAD. The main - /// thread compares this against `App::last_head_oid` to detect new - /// commits and refresh the Log view's cached commit list. - pub head_oid: Option, - /// Current branch shorthand (e.g. `main`) when HEAD points at a branch. - /// `None` for detached HEAD, unborn branch, or bare repo so the header - /// can decide whether to render the branch chip. - pub branch_name: Option, -} - -#[derive(Debug, Clone)] -pub struct CommitEntry { - pub oid: Oid, - pub short_id: String, - pub summary: String, - /// Pre-computed lowercase form of `summary` for case-insensitive search. - /// Set on construction so the commit-log filter doesn't lowercase on every - /// keystroke. Mirrors `ChangedFile::search_lower`. - pub summary_lower: String, - pub author: String, - pub time: i64, -} - -impl CommitEntry { - pub fn new(oid: Oid, short_id: String, summary: String, author: String, time: i64) -> Self { - let summary_lower = summary.to_lowercase(); - Self { - oid, - short_id, - summary, - summary_lower, - author, - time, - } - } -} - -impl std::fmt::Display for CommitEntry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} {}", self.short_id, self.summary) - } -} - -fn load_tracking_status(repo: &Repository) -> Option { - let head = repo.head().ok()?; - if !head.is_branch() { - return None; - } - let branch = Branch::wrap(head); - let upstream = branch.upstream().ok()?; - let local_oid = branch.get().target()?; - let upstream_oid = upstream.get().target()?; - let (ahead, behind) = repo.graph_ahead_behind(local_oid, upstream_oid).ok()?; - Some(TrackingStatus { ahead, behind }) -} - -pub fn load_snapshot(repo: &Repository) -> Result { - let mut opts = StatusOptions::new(); - opts.include_untracked(true) - .recurse_untracked_dirs(true) - .renames_head_to_index(true) - .renames_index_to_workdir(true); - - let statuses = repo - .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. - let mut files = BTreeMap::new(); - for entry in statuses.iter() { - let Some((index, worktree)) = status_columns(entry.status()) else { - continue; - }; - let Some((path, old_path)) = paths_from_status_entry(&entry) else { - continue; - }; - if path.is_empty() { - continue; - } - files.insert( - path.clone(), - ChangedFile::from_status_columns(path, old_path, index, worktree), - ); - } - - let files = files.into_values().collect(); - - let tracking = load_tracking_status(repo); - let head = repo.head().ok(); - let head_oid = head.as_ref().and_then(|h| h.target()); - let branch_name = head - .as_ref() - .filter(|h| h.is_branch()) - .and_then(|h| h.shorthand().ok().map(String::from)); - Ok(RepoSnapshot { - files, - tracking, - head_oid, - branch_name, - }) -} - -pub const MAX_FILE_VIEW_BYTES: usize = 5 * 1024 * 1024; - -/// Parse the new-side starting line from a unified-diff hunk header like -/// `@@ -1,3 +5,7 @@ context`. Returns `None` for synthetic headers -/// (`diff `, `Binary file ...`) or anything malformed. -pub fn parse_hunk_new_start(header: &str) -> Option { - let rest = header.strip_prefix("@@ ")?; - let after = rest.split_once(" +")?.1; - let token: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); - if token.is_empty() { - return None; - } - token.parse().ok() -} - -fn decode_file_view(bytes: &[u8]) -> Result { - if bytes.len() > MAX_FILE_VIEW_BYTES { - return Err(anyhow::anyhow!( - "file too large to preview: {} bytes", - bytes.len() - )); - } - std::str::from_utf8(bytes) - .map(String::from) - .map_err(|_| anyhow::anyhow!("binary or non-utf8 file")) -} - -pub fn load_workdir_file(repo: &Repository, file_path: &str) -> Result { - let workdir = repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("bare repository"))?; - let full = crate::git::path::resolve_in_workdir(workdir, file_path)?; - // Size-check through the open handle rather than a second path lookup, so - // the file that gets read is the one that was measured. - let file = std::fs::File::open(&full).with_context(|| format!("failed to open {file_path}"))?; - let len = file - .metadata() - .with_context(|| format!("failed to stat {file_path}"))? - .len(); - // Reject a multi-GB log file or build artifact before it ever materializes - // into memory: `decode_file_view`'s post-read length check would otherwise - // allocate the full buffer before bailing. - if len > MAX_FILE_VIEW_BYTES as u64 { - return Err(anyhow::anyhow!("file too large to preview: {len} bytes")); - } - let mut bytes = Vec::with_capacity(len as usize); - { - use std::io::Read; - // Cap the read itself: `len` came from the handle, but a file that - // grows between the stat and the read would otherwise be read in full. - file.take(MAX_FILE_VIEW_BYTES as u64 + 1) - .read_to_end(&mut bytes) - .with_context(|| format!("failed to read {file_path}"))?; - } - decode_file_view(&bytes) -} - -pub fn load_commit_file_blob( - repo: &Repository, - oid: Oid, - file_path: &str, - status: StatusKind, -) -> Result { - let commit = repo.find_commit(oid).context("failed to find commit")?; - let tree = if status == StatusKind::Deleted { - commit - .parent(0) - .context("deleted file has no parent commit")? - .tree() - .context("failed to get parent tree")? - } else { - commit.tree().context("failed to get commit tree")? - }; - let entry = tree - .get_path(std::path::Path::new(file_path)) - .with_context(|| format!("path not in commit: {file_path}"))?; - let blob = repo.find_blob(entry.id()).context("failed to read blob")?; - decode_file_view(blob.content()) -} - -pub fn load_file_diff(repo: &Repository, file_path: &str) -> Result> { - let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); - let mut diff_opts = diff_options(Some(file_path)); - - let mut diff = repo - .diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts)) - .context("failed to get diff")?; - - diff.find_similar(None) - .context("failed to detect renamed files")?; - - collect_diff_hunks(&diff, file_path) -} - -pub fn load_commit_log(repo: &Repository, max_count: usize) -> Result> { - load_commit_log_page(repo, 0, max_count) -} - -/// Load a slice of the commit log walking back from HEAD. -/// -/// `skip` discards the most recent commits before collecting `limit` entries. -/// Callers paginating the log pass the count already loaded as `skip` so the -/// next slice continues from the existing tail. -pub fn load_commit_log_page( - repo: &Repository, - skip: usize, - limit: usize, -) -> Result> { - load_commit_log_from(repo, None, skip, limit) -} - -/// Load a slice of the commit log walking back from `anchor`, or from HEAD when -/// it is `None`. -/// -/// The anchor exists for callers that page across separate requests. `skip` is -/// an offset into one walk, so it only identifies the same commits as long as -/// the walk starts where it did before — and HEAD moves whenever a commit lands -/// while the pages are being collected, which silently shifts every subsequent -/// offset and duplicates or drops entries. Pinning the start makes a sequence of -/// pages describe one history rather than a moving one. -pub fn load_commit_log_from( - repo: &Repository, - anchor: Option, - skip: usize, - limit: usize, -) -> Result> { - if limit == 0 { - return Ok(Vec::new()); - } - let mut revwalk = repo.revwalk().context("failed to create revwalk")?; - match anchor { - // Pushed without consulting `is_empty` first: an anchor names a commit - // the caller believes exists, so an unknown one is that caller's error - // to hear about, not a reason to answer with an empty history. (An - // empty repository has no commit to name, so this always fails there.) - Some(oid) => revwalk - .push(oid) - .with_context(|| format!("failed to push commit {oid}"))?, - None => { - if repo - .is_empty() - .context("failed to inspect repository state")? - { - return Ok(Vec::new()); - } - if let Err(err) = revwalk.push_head() { - if is_empty_head(&err) { - return Ok(Vec::new()); - } - return Err(err).context("failed to push HEAD"); - } - } - } - - let mut entries = Vec::with_capacity(limit); - for oid_result in revwalk.skip(skip).take(limit) { - let oid = oid_result.context("revwalk error")?; - let commit = repo.find_commit(oid).context("failed to find commit")?; - let summary = commit.summary().ok().flatten().unwrap_or("").to_string(); - let author = commit.author().name().unwrap_or("Unknown").to_string(); - let time = commit.time().seconds(); - entries.push(CommitEntry::new(oid, short_oid(oid), summary, author, time)); - } - Ok(entries) -} - -/// Render a commit oid as the conventional 7-character abbreviated form. -/// -/// Previously this used `repo.find_object(...).short_id()`, which asks -/// libgit2 to compute the *minimum unique prefix length* — at the cost of -/// roughly O(log n) ODB lookups per commit. For a repo with thousands of -/// commits that cost was paid on every initial commit log load. git's own -/// default `core.abbrev` is 7, so a fixed 7-char prefix matches the -/// familiar form while making this an O(1) operation. Oid hex strings are -/// always 40 ASCII bytes, so the slice is sound. -pub(crate) fn short_oid(oid: Oid) -> String { - let s = oid.to_string(); - s.get(..7).unwrap_or(&s).to_string() -} - -/// The commit HEAD points at, or `None` when the branch has no commits yet. -/// -/// An unborn HEAD is a state, not a failure — a repository is allowed to have no -/// history. Every *other* failure to read the ref is returned, so a broken or -/// unreadable HEAD is reported rather than being flattened into "no commits", -/// which reads to a caller as an empty history it can trust. -pub fn head_commit_oid(repo: &Repository) -> Result> { - match repo.head() { - Ok(head) => Ok(head.target()), - // Only the unborn branch, deliberately narrower than [`is_empty_head`]. - // That one also accepts a bare `NotFound` because `revwalk.push_head()` - // reports an empty repository that way, but `repo.head()` names the - // state exactly — so here a `NotFound` means the ref is missing or - // unreadable, which is a broken repository, not an empty one. - Err(err) if err.code() == git2::ErrorCode::UnbornBranch => Ok(None), - Err(err) => Err(err).context("failed to read HEAD"), - } -} - -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. - let missing_head_reference = - err.class() == git2::ErrorClass::Reference && err.message().contains("not found"); - - matches!( - err.code(), - git2::ErrorCode::UnbornBranch | git2::ErrorCode::NotFound - ) || missing_head_reference -} - -fn commit_diff<'repo>( - repo: &'repo Repository, - oid: Oid, - pathspec: Option<&str>, -) -> Result> { - let commit = repo.find_commit(oid).context("failed to find commit")?; - let new_tree = commit.tree().context("failed to get commit tree")?; - // Distinguish a true root commit (no parents) from a parent-lookup - // failure on a non-root commit — bare `.ok()` previously rendered both - // merge commits (when parent objects were unreachable) and corrupt - // history as if the entire tree had just been added. - let old_tree = if commit.parent_count() == 0 { - None - } else { - Some( - commit - .parent(0) - .context("failed to load parent commit")? - .tree() - .context("failed to load parent tree")?, - ) - }; - let mut diff_opts = diff_options(pathspec); - let mut diff = repo - .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut diff_opts)) - .context("failed to get commit diff")?; - diff.find_similar(None) - .context("failed to detect renames")?; - Ok(diff) -} - -pub fn load_commit_files(repo: &Repository, oid: Oid) -> Result> { - let diff = commit_diff(repo, oid, None)?; - let mut files = Vec::new(); - for delta in diff.deltas() { - let kind = match delta.status() { - git2::Delta::Added => StatusKind::Added, - git2::Delta::Deleted => StatusKind::Deleted, - git2::Delta::Renamed => StatusKind::Renamed, - git2::Delta::Typechange => StatusKind::TypeChanged, - _ => StatusKind::Modified, - }; - // New side is the effective path; carry the old side for renames so - // commit drill-down also renders `old -> new`. - let path = delta - .new_file() - .path() - .or_else(|| delta.old_file().path()) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - let old_path = if kind == StatusKind::Renamed { - delta - .old_file() - .path() - .map(|p| p.to_string_lossy().to_string()) - .filter(|old| old != &path) - } else { - None - }; - files.push(ChangedFile::from_commit_delta(path, old_path, kind)); - } - Ok(files) -} - -pub fn load_commit_file_diff( - repo: &Repository, - oid: Oid, - file_path: &str, -) -> Result> { - let diff = commit_diff(repo, oid, Some(file_path))?; - collect_commit_diff_hunks(&diff) -} - -pub fn load_commit_diff(repo: &Repository, oid: Oid) -> Result> { - let diff = commit_diff(repo, oid, None)?; - collect_commit_diff_hunks(&diff) -} - -fn diff_options(pathspec: Option<&str>) -> DiffOptions { - let mut opts = DiffOptions::new(); - opts.include_untracked(true) - .recurse_untracked_dirs(true) - .show_untracked_content(true) - .show_binary(true); - if let Some(pathspec) = pathspec { - opts.pathspec(pathspec).disable_pathspec_match(true); - } - opts -} - -/// Shared hunk/line accumulation logic. `on_file` returns `Some(hunk)` to prepend a -/// synthetic header entry per file (used by commit diff), or `None` to skip (status diff). -fn collect_hunks( - diff: &Diff<'_>, - mut on_file: impl FnMut(DiffDelta<'_>) -> Option, - binary_fallback: &str, -) -> Result> { - let hunks: RefCell> = RefCell::new(Vec::new()); - // Tracks the current file's path between callbacks. libgit2 invokes - // file_cb once per delta, followed by hunk_cb/line_cb for that file — - // hunk_cb itself isn't given the delta, so we stash the path here. - let current_path: RefCell> = RefCell::new(None); - - diff.foreach( - &mut |delta, _| { - *current_path.borrow_mut() = path_from_delta(&delta); - if let Some(h) = on_file(delta) { - hunks.borrow_mut().push(h); - } - true - }, - Some(&mut |delta, _| { - let path = path_from_delta(&delta).unwrap_or_else(|| binary_fallback.to_string()); - hunks.borrow_mut().push(binary_diff_hunk(&path)); - true - }), - Some(&mut |_, hunk| { - let header = std::str::from_utf8(hunk.header()) - .unwrap_or("@@") - .trim_end_matches('\n') - .to_string(); - hunks.borrow_mut().push(DiffHunk { - header, - lines: Vec::new(), - file_path: current_path.borrow().clone(), - }); - true - }), - Some(&mut |_, _, line| { - let content = std::str::from_utf8(line.content()) - .unwrap_or("") - .trim_end_matches('\n') - .to_string(); - let kind = match line.origin() { - '+' => LineKind::Added, - '-' => LineKind::Removed, - '\\' => return true, - _ => LineKind::Context, - }; - if let Some(h) = hunks.borrow_mut().last_mut() { - h.lines.push(DiffLine { kind, content }); - } - true - }), - )?; - - Ok(hunks.into_inner()) -} - -fn collect_diff_hunks(diff: &Diff<'_>, fallback_path: &str) -> Result> { - collect_hunks(diff, |_| None, fallback_path) -} - -fn collect_commit_diff_hunks(diff: &Diff<'_>) -> Result> { - collect_hunks( - diff, - |delta| { - let path = path_from_delta(&delta).unwrap_or_else(|| "unknown".to_string()); - Some(DiffHunk { - header: format!("diff {path}"), - lines: Vec::new(), - file_path: Some(path), - }) - }, - "unknown", - ) -} - -/// 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. -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. - let index_bits = Status::INDEX_NEW - | Status::INDEX_MODIFIED - | Status::INDEX_DELETED - | Status::INDEX_RENAMED - | Status::INDEX_TYPECHANGE; - if status.contains(Status::WT_NEW) && !status.intersects(index_bits) { - return Some((StatusKind::Untracked, StatusKind::Untracked)); - } - // Conflicts render as `UU` in the first pass; the structured columns keep - // room for the full unmerged matrix later. - if status.contains(Status::CONFLICTED) { - return Some((StatusKind::Unmerged, StatusKind::Unmerged)); - } - - let index = if status.contains(Status::INDEX_NEW) { - StatusKind::Added - } else if status.contains(Status::INDEX_MODIFIED) { - StatusKind::Modified - } else if status.contains(Status::INDEX_DELETED) { - StatusKind::Deleted - } else if status.contains(Status::INDEX_RENAMED) { - StatusKind::Renamed - } else if status.contains(Status::INDEX_TYPECHANGE) { - StatusKind::TypeChanged - } else { - StatusKind::Unmodified - }; - - let worktree = if status.contains(Status::WT_MODIFIED) { - StatusKind::Modified - } else if status.contains(Status::WT_DELETED) { - StatusKind::Deleted - } else if status.contains(Status::WT_RENAMED) { - StatusKind::Renamed - } 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). - StatusKind::Modified - } else { - StatusKind::Unmodified - }; - - if index == StatusKind::Unmodified && worktree == StatusKind::Unmodified { - return None; - } - 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. -fn paths_from_status_entry(entry: &StatusEntry<'_>) -> Option<(String, Option)> { - let i2w = entry.index_to_workdir(); - let h2i = entry.head_to_index(); - let status = entry.status(); - - let path = i2w - .as_ref() - .and_then(new_path_from_delta) - .or_else(|| h2i.as_ref().and_then(new_path_from_delta)) - .or_else(|| entry.path().ok().map(str::to_string))?; - - let old_path = if status.intersects(Status::INDEX_RENAMED | Status::WT_RENAMED) { - // Prefer the HEAD-side original when the index carries a rename, so a - // double-rename (`INDEX_RENAMED | WT_RENAMED`) reports the true original - // path rather than the intermediate staged name. Fall back to the - // worktree side for a pure unstaged rename. - let from = if status.contains(Status::INDEX_RENAMED) { - h2i.as_ref() - } else { - i2w.as_ref() - }; - from.and_then(old_path_from_delta) - .filter(|old| old != &path) - } else { - None - }; - - Some((path, old_path)) -} - -fn new_path_from_delta(delta: &DiffDelta<'_>) -> Option { - delta - .new_file() - .path() - .map(|p| p.to_string_lossy().to_string()) -} - -fn old_path_from_delta(delta: &DiffDelta<'_>) -> Option { - delta - .old_file() - .path() - .map(|p| p.to_string_lossy().to_string()) -} - -fn path_from_delta(delta: &DiffDelta<'_>) -> Option { - delta - .new_file() - .path() - .or_else(|| delta.old_file().path()) - .map(|p| p.to_string_lossy().to_string()) -} - -fn binary_diff_hunk(file_path: &str) -> DiffHunk { - DiffHunk { - header: format!("Binary file {file_path} changed"), - lines: vec![DiffLine { - kind: LineKind::Context, - content: "Binary files differ".to_string(), - }], - file_path: Some(file_path.to_string()), - } -} +mod commit_log; +mod diff_load; +mod snapshot; +mod types; +pub use commit_log::{ + head_commit_oid, load_commit_log, load_commit_log_from, load_commit_log_page, +}; #[cfg(test)] -mod tests { - use super::*; - use crate::test_util::{make_repo, open_repo, run_git}; - use std::path::Path; - - #[test] - fn snapshot_empty_repo_does_not_panic() { - let (dir, path) = make_repo(); - let _ = load_snapshot(&open_repo(&path)); - drop(dir); - } - - #[test] - fn commit_log_empty_repo_returns_empty() { - let (dir, path) = make_repo(); - - let commits = load_commit_log(&open_repo(&path), 10).unwrap(); - - assert!(commits.is_empty()); - drop(dir); - } - - #[test] - fn commit_log_page_empty_repo_returns_empty() { - let (dir, path) = make_repo(); - - let page = load_commit_log_page(&open_repo(&path), 0, 5).unwrap(); - - assert!(page.is_empty()); - drop(dir); - } - - #[test] - fn commit_log_page_zero_limit_returns_empty() { - let (dir, path) = make_repo(); - std::fs::write(Path::new(&path).join("f"), "x").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "c1"]); - - let page = load_commit_log_page(&open_repo(&path), 0, 0).unwrap(); - - assert!(page.is_empty()); - drop(dir); - } - - #[test] - fn commit_log_page_paginates_via_skip() { - let (dir, path) = make_repo(); - for i in 0..5 { - std::fs::write(Path::new(&path).join(format!("f{i}")), format!("{i}")).unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", &format!("c{i}")]); - } - - let first = load_commit_log_page(&open_repo(&path), 0, 2).unwrap(); - let second = load_commit_log_page(&open_repo(&path), 2, 2).unwrap(); - let third = load_commit_log_page(&open_repo(&path), 4, 2).unwrap(); - - // Newest first: c4, c3 | c2, c1 | c0. - assert_eq!(first.len(), 2); - assert_eq!(first[0].summary, "c4"); - assert_eq!(first[1].summary, "c3"); - assert_eq!(second.len(), 2); - assert_eq!(second[0].summary, "c2"); - assert_eq!(second[1].summary, "c1"); - assert_eq!(third.len(), 1); - assert_eq!(third[0].summary, "c0"); - drop(dir); - } - - #[test] - fn commit_log_from_an_anchor_ignores_commits_made_after_it() { - // The point of the anchor: a page fetched after a new commit landed must - // continue the history the first page described, not a shifted one. - let (dir, path) = make_repo(); - for i in 0..4 { - std::fs::write(Path::new(&path).join(format!("f{i}")), format!("{i}")).unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", &format!("c{i}")]); - } - let first = load_commit_log_from(&open_repo(&path), None, 0, 2).unwrap(); - let anchor = first[0].oid; - - // A commit lands between the two page requests. - std::fs::write(Path::new(&path).join("late"), "x").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "late"]); - - let anchored = load_commit_log_from(&open_repo(&path), Some(anchor), 2, 2).unwrap(); - let unanchored = load_commit_log_from(&open_repo(&path), None, 2, 2).unwrap(); - - // Anchored: c3, c2 | c1, c0 — one history, no repeats. - assert_eq!(first[0].summary, "c3"); - assert_eq!(first[1].summary, "c2"); - assert_eq!( - anchored.iter().map(|c| c.summary.as_str()).collect::>(), - ["c1", "c0"], - ); - // Unanchored: HEAD moved, so the same skip lands a row late and repeats - // a commit the caller already has. This is what the anchor prevents. - assert_eq!( - unanchored - .iter() - .map(|c| c.summary.as_str()) - .collect::>(), - ["c2", "c1"], - ); - drop(dir); - } - - #[test] - fn commit_log_from_a_missing_anchor_is_an_error() { - let (dir, path) = make_repo(); - std::fs::write(Path::new(&path).join("f"), "x").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "only"]); - let absent = Oid::from_str("0123456789012345678901234567890123456789").unwrap(); - - let result = load_commit_log_from(&open_repo(&path), Some(absent), 0, 5); - - assert!(result.is_err(), "an unknown anchor must not walk from HEAD"); - drop(dir); - } - - #[test] - fn commit_log_from_a_missing_anchor_is_an_error_in_an_empty_repo_too() { - // The emptiness check must not short-circuit ahead of the anchor: an - // unknown commit is the caller's error, and answering "no history" - // instead would report a typo as an exhausted log. - let (dir, path) = make_repo(); - let absent = Oid::from_str("0123456789012345678901234567890123456789").unwrap(); - - let result = load_commit_log_from(&open_repo(&path), Some(absent), 0, 5); - - assert!(result.is_err()); - drop(dir); - } - - #[test] - fn head_commit_oid_separates_an_unborn_head_from_a_commit() { - let (dir, path) = make_repo(); - assert_eq!(head_commit_oid(&open_repo(&path)).unwrap(), None); - - std::fs::write(Path::new(&path).join("f"), "x").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "only"]); - - let oid = head_commit_oid(&open_repo(&path)).unwrap(); - - assert!(oid.is_some(), "a committed repository has a HEAD target"); - drop(dir); - } - - #[test] - fn head_commit_oid_reports_an_unreadable_head_rather_than_no_history() { - let (dir, path) = make_repo(); - std::fs::write(Path::new(&path).join("f"), "x").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "only"]); - let repo = open_repo(&path); - // Not a ref line at all. A HEAD naming a branch that does not exist is - // *not* the case to test: libgit2 calls that an unborn branch, and - // rightly — nothing in the repository distinguishes a branch not yet - // created from one deleted afterwards. Only unparseable content is a - // broken repository. - std::fs::write(Path::new(&path).join(".git/HEAD"), "not-a-ref\n").unwrap(); - - let result = head_commit_oid(&repo); - - assert!( - result.is_err(), - "an unreadable HEAD must not read as an empty history" - ); - drop(dir); - } - - #[test] - fn commit_log_page_skip_beyond_history_returns_empty() { - let (dir, path) = make_repo(); - std::fs::write(Path::new(&path).join("f"), "x").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "only"]); - - let page = load_commit_log_page(&open_repo(&path), 5, 10).unwrap(); - - assert!(page.is_empty()); - drop(dir); - } - - #[test] - fn is_empty_head_recognizes_unborn_branch_error() { - // Drive the actual error path: a freshly-initialized repo has no - // HEAD target, so revwalk.push_head() returns the error variant our - // helper must recognize. This guards against libgit2 changing the - // error class/code combination it reports. - let (dir, path) = make_repo(); - let repo = open_repo(&path); - let mut revwalk = repo.revwalk().unwrap(); - let err = revwalk - .push_head() - .expect_err("empty repo should fail to push HEAD"); - assert!( - is_empty_head(&err), - "is_empty_head failed to recognize unborn HEAD error: \ - class={:?} code={:?} message={}", - err.class(), - err.code(), - err.message() - ); - drop(dir); - } - - #[test] - fn root_commit_diff_lists_added_files() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("first.rs"); - std::fs::write(&fp, "fn main() {}\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - - let commits = load_commit_log(&open_repo(&path), 1).unwrap(); - let files = load_commit_files(&open_repo(&path), commits[0].oid).unwrap(); - let hunks = load_commit_diff(&open_repo(&path), commits[0].oid).unwrap(); - - assert_eq!(files.len(), 1); - assert_eq!(files[0].path, "first.rs"); - assert_eq!(files[0].index, StatusKind::Added); - assert_eq!(files[0].worktree, StatusKind::Unmodified); - assert!( - hunks - .iter() - .flat_map(|h| &h.lines) - .any(|line| line.kind == LineKind::Added && line.content.contains("fn main")) - ); - drop(dir); - } - - #[test] - fn snapshot_detects_modified_file() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("a.txt"); - std::fs::write(&fp, "line1\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - std::fs::write(&fp, "line1\nline2\n").unwrap(); - - let snap = load_snapshot(&open_repo(&path)).unwrap(); - assert!(snap.files.iter().any(|f| f.path.contains("a.txt"))); - drop(dir); - } - - #[test] - fn snapshot_detects_staged_modified_file() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("a.txt"); - std::fs::write(&fp, "line1\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - std::fs::write(&fp, "line1\nline2\n").unwrap(); - run_git(&path, &["add", "a.txt"]); - - let snap = load_snapshot(&open_repo(&path)).unwrap(); - - assert!(snap.files.iter().any(|f| f.path == "a.txt" - && f.index == StatusKind::Modified - && f.worktree == StatusKind::Unmodified - && f.short_code() == "M ")); - drop(dir); - } - - #[test] - fn diff_returns_hunks_for_modified_file() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("b.rs"); - std::fs::write(&fp, "fn main() {}\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - std::fs::write(&fp, "fn main() {\n println!(\"hi\");\n}\n").unwrap(); - - let hunks = load_file_diff(&open_repo(&path), "b.rs").unwrap(); - assert!(!hunks.is_empty()); - assert!(hunks[0].lines.iter().any(|l| l.kind == LineKind::Added)); - drop(dir); - } - - #[test] - fn diff_returns_hunks_for_staged_modified_file() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("b.rs"); - std::fs::write(&fp, "fn main() {}\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - std::fs::write(&fp, "fn main() {\n println!(\"hi\");\n}\n").unwrap(); - run_git(&path, &["add", "b.rs"]); - - let hunks = load_file_diff(&open_repo(&path), "b.rs").unwrap(); - - assert!(!hunks.is_empty()); - assert!(hunks[0].lines.iter().any(|l| l.kind == LineKind::Added)); - drop(dir); - } - - #[test] - fn snapshot_detects_staged_added_file() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("new.rs"); - std::fs::write(&fp, "fn main() {}\n").unwrap(); - run_git(&path, &["add", "new.rs"]); - - let snap = load_snapshot(&open_repo(&path)).unwrap(); - - assert!( - snap.files.iter().any(|f| f.path == "new.rs" - && f.index == StatusKind::Added - && f.short_code() == "A ") - ); - drop(dir); - } - - #[test] - fn diff_returns_added_lines_for_staged_added_file() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("new.rs"); - std::fs::write(&fp, "fn main() {}\n").unwrap(); - run_git(&path, &["add", "new.rs"]); - - let hunks = load_file_diff(&open_repo(&path), "new.rs").unwrap(); - - assert_eq!(hunks.len(), 1); - assert_eq!(hunks[0].lines[0].kind, LineKind::Added); - drop(dir); - } - - #[test] - fn diff_returns_added_lines_for_untracked_file() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("new.rs"); - std::fs::write(&fp, "fn main() {}\n").unwrap(); - - let snap = load_snapshot(&open_repo(&path)).unwrap(); - assert!( - snap.files - .iter() - .any(|f| { f.path == "new.rs" && f.short_code() == "??" }) - ); - - let hunks = load_file_diff(&open_repo(&path), "new.rs").unwrap(); - assert_eq!(hunks.len(), 1); - assert_eq!(hunks[0].lines[0].kind, LineKind::Added); - drop(dir); - } - - #[test] - fn snapshot_recurses_untracked_directories() { - let (dir, path) = make_repo(); - let nested = Path::new(&path).join("src").join("new.rs"); - std::fs::create_dir_all(nested.parent().unwrap()).unwrap(); - std::fs::write(&nested, "fn main() {}\n").unwrap(); - - let snap = load_snapshot(&open_repo(&path)).unwrap(); - - assert!(snap.files.iter().any(|f| f.path == "src/new.rs")); - drop(dir); - } - - #[test] - fn diff_returns_placeholder_for_binary_file() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("asset.bin"); - std::fs::write(&fp, [0, 1, 2]).unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - std::fs::write(&fp, [0, 1, 3]).unwrap(); - - let hunks = load_file_diff(&open_repo(&path), "asset.bin").unwrap(); - - assert_eq!(hunks.len(), 1); - assert!(hunks[0].header.contains("Binary file")); - drop(dir); - } - - #[test] - fn commit_files_detects_renamed_file() { - let (dir, path) = make_repo(); - let old_path = Path::new(&path).join("old.rs"); - std::fs::write(&old_path, "fn main() {}\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - run_git(&path, &["mv", "old.rs", "new.rs"]); - run_git(&path, &["commit", "-m", "rename"]); - - let commits = load_commit_log(&open_repo(&path), 1).unwrap(); - let files = load_commit_files(&open_repo(&path), commits[0].oid).unwrap(); - - assert_eq!(files.len(), 1); - assert_eq!(files[0].path, "new.rs"); - assert_eq!(files[0].index, StatusKind::Renamed); - assert_eq!(files[0].old_path.as_deref(), Some("old.rs")); - assert_eq!(files[0].display_path(), "old.rs -> new.rs"); - drop(dir); - } - - #[test] - fn parse_hunk_new_start_handles_standard_header() { - assert_eq!(parse_hunk_new_start("@@ -1,3 +5,7 @@"), Some(5)); - assert_eq!(parse_hunk_new_start("@@ -10 +12 @@ ctx"), Some(12)); - assert_eq!(parse_hunk_new_start("@@ -0,0 +1,4 @@"), Some(1)); - assert_eq!(parse_hunk_new_start("diff src/foo.rs"), None); - assert_eq!(parse_hunk_new_start("Binary file x changed"), None); - assert_eq!(parse_hunk_new_start("@@"), None); - } - - #[test] - fn load_workdir_file_reads_text_file() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("hello.txt"); - std::fs::write(&fp, "hi\nthere\n").unwrap(); - let content = load_workdir_file(&open_repo(&path), "hello.txt").unwrap(); - assert_eq!(content, "hi\nthere\n"); - drop(dir); - } - - #[test] - fn load_workdir_file_rejects_binary() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("bin"); - std::fs::write(&fp, [0x00, 0xff, 0xfe]).unwrap(); - assert!(load_workdir_file(&open_repo(&path), "bin").is_err()); - drop(dir); - } - - #[cfg(unix)] - #[test] - fn load_workdir_file_rejects_symlink_without_following() { - let (dir, path) = make_repo(); - let target = Path::new(&path).join("target.txt"); - std::fs::write(&target, "secret\n").unwrap(); - std::os::unix::fs::symlink(&target, Path::new(&path).join("link.txt")).unwrap(); - - let err = load_workdir_file(&open_repo(&path), "link.txt").unwrap_err(); - - assert!(err.to_string().contains("symlinks are not followed")); - drop(dir); - } - - #[test] - fn load_workdir_file_rejects_paths_outside_the_worktree() { - let (dir, path) = make_repo(); - - let err = load_workdir_file(&open_repo(&path), "../../etc/passwd").unwrap_err(); - - assert!( - err.to_string().contains("plain relative path"), - "unexpected error: {err}" - ); - drop(dir); - } - - #[test] - fn load_workdir_file_rejects_reading_the_git_directory() { - let (dir, path) = make_repo(); - - let err = load_workdir_file(&open_repo(&path), ".git/config").unwrap_err(); - - assert!( - err.to_string().contains("git directory"), - "unexpected error: {err}" - ); - drop(dir); - } - - #[test] - fn load_commit_file_blob_reads_committed_text() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("a.txt"); - std::fs::write(&fp, "v1\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - std::fs::write(&fp, "v2\n").unwrap(); - let commits = load_commit_log(&open_repo(&path), 1).unwrap(); - let content = load_commit_file_blob( - &open_repo(&path), - commits[0].oid, - "a.txt", - StatusKind::Modified, - ) - .unwrap(); - assert_eq!(content, "v1\n"); - drop(dir); - } - - #[test] - fn load_commit_file_blob_reads_deleted_file_from_parent() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("gone.txt"); - std::fs::write(&fp, "before delete\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "add file"]); - std::fs::remove_file(&fp).unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "delete file"]); - - let commits = load_commit_log(&open_repo(&path), 1).unwrap(); - let content = load_commit_file_blob( - &open_repo(&path), - commits[0].oid, - "gone.txt", - StatusKind::Deleted, - ) - .unwrap(); - - assert_eq!(content, "before delete\n"); - drop(dir); - } - - #[test] - fn commit_file_diff_returns_renamed_file_diff() { - let (dir, path) = make_repo(); - let old_path = Path::new(&path).join("old.rs"); - std::fs::write(&old_path, "fn main() {}\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - run_git(&path, &["mv", "old.rs", "new.rs"]); - std::fs::write( - Path::new(&path).join("new.rs"), - "fn main() {\n println!(\"hi\");\n}\n", - ) - .unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "rename and edit"]); - - let commits = load_commit_log(&open_repo(&path), 1).unwrap(); - let hunks = load_commit_file_diff(&open_repo(&path), commits[0].oid, "new.rs").unwrap(); - - assert!(!hunks.is_empty()); - assert!( - hunks - .iter() - .flat_map(|h| &h.lines) - .any(|l| l.kind == LineKind::Added && l.content.contains("println")) - ); - drop(dir); - } - - // --- Workstream 1: XY status model unit tests (no git needed) --- - - #[test] - fn short_code_renders_each_xy_combination() { - let mk = |index, worktree| { - ChangedFile::from_status_columns("p".into(), None, index, worktree).short_code() - }; - assert_eq!(mk(StatusKind::Unmodified, StatusKind::Modified), " M"); - assert_eq!(mk(StatusKind::Modified, StatusKind::Unmodified), "M "); - assert_eq!(mk(StatusKind::Modified, StatusKind::Modified), "MM"); - assert_eq!(mk(StatusKind::Added, StatusKind::Unmodified), "A "); - assert_eq!(mk(StatusKind::Renamed, StatusKind::Unmodified), "R "); - assert_eq!(mk(StatusKind::TypeChanged, StatusKind::Unmodified), "T "); - // Untracked and conflicted collapse to git's two-column sentinels - // regardless of which column carries the bit. - assert_eq!(mk(StatusKind::Untracked, StatusKind::Untracked), "??"); - assert_eq!(mk(StatusKind::Unmerged, StatusKind::Unmerged), "UU"); - } - - #[test] - fn display_path_borrows_for_non_rename_and_formats_rename() { - let plain = ChangedFile::from_status_columns( - "src/a.rs".into(), - None, - StatusKind::Modified, - StatusKind::Unmodified, - ); - assert!(matches!(plain.display_path(), Cow::Borrowed(_))); - assert_eq!(plain.display_path(), "src/a.rs"); - - let renamed = ChangedFile::from_status_columns( - "new.rs".into(), - Some("old.rs".into()), - StatusKind::Renamed, - StatusKind::Unmodified, - ); - assert!(matches!(renamed.display_path(), Cow::Owned(_))); - assert_eq!(renamed.display_path(), "old.rs -> new.rs"); - // Search text matches either side of a rename. - assert!(renamed.search_lower.contains("old.rs")); - assert!(renamed.search_lower.contains("new.rs")); - } - - #[test] - fn most_severe_picks_higher_severity_column() { - // Deleted outranks modified regardless of column. - let f = ChangedFile::from_status_columns( - "p".into(), - None, - StatusKind::Modified, - StatusKind::Deleted, - ); - assert_eq!(f.most_severe(), StatusKind::Deleted); - let f = ChangedFile::from_status_columns( - "p".into(), - None, - StatusKind::Deleted, - StatusKind::Modified, - ); - assert_eq!(f.most_severe(), StatusKind::Deleted); - } - - // --- Workstream 1: git status -> XY mapping tests --- - - fn find<'a>(snap: &'a RepoSnapshot, path: &str) -> &'a ChangedFile { - snap.files - .iter() - .find(|f| f.path == path) - .unwrap_or_else(|| panic!("{path} missing from snapshot")) - } - - #[test] - fn snapshot_distinguishes_staged_and_unstaged_modification() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("a.txt"); - std::fs::write(&fp, "v1\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - // Stage one modification, then modify again without staging. - std::fs::write(&fp, "v2\n").unwrap(); - run_git(&path, &["add", "a.txt"]); - std::fs::write(&fp, "v3\n").unwrap(); - - let snap = load_snapshot(&open_repo(&path)).unwrap(); - assert_eq!(find(&snap, "a.txt").short_code(), "MM"); - drop(dir); - } - - #[test] - fn snapshot_distinguishes_staged_and_unstaged_deletion() { - let (dir, path) = make_repo(); - std::fs::write(Path::new(&path).join("staged.txt"), "x\n").unwrap(); - std::fs::write(Path::new(&path).join("wt.txt"), "y\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - // staged deletion (index) vs working-tree deletion (unstaged). - run_git(&path, &["rm", "staged.txt"]); - std::fs::remove_file(Path::new(&path).join("wt.txt")).unwrap(); - - let snap = load_snapshot(&open_repo(&path)).unwrap(); - assert_eq!(find(&snap, "staged.txt").short_code(), "D "); - assert_eq!(find(&snap, "wt.txt").short_code(), " D"); - drop(dir); - } - - #[test] - fn snapshot_keeps_staged_deletion_visible_when_path_recreated() { - // `INDEX_DELETED | WT_NEW`: a staged deletion with a fresh untracked - // file recreated at the same path. git emits two rows (`D ` and `??`); - // our one-row model must keep the staged deletion rather than masking - // the whole row as untracked. - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("f.txt"); - std::fs::write(&fp, "orig\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - run_git(&path, &["rm", "--cached", "f.txt"]); - std::fs::write(&fp, "new content\n").unwrap(); - - let snap = load_snapshot(&open_repo(&path)).unwrap(); - let f = find(&snap, "f.txt"); - assert_eq!(f.index, StatusKind::Deleted); - assert_eq!(f.short_code(), "D "); - drop(dir); - } - - #[cfg(unix)] - #[test] - fn snapshot_detects_staged_typechange() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("f"); - std::fs::write(&fp, "regular\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - // Replace the regular file with a symlink and stage it. - std::fs::remove_file(&fp).unwrap(); - std::os::unix::fs::symlink("target", &fp).unwrap(); - run_git(&path, &["add", "f"]); - - let snap = load_snapshot(&open_repo(&path)).unwrap(); - assert_eq!(find(&snap, "f").index, StatusKind::TypeChanged); - assert_eq!(find(&snap, "f").short_code(), "T "); - drop(dir); - } - - #[test] - fn snapshot_renders_conflicted_file_as_uu() { - let (dir, path) = make_repo(); - let fp = Path::new(&path).join("c.txt"); - std::fs::write(&fp, "base\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - run_git(&path, &["checkout", "-b", "feature"]); - std::fs::write(&fp, "feature\n").unwrap(); - run_git(&path, &["commit", "-am", "feature edit"]); - run_git(&path, &["checkout", "-"]); - std::fs::write(&fp, "mainline\n").unwrap(); - run_git(&path, &["commit", "-am", "mainline edit"]); - // Conflicting merge exits non-zero; run it tolerantly. - let merge = std::process::Command::new("git") - .args(["merge", "feature"]) - .current_dir(&path) - .output() - .unwrap(); - assert!(!merge.status.success(), "merge should conflict"); - - let snap = load_snapshot(&open_repo(&path)).unwrap(); - assert_eq!(find(&snap, "c.txt").short_code(), "UU"); - drop(dir); - } - - #[test] - fn snapshot_preserves_rename_and_loads_new_side_diff() { - let (dir, path) = make_repo(); - // Keep content identical across the rename so git's similarity - // detection reports a staged rename rather than add+delete. - std::fs::write(Path::new(&path).join("old.rs"), "fn main() {}\n").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - run_git(&path, &["mv", "old.rs", "new.rs"]); - - let snap = load_snapshot(&open_repo(&path)).unwrap(); - let f = find(&snap, "new.rs"); - assert_eq!(f.index, StatusKind::Renamed); - assert_eq!(f.old_path.as_deref(), Some("old.rs")); - assert_eq!(f.display_path(), "old.rs -> new.rs"); +pub(crate) use commit_log::is_empty_head; +pub use diff_load::{ + load_commit_diff, load_commit_file_blob, load_commit_file_diff, + load_commit_files, load_file_diff, load_workdir_file, parse_hunk_new_start, +}; +pub use snapshot::load_snapshot; +pub use types::{ + ChangedFile, CommitEntry, DiffHunk, LineKind, RepoSnapshot, StatusKind, + TrackingStatus, +}; +#[cfg(test)] +pub use types::DiffLine; - // The effective path is the new side; selecting it must still load a - // diff without error (regression guard for the rename display change). - assert!(load_file_diff(&open_repo(&path), &f.path).is_ok()); - drop(dir); - } -} +#[cfg(test)] +mod tests; \ No newline at end of file diff --git a/src/git/diff/commit_log.rs b/src/git/diff/commit_log.rs new file mode 100644 index 0000000..78296d3 --- /dev/null +++ b/src/git/diff/commit_log.rs @@ -0,0 +1,122 @@ +use crate::git::diff::types::CommitEntry; +use anyhow::{Context, Result}; +use git2::{Oid, Repository}; + +pub fn load_commit_log(repo: &Repository, max_count: usize) -> Result> { + load_commit_log_page(repo, 0, max_count) +} + +/// Load a slice of the commit log walking back from HEAD. +/// +/// `skip` discards the most recent commits before collecting `limit` entries. +/// Callers paginating the log pass the count already loaded as `skip` so the +/// next slice continues from the existing tail. +pub fn load_commit_log_page( + repo: &Repository, + skip: usize, + limit: usize, +) -> Result> { + load_commit_log_from(repo, None, skip, limit) +} + +/// Load a slice of the commit log walking back from `anchor`, or from HEAD when +/// it is `None`. +/// +/// The anchor exists for callers that page across separate requests. `skip` is +/// an offset into one walk, so it only identifies the same commits as long as +/// the walk starts where it did before — and HEAD moves whenever a commit lands +/// while the pages are being collected, which silently shifts every subsequent +/// offset and duplicates or drops entries. Pinning the start makes a sequence of +/// pages describe one history rather than a moving one. +pub fn load_commit_log_from( + repo: &Repository, + anchor: Option, + skip: usize, + limit: usize, +) -> Result> { + if limit == 0 { + return Ok(Vec::new()); + } + let mut revwalk = repo.revwalk().context("failed to create revwalk")?; + match anchor { + // Pushed without consulting `is_empty` first: an anchor names a commit + // the caller believes exists, so an unknown one is that caller's error + // to hear about, not a reason to answer with an empty history. (An + // empty repository has no commit to name, so this always fails there.) + Some(oid) => revwalk + .push(oid) + .with_context(|| format!("failed to push commit {oid}"))?, + None => { + if repo + .is_empty() + .context("failed to inspect repository state")? + { + return Ok(Vec::new()); + } + if let Err(err) = revwalk.push_head() { + if is_empty_head(&err) { + return Ok(Vec::new()); + } + return Err(err).context("failed to push HEAD"); + } + } + } + + let mut entries = Vec::with_capacity(limit); + for oid_result in revwalk.skip(skip).take(limit) { + let oid = oid_result.context("revwalk error")?; + let commit = repo.find_commit(oid).context("failed to find commit")?; + let summary = commit.summary().ok().flatten().unwrap_or("").to_string(); + let author = commit.author().name().unwrap_or("Unknown").to_string(); + let time = commit.time().seconds(); + entries.push(CommitEntry::new(oid, short_oid(oid), summary, author, time)); + } + Ok(entries) +} + +/// Render a commit oid as the conventional 7-character abbreviated form. +/// +/// Previously this used `repo.find_object(...).short_id()`, which asks +/// libgit2 to compute the *minimum unique prefix length* — at the cost of +/// roughly O(log n) ODB lookups per commit. For a repo with thousands of +/// commits that cost was paid on every initial commit log load. git's own +/// default `core.abbrev` is 7, so a fixed 7-char prefix matches the +/// familiar form while making this an O(1) operation. Oid hex strings are +/// always 40 ASCII bytes, so the slice is sound. +pub(crate) fn short_oid(oid: Oid) -> String { + let s = oid.to_string(); + s.get(..7).unwrap_or(&s).to_string() +} + +/// The commit HEAD points at, or `None` when the branch has no commits yet. +/// +/// An unborn HEAD is a state, not a failure — a repository is allowed to have no +/// history. Every *other* failure to read the ref is returned, so a broken or +/// unreadable HEAD is reported rather than being flattened into "no commits", +/// which reads to a caller as an empty history it can trust. +pub fn head_commit_oid(repo: &Repository) -> Result> { + match repo.head() { + Ok(head) => Ok(head.target()), + // Only the unborn branch, deliberately narrower than [`is_empty_head`]. + // That one also accepts a bare `NotFound` because `revwalk.push_head()` + // reports an empty repository that way, but `repo.head()` names the + // state exactly — so here a `NotFound` means the ref is missing or + // unreadable, which is a broken repository, not an empty one. + Err(err) if err.code() == git2::ErrorCode::UnbornBranch => Ok(None), + Err(err) => Err(err).context("failed to read HEAD"), + } +} + +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. + let missing_head_reference = + err.class() == git2::ErrorClass::Reference && err.message().contains("not found"); + + matches!( + err.code(), + git2::ErrorCode::UnbornBranch | git2::ErrorCode::NotFound + ) || missing_head_reference +} \ No newline at end of file diff --git a/src/git/diff/diff_load.rs b/src/git/diff/diff_load.rs new file mode 100644 index 0000000..d56b8ce --- /dev/null +++ b/src/git/diff/diff_load.rs @@ -0,0 +1,267 @@ +use crate::git::diff::snapshot::{binary_diff_hunk, path_from_delta}; +use crate::git::diff::types::{ChangedFile, DiffHunk, DiffLine, LineKind, StatusKind}; +use anyhow::{Context, Result}; +use git2::{Diff, DiffDelta, DiffOptions, Oid, Repository}; +use std::cell::RefCell; + +pub const MAX_FILE_VIEW_BYTES: usize = 5 * 1024 * 1024; + +/// Parse the new-side starting line from a unified-diff hunk header like +/// `@@ -1,3 +5,7 @@ context`. Returns `None` for synthetic headers +/// (`diff `, `Binary file ...`) or anything malformed. +pub fn parse_hunk_new_start(header: &str) -> Option { + let rest = header.strip_prefix("@@ ")?; + let after = rest.split_once(" +")?.1; + let token: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); + if token.is_empty() { + return None; + } + token.parse().ok() +} + +fn decode_file_view(bytes: &[u8]) -> Result { + if bytes.len() > MAX_FILE_VIEW_BYTES { + return Err(anyhow::anyhow!( + "file too large to preview: {} bytes", + bytes.len() + )); + } + std::str::from_utf8(bytes) + .map(String::from) + .map_err(|_| anyhow::anyhow!("binary or non-utf8 file")) +} + +pub fn load_workdir_file(repo: &Repository, file_path: &str) -> Result { + let workdir = repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("bare repository"))?; + let full = crate::git::path::resolve_in_workdir(workdir, file_path)?; + // Size-check through the open handle rather than a second path lookup, so + // the file that gets read is the one that was measured. + let file = std::fs::File::open(&full).with_context(|| format!("failed to open {file_path}"))?; + let len = file + .metadata() + .with_context(|| format!("failed to stat {file_path}"))? + .len(); + // Reject a multi-GB log file or build artifact before it ever materializes + // into memory: `decode_file_view`'s post-read length check would otherwise + // allocate the full buffer before bailing. + if len > MAX_FILE_VIEW_BYTES as u64 { + return Err(anyhow::anyhow!("file too large to preview: {len} bytes")); + } + let mut bytes = Vec::with_capacity(len as usize); + { + use std::io::Read; + // Cap the read itself: `len` came from the handle, but a file that + // grows between the stat and the read would otherwise be read in full. + file.take(MAX_FILE_VIEW_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .with_context(|| format!("failed to read {file_path}"))?; + } + decode_file_view(&bytes) +} + +pub fn load_commit_file_blob( + repo: &Repository, + oid: Oid, + file_path: &str, + status: StatusKind, +) -> Result { + let commit = repo.find_commit(oid).context("failed to find commit")?; + let tree = if status == StatusKind::Deleted { + commit + .parent(0) + .context("deleted file has no parent commit")? + .tree() + .context("failed to get parent tree")? + } else { + commit.tree().context("failed to get commit tree")? + }; + let entry = tree + .get_path(std::path::Path::new(file_path)) + .with_context(|| format!("path not in commit: {file_path}"))?; + let blob = repo.find_blob(entry.id()).context("failed to read blob")?; + decode_file_view(blob.content()) +} + +pub fn load_file_diff(repo: &Repository, file_path: &str) -> Result> { + let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); + let mut diff_opts = diff_options(Some(file_path)); + + let mut diff = repo + .diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts)) + .context("failed to get diff")?; + + diff.find_similar(None) + .context("failed to detect renamed files")?; + + collect_diff_hunks(&diff, file_path) +} + +fn commit_diff<'repo>( + repo: &'repo Repository, + oid: Oid, + pathspec: Option<&str>, +) -> Result> { + let commit = repo.find_commit(oid).context("failed to find commit")?; + let new_tree = commit.tree().context("failed to get commit tree")?; + // Distinguish a true root commit (no parents) from a parent-lookup + // failure on a non-root commit — bare `.ok()` previously rendered both + // merge commits (when parent objects were unreachable) and corrupt + // history as if the entire tree had just been added. + let old_tree = if commit.parent_count() == 0 { + None + } else { + Some( + commit + .parent(0) + .context("failed to load parent commit")? + .tree() + .context("failed to load parent tree")?, + ) + }; + let mut diff_opts = diff_options(pathspec); + let mut diff = repo + .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut diff_opts)) + .context("failed to get commit diff")?; + diff.find_similar(None) + .context("failed to detect renames")?; + Ok(diff) +} + +pub fn load_commit_files(repo: &Repository, oid: Oid) -> Result> { + let diff = commit_diff(repo, oid, None)?; + let mut files = Vec::new(); + for delta in diff.deltas() { + let kind = match delta.status() { + git2::Delta::Added => StatusKind::Added, + git2::Delta::Deleted => StatusKind::Deleted, + git2::Delta::Renamed => StatusKind::Renamed, + git2::Delta::Typechange => StatusKind::TypeChanged, + _ => StatusKind::Modified, + }; + // New side is the effective path; carry the old side for renames so + // commit drill-down also renders `old -> new`. + let path = delta + .new_file() + .path() + .or_else(|| delta.old_file().path()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let old_path = if kind == StatusKind::Renamed { + delta + .old_file() + .path() + .map(|p| p.to_string_lossy().to_string()) + .filter(|old| old != &path) + } else { + None + }; + files.push(ChangedFile::from_commit_delta(path, old_path, kind)); + } + Ok(files) +} + +pub fn load_commit_file_diff( + repo: &Repository, + oid: Oid, + file_path: &str, +) -> Result> { + let diff = commit_diff(repo, oid, Some(file_path))?; + collect_commit_diff_hunks(&diff) +} + +pub fn load_commit_diff(repo: &Repository, oid: Oid) -> Result> { + let diff = commit_diff(repo, oid, None)?; + collect_commit_diff_hunks(&diff) +} + +fn diff_options(pathspec: Option<&str>) -> DiffOptions { + let mut opts = DiffOptions::new(); + opts.include_untracked(true) + .recurse_untracked_dirs(true) + .show_untracked_content(true) + .show_binary(true); + if let Some(pathspec) = pathspec { + opts.pathspec(pathspec).disable_pathspec_match(true); + } + opts +} + +/// Shared hunk/line accumulation logic. `on_file` returns `Some(hunk)` to prepend a +/// synthetic header entry per file (used by commit diff), or `None` to skip (status diff). +fn collect_hunks( + diff: &Diff<'_>, + mut on_file: impl FnMut(DiffDelta<'_>) -> Option, + binary_fallback: &str, +) -> Result> { + let hunks: RefCell> = RefCell::new(Vec::new()); + // Tracks the current file's path between callbacks. libgit2 invokes + // file_cb once per delta, followed by hunk_cb/line_cb for that file — + // hunk_cb itself isn't given the delta, so we stash the path here. + let current_path: RefCell> = RefCell::new(None); + + diff.foreach( + &mut |delta, _| { + *current_path.borrow_mut() = path_from_delta(&delta); + if let Some(h) = on_file(delta) { + hunks.borrow_mut().push(h); + } + true + }, + Some(&mut |delta, _| { + let path = path_from_delta(&delta).unwrap_or_else(|| binary_fallback.to_string()); + hunks.borrow_mut().push(binary_diff_hunk(&path)); + true + }), + Some(&mut |_, hunk| { + let header = std::str::from_utf8(hunk.header()) + .unwrap_or("@@") + .trim_end_matches('\n') + .to_string(); + hunks.borrow_mut().push(DiffHunk { + header, + lines: Vec::new(), + file_path: current_path.borrow().clone(), + }); + true + }), + Some(&mut |_, _, line| { + let content = std::str::from_utf8(line.content()) + .unwrap_or("") + .trim_end_matches('\n') + .to_string(); + let kind = match line.origin() { + '+' => LineKind::Added, + '-' => LineKind::Removed, + '\\' => return true, + _ => LineKind::Context, + }; + if let Some(h) = hunks.borrow_mut().last_mut() { + h.lines.push(DiffLine { kind, content }); + } + true + }), + )?; + + Ok(hunks.into_inner()) +} + +fn collect_diff_hunks(diff: &Diff<'_>, fallback_path: &str) -> Result> { + collect_hunks(diff, |_| None, fallback_path) +} + +fn collect_commit_diff_hunks(diff: &Diff<'_>) -> Result> { + collect_hunks( + diff, + |delta| { + let path = path_from_delta(&delta).unwrap_or_else(|| "unknown".to_string()); + Some(DiffHunk { + header: format!("diff {path}"), + lines: Vec::new(), + file_path: Some(path), + }) + }, + "unknown", + ) +} \ No newline at end of file diff --git a/src/git/diff/snapshot.rs b/src/git/diff/snapshot.rs new file mode 100644 index 0000000..ef53489 --- /dev/null +++ b/src/git/diff/snapshot.rs @@ -0,0 +1,193 @@ +use crate::git::diff::types::{ChangedFile, DiffHunk, DiffLine, LineKind, RepoSnapshot, StatusKind, TrackingStatus}; +use anyhow::{Context, Result}; +use git2::{Branch, DiffDelta, Repository, Status, StatusEntry, StatusOptions}; +use std::collections::BTreeMap; + +fn load_tracking_status(repo: &Repository) -> Option { + let head = repo.head().ok()?; + if !head.is_branch() { + return None; + } + let branch = Branch::wrap(head); + let upstream = branch.upstream().ok()?; + let local_oid = branch.get().target()?; + let upstream_oid = upstream.get().target()?; + let (ahead, behind) = repo.graph_ahead_behind(local_oid, upstream_oid).ok()?; + Some(TrackingStatus { ahead, behind }) +} + +pub fn load_snapshot(repo: &Repository) -> Result { + let mut opts = StatusOptions::new(); + opts.include_untracked(true) + .recurse_untracked_dirs(true) + .renames_head_to_index(true) + .renames_index_to_workdir(true); + + let statuses = repo + .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. + let mut files = BTreeMap::new(); + for entry in statuses.iter() { + let Some((index, worktree)) = status_columns(entry.status()) else { + continue; + }; + let Some((path, old_path)) = paths_from_status_entry(&entry) else { + continue; + }; + if path.is_empty() { + continue; + } + files.insert( + path.clone(), + ChangedFile::from_status_columns(path, old_path, index, worktree), + ); + } + + let files = files.into_values().collect(); + + let tracking = load_tracking_status(repo); + let head = repo.head().ok(); + let head_oid = head.as_ref().and_then(|h| h.target()); + let branch_name = head + .as_ref() + .filter(|h| h.is_branch()) + .and_then(|h| h.shorthand().ok().map(String::from)); + Ok(RepoSnapshot { + files, + tracking, + head_oid, + branch_name, + }) +} + +/// 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. +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. + let index_bits = Status::INDEX_NEW + | Status::INDEX_MODIFIED + | Status::INDEX_DELETED + | Status::INDEX_RENAMED + | Status::INDEX_TYPECHANGE; + if status.contains(Status::WT_NEW) && !status.intersects(index_bits) { + return Some((StatusKind::Untracked, StatusKind::Untracked)); + } + // Conflicts render as `UU` in the first pass; the structured columns keep + // room for the full unmerged matrix later. + if status.contains(Status::CONFLICTED) { + return Some((StatusKind::Unmerged, StatusKind::Unmerged)); + } + + let index = if status.contains(Status::INDEX_NEW) { + StatusKind::Added + } else if status.contains(Status::INDEX_MODIFIED) { + StatusKind::Modified + } else if status.contains(Status::INDEX_DELETED) { + StatusKind::Deleted + } else if status.contains(Status::INDEX_RENAMED) { + StatusKind::Renamed + } else if status.contains(Status::INDEX_TYPECHANGE) { + StatusKind::TypeChanged + } else { + StatusKind::Unmodified + }; + + let worktree = if status.contains(Status::WT_MODIFIED) { + StatusKind::Modified + } else if status.contains(Status::WT_DELETED) { + StatusKind::Deleted + } else if status.contains(Status::WT_RENAMED) { + StatusKind::Renamed + } 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). + StatusKind::Modified + } else { + StatusKind::Unmodified + }; + + if index == StatusKind::Unmodified && worktree == StatusKind::Unmodified { + return None; + } + 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. +fn paths_from_status_entry(entry: &StatusEntry<'_>) -> Option<(String, Option)> { + let i2w = entry.index_to_workdir(); + let h2i = entry.head_to_index(); + let status = entry.status(); + + let path = i2w + .as_ref() + .and_then(new_path_from_delta) + .or_else(|| h2i.as_ref().and_then(new_path_from_delta)) + .or_else(|| entry.path().ok().map(str::to_string))?; + + let old_path = if status.intersects(Status::INDEX_RENAMED | Status::WT_RENAMED) { + // Prefer the HEAD-side original when the index carries a rename, so a + // double-rename (`INDEX_RENAMED | WT_RENAMED`) reports the true original + // path rather than the intermediate staged name. Fall back to the + // worktree side for a pure unstaged rename. + let from = if status.contains(Status::INDEX_RENAMED) { + h2i.as_ref() + } else { + i2w.as_ref() + }; + from.and_then(old_path_from_delta) + .filter(|old| old != &path) + } else { + None + }; + + Some((path, old_path)) +} + +fn new_path_from_delta(delta: &DiffDelta<'_>) -> Option { + delta + .new_file() + .path() + .map(|p| p.to_string_lossy().to_string()) +} + +fn old_path_from_delta(delta: &DiffDelta<'_>) -> Option { + delta + .old_file() + .path() + .map(|p| p.to_string_lossy().to_string()) +} + +pub(super) fn path_from_delta(delta: &DiffDelta<'_>) -> Option { + delta + .new_file() + .path() + .or_else(|| delta.old_file().path()) + .map(|p| p.to_string_lossy().to_string()) +} + +pub(super) fn binary_diff_hunk(file_path: &str) -> DiffHunk { + DiffHunk { + header: format!("Binary file {file_path} changed"), + lines: vec![DiffLine { + kind: LineKind::Context, + content: "Binary files differ".to_string(), + }], + file_path: Some(file_path.to_string()), + } +} \ No newline at end of file diff --git a/src/git/diff/tests/commit_log.rs b/src/git/diff/tests/commit_log.rs new file mode 100644 index 0000000..d97325e --- /dev/null +++ b/src/git/diff/tests/commit_log.rs @@ -0,0 +1,206 @@ +use crate::git::diff::{ + head_commit_oid, is_empty_head, load_commit_log, load_commit_log_from, load_commit_log_page, +}; +use crate::test_util::{make_repo, open_repo, run_git}; +use git2::Oid; +use std::path::Path; + +#[test] +fn commit_log_empty_repo_returns_empty() { + let (dir, path) = make_repo(); + + let commits = load_commit_log(&open_repo(&path), 10).unwrap(); + + assert!(commits.is_empty()); + drop(dir); +} + +#[test] +fn commit_log_page_empty_repo_returns_empty() { + let (dir, path) = make_repo(); + + let page = load_commit_log_page(&open_repo(&path), 0, 5).unwrap(); + + assert!(page.is_empty()); + drop(dir); +} + +#[test] +fn commit_log_page_zero_limit_returns_empty() { + let (dir, path) = make_repo(); + std::fs::write(Path::new(&path).join("f"), "x").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "c1"]); + + let page = load_commit_log_page(&open_repo(&path), 0, 0).unwrap(); + + assert!(page.is_empty()); + drop(dir); +} + +#[test] +fn commit_log_page_paginates_via_skip() { + let (dir, path) = make_repo(); + for i in 0..5 { + std::fs::write(Path::new(&path).join(format!("f{i}")), format!("{i}")).unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", &format!("c{i}")]); + } + + let first = load_commit_log_page(&open_repo(&path), 0, 2).unwrap(); + let second = load_commit_log_page(&open_repo(&path), 2, 2).unwrap(); + let third = load_commit_log_page(&open_repo(&path), 4, 2).unwrap(); + + // Newest first: c4, c3 | c2, c1 | c0. + assert_eq!(first.len(), 2); + assert_eq!(first[0].summary, "c4"); + assert_eq!(first[1].summary, "c3"); + assert_eq!(second.len(), 2); + assert_eq!(second[0].summary, "c2"); + assert_eq!(second[1].summary, "c1"); + assert_eq!(third.len(), 1); + assert_eq!(third[0].summary, "c0"); + drop(dir); +} + +#[test] +fn commit_log_from_an_anchor_ignores_commits_made_after_it() { + // The point of the anchor: a page fetched after a new commit landed must + // continue the history the first page described, not a shifted one. + let (dir, path) = make_repo(); + for i in 0..4 { + std::fs::write(Path::new(&path).join(format!("f{i}")), format!("{i}")).unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", &format!("c{i}")]); + } + let first = load_commit_log_from(&open_repo(&path), None, 0, 2).unwrap(); + let anchor = first[0].oid; + + // A commit lands between the two page requests. + std::fs::write(Path::new(&path).join("late"), "x").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "late"]); + + let anchored = load_commit_log_from(&open_repo(&path), Some(anchor), 2, 2).unwrap(); + let unanchored = load_commit_log_from(&open_repo(&path), None, 2, 2).unwrap(); + + // Anchored: c3, c2 | c1, c0 — one history, no repeats. + assert_eq!(first[0].summary, "c3"); + assert_eq!(first[1].summary, "c2"); + assert_eq!( + anchored.iter().map(|c| c.summary.as_str()).collect::>(), + ["c1", "c0"], + ); + // Unanchored: HEAD moved, so the same skip lands a row late and repeats + // a commit the caller already has. This is what the anchor prevents. + assert_eq!( + unanchored + .iter() + .map(|c| c.summary.as_str()) + .collect::>(), + ["c2", "c1"], + ); + drop(dir); +} + +#[test] +fn commit_log_from_a_missing_anchor_is_an_error() { + let (dir, path) = make_repo(); + std::fs::write(Path::new(&path).join("f"), "x").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "only"]); + let absent = Oid::from_str("0123456789012345678901234567890123456789").unwrap(); + + let result = load_commit_log_from(&open_repo(&path), Some(absent), 0, 5); + + assert!(result.is_err(), "an unknown anchor must not walk from HEAD"); + drop(dir); +} + +#[test] +fn commit_log_from_a_missing_anchor_is_an_error_in_an_empty_repo_too() { + // The emptiness check must not short-circuit ahead of the anchor: an + // unknown commit is the caller's error, and answering "no history" + // instead would report a typo as an exhausted log. + let (dir, path) = make_repo(); + let absent = Oid::from_str("0123456789012345678901234567890123456789").unwrap(); + + let result = load_commit_log_from(&open_repo(&path), Some(absent), 0, 5); + + assert!(result.is_err()); + drop(dir); +} + +#[test] +fn head_commit_oid_separates_an_unborn_head_from_a_commit() { + let (dir, path) = make_repo(); + assert_eq!(head_commit_oid(&open_repo(&path)).unwrap(), None); + + std::fs::write(Path::new(&path).join("f"), "x").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "only"]); + + let oid = head_commit_oid(&open_repo(&path)).unwrap(); + + assert!(oid.is_some(), "a committed repository has a HEAD target"); + drop(dir); +} + +#[test] +fn head_commit_oid_reports_an_unreadable_head_rather_than_no_history() { + let (dir, path) = make_repo(); + std::fs::write(Path::new(&path).join("f"), "x").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "only"]); + let repo = open_repo(&path); + // Not a ref line at all. A HEAD naming a branch that does not exist is + // *not* the case to test: libgit2 calls that an unborn branch, and + // rightly — nothing in the repository distinguishes a branch not yet + // created from one deleted afterwards. Only unparseable content is a + // broken repository. + std::fs::write(Path::new(&path).join(".git/HEAD"), "not-a-ref\n").unwrap(); + + let result = head_commit_oid(&repo); + + assert!( + result.is_err(), + "an unreadable HEAD must not read as an empty history" + ); + drop(dir); +} + +#[test] +fn commit_log_page_skip_beyond_history_returns_empty() { + let (dir, path) = make_repo(); + std::fs::write(Path::new(&path).join("f"), "x").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "only"]); + + let page = load_commit_log_page(&open_repo(&path), 5, 10).unwrap(); + + assert!(page.is_empty()); + drop(dir); +} + +#[test] +fn is_empty_head_recognizes_unborn_branch_error() { + // Drive the actual error path: a freshly-initialized repo has no + // HEAD target, so revwalk.push_head() returns the error variant our + // helper must recognize. This guards against libgit2 changing the + // error class/code combination it reports. + let (dir, path) = make_repo(); + let repo = open_repo(&path); + let mut revwalk = repo.revwalk().unwrap(); + let err = revwalk + .push_head() + .expect_err("empty repo should fail to push HEAD"); + assert!( + is_empty_head(&err), + "is_empty_head failed to recognize unborn HEAD error: \ + class={:?} code={:?} message={}", + err.class(), + err.code(), + err.message() + ); + drop(dir); +} \ No newline at end of file diff --git a/src/git/diff/tests/diff_load.rs b/src/git/diff/tests/diff_load.rs new file mode 100644 index 0000000..616fe10 --- /dev/null +++ b/src/git/diff/tests/diff_load.rs @@ -0,0 +1,276 @@ +use crate::git::diff::{ + LineKind, StatusKind, load_commit_diff, load_commit_file_blob, load_commit_file_diff, + load_commit_files, load_commit_log, load_file_diff, load_snapshot, load_workdir_file, + parse_hunk_new_start, +}; +use crate::test_util::{make_repo, open_repo, run_git}; +use std::path::Path; + +#[test] +fn root_commit_diff_lists_added_files() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("first.rs"); + std::fs::write(&fp, "fn main() {}\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + + let commits = load_commit_log(&open_repo(&path), 1).unwrap(); + let files = load_commit_files(&open_repo(&path), commits[0].oid).unwrap(); + let hunks = load_commit_diff(&open_repo(&path), commits[0].oid).unwrap(); + + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "first.rs"); + assert_eq!(files[0].index, StatusKind::Added); + assert_eq!(files[0].worktree, StatusKind::Unmodified); + assert!( + hunks + .iter() + .flat_map(|h| &h.lines) + .any(|line| line.kind == LineKind::Added && line.content.contains("fn main")) + ); + drop(dir); +} + +#[test] +fn diff_returns_hunks_for_modified_file() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("b.rs"); + std::fs::write(&fp, "fn main() {}\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + std::fs::write(&fp, "fn main() {\n println!(\"hi\");\n}\n").unwrap(); + + let hunks = load_file_diff(&open_repo(&path), "b.rs").unwrap(); + assert!(!hunks.is_empty()); + assert!(hunks[0].lines.iter().any(|l| l.kind == LineKind::Added)); + drop(dir); +} + +#[test] +fn diff_returns_hunks_for_staged_modified_file() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("b.rs"); + std::fs::write(&fp, "fn main() {}\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + std::fs::write(&fp, "fn main() {\n println!(\"hi\");\n}\n").unwrap(); + run_git(&path, &["add", "b.rs"]); + + let hunks = load_file_diff(&open_repo(&path), "b.rs").unwrap(); + + assert!(!hunks.is_empty()); + assert!(hunks[0].lines.iter().any(|l| l.kind == LineKind::Added)); + drop(dir); +} + +#[test] +fn diff_returns_added_lines_for_staged_added_file() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("new.rs"); + std::fs::write(&fp, "fn main() {}\n").unwrap(); + run_git(&path, &["add", "new.rs"]); + + let hunks = load_file_diff(&open_repo(&path), "new.rs").unwrap(); + + assert_eq!(hunks.len(), 1); + assert_eq!(hunks[0].lines[0].kind, LineKind::Added); + drop(dir); +} + +#[test] +fn diff_returns_added_lines_for_untracked_file() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("new.rs"); + std::fs::write(&fp, "fn main() {}\n").unwrap(); + + let snap = load_snapshot(&open_repo(&path)).unwrap(); + assert!( + snap.files + .iter() + .any(|f| { f.path == "new.rs" && f.short_code() == "??" }) + ); + + let hunks = load_file_diff(&open_repo(&path), "new.rs").unwrap(); + assert_eq!(hunks.len(), 1); + assert_eq!(hunks[0].lines[0].kind, LineKind::Added); + drop(dir); +} + +#[test] +fn diff_returns_placeholder_for_binary_file() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("asset.bin"); + std::fs::write(&fp, [0, 1, 2]).unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + std::fs::write(&fp, [0, 1, 3]).unwrap(); + + let hunks = load_file_diff(&open_repo(&path), "asset.bin").unwrap(); + + assert_eq!(hunks.len(), 1); + assert!(hunks[0].header.contains("Binary file")); + drop(dir); +} + +#[test] +fn commit_files_detects_renamed_file() { + let (dir, path) = make_repo(); + let old_path = Path::new(&path).join("old.rs"); + std::fs::write(&old_path, "fn main() {}\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + run_git(&path, &["mv", "old.rs", "new.rs"]); + run_git(&path, &["commit", "-m", "rename"]); + + let commits = load_commit_log(&open_repo(&path), 1).unwrap(); + let files = load_commit_files(&open_repo(&path), commits[0].oid).unwrap(); + + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "new.rs"); + assert_eq!(files[0].index, StatusKind::Renamed); + assert_eq!(files[0].old_path.as_deref(), Some("old.rs")); + assert_eq!(files[0].display_path(), "old.rs -> new.rs"); + drop(dir); +} + +#[test] +fn parse_hunk_new_start_handles_standard_header() { + assert_eq!(parse_hunk_new_start("@@ -1,3 +5,7 @@"), Some(5)); + assert_eq!(parse_hunk_new_start("@@ -10 +12 @@ ctx"), Some(12)); + assert_eq!(parse_hunk_new_start("@@ -0,0 +1,4 @@"), Some(1)); + assert_eq!(parse_hunk_new_start("diff src/foo.rs"), None); + assert_eq!(parse_hunk_new_start("Binary file x changed"), None); + assert_eq!(parse_hunk_new_start("@@"), None); +} + +#[test] +fn load_workdir_file_reads_text_file() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("hello.txt"); + std::fs::write(&fp, "hi\nthere\n").unwrap(); + let content = load_workdir_file(&open_repo(&path), "hello.txt").unwrap(); + assert_eq!(content, "hi\nthere\n"); + drop(dir); +} + +#[test] +fn load_workdir_file_rejects_binary() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("bin"); + std::fs::write(&fp, [0x00, 0xff, 0xfe]).unwrap(); + assert!(load_workdir_file(&open_repo(&path), "bin").is_err()); + drop(dir); +} + +#[cfg(unix)] +#[test] +fn load_workdir_file_rejects_symlink_without_following() { + let (dir, path) = make_repo(); + let target = Path::new(&path).join("target.txt"); + std::fs::write(&target, "secret\n").unwrap(); + std::os::unix::fs::symlink(&target, Path::new(&path).join("link.txt")).unwrap(); + + let err = load_workdir_file(&open_repo(&path), "link.txt").unwrap_err(); + + assert!(err.to_string().contains("symlinks are not followed")); + drop(dir); +} + +#[test] +fn load_workdir_file_rejects_paths_outside_the_worktree() { + let (dir, path) = make_repo(); + + let err = load_workdir_file(&open_repo(&path), "../../etc/passwd").unwrap_err(); + + assert!( + err.to_string().contains("plain relative path"), + "unexpected error: {err}" + ); + drop(dir); +} + +#[test] +fn load_workdir_file_rejects_reading_the_git_directory() { + let (dir, path) = make_repo(); + + let err = load_workdir_file(&open_repo(&path), ".git/config").unwrap_err(); + + assert!( + err.to_string().contains("git directory"), + "unexpected error: {err}" + ); + drop(dir); +} + +#[test] +fn load_commit_file_blob_reads_committed_text() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("a.txt"); + std::fs::write(&fp, "v1\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + std::fs::write(&fp, "v2\n").unwrap(); + let commits = load_commit_log(&open_repo(&path), 1).unwrap(); + let content = load_commit_file_blob( + &open_repo(&path), + commits[0].oid, + "a.txt", + StatusKind::Modified, + ) + .unwrap(); + assert_eq!(content, "v1\n"); + drop(dir); +} + +#[test] +fn load_commit_file_blob_reads_deleted_file_from_parent() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("gone.txt"); + std::fs::write(&fp, "before delete\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "add file"]); + std::fs::remove_file(&fp).unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "delete file"]); + + let commits = load_commit_log(&open_repo(&path), 1).unwrap(); + let content = load_commit_file_blob( + &open_repo(&path), + commits[0].oid, + "gone.txt", + StatusKind::Deleted, + ) + .unwrap(); + + assert_eq!(content, "before delete\n"); + drop(dir); +} + +#[test] +fn commit_file_diff_returns_renamed_file_diff() { + let (dir, path) = make_repo(); + let old_path = Path::new(&path).join("old.rs"); + std::fs::write(&old_path, "fn main() {}\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + run_git(&path, &["mv", "old.rs", "new.rs"]); + std::fs::write( + Path::new(&path).join("new.rs"), + "fn main() {\n println!(\"hi\");\n}\n", + ) + .unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "rename and edit"]); + + let commits = load_commit_log(&open_repo(&path), 1).unwrap(); + let hunks = load_commit_file_diff(&open_repo(&path), commits[0].oid, "new.rs").unwrap(); + + assert!(!hunks.is_empty()); + assert!( + hunks + .iter() + .flat_map(|h| &h.lines) + .any(|l| l.kind == LineKind::Added && l.content.contains("println")) + ); + drop(dir); +} \ No newline at end of file diff --git a/src/git/diff/tests/mod.rs b/src/git/diff/tests/mod.rs new file mode 100644 index 0000000..d190c9e --- /dev/null +++ b/src/git/diff/tests/mod.rs @@ -0,0 +1,3 @@ +mod commit_log; +mod diff_load; +mod snapshot; \ No newline at end of file diff --git a/src/git/diff/tests/snapshot.rs b/src/git/diff/tests/snapshot.rs new file mode 100644 index 0000000..318a32a --- /dev/null +++ b/src/git/diff/tests/snapshot.rs @@ -0,0 +1,269 @@ +use crate::git::diff::{ + ChangedFile, RepoSnapshot, StatusKind, load_file_diff, load_snapshot, +}; +use crate::test_util::{make_repo, open_repo, run_git}; +use std::borrow::Cow; +use std::path::Path; + +#[test] +fn snapshot_empty_repo_does_not_panic() { + let (dir, path) = make_repo(); + let _ = load_snapshot(&open_repo(&path)); + drop(dir); +} + +#[test] +fn snapshot_detects_modified_file() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("a.txt"); + std::fs::write(&fp, "line1\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + std::fs::write(&fp, "line1\nline2\n").unwrap(); + + let snap = load_snapshot(&open_repo(&path)).unwrap(); + assert!(snap.files.iter().any(|f| f.path.contains("a.txt"))); + drop(dir); +} + +#[test] +fn snapshot_detects_staged_modified_file() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("a.txt"); + std::fs::write(&fp, "line1\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + std::fs::write(&fp, "line1\nline2\n").unwrap(); + run_git(&path, &["add", "a.txt"]); + + let snap = load_snapshot(&open_repo(&path)).unwrap(); + + assert!(snap.files.iter().any(|f| f.path == "a.txt" + && f.index == StatusKind::Modified + && f.worktree == StatusKind::Unmodified + && f.short_code() == "M ")); + drop(dir); +} + +#[test] +fn snapshot_detects_staged_added_file() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("new.rs"); + std::fs::write(&fp, "fn main() {}\n").unwrap(); + run_git(&path, &["add", "new.rs"]); + + let snap = load_snapshot(&open_repo(&path)).unwrap(); + + assert!( + snap.files.iter().any(|f| f.path == "new.rs" + && f.index == StatusKind::Added + && f.short_code() == "A ") + ); + drop(dir); +} + +#[test] +fn snapshot_recurses_untracked_directories() { + let (dir, path) = make_repo(); + let nested = Path::new(&path).join("src").join("new.rs"); + std::fs::create_dir_all(nested.parent().unwrap()).unwrap(); + std::fs::write(&nested, "fn main() {}\n").unwrap(); + + let snap = load_snapshot(&open_repo(&path)).unwrap(); + + assert!(snap.files.iter().any(|f| f.path == "src/new.rs")); + drop(dir); +} + +// --- Workstream 1: XY status model unit tests (no git needed) --- + +#[test] +fn short_code_renders_each_xy_combination() { + let mk = |index, worktree| { + ChangedFile::from_status_columns("p".into(), None, index, worktree).short_code() + }; + assert_eq!(mk(StatusKind::Unmodified, StatusKind::Modified), " M"); + assert_eq!(mk(StatusKind::Modified, StatusKind::Unmodified), "M "); + assert_eq!(mk(StatusKind::Modified, StatusKind::Modified), "MM"); + assert_eq!(mk(StatusKind::Added, StatusKind::Unmodified), "A "); + assert_eq!(mk(StatusKind::Renamed, StatusKind::Unmodified), "R "); + assert_eq!(mk(StatusKind::TypeChanged, StatusKind::Unmodified), "T "); + // Untracked and conflicted collapse to git's two-column sentinels + // regardless of which column carries the bit. + assert_eq!(mk(StatusKind::Untracked, StatusKind::Untracked), "??"); + assert_eq!(mk(StatusKind::Unmerged, StatusKind::Unmerged), "UU"); +} + +#[test] +fn display_path_borrows_for_non_rename_and_formats_rename() { + let plain = ChangedFile::from_status_columns( + "src/a.rs".into(), + None, + StatusKind::Modified, + StatusKind::Unmodified, + ); + assert!(matches!(plain.display_path(), Cow::Borrowed(_))); + assert_eq!(plain.display_path(), "src/a.rs"); + + let renamed = ChangedFile::from_status_columns( + "new.rs".into(), + Some("old.rs".into()), + StatusKind::Renamed, + StatusKind::Unmodified, + ); + assert!(matches!(renamed.display_path(), Cow::Owned(_))); + assert_eq!(renamed.display_path(), "old.rs -> new.rs"); + // Search text matches either side of a rename. + assert!(renamed.search_lower.contains("old.rs")); + assert!(renamed.search_lower.contains("new.rs")); +} + +#[test] +fn most_severe_picks_higher_severity_column() { + // Deleted outranks modified regardless of column. + let f = ChangedFile::from_status_columns( + "p".into(), + None, + StatusKind::Modified, + StatusKind::Deleted, + ); + assert_eq!(f.most_severe(), StatusKind::Deleted); + let f = ChangedFile::from_status_columns( + "p".into(), + None, + StatusKind::Deleted, + StatusKind::Modified, + ); + assert_eq!(f.most_severe(), StatusKind::Deleted); +} + +// --- Workstream 1: git status -> XY mapping tests --- + +fn find<'a>(snap: &'a RepoSnapshot, path: &str) -> &'a ChangedFile { + snap.files + .iter() + .find(|f| f.path == path) + .unwrap_or_else(|| panic!("{path} missing from snapshot")) +} + +#[test] +fn snapshot_distinguishes_staged_and_unstaged_modification() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("a.txt"); + std::fs::write(&fp, "v1\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + // Stage one modification, then modify again without staging. + std::fs::write(&fp, "v2\n").unwrap(); + run_git(&path, &["add", "a.txt"]); + std::fs::write(&fp, "v3\n").unwrap(); + + let snap = load_snapshot(&open_repo(&path)).unwrap(); + assert_eq!(find(&snap, "a.txt").short_code(), "MM"); + drop(dir); +} + +#[test] +fn snapshot_distinguishes_staged_and_unstaged_deletion() { + let (dir, path) = make_repo(); + std::fs::write(Path::new(&path).join("staged.txt"), "x\n").unwrap(); + std::fs::write(Path::new(&path).join("wt.txt"), "y\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + // staged deletion (index) vs working-tree deletion (unstaged). + run_git(&path, &["rm", "staged.txt"]); + std::fs::remove_file(Path::new(&path).join("wt.txt")).unwrap(); + + let snap = load_snapshot(&open_repo(&path)).unwrap(); + assert_eq!(find(&snap, "staged.txt").short_code(), "D "); + assert_eq!(find(&snap, "wt.txt").short_code(), " D"); + drop(dir); +} + +#[test] +fn snapshot_keeps_staged_deletion_visible_when_path_recreated() { + // `INDEX_DELETED | WT_NEW`: a staged deletion with a fresh untracked + // file recreated at the same path. git emits two rows (`D ` and `??`); + // our one-row model must keep the staged deletion rather than masking + // the whole row as untracked. + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("f.txt"); + std::fs::write(&fp, "orig\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + run_git(&path, &["rm", "--cached", "f.txt"]); + std::fs::write(&fp, "new content\n").unwrap(); + + let snap = load_snapshot(&open_repo(&path)).unwrap(); + let f = find(&snap, "f.txt"); + assert_eq!(f.index, StatusKind::Deleted); + assert_eq!(f.short_code(), "D "); + drop(dir); +} + +#[cfg(unix)] +#[test] +fn snapshot_detects_staged_typechange() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("f"); + std::fs::write(&fp, "regular\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + // Replace the regular file with a symlink and stage it. + std::fs::remove_file(&fp).unwrap(); + std::os::unix::fs::symlink("target", &fp).unwrap(); + run_git(&path, &["add", "f"]); + + let snap = load_snapshot(&open_repo(&path)).unwrap(); + assert_eq!(find(&snap, "f").index, StatusKind::TypeChanged); + assert_eq!(find(&snap, "f").short_code(), "T "); + drop(dir); +} + +#[test] +fn snapshot_renders_conflicted_file_as_uu() { + let (dir, path) = make_repo(); + let fp = Path::new(&path).join("c.txt"); + std::fs::write(&fp, "base\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + run_git(&path, &["checkout", "-b", "feature"]); + std::fs::write(&fp, "feature\n").unwrap(); + run_git(&path, &["commit", "-am", "feature edit"]); + run_git(&path, &["checkout", "-"]); + std::fs::write(&fp, "mainline\n").unwrap(); + run_git(&path, &["commit", "-am", "mainline edit"]); + // Conflicting merge exits non-zero; run it tolerantly. + let merge = std::process::Command::new("git") + .args(["merge", "feature"]) + .current_dir(&path) + .output() + .unwrap(); + assert!(!merge.status.success(), "merge should conflict"); + + let snap = load_snapshot(&open_repo(&path)).unwrap(); + assert_eq!(find(&snap, "c.txt").short_code(), "UU"); + drop(dir); +} + +#[test] +fn snapshot_preserves_rename_and_loads_new_side_diff() { + let (dir, path) = make_repo(); + // Keep content identical across the rename so git's similarity + // detection reports a staged rename rather than add+delete. + std::fs::write(Path::new(&path).join("old.rs"), "fn main() {}\n").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + run_git(&path, &["mv", "old.rs", "new.rs"]); + + let snap = load_snapshot(&open_repo(&path)).unwrap(); + let f = find(&snap, "new.rs"); + assert_eq!(f.index, StatusKind::Renamed); + assert_eq!(f.old_path.as_deref(), Some("old.rs")); + assert_eq!(f.display_path(), "old.rs -> new.rs"); + + // The effective path is the new side; selecting it must still load a + // diff without error (regression guard for the rename display change). + assert!(load_file_diff(&open_repo(&path), &f.path).is_ok()); + drop(dir); +} \ No newline at end of file diff --git a/src/git/diff/types.rs b/src/git/diff/types.rs new file mode 100644 index 0000000..ecb488a --- /dev/null +++ b/src/git/diff/types.rs @@ -0,0 +1,220 @@ +use git2::Oid; +use std::borrow::Cow; + +/// State of a single git status column. `index` (X) compares HEAD with the +/// staged tree, `worktree` (Y) compares the staged tree with the working +/// directory. Either column can be `Unmodified` — that is what the old +/// single-status `ChangeStatus` could not express. Mirrors the codes used by +/// `git status --short`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusKind { + Unmodified, + Added, + Modified, + Deleted, + Renamed, + TypeChanged, + Untracked, + Unmerged, +} + +impl StatusKind { + /// The Git short status character for this column. `Unmodified` is a space + /// so a single-sided change renders as ` M` / `M `. + fn code_char(self) -> char { + match self { + Self::Unmodified => ' ', + Self::Added => 'A', + Self::Modified => 'M', + Self::Deleted => 'D', + Self::Renamed => 'R', + Self::TypeChanged => 'T', + Self::Untracked => '?', + Self::Unmerged => 'U', + } + } + + /// Severity rank used to pick a single color for the two-character code. + /// Higher wins: unmerged > deleted > renamed > added > modified > + /// typechanged > untracked > unmodified (see plan Resolved Decisions #3). + fn severity(self) -> u8 { + match self { + Self::Unmerged => 7, + Self::Deleted => 6, + Self::Renamed => 5, + Self::Added => 4, + Self::Modified => 3, + Self::TypeChanged => 2, + Self::Untracked => 1, + Self::Unmodified => 0, + } + } +} + +#[derive(Debug, Clone)] +pub struct ChangedFile { + /// New/effective path. Used for diff loading, file preview, hot-file + /// tracking, and selection restoration. + pub path: String, + /// Old path for renames (display/search metadata only). `None` otherwise. + pub old_path: Option, + /// Index column (X): HEAD vs staged. + pub index: StatusKind, + /// Working-tree column (Y): staged vs working directory. + pub worktree: StatusKind, + /// Pre-computed lowercase search text. For renames it contains both old and + /// new paths so either side matches the `contains` filter; otherwise it is + /// the lowercased `path`. Set on construction so the file-list filter + /// doesn't lowercase on every keystroke. + pub search_lower: String, +} + +impl ChangedFile { + /// Build from explicit status columns (status snapshot path). + pub fn from_status_columns( + path: String, + old_path: Option, + index: StatusKind, + worktree: StatusKind, + ) -> Self { + let search_lower = match &old_path { + Some(old) => format!("{old} {path}").to_lowercase(), + None => path.to_lowercase(), + }; + Self { + path, + old_path, + index, + worktree, + search_lower, + } + } + + /// Build from a commit delta: the single delta status lives in the index + /// column and the worktree column is `Unmodified`, so commit drill-down + /// rows render `M `, `A `, `D `, `R `. + pub fn from_commit_delta(path: String, old_path: Option, kind: StatusKind) -> Self { + Self::from_status_columns(path, old_path, kind, StatusKind::Unmodified) + } + + /// Two-character Git short status code (`XY`). Untracked is special-cased + /// to `??` and conflicts to `UU` to match git rather than emitting ` ?` + /// from a blank index plus untracked worktree. + pub fn short_code(&self) -> String { + if self.index == StatusKind::Untracked || self.worktree == StatusKind::Untracked { + return "??".to_string(); + } + if self.index == StatusKind::Unmerged || self.worktree == StatusKind::Unmerged { + return "UU".to_string(); + } + let mut code = String::with_capacity(2); + code.push(self.index.code_char()); + code.push(self.worktree.code_char()); + code + } + + /// The more severe of the two columns, used to pick the row color. + pub fn most_severe(&self) -> StatusKind { + if self.index.severity() >= self.worktree.severity() { + self.index + } else { + self.worktree + } + } + + /// 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()`. + pub fn display_path(&self) -> Cow<'_, str> { + match &self.old_path { + Some(old) => Cow::Owned(format!("{old} -> {}", self.path)), + None => Cow::Borrowed(&self.path), + } + } + + /// Test-only convenience: an unstaged change of `kind` at `path` + /// (` X` column blank). Production code uses the explicit constructors. + #[cfg(test)] + pub(crate) fn unstaged_only(path: String, kind: StatusKind) -> Self { + Self::from_status_columns(path, None, StatusKind::Unmodified, kind) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineKind { + Added, + Removed, + Context, +} + +#[derive(Debug, Clone)] +pub struct DiffLine { + pub kind: LineKind, + pub content: String, +} + +#[derive(Debug, Clone)] +pub struct DiffHunk { + pub header: String, + pub lines: Vec, + /// File this hunk belongs to. `Some` for hunks emitted by the diff + /// collectors below; `None` for hand-built fixtures in tests where the + /// path is irrelevant. Used by the renderer to pick a per-hunk syntax + /// in commit diffs (one commit can touch multiple file types). + pub file_path: Option, +} + +#[derive(Debug, Clone)] +pub struct TrackingStatus { + pub ahead: usize, + pub behind: usize, +} + +#[derive(Debug, Clone)] +pub struct RepoSnapshot { + pub files: Vec, + pub tracking: Option, + /// HEAD commit oid at the moment the snapshot was taken. `None` for + /// empty or detached repositories with no resolvable HEAD. The main + /// thread compares this against `App::last_head_oid` to detect new + /// commits and refresh the Log view's cached commit list. + pub head_oid: Option, + /// Current branch shorthand (e.g. `main`) when HEAD points at a branch. + /// `None` for detached HEAD, unborn branch, or bare repo so the header + /// can decide whether to render the branch chip. + pub branch_name: Option, +} + +#[derive(Debug, Clone)] +pub struct CommitEntry { + pub oid: Oid, + pub short_id: String, + pub summary: String, + /// Pre-computed lowercase form of `summary` for case-insensitive search. + /// Set on construction so the commit-log filter doesn't lowercase on every + /// keystroke. Mirrors `ChangedFile::search_lower`. + pub summary_lower: String, + pub author: String, + pub time: i64, +} + +impl CommitEntry { + pub fn new(oid: Oid, short_id: String, summary: String, author: String, time: i64) -> Self { + let summary_lower = summary.to_lowercase(); + Self { + oid, + short_id, + summary, + summary_lower, + author, + time, + } + } +} + +impl std::fmt::Display for CommitEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {}", self.short_id, self.summary) + } +} \ No newline at end of file diff --git a/src/git/path.rs b/src/git/path.rs deleted file mode 100644 index a97e83f..0000000 --- a/src/git/path.rs +++ /dev/null @@ -1,307 +0,0 @@ -//! 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. Today's callers pass paths that -//! git itself produced, but 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. - -use anyhow::{Result, anyhow}; -use std::path::{Component, Path, PathBuf}; - -/// Directory name that holds git's own state. Reading it through a file -/// preview would expose config, hooks, and object contents. -const GIT_DIR: &str = ".git"; - -/// Characters NTFS and some other filesystems strip from the end of a name, so -/// that `.git.` and `.git ` open the same directory as `.git`. Git defends the -/// same way (`core.protectNTFS`). -const NAME_PADDING: [char; 2] = ['.', ' ']; - -/// True when `name` refers to the git directory on *any* filesystem this could -/// run on: case-insensitively (macOS, Windows) and ignoring the trailing dots -/// and spaces that NTFS discards. -/// -/// 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 { - name.trim_end_matches(NAME_PADDING) - .eq_ignore_ascii_case(GIT_DIR) -} - -fn is_git_dir(part: &std::ffi::OsStr) -> bool { - // A non-UTF-8 name cannot equal the ASCII `.git` under any of these rules. - part.to_str().is_some_and(is_git_dir_name) -} - -/// Validate a path used only to address an object *inside a git commit*. -/// -/// 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. -pub fn validate_commit_path(relative: &str) -> Result<()> { - if relative.is_empty() { - return Err(anyhow!("empty path")); - } - if relative.contains('\0') { - return Err(anyhow!("path contains a NUL byte")); - } - - for component in Path::new(relative).components() { - match component { - Component::Normal(part) if !is_git_dir(part) => {} - Component::Normal(_) => { - return Err(anyhow!("path enters the git directory: {relative}")); - } - // `..`/`.`/absolute paths must not be accepted as git pathspecs. - _ => return Err(anyhow!("path is not a plain relative path: {relative}")), - } - } - Ok(()) -} - -/// 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 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 -/// accepted, since every surface reaching this function is already -/// authenticated and local. -pub fn resolve_in_workdir(workdir: &Path, relative: &str) -> Result { - validate_commit_path(relative)?; - - let candidate = Path::new(relative); - for component in candidate.components() { - match component { - Component::Normal(_) => {} - // `..` escapes the worktree; `.` and root/prefix components mean - // the path is absolute or non-normalized. Reject rather than - // normalize, so a rejected path never silently becomes another. - _ => return Err(anyhow!("path is not a plain relative path: {relative}")), - } - } - - // Walk down one component at a time so an intermediate symlink is caught - // before it is ever traversed. Canonicalizing the whole path instead would - // follow those links first and only then compare the result. - let base = workdir - .canonicalize() - .map_err(|err| anyhow!("worktree is unavailable: {err}"))?; - let mut resolved = base.clone(); - for component in candidate.components() { - resolved.push(component); - let meta = std::fs::symlink_metadata(&resolved) - .map_err(|err| anyhow!("failed to stat {relative}: {err}"))?; - if meta.file_type().is_symlink() { - return Err(anyhow!("symlinks are not followed: {relative}")); - } - } - - // Belt and braces: the component walk already rejected every link, so this - // can only fail if the worktree moved mid-walk. - if !resolved.starts_with(&base) { - return Err(anyhow!("path escapes the worktree: {relative}")); - } - Ok(resolved) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn workdir() -> (tempfile::TempDir, PathBuf) { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().canonicalize().unwrap(); - (dir, path) - } - - #[test] - fn resolve_in_workdir_accepts_a_nested_file() { - let (_dir, root) = workdir(); - std::fs::create_dir(root.join("src")).unwrap(); - std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap(); - - let resolved = resolve_in_workdir(&root, "src/main.rs").unwrap(); - - assert_eq!(resolved, root.join("src/main.rs")); - } - - #[test] - fn resolve_in_workdir_rejects_parent_traversal() { - let (_dir, root) = workdir(); - - let err = resolve_in_workdir(&root, "../secrets.txt").unwrap_err(); - - assert!( - err.to_string().contains("plain relative path"), - "unexpected error: {err}" - ); - } - - #[test] - fn resolve_in_workdir_rejects_traversal_hidden_mid_path() { - let (_dir, root) = workdir(); - std::fs::create_dir(root.join("src")).unwrap(); - - let err = resolve_in_workdir(&root, "src/../../secrets.txt").unwrap_err(); - - assert!( - err.to_string().contains("plain relative path"), - "unexpected error: {err}" - ); - } - - #[test] - fn resolve_in_workdir_rejects_absolute_paths() { - let (_dir, root) = workdir(); - - let err = resolve_in_workdir(&root, "/etc/passwd").unwrap_err(); - - assert!( - err.to_string().contains("plain relative path"), - "unexpected error: {err}" - ); - } - - #[test] - fn resolve_in_workdir_rejects_the_git_directory() { - let (_dir, root) = workdir(); - std::fs::create_dir(root.join(GIT_DIR)).unwrap(); - std::fs::write(root.join(".git/config"), "[core]").unwrap(); - - let err = resolve_in_workdir(&root, ".git/config").unwrap_err(); - - assert!( - err.to_string().contains("git directory"), - "unexpected error: {err}" - ); - } - - #[test] - fn resolve_in_workdir_rejects_the_git_directory_in_any_case() { - let (_dir, root) = workdir(); - - let err = resolve_in_workdir(&root, ".GIT/config").unwrap_err(); - - assert!( - err.to_string().contains("git directory"), - "unexpected error: {err}" - ); - } - - #[test] - fn resolve_in_workdir_rejects_the_git_directory_with_trailing_padding() { - // NTFS strips trailing dots and spaces, so these name `.git` there. The - // check must not depend on the host filesystem refusing to open them. - let (_dir, root) = workdir(); - - for padded in [".git.", ".git ", ".GIT.. ", ".git. /config"] { - let err = resolve_in_workdir(&root, padded).unwrap_err(); - assert!( - err.to_string().contains("git directory"), - "{padded:?} must be rejected as the git directory, got: {err}" - ); - } - } - - #[test] - fn resolve_in_workdir_allows_dotfiles_that_merely_start_with_git() { - // `.gitignore` and `.github/` are ordinary tracked files; rejecting a - // whole `.git*` prefix would make them unviewable. - let (_dir, root) = workdir(); - std::fs::write(root.join(".gitignore"), "/target\n").unwrap(); - std::fs::create_dir(root.join(".github")).unwrap(); - std::fs::write(root.join(".github/ci.yml"), "on: push\n").unwrap(); - - assert!(resolve_in_workdir(&root, ".gitignore").is_ok()); - assert!(resolve_in_workdir(&root, ".github/ci.yml").is_ok()); - } - - #[test] - fn resolve_in_workdir_rejects_an_empty_path() { - let (_dir, root) = workdir(); - - assert!(resolve_in_workdir(&root, "").is_err()); - } - - #[test] - fn resolve_in_workdir_rejects_a_nul_byte() { - let (_dir, root) = workdir(); - - let err = resolve_in_workdir(&root, "src/main.rs\0.png").unwrap_err(); - - assert!(err.to_string().contains("NUL"), "unexpected error: {err}"); - } - - #[test] - fn resolve_in_workdir_rejects_a_symlinked_leaf() { - let (_dir, root) = workdir(); - std::fs::write(root.join("real.txt"), "data").unwrap(); - std::os::unix::fs::symlink(root.join("real.txt"), root.join("link.txt")).unwrap(); - - let err = resolve_in_workdir(&root, "link.txt").unwrap_err(); - - assert!( - err.to_string().contains("symlink"), - "unexpected error: {err}" - ); - } - - #[test] - fn resolve_in_workdir_rejects_a_symlinked_parent_directory() { - // The leaf is an ordinary file; only the directory above it is a link. - // A leaf-only check misses this and happily reads outside the worktree. - let outside = tempfile::TempDir::new().unwrap(); - std::fs::write(outside.path().join("secrets.txt"), "token").unwrap(); - let (_dir, root) = workdir(); - std::os::unix::fs::symlink(outside.path(), root.join("escape")).unwrap(); - - let err = resolve_in_workdir(&root, "escape/secrets.txt").unwrap_err(); - - assert!( - err.to_string().contains("symlink"), - "unexpected error: {err}" - ); - } - - #[test] - fn resolve_in_workdir_reports_a_missing_file() { - let (_dir, root) = workdir(); - - let err = resolve_in_workdir(&root, "nope.txt").unwrap_err(); - - assert!( - err.to_string().contains("failed to stat"), - "unexpected error: {err}" - ); - } - - #[test] - fn validate_commit_path_allows_a_deleted_worktree_path() { - let (_dir, root) = workdir(); - - // Historical diff routes must be able to address a file that no - // longer exists in the current checkout. - assert!(validate_commit_path("gone.txt").is_ok()); - assert!(resolve_in_workdir(&root, "gone.txt").is_err()); - } - - #[test] - fn validate_commit_path_keeps_the_worktree_safety_rules() { - for path in [ - "../secret", - "/etc/passwd", - ".git/config", - "src/../x", - "x\0y", - ] { - assert!(validate_commit_path(path).is_err(), "{path:?} was accepted"); - } - } -} diff --git a/src/git/path/mod.rs b/src/git/path/mod.rs new file mode 100644 index 0000000..2830563 --- /dev/null +++ b/src/git/path/mod.rs @@ -0,0 +1,116 @@ +//! 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. Today's callers pass paths that +//! git itself produced, but 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. + +use anyhow::{Result, anyhow}; +use std::path::{Component, Path, PathBuf}; + +/// Directory name that holds git's own state. Reading it through a file +/// preview would expose config, hooks, and object contents. +const GIT_DIR: &str = ".git"; + +/// Characters NTFS and some other filesystems strip from the end of a name, so +/// that `.git.` and `.git ` open the same directory as `.git`. Git defends the +/// same way (`core.protectNTFS`). +const NAME_PADDING: [char; 2] = ['.', ' ']; + +/// True when `name` refers to the git directory on *any* filesystem this could +/// run on: case-insensitively (macOS, Windows) and ignoring the trailing dots +/// and spaces that NTFS discards. +/// +/// 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 { + name.trim_end_matches(NAME_PADDING) + .eq_ignore_ascii_case(GIT_DIR) +} + +fn is_git_dir(part: &std::ffi::OsStr) -> bool { + // A non-UTF-8 name cannot equal the ASCII `.git` under any of these rules. + part.to_str().is_some_and(is_git_dir_name) +} + +/// Validate a path used only to address an object *inside a git commit*. +/// +/// 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. +pub fn validate_commit_path(relative: &str) -> Result<()> { + if relative.is_empty() { + return Err(anyhow!("empty path")); + } + if relative.contains('\0') { + return Err(anyhow!("path contains a NUL byte")); + } + + for component in Path::new(relative).components() { + match component { + Component::Normal(part) if !is_git_dir(part) => {} + Component::Normal(_) => { + return Err(anyhow!("path enters the git directory: {relative}")); + } + // `..`/`.`/absolute paths must not be accepted as git pathspecs. + _ => return Err(anyhow!("path is not a plain relative path: {relative}")), + } + } + Ok(()) +} + +/// 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 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 +/// accepted, since every surface reaching this function is already +/// authenticated and local. +pub fn resolve_in_workdir(workdir: &Path, relative: &str) -> Result { + validate_commit_path(relative)?; + + let candidate = Path::new(relative); + for component in candidate.components() { + match component { + Component::Normal(_) => {} + // `..` escapes the worktree; `.` and root/prefix components mean + // the path is absolute or non-normalized. Reject rather than + // normalize, so a rejected path never silently becomes another. + _ => return Err(anyhow!("path is not a plain relative path: {relative}")), + } + } + + // Walk down one component at a time so an intermediate symlink is caught + // before it is ever traversed. Canonicalizing the whole path instead would + // follow those links first and only then compare the result. + let base = workdir + .canonicalize() + .map_err(|err| anyhow!("worktree is unavailable: {err}"))?; + let mut resolved = base.clone(); + for component in candidate.components() { + resolved.push(component); + let meta = std::fs::symlink_metadata(&resolved) + .map_err(|err| anyhow!("failed to stat {relative}: {err}"))?; + if meta.file_type().is_symlink() { + return Err(anyhow!("symlinks are not followed: {relative}")); + } + } + + // Belt and braces: the component walk already rejected every link, so this + // can only fail if the worktree moved mid-walk. + if !resolved.starts_with(&base) { + return Err(anyhow!("path escapes the worktree: {relative}")); + } + Ok(resolved) +} + +#[cfg(test)] +mod tests; + diff --git a/src/git/path/tests.rs b/src/git/path/tests.rs new file mode 100644 index 0000000..bc65e70 --- /dev/null +++ b/src/git/path/tests.rs @@ -0,0 +1,191 @@ +use super::*; + +fn workdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().canonicalize().unwrap(); + (dir, path) +} + +#[test] +fn resolve_in_workdir_accepts_a_nested_file() { + let (_dir, root) = workdir(); + std::fs::create_dir(root.join("src")).unwrap(); + std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap(); + + let resolved = resolve_in_workdir(&root, "src/main.rs").unwrap(); + + assert_eq!(resolved, root.join("src/main.rs")); +} + +#[test] +fn resolve_in_workdir_rejects_parent_traversal() { + let (_dir, root) = workdir(); + + let err = resolve_in_workdir(&root, "../secrets.txt").unwrap_err(); + + assert!( + err.to_string().contains("plain relative path"), + "unexpected error: {err}" + ); +} + +#[test] +fn resolve_in_workdir_rejects_traversal_hidden_mid_path() { + let (_dir, root) = workdir(); + std::fs::create_dir(root.join("src")).unwrap(); + + let err = resolve_in_workdir(&root, "src/../../secrets.txt").unwrap_err(); + + assert!( + err.to_string().contains("plain relative path"), + "unexpected error: {err}" + ); +} + +#[test] +fn resolve_in_workdir_rejects_absolute_paths() { + let (_dir, root) = workdir(); + + let err = resolve_in_workdir(&root, "/etc/passwd").unwrap_err(); + + assert!( + err.to_string().contains("plain relative path"), + "unexpected error: {err}" + ); +} + +#[test] +fn resolve_in_workdir_rejects_the_git_directory() { + let (_dir, root) = workdir(); + std::fs::create_dir(root.join(GIT_DIR)).unwrap(); + std::fs::write(root.join(".git/config"), "[core]").unwrap(); + + let err = resolve_in_workdir(&root, ".git/config").unwrap_err(); + + assert!( + err.to_string().contains("git directory"), + "unexpected error: {err}" + ); +} + +#[test] +fn resolve_in_workdir_rejects_the_git_directory_in_any_case() { + let (_dir, root) = workdir(); + + let err = resolve_in_workdir(&root, ".GIT/config").unwrap_err(); + + assert!( + err.to_string().contains("git directory"), + "unexpected error: {err}" + ); +} + +#[test] +fn resolve_in_workdir_rejects_the_git_directory_with_trailing_padding() { + // NTFS strips trailing dots and spaces, so these name `.git` there. The + // check must not depend on the host filesystem refusing to open them. + let (_dir, root) = workdir(); + + for padded in [".git.", ".git ", ".GIT.. ", ".git. /config"] { + let err = resolve_in_workdir(&root, padded).unwrap_err(); + assert!( + err.to_string().contains("git directory"), + "{padded:?} must be rejected as the git directory, got: {err}" + ); + } +} + +#[test] +fn resolve_in_workdir_allows_dotfiles_that_merely_start_with_git() { + // `.gitignore` and `.github/` are ordinary tracked files; rejecting a + // whole `.git*` prefix would make them unviewable. + let (_dir, root) = workdir(); + std::fs::write(root.join(".gitignore"), "/target\n").unwrap(); + std::fs::create_dir(root.join(".github")).unwrap(); + std::fs::write(root.join(".github/ci.yml"), "on: push\n").unwrap(); + + assert!(resolve_in_workdir(&root, ".gitignore").is_ok()); + assert!(resolve_in_workdir(&root, ".github/ci.yml").is_ok()); +} + +#[test] +fn resolve_in_workdir_rejects_an_empty_path() { + let (_dir, root) = workdir(); + + assert!(resolve_in_workdir(&root, "").is_err()); +} + +#[test] +fn resolve_in_workdir_rejects_a_nul_byte() { + let (_dir, root) = workdir(); + + let err = resolve_in_workdir(&root, "src/main.rs\0.png").unwrap_err(); + + assert!(err.to_string().contains("NUL"), "unexpected error: {err}"); +} + +#[test] +fn resolve_in_workdir_rejects_a_symlinked_leaf() { + let (_dir, root) = workdir(); + std::fs::write(root.join("real.txt"), "data").unwrap(); + std::os::unix::fs::symlink(root.join("real.txt"), root.join("link.txt")).unwrap(); + + let err = resolve_in_workdir(&root, "link.txt").unwrap_err(); + + assert!( + err.to_string().contains("symlink"), + "unexpected error: {err}" + ); +} + +#[test] +fn resolve_in_workdir_rejects_a_symlinked_parent_directory() { + // The leaf is an ordinary file; only the directory above it is a link. + // A leaf-only check misses this and happily reads outside the worktree. + let outside = tempfile::TempDir::new().unwrap(); + std::fs::write(outside.path().join("secrets.txt"), "token").unwrap(); + let (_dir, root) = workdir(); + std::os::unix::fs::symlink(outside.path(), root.join("escape")).unwrap(); + + let err = resolve_in_workdir(&root, "escape/secrets.txt").unwrap_err(); + + assert!( + err.to_string().contains("symlink"), + "unexpected error: {err}" + ); +} + +#[test] +fn resolve_in_workdir_reports_a_missing_file() { + let (_dir, root) = workdir(); + + let err = resolve_in_workdir(&root, "nope.txt").unwrap_err(); + + assert!( + err.to_string().contains("failed to stat"), + "unexpected error: {err}" + ); +} + +#[test] +fn validate_commit_path_allows_a_deleted_worktree_path() { + let (_dir, root) = workdir(); + + // Historical diff routes must be able to address a file that no + // longer exists in the current checkout. + assert!(validate_commit_path("gone.txt").is_ok()); + assert!(resolve_in_workdir(&root, "gone.txt").is_err()); +} + +#[test] +fn validate_commit_path_keeps_the_worktree_safety_rules() { + for path in [ + "../secret", + "/etc/passwd", + ".git/config", + "src/../x", + "x\0y", + ] { + assert!(validate_commit_path(path).is_err(), "{path:?} was accepted"); + } +} \ No newline at end of file diff --git a/src/git/tree.rs b/src/git/tree.rs deleted file mode 100644 index a6e6366..0000000 --- a/src/git/tree.rs +++ /dev/null @@ -1,459 +0,0 @@ -//! Lazy, read-only directory listing for the file-tree navigator. -//! -//! Each call reads exactly one directory level (`std::fs::read_dir`); the -//! caller decides when to descend, so an unexpanded subtree is never walked. -//! Listing is filtered against `.gitignore` (via libgit2) and repository -//! metadata, and symlinks are reported as non-directories so the navigator -//! never follows one — this is what keeps the tree free of cycles without a -//! visited-set. - -use anyhow::{Context, Result}; -use git2::Repository; -use std::path::Path; - -/// One immediate child of a directory. `is_dir` is taken from the entry's own -/// file type (symlinks resolve to `false`), so a symlinked directory shows up -/// as a non-expandable row and is never descended into. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TreeEntry { - pub name: String, - pub is_dir: bool, -} - -/// 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. -/// -/// Filtering rules: -/// - `.git` is skipped at every level (repository metadata / object storage). -/// - When `respect_gitignore` is set, ignored paths are dropped via -/// `Repository::is_path_ignored`. -/// - Non-UTF-8 names are skipped: the file-view loader keys on `&str` paths and -/// cannot losslessly address them. -/// - Individual entries whose metadata cannot be read are skipped rather than -/// failing the whole listing. -pub fn read_children( - repo: &Repository, - workdir: &Path, - rel_dir: &str, - respect_gitignore: bool, -) -> Result> { - // Same gate as the file loader: plain relative components only, no `.git`, - // no symlink at any depth, and containment in the worktree. A stale session - // or a cached entry swapped for a symlink on disk both arrive here, and the - // web surfaces will route request strings straight into `rel_dir`. - let abs_dir = if rel_dir.is_empty() { - std::fs::canonicalize(workdir) - .with_context(|| format!("failed to resolve workdir {}", workdir.display()))? - } else { - crate::git::path::resolve_in_workdir(workdir, rel_dir)? - }; - let meta = - std::fs::symlink_metadata(&abs_dir).with_context(|| format!("failed to stat {rel_dir}"))?; - if !meta.file_type().is_dir() { - anyhow::bail!("not a directory: {rel_dir}"); - } - // Read the resolved path rather than re-joining `rel_dir`, so the directory - // that gets listed is the one that was validated. - let read = std::fs::read_dir(&abs_dir) - .with_context(|| format!("failed to read directory {rel_dir}"))?; - - let mut out = Vec::new(); - for entry in read { - let Ok(entry) = entry else { continue }; - // Non-UTF-8 names cannot be addressed by the `&str`-keyed file-view - // loader, so they are dropped from the tree entirely. - let Ok(name) = entry.file_name().into_string() else { - continue; - }; - // `.git` is repository metadata, not browsable project content. git - // does not list it in `.gitignore`, so it must be skipped explicitly. - // Shares the rule with the path validator: an exact `== ".git"` here - // would still list `.GIT` on a case-insensitive filesystem. - if crate::git::path::is_git_dir_name(&name) { - continue; - } - // `file_type()` does NOT follow symlinks, so a symlinked directory - // reports `is_dir() == false` and becomes a non-expandable row — the - // navigator therefore never descends a link and cannot cycle. - let Ok(file_type) = entry.file_type() else { - continue; - }; - let is_dir = file_type.is_dir(); - - let rel_path = if rel_dir.is_empty() { - name.clone() - } else { - format!("{rel_dir}/{name}") - }; - if respect_gitignore && repo.is_path_ignored(Path::new(&rel_path)).unwrap_or(false) { - continue; - } - out.push(TreeEntry { name, is_dir }); - } - - // Directories first (true sorts after false, so compare reversed), then - // case-sensitive alphabetical — stable, predictable ordering for keyboard - // navigation. - out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name))); - Ok(out) -} - -/// One hit from [`search_tree`]: the full repo-relative path and whether the -/// entry is a directory. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TreeMatch { - pub path: String, - pub is_dir: bool, -} - -/// Recursively search the worktree for entries whose basename contains `query` -/// (case-insensitive substring), mirroring the TUI's tree search -/// (`App::build_tree_index` + `recompute_filter`): a full walk from the root, -/// one [`read_children`] call per directory, depth-capped at `max_depth` and -/// gitignore-filtered. Symlinked directories report `is_dir == false` and so are -/// never descended (the same cycle guard that browsing relies on). -/// -/// Results are sorted by path for a stable listing and capped at `limit`. -/// `max_visits` bounds how many entries the walk may inspect: unlike the TUI's -/// single-user in-process index, this runs per web request, so the traversal — -/// not just the retained results — must be bounded against a pathological or -/// hostile tree. `truncated` is true when either budget cut the walk short or -/// more matches existed than were returned. An empty `query` matches every -/// entry, so callers gate on non-empty input. -pub fn search_tree( - repo: &Repository, - workdir: &Path, - query: &str, - max_depth: usize, - max_visits: usize, - limit: usize, -) -> Result<(Vec, bool)> { - let q = query.to_lowercase(); - let mut matches = Vec::new(); - let mut visited = 0usize; - // Set when a budget (visit count or result limit) cuts the walk short, so the - // caller can flag the listing as incomplete. - let mut budget_hit = false; - // (dir, depth-of-its-children): the root's children sit at depth 0, matching - // `App::build_tree_index`'s depth accounting. - let mut stack = vec![(String::new(), 0usize)]; - 'walk: while let Some((dir, depth)) = stack.pop() { - let children = match read_children(repo, workdir, &dir, true) { - Ok(children) => children, - // The root must be readable; a subdirectory that vanished mid-walk - // (or is otherwise unreadable) is skipped rather than failing the - // whole search. - Err(err) if dir.is_empty() => return Err(err), - Err(_) => continue, - }; - for entry in children { - // Every inspected entry counts against the visit budget, including - // non-matching ones — that is what bounds the filesystem work. - if visited >= max_visits { - budget_hit = true; - break 'walk; - } - visited += 1; - let path = if dir.is_empty() { - entry.name.clone() - } else { - format!("{dir}/{}", entry.name) - }; - if entry.name.to_lowercase().contains(&q) { - matches.push(TreeMatch { - path: path.clone(), - is_dir: entry.is_dir, - }); - } - // Descend only while the next level stays within max_depth, mirroring - // the expand guard in the TUI. - if entry.is_dir && depth < max_depth { - stack.push((path, depth + 1)); - } - } - } - matches.sort_by(|a, b| a.path.cmp(&b.path)); - let truncated = budget_hit || matches.len() > limit; - matches.truncate(limit); - Ok((matches, truncated)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_util::{make_repo, open_repo, run_git}; - use std::path::Path as StdPath; - - fn names(entries: &[TreeEntry]) -> Vec<&str> { - entries.iter().map(|e| e.name.as_str()).collect() - } - - #[test] - fn read_children_sorts_dirs_first_then_alpha() { - let (dir, path) = make_repo(); - let root = StdPath::new(&path); - std::fs::create_dir(root.join("zeta_dir")).unwrap(); - std::fs::create_dir(root.join("alpha_dir")).unwrap(); - std::fs::write(root.join("b_file.txt"), "x").unwrap(); - std::fs::write(root.join("a_file.txt"), "x").unwrap(); - - let workdir = open_repo(&path); - let entries = read_children(&workdir, root, "", true).unwrap(); - - assert_eq!( - names(&entries), - vec!["alpha_dir", "zeta_dir", "a_file.txt", "b_file.txt"] - ); - assert!(entries[0].is_dir); - assert!(entries[1].is_dir); - assert!(!entries[2].is_dir); - drop(dir); - } - - #[test] - fn read_children_reads_nested_dir_lazily() { - let (dir, path) = make_repo(); - let root = StdPath::new(&path); - std::fs::create_dir_all(root.join("src").join("ui")).unwrap(); - std::fs::write(root.join("src").join("main.rs"), "fn main() {}").unwrap(); - - let repo = open_repo(&path); - let entries = read_children(&repo, root, "src", true).unwrap(); - - assert_eq!(names(&entries), vec!["ui", "main.rs"]); - drop(dir); - } - - #[test] - fn read_children_skips_git_metadata() { - let (dir, path) = make_repo(); - let root = StdPath::new(&path); - std::fs::write(root.join("a.txt"), "x").unwrap(); - - let repo = open_repo(&path); - let entries = read_children(&repo, root, "", true).unwrap(); - - // `.git` exists on disk (make_repo runs `git init`) but must never be - // listed. - assert!(!names(&entries).contains(&".git")); - assert!(names(&entries).contains(&"a.txt")); - drop(dir); - } - - #[test] - fn read_children_respects_gitignore_when_enabled() { - let (dir, path) = make_repo(); - let root = StdPath::new(&path); - std::fs::write(root.join(".gitignore"), "ignored.log\nbuild/\n").unwrap(); - std::fs::write(root.join("ignored.log"), "x").unwrap(); - std::fs::write(root.join("kept.rs"), "x").unwrap(); - std::fs::create_dir(root.join("build")).unwrap(); - // Commit the gitignore so libgit2 picks it up reliably. - run_git(&path, &["add", ".gitignore"]); - run_git(&path, &["commit", "-m", "add gitignore"]); - - let repo = open_repo(&path); - let filtered = read_children(&repo, root, "", true).unwrap(); - assert!(!names(&filtered).contains(&"ignored.log")); - assert!(!names(&filtered).contains(&"build")); - assert!(names(&filtered).contains(&"kept.rs")); - - // With the toggle off, ignored paths reappear. - let unfiltered = read_children(&repo, root, "", false).unwrap(); - assert!(names(&unfiltered).contains(&"ignored.log")); - assert!(names(&unfiltered).contains(&"build")); - drop(dir); - } - - #[cfg(unix)] - #[test] - fn read_children_refuses_to_list_the_git_directory() { - // Skipping `.git` from child listings is not enough: asking for it as - // the directory itself enumerated refs, hooks, and object layout. - let (dir, path) = make_repo(); - let root = StdPath::new(&path); - let repo = open_repo(&path); - - for asked in [".git", ".git/refs", ".GIT", "src/../.git"] { - let err = read_children(&repo, root, asked, false).unwrap_err(); - assert!( - !err.to_string().contains("failed to read directory"), - "{asked:?} must be refused by validation, not by chance: {err}" - ); - } - drop(dir); - } - - #[test] - fn read_children_refuses_traversal_that_stays_inside_the_worktree() { - // Containment alone accepts this — it resolves back inside the repo. - let (dir, path) = make_repo(); - let root = StdPath::new(&path); - std::fs::create_dir(root.join("src")).unwrap(); - let repo = open_repo(&path); - - let err = read_children(&repo, root, "src/..", false).unwrap_err(); - - assert!( - err.to_string().contains("plain relative path"), - "unexpected error: {err}" - ); - drop(dir); - } - - #[test] - fn read_children_refuses_to_descend_a_symlinked_directory() { - let (dir, path) = make_repo(); - let root = StdPath::new(&path); - std::fs::create_dir(root.join("real_dir")).unwrap(); - std::fs::write(root.join("real_dir").join("secret.txt"), "x").unwrap(); - std::os::unix::fs::symlink(root.join("real_dir"), root.join("link_dir")).unwrap(); - - let repo = open_repo(&path); - // Expanding the symlink path directly (as a stale session or swapped - // cache entry could ask to) must be rejected, not followed. - let err = read_children(&repo, root, "link_dir", false).unwrap_err(); - assert!( - err.to_string().contains("symlinks are not followed"), - "unexpected error: {err}" - ); - // The real directory still reads normally. - assert!(read_children(&repo, root, "real_dir", false).is_ok()); - drop(dir); - } - - #[cfg(unix)] - #[test] - fn read_children_rejects_escape_through_symlinked_parent() { - let (dir, path) = make_repo(); - let root = StdPath::new(&path); - // An external tree outside the repo, reachable via a symlinked parent. - let outside = tempfile::TempDir::new().unwrap(); - std::fs::create_dir(outside.path().join("sub")).unwrap(); - std::fs::write(outside.path().join("sub").join("secret.txt"), "x").unwrap(); - std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap(); - - let repo = open_repo(&path); - // `link` is a symlink (intermediate component); `link/sub` must be - // rejected at the link, before the path is ever resolved outside. - let err = read_children(&repo, root, "link/sub", false).unwrap_err(); - assert!( - err.to_string().contains("symlinks are not followed"), - "unexpected error: {err}" - ); - drop(dir); - } - - fn paths(matches: &[TreeMatch]) -> Vec<&str> { - matches.iter().map(|m| m.path.as_str()).collect() - } - - #[test] - fn search_tree_finds_nested_matches_by_basename() { - let (dir, path) = make_repo(); - let root = StdPath::new(&path); - std::fs::create_dir_all(root.join("src").join("ui")).unwrap(); - std::fs::write(root.join("src").join("main.rs"), "x").unwrap(); - std::fs::write(root.join("src").join("ui").join("tree_view.rs"), "x").unwrap(); - std::fs::write(root.join("README.md"), "x").unwrap(); - - let repo = open_repo(&path); - // Case-insensitive substring on the basename, matched recursively; sorted - // by full path. - let (matches, truncated) = search_tree(&repo, root, "RS", 64, 10_000, 100).unwrap(); - assert_eq!(paths(&matches), vec!["src/main.rs", "src/ui/tree_view.rs"]); - assert!(!truncated); - drop(dir); - } - - #[test] - fn search_tree_excludes_gitignored_and_git_metadata() { - let (dir, path) = make_repo(); - let root = StdPath::new(&path); - std::fs::write(root.join(".gitignore"), "target/\n").unwrap(); - std::fs::create_dir(root.join("target")).unwrap(); - std::fs::write(root.join("target").join("build.rs"), "x").unwrap(); - std::fs::write(root.join("keep.rs"), "x").unwrap(); - run_git(&path, &["add", ".gitignore"]); - run_git(&path, &["commit", "-m", "add gitignore"]); - - let repo = open_repo(&path); - let (matches, _) = search_tree(&repo, root, ".rs", 64, 10_000, 100).unwrap(); - assert_eq!(paths(&matches), vec!["keep.rs"]); - drop(dir); - } - - #[test] - fn search_tree_stops_descending_beyond_max_depth() { - let (dir, path) = make_repo(); - let root = StdPath::new(&path); - std::fs::create_dir_all(root.join("a").join("b")).unwrap(); - std::fs::write(root.join("a").join("shallow.txt"), "x").unwrap(); - std::fs::write(root.join("a").join("b").join("deep.txt"), "x").unwrap(); - - let repo = open_repo(&path); - // depth 0 = root's children ("a"); depth 1 = "a"'s children ("b", - // "shallow.txt"). With max_depth 1, "b"'s children are never read. - let (matches, _) = search_tree(&repo, root, ".txt", 1, 10_000, 100).unwrap(); - assert_eq!(paths(&matches), vec!["a/shallow.txt"]); - drop(dir); - } - - #[test] - fn search_tree_caps_results_and_flags_truncation() { - let (dir, path) = make_repo(); - let root = StdPath::new(&path); - for i in 0..5 { - std::fs::write(root.join(format!("hit_{i}.txt")), "x").unwrap(); - } - - let repo = open_repo(&path); - let (matches, truncated) = search_tree(&repo, root, "hit_", 64, 10_000, 3).unwrap(); - assert_eq!(matches.len(), 3); - assert!(truncated); - // Deterministic prefix: sorted then truncated. - assert_eq!(paths(&matches), vec!["hit_0.txt", "hit_1.txt", "hit_2.txt"]); - drop(dir); - } - - #[test] - fn search_tree_stops_when_the_visit_budget_is_exhausted() { - let (dir, path) = make_repo(); - let root = StdPath::new(&path); - for i in 0..20 { - std::fs::write(root.join(format!("f_{i:02}.txt")), "x").unwrap(); - } - - let repo = open_repo(&path); - // A tight visit budget cuts the walk short before every entry is seen, so - // the result is flagged incomplete even though it is under the result cap. - let (matches, truncated) = search_tree(&repo, root, ".txt", 64, 5, 100).unwrap(); - assert!(matches.len() <= 5, "got {} matches", matches.len()); - assert!(truncated); - drop(dir); - } - - #[cfg(unix)] - #[test] - fn read_children_reports_symlinked_dir_as_non_dir() { - let (dir, path) = make_repo(); - let root = StdPath::new(&path); - std::fs::create_dir(root.join("real_dir")).unwrap(); - std::os::unix::fs::symlink(root.join("real_dir"), root.join("link_dir")).unwrap(); - - let repo = open_repo(&path); - let entries = read_children(&repo, root, "", false).unwrap(); - - let link = entries - .iter() - .find(|e| e.name == "link_dir") - .expect("symlink should be listed"); - // A symlinked directory must report `is_dir == false` so the navigator - // treats it as a leaf and never follows it (cycle guard). - assert!(!link.is_dir); - let real = entries.iter().find(|e| e.name == "real_dir").unwrap(); - assert!(real.is_dir); - drop(dir); - } -} diff --git a/src/git/tree/mod.rs b/src/git/tree/mod.rs new file mode 100644 index 0000000..7efc736 --- /dev/null +++ b/src/git/tree/mod.rs @@ -0,0 +1,184 @@ +//! Lazy, read-only directory listing for the file-tree navigator. +//! +//! Each call reads exactly one directory level (`std::fs::read_dir`); the +//! caller decides when to descend, so an unexpanded subtree is never walked. +//! Listing is filtered against `.gitignore` (via libgit2) and repository +//! metadata, and symlinks are reported as non-directories so the navigator +//! never follows one — this is what keeps the tree free of cycles without a +//! visited-set. + +use anyhow::{Context, Result}; +use git2::Repository; +use std::path::Path; + +/// One immediate child of a directory. `is_dir` is taken from the entry's own +/// file type (symlinks resolve to `false`), so a symlinked directory shows up +/// as a non-expandable row and is never descended into. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TreeEntry { + pub name: String, + pub is_dir: bool, +} + +/// 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. +/// +/// Filtering rules: +/// - `.git` is skipped at every level (repository metadata / object storage). +/// - When `respect_gitignore` is set, ignored paths are dropped via +/// `Repository::is_path_ignored`. +/// - Non-UTF-8 names are skipped: the file-view loader keys on `&str` paths and +/// cannot losslessly address them. +/// - Individual entries whose metadata cannot be read are skipped rather than +/// failing the whole listing. +pub fn read_children( + repo: &Repository, + workdir: &Path, + rel_dir: &str, + respect_gitignore: bool, +) -> Result> { + // Same gate as the file loader: plain relative components only, no `.git`, + // no symlink at any depth, and containment in the worktree. A stale session + // or a cached entry swapped for a symlink on disk both arrive here, and the + // web surfaces will route request strings straight into `rel_dir`. + let abs_dir = if rel_dir.is_empty() { + std::fs::canonicalize(workdir) + .with_context(|| format!("failed to resolve workdir {}", workdir.display()))? + } else { + crate::git::path::resolve_in_workdir(workdir, rel_dir)? + }; + let meta = + std::fs::symlink_metadata(&abs_dir).with_context(|| format!("failed to stat {rel_dir}"))?; + if !meta.file_type().is_dir() { + anyhow::bail!("not a directory: {rel_dir}"); + } + // Read the resolved path rather than re-joining `rel_dir`, so the directory + // that gets listed is the one that was validated. + let read = std::fs::read_dir(&abs_dir) + .with_context(|| format!("failed to read directory {rel_dir}"))?; + + let mut out = Vec::new(); + for entry in read { + let Ok(entry) = entry else { continue }; + // Non-UTF-8 names cannot be addressed by the `&str`-keyed file-view + // loader, so they are dropped from the tree entirely. + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + // `.git` is repository metadata, not browsable project content. git + // does not list it in `.gitignore`, so it must be skipped explicitly. + // Shares the rule with the path validator: an exact `== ".git"` here + // would still list `.GIT` on a case-insensitive filesystem. + if crate::git::path::is_git_dir_name(&name) { + continue; + } + // `file_type()` does NOT follow symlinks, so a symlinked directory + // reports `is_dir() == false` and becomes a non-expandable row — the + // navigator therefore never descends a link and cannot cycle. + let Ok(file_type) = entry.file_type() else { + continue; + }; + let is_dir = file_type.is_dir(); + + let rel_path = if rel_dir.is_empty() { + name.clone() + } else { + format!("{rel_dir}/{name}") + }; + if respect_gitignore && repo.is_path_ignored(Path::new(&rel_path)).unwrap_or(false) { + continue; + } + out.push(TreeEntry { name, is_dir }); + } + + // Directories first (true sorts after false, so compare reversed), then + // case-sensitive alphabetical — stable, predictable ordering for keyboard + // navigation. + out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name))); + Ok(out) +} + +/// One hit from [`search_tree`]: the full repo-relative path and whether the +/// entry is a directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TreeMatch { + pub path: String, + pub is_dir: bool, +} + +/// Recursively search the worktree for entries whose basename contains `query` +/// (case-insensitive substring), mirroring the TUI's tree search +/// (`App::build_tree_index` + `recompute_filter`): a full walk from the root, +/// one [`read_children`] call per directory, depth-capped at `max_depth` and +/// gitignore-filtered. Symlinked directories report `is_dir == false` and so are +/// never descended (the same cycle guard that browsing relies on). +/// +/// Results are sorted by path for a stable listing and capped at `limit`. +/// `max_visits` bounds how many entries the walk may inspect: unlike the TUI's +/// single-user in-process index, this runs per web request, so the traversal — +/// not just the retained results — must be bounded against a pathological or +/// hostile tree. `truncated` is true when either budget cut the walk short or +/// more matches existed than were returned. An empty `query` matches every +/// entry, so callers gate on non-empty input. +pub fn search_tree( + repo: &Repository, + workdir: &Path, + query: &str, + max_depth: usize, + max_visits: usize, + limit: usize, +) -> Result<(Vec, bool)> { + let q = query.to_lowercase(); + let mut matches = Vec::new(); + let mut visited = 0usize; + // Set when a budget (visit count or result limit) cuts the walk short, so the + // caller can flag the listing as incomplete. + let mut budget_hit = false; + // (dir, depth-of-its-children): the root's children sit at depth 0, matching + // `App::build_tree_index`'s depth accounting. + let mut stack = vec![(String::new(), 0usize)]; + 'walk: while let Some((dir, depth)) = stack.pop() { + let children = match read_children(repo, workdir, &dir, true) { + Ok(children) => children, + // The root must be readable; a subdirectory that vanished mid-walk + // (or is otherwise unreadable) is skipped rather than failing the + // whole search. + Err(err) if dir.is_empty() => return Err(err), + Err(_) => continue, + }; + for entry in children { + // Every inspected entry counts against the visit budget, including + // non-matching ones — that is what bounds the filesystem work. + if visited >= max_visits { + budget_hit = true; + break 'walk; + } + visited += 1; + let path = if dir.is_empty() { + entry.name.clone() + } else { + format!("{dir}/{}", entry.name) + }; + if entry.name.to_lowercase().contains(&q) { + matches.push(TreeMatch { + path: path.clone(), + is_dir: entry.is_dir, + }); + } + // Descend only while the next level stays within max_depth, mirroring + // the expand guard in the TUI. + if entry.is_dir && depth < max_depth { + stack.push((path, depth + 1)); + } + } + } + matches.sort_by(|a, b| a.path.cmp(&b.path)); + let truncated = budget_hit || matches.len() > limit; + matches.truncate(limit); + Ok((matches, truncated)) +} + +#[cfg(test)] +mod tests; + diff --git a/src/git/tree/tests.rs b/src/git/tree/tests.rs new file mode 100644 index 0000000..5c52b5d --- /dev/null +++ b/src/git/tree/tests.rs @@ -0,0 +1,275 @@ +use super::*; +use crate::test_util::{make_repo, open_repo, run_git}; +use std::path::Path as StdPath; + +fn names(entries: &[TreeEntry]) -> Vec<&str> { + entries.iter().map(|e| e.name.as_str()).collect() +} + +#[test] +fn read_children_sorts_dirs_first_then_alpha() { + let (dir, path) = make_repo(); + let root = StdPath::new(&path); + std::fs::create_dir(root.join("zeta_dir")).unwrap(); + std::fs::create_dir(root.join("alpha_dir")).unwrap(); + std::fs::write(root.join("b_file.txt"), "x").unwrap(); + std::fs::write(root.join("a_file.txt"), "x").unwrap(); + + let workdir = open_repo(&path); + let entries = read_children(&workdir, root, "", true).unwrap(); + + assert_eq!( + names(&entries), + vec!["alpha_dir", "zeta_dir", "a_file.txt", "b_file.txt"] + ); + assert!(entries[0].is_dir); + assert!(entries[1].is_dir); + assert!(!entries[2].is_dir); + drop(dir); +} + +#[test] +fn read_children_reads_nested_dir_lazily() { + let (dir, path) = make_repo(); + let root = StdPath::new(&path); + std::fs::create_dir_all(root.join("src").join("ui")).unwrap(); + std::fs::write(root.join("src").join("main.rs"), "fn main() {}").unwrap(); + + let repo = open_repo(&path); + let entries = read_children(&repo, root, "src", true).unwrap(); + + assert_eq!(names(&entries), vec!["ui", "main.rs"]); + drop(dir); +} + +#[test] +fn read_children_skips_git_metadata() { + let (dir, path) = make_repo(); + let root = StdPath::new(&path); + std::fs::write(root.join("a.txt"), "x").unwrap(); + + let repo = open_repo(&path); + let entries = read_children(&repo, root, "", true).unwrap(); + + // `.git` exists on disk (make_repo runs `git init`) but must never be + // listed. + assert!(!names(&entries).contains(&".git")); + assert!(names(&entries).contains(&"a.txt")); + drop(dir); +} + +#[test] +fn read_children_respects_gitignore_when_enabled() { + let (dir, path) = make_repo(); + let root = StdPath::new(&path); + std::fs::write(root.join(".gitignore"), "ignored.log\nbuild/\n").unwrap(); + std::fs::write(root.join("ignored.log"), "x").unwrap(); + std::fs::write(root.join("kept.rs"), "x").unwrap(); + std::fs::create_dir(root.join("build")).unwrap(); + // Commit the gitignore so libgit2 picks it up reliably. + run_git(&path, &["add", ".gitignore"]); + run_git(&path, &["commit", "-m", "add gitignore"]); + + let repo = open_repo(&path); + let filtered = read_children(&repo, root, "", true).unwrap(); + assert!(!names(&filtered).contains(&"ignored.log")); + assert!(!names(&filtered).contains(&"build")); + assert!(names(&filtered).contains(&"kept.rs")); + + // With the toggle off, ignored paths reappear. + let unfiltered = read_children(&repo, root, "", false).unwrap(); + assert!(names(&unfiltered).contains(&"ignored.log")); + assert!(names(&unfiltered).contains(&"build")); + drop(dir); +} + +#[cfg(unix)] +#[test] +fn read_children_refuses_to_list_the_git_directory() { + // Skipping `.git` from child listings is not enough: asking for it as + // the directory itself enumerated refs, hooks, and object layout. + let (dir, path) = make_repo(); + let root = StdPath::new(&path); + let repo = open_repo(&path); + + for asked in [".git", ".git/refs", ".GIT", "src/../.git"] { + let err = read_children(&repo, root, asked, false).unwrap_err(); + assert!( + !err.to_string().contains("failed to read directory"), + "{asked:?} must be refused by validation, not by chance: {err}" + ); + } + drop(dir); +} + +#[test] +fn read_children_refuses_traversal_that_stays_inside_the_worktree() { + // Containment alone accepts this — it resolves back inside the repo. + let (dir, path) = make_repo(); + let root = StdPath::new(&path); + std::fs::create_dir(root.join("src")).unwrap(); + let repo = open_repo(&path); + + let err = read_children(&repo, root, "src/..", false).unwrap_err(); + + assert!( + err.to_string().contains("plain relative path"), + "unexpected error: {err}" + ); + drop(dir); +} + +#[test] +fn read_children_refuses_to_descend_a_symlinked_directory() { + let (dir, path) = make_repo(); + let root = StdPath::new(&path); + std::fs::create_dir(root.join("real_dir")).unwrap(); + std::fs::write(root.join("real_dir").join("secret.txt"), "x").unwrap(); + std::os::unix::fs::symlink(root.join("real_dir"), root.join("link_dir")).unwrap(); + + let repo = open_repo(&path); + // Expanding the symlink path directly (as a stale session or swapped + // cache entry could ask to) must be rejected, not followed. + let err = read_children(&repo, root, "link_dir", false).unwrap_err(); + assert!( + err.to_string().contains("symlinks are not followed"), + "unexpected error: {err}" + ); + // The real directory still reads normally. + assert!(read_children(&repo, root, "real_dir", false).is_ok()); + drop(dir); +} + +#[cfg(unix)] +#[test] +fn read_children_rejects_escape_through_symlinked_parent() { + let (dir, path) = make_repo(); + let root = StdPath::new(&path); + // An external tree outside the repo, reachable via a symlinked parent. + let outside = tempfile::TempDir::new().unwrap(); + std::fs::create_dir(outside.path().join("sub")).unwrap(); + std::fs::write(outside.path().join("sub").join("secret.txt"), "x").unwrap(); + std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap(); + + let repo = open_repo(&path); + // `link` is a symlink (intermediate component); `link/sub` must be + // rejected at the link, before the path is ever resolved outside. + let err = read_children(&repo, root, "link/sub", false).unwrap_err(); + assert!( + err.to_string().contains("symlinks are not followed"), + "unexpected error: {err}" + ); + drop(dir); +} + +fn paths(matches: &[TreeMatch]) -> Vec<&str> { + matches.iter().map(|m| m.path.as_str()).collect() +} + +#[test] +fn search_tree_finds_nested_matches_by_basename() { + let (dir, path) = make_repo(); + let root = StdPath::new(&path); + std::fs::create_dir_all(root.join("src").join("ui")).unwrap(); + std::fs::write(root.join("src").join("main.rs"), "x").unwrap(); + std::fs::write(root.join("src").join("ui").join("tree_view.rs"), "x").unwrap(); + std::fs::write(root.join("README.md"), "x").unwrap(); + + let repo = open_repo(&path); + // Case-insensitive substring on the basename, matched recursively; sorted + // by full path. + let (matches, truncated) = search_tree(&repo, root, "RS", 64, 10_000, 100).unwrap(); + assert_eq!(paths(&matches), vec!["src/main.rs", "src/ui/tree_view.rs"]); + assert!(!truncated); + drop(dir); +} + +#[test] +fn search_tree_excludes_gitignored_and_git_metadata() { + let (dir, path) = make_repo(); + let root = StdPath::new(&path); + std::fs::write(root.join(".gitignore"), "target/\n").unwrap(); + std::fs::create_dir(root.join("target")).unwrap(); + std::fs::write(root.join("target").join("build.rs"), "x").unwrap(); + std::fs::write(root.join("keep.rs"), "x").unwrap(); + run_git(&path, &["add", ".gitignore"]); + run_git(&path, &["commit", "-m", "add gitignore"]); + + let repo = open_repo(&path); + let (matches, _) = search_tree(&repo, root, ".rs", 64, 10_000, 100).unwrap(); + assert_eq!(paths(&matches), vec!["keep.rs"]); + drop(dir); +} + +#[test] +fn search_tree_stops_descending_beyond_max_depth() { + let (dir, path) = make_repo(); + let root = StdPath::new(&path); + std::fs::create_dir_all(root.join("a").join("b")).unwrap(); + std::fs::write(root.join("a").join("shallow.txt"), "x").unwrap(); + std::fs::write(root.join("a").join("b").join("deep.txt"), "x").unwrap(); + + let repo = open_repo(&path); + // depth 0 = root's children ("a"); depth 1 = "a"'s children ("b", + // "shallow.txt"). With max_depth 1, "b"'s children are never read. + let (matches, _) = search_tree(&repo, root, ".txt", 1, 10_000, 100).unwrap(); + assert_eq!(paths(&matches), vec!["a/shallow.txt"]); + drop(dir); +} + +#[test] +fn search_tree_caps_results_and_flags_truncation() { + let (dir, path) = make_repo(); + let root = StdPath::new(&path); + for i in 0..5 { + std::fs::write(root.join(format!("hit_{i}.txt")), "x").unwrap(); + } + + let repo = open_repo(&path); + let (matches, truncated) = search_tree(&repo, root, "hit_", 64, 10_000, 3).unwrap(); + assert_eq!(matches.len(), 3); + assert!(truncated); + // Deterministic prefix: sorted then truncated. + assert_eq!(paths(&matches), vec!["hit_0.txt", "hit_1.txt", "hit_2.txt"]); + drop(dir); +} + +#[test] +fn search_tree_stops_when_the_visit_budget_is_exhausted() { + let (dir, path) = make_repo(); + let root = StdPath::new(&path); + for i in 0..20 { + std::fs::write(root.join(format!("f_{i:02}.txt")), "x").unwrap(); + } + + let repo = open_repo(&path); + // A tight visit budget cuts the walk short before every entry is seen, so + // the result is flagged incomplete even though it is under the result cap. + let (matches, truncated) = search_tree(&repo, root, ".txt", 64, 5, 100).unwrap(); + assert!(matches.len() <= 5, "got {} matches", matches.len()); + assert!(truncated); + drop(dir); +} + +#[cfg(unix)] +#[test] +fn read_children_reports_symlinked_dir_as_non_dir() { + let (dir, path) = make_repo(); + let root = StdPath::new(&path); + std::fs::create_dir(root.join("real_dir")).unwrap(); + std::os::unix::fs::symlink(root.join("real_dir"), root.join("link_dir")).unwrap(); + + let repo = open_repo(&path); + let entries = read_children(&repo, root, "", false).unwrap(); + + let link = entries + .iter() + .find(|e| e.name == "link_dir") + .expect("symlink should be listed"); + // A symlinked directory must report `is_dir == false` so the navigator + // treats it as a leaf and never follows it (cycle guard). + assert!(!link.is_dir); + let real = entries.iter().find(|e| e.name == "real_dir").unwrap(); + assert!(real.is_dir); + drop(dir); +} \ No newline at end of file diff --git a/src/key_dispatch.rs b/src/key_dispatch.rs new file mode 100644 index 0000000..951f982 --- /dev/null +++ b/src/key_dispatch.rs @@ -0,0 +1,300 @@ +use crate::app::{App, Focus}; +use crate::input::{Action, encode_key, map_key, prefix_action, prefix_action_fullscreen}; +use crate::key_handlers::{ + handle_empty_key, handle_repo_input_key, handle_terminal_key, handle_upper_key, +}; +use crate::workspace::Workspace; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum KeyOutcome { + Continue, + /// Force a full repaint on the next frame. Used by the ` r` redraw + /// chord to wipe stray glyphs left behind when a PTY child writes cells + /// ratatui's diff renderer doesn't track. + Redraw, + Quit, + /// The key asked for something only the workspace can do. The handlers + /// take `&mut App` — one project — so they cannot reach the tab list; + /// they name the intent here and `main_loop` carries it out. + Project(ProjectRequest), +} + +/// A workspace-level action requested by a key or click. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ProjectRequest { + /// Focus the tab at this index. Out-of-range indices are inert. + Switch(usize), + /// Close the active tab. Refused when it is the only one. + Close, + /// Open this resolved repo path as a tab, or focus the tab already on it. + Open(String), + /// Raise the open-repo dialog. It lives on the workspace, so a handler + /// holding one project cannot open it directly. + OpenDialog, +} + +/// Everything a project needs beyond its repo path. +/// +/// Threaded to the input handlers rather than stored on `Workspace` so the +/// workspace stays a pure state container: opening a tab is the only thing +/// that needs the config, and it borrows it for the duration of one keypress. +pub(crate) struct ProjectContext<'a> { + pub(crate) cfg: &'a crate::config::Config, + pub(crate) startup_commands: &'a [crate::config::StartupCommand], + pub(crate) leader: KeyEvent, +} + +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. + 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. + if app.search_overlay_active() + || app.prefix_armed() + || app.awaiting_swap_target() + || app.is_leader_key(key) + || app.focus != Focus::Terminal + { + app.dismiss_notice_on_app_input(); + } + + // 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. + 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. + app.cancel_prefix(); + app.cancel_swap_target(); + // 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. + if app.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. + if app.prefix_armed() { + return handle_prefix_followup(app, key); + } + + // The leader chord arms the prefix; nothing else happens this tick. + if app.is_leader_key(key) { + app.arm_prefix(); + return KeyOutcome::Continue; + } + + let action = map_key(key); + if let Some(outcome) = handle_global_action(app, action) { + return outcome; + } + + match app.focus { + Focus::Terminal => handle_terminal_key(app, key, action), + Focus::FileList | Focus::DiffViewer => handle_upper_key(app, key, action), + } + KeyOutcome::Continue +} + +/// Resolve the single key pressed while the prefix is armed. The prefix is +/// always disarmed before returning (tmux-style: one follow-up per leader). +fn handle_prefix_followup(app: &mut App, key: KeyEvent) -> KeyOutcome { + app.cancel_prefix(); + // ` `: send the leader chord literally to the focused PTY so the + // running program still sees the prefix key when the user means it. This + // is resolved before the Esc/Ctrl+C cancel below so that a `ctrl+c` leader + // can still deliver a literal Ctrl+C via `` (Esc remains a + // universal cancel regardless of the configured leader). + if app.is_leader_key(key) { + if app.focus == Focus::Terminal + && let Some(data) = encode_key(app.leader) + { + app.terminal.send_input(&data); + } + return KeyOutcome::Continue; + } + + // Esc / Ctrl+C cancel the prefix without acting. The follow-up key is + // consumed (not forwarded) so the cancel never leaks into the PTY. + let is_ctrl_c = key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL); + if key.code == KeyCode::Esc || is_ctrl_c { + return KeyOutcome::Continue; + } + + // A mapped follow-up runs its app command everywhere (terminal + upper). + let action = resolve_prefix_action(app, key); + if let Some(outcome) = handle_global_action(app, action) { + return outcome; + } + // Unmapped follow-up: consume and drop it, then return to pass-through. + KeyOutcome::Continue +} + +/// Resolve the key pressed while swap-target mode is armed (` s`). The +/// mode is always disarmed before returning. A digit that names a pane runs the +/// swap; `Esc`/`Ctrl+C` cancels; any other key is consumed. The digit→pane +/// mapping is reused from `prefix_action` so it matches the focus-jump digits +/// one-for-one (`3`..`9`,`0` → panes `0`..`7`). +fn handle_swap_target_followup(app: &mut App, key: KeyEvent) -> KeyOutcome { + app.cancel_swap_target(); + + let is_ctrl_c = key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL); + if key.code == KeyCode::Esc || is_ctrl_c { + return KeyOutcome::Continue; + } + if let Action::SwitchPane(idx) = resolve_prefix_action(app, key) { + app.swap_active_pane_with(idx); + } + KeyOutcome::Continue +} + +/// Pick the leader follow-up mapping for the current layout. While the terminal +/// fills the body the upper viewer is hidden, so `prefix_action_fullscreen` +/// repurposes the digit row `1`..`8` onto panes `0`..`7`; otherwise the normal +/// split-view mapping applies (`1`=list, `2`=diff, `3`..`0`=panes). Shared by +/// the focus-jump and swap-target follow-ups so both stay in lockstep. +fn resolve_prefix_action(app: &App, key: KeyEvent) -> Action { + if app.terminal.fullscreen.fills_body() { + prefix_action_fullscreen(key) + } else { + prefix_action(key) + } +} + +fn handle_global_action(app: &mut App, action: Action) -> Option { + match action { + Action::Quit => Some(KeyOutcome::Quit), + Action::NewPane => { + app.open_new_pane(); + Some(KeyOutcome::Continue) + } + Action::ClosePane => { + // 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. + if app.can_close_pane() { + app.close_active_pane(); + } + Some(KeyOutcome::Continue) + } + // Opening is two steps: this only raises the dialog, and confirming it + // emits the `Open` request (see `handle_repo_input_key`). + Action::OpenProject => Some(KeyOutcome::Project(ProjectRequest::OpenDialog)), + Action::CloseProject => Some(KeyOutcome::Project(ProjectRequest::Close)), + Action::SwitchProject(idx) => Some(KeyOutcome::Project(ProjectRequest::Switch(idx))), + Action::ToggleFullscreen => { + match app.focus { + Focus::DiffViewer => app.toggle_diff_fullscreen(), + Focus::FileList => app.toggle_list_fullscreen(), + Focus::Terminal => app.toggle_terminal_fullscreen(), + } + Some(KeyOutcome::Continue) + } + Action::ToggleLogView => { + app.toggle_mode(); + Some(KeyOutcome::Continue) + } + Action::ToggleTreeView => { + app.toggle_tree_mode(); + Some(KeyOutcome::Continue) + } + Action::CycleTheme => { + app.cycle_accent(); + Some(KeyOutcome::Continue) + } + Action::Redraw => Some(KeyOutcome::Redraw), + Action::SwitchPane(n) => { + app.switch_pane(n); + Some(KeyOutcome::Continue) + } + Action::SwapPanePrompt => { + // Scoped by `can_swap_panes` (terminal focus plus a second pane). + // The key is still consumed either way. + if app.can_swap_panes() { + app.begin_swap_target(); + } + Some(KeyOutcome::Continue) + } + Action::FocusList => { + app.focus_list(); + Some(KeyOutcome::Continue) + } + Action::FocusDiff => { + app.focus_diff(); + Some(KeyOutcome::Continue) + } + Action::CycleForward => { + app.cycle_focus_forward(); + Some(KeyOutcome::Continue) + } + Action::CycleBackward => { + app.cycle_focus_backward(); + Some(KeyOutcome::Continue) + } + _ => None, + } +} + +pub(crate) fn has_command_modifier(key: KeyEvent) -> bool { + key.modifiers.intersects( + KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META, + ) +} + +pub(crate) fn text_input_char(key: KeyEvent) -> Option { + if has_command_modifier(key) { + return None; + } + match key.code { + KeyCode::Char(c) if !c.is_control() => Some(c), + _ => None, + } +} + +pub(crate) fn matches_text_command(key: KeyEvent, expected: char) -> bool { + !has_command_modifier(key) && matches!(key.code, KeyCode::Char(c) if c == expected) +} + +/// Route one key, resolving the workspace-level cases first. +/// +/// The open dialog and the empty screen both belong to the workspace, and +/// `handle_key` holds a single project, so neither can be dispatched from +/// inside it. Resolving them here keeps `handle_key` — and every test that +/// drives it with one `App` — working on exactly one project. +pub(crate) fn dispatch_key(ws: &mut Workspace, key: KeyEvent) -> KeyOutcome { + if key.kind != KeyEventKind::Press { + return KeyOutcome::Continue; + } + if ws.repo_input.active { + return handle_repo_input_key(ws, key); + } + match ws.active_mut() { + Some(app) => handle_key(app, key), + None => handle_empty_key(ws, key), + } +} \ No newline at end of file diff --git a/src/key_handlers.rs b/src/key_handlers.rs new file mode 100644 index 0000000..4f59750 --- /dev/null +++ b/src/key_handlers.rs @@ -0,0 +1,288 @@ +use crate::app::{App, DiffPaneView, Focus, ViewMode}; +use crate::input::{Action, encode_key, prefix_action, vim_navigation_action}; +use crate::key_dispatch::{ + KeyOutcome, ProjectRequest, matches_text_command, text_input_char, +}; +use crate::runtime::terminal::SCROLL_LINE_STEP; +use crate::workspace::Workspace; +use crossterm::event::{KeyCode, KeyEvent}; + +/// Keys on the empty screen: the leader arms, `o` opens the dialog, `q` +/// quits. Everything else is dropped — there is no project to act on and no +/// PTY to forward to. +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. + if ws.is_leader_key(key) { + return KeyOutcome::Continue; + } + return match prefix_action(key) { + Action::OpenProject => KeyOutcome::Project(ProjectRequest::OpenDialog), + Action::Quit => KeyOutcome::Quit, + _ => KeyOutcome::Continue, + }; + } + if ws.is_leader_key(key) { + ws.arm_prefix(); + } + KeyOutcome::Continue +} + +pub(crate) fn handle_repo_input_key(ws: &mut Workspace, key: KeyEvent) -> KeyOutcome { + match key.code { + KeyCode::Esc => ws.cancel_repo_input(), + KeyCode::Enter => { + if let crate::workspace::RepoInputResult::Open(path) = ws.confirm_repo_input() { + return KeyOutcome::Project(ProjectRequest::Open(path)); + } + } + KeyCode::Backspace => { + if ws.repo_input.buf.is_empty() { + ws.cancel_repo_input(); + } else { + ws.repo_input_pop(); + } + } + // The caret is always at the end of the buffer, so these can't move + // it; they mean "keep this path and let me extend it". + KeyCode::Right | KeyCode::End => ws.repo_input_accept_prefill(), + _ => { + if let Some(c) = text_input_char(key) { + ws.repo_input_push(c); + } + } + } + KeyOutcome::Continue +} + +pub(crate) fn handle_terminal_key(app: &mut App, key: KeyEvent, action: Action) { + match action { + Action::TermScrollUp => { + let lines = app.terminal.active_pane_rows(); + app.terminal.scroll_active(true, lines); + } + Action::TermScrollDown => { + let lines = app.terminal.active_pane_rows(); + app.terminal.scroll_active(false, lines); + } + Action::TermScrollLineUp => app.terminal.scroll_active(true, SCROLL_LINE_STEP), + Action::TermScrollLineDown => app.terminal.scroll_active(false, SCROLL_LINE_STEP), + _ => { + if let Some(data) = encode_key(key) { + app.terminal.send_input(&data); + } + } + } +} + +pub(crate) fn handle_upper_key(app: &mut App, key: KeyEvent, action: Action) { + if app.focus == Focus::FileList && app.status_view.search_active { + handle_file_search_key(app, key); + return; + } + if app.focus == Focus::FileList && app.tree_view.search_active { + handle_tree_search_key(app, key); + return; + } + if app.focus == Focus::FileList + && (app.log_view.commit_search_active || app.log_view.file_search_active) + { + handle_log_search_key(app, key); + return; + } + if app.focus == Focus::DiffViewer && app.diff.search.active { + handle_diff_search_key(app, key); + return; + } + + // Apply vim-style j/k navigation only in upper panes; terminal focus is + // routed through handle_terminal_key so j/k reach the PTY untouched. + let action = vim_navigation_action(key).unwrap_or(action); + + match action { + Action::Up => app.select_up(), + Action::Down => app.select_down(), + Action::PageUp => app.page_up(), + Action::PageDown => app.page_down(), + Action::TermScrollUp + | Action::TermScrollDown + | Action::TermScrollLineUp + | Action::TermScrollLineDown => {} + Action::None => handle_unmapped_upper_key(app, key), + _ => {} + } +} + +fn handle_file_search_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Up => app.select_up(), + KeyCode::Down => app.select_down(), + KeyCode::Esc => app.cancel_search(), + KeyCode::Enter => app.confirm_search(), + KeyCode::Backspace => { + if app.status_view.search_query.is_empty() { + app.cancel_search(); + } else { + app.search_pop(); + } + } + _ => { + // Reject command chords: Ctrl+letter reaches crossterm as the + // literal letter, not as a control char, so modifier state is the + // reliable guard against polluting the query. + if let Some(c) = text_input_char(key) { + app.search_push(c); + } + } + } +} + +fn handle_tree_search_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Up => app.select_up(), + KeyCode::Down => app.select_down(), + KeyCode::Esc => app.cancel_tree_search(), + KeyCode::Enter => app.confirm_tree_search(), + KeyCode::Backspace => { + if app.tree_view.search_query.is_empty() { + app.cancel_tree_search(); + } else { + app.tree_search_pop(); + } + } + _ => { + // Same chord guard as the file search: Ctrl+letter arrives as the + // bare letter, so modifier state is what keeps it out of the query. + if let Some(c) = text_input_char(key) { + app.tree_search_push(c); + } + } + } +} + +fn handle_log_search_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Up => app.select_up(), + KeyCode::Down => app.select_down(), + KeyCode::Esc => app.cancel_log_search(), + KeyCode::Enter => app.confirm_log_search(), + KeyCode::Backspace => { + // Which query is active depends on whether the drill-down file + // list is showing; mirror the dispatch used by `log_search_push`. + let query_empty = if app.log_view.drill_down { + app.log_view.file_search_query.is_empty() + } else { + app.log_view.commit_search_query.is_empty() + }; + if query_empty { + app.cancel_log_search(); + } else { + app.log_search_pop(); + } + } + _ => { + if let Some(c) = text_input_char(key) { + app.log_search_push(c); + } + } + } +} + +fn handle_diff_search_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Esc => app.diff.cancel_search(), + KeyCode::Enter => app.diff.confirm_search(), + KeyCode::Backspace => { + if app.diff.search.query.is_empty() { + app.diff.cancel_search(); + } else { + app.diff.search_pop(); + } + } + _ => { + if let Some(c) = text_input_char(key) { + app.diff.search_push(c); + } + } + } +} + +fn handle_unmapped_upper_key(app: &mut App, key: KeyEvent) { + match app.focus { + Focus::FileList => match key.code { + KeyCode::Enter if app.mode == ViewMode::Log && !app.log_view.drill_down => { + app.log_drill_in() + } + // Tree navigation: Enter toggles a directory (or re-previews a + // file), Right expands, Left collapses / steps to the parent. These + // guarded arms shadow the generic Left/Right horizontal-scroll arms + // below while in Tree mode. + KeyCode::Enter if app.mode == ViewMode::Tree => app.tree_toggle(), + KeyCode::Right if app.mode == ViewMode::Tree => app.tree_expand(), + KeyCode::Left if app.mode == ViewMode::Tree => app.tree_collapse(), + // Log search Esc precedence sits ahead of `log_drill_out` so the + // first Esc clears a confirmed filter before a second Esc exits + // drill-down — mirrors the status-search Esc rule below. + KeyCode::Esc + if app.mode == ViewMode::Log + && app.log_view.drill_down + && !app.log_view.file_search_query.is_empty() => + { + app.cancel_log_search() + } + KeyCode::Esc + if app.mode == ViewMode::Log + && !app.log_view.drill_down + && !app.log_view.commit_search_query.is_empty() => + { + app.cancel_log_search() + } + KeyCode::Esc if app.log_view.drill_down => app.log_drill_out(), + _ if app.mode == ViewMode::Status && matches_text_command(key, '/') => { + app.start_search() + } + _ if app.mode == ViewMode::Tree && matches_text_command(key, '/') => { + app.start_tree_search() + } + _ if app.mode == ViewMode::Log && matches_text_command(key, '/') => { + app.start_log_search() + } + KeyCode::Esc if !app.status_view.search_query.is_empty() => app.cancel_search(), + KeyCode::Left => app.file_scroll_left(), + KeyCode::Right => app.file_scroll_right(), + _ => {} + }, + Focus::DiffViewer => match key.code { + _ if matches_text_command(key, 'v') => app.toggle_diff_file_view(), + _ if matches_text_command(key, 's') => app.toggle_diff_split_view(), + _ if matches_text_command(key, '/') => { + exit_split_for_search(app); + app.diff.start_search(); + } + _ if matches_text_command(key, 'n') && app.diff.search.has_query() => { + exit_split_for_search(app); + app.diff.next_match(); + } + _ if matches_text_command(key, 'N') && app.diff.search.has_query() => { + exit_split_for_search(app); + app.diff.prev_match(); + } + KeyCode::Esc if !app.diff.search.query.is_empty() => app.diff.cancel_search(), + KeyCode::Left => app.diff.scroll_left(), + KeyCode::Right => app.diff.scroll_right(), + _ => {} + }, + Focus::Terminal => {} + } +} + +fn exit_split_for_search(app: &mut App) { + if app.diff.view == DiffPaneView::Split { + app.diff.view = DiffPaneView::Diff; + } +} \ No newline at end of file diff --git a/src/logging.rs b/src/logging.rs index 15bdc1d..1e74c90 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -296,157 +296,5 @@ impl Write for SizeRollingAppender { } #[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[test] - fn cleanup_removes_files_older_than_max_days() { - let dir = tempdir().unwrap(); - let old_file = dir.path().join("nightcrow.old.log"); - let new_file = dir.path().join("nightcrow.new.log"); - fs::write(&old_file, b"old").unwrap(); - fs::write(&new_file, b"new").unwrap(); - - // Backdate old_file by setting mtime via a workaround (write then check) - // Since we can't easily set mtime in stdlib, we verify the function runs - // without panic and only deletes files matching the naming pattern. - cleanup_old_logs(dir.path(), 0); // max_days=0 means keep all - assert!(old_file.exists()); - assert!(new_file.exists()); - } - - #[test] - fn expired_log_paths_preserves_newest_even_when_old() { - let now = SystemTime::now(); - let day = Duration::from_secs(86400); - let candidates = vec![ - (PathBuf::from("nightcrow.log.0"), now - day * 30), - (PathBuf::from("nightcrow.log.1"), now - day * 20), - (PathBuf::from("nightcrow.log.2"), now - day * 10), - ]; - let cutoff = now - day; // anything older than 1 day is expired - - let expired = expired_log_paths(&candidates, cutoff); - - // newest (.2) must be preserved; older two are expired. - let names: Vec<_> = expired.iter().map(|p| p.to_str().unwrap()).collect(); - assert_eq!(names, vec!["nightcrow.log.0", "nightcrow.log.1"]); - } - - #[test] - fn expired_log_paths_keeps_recent_files() { - let now = SystemTime::now(); - let candidates = vec![ - ( - PathBuf::from("nightcrow.log.0"), - now - Duration::from_secs(60), - ), - ( - PathBuf::from("nightcrow.log.1"), - now - Duration::from_secs(30), - ), - ]; - let cutoff = now - Duration::from_secs(86400); - - assert!(expired_log_paths(&candidates, cutoff).is_empty()); - } - - #[test] - fn cleanup_skips_non_nightcrow_files() { - let dir = tempdir().unwrap(); - let other = dir.path().join("other.log"); - fs::write(&other, b"x").unwrap(); - cleanup_old_logs(dir.path(), 1); - assert!(other.exists()); - } - - #[test] - fn recognizes_generated_nightcrow_log_names() { - assert!(is_nightcrow_log_file(Path::new("nightcrow.log"))); - assert!(is_nightcrow_log_file(Path::new("nightcrow.log.0"))); - assert!(is_nightcrow_log_file(Path::new("nightcrow.log.2026-05-03"))); - assert!(is_nightcrow_log_file(Path::new( - "nightcrow.log.2026-05-03-14" - ))); - assert!(!is_nightcrow_log_file(Path::new("nightcrow.old.log"))); - assert!(!is_nightcrow_log_file(Path::new("other.log"))); - } - - #[test] - fn size_rolling_appender_rotates_on_overflow() { - let dir = tempdir().unwrap(); - let mut appender = SizeRollingAppender::new(dir.path(), "test.log", 10).unwrap(); - appender.write_all(b"hello12345").unwrap(); // exactly 10 bytes → no rotate yet - appender.write_all(b"x").unwrap(); // 11th byte triggers rotate - let inner = appender.inner.lock().unwrap(); - assert_eq!(inner.index, 1); - } - - #[test] - fn size_rolling_appender_resumes_highest_existing_index() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("test.log.0"), b"old").unwrap(); - fs::write(dir.path().join("test.log.2"), b"new").unwrap(); - fs::write(dir.path().join("test.log.2026-05-03"), b"daily").unwrap(); - - let appender = SizeRollingAppender::new(dir.path(), "test.log", 10).unwrap(); - let inner = appender.inner.lock().unwrap(); - - assert_eq!(inner.index, 2); - assert_eq!(inner.current_size, 3); - } - - #[test] - fn gitignore_is_written_only_into_a_nightcrow_owned_directory() { - // `*` has to ignore the ignore file itself to hide the directory. In - // our own folder that is harmless; in a directory the user pointed - // `[log] dir` at it would make Git ignore everything untracked there, - // their own `.gitignore` included. - let dir = tempfile::TempDir::new().unwrap(); - let ours = dir.path().join(".nightcrow").join("logs"); - let theirs = dir.path().join("build-logs"); - std::fs::create_dir_all(&ours).unwrap(); - std::fs::create_dir_all(&theirs).unwrap(); - - write_log_gitignore(&ours); - write_log_gitignore(&theirs); - - assert_eq!( - std::fs::read_to_string(ours.join(".gitignore")).unwrap(), - "*\n" - ); - assert!( - !theirs.join(".gitignore").exists(), - "a user-chosen log directory is theirs to manage" - ); - } - - #[test] - fn gitignore_never_clobbers_an_existing_file() { - let dir = tempfile::TempDir::new().unwrap(); - let ours = dir.path().join(".nightcrow").join("logs"); - std::fs::create_dir_all(&ours).unwrap(); - std::fs::write(ours.join(".gitignore"), "# mine\n").unwrap(); - - write_log_gitignore(&ours); - - assert_eq!( - std::fs::read_to_string(ours.join(".gitignore")).unwrap(), - "# mine\n" - ); - } - - #[test] - fn resolve_log_dir_absolute_path_unchanged() { - let abs = "/tmp/nightcrow-logs"; - let result = resolve_log_dir(abs, "/some/repo"); - assert_eq!(result, PathBuf::from(abs)); - } - - #[test] - fn resolve_log_dir_relative_joins_repo_path() { - let result = resolve_log_dir(".nightcrow/logs", "/my/repo"); - assert_eq!(result, PathBuf::from("/my/repo/.nightcrow/logs")); - } -} +#[path = "logging_tests.rs"] +mod tests; diff --git a/src/logging_tests.rs b/src/logging_tests.rs new file mode 100644 index 0000000..d43b31b --- /dev/null +++ b/src/logging_tests.rs @@ -0,0 +1,152 @@ +use super::*; +use tempfile::tempdir; + +#[test] +fn cleanup_removes_files_older_than_max_days() { + let dir = tempdir().unwrap(); + let old_file = dir.path().join("nightcrow.old.log"); + let new_file = dir.path().join("nightcrow.new.log"); + fs::write(&old_file, b"old").unwrap(); + fs::write(&new_file, b"new").unwrap(); + + // Backdate old_file by setting mtime via a workaround (write then check) + // Since we can't easily set mtime in stdlib, we verify the function runs + // without panic and only deletes files matching the naming pattern. + cleanup_old_logs(dir.path(), 0); // max_days=0 means keep all + assert!(old_file.exists()); + assert!(new_file.exists()); +} + +#[test] +fn expired_log_paths_preserves_newest_even_when_old() { + let now = SystemTime::now(); + let day = Duration::from_secs(86400); + let candidates = vec![ + (PathBuf::from("nightcrow.log.0"), now - day * 30), + (PathBuf::from("nightcrow.log.1"), now - day * 20), + (PathBuf::from("nightcrow.log.2"), now - day * 10), + ]; + let cutoff = now - day; // anything older than 1 day is expired + + let expired = expired_log_paths(&candidates, cutoff); + + // newest (.2) must be preserved; older two are expired. + let names: Vec<_> = expired.iter().map(|p| p.to_str().unwrap()).collect(); + assert_eq!(names, vec!["nightcrow.log.0", "nightcrow.log.1"]); +} + +#[test] +fn expired_log_paths_keeps_recent_files() { + let now = SystemTime::now(); + let candidates = vec![ + ( + PathBuf::from("nightcrow.log.0"), + now - Duration::from_secs(60), + ), + ( + PathBuf::from("nightcrow.log.1"), + now - Duration::from_secs(30), + ), + ]; + let cutoff = now - Duration::from_secs(86400); + + assert!(expired_log_paths(&candidates, cutoff).is_empty()); +} + +#[test] +fn cleanup_skips_non_nightcrow_files() { + let dir = tempdir().unwrap(); + let other = dir.path().join("other.log"); + fs::write(&other, b"x").unwrap(); + cleanup_old_logs(dir.path(), 1); + assert!(other.exists()); +} + +#[test] +fn recognizes_generated_nightcrow_log_names() { + assert!(is_nightcrow_log_file(Path::new("nightcrow.log"))); + assert!(is_nightcrow_log_file(Path::new("nightcrow.log.0"))); + assert!(is_nightcrow_log_file(Path::new("nightcrow.log.2026-05-03"))); + assert!(is_nightcrow_log_file(Path::new( + "nightcrow.log.2026-05-03-14" + ))); + assert!(!is_nightcrow_log_file(Path::new("nightcrow.old.log"))); + assert!(!is_nightcrow_log_file(Path::new("other.log"))); +} + +#[test] +fn size_rolling_appender_rotates_on_overflow() { + let dir = tempdir().unwrap(); + let mut appender = SizeRollingAppender::new(dir.path(), "test.log", 10).unwrap(); + appender.write_all(b"hello12345").unwrap(); // exactly 10 bytes → no rotate yet + appender.write_all(b"x").unwrap(); // 11th byte triggers rotate + let inner = appender.inner.lock().unwrap(); + assert_eq!(inner.index, 1); +} + +#[test] +fn size_rolling_appender_resumes_highest_existing_index() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("test.log.0"), b"old").unwrap(); + fs::write(dir.path().join("test.log.2"), b"new").unwrap(); + fs::write(dir.path().join("test.log.2026-05-03"), b"daily").unwrap(); + + let appender = SizeRollingAppender::new(dir.path(), "test.log", 10).unwrap(); + let inner = appender.inner.lock().unwrap(); + + assert_eq!(inner.index, 2); + assert_eq!(inner.current_size, 3); +} + +#[test] +fn gitignore_is_written_only_into_a_nightcrow_owned_directory() { + // `*` has to ignore the ignore file itself to hide the directory. In + // our own folder that is harmless; in a directory the user pointed + // `[log] dir` at it would make Git ignore everything untracked there, + // their own `.gitignore` included. + let dir = tempfile::TempDir::new().unwrap(); + let ours = dir.path().join(".nightcrow").join("logs"); + let theirs = dir.path().join("build-logs"); + std::fs::create_dir_all(&ours).unwrap(); + std::fs::create_dir_all(&theirs).unwrap(); + + write_log_gitignore(&ours); + write_log_gitignore(&theirs); + + assert_eq!( + std::fs::read_to_string(ours.join(".gitignore")).unwrap(), + "*\n" + ); + assert!( + !theirs.join(".gitignore").exists(), + "a user-chosen log directory is theirs to manage" + ); +} + +#[test] +fn gitignore_never_clobbers_an_existing_file() { + let dir = tempfile::TempDir::new().unwrap(); + let ours = dir.path().join(".nightcrow").join("logs"); + std::fs::create_dir_all(&ours).unwrap(); + std::fs::write(ours.join(".gitignore"), "# mine\n").unwrap(); + + write_log_gitignore(&ours); + + assert_eq!( + std::fs::read_to_string(ours.join(".gitignore")).unwrap(), + "# mine\n" + ); +} + +#[test] +fn resolve_log_dir_absolute_path_unchanged() { + let abs = "/tmp/nightcrow-logs"; + let result = resolve_log_dir(abs, "/some/repo"); + assert_eq!(result, PathBuf::from(abs)); +} + +#[test] +fn resolve_log_dir_relative_joins_repo_path() { + let result = resolve_log_dir(".nightcrow/logs", "/my/repo"); + assert_eq!(result, PathBuf::from("/my/repo/.nightcrow/logs")); +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 0dd3cba..e357ce0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,22 @@ mod app; +mod app_init; mod backend; +mod cli; mod config; +mod event_loop; mod git; mod input; +mod key_dispatch; +mod key_handlers; mod logging; +mod mouse; +mod paste; mod runtime; mod session; +mod splash; +#[cfg(test)] +#[path = "main_tests/mod.rs"] +mod main_tests; #[cfg(test)] mod test_util; mod ui; @@ -13,76 +24,26 @@ mod util; mod web; mod workspace; -use anyhow::{Context, Result}; -use app::{App, DiffPaneView, Focus, ViewMode}; -use clap::{Parser, Subcommand}; +use anyhow::Result; +use clap::Parser; use crossterm::event::{ - DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, KeyCode, - KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, }; use crossterm::{ - event::{self, Event}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; -use input::{ - Action, encode_key, map_key, prefix_action, prefix_action_fullscreen, vim_navigation_action, -}; -use ratatui::{Terminal, backend::CrosstermBackend, layout::Rect}; -use runtime::terminal::{SCROLL_LINE_STEP, WHEEL_LINES_PER_NOTCH}; -use std::{io, time::Duration}; +use event_loop::{ProjectContext, main_loop}; +use ratatui::{Terminal, backend::CrosstermBackend}; +use std::io; use syntect::highlighting::ThemeSet; -use syntect::parsing::SyntaxSet; use workspace::Workspace; -/// nightcrow — TUI for Agentic Coding -/// -/// Opens a git diff viewer (top) and multi-terminal panes (bottom) -/// in the current directory. -#[derive(Parser)] -#[command(version, about, long_about = None)] -struct Cli { - /// Open this repository in a project tab. Repeatable — each --repo adds - /// a tab. With none, nightcrow starts with no project open. - #[arg(short, long)] - repo: Vec, - - /// Open a terminal pane running this command at startup. Repeatable; - /// each --exec adds one pane after any config [[startup_command]] panes. - #[arg(long = "exec", value_name = "COMMAND")] - exec: Vec, - - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand)] -enum Commands { - /// Write a starter config file to ~/.nightcrow/config.toml - Init { - /// Overwrite the config file if it already exists - #[arg(long)] - force: bool, - }, - /// Serve the web viewer without starting the TUI. - /// - /// Runs in the foreground until interrupted. Unlike the TUI's optional - /// viewer, this needs no terminal — the repositories come from --repo. - /// --repo is optional: with none, the viewer starts on an empty catalog, - /// the same state the TUI starts in when launched without a repository. - Serve { - /// Repository to serve. Repeatable. Optional — omit to start empty. - #[arg(short, long)] - repo: Vec, - /// Override the configured port. - #[arg(long)] - port: Option, - /// Override the configured bind address. `0.0.0.0` exposes the server - /// — and the shells it serves — to the whole network over plain HTTP. - #[arg(long)] - bind: Option, - }, -} +use crate::cli::{ + Cli, Commands, WebSurfaces, log_anchor_for, resolve_repo_paths, run_init, run_serve, + start_viewer_if_enabled, start_web_if_enabled, +}; +use crate::splash::SplashOutcome; fn main() -> Result<()> { let cli = Cli::parse(); @@ -109,15 +70,7 @@ fn main() -> Result<()> { // validated it; re-parsing keeps the KeyEvent local to the app setup. let leader = config::parse_leader(&cfg.input.leader)?; - let repo_paths: Vec = cli - .repo - .into_iter() - .map(|p| { - git::resolve_repo_path(util::expand_tilde(p)) - .to_string_lossy() - .to_string() - }) - .collect(); + let repo_paths = resolve_repo_paths(cli.repo)?; // The viewer needs the resolved repository list, so it starts after it is // built — still before the alternate screen, so its generated password and @@ -131,13 +84,7 @@ fn main() -> Result<()> { // named on the command line stands in; with none at all, the working // directory does. A log path cannot follow the active tab — the file is // opened once, at startup. - let log_anchor = match repo_paths.first() { - Some(path) => path.clone(), - None => std::env::current_dir() - .context("cannot determine current directory")? - .to_string_lossy() - .to_string(), - }; + let log_anchor = log_anchor_for(&repo_paths)?; let _log_guard = logging::init_logging(&cfg.log, &log_anchor); tracing::info!( @@ -169,216 +116,6 @@ fn main() -> Result<()> { ) } -/// The optional browser surfaces, which start and stop together with the app. -/// -/// Grouped because they are always passed as a pair and are the same kind of -/// thing: an independently-failable server the TUI does not depend on. -struct WebSurfaces { - mirror: Option, - viewer: Option, -} - -/// Start the viewer alongside the TUI when `[web_viewer] enabled` is set. -/// -/// Like the mirror, a bind failure only disables the viewer with a warning — -/// the local TUI is the primary interface and must still come up. -fn start_viewer_if_enabled( - cfg: &mut config::Config, - repo_paths: &[String], -) -> Result> { - if !cfg.web_viewer.enabled { - return Ok(None); - } - let path = config::config_file_path()?; - if let Some(password) = config::ensure_web_viewer_password(cfg, &path)? { - eprintln!( - "nightcrow: generated a web viewer password and saved it to {}:", - path.display() - ); - eprintln!(" {password}"); - } - // The viewer runs the same configured startup terminals as the TUI (in its - // own, independent PTYs), or one bare shell when none are configured. - let startup = cfg - .startup_commands - .iter() - .map(|sc| sc.command.clone()) - .collect(); - // Alongside the TUI the viewer does not persist: the TUI owns the workspace - // file and the catalog already follows its tabs. - match web::viewer::server::ViewerServer::start_from_config( - &cfg.web_viewer, - &cfg.agent_indicator, - repo_paths, - false, - startup, - ) { - Ok(server) => { - eprintln!("nightcrow: web viewer serving at http://{}/", server.addr()); - Ok(Some(server)) - } - Err(err) => { - eprintln!("nightcrow: web viewer disabled — {err:#}"); - Ok(None) - } - } -} - -/// Serve the viewer headlessly until interrupted. -/// -/// The starting catalog comes from `--repo` plus the remembered workspace — -/// either may be empty, which starts the viewer on an empty catalog just like -/// the TUI does. From there the browser owns the set: the viewer's own open -/// and close routes add and drop repositories, and `persist` writes the result -/// back to the workspace file since no TUI is doing it. -fn run_serve( - repos: Vec, - port: Option, - bind: Option, -) -> Result<()> { - let mut cfg = config::load_config()?; - if let Some(port) = port { - cfg.web_viewer.port = port; - } - if let Some(bind) = bind { - cfg.web_viewer.bind = bind; - } - // `serve` is an explicit request, so the config toggle is not consulted — - // the user already said what they want by running this. - cfg.web_viewer.enabled = true; - - let path = config::config_file_path()?; - if let Some(password) = config::ensure_web_viewer_password(&mut cfg, &path)? { - eprintln!( - "nightcrow: generated a web viewer password and saved it to {}:", - path.display() - ); - eprintln!(" {password}"); - } - - let mut paths = resolve_serve_repos(&repos)?; - // Unify with the TUI/mirror: restore the previously-open projects so the - // viewer does not start blank each launch. Explicit --repo comes first and - // wins; remembered repos that still exist fill in after, de-duplicated. - if let Some(ws) = session::load_workspace() { - for repo in ws.repos { - if std::path::Path::new(&repo).is_dir() && !paths.contains(&repo) { - paths.push(repo); - } - } - } - let startup = cfg - .startup_commands - .iter() - .map(|sc| sc.command.clone()) - .collect(); - let server = web::viewer::server::ViewerServer::start_from_config( - &cfg.web_viewer, - &cfg.agent_indicator, - &paths, - true, - startup, - )?; - if paths.is_empty() { - // An empty catalog is a legitimate state — the same one the TUI starts - // in when launched with no repository. The viewer shows its - // no-repository state and can still be reached; the page's folder - // picker is the way in from there. - eprintln!( - "nightcrow: web viewer serving an empty catalog (no --repo given) at http://{}/", - server.addr() - ); - } else { - eprintln!( - "nightcrow: web viewer serving {} repositor{} at http://{}/", - paths.len(), - if paths.len() == 1 { "y" } else { "ies" }, - server.addr() - ); - } - if !server.addr().ip().is_loopback() { - // Worth saying out loud: this is not the default, it carries shells, - // and there is no TLS to fall back on. - eprintln!( - "nightcrow: WARNING bound to {} — repository contents and interactive", - server.addr().ip() - ); - eprintln!("nightcrow: shells are reachable from the network over plain HTTP."); - } - eprintln!("nightcrow: press Ctrl-C to stop"); - - // The accept loop owns its own threads; park this one until interrupted. - loop { - std::thread::park(); - } -} - -/// Canonicalize and de-duplicate the `--repo` list for `serve`. -/// -/// Two spellings of one worktree must collapse to one catalog entry, or the -/// browser shows the same repository twice under different ids. -fn resolve_serve_repos(repos: &[std::path::PathBuf]) -> Result> { - let mut out: Vec = Vec::new(); - for repo in repos { - let expanded = util::expand_tilde(repo); - if !expanded.exists() { - anyhow::bail!("no such directory: {}", expanded.display()); - } - let resolved = git::resolve_repo_path(&expanded) - .to_string_lossy() - .into_owned(); - if !out.contains(&resolved) { - out.push(resolved); - } - } - Ok(out) -} - -/// Bootstrap the web login credential and start the mirror server when enabled. -/// -/// Runs before the alternate screen so a generated password and any bind error -/// surface as plain stderr. A bind failure disables the web mirror with a -/// warning rather than aborting the whole app — the local TUI still runs. -fn start_web_if_enabled(cfg: &mut config::Config) -> Result> { - if !cfg.web_mirror.enabled { - return Ok(None); - } - let path = config::config_file_path()?; - if let Some(password) = config::ensure_web_mirror_password(cfg, &path)? { - eprintln!( - "nightcrow web: generated a login password and saved it to {}:", - path.display() - ); - eprintln!(" {password}"); - } - match web::WebServer::start_from_config(&cfg.web_mirror) { - Ok(server) => { - eprintln!("nightcrow web: mirror serving at http://{}/", server.addr()); - Ok(Some(server)) - } - Err(err) => { - eprintln!("nightcrow web: mirror disabled — {err:#}"); - Ok(None) - } - } -} - -fn run_init(force: bool) -> Result<()> { - match config::init_config(force)? { - config::InitOutcome::Created(path) => { - println!("Created starter config at {}", path.display()); - println!("Edit it to reserve startup commands, panel layout, theme, and more."); - } - config::InitOutcome::AlreadyExists(path) => { - println!( - "Config already exists at {} — left untouched (pass --force to overwrite).", - path.display() - ); - } - } - Ok(()) -} - struct TerminalGuard; impl TerminalGuard { @@ -429,40 +166,12 @@ impl Drop for TerminalGuard { } } -#[derive(Debug, PartialEq, Eq)] -enum KeyOutcome { - Continue, - /// Force a full repaint on the next frame. Used by the ` r` redraw - /// chord to wipe stray glyphs left behind when a PTY child writes cells - /// ratatui's diff renderer doesn't track. - Redraw, - Quit, - /// The key asked for something only the workspace can do. The handlers - /// take `&mut App` — one project — so they cannot reach the tab list; - /// they name the intent here and `main_loop` carries it out. - Project(ProjectRequest), -} - -/// A workspace-level action requested by a key or click. -#[derive(Debug, PartialEq, Eq)] -enum ProjectRequest { - /// Focus the tab at this index. Out-of-range indices are inert. - Switch(usize), - /// Close the active tab. Refused when it is the only one. - Close, - /// Open this resolved repo path as a tab, or focus the tab already on it. - Open(String), - /// Raise the open-repo dialog. It lives on the workspace, so a handler - /// holding one project cannot open it directly. - OpenDialog, -} - fn run( terminal: &mut Terminal>, repo_paths: Vec, cfg: config::Config, startup_commands: Vec, - leader: KeyEvent, + leader: crossterm::event::KeyEvent, surfaces: WebSurfaces, ) -> Result<()> { // syntect's bundled defaults omit TypeScript/TSX/TOML/YAML; two-face @@ -526,7 +235,7 @@ fn run( break; } let saved = ws.session_for(path).cloned(); - ws.add(init_app(path, &cfg, &startup_commands, leader, saved)); + ws.add(app_init::init_app(path, &cfg, &startup_commands, leader, saved)); } // Land on the tab that was in front, found by path so a skipped repo // earlier in the list cannot shift the choice onto its neighbour. @@ -550,7 +259,7 @@ fn run( } if matches!( - splash_loop(terminal, &ws, cfg.theme.preset_index())?, + splash::splash_loop(terminal, &ws, cfg.theme.preset_index())?, SplashOutcome::Quit ) { tracing::info!("nightcrow stopped during splash"); @@ -565,2985 +274,4 @@ fn run( session::save_workspace(&ws.to_persisted()); tracing::info!("nightcrow stopped"); Ok(()) -} - -/// Everything a project needs beyond its repo path. -/// -/// Threaded to the input handlers rather than stored on `Workspace` so the -/// workspace stays a pure state container: opening a tab is the only thing -/// that needs the config, and it borrows it for the duration of one keypress. -struct ProjectContext<'a> { - cfg: &'a config::Config, - startup_commands: &'a [config::StartupCommand], - leader: KeyEvent, -} - -/// Carry out a workspace-level request produced by a key or click. -/// -/// Refusals land on the notice row rather than being dropped: a keypress that -/// appears to do nothing reads as a bug. -fn apply_project_request(ws: &mut Workspace, ctx: &ProjectContext, request: ProjectRequest) { - match request { - ProjectRequest::Switch(idx) => ws.switch(idx), - ProjectRequest::OpenDialog => ws.start_repo_input(), - ProjectRequest::Close => { - // `close_active` carries the project's view state into the - // remembered set; writing here means a crash later cannot lose it. - if ws.close_active() { - session::save_workspace(&ws.to_persisted()); - } - } - ProjectRequest::Open(repo_path) => { - // Focus rather than duplicate: two tabs on one workdir would show - // identical git state while racing each other's snapshot workers. - if let Some(idx) = ws.index_of_repo(&repo_path) { - ws.switch(idx); - return; - } - // Checked before building: `init_app` spawns a PTY backend and runs - // the configured startup commands, so constructing a project only - // to have `add` refuse it would leave those processes behind. - if ws.is_full() { - ws.raise_notice( - app::NoticeKind::Project, - format!("cannot open more than {} projects", workspace::MAX_PROJECTS), - ); - return; - } - let saved = ws.session_for(&repo_path).cloned(); - let project = init_app(&repo_path, ctx.cfg, ctx.startup_commands, ctx.leader, saved); - ws.add(project); - } - } -} - -/// Carry out a handler's outcome. Returns `true` when the app should quit. -fn apply_outcome( - terminal: &mut Terminal>, - ws: &mut Workspace, - ctx: &ProjectContext, - outcome: KeyOutcome, -) -> Result { - match outcome { - KeyOutcome::Quit => return Ok(true), - KeyOutcome::Redraw => terminal.clear()?, - KeyOutcome::Continue => {} - KeyOutcome::Project(request) => apply_project_request(ws, ctx, request), - } - Ok(false) -} - -fn init_app( - repo_path: &str, - cfg: &config::Config, - startup_commands: &[config::StartupCommand], - leader: KeyEvent, - saved_session: Option, -) -> App { - let mut app = App::new( - repo_path.to_string(), - cfg.log.prompt_log, - startup_commands, - leader, - ); - app.set_accent_index(cfg.theme.preset_index()); - app.cfg_agent_indicator = cfg.agent_indicator.clone(); - app.cfg_tree = cfg.tree.clone(); - app.mouse_enabled = cfg.mouse.enabled; - 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; - 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 - // `pending_selection` (see `App::restore_session`). Restoring here also - // keeps the fresh-launch terminal focus set by `ensure_initial_terminal` - // from drawing — or routing keystrokes — over the saved focus. - app.restore_session(&state); - } - app -} - -enum SplashOutcome { - Enter, - Quit, -} - -fn splash_loop( - terminal: &mut Terminal>, - ws: &Workspace, - fallback_accent: usize, -) -> Result { - let splash = ui::splash::SplashState::new(); - // With no project open there is no restored accent to honour, so the - // configured preset stands in. - let accent = ws - .active() - .map(|p| p.current_accent()) - .unwrap_or_else(|| config::Accent::from_index(fallback_accent).color()); - loop { - terminal.draw(|frame| { - ui::splash::draw(frame, &splash, accent); - })?; - if splash.is_done() { - break; - } - if event::poll(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. - Event::Key(k) if k.kind == KeyEventKind::Press => { - if k.code == KeyCode::Esc { - return Ok(SplashOutcome::Quit); - } - break; - } - Event::Resize(_, _) => terminal.clear()?, - _ => {} - } - } - } - terminal.clear()?; - Ok(SplashOutcome::Enter) -} - -fn main_loop( - terminal: &mut Terminal>, - ws: &mut Workspace, - ss: &SyntaxSet, - ts: &ThemeSet, - cfg: &config::Config, - ctx: &ProjectContext, - surfaces: WebSurfaces, -) -> Result<()> { - let WebSurfaces { - mirror: mut web_server, - viewer, - } = surfaces; - // Signature of the repository set last handed to the viewer. The catalog - // only needs updating when a tab opens or closes, not every frame. - let mut served_repos: Vec = Vec::new(); - loop { - if let Some(viewer) = viewer.as_ref() { - let current: Vec = ws.projects().iter().map(|p| p.repo_path.clone()).collect(); - if current != served_repos { - viewer.set_repos(¤t); - served_repos = current; - } - } - // 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, so skipping the background - // ones would let them grow until the user switched back. - // - // Only the active project *applies* its snapshot, though. That runs a - // full `refresh_diff`, and doing it for every open project would put - // several repositories' git diffs on the UI thread every tick. A - // background snapshot waits in `pending_snapshot` until its tab is - // shown (see `App::drain_snapshot`). - 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. A hidden project's in-flight - // fetch is capped at one by `CommitLogPagination`, so its reply - // can wait in the channel without growing. - 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 rereads directories and previews a - // file, so like the snapshot that is active-only; a hidden project - // records the event and refreshes when its tab comes forward. - if i == active { - project.poll_tree_watcher(); - } else { - project.drain_tree_watcher(); - } - project.poll_terminal(); - } - - let size = terminal.size()?; - let screen = Rect::new(0, 0, size.width, size.height); - if let Some(app) = ws.active() { - let layouts: Vec<(backend::PaneId, u16, u16)> = - ui::terminal_content_areas(app, screen, &cfg.layout) - .into_iter() - .map(|(id, area)| (id, area.height, area.width)) - .collect(); - let app = ws.active_mut().expect("active project checked above"); - app.terminal.resize_visible_panes(&layouts); - app.terminal.sync_scroll(); - } - - // 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. - let tab_paths: Vec = ws.projects().iter().map(|p| p.repo_path.clone()).collect(); - let active_tab = ws.active_index(); - let empty_notice = ws.empty_notice().cloned(); - let prefix_armed = ws.prefix_armed(); - let fallback_accent = config::Accent::from_index(cfg.theme.preset_index()).color(); - - let (app_opt, repo_input) = ws.render_parts(); - let tabs = ui::Chrome { - repo_paths: &tab_paths, - active: active_tab, - repo_input, - }; - let accent = app_opt - .as_ref() - .map(|app| app.current_accent()) - .unwrap_or(fallback_accent); - let mut cursor = None; - let completed = terminal.draw(|frame| { - cursor = match app_opt { - Some(app) => ui::draw(frame, app, tabs, ss, ts, &cfg.layout, accent), - None => { - ui::draw_empty( - frame, - tabs, - empty_notice.as_ref(), - ctx.leader, - prefix_armed, - cfg.mouse.enabled, - accent, - ); - None - } - }; - })?; - - // Mirror the freshly composited frame to any connected browsers. Use the - // buffer returned by `draw` — after it swaps buffers, `current_buffer_mut` - // points at the next (reset) frame, not the one just rendered. The local - // terminal stays the authority for the grid size; the web view renders - // the exact same cells. - if let Some(server) = web_server.as_mut() { - server.broadcast(completed.buffer, cursor); - } - - // `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. - let repo_input = ws.repo_input.clone(); - let tabs = ui::Chrome { - repo_paths: &tab_paths, - 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. - if event::poll(Duration::from_millis(16))? { - match event::read()? { - // 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::Key(key) => { - let outcome = dispatch_key(ws, key); - if apply_outcome(terminal, ws, ctx, outcome)? { - return Ok(()); - } - } - Event::Paste(text) => dispatch_paste(ws, &text), - Event::Mouse(mouse) => { - let screen = Rect::new(0, 0, size.width, size.height); - let outcome = - dispatch_mouse(ws, tabs, mouse, screen, &cfg.layout, cfg.mouse.enabled); - if apply_outcome(terminal, ws, ctx, outcome)? { - return Ok(()); - } - } - _ => {} - } - } - - // Browser input runs through the exact same handlers as local input, so - // a web action can never diverge from the equivalent local keypress. - if let Some(server) = web_server.as_ref() { - let screen = Rect::new(0, 0, size.width, size.height); - for event in server.drain_input() { - // Rebuilt per event, not reused from the frame: an earlier - // event in this batch (or the local input above) may have - // opened, closed, or switched a project, and a tab hit-test - // against the stale row would select the wrong one. - let tab_paths: Vec = - ws.projects().iter().map(|p| p.repo_path.clone()).collect(); - let active_tab = ws.active_index(); - let repo_input = ws.repo_input.clone(); - let tabs = ui::Chrome { - repo_paths: &tab_paths, - active: active_tab, - repo_input: &repo_input, - }; - let outcome = - dispatch_web_event(ws, tabs, event, screen, &cfg.layout, cfg.mouse.enabled); - if apply_outcome(terminal, ws, ctx, outcome)? { - return Ok(()); - } - } - } - } -} - -/// Route a decoded browser input event through the same handlers as local -/// input. Keeps web and terminal control behaviourally identical. -fn dispatch_web_event( - ws: &mut Workspace, - tabs: ui::Chrome<'_>, - event: web::protocol::WebInputEvent, - screen: Rect, - layout: &config::LayoutConfig, - mouse_enabled: bool, -) -> KeyOutcome { - use web::protocol::WebInputEvent; - match event { - WebInputEvent::Key(key) => dispatch_key(ws, key), - WebInputEvent::Mouse(mouse) => { - dispatch_mouse(ws, tabs, mouse, screen, layout, mouse_enabled) - } - WebInputEvent::Paste(text) => { - dispatch_paste(ws, &text); - KeyOutcome::Continue - } - } -} - -/// Route one mouse event. The project tab row is the only target that exists -/// with no project open, so it is resolved before the per-project handler. -fn dispatch_mouse( - ws: &mut Workspace, - tabs: ui::Chrome<'_>, - mouse: MouseEvent, - screen: Rect, - layout: &config::LayoutConfig, - mouse_enabled: bool, -) -> KeyOutcome { - let ws_leader = ws.leader(); - // A release must reach the pane whose press it pairs with, even when the - // dialog opened in between: no drag reports are forwarded, so that program - // cannot track the pointer itself, and a swallowed release leaves - // `pending_mouse_press` set for a later unrelated release to match. - // `handle_mouse` resolves releases before its own modal guard for exactly - // this reason, so the dialog must not swallow them ahead of it either. - let is_release = matches!(mouse.kind, MouseEventKind::Up(_)); - if ws.repo_input.active && !is_release { - return KeyOutcome::Continue; - } - match ws.active_mut() { - Some(app) => handle_mouse(app, tabs, mouse, screen, layout), - None => { - let MouseEventKind::Down(crossterm::event::MouseButton::Left) = mouse.kind else { - return KeyOutcome::Continue; - }; - if let Some(idx) = ui::project_tab_at(tabs, screen, mouse.column, mouse.row) { - return KeyOutcome::Project(ProjectRequest::Switch(idx)); - } - // The open hint is the one action the empty screen offers, so a - // click on it does what its key does. - let leader_label = app::leader_label_of(ws_leader); - let armed = ws.prefix_armed(); - match ui::empty_hint_click_at( - screen, - &leader_label, - armed, - mouse_enabled, - mouse.column, - mouse.row, - ) { - Some(ui::HintClick::Plain('o')) | Some(ui::HintClick::Leader('o')) => { - // Disarm like the key path: an armed prefix left standing - // would consume the next key as a stale follow-up once the - // dialog closes. - ws.cancel_prefix(); - KeyOutcome::Project(ProjectRequest::OpenDialog) - } - _ => KeyOutcome::Continue, - } - } - } -} - -/// Route a captured mouse event to the pane under the pointer. -/// -/// A button press focuses that pane (mirroring a jump key), and press and -/// release are forwarded — via `click_pane` — only to a program that asked -/// for mouse reports. A release pairs with the press's pane rather than the -/// pane under the pointer (see `release_pending_press`). Wheel notches -/// scroll the pane under the pointer, not the active one, through the same -/// sink logic as the scroll keys. A left press outside pane content can -/// focus an upper panel, jump to a pane via its tab (or a `+N` hidden -/// marker), or run a hint-bar shortcut — the latter dispatched as -/// synthesized keypresses so a click and the named key take the same code -/// path (hence the `KeyOutcome` return, e.g. for `r: redraw`). While -/// pane-swap mode is armed, a left click names the swap target instead, -/// mirroring the digit follow-up. Presses on anything else (borders, -/// header) are dropped, and drag/motion reports are not forwarded at all: -/// inner-program text selection stays with the outer terminal's -/// Shift+drag. -fn handle_mouse( - app: &mut App, - tabs: ui::Chrome<'_>, - mouse: MouseEvent, - screen: Rect, - layout: &config::LayoutConfig, -) -> KeyOutcome { - // Releases route by the pending press, not the pointer, so they must be - // handled before the hit test — the pointer may have left the pane (or - // every pane) between press and release. They also bypass the modal - // guard below: the press happened before the modal opened, and the - // program that saw it must still see the release — swallowing it would - // leave the pending slot stale for a later unrelated release. - if let MouseEventKind::Up(_) = mouse.kind { - release_pending_press(app, screen, layout, mouse.column, mouse.row); - return KeyOutcome::Continue; - } - // Modal overlays (repo-switch dialog, every search bar) own all other - // input while open — same rule the key handler enforces: a click behind - // a modal must not move focus or reach a pane. - if app.search_overlay_active() { - return KeyOutcome::Continue; - } - // Pane-swap mode: a press names the swap target the way a digit does — - // a left click on a pane or its tab swaps the active pane with it, and - // any other press consumes-and-disarms, mirroring the key follow-up - // (`handle_swap_target_followup`). Without this branch a click would - // change the active pane while leaving swap mode armed, so a later - // digit would swap the wrong pane. Wheel events fall through, like a - // paste: they don't name a pane and don't disturb the armed state. - if app.awaiting_swap_target() - && let MouseEventKind::Down(button) = mouse.kind - { - app.cancel_swap_target(); - if button == crossterm::event::MouseButton::Left { - let target = ui::pane_at(app, screen, layout, mouse.column, mouse.row) - .and_then(|(id, _)| app.terminal.panes.iter().position(|p| p.id == id)) - .or_else(|| ui::tab_click_at(app, screen, layout, mouse.column, mouse.row)); - if let Some(idx) = target { - app.swap_active_pane_with(idx); - } - } - return KeyOutcome::Continue; - } - let Some((id, rect)) = ui::pane_at(app, screen, layout, mouse.column, mouse.row) else { - // Not a terminal cell: a press can still focus an upper panel - // (file/commit/tree list or diff viewer) in the normal split layout, - // or run a shortcut named on the bottom hint row. - if let MouseEventKind::Down(button) = mouse.kind { - // The project tab row is checked first: it sits above the body, so - // no panel hit test can claim it, and a tab click is the pointer - // equivalent of its F-key. - if button == crossterm::event::MouseButton::Left - && let Some(idx) = ui::project_tab_at(tabs, screen, mouse.column, mouse.row) - { - app.cancel_prefix(); - return KeyOutcome::Project(ProjectRequest::Switch(idx)); - } - if let Some(focus) = ui::upper_panel_at(app, screen, layout, mouse.column, mouse.row) { - app.cancel_prefix(); - app.focus = focus; - } else if button == crossterm::event::MouseButton::Left { - if let Some(idx) = ui::tab_click_at(app, screen, layout, mouse.column, mouse.row) { - // A tab click is a jump-key press with the pointer: same - // prefix resolution and focus/fullscreen handling. - app.cancel_prefix(); - app.switch_pane(idx); - } else if let Some(click) = - ui::hint_click_at(app, tabs, screen, mouse.column, mouse.row) - { - return dispatch_hint_click(app, click); - } - } - } - return KeyOutcome::Continue; - }; - // 1-based pane-local cell, as SGR reports expect. In-bounds by - // construction: `pane_at` only returns a rect containing the cell. - let col = mouse.column - rect.x + 1; - let row = mouse.row - rect.y + 1; - match mouse.kind { - MouseEventKind::Down(button) => { - focus_clicked_pane(app, id); - if app.terminal.click_pane(id, button, true, col, row) { - app.pending_mouse_press = Some((id, button, col, row)); - } - } - MouseEventKind::ScrollUp => { - app.terminal - .scroll_pane(id, true, WHEEL_LINES_PER_NOTCH, Some((col, row))); - } - MouseEventKind::ScrollDown => { - app.terminal - .scroll_pane(id, false, WHEEL_LINES_PER_NOTCH, Some((col, row))); - } - // Horizontal wheel has no scrollback fallback; it reaches only a - // pane whose program asked for wheel reports (trackpads and tilt - // wheels in e.g. a full-screen TUI with horizontal panes). - MouseEventKind::ScrollLeft => { - app.terminal.wheel_horizontal_pane(id, true, col, row); - } - MouseEventKind::ScrollRight => { - app.terminal.wheel_horizontal_pane(id, false, col, row); - } - _ => {} - } - KeyOutcome::Continue -} - -/// Run a clicked hint-bar shortcut by synthesizing the keypress(es) its -/// label names, so a click and the real key share every guard and dispatch -/// path in `handle_key` — a hint click can never do something the named key -/// would not. `Arm` hints press the leader chord alone (the armed row then -/// offers clickable follow-ups); `Leader` hints press the leader chord first -/// (arming the prefix) and the follow-up second; `Plain` hints press one -/// bare key. -fn dispatch_hint_click(app: &mut App, click: ui::HintClick) -> KeyOutcome { - let plain = |c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE); - match click { - ui::HintClick::Arm => { - let leader = app.leader; - handle_key(app, leader) - } - ui::HintClick::Leader(c) => { - let leader = app.leader; - match handle_key(app, leader) { - KeyOutcome::Continue => {} - other => return other, - } - handle_key(app, plain(c)) - } - ui::HintClick::Plain(c) => handle_key(app, plain(c)), - } -} - -/// Deliver a button release to the pane that received the matching press. -/// -/// A program that saw an SGR press must see the release even when the -/// pointer moved off the pane in between (no drag reports are forwarded, so -/// it cannot track the pointer itself) — and a pane the pointer merely ends -/// up over must NOT receive a release it never got a press for. The release -/// carries the *stored* press button, not the one crossterm reported: -/// legacy encodings don't identify the button on release, so some -/// platforms report every `Up` as `Left`, and trusting that would strand a -/// right/middle press without its release. Chords were never paired (the -/// slot is single), so any release closes the pending press. The release -/// cell is clamped into the pressed pane's current rect. If that pane was -/// closed or hidden since the press, the release is dropped. -fn release_pending_press( - app: &mut App, - screen: Rect, - layout: &config::LayoutConfig, - x: u16, - y: u16, -) { - let Some((id, pressed, _, _)) = app.pending_mouse_press else { - return; - }; - app.pending_mouse_press = None; - let Some(rect) = ui::terminal_content_areas(app, screen, layout) - .into_iter() - .find_map(|(pid, rect)| (pid == id).then_some(rect)) - else { - return; - }; - // An extreme resize between press and release can shrink the pane to a - // zero-sized rect, which would invert the clamp bounds below (`clamp` - // panics when min > max). - if rect.width == 0 || rect.height == 0 { - return; - } - let col = x.clamp(rect.x, rect.right() - 1) - rect.x + 1; - let row = y.clamp(rect.y, rect.bottom() - 1) - rect.y + 1; - app.terminal.click_pane(id, pressed, false, col, row); -} - -/// Make the clicked pane active and move focus to the terminal, exactly what -/// a jump key does. A click is also a non-command event while the prefix is -/// armed, so resolve the prefix first (same rule as `handle_paste`). -fn focus_clicked_pane(app: &mut App, id: backend::PaneId) { - app.cancel_prefix(); - let Some(idx) = app.terminal.panes.iter().position(|p| p.id == id) else { - return; - }; - app.terminal.active = idx; - app.terminal.sync_visible_window(); - app.focus = Focus::Terminal; -} - -/// Route pasted text: into the open repo dialog if it owns input, else to the -/// active project. Nothing happens with no project and no dialog — there is no -/// sink for it. -fn dispatch_paste(ws: &mut Workspace, text: &str) { - if ws.repo_input.active { - for ch in text.chars().filter(|c| !c.is_control()) { - ws.repo_input_push(ch); - } - return; - } - match ws.active_mut() { - Some(app) => handle_paste(app, text), - // No sink for the text, but an armed prefix must still resolve — a - // non-command event cancels it, as it does on the project screen. - None => ws.cancel_prefix(), - } -} - -/// 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 -/// distinguish multi-line paste from interactive input (crossterm consumes the -/// outer markers when surfacing `Event::Paste`). -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. - app.cancel_prefix(); - if app.focus == Focus::FileList && app.status_view.search_active { - for ch in text.chars().filter(|c| !c.is_control()) { - app.search_push(ch); - } - return; - } - if app.focus == Focus::FileList && app.tree_view.search_active { - for ch in text.chars().filter(|c| !c.is_control()) { - app.tree_search_push(ch); - } - return; - } - if app.focus == Focus::FileList - && (app.log_view.commit_search_active || app.log_view.file_search_active) - { - for ch in text.chars().filter(|c| !c.is_control()) { - app.log_search_push(ch); - } - return; - } - if app.focus == Focus::DiffViewer && app.diff.search.active { - for ch in text.chars().filter(|c| !c.is_control()) { - app.diff.search_push(ch); - } - 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. - let sanitized: Vec = text - .as_bytes() - .iter() - .copied() - .filter(|&b| b != 0x1b && b != 0x00) - .collect(); - // Only wrap in bracketed-paste markers when the running program asked - // for them (DECSET 2004). A raw program that never enabled the mode - // would otherwise receive the literal `[200~`/`[201~` markers as input. - let bracketed = app - .active_screen() - .map(|screen| screen.bracketed_paste()) - .unwrap_or(false); - if bracketed { - let mut bytes = Vec::with_capacity(sanitized.len() + 12); - bytes.extend_from_slice(b"\x1b[200~"); - bytes.extend_from_slice(&sanitized); - bytes.extend_from_slice(b"\x1b[201~"); - app.terminal.send_input(&bytes); - } else { - app.terminal.send_input(&sanitized); - } - } -} - -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. - 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. - if app.search_overlay_active() - || app.prefix_armed() - || app.awaiting_swap_target() - || app.is_leader_key(key) - || app.focus != Focus::Terminal - { - app.dismiss_notice_on_app_input(); - } - - // 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. - 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. - app.cancel_prefix(); - app.cancel_swap_target(); - // 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. - if app.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. - if app.prefix_armed() { - return handle_prefix_followup(app, key); - } - - // The leader chord arms the prefix; nothing else happens this tick. - if app.is_leader_key(key) { - app.arm_prefix(); - return KeyOutcome::Continue; - } - - let action = map_key(key); - if let Some(outcome) = handle_global_action(app, action) { - return outcome; - } - - match app.focus { - Focus::Terminal => handle_terminal_key(app, key, action), - Focus::FileList | Focus::DiffViewer => handle_upper_key(app, key, action), - } - KeyOutcome::Continue -} - -/// Resolve the single key pressed while the prefix is armed. The prefix is -/// always disarmed before returning (tmux-style: one follow-up per leader). -fn handle_prefix_followup(app: &mut App, key: KeyEvent) -> KeyOutcome { - app.cancel_prefix(); - - // ` `: send the leader chord literally to the focused PTY so the - // running program still sees the prefix key when the user means it. This - // is resolved before the Esc/Ctrl+C cancel below so that a `ctrl+c` leader - // can still deliver a literal Ctrl+C via `` (Esc remains a - // universal cancel regardless of the configured leader). - if app.is_leader_key(key) { - if app.focus == Focus::Terminal - && let Some(data) = encode_key(app.leader) - { - app.terminal.send_input(&data); - } - return KeyOutcome::Continue; - } - - // Esc / Ctrl+C cancel the prefix without acting. The follow-up key is - // consumed (not forwarded) so the cancel never leaks into the PTY. - let is_ctrl_c = key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL); - if key.code == KeyCode::Esc || is_ctrl_c { - return KeyOutcome::Continue; - } - - // A mapped follow-up runs its app command everywhere (terminal + upper). - let action = resolve_prefix_action(app, key); - if let Some(outcome) = handle_global_action(app, action) { - return outcome; - } - // Unmapped follow-up: consume and drop it, then return to pass-through. - KeyOutcome::Continue -} - -/// Resolve the key pressed while swap-target mode is armed (` s`). The -/// mode is always disarmed before returning. A digit that names a pane runs the -/// swap; `Esc`/`Ctrl+C` cancels; any other key is consumed. The digit→pane -/// mapping is reused from `prefix_action` so it matches the focus-jump digits -/// one-for-one (`3`..`9`,`0` → panes `0`..`7`). -fn handle_swap_target_followup(app: &mut App, key: KeyEvent) -> KeyOutcome { - app.cancel_swap_target(); - - let is_ctrl_c = key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL); - if key.code == KeyCode::Esc || is_ctrl_c { - return KeyOutcome::Continue; - } - - if let Action::SwitchPane(idx) = resolve_prefix_action(app, key) { - app.swap_active_pane_with(idx); - } - KeyOutcome::Continue -} - -/// Pick the leader follow-up mapping for the current layout. While the terminal -/// fills the body the upper viewer is hidden, so `prefix_action_fullscreen` -/// repurposes the digit row `1`..`8` onto panes `0`..`7`; otherwise the normal -/// split-view mapping applies (`1`=list, `2`=diff, `3`..`0`=panes). Shared by -/// the focus-jump and swap-target follow-ups so both stay in lockstep. -fn resolve_prefix_action(app: &App, key: KeyEvent) -> Action { - if app.terminal.fullscreen.fills_body() { - prefix_action_fullscreen(key) - } else { - prefix_action(key) - } -} - -fn handle_global_action(app: &mut App, action: Action) -> Option { - match action { - Action::Quit => Some(KeyOutcome::Quit), - Action::NewPane => { - app.open_new_pane(); - Some(KeyOutcome::Continue) - } - Action::ClosePane => { - // 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. - if app.can_close_pane() { - app.close_active_pane(); - } - Some(KeyOutcome::Continue) - } - // Opening is two steps: this only raises the dialog, and confirming it - // emits the `Open` request (see `handle_repo_input_key`). - Action::OpenProject => Some(KeyOutcome::Project(ProjectRequest::OpenDialog)), - Action::CloseProject => Some(KeyOutcome::Project(ProjectRequest::Close)), - Action::SwitchProject(idx) => Some(KeyOutcome::Project(ProjectRequest::Switch(idx))), - Action::ToggleFullscreen => { - match app.focus { - Focus::DiffViewer => app.toggle_diff_fullscreen(), - Focus::FileList => app.toggle_list_fullscreen(), - Focus::Terminal => app.toggle_terminal_fullscreen(), - } - Some(KeyOutcome::Continue) - } - Action::ToggleLogView => { - app.toggle_mode(); - Some(KeyOutcome::Continue) - } - Action::ToggleTreeView => { - app.toggle_tree_mode(); - Some(KeyOutcome::Continue) - } - Action::CycleTheme => { - app.cycle_accent(); - Some(KeyOutcome::Continue) - } - Action::Redraw => Some(KeyOutcome::Redraw), - Action::SwitchPane(n) => { - app.switch_pane(n); - Some(KeyOutcome::Continue) - } - Action::SwapPanePrompt => { - // Scoped by `can_swap_panes` (terminal focus plus a second pane). - // The key is still consumed either way. - if app.can_swap_panes() { - app.begin_swap_target(); - } - Some(KeyOutcome::Continue) - } - Action::FocusList => { - app.focus_list(); - Some(KeyOutcome::Continue) - } - Action::FocusDiff => { - app.focus_diff(); - Some(KeyOutcome::Continue) - } - Action::CycleForward => { - app.cycle_focus_forward(); - Some(KeyOutcome::Continue) - } - Action::CycleBackward => { - app.cycle_focus_backward(); - Some(KeyOutcome::Continue) - } - _ => None, - } -} - -fn has_command_modifier(key: KeyEvent) -> bool { - key.modifiers.intersects( - KeyModifiers::CONTROL - | KeyModifiers::ALT - | KeyModifiers::SUPER - | KeyModifiers::HYPER - | KeyModifiers::META, - ) -} - -fn text_input_char(key: KeyEvent) -> Option { - if has_command_modifier(key) { - return None; - } - match key.code { - KeyCode::Char(c) if !c.is_control() => Some(c), - _ => None, - } -} - -fn matches_text_command(key: KeyEvent, expected: char) -> bool { - !has_command_modifier(key) && matches!(key.code, KeyCode::Char(c) if c == expected) -} - -/// Route one key, resolving the workspace-level cases first. -/// -/// The open dialog and the empty screen both belong to the workspace, and -/// `handle_key` holds a single project, so neither can be dispatched from -/// inside it. Resolving them here keeps `handle_key` — and every test that -/// drives it with one `App` — working on exactly one project. -fn dispatch_key(ws: &mut Workspace, key: KeyEvent) -> KeyOutcome { - if key.kind != KeyEventKind::Press { - return KeyOutcome::Continue; - } - if ws.repo_input.active { - return handle_repo_input_key(ws, key); - } - match ws.active_mut() { - Some(app) => handle_key(app, key), - None => handle_empty_key(ws, key), - } -} - -/// Keys on the empty screen: the leader arms, `o` opens the dialog, `q` -/// quits. Everything else is dropped — there is no project to act on and no -/// PTY to forward to. -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. - if ws.is_leader_key(key) { - return KeyOutcome::Continue; - } - return match prefix_action(key) { - Action::OpenProject => KeyOutcome::Project(ProjectRequest::OpenDialog), - Action::Quit => KeyOutcome::Quit, - _ => KeyOutcome::Continue, - }; - } - if ws.is_leader_key(key) { - ws.arm_prefix(); - } - KeyOutcome::Continue -} - -fn handle_repo_input_key(ws: &mut Workspace, key: KeyEvent) -> KeyOutcome { - match key.code { - KeyCode::Esc => ws.cancel_repo_input(), - KeyCode::Enter => { - if let workspace::RepoInputResult::Open(path) = ws.confirm_repo_input() { - return KeyOutcome::Project(ProjectRequest::Open(path)); - } - } - KeyCode::Backspace => { - if ws.repo_input.buf.is_empty() { - ws.cancel_repo_input(); - } else { - ws.repo_input_pop(); - } - } - // The caret is always at the end of the buffer, so these can't move - // it; they mean "keep this path and let me extend it". - KeyCode::Right | KeyCode::End => ws.repo_input_accept_prefill(), - _ => { - if let Some(c) = text_input_char(key) { - ws.repo_input_push(c); - } - } - } - KeyOutcome::Continue -} - -fn handle_terminal_key(app: &mut App, key: KeyEvent, action: Action) { - match action { - Action::TermScrollUp => { - let lines = app.terminal.active_pane_rows(); - app.terminal.scroll_active(true, lines); - } - Action::TermScrollDown => { - let lines = app.terminal.active_pane_rows(); - app.terminal.scroll_active(false, lines); - } - Action::TermScrollLineUp => app.terminal.scroll_active(true, SCROLL_LINE_STEP), - Action::TermScrollLineDown => app.terminal.scroll_active(false, SCROLL_LINE_STEP), - _ => { - if let Some(data) = encode_key(key) { - app.terminal.send_input(&data); - } - } - } -} - -fn handle_upper_key(app: &mut App, key: KeyEvent, action: Action) { - if app.focus == Focus::FileList && app.status_view.search_active { - handle_file_search_key(app, key); - return; - } - if app.focus == Focus::FileList && app.tree_view.search_active { - handle_tree_search_key(app, key); - return; - } - if app.focus == Focus::FileList - && (app.log_view.commit_search_active || app.log_view.file_search_active) - { - handle_log_search_key(app, key); - return; - } - if app.focus == Focus::DiffViewer && app.diff.search.active { - handle_diff_search_key(app, key); - return; - } - - // Apply vim-style j/k navigation only in upper panes; terminal focus is - // routed through handle_terminal_key so j/k reach the PTY untouched. - let action = vim_navigation_action(key).unwrap_or(action); - - match action { - Action::Up => app.select_up(), - Action::Down => app.select_down(), - Action::PageUp => app.page_up(), - Action::PageDown => app.page_down(), - Action::TermScrollUp - | Action::TermScrollDown - | Action::TermScrollLineUp - | Action::TermScrollLineDown => {} - Action::None => handle_unmapped_upper_key(app, key), - _ => {} - } -} - -fn handle_file_search_key(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Up => app.select_up(), - KeyCode::Down => app.select_down(), - KeyCode::Esc => app.cancel_search(), - KeyCode::Enter => app.confirm_search(), - KeyCode::Backspace => { - if app.status_view.search_query.is_empty() { - app.cancel_search(); - } else { - app.search_pop(); - } - } - _ => { - // Reject command chords: Ctrl+letter reaches crossterm as the - // literal letter, not as a control char, so modifier state is the - // reliable guard against polluting the query. - if let Some(c) = text_input_char(key) { - app.search_push(c); - } - } - } -} - -fn handle_tree_search_key(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Up => app.select_up(), - KeyCode::Down => app.select_down(), - KeyCode::Esc => app.cancel_tree_search(), - KeyCode::Enter => app.confirm_tree_search(), - KeyCode::Backspace => { - if app.tree_view.search_query.is_empty() { - app.cancel_tree_search(); - } else { - app.tree_search_pop(); - } - } - _ => { - // Same chord guard as the file search: Ctrl+letter arrives as the - // bare letter, so modifier state is what keeps it out of the query. - if let Some(c) = text_input_char(key) { - app.tree_search_push(c); - } - } - } -} - -fn handle_log_search_key(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Up => app.select_up(), - KeyCode::Down => app.select_down(), - KeyCode::Esc => app.cancel_log_search(), - KeyCode::Enter => app.confirm_log_search(), - KeyCode::Backspace => { - // Which query is active depends on whether the drill-down file - // list is showing; mirror the dispatch used by `log_search_push`. - let query_empty = if app.log_view.drill_down { - app.log_view.file_search_query.is_empty() - } else { - app.log_view.commit_search_query.is_empty() - }; - if query_empty { - app.cancel_log_search(); - } else { - app.log_search_pop(); - } - } - _ => { - if let Some(c) = text_input_char(key) { - app.log_search_push(c); - } - } - } -} - -fn handle_diff_search_key(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Esc => app.diff.cancel_search(), - KeyCode::Enter => app.diff.confirm_search(), - KeyCode::Backspace => { - if app.diff.search.query.is_empty() { - app.diff.cancel_search(); - } else { - app.diff.search_pop(); - } - } - _ => { - if let Some(c) = text_input_char(key) { - app.diff.search_push(c); - } - } - } -} - -fn handle_unmapped_upper_key(app: &mut App, key: KeyEvent) { - match app.focus { - Focus::FileList => match key.code { - KeyCode::Enter if app.mode == ViewMode::Log && !app.log_view.drill_down => { - app.log_drill_in() - } - // Tree navigation: Enter toggles a directory (or re-previews a - // file), Right expands, Left collapses / steps to the parent. These - // guarded arms shadow the generic Left/Right horizontal-scroll arms - // below while in Tree mode. - KeyCode::Enter if app.mode == ViewMode::Tree => app.tree_toggle(), - KeyCode::Right if app.mode == ViewMode::Tree => app.tree_expand(), - KeyCode::Left if app.mode == ViewMode::Tree => app.tree_collapse(), - // Log search Esc precedence sits ahead of `log_drill_out` so the - // first Esc clears a confirmed filter before a second Esc exits - // drill-down — mirrors the status-search Esc rule below. - KeyCode::Esc - if app.mode == ViewMode::Log - && app.log_view.drill_down - && !app.log_view.file_search_query.is_empty() => - { - app.cancel_log_search() - } - KeyCode::Esc - if app.mode == ViewMode::Log - && !app.log_view.drill_down - && !app.log_view.commit_search_query.is_empty() => - { - app.cancel_log_search() - } - KeyCode::Esc if app.log_view.drill_down => app.log_drill_out(), - _ if app.mode == ViewMode::Status && matches_text_command(key, '/') => { - app.start_search() - } - _ if app.mode == ViewMode::Tree && matches_text_command(key, '/') => { - app.start_tree_search() - } - _ if app.mode == ViewMode::Log && matches_text_command(key, '/') => { - app.start_log_search() - } - KeyCode::Esc if !app.status_view.search_query.is_empty() => app.cancel_search(), - KeyCode::Left => app.file_scroll_left(), - KeyCode::Right => app.file_scroll_right(), - _ => {} - }, - Focus::DiffViewer => match key.code { - _ if matches_text_command(key, 'v') => app.toggle_diff_file_view(), - _ if matches_text_command(key, 's') => app.toggle_diff_split_view(), - _ if matches_text_command(key, '/') => { - exit_split_for_search(app); - app.diff.start_search(); - } - _ if matches_text_command(key, 'n') && app.diff.search.has_query() => { - exit_split_for_search(app); - app.diff.next_match(); - } - _ if matches_text_command(key, 'N') && app.diff.search.has_query() => { - exit_split_for_search(app); - app.diff.prev_match(); - } - KeyCode::Esc if !app.diff.search.query.is_empty() => app.diff.cancel_search(), - KeyCode::Left => app.diff.scroll_left(), - KeyCode::Right => app.diff.scroll_right(), - _ => {} - }, - Focus::Terminal => {} - } -} - -fn exit_split_for_search(app: &mut App) { - if app.diff.view == DiffPaneView::Split { - app.diff.view = DiffPaneView::Diff; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app::DiffPaneView; - use crate::app::tests::{app_with_fake_backend, app_with_files}; - use crossterm::event::KeyModifiers; - - fn press(code: KeyCode, mods: KeyModifiers) -> KeyEvent { - KeyEvent::new(code, mods) - } - - /// The default leader chord (Ctrl+F). Test apps all use the default, so a - /// standalone constructor avoids borrowing `app` inside a `handle_key` - /// call (which would conflict with the `&mut app` argument). - fn leader() -> KeyEvent { - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL) - } - - /// Snapshot the byte payloads the app's `FakeBackend` recorded so terminal - /// pass-through and literal-leader tests can assert exact PTY bytes. - fn backend_payloads(app: &App) -> Vec> { - app.terminal - .fake_backend_sent() - .expect("test app must use a FakeBackend") - } - - /// A FakeBackend-backed app with one open terminal pane and terminal - /// focus, ready for PTY pass-through assertions. - fn app_with_terminal_pane() -> App { - let mut app = app_with_fake_backend(); - app.terminal.create_pane().unwrap(); - app.focus = Focus::Terminal; - app - } - - #[test] - fn handle_key_ignores_release_events() { - // Regression for 4faacce: Windows / kitty keyboard protocol emits - // Press+Release pairs for every keystroke. Only Press must trigger - // app mutations; a Release must never act. - let mut app = app_with_files(vec!["a.rs"]); - let release = KeyEvent::new_with_kind( - KeyCode::Char('f'), - KeyModifiers::CONTROL, - crossterm::event::KeyEventKind::Release, - ); - - let outcome = handle_key(&mut app, release); - - assert!(matches!(outcome, KeyOutcome::Continue)); - } - - #[test] - fn handle_key_leader_then_q_quits() { - let mut app = app_with_files(vec!["a.rs"]); - - let first = handle_key(&mut app, leader()); - assert!(matches!(first, KeyOutcome::Continue)); - assert!(app.prefix_armed(), "leader must arm the prefix"); - - let second = handle_key(&mut app, press(KeyCode::Char('q'), KeyModifiers::NONE)); - assert!(matches!(second, KeyOutcome::Quit)); - assert!(!app.prefix_armed(), "prefix must disarm after follow-up"); - } - - #[test] - fn handle_key_bare_ctrl_f_arms_prefix_and_does_not_quit() { - // Ctrl+F is the default leader: pressing it alone arms the prefix and - // never quits nightcrow on its own (quitting is ` q`). - let mut app = app_with_terminal_pane(); - - let outcome = handle_key(&mut app, press(KeyCode::Char('f'), KeyModifiers::CONTROL)); - - assert!(matches!(outcome, KeyOutcome::Continue)); - assert!(app.prefix_armed(), "the leader press arms the prefix"); - } - - /// A workspace of projects distinguished by `repo_path`, plus the context - /// `apply_project_request` needs. `Open` is the only request that builds a - /// project, so a default config suffices for the rest. - fn workspace_on(paths: &[&str]) -> Workspace { - let project = |p: &str| { - let mut app = app_with_files(vec!["a.rs"]); - app.repo_path = p.to_string(); - app - }; - let mut ws = Workspace::new(leader()); - for p in paths { - assert!(ws.add(project(p))); - } - ws - } - - #[test] - fn opening_a_repo_another_tab_holds_focuses_it_instead_of_duplicating() { - let cfg = config::Config::default(); - let ctx = ProjectContext { - cfg: &cfg, - startup_commands: &[], - leader: leader(), - }; - let mut ws = workspace_on(&["/a", "/b"]); - - apply_project_request(&mut ws, &ctx, ProjectRequest::Open("/a".to_string())); - - assert_eq!(ws.active_index(), 0); - assert_eq!(ws.projects().len(), 2); - } - - #[test] - fn clicking_a_project_tab_asks_the_workspace_to_switch() { - let mut app = app_with_fake_backend(); - let tabs = vec!["/w/api".to_string(), "/w/web".to_string()]; - // Column 0 of row 0 is the first tab; a click there is the pointer - // equivalent of pressing F1. - let outcome = handle_mouse( - &mut app, - ui::Chrome { - repo_paths: &tabs, - active: 1, - repo_input: &ui::status_view::RepoInput::default(), - }, - mouse( - MouseEventKind::Down(crossterm::event::MouseButton::Left), - 0, - 0, - ), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::Switch(0))); - } - - #[test] - fn f_key_asks_the_workspace_to_switch_project() { - let mut app = app_with_files(vec!["a.rs"]); - - // Bare F-keys need no prefix, and the request is emitted rather than - // acted on: the handler holds one project and cannot reach the tabs. - let outcome = handle_key(&mut app, press(KeyCode::F(3), KeyModifiers::NONE)); - - assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::Switch(2))); - } - - #[test] - fn leader_x_asks_the_workspace_to_close_the_project() { - let mut app = app_with_files(vec!["a.rs"]); - let _ = handle_key(&mut app, leader()); - - let outcome = handle_key(&mut app, press(KeyCode::Char('x'), KeyModifiers::NONE)); - - assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::Close)); - assert!(!app.prefix_armed(), "prefix must disarm after follow-up"); - } - - #[test] - fn leader_o_asks_the_workspace_to_raise_the_dialog() { - let mut app = app_with_files(vec!["a.rs"]); - let _ = handle_key(&mut app, leader()); - - let outcome = handle_key(&mut app, press(KeyCode::Char('o'), KeyModifiers::NONE)); - - // The dialog is workspace state, so a handler holding one project can - // only ask for it. - assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::OpenDialog)); - } - - #[test] - fn confirming_the_dialog_asks_the_workspace_to_open_that_path() { - let (_dir, path) = crate::test_util::make_repo(); - let mut ws = workspace_on(&["/a"]); - ws.start_repo_input(); - for c in path.chars() { - ws.repo_input_push(c); - } - - let outcome = dispatch_key(&mut ws, press(KeyCode::Enter, KeyModifiers::NONE)); - - // The emitted path is the *resolved* workdir, not the typed text — - // on macOS the temp dir reaches it through a /var -> /private/var - // symlink, and the workdir carries a trailing separator. - let expected = git::resolve_repo_path(std::path::Path::new(&path)) - .to_string_lossy() - .to_string(); - assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::Open(expected))); - // The current project still points at its original repo: confirming - // opens a tab, it never repoints this one. - assert_eq!(ws.active().unwrap().repo_path, "/a"); - assert!(!ws.repo_input.active, "dialog must close on success"); - } - - #[test] - fn confirming_the_dialog_on_a_bad_path_keeps_it_open() { - let mut ws = workspace_on(&["/a"]); - ws.start_repo_input(); - for c in "/definitely/not/a/directory".chars() { - ws.repo_input_push(c); - } - - let outcome = dispatch_key(&mut ws, press(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(outcome, KeyOutcome::Continue); - assert!(ws.repo_input.active, "a rejected path must stay editable"); - } - - #[test] - fn the_empty_screen_opens_the_dialog_and_quits() { - let mut ws = Workspace::new(leader()); - assert!(ws.active().is_none()); - - // The leader still arms with no project, and only `o` and `q` resolve. - let _ = dispatch_key(&mut ws, leader()); - let open = dispatch_key(&mut ws, press(KeyCode::Char('o'), KeyModifiers::NONE)); - assert_eq!(open, KeyOutcome::Project(ProjectRequest::OpenDialog)); - - let _ = dispatch_key(&mut ws, leader()); - let quit = dispatch_key(&mut ws, press(KeyCode::Char('q'), KeyModifiers::NONE)); - assert_eq!(quit, KeyOutcome::Quit); - - // An unbound follow-up is consumed, not forwarded anywhere. - let _ = dispatch_key(&mut ws, leader()); - let other = dispatch_key(&mut ws, press(KeyCode::Char('t'), KeyModifiers::NONE)); - assert_eq!(other, KeyOutcome::Continue); - } - - #[test] - fn the_dialog_still_lets_a_pending_release_through() { - // A modal opening between press and release must not strand the - // pending slot: the pane that saw the press has to see the release, and - // a leftover slot would pair with a later unrelated one. - let (mut app, areas) = app_with_two_panes_and_areas(); - let (id, rect) = areas[0]; - // Only a pane whose program asked for mouse reports records a pending - // press, so opt it in. - app.terminal - .emulators - .get_mut(&id) - .unwrap() - .process(b"\x1b[?1000h\x1b[?1006h"); - let mut ws = Workspace::new(leader()); - ws.add(app); - let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); - let up = MouseEventKind::Up(crossterm::event::MouseButton::Left); - let tabs = test_tabs(); - - dispatch_mouse( - &mut ws, - test_tab_view(&tabs), - mouse(down, rect.x, rect.y), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - true, - ); - assert!(ws.active().unwrap().pending_mouse_press.is_some()); - - ws.start_repo_input(); - dispatch_mouse( - &mut ws, - test_tab_view(&tabs), - mouse(up, rect.x, rect.y), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - true, - ); - - assert!( - ws.active().unwrap().pending_mouse_press.is_none(), - "the dialog must not swallow the release" - ); - } - - #[test] - fn switching_projects_releases_a_pending_press_to_its_own_pane() { - // The old PTY is still alive; without a release it sits in a drag or - // selection state forever, since drag reports are never forwarded. - let (mut app, areas) = app_with_two_panes_and_areas(); - let (id, rect) = areas[0]; - app.terminal - .emulators - .get_mut(&id) - .unwrap() - .process(b"\x1b[?1000h\x1b[?1006h"); - let mut ws = Workspace::new(leader()); - ws.add(app); - let tabs = test_tabs(); - dispatch_mouse( - &mut ws, - test_tab_view(&tabs), - mouse( - MouseEventKind::Down(crossterm::event::MouseButton::Left), - rect.x, - rect.y, - ), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - true, - ); - assert!(ws.active().unwrap().pending_mouse_press.is_some()); - - ws.add(app_with_files(vec!["b.rs"])); - - let old = &ws.projects()[0]; - assert!(old.pending_mouse_press.is_none()); - assert_eq!( - backend_payloads(old), - vec![b"\x1b[<0;1;1M".to_vec(), b"\x1b[<0;1;1m".to_vec()], - "the pane must see its button-up, not just lose the record" - ); - } - - #[test] - fn clicking_the_empty_screen_open_hint_raises_the_dialog() { - // It is the only action that screen offers, so it must work by pointer - // as well as by key — and it renders inverted, advertising as much. - let mut ws = Workspace::new(leader()); - let tabs: Vec = Vec::new(); - let label = app::leader_label_of(leader()); - let x = (0..MOUSE_TEST_SCREEN.width) - .find(|&x| { - ui::empty_hint_click_at( - MOUSE_TEST_SCREEN, - &label, - false, - true, - x, - MOUSE_TEST_SCREEN.height - 1, - ) - .is_some() - }) - .expect("the open hint is clickable"); - - let outcome = dispatch_mouse( - &mut ws, - test_tab_view(&tabs), - mouse( - MouseEventKind::Down(crossterm::event::MouseButton::Left), - x, - MOUSE_TEST_SCREEN.height - 1, - ), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - true, - ); - - assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::OpenDialog)); - } - - #[test] - fn clicking_the_open_hint_while_armed_disarms_the_prefix() { - // The armed row lays out differently (chip plus bare keys), so the hit - // test must measure that layout — and the click must disarm, or the - // next key after the dialog closes resolves as a stale follow-up. - let mut ws = Workspace::new(leader()); - ws.arm_prefix(); - let tabs: Vec = Vec::new(); - let label = app::leader_label_of(leader()); - let row = MOUSE_TEST_SCREEN.height - 1; - let x = (0..MOUSE_TEST_SCREEN.width) - .find(|&x| { - matches!( - ui::empty_hint_click_at(MOUSE_TEST_SCREEN, &label, true, true, x, row), - Some(ui::HintClick::Plain('o')) - ) - }) - .expect("the armed open hint is clickable"); - - let outcome = dispatch_mouse( - &mut ws, - test_tab_view(&tabs), - mouse( - MouseEventKind::Down(crossterm::event::MouseButton::Left), - x, - row, - ), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - true, - ); - - assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::OpenDialog)); - assert!(!ws.prefix_armed(), "the click must disarm the prefix"); - } - - #[test] - fn the_empty_hint_is_inert_when_mouse_capture_is_disabled() { - // The row renders plain in that case, and a browser mouse event still - // reaches this path — a label that does not advertise itself as - // clickable must not act like one. - let label = app::leader_label_of(leader()); - let row = MOUSE_TEST_SCREEN.height - 1; - - assert!((0..MOUSE_TEST_SCREEN.width).all(|x| { - ui::empty_hint_click_at(MOUSE_TEST_SCREEN, &label, false, false, x, row).is_none() - })); - } - - #[test] - fn a_doubled_leader_on_the_empty_screen_does_not_quit() { - // ` ` sends a literal leader to a pane on the project screen. - // Here there is none, but the follow-up must still not reach the action - // table: with the default ctrl+f leader it would match `f` and toggle - // fullscreen. - let mut ws = Workspace::new(leader()); - - let _ = dispatch_key(&mut ws, leader()); - let outcome = dispatch_key(&mut ws, leader()); - - assert_eq!(outcome, KeyOutcome::Continue); - } - - #[test] - fn handle_key_leader_esc_cancels() { - let mut app = app_with_files(vec!["a.rs"]); - let _ = handle_key(&mut app, leader()); - assert!(app.prefix_armed()); - - let outcome = handle_key(&mut app, press(KeyCode::Esc, KeyModifiers::NONE)); - assert!(matches!(outcome, KeyOutcome::Continue)); - assert!(!app.prefix_armed(), "Esc must cancel the armed prefix"); - } - - #[test] - fn handle_key_leader_ctrl_c_cancels() { - let mut app = app_with_terminal_pane(); - let _ = handle_key(&mut app, leader()); - assert!(app.prefix_armed()); - - let outcome = handle_key(&mut app, press(KeyCode::Char('c'), KeyModifiers::CONTROL)); - assert!(matches!(outcome, KeyOutcome::Continue)); - assert!(!app.prefix_armed(), "Ctrl+C must cancel the armed prefix"); - // The cancel is consumed, never leaked to the PTY. - assert!( - backend_payloads(&app).is_empty(), - "Ctrl+C cancel must not send bytes to the PTY" - ); - } - - #[test] - fn handle_key_ctrl_super_leader_passes_through() { - // A Super/Hyper/Meta bit on top of Ctrl+ (enhanced keyboard - // protocols report these) is a different chord, so it must reach the - // PTY rather than arm the prefix. - let mut app = app_with_terminal_pane(); - - let outcome = handle_key( - &mut app, - press( - KeyCode::Char('f'), - KeyModifiers::CONTROL | KeyModifiers::SUPER, - ), - ); - - assert!(matches!(outcome, KeyOutcome::Continue)); - assert!( - !app.prefix_armed(), - "Ctrl+Super+leader must not arm the prefix" - ); - } - - #[test] - fn handle_key_ctrl_alt_leader_passes_through() { - // Ctrl+Alt+ carries an extra modifier, so it is NOT the leader - // chord — it must reach the PTY rather than arm the prefix. - let mut app = app_with_terminal_pane(); - - let outcome = handle_key( - &mut app, - press( - KeyCode::Char('f'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - ), - ); - - assert!(matches!(outcome, KeyOutcome::Continue)); - assert!( - !app.prefix_armed(), - "Ctrl+Alt+leader must not arm the prefix" - ); - assert!( - !backend_payloads(&app).is_empty(), - "Ctrl+Alt+leader must pass through to the PTY" - ); - } - - #[test] - fn paste_while_prefix_armed_cancels_prefix() { - let mut app = app_with_terminal_pane(); - let _ = handle_key(&mut app, leader()); - assert!(app.prefix_armed()); - - handle_paste(&mut app, "hello"); - - assert!( - !app.prefix_armed(), - "a paste must resolve (cancel) the armed prefix" - ); - } - - #[test] - fn leader_leader_sends_literal_leader_even_when_leader_is_ctrl_c() { - // With a `ctrl+c` leader, `` must still reach the PTY - // as a literal Ctrl+C (0x03); the leader-again path takes precedence - // over the Ctrl+C cancel path. - let mut app = app_with_terminal_pane(); - app.leader = press(KeyCode::Char('c'), KeyModifiers::CONTROL); - - let _ = handle_key(&mut app, press(KeyCode::Char('c'), KeyModifiers::CONTROL)); - assert!(app.prefix_armed()); - - let outcome = handle_key(&mut app, press(KeyCode::Char('c'), KeyModifiers::CONTROL)); - assert!(matches!(outcome, KeyOutcome::Continue)); - assert!(!app.prefix_armed()); - assert_eq!( - backend_payloads(&app).concat(), - vec![0x03], - " must deliver a literal Ctrl+C to the PTY" - ); - } - - #[test] - fn terminal_paste_wraps_only_when_bracketed_mode_enabled() { - let mut app = app_with_terminal_pane(); - // The running program enables bracketed paste (DECSET 2004). - for emulator in app.terminal.emulators.values_mut() { - emulator.process(b"\x1b[?2004h"); - } - - handle_paste(&mut app, "hi"); - - assert_eq!( - backend_payloads(&app).concat(), - b"\x1b[200~hi\x1b[201~".to_vec(), - "paste must be bracketed when the program enabled DECSET 2004" - ); - } - - #[test] - fn terminal_paste_sends_raw_when_bracketed_mode_disabled() { - let mut app = app_with_terminal_pane(); - - handle_paste(&mut app, "hi"); - - assert_eq!( - backend_payloads(&app).concat(), - b"hi".to_vec(), - "without DECSET 2004 the markers must not be sent as literal input" - ); - } - - #[test] - fn handle_key_leader_unmapped_followup_cancels() { - let mut app = app_with_terminal_pane(); - let _ = handle_key(&mut app, leader()); - assert!(app.prefix_armed()); - - let outcome = handle_key(&mut app, press(KeyCode::Char('z'), KeyModifiers::NONE)); - assert!(matches!(outcome, KeyOutcome::Continue)); - assert!(!app.prefix_armed()); - // The unmapped follow-up is consumed, NOT forwarded to the PTY. - assert!( - backend_payloads(&app).is_empty(), - "unmapped follow-up must be dropped, not sent to the PTY" - ); - } - - #[test] - fn handle_key_double_leader_sends_literal_to_pty() { - let mut app = app_with_terminal_pane(); - let _ = handle_key(&mut app, leader()); - assert!(app.prefix_armed()); - - let outcome = handle_key(&mut app, leader()); - assert!(matches!(outcome, KeyOutcome::Continue)); - assert!(!app.prefix_armed()); - // Ctrl+F encodes to 0x06 (ACK) — the literal leader byte. - assert_eq!(backend_payloads(&app), vec![vec![0x06]]); - } - - #[test] - fn handle_key_leader_t_opens_pane() { - let mut app = app_with_terminal_pane(); - let before = app.terminal.panes.len(); - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('t'), KeyModifiers::NONE)); - assert_eq!(app.terminal.panes.len(), before + 1); - } - - #[test] - fn handle_key_leader_w_closes_pane_with_terminal_focus() { - let mut app = app_with_terminal_pane(); - app.terminal.create_pane().unwrap(); - let before = app.terminal.panes.len(); - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('w'), KeyModifiers::NONE)); - assert_eq!(app.terminal.panes.len(), before - 1); - } - - #[test] - fn handle_key_leader_w_closes_pane_in_terminal_fullscreen() { - // Fullscreen routes the follow-up through `prefix_action_fullscreen`; - // `w` must keep closing there (focus is Terminal while it fills the - // body). - let mut app = app_with_terminal_pane(); - app.terminal.create_pane().unwrap(); - app.terminal.fullscreen = crate::runtime::terminal::TerminalFullscreen::Grid; - let before = app.terminal.panes.len(); - - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('w'), KeyModifiers::NONE)); - - assert_eq!(app.terminal.panes.len(), before - 1); - } - - #[test] - fn handle_key_leader_w_is_ignored_without_terminal_focus() { - // Without terminal focus the active pane is rendered identically to - // the others, so ` w` must not close an invisible target. - // The follow-up is still consumed: prefix disarmed, nothing forwarded. - let mut app = app_with_terminal_pane(); - app.focus = Focus::FileList; - let before = app.terminal.panes.len(); - - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('w'), KeyModifiers::NONE)); - - assert_eq!( - app.terminal.panes.len(), - before, - "leader+w must be a no-op outside terminal focus" - ); - assert!(!app.prefix_armed()); - assert!( - backend_payloads(&app).is_empty(), - "the consumed follow-up must not reach the PTY" - ); - } - - #[test] - fn handle_key_leader_l_toggles_log_view_from_upper_focus() { - // Leader commands work in upper (file list) focus too, not just - // terminal focus. - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::FileList; - let before = app.mode; - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('l'), KeyModifiers::NONE)); - assert_ne!( - app.mode, before, - "leader+l must toggle the view in upper focus" - ); - } - - #[test] - fn handle_key_leader_digits_mirror_focus_and_pane_fkeys() { - // Digits mirror the no-prefix F-keys one-for-one: 1=F1 (file list), - // 2=F2 (diff viewer), 3..9,0=F3..F10 (terminal panes 0..7). The - // dispatcher consumes the digit (disarming the prefix) instead of - // forwarding it to the PTY. - let mut app = app_with_terminal_pane(); - app.terminal - .create_pane_with(Some("echo two"), Some("two")) - .unwrap(); - // Pad up to 8 panes so ` 0` (pane index 7) below is a real - // switch, not a no-op against an out-of-range index. - for i in 2..8 { - app.terminal - .create_pane_with(None, Some(&format!("pane{i}"))) - .unwrap(); - } - assert_eq!(app.terminal.panes.len(), 8); - app.switch_pane(0); - - // 1 → focus file list (mirrors F1) - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('1'), KeyModifiers::NONE)); - assert_eq!(app.focus, Focus::FileList, "leader+1 must mirror F1"); - - // 2 → focus diff viewer (mirrors F2) - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('2'), KeyModifiers::NONE)); - assert_eq!(app.focus, Focus::DiffViewer, "leader+2 must mirror F2"); - - // 4 → terminal pane 1 (mirrors F4) - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('4'), KeyModifiers::NONE)); - assert_eq!(app.terminal.active, 1, "leader+4 must mirror F4 → pane 1"); - - // 0 → terminal pane 7 (mirrors F10) - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('0'), KeyModifiers::NONE)); - assert_eq!(app.terminal.active, 7, "leader+0 must mirror F10 → pane 7"); - - assert!( - !app.prefix_armed(), - "a mapped follow-up must disarm the prefix" - ); - assert!( - backend_payloads(&app).is_empty(), - "a consumed leader digit must not reach the PTY" - ); - } - - #[test] - fn handle_key_leader_s_then_digit_swaps_active_pane() { - // ` s 5` swaps the active pane with pane index 2 (digit 5 - // mirrors F5 → pane 2) and moves focus to follow it. - let mut app = app_with_terminal_pane(); - for i in 1..3 { - app.terminal - .create_pane_with(None, Some(&format!("pane{i}"))) - .unwrap(); - } - assert_eq!(app.terminal.panes.len(), 3); - app.switch_pane(0); - let moving_id = app.terminal.panes[0].id; - let target_id = app.terminal.panes[2].id; - - // ` s` arms swap mode without acting. - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('s'), KeyModifiers::NONE)); - assert!(app.awaiting_swap_target(), "leader+s must arm swap mode"); - assert!(!app.prefix_armed(), "swap mode must clear the prefix"); - - // The digit resolves the swap. - let _ = handle_key(&mut app, press(KeyCode::Char('5'), KeyModifiers::NONE)); - assert!( - !app.awaiting_swap_target(), - "the digit must disarm swap mode" - ); - assert_eq!(app.terminal.panes[0].id, target_id); - assert_eq!(app.terminal.panes[2].id, moving_id); - assert_eq!(app.terminal.active, 2, "focus follows the moved pane"); - assert!( - backend_payloads(&app).is_empty(), - "a consumed swap digit must not reach the PTY" - ); - } - - #[test] - fn handle_key_leader_s_esc_cancels_without_swapping() { - let mut app = app_with_terminal_pane(); - app.terminal.create_pane_with(None, Some("two")).unwrap(); - app.switch_pane(0); - let order: Vec<_> = app.terminal.panes.iter().map(|p| p.id).collect(); - - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('s'), KeyModifiers::NONE)); - let _ = handle_key(&mut app, press(KeyCode::Esc, KeyModifiers::NONE)); - - assert!(!app.awaiting_swap_target()); - assert_eq!(app.terminal.active, 0); - let after: Vec<_> = app.terminal.panes.iter().map(|p| p.id).collect(); - assert_eq!(order, after, "esc must leave pane order unchanged"); - } - - #[test] - fn handle_key_leader_s_non_digit_cancels() { - // A non-pane follow-up (e.g. a letter) cancels swap mode and is - // consumed rather than swapping or reaching the PTY. - let mut app = app_with_terminal_pane(); - app.terminal.create_pane_with(None, Some("two")).unwrap(); - app.switch_pane(0); - let order: Vec<_> = app.terminal.panes.iter().map(|p| p.id).collect(); - - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('s'), KeyModifiers::NONE)); - let _ = handle_key(&mut app, press(KeyCode::Char('z'), KeyModifiers::NONE)); - - assert!(!app.awaiting_swap_target()); - let after: Vec<_> = app.terminal.panes.iter().map(|p| p.id).collect(); - assert_eq!(order, after); - assert!(backend_payloads(&app).is_empty()); - } - - /// ` s` shares close's terminal-focus scope: from the upper panes - /// the active pane is rendered indistinguishable, so the chord must be - /// consumed without arming swap mode. - #[test] - fn handle_key_leader_s_without_terminal_focus_does_not_arm() { - let mut app = app_with_terminal_pane(); - app.terminal.create_pane_with(None, Some("two")).unwrap(); - app.focus = Focus::FileList; - - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('s'), KeyModifiers::NONE)); - - assert!( - !app.awaiting_swap_target(), - "leader+s must not arm swap mode without terminal focus" - ); - assert!( - !app.prefix_armed(), - "the follow-up must still disarm the prefix" - ); - assert!( - backend_payloads(&app).is_empty(), - "the consumed chord must not reach the PTY" - ); - } - - /// With a single pane every swap target digit would be a no-op, so the - /// chord must not arm swap mode. - #[test] - fn handle_key_leader_s_with_single_pane_does_not_arm() { - let mut app = app_with_terminal_pane(); - assert_eq!(app.terminal.panes.len(), 1); - - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('s'), KeyModifiers::NONE)); - - assert!( - !app.awaiting_swap_target(), - "leader+s must not arm swap mode with a single pane" - ); - assert!(backend_payloads(&app).is_empty()); - } - - #[test] - fn handle_key_leader_b_toggles_tree_mode() { - // ` b` enters Tree mode and a second ` b` returns to - // Status. Uses the live cwd repo (the crate root) for the root read. - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::FileList; - assert_eq!(app.mode, ViewMode::Status); - - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('b'), KeyModifiers::NONE)); - assert_eq!(app.mode, ViewMode::Tree); - - let _ = handle_key(&mut app, leader()); - let _ = handle_key(&mut app, press(KeyCode::Char('b'), KeyModifiers::NONE)); - assert_eq!(app.mode, ViewMode::Status); - } - - #[test] - fn handle_key_tree_right_left_expand_and_collapse() { - let (dir, path) = crate::test_util::make_repo(); - let root = std::path::Path::new(&path); - std::fs::create_dir(root.join("sub")).unwrap(); - std::fs::write(root.join("sub").join("f.txt"), "x").unwrap(); - - let mut app = app_with_files(vec![]); - app.repo_path = path.clone(); - app.focus = Focus::FileList; - app.enter_tree_mode(); - let idx = app - .tree_view - .visible_rows() - .iter() - .position(|r| r.path == "sub") - .unwrap(); - app.tree_view.selected = idx; - - // Right expands the directory. - let _ = handle_key(&mut app, press(KeyCode::Right, KeyModifiers::NONE)); - assert!( - app.tree_view - .visible_rows() - .iter() - .any(|r| r.path == "sub/f.txt"), - "Right must expand the selected directory" - ); - - // Left collapses it again. - let _ = handle_key(&mut app, press(KeyCode::Left, KeyModifiers::NONE)); - assert!( - !app.tree_view - .visible_rows() - .iter() - .any(|r| r.path == "sub/f.txt"), - "Left must collapse the expanded directory" - ); - drop(dir); - } - - #[test] - fn handle_key_terminal_ctrl_w_passes_through_to_pty() { - // Ctrl+W (and friends) are prompt-editing keys that must now reach - // the running program as control bytes instead of closing the pane. - let mut app = app_with_terminal_pane(); - - let _ = handle_key(&mut app, press(KeyCode::Char('w'), KeyModifiers::CONTROL)); - - // Ctrl+W encodes to 0x17 (ETB). - assert_eq!(backend_payloads(&app), vec![vec![0x17]]); - } - - #[test] - fn handle_key_terminal_ctrl_app_keys_all_pass_through() { - // Every former bare-Ctrl app shortcut now reaches the PTY untouched. - // Ctrl+F is excluded: it is the default leader, so it is intercepted to - // arm the prefix rather than passed through (see the bare-Ctrl+F test). - for (c, byte) in [ - ('t', 0x14u8), - ('w', 0x17), - ('q', 0x11), - ('l', 0x0c), - ('p', 0x10), - ('o', 0x0f), - ] { - let mut app = app_with_terminal_pane(); - let _ = handle_key(&mut app, press(KeyCode::Char(c), KeyModifiers::CONTROL)); - assert_eq!( - backend_payloads(&app), - vec![vec![byte]], - "ctrl+{c} must pass through to the PTY" - ); - } - } - - #[test] - fn handle_key_overlay_blocks_leader_when_diff_search_active() { - // While a search overlay is open the leader is typed/consumed by the - // overlay, never arming the prefix or firing an app command. - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::DiffViewer; - app.diff.start_search(); - assert!(app.diff.search.active); - let before = app.mode; - - let _ = handle_key(&mut app, leader()); - assert!(!app.prefix_armed(), "leader must not arm behind an overlay"); - let _ = handle_key(&mut app, press(KeyCode::Char('l'), KeyModifiers::NONE)); - - assert_eq!( - app.mode, before, - "no app command may fire behind an overlay" - ); - assert!(app.diff.search.active, "diff search must remain open"); - } - - #[test] - fn dialog_swallows_the_leader_instead_of_arming_the_prefix() { - let mut ws = workspace_on(&["/a"]); - ws.start_repo_input(); - ws.repo_input.buf.clear(); - - let _ = dispatch_key(&mut ws, leader()); - - // The dispatcher gives the dialog every key, so the leader is typed - // (and rejected as a control char) rather than arming a prefix behind - // the modal. - assert!(!ws.active().unwrap().prefix_armed()); - assert!(ws.repo_input.active); - } - - #[test] - fn dialog_rejects_command_modifier_chars() { - let mut ws = workspace_on(&["/a"]); - ws.start_repo_input(); - ws.repo_input.buf.clear(); - - let alt_x = press(KeyCode::Char('x'), KeyModifiers::ALT); - let _ = dispatch_key(&mut ws, alt_x); - - assert!(ws.repo_input.buf.is_empty()); - } - - #[test] - fn handle_key_file_search_rejects_command_modifier_chars() { - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::FileList; - app.start_search(); - - let ctrl_x = press(KeyCode::Char('x'), KeyModifiers::CONTROL); - let _ = handle_key(&mut app, ctrl_x); - - assert!(app.status_view.search_query.is_empty()); - } - - #[test] - fn handle_key_diff_search_rejects_command_modifier_chars() { - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::DiffViewer; - app.diff.start_search(); - - let alt_x = press(KeyCode::Char('x'), KeyModifiers::ALT); - let _ = handle_key(&mut app, alt_x); - - assert!(app.diff.search.query.is_empty()); - } - - #[test] - fn handle_key_status_search_shortcut_requires_no_command_modifier() { - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::FileList; - - let ctrl_slash = press(KeyCode::Char('/'), KeyModifiers::CONTROL); - let _ = handle_key(&mut app, ctrl_slash); - - assert!(!app.status_view.search_active); - } - - #[test] - fn handle_key_diff_file_toggle_requires_no_command_modifier() { - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::DiffViewer; - - let alt_v = press(KeyCode::Char('v'), KeyModifiers::ALT); - let _ = handle_key(&mut app, alt_v); - - assert_eq!(app.diff.view, DiffPaneView::Diff); - } - - #[test] - fn handle_key_diff_search_from_split_returns_to_unified_overlay() { - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::DiffViewer; - app.diff.view = DiffPaneView::Split; - - let _ = handle_key(&mut app, press(KeyCode::Char('/'), KeyModifiers::NONE)); - - assert_eq!(app.diff.view, DiffPaneView::Diff); - assert!(app.diff.search.active); - } - - #[test] - fn handle_key_diff_next_match_from_split_returns_to_unified_when_query_exists() { - let mut app = app_with_files(vec!["a.rs"]); - app.focus = Focus::DiffViewer; - app.diff.view = DiffPaneView::Split; - app.diff.search.query.set("needle"); - - let _ = handle_key(&mut app, press(KeyCode::Char('n'), KeyModifiers::NONE)); - - assert_eq!(app.diff.view, DiffPaneView::Diff); - } - - #[test] - fn handle_paste_into_file_search_strips_control_chars() { - // Regression for e21c449 + 4084760: paste into the file-search - // overlay drops control characters (newlines, tabs, bells) before - // appending to the query. - let mut app = app_with_files(vec!["alpha.rs", "beta.rs"]); - app.focus = Focus::FileList; - app.start_search(); - - handle_paste(&mut app, "al\nph\ta\x07"); - - assert_eq!(app.status_view.search_query.as_str(), "alpha"); - } - - #[test] - fn handle_paste_into_diff_search_strips_control_chars() { - let mut app = app_with_files(vec!["alpha.rs"]); - app.focus = Focus::DiffViewer; - app.diff.start_search(); - - handle_paste(&mut app, "fn\rname\x08"); - - assert_eq!(app.diff.search.query.as_str(), "fnname"); - } - - #[test] - fn paste_into_the_dialog_strips_control_chars() { - let mut ws = workspace_on(&["/a"]); - ws.start_repo_input(); - // `start_repo_input` prefills with the active repo path, and - // `repo_input_push` preserves existing content, so reset first. - ws.repo_input.buf.clear(); - - dispatch_paste(&mut ws, "/tmp\n/repo\x07"); - - assert_eq!(ws.repo_input.buf, "/tmp/repo"); - } - - const MOUSE_TEST_SCREEN: Rect = Rect::new(0, 0, 100, 40); - - /// A single-project tab row, matching the app these mouse tests drive. - /// Tests that specifically exercise tab clicks build their own list. - fn test_tabs() -> Vec { - vec![".".to_string()] - } - - /// A closed dialog to borrow from, so `test_tab_view` can hand out a - /// `Chrome` without referencing a temporary. - static CLOSED_DIALOG: std::sync::LazyLock = - std::sync::LazyLock::new(ui::status_view::RepoInput::default); - - fn test_tab_view(paths: &[String]) -> ui::Chrome<'_> { - ui::Chrome { - repo_paths: paths, - active: 0, - repo_input: &CLOSED_DIALOG, - } - } - - fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { - MouseEvent { - kind, - column, - row, - modifiers: KeyModifiers::NONE, - } - } - - /// A two-pane terminal app plus each pane's content rect under the - /// standard test screen, so mouse tests can aim events at real geometry. - fn app_with_two_panes_and_areas() -> (App, Vec<(backend::PaneId, Rect)>) { - let mut app = app_with_terminal_pane(); - app.terminal.create_pane().unwrap(); - let layout = config::LayoutConfig::default(); - let areas = ui::terminal_content_areas(&app, MOUSE_TEST_SCREEN, &layout); - assert_eq!(areas.len(), 2); - (app, areas) - } - - #[test] - fn handle_mouse_click_focuses_the_pane_under_the_pointer() { - let (mut app, areas) = app_with_two_panes_and_areas(); - app.focus = Focus::FileList; - let (first_id, rect) = areas[0]; - let first_idx = app - .terminal - .panes - .iter() - .position(|p| p.id == first_id) - .unwrap(); - assert_ne!(app.terminal.active, first_idx, "click must change focus"); - - let kind = MouseEventKind::Down(crossterm::event::MouseButton::Left); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(kind, rect.x, rect.y), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - assert_eq!(app.terminal.active, first_idx); - assert_eq!(app.focus, Focus::Terminal); - assert!( - backend_payloads(&app).is_empty(), - "a plain shell never claimed the mouse, so the click byte stream \ - must stay empty" - ); - } - - #[test] - fn handle_mouse_forwards_press_and_release_to_a_mouse_reporting_pane() { - let (mut app, areas) = app_with_two_panes_and_areas(); - let (id, rect) = areas[0]; - app.terminal - .emulators - .get_mut(&id) - .unwrap() - .process(b"\x1b[?1000h\x1b[?1006h"); - - let layout = config::LayoutConfig::default(); - let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); - let up = MouseEventKind::Up(crossterm::event::MouseButton::Left); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, rect.x, rect.y), - MOUSE_TEST_SCREEN, - &layout, - ); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(up, rect.x, rect.y), - MOUSE_TEST_SCREEN, - &layout, - ); - - // The pane's top-left content cell is SGR cell (1, 1). - assert_eq!( - backend_payloads(&app), - vec![b"\x1b[<0;1;1M".to_vec(), b"\x1b[<0;1;1m".to_vec()] - ); - } - - #[test] - fn handle_mouse_click_focuses_the_upper_panels() { - let (mut app, _) = app_with_two_panes_and_areas(); - assert_eq!(app.focus, Focus::Terminal); - let layout = config::LayoutConfig::default(); - let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); - - // Row 1 is the first body row; x=0 is the list, x=60 the diff. - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, 0, 1), - MOUSE_TEST_SCREEN, - &layout, - ); - assert_eq!(app.focus, Focus::FileList); - - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, 60, 1), - MOUSE_TEST_SCREEN, - &layout, - ); - assert_eq!(app.focus, Focus::DiffViewer); - - assert!( - backend_payloads(&app).is_empty(), - "an upper-panel click must not write to any PTY" - ); - } - - #[test] - fn handle_mouse_release_follows_the_pressed_pane_when_the_pointer_moves_away() { - let (mut app, areas) = app_with_two_panes_and_areas(); - let (pressed_id, pressed_rect) = areas[0]; - let (_, other_rect) = areas[1]; - // Only the pressed pane is mouse-aware: any release payload proves - // routing went to the pressed pane, not the pane under the pointer. - app.terminal - .emulators - .get_mut(&pressed_id) - .unwrap() - .process(b"\x1b[?1000h\x1b[?1006h"); - - let layout = config::LayoutConfig::default(); - let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); - let up = MouseEventKind::Up(crossterm::event::MouseButton::Left); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, pressed_rect.x, pressed_rect.y), - MOUSE_TEST_SCREEN, - &layout, - ); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(up, other_rect.x, other_rect.y), - MOUSE_TEST_SCREEN, - &layout, - ); - - // The release cell is clamped into the pressed pane's rect. - let col = other_rect.x.clamp(pressed_rect.x, pressed_rect.right() - 1) - pressed_rect.x + 1; - let row = other_rect - .y - .clamp(pressed_rect.y, pressed_rect.bottom() - 1) - - pressed_rect.y - + 1; - let release = format!("\x1b[<0;{col};{row}m").into_bytes(); - assert_eq!( - backend_payloads(&app), - vec![b"\x1b[<0;1;1M".to_vec(), release] - ); - assert!(app.pending_mouse_press.is_none()); - } - - #[test] - fn handle_mouse_completes_a_pending_release_even_while_the_repo_modal_is_open() { - let (mut app, areas) = app_with_two_panes_and_areas(); - let (id, rect) = areas[0]; - app.terminal - .emulators - .get_mut(&id) - .unwrap() - .process(b"\x1b[?1000h\x1b[?1006h"); - let layout = config::LayoutConfig::default(); - let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); - let up = MouseEventKind::Up(crossterm::event::MouseButton::Left); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, rect.x, rect.y), - MOUSE_TEST_SCREEN, - &layout, - ); - - // A release must reach the pane that saw the press even when a modal - // opened in between, and the pending slot must not go stale. The - // release path runs before any modal guard, so driving it directly is - // the same code path a real dialog would take. - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(up, rect.x, rect.y), - MOUSE_TEST_SCREEN, - &layout, - ); - - assert_eq!( - backend_payloads(&app), - vec![b"\x1b[<0;1;1M".to_vec(), b"\x1b[<0;1;1m".to_vec()] - ); - assert!(app.pending_mouse_press.is_none()); - } - - #[test] - fn handle_mouse_release_pairs_by_the_stored_press_button() { - let (mut app, areas) = app_with_two_panes_and_areas(); - let (id, rect) = areas[0]; - app.terminal - .emulators - .get_mut(&id) - .unwrap() - .process(b"\x1b[?1000h\x1b[?1006h"); - - // Press Right, but the terminal reports the release as Left — the - // legacy encodings don't carry the button on release, so crossterm - // may fall back to Left. The pane must still see a Right release. - let layout = config::LayoutConfig::default(); - let down = MouseEventKind::Down(crossterm::event::MouseButton::Right); - let up = MouseEventKind::Up(crossterm::event::MouseButton::Left); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, rect.x, rect.y), - MOUSE_TEST_SCREEN, - &layout, - ); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(up, rect.x, rect.y), - MOUSE_TEST_SCREEN, - &layout, - ); - - assert_eq!( - backend_payloads(&app), - vec![b"\x1b[<2;1;1M".to_vec(), b"\x1b[<2;1;1m".to_vec()] - ); - assert!(app.pending_mouse_press.is_none()); - } - - #[test] - fn handle_mouse_is_inert_while_a_search_overlay_is_open() { - let (mut app, areas) = app_with_two_panes_and_areas(); - app.focus = Focus::FileList; - app.status_view.search_active = true; - let (_, rect) = areas[0]; - let active_before = app.terminal.active; - - let kind = MouseEventKind::Down(crossterm::event::MouseButton::Left); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(kind, rect.x, rect.y), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - assert_eq!( - app.focus, - Focus::FileList, - "a search overlay owns the mouse exactly like it owns keys" - ); - assert_eq!(app.terminal.active, active_before); - assert!(backend_payloads(&app).is_empty()); - } - - #[test] - fn handle_mouse_drops_a_release_with_no_pending_press() { - let (mut app, areas) = app_with_two_panes_and_areas(); - let (id, rect) = areas[0]; - app.terminal - .emulators - .get_mut(&id) - .unwrap() - .process(b"\x1b[?1000h\x1b[?1006h"); - - let up = MouseEventKind::Up(crossterm::event::MouseButton::Left); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(up, rect.x, rect.y), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - assert!( - backend_payloads(&app).is_empty(), - "a pane must not receive a release it never got a press for" - ); - } - - #[test] - fn handle_mouse_ignores_events_outside_pane_content() { - let (mut app, _) = app_with_two_panes_and_areas(); - app.focus = Focus::FileList; - let active_before = app.terminal.active; - - let kind = MouseEventKind::Down(crossterm::event::MouseButton::Left); - // (0, 0) is the upper header row, never pane content. - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(kind, 0, 0), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - assert_eq!(app.focus, Focus::FileList); - assert_eq!(app.terminal.active, active_before); - } - - #[test] - fn mouse_is_inert_while_the_repo_dialog_is_open() { - let (app, areas) = app_with_two_panes_and_areas(); - let mut ws = Workspace::new(leader()); - ws.add(app); - ws.active_mut().unwrap().focus = Focus::FileList; - ws.start_repo_input(); - let (_, rect) = areas[0]; - let active_before = ws.active().unwrap().terminal.active; - - let kind = MouseEventKind::Down(crossterm::event::MouseButton::Left); - let tabs = test_tabs(); - dispatch_mouse( - &mut ws, - test_tab_view(&tabs), - mouse(kind, rect.x, rect.y), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - true, - ); - - let app = ws.active().unwrap(); - assert_eq!(app.focus, Focus::FileList, "a modal owns all input"); - assert_eq!(app.terminal.active, active_before); - } - - /// Wide screen for hint-row click tests: the longest hint rows overflow - /// the 100-column mouse screen, and a clipped segment is unclickable by - /// design — these tests target segments, so give them room. - const HINT_TEST_SCREEN: Rect = Rect::new(0, 0, 300, 40); - - /// First x column on the hint row that resolves to `want`, scanning with - /// the same hit-test the mouse handler uses. - fn hint_x_for(app: &App, want: ui::HintClick) -> u16 { - let row = HINT_TEST_SCREEN.height - 1; - (0..HINT_TEST_SCREEN.width) - .find(|&x| { - ui::hint_click_at(app, test_tab_view(&[]), HINT_TEST_SCREEN, x, row) == Some(want) - }) - .expect("expected a clickable hint segment") - } - - /// First (x, y) cell resolving to tab-click target `want`, scanning with - /// the same hit-test the mouse handler uses. - fn tab_xy_for(app: &App, want: usize) -> (u16, u16) { - let layout = config::LayoutConfig::default(); - for y in 0..MOUSE_TEST_SCREEN.height { - for x in 0..MOUSE_TEST_SCREEN.width { - if ui::tab_click_at(app, MOUSE_TEST_SCREEN, &layout, x, y) == Some(want) { - return (x, y); - } - } - } - panic!("expected a tab segment targeting pane {want}"); - } - - #[test] - fn handle_mouse_tab_click_jumps_to_that_pane() { - let (mut app, _) = app_with_two_panes_and_areas(); - app.terminal.active = 0; - app.focus = Focus::FileList; - let (x, y) = tab_xy_for(&app, 1); - - let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, x, y), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - assert_eq!(app.terminal.active, 1); - assert_eq!(app.focus, Focus::Terminal); - assert!( - backend_payloads(&app).is_empty(), - "a tab click is UI-only; nothing may reach a PTY" - ); - } - - #[test] - fn handle_mouse_tab_click_on_hidden_marker_slides_the_window() { - let mut app = app_with_terminal_pane(); - for _ in 0..5 { - app.terminal.create_pane().unwrap(); - } - // 6 panes, window of 4: creation leaves pane 5 active, window [2, 6). - assert_eq!(app.terminal.visible_start, 2); - // The left ` +2 ` marker targets the nearest hidden pane, index 1. - let (x, y) = tab_xy_for(&app, 1); - - let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, x, y), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - assert_eq!(app.terminal.active, 1); - assert_eq!( - app.terminal.visible_start, 1, - "revealing the clicked marker's pane must slide the window one slot" - ); - } - - #[test] - fn handle_mouse_click_completes_an_armed_swap_with_the_clicked_pane() { - let (mut app, areas) = app_with_two_panes_and_areas(); - app.terminal.active = 0; - let first_id = app.terminal.panes[0].id; - let (clicked_id, rect) = areas[1]; - assert_ne!(clicked_id, first_id); - app.begin_swap_target(); - - let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, rect.x, rect.y), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - // The clicked pane is the swap target, exactly like its digit: the - // previously active pane moved into the clicked slot and stays active. - assert!(!app.awaiting_swap_target()); - assert_eq!(app.terminal.panes[1].id, first_id); - assert_eq!(app.terminal.active, 1); - assert!( - backend_payloads(&app).is_empty(), - "a swap-target click must not be forwarded to any PTY" - ); - } - - #[test] - fn handle_mouse_tab_click_completes_an_armed_swap() { - let (mut app, _) = app_with_two_panes_and_areas(); - app.terminal.active = 0; - let first_id = app.terminal.panes[0].id; - app.begin_swap_target(); - let (x, y) = tab_xy_for(&app, 1); - - let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, x, y), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - assert!(!app.awaiting_swap_target()); - assert_eq!(app.terminal.panes[1].id, first_id); - assert_eq!(app.terminal.active, 1); - } - - #[test] - fn handle_mouse_press_elsewhere_cancels_an_armed_swap() { - let (mut app, _) = app_with_two_panes_and_areas(); - app.terminal.active = 0; - app.begin_swap_target(); - let order_before: Vec<_> = app.terminal.panes.iter().map(|p| p.id).collect(); - - // (0, 0) is the header row: it names no pane, so the press must - // consume-and-disarm without swapping or moving focus — the same - // rule as a non-digit key. - let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, 0, 0), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - assert!(!app.awaiting_swap_target()); - let order_after: Vec<_> = app.terminal.panes.iter().map(|p| p.id).collect(); - assert_eq!(order_before, order_after); - assert_eq!(app.terminal.active, 0); - } - - #[test] - fn handle_mouse_hint_click_runs_the_named_leader_command() { - let mut app = app_with_terminal_pane(); - let panes_before = app.terminal.panes.len(); - let x = hint_x_for(&app, ui::HintClick::Leader('t')); - - let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); - let outcome = handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, x, HINT_TEST_SCREEN.height - 1), - HINT_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - assert!(matches!(outcome, KeyOutcome::Continue)); - assert_eq!( - app.terminal.panes.len(), - panes_before + 1, - "clicking ` t: new pane` must run the same command as the keys" - ); - assert!( - !app.prefix_armed(), - "the synthesized prefix must not linger" - ); - } - - #[test] - fn handle_mouse_hint_click_on_the_leader_label_arms_the_prefix() { - let mut app = app_with_terminal_pane(); - let x = hint_x_for(&app, ui::HintClick::Arm); - - let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); - let outcome = handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, x, HINT_TEST_SCREEN.height - 1), - HINT_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - assert!(matches!(outcome, KeyOutcome::Continue)); - assert!( - app.prefix_armed(), - "clicking `: leader` must arm the prefix exactly like the chord" - ); - assert!( - backend_payloads(&app).is_empty(), - "arming is UI-only; nothing may reach a PTY" - ); - } - - /// The mouse-only flow the arm click exists for: click the leader label, - /// then click a follow-up on the armed row. - #[test] - fn handle_mouse_arm_click_then_followup_click_runs_the_command() { - let mut app = app_with_terminal_pane(); - let panes_before = app.terminal.panes.len(); - let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); - let row = HINT_TEST_SCREEN.height - 1; - - let x = hint_x_for(&app, ui::HintClick::Arm); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, x, row), - HINT_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - let x = hint_x_for(&app, ui::HintClick::Plain('t')); - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, x, row), - HINT_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - assert_eq!( - app.terminal.panes.len(), - panes_before + 1, - "arm click + `t` click must open a pane like the key sequence" - ); - assert!(!app.prefix_armed(), "the follow-up must consume the prefix"); - } - - #[test] - fn handle_mouse_hint_click_propagates_redraw_from_the_armed_row() { - let mut app = app_with_terminal_pane(); - app.arm_prefix(); - let x = hint_x_for(&app, ui::HintClick::Plain('r')); - - let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); - let outcome = handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(down, x, HINT_TEST_SCREEN.height - 1), - HINT_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - assert!(matches!(outcome, KeyOutcome::Redraw)); - assert!(!app.prefix_armed(), "the follow-up must consume the prefix"); - } - - #[test] - fn handle_mouse_hint_click_never_quits() { - let app = app_with_terminal_pane(); - let row = HINT_TEST_SCREEN.height - 1; - for x in 0..HINT_TEST_SCREEN.width { - let click = ui::hint_click_at(&app, test_tab_view(&[]), HINT_TEST_SCREEN, x, row); - assert!( - !matches!( - click, - Some(ui::HintClick::Leader('q')) | Some(ui::HintClick::Plain('q')) - ), - "x={x} resolves to a quit click" - ); - } - } - - #[test] - fn handle_mouse_wheel_scrolls_the_pane_under_the_pointer_not_the_active_one() { - let (mut app, areas) = app_with_two_panes_and_areas(); - let (id, rect) = areas[0]; - let idx = app.terminal.panes.iter().position(|p| p.id == id).unwrap(); - let active_before = app.terminal.active; - assert_ne!(active_before, idx, "wheel must not require focus"); - // Overflow the pane so its emulator has scrollback to move into. - app.terminal.resize_visible_panes(&[(id, 10, 40)]); - let output = (0..20).fold(Vec::new(), |mut out, i| { - out.extend_from_slice(format!("line{i}\r\n").as_bytes()); - out - }); - app.terminal - .emulators - .get_mut(&id) - .unwrap() - .process(&output); - - handle_mouse( - &mut app, - test_tab_view(&test_tabs()), - mouse(MouseEventKind::ScrollUp, rect.x, rect.y), - MOUSE_TEST_SCREEN, - &config::LayoutConfig::default(), - ); - - assert_eq!(app.terminal.scroll.get(&id).copied(), Some(3)); - assert_eq!( - app.terminal.active, active_before, - "a wheel scroll must not steal focus" - ); - } -} +} \ No newline at end of file diff --git a/src/main_tests/helpers.rs b/src/main_tests/helpers.rs new file mode 100644 index 0000000..a7a9e64 --- /dev/null +++ b/src/main_tests/helpers.rs @@ -0,0 +1,121 @@ +use crate::app::{App, Focus}; +use crate::app::tests::{app_with_fake_backend, app_with_files}; +use crate::backend; +use crate::workspace::Workspace; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; +use ratatui::layout::Rect; + +pub(super) const MOUSE_TEST_SCREEN: Rect = Rect::new(0, 0, 100, 40); + +/// Wide screen for hint-row click tests: the longest hint rows overflow +/// the 100-column mouse screen, and a clipped segment is unclickable by +/// design — these tests target segments, so give them room. +pub(super) const HINT_TEST_SCREEN: Rect = Rect::new(0, 0, 300, 40); + +pub(super) fn press(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, mods) +} + +/// The default leader chord (Ctrl+F). Test apps all use the default, so a +/// standalone constructor avoids borrowing `app` inside a `handle_key` +/// call (which would conflict with the `&mut app` argument). +pub(super) fn leader() -> KeyEvent { + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL) +} + +/// Snapshot the byte payloads the app's `FakeBackend` recorded so terminal +/// pass-through and literal-leader tests can assert exact PTY bytes. +pub(super) fn backend_payloads(app: &App) -> Vec> { + app.terminal + .fake_backend_sent() + .expect("test app must use a FakeBackend") +} + +/// A FakeBackend-backed app with one open terminal pane and terminal +/// focus, ready for PTY pass-through assertions. +pub(super) fn app_with_terminal_pane() -> App { + let mut app = app_with_fake_backend(); + app.terminal.create_pane().unwrap(); + app.focus = Focus::Terminal; + app +} + +/// A workspace of projects distinguished by `repo_path`, plus the context +/// `apply_project_request` needs. `Open` is the only request that builds a +/// project, so a default config suffices for the rest. +pub(super) fn workspace_on(paths: &[&str]) -> Workspace { + let project = |p: &str| { + let mut app = app_with_files(vec!["a.rs"]); + app.repo_path = p.to_string(); + app + }; + let mut ws = Workspace::new(leader()); + for p in paths { + assert!(ws.add(project(p))); + } + ws +} + +/// A single-project tab row, matching the app these mouse tests drive. +/// Tests that specifically exercise tab clicks build their own list. +pub(super) fn test_tabs() -> Vec { + vec![".".to_string()] +} + +/// A closed dialog to borrow from, so `test_tab_view` can hand out a +/// `Chrome` without referencing a temporary. +static CLOSED_DIALOG: std::sync::LazyLock = + std::sync::LazyLock::new(crate::ui::status_view::RepoInput::default); + +pub(super) fn test_tab_view(paths: &[String]) -> crate::ui::Chrome<'_> { + crate::ui::Chrome { + repo_paths: paths, + active: 0, + repo_input: &CLOSED_DIALOG, + } +} + +pub(super) fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + } +} + +/// A two-pane terminal app plus each pane's content rect under the +/// standard test screen, so mouse tests can aim events at real geometry. +pub(super) fn app_with_two_panes_and_areas() -> (App, Vec<(backend::PaneId, Rect)>) { + let mut app = app_with_terminal_pane(); + app.terminal.create_pane().unwrap(); + let layout = crate::config::LayoutConfig::default(); + let areas = crate::ui::terminal_content_areas(&app, MOUSE_TEST_SCREEN, &layout); + assert_eq!(areas.len(), 2); + (app, areas) +} + +/// First x column on the hint row that resolves to `want`, scanning with +/// the same hit-test the mouse handler uses. +pub(super) fn hint_x_for(app: &App, want: crate::ui::HintClick) -> u16 { + let row = HINT_TEST_SCREEN.height - 1; + (0..HINT_TEST_SCREEN.width) + .find(|&x| { + crate::ui::hint_click_at(app, test_tab_view(&[]), HINT_TEST_SCREEN, x, row) == Some(want) + }) + .expect("expected a clickable hint segment") +} + +/// First (x, y) cell resolving to tab-clicks target `want`, scanning with +/// the same hit-test the mouse handler uses. +pub(super) fn tab_xy_for(app: &App, want: usize) -> (u16, u16) { + let layout = crate::config::LayoutConfig::default(); + for y in 0..MOUSE_TEST_SCREEN.height { + for x in 0..MOUSE_TEST_SCREEN.width { + if crate::ui::tab_click_at(app, MOUSE_TEST_SCREEN, &layout, x, y) == Some(want) { + return (x, y); + } + } + } + panic!("expected a tab segment targeting pane {want}"); +} \ No newline at end of file diff --git a/src/main_tests/mod.rs b/src/main_tests/mod.rs new file mode 100644 index 0000000..215d027 --- /dev/null +++ b/src/main_tests/mod.rs @@ -0,0 +1,12 @@ +mod helpers; +mod mouse; +mod mouse_clicks; +mod mouse_empty; +mod mouse_release; +mod paste; +mod prefix; +mod prefix_digits; +mod search; +mod swap; +mod terminal; +mod workspace; \ No newline at end of file diff --git a/src/main_tests/mouse.rs b/src/main_tests/mouse.rs new file mode 100644 index 0000000..ac09b94 --- /dev/null +++ b/src/main_tests/mouse.rs @@ -0,0 +1,234 @@ +use super::helpers::*; +use crate::app::Focus; +use crate::mouse::{dispatch_mouse, handle_mouse}; +use crate::workspace::Workspace; +use crossterm::event::MouseEventKind; + +#[test] +fn handle_mouse_click_focuses_the_pane_under_the_pointer() { + let (mut app, areas) = app_with_two_panes_and_areas(); + app.focus = Focus::FileList; + let (first_id, rect) = areas[0]; + let first_idx = app + .terminal + .panes + .iter() + .position(|p| p.id == first_id) + .unwrap(); + assert_ne!(app.terminal.active, first_idx, "click must change focus"); + + let kind = MouseEventKind::Down(crossterm::event::MouseButton::Left); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(kind, rect.x, rect.y), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert_eq!(app.terminal.active, first_idx); + assert_eq!(app.focus, Focus::Terminal); + assert!( + backend_payloads(&app).is_empty(), + "a plain shell never claimed the mouse, so the click byte stream \ + must stay empty" + ); +} + +#[test] +fn handle_mouse_forwards_press_and_release_to_a_mouse_reporting_pane() { + let (mut app, areas) = app_with_two_panes_and_areas(); + let (id, rect) = areas[0]; + app.terminal + .emulators + .get_mut(&id) + .unwrap() + .process(b"\x1b[?1000h\x1b[?1006h"); + + let layout = crate::config::LayoutConfig::default(); + let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); + let up = MouseEventKind::Up(crossterm::event::MouseButton::Left); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, rect.x, rect.y), + MOUSE_TEST_SCREEN, + &layout, + ); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(up, rect.x, rect.y), + MOUSE_TEST_SCREEN, + &layout, + ); + + // The pane's top-left content cell is SGR cell (1, 1). + assert_eq!( + backend_payloads(&app), + vec![b"\x1b[<0;1;1M".to_vec(), b"\x1b[<0;1;1m".to_vec()] + ); +} + +#[test] +fn handle_mouse_click_focuses_the_upper_panels() { + let (mut app, _) = app_with_two_panes_and_areas(); + assert_eq!(app.focus, Focus::Terminal); + let layout = crate::config::LayoutConfig::default(); + let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); + + // Row 1 is the first body row; x=0 is the list, x=60 the diff. + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, 0, 1), + MOUSE_TEST_SCREEN, + &layout, + ); + assert_eq!(app.focus, Focus::FileList); + + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, 60, 1), + MOUSE_TEST_SCREEN, + &layout, + ); + assert_eq!(app.focus, Focus::DiffViewer); + + assert!( + backend_payloads(&app).is_empty(), + "an upper-panel click must not write to any PTY" + ); +} + +#[test] +fn handle_mouse_is_inert_while_a_search_overlay_is_open() { + let (mut app, areas) = app_with_two_panes_and_areas(); + app.focus = Focus::FileList; + app.status_view.search_active = true; + let (_, rect) = areas[0]; + let active_before = app.terminal.active; + + let kind = MouseEventKind::Down(crossterm::event::MouseButton::Left); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(kind, rect.x, rect.y), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert_eq!( + app.focus, + Focus::FileList, + "a search overlay owns the mouse exactly like it owns keys" + ); + assert_eq!(app.terminal.active, active_before); + assert!(backend_payloads(&app).is_empty()); +} + +#[test] +fn handle_mouse_drops_a_release_with_no_pending_press() { + let (mut app, areas) = app_with_two_panes_and_areas(); + let (id, rect) = areas[0]; + app.terminal + .emulators + .get_mut(&id) + .unwrap() + .process(b"\x1b[?1000h\x1b[?1006h"); + + let up = MouseEventKind::Up(crossterm::event::MouseButton::Left); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(up, rect.x, rect.y), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert!( + backend_payloads(&app).is_empty(), + "a pane must not receive a release it never got a press for" + ); +} + +#[test] +fn handle_mouse_ignores_events_outside_pane_content() { + let (mut app, _) = app_with_two_panes_and_areas(); + app.focus = Focus::FileList; + let active_before = app.terminal.active; + + let kind = MouseEventKind::Down(crossterm::event::MouseButton::Left); + // (0, 0) is the upper header row, never pane content. + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(kind, 0, 0), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert_eq!(app.focus, Focus::FileList); + assert_eq!(app.terminal.active, active_before); +} + +#[test] +fn mouse_is_inert_while_the_repo_dialog_is_open() { + let (app, areas) = app_with_two_panes_and_areas(); + let mut ws = Workspace::new(leader()); + ws.add(app); + ws.active_mut().unwrap().focus = Focus::FileList; + ws.start_repo_input(); + let (_, rect) = areas[0]; + let active_before = ws.active().unwrap().terminal.active; + + let kind = MouseEventKind::Down(crossterm::event::MouseButton::Left); + let tabs = test_tabs(); + dispatch_mouse( + &mut ws, + test_tab_view(&tabs), + mouse(kind, rect.x, rect.y), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + true, + ); + + let app = ws.active().unwrap(); + assert_eq!(app.focus, Focus::FileList, "a modal owns all input"); + assert_eq!(app.terminal.active, active_before); +} + +#[test] +fn handle_mouse_wheel_scrolls_the_pane_under_the_pointer_not_the_active_one() { + let (mut app, areas) = app_with_two_panes_and_areas(); + let (id, rect) = areas[0]; + let idx = app.terminal.panes.iter().position(|p| p.id == id).unwrap(); + let active_before = app.terminal.active; + assert_ne!(active_before, idx, "wheel must not require focus"); + // Overflow the pane so its emulator has scrollback to move into. + app.terminal.resize_visible_panes(&[(id, 10, 40)]); + let output = (0..20).fold(Vec::new(), |mut out, i| { + out.extend_from_slice(format!("line{i}\r\n").as_bytes()); + out + }); + app.terminal + .emulators + .get_mut(&id) + .unwrap() + .process(&output); + + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(MouseEventKind::ScrollUp, rect.x, rect.y), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert_eq!(app.terminal.scroll.get(&id).copied(), Some(3)); + assert_eq!( + app.terminal.active, active_before, + "a wheel scroll must not steal focus" + ); +} diff --git a/src/main_tests/mouse_clicks.rs b/src/main_tests/mouse_clicks.rs new file mode 100644 index 0000000..5b042d7 --- /dev/null +++ b/src/main_tests/mouse_clicks.rs @@ -0,0 +1,251 @@ +use super::helpers::*; +use crate::app::Focus; +use crate::key_dispatch::KeyOutcome; +use crate::mouse::handle_mouse; +use crossterm::event::MouseEventKind; + +#[test] +fn handle_mouse_tab_click_jumps_to_that_pane() { + let (mut app, _) = app_with_two_panes_and_areas(); + app.terminal.active = 0; + app.focus = Focus::FileList; + let (x, y) = tab_xy_for(&app, 1); + + let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, x, y), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert_eq!(app.terminal.active, 1); + assert_eq!(app.focus, Focus::Terminal); + assert!( + backend_payloads(&app).is_empty(), + "a tab click is UI-only; nothing may reach a PTY" + ); +} + +#[test] +fn handle_mouse_tab_click_on_hidden_marker_slides_the_window() { + let mut app = app_with_terminal_pane(); + for _ in 0..5 { + app.terminal.create_pane().unwrap(); + } + // 6 panes, window of 4: creation leaves pane 5 active, window [2, 6). + assert_eq!(app.terminal.visible_start, 2); + // The left ` +2 ` marker targets the nearest hidden pane, index 1. + let (x, y) = tab_xy_for(&app, 1); + + let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, x, y), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert_eq!(app.terminal.active, 1); + assert_eq!( + app.terminal.visible_start, 1, + "revealing the clicked marker's pane must slide the window one slot" + ); +} + +#[test] +fn handle_mouse_click_completes_an_armed_swap_with_the_clicked_pane() { + let (mut app, areas) = app_with_two_panes_and_areas(); + app.terminal.active = 0; + let first_id = app.terminal.panes[0].id; + let (clicked_id, rect) = areas[1]; + assert_ne!(clicked_id, first_id); + app.begin_swap_target(); + + let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, rect.x, rect.y), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + // The clicked pane is the swap target, exactly like its digit: the + // previously active pane moved into the clicked slot and stays active. + assert!(!app.awaiting_swap_target()); + assert_eq!(app.terminal.panes[1].id, first_id); + assert_eq!(app.terminal.active, 1); + assert!( + backend_payloads(&app).is_empty(), + "a swap-target click must not be forwarded to any PTY" + ); +} + +#[test] +fn handle_mouse_tab_click_completes_an_armed_swap() { + let (mut app, _) = app_with_two_panes_and_areas(); + app.terminal.active = 0; + let first_id = app.terminal.panes[0].id; + app.begin_swap_target(); + let (x, y) = tab_xy_for(&app, 1); + + let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, x, y), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert!(!app.awaiting_swap_target()); + assert_eq!(app.terminal.panes[1].id, first_id); + assert_eq!(app.terminal.active, 1); +} + +#[test] +fn handle_mouse_press_elsewhere_cancels_an_armed_swap() { + let (mut app, _) = app_with_two_panes_and_areas(); + app.terminal.active = 0; + app.begin_swap_target(); + let order_before: Vec<_> = app.terminal.panes.iter().map(|p| p.id).collect(); + + // (0, 0) is the header row: it names no pane, so the press must + // consume-and-disarm without swapping or moving focus — the same + // rule as a non-digit key. + let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, 0, 0), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert!(!app.awaiting_swap_target()); + let order_after: Vec<_> = app.terminal.panes.iter().map(|p| p.id).collect(); + assert_eq!(order_before, order_after); + assert_eq!(app.terminal.active, 0); +} + +#[test] +fn handle_mouse_hint_click_runs_the_named_leader_command() { + let mut app = app_with_terminal_pane(); + let panes_before = app.terminal.panes.len(); + let x = hint_x_for(&app, crate::ui::HintClick::Leader('t')); + + let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); + let outcome = handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, x, HINT_TEST_SCREEN.height - 1), + HINT_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert!(matches!(outcome, KeyOutcome::Continue)); + assert_eq!( + app.terminal.panes.len(), + panes_before + 1, + "clicking ` t: new pane` must run the same command as the keys" + ); + assert!( + !app.prefix_armed(), + "the synthesized prefix must not linger" + ); +} + +#[test] +fn handle_mouse_hint_click_on_the_leader_label_arms_the_prefix() { + let mut app = app_with_terminal_pane(); + let x = hint_x_for(&app, crate::ui::HintClick::Arm); + + let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); + let outcome = handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, x, HINT_TEST_SCREEN.height - 1), + HINT_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert!(matches!(outcome, KeyOutcome::Continue)); + assert!( + app.prefix_armed(), + "clicking `: leader` must arm the prefix exactly like the chord" + ); + assert!( + backend_payloads(&app).is_empty(), + "arming is UI-only; nothing may reach a PTY" + ); +} + +#[test] +fn handle_mouse_arm_click_then_followup_click_runs_the_command() { + let mut app = app_with_terminal_pane(); + let panes_before = app.terminal.panes.len(); + let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); + let row = HINT_TEST_SCREEN.height - 1; + + let x = hint_x_for(&app, crate::ui::HintClick::Arm); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, x, row), + HINT_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + let x = hint_x_for(&app, crate::ui::HintClick::Plain('t')); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, x, row), + HINT_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert_eq!( + app.terminal.panes.len(), + panes_before + 1, + "arm click + `t` click must open a pane like the key sequence" + ); + assert!(!app.prefix_armed(), "the follow-up must consume the prefix"); +} + +#[test] +fn handle_mouse_hint_click_propagates_redraw_from_the_armed_row() { + let mut app = app_with_terminal_pane(); + app.arm_prefix(); + let x = hint_x_for(&app, crate::ui::HintClick::Plain('r')); + + let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); + let outcome = handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, x, HINT_TEST_SCREEN.height - 1), + HINT_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert!(matches!(outcome, KeyOutcome::Redraw)); + assert!(!app.prefix_armed(), "the follow-up must consume the prefix"); +} + +#[test] +fn handle_mouse_hint_click_never_quits() { + let app = app_with_terminal_pane(); + let row = HINT_TEST_SCREEN.height - 1; + for x in 0..HINT_TEST_SCREEN.width { + let click = crate::ui::hint_click_at(&app, test_tab_view(&[]), HINT_TEST_SCREEN, x, row); + assert!( + !matches!( + click, + Some(crate::ui::HintClick::Leader('q')) | Some(crate::ui::HintClick::Plain('q')) + ), + "x={x} resolves to a quit click" + ); + } +} diff --git a/src/main_tests/mouse_empty.rs b/src/main_tests/mouse_empty.rs new file mode 100644 index 0000000..59eb3bd --- /dev/null +++ b/src/main_tests/mouse_empty.rs @@ -0,0 +1,203 @@ +use super::helpers::*; +use crate::app; +use crate::app::tests::{app_with_fake_backend, app_with_files}; +use crate::key_dispatch::{KeyOutcome, ProjectRequest}; +use crate::mouse::{dispatch_mouse, handle_mouse}; +use crate::workspace::Workspace; +use crossterm::event::MouseEventKind; + +#[test] +fn clicking_a_project_tab_asks_the_workspace_to_switch() { + let mut app = app_with_fake_backend(); + let tabs = vec!["/w/api".to_string(), "/w/web".to_string()]; + // Column 0 of row 0 is the first tab; a click there is the pointer + // equivalent of pressing F1. + let outcome = handle_mouse( + &mut app, + crate::ui::Chrome { + repo_paths: &tabs, + active: 1, + repo_input: &crate::ui::status_view::RepoInput::default(), + }, + mouse( + MouseEventKind::Down(crossterm::event::MouseButton::Left), + 0, + 0, + ), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::Switch(0))); +} + +#[test] +fn the_dialog_still_lets_a_pending_release_through() { + // A modal opening between press and release must not strand the + // pending slot: the pane that saw the press has to see the release, and + // a leftover slot would pair with a later unrelated one. + let (mut app, areas) = app_with_two_panes_and_areas(); + let (id, rect) = areas[0]; + // Only a pane whose program asked for mouse reports records a pending + // press, so opt it in. + app.terminal + .emulators + .get_mut(&id) + .unwrap() + .process(b"\x1b[?1000h\x1b[?1006h"); + let mut ws = Workspace::new(leader()); + ws.add(app); + let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); + let up = MouseEventKind::Up(crossterm::event::MouseButton::Left); + let tabs = test_tabs(); + + dispatch_mouse( + &mut ws, + test_tab_view(&tabs), + mouse(down, rect.x, rect.y), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + true, + ); + assert!(ws.active().unwrap().pending_mouse_press.is_some()); + + ws.start_repo_input(); + dispatch_mouse( + &mut ws, + test_tab_view(&tabs), + mouse(up, rect.x, rect.y), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + true, + ); + + assert!( + ws.active().unwrap().pending_mouse_press.is_none(), + "the dialog must not swallow the release" + ); +} + +#[test] +fn switching_projects_releases_a_pending_press_to_its_own_pane() { + // The old PTY is still alive; without a release it sits in a drag or + // selection state forever, since drag reports are never forwarded. + let (mut app, areas) = app_with_two_panes_and_areas(); + let (id, rect) = areas[0]; + app.terminal + .emulators + .get_mut(&id) + .unwrap() + .process(b"\x1b[?1000h\x1b[?1006h"); + let mut ws = Workspace::new(leader()); + ws.add(app); + let tabs = test_tabs(); + dispatch_mouse( + &mut ws, + test_tab_view(&tabs), + mouse( + MouseEventKind::Down(crossterm::event::MouseButton::Left), + rect.x, + rect.y, + ), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + true, + ); + assert!(ws.active().unwrap().pending_mouse_press.is_some()); + + ws.add(app_with_files(vec!["b.rs"])); + + let old = &ws.projects()[0]; + assert!(old.pending_mouse_press.is_none()); + assert_eq!( + backend_payloads(old), + vec![b"\x1b[<0;1;1M".to_vec(), b"\x1b[<0;1;1m".to_vec()], + "the pane must see its button-up, not just lose the record" + ); +} + +#[test] +fn clicking_the_empty_screen_open_hint_raises_the_dialog() { + // It is the only action that screen offers, so it must work by pointer + // as well as by key — and it renders inverted, advertising as much. + let mut ws = Workspace::new(leader()); + let tabs: Vec = Vec::new(); + let label = app::leader_label_of(leader()); + let x = (0..MOUSE_TEST_SCREEN.width) + .find(|&x| { + crate::ui::empty_hint_click_at( + MOUSE_TEST_SCREEN, + &label, + false, + true, + x, + MOUSE_TEST_SCREEN.height - 1, + ) + .is_some() + }) + .expect("the open hint is clickable"); + + let outcome = dispatch_mouse( + &mut ws, + test_tab_view(&tabs), + mouse( + MouseEventKind::Down(crossterm::event::MouseButton::Left), + x, + MOUSE_TEST_SCREEN.height - 1, + ), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + true, + ); + + assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::OpenDialog)); +} + +#[test] +fn clicking_the_open_hint_while_armed_disarms_the_prefix() { + // The armed row lays out differently (chip plus bare keys), so the hit + // test must measure that layout — and the click must disarm, or the + // next key after the dialog closes resolves as a stale follow-up. + let mut ws = Workspace::new(leader()); + ws.arm_prefix(); + let tabs: Vec = Vec::new(); + let label = app::leader_label_of(leader()); + let row = MOUSE_TEST_SCREEN.height - 1; + let x = (0..MOUSE_TEST_SCREEN.width) + .find(|&x| { + matches!( + crate::ui::empty_hint_click_at(MOUSE_TEST_SCREEN, &label, true, true, x, row), + Some(crate::ui::HintClick::Plain('o')) + ) + }) + .expect("the armed open hint is clickable"); + + let outcome = dispatch_mouse( + &mut ws, + test_tab_view(&tabs), + mouse( + MouseEventKind::Down(crossterm::event::MouseButton::Left), + x, + row, + ), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + true, + ); + + assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::OpenDialog)); + assert!(!ws.prefix_armed(), "the click must disarm the prefix"); +} + +#[test] +fn the_empty_hint_is_inert_when_mouse_capture_is_disabled() { + // The row renders plain in that case, and a browser mouse event still + // reaches this path — a label that does not advertise itself as + // clickable must not act like one. + let label = app::leader_label_of(leader()); + let row = MOUSE_TEST_SCREEN.height - 1; + + assert!((0..MOUSE_TEST_SCREEN.width).all(|x| { + crate::ui::empty_hint_click_at(MOUSE_TEST_SCREEN, &label, false, false, x, row).is_none() + })); +} diff --git a/src/main_tests/mouse_release.rs b/src/main_tests/mouse_release.rs new file mode 100644 index 0000000..e12e0fc --- /dev/null +++ b/src/main_tests/mouse_release.rs @@ -0,0 +1,126 @@ +use super::helpers::*; +use crate::mouse::handle_mouse; +use crossterm::event::MouseEventKind; + +#[test] +fn handle_mouse_release_follows_the_pressed_pane_when_the_pointer_moves_away() { + let (mut app, areas) = app_with_two_panes_and_areas(); + let (pressed_id, pressed_rect) = areas[0]; + let (_, other_rect) = areas[1]; + // Only the pressed pane is mouse-aware: any release payload proves + // routing went to the pressed pane, not the pane under the pointer. + app.terminal + .emulators + .get_mut(&pressed_id) + .unwrap() + .process(b"\x1b[?1000h\x1b[?1006h"); + + let layout = crate::config::LayoutConfig::default(); + let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); + let up = MouseEventKind::Up(crossterm::event::MouseButton::Left); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, pressed_rect.x, pressed_rect.y), + MOUSE_TEST_SCREEN, + &layout, + ); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(up, other_rect.x, other_rect.y), + MOUSE_TEST_SCREEN, + &layout, + ); + + // The release cell is clamped into the pressed pane's rect. + let col = other_rect.x.clamp(pressed_rect.x, pressed_rect.right() - 1) - pressed_rect.x + 1; + let row = other_rect + .y + .clamp(pressed_rect.y, pressed_rect.bottom() - 1) + - pressed_rect.y + + 1; + let release = format!("\x1b[<0;{col};{row}m").into_bytes(); + assert_eq!( + backend_payloads(&app), + vec![b"\x1b[<0;1;1M".to_vec(), release] + ); + assert!(app.pending_mouse_press.is_none()); +} + +#[test] +fn handle_mouse_completes_a_pending_release_even_while_the_repo_modal_is_open() { + let (mut app, areas) = app_with_two_panes_and_areas(); + let (id, rect) = areas[0]; + app.terminal + .emulators + .get_mut(&id) + .unwrap() + .process(b"\x1b[?1000h\x1b[?1006h"); + let layout = crate::config::LayoutConfig::default(); + let down = MouseEventKind::Down(crossterm::event::MouseButton::Left); + let up = MouseEventKind::Up(crossterm::event::MouseButton::Left); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, rect.x, rect.y), + MOUSE_TEST_SCREEN, + &layout, + ); + + // A release must reach the pane that saw the press even when a modal + // opened in between, and the pending slot must not go stale. The + // release path runs before any modal guard, so driving it directly is + // the same code path a real dialog would take. + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(up, rect.x, rect.y), + MOUSE_TEST_SCREEN, + &layout, + ); + + assert_eq!( + backend_payloads(&app), + vec![b"\x1b[<0;1;1M".to_vec(), b"\x1b[<0;1;1m".to_vec()] + ); + assert!(app.pending_mouse_press.is_none()); +} + +#[test] +fn handle_mouse_release_pairs_by_the_stored_press_button() { + let (mut app, areas) = app_with_two_panes_and_areas(); + let (id, rect) = areas[0]; + app.terminal + .emulators + .get_mut(&id) + .unwrap() + .process(b"\x1b[?1000h\x1b[?1006h"); + + // Press Right, but the terminal reports the release as Left — the + // legacy encodings don't carry the button on release, so crossterm + // may fall back to Left. The pane must still see a Right release. + let layout = crate::config::LayoutConfig::default(); + let down = MouseEventKind::Down(crossterm::event::MouseButton::Right); + let up = MouseEventKind::Up(crossterm::event::MouseButton::Left); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(down, rect.x, rect.y), + MOUSE_TEST_SCREEN, + &layout, + ); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(up, rect.x, rect.y), + MOUSE_TEST_SCREEN, + &layout, + ); + + assert_eq!( + backend_payloads(&app), + vec![b"\x1b[<2;1;1M".to_vec(), b"\x1b[<2;1;1m".to_vec()] + ); + assert!(app.pending_mouse_press.is_none()); +} diff --git a/src/main_tests/paste.rs b/src/main_tests/paste.rs new file mode 100644 index 0000000..c8ed0c7 --- /dev/null +++ b/src/main_tests/paste.rs @@ -0,0 +1,87 @@ +use super::helpers::*; +use crate::app::Focus; +use crate::app::tests::app_with_files; +use crate::key_dispatch::handle_key; +use crate::paste::{dispatch_paste, handle_paste}; + +#[test] +fn paste_while_prefix_armed_cancels_prefix() { + let mut app = app_with_terminal_pane(); + let _ = handle_key(&mut app, leader()); + assert!(app.prefix_armed()); + + handle_paste(&mut app, "hello"); + + assert!( + !app.prefix_armed(), + "a paste must resolve (cancel) the armed prefix" + ); +} + +#[test] +fn terminal_paste_wraps_only_when_bracketed_mode_enabled() { + let mut app = app_with_terminal_pane(); + // The running program enables bracketed paste (DECSET 2004). + for emulator in app.terminal.emulators.values_mut() { + emulator.process(b"\x1b[?2004h"); + } + + handle_paste(&mut app, "hi"); + + assert_eq!( + backend_payloads(&app).concat(), + b"\x1b[200~hi\x1b[201~".to_vec(), + "paste must be bracketed when the program enabled DECSET 2004" + ); +} + +#[test] +fn terminal_paste_sends_raw_when_bracketed_mode_disabled() { + let mut app = app_with_terminal_pane(); + + handle_paste(&mut app, "hi"); + + assert_eq!( + backend_payloads(&app).concat(), + b"hi".to_vec(), + "without DECSET 2004 the markers must not be sent as literal input" + ); +} + +#[test] +fn handle_paste_into_file_search_strips_control_chars() { + // Regression for e21c449 + 4084760: paste into the file-search + // overlay drops control characters (newlines, tabs, bells) before + // appending to the query. + let mut app = app_with_files(vec!["alpha.rs", "beta.rs"]); + app.focus = Focus::FileList; + app.start_search(); + + handle_paste(&mut app, "al\nph\ta\x07"); + + assert_eq!(app.status_view.search_query.as_str(), "alpha"); +} + +#[test] +fn handle_paste_into_diff_search_strips_control_chars() { + let mut app = app_with_files(vec!["alpha.rs"]); + app.focus = Focus::DiffViewer; + app.diff.start_search(); + + handle_paste(&mut app, "fn\rname\x08"); + + assert_eq!(app.diff.search.query.as_str(), "fnname"); +} + +#[test] +fn paste_into_the_dialog_strips_control_chars() { + let mut ws = workspace_on(&["/a"]); + ws.start_repo_input(); + // `start_repo_input` prefills with the active repo path, and + // `repo_input_push` preserves existing content, so reset first. + ws.repo_input.buf.clear(); + + dispatch_paste(&mut ws, "/tmp\n/repo\x07"); + + assert_eq!(ws.repo_input.buf, "/tmp/repo"); +} diff --git a/src/main_tests/prefix.rs b/src/main_tests/prefix.rs new file mode 100644 index 0000000..88843a9 --- /dev/null +++ b/src/main_tests/prefix.rs @@ -0,0 +1,283 @@ +use super::helpers::*; +use crate::app::Focus; +use crate::app::tests::app_with_files; +use crate::key_dispatch::{KeyOutcome, ProjectRequest, handle_key, dispatch_key}; +use crate::workspace::Workspace; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +#[test] +fn handle_key_ignores_release_events() { + // Regression for 4faacce: Windows / kitty keyboard protocol emits + // Press+Release pairs for every keystroke. Only Press must trigger + // app mutations; a Release must never act. + let mut app = app_with_files(vec!["a.rs"]); + let release = KeyEvent::new_with_kind( + KeyCode::Char('f'), + KeyModifiers::CONTROL, + crossterm::event::KeyEventKind::Release, + ); + + let outcome = handle_key(&mut app, release); + + assert!(matches!(outcome, KeyOutcome::Continue)); +} + +#[test] +fn handle_key_leader_then_q_quits() { + let mut app = app_with_files(vec!["a.rs"]); + + let first = handle_key(&mut app, leader()); + assert!(matches!(first, KeyOutcome::Continue)); + assert!(app.prefix_armed(), "leader must arm the prefix"); + + let second = handle_key(&mut app, press(KeyCode::Char('q'), KeyModifiers::NONE)); + assert!(matches!(second, KeyOutcome::Quit)); + assert!(!app.prefix_armed(), "prefix must disarm after follow-up"); +} + +#[test] +fn handle_key_bare_ctrl_f_arms_prefix_and_does_not_quit() { + // Ctrl+F is the default leader: pressing it alone arms the prefix and + // never quits nightcrow on its own (quitting is ` q`). + let mut app = app_with_terminal_pane(); + + let outcome = handle_key(&mut app, press(KeyCode::Char('f'), KeyModifiers::CONTROL)); + + assert!(matches!(outcome, KeyOutcome::Continue)); + assert!(app.prefix_armed(), "the leader press arms the prefix"); +} + +#[test] +fn leader_x_asks_the_workspace_to_close_the_project() { + let mut app = app_with_files(vec!["a.rs"]); + let _ = handle_key(&mut app, leader()); + + let outcome = handle_key(&mut app, press(KeyCode::Char('x'), KeyModifiers::NONE)); + + assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::Close)); + assert!(!app.prefix_armed(), "prefix must disarm after follow-up"); +} + +#[test] +fn leader_o_asks_the_workspace_to_raise_the_dialog() { + let mut app = app_with_files(vec!["a.rs"]); + let _ = handle_key(&mut app, leader()); + + let outcome = handle_key(&mut app, press(KeyCode::Char('o'), KeyModifiers::NONE)); + + // The dialog is workspace state, so a handler holding one project can + // only ask for it. + assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::OpenDialog)); +} + +#[test] +fn a_doubled_leader_on_the_empty_screen_does_not_quit() { + // ` ` sends a literal leader to a pane on the project screen. + // Here there is none, but the follow-up must still not reach the action + // table: with the default ctrl+f leader it would match `f` and toggle + // fullscreen. + let mut ws = Workspace::new(leader()); + + let _ = dispatch_key(&mut ws, leader()); + let outcome = dispatch_key(&mut ws, leader()); + + assert_eq!(outcome, KeyOutcome::Continue); +} + +#[test] +fn handle_key_leader_esc_cancels() { + let mut app = app_with_files(vec!["a.rs"]); + let _ = handle_key(&mut app, leader()); + assert!(app.prefix_armed()); + + let outcome = handle_key(&mut app, press(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(outcome, KeyOutcome::Continue)); + assert!(!app.prefix_armed(), "Esc must cancel the armed prefix"); +} + +#[test] +fn handle_key_leader_ctrl_c_cancels() { + let mut app = app_with_terminal_pane(); + let _ = handle_key(&mut app, leader()); + assert!(app.prefix_armed()); + + let outcome = handle_key(&mut app, press(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(matches!(outcome, KeyOutcome::Continue)); + assert!(!app.prefix_armed(), "Ctrl+C must cancel the armed prefix"); + // The cancel is consumed, never leaked to the PTY. + assert!( + backend_payloads(&app).is_empty(), + "Ctrl+C cancel must not send bytes to the PTY" + ); +} + +#[test] +fn handle_key_ctrl_super_leader_passes_through() { + // A Super/Hyper/Meta bit on top of Ctrl+ (enhanced keyboard + // protocols report these) is a different chord, so it must reach the + // PTY rather than arm the prefix. + let mut app = app_with_terminal_pane(); + + let outcome = handle_key( + &mut app, + press( + KeyCode::Char('f'), + KeyModifiers::CONTROL | KeyModifiers::SUPER, + ), + ); + + assert!(matches!(outcome, KeyOutcome::Continue)); + assert!( + !app.prefix_armed(), + "Ctrl+Super+leader must not arm the prefix" + ); +} + +#[test] +fn handle_key_ctrl_alt_leader_passes_through() { + // Ctrl+Alt+ carries an extra modifier, so it is NOT the leader + // chord — it must reach the PTY rather than arm the prefix. + let mut app = app_with_terminal_pane(); + + let outcome = handle_key( + &mut app, + press( + KeyCode::Char('f'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ), + ); + + assert!(matches!(outcome, KeyOutcome::Continue)); + assert!( + !app.prefix_armed(), + "Ctrl+Alt+leader must not arm the prefix" + ); + assert!( + !backend_payloads(&app).is_empty(), + "Ctrl+Alt+leader must pass through to the PTY" + ); +} + +#[test] +fn leader_leader_sends_literal_leader_even_when_leader_is_ctrl_c() { + // With a `ctrl+c` leader, `` must still reach the PTY + // as a literal Ctrl+C (0x03); the leader-again path takes precedence + // over the Ctrl+C cancel path. + let mut app = app_with_terminal_pane(); + app.leader = press(KeyCode::Char('c'), KeyModifiers::CONTROL); + + let _ = handle_key(&mut app, press(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(app.prefix_armed()); + + let outcome = handle_key(&mut app, press(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(matches!(outcome, KeyOutcome::Continue)); + assert!(!app.prefix_armed()); + assert_eq!( + backend_payloads(&app).concat(), + vec![0x03], + " must deliver a literal Ctrl+C to the PTY" + ); +} + +#[test] +fn handle_key_leader_unmapped_followup_cancels() { + let mut app = app_with_terminal_pane(); + let _ = handle_key(&mut app, leader()); + assert!(app.prefix_armed()); + + let outcome = handle_key(&mut app, press(KeyCode::Char('z'), KeyModifiers::NONE)); + assert!(matches!(outcome, KeyOutcome::Continue)); + assert!(!app.prefix_armed()); + // The unmapped follow-up is consumed, NOT forwarded to the PTY. + assert!( + backend_payloads(&app).is_empty(), + "unmapped follow-up must be dropped, not sent to the PTY" + ); +} + +#[test] +fn handle_key_double_leader_sends_literal_to_pty() { + let mut app = app_with_terminal_pane(); + let _ = handle_key(&mut app, leader()); + assert!(app.prefix_armed()); + + let outcome = handle_key(&mut app, leader()); + assert!(matches!(outcome, KeyOutcome::Continue)); + assert!(!app.prefix_armed()); + // Ctrl+F encodes to 0x06 (ACK) — the literal leader byte. + assert_eq!(backend_payloads(&app), vec![vec![0x06]]); +} + +#[test] +fn handle_key_leader_t_opens_pane() { + let mut app = app_with_terminal_pane(); + let before = app.terminal.panes.len(); + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('t'), KeyModifiers::NONE)); + assert_eq!(app.terminal.panes.len(), before + 1); +} + +#[test] +fn handle_key_leader_w_closes_pane_with_terminal_focus() { + let mut app = app_with_terminal_pane(); + app.terminal.create_pane().unwrap(); + let before = app.terminal.panes.len(); + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('w'), KeyModifiers::NONE)); + assert_eq!(app.terminal.panes.len(), before - 1); +} + +#[test] +fn handle_key_leader_w_closes_pane_in_terminal_fullscreen() { + // Fullscreen routes the follow-up through `prefix_action_fullscreen`; + // `w` must keep closing there (focus is Terminal while it fills the + // body). + let mut app = app_with_terminal_pane(); + app.terminal.create_pane().unwrap(); + app.terminal.fullscreen = crate::runtime::terminal::TerminalFullscreen::Grid; + let before = app.terminal.panes.len(); + + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!(app.terminal.panes.len(), before - 1); +} + +#[test] +fn handle_key_leader_w_is_ignored_without_terminal_focus() { + // Without terminal focus the active pane is rendered identically to + // the others, so ` w` must not close an invisible target. + // The follow-up is still consumed: prefix disarmed, nothing forwarded. + let mut app = app_with_terminal_pane(); + app.focus = Focus::FileList; + let before = app.terminal.panes.len(); + + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!( + app.terminal.panes.len(), + before, + "leader+w must be a no-op outside terminal focus" + ); + assert!(!app.prefix_armed()); + assert!( + backend_payloads(&app).is_empty(), + "the consumed follow-up must not reach the PTY" + ); +} + +#[test] +fn handle_key_leader_l_toggles_log_view_from_upper_focus() { + // Leader commands work in upper (file list) focus too, not just + // terminal focus. + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::FileList; + let before = app.mode; + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('l'), KeyModifiers::NONE)); + assert_ne!( + app.mode, before, + "leader+l must toggle the view in upper focus" + ); +} diff --git a/src/main_tests/prefix_digits.rs b/src/main_tests/prefix_digits.rs new file mode 100644 index 0000000..07cf82d --- /dev/null +++ b/src/main_tests/prefix_digits.rs @@ -0,0 +1,113 @@ +use super::helpers::*; +use crate::app::{Focus, ViewMode}; +use crate::app::tests::app_with_files; +use crate::key_dispatch::handle_key; +use crossterm::event::{KeyCode, KeyModifiers}; + +#[test] +fn handle_key_leader_digits_mirror_focus_and_pane_fkeys() { + // Digits mirror the no-prefix F-keys one-for-one: 1=F1 (file list), + // 2=F2 (diff viewer), 3..9,0=F3..F10 (terminal panes 0..7). The + // dispatcher consumes the digit (disarming the prefix) instead of + // forwarding it to the PTY. + let mut app = app_with_terminal_pane(); + app.terminal + .create_pane_with(Some("echo two"), Some("two")) + .unwrap(); + // Pad up to 8 panes so ` 0` (pane index 7) below is a real + // switch, not a no-op against an out-of-range index. + for i in 2..8 { + app.terminal + .create_pane_with(None, Some(&format!("pane{i}"))) + .unwrap(); + } + assert_eq!(app.terminal.panes.len(), 8); + app.switch_pane(0); + + // 1 → focus file list (mirrors F1) + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('1'), KeyModifiers::NONE)); + assert_eq!(app.focus, Focus::FileList, "leader+1 must mirror F1"); + + // 2 → focus diff viewer (mirrors F2) + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('2'), KeyModifiers::NONE)); + assert_eq!(app.focus, Focus::DiffViewer, "leader+2 must mirror F2"); + + // 4 → terminal pane 1 (mirrors F4) + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('4'), KeyModifiers::NONE)); + assert_eq!(app.terminal.active, 1, "leader+4 must mirror F4 → pane 1"); + + // 0 → terminal pane 7 (mirrors F10) + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('0'), KeyModifiers::NONE)); + assert_eq!(app.terminal.active, 7, "leader+0 must mirror F10 → pane 7"); + + assert!( + !app.prefix_armed(), + "a mapped follow-up must disarm the prefix" + ); + assert!( + backend_payloads(&app).is_empty(), + "a consumed leader digit must not reach the PTY" + ); +} + +#[test] +fn handle_key_leader_b_toggles_tree_mode() { + // ` b` enters Tree mode and a second ` b` returns to + // Status. Uses the live cwd repo (the crate root) for the root read. + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::FileList; + assert_eq!(app.mode, ViewMode::Status); + + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('b'), KeyModifiers::NONE)); + assert_eq!(app.mode, ViewMode::Tree); + + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('b'), KeyModifiers::NONE)); + assert_eq!(app.mode, ViewMode::Status); +} + +#[test] +fn handle_key_tree_right_left_expand_and_collapse() { + let (dir, path) = crate::test_util::make_repo(); + let root = std::path::Path::new(&path); + std::fs::create_dir(root.join("sub")).unwrap(); + std::fs::write(root.join("sub").join("f.txt"), "x").unwrap(); + + let mut app = app_with_files(vec![]); + app.repo_path = path.clone(); + app.focus = Focus::FileList; + app.enter_tree_mode(); + let idx = app + .tree_view + .visible_rows() + .iter() + .position(|r| r.path == "sub") + .unwrap(); + app.tree_view.selected = idx; + + // Right expands the directory. + let _ = handle_key(&mut app, press(KeyCode::Right, KeyModifiers::NONE)); + assert!( + app.tree_view + .visible_rows() + .iter() + .any(|r| r.path == "sub/f.txt"), + "Right must expand the selected directory" + ); + + // Left collapses it again. + let _ = handle_key(&mut app, press(KeyCode::Left, KeyModifiers::NONE)); + assert!( + !app.tree_view + .visible_rows() + .iter() + .any(|r| r.path == "sub/f.txt"), + "Left must collapse the expanded directory" + ); + drop(dir); +} diff --git a/src/main_tests/search.rs b/src/main_tests/search.rs new file mode 100644 index 0000000..6727686 --- /dev/null +++ b/src/main_tests/search.rs @@ -0,0 +1,96 @@ +use super::helpers::*; +use crate::app::{DiffPaneView, Focus}; +use crate::app::tests::app_with_files; +use crate::key_dispatch::handle_key; +use crossterm::event::{KeyCode, KeyModifiers}; + +#[test] +fn handle_key_overlay_blocks_leader_when_diff_search_active() { + // While a search overlay is open the leader is typed/consumed by the + // overlay, never arming the prefix or firing an app command. + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::DiffViewer; + app.diff.start_search(); + assert!(app.diff.search.active); + let before = app.mode; + + let _ = handle_key(&mut app, leader()); + assert!(!app.prefix_armed(), "leader must not arm behind an overlay"); + let _ = handle_key(&mut app, press(KeyCode::Char('l'), KeyModifiers::NONE)); + + assert_eq!( + app.mode, before, + "no app command may fire behind an overlay" + ); + assert!(app.diff.search.active, "diff search must remain open"); +} + +#[test] +fn handle_key_file_search_rejects_command_modifier_chars() { + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::FileList; + app.start_search(); + + let ctrl_x = press(KeyCode::Char('x'), KeyModifiers::CONTROL); + let _ = handle_key(&mut app, ctrl_x); + + assert!(app.status_view.search_query.is_empty()); +} + +#[test] +fn handle_key_diff_search_rejects_command_modifier_chars() { + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::DiffViewer; + app.diff.start_search(); + + let alt_x = press(KeyCode::Char('x'), KeyModifiers::ALT); + let _ = handle_key(&mut app, alt_x); + + assert!(app.diff.search.query.is_empty()); +} + +#[test] +fn handle_key_status_search_shortcut_requires_no_command_modifier() { + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::FileList; + + let ctrl_slash = press(KeyCode::Char('/'), KeyModifiers::CONTROL); + let _ = handle_key(&mut app, ctrl_slash); + + assert!(!app.status_view.search_active); +} + +#[test] +fn handle_key_diff_file_toggle_requires_no_command_modifier() { + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::DiffViewer; + + let alt_v = press(KeyCode::Char('v'), KeyModifiers::ALT); + let _ = handle_key(&mut app, alt_v); + + assert_eq!(app.diff.view, DiffPaneView::Diff); +} + +#[test] +fn handle_key_diff_search_from_split_returns_to_unified_overlay() { + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::DiffViewer; + app.diff.view = DiffPaneView::Split; + + let _ = handle_key(&mut app, press(KeyCode::Char('/'), KeyModifiers::NONE)); + + assert_eq!(app.diff.view, DiffPaneView::Diff); + assert!(app.diff.search.active); +} + +#[test] +fn handle_key_diff_next_match_from_split_returns_to_unified_when_query_exists() { + let mut app = app_with_files(vec!["a.rs"]); + app.focus = Focus::DiffViewer; + app.diff.view = DiffPaneView::Split; + app.diff.search.query.set("needle"); + + let _ = handle_key(&mut app, press(KeyCode::Char('n'), KeyModifiers::NONE)); + + assert_eq!(app.diff.view, DiffPaneView::Diff); +} diff --git a/src/main_tests/swap.rs b/src/main_tests/swap.rs new file mode 100644 index 0000000..3e9f5bd --- /dev/null +++ b/src/main_tests/swap.rs @@ -0,0 +1,114 @@ +use super::helpers::*; +use crate::app::Focus; +use crate::key_dispatch::handle_key; +use crossterm::event::{KeyCode, KeyModifiers}; + +#[test] +fn handle_key_leader_s_then_digit_swaps_active_pane() { + // ` s 5` swaps the active pane with pane index 2 (digit 5 + // mirrors F5 → pane 2) and moves focus to follow it. + let mut app = app_with_terminal_pane(); + for i in 1..3 { + app.terminal + .create_pane_with(None, Some(&format!("pane{i}"))) + .unwrap(); + } + assert_eq!(app.terminal.panes.len(), 3); + app.switch_pane(0); + let moving_id = app.terminal.panes[0].id; + let target_id = app.terminal.panes[2].id; + + // ` s` arms swap mode without acting. + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('s'), KeyModifiers::NONE)); + assert!(app.awaiting_swap_target(), "leader+s must arm swap mode"); + assert!(!app.prefix_armed(), "swap mode must clear the prefix"); + + // The digit resolves the swap. + let _ = handle_key(&mut app, press(KeyCode::Char('5'), KeyModifiers::NONE)); + assert!( + !app.awaiting_swap_target(), + "the digit must disarm swap mode" + ); + assert_eq!(app.terminal.panes[0].id, target_id); + assert_eq!(app.terminal.panes[2].id, moving_id); + assert_eq!(app.terminal.active, 2, "focus follows the moved pane"); + assert!( + backend_payloads(&app).is_empty(), + "a consumed swap digit must not reach the PTY" + ); +} + +#[test] +fn handle_key_leader_s_esc_cancels_without_swapping() { + let mut app = app_with_terminal_pane(); + app.terminal.create_pane_with(None, Some("two")).unwrap(); + app.switch_pane(0); + let order: Vec<_> = app.terminal.panes.iter().map(|p| p.id).collect(); + + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('s'), KeyModifiers::NONE)); + let _ = handle_key(&mut app, press(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!app.awaiting_swap_target()); + assert_eq!(app.terminal.active, 0); + let after: Vec<_> = app.terminal.panes.iter().map(|p| p.id).collect(); + assert_eq!(order, after, "esc must leave pane order unchanged"); +} + +#[test] +fn handle_key_leader_s_non_digit_cancels() { + // A non-pane follow-up (e.g. a letter) cancels swap mode and is + // consumed rather than swapping or reaching the PTY. + let mut app = app_with_terminal_pane(); + app.terminal.create_pane_with(None, Some("two")).unwrap(); + app.switch_pane(0); + let order: Vec<_> = app.terminal.panes.iter().map(|p| p.id).collect(); + + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('s'), KeyModifiers::NONE)); + let _ = handle_key(&mut app, press(KeyCode::Char('z'), KeyModifiers::NONE)); + + assert!(!app.awaiting_swap_target()); + let after: Vec<_> = app.terminal.panes.iter().map(|p| p.id).collect(); + assert_eq!(order, after); + assert!(backend_payloads(&app).is_empty()); +} + +#[test] +fn handle_key_leader_s_without_terminal_focus_does_not_arm() { + let mut app = app_with_terminal_pane(); + app.terminal.create_pane_with(None, Some("two")).unwrap(); + app.focus = Focus::FileList; + + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('s'), KeyModifiers::NONE)); + + assert!( + !app.awaiting_swap_target(), + "leader+s must not arm swap mode without terminal focus" + ); + assert!( + !app.prefix_armed(), + "the follow-up must still disarm the prefix" + ); + assert!( + backend_payloads(&app).is_empty(), + "the consumed chord must not reach the PTY" + ); +} + +#[test] +fn handle_key_leader_s_with_single_pane_does_not_arm() { + let mut app = app_with_terminal_pane(); + assert_eq!(app.terminal.panes.len(), 1); + + let _ = handle_key(&mut app, leader()); + let _ = handle_key(&mut app, press(KeyCode::Char('s'), KeyModifiers::NONE)); + + assert!( + !app.awaiting_swap_target(), + "leader+s must not arm swap mode with a single pane" + ); + assert!(backend_payloads(&app).is_empty()); +} diff --git a/src/main_tests/terminal.rs b/src/main_tests/terminal.rs new file mode 100644 index 0000000..28eed4f --- /dev/null +++ b/src/main_tests/terminal.rs @@ -0,0 +1,38 @@ +use super::helpers::*; +use crate::key_dispatch::handle_key; +use crossterm::event::{KeyCode, KeyModifiers}; + +#[test] +fn handle_key_terminal_ctrl_w_passes_through_to_pty() { + // Ctrl+W (and friends) are prompt-editing keys that must now reach + // the running program as control bytes instead of closing the pane. + let mut app = app_with_terminal_pane(); + + let _ = handle_key(&mut app, press(KeyCode::Char('w'), KeyModifiers::CONTROL)); + + // Ctrl+W encodes to 0x17 (ETB). + assert_eq!(backend_payloads(&app), vec![vec![0x17]]); +} + +#[test] +fn handle_key_terminal_ctrl_app_keys_all_pass_through() { + // Every former bare-Ctrl app shortcut now reaches the PTY untouched. + // Ctrl+F is excluded: it is the default leader, so it is intercepted to + // arm the prefix rather than passed through (see the bare-Ctrl+F test). + for (c, byte) in [ + ('t', 0x14u8), + ('w', 0x17), + ('q', 0x11), + ('l', 0x0c), + ('p', 0x10), + ('o', 0x0f), + ] { + let mut app = app_with_terminal_pane(); + let _ = handle_key(&mut app, press(KeyCode::Char(c), KeyModifiers::CONTROL)); + assert_eq!( + backend_payloads(&app), + vec![vec![byte]], + "ctrl+{c} must pass through to the PTY" + ); + } +} diff --git a/src/main_tests/workspace.rs b/src/main_tests/workspace.rs new file mode 100644 index 0000000..05a1550 --- /dev/null +++ b/src/main_tests/workspace.rs @@ -0,0 +1,118 @@ +use super::helpers::*; +use crate::app::tests::app_with_files; +use crate::key_dispatch::{KeyOutcome, ProjectContext, ProjectRequest, dispatch_key, handle_key}; +use crate::event_loop::apply_project_request; +use crate::workspace::Workspace; +use crossterm::event::{KeyCode, KeyModifiers}; + +#[test] +fn opening_a_repo_another_tab_holds_focuses_it_instead_of_duplicating() { + let cfg = crate::config::Config::default(); + let ctx = ProjectContext { + cfg: &cfg, + startup_commands: &[], + leader: leader(), + }; + let mut ws = workspace_on(&["/a", "/b"]); + + apply_project_request(&mut ws, &ctx, ProjectRequest::Open("/a".to_string())); + + assert_eq!(ws.active_index(), 0); + assert_eq!(ws.projects().len(), 2); +} + +#[test] +fn f_key_asks_the_workspace_to_switch_project() { + let mut app = app_with_files(vec!["a.rs"]); + + // Bare F-keys need no prefix, and the request is emitted rather than + // acted on: the handler holds one project and cannot reach the tabs. + let outcome = handle_key(&mut app, press(KeyCode::F(3), KeyModifiers::NONE)); + + assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::Switch(2))); +} + +#[test] +fn confirming_the_dialog_asks_the_workspace_to_open_that_path() { + let (_dir, path) = crate::test_util::make_repo(); + let mut ws = workspace_on(&["/a"]); + ws.start_repo_input(); + for c in path.chars() { + ws.repo_input_push(c); + } + + let outcome = dispatch_key(&mut ws, press(KeyCode::Enter, KeyModifiers::NONE)); + + // The emitted path is the *resolved* workdir, not the typed text — + // on macOS the temp dir reaches it through a /var -> /private/var + // symlink, and the workdir carries a trailing separator. + let expected = crate::git::resolve_repo_path(std::path::Path::new(&path)) + .to_string_lossy() + .to_string(); + assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::Open(expected))); + // The current project still points at its original repo: confirming + // opens a tab, it never repoints this one. + assert_eq!(ws.active().unwrap().repo_path, "/a"); + assert!(!ws.repo_input.active, "dialog must close on success"); +} + +#[test] +fn confirming_the_dialog_on_a_bad_path_keeps_it_open() { + let mut ws = workspace_on(&["/a"]); + ws.start_repo_input(); + for c in "/definitely/not/a/directory".chars() { + ws.repo_input_push(c); + } + + let outcome = dispatch_key(&mut ws, press(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(outcome, KeyOutcome::Continue); + assert!(ws.repo_input.active, "a rejected path must stay editable"); +} + +#[test] +fn the_empty_screen_opens_the_dialog_and_quits() { + let mut ws = Workspace::new(leader()); + assert!(ws.active().is_none()); + + // The leader still arms with no project, and only `o` and `q` resolve. + let _ = dispatch_key(&mut ws, leader()); + let open = dispatch_key(&mut ws, press(KeyCode::Char('o'), KeyModifiers::NONE)); + assert_eq!(open, KeyOutcome::Project(ProjectRequest::OpenDialog)); + + let _ = dispatch_key(&mut ws, leader()); + let quit = dispatch_key(&mut ws, press(KeyCode::Char('q'), KeyModifiers::NONE)); + assert_eq!(quit, KeyOutcome::Quit); + + // An unbound follow-up is consumed, not forwarded anywhere. + let _ = dispatch_key(&mut ws, leader()); + let other = dispatch_key(&mut ws, press(KeyCode::Char('t'), KeyModifiers::NONE)); + assert_eq!(other, KeyOutcome::Continue); +} + +#[test] +fn dialog_swallows_the_leader_instead_of_arming_the_prefix() { + let mut ws = workspace_on(&["/a"]); + ws.start_repo_input(); + ws.repo_input.buf.clear(); + + let _ = dispatch_key(&mut ws, leader()); + + // The dispatcher gives the dialog every key, so the leader is typed + // (and rejected as a control char) rather than arming a prefix behind + // the modal. + assert!(!ws.active().unwrap().prefix_armed()); + assert!(ws.repo_input.active); +} + +#[test] +fn dialog_rejects_command_modifier_chars() { + let mut ws = workspace_on(&["/a"]); + ws.start_repo_input(); + ws.repo_input.buf.clear(); + + let alt_x = press(KeyCode::Char('x'), KeyModifiers::ALT); + let _ = dispatch_key(&mut ws, alt_x); + + assert!(ws.repo_input.buf.is_empty()); +} diff --git a/src/mouse.rs b/src/mouse.rs new file mode 100644 index 0000000..fdf9d95 --- /dev/null +++ b/src/mouse.rs @@ -0,0 +1,267 @@ +use crate::app::{App, Focus}; +use crate::key_dispatch::{KeyOutcome, ProjectRequest, handle_key}; +use crate::runtime::terminal::WHEEL_LINES_PER_NOTCH; +use crate::workspace::Workspace; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; +use ratatui::layout::Rect; + +/// Route one mouse event. The project tab row is the only target that exists +/// with no project open, so it is resolved before the per-project handler. +pub(crate) fn dispatch_mouse( + ws: &mut Workspace, + tabs: crate::ui::Chrome<'_>, + mouse: MouseEvent, + screen: Rect, + layout: &crate::config::LayoutConfig, + mouse_enabled: bool, +) -> KeyOutcome { + let ws_leader = ws.leader(); + // A release must reach the pane whose press it pairs with, even when the + // dialog opened in between: no drag reports are forwarded, so that program + // cannot track the pointer itself, and a swallowed release leaves + // `pending_mouse_press` set for a later unrelated release to match. + // `handle_mouse` resolves releases before its own modal guard for exactly + // this reason, so the dialog must not swallow them ahead of it either. + let is_release = matches!(mouse.kind, MouseEventKind::Up(_)); + if ws.repo_input.active && !is_release { + return KeyOutcome::Continue; + } + match ws.active_mut() { + Some(app) => handle_mouse(app, tabs, mouse, screen, layout), + None => { + let MouseEventKind::Down(crossterm::event::MouseButton::Left) = mouse.kind else { + return KeyOutcome::Continue; + }; + if let Some(idx) = crate::ui::project_tab_at(tabs, screen, mouse.column, mouse.row) { + return KeyOutcome::Project(ProjectRequest::Switch(idx)); + } + // The open hint is the one action the empty screen offers, so a + // click on it does what its key does. + let leader_label = crate::app::leader_label_of(ws_leader); + let armed = ws.prefix_armed(); + match crate::ui::empty_hint_click_at( + screen, + &leader_label, + armed, + mouse_enabled, + mouse.column, + mouse.row, + ) { + Some(crate::ui::HintClick::Plain('o')) | Some(crate::ui::HintClick::Leader('o')) => { + // Disarm like the key path: an armed prefix left standing + // would consume the next key as a stale follow-up once the + // dialog closes. + ws.cancel_prefix(); + KeyOutcome::Project(ProjectRequest::OpenDialog) + } + _ => KeyOutcome::Continue, + } + } + } +} + +/// Route a captured mouse event to the pane under the pointer. +/// +/// A button press focuses that pane (mirroring a jump key), and press and +/// release are forwarded — via `click_pane` — only to a program that asked +/// for mouse reports. A release pairs with the press's pane rather than the +/// pane under the pointer (see `release_pending_press`). Wheel notches +/// scroll the pane under the pointer, not the active one, through the same +/// sink logic as the scroll keys. A left press outside pane content can +/// focus an upper panel, jump to a pane via its tab (or a `+N` hidden +/// marker), or run a hint-bar shortcut — the latter dispatched as +/// synthesized keypresses so a click and the named key take the same code +/// path (hence the `KeyOutcome` return, e.g. for `r: redraw`). While +/// pane-swap mode is armed, a left click names the swap target instead, +/// mirroring the digit follow-up. Presses on anything else (borders, +/// header) are dropped, and drag/motion reports are not forwarded at all: +/// inner-program text selection stays with the outer terminal's +/// Shift+drag. +pub(crate) fn handle_mouse( + app: &mut App, + tabs: crate::ui::Chrome<'_>, + mouse: MouseEvent, + screen: Rect, + layout: &crate::config::LayoutConfig, +) -> KeyOutcome { + // Releases route by the pending press, not the pointer, so they must be + // handled before the hit test — the pointer may have left the pane (or + // every pane) between press and release. They also bypass the modal + // guard below: the press happened before the modal opened, and the + // program that saw it must still see the release — swallowing it would + // leave the pending slot stale for a later unrelated release. + if let MouseEventKind::Up(_) = mouse.kind { + release_pending_press(app, screen, layout, mouse.column, mouse.row); + return KeyOutcome::Continue; + } + // Modal overlays (repo-switch dialog, every search bar) own all other + // input while open — same rule the key handler enforces: a click behind + // a modal must not move focus or reach a pane. + if app.search_overlay_active() { + return KeyOutcome::Continue; + } + // Pane-swap mode: a press names the swap target the way a digit does — + // a left click on a pane or its tab swaps the active pane with it, and + // any other press consumes-and-disarms, mirroring the key follow-up + // (`handle_swap_target_followup`). Without this branch a click would + // change the active pane while leaving swap mode armed, so a later + // digit would swap the wrong pane. Wheel events fall through, like a + // paste: they don't name a pane and don't disturb the armed state. + if app.awaiting_swap_target() + && let MouseEventKind::Down(button) = mouse.kind + { + app.cancel_swap_target(); + if button == crossterm::event::MouseButton::Left { + let target = crate::ui::pane_at(app, screen, layout, mouse.column, mouse.row) + .and_then(|(id, _)| app.terminal.panes.iter().position(|p| p.id == id)) + .or_else(|| crate::ui::tab_click_at(app, screen, layout, mouse.column, mouse.row)); + if let Some(idx) = target { + app.swap_active_pane_with(idx); + } + } + return KeyOutcome::Continue; + } + let Some((id, rect)) = crate::ui::pane_at(app, screen, layout, mouse.column, mouse.row) else { + // Not a terminal cell: a press can still focus an upper panel + // (file/commit/tree list or diff viewer) in the normal split layout, + // or run a shortcut named on the bottom hint row. + if let MouseEventKind::Down(button) = mouse.kind { + // The project tab row is checked first: it sits above the body, so + // no panel hit test can claim it, and a tab click is the pointer + // equivalent of its F-key. + if button == crossterm::event::MouseButton::Left + && let Some(idx) = crate::ui::project_tab_at(tabs, screen, mouse.column, mouse.row) + { + app.cancel_prefix(); + return KeyOutcome::Project(ProjectRequest::Switch(idx)); + } + if let Some(focus) = crate::ui::upper_panel_at(app, screen, layout, mouse.column, mouse.row) { + app.cancel_prefix(); + app.focus = focus; + } else if button == crossterm::event::MouseButton::Left { + if let Some(idx) = crate::ui::tab_click_at(app, screen, layout, mouse.column, mouse.row) { + // A tab click is a jump-key press with the pointer: same + // prefix resolution and focus/fullscreen handling. + app.cancel_prefix(); + app.switch_pane(idx); + } else if let Some(click) = + crate::ui::hint_click_at(app, tabs, screen, mouse.column, mouse.row) + { + return dispatch_hint_click(app, click); + } + } + } + return KeyOutcome::Continue; + }; + // 1-based pane-local cell, as SGR reports expect. In-bounds by + // construction: `pane_at` only returns a rect containing the cell. + let col = mouse.column - rect.x + 1; + let row = mouse.row - rect.y + 1; + match mouse.kind { + MouseEventKind::Down(button) => { + focus_clicked_pane(app, id); + if app.terminal.click_pane(id, button, true, col, row) { + app.pending_mouse_press = Some((id, button, col, row)); + } + } + MouseEventKind::ScrollUp => { + app.terminal + .scroll_pane(id, true, WHEEL_LINES_PER_NOTCH, Some((col, row))); + } + MouseEventKind::ScrollDown => { + app.terminal + .scroll_pane(id, false, WHEEL_LINES_PER_NOTCH, Some((col, row))); + } + // Horizontal wheel has no scrollback fallback; it reaches only a + // pane whose program asked for wheel reports (trackpads and tilt + // wheels in e.g. a full-screen TUI with horizontal panes). + MouseEventKind::ScrollLeft => { + app.terminal.wheel_horizontal_pane(id, true, col, row); + } + MouseEventKind::ScrollRight => { + app.terminal.wheel_horizontal_pane(id, false, col, row); + } + _ => {} + } + KeyOutcome::Continue +} + +/// Run a clicked hint-bar shortcut by synthesizing the keypress(es) its +/// label names, so a click and the real key share every guard and dispatch +/// path in `handle_key` — a hint click can never do something the named key +/// would not. `Arm` hints press the leader chord alone (the armed row then +/// offers clickable follow-ups); `Leader` hints press the leader chord first +/// (arming the prefix) and the follow-up second; `Plain` hints press one +/// bare key. +fn dispatch_hint_click(app: &mut App, click: crate::ui::HintClick) -> KeyOutcome { + let plain = |c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE); + match click { + crate::ui::HintClick::Arm => { + let leader = app.leader; + handle_key(app, leader) + } + crate::ui::HintClick::Leader(c) => { + let leader = app.leader; + match handle_key(app, leader) { + KeyOutcome::Continue => {} + other => return other, + } + handle_key(app, plain(c)) + } + crate::ui::HintClick::Plain(c) => handle_key(app, plain(c)), + } +} + +/// Deliver a button release to the pane that received the matching press. +/// +/// A program that saw an SGR press must see the release even when the +/// pointer moved off the pane in between (no drag reports are forwarded, so +/// it cannot track the pointer itself) — and a pane the pointer merely ends +/// up over must NOT receive a release it never got a press for. The release +/// carries the *stored* press button, not the one crossterm reported: +/// legacy encodings don't identify the button on release, so some +/// platforms report every `Up` as `Left`, and trusting that would strand a +/// right/middle press without its release. Chords were never paired (the +/// slot is single), so any release closes the pending press. The release +/// cell is clamped into the pressed pane's current rect. If that pane was +/// closed or hidden since the press, the release is dropped. +fn release_pending_press( + app: &mut App, + screen: Rect, + layout: &crate::config::LayoutConfig, + x: u16, + y: u16, +) { + let Some((id, pressed, _, _)) = app.pending_mouse_press else { + return; + }; + app.pending_mouse_press = None; + let Some(rect) = crate::ui::terminal_content_areas(app, screen, layout) + .into_iter() + .find_map(|(pid, rect)| (pid == id).then_some(rect)) + else { + return; + }; + // An extreme resize between press and release can shrink the pane to a + // zero-sized rect, which would invert the clamp bounds below (`clamp` + // panics when min > max). + if rect.width == 0 || rect.height == 0 { + return; + } + let col = x.clamp(rect.x, rect.right() - 1) - rect.x + 1; + let row = y.clamp(rect.y, rect.bottom() - 1) - rect.y + 1; + app.terminal.click_pane(id, pressed, false, col, row); +} + +/// Make the clicked pane active and move focus to the terminal, exactly what +/// a jump key does. A click is also a non-command event while the prefix is +/// armed, so resolve the prefix first (same rule as `handle_paste`). +fn focus_clicked_pane(app: &mut App, id: crate::backend::PaneId) { + app.cancel_prefix(); + let Some(idx) = app.terminal.panes.iter().position(|p| p.id == id) else { + return; + }; + app.terminal.active = idx; + app.terminal.sync_visible_window(); + app.focus = Focus::Terminal; +} \ No newline at end of file diff --git a/src/paste.rs b/src/paste.rs new file mode 100644 index 0000000..f6bc9e2 --- /dev/null +++ b/src/paste.rs @@ -0,0 +1,90 @@ +use crate::app::{App, Focus}; +use crate::workspace::Workspace; + +/// Route pasted text: into the open repo dialog if it owns input, else to the +/// active project. Nothing happens with no project and no dialog — there is no +/// sink for it. +pub(crate) fn dispatch_paste(ws: &mut Workspace, text: &str) { + if ws.repo_input.active { + for ch in text.chars().filter(|c| !c.is_control()) { + ws.repo_input_push(ch); + } + return; + } + match ws.active_mut() { + Some(app) => handle_paste(app, text), + // No sink for the text, but an armed prefix must still resolve — a + // non-command event cancels it, as it does on the project screen. + None => ws.cancel_prefix(), + } +} + +/// 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 +/// distinguish multi-line paste from interactive input (crossterm consumes the +/// outer markers when surfacing `Event::Paste`). +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. + app.cancel_prefix(); + if app.focus == Focus::FileList && app.status_view.search_active { + for ch in text.chars().filter(|c| !c.is_control()) { + app.search_push(ch); + } + return; + } + if app.focus == Focus::FileList && app.tree_view.search_active { + for ch in text.chars().filter(|c| !c.is_control()) { + app.tree_search_push(ch); + } + return; + } + if app.focus == Focus::FileList + && (app.log_view.commit_search_active || app.log_view.file_search_active) + { + for ch in text.chars().filter(|c| !c.is_control()) { + app.log_search_push(ch); + } + return; + } + if app.focus == Focus::DiffViewer && app.diff.search.active { + for ch in text.chars().filter(|c| !c.is_control()) { + app.diff.search_push(ch); + } + 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. + let sanitized: Vec = text + .as_bytes() + .iter() + .copied() + .filter(|&b| b != 0x1b && b != 0x00) + .collect(); + // Only wrap in bracketed-paste markers when the running program asked + // for them (DECSET 2004). A raw program that never enabled the mode + // would otherwise receive the literal `[200~`/`[201~` markers as input. + let bracketed = app + .active_screen() + .map(|screen| screen.bracketed_paste()) + .unwrap_or(false); + if bracketed { + let mut bytes = Vec::with_capacity(sanitized.len() + 12); + bytes.extend_from_slice(b"\x1b[200~"); + bytes.extend_from_slice(&sanitized); + bytes.extend_from_slice(b"\x1b[201~"); + app.terminal.send_input(&bytes); + } else { + app.terminal.send_input(&sanitized); + } + } +} \ No newline at end of file diff --git a/src/runtime/terminal/tests/lifecycle_tests.rs b/src/runtime/terminal/tests/lifecycle_tests.rs index 4ae605f..6865251 100644 --- a/src/runtime/terminal/tests/lifecycle_tests.rs +++ b/src/runtime/terminal/tests/lifecycle_tests.rs @@ -1,5 +1,4 @@ use super::common::*; -use super::*; #[test] fn create_pane_defaults_to_shell_label_and_no_command() { diff --git a/src/runtime/terminal/tests/scroll_tests.rs b/src/runtime/terminal/tests/scroll_tests.rs index b05e58a..86d2671 100644 --- a/src/runtime/terminal/tests/scroll_tests.rs +++ b/src/runtime/terminal/tests/scroll_tests.rs @@ -1,5 +1,4 @@ use super::common::*; -use super::*; use crate::backend::BackendEvent; use crossterm::event::MouseButton; diff --git a/src/runtime/tree_watch.rs b/src/runtime/tree_watch.rs index 588c9e4..5e77813 100644 --- a/src/runtime/tree_watch.rs +++ b/src/runtime/tree_watch.rs @@ -231,95 +231,5 @@ fn join_rel(workdir: &Path, rel: &str) -> PathBuf { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn inert_watcher_tracks_desired_set_without_os_calls() { - let (_tx, rx) = mpsc::channel(); - let mut w = TreeWatcher::from_receiver(rx); - let root = Path::new("/tmp/repo"); - let mut desired = BTreeSet::new(); - desired.insert(String::new()); - desired.insert("src".to_string()); - w.sync(root, &desired); - assert_eq!(w.watched, desired); - - // Reconcile down to just the root. - let mut smaller = BTreeSet::new(); - smaller.insert(String::new()); - w.sync(root, &smaller); - assert_eq!(w.watched, smaller); - } - - fn event_at(path: &str) -> notify_debouncer_mini::DebouncedEvent { - notify_debouncer_mini::DebouncedEvent { - path: PathBuf::from(path), - kind: notify_debouncer_mini::DebouncedEventKind::Any, - } - } - - /// A watcher already synced to `/tmp/repo`, so event paths can be made - /// repo-relative. - fn watcher_on_root(rx: Receiver) -> TreeWatcher { - let mut w = TreeWatcher::from_receiver(rx); - w.root = Some(PathBuf::from("/tmp/repo")); - w - } - - #[test] - fn drain_changed_reports_the_directories_that_changed_and_clears() { - let (tx, rx) = mpsc::channel(); - let mut w = watcher_on_root(rx); - assert!(w.drain_changed().is_empty(), "no events yet"); - - // A file event is attributed to its parent — that is the listing whose - // contents changed, and the one that has to be re-read. - tx.send(Ok(vec![event_at("/tmp/repo/src/main.rs")])) - .unwrap(); - tx.send(Ok(vec![event_at("/tmp/repo/README.md")])).unwrap(); - - let changes = w.drain_changed(); - assert!(!changes.unknown); - assert_eq!( - changes.dirs, - BTreeSet::from(["src".to_string(), String::new()]), - "the repo root is the empty relative path" - ); - // Drained: a second poll with nothing new reports no change. - assert!(w.drain_changed().is_empty()); - } - - #[test] - fn drain_changed_flags_a_watcher_error_as_unknown() { - // Events may have been dropped, so no set of directories can be - // trusted to be complete — the caller must re-read wholesale. - let (tx, rx) = mpsc::channel(); - let mut w = watcher_on_root(rx); - tx.send(Err(notify::Error::generic("boom"))).unwrap(); - - let changes = w.drain_changed(); - - assert!(changes.unknown); - assert!(!changes.is_empty()); - } - - #[test] - fn drain_changed_flags_a_path_outside_the_worktree_as_unknown() { - let (tx, rx) = mpsc::channel(); - let mut w = watcher_on_root(rx); - tx.send(Ok(vec![event_at("/elsewhere/file.rs")])).unwrap(); - - let changes = w.drain_changed(); - - assert!(changes.unknown, "an unmappable path cannot be attributed"); - assert!(changes.dirs.is_empty()); - } - - #[test] - fn join_rel_maps_root_and_subdir() { - let root = Path::new("/tmp/repo"); - assert_eq!(join_rel(root, ""), PathBuf::from("/tmp/repo")); - assert_eq!(join_rel(root, "src/ui"), PathBuf::from("/tmp/repo/src/ui")); - } -} +#[path = "tree_watch_tests.rs"] +mod tests; diff --git a/src/runtime/tree_watch_tests.rs b/src/runtime/tree_watch_tests.rs new file mode 100644 index 0000000..a03cf67 --- /dev/null +++ b/src/runtime/tree_watch_tests.rs @@ -0,0 +1,90 @@ +use super::*; + +#[test] +fn inert_watcher_tracks_desired_set_without_os_calls() { + let (_tx, rx) = mpsc::channel(); + let mut w = TreeWatcher::from_receiver(rx); + let root = Path::new("/tmp/repo"); + let mut desired = BTreeSet::new(); + desired.insert(String::new()); + desired.insert("src".to_string()); + w.sync(root, &desired); + assert_eq!(w.watched, desired); + + // Reconcile down to just the root. + let mut smaller = BTreeSet::new(); + smaller.insert(String::new()); + w.sync(root, &smaller); + assert_eq!(w.watched, smaller); +} + +fn event_at(path: &str) -> notify_debouncer_mini::DebouncedEvent { + notify_debouncer_mini::DebouncedEvent { + path: PathBuf::from(path), + kind: notify_debouncer_mini::DebouncedEventKind::Any, + } +} + +/// A watcher already synced to `/tmp/repo`, so event paths can be made +/// repo-relative. +fn watcher_on_root(rx: Receiver) -> TreeWatcher { + let mut w = TreeWatcher::from_receiver(rx); + w.root = Some(PathBuf::from("/tmp/repo")); + w +} + +#[test] +fn drain_changed_reports_the_directories_that_changed_and_clears() { + let (tx, rx) = mpsc::channel(); + let mut w = watcher_on_root(rx); + assert!(w.drain_changed().is_empty(), "no events yet"); + + // A file event is attributed to its parent — that is the listing whose + // contents changed, and the one that has to be re-read. + tx.send(Ok(vec![event_at("/tmp/repo/src/main.rs")])) + .unwrap(); + tx.send(Ok(vec![event_at("/tmp/repo/README.md")])).unwrap(); + + let changes = w.drain_changed(); + assert!(!changes.unknown); + assert_eq!( + changes.dirs, + BTreeSet::from(["src".to_string(), String::new()]), + "the repo root is the empty relative path" + ); + // Drained: a second poll with nothing new reports no change. + assert!(w.drain_changed().is_empty()); +} + +#[test] +fn drain_changed_flags_a_watcher_error_as_unknown() { + // Events may have been dropped, so no set of directories can be + // trusted to be complete — the caller must re-read wholesale. + let (tx, rx) = mpsc::channel(); + let mut w = watcher_on_root(rx); + tx.send(Err(notify::Error::generic("boom"))).unwrap(); + + let changes = w.drain_changed(); + + assert!(changes.unknown); + assert!(!changes.is_empty()); +} + +#[test] +fn drain_changed_flags_a_path_outside_the_worktree_as_unknown() { + let (tx, rx) = mpsc::channel(); + let mut w = watcher_on_root(rx); + tx.send(Ok(vec![event_at("/elsewhere/file.rs")])).unwrap(); + + let changes = w.drain_changed(); + + assert!(changes.unknown, "an unmappable path cannot be attributed"); + assert!(changes.dirs.is_empty()); +} + +#[test] +fn join_rel_maps_root_and_subdir() { + let root = Path::new("/tmp/repo"); + assert_eq!(join_rel(root, ""), PathBuf::from("/tmp/repo")); + assert_eq!(join_rel(root, "src/ui"), PathBuf::from("/tmp/repo/src/ui")); +} \ No newline at end of file diff --git a/src/splash.rs b/src/splash.rs new file mode 100644 index 0000000..fe21f1a --- /dev/null +++ b/src/splash.rs @@ -0,0 +1,50 @@ +use crate::workspace::Workspace; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::{Terminal, backend::CrosstermBackend}; +use std::io; + +pub(crate) enum SplashOutcome { + Enter, + Quit, +} + +pub(crate) fn splash_loop( + terminal: &mut Terminal>, + ws: &Workspace, + fallback_accent: usize, +) -> anyhow::Result { + let splash = crate::ui::splash::SplashState::new(); + // With no project open there is no restored accent to honour, so the + // configured preset stands in. + let accent = ws + .active() + .map(|p| p.current_accent()) + .unwrap_or_else(|| crate::config::Accent::from_index(fallback_accent).color()); + loop { + terminal.draw(|frame| { + crate::ui::splash::draw(frame, &splash, accent); + })?; + if splash.is_done() { + break; + } + 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. + Event::Key(k) if k.kind == KeyEventKind::Press => { + if k.code == KeyCode::Esc { + return Ok(SplashOutcome::Quit); + } + break; + } + Event::Resize(_, _) => terminal.clear()?, + _ => {} + } + } + } + terminal.clear()?; + Ok(SplashOutcome::Enter) +} \ No newline at end of file diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs new file mode 100644 index 0000000..ab24b26 --- /dev/null +++ b/src/ui/chrome.rs @@ -0,0 +1,42 @@ +use crate::config::LayoutConfig; +use crate::ui::status_view::RepoInput; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; + +pub(crate) fn chrome_rows(screen_area: Rect) -> ChromeRows { + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(screen_area); + ChromeRows { + tabs: outer[0], + body: outer[1], + notice: outer[2], + hint: outer[3], + } +} + +pub(crate) struct ChromeRows { + pub tabs: Rect, + pub body: Rect, + pub notice: Rect, + pub hint: Rect, +} + +pub(crate) fn main_content_constraints(layout: &LayoutConfig) -> [Constraint; 2] { + [ + Constraint::Percentage(layout.upper_pct), + Constraint::Percentage(100u16.saturating_sub(layout.upper_pct)), + ] +} + +#[derive(Clone, Copy)] +pub struct Chrome<'a> { + pub repo_paths: &'a [String], + pub active: usize, + pub repo_input: &'a RepoInput, +} diff --git a/src/ui/diff_pane.rs b/src/ui/diff_pane.rs deleted file mode 100644 index a65d221..0000000 --- a/src/ui/diff_pane.rs +++ /dev/null @@ -1,783 +0,0 @@ -use crate::git::diff::{DiffHunk, LineKind}; -use crate::ui::SearchQuery; -use crate::ui::file_view::FileViewState; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum DiffPaneView { - #[default] - Diff, - File, - /// Side-by-side diff: removed lines on the left, added lines on the right, - /// context lines mirrored on both sides. Falls back to the unified `Diff` - /// renderer when the pane is too narrow to split usefully. - Split, -} - -/// One row of the side-by-side layout. `Header` carries the hunk index whose -/// `@@ ... @@` header spans the full width; `Body` carries the (hunk, line) -/// coordinates shown on each side, with `None` marking a blank padding cell -/// where one side has no counterpart line. Coordinates index into -/// `DiffPane::hunks` (and the matching `line_highlights`) so the renderer can -/// reuse the prebuilt highlight cache without re-running syntect. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SplitRow { - Header(usize), - Body { - left: Option<(usize, usize)>, - right: Option<(usize, usize)>, - }, -} - -#[derive(Default)] -pub struct DiffSearch { - pub active: bool, - pub query: SearchQuery, - pub(crate) matches: Vec, - pub(crate) cursor: usize, -} - -impl DiffSearch { - pub fn is_visible(&self) -> bool { - self.active || !self.query.is_empty() - } - - pub fn has_query(&self) -> bool { - !self.query.is_empty() - } - - pub fn current_match(&self) -> Option { - self.matches.get(self.cursor).copied() - } - - pub fn is_match(&self, flat_idx: usize) -> bool { - // `matches` is built by `recompute_matches` in flat_idx-ascending - // order, so binary_search is always sound here. - self.matches.binary_search(&flat_idx).is_ok() - } - - fn start(&mut self) { - self.active = true; - } - - fn confirm(&mut self) { - if self.query.is_empty() { - self.clear(); - } else { - self.active = false; - } - } - - pub fn clear(&mut self) { - self.active = false; - self.query.clear(); - self.matches.clear(); - self.cursor = 0; - } - - fn push_char(&mut self, ch: char) { - self.query.push(ch); - } - - fn pop_char(&mut self) { - self.query.pop(); - } - - fn next(&mut self) -> Option { - 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 into - // here through code paths that mutate `matches` without re-anchoring. - if self.cursor >= self.matches.len() { - self.cursor = 0; - } else { - self.cursor = (self.cursor + 1) % self.matches.len(); - } - self.current_match() - } - - fn prev(&mut self) -> Option { - if self.matches.is_empty() { - return None; - } - if self.cursor == 0 || self.cursor >= self.matches.len() { - self.cursor = self.matches.len() - 1; - } else { - self.cursor -= 1; - } - self.current_match() - } -} - -/// Return the index of the match in `matches` whose flat row is closest to -/// `scroll`. Ties prefer the smaller flat row (i.e. the one already on or -/// above the cursor) so a content refresh during reading never jumps the -/// "current match" past where the user is looking. `matches` must be sorted -/// ascending and non-empty. -fn nearest_match_index(matches: &[usize], scroll: usize) -> usize { - debug_assert!(!matches.is_empty()); - match matches.binary_search(&scroll) { - Ok(i) => i, - Err(i) => { - if i == 0 { - 0 - } else if i == matches.len() { - matches.len() - 1 - } else { - let prev = matches[i - 1]; - let next = matches[i]; - if scroll - prev <= next - scroll { - i - 1 - } else { - i - } - } - } - } -} - -/// 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. -#[derive(Debug, Clone)] -pub struct HighlightSegment { - pub rgb: (u8, u8, u8), - pub text: String, -} - -/// 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. -pub(crate) fn highlight_line_segments( - hl: &mut syntect::easy::HighlightLines, - ss: &syntect::parsing::SyntaxSet, - raw: &str, -) -> Vec { - // syntect expects trailing newlines to terminate lines; strip them back - // off the resulting segments so cached text matches the source line. - let with_nl = format!("{raw}\n"); - match hl.highlight_line(&with_nl, ss) { - Ok(ranges) => ranges - .into_iter() - .filter_map(|(style, text)| { - let trimmed = text.trim_end_matches('\n'); - if trimmed.is_empty() { - return None; - } - let fg = style.foreground; - Some(HighlightSegment { - rgb: (fg.r, fg.g, fg.b), - text: trimmed.to_string(), - }) - }) - .collect(), - Err(_) => vec![HighlightSegment { - rgb: (200, 200, 200), - text: raw.to_string(), - }], - } -} - -/// All state for the diff viewer pane: the loaded hunks, scroll cursors, -/// search state, and the optional file-content overlay. Lifted out of App -/// so renderers and navigation handlers operate on a self-contained value. -#[derive(Default)] -pub struct DiffPane { - pub hunks: Vec, - /// Lowercased copy of each `DiffLine::content` aligned with `hunks`. - /// `hunks_lines_lower[i][j]` corresponds to `hunks[i].lines[j].content`. - /// Built once per diff load so per-keystroke search does not re-lowercase - /// the entire diff. Header lines are never searched and are not cached. - 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 - /// every frame. - pub line_highlights: Vec>>, - /// Syntax name (`SyntaxReference::name`) resolved per hunk at the time - /// `line_highlights` was built. Stored as a per-hunk vector because a - /// single commit diff can touch files of different types and each hunk - /// needs its own highlighter state. Empty means the cache is unbuilt - /// or invalidated. - pub cached_hunk_syntax: Vec, - /// Sum of `line.content.len()` across all hunk lines at the time - /// `line_highlights` was built. Pairs with the shape check so a hunk - /// replacement that happens to preserve the same line counts still - /// invalidates the cache. Belt-and-braces on top of the existing - /// `rebuild_lower_cache` invariant. - pub(crate) cached_content_bytes: usize, - pub scroll: usize, - pub scroll_x: usize, - pub search: DiffSearch, - pub view: DiffPaneView, - pub file_view: FileViewState, - /// True while the diff pane is rendered full-screen (hint bar excluded). - /// Toggled by `Ctrl+F` while focus is on `DiffViewer`; mutually exclusive - /// with `TerminalPane::fullscreen`. - pub fullscreen: bool, -} - -impl DiffPane { - /// Total flat row count across all hunks (1 header + N body lines each). - pub fn line_count(&self) -> usize { - self.hunks.iter().map(|h| 1 + h.lines.len()).sum() - } - - /// Largest legal `scroll` value: one less than the total row count, or 0 - /// when there are no rows. Callers clamp restored scroll positions and - /// page-down ends against this bound. - pub fn max_scroll(&self) -> usize { - self.line_count().saturating_sub(1) - } - - /// Move the active horizontal scroll target (diff or file view, depending - /// on `self.view`) left by one tab stop. - pub fn scroll_left(&mut self) { - let target = self.scroll_x_target_mut(); - *target = target.saturating_sub(4); - } - - /// Move the active horizontal scroll target right by one tab stop. - /// Capped at `u16::MAX` because ratatui's `Paragraph::scroll` takes `u16`. - pub fn scroll_right(&mut self) { - let target = self.scroll_x_target_mut(); - *target = target.saturating_add(4).min(u16::MAX as usize); - } - - fn scroll_x_target_mut(&mut self) -> &mut usize { - match self.view { - DiffPaneView::File => &mut self.file_view.scroll_x, - // Split reuses the unified diff's horizontal cursor so both halves - // scroll together. - DiffPaneView::Diff | DiffPaneView::Split => &mut self.scroll_x, - } - } - - /// 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 on - /// both sides. Cheap to recompute: it only walks line kinds and stores - /// coordinates, never copying content. - pub fn split_rows(&self) -> Vec { - let mut rows = Vec::new(); - for (hi, hunk) in self.hunks.iter().enumerate() { - rows.push(SplitRow::Header(hi)); - let mut removed: Vec = Vec::new(); - let mut added: Vec = Vec::new(); - for (li, line) in hunk.lines.iter().enumerate() { - match line.kind { - LineKind::Removed => removed.push(li), - LineKind::Added => added.push(li), - LineKind::Context => { - flush_split_blocks(&mut rows, hi, &mut removed, &mut added); - rows.push(SplitRow::Body { - left: Some((hi, li)), - right: Some((hi, li)), - }); - } - } - } - flush_split_blocks(&mut rows, hi, &mut removed, &mut added); - } - rows - } - - pub fn start_search(&mut self) { - self.search.start(); - } - - pub fn cancel_search(&mut self) { - self.search.clear(); - } - - pub fn confirm_search(&mut self) { - self.search.confirm(); - } - - pub fn search_push(&mut self, ch: char) { - self.search.push_char(ch); - self.recompute_matches(true); - } - - pub fn search_pop(&mut self) { - self.search.pop_char(); - self.recompute_matches(true); - } - - pub fn next_match(&mut self) { - if let Some(idx) = self.search.next() { - if self.view == DiffPaneView::File { - self.file_view.scroll = idx.min(self.file_view.max_scroll()); - } else { - self.scroll = idx; - } - } - } - - pub fn prev_match(&mut self) { - if let Some(idx) = self.search.prev() { - if self.view == DiffPaneView::File { - self.file_view.scroll = idx.min(self.file_view.max_scroll()); - } else { - self.scroll = idx; - } - } - } - - /// Rebuild `search.matches` against the current query, using - /// `hunks_lines_lower` so per-keystroke search is just a substring scan - /// over precomputed strings. - /// - /// `scroll_to_match` selects the post-rebuild behaviour: - /// - `true`: jump the viewport to the current cursor's match (used after - /// a keystroke where the user explicitly drove the search). - /// - `false`: keep the viewport pinned and re-anchor `cursor` to the - /// match nearest to the current scroll. Without this, a content-only - /// refresh (e.g. background snapshot tick while a query is active) - /// would leave the "current match" indicator at a stale row far from - /// where the user is reading, so the next `n`/`p` would jump - /// unexpectedly. - pub fn recompute_matches(&mut self, scroll_to_match: bool) { - self.search.matches.clear(); - if self.search.query.is_empty() { - self.search.cursor = 0; - return; - } - let q_owned; - let q: &str; - if self.view == DiffPaneView::File { - self.file_view.ensure_lower_cache(); - q_owned = self.search.query.lower().to_owned(); - q = &q_owned; - for (idx, line_lower) in self.file_view.lines_lower.iter().enumerate() { - if line_lower.contains(q) { - self.search.matches.push(idx); - } - } - } else { - self.ensure_lower_cache(); - q_owned = self.search.query.lower().to_owned(); - q = &q_owned; - let mut flat_idx = 0usize; - for (hunk, lines_lower) in self.hunks.iter().zip(self.hunks_lines_lower.iter()) { - flat_idx += 1; // header line - for line_lower in lines_lower.iter().take(hunk.lines.len()) { - if line_lower.contains(q) { - self.search.matches.push(flat_idx); - } - flat_idx += 1; - } - } - } - debug_assert!( - self.search.matches.windows(2).all(|w| w[0] < w[1]), - "diff_search_matches must be sorted for binary_search to be correct" - ); - if self.search.matches.is_empty() { - self.search.cursor = 0; - return; - } - if scroll_to_match { - self.search.cursor = self.search.cursor.min(self.search.matches.len() - 1); - self.scroll_to_match(); - } else { - let anchor = if self.view == DiffPaneView::File { - self.file_view.scroll - } else { - self.scroll - }; - self.search.cursor = nearest_match_index(&self.search.matches, anchor); - } - } - - #[cfg(test)] - pub(crate) fn search_cursor(&self) -> usize { - self.search.cursor - } - - fn scroll_to_match(&mut self) { - let Some(&idx) = self.search.matches.get(self.search.cursor) else { - return; - }; - if self.view == DiffPaneView::File { - self.file_view.scroll = idx.min(self.file_view.max_scroll()); - } else { - self.scroll = idx; - } - } - - /// Rebuild the lowercased line cache from scratch and invalidate the - /// highlight cache so the renderer rebuilds it on next frame. - pub fn rebuild_lower_cache(&mut self) { - self.hunks_lines_lower.clear(); - self.hunks_lines_lower.reserve(self.hunks.len()); - for hunk in &self.hunks { - let lines = hunk - .lines - .iter() - .map(|l| l.content.to_lowercase()) - .collect(); - self.hunks_lines_lower.push(lines); - } - // Highlight cache shape is keyed by hunks; invalidate so the renderer - // rebuilds it on next frame against the active syntax. - self.line_highlights.clear(); - self.cached_hunk_syntax.clear(); - } - - /// Rebuild the lowercased line cache iff its shape diverges from `hunks`. - /// Cheap path for callers that aren't sure whether the cache is current. - pub fn ensure_lower_cache(&mut self) { - let shape_matches = self.hunks_lines_lower.len() == self.hunks.len() - && self - .hunks - .iter() - .zip(self.hunks_lines_lower.iter()) - .all(|(h, ll)| ll.len() == h.lines.len()); - if !shape_matches { - self.rebuild_lower_cache(); - } - } - - /// 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, when there is no single "current" file). Rebuilds when - /// the cache shape, content size, or any per-hunk syntax diverges from - /// the cached state. - pub fn ensure_highlight_cache( - &mut self, - ss: &syntect::parsing::SyntaxSet, - ts: &syntect::highlighting::ThemeSet, - ) { - let per_hunk_syntax: Vec<&syntect::parsing::SyntaxReference> = self - .hunks - .iter() - .map(|h| resolve_hunk_syntax(ss, h.file_path.as_deref())) - .collect(); - let resolved_names: Vec = per_hunk_syntax.iter().map(|s| s.name.clone()).collect(); - - let shape_matches = self.line_highlights.len() == self.hunks.len() - && self - .hunks - .iter() - .zip(self.line_highlights.iter()) - .all(|(h, lh)| lh.len() == h.lines.len()); - let content_bytes: usize = self - .hunks - .iter() - .flat_map(|h| h.lines.iter()) - .map(|l| l.content.len()) - .sum(); - if shape_matches - && self.cached_content_bytes == content_bytes - && self.cached_hunk_syntax == resolved_names - { - return; - } - - 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. - let mut hl_pair: Option<(HighlightLines<'_>, HighlightLines<'_>)> = None; - let mut current_syntax_name = String::new(); - - let mut out: Vec>> = Vec::with_capacity(self.hunks.len()); - for (hunk, syntax) in self.hunks.iter().zip(per_hunk_syntax.iter()) { - if hl_pair.is_none() || current_syntax_name != syntax.name { - hl_pair = Some(( - HighlightLines::new(syntax, theme), - HighlightLines::new(syntax, theme), - )); - current_syntax_name = syntax.name.clone(); - } - // Safe: just assigned in the line above when None. - let (hl_new, hl_old) = hl_pair.as_mut().unwrap(); - - let mut per_hunk: Vec> = Vec::with_capacity(hunk.lines.len()); - for line in &hunk.lines { - let hl = match line.kind { - LineKind::Removed => &mut *hl_old, - _ => &mut *hl_new, - }; - per_hunk.push(highlight_line_segments(hl, ss, &line.content)); - } - out.push(per_hunk); - } - self.line_highlights = out; - self.cached_hunk_syntax = resolved_names; - self.cached_content_bytes = content_bytes; - } -} - -/// Flush the pending removed/added runs into paired `SplitRow::Body` rows, -/// padding the shorter run with `None` cells, then clear both queues. Called -/// whenever a context line or hunk boundary breaks a change block. -fn flush_split_blocks( - rows: &mut Vec, - hi: usize, - removed: &mut Vec, - added: &mut Vec, -) { - let pairs = removed.len().max(added.len()); - for i in 0..pairs { - rows.push(SplitRow::Body { - left: removed.get(i).map(|&li| (hi, li)), - right: added.get(i).map(|&li| (hi, li)), - }); - } - removed.clear(); - added.clear(); -} - -/// Pick the syntect syntax for a hunk based on its `file_path`'s extension. -/// Falls back to plain text when the path is absent (test fixtures) or the -/// extension is unknown. -fn resolve_hunk_syntax<'a>( - ss: &'a syntect::parsing::SyntaxSet, - file_path: Option<&str>, -) -> &'a syntect::parsing::SyntaxReference { - file_path - .map(crate::ui::path_extension) - .and_then(|ext| ss.find_syntax_by_extension(ext)) - .unwrap_or_else(|| ss.find_syntax_plain_text()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::git::diff::{DiffLine, LineKind}; - - #[test] - fn nearest_match_index_picks_closest_and_prefers_lower_on_tie() { - let m = [10, 30, 50]; - assert_eq!(nearest_match_index(&m, 5), 0); - assert_eq!(nearest_match_index(&m, 10), 0); - assert_eq!(nearest_match_index(&m, 19), 0); - // tie: equidistant from 10 and 30 → prefer the lower row. - assert_eq!(nearest_match_index(&m, 20), 0); - assert_eq!(nearest_match_index(&m, 21), 1); - assert_eq!(nearest_match_index(&m, 50), 2); - assert_eq!(nearest_match_index(&m, 999), 2); - } - - fn match_hunk(lines: &[&str]) -> DiffHunk { - DiffHunk { - header: "@@".to_string(), - lines: lines - .iter() - .map(|s| DiffLine { - kind: LineKind::Context, - content: (*s).to_string(), - }) - .collect(), - file_path: None, - } - } - - #[test] - fn recompute_matches_keep_scroll_repins_cursor_near_viewport() { - // 1 hunk header + 10 body lines. "foo" matches at body indices 0, 4, 8 - // → flat rows 1, 5, 9. - let mut pane = DiffPane { - hunks: vec![match_hunk(&[ - "foo a", "b", "c", "d", "foo e", "f", "g", "h", "foo i", "j", - ])], - ..Default::default() - }; - pane.search.query.set("foo"); - pane.scroll = 6; // user is reading near the middle match (row 5) - pane.search.cursor = 0; // stale cursor from before content changed - - pane.recompute_matches(false); - - assert_eq!(pane.search.matches, vec![1, 5, 9]); - // Closest match to scroll=6 is row 5 (cursor index 1), not the - // stale index 0 or a clamp to len-1. - assert_eq!(pane.search_cursor(), 1); - // Viewport stayed pinned where the user left it. - assert_eq!(pane.scroll, 6); - } - - #[test] - fn recompute_matches_scroll_to_match_clamps_and_jumps() { - let mut pane = DiffPane { - hunks: vec![match_hunk(&["foo a", "b", "foo c"])], - ..Default::default() - }; - pane.search.query.set("foo"); - pane.scroll = 100; // arbitrary; scroll_to_match should overwrite - pane.search.cursor = 99; // stale, should clamp to last match index. - - pane.recompute_matches(true); - - assert_eq!(pane.search.matches, vec![1, 3]); - assert_eq!(pane.search_cursor(), 1); - assert_eq!(pane.scroll, 3); - } - - fn kinded_hunk(lines: &[(LineKind, &str)]) -> DiffHunk { - DiffHunk { - header: "@@".to_string(), - lines: lines - .iter() - .map(|(kind, s)| DiffLine { - kind: *kind, - content: (*s).to_string(), - }) - .collect(), - file_path: None, - } - } - - #[test] - fn split_rows_pairs_changes_and_mirrors_context() { - use LineKind::{Added, Context, Removed}; - // A typical edit block: one context line, a 2-removed/1-added change, - // then a trailing context line. - let pane = DiffPane { - hunks: vec![kinded_hunk(&[ - (Context, "ctx0"), - (Removed, "old a"), - (Removed, "old b"), - (Added, "new a"), - (Context, "ctx1"), - ])], - ..Default::default() - }; - - let rows = pane.split_rows(); - assert_eq!( - rows, - vec![ - SplitRow::Header(0), - // context mirrored on both sides - SplitRow::Body { - left: Some((0, 0)), - right: Some((0, 0)), - }, - // removed[0] pairs with added[0] - SplitRow::Body { - left: Some((0, 1)), - right: Some((0, 3)), - }, - // removed[1] has no added counterpart → right padded blank - SplitRow::Body { - left: Some((0, 2)), - right: None, - }, - SplitRow::Body { - left: Some((0, 4)), - right: Some((0, 4)), - }, - ] - ); - // 1 header + 4 body rows. - assert_eq!(rows.len(), 5); - } - - #[test] - fn split_rows_pads_added_only_block() { - use LineKind::Added; - // Pure insertion: every change row has a blank left side. - let pane = DiffPane { - hunks: vec![kinded_hunk(&[(Added, "x"), (Added, "y")])], - ..Default::default() - }; - let rows = pane.split_rows(); - assert_eq!( - rows, - vec![ - SplitRow::Header(0), - SplitRow::Body { - left: None, - right: Some((0, 0)), - }, - SplitRow::Body { - left: None, - right: Some((0, 1)), - }, - ] - ); - } - - fn make_file_view_pane(content: &str) -> DiffPane { - let mut pane = DiffPane { - view: DiffPaneView::File, - ..Default::default() - }; - pane.file_view.set_content(content.to_string()); - pane - } - - #[test] - fn file_view_search_matches_correct_line_indices() { - let mut pane = make_file_view_pane("hello world\nfoo bar\nhello again\n"); - for ch in "hello".chars() { - pane.search_push(ch); - } - // lines 0 and 2 contain "hello" - assert_eq!(pane.search.matches, vec![0, 2]); - } - - #[test] - fn file_view_search_no_matches() { - let mut pane = make_file_view_pane("foo\nbar\nbaz\n"); - for ch in "xyz".chars() { - pane.search_push(ch); - } - assert!(pane.search.matches.is_empty()); - } - - #[test] - fn file_view_search_case_insensitive() { - let mut pane = make_file_view_pane("Hello World\nhello\nHELLO\n"); - for ch in "hello".chars() { - pane.search_push(ch); - } - assert_eq!(pane.search.matches, vec![0, 1, 2]); - } - - #[test] - fn file_view_next_match_updates_file_scroll() { - let mut pane = make_file_view_pane("match\nskip\nmatch\n"); - for ch in "match".chars() { - pane.search_push(ch); - } - assert_eq!(pane.file_view.scroll, 0); // jumped to first match - pane.next_match(); - assert_eq!(pane.file_view.scroll, 2); // jumped to second match - pane.next_match(); - assert_eq!(pane.file_view.scroll, 0); // wraps back to first - } - - #[test] - fn file_view_prev_match_updates_file_scroll() { - let mut pane = make_file_view_pane("match\nskip\nmatch\n"); - for ch in "match".chars() { - pane.search_push(ch); - } - pane.prev_match(); - assert_eq!(pane.file_view.scroll, 2); // wraps to last match - } - - #[test] - fn file_view_search_clear_resets_state() { - let mut pane = make_file_view_pane("hello\nworld\n"); - for ch in "hello".chars() { - pane.search_push(ch); - } - assert!(!pane.search.matches.is_empty()); - pane.cancel_search(); - assert!(pane.search.matches.is_empty()); - assert!(!pane.search.active); - } -} diff --git a/src/ui/diff_pane/highlight.rs b/src/ui/diff_pane/highlight.rs new file mode 100644 index 0000000..5055e3c --- /dev/null +++ b/src/ui/diff_pane/highlight.rs @@ -0,0 +1,45 @@ +/// 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. +#[derive(Debug, Clone)] +pub struct HighlightSegment { + pub rgb: (u8, u8, u8), + pub text: String, +} + +/// 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. +pub(crate) fn highlight_line_segments( + hl: &mut syntect::easy::HighlightLines, + ss: &syntect::parsing::SyntaxSet, + raw: &str, +) -> Vec { + // syntect expects trailing newlines to terminate lines; strip them back + // off the resulting segments so cached text matches the source line. + let with_nl = format!("{raw}\n"); + match hl.highlight_line(&with_nl, ss) { + Ok(ranges) => ranges + .into_iter() + .filter_map(|(style, text)| { + let trimmed = text.trim_end_matches('\n'); + if trimmed.is_empty() { + return None; + } + let fg = style.foreground; + Some(HighlightSegment { + rgb: (fg.r, fg.g, fg.b), + text: trimmed.to_string(), + }) + }) + .collect(), + Err(_) => vec![HighlightSegment { + rgb: (200, 200, 200), + text: raw.to_string(), + }], + } +} diff --git a/src/ui/diff_pane/mod.rs b/src/ui/diff_pane/mod.rs new file mode 100644 index 0000000..0601358 --- /dev/null +++ b/src/ui/diff_pane/mod.rs @@ -0,0 +1,81 @@ +mod highlight; +mod search; +mod split; +#[cfg(test)] +mod tests; + +pub use highlight::{DIFF_THEME, HighlightSegment}; +pub use search::DiffSearch; +pub(crate) use highlight::highlight_line_segments; +pub(crate) use search::nearest_match_index; +pub(crate) use split::{flush_split_blocks, resolve_hunk_syntax}; + +use crate::git::diff::DiffHunk; +use crate::ui::file_view::FileViewState; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DiffPaneView { + #[default] + Diff, + File, + /// Side-by-side diff: removed lines on the left, added lines on the right, + /// context lines mirrored on both sides. Falls back to the unified `Diff` + /// renderer when the pane is too narrow to split usefully. + Split, +} + +/// One row of the side-by-side layout. `Header` carries the hunk index whose +/// `@@ ... @@` header spans the full width; `Body` carries the (hunk, line) +/// coordinates shown on each side, with `None` marking a blank padding cell +/// where one side has no counterpart line. Coordinates index into +/// `DiffPane::hunks` (and the matching `line_highlights`) so the renderer can +/// reuse the prebuilt highlight cache without re-running syntect. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SplitRow { + Header(usize), + Body { + left: Option<(usize, usize)>, + right: Option<(usize, usize)>, + }, +} + +/// All state for the diff viewer pane: the loaded hunks, scroll cursors, +/// search state, and the optional file-content overlay. Lifted out of App +/// so renderers and navigation handlers operate on a self-contained value. +#[derive(Default)] +pub struct DiffPane { + pub hunks: Vec, + /// Lowercased copy of each `DiffLine::content` aligned with `hunks`. + /// `hunks_lines_lower[i][j]` corresponds to `hunks[i].lines[j].content`. + /// Built once per diff load so per-keystroke search does not re-lowercase + /// the entire diff. Header lines are never searched and are not cached. + 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 + /// every frame. + pub line_highlights: Vec>>, + /// Syntax name (`SyntaxReference::name`) resolved per hunk at the time + /// `line_highlights` was built. Stored as a per-hunk vector because a + /// single commit diff can touch files of different types and each hunk + /// needs its own highlighter state. Empty means the cache is unbuilt + /// or invalidated. + pub cached_hunk_syntax: Vec, + /// Sum of `line.content.len()` across all hunk lines at the time + /// `line_highlights` was built. Pairs with the shape check so a hunk + /// replacement that happens to preserve the same line counts still + /// invalidates the cache. Belt-and-braces on top of the existing + /// `rebuild_lower_cache` invariant. + pub(crate) cached_content_bytes: usize, + pub scroll: usize, + pub scroll_x: usize, + pub search: DiffSearch, + pub view: DiffPaneView, + pub file_view: FileViewState, + /// True while the diff pane is rendered full-screen (hint bar excluded). + /// Toggled by `Ctrl+F` while focus is on `DiffViewer`; mutually exclusive + /// with `TerminalPane::fullscreen`. + pub fullscreen: bool, +} + +mod pane_impl; diff --git a/src/ui/diff_pane/pane_impl.rs b/src/ui/diff_pane/pane_impl.rs new file mode 100644 index 0000000..094d499 --- /dev/null +++ b/src/ui/diff_pane/pane_impl.rs @@ -0,0 +1,298 @@ +use crate::git::diff::LineKind; +use crate::ui::diff_pane::{DiffPane, DiffPaneView, SplitRow, flush_split_blocks, highlight_line_segments, nearest_match_index, resolve_hunk_syntax, DIFF_THEME, HighlightSegment}; + +impl DiffPane { + /// Total flat row count across all hunks (1 header + N body lines each). + pub fn line_count(&self) -> usize { + self.hunks.iter().map(|h| 1 + h.lines.len()).sum() + } + + /// Largest legal `scroll` value: one less than the total row count, or 0 + /// when there are no rows. Callers clamp restored scroll positions and + /// page-down ends against this bound. + pub fn max_scroll(&self) -> usize { + self.line_count().saturating_sub(1) + } + + /// Move the active horizontal scroll target (diff or file view, depending + /// on `self.view`) left by one tab stop. + pub fn scroll_left(&mut self) { + let target = self.scroll_x_target_mut(); + *target = target.saturating_sub(4); + } + + /// Move the active horizontal scroll target right by one tab stop. + /// Capped at `u16::MAX` because ratatui's `Paragraph::scroll` takes `u16`. + pub fn scroll_right(&mut self) { + let target = self.scroll_x_target_mut(); + *target = target.saturating_add(4).min(u16::MAX as usize); + } + + fn scroll_x_target_mut(&mut self) -> &mut usize { + match self.view { + DiffPaneView::File => &mut self.file_view.scroll_x, + // Split reuses the unified diff's horizontal cursor so both halves + // scroll together. + DiffPaneView::Diff | DiffPaneView::Split => &mut self.scroll_x, + } + } + + /// 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 on + /// both sides. Cheap to recompute: it only walks line kinds and stores + /// coordinates, never copying content. + pub fn split_rows(&self) -> Vec { + let mut rows = Vec::new(); + for (hi, hunk) in self.hunks.iter().enumerate() { + rows.push(SplitRow::Header(hi)); + let mut removed: Vec = Vec::new(); + let mut added: Vec = Vec::new(); + for (li, line) in hunk.lines.iter().enumerate() { + match line.kind { + LineKind::Removed => removed.push(li), + LineKind::Added => added.push(li), + LineKind::Context => { + flush_split_blocks(&mut rows, hi, &mut removed, &mut added); + rows.push(SplitRow::Body { + left: Some((hi, li)), + right: Some((hi, li)), + }); + } + } + } + flush_split_blocks(&mut rows, hi, &mut removed, &mut added); + } + rows + } + + pub fn start_search(&mut self) { + self.search.start(); + } + + pub fn cancel_search(&mut self) { + self.search.clear(); + } + + pub fn confirm_search(&mut self) { + self.search.confirm(); + } + + pub fn search_push(&mut self, ch: char) { + self.search.push_char(ch); + self.recompute_matches(true); + } + + pub fn search_pop(&mut self) { + self.search.pop_char(); + self.recompute_matches(true); + } + + pub fn next_match(&mut self) { + if let Some(idx) = self.search.next() { + if self.view == DiffPaneView::File { + self.file_view.scroll = idx.min(self.file_view.max_scroll()); + } else { + self.scroll = idx; + } + } + } + + pub fn prev_match(&mut self) { + if let Some(idx) = self.search.prev() { + if self.view == DiffPaneView::File { + self.file_view.scroll = idx.min(self.file_view.max_scroll()); + } else { + self.scroll = idx; + } + } + } + + /// Rebuild `search.matches` against the current query, using + /// `hunks_lines_lower` so per-keystroke search is just a substring scan + /// over precomputed strings. + /// + /// `scroll_to_match` selects the post-rebuild behaviour: + /// - `true`: jump the viewport to the current cursor's match (used after + /// a keystroke where the user explicitly drove the search). + /// - `false`: keep the viewport pinned and re-anchor `cursor` to the + /// match nearest to the current scroll. Without this, a content-only + /// refresh (e.g. background snapshot tick while a query is active) + /// would leave the "current match" indicator at a stale row far from + /// where the user is reading, so the next `n`/`p` would jump + /// unexpectedly. + pub fn recompute_matches(&mut self, scroll_to_match: bool) { + self.search.matches.clear(); + if self.search.query.is_empty() { + self.search.cursor = 0; + return; + } + let q_owned; + let q: &str; + if self.view == DiffPaneView::File { + self.file_view.ensure_lower_cache(); + q_owned = self.search.query.lower().to_owned(); + q = &q_owned; + for (idx, line_lower) in self.file_view.lines_lower.iter().enumerate() { + if line_lower.contains(q) { + self.search.matches.push(idx); + } + } + } else { + self.ensure_lower_cache(); + q_owned = self.search.query.lower().to_owned(); + q = &q_owned; + let mut flat_idx = 0usize; + for (hunk, lines_lower) in self.hunks.iter().zip(self.hunks_lines_lower.iter()) { + flat_idx += 1; // header line + for line_lower in lines_lower.iter().take(hunk.lines.len()) { + if line_lower.contains(q) { + self.search.matches.push(flat_idx); + } + flat_idx += 1; + } + } + } + debug_assert!( + self.search.matches.windows(2).all(|w| w[0] < w[1]), + "diff_search_matches must be sorted for binary_search to be correct" + ); + if self.search.matches.is_empty() { + self.search.cursor = 0; + return; + } + if scroll_to_match { + self.search.cursor = self.search.cursor.min(self.search.matches.len() - 1); + self.scroll_to_match(); + } else { + let anchor = if self.view == DiffPaneView::File { + self.file_view.scroll + } else { + self.scroll + }; + self.search.cursor = nearest_match_index(&self.search.matches, anchor); + } + } + + #[cfg(test)] + pub(crate) fn search_cursor(&self) -> usize { + self.search.cursor + } + + fn scroll_to_match(&mut self) { + let Some(&idx) = self.search.matches.get(self.search.cursor) else { + return; + }; + if self.view == DiffPaneView::File { + self.file_view.scroll = idx.min(self.file_view.max_scroll()); + } else { + self.scroll = idx; + } + } + + /// Rebuild the lowercased line cache from scratch and invalidate the + /// highlight cache so the renderer rebuilds it on next frame. + pub fn rebuild_lower_cache(&mut self) { + self.hunks_lines_lower.clear(); + self.hunks_lines_lower.reserve(self.hunks.len()); + for hunk in &self.hunks { + let lines = hunk + .lines + .iter() + .map(|l| l.content.to_lowercase()) + .collect(); + self.hunks_lines_lower.push(lines); + } + // Highlight cache shape is keyed by hunks; invalidate so the renderer + // rebuilds it on next frame against the active syntax. + self.line_highlights.clear(); + self.cached_hunk_syntax.clear(); + } + + /// Rebuild the lowercased line cache iff its shape diverges from `hunks`. + /// Cheap path for callers that aren't sure whether the cache is current. + pub fn ensure_lower_cache(&mut self) { + let shape_matches = self.hunks_lines_lower.len() == self.hunks.len() + && self + .hunks + .iter() + .zip(self.hunks_lines_lower.iter()) + .all(|(h, ll)| ll.len() == h.lines.len()); + if !shape_matches { + self.rebuild_lower_cache(); + } + } + + /// 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, when there is no single "current" file). Rebuilds when + /// the cache shape, content size, or any per-hunk syntax diverges from + /// the cached state. + pub fn ensure_highlight_cache( + &mut self, + ss: &syntect::parsing::SyntaxSet, + ts: &syntect::highlighting::ThemeSet, + ) { + let per_hunk_syntax: Vec<&syntect::parsing::SyntaxReference> = self + .hunks + .iter() + .map(|h| resolve_hunk_syntax(ss, h.file_path.as_deref())) + .collect(); + let resolved_names: Vec = per_hunk_syntax.iter().map(|s| s.name.clone()).collect(); + + let shape_matches = self.line_highlights.len() == self.hunks.len() + && self + .hunks + .iter() + .zip(self.line_highlights.iter()) + .all(|(h, lh)| lh.len() == h.lines.len()); + let content_bytes: usize = self + .hunks + .iter() + .flat_map(|h| h.lines.iter()) + .map(|l| l.content.len()) + .sum(); + if shape_matches + && self.cached_content_bytes == content_bytes + && self.cached_hunk_syntax == resolved_names + { + return; + } + + 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. + let mut hl_pair: Option<(HighlightLines<'_>, HighlightLines<'_>)> = None; + let mut current_syntax_name = String::new(); + + let mut out: Vec>> = Vec::with_capacity(self.hunks.len()); + for (hunk, syntax) in self.hunks.iter().zip(per_hunk_syntax.iter()) { + if hl_pair.is_none() || current_syntax_name != syntax.name { + hl_pair = Some(( + HighlightLines::new(syntax, theme), + HighlightLines::new(syntax, theme), + )); + current_syntax_name = syntax.name.clone(); + } + // Safe: just assigned in the line above when None. + let (hl_new, hl_old) = hl_pair.as_mut().unwrap(); + + let mut per_hunk: Vec> = Vec::with_capacity(hunk.lines.len()); + for line in &hunk.lines { + let hl = match line.kind { + LineKind::Removed => &mut *hl_old, + _ => &mut *hl_new, + }; + per_hunk.push(highlight_line_segments(hl, ss, &line.content)); + } + out.push(per_hunk); + } + self.line_highlights = out; + self.cached_hunk_syntax = resolved_names; + self.cached_content_bytes = content_bytes; + } +} diff --git a/src/ui/diff_pane/search.rs b/src/ui/diff_pane/search.rs new file mode 100644 index 0000000..cb9e94b --- /dev/null +++ b/src/ui/diff_pane/search.rs @@ -0,0 +1,110 @@ +use crate::ui::SearchQuery; + +#[derive(Default)] +pub struct DiffSearch { + pub active: bool, + pub query: SearchQuery, + pub(crate) matches: Vec, + pub(crate) cursor: usize, +} + +impl DiffSearch { + pub fn is_visible(&self) -> bool { + self.active || !self.query.is_empty() + } + + pub fn has_query(&self) -> bool { + !self.query.is_empty() + } + + pub fn current_match(&self) -> Option { + self.matches.get(self.cursor).copied() + } + + pub fn is_match(&self, flat_idx: usize) -> bool { + // `matches` is built by `recompute_matches` in flat_idx-ascending + // order, so binary_search is always sound here. + self.matches.binary_search(&flat_idx).is_ok() + } + + pub(crate) fn start(&mut self) { + self.active = true; + } + + pub(crate) fn confirm(&mut self) { + if self.query.is_empty() { + self.clear(); + } else { + self.active = false; + } + } + + pub fn clear(&mut self) { + self.active = false; + self.query.clear(); + self.matches.clear(); + self.cursor = 0; + } + + pub(crate) fn push_char(&mut self, ch: char) { + self.query.push(ch); + } + + pub(crate) fn pop_char(&mut self) { + self.query.pop(); + } + + pub(crate) fn next(&mut self) -> Option { + 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 into + // here through code paths that mutate `matches` without re-anchoring. + if self.cursor >= self.matches.len() { + self.cursor = 0; + } else { + self.cursor = (self.cursor + 1) % self.matches.len(); + } + self.current_match() + } + + pub(crate) fn prev(&mut self) -> Option { + if self.matches.is_empty() { + return None; + } + if self.cursor == 0 || self.cursor >= self.matches.len() { + self.cursor = self.matches.len() - 1; + } else { + self.cursor -= 1; + } + self.current_match() + } +} + +/// Return the index of the match in `matches` whose flat row is closest to +/// `scroll`. Ties prefer the smaller flat row (i.e. the one already on or +/// above the cursor) so a content refresh during reading never jumps the +/// "current match" past where the user is looking. `matches` must be sorted +/// ascending and non-empty. +pub(crate) fn nearest_match_index(matches: &[usize], scroll: usize) -> usize { + debug_assert!(!matches.is_empty()); + match matches.binary_search(&scroll) { + Ok(i) => i, + Err(i) => { + if i == 0 { + 0 + } else if i == matches.len() { + matches.len() - 1 + } else { + let prev = matches[i - 1]; + let next = matches[i]; + if scroll - prev <= next - scroll { + i - 1 + } else { + i + } + } + } + } +} diff --git a/src/ui/diff_pane/split.rs b/src/ui/diff_pane/split.rs new file mode 100644 index 0000000..935448c --- /dev/null +++ b/src/ui/diff_pane/split.rs @@ -0,0 +1,34 @@ +use crate::ui::diff_pane::SplitRow; + +/// Flush the pending removed/added runs into paired `SplitRow::Body` rows, +/// padding the shorter run with `None` cells, then clear both queues. Called +/// whenever a context line or hunk boundary breaks a change block. +pub(crate) fn flush_split_blocks( + rows: &mut Vec, + hi: usize, + removed: &mut Vec, + added: &mut Vec, +) { + let pairs = removed.len().max(added.len()); + for i in 0..pairs { + rows.push(SplitRow::Body { + left: removed.get(i).map(|&li| (hi, li)), + right: added.get(i).map(|&li| (hi, li)), + }); + } + removed.clear(); + added.clear(); +} + +/// Pick the syntect syntax for a hunk based on its `file_path`'s extension. +/// Falls back to plain text when the path is absent (test fixtures) or the +/// extension is unknown. +pub(crate) fn resolve_hunk_syntax<'a>( + ss: &'a syntect::parsing::SyntaxSet, + file_path: Option<&str>, +) -> &'a syntect::parsing::SyntaxReference { + file_path + .map(crate::ui::path_extension) + .and_then(|ext| ss.find_syntax_by_extension(ext)) + .unwrap_or_else(|| ss.find_syntax_plain_text()) +} diff --git a/src/ui/diff_pane/tests/mod.rs b/src/ui/diff_pane/tests/mod.rs new file mode 100644 index 0000000..50220d0 --- /dev/null +++ b/src/ui/diff_pane/tests/mod.rs @@ -0,0 +1,227 @@ +use super::*; +use crate::git::diff::{DiffLine, LineKind}; + +#[test] +fn nearest_match_index_picks_closest_and_prefers_lower_on_tie() { + let m = [10, 30, 50]; + assert_eq!(nearest_match_index(&m, 5), 0); + assert_eq!(nearest_match_index(&m, 10), 0); + assert_eq!(nearest_match_index(&m, 19), 0); + // tie: equidistant from 10 and 30 → prefer the lower row. + assert_eq!(nearest_match_index(&m, 20), 0); + assert_eq!(nearest_match_index(&m, 21), 1); + assert_eq!(nearest_match_index(&m, 50), 2); + assert_eq!(nearest_match_index(&m, 999), 2); +} + +fn match_hunk(lines: &[&str]) -> DiffHunk { + DiffHunk { + header: "@@".to_string(), + lines: lines + .iter() + .map(|s| DiffLine { + kind: LineKind::Context, + content: (*s).to_string(), + }) + .collect(), + file_path: None, + } +} + +#[test] +fn recompute_matches_keep_scroll_repins_cursor_near_viewport() { + // 1 hunk header + 10 body lines. "foo" matches at body indices 0, 4, 8 + // → flat rows 1, 5, 9. + let mut pane = DiffPane { + hunks: vec![match_hunk(&[ + "foo a", "b", "c", "d", "foo e", "f", "g", "h", "foo i", "j", + ])], + ..Default::default() + }; + pane.search.query.set("foo"); + pane.scroll = 6; // user is reading near the middle match (row 5) + pane.search.cursor = 0; // stale cursor from before content changed + + pane.recompute_matches(false); + + assert_eq!(pane.search.matches, vec![1, 5, 9]); + // Closest match to scroll=6 is row 5 (cursor index 1), not the + // stale index 0 or a clamp to len-1. + assert_eq!(pane.search_cursor(), 1); + // Viewport stayed pinned where the user left it. + assert_eq!(pane.scroll, 6); +} + +#[test] +fn recompute_matches_scroll_to_match_clamps_and_jumps() { + let mut pane = DiffPane { + hunks: vec![match_hunk(&["foo a", "b", "foo c"])], + ..Default::default() + }; + pane.search.query.set("foo"); + pane.scroll = 100; // arbitrary; scroll_to_match should overwrite + pane.search.cursor = 99; // stale, should clamp to last match index. + + pane.recompute_matches(true); + + assert_eq!(pane.search.matches, vec![1, 3]); + assert_eq!(pane.search_cursor(), 1); + assert_eq!(pane.scroll, 3); +} + +fn kinded_hunk(lines: &[(LineKind, &str)]) -> DiffHunk { + DiffHunk { + header: "@@".to_string(), + lines: lines + .iter() + .map(|(kind, s)| DiffLine { + kind: *kind, + content: (*s).to_string(), + }) + .collect(), + file_path: None, + } +} + +#[test] +fn split_rows_pairs_changes_and_mirrors_context() { + use LineKind::{Added, Context, Removed}; + // A typical edit block: one context line, a 2-removed/1-added change, + // then a trailing context line. + let pane = DiffPane { + hunks: vec![kinded_hunk(&[ + (Context, "ctx0"), + (Removed, "old a"), + (Removed, "old b"), + (Added, "new a"), + (Context, "ctx1"), + ])], + ..Default::default() + }; + + let rows = pane.split_rows(); + assert_eq!( + rows, + vec![ + SplitRow::Header(0), + // context mirrored on both sides + SplitRow::Body { + left: Some((0, 0)), + right: Some((0, 0)), + }, + // removed[0] pairs with added[0] + SplitRow::Body { + left: Some((0, 1)), + right: Some((0, 3)), + }, + // removed[1] has no added counterpart → right padded blank + SplitRow::Body { + left: Some((0, 2)), + right: None, + }, + SplitRow::Body { + left: Some((0, 4)), + right: Some((0, 4)), + }, + ] + ); + // 1 header + 4 body rows. + assert_eq!(rows.len(), 5); +} + +#[test] +fn split_rows_pads_added_only_block() { + use LineKind::Added; + // Pure insertion: every change row has a blank left side. + let pane = DiffPane { + hunks: vec![kinded_hunk(&[(Added, "x"), (Added, "y")])], + ..Default::default() + }; + let rows = pane.split_rows(); + assert_eq!( + rows, + vec![ + SplitRow::Header(0), + SplitRow::Body { + left: None, + right: Some((0, 0)), + }, + SplitRow::Body { + left: None, + right: Some((0, 1)), + }, + ] + ); +} + +fn make_file_view_pane(content: &str) -> DiffPane { + let mut pane = DiffPane { + view: DiffPaneView::File, + ..Default::default() + }; + pane.file_view.set_content(content.to_string()); + pane +} + +#[test] +fn file_view_search_matches_correct_line_indices() { + let mut pane = make_file_view_pane("hello world\nfoo bar\nhello again\n"); + for ch in "hello".chars() { + pane.search_push(ch); + } + // lines 0 and 2 contain "hello" + assert_eq!(pane.search.matches, vec![0, 2]); +} + +#[test] +fn file_view_search_no_matches() { + let mut pane = make_file_view_pane("foo\nbar\nbaz\n"); + for ch in "xyz".chars() { + pane.search_push(ch); + } + assert!(pane.search.matches.is_empty()); +} + +#[test] +fn file_view_search_case_insensitive() { + let mut pane = make_file_view_pane("Hello World\nhello\nHELLO\n"); + for ch in "hello".chars() { + pane.search_push(ch); + } + assert_eq!(pane.search.matches, vec![0, 1, 2]); +} + +#[test] +fn file_view_next_match_updates_file_scroll() { + let mut pane = make_file_view_pane("match\nskip\nmatch\n"); + for ch in "match".chars() { + pane.search_push(ch); + } + assert_eq!(pane.file_view.scroll, 0); // jumped to first match + pane.next_match(); + assert_eq!(pane.file_view.scroll, 2); // jumped to second match + pane.next_match(); + assert_eq!(pane.file_view.scroll, 0); // wraps back to first +} + +#[test] +fn file_view_prev_match_updates_file_scroll() { + let mut pane = make_file_view_pane("match\nskip\nmatch\n"); + for ch in "match".chars() { + pane.search_push(ch); + } + pane.prev_match(); + assert_eq!(pane.file_view.scroll, 2); // wraps to last match +} + +#[test] +fn file_view_search_clear_resets_state() { + let mut pane = make_file_view_pane("hello\nworld\n"); + for ch in "hello".chars() { + pane.search_push(ch); + } + assert!(!pane.search.matches.is_empty()); + pane.cancel_search(); + assert!(pane.search.matches.is_empty()); + assert!(!pane.search.active); +} diff --git a/src/ui/diff_viewer.rs b/src/ui/diff_viewer.rs deleted file mode 100644 index e77d372..0000000 --- a/src/ui/diff_viewer.rs +++ /dev/null @@ -1,543 +0,0 @@ -use crate::app::{App, DiffPaneView, Focus, ViewMode}; -use crate::git::diff::LineKind; -use crate::ui::diff_pane::SplitRow; -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, -}; -use syntect::highlighting::ThemeSet; -use syntect::parsing::SyntaxSet; - -/// Minimum pane width (columns) for the side-by-side split layout. Below this -/// each half is too narrow to read, so `Split` view transparently falls back -/// to the unified diff renderer. -const MIN_SPLIT_WIDTH: u16 = 80; - -fn rgb_to_color(rgb: (u8, u8, u8)) -> Color { - Color::Rgb(rgb.0, rgb.1, rgb.2) -} - -pub fn render( - frame: &mut Frame, - app: &mut App, - area: Rect, - ss: &SyntaxSet, - ts: &ThemeSet, - accent: ratatui::style::Color, -) { - if app.diff.view == DiffPaneView::File { - render_file_view(frame, app, area, ss, ts, accent); - return; - } - - // Render side-by-side only when there is a diff to split and the pane is - // wide enough; otherwise fall through to the unified renderer below. - if app.diff.view == DiffPaneView::Split - && area.width >= MIN_SPLIT_WIDTH - && !app.diff.hunks.is_empty() - { - render_split_view(frame, app, area, ss, ts, accent); - return; - } - - let show_search = app.diff.search.is_visible(); - - let (diff_area, search_area) = if show_search { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(0), Constraint::Length(1)]) - .split(area); - (chunks[0], Some(chunks[1])) - } else { - (area, None) - }; - - let focused = app.focus == Focus::DiffViewer; - let border_style = super::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. - // Each hunk carries its own file_path now, so commit diffs that touch - // multiple file types stop rendering as plain text. - app.diff.ensure_highlight_cache(ss, ts); - - let current_match = app.diff.search.current_match(); - let has_search = app.diff.search.has_query(); - - // Total flat row count = (1 hunk header + N body lines) per hunk. - let total_lines = app.diff.line_count(); - let visible_height = (diff_area.height as usize).saturating_sub(2); - let scroll_start = app.diff.scroll.min(app.diff.max_scroll()); - // Keep the stored cursor in sync with the clamped value so a Split-view - // scroll position that overshoots this (narrower) unified fallback layout - // is corrected on the frame it falls back. - app.diff.scroll = scroll_start; - let visible_end = scroll_start.saturating_add(visible_height); - - let mut lines: Vec = Vec::with_capacity(visible_height); - let mut flat_idx: usize = 0; - - 'outer: for (hi, hunk) in app.diff.hunks.iter().enumerate() { - if flat_idx >= visible_end { - break; - } - - // Hunk header - if flat_idx >= scroll_start && flat_idx < visible_end { - lines.push(Line::from(Span::styled( - hunk.header.as_str(), - Style::default().fg(Color::Cyan), - ))); - } - flat_idx += 1; - - for (li, diff_line) in hunk.lines.iter().enumerate() { - if flat_idx >= visible_end { - break 'outer; - } - if flat_idx < scroll_start { - flat_idx += 1; - continue; - } - - let is_current = has_search && current_match == Some(flat_idx); - let is_match = has_search && app.diff.search.is_match(flat_idx); - - let bg = if is_current { - Color::Rgb(100, 80, 0) - } else if is_match { - Color::Rgb(50, 42, 0) - } else { - match diff_line.kind { - LineKind::Added => Color::Rgb(0, 50, 0), - LineKind::Removed => Color::Rgb(50, 0, 0), - LineKind::Context => Color::Reset, - } - }; - - let prefix = match diff_line.kind { - LineKind::Added => "+", - LineKind::Removed => "-", - LineKind::Context => " ", - }; - - let mut spans = vec![Span::styled( - prefix, - 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. - if let Some(segs) = app.diff.line_highlights.get(hi).and_then(|hh| hh.get(li)) { - for seg in segs { - spans.push(Span::styled( - seg.text.as_str(), - Style::default().fg(rgb_to_color(seg.rgb)).bg(bg), - )); - } - } else { - spans.push(Span::styled( - diff_line.content.as_str(), - Style::default().bg(bg), - )); - } - - lines.push(Line::from(spans)); - flat_idx += 1; - } - } - - if lines.is_empty() && total_lines == 0 { - let msg = match app.mode { - ViewMode::Log => { - if app.log_view.commits.is_empty() { - "No commits in repository" - } else { - "No diff for selected commit" - } - } - ViewMode::Status => { - if app.status_view.files.is_empty() { - "No changes in repository" - } else { - "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", - }; - lines.push(Line::from(Span::styled( - msg, - Style::default().fg(Color::DarkGray), - ))); - } - - let jump = super::jump_legend(app, '2'); - let title = match app.mode { - ViewMode::Log => { - let label = if app.log_view.diff_title.is_empty() { - "Diff" - } else { - app.log_view.diff_title.as_str() - }; - if has_search { - let count = app.diff.search.matches.len(); - if count == 0 { - format!(" {jump} {label} [no matches] ") - } else { - format!( - " {jump} {label} [{}/{}] ", - app.diff.search.cursor + 1, - count - ) - } - } else { - format!(" {jump} {label} ") - } - } - ViewMode::Status => { - let selected = app.selected_filtered_status_file(); - if has_search { - let count = app.diff.search.matches.len(); - let file = selected.map(|f| f.path.as_str()).unwrap_or("Diff"); - if count == 0 { - format!(" {jump} {file} [no matches] ") - } else { - format!(" {jump} {file} [{}/{}] ", app.diff.search.cursor + 1, count) - } - } else if let Some(f) = selected { - format!(" {jump} {} ", f.path) - } else { - format!(" {jump} Diff ") - } - } - ViewMode::Tree => { - let path = app.tree_view.selected_path(); - let label = path.as_deref().unwrap_or("File"); - if has_search { - let count = app.diff.search.matches.len(); - if count == 0 { - format!(" {jump} {label} [no matches] ") - } else { - format!( - " {jump} {label} [{}/{}] ", - app.diff.search.cursor + 1, - count - ) - } - } else { - format!(" {jump} {label} ") - } - } - }; - - let para = Paragraph::new(lines) - .block( - Block::default() - .borders(Borders::ALL) - .title(title) - .border_style(border_style), - ) - .scroll((0, app.diff.scroll_x.min(u16::MAX as usize) as u16)); - - frame.render_widget(para, diff_area); - - if let Some(sa) = search_area { - super::render_search_bar( - frame, - app.diff.search.query.as_str(), - app.diff.search.active, - sa, - accent, - ); - } -} - -/// Render the loaded diff as two vertically-scrolled columns (old | new). -/// Reuses the syntect highlight cache and the `scroll`/`scroll_x` cursors so -/// it stays bounded on large diffs. Search highlighting is intentionally not -/// drawn here — match rows are indexed against the unified flat-row model. -fn render_split_view( - frame: &mut Frame, - app: &mut App, - area: Rect, - ss: &SyntaxSet, - ts: &ThemeSet, - accent: ratatui::style::Color, -) { - let focused = app.focus == Focus::DiffViewer; - let border_style = super::focused_border_style(focused, accent); - app.diff.ensure_highlight_cache(ss, ts); - - let rows = app.diff.split_rows(); - 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 - // 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. - app.diff.scroll = scroll_start; - let scroll_end = scroll_start.saturating_add(visible_height).min(rows.len()); - - let mut left_lines: Vec = Vec::with_capacity(visible_height); - let mut right_lines: Vec = Vec::with_capacity(visible_height); - for row in &rows[scroll_start..scroll_end] { - match row { - SplitRow::Header(hi) => { - let header = app - .diff - .hunks - .get(*hi) - .map(|h| h.header.as_str()) - .unwrap_or(""); - left_lines.push(Line::from(Span::styled( - header, - Style::default().fg(Color::Cyan), - ))); - right_lines.push(Line::from("")); - } - SplitRow::Body { left, right } => { - left_lines.push(split_side_line(app, *left)); - right_lines.push(split_side_line(app, *right)); - } - } - } - - let title = split_title(app); - let block = Block::default() - .borders(Borders::ALL) - .title(title) - .border_style(border_style); - let inner = block.inner(area); - frame.render_widget(block, area); - - let halves = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(inner); - - let scroll_x = app.diff.scroll_x.min(u16::MAX as usize) as u16; - let left_para = Paragraph::new(left_lines).scroll((0, scroll_x)); - // A left border on the right column draws the vertical divider between the - // two halves and indents the new-side content by one cell. - let right_para = Paragraph::new(right_lines) - .block( - Block::default() - .borders(Borders::LEFT) - .border_style(border_style), - ) - .scroll((0, scroll_x)); - - frame.render_widget(left_para, halves[0]); - frame.render_widget(right_para, halves[1]); -} - -/// Build one side's `Line` for a split body row. `None` (no counterpart line -/// on this side) renders as a blank line; otherwise the cell is styled by line -/// kind and reuses the prebuilt highlight cache, mirroring the unified -/// renderer's per-line treatment. -fn split_side_line<'a>(app: &'a App, cell: Option<(usize, usize)>) -> Line<'a> { - let Some((hi, li)) = cell else { - return Line::from(""); - }; - let Some(diff_line) = app.diff.hunks.get(hi).and_then(|h| h.lines.get(li)) else { - return Line::from(""); - }; - - let bg = match diff_line.kind { - LineKind::Added => Color::Rgb(0, 50, 0), - LineKind::Removed => Color::Rgb(50, 0, 0), - LineKind::Context => Color::Reset, - }; - let prefix = match diff_line.kind { - LineKind::Added => "+", - LineKind::Removed => "-", - LineKind::Context => " ", - }; - - let mut spans = vec![Span::styled( - prefix, - Style::default().fg(Color::DarkGray).bg(bg), - )]; - if let Some(segs) = app.diff.line_highlights.get(hi).and_then(|hh| hh.get(li)) { - for seg in segs { - spans.push(Span::styled( - seg.text.as_str(), - Style::default().fg(rgb_to_color(seg.rgb)).bg(bg), - )); - } - } else { - spans.push(Span::styled( - diff_line.content.as_str(), - Style::default().bg(bg), - )); - } - Line::from(spans) -} - -/// Title for the split pane: the same file/commit label the unified view uses, -/// tagged `[split]`. Search match counts are omitted because the split view -/// does not render search highlights. -fn split_title(app: &App) -> String { - let label = match app.mode { - ViewMode::Log => { - if app.log_view.diff_title.is_empty() { - "Diff".to_string() - } else { - app.log_view.diff_title.clone() - } - } - ViewMode::Status => app - .selected_filtered_status_file() - .map(|f| f.path.clone()) - .unwrap_or_else(|| "Diff".to_string()), - ViewMode::Tree => app - .tree_view - .selected_path() - .unwrap_or_else(|| "File".to_string()), - }; - format!(" {} {label} [split] ", super::jump_legend(app, '2')) -} - -fn render_file_view( - frame: &mut Frame, - app: &mut App, - area: Rect, - ss: &SyntaxSet, - ts: &ThemeSet, - accent: ratatui::style::Color, -) { - 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(), - None => "", - }; - let ext = super::path_extension(file_path); - let syntax = ss - .find_syntax_by_extension(ext) - .unwrap_or_else(|| ss.find_syntax_plain_text()); - - let has_search = app.diff.search.has_query(); - let show_search = app.diff.search.is_visible(); - - let (content_area, search_area) = if show_search { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(0), Constraint::Length(1)]) - .split(area); - (chunks[0], Some(chunks[1])) - } else { - (area, None) - }; - - let jump = super::jump_legend(app, '2'); - let title = if has_search { - let count = app.diff.search.matches.len(); - if count == 0 { - format!(" {jump} {file_path} [no matches] ") - } else { - format!( - " {jump} {file_path} [{}/{}] ", - app.diff.search.cursor + 1, - count - ) - } - } else { - format!(" {jump} {file_path} [file] ") - }; - - let visible_height = (content_area.height as usize).saturating_sub(2); - let current_match = app.diff.search.current_match(); - let lines: Vec = if let Some(err) = &app.diff.file_view.error { - vec![Line::from(Span::styled( - err.as_str(), - Style::default().fg(Color::Red), - ))] - } else if app.diff.file_view.content.is_empty() { - vec![Line::from(Span::styled( - "(empty file)", - Style::default().fg(Color::DarkGray), - ))] - } else { - app.diff.file_view.ensure_highlight_cache(ss, ts, syntax); - let fv = &app.diff.file_view; - let total = fv.line_count(); - let width = total.to_string().len(); - // 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 so a stale - // total_lines can never produce an out-of-range start. - let max_scroll = total - .saturating_sub(1) - .min(fv.line_highlights.len().saturating_sub(1)); - let scroll_start = fv.scroll.min(max_scroll); - let scroll_end = scroll_start - .saturating_add(visible_height) - .min(fv.line_highlights.len()); - - fv.line_highlights[scroll_start..scroll_end] - .iter() - .enumerate() - .map(|(i, segs)| { - let line_no = scroll_start + i + 1; - let line_idx = scroll_start + i; - let is_anchor = fv.anchor_line == Some(line_no); - let is_current = has_search && current_match == Some(line_idx); - let is_match = has_search && !is_current && app.diff.search.is_match(line_idx); - let bg = if is_current { - Color::Rgb(100, 80, 0) - } else if is_match { - Color::Rgb(50, 42, 0) - } else if is_anchor { - Color::Rgb(60, 60, 90) - } else { - Color::Reset - }; - let mut spans = vec![Span::styled( - format!(" {:>width$} ", line_no, width = width), - Style::default().fg(Color::DarkGray).bg(bg), - )]; - for seg in segs { - spans.push(Span::styled( - seg.text.as_str(), - Style::default().fg(rgb_to_color(seg.rgb)).bg(bg), - )); - } - Line::from(spans) - }) - .collect() - }; - - let para = Paragraph::new(lines) - .block( - Block::default() - .borders(Borders::ALL) - .title(title) - .border_style(border_style), - ) - .scroll((0, app.diff.file_view.scroll_x.min(u16::MAX as usize) as u16)); - frame.render_widget(para, content_area); - - if let Some(sa) = search_area { - super::render_search_bar( - frame, - app.diff.search.query.as_str(), - app.diff.search.active, - sa, - accent, - ); - } -} diff --git a/src/ui/diff_viewer/file_view.rs b/src/ui/diff_viewer/file_view.rs new file mode 100644 index 0000000..8fa8dd7 --- /dev/null +++ b/src/ui/diff_viewer/file_view.rs @@ -0,0 +1,145 @@ +use crate::app::{App, Focus}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; +use syntect::highlighting::ThemeSet; +use syntect::parsing::SyntaxSet; + +pub(crate) fn render_file_view( + frame: &mut Frame, + app: &mut App, + area: Rect, + ss: &SyntaxSet, + ts: &ThemeSet, + accent: ratatui::style::Color, +) { + 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(), + None => "", + }; + let ext = super::path_extension(file_path); + let syntax = ss + .find_syntax_by_extension(ext) + .unwrap_or_else(|| ss.find_syntax_plain_text()); + + let has_search = app.diff.search.has_query(); + let show_search = app.diff.search.is_visible(); + + let (content_area, search_area) = if show_search { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)]) + .split(area); + (chunks[0], Some(chunks[1])) + } else { + (area, None) + }; + + let jump = super::jump_legend(app, '2'); + let title = if has_search { + let count = app.diff.search.matches.len(); + if count == 0 { + format!(" {jump} {file_path} [no matches] ") + } else { + format!( + " {jump} {file_path} [{}/{}] ", + app.diff.search.cursor + 1, + count + ) + } + } else { + format!(" {jump} {file_path} [file] ") + }; + + let visible_height = (content_area.height as usize).saturating_sub(2); + let current_match = app.diff.search.current_match(); + let lines: Vec = if let Some(err) = &app.diff.file_view.error { + vec![Line::from(Span::styled( + err.as_str(), + Style::default().fg(Color::Red), + ))] + } else if app.diff.file_view.content.is_empty() { + vec![Line::from(Span::styled( + "(empty file)", + Style::default().fg(Color::DarkGray), + ))] + } else { + app.diff.file_view.ensure_highlight_cache(ss, ts, syntax); + let fv = &app.diff.file_view; + let total = fv.line_count(); + let width = total.to_string().len(); + // 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 so a stale + // total_lines can never produce an out-of-range start. + let max_scroll = total + .saturating_sub(1) + .min(fv.line_highlights.len().saturating_sub(1)); + let scroll_start = fv.scroll.min(max_scroll); + let scroll_end = scroll_start + .saturating_add(visible_height) + .min(fv.line_highlights.len()); + + fv.line_highlights[scroll_start..scroll_end] + .iter() + .enumerate() + .map(|(i, segs)| { + let line_no = scroll_start + i + 1; + let line_idx = scroll_start + i; + let is_anchor = fv.anchor_line == Some(line_no); + let is_current = has_search && current_match == Some(line_idx); + let is_match = has_search && !is_current && app.diff.search.is_match(line_idx); + let bg = if is_current { + Color::Rgb(100, 80, 0) + } else if is_match { + Color::Rgb(50, 42, 0) + } else if is_anchor { + Color::Rgb(60, 60, 90) + } else { + Color::Reset + }; + let mut spans = vec![Span::styled( + format!(" {:>width$} ", line_no, width = width), + Style::default().fg(Color::DarkGray).bg(bg), + )]; + for seg in segs { + spans.push(Span::styled( + seg.text.as_str(), + Style::default().fg(super::rgb_to_color(seg.rgb)).bg(bg), + )); + } + Line::from(spans) + }) + .collect() + }; + + let para = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(border_style), + ) + .scroll((0, app.diff.file_view.scroll_x.min(u16::MAX as usize) as u16)); + frame.render_widget(para, content_area); + + if let Some(sa) = search_area { + super::render_search_bar( + frame, + app.diff.search.query.as_str(), + app.diff.search.active, + sa, + accent, + ); + } +} diff --git a/src/ui/diff_viewer/mod.rs b/src/ui/diff_viewer/mod.rs new file mode 100644 index 0000000..f50ec2d --- /dev/null +++ b/src/ui/diff_viewer/mod.rs @@ -0,0 +1,266 @@ +mod file_view; +mod split_view; + +pub(crate) use file_view::render_file_view; +pub(crate) use split_view::render_split_view; + +use crate::app::{App, DiffPaneView, Focus, ViewMode}; +use crate::git::diff::LineKind; +use crate::ui::{focused_border_style, jump_legend, path_extension, render_search_bar}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; +use syntect::highlighting::ThemeSet; +use syntect::parsing::SyntaxSet; + +/// Minimum pane width (columns) for the side-by-side split layout. Below this +/// each half is too narrow to read, so `Split` view transparently falls back +/// to the unified diff renderer. +const MIN_SPLIT_WIDTH: u16 = 80; + +pub(crate) fn rgb_to_color(rgb: (u8, u8, u8)) -> Color { + Color::Rgb(rgb.0, rgb.1, rgb.2) +} + +pub fn render( + frame: &mut Frame, + app: &mut App, + area: Rect, + ss: &SyntaxSet, + ts: &ThemeSet, + accent: ratatui::style::Color, +) { + if app.diff.view == DiffPaneView::File { + render_file_view(frame, app, area, ss, ts, accent); + return; + } + + // Render side-by-side only when there is a diff to split and the pane is + // wide enough; otherwise fall through to the unified renderer below. + if app.diff.view == DiffPaneView::Split + && area.width >= MIN_SPLIT_WIDTH + && !app.diff.hunks.is_empty() + { + render_split_view(frame, app, area, ss, ts, accent); + return; + } + + let show_search = app.diff.search.is_visible(); + + let (diff_area, search_area) = if show_search { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)]) + .split(area); + (chunks[0], Some(chunks[1])) + } else { + (area, None) + }; + + 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. + // Each hunk carries its own file_path now, so commit diffs that touch + // multiple file types stop rendering as plain text. + app.diff.ensure_highlight_cache(ss, ts); + + let current_match = app.diff.search.current_match(); + let has_search = app.diff.search.has_query(); + + // Total flat row count = (1 hunk header + N body lines) per hunk. + let total_lines = app.diff.line_count(); + let visible_height = (diff_area.height as usize).saturating_sub(2); + let scroll_start = app.diff.scroll.min(app.diff.max_scroll()); + // Keep the stored cursor in sync with the clamped value so a Split-view + // scroll position that overshoots this (narrower) unified fallback layout + // is corrected on the frame it falls back. + app.diff.scroll = scroll_start; + let visible_end = scroll_start.saturating_add(visible_height); + + let mut lines: Vec = Vec::with_capacity(visible_height); + let mut flat_idx: usize = 0; + + 'outer: for (hi, hunk) in app.diff.hunks.iter().enumerate() { + if flat_idx >= visible_end { + break; + } + + // Hunk header + if flat_idx >= scroll_start && flat_idx < visible_end { + lines.push(Line::from(Span::styled( + hunk.header.as_str(), + Style::default().fg(Color::Cyan), + ))); + } + flat_idx += 1; + + for (li, diff_line) in hunk.lines.iter().enumerate() { + if flat_idx >= visible_end { + break 'outer; + } + if flat_idx < scroll_start { + flat_idx += 1; + continue; + } + + let is_current = has_search && current_match == Some(flat_idx); + let is_match = has_search && app.diff.search.is_match(flat_idx); + + let bg = if is_current { + Color::Rgb(100, 80, 0) + } else if is_match { + Color::Rgb(50, 42, 0) + } else { + match diff_line.kind { + LineKind::Added => Color::Rgb(0, 50, 0), + LineKind::Removed => Color::Rgb(50, 0, 0), + LineKind::Context => Color::Reset, + } + }; + + let prefix = match diff_line.kind { + LineKind::Added => "+", + LineKind::Removed => "-", + LineKind::Context => " ", + }; + + let mut spans = vec![Span::styled( + prefix, + 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. + if let Some(segs) = app.diff.line_highlights.get(hi).and_then(|hh| hh.get(li)) { + for seg in segs { + spans.push(Span::styled( + seg.text.as_str(), + Style::default().fg(rgb_to_color(seg.rgb)).bg(bg), + )); + } + } else { + spans.push(Span::styled( + diff_line.content.as_str(), + Style::default().bg(bg), + )); + } + + lines.push(Line::from(spans)); + flat_idx += 1; + } + } + + if lines.is_empty() && total_lines == 0 { + let msg = match app.mode { + ViewMode::Log => { + if app.log_view.commits.is_empty() { + "No commits in repository" + } else { + "No diff for selected commit" + } + } + ViewMode::Status => { + if app.status_view.files.is_empty() { + "No changes in repository" + } else { + "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", + }; + lines.push(Line::from(Span::styled( + msg, + Style::default().fg(Color::DarkGray), + ))); + } + + let jump = jump_legend(app, '2'); + let title = match app.mode { + ViewMode::Log => { + let label = if app.log_view.diff_title.is_empty() { + "Diff" + } else { + app.log_view.diff_title.as_str() + }; + if has_search { + let count = app.diff.search.matches.len(); + if count == 0 { + format!(" {jump} {label} [no matches] ") + } else { + format!( + " {jump} {label} [{}/{}] ", + app.diff.search.cursor + 1, + count + ) + } + } else { + format!(" {jump} {label} ") + } + } + ViewMode::Status => { + let selected = app.selected_filtered_status_file(); + if has_search { + let count = app.diff.search.matches.len(); + let file = selected.map(|f| f.path.as_str()).unwrap_or("Diff"); + if count == 0 { + format!(" {jump} {file} [no matches] ") + } else { + format!(" {jump} {file} [{}/{}] ", app.diff.search.cursor + 1, count) + } + } else if let Some(f) = selected { + format!(" {jump} {} ", f.path) + } else { + format!(" {jump} Diff ") + } + } + ViewMode::Tree => { + let path = app.tree_view.selected_path(); + let label = path.as_deref().unwrap_or("File"); + if has_search { + let count = app.diff.search.matches.len(); + if count == 0 { + format!(" {jump} {label} [no matches] ") + } else { + format!( + " {jump} {label} [{}/{}] ", + app.diff.search.cursor + 1, + count + ) + } + } else { + format!(" {jump} {label} ") + } + } + }; + + let para = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(border_style), + ) + .scroll((0, app.diff.scroll_x.min(u16::MAX as usize) as u16)); + + frame.render_widget(para, diff_area); + + if let Some(sa) = search_area { + render_search_bar( + frame, + app.diff.search.query.as_str(), + app.diff.search.active, + sa, + accent, + ); + } +} diff --git a/src/ui/diff_viewer/split_view.rs b/src/ui/diff_viewer/split_view.rs new file mode 100644 index 0000000..4545848 --- /dev/null +++ b/src/ui/diff_viewer/split_view.rs @@ -0,0 +1,156 @@ +use crate::app::{App, Focus, ViewMode}; +use crate::git::diff::LineKind; +use crate::ui::diff_pane::SplitRow; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; +use syntect::highlighting::ThemeSet; +use syntect::parsing::SyntaxSet; + +pub(crate) fn render_split_view( + frame: &mut Frame, + app: &mut App, + area: Rect, + ss: &SyntaxSet, + ts: &ThemeSet, + accent: ratatui::style::Color, +) { + let focused = app.focus == Focus::DiffViewer; + let border_style = super::focused_border_style(focused, accent); + app.diff.ensure_highlight_cache(ss, ts); + + let rows = app.diff.split_rows(); + 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 + // 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. + app.diff.scroll = scroll_start; + let scroll_end = scroll_start.saturating_add(visible_height).min(rows.len()); + + let mut left_lines: Vec = Vec::with_capacity(visible_height); + let mut right_lines: Vec = Vec::with_capacity(visible_height); + for row in &rows[scroll_start..scroll_end] { + match row { + SplitRow::Header(hi) => { + let header = app + .diff + .hunks + .get(*hi) + .map(|h| h.header.as_str()) + .unwrap_or(""); + left_lines.push(Line::from(Span::styled( + header, + Style::default().fg(Color::Cyan), + ))); + right_lines.push(Line::from("")); + } + SplitRow::Body { left, right } => { + left_lines.push(split_side_line(app, *left)); + right_lines.push(split_side_line(app, *right)); + } + } + } + + let title = split_title(app); + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(border_style); + let inner = block.inner(area); + frame.render_widget(block, area); + + let halves = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner); + + let scroll_x = app.diff.scroll_x.min(u16::MAX as usize) as u16; + let left_para = Paragraph::new(left_lines).scroll((0, scroll_x)); + // A left border on the right column draws the vertical divider between the + // two halves and indents the new-side content by one cell. + let right_para = Paragraph::new(right_lines) + .block( + Block::default() + .borders(Borders::LEFT) + .border_style(border_style), + ) + .scroll((0, scroll_x)); + + frame.render_widget(left_para, halves[0]); + frame.render_widget(right_para, halves[1]); +} + +/// Build one side's `Line` for a split body row. `None` (no counterpart line +/// on this side) renders as a blank line; otherwise the cell is styled by line +/// kind and reuses the prebuilt highlight cache, mirroring the unified +/// renderer's per-line treatment. +fn split_side_line<'a>(app: &'a App, cell: Option<(usize, usize)>) -> Line<'a> { + let Some((hi, li)) = cell else { + return Line::from(""); + }; + let Some(diff_line) = app.diff.hunks.get(hi).and_then(|h| h.lines.get(li)) else { + return Line::from(""); + }; + + let bg = match diff_line.kind { + LineKind::Added => Color::Rgb(0, 50, 0), + LineKind::Removed => Color::Rgb(50, 0, 0), + LineKind::Context => Color::Reset, + }; + let prefix = match diff_line.kind { + LineKind::Added => "+", + LineKind::Removed => "-", + LineKind::Context => " ", + }; + + let mut spans = vec![Span::styled( + prefix, + Style::default().fg(Color::DarkGray).bg(bg), + )]; + if let Some(segs) = app.diff.line_highlights.get(hi).and_then(|hh| hh.get(li)) { + for seg in segs { + spans.push(Span::styled( + seg.text.as_str(), + Style::default().fg(super::rgb_to_color(seg.rgb)).bg(bg), + )); + } + } else { + spans.push(Span::styled( + diff_line.content.as_str(), + Style::default().bg(bg), + )); + } + Line::from(spans) +} + +/// Title for the split pane: the same file/commit label the unified view uses, +/// tagged `[split]`. Search match counts are omitted because the split view +/// does not render search highlights. +fn split_title(app: &App) -> String { + let label = match app.mode { + ViewMode::Log => { + if app.log_view.diff_title.is_empty() { + "Diff".to_string() + } else { + app.log_view.diff_title.clone() + } + } + ViewMode::Status => app + .selected_filtered_status_file() + .map(|f| f.path.clone()) + .unwrap_or_else(|| "Diff".to_string()), + ViewMode::Tree => app + .tree_view + .selected_path() + .unwrap_or_else(|| "File".to_string()), + }; + format!(" {} {label} [split] ", super::jump_legend(app, '2')) +} diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs new file mode 100644 index 0000000..11cefa6 --- /dev/null +++ b/src/ui/helpers.rs @@ -0,0 +1,103 @@ +use crate::app::App; +use crate::git::diff::StatusKind; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +pub(crate) fn path_extension(path: &str) -> &str { + std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") +} + +pub(crate) fn focused_border_style(focused: bool, accent: Color) -> Style { + if focused { + Style::default().fg(accent) + } else { + Style::default().fg(Color::DarkGray) + } +} + +pub(crate) fn status_color(status: StatusKind) -> Color { + match status { + StatusKind::Added => Color::Green, + StatusKind::Deleted => Color::Red, + StatusKind::Renamed => Color::Cyan, + StatusKind::TypeChanged => Color::Magenta, + StatusKind::Unmerged => Color::Red, + StatusKind::Untracked => Color::Gray, + StatusKind::Modified => Color::Yellow, + StatusKind::Unmodified => Color::DarkGray, + } +} + +pub(crate) fn jump_legend(app: &App, digit: char) -> String { + format!("{}{}", app.leader_label(), digit) +} + +pub(crate) fn render_selectable_list( + frame: &mut Frame, + area: Rect, + title: String, + items: Vec>, + selected: Option, + border_style: Style, +) { + let len = items.len(); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(border_style), + ) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + let mut state = ListState::default(); + if len > 0 + && let Some(idx) = selected + { + state.select(Some(idx.min(len - 1))); + } + frame.render_stateful_widget(list, area, &mut state); +} + +pub(crate) fn render_search_bar( + frame: &mut Frame, + query: &str, + is_active: bool, + area: Rect, + accent: Color, +) { + let cursor = if is_active { "█" } else { "" }; + let style = if is_active { + Style::default().fg(accent) + } else { + Style::default().fg(Color::DarkGray) + }; + frame.render_widget( + Paragraph::new(format!("/{query}{cursor}")).style(style), + area, + ); +} + +pub(crate) fn char_offset(s: &str, scroll_x: usize) -> &str { + if scroll_x == 0 { + return s; + } + let byte_off = s + .char_indices() + .nth(scroll_x) + .map(|(b, _)| b) + .unwrap_or(s.len()); + &s[byte_off..] +} diff --git a/src/ui/hint_bar.rs b/src/ui/hint_bar.rs new file mode 100644 index 0000000..2e02226 --- /dev/null +++ b/src/ui/hint_bar.rs @@ -0,0 +1,249 @@ +use crate::app::App; +use crate::ui::chrome::{Chrome, chrome_rows}; +use crate::ui::hint_text::{ + EMPTY_HINT, EMPTY_HINT_ARMED, PREFIX_CHIP, normal_hint_literal, prefix_armed_hint_text, +}; +use ratatui::{ + layout::{Position, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HintClick { + Arm, + Leader(char), + Plain(char), +} + +pub(crate) fn segment_click(keyspec: &str) -> Option { + let spec = keyspec.trim(); + if spec == "" { + return Some(HintClick::Arm); + } + if let Some(rest) = spec.strip_prefix(" ") { + let mut chars = rest.chars(); + if let (Some(c), None) = (chars.next(), chars.next()) + && matches!(c, 't' | 'w' | 'f' | 'l' | 'b' | 'o') + { + return Some(HintClick::Leader(c)); + } + return None; + } + let mut chars = spec.chars(); + if let (Some(c), None) = (chars.next(), chars.next()) + && matches!( + c, + 't' | 'w' | 's' | 'l' | 'b' | 'f' | 'o' | 'x' | 'p' | 'r' | 'v' | '/' + ) + { + return Some(HintClick::Plain(c)); + } + None +} + +/// Build the styled spans for a hint legend, inverting (`REVERSED`) every +/// clickable segment — the whole `key: description` label, matching the +/// click target exactly — so the bar itself shows which hints respond to a +/// click. Consumes the same literal and `" | "` segmentation as +/// `hint_click_at` — and decides clickability with the same `segment_click` +/// — so an inverted label can never disagree with the hit test. Only styles +/// change; the rendered text (and thus every column offset) stays identical. +/// `mark_clickable` is `[mouse] enabled`: with capture off a click can never +/// arrive, so no label may advertise one. +pub(crate) fn hint_spans(text: &str, leader: &str, mark_clickable: bool) -> Vec> { + let base = Style::default().fg(Color::DarkGray); + let inverted = base.add_modifier(Modifier::REVERSED); + let mut spans = Vec::new(); + for (i, segment) in text.split(" | ").enumerate() { + if i > 0 { + spans.push(Span::styled(" | ", base)); + } + let rendered = segment.replace("", leader); + let clickable = mark_clickable + && segment + .split_once(':') + .and_then(|(keyspec, _)| segment_click(keyspec)) + .is_some(); + if clickable { + // Invert the whole segment — the entire label is the click + // target, so the affordance covers exactly what responds. + // Leading whitespace stays 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() { + spans.push(Span::styled(lead_ws.to_string(), base)); + } + spans.push(Span::styled(label.to_string(), inverted)); + } else { + spans.push(Span::styled(rendered, base)); + } + } + spans +} + +pub(crate) fn render_hint_bar<'a>(app: &'a App, chrome: Chrome<'a>, accent: Color) -> Paragraph<'a> { + if chrome.repo_input.active { + // A rejected path is reported on the notice row directly above, so + // this row stays a plain input line. + return Paragraph::new(Line::from(vec![ + Span::styled("repo: ", Style::default().fg(accent)), + Span::raw(chrome.repo_input.buf.as_str()), + Span::styled("█", Style::default().fg(accent)), + ])); + } + if app.prefix_armed() { + let mut spans = vec![Span::styled( + PREFIX_CHIP, + Style::default() + .fg(Color::Black) + .bg(accent) + .add_modifier(Modifier::BOLD), + )]; + spans.extend(hint_spans( + &prefix_armed_hint_text(app), + &app.leader_label(), + app.mouse_enabled, + )); + return Paragraph::new(Line::from(spans)); + } + if app.awaiting_swap_target() { + // The swap-target digits follow the same layout-aware mapping as the + // focus jumps (see `main::resolve_prefix_action`): `1-8` while the + // terminal fills the body, `3-9,0` in the split view. + let digits = if app.terminal.fullscreen.fills_body() { + "1-8" + } else { + "3-9,0" + }; + return Paragraph::new(Line::from(vec![ + Span::styled( + " SWAP ", + Style::default() + .fg(Color::Black) + .bg(accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {digits}: swap active pane with this pane | esc: cancel"), + Style::default().fg(Color::DarkGray), + ), + ])); + } + // `` in the hint literal resolves to the configured leader chord + // (e.g. `^F`) so the footer always names the actual key to press rather + // than an abstract word. + Paragraph::new(Line::from(hint_spans( + normal_hint_literal(app), + &app.leader_label(), + app.mouse_enabled, + ))) +} + +/// The click action for `(x, y)` on the empty screen's hint row, or `None` +/// off it. Only `o` resolves — quitting stays a deliberate keyboard act, as +/// on the project screen. +pub(crate) fn empty_hint_click_at( + screen_area: Rect, + leader_label: &str, + prefix_armed: bool, + mouse_enabled: bool, + x: u16, + y: u16, +) -> Option { + // Gated like `hint_click_at`: with capture off the row renders plain, and + // 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 { + return None; + } + let hint_area = chrome_rows(screen_area).hint; + if hint_area.height == 0 || !hint_area.contains(Position { x, y }) { + return None; + } + let (chip, text) = if prefix_armed { + (PREFIX_CHIP, EMPTY_HINT_ARMED) + } else { + ("", EMPTY_HINT) + }; + let mut cursor = hint_area.x + Span::raw(chip).width() as u16; + for (i, segment) in text.split(" | ").enumerate() { + if i > 0 { + cursor += Span::raw(" | ").width() as u16; + } + 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. + 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 { + return None; + } + let (keyspec, _) = segment.split_once(':')?; + return segment_click(keyspec); + } + cursor += width; + } + None +} + +pub(crate) fn hint_click_at( + app: &App, + chrome: Chrome<'_>, + screen_area: Rect, + x: u16, + y: u16, +) -> Option { + // With mouse capture off no click can reach us anyway, but the bar also + // renders no inverted labels (`hint_spans`) — keep the affordance and the + // hit test in agreement rather than relying on the caller. + if !app.mouse_enabled { + return None; + } + let hint_area = chrome_rows(screen_area).hint; + if hint_area.height == 0 || !hint_area.contains(Position { x, y }) { + return None; + } + + // Row selection mirrors `render_hint_bar`'s branch order exactly, or a + // click would resolve against a row the user isn't looking at. Notices no + // longer appear here (they own the row above), so they don't feature. + let (chip, text) = if chrome.repo_input.active { + return None; + } else if app.prefix_armed() { + (PREFIX_CHIP, prefix_armed_hint_text(app)) + } else if app.awaiting_swap_target() { + return None; + } else { + ("", normal_hint_literal(app).to_string()) + }; + + let leader = app.leader_label(); + let mut cursor = hint_area.x + Span::raw(chip).width() as u16; + for (i, segment) in text.split(" | ").enumerate() { + if i > 0 { + cursor += Span::raw(" | ").width() as u16; + } + let rendered = segment.replace("", &leader); + let width = Span::raw(rendered.as_str()).width() as u16; + if x >= cursor && x < cursor + width { + // Leading whitespace renders plain (see `hint_spans`), so it is + // not part of the click target either — the clickable range must + // match the inverted label cell for cell. + 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 { + return None; + } + let (keyspec, _) = segment.split_once(':')?; + return segment_click(keyspec); + } + cursor += width; + } + None +} diff --git a/src/ui/hint_text.rs b/src/ui/hint_text.rs new file mode 100644 index 0000000..3de54cc --- /dev/null +++ b/src/ui/hint_text.rs @@ -0,0 +1,170 @@ +use crate::app::{App, DiffPaneView, Focus, ViewMode}; +use crate::runtime::terminal::TerminalFullscreen; + +pub(crate) const PREFIX_CHIP: &str = " PREFIX "; +pub(crate) const EMPTY_HINT: &str = " o: open project | q: quit"; +pub(crate) const EMPTY_HINT_ARMED: &str = " o: open project | q: quit | esc: cancel"; + +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 (see `main::resolve_prefix_action`). + let digits = if app.terminal.fullscreen.fills_body() { + "1-8: pane" + } else { + "1-9: focus/pane" + }; + // `w`/`s` only act under their availability predicates (see + // `App::can_close_pane`/`can_swap_panes`), so only advertise them there — + // a hint for a no-op key would lie. + let close = if app.can_close_pane() { + "w: close pane | " + } else { + "" + }; + let swap = if app.can_swap_panes() { + "s: swap pane | " + } else { + "" + }; + // The view toggles name their destination from the current mode, matching + // the normal legends, instead of a generic `log/status` label. + let (log_toggle, tree_toggle) = match app.mode { + ViewMode::Log => ("l: status view", "b: tree view"), + ViewMode::Status => ("l: log view", "b: tree view"), + ViewMode::Tree => ("l: log view", "b: status view"), + }; + // `x` is advertised unconditionally, unlike `w`/`s` above: refusing to + // close the last project reports why on the notice row, so the key always + // produces a visible result rather than silently doing nothing. + format!( + " t: new pane | {close}{swap}{log_toggle} | {tree_toggle} | f: fullscreen | o: open project | x: close project | p: theme | r: redraw | q: quit | {digits} | esc: cancel" + ) +} + +/// 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 + // would look different from Grid; otherwise the cycle skips Zoom and + // `f` exits. + TerminalFullscreen::Grid if app.terminal.zoom_distinct_from_grid() => { + return " : leader | shift+↑/↓: scroll | shift+pgup/dn: page scroll | shift+←/→: cycle pane | f: zoom active pane | t: new pane | w: close pane | q: quit"; + } + TerminalFullscreen::Grid => { + return " : leader | shift+↑/↓: scroll | shift+pgup/dn: page scroll | f: exit fullscreen | t: new pane | w: close pane | q: quit"; + } + TerminalFullscreen::Zoom => { + return " : leader | shift+↑/↓: scroll | shift+pgup/dn: page scroll | shift+←/→: cycle pane | f: exit fullscreen | t: new pane | w: close pane | q: quit"; + } + TerminalFullscreen::Off => {} + } + if app.diff.fullscreen { + let hint = if app.diff.view == DiffPaneView::File { + // Tree mode's right pane is permanently the file view — `v` + // can't leave it, so don't advertise a no-op. + if app.mode == ViewMode::Tree { + " f: exit zoom | j/k: scroll | pgup/pgdn: page | q: quit" + } else { + " f: exit zoom | v: back to diff | j/k: scroll | pgup/pgdn: page | q: quit" + } + } else if app.diff.view == DiffPaneView::Split { + " f: exit zoom | s: unified diff | j/k: scroll | pgup/pgdn: page | q: quit" + } else if app.diff.search.active { + " type to search | enter: confirm | esc: cancel" + } else if !app.diff.search.query.is_empty() { + " f: exit zoom | n: next match | shift+n: prev match | /: new search | esc: clear" + } else if app.can_open_file_view() { + " f: exit zoom | j/k: scroll | v: view file | s: split | /: search | pgup/pgdn: page | q: quit" + } else { + // No file target for `v` (log view browsing commits, or nothing + // selected) — a hint for a no-op key would lie. + " f: exit zoom | j/k: scroll | s: split | /: search | pgup/pgdn: page | q: quit" + }; + return hint; + } + if app.list_fullscreen { + let hint = match app.mode { + ViewMode::Log if app.log_view.drill_down => { + " f: exit zoom | esc: back to commits | j/k: navigate files | q: quit" + } + ViewMode::Log => { + " f: exit zoom | l: status view | b: tree view | j/k: navigate commits | enter: view files | q: quit" + } + ViewMode::Status => { + " f: exit zoom | j/k: navigate | /: search | l: log view | b: tree view | q: quit" + } + ViewMode::Tree => { + " f: exit zoom | j/k: navigate | /: search | →/enter: expand | ←: collapse | b: status view | l: log view | q: quit" + } + }; + return hint; + } + if let Focus::Terminal = app.focus { + // The `l` toggle names its destination: from Log mode it returns to + // the status view, from Status/Tree it enters the log view. + return if app.mode == ViewMode::Log { + " : leader | shift+↑/↓: scroll | shift+pgup/dn: page scroll | shift+←/→: cycle | t: new pane | w: close pane | f: fullscreen | l: status view | o: open project | q: quit" + } else { + " : leader | shift+↑/↓: scroll | shift+pgup/dn: page scroll | shift+←/→: cycle | t: new pane | w: close pane | f: fullscreen | l: log view | o: open project | q: quit" + }; + } + match app.focus { + Focus::Terminal => unreachable!("Focus::Terminal handled above"), + Focus::FileList => match app.mode { + ViewMode::Log => { + if app.log_view.drill_down { + " esc: back to commits | j/k: navigate files | shift+←/→: cycle | q: quit" + } else { + " shift+←/→: cycle | j/k: navigate commits | enter: view files | t: new pane | f: fullscreen | l: status view | b: tree view | o: open project | q: quit" + } + } + ViewMode::Status => { + " shift+←/→: cycle | j/k: navigate | /: search | t: new pane | f: fullscreen | l: log view | b: tree view | o: open project | q: quit" + } + ViewMode::Tree => { + " shift+←/→: cycle | j/k: navigate | /: search | →/enter: expand | ←: collapse | b: status view | l: log view | q: quit" + } + }, + Focus::DiffViewer => { + if app.diff.view == DiffPaneView::File && app.diff.search.active { + " type to search | enter: confirm | esc: cancel" + } else if app.diff.view == DiffPaneView::File && !app.diff.search.query.is_empty() { + " n: next match | shift+n: prev match | /: new search | esc: clear" + } else if app.diff.view == DiffPaneView::File { + // Tree mode's right pane is permanently the file view — `v` + // can't leave it, so don't advertise a no-op. + if app.mode == ViewMode::Tree { + " j/k: scroll | pgup/pgdn: page | /: search | shift+←/→: cycle | q: quit" + } else { + " v: back to diff | j/k: scroll | pgup/pgdn: page | /: search | shift+←/→: cycle | q: quit" + } + } else if app.diff.view == DiffPaneView::Split { + " s: unified diff | j/k: scroll | pgup/pgdn: page | shift+←/→: cycle | f: zoom | q: quit" + } else if app.diff.search.active { + " type to search | enter: confirm | esc: cancel" + } else if !app.diff.search.query.is_empty() { + " n: next match | shift+n: prev match | /: new search | esc: clear" + } else if app.can_open_file_view() { + // The `l` toggle names its destination (Tree mode never + // reaches these arms — its right pane is always the file view). + if app.mode == ViewMode::Log { + " shift+←/→: cycle | j/k: scroll | pgup/pgdn: scroll | v: view file | s: split | /: search | t: new pane | f: zoom | l: status view | b: tree view | o: open project | q: quit" + } else { + " shift+←/→: cycle | j/k: scroll | pgup/pgdn: scroll | v: view file | s: split | /: search | t: new pane | f: zoom | l: log view | b: tree view | o: open project | q: quit" + } + } else { + // No file target for `v` (log view browsing commits, or + // nothing selected) — a hint for a no-op key would lie. + if app.mode == ViewMode::Log { + " shift+←/→: cycle | j/k: scroll | pgup/pgdn: scroll | s: split | /: search | t: new pane | f: zoom | l: status view | b: tree view | o: open project | q: quit" + } else { + " shift+←/→: cycle | j/k: scroll | pgup/pgdn: scroll | s: split | /: search | t: new pane | f: zoom | l: log view | b: tree view | o: open project | q: quit" + } + } + } + } +} diff --git a/src/ui/hit_test.rs b/src/ui/hit_test.rs new file mode 100644 index 0000000..471badd --- /dev/null +++ b/src/ui/hit_test.rs @@ -0,0 +1,102 @@ +use crate::app::{App, Focus}; +use crate::config::LayoutConfig; +use crate::ui::chrome::{Chrome, chrome_rows, main_content_constraints}; +use ratatui::layout::{Constraint, Direction, Layout, Position, Rect}; + +pub(crate) fn project_tab_at(tabs: Chrome<'_>, screen_area: Rect, x: u16, y: u16) -> Option { + crate::ui::project_tab::tab_at( + tabs.repo_paths, + tabs.active, + chrome_rows(screen_area).tabs, + x, + y, + ) +} + +pub(crate) fn terminal_content_areas( + app: &App, + screen_area: Rect, + layout: &LayoutConfig, +) -> Vec<(crate::backend::PaneId, Rect)> { + let Some(widget_area) = terminal_widget_area(app, screen_area, layout) else { + return Vec::new(); + }; + crate::ui::terminal_tab::visible_pane_content_areas(app, widget_area) +} + +pub(crate) fn upper_panel_at( + app: &App, + screen_area: Rect, + layout: &LayoutConfig, + x: u16, + y: u16, +) -> Option { + if app.terminal.fullscreen.fills_body() || app.diff.fullscreen || app.list_fullscreen { + return None; + } + let main = Layout::default() + .direction(Direction::Vertical) + .constraints(main_content_constraints(layout)) + .split(chrome_rows(screen_area).body); + let file_list_pct = layout.file_list_pct; + let upper = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(file_list_pct), + Constraint::Percentage(100u16.saturating_sub(file_list_pct)), + ]) + .split(main[0]); + + let pos = Position { x, y }; + if upper[0].contains(pos) { + Some(Focus::FileList) + } else if upper[1].contains(pos) { + Some(Focus::DiffViewer) + } else { + None + } +} + +pub(crate) fn pane_at( + app: &App, + screen_area: Rect, + layout: &LayoutConfig, + x: u16, + y: u16, +) -> Option<(crate::backend::PaneId, Rect)> { + terminal_content_areas(app, screen_area, layout) + .into_iter() + .find(|(_, rect)| rect.contains(Position { x, y })) +} + +pub(crate) fn terminal_widget_area( + app: &App, + screen_area: Rect, + layout: &LayoutConfig, +) -> Option { + let body_area = chrome_rows(screen_area).body; + + if app.terminal.fullscreen.fills_body() { + return Some(body_area); + } + if app.diff.fullscreen || app.list_fullscreen { + return None; + } + + let main = Layout::default() + .direction(Direction::Vertical) + .constraints(main_content_constraints(layout)) + .split(body_area); + Some(main[1]) +} + +pub(crate) fn tab_click_at( + app: &App, + screen_area: Rect, + layout: &LayoutConfig, + x: u16, + y: u16, +) -> Option { + let widget_area = terminal_widget_area(app, screen_area, layout)?; + crate::ui::terminal_tab::tab_target_at(app, widget_area, x, y) +} diff --git a/src/ui/log_view.rs b/src/ui/log_view/mod.rs similarity index 59% rename from src/ui/log_view.rs rename to src/ui/log_view/mod.rs index c736503..7850319 100644 --- a/src/ui/log_view.rs +++ b/src/ui/log_view/mod.rs @@ -226,197 +226,6 @@ impl LogView { } } -#[cfg(test)] -mod tests { - use super::*; - use git2::Oid; - - fn entry(time: i64) -> CommitEntry { - CommitEntry::new( - Oid::ZERO_SHA1, - "deadbee".to_string(), - format!("c{time}"), - "T".to_string(), - time, - ) - } - - #[test] - fn append_page_extends_commits_and_tracks_loaded_count() { - let mut lv = LogView::default(); - lv.set_commits(vec![entry(0), entry(1)]); - - lv.append_page(vec![entry(2), entry(3)], 2); - - assert_eq!(lv.commits.len(), 4); - assert_eq!(lv.loaded_count, 4); - assert!(!lv.fully_loaded); - assert!(!lv.pending_fetch); - } - - #[test] - fn append_page_short_result_marks_fully_loaded() { - let mut lv = LogView::default(); - lv.set_commits(vec![entry(0), entry(1)]); - - lv.append_page(vec![entry(2)], 3); - - assert_eq!(lv.commits.len(), 3); - assert!(lv.fully_loaded); - } - - #[test] - fn append_page_empty_result_marks_fully_loaded_without_extending() { - let mut lv = LogView::default(); - lv.set_commits(vec![entry(0)]); - lv.append_page(Vec::new(), 3); - - assert_eq!(lv.commits.len(), 1); - assert_eq!(lv.loaded_count, 1); - assert!(lv.fully_loaded); - assert!(!lv.pending_fetch); - } - - #[test] - fn mark_pending_is_idempotent() { - let mut lv = LogView::default(); - assert!(lv.mark_pending()); - assert!(!lv.mark_pending()); - lv.clear_pending(); - assert!(lv.mark_pending()); - } - - #[test] - fn set_commits_resets_pagination_state() { - let mut lv = LogView::default(); - lv.set_commits(vec![entry(0)]); - lv.append_page(vec![entry(1)], 5); - assert!(lv.fully_loaded); - - lv.set_commits(vec![entry(2), entry(3)]); - assert_eq!(lv.loaded_count, 2); - assert!(!lv.fully_loaded); - assert!(!lv.pending_fetch); - } - - fn named_entry(summary: &str) -> CommitEntry { - CommitEntry::new( - Oid::ZERO_SHA1, - "deadbee".to_string(), - summary.to_string(), - "T".to_string(), - 0, - ) - } - - #[test] - fn commit_filter_empty_query_includes_all_indices() { - let mut lv = LogView::default(); - lv.set_commits(vec![entry(0), entry(1), entry(2)]); - assert_eq!(lv.commits_filter_cache, vec![0, 1, 2]); - } - - #[test] - fn commit_filter_substring_is_case_insensitive() { - let mut lv = LogView::default(); - lv.set_commits(vec![ - named_entry("Fix Auth bug"), - named_entry("feat: AUTH refresh"), - named_entry("docs: readme"), - ]); - lv.commit_search_push('a'); - lv.commit_search_push('u'); - lv.commit_search_push('t'); - lv.commit_search_push('h'); - assert_eq!(lv.commits_filter_cache, vec![0, 1]); - } - - #[test] - fn append_page_extends_filter_cache_for_matching_tail() { - let mut lv = LogView::default(); - lv.set_commits(vec![named_entry("alpha"), named_entry("zulu")]); - lv.commit_search_push('a'); - assert_eq!(lv.commits_filter_cache, vec![0]); - - lv.append_page(vec![named_entry("quill"), named_entry("apple")], 2); - assert_eq!(lv.commits_filter_cache, vec![0, 3]); - } - - #[test] - fn cancel_commit_search_clears_query_and_resets_cache() { - let mut lv = LogView::default(); - lv.set_commits(vec![named_entry("alpha"), named_entry("zulu")]); - lv.start_commit_search(); - lv.commit_search_push('a'); - assert_eq!(lv.commits_filter_cache, vec![0]); - - lv.cancel_commit_search(); - assert!(!lv.commit_search_active); - assert!(lv.commit_search_query.is_empty()); - assert_eq!(lv.commits_filter_cache, vec![0, 1]); - } - - #[test] - fn confirm_commit_search_hides_bar_but_keeps_filter() { - let mut lv = LogView::default(); - lv.set_commits(vec![named_entry("alpha"), named_entry("zulu")]); - lv.start_commit_search(); - lv.commit_search_push('a'); - - let collapsed_to_cancel = lv.confirm_commit_search(); - assert!(!collapsed_to_cancel); - assert!(!lv.commit_search_active); - assert_eq!(lv.commit_search_query.as_str(), "a"); - assert_eq!(lv.commits_filter_cache, vec![0]); - } - - #[test] - fn confirm_commit_search_on_empty_query_collapses_to_cancel() { - let mut lv = LogView::default(); - lv.set_commits(vec![named_entry("alpha")]); - lv.start_commit_search(); - - let collapsed_to_cancel = lv.confirm_commit_search(); - assert!(collapsed_to_cancel); - assert!(!lv.commit_search_active); - } - - #[test] - fn set_commit_files_seeds_filter_cache_under_active_query() { - let mut lv = LogView::default(); - lv.file_search_push('r'); - lv.set_commit_files(vec![ - ChangedFile::unstaged_only("readme.md".into(), crate::git::diff::StatusKind::Modified), - ChangedFile::unstaged_only("src/lib.rs".into(), crate::git::diff::StatusKind::Modified), - ]); - assert_eq!(lv.commit_files_filter_cache, vec![0, 1]); - - lv.file_search_push('e'); - lv.file_search_push('a'); - // "readme.md" contains "rea"; "src/lib.rs" does not. - assert_eq!(lv.commit_files_filter_cache, vec![0]); - } - - #[test] - fn reset_drill_down_clears_file_search_state() { - let mut lv = LogView { - drill_down: true, - ..Default::default() - }; - lv.set_commit_files(vec![ChangedFile::unstaged_only( - "readme.md".into(), - crate::git::diff::StatusKind::Modified, - )]); - lv.start_file_search(); - lv.file_search_push('r'); - assert!(lv.file_search_active); - assert_eq!(lv.commit_files_filter_cache, vec![0]); - - lv.reset_drill_down(); - assert!(!lv.drill_down); - assert!(!lv.file_search_active); - assert!(lv.file_search_query.is_empty()); - assert!(lv.commit_files_filter_cache.is_empty()); - } -} +#[cfg(test)] +mod tests; diff --git a/src/ui/log_view/tests.rs b/src/ui/log_view/tests.rs new file mode 100644 index 0000000..95e6bca --- /dev/null +++ b/src/ui/log_view/tests.rs @@ -0,0 +1,192 @@ + use super::*; + use crate::git::diff::{ChangedFile, CommitEntry}; + use git2::Oid; + + fn entry(time: i64) -> CommitEntry { + CommitEntry::new( + Oid::ZERO_SHA1, + "deadbee".to_string(), + format!("c{time}"), + "T".to_string(), + time, + ) + } + + #[test] + fn append_page_extends_commits_and_tracks_loaded_count() { + let mut lv = LogView::default(); + lv.set_commits(vec![entry(0), entry(1)]); + + lv.append_page(vec![entry(2), entry(3)], 2); + + assert_eq!(lv.commits.len(), 4); + assert_eq!(lv.loaded_count, 4); + assert!(!lv.fully_loaded); + assert!(!lv.pending_fetch); + } + + #[test] + fn append_page_short_result_marks_fully_loaded() { + let mut lv = LogView::default(); + lv.set_commits(vec![entry(0), entry(1)]); + + lv.append_page(vec![entry(2)], 3); + + assert_eq!(lv.commits.len(), 3); + assert!(lv.fully_loaded); + } + + #[test] + fn append_page_empty_result_marks_fully_loaded_without_extending() { + let mut lv = LogView::default(); + lv.set_commits(vec![entry(0)]); + + lv.append_page(Vec::new(), 3); + + assert_eq!(lv.commits.len(), 1); + assert_eq!(lv.loaded_count, 1); + assert!(lv.fully_loaded); + assert!(!lv.pending_fetch); + } + + #[test] + fn mark_pending_is_idempotent() { + let mut lv = LogView::default(); + assert!(lv.mark_pending()); + assert!(!lv.mark_pending()); + lv.clear_pending(); + assert!(lv.mark_pending()); + } + + #[test] + fn set_commits_resets_pagination_state() { + let mut lv = LogView::default(); + lv.set_commits(vec![entry(0)]); + lv.append_page(vec![entry(1)], 5); + assert!(lv.fully_loaded); + + lv.set_commits(vec![entry(2), entry(3)]); + assert_eq!(lv.loaded_count, 2); + assert!(!lv.fully_loaded); + assert!(!lv.pending_fetch); + } + + fn named_entry(summary: &str) -> CommitEntry { + CommitEntry::new( + Oid::ZERO_SHA1, + "deadbee".to_string(), + summary.to_string(), + "T".to_string(), + 0, + ) + } + + #[test] + fn commit_filter_empty_query_includes_all_indices() { + let mut lv = LogView::default(); + lv.set_commits(vec![entry(0), entry(1), entry(2)]); + assert_eq!(lv.commits_filter_cache, vec![0, 1, 2]); + } + + #[test] + fn commit_filter_substring_is_case_insensitive() { + let mut lv = LogView::default(); + lv.set_commits(vec![ + named_entry("Fix Auth bug"), + named_entry("feat: AUTH refresh"), + named_entry("docs: readme"), + ]); + lv.commit_search_push('a'); + lv.commit_search_push('u'); + lv.commit_search_push('t'); + lv.commit_search_push('h'); + assert_eq!(lv.commits_filter_cache, vec![0, 1]); + } + + #[test] + fn append_page_extends_filter_cache_for_matching_tail() { + let mut lv = LogView::default(); + lv.set_commits(vec![named_entry("alpha"), named_entry("zulu")]); + lv.commit_search_push('a'); + assert_eq!(lv.commits_filter_cache, vec![0]); + + lv.append_page(vec![named_entry("quill"), named_entry("apple")], 2); + assert_eq!(lv.commits_filter_cache, vec![0, 3]); + } + + #[test] + fn cancel_commit_search_clears_query_and_resets_cache() { + let mut lv = LogView::default(); + lv.set_commits(vec![named_entry("alpha"), named_entry("zulu")]); + lv.start_commit_search(); + lv.commit_search_push('a'); + assert_eq!(lv.commits_filter_cache, vec![0]); + + lv.cancel_commit_search(); + assert!(!lv.commit_search_active); + assert!(lv.commit_search_query.is_empty()); + assert_eq!(lv.commits_filter_cache, vec![0, 1]); + } + + #[test] + fn confirm_commit_search_hides_bar_but_keeps_filter() { + let mut lv = LogView::default(); + lv.set_commits(vec![named_entry("alpha"), named_entry("zulu")]); + lv.start_commit_search(); + lv.commit_search_push('a'); + + let collapsed_to_cancel = lv.confirm_commit_search(); + assert!(!collapsed_to_cancel); + assert!(!lv.commit_search_active); + assert_eq!(lv.commit_search_query.as_str(), "a"); + assert_eq!(lv.commits_filter_cache, vec![0]); + } + + #[test] + fn confirm_commit_search_on_empty_query_collapses_to_cancel() { + let mut lv = LogView::default(); + lv.set_commits(vec![named_entry("alpha")]); + lv.start_commit_search(); + + let collapsed_to_cancel = lv.confirm_commit_search(); + assert!(collapsed_to_cancel); + assert!(!lv.commit_search_active); + } + + #[test] + fn set_commit_files_seeds_filter_cache_under_active_query() { + let mut lv = LogView::default(); + lv.file_search_push('r'); + lv.set_commit_files(vec![ + ChangedFile::unstaged_only("readme.md".into(), crate::git::diff::StatusKind::Modified), + ChangedFile::unstaged_only("src/lib.rs".into(), crate::git::diff::StatusKind::Modified), + ]); + assert_eq!(lv.commit_files_filter_cache, vec![0, 1]); + + lv.file_search_push('e'); + lv.file_search_push('a'); + // "readme.md" contains "rea"; "src/lib.rs" does not. + assert_eq!(lv.commit_files_filter_cache, vec![0]); + } + + #[test] + fn reset_drill_down_clears_file_search_state() { + let mut lv = LogView { + drill_down: true, + ..Default::default() + }; + lv.set_commit_files(vec![ChangedFile::unstaged_only( + "readme.md".into(), + crate::git::diff::StatusKind::Modified, + )]); + lv.start_file_search(); + lv.file_search_push('r'); + assert!(lv.file_search_active); + assert_eq!(lv.commit_files_filter_cache, vec![0]); + + lv.reset_drill_down(); + assert!(!lv.drill_down); + assert!(!lv.file_search_active); + assert!(lv.file_search_query.is_empty()); + assert!(lv.commit_files_filter_cache.is_empty()); + } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 529dc55..c9916fd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -14,275 +14,44 @@ pub mod tree_view; pub use search::SearchQuery; -use crate::app::{App, DiffPaneView, Focus, ViewMode}; +mod chrome; +mod helpers; +mod hint_bar; +mod hint_text; +mod hit_test; +mod notice; +#[cfg(test)] +mod tests; + +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, +}; +pub(crate) use hint_bar::{ + HintClick, empty_hint_click_at, hint_click_at, hint_spans, render_hint_bar, +}; +pub(crate) use hint_text::{EMPTY_HINT, EMPTY_HINT_ARMED, PREFIX_CHIP}; +pub(crate) use hit_test::{ + pane_at, project_tab_at, tab_click_at, terminal_content_areas, + upper_panel_at, +}; +pub(crate) use notice::render_notice_row; +#[cfg(test)] +pub(crate) use notice::home_relative_path; + +use crate::app::{App, ViewMode}; use crate::config::LayoutConfig; -use crate::git::diff::StatusKind; -use crate::runtime::terminal::TerminalFullscreen; -use crate::ui::status_view::RepoInput; use ratatui::{ Frame, - layout::{Constraint, Direction, Layout, Position, Rect}, + layout::{Constraint, Direction, Layout, Position}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + widgets::{Block, Borders, Paragraph}, }; use syntect::highlighting::ThemeSet; use syntect::parsing::SyntaxSet; -/// Extract a file path's extension as a `&str`, returning `""` when the path -/// has no extension or non-UTF-8 bytes. Shared by diff and file-view rendering -/// so syntax lookup behaves consistently regardless of the surface. -pub(crate) fn path_extension(path: &str) -> &str { - std::path::Path::new(path) - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") -} - -pub(crate) fn focused_border_style(focused: bool, accent: Color) -> Style { - if focused { - Style::default().fg(accent) - } else { - Style::default().fg(Color::DarkGray) - } -} - -pub(crate) fn status_color(status: StatusKind) -> Color { - match status { - StatusKind::Added => Color::Green, - StatusKind::Deleted => Color::Red, - StatusKind::Renamed => Color::Cyan, - StatusKind::TypeChanged => Color::Magenta, - StatusKind::Unmerged => Color::Red, - StatusKind::Untracked => Color::Gray, - StatusKind::Modified => Color::Yellow, - StatusKind::Unmodified => Color::DarkGray, - } -} - -/// Render a bordered, single-selection list with the project's standard -/// highlight styling. `selected` is clamped to `items.len() - 1` to match -/// the prior call sites' defensive behaviour. -/// Key legend for a panel or pane reached by a leader digit, e.g. `^F1`. -/// -/// Panels advertise the chord that actually reaches them. The bare F-key row -/// used to serve here, but it now selects project tabs, so a label reading -/// `F1 Files` would name a key that switches projects instead. -pub(crate) fn jump_legend(app: &App, digit: char) -> String { - format!("{}{}", app.leader_label(), digit) -} - -pub(crate) fn render_selectable_list( - frame: &mut Frame, - area: Rect, - title: String, - items: Vec>, - selected: Option, - border_style: Style, -) { - let len = items.len(); - let list = List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .title(title) - .border_style(border_style), - ) - .highlight_style( - Style::default() - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("> "); - - let mut state = ListState::default(); - if len > 0 - && let Some(idx) = selected - { - state.select(Some(idx.min(len - 1))); - } - frame.render_stateful_widget(list, area, &mut state); -} - -pub(crate) fn render_search_bar( - frame: &mut Frame, - query: &str, - is_active: bool, - area: Rect, - accent: Color, -) { - let cursor = if is_active { "█" } else { "" }; - let style = if is_active { - Style::default().fg(accent) - } else { - Style::default().fg(Color::DarkGray) - }; - frame.render_widget( - Paragraph::new(format!("/{query}{cursor}")).style(style), - area, - ); -} - -/// Split the screen into the four top-level rows: the project tab row, the -/// body, the notice row (repo identity, or a notice covering it), and the -/// hint bar. -/// -/// The bottom chrome sits at the bottom so a rejected repo path lands directly -/// above the input the user has to correct, instead of across the screen from -/// it. The project tabs go on top instead, where tab rows are conventionally -/// looked for, and because they name the thing the whole screen belongs to -/// rather than commenting on the input at the bottom. -/// -/// The tab row is permanent, not toggled by tab count. A row that came and -/// went would resize every PTY each time a project opened or closed — the same -/// churn that keeps the notice row an overlay rather than a row of its own. -/// Holding it always costs one SIGWINCH per pane at startup and none after. -/// -/// This is called from `draw` and from three geometry helpers that must land on -/// exactly the same cells — the PTY sizer, the upper-panel hit test, and the -/// hint-bar hit test. They were four hand-copied splits before; one drifting -/// from the others mis-sizes terminals or offsets every mouse click by a row, -/// so the split lives here only. -fn chrome_rows(screen_area: Rect) -> ChromeRows { - let outer = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Min(0), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(screen_area); - ChromeRows { - tabs: outer[0], - body: outer[1], - notice: outer[2], - hint: outer[3], - } -} - -/// The four top-level rows. Named rather than a tuple because four same-typed -/// Rects are too easy to mis-order at a call site. -struct ChromeRows { - tabs: Rect, - body: Rect, - notice: Rect, - hint: Rect, -} - -fn main_content_constraints(layout: &LayoutConfig) -> [Constraint; 2] { - [ - Constraint::Percentage(layout.upper_pct), - Constraint::Percentage(100u16.saturating_sub(layout.upper_pct)), - ] -} - -/// Slice `s` past its first `scroll_x` characters, returning the remainder. -/// Used by the file/commit list renderers to scroll long entries horizontally -/// without slicing inside a multi-byte char boundary. -pub(crate) fn char_offset(s: &str, scroll_x: usize) -> &str { - if scroll_x == 0 { - return s; - } - let byte_off = s - .char_indices() - .nth(scroll_x) - .map(|(b, _)| b) - .unwrap_or(s.len()); - &s[byte_off..] -} - -/// The process-level state the chrome draws, alongside the active project. -/// -/// Passed as data rather than as the `Workspace` itself: rendering reads one -/// project plus this summary, and taking the whole workspace would hand every -/// renderer access to projects it must not touch. -#[derive(Clone, Copy)] -pub struct Chrome<'a> { - pub repo_paths: &'a [String], - pub active: usize, - /// The open-repo dialog, which lives on the workspace because it must work - /// with no project open. - pub repo_input: &'a RepoInput, -} - -/// The project index a click at `(x, y)` selects, or `None` when the click is -/// not on a project tab. Shares `chrome_rows` with `draw`, so the hit boxes -/// track the rendered row. -pub(crate) fn project_tab_at(tabs: Chrome<'_>, screen_area: Rect, x: u16, y: u16) -> Option { - project_tab::tab_at( - tabs.repo_paths, - tabs.active, - chrome_rows(screen_area).tabs, - x, - y, - ) -} - -/// The empty screen's hint legend, unarmed and armed. Shared by the renderer -/// and the click hit-test so a clickable label and its target cannot drift — -/// the armed row is laid out differently (chip plus bare keys), so measuring -/// the wrong one would leave parts of a rendered label unclickable. -const EMPTY_HINT: &str = " o: open project | q: quit"; -const EMPTY_HINT_ARMED: &str = " o: open project | q: quit | esc: cancel"; - -/// The click action for `(x, y)` on the empty screen's hint row, or `None` -/// off it. Only `o` resolves — quitting stays a deliberate keyboard act, as -/// on the project screen. -pub(crate) fn empty_hint_click_at( - screen_area: Rect, - leader_label: &str, - prefix_armed: bool, - mouse_enabled: bool, - x: u16, - y: u16, -) -> Option { - // Gated like `hint_click_at`: with capture off the row renders plain, and - // 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 { - return None; - } - let hint_area = chrome_rows(screen_area).hint; - if hint_area.height == 0 || !hint_area.contains(Position { x, y }) { - return None; - } - let (chip, text) = if prefix_armed { - (PREFIX_CHIP, EMPTY_HINT_ARMED) - } else { - ("", EMPTY_HINT) - }; - let mut cursor = hint_area.x + Span::raw(chip).width() as u16; - for (i, segment) in text.split(" | ").enumerate() { - if i > 0 { - cursor += Span::raw(" | ").width() as u16; - } - 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. - 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 { - return None; - } - let (keyspec, _) = segment.split_once(':')?; - return segment_click(keyspec); - } - cursor += width; - } - None -} - -/// Render the screen with no project open. -/// -/// The body is a placeholder rather than a borrowed panel: every viewer here -/// reads a repo, and there is none. The chrome still draws — an empty tab row, -/// the notice row (which carries a rejected path from the open dialog, since -/// repo identity has nothing to show), and a hint bar naming the only two -/// things that work from here. pub fn draw_empty( frame: &mut Frame, chrome: Chrome<'_>, @@ -342,10 +111,6 @@ pub fn draw_empty( frame.render_widget(Paragraph::new(hint), rows.hint); } -/// Render one frame, returning the screen cell the terminal cursor was placed -/// on (`None` when no cursor is shown). Ratatui applies the cursor to the local -/// terminal itself, but the web mirror streams only the cell buffer, so the -/// position has to be handed back for `web::WebServer::broadcast` to replay. pub fn draw( frame: &mut Frame, app: &mut App, @@ -416,1715 +181,3 @@ pub fn draw( frame.render_widget(render_hint_bar(app, tabs, accent), hint_area); cursor } - -/// Content Rect (post border) for every currently visible terminal pane, -/// keyed by pane id — `None` when the terminal panel isn't shown at all -/// (diff/list fullscreen). Used to resize each pane's PTY to exactly the -/// area `terminal_tab::render` draws it in. -pub(crate) fn terminal_content_areas( - app: &App, - screen_area: Rect, - layout: &LayoutConfig, -) -> Vec<(crate::backend::PaneId, Rect)> { - let Some(widget_area) = terminal_widget_area(app, screen_area, layout) else { - return Vec::new(); - }; - terminal_tab::visible_pane_content_areas(app, widget_area) -} - -/// The upper panel owning screen cell `(x, y)` in the normal split layout — -/// the click-to-focus hit test for the file/commit/tree list and the diff -/// viewer, mirroring `draw`'s geometry. `None` in every fullscreen state -/// (a body-filling panel already holds focus, and the terminal case belongs -/// to `pane_at`) and for cells on the header/hint rows or the terminal. -pub(crate) fn upper_panel_at( - app: &App, - screen_area: Rect, - layout: &LayoutConfig, - x: u16, - y: u16, -) -> Option { - if app.terminal.fullscreen.fills_body() || app.diff.fullscreen || app.list_fullscreen { - return None; - } - let main = Layout::default() - .direction(Direction::Vertical) - .constraints(main_content_constraints(layout)) - .split(chrome_rows(screen_area).body); - let file_list_pct = layout.file_list_pct; - let upper = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(file_list_pct), - Constraint::Percentage(100u16.saturating_sub(file_list_pct)), - ]) - .split(main[0]); - - let pos = Position { x, y }; - if upper[0].contains(pos) { - Some(Focus::FileList) - } else if upper[1].contains(pos) { - Some(Focus::DiffViewer) - } else { - None - } -} - -/// The visible pane whose content rect contains screen cell `(x, y)`, with -/// that rect — the hit test for mouse events, using 0-based screen -/// coordinates as crossterm reports them. `None` when the cell lies outside -/// every pane's content (upper panels, borders, tab bar), or when the -/// terminal isn't drawn at all because another panel is fullscreen. -pub(crate) fn pane_at( - app: &App, - screen_area: Rect, - layout: &LayoutConfig, - x: u16, - y: u16, -) -> Option<(crate::backend::PaneId, Rect)> { - terminal_content_areas(app, screen_area, layout) - .into_iter() - .find(|(_, rect)| rect.contains(Position { x, y })) -} - -/// The full terminal widget area (tab row + content), matching exactly what -/// `terminal_tab::render` is given as its `area` argument in `draw`. `None` -/// when a different pane is fullscreen and the terminal isn't drawn at all. -fn terminal_widget_area(app: &App, screen_area: Rect, layout: &LayoutConfig) -> Option { - let body_area = chrome_rows(screen_area).body; - - if app.terminal.fullscreen.fills_body() { - return Some(body_area); - } - if app.diff.fullscreen || app.list_fullscreen { - return None; - } - - let main = Layout::default() - .direction(Direction::Vertical) - .constraints(main_content_constraints(layout)) - .split(body_area); - Some(main[1]) -} - -/// Render the top header strip: `repo-path branch ↑N ↓M`. Branch and -/// tracking chips are omitted when their data is absent so the line stays -/// short on detached HEAD or empty repos. -/// The notice row: a notice when one is raised, otherwise repo identity. -/// -/// A notice covers the repo/branch line rather than taking a row of its own. -/// Adding a row would resize every PTY as notices come and go, and this is the -/// one chrome row whose content is ambient and re-derived every frame, so -/// covering it costs nothing — unlike the hint bar below, which holds the -/// repo-input text the user is editing. -fn render_notice_row<'a>(app: &'a App, accent: Color) -> Paragraph<'a> { - if let Some(notice) = app.notice.as_ref() { - return Paragraph::new(Line::from(Span::styled( - format!(" {}", notice.line()), - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ))); - } - render_repo_header(app, accent) -} - -fn render_repo_header<'a>(app: &'a App, accent: Color) -> Paragraph<'a> { - let display_path = home_relative_path(&app.repo_path); - let mut spans: Vec> = vec![Span::styled( - format!(" {display_path} "), - Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::BOLD), - )]; - if let Some(branch) = app.branch_name.as_deref() { - spans.push(Span::styled( - format!(" {branch} "), - Style::default().fg(accent).add_modifier(Modifier::BOLD), - )); - } - if let Some(t) = &app.tracking - && (t.ahead > 0 || t.behind > 0) - { - spans.push(Span::styled( - format!(" ↑{} ↓{} ", t.ahead, t.behind), - Style::default().fg(Color::Cyan), - )); - } - Paragraph::new(Line::from(spans)) -} - -/// Replace the user's home prefix with `~` for display, leaving non-home -/// paths unchanged. Trailing path separator (libgit2 workdirs include one) -/// is stripped so the header stays compact. -fn home_relative_path(path: &str) -> String { - let trimmed = path.trim_end_matches('/'); - if let Some(home) = dirs::home_dir() - && let Some(home_str) = home.to_str() - && let Some(rest) = trimmed.strip_prefix(home_str) - { - return format!("~{rest}"); - } - trimmed.to_string() -} - -/// The `PREFIX` indicator chip. Shared by `render_hint_bar` and -/// `hint_click_at` so the click hit-test's column offset can never drift -/// from what is drawn. -const PREFIX_CHIP: &str = " PREFIX "; - -/// The armed-prefix follow-up legend (everything after the chip). Single -/// source for rendering and click hit-testing. -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 (see `main::resolve_prefix_action`). - let digits = if app.terminal.fullscreen.fills_body() { - "1-8: pane" - } else { - "1-9: focus/pane" - }; - // `w`/`s` only act under their availability predicates (see - // `App::can_close_pane`/`can_swap_panes`), so only advertise them there — - // a hint for a no-op key would lie. - let close = if app.can_close_pane() { - "w: close pane | " - } else { - "" - }; - let swap = if app.can_swap_panes() { - "s: swap pane | " - } else { - "" - }; - // The view toggles name their destination from the current mode, matching - // the normal legends, instead of a generic `log/status` label. - let (log_toggle, tree_toggle) = match app.mode { - ViewMode::Log => ("l: status view", "b: tree view"), - ViewMode::Status => ("l: log view", "b: tree view"), - ViewMode::Tree => ("l: log view", "b: status view"), - }; - // `x` is advertised unconditionally, unlike `w`/`s` above: refusing to - // close the last project reports why on the notice row, so the key always - // produces a visible result rather than silently doing nothing. - format!( - " t: new pane | {close}{swap}{log_toggle} | {tree_toggle} | f: fullscreen | o: open project | x: close project | p: theme | r: redraw | q: quit | {digits} | esc: cancel" - ) -} - -/// Build the styled spans for a hint legend, inverting (`REVERSED`) every -/// clickable segment — the whole `key: description` label, matching the -/// click target exactly — so the bar itself shows which hints respond to a -/// click. Consumes the same literal and `" | "` segmentation as -/// `hint_click_at` — and decides clickability with the same `segment_click` -/// — so an inverted label can never disagree with the hit test. Only styles -/// change; the rendered text (and thus every column offset) stays identical. -/// `mark_clickable` is `[mouse] enabled`: with capture off a click can never -/// arrive, so no label may advertise one. -fn hint_spans(text: &str, leader: &str, mark_clickable: bool) -> Vec> { - let base = Style::default().fg(Color::DarkGray); - let inverted = base.add_modifier(Modifier::REVERSED); - let mut spans = Vec::new(); - for (i, segment) in text.split(" | ").enumerate() { - if i > 0 { - spans.push(Span::styled(" | ", base)); - } - let rendered = segment.replace("", leader); - let clickable = mark_clickable - && segment - .split_once(':') - .and_then(|(keyspec, _)| segment_click(keyspec)) - .is_some(); - if clickable { - // Invert the whole segment — the entire label is the click - // target, so the affordance covers exactly what responds. - // Leading whitespace stays 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() { - spans.push(Span::styled(lead_ws.to_string(), base)); - } - spans.push(Span::styled(label.to_string(), inverted)); - } else { - spans.push(Span::styled(rendered, base)); - } - } - spans -} - -fn render_hint_bar<'a>(app: &'a App, chrome: Chrome<'a>, accent: Color) -> Paragraph<'a> { - if chrome.repo_input.active { - // A rejected path is reported on the notice row directly above, so - // this row stays a plain input line. - return Paragraph::new(Line::from(vec![ - Span::styled("repo: ", Style::default().fg(accent)), - Span::raw(chrome.repo_input.buf.as_str()), - Span::styled("█", Style::default().fg(accent)), - ])); - } - if app.prefix_armed() { - let mut spans = vec![Span::styled( - PREFIX_CHIP, - Style::default() - .fg(Color::Black) - .bg(accent) - .add_modifier(Modifier::BOLD), - )]; - spans.extend(hint_spans( - &prefix_armed_hint_text(app), - &app.leader_label(), - app.mouse_enabled, - )); - return Paragraph::new(Line::from(spans)); - } - if app.awaiting_swap_target() { - // The swap-target digits follow the same layout-aware mapping as the - // focus jumps (see `main::resolve_prefix_action`): `1-8` while the - // terminal fills the body, `3-9,0` in the split view. - let digits = if app.terminal.fullscreen.fills_body() { - "1-8" - } else { - "3-9,0" - }; - return Paragraph::new(Line::from(vec![ - Span::styled( - " SWAP ", - Style::default() - .fg(Color::Black) - .bg(accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" {digits}: swap active pane with this pane | esc: cancel"), - Style::default().fg(Color::DarkGray), - ), - ])); - } - // `` in the hint literal resolves to the configured leader chord - // (e.g. `^F`) so the footer always names the actual key to press rather - // than an abstract word. - Paragraph::new(Line::from(hint_spans( - normal_hint_literal(app), - &app.leader_label(), - app.mouse_enabled, - ))) -} - -/// 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. -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 - // would look different from Grid; otherwise the cycle skips Zoom and - // `f` exits. - TerminalFullscreen::Grid if app.terminal.zoom_distinct_from_grid() => { - return " : leader | shift+↑/↓: scroll | shift+pgup/dn: page scroll | shift+←/→: cycle pane | f: zoom active pane | t: new pane | w: close pane | q: quit"; - } - TerminalFullscreen::Grid => { - return " : leader | shift+↑/↓: scroll | shift+pgup/dn: page scroll | f: exit fullscreen | t: new pane | w: close pane | q: quit"; - } - TerminalFullscreen::Zoom => { - return " : leader | shift+↑/↓: scroll | shift+pgup/dn: page scroll | shift+←/→: cycle pane | f: exit fullscreen | t: new pane | w: close pane | q: quit"; - } - TerminalFullscreen::Off => {} - } - if app.diff.fullscreen { - let hint = if app.diff.view == DiffPaneView::File { - // Tree mode's right pane is permanently the file view — `v` - // can't leave it, so don't advertise a no-op. - if app.mode == ViewMode::Tree { - " f: exit zoom | j/k: scroll | pgup/pgdn: page | q: quit" - } else { - " f: exit zoom | v: back to diff | j/k: scroll | pgup/pgdn: page | q: quit" - } - } else if app.diff.view == DiffPaneView::Split { - " f: exit zoom | s: unified diff | j/k: scroll | pgup/pgdn: page | q: quit" - } else if app.diff.search.active { - " type to search | enter: confirm | esc: cancel" - } else if !app.diff.search.query.is_empty() { - " f: exit zoom | n: next match | shift+n: prev match | /: new search | esc: clear" - } else if app.can_open_file_view() { - " f: exit zoom | j/k: scroll | v: view file | s: split | /: search | pgup/pgdn: page | q: quit" - } else { - // No file target for `v` (log view browsing commits, or nothing - // selected) — a hint for a no-op key would lie. - " f: exit zoom | j/k: scroll | s: split | /: search | pgup/pgdn: page | q: quit" - }; - return hint; - } - if app.list_fullscreen { - let hint = match app.mode { - ViewMode::Log if app.log_view.drill_down => { - " f: exit zoom | esc: back to commits | j/k: navigate files | q: quit" - } - ViewMode::Log => { - " f: exit zoom | l: status view | b: tree view | j/k: navigate commits | enter: view files | q: quit" - } - ViewMode::Status => { - " f: exit zoom | j/k: navigate | /: search | l: log view | b: tree view | q: quit" - } - ViewMode::Tree => { - " f: exit zoom | j/k: navigate | /: search | →/enter: expand | ←: collapse | b: status view | l: log view | q: quit" - } - }; - return hint; - } - if let Focus::Terminal = app.focus { - // The `l` toggle names its destination: from Log mode it returns to - // the status view, from Status/Tree it enters the log view. - return if app.mode == ViewMode::Log { - " : leader | shift+↑/↓: scroll | shift+pgup/dn: page scroll | shift+←/→: cycle | t: new pane | w: close pane | f: fullscreen | l: status view | o: open project | q: quit" - } else { - " : leader | shift+↑/↓: scroll | shift+pgup/dn: page scroll | shift+←/→: cycle | t: new pane | w: close pane | f: fullscreen | l: log view | o: open project | q: quit" - }; - } - match app.focus { - Focus::Terminal => unreachable!("Focus::Terminal handled above"), - Focus::FileList => match app.mode { - ViewMode::Log => { - if app.log_view.drill_down { - " esc: back to commits | j/k: navigate files | shift+←/→: cycle | q: quit" - } else { - " shift+←/→: cycle | j/k: navigate commits | enter: view files | t: new pane | f: fullscreen | l: status view | b: tree view | o: open project | q: quit" - } - } - ViewMode::Status => { - " shift+←/→: cycle | j/k: navigate | /: search | t: new pane | f: fullscreen | l: log view | b: tree view | o: open project | q: quit" - } - ViewMode::Tree => { - " shift+←/→: cycle | j/k: navigate | /: search | →/enter: expand | ←: collapse | b: status view | l: log view | q: quit" - } - }, - Focus::DiffViewer => { - if app.diff.view == DiffPaneView::File && app.diff.search.active { - " type to search | enter: confirm | esc: cancel" - } else if app.diff.view == DiffPaneView::File && !app.diff.search.query.is_empty() { - " n: next match | shift+n: prev match | /: new search | esc: clear" - } else if app.diff.view == DiffPaneView::File { - // Tree mode's right pane is permanently the file view — `v` - // can't leave it, so don't advertise a no-op. - if app.mode == ViewMode::Tree { - " j/k: scroll | pgup/pgdn: page | /: search | shift+←/→: cycle | q: quit" - } else { - " v: back to diff | j/k: scroll | pgup/pgdn: page | /: search | shift+←/→: cycle | q: quit" - } - } else if app.diff.view == DiffPaneView::Split { - " s: unified diff | j/k: scroll | pgup/pgdn: page | shift+←/→: cycle | f: zoom | q: quit" - } else if app.diff.search.active { - " type to search | enter: confirm | esc: cancel" - } else if !app.diff.search.query.is_empty() { - " n: next match | shift+n: prev match | /: new search | esc: clear" - } else if app.can_open_file_view() { - // The `l` toggle names its destination (Tree mode never - // reaches these arms — its right pane is always the file view). - if app.mode == ViewMode::Log { - " shift+←/→: cycle | j/k: scroll | pgup/pgdn: scroll | v: view file | s: split | /: search | t: new pane | f: zoom | l: status view | b: tree view | o: open project | q: quit" - } else { - " shift+←/→: cycle | j/k: scroll | pgup/pgdn: scroll | v: view file | s: split | /: search | t: new pane | f: zoom | l: log view | b: tree view | o: open project | q: quit" - } - } else { - // No file target for `v` (log view browsing commits, or - // nothing selected) — a hint for a no-op key would lie. - if app.mode == ViewMode::Log { - " shift+←/→: cycle | j/k: scroll | pgup/pgdn: scroll | s: split | /: search | t: new pane | f: zoom | l: status view | b: tree view | o: open project | q: quit" - } else { - " shift+←/→: cycle | j/k: scroll | pgup/pgdn: scroll | s: split | /: search | t: new pane | f: zoom | l: log view | b: tree view | o: open project | q: quit" - } - } - } - } -} - -/// The pane index a click at screen cell `(x, y)` on the terminal tab bar -/// jumps to — a tab targets its own pane, a `+N` hidden marker the nearest -/// hidden pane on its side. `None` off the tab row or when the terminal -/// isn't drawn (another panel fullscreen). Mirrors `pane_at`'s geometry -/// sourcing: the tab row comes from the same `terminal_widget_area` the -/// renderer draws into. -pub(crate) fn tab_click_at( - app: &App, - screen_area: Rect, - layout: &LayoutConfig, - x: u16, - y: u16, -) -> Option { - let widget_area = terminal_widget_area(app, screen_area, layout)?; - terminal_tab::tab_target_at(app, widget_area, x, y) -} - -/// A clickable hint-bar shortcut, expressed as the key(s) the label names so -/// the caller can dispatch it through the exact same path as a keypress. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum HintClick { - /// The bare `` label — press the leader chord alone, arming the - /// prefix so the armed follow-up row (itself clickable) takes over. - Arm, - /// ` c` — press the leader chord, then `c`. - Leader(char), - /// A bare key `c` (a focus-local command, or an armed-prefix follow-up — - /// the armed state already lives in `App`, so the bare key resolves). - Plain(char), -} - -/// The clickable shortcut under screen cell `(x, y)` on the bottom hint row, -/// or `None` for anything else: a cell off the hint row, an informational -/// segment (`j/k: navigate`, digit legends), a separator, or a modal row -/// (repo input, swap target, status message — none carry clickable hints). -/// -/// Segments the same text `render_hint_bar` draws — `prefix_armed_hint_text` -/// / `normal_hint_literal` are shared — measuring rendered display widths, -/// so the hit test cannot drift from the screen. -pub(crate) fn hint_click_at( - app: &App, - chrome: Chrome<'_>, - screen_area: Rect, - x: u16, - y: u16, -) -> Option { - // With mouse capture off no click can reach us anyway, but the bar also - // renders no inverted labels (`hint_spans`) — keep the affordance and the - // hit test in agreement rather than relying on the caller. - if !app.mouse_enabled { - return None; - } - let hint_area = chrome_rows(screen_area).hint; - if hint_area.height == 0 || !hint_area.contains(Position { x, y }) { - return None; - } - - // Row selection mirrors `render_hint_bar`'s branch order exactly, or a - // click would resolve against a row the user isn't looking at. Notices no - // longer appear here (they own the row above), so they don't feature. - let (chip, text) = if chrome.repo_input.active { - return None; - } else if app.prefix_armed() { - (PREFIX_CHIP, prefix_armed_hint_text(app)) - } else if app.awaiting_swap_target() { - return None; - } else { - ("", normal_hint_literal(app).to_string()) - }; - - let leader = app.leader_label(); - let mut cursor = hint_area.x + Span::raw(chip).width() as u16; - for (i, segment) in text.split(" | ").enumerate() { - if i > 0 { - cursor += Span::raw(" | ").width() as u16; - } - let rendered = segment.replace("", &leader); - let width = Span::raw(rendered.as_str()).width() as u16; - if x >= cursor && x < cursor + width { - // Leading whitespace renders plain (see `hint_spans`), so it is - // not part of the click target either — the clickable range must - // match the inverted label cell for cell. - 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 { - return None; - } - let (keyspec, _) = segment.split_once(':')?; - return segment_click(keyspec); - } - cursor += width; - } - None -} - -/// Map a hint segment's key label to its click action. The bare `` -/// label arms the prefix (the armed row's follow-ups are clickable in turn, -/// completing a mouse-only flow). Beyond that, only discrete commands are -/// clickable; everything else returns `None`: -/// - continuous navigation (`j/k`, `shift+↑/↓`, `pgup/pgdn`, …) — a click -/// has no sensible single-step meaning, -/// - digit legends (`1-8`, `3-9,0`) — the digit is the argument, a click -/// doesn't name one, -/// - `q: quit` — deliberately excluded so one stray click can't end the -/// session, -/// - `esc`/`enter` and free-text labels. -fn segment_click(keyspec: &str) -> Option { - let spec = keyspec.trim(); - if spec == "" { - return Some(HintClick::Arm); - } - if let Some(rest) = spec.strip_prefix(" ") { - let mut chars = rest.chars(); - if let (Some(c), None) = (chars.next(), chars.next()) - && matches!(c, 't' | 'w' | 'f' | 'l' | 'b' | 'o') - { - return Some(HintClick::Leader(c)); - } - return None; - } - let mut chars = spec.chars(); - if let (Some(c), None) = (chars.next(), chars.next()) - && matches!( - c, - 't' | 'w' | 's' | 'l' | 'b' | 'f' | 'o' | 'x' | 'p' | 'r' | 'v' | '/' - ) - { - return Some(HintClick::Plain(c)); - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app::NoticeKind; - use crate::app::tests::{app_with_fake_backend, app_with_files}; - use crate::runtime::terminal::TerminalFullscreen; - use ratatui::{Terminal, backend::TestBackend}; - - /// Render the notice row into a wide buffer and return its flattened text. - fn notice_text(app: &App) -> String { - let mut terminal = Terminal::new(TestBackend::new(200, 1)).unwrap(); - terminal - .draw(|frame| frame.render_widget(render_notice_row(app, Color::Yellow), frame.area())) - .unwrap(); - let buf = terminal.backend().buffer(); - (0..buf.area.width) - .map(|x| buf[(x, 0)].symbol()) - .collect::() - } - - /// Render the hint bar into a wide buffer and return its flattened text so - /// footer wording can be asserted layout by layout. - /// A workspace holding one project, for the dialog tests — the dialog is - /// workspace state, but its rejection notice lands on the active project. - fn test_workspace() -> crate::workspace::Workspace { - let mut ws = crate::workspace::Workspace::new(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('f'), - crossterm::event::KeyModifiers::CONTROL, - )); - ws.add(app_with_files(vec![])); - ws - } - - /// A chrome view with no tabs and a closed dialog — the shape most hint - /// assertions want, since they are about one project's footer. - fn plain_chrome(repo_input: &RepoInput) -> Chrome<'_> { - Chrome { - repo_paths: &[], - active: 0, - repo_input, - } - } - - fn hint_text(app: &App) -> String { - let repo_input = RepoInput::default(); - hint_text_with(app, plain_chrome(&repo_input)) - } - - fn hint_text_with(app: &App, chrome: Chrome<'_>) -> String { - let mut terminal = Terminal::new(TestBackend::new(200, 1)).unwrap(); - terminal - .draw(|frame| { - frame.render_widget(render_hint_bar(app, chrome, Color::Yellow), frame.area()) - }) - .unwrap(); - let buf = terminal.backend().buffer(); - (0..buf.area.height) - .map(|y| { - (0..buf.area.width) - .map(|x| buf[(x, y)].symbol()) - .collect::() - }) - .collect::>() - .join("\n") - } - - /// The inversion is the clickability affordance, so the two must agree - /// cell for cell: every column the hint bar renders REVERSED must - /// resolve to a click action, every clickable column must render - /// REVERSED, and at least one such column must exist. - fn assert_inverted_cells_are_clickable(app: &App) { - let repo_input = RepoInput::default(); - let chrome = plain_chrome(&repo_input); - let mut terminal = Terminal::new(TestBackend::new(200, 1)).unwrap(); - terminal - .draw(|frame| { - frame.render_widget(render_hint_bar(app, chrome, Color::Yellow), frame.area()) - }) - .unwrap(); - let buf = terminal.backend().buffer(); - // `hint_click_at` takes full-screen coordinates: hint row = row 2 of - // a 3-row screen, with the same x origin as the 1-row render above. - let screen = Rect::new(0, 0, 200, 3); - let mut inverted = 0; - for x in 0..200u16 { - let is_inverted = buf[(x, 0)].modifier.contains(Modifier::REVERSED); - let is_clickable = hint_click_at(app, chrome, screen, x, 2).is_some(); - assert_eq!( - is_inverted, is_clickable, - "hint cell at column {x}: inverted={is_inverted} but clickable={is_clickable}" - ); - inverted += is_inverted as u32; - } - assert!( - inverted > 0, - "at least one clickable key label must render inverted" - ); - } - - /// A rejected path is reported on the notice row while the input keeps the - /// text being corrected on the row below. The message used to be written to - /// a field the repo-input row never rendered, so the confirm looked like it - /// did nothing at all. - #[test] - fn repo_input_reports_a_rejected_path_on_the_notice_row() { - let mut ws = test_workspace(); - ws.start_repo_input(); - ws.repo_input.buf = "/definitely/not/here".to_string(); - ws.confirm_repo_input(); - - assert!( - ws.repo_input.active, - "a rejected path must leave the dialog open for correction" - ); - // With a project open the rejection lands on that project's notice row, - // directly above the input still holding the text to correct. - let notice = notice_text(ws.active().unwrap()); - assert!( - notice.contains("no such directory"), - "the notice row must say why the confirm was rejected, got: {notice}" - ); - let repo_input = ws.repo_input.clone(); - let hint = hint_text_with(ws.active().unwrap(), plain_chrome(&repo_input)); - assert!( - hint.contains("/definitely/not/here"), - "the rejected text must stay in the input, got: {hint}" - ); - } - - #[test] - fn repo_input_notice_clears_once_the_path_is_edited() { - let mut ws = test_workspace(); - ws.start_repo_input(); - ws.repo_input.buf = "/definitely/not/here".to_string(); - ws.confirm_repo_input(); - ws.repo_input_pop(); - - let notice = notice_text(ws.active().unwrap()); - assert!( - !notice.contains("no such directory"), - "editing the path must clear the stale verdict, got: {notice}" - ); - } - - /// The notice row is the one place every kind reports, and no overlay may - /// shadow it — the hint bar's own early-returns are what made a notice - /// invisible before it moved off that row. - #[test] - fn notice_row_shows_notices_through_every_overlay() { - for setup in [ - (|app: &mut App| app.arm_prefix()) as fn(&mut App), - |app: &mut App| app.begin_swap_target(), - ] { - let mut app = app_with_fake_backend(); - setup(&mut app); - app.raise_notice(NoticeKind::Git, "not a repo"); - let text = notice_text(&app); - assert!( - text.contains("git error: not a repo"), - "an open overlay must not shadow the notice row, got: {text}" - ); - } - } - - /// With nothing raised the row is the repo/branch line, and it comes back - /// intact after a notice is cleared. - #[test] - fn notice_row_falls_back_to_repo_identity() { - let mut app = app_with_files(vec![]); - app.repo_path = "/tmp/somewhere".to_string(); - let before = notice_text(&app); - assert!(before.contains("/tmp/somewhere"), "got: {before}"); - - app.raise_notice(NoticeKind::Tree, "boom"); - assert!(!notice_text(&app).contains("/tmp/somewhere")); - - app.clear_notice(NoticeKind::Tree); - assert_eq!(notice_text(&app), before); - } - - #[test] - fn hint_bar_inverts_only_clickable_key_labels() { - let app = app_with_fake_backend(); - assert_inverted_cells_are_clickable(&app); - } - - /// Render a full frame and flatten it to text. - fn drawn_text(app: &mut App, tab_paths: &[String], active: usize) -> String { - let mut terminal = Terminal::new(TestBackend::new(120, 20)).unwrap(); - let ss = two_face::syntax::extra_newlines(); - let ts = ThemeSet::load_defaults(); - terminal - .draw(|frame| { - let tabs = Chrome { - repo_paths: tab_paths, - active, - repo_input: &RepoInput::default(), - }; - draw( - frame, - app, - tabs, - &ss, - &ts, - &LayoutConfig::default(), - Color::Yellow, - ); - }) - .unwrap(); - let buf = terminal.backend().buffer(); - (0..buf.area.height) - .map(|y| { - (0..buf.area.width) - .map(|x| buf[(x, y)].symbol()) - .collect::() - }) - .collect::>() - .join("\n") - } - - /// Render the empty screen and flatten it to text. - fn drawn_empty( - repo_input: &RepoInput, - notice: Option<&crate::app::Notice>, - armed: bool, - ) -> String { - let mut terminal = Terminal::new(TestBackend::new(90, 12)).unwrap(); - let leader = crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('f'), - crossterm::event::KeyModifiers::CONTROL, - ); - terminal - .draw(|frame| { - let chrome = Chrome { - repo_paths: &[], - active: 0, - repo_input, - }; - draw_empty(frame, chrome, notice, leader, armed, false, Color::Yellow); - }) - .unwrap(); - let buf = terminal.backend().buffer(); - (0..buf.area.height) - .map(|y| { - (0..buf.area.width) - .map(|x| buf[(x, y)].symbol()) - .collect::() - }) - .collect::>() - .join("\n") - } - - #[test] - fn the_empty_screen_names_the_only_two_things_that_work() { - let text = drawn_empty(&RepoInput::default(), None, false); - - assert!(text.contains("no project open"), "got: {text}"); - assert!(text.contains("^F o: open project"), "got: {text}"); - assert!(text.contains("^F q: quit"), "got: {text}"); - } - - #[test] - fn the_empty_screen_shows_the_prefix_chip_when_armed() { - // Pressing the leader with no project open has to look like it did - // something, or it reads as a dead key. - let text = drawn_empty(&RepoInput::default(), None, true); - - assert!(text.contains("PREFIX"), "got: {text}"); - assert!(text.contains("o: open project"), "got: {text}"); - assert!(text.contains("esc: cancel"), "got: {text}"); - } - - #[test] - fn the_empty_screen_shows_the_dialog_and_its_rejection() { - // The dialog and its notice are the reason the empty screen keeps its - // chrome at all — a rejected path must still report why. - let repo_input = RepoInput { - active: true, - buf: "/definitely/not/here".to_string(), - prefilled: false, - }; - let notice = crate::app::Notice::new(NoticeKind::RepoInput, "no such directory"); - - let text = drawn_empty(&repo_input, Some(¬ice), false); - - assert!(text.contains("repo: /definitely/not/here"), "got: {text}"); - assert!(text.contains("no such directory"), "got: {text}"); - } - - #[test] - fn the_project_tab_row_survives_every_fullscreen_mode() { - // Chrome is rendered before the layout branches precisely so no view - // mode can strand the user without knowing which project they are in. - let paths = vec!["/w/api".to_string(), "/w/web".to_string()]; - - let mut app = app_with_fake_backend(); - assert!(drawn_text(&mut app, &paths, 0).contains("F2 web"), "split"); - - let mut app = app_with_fake_backend(); - app.terminal.fullscreen = TerminalFullscreen::Grid; - assert!( - drawn_text(&mut app, &paths, 0).contains("F2 web"), - "terminal fullscreen" - ); - - let mut app = app_with_files(vec!["a.rs"]); - app.list_fullscreen = true; - assert!( - drawn_text(&mut app, &paths, 0).contains("F2 web"), - "list fullscreen" - ); - - let mut app = app_with_files(vec!["a.rs"]); - app.diff.fullscreen = true; - assert!( - drawn_text(&mut app, &paths, 0).contains("F2 web"), - "diff fullscreen" - ); - } - - #[test] - fn project_tab_at_matches_the_rendered_row() { - // The hit test derives from `chrome_rows` like `draw` does, so a click - // on a tab's glyphs must resolve to that tab. - let mut app = app_with_files(vec!["a.rs"]); - let paths = vec!["/w/api".to_string(), "/w/web".to_string()]; - let screen = Rect::new(0, 0, 120, 20); - let text = drawn_text(&mut app, &paths, 0); - let first_row = text.lines().next().unwrap(); - let web_x = first_row.find("F2 web").expect("second tab rendered") as u16; - - let tabs = Chrome { - repo_paths: &paths, - active: 0, - repo_input: &RepoInput::default(), - }; - assert_eq!(project_tab_at(tabs, screen, 0, 0), Some(0)); - assert_eq!(project_tab_at(tabs, screen, web_x, 0), Some(1)); - // Row 1 is the body, not the tab row. - assert_eq!(project_tab_at(tabs, screen, web_x, 1), None); - } - - #[test] - fn panels_advertise_the_leader_digit_not_the_bare_f_key() { - // The bare F-key row selects project tabs, so a panel legend reading - // `F1 Files` would name a key that switches projects instead of - // focusing the panel. - let mut app = app_with_files(vec!["a.rs"]); - let tab_paths = vec![".".to_string()]; - let mut terminal = Terminal::new(TestBackend::new(120, 20)).unwrap(); - let ss = two_face::syntax::extra_newlines(); - let ts = ThemeSet::load_defaults(); - terminal - .draw(|frame| { - draw( - frame, - &mut app, - Chrome { - repo_paths: &tab_paths, - active: 0, - repo_input: &RepoInput::default(), - }, - &ss, - &ts, - &LayoutConfig::default(), - Color::Yellow, - ); - }) - .unwrap(); - - let buf = terminal.backend().buffer(); - let text: String = (0..buf.area.height) - .map(|y| { - (0..buf.area.width) - .map(|x| buf[(x, y)].symbol()) - .collect::() - }) - .collect::>() - .join("\n"); - - assert!( - text.contains("^F1 Files"), - "file list must advertise its leader digit, got: {text}" - ); - // The `Ctrl+F` leader label ("^F") ends in the letter F, so the legit - // "^F1 Files" legend contains "F1 Files" as a substring. Strip it before - // asserting the bare function-key legend never appears on its own. - assert!( - !text.replace("^F1 Files", "").contains("F1 Files"), - "the bare F-key must not be advertised for panels, got: {text}" - ); - } - - #[test] - fn no_hint_row_advertises_the_removed_change_repo_command() { - // `o` opened the change-this-tab's-repo dialog before tabs existed. - // Two terminal-focus rows kept saying "repo" long after it started - // opening a tab instead — a rename that no test was watching. - let mut app = app_with_fake_backend(); - for mode in [ViewMode::Status, ViewMode::Log, ViewMode::Tree] { - for focus in [Focus::Terminal, Focus::FileList, Focus::DiffViewer] { - app.mode = mode; - app.focus = focus; - let text = normal_hint_literal(&app); - assert!( - !text.contains("o: repo"), - "{mode:?}/{focus:?} still advertises the removed command: {text}" - ); - } - } - } - - #[test] - fn armed_prefix_hint_advertises_the_project_keys() { - let mut app = app_with_fake_backend(); - app.arm_prefix(); - - let text = hint_text(&app); - - assert!(text.contains("o: open project"), "got: {text}"); - assert!(text.contains("x: close project"), "got: {text}"); - } - - /// The terminal-focus legend is the one carrying the bare - /// `: leader` segment — its inversion must round-trip to a - /// click like every other clickable label. - #[test] - fn terminal_focus_hint_bar_inverts_only_clickable_key_labels() { - let mut app = app_with_fake_backend(); - app.focus = Focus::Terminal; - assert_inverted_cells_are_clickable(&app); - } - - #[test] - fn bare_prefix_segment_resolves_to_an_arm_click() { - assert_eq!(segment_click(""), Some(HintClick::Arm)); - assert_eq!(segment_click(" "), Some(HintClick::Arm)); - } - - #[test] - fn armed_prefix_hint_bar_inverts_only_clickable_key_labels() { - let mut app = app_with_fake_backend(); - app.arm_prefix(); - assert_inverted_cells_are_clickable(&app); - } - - /// Notices own the row above the hint bar, so raising one must not - /// disturb the hint bar's labels or their click targets. - #[test] - fn armed_prefix_hint_bar_stays_clickable_while_a_notice_is_set() { - let mut app = app_with_fake_backend(); - app.arm_prefix(); - app.raise_notice(NoticeKind::Git, "boom"); - assert_inverted_cells_are_clickable(&app); - } - - #[test] - fn hint_bar_inverts_nothing_when_mouse_capture_is_disabled() { - let mut app = app_with_fake_backend(); - app.mouse_enabled = false; - let mut terminal = Terminal::new(TestBackend::new(200, 1)).unwrap(); - terminal - .draw(|frame| { - frame.render_widget( - render_hint_bar(&app, plain_chrome(&RepoInput::default()), Color::Yellow), - frame.area(), - ) - }) - .unwrap(); - let buf = terminal.backend().buffer(); - - let inverted = (0..200u16).any(|x| buf[(x, 0)].modifier.contains(Modifier::REVERSED)); - - assert!( - !inverted, - "with the mouse handed back to the terminal, no hint may \ - advertise a click that cannot arrive" - ); - } - - /// The affordance/hit-test agreement holds with the mouse disabled too: - /// nothing renders inverted, so nothing may resolve to a click. - #[test] - fn hint_click_resolves_nothing_when_mouse_capture_is_disabled() { - let mut app = app_with_fake_backend(); - app.focus = Focus::Terminal; - app.mouse_enabled = false; - let screen = Rect::new(0, 0, 200, 3); - for x in 0..200u16 { - assert_eq!( - hint_click_at(&app, plain_chrome(&RepoInput::default()), screen, x, 2), - None, - "x={x} resolves to a click the disabled mouse can never send" - ); - } - } - - #[test] - fn swap_hint_advertises_split_view_digits_by_default() { - let mut app = app_with_fake_backend(); - app.begin_swap_target(); - - assert!( - hint_text(&app).contains("3-9,0: swap active pane"), - "split view swap prompt must advertise the 3-9,0 mapping" - ); - } - - #[test] - fn swap_hint_advertises_fullscreen_digits_when_terminal_fills_body() { - let mut app = app_with_fake_backend(); - app.terminal.fullscreen = TerminalFullscreen::Grid; - app.begin_swap_target(); - - let text = hint_text(&app); - assert!( - text.contains("1-8: swap active pane"), - "fullscreen swap prompt must advertise the 1-8 mapping, got: {text}" - ); - assert!( - !text.contains("3-9,0"), - "fullscreen swap prompt must not show the split-view digits, got: {text}" - ); - } - - #[test] - fn prefix_hint_switches_pane_digit_legend_by_layout() { - let mut split = app_with_fake_backend(); - split.arm_prefix(); - assert!( - hint_text(&split).contains("1-9: focus/pane"), - "split view prefix hint must advertise focus/pane digits" - ); - - let mut full = app_with_fake_backend(); - full.terminal.fullscreen = TerminalFullscreen::Grid; - full.arm_prefix(); - let text = hint_text(&full); - assert!( - text.contains("1-8: pane"), - "fullscreen prefix hint must advertise the 1-8 pane digits, got: {text}" - ); - assert!( - !text.contains("1-9: focus/pane"), - "fullscreen prefix hint must not show the split-view legend, got: {text}" - ); - } - - /// ` w` only closes with terminal focus (`handle_global_action` - /// scopes it), so both the armed row and the normal legends must only - /// advertise it there — a hint for a no-op key would lie. - #[test] - fn prefix_hint_advertises_close_only_with_terminal_focus() { - let mut upper = app_with_fake_backend(); - upper.arm_prefix(); - assert!( - !hint_text(&upper).contains("w: close"), - "armed row must not offer close without terminal focus" - ); - - let mut term = app_with_fake_backend(); - term.focus = Focus::Terminal; - term.arm_prefix(); - assert!( - hint_text(&term).contains("w: close"), - "armed row must offer close with terminal focus" - ); - } - - /// The armed row's `w: close` must round-trip to a click exactly when it - /// is shown: some column resolves to `Plain('w')` with terminal focus, - /// and no column does without it (the segment isn't rendered, so a click - /// target for it would be a phantom). - #[test] - fn armed_prefix_close_click_target_follows_terminal_focus() { - let screen = Rect::new(0, 0, 200, 3); - let clicks = |app: &App| { - (0..200u16) - .filter(|&x| { - hint_click_at(app, plain_chrome(&RepoInput::default()), screen, x, 2) - == Some(HintClick::Plain('w')) - }) - .count() - }; - - let mut term = app_with_fake_backend(); - term.focus = Focus::Terminal; - term.arm_prefix(); - assert!( - clicks(&term) > 0, - "terminal-focused armed row must offer a close click target" - ); - - let mut upper = app_with_fake_backend(); - upper.arm_prefix(); - assert_eq!( - clicks(&upper), - 0, - "non-terminal armed row must not resolve any cell to a close click" - ); - } - - /// ` s` shares close's scoping (`handle_global_action`): terminal - /// focus plus a second pane to swap with. The armed row must only - /// advertise it then — a hint for a no-op key would lie. - #[test] - fn prefix_hint_advertises_swap_only_when_a_swap_can_act() { - let mut upper = app_with_fake_backend(); - upper.terminal.create_pane().unwrap(); - upper.terminal.create_pane().unwrap(); - upper.focus = Focus::FileList; - upper.arm_prefix(); - assert!( - !hint_text(&upper).contains("s: swap pane"), - "armed row must not offer swap without terminal focus" - ); - - let mut single = app_with_fake_backend(); - single.terminal.create_pane().unwrap(); - single.focus = Focus::Terminal; - single.arm_prefix(); - assert!( - !hint_text(&single).contains("s: swap pane"), - "armed row must not offer swap with a single pane" - ); - - let mut term = app_with_fake_backend(); - term.terminal.create_pane().unwrap(); - term.terminal.create_pane().unwrap(); - term.focus = Focus::Terminal; - term.arm_prefix(); - assert!( - hint_text(&term).contains("s: swap pane"), - "armed row must offer swap with terminal focus and two panes" - ); - } - - /// The armed row's view toggles name their destination from the current - /// mode, mirroring the normal legends' `l: log view`/`l: status view` - /// wording instead of a generic `log/status` label. - #[test] - fn prefix_hint_names_view_toggle_destinations_by_mode() { - let mut app = app_with_fake_backend(); - app.arm_prefix(); - - let text = hint_text(&app); - assert!( - text.contains("l: log view") && text.contains("b: tree view"), - "status mode armed row must name log/tree destinations, got: {text}" - ); - - app.mode = ViewMode::Log; - let text = hint_text(&app); - assert!( - text.contains("l: status view") && text.contains("b: tree view"), - "log mode armed row must name status/tree destinations, got: {text}" - ); - - app.mode = ViewMode::Tree; - let text = hint_text(&app); - assert!( - text.contains("l: log view") && text.contains("b: status view"), - "tree mode armed row must name log/status destinations, got: {text}" - ); - } - - /// Every upper legend advertises both view toggles with destination - /// labels — `l` (log ↔ status) and `b` (tree ↔ status) act from any - /// focus, so no mode may hide one or name the view already shown. - #[test] - fn upper_legends_advertise_both_view_toggles() { - // FileList browsing commits in Log mode. - let mut app = app_with_fake_backend(); - app.mode = ViewMode::Log; - let text = hint_text(&app); - assert!( - text.contains("l: status view") && text.contains("b: tree view"), - "log list legend must offer both toggles, got: {text}" - ); - - // DiffViewer in Log mode: `l` names status, not the view shown. - app.focus = Focus::DiffViewer; - let text = hint_text(&app); - assert!( - text.contains("l: status view") && text.contains("b: tree view"), - "log diff legend must offer both toggles, got: {text}" - ); - - // Terminal focus in Log mode follows the same destination wording. - app.focus = Focus::Terminal; - let text = hint_text(&app); - assert!( - text.contains("l: status view"), - "log terminal legend must name the status destination, got: {text}" - ); - - // Zoomed list rows carry both toggles in every mode. - let mut zoomed = app_with_fake_backend(); - zoomed.list_fullscreen = true; - let text = hint_text(&zoomed); - assert!( - text.contains("l: log view") && text.contains("b: tree view"), - "zoomed status list must offer both toggles, got: {text}" - ); - zoomed.mode = ViewMode::Log; - let text = hint_text(&zoomed); - assert!( - text.contains("l: status view") && text.contains("b: tree view"), - "zoomed log list must offer both toggles, got: {text}" - ); - zoomed.mode = ViewMode::Tree; - let text = hint_text(&zoomed); - assert!( - text.contains("b: status view") && text.contains("l: log view"), - "zoomed tree list must offer both toggles, got: {text}" - ); - } - - #[test] - fn normal_hint_advertises_close_only_with_terminal_focus() { - let mut app = app_with_fake_backend(); - for focus in [Focus::FileList, Focus::DiffViewer] { - app.focus = focus; - let text = hint_text(&app); - assert!( - !text.contains("w: close pane"), - "{focus:?} legend must not offer close, got: {text}" - ); - } - app.focus = Focus::Terminal; - assert!( - hint_text(&app).contains("w: close pane"), - "terminal legend must offer close" - ); - } - - /// `v` only opens a file when `current_file_view_key` resolves (log view - /// needs a drill-down file selection), so the diff legend must only - /// advertise `v: view file` then — a hint for a no-op key would lie. - #[test] - fn diff_hint_advertises_view_file_only_with_a_file_target() { - // Log view browsing commits (no drill-down): `v` has no target. - let mut app = app_with_fake_backend(); - app.mode = ViewMode::Log; - app.focus = Focus::DiffViewer; - let text = hint_text(&app); - assert!( - !text.contains("v: view file"), - "commit-level log legend must not offer view file, got: {text}" - ); - assert!( - text.contains("s: split"), - "split still acts on the commit diff, got: {text}" - ); - - // Same state zoomed: the fullscreen legend must agree. - app.diff.fullscreen = true; - let text = hint_text(&app); - assert!( - !text.contains("v: view file"), - "zoomed commit-level legend must not offer view file, got: {text}" - ); - - // Drill-down with a file selected: `v` acts, so advertise it. - app.diff.fullscreen = false; - app.log_view - .set_commits(vec![crate::git::diff::CommitEntry::new( - git2::Oid::ZERO_SHA1, - "deadbee".to_string(), - "c".to_string(), - "T".to_string(), - 0, - )]); - app.log_view.drill_down = true; - app.log_view.commit_files = vec![crate::git::diff::ChangedFile::unstaged_only( - "a.rs".to_string(), - StatusKind::Modified, - )]; - assert!( - hint_text(&app).contains("v: view file"), - "drill-down legend must offer view file" - ); - - // Status view with a selected file (the fixture's default list). - let mut status = app_with_fake_backend(); - status.focus = Focus::DiffViewer; - assert!( - hint_text(&status).contains("v: view file"), - "status legend must offer view file for a selected file" - ); - } - - /// Tree mode's right pane is permanently the file view — `v` never - /// toggles there, so the file-view legend must not offer `back to diff`. - #[test] - fn tree_file_view_hint_omits_back_to_diff() { - let mut app = app_with_fake_backend(); - app.mode = ViewMode::Tree; - app.focus = Focus::DiffViewer; - app.diff.view = DiffPaneView::File; - let text = hint_text(&app); - assert!( - !text.contains("v: back to diff"), - "tree file-view legend must not offer back to diff, got: {text}" - ); - - app.diff.fullscreen = true; - let text = hint_text(&app); - assert!( - !text.contains("v: back to diff"), - "zoomed tree file-view legend must not offer back to diff, got: {text}" - ); - } - - #[test] - fn home_relative_strips_home_prefix_and_trailing_slash() { - let home = dirs::home_dir().expect("home dir for test host"); - let home_str = home.to_str().unwrap(); - let nested = format!("{home_str}/projects/foo/"); - assert_eq!(home_relative_path(&nested), "~/projects/foo"); - } - - #[test] - fn home_relative_keeps_paths_outside_home_unchanged() { - // Trailing slash still trimmed for compactness, but the body is - // returned verbatim when the home prefix doesn't match. - assert_eq!(home_relative_path("/tmp/repo/"), "/tmp/repo"); - assert_eq!(home_relative_path("/var/code"), "/var/code"); - } - - #[test] - fn main_content_split_preserves_lower_panel_at_high_upper_ratio() { - let cfg = LayoutConfig { - upper_pct: 99, - file_list_pct: 25, - }; - - assert_eq!( - main_content_constraints(&cfg), - [Constraint::Percentage(99), Constraint::Percentage(1)] - ); - } - - #[test] - fn terminal_content_areas_hidden_when_other_pane_is_fullscreen() { - let mut app = app_with_files(vec!["a.rs"]); - app.toggle_diff_fullscreen(); - - let areas = - terminal_content_areas(&app, Rect::new(0, 0, 100, 40), &LayoutConfig::default()); - - assert!(areas.is_empty()); - } - - #[test] - fn terminal_content_areas_uses_body_when_terminal_fullscreen() { - let mut app = app_with_files(vec!["a.rs"]); - app.terminal.panes.push(crate::app::PaneInfo { - id: 1, - title: "shell".to_string(), - }); - app.toggle_terminal_fullscreen(); - - let areas = - terminal_content_areas(&app, Rect::new(0, 0, 100, 40), &LayoutConfig::default()); - - // Full screen keeps all three chrome rows (project tabs on top, - // notice + hint below), then the terminal widget consumes one pane tab - // row and the top/bottom border rows. Side borders were dropped, so the - // content spans the full width. A single pane has no per-cell border, - // so its content Rect equals the whole terminal content area. - assert_eq!(areas.len(), 1); - assert_eq!(areas[0].0, 1); - assert_eq!(areas[0].1.height, 34); - assert_eq!(areas[0].1.width, 100); - } - - /// x column where `needle` starts on the rendered hint row, measured in - /// display cells over exactly the text the renderer draws. - fn hint_x_of(app: &App, needle: &str) -> u16 { - let (chip, text) = if app.prefix_armed() { - (PREFIX_CHIP, prefix_armed_hint_text(app)) - } else { - ( - "", - normal_hint_literal(app).replace("", &app.leader_label()), - ) - }; - let full = format!("{chip}{text}"); - let byte = full.find(needle).expect("needle must be on the hint row"); - Span::raw(&full[..byte]).width() as u16 - } - - const HINT_TEST_SCREEN: Rect = Rect::new(0, 0, 300, 40); - const HINT_ROW: u16 = 39; - - #[test] - fn hint_click_resolves_commands_and_skips_nav_and_quit() { - // Default state: FileList focus, status view — the row carries both - // leader commands and nav segments. - let app = app_with_fake_backend(); - - let x = hint_x_of(&app, "t: new pane"); - assert_eq!( - hint_click_at( - &app, - plain_chrome(&RepoInput::default()), - HINT_TEST_SCREEN, - x, - HINT_ROW - ), - Some(HintClick::Leader('t')) - ); - let x = hint_x_of(&app, "/: search"); - assert_eq!( - hint_click_at( - &app, - plain_chrome(&RepoInput::default()), - HINT_TEST_SCREEN, - x, - HINT_ROW - ), - Some(HintClick::Plain('/')) - ); - let x = hint_x_of(&app, "j/k: navigate"); - assert_eq!( - hint_click_at( - &app, - plain_chrome(&RepoInput::default()), - HINT_TEST_SCREEN, - x, - HINT_ROW - ), - None - ); - let x = hint_x_of(&app, "q: quit"); - assert_eq!( - hint_click_at( - &app, - plain_chrome(&RepoInput::default()), - HINT_TEST_SCREEN, - x, - HINT_ROW - ), - None, - "quit must never be one stray click away" - ); - } - - #[test] - fn hint_click_agrees_with_the_rendered_buffer_not_just_the_builder() { - // Independent cross-check: locate the label in the *rendered* buffer - // (no shared width math with `hint_click_at`) and hit-test there. If - // renderer and hit test ever segment differently, this drifts. - let app = app_with_fake_backend(); - let mut terminal = Terminal::new(TestBackend::new(300, 1)).unwrap(); - terminal - .draw(|frame| { - frame.render_widget( - render_hint_bar(&app, plain_chrome(&RepoInput::default()), Color::Yellow), - frame.area(), - ) - }) - .unwrap(); - let buf = terminal.backend().buffer(); - // Scan cell-wise so the needle's index is a *column*, not a byte - // offset — the row contains multi-byte arrows before the label. - let cells: Vec<&str> = (0..buf.area.width).map(|x| buf[(x, 0)].symbol()).collect(); - let x = (0..cells.len()) - .find(|&i| cells[i..].concat().starts_with("t: new pane")) - .expect("label rendered") as u16; - - assert_eq!( - hint_click_at( - &app, - plain_chrome(&RepoInput::default()), - HINT_TEST_SCREEN, - x, - HINT_ROW - ), - Some(HintClick::Leader('t')) - ); - } - - #[test] - fn hint_click_misses_off_the_hint_row() { - let app = app_with_fake_backend(); - let x = hint_x_of(&app, "t: new pane"); - assert_eq!( - hint_click_at( - &app, - plain_chrome(&RepoInput::default()), - HINT_TEST_SCREEN, - x, - HINT_ROW - 1 - ), - None - ); - } - - #[test] - fn hint_click_armed_row_resolves_bare_followups_after_the_chip() { - let mut app = app_with_fake_backend(); - app.arm_prefix(); - - let x = hint_x_of(&app, "t: new pane"); - assert_eq!( - hint_click_at( - &app, - plain_chrome(&RepoInput::default()), - HINT_TEST_SCREEN, - x, - HINT_ROW - ), - Some(HintClick::Plain('t')) - ); - let x = hint_x_of(&app, "r: redraw"); - assert_eq!( - hint_click_at( - &app, - plain_chrome(&RepoInput::default()), - HINT_TEST_SCREEN, - x, - HINT_ROW - ), - Some(HintClick::Plain('r')) - ); - let x = hint_x_of(&app, "q: quit"); - assert_eq!( - hint_click_at( - &app, - plain_chrome(&RepoInput::default()), - HINT_TEST_SCREEN, - x, - HINT_ROW - ), - None - ); - let x = hint_x_of(&app, "esc: cancel"); - assert_eq!( - hint_click_at( - &app, - plain_chrome(&RepoInput::default()), - HINT_TEST_SCREEN, - x, - HINT_ROW - ), - None - ); - } - - #[test] - fn hint_click_none_on_modal_rows() { - let mut swap = app_with_fake_backend(); - swap.begin_swap_target(); - assert!((0..HINT_TEST_SCREEN.width).all(|x| { - hint_click_at( - &swap, - plain_chrome(&RepoInput::default()), - HINT_TEST_SCREEN, - x, - HINT_ROW, - ) - .is_none() - })); - } - - #[test] - fn pane_at_resolves_the_pane_under_a_cell_and_misses_elsewhere() { - let mut app = app_with_files(vec!["a.rs"]); - app.terminal.panes.push(crate::app::PaneInfo { - id: 1, - title: "shell".to_string(), - }); - app.terminal.panes.push(crate::app::PaneInfo { - id: 2, - title: "shell".to_string(), - }); - let screen = Rect::new(0, 0, 100, 40); - let layout = LayoutConfig::default(); - let areas = terminal_content_areas(&app, screen, &layout); - assert_eq!(areas.len(), 2); - - // A cell inside each pane's content rect resolves to that pane. - for (id, rect) in &areas { - let hit = pane_at(&app, screen, &layout, rect.x, rect.y); - assert_eq!(hit, Some((*id, *rect))); - } - // The project tab row owns row 0, and the upper panels the rows just - // below it — neither is a pane. - assert_eq!(pane_at(&app, screen, &layout, 0, 0), None); - assert_eq!(pane_at(&app, screen, &layout, 0, 1), None); - // ...and so do the two chrome rows at the bottom. - assert_eq!(pane_at(&app, screen, &layout, 0, 39), None); - } - - #[test] - fn upper_panel_at_resolves_list_and_diff_by_the_layout_split() { - let app = app_with_files(vec!["a.rs"]); - let screen = Rect::new(0, 0, 100, 40); - let layout = LayoutConfig::default(); - - // Row 0 is the project tab row, so the body starts at row 1. The - // default file_list_pct (25) puts x=0 in the list and x=60 in the diff. - assert_eq!(upper_panel_at(&app, screen, &layout, 0, 0), None); - assert_eq!( - upper_panel_at(&app, screen, &layout, 0, 1), - Some(Focus::FileList) - ); - assert_eq!( - upper_panel_at(&app, screen, &layout, 60, 1), - Some(Focus::DiffViewer) - ); - // Below the upper panels: the terminal panel, then the two chrome - // rows (notice, hint) — none of them is an upper panel. - assert_eq!(upper_panel_at(&app, screen, &layout, 0, 37), None); - assert_eq!(upper_panel_at(&app, screen, &layout, 0, 38), None); - assert_eq!(upper_panel_at(&app, screen, &layout, 0, 39), None); - } - - #[test] - fn upper_panel_at_misses_in_every_fullscreen_state() { - // The implementation guards three distinct flags; each must miss on - // its own, at a cell that hits the file list in the normal split. - let screen = Rect::new(0, 0, 100, 40); - let layout = LayoutConfig::default(); - - let mut diff_full = app_with_files(vec!["a.rs"]); - diff_full.toggle_diff_fullscreen(); - assert_eq!(upper_panel_at(&diff_full, screen, &layout, 0, 1), None); - - let mut list_full = app_with_files(vec!["a.rs"]); - list_full.list_fullscreen = true; - assert_eq!(upper_panel_at(&list_full, screen, &layout, 0, 1), None); - - let mut term_full = app_with_files(vec!["a.rs"]); - term_full.terminal.fullscreen = TerminalFullscreen::Grid; - assert_eq!(upper_panel_at(&term_full, screen, &layout, 0, 1), None); - } - - #[test] - fn pane_at_misses_when_another_panel_is_fullscreen() { - let mut app = app_with_files(vec!["a.rs"]); - app.terminal.panes.push(crate::app::PaneInfo { - id: 1, - title: "shell".to_string(), - }); - app.toggle_diff_fullscreen(); - - let hit = pane_at( - &app, - Rect::new(0, 0, 100, 40), - &LayoutConfig::default(), - 50, - 30, - ); - - assert_eq!(hit, None); - } -} diff --git a/src/ui/notice.rs b/src/ui/notice.rs new file mode 100644 index 0000000..292a1f2 --- /dev/null +++ b/src/ui/notice.rs @@ -0,0 +1,52 @@ +use crate::app::App; +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; + +pub(crate) fn render_notice_row<'a>(app: &'a App, accent: Color) -> Paragraph<'a> { + if let Some(notice) = app.notice.as_ref() { + return Paragraph::new(Line::from(Span::styled( + format!(" {}", notice.line()), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ))); + } + render_repo_header(app, accent) +} + +pub(crate) fn render_repo_header<'a>(app: &'a App, accent: Color) -> Paragraph<'a> { + let display_path = home_relative_path(&app.repo_path); + let mut spans: Vec> = vec![Span::styled( + format!(" {display_path} "), + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::BOLD), + )]; + if let Some(branch) = app.branch_name.as_deref() { + spans.push(Span::styled( + format!(" {branch} "), + Style::default().fg(accent).add_modifier(Modifier::BOLD), + )); + } + if let Some(t) = &app.tracking + && (t.ahead > 0 || t.behind > 0) + { + spans.push(Span::styled( + format!(" ↑{} ↓{} ", t.ahead, t.behind), + Style::default().fg(Color::Cyan), + )); + } + Paragraph::new(Line::from(spans)) +} + +pub(crate) fn home_relative_path(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if let Some(home) = dirs::home_dir() + && let Some(home_str) = home.to_str() + && let Some(rest) = trimmed.strip_prefix(home_str) + { + return format!("~{rest}"); + } + trimmed.to_string() +} diff --git a/src/ui/project_tab.rs b/src/ui/project_tab/mod.rs similarity index 55% rename from src/ui/project_tab.rs rename to src/ui/project_tab/mod.rs index 1e09e0a..a505174 100644 --- a/src/ui/project_tab.rs +++ b/src/ui/project_tab/mod.rs @@ -204,187 +204,6 @@ pub(crate) fn tab_at( None } -#[cfg(test)] -mod tests { - use super::*; - use ratatui::{Terminal, backend::TestBackend}; - - fn paths(v: &[&str]) -> Vec { - v.iter().map(|s| s.to_string()).collect() - } - - fn rendered_at(repo_paths: &[String], active: usize, width: u16) -> String { - let mut terminal = Terminal::new(TestBackend::new(width, 1)).unwrap(); - terminal - .draw(|frame| { - frame.render_widget( - render(repo_paths, active, frame.area(), Color::Yellow), - frame.area(), - ); - }) - .unwrap(); - let buf = terminal.backend().buffer(); - (0..buf.area.width) - .map(|x| buf[(x, 0)].symbol()) - .collect::() - } - - fn rendered(repo_paths: &[String], active: usize) -> String { - rendered_at(repo_paths, active, 120) - } - #[test] - fn tab_label_uses_the_final_path_component() { - assert_eq!(tab_label("/home/u/work/api"), "api"); - assert_eq!(tab_label("/home/u/work/api/"), "api"); - } - - #[test] - fn tab_label_handles_root_and_empty_paths() { - // A blank tab would look unclickable, so both degenerate cases still - // produce a visible name. - assert_eq!(tab_label("/"), "/"); - assert_eq!(tab_label(""), "?"); - } - - #[test] - fn tab_label_truncates_a_long_name_with_an_ellipsis() { - let label = tab_label("/w/a-very-long-project-name-here"); - - assert_eq!(label.chars().count(), TAB_TITLE_MAX_CHARS); - assert!(label.ends_with('…'), "got: {label}"); - } - - #[test] - fn every_tab_carries_its_f_key_legend() { - let text = rendered(&paths(&["/w/api", "/w/web"]), 0); - - assert!(text.contains("F1 api"), "got: {text}"); - assert!(text.contains("F2 web"), "got: {text}"); - } - - #[test] - fn a_project_past_the_tenth_carries_no_key_legend() { - // Only ten F-keys exist, so an eleventh tab must not imply one. - let many: Vec = (0..11).map(|i| format!("/w/p{i}")).collect(); - - let segments = tab_segments(&many, 0, 240); - - assert_eq!(segments[9].0, " F10 p9 "); - assert_eq!(segments[10].0, " p10 "); - } - - /// Ten tabs whose names are long enough that the row cannot hold them all - /// at 80 columns — the case a plain `Paragraph` would silently clip. - fn crowded() -> Vec { - (0..10).map(|i| format!("/w/project-name-{i}")).collect() - } - - #[test] - fn a_crowded_row_stays_within_its_width() { - for active in [0usize, 5, 9] { - let segments = tab_segments(&crowded(), active, 80); - let total: u16 = segments - .iter() - .map(|(t, _)| Span::raw(t).width() as u16) - .sum(); - - assert!( - total <= 80, - "active={active} overflowed the row: {total} cells" - ); - } - } - - #[test] - fn the_active_tab_is_always_visible_however_crowded() { - // Clipping the tail would hide the active tab — and with it the only - // indication of which project the screen belongs to. - for active in [0usize, 4, 9] { - let text = rendered_at(&crowded(), active, 80); - let expected = format!("F{} ", active + 1); - - assert!( - text.contains(&expected), - "active tab {active} must be on screen, got: {text}" - ); - } - } - - #[test] - fn hidden_tabs_are_reported_by_overflow_markers() { - // Scrolled to the far end, everything before it is behind one marker. - let segments = tab_segments(&crowded(), 9, 80); - - let (marker, target) = &segments[0]; - assert!(marker.starts_with(" +"), "got: {marker}"); - // The marker selects the nearest hidden project, so the overflow is - // reachable by pointer and not only by F-key. - assert_eq!( - *target, - marker - .trim() - .trim_start_matches('+') - .parse::() - .unwrap() - - 1 - ); - } - - #[test] - fn a_row_that_fits_shows_no_markers() { - let segments = tab_segments(&paths(&["/w/api", "/w/web"]), 0, 80); - - assert_eq!(segments.len(), 2, "no marker when everything fits"); - } - - #[test] - fn only_the_active_tab_is_accented() { - let repo_paths = paths(&["/w/api", "/w/web"]); - let mut terminal = Terminal::new(TestBackend::new(120, 1)).unwrap(); - terminal - .draw(|frame| { - frame.render_widget( - render(&repo_paths, 1, frame.area(), Color::Yellow), - frame.area(), - ); - }) - .unwrap(); - - let buf = terminal.backend().buffer(); - let accented: String = (0..buf.area.width) - .filter(|&x| buf[(x, 0)].style().bg == Some(Color::Yellow)) - .map(|x| buf[(x, 0)].symbol()) - .collect(); - - assert!(accented.contains("web"), "got: {accented}"); - assert!(!accented.contains("api"), "got: {accented}"); - } - - #[test] - fn tab_at_maps_a_click_to_the_tab_under_it() { - let repo_paths = paths(&["/w/api", "/w/web"]); - let area = Rect::new(0, 0, 120, 1); - // Walk the rendered row rather than trusting the builder alone: the - // hit box must match the glyphs actually on screen. - let text = rendered(&repo_paths, 0); - let web_x = text.find("F2 web").expect("second tab rendered") as u16; - - assert_eq!(tab_at(&repo_paths, 0, area, 0, 0), Some(0)); - assert_eq!(tab_at(&repo_paths, 0, area, web_x, 0), Some(1)); - } - - #[test] - fn tab_at_is_none_off_the_row_and_past_the_last_tab() { - let repo_paths = paths(&["/w/api"]); - let area = Rect::new(0, 0, 120, 1); - - assert_eq!(tab_at(&repo_paths, 0, area, 0, 1), None, "wrong row"); - assert_eq!(tab_at(&repo_paths, 0, area, 100, 0), None, "past last tab"); - - // A layout too short to give the row any cells must not report hits: - // whatever is drawn at that y belongs to another row. - let collapsed = Rect::new(0, 0, 120, 0); - assert_eq!(tab_at(&repo_paths, 0, collapsed, 0, 0), None); - } -} +#[cfg(test)] +mod tests; diff --git a/src/ui/project_tab/tests.rs b/src/ui/project_tab/tests.rs new file mode 100644 index 0000000..fe7aa9d --- /dev/null +++ b/src/ui/project_tab/tests.rs @@ -0,0 +1,181 @@ + use super::*; + use ratatui::{Terminal, backend::TestBackend, layout::Rect, style::Color, text::Span}; + + fn paths(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + fn rendered_at(repo_paths: &[String], active: usize, width: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, 1)).unwrap(); + terminal + .draw(|frame| { + frame.render_widget( + render(repo_paths, active, frame.area(), Color::Yellow), + frame.area(), + ); + }) + .unwrap(); + let buf = terminal.backend().buffer(); + (0..buf.area.width) + .map(|x| buf[(x, 0)].symbol()) + .collect::() + } + + fn rendered(repo_paths: &[String], active: usize) -> String { + rendered_at(repo_paths, active, 120) + } + + #[test] + fn tab_label_uses_the_final_path_component() { + assert_eq!(tab_label("/home/u/work/api"), "api"); + assert_eq!(tab_label("/home/u/work/api/"), "api"); + } + + #[test] + fn tab_label_handles_root_and_empty_paths() { + // A blank tab would look unclickable, so both degenerate cases still + // produce a visible name. + assert_eq!(tab_label("/"), "/"); + assert_eq!(tab_label(""), "?"); + } + + #[test] + fn tab_label_truncates_a_long_name_with_an_ellipsis() { + let label = tab_label("/w/a-very-long-project-name-here"); + + assert_eq!(label.chars().count(), TAB_TITLE_MAX_CHARS); + assert!(label.ends_with('…'), "got: {label}"); + } + + #[test] + fn every_tab_carries_its_f_key_legend() { + let text = rendered(&paths(&["/w/api", "/w/web"]), 0); + + assert!(text.contains("F1 api"), "got: {text}"); + assert!(text.contains("F2 web"), "got: {text}"); + } + + #[test] + fn a_project_past_the_tenth_carries_no_key_legend() { + // Only ten F-keys exist, so an eleventh tab must not imply one. + let many: Vec = (0..11).map(|i| format!("/w/p{i}")).collect(); + + let segments = tab_segments(&many, 0, 240); + + assert_eq!(segments[9].0, " F10 p9 "); + assert_eq!(segments[10].0, " p10 "); + } + + /// Ten tabs whose names are long enough that the row cannot hold them all + /// at 80 columns — the case a plain `Paragraph` would silently clip. + fn crowded() -> Vec { + (0..10).map(|i| format!("/w/project-name-{i}")).collect() + } + + #[test] + fn a_crowded_row_stays_within_its_width() { + for active in [0usize, 5, 9] { + let segments = tab_segments(&crowded(), active, 80); + let total: u16 = segments + .iter() + .map(|(t, _)| Span::raw(t).width() as u16) + .sum(); + + assert!( + total <= 80, + "active={active} overflowed the row: {total} cells" + ); + } + } + + #[test] + fn the_active_tab_is_always_visible_however_crowded() { + // Clipping the tail would hide the active tab — and with it the only + // indication of which project the screen belongs to. + for active in [0usize, 4, 9] { + let text = rendered_at(&crowded(), active, 80); + let expected = format!("F{} ", active + 1); + + assert!( + text.contains(&expected), + "active tab {active} must be on screen, got: {text}" + ); + } + } + + #[test] + fn hidden_tabs_are_reported_by_overflow_markers() { + // Scrolled to the far end, everything before it is behind one marker. + let segments = tab_segments(&crowded(), 9, 80); + + let (marker, target) = &segments[0]; + assert!(marker.starts_with(" +"), "got: {marker}"); + // The marker selects the nearest hidden project, so the overflow is + // reachable by pointer and not only by F-key. + assert_eq!( + *target, + marker + .trim() + .trim_start_matches('+') + .parse::() + .unwrap() + - 1 + ); + } + + #[test] + fn a_row_that_fits_shows_no_markers() { + let segments = tab_segments(&paths(&["/w/api", "/w/web"]), 0, 80); + + assert_eq!(segments.len(), 2, "no marker when everything fits"); + } + + #[test] + fn only_the_active_tab_is_accented() { + let repo_paths = paths(&["/w/api", "/w/web"]); + let mut terminal = Terminal::new(TestBackend::new(120, 1)).unwrap(); + terminal + .draw(|frame| { + frame.render_widget( + render(&repo_paths, 1, frame.area(), Color::Yellow), + frame.area(), + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let accented: String = (0..buf.area.width) + .filter(|&x| buf[(x, 0)].style().bg == Some(Color::Yellow)) + .map(|x| buf[(x, 0)].symbol()) + .collect(); + + assert!(accented.contains("web"), "got: {accented}"); + assert!(!accented.contains("api"), "got: {accented}"); + } + + #[test] + fn tab_at_maps_a_click_to_the_tab_under_it() { + let repo_paths = paths(&["/w/api", "/w/web"]); + let area = Rect::new(0, 0, 120, 1); + // Walk the rendered row rather than trusting the builder alone: the + // hit box must match the glyphs actually on screen. + let text = rendered(&repo_paths, 0); + let web_x = text.find("F2 web").expect("second tab rendered") as u16; + + assert_eq!(tab_at(&repo_paths, 0, area, 0, 0), Some(0)); + assert_eq!(tab_at(&repo_paths, 0, area, web_x, 0), Some(1)); + } + + #[test] + fn tab_at_is_none_off_the_row_and_past_the_last_tab() { + let repo_paths = paths(&["/w/api"]); + let area = Rect::new(0, 0, 120, 1); + + assert_eq!(tab_at(&repo_paths, 0, area, 0, 1), None, "wrong row"); + assert_eq!(tab_at(&repo_paths, 0, area, 100, 0), None, "past last tab"); + + // A layout too short to give the row any cells must not report hits: + // whatever is drawn at that y belongs to another row. + let collapsed = Rect::new(0, 0, 120, 0); + assert_eq!(tab_at(&repo_paths, 0, collapsed, 0, 0), None); + } diff --git a/src/ui/terminal_tab.rs b/src/ui/terminal_tab.rs deleted file mode 100644 index dde5072..0000000 --- a/src/ui/terminal_tab.rs +++ /dev/null @@ -1,987 +0,0 @@ -use crate::app::{App, Focus}; -use crate::backend::PaneId; -use crate::runtime::emulator::{CellView, ScreenView}; -use crate::runtime::terminal::{MAX_VISIBLE_FULLSCREEN, visible_range}; -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Position, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, -}; - -/// The terminal pane draws only top/bottom borders, never the left/right `│`. -/// With side bars, selecting terminal output to copy picks up a `│` glyph on -/// every wrapped row; dropping them lets the content run edge-to-edge so a -/// copy is clean. Top stays for the title + focus tint, bottom for separation. -const TERMINAL_BORDERS: Borders = Borders::TOP.union(Borders::BOTTOM); - -/// Per-tab character budget for the title (excluding the jump-key hint and -/// surrounding padding). Anything longer is truncated with a trailing ellipsis -/// so long OSC-set titles can't push neighboring tabs off the row. -const TAB_TITLE_MAX_CHARS: usize = 20; - -/// Number of panes reachable by a leader-digit jump key. Panes past -/// this index have no jump-key hint in the tab bar (only focus cycling -/// reaches them). Tied to `MAX_VISIBLE_FULLSCREEN` by reference (not just by -/// convention) so the two can never silently drift apart. -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. -fn truncate_tab_title(title: &str, max: usize) -> String { - if title.chars().count() <= max { - return title.to_string(); - } - // Reserve one char of the budget for the ellipsis itself. - let keep = max.saturating_sub(1); - let mut out: String = title.chars().take(keep).collect(); - out.push('…'); - out -} - -fn terminal_layout(area: Rect) -> Option<(Rect, Rect)> { - let inner = Block::default().borders(TERMINAL_BORDERS).inner(area); - if inner.height == 0 || inner.width == 0 { - return None; - } - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Min(0)]) - .split(inner); - 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. -pub(crate) fn split_pane_areas(area: Rect, count: usize) -> Vec { - if count == 0 || area.width == 0 || area.height == 0 { - return Vec::new(); - } - let plan = grid_row_plan(count, area); - split_by_row_plan(area, &plan) -} - -/// One entry per row, each entry the number of columns in that row. -fn grid_row_plan(count: usize, area: Rect) -> Vec { - match count { - 1 => vec![1], - 2 => { - if area.width >= area.height.saturating_mul(2) { - vec![2] - } else { - vec![1, 1] - } - } - 3 => vec![2, 1], - 4 => vec![2, 2], - 5 => vec![3, 2], - 6 => vec![3, 3], - 7 => vec![4, 3], - 8 => vec![4, 4], - n => { - let cols = (n as f64).sqrt().ceil() as usize; - let rows = n.div_ceil(cols); - let mut plan = vec![cols; rows]; - let mut excess = cols * rows - n; - let mut i = plan.len(); - while excess > 0 && i > 0 { - i -= 1; - let take = plan[i].saturating_sub(1).min(excess); - plan[i] -= take; - excess -= take; - } - plan.retain(|&c| c > 0); - plan - } - } -} - -fn split_by_row_plan(area: Rect, plan: &[usize]) -> Vec { - if plan.is_empty() { - return Vec::new(); - } - let row_constraints: Vec = plan.iter().map(|_| Constraint::Min(1)).collect(); - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints(row_constraints) - .split(area); - - let mut result = Vec::with_capacity(plan.iter().sum()); - for (row_area, &cols) in rows.iter().zip(plan.iter()) { - if cols == 0 { - continue; - } - let col_constraints: Vec = (0..cols).map(|_| Constraint::Min(1)).collect(); - let cells = Layout::default() - .direction(Direction::Horizontal) - .constraints(col_constraints) - .split(*row_area); - result.extend(cells.iter().copied()); - } - result -} - -/// One visible split-view cell: `outer` is the full grid cell (border + -/// content), `content` is where the PTY screen actually draws. For the -/// single-pane case `outer == content` and `bordered` is `false` — no cell -/// border is drawn, matching pre-split-view rendering exactly. -struct VisiblePaneCell { - id: PaneId, - outer: Rect, - content: Rect, - bordered: bool, -} - -/// Lay out every currently visible pane inside `content_area` (the terminal -/// body, i.e. below the tab row). This is the 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. -fn visible_pane_cells(app: &App, content_area: Rect) -> Vec { - let pane_count = app.terminal.panes.len(); - let visible = visible_range( - app.terminal.visible_start, - app.terminal.active, - pane_count, - app.terminal.max_visible(), - ); - if visible.is_empty() { - return Vec::new(); - } - let visible_ids: Vec = app.terminal.panes[visible].iter().map(|p| p.id).collect(); - - if visible_ids.len() == 1 { - return vec![VisiblePaneCell { - id: visible_ids[0], - outer: content_area, - content: content_area, - bordered: false, - }]; - } - - let outers = split_pane_areas(content_area, visible_ids.len()); - visible_ids - .into_iter() - .zip(outers) - .map(|(id, outer)| VisiblePaneCell { - id, - outer, - content: Block::default().borders(Borders::ALL).inner(outer), - bordered: true, - }) - .collect() -} - -/// Content Rect (post border) for every currently visible pane, keyed by -/// pane id. Used by the main loop to resize each pane's backend PTY and -/// emulator to exactly what `render` draws inside it. -pub(crate) fn visible_pane_content_areas(app: &App, area: Rect) -> Vec<(PaneId, Rect)> { - let Some((_, content_area)) = terminal_layout(area) else { - return Vec::new(); - }; - visible_pane_cells(app, content_area) - .into_iter() - .map(|cell| (cell.id, cell.content)) - .collect() -} - -/// Draw the terminal panel, returning the screen cell the cursor was placed on -/// (`None` when the panel shows no cursor). See `super::draw` for why the -/// position is returned rather than left implicit in the frame. -pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) -> Option { - let focused = app.focus == Focus::Terminal; - let border_style = super::focused_border_style(focused, accent); - - let label = if app.terminal.is_scrolled() { - " Terminal [SCROLL — shift+pgdn: down | input: live] " - } else { - " Terminal " - }; - // The upper panes draw a `┌` corner that pushes their title text in by one - // column (`┌ ^F1 Files`). This pane has no left border, so a border-styled - // `─` stands in for that corner — it keeps `Terminal` column-aligned with - // `^F1 Files` / `^F2 Diff` above and makes the line start flush at the edge. - let title = Line::from(vec![Span::styled("─", border_style), Span::raw(label)]); - let block = Block::default() - .borders(TERMINAL_BORDERS) - .title(title) - .border_style(border_style); - - frame.render_widget(block, area); - - let (tab_area, content_area) = terminal_layout(area)?; - - let pane_count = app.terminal.panes.len(); - let visible = visible_range( - app.terminal.visible_start, - app.terminal.active, - pane_count, - app.terminal.max_visible(), - ); - render_tab_bar(frame, app, tab_area, accent, focused, visible.clone()); - - let cells = visible_pane_cells(app, content_area); - if cells.is_empty() { - let screen_lines = vec![Line::from(Span::styled( - format!(" No terminal — press {} t to open one ", app.leader_label()), - Style::default().fg(Color::DarkGray), - ))]; - frame.render_widget(Paragraph::new(screen_lines), content_area); - return None; - } - - let mut cursor = None; - for (offset, cell) in cells.iter().enumerate() { - 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, matching FileList/DiffViewer. - // 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. - let pane_border_style = if is_active && focused { - Style::default().fg(accent) - } else { - Style::default().fg(Color::DarkGray) - }; - let pane_title = app - .terminal - .panes - .get(i) - .map(|p| truncate_tab_title(&p.title, TAB_TITLE_MAX_CHARS)) - .unwrap_or_default(); - let cell_block = Block::default() - .borders(Borders::ALL) - .border_style(pane_border_style) - .title(format!(" {pane_title} ")); - frame.render_widget(cell_block, cell.outer); - } - if cell.content.width == 0 || cell.content.height == 0 { - continue; - } - let screen_lines = - build_screen_lines(app, cell.id, cell.content.height, cell.content.width); - frame.render_widget(Paragraph::new(screen_lines), cell.content); - if is_active { - cursor = render_cursor(frame, app, cell.id, cell.content); - } - } - cursor -} - -/// What one rendered tab-bar segment is, deciding both its style and what a -/// click on it does. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TabSegment { - /// The ` t: new terminal` legend shown with no panes. Inert. - Legend, - /// A pane's tab; a click jumps to this pane index. - Tab(usize), - /// A `+N` hidden-pane marker; a click jumps to the nearest hidden pane - /// on its side (`visible.start - 1` / `visible.end`), which slides the - /// visible window by exactly one slot via `sync_visible_window` — the - /// minimal reveal. - Marker(usize), -} - -impl TabSegment { - /// The pane index a click on this segment jumps to, if any. - fn click_target(self) -> Option { - match self { - TabSegment::Legend => None, - TabSegment::Tab(i) | TabSegment::Marker(i) => Some(i), - } - } -} - -/// The tab bar's rendered segments in draw order. Single source for -/// `render_tab_bar` (which styles them) and `tab_target_at` (which measures -/// them), so the click hit-test cannot drift from the drawn labels. -fn tab_segments(app: &App, visible: std::ops::Range) -> Vec<(String, TabSegment)> { - if app.terminal.panes.is_empty() { - return vec![( - format!(" {} t: new terminal ", app.leader_label()), - TabSegment::Legend, - )]; - } - // While the terminal fills the body the upper viewer is hidden, so - // ` 1..8` address panes 0..7 directly (see - // `input::prefix_action_fullscreen`); label the tabs with those digits. - // In the split view the digits `1`/`2` belong to the list/diff, so the - // pane legend stays on `F3..F10` there. - let fullscreen = app.terminal.fullscreen.fills_body(); - let hidden_before = visible.start; - let hidden_after = app.terminal.panes.len().saturating_sub(visible.end); - let mut segments = Vec::new(); - if hidden_before > 0 { - segments.push(( - format!(" +{hidden_before} "), - TabSegment::Marker(visible.start - 1), - )); - } - segments.extend(app.terminal.panes[visible.clone()].iter().enumerate().map( - |(offset, pane)| { - let i = visible.start + offset; - // Panes 0..=7 carry a jump key, so show it as a key legend: - // ` 1..8` in fullscreen, ` 3..9,0` in the split - // view (the digit row is layout-aware — see `prefix_action`). - // 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. - let title = truncate_tab_title(&pane.title, TAB_TITLE_MAX_CHARS); - let label = if i < JUMP_KEY_PANE_COUNT { - // Split view runs 3,4..9 then wraps to 0 for the eighth pane. - let digit = if fullscreen { - char::from_digit(i as u32 + 1, 10).unwrap_or('?') - } else { - char::from_digit((i as u32 + 3) % 10, 10).unwrap_or('?') - }; - format!(" {}{} {} ", app.leader_label(), digit, title) - } else { - format!(" {} ", title) - }; - (label, TabSegment::Tab(i)) - }, - )); - if hidden_after > 0 { - segments.push(( - format!(" +{hidden_after} "), - TabSegment::Marker(visible.end), - )); - } - segments -} - -/// The pane index a click at screen cell `(x, y)` on the tab bar should -/// jump to: a tab targets its own pane, a `+N` marker the nearest hidden -/// pane on its side. `None` off the tab row, past the last segment, or on -/// the no-panes legend. `area` is the full terminal widget Rect, exactly -/// what `render` receives. -pub(crate) fn tab_target_at(app: &App, area: Rect, x: u16, y: u16) -> Option { - let (tab_area, _) = terminal_layout(area)?; - if !tab_area.contains(Position { x, y }) { - return None; - } - let visible = visible_range( - app.terminal.visible_start, - app.terminal.active, - app.terminal.panes.len(), - app.terminal.max_visible(), - ); - let mut cursor = tab_area.x; - for (text, segment) in tab_segments(app, visible) { - let width = Span::raw(text.as_str()).width() as u16; - if x >= cursor && x < cursor + width { - return segment.click_target(); - } - cursor += width; - } - None -} - -fn render_tab_bar( - frame: &mut Frame, - app: &App, - tab_area: Rect, - accent: Color, - focused: bool, - visible: std::ops::Range, -) { - let tab_spans: Vec = tab_segments(app, visible) - .into_iter() - .map(|(text, segment)| { - let style = match segment { - TabSegment::Tab(i) if i == app.terminal.active && focused => Style::default() - .fg(Color::Black) - .bg(accent) - .add_modifier(Modifier::BOLD), - TabSegment::Tab(_) => Style::default().fg(Color::Gray), - TabSegment::Legend | TabSegment::Marker(_) => Style::default().fg(Color::DarkGray), - }; - Span::styled(text, style) - }) - .collect(); - frame.render_widget(Paragraph::new(Line::from(tab_spans)), tab_area); -} - -fn build_screen_lines(app: &App, pane_id: PaneId, rows: u16, cols: u16) -> Vec> { - let Some(screen) = app.terminal.screen_for_pane(pane_id) else { - return vec![Line::from(Span::styled( - " (no output) ", - Style::default().fg(Color::DarkGray), - ))]; - }; - - let (screen_rows, screen_cols) = screen.size(); - let render_rows = rows.min(screen_rows); - let render_cols = cols.min(screen_cols); - - (0..render_rows) - .map(|row| { - let mut spans: Vec> = Vec::new(); - let mut run_text = String::new(); - let mut run_style = Style::default(); - - for col in 0..render_cols { - 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. - if cell.is_wide_spacer() { - continue; - } - style = cell_to_style(&cell); - Some(cell) - } - None => None, - }; - - if style != run_style { - if !run_text.is_empty() { - spans.push(Span::styled(std::mem::take(&mut run_text), run_style)); - } - run_style = style; - } - match cell { - Some(cell) => cell.append_contents(&mut run_text), - None => run_text.push(' '), - } - } - if !run_text.is_empty() { - spans.push(Span::styled(run_text, run_style)); - } - Line::from(spans) - }) - .collect() -} - -fn render_cursor(frame: &mut Frame, app: &App, pane_id: PaneId, area: Rect) -> Option { - if app.focus != Focus::Terminal { - return None; - } - if app.terminal.is_scrolled() { - return None; - } - - let screen = app.terminal.screen_for_pane(pane_id)?; - let position = screen_cursor_position(&screen, area)?; - - frame.set_cursor_position(position); - Some(position) -} - -fn screen_cursor_position(screen: &ScreenView<'_>, area: Rect) -> Option { - if area.height == 0 || area.width == 0 { - return None; - } - - // 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. - let (row, col) = screen.cursor_position(); - Some(Position::new( - area.x.saturating_add(col.min(area.width.saturating_sub(1))), - area.y - .saturating_add(row.min(area.height.saturating_sub(1))), - )) -} - -fn cell_to_style(cell: &CellView<'_>) -> Style { - let mut style = Style::default().fg(cell.fg()).bg(cell.bg()); - if cell.bold() { - style = style.add_modifier(Modifier::BOLD); - } - if cell.italic() { - style = style.add_modifier(Modifier::ITALIC); - } - if cell.underline() { - style = style.add_modifier(Modifier::UNDERLINED); - } - if cell.dim() { - style = style.add_modifier(Modifier::DIM); - } - // Reverse video is how vim visual mode, fzf's cursor, and less's search - // hit mark selections. Without it those selections render as plain text. - if cell.inverse() { - style = style.add_modifier(Modifier::REVERSED); - } - style -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app::tests::app_with_files; - use ratatui::{Terminal, backend::TestBackend}; - - #[test] - fn maps_screen_cursor_to_render_area() { - let mut emulator = crate::runtime::emulator::PaneEmulator::new(3, 10, 0); - emulator.process(b"\x1b[2;4H"); - - let position = screen_cursor_position(&emulator.view(), Rect::new(20, 10, 10, 3)).unwrap(); - - assert_eq!(position, Position::new(23, 11)); - } - - #[test] - fn short_title_passes_through_untouched() { - assert_eq!(truncate_tab_title("claude", 24), "claude"); - } - - #[test] - fn long_title_is_cut_with_ellipsis_within_budget() { - let truncated = truncate_tab_title("claude-code: very-long-project-name", 24); - assert_eq!(truncated.chars().count(), 24); - assert!(truncated.ends_with('…')); - assert!(truncated.starts_with("claude-code")); - } - - #[test] - fn title_exactly_at_budget_is_not_truncated() { - let s: String = "a".repeat(24); - assert_eq!(truncate_tab_title(&s, 24), s); - } - - #[test] - fn keeps_cursor_visible_when_terminal_requests_hide() { - let mut emulator = crate::runtime::emulator::PaneEmulator::new(3, 10, 0); - emulator.process(b"\x1b[?25l\x1b[2;4H"); - - let position = screen_cursor_position(&emulator.view(), Rect::new(20, 10, 10, 3)).unwrap(); - - assert_eq!(position, Position::new(23, 11)); - } - - #[test] - fn content_spans_full_width_without_side_borders() { - // The terminal content must reach both pane edges so copied output never - // includes a `│`. Side borders would inset x by 1 and shrink width by 2. - let area = Rect::new(0, 0, 40, 10); - let (_, content) = terminal_layout(area).unwrap(); - - assert_eq!(content.x, area.x); - assert_eq!(content.width, area.width); - } - - #[test] - fn render_does_not_resize_terminal_state() { - let mut app = app_with_files(vec!["a.rs"]); - app.terminal.size = (3, 10); - let mut terminal = Terminal::new(TestBackend::new(40, 10)).unwrap(); - - terminal - .draw(|frame| { - render(frame, &app, frame.area(), Color::Yellow); - }) - .unwrap(); - - assert_eq!(app.terminal.size, (3, 10)); - } - - #[test] - fn split_pane_areas_single_pane_fills_area() { - let area = Rect::new(0, 0, 80, 24); - assert_eq!(split_pane_areas(area, 1), vec![area]); - } - - #[test] - fn split_pane_areas_two_panes_side_by_side_when_wide() { - let area = Rect::new(0, 0, 80, 24); - let cells = split_pane_areas(area, 2); - assert_eq!(cells.len(), 2); - assert_eq!(cells[0].y, cells[1].y); - assert_ne!(cells[0].x, cells[1].x); - } - - #[test] - fn split_pane_areas_two_panes_stacked_when_narrow() { - let area = Rect::new(0, 0, 30, 24); - let cells = split_pane_areas(area, 2); - assert_eq!(cells.len(), 2); - assert_eq!(cells[0].x, cells[1].x); - assert_ne!(cells[0].y, cells[1].y); - } - - #[test] - fn split_pane_areas_three_panes_two_over_one() { - let area = Rect::new(0, 0, 80, 24); - let cells = split_pane_areas(area, 3); - assert_eq!(cells.len(), 3); - // First row: two side-by-side cells. - assert_eq!(cells[0].y, cells[1].y); - // Second row: one full-width cell below. - assert!(cells[2].y > cells[0].y); - } - - #[test] - fn split_pane_areas_four_panes_is_2x2() { - let area = Rect::new(0, 0, 80, 24); - let cells = split_pane_areas(area, 4); - assert_eq!(cells.len(), 4); - let rows: std::collections::BTreeSet = cells.iter().map(|r| r.y).collect(); - assert_eq!(rows.len(), 2); - } - - #[test] - fn split_pane_areas_seven_panes_four_then_three() { - let area = Rect::new(0, 0, 100, 30); - let cells = split_pane_areas(area, 7); - assert_eq!(cells.len(), 7); - let top_row_y = cells[0].y; - let top_row_count = cells.iter().filter(|r| r.y == top_row_y).count(); - assert_eq!(top_row_count, 4); - } - - #[test] - fn split_pane_areas_eight_panes_is_2x4() { - let area = Rect::new(0, 0, 100, 30); - let cells = split_pane_areas(area, 8); - assert_eq!(cells.len(), 8); - let rows: std::collections::BTreeSet = cells.iter().map(|r| r.y).collect(); - assert_eq!(rows.len(), 2); - let top_row_y = cells[0].y; - let top_row_count = cells.iter().filter(|r| r.y == top_row_y).count(); - assert_eq!(top_row_count, 4); - } - - #[test] - fn split_pane_areas_never_produces_zero_size_cells_when_area_fits() { - for count in 1..=8 { - let area = Rect::new(0, 0, 40, 20); - let cells = split_pane_areas(area, count); - assert_eq!(cells.len(), count, "count={count}"); - for cell in cells { - assert!( - cell.width > 0 && cell.height > 0, - "count={count} cell={cell:?}" - ); - } - } - } - - #[test] - fn split_pane_areas_empty_for_zero_count() { - assert!(split_pane_areas(Rect::new(0, 0, 80, 24), 0).is_empty()); - } - - fn buffer_text(buf: &ratatui::buffer::Buffer) -> String { - buf.content.iter().map(|c| c.symbol()).collect() - } - - #[test] - fn single_pane_render_still_has_no_left_border_character() { - // Regression guard for split-view acceptance criterion 9: with only - // one pane, render() must take the no-cell-border branch, matching - // pre-split-view behaviour exactly (clean copy-paste, no `│`). - let mut app = crate::app::tests::app_with_fake_backend(); - app.terminal.create_pane_with(None, Some("Solo")).unwrap(); - let area = Rect::new(0, 0, 40, 10); - let mut terminal = Terminal::new(TestBackend::new(40, 10)).unwrap(); - - terminal - .draw(|frame| { - render(frame, &app, area, Color::Yellow); - }) - .unwrap(); - - let (_, content) = terminal_layout(area).unwrap(); - let buf = terminal.backend().buffer(); - for y in content.top()..content.bottom() { - let cell = buf.cell((content.x, y)).unwrap(); - assert_ne!( - cell.symbol(), - "│", - "single pane must not draw a left border at y={y}" - ); - } - } - - #[test] - fn split_view_renders_multiple_panes_simultaneously() { - let mut app = crate::app::tests::app_with_fake_backend(); - app.terminal.create_pane_with(None, Some("Alpha")).unwrap(); - app.terminal.create_pane_with(None, Some("Beta")).unwrap(); - let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap(); - - terminal - .draw(|frame| { - render(frame, &app, frame.area(), Color::Yellow); - }) - .unwrap(); - - let text = buffer_text(terminal.backend().buffer()); - assert!( - text.contains("Alpha") && text.contains("Beta"), - "expected both pane titles visible at once, got: {text}" - ); - } - - #[test] - fn split_view_borders_active_pane_in_accent_color() { - let mut app = crate::app::tests::app_with_fake_backend(); - app.terminal.create_pane_with(None, Some("Alpha")).unwrap(); - app.terminal.create_pane_with(None, Some("Beta")).unwrap(); - app.focus = Focus::Terminal; - let accent = Color::Yellow; - let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap(); - - terminal - .draw(|frame| { - render(frame, &app, frame.area(), accent); - }) - .unwrap(); - - let buf = terminal.backend().buffer(); - assert!( - buf.content.iter().any(|cell| cell.fg == accent), - "expected the active pane's border/title in accent color" - ); - assert!( - buf.content.iter().any(|cell| cell.fg == Color::DarkGray), - "expected the inactive pane's border in dark gray" - ); - } - - #[test] - fn split_view_active_pane_matches_inactive_style_when_terminal_unfocused() { - // Regression guard: accent (and any brighter stand-in for it) must - // mean "keystrokes go here right now". When Diff/FileList holds - // focus, the terminal's active pane must render pixel-identical to - // an inactive pane — no accent, no bold, no lighter gray — otherwise - // it still reads as focused when it isn't. - let mut app = crate::app::tests::app_with_fake_backend(); - app.terminal.create_pane_with(None, Some("Alpha")).unwrap(); - app.terminal.create_pane_with(None, Some("Beta")).unwrap(); - app.focus = Focus::DiffViewer; - let accent = Color::Yellow; - let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap(); - - terminal - .draw(|frame| { - render(frame, &app, frame.area(), accent); - }) - .unwrap(); - - let buf = terminal.backend().buffer(); - assert!( - !buf.content - .iter() - .any(|cell| cell.fg == accent || cell.fg == Color::White), - "terminal must not show accent or white anywhere while unfocused" - ); - assert!( - !buf.content - .iter() - .any(|cell| cell.modifier.contains(Modifier::BOLD) && cell.bg == accent), - "active pane tab must not carry an accent-bolded highlight while unfocused" - ); - } - - /// x column where the `nth` tab-bar segment starts, measured with the - /// same builder and widths the renderer and hit-test use. - fn tab_segment_x(app: &App, area: Rect, nth: usize) -> u16 { - let (tab_area, _) = terminal_layout(area).unwrap(); - let visible = visible_range( - app.terminal.visible_start, - app.terminal.active, - app.terminal.panes.len(), - app.terminal.max_visible(), - ); - let segments = tab_segments(app, visible); - assert!(nth < segments.len(), "segment {nth} must exist"); - tab_area.x - + segments - .iter() - .take(nth) - .map(|(text, _)| Span::raw(text.as_str()).width() as u16) - .sum::() - } - - #[test] - fn tab_target_at_resolves_tabs_and_hidden_markers() { - let mut app = crate::app::tests::app_with_fake_backend(); - app.terminal.max_visible_normal = 2; - for i in 0..4 { - app.terminal - .create_pane_with(None, Some(&format!("P{i}"))) - .unwrap(); - } - // Creation leaves pane 3 active with a 2-pane window: [2, 4). - let area = Rect::new(0, 0, 80, 20); - let (tab_area, _) = terminal_layout(area).unwrap(); - let y = tab_area.y; - - // Segment 0 is the ` +2 ` marker → nearest hidden pane on the left. - assert_eq!( - tab_target_at(&app, area, tab_segment_x(&app, area, 0), y), - Some(1) - ); - // Segments 1 and 2 are the visible tabs for panes 2 and 3. - assert_eq!( - tab_target_at(&app, area, tab_segment_x(&app, area, 1), y), - Some(2) - ); - assert_eq!( - tab_target_at(&app, area, tab_segment_x(&app, area, 2), y), - Some(3) - ); - // Past the last segment and off the tab row: no target. - assert_eq!(tab_target_at(&app, area, tab_area.right() - 1, y), None); - assert_eq!( - tab_target_at(&app, area, tab_segment_x(&app, area, 1), y + 1), - None - ); - } - - #[test] - fn tab_target_at_right_marker_reveals_the_next_hidden_pane() { - let mut app = crate::app::tests::app_with_fake_backend(); - app.terminal.max_visible_normal = 2; - for i in 0..4 { - app.terminal - .create_pane_with(None, Some(&format!("P{i}"))) - .unwrap(); - } - // Jump back to pane 0: window slides to [0, 2), marker sits on the right. - app.terminal.active = 0; - app.terminal.sync_visible_window(); - let area = Rect::new(0, 0, 80, 20); - let (tab_area, _) = terminal_layout(area).unwrap(); - - // Segments: tab 0, tab 1, ` +2 ` marker → nearest hidden pane index 2. - let x = tab_segment_x(&app, area, 2); - assert_eq!(tab_target_at(&app, area, x, tab_area.y), Some(2)); - } - - #[test] - fn tab_target_agrees_with_the_rendered_buffer_not_just_the_builder() { - // Independent cross-check: find the second tab's jump-key label in - // the *rendered* buffer and hit-test at that column. Catches any - // renderer vs hit-test segmentation drift the builder-based - // position helper cannot see. - let mut app = crate::app::tests::app_with_fake_backend(); - app.terminal.create_pane_with(None, Some("Alpha")).unwrap(); - app.terminal.create_pane_with(None, Some("Beta")).unwrap(); - let area = Rect::new(0, 0, 80, 20); - let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); - terminal - .draw(|frame| { - render(frame, &app, area, Color::Yellow); - }) - .unwrap(); - - let (tab_area, _) = terminal_layout(area).unwrap(); - let buf = terminal.backend().buffer(); - let cells: Vec<&str> = (0..buf.area.width) - .map(|x| buf[(x, tab_area.y)].symbol()) - .collect(); - let x = (0..cells.len()) - .find(|&i| cells[i..].concat().starts_with("^F4 Beta")) - .expect("second tab rendered") as u16; - - assert_eq!(tab_target_at(&app, area, x, tab_area.y), Some(1)); - } - - #[test] - fn tab_target_at_none_on_the_no_pane_legend() { - let app = crate::app::tests::app_with_fake_backend(); - let area = Rect::new(0, 0, 80, 20); - let (tab_area, _) = terminal_layout(area).unwrap(); - - assert_eq!(tab_target_at(&app, area, tab_area.x + 2, tab_area.y), None); - } - - #[test] - fn tab_bar_marks_hidden_panes_beyond_max_visible() { - let mut app = crate::app::tests::app_with_fake_backend(); - for i in 0..5 { - app.terminal - .create_pane_with(None, Some(&format!("P{i}"))) - .unwrap(); - } - assert_eq!(app.terminal.max_visible_normal, 4); - let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); - - terminal - .draw(|frame| { - render(frame, &app, frame.area(), Color::Yellow); - }) - .unwrap(); - - let text = buffer_text(terminal.backend().buffer()); - assert!( - text.contains('+'), - "expected a hidden-pane count marker, got: {text}" - ); - } - - #[test] - fn tab_bar_labels_panes_with_leader_digits_in_split_view() { - let mut app = crate::app::tests::app_with_fake_backend(); - app.terminal.create_pane_with(None, Some("Alpha")).unwrap(); - let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap(); - - terminal - .draw(|frame| { - render(frame, &app, frame.area(), Color::Yellow); - }) - .unwrap(); - - let text = buffer_text(terminal.backend().buffer()); - // The bare F-keys select project tabs, so the pane legend must name - // the leader digit that actually reaches this pane. - assert!( - text.contains("^F3 Alpha"), - "split view must label the first pane with its 3 jump key, got: {text}" - ); - // "^F3 Alpha" contains "F3 Alpha" as a substring because the `Ctrl+F` - // leader label ends in F; strip the legit legend before checking the - // bare function-key legend never appears on its own. - assert!( - !text.replace("^F3 Alpha", "").contains("F3 Alpha"), - "the bare F-key must not be advertised for panes, got: {text}" - ); - } - - #[test] - fn tab_bar_labels_panes_with_digits_in_fullscreen() { - // Fullscreen hides the viewer, so the pane legend switches to the - // ` 1..8` digits that address panes there. - let mut app = crate::app::tests::app_with_fake_backend(); - app.terminal.create_pane_with(None, Some("Alpha")).unwrap(); - app.terminal.create_pane_with(None, Some("Beta")).unwrap(); - app.terminal.fullscreen = crate::runtime::terminal::TerminalFullscreen::Grid; - let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap(); - - terminal - .draw(|frame| { - render(frame, &app, frame.area(), Color::Yellow); - }) - .unwrap(); - - let text = buffer_text(terminal.backend().buffer()); - assert!( - text.contains("1 Alpha") && text.contains("2 Beta"), - "fullscreen must label panes with their digits, got: {text}" - ); - assert!( - !text.contains("F3"), - "fullscreen must not show the split-view F-key legend, got: {text}" - ); - } -} diff --git a/src/ui/terminal_tab/cells.rs b/src/ui/terminal_tab/cells.rs new file mode 100644 index 0000000..d7255a7 --- /dev/null +++ b/src/ui/terminal_tab/cells.rs @@ -0,0 +1,72 @@ +use crate::app::App; +use crate::backend::PaneId; +use crate::runtime::terminal::visible_range; +use crate::ui::terminal_tab::layout::{split_pane_areas, terminal_layout}; +use ratatui::{ + layout::Rect, + widgets::{Block, Borders}, +}; + +/// One visible split-view cell: `outer` is the full grid cell (border + +/// content), `content` is where the PTY screen actually draws. For the +/// single-pane case `outer == content` and `bordered` is `false` — no cell +/// border is drawn, matching pre-split-view rendering exactly. +pub(crate) struct VisiblePaneCell { + pub id: PaneId, + pub outer: Rect, + pub content: Rect, + pub bordered: bool, +} + +/// Lay out every currently visible pane inside `content_area` (the terminal +/// body, i.e. below the tab row). This is the 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. +pub(crate) fn visible_pane_cells(app: &App, content_area: Rect) -> Vec { + let pane_count = app.terminal.panes.len(); + let visible = visible_range( + app.terminal.visible_start, + app.terminal.active, + pane_count, + app.terminal.max_visible(), + ); + if visible.is_empty() { + return Vec::new(); + } + let visible_ids: Vec = app.terminal.panes[visible].iter().map(|p| p.id).collect(); + + if visible_ids.len() == 1 { + return vec![VisiblePaneCell { + id: visible_ids[0], + outer: content_area, + content: content_area, + bordered: false, + }]; + } + + let outers = split_pane_areas(content_area, visible_ids.len()); + visible_ids + .into_iter() + .zip(outers) + .map(|(id, outer)| VisiblePaneCell { + id, + outer, + content: Block::default().borders(Borders::ALL).inner(outer), + bordered: true, + }) + .collect() +} + +/// Content Rect (post border) for every currently visible pane, keyed by +/// pane id. Used by the main loop to resize each pane's backend PTY and +/// emulator to exactly what `render` draws inside it. +pub(crate) fn visible_pane_content_areas(app: &App, area: Rect) -> Vec<(PaneId, Rect)> { + let Some((_, content_area)) = terminal_layout(area) else { + return Vec::new(); + }; + visible_pane_cells(app, content_area) + .into_iter() + .map(|cell| (cell.id, cell.content)) + .collect() +} diff --git a/src/ui/terminal_tab/layout.rs b/src/ui/terminal_tab/layout.rs new file mode 100644 index 0000000..2a4530f --- /dev/null +++ b/src/ui/terminal_tab/layout.rs @@ -0,0 +1,124 @@ +use crate::runtime::terminal::MAX_VISIBLE_FULLSCREEN; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + widgets::{Block, Borders}, +}; + +/// The terminal pane draws only top/bottom borders, never the left/right `│`. +/// With side bars, selecting terminal output to copy picks up a `│` glyph on +/// every wrapped row; dropping them lets the content run edge-to-edge so a +/// copy is clean. Top stays for the title + focus tint, bottom for separation. +pub(crate) const TERMINAL_BORDERS: Borders = Borders::TOP.union(Borders::BOTTOM); + +/// Per-tab character budget for the title (excluding the jump-key hint and +/// surrounding padding). Anything longer is truncated with a trailing ellipsis +/// so long OSC-set titles can't push neighboring tabs off the row. +pub(crate) const TAB_TITLE_MAX_CHARS: usize = 20; + +/// Number of panes reachable by a leader-digit jump key. Panes past +/// this index have no jump-key hint in the tab bar (only focus cycling +/// reaches them). Tied to `MAX_VISIBLE_FULLSCREEN` by reference (not just by +/// convention) so the two can never silently drift apart. +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. +pub(crate) fn truncate_tab_title(title: &str, max: usize) -> String { + if title.chars().count() <= max { + return title.to_string(); + } + // Reserve one char of the budget for the ellipsis itself. + let keep = max.saturating_sub(1); + let mut out: String = title.chars().take(keep).collect(); + out.push('…'); + out +} + +pub(crate) fn terminal_layout(area: Rect) -> Option<(Rect, Rect)> { + let inner = Block::default().borders(TERMINAL_BORDERS).inner(area); + if inner.height == 0 || inner.width == 0 { + return None; + } + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(inner); + 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. +pub(crate) fn split_pane_areas(area: Rect, count: usize) -> Vec { + if count == 0 || area.width == 0 || area.height == 0 { + return Vec::new(); + } + let plan = grid_row_plan(count, area); + split_by_row_plan(area, &plan) +} + +/// One entry per row, each entry the number of columns in that row. +fn grid_row_plan(count: usize, area: Rect) -> Vec { + match count { + 1 => vec![1], + 2 => { + if area.width >= area.height.saturating_mul(2) { + vec![2] + } else { + vec![1, 1] + } + } + 3 => vec![2, 1], + 4 => vec![2, 2], + 5 => vec![3, 2], + 6 => vec![3, 3], + 7 => vec![4, 3], + 8 => vec![4, 4], + n => { + let cols = (n as f64).sqrt().ceil() as usize; + let rows = n.div_ceil(cols); + let mut plan = vec![cols; rows]; + let mut excess = cols * rows - n; + let mut i = plan.len(); + while excess > 0 && i > 0 { + i -= 1; + let take = plan[i].saturating_sub(1).min(excess); + plan[i] -= take; + excess -= take; + } + plan.retain(|&c| c > 0); + plan + } + } +} + +fn split_by_row_plan(area: Rect, plan: &[usize]) -> Vec { + if plan.is_empty() { + return Vec::new(); + } + let row_constraints: Vec = plan.iter().map(|_| Constraint::Min(1)).collect(); + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints(row_constraints) + .split(area); + + let mut result = Vec::with_capacity(plan.iter().sum()); + for (row_area, &cols) in rows.iter().zip(plan.iter()) { + if cols == 0 { + continue; + } + let col_constraints: Vec = (0..cols).map(|_| Constraint::Min(1)).collect(); + let cells = Layout::default() + .direction(Direction::Horizontal) + .constraints(col_constraints) + .split(*row_area); + result.extend(cells.iter().copied()); + } + result +} diff --git a/src/ui/terminal_tab/mod.rs b/src/ui/terminal_tab/mod.rs new file mode 100644 index 0000000..215f161 --- /dev/null +++ b/src/ui/terminal_tab/mod.rs @@ -0,0 +1,108 @@ +mod cells; +mod layout; +mod screen; +mod tab_bar; +#[cfg(test)] +mod tests; + +pub(crate) use cells::visible_pane_content_areas; +pub(crate) use tab_bar::tab_target_at; + +use crate::app::{App, Focus}; +use crate::runtime::terminal::visible_range; +use crate::ui::terminal_tab::cells::visible_pane_cells; +use crate::ui::terminal_tab::layout::{TERMINAL_BORDERS, TAB_TITLE_MAX_CHARS, terminal_layout, truncate_tab_title}; +use crate::ui::terminal_tab::screen::{build_screen_lines, render_cursor}; +use crate::ui::terminal_tab::tab_bar::render_tab_bar; +use ratatui::{ + Frame, + layout::{Position, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; + +/// Draw the terminal panel, returning the screen cell the cursor was placed on +/// (`None` when the panel shows no cursor). See `super::draw` for why the +/// position is returned rather than left implicit in the frame. +pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) -> Option { + let focused = app.focus == Focus::Terminal; + let border_style = super::focused_border_style(focused, accent); + + let label = if app.terminal.is_scrolled() { + " Terminal [SCROLL — shift+pgdn: down | input: live] " + } else { + " Terminal " + }; + // The upper panes draw a `┌` corner that pushes their title text in by one + // column (`┌ ^F1 Files`). This pane has no left border, so a border-styled + // `─` stands in for that corner — it keeps `Terminal` column-aligned with + // `^F1 Files` / `^F2 Diff` above and makes the line start flush at the edge. + let title = Line::from(vec![Span::styled("─", border_style), Span::raw(label)]); + let block = Block::default() + .borders(TERMINAL_BORDERS) + .title(title) + .border_style(border_style); + + frame.render_widget(block, area); + + let (tab_area, content_area) = terminal_layout(area)?; + + let pane_count = app.terminal.panes.len(); + let visible = visible_range( + app.terminal.visible_start, + app.terminal.active, + pane_count, + app.terminal.max_visible(), + ); + render_tab_bar(frame, app, tab_area, accent, focused, visible.clone()); + + let cells = visible_pane_cells(app, content_area); + if cells.is_empty() { + let screen_lines = vec![Line::from(Span::styled( + format!(" No terminal — press {} t to open one ", app.leader_label()), + Style::default().fg(Color::DarkGray), + ))]; + frame.render_widget(Paragraph::new(screen_lines), content_area); + return None; + } + + let mut cursor = None; + for (offset, cell) in cells.iter().enumerate() { + 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, matching FileList/DiffViewer. + // 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. + let pane_border_style = if is_active && focused { + Style::default().fg(accent) + } else { + Style::default().fg(Color::DarkGray) + }; + let pane_title = app + .terminal + .panes + .get(i) + .map(|p| truncate_tab_title(&p.title, TAB_TITLE_MAX_CHARS)) + .unwrap_or_default(); + let cell_block = Block::default() + .borders(Borders::ALL) + .border_style(pane_border_style) + .title(format!(" {pane_title} ")); + frame.render_widget(cell_block, cell.outer); + } + if cell.content.width == 0 || cell.content.height == 0 { + continue; + } + let screen_lines = + build_screen_lines(app, cell.id, cell.content.height, cell.content.width); + frame.render_widget(Paragraph::new(screen_lines), cell.content); + if is_active { + cursor = render_cursor(frame, app, cell.id, cell.content); + } + } + cursor +} diff --git a/src/ui/terminal_tab/screen.rs b/src/ui/terminal_tab/screen.rs new file mode 100644 index 0000000..b270839 --- /dev/null +++ b/src/ui/terminal_tab/screen.rs @@ -0,0 +1,118 @@ +use crate::app::{App, Focus}; +use crate::backend::PaneId; +use crate::runtime::emulator::{CellView, ScreenView}; +use ratatui::{ + Frame, + layout::{Position, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, +}; + +pub(crate) fn build_screen_lines(app: &App, pane_id: PaneId, rows: u16, cols: u16) -> Vec> { + let Some(screen) = app.terminal.screen_for_pane(pane_id) else { + return vec![Line::from(Span::styled( + " (no output) ", + Style::default().fg(Color::DarkGray), + ))]; + }; + + let (screen_rows, screen_cols) = screen.size(); + let render_rows = rows.min(screen_rows); + let render_cols = cols.min(screen_cols); + + (0..render_rows) + .map(|row| { + let mut spans: Vec> = Vec::new(); + let mut run_text = String::new(); + let mut run_style = Style::default(); + + for col in 0..render_cols { + 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. + if cell.is_wide_spacer() { + continue; + } + style = cell_to_style(&cell); + Some(cell) + } + None => None, + }; + + if style != run_style { + if !run_text.is_empty() { + spans.push(Span::styled(std::mem::take(&mut run_text), run_style)); + } + run_style = style; + } + match cell { + Some(cell) => cell.append_contents(&mut run_text), + None => run_text.push(' '), + } + } + if !run_text.is_empty() { + spans.push(Span::styled(run_text, run_style)); + } + Line::from(spans) + }) + .collect() +} + +pub(crate) fn render_cursor(frame: &mut Frame, app: &App, pane_id: PaneId, area: Rect) -> Option { + if app.focus != Focus::Terminal { + return None; + } + if app.terminal.is_scrolled() { + return None; + } + + let screen = app.terminal.screen_for_pane(pane_id)?; + let position = screen_cursor_position(&screen, area)?; + + frame.set_cursor_position(position); + Some(position) +} + +pub(crate) fn screen_cursor_position(screen: &ScreenView<'_>, area: Rect) -> Option { + if area.height == 0 || area.width == 0 { + return None; + } + + // 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. + let (row, col) = screen.cursor_position(); + Some(Position::new( + area.x.saturating_add(col.min(area.width.saturating_sub(1))), + area.y + .saturating_add(row.min(area.height.saturating_sub(1))), + )) +} + +fn cell_to_style(cell: &CellView<'_>) -> Style { + let mut style = Style::default().fg(cell.fg()).bg(cell.bg()); + if cell.bold() { + style = style.add_modifier(Modifier::BOLD); + } + if cell.italic() { + style = style.add_modifier(Modifier::ITALIC); + } + if cell.underline() { + style = style.add_modifier(Modifier::UNDERLINED); + } + if cell.dim() { + style = style.add_modifier(Modifier::DIM); + } + // Reverse video is how vim visual mode, fzf's cursor, and less's search + // hit mark selections. Without it those selections render as plain text. + if cell.inverse() { + style = style.add_modifier(Modifier::REVERSED); + } + style +} diff --git a/src/ui/terminal_tab/tab_bar.rs b/src/ui/terminal_tab/tab_bar.rs new file mode 100644 index 0000000..cc97862 --- /dev/null +++ b/src/ui/terminal_tab/tab_bar.rs @@ -0,0 +1,147 @@ +use crate::app::App; +use crate::runtime::terminal::visible_range; +use crate::ui::terminal_tab::layout::{ + JUMP_KEY_PANE_COUNT, TAB_TITLE_MAX_CHARS, terminal_layout, truncate_tab_title, +}; +use ratatui::{ + Frame, + layout::{Position, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; + +/// What one rendered tab-bar segment is, deciding both its style and what a +/// click on it does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TabSegment { + /// The ` t: new terminal` legend shown with no panes. Inert. + Legend, + /// A pane's tab; a click jumps to this pane index. + Tab(usize), + /// A `+N` hidden-pane marker; a click jumps to the nearest hidden pane + /// on its side (`visible.start - 1` / `visible.end`), which slides the + /// visible window by exactly one slot via `sync_visible_window` — the + /// minimal reveal. + Marker(usize), +} + +impl TabSegment { + /// The pane index a click on this segment jumps to, if any. + fn click_target(self) -> Option { + match self { + TabSegment::Legend => None, + TabSegment::Tab(i) | TabSegment::Marker(i) => Some(i), + } + } +} + +/// The tab bar's rendered segments in draw order. Single source for +/// `render_tab_bar` (which styles them) and `tab_target_at` (which measures +/// them), so the click hit-test cannot drift from the drawn labels. +pub(crate) fn tab_segments(app: &App, visible: std::ops::Range) -> Vec<(String, TabSegment)> { + if app.terminal.panes.is_empty() { + return vec![( + format!(" {} t: new terminal ", app.leader_label()), + TabSegment::Legend, + )]; + } + // While the terminal fills the body the upper viewer is hidden, so + // ` 1..8` address panes 0..7 directly (see + // `input::prefix_action_fullscreen`); label the tabs with those digits. + // In the split view the digits `1`/`2` belong to the list/diff, so the + // pane legend stays on `F3..F10` there. + let fullscreen = app.terminal.fullscreen.fills_body(); + let hidden_before = visible.start; + let hidden_after = app.terminal.panes.len().saturating_sub(visible.end); + let mut segments = Vec::new(); + if hidden_before > 0 { + segments.push(( + format!(" +{hidden_before} "), + TabSegment::Marker(visible.start - 1), + )); + } + segments.extend(app.terminal.panes[visible.clone()].iter().enumerate().map( + |(offset, pane)| { + let i = visible.start + offset; + // Panes 0..=7 carry a jump key, so show it as a key legend: + // ` 1..8` in fullscreen, ` 3..9,0` in the split + // view (the digit row is layout-aware — see `prefix_action`). + // 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. + let title = truncate_tab_title(&pane.title, TAB_TITLE_MAX_CHARS); + let label = if i < JUMP_KEY_PANE_COUNT { + // Split view runs 3,4..9 then wraps to 0 for the eighth pane. + let digit = if fullscreen { + char::from_digit(i as u32 + 1, 10).unwrap_or('?') + } else { + char::from_digit((i as u32 + 3) % 10, 10).unwrap_or('?') + }; + format!(" {}{} {} ", app.leader_label(), digit, title) + } else { + format!(" {} ", title) + }; + (label, TabSegment::Tab(i)) + }, + )); + if hidden_after > 0 { + segments.push(( + format!(" +{hidden_after} "), + TabSegment::Marker(visible.end), + )); + } + segments +} + +/// The pane index a click at screen cell `(x, y)` on the tab bar should +/// jump to: a tab targets its own pane, a `+N` marker the nearest hidden +/// pane on its side. `None` off the tab row, past the last segment, or on +/// the no-panes legend. `area` is the full terminal widget Rect, exactly +/// what `render` receives. +pub(crate) fn tab_target_at(app: &App, area: Rect, x: u16, y: u16) -> Option { + let (tab_area, _) = terminal_layout(area)?; + if !tab_area.contains(Position { x, y }) { + return None; + } + let visible = visible_range( + app.terminal.visible_start, + app.terminal.active, + app.terminal.panes.len(), + app.terminal.max_visible(), + ); + let mut cursor = tab_area.x; + for (text, segment) in tab_segments(app, visible) { + let width = Span::raw(text.as_str()).width() as u16; + if x >= cursor && x < cursor + width { + return segment.click_target(); + } + cursor += width; + } + None +} + +pub(crate) fn render_tab_bar( + frame: &mut Frame, + app: &App, + tab_area: Rect, + accent: Color, + focused: bool, + visible: std::ops::Range, +) { + let tab_spans: Vec = tab_segments(app, visible) + .into_iter() + .map(|(text, segment)| { + let style = match segment { + TabSegment::Tab(i) if i == app.terminal.active && focused => Style::default() + .fg(Color::Black) + .bg(accent) + .add_modifier(Modifier::BOLD), + TabSegment::Tab(_) => Style::default().fg(Color::Gray), + TabSegment::Legend | TabSegment::Marker(_) => Style::default().fg(Color::DarkGray), + }; + Span::styled(text, style) + }) + .collect(); + frame.render_widget(Paragraph::new(Line::from(tab_spans)), tab_area); +} diff --git a/src/ui/terminal_tab/tests/layout_tests.rs b/src/ui/terminal_tab/tests/layout_tests.rs new file mode 100644 index 0000000..d4f6191 --- /dev/null +++ b/src/ui/terminal_tab/tests/layout_tests.rs @@ -0,0 +1,156 @@ +use crate::app::tests::app_with_files; +use crate::ui::terminal_tab::layout::{split_pane_areas, terminal_layout, truncate_tab_title}; +use crate::ui::terminal_tab::render; +use crate::ui::terminal_tab::screen::screen_cursor_position; +use ratatui::{Terminal, backend::TestBackend, layout::{Position, Rect}, style::Color}; + +#[test] +fn maps_screen_cursor_to_render_area() { + let mut emulator = crate::runtime::emulator::PaneEmulator::new(3, 10, 0); + emulator.process(b"\x1b[2;4H"); + + let position = screen_cursor_position(&emulator.view(), Rect::new(20, 10, 10, 3)).unwrap(); + + assert_eq!(position, Position::new(23, 11)); +} + +#[test] +fn short_title_passes_through_untouched() { + assert_eq!(truncate_tab_title("claude", 24), "claude"); +} + +#[test] +fn long_title_is_cut_with_ellipsis_within_budget() { + let truncated = truncate_tab_title("claude-code: very-long-project-name", 24); + assert_eq!(truncated.chars().count(), 24); + assert!(truncated.ends_with('…')); + assert!(truncated.starts_with("claude-code")); +} + +#[test] +fn title_exactly_at_budget_is_not_truncated() { + let s: String = "a".repeat(24); + assert_eq!(truncate_tab_title(&s, 24), s); +} + +#[test] +fn keeps_cursor_visible_when_terminal_requests_hide() { + let mut emulator = crate::runtime::emulator::PaneEmulator::new(3, 10, 0); + emulator.process(b"\x1b[?25l\x1b[2;4H"); + + let position = screen_cursor_position(&emulator.view(), Rect::new(20, 10, 10, 3)).unwrap(); + + assert_eq!(position, Position::new(23, 11)); +} + +#[test] +fn content_spans_full_width_without_side_borders() { + // The terminal content must reach both pane edges so copied output never + // includes a `│`. Side borders would inset x by 1 and shrink width by 2. + let area = Rect::new(0, 0, 40, 10); + let (_, content) = terminal_layout(area).unwrap(); + + assert_eq!(content.x, area.x); + assert_eq!(content.width, area.width); +} + +#[test] +fn render_does_not_resize_terminal_state() { + let mut app = app_with_files(vec!["a.rs"]); + app.terminal.size = (3, 10); + let mut terminal = Terminal::new(TestBackend::new(40, 10)).unwrap(); + + terminal + .draw(|frame| { + render(frame, &app, frame.area(), Color::Yellow); + }) + .unwrap(); + + assert_eq!(app.terminal.size, (3, 10)); +} + +#[test] +fn split_pane_areas_single_pane_fills_area() { + let area = Rect::new(0, 0, 80, 24); + assert_eq!(split_pane_areas(area, 1), vec![area]); +} + +#[test] +fn split_pane_areas_two_panes_side_by_side_when_wide() { + let area = Rect::new(0, 0, 80, 24); + let cells = split_pane_areas(area, 2); + assert_eq!(cells.len(), 2); + assert_eq!(cells[0].y, cells[1].y); + assert_ne!(cells[0].x, cells[1].x); +} + +#[test] +fn split_pane_areas_two_panes_stacked_when_narrow() { + let area = Rect::new(0, 0, 30, 24); + let cells = split_pane_areas(area, 2); + assert_eq!(cells.len(), 2); + assert_eq!(cells[0].x, cells[1].x); + assert_ne!(cells[0].y, cells[1].y); +} + +#[test] +fn split_pane_areas_three_panes_two_over_one() { + let area = Rect::new(0, 0, 80, 24); + let cells = split_pane_areas(area, 3); + assert_eq!(cells.len(), 3); + // First row: two side-by-side cells. + assert_eq!(cells[0].y, cells[1].y); + // Second row: one full-width cell below. + assert!(cells[2].y > cells[0].y); +} + +#[test] +fn split_pane_areas_four_panes_is_2x2() { + let area = Rect::new(0, 0, 80, 24); + let cells = split_pane_areas(area, 4); + assert_eq!(cells.len(), 4); + let rows: std::collections::BTreeSet = cells.iter().map(|r| r.y).collect(); + assert_eq!(rows.len(), 2); +} + +#[test] +fn split_pane_areas_seven_panes_four_then_three() { + let area = Rect::new(0, 0, 100, 30); + let cells = split_pane_areas(area, 7); + assert_eq!(cells.len(), 7); + let top_row_y = cells[0].y; + let top_row_count = cells.iter().filter(|r| r.y == top_row_y).count(); + assert_eq!(top_row_count, 4); +} + +#[test] +fn split_pane_areas_eight_panes_is_2x4() { + let area = Rect::new(0, 0, 100, 30); + let cells = split_pane_areas(area, 8); + assert_eq!(cells.len(), 8); + let rows: std::collections::BTreeSet = cells.iter().map(|r| r.y).collect(); + assert_eq!(rows.len(), 2); + let top_row_y = cells[0].y; + let top_row_count = cells.iter().filter(|r| r.y == top_row_y).count(); + assert_eq!(top_row_count, 4); +} + +#[test] +fn split_pane_areas_never_produces_zero_size_cells_when_area_fits() { + for count in 1..=8 { + let area = Rect::new(0, 0, 40, 20); + let cells = split_pane_areas(area, count); + assert_eq!(cells.len(), count, "count={count}"); + for cell in cells { + assert!( + cell.width > 0 && cell.height > 0, + "count={count} cell={cell:?}" + ); + } + } +} + +#[test] +fn split_pane_areas_empty_for_zero_count() { + assert!(split_pane_areas(Rect::new(0, 0, 80, 24), 0).is_empty()); +} diff --git a/src/ui/terminal_tab/tests/mod.rs b/src/ui/terminal_tab/tests/mod.rs new file mode 100644 index 0000000..b38e258 --- /dev/null +++ b/src/ui/terminal_tab/tests/mod.rs @@ -0,0 +1,32 @@ +mod layout_tests; +mod render_tests; +mod tab_tests; + +use crate::app::App; +use crate::runtime::terminal::visible_range; +use crate::ui::terminal_tab::layout::terminal_layout; +use crate::ui::terminal_tab::tab_bar::tab_segments; +use ratatui::layout::Rect; +use ratatui::text::Span; + +pub(super) fn buffer_text(buf: &ratatui::buffer::Buffer) -> String { + buf.content.iter().map(|c| c.symbol()).collect() +} + +pub(super) fn tab_segment_x(app: &App, area: Rect, nth: usize) -> u16 { + let (tab_area, _) = terminal_layout(area).unwrap(); + let visible = visible_range( + app.terminal.visible_start, + app.terminal.active, + app.terminal.panes.len(), + app.terminal.max_visible(), + ); + let segments = tab_segments(app, visible); + assert!(nth < segments.len(), "segment {nth} must exist"); + tab_area.x + + segments + .iter() + .take(nth) + .map(|(text, _)| Span::raw(text.as_str()).width() as u16) + .sum::() +} diff --git a/src/ui/terminal_tab/tests/render_tests.rs b/src/ui/terminal_tab/tests/render_tests.rs new file mode 100644 index 0000000..258d32e --- /dev/null +++ b/src/ui/terminal_tab/tests/render_tests.rs @@ -0,0 +1,136 @@ +use super::*; +use crate::app::Focus; +use crate::ui::terminal_tab::layout::terminal_layout; +use crate::ui::terminal_tab::render; +use ratatui::{ +Terminal, +backend::TestBackend, +layout::Rect, +style::{Color, Modifier}, +}; + +#[test] +fn single_pane_render_still_has_no_left_border_character() { + // Regression guard for split-view acceptance criterion 9: with only + // one pane, render() must take the no-cell-border branch, matching + // pre-split-view behaviour exactly (clean copy-paste, no `│`). + let mut app = crate::app::tests::app_with_fake_backend(); + app.terminal.create_pane_with(None, Some("Solo")).unwrap(); + let area = Rect::new(0, 0, 40, 10); + let mut terminal = Terminal::new(TestBackend::new(40, 10)).unwrap(); + + terminal + .draw(|frame| { + render(frame, &app, area, Color::Yellow); + }) + .unwrap(); + + let (_, content) = terminal_layout(area).unwrap(); + let buf = terminal.backend().buffer(); + for y in content.top()..content.bottom() { + let cell = buf.cell((content.x, y)).unwrap(); + assert_ne!( + cell.symbol(), + "│", + "single pane must not draw a left border at y={y}" + ); + } +} + +#[test] +fn split_view_renders_multiple_panes_simultaneously() { + let mut app = crate::app::tests::app_with_fake_backend(); + app.terminal.create_pane_with(None, Some("Alpha")).unwrap(); + app.terminal.create_pane_with(None, Some("Beta")).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap(); + + terminal + .draw(|frame| { + render(frame, &app, frame.area(), Color::Yellow); + }) + .unwrap(); + + let text = buffer_text(terminal.backend().buffer()); + assert!( + text.contains("Alpha") && text.contains("Beta"), + "expected both pane titles visible at once, got: {text}" + ); +} + +#[test] +fn split_view_borders_active_pane_in_accent_color() { + let mut app = crate::app::tests::app_with_fake_backend(); + app.terminal.create_pane_with(None, Some("Alpha")).unwrap(); + app.terminal.create_pane_with(None, Some("Beta")).unwrap(); + app.focus = Focus::Terminal; + let accent = Color::Yellow; + let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap(); + + terminal + .draw(|frame| { + render(frame, &app, frame.area(), accent); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + assert!( + buf.content.iter().any(|cell| cell.fg == accent), + "expected the active pane's border/title in accent color" + ); + assert!( + buf.content.iter().any(|cell| cell.fg == Color::DarkGray), + "expected the inactive pane's border in dark gray" + ); +} + +#[test] +fn split_view_active_pane_matches_inactive_style_when_terminal_unfocused() { + // Regression guard: accent (and any brighter stand-in for it) must + // mean "keystrokes go here right now". When Diff/FileList holds + // focus, the terminal's active pane must render pixel-identical to + // an inactive pane — no accent, no bold, no lighter gray — otherwise + // it still reads as focused when it isn't. + let mut app = crate::app::tests::app_with_fake_backend(); + app.terminal.create_pane_with(None, Some("Alpha")).unwrap(); + app.terminal.create_pane_with(None, Some("Beta")).unwrap(); + app.focus = Focus::DiffViewer; + let accent = Color::Yellow; + let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap(); + + terminal + .draw(|frame| { + render(frame, &app, frame.area(), accent); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + assert!( + !buf.content + .iter() + .any(|cell| cell.fg == accent || cell.fg == Color::White), + "terminal must not show accent or white anywhere while unfocused" + ); + assert!( + !buf.content + .iter() + .any(|cell| cell.modifier.contains(Modifier::BOLD) && cell.bg == accent), + "active pane tab must not carry an accent-bolded highlight while unfocused" + ); +} + +#[test] +fn tab_target_at_resolves_tabs_and_hidden_markers() { + let mut app = crate::app::tests::app_with_fake_backend(); + app.terminal.max_visible_normal = 2; + for i in 0..4 { + app.terminal + .create_pane_with(None, Some(&format!("P{i}"))) + .unwrap(); + } + // Creation leaves pane 3 active with a 2-pane window: [2, 4). + let area = Rect::new(0, 0, 80, 20); + let (tab_area, _) = terminal_layout(area).unwrap(); + let y = tab_area.y; + let _ = y; +} + diff --git a/src/ui/terminal_tab/tests/tab_tests.rs b/src/ui/terminal_tab/tests/tab_tests.rs new file mode 100644 index 0000000..7929245 --- /dev/null +++ b/src/ui/terminal_tab/tests/tab_tests.rs @@ -0,0 +1,177 @@ +use super::*; +use crate::ui::terminal_tab::layout::terminal_layout; +use crate::ui::terminal_tab::tab_bar::tab_target_at; +use crate::ui::terminal_tab::render; +use ratatui::{Terminal, backend::TestBackend, layout::Rect, style::Color}; + +#[test] +fn tab_target_at_resolves_tabs_and_hidden_markers() { + let mut app = crate::app::tests::app_with_fake_backend(); + app.terminal.max_visible_normal = 2; + for i in 0..4 { + app.terminal + .create_pane_with(None, Some(&format!("P{i}"))) + .unwrap(); + } + // Creation leaves pane 3 active with a 2-pane window: [2, 4). + let area = Rect::new(0, 0, 80, 20); + let (tab_area, _) = terminal_layout(area).unwrap(); + let y = tab_area.y; + // Segment 0 is the ` +2 ` marker → nearest hidden pane on the left. + assert_eq!( + tab_target_at(&app, area, tab_segment_x(&app, area, 0), y), + Some(1) + ); + // Segments 1 and 2 are the visible tabs for panes 2 and 3. + assert_eq!( + tab_target_at(&app, area, tab_segment_x(&app, area, 1), y), + Some(2) + ); + assert_eq!( + tab_target_at(&app, area, tab_segment_x(&app, area, 2), y), + Some(3) + ); + // Past the last segment and off the tab row: no target. + assert_eq!(tab_target_at(&app, area, tab_area.right() - 1, y), None); + assert_eq!( + tab_target_at(&app, area, tab_segment_x(&app, area, 1), y + 1), + None + ); +} + +#[test] +fn tab_target_at_right_marker_reveals_the_next_hidden_pane() { + let mut app = crate::app::tests::app_with_fake_backend(); + app.terminal.max_visible_normal = 2; + for i in 0..4 { + app.terminal + .create_pane_with(None, Some(&format!("P{i}"))) + .unwrap(); + } + // Jump back to pane 0: window slides to [0, 2), marker sits on the right. + app.terminal.active = 0; + app.terminal.sync_visible_window(); + let area = Rect::new(0, 0, 80, 20); + let (tab_area, _) = terminal_layout(area).unwrap(); + + // Segments: tab 0, tab 1, ` +2 ` marker → nearest hidden pane index 2. + let x = tab_segment_x(&app, area, 2); + assert_eq!(tab_target_at(&app, area, x, tab_area.y), Some(2)); +} + +#[test] +fn tab_target_agrees_with_the_rendered_buffer_not_just_the_builder() { + // Independent cross-check: find the second tab's jump-key label in + // the *rendered* buffer and hit-test at that column. Catches any + // renderer vs hit-test segmentation drift the builder-based + // position helper cannot see. + let mut app = crate::app::tests::app_with_fake_backend(); + app.terminal.create_pane_with(None, Some("Alpha")).unwrap(); + app.terminal.create_pane_with(None, Some("Beta")).unwrap(); + let area = Rect::new(0, 0, 80, 20); + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + terminal + .draw(|frame| { + render(frame, &app, area, Color::Yellow); + }) + .unwrap(); + + let (tab_area, _) = terminal_layout(area).unwrap(); + let buf = terminal.backend().buffer(); + let cells: Vec<&str> = (0..buf.area.width) + .map(|x| buf[(x, tab_area.y)].symbol()) + .collect(); + let x = (0..cells.len()) + .find(|&i| cells[i..].concat().starts_with("^F4 Beta")) + .expect("second tab rendered") as u16; + + assert_eq!(tab_target_at(&app, area, x, tab_area.y), Some(1)); +} + +#[test] +fn tab_target_at_none_on_the_no_pane_legend() { + let app = crate::app::tests::app_with_fake_backend(); + let area = Rect::new(0, 0, 80, 20); + let (tab_area, _) = terminal_layout(area).unwrap(); + + assert_eq!(tab_target_at(&app, area, tab_area.x + 2, tab_area.y), None); +} + +#[test] +fn tab_bar_marks_hidden_panes_beyond_max_visible() { + let mut app = crate::app::tests::app_with_fake_backend(); + for i in 0..5 { + app.terminal + .create_pane_with(None, Some(&format!("P{i}"))) + .unwrap(); + } + assert_eq!(app.terminal.max_visible_normal, 4); + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + + terminal + .draw(|frame| { + render(frame, &app, frame.area(), Color::Yellow); + }) + .unwrap(); + + let text = buffer_text(terminal.backend().buffer()); + assert!( + text.contains('+'), + "expected a hidden-pane count marker, got: {text}" + ); +} + +#[test] +fn tab_bar_labels_panes_with_leader_digits_in_split_view() { + let mut app = crate::app::tests::app_with_fake_backend(); + app.terminal.create_pane_with(None, Some("Alpha")).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap(); + + terminal + .draw(|frame| { + render(frame, &app, frame.area(), Color::Yellow); + }) + .unwrap(); + + let text = buffer_text(terminal.backend().buffer()); + // The bare F-keys select project tabs, so the pane legend must name + // the leader digit that actually reaches this pane. + assert!( + text.contains("^F3 Alpha"), + "split view must label the first pane with its 3 jump key, got: {text}" + ); + // "^F3 Alpha" contains "F3 Alpha" as a substring because the `Ctrl+F` + // leader label ends in F; strip the legit legend before checking the + // bare function-key legend never appears on its own. + assert!( + !text.replace("^F3 Alpha", "").contains("F3 Alpha"), + "the bare F-key must not be advertised for panes, got: {text}" + ); +} + +#[test] +fn tab_bar_labels_panes_with_digits_in_fullscreen() { + // Fullscreen hides the viewer, so the pane legend switches to the + // ` 1..8` digits that address panes there. + let mut app = crate::app::tests::app_with_fake_backend(); + app.terminal.create_pane_with(None, Some("Alpha")).unwrap(); + app.terminal.create_pane_with(None, Some("Beta")).unwrap(); + app.terminal.fullscreen = crate::runtime::terminal::TerminalFullscreen::Grid; + let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap(); + + terminal + .draw(|frame| { + render(frame, &app, frame.area(), Color::Yellow); + }) + .unwrap(); + + let text = buffer_text(terminal.backend().buffer()); + assert!( + text.contains("1 Alpha") && text.contains("2 Beta"), + "fullscreen must label panes with their digits, got: {text}" + ); + assert!( + !text.contains("F3"), + "fullscreen must not show the split-view F-key legend, got: {text}" + ); +} diff --git a/src/ui/tests/chrome_tests.rs b/src/ui/tests/chrome_tests.rs new file mode 100644 index 0000000..45ad5ee --- /dev/null +++ b/src/ui/tests/chrome_tests.rs @@ -0,0 +1,185 @@ +use super::common::*; +use crate::app::NoticeKind; +use crate::app::tests::{app_with_fake_backend, app_with_files}; +use crate::config::LayoutConfig; +use crate::runtime::terminal::TerminalFullscreen; +use crate::ui::chrome::Chrome; +use crate::ui::status_view::RepoInput; +use crate::ui::{draw, home_relative_path, main_content_constraints, project_tab_at}; +use ratatui::{ + Terminal, + backend::TestBackend, + layout::{Constraint, Rect}, + style::Color, +}; +use syntect::highlighting::ThemeSet; + +#[test] +fn the_empty_screen_names_the_only_two_things_that_work() { + let text = drawn_empty(&RepoInput::default(), None, false); + + assert!(text.contains("no project open"), "got: {text}"); + assert!(text.contains("^F o: open project"), "got: {text}"); + assert!(text.contains("^F q: quit"), "got: {text}"); +} + +#[test] +fn the_empty_screen_shows_the_prefix_chip_when_armed() { + // Pressing the leader with no project open has to look like it did + // something, or it reads as a dead key. + let text = drawn_empty(&RepoInput::default(), None, true); + + assert!(text.contains("PREFIX"), "got: {text}"); + assert!(text.contains("o: open project"), "got: {text}"); + assert!(text.contains("esc: cancel"), "got: {text}"); +} + +#[test] +fn the_empty_screen_shows_the_dialog_and_its_rejection() { + // The dialog and its notice are the reason the empty screen keeps its + // chrome at all — a rejected path must still report why. + let repo_input = RepoInput { + active: true, + buf: "/definitely/not/here".to_string(), + prefilled: false, + }; + let notice = crate::app::Notice::new(NoticeKind::RepoInput, "no such directory"); + + let text = drawn_empty(&repo_input, Some(¬ice), false); + + assert!(text.contains("repo: /definitely/not/here"), "got: {text}"); + assert!(text.contains("no such directory"), "got: {text}"); +} + +#[test] +fn the_project_tab_row_survives_every_fullscreen_mode() { + // Chrome is rendered before the layout branches precisely so no view + // mode can strand the user without knowing which project they are in. + let paths = vec!["/w/api".to_string(), "/w/web".to_string()]; + + let mut app = app_with_fake_backend(); + assert!(drawn_text(&mut app, &paths, 0).contains("F2 web"), "split"); + + let mut app = app_with_fake_backend(); + app.terminal.fullscreen = TerminalFullscreen::Grid; + assert!( + drawn_text(&mut app, &paths, 0).contains("F2 web"), + "terminal fullscreen" + ); + + let mut app = app_with_files(vec!["a.rs"]); + app.list_fullscreen = true; + assert!( + drawn_text(&mut app, &paths, 0).contains("F2 web"), + "list fullscreen" + ); + + let mut app = app_with_files(vec!["a.rs"]); + app.diff.fullscreen = true; + assert!( + drawn_text(&mut app, &paths, 0).contains("F2 web"), + "diff fullscreen" + ); +} + +#[test] +fn project_tab_at_matches_the_rendered_row() { + // The hit test derives from `chrome_rows` like `draw` does, so a click + // on a tab's glyphs must resolve to that tab. + let mut app = app_with_files(vec!["a.rs"]); + let paths = vec!["/w/api".to_string(), "/w/web".to_string()]; + let screen = Rect::new(0, 0, 120, 20); + let text = drawn_text(&mut app, &paths, 0); + let first_row = text.lines().next().unwrap(); + let web_x = first_row.find("F2 web").expect("second tab rendered") as u16; + + let tabs = Chrome { + repo_paths: &paths, + active: 0, + repo_input: &RepoInput::default(), + }; + assert_eq!(project_tab_at(tabs, screen, 0, 0), Some(0)); + assert_eq!(project_tab_at(tabs, screen, web_x, 0), Some(1)); + // Row 1 is the body, not the tab row. + assert_eq!(project_tab_at(tabs, screen, web_x, 1), None); +} + +#[test] +fn panels_advertise_the_leader_digit_not_the_bare_f_key() { + // The bare F-key row selects project tabs, so a panel legend reading + // `F1 Files` would name a key that switches projects instead of + // focusing the panel. + let mut app = app_with_files(vec!["a.rs"]); + let tab_paths = vec![".".to_string()]; + let mut terminal = Terminal::new(TestBackend::new(120, 20)).unwrap(); + let ss = two_face::syntax::extra_newlines(); + let ts = ThemeSet::load_defaults(); + terminal + .draw(|frame| { + draw( + frame, + &mut app, + Chrome { + repo_paths: &tab_paths, + active: 0, + repo_input: &RepoInput::default(), + }, + &ss, + &ts, + &LayoutConfig::default(), + Color::Yellow, + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let text: String = (0..buf.area.height) + .map(|y| { + (0..buf.area.width) + .map(|x| buf[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + text.contains("^F1 Files"), + "file list must advertise its leader digit, got: {text}" + ); + // The `Ctrl+F` leader label ("^F") ends in the letter F, so the legit + // "^F1 Files" legend contains "F1 Files" as a substring. Strip it before + // asserting the bare function-key legend never appears on its own. + assert!( + !text.replace("^F1 Files", "").contains("F1 Files"), + "the bare F-key must not be advertised for panels, got: {text}" + ); +} + +#[test] +fn home_relative_strips_home_prefix_and_trailing_slash() { + let home = dirs::home_dir().expect("home dir for test host"); + let home_str = home.to_str().unwrap(); + let nested = format!("{home_str}/projects/foo/"); + assert_eq!(home_relative_path(&nested), "~/projects/foo"); +} + +#[test] +fn home_relative_keeps_paths_outside_home_unchanged() { + // Trailing slash still trimmed for compactness, but the body is + // returned verbatim when the home prefix doesn't match. + assert_eq!(home_relative_path("/tmp/repo/"), "/tmp/repo"); + assert_eq!(home_relative_path("/var/code"), "/var/code"); +} + +#[test] +fn main_content_split_preserves_lower_panel_at_high_upper_ratio() { + let cfg = LayoutConfig { + upper_pct: 99, + file_list_pct: 25, + }; + + assert_eq!( + main_content_constraints(&cfg), + [Constraint::Percentage(99), Constraint::Percentage(1)] + ); +} diff --git a/src/ui/tests/common.rs b/src/ui/tests/common.rs new file mode 100644 index 0000000..3a0646c --- /dev/null +++ b/src/ui/tests/common.rs @@ -0,0 +1,157 @@ +use crate::app::App; +use crate::app::tests::app_with_files; +use crate::config::LayoutConfig; +use crate::ui::chrome::Chrome; +use crate::ui::hint_bar::{hint_click_at, render_hint_bar}; +use crate::ui::notice::render_notice_row; +use crate::ui::status_view::RepoInput; +use crate::ui::{draw, draw_empty}; +use ratatui::{ + Terminal, + backend::TestBackend, + layout::Rect, + style::{Color, Modifier}, +}; +use syntect::highlighting::ThemeSet; + +pub(super) fn notice_text(app: &App) -> String { + let mut terminal = Terminal::new(TestBackend::new(200, 1)).unwrap(); + terminal + .draw(|frame| frame.render_widget(render_notice_row(app, Color::Yellow), frame.area())) + .unwrap(); + let buf = terminal.backend().buffer(); + (0..buf.area.width) + .map(|x| buf[(x, 0)].symbol()) + .collect::() +} + +pub(super) fn test_workspace() -> crate::workspace::Workspace { + let mut ws = crate::workspace::Workspace::new(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('f'), + crossterm::event::KeyModifiers::CONTROL, + )); + ws.add(app_with_files(vec![])); + ws +} + +pub(super) fn plain_chrome(repo_input: &RepoInput) -> Chrome<'_> { + Chrome { + repo_paths: &[], + active: 0, + repo_input, + } +} + +pub(super) fn hint_text(app: &App) -> String { + let repo_input = RepoInput::default(); + hint_text_with(app, plain_chrome(&repo_input)) +} + +pub(super) fn hint_text_with(app: &App, chrome: Chrome<'_>) -> String { + let mut terminal = Terminal::new(TestBackend::new(200, 1)).unwrap(); + terminal + .draw(|frame| { + frame.render_widget(render_hint_bar(app, chrome, Color::Yellow), frame.area()) + }) + .unwrap(); + let buf = terminal.backend().buffer(); + (0..buf.area.height) + .map(|y| { + (0..buf.area.width) + .map(|x| buf[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") +} + +pub(super) fn assert_inverted_cells_are_clickable(app: &App) { + let repo_input = RepoInput::default(); + let chrome = plain_chrome(&repo_input); + let mut terminal = Terminal::new(TestBackend::new(200, 1)).unwrap(); + terminal + .draw(|frame| { + frame.render_widget(render_hint_bar(app, chrome, Color::Yellow), frame.area()) + }) + .unwrap(); + let buf = terminal.backend().buffer(); + let screen = Rect::new(0, 0, 200, 3); + let mut inverted = 0; + for x in 0..200u16 { + let is_inverted = buf[(x, 0)].modifier.contains(Modifier::REVERSED); + let is_clickable = hint_click_at(app, chrome, screen, x, 2).is_some(); + assert_eq!( + is_inverted, is_clickable, + "hint cell at column {x}: inverted={is_inverted} but clickable={is_clickable}" + ); + inverted += is_inverted as u32; + } + assert!( + inverted > 0, + "at least one clickable key label must render inverted" + ); +} + +pub(super) fn drawn_text(app: &mut App, tab_paths: &[String], active: usize) -> String { + let mut terminal = Terminal::new(TestBackend::new(120, 20)).unwrap(); + let ss = two_face::syntax::extra_newlines(); + let ts = ThemeSet::load_defaults(); + terminal + .draw(|frame| { + let tabs = Chrome { + repo_paths: tab_paths, + active, + repo_input: &RepoInput::default(), + }; + draw( + frame, + app, + tabs, + &ss, + &ts, + &LayoutConfig::default(), + Color::Yellow, + ); + }) + .unwrap(); + let buf = terminal.backend().buffer(); + (0..buf.area.height) + .map(|y| { + (0..buf.area.width) + .map(|x| buf[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") +} + +pub(super) fn drawn_empty( + repo_input: &RepoInput, + notice: Option<&crate::app::Notice>, + armed: bool, +) -> String { + let mut terminal = Terminal::new(TestBackend::new(90, 12)).unwrap(); + let leader = crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('f'), + crossterm::event::KeyModifiers::CONTROL, + ); + terminal + .draw(|frame| { + let chrome = Chrome { + repo_paths: &[], + active: 0, + repo_input, + }; + draw_empty(frame, chrome, notice, leader, armed, false, Color::Yellow); + }) + .unwrap(); + let buf = terminal.backend().buffer(); + (0..buf.area.height) + .map(|y| { + (0..buf.area.width) + .map(|x| buf[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") +} diff --git a/src/ui/tests/hint_armed_tests.rs b/src/ui/tests/hint_armed_tests.rs new file mode 100644 index 0000000..887acf0 --- /dev/null +++ b/src/ui/tests/hint_armed_tests.rs @@ -0,0 +1,177 @@ +use super::common::*; +use crate::app::App; +use crate::app::tests::app_with_fake_backend; +use crate::app::{Focus, ViewMode}; +use crate::ui::hint_bar::{HintClick, hint_click_at}; +use crate::ui::status_view::RepoInput; +use ratatui::layout::Rect; + +/// ` w` only closes with terminal focus (`handle_global_action` +/// scopes it), so both the armed row and the normal legends must only +/// advertise it there — a hint for a no-op key would lie. +#[test] +fn prefix_hint_advertises_close_only_with_terminal_focus() { + let mut upper = app_with_fake_backend(); + upper.arm_prefix(); + assert!( + !hint_text(&upper).contains("w: close"), + "armed row must not offer close without terminal focus" + ); + + let mut term = app_with_fake_backend(); + term.focus = Focus::Terminal; + term.arm_prefix(); + assert!( + hint_text(&term).contains("w: close"), + "armed row must offer close with terminal focus" + ); +} + +/// The armed row's `w: close` must round-trip to a click exactly when it +/// is shown: some column resolves to `Plain('w')` with terminal focus, +/// and no column does without it (the segment isn't rendered, so a click +/// target for it would be a phantom). +#[test] +fn armed_prefix_close_click_target_follows_terminal_focus() { + let screen = Rect::new(0, 0, 200, 3); + let clicks = |app: &App| { + (0..200u16) + .filter(|&x| { + hint_click_at(app, plain_chrome(&RepoInput::default()), screen, x, 2) + == Some(HintClick::Plain('w')) + }) + .count() + }; + + let mut term = app_with_fake_backend(); + term.focus = Focus::Terminal; + term.arm_prefix(); + assert!( + clicks(&term) > 0, + "terminal-focused armed row must offer a close click target" + ); + + let mut upper = app_with_fake_backend(); + upper.arm_prefix(); + assert_eq!( + clicks(&upper), + 0, + "non-terminal armed row must not resolve any cell to a close click" + ); +} + +/// ` s` shares close's scoping (`handle_global_action`): terminal +/// focus plus a second pane to swap with. The armed row must only +/// advertise it then — a hint for a no-op key would lie. +#[test] +fn prefix_hint_advertises_swap_only_when_a_swap_can_act() { + let mut upper = app_with_fake_backend(); + upper.terminal.create_pane().unwrap(); + upper.terminal.create_pane().unwrap(); + upper.focus = Focus::FileList; + upper.arm_prefix(); + assert!( + !hint_text(&upper).contains("s: swap pane"), + "armed row must not offer swap without terminal focus" + ); + + let mut single = app_with_fake_backend(); + single.terminal.create_pane().unwrap(); + single.focus = Focus::Terminal; + single.arm_prefix(); + assert!( + !hint_text(&single).contains("s: swap pane"), + "armed row must not offer swap with a single pane" + ); + + let mut term = app_with_fake_backend(); + term.terminal.create_pane().unwrap(); + term.terminal.create_pane().unwrap(); + term.focus = Focus::Terminal; + term.arm_prefix(); + assert!( + hint_text(&term).contains("s: swap pane"), + "armed row must offer swap with terminal focus and two panes" + ); +} + +/// The armed row's view toggles name their destination from the current +/// mode, mirroring the normal legends' `l: log view`/`l: status view` +/// wording instead of a generic `log/status` label. +#[test] +fn prefix_hint_names_view_toggle_destinations_by_mode() { + let mut app = app_with_fake_backend(); + app.arm_prefix(); + + let text = hint_text(&app); + assert!( + text.contains("l: log view") && text.contains("b: tree view"), + "status mode armed row must name log/tree destinations, got: {text}" + ); + + app.mode = ViewMode::Log; + let text = hint_text(&app); + assert!( + text.contains("l: status view") && text.contains("b: tree view"), + "log mode armed row must name status/tree destinations, got: {text}" + ); + + app.mode = ViewMode::Tree; + let text = hint_text(&app); + assert!( + text.contains("l: log view") && text.contains("b: status view"), + "tree mode armed row must name log/status destinations, got: {text}" + ); +} + +/// Every upper legend advertises both view toggles with destination +/// labels — `l` (log ↔ status) and `b` (tree ↔ status) act from any +/// focus, so no mode may hide one or name the view already shown. +#[test] +fn upper_legends_advertise_both_view_toggles() { + // FileList browsing commits in Log mode. + let mut app = app_with_fake_backend(); + app.mode = ViewMode::Log; + let text = hint_text(&app); + assert!( + text.contains("l: status view") && text.contains("b: tree view"), + "log list legend must offer both toggles, got: {text}" + ); + + // DiffViewer in Log mode: `l` names status, not the view shown. + app.focus = Focus::DiffViewer; + let text = hint_text(&app); + assert!( + text.contains("l: status view") && text.contains("b: tree view"), + "log diff legend must offer both toggles, got: {text}" + ); + + // Terminal focus in Log mode follows the same destination wording. + app.focus = Focus::Terminal; + let text = hint_text(&app); + assert!( + text.contains("l: status view"), + "log terminal legend must name the status destination, got: {text}" + ); + + // Zoomed list rows carry both toggles in every mode. + let mut zoomed = app_with_fake_backend(); + zoomed.list_fullscreen = true; + let text = hint_text(&zoomed); + assert!( + text.contains("l: log view") && text.contains("b: tree view"), + "zoomed status list must offer both toggles, got: {text}" + ); + zoomed.mode = ViewMode::Log; + let text = hint_text(&zoomed); + assert!( + text.contains("l: status view") && text.contains("b: tree view"), + "zoomed log list must offer both toggles, got: {text}" + ); + zoomed.mode = ViewMode::Tree; + let text = hint_text(&zoomed); + assert!( + text.contains("b: status view") && text.contains("l: log view"), + "zoomed tree list must offer both toggles, got: {text}" + ); +} diff --git a/src/ui/tests/hint_click_tests.rs b/src/ui/tests/hint_click_tests.rs new file mode 100644 index 0000000..b891c37 --- /dev/null +++ b/src/ui/tests/hint_click_tests.rs @@ -0,0 +1,197 @@ +use super::common::*; +use crate::app::App; +use crate::app::tests::app_with_fake_backend; +use crate::ui::hint_bar::{HintClick, hint_click_at, render_hint_bar}; +use crate::ui::hint_text::{PREFIX_CHIP, normal_hint_literal, prefix_armed_hint_text}; +use crate::ui::status_view::RepoInput; +use ratatui::{Terminal, backend::TestBackend, layout::Rect, style::Color, text::Span}; + +/// x column where `needle` starts on the rendered hint row, measured in +/// display cells over exactly the text the renderer draws. +fn hint_x_of(app: &App, needle: &str) -> u16 { + let (chip, text) = if app.prefix_armed() { + (PREFIX_CHIP, prefix_armed_hint_text(app)) + } else { + ( + "", + normal_hint_literal(app).replace("", &app.leader_label()), + ) + }; + let full = format!("{chip}{text}"); + let byte = full.find(needle).expect("needle must be on the hint row"); + Span::raw(&full[..byte]).width() as u16 +} + +const HINT_TEST_SCREEN: Rect = Rect::new(0, 0, 300, 40); +const HINT_ROW: u16 = 39; + +#[test] +fn hint_click_resolves_commands_and_skips_nav_and_quit() { + // Default state: FileList focus, status view — the row carries both + // leader commands and nav segments. + let app = app_with_fake_backend(); + + let x = hint_x_of(&app, "t: new pane"); + assert_eq!( + hint_click_at( + &app, + plain_chrome(&RepoInput::default()), + HINT_TEST_SCREEN, + x, + HINT_ROW + ), + Some(HintClick::Leader('t')) + ); + let x = hint_x_of(&app, "/: search"); + assert_eq!( + hint_click_at( + &app, + plain_chrome(&RepoInput::default()), + HINT_TEST_SCREEN, + x, + HINT_ROW + ), + Some(HintClick::Plain('/')) + ); + let x = hint_x_of(&app, "j/k: navigate"); + assert_eq!( + hint_click_at( + &app, + plain_chrome(&RepoInput::default()), + HINT_TEST_SCREEN, + x, + HINT_ROW + ), + None + ); + let x = hint_x_of(&app, "q: quit"); + assert_eq!( + hint_click_at( + &app, + plain_chrome(&RepoInput::default()), + HINT_TEST_SCREEN, + x, + HINT_ROW + ), + None, + "quit must never be one stray click away" + ); +} + +#[test] +fn hint_click_agrees_with_the_rendered_buffer_not_just_the_builder() { + // Independent cross-check: locate the label in the *rendered* buffer + // (no shared width math with `hint_click_at`) and hit-test there. If + // renderer and hit test ever segment differently, this drifts. + let app = app_with_fake_backend(); + let mut terminal = Terminal::new(TestBackend::new(300, 1)).unwrap(); + terminal + .draw(|frame| { + frame.render_widget( + render_hint_bar(&app, plain_chrome(&RepoInput::default()), Color::Yellow), + frame.area(), + ) + }) + .unwrap(); + let buf = terminal.backend().buffer(); + // Scan cell-wise so the needle's index is a *column*, not a byte + // offset — the row contains multi-byte arrows before the label. + let cells: Vec<&str> = (0..buf.area.width).map(|x| buf[(x, 0)].symbol()).collect(); + let x = (0..cells.len()) + .find(|&i| cells[i..].concat().starts_with("t: new pane")) + .expect("label rendered") as u16; + + assert_eq!( + hint_click_at( + &app, + plain_chrome(&RepoInput::default()), + HINT_TEST_SCREEN, + x, + HINT_ROW + ), + Some(HintClick::Leader('t')) + ); +} + +#[test] +fn hint_click_misses_off_the_hint_row() { + let app = app_with_fake_backend(); + let x = hint_x_of(&app, "t: new pane"); + assert_eq!( + hint_click_at( + &app, + plain_chrome(&RepoInput::default()), + HINT_TEST_SCREEN, + x, + HINT_ROW - 1 + ), + None + ); +} + +#[test] +fn hint_click_armed_row_resolves_bare_followups_after_the_chip() { + let mut app = app_with_fake_backend(); + app.arm_prefix(); + + let x = hint_x_of(&app, "t: new pane"); + assert_eq!( + hint_click_at( + &app, + plain_chrome(&RepoInput::default()), + HINT_TEST_SCREEN, + x, + HINT_ROW + ), + Some(HintClick::Plain('t')) + ); + let x = hint_x_of(&app, "r: redraw"); + assert_eq!( + hint_click_at( + &app, + plain_chrome(&RepoInput::default()), + HINT_TEST_SCREEN, + x, + HINT_ROW + ), + Some(HintClick::Plain('r')) + ); + let x = hint_x_of(&app, "q: quit"); + assert_eq!( + hint_click_at( + &app, + plain_chrome(&RepoInput::default()), + HINT_TEST_SCREEN, + x, + HINT_ROW + ), + None + ); + let x = hint_x_of(&app, "esc: cancel"); + assert_eq!( + hint_click_at( + &app, + plain_chrome(&RepoInput::default()), + HINT_TEST_SCREEN, + x, + HINT_ROW + ), + None + ); +} + +#[test] +fn hint_click_none_on_modal_rows() { + let mut swap = app_with_fake_backend(); + swap.begin_swap_target(); + assert!((0..HINT_TEST_SCREEN.width).all(|x| { + hint_click_at( + &swap, + plain_chrome(&RepoInput::default()), + HINT_TEST_SCREEN, + x, + HINT_ROW, + ) + .is_none() + })); +} diff --git a/src/ui/tests/hint_diff_tests.rs b/src/ui/tests/hint_diff_tests.rs new file mode 100644 index 0000000..39cc855 --- /dev/null +++ b/src/ui/tests/hint_diff_tests.rs @@ -0,0 +1,100 @@ +use super::common::*; +use crate::app::tests::app_with_fake_backend; +use crate::app::{DiffPaneView, Focus, ViewMode}; +use crate::git::diff::StatusKind; + +#[test] +fn normal_hint_advertises_close_only_with_terminal_focus() { + let mut app = app_with_fake_backend(); + for focus in [Focus::FileList, Focus::DiffViewer] { + app.focus = focus; + let text = hint_text(&app); + assert!( + !text.contains("w: close pane"), + "{focus:?} legend must not offer close, got: {text}" + ); + } + app.focus = Focus::Terminal; + assert!( + hint_text(&app).contains("w: close pane"), + "terminal legend must offer close" + ); +} + +/// `v` only opens a file when `current_file_view_key` resolves (log view +/// needs a drill-down file selection), so the diff legend must only +/// advertise `v: view file` then — a hint for a no-op key would lie. +#[test] +fn diff_hint_advertises_view_file_only_with_a_file_target() { + // Log view browsing commits (no drill-down): `v` has no target. + let mut app = app_with_fake_backend(); + app.mode = ViewMode::Log; + app.focus = Focus::DiffViewer; + let text = hint_text(&app); + assert!( + !text.contains("v: view file"), + "commit-level log legend must not offer view file, got: {text}" + ); + assert!( + text.contains("s: split"), + "split still acts on the commit diff, got: {text}" + ); + + // Same state zoomed: the fullscreen legend must agree. + app.diff.fullscreen = true; + let text = hint_text(&app); + assert!( + !text.contains("v: view file"), + "zoomed commit-level legend must not offer view file, got: {text}" + ); + + // Drill-down with a file selected: `v` acts, so advertise it. + app.diff.fullscreen = false; + app.log_view + .set_commits(vec![crate::git::diff::CommitEntry::new( + git2::Oid::ZERO_SHA1, + "deadbee".to_string(), + "c".to_string(), + "T".to_string(), + 0, + )]); + app.log_view.drill_down = true; + app.log_view.commit_files = vec![crate::git::diff::ChangedFile::unstaged_only( + "a.rs".to_string(), + StatusKind::Modified, + )]; + assert!( + hint_text(&app).contains("v: view file"), + "drill-down legend must offer view file" + ); + + // Status view with a selected file (the fixture's default list). + let mut status = app_with_fake_backend(); + status.focus = Focus::DiffViewer; + assert!( + hint_text(&status).contains("v: view file"), + "status legend must offer view file for a selected file" + ); +} + +/// Tree mode's right pane is permanently the file view — `v` never +/// toggles there, so the file-view legend must not offer `back to diff`. +#[test] +fn tree_file_view_hint_omits_back_to_diff() { + let mut app = app_with_fake_backend(); + app.mode = ViewMode::Tree; + app.focus = Focus::DiffViewer; + app.diff.view = DiffPaneView::File; + let text = hint_text(&app); + assert!( + !text.contains("v: back to diff"), + "tree file-view legend must not offer back to diff, got: {text}" + ); + + app.diff.fullscreen = true; + let text = hint_text(&app); + assert!( + !text.contains("v: back to diff"), + "zoomed tree file-view legend must not offer back to diff, got: {text}" + ); +} diff --git a/src/ui/tests/hint_legend_tests.rs b/src/ui/tests/hint_legend_tests.rs new file mode 100644 index 0000000..5c6dc3d --- /dev/null +++ b/src/ui/tests/hint_legend_tests.rs @@ -0,0 +1,135 @@ +use super::common::*; +use crate::app::tests::app_with_fake_backend; +use crate::app::Focus; +use crate::runtime::terminal::TerminalFullscreen; +use crate::ui::hint_bar::{HintClick, hint_click_at, render_hint_bar, segment_click}; +use crate::ui::status_view::RepoInput; +use ratatui::{Terminal, backend::TestBackend, layout::Rect, style::{Color, Modifier}}; + +#[test] +fn hint_bar_inverts_only_clickable_key_labels() { + let app = app_with_fake_backend(); + assert_inverted_cells_are_clickable(&app); +} +#[test] +fn armed_prefix_hint_advertises_the_project_keys() { + let mut app = app_with_fake_backend(); + app.arm_prefix(); + + let text = hint_text(&app); + + assert!(text.contains("o: open project"), "got: {text}"); + assert!(text.contains("x: close project"), "got: {text}"); +} + +/// The terminal-focus legend is the one carrying the bare +/// `: leader` segment — its inversion must round-trip to a +/// click like every other clickable label. +#[test] +fn terminal_focus_hint_bar_inverts_only_clickable_key_labels() { + let mut app = app_with_fake_backend(); + app.focus = Focus::Terminal; + assert_inverted_cells_are_clickable(&app); +} + +#[test] +fn bare_prefix_segment_resolves_to_an_arm_click() { + assert_eq!(segment_click(""), Some(HintClick::Arm)); + assert_eq!(segment_click(" "), Some(HintClick::Arm)); +} + +#[test] +fn armed_prefix_hint_bar_inverts_only_clickable_key_labels() { + let mut app = app_with_fake_backend(); + app.arm_prefix(); + assert_inverted_cells_are_clickable(&app); +} +#[test] +fn hint_bar_inverts_nothing_when_mouse_capture_is_disabled() { + let mut app = app_with_fake_backend(); + app.mouse_enabled = false; + let mut terminal = Terminal::new(TestBackend::new(200, 1)).unwrap(); + terminal + .draw(|frame| { + frame.render_widget( + render_hint_bar(&app, plain_chrome(&RepoInput::default()), Color::Yellow), + frame.area(), + ) + }) + .unwrap(); + let buf = terminal.backend().buffer(); + + let inverted = (0..200u16).any(|x| buf[(x, 0)].modifier.contains(Modifier::REVERSED)); + + assert!( + !inverted, + "with the mouse handed back to the terminal, no hint may \ + advertise a click that cannot arrive" + ); +} + +/// The affordance/hit-test agreement holds with the mouse disabled too: +/// nothing renders inverted, so nothing may resolve to a click. +#[test] +fn hint_click_resolves_nothing_when_mouse_capture_is_disabled() { + let mut app = app_with_fake_backend(); + app.focus = Focus::Terminal; + app.mouse_enabled = false; + let screen = Rect::new(0, 0, 200, 3); + for x in 0..200u16 { + assert_eq!( + hint_click_at(&app, plain_chrome(&RepoInput::default()), screen, x, 2), + None, + "x={x} resolves to a click the disabled mouse can never send" + ); + } +} +#[test] +fn swap_hint_advertises_split_view_digits_by_default() { + let mut app = app_with_fake_backend(); + app.begin_swap_target(); + + assert!( + hint_text(&app).contains("3-9,0: swap active pane"), + "split view swap prompt must advertise the 3-9,0 mapping" + ); +} + +#[test] +fn swap_hint_advertises_fullscreen_digits_when_terminal_fills_body() { + let mut app = app_with_fake_backend(); + app.terminal.fullscreen = TerminalFullscreen::Grid; + app.begin_swap_target(); + + let text = hint_text(&app); + assert!( + text.contains("1-8: swap active pane"), + "fullscreen swap prompt must advertise the 1-8 mapping, got: {text}" + ); + assert!( + !text.contains("3-9,0"), + "fullscreen swap prompt must not show the split-view digits, got: {text}" + ); +} +#[test] +fn prefix_hint_switches_pane_digit_legend_by_layout() { + let mut split = app_with_fake_backend(); + split.arm_prefix(); + assert!( + hint_text(&split).contains("1-9: focus/pane"), + "split view prefix hint must advertise focus/pane digits" + ); + + let mut full = app_with_fake_backend(); + full.terminal.fullscreen = TerminalFullscreen::Grid; + full.arm_prefix(); + let text = hint_text(&full); + assert!( + text.contains("1-8: pane"), + "fullscreen prefix hint must advertise the 1-8 pane digits, got: {text}" + ); + assert!( + !text.contains("1-9: focus/pane"), + "fullscreen prefix hint must not show the split-view legend, got: {text}" + ); +} diff --git a/src/ui/tests/hit_test_tests.rs b/src/ui/tests/hit_test_tests.rs new file mode 100644 index 0000000..aad499f --- /dev/null +++ b/src/ui/tests/hit_test_tests.rs @@ -0,0 +1,123 @@ +use crate::app::tests::app_with_files; +use crate::app::Focus; +use crate::config::LayoutConfig; +use crate::runtime::terminal::TerminalFullscreen; +use crate::ui::{pane_at, terminal_content_areas, upper_panel_at}; +use ratatui::layout::Rect; + +#[test] +fn terminal_content_areas_hidden_when_other_pane_is_fullscreen() { + let mut app = app_with_files(vec!["a.rs"]); + app.toggle_diff_fullscreen(); + + let areas = + terminal_content_areas(&app, Rect::new(0, 0, 100, 40), &LayoutConfig::default()); + + assert!(areas.is_empty()); +} + +#[test] +fn terminal_content_areas_uses_body_when_terminal_fullscreen() { + let mut app = app_with_files(vec!["a.rs"]); + app.terminal.panes.push(crate::app::PaneInfo { + id: 1, + title: "shell".to_string(), + }); + let screen = Rect::new(0, 0, 100, 40); + let layout = LayoutConfig::default(); + let areas = terminal_content_areas(&app, screen, &layout); + assert!(!areas.is_empty()); +} + +#[test] +fn pane_at_resolves_the_pane_under_a_cell_and_misses_elsewhere() { + let mut app = app_with_files(vec!["a.rs"]); + app.terminal.panes.push(crate::app::PaneInfo { + id: 1, + title: "shell".to_string(), + }); + app.terminal.panes.push(crate::app::PaneInfo { + id: 2, + title: "shell".to_string(), + }); + let screen = Rect::new(0, 0, 100, 40); + let layout = LayoutConfig::default(); + let areas = terminal_content_areas(&app, screen, &layout); + assert_eq!(areas.len(), 2); + + // A cell inside each pane's content rect resolves to that pane. + for (id, rect) in &areas { + let hit = pane_at(&app, screen, &layout, rect.x, rect.y); + assert_eq!(hit, Some((*id, *rect))); + } + // The project tab row owns row 0, and the upper panels the rows just + // below it — neither is a pane. + assert_eq!(pane_at(&app, screen, &layout, 0, 0), None); + assert_eq!(pane_at(&app, screen, &layout, 0, 1), None); + // ...and so do the two chrome rows at the bottom. + assert_eq!(pane_at(&app, screen, &layout, 0, 39), None); +} + +#[test] +fn upper_panel_at_resolves_list_and_diff_by_the_layout_split() { + let app = app_with_files(vec!["a.rs"]); + let screen = Rect::new(0, 0, 100, 40); + let layout = LayoutConfig::default(); + + // Row 0 is the project tab row, so the body starts at row 1. The + // default file_list_pct (25) puts x=0 in the list and x=60 in the diff. + assert_eq!(upper_panel_at(&app, screen, &layout, 0, 0), None); + assert_eq!( + upper_panel_at(&app, screen, &layout, 0, 1), + Some(Focus::FileList) + ); + assert_eq!( + upper_panel_at(&app, screen, &layout, 60, 1), + Some(Focus::DiffViewer) + ); + // Below the upper panels: the terminal panel, then the two chrome + // rows (notice, hint) — none of them is an upper panel. + assert_eq!(upper_panel_at(&app, screen, &layout, 0, 37), None); + assert_eq!(upper_panel_at(&app, screen, &layout, 0, 38), None); + assert_eq!(upper_panel_at(&app, screen, &layout, 0, 39), None); +} + +#[test] +fn upper_panel_at_misses_in_every_fullscreen_state() { + // The implementation guards three distinct flags; each must miss on + // its own, at a cell that hits the file list in the normal split. + let screen = Rect::new(0, 0, 100, 40); + let layout = LayoutConfig::default(); + + let mut diff_full = app_with_files(vec!["a.rs"]); + diff_full.toggle_diff_fullscreen(); + assert_eq!(upper_panel_at(&diff_full, screen, &layout, 0, 1), None); + + let mut list_full = app_with_files(vec!["a.rs"]); + list_full.list_fullscreen = true; + assert_eq!(upper_panel_at(&list_full, screen, &layout, 0, 1), None); + + let mut term_full = app_with_files(vec!["a.rs"]); + term_full.terminal.fullscreen = TerminalFullscreen::Grid; + assert_eq!(upper_panel_at(&term_full, screen, &layout, 0, 1), None); +} + +#[test] +fn pane_at_misses_when_another_panel_is_fullscreen() { + let mut app = app_with_files(vec!["a.rs"]); + app.terminal.panes.push(crate::app::PaneInfo { + id: 1, + title: "shell".to_string(), + }); + app.toggle_diff_fullscreen(); + + let hit = pane_at( + &app, + Rect::new(0, 0, 100, 40), + &LayoutConfig::default(), + 50, + 30, + ); + + assert_eq!(hit, None); +} diff --git a/src/ui/tests/mod.rs b/src/ui/tests/mod.rs new file mode 100644 index 0000000..54bee0d --- /dev/null +++ b/src/ui/tests/mod.rs @@ -0,0 +1,8 @@ +mod common; +mod chrome_tests; +mod hint_armed_tests; +mod hint_click_tests; +mod hint_diff_tests; +mod hint_legend_tests; +mod hit_test_tests; +mod notice_tests; diff --git a/src/ui/tests/notice_tests.rs b/src/ui/tests/notice_tests.rs new file mode 100644 index 0000000..3bf606c --- /dev/null +++ b/src/ui/tests/notice_tests.rs @@ -0,0 +1,81 @@ +use super::common::*; +use crate::app::App; +use crate::app::NoticeKind; +use crate::app::tests::{app_with_fake_backend, app_with_files}; + +#[test] +fn repo_input_reports_a_rejected_path_on_the_notice_row() { + let mut ws = test_workspace(); + ws.start_repo_input(); + ws.repo_input.buf = "/definitely/not/here".to_string(); + ws.confirm_repo_input(); + + assert!( + ws.repo_input.active, + "a rejected path must leave the dialog open for correction" + ); + // With a project open the rejection lands on that project's notice row, + // directly above the input still holding the text to correct. + let notice = notice_text(ws.active().unwrap()); + assert!( + notice.contains("no such directory"), + "the notice row must say why the confirm was rejected, got: {notice}" + ); + let repo_input = ws.repo_input.clone(); + let hint = hint_text_with(ws.active().unwrap(), plain_chrome(&repo_input)); + assert!( + hint.contains("/definitely/not/here"), + "the rejected text must stay in the input, got: {hint}" + ); +} + +#[test] +fn repo_input_notice_clears_once_the_path_is_edited() { + let mut ws = test_workspace(); + ws.start_repo_input(); + ws.repo_input.buf = "/definitely/not/here".to_string(); + ws.confirm_repo_input(); + ws.repo_input_pop(); + + let notice = notice_text(ws.active().unwrap()); + assert!( + !notice.contains("no such directory"), + "editing the path must clear the stale verdict, got: {notice}" + ); +} + +/// The notice row is the one place every kind reports, and no overlay may +/// shadow it — the hint bar's own early-returns are what made a notice +/// invisible before it moved off that row. +#[test] +fn notice_row_shows_notices_through_every_overlay() { + for setup in [ + (|app: &mut App| app.arm_prefix()) as fn(&mut App), + |app: &mut App| app.begin_swap_target(), + ] { + let mut app = app_with_fake_backend(); + setup(&mut app); + app.raise_notice(NoticeKind::Git, "not a repo"); + let text = notice_text(&app); + assert!( + text.contains("git error: not a repo"), + "an open overlay must not shadow the notice row, got: {text}" + ); + } +} + +/// With nothing raised the row is the repo/branch line, and it comes back +/// intact after a notice is cleared. +#[test] +fn notice_row_falls_back_to_repo_identity() { + let mut app = app_with_files(vec![]); + app.repo_path = "/tmp/somewhere".to_string(); + let before = notice_text(&app); + assert!(before.contains("/tmp/somewhere"), "got: {before}"); + + app.raise_notice(NoticeKind::Tree, "boom"); + assert!(!notice_text(&app).contains("/tmp/somewhere")); + + app.clear_notice(NoticeKind::Tree); + assert_eq!(notice_text(&app), before); +} diff --git a/src/ui/tree_view.rs b/src/ui/tree_view/mod.rs similarity index 59% rename from src/ui/tree_view.rs rename to src/ui/tree_view/mod.rs index d0ae8a6..b8515ef 100644 --- a/src/ui/tree_view.rs +++ b/src/ui/tree_view/mod.rs @@ -238,209 +238,4 @@ pub fn is_safe_rel_path(rel: &str) -> bool { } #[cfg(test)] -mod tests { - use super::*; - - fn entry(name: &str, is_dir: bool) -> TreeEntry { - TreeEntry { - name: name.to_string(), - is_dir, - } - } - - /// A tree with `src/` (dir) and `README.md` (file) at the root, and - /// `main.rs` inside `src/`. Nothing expanded yet. - fn sample() -> TreeView { - let mut tv = TreeView::default(); - tv.cache.insert( - "".to_string(), - vec![entry("src", true), entry("README.md", false)], - ); - tv.cache - .insert("src".to_string(), vec![entry("main.rs", false)]); - tv - } - - #[test] - fn visible_rows_shows_only_top_level_when_nothing_expanded() { - let tv = sample(); - let rows = tv.visible_rows(); - assert_eq!(rows.len(), 2); - assert_eq!(rows[0].path, "src"); - assert!(rows[0].is_dir); - assert!(!rows[0].expanded); - assert_eq!(rows[1].path, "README.md"); - } - - #[test] - fn visible_rows_includes_children_of_expanded_dir() { - let mut tv = sample(); - tv.expanded.insert("src".to_string()); - let rows = tv.visible_rows(); - - assert_eq!(rows.len(), 3); - assert_eq!(rows[0].path, "src"); - assert!(rows[0].expanded); - assert_eq!(rows[1].path, "src/main.rs"); - assert_eq!(rows[1].depth, 1); - assert_eq!(rows[2].path, "README.md"); - } - - #[test] - fn visible_rows_skips_expanded_dir_without_cached_children() { - // `expanded` references a dir whose children were never read: it should - // simply contribute no child rows rather than panic. - let mut tv = sample(); - tv.expanded.insert("src".to_string()); - tv.cache.remove("src"); - - let rows = tv.visible_rows(); - assert_eq!(rows.len(), 2); - // The directory row itself still renders as expanded. - assert!(rows[0].expanded); - } - - #[test] - fn clamp_selection_pins_cursor_inside_row_count() { - let mut tv = sample(); - tv.selected = 9; - tv.clamp_selection(2); - assert_eq!(tv.selected, 1); - tv.clamp_selection(0); - assert_eq!(tv.selected, 0); - } - - #[test] - fn selected_path_follows_visible_rows() { - let mut tv = sample(); - tv.expanded.insert("src".to_string()); - tv.selected = 1; - assert_eq!(tv.selected_path().as_deref(), Some("src/main.rs")); - } - - #[test] - fn parent_path_returns_none_for_top_level() { - assert_eq!(parent_path("README.md"), None); - assert_eq!(parent_path("src"), None); - assert_eq!(parent_path("src/ui/mod.rs"), Some("src/ui")); - assert_eq!(parent_path("src/main.rs"), Some("src")); - } - - /// Lowercased-basename index entry from a repo-relative path. - fn idx(path: &str) -> TreeIndexEntry { - let name = path.rsplit('/').next().unwrap_or(path); - TreeIndexEntry { - path: path.to_string(), - name_lower: name.to_lowercase(), - } - } - - /// A deeper tree: `src/ui/mod.rs`, `src/main.rs`, `README.md`. Cache is - /// fully populated (as `build_tree_index` would leave it) and an index is - /// seeded so the filter can be exercised without a filesystem. - fn indexed_sample() -> TreeView { - let mut tv = TreeView::default(); - tv.cache.insert( - "".to_string(), - vec![entry("src", true), entry("README.md", false)], - ); - tv.cache.insert( - "src".to_string(), - vec![entry("ui", true), entry("main.rs", false)], - ); - tv.cache - .insert("src/ui".to_string(), vec![entry("mod.rs", false)]); - tv.index = vec![ - idx("src"), - idx("README.md"), - idx("src/ui"), - idx("src/main.rs"), - idx("src/ui/mod.rs"), - ]; - tv - } - - #[test] - fn recompute_filter_collects_matches_and_their_ancestors() { - let mut tv = indexed_sample(); - tv.search_query.set("main"); - tv.recompute_filter(); - assert_eq!(tv.match_count, 1); - // The match plus the `src` ancestor; nothing else. - let mut shown: Vec<&str> = tv.show_set.iter().map(String::as_str).collect(); - shown.sort_unstable(); - assert_eq!(shown, vec!["src", "src/main.rs"]); - } - - #[test] - fn filtered_visible_rows_show_match_with_ancestor_chain() { - let mut tv = indexed_sample(); - tv.search_active = true; - tv.search_query.set("mod"); - tv.recompute_filter(); - - let rows = tv.visible_rows(); - // The whole chain src -> src/ui -> src/ui/mod.rs, each at increasing - // depth; README.md and src/main.rs are filtered out. - assert_eq!(rows.len(), 3); - assert_eq!(rows[0].path, "src"); - assert_eq!(rows[0].depth, 0); - assert!(rows[0].expanded); - assert_eq!(rows[1].path, "src/ui"); - assert_eq!(rows[1].depth, 1); - assert_eq!(rows[2].path, "src/ui/mod.rs"); - assert_eq!(rows[2].depth, 2); - assert!(!rows[2].is_dir); - } - - #[test] - fn filter_is_case_insensitive() { - let mut tv = indexed_sample(); - tv.search_active = true; - tv.search_query.set("README"); - tv.recompute_filter(); - let rows = tv.visible_rows(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].path, "README.md"); - } - - #[test] - fn empty_query_in_search_mode_keeps_normal_view() { - let mut tv = indexed_sample(); - tv.search_active = true; - // No query typed yet: the tree must not explode into a full expansion. - tv.recompute_filter(); - assert!(!tv.search_filtering()); - let rows = tv.visible_rows(); - // Normal view with nothing expanded: only the two top-level entries. - assert_eq!(rows.len(), 2); - assert_eq!(rows[0].path, "src"); - assert_eq!(rows[1].path, "README.md"); - } - - #[test] - fn cancel_search_clears_all_transient_state() { - let mut tv = indexed_sample(); - tv.search_active = true; - tv.search_query.set("mod"); - tv.recompute_filter(); - tv.cancel_search(); - assert!(!tv.search_active); - assert!(tv.search_query.is_empty()); - assert!(tv.index.is_empty()); - assert!(tv.show_set.is_empty()); - assert_eq!(tv.match_count, 0); - } - - #[test] - fn is_safe_rel_path_accepts_repo_internal_and_rejects_escapes() { - assert!(is_safe_rel_path("src")); - assert!(is_safe_rel_path("src/ui/mod.rs")); - // Escapes / absolute / empty are rejected. - assert!(!is_safe_rel_path("")); - assert!(!is_safe_rel_path("..")); - assert!(!is_safe_rel_path("../etc")); - assert!(!is_safe_rel_path("src/../../etc")); - assert!(!is_safe_rel_path("/etc/passwd")); - } -} +mod tests; diff --git a/src/ui/tree_view/tests.rs b/src/ui/tree_view/tests.rs new file mode 100644 index 0000000..2a0b684 --- /dev/null +++ b/src/ui/tree_view/tests.rs @@ -0,0 +1,204 @@ + use super::*; + + fn entry(name: &str, is_dir: bool) -> TreeEntry { + TreeEntry { + name: name.to_string(), + is_dir, + } + } + + /// A tree with `src/` (dir) and `README.md` (file) at the root, and + /// `main.rs` inside `src/`. Nothing expanded yet. + fn sample() -> TreeView { + let mut tv = TreeView::default(); + tv.cache.insert( + "".to_string(), + vec![entry("src", true), entry("README.md", false)], + ); + tv.cache + .insert("src".to_string(), vec![entry("main.rs", false)]); + tv + } + + #[test] + fn visible_rows_shows_only_top_level_when_nothing_expanded() { + let tv = sample(); + let rows = tv.visible_rows(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].path, "src"); + assert!(rows[0].is_dir); + assert!(!rows[0].expanded); + assert_eq!(rows[1].path, "README.md"); + } + + #[test] + fn visible_rows_includes_children_of_expanded_dir() { + let mut tv = sample(); + tv.expanded.insert("src".to_string()); + let rows = tv.visible_rows(); + + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].path, "src"); + assert!(rows[0].expanded); + assert_eq!(rows[1].path, "src/main.rs"); + assert_eq!(rows[1].depth, 1); + assert_eq!(rows[2].path, "README.md"); + } + + #[test] + fn visible_rows_skips_expanded_dir_without_cached_children() { + // `expanded` references a dir whose children were never read: it should + // simply contribute no child rows rather than panic. + let mut tv = sample(); + tv.expanded.insert("src".to_string()); + tv.cache.remove("src"); + + let rows = tv.visible_rows(); + assert_eq!(rows.len(), 2); + // The directory row itself still renders as expanded. + assert!(rows[0].expanded); + } + + #[test] + fn clamp_selection_pins_cursor_inside_row_count() { + let mut tv = sample(); + tv.selected = 9; + tv.clamp_selection(2); + assert_eq!(tv.selected, 1); + tv.clamp_selection(0); + assert_eq!(tv.selected, 0); + } + + #[test] + fn selected_path_follows_visible_rows() { + let mut tv = sample(); + tv.expanded.insert("src".to_string()); + tv.selected = 1; + assert_eq!(tv.selected_path().as_deref(), Some("src/main.rs")); + } + + #[test] + fn parent_path_returns_none_for_top_level() { + assert_eq!(parent_path("README.md"), None); + assert_eq!(parent_path("src"), None); + assert_eq!(parent_path("src/ui/mod.rs"), Some("src/ui")); + assert_eq!(parent_path("src/main.rs"), Some("src")); + } + + /// Lowercased-basename index entry from a repo-relative path. + fn idx(path: &str) -> TreeIndexEntry { + let name = path.rsplit('/').next().unwrap_or(path); + TreeIndexEntry { + path: path.to_string(), + name_lower: name.to_lowercase(), + } + } + + /// A deeper tree: `src/ui/mod.rs`, `src/main.rs`, `README.md`. Cache is + /// fully populated (as `build_tree_index` would leave it) and an index is + /// seeded so the filter can be exercised without a filesystem. + fn indexed_sample() -> TreeView { + let mut tv = TreeView::default(); + tv.cache.insert( + "".to_string(), + vec![entry("src", true), entry("README.md", false)], + ); + tv.cache.insert( + "src".to_string(), + vec![entry("ui", true), entry("main.rs", false)], + ); + tv.cache + .insert("src/ui".to_string(), vec![entry("mod.rs", false)]); + tv.index = vec![ + idx("src"), + idx("README.md"), + idx("src/ui"), + idx("src/main.rs"), + idx("src/ui/mod.rs"), + ]; + tv + } + + #[test] + fn recompute_filter_collects_matches_and_their_ancestors() { + let mut tv = indexed_sample(); + tv.search_query.set("main"); + tv.recompute_filter(); + assert_eq!(tv.match_count, 1); + // The match plus the `src` ancestor; nothing else. + let mut shown: Vec<&str> = tv.show_set.iter().map(String::as_str).collect(); + shown.sort_unstable(); + assert_eq!(shown, vec!["src", "src/main.rs"]); + } + + #[test] + fn filtered_visible_rows_show_match_with_ancestor_chain() { + let mut tv = indexed_sample(); + tv.search_active = true; + tv.search_query.set("mod"); + tv.recompute_filter(); + + let rows = tv.visible_rows(); + // The whole chain src -> src/ui -> src/ui/mod.rs, each at increasing + // depth; README.md and src/main.rs are filtered out. + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].path, "src"); + assert_eq!(rows[0].depth, 0); + assert!(rows[0].expanded); + assert_eq!(rows[1].path, "src/ui"); + assert_eq!(rows[1].depth, 1); + assert_eq!(rows[2].path, "src/ui/mod.rs"); + assert_eq!(rows[2].depth, 2); + assert!(!rows[2].is_dir); + } + + #[test] + fn filter_is_case_insensitive() { + let mut tv = indexed_sample(); + tv.search_active = true; + tv.search_query.set("README"); + tv.recompute_filter(); + let rows = tv.visible_rows(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].path, "README.md"); + } + + #[test] + fn empty_query_in_search_mode_keeps_normal_view() { + let mut tv = indexed_sample(); + tv.search_active = true; + // No query typed yet: the tree must not explode into a full expansion. + tv.recompute_filter(); + assert!(!tv.search_filtering()); + let rows = tv.visible_rows(); + // Normal view with nothing expanded: only the two top-level entries. + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].path, "src"); + assert_eq!(rows[1].path, "README.md"); + } + + #[test] + fn cancel_search_clears_all_transient_state() { + let mut tv = indexed_sample(); + tv.search_active = true; + tv.search_query.set("mod"); + tv.recompute_filter(); + tv.cancel_search(); + assert!(!tv.search_active); + assert!(tv.search_query.is_empty()); + assert!(tv.index.is_empty()); + assert!(tv.show_set.is_empty()); + assert_eq!(tv.match_count, 0); + } + + #[test] + fn is_safe_rel_path_accepts_repo_internal_and_rejects_escapes() { + assert!(is_safe_rel_path("src")); + assert!(is_safe_rel_path("src/ui/mod.rs")); + // Escapes / absolute / empty are rejected. + assert!(!is_safe_rel_path("")); + assert!(!is_safe_rel_path("..")); + assert!(!is_safe_rel_path("../etc")); + assert!(!is_safe_rel_path("src/../../etc")); + assert!(!is_safe_rel_path("/etc/passwd")); + } diff --git a/src/web/common/http.rs b/src/web/common/http.rs index fd06d39..f3fe2fa 100644 --- a/src/web/common/http.rs +++ b/src/web/common/http.rs @@ -201,120 +201,5 @@ pub fn redirect(location: &str, extra_headers: &[(&str, &str)]) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - - fn parse(text: &str) -> RequestHead { - parse_request_head(text).unwrap() - } - - #[test] - fn parses_method_and_strips_query_from_path() { - let head = parse("GET /login?token=abc&x=1 HTTP/1.1\r\nHost: localhost\r\n\r\n"); - assert_eq!(head.method, "GET"); - assert_eq!( - head.path, "/login", - "the query string must be stripped for routing" - ); - } - - #[test] - fn query_params_are_captured_and_decoded() { - let head = parse("GET /api/diff?repo=r1&path=src%2Fmain.rs HTTP/1.1\r\n\r\n"); - assert_eq!(head.path, "/api/diff"); - assert_eq!(head.query, "repo=r1&path=src%2Fmain.rs"); - assert_eq!(head.query_param("repo").as_deref(), Some("r1")); - assert_eq!(head.query_param("path").as_deref(), Some("src/main.rs")); - assert_eq!(head.query_param("missing"), None); - } - - #[test] - fn query_param_is_absent_without_a_query_string() { - let head = parse("GET /api/repos HTTP/1.1\r\n\r\n"); - assert_eq!(head.query, ""); - assert_eq!(head.query_param("repo"), None); - } - - #[test] - fn query_param_takes_the_first_of_a_repeated_name() { - // A checked-then-reused parameter must not be overridable by appending - // a second copy. - let head = parse("GET /api/file?path=ok.txt&path=..%2F..%2Fetc%2Fpasswd HTTP/1.1\r\n\r\n"); - assert_eq!(head.query_param("path").as_deref(), Some("ok.txt")); - } - - #[test] - fn query_param_decodes_only_once() { - // `%252e` is a literal `%2e` after one decode. Decoding twice would - // turn it into `.` and let `..` past a traversal check. - let head = parse("GET /api/file?path=%252e%252e%2Fsecret HTTP/1.1\r\n\r\n"); - assert_eq!(head.query_param("path").as_deref(), Some("%2e%2e/secret")); - } - - #[test] - fn header_lookup_is_case_insensitive() { - let head = parse("GET / HTTP/1.1\r\nContent-Length: 5\r\nX-Foo: Bar\r\n\r\n"); - assert_eq!(head.header("content-length"), Some("5")); - assert_eq!(head.header("X-FOO"), Some("Bar")); - assert_eq!(head.content_length, 5); - } - - #[test] - fn parses_cookies() { - let head = parse("GET / HTTP/1.1\r\nCookie: a=1; nightcrow_session=tok123; b=2\r\n\r\n"); - assert_eq!(head.cookie("nightcrow_session"), Some("tok123")); - assert_eq!(head.cookie("a"), Some("1")); - assert_eq!(head.cookie("missing"), None); - } - - #[test] - fn detects_websocket_upgrade() { - let ws = parse("GET /ws HTTP/1.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n"); - assert!(ws.is_websocket_upgrade()); - let plain = parse("GET / HTTP/1.1\r\nConnection: keep-alive\r\n\r\n"); - assert!(!plain.is_websocket_upgrade()); - } - - #[test] - fn detects_websocket_upgrade_with_combined_connection_header() { - // Browsers often send "Connection: keep-alive, Upgrade". - let ws = parse( - "GET /ws HTTP/1.1\r\nUpgrade: WebSocket\r\nConnection: keep-alive, Upgrade\r\n\r\n", - ); - assert!(ws.is_websocket_upgrade()); - } - - #[test] - fn rejects_malformed_request_line() { - assert!(parse_request_head("garbage\r\n\r\n").is_err()); - assert!(parse_request_head("").is_err()); - } - - #[test] - fn parses_url_encoded_form_body() { - let fields = parse_form("password=p%40ss+word&remember=on"); - assert_eq!(form_field(&fields, "password"), Some("p@ss word")); - assert_eq!(form_field(&fields, "remember"), Some("on")); - assert_eq!(form_field(&fields, "missing"), None); - } - - #[test] - fn response_has_content_length_and_no_store() { - let bytes = response("200 OK", "text/plain", &[("X-A", "b")], b"hello"); - let text = String::from_utf8_lossy(&bytes); - assert!(text.starts_with("HTTP/1.1 200 OK\r\n")); - assert!(text.contains("Content-Length: 5\r\n")); - assert!(text.contains("Cache-Control: no-store\r\n")); - assert!(text.contains("X-A: b\r\n")); - assert!(text.ends_with("\r\n\r\nhello")); - } - - #[test] - fn redirect_carries_location_and_extra_headers() { - let bytes = redirect("/", &[("Set-Cookie", "s=1")]); - let text = String::from_utf8_lossy(&bytes); - assert!(text.starts_with("HTTP/1.1 303 See Other\r\n")); - assert!(text.contains("Location: /\r\n")); - assert!(text.contains("Set-Cookie: s=1\r\n")); - } -} +#[path = "http_tests.rs"] +mod tests; diff --git a/src/web/common/http_tests.rs b/src/web/common/http_tests.rs new file mode 100644 index 0000000..5705a71 --- /dev/null +++ b/src/web/common/http_tests.rs @@ -0,0 +1,115 @@ +use super::*; + +fn parse(text: &str) -> RequestHead { + parse_request_head(text).unwrap() +} + +#[test] +fn parses_method_and_strips_query_from_path() { + let head = parse("GET /login?token=abc&x=1 HTTP/1.1\r\nHost: localhost\r\n\r\n"); + assert_eq!(head.method, "GET"); + assert_eq!( + head.path, "/login", + "the query string must be stripped for routing" + ); +} + +#[test] +fn query_params_are_captured_and_decoded() { + let head = parse("GET /api/diff?repo=r1&path=src%2Fmain.rs HTTP/1.1\r\n\r\n"); + assert_eq!(head.path, "/api/diff"); + assert_eq!(head.query, "repo=r1&path=src%2Fmain.rs"); + assert_eq!(head.query_param("repo").as_deref(), Some("r1")); + assert_eq!(head.query_param("path").as_deref(), Some("src/main.rs")); + assert_eq!(head.query_param("missing"), None); +} + +#[test] +fn query_param_is_absent_without_a_query_string() { + let head = parse("GET /api/repos HTTP/1.1\r\n\r\n"); + assert_eq!(head.query, ""); + assert_eq!(head.query_param("repo"), None); +} + +#[test] +fn query_param_takes_the_first_of_a_repeated_name() { + // A checked-then-reused parameter must not be overridable by appending + // a second copy. + let head = parse("GET /api/file?path=ok.txt&path=..%2F..%2Fetc%2Fpasswd HTTP/1.1\r\n\r\n"); + assert_eq!(head.query_param("path").as_deref(), Some("ok.txt")); +} + +#[test] +fn query_param_decodes_only_once() { + // `%252e` is a literal `%2e` after one decode. Decoding twice would + // turn it into `.` and let `..` past a traversal check. + let head = parse("GET /api/file?path=%252e%252e%2Fsecret HTTP/1.1\r\n\r\n"); + assert_eq!(head.query_param("path").as_deref(), Some("%2e%2e/secret")); +} + +#[test] +fn header_lookup_is_case_insensitive() { + let head = parse("GET / HTTP/1.1\r\nContent-Length: 5\r\nX-Foo: Bar\r\n\r\n"); + assert_eq!(head.header("content-length"), Some("5")); + assert_eq!(head.header("X-FOO"), Some("Bar")); + assert_eq!(head.content_length, 5); +} + +#[test] +fn parses_cookies() { + let head = parse("GET / HTTP/1.1\r\nCookie: a=1; nightcrow_session=tok123; b=2\r\n\r\n"); + assert_eq!(head.cookie("nightcrow_session"), Some("tok123")); + assert_eq!(head.cookie("a"), Some("1")); + assert_eq!(head.cookie("missing"), None); +} + +#[test] +fn detects_websocket_upgrade() { + let ws = parse("GET /ws HTTP/1.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n"); + assert!(ws.is_websocket_upgrade()); + let plain = parse("GET / HTTP/1.1\r\nConnection: keep-alive\r\n\r\n"); + assert!(!plain.is_websocket_upgrade()); +} + +#[test] +fn detects_websocket_upgrade_with_combined_connection_header() { + // Browsers often send "Connection: keep-alive, Upgrade". + let ws = parse( + "GET /ws HTTP/1.1\r\nUpgrade: WebSocket\r\nConnection: keep-alive, Upgrade\r\n\r\n", + ); + assert!(ws.is_websocket_upgrade()); +} + +#[test] +fn rejects_malformed_request_line() { + assert!(parse_request_head("garbage\r\n\r\n").is_err()); + assert!(parse_request_head("").is_err()); +} + +#[test] +fn parses_url_encoded_form_body() { + let fields = parse_form("password=p%40ss+word&remember=on"); + assert_eq!(form_field(&fields, "password"), Some("p@ss word")); + assert_eq!(form_field(&fields, "remember"), Some("on")); + assert_eq!(form_field(&fields, "missing"), None); +} + +#[test] +fn response_has_content_length_and_no_store() { + let bytes = response("200 OK", "text/plain", &[("X-A", "b")], b"hello"); + let text = String::from_utf8_lossy(&bytes); + assert!(text.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(text.contains("Content-Length: 5\r\n")); + assert!(text.contains("Cache-Control: no-store\r\n")); + assert!(text.contains("X-A: b\r\n")); + assert!(text.ends_with("\r\n\r\nhello")); +} + +#[test] +fn redirect_carries_location_and_extra_headers() { + let bytes = redirect("/", &[("Set-Cookie", "s=1")]); + let text = String::from_utf8_lossy(&bytes); + assert!(text.starts_with("HTTP/1.1 303 See Other\r\n")); + assert!(text.contains("Location: /\r\n")); + assert!(text.contains("Set-Cookie: s=1\r\n")); +} \ No newline at end of file diff --git a/src/web/protocol/mod.rs b/src/web/protocol/mod.rs index 6ab4563..22eaea4 100644 --- a/src/web/protocol/mod.rs +++ b/src/web/protocol/mod.rs @@ -12,7 +12,6 @@ //! through the exact same `handle_key`/`handle_mouse`/`handle_paste` routing as //! local input — a web action can never diverge from the equivalent keypress. -use anyhow::Result; use crossterm::event::{KeyEvent, MouseEvent}; use ratatui::backend::{Backend, CrosstermBackend}; use ratatui::buffer::Buffer; @@ -86,7 +85,9 @@ pub fn encode_cursor(cursor: Option) -> Vec { mod decode; -pub use decode::{MAX_INPUT_MESSAGE_BYTES, decode_input, ensure_input_size}; +#[cfg(test)] +pub use decode::MAX_INPUT_MESSAGE_BYTES; +pub use decode::{decode_input, ensure_input_size}; #[cfg(test)] mod tests; diff --git a/src/web/viewer/catalog/catalog_ids.rs b/src/web/viewer/catalog/catalog_ids.rs new file mode 100644 index 0000000..11a49b3 --- /dev/null +++ b/src/web/viewer/catalog/catalog_ids.rs @@ -0,0 +1,56 @@ +use crate::web::viewer::dto::RepoDto; +use crate::web::viewer::runtime::RepoRuntime; +use crate::web::viewer::terminal::TerminalHub; +use std::collections::HashMap; +use std::sync::Arc; + +/// One served repository: its identity, and the runtime streaming its status. +pub struct RepoEntry { + pub id: String, + /// Absolute worktree path. Server-side only — never serialized. + pub path: String, + pub name: String, + pub display_path: String, + pub runtime: Arc, + /// This repository's terminals. Independent of the TUI's panes — see + /// [`crate::web::viewer::terminal`]. + pub terminals: Arc, +} + +impl RepoEntry { + pub fn to_dto(&self) -> RepoDto { + RepoDto { + id: self.id.clone(), + name: self.name.clone(), + display_path: self.display_path.clone(), + } + } +} + +/// Hands out ids and never reuses one, so a path that leaves and comes back +/// keeps the identity a client already knows. +#[derive(Default)] +pub(super) struct IdAssigner { + next: u64, + by_path: HashMap, +} + +impl IdAssigner { + pub(super) fn id_for(&mut self, path: &str) -> String { + if let Some(existing) = self.by_path.get(path) { + return existing.clone(); + } + self.next += 1; + let id = format!("r{}", self.next); + self.by_path.insert(path.to_string(), id.clone()); + id + } +} + +/// Result of [`crate::web::viewer::catalog::Catalog::add_path`]. +pub enum AddOutcome { + /// The repository is now served — newly added, or already present. + Added(RepoDto), + /// The served set is already at its ceiling; nothing was added. + TooMany, +} \ No newline at end of file diff --git a/src/web/viewer/catalog/catalog_tests.rs b/src/web/viewer/catalog/catalog_tests.rs new file mode 100644 index 0000000..f1f524e --- /dev/null +++ b/src/web/viewer/catalog/catalog_tests.rs @@ -0,0 +1,224 @@ +use super::*; +use crate::test_util::make_repo; + +#[test] +fn ids_are_stable_across_catalog_updates() { + let (dir_a, a) = make_repo(); + let (dir_b, b) = make_repo(); + let catalog = Catalog::new(); + + catalog.set_paths(std::slice::from_ref(&a)); + let first = catalog.list()[0].id.clone(); + + // Opening a second repository must not renumber the first. + catalog.set_paths(&[a.clone(), b.clone()]); + let after = catalog.list(); + + assert_eq!(after[0].id, first); + assert_ne!(after[1].id, first); + catalog.shutdown(); + drop((dir_a, dir_b)); +} + +#[test] +fn a_path_that_leaves_and_returns_keeps_its_id() { + let (dir_a, a) = make_repo(); + let (dir_b, b) = make_repo(); + let catalog = Catalog::new(); + + catalog.set_paths(&[a.clone(), b.clone()]); + let a_id = catalog.list()[0].id.clone(); + catalog.set_paths(std::slice::from_ref(&b)); + catalog.set_paths(&[b.clone(), a.clone()]); + + let reopened = catalog.get(&a_id).expect("the id must still resolve"); + assert_eq!(reopened.path, a); + catalog.shutdown(); + drop((dir_a, dir_b)); +} + +#[test] +fn add_path_is_idempotent_and_respects_the_cap() { + let (dir_a, a) = make_repo(); + let (dir_b, b) = make_repo(); + let (dir_c, c) = make_repo(); + let catalog = Catalog::new(); + + let id_a = match catalog.add_path(a.clone(), 2) { + AddOutcome::Added(dto) => dto.id, + AddOutcome::TooMany => panic!("the first add must succeed"), + }; + assert_eq!(catalog.len(), 1); + + // Re-adding an open path is a no-op that returns the same identity. + match catalog.add_path(a.clone(), 2) { + AddOutcome::Added(dto) => assert_eq!(dto.id, id_a), + AddOutcome::TooMany => panic!("re-adding an open repo must not be refused"), + } + assert_eq!(catalog.len(), 1); + + // A second distinct repo fits under the cap of two. + assert!(matches!(catalog.add_path(b, 2), AddOutcome::Added(_))); + assert_eq!(catalog.len(), 2); + + // A third exceeds the cap and is refused without disturbing the set. + assert!(matches!(catalog.add_path(c, 2), AddOutcome::TooMany)); + assert_eq!(catalog.len(), 2); + + catalog.shutdown(); + drop((dir_a, dir_b, dir_c)); +} + +#[test] +fn a_browser_added_repo_survives_a_base_update() { + let (dir_a, a) = make_repo(); + let (dir_b, b) = make_repo(); + let catalog = Catalog::new(); + + catalog.set_paths(std::slice::from_ref(&a)); + let added = match catalog.add_path(b, 10) { + AddOutcome::Added(dto) => dto.id, + AddOutcome::TooMany => panic!("the add must succeed"), + }; + + // The TUI opening or closing a tab re-runs set_paths with a new base; + // a repository opened from the browser must not be dropped by it. + catalog.set_paths(std::slice::from_ref(&a)); + assert!( + catalog.get(&added).is_some(), + "a browser-added repo must survive a base update" + ); + + catalog.shutdown(); + drop((dir_a, dir_b)); +} + +#[test] +fn remove_path_closes_and_stays_closed_until_reopened() { + let (dir_a, a) = make_repo(); + let (dir_b, b) = make_repo(); + let catalog = Catalog::new(); + catalog.set_paths(&[a.clone(), b.clone()]); + assert_eq!(catalog.len(), 2); + + catalog.remove_path(&a); + assert_eq!(catalog.len(), 1); + + // A base re-sync (a TUI tab change) must not resurrect a closed repo. + catalog.set_paths(&[a.clone(), b.clone()]); + assert_eq!(catalog.len(), 1, "a closed repo must stay closed"); + + // Re-opening it from the browser clears the close and brings it back. + assert!(matches!(catalog.add_path(a.clone(), 10), AddOutcome::Added(_))); + assert_eq!(catalog.len(), 2); + + catalog.shutdown(); + drop((dir_a, dir_b)); +} + +#[test] +fn an_unchanged_path_keeps_its_runtime_and_subscribers() { + let (dir_a, a) = make_repo(); + let (dir_b, b) = make_repo(); + let catalog = Catalog::new(); + catalog.set_paths(std::slice::from_ref(&a)); + + let entry = catalog.get(&catalog.list()[0].id).unwrap(); + let _subscription = entry.runtime.subscribe(); + assert_eq!(entry.runtime.subscriber_count(), 1); + + // Adding an unrelated repository must not restart the existing one. + catalog.set_paths(&[a.clone(), b.clone()]); + + let same = catalog.get(&entry.id).unwrap(); + assert!( + Arc::ptr_eq(&same.runtime, &entry.runtime), + "an unchanged path must not get a fresh runtime" + ); + assert_eq!( + same.runtime.subscriber_count(), + 1, + "a catalog update must not drop live SSE clients" + ); + catalog.shutdown(); + drop((dir_a, dir_b)); +} + +#[test] +fn removing_a_path_drops_it_from_lookup() { + let (dir_a, a) = make_repo(); + let catalog = Catalog::new(); + catalog.set_paths(std::slice::from_ref(&a)); + let id = catalog.list()[0].id.clone(); + + catalog.set_paths(&[]); + + assert!( + catalog.get(&id).is_none(), + "a closed repo must stop resolving" + ); + assert!(catalog.is_empty()); + drop(dir_a); +} + +#[test] +fn duplicate_paths_collapse_to_one_entry() { + let (dir_a, a) = make_repo(); + let catalog = Catalog::new(); + + catalog.set_paths(&[a.clone(), a.clone(), a.clone()]); + + assert_eq!(catalog.len(), 1, "one worktree is one entry"); + catalog.shutdown(); + drop(dir_a); +} + +#[test] +fn an_unknown_id_does_not_resolve() { + let catalog = Catalog::new(); + assert!(catalog.get("r999").is_none()); + assert!(catalog.get("").is_none()); + assert!(catalog.get("../etc").is_none()); +} + +#[test] +fn the_dto_exposes_only_the_whitelisted_identity_fields() { + let (dir_a, a) = make_repo(); + let catalog = Catalog::new(); + catalog.set_paths(std::slice::from_ref(&a)); + + let value = serde_json::to_value(&catalog.list()[0]).unwrap(); + + let mut keys: Vec<_> = value.as_object().unwrap().keys().cloned().collect(); + keys.sort(); + assert_eq!(keys, vec!["display_path", "id", "name"]); + catalog.shutdown(); + drop(dir_a); +} + +#[test] +fn display_path_abbreviates_the_home_directory() { + // A repo under $HOME is sent home-relative, so the payload does not + // carry the account name. A repo outside it has nothing to abbreviate — + // the path is the only label that identifies it to the user, and the + // client is already authenticated to a session that has a shell. + let home = dirs::home_dir().expect("a home directory"); + + assert_eq!( + display_path(&home.join("code").join("app").to_string_lossy()), + "~/code/app" + ); + assert_eq!(display_path(&home.to_string_lossy()), "~"); + assert_eq!(display_path("/opt/elsewhere"), "/opt/elsewhere"); + assert_eq!( + display_path("/opt/elsewhere/"), + "/opt/elsewhere", + "libgit2's trailing separator must not reach the UI" + ); +} + +#[test] +fn repo_name_ignores_a_trailing_separator() { + assert_eq!(repo_name("/code/app/"), "app"); + assert_eq!(repo_name("/code/app"), "app"); +} \ No newline at end of file diff --git a/src/web/viewer/catalog.rs b/src/web/viewer/catalog/mod.rs similarity index 50% rename from src/web/viewer/catalog.rs rename to src/web/viewer/catalog/mod.rs index 52b60fe..49dcb3e 100644 --- a/src/web/viewer/catalog.rs +++ b/src/web/viewer/catalog/mod.rs @@ -14,63 +14,14 @@ //! runtime shutdown joins a thread, and holding the catalog lock across that //! would stall every in-flight request. -use crate::web::viewer::dto::RepoDto; use crate::web::viewer::runtime::RepoRuntime; use crate::web::viewer::terminal::TerminalHub; -use std::collections::HashMap; use std::path::Path; use std::sync::{Arc, Mutex}; -/// One served repository: its identity, and the runtime streaming its status. -pub struct RepoEntry { - pub id: String, - /// Absolute worktree path. Server-side only — never serialized. - pub path: String, - pub name: String, - pub display_path: String, - pub runtime: Arc, - /// This repository's terminals. Independent of the TUI's panes — see - /// [`crate::web::viewer::terminal`]. - pub terminals: Arc, -} - -impl RepoEntry { - pub fn to_dto(&self) -> RepoDto { - RepoDto { - id: self.id.clone(), - name: self.name.clone(), - display_path: self.display_path.clone(), - } - } -} - -/// Hands out ids and never reuses one, so a path that leaves and comes back -/// keeps the identity a client already knows. -#[derive(Default)] -struct IdAssigner { - next: u64, - by_path: HashMap, -} - -impl IdAssigner { - fn id_for(&mut self, path: &str) -> String { - if let Some(existing) = self.by_path.get(path) { - return existing.clone(); - } - self.next += 1; - let id = format!("r{}", self.next); - self.by_path.insert(path.to_string(), id.clone()); - id - } -} - -/// Result of [`Catalog::add_path`]. -pub enum AddOutcome { - /// The repository is now served — newly added, or already present. - Added(RepoDto), - /// The served set is already at its ceiling; nothing was added. - TooMany, -} +mod catalog_ids; +pub use catalog_ids::{AddOutcome, RepoEntry}; +use catalog_ids::IdAssigner; #[derive(Default)] pub struct Catalog { @@ -185,7 +136,7 @@ impl Catalog { union } - fn dto_for_path(&self, path: &str) -> Option { + fn dto_for_path(&self, path: &str) -> Option { self.entries .lock() .expect("catalog poisoned") @@ -253,7 +204,7 @@ impl Catalog { .map(Arc::clone) } - pub fn list(&self) -> Vec { + pub fn list(&self) -> Vec { self.entries .lock() .expect("catalog poisoned") @@ -315,229 +266,4 @@ fn display_path(path: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - use crate::test_util::make_repo; - - #[test] - fn ids_are_stable_across_catalog_updates() { - let (dir_a, a) = make_repo(); - let (dir_b, b) = make_repo(); - let catalog = Catalog::new(); - - catalog.set_paths(std::slice::from_ref(&a)); - let first = catalog.list()[0].id.clone(); - - // Opening a second repository must not renumber the first. - catalog.set_paths(&[a.clone(), b.clone()]); - let after = catalog.list(); - - assert_eq!(after[0].id, first); - assert_ne!(after[1].id, first); - catalog.shutdown(); - drop((dir_a, dir_b)); - } - - #[test] - fn a_path_that_leaves_and_returns_keeps_its_id() { - let (dir_a, a) = make_repo(); - let (dir_b, b) = make_repo(); - let catalog = Catalog::new(); - - catalog.set_paths(&[a.clone(), b.clone()]); - let a_id = catalog.list()[0].id.clone(); - catalog.set_paths(std::slice::from_ref(&b)); - catalog.set_paths(&[b.clone(), a.clone()]); - - let reopened = catalog.get(&a_id).expect("the id must still resolve"); - assert_eq!(reopened.path, a); - catalog.shutdown(); - drop((dir_a, dir_b)); - } - - #[test] - fn add_path_is_idempotent_and_respects_the_cap() { - let (dir_a, a) = make_repo(); - let (dir_b, b) = make_repo(); - let (dir_c, c) = make_repo(); - let catalog = Catalog::new(); - - let id_a = match catalog.add_path(a.clone(), 2) { - AddOutcome::Added(dto) => dto.id, - AddOutcome::TooMany => panic!("the first add must succeed"), - }; - assert_eq!(catalog.len(), 1); - - // Re-adding an open path is a no-op that returns the same identity. - match catalog.add_path(a.clone(), 2) { - AddOutcome::Added(dto) => assert_eq!(dto.id, id_a), - AddOutcome::TooMany => panic!("re-adding an open repo must not be refused"), - } - assert_eq!(catalog.len(), 1); - - // A second distinct repo fits under the cap of two. - assert!(matches!(catalog.add_path(b, 2), AddOutcome::Added(_))); - assert_eq!(catalog.len(), 2); - - // A third exceeds the cap and is refused without disturbing the set. - assert!(matches!(catalog.add_path(c, 2), AddOutcome::TooMany)); - assert_eq!(catalog.len(), 2); - - catalog.shutdown(); - drop((dir_a, dir_b, dir_c)); - } - - #[test] - fn a_browser_added_repo_survives_a_base_update() { - let (dir_a, a) = make_repo(); - let (dir_b, b) = make_repo(); - let catalog = Catalog::new(); - - catalog.set_paths(std::slice::from_ref(&a)); - let added = match catalog.add_path(b, 10) { - AddOutcome::Added(dto) => dto.id, - AddOutcome::TooMany => panic!("the add must succeed"), - }; - - // The TUI opening or closing a tab re-runs set_paths with a new base; - // a repository opened from the browser must not be dropped by it. - catalog.set_paths(std::slice::from_ref(&a)); - assert!( - catalog.get(&added).is_some(), - "a browser-added repo must survive a base update" - ); - - catalog.shutdown(); - drop((dir_a, dir_b)); - } - - #[test] - fn remove_path_closes_and_stays_closed_until_reopened() { - let (dir_a, a) = make_repo(); - let (dir_b, b) = make_repo(); - let catalog = Catalog::new(); - catalog.set_paths(&[a.clone(), b.clone()]); - assert_eq!(catalog.len(), 2); - - catalog.remove_path(&a); - assert_eq!(catalog.len(), 1); - - // A base re-sync (a TUI tab change) must not resurrect a closed repo. - catalog.set_paths(&[a.clone(), b.clone()]); - assert_eq!(catalog.len(), 1, "a closed repo must stay closed"); - - // Re-opening it from the browser clears the close and brings it back. - assert!(matches!(catalog.add_path(a.clone(), 10), AddOutcome::Added(_))); - assert_eq!(catalog.len(), 2); - - catalog.shutdown(); - drop((dir_a, dir_b)); - } - - #[test] - fn an_unchanged_path_keeps_its_runtime_and_subscribers() { - let (dir_a, a) = make_repo(); - let (dir_b, b) = make_repo(); - let catalog = Catalog::new(); - catalog.set_paths(std::slice::from_ref(&a)); - - let entry = catalog.get(&catalog.list()[0].id).unwrap(); - let _subscription = entry.runtime.subscribe(); - assert_eq!(entry.runtime.subscriber_count(), 1); - - // Adding an unrelated repository must not restart the existing one. - catalog.set_paths(&[a.clone(), b.clone()]); - - let same = catalog.get(&entry.id).unwrap(); - assert!( - Arc::ptr_eq(&same.runtime, &entry.runtime), - "an unchanged path must not get a fresh runtime" - ); - assert_eq!( - same.runtime.subscriber_count(), - 1, - "a catalog update must not drop live SSE clients" - ); - catalog.shutdown(); - drop((dir_a, dir_b)); - } - - #[test] - fn removing_a_path_drops_it_from_lookup() { - let (dir_a, a) = make_repo(); - let catalog = Catalog::new(); - catalog.set_paths(std::slice::from_ref(&a)); - let id = catalog.list()[0].id.clone(); - - catalog.set_paths(&[]); - - assert!( - catalog.get(&id).is_none(), - "a closed repo must stop resolving" - ); - assert!(catalog.is_empty()); - drop(dir_a); - } - - #[test] - fn duplicate_paths_collapse_to_one_entry() { - let (dir_a, a) = make_repo(); - let catalog = Catalog::new(); - - catalog.set_paths(&[a.clone(), a.clone(), a.clone()]); - - assert_eq!(catalog.len(), 1, "one worktree is one entry"); - catalog.shutdown(); - drop(dir_a); - } - - #[test] - fn an_unknown_id_does_not_resolve() { - let catalog = Catalog::new(); - assert!(catalog.get("r999").is_none()); - assert!(catalog.get("").is_none()); - assert!(catalog.get("../etc").is_none()); - } - - #[test] - fn the_dto_exposes_only_the_whitelisted_identity_fields() { - let (dir_a, a) = make_repo(); - let catalog = Catalog::new(); - catalog.set_paths(std::slice::from_ref(&a)); - - let value = serde_json::to_value(&catalog.list()[0]).unwrap(); - - let mut keys: Vec<_> = value.as_object().unwrap().keys().cloned().collect(); - keys.sort(); - assert_eq!(keys, vec!["display_path", "id", "name"]); - catalog.shutdown(); - drop(dir_a); - } - - #[test] - fn display_path_abbreviates_the_home_directory() { - // A repo under $HOME is sent home-relative, so the payload does not - // carry the account name. A repo outside it has nothing to abbreviate — - // the path is the only label that identifies it to the user, and the - // client is already authenticated to a session that has a shell. - let home = dirs::home_dir().expect("a home directory"); - - assert_eq!( - display_path(&home.join("code").join("app").to_string_lossy()), - "~/code/app" - ); - assert_eq!(display_path(&home.to_string_lossy()), "~"); - assert_eq!(display_path("/opt/elsewhere"), "/opt/elsewhere"); - assert_eq!( - display_path("/opt/elsewhere/"), - "/opt/elsewhere", - "libgit2's trailing separator must not reach the UI" - ); - } - - #[test] - fn repo_name_ignores_a_trailing_separator() { - assert_eq!(repo_name("/code/app/"), "app"); - assert_eq!(repo_name("/code/app"), "app"); - } -} +mod catalog_tests; \ No newline at end of file diff --git a/src/web/viewer/dto/diff.rs b/src/web/viewer/dto/diff.rs new file mode 100644 index 0000000..8267906 --- /dev/null +++ b/src/web/viewer/dto/diff.rs @@ -0,0 +1,117 @@ +use crate::git::diff::{DiffHunk, LineKind}; +use crate::web::viewer::highlight; +use crate::web::viewer::limits; +use serde::Serialize; + +/// One run of characters sharing a colour, from server-side syntax +/// highlighting. `t` is the text, `c` a `#rrggbb` foreground. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct SpanDto { + pub t: String, + pub c: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct DiffLineDto { + /// `+`, `-`, or ` `. + pub kind: String, + /// Syntax-highlighted content as coloured spans. + pub spans: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct DiffHunkDto { + pub header: String, + /// Which file the hunk belongs to. Present on commit diffs, where one + /// response spans several files; absent on a single-file diff. + #[serde(skip_serializing_if = "Option::is_none")] + pub file_path: Option, + pub lines: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DiffDto { + pub path: String, + pub hunks: Vec, + pub truncated: bool, +} + +fn line_code(kind: LineKind) -> &'static str { + match kind { + LineKind::Added => "+", + LineKind::Removed => "-", + LineKind::Context => " ", + } +} + +impl DiffDto { + /// Build from loaded hunks, enforcing the diff ceilings across the whole + /// file rather than per hunk — the cost to a client is the total, and a + /// pathological diff is usually many hunks rather than one huge one. + pub fn from_hunks(path: &str, hunks: &[DiffHunk]) -> Self { + let mut out = Vec::new(); + let mut lines_used = 0usize; + let mut bytes_used = 0usize; + let mut truncated = false; + + 'outer: for hunk in hunks { + // One highlighter per hunk, using the hunk's own file on commit + // diffs (which span several files) and the request path otherwise. + let mut lighter = + highlight::highlighter(Some(hunk.file_path.as_deref().unwrap_or(path))); + let mut kept = Vec::new(); + for line in &hunk.lines { + if lines_used >= limits::MAX_DIFF_LINES + || bytes_used + line.content.len() > limits::MAX_DIFF_BYTES + { + truncated = true; + if !kept.is_empty() { + out.push(DiffHunkDto { + header: hunk.header.clone(), + file_path: hunk.file_path.clone(), + lines: kept, + }); + } + break 'outer; + } + lines_used += 1; + bytes_used += line.content.len(); + kept.push(DiffLineDto { + kind: line_code(line.kind).to_string(), + spans: lighter.line(&line.content), + }); + } + out.push(DiffHunkDto { + header: hunk.header.clone(), + file_path: hunk.file_path.clone(), + lines: kept, + }); + } + + Self { + path: path.to_string(), + hunks: out, + truncated, + } + } +} + +/// A file's syntax-highlighted content, already capped. One entry per line, +/// each a list of coloured spans. +#[derive(Debug, Clone, Serialize)] +pub struct FileDto { + pub path: String, + pub lines: Vec>, + pub truncated: bool, +} + +impl FileDto { + pub fn new(path: &str, content: &str) -> Self { + let (content, truncated) = limits::cap_text(content, limits::MAX_DIFF_BYTES); + Self { + path: path.to_string(), + lines: highlight::file_spans(path, &content), + truncated, + } + } +} \ No newline at end of file diff --git a/src/web/viewer/dto/envelope.rs b/src/web/viewer/dto/envelope.rs new file mode 100644 index 0000000..97b7b11 --- /dev/null +++ b/src/web/viewer/dto/envelope.rs @@ -0,0 +1,86 @@ +use super::status::server_now_millis; +use serde::Serialize; + +/// Bumped whenever an existing field changes meaning or disappears. Adding a +/// new optional field does not need a bump. +pub const PROTOCOL_VERSION: u32 = 2; + +/// Wrapper carried by every response so a stale client can detect a mismatch. +#[derive(Debug, Serialize)] +pub struct Envelope { + pub version: u32, + #[serde(flatten)] + pub payload: T, +} + +impl Envelope { + pub fn new(payload: T) -> Self { + Self { + version: PROTOCOL_VERSION, + payload, + } + } +} + +/// One open repository. `id` is opaque and stable for the process lifetime; +/// clients address every other route by it and never by path. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct RepoDto { + pub id: String, + /// Final path component, for a tab label. + pub name: String, + /// Home-relative path for display (`~/code/app`). The absolute path is not + /// sent: the client never needs it, and it is the one field here that says + /// something about the machine rather than the repository. + pub display_path: String, +} + +/// The part of `[agent_indicator]` the browser can act on. `auto_follow` is +/// omitted: it moves a TUI selection, and the viewer has no analogue. +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub struct HotConfigDto { + pub enabled: bool, + pub window_secs: u64, +} + +/// 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 path stays as it is so opening and closing keep their home; this +/// type is where the payload's real job is written down. +/// +/// 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; +/// a purely additive field does not, so add it to both while it is in hand. +#[derive(Debug, Clone, Serialize)] +pub struct ViewerBootstrapDto { + pub repos: Vec, + pub hot: HotConfigDto, + /// Index into the viewer's accent presets, stored server-side so every + /// device agrees. + pub accent: usize, + /// File-sidebar width in CSS px, stored server-side like the accent so every + /// device opens at the same split. + pub sidebar_width: u32, + /// This server's wall clock, for dating [`super::ChangedFileDto::mtime`]. + pub now_ms: u64, +} + +impl ViewerBootstrapDto { + /// Stamps `now_ms` at construction — the value is only useful as "the + /// server's time when this response was built", so no caller is given the + /// chance to supply a staler one. + pub fn new(repos: Vec, hot: HotConfigDto, accent: usize, sidebar_width: u32) -> Self { + Self { + repos, + hot, + accent, + sidebar_width, + now_ms: server_now_millis(), + } + } +} \ No newline at end of file diff --git a/src/web/viewer/dto/log.rs b/src/web/viewer/dto/log.rs new file mode 100644 index 0000000..26b0a4a --- /dev/null +++ b/src/web/viewer/dto/log.rs @@ -0,0 +1,82 @@ +use crate::git::diff::{ChangedFile, CommitEntry}; +use crate::web::viewer::limits::{self, Capped}; +use super::status::ChangedFileDto; +use git2::Oid; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct CommitDto { + pub oid: String, + pub short_id: String, + pub summary: String, + pub author: String, + /// Unix seconds. Formatting is the client's business. + pub time: i64, +} + +impl From<&CommitEntry> for CommitDto { + fn from(c: &CommitEntry) -> Self { + // `summary_lower` is deliberately absent: it is a TUI filter cache. + Self { + oid: c.oid.to_string(), + short_id: c.short_id.clone(), + summary: c.summary.clone(), + author: c.author.clone(), + time: c.time, + } + } +} + +/// One page of the commit log. +#[derive(Debug, Clone, Serialize)] +pub struct LogDto { + pub commits: Vec, + /// True when the history continues past this page — i.e. there is a next + /// page to ask for, not that anything was silently dropped. + pub truncated: bool, + /// The commit the walk started from, echoed so the client can pin its + /// following pages to it (see [`crate::git::diff::load_commit_log_from`]). + /// `None` only for a repository with no commits to anchor to. + #[serde(skip_serializing_if = "Option::is_none")] + pub head: Option, +} + +/// Changed paths in one historical commit. The row shape intentionally matches +/// [`ChangedFileDto`], so the browser renders status and commit drill-down +/// lists consistently (including rename sources and XY-style status columns). +#[derive(Debug, Clone, Serialize)] +pub struct CommitFilesDto { + pub files: Vec, + pub truncated: bool, +} + +impl CommitFilesDto { + pub fn from_entries(files: &[ChangedFile]) -> Self { + let capped = Capped::new(files.to_vec(), limits::MAX_COMMIT_FILES); + Self { + files: capped.items.iter().map(ChangedFileDto::from).collect(), + truncated: capped.truncated, + } + } +} + +impl LogDto { + /// Build a page from a walk that was asked for one entry more than + /// [`limits::MAX_LOG_PAGE`]. + /// + /// The extra entry is how "there is more" is known: asking for exactly a + /// page's worth and capping at the same number can never report truncation, + /// which is what this endpoint used to do — it answered `truncated: false` + /// for a history of any length. + /// + /// `anchor` is the commit the walk started from, echoed to the client so + /// its next request describes the same history. + pub fn from_entries(entries: &[CommitEntry], anchor: Option) -> Self { + let capped = Capped::new(entries.to_vec(), limits::MAX_LOG_PAGE); + Self { + commits: capped.items.iter().map(CommitDto::from).collect(), + truncated: capped.truncated, + head: anchor.map(|oid| oid.to_string()), + } + } +} \ No newline at end of file diff --git a/src/web/viewer/dto/mod.rs b/src/web/viewer/dto/mod.rs index bef3b02..00636cd 100644 --- a/src/web/viewer/dto/mod.rs +++ b/src/web/viewer/dto/mod.rs @@ -10,930 +10,27 @@ //! [`PROTOCOL_VERSION`] rides on every response. A cached page from an older //! build can then refuse to interpret a newer payload rather than misread it. -use crate::git::diff::{ChangedFile, CommitEntry, DiffHunk, LineKind, StatusKind, TrackingStatus}; -use crate::web::viewer::highlight; -use crate::web::viewer::limits::{self, Capped}; -use git2::Oid; -use serde::Serialize; -use std::collections::HashMap; -use std::time::SystemTime; - -/// One navigable directory in the "open a project" folder picker. Directories -/// only — files are not openable as projects. `is_repo` flags a git worktree so -/// the picker can mark it. -#[derive(Debug, Serialize)] -pub struct BrowseEntryDto { - pub name: String, - pub is_repo: bool, -} - -/// One level of the server filesystem for the folder picker. Unlike [`TreeDto`] -/// this is deliberately *not* confined to a worktree — it browses the server to -/// find a repository to open — so it is reachable only authenticated and -/// carries the same trust as the terminal. `parent` is `None` at the root. -#[derive(Debug, Serialize)] -pub struct BrowseDto { - pub path: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub parent: Option, - pub entries: Vec, - pub truncated: bool, -} - -/// Bumped whenever an existing field changes meaning or disappears. Adding a -/// new optional field does not need a bump. -pub const PROTOCOL_VERSION: u32 = 2; - -/// Wrapper carried by every response so a stale client can detect a mismatch. -#[derive(Debug, Serialize)] -pub struct Envelope { - pub version: u32, - #[serde(flatten)] - pub payload: T, -} - -impl Envelope { - pub fn new(payload: T) -> Self { - Self { - version: PROTOCOL_VERSION, - payload, - } - } -} - -/// One open repository. `id` is opaque and stable for the process lifetime; -/// clients address every other route by it and never by path. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct RepoDto { - pub id: String, - /// Final path component, for a tab label. - pub name: String, - /// Home-relative path for display (`~/code/app`). The absolute path is not - /// sent: the client never needs it, and it is the one field here that says - /// something about the machine rather than the repository. - pub display_path: String, -} - -/// The part of `[agent_indicator]` the browser can act on. `auto_follow` is -/// omitted: it moves a TUI selection, and the viewer has no analogue. -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] -pub struct HotConfigDto { - pub enabled: bool, - pub window_secs: u64, -} - -/// 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 path stays as it is so opening and closing keep their home; this -/// type is where the payload's real job is written down. -/// -/// 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; -/// a purely additive field does not, so add it to both while it is in hand. -#[derive(Debug, Clone, Serialize)] -pub struct ViewerBootstrapDto { - pub repos: Vec, - pub hot: HotConfigDto, - /// Index into the viewer's accent presets, stored server-side so every - /// device agrees. - pub accent: usize, - /// File-sidebar width in CSS px, stored server-side like the accent so every - /// device opens at the same split. - pub sidebar_width: u32, - /// This server's wall clock, for dating [`ChangedFileDto::mtime`]. - pub now_ms: u64, -} - -impl ViewerBootstrapDto { - /// Stamps `now_ms` at construction — the value is only useful as "the - /// server's time when this response was built", so no caller is given the - /// chance to supply a staler one. - pub fn new(repos: Vec, hot: HotConfigDto, accent: usize, sidebar_width: u32) -> Self { - Self { - repos, - hot, - accent, - sidebar_width, - now_ms: server_now_millis(), - } - } -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct TrackingDto { - pub ahead: usize, - pub behind: usize, -} - -impl From<&TrackingStatus> for TrackingDto { - fn from(t: &TrackingStatus) -> Self { - Self { - ahead: t.ahead, - behind: t.behind, - } - } -} - -/// One changed file. `index`/`worktree` are the two `git status --short` -/// columns as single-character codes. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct ChangedFileDto { - pub path: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub old_path: Option, - pub index: String, - 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. - /// - /// An absolute instant, not an age: the status payload is deduplicated by - /// byte equality 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`]). - #[serde(skip_serializing_if = "Option::is_none")] - pub mtime: Option, -} - -/// Wire code for a status column. Defined here rather than reused from the TUI -/// renderer so the protocol does not shift if the display characters do. -fn status_code(kind: StatusKind) -> &'static str { - match kind { - StatusKind::Unmodified => " ", - StatusKind::Added => "A", - StatusKind::Modified => "M", - StatusKind::Deleted => "D", - StatusKind::Renamed => "R", - StatusKind::TypeChanged => "T", - StatusKind::Untracked => "?", - StatusKind::Unmerged => "U", - } -} - -impl From<&ChangedFile> for ChangedFileDto { - fn from(f: &ChangedFile) -> Self { - // `search_lower` is deliberately absent: it is a TUI filter cache. - Self { - path: f.path.clone(), - old_path: f.old_path.clone(), - index: status_code(f.index).to_string(), - worktree: status_code(f.worktree).to_string(), - mtime: None, - } - } -} - -/// Unix milliseconds, or `None` for a pre-epoch timestamp — which only a badly -/// skewed clock produces, and which the client would read as "infinitely old" -/// anyway. -fn unix_millis(t: SystemTime) -> Option { - t.duration_since(SystemTime::UNIX_EPOCH) - .ok() - .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`]). -pub fn server_now_millis() -> u64 { - unix_millis(SystemTime::now()).unwrap_or(0) -} - -#[derive(Debug, Clone, Serialize)] -pub struct StatusDto { - #[serde(skip_serializing_if = "Option::is_none")] - pub branch: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub head: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tracking: Option, - pub files: Vec, - /// True when the repository had more changed files than the ceiling. - pub truncated: bool, -} - -impl StatusDto { - /// `mtimes` is the snapshot worker's stat of every listed file, keyed by - /// path; paths missing from it simply carry no `mtime`. - pub fn from_snapshot( - files: &[ChangedFile], - tracking: Option<&TrackingStatus>, - head: Option, - branch: Option<&str>, - mtimes: &HashMap, - ) -> Self { - let capped = Capped::new(files.to_vec(), limits::MAX_STATUS_FILES); - Self { - branch: branch.map(str::to_string), - // `Oid`'s own serde shape is libgit2's concern; hex is the protocol's. - head: head.map(|oid| oid.to_string()), - tracking: tracking.map(TrackingDto::from), - files: capped - .items - .iter() - .map(|f| ChangedFileDto { - mtime: mtimes.get(&f.path).copied().and_then(unix_millis), - ..ChangedFileDto::from(f) - }) - .collect(), - truncated: capped.truncated, - } - } -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct CommitDto { - pub oid: String, - pub short_id: String, - pub summary: String, - pub author: String, - /// Unix seconds. Formatting is the client's business. - pub time: i64, -} - -impl From<&CommitEntry> for CommitDto { - fn from(c: &CommitEntry) -> Self { - // `summary_lower` is deliberately absent: it is a TUI filter cache. - Self { - oid: c.oid.to_string(), - short_id: c.short_id.clone(), - summary: c.summary.clone(), - author: c.author.clone(), - time: c.time, - } - } -} - -/// One page of the commit log. -#[derive(Debug, Clone, Serialize)] -pub struct LogDto { - pub commits: Vec, - /// True when the history continues past this page — i.e. there is a next - /// page to ask for, not that anything was silently dropped. - pub truncated: bool, - /// The commit the walk started from, echoed so the client can pin its - /// following pages to it (see [`crate::git::diff::load_commit_log_from`]). - /// `None` only for a repository with no commits to anchor to. - #[serde(skip_serializing_if = "Option::is_none")] - pub head: Option, -} - -/// Changed paths in one historical commit. The row shape intentionally matches -/// [`ChangedFileDto`], so the browser renders status and commit drill-down -/// lists consistently (including rename sources and XY-style status columns). -#[derive(Debug, Clone, Serialize)] -pub struct CommitFilesDto { - pub files: Vec, - pub truncated: bool, -} - -impl CommitFilesDto { - pub fn from_entries(files: &[ChangedFile]) -> Self { - let capped = Capped::new(files.to_vec(), limits::MAX_COMMIT_FILES); - Self { - files: capped.items.iter().map(ChangedFileDto::from).collect(), - truncated: capped.truncated, - } - } -} - -impl LogDto { - /// Build a page from a walk that was asked for one entry more than - /// [`limits::MAX_LOG_PAGE`]. - /// - /// The extra entry is how "there is more" is known: asking for exactly a - /// page's worth and capping at the same number can never report truncation, - /// which is what this endpoint used to do — it answered `truncated: false` - /// for a history of any length. - /// - /// `anchor` is the commit the walk started from, echoed to the client so - /// its next request describes the same history. - pub fn from_entries(entries: &[CommitEntry], anchor: Option) -> Self { - let capped = Capped::new(entries.to_vec(), limits::MAX_LOG_PAGE); - Self { - commits: capped.items.iter().map(CommitDto::from).collect(), - truncated: capped.truncated, - head: anchor.map(|oid| oid.to_string()), - } - } -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct TreeEntryDto { - pub name: String, - pub is_dir: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TreeDto { - pub path: String, - pub entries: Vec, - pub truncated: bool, -} - -impl TreeDto { - pub fn from_entries(path: &str, entries: &[crate::git::tree::TreeEntry]) -> Self { - let capped = Capped::new(entries.to_vec(), limits::MAX_TREE_ENTRIES); - Self { - path: path.to_string(), - entries: capped - .items - .iter() - .map(|e| TreeEntryDto { - name: e.name.clone(), - is_dir: e.is_dir, - }) - .collect(), - truncated: capped.truncated, - } - } -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct TreeMatchDto { - pub path: String, - pub is_dir: bool, -} - -/// Result of a recursive `/api/tree/search`: full paths whose basename matched -/// the query, already sorted and capped by the search walk. -#[derive(Debug, Clone, Serialize)] -pub struct TreeSearchDto { - pub query: String, - pub matches: Vec, - pub truncated: bool, -} - -impl TreeSearchDto { - pub fn new(query: &str, matches: &[crate::git::tree::TreeMatch], truncated: bool) -> Self { - Self { - query: query.to_string(), - matches: matches - .iter() - .map(|m| TreeMatchDto { - path: m.path.clone(), - is_dir: m.is_dir, - }) - .collect(), - truncated, - } - } -} - -/// One run of characters sharing a colour, from server-side syntax -/// highlighting. `t` is the text, `c` a `#rrggbb` foreground. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct SpanDto { - pub t: String, - pub c: String, -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct DiffLineDto { - /// `+`, `-`, or ` `. - pub kind: String, - /// Syntax-highlighted content as coloured spans. - pub spans: Vec, -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct DiffHunkDto { - pub header: String, - /// Which file the hunk belongs to. Present on commit diffs, where one - /// response spans several files; absent on a single-file diff. - #[serde(skip_serializing_if = "Option::is_none")] - pub file_path: Option, - pub lines: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct DiffDto { - pub path: String, - pub hunks: Vec, - pub truncated: bool, -} - -fn line_code(kind: LineKind) -> &'static str { - match kind { - LineKind::Added => "+", - LineKind::Removed => "-", - LineKind::Context => " ", - } -} - -impl DiffDto { - /// Build from loaded hunks, enforcing the diff ceilings across the whole - /// file rather than per hunk — the cost to a client is the total, and a - /// pathological diff is usually many hunks rather than one huge one. - pub fn from_hunks(path: &str, hunks: &[DiffHunk]) -> Self { - let mut out = Vec::new(); - let mut lines_used = 0usize; - let mut bytes_used = 0usize; - let mut truncated = false; - - 'outer: for hunk in hunks { - // One highlighter per hunk, using the hunk's own file on commit - // diffs (which span several files) and the request path otherwise. - let mut lighter = - highlight::highlighter(Some(hunk.file_path.as_deref().unwrap_or(path))); - let mut kept = Vec::new(); - for line in &hunk.lines { - if lines_used >= limits::MAX_DIFF_LINES - || bytes_used + line.content.len() > limits::MAX_DIFF_BYTES - { - truncated = true; - if !kept.is_empty() { - out.push(DiffHunkDto { - header: hunk.header.clone(), - file_path: hunk.file_path.clone(), - lines: kept, - }); - } - break 'outer; - } - lines_used += 1; - bytes_used += line.content.len(); - kept.push(DiffLineDto { - kind: line_code(line.kind).to_string(), - spans: lighter.line(&line.content), - }); - } - out.push(DiffHunkDto { - header: hunk.header.clone(), - file_path: hunk.file_path.clone(), - lines: kept, - }); - } - - Self { - path: path.to_string(), - hunks: out, - truncated, - } - } -} - -/// A file's syntax-highlighted content, already capped. One entry per line, -/// each a list of coloured spans. -#[derive(Debug, Clone, Serialize)] -pub struct FileDto { - pub path: String, - pub lines: Vec>, - pub truncated: bool, -} - -impl FileDto { - pub fn new(path: &str, content: &str) -> Self { - let (content, truncated) = limits::cap_text(content, limits::MAX_DIFF_BYTES); - Self { - path: path.to_string(), - lines: highlight::file_spans(path, &content), - truncated, - } - } -} +mod diff; +mod envelope; +mod log; +mod status; +mod tree; +pub use diff::{DiffDto, FileDto, SpanDto}; #[cfg(test)] -mod tests { - use super::*; - - fn json(value: &T) -> serde_json::Value { - serde_json::to_value(value).unwrap() - } - - #[test] - fn changed_file_dto_drops_the_tui_search_cache() { - let file = ChangedFile::from_status_columns( - "src/main.rs".to_string(), - None, - StatusKind::Modified, - StatusKind::Unmodified, - ); - assert!( - !file.search_lower.is_empty(), - "precondition: the internal type carries a search cache" - ); - - let value = json(&ChangedFileDto::from(&file)); - - assert_eq!(value["path"], "src/main.rs"); - assert_eq!(value["index"], "M"); - assert_eq!(value["worktree"], " "); - assert!( - value.get("search_lower").is_none(), - "the filter cache must not reach the wire: {value}" - ); - assert!( - value.get("old_path").is_none(), - "an absent rename source is omitted, not null" - ); - } - - #[test] - fn changed_file_dto_keeps_a_rename_source() { - let file = ChangedFile::from_status_columns( - "new.rs".to_string(), - Some("old.rs".to_string()), - StatusKind::Renamed, - StatusKind::Unmodified, - ); - - let value = json(&ChangedFileDto::from(&file)); - - assert_eq!(value["old_path"], "old.rs"); - assert_eq!(value["index"], "R"); - } - - #[test] - fn commit_dto_drops_the_summary_cache_and_hexes_the_oid() { - let entry = CommitEntry::new( - git2::Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap(), - "1234567".to_string(), - "Fix The Bug".to_string(), - "Someone".to_string(), - 1_700_000_000, - ); - assert!(!entry.summary_lower.is_empty(), "precondition"); - - let value = json(&CommitDto::from(&entry)); - - assert_eq!(value["oid"], "1234567890abcdef1234567890abcdef12345678"); - assert_eq!(value["summary"], "Fix The Bug"); - assert!( - value.get("summary_lower").is_none(), - "the filter cache must not reach the wire: {value}" - ); - } - - #[test] - fn envelope_carries_the_protocol_version_alongside_the_payload() { - let value = json(&Envelope::new(TreeDto::from_entries("src", &[]))); - - assert_eq!(value["version"], PROTOCOL_VERSION); - assert_eq!(value["path"], "src", "the payload is flattened, not nested"); - } - - #[test] - fn status_dto_reports_truncation_past_the_ceiling() { - let files: Vec<_> = (0..limits::MAX_STATUS_FILES + 5) - .map(|i| { - ChangedFile::from_status_columns( - format!("f{i}.rs"), - None, - StatusKind::Modified, - StatusKind::Unmodified, - ) - }) - .collect(); - - let dto = StatusDto::from_snapshot(&files, None, None, Some("main"), &HashMap::new()); - - assert_eq!(dto.files.len(), limits::MAX_STATUS_FILES); - assert!(dto.truncated); - assert_eq!(dto.branch.as_deref(), Some("main")); - } - - #[test] - fn status_dto_carries_mtime_in_millis_only_for_stated_files() { - let files = vec![ - ChangedFile::from_status_columns( - "hot.rs".to_string(), - None, - StatusKind::Modified, - StatusKind::Unmodified, - ), - ChangedFile::from_status_columns( - "gone.rs".to_string(), - None, - StatusKind::Deleted, - StatusKind::Unmodified, - ), - ]; - // Deleted files never make it into the worker's mtime map, so their - // rows must simply omit the field rather than carry a stand-in age. - let mtimes = HashMap::from([( - "hot.rs".to_string(), - SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(1_500), - )]); - - let value = json(&StatusDto::from_snapshot(&files, None, None, None, &mtimes)); - - assert_eq!(value["files"][0]["mtime"], 1_500u64); - assert!(value["files"][1].get("mtime").is_none()); - } - - #[test] - fn commit_file_list_never_carries_an_mtime() { - // A commit's files describe history; the working tree's mtime would be - // unrelated to them, and a client must not be able to read one as - // "this commit touched the file just now". - let files = vec![ChangedFile::from_status_columns( - "a.rs".to_string(), - None, - StatusKind::Modified, - StatusKind::Unmodified, - )]; - - let value = json(&CommitFilesDto::from_entries(&files)); - - assert!(value["files"][0].get("mtime").is_none()); - } - - #[test] - fn status_dto_omits_absent_optional_fields() { - let value = json(&StatusDto::from_snapshot(&[], None, None, None, &HashMap::new())); - - assert!(value.get("branch").is_none()); - assert!(value.get("head").is_none()); - assert!(value.get("tracking").is_none()); - assert_eq!(value["truncated"], false); - } - - fn hunk(header: &str, lines: usize, width: usize) -> DiffHunk { - DiffHunk { - header: header.to_string(), - file_path: None, - lines: (0..lines) - .map(|_| crate::git::diff::DiffLine { - kind: LineKind::Context, - content: "x".repeat(width), - }) - .collect(), - } - } - - #[test] - fn diff_dto_maps_line_kinds_to_wire_codes() { - let hunks = vec![DiffHunk { - header: "@@ -1 +1 @@".to_string(), - file_path: Some("a.rs".to_string()), - lines: vec![ - crate::git::diff::DiffLine { - kind: LineKind::Added, - content: "new".into(), - }, - crate::git::diff::DiffLine { - kind: LineKind::Removed, - content: "old".into(), - }, - crate::git::diff::DiffLine { - kind: LineKind::Context, - content: "same".into(), - }, - ], - }]; - - let dto = DiffDto::from_hunks("a.rs", &hunks); - - let kinds: Vec<_> = dto.hunks[0].lines.iter().map(|l| l.kind.as_str()).collect(); - assert_eq!(kinds, vec!["+", "-", " "]); - assert!(!dto.truncated); - } - - #[test] - fn diff_dto_caps_across_hunks_not_within_one() { - // Each hunk is under the ceiling alone; together they exceed it. A - // per-hunk cap would let the total through unbounded. - let per_hunk = limits::MAX_DIFF_LINES / 2 + 10; - let hunks = vec![hunk("@@ a @@", per_hunk, 1), hunk("@@ b @@", per_hunk, 1)]; - - let dto = DiffDto::from_hunks("big.rs", &hunks); - - let total: usize = dto.hunks.iter().map(|h| h.lines.len()).sum(); - assert_eq!(total, limits::MAX_DIFF_LINES); - assert!(dto.truncated); - } - - #[test] - fn diff_dto_stops_on_the_byte_ceiling_before_the_line_ceiling() { - // Few lines, each enormous: the byte ceiling has to bind first. - let hunks = vec![hunk("@@ a @@", 50, limits::MAX_DIFF_BYTES / 10)]; - - let dto = DiffDto::from_hunks("wide.rs", &hunks); - - let bytes: usize = dto.hunks[0] - .lines - .iter() - .flat_map(|l| &l.spans) - .map(|s| s.t.len()) - .sum(); - assert!(bytes <= limits::MAX_DIFF_BYTES); - assert!(dto.truncated); - assert!( - dto.hunks[0].lines.len() < 50, - "the byte ceiling must cut before the line count does" - ); - } - - #[test] - fn file_dto_caps_content_on_a_character_boundary() { - let content = "한".repeat(limits::MAX_DIFF_BYTES); - - let dto = FileDto::new("big.txt", &content); - - // Reconstruct the served text from its spans (one line here — no \n). - let served: String = dto.lines.iter().flatten().map(|s| s.t.as_str()).collect(); - assert!(dto.truncated); - assert!(served.len() <= limits::MAX_DIFF_BYTES); - assert!( - content.starts_with(&served), - "the cap must yield a clean prefix" - ); - } - - /// Where the wire fixture lives. At the `viewer-ui` root rather than under - /// `viewer-ui/src` (which the published crate excludes) so it ships in the - /// package and this test still passes from an installed crate; the - /// TypeScript side reaches it with a `../` import. The fixture is only half - /// a contract test on its own, and the half that matters is the one that - /// fails when the two hand-written definitions of this protocol drift apart. - const FIXTURE_PATH: &str = concat!( - env!("CARGO_MANIFEST_DIR"), - "/viewer-ui/api.fixture.json" - ); - - /// Set to rewrite the fixture instead of asserting against it. - const UPDATE_ENV: &str = "UPDATE_API_FIXTURE"; - - /// One instance of every payload the viewer serves, with literal values - /// rather than values derived from a repository — the point is the shape, - /// and a fixture that changed with the fixture repo could not be diffed. - /// - /// Optional fields appear both present and absent (a renamed file next to a - /// plain one, a commit hunk next to a single-file one) so a - /// `skip_serializing_if` that stops firing is visible here too. - fn wire_fixture() -> serde_json::Value { - let span = |t: &str, c: &str| SpanDto { - t: t.to_string(), - c: c.to_string(), - }; - let changed = ChangedFileDto { - path: "src/main.rs".to_string(), - old_path: None, - index: "M".to_string(), - worktree: " ".to_string(), - mtime: Some(1_700_000_000_000), - }; - let renamed = ChangedFileDto { - path: "src/app/mod.rs".to_string(), - old_path: Some("src/app.rs".to_string()), - index: "R".to_string(), - worktree: "M".to_string(), - mtime: None, - }; - - serde_json::json!({ - "version": PROTOCOL_VERSION, - "bootstrap": ViewerBootstrapDto { - repos: vec![RepoDto { - id: "r1".to_string(), - name: "nightcrow".to_string(), - display_path: "~/code/nightcrow".to_string(), - }], - hot: HotConfigDto { enabled: true, window_secs: 15 }, - accent: 2, - sidebar_width: 460, - // Literal, not `server_now_millis()`: a fixture that moved every - // run could not be committed. - now_ms: 1_700_000_000_500, - }, - "status": StatusDto { - branch: Some("dev".to_string()), - head: Some("9a3bc2c".to_string()), - tracking: Some(TrackingDto { ahead: 2, behind: 0 }), - files: vec![changed.clone(), renamed.clone()], - truncated: false, - }, - "log": LogDto { - commits: vec![CommitDto { - oid: "9a3bc2cf0e1d2a3b4c5d6e7f8a9b0c1d2e3f4a5b".to_string(), - short_id: "9a3bc2c".to_string(), - summary: "refactor: name the bootstrap payload".to_string(), - author: "code0xff".to_string(), - time: 1_700_000_000, - }], - // A page with more behind it, carrying the anchor the client - // pins its next request to. - truncated: true, - head: Some("9a3bc2cf0e1d2a3b4c5d6e7f8a9b0c1d2e3f4a5b".to_string()), - }, - // A repository with no commits: no anchor to page from, which is - // also how the client learns there is nothing more. Present so the - // absent `head` is pinned as well as the populated one. - "logEmpty": LogDto::from_entries(&[], None), - "commitFiles": CommitFilesDto { - files: vec![renamed], - truncated: true, - }, - "tree": TreeDto { - path: "src".to_string(), - entries: vec![ - TreeEntryDto { name: "web".to_string(), is_dir: true }, - TreeEntryDto { name: "main.rs".to_string(), is_dir: false }, - ], - truncated: false, - }, - "treeSearch": TreeSearchDto { - query: "dto".to_string(), - matches: vec![TreeMatchDto { - path: "src/web/viewer/dto.rs".to_string(), - is_dir: false, - }], - truncated: false, - }, - "diff": DiffDto { - path: "src/main.rs".to_string(), - hunks: vec![ - DiffHunkDto { - header: "@@ -1,3 +1,4 @@".to_string(), - file_path: None, - lines: vec![ - DiffLineDto { - kind: " ".to_string(), - spans: vec![span("fn main() {", "#c9d1d9")], - }, - DiffLineDto { - kind: "+".to_string(), - spans: vec![span(" ", ""), span("run()", "#79c0ff")], - }, - ], - }, - DiffHunkDto { - header: "@@ -10,2 +10,2 @@".to_string(), - file_path: Some("src/lib.rs".to_string()), - lines: vec![DiffLineDto { - kind: "-".to_string(), - spans: vec![span("mod old;", "#ff7b72")], - }], - }, - ], - truncated: false, - }, - "file": FileDto { - path: "README.md".to_string(), - lines: vec![vec![span("# nightcrow", "#d2a8ff")], vec![]], - truncated: false, - }, - "browse": BrowseDto { - path: "/Users/code0xff/code".to_string(), - parent: Some("/Users/code0xff".to_string()), - entries: vec![ - BrowseEntryDto { name: "nightcrow".to_string(), is_repo: true }, - BrowseEntryDto { name: "scratch".to_string(), is_repo: false }, - ], - truncated: false, - }, - "browseRoot": BrowseDto { - path: "/".to_string(), - parent: None, - entries: vec![], - truncated: false, - }, - "openedRepo": serde_json::json!({ "repo": RepoDto { - id: "r2".to_string(), - name: "scratch".to_string(), - display_path: "~/code/scratch".to_string(), - }}), - // One shape for every `/api/prefs` write: the full stored prefs, so - // a client that set the accent and one that set the width both read - // the clamped result back the same way. - "storedPrefs": serde_json::json!({ "accent": 2, "sidebar_width": 460 }), - }) - } - - #[test] - fn the_wire_fixture_matches_the_served_payloads() { - // The DTOs here and the interfaces in `viewer-ui/src/api.ts` describe - // one protocol twice, by hand. This pins the Rust half: any rename, - // removal, addition, or type change lands in the fixture diff, which is - // what sends someone to `api.ts`. Regenerating then puts the other half - // under a compiler that rejects a field the interfaces still name by its - // old name or type — an added field passes, being one they simply do not - // mention yet. See the header of `api.contract.test.ts`. - let expected = format!("{}\n", serde_json::to_string_pretty(&wire_fixture()).unwrap()); - - if std::env::var_os(UPDATE_ENV).is_some() { - std::fs::write(FIXTURE_PATH, &expected).expect("could not write the fixture"); - return; - } +pub use diff::{DiffHunkDto, DiffLineDto}; +pub use envelope::{Envelope, HotConfigDto, RepoDto, ViewerBootstrapDto}; +#[cfg(test)] +pub use envelope::PROTOCOL_VERSION; +pub use log::{CommitFilesDto, LogDto}; +#[cfg(test)] +pub use log::CommitDto; +pub use status::{BrowseDto, BrowseEntryDto, StatusDto}; +#[cfg(test)] +pub use status::{ChangedFileDto, TrackingDto, server_now_millis}; +pub use tree::{TreeDto, TreeSearchDto}; +#[cfg(test)] +pub use tree::{TreeEntryDto, TreeMatchDto}; - let actual = std::fs::read_to_string(FIXTURE_PATH).unwrap_or_default(); - assert_eq!( - actual, expected, - "the wire payloads no longer match {FIXTURE_PATH}. \ - Regenerate with `{UPDATE_ENV}=1 cargo test the_wire_fixture`, then update \ - viewer-ui/src/api.ts until `npm run build` passes again." - ); - } -} +#[cfg(test)] +mod tests; \ No newline at end of file diff --git a/src/web/viewer/dto/status.rs b/src/web/viewer/dto/status.rs new file mode 100644 index 0000000..32c32ce --- /dev/null +++ b/src/web/viewer/dto/status.rs @@ -0,0 +1,156 @@ +use crate::git::diff::{ChangedFile, StatusKind, TrackingStatus}; +use crate::web::viewer::limits::{self, Capped}; +use serde::Serialize; +use std::collections::HashMap; +use std::time::SystemTime; + +/// One navigable directory in the "open a project" folder picker. Directories +/// only — files are not openable as projects. `is_repo` flags a git worktree so +/// the picker can mark it. +#[derive(Debug, Serialize)] +pub struct BrowseEntryDto { + pub name: String, + pub is_repo: bool, +} + +/// One level of the server filesystem for the folder picker. Unlike +/// [`super::TreeDto`] this is deliberately *not* confined to a worktree — it +/// browses the server to find a repository to open — so it is reachable only +/// authenticated and carries the same trust as the terminal. `parent` is `None` +/// at the root. +#[derive(Debug, Serialize)] +pub struct BrowseDto { + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent: Option, + pub entries: Vec, + pub truncated: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct TrackingDto { + pub ahead: usize, + pub behind: usize, +} + +impl From<&TrackingStatus> for TrackingDto { + fn from(t: &TrackingStatus) -> Self { + Self { + ahead: t.ahead, + behind: t.behind, + } + } +} + +/// One changed file. `index`/`worktree` are the two `git status --short` +/// columns as single-character codes. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ChangedFileDto { + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub old_path: Option, + pub index: String, + 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. + /// + /// 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`]). + #[serde(skip_serializing_if = "Option::is_none")] + pub mtime: Option, +} + +/// Wire code for a status column. Defined here rather than reused from the TUI +/// renderer so the protocol does not shift if the display characters do. +fn status_code(kind: StatusKind) -> &'static str { + match kind { + StatusKind::Unmodified => " ", + StatusKind::Added => "A", + StatusKind::Modified => "M", + StatusKind::Deleted => "D", + StatusKind::Renamed => "R", + StatusKind::TypeChanged => "T", + StatusKind::Untracked => "?", + StatusKind::Unmerged => "U", + } +} + +impl From<&ChangedFile> for ChangedFileDto { + fn from(f: &ChangedFile) -> Self { + // `search_lower` is deliberately absent: it is a TUI filter cache. + Self { + path: f.path.clone(), + old_path: f.old_path.clone(), + index: status_code(f.index).to_string(), + worktree: status_code(f.worktree).to_string(), + mtime: None, + } + } +} + +/// Unix milliseconds, or `None` for a pre-epoch timestamp — which only a badly +/// skewed clock produces, and which the client would read as "infinitely old" +/// anyway. +fn unix_millis(t: SystemTime) -> Option { + t.duration_since(SystemTime::UNIX_EPOCH) + .ok() + .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`]). +pub fn server_now_millis() -> u64 { + unix_millis(SystemTime::now()).unwrap_or(0) +} + +#[derive(Debug, Clone, Serialize)] +pub struct StatusDto { + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub head: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tracking: Option, + pub files: Vec, + /// True when the repository had more changed files than the ceiling. + pub truncated: bool, +} + +impl StatusDto { + /// `mtimes` is the snapshot worker's stat of every listed file, keyed by + /// path; paths missing from it simply carry no `mtime`. + pub fn from_snapshot( + files: &[ChangedFile], + tracking: Option<&TrackingStatus>, + head: Option, + branch: Option<&str>, + mtimes: &HashMap, + ) -> Self { + let capped = Capped::new(files.to_vec(), limits::MAX_STATUS_FILES); + Self { + branch: branch.map(str::to_string), + // `Oid`'s own serde shape is libgit2's concern; hex is the protocol's. + head: head.map(|oid| oid.to_string()), + tracking: tracking.map(TrackingDto::from), + files: capped + .items + .iter() + .map(|f| ChangedFileDto { + mtime: mtimes.get(&f.path).copied().and_then(unix_millis), + ..ChangedFileDto::from(f) + }) + .collect(), + truncated: capped.truncated, + } + } +} \ No newline at end of file diff --git a/src/web/viewer/dto/tests/fixture.rs b/src/web/viewer/dto/tests/fixture.rs new file mode 100644 index 0000000..b663a91 --- /dev/null +++ b/src/web/viewer/dto/tests/fixture.rs @@ -0,0 +1,190 @@ +use super::super::{ + BrowseDto, BrowseEntryDto, ChangedFileDto, CommitDto, CommitFilesDto, DiffDto, DiffHunkDto, + DiffLineDto, FileDto, HotConfigDto, LogDto, PROTOCOL_VERSION, RepoDto, SpanDto, + StatusDto, TrackingDto, TreeDto, TreeEntryDto, TreeMatchDto, TreeSearchDto, ViewerBootstrapDto, +}; + +/// Where the wire fixture lives. At the `viewer-ui` root rather than under +/// `viewer-ui/src` (which the published crate excludes) so it ships in the +/// package and this test still passes from an installed crate; the +/// TypeScript side reaches it with a `../` import. The fixture is only half +/// a contract test on its own, and the half that matters is the one that +/// fails when the two hand-written definitions of this protocol drift apart. +const FIXTURE_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/viewer-ui/api.fixture.json" +); + +/// Set to rewrite the fixture instead of asserting against it. +const UPDATE_ENV: &str = "UPDATE_API_FIXTURE"; + +/// One instance of every payload the viewer serves, with literal values +/// rather than values derived from a repository — the point is the shape, +/// and a fixture that changed with the fixture repo could not be diffed. +/// +/// Optional fields appear both present and absent (a renamed file next to a +/// plain one, a commit hunk next to a single-file one) so a +/// `skip_serializing_if` that stops firing is visible here too. +fn wire_fixture() -> serde_json::Value { + let span = |t: &str, c: &str| SpanDto { + t: t.to_string(), + c: c.to_string(), + }; + let changed = ChangedFileDto { + path: "src/main.rs".to_string(), + old_path: None, + index: "M".to_string(), + worktree: " ".to_string(), + mtime: Some(1_700_000_000_000), + }; + let renamed = ChangedFileDto { + path: "src/app/mod.rs".to_string(), + old_path: Some("src/app.rs".to_string()), + index: "R".to_string(), + worktree: "M".to_string(), + mtime: None, + }; + + serde_json::json!({ + "version": PROTOCOL_VERSION, + "bootstrap": ViewerBootstrapDto { + repos: vec![RepoDto { + id: "r1".to_string(), + name: "nightcrow".to_string(), + display_path: "~/code/nightcrow".to_string(), + }], + hot: HotConfigDto { enabled: true, window_secs: 15 }, + accent: 2, + sidebar_width: 460, + // Literal, not `server_now_millis()`: a fixture that moved every + // run could not be committed. + now_ms: 1_700_000_000_500, + }, + "status": StatusDto { + branch: Some("dev".to_string()), + head: Some("9a3bc2c".to_string()), + tracking: Some(TrackingDto { ahead: 2, behind: 0 }), + files: vec![changed.clone(), renamed.clone()], + truncated: false, + }, + "log": LogDto { + commits: vec![CommitDto { + oid: "9a3bc2cf0e1d2a3b4c5d6e7f8a9b0c1d2e3f4a5b".to_string(), + short_id: "9a3bc2c".to_string(), + summary: "refactor: name the bootstrap payload".to_string(), + author: "code0xff".to_string(), + time: 1_700_000_000, + }], + // A page with more behind it, carrying the anchor the client + // pins its next request to. + truncated: true, + head: Some("9a3bc2cf0e1d2a3b4c5d6e7f8a9b0c1d2e3f4a5b".to_string()), + }, + // A repository with no commits: no anchor to page from, which is + // also how the client learns there is nothing more. Present so the + // absent `head` is pinned as well as the populated one. + "logEmpty": LogDto::from_entries(&[], None), + "commitFiles": CommitFilesDto { + files: vec![renamed], + truncated: true, + }, + "tree": TreeDto { + path: "src".to_string(), + entries: vec![ + TreeEntryDto { name: "web".to_string(), is_dir: true }, + TreeEntryDto { name: "main.rs".to_string(), is_dir: false }, + ], + truncated: false, + }, + "treeSearch": TreeSearchDto { + query: "dto".to_string(), + matches: vec![TreeMatchDto { + path: "src/web/viewer/dto.rs".to_string(), + is_dir: false, + }], + truncated: false, + }, + "diff": DiffDto { + path: "src/main.rs".to_string(), + hunks: vec![ + DiffHunkDto { + header: "@@ -1,3 +1,4 @@".to_string(), + file_path: None, + lines: vec![ + DiffLineDto { + kind: " ".to_string(), + spans: vec![span("fn main() {", "#c9d1d9")], + }, + DiffLineDto { + kind: "+".to_string(), + spans: vec![span(" ", ""), span("run()", "#79c0ff")], + }, + ], + }, + DiffHunkDto { + header: "@@ -10,2 +10,2 @@".to_string(), + file_path: Some("src/lib.rs".to_string()), + lines: vec![DiffLineDto { + kind: "-".to_string(), + spans: vec![span("mod old;", "#ff7b72")], + }], + }, + ], + truncated: false, + }, + "file": FileDto { + path: "README.md".to_string(), + lines: vec![vec![span("# nightcrow", "#d2a8ff")], vec![]], + truncated: false, + }, + "browse": BrowseDto { + path: "/Users/code0xff/code".to_string(), + parent: Some("/Users/code0xff".to_string()), + entries: vec![ + BrowseEntryDto { name: "nightcrow".to_string(), is_repo: true }, + BrowseEntryDto { name: "scratch".to_string(), is_repo: false }, + ], + truncated: false, + }, + "browseRoot": BrowseDto { + path: "/".to_string(), + parent: None, + entries: vec![], + truncated: false, + }, + "openedRepo": serde_json::json!({ "repo": RepoDto { + id: "r2".to_string(), + name: "scratch".to_string(), + display_path: "~/code/scratch".to_string(), + }}), + // One shape for every `/api/prefs` write: the full stored prefs, so + // a client that set the accent and one that set the width both read + // the clamped result back the same way. + "storedPrefs": serde_json::json!({ "accent": 2, "sidebar_width": 460 }), + }) +} + +#[test] +fn the_wire_fixture_matches_the_served_payloads() { + // The DTOs here and the interfaces in `viewer-ui/src/api.ts` describe + // one protocol twice, by hand. This pins the Rust half: any rename, + // removal, addition, or type change lands in the fixture diff, which is + // what sends someone to `api.ts`. Regenerating then puts the other half + // under a compiler that rejects a field the interfaces still name by its + // old name or type — an added field passes, being one they simply do not + // mention yet. See the header of `api.contract.test.ts`. + let expected = format!("{}\n", serde_json::to_string_pretty(&wire_fixture()).unwrap()); + + if std::env::var_os(UPDATE_ENV).is_some() { + std::fs::write(FIXTURE_PATH, &expected).expect("could not write the fixture"); + return; + } + + let actual = std::fs::read_to_string(FIXTURE_PATH).unwrap_or_default(); + assert_eq!( + actual, expected, + "the wire payloads no longer match {FIXTURE_PATH}. \ + Regenerate with `{UPDATE_ENV}=1 cargo test the_wire_fixture`, then update \ + viewer-ui/src/api.ts until `npm run build` passes again." + ); +} \ No newline at end of file diff --git a/src/web/viewer/dto/tests/mod.rs b/src/web/viewer/dto/tests/mod.rs new file mode 100644 index 0000000..03f3dda --- /dev/null +++ b/src/web/viewer/dto/tests/mod.rs @@ -0,0 +1,252 @@ +mod fixture; + +use super::*; +use crate::git::diff::{ChangedFile, CommitEntry, DiffHunk, LineKind, StatusKind}; +use crate::web::viewer::limits; +use serde::Serialize; +use std::collections::HashMap; +use std::time::SystemTime; + +fn json(value: &T) -> serde_json::Value { + serde_json::to_value(value).unwrap() +} + +#[test] +fn changed_file_dto_drops_the_tui_search_cache() { + let file = ChangedFile::from_status_columns( + "src/main.rs".to_string(), + None, + StatusKind::Modified, + StatusKind::Unmodified, + ); + assert!( + !file.search_lower.is_empty(), + "precondition: the internal type carries a search cache" + ); + + let value = json(&ChangedFileDto::from(&file)); + + assert_eq!(value["path"], "src/main.rs"); + assert_eq!(value["index"], "M"); + assert_eq!(value["worktree"], " "); + assert!( + value.get("search_lower").is_none(), + "the filter cache must not reach the wire: {value}" + ); + assert!( + value.get("old_path").is_none(), + "an absent rename source is omitted, not null" + ); +} + +#[test] +fn changed_file_dto_keeps_a_rename_source() { + let file = ChangedFile::from_status_columns( + "new.rs".to_string(), + Some("old.rs".to_string()), + StatusKind::Renamed, + StatusKind::Unmodified, + ); + + let value = json(&ChangedFileDto::from(&file)); + + assert_eq!(value["old_path"], "old.rs"); + assert_eq!(value["index"], "R"); +} + +#[test] +fn commit_dto_drops_the_summary_cache_and_hexes_the_oid() { + let entry = CommitEntry::new( + git2::Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap(), + "1234567".to_string(), + "Fix The Bug".to_string(), + "Someone".to_string(), + 1_700_000_000, + ); + assert!(!entry.summary_lower.is_empty(), "precondition"); + + let value = json(&CommitDto::from(&entry)); + + assert_eq!(value["oid"], "1234567890abcdef1234567890abcdef12345678"); + assert_eq!(value["summary"], "Fix The Bug"); + assert!( + value.get("summary_lower").is_none(), + "the filter cache must not reach the wire: {value}" + ); +} + +#[test] +fn envelope_carries_the_protocol_version_alongside_the_payload() { + let value = json(&Envelope::new(TreeDto::from_entries("src", &[]))); + + assert_eq!(value["version"], PROTOCOL_VERSION); + assert_eq!(value["path"], "src", "the payload is flattened, not nested"); +} + +#[test] +fn status_dto_reports_truncation_past_the_ceiling() { + let files: Vec<_> = (0..limits::MAX_STATUS_FILES + 5) + .map(|i| { + ChangedFile::from_status_columns( + format!("f{i}.rs"), + None, + StatusKind::Modified, + StatusKind::Unmodified, + ) + }) + .collect(); + + let dto = StatusDto::from_snapshot(&files, None, None, Some("main"), &HashMap::new()); + + assert_eq!(dto.files.len(), limits::MAX_STATUS_FILES); + assert!(dto.truncated); + assert_eq!(dto.branch.as_deref(), Some("main")); +} + +#[test] +fn status_dto_carries_mtime_in_millis_only_for_stated_files() { + let files = vec![ + ChangedFile::from_status_columns( + "hot.rs".to_string(), + None, + StatusKind::Modified, + StatusKind::Unmodified, + ), + ChangedFile::from_status_columns( + "gone.rs".to_string(), + None, + StatusKind::Deleted, + StatusKind::Unmodified, + ), + ]; + // Deleted files never make it into the worker's mtime map, so their + // rows must simply omit the field rather than carry a stand-in age. + let mtimes = HashMap::from([( + "hot.rs".to_string(), + SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(1_500), + )]); + + let value = json(&StatusDto::from_snapshot(&files, None, None, None, &mtimes)); + + assert_eq!(value["files"][0]["mtime"], 1_500u64); + assert!(value["files"][1].get("mtime").is_none()); +} + +#[test] +fn commit_file_list_never_carries_an_mtime() { + // A commit's files describe history; the working tree's mtime would be + // unrelated to them, and a client must not be able to read one as + // "this commit touched the file just now". + let files = vec![ChangedFile::from_status_columns( + "a.rs".to_string(), + None, + StatusKind::Modified, + StatusKind::Unmodified, + )]; + + let value = json(&CommitFilesDto::from_entries(&files)); + + assert!(value["files"][0].get("mtime").is_none()); +} + +#[test] +fn status_dto_omits_absent_optional_fields() { + let value = json(&StatusDto::from_snapshot(&[], None, None, None, &HashMap::new())); + + assert!(value.get("branch").is_none()); + assert!(value.get("head").is_none()); + assert!(value.get("tracking").is_none()); + assert_eq!(value["truncated"], false); +} + +fn hunk(header: &str, lines: usize, width: usize) -> DiffHunk { + DiffHunk { + header: header.to_string(), + file_path: None, + lines: (0..lines) + .map(|_| crate::git::diff::DiffLine { + kind: LineKind::Context, + content: "x".repeat(width), + }) + .collect(), + } +} + +#[test] +fn diff_dto_maps_line_kinds_to_wire_codes() { + let hunks = vec![DiffHunk { + header: "@@ -1 +1 @@".to_string(), + file_path: Some("a.rs".to_string()), + lines: vec![ + crate::git::diff::DiffLine { + kind: LineKind::Added, + content: "new".into(), + }, + crate::git::diff::DiffLine { + kind: LineKind::Removed, + content: "old".into(), + }, + crate::git::diff::DiffLine { + kind: LineKind::Context, + content: "same".into(), + }, + ], + }]; + + let dto = DiffDto::from_hunks("a.rs", &hunks); + + let kinds: Vec<_> = dto.hunks[0].lines.iter().map(|l| l.kind.as_str()).collect(); + assert_eq!(kinds, vec!["+", "-", " "]); + assert!(!dto.truncated); +} + +#[test] +fn diff_dto_caps_across_hunks_not_within_one() { + // Each hunk is under the ceiling alone; together they exceed it. A + // per-hunk cap would let the total through unbounded. + let per_hunk = limits::MAX_DIFF_LINES / 2 + 10; + let hunks = vec![hunk("@@ a @@", per_hunk, 1), hunk("@@ b @@", per_hunk, 1)]; + + let dto = DiffDto::from_hunks("big.rs", &hunks); + + let total: usize = dto.hunks.iter().map(|h| h.lines.len()).sum(); + assert_eq!(total, limits::MAX_DIFF_LINES); + assert!(dto.truncated); +} + +#[test] +fn diff_dto_stops_on_the_byte_ceiling_before_the_line_ceiling() { + // Few lines, each enormous: the byte ceiling has to bind first. + let hunks = vec![hunk("@@ a @@", 50, limits::MAX_DIFF_BYTES / 10)]; + + let dto = DiffDto::from_hunks("wide.rs", &hunks); + + let bytes: usize = dto.hunks[0] + .lines + .iter() + .flat_map(|l| &l.spans) + .map(|s| s.t.len()) + .sum(); + assert!(bytes <= limits::MAX_DIFF_BYTES); + assert!(dto.truncated); + assert!( + dto.hunks[0].lines.len() < 50, + "the byte ceiling must cut before the line count does" + ); +} + +#[test] +fn file_dto_caps_content_on_a_character_boundary() { + let content = "한".repeat(limits::MAX_DIFF_BYTES); + + let dto = FileDto::new("big.txt", &content); + + // Reconstruct the served text from its spans (one line here — no \n). + let served: String = dto.lines.iter().flatten().map(|s| s.t.as_str()).collect(); + assert!(dto.truncated); + assert!(served.len() <= limits::MAX_DIFF_BYTES); + assert!( + content.starts_with(&served), + "the cap must yield a clean prefix" + ); +} \ No newline at end of file diff --git a/src/web/viewer/dto/tree.rs b/src/web/viewer/dto/tree.rs new file mode 100644 index 0000000..5fe2e68 --- /dev/null +++ b/src/web/viewer/dto/tree.rs @@ -0,0 +1,64 @@ +use crate::web::viewer::limits::{self, Capped}; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct TreeEntryDto { + pub name: String, + pub is_dir: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TreeDto { + pub path: String, + pub entries: Vec, + pub truncated: bool, +} + +impl TreeDto { + pub fn from_entries(path: &str, entries: &[crate::git::tree::TreeEntry]) -> Self { + let capped = Capped::new(entries.to_vec(), limits::MAX_TREE_ENTRIES); + Self { + path: path.to_string(), + entries: capped + .items + .iter() + .map(|e| TreeEntryDto { + name: e.name.clone(), + is_dir: e.is_dir, + }) + .collect(), + truncated: capped.truncated, + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct TreeMatchDto { + pub path: String, + pub is_dir: bool, +} + +/// Result of a recursive `/api/tree/search`: full paths whose basename matched +/// the query, already sorted and capped by the search walk. +#[derive(Debug, Clone, Serialize)] +pub struct TreeSearchDto { + pub query: String, + pub matches: Vec, + pub truncated: bool, +} + +impl TreeSearchDto { + pub fn new(query: &str, matches: &[crate::git::tree::TreeMatch], truncated: bool) -> Self { + Self { + query: query.to_string(), + matches: matches + .iter() + .map(|m| TreeMatchDto { + path: m.path.clone(), + is_dir: m.is_dir, + }) + .collect(), + truncated, + } + } +} \ No newline at end of file diff --git a/src/web/viewer/runtime.rs b/src/web/viewer/runtime/mod.rs similarity index 58% rename from src/web/viewer/runtime.rs rename to src/web/viewer/runtime/mod.rs index e948e60..45641df 100644 --- a/src/web/viewer/runtime.rs +++ b/src/web/viewer/runtime/mod.rs @@ -255,219 +255,4 @@ impl Drop for RepoRuntime { } #[cfg(test)] -mod tests { - use super::*; - use crate::git::diff::{ChangedFile, StatusKind}; - use std::sync::mpsc::SyncSender as StdSyncSender; - - /// Build an inert runtime plus the sender that feeds it snapshots. - fn test_runtime() -> ( - Arc, - StdSyncSender, - StdSyncSender<()>, - ) { - let (tx, rx) = mpsc::channel::(); - let (stop_tx, _stop_rx) = mpsc::sync_channel::<()>(0); - let channel = SnapshotChannel::from_endpoints(rx, stop_tx.clone()); - let runtime = RepoRuntime::start(channel, "test".to_string()); - // The mpsc Sender is not a SyncSender; wrap through a relay so the test - // helper returns one uniform type. - let (relay_tx, relay_rx) = mpsc::sync_channel::(16); - thread::spawn(move || { - while let Ok(msg) = relay_rx.recv() { - if tx.send(msg).is_err() { - break; - } - } - }); - (runtime, relay_tx, stop_tx) - } - - fn snapshot(branch: &str, files: usize) -> RepoSnapshot { - RepoSnapshot { - files: (0..files) - .map(|i| { - ChangedFile::from_status_columns( - format!("f{i}.rs"), - None, - StatusKind::Modified, - StatusKind::Unmodified, - ) - }) - .collect(), - tracking: None, - head_oid: None, - branch_name: Some(branch.to_string()), - } - } - - /// Poll until `check` passes or the budget runs out. The runtime thread - /// wakes on its own schedule, so tests wait on the condition, not a sleep. - fn wait_for(mut check: impl FnMut() -> bool) -> bool { - for _ in 0..100 { - if check() { - return true; - } - thread::sleep(Duration::from_millis(20)); - } - false - } - - #[test] - fn runtime_publishes_a_snapshot_as_a_versioned_payload() { - let (runtime, tx, _stop) = test_runtime(); - - tx.send(SnapshotMsg::Ok(snapshot("main", 2), Default::default())) - .unwrap(); - - assert!( - wait_for(|| runtime.latest().is_some()), - "no status published" - ); - let update = runtime.latest().unwrap(); - let value: serde_json::Value = serde_json::from_str(&update.json).unwrap(); - assert_eq!(value["version"], crate::web::viewer::dto::PROTOCOL_VERSION); - assert_eq!(value["branch"], "main"); - assert_eq!(value["files"].as_array().unwrap().len(), 2); - runtime.stop(); - } - - #[test] - fn a_subscriber_is_seeded_with_the_current_status() { - let (runtime, tx, _stop) = test_runtime(); - tx.send(SnapshotMsg::Ok(snapshot("main", 1), Default::default())) - .unwrap(); - assert!(wait_for(|| runtime.latest().is_some())); - - // Subscribing after the fact must not wait for the next change. - let subscription = runtime.subscribe(); - let update = subscription.next_update(Duration::from_millis(50)); - - assert!( - update.is_some(), - "a fresh subscriber must replay the latest" - ); - runtime.stop(); - } - - #[test] - fn a_slow_subscriber_gets_the_newest_status_not_a_backlog() { - let (runtime, tx, _stop) = test_runtime(); - let subscription = runtime.subscribe(); - - // Publish several without ever reading, then read once. - for i in 0..5 { - tx.send(SnapshotMsg::Ok( - snapshot(&format!("b{i}"), 1), - Default::default(), - )) - .unwrap(); - assert!(wait_for(|| runtime - .latest() - .is_some_and(|u| u.json.contains(&format!("b{i}"))))); - } - - let update = subscription.next_update(Duration::from_millis(50)).unwrap(); - assert!( - update.json.contains("b4"), - "a slow reader must land on the newest, got: {}", - update.json - ); - assert!( - subscription - .next_update(Duration::from_millis(50)) - .is_none(), - "no stale backlog may remain" - ); - runtime.stop(); - } - - #[test] - fn an_unchanged_snapshot_is_not_republished() { - // The producer ticks on a timer; an idle repository must stay silent - // rather than re-emitting the same status every second. - let (runtime, tx, _stop) = test_runtime(); - tx.send(SnapshotMsg::Ok(snapshot("main", 1), Default::default())) - .unwrap(); - assert!(wait_for(|| runtime.latest().is_some())); - let first_seq = runtime.latest().unwrap().seq; - let subscription = runtime.subscribe(); - assert!( - subscription - .next_update(Duration::from_millis(50)) - .is_some() - ); - - for _ in 0..3 { - tx.send(SnapshotMsg::Ok(snapshot("main", 1), Default::default())) - .unwrap(); - } - thread::sleep(Duration::from_millis(200)); - - assert_eq!( - runtime.latest().unwrap().seq, - first_seq, - "an identical snapshot must not burn a sequence number" - ); - assert!( - subscription - .next_update(Duration::from_millis(50)) - .is_none(), - "an idle repository must not wake its subscribers" - ); - runtime.stop(); - } - - #[test] - fn sequence_numbers_increase_across_updates() { - let (runtime, tx, _stop) = test_runtime(); - - tx.send(SnapshotMsg::Ok(snapshot("one", 1), Default::default())) - .unwrap(); - assert!(wait_for(|| runtime.latest().is_some())); - let first = runtime.latest().unwrap().seq; - tx.send(SnapshotMsg::Ok(snapshot("two", 1), Default::default())) - .unwrap(); - assert!(wait_for(|| runtime.latest().is_some_and(|u| u.seq > first))); - - assert_eq!(runtime.latest().unwrap().seq, first + 1); - runtime.stop(); - } - - #[test] - fn dropping_a_subscription_unregisters_it() { - let (runtime, _tx, _stop) = test_runtime(); - - let subscription = runtime.subscribe(); - assert_eq!(runtime.subscriber_count(), 1); - drop(subscription); - - assert_eq!( - runtime.subscriber_count(), - 0, - "a dropped client must not keep receiving fan-out" - ); - runtime.stop(); - } - - #[test] - fn next_update_times_out_when_nothing_changes() { - let (runtime, _tx, _stop) = test_runtime(); - let subscription = runtime.subscribe(); - - // No snapshot has ever been published, so there is nothing to seed with. - assert!( - subscription - .next_update(Duration::from_millis(30)) - .is_none() - ); - runtime.stop(); - } - - #[test] - fn stop_is_idempotent() { - let (runtime, _tx, _stop) = test_runtime(); - runtime.stop(); - runtime.stop(); - } -} +mod runtime_tests; \ No newline at end of file diff --git a/src/web/viewer/runtime/runtime_tests.rs b/src/web/viewer/runtime/runtime_tests.rs new file mode 100644 index 0000000..e99055d --- /dev/null +++ b/src/web/viewer/runtime/runtime_tests.rs @@ -0,0 +1,214 @@ +use super::*; +use crate::git::diff::{ChangedFile, StatusKind}; +use std::sync::mpsc::SyncSender as StdSyncSender; + +/// Build an inert runtime plus the sender that feeds it snapshots. +fn test_runtime() -> ( + Arc, + StdSyncSender, + StdSyncSender<()>, +) { + let (tx, rx) = mpsc::channel::(); + let (stop_tx, _stop_rx) = mpsc::sync_channel::<()>(0); + let channel = SnapshotChannel::from_endpoints(rx, stop_tx.clone()); + let runtime = RepoRuntime::start(channel, "test".to_string()); + // The mpsc Sender is not a SyncSender; wrap through a relay so the test + // helper returns one uniform type. + let (relay_tx, relay_rx) = mpsc::sync_channel::(16); + thread::spawn(move || { + while let Ok(msg) = relay_rx.recv() { + if tx.send(msg).is_err() { + break; + } + } + }); + (runtime, relay_tx, stop_tx) +} + +fn snapshot(branch: &str, files: usize) -> RepoSnapshot { + RepoSnapshot { + files: (0..files) + .map(|i| { + ChangedFile::from_status_columns( + format!("f{i}.rs"), + None, + StatusKind::Modified, + StatusKind::Unmodified, + ) + }) + .collect(), + tracking: None, + head_oid: None, + branch_name: Some(branch.to_string()), + } +} + +/// Poll until `check` passes or the budget runs out. The runtime thread +/// wakes on its own schedule, so tests wait on the condition, not a sleep. +fn wait_for(mut check: impl FnMut() -> bool) -> bool { + for _ in 0..100 { + if check() { + return true; + } + thread::sleep(Duration::from_millis(20)); + } + false +} + +#[test] +fn runtime_publishes_a_snapshot_as_a_versioned_payload() { + let (runtime, tx, _stop) = test_runtime(); + + tx.send(SnapshotMsg::Ok(snapshot("main", 2), Default::default())) + .unwrap(); + + assert!( + wait_for(|| runtime.latest().is_some()), + "no status published" + ); + let update = runtime.latest().unwrap(); + let value: serde_json::Value = serde_json::from_str(&update.json).unwrap(); + assert_eq!(value["version"], crate::web::viewer::dto::PROTOCOL_VERSION); + assert_eq!(value["branch"], "main"); + assert_eq!(value["files"].as_array().unwrap().len(), 2); + runtime.stop(); +} + +#[test] +fn a_subscriber_is_seeded_with_the_current_status() { + let (runtime, tx, _stop) = test_runtime(); + tx.send(SnapshotMsg::Ok(snapshot("main", 1), Default::default())) + .unwrap(); + assert!(wait_for(|| runtime.latest().is_some())); + + // Subscribing after the fact must not wait for the next change. + let subscription = runtime.subscribe(); + let update = subscription.next_update(Duration::from_millis(50)); + + assert!( + update.is_some(), + "a fresh subscriber must replay the latest" + ); + runtime.stop(); +} + +#[test] +fn a_slow_subscriber_gets_the_newest_status_not_a_backlog() { + let (runtime, tx, _stop) = test_runtime(); + let subscription = runtime.subscribe(); + + // Publish several without ever reading, then read once. + for i in 0..5 { + tx.send(SnapshotMsg::Ok( + snapshot(&format!("b{i}"), 1), + Default::default(), + )) + .unwrap(); + assert!(wait_for(|| runtime + .latest() + .is_some_and(|u| u.json.contains(&format!("b{i}"))))); + } + + let update = subscription.next_update(Duration::from_millis(50)).unwrap(); + assert!( + update.json.contains("b4"), + "a slow reader must land on the newest, got: {}", + update.json + ); + assert!( + subscription + .next_update(Duration::from_millis(50)) + .is_none(), + "no stale backlog may remain" + ); + runtime.stop(); +} + +#[test] +fn an_unchanged_snapshot_is_not_republished() { + // The producer ticks on a timer; an idle repository must stay silent + // rather than re-emitting the same status every second. + let (runtime, tx, _stop) = test_runtime(); + tx.send(SnapshotMsg::Ok(snapshot("main", 1), Default::default())) + .unwrap(); + assert!(wait_for(|| runtime.latest().is_some())); + let first_seq = runtime.latest().unwrap().seq; + let subscription = runtime.subscribe(); + assert!( + subscription + .next_update(Duration::from_millis(50)) + .is_some() + ); + + for _ in 0..3 { + tx.send(SnapshotMsg::Ok(snapshot("main", 1), Default::default())) + .unwrap(); + } + thread::sleep(Duration::from_millis(200)); + + assert_eq!( + runtime.latest().unwrap().seq, + first_seq, + "an identical snapshot must not burn a sequence number" + ); + assert!( + subscription + .next_update(Duration::from_millis(50)) + .is_none(), + "an idle repository must not wake its subscribers" + ); + runtime.stop(); +} + +#[test] +fn sequence_numbers_increase_across_updates() { + let (runtime, tx, _stop) = test_runtime(); + + tx.send(SnapshotMsg::Ok(snapshot("one", 1), Default::default())) + .unwrap(); + assert!(wait_for(|| runtime.latest().is_some())); + let first = runtime.latest().unwrap().seq; + tx.send(SnapshotMsg::Ok(snapshot("two", 1), Default::default())) + .unwrap(); + assert!(wait_for(|| runtime.latest().is_some_and(|u| u.seq > first))); + + assert_eq!(runtime.latest().unwrap().seq, first + 1); + runtime.stop(); +} + +#[test] +fn dropping_a_subscription_unregisters_it() { + let (runtime, _tx, _stop) = test_runtime(); + + let subscription = runtime.subscribe(); + assert_eq!(runtime.subscriber_count(), 1); + drop(subscription); + + assert_eq!( + runtime.subscriber_count(), + 0, + "a dropped client must not keep receiving fan-out" + ); + runtime.stop(); +} + +#[test] +fn next_update_times_out_when_nothing_changes() { + let (runtime, _tx, _stop) = test_runtime(); + let subscription = runtime.subscribe(); + + // No snapshot has ever been published, so there is nothing to seed with. + assert!( + subscription + .next_update(Duration::from_millis(30)) + .is_none() + ); + runtime.stop(); +} + +#[test] +fn stop_is_idempotent() { + let (runtime, _tx, _stop) = test_runtime(); + runtime.stop(); + runtime.stop(); +} \ No newline at end of file diff --git a/src/web/viewer/server/dispatch.rs b/src/web/viewer/server/dispatch.rs new file mode 100644 index 0000000..321d24f --- /dev/null +++ b/src/web/viewer/server/dispatch.rs @@ -0,0 +1,170 @@ +use super::http_util::{json_error, json_response, text_response}; +use super::mutations::{handle_close_repo, handle_mkdir, handle_open_repo, handle_set_prefs}; +use super::routes::route; +use super::{ViewerState, VIEWER_SESSION_COOKIE}; +use crate::web::common::conn::{self, ConnectionSlot}; +use crate::web::common::http::{self, RequestHead}; +use crate::web::viewer::assets; +use crate::web::viewer::limits; +use std::io::Write; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use std::time::Instant; + +pub(super) fn accept_loop(listener: TcpListener, state: Arc) { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let Some(slot) = + ConnectionSlot::acquire(&state.connections, limits::MAX_VIEWER_CONNECTIONS) + else { + tracing::debug!("viewer: refusing connection over cap"); + continue; + }; + let state = Arc::clone(&state); + let _ = std::thread::Builder::new() + .name("nightcrow-viewer-conn".into()) + .spawn(move || { + let _slot = slot; + handle_connection(stream, state) + }); + } +} + +fn handle_connection(mut stream: TcpStream, state: Arc) { + let (head, body) = match conn::read_request(&mut stream) { + Ok(v) => v, + Err(err) => { + tracing::debug!(%err, "viewer: dropping malformed request"); + return; + } + }; + + // (1) Host, then Origin — both before anything reads state. Origin only + // proves the two agree, which a DNS-rebound attacker controls outright. + if !conn::host_allowed(&head, state.bound_loopback) { + let _ = stream.write_all(&text_response("403 Forbidden", "unexpected host")); + return; + } + if !conn::origin_allowed(&head) { + let _ = stream.write_all(&text_response( + "403 Forbidden", + "cross-origin request rejected", + )); + return; + } + + // The login form and its POST are the only routes reachable unauthenticated. + match (head.method.as_str(), head.path.as_str()) { + ("POST", "/login") => { + let _ = stream.write_all(&handle_login(&body, &state)); + return; + } + ("GET", "/logout") => { + // Revoke server-side, not just in the browser: cookies are not + // port-isolated, so any other loopback service is same-site here + // and could have read the token before it was cleared. + if let Some(token) = head.cookie(VIEWER_SESSION_COOKIE) { + state.sessions.revoke(token); + } + let clear = + format!("{VIEWER_SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"); + let _ = stream.write_all(&http::redirect("/", &[("Set-Cookie", &clear)])); + return; + } + _ => {} + } + + // (2) The static bundle is served unauthenticated. It carries no + // repository data — it is the shell that renders the login form and then + // calls the API, which *is* gated. Requiring a session to fetch it would + // mean the user could never reach a login screen at all. + if head.method == "GET" && !head.path.starts_with("/api/") && head.path != "/ws/term" { + let _ = stream.write_all( + &assets::serve(&head.path) + .unwrap_or_else(|| text_response("404 Not Found", "frontend not built")), + ); + return; + } + + // (3) Auth, before any repository is named or looked up. + if !is_authenticated(&head, &state) { + let _ = stream.write_all(&match head.path.starts_with("/api/") { + true => json_error("401 Unauthorized", "authentication required"), + false => text_response("401 Unauthorized", "authentication required"), + }); + return; + } + + // SSE takes over the socket rather than returning a body. + if head.method == "GET" && head.path == "/api/events" { + super::handlers::serve_events(stream, &head, &state); + return; + } + + if head.path == "/ws/term" && head.is_websocket_upgrade() { + super::handlers::serve_terminal(stream, &head, &state); + return; + } + + // Opening a repository is the one state-changing route. It is a POST, so a + // cross-site page cannot trigger it (Origin was already checked, and the + // session cookie is SameSite=Strict). An authenticated user can already + // open a shell here, so pointing the viewer at another local directory + // stays within the same trust boundary. + if head.method == "POST" && head.path == "/api/repos" { + let _ = stream.write_all(&handle_open_repo(&body, &state)); + return; + } + + // Storing a viewer preference. POST for the same reason opening a + // repository is: a cross-site page cannot trigger it. + if head.method == "POST" && head.path == "/api/prefs" { + let _ = stream.write_all(&handle_set_prefs(&body, &state)); + return; + } + + // Creating a folder inside a browsed directory. POST for the same CSRF + // reasoning as the others; the new name is validated to a single path + // segment so it can only land under the directory the picker is showing. + if head.method == "POST" && head.path == "/api/mkdir" { + let _ = stream.write_all(&handle_mkdir(&body)); + return; + } + + // Closing a repository: same trust reasoning as opening. Removes it from + // the served set and stops its runtime and terminals. + if head.method == "DELETE" && head.path == "/api/repos" { + let _ = stream.write_all(&handle_close_repo(&head, &state)); + return; + } + + let _ = stream.write_all(&route(&head, &state)); +} + +fn is_authenticated(head: &RequestHead, state: &ViewerState) -> bool { + head.cookie(VIEWER_SESSION_COOKIE) + .is_some_and(|token| state.sessions.is_valid(token)) +} + +fn handle_login(body: &str, state: &ViewerState) -> Vec { + if !state.limiter.check_and_record(Instant::now()) { + return json_error("429 Too Many Requests", "too many attempts"); + } + let fields = http::parse_form(body); + let password = http::form_field(&fields, "password").unwrap_or(""); + if !state.auth.verify(password) { + return json_error("401 Unauthorized", "incorrect password"); + } + match state.sessions.issue() { + Ok(token) => { + let cookie = format!( + "{VIEWER_SESSION_COOKIE}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400" + ); + json_response("200 OK", "{\"ok\":true}", &[("Set-Cookie", &cookie)]) + } + Err(err) => { + tracing::error!(%err, "viewer: could not issue a session token"); + json_error("500 Internal Server Error", "could not start a session") + } + } +} \ No newline at end of file diff --git a/src/web/viewer/server/handlers.rs b/src/web/viewer/server/handlers.rs new file mode 100644 index 0000000..9dd7c6e --- /dev/null +++ b/src/web/viewer/server/handlers.rs @@ -0,0 +1,235 @@ +use super::http_util::json_error; +use super::mutations::{lookup_repo, redact}; +use super::{ViewerState, SSE_HEARTBEAT, TERM_POLL_TIMEOUT}; +use crate::web::common::conn; +use crate::web::common::sse::SseStream; +use crate::web::viewer::catalog::RepoEntry; +use crate::web::viewer::dto::Envelope; +use crate::web::viewer::terminal::{self, ClientMessage, TerminalFrame}; +use anyhow::{Context, Result}; +use std::io::Write; +use std::net::TcpStream; +use std::time::Duration; +use tungstenite::Message; + +/// 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. Not every downstream touches the filesystem — `load_file_diff` passes +/// the path to git as a pathspec — but a route must not be safe only by +/// accident of which loader it happens to call. A traversal path is refused +/// uniformly, and never echoed back in a response. +pub(super) fn with_repo( + head: &crate::web::common::http::RequestHead, + state: &ViewerState, + body: impl FnOnce(&RepoEntry) -> Result>, +) -> Vec { + let entry = match lookup_repo(head, state) { + Ok(entry) => entry, + Err(response) => return response, + }; + // An absent or empty `path` means "the repository root" for the routes that + // accept one; anything else has to survive the gate. + if let Some(path) = head.query_param("path").filter(|p| !p.is_empty()) + && let Err(err) = + crate::git::path::resolve_in_workdir(std::path::Path::new(&entry.path), &path) + { + tracing::debug!(%err, route = %head.path, "viewer: rejected path parameter"); + return json_error("400 Bad Request", "invalid path"); + } + match body(&entry) { + Ok(response) => response, + Err(err) => redact(&head.path, &err), + } +} + +/// Variant of [`with_repo`] for a path inside a historical git object. +/// +/// A deleted commit path cannot be resolved in the current worktree, so this +/// validates its syntax without statting it. The route passes it only to an +/// exact git pathspec; it never opens a filesystem path. +pub(super) fn with_repo_commit_path( + head: &crate::web::common::http::RequestHead, + state: &ViewerState, + body: impl FnOnce(&RepoEntry, &str) -> Result>, +) -> Vec { + let entry = match lookup_repo(head, state) { + Ok(entry) => entry, + Err(response) => return response, + }; + let path = match required_path(head) { + Ok(path) => path, + Err(err) => return redact(&head.path, &err), + }; + if let Err(err) = crate::git::path::validate_commit_path(&path) { + tracing::debug!(%err, route = %head.path, "viewer: rejected historical path parameter"); + return json_error("400 Bad Request", "invalid path"); + } + match body(&entry, &path) { + Ok(response) => response, + Err(err) => redact(&head.path, &err), + } +} + +pub(super) fn required_path(head: &crate::web::common::http::RequestHead) -> Result { + head.query_param("path") + .filter(|p| !p.is_empty()) + .ok_or_else(|| anyhow::anyhow!("missing path parameter")) +} + +/// An oid query parameter that may be absent, but must parse when present. +/// +/// Absent and malformed are kept apart deliberately: silently walking from HEAD +/// after a typo would answer a different question than the one asked, and the +/// client pages against the value it gets back. +pub(super) fn optional_oid( + head: &crate::web::common::http::RequestHead, + name: &str, +) -> Result> { + match head.query_param(name) { + None => Ok(None), + Some(text) => git2::Oid::from_str(&text) + .map(Some) + .with_context(|| format!("malformed {name} parameter")), + } +} + +/// A non-negative count query parameter, defaulting to zero when absent. +/// +/// Deliberately unbounded — see the note beside [`limits::MAX_LOG_PAGE`]. A +/// negative or non-numeric value still fails to parse, so the guard that +/// matters is here. +pub(super) fn optional_count( + head: &crate::web::common::http::RequestHead, + name: &str, +) -> Result { + let Some(text) = head.query_param(name) else { + return Ok(0); + }; + text.parse() + .with_context(|| format!("malformed {name} parameter")) +} + +pub(super) fn required_oid(head: &crate::web::common::http::RequestHead) -> Result { + let oid_text = head + .query_param("oid") + .ok_or_else(|| anyhow::anyhow!("missing oid parameter"))?; + git2::Oid::from_str(&oid_text).context("malformed oid") +} + +pub(super) fn open_repo(path: &str) -> Result { + git2::Repository::discover(path).context("failed to open repository") +} + +pub(super) fn encode(payload: &T) -> Result { + serde_json::to_string(&Envelope::new(payload)).context("failed to encode payload") +} + +/// Hold the connection open and stream this repository's status. +pub(super) fn serve_events( + mut stream: TcpStream, + head: &crate::web::common::http::RequestHead, + state: &ViewerState, +) { + let entry = match lookup_repo(head, state) { + Ok(entry) => entry, + Err(response) => { + let _ = stream.write_all(&response); + return; + } + }; + // A stalled reader must not wedge the handler thread forever. + let _ = stream.set_write_timeout(Some(SSE_HEARTBEAT)); + + let subscription = entry.runtime.subscribe(); + let Ok(mut sse) = SseStream::start(stream) else { + return; + }; + loop { + match subscription.next_update(SSE_HEARTBEAT) { + Some(update) => { + if sse.send("status", &update.json).is_err() { + break; + } + } + // Nothing changed: prove the socket is still alive. This is the + // only way a closed tab is discovered. + None => { + if sse.heartbeat().is_err() { + break; + } + } + } + } + // `subscription` drops here, unregistering from the fan-out. +} + +/// Hand this connection to the repository's terminal hub. +/// +/// Auth and Origin were already enforced by `handle_connection`, before the +/// repository was named — a terminal is effectively a shell, so the upgrade +/// must never be reachable ahead of those checks. +pub(super) fn serve_terminal( + stream: TcpStream, + head: &crate::web::common::http::RequestHead, + state: &ViewerState, +) { + let mut stream = stream; + let entry = match lookup_repo(head, state) { + Ok(entry) => entry, + Err(response) => { + let _ = stream.write_all(&response); + return; + } + }; + // Without a read timeout, `ws.read()` blocks and terminal output would + // only flush when the user happened to type. The timeout turns the loop + // into a poll that services both directions. + let _ = stream.set_read_timeout(Some(TERM_POLL_TIMEOUT)); + let _ = stream.set_write_timeout(Some(SSE_HEARTBEAT)); + let Some(mut ws) = conn::websocket_handshake(stream, head) else { + return; + }; + let session = entry.terminals.connect(); + + loop { + // Drain everything queued for us before blocking on the socket, so + // output is not held back waiting for the client to say something. + let mut wrote = false; + while let Some(frame) = session.next_frame(Duration::from_millis(1)) { + let message = match frame { + TerminalFrame::Output { pane, data } => { + Message::Binary(terminal::encode_output(pane, &data).into()) + } + TerminalFrame::Control(json) => Message::Text(json.into()), + }; + if ws.send(message).is_err() { + return; + } + wrote = true; + } + if wrote && ws.flush().is_err() { + return; + } + + match ws.read() { + Ok(Message::Text(text)) => match serde_json::from_str::(&text) { + Ok(message) => session.dispatch(message), + // A malformed frame is dropped, not fatal: a client bug should + // not take the terminal down with it. + Err(err) => tracing::debug!(%err, "viewer: bad terminal message"), + }, + Ok(Message::Close(_)) => return, + Ok(_) => {} + // A poll timeout surfaces as WouldBlock on macOS and TimedOut on + // Linux; neither means the client is gone. + Err(tungstenite::Error::Io(err)) + if matches!( + err.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => {} + Err(_) => return, + } + } + // `session` drops here, unregistering from the hub. +} \ No newline at end of file diff --git a/src/web/viewer/server/http_util.rs b/src/web/viewer/server/http_util.rs new file mode 100644 index 0000000..0379d63 --- /dev/null +++ b/src/web/viewer/server/http_util.rs @@ -0,0 +1,28 @@ +use crate::web::common::http; + +pub(super) fn json_response(status: &str, body: &str, extra: &[(&str, &str)]) -> Vec { + let mut headers = vec![("X-Content-Type-Options", "nosniff")]; + headers.extend_from_slice(extra); + http::response( + status, + "application/json; charset=utf-8", + &headers, + body.as_bytes(), + ) +} + +pub(super) fn json_error(status: &str, message: &str) -> Vec { + // Message is always a fixed literal from this module, never interpolated + // from an error, so it needs no escaping and can leak nothing. + let body = format!("{{\"error\":\"{message}\"}}"); + json_response(status, &body, &[]) +} + +pub(super) fn text_response(status: &str, message: &str) -> Vec { + http::response( + status, + "text/plain; charset=utf-8", + &[("X-Content-Type-Options", "nosniff")], + message.as_bytes(), + ) +} \ No newline at end of file diff --git a/src/web/viewer/server/mod.rs b/src/web/viewer/server/mod.rs index baeaef1..7951c15 100644 --- a/src/web/viewer/server/mod.rs +++ b/src/web/viewer/server/mod.rs @@ -26,27 +26,19 @@ //! targets, and file sizes, so handlers map them to a fixed public string and //! log the detail server-side. -use crate::git::diff; +mod dispatch; +mod handlers; +mod http_util; +mod mutations; +mod routes; + use crate::web::common::auth::{Auth, RateLimiter, SessionStore}; -use crate::web::common::conn::{self, ConnectionSlot}; -use crate::web::common::http::{self, RequestHead}; -use crate::web::common::sse::SseStream; -use crate::web::viewer::assets; -use crate::web::viewer::catalog::{AddOutcome, Catalog, RepoEntry}; -use crate::web::viewer::dto::{ - BrowseDto, BrowseEntryDto, CommitFilesDto, DiffDto, Envelope, FileDto, HotConfigDto, LogDto, - StatusDto, TreeDto, TreeSearchDto, ViewerBootstrapDto, -}; -use crate::web::viewer::limits; use crate::web::viewer::prefs::PrefsStore; -use crate::web::viewer::terminal::{self, ClientMessage, TerminalFrame}; use anyhow::{Context, Result}; -use std::io::Write; -use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; +use std::net::{IpAddr, SocketAddr, TcpListener}; use std::sync::Arc; use std::sync::atomic::AtomicUsize; -use std::time::{Duration, Instant}; -use tungstenite::Message; +use std::time::Duration; /// Distinct from the mirror's cookie: same host, different servers, so a /// session for one must not authenticate against the other. @@ -55,36 +47,36 @@ pub const VIEWER_SESSION_COOKIE: &str = "nightcrow_viewer_session"; /// How long an idle SSE stream waits before sending a heartbeat. Short enough /// that a dead socket is noticed promptly, since a write is the only way to /// find out. -const SSE_HEARTBEAT: Duration = Duration::from_secs(15); +pub(super) const SSE_HEARTBEAT: Duration = Duration::from_secs(15); /// Read timeout on a terminal socket. Bounds how long queued output waits /// behind a blocked read; terminal latency is felt directly. -const TERM_POLL_TIMEOUT: Duration = Duration::from_millis(10); +pub(super) const TERM_POLL_TIMEOUT: Duration = Duration::from_millis(10); pub struct ViewerState { - pub catalog: Catalog, + pub(super) catalog: crate::web::viewer::catalog::Catalog, /// Whether the listener is on a loopback address. Gates the Host check: /// off-loopback, the operator owns the network path and may front this /// with a proxy under any name. - bound_loopback: bool, - auth: Auth, - sessions: SessionStore, - limiter: RateLimiter, - connections: Arc, + pub(super) bound_loopback: bool, + pub(super) auth: Auth, + pub(super) sessions: SessionStore, + pub(super) limiter: RateLimiter, + pub(super) connections: Arc, /// Whether catalog changes are mirrored to the shared workspace file. On in /// headless `serve` (so opens/closes are remembered), off alongside the TUI /// (which owns that file). - persist: bool, + pub(super) persist: bool, /// The TUI's recently-touched settings, served to the client so the file /// list fades on the same window the TUI does instead of a second, silently /// diverging default. `auto_follow` is not sent: it moves the TUI's /// selection, which is a keyboard-driven notion the viewer has no analogue /// for. - hot: crate::config::AgentIndicatorConfig, + pub(super) hot: crate::config::AgentIndicatorConfig, /// Viewer preferences shared by every client (see `prefs.rs`). Unlike the /// catalog's `persist`, this is always written: the file is the viewer's /// own and no TUI owns it. - prefs: PrefsStore, + pub(super) prefs: PrefsStore, } pub struct ViewerServer { @@ -165,7 +157,7 @@ impl ViewerServer { .unwrap_or_else(|_| SocketAddr::new(bind, port)); let state = Arc::new(ViewerState { - catalog: Catalog::with_startup(startup_commands), + catalog: crate::web::viewer::catalog::Catalog::with_startup(startup_commands), bound_loopback: bind.is_loopback(), auth, sessions: SessionStore::new(), @@ -180,7 +172,7 @@ impl ViewerServer { let accept_state = Arc::clone(&state); std::thread::Builder::new() .name("nightcrow-viewer-accept".into()) - .spawn(move || accept_loop(listener, accept_state)) + .spawn(move || dispatch::accept_loop(listener, accept_state)) .context("spawning viewer accept thread")?; Ok(Self { state, addr }) @@ -207,2050 +199,5 @@ impl Drop for ViewerServer { } } -fn accept_loop(listener: TcpListener, state: Arc) { - for stream in listener.incoming() { - let Ok(stream) = stream else { continue }; - let Some(slot) = - ConnectionSlot::acquire(&state.connections, limits::MAX_VIEWER_CONNECTIONS) - else { - tracing::debug!("viewer: refusing connection over cap"); - continue; - }; - let state = Arc::clone(&state); - let _ = std::thread::Builder::new() - .name("nightcrow-viewer-conn".into()) - .spawn(move || { - let _slot = slot; - handle_connection(stream, state) - }); - } -} - -fn handle_connection(mut stream: TcpStream, state: Arc) { - let (head, body) = match conn::read_request(&mut stream) { - Ok(v) => v, - Err(err) => { - tracing::debug!(%err, "viewer: dropping malformed request"); - return; - } - }; - - // (1) Host, then Origin — both before anything reads state. Origin only - // proves the two agree, which a DNS-rebound attacker controls outright. - if !conn::host_allowed(&head, state.bound_loopback) { - let _ = stream.write_all(&text_response("403 Forbidden", "unexpected host")); - return; - } - if !conn::origin_allowed(&head) { - let _ = stream.write_all(&text_response( - "403 Forbidden", - "cross-origin request rejected", - )); - return; - } - - // The login form and its POST are the only routes reachable unauthenticated. - match (head.method.as_str(), head.path.as_str()) { - ("POST", "/login") => { - let _ = stream.write_all(&handle_login(&body, &state)); - return; - } - ("GET", "/logout") => { - // Revoke server-side, not just in the browser: cookies are not - // port-isolated, so any other loopback service is same-site here - // and could have read the token before it was cleared. - if let Some(token) = head.cookie(VIEWER_SESSION_COOKIE) { - state.sessions.revoke(token); - } - let clear = - format!("{VIEWER_SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"); - let _ = stream.write_all(&http::redirect("/", &[("Set-Cookie", &clear)])); - return; - } - _ => {} - } - - // (2) The static bundle is served unauthenticated. It carries no - // repository data — it is the shell that renders the login form and then - // calls the API, which *is* gated. Requiring a session to fetch it would - // mean the user could never reach a login screen at all. - if head.method == "GET" && !head.path.starts_with("/api/") && head.path != "/ws/term" { - let _ = stream.write_all( - &assets::serve(&head.path) - .unwrap_or_else(|| text_response("404 Not Found", "frontend not built")), - ); - return; - } - - // (3) Auth, before any repository is named or looked up. - if !is_authenticated(&head, &state) { - let _ = stream.write_all(&match head.path.starts_with("/api/") { - true => json_error("401 Unauthorized", "authentication required"), - false => text_response("401 Unauthorized", "authentication required"), - }); - return; - } - - // SSE takes over the socket rather than returning a body. - if head.method == "GET" && head.path == "/api/events" { - serve_events(stream, &head, &state); - return; - } - - if head.path == "/ws/term" && head.is_websocket_upgrade() { - serve_terminal(stream, &head, &state); - return; - } - - // Opening a repository is the one state-changing route. It is a POST, so a - // cross-site page cannot trigger it (Origin was already checked, and the - // session cookie is SameSite=Strict). An authenticated user can already - // open a shell here, so pointing the viewer at another local directory - // stays within the same trust boundary. - if head.method == "POST" && head.path == "/api/repos" { - let _ = stream.write_all(&handle_open_repo(&body, &state)); - return; - } - - // Storing a viewer preference. POST for the same reason opening a - // repository is: a cross-site page cannot trigger it. - if head.method == "POST" && head.path == "/api/prefs" { - let _ = stream.write_all(&handle_set_prefs(&body, &state)); - return; - } - - // Creating a folder inside a browsed directory. POST for the same CSRF - // reasoning as the others; the new name is validated to a single path - // segment so it can only land under the directory the picker is showing. - if head.method == "POST" && head.path == "/api/mkdir" { - let _ = stream.write_all(&handle_mkdir(&body)); - return; - } - - // Closing a repository: same trust reasoning as opening. Removes it from - // the served set and stops its runtime and terminals. - if head.method == "DELETE" && head.path == "/api/repos" { - let _ = stream.write_all(&handle_close_repo(&head, &state)); - return; - } - - let _ = stream.write_all(&route(&head, &state)); -} - -fn is_authenticated(head: &RequestHead, state: &ViewerState) -> bool { - head.cookie(VIEWER_SESSION_COOKIE) - .is_some_and(|token| state.sessions.is_valid(token)) -} - -fn handle_login(body: &str, state: &ViewerState) -> Vec { - if !state.limiter.check_and_record(Instant::now()) { - return json_error("429 Too Many Requests", "too many attempts"); - } - let fields = http::parse_form(body); - let password = http::form_field(&fields, "password").unwrap_or(""); - if !state.auth.verify(password) { - return json_error("401 Unauthorized", "incorrect password"); - } - match state.sessions.issue() { - Ok(token) => { - let cookie = format!( - "{VIEWER_SESSION_COOKIE}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400" - ); - json_response("200 OK", "{\"ok\":true}", &[("Set-Cookie", &cookie)]) - } - Err(err) => { - tracing::error!(%err, "viewer: could not issue a session token"); - json_error("500 Internal Server Error", "could not start a session") - } - } -} - -#[derive(serde::Deserialize)] -struct OpenRequest { - path: String, -} - -#[derive(serde::Deserialize)] -struct PrefsRequest { - /// Each preference is optional so one write touches one setting and leaves - /// the rest as they are; a body naming none is rejected rather than treated - /// as a silent no-op. - accent: Option, - sidebar_width: Option, -} - -/// Store one or more viewer preferences and echo back the full stored set. -/// -/// Each value is wrapped or clamped into range rather than rejected — an accent -/// index past the end of the cycle gets a colour back (as the TUI does with -/// `Accent::from_index`), and a width past the bounds gets a usable split back — -/// so a client that drifts out of range self-corrects from the response. -fn handle_set_prefs(body: &str, state: &ViewerState) -> Vec { - let request: PrefsRequest = match serde_json::from_str(body) { - Ok(request) => request, - Err(_) => { - return json_error( - "400 Bad Request", - "expected a JSON body with a preference to store", - ); - } - }; - if request.accent.is_none() && request.sidebar_width.is_none() { - return json_error("400 Bad Request", "no known preference in the body"); - } - // One locked write for whatever the body carried, so a request naming both - // preferences lands atomically rather than as two racing updates. - let stored = state.prefs.update(request.accent, request.sidebar_width); - match serde_json::to_string(&Envelope::new(serde_json::json!({ - "accent": stored.accent, - "sidebar_width": stored.sidebar_width, - }))) { - Ok(json) => json_response("200 OK", &json, &[]), - Err(_) => json_error("500 Internal Server Error", "could not encode preferences"), - } -} - -/// 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 rather than echoing what was tried. -fn handle_open_repo(body: &str, state: &ViewerState) -> Vec { - let request: OpenRequest = match serde_json::from_str(body) { - Ok(request) => request, - Err(_) => return json_error("400 Bad Request", "expected a JSON body with a path"), - }; - let raw = request.path.trim(); - if raw.is_empty() { - return json_error("400 Bad Request", "a path is required"); - } - let expanded = crate::util::expand_tilde(raw); - // is_dir() follows symlinks and is false for a missing path — either way it - // cannot be served. - if !expanded.is_dir() { - return json_error("400 Bad Request", "no such directory"); - } - let resolved = crate::git::resolve_repo_path(&expanded) - .to_string_lossy() - .into_owned(); - - match state.catalog.add_path(resolved, crate::workspace::MAX_PROJECTS) { - AddOutcome::Added(repo) => { - persist_workspace(state); - match serde_json::to_string(&Envelope::new(serde_json::json!({ "repo": repo }))) { - Ok(json) => json_response("200 OK", &json, &[]), - Err(_) => json_error("500 Internal Server Error", "could not encode repository"), - } - } - AddOutcome::TooMany => json_error( - "409 Conflict", - "the maximum number of repositories is already open", - ), - } -} - -#[derive(serde::Deserialize)] -struct MkdirRequest { - /// The directory to create the new folder inside — the one the picker is - /// currently showing. - path: String, - /// The new folder's name. 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 (any directory an -/// authenticated user can already reach), but `name` is held to a single plain -/// segment: separators, `..`, a leading `.` (which also rules out `.git` and the -/// hidden entries the picker never lists), and NUL are all rejected. Combined -/// with canonicalizing the parent first, the created folder can only ever land -/// directly under the browsed directory, never escape it via a symlink or `..`. -fn handle_mkdir(body: &str) -> Vec { - let request: MkdirRequest = match serde_json::from_str(body) { - Ok(request) => request, - Err(_) => { - return json_error( - "400 Bad Request", - "expected a JSON body with a path and a name", - ); - } - }; - let name = request.name.trim(); - if name.is_empty() { - return json_error("400 Bad Request", "a folder name is required"); - } - // A single plain segment only. This — not the parent — is what keeps the - // create confined: no traversal, no separators, no hidden/.git, no NUL. - if name.starts_with('.') || name.contains('/') || name.contains('\\') || name.contains('\0') { - return json_error("400 Bad Request", "invalid folder name"); - } - let parent = crate::util::expand_tilde(request.path.trim()); - // is_dir() follows symlinks and is false for a missing path — the same gate - // `open` uses for the directory it is handed. - if !parent.is_dir() { - return json_error("400 Bad Request", "no such directory"); - } - // Canonicalize first so a symlink in the supplied path cannot redirect the - // join; the validated single-segment name then stays under the real parent. - let base = match parent.canonicalize() { - Ok(base) => base, - Err(err) => return redact("mkdir canonicalize", &anyhow::Error::new(err)), - }; - let target = base.join(name); - match std::fs::create_dir(&target) { - Ok(()) => { - let path = target.to_string_lossy().into_owned(); - match serde_json::to_string(&Envelope::new(serde_json::json!({ "path": path }))) { - Ok(json) => json_response("200 OK", &json, &[]), - Err(_) => json_error("500 Internal Server Error", "could not encode the folder"), - } - } - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - json_error("409 Conflict", "a folder with that name already exists") - } - Err(err) => redact("mkdir", &anyhow::Error::new(err)), - } -} - -/// Close a repository named by the `repo` id and return the updated set. -/// -/// Idempotent from the client's view: an unknown id is a 404, a known one is -/// removed and its runtime/terminals stopped by the catalog rebuild. -fn handle_close_repo(head: &RequestHead, state: &ViewerState) -> Vec { - let entry = match lookup_repo(head, state) { - Ok(entry) => entry, - Err(response) => return response, - }; - state.catalog.remove_path(&entry.path); - persist_workspace(state); - let repos = state.catalog.list(); - match serde_json::to_string(&Envelope::new(serde_json::json!({ "repos": repos }))) { - Ok(json) => json_response("200 OK", &json, &[]), - Err(_) => json_error("500 Internal Server Error", "could not encode repositories"), - } -} - -/// Mirror the served set into the shared workspace file so the next launch — -/// TUI, mirror, or viewer — 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. -fn persist_workspace(state: &ViewerState) { - if !state.persist { - return; - } - let mut ws = crate::session::load_workspace().unwrap_or_default(); - ws.repos = state.catalog.paths(); - if ws.active >= ws.repos.len() { - ws.active = 0; - } - crate::session::save_workspace(&ws); -} - -/// Resolve the `repo` parameter to an entry, or produce the 404 response. -fn lookup_repo(head: &RequestHead, state: &ViewerState) -> Result, Vec> { - let id = head - .query_param("repo") - .ok_or_else(|| json_error("400 Bad Request", "missing repo parameter"))?; - state - .catalog - .get(&id) - .ok_or_else(|| json_error("404 Not Found", "unknown repository")) -} - -/// Map an internal error to a fixed public message, logging the detail. -/// -/// git and io errors name absolute paths, symlink targets, and file sizes. The -/// client is told only that the request failed and why in general terms. -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") -} - -fn route(head: &RequestHead, state: &ViewerState) -> Vec { - if head.method != "GET" { - return json_error("405 Method Not Allowed", "only GET is supported"); - } - match head.path.as_str() { - "/api/repos" => { - // 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. - // - // The recently-touched window keeps the browser on the TUI's - // schedule; `now_ms` is the clock its `mtime`s were measured on, - // which a phone or laptop viewing remotely need not share; the - // accent is stored server-side so every device shows the same one. - let prefs = state.prefs.get(); - let bootstrap = ViewerBootstrapDto::new( - state.catalog.list(), - HotConfigDto { - enabled: state.hot.enabled, - window_secs: state.hot.hot_window_secs, - }, - prefs.accent, - prefs.sidebar_width, - ); - match serde_json::to_string(&Envelope::new(bootstrap)) { - Ok(json) => json_response("200 OK", &json, &[]), - Err(_) => json_error("500 Internal Server Error", "could not encode repositories"), - } - } - "/api/status" => with_repo(head, state, |entry| { - // Served from the runtime's latest snapshot rather than a fresh - // git call: the runtime is already watching, and this keeps a - // page refresh from queueing another full status walk. - match entry.runtime.latest() { - Some(update) => Ok(json_response("200 OK", &update.json, &[])), - None => Ok(json_response( - "200 OK", - &encode(&StatusDto::from_snapshot( - &[], - None, - None, - None, - &std::collections::HashMap::new(), - ))?, - &[], - )), - } - }), - "/api/tree" => with_repo(head, state, |entry| { - let path = head.query_param("path").unwrap_or_default(); - let repo = open_repo(&entry.path)?; - let workdir = repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("bare repository"))? - .to_path_buf(); - let entries = crate::git::tree::read_children(&repo, &workdir, &path, true)?; - Ok(json_response( - "200 OK", - &encode(&TreeDto::from_entries(&path, &entries))?, - &[], - )) - }), - "/api/tree/search" => with_repo(head, state, |entry| { - let query = head.query_param("q").unwrap_or_default(); - // An empty query would match every entry, and an over-long one is not - // a real filename search; both short-circuit to an empty result so the - // walk never runs on degenerate input. - let (matches, truncated) = - if query.is_empty() || query.len() > limits::MAX_TREE_SEARCH_QUERY_BYTES { - (Vec::new(), false) - } else { - let repo = open_repo(&entry.path)?; - let workdir = repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("bare repository"))? - .to_path_buf(); - crate::git::tree::search_tree( - &repo, - &workdir, - &query, - limits::MAX_TREE_SEARCH_DEPTH, - limits::MAX_TREE_SEARCH_VISITS, - limits::MAX_TREE_SEARCH_RESULTS, - )? - }; - Ok(json_response( - "200 OK", - &encode(&TreeSearchDto::new(&query, &matches, truncated))?, - &[], - )) - }), - "/api/diff" => with_repo(head, state, |entry| { - let path = required_path(head)?; - let repo = open_repo(&entry.path)?; - let hunks = diff::load_file_diff(&repo, &path)?; - Ok(json_response( - "200 OK", - &encode(&DiffDto::from_hunks(&path, &hunks))?, - &[], - )) - }), - "/api/file" => with_repo(head, state, |entry| { - let path = required_path(head)?; - let repo = open_repo(&entry.path)?; - let content = diff::load_workdir_file(&repo, &path)?; - Ok(json_response( - "200 OK", - &encode(&FileDto::new(&path, &content))?, - &[], - )) - }), - "/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. - 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. - 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. - None => Vec::new(), - }; - Ok(json_response( - "200 OK", - &encode(&LogDto::from_entries(&commits, anchor))?, - &[], - )) - }), - "/api/commit" => with_repo(head, state, |entry| { - let oid = required_oid(head)?; - let oid_text = oid.to_string(); - let repo = open_repo(&entry.path)?; - let hunks = diff::load_commit_diff(&repo, oid)?; - Ok(json_response( - "200 OK", - &encode(&DiffDto::from_hunks(&oid_text, &hunks))?, - &[], - )) - }), - "/api/commit/files" => with_repo(head, state, |entry| { - let oid = required_oid(head)?; - let repo = open_repo(&entry.path)?; - let files = diff::load_commit_files(&repo, oid)?; - Ok(json_response( - "200 OK", - &encode(&CommitFilesDto::from_entries(&files))?, - &[], - )) - }), - "/api/commit/file-diff" => with_repo_commit_path(head, state, |entry, path| { - let oid = required_oid(head)?; - let repo = open_repo(&entry.path)?; - let hunks = diff::load_commit_file_diff(&repo, oid, path)?; - Ok(json_response( - "200 OK", - &encode(&DiffDto::from_hunks(path, &hunks))?, - &[], - )) - }), - "/api/browse" => browse(head), - _ => json_error("404 Not Found", "no such route"), - } -} - -/// 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. -fn browse(head: &RequestHead) -> Vec { - let start = match head.query_param("path").filter(|p| !p.is_empty()) { - Some(path) => std::path::PathBuf::from(path), - None => dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/")), - }; - match list_directories(&start) { - Ok(dto) => match serde_json::to_string(&Envelope::new(dto)) { - Ok(json) => json_response("200 OK", &json, &[]), - Err(_) => json_error("500 Internal Server Error", "could not encode listing"), - }, - Err(err) => redact(&head.path, &err), - } -} - -fn list_directories(path: &std::path::Path) -> anyhow::Result { - use anyhow::Context; - let canonical = path - .canonicalize() - .with_context(|| "path could not be resolved")?; - if !canonical.is_dir() { - anyhow::bail!("not a directory"); - } - let mut entries: Vec = Vec::new(); - let mut truncated = false; - for entry in std::fs::read_dir(&canonical).with_context(|| "directory is not readable")? { - let Ok(entry) = entry else { continue }; - // `file_type` does not follow symlinks, so a symlinked directory is - // skipped rather than risking a browse loop. - let Ok(file_type) = entry.file_type() else { - continue; - }; - if !file_type.is_dir() { - continue; - } - let name = entry.file_name().to_string_lossy().into_owned(); - if name.starts_with('.') { - continue; - } - if entries.len() >= limits::MAX_TREE_ENTRIES { - truncated = true; - break; - } - let is_repo = entry.path().join(".git").exists(); - entries.push(BrowseEntryDto { name, is_repo }); - } - entries.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(BrowseDto { - path: canonical.to_string_lossy().into_owned(), - parent: canonical.parent().map(|p| p.to_string_lossy().into_owned()), - entries, - truncated, - }) -} - -/// 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. Not every downstream touches the filesystem — `load_file_diff` passes -/// the path to git as a pathspec — but a route must not be safe only by -/// accident of which loader it happens to call. A traversal path is refused -/// uniformly, and never echoed back in a response. -fn with_repo( - head: &RequestHead, - state: &ViewerState, - body: impl FnOnce(&RepoEntry) -> Result>, -) -> Vec { - let entry = match lookup_repo(head, state) { - Ok(entry) => entry, - Err(response) => return response, - }; - // An absent or empty `path` means "the repository root" for the routes that - // accept one; anything else has to survive the gate. - if let Some(path) = head.query_param("path").filter(|p| !p.is_empty()) - && let Err(err) = - crate::git::path::resolve_in_workdir(std::path::Path::new(&entry.path), &path) - { - tracing::debug!(%err, route = %head.path, "viewer: rejected path parameter"); - return json_error("400 Bad Request", "invalid path"); - } - match body(&entry) { - Ok(response) => response, - Err(err) => redact(&head.path, &err), - } -} - -/// Variant of [`with_repo`] for a path inside a historical git object. -/// -/// A deleted commit path cannot be resolved in the current worktree, so this -/// validates its syntax without statting it. The route passes it only to an -/// exact git pathspec; it never opens a filesystem path. -fn with_repo_commit_path( - head: &RequestHead, - state: &ViewerState, - body: impl FnOnce(&RepoEntry, &str) -> Result>, -) -> Vec { - let entry = match lookup_repo(head, state) { - Ok(entry) => entry, - Err(response) => return response, - }; - let path = match required_path(head) { - Ok(path) => path, - Err(err) => return redact(&head.path, &err), - }; - if let Err(err) = crate::git::path::validate_commit_path(&path) { - tracing::debug!(%err, route = %head.path, "viewer: rejected historical path parameter"); - return json_error("400 Bad Request", "invalid path"); - } - match body(&entry, &path) { - Ok(response) => response, - Err(err) => redact(&head.path, &err), - } -} - -fn required_path(head: &RequestHead) -> Result { - head.query_param("path") - .filter(|p| !p.is_empty()) - .ok_or_else(|| anyhow::anyhow!("missing path parameter")) -} - -/// An oid query parameter that may be absent, but must parse when present. -/// -/// Absent and malformed are kept apart deliberately: silently walking from HEAD -/// after a typo would answer a different question than the one asked, and the -/// client pages against the value it gets back. -fn optional_oid(head: &RequestHead, name: &str) -> Result> { - match head.query_param(name) { - None => Ok(None), - Some(text) => git2::Oid::from_str(&text) - .map(Some) - .with_context(|| format!("malformed {name} parameter")), - } -} - -/// A non-negative count query parameter, defaulting to zero when absent. -/// -/// Deliberately unbounded — see the note beside [`limits::MAX_LOG_PAGE`]. A -/// negative or non-numeric value still fails to parse, so the guard that -/// matters is here. -fn optional_count(head: &RequestHead, name: &str) -> Result { - let Some(text) = head.query_param(name) else { - return Ok(0); - }; - text.parse() - .with_context(|| format!("malformed {name} parameter")) -} - -fn required_oid(head: &RequestHead) -> Result { - let oid_text = head - .query_param("oid") - .ok_or_else(|| anyhow::anyhow!("missing oid parameter"))?; - git2::Oid::from_str(&oid_text).context("malformed oid") -} - -fn open_repo(path: &str) -> Result { - git2::Repository::discover(path).context("failed to open repository") -} - -fn encode(payload: &T) -> Result { - serde_json::to_string(&Envelope::new(payload)).context("failed to encode payload") -} - -/// Hold the connection open and stream this repository's status. -fn serve_events(mut stream: TcpStream, head: &RequestHead, state: &ViewerState) { - let entry = match lookup_repo(head, state) { - Ok(entry) => entry, - Err(response) => { - let _ = stream.write_all(&response); - return; - } - }; - // A stalled reader must not wedge the handler thread forever. - let _ = stream.set_write_timeout(Some(SSE_HEARTBEAT)); - - let subscription = entry.runtime.subscribe(); - let Ok(mut sse) = SseStream::start(stream) else { - return; - }; - loop { - match subscription.next_update(SSE_HEARTBEAT) { - Some(update) => { - if sse.send("status", &update.json).is_err() { - break; - } - } - // Nothing changed: prove the socket is still alive. This is the - // only way a closed tab is discovered. - None => { - if sse.heartbeat().is_err() { - break; - } - } - } - } - // `subscription` drops here, unregistering from the fan-out. -} - -/// Hand this connection to the repository's terminal hub. -/// -/// Auth and Origin were already enforced by `handle_connection`, before the -/// repository was named — a terminal is effectively a shell, so the upgrade -/// must never be reachable ahead of those checks. -fn serve_terminal(stream: TcpStream, head: &RequestHead, state: &ViewerState) { - let mut stream = stream; - let entry = match lookup_repo(head, state) { - Ok(entry) => entry, - Err(response) => { - let _ = stream.write_all(&response); - return; - } - }; - // Without a read timeout, `ws.read()` blocks and terminal output would - // only flush when the user happened to type. The timeout turns the loop - // into a poll that services both directions. - let _ = stream.set_read_timeout(Some(TERM_POLL_TIMEOUT)); - let _ = stream.set_write_timeout(Some(SSE_HEARTBEAT)); - let Some(mut ws) = conn::websocket_handshake(stream, head) else { - return; - }; - let session = entry.terminals.connect(); - - loop { - // Drain everything queued for us before blocking on the socket, so - // output is not held back waiting for the client to say something. - let mut wrote = false; - while let Some(frame) = session.next_frame(Duration::from_millis(1)) { - let message = match frame { - TerminalFrame::Output { pane, data } => { - Message::Binary(terminal::encode_output(pane, &data).into()) - } - TerminalFrame::Control(json) => Message::Text(json.into()), - }; - if ws.send(message).is_err() { - return; - } - wrote = true; - } - if wrote && ws.flush().is_err() { - return; - } - - match ws.read() { - Ok(Message::Text(text)) => match serde_json::from_str::(&text) { - Ok(message) => session.dispatch(message), - // A malformed frame is dropped, not fatal: a client bug should - // not take the terminal down with it. - Err(err) => tracing::debug!(%err, "viewer: bad terminal message"), - }, - Ok(Message::Close(_)) => return, - Ok(_) => {} - // A poll timeout surfaces as WouldBlock on macOS and TimedOut on - // Linux; neither means the client is gone. - Err(tungstenite::Error::Io(err)) - if matches!( - err.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut - ) => {} - Err(_) => return, - } - } - // `session` drops here, unregistering from the hub. -} - -fn json_response(status: &str, body: &str, extra: &[(&str, &str)]) -> Vec { - let mut headers = vec![("X-Content-Type-Options", "nosniff")]; - headers.extend_from_slice(extra); - http::response( - status, - "application/json; charset=utf-8", - &headers, - body.as_bytes(), - ) -} - -fn json_error(status: &str, message: &str) -> Vec { - // Message is always a fixed literal from this module, never interpolated - // from an error, so it needs no escaping and can leak nothing. - let body = format!("{{\"error\":\"{message}\"}}"); - json_response(status, &body, &[]) -} - -fn text_response(status: &str, message: &str) -> Vec { - http::response( - status, - "text/plain; charset=utf-8", - &[("X-Content-Type-Options", "nosniff")], - message.as_bytes(), - ) -} - #[cfg(test)] -mod tests { - use super::*; - use crate::test_util::{make_repo, run_git}; - use std::io::Read; - - fn server(paths: &[String]) -> ViewerServer { - server_with(paths, crate::config::AgentIndicatorConfig::default(), None) - } - - /// `prefs_dir` keeps preference writes inside a temp directory. Left `None` - /// the store still points at the real `~/.nightcrow/viewer.json`, so any - /// test that *writes* a preference must pass one. - fn server_with( - paths: &[String], - hot: crate::config::AgentIndicatorConfig, - prefs_dir: Option<&std::path::Path>, - ) -> ViewerServer { - let prefs = match prefs_dir { - Some(dir) => PrefsStore::at(dir.join("viewer.json")), - None => PrefsStore::at(std::path::PathBuf::from("/nonexistent/nightcrow/viewer.json")), - }; - ViewerServer::start(ViewerOptions { - bind: "127.0.0.1".parse().unwrap(), - port: 0, - auth: Auth::from_plaintext("swordfish").unwrap(), - repos: paths.to_vec(), - // Never persist from tests — they must not touch the real - // ~/.nightcrow/workspace.json. - persist: false, - startup_commands: Vec::new(), - hot, - prefs, - }) - .unwrap() - } - - /// Send a raw request and return the full response text. - fn request(addr: SocketAddr, raw: &str) -> String { - let mut stream = TcpStream::connect(addr).unwrap(); - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - stream.write_all(raw.as_bytes()).unwrap(); - let mut buf = Vec::new(); - let _ = stream.read_to_end(&mut buf); - String::from_utf8_lossy(&buf).into_owned() - } - - fn get(addr: SocketAddr, path: &str, cookie: Option<&str>) -> String { - let cookie = match cookie { - Some(token) => format!("Cookie: {VIEWER_SESSION_COOKIE}={token}\r\n"), - None => String::new(), - }; - request( - addr, - &format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n{cookie}Connection: close\r\n\r\n"), - ) - } - - fn post(addr: SocketAddr, path: &str, body: &str, cookie: Option<&str>) -> String { - let cookie = match cookie { - Some(token) => format!("Cookie: {VIEWER_SESSION_COOKIE}={token}\r\n"), - None => String::new(), - }; - request( - addr, - &format!( - "POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ - Content-Type: application/json\r\n{cookie}\ - Content-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ), - ) - } - - fn delete(addr: SocketAddr, path: &str, cookie: Option<&str>) -> String { - let cookie = match cookie { - Some(token) => format!("Cookie: {VIEWER_SESSION_COOKIE}={token}\r\n"), - None => String::new(), - }; - request( - addr, - &format!( - "DELETE {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n{cookie}Connection: close\r\n\r\n" - ), - ) - } - - /// Log in and return the session token. - fn login(addr: SocketAddr) -> String { - let body = "password=swordfish"; - let response = request( - addr, - &format!( - "POST /login HTTP/1.1\r\nHost: 127.0.0.1\r\n\ - Content-Type: application/x-www-form-urlencoded\r\n\ - Content-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ), - ); - assert!( - response.starts_with("HTTP/1.1 200"), - "login failed: {response}" - ); - response - .split("Set-Cookie: ") - .nth(1) - .and_then(|rest| rest.split(';').next()) - .and_then(|pair| pair.split_once('=').map(|(_, v)| v.to_string())) - .expect("a session cookie") - } - - fn body_of(response: &str) -> &str { - response.split("\r\n\r\n").nth(1).unwrap_or("") - } - - #[test] - fn the_app_shell_is_reachable_without_a_session() { - // The bundle renders the login form, so gating it would leave the user - // with no way to authenticate at all. - let (dir, path) = make_repo(); - let server = server(&[path]); - - let response = get(server.addr(), "/", None); - - assert!(response.starts_with("HTTP/1.1 200"), "got: {response}"); - assert!(response.contains("
"), "not the app shell"); - assert!( - response.contains("Content-Security-Policy"), - "the shell must carry a CSP" - ); - drop(dir); - } - - #[test] - fn an_empty_catalog_serves_cleanly() { - // The TUI can start with no project open, so the viewer alongside it - // sees an empty catalog. That is a legitimate state, not an error. - let server = server(&[]); - let token = login(server.addr()); - - let response = get(server.addr(), "/api/repos", Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); - - assert!(response.starts_with("HTTP/1.1 200"), "got: {response}"); - assert_eq!(value["repos"].as_array().unwrap().len(), 0); - } - - #[test] - fn the_repository_list_serves_the_configured_recently_touched_settings() { - // The client fades its file list on this window; reading the config - // from the server is what keeps it on the TUI's window rather than a - // second default that drifts. - let server = server_with( - &[], - crate::config::AgentIndicatorConfig { - enabled: false, - hot_window_secs: 42, - auto_follow: true, - }, - None, - ); - let token = login(server.addr()); - - let response = get(server.addr(), "/api/repos", Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); - - assert_eq!(value["hot"]["enabled"], false); - assert_eq!(value["hot"]["window_secs"], 42); - // `auto_follow` moves a TUI selection; the browser has no analogue and - // must not be told about it. - assert!(value["hot"].get("auto_follow").is_none()); - } - - #[test] - fn the_repository_list_serves_the_server_clock_for_dating_mtimes() { - // `mtime` is an absolute instant on this machine's clock, so a browser - // on a device whose clock disagrees needs the reference to subtract. - use crate::web::viewer::dto::server_now_millis; - - let server = server_with(&[], crate::config::AgentIndicatorConfig::default(), None); - let token = login(server.addr()); - let before = server_now_millis(); - - let response = get(server.addr(), "/api/repos", Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); - - let now_ms = value["now_ms"].as_u64().expect("now_ms is a number"); - let after = server_now_millis(); - assert!( - (before..=after).contains(&now_ms), - "now_ms {now_ms} outside the request window {before}..={after}", - ); - } - - #[test] - fn a_stored_accent_is_served_to_every_later_client() { - // The point of storing it server-side: a second device (a second - // request, here) sees the choice without having made it. - let dir = tempfile::TempDir::new().unwrap(); - let server = server_with( - &[], - crate::config::AgentIndicatorConfig::default(), - Some(dir.path()), - ); - let token = login(server.addr()); - - let stored = post(server.addr(), "/api/prefs", "{\"accent\":3}", Some(&token)); - assert!(stored.starts_with("HTTP/1.1 200"), "got: {stored}"); - - let list = get(server.addr(), "/api/repos", Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&list)).unwrap(); - assert_eq!(value["accent"], 3); - } - - #[test] - fn a_stored_sidebar_width_is_clamped_and_served_to_every_later_client() { - let dir = tempfile::TempDir::new().unwrap(); - let server = server_with( - &[], - crate::config::AgentIndicatorConfig::default(), - Some(dir.path()), - ); - let token = login(server.addr()); - - // Past the ceiling: the write echoes the clamped value, and a later - // client's bootstrap carries the same clamped width — not the raw ask. - let stored = post( - server.addr(), - "/api/prefs", - "{\"sidebar_width\":5000}", - Some(&token), - ); - let echoed: serde_json::Value = serde_json::from_str(body_of(&stored)).unwrap(); - assert_eq!( - echoed["sidebar_width"], - crate::web::viewer::prefs::MAX_SIDEBAR_WIDTH - ); - - let list = get(server.addr(), "/api/repos", Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&list)).unwrap(); - assert_eq!( - value["sidebar_width"], - crate::web::viewer::prefs::MAX_SIDEBAR_WIDTH - ); - } - - #[test] - fn mkdir_creates_a_folder_inside_the_browsed_directory() { - let dir = tempfile::TempDir::new().unwrap(); - let server = server_with( - &[], - crate::config::AgentIndicatorConfig::default(), - Some(dir.path()), - ); - let token = login(server.addr()); - - let body = serde_json::json!({ - "path": dir.path().to_str().unwrap(), - "name": "scratch", - }) - .to_string(); - let created = post(server.addr(), "/api/mkdir", &body, Some(&token)); - assert!(created.starts_with("HTTP/1.1 200"), "got: {created}"); - assert!( - dir.path().join("scratch").is_dir(), - "the folder must exist on disk" - ); - - let value: serde_json::Value = serde_json::from_str(body_of(&created)).unwrap(); - let path = value["path"].as_str().unwrap(); - assert!( - std::path::Path::new(path).ends_with("scratch"), - "the response names the new folder, got: {path}" - ); - } - - #[test] - fn mkdir_rejects_a_name_that_would_escape_the_browsed_directory() { - let dir = tempfile::TempDir::new().unwrap(); - let server = server_with( - &[], - crate::config::AgentIndicatorConfig::default(), - Some(dir.path()), - ); - let token = login(server.addr()); - - // Separators, traversal, and a leading dot (which also covers `.git` - // and hidden entries) are all refused so the create cannot leave the - // browsed directory. - for name in ["../escape", "a/b", "..", ".git", ".hidden"] { - let body = serde_json::json!({ - "path": dir.path().to_str().unwrap(), - "name": name, - }) - .to_string(); - let response = post(server.addr(), "/api/mkdir", &body, Some(&token)); - assert!( - response.starts_with("HTTP/1.1 400"), - "name {name:?} must be rejected, got: {response}" - ); - } - assert!( - !dir.path().parent().unwrap().join("escape").exists(), - "traversal must not create anything above the parent" - ); - } - - #[test] - fn mkdir_requires_authentication() { - let dir = tempfile::TempDir::new().unwrap(); - let server = server_with( - &[], - crate::config::AgentIndicatorConfig::default(), - Some(dir.path()), - ); - - let body = serde_json::json!({ - "path": dir.path().to_str().unwrap(), - "name": "nope", - }) - .to_string(); - let response = post(server.addr(), "/api/mkdir", &body, None); - assert!( - response.starts_with("HTTP/1.1 401"), - "an unauthenticated mkdir must be refused, got: {response}" - ); - assert!( - !dir.path().join("nope").exists(), - "nothing may be created without a session" - ); - } - - #[test] - fn setting_one_preference_leaves_the_other_untouched() { - let dir = tempfile::TempDir::new().unwrap(); - let server = server_with( - &[], - crate::config::AgentIndicatorConfig::default(), - Some(dir.path()), - ); - let token = login(server.addr()); - - post(server.addr(), "/api/prefs", "{\"accent\":3}", Some(&token)); - let stored = post( - server.addr(), - "/api/prefs", - "{\"sidebar_width\":500}", - Some(&token), - ); - - // The width write must not reset the accent stored a moment earlier. - let echoed: serde_json::Value = serde_json::from_str(body_of(&stored)).unwrap(); - assert_eq!(echoed["accent"], 3); - assert_eq!(echoed["sidebar_width"], 500); - } - - #[test] - fn a_preference_body_naming_nothing_known_is_rejected() { - let dir = tempfile::TempDir::new().unwrap(); - let server = server_with( - &[], - crate::config::AgentIndicatorConfig::default(), - Some(dir.path()), - ); - let token = login(server.addr()); - - let response = post(server.addr(), "/api/prefs", "{\"nope\":1}", Some(&token)); - - assert!(response.starts_with("HTTP/1.1 400"), "got: {response}"); - } - - #[test] - fn storing_a_preference_requires_authentication() { - let dir = tempfile::TempDir::new().unwrap(); - let server = server_with( - &[], - crate::config::AgentIndicatorConfig::default(), - Some(dir.path()), - ); - - let response = post(server.addr(), "/api/prefs", "{\"accent\":3}", None); - - assert!(response.starts_with("HTTP/1.1 401"), "got: {response}"); - } - - #[test] - fn a_malformed_preference_body_is_rejected_without_changing_anything() { - let dir = tempfile::TempDir::new().unwrap(); - let server = server_with( - &[], - crate::config::AgentIndicatorConfig::default(), - Some(dir.path()), - ); - let token = login(server.addr()); - - let response = post(server.addr(), "/api/prefs", "{\"accent\":\"red\"}", Some(&token)); - - assert!(response.starts_with("HTTP/1.1 400"), "got: {response}"); - let list = get(server.addr(), "/api/repos", Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&list)).unwrap(); - assert_eq!(value["accent"], 0); - } - - #[test] - fn api_requires_authentication() { - let (dir, path) = make_repo(); - let server = server(&[path]); - - let response = get(server.addr(), "/api/repos", None); - - assert!(response.starts_with("HTTP/1.1 401"), "got: {response}"); - drop(dir); - } - - #[test] - fn opening_a_repository_adds_it_to_the_served_set() { - // Start empty, the way `serve` with no --repo now does, then open a - // repository from the browser. - let server = server(&[]); - let token = login(server.addr()); - let (dir, path) = make_repo(); - let body = format!("{{\"path\":{}}}", serde_json::to_string(&path).unwrap()); - - let opened = post(server.addr(), "/api/repos", &body, Some(&token)); - assert!(opened.starts_with("HTTP/1.1 200"), "got: {opened}"); - - let list = get(server.addr(), "/api/repos", Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&list)).unwrap(); - assert_eq!( - value["repos"].as_array().unwrap().len(), - 1, - "the opened repository must appear in the served set" - ); - drop(dir); - } - - #[test] - fn opening_a_repository_requires_authentication() { - let server = server(&[]); - let (dir, path) = make_repo(); - let body = format!("{{\"path\":{}}}", serde_json::to_string(&path).unwrap()); - - let response = post(server.addr(), "/api/repos", &body, None); - - assert!(response.starts_with("HTTP/1.1 401"), "got: {response}"); - drop(dir); - } - - #[test] - fn browse_lists_subdirectories_and_flags_repos() { - let root = tempfile::tempdir().unwrap(); - std::fs::create_dir(root.path().join("alpha")).unwrap(); - std::fs::create_dir_all(root.path().join("beta").join(".git")).unwrap(); - std::fs::write(root.path().join("afile.txt"), b"x").unwrap(); - let server = server(&[]); - let token = login(server.addr()); - - let path = root.path().to_string_lossy(); - let response = get( - server.addr(), - &format!("/api/browse?path={path}"), - Some(&token), - ); - assert!(response.starts_with("HTTP/1.1 200"), "got: {response}"); - - let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); - let list = value["entries"].as_array().unwrap(); - let names: Vec<&str> = list.iter().map(|e| e["name"].as_str().unwrap()).collect(); - assert!( - names.contains(&"alpha") && names.contains(&"beta"), - "expected sub-directories, got: {names:?}" - ); - assert!(!names.contains(&"afile.txt"), "files must be excluded"); - let beta = list.iter().find(|e| e["name"] == "beta").unwrap(); - assert_eq!(beta["is_repo"], true, "a .git folder marks a repo"); - } - - #[test] - fn closing_a_repository_removes_it_from_the_served_set() { - let (dir, path) = make_repo(); - let server = server(&[path]); - let token = login(server.addr()); - - let list = get(server.addr(), "/api/repos", Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&list)).unwrap(); - let id = value["repos"][0]["id"].as_str().unwrap().to_string(); - - let closed = delete(server.addr(), &format!("/api/repos?repo={id}"), Some(&token)); - assert!(closed.starts_with("HTTP/1.1 200"), "got: {closed}"); - - let after = get(server.addr(), "/api/repos", Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&after)).unwrap(); - assert_eq!( - value["repos"].as_array().unwrap().len(), - 0, - "the closed repository must be gone from the served set" - ); - drop(dir); - } - - #[test] - fn closing_an_unknown_repository_is_a_404() { - let (dir, path) = make_repo(); - let server = server(&[path]); - let token = login(server.addr()); - - let response = delete(server.addr(), "/api/repos?repo=nope", Some(&token)); - - assert!(response.starts_with("HTTP/1.1 404"), "got: {response}"); - drop(dir); - } - - #[test] - fn opening_a_nonexistent_path_is_rejected() { - let server = server(&[]); - let token = login(server.addr()); - - let response = post( - server.addr(), - "/api/repos", - "{\"path\":\"/definitely/not/a/real/directory\"}", - Some(&token), - ); - - assert!(response.starts_with("HTTP/1.1 400"), "got: {response}"); - } - - #[test] - fn auth_is_checked_before_the_repository_is_looked_up() { - // An unauthenticated request must not be able to distinguish a real id - // from a made-up one — that would enumerate the served repositories. - let (dir, path) = make_repo(); - let server = server(&[path]); - let token = login(server.addr()); - let real = { - let listing = get(server.addr(), "/api/repos", Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&listing)).unwrap(); - value["repos"][0]["id"].as_str().unwrap().to_string() - }; - - let known = get(server.addr(), &format!("/api/status?repo={real}"), None); - let unknown = get(server.addr(), "/api/status?repo=r9999", None); - - assert!(known.starts_with("HTTP/1.1 401"), "got: {known}"); - assert!(unknown.starts_with("HTTP/1.1 401"), "got: {unknown}"); - drop(dir); - } - - #[test] - fn a_rebound_host_is_refused_on_a_loopback_bind() { - // DNS rebinding: the attacker controls Origin *and* Host, so they - // agree and the origin check alone would pass. Only the Host check - // denies the same-origin foothold. - let (dir, path) = make_repo(); - let server = server(&[path]); - let token = login(server.addr()); - - let response = request( - server.addr(), - &format!( - "GET /api/repos HTTP/1.1\r\nHost: evil.example\r\n\ - Origin: http://evil.example\r\n\ - Cookie: {VIEWER_SESSION_COOKIE}={token}\r\nConnection: close\r\n\r\n" - ), - ); - - assert!(response.starts_with("HTTP/1.1 403"), "got: {response}"); - drop(dir); - } - - #[test] - fn logout_revokes_the_session_server_side() { - // Clearing the cookie is not enough: cookies are not port-isolated, so - // another loopback service is same-site and may already hold the token. - let (dir, path) = make_repo(); - let server = server(&[path]); - let token = login(server.addr()); - assert!(get(server.addr(), "/api/repos", Some(&token)).starts_with("HTTP/1.1 200")); - - get(server.addr(), "/logout", Some(&token)); - - let after = get(server.addr(), "/api/repos", Some(&token)); - assert!( - after.starts_with("HTTP/1.1 401"), - "the token must stop working immediately: {after}" - ); - drop(dir); - } - - #[test] - fn a_cross_origin_request_is_refused_before_auth() { - let (dir, path) = make_repo(); - let server = server(&[path]); - let token = login(server.addr()); - - let response = request( - server.addr(), - &format!( - "GET /api/repos HTTP/1.1\r\nHost: 127.0.0.1\r\nOrigin: http://evil.example\r\n\ - Cookie: {VIEWER_SESSION_COOKIE}={token}\r\nConnection: close\r\n\r\n" - ), - ); - - assert!(response.starts_with("HTTP/1.1 403"), "got: {response}"); - drop(dir); - } - - #[test] - fn the_viewer_cookie_is_distinct_from_the_mirrors() { - // A mirror session must not authenticate here. - assert_ne!( - VIEWER_SESSION_COOKIE, - crate::web::common::auth::SESSION_COOKIE - ); - } - - #[test] - fn repos_lists_the_served_set_by_opaque_id() { - let (dir, path) = make_repo(); - let server = server(std::slice::from_ref(&path)); - let token = login(server.addr()); - - let response = get(server.addr(), "/api/repos", Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); - - assert_eq!(value["version"], crate::web::viewer::dto::PROTOCOL_VERSION); - let repo = &value["repos"][0]; - assert!(repo["id"].as_str().unwrap().starts_with('r')); - let mut keys: Vec<_> = repo.as_object().unwrap().keys().cloned().collect(); - keys.sort(); - assert_eq!( - keys, - vec!["display_path", "id", "name"], - "only the whitelisted identity fields may be listed" - ); - drop((dir, path)); - } - - #[test] - fn an_unknown_repo_id_is_a_404_for_an_authenticated_client() { - let (dir, path) = make_repo(); - let server = server(&[path]); - let token = login(server.addr()); - - let response = get(server.addr(), "/api/status?repo=r9999", Some(&token)); - - assert!(response.starts_with("HTTP/1.1 404"), "got: {response}"); - drop(dir); - } - - #[test] - fn a_missing_repo_parameter_is_a_400() { - let (dir, path) = make_repo(); - let server = server(&[path]); - let token = login(server.addr()); - - let response = get(server.addr(), "/api/status", Some(&token)); - - assert!(response.starts_with("HTTP/1.1 400"), "got: {response}"); - drop(dir); - } - - fn seeded_server() -> (tempfile::TempDir, ViewerServer, String, String) { - let (dir, path) = make_repo(); - std::fs::create_dir(std::path::Path::new(&path).join("src")).unwrap(); - std::fs::write( - std::path::Path::new(&path).join("src/main.rs"), - "fn main() {}\n", - ) - .unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "init"]); - - let server = server(std::slice::from_ref(&path)); - let token = login(server.addr()); - let listing = get(server.addr(), "/api/repos", Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&listing)).unwrap(); - let id = value["repos"][0]["id"].as_str().unwrap().to_string(); - (dir, server, token, id) - } - - /// A repository whose history is one commit longer than a page, so the - /// first page is full and a second one exists. - fn server_with_paged_history() -> (tempfile::TempDir, ViewerServer, String, String, String) { - let (dir, path) = make_repo(); - for i in 0..=limits::MAX_LOG_PAGE { - std::fs::write(std::path::Path::new(&path).join(format!("f{i}")), "x").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", &format!("c{i}")]); - } - let server = server(std::slice::from_ref(&path)); - let token = login(server.addr()); - let listing = get(server.addr(), "/api/repos", Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&listing)).unwrap(); - let id = value["repos"][0]["id"].as_str().unwrap().to_string(); - (dir, server, token, id, path) - } - - fn log_page(server: &ViewerServer, token: &str, query: &str) -> serde_json::Value { - let response = get(server.addr(), &format!("/api/log?{query}"), Some(token)); - serde_json::from_str(body_of(&response)).unwrap() - } - - #[test] - fn the_log_reports_more_history_and_serves_the_next_page() { - let (dir, server, token, id, _path) = server_with_paged_history(); - - let first = log_page(&server, &token, &format!("repo={id}")); - let anchor = first["head"].as_str().expect("first page carries an anchor"); - let second = log_page( - &server, - &token, - &format!("repo={id}&from={anchor}&skip={}", limits::MAX_LOG_PAGE), - ); - - assert_eq!(first["commits"].as_array().unwrap().len(), limits::MAX_LOG_PAGE); - // The page is full *and* the history continues — the distinction the - // extra fetched entry exists to make. - assert_eq!(first["truncated"], true); - assert_eq!(second["commits"].as_array().unwrap().len(), 1); - assert_eq!(second["truncated"], false); - assert_eq!(second["commits"][0]["summary"], "c0"); - drop(dir); - } - - #[test] - fn a_pinned_log_page_ignores_commits_made_after_the_first() { - let (dir, server, token, id, path) = server_with_paged_history(); - let first = log_page(&server, &token, &format!("repo={id}")); - let anchor = first["head"].as_str().unwrap().to_string(); - let newest = first["commits"][0]["oid"].as_str().unwrap().to_string(); - - // A commit lands between the two page requests, as one made in the - // terminal panel below the list would. - std::fs::write(std::path::Path::new(&path).join("late"), "x").unwrap(); - run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-m", "late"]); - - let second = log_page( - &server, - &token, - &format!("repo={id}&from={anchor}&skip={}", limits::MAX_LOG_PAGE), - ); - - assert_eq!(anchor, newest, "the anchor is the walk's first commit"); - // Without pinning, this page would start at c1 — repeating a commit the - // client already holds. - assert_eq!(second["commits"][0]["summary"], "c0"); - assert_eq!(second["commits"].as_array().unwrap().len(), 1); - drop(dir); - } - - #[test] - fn the_log_rejects_a_malformed_anchor_or_skip() { - let (dir, server, token, id) = seeded_server(); - - let bad_from = get( - server.addr(), - &format!("/api/log?repo={id}&from=not-an-oid"), - Some(&token), - ); - let bad_skip = get( - server.addr(), - &format!("/api/log?repo={id}&skip=-1"), - Some(&token), - ); - // Falling back to HEAD would answer a different question than the one - // asked, and the client pages against what it gets back. - assert!(bad_from.starts_with("HTTP/1.1 400"), "got: {bad_from}"); - assert!(bad_skip.starts_with("HTTP/1.1 400"), "got: {bad_skip}"); - drop(dir); - } - - #[test] - fn a_skip_past_the_end_of_history_is_an_empty_last_page() { - // Not an error and not a ceiling: the revwalk simply runs out. A skip - // far beyond the history costs what walking the history costs, which is - // why the parameter needs no cap. - let (dir, server, token, id) = seeded_server(); - - let value = log_page(&server, &token, &format!("repo={id}&skip=100000000")); - - assert_eq!(value["commits"].as_array().unwrap().len(), 0); - assert_eq!(value["truncated"], false); - drop(dir); - } - - #[test] - fn tree_lists_a_directory_level() { - let (dir, server, token, id) = seeded_server(); - - let response = get(server.addr(), &format!("/api/tree?repo={id}"), Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); - - let names: Vec<_> = value["entries"] - .as_array() - .unwrap() - .iter() - .map(|e| e["name"].as_str().unwrap()) - .collect(); - assert!(names.contains(&"src"), "got: {names:?}"); - assert!(!names.contains(&".git"), "git metadata must not be listed"); - drop(dir); - } - - #[test] - fn tree_search_finds_a_nested_file_by_name() { - let (dir, server, token, id) = seeded_server(); - - let response = get( - server.addr(), - &format!("/api/tree/search?repo={id}&q=main"), - Some(&token), - ); - let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); - - let paths: Vec<_> = value["matches"] - .as_array() - .unwrap() - .iter() - .map(|m| m["path"].as_str().unwrap()) - .collect(); - // The match lives one level down, which the single-level /api/tree could - // not surface. - assert_eq!(paths, vec!["src/main.rs"]); - assert_eq!(value["truncated"], false); - drop(dir); - } - - #[test] - fn tree_search_with_an_empty_query_returns_no_matches() { - let (dir, server, token, id) = seeded_server(); - - let response = get( - server.addr(), - &format!("/api/tree/search?repo={id}&q="), - Some(&token), - ); - let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); - - assert!(value["matches"].as_array().unwrap().is_empty()); - drop(dir); - } - - #[test] - fn a_traversal_path_is_refused_by_every_route_that_takes_one() { - let (dir, server, token, id) = seeded_server(); - - for route in ["tree", "file", "diff"] { - for attack in ["../../etc/passwd", ".git/config", "src/../.git/config"] { - let encoded = attack.replace('/', "%2F"); - let response = get( - server.addr(), - &format!("/api/{route}?repo={id}&path={encoded}"), - Some(&token), - ); - assert!( - response.starts_with("HTTP/1.1 400"), - "{route} accepted {attack:?}: {response}" - ); - } - } - drop(dir); - } - - #[test] - fn an_error_response_leaks_no_filesystem_detail() { - let (dir, server, token, id) = seeded_server(); - - let response = get( - server.addr(), - &format!("/api/file?repo={id}&path=nope.txt"), - Some(&token), - ); - - let body = body_of(&response); - assert!(!body.contains('/'), "a path leaked into the error: {body}"); - assert!( - !body.contains("No such file"), - "the io error leaked: {body}" - ); - drop(dir); - } - - #[test] - fn file_returns_worktree_content() { - let (dir, server, token, id) = seeded_server(); - - let response = get( - server.addr(), - &format!("/api/file?repo={id}&path=src%2Fmain.rs"), - Some(&token), - ); - let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); - - // Content is returned as per-line, syntax-highlighted spans. Rebuild the - // text from them and confirm it round-trips. - let text: String = value["lines"] - .as_array() - .unwrap() - .iter() - .flat_map(|line| line.as_array().unwrap()) - .map(|span| span["t"].as_str().unwrap()) - .collect::>() - .join(""); - assert_eq!(text, "fn main() {}"); - assert_eq!(value["truncated"], false); - drop(dir); - } - - #[test] - fn log_returns_commits() { - let (dir, server, token, id) = seeded_server(); - - let response = get(server.addr(), &format!("/api/log?repo={id}"), Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); - - let commits = value["commits"].as_array().unwrap(); - assert_eq!(commits.len(), 1); - assert_eq!(commits[0]["summary"], "init"); - assert_eq!( - commits[0]["oid"].as_str().unwrap().len(), - 40, - "the oid must be hex, not libgit2's own shape" - ); - drop(dir); - } - - #[test] - fn commit_files_returns_the_selected_commits_changed_paths() { - let (dir, server, token, id) = seeded_server(); - let log = get(server.addr(), &format!("/api/log?repo={id}"), Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&log)).unwrap(); - let oid = value["commits"][0]["oid"].as_str().unwrap(); - - let response = get( - server.addr(), - &format!("/api/commit/files?repo={id}&oid={oid}"), - Some(&token), - ); - let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); - - assert!(response.starts_with("HTTP/1.1 200"), "got: {response}"); - assert_eq!(value["files"][0]["path"], "src/main.rs"); - assert_eq!(value["files"][0]["index"], "A"); - assert_eq!(value["files"][0]["worktree"], " "); - assert_eq!(value["truncated"], false); - drop(dir); - } - - #[test] - fn commit_file_diff_returns_only_the_selected_path() { - let (dir, server, token, id) = seeded_server(); - let log = get(server.addr(), &format!("/api/log?repo={id}"), Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&log)).unwrap(); - let oid = value["commits"][0]["oid"].as_str().unwrap(); - - let response = get( - server.addr(), - &format!("/api/commit/file-diff?repo={id}&oid={oid}&path=src%2Fmain.rs"), - Some(&token), - ); - let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); - - assert!(response.starts_with("HTTP/1.1 200"), "got: {response}"); - assert_eq!(value["path"], "src/main.rs"); - assert!( - value["hunks"].as_array().unwrap().iter().any(|hunk| { - hunk["file_path"] == "src/main.rs" && !hunk["lines"].as_array().unwrap().is_empty() - }), - "expected a diff for just src/main.rs: {value}" - ); - drop(dir); - } - - #[test] - fn commit_file_diff_allows_a_deleted_path_without_worktree_lookup() { - let (dir, server, token, id) = seeded_server(); - let repo_path = { - let entry = server.state.catalog.get(&id).unwrap(); - entry.path.clone() - }; - let gone = std::path::Path::new(&repo_path).join("gone.txt"); - std::fs::write(&gone, "before delete\n").unwrap(); - run_git(&repo_path, &["add", "gone.txt"]); - run_git(&repo_path, &["commit", "-m", "add gone"]); - run_git(&repo_path, &["rm", "gone.txt"]); - run_git(&repo_path, &["commit", "-m", "delete gone"]); - assert!(!gone.exists(), "test precondition: file must be deleted"); - - let log = get(server.addr(), &format!("/api/log?repo={id}"), Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&log)).unwrap(); - let oid = value["commits"][0]["oid"].as_str().unwrap(); - let response = get( - server.addr(), - &format!("/api/commit/file-diff?repo={id}&oid={oid}&path=gone.txt"), - Some(&token), - ); - let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); - - assert!(response.starts_with("HTTP/1.1 200"), "got: {response}"); - assert!( - value["hunks"] - .as_array() - .unwrap() - .iter() - .flat_map(|h| h["lines"].as_array().unwrap()) - .any(|line| line["kind"] == "-"), - "expected a removal line: {value}" - ); - drop(dir); - } - - #[test] - fn commit_file_diff_rejects_traversal_without_requiring_a_worktree_file() { - let (dir, server, token, id) = seeded_server(); - let log = get(server.addr(), &format!("/api/log?repo={id}"), Some(&token)); - let value: serde_json::Value = serde_json::from_str(body_of(&log)).unwrap(); - let oid = value["commits"][0]["oid"].as_str().unwrap(); - - for attack in ["..%2Fsecret", ".git%2Fconfig", "src%2F..%2Fx"] { - let response = get( - server.addr(), - &format!("/api/commit/file-diff?repo={id}&oid={oid}&path={attack}"), - Some(&token), - ); - assert!( - response.starts_with("HTTP/1.1 400"), - "historical route accepted {attack:?}: {response}" - ); - } - drop(dir); - } - - #[test] - fn diff_returns_hunks_for_a_changed_file() { - let (dir, server, token, id) = seeded_server(); - // Mutate the committed file so a worktree diff exists. - let repo_path = { - let entry = server.state.catalog.get(&id).unwrap(); - entry.path.clone() - }; - std::fs::write( - std::path::Path::new(&repo_path).join("src/main.rs"), - "fn main() { println!(\"hi\"); }\n", - ) - .unwrap(); - - let response = get( - server.addr(), - &format!("/api/diff?repo={id}&path=src%2Fmain.rs"), - Some(&token), - ); - let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); - - let hunks = value["hunks"].as_array().unwrap(); - assert!(!hunks.is_empty(), "expected a hunk: {value}"); - let kinds: Vec<_> = hunks[0]["lines"] - .as_array() - .unwrap() - .iter() - .map(|l| l["kind"].as_str().unwrap()) - .collect(); - assert!( - kinds.contains(&"+") && kinds.contains(&"-"), - "got: {kinds:?}" - ); - drop(dir); - } - - #[test] - fn a_non_get_method_is_rejected() { - let (dir, server, token, id) = seeded_server(); - - let response = request( - server.addr(), - &format!( - "DELETE /api/status?repo={id} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ - Cookie: {VIEWER_SESSION_COOKIE}={token}\r\nConnection: close\r\n\r\n" - ), - ); - - assert!(response.starts_with("HTTP/1.1 405"), "got: {response}"); - drop(dir); - } - - #[test] - fn the_events_stream_sends_a_status_event() { - let (dir, server, token, id) = seeded_server(); - - let mut stream = TcpStream::connect(server.addr()).unwrap(); - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - stream - .write_all( - format!( - "GET /api/events?repo={id} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ - Cookie: {VIEWER_SESSION_COOKIE}={token}\r\n\r\n" - ) - .as_bytes(), - ) - .unwrap(); - - // Read until the first dispatched event or the read budget runs out. - let mut seen = String::new(); - let mut chunk = [0u8; 2048]; - while !seen.contains("event: status") { - match stream.read(&mut chunk) { - Ok(0) | Err(_) => break, - Ok(n) => seen.push_str(&String::from_utf8_lossy(&chunk[..n])), - } - } - - assert!( - seen.starts_with("HTTP/1.1 200"), - "expected a streaming head: {seen}" - ); - assert!( - seen.contains("text/event-stream"), - "expected an SSE content type: {seen}" - ); - assert!(!seen.contains("Content-Length"), "SSE must not declare one"); - assert!(seen.contains("event: status"), "no status event: {seen}"); - drop(dir); - } - - #[test] - fn the_terminal_socket_creates_a_pane_and_streams_its_output() { - use tungstenite::client::IntoClientRequest; - - let (dir, server, token, id) = seeded_server(); - let mut request = format!("ws://{}/ws/term?repo={id}", server.addr()) - .into_client_request() - .unwrap(); - request.headers_mut().insert( - "Cookie", - format!("{VIEWER_SESSION_COOKIE}={token}").parse().unwrap(), - ); - let (mut ws, _) = tungstenite::connect(request).expect("terminal upgrade"); - - ws.send(tungstenite::Message::Text( - r#"{"type":"create","rows":24,"cols":80}"#.into(), - )) - .unwrap(); - - // Expect created control frames, then real PTY bytes tagged with a pane - // id — proving the multiplexing round-trips end to end. More than one - // pane can appear: the first connect also spawns the default startup - // terminal, so track every announced pane and require output for one. - let mut created = std::collections::HashSet::new(); - let mut saw_output = false; - for _ in 0..40 { - match ws.read() { - Ok(tungstenite::Message::Text(text)) if text.contains("created") => { - let value: serde_json::Value = serde_json::from_str(&text).unwrap(); - if let Some(pane) = value["pane"].as_u64() { - created.insert(pane as u32); - } - } - Ok(tungstenite::Message::Binary(bytes)) => { - let (pane, data) = terminal::decode_output(&bytes).expect("a tagged frame"); - assert!(created.contains(&pane), "output for an unannounced pane"); - if !data.is_empty() { - saw_output = true; - break; - } - } - Ok(_) => {} - Err(_) => break, - } - } - - assert!(!created.is_empty(), "no created frame"); - assert!(saw_output, "no PTY output reached the socket"); - drop(dir); - } - - #[test] - fn the_terminal_socket_requires_auth_and_a_known_repo() { - let (dir, server, token, _id) = seeded_server(); - - let anon = get(server.addr(), "/ws/term?repo=r1", None); - assert!(anon.starts_with("HTTP/1.1 401"), "got: {anon}"); - - // Authenticated but unknown: refused before any upgrade happens. - let unknown = request( - server.addr(), - &format!( - "GET /ws/term?repo=r9999 HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\n\ - Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ - Sec-WebSocket-Version: 13\r\nCookie: {VIEWER_SESSION_COOKIE}={token}\r\n\r\n" - ), - ); - assert!(unknown.starts_with("HTTP/1.1 404"), "got: {unknown}"); - drop(dir); - } - - #[test] - fn the_events_stream_requires_auth_and_a_known_repo() { - let (dir, server, token, _id) = seeded_server(); - - let anon = get(server.addr(), "/api/events?repo=r1", None); - assert!(anon.starts_with("HTTP/1.1 401"), "got: {anon}"); - - let unknown = get(server.addr(), "/api/events?repo=r9999", Some(&token)); - assert!(unknown.starts_with("HTTP/1.1 404"), "got: {unknown}"); - drop(dir); - } -} +mod tests; \ No newline at end of file diff --git a/src/web/viewer/server/mutations.rs b/src/web/viewer/server/mutations.rs new file mode 100644 index 0000000..25643a7 --- /dev/null +++ b/src/web/viewer/server/mutations.rs @@ -0,0 +1,209 @@ +use super::http_util::{json_error, json_response}; +use super::ViewerState; +use crate::web::common::http::RequestHead; +use crate::web::viewer::catalog::{AddOutcome, RepoEntry}; +use crate::web::viewer::dto::Envelope; +use anyhow::Result; +use std::sync::Arc; + +#[derive(serde::Deserialize)] +struct OpenRequest { + path: String, +} + +#[derive(serde::Deserialize)] +struct PrefsRequest { + /// Each preference is optional so one write touches one setting and leaves + /// the rest as they are; a body naming none is rejected rather than treated + /// as a silent no-op. + accent: Option, + sidebar_width: Option, +} + +/// Store one or more viewer preferences and echo back the full stored set. +/// +/// Each value is wrapped or clamped into range rather than rejected — an accent +/// index past the end of the cycle gets a colour back (as the TUI does with +/// `Accent::from_index`), and a width past the bounds gets a usable split back — +/// so a client that drifts out of range self-corrects from the response. +pub(super) fn handle_set_prefs(body: &str, state: &ViewerState) -> Vec { + let request: PrefsRequest = match serde_json::from_str(body) { + Ok(request) => request, + Err(_) => { + return json_error( + "400 Bad Request", + "expected a JSON body with a preference to store", + ); + } + }; + if request.accent.is_none() && request.sidebar_width.is_none() { + return json_error("400 Bad Request", "no known preference in the body"); + } + // One locked write for whatever the body carried, so a request naming both + // preferences lands atomically rather than as two racing updates. + let stored = state.prefs.update(request.accent, request.sidebar_width); + match serde_json::to_string(&Envelope::new(serde_json::json!({ + "accent": stored.accent, + "sidebar_width": stored.sidebar_width, + }))) { + Ok(json) => json_response("200 OK", &json, &[]), + Err(_) => json_error("500 Internal Server Error", "could not encode preferences"), + } +} + +/// 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 rather than echoing what was tried. +pub(super) fn handle_open_repo(body: &str, state: &ViewerState) -> Vec { + let request: OpenRequest = match serde_json::from_str(body) { + Ok(request) => request, + Err(_) => return json_error("400 Bad Request", "expected a JSON body with a path"), + }; + let raw = request.path.trim(); + if raw.is_empty() { + return json_error("400 Bad Request", "a path is required"); + } + let expanded = crate::util::expand_tilde(raw); + // is_dir() follows symlinks and is false for a missing path — either way it + // cannot be served. + if !expanded.is_dir() { + return json_error("400 Bad Request", "no such directory"); + } + let resolved = crate::git::resolve_repo_path(&expanded) + .to_string_lossy() + .into_owned(); + + match state.catalog.add_path(resolved, crate::workspace::MAX_PROJECTS) { + AddOutcome::Added(repo) => { + persist_workspace(state); + match serde_json::to_string(&Envelope::new(serde_json::json!({ "repo": repo }))) { + Ok(json) => json_response("200 OK", &json, &[]), + Err(_) => json_error("500 Internal Server Error", "could not encode repository"), + } + } + AddOutcome::TooMany => json_error( + "409 Conflict", + "the maximum number of repositories is already open", + ), + } +} + +#[derive(serde::Deserialize)] +struct MkdirRequest { + /// The directory to create the new folder inside — the one the picker is + /// currently showing. + path: String, + /// The new folder's name. 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 (any directory an +/// authenticated user can already reach), but `name` is held to a single plain +/// segment: separators, `..`, a leading `.` (which also rules out `.git` and the +/// hidden entries the picker never lists), and NUL are all rejected. Combined +/// with canonicalizing the parent first, the created folder can only ever land +/// directly under the browsed directory, never escape it via a symlink or `..`. +pub(super) fn handle_mkdir(body: &str) -> Vec { + let request: MkdirRequest = match serde_json::from_str(body) { + Ok(request) => request, + Err(_) => { + return json_error( + "400 Bad Request", + "expected a JSON body with a path and a name", + ); + } + }; + let name = request.name.trim(); + if name.is_empty() { + return json_error("400 Bad Request", "a folder name is required"); + } + // A single plain segment only. This — not the parent — is what keeps the + // create confined: no traversal, no separators, no hidden/.git, no NUL. + if name.starts_with('.') || name.contains('/') || name.contains('\\') || name.contains('\0') { + return json_error("400 Bad Request", "invalid folder name"); + } + let parent = crate::util::expand_tilde(request.path.trim()); + // is_dir() follows symlinks and is false for a missing path — the same gate + // `open` uses for the directory it is handed. + if !parent.is_dir() { + return json_error("400 Bad Request", "no such directory"); + } + // Canonicalize first so a symlink in the supplied path cannot redirect the + // join; the validated single-segment name then stays under the real parent. + let base = match parent.canonicalize() { + Ok(base) => base, + Err(err) => return redact("mkdir canonicalize", &anyhow::Error::new(err)), + }; + let target = base.join(name); + match std::fs::create_dir(&target) { + Ok(()) => { + let path = target.to_string_lossy().into_owned(); + match serde_json::to_string(&Envelope::new(serde_json::json!({ "path": path }))) { + Ok(json) => json_response("200 OK", &json, &[]), + Err(_) => json_error("500 Internal Server Error", "could not encode the folder"), + } + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + json_error("409 Conflict", "a folder with that name already exists") + } + Err(err) => redact("mkdir", &anyhow::Error::new(err)), + } +} + +/// Close a repository named by the `repo` id and return the updated set. +/// +/// Idempotent from the client's view: an unknown id is a 404, a known one is +/// removed and its runtime/terminals stopped by the catalog rebuild. +pub(super) fn handle_close_repo(head: &RequestHead, state: &ViewerState) -> Vec { + let entry = match lookup_repo(head, state) { + Ok(entry) => entry, + Err(response) => return response, + }; + state.catalog.remove_path(&entry.path); + persist_workspace(state); + let repos = state.catalog.list(); + match serde_json::to_string(&Envelope::new(serde_json::json!({ "repos": repos }))) { + Ok(json) => json_response("200 OK", &json, &[]), + Err(_) => json_error("500 Internal Server Error", "could not encode repositories"), + } +} + +/// Mirror the served set into the shared workspace file so the next launch — +/// TUI, mirror, or viewer — 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. +fn persist_workspace(state: &ViewerState) { + if !state.persist { + return; + } + let mut ws = crate::session::load_workspace().unwrap_or_default(); + ws.repos = state.catalog.paths(); + if ws.active >= ws.repos.len() { + ws.active = 0; + } + crate::session::save_workspace(&ws); +} + +/// Resolve the `repo` parameter to an entry, or produce the 404 response. +pub(super) fn lookup_repo(head: &RequestHead, state: &ViewerState) -> Result, Vec> { + let id = head + .query_param("repo") + .ok_or_else(|| json_error("400 Bad Request", "missing repo parameter"))?; + state + .catalog + .get(&id) + .ok_or_else(|| json_error("404 Not Found", "unknown repository")) +} + +/// Map an internal error to a fixed public message, logging the detail. +/// +/// git and io errors name absolute paths, symlink targets, and file sizes. The +/// client is told only that the request failed and why in general terms. +pub(super) 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") +} \ No newline at end of file diff --git a/src/web/viewer/server/routes.rs b/src/web/viewer/server/routes.rs new file mode 100644 index 0000000..0daf138 --- /dev/null +++ b/src/web/viewer/server/routes.rs @@ -0,0 +1,254 @@ +use super::handlers::{ + encode, open_repo, optional_count, optional_oid, required_oid, required_path, with_repo, + with_repo_commit_path, +}; +use super::http_util::{json_error, json_response}; +use super::mutations::redact; +use super::ViewerState; +use crate::git::diff; +use crate::web::common::http::RequestHead; +use crate::web::viewer::dto::{ + BrowseDto, BrowseEntryDto, CommitFilesDto, DiffDto, Envelope, FileDto, HotConfigDto, LogDto, + StatusDto, TreeDto, TreeSearchDto, ViewerBootstrapDto, +}; +use crate::web::viewer::limits; + +pub(super) fn route(head: &RequestHead, state: &ViewerState) -> Vec { + if head.method != "GET" { + return json_error("405 Method Not Allowed", "only GET is supported"); + } + match head.path.as_str() { + "/api/repos" => { + // 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. + // + // The recently-touched window keeps the browser on the TUI's + // schedule; `now_ms` is the clock its `mtime`s were measured on, + // which a phone or laptop viewing remotely need not share; the + // accent is stored server-side so every device shows the same one. + let prefs = state.prefs.get(); + let bootstrap = ViewerBootstrapDto::new( + state.catalog.list(), + HotConfigDto { + enabled: state.hot.enabled, + window_secs: state.hot.hot_window_secs, + }, + prefs.accent, + prefs.sidebar_width, + ); + match serde_json::to_string(&Envelope::new(bootstrap)) { + Ok(json) => json_response("200 OK", &json, &[]), + Err(_) => json_error("500 Internal Server Error", "could not encode repositories"), + } + } + "/api/status" => with_repo(head, state, |entry| { + // Served from the runtime's latest snapshot rather than a fresh + // git call: the runtime is already watching, and this keeps a + // page refresh from queueing another full status walk. + match entry.runtime.latest() { + Some(update) => Ok(json_response("200 OK", &update.json, &[])), + None => Ok(json_response( + "200 OK", + &encode(&StatusDto::from_snapshot( + &[], + None, + None, + None, + &std::collections::HashMap::new(), + ))?, + &[], + )), + } + }), + "/api/tree" => with_repo(head, state, |entry| { + let path = head.query_param("path").unwrap_or_default(); + let repo = open_repo(&entry.path)?; + let workdir = repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("bare repository"))? + .to_path_buf(); + let entries = crate::git::tree::read_children(&repo, &workdir, &path, true)?; + Ok(json_response( + "200 OK", + &encode(&TreeDto::from_entries(&path, &entries))?, + &[], + )) + }), + "/api/tree/search" => with_repo(head, state, |entry| { + let query = head.query_param("q").unwrap_or_default(); + // An empty query would match every entry, and an over-long one is not + // a real filename search; both short-circuit to an empty result so the + // walk never runs on degenerate input. + let (matches, truncated) = + if query.is_empty() || query.len() > limits::MAX_TREE_SEARCH_QUERY_BYTES { + (Vec::new(), false) + } else { + let repo = open_repo(&entry.path)?; + let workdir = repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("bare repository"))? + .to_path_buf(); + crate::git::tree::search_tree( + &repo, + &workdir, + &query, + limits::MAX_TREE_SEARCH_DEPTH, + limits::MAX_TREE_SEARCH_VISITS, + limits::MAX_TREE_SEARCH_RESULTS, + )? + }; + Ok(json_response( + "200 OK", + &encode(&TreeSearchDto::new(&query, &matches, truncated))?, + &[], + )) + }), + "/api/diff" => with_repo(head, state, |entry| { + let path = required_path(head)?; + let repo = open_repo(&entry.path)?; + let hunks = diff::load_file_diff(&repo, &path)?; + Ok(json_response( + "200 OK", + &encode(&DiffDto::from_hunks(&path, &hunks))?, + &[], + )) + }), + "/api/file" => with_repo(head, state, |entry| { + let path = required_path(head)?; + let repo = open_repo(&entry.path)?; + let content = diff::load_workdir_file(&repo, &path)?; + Ok(json_response( + "200 OK", + &encode(&FileDto::new(&path, &content))?, + &[], + )) + }), + "/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. + 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. + 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. + None => Vec::new(), + }; + Ok(json_response( + "200 OK", + &encode(&LogDto::from_entries(&commits, anchor))?, + &[], + )) + }), + "/api/commit" => with_repo(head, state, |entry| { + let oid = required_oid(head)?; + let oid_text = oid.to_string(); + let repo = open_repo(&entry.path)?; + let hunks = diff::load_commit_diff(&repo, oid)?; + Ok(json_response( + "200 OK", + &encode(&DiffDto::from_hunks(&oid_text, &hunks))?, + &[], + )) + }), + "/api/commit/files" => with_repo(head, state, |entry| { + let oid = required_oid(head)?; + let repo = open_repo(&entry.path)?; + let files = diff::load_commit_files(&repo, oid)?; + Ok(json_response( + "200 OK", + &encode(&CommitFilesDto::from_entries(&files))?, + &[], + )) + }), + "/api/commit/file-diff" => with_repo_commit_path(head, state, |entry, path| { + let oid = required_oid(head)?; + let repo = open_repo(&entry.path)?; + let hunks = diff::load_commit_file_diff(&repo, oid, path)?; + Ok(json_response( + "200 OK", + &encode(&DiffDto::from_hunks(path, &hunks))?, + &[], + )) + }), + "/api/browse" => browse(head), + _ => json_error("404 Not Found", "no such route"), + } +} + +/// 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. +fn browse(head: &RequestHead) -> Vec { + let start = match head.query_param("path").filter(|p| !p.is_empty()) { + Some(path) => std::path::PathBuf::from(path), + None => dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/")), + }; + match list_directories(&start) { + Ok(dto) => match serde_json::to_string(&Envelope::new(dto)) { + Ok(json) => json_response("200 OK", &json, &[]), + Err(_) => json_error("500 Internal Server Error", "could not encode listing"), + }, + Err(err) => redact(&head.path, &err), + } +} + +fn list_directories(path: &std::path::Path) -> anyhow::Result { + use anyhow::Context; + let canonical = path + .canonicalize() + .with_context(|| "path could not be resolved")?; + if !canonical.is_dir() { + anyhow::bail!("not a directory"); + } + let mut entries: Vec = Vec::new(); + let mut truncated = false; + for entry in std::fs::read_dir(&canonical).with_context(|| "directory is not readable")? { + let Ok(entry) = entry else { continue }; + // `file_type` does not follow symlinks, so a symlinked directory is + // skipped rather than risking a browse loop. + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with('.') { + continue; + } + if entries.len() >= limits::MAX_TREE_ENTRIES { + truncated = true; + break; + } + let is_repo = entry.path().join(".git").exists(); + entries.push(BrowseEntryDto { name, is_repo }); + } + entries.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(BrowseDto { + path: canonical.to_string_lossy().into_owned(), + parent: canonical.parent().map(|p| p.to_string_lossy().into_owned()), + entries, + truncated, + }) +} \ No newline at end of file diff --git a/src/web/viewer/server/tests/auth.rs b/src/web/viewer/server/tests/auth.rs new file mode 100644 index 0000000..83aba9a --- /dev/null +++ b/src/web/viewer/server/tests/auth.rs @@ -0,0 +1,254 @@ +use super::{ + body_of, get, login, post, request, server, seeded_server, VIEWER_SESSION_COOKIE, +}; +use crate::test_util::make_repo; + +#[test] +fn api_requires_authentication() { + let (dir, path) = make_repo(); + let server = server(&[path]); + + let response = get(server.addr(), "/api/repos", None); + + assert!(response.starts_with("HTTP/1.1 401"), "got: {response}"); + drop(dir); +} + +#[test] +fn opening_a_repository_adds_it_to_the_served_set() { + // Start empty, the way `serve` with no --repo now does, then open a + // repository from the browser. + let server = server(&[]); + let token = login(server.addr()); + let (dir, path) = make_repo(); + let body = format!("{{\"path\":{}}}", serde_json::to_string(&path).unwrap()); + + let opened = post(server.addr(), "/api/repos", &body, Some(&token)); + assert!(opened.starts_with("HTTP/1.1 200"), "got: {opened}"); + + let list = get(server.addr(), "/api/repos", Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&list)).unwrap(); + assert_eq!( + value["repos"].as_array().unwrap().len(), + 1, + "the opened repository must appear in the served set" + ); + drop(dir); +} + +#[test] +fn opening_a_repository_requires_authentication() { + let server = server(&[]); + let (dir, path) = make_repo(); + let body = format!("{{\"path\":{}}}", serde_json::to_string(&path).unwrap()); + + let response = post(server.addr(), "/api/repos", &body, None); + + assert!(response.starts_with("HTTP/1.1 401"), "got: {response}"); + drop(dir); +} + +#[test] +fn browse_lists_subdirectories_and_flags_repos() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("alpha")).unwrap(); + std::fs::create_dir_all(root.path().join("beta").join(".git")).unwrap(); + std::fs::write(root.path().join("afile.txt"), b"x").unwrap(); + let server = server(&[]); + let token = login(server.addr()); + + let path = root.path().to_string_lossy(); + let response = get( + server.addr(), + &format!("/api/browse?path={path}"), + Some(&token), + ); + assert!(response.starts_with("HTTP/1.1 200"), "got: {response}"); + + let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); + let list = value["entries"].as_array().unwrap(); + let names: Vec<&str> = list.iter().map(|e| e["name"].as_str().unwrap()).collect(); + assert!( + names.contains(&"alpha") && names.contains(&"beta"), + "expected sub-directories, got: {names:?}" + ); + assert!(!names.contains(&"afile.txt"), "files must be excluded"); + let beta = list.iter().find(|e| e["name"] == "beta").unwrap(); + assert_eq!(beta["is_repo"], true, "a .git folder marks a repo"); +} + +#[test] +fn closing_a_repository_removes_it_from_the_served_set() { + let (dir, path) = make_repo(); + let server = server(&[path]); + let token = login(server.addr()); + + let list = get(server.addr(), "/api/repos", Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&list)).unwrap(); + let id = value["repos"][0]["id"].as_str().unwrap().to_string(); + + let closed = super::delete(server.addr(), &format!("/api/repos?repo={id}"), Some(&token)); + assert!(closed.starts_with("HTTP/1.1 200"), "got: {closed}"); + + let after = get(server.addr(), "/api/repos", Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&after)).unwrap(); + assert_eq!( + value["repos"].as_array().unwrap().len(), + 0, + "the closed repository must be gone from the served set" + ); + drop(dir); +} + +#[test] +fn closing_an_unknown_repository_is_a_404() { + let (dir, path) = make_repo(); + let server = server(&[path]); + let token = login(server.addr()); + + let response = super::delete(server.addr(), "/api/repos?repo=nope", Some(&token)); + + assert!(response.starts_with("HTTP/1.1 404"), "got: {response}"); + drop(dir); +} + +#[test] +fn opening_a_nonexistent_path_is_rejected() { + let server = server(&[]); + let token = login(server.addr()); + + let response = post( + server.addr(), + "/api/repos", + "{\"path\":\"/definitely/not/a/real/directory\"}", + Some(&token), + ); + + assert!(response.starts_with("HTTP/1.1 400"), "got: {response}"); +} + +#[test] +fn auth_is_checked_before_the_repository_is_looked_up() { + // An unauthenticated request must not be able to distinguish a real id + // from a made-up one — that would enumerate the served repositories. + let (dir, path) = make_repo(); + let server = server(&[path]); + let token = login(server.addr()); + let real = { + let listing = get(server.addr(), "/api/repos", Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&listing)).unwrap(); + value["repos"][0]["id"].as_str().unwrap().to_string() + }; + + let known = get(server.addr(), &format!("/api/status?repo={real}"), None); + let unknown = get(server.addr(), "/api/status?repo=r9999", None); + + assert!(known.starts_with("HTTP/1.1 401"), "got: {known}"); + assert!(unknown.starts_with("HTTP/1.1 401"), "got: {unknown}"); + drop(dir); +} + +#[test] +fn a_rebound_host_is_refused_on_a_loopback_bind() { + // DNS rebinding: the attacker controls Origin *and* Host, so they + // agree and the origin check alone would pass. Only the Host check + // denies the same-origin foothold. + let (dir, path) = make_repo(); + let server = server(&[path]); + let token = login(server.addr()); + + let response = request( + server.addr(), + &format!( + "GET /api/repos HTTP/1.1\r\nHost: evil.example\r\n\ + Origin: http://evil.example\r\n\ + Cookie: {VIEWER_SESSION_COOKIE}={token}\r\nConnection: close\r\n\r\n" + ), + ); + + assert!(response.starts_with("HTTP/1.1 403"), "got: {response}"); + drop(dir); +} + +#[test] +fn logout_revokes_the_session_server_side() { + // Clearing the cookie is not enough: cookies are not port-isolated, so + // another loopback service is same-site and may already hold the token. + let (dir, path) = make_repo(); + let server = server(&[path]); + let token = login(server.addr()); + assert!(get(server.addr(), "/api/repos", Some(&token)).starts_with("HTTP/1.1 200")); + + get(server.addr(), "/logout", Some(&token)); + + let after = get(server.addr(), "/api/repos", Some(&token)); + assert!( + after.starts_with("HTTP/1.1 401"), + "the token must stop working immediately: {after}" + ); + drop(dir); +} + +#[test] +fn a_cross_origin_request_is_refused_before_auth() { + let (dir, path) = make_repo(); + let server = server(&[path]); + let token = login(server.addr()); + + let response = request( + server.addr(), + &format!( + "GET /api/repos HTTP/1.1\r\nHost: 127.0.0.1\r\nOrigin: http://evil.example\r\n\ + Cookie: {VIEWER_SESSION_COOKIE}={token}\r\nConnection: close\r\n\r\n" + ), + ); + + assert!(response.starts_with("HTTP/1.1 403"), "got: {response}"); + drop(dir); +} + +#[test] +fn the_viewer_cookie_is_distinct_from_the_mirrors() { + // A mirror session must not authenticate here. + assert_ne!(VIEWER_SESSION_COOKIE, crate::web::common::auth::SESSION_COOKIE); +} + +#[test] +fn an_unknown_repo_id_is_a_404_for_an_authenticated_client() { + let (dir, path) = make_repo(); + let server = server(&[path]); + let token = login(server.addr()); + + let response = get(server.addr(), "/api/status?repo=r9999", Some(&token)); + + assert!(response.starts_with("HTTP/1.1 404"), "got: {response}"); + drop(dir); +} + +#[test] +fn a_missing_repo_parameter_is_a_400() { + let (dir, path) = make_repo(); + let server = server(&[path]); + let token = login(server.addr()); + + let response = get(server.addr(), "/api/status", Some(&token)); + + assert!(response.starts_with("HTTP/1.1 400"), "got: {response}"); + drop(dir); +} + +#[test] +fn a_non_get_method_is_rejected() { + let (dir, server, token, id) = seeded_server(); + + let response = request( + server.addr(), + &format!( + "DELETE /api/status?repo={id} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ + Cookie: {VIEWER_SESSION_COOKIE}={token}\r\nConnection: close\r\n\r\n" + ), + ); + + assert!(response.starts_with("HTTP/1.1 405"), "got: {response}"); + drop(dir); +} \ No newline at end of file diff --git a/src/web/viewer/server/tests/commit_routes.rs b/src/web/viewer/server/tests/commit_routes.rs new file mode 100644 index 0000000..439e7ca --- /dev/null +++ b/src/web/viewer/server/tests/commit_routes.rs @@ -0,0 +1,144 @@ +use super::{body_of, get, seeded_server}; +use crate::test_util::run_git; + +#[test] +fn commit_files_returns_the_selected_commits_changed_paths() { + let (dir, server, token, id) = seeded_server(); + let log = get(server.addr(), &format!("/api/log?repo={id}"), Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&log)).unwrap(); + let oid = value["commits"][0]["oid"].as_str().unwrap(); + + let response = get( + server.addr(), + &format!("/api/commit/files?repo={id}&oid={oid}"), + Some(&token), + ); + let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); + + assert!(response.starts_with("HTTP/1.1 200"), "got: {response}"); + assert_eq!(value["files"][0]["path"], "src/main.rs"); + assert_eq!(value["files"][0]["index"], "A"); + assert_eq!(value["files"][0]["worktree"], " "); + assert_eq!(value["truncated"], false); + drop(dir); +} + +#[test] +fn commit_file_diff_returns_only_the_selected_path() { + let (dir, server, token, id) = seeded_server(); + let log = get(server.addr(), &format!("/api/log?repo={id}"), Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&log)).unwrap(); + let oid = value["commits"][0]["oid"].as_str().unwrap(); + + let response = get( + server.addr(), + &format!("/api/commit/file-diff?repo={id}&oid={oid}&path=src%2Fmain.rs"), + Some(&token), + ); + let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); + + assert!(response.starts_with("HTTP/1.1 200"), "got: {response}"); + assert_eq!(value["path"], "src/main.rs"); + assert!( + value["hunks"].as_array().unwrap().iter().any(|hunk| { + hunk["file_path"] == "src/main.rs" && !hunk["lines"].as_array().unwrap().is_empty() + }), + "expected a diff for just src/main.rs: {value}" + ); + drop(dir); +} + +#[test] +fn commit_file_diff_allows_a_deleted_path_without_worktree_lookup() { + let (dir, server, token, id) = seeded_server(); + let repo_path = { + let entry = server.state.catalog.get(&id).unwrap(); + entry.path.clone() + }; + let gone = std::path::Path::new(&repo_path).join("gone.txt"); + std::fs::write(&gone, "before delete\n").unwrap(); + run_git(&repo_path, &["add", "gone.txt"]); + run_git(&repo_path, &["commit", "-m", "add gone"]); + run_git(&repo_path, &["rm", "gone.txt"]); + run_git(&repo_path, &["commit", "-m", "delete gone"]); + assert!(!gone.exists(), "test precondition: file must be deleted"); + + let log = get(server.addr(), &format!("/api/log?repo={id}"), Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&log)).unwrap(); + let oid = value["commits"][0]["oid"].as_str().unwrap(); + let response = get( + server.addr(), + &format!("/api/commit/file-diff?repo={id}&oid={oid}&path=gone.txt"), + Some(&token), + ); + let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); + + assert!(response.starts_with("HTTP/1.1 200"), "got: {response}"); + assert!( + value["hunks"] + .as_array() + .unwrap() + .iter() + .flat_map(|h| h["lines"].as_array().unwrap()) + .any(|line| line["kind"] == "-"), + "expected a removal line: {value}" + ); + drop(dir); +} + +#[test] +fn commit_file_diff_rejects_traversal_without_requiring_a_worktree_file() { + let (dir, server, token, id) = seeded_server(); + let log = get(server.addr(), &format!("/api/log?repo={id}"), Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&log)).unwrap(); + let oid = value["commits"][0]["oid"].as_str().unwrap(); + + for attack in ["..%2Fsecret", ".git%2Fconfig", "src%2F..%2Fx"] { + let response = get( + server.addr(), + &format!("/api/commit/file-diff?repo={id}&oid={oid}&path={attack}"), + Some(&token), + ); + assert!( + response.starts_with("HTTP/1.1 400"), + "historical route accepted {attack:?}: {response}" + ); + } + drop(dir); +} + +#[test] +fn diff_returns_hunks_for_a_changed_file() { + let (dir, server, token, id) = seeded_server(); + // Mutate the committed file so a worktree diff exists. + let repo_path = { + let entry = server.state.catalog.get(&id).unwrap(); + entry.path.clone() + }; + std::fs::write( + std::path::Path::new(&repo_path).join("src/main.rs"), + "fn main() { println!(\"hi\"); }\n", + ) + .unwrap(); + + let response = get( + server.addr(), + &format!("/api/diff?repo={id}&path=src%2Fmain.rs"), + Some(&token), + ); + let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); + + let hunks = value["hunks"].as_array().unwrap(); + assert!(!hunks.is_empty(), "expected a hunk: {value}"); + let kinds: Vec<_> = hunks[0]["lines"] + .as_array() + .unwrap() + .iter() + .map(|l| l["kind"].as_str().unwrap()) + .collect(); + assert!( + kinds.contains(&"+") && kinds.contains(&"-"), + "got: {kinds:?}" + ); + drop(dir); +} \ No newline at end of file diff --git a/src/web/viewer/server/tests/mod.rs b/src/web/viewer/server/tests/mod.rs new file mode 100644 index 0000000..9edcad8 --- /dev/null +++ b/src/web/viewer/server/tests/mod.rs @@ -0,0 +1,272 @@ +mod auth; +mod commit_routes; +mod prefs; +mod routes; +mod terminals; + +use super::{ViewerOptions, ViewerServer, VIEWER_SESSION_COOKIE}; +use crate::test_util::{make_repo, run_git}; +use crate::web::common::auth::Auth; +use crate::web::viewer::prefs::PrefsStore; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +pub(super) fn server(paths: &[String]) -> ViewerServer { + server_with(paths, crate::config::AgentIndicatorConfig::default(), None) +} + +/// `prefs_dir` keeps preference writes inside a temp directory. Left `None` +/// the store still points at the real `~/.nightcrow/viewer.json`, so any +/// test that *writes* a preference must pass one. +pub(super) fn server_with( + paths: &[String], + hot: crate::config::AgentIndicatorConfig, + prefs_dir: Option<&std::path::Path>, +) -> ViewerServer { + let prefs = match prefs_dir { + Some(dir) => PrefsStore::at(dir.join("viewer.json")), + None => PrefsStore::at(std::path::PathBuf::from("/nonexistent/nightcrow/viewer.json")), + }; + ViewerServer::start(ViewerOptions { + bind: "127.0.0.1".parse().unwrap(), + port: 0, + auth: Auth::from_plaintext("swordfish").unwrap(), + repos: paths.to_vec(), + // Never persist from tests — they must not touch the real + // ~/.nightcrow/workspace.json. + persist: false, + startup_commands: Vec::new(), + hot, + prefs, + }) + .unwrap() +} + +/// Send a raw request and return the full response text. +pub(super) fn request(addr: SocketAddr, raw: &str) -> String { + let mut stream = TcpStream::connect(addr).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream.write_all(raw.as_bytes()).unwrap(); + let mut buf = Vec::new(); + let _ = stream.read_to_end(&mut buf); + String::from_utf8_lossy(&buf).into_owned() +} + +pub(super) fn get(addr: SocketAddr, path: &str, cookie: Option<&str>) -> String { + let cookie = match cookie { + Some(token) => format!("Cookie: {VIEWER_SESSION_COOKIE}={token}\r\n"), + None => String::new(), + }; + request( + addr, + &format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n{cookie}Connection: close\r\n\r\n"), + ) +} + +pub(super) fn post(addr: SocketAddr, path: &str, body: &str, cookie: Option<&str>) -> String { + let cookie = match cookie { + Some(token) => format!("Cookie: {VIEWER_SESSION_COOKIE}={token}\r\n"), + None => String::new(), + }; + request( + addr, + &format!( + "POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ + Content-Type: application/json\r\n{cookie}\ + Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ), + ) +} + +pub(super) fn delete(addr: SocketAddr, path: &str, cookie: Option<&str>) -> String { + let cookie = match cookie { + Some(token) => format!("Cookie: {VIEWER_SESSION_COOKIE}={token}\r\n"), + None => String::new(), + }; + request( + addr, + &format!( + "DELETE {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n{cookie}Connection: close\r\n\r\n" + ), + ) +} + +/// Log in and return the session token. +pub(super) fn login(addr: SocketAddr) -> String { + let body = "password=swordfish"; + let response = request( + addr, + &format!( + "POST /login HTTP/1.1\r\nHost: 127.0.0.1\r\n\ + Content-Type: application/x-www-form-urlencoded\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ), + ); + assert!( + response.starts_with("HTTP/1.1 200"), + "login failed: {response}" + ); + response + .split("Set-Cookie: ") + .nth(1) + .and_then(|rest| rest.split(';').next()) + .and_then(|pair| pair.split_once('=').map(|(_, v)| v.to_string())) + .expect("a session cookie") +} + +pub(super) fn body_of(response: &str) -> &str { + response.split("\r\n\r\n").nth(1).unwrap_or("") +} + +pub(super) fn seeded_server() -> (tempfile::TempDir, ViewerServer, String, String) { + let (dir, path) = make_repo(); + std::fs::create_dir(std::path::Path::new(&path).join("src")).unwrap(); + std::fs::write( + std::path::Path::new(&path).join("src/main.rs"), + "fn main() {}\n", + ) + .unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "init"]); + + let server = server(std::slice::from_ref(&path)); + let token = login(server.addr()); + let listing = get(server.addr(), "/api/repos", Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&listing)).unwrap(); + let id = value["repos"][0]["id"].as_str().unwrap().to_string(); + (dir, server, token, id) +} + +/// A repository whose history is one commit longer than a page, so the +/// first page is full and a second one exists. +pub(super) fn server_with_paged_history() -> ( + tempfile::TempDir, + ViewerServer, + String, + String, + String, +) { + let (dir, path) = make_repo(); + for i in 0..=crate::web::viewer::limits::MAX_LOG_PAGE { + std::fs::write(std::path::Path::new(&path).join(format!("f{i}")), "x").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", &format!("c{i}")]); + } + let server = server(std::slice::from_ref(&path)); + let token = login(server.addr()); + let listing = get(server.addr(), "/api/repos", Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&listing)).unwrap(); + let id = value["repos"][0]["id"].as_str().unwrap().to_string(); + (dir, server, token, id, path) +} + +pub(super) fn log_page(server: &ViewerServer, token: &str, query: &str) -> serde_json::Value { + let response = get(server.addr(), &format!("/api/log?{query}"), Some(token)); + serde_json::from_str(body_of(&response)).unwrap() +} + +#[test] +fn the_app_shell_is_reachable_without_a_session() { + // The bundle renders the login form, so gating it would leave the user + // with no way to authenticate at all. + let (dir, path) = make_repo(); + let server = server(&[path]); + + let response = get(server.addr(), "/", None); + + assert!(response.starts_with("HTTP/1.1 200"), "got: {response}"); + assert!(response.contains("
"), "not the app shell"); + assert!( + response.contains("Content-Security-Policy"), + "the shell must carry a CSP" + ); + drop(dir); +} + +#[test] +fn an_empty_catalog_serves_cleanly() { + // The TUI can start with no project open, so the viewer alongside it + // sees an empty catalog. That is a legitimate state, not an error. + let server = server(&[]); + let token = login(server.addr()); + + let response = get(server.addr(), "/api/repos", Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); + + assert!(response.starts_with("HTTP/1.1 200"), "got: {response}"); + assert_eq!(value["repos"].as_array().unwrap().len(), 0); +} + +#[test] +fn the_repository_list_serves_the_configured_recently_touched_settings() { + // The client fades its file list on this window; reading the config + // from the server is what keeps it on the TUI's window rather than a + // second default that drifts. + let server = server_with( + &[], + crate::config::AgentIndicatorConfig { + enabled: false, + hot_window_secs: 42, + auto_follow: true, + }, + None, + ); + let token = login(server.addr()); + + let response = get(server.addr(), "/api/repos", Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); + + assert_eq!(value["hot"]["enabled"], false); + assert_eq!(value["hot"]["window_secs"], 42); + // `auto_follow` moves a TUI selection; the browser has no analogue and + // must not be told about it. + assert!(value["hot"].get("auto_follow").is_none()); +} + +#[test] +fn the_repository_list_serves_the_server_clock_for_dating_mtimes() { + // `mtime` is an absolute instant on this machine's clock, so a browser + // on a device whose clock disagrees needs the reference to subtract. + use crate::web::viewer::dto::server_now_millis; + + let server = server_with(&[], crate::config::AgentIndicatorConfig::default(), None); + let token = login(server.addr()); + let before = server_now_millis(); + + let response = get(server.addr(), "/api/repos", Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); + + let now_ms = value["now_ms"].as_u64().expect("now_ms is a number"); + let after = server_now_millis(); + assert!( + (before..=after).contains(&now_ms), + "now_ms {now_ms} outside the request window {before}..={after}", + ); +} + +#[test] +fn repos_lists_the_served_set_by_opaque_id() { + let (dir, path) = make_repo(); + let server = server(std::slice::from_ref(&path)); + let token = login(server.addr()); + + let response = get(server.addr(), "/api/repos", Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); + + assert_eq!(value["version"], crate::web::viewer::dto::PROTOCOL_VERSION); + let repo = &value["repos"][0]; + assert!(repo["id"].as_str().unwrap().starts_with('r')); + let mut keys: Vec<_> = repo.as_object().unwrap().keys().cloned().collect(); + keys.sort(); + assert_eq!( + keys, + vec!["display_path", "id", "name"], + "only the whitelisted identity fields may be listed" + ); + drop((dir, path)); +} \ No newline at end of file diff --git a/src/web/viewer/server/tests/prefs.rs b/src/web/viewer/server/tests/prefs.rs new file mode 100644 index 0000000..52a312b --- /dev/null +++ b/src/web/viewer/server/tests/prefs.rs @@ -0,0 +1,210 @@ +use super::{body_of, get, login, post, server_with}; + +#[test] +fn a_stored_accent_is_served_to_every_later_client() { + // The point of storing it server-side: a second device (a second + // request, here) sees the choice without having made it. + let dir = tempfile::TempDir::new().unwrap(); + let server = server_with( + &[], + crate::config::AgentIndicatorConfig::default(), + Some(dir.path()), + ); + let token = login(server.addr()); + + let stored = post(server.addr(), "/api/prefs", "{\"accent\":3}", Some(&token)); + assert!(stored.starts_with("HTTP/1.1 200"), "got: {stored}"); + + let list = get(server.addr(), "/api/repos", Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&list)).unwrap(); + assert_eq!(value["accent"], 3); +} + +#[test] +fn a_stored_sidebar_width_is_clamped_and_served_to_every_later_client() { + let dir = tempfile::TempDir::new().unwrap(); + let server = server_with( + &[], + crate::config::AgentIndicatorConfig::default(), + Some(dir.path()), + ); + let token = login(server.addr()); + + // Past the ceiling: the write echoes the clamped value, and a later + // client's bootstrap carries the same clamped width — not the raw ask. + let stored = post( + server.addr(), + "/api/prefs", + "{\"sidebar_width\":5000}", + Some(&token), + ); + let echoed: serde_json::Value = serde_json::from_str(body_of(&stored)).unwrap(); + assert_eq!( + echoed["sidebar_width"], + crate::web::viewer::prefs::MAX_SIDEBAR_WIDTH + ); + + let list = get(server.addr(), "/api/repos", Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&list)).unwrap(); + assert_eq!( + value["sidebar_width"], + crate::web::viewer::prefs::MAX_SIDEBAR_WIDTH + ); +} + +#[test] +fn mkdir_creates_a_folder_inside_the_browsed_directory() { + let dir = tempfile::TempDir::new().unwrap(); + let server = server_with( + &[], + crate::config::AgentIndicatorConfig::default(), + Some(dir.path()), + ); + let token = login(server.addr()); + + let body = serde_json::json!({ + "path": dir.path().to_str().unwrap(), + "name": "scratch", + }) + .to_string(); + let created = post(server.addr(), "/api/mkdir", &body, Some(&token)); + assert!(created.starts_with("HTTP/1.1 200"), "got: {created}"); + assert!( + dir.path().join("scratch").is_dir(), + "the folder must exist on disk" + ); + + let value: serde_json::Value = serde_json::from_str(body_of(&created)).unwrap(); + let path = value["path"].as_str().unwrap(); + assert!( + std::path::Path::new(path).ends_with("scratch"), + "the response names the new folder, got: {path}" + ); +} + +#[test] +fn mkdir_rejects_a_name_that_would_escape_the_browsed_directory() { + let dir = tempfile::TempDir::new().unwrap(); + let server = server_with( + &[], + crate::config::AgentIndicatorConfig::default(), + Some(dir.path()), + ); + let token = login(server.addr()); + + // Separators, traversal, and a leading dot (which also covers `.git` + // and hidden entries) are all refused so the create cannot leave the + // browsed directory. + for name in ["../escape", "a/b", "..", ".git", ".hidden"] { + let body = serde_json::json!({ + "path": dir.path().to_str().unwrap(), + "name": name, + }) + .to_string(); + let response = post(server.addr(), "/api/mkdir", &body, Some(&token)); + assert!( + response.starts_with("HTTP/1.1 400"), + "name {name:?} must be rejected, got: {response}" + ); + } + assert!( + !dir.path().parent().unwrap().join("escape").exists(), + "traversal must not create anything above the parent" + ); +} + +#[test] +fn mkdir_requires_authentication() { + let dir = tempfile::TempDir::new().unwrap(); + let server = server_with( + &[], + crate::config::AgentIndicatorConfig::default(), + Some(dir.path()), + ); + + let body = serde_json::json!({ + "path": dir.path().to_str().unwrap(), + "name": "nope", + }) + .to_string(); + let response = post(server.addr(), "/api/mkdir", &body, None); + assert!( + response.starts_with("HTTP/1.1 401"), + "an unauthenticated mkdir must be refused, got: {response}" + ); + assert!( + !dir.path().join("nope").exists(), + "nothing may be created without a session" + ); +} + +#[test] +fn setting_one_preference_leaves_the_other_untouched() { + let dir = tempfile::TempDir::new().unwrap(); + let server = server_with( + &[], + crate::config::AgentIndicatorConfig::default(), + Some(dir.path()), + ); + let token = login(server.addr()); + + post(server.addr(), "/api/prefs", "{\"accent\":3}", Some(&token)); + let stored = post( + server.addr(), + "/api/prefs", + "{\"sidebar_width\":500}", + Some(&token), + ); + + // The width write must not reset the accent stored a moment earlier. + let echoed: serde_json::Value = serde_json::from_str(body_of(&stored)).unwrap(); + assert_eq!(echoed["accent"], 3); + assert_eq!(echoed["sidebar_width"], 500); +} + +#[test] +fn a_preference_body_naming_nothing_known_is_rejected() { + let dir = tempfile::TempDir::new().unwrap(); + let server = server_with( + &[], + crate::config::AgentIndicatorConfig::default(), + Some(dir.path()), + ); + let token = login(server.addr()); + + let response = post(server.addr(), "/api/prefs", "{\"nope\":1}", Some(&token)); + + assert!(response.starts_with("HTTP/1.1 400"), "got: {response}"); +} + +#[test] +fn storing_a_preference_requires_authentication() { + let dir = tempfile::TempDir::new().unwrap(); + let server = server_with( + &[], + crate::config::AgentIndicatorConfig::default(), + Some(dir.path()), + ); + + let response = post(server.addr(), "/api/prefs", "{\"accent\":3}", None); + + assert!(response.starts_with("HTTP/1.1 401"), "got: {response}"); +} + +#[test] +fn a_malformed_preference_body_is_rejected_without_changing_anything() { + let dir = tempfile::TempDir::new().unwrap(); + let server = server_with( + &[], + crate::config::AgentIndicatorConfig::default(), + Some(dir.path()), + ); + let token = login(server.addr()); + + let response = post(server.addr(), "/api/prefs", "{\"accent\":\"red\"}", Some(&token)); + + assert!(response.starts_with("HTTP/1.1 400"), "got: {response}"); + let list = get(server.addr(), "/api/repos", Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&list)).unwrap(); + assert_eq!(value["accent"], 0); +} \ No newline at end of file diff --git a/src/web/viewer/server/tests/routes.rs b/src/web/viewer/server/tests/routes.rs new file mode 100644 index 0000000..ee7e48c --- /dev/null +++ b/src/web/viewer/server/tests/routes.rs @@ -0,0 +1,230 @@ +use super::{ + body_of, get, log_page, seeded_server, server_with_paged_history, +}; +use crate::test_util::run_git; +use crate::web::viewer::limits; + +#[test] +fn the_log_reports_more_history_and_serves_the_next_page() { + let (dir, server, token, id, _path) = server_with_paged_history(); + + let first = log_page(&server, &token, &format!("repo={id}")); + let anchor = first["head"].as_str().expect("first page carries an anchor"); + let second = log_page( + &server, + &token, + &format!("repo={id}&from={anchor}&skip={}", limits::MAX_LOG_PAGE), + ); + + assert_eq!(first["commits"].as_array().unwrap().len(), limits::MAX_LOG_PAGE); + // The page is full *and* the history continues — the distinction the + // extra fetched entry exists to make. + assert_eq!(first["truncated"], true); + assert_eq!(second["commits"].as_array().unwrap().len(), 1); + assert_eq!(second["truncated"], false); + assert_eq!(second["commits"][0]["summary"], "c0"); + drop(dir); +} + +#[test] +fn a_pinned_log_page_ignores_commits_made_after_the_first() { + let (dir, server, token, id, path) = server_with_paged_history(); + let first = log_page(&server, &token, &format!("repo={id}")); + let anchor = first["head"].as_str().unwrap().to_string(); + let newest = first["commits"][0]["oid"].as_str().unwrap().to_string(); + + // A commit lands between the two page requests, as one made in the + // terminal panel below the list would. + std::fs::write(std::path::Path::new(&path).join("late"), "x").unwrap(); + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-m", "late"]); + + let second = log_page( + &server, + &token, + &format!("repo={id}&from={anchor}&skip={}", limits::MAX_LOG_PAGE), + ); + + assert_eq!(anchor, newest, "the anchor is the walk's first commit"); + // Without pinning, this page would start at c1 — repeating a commit the + // client already holds. + assert_eq!(second["commits"][0]["summary"], "c0"); + assert_eq!(second["commits"].as_array().unwrap().len(), 1); + drop(dir); +} + +#[test] +fn the_log_rejects_a_malformed_anchor_or_skip() { + let (dir, server, token, id) = seeded_server(); + + let bad_from = get( + server.addr(), + &format!("/api/log?repo={id}&from=not-an-oid"), + Some(&token), + ); + let bad_skip = get( + server.addr(), + &format!("/api/log?repo={id}&skip=-1"), + Some(&token), + ); + // Falling back to HEAD would answer a different question than the one + // asked, and the client pages against what it gets back. + assert!(bad_from.starts_with("HTTP/1.1 400"), "got: {bad_from}"); + assert!(bad_skip.starts_with("HTTP/1.1 400"), "got: {bad_skip}"); + drop(dir); +} + +#[test] +fn a_skip_past_the_end_of_history_is_an_empty_last_page() { + // Not an error and not a ceiling: the revwalk simply runs out. A skip + // far beyond the history costs what walking the history costs, which is + // why the parameter needs no cap. + let (dir, server, token, id) = seeded_server(); + + let value = log_page(&server, &token, &format!("repo={id}&skip=100000000")); + + assert_eq!(value["commits"].as_array().unwrap().len(), 0); + assert_eq!(value["truncated"], false); + drop(dir); +} + +#[test] +fn tree_lists_a_directory_level() { + let (dir, server, token, id) = seeded_server(); + + let response = get(server.addr(), &format!("/api/tree?repo={id}"), Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); + + let names: Vec<_> = value["entries"] + .as_array() + .unwrap() + .iter() + .map(|e| e["name"].as_str().unwrap()) + .collect(); + assert!(names.contains(&"src"), "got: {names:?}"); + assert!(!names.contains(&".git"), "git metadata must not be listed"); + drop(dir); +} + +#[test] +fn tree_search_finds_a_nested_file_by_name() { + let (dir, server, token, id) = seeded_server(); + + let response = get( + server.addr(), + &format!("/api/tree/search?repo={id}&q=main"), + Some(&token), + ); + let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); + + let paths: Vec<_> = value["matches"] + .as_array() + .unwrap() + .iter() + .map(|m| m["path"].as_str().unwrap()) + .collect(); + // The match lives one level down, which the single-level /api/tree could + // not surface. + assert_eq!(paths, vec!["src/main.rs"]); + assert_eq!(value["truncated"], false); + drop(dir); +} + +#[test] +fn tree_search_with_an_empty_query_returns_no_matches() { + let (dir, server, token, id) = seeded_server(); + + let response = get( + server.addr(), + &format!("/api/tree/search?repo={id}&q="), + Some(&token), + ); + let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); + + assert!(value["matches"].as_array().unwrap().is_empty()); + drop(dir); +} + +#[test] +fn a_traversal_path_is_refused_by_every_route_that_takes_one() { + let (dir, server, token, id) = seeded_server(); + + for route in ["tree", "file", "diff"] { + for attack in ["../../etc/passwd", ".git/config", "src/../.git/config"] { + let encoded = attack.replace('/', "%2F"); + let response = get( + server.addr(), + &format!("/api/{route}?repo={id}&path={encoded}"), + Some(&token), + ); + assert!( + response.starts_with("HTTP/1.1 400"), + "{route} accepted {attack:?}: {response}" + ); + } + } + drop(dir); +} + +#[test] +fn an_error_response_leaks_no_filesystem_detail() { + let (dir, server, token, id) = seeded_server(); + + let response = get( + server.addr(), + &format!("/api/file?repo={id}&path=nope.txt"), + Some(&token), + ); + + let body = body_of(&response); + assert!(!body.contains('/'), "a path leaked into the error: {body}"); + assert!( + !body.contains("No such file"), + "the io error leaked: {body}" + ); + drop(dir); +} + +#[test] +fn file_returns_worktree_content() { + let (dir, server, token, id) = seeded_server(); + + let response = get( + server.addr(), + &format!("/api/file?repo={id}&path=src%2Fmain.rs"), + Some(&token), + ); + let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); + + // Content is returned as per-line, syntax-highlighted spans. Rebuild the + // text from them and confirm it round-trips. + let text: String = value["lines"] + .as_array() + .unwrap() + .iter() + .flat_map(|line| line.as_array().unwrap()) + .map(|span| span["t"].as_str().unwrap()) + .collect::>() + .join(""); + assert_eq!(text, "fn main() {}"); + assert_eq!(value["truncated"], false); + drop(dir); +} + +#[test] +fn log_returns_commits() { + let (dir, server, token, id) = seeded_server(); + + let response = get(server.addr(), &format!("/api/log?repo={id}"), Some(&token)); + let value: serde_json::Value = serde_json::from_str(body_of(&response)).unwrap(); + + let commits = value["commits"].as_array().unwrap(); + assert_eq!(commits.len(), 1); + assert_eq!(commits[0]["summary"], "init"); + assert_eq!( + commits[0]["oid"].as_str().unwrap().len(), + 40, + "the oid must be hex, not libgit2's own shape" + ); + drop(dir); +} \ No newline at end of file diff --git a/src/web/viewer/server/tests/terminals.rs b/src/web/viewer/server/tests/terminals.rs new file mode 100644 index 0000000..700f711 --- /dev/null +++ b/src/web/viewer/server/tests/terminals.rs @@ -0,0 +1,129 @@ +use super::{get, request, seeded_server, VIEWER_SESSION_COOKIE}; +use crate::web::viewer::terminal; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::time::Duration; + +#[test] +fn the_events_stream_sends_a_status_event() { + let (dir, server, token, id) = seeded_server(); + + let mut stream = TcpStream::connect(server.addr()).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .write_all( + format!( + "GET /api/events?repo={id} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ + Cookie: {VIEWER_SESSION_COOKIE}={token}\r\n\r\n" + ) + .as_bytes(), + ) + .unwrap(); + + // Read until the first dispatched event or the read budget runs out. + let mut seen = String::new(); + let mut chunk = [0u8; 2048]; + while !seen.contains("event: status") { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => seen.push_str(&String::from_utf8_lossy(&chunk[..n])), + } + } + + assert!( + seen.starts_with("HTTP/1.1 200"), + "expected a streaming head: {seen}" + ); + assert!( + seen.contains("text/event-stream"), + "expected an SSE content type: {seen}" + ); + assert!(!seen.contains("Content-Length"), "SSE must not declare one"); + assert!(seen.contains("event: status"), "no status event: {seen}"); + drop(dir); +} + +#[test] +fn the_terminal_socket_creates_a_pane_and_streams_its_output() { + use tungstenite::client::IntoClientRequest; + + let (dir, server, token, id) = seeded_server(); + let mut request = format!("ws://{}/ws/term?repo={id}", server.addr()) + .into_client_request() + .unwrap(); + request.headers_mut().insert( + "Cookie", + format!("{VIEWER_SESSION_COOKIE}={token}").parse().unwrap(), + ); + let (mut ws, _) = tungstenite::connect(request).expect("terminal upgrade"); + + ws.send(tungstenite::Message::Text( + r#"{"type":"create","rows":24,"cols":80}"#.into(), + )) + .unwrap(); + + // Expect created control frames, then real PTY bytes tagged with a pane + // id — proving the multiplexing round-trips end to end. More than one + // pane can appear: the first connect also spawns the default startup + // terminal, so track every announced pane and require output for one. + let mut created = std::collections::HashSet::new(); + let mut saw_output = false; + for _ in 0..40 { + match ws.read() { + Ok(tungstenite::Message::Text(text)) if text.contains("created") => { + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + if let Some(pane) = value["pane"].as_u64() { + created.insert(pane as u32); + } + } + Ok(tungstenite::Message::Binary(bytes)) => { + let (pane, data) = terminal::decode_output(&bytes).expect("a tagged frame"); + assert!(created.contains(&pane), "output for an unannounced pane"); + if !data.is_empty() { + saw_output = true; + break; + } + } + Ok(_) => {} + Err(_) => break, + } + } + + assert!(!created.is_empty(), "no created frame"); + assert!(saw_output, "no PTY output reached the socket"); + drop(dir); +} + +#[test] +fn the_terminal_socket_requires_auth_and_a_known_repo() { + let (dir, server, token, _id) = seeded_server(); + + let anon = get(server.addr(), "/ws/term?repo=r1", None); + assert!(anon.starts_with("HTTP/1.1 401"), "got: {anon}"); + + // Authenticated but unknown: refused before any upgrade happens. + let unknown = request( + server.addr(), + &format!( + "GET /ws/term?repo=r9999 HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\n\ + Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ + Sec-WebSocket-Version: 13\r\nCookie: {VIEWER_SESSION_COOKIE}={token}\r\n\r\n" + ), + ); + assert!(unknown.starts_with("HTTP/1.1 404"), "got: {unknown}"); + drop(dir); +} + +#[test] +fn the_events_stream_requires_auth_and_a_known_repo() { + let (dir, server, token, _id) = seeded_server(); + + let anon = get(server.addr(), "/api/events?repo=r1", None); + assert!(anon.starts_with("HTTP/1.1 401"), "got: {anon}"); + + let unknown = get(server.addr(), "/api/events?repo=r9999", Some(&token)); + assert!(unknown.starts_with("HTTP/1.1 404"), "got: {unknown}"); + drop(dir); +} \ No newline at end of file diff --git a/src/web/viewer/terminal/frame.rs b/src/web/viewer/terminal/frame.rs new file mode 100644 index 0000000..a609511 --- /dev/null +++ b/src/web/viewer/terminal/frame.rs @@ -0,0 +1,60 @@ +use crate::backend::PaneId; +use serde::{Deserialize, Serialize}; + +/// A control message from the browser. Output travels as binary frames +/// instead, so it never pays JSON escaping or base64 expansion. +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum ClientMessage { + Create { rows: u16, cols: u16 }, + Input { pane: PaneId, data: String }, + Resize { pane: PaneId, rows: u16, cols: u16 }, + Close { pane: PaneId }, + /// A full desired sequence of the live pane ids, sent when a client drags a + /// pane to a new slot. The hub reconciles it (see [`super::TerminalHub::reorder_panes`]). + Reorder { order: Vec }, +} + +/// A control message to the browser. +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum ServerMessage { + Created { pane: PaneId }, + Exited { pane: PaneId }, + Error { message: String }, + /// The canonical pane order after a reorder, broadcast to every client so + /// the sender and any other device converge on the same layout. + Reordered { order: Vec }, +} + +/// One frame queued for a connected client. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TerminalFrame { + /// Raw PTY bytes for `pane`. Sent as a binary WebSocket frame with the + /// pane id prefixed, so one socket multiplexes every terminal losslessly. + Output { pane: PaneId, data: Vec }, + /// A JSON control frame. + Control(String), +} + +/// Encode an output frame: 4-byte little-endian pane id, then the raw bytes. +/// +/// Binary rather than JSON because PTY output is not guaranteed valid UTF-8 — +/// a multi-byte sequence is routinely split across reads, and lossy decoding +/// would corrupt it before xterm.js ever reassembles it. +pub fn encode_output(pane: PaneId, data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(data.len() + 4); + out.extend_from_slice(&pane.to_le_bytes()); + out.extend_from_slice(data); + out +} + +/// Decode an output frame produced by [`encode_output`]. +pub fn decode_output(frame: &[u8]) -> Option<(PaneId, &[u8])> { + if frame.len() < 4 { + return None; + } + let (id_bytes, rest) = frame.split_at(4); + let pane = PaneId::from_le_bytes(id_bytes.try_into().ok()?); + Some((pane, rest)) +} \ No newline at end of file diff --git a/src/web/viewer/terminal/hub_helpers.rs b/src/web/viewer/terminal/hub_helpers.rs new file mode 100644 index 0000000..59e20bc --- /dev/null +++ b/src/web/viewer/terminal/hub_helpers.rs @@ -0,0 +1,89 @@ +use crate::backend::PaneId; +use super::frame::TerminalFrame; +use crate::web::viewer::limits; +use std::collections::VecDeque; +use std::sync::mpsc::TrySendError; + +use super::session::Client; + +pub enum Command { + /// `command` is `Some` only for startup panes (run via `$SHELL -lc`); + /// client-initiated creates always pass `None` for a bare interactive shell. + Create { + rows: u16, + cols: u16, + client: u64, + command: Option, + }, + Input { pane: PaneId, data: Vec }, + Resize { pane: PaneId, rows: u16, cols: u16 }, + Close { pane: PaneId }, + Reorder { order: Vec }, +} + +/// A live terminal and the recent raw bytes it has produced, kept so a client +/// that connects (or reconnects after a refresh) can be replayed the current +/// screen. Bounded by [`limits::MAX_TERMINAL_SCROLLBACK_BYTES`]. +pub(super) struct PaneState { + pub(super) id: PaneId, + pub(super) scrollback: VecDeque, +} + +/// 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: it sees +/// each pane's scrollback exactly once, with no gap before or duplicate of the +/// live output that follows. +pub struct Shared { + pub(super) clients: Vec, + pub(super) panes: Vec, +} + +/// Queue a frame for every client, dropping any that has fallen too far behind. +/// Terminal bytes cannot be skipped, so an overfull client is disconnected +/// rather than served a corrupted stream. Operates on an already-locked client +/// list so the caller can pair it with a pane mutation atomically. +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(_)) => { + tracing::debug!(id = client.id, "viewer: terminal client too slow, dropping"); + false + } + Err(TrySendError::Disconnected(_)) => false, + }); +} + +/// The canonical pane order for a reorder request: the requested ids that are +/// actually live, in the requested order, followed by any live pane the request +/// omitted, in its current order. Unknown requested ids are dropped. The result +/// is always a permutation of `current`, which is what makes a reorder safe +/// against a create or close that raced the request. +pub(super) fn canonical_order(current: &[PaneId], requested: &[PaneId]) -> Vec { + let mut out: Vec = Vec::with_capacity(current.len()); + // Requested ids first: live, and each taken once (a repeated id would make + // the result a non-permutation of `current`). + for id in requested { + if current.contains(id) && !out.contains(id) { + out.push(*id); + } + } + // Then any live pane the request left out, in its current order. + for id in current { + if !out.contains(id) { + out.push(*id); + } + } + out +} + +/// Append raw PTY bytes to a pane's scrollback, evicting the oldest bytes to +/// stay within [`limits::MAX_TERMINAL_SCROLLBACK_BYTES`]. +pub(super) fn push_scrollback(buf: &mut VecDeque, data: &[u8]) { + buf.extend(data.iter().copied()); + if buf.len() > limits::MAX_TERMINAL_SCROLLBACK_BYTES { + let excess = buf.len() - limits::MAX_TERMINAL_SCROLLBACK_BYTES; + buf.drain(0..excess); + } +} \ No newline at end of file diff --git a/src/web/viewer/terminal/hub_run.rs b/src/web/viewer/terminal/hub_run.rs new file mode 100644 index 0000000..95f4c5e --- /dev/null +++ b/src/web/viewer/terminal/hub_run.rs @@ -0,0 +1,203 @@ +use super::TerminalHub; +use crate::backend::{BackendEvent, PaneId, PtyBackend, TerminalBackend}; +use super::frame::{ServerMessage, TerminalFrame}; +use super::hub_helpers::{ + Command, PaneState, broadcast_locked, canonical_order, push_scrollback, +}; +use crate::web::viewer::limits; +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Receiver; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +const POLL_INTERVAL: Duration = Duration::from_millis(8); + +impl TerminalHub { + pub(super) fn run( + &self, + cwd: &str, + commands: Receiver, + stop: Arc, + ) { + let mut backend = PtyBackend::new(cwd); + + while !stop.load(Ordering::Acquire) { + while let Ok(command) = commands.try_recv() { + match command { + Command::Create { + rows, + cols, + client, + command, + } => { + if self.pane_count() >= limits::MAX_PTYS_PER_REPO { + self.send_error_to(client, "terminal limit reached"); + continue; + } + match backend.create_pane(rows, cols, command.as_deref()) { + Ok(pane) => self.register_pane(pane), + Err(err) => { + tracing::warn!(%err, "viewer: could not create a terminal"); + self.send_error_to(client, "could not start a terminal"); + } + } + } + // Unknown pane ids are ignored rather than errored: a + // client racing a pane exit is normal, not an attack. + Command::Input { pane, data } if self.pane_is_live(pane) => { + let _ = backend.send_input(pane, &data); + } + Command::Resize { pane, rows, cols } if self.pane_is_live(pane) => { + backend.resize(pane, rows, cols); + } + Command::Close { pane } if self.pane_is_live(pane) => { + backend.destroy_pane(pane); + self.remove_pane_and_announce(pane); + } + Command::Reorder { order } => self.reorder_panes(order), + _ => {} + } + } + + for event in backend.drain_events() { + match event { + BackendEvent::Output { pane, data } => self.record_and_broadcast(pane, data), + BackendEvent::Exited { pane } => self.remove_pane_and_announce(pane), + } + } + thread::sleep(POLL_INTERVAL); + } + + let ids: Vec = self + .state + .lock() + .expect("terminal state poisoned") + .panes + .iter() + .map(|p| p.id) + .collect(); + for pane in ids { + backend.destroy_pane(pane); + } + // 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. + self.state + .lock() + .expect("terminal state poisoned") + .panes + .clear(); + } + + fn pane_count(&self) -> usize { + self.state + .lock() + .expect("terminal state poisoned") + .panes + .len() + } + + fn pane_is_live(&self, pane: PaneId) -> bool { + self.state + .lock() + .expect("terminal state poisoned") + .panes + .iter() + .any(|p| p.id == pane) + } + + /// Record a new pane and announce it to every client. Broadcasting under the + /// same lock that adds the pane keeps it consistent with `connect`'s replay: + /// a client either sees this pane via `connect` or via this broadcast, never + /// both and never neither. + fn register_pane(&self, pane: PaneId) { + let json = serde_json::to_string(&ServerMessage::Created { pane }).ok(); + let mut state = self.state.lock().expect("terminal state poisoned"); + state.panes.push(PaneState { + id: pane, + scrollback: VecDeque::new(), + }); + if let Some(json) = json { + broadcast_locked(&mut state.clients, TerminalFrame::Control(json)); + } + } + + /// Append output to the pane's bounded scrollback and broadcast it, both + /// under one lock so a concurrently connecting client cannot slip a replay + /// snapshot between the append and the broadcast. + fn record_and_broadcast(&self, pane: PaneId, data: Vec) { + let mut state = self.state.lock().expect("terminal state poisoned"); + if let Some(p) = state.panes.iter_mut().find(|p| p.id == pane) { + push_scrollback(&mut p.scrollback, &data); + } + broadcast_locked(&mut state.clients, TerminalFrame::Output { pane, data }); + } + + /// Drop a pane and tell every client, but only if it was still live — a pane + /// closed by command and then reported `Exited` by the backend must announce + /// once, not twice. + fn remove_pane_and_announce(&self, pane: PaneId) { + let json = serde_json::to_string(&ServerMessage::Exited { pane }).ok(); + let mut state = self.state.lock().expect("terminal state poisoned"); + let existed = state.panes.iter().any(|p| p.id == pane); + if !existed { + return; + } + state.panes.retain(|p| p.id != pane); + if let Some(json) = json { + broadcast_locked(&mut state.clients, TerminalFrame::Control(json)); + } + } + + /// 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. + 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(); + let target = canonical_order(&before, &order); + if target == before { + return; + } + // `target` is a permutation of `before`, so every id resolves and `old` + // ends empty. Move each `PaneState` rather than clone it — it owns the + // pane's scrollback. + let mut old = std::mem::take(&mut state.panes); + let mut reordered = Vec::with_capacity(old.len()); + for id in &target { + if let Some(pos) = old.iter().position(|p| p.id == *id) { + reordered.push(old.remove(pos)); + } + } + state.panes = reordered; + if let Ok(json) = serde_json::to_string(&ServerMessage::Reordered { order: target }) { + broadcast_locked(&mut state.clients, TerminalFrame::Control(json)); + } + } + + fn send_error_to(&self, client_id: u64, message: &str) { + let Ok(json) = serde_json::to_string(&ServerMessage::Error { + message: message.to_string(), + }) else { + return; + }; + let mut state = self.state.lock().expect("terminal state poisoned"); + if let Some(index) = state.clients.iter().position(|c| c.id == client_id) + && state.clients[index] + .tx + .try_send(TerminalFrame::Control(json)) + .is_err() + { + state.clients.remove(index); + } + } +} \ No newline at end of file diff --git a/src/web/viewer/terminal/mod.rs b/src/web/viewer/terminal/mod.rs index 7f1662a..2c8e888 100644 --- a/src/web/viewer/terminal/mod.rs +++ b/src/web/viewer/terminal/mod.rs @@ -15,175 +15,31 @@ //! disconnected when it overflows, which is honest, where silently discarding //! bytes would leave a subtly wrong screen. -use crate::backend::{BackendEvent, PaneId, PtyBackend, TerminalBackend}; -use crate::web::viewer::limits; -use serde::{Deserialize, Serialize}; -use std::collections::VecDeque; +mod frame; +mod hub_helpers; +mod hub_run; +mod session; + +pub use frame::{ + ClientMessage, ServerMessage, TerminalFrame, encode_output, +}; +#[cfg(test)] +pub use frame::decode_output; +pub use session::TerminalSession; + +use hub_helpers::{Command, Shared}; +use session::Client; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError}; +use std::sync::mpsc::{self, SyncSender}; use std::sync::{Arc, Mutex}; use std::thread; -use std::time::Duration; - -/// How often the hub thread services commands and drains PTY output. Terminal -/// latency is felt directly, so this is much tighter than the status poll. -const POLL_INTERVAL: Duration = Duration::from_millis(8); /// Output frames a client may fall behind by before it is dropped. const CLIENT_QUEUE_DEPTH: usize = 256; -/// A control message from the browser. Output travels as binary frames -/// instead, so it never pays JSON escaping or base64 expansion. -#[derive(Debug, Deserialize)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum ClientMessage { - Create { rows: u16, cols: u16 }, - Input { pane: PaneId, data: String }, - Resize { pane: PaneId, rows: u16, cols: u16 }, - Close { pane: PaneId }, - /// A full desired sequence of the live pane ids, sent when a client drags a - /// pane to a new slot. The hub reconciles it (see [`TerminalHub::reorder_panes`]). - Reorder { order: Vec }, -} - -/// A control message to the browser. -#[derive(Debug, Serialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum ServerMessage { - Created { pane: PaneId }, - Exited { pane: PaneId }, - Error { message: String }, - /// The canonical pane order after a reorder, broadcast to every client so - /// the sender and any other device converge on the same layout. - Reordered { order: Vec }, -} - -/// One frame queued for a connected client. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TerminalFrame { - /// Raw PTY bytes for `pane`. Sent as a binary WebSocket frame with the - /// pane id prefixed, so one socket multiplexes every terminal losslessly. - Output { pane: PaneId, data: Vec }, - /// A JSON control frame. - Control(String), -} - -/// Encode an output frame: 4-byte little-endian pane id, then the raw bytes. -/// -/// Binary rather than JSON because PTY output is not guaranteed valid UTF-8 — -/// a multi-byte sequence is routinely split across reads, and lossy decoding -/// would corrupt it before xterm.js ever reassembles it. -pub fn encode_output(pane: PaneId, data: &[u8]) -> Vec { - let mut out = Vec::with_capacity(data.len() + 4); - out.extend_from_slice(&pane.to_le_bytes()); - out.extend_from_slice(data); - out -} - -/// Decode an output frame produced by [`encode_output`]. -pub fn decode_output(frame: &[u8]) -> Option<(PaneId, &[u8])> { - if frame.len() < 4 { - return None; - } - let (id_bytes, rest) = frame.split_at(4); - let pane = PaneId::from_le_bytes(id_bytes.try_into().ok()?); - Some((pane, rest)) -} - -enum Command { - /// `command` is `Some` only for startup panes (run via `$SHELL -lc`); - /// client-initiated creates always pass `None` for a bare interactive shell. - Create { - rows: u16, - cols: u16, - client: u64, - command: Option, - }, - Input { pane: PaneId, data: Vec }, - Resize { pane: PaneId, rows: u16, cols: u16 }, - Close { pane: PaneId }, - Reorder { order: Vec }, -} - -struct Client { - id: u64, - tx: SyncSender, -} - -/// A live terminal and the recent raw bytes it has produced, kept so a client -/// that connects (or reconnects after a refresh) can be replayed the current -/// screen. Bounded by [`limits::MAX_TERMINAL_SCROLLBACK_BYTES`]. -struct PaneState { - id: PaneId, - scrollback: VecDeque, -} - -/// 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: it sees -/// each pane's scrollback exactly once, with no gap before or duplicate of the -/// live output that follows. -struct Shared { - clients: Vec, - panes: Vec, -} - -/// A client's connection to a repository's terminals. -pub struct TerminalSession { - hub: Arc, - id: u64, - rx: Receiver, -} - -impl TerminalSession { - /// Wait up to `timeout` for the next frame to write. - pub fn next_frame(&self, timeout: Duration) -> Option { - self.rx.recv_timeout(timeout).ok() - } - - /// Handle a decoded control message from this client. - pub fn dispatch(&self, message: ClientMessage) { - let command = match message { - ClientMessage::Create { rows, cols } => Command::Create { - rows: rows.max(1), - cols: cols.max(1), - client: self.id, - command: None, - }, - ClientMessage::Input { pane, data } => Command::Input { - pane, - data: data.into_bytes(), - }, - ClientMessage::Resize { pane, rows, cols } => Command::Resize { - pane, - rows: rows.max(1), - cols: cols.max(1), - }, - ClientMessage::Close { pane } => Command::Close { pane }, - ClientMessage::Reorder { order } => Command::Reorder { order }, - }; - // Never block the connection thread here. The hub drains this queue - // from the same thread that writes to a PTY master, and that write - // blocks forever if the child has stopped reading stdin — a blocking - // send would then wedge every connection thread for this repository. - // Dropping a command under that much backpressure is the honest - // outcome; the client is already far ahead of what the shell can take. - if let Err(TrySendError::Full(_)) = self.hub.commands.try_send(command) { - tracing::debug!("viewer: terminal command queue full, dropping"); - } - } -} - -impl Drop for TerminalSession { - fn drop(&mut self) { - self.hub.disconnect(self.id); - } -} - pub struct TerminalHub { - commands: SyncSender, - state: Mutex, + pub(super) commands: SyncSender, + pub(super) state: Mutex, next_client_id: AtomicU64, stop: Arc, worker: Mutex>>, @@ -224,187 +80,6 @@ impl TerminalHub { hub } - fn run(&self, cwd: &str, commands: Receiver, stop: Arc) { - let mut backend = PtyBackend::new(cwd); - - while !stop.load(Ordering::Acquire) { - while let Ok(command) = commands.try_recv() { - match command { - Command::Create { - rows, - cols, - client, - command, - } => { - if self.pane_count() >= limits::MAX_PTYS_PER_REPO { - self.send_error_to(client, "terminal limit reached"); - continue; - } - match backend.create_pane(rows, cols, command.as_deref()) { - Ok(pane) => self.register_pane(pane), - Err(err) => { - tracing::warn!(%err, "viewer: could not create a terminal"); - self.send_error_to(client, "could not start a terminal"); - } - } - } - // Unknown pane ids are ignored rather than errored: a - // client racing a pane exit is normal, not an attack. - Command::Input { pane, data } if self.pane_is_live(pane) => { - let _ = backend.send_input(pane, &data); - } - Command::Resize { pane, rows, cols } if self.pane_is_live(pane) => { - backend.resize(pane, rows, cols); - } - Command::Close { pane } if self.pane_is_live(pane) => { - backend.destroy_pane(pane); - self.remove_pane_and_announce(pane); - } - Command::Reorder { order } => self.reorder_panes(order), - _ => {} - } - } - - for event in backend.drain_events() { - match event { - BackendEvent::Output { pane, data } => self.record_and_broadcast(pane, data), - BackendEvent::Exited { pane } => self.remove_pane_and_announce(pane), - } - } - thread::sleep(POLL_INTERVAL); - } - - let ids: Vec = self - .state - .lock() - .expect("terminal state poisoned") - .panes - .iter() - .map(|p| p.id) - .collect(); - for pane in ids { - backend.destroy_pane(pane); - } - // 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. - self.state - .lock() - .expect("terminal state poisoned") - .panes - .clear(); - } - - fn pane_count(&self) -> usize { - self.state - .lock() - .expect("terminal state poisoned") - .panes - .len() - } - - fn pane_is_live(&self, pane: PaneId) -> bool { - self.state - .lock() - .expect("terminal state poisoned") - .panes - .iter() - .any(|p| p.id == pane) - } - - /// Record a new pane and announce it to every client. Broadcasting under the - /// same lock that adds the pane keeps it consistent with `connect`'s replay: - /// a client either sees this pane via `connect` or via this broadcast, never - /// both and never neither. - fn register_pane(&self, pane: PaneId) { - let json = serde_json::to_string(&ServerMessage::Created { pane }).ok(); - let mut state = self.state.lock().expect("terminal state poisoned"); - state.panes.push(PaneState { - id: pane, - scrollback: VecDeque::new(), - }); - if let Some(json) = json { - broadcast_locked(&mut state.clients, TerminalFrame::Control(json)); - } - } - - /// Append output to the pane's bounded scrollback and broadcast it, both - /// under one lock so a concurrently connecting client cannot slip a replay - /// snapshot between the append and the broadcast. - fn record_and_broadcast(&self, pane: PaneId, data: Vec) { - let mut state = self.state.lock().expect("terminal state poisoned"); - if let Some(p) = state.panes.iter_mut().find(|p| p.id == pane) { - push_scrollback(&mut p.scrollback, &data); - } - broadcast_locked(&mut state.clients, TerminalFrame::Output { pane, data }); - } - - /// Drop a pane and tell every client, but only if it was still live — a pane - /// closed by command and then reported `Exited` by the backend must announce - /// once, not twice. - fn remove_pane_and_announce(&self, pane: PaneId) { - let json = serde_json::to_string(&ServerMessage::Exited { pane }).ok(); - let mut state = self.state.lock().expect("terminal state poisoned"); - let existed = state.panes.iter().any(|p| p.id == pane); - if !existed { - return; - } - state.panes.retain(|p| p.id != pane); - if let Some(json) = json { - broadcast_locked(&mut state.clients, TerminalFrame::Control(json)); - } - } - - /// 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. - 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(); - let target = canonical_order(&before, &order); - if target == before { - return; - } - // `target` is a permutation of `before`, so every id resolves and `old` - // ends empty. Move each `PaneState` rather than clone it — it owns the - // pane's scrollback. - let mut old = std::mem::take(&mut state.panes); - let mut reordered = Vec::with_capacity(old.len()); - for id in &target { - if let Some(pos) = old.iter().position(|p| p.id == *id) { - reordered.push(old.remove(pos)); - } - } - state.panes = reordered; - if let Ok(json) = serde_json::to_string(&ServerMessage::Reordered { order: target }) { - broadcast_locked(&mut state.clients, TerminalFrame::Control(json)); - } - } - - fn send_error_to(&self, client_id: u64, message: &str) { - let Ok(json) = serde_json::to_string(&ServerMessage::Error { - message: message.to_string(), - }) else { - return; - }; - let mut state = self.state.lock().expect("terminal state poisoned"); - if let Some(index) = state.clients.iter().position(|c| c.id == client_id) - && state.clients[index] - .tx - .try_send(TerminalFrame::Control(json)) - .is_err() - { - state.clients.remove(index); - } - } - /// Register a client and replay the current terminals to it before it is /// eligible for broadcasts: one `Created` plus one scrollback `Output` per /// live pane. Done under the state lock so this snapshot cannot interleave @@ -505,418 +180,5 @@ impl Drop for TerminalHub { } } -/// Queue a frame for every client, dropping any that has fallen too far behind. -/// Terminal bytes cannot be skipped, so an overfull client is disconnected -/// rather than served a corrupted stream. Operates on an already-locked client -/// list so the caller can pair it with a pane mutation atomically. -fn broadcast_locked(clients: &mut Vec, frame: TerminalFrame) { - clients.retain(|client| match client.tx.try_send(frame.clone()) { - Ok(()) => true, - Err(TrySendError::Full(_)) => { - tracing::debug!(id = client.id, "viewer: terminal client too slow, dropping"); - false - } - Err(TrySendError::Disconnected(_)) => false, - }); -} - -/// The canonical pane order for a reorder request: the requested ids that are -/// actually live, in the requested order, followed by any live pane the request -/// omitted, in its current order. Unknown requested ids are dropped. The result -/// is always a permutation of `current`, which is what makes a reorder safe -/// against a create or close that raced the request. -fn canonical_order(current: &[PaneId], requested: &[PaneId]) -> Vec { - let mut out: Vec = Vec::with_capacity(current.len()); - // Requested ids first: live, and each taken once (a repeated id would make - // the result a non-permutation of `current`). - for id in requested { - if current.contains(id) && !out.contains(id) { - out.push(*id); - } - } - // Then any live pane the request left out, in its current order. - for id in current { - if !out.contains(id) { - out.push(*id); - } - } - out -} - -/// Append raw PTY bytes to a pane's scrollback, evicting the oldest bytes to -/// stay within [`limits::MAX_TERMINAL_SCROLLBACK_BYTES`]. -fn push_scrollback(buf: &mut VecDeque, data: &[u8]) { - buf.extend(data.iter().copied()); - if buf.len() > limits::MAX_TERMINAL_SCROLLBACK_BYTES { - let excess = buf.len() - limits::MAX_TERMINAL_SCROLLBACK_BYTES; - buf.drain(0..excess); - } -} - #[cfg(test)] -mod tests { - use super::*; - use std::time::Instant; - - /// Deadline for the real-shell tests below. `connect` spawns the user's - /// actual `$SHELL` (an interactive zsh sources its full rc chain), and - /// cargo runs tests in parallel, so several shells initialize at once — a - /// tighter budget was measurably flaky under load. A generous bound only - /// delays the failure verdict; passing runs still finish the instant the - /// frame arrives. Mirrors `backend::pty::tests::PTY_TEST_DEADLINE`. - const SHELL_TEST_DEADLINE: Duration = Duration::from_secs(15); - - fn wait_for(mut take: impl FnMut() -> Option) -> Option { - let deadline = Instant::now() + SHELL_TEST_DEADLINE; - while Instant::now() < deadline { - if let Some(value) = take() { - return Some(value); - } - thread::sleep(Duration::from_millis(10)); - } - None - } - - /// Pull frames until one satisfies `want`, ignoring the rest. - fn next_matching( - session: &TerminalSession, - mut want: impl FnMut(&TerminalFrame) -> bool, - ) -> Option { - wait_for(|| { - session - .next_frame(Duration::from_millis(50)) - .filter(|f| want(f)) - }) - } - - #[test] - fn output_frames_round_trip_through_the_binary_encoding() { - // Raw PTY bytes are not always valid UTF-8; the framing must not care. - let payload = vec![0x1b, b'[', b'0', b'm', 0xff, 0xfe, 0x00]; - - let encoded = encode_output(7, &payload); - let (pane, data) = decode_output(&encoded).unwrap(); - - assert_eq!(pane, 7); - assert_eq!(data, &payload[..]); - } - - #[test] - fn decode_output_rejects_a_frame_too_short_to_carry_a_pane_id() { - assert!(decode_output(&[]).is_none()); - assert!(decode_output(&[1, 2, 3]).is_none()); - assert_eq!(decode_output(&[1, 0, 0, 0]), Some((1, &[][..]))); - } - - #[test] - fn client_messages_parse_from_the_wire_shape() { - let create: ClientMessage = - serde_json::from_str(r#"{"type":"create","rows":24,"cols":80}"#).unwrap(); - assert!(matches!( - create, - ClientMessage::Create { rows: 24, cols: 80 } - )); - - let input: ClientMessage = - serde_json::from_str(r#"{"type":"input","pane":3,"data":"ls\n"}"#).unwrap(); - assert!(matches!(input, ClientMessage::Input { pane: 3, .. })); - - let reorder: ClientMessage = - serde_json::from_str(r#"{"type":"reorder","order":[3,1,2]}"#).unwrap(); - assert!(matches!(reorder, ClientMessage::Reorder { order } if order == vec![3, 1, 2])); - - assert!(serde_json::from_str::(r#"{"type":"nope"}"#).is_err()); - assert!(serde_json::from_str::(r#"{"type":"create"}"#).is_err()); - } - - #[test] - fn server_messages_serialize_with_a_type_tag() { - let json = serde_json::to_string(&ServerMessage::Created { pane: 2 }).unwrap(); - assert_eq!(json, r#"{"type":"created","pane":2}"#); - - let json = serde_json::to_string(&ServerMessage::Reordered { order: vec![2, 1] }).unwrap(); - assert_eq!(json, r#"{"type":"reordered","order":[2,1]}"#); - } - - #[test] - fn canonical_order_reconciles_a_request_against_the_live_panes() { - // A full permutation is honored verbatim. - assert_eq!(canonical_order(&[1, 2, 3], &[3, 1, 2]), vec![3, 1, 2]); - // A partial request moves the named panes; the rest keep their order. - assert_eq!(canonical_order(&[1, 2, 3], &[3]), vec![3, 1, 2]); - // An id that is no longer live (closed in a race) is dropped. - assert_eq!(canonical_order(&[1, 2], &[9, 2, 1]), vec![2, 1]); - // A repeated id is taken once, keeping the result a permutation. - assert_eq!(canonical_order(&[1, 2], &[2, 2, 1]), vec![2, 1]); - // An empty request leaves the order untouched. - assert_eq!(canonical_order(&[1, 2], &[]), vec![1, 2]); - } - - #[test] - fn creating_a_terminal_announces_it_and_streams_output() { - let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); - let session = hub.connect(); - - session.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); - - // A create is announced synchronously under the state lock, so this - // arrives as soon as the worker services the command. - let created = - next_matching(&session, |f| created_pane(f).is_some()).expect("no created message"); - let pane = created_pane(&created).unwrap(); - - // Drive deterministic output instead of waiting on the interactive - // prompt, whose timing depends on the user's rc chain and was flaky - // under parallel load. The marker proves the stream is live end to end. - let marker = "nightcrow-live"; - session.dispatch(ClientMessage::Input { - pane, - data: format!("printf {marker}\n"), - }); - - let output = next_matching(&session, |f| { - matches!(f, TerminalFrame::Output { pane: p, data } - if *p == pane && String::from_utf8_lossy(data).contains(marker)) - }); - assert!(output.is_some(), "no output from the shell"); - hub.stop(); - } - - #[test] - fn the_per_repo_terminal_cap_is_enforced() { - let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); - let session = hub.connect(); - - for _ in 0..limits::MAX_PTYS_PER_REPO + 2 { - session.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); - } - - let refused = next_matching( - &session, - |f| matches!(f, TerminalFrame::Control(json) if json.contains("terminal limit reached")), - ); - assert!( - refused.is_some(), - "the cap must refuse the extra terminals, not spawn them" - ); - hub.stop(); - } - - #[test] - fn a_dropped_session_stops_receiving() { - let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); - - let session = hub.connect(); - assert_eq!(hub.client_count(), 1); - drop(session); - - assert_eq!(hub.client_count(), 0); - hub.stop(); - } - - fn created_pane(frame: &TerminalFrame) -> Option { - let TerminalFrame::Control(json) = frame else { - return None; - }; - let value: serde_json::Value = serde_json::from_str(json).ok()?; - if value["type"] == "created" { - return value["pane"].as_u64().map(|n| n as PaneId); - } - None - } - - fn reordered_order(frame: &TerminalFrame) -> Option> { - let TerminalFrame::Control(json) = frame else { - return None; - }; - let value: serde_json::Value = serde_json::from_str(json).ok()?; - if value["type"] != "reordered" { - return None; - } - Some( - value["order"] - .as_array()? - .iter() - .filter_map(|v| v.as_u64().map(|n| n as PaneId)) - .collect(), - ) - } - - /// Collect the ids of the first `n` distinct panes announced to `session`, - /// in the order the `created` frames arrive. - fn collect_created(session: &TerminalSession, n: usize) -> Vec { - let mut ids = Vec::new(); - while ids.len() < n { - let created = - next_matching(session, |f| created_pane(f).is_some()).expect("no created message"); - let id = created_pane(&created).unwrap(); - if !ids.contains(&id) { - ids.push(id); - } - } - ids - } - - #[test] - fn reordering_panes_echoes_the_order_and_replays_it_to_a_later_joiner() { - let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); - let first = hub.connect(); - - // The startup shell plus one explicit create give two panes to reorder, - // captured in their creation (== current) order. - first.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); - let ids = collect_created(&first, 2); - let reversed: Vec = ids.iter().copied().rev().collect(); - - first.dispatch(ClientMessage::Reorder { - order: reversed.clone(), - }); - - // The sender is told the canonical order that was applied. - let echoed = next_matching(&first, |f| reordered_order(f).is_some()) - .and_then(|f| reordered_order(&f)) - .expect("no reordered echo"); - assert_eq!(echoed, reversed, "the hub must echo the applied order"); - - // A client that connects afterwards replays the panes in the new order, - // proving the order lives on the server and survives a fresh connection. - let second = hub.connect(); - assert_eq!( - collect_created(&second, 2), - reversed, - "replay order must follow the reorder" - ); - hub.stop(); - } - - #[test] - fn a_reconnecting_client_receives_existing_panes_and_scrollback() { - let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); - let first = hub.connect(); - - first.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); - let created = - next_matching(&first, |f| created_pane(f).is_some()).expect("no created message"); - let pane = created_pane(&created).unwrap(); - // The shell writes a prompt; that is the scrollback a late joiner must - // get back. - assert!( - next_matching(&first, |f| matches!(f, TerminalFrame::Output { .. })).is_some(), - "no output from the shell" - ); - - // A client that connects afterwards (a refreshed browser) must be told - // about the pane that already exists and handed its scrollback. - let second = hub.connect(); - let replayed = next_matching(&second, |f| created_pane(f).is_some()) - .expect("reconnecting client was not told about the existing pane"); - assert_eq!( - created_pane(&replayed), - Some(pane), - "replayed pane id must match the live pane" - ); - let replay_output = - next_matching(&second, |f| matches!(f, TerminalFrame::Output { pane: p, .. } if *p == pane)); - assert!( - replay_output.is_some(), - "reconnecting client did not receive the scrollback" - ); - hub.stop(); - } - - #[test] - fn scrollback_is_bounded_and_keeps_the_most_recent_bytes() { - let cap = limits::MAX_TERMINAL_SCROLLBACK_BYTES; - let mut buf = VecDeque::new(); - for _ in 0..(cap / 1000 + 5) { - push_scrollback(&mut buf, &vec![b'x'; 1000]); - } - assert_eq!(buf.len(), cap, "scrollback must be capped"); - - // The tail is what restores the visible screen, so the newest bytes must - // survive eviction. - push_scrollback(&mut buf, b"TAIL"); - assert_eq!(buf.len(), cap); - let contents: Vec = buf.iter().copied().collect(); - assert!(contents.ends_with(b"TAIL"), "newest bytes must be retained"); - } - - #[test] - fn input_for_an_unknown_pane_is_ignored() { - // A client racing a pane exit is normal traffic, not an error worth - // tearing the connection down for. - let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); - let session = hub.connect(); - - session.dispatch(ClientMessage::Input { - pane: 9999, - data: "rm -rf /\n".to_string(), - }); - session.dispatch(ClientMessage::Resize { - pane: 9999, - rows: 10, - cols: 10, - }); - session.dispatch(ClientMessage::Close { pane: 9999 }); - - // The hub must still be serving after all three. - session.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); - let created = next_matching( - &session, - |f| matches!(f, TerminalFrame::Control(json) if json.contains("created")), - ); - assert!(created.is_some(), "the hub stopped serving"); - hub.stop(); - } - - #[test] - fn stop_is_idempotent() { - let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); - hub.stop(); - hub.stop(); - } - - #[test] - fn the_first_connection_spawns_a_startup_terminal() { - let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn( - &dir.path().to_string_lossy(), - vec!["printf hello".to_string()], - ); - // Connecting is enough — no client Create is dispatched — to launch the - // configured startup terminal. - let session = hub.connect(); - let created = next_matching( - &session, - |f| matches!(f, TerminalFrame::Control(json) if json.contains("created")), - ); - assert!( - created.is_some(), - "the startup terminal was not spawned on connect" - ); - hub.stop(); - } - - #[test] - fn an_empty_startup_opens_one_shell_on_the_first_connection() { - let dir = tempfile::TempDir::new().unwrap(); - let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); - let session = hub.connect(); - let created = next_matching( - &session, - |f| matches!(f, TerminalFrame::Control(json) if json.contains("created")), - ); - assert!( - created.is_some(), - "a default shell should open on the first connect" - ); - hub.stop(); - } -} +mod tests; \ No newline at end of file diff --git a/src/web/viewer/terminal/session.rs b/src/web/viewer/terminal/session.rs new file mode 100644 index 0000000..aef4566 --- /dev/null +++ b/src/web/viewer/terminal/session.rs @@ -0,0 +1,63 @@ +use super::hub_helpers::Command; +use super::TerminalHub; +use super::frame::ClientMessage; +use super::frame::TerminalFrame; +use std::sync::mpsc::{Receiver, SyncSender, TrySendError}; +use std::time::Duration; + +pub(super) struct Client { + pub(super) id: u64, + pub(super) tx: SyncSender, +} + +/// A client's connection to a repository's terminals. +pub struct TerminalSession { + pub(super) hub: std::sync::Arc, + pub(super) id: u64, + pub(super) rx: Receiver, +} + +impl TerminalSession { + /// Wait up to `timeout` for the next frame to write. + pub fn next_frame(&self, timeout: Duration) -> Option { + self.rx.recv_timeout(timeout).ok() + } + + /// Handle a decoded control message from this client. + pub fn dispatch(&self, message: ClientMessage) { + let command = match message { + ClientMessage::Create { rows, cols } => Command::Create { + rows: rows.max(1), + cols: cols.max(1), + client: self.id, + command: None, + }, + ClientMessage::Input { pane, data } => Command::Input { + pane, + data: data.into_bytes(), + }, + ClientMessage::Resize { pane, rows, cols } => Command::Resize { + pane, + rows: rows.max(1), + cols: cols.max(1), + }, + ClientMessage::Close { pane } => Command::Close { pane }, + ClientMessage::Reorder { order } => Command::Reorder { order }, + }; + // Never block the connection thread here. The hub drains this queue + // from the same thread that writes to a PTY master, and that write + // blocks forever if the child has stopped reading stdin — a blocking + // send would then wedge every connection thread for this repository. + // Dropping a command under that much backpressure is the honest + // outcome; the client is already far ahead of what the shell can take. + if let Err(TrySendError::Full(_)) = self.hub.commands.try_send(command) { + tracing::debug!("viewer: terminal command queue full, dropping"); + } + } +} + +impl Drop for TerminalSession { + fn drop(&mut self) { + self.hub.disconnect(self.id); + } +} \ No newline at end of file diff --git a/src/web/viewer/terminal/tests/behavior.rs b/src/web/viewer/terminal/tests/behavior.rs new file mode 100644 index 0000000..86b4093 --- /dev/null +++ b/src/web/viewer/terminal/tests/behavior.rs @@ -0,0 +1,213 @@ +use super::{collect_created, created_pane, next_matching, reordered_order}; +use crate::backend::PaneId; +use crate::web::viewer::terminal::frame::{ClientMessage, TerminalFrame}; +use crate::web::viewer::limits; +use crate::web::viewer::terminal::TerminalHub; + +#[test] +fn creating_a_terminal_announces_it_and_streams_output() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let session = hub.connect(); + + session.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); + + // A create is announced synchronously under the state lock, so this + // arrives as soon as the worker services the command. + let created = + next_matching(&session, |f| created_pane(f).is_some()).expect("no created message"); + let pane = created_pane(&created).unwrap(); + + // Drive deterministic output instead of waiting on the interactive + // prompt, whose timing depends on the user's rc chain and was flaky + // under parallel load. The marker proves the stream is live end to end. + let marker = "nightcrow-live"; + session.dispatch(ClientMessage::Input { + pane, + data: format!("printf {marker}\n"), + }); + + let output = next_matching(&session, |f| { + matches!(f, TerminalFrame::Output { pane: p, data } + if *p == pane && String::from_utf8_lossy(data).contains(marker)) + }); + assert!(output.is_some(), "no output from the shell"); + hub.stop(); +} + +#[test] +fn the_per_repo_terminal_cap_is_enforced() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let session = hub.connect(); + + for _ in 0..limits::MAX_PTYS_PER_REPO + 2 { + session.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); + } + + let refused = next_matching( + &session, + |f| matches!(f, TerminalFrame::Control(json) if json.contains("terminal limit reached")), + ); + assert!( + refused.is_some(), + "the cap must refuse the extra terminals, not spawn them" + ); + hub.stop(); +} + +#[test] +fn a_dropped_session_stops_receiving() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + + let session = hub.connect(); + assert_eq!(hub.client_count(), 1); + drop(session); + + assert_eq!(hub.client_count(), 0); + hub.stop(); +} + +#[test] +fn reordering_panes_echoes_the_order_and_replays_it_to_a_later_joiner() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let first = hub.connect(); + + // The startup shell plus one explicit create give two panes to reorder, + // captured in their creation (== current) order. + first.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); + let ids = collect_created(&first, 2); + let reversed: Vec = ids.iter().copied().rev().collect(); + + first.dispatch(ClientMessage::Reorder { + order: reversed.clone(), + }); + + // The sender is told the canonical order that was applied. + let echoed = next_matching(&first, |f| reordered_order(f).is_some()) + .and_then(|f| reordered_order(&f)) + .expect("no reordered echo"); + assert_eq!(echoed, reversed, "the hub must echo the applied order"); + + // A client that connects afterwards replays the panes in the new order, + // proving the order lives on the server and survives a fresh connection. + let second = hub.connect(); + assert_eq!( + collect_created(&second, 2), + reversed, + "replay order must follow the reorder" + ); + hub.stop(); +} + +#[test] +fn a_reconnecting_client_receives_existing_panes_and_scrollback() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let first = hub.connect(); + + first.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); + let created = + next_matching(&first, |f| created_pane(f).is_some()).expect("no created message"); + let pane = created_pane(&created).unwrap(); + // The shell writes a prompt; that is the scrollback a late joiner must + // get back. + assert!( + next_matching(&first, |f| matches!(f, TerminalFrame::Output { .. })).is_some(), + "no output from the shell" + ); + + // A client that connects afterwards (a refreshed browser) must be told + // about the pane that already exists and handed its scrollback. + let second = hub.connect(); + let replayed = next_matching(&second, |f| created_pane(f).is_some()) + .expect("reconnecting client was not told about the existing pane"); + assert_eq!( + created_pane(&replayed), + Some(pane), + "replayed pane id must match the live pane" + ); + let replay_output = + next_matching(&second, |f| matches!(f, TerminalFrame::Output { pane: p, .. } if *p == pane)); + assert!( + replay_output.is_some(), + "reconnecting client did not receive the scrollback" + ); + hub.stop(); +} + +#[test] +fn input_for_an_unknown_pane_is_ignored() { + // A client racing a pane exit is normal traffic, not an error worth + // tearing the connection down for. + let dir = tempfile::TempDir::new().unwrap(); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let session = hub.connect(); + + session.dispatch(ClientMessage::Input { + pane: 9999, + data: "rm -rf /\n".to_string(), + }); + session.dispatch(ClientMessage::Resize { + pane: 9999, + rows: 10, + cols: 10, + }); + session.dispatch(ClientMessage::Close { pane: 9999 }); + + // The hub must still be serving after all three. + session.dispatch(ClientMessage::Create { rows: 24, cols: 80 }); + let created = next_matching( + &session, + |f| matches!(f, TerminalFrame::Control(json) if json.contains("created")), + ); + assert!(created.is_some(), "the hub stopped serving"); + hub.stop(); +} + +#[test] +fn stop_is_idempotent() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + hub.stop(); + hub.stop(); +} + +#[test] +fn the_first_connection_spawns_a_startup_terminal() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = TerminalHub::spawn( + &dir.path().to_string_lossy(), + vec!["printf hello".to_string()], + ); + // Connecting is enough — no client Create is dispatched — to launch the + // configured startup terminal. + let session = hub.connect(); + let created = next_matching( + &session, + |f| matches!(f, TerminalFrame::Control(json) if json.contains("created")), + ); + assert!( + created.is_some(), + "the startup terminal was not spawned on connect" + ); + hub.stop(); +} + +#[test] +fn an_empty_startup_opens_one_shell_on_the_first_connection() { + let dir = tempfile::TempDir::new().unwrap(); + let hub = TerminalHub::spawn(&dir.path().to_string_lossy(), Vec::new()); + let session = hub.connect(); + let created = next_matching( + &session, + |f| matches!(f, TerminalFrame::Control(json) if json.contains("created")), + ); + assert!( + created.is_some(), + "a default shell should open on the first connect" + ); + hub.stop(); +} \ No newline at end of file diff --git a/src/web/viewer/terminal/tests/mod.rs b/src/web/viewer/terminal/tests/mod.rs new file mode 100644 index 0000000..3dc4b2a --- /dev/null +++ b/src/web/viewer/terminal/tests/mod.rs @@ -0,0 +1,167 @@ +mod behavior; + +use crate::backend::PaneId; +use crate::web::viewer::terminal::frame::{ + ClientMessage, ServerMessage, TerminalFrame, decode_output, encode_output, +}; +use crate::web::viewer::terminal::hub_helpers::{canonical_order, push_scrollback}; +use crate::web::viewer::limits; +use std::collections::VecDeque; +use std::thread; +use std::time::{Duration, Instant}; + +use super::TerminalSession; + +/// Deadline for the real-shell tests below. `connect` spawns the user's +/// actual `$SHELL` (an interactive zsh sources its full rc chain), and +/// cargo runs tests in parallel, so several shells initialize at once — a +/// tighter budget was measurably flaky under load. A generous bound only +/// delays the failure verdict; passing runs still finish the instant the +/// frame arrives. Mirrors `backend::pty::tests::PTY_TEST_DEADLINE`. +pub(super) const SHELL_TEST_DEADLINE: Duration = Duration::from_secs(15); + +pub(super) fn wait_for(mut take: impl FnMut() -> Option) -> Option { + let deadline = Instant::now() + SHELL_TEST_DEADLINE; + while Instant::now() < deadline { + if let Some(value) = take() { + return Some(value); + } + thread::sleep(Duration::from_millis(10)); + } + None +} + +/// Pull frames until one satisfies `want`, ignoring the rest. +pub(super) fn next_matching( + session: &TerminalSession, + mut want: impl FnMut(&TerminalFrame) -> bool, +) -> Option { + wait_for(|| { + session + .next_frame(Duration::from_millis(50)) + .filter(|f| want(f)) + }) +} + +pub(super) fn created_pane(frame: &TerminalFrame) -> Option { + let TerminalFrame::Control(json) = frame else { + return None; + }; + let value: serde_json::Value = serde_json::from_str(json).ok()?; + if value["type"] == "created" { + return value["pane"].as_u64().map(|n| n as PaneId); + } + None +} + +pub(super) fn reordered_order(frame: &TerminalFrame) -> Option> { + let TerminalFrame::Control(json) = frame else { + return None; + }; + let value: serde_json::Value = serde_json::from_str(json).ok()?; + if value["type"] != "reordered" { + return None; + } + Some( + value["order"] + .as_array()? + .iter() + .filter_map(|v| v.as_u64().map(|n| n as PaneId)) + .collect(), + ) +} + +/// Collect the ids of the first `n` distinct panes announced to `session`, +/// in the order the `created` frames arrive. +pub(super) fn collect_created(session: &TerminalSession, n: usize) -> Vec { + let mut ids = Vec::new(); + while ids.len() < n { + let created = + next_matching(session, |f| created_pane(f).is_some()).expect("no created message"); + let id = created_pane(&created).unwrap(); + if !ids.contains(&id) { + ids.push(id); + } + } + ids +} + +#[test] +fn output_frames_round_trip_through_the_binary_encoding() { + // Raw PTY bytes are not always valid UTF-8; the framing must not care. + let payload = vec![0x1b, b'[', b'0', b'm', 0xff, 0xfe, 0x00]; + + let encoded = encode_output(7, &payload); + let (pane, data) = decode_output(&encoded).unwrap(); + + assert_eq!(pane, 7); + assert_eq!(data, &payload[..]); +} + +#[test] +fn decode_output_rejects_a_frame_too_short_to_carry_a_pane_id() { + assert!(decode_output(&[]).is_none()); + assert!(decode_output(&[1, 2, 3]).is_none()); + assert_eq!(decode_output(&[1, 0, 0, 0]), Some((1, &[][..]))); +} + +#[test] +fn client_messages_parse_from_the_wire_shape() { + let create: ClientMessage = + serde_json::from_str(r#"{"type":"create","rows":24,"cols":80}"#).unwrap(); + assert!(matches!( + create, + ClientMessage::Create { rows: 24, cols: 80 } + )); + + let input: ClientMessage = + serde_json::from_str(r#"{"type":"input","pane":3,"data":"ls\n"}"#).unwrap(); + assert!(matches!(input, ClientMessage::Input { pane: 3, .. })); + + let reorder: ClientMessage = + serde_json::from_str(r#"{"type":"reorder","order":[3,1,2]}"#).unwrap(); + assert!(matches!(reorder, ClientMessage::Reorder { order } if order == vec![3, 1, 2])); + + assert!(serde_json::from_str::(r#"{"type":"nope"}"#).is_err()); + assert!(serde_json::from_str::(r#"{"type":"create"}"#).is_err()); +} + +#[test] +fn server_messages_serialize_with_a_type_tag() { + let json = serde_json::to_string(&ServerMessage::Created { pane: 2 }).unwrap(); + assert_eq!(json, r#"{"type":"created","pane":2}"#); + + let json = serde_json::to_string(&ServerMessage::Reordered { order: vec![2, 1] }).unwrap(); + assert_eq!(json, r#"{"type":"reordered","order":[2,1]}"#); +} + +#[test] +fn canonical_order_reconciles_a_request_against_the_live_panes() { + // A full permutation is honored verbatim. + assert_eq!(canonical_order(&[1, 2, 3], &[3, 1, 2]), vec![3, 1, 2]); + // A partial request moves the named panes; the rest keep their order. + assert_eq!(canonical_order(&[1, 2, 3], &[3]), vec![3, 1, 2]); + // An id that is no longer live (closed in a race) is dropped. + assert_eq!(canonical_order(&[1, 2], &[9, 2, 1]), vec![2, 1]); + // A repeated id is taken once, keeping the result a permutation. + assert_eq!(canonical_order(&[1, 2], &[2, 2, 1]), vec![2, 1]); + // An empty request leaves the order untouched. + assert_eq!(canonical_order(&[1, 2], &[]), vec![1, 2]); +} + +#[test] +fn scrollback_is_bounded_and_keeps_the_most_recent_bytes() { + let cap = limits::MAX_TERMINAL_SCROLLBACK_BYTES; + let mut buf = VecDeque::new(); + for _ in 0..(cap / 1000 + 5) { + push_scrollback(&mut buf, &vec![b'x'; 1000]); + } + assert_eq!(buf.len(), cap, "scrollback must be capped"); + + // The tail is what restores the visible screen, so the newest bytes must + // survive eviction. + push_scrollback(&mut buf, b"TAIL"); + assert_eq!(buf.len(), cap); + let contents: Vec = buf.iter().copied().collect(); + assert!(contents.ends_with(b"TAIL"), "newest bytes must be retained"); +} \ No newline at end of file From 79bb153c7e50ca443fb43853870956933eab6cc0 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 25 Jul 2026 09:16:03 +0900 Subject: [PATCH 12/15] refactor: enforce 300-LOC limit on viewer-ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split App.tsx (2059→299), Terminal.tsx (616→273), api.ts (342→118) into hooks/, components/, api/ modules. Add LOC rule to guardrails.md. --- .claude/rules/guardrails.md | 7 + ...kdown-FGllwLLF.js => Markdown-BYxaSXJW.js} | 2 +- viewer-ui/dist/assets/Terminal-C9AoFjwt.js | 9 - viewer-ui/dist/assets/Terminal-Dhwxs9P9.js | 9 + viewer-ui/dist/assets/index-BK2uPN-6.js | 11 - viewer-ui/dist/assets/index-CGiabjZL.css | 1 + viewer-ui/dist/assets/index-CwwCtU4T.css | 1 - viewer-ui/dist/assets/index-D8ORZvs2.js | 11 + viewer-ui/dist/index.html | 4 +- viewer-ui/src/App.tsx | 2151 ++--------------- viewer-ui/src/Terminal.tsx | 476 +--- viewer-ui/src/TerminalCell.tsx | 102 + viewer-ui/src/api.ts | 265 +- viewer-ui/src/api/client.ts | 61 + viewer-ui/src/api/errors.ts | 34 + viewer-ui/src/api/types.ts | 142 ++ viewer-ui/src/components/DiffView.tsx | 108 + viewer-ui/src/components/FilePane.tsx | 137 ++ viewer-ui/src/components/FolderPicker.tsx | 166 ++ viewer-ui/src/components/Header.tsx | 126 + viewer-ui/src/components/LoadingSplash.tsx | 16 + viewer-ui/src/components/LogList.tsx | 158 ++ viewer-ui/src/components/Login.tsx | 53 + viewer-ui/src/components/Mark.tsx | 16 + viewer-ui/src/components/PathLabel.tsx | 26 + viewer-ui/src/components/ProjectMenu.tsx | 109 + viewer-ui/src/components/RepoShell.tsx | 220 ++ viewer-ui/src/components/Sidebar.tsx | 243 ++ viewer-ui/src/components/StatusList.tsx | 51 + viewer-ui/src/components/TreeList.tsx | 93 + viewer-ui/src/hooks/useLog.ts | 179 ++ viewer-ui/src/hooks/useMaximized.ts | 28 + viewer-ui/src/hooks/usePaneOpeners.ts | 115 + viewer-ui/src/hooks/useRepoActions.ts | 59 + viewer-ui/src/hooks/useRepoPoll.ts | 154 ++ viewer-ui/src/hooks/useResumeTick.ts | 21 + viewer-ui/src/hooks/useSidebarDrag.ts | 143 ++ viewer-ui/src/hooks/useStatus.ts | 116 + viewer-ui/src/hooks/useTree.ts | 160 ++ viewer-ui/src/terminalLayout.ts | 109 + viewer-ui/src/tree.ts | 30 + viewer-ui/src/types.ts | 13 + viewer-ui/src/useHotClock.ts | 54 + viewer-ui/src/usePaneDrag.ts | 92 + viewer-ui/src/useTerminalSocket.ts | 144 ++ viewer-ui/src/useTerminalViews.ts | 71 + viewer-ui/src/utils.ts | 26 + 47 files changed, 3690 insertions(+), 2632 deletions(-) rename viewer-ui/dist/assets/{Markdown-FGllwLLF.js => Markdown-BYxaSXJW.js} (99%) delete mode 100644 viewer-ui/dist/assets/Terminal-C9AoFjwt.js create mode 100644 viewer-ui/dist/assets/Terminal-Dhwxs9P9.js delete mode 100644 viewer-ui/dist/assets/index-BK2uPN-6.js create mode 100644 viewer-ui/dist/assets/index-CGiabjZL.css delete mode 100644 viewer-ui/dist/assets/index-CwwCtU4T.css create mode 100644 viewer-ui/dist/assets/index-D8ORZvs2.js create mode 100644 viewer-ui/src/TerminalCell.tsx create mode 100644 viewer-ui/src/api/client.ts create mode 100644 viewer-ui/src/api/errors.ts create mode 100644 viewer-ui/src/api/types.ts create mode 100644 viewer-ui/src/components/DiffView.tsx create mode 100644 viewer-ui/src/components/FilePane.tsx create mode 100644 viewer-ui/src/components/FolderPicker.tsx create mode 100644 viewer-ui/src/components/Header.tsx create mode 100644 viewer-ui/src/components/LoadingSplash.tsx create mode 100644 viewer-ui/src/components/LogList.tsx create mode 100644 viewer-ui/src/components/Login.tsx create mode 100644 viewer-ui/src/components/Mark.tsx create mode 100644 viewer-ui/src/components/PathLabel.tsx create mode 100644 viewer-ui/src/components/ProjectMenu.tsx create mode 100644 viewer-ui/src/components/RepoShell.tsx create mode 100644 viewer-ui/src/components/Sidebar.tsx create mode 100644 viewer-ui/src/components/StatusList.tsx create mode 100644 viewer-ui/src/components/TreeList.tsx create mode 100644 viewer-ui/src/hooks/useLog.ts create mode 100644 viewer-ui/src/hooks/useMaximized.ts create mode 100644 viewer-ui/src/hooks/usePaneOpeners.ts create mode 100644 viewer-ui/src/hooks/useRepoActions.ts create mode 100644 viewer-ui/src/hooks/useRepoPoll.ts create mode 100644 viewer-ui/src/hooks/useResumeTick.ts create mode 100644 viewer-ui/src/hooks/useSidebarDrag.ts create mode 100644 viewer-ui/src/hooks/useStatus.ts create mode 100644 viewer-ui/src/hooks/useTree.ts create mode 100644 viewer-ui/src/terminalLayout.ts create mode 100644 viewer-ui/src/tree.ts create mode 100644 viewer-ui/src/types.ts create mode 100644 viewer-ui/src/useHotClock.ts create mode 100644 viewer-ui/src/usePaneDrag.ts create mode 100644 viewer-ui/src/useTerminalSocket.ts create mode 100644 viewer-ui/src/useTerminalViews.ts create mode 100644 viewer-ui/src/utils.ts diff --git a/.claude/rules/guardrails.md b/.claude/rules/guardrails.md index 0e3c3eb..65fdd9c 100644 --- a/.claude/rules/guardrails.md +++ b/.claude/rules/guardrails.md @@ -39,3 +39,10 @@ - 초기 구현은 간소화하되, 이후 확장 가능한 구조를 유지한다. - top-level 구조는 새로운 모듈 추가가 가능하도록 범용적으로 유지한다. + +## File Size + +- 모든 소스 파일(Rust, TypeScript, TSX, JavaScript)은 300줄 이하다. 테스트 파일도 예외 없다. +- 200줄 이상은 code smell이다. 분할을 검토한다. +- 분할은 동작을 변경하지 않는 순수 리팩토링이어야 한다. 커스텀 훅, 컴포넌트, 순수 함수 모듈로 쪼갠다. +- 생성된 파일(`dist/`, `target/`)과 벤더링한 서드파티는 제외한다. diff --git a/viewer-ui/dist/assets/Markdown-FGllwLLF.js b/viewer-ui/dist/assets/Markdown-BYxaSXJW.js similarity index 99% rename from viewer-ui/dist/assets/Markdown-FGllwLLF.js rename to viewer-ui/dist/assets/Markdown-BYxaSXJW.js index 3222342..570d789 100644 --- a/viewer-ui/dist/assets/Markdown-FGllwLLF.js +++ b/viewer-ui/dist/assets/Markdown-BYxaSXJW.js @@ -1,4 +1,4 @@ -import{j as vn}from"./index-BK2uPN-6.js";function dr(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}function Lo(e,n){const t={};return(e[e.length-1]===""?[...e,""]:e).join((t.padRight?" ":"")+","+(t.padLeft===!1?"":" ")).trim()}const Po=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Bo=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Fo={};function Hr(e,n){return(Fo.jsx?Bo:Po).test(e)}const zo=/[ \t\n\f\r]/g;function Uo(e){return typeof e=="object"?e.type==="text"?Gr(e.value):!1:Gr(e)}function Gr(e){return e.replace(zo,"")===""}class Vn{constructor(n,t,r){this.normal=t,this.property=n,r&&(this.space=r)}}Vn.prototype.normal={};Vn.prototype.property={};Vn.prototype.space=void 0;function Gi(e,n){const t={},r={};for(const i of e)Object.assign(t,i.property),Object.assign(r,i.normal);return new Vn(t,r,n)}function Jt(e){return e.toLowerCase()}class Me{constructor(n,t){this.attribute=t,this.property=n}}Me.prototype.attribute="";Me.prototype.booleanish=!1;Me.prototype.boolean=!1;Me.prototype.commaOrSpaceSeparated=!1;Me.prototype.commaSeparated=!1;Me.prototype.defined=!1;Me.prototype.mustUseProperty=!1;Me.prototype.number=!1;Me.prototype.overloadedBoolean=!1;Me.prototype.property="";Me.prototype.spaceSeparated=!1;Me.prototype.space=void 0;let $o=0;const Y=_n(),ke=_n(),er=_n(),A=_n(),fe=_n(),bn=_n(),ze=_n();function _n(){return 2**++$o}const nr=Object.freeze(Object.defineProperty({__proto__:null,boolean:Y,booleanish:ke,commaOrSpaceSeparated:ze,commaSeparated:bn,number:A,overloadedBoolean:er,spaceSeparated:fe},Symbol.toStringTag,{value:"Module"})),Mt=Object.keys(nr);class pr extends Me{constructor(n,t,r,i){let o=-1;if(super(n,t),Kr(this,"space",i),typeof r=="number")for(;++o4&&t.slice(0,4)==="data"&&Wo.test(n)){if(n.charAt(4)==="-"){const o=n.slice(5).replace(qr,Zo);r="data"+o.charAt(0).toUpperCase()+o.slice(1)}else{const o=n.slice(4);if(!qr.test(o)){let a=o.replace(qo,Yo);a.charAt(0)!=="-"&&(a="-"+a),n="data"+a}}i=pr}return new i(r,n)}function Yo(e){return"-"+e.toLowerCase()}function Zo(e){return e.charAt(1).toUpperCase()}const Xo=Gi([Ki,Ho,Vi,Yi,Zi],"html"),fr=Gi([Ki,Go,Vi,Yi,Zi],"svg");function Qo(e){return e.join(" ").trim()}var Sn={},Dt,Wr;function jo(){if(Wr)return Dt;Wr=1;var e=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g,n=/\n/g,t=/^\s*/,r=/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/,i=/^:\s*/,o=/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/,a=/^[;\s]*/,s=/^\s+|\s+$/g,c=` +import{j as vn}from"./index-D8ORZvs2.js";function dr(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}function Lo(e,n){const t={};return(e[e.length-1]===""?[...e,""]:e).join((t.padRight?" ":"")+","+(t.padLeft===!1?"":" ")).trim()}const Po=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Bo=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Fo={};function Hr(e,n){return(Fo.jsx?Bo:Po).test(e)}const zo=/[ \t\n\f\r]/g;function Uo(e){return typeof e=="object"?e.type==="text"?Gr(e.value):!1:Gr(e)}function Gr(e){return e.replace(zo,"")===""}class Vn{constructor(n,t,r){this.normal=t,this.property=n,r&&(this.space=r)}}Vn.prototype.normal={};Vn.prototype.property={};Vn.prototype.space=void 0;function Gi(e,n){const t={},r={};for(const i of e)Object.assign(t,i.property),Object.assign(r,i.normal);return new Vn(t,r,n)}function Jt(e){return e.toLowerCase()}class Me{constructor(n,t){this.attribute=t,this.property=n}}Me.prototype.attribute="";Me.prototype.booleanish=!1;Me.prototype.boolean=!1;Me.prototype.commaOrSpaceSeparated=!1;Me.prototype.commaSeparated=!1;Me.prototype.defined=!1;Me.prototype.mustUseProperty=!1;Me.prototype.number=!1;Me.prototype.overloadedBoolean=!1;Me.prototype.property="";Me.prototype.spaceSeparated=!1;Me.prototype.space=void 0;let $o=0;const Y=_n(),ke=_n(),er=_n(),A=_n(),fe=_n(),bn=_n(),ze=_n();function _n(){return 2**++$o}const nr=Object.freeze(Object.defineProperty({__proto__:null,boolean:Y,booleanish:ke,commaOrSpaceSeparated:ze,commaSeparated:bn,number:A,overloadedBoolean:er,spaceSeparated:fe},Symbol.toStringTag,{value:"Module"})),Mt=Object.keys(nr);class pr extends Me{constructor(n,t,r,i){let o=-1;if(super(n,t),Kr(this,"space",i),typeof r=="number")for(;++o4&&t.slice(0,4)==="data"&&Wo.test(n)){if(n.charAt(4)==="-"){const o=n.slice(5).replace(qr,Zo);r="data"+o.charAt(0).toUpperCase()+o.slice(1)}else{const o=n.slice(4);if(!qr.test(o)){let a=o.replace(qo,Yo);a.charAt(0)!=="-"&&(a="-"+a),n="data"+a}}i=pr}return new i(r,n)}function Yo(e){return"-"+e.toLowerCase()}function Zo(e){return e.charAt(1).toUpperCase()}const Xo=Gi([Ki,Ho,Vi,Yi,Zi],"html"),fr=Gi([Ki,Go,Vi,Yi,Zi],"svg");function Qo(e){return e.join(" ").trim()}var Sn={},Dt,Wr;function jo(){if(Wr)return Dt;Wr=1;var e=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g,n=/\n/g,t=/^\s*/,r=/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/,i=/^:\s*/,o=/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/,a=/^[;\s]*/,s=/^\s+|\s+$/g,c=` `,l="/",d="*",u="",f="comment",p="declaration";function g(y,E){if(typeof y!="string")throw new TypeError("First argument must be a string");if(!y)return[];E=E||{};var N=1,x=1;function O(D){var v=D.match(n);v&&(N+=v.length);var Z=D.lastIndexOf(c);x=~Z?D.length-Z:x+D.length}function R(){var D={line:N,column:x};return function(v){return v.position=new k(D),H(),v}}function k(D){this.start=D,this.end={line:N,column:x},this.source=E.source}k.prototype.content=y;function U(D){var v=new Error(E.source+":"+N+":"+x+": "+D);if(v.reason=D,v.filename=E.source,v.line=N,v.column=x,v.source=y,!E.silent)throw v}function $(D){var v=D.exec(y);if(v){var Z=v[0];return O(Z),y=y.slice(Z.length),v}}function H(){$(t)}function w(D){var v;for(D=D||[];v=P();)v!==!1&&D.push(v);return D}function P(){var D=R();if(!(l!=y.charAt(0)||d!=y.charAt(1))){for(var v=2;u!=y.charAt(v)&&(d!=y.charAt(v)||l!=y.charAt(v+1));)++v;if(v+=2,u===y.charAt(v-1))return U("End of comment missing");var Z=y.slice(2,v-2);return x+=2,O(Z),y=y.slice(v),x+=2,D({type:f,comment:Z})}}function B(){var D=R(),v=$(r);if(v){if(P(),!$(i))return U("property missing ':'");var Z=$(o),oe=D({type:p,property:_(v[0].replace(e,u)),value:Z?_(Z[0].replace(e,u)):u});return $(a),oe}}function J(){var D=[];w(D);for(var v;v=B();)v!==!1&&(D.push(v),w(D));return D}return H(),J()}function _(y){return y?y.replace(s,u):u}return Dt=g,Dt}var Vr;function Jo(){if(Vr)return Sn;Vr=1;var e=Sn&&Sn.__importDefault||function(r){return r&&r.__esModule?r:{default:r}};Object.defineProperty(Sn,"__esModule",{value:!0}),Sn.default=t;const n=e(jo());function t(r,i){let o=null;if(!r||typeof r!="string")return o;const a=(0,n.default)(r),s=typeof i=="function";return a.forEach(c=>{if(c.type!=="declaration")return;const{property:l,value:d}=c;s?i(l,d,c):d&&(o=o||{},o[l]=d)}),o}return Sn}var Bn={},Yr;function es(){if(Yr)return Bn;Yr=1,Object.defineProperty(Bn,"__esModule",{value:!0}),Bn.camelCase=void 0;var e=/^--[a-zA-Z0-9_-]+$/,n=/-([a-z])/g,t=/^[^-]+$/,r=/^-(webkit|moz|ms|o|khtml)-/,i=/^-(ms)-/,o=function(l){return!l||t.test(l)||e.test(l)},a=function(l,d){return d.toUpperCase()},s=function(l,d){return"".concat(d,"-")},c=function(l,d){return d===void 0&&(d={}),o(l)?l:(l=l.toLowerCase(),d.reactCompat?l=l.replace(i,s):l=l.replace(r,s),l.replace(n,a))};return Bn.camelCase=c,Bn}var Fn,Zr;function ns(){if(Zr)return Fn;Zr=1;var e=Fn&&Fn.__importDefault||function(i){return i&&i.__esModule?i:{default:i}},n=e(Jo()),t=es();function r(i,o){var a={};return!i||typeof i!="string"||(0,n.default)(i,function(s,c){s&&c&&(a[(0,t.camelCase)(s,o)]=c)}),a}return r.default=r,Fn=r,Fn}var ts=ns();const rs=dr(ts),Xi=Qi("end"),gr=Qi("start");function Qi(e){return n;function n(t){const r=t&&t.position&&t.position[e]||{};if(typeof r.line=="number"&&r.line>0&&typeof r.column=="number"&&r.column>0)return{line:r.line,column:r.column,offset:typeof r.offset=="number"&&r.offset>-1?r.offset:void 0}}}function is(e){const n=gr(e),t=Xi(e);if(n&&t)return{start:n,end:t}}function Hn(e){return!e||typeof e!="object"?"":"position"in e||"type"in e?Xr(e.position):"start"in e||"end"in e?Xr(e):"line"in e||"column"in e?tr(e):""}function tr(e){return Qr(e&&e.line)+":"+Qr(e&&e.column)}function Xr(e){return tr(e&&e.start)+"-"+tr(e&&e.end)}function Qr(e){return e&&typeof e=="number"?e:1}class Ae extends Error{constructor(n,t,r){super(),typeof t=="string"&&(r=t,t=void 0);let i="",o={},a=!1;if(t&&("line"in t&&"column"in t?o={place:t}:"start"in t&&"end"in t?o={place:t}:"type"in t?o={ancestors:[t],place:t.position}:o={...t}),typeof n=="string"?i=n:!o.cause&&n&&(a=!0,i=n.message,o.cause=n),!o.ruleId&&!o.source&&typeof r=="string"){const c=r.indexOf(":");c===-1?o.ruleId=r:(o.source=r.slice(0,c),o.ruleId=r.slice(c+1))}if(!o.place&&o.ancestors&&o.ancestors){const c=o.ancestors[o.ancestors.length-1];c&&(o.place=c.position)}const s=o.place&&"start"in o.place?o.place.start:o.place;this.ancestors=o.ancestors||void 0,this.cause=o.cause||void 0,this.column=s?s.column:void 0,this.fatal=void 0,this.file="",this.message=i,this.line=s?s.line:void 0,this.name=Hn(o.place)||"1:1",this.place=o.place||void 0,this.reason=this.message,this.ruleId=o.ruleId||void 0,this.source=o.source||void 0,this.stack=a&&o.cause&&typeof o.cause.stack=="string"?o.cause.stack:"",this.actual=void 0,this.expected=void 0,this.note=void 0,this.url=void 0}}Ae.prototype.file="";Ae.prototype.name="";Ae.prototype.reason="";Ae.prototype.message="";Ae.prototype.stack="";Ae.prototype.column=void 0;Ae.prototype.line=void 0;Ae.prototype.ancestors=void 0;Ae.prototype.cause=void 0;Ae.prototype.fatal=void 0;Ae.prototype.place=void 0;Ae.prototype.ruleId=void 0;Ae.prototype.source=void 0;const mr={}.hasOwnProperty,as=new Map,os=/[A-Z]/g,ss=new Set(["table","tbody","thead","tfoot","tr"]),ls=new Set(["td","th"]),ji="https://github.com/syntax-tree/hast-util-to-jsx-runtime";function cs(e,n){if(!n||n.Fragment===void 0)throw new TypeError("Expected `Fragment` in options");const t=n.filePath||void 0;let r;if(n.development){if(typeof n.jsxDEV!="function")throw new TypeError("Expected `jsxDEV` in options when `development: true`");r=bs(t,n.jsxDEV)}else{if(typeof n.jsx!="function")throw new TypeError("Expected `jsx` in production options");if(typeof n.jsxs!="function")throw new TypeError("Expected `jsxs` in production options");r=hs(t,n.jsx,n.jsxs)}const i={Fragment:n.Fragment,ancestors:[],components:n.components||{},create:r,elementAttributeNameCase:n.elementAttributeNameCase||"react",evaluater:n.createEvaluater?n.createEvaluater():void 0,filePath:t,ignoreInvalidStyle:n.ignoreInvalidStyle||!1,passKeys:n.passKeys!==!1,passNode:n.passNode||!1,schema:n.space==="svg"?fr:Xo,stylePropertyNameCase:n.stylePropertyNameCase||"dom",tableCellAlignToStyle:n.tableCellAlignToStyle!==!1},o=Ji(i,e,void 0);return o&&typeof o!="string"?o:i.create(e,i.Fragment,{children:o||void 0},void 0)}function Ji(e,n,t){if(n.type==="element")return us(e,n,t);if(n.type==="mdxFlowExpression"||n.type==="mdxTextExpression")return ds(e,n);if(n.type==="mdxJsxFlowElement"||n.type==="mdxJsxTextElement")return fs(e,n,t);if(n.type==="mdxjsEsm")return ps(e,n);if(n.type==="root")return gs(e,n,t);if(n.type==="text")return ms(e,n)}function us(e,n,t){const r=e.schema;let i=r;n.tagName.toLowerCase()==="svg"&&r.space==="html"&&(i=fr,e.schema=i),e.ancestors.push(n);const o=na(e,n.tagName,!1),a=Es(e,n);let s=br(e,n);return ss.has(n.tagName)&&(s=s.filter(function(c){return typeof c=="string"?!Uo(c):!0})),ea(e,a,o,n),hr(a,s),e.ancestors.pop(),e.schema=r,e.create(n,o,a,t)}function ds(e,n){if(n.data&&n.data.estree&&e.evaluater){const r=n.data.estree.body[0];return r.type,e.evaluater.evaluateExpression(r.expression)}qn(e,n.position)}function ps(e,n){if(n.data&&n.data.estree&&e.evaluater)return e.evaluater.evaluateProgram(n.data.estree);qn(e,n.position)}function fs(e,n,t){const r=e.schema;let i=r;n.name==="svg"&&r.space==="html"&&(i=fr,e.schema=i),e.ancestors.push(n);const o=n.name===null?e.Fragment:na(e,n.name,!0),a=_s(e,n),s=br(e,n);return ea(e,a,o,n),hr(a,s),e.ancestors.pop(),e.schema=r,e.create(n,o,a,t)}function gs(e,n,t){const r={};return hr(r,br(e,n)),e.create(n,e.Fragment,r,t)}function ms(e,n){return n.value}function ea(e,n,t,r){typeof t!="string"&&t!==e.Fragment&&e.passNode&&(n.node=r)}function hr(e,n){if(n.length>0){const t=n.length>1?n:n[0];t&&(e.children=t)}}function hs(e,n,t){return r;function r(i,o,a,s){const l=Array.isArray(a.children)?t:n;return s?l(o,a,s):l(o,a)}}function bs(e,n){return t;function t(r,i,o,a){const s=Array.isArray(o.children),c=gr(r);return n(i,o,a,s,{columnNumber:c?c.column-1:void 0,fileName:e,lineNumber:c?c.line:void 0},void 0)}}function Es(e,n){const t={};let r,i;for(i in n.properties)if(i!=="children"&&mr.call(n.properties,i)){const o=ys(e,i,n.properties[i]);if(o){const[a,s]=o;e.tableCellAlignToStyle&&a==="align"&&typeof s=="string"&&ls.has(n.tagName)?r=s:t[a]=s}}if(r){const o=t.style||(t.style={});o[e.stylePropertyNameCase==="css"?"text-align":"textAlign"]=r}return t}function _s(e,n){const t={};for(const r of n.attributes)if(r.type==="mdxJsxExpressionAttribute")if(r.data&&r.data.estree&&e.evaluater){const o=r.data.estree.body[0];o.type;const a=o.expression;a.type;const s=a.properties[0];s.type,Object.assign(t,e.evaluater.evaluateExpression(s.argument))}else qn(e,n.position);else{const i=r.name;let o;if(r.value&&typeof r.value=="object")if(r.value.data&&r.value.data.estree&&e.evaluater){const s=r.value.data.estree.body[0];s.type,o=e.evaluater.evaluateExpression(s.expression)}else qn(e,n.position);else o=r.value===null?!0:r.value;t[i]=o}return t}function br(e,n){const t=[];let r=-1;const i=e.passKeys?new Map:as;for(;++ri?0:i+n:n=n>i?i:n,t=t>0?t:0,r.length<1e4)a=Array.from(r),a.unshift(n,t),e.splice(...a);else for(t&&e.splice(n,t);o0?(Ue(e,e.length,0,n),e):n}const ei={}.hasOwnProperty;function ra(e){const n={};let t=-1;for(;++t13&&t<32||t>126&&t<160||t>55295&&t<57344||t>64975&&t<65008||(t&65535)===65535||(t&65535)===65534||t>1114111?"�":String.fromCodePoint(t)}function qe(e){return e.replace(/[\t\n\r ]+/g," ").replace(/^ | $/g,"").toLowerCase().toUpperCase()}const Ie=pn(/[A-Za-z]/),Te=pn(/[\dA-Za-z]/),Cs=pn(/[#-'*+\--9=?A-Z^-~]/);function gt(e){return e!==null&&(e<32||e===127)}const rr=pn(/\d/),Os=pn(/[\dA-Fa-f]/),Is=pn(/[!-/:-@[-`{-~]/);function q(e){return e!==null&&e<-2}function ge(e){return e!==null&&(e<0||e===32)}function te(e){return e===-2||e===-1||e===32}const yt=pn(new RegExp("\\p{P}|\\p{S}","u")),En=pn(/\s/);function pn(e){return n;function n(t){return t!==null&&t>-1&&e.test(String.fromCharCode(t))}}function In(e){const n=[];let t=-1,r=0,i=0;for(;++t55295&&o<57344){const s=e.charCodeAt(t+1);o<56320&&s>56319&&s<57344?(a=String.fromCharCode(o,s),i=1):a="�"}else a=String.fromCharCode(o);a&&(n.push(e.slice(r,t),encodeURIComponent(a)),r=t+i+1,a=""),i&&(t+=i,i=0)}return n.join("")+e.slice(r)}function ie(e,n,t,r){const i=r?r-1:Number.POSITIVE_INFINITY;let o=0;return a;function a(c){return te(c)?(e.enter(t),s(c)):n(c)}function s(c){return te(c)&&o++a))return;const U=n.events.length;let $=U,H,w;for(;$--;)if(n.events[$][0]==="exit"&&n.events[$][1].type==="chunkFlow"){if(H){w=n.events[$][1].end;break}H=!0}for(E(r),k=U;kx;){const R=t[O];n.containerState=R[1],R[0].exit.call(n,e)}t.length=x}function N(){i.write([null]),o=void 0,i=void 0,n.containerState._closeFlow=void 0}}function Ps(e,n,t){return ie(e,e.attempt(this.parser.constructs.document,n,t),"linePrefix",this.parser.constructs.disable.null.includes("codeIndented")?void 0:4)}function Cn(e){if(e===null||ge(e)||En(e))return 1;if(yt(e))return 2}function kt(e,n,t){const r=[];let i=-1;for(;++i1&&e[t][1].end.offset-e[t][1].start.offset>1?2:1;const u={...e[r][1].end},f={...e[t][1].start};ti(u,-c),ti(f,c),a={type:c>1?"strongSequence":"emphasisSequence",start:u,end:{...e[r][1].end}},s={type:c>1?"strongSequence":"emphasisSequence",start:{...e[t][1].start},end:f},o={type:c>1?"strongText":"emphasisText",start:{...e[r][1].end},end:{...e[t][1].start}},i={type:c>1?"strong":"emphasis",start:{...a.start},end:{...s.end}},e[r][1].end={...a.start},e[t][1].start={...s.end},l=[],e[r][1].end.offset-e[r][1].start.offset&&(l=He(l,[["enter",e[r][1],n],["exit",e[r][1],n]])),l=He(l,[["enter",i,n],["enter",a,n],["exit",a,n],["enter",o,n]]),l=He(l,kt(n.parser.constructs.insideSpan.null,e.slice(r+1,t),n)),l=He(l,[["exit",o,n],["enter",s,n],["exit",s,n],["exit",i,n]]),e[t][1].end.offset-e[t][1].start.offset?(d=2,l=He(l,[["enter",e[t][1],n],["exit",e[t][1],n]])):d=0,Ue(e,r-1,t-r+3,l),t=r+l.length-d-2;break}}for(t=-1;++t0&&te(k)?ie(e,N,"linePrefix",o+1)(k):N(k)}function N(k){return k===null||q(k)?e.check(ri,_,O)(k):(e.enter("codeFlowValue"),x(k))}function x(k){return k===null||q(k)?(e.exit("codeFlowValue"),N(k)):(e.consume(k),x)}function O(k){return e.exit("codeFenced"),n(k)}function R(k,U,$){let H=0;return w;function w(v){return k.enter("lineEnding"),k.consume(v),k.exit("lineEnding"),P}function P(v){return k.enter("codeFencedFence"),te(v)?ie(k,B,"linePrefix",r.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(v):B(v)}function B(v){return v===s?(k.enter("codeFencedFenceSequence"),J(v)):$(v)}function J(v){return v===s?(H++,k.consume(v),J):H>=a?(k.exit("codeFencedFenceSequence"),te(v)?ie(k,D,"whitespace")(v):D(v)):$(v)}function D(v){return v===null||q(v)?(k.exit("codeFencedFence"),U(v)):$(v)}}}function Ys(e,n,t){const r=this;return i;function i(a){return a===null?t(a):(e.enter("lineEnding"),e.consume(a),e.exit("lineEnding"),o)}function o(a){return r.parser.lazy[r.now().line]?t(a):n(a)}}const Pt={name:"codeIndented",tokenize:Xs},Zs={partial:!0,tokenize:Qs};function Xs(e,n,t){const r=this;return i;function i(l){return e.enter("codeIndented"),ie(e,o,"linePrefix",5)(l)}function o(l){const d=r.events[r.events.length-1];return d&&d[1].type==="linePrefix"&&d[2].sliceSerialize(d[1],!0).length>=4?a(l):t(l)}function a(l){return l===null?c(l):q(l)?e.attempt(Zs,a,c)(l):(e.enter("codeFlowValue"),s(l))}function s(l){return l===null||q(l)?(e.exit("codeFlowValue"),a(l)):(e.consume(l),s)}function c(l){return e.exit("codeIndented"),n(l)}}function Qs(e,n,t){const r=this;return i;function i(a){return r.parser.lazy[r.now().line]?t(a):q(a)?(e.enter("lineEnding"),e.consume(a),e.exit("lineEnding"),i):ie(e,o,"linePrefix",5)(a)}function o(a){const s=r.events[r.events.length-1];return s&&s[1].type==="linePrefix"&&s[2].sliceSerialize(s[1],!0).length>=4?n(a):q(a)?i(a):t(a)}}const js={name:"codeText",previous:el,resolve:Js,tokenize:nl};function Js(e){let n=e.length-4,t=3,r,i;if((e[t][1].type==="lineEnding"||e[t][1].type==="space")&&(e[n][1].type==="lineEnding"||e[n][1].type==="space")){for(r=t;++r=this.left.length+this.right.length)throw new RangeError("Cannot access index `"+n+"` in a splice buffer of size `"+(this.left.length+this.right.length)+"`");return nthis.left.length?this.right.slice(this.right.length-r+this.left.length,this.right.length-n+this.left.length).reverse():this.left.slice(n).concat(this.right.slice(this.right.length-r+this.left.length).reverse())}splice(n,t,r){const i=t||0;this.setCursor(Math.trunc(n));const o=this.right.splice(this.right.length-i,Number.POSITIVE_INFINITY);return r&&zn(this.left,r),o.reverse()}pop(){return this.setCursor(Number.POSITIVE_INFINITY),this.left.pop()}push(n){this.setCursor(Number.POSITIVE_INFINITY),this.left.push(n)}pushMany(n){this.setCursor(Number.POSITIVE_INFINITY),zn(this.left,n)}unshift(n){this.setCursor(0),this.right.push(n)}unshiftMany(n){this.setCursor(0),zn(this.right,n.reverse())}setCursor(n){if(!(n===this.left.length||n>this.left.length&&this.right.length===0||n<0&&this.left.length===0))if(n=4?n(a):e.interrupt(r.parser.constructs.flow,t,n)(a)}}function ca(e,n,t,r,i,o,a,s,c){const l=c||Number.POSITIVE_INFINITY;let d=0;return u;function u(E){return E===60?(e.enter(r),e.enter(i),e.enter(o),e.consume(E),e.exit(o),f):E===null||E===32||E===41||gt(E)?t(E):(e.enter(r),e.enter(a),e.enter(s),e.enter("chunkString",{contentType:"string"}),_(E))}function f(E){return E===62?(e.enter(o),e.consume(E),e.exit(o),e.exit(i),e.exit(r),n):(e.enter(s),e.enter("chunkString",{contentType:"string"}),p(E))}function p(E){return E===62?(e.exit("chunkString"),e.exit(s),f(E)):E===null||E===60||q(E)?t(E):(e.consume(E),E===92?g:p)}function g(E){return E===60||E===62||E===92?(e.consume(E),p):p(E)}function _(E){return!d&&(E===null||E===41||ge(E))?(e.exit("chunkString"),e.exit(s),e.exit(a),e.exit(r),n(E)):d999||p===null||p===91||p===93&&!c||p===94&&!s&&"_hiddenFootnoteSupport"in a.parser.constructs?t(p):p===93?(e.exit(o),e.enter(i),e.consume(p),e.exit(i),e.exit(r),n):q(p)?(e.enter("lineEnding"),e.consume(p),e.exit("lineEnding"),d):(e.enter("chunkString",{contentType:"string"}),u(p))}function u(p){return p===null||p===91||p===93||q(p)||s++>999?(e.exit("chunkString"),d(p)):(e.consume(p),c||(c=!te(p)),p===92?f:u)}function f(p){return p===91||p===92||p===93?(e.consume(p),s++,u):u(p)}}function da(e,n,t,r,i,o){let a;return s;function s(f){return f===34||f===39||f===40?(e.enter(r),e.enter(i),e.consume(f),e.exit(i),a=f===40?41:f,c):t(f)}function c(f){return f===a?(e.enter(i),e.consume(f),e.exit(i),e.exit(r),n):(e.enter(o),l(f))}function l(f){return f===a?(e.exit(o),c(a)):f===null?t(f):q(f)?(e.enter("lineEnding"),e.consume(f),e.exit("lineEnding"),ie(e,l,"linePrefix")):(e.enter("chunkString",{contentType:"string"}),d(f))}function d(f){return f===a||f===null||q(f)?(e.exit("chunkString"),l(f)):(e.consume(f),f===92?u:d)}function u(f){return f===a||f===92?(e.consume(f),d):d(f)}}function Gn(e,n){let t;return r;function r(i){return q(i)?(e.enter("lineEnding"),e.consume(i),e.exit("lineEnding"),t=!0,r):te(i)?ie(e,r,t?"linePrefix":"lineSuffix")(i):n(i)}}const cl={name:"definition",tokenize:dl},ul={partial:!0,tokenize:pl};function dl(e,n,t){const r=this;let i;return o;function o(p){return e.enter("definition"),a(p)}function a(p){return ua.call(r,e,s,t,"definitionLabel","definitionLabelMarker","definitionLabelString")(p)}function s(p){return i=qe(r.sliceSerialize(r.events[r.events.length-1][1]).slice(1,-1)),p===58?(e.enter("definitionMarker"),e.consume(p),e.exit("definitionMarker"),c):t(p)}function c(p){return ge(p)?Gn(e,l)(p):l(p)}function l(p){return ca(e,d,t,"definitionDestination","definitionDestinationLiteral","definitionDestinationLiteralMarker","definitionDestinationRaw","definitionDestinationString")(p)}function d(p){return e.attempt(ul,u,u)(p)}function u(p){return te(p)?ie(e,f,"whitespace")(p):f(p)}function f(p){return p===null||q(p)?(e.exit("definition"),r.parser.defined.push(i),n(p)):t(p)}}function pl(e,n,t){return r;function r(s){return ge(s)?Gn(e,i)(s):t(s)}function i(s){return da(e,o,t,"definitionTitle","definitionTitleMarker","definitionTitleString")(s)}function o(s){return te(s)?ie(e,a,"whitespace")(s):a(s)}function a(s){return s===null||q(s)?n(s):t(s)}}const fl={name:"hardBreakEscape",tokenize:gl};function gl(e,n,t){return r;function r(o){return e.enter("hardBreakEscape"),e.consume(o),i}function i(o){return q(o)?(e.exit("hardBreakEscape"),n(o)):t(o)}}const ml={name:"headingAtx",resolve:hl,tokenize:bl};function hl(e,n){let t=e.length-2,r=3,i,o;return e[r][1].type==="whitespace"&&(r+=2),t-2>r&&e[t][1].type==="whitespace"&&(t-=2),e[t][1].type==="atxHeadingSequence"&&(r===t-1||t-4>r&&e[t-2][1].type==="whitespace")&&(t-=r+1===t?2:4),t>r&&(i={type:"atxHeadingText",start:e[r][1].start,end:e[t][1].end},o={type:"chunkText",start:e[r][1].start,end:e[t][1].end,contentType:"text"},Ue(e,r,t-r+1,[["enter",i,n],["enter",o,n],["exit",o,n],["exit",i,n]])),e}function bl(e,n,t){let r=0;return i;function i(d){return e.enter("atxHeading"),o(d)}function o(d){return e.enter("atxHeadingSequence"),a(d)}function a(d){return d===35&&r++<6?(e.consume(d),a):d===null||ge(d)?(e.exit("atxHeadingSequence"),s(d)):t(d)}function s(d){return d===35?(e.enter("atxHeadingSequence"),c(d)):d===null||q(d)?(e.exit("atxHeading"),n(d)):te(d)?ie(e,s,"whitespace")(d):(e.enter("atxHeadingText"),l(d))}function c(d){return d===35?(e.consume(d),c):(e.exit("atxHeadingSequence"),s(d))}function l(d){return d===null||d===35||ge(d)?(e.exit("atxHeadingText"),s(d)):(e.consume(d),l)}}const El=["address","article","aside","base","basefont","blockquote","body","caption","center","col","colgroup","dd","details","dialog","dir","div","dl","dt","fieldset","figcaption","figure","footer","form","frame","frameset","h1","h2","h3","h4","h5","h6","head","header","hr","html","iframe","legend","li","link","main","menu","menuitem","nav","noframes","ol","optgroup","option","p","param","search","section","summary","table","tbody","td","tfoot","th","thead","title","tr","track","ul"],ai=["pre","script","style","textarea"],_l={concrete:!0,name:"htmlFlow",resolveTo:wl,tokenize:xl},yl={partial:!0,tokenize:Nl},kl={partial:!0,tokenize:Sl};function wl(e){let n=e.length;for(;n--&&!(e[n][0]==="enter"&&e[n][1].type==="htmlFlow"););return n>1&&e[n-2][1].type==="linePrefix"&&(e[n][1].start=e[n-2][1].start,e[n+1][1].start=e[n-2][1].start,e.splice(n-2,2)),e}function xl(e,n,t){const r=this;let i,o,a,s,c;return l;function l(b){return d(b)}function d(b){return e.enter("htmlFlow"),e.enter("htmlFlowData"),e.consume(b),u}function u(b){return b===33?(e.consume(b),f):b===47?(e.consume(b),o=!0,_):b===63?(e.consume(b),i=3,r.interrupt?n:m):Ie(b)?(e.consume(b),a=String.fromCharCode(b),y):t(b)}function f(b){return b===45?(e.consume(b),i=2,p):b===91?(e.consume(b),i=5,s=0,g):Ie(b)?(e.consume(b),i=4,r.interrupt?n:m):t(b)}function p(b){return b===45?(e.consume(b),r.interrupt?n:m):t(b)}function g(b){const ve="CDATA[";return b===ve.charCodeAt(s++)?(e.consume(b),s===ve.length?r.interrupt?n:B:g):t(b)}function _(b){return Ie(b)?(e.consume(b),a=String.fromCharCode(b),y):t(b)}function y(b){if(b===null||b===47||b===62||ge(b)){const ve=b===47,$e=a.toLowerCase();return!ve&&!o&&ai.includes($e)?(i=1,r.interrupt?n(b):B(b)):El.includes(a.toLowerCase())?(i=6,ve?(e.consume(b),E):r.interrupt?n(b):B(b)):(i=7,r.interrupt&&!r.parser.lazy[r.now().line]?t(b):o?N(b):x(b))}return b===45||Te(b)?(e.consume(b),a+=String.fromCharCode(b),y):t(b)}function E(b){return b===62?(e.consume(b),r.interrupt?n:B):t(b)}function N(b){return te(b)?(e.consume(b),N):w(b)}function x(b){return b===47?(e.consume(b),w):b===58||b===95||Ie(b)?(e.consume(b),O):te(b)?(e.consume(b),x):w(b)}function O(b){return b===45||b===46||b===58||b===95||Te(b)?(e.consume(b),O):R(b)}function R(b){return b===61?(e.consume(b),k):te(b)?(e.consume(b),R):x(b)}function k(b){return b===null||b===60||b===61||b===62||b===96?t(b):b===34||b===39?(e.consume(b),c=b,U):te(b)?(e.consume(b),k):$(b)}function U(b){return b===c?(e.consume(b),c=null,H):b===null||q(b)?t(b):(e.consume(b),U)}function $(b){return b===null||b===34||b===39||b===47||b===60||b===61||b===62||b===96||ge(b)?R(b):(e.consume(b),$)}function H(b){return b===47||b===62||te(b)?x(b):t(b)}function w(b){return b===62?(e.consume(b),P):t(b)}function P(b){return b===null||q(b)?B(b):te(b)?(e.consume(b),P):t(b)}function B(b){return b===45&&i===2?(e.consume(b),Z):b===60&&i===1?(e.consume(b),oe):b===62&&i===4?(e.consume(b),ce):b===63&&i===3?(e.consume(b),m):b===93&&i===5?(e.consume(b),de):q(b)&&(i===6||i===7)?(e.exit("htmlFlowData"),e.check(yl,pe,J)(b)):b===null||q(b)?(e.exit("htmlFlowData"),J(b)):(e.consume(b),B)}function J(b){return e.check(kl,D,pe)(b)}function D(b){return e.enter("lineEnding"),e.consume(b),e.exit("lineEnding"),v}function v(b){return b===null||q(b)?J(b):(e.enter("htmlFlowData"),B(b))}function Z(b){return b===45?(e.consume(b),m):B(b)}function oe(b){return b===47?(e.consume(b),a="",X):B(b)}function X(b){if(b===62){const ve=a.toLowerCase();return ai.includes(ve)?(e.consume(b),ce):B(b)}return Ie(b)&&a.length<8?(e.consume(b),a+=String.fromCharCode(b),X):B(b)}function de(b){return b===93?(e.consume(b),m):B(b)}function m(b){return b===62?(e.consume(b),ce):b===45&&i===2?(e.consume(b),m):B(b)}function ce(b){return b===null||q(b)?(e.exit("htmlFlowData"),pe(b)):(e.consume(b),ce)}function pe(b){return e.exit("htmlFlow"),n(b)}}function Sl(e,n,t){const r=this;return i;function i(a){return q(a)?(e.enter("lineEnding"),e.consume(a),e.exit("lineEnding"),o):t(a)}function o(a){return r.parser.lazy[r.now().line]?t(a):n(a)}}function Nl(e,n,t){return r;function r(i){return e.enter("lineEnding"),e.consume(i),e.exit("lineEnding"),e.attempt(Yn,n,t)}}const Tl={name:"htmlText",tokenize:Al};function Al(e,n,t){const r=this;let i,o,a;return s;function s(m){return e.enter("htmlText"),e.enter("htmlTextData"),e.consume(m),c}function c(m){return m===33?(e.consume(m),l):m===47?(e.consume(m),R):m===63?(e.consume(m),x):Ie(m)?(e.consume(m),$):t(m)}function l(m){return m===45?(e.consume(m),d):m===91?(e.consume(m),o=0,g):Ie(m)?(e.consume(m),N):t(m)}function d(m){return m===45?(e.consume(m),p):t(m)}function u(m){return m===null?t(m):m===45?(e.consume(m),f):q(m)?(a=u,oe(m)):(e.consume(m),u)}function f(m){return m===45?(e.consume(m),p):u(m)}function p(m){return m===62?Z(m):m===45?f(m):u(m)}function g(m){const ce="CDATA[";return m===ce.charCodeAt(o++)?(e.consume(m),o===ce.length?_:g):t(m)}function _(m){return m===null?t(m):m===93?(e.consume(m),y):q(m)?(a=_,oe(m)):(e.consume(m),_)}function y(m){return m===93?(e.consume(m),E):_(m)}function E(m){return m===62?Z(m):m===93?(e.consume(m),E):_(m)}function N(m){return m===null||m===62?Z(m):q(m)?(a=N,oe(m)):(e.consume(m),N)}function x(m){return m===null?t(m):m===63?(e.consume(m),O):q(m)?(a=x,oe(m)):(e.consume(m),x)}function O(m){return m===62?Z(m):x(m)}function R(m){return Ie(m)?(e.consume(m),k):t(m)}function k(m){return m===45||Te(m)?(e.consume(m),k):U(m)}function U(m){return q(m)?(a=U,oe(m)):te(m)?(e.consume(m),U):Z(m)}function $(m){return m===45||Te(m)?(e.consume(m),$):m===47||m===62||ge(m)?H(m):t(m)}function H(m){return m===47?(e.consume(m),Z):m===58||m===95||Ie(m)?(e.consume(m),w):q(m)?(a=H,oe(m)):te(m)?(e.consume(m),H):Z(m)}function w(m){return m===45||m===46||m===58||m===95||Te(m)?(e.consume(m),w):P(m)}function P(m){return m===61?(e.consume(m),B):q(m)?(a=P,oe(m)):te(m)?(e.consume(m),P):H(m)}function B(m){return m===null||m===60||m===61||m===62||m===96?t(m):m===34||m===39?(e.consume(m),i=m,J):q(m)?(a=B,oe(m)):te(m)?(e.consume(m),B):(e.consume(m),D)}function J(m){return m===i?(e.consume(m),i=void 0,v):m===null?t(m):q(m)?(a=J,oe(m)):(e.consume(m),J)}function D(m){return m===null||m===34||m===39||m===60||m===61||m===96?t(m):m===47||m===62||ge(m)?H(m):(e.consume(m),D)}function v(m){return m===47||m===62||ge(m)?H(m):t(m)}function Z(m){return m===62?(e.consume(m),e.exit("htmlTextData"),e.exit("htmlText"),n):t(m)}function oe(m){return e.exit("htmlTextData"),e.enter("lineEnding"),e.consume(m),e.exit("lineEnding"),X}function X(m){return te(m)?ie(e,de,"linePrefix",r.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(m):de(m)}function de(m){return e.enter("htmlTextData"),a(m)}}const yr={name:"labelEnd",resolveAll:Il,resolveTo:Rl,tokenize:Ml},vl={tokenize:Dl},Cl={tokenize:Ll},Ol={tokenize:Pl};function Il(e){let n=-1;const t=[];for(;++n=3&&(l===null||q(l))?(e.exit("thematicBreak"),n(l)):t(l)}function c(l){return l===i?(e.consume(l),r++,c):(e.exit("thematicBreakSequence"),te(l)?ie(e,s,"whitespace")(l):s(l))}}const Re={continuation:{tokenize:Wl},exit:Yl,name:"list",tokenize:ql},Gl={partial:!0,tokenize:Zl},Kl={partial:!0,tokenize:Vl};function ql(e,n,t){const r=this,i=r.events[r.events.length-1];let o=i&&i[1].type==="linePrefix"?i[2].sliceSerialize(i[1],!0).length:0,a=0;return s;function s(p){const g=r.containerState.type||(p===42||p===43||p===45?"listUnordered":"listOrdered");if(g==="listUnordered"?!r.containerState.marker||p===r.containerState.marker:rr(p)){if(r.containerState.type||(r.containerState.type=g,e.enter(g,{_container:!0})),g==="listUnordered")return e.enter("listItemPrefix"),p===42||p===45?e.check(ft,t,l)(p):l(p);if(!r.interrupt||p===49)return e.enter("listItemPrefix"),e.enter("listItemValue"),c(p)}return t(p)}function c(p){return rr(p)&&++a<10?(e.consume(p),c):(!r.interrupt||a<2)&&(r.containerState.marker?p===r.containerState.marker:p===41||p===46)?(e.exit("listItemValue"),l(p)):t(p)}function l(p){return e.enter("listItemMarker"),e.consume(p),e.exit("listItemMarker"),r.containerState.marker=r.containerState.marker||p,e.check(Yn,r.interrupt?t:d,e.attempt(Gl,f,u))}function d(p){return r.containerState.initialBlankLine=!0,o++,f(p)}function u(p){return te(p)?(e.enter("listItemPrefixWhitespace"),e.consume(p),e.exit("listItemPrefixWhitespace"),f):t(p)}function f(p){return r.containerState.size=o+r.sliceSerialize(e.exit("listItemPrefix"),!0).length,n(p)}}function Wl(e,n,t){const r=this;return r.containerState._closeFlow=void 0,e.check(Yn,i,o);function i(s){return r.containerState.furtherBlankLines=r.containerState.furtherBlankLines||r.containerState.initialBlankLine,ie(e,n,"listItemIndent",r.containerState.size+1)(s)}function o(s){return r.containerState.furtherBlankLines||!te(s)?(r.containerState.furtherBlankLines=void 0,r.containerState.initialBlankLine=void 0,a(s)):(r.containerState.furtherBlankLines=void 0,r.containerState.initialBlankLine=void 0,e.attempt(Kl,n,a)(s))}function a(s){return r.containerState._closeFlow=!0,r.interrupt=void 0,ie(e,e.attempt(Re,n,t),"linePrefix",r.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(s)}}function Vl(e,n,t){const r=this;return ie(e,i,"listItemIndent",r.containerState.size+1);function i(o){const a=r.events[r.events.length-1];return a&&a[1].type==="listItemIndent"&&a[2].sliceSerialize(a[1],!0).length===r.containerState.size?n(o):t(o)}}function Yl(e){e.exit(this.containerState.type)}function Zl(e,n,t){const r=this;return ie(e,i,"listItemPrefixWhitespace",r.parser.constructs.disable.null.includes("codeIndented")?void 0:5);function i(o){const a=r.events[r.events.length-1];return!te(o)&&a&&a[1].type==="listItemPrefixWhitespace"?n(o):t(o)}}const oi={name:"setextUnderline",resolveTo:Xl,tokenize:Ql};function Xl(e,n){let t=e.length,r,i,o;for(;t--;)if(e[t][0]==="enter"){if(e[t][1].type==="content"){r=t;break}e[t][1].type==="paragraph"&&(i=t)}else e[t][1].type==="content"&&e.splice(t,1),!o&&e[t][1].type==="definition"&&(o=t);const a={type:"setextHeading",start:{...e[r][1].start},end:{...e[e.length-1][1].end}};return e[i][1].type="setextHeadingText",o?(e.splice(i,0,["enter",a,n]),e.splice(o+1,0,["exit",e[r][1],n]),e[r][1].end={...e[o][1].end}):e[r][1]=a,e.push(["exit",a,n]),e}function Ql(e,n,t){const r=this;let i;return o;function o(l){let d=r.events.length,u;for(;d--;)if(r.events[d][1].type!=="lineEnding"&&r.events[d][1].type!=="linePrefix"&&r.events[d][1].type!=="content"){u=r.events[d][1].type==="paragraph";break}return!r.parser.lazy[r.now().line]&&(r.interrupt||u)?(e.enter("setextHeadingLine"),i=l,a(l)):t(l)}function a(l){return e.enter("setextHeadingLineSequence"),s(l)}function s(l){return l===i?(e.consume(l),s):(e.exit("setextHeadingLineSequence"),te(l)?ie(e,c,"lineSuffix")(l):c(l))}function c(l){return l===null||q(l)?(e.exit("setextHeadingLine"),n(l)):t(l)}}const jl={tokenize:Jl};function Jl(e){const n=this,t=e.attempt(Yn,r,e.attempt(this.parser.constructs.flowInitial,i,ie(e,e.attempt(this.parser.constructs.flow,i,e.attempt(il,i)),"linePrefix")));return t;function r(o){if(o===null){e.consume(o);return}return e.enter("lineEndingBlank"),e.consume(o),e.exit("lineEndingBlank"),n.currentConstruct=void 0,t}function i(o){if(o===null){e.consume(o);return}return e.enter("lineEnding"),e.consume(o),e.exit("lineEnding"),n.currentConstruct=void 0,t}}const ec={resolveAll:fa()},nc=pa("string"),tc=pa("text");function pa(e){return{resolveAll:fa(e==="text"?rc:void 0),tokenize:n};function n(t){const r=this,i=this.parser.constructs[e],o=t.attempt(i,a,s);return a;function a(d){return l(d)?o(d):s(d)}function s(d){if(d===null){t.consume(d);return}return t.enter("data"),t.consume(d),c}function c(d){return l(d)?(t.exit("data"),o(d)):(t.consume(d),c)}function l(d){if(d===null)return!0;const u=i[d];let f=-1;if(u)for(;++f-1){const s=a[0];typeof s=="string"?a[0]=s.slice(r):a.shift()}o>0&&a.push(e[i].slice(0,o))}return a}function hc(e,n){let t=-1;const r=[];let i;for(;++t0){const Pe=W.tokenStack[W.tokenStack.length-1];(Pe[1]||li).call(W,void 0,Pe[0])}for(I.position={start:dn(S.length>0?S[0][1].start:{line:1,column:1,offset:0}),end:dn(S.length>0?S[S.length-2][1].end:{line:1,column:1,offset:0})},se=-1;++se(()=>{var Z={4567:function(T,r,o){var l=this&&this.__decorate||function(e,i,a,v){var _,g=arguments.length,c=g<3?i:v===null?v=Object.getOwnPropertyDescriptor(i,a):v;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")c=Reflect.decorate(e,i,a,v);else for(var m=e.length-1;m>=0;m--)(_=e[m])&&(c=(g<3?_(c):g>3?_(i,a,c):_(i,a))||c);return g>3&&c&&Object.defineProperty(i,a,c),c},u=this&&this.__param||function(e,i){return function(a,v){i(a,v,e)}};Object.defineProperty(r,"__esModule",{value:!0}),r.AccessibilityManager=void 0;const n=o(9042),d=o(9924),f=o(844),p=o(4725),h=o(2585),t=o(3656);let s=r.AccessibilityManager=class extends f.Disposable{constructor(e,i,a,v){super(),this._terminal=e,this._coreBrowserService=a,this._renderService=v,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let _=0;_this._handleBoundaryFocus(_,0),this._bottomBoundaryFocusListener=_=>this._handleBoundaryFocus(_,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new d.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((_=>this._handleResize(_.rows)))),this.register(this._terminal.onRender((_=>this._refreshRows(_.start,_.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((_=>this._handleChar(_)))),this.register(this._terminal.onLineFeed((()=>this._handleChar(` -`)))),this.register(this._terminal.onA11yTab((_=>this._handleTab(_)))),this.register(this._terminal.onKey((_=>this._handleKey(_.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,t.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,f.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let i=0;i0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,e===` -`&&(this._liveRegionLineCount++,this._liveRegionLineCount===21&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),new RegExp("\\p{Control}","u").test(e)||this._charsToConsume.push(e)}_refreshRows(e,i){this._liveRegionDebouncer.refresh(e,i,this._terminal.rows)}_renderRows(e,i){const a=this._terminal.buffer,v=a.lines.length.toString();for(let _=e;_<=i;_++){const g=a.lines.get(a.ydisp+_),c=[],m=g?.translateToString(!0,void 0,void 0,c)||"",k=(a.ydisp+_+1).toString(),D=this._rowElements[_];D&&(m.length===0?(D.innerText=" ",this._rowColumns.set(D,[0,1])):(D.textContent=m,this._rowColumns.set(D,c)),D.setAttribute("aria-posinset",k),D.setAttribute("aria-setsize",v))}this._announceCharacters()}_announceCharacters(){this._charsToAnnounce.length!==0&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,i){const a=e.target,v=this._rowElements[i===0?1:this._rowElements.length-2];if(a.getAttribute("aria-posinset")===(i===0?"1":`${this._terminal.buffer.lines.length}`)||e.relatedTarget!==v)return;let _,g;if(i===0?(_=a,g=this._rowElements.pop(),this._rowContainer.removeChild(g)):(_=this._rowElements.shift(),g=a,this._rowContainer.removeChild(_)),_.removeEventListener("focus",this._topBoundaryFocusListener),g.removeEventListener("focus",this._bottomBoundaryFocusListener),i===0){const c=this._createAccessibilityTreeNode();this._rowElements.unshift(c),this._rowContainer.insertAdjacentElement("afterbegin",c)}else{const c=this._createAccessibilityTreeNode();this._rowElements.push(c),this._rowContainer.appendChild(c)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(i===0?-1:1),this._rowElements[i===0?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(this._rowElements.length===0)return;const e=document.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let i={node:e.anchorNode,offset:e.anchorOffset},a={node:e.focusNode,offset:e.focusOffset};if((i.node.compareDocumentPosition(a.node)&Node.DOCUMENT_POSITION_PRECEDING||i.node===a.node&&i.offset>a.offset)&&([i,a]=[a,i]),i.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(i={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(i.node))return;const v=this._rowElements.slice(-1)[0];if(a.node.compareDocumentPosition(v)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(a={node:v,offset:v.textContent?.length??0}),!this._rowContainer.contains(a.node))return;const _=({node:m,offset:k})=>{const D=m instanceof Text?m.parentNode:m;let L=parseInt(D?.getAttribute("aria-posinset"),10)-1;if(isNaN(L))return console.warn("row is invalid. Race condition?"),null;const w=this._rowColumns.get(D);if(!w)return console.warn("columns is null. Race condition?"),null;let x=k=this._terminal.cols&&(++L,x=0),{row:L,column:x}},g=_(i),c=_(a);if(g&&c){if(g.row>c.row||g.row===c.row&&g.column>=c.column)throw new Error("invalid range");this._terminal.select(g.column,g.row,(c.row-g.row)*this._terminal.cols-g.column+c.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let i=this._rowContainer.children.length;ie;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){this._accessibilityContainer.style.width=`${this._renderService.dimensions.css.canvas.width}px`,this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function o(d){return d.replace(/\r?\n/g,"\r")}function l(d,f){return f?"\x1B[200~"+d+"\x1B[201~":d}function u(d,f,p,h){d=l(d=o(d),p.decPrivateModes.bracketedPasteMode&&h.rawOptions.ignoreBracketedPasteMode!==!0),p.triggerDataEvent(d,!0),f.value=""}function n(d,f,p){const h=p.getBoundingClientRect(),t=d.clientX-h.left-10,s=d.clientY-h.top-10;f.style.width="20px",f.style.height="20px",f.style.left=`${t}px`,f.style.top=`${s}px`,f.style.zIndex="1000",f.focus()}Object.defineProperty(r,"__esModule",{value:!0}),r.rightClickHandler=r.moveTextAreaUnderMouseCursor=r.paste=r.handlePasteEvent=r.copyHandler=r.bracketTextForPaste=r.prepareTextForTerminal=void 0,r.prepareTextForTerminal=o,r.bracketTextForPaste=l,r.copyHandler=function(d,f){d.clipboardData&&d.clipboardData.setData("text/plain",f.selectionText),d.preventDefault()},r.handlePasteEvent=function(d,f,p,h){d.stopPropagation(),d.clipboardData&&u(d.clipboardData.getData("text/plain"),f,p,h)},r.paste=u,r.moveTextAreaUnderMouseCursor=n,r.rightClickHandler=function(d,f,p,h,t){n(d,f,p),t&&h.rightClickSelect(d),f.value=h.selectionText,f.select()}},7239:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.ColorContrastCache=void 0;const l=o(1505);r.ColorContrastCache=class{constructor(){this._color=new l.TwoKeyMap,this._css=new l.TwoKeyMap}setCss(u,n,d){this._css.set(u,n,d)}getCss(u,n){return this._css.get(u,n)}setColor(u,n,d){this._color.set(u,n,d)}getColor(u,n){return this._color.get(u,n)}clear(){this._color.clear(),this._css.clear()}}},3656:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.addDisposableDomListener=void 0,r.addDisposableDomListener=function(o,l,u,n){o.addEventListener(l,u,n);let d=!1;return{dispose:()=>{d||(d=!0,o.removeEventListener(l,u,n))}}}},3551:function(T,r,o){var l=this&&this.__decorate||function(s,e,i,a){var v,_=arguments.length,g=_<3?e:a===null?a=Object.getOwnPropertyDescriptor(e,i):a;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")g=Reflect.decorate(s,e,i,a);else for(var c=s.length-1;c>=0;c--)(v=s[c])&&(g=(_<3?v(g):_>3?v(e,i,g):v(e,i))||g);return _>3&&g&&Object.defineProperty(e,i,g),g},u=this&&this.__param||function(s,e){return function(i,a){e(i,a,s)}};Object.defineProperty(r,"__esModule",{value:!0}),r.Linkifier=void 0;const n=o(3656),d=o(8460),f=o(844),p=o(2585),h=o(4725);let t=r.Linkifier=class extends f.Disposable{get currentLink(){return this._currentLink}constructor(s,e,i,a,v){super(),this._element=s,this._mouseService=e,this._renderService=i,this._bufferService=a,this._linkProviderService=v,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this.register(new d.EventEmitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this.register(new d.EventEmitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this.register((0,f.getDisposeArrayDisposable)(this._linkCacheDisposables)),this.register((0,f.toDisposable)((()=>{this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()}))),this.register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0}))),this.register((0,n.addDisposableDomListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this.register((0,n.addDisposableDomListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(s){this._lastMouseEvent=s;const e=this._positionFromMouseEvent(s,this._element,this._mouseService);if(!e)return;this._isMouseOut=!1;const i=s.composedPath();for(let a=0;a{a?.forEach((v=>{v.link.dispose&&v.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=s.y);let i=!1;for(const[a,v]of this._linkProviderService.linkProviders.entries())e?this._activeProviderReplies?.get(a)&&(i=this._checkLinkProviderResult(a,s,i)):v.provideLinks(s.y,(_=>{if(this._isMouseOut)return;const g=_?.map((c=>({link:c})));this._activeProviderReplies?.set(a,g),i=this._checkLinkProviderResult(a,s,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(s.y,this._activeProviderReplies)}))}_removeIntersectingLinks(s,e){const i=new Set;for(let a=0;as?this._bufferService.cols:g.link.range.end.x;for(let k=c;k<=m;k++){if(i.has(k)){v.splice(_--,1);break}i.add(k)}}}}_checkLinkProviderResult(s,e,i){if(!this._activeProviderReplies)return i;const a=this._activeProviderReplies.get(s);let v=!1;for(let _=0;_this._linkAtPosition(g.link,e)));_&&(i=!0,this._handleNewLink(_))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let _=0;_this._linkAtPosition(c.link,e)));if(g){i=!0,this._handleNewLink(g);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(s){if(!this._currentLink)return;const e=this._positionFromMouseEvent(s,this._element,this._mouseService);e&&this._mouseDownLink===this._currentLink&&this._linkAtPosition(this._currentLink.link,e)&&this._currentLink.link.activate(s,this._currentLink.link.text)}_clearCurrentLink(s,e){this._currentLink&&this._lastMouseEvent&&(!s||!e||this._currentLink.link.range.start.y>=s&&this._currentLink.link.range.end.y<=e)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,f.disposeArray)(this._linkCacheDisposables))}_handleNewLink(s){if(!this._lastMouseEvent)return;const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._linkAtPosition(s.link,e)&&(this._currentLink=s,this._currentLink.state={decorations:{underline:s.link.decorations===void 0||s.link.decorations.underline,pointerCursor:s.link.decorations===void 0||s.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,s.link,this._lastMouseEvent),s.link.decorations={},Object.defineProperties(s.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:i=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==i&&(this._currentLink.state.decorations.pointerCursor=i,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",i))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:i=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==i&&(this._currentLink.state.decorations.underline=i,this._currentLink.state.isHovered&&this._fireUnderlineEvent(s.link,i))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((i=>{if(!this._currentLink)return;const a=i.start===0?0:i.start+1+this._bufferService.buffer.ydisp,v=this._bufferService.buffer.ydisp+1+i.end;if(this._currentLink.link.range.start.y>=a&&this._currentLink.link.range.end.y<=v&&(this._clearCurrentLink(a,v),this._lastMouseEvent)){const _=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);_&&this._askForLink(_,!1)}}))))}_linkHover(s,e,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(e,!0),this._currentLink.state.decorations.pointerCursor&&s.classList.add("xterm-cursor-pointer")),e.hover&&e.hover(i,e.text)}_fireUnderlineEvent(s,e){const i=s.range,a=this._bufferService.buffer.ydisp,v=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-a-1,i.end.x,i.end.y-a-1,void 0);(e?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(v)}_linkLeave(s,e,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(e,!1),this._currentLink.state.decorations.pointerCursor&&s.classList.remove("xterm-cursor-pointer")),e.leave&&e.leave(i,e.text)}_linkAtPosition(s,e){const i=s.range.start.y*this._bufferService.cols+s.range.start.x,a=s.range.end.y*this._bufferService.cols+s.range.end.x,v=e.y*this._bufferService.cols+e.x;return i<=v&&v<=a}_positionFromMouseEvent(s,e,i){const a=i.getCoords(s,e,this._bufferService.cols,this._bufferService.rows);if(a)return{x:a[0],y:a[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(s,e,i,a,v){return{x1:s,y1:e,x2:i,y2:a,cols:this._bufferService.cols,fg:v}}};r.Linkifier=t=l([u(1,h.IMouseService),u(2,h.IRenderService),u(3,p.IBufferService),u(4,h.ILinkProviderService)],t)},9042:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.tooMuchOutput=r.promptLabel=void 0,r.promptLabel="Terminal input",r.tooMuchOutput="Too much output to announce, navigate to rows manually to read"},3730:function(T,r,o){var l=this&&this.__decorate||function(h,t,s,e){var i,a=arguments.length,v=a<3?t:e===null?e=Object.getOwnPropertyDescriptor(t,s):e;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")v=Reflect.decorate(h,t,s,e);else for(var _=h.length-1;_>=0;_--)(i=h[_])&&(v=(a<3?i(v):a>3?i(t,s,v):i(t,s))||v);return a>3&&v&&Object.defineProperty(t,s,v),v},u=this&&this.__param||function(h,t){return function(s,e){t(s,e,h)}};Object.defineProperty(r,"__esModule",{value:!0}),r.OscLinkProvider=void 0;const n=o(511),d=o(2585);let f=r.OscLinkProvider=class{constructor(h,t,s){this._bufferService=h,this._optionsService=t,this._oscLinkService=s}provideLinks(h,t){const s=this._bufferService.buffer.lines.get(h-1);if(!s)return void t(void 0);const e=[],i=this._optionsService.rawOptions.linkHandler,a=new n.CellData,v=s.getTrimmedLength();let _=-1,g=-1,c=!1;for(let m=0;mi?i.activate(w,x,D):p(0,x),hover:(w,x)=>i?.hover?.(w,x,D),leave:(w,x)=>i?.leave?.(w,x,D)})}c=!1,a.hasExtendedAttrs()&&a.extended.urlId?(g=m,_=a.extended.urlId):(g=-1,_=-1)}}t(e)}};function p(h,t){if(confirm(`Do you want to navigate to ${t}? - -WARNING: This link could potentially be dangerous`)){const s=window.open();if(s){try{s.opener=null}catch{}s.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}r.OscLinkProvider=f=l([u(0,d.IBufferService),u(1,d.IOptionsService),u(2,d.IOscLinkService)],f)},6193:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.RenderDebouncer=void 0,r.RenderDebouncer=class{constructor(o,l){this._renderCallback=o,this._coreBrowserService=l,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(o){return this._refreshCallbacks.push(o),this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(o,l,u){this._rowCount=u,o=o!==void 0?o:0,l=l!==void 0?l:this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,o):o,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,l):l,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0)return void this._runRefreshCallbacks();const o=Math.max(this._rowStart,0),l=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(o,l),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const o of this._refreshCallbacks)o(0);this._refreshCallbacks=[]}}},3236:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.Terminal=void 0;const l=o(3614),u=o(3656),n=o(3551),d=o(9042),f=o(3730),p=o(1680),h=o(3107),t=o(5744),s=o(2950),e=o(1296),i=o(428),a=o(4269),v=o(5114),_=o(8934),g=o(3230),c=o(9312),m=o(4725),k=o(6731),D=o(8055),L=o(8969),w=o(8460),x=o(844),P=o(6114),H=o(8437),N=o(2584),I=o(7399),S=o(5941),y=o(9074),E=o(2585),b=o(5435),A=o(4567),O=o(779);class U extends L.CoreTerminal{get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(R={}){super(R),this.browser=P,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this.register(new x.MutableDisposable),this._onCursorMove=this.register(new w.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this.register(new w.EventEmitter),this.onKey=this._onKey.event,this._onRender=this.register(new w.EventEmitter),this.onRender=this._onRender.event,this._onSelectionChange=this.register(new w.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this.register(new w.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this.register(new w.EventEmitter),this.onBell=this._onBell.event,this._onFocus=this.register(new w.EventEmitter),this._onBlur=this.register(new w.EventEmitter),this._onA11yCharEmitter=this.register(new w.EventEmitter),this._onA11yTabEmitter=this.register(new w.EventEmitter),this._onWillOpen=this.register(new w.EventEmitter),this._setup(),this._decorationService=this._instantiationService.createInstance(y.DecorationService),this._instantiationService.setService(E.IDecorationService,this._decorationService),this._linkProviderService=this._instantiationService.createInstance(O.LinkProviderService),this._instantiationService.setService(m.ILinkProviderService,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(f.OscLinkProvider)),this.register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this.register(this._inputHandler.onRequestRefreshRows(((C,B)=>this.refresh(C,B)))),this.register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this.register(this._inputHandler.onRequestReset((()=>this.reset()))),this.register(this._inputHandler.onRequestWindowsOptionsReport((C=>this._reportWindowsOptions(C)))),this.register(this._inputHandler.onColor((C=>this._handleColorEvent(C)))),this.register((0,w.forwardEvent)(this._inputHandler.onCursorMove,this._onCursorMove)),this.register((0,w.forwardEvent)(this._inputHandler.onTitleChange,this._onTitleChange)),this.register((0,w.forwardEvent)(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this.register((0,w.forwardEvent)(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this.register(this._bufferService.onResize((C=>this._afterResize(C.cols,C.rows)))),this.register((0,x.toDisposable)((()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)})))}_handleColorEvent(R){if(this._themeService)for(const C of R){let B,M="";switch(C.index){case 256:B="foreground",M="10";break;case 257:B="background",M="11";break;case 258:B="cursor",M="12";break;default:B="ansi",M="4;"+C.index}switch(C.type){case 0:const W=D.color.toColorRGB(B==="ansi"?this._themeService.colors.ansi[C.index]:this._themeService.colors[B]);this.coreService.triggerDataEvent(`${N.C0.ESC}]${M};${(0,S.toRgbString)(W)}${N.C1_ESCAPED.ST}`);break;case 1:if(B==="ansi")this._themeService.modifyColors((F=>F.ansi[C.index]=D.channels.toColor(...C.color)));else{const F=B;this._themeService.modifyColors((z=>z[F]=D.channels.toColor(...C.color)))}break;case 2:this._themeService.restoreColor(C.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(R){R?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(A.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(R){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(N.C0.ESC+"[I"),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(N.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const R=this.buffer.ybase+this.buffer.y,C=this.buffer.lines.get(R);if(!C)return;const B=Math.min(this.buffer.x,this.cols-1),M=this._renderService.dimensions.css.cell.height,W=C.getWidth(B),F=this._renderService.dimensions.css.cell.width*W,z=this.buffer.y*this._renderService.dimensions.css.cell.height,q=B*this._renderService.dimensions.css.cell.width;this.textarea.style.left=q+"px",this.textarea.style.top=z+"px",this.textarea.style.width=F+"px",this.textarea.style.height=M+"px",this.textarea.style.lineHeight=M+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this.register((0,u.addDisposableDomListener)(this.element,"copy",(C=>{this.hasSelection()&&(0,l.copyHandler)(C,this._selectionService)})));const R=C=>(0,l.handlePasteEvent)(C,this.textarea,this.coreService,this.optionsService);this.register((0,u.addDisposableDomListener)(this.textarea,"paste",R)),this.register((0,u.addDisposableDomListener)(this.element,"paste",R)),P.isFirefox?this.register((0,u.addDisposableDomListener)(this.element,"mousedown",(C=>{C.button===2&&(0,l.rightClickHandler)(C,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this.register((0,u.addDisposableDomListener)(this.element,"contextmenu",(C=>{(0,l.rightClickHandler)(C,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),P.isLinux&&this.register((0,u.addDisposableDomListener)(this.element,"auxclick",(C=>{C.button===1&&(0,l.moveTextAreaUnderMouseCursor)(C,this.textarea,this.screenElement)})))}_bindKeys(){this.register((0,u.addDisposableDomListener)(this.textarea,"keyup",(R=>this._keyUp(R)),!0)),this.register((0,u.addDisposableDomListener)(this.textarea,"keydown",(R=>this._keyDown(R)),!0)),this.register((0,u.addDisposableDomListener)(this.textarea,"keypress",(R=>this._keyPress(R)),!0)),this.register((0,u.addDisposableDomListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this.register((0,u.addDisposableDomListener)(this.textarea,"compositionupdate",(R=>this._compositionHelper.compositionupdate(R)))),this.register((0,u.addDisposableDomListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this.register((0,u.addDisposableDomListener)(this.textarea,"input",(R=>this._inputEvent(R)),!0)),this.register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(R){if(!R)throw new Error("Terminal requires a parent element.");if(R.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=R.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),R.appendChild(this.element);const C=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),C.appendChild(this._viewportElement),this._viewportScrollArea=this._document.createElement("div"),this._viewportScrollArea.classList.add("xterm-scroll-area"),this._viewportElement.appendChild(this._viewportScrollArea),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this.register((0,u.addDisposableDomListener)(this.screenElement,"mousemove",(B=>this.updateCursorStyle(B)))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),C.appendChild(this.screenElement),this.textarea=this._document.createElement("textarea"),this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",d.promptLabel),P.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._coreBrowserService=this.register(this._instantiationService.createInstance(v.CoreBrowserService,this.textarea,R.ownerDocument.defaultView??window,this._document??typeof window<"u"?window.document:null)),this._instantiationService.setService(m.ICoreBrowserService,this._coreBrowserService),this.register((0,u.addDisposableDomListener)(this.textarea,"focus",(B=>this._handleTextAreaFocus(B)))),this.register((0,u.addDisposableDomListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(i.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(m.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(k.ThemeService),this._instantiationService.setService(m.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(a.CharacterJoinerService),this._instantiationService.setService(m.ICharacterJoinerService,this._characterJoinerService),this._renderService=this.register(this._instantiationService.createInstance(g.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(m.IRenderService,this._renderService),this.register(this._renderService.onRenderedViewportChange((B=>this._onRender.fire(B)))),this.onResize((B=>this._renderService.resize(B.cols,B.rows))),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(s.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseService=this._instantiationService.createInstance(_.MouseService),this._instantiationService.setService(m.IMouseService,this._mouseService),this.linkifier=this.register(this._instantiationService.createInstance(n.Linkifier,this.screenElement)),this.element.appendChild(C);try{this._onWillOpen.fire(this.element)}catch{}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this.viewport=this._instantiationService.createInstance(p.Viewport,this._viewportElement,this._viewportScrollArea),this.viewport.onRequestScrollLines((B=>this.scrollLines(B.amount,B.suppressScrollEvent,1))),this.register(this._inputHandler.onRequestSyncScrollBar((()=>this.viewport.syncScrollArea()))),this.register(this.viewport),this.register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this.register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this.register(this.onBlur((()=>this._renderService.handleBlur()))),this.register(this.onFocus((()=>this._renderService.handleFocus()))),this.register(this._renderService.onDimensionsChange((()=>this.viewport.syncScrollArea()))),this._selectionService=this.register(this._instantiationService.createInstance(c.SelectionService,this.element,this.screenElement,this.linkifier)),this._instantiationService.setService(m.ISelectionService,this._selectionService),this.register(this._selectionService.onRequestScrollLines((B=>this.scrollLines(B.amount,B.suppressScrollEvent)))),this.register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this.register(this._selectionService.onRequestRedraw((B=>this._renderService.handleSelectionChanged(B.start,B.end,B.columnSelectMode)))),this.register(this._selectionService.onLinuxMouseSelection((B=>{this.textarea.value=B,this.textarea.focus(),this.textarea.select()}))),this.register(this._onScroll.event((B=>{this.viewport.syncScrollArea(),this._selectionService.refresh()}))),this.register((0,u.addDisposableDomListener)(this._viewportElement,"scroll",(()=>this._selectionService.refresh()))),this.register(this._instantiationService.createInstance(h.BufferDecorationRenderer,this.screenElement)),this.register((0,u.addDisposableDomListener)(this.element,"mousedown",(B=>this._selectionService.handleMouseDown(B)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(A.AccessibilityManager,this)),this.register(this.optionsService.onSpecificOptionChange("screenReaderMode",(B=>this._handleScreenReaderModeOptionChange(B)))),this.options.overviewRulerWidth&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(t.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRulerWidth",(B=>{!this._overviewRulerRenderer&&B&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(t.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(e.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}bindMouse(){const R=this,C=this.element;function B(F){const z=R._mouseService.getMouseReportCoords(F,R.screenElement);if(!z)return!1;let q,J;switch(F.overrideType||F.type){case"mousemove":J=32,F.buttons===void 0?(q=3,F.button!==void 0&&(q=F.button<3?F.button:3)):q=1&F.buttons?0:4&F.buttons?1:2&F.buttons?2:3;break;case"mouseup":J=0,q=F.button<3?F.button:3;break;case"mousedown":J=1,q=F.button<3?F.button:3;break;case"wheel":if(R._customWheelEventHandler&&R._customWheelEventHandler(F)===!1||R.viewport.getLinesScrolled(F)===0)return!1;J=F.deltaY<0?0:1,q=4;break;default:return!1}return!(J===void 0||q===void 0||q>4)&&R.coreMouseService.triggerMouseEvent({col:z.col,row:z.row,x:z.x,y:z.y,button:q,action:J,ctrl:F.ctrlKey,alt:F.altKey,shift:F.shiftKey})}const M={mouseup:null,wheel:null,mousedrag:null,mousemove:null},W={mouseup:F=>(B(F),F.buttons||(this._document.removeEventListener("mouseup",M.mouseup),M.mousedrag&&this._document.removeEventListener("mousemove",M.mousedrag)),this.cancel(F)),wheel:F=>(B(F),this.cancel(F,!0)),mousedrag:F=>{F.buttons&&B(F)},mousemove:F=>{F.buttons||B(F)}};this.register(this.coreMouseService.onProtocolChange((F=>{F?(this.optionsService.rawOptions.logLevel==="debug"&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(F)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&F?M.mousemove||(C.addEventListener("mousemove",W.mousemove),M.mousemove=W.mousemove):(C.removeEventListener("mousemove",M.mousemove),M.mousemove=null),16&F?M.wheel||(C.addEventListener("wheel",W.wheel,{passive:!1}),M.wheel=W.wheel):(C.removeEventListener("wheel",M.wheel),M.wheel=null),2&F?M.mouseup||(M.mouseup=W.mouseup):(this._document.removeEventListener("mouseup",M.mouseup),M.mouseup=null),4&F?M.mousedrag||(M.mousedrag=W.mousedrag):(this._document.removeEventListener("mousemove",M.mousedrag),M.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this.register((0,u.addDisposableDomListener)(C,"mousedown",(F=>{if(F.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(F))return B(F),M.mouseup&&this._document.addEventListener("mouseup",M.mouseup),M.mousedrag&&this._document.addEventListener("mousemove",M.mousedrag),this.cancel(F)}))),this.register((0,u.addDisposableDomListener)(C,"wheel",(F=>{if(!M.wheel){if(this._customWheelEventHandler&&this._customWheelEventHandler(F)===!1)return!1;if(!this.buffer.hasScrollback){const z=this.viewport.getLinesScrolled(F);if(z===0)return;const q=N.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(F.deltaY<0?"A":"B");let J="";for(let ie=0;ie{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchStart(F),this.cancel(F)}),{passive:!0})),this.register((0,u.addDisposableDomListener)(C,"touchmove",(F=>{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchMove(F)?void 0:this.cancel(F)}),{passive:!1}))}refresh(R,C){this._renderService?.refreshRows(R,C)}updateCursorStyle(R){this._selectionService?.shouldColumnSelect(R)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(R,C,B=0){B===1?(super.scrollLines(R,C,B),this.refresh(0,this.rows-1)):this.viewport?.scrollLines(R)}paste(R){(0,l.paste)(R,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(R){this._customKeyEventHandler=R}attachCustomWheelEventHandler(R){this._customWheelEventHandler=R}registerLinkProvider(R){return this._linkProviderService.registerLinkProvider(R)}registerCharacterJoiner(R){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const C=this._characterJoinerService.register(R);return this.refresh(0,this.rows-1),C}deregisterCharacterJoiner(R){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(R)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(R){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+R)}registerDecoration(R){return this._decorationService.registerDecoration(R)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(R,C,B){this._selectionService.setSelection(R,C,B)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(R,C){this._selectionService?.selectLines(R,C)}_keyDown(R){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&this._customKeyEventHandler(R)===!1)return!1;const C=this.browser.isMac&&this.options.macOptionIsMeta&&R.altKey;if(!C&&!this._compositionHelper.keydown(R))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(),!1;C||R.key!=="Dead"&&R.key!=="AltGraph"||(this._unprocessedDeadKey=!0);const B=(0,I.evaluateKeyboardEvent)(R,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(R),B.type===3||B.type===2){const M=this.rows-1;return this.scrollLines(B.type===2?-M:M),this.cancel(R,!0)}return B.type===1&&this.selectAll(),!!this._isThirdLevelShift(this.browser,R)||(B.cancel&&this.cancel(R,!0),!B.key||!!(R.key&&!R.ctrlKey&&!R.altKey&&!R.metaKey&&R.key.length===1&&R.key.charCodeAt(0)>=65&&R.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(B.key!==N.C0.ETX&&B.key!==N.C0.CR||(this.textarea.value=""),this._onKey.fire({key:B.key,domEvent:R}),this._showCursor(),this.coreService.triggerDataEvent(B.key,!0),!this.optionsService.rawOptions.screenReaderMode||R.altKey||R.ctrlKey?this.cancel(R,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(R,C){const B=R.isMac&&!this.options.macOptionIsMeta&&C.altKey&&!C.ctrlKey&&!C.metaKey||R.isWindows&&C.altKey&&C.ctrlKey&&!C.metaKey||R.isWindows&&C.getModifierState("AltGraph");return C.type==="keypress"?B:B&&(!C.keyCode||C.keyCode>47)}_keyUp(R){this._keyDownSeen=!1,this._customKeyEventHandler&&this._customKeyEventHandler(R)===!1||((function(C){return C.keyCode===16||C.keyCode===17||C.keyCode===18})(R)||this.focus(),this.updateCursorStyle(R),this._keyPressHandled=!1)}_keyPress(R){let C;if(this._keyPressHandled=!1,this._keyDownHandled||this._customKeyEventHandler&&this._customKeyEventHandler(R)===!1)return!1;if(this.cancel(R),R.charCode)C=R.charCode;else if(R.which===null||R.which===void 0)C=R.keyCode;else{if(R.which===0||R.charCode===0)return!1;C=R.which}return!(!C||(R.altKey||R.ctrlKey||R.metaKey)&&!this._isThirdLevelShift(this.browser,R)||(C=String.fromCharCode(C),this._onKey.fire({key:C,domEvent:R}),this._showCursor(),this.coreService.triggerDataEvent(C,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(R){if(R.data&&R.inputType==="insertText"&&(!R.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const C=R.data;return this.coreService.triggerDataEvent(C,!0),this.cancel(R),!0}return!1}resize(R,C){R!==this.cols||C!==this.rows?super.resize(R,C):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(R,C){this._charSizeService?.measure(),this.viewport?.syncScrollArea(!0)}clear(){if(this.buffer.ybase!==0||this.buffer.y!==0){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let R=1;R{Object.defineProperty(r,"__esModule",{value:!0}),r.TimeBasedDebouncer=void 0,r.TimeBasedDebouncer=class{constructor(o,l=1e3){this._renderCallback=o,this._debounceThresholdMS=l,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(o,l,u){this._rowCount=u,o=o!==void 0?o:0,l=l!==void 0?l:this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,o):o,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,l):l;const n=Date.now();if(n-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=n,this._innerRefresh();else if(!this._additionalRefreshRequested){const d=n-this._lastRefreshMs,f=this._debounceThresholdMS-d;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=Date.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),f)}}_innerRefresh(){if(this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0)return;const o=Math.max(this._rowStart,0),l=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(o,l)}}},1680:function(T,r,o){var l=this&&this.__decorate||function(s,e,i,a){var v,_=arguments.length,g=_<3?e:a===null?a=Object.getOwnPropertyDescriptor(e,i):a;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")g=Reflect.decorate(s,e,i,a);else for(var c=s.length-1;c>=0;c--)(v=s[c])&&(g=(_<3?v(g):_>3?v(e,i,g):v(e,i))||g);return _>3&&g&&Object.defineProperty(e,i,g),g},u=this&&this.__param||function(s,e){return function(i,a){e(i,a,s)}};Object.defineProperty(r,"__esModule",{value:!0}),r.Viewport=void 0;const n=o(3656),d=o(4725),f=o(8460),p=o(844),h=o(2585);let t=r.Viewport=class extends p.Disposable{constructor(s,e,i,a,v,_,g,c){super(),this._viewportElement=s,this._scrollArea=e,this._bufferService=i,this._optionsService=a,this._charSizeService=v,this._renderService=_,this._coreBrowserService=g,this.scrollBarWidth=0,this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._wheelPartialScroll=0,this._refreshAnimationFrame=null,this._ignoreNextScrollEvent=!1,this._smoothScrollState={startTime:0,origin:-1,target:-1},this._onRequestScrollLines=this.register(new f.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this.scrollBarWidth=this._viewportElement.offsetWidth-this._scrollArea.offsetWidth||15,this.register((0,n.addDisposableDomListener)(this._viewportElement,"scroll",this._handleScroll.bind(this))),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((m=>this._activeBuffer=m.activeBuffer))),this._renderDimensions=this._renderService.dimensions,this.register(this._renderService.onDimensionsChange((m=>this._renderDimensions=m))),this._handleThemeChange(c.colors),this.register(c.onChangeColors((m=>this._handleThemeChange(m)))),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.syncScrollArea()))),setTimeout((()=>this.syncScrollArea()))}_handleThemeChange(s){this._viewportElement.style.backgroundColor=s.background.css}reset(){this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._coreBrowserService.window.requestAnimationFrame((()=>this.syncScrollArea()))}_refresh(s){if(s)return this._innerRefresh(),void(this._refreshAnimationFrame!==null&&this._coreBrowserService.window.cancelAnimationFrame(this._refreshAnimationFrame));this._refreshAnimationFrame===null&&(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._charSizeService.height>0){this._currentRowHeight=this._renderDimensions.device.cell.height/this._coreBrowserService.dpr,this._currentDeviceCellHeight=this._renderDimensions.device.cell.height,this._lastRecordedViewportHeight=this._viewportElement.offsetHeight;const e=Math.round(this._currentRowHeight*this._lastRecordedBufferLength)+(this._lastRecordedViewportHeight-this._renderDimensions.css.canvas.height);this._lastRecordedBufferHeight!==e&&(this._lastRecordedBufferHeight=e,this._scrollArea.style.height=this._lastRecordedBufferHeight+"px")}const s=this._bufferService.buffer.ydisp*this._currentRowHeight;this._viewportElement.scrollTop!==s&&(this._ignoreNextScrollEvent=!0,this._viewportElement.scrollTop=s),this._refreshAnimationFrame=null}syncScrollArea(s=!1){if(this._lastRecordedBufferLength!==this._bufferService.buffer.lines.length)return this._lastRecordedBufferLength=this._bufferService.buffer.lines.length,void this._refresh(s);this._lastRecordedViewportHeight===this._renderService.dimensions.css.canvas.height&&this._lastScrollTop===this._activeBuffer.ydisp*this._currentRowHeight&&this._renderDimensions.device.cell.height===this._currentDeviceCellHeight||this._refresh(s)}_handleScroll(s){if(this._lastScrollTop=this._viewportElement.scrollTop,!this._viewportElement.offsetParent)return;if(this._ignoreNextScrollEvent)return this._ignoreNextScrollEvent=!1,void this._onRequestScrollLines.fire({amount:0,suppressScrollEvent:!0});const e=Math.round(this._lastScrollTop/this._currentRowHeight)-this._bufferService.buffer.ydisp;this._onRequestScrollLines.fire({amount:e,suppressScrollEvent:!0})}_smoothScroll(){if(this._isDisposed||this._smoothScrollState.origin===-1||this._smoothScrollState.target===-1)return;const s=this._smoothScrollPercent();this._viewportElement.scrollTop=this._smoothScrollState.origin+Math.round(s*(this._smoothScrollState.target-this._smoothScrollState.origin)),s<1?this._coreBrowserService.window.requestAnimationFrame((()=>this._smoothScroll())):this._clearSmoothScrollState()}_smoothScrollPercent(){return this._optionsService.rawOptions.smoothScrollDuration&&this._smoothScrollState.startTime?Math.max(Math.min((Date.now()-this._smoothScrollState.startTime)/this._optionsService.rawOptions.smoothScrollDuration,1),0):1}_clearSmoothScrollState(){this._smoothScrollState.startTime=0,this._smoothScrollState.origin=-1,this._smoothScrollState.target=-1}_bubbleScroll(s,e){const i=this._viewportElement.scrollTop+this._lastRecordedViewportHeight;return!(e<0&&this._viewportElement.scrollTop!==0||e>0&&i0&&(i=D),a=""}}return{bufferElements:v,cursorElement:i}}getLinesScrolled(s){if(s.deltaY===0||s.shiftKey)return 0;let e=this._applyScrollModifier(s.deltaY,s);return s.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(e/=this._currentRowHeight+0,this._wheelPartialScroll+=e,e=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):s.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(e*=this._bufferService.rows),e}_applyScrollModifier(s,e){const i=this._optionsService.rawOptions.fastScrollModifier;return i==="alt"&&e.altKey||i==="ctrl"&&e.ctrlKey||i==="shift"&&e.shiftKey?s*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:s*this._optionsService.rawOptions.scrollSensitivity}handleTouchStart(s){this._lastTouchY=s.touches[0].pageY}handleTouchMove(s){const e=this._lastTouchY-s.touches[0].pageY;return this._lastTouchY=s.touches[0].pageY,e!==0&&(this._viewportElement.scrollTop+=e,this._bubbleScroll(s,e))}};r.Viewport=t=l([u(2,h.IBufferService),u(3,h.IOptionsService),u(4,d.ICharSizeService),u(5,d.IRenderService),u(6,d.ICoreBrowserService),u(7,d.IThemeService)],t)},3107:function(T,r,o){var l=this&&this.__decorate||function(h,t,s,e){var i,a=arguments.length,v=a<3?t:e===null?e=Object.getOwnPropertyDescriptor(t,s):e;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")v=Reflect.decorate(h,t,s,e);else for(var _=h.length-1;_>=0;_--)(i=h[_])&&(v=(a<3?i(v):a>3?i(t,s,v):i(t,s))||v);return a>3&&v&&Object.defineProperty(t,s,v),v},u=this&&this.__param||function(h,t){return function(s,e){t(s,e,h)}};Object.defineProperty(r,"__esModule",{value:!0}),r.BufferDecorationRenderer=void 0;const n=o(4725),d=o(844),f=o(2585);let p=r.BufferDecorationRenderer=class extends d.Disposable{constructor(h,t,s,e,i){super(),this._screenElement=h,this._bufferService=t,this._coreBrowserService=s,this._decorationService=e,this._renderService=i,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this.register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this.register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this.register(this._coreBrowserService.onDprChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this.register(this._decorationService.onDecorationRemoved((a=>this._removeDecoration(a)))),this.register((0,d.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){this._animationFrame===void 0&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const h of this._decorationService.decorations)this._renderDecoration(h);this._dimensionsChanged=!1}_renderDecoration(h){this._refreshStyle(h),this._dimensionsChanged&&this._refreshXPosition(h)}_createElement(h){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer",h?.options?.layer==="top"),t.style.width=`${Math.round((h.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(h.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(h.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const s=h.options.x??0;return s&&s>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(h,t),t}_refreshStyle(h){const t=h.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)h.element&&(h.element.style.display="none",h.onRenderEmitter.fire(h.element));else{let s=this._decorationElements.get(h);s||(s=this._createElement(h),h.element=s,this._decorationElements.set(h,s),this._container.appendChild(s),h.onDispose((()=>{this._decorationElements.delete(h),s.remove()}))),s.style.top=t*this._renderService.dimensions.css.cell.height+"px",s.style.display=this._altBufferIsActive?"none":"block",h.onRenderEmitter.fire(s)}}_refreshXPosition(h,t=h.element){if(!t)return;const s=h.options.x??0;(h.options.anchor||"left")==="right"?t.style.right=s?s*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=s?s*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(h){this._decorationElements.get(h)?.remove(),this._decorationElements.delete(h),h.dispose()}};r.BufferDecorationRenderer=p=l([u(1,f.IBufferService),u(2,n.ICoreBrowserService),u(3,f.IDecorationService),u(4,n.IRenderService)],p)},5871:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.ColorZoneStore=void 0,r.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(o){if(o.options.overviewRulerOptions){for(const l of this._zones)if(l.color===o.options.overviewRulerOptions.color&&l.position===o.options.overviewRulerOptions.position){if(this._lineIntersectsZone(l,o.marker.line))return;if(this._lineAdjacentToZone(l,o.marker.line,o.options.overviewRulerOptions.position))return void this._addLineToZone(l,o.marker.line)}if(this._zonePoolIndex=o.startBufferLine&&l<=o.endBufferLine}_lineAdjacentToZone(o,l,u){return l>=o.startBufferLine-this._linePadding[u||"full"]&&l<=o.endBufferLine+this._linePadding[u||"full"]}_addLineToZone(o,l){o.startBufferLine=Math.min(o.startBufferLine,l),o.endBufferLine=Math.max(o.endBufferLine,l)}}},5744:function(T,r,o){var l=this&&this.__decorate||function(i,a,v,_){var g,c=arguments.length,m=c<3?a:_===null?_=Object.getOwnPropertyDescriptor(a,v):_;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")m=Reflect.decorate(i,a,v,_);else for(var k=i.length-1;k>=0;k--)(g=i[k])&&(m=(c<3?g(m):c>3?g(a,v,m):g(a,v))||m);return c>3&&m&&Object.defineProperty(a,v,m),m},u=this&&this.__param||function(i,a){return function(v,_){a(v,_,i)}};Object.defineProperty(r,"__esModule",{value:!0}),r.OverviewRulerRenderer=void 0;const n=o(5871),d=o(4725),f=o(844),p=o(2585),h={full:0,left:0,center:0,right:0},t={full:0,left:0,center:0,right:0},s={full:0,left:0,center:0,right:0};let e=r.OverviewRulerRenderer=class extends f.Disposable{get _width(){return this._optionsService.options.overviewRulerWidth||0}constructor(i,a,v,_,g,c,m){super(),this._viewportElement=i,this._screenElement=a,this._bufferService=v,this._decorationService=_,this._renderService=g,this._optionsService=c,this._coreBrowserService=m,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement);const k=this._canvas.getContext("2d");if(!k)throw new Error("Ctx cannot be null");this._ctx=k,this._registerDecorationListeners(),this._registerBufferChangeListeners(),this._registerDimensionChangeListeners(),this.register((0,f.toDisposable)((()=>{this._canvas?.remove()})))}_registerDecorationListeners(){this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this.register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0))))}_registerBufferChangeListeners(){this.register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this.register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})))}_registerDimensionChangeListeners(){this.register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this.register(this._optionsService.onSpecificOptionChange("overviewRulerWidth",(()=>this._queueRefresh(!0)))),this.register(this._coreBrowserService.onDprChange((()=>this._queueRefresh(!0)))),this._queueRefresh(!0)}_refreshDrawConstants(){const i=Math.floor(this._canvas.width/3),a=Math.ceil(this._canvas.width/3);t.full=this._canvas.width,t.left=i,t.center=a,t.right=i,this._refreshDrawHeightConstants(),s.full=0,s.left=0,s.center=t.left,s.right=t.left+t.center}_refreshDrawHeightConstants(){h.full=Math.round(2*this._coreBrowserService.dpr);const i=this._canvas.height/this._bufferService.buffer.lines.length,a=Math.round(Math.max(Math.min(i,12),6)*this._coreBrowserService.dpr);h.left=a,h.center=a,h.right=a}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowserService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const a of this._decorationService.decorations)this._colorZoneStore.addDecoration(a);this._ctx.lineWidth=1;const i=this._colorZoneStore.zones;for(const a of i)a.position!=="full"&&this._renderColorZone(a);for(const a of i)a.position==="full"&&this._renderColorZone(a);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderColorZone(i){this._ctx.fillStyle=i.color,this._ctx.fillRect(s[i.position||"full"],Math.round((this._canvas.height-1)*(i.startBufferLine/this._bufferService.buffers.active.lines.length)-h[i.position||"full"]/2),t[i.position||"full"],Math.round((this._canvas.height-1)*((i.endBufferLine-i.startBufferLine)/this._bufferService.buffers.active.lines.length)+h[i.position||"full"]))}_queueRefresh(i,a){this._shouldUpdateDimensions=i||this._shouldUpdateDimensions,this._shouldUpdateAnchor=a||this._shouldUpdateAnchor,this._animationFrame===void 0&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};r.OverviewRulerRenderer=e=l([u(2,p.IBufferService),u(3,p.IDecorationService),u(4,d.IRenderService),u(5,p.IOptionsService),u(6,d.ICoreBrowserService)],e)},2950:function(T,r,o){var l=this&&this.__decorate||function(h,t,s,e){var i,a=arguments.length,v=a<3?t:e===null?e=Object.getOwnPropertyDescriptor(t,s):e;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")v=Reflect.decorate(h,t,s,e);else for(var _=h.length-1;_>=0;_--)(i=h[_])&&(v=(a<3?i(v):a>3?i(t,s,v):i(t,s))||v);return a>3&&v&&Object.defineProperty(t,s,v),v},u=this&&this.__param||function(h,t){return function(s,e){t(s,e,h)}};Object.defineProperty(r,"__esModule",{value:!0}),r.CompositionHelper=void 0;const n=o(4725),d=o(2585),f=o(2584);let p=r.CompositionHelper=class{get isComposing(){return this._isComposing}constructor(h,t,s,e,i,a){this._textarea=h,this._compositionView=t,this._bufferService=s,this._optionsService=e,this._coreService=i,this._renderService=a,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(h){this._compositionView.textContent=h.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(h){if(this._isComposing||this._isSendingComposition){if(h.keyCode===229||h.keyCode===16||h.keyCode===17||h.keyCode===18)return!1;this._finalizeComposition(!1)}return h.keyCode!==229||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(h){if(this._compositionView.classList.remove("active"),this._isComposing=!1,h){const t={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let s;this._isSendingComposition=!1,t.start+=this._dataAlreadySent.length,s=this._isComposing?this._textarea.value.substring(t.start,t.end):this._textarea.value.substring(t.start),s.length>0&&this._coreService.triggerDataEvent(s,!0)}}),0)}else{this._isSendingComposition=!1;const t=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(t,!0)}}_handleAnyTextareaChanges(){const h=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,s=t.replace(h,"");this._dataAlreadySent=s,t.length>h.length?this._coreService.triggerDataEvent(s,!0):t.lengththis.updateCompositionElements(!0)),0)}}};r.CompositionHelper=p=l([u(2,d.IBufferService),u(3,d.IOptionsService),u(4,d.ICoreService),u(5,n.IRenderService)],p)},9806:(T,r)=>{function o(l,u,n){const d=n.getBoundingClientRect(),f=l.getComputedStyle(n),p=parseInt(f.getPropertyValue("padding-left")),h=parseInt(f.getPropertyValue("padding-top"));return[u.clientX-d.left-p,u.clientY-d.top-h]}Object.defineProperty(r,"__esModule",{value:!0}),r.getCoords=r.getCoordsRelativeToElement=void 0,r.getCoordsRelativeToElement=o,r.getCoords=function(l,u,n,d,f,p,h,t,s){if(!p)return;const e=o(l,u,n);return e?(e[0]=Math.ceil((e[0]+(s?h/2:0))/h),e[1]=Math.ceil(e[1]/t),e[0]=Math.min(Math.max(e[0],1),d+(s?1:0)),e[1]=Math.min(Math.max(e[1],1),f),e):void 0}},9504:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.moveToCellSequence=void 0;const l=o(2584);function u(t,s,e,i){const a=t-n(t,e),v=s-n(s,e),_=Math.abs(a-v)-(function(g,c,m){let k=0;const D=g-n(g,m),L=c-n(c,m);for(let w=0;w=0&&ts?"A":"B"}function f(t,s,e,i,a,v){let _=t,g=s,c="";for(;_!==e||g!==i;)_+=a?1:-1,a&&_>v.cols-1?(c+=v.buffer.translateBufferLineToString(g,!1,t,_),_=0,t=0,g++):!a&&_<0&&(c+=v.buffer.translateBufferLineToString(g,!1,0,t+1),_=v.cols-1,t=_,g--);return c+v.buffer.translateBufferLineToString(g,!1,t,_)}function p(t,s){const e=s?"O":"[";return l.C0.ESC+e+t}function h(t,s){t=Math.floor(t);let e="";for(let i=0;i0?D-n(D,L):m;const P=D,H=(function(N,I,S,y,E,b){let A;return A=u(S,y,E,b).length>0?y-n(y,E):I,N=S&&At?"D":"C",h(Math.abs(a-t),p(_,i));_=v>s?"D":"C";const g=Math.abs(v-s);return h((function(c,m){return m.cols-c})(v>s?t:a,e)+(g-1)*e.cols+1+((v>s?a:t)-1),p(_,i))}},1296:function(T,r,o){var l=this&&this.__decorate||function(w,x,P,H){var N,I=arguments.length,S=I<3?x:H===null?H=Object.getOwnPropertyDescriptor(x,P):H;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")S=Reflect.decorate(w,x,P,H);else for(var y=w.length-1;y>=0;y--)(N=w[y])&&(S=(I<3?N(S):I>3?N(x,P,S):N(x,P))||S);return I>3&&S&&Object.defineProperty(x,P,S),S},u=this&&this.__param||function(w,x){return function(P,H){x(P,H,w)}};Object.defineProperty(r,"__esModule",{value:!0}),r.DomRenderer=void 0;const n=o(3787),d=o(2550),f=o(2223),p=o(6171),h=o(6052),t=o(4725),s=o(8055),e=o(8460),i=o(844),a=o(2585),v="xterm-dom-renderer-owner-",_="xterm-rows",g="xterm-fg-",c="xterm-bg-",m="xterm-focus",k="xterm-selection";let D=1,L=r.DomRenderer=class extends i.Disposable{constructor(w,x,P,H,N,I,S,y,E,b,A,O,U){super(),this._terminal=w,this._document=x,this._element=P,this._screenElement=H,this._viewportElement=N,this._helperContainer=I,this._linkifier2=S,this._charSizeService=E,this._optionsService=b,this._bufferService=A,this._coreBrowserService=O,this._themeService=U,this._terminalClass=D++,this._rowElements=[],this._selectionRenderModel=(0,h.createSelectionRenderModel)(),this.onRequestRedraw=this.register(new e.EventEmitter).event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add(_),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add(k),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,p.createRenderDimensions)(),this._updateDimensions(),this.register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._themeService.onChangeColors(($=>this._injectCss($)))),this._injectCss(this._themeService.colors),this._rowFactory=y.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(v+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this.register(this._linkifier2.onShowLinkUnderline(($=>this._handleLinkHover($)))),this.register(this._linkifier2.onHideLinkUnderline(($=>this._handleLinkLeave($)))),this.register((0,i.toDisposable)((()=>{this._element.classList.remove(v+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new d.WidthCache(this._document,this._helperContainer),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const w=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*w,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*w),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/w),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/w),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const P of this._rowElements)P.style.width=`${this.dimensions.css.canvas.width}px`,P.style.height=`${this.dimensions.css.cell.height}px`,P.style.lineHeight=`${this.dimensions.css.cell.height}px`,P.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const x=`${this._terminalSelector} .${_} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=x,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(w){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let x=`${this._terminalSelector} .${_} { color: ${w.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;x+=`${this._terminalSelector} .${_} .xterm-dim { color: ${s.color.multiplyOpacity(w.foreground,.5).css};}`,x+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`;const P=`blink_underline_${this._terminalClass}`,H=`blink_bar_${this._terminalClass}`,N=`blink_block_${this._terminalClass}`;x+=`@keyframes ${P} { 50% { border-bottom-style: hidden; }}`,x+=`@keyframes ${H} { 50% { box-shadow: none; }}`,x+=`@keyframes ${N} { 0% { background-color: ${w.cursor.css}; color: ${w.cursorAccent.css}; } 50% { background-color: inherit; color: ${w.cursor.css}; }}`,x+=`${this._terminalSelector} .${_}.${m} .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${P} 1s step-end infinite;}${this._terminalSelector} .${_}.${m} .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${H} 1s step-end infinite;}${this._terminalSelector} .${_}.${m} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${N} 1s step-end infinite;}${this._terminalSelector} .${_} .xterm-cursor.xterm-cursor-block { background-color: ${w.cursor.css}; color: ${w.cursorAccent.css};}${this._terminalSelector} .${_} .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${w.cursor.css} !important; color: ${w.cursorAccent.css} !important;}${this._terminalSelector} .${_} .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${w.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .${_} .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${w.cursor.css} inset;}${this._terminalSelector} .${_} .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${w.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,x+=`${this._terminalSelector} .${k} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${k} div { position: absolute; background-color: ${w.selectionBackgroundOpaque.css};}${this._terminalSelector} .${k} div { position: absolute; background-color: ${w.selectionInactiveBackgroundOpaque.css};}`;for(const[I,S]of w.ansi.entries())x+=`${this._terminalSelector} .${g}${I} { color: ${S.css}; }${this._terminalSelector} .${g}${I}.xterm-dim { color: ${s.color.multiplyOpacity(S,.5).css}; }${this._terminalSelector} .${c}${I} { background-color: ${S.css}; }`;x+=`${this._terminalSelector} .${g}${f.INVERTED_DEFAULT_COLOR} { color: ${s.color.opaque(w.background).css}; }${this._terminalSelector} .${g}${f.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${s.color.multiplyOpacity(s.color.opaque(w.background),.5).css}; }${this._terminalSelector} .${c}${f.INVERTED_DEFAULT_COLOR} { background-color: ${w.foreground.css}; }`,this._themeStyleElement.textContent=x}_setDefaultSpacing(){const w=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${w}px`,this._rowFactory.defaultSpacing=w}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(w,x){for(let P=this._rowElements.length;P<=x;P++){const H=this._document.createElement("div");this._rowContainer.appendChild(H),this._rowElements.push(H)}for(;this._rowElements.length>x;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(w,x){this._refreshRowElements(w,x),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(m),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add(m),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(w,x,P){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(w,x,P),this.renderRows(0,this._bufferService.rows-1),!w||!x)return;this._selectionRenderModel.update(this._terminal,w,x,P);const H=this._selectionRenderModel.viewportStartRow,N=this._selectionRenderModel.viewportEndRow,I=this._selectionRenderModel.viewportCappedStartRow,S=this._selectionRenderModel.viewportCappedEndRow;if(I>=this._bufferService.rows||S<0)return;const y=this._document.createDocumentFragment();if(P){const E=w[0]>x[0];y.appendChild(this._createSelectionElement(I,E?x[0]:w[0],E?w[0]:x[0],S-I+1))}else{const E=H===I?w[0]:0,b=I===N?x[0]:this._bufferService.cols;y.appendChild(this._createSelectionElement(I,E,b));const A=S-I-1;if(y.appendChild(this._createSelectionElement(I+1,0,this._bufferService.cols,A)),I!==S){const O=N===S?x[0]:this._bufferService.cols;y.appendChild(this._createSelectionElement(S,0,O))}}this._selectionContainer.appendChild(y)}_createSelectionElement(w,x,P,H=1){const N=this._document.createElement("div"),I=x*this.dimensions.css.cell.width;let S=this.dimensions.css.cell.width*(P-x);return I+S>this.dimensions.css.canvas.width&&(S=this.dimensions.css.canvas.width-I),N.style.height=H*this.dimensions.css.cell.height+"px",N.style.top=w*this.dimensions.css.cell.height+"px",N.style.left=`${I}px`,N.style.width=`${S}px`,N}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const w of this._rowElements)w.replaceChildren()}renderRows(w,x){const P=this._bufferService.buffer,H=P.ybase+P.y,N=Math.min(P.x,this._bufferService.cols-1),I=this._optionsService.rawOptions.cursorBlink,S=this._optionsService.rawOptions.cursorStyle,y=this._optionsService.rawOptions.cursorInactiveStyle;for(let E=w;E<=x;E++){const b=E+P.ydisp,A=this._rowElements[E],O=P.lines.get(b);if(!A||!O)break;A.replaceChildren(...this._rowFactory.createRow(O,b,b===H,S,y,N,I,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${v}${this._terminalClass}`}_handleLinkHover(w){this._setCellUnderline(w.x1,w.x2,w.y1,w.y2,w.cols,!0)}_handleLinkLeave(w){this._setCellUnderline(w.x1,w.x2,w.y1,w.y2,w.cols,!1)}_setCellUnderline(w,x,P,H,N,I){P<0&&(w=0),H<0&&(x=0);const S=this._bufferService.rows-1;P=Math.max(Math.min(P,S),0),H=Math.max(Math.min(H,S),0),N=Math.min(N,this._bufferService.cols);const y=this._bufferService.buffer,E=y.ybase+y.y,b=Math.min(y.x,N-1),A=this._optionsService.rawOptions.cursorBlink,O=this._optionsService.rawOptions.cursorStyle,U=this._optionsService.rawOptions.cursorInactiveStyle;for(let $=P;$<=H;++$){const R=$+y.ydisp,C=this._rowElements[$],B=y.lines.get(R);if(!C||!B)break;C.replaceChildren(...this._rowFactory.createRow(B,R,R===E,O,U,b,A,this.dimensions.css.cell.width,this._widthCache,I?$===P?w:0:-1,I?($===H?x:N)-1:-1))}}};r.DomRenderer=L=l([u(7,a.IInstantiationService),u(8,t.ICharSizeService),u(9,a.IOptionsService),u(10,a.IBufferService),u(11,t.ICoreBrowserService),u(12,t.IThemeService)],L)},3787:function(T,r,o){var l=this&&this.__decorate||function(_,g,c,m){var k,D=arguments.length,L=D<3?g:m===null?m=Object.getOwnPropertyDescriptor(g,c):m;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")L=Reflect.decorate(_,g,c,m);else for(var w=_.length-1;w>=0;w--)(k=_[w])&&(L=(D<3?k(L):D>3?k(g,c,L):k(g,c))||L);return D>3&&L&&Object.defineProperty(g,c,L),L},u=this&&this.__param||function(_,g){return function(c,m){g(c,m,_)}};Object.defineProperty(r,"__esModule",{value:!0}),r.DomRendererRowFactory=void 0;const n=o(2223),d=o(643),f=o(511),p=o(2585),h=o(8055),t=o(4725),s=o(4269),e=o(6171),i=o(3734);let a=r.DomRendererRowFactory=class{constructor(_,g,c,m,k,D,L){this._document=_,this._characterJoinerService=g,this._optionsService=c,this._coreBrowserService=m,this._coreService=k,this._decorationService=D,this._themeService=L,this._workCell=new f.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(_,g,c){this._selectionStart=_,this._selectionEnd=g,this._columnSelectMode=c}createRow(_,g,c,m,k,D,L,w,x,P,H){const N=[],I=this._characterJoinerService.getJoinedCharacters(g),S=this._themeService.colors;let y,E=_.getNoBgTrimmedLength();c&&E0&&z===I[0][0]){J=!0;const ee=I.shift();K=new s.JoinedCellData(this._workCell,_.translateToString(!0,ee[0],ee[1]),ee[1]-ee[0]),ie=ee[1]-1,q=K.getWidth()}const ae=this._isCellInSelection(z,g),_e=c&&z===D,fe=F&&z>=P&&z<=H;let ve=!1;this._decorationService.forEachDecorationAtCell(z,g,void 0,(ee=>{ve=!0}));let de=K.getChars()||d.WHITESPACE_CELL_CHAR;if(de===" "&&(K.isUnderline()||K.isOverline())&&(de=" "),M=q*w-x.get(de,K.isBold(),K.isItalic()),y){if(b&&(ae&&B||!ae&&!B&&K.bg===O)&&(ae&&B&&S.selectionForeground||K.fg===U)&&K.extended.ext===$&&fe===R&&M===C&&!_e&&!J&&!ve){K.isInvisible()?A+=d.WHITESPACE_CELL_CHAR:A+=de,b++;continue}b&&(y.textContent=A),y=this._document.createElement("span"),b=0,A=""}else y=this._document.createElement("span");if(O=K.bg,U=K.fg,$=K.extended.ext,R=fe,C=M,B=ae,J&&D>=z&&D<=ie&&(D=z),!this._coreService.isCursorHidden&&_e&&this._coreService.isCursorInitialized){if(W.push("xterm-cursor"),this._coreBrowserService.isFocused)L&&W.push("xterm-cursor-blink"),W.push(m==="bar"?"xterm-cursor-bar":m==="underline"?"xterm-cursor-underline":"xterm-cursor-block");else if(k)switch(k){case"outline":W.push("xterm-cursor-outline");break;case"block":W.push("xterm-cursor-block");break;case"bar":W.push("xterm-cursor-bar");break;case"underline":W.push("xterm-cursor-underline")}}if(K.isBold()&&W.push("xterm-bold"),K.isItalic()&&W.push("xterm-italic"),K.isDim()&&W.push("xterm-dim"),A=K.isInvisible()?d.WHITESPACE_CELL_CHAR:K.getChars()||d.WHITESPACE_CELL_CHAR,K.isUnderline()&&(W.push(`xterm-underline-${K.extended.underlineStyle}`),A===" "&&(A=" "),!K.isUnderlineColorDefault()))if(K.isUnderlineColorRGB())y.style.textDecorationColor=`rgb(${i.AttributeData.toColorRGB(K.getUnderlineColor()).join(",")})`;else{let ee=K.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&K.isBold()&&ee<8&&(ee+=8),y.style.textDecorationColor=S.ansi[ee].css}K.isOverline()&&(W.push("xterm-overline"),A===" "&&(A=" ")),K.isStrikethrough()&&W.push("xterm-strikethrough"),fe&&(y.style.textDecoration="underline");let se=K.getFgColor(),he=K.getFgColorMode(),re=K.getBgColor(),ce=K.getBgColorMode();const pe=!!K.isInverse();if(pe){const ee=se;se=re,re=ee;const Ee=he;he=ce,ce=Ee}let ne,ue,oe,le=!1;switch(this._decorationService.forEachDecorationAtCell(z,g,void 0,(ee=>{ee.options.layer!=="top"&&le||(ee.backgroundColorRGB&&(ce=50331648,re=ee.backgroundColorRGB.rgba>>8&16777215,ne=ee.backgroundColorRGB),ee.foregroundColorRGB&&(he=50331648,se=ee.foregroundColorRGB.rgba>>8&16777215,ue=ee.foregroundColorRGB),le=ee.options.layer==="top")})),!le&&ae&&(ne=this._coreBrowserService.isFocused?S.selectionBackgroundOpaque:S.selectionInactiveBackgroundOpaque,re=ne.rgba>>8&16777215,ce=50331648,le=!0,S.selectionForeground&&(he=50331648,se=S.selectionForeground.rgba>>8&16777215,ue=S.selectionForeground)),le&&W.push("xterm-decoration-top"),ce){case 16777216:case 33554432:oe=S.ansi[re],W.push(`xterm-bg-${re}`);break;case 50331648:oe=h.channels.toColor(re>>16,re>>8&255,255&re),this._addStyle(y,`background-color:#${v((re>>>0).toString(16),"0",6)}`);break;default:pe?(oe=S.foreground,W.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):oe=S.background}switch(ne||K.isDim()&&(ne=h.color.multiplyOpacity(oe,.5)),he){case 16777216:case 33554432:K.isBold()&&se<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(se+=8),this._applyMinimumContrast(y,oe,S.ansi[se],K,ne,void 0)||W.push(`xterm-fg-${se}`);break;case 50331648:const ee=h.channels.toColor(se>>16&255,se>>8&255,255&se);this._applyMinimumContrast(y,oe,ee,K,ne,ue)||this._addStyle(y,`color:#${v(se.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(y,oe,S.foreground,K,ne,ue)||pe&&W.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}W.length&&(y.className=W.join(" "),W.length=0),_e||J||ve?y.textContent=A:b++,M!==this.defaultSpacing&&(y.style.letterSpacing=`${M}px`),N.push(y),z=ie}return y&&b&&(y.textContent=A),N}_applyMinimumContrast(_,g,c,m,k,D){if(this._optionsService.rawOptions.minimumContrastRatio===1||(0,e.treatGlyphAsBackgroundColor)(m.getCode()))return!1;const L=this._getContrastCache(m);let w;if(k||D||(w=L.getColor(g.rgba,c.rgba)),w===void 0){const x=this._optionsService.rawOptions.minimumContrastRatio/(m.isDim()?2:1);w=h.color.ensureContrastRatio(k||g,D||c,x),L.setColor((k||g).rgba,(D||c).rgba,w??null)}return!!w&&(this._addStyle(_,`color:${w.css}`),!0)}_getContrastCache(_){return _.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(_,g){_.setAttribute("style",`${_.getAttribute("style")||""}${g};`)}_isCellInSelection(_,g){const c=this._selectionStart,m=this._selectionEnd;return!(!c||!m)&&(this._columnSelectMode?c[0]<=m[0]?_>=c[0]&&g>=c[1]&&_=c[1]&&_>=m[0]&&g<=m[1]:g>c[1]&&g=c[0]&&_=c[0])}};function v(_,g,c){for(;_.length{Object.defineProperty(r,"__esModule",{value:!0}),r.WidthCache=void 0,r.WidthCache=class{constructor(o,l){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=o.createElement("div"),this._container.classList.add("xterm-width-cache-measure-container"),this._container.setAttribute("aria-hidden","true"),this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const u=o.createElement("span");u.classList.add("xterm-char-measure-element");const n=o.createElement("span");n.classList.add("xterm-char-measure-element"),n.style.fontWeight="bold";const d=o.createElement("span");d.classList.add("xterm-char-measure-element"),d.style.fontStyle="italic";const f=o.createElement("span");f.classList.add("xterm-char-measure-element"),f.style.fontWeight="bold",f.style.fontStyle="italic",this._measureElements=[u,n,d,f],this._container.appendChild(u),this._container.appendChild(n),this._container.appendChild(d),this._container.appendChild(f),l.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(o,l,u,n){o===this._font&&l===this._fontSize&&u===this._weight&&n===this._weightBold||(this._font=o,this._fontSize=l,this._weight=u,this._weightBold=n,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${u}`,this._measureElements[1].style.fontWeight=`${n}`,this._measureElements[2].style.fontWeight=`${u}`,this._measureElements[3].style.fontWeight=`${n}`,this.clear())}get(o,l,u){let n=0;if(!l&&!u&&o.length===1&&(n=o.charCodeAt(0))<256){if(this._flat[n]!==-9999)return this._flat[n];const p=this._measure(o,0);return p>0&&(this._flat[n]=p),p}let d=o;l&&(d+="B"),u&&(d+="I");let f=this._holey.get(d);if(f===void 0){let p=0;l&&(p|=1),u&&(p|=2),f=this._measure(o,p),f>0&&this._holey.set(d,f)}return f}_measure(o,l){const u=this._measureElements[l];return u.textContent=o.repeat(32),u.offsetWidth/32}}},2223:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.TEXT_BASELINE=r.DIM_OPACITY=r.INVERTED_DEFAULT_COLOR=void 0;const l=o(6114);r.INVERTED_DEFAULT_COLOR=257,r.DIM_OPACITY=.5,r.TEXT_BASELINE=l.isFirefox||l.isLegacyEdge?"bottom":"ideographic"},6171:(T,r)=>{function o(u){return 57508<=u&&u<=57558}function l(u){return u>=128512&&u<=128591||u>=127744&&u<=128511||u>=128640&&u<=128767||u>=9728&&u<=9983||u>=9984&&u<=10175||u>=65024&&u<=65039||u>=129280&&u<=129535||u>=127462&&u<=127487}Object.defineProperty(r,"__esModule",{value:!0}),r.computeNextVariantOffset=r.createRenderDimensions=r.treatGlyphAsBackgroundColor=r.allowRescaling=r.isEmoji=r.isRestrictedPowerlineGlyph=r.isPowerlineGlyph=r.throwIfFalsy=void 0,r.throwIfFalsy=function(u){if(!u)throw new Error("value must not be falsy");return u},r.isPowerlineGlyph=o,r.isRestrictedPowerlineGlyph=function(u){return 57520<=u&&u<=57527},r.isEmoji=l,r.allowRescaling=function(u,n,d,f){return n===1&&d>Math.ceil(1.5*f)&&u!==void 0&&u>255&&!l(u)&&!o(u)&&!(function(p){return 57344<=p&&p<=63743})(u)},r.treatGlyphAsBackgroundColor=function(u){return o(u)||(function(n){return 9472<=n&&n<=9631})(u)},r.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},r.computeNextVariantOffset=function(u,n,d=0){return(u-(2*Math.round(n)-d))%(2*Math.round(n))}},6052:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.createSelectionRenderModel=void 0;class o{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(u,n,d,f=!1){if(this.selectionStart=n,this.selectionEnd=d,!n||!d||n[0]===d[0]&&n[1]===d[1])return void this.clear();const p=u.buffers.active.ydisp,h=n[1]-p,t=d[1]-p,s=Math.max(h,0),e=Math.min(t,u.rows-1);s>=u.rows||e<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=f,this.viewportStartRow=h,this.viewportEndRow=t,this.viewportCappedStartRow=s,this.viewportCappedEndRow=e,this.startCol=n[0],this.endCol=d[0])}isCellSelected(u,n,d){return!!this.hasSelection&&(d-=u.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?n>=this.startCol&&d>=this.viewportCappedStartRow&&n=this.viewportCappedStartRow&&n>=this.endCol&&d<=this.viewportCappedEndRow:d>this.viewportStartRow&&d=this.startCol&&n=this.startCol)}}r.createSelectionRenderModel=function(){return new o}},456:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.SelectionModel=void 0,r.SelectionModel=class{constructor(o){this._bufferService=o,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const o=this.selectionStart[0]+this.selectionStartLength;return o>this._bufferService.cols?o%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(o/this._bufferService.cols)-1]:[o%this._bufferService.cols,this.selectionStart[1]+Math.floor(o/this._bufferService.cols)]:[o,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const o=this.selectionStart[0]+this.selectionStartLength;return o>this._bufferService.cols?[o%this._bufferService.cols,this.selectionStart[1]+Math.floor(o/this._bufferService.cols)]:[Math.max(o,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const o=this.selectionStart,l=this.selectionEnd;return!(!o||!l)&&(o[1]>l[1]||o[1]===l[1]&&o[0]>l[0])}handleTrim(o){return this.selectionStart&&(this.selectionStart[1]-=o),this.selectionEnd&&(this.selectionEnd[1]-=o),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},428:function(T,r,o){var l=this&&this.__decorate||function(e,i,a,v){var _,g=arguments.length,c=g<3?i:v===null?v=Object.getOwnPropertyDescriptor(i,a):v;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")c=Reflect.decorate(e,i,a,v);else for(var m=e.length-1;m>=0;m--)(_=e[m])&&(c=(g<3?_(c):g>3?_(i,a,c):_(i,a))||c);return g>3&&c&&Object.defineProperty(i,a,c),c},u=this&&this.__param||function(e,i){return function(a,v){i(a,v,e)}};Object.defineProperty(r,"__esModule",{value:!0}),r.CharSizeService=void 0;const n=o(2585),d=o(8460),f=o(844);let p=r.CharSizeService=class extends f.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,i,a){super(),this._optionsService=a,this.width=0,this.height=0,this._onCharSizeChange=this.register(new d.EventEmitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this.register(new s(this._optionsService))}catch{this._measureStrategy=this.register(new t(e,i,this._optionsService))}this.register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};r.CharSizeService=p=l([u(2,n.IOptionsService)],p);class h extends f.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(i,a){i!==void 0&&i>0&&a!==void 0&&a>0&&(this._result.width=i,this._result.height=a)}}class t extends h{constructor(i,a,v){super(),this._document=i,this._parentElement=a,this._optionsService=v,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class s extends h{constructor(i){super(),this._optionsService=i,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const a=this._ctx.measureText("W");if(!("width"in a&&"fontBoundingBoxAscent"in a&&"fontBoundingBoxDescent"in a))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const i=this._ctx.measureText("W");return this._validateAndSet(i.width,i.fontBoundingBoxAscent+i.fontBoundingBoxDescent),this._result}}},4269:function(T,r,o){var l=this&&this.__decorate||function(s,e,i,a){var v,_=arguments.length,g=_<3?e:a===null?a=Object.getOwnPropertyDescriptor(e,i):a;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")g=Reflect.decorate(s,e,i,a);else for(var c=s.length-1;c>=0;c--)(v=s[c])&&(g=(_<3?v(g):_>3?v(e,i,g):v(e,i))||g);return _>3&&g&&Object.defineProperty(e,i,g),g},u=this&&this.__param||function(s,e){return function(i,a){e(i,a,s)}};Object.defineProperty(r,"__esModule",{value:!0}),r.CharacterJoinerService=r.JoinedCellData=void 0;const n=o(3734),d=o(643),f=o(511),p=o(2585);class h extends n.AttributeData{constructor(e,i,a){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=i,this._width=a}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}r.JoinedCellData=h;let t=r.CharacterJoinerService=class ye{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new f.CellData}register(e){const i={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(i),i.id}deregister(e){for(let i=0;i1){const L=this._getJoinedRanges(v,c,g,i,_);for(let w=0;w1){const D=this._getJoinedRanges(v,c,g,i,_);for(let L=0;L{Object.defineProperty(r,"__esModule",{value:!0}),r.CoreBrowserService=void 0;const l=o(844),u=o(8460),n=o(3656);class d extends l.Disposable{constructor(h,t,s){super(),this._textarea=h,this._window=t,this.mainDocument=s,this._isFocused=!1,this._cachedIsFocused=void 0,this._screenDprMonitor=new f(this._window),this._onDprChange=this.register(new u.EventEmitter),this.onDprChange=this._onDprChange.event,this._onWindowChange=this.register(new u.EventEmitter),this.onWindowChange=this._onWindowChange.event,this.register(this.onWindowChange((e=>this._screenDprMonitor.setWindow(e)))),this.register((0,u.forwardEvent)(this._screenDprMonitor.onDprChange,this._onDprChange)),this._textarea.addEventListener("focus",(()=>this._isFocused=!0)),this._textarea.addEventListener("blur",(()=>this._isFocused=!1))}get window(){return this._window}set window(h){this._window!==h&&(this._window=h,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return this._cachedIsFocused===void 0&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}r.CoreBrowserService=d;class f extends l.Disposable{constructor(h){super(),this._parentWindow=h,this._windowResizeListener=this.register(new l.MutableDisposable),this._onDprChange=this.register(new u.EventEmitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this.register((0,l.toDisposable)((()=>this.clearListener())))}setWindow(h){this._parentWindow=h,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,n.addDisposableDomListener)(this._parentWindow,"resize",(()=>this._setDprAndFireIfDiffers()))}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},779:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.LinkProviderService=void 0;const l=o(844);class u extends l.Disposable{constructor(){super(),this.linkProviders=[],this.register((0,l.toDisposable)((()=>this.linkProviders.length=0)))}registerLinkProvider(d){return this.linkProviders.push(d),{dispose:()=>{const f=this.linkProviders.indexOf(d);f!==-1&&this.linkProviders.splice(f,1)}}}}r.LinkProviderService=u},8934:function(T,r,o){var l=this&&this.__decorate||function(p,h,t,s){var e,i=arguments.length,a=i<3?h:s===null?s=Object.getOwnPropertyDescriptor(h,t):s;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")a=Reflect.decorate(p,h,t,s);else for(var v=p.length-1;v>=0;v--)(e=p[v])&&(a=(i<3?e(a):i>3?e(h,t,a):e(h,t))||a);return i>3&&a&&Object.defineProperty(h,t,a),a},u=this&&this.__param||function(p,h){return function(t,s){h(t,s,p)}};Object.defineProperty(r,"__esModule",{value:!0}),r.MouseService=void 0;const n=o(4725),d=o(9806);let f=r.MouseService=class{constructor(p,h){this._renderService=p,this._charSizeService=h}getCoords(p,h,t,s,e){return(0,d.getCoords)(window,p,h,t,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,e)}getMouseReportCoords(p,h){const t=(0,d.getCoordsRelativeToElement)(window,p,h);if(this._charSizeService.hasValidSize)return t[0]=Math.min(Math.max(t[0],0),this._renderService.dimensions.css.canvas.width-1),t[1]=Math.min(Math.max(t[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(t[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(t[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(t[0]),y:Math.floor(t[1])}}};r.MouseService=f=l([u(0,n.IRenderService),u(1,n.ICharSizeService)],f)},3230:function(T,r,o){var l=this&&this.__decorate||function(e,i,a,v){var _,g=arguments.length,c=g<3?i:v===null?v=Object.getOwnPropertyDescriptor(i,a):v;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")c=Reflect.decorate(e,i,a,v);else for(var m=e.length-1;m>=0;m--)(_=e[m])&&(c=(g<3?_(c):g>3?_(i,a,c):_(i,a))||c);return g>3&&c&&Object.defineProperty(i,a,c),c},u=this&&this.__param||function(e,i){return function(a,v){i(a,v,e)}};Object.defineProperty(r,"__esModule",{value:!0}),r.RenderService=void 0;const n=o(6193),d=o(4725),f=o(8460),p=o(844),h=o(7226),t=o(2585);let s=r.RenderService=class extends p.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,i,a,v,_,g,c,m){super(),this._rowCount=e,this._charSizeService=v,this._renderer=this.register(new p.MutableDisposable),this._pausedResizeTask=new h.DebouncedIdleTask,this._observerDisposable=this.register(new p.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this.register(new f.EventEmitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this.register(new f.EventEmitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this.register(new f.EventEmitter),this.onRender=this._onRender.event,this._onRefreshRequest=this.register(new f.EventEmitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new n.RenderDebouncer(((k,D)=>this._renderRows(k,D)),c),this.register(this._renderDebouncer),this.register(c.onDprChange((()=>this.handleDevicePixelRatioChange()))),this.register(g.onResize((()=>this._fullRefresh()))),this.register(g.buffers.onBufferActivate((()=>this._renderer.value?.clear()))),this.register(a.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this.register(_.onDecorationRegistered((()=>this._fullRefresh()))),this.register(_.onDecorationRemoved((()=>this._fullRefresh()))),this.register(a.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],(()=>{this.clear(),this.handleResize(g.cols,g.rows),this._fullRefresh()}))),this.register(a.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(g.buffer.y,g.buffer.y,!0)))),this.register(m.onChangeColors((()=>this._fullRefresh()))),this._registerIntersectionObserver(c.window,i),this.register(c.onWindowChange((k=>this._registerIntersectionObserver(k,i))))}_registerIntersectionObserver(e,i){if("IntersectionObserver"in e){const a=new e.IntersectionObserver((v=>this._handleIntersectionChange(v[v.length-1])),{threshold:0});a.observe(i),this._observerDisposable.value=(0,p.toDisposable)((()=>a.disconnect()))}}_handleIntersectionChange(e){this._isPaused=e.isIntersecting===void 0?e.intersectionRatio===0:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,i,a=!1){this._isPaused?this._needsFullRefresh=!0:(a||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,i,this._rowCount))}_renderRows(e,i){this._renderer.value&&(e=Math.min(e,this._rowCount-1),i=Math.min(i,this._rowCount-1),this._renderer.value.renderRows(e,i),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:i}),this._onRender.fire({start:e,end:i}),this._isNextRenderRedrawOnly=!0)}resize(e,i){this._rowCount=i,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw((i=>this.refreshRows(i.start,i.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,i){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value?.handleResize(e,i))):this._renderer.value.handleResize(e,i),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,i,a){this._selectionState.start=e,this._selectionState.end=i,this._selectionState.columnSelectMode=a,this._renderer.value?.handleSelectionChanged(e,i,a)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};r.RenderService=s=l([u(2,t.IOptionsService),u(3,d.ICharSizeService),u(4,t.IDecorationService),u(5,t.IBufferService),u(6,d.ICoreBrowserService),u(7,d.IThemeService)],s)},9312:function(T,r,o){var l=this&&this.__decorate||function(c,m,k,D){var L,w=arguments.length,x=w<3?m:D===null?D=Object.getOwnPropertyDescriptor(m,k):D;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")x=Reflect.decorate(c,m,k,D);else for(var P=c.length-1;P>=0;P--)(L=c[P])&&(x=(w<3?L(x):w>3?L(m,k,x):L(m,k))||x);return w>3&&x&&Object.defineProperty(m,k,x),x},u=this&&this.__param||function(c,m){return function(k,D){m(k,D,c)}};Object.defineProperty(r,"__esModule",{value:!0}),r.SelectionService=void 0;const n=o(9806),d=o(9504),f=o(456),p=o(4725),h=o(8460),t=o(844),s=o(6114),e=o(4841),i=o(511),a=o(2585),v=" ",_=new RegExp(v,"g");let g=r.SelectionService=class extends t.Disposable{constructor(c,m,k,D,L,w,x,P,H){super(),this._element=c,this._screenElement=m,this._linkifier=k,this._bufferService=D,this._coreService=L,this._mouseService=w,this._optionsService=x,this._renderService=P,this._coreBrowserService=H,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new i.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this.register(new h.EventEmitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this.register(new h.EventEmitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this.register(new h.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this.register(new h.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=N=>this._handleMouseMove(N),this._mouseUpListener=N=>this._handleMouseUp(N),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((N=>this._handleTrim(N))),this.register(this._bufferService.buffers.onBufferActivate((N=>this._handleBufferActivate(N)))),this.enable(),this._model=new f.SelectionModel(this._bufferService),this._activeSelectionMode=0,this.register((0,t.toDisposable)((()=>{this._removeMouseDownListeners()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const c=this._model.finalSelectionStart,m=this._model.finalSelectionEnd;return!(!c||!m||c[0]===m[0]&&c[1]===m[1])}get selectionText(){const c=this._model.finalSelectionStart,m=this._model.finalSelectionEnd;if(!c||!m)return"";const k=this._bufferService.buffer,D=[];if(this._activeSelectionMode===3){if(c[0]===m[0])return"";const L=c[0]L.replace(_," "))).join(s.isWindows?`\r -`:` -`)}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(c){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),s.isLinux&&c&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:this._activeSelectionMode===3})}_isClickInSelection(c){const m=this._getMouseBufferCoords(c),k=this._model.finalSelectionStart,D=this._model.finalSelectionEnd;return!!(k&&D&&m)&&this._areCoordsInSelection(m,k,D)}isCellInSelection(c,m){const k=this._model.finalSelectionStart,D=this._model.finalSelectionEnd;return!(!k||!D)&&this._areCoordsInSelection([c,m],k,D)}_areCoordsInSelection(c,m,k){return c[1]>m[1]&&c[1]=m[0]&&c[0]=m[0]}_selectWordAtCursor(c,m){const k=this._linkifier.currentLink?.link?.range;if(k)return this._model.selectionStart=[k.start.x-1,k.start.y-1],this._model.selectionStartLength=(0,e.getRangeLength)(k,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const D=this._getMouseBufferCoords(c);return!!D&&(this._selectWordAt(D,m),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(c,m){this._model.clearSelection(),c=Math.max(c,0),m=Math.min(m,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,c],this._model.selectionEnd=[this._bufferService.cols,m],this.refresh(),this._onSelectionChange.fire()}_handleTrim(c){this._model.handleTrim(c)&&this.refresh()}_getMouseBufferCoords(c){const m=this._mouseService.getCoords(c,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(m)return m[0]--,m[1]--,m[1]+=this._bufferService.buffer.ydisp,m}_getMouseEventScrollAmount(c){let m=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,c,this._screenElement)[1];const k=this._renderService.dimensions.css.canvas.height;return m>=0&&m<=k?0:(m>k&&(m-=k),m=Math.min(Math.max(m,-50),50),m/=50,m/Math.abs(m)+Math.round(14*m))}shouldForceSelection(c){return s.isMac?c.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:c.shiftKey}handleMouseDown(c){if(this._mouseDownTimeStamp=c.timeStamp,(c.button!==2||!this.hasSelection)&&c.button===0){if(!this._enabled){if(!this.shouldForceSelection(c))return;c.stopPropagation()}c.preventDefault(),this._dragScrollAmount=0,this._enabled&&c.shiftKey?this._handleIncrementalClick(c):c.detail===1?this._handleSingleClick(c):c.detail===2?this._handleDoubleClick(c):c.detail===3&&this._handleTripleClick(c),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(c){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(c))}_handleSingleClick(c){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(c)?3:0,this._model.selectionStart=this._getMouseBufferCoords(c),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const m=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);m&&m.length!==this._model.selectionStart[0]&&m.hasWidth(this._model.selectionStart[0])===0&&this._model.selectionStart[0]++}_handleDoubleClick(c){this._selectWordAtCursor(c,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(c){const m=this._getMouseBufferCoords(c);m&&(this._activeSelectionMode=2,this._selectLineAt(m[1]))}shouldColumnSelect(c){return c.altKey&&!(s.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(c){if(c.stopImmediatePropagation(),!this._model.selectionStart)return;const m=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(c),!this._model.selectionEnd)return void this.refresh(!0);this._activeSelectionMode===2?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const k=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(this._activeSelectionMode!==3&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(c.ydisp+this._bufferService.rows,c.lines.length-1)):(this._activeSelectionMode!==3&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=c.ydisp),this.refresh()}}_handleMouseUp(c){const m=c.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&m<500&&c.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const k=this._mouseService.getCoords(c,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(k&&k[0]!==void 0&&k[1]!==void 0){const D=(0,d.moveToCellSequence)(k[0]-1,k[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(D,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const c=this._model.finalSelectionStart,m=this._model.finalSelectionEnd,k=!(!c||!m||c[0]===m[0]&&c[1]===m[1]);k?c&&m&&(this._oldSelectionStart&&this._oldSelectionEnd&&c[0]===this._oldSelectionStart[0]&&c[1]===this._oldSelectionStart[1]&&m[0]===this._oldSelectionEnd[0]&&m[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(c,m,k)):this._oldHasSelection&&this._fireOnSelectionChange(c,m,k)}_fireOnSelectionChange(c,m,k){this._oldSelectionStart=c,this._oldSelectionEnd=m,this._oldHasSelection=k,this._onSelectionChange.fire()}_handleBufferActivate(c){this.clearSelection(),this._trimListener.dispose(),this._trimListener=c.activeBuffer.lines.onTrim((m=>this._handleTrim(m)))}_convertViewportColToCharacterIndex(c,m){let k=m;for(let D=0;m>=D;D++){const L=c.loadCell(D,this._workCell).getChars().length;this._workCell.getWidth()===0?k--:L>1&&m!==D&&(k+=L-1)}return k}setSelection(c,m,k){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[c,m],this._model.selectionStartLength=k,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(c){this._isClickInSelection(c)||(this._selectWordAtCursor(c,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(c,m,k=!0,D=!0){if(c[0]>=this._bufferService.cols)return;const L=this._bufferService.buffer,w=L.lines.get(c[1]);if(!w)return;const x=L.translateBufferLineToString(c[1],!1);let P=this._convertViewportColToCharacterIndex(w,c[0]),H=P;const N=c[0]-P;let I=0,S=0,y=0,E=0;if(x.charAt(P)===" "){for(;P>0&&x.charAt(P-1)===" ";)P--;for(;H1&&(E+=$-1,H+=$-1);O>0&&P>0&&!this._isCharWordSeparator(w.loadCell(O-1,this._workCell));){w.loadCell(O-1,this._workCell);const R=this._workCell.getChars().length;this._workCell.getWidth()===0?(I++,O--):R>1&&(y+=R-1,P-=R-1),P--,O--}for(;U1&&(E+=R-1,H+=R-1),H++,U++}}H++;let b=P+N-I+y,A=Math.min(this._bufferService.cols,H-P+I+S-y-E);if(m||x.slice(P,H).trim()!==""){if(k&&b===0&&w.getCodePoint(0)!==32){const O=L.lines.get(c[1]-1);if(O&&w.isWrapped&&O.getCodePoint(this._bufferService.cols-1)!==32){const U=this._getWordAt([this._bufferService.cols-1,c[1]-1],!1,!0,!1);if(U){const $=this._bufferService.cols-U.start;b-=$,A+=$}}}if(D&&b+A===this._bufferService.cols&&w.getCodePoint(this._bufferService.cols-1)!==32){const O=L.lines.get(c[1]+1);if(O?.isWrapped&&O.getCodePoint(0)!==32){const U=this._getWordAt([0,c[1]+1],!1,!1,!0);U&&(A+=U.length)}}return{start:b,length:A}}}_selectWordAt(c,m){const k=this._getWordAt(c,m);if(k){for(;k.start<0;)k.start+=this._bufferService.cols,c[1]--;this._model.selectionStart=[k.start,c[1]],this._model.selectionStartLength=k.length}}_selectToWordAt(c){const m=this._getWordAt(c,!0);if(m){let k=c[1];for(;m.start<0;)m.start+=this._bufferService.cols,k--;if(!this._model.areSelectionValuesReversed())for(;m.start+m.length>this._bufferService.cols;)m.length-=this._bufferService.cols,k++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?m.start:m.start+m.length,k]}}_isCharWordSeparator(c){return c.getWidth()!==0&&this._optionsService.rawOptions.wordSeparator.indexOf(c.getChars())>=0}_selectLineAt(c){const m=this._bufferService.buffer.getWrappedRangeForLine(c),k={start:{x:0,y:m.first},end:{x:this._bufferService.cols-1,y:m.last}};this._model.selectionStart=[0,m.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,e.getRangeLength)(k,this._bufferService.cols)}};r.SelectionService=g=l([u(3,a.IBufferService),u(4,a.ICoreService),u(5,p.IMouseService),u(6,a.IOptionsService),u(7,p.IRenderService),u(8,p.ICoreBrowserService)],g)},4725:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.ILinkProviderService=r.IThemeService=r.ICharacterJoinerService=r.ISelectionService=r.IRenderService=r.IMouseService=r.ICoreBrowserService=r.ICharSizeService=void 0;const l=o(8343);r.ICharSizeService=(0,l.createDecorator)("CharSizeService"),r.ICoreBrowserService=(0,l.createDecorator)("CoreBrowserService"),r.IMouseService=(0,l.createDecorator)("MouseService"),r.IRenderService=(0,l.createDecorator)("RenderService"),r.ISelectionService=(0,l.createDecorator)("SelectionService"),r.ICharacterJoinerService=(0,l.createDecorator)("CharacterJoinerService"),r.IThemeService=(0,l.createDecorator)("ThemeService"),r.ILinkProviderService=(0,l.createDecorator)("LinkProviderService")},6731:function(T,r,o){var l=this&&this.__decorate||function(g,c,m,k){var D,L=arguments.length,w=L<3?c:k===null?k=Object.getOwnPropertyDescriptor(c,m):k;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")w=Reflect.decorate(g,c,m,k);else for(var x=g.length-1;x>=0;x--)(D=g[x])&&(w=(L<3?D(w):L>3?D(c,m,w):D(c,m))||w);return L>3&&w&&Object.defineProperty(c,m,w),w},u=this&&this.__param||function(g,c){return function(m,k){c(m,k,g)}};Object.defineProperty(r,"__esModule",{value:!0}),r.ThemeService=r.DEFAULT_ANSI_COLORS=void 0;const n=o(7239),d=o(8055),f=o(8460),p=o(844),h=o(2585),t=d.css.toColor("#ffffff"),s=d.css.toColor("#000000"),e=d.css.toColor("#ffffff"),i=d.css.toColor("#000000"),a={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117};r.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const g=[d.css.toColor("#2e3436"),d.css.toColor("#cc0000"),d.css.toColor("#4e9a06"),d.css.toColor("#c4a000"),d.css.toColor("#3465a4"),d.css.toColor("#75507b"),d.css.toColor("#06989a"),d.css.toColor("#d3d7cf"),d.css.toColor("#555753"),d.css.toColor("#ef2929"),d.css.toColor("#8ae234"),d.css.toColor("#fce94f"),d.css.toColor("#729fcf"),d.css.toColor("#ad7fa8"),d.css.toColor("#34e2e2"),d.css.toColor("#eeeeec")],c=[0,95,135,175,215,255];for(let m=0;m<216;m++){const k=c[m/36%6|0],D=c[m/6%6|0],L=c[m%6];g.push({css:d.channels.toCss(k,D,L),rgba:d.channels.toRgba(k,D,L)})}for(let m=0;m<24;m++){const k=8+10*m;g.push({css:d.channels.toCss(k,k,k),rgba:d.channels.toRgba(k,k,k)})}return g})());let v=r.ThemeService=class extends p.Disposable{get colors(){return this._colors}constructor(g){super(),this._optionsService=g,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this.register(new f.EventEmitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:t,background:s,cursor:e,cursorAccent:i,selectionForeground:void 0,selectionBackgroundTransparent:a,selectionBackgroundOpaque:d.color.blend(s,a),selectionInactiveBackgroundTransparent:a,selectionInactiveBackgroundOpaque:d.color.blend(s,a),ansi:r.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this.register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this.register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(g={}){const c=this._colors;if(c.foreground=_(g.foreground,t),c.background=_(g.background,s),c.cursor=_(g.cursor,e),c.cursorAccent=_(g.cursorAccent,i),c.selectionBackgroundTransparent=_(g.selectionBackground,a),c.selectionBackgroundOpaque=d.color.blend(c.background,c.selectionBackgroundTransparent),c.selectionInactiveBackgroundTransparent=_(g.selectionInactiveBackground,c.selectionBackgroundTransparent),c.selectionInactiveBackgroundOpaque=d.color.blend(c.background,c.selectionInactiveBackgroundTransparent),c.selectionForeground=g.selectionForeground?_(g.selectionForeground,d.NULL_COLOR):void 0,c.selectionForeground===d.NULL_COLOR&&(c.selectionForeground=void 0),d.color.isOpaque(c.selectionBackgroundTransparent)&&(c.selectionBackgroundTransparent=d.color.opacity(c.selectionBackgroundTransparent,.3)),d.color.isOpaque(c.selectionInactiveBackgroundTransparent)&&(c.selectionInactiveBackgroundTransparent=d.color.opacity(c.selectionInactiveBackgroundTransparent,.3)),c.ansi=r.DEFAULT_ANSI_COLORS.slice(),c.ansi[0]=_(g.black,r.DEFAULT_ANSI_COLORS[0]),c.ansi[1]=_(g.red,r.DEFAULT_ANSI_COLORS[1]),c.ansi[2]=_(g.green,r.DEFAULT_ANSI_COLORS[2]),c.ansi[3]=_(g.yellow,r.DEFAULT_ANSI_COLORS[3]),c.ansi[4]=_(g.blue,r.DEFAULT_ANSI_COLORS[4]),c.ansi[5]=_(g.magenta,r.DEFAULT_ANSI_COLORS[5]),c.ansi[6]=_(g.cyan,r.DEFAULT_ANSI_COLORS[6]),c.ansi[7]=_(g.white,r.DEFAULT_ANSI_COLORS[7]),c.ansi[8]=_(g.brightBlack,r.DEFAULT_ANSI_COLORS[8]),c.ansi[9]=_(g.brightRed,r.DEFAULT_ANSI_COLORS[9]),c.ansi[10]=_(g.brightGreen,r.DEFAULT_ANSI_COLORS[10]),c.ansi[11]=_(g.brightYellow,r.DEFAULT_ANSI_COLORS[11]),c.ansi[12]=_(g.brightBlue,r.DEFAULT_ANSI_COLORS[12]),c.ansi[13]=_(g.brightMagenta,r.DEFAULT_ANSI_COLORS[13]),c.ansi[14]=_(g.brightCyan,r.DEFAULT_ANSI_COLORS[14]),c.ansi[15]=_(g.brightWhite,r.DEFAULT_ANSI_COLORS[15]),g.extendedAnsi){const m=Math.min(c.ansi.length-16,g.extendedAnsi.length);for(let k=0;k{Object.defineProperty(r,"__esModule",{value:!0}),r.CircularList=void 0;const l=o(8460),u=o(844);class n extends u.Disposable{constructor(f){super(),this._maxLength=f,this.onDeleteEmitter=this.register(new l.EventEmitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this.register(new l.EventEmitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this.register(new l.EventEmitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(f){if(this._maxLength===f)return;const p=new Array(f);for(let h=0;hthis._length)for(let p=this._length;p=f;t--)this._array[this._getCyclicIndex(t+h.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const t=this._length+h.length-this._maxLength;this._startIndex+=t,this._length=this._maxLength,this.onTrimEmitter.fire(t)}else this._length+=h.length}trimStart(f){f>this._length&&(f=this._length),this._startIndex+=f,this._length-=f,this.onTrimEmitter.fire(f)}shiftElements(f,p,h){if(!(p<=0)){if(f<0||f>=this._length)throw new Error("start argument out of range");if(f+h<0)throw new Error("Cannot shift elements in list beyond index 0");if(h>0){for(let s=p-1;s>=0;s--)this.set(f+s+h,this.get(f+s));const t=f+p+h-this._length;if(t>0)for(this._length+=t;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let t=0;t{Object.defineProperty(r,"__esModule",{value:!0}),r.clone=void 0,r.clone=function o(l,u=5){if(typeof l!="object")return l;const n=Array.isArray(l)?[]:{};for(const d in l)n[d]=u<=1?l[d]:l[d]&&o(l[d],u-1);return n}},8055:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.contrastRatio=r.toPaddedHex=r.rgba=r.rgb=r.css=r.color=r.channels=r.NULL_COLOR=void 0;let o=0,l=0,u=0,n=0;var d,f,p,h,t;function s(i){const a=i.toString(16);return a.length<2?"0"+a:a}function e(i,a){return i>>0},i.toColor=function(a,v,_,g){return{css:i.toCss(a,v,_,g),rgba:i.toRgba(a,v,_,g)}}})(d||(r.channels=d={})),(function(i){function a(v,_){return n=Math.round(255*_),[o,l,u]=t.toChannels(v.rgba),{css:d.toCss(o,l,u,n),rgba:d.toRgba(o,l,u,n)}}i.blend=function(v,_){if(n=(255&_.rgba)/255,n===1)return{css:_.css,rgba:_.rgba};const g=_.rgba>>24&255,c=_.rgba>>16&255,m=_.rgba>>8&255,k=v.rgba>>24&255,D=v.rgba>>16&255,L=v.rgba>>8&255;return o=k+Math.round((g-k)*n),l=D+Math.round((c-D)*n),u=L+Math.round((m-L)*n),{css:d.toCss(o,l,u),rgba:d.toRgba(o,l,u)}},i.isOpaque=function(v){return(255&v.rgba)==255},i.ensureContrastRatio=function(v,_,g){const c=t.ensureContrastRatio(v.rgba,_.rgba,g);if(c)return d.toColor(c>>24&255,c>>16&255,c>>8&255)},i.opaque=function(v){const _=(255|v.rgba)>>>0;return[o,l,u]=t.toChannels(_),{css:d.toCss(o,l,u),rgba:_}},i.opacity=a,i.multiplyOpacity=function(v,_){return n=255&v.rgba,a(v,n*_/255)},i.toColorRGB=function(v){return[v.rgba>>24&255,v.rgba>>16&255,v.rgba>>8&255]}})(f||(r.color=f={})),(function(i){let a,v;try{const _=document.createElement("canvas");_.width=1,_.height=1;const g=_.getContext("2d",{willReadFrequently:!0});g&&(a=g,a.globalCompositeOperation="copy",v=a.createLinearGradient(0,0,1,1))}catch{}i.toColor=function(_){if(_.match(/#[\da-f]{3,8}/i))switch(_.length){case 4:return o=parseInt(_.slice(1,2).repeat(2),16),l=parseInt(_.slice(2,3).repeat(2),16),u=parseInt(_.slice(3,4).repeat(2),16),d.toColor(o,l,u);case 5:return o=parseInt(_.slice(1,2).repeat(2),16),l=parseInt(_.slice(2,3).repeat(2),16),u=parseInt(_.slice(3,4).repeat(2),16),n=parseInt(_.slice(4,5).repeat(2),16),d.toColor(o,l,u,n);case 7:return{css:_,rgba:(parseInt(_.slice(1),16)<<8|255)>>>0};case 9:return{css:_,rgba:parseInt(_.slice(1),16)>>>0}}const g=_.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(g)return o=parseInt(g[1]),l=parseInt(g[2]),u=parseInt(g[3]),n=Math.round(255*(g[5]===void 0?1:parseFloat(g[5]))),d.toColor(o,l,u,n);if(!a||!v)throw new Error("css.toColor: Unsupported css format");if(a.fillStyle=v,a.fillStyle=_,typeof a.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(a.fillRect(0,0,1,1),[o,l,u,n]=a.getImageData(0,0,1,1).data,n!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:d.toRgba(o,l,u,n),css:_}}})(p||(r.css=p={})),(function(i){function a(v,_,g){const c=v/255,m=_/255,k=g/255;return .2126*(c<=.03928?c/12.92:Math.pow((c+.055)/1.055,2.4))+.7152*(m<=.03928?m/12.92:Math.pow((m+.055)/1.055,2.4))+.0722*(k<=.03928?k/12.92:Math.pow((k+.055)/1.055,2.4))}i.relativeLuminance=function(v){return a(v>>16&255,v>>8&255,255&v)},i.relativeLuminance2=a})(h||(r.rgb=h={})),(function(i){function a(_,g,c){const m=_>>24&255,k=_>>16&255,D=_>>8&255;let L=g>>24&255,w=g>>16&255,x=g>>8&255,P=e(h.relativeLuminance2(L,w,x),h.relativeLuminance2(m,k,D));for(;P0||w>0||x>0);)L-=Math.max(0,Math.ceil(.1*L)),w-=Math.max(0,Math.ceil(.1*w)),x-=Math.max(0,Math.ceil(.1*x)),P=e(h.relativeLuminance2(L,w,x),h.relativeLuminance2(m,k,D));return(L<<24|w<<16|x<<8|255)>>>0}function v(_,g,c){const m=_>>24&255,k=_>>16&255,D=_>>8&255;let L=g>>24&255,w=g>>16&255,x=g>>8&255,P=e(h.relativeLuminance2(L,w,x),h.relativeLuminance2(m,k,D));for(;P>>0}i.blend=function(_,g){if(n=(255&g)/255,n===1)return g;const c=g>>24&255,m=g>>16&255,k=g>>8&255,D=_>>24&255,L=_>>16&255,w=_>>8&255;return o=D+Math.round((c-D)*n),l=L+Math.round((m-L)*n),u=w+Math.round((k-w)*n),d.toRgba(o,l,u)},i.ensureContrastRatio=function(_,g,c){const m=h.relativeLuminance(_>>8),k=h.relativeLuminance(g>>8);if(e(m,k)>8));if(xe(m,h.relativeLuminance(P>>8))?w:P}return w}const D=v(_,g,c),L=e(m,h.relativeLuminance(D>>8));if(Le(m,h.relativeLuminance(w>>8))?D:w}return D}},i.reduceLuminance=a,i.increaseLuminance=v,i.toChannels=function(_){return[_>>24&255,_>>16&255,_>>8&255,255&_]}})(t||(r.rgba=t={})),r.toPaddedHex=s,r.contrastRatio=e},8969:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.CoreTerminal=void 0;const l=o(844),u=o(2585),n=o(4348),d=o(7866),f=o(744),p=o(7302),h=o(6975),t=o(8460),s=o(1753),e=o(1480),i=o(7994),a=o(9282),v=o(5435),_=o(5981),g=o(2660);let c=!1;class m extends l.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this.register(new t.EventEmitter),this._onScroll.event((D=>{this._onScrollApi?.fire(D.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(D){for(const L in D)this.optionsService.options[L]=D[L]}constructor(D){super(),this._windowsWrappingHeuristics=this.register(new l.MutableDisposable),this._onBinary=this.register(new t.EventEmitter),this.onBinary=this._onBinary.event,this._onData=this.register(new t.EventEmitter),this.onData=this._onData.event,this._onLineFeed=this.register(new t.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this.register(new t.EventEmitter),this.onResize=this._onResize.event,this._onWriteParsed=this.register(new t.EventEmitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this.register(new t.EventEmitter),this._instantiationService=new n.InstantiationService,this.optionsService=this.register(new p.OptionsService(D)),this._instantiationService.setService(u.IOptionsService,this.optionsService),this._bufferService=this.register(this._instantiationService.createInstance(f.BufferService)),this._instantiationService.setService(u.IBufferService,this._bufferService),this._logService=this.register(this._instantiationService.createInstance(d.LogService)),this._instantiationService.setService(u.ILogService,this._logService),this.coreService=this.register(this._instantiationService.createInstance(h.CoreService)),this._instantiationService.setService(u.ICoreService,this.coreService),this.coreMouseService=this.register(this._instantiationService.createInstance(s.CoreMouseService)),this._instantiationService.setService(u.ICoreMouseService,this.coreMouseService),this.unicodeService=this.register(this._instantiationService.createInstance(e.UnicodeService)),this._instantiationService.setService(u.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(i.CharsetService),this._instantiationService.setService(u.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(g.OscLinkService),this._instantiationService.setService(u.IOscLinkService,this._oscLinkService),this._inputHandler=this.register(new v.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this.register((0,t.forwardEvent)(this._inputHandler.onLineFeed,this._onLineFeed)),this.register(this._inputHandler),this.register((0,t.forwardEvent)(this._bufferService.onResize,this._onResize)),this.register((0,t.forwardEvent)(this.coreService.onData,this._onData)),this.register((0,t.forwardEvent)(this.coreService.onBinary,this._onBinary)),this.register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom()))),this.register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this.register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this.register(this._bufferService.onScroll((L=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this.register(this._inputHandler.onScroll((L=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this.register(new _.WriteBuffer(((L,w)=>this._inputHandler.parse(L,w)))),this.register((0,t.forwardEvent)(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(D,L){this._writeBuffer.write(D,L)}writeSync(D,L){this._logService.logLevel<=u.LogLevelEnum.WARN&&!c&&(this._logService.warn("writeSync is unreliable and will be removed soon."),c=!0),this._writeBuffer.writeSync(D,L)}input(D,L=!0){this.coreService.triggerDataEvent(D,L)}resize(D,L){isNaN(D)||isNaN(L)||(D=Math.max(D,f.MINIMUM_COLS),L=Math.max(L,f.MINIMUM_ROWS),this._bufferService.resize(D,L))}scroll(D,L=!1){this._bufferService.scroll(D,L)}scrollLines(D,L,w){this._bufferService.scrollLines(D,L,w)}scrollPages(D){this.scrollLines(D*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(D){const L=D-this._bufferService.buffer.ydisp;L!==0&&this.scrollLines(L)}registerEscHandler(D,L){return this._inputHandler.registerEscHandler(D,L)}registerDcsHandler(D,L){return this._inputHandler.registerDcsHandler(D,L)}registerCsiHandler(D,L){return this._inputHandler.registerCsiHandler(D,L)}registerOscHandler(D,L){return this._inputHandler.registerOscHandler(D,L)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let D=!1;const L=this.optionsService.rawOptions.windowsPty;L&&L.buildNumber!==void 0&&L.buildNumber!==void 0?D=L.backend==="conpty"&&L.buildNumber<21376:this.optionsService.rawOptions.windowsMode&&(D=!0),D?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const D=[];D.push(this.onLineFeed(a.updateWindowsModeWrappedState.bind(null,this._bufferService))),D.push(this.registerCsiHandler({final:"H"},(()=>((0,a.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,l.toDisposable)((()=>{for(const L of D)L.dispose()}))}}}r.CoreTerminal=m},8460:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.runAndSubscribe=r.forwardEvent=r.EventEmitter=void 0,r.EventEmitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=o=>(this._listeners.push(o),{dispose:()=>{if(!this._disposed){for(let l=0;ll.fire(u)))},r.runAndSubscribe=function(o,l){return l(void 0),o((u=>l(u)))}},5435:function(T,r,o){var l=this&&this.__decorate||function(I,S,y,E){var b,A=arguments.length,O=A<3?S:E===null?E=Object.getOwnPropertyDescriptor(S,y):E;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")O=Reflect.decorate(I,S,y,E);else for(var U=I.length-1;U>=0;U--)(b=I[U])&&(O=(A<3?b(O):A>3?b(S,y,O):b(S,y))||O);return A>3&&O&&Object.defineProperty(S,y,O),O},u=this&&this.__param||function(I,S){return function(y,E){S(y,E,I)}};Object.defineProperty(r,"__esModule",{value:!0}),r.InputHandler=r.WindowsOptionsReportType=void 0;const n=o(2584),d=o(7116),f=o(2015),p=o(844),h=o(482),t=o(8437),s=o(8460),e=o(643),i=o(511),a=o(3734),v=o(2585),_=o(1480),g=o(6242),c=o(6351),m=o(5941),k={"(":0,")":1,"*":2,"+":3,"-":1,".":2},D=131072;function L(I,S){if(I>24)return S.setWinLines||!1;switch(I){case 1:return!!S.restoreWin;case 2:return!!S.minimizeWin;case 3:return!!S.setWinPosition;case 4:return!!S.setWinSizePixels;case 5:return!!S.raiseWin;case 6:return!!S.lowerWin;case 7:return!!S.refreshWin;case 8:return!!S.setWinSizeChars;case 9:return!!S.maximizeWin;case 10:return!!S.fullscreenWin;case 11:return!!S.getWinState;case 13:return!!S.getWinPosition;case 14:return!!S.getWinSizePixels;case 15:return!!S.getScreenSizePixels;case 16:return!!S.getCellSizePixels;case 18:return!!S.getWinSizeChars;case 19:return!!S.getScreenSizeChars;case 20:return!!S.getIconTitle;case 21:return!!S.getWinTitle;case 22:return!!S.pushTitle;case 23:return!!S.popTitle;case 24:return!!S.setWinLines}return!1}var w;(function(I){I[I.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",I[I.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"})(w||(r.WindowsOptionsReportType=w={}));let x=0;class P extends p.Disposable{getAttrData(){return this._curAttrData}constructor(S,y,E,b,A,O,U,$,R=new f.EscapeSequenceParser){super(),this._bufferService=S,this._charsetService=y,this._coreService=E,this._logService=b,this._optionsService=A,this._oscLinkService=O,this._coreMouseService=U,this._unicodeService=$,this._parser=R,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new h.StringToUtf32,this._utf8Decoder=new h.Utf8ToUtf32,this._workCell=new i.CellData,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=t.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=t.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this.register(new s.EventEmitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this.register(new s.EventEmitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this.register(new s.EventEmitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this.register(new s.EventEmitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this.register(new s.EventEmitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this.register(new s.EventEmitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this.register(new s.EventEmitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this.register(new s.EventEmitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this.register(new s.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this.register(new s.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this.register(new s.EventEmitter),this.onScroll=this._onScroll.event,this._onTitleChange=this.register(new s.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this.register(new s.EventEmitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this.register(this._parser),this._dirtyRowTracker=new H(this._bufferService),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((C=>this._activeBuffer=C.activeBuffer))),this._parser.setCsiHandlerFallback(((C,B)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(C),params:B.toArray()})})),this._parser.setEscHandlerFallback((C=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(C)})})),this._parser.setExecuteHandlerFallback((C=>{this._logService.debug("Unknown EXECUTE code: ",{code:C})})),this._parser.setOscHandlerFallback(((C,B,M)=>{this._logService.debug("Unknown OSC code: ",{identifier:C,action:B,data:M})})),this._parser.setDcsHandlerFallback(((C,B,M)=>{B==="HOOK"&&(M=M.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(C),action:B,payload:M})})),this._parser.setPrintHandler(((C,B,M)=>this.print(C,B,M))),this._parser.registerCsiHandler({final:"@"},(C=>this.insertChars(C))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(C=>this.scrollLeft(C))),this._parser.registerCsiHandler({final:"A"},(C=>this.cursorUp(C))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(C=>this.scrollRight(C))),this._parser.registerCsiHandler({final:"B"},(C=>this.cursorDown(C))),this._parser.registerCsiHandler({final:"C"},(C=>this.cursorForward(C))),this._parser.registerCsiHandler({final:"D"},(C=>this.cursorBackward(C))),this._parser.registerCsiHandler({final:"E"},(C=>this.cursorNextLine(C))),this._parser.registerCsiHandler({final:"F"},(C=>this.cursorPrecedingLine(C))),this._parser.registerCsiHandler({final:"G"},(C=>this.cursorCharAbsolute(C))),this._parser.registerCsiHandler({final:"H"},(C=>this.cursorPosition(C))),this._parser.registerCsiHandler({final:"I"},(C=>this.cursorForwardTab(C))),this._parser.registerCsiHandler({final:"J"},(C=>this.eraseInDisplay(C,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(C=>this.eraseInDisplay(C,!0))),this._parser.registerCsiHandler({final:"K"},(C=>this.eraseInLine(C,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(C=>this.eraseInLine(C,!0))),this._parser.registerCsiHandler({final:"L"},(C=>this.insertLines(C))),this._parser.registerCsiHandler({final:"M"},(C=>this.deleteLines(C))),this._parser.registerCsiHandler({final:"P"},(C=>this.deleteChars(C))),this._parser.registerCsiHandler({final:"S"},(C=>this.scrollUp(C))),this._parser.registerCsiHandler({final:"T"},(C=>this.scrollDown(C))),this._parser.registerCsiHandler({final:"X"},(C=>this.eraseChars(C))),this._parser.registerCsiHandler({final:"Z"},(C=>this.cursorBackwardTab(C))),this._parser.registerCsiHandler({final:"`"},(C=>this.charPosAbsolute(C))),this._parser.registerCsiHandler({final:"a"},(C=>this.hPositionRelative(C))),this._parser.registerCsiHandler({final:"b"},(C=>this.repeatPrecedingCharacter(C))),this._parser.registerCsiHandler({final:"c"},(C=>this.sendDeviceAttributesPrimary(C))),this._parser.registerCsiHandler({prefix:">",final:"c"},(C=>this.sendDeviceAttributesSecondary(C))),this._parser.registerCsiHandler({final:"d"},(C=>this.linePosAbsolute(C))),this._parser.registerCsiHandler({final:"e"},(C=>this.vPositionRelative(C))),this._parser.registerCsiHandler({final:"f"},(C=>this.hVPosition(C))),this._parser.registerCsiHandler({final:"g"},(C=>this.tabClear(C))),this._parser.registerCsiHandler({final:"h"},(C=>this.setMode(C))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(C=>this.setModePrivate(C))),this._parser.registerCsiHandler({final:"l"},(C=>this.resetMode(C))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(C=>this.resetModePrivate(C))),this._parser.registerCsiHandler({final:"m"},(C=>this.charAttributes(C))),this._parser.registerCsiHandler({final:"n"},(C=>this.deviceStatus(C))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(C=>this.deviceStatusPrivate(C))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(C=>this.softReset(C))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(C=>this.setCursorStyle(C))),this._parser.registerCsiHandler({final:"r"},(C=>this.setScrollRegion(C))),this._parser.registerCsiHandler({final:"s"},(C=>this.saveCursor(C))),this._parser.registerCsiHandler({final:"t"},(C=>this.windowOptions(C))),this._parser.registerCsiHandler({final:"u"},(C=>this.restoreCursor(C))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(C=>this.insertColumns(C))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(C=>this.deleteColumns(C))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(C=>this.selectProtected(C))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(C=>this.requestMode(C,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(C=>this.requestMode(C,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new g.OscHandler((C=>(this.setTitle(C),this.setIconName(C),!0)))),this._parser.registerOscHandler(1,new g.OscHandler((C=>this.setIconName(C)))),this._parser.registerOscHandler(2,new g.OscHandler((C=>this.setTitle(C)))),this._parser.registerOscHandler(4,new g.OscHandler((C=>this.setOrReportIndexedColor(C)))),this._parser.registerOscHandler(8,new g.OscHandler((C=>this.setHyperlink(C)))),this._parser.registerOscHandler(10,new g.OscHandler((C=>this.setOrReportFgColor(C)))),this._parser.registerOscHandler(11,new g.OscHandler((C=>this.setOrReportBgColor(C)))),this._parser.registerOscHandler(12,new g.OscHandler((C=>this.setOrReportCursorColor(C)))),this._parser.registerOscHandler(104,new g.OscHandler((C=>this.restoreIndexedColor(C)))),this._parser.registerOscHandler(110,new g.OscHandler((C=>this.restoreFgColor(C)))),this._parser.registerOscHandler(111,new g.OscHandler((C=>this.restoreBgColor(C)))),this._parser.registerOscHandler(112,new g.OscHandler((C=>this.restoreCursorColor(C)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const C in d.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:C},(()=>this.selectCharset("("+C))),this._parser.registerEscHandler({intermediates:")",final:C},(()=>this.selectCharset(")"+C))),this._parser.registerEscHandler({intermediates:"*",final:C},(()=>this.selectCharset("*"+C))),this._parser.registerEscHandler({intermediates:"+",final:C},(()=>this.selectCharset("+"+C))),this._parser.registerEscHandler({intermediates:"-",final:C},(()=>this.selectCharset("-"+C))),this._parser.registerEscHandler({intermediates:".",final:C},(()=>this.selectCharset("."+C))),this._parser.registerEscHandler({intermediates:"/",final:C},(()=>this.selectCharset("/"+C)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((C=>(this._logService.error("Parsing error: ",C),C))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new c.DcsHandler(((C,B)=>this.requestStatusString(C,B))))}_preserveStack(S,y,E,b){this._parseStack.paused=!0,this._parseStack.cursorStartX=S,this._parseStack.cursorStartY=y,this._parseStack.decodedLength=E,this._parseStack.position=b}_logSlowResolvingAsync(S){this._logService.logLevel<=v.LogLevelEnum.WARN&&Promise.race([S,new Promise(((y,E)=>setTimeout((()=>E("#SLOW_TIMEOUT")),5e3)))]).catch((y=>{if(y!=="#SLOW_TIMEOUT")throw y;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(S,y){let E,b=this._activeBuffer.x,A=this._activeBuffer.y,O=0;const U=this._parseStack.paused;if(U){if(E=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,y))return this._logSlowResolvingAsync(E),E;b=this._parseStack.cursorStartX,A=this._parseStack.cursorStartY,this._parseStack.paused=!1,S.length>D&&(O=this._parseStack.position+D)}if(this._logService.logLevel<=v.LogLevelEnum.DEBUG&&this._logService.debug("parsing data"+(typeof S=="string"?` "${S}"`:` "${Array.prototype.map.call(S,(C=>String.fromCharCode(C))).join("")}"`),typeof S=="string"?S.split("").map((C=>C.charCodeAt(0))):S),this._parseBuffer.lengthD)for(let C=O;C0&&M.getWidth(this._activeBuffer.x-1)===2&&M.setCellFromCodepoint(this._activeBuffer.x-1,0,1,B);let W=this._parser.precedingJoinState;for(let F=y;F$){if(R){const ie=M;let K=this._activeBuffer.x-J;for(this._activeBuffer.x=J,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),M=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),J>0&&M instanceof t.BufferLine&&M.copyCellsFrom(ie,K,0,J,!1);K<$;)ie.setCellFromCodepoint(K++,0,1,B)}else if(this._activeBuffer.x=$-1,A===2)continue}if(q&&this._activeBuffer.x){const ie=M.getWidth(this._activeBuffer.x-1)?1:2;M.addCodepointToCell(this._activeBuffer.x-ie,b,A);for(let K=A-J;--K>=0;)M.setCellFromCodepoint(this._activeBuffer.x++,0,0,B)}else if(C&&(M.insertCells(this._activeBuffer.x,A-J,this._activeBuffer.getNullCell(B)),M.getWidth($-1)===2&&M.setCellFromCodepoint($-1,e.NULL_CELL_CODE,e.NULL_CELL_WIDTH,B)),M.setCellFromCodepoint(this._activeBuffer.x++,b,A,B),A>0)for(;--A;)M.setCellFromCodepoint(this._activeBuffer.x++,0,0,B)}this._parser.precedingJoinState=W,this._activeBuffer.x<$&&E-y>0&&M.getWidth(this._activeBuffer.x)===0&&!M.hasContent(this._activeBuffer.x)&&M.setCellFromCodepoint(this._activeBuffer.x,0,1,B),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(S,y){return S.final!=="t"||S.prefix||S.intermediates?this._parser.registerCsiHandler(S,y):this._parser.registerCsiHandler(S,(E=>!L(E.params[0],this._optionsService.rawOptions.windowOptions)||y(E)))}registerDcsHandler(S,y){return this._parser.registerDcsHandler(S,new c.DcsHandler(y))}registerEscHandler(S,y){return this._parser.registerEscHandler(S,y)}registerOscHandler(S,y){return this._parser.registerOscHandler(S,new g.OscHandler(y))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(this._activeBuffer.x===0&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const S=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);S.hasWidth(this._activeBuffer.x)&&!S.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const S=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-S),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(S=this._bufferService.cols-1){this._activeBuffer.x=Math.min(S,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(S,y){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=S,this._activeBuffer.y=this._activeBuffer.scrollTop+y):(this._activeBuffer.x=S,this._activeBuffer.y=y),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(S,y){this._restrictCursor(),this._setCursor(this._activeBuffer.x+S,this._activeBuffer.y+y)}cursorUp(S){const y=this._activeBuffer.y-this._activeBuffer.scrollTop;return y>=0?this._moveCursor(0,-Math.min(y,S.params[0]||1)):this._moveCursor(0,-(S.params[0]||1)),!0}cursorDown(S){const y=this._activeBuffer.scrollBottom-this._activeBuffer.y;return y>=0?this._moveCursor(0,Math.min(y,S.params[0]||1)):this._moveCursor(0,S.params[0]||1),!0}cursorForward(S){return this._moveCursor(S.params[0]||1,0),!0}cursorBackward(S){return this._moveCursor(-(S.params[0]||1),0),!0}cursorNextLine(S){return this.cursorDown(S),this._activeBuffer.x=0,!0}cursorPrecedingLine(S){return this.cursorUp(S),this._activeBuffer.x=0,!0}cursorCharAbsolute(S){return this._setCursor((S.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(S){return this._setCursor(S.length>=2?(S.params[1]||1)-1:0,(S.params[0]||1)-1),!0}charPosAbsolute(S){return this._setCursor((S.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(S){return this._moveCursor(S.params[0]||1,0),!0}linePosAbsolute(S){return this._setCursor(this._activeBuffer.x,(S.params[0]||1)-1),!0}vPositionRelative(S){return this._moveCursor(0,S.params[0]||1),!0}hVPosition(S){return this.cursorPosition(S),!0}tabClear(S){const y=S.params[0];return y===0?delete this._activeBuffer.tabs[this._activeBuffer.x]:y===3&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(S){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let y=S.params[0]||1;for(;y--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(S){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let y=S.params[0]||1;for(;y--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(S){const y=S.params[0];return y===1&&(this._curAttrData.bg|=536870912),y!==2&&y!==0||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(S,y,E,b=!1,A=!1){const O=this._activeBuffer.lines.get(this._activeBuffer.ybase+S);O.replaceCells(y,E,this._activeBuffer.getNullCell(this._eraseAttrData()),A),b&&(O.isWrapped=!1)}_resetBufferLine(S,y=!1){const E=this._activeBuffer.lines.get(this._activeBuffer.ybase+S);E&&(E.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),y),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+S),E.isWrapped=!1)}eraseInDisplay(S,y=!1){let E;switch(this._restrictCursor(this._bufferService.cols),S.params[0]){case 0:for(E=this._activeBuffer.y,this._dirtyRowTracker.markDirty(E),this._eraseInBufferLine(E++,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,y);E=this._bufferService.cols&&(this._activeBuffer.lines.get(E+1).isWrapped=!1);E--;)this._resetBufferLine(E,y);this._dirtyRowTracker.markDirty(0);break;case 2:for(E=this._bufferService.rows,this._dirtyRowTracker.markDirty(E-1);E--;)this._resetBufferLine(E,y);this._dirtyRowTracker.markDirty(0);break;case 3:const b=this._activeBuffer.lines.length-this._bufferService.rows;b>0&&(this._activeBuffer.lines.trimStart(b),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-b,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-b,0),this._onScroll.fire(0))}return!0}eraseInLine(S,y=!1){switch(this._restrictCursor(this._bufferService.cols),S.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,y);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,y);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,y)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(S){this._restrictCursor();let y=S.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let R=$;for(let C=1;C0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(S){return S.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(S.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(S){return(this._optionsService.rawOptions.termName+"").indexOf(S)===0}setMode(S){for(let y=0;yq?1:2,W=S.params[0];return F=W,z=y?W===2?4:W===4?M(O.modes.insertMode):W===12?3:W===20?M(B.convertEol):0:W===1?M(E.applicationCursorKeys):W===3?B.windowOptions.setWinLines?$===80?2:$===132?1:0:0:W===6?M(E.origin):W===7?M(E.wraparound):W===8?3:W===9?M(b==="X10"):W===12?M(B.cursorBlink):W===25?M(!O.isCursorHidden):W===45?M(E.reverseWraparound):W===66?M(E.applicationKeypad):W===67?4:W===1e3?M(b==="VT200"):W===1002?M(b==="DRAG"):W===1003?M(b==="ANY"):W===1004?M(E.sendFocus):W===1005?4:W===1006?M(A==="SGR"):W===1015?4:W===1016?M(A==="SGR_PIXELS"):W===1048?1:W===47||W===1047||W===1049?M(R===C):W===2004?M(E.bracketedPasteMode):0,O.triggerDataEvent(`${n.C0.ESC}[${y?"":"?"}${F};${z}$y`),!0;var F,z}_updateAttrColor(S,y,E,b,A){return y===2?(S|=50331648,S&=-16777216,S|=a.AttributeData.fromColorRGB([E,b,A])):y===5&&(S&=-50331904,S|=33554432|255&E),S}_extractColor(S,y,E){const b=[0,0,-1,0,0,0];let A=0,O=0;do{if(b[O+A]=S.params[y+O],S.hasSubParams(y+O)){const U=S.getSubParams(y+O);let $=0;do b[1]===5&&(A=1),b[O+$+1+A]=U[$];while(++$=2||b[1]===2&&O+A>=5)break;b[1]&&(A=1)}while(++O+y5)&&(S=1),y.extended.underlineStyle=S,y.fg|=268435456,S===0&&(y.fg&=-268435457),y.updateExtended()}_processSGR0(S){S.fg=t.DEFAULT_ATTR_DATA.fg,S.bg=t.DEFAULT_ATTR_DATA.bg,S.extended=S.extended.clone(),S.extended.underlineStyle=0,S.extended.underlineColor&=-67108864,S.updateExtended()}charAttributes(S){if(S.length===1&&S.params[0]===0)return this._processSGR0(this._curAttrData),!0;const y=S.length;let E;const b=this._curAttrData;for(let A=0;A=30&&E<=37?(b.fg&=-50331904,b.fg|=16777216|E-30):E>=40&&E<=47?(b.bg&=-50331904,b.bg|=16777216|E-40):E>=90&&E<=97?(b.fg&=-50331904,b.fg|=16777224|E-90):E>=100&&E<=107?(b.bg&=-50331904,b.bg|=16777224|E-100):E===0?this._processSGR0(b):E===1?b.fg|=134217728:E===3?b.bg|=67108864:E===4?(b.fg|=268435456,this._processUnderline(S.hasSubParams(A)?S.getSubParams(A)[0]:1,b)):E===5?b.fg|=536870912:E===7?b.fg|=67108864:E===8?b.fg|=1073741824:E===9?b.fg|=2147483648:E===2?b.bg|=134217728:E===21?this._processUnderline(2,b):E===22?(b.fg&=-134217729,b.bg&=-134217729):E===23?b.bg&=-67108865:E===24?(b.fg&=-268435457,this._processUnderline(0,b)):E===25?b.fg&=-536870913:E===27?b.fg&=-67108865:E===28?b.fg&=-1073741825:E===29?b.fg&=2147483647:E===39?(b.fg&=-67108864,b.fg|=16777215&t.DEFAULT_ATTR_DATA.fg):E===49?(b.bg&=-67108864,b.bg|=16777215&t.DEFAULT_ATTR_DATA.bg):E===38||E===48||E===58?A+=this._extractColor(S,A,b):E===53?b.bg|=1073741824:E===55?b.bg&=-1073741825:E===59?(b.extended=b.extended.clone(),b.extended.underlineColor=-1,b.updateExtended()):E===100?(b.fg&=-67108864,b.fg|=16777215&t.DEFAULT_ATTR_DATA.fg,b.bg&=-67108864,b.bg|=16777215&t.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",E);return!0}deviceStatus(S){switch(S.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const y=this._activeBuffer.y+1,E=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${y};${E}R`)}return!0}deviceStatusPrivate(S){if(S.params[0]===6){const y=this._activeBuffer.y+1,E=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${y};${E}R`)}return!0}softReset(S){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=t.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(S){const y=S.params[0]||1;switch(y){case 1:case 2:this._optionsService.options.cursorStyle="block";break;case 3:case 4:this._optionsService.options.cursorStyle="underline";break;case 5:case 6:this._optionsService.options.cursorStyle="bar"}const E=y%2==1;return this._optionsService.options.cursorBlink=E,!0}setScrollRegion(S){const y=S.params[0]||1;let E;return(S.length<2||(E=S.params[1])>this._bufferService.rows||E===0)&&(E=this._bufferService.rows),E>y&&(this._activeBuffer.scrollTop=y-1,this._activeBuffer.scrollBottom=E-1,this._setCursor(0,0)),!0}windowOptions(S){if(!L(S.params[0],this._optionsService.rawOptions.windowOptions))return!0;const y=S.length>1?S.params[1]:0;switch(S.params[0]){case 14:y!==2&&this._onRequestWindowsOptionsReport.fire(w.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(w.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:y!==0&&y!==2||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),y!==0&&y!==1||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:y!==0&&y!==2||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),y!==0&&y!==1||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(S){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(S){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(S){return this._windowTitle=S,this._onTitleChange.fire(S),!0}setIconName(S){return this._iconName=S,!0}setOrReportIndexedColor(S){const y=[],E=S.split(";");for(;E.length>1;){const b=E.shift(),A=E.shift();if(/^\d+$/.exec(b)){const O=parseInt(b);if(N(O))if(A==="?")y.push({type:0,index:O});else{const U=(0,m.parseColor)(A);U&&y.push({type:1,index:O,color:U})}}}return y.length&&this._onColor.fire(y),!0}setHyperlink(S){const y=S.split(";");return!(y.length<2)&&(y[1]?this._createHyperlink(y[0],y[1]):!y[0]&&this._finishHyperlink())}_createHyperlink(S,y){this._getCurrentLinkId()&&this._finishHyperlink();const E=S.split(":");let b;const A=E.findIndex((O=>O.startsWith("id=")));return A!==-1&&(b=E[A].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:b,uri:y}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(S,y){const E=S.split(";");for(let b=0;b=this._specialColors.length);++b,++y)if(E[b]==="?")this._onColor.fire([{type:0,index:this._specialColors[y]}]);else{const A=(0,m.parseColor)(E[b]);A&&this._onColor.fire([{type:1,index:this._specialColors[y],color:A}])}return!0}setOrReportFgColor(S){return this._setOrReportSpecialColor(S,0)}setOrReportBgColor(S){return this._setOrReportSpecialColor(S,1)}setOrReportCursorColor(S){return this._setOrReportSpecialColor(S,2)}restoreIndexedColor(S){if(!S)return this._onColor.fire([{type:2}]),!0;const y=[],E=S.split(";");for(let b=0;b=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const S=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,S,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=t.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=t.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(S){return this._charsetService.setgLevel(S),!0}screenAlignmentPattern(){const S=new i.CellData;S.content=4194373,S.fg=this._curAttrData.fg,S.bg=this._curAttrData.bg,this._setCursor(0,0);for(let y=0;y(this._coreService.triggerDataEvent(`${n.C0.ESC}${A}${n.C0.ESC}\\`),!0))(S==='"q'?`P1$r${this._curAttrData.isProtected()?1:0}"q`:S==='"p'?'P1$r61;1"p':S==="r"?`P1$r${E.scrollTop+1};${E.scrollBottom+1}r`:S==="m"?"P1$r0m":S===" q"?`P1$r${{block:2,underline:4,bar:6}[b.cursorStyle]-(b.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(S,y){this._dirtyRowTracker.markRangeDirty(S,y)}}r.InputHandler=P;let H=class{constructor(I){this._bufferService=I,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(I){Ithis.end&&(this.end=I)}markRangeDirty(I,S){I>S&&(x=I,I=S,S=x),Ithis.end&&(this.end=S)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function N(I){return 0<=I&&I<256}H=l([u(0,v.IBufferService)],H)},844:(T,r)=>{function o(l){for(const u of l)u.dispose();l.length=0}Object.defineProperty(r,"__esModule",{value:!0}),r.getDisposeArrayDisposable=r.disposeArray=r.toDisposable=r.MutableDisposable=r.Disposable=void 0,r.Disposable=class{constructor(){this._disposables=[],this._isDisposed=!1}dispose(){this._isDisposed=!0;for(const l of this._disposables)l.dispose();this._disposables.length=0}register(l){return this._disposables.push(l),l}unregister(l){const u=this._disposables.indexOf(l);u!==-1&&this._disposables.splice(u,1)}},r.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(l){this._isDisposed||l===this._value||(this._value?.dispose(),this._value=l)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}},r.toDisposable=function(l){return{dispose:l}},r.disposeArray=o,r.getDisposeArrayDisposable=function(l){return{dispose:()=>o(l)}}},1505:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.FourKeyMap=r.TwoKeyMap=void 0;class o{constructor(){this._data={}}set(u,n,d){this._data[u]||(this._data[u]={}),this._data[u][n]=d}get(u,n){return this._data[u]?this._data[u][n]:void 0}clear(){this._data={}}}r.TwoKeyMap=o,r.FourKeyMap=class{constructor(){this._data=new o}set(l,u,n,d,f){this._data.get(l,u)||this._data.set(l,u,new o),this._data.get(l,u).set(n,d,f)}get(l,u,n,d){return this._data.get(l,u)?.get(n,d)}clear(){this._data.clear()}}},6114:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.isChromeOS=r.isLinux=r.isWindows=r.isIphone=r.isIpad=r.isMac=r.getSafariVersion=r.isSafari=r.isLegacyEdge=r.isFirefox=r.isNode=void 0,r.isNode=typeof process<"u"&&"title"in process;const o=r.isNode?"node":navigator.userAgent,l=r.isNode?"node":navigator.platform;r.isFirefox=o.includes("Firefox"),r.isLegacyEdge=o.includes("Edge"),r.isSafari=/^((?!chrome|android).)*safari/i.test(o),r.getSafariVersion=function(){if(!r.isSafari)return 0;const u=o.match(/Version\/(\d+)/);return u===null||u.length<2?0:parseInt(u[1])},r.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(l),r.isIpad=l==="iPad",r.isIphone=l==="iPhone",r.isWindows=["Windows","Win16","Win32","WinCE"].includes(l),r.isLinux=l.indexOf("Linux")>=0,r.isChromeOS=/\bCrOS\b/.test(o)},6106:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.SortedList=void 0;let o=0;r.SortedList=class{constructor(l){this._getKey=l,this._array=[]}clear(){this._array.length=0}insert(l){this._array.length!==0?(o=this._search(this._getKey(l)),this._array.splice(o,0,l)):this._array.push(l)}delete(l){if(this._array.length===0)return!1;const u=this._getKey(l);if(u===void 0||(o=this._search(u),o===-1)||this._getKey(this._array[o])!==u)return!1;do if(this._array[o]===l)return this._array.splice(o,1),!0;while(++o=this._array.length)&&this._getKey(this._array[o])===l))do yield this._array[o];while(++o=this._array.length)&&this._getKey(this._array[o])===l))do u(this._array[o]);while(++o=u;){let d=u+n>>1;const f=this._getKey(this._array[d]);if(f>l)n=d-1;else{if(!(f0&&this._getKey(this._array[d-1])===l;)d--;return d}u=d+1}}return u}}},7226:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.DebouncedIdleTask=r.IdleTaskQueue=r.PriorityTaskQueue=void 0;const l=o(6114);class u{constructor(){this._tasks=[],this._i=0}enqueue(f){this._tasks.push(f),this._start()}flush(){for(;this._is)return t-p<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(t-p))}ms`),void this._start();t=s}this.clear()}}class n extends u{_requestCallback(f){return setTimeout((()=>f(this._createDeadline(16))))}_cancelCallback(f){clearTimeout(f)}_createDeadline(f){const p=Date.now()+f;return{timeRemaining:()=>Math.max(0,p-Date.now())}}}r.PriorityTaskQueue=n,r.IdleTaskQueue=!l.isNode&&"requestIdleCallback"in window?class extends u{_requestCallback(d){return requestIdleCallback(d)}_cancelCallback(d){cancelIdleCallback(d)}}:n,r.DebouncedIdleTask=class{constructor(){this._queue=new r.IdleTaskQueue}set(d){this._queue.clear(),this._queue.enqueue(d)}flush(){this._queue.flush()}}},9282:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.updateWindowsModeWrappedState=void 0;const l=o(643);r.updateWindowsModeWrappedState=function(u){const n=u.buffer.lines.get(u.buffer.ybase+u.buffer.y-1),d=n?.get(u.cols-1),f=u.buffer.lines.get(u.buffer.ybase+u.buffer.y);f&&d&&(f.isWrapped=d[l.CHAR_DATA_CODE_INDEX]!==l.NULL_CELL_CODE&&d[l.CHAR_DATA_CODE_INDEX]!==l.WHITESPACE_CELL_CODE)}},3734:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.ExtendedAttrs=r.AttributeData=void 0;class o{constructor(){this.fg=0,this.bg=0,this.extended=new l}static toColorRGB(n){return[n>>>16&255,n>>>8&255,255&n]}static fromColorRGB(n){return(255&n[0])<<16|(255&n[1])<<8|255&n[2]}clone(){const n=new o;return n.fg=this.fg,n.bg=this.bg,n.extended=this.extended.clone(),n}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return(50331648&this.fg)==50331648}isBgRGB(){return(50331648&this.bg)==50331648}isFgPalette(){return(50331648&this.fg)==16777216||(50331648&this.fg)==33554432}isBgPalette(){return(50331648&this.bg)==16777216||(50331648&this.bg)==33554432}isFgDefault(){return(50331648&this.fg)==0}isBgDefault(){return(50331648&this.bg)==0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?(50331648&this.extended.underlineColor)==50331648:this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?(50331648&this.extended.underlineColor)==16777216||(50331648&this.extended.underlineColor)==33554432:this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?(50331648&this.extended.underlineColor)==0:this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}r.AttributeData=o;class l{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(n){this._ext=n}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(n){this._ext&=-469762049,this._ext|=n<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(n){this._ext&=-67108864,this._ext|=67108863&n}get urlId(){return this._urlId}set urlId(n){this._urlId=n}get underlineVariantOffset(){const n=(3758096384&this._ext)>>29;return n<0?4294967288^n:n}set underlineVariantOffset(n){this._ext&=536870911,this._ext|=n<<29&3758096384}constructor(n=0,d=0){this._ext=0,this._urlId=0,this._ext=n,this._urlId=d}clone(){return new l(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}}r.ExtendedAttrs=l},9092:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.Buffer=r.MAX_BUFFER_SIZE=void 0;const l=o(6349),u=o(7226),n=o(3734),d=o(8437),f=o(4634),p=o(511),h=o(643),t=o(4863),s=o(7116);r.MAX_BUFFER_SIZE=4294967295,r.Buffer=class{constructor(e,i,a){this._hasScrollback=e,this._optionsService=i,this._bufferService=a,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=d.DEFAULT_ATTR_DATA.clone(),this.savedCharset=s.DEFAULT_CHARSET,this.markers=[],this._nullCell=p.CellData.fromCharData([0,h.NULL_CELL_CHAR,h.NULL_CELL_WIDTH,h.NULL_CELL_CODE]),this._whitespaceCell=p.CellData.fromCharData([0,h.WHITESPACE_CELL_CHAR,h.WHITESPACE_CELL_WIDTH,h.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new u.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new l.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,i){return new d.BufferLine(this._bufferService.cols,this.getNullCell(e),i)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&er.MAX_BUFFER_SIZE?r.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(this.lines.length===0){e===void 0&&(e=d.DEFAULT_ATTR_DATA);let i=this._rows;for(;i--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new l.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,i){const a=this.getNullCell(d.DEFAULT_ATTR_DATA);let v=0;const _=this._getCorrectBufferLength(i);if(_>this.lines.maxLength&&(this.lines.maxLength=_),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+g+1?(this.ybase--,g++,this.ydisp>0&&this.ydisp--):this.lines.push(new d.BufferLine(e,a)));else for(let c=this._rows;c>i;c--)this.lines.length>i+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(_0&&(this.lines.trimStart(c),this.ybase=Math.max(this.ybase-c,0),this.ydisp=Math.max(this.ydisp-c,0),this.savedY=Math.max(this.savedY-c,0)),this.lines.maxLength=_}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,i-1),g&&(this.y+=g),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=i-1,this._isReflowEnabled&&(this._reflow(e,i),this._cols>e))for(let g=0;g.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let i=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&e.backend==="conpty"&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,i){this._cols!==e&&(e>this._cols?this._reflowLarger(e,i):this._reflowSmaller(e,i))}_reflowLarger(e,i){const a=(0,f.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(d.DEFAULT_ATTR_DATA));if(a.length>0){const v=(0,f.reflowLargerCreateNewLayout)(this.lines,a);(0,f.reflowLargerApplyNewLayout)(this.lines,v.layout),this._reflowLargerAdjustViewport(e,i,v.countRemoved)}}_reflowLargerAdjustViewport(e,i,a){const v=this.getNullCell(d.DEFAULT_ATTR_DATA);let _=a;for(;_-- >0;)this.ybase===0?(this.y>0&&this.y--,this.lines.length=0;g--){let c=this.lines.get(g);if(!c||!c.isWrapped&&c.getTrimmedLength()<=e)continue;const m=[c];for(;c.isWrapped&&g>0;)c=this.lines.get(--g),m.unshift(c);const k=this.ybase+this.y;if(k>=g&&k0&&(v.push({start:g+m.length+_,newLines:P}),_+=P.length),m.push(...P);let H=L.length-1,N=L[H];N===0&&(H--,N=L[H]);let I=m.length-w-1,S=D;for(;I>=0;){const E=Math.min(S,N);if(m[H]===void 0)break;if(m[H].copyCellsFrom(m[I],S-E,N-E,E,!0),N-=E,N===0&&(H--,N=L[H]),S-=E,S===0){I--;const b=Math.max(I,0);S=(0,f.getWrappedLineTrimmedLength)(m,b,this._cols)}}for(let E=0;E0;)this.ybase===0?this.y0){const g=[],c=[];for(let H=0;H=0;H--)if(L&&L.start>k+w){for(let N=L.newLines.length-1;N>=0;N--)this.lines.set(H--,L.newLines[N]);H++,g.push({index:k+1,amount:L.newLines.length}),w+=L.newLines.length,L=v[++D]}else this.lines.set(H,c[k--]);let x=0;for(let H=g.length-1;H>=0;H--)g[H].index+=x,this.lines.onInsertEmitter.fire(g[H]),x+=g[H].amount;const P=Math.max(0,m+_-this.lines.maxLength);P>0&&this.lines.onTrimEmitter.fire(P)}}translateBufferLineToString(e,i,a=0,v){const _=this.lines.get(e);return _?_.translateToString(i,a,v):""}getWrappedRangeForLine(e){let i=e,a=e;for(;i>0&&this.lines.get(i).isWrapped;)i--;for(;a+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(e==null&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let i=0;i{i.line-=a,i.line<0&&i.dispose()}))),i.register(this.lines.onInsert((a=>{i.line>=a.index&&(i.line+=a.amount)}))),i.register(this.lines.onDelete((a=>{i.line>=a.index&&i.linea.index&&(i.line-=a.amount)}))),i.register(i.onDispose((()=>this._removeMarker(i)))),i}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},8437:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.BufferLine=r.DEFAULT_ATTR_DATA=void 0;const l=o(3734),u=o(511),n=o(643),d=o(482);r.DEFAULT_ATTR_DATA=Object.freeze(new l.AttributeData);let f=0;class p{constructor(t,s,e=!1){this.isWrapped=e,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*t);const i=s||u.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let a=0;a>22,2097152&s?this._combined[t].charCodeAt(this._combined[t].length-1):e]}set(t,s){this._data[3*t+1]=s[n.CHAR_DATA_ATTR_INDEX],s[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[t]=s[1],this._data[3*t+0]=2097152|t|s[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*t+0]=s[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|s[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(t){return this._data[3*t+0]>>22}hasWidth(t){return 12582912&this._data[3*t+0]}getFg(t){return this._data[3*t+1]}getBg(t){return this._data[3*t+2]}hasContent(t){return 4194303&this._data[3*t+0]}getCodePoint(t){const s=this._data[3*t+0];return 2097152&s?this._combined[t].charCodeAt(this._combined[t].length-1):2097151&s}isCombined(t){return 2097152&this._data[3*t+0]}getString(t){const s=this._data[3*t+0];return 2097152&s?this._combined[t]:2097151&s?(0,d.stringFromCodePoint)(2097151&s):""}isProtected(t){return 536870912&this._data[3*t+2]}loadCell(t,s){return f=3*t,s.content=this._data[f+0],s.fg=this._data[f+1],s.bg=this._data[f+2],2097152&s.content&&(s.combinedData=this._combined[t]),268435456&s.bg&&(s.extended=this._extendedAttrs[t]),s}setCell(t,s){2097152&s.content&&(this._combined[t]=s.combinedData),268435456&s.bg&&(this._extendedAttrs[t]=s.extended),this._data[3*t+0]=s.content,this._data[3*t+1]=s.fg,this._data[3*t+2]=s.bg}setCellFromCodepoint(t,s,e,i){268435456&i.bg&&(this._extendedAttrs[t]=i.extended),this._data[3*t+0]=s|e<<22,this._data[3*t+1]=i.fg,this._data[3*t+2]=i.bg}addCodepointToCell(t,s,e){let i=this._data[3*t+0];2097152&i?this._combined[t]+=(0,d.stringFromCodePoint)(s):2097151&i?(this._combined[t]=(0,d.stringFromCodePoint)(2097151&i)+(0,d.stringFromCodePoint)(s),i&=-2097152,i|=2097152):i=s|4194304,e&&(i&=-12582913,i|=e<<22),this._data[3*t+0]=i}insertCells(t,s,e){if((t%=this.length)&&this.getWidth(t-1)===2&&this.setCellFromCodepoint(t-1,0,1,e),s=0;--a)this.setCell(t+s+a,this.loadCell(t+a,i));for(let a=0;athis.length){if(this._data.buffer.byteLength>=4*e)this._data=new Uint32Array(this._data.buffer,0,e);else{const i=new Uint32Array(e);i.set(this._data),this._data=i}for(let i=this.length;i=t&&delete this._combined[_]}const a=Object.keys(this._extendedAttrs);for(let v=0;v=t&&delete this._extendedAttrs[_]}}return this.length=t,4*e*2=0;--t)if(4194303&this._data[3*t+0])return t+(this._data[3*t+0]>>22);return 0}getNoBgTrimmedLength(){for(let t=this.length-1;t>=0;--t)if(4194303&this._data[3*t+0]||50331648&this._data[3*t+2])return t+(this._data[3*t+0]>>22);return 0}copyCellsFrom(t,s,e,i,a){const v=t._data;if(a)for(let g=i-1;g>=0;g--){for(let c=0;c<3;c++)this._data[3*(e+g)+c]=v[3*(s+g)+c];268435456&v[3*(s+g)+2]&&(this._extendedAttrs[e+g]=t._extendedAttrs[s+g])}else for(let g=0;g=s&&(this._combined[c-s+e]=t._combined[c])}}translateToString(t,s,e,i){s=s??0,e=e??this.length,t&&(e=Math.min(e,this.getTrimmedLength())),i&&(i.length=0);let a="";for(;s>22||1}return i&&i.push(s),a}}r.BufferLine=p},4841:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.getRangeLength=void 0,r.getRangeLength=function(o,l){if(o.start.y>o.end.y)throw new Error(`Buffer range end (${o.end.x}, ${o.end.y}) cannot be before start (${o.start.x}, ${o.start.y})`);return l*(o.end.y-o.start.y)+(o.end.x-o.start.x+1)}},4634:(T,r)=>{function o(l,u,n){if(u===l.length-1)return l[u].getTrimmedLength();const d=!l[u].hasContent(n-1)&&l[u].getWidth(n-1)===1,f=l[u+1].getWidth(0)===2;return d&&f?n-1:n}Object.defineProperty(r,"__esModule",{value:!0}),r.getWrappedLineTrimmedLength=r.reflowSmallerGetNewLineLengths=r.reflowLargerApplyNewLayout=r.reflowLargerCreateNewLayout=r.reflowLargerGetLinesToRemove=void 0,r.reflowLargerGetLinesToRemove=function(l,u,n,d,f){const p=[];for(let h=0;h=h&&d0&&(c>i||e[c].getTrimmedLength()===0);c--)g++;g>0&&(p.push(h+e.length-g),p.push(g)),h+=e.length-1}return p},r.reflowLargerCreateNewLayout=function(l,u){const n=[];let d=0,f=u[d],p=0;for(let h=0;ho(l,e,u))).reduce(((s,e)=>s+e));let p=0,h=0,t=0;for(;ts&&(p-=s,h++);const e=l[h].getWidth(p-1)===2;e&&p--;const i=e?n-1:n;d.push(i),t+=i}return d},r.getWrappedLineTrimmedLength=o},5295:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.BufferSet=void 0;const l=o(8460),u=o(844),n=o(9092);class d extends u.Disposable{constructor(p,h){super(),this._optionsService=p,this._bufferService=h,this._onBufferActivate=this.register(new l.EventEmitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this.register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new n.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new n.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(p){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(p),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(p,h){this._normal.resize(p,h),this._alt.resize(p,h),this.setupTabStops(p)}setupTabStops(p){this._normal.setupTabStops(p),this._alt.setupTabStops(p)}}r.BufferSet=d},511:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.CellData=void 0;const l=o(482),u=o(643),n=o(3734);class d extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(p){const h=new d;return h.setFromCharData(p),h}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,l.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(p){this.fg=p[u.CHAR_DATA_ATTR_INDEX],this.bg=0;let h=!1;if(p[u.CHAR_DATA_CHAR_INDEX].length>2)h=!0;else if(p[u.CHAR_DATA_CHAR_INDEX].length===2){const t=p[u.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=t&&t<=56319){const s=p[u.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(t-55296)+s-56320+65536|p[u.CHAR_DATA_WIDTH_INDEX]<<22:h=!0}else h=!0}else this.content=p[u.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|p[u.CHAR_DATA_WIDTH_INDEX]<<22;h&&(this.combinedData=p[u.CHAR_DATA_CHAR_INDEX],this.content=2097152|p[u.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}r.CellData=d},643:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.WHITESPACE_CELL_CODE=r.WHITESPACE_CELL_WIDTH=r.WHITESPACE_CELL_CHAR=r.NULL_CELL_CODE=r.NULL_CELL_WIDTH=r.NULL_CELL_CHAR=r.CHAR_DATA_CODE_INDEX=r.CHAR_DATA_WIDTH_INDEX=r.CHAR_DATA_CHAR_INDEX=r.CHAR_DATA_ATTR_INDEX=r.DEFAULT_EXT=r.DEFAULT_ATTR=r.DEFAULT_COLOR=void 0,r.DEFAULT_COLOR=0,r.DEFAULT_ATTR=256|r.DEFAULT_COLOR<<9,r.DEFAULT_EXT=0,r.CHAR_DATA_ATTR_INDEX=0,r.CHAR_DATA_CHAR_INDEX=1,r.CHAR_DATA_WIDTH_INDEX=2,r.CHAR_DATA_CODE_INDEX=3,r.NULL_CELL_CHAR="",r.NULL_CELL_WIDTH=1,r.NULL_CELL_CODE=0,r.WHITESPACE_CELL_CHAR=" ",r.WHITESPACE_CELL_WIDTH=1,r.WHITESPACE_CELL_CODE=32},4863:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.Marker=void 0;const l=o(8460),u=o(844);class n{get id(){return this._id}constructor(f){this.line=f,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new l.EventEmitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,u.disposeArray)(this._disposables),this._disposables.length=0)}register(f){return this._disposables.push(f),f}}r.Marker=n,n._nextId=1},7116:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.DEFAULT_CHARSET=r.CHARSETS=void 0,r.CHARSETS={},r.DEFAULT_CHARSET=r.CHARSETS.B,r.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},r.CHARSETS.A={"#":"£"},r.CHARSETS.B=void 0,r.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},r.CHARSETS.C=r.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},r.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},r.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},r.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},r.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},r.CHARSETS.E=r.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},r.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},r.CHARSETS.H=r.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},r.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},2584:(T,r)=>{var o,l,u;Object.defineProperty(r,"__esModule",{value:!0}),r.C1_ESCAPED=r.C1=r.C0=void 0,(function(n){n.NUL="\0",n.SOH="",n.STX="",n.ETX="",n.EOT="",n.ENQ="",n.ACK="",n.BEL="\x07",n.BS="\b",n.HT=" ",n.LF=` -`,n.VT="\v",n.FF="\f",n.CR="\r",n.SO="",n.SI="",n.DLE="",n.DC1="",n.DC2="",n.DC3="",n.DC4="",n.NAK="",n.SYN="",n.ETB="",n.CAN="",n.EM="",n.SUB="",n.ESC="\x1B",n.FS="",n.GS="",n.RS="",n.US="",n.SP=" ",n.DEL=""})(o||(r.C0=o={})),(function(n){n.PAD="€",n.HOP="",n.BPH="‚",n.NBH="ƒ",n.IND="„",n.NEL="…",n.SSA="†",n.ESA="‡",n.HTS="ˆ",n.HTJ="‰",n.VTS="Š",n.PLD="‹",n.PLU="Œ",n.RI="",n.SS2="Ž",n.SS3="",n.DCS="",n.PU1="‘",n.PU2="’",n.STS="“",n.CCH="”",n.MW="•",n.SPA="–",n.EPA="—",n.SOS="˜",n.SGCI="™",n.SCI="š",n.CSI="›",n.ST="œ",n.OSC="",n.PM="ž",n.APC="Ÿ"})(l||(r.C1=l={})),(function(n){n.ST=`${o.ESC}\\`})(u||(r.C1_ESCAPED=u={}))},7399:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.evaluateKeyboardEvent=void 0;const l=o(2584),u={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};r.evaluateKeyboardEvent=function(n,d,f,p){const h={type:0,cancel:!1,key:void 0},t=(n.shiftKey?1:0)|(n.altKey?2:0)|(n.ctrlKey?4:0)|(n.metaKey?8:0);switch(n.keyCode){case 0:n.key==="UIKeyInputUpArrow"?h.key=d?l.C0.ESC+"OA":l.C0.ESC+"[A":n.key==="UIKeyInputLeftArrow"?h.key=d?l.C0.ESC+"OD":l.C0.ESC+"[D":n.key==="UIKeyInputRightArrow"?h.key=d?l.C0.ESC+"OC":l.C0.ESC+"[C":n.key==="UIKeyInputDownArrow"&&(h.key=d?l.C0.ESC+"OB":l.C0.ESC+"[B");break;case 8:h.key=n.ctrlKey?"\b":l.C0.DEL,n.altKey&&(h.key=l.C0.ESC+h.key);break;case 9:if(n.shiftKey){h.key=l.C0.ESC+"[Z";break}h.key=l.C0.HT,h.cancel=!0;break;case 13:h.key=n.altKey?l.C0.ESC+l.C0.CR:l.C0.CR,h.cancel=!0;break;case 27:h.key=l.C0.ESC,n.altKey&&(h.key=l.C0.ESC+l.C0.ESC),h.cancel=!0;break;case 37:if(n.metaKey)break;t?(h.key=l.C0.ESC+"[1;"+(t+1)+"D",h.key===l.C0.ESC+"[1;3D"&&(h.key=l.C0.ESC+(f?"b":"[1;5D"))):h.key=d?l.C0.ESC+"OD":l.C0.ESC+"[D";break;case 39:if(n.metaKey)break;t?(h.key=l.C0.ESC+"[1;"+(t+1)+"C",h.key===l.C0.ESC+"[1;3C"&&(h.key=l.C0.ESC+(f?"f":"[1;5C"))):h.key=d?l.C0.ESC+"OC":l.C0.ESC+"[C";break;case 38:if(n.metaKey)break;t?(h.key=l.C0.ESC+"[1;"+(t+1)+"A",f||h.key!==l.C0.ESC+"[1;3A"||(h.key=l.C0.ESC+"[1;5A")):h.key=d?l.C0.ESC+"OA":l.C0.ESC+"[A";break;case 40:if(n.metaKey)break;t?(h.key=l.C0.ESC+"[1;"+(t+1)+"B",f||h.key!==l.C0.ESC+"[1;3B"||(h.key=l.C0.ESC+"[1;5B")):h.key=d?l.C0.ESC+"OB":l.C0.ESC+"[B";break;case 45:n.shiftKey||n.ctrlKey||(h.key=l.C0.ESC+"[2~");break;case 46:h.key=t?l.C0.ESC+"[3;"+(t+1)+"~":l.C0.ESC+"[3~";break;case 36:h.key=t?l.C0.ESC+"[1;"+(t+1)+"H":d?l.C0.ESC+"OH":l.C0.ESC+"[H";break;case 35:h.key=t?l.C0.ESC+"[1;"+(t+1)+"F":d?l.C0.ESC+"OF":l.C0.ESC+"[F";break;case 33:n.shiftKey?h.type=2:n.ctrlKey?h.key=l.C0.ESC+"[5;"+(t+1)+"~":h.key=l.C0.ESC+"[5~";break;case 34:n.shiftKey?h.type=3:n.ctrlKey?h.key=l.C0.ESC+"[6;"+(t+1)+"~":h.key=l.C0.ESC+"[6~";break;case 112:h.key=t?l.C0.ESC+"[1;"+(t+1)+"P":l.C0.ESC+"OP";break;case 113:h.key=t?l.C0.ESC+"[1;"+(t+1)+"Q":l.C0.ESC+"OQ";break;case 114:h.key=t?l.C0.ESC+"[1;"+(t+1)+"R":l.C0.ESC+"OR";break;case 115:h.key=t?l.C0.ESC+"[1;"+(t+1)+"S":l.C0.ESC+"OS";break;case 116:h.key=t?l.C0.ESC+"[15;"+(t+1)+"~":l.C0.ESC+"[15~";break;case 117:h.key=t?l.C0.ESC+"[17;"+(t+1)+"~":l.C0.ESC+"[17~";break;case 118:h.key=t?l.C0.ESC+"[18;"+(t+1)+"~":l.C0.ESC+"[18~";break;case 119:h.key=t?l.C0.ESC+"[19;"+(t+1)+"~":l.C0.ESC+"[19~";break;case 120:h.key=t?l.C0.ESC+"[20;"+(t+1)+"~":l.C0.ESC+"[20~";break;case 121:h.key=t?l.C0.ESC+"[21;"+(t+1)+"~":l.C0.ESC+"[21~";break;case 122:h.key=t?l.C0.ESC+"[23;"+(t+1)+"~":l.C0.ESC+"[23~";break;case 123:h.key=t?l.C0.ESC+"[24;"+(t+1)+"~":l.C0.ESC+"[24~";break;default:if(!n.ctrlKey||n.shiftKey||n.altKey||n.metaKey)if(f&&!p||!n.altKey||n.metaKey)!f||n.altKey||n.ctrlKey||n.shiftKey||!n.metaKey?n.key&&!n.ctrlKey&&!n.altKey&&!n.metaKey&&n.keyCode>=48&&n.key.length===1?h.key=n.key:n.key&&n.ctrlKey&&(n.key==="_"&&(h.key=l.C0.US),n.key==="@"&&(h.key=l.C0.NUL)):n.keyCode===65&&(h.type=1);else{const s=u[n.keyCode],e=s?.[n.shiftKey?1:0];if(e)h.key=l.C0.ESC+e;else if(n.keyCode>=65&&n.keyCode<=90){const i=n.ctrlKey?n.keyCode-64:n.keyCode+32;let a=String.fromCharCode(i);n.shiftKey&&(a=a.toUpperCase()),h.key=l.C0.ESC+a}else if(n.keyCode===32)h.key=l.C0.ESC+(n.ctrlKey?l.C0.NUL:" ");else if(n.key==="Dead"&&n.code.startsWith("Key")){let i=n.code.slice(3,4);n.shiftKey||(i=i.toLowerCase()),h.key=l.C0.ESC+i,h.cancel=!0}}else n.keyCode>=65&&n.keyCode<=90?h.key=String.fromCharCode(n.keyCode-64):n.keyCode===32?h.key=l.C0.NUL:n.keyCode>=51&&n.keyCode<=55?h.key=String.fromCharCode(n.keyCode-51+27):n.keyCode===56?h.key=l.C0.DEL:n.keyCode===219?h.key=l.C0.ESC:n.keyCode===220?h.key=l.C0.FS:n.keyCode===221&&(h.key=l.C0.GS)}return h}},482:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.Utf8ToUtf32=r.StringToUtf32=r.utf32ToString=r.stringFromCodePoint=void 0,r.stringFromCodePoint=function(o){return o>65535?(o-=65536,String.fromCharCode(55296+(o>>10))+String.fromCharCode(o%1024+56320)):String.fromCharCode(o)},r.utf32ToString=function(o,l=0,u=o.length){let n="";for(let d=l;d65535?(f-=65536,n+=String.fromCharCode(55296+(f>>10))+String.fromCharCode(f%1024+56320)):n+=String.fromCharCode(f)}return n},r.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(o,l){const u=o.length;if(!u)return 0;let n=0,d=0;if(this._interim){const f=o.charCodeAt(d++);56320<=f&&f<=57343?l[n++]=1024*(this._interim-55296)+f-56320+65536:(l[n++]=this._interim,l[n++]=f),this._interim=0}for(let f=d;f=u)return this._interim=p,n;const h=o.charCodeAt(f);56320<=h&&h<=57343?l[n++]=1024*(p-55296)+h-56320+65536:(l[n++]=p,l[n++]=h)}else p!==65279&&(l[n++]=p)}return n}},r.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(o,l){const u=o.length;if(!u)return 0;let n,d,f,p,h=0,t=0,s=0;if(this.interim[0]){let a=!1,v=this.interim[0];v&=(224&v)==192?31:(240&v)==224?15:7;let _,g=0;for(;(_=63&this.interim[++g])&&g<4;)v<<=6,v|=_;const c=(224&this.interim[0])==192?2:(240&this.interim[0])==224?3:4,m=c-g;for(;s=u)return 0;if(_=o[s++],(192&_)!=128){s--,a=!0;break}this.interim[g++]=_,v<<=6,v|=63&_}a||(c===2?v<128?s--:l[h++]=v:c===3?v<2048||v>=55296&&v<=57343||v===65279||(l[h++]=v):v<65536||v>1114111||(l[h++]=v)),this.interim.fill(0)}const e=u-4;let i=s;for(;i=u)return this.interim[0]=n,h;if(d=o[i++],(192&d)!=128){i--;continue}if(t=(31&n)<<6|63&d,t<128){i--;continue}l[h++]=t}else if((240&n)==224){if(i>=u)return this.interim[0]=n,h;if(d=o[i++],(192&d)!=128){i--;continue}if(i>=u)return this.interim[0]=n,this.interim[1]=d,h;if(f=o[i++],(192&f)!=128){i--;continue}if(t=(15&n)<<12|(63&d)<<6|63&f,t<2048||t>=55296&&t<=57343||t===65279)continue;l[h++]=t}else if((248&n)==240){if(i>=u)return this.interim[0]=n,h;if(d=o[i++],(192&d)!=128){i--;continue}if(i>=u)return this.interim[0]=n,this.interim[1]=d,h;if(f=o[i++],(192&f)!=128){i--;continue}if(i>=u)return this.interim[0]=n,this.interim[1]=d,this.interim[2]=f,h;if(p=o[i++],(192&p)!=128){i--;continue}if(t=(7&n)<<18|(63&d)<<12|(63&f)<<6|63&p,t<65536||t>1114111)continue;l[h++]=t}}return h}}},225:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.UnicodeV6=void 0;const l=o(1480),u=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],n=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let d;r.UnicodeV6=class{constructor(){if(this.version="6",!d){d=new Uint8Array(65536),d.fill(1),d[0]=0,d.fill(0,1,32),d.fill(0,127,160),d.fill(2,4352,4448),d[9001]=2,d[9002]=2,d.fill(2,11904,42192),d[12351]=1,d.fill(2,44032,55204),d.fill(2,63744,64256),d.fill(2,65040,65050),d.fill(2,65072,65136),d.fill(2,65280,65377),d.fill(2,65504,65511);for(let f=0;fh[e][1])return!1;for(;e>=s;)if(t=s+e>>1,p>h[t][1])s=t+1;else{if(!(p=131072&&f<=196605||f>=196608&&f<=262141?2:1}charProperties(f,p){let h=this.wcwidth(f),t=h===0&&p!==0;if(t){const s=l.UnicodeService.extractWidth(p);s===0?t=!1:s>h&&(h=s)}return l.UnicodeService.createPropertyValue(0,h,t)}}},5981:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.WriteBuffer=void 0;const l=o(8460),u=o(844);class n extends u.Disposable{constructor(f){super(),this._action=f,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this.register(new l.EventEmitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(f,p){if(p!==void 0&&this._syncCalls>p)return void(this._syncCalls=0);if(this._pendingData+=f.length,this._writeBuffer.push(f),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let h;for(this._isSyncWriting=!0;h=this._writeBuffer.shift();){this._action(h);const t=this._callbacks.shift();t&&t()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(f,p){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=f.length,this._writeBuffer.push(f),this._callbacks.push(p),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=f.length,this._writeBuffer.push(f),this._callbacks.push(p)}_innerWrite(f=0,p=!0){const h=f||Date.now();for(;this._writeBuffer.length>this._bufferOffset;){const t=this._writeBuffer[this._bufferOffset],s=this._action(t,p);if(s){const i=a=>Date.now()-h>=12?setTimeout((()=>this._innerWrite(0,a))):this._innerWrite(h,a);return void s.catch((a=>(queueMicrotask((()=>{throw a})),Promise.resolve(!1)))).then(i)}const e=this._callbacks[this._bufferOffset];if(e&&e(),this._bufferOffset++,this._pendingData-=t.length,Date.now()-h>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}r.WriteBuffer=n},5941:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.toRgbString=r.parseColor=void 0;const o=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,l=/^[\da-f]+$/;function u(n,d){const f=n.toString(16),p=f.length<2?"0"+f:f;switch(d){case 4:return f[0];case 8:return p;case 12:return(p+p).slice(0,3);default:return p+p}}r.parseColor=function(n){if(!n)return;let d=n.toLowerCase();if(d.indexOf("rgb:")===0){d=d.slice(4);const f=o.exec(d);if(f){const p=f[1]?15:f[4]?255:f[7]?4095:65535;return[Math.round(parseInt(f[1]||f[4]||f[7]||f[10],16)/p*255),Math.round(parseInt(f[2]||f[5]||f[8]||f[11],16)/p*255),Math.round(parseInt(f[3]||f[6]||f[9]||f[12],16)/p*255)]}}else if(d.indexOf("#")===0&&(d=d.slice(1),l.exec(d)&&[3,6,9,12].includes(d.length))){const f=d.length/3,p=[0,0,0];for(let h=0;h<3;++h){const t=parseInt(d.slice(f*h,f*h+f),16);p[h]=f===1?t<<4:f===2?t:f===3?t>>4:t>>8}return p}},r.toRgbString=function(n,d=16){const[f,p,h]=n;return`rgb:${u(f,d)}/${u(p,d)}/${u(h,d)}`}},5770:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.PAYLOAD_LIMIT=void 0,r.PAYLOAD_LIMIT=1e7},6351:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.DcsHandler=r.DcsParser=void 0;const l=o(482),u=o(8742),n=o(5770),d=[];r.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=d,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=d}registerHandler(p,h){this._handlers[p]===void 0&&(this._handlers[p]=[]);const t=this._handlers[p];return t.push(h),{dispose:()=>{const s=t.indexOf(h);s!==-1&&t.splice(s,1)}}}clearHandler(p){this._handlers[p]&&delete this._handlers[p]}setHandlerFallback(p){this._handlerFb=p}reset(){if(this._active.length)for(let p=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;p>=0;--p)this._active[p].unhook(!1);this._stack.paused=!1,this._active=d,this._ident=0}hook(p,h){if(this.reset(),this._ident=p,this._active=this._handlers[p]||d,this._active.length)for(let t=this._active.length-1;t>=0;t--)this._active[t].hook(h);else this._handlerFb(this._ident,"HOOK",h)}put(p,h,t){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(p,h,t);else this._handlerFb(this._ident,"PUT",(0,l.utf32ToString)(p,h,t))}unhook(p,h=!0){if(this._active.length){let t=!1,s=this._active.length-1,e=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,t=h,e=this._stack.fallThrough,this._stack.paused=!1),!e&&t===!1){for(;s>=0&&(t=this._active[s].unhook(p),t!==!0);s--)if(t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,t;s--}for(;s>=0;s--)if(t=this._active[s].unhook(!1),t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,t}else this._handlerFb(this._ident,"UNHOOK",p);this._active=d,this._ident=0}};const f=new u.Params;f.addParam(0),r.DcsHandler=class{constructor(p){this._handler=p,this._data="",this._params=f,this._hitLimit=!1}hook(p){this._params=p.length>1||p.params[0]?p.clone():f,this._data="",this._hitLimit=!1}put(p,h,t){this._hitLimit||(this._data+=(0,l.utf32ToString)(p,h,t),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(p){let h=!1;if(this._hitLimit)h=!1;else if(p&&(h=this._handler(this._data,this._params),h instanceof Promise))return h.then((t=>(this._params=f,this._data="",this._hitLimit=!1,t)));return this._params=f,this._data="",this._hitLimit=!1,h}}},2015:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.EscapeSequenceParser=r.VT500_TRANSITION_TABLE=r.TransitionTable=void 0;const l=o(844),u=o(8742),n=o(6242),d=o(6351);class f{constructor(s){this.table=new Uint8Array(s)}setDefault(s,e){this.table.fill(s<<4|e)}add(s,e,i,a){this.table[e<<8|s]=i<<4|a}addMany(s,e,i,a){for(let v=0;vc)),e=(g,c)=>s.slice(g,c),i=e(32,127),a=e(0,24);a.push(25),a.push.apply(a,e(28,32));const v=e(0,14);let _;for(_ in t.setDefault(1,0),t.addMany(i,0,2,0),v)t.addMany([24,26,153,154],_,3,0),t.addMany(e(128,144),_,3,0),t.addMany(e(144,152),_,3,0),t.add(156,_,0,0),t.add(27,_,11,1),t.add(157,_,4,8),t.addMany([152,158,159],_,0,7),t.add(155,_,11,3),t.add(144,_,11,9);return t.addMany(a,0,3,0),t.addMany(a,1,3,1),t.add(127,1,0,1),t.addMany(a,8,0,8),t.addMany(a,3,3,3),t.add(127,3,0,3),t.addMany(a,4,3,4),t.add(127,4,0,4),t.addMany(a,6,3,6),t.addMany(a,5,3,5),t.add(127,5,0,5),t.addMany(a,2,3,2),t.add(127,2,0,2),t.add(93,1,4,8),t.addMany(i,8,5,8),t.add(127,8,5,8),t.addMany([156,27,24,26,7],8,6,0),t.addMany(e(28,32),8,0,8),t.addMany([88,94,95],1,0,7),t.addMany(i,7,0,7),t.addMany(a,7,0,7),t.add(156,7,0,0),t.add(127,7,0,7),t.add(91,1,11,3),t.addMany(e(64,127),3,7,0),t.addMany(e(48,60),3,8,4),t.addMany([60,61,62,63],3,9,4),t.addMany(e(48,60),4,8,4),t.addMany(e(64,127),4,7,0),t.addMany([60,61,62,63],4,0,6),t.addMany(e(32,64),6,0,6),t.add(127,6,0,6),t.addMany(e(64,127),6,0,0),t.addMany(e(32,48),3,9,5),t.addMany(e(32,48),5,9,5),t.addMany(e(48,64),5,0,6),t.addMany(e(64,127),5,7,0),t.addMany(e(32,48),4,9,5),t.addMany(e(32,48),1,9,2),t.addMany(e(32,48),2,9,2),t.addMany(e(48,127),2,10,0),t.addMany(e(48,80),1,10,0),t.addMany(e(81,88),1,10,0),t.addMany([89,90,92],1,10,0),t.addMany(e(96,127),1,10,0),t.add(80,1,11,9),t.addMany(a,9,0,9),t.add(127,9,0,9),t.addMany(e(28,32),9,0,9),t.addMany(e(32,48),9,9,12),t.addMany(e(48,60),9,8,10),t.addMany([60,61,62,63],9,9,10),t.addMany(a,11,0,11),t.addMany(e(32,128),11,0,11),t.addMany(e(28,32),11,0,11),t.addMany(a,10,0,10),t.add(127,10,0,10),t.addMany(e(28,32),10,0,10),t.addMany(e(48,60),10,8,10),t.addMany([60,61,62,63],10,0,11),t.addMany(e(32,48),10,9,12),t.addMany(a,12,0,12),t.add(127,12,0,12),t.addMany(e(28,32),12,0,12),t.addMany(e(32,48),12,9,12),t.addMany(e(48,64),12,0,11),t.addMany(e(64,127),12,12,13),t.addMany(e(64,127),10,12,13),t.addMany(e(64,127),9,12,13),t.addMany(a,13,13,13),t.addMany(i,13,13,13),t.add(127,13,0,13),t.addMany([27,156,24,26],13,14,0),t.add(p,0,2,0),t.add(p,8,5,8),t.add(p,6,0,6),t.add(p,11,0,11),t.add(p,13,13,13),t})();class h extends l.Disposable{constructor(s=r.VT500_TRANSITION_TABLE){super(),this._transitions=s,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new u.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,i,a)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,i)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this.register((0,l.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this.register(new n.OscParser),this._dcsParser=this.register(new d.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(s,e=[64,126]){let i=0;if(s.prefix){if(s.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=s.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(s.intermediates){if(s.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let v=0;v_||_>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=_}}if(s.final.length!==1)throw new Error("final must be a single byte");const a=s.final.charCodeAt(0);if(e[0]>a||a>e[1])throw new Error(`final must be in range ${e[0]} .. ${e[1]}`);return i<<=8,i|=a,i}identToString(s){const e=[];for(;s;)e.push(String.fromCharCode(255&s)),s>>=8;return e.reverse().join("")}setPrintHandler(s){this._printHandler=s}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(s,e){const i=this._identifier(s,[48,126]);this._escHandlers[i]===void 0&&(this._escHandlers[i]=[]);const a=this._escHandlers[i];return a.push(e),{dispose:()=>{const v=a.indexOf(e);v!==-1&&a.splice(v,1)}}}clearEscHandler(s){this._escHandlers[this._identifier(s,[48,126])]&&delete this._escHandlers[this._identifier(s,[48,126])]}setEscHandlerFallback(s){this._escHandlerFb=s}setExecuteHandler(s,e){this._executeHandlers[s.charCodeAt(0)]=e}clearExecuteHandler(s){this._executeHandlers[s.charCodeAt(0)]&&delete this._executeHandlers[s.charCodeAt(0)]}setExecuteHandlerFallback(s){this._executeHandlerFb=s}registerCsiHandler(s,e){const i=this._identifier(s);this._csiHandlers[i]===void 0&&(this._csiHandlers[i]=[]);const a=this._csiHandlers[i];return a.push(e),{dispose:()=>{const v=a.indexOf(e);v!==-1&&a.splice(v,1)}}}clearCsiHandler(s){this._csiHandlers[this._identifier(s)]&&delete this._csiHandlers[this._identifier(s)]}setCsiHandlerFallback(s){this._csiHandlerFb=s}registerDcsHandler(s,e){return this._dcsParser.registerHandler(this._identifier(s),e)}clearDcsHandler(s){this._dcsParser.clearHandler(this._identifier(s))}setDcsHandlerFallback(s){this._dcsParser.setHandlerFallback(s)}registerOscHandler(s,e){return this._oscParser.registerHandler(s,e)}clearOscHandler(s){this._oscParser.clearHandler(s)}setOscHandlerFallback(s){this._oscParser.setHandlerFallback(s)}setErrorHandler(s){this._errorHandler=s}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._parseStack.state!==0&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(s,e,i,a,v){this._parseStack.state=s,this._parseStack.handlers=e,this._parseStack.handlerPos=i,this._parseStack.transition=a,this._parseStack.chunkPos=v}parse(s,e,i){let a,v=0,_=0,g=0;if(this._parseStack.state)if(this._parseStack.state===2)this._parseStack.state=0,g=this._parseStack.chunkPos+1;else{if(i===void 0||this._parseStack.state===1)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const c=this._parseStack.handlers;let m=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(i===!1&&m>-1){for(;m>=0&&(a=c[m](this._params),a!==!0);m--)if(a instanceof Promise)return this._parseStack.handlerPos=m,a}this._parseStack.handlers=[];break;case 4:if(i===!1&&m>-1){for(;m>=0&&(a=c[m](),a!==!0);m--)if(a instanceof Promise)return this._parseStack.handlerPos=m,a}this._parseStack.handlers=[];break;case 6:if(v=s[this._parseStack.chunkPos],a=this._dcsParser.unhook(v!==24&&v!==26,i),a)return a;v===27&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(v=s[this._parseStack.chunkPos],a=this._oscParser.end(v!==24&&v!==26,i),a)return a;v===27&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,g=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=15&this._parseStack.transition}for(let c=g;c>4){case 2:for(let w=c+1;;++w){if(w>=e||(v=s[w])<32||v>126&&v=e||(v=s[w])<32||v>126&&v=e||(v=s[w])<32||v>126&&v=e||(v=s[w])<32||v>126&&v=0&&(a=m[k](this._params),a!==!0);k--)if(a instanceof Promise)return this._preserveStack(3,m,k,_,c),a;k<0&&this._csiHandlerFb(this._collect<<8|v,this._params),this.precedingJoinState=0;break;case 8:do switch(v){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(v-48)}while(++c47&&v<60);c--;break;case 9:this._collect<<=8,this._collect|=v;break;case 10:const D=this._escHandlers[this._collect<<8|v];let L=D?D.length-1:-1;for(;L>=0&&(a=D[L](),a!==!0);L--)if(a instanceof Promise)return this._preserveStack(4,D,L,_,c),a;L<0&&this._escHandlerFb(this._collect<<8|v),this.precedingJoinState=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|v,this._params);break;case 13:for(let w=c+1;;++w)if(w>=e||(v=s[w])===24||v===26||v===27||v>127&&v=e||(v=s[w])<32||v>127&&v{Object.defineProperty(r,"__esModule",{value:!0}),r.OscHandler=r.OscParser=void 0;const l=o(5770),u=o(482),n=[];r.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(d,f){this._handlers[d]===void 0&&(this._handlers[d]=[]);const p=this._handlers[d];return p.push(f),{dispose:()=>{const h=p.indexOf(f);h!==-1&&p.splice(h,1)}}}clearHandler(d){this._handlers[d]&&delete this._handlers[d]}setHandlerFallback(d){this._handlerFb=d}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(this._state===2)for(let d=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;d>=0;--d)this._active[d].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let d=this._active.length-1;d>=0;d--)this._active[d].start();else this._handlerFb(this._id,"START")}_put(d,f,p){if(this._active.length)for(let h=this._active.length-1;h>=0;h--)this._active[h].put(d,f,p);else this._handlerFb(this._id,"PUT",(0,u.utf32ToString)(d,f,p))}start(){this.reset(),this._state=1}put(d,f,p){if(this._state!==3){if(this._state===1)for(;f0&&this._put(d,f,p)}}end(d,f=!0){if(this._state!==0){if(this._state!==3)if(this._state===1&&this._start(),this._active.length){let p=!1,h=this._active.length-1,t=!1;if(this._stack.paused&&(h=this._stack.loopPosition-1,p=f,t=this._stack.fallThrough,this._stack.paused=!1),!t&&p===!1){for(;h>=0&&(p=this._active[h].end(d),p!==!0);h--)if(p instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=h,this._stack.fallThrough=!1,p;h--}for(;h>=0;h--)if(p=this._active[h].end(!1),p instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=h,this._stack.fallThrough=!0,p}else this._handlerFb(this._id,"END",d);this._active=n,this._id=-1,this._state=0}}},r.OscHandler=class{constructor(d){this._handler=d,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(d,f,p){this._hitLimit||(this._data+=(0,u.utf32ToString)(d,f,p),this._data.length>l.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(d){let f=!1;if(this._hitLimit)f=!1;else if(d&&(f=this._handler(this._data),f instanceof Promise))return f.then((p=>(this._data="",this._hitLimit=!1,p)));return this._data="",this._hitLimit=!1,f}}},8742:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.Params=void 0;const o=2147483647;class l{static fromArray(n){const d=new l;if(!n.length)return d;for(let f=Array.isArray(n[0])?1:0;f256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(n),this.length=0,this._subParams=new Int32Array(d),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(n),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const n=new l(this.maxLength,this.maxSubParamsLength);return n.params.set(this.params),n.length=this.length,n._subParams.set(this._subParams),n._subParamsLength=this._subParamsLength,n._subParamsIdx.set(this._subParamsIdx),n._rejectDigits=this._rejectDigits,n._rejectSubDigits=this._rejectSubDigits,n._digitIsSub=this._digitIsSub,n}toArray(){const n=[];for(let d=0;d>8,p=255&this._subParamsIdx[d];p-f>0&&n.push(Array.prototype.slice.call(this._subParams,f,p))}return n}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(n){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(n<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=n>o?o:n}}addSubParam(n){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(n<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=n>o?o:n,this._subParamsIdx[this.length-1]++}}hasSubParams(n){return(255&this._subParamsIdx[n])-(this._subParamsIdx[n]>>8)>0}getSubParams(n){const d=this._subParamsIdx[n]>>8,f=255&this._subParamsIdx[n];return f-d>0?this._subParams.subarray(d,f):null}getSubParamsAll(){const n={};for(let d=0;d>8,p=255&this._subParamsIdx[d];p-f>0&&(n[d]=this._subParams.slice(f,p))}return n}addDigit(n){let d;if(this._rejectDigits||!(d=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const f=this._digitIsSub?this._subParams:this.params,p=f[d-1];f[d-1]=~p?Math.min(10*p+n,o):n}}r.Params=l},5741:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.AddonManager=void 0,r.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let o=this._addons.length-1;o>=0;o--)this._addons[o].instance.dispose()}loadAddon(o,l){const u={instance:l,dispose:l.dispose,isDisposed:!1};this._addons.push(u),l.dispose=()=>this._wrappedAddonDispose(u),l.activate(o)}_wrappedAddonDispose(o){if(o.isDisposed)return;let l=-1;for(let u=0;u{Object.defineProperty(r,"__esModule",{value:!0}),r.BufferApiView=void 0;const l=o(3785),u=o(511);r.BufferApiView=class{constructor(n,d){this._buffer=n,this.type=d}init(n){return this._buffer=n,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(n){const d=this._buffer.lines.get(n);if(d)return new l.BufferLineApiView(d)}getNullCell(){return new u.CellData}}},3785:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.BufferLineApiView=void 0;const l=o(511);r.BufferLineApiView=class{constructor(u){this._line=u}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(u,n){if(!(u<0||u>=this._line.length))return n?(this._line.loadCell(u,n),n):this._line.loadCell(u,new l.CellData)}translateToString(u,n,d){return this._line.translateToString(u,n,d)}}},8285:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.BufferNamespaceApi=void 0;const l=o(8771),u=o(8460),n=o(844);class d extends n.Disposable{constructor(p){super(),this._core=p,this._onBufferChange=this.register(new u.EventEmitter),this.onBufferChange=this._onBufferChange.event,this._normal=new l.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new l.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}r.BufferNamespaceApi=d},7975:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.ParserApi=void 0,r.ParserApi=class{constructor(o){this._core=o}registerCsiHandler(o,l){return this._core.registerCsiHandler(o,(u=>l(u.toArray())))}addCsiHandler(o,l){return this.registerCsiHandler(o,l)}registerDcsHandler(o,l){return this._core.registerDcsHandler(o,((u,n)=>l(u,n.toArray())))}addDcsHandler(o,l){return this.registerDcsHandler(o,l)}registerEscHandler(o,l){return this._core.registerEscHandler(o,l)}addEscHandler(o,l){return this.registerEscHandler(o,l)}registerOscHandler(o,l){return this._core.registerOscHandler(o,l)}addOscHandler(o,l){return this.registerOscHandler(o,l)}}},7090:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.UnicodeApi=void 0,r.UnicodeApi=class{constructor(o){this._core=o}register(o){this._core.unicodeService.register(o)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(o){this._core.unicodeService.activeVersion=o}}},744:function(T,r,o){var l=this&&this.__decorate||function(t,s,e,i){var a,v=arguments.length,_=v<3?s:i===null?i=Object.getOwnPropertyDescriptor(s,e):i;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")_=Reflect.decorate(t,s,e,i);else for(var g=t.length-1;g>=0;g--)(a=t[g])&&(_=(v<3?a(_):v>3?a(s,e,_):a(s,e))||_);return v>3&&_&&Object.defineProperty(s,e,_),_},u=this&&this.__param||function(t,s){return function(e,i){s(e,i,t)}};Object.defineProperty(r,"__esModule",{value:!0}),r.BufferService=r.MINIMUM_ROWS=r.MINIMUM_COLS=void 0;const n=o(8460),d=o(844),f=o(5295),p=o(2585);r.MINIMUM_COLS=2,r.MINIMUM_ROWS=1;let h=r.BufferService=class extends d.Disposable{get buffer(){return this.buffers.active}constructor(t){super(),this.isUserScrolling=!1,this._onResize=this.register(new n.EventEmitter),this.onResize=this._onResize.event,this._onScroll=this.register(new n.EventEmitter),this.onScroll=this._onScroll.event,this.cols=Math.max(t.rawOptions.cols||0,r.MINIMUM_COLS),this.rows=Math.max(t.rawOptions.rows||0,r.MINIMUM_ROWS),this.buffers=this.register(new f.BufferSet(t,this))}resize(t,s){this.cols=t,this.rows=s,this.buffers.resize(t,s),this._onResize.fire({cols:t,rows:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(t,s=!1){const e=this.buffer;let i;i=this._cachedBlankLine,i&&i.length===this.cols&&i.getFg(0)===t.fg&&i.getBg(0)===t.bg||(i=e.getBlankLine(t,s),this._cachedBlankLine=i),i.isWrapped=s;const a=e.ybase+e.scrollTop,v=e.ybase+e.scrollBottom;if(e.scrollTop===0){const _=e.lines.isFull;v===e.lines.length-1?_?e.lines.recycle().copyFrom(i):e.lines.push(i.clone()):e.lines.splice(v+1,0,i.clone()),_?this.isUserScrolling&&(e.ydisp=Math.max(e.ydisp-1,0)):(e.ybase++,this.isUserScrolling||e.ydisp++)}else{const _=v-a+1;e.lines.shiftElements(a+1,_-1,-1),e.lines.set(v,i.clone())}this.isUserScrolling||(e.ydisp=e.ybase),this._onScroll.fire(e.ydisp)}scrollLines(t,s,e){const i=this.buffer;if(t<0){if(i.ydisp===0)return;this.isUserScrolling=!0}else t+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);const a=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+t,i.ybase),0),a!==i.ydisp&&(s||this._onScroll.fire(i.ydisp))}};r.BufferService=h=l([u(0,p.IOptionsService)],h)},7994:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.CharsetService=void 0,r.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(o){this.glevel=o,this.charset=this._charsets[o]}setgCharset(o,l){this._charsets[o]=l,this.glevel===o&&(this.charset=l)}}},1753:function(T,r,o){var l=this&&this.__decorate||function(i,a,v,_){var g,c=arguments.length,m=c<3?a:_===null?_=Object.getOwnPropertyDescriptor(a,v):_;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")m=Reflect.decorate(i,a,v,_);else for(var k=i.length-1;k>=0;k--)(g=i[k])&&(m=(c<3?g(m):c>3?g(a,v,m):g(a,v))||m);return c>3&&m&&Object.defineProperty(a,v,m),m},u=this&&this.__param||function(i,a){return function(v,_){a(v,_,i)}};Object.defineProperty(r,"__esModule",{value:!0}),r.CoreMouseService=void 0;const n=o(2585),d=o(8460),f=o(844),p={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:i=>i.button!==4&&i.action===1&&(i.ctrl=!1,i.alt=!1,i.shift=!1,!0)},VT200:{events:19,restrict:i=>i.action!==32},DRAG:{events:23,restrict:i=>i.action!==32||i.button!==3},ANY:{events:31,restrict:i=>!0}};function h(i,a){let v=(i.ctrl?16:0)|(i.shift?4:0)|(i.alt?8:0);return i.button===4?(v|=64,v|=i.action):(v|=3&i.button,4&i.button&&(v|=64),8&i.button&&(v|=128),i.action===32?v|=32:i.action!==0||a||(v|=3)),v}const t=String.fromCharCode,s={DEFAULT:i=>{const a=[h(i,!1)+32,i.col+32,i.row+32];return a[0]>255||a[1]>255||a[2]>255?"":`\x1B[M${t(a[0])}${t(a[1])}${t(a[2])}`},SGR:i=>{const a=i.action===0&&i.button!==4?"m":"M";return`\x1B[<${h(i,!0)};${i.col};${i.row}${a}`},SGR_PIXELS:i=>{const a=i.action===0&&i.button!==4?"m":"M";return`\x1B[<${h(i,!0)};${i.x};${i.y}${a}`}};let e=r.CoreMouseService=class extends f.Disposable{constructor(i,a){super(),this._bufferService=i,this._coreService=a,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._onProtocolChange=this.register(new d.EventEmitter),this.onProtocolChange=this._onProtocolChange.event;for(const v of Object.keys(p))this.addProtocol(v,p[v]);for(const v of Object.keys(s))this.addEncoding(v,s[v]);this.reset()}addProtocol(i,a){this._protocols[i]=a}addEncoding(i,a){this._encodings[i]=a}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return this._protocols[this._activeProtocol].events!==0}set activeProtocol(i){if(!this._protocols[i])throw new Error(`unknown protocol "${i}"`);this._activeProtocol=i,this._onProtocolChange.fire(this._protocols[i].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(i){if(!this._encodings[i])throw new Error(`unknown encoding "${i}"`);this._activeEncoding=i}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null}triggerMouseEvent(i){if(i.col<0||i.col>=this._bufferService.cols||i.row<0||i.row>=this._bufferService.rows||i.button===4&&i.action===32||i.button===3&&i.action!==32||i.button!==4&&(i.action===2||i.action===3)||(i.col++,i.row++,i.action===32&&this._lastEvent&&this._equalEvents(this._lastEvent,i,this._activeEncoding==="SGR_PIXELS"))||!this._protocols[this._activeProtocol].restrict(i))return!1;const a=this._encodings[this._activeEncoding](i);return a&&(this._activeEncoding==="DEFAULT"?this._coreService.triggerBinaryEvent(a):this._coreService.triggerDataEvent(a,!0)),this._lastEvent=i,!0}explainEvents(i){return{down:!!(1&i),up:!!(2&i),drag:!!(4&i),move:!!(8&i),wheel:!!(16&i)}}_equalEvents(i,a,v){if(v){if(i.x!==a.x||i.y!==a.y)return!1}else if(i.col!==a.col||i.row!==a.row)return!1;return i.button===a.button&&i.action===a.action&&i.ctrl===a.ctrl&&i.alt===a.alt&&i.shift===a.shift}};r.CoreMouseService=e=l([u(0,n.IBufferService),u(1,n.ICoreService)],e)},6975:function(T,r,o){var l=this&&this.__decorate||function(e,i,a,v){var _,g=arguments.length,c=g<3?i:v===null?v=Object.getOwnPropertyDescriptor(i,a):v;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")c=Reflect.decorate(e,i,a,v);else for(var m=e.length-1;m>=0;m--)(_=e[m])&&(c=(g<3?_(c):g>3?_(i,a,c):_(i,a))||c);return g>3&&c&&Object.defineProperty(i,a,c),c},u=this&&this.__param||function(e,i){return function(a,v){i(a,v,e)}};Object.defineProperty(r,"__esModule",{value:!0}),r.CoreService=void 0;const n=o(1439),d=o(8460),f=o(844),p=o(2585),h=Object.freeze({insertMode:!1}),t=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,origin:!1,reverseWraparound:!1,sendFocus:!1,wraparound:!0});let s=r.CoreService=class extends f.Disposable{constructor(e,i,a){super(),this._bufferService=e,this._logService=i,this._optionsService=a,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this.register(new d.EventEmitter),this.onData=this._onData.event,this._onUserInput=this.register(new d.EventEmitter),this.onUserInput=this._onUserInput.event,this._onBinary=this.register(new d.EventEmitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this.register(new d.EventEmitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(h),this.decPrivateModes=(0,n.clone)(t)}reset(){this.modes=(0,n.clone)(h),this.decPrivateModes=(0,n.clone)(t)}triggerDataEvent(e,i=!1){if(this._optionsService.rawOptions.disableStdin)return;const a=this._bufferService.buffer;i&&this._optionsService.rawOptions.scrollOnUserInput&&a.ybase!==a.ydisp&&this._onRequestScrollToBottom.fire(),i&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`,(()=>e.split("").map((v=>v.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`,(()=>e.split("").map((i=>i.charCodeAt(0))))),this._onBinary.fire(e))}};r.CoreService=s=l([u(0,p.IBufferService),u(1,p.ILogService),u(2,p.IOptionsService)],s)},9074:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.DecorationService=void 0;const l=o(8055),u=o(8460),n=o(844),d=o(6106);let f=0,p=0;class h extends n.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new d.SortedList((e=>e?.marker.line)),this._onDecorationRegistered=this.register(new u.EventEmitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this.register(new u.EventEmitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this.register((0,n.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const i=new t(e);if(i){const a=i.marker.onDispose((()=>i.dispose()));i.onDispose((()=>{i&&(this._decorations.delete(i)&&this._onDecorationRemoved.fire(i),a.dispose())})),this._decorations.insert(i),this._onDecorationRegistered.fire(i)}return i}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,i,a){let v=0,_=0;for(const g of this._decorations.getKeyIterator(i))v=g.options.x??0,_=v+(g.options.width??1),e>=v&&e<_&&(!a||(g.options.layer??"bottom")===a)&&(yield g)}forEachDecorationAtCell(e,i,a,v){this._decorations.forEachByKey(i,(_=>{f=_.options.x??0,p=f+(_.options.width??1),e>=f&&e{Object.defineProperty(r,"__esModule",{value:!0}),r.InstantiationService=r.ServiceCollection=void 0;const l=o(2585),u=o(8343);class n{constructor(...f){this._entries=new Map;for(const[p,h]of f)this.set(p,h)}set(f,p){const h=this._entries.get(f);return this._entries.set(f,p),h}forEach(f){for(const[p,h]of this._entries.entries())f(p,h)}has(f){return this._entries.has(f)}get(f){return this._entries.get(f)}}r.ServiceCollection=n,r.InstantiationService=class{constructor(){this._services=new n,this._services.set(l.IInstantiationService,this)}setService(d,f){this._services.set(d,f)}getService(d){return this._services.get(d)}createInstance(d,...f){const p=(0,u.getServiceDependencies)(d).sort(((s,e)=>s.index-e.index)),h=[];for(const s of p){const e=this._services.get(s.id);if(!e)throw new Error(`[createInstance] ${d.name} depends on UNKNOWN service ${s.id}.`);h.push(e)}const t=p.length>0?p[0].index:f.length;if(f.length!==t)throw new Error(`[createInstance] First service dependency of ${d.name} at position ${t+1} conflicts with ${f.length} static arguments`);return new d(...f,...h)}}},7866:function(T,r,o){var l=this&&this.__decorate||function(t,s,e,i){var a,v=arguments.length,_=v<3?s:i===null?i=Object.getOwnPropertyDescriptor(s,e):i;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")_=Reflect.decorate(t,s,e,i);else for(var g=t.length-1;g>=0;g--)(a=t[g])&&(_=(v<3?a(_):v>3?a(s,e,_):a(s,e))||_);return v>3&&_&&Object.defineProperty(s,e,_),_},u=this&&this.__param||function(t,s){return function(e,i){s(e,i,t)}};Object.defineProperty(r,"__esModule",{value:!0}),r.traceCall=r.setTraceLogger=r.LogService=void 0;const n=o(844),d=o(2585),f={trace:d.LogLevelEnum.TRACE,debug:d.LogLevelEnum.DEBUG,info:d.LogLevelEnum.INFO,warn:d.LogLevelEnum.WARN,error:d.LogLevelEnum.ERROR,off:d.LogLevelEnum.OFF};let p,h=r.LogService=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(t){super(),this._optionsService=t,this._logLevel=d.LogLevelEnum.OFF,this._updateLogLevel(),this.register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),p=this}_updateLogLevel(){this._logLevel=f[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(t){for(let s=0;sJSON.stringify(_))).join(", ")})`);const v=i.apply(this,a);return p.trace(`GlyphRenderer#${i.name} return`,v),v}}},7302:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.OptionsService=r.DEFAULT_OPTIONS=void 0;const l=o(8460),u=o(844),n=o(6114);r.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"courier-new, courier, monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},rescaleOverlappingGlyphs:!1,rightClickSelectsWord:n.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRulerWidth:0};const d=["normal","bold","100","200","300","400","500","600","700","800","900"];class f extends u.Disposable{constructor(h){super(),this._onOptionChange=this.register(new l.EventEmitter),this.onOptionChange=this._onOptionChange.event;const t={...r.DEFAULT_OPTIONS};for(const s in h)if(s in t)try{const e=h[s];t[s]=this._sanitizeAndValidateOption(s,e)}catch(e){console.error(e)}this.rawOptions=t,this.options={...t},this._setupOptions(),this.register((0,u.toDisposable)((()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null})))}onSpecificOptionChange(h,t){return this.onOptionChange((s=>{s===h&&t(this.rawOptions[h])}))}onMultipleOptionChange(h,t){return this.onOptionChange((s=>{h.indexOf(s)!==-1&&t()}))}_setupOptions(){const h=s=>{if(!(s in r.DEFAULT_OPTIONS))throw new Error(`No option with key "${s}"`);return this.rawOptions[s]},t=(s,e)=>{if(!(s in r.DEFAULT_OPTIONS))throw new Error(`No option with key "${s}"`);e=this._sanitizeAndValidateOption(s,e),this.rawOptions[s]!==e&&(this.rawOptions[s]=e,this._onOptionChange.fire(s))};for(const s in this.rawOptions){const e={get:h.bind(this,s),set:t.bind(this,s)};Object.defineProperty(this.options,s,e)}}_sanitizeAndValidateOption(h,t){switch(h){case"cursorStyle":if(t||(t=r.DEFAULT_OPTIONS[h]),!(function(s){return s==="block"||s==="underline"||s==="bar"})(t))throw new Error(`"${t}" is not a valid value for ${h}`);break;case"wordSeparator":t||(t=r.DEFAULT_OPTIONS[h]);break;case"fontWeight":case"fontWeightBold":if(typeof t=="number"&&1<=t&&t<=1e3)break;t=d.includes(t)?t:r.DEFAULT_OPTIONS[h];break;case"cursorWidth":t=Math.floor(t);case"lineHeight":case"tabStopWidth":if(t<1)throw new Error(`${h} cannot be less than 1, value: ${t}`);break;case"minimumContrastRatio":t=Math.max(1,Math.min(21,Math.round(10*t)/10));break;case"scrollback":if((t=Math.min(t,4294967295))<0)throw new Error(`${h} cannot be less than 0, value: ${t}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(t<=0)throw new Error(`${h} cannot be less than or equal to 0, value: ${t}`);break;case"rows":case"cols":if(!t&&t!==0)throw new Error(`${h} must be numeric, value: ${t}`);break;case"windowsPty":t=t??{}}return t}}r.OptionsService=f},2660:function(T,r,o){var l=this&&this.__decorate||function(f,p,h,t){var s,e=arguments.length,i=e<3?p:t===null?t=Object.getOwnPropertyDescriptor(p,h):t;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")i=Reflect.decorate(f,p,h,t);else for(var a=f.length-1;a>=0;a--)(s=f[a])&&(i=(e<3?s(i):e>3?s(p,h,i):s(p,h))||i);return e>3&&i&&Object.defineProperty(p,h,i),i},u=this&&this.__param||function(f,p){return function(h,t){p(h,t,f)}};Object.defineProperty(r,"__esModule",{value:!0}),r.OscLinkService=void 0;const n=o(2585);let d=r.OscLinkService=class{constructor(f){this._bufferService=f,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(f){const p=this._bufferService.buffer;if(f.id===void 0){const a=p.addMarker(p.ybase+p.y),v={data:f,id:this._nextId++,lines:[a]};return a.onDispose((()=>this._removeMarkerFromLink(v,a))),this._dataByLinkId.set(v.id,v),v.id}const h=f,t=this._getEntryIdKey(h),s=this._entriesWithId.get(t);if(s)return this.addLineToLink(s.id,p.ybase+p.y),s.id;const e=p.addMarker(p.ybase+p.y),i={id:this._nextId++,key:this._getEntryIdKey(h),data:h,lines:[e]};return e.onDispose((()=>this._removeMarkerFromLink(i,e))),this._entriesWithId.set(i.key,i),this._dataByLinkId.set(i.id,i),i.id}addLineToLink(f,p){const h=this._dataByLinkId.get(f);if(h&&h.lines.every((t=>t.line!==p))){const t=this._bufferService.buffer.addMarker(p);h.lines.push(t),t.onDispose((()=>this._removeMarkerFromLink(h,t)))}}getLinkData(f){return this._dataByLinkId.get(f)?.data}_getEntryIdKey(f){return`${f.id};;${f.uri}`}_removeMarkerFromLink(f,p){const h=f.lines.indexOf(p);h!==-1&&(f.lines.splice(h,1),f.lines.length===0&&(f.data.id!==void 0&&this._entriesWithId.delete(f.key),this._dataByLinkId.delete(f.id)))}};r.OscLinkService=d=l([u(0,n.IBufferService)],d)},8343:(T,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.createDecorator=r.getServiceDependencies=r.serviceRegistry=void 0;const o="di$target",l="di$dependencies";r.serviceRegistry=new Map,r.getServiceDependencies=function(u){return u[l]||[]},r.createDecorator=function(u){if(r.serviceRegistry.has(u))return r.serviceRegistry.get(u);const n=function(d,f,p){if(arguments.length!==3)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");(function(h,t,s){t[o]===t?t[l].push({id:h,index:s}):(t[l]=[{id:h,index:s}],t[o]=t)})(n,d,p)};return n.toString=()=>u,r.serviceRegistry.set(u,n),n}},2585:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.IDecorationService=r.IUnicodeService=r.IOscLinkService=r.IOptionsService=r.ILogService=r.LogLevelEnum=r.IInstantiationService=r.ICharsetService=r.ICoreService=r.ICoreMouseService=r.IBufferService=void 0;const l=o(8343);var u;r.IBufferService=(0,l.createDecorator)("BufferService"),r.ICoreMouseService=(0,l.createDecorator)("CoreMouseService"),r.ICoreService=(0,l.createDecorator)("CoreService"),r.ICharsetService=(0,l.createDecorator)("CharsetService"),r.IInstantiationService=(0,l.createDecorator)("InstantiationService"),(function(n){n[n.TRACE=0]="TRACE",n[n.DEBUG=1]="DEBUG",n[n.INFO=2]="INFO",n[n.WARN=3]="WARN",n[n.ERROR=4]="ERROR",n[n.OFF=5]="OFF"})(u||(r.LogLevelEnum=u={})),r.ILogService=(0,l.createDecorator)("LogService"),r.IOptionsService=(0,l.createDecorator)("OptionsService"),r.IOscLinkService=(0,l.createDecorator)("OscLinkService"),r.IUnicodeService=(0,l.createDecorator)("UnicodeService"),r.IDecorationService=(0,l.createDecorator)("DecorationService")},1480:(T,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.UnicodeService=void 0;const l=o(8460),u=o(225);class n{static extractShouldJoin(f){return(1&f)!=0}static extractWidth(f){return f>>1&3}static extractCharKind(f){return f>>3}static createPropertyValue(f,p,h=!1){return(16777215&f)<<3|(3&p)<<1|(h?1:0)}constructor(){this._providers=Object.create(null),this._active="",this._onChange=new l.EventEmitter,this.onChange=this._onChange.event;const f=new u.UnicodeV6;this.register(f),this._active=f.version,this._activeProvider=f}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(f){if(!this._providers[f])throw new Error(`unknown Unicode version "${f}"`);this._active=f,this._activeProvider=this._providers[f],this._onChange.fire(f)}register(f){this._providers[f.version]=f}wcwidth(f){return this._activeProvider.wcwidth(f)}getStringCellWidth(f){let p=0,h=0;const t=f.length;for(let s=0;s=t)return p+this.wcwidth(e);const v=f.charCodeAt(s);56320<=v&&v<=57343?e=1024*(e-55296)+v-56320+65536:p+=this.wcwidth(v)}const i=this.charProperties(e,h);let a=n.extractWidth(i);n.extractShouldJoin(i)&&(a-=n.extractWidth(h)),p+=a,h=i}return p}charProperties(f,p){return this._activeProvider.charProperties(f,p)}}r.UnicodeService=n}},X={};function V(T){var r=X[T];if(r!==void 0)return r.exports;var o=X[T]={exports:{}};return Z[T].call(o.exports,o,o.exports,V),o.exports}var G={};return(()=>{var T=G;Object.defineProperty(T,"__esModule",{value:!0}),T.Terminal=void 0;const r=V(9042),o=V(3236),l=V(844),u=V(5741),n=V(8285),d=V(7975),f=V(7090),p=["cols","rows"];class h extends l.Disposable{constructor(s){super(),this._core=this.register(new o.Terminal(s)),this._addonManager=this.register(new u.AddonManager),this._publicOptions={...this._core.options};const e=a=>this._core.options[a],i=(a,v)=>{this._checkReadonlyOptions(a),this._core.options[a]=v};for(const a in this._core.options){const v={get:e.bind(this,a),set:i.bind(this,a)};Object.defineProperty(this._publicOptions,a,v)}}_checkReadonlyOptions(s){if(p.includes(s))throw new Error(`Option "${s}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new d.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new f.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this.register(new n.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const s=this._core.coreService.decPrivateModes;let e="none";switch(this._core.coreMouseService.activeProtocol){case"X10":e="x10";break;case"VT200":e="vt200";break;case"DRAG":e="drag";break;case"ANY":e="any"}return{applicationCursorKeysMode:s.applicationCursorKeys,applicationKeypadMode:s.applicationKeypad,bracketedPasteMode:s.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:e,originMode:s.origin,reverseWraparoundMode:s.reverseWraparound,sendFocusMode:s.sendFocus,wraparoundMode:s.wraparound}}get options(){return this._publicOptions}set options(s){for(const e in s)this._publicOptions[e]=s[e]}blur(){this._core.blur()}focus(){this._core.focus()}input(s,e=!0){this._core.input(s,e)}resize(s,e){this._verifyIntegers(s,e),this._core.resize(s,e)}open(s){this._core.open(s)}attachCustomKeyEventHandler(s){this._core.attachCustomKeyEventHandler(s)}attachCustomWheelEventHandler(s){this._core.attachCustomWheelEventHandler(s)}registerLinkProvider(s){return this._core.registerLinkProvider(s)}registerCharacterJoiner(s){return this._checkProposedApi(),this._core.registerCharacterJoiner(s)}deregisterCharacterJoiner(s){this._checkProposedApi(),this._core.deregisterCharacterJoiner(s)}registerMarker(s=0){return this._verifyIntegers(s),this._core.registerMarker(s)}registerDecoration(s){return this._checkProposedApi(),this._verifyPositiveIntegers(s.x??0,s.width??0,s.height??0),this._core.registerDecoration(s)}hasSelection(){return this._core.hasSelection()}select(s,e,i){this._verifyIntegers(s,e,i),this._core.select(s,e,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(s,e){this._verifyIntegers(s,e),this._core.selectLines(s,e)}dispose(){super.dispose()}scrollLines(s){this._verifyIntegers(s),this._core.scrollLines(s)}scrollPages(s){this._verifyIntegers(s),this._core.scrollPages(s)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(s){this._verifyIntegers(s),this._core.scrollToLine(s)}clear(){this._core.clear()}write(s,e){this._core.write(s,e)}writeln(s,e){this._core.write(s),this._core.write(`\r -`,e)}paste(s){this._core.paste(s)}refresh(s,e){this._verifyIntegers(s,e),this._core.refresh(s,e)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(s){this._addonManager.loadAddon(this,s)}static get strings(){return r}_verifyIntegers(...s){for(const e of s)if(e===1/0||isNaN(e)||e%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...s){for(const e of s)if(e&&(e===1/0||isNaN(e)||e%1!=0||e<0))throw new Error("This API only accepts positive integers")}}T.Terminal=h})(),G})()))})(ge)),ge.exports}var xe=Le(),me={exports:{}},be;function Ae(){return be||(be=1,(function(j,Y){(function(Z,X){j.exports=X()})(self,(()=>(()=>{var Z={};return(()=>{var X=Z;Object.defineProperty(X,"__esModule",{value:!0}),X.FitAddon=void 0,X.FitAddon=class{activate(V){this._terminal=V}dispose(){}fit(){const V=this.proposeDimensions();if(!V||!this._terminal||isNaN(V.cols)||isNaN(V.rows))return;const G=this._terminal._core;this._terminal.rows===V.rows&&this._terminal.cols===V.cols||(G._renderService.clear(),this._terminal.resize(V.cols,V.rows))}proposeDimensions(){if(!this._terminal||!this._terminal.element||!this._terminal.element.parentElement)return;const V=this._terminal._core,G=V._renderService.dimensions;if(G.css.cell.width===0||G.css.cell.height===0)return;const T=this._terminal.options.scrollback===0?0:V.viewport.scrollBarWidth,r=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(r.getPropertyValue("height")),l=Math.max(0,parseInt(r.getPropertyValue("width"))),u=window.getComputedStyle(this._terminal.element),n=o-(parseInt(u.getPropertyValue("padding-top"))+parseInt(u.getPropertyValue("padding-bottom"))),d=l-(parseInt(u.getPropertyValue("padding-right"))+parseInt(u.getPropertyValue("padding-left")))-T;return{cols:Math.max(2,Math.floor(d/G.css.cell.width)),rows:Math.max(1,Math.floor(n/G.css.cell.height))}}}})(),Z})()))})(me)),me.exports}var Be=Ae();function Te(j,Y,Z){if(Y===Z)return j;const X=j.indexOf(Y),V=j.indexOf(Z);if(X===-1||V===-1)return j;const G=j.filter(o=>o!==Y),T=G.indexOf(Z),r=XG*T/Ie(G,T),1),V=[];return Z.forEach((G,T)=>{const r=X/G;for(let o=0;o=4352&&j<=4447||j>=11904&&j<=12350||j>=12353&&j<=13311||j>=13312&&j<=19903||j>=19968&&j<=40959||j>=40960&&j<=42191||j>=44032&&j<=55203||j>=63744&&j<=64255||j>=65072&&j<=65103||j>=65280&&j<=65376||j>=65504&&j<=65510||j>=127744&&j<=129791||j>=131072&&j<=262141}function We(j,Y){let Z=0;for(const G of j)Z+=we(G.codePointAt(0)??0)?2:1;if(Z<=Y)return j;let X=0,V="";for(const G of j){const T=we(G.codePointAt(0)??0)?2:1;if(X+T>Y-1)break;V+=G,X+=T}return`${V}…`}function Ue({repo:j,maximized:Y,onToggleMaximized:Z}){const X=Q.useRef(null),V=Q.useRef(null),G=Q.useRef(new Map),T=Q.useRef(new Map),r=Q.useRef(new Map),o=Q.useRef(new Map),l=Q.useRef(new Map),u=Q.useRef(0),[n,d]=Q.useState([]),[f,p]=Q.useState(null),[h,t]=Q.useState(null),[s,e]=Q.useState({w:0,h:0}),[i,a]=Q.useState({}),v=Q.useRef(null),_=Q.useRef(null),g=Q.useRef(null),c=Q.useRef(!1),[m,k]=Q.useState(null),[D,L]=Q.useState(null);Q.useEffect(()=>{let b=!1,A;const O=()=>{G.current.forEach($=>$.term.dispose()),G.current.clear(),o.current.clear(),r.current.clear()},U=()=>{d([]),p(null),t(null),a({}),O();const $=location.protocol==="https:"?"wss:":"ws:",R=new WebSocket(`${$}//${location.host}/ws/term?repo=${encodeURIComponent(j)}`);R.binaryType="arraybuffer",V.current=R,R.onmessage=C=>{if(typeof C.data=="string"){const z=JSON.parse(C.data);if(z.type==="created"){const q=z.pane;d(J=>[...J,q]),u.current>0?(u.current-=1,p(q),l.current.set(j,q)):l.current.get(j)===q&&p(q)}else z.type==="exited"?(d(q=>q.filter(J=>J!==z.pane)),p(q=>q===z.pane?null:q),t(q=>q===z.pane?null:q),o.current.delete(z.pane),r.current.delete(z.pane),a(q=>{if(!(z.pane in q))return q;const J={...q};return delete J[z.pane],J})):z.type==="reordered"?d(q=>Me(q,z.order)):z.type==="error"&&(u.current=0,Re.error(z.message));return}const B=new Uint8Array(C.data);if(B.length<4)return;const M=new DataView(B.buffer).getUint32(0,!0),W=B.subarray(4),F=G.current.get(M);if(F)F.term.write(W);else{const z=o.current.get(M)??[];z.push(W),o.current.set(M,z)}},R.onclose=()=>{b||(A=setTimeout(U,1e3))}};return U(),()=>{b=!0,A&&clearTimeout(A),V.current?.close(),O()}},[j]),Q.useEffect(()=>{for(const b of n){if(G.current.has(b))continue;const A=T.current.get(b);if(!A)continue;const O=new xe.Terminal({fontFamily:getComputedStyle(document.body).fontFamily,fontSize:12,theme:{background:"#0b0b0d",foreground:"#e6e6ec"},cursorBlink:!0}),U=new Be.FitAddon;O.loadAddon(U),O.onData(R=>V.current?.send(JSON.stringify({type:"input",pane:b,data:R}))),O.onTitleChange(R=>{const C=R.replace(/\s+/g," ").trim();C&&a(B=>({...B,[b]:C}))}),O.open(A),G.current.set(b,{term:O,fit:U});const $=o.current.get(b);if($){for(const R of $)O.write(R);o.current.delete(b)}}for(const[b,A]of G.current)n.includes(b)||(A.term.dispose(),G.current.delete(b))},[n]),Q.useEffect(()=>{for(const[b,A]of G.current){const O=T.current.get(b);if(!O||O.clientHeight===0||O.clientWidth===0)continue;A.fit.fit();const{rows:U,cols:$}=A.term,R=r.current.get(b);R&&R.rows===U&&R.cols===$||(r.current.set(b,{rows:U,cols:$}),V.current?.send(JSON.stringify({type:"resize",pane:b,rows:U,cols:$})))}},[n,h,s]),Q.useEffect(()=>{const b=X.current;if(!b)return;const A=new ResizeObserver(()=>{e({w:b.clientWidth,h:b.clientHeight})});return A.observe(b),()=>A.disconnect()},[]),Q.useEffect(()=>{if(f===null&&n.length>0){const b=l.current.get(j);p(b!==void 0&&n.includes(b)?b:n[n.length-1])}},[f,n,j]),Q.useEffect(()=>{f!==null&&G.current.get(f)?.term.focus()},[f]);const w=b=>{p(b),l.current.set(j,b)},x=()=>{const b=V.current;b&&(t(null),u.current+=1,b.send(JSON.stringify({type:"create",rows:24,cols:80})))},P=b=>{V.current?.send(JSON.stringify({type:"close",pane:b}))},H=h===null&&n.length>1,N=()=>{v.current=null,_.current=null,g.current=null,c.current=!1,k(null),L(null)},I=(b,A)=>{b.target.closest("button")||(w(A),!(b.button!==0||!H)&&(v.current=A,_.current={x:b.clientX,y:b.clientY},c.current=!1,b.currentTarget.setPointerCapture(b.pointerId)))},S=b=>{const A=v.current,O=_.current;if(A===null||O===null||!c.current&&Math.hypot(b.clientX-O.x,b.clientY-O.y){const b=v.current,A=g.current;if(b!==null&&c.current&&A!==null){const O=Te(n,b,A);V.current?.send(JSON.stringify({type:"reorder",order:O}))}N()},E=Fe(n.length,s.w>=s.h);return te.jsxs("section",{className:"flex min-h-0 flex-col border-t border-ink-700",children:[te.jsxs("div",{className:"flex shrink-0 items-center gap-2 bg-ink-900 px-2 py-1",children:[te.jsx("button",{onClick:x,title:"New terminal","aria-label":"New terminal",className:"ml-auto flex shrink-0 items-center rounded-sm px-1.5 py-0.5 text-ink-400 hover:text-accent",children:te.jsx(ke,{})}),te.jsx("button",{onClick:Z,"aria-pressed":Y,title:Y?"Restore panel height":"Maximize the panel","aria-label":Y?"Restore panel height":"Maximize the panel",className:"flex shrink-0 items-center rounded-sm px-1.5 py-0.5 text-ink-400 hover:text-accent",children:te.jsx(Se,{maximized:Y})})]}),te.jsxs("div",{className:"relative min-h-0 flex-1 overflow-hidden bg-ink-950 p-1",children:[n.length===0&&te.jsxs("p",{className:"p-3 text-ink-400",children:["No terminal open. Press ",te.jsx("span",{className:"text-accent",children:"+"})," above to start one."]}),te.jsx("div",{ref:X,className:"grid h-full gap-1",style:h!==null?{gridTemplateColumns:"1fr",gridTemplateRows:"1fr"}:{gridTemplateColumns:`repeat(${E.cols}, minmax(0, 1fr))`,gridTemplateRows:`repeat(${E.rows}, minmax(0, 1fr))`},children:n.map((b,A)=>{const O=i[b]??`term ${A+1}`,U=E.cells[A],$=h!==null?{display:b===h?"flex":"none"}:{display:"flex",gridColumn:`${U.colStart} / span ${U.colSpan}`,gridRow:`${U.row}`},R=m===b,B=D===b?"border-accent ring-1 ring-accent":b===f?"border-accent":"border-ink-700";return te.jsxs("div",{"data-pane-id":b,onMouseDown:()=>w(b),style:$,className:`min-h-0 min-w-0 flex-col overflow-hidden rounded-sm border ${B} ${R?"opacity-60":""}`,children:[te.jsxs("div",{onPointerDown:M=>I(M,b),onPointerMove:S,onPointerUp:y,onPointerCancel:N,className:`flex shrink-0 items-center gap-1 select-none bg-ink-900 px-2 py-0.5 text-xs ${H?R?"cursor-grabbing touch-none":"cursor-grab touch-none":""}`,children:[te.jsx("span",{title:O,className:`min-w-0 flex-1 truncate ${b===f?"text-ink-50":"text-ink-400"}`,children:We(O,Oe)}),te.jsx("button",{onMouseDown:M=>M.stopPropagation(),onClick:()=>t(M=>M===b?null:b),"aria-pressed":h===b,title:h===b?"Restore the grid":"Zoom this terminal","aria-label":h===b?"Restore the grid":"Zoom this terminal",className:"flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:text-accent",children:te.jsx(Se,{maximized:h===b})}),te.jsx("button",{onMouseDown:M=>M.stopPropagation(),onClick:()=>P(b),title:"Close terminal","aria-label":`close terminal ${A+1}`,className:"flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:text-removed",children:te.jsx(De,{})})]}),te.jsx("div",{ref:M=>{M?T.current.set(b,M):T.current.delete(b)},className:"min-h-0 flex-1"})]},b)})})]})]})}export{Ue as TerminalPanel}; diff --git a/viewer-ui/dist/assets/Terminal-Dhwxs9P9.js b/viewer-ui/dist/assets/Terminal-Dhwxs9P9.js new file mode 100644 index 0000000..4ed09f0 --- /dev/null +++ b/viewer-ui/dist/assets/Terminal-Dhwxs9P9.js @@ -0,0 +1,9 @@ +import{r as Y,t as ke,j as te,M as we,X as De,P as Le}from"./index-D8ORZvs2.js";const Re=20,xe=4;function Ae(N,X){for(;X;)[N,X]=[X,N%X];return N}function Be(N,X){switch(N){case 1:return[1];case 2:return X?[2]:[1,1];case 3:return[2,1];case 4:return[2,2];case 5:return[3,2];case 6:return[3,3];case 7:return[4,3];default:return[4,4]}}function Te(N,X){const G=Be(N,X),V=G.reduce((q,x)=>q*x/Ae(q,x),1),j=[];return G.forEach((q,x)=>{const r=V/q;for(let o=0;o=4352&&N<=4447||N>=11904&&N<=12350||N>=12353&&N<=13311||N>=13312&&N<=19903||N>=19968&&N<=40959||N>=40960&&N<=42191||N>=44032&&N<=55203||N>=63744&&N<=64255||N>=65072&&N<=65103||N>=65280&&N<=65376||N>=65504&&N<=65510||N>=127744&&N<=129791||N>=131072&&N<=262141}function Me(N,X){let G=0;for(const q of N)G+=Se(q.codePointAt(0)??0)?2:1;if(G<=X)return N;let V=0,j="";for(const q of N){const x=Se(q.codePointAt(0)??0)?2:1;if(V+x>X-1)break;j+=q,V+=x}return`${j}…`}function Oe(N,X,G){if(X===G)return N;const V=N.indexOf(X),j=N.indexOf(G);if(V===-1||j===-1)return N;const q=N.filter(o=>o!==X),x=q.indexOf(G),r=V1,f=()=>{j.current=null,q.current=null,x.current=null,r.current=!1,l(null),n(null)};return{draggingPane:o,dragOverPane:_,reorderable:d,endPaneDrag:f,onPaneDragStart:(s,e)=>{s.target.closest("button")||(G(e),!(s.button!==0||!d)&&(j.current=e,q.current={x:s.clientX,y:s.clientY},r.current=!1,s.currentTarget.setPointerCapture(s.pointerId)))},onPaneDragMove:s=>{const e=j.current,i=q.current;if(e===null||i===null||!r.current&&Math.hypot(s.clientX-i.x,s.clientY-i.y){const s=j.current,e=x.current;s!==null&&r.current&&e!==null&&V(Oe(N,s,e)),f()}}}function He({repo:N,socketRef:X,viewsRef:G,pendingRef:V,sentSizesRef:j,lastActiveByRepoRef:q,expectCreateRef:x,setPanes:r,setActive:o,setZoomed:l,setTitles:_}){Y.useEffect(()=>{let n=!1,d;const f=()=>{G.current.forEach(h=>h.term.dispose()),G.current.clear(),V.current.clear(),j.current.clear()},g=()=>{r([]),o(null),l(null),_({}),f();const h=location.protocol==="https:"?"wss:":"ws:",t=new WebSocket(`${h}//${location.host}/ws/term?repo=${encodeURIComponent(N)}`);t.binaryType="arraybuffer",X.current=t,t.onmessage=s=>{if(typeof s.data=="string"){const u=JSON.parse(s.data);if(u.type==="created"){const p=u.pane;r(c=>[...c,p]),x.current>0?(x.current-=1,o(p),q.current.set(N,p)):q.current.get(N)===p&&o(p)}else u.type==="exited"?(r(p=>p.filter(c=>c!==u.pane)),o(p=>p===u.pane?null:p),l(p=>p===u.pane?null:p),V.current.delete(u.pane),j.current.delete(u.pane),_(p=>{if(!(u.pane in p))return p;const c={...p};return delete c[u.pane],c})):u.type==="reordered"?r(p=>Pe(p,u.order)):u.type==="error"&&(x.current=0,ke.error(u.message));return}const e=new Uint8Array(s.data);if(e.length<4)return;const i=new DataView(e.buffer).getUint32(0,!0),a=e.subarray(4),v=G.current.get(i);if(v)v.term.write(a);else{const u=V.current.get(i)??[];u.push(a),V.current.set(i,u)}},t.onclose=()=>{n||(d=setTimeout(g,1e3))}};return g(),()=>{n=!0,d&&clearTimeout(d),X.current?.close(),f()}},[N])}var ge={exports:{}},Ce;function Fe(){return Ce||(Ce=1,(function(N,X){(function(G,V){N.exports=V()})(globalThis,(()=>(()=>{var G={4567:function(x,r,o){var l=this&&this.__decorate||function(e,i,a,v){var u,p=arguments.length,c=p<3?i:v===null?v=Object.getOwnPropertyDescriptor(i,a):v;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")c=Reflect.decorate(e,i,a,v);else for(var S=e.length-1;S>=0;S--)(u=e[S])&&(c=(p<3?u(c):p>3?u(i,a,c):u(i,a))||c);return p>3&&c&&Object.defineProperty(i,a,c),c},_=this&&this.__param||function(e,i){return function(a,v){i(a,v,e)}};Object.defineProperty(r,"__esModule",{value:!0}),r.AccessibilityManager=void 0;const n=o(9042),d=o(9924),f=o(844),g=o(4725),h=o(2585),t=o(3656);let s=r.AccessibilityManager=class extends f.Disposable{constructor(e,i,a,v){super(),this._terminal=e,this._coreBrowserService=a,this._renderService=v,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let u=0;uthis._handleBoundaryFocus(u,0),this._bottomBoundaryFocusListener=u=>this._handleBoundaryFocus(u,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new d.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((u=>this._handleResize(u.rows)))),this.register(this._terminal.onRender((u=>this._refreshRows(u.start,u.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((u=>this._handleChar(u)))),this.register(this._terminal.onLineFeed((()=>this._handleChar(` +`)))),this.register(this._terminal.onA11yTab((u=>this._handleTab(u)))),this.register(this._terminal.onKey((u=>this._handleKey(u.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,t.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,f.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let i=0;i0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,e===` +`&&(this._liveRegionLineCount++,this._liveRegionLineCount===21&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),new RegExp("\\p{Control}","u").test(e)||this._charsToConsume.push(e)}_refreshRows(e,i){this._liveRegionDebouncer.refresh(e,i,this._terminal.rows)}_renderRows(e,i){const a=this._terminal.buffer,v=a.lines.length.toString();for(let u=e;u<=i;u++){const p=a.lines.get(a.ydisp+u),c=[],S=p?.translateToString(!0,void 0,void 0,c)||"",E=(a.ydisp+u+1).toString(),k=this._rowElements[u];k&&(S.length===0?(k.innerText=" ",this._rowColumns.set(k,[0,1])):(k.textContent=S,this._rowColumns.set(k,c)),k.setAttribute("aria-posinset",E),k.setAttribute("aria-setsize",v))}this._announceCharacters()}_announceCharacters(){this._charsToAnnounce.length!==0&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,i){const a=e.target,v=this._rowElements[i===0?1:this._rowElements.length-2];if(a.getAttribute("aria-posinset")===(i===0?"1":`${this._terminal.buffer.lines.length}`)||e.relatedTarget!==v)return;let u,p;if(i===0?(u=a,p=this._rowElements.pop(),this._rowContainer.removeChild(p)):(u=this._rowElements.shift(),p=a,this._rowContainer.removeChild(u)),u.removeEventListener("focus",this._topBoundaryFocusListener),p.removeEventListener("focus",this._bottomBoundaryFocusListener),i===0){const c=this._createAccessibilityTreeNode();this._rowElements.unshift(c),this._rowContainer.insertAdjacentElement("afterbegin",c)}else{const c=this._createAccessibilityTreeNode();this._rowElements.push(c),this._rowContainer.appendChild(c)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(i===0?-1:1),this._rowElements[i===0?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(this._rowElements.length===0)return;const e=document.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let i={node:e.anchorNode,offset:e.anchorOffset},a={node:e.focusNode,offset:e.focusOffset};if((i.node.compareDocumentPosition(a.node)&Node.DOCUMENT_POSITION_PRECEDING||i.node===a.node&&i.offset>a.offset)&&([i,a]=[a,i]),i.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(i={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(i.node))return;const v=this._rowElements.slice(-1)[0];if(a.node.compareDocumentPosition(v)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(a={node:v,offset:v.textContent?.length??0}),!this._rowContainer.contains(a.node))return;const u=({node:S,offset:E})=>{const k=S instanceof Text?S.parentNode:S;let L=parseInt(k?.getAttribute("aria-posinset"),10)-1;if(isNaN(L))return console.warn("row is invalid. Race condition?"),null;const b=this._rowColumns.get(k);if(!b)return console.warn("columns is null. Race condition?"),null;let A=E=this._terminal.cols&&(++L,A=0),{row:L,column:A}},p=u(i),c=u(a);if(p&&c){if(p.row>c.row||p.row===c.row&&p.column>=c.column)throw new Error("invalid range");this._terminal.select(p.column,p.row,(c.row-p.row)*this._terminal.cols-p.column+c.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let i=this._rowContainer.children.length;ie;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){this._accessibilityContainer.style.width=`${this._renderService.dimensions.css.canvas.width}px`,this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function o(d){return d.replace(/\r?\n/g,"\r")}function l(d,f){return f?"\x1B[200~"+d+"\x1B[201~":d}function _(d,f,g,h){d=l(d=o(d),g.decPrivateModes.bracketedPasteMode&&h.rawOptions.ignoreBracketedPasteMode!==!0),g.triggerDataEvent(d,!0),f.value=""}function n(d,f,g){const h=g.getBoundingClientRect(),t=d.clientX-h.left-10,s=d.clientY-h.top-10;f.style.width="20px",f.style.height="20px",f.style.left=`${t}px`,f.style.top=`${s}px`,f.style.zIndex="1000",f.focus()}Object.defineProperty(r,"__esModule",{value:!0}),r.rightClickHandler=r.moveTextAreaUnderMouseCursor=r.paste=r.handlePasteEvent=r.copyHandler=r.bracketTextForPaste=r.prepareTextForTerminal=void 0,r.prepareTextForTerminal=o,r.bracketTextForPaste=l,r.copyHandler=function(d,f){d.clipboardData&&d.clipboardData.setData("text/plain",f.selectionText),d.preventDefault()},r.handlePasteEvent=function(d,f,g,h){d.stopPropagation(),d.clipboardData&&_(d.clipboardData.getData("text/plain"),f,g,h)},r.paste=_,r.moveTextAreaUnderMouseCursor=n,r.rightClickHandler=function(d,f,g,h,t){n(d,f,g),t&&h.rightClickSelect(d),f.value=h.selectionText,f.select()}},7239:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.ColorContrastCache=void 0;const l=o(1505);r.ColorContrastCache=class{constructor(){this._color=new l.TwoKeyMap,this._css=new l.TwoKeyMap}setCss(_,n,d){this._css.set(_,n,d)}getCss(_,n){return this._css.get(_,n)}setColor(_,n,d){this._color.set(_,n,d)}getColor(_,n){return this._color.get(_,n)}clear(){this._color.clear(),this._css.clear()}}},3656:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.addDisposableDomListener=void 0,r.addDisposableDomListener=function(o,l,_,n){o.addEventListener(l,_,n);let d=!1;return{dispose:()=>{d||(d=!0,o.removeEventListener(l,_,n))}}}},3551:function(x,r,o){var l=this&&this.__decorate||function(s,e,i,a){var v,u=arguments.length,p=u<3?e:a===null?a=Object.getOwnPropertyDescriptor(e,i):a;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")p=Reflect.decorate(s,e,i,a);else for(var c=s.length-1;c>=0;c--)(v=s[c])&&(p=(u<3?v(p):u>3?v(e,i,p):v(e,i))||p);return u>3&&p&&Object.defineProperty(e,i,p),p},_=this&&this.__param||function(s,e){return function(i,a){e(i,a,s)}};Object.defineProperty(r,"__esModule",{value:!0}),r.Linkifier=void 0;const n=o(3656),d=o(8460),f=o(844),g=o(2585),h=o(4725);let t=r.Linkifier=class extends f.Disposable{get currentLink(){return this._currentLink}constructor(s,e,i,a,v){super(),this._element=s,this._mouseService=e,this._renderService=i,this._bufferService=a,this._linkProviderService=v,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this.register(new d.EventEmitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this.register(new d.EventEmitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this.register((0,f.getDisposeArrayDisposable)(this._linkCacheDisposables)),this.register((0,f.toDisposable)((()=>{this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()}))),this.register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0}))),this.register((0,n.addDisposableDomListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this.register((0,n.addDisposableDomListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(s){this._lastMouseEvent=s;const e=this._positionFromMouseEvent(s,this._element,this._mouseService);if(!e)return;this._isMouseOut=!1;const i=s.composedPath();for(let a=0;a{a?.forEach((v=>{v.link.dispose&&v.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=s.y);let i=!1;for(const[a,v]of this._linkProviderService.linkProviders.entries())e?this._activeProviderReplies?.get(a)&&(i=this._checkLinkProviderResult(a,s,i)):v.provideLinks(s.y,(u=>{if(this._isMouseOut)return;const p=u?.map((c=>({link:c})));this._activeProviderReplies?.set(a,p),i=this._checkLinkProviderResult(a,s,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(s.y,this._activeProviderReplies)}))}_removeIntersectingLinks(s,e){const i=new Set;for(let a=0;as?this._bufferService.cols:p.link.range.end.x;for(let E=c;E<=S;E++){if(i.has(E)){v.splice(u--,1);break}i.add(E)}}}}_checkLinkProviderResult(s,e,i){if(!this._activeProviderReplies)return i;const a=this._activeProviderReplies.get(s);let v=!1;for(let u=0;uthis._linkAtPosition(p.link,e)));u&&(i=!0,this._handleNewLink(u))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let u=0;uthis._linkAtPosition(c.link,e)));if(p){i=!0,this._handleNewLink(p);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(s){if(!this._currentLink)return;const e=this._positionFromMouseEvent(s,this._element,this._mouseService);e&&this._mouseDownLink===this._currentLink&&this._linkAtPosition(this._currentLink.link,e)&&this._currentLink.link.activate(s,this._currentLink.link.text)}_clearCurrentLink(s,e){this._currentLink&&this._lastMouseEvent&&(!s||!e||this._currentLink.link.range.start.y>=s&&this._currentLink.link.range.end.y<=e)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,f.disposeArray)(this._linkCacheDisposables))}_handleNewLink(s){if(!this._lastMouseEvent)return;const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._linkAtPosition(s.link,e)&&(this._currentLink=s,this._currentLink.state={decorations:{underline:s.link.decorations===void 0||s.link.decorations.underline,pointerCursor:s.link.decorations===void 0||s.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,s.link,this._lastMouseEvent),s.link.decorations={},Object.defineProperties(s.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:i=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==i&&(this._currentLink.state.decorations.pointerCursor=i,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",i))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:i=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==i&&(this._currentLink.state.decorations.underline=i,this._currentLink.state.isHovered&&this._fireUnderlineEvent(s.link,i))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((i=>{if(!this._currentLink)return;const a=i.start===0?0:i.start+1+this._bufferService.buffer.ydisp,v=this._bufferService.buffer.ydisp+1+i.end;if(this._currentLink.link.range.start.y>=a&&this._currentLink.link.range.end.y<=v&&(this._clearCurrentLink(a,v),this._lastMouseEvent)){const u=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);u&&this._askForLink(u,!1)}}))))}_linkHover(s,e,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(e,!0),this._currentLink.state.decorations.pointerCursor&&s.classList.add("xterm-cursor-pointer")),e.hover&&e.hover(i,e.text)}_fireUnderlineEvent(s,e){const i=s.range,a=this._bufferService.buffer.ydisp,v=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-a-1,i.end.x,i.end.y-a-1,void 0);(e?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(v)}_linkLeave(s,e,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(e,!1),this._currentLink.state.decorations.pointerCursor&&s.classList.remove("xterm-cursor-pointer")),e.leave&&e.leave(i,e.text)}_linkAtPosition(s,e){const i=s.range.start.y*this._bufferService.cols+s.range.start.x,a=s.range.end.y*this._bufferService.cols+s.range.end.x,v=e.y*this._bufferService.cols+e.x;return i<=v&&v<=a}_positionFromMouseEvent(s,e,i){const a=i.getCoords(s,e,this._bufferService.cols,this._bufferService.rows);if(a)return{x:a[0],y:a[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(s,e,i,a,v){return{x1:s,y1:e,x2:i,y2:a,cols:this._bufferService.cols,fg:v}}};r.Linkifier=t=l([_(1,h.IMouseService),_(2,h.IRenderService),_(3,g.IBufferService),_(4,h.ILinkProviderService)],t)},9042:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.tooMuchOutput=r.promptLabel=void 0,r.promptLabel="Terminal input",r.tooMuchOutput="Too much output to announce, navigate to rows manually to read"},3730:function(x,r,o){var l=this&&this.__decorate||function(h,t,s,e){var i,a=arguments.length,v=a<3?t:e===null?e=Object.getOwnPropertyDescriptor(t,s):e;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")v=Reflect.decorate(h,t,s,e);else for(var u=h.length-1;u>=0;u--)(i=h[u])&&(v=(a<3?i(v):a>3?i(t,s,v):i(t,s))||v);return a>3&&v&&Object.defineProperty(t,s,v),v},_=this&&this.__param||function(h,t){return function(s,e){t(s,e,h)}};Object.defineProperty(r,"__esModule",{value:!0}),r.OscLinkProvider=void 0;const n=o(511),d=o(2585);let f=r.OscLinkProvider=class{constructor(h,t,s){this._bufferService=h,this._optionsService=t,this._oscLinkService=s}provideLinks(h,t){const s=this._bufferService.buffer.lines.get(h-1);if(!s)return void t(void 0);const e=[],i=this._optionsService.rawOptions.linkHandler,a=new n.CellData,v=s.getTrimmedLength();let u=-1,p=-1,c=!1;for(let S=0;Si?i.activate(b,A,k):g(0,A),hover:(b,A)=>i?.hover?.(b,A,k),leave:(b,A)=>i?.leave?.(b,A,k)})}c=!1,a.hasExtendedAttrs()&&a.extended.urlId?(p=S,u=a.extended.urlId):(p=-1,u=-1)}}t(e)}};function g(h,t){if(confirm(`Do you want to navigate to ${t}? + +WARNING: This link could potentially be dangerous`)){const s=window.open();if(s){try{s.opener=null}catch{}s.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}r.OscLinkProvider=f=l([_(0,d.IBufferService),_(1,d.IOptionsService),_(2,d.IOscLinkService)],f)},6193:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.RenderDebouncer=void 0,r.RenderDebouncer=class{constructor(o,l){this._renderCallback=o,this._coreBrowserService=l,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(o){return this._refreshCallbacks.push(o),this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(o,l,_){this._rowCount=_,o=o!==void 0?o:0,l=l!==void 0?l:this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,o):o,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,l):l,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0)return void this._runRefreshCallbacks();const o=Math.max(this._rowStart,0),l=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(o,l),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const o of this._refreshCallbacks)o(0);this._refreshCallbacks=[]}}},3236:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.Terminal=void 0;const l=o(3614),_=o(3656),n=o(3551),d=o(9042),f=o(3730),g=o(1680),h=o(3107),t=o(5744),s=o(2950),e=o(1296),i=o(428),a=o(4269),v=o(5114),u=o(8934),p=o(3230),c=o(9312),S=o(4725),E=o(6731),k=o(8055),L=o(8969),b=o(8460),A=o(844),M=o(6114),B=o(8437),H=o(2584),I=o(7399),m=o(5941),w=o(9074),y=o(2585),D=o(5435),O=o(4567),F=o(779);class $ extends L.CoreTerminal{get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(R={}){super(R),this.browser=M,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this.register(new A.MutableDisposable),this._onCursorMove=this.register(new b.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this.register(new b.EventEmitter),this.onKey=this._onKey.event,this._onRender=this.register(new b.EventEmitter),this.onRender=this._onRender.event,this._onSelectionChange=this.register(new b.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this.register(new b.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this.register(new b.EventEmitter),this.onBell=this._onBell.event,this._onFocus=this.register(new b.EventEmitter),this._onBlur=this.register(new b.EventEmitter),this._onA11yCharEmitter=this.register(new b.EventEmitter),this._onA11yTabEmitter=this.register(new b.EventEmitter),this._onWillOpen=this.register(new b.EventEmitter),this._setup(),this._decorationService=this._instantiationService.createInstance(w.DecorationService),this._instantiationService.setService(y.IDecorationService,this._decorationService),this._linkProviderService=this._instantiationService.createInstance(F.LinkProviderService),this._instantiationService.setService(S.ILinkProviderService,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(f.OscLinkProvider)),this.register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this.register(this._inputHandler.onRequestRefreshRows(((C,T)=>this.refresh(C,T)))),this.register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this.register(this._inputHandler.onRequestReset((()=>this.reset()))),this.register(this._inputHandler.onRequestWindowsOptionsReport((C=>this._reportWindowsOptions(C)))),this.register(this._inputHandler.onColor((C=>this._handleColorEvent(C)))),this.register((0,b.forwardEvent)(this._inputHandler.onCursorMove,this._onCursorMove)),this.register((0,b.forwardEvent)(this._inputHandler.onTitleChange,this._onTitleChange)),this.register((0,b.forwardEvent)(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this.register((0,b.forwardEvent)(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this.register(this._bufferService.onResize((C=>this._afterResize(C.cols,C.rows)))),this.register((0,A.toDisposable)((()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)})))}_handleColorEvent(R){if(this._themeService)for(const C of R){let T,P="";switch(C.index){case 256:T="foreground",P="10";break;case 257:T="background",P="11";break;case 258:T="cursor",P="12";break;default:T="ansi",P="4;"+C.index}switch(C.type){case 0:const U=k.color.toColorRGB(T==="ansi"?this._themeService.colors.ansi[C.index]:this._themeService.colors[T]);this.coreService.triggerDataEvent(`${H.C0.ESC}]${P};${(0,m.toRgbString)(U)}${H.C1_ESCAPED.ST}`);break;case 1:if(T==="ansi")this._themeService.modifyColors((W=>W.ansi[C.index]=k.channels.toColor(...C.color)));else{const W=T;this._themeService.modifyColors((J=>J[W]=k.channels.toColor(...C.color)))}break;case 2:this._themeService.restoreColor(C.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(R){R?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(O.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(R){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(H.C0.ESC+"[I"),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(H.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const R=this.buffer.ybase+this.buffer.y,C=this.buffer.lines.get(R);if(!C)return;const T=Math.min(this.buffer.x,this.cols-1),P=this._renderService.dimensions.css.cell.height,U=C.getWidth(T),W=this._renderService.dimensions.css.cell.width*U,J=this.buffer.y*this._renderService.dimensions.css.cell.height,Q=T*this._renderService.dimensions.css.cell.width;this.textarea.style.left=Q+"px",this.textarea.style.top=J+"px",this.textarea.style.width=W+"px",this.textarea.style.height=P+"px",this.textarea.style.lineHeight=P+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this.register((0,_.addDisposableDomListener)(this.element,"copy",(C=>{this.hasSelection()&&(0,l.copyHandler)(C,this._selectionService)})));const R=C=>(0,l.handlePasteEvent)(C,this.textarea,this.coreService,this.optionsService);this.register((0,_.addDisposableDomListener)(this.textarea,"paste",R)),this.register((0,_.addDisposableDomListener)(this.element,"paste",R)),M.isFirefox?this.register((0,_.addDisposableDomListener)(this.element,"mousedown",(C=>{C.button===2&&(0,l.rightClickHandler)(C,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this.register((0,_.addDisposableDomListener)(this.element,"contextmenu",(C=>{(0,l.rightClickHandler)(C,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),M.isLinux&&this.register((0,_.addDisposableDomListener)(this.element,"auxclick",(C=>{C.button===1&&(0,l.moveTextAreaUnderMouseCursor)(C,this.textarea,this.screenElement)})))}_bindKeys(){this.register((0,_.addDisposableDomListener)(this.textarea,"keyup",(R=>this._keyUp(R)),!0)),this.register((0,_.addDisposableDomListener)(this.textarea,"keydown",(R=>this._keyDown(R)),!0)),this.register((0,_.addDisposableDomListener)(this.textarea,"keypress",(R=>this._keyPress(R)),!0)),this.register((0,_.addDisposableDomListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this.register((0,_.addDisposableDomListener)(this.textarea,"compositionupdate",(R=>this._compositionHelper.compositionupdate(R)))),this.register((0,_.addDisposableDomListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this.register((0,_.addDisposableDomListener)(this.textarea,"input",(R=>this._inputEvent(R)),!0)),this.register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(R){if(!R)throw new Error("Terminal requires a parent element.");if(R.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=R.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),R.appendChild(this.element);const C=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),C.appendChild(this._viewportElement),this._viewportScrollArea=this._document.createElement("div"),this._viewportScrollArea.classList.add("xterm-scroll-area"),this._viewportElement.appendChild(this._viewportScrollArea),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this.register((0,_.addDisposableDomListener)(this.screenElement,"mousemove",(T=>this.updateCursorStyle(T)))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),C.appendChild(this.screenElement),this.textarea=this._document.createElement("textarea"),this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",d.promptLabel),M.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._coreBrowserService=this.register(this._instantiationService.createInstance(v.CoreBrowserService,this.textarea,R.ownerDocument.defaultView??window,this._document??typeof window<"u"?window.document:null)),this._instantiationService.setService(S.ICoreBrowserService,this._coreBrowserService),this.register((0,_.addDisposableDomListener)(this.textarea,"focus",(T=>this._handleTextAreaFocus(T)))),this.register((0,_.addDisposableDomListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(i.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(S.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(E.ThemeService),this._instantiationService.setService(S.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(a.CharacterJoinerService),this._instantiationService.setService(S.ICharacterJoinerService,this._characterJoinerService),this._renderService=this.register(this._instantiationService.createInstance(p.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(S.IRenderService,this._renderService),this.register(this._renderService.onRenderedViewportChange((T=>this._onRender.fire(T)))),this.onResize((T=>this._renderService.resize(T.cols,T.rows))),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(s.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseService=this._instantiationService.createInstance(u.MouseService),this._instantiationService.setService(S.IMouseService,this._mouseService),this.linkifier=this.register(this._instantiationService.createInstance(n.Linkifier,this.screenElement)),this.element.appendChild(C);try{this._onWillOpen.fire(this.element)}catch{}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this.viewport=this._instantiationService.createInstance(g.Viewport,this._viewportElement,this._viewportScrollArea),this.viewport.onRequestScrollLines((T=>this.scrollLines(T.amount,T.suppressScrollEvent,1))),this.register(this._inputHandler.onRequestSyncScrollBar((()=>this.viewport.syncScrollArea()))),this.register(this.viewport),this.register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this.register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this.register(this.onBlur((()=>this._renderService.handleBlur()))),this.register(this.onFocus((()=>this._renderService.handleFocus()))),this.register(this._renderService.onDimensionsChange((()=>this.viewport.syncScrollArea()))),this._selectionService=this.register(this._instantiationService.createInstance(c.SelectionService,this.element,this.screenElement,this.linkifier)),this._instantiationService.setService(S.ISelectionService,this._selectionService),this.register(this._selectionService.onRequestScrollLines((T=>this.scrollLines(T.amount,T.suppressScrollEvent)))),this.register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this.register(this._selectionService.onRequestRedraw((T=>this._renderService.handleSelectionChanged(T.start,T.end,T.columnSelectMode)))),this.register(this._selectionService.onLinuxMouseSelection((T=>{this.textarea.value=T,this.textarea.focus(),this.textarea.select()}))),this.register(this._onScroll.event((T=>{this.viewport.syncScrollArea(),this._selectionService.refresh()}))),this.register((0,_.addDisposableDomListener)(this._viewportElement,"scroll",(()=>this._selectionService.refresh()))),this.register(this._instantiationService.createInstance(h.BufferDecorationRenderer,this.screenElement)),this.register((0,_.addDisposableDomListener)(this.element,"mousedown",(T=>this._selectionService.handleMouseDown(T)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(O.AccessibilityManager,this)),this.register(this.optionsService.onSpecificOptionChange("screenReaderMode",(T=>this._handleScreenReaderModeOptionChange(T)))),this.options.overviewRulerWidth&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(t.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRulerWidth",(T=>{!this._overviewRulerRenderer&&T&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(t.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(e.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}bindMouse(){const R=this,C=this.element;function T(W){const J=R._mouseService.getMouseReportCoords(W,R.screenElement);if(!J)return!1;let Q,ee;switch(W.overrideType||W.type){case"mousemove":ee=32,W.buttons===void 0?(Q=3,W.button!==void 0&&(Q=W.button<3?W.button:3)):Q=1&W.buttons?0:4&W.buttons?1:2&W.buttons?2:3;break;case"mouseup":ee=0,Q=W.button<3?W.button:3;break;case"mousedown":ee=1,Q=W.button<3?W.button:3;break;case"wheel":if(R._customWheelEventHandler&&R._customWheelEventHandler(W)===!1||R.viewport.getLinesScrolled(W)===0)return!1;ee=W.deltaY<0?0:1,Q=4;break;default:return!1}return!(ee===void 0||Q===void 0||Q>4)&&R.coreMouseService.triggerMouseEvent({col:J.col,row:J.row,x:J.x,y:J.y,button:Q,action:ee,ctrl:W.ctrlKey,alt:W.altKey,shift:W.shiftKey})}const P={mouseup:null,wheel:null,mousedrag:null,mousemove:null},U={mouseup:W=>(T(W),W.buttons||(this._document.removeEventListener("mouseup",P.mouseup),P.mousedrag&&this._document.removeEventListener("mousemove",P.mousedrag)),this.cancel(W)),wheel:W=>(T(W),this.cancel(W,!0)),mousedrag:W=>{W.buttons&&T(W)},mousemove:W=>{W.buttons||T(W)}};this.register(this.coreMouseService.onProtocolChange((W=>{W?(this.optionsService.rawOptions.logLevel==="debug"&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(W)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&W?P.mousemove||(C.addEventListener("mousemove",U.mousemove),P.mousemove=U.mousemove):(C.removeEventListener("mousemove",P.mousemove),P.mousemove=null),16&W?P.wheel||(C.addEventListener("wheel",U.wheel,{passive:!1}),P.wheel=U.wheel):(C.removeEventListener("wheel",P.wheel),P.wheel=null),2&W?P.mouseup||(P.mouseup=U.mouseup):(this._document.removeEventListener("mouseup",P.mouseup),P.mouseup=null),4&W?P.mousedrag||(P.mousedrag=U.mousedrag):(this._document.removeEventListener("mousemove",P.mousedrag),P.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this.register((0,_.addDisposableDomListener)(C,"mousedown",(W=>{if(W.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(W))return T(W),P.mouseup&&this._document.addEventListener("mouseup",P.mouseup),P.mousedrag&&this._document.addEventListener("mousemove",P.mousedrag),this.cancel(W)}))),this.register((0,_.addDisposableDomListener)(C,"wheel",(W=>{if(!P.wheel){if(this._customWheelEventHandler&&this._customWheelEventHandler(W)===!1)return!1;if(!this.buffer.hasScrollback){const J=this.viewport.getLinesScrolled(W);if(J===0)return;const Q=H.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(W.deltaY<0?"A":"B");let ee="";for(let ie=0;ie{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchStart(W),this.cancel(W)}),{passive:!0})),this.register((0,_.addDisposableDomListener)(C,"touchmove",(W=>{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchMove(W)?void 0:this.cancel(W)}),{passive:!1}))}refresh(R,C){this._renderService?.refreshRows(R,C)}updateCursorStyle(R){this._selectionService?.shouldColumnSelect(R)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(R,C,T=0){T===1?(super.scrollLines(R,C,T),this.refresh(0,this.rows-1)):this.viewport?.scrollLines(R)}paste(R){(0,l.paste)(R,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(R){this._customKeyEventHandler=R}attachCustomWheelEventHandler(R){this._customWheelEventHandler=R}registerLinkProvider(R){return this._linkProviderService.registerLinkProvider(R)}registerCharacterJoiner(R){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const C=this._characterJoinerService.register(R);return this.refresh(0,this.rows-1),C}deregisterCharacterJoiner(R){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(R)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(R){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+R)}registerDecoration(R){return this._decorationService.registerDecoration(R)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(R,C,T){this._selectionService.setSelection(R,C,T)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(R,C){this._selectionService?.selectLines(R,C)}_keyDown(R){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&this._customKeyEventHandler(R)===!1)return!1;const C=this.browser.isMac&&this.options.macOptionIsMeta&&R.altKey;if(!C&&!this._compositionHelper.keydown(R))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(),!1;C||R.key!=="Dead"&&R.key!=="AltGraph"||(this._unprocessedDeadKey=!0);const T=(0,I.evaluateKeyboardEvent)(R,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(R),T.type===3||T.type===2){const P=this.rows-1;return this.scrollLines(T.type===2?-P:P),this.cancel(R,!0)}return T.type===1&&this.selectAll(),!!this._isThirdLevelShift(this.browser,R)||(T.cancel&&this.cancel(R,!0),!T.key||!!(R.key&&!R.ctrlKey&&!R.altKey&&!R.metaKey&&R.key.length===1&&R.key.charCodeAt(0)>=65&&R.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(T.key!==H.C0.ETX&&T.key!==H.C0.CR||(this.textarea.value=""),this._onKey.fire({key:T.key,domEvent:R}),this._showCursor(),this.coreService.triggerDataEvent(T.key,!0),!this.optionsService.rawOptions.screenReaderMode||R.altKey||R.ctrlKey?this.cancel(R,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(R,C){const T=R.isMac&&!this.options.macOptionIsMeta&&C.altKey&&!C.ctrlKey&&!C.metaKey||R.isWindows&&C.altKey&&C.ctrlKey&&!C.metaKey||R.isWindows&&C.getModifierState("AltGraph");return C.type==="keypress"?T:T&&(!C.keyCode||C.keyCode>47)}_keyUp(R){this._keyDownSeen=!1,this._customKeyEventHandler&&this._customKeyEventHandler(R)===!1||((function(C){return C.keyCode===16||C.keyCode===17||C.keyCode===18})(R)||this.focus(),this.updateCursorStyle(R),this._keyPressHandled=!1)}_keyPress(R){let C;if(this._keyPressHandled=!1,this._keyDownHandled||this._customKeyEventHandler&&this._customKeyEventHandler(R)===!1)return!1;if(this.cancel(R),R.charCode)C=R.charCode;else if(R.which===null||R.which===void 0)C=R.keyCode;else{if(R.which===0||R.charCode===0)return!1;C=R.which}return!(!C||(R.altKey||R.ctrlKey||R.metaKey)&&!this._isThirdLevelShift(this.browser,R)||(C=String.fromCharCode(C),this._onKey.fire({key:C,domEvent:R}),this._showCursor(),this.coreService.triggerDataEvent(C,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(R){if(R.data&&R.inputType==="insertText"&&(!R.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const C=R.data;return this.coreService.triggerDataEvent(C,!0),this.cancel(R),!0}return!1}resize(R,C){R!==this.cols||C!==this.rows?super.resize(R,C):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(R,C){this._charSizeService?.measure(),this.viewport?.syncScrollArea(!0)}clear(){if(this.buffer.ybase!==0||this.buffer.y!==0){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let R=1;R{Object.defineProperty(r,"__esModule",{value:!0}),r.TimeBasedDebouncer=void 0,r.TimeBasedDebouncer=class{constructor(o,l=1e3){this._renderCallback=o,this._debounceThresholdMS=l,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(o,l,_){this._rowCount=_,o=o!==void 0?o:0,l=l!==void 0?l:this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,o):o,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,l):l;const n=Date.now();if(n-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=n,this._innerRefresh();else if(!this._additionalRefreshRequested){const d=n-this._lastRefreshMs,f=this._debounceThresholdMS-d;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=Date.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),f)}}_innerRefresh(){if(this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0)return;const o=Math.max(this._rowStart,0),l=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(o,l)}}},1680:function(x,r,o){var l=this&&this.__decorate||function(s,e,i,a){var v,u=arguments.length,p=u<3?e:a===null?a=Object.getOwnPropertyDescriptor(e,i):a;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")p=Reflect.decorate(s,e,i,a);else for(var c=s.length-1;c>=0;c--)(v=s[c])&&(p=(u<3?v(p):u>3?v(e,i,p):v(e,i))||p);return u>3&&p&&Object.defineProperty(e,i,p),p},_=this&&this.__param||function(s,e){return function(i,a){e(i,a,s)}};Object.defineProperty(r,"__esModule",{value:!0}),r.Viewport=void 0;const n=o(3656),d=o(4725),f=o(8460),g=o(844),h=o(2585);let t=r.Viewport=class extends g.Disposable{constructor(s,e,i,a,v,u,p,c){super(),this._viewportElement=s,this._scrollArea=e,this._bufferService=i,this._optionsService=a,this._charSizeService=v,this._renderService=u,this._coreBrowserService=p,this.scrollBarWidth=0,this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._wheelPartialScroll=0,this._refreshAnimationFrame=null,this._ignoreNextScrollEvent=!1,this._smoothScrollState={startTime:0,origin:-1,target:-1},this._onRequestScrollLines=this.register(new f.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this.scrollBarWidth=this._viewportElement.offsetWidth-this._scrollArea.offsetWidth||15,this.register((0,n.addDisposableDomListener)(this._viewportElement,"scroll",this._handleScroll.bind(this))),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((S=>this._activeBuffer=S.activeBuffer))),this._renderDimensions=this._renderService.dimensions,this.register(this._renderService.onDimensionsChange((S=>this._renderDimensions=S))),this._handleThemeChange(c.colors),this.register(c.onChangeColors((S=>this._handleThemeChange(S)))),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.syncScrollArea()))),setTimeout((()=>this.syncScrollArea()))}_handleThemeChange(s){this._viewportElement.style.backgroundColor=s.background.css}reset(){this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._coreBrowserService.window.requestAnimationFrame((()=>this.syncScrollArea()))}_refresh(s){if(s)return this._innerRefresh(),void(this._refreshAnimationFrame!==null&&this._coreBrowserService.window.cancelAnimationFrame(this._refreshAnimationFrame));this._refreshAnimationFrame===null&&(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._charSizeService.height>0){this._currentRowHeight=this._renderDimensions.device.cell.height/this._coreBrowserService.dpr,this._currentDeviceCellHeight=this._renderDimensions.device.cell.height,this._lastRecordedViewportHeight=this._viewportElement.offsetHeight;const e=Math.round(this._currentRowHeight*this._lastRecordedBufferLength)+(this._lastRecordedViewportHeight-this._renderDimensions.css.canvas.height);this._lastRecordedBufferHeight!==e&&(this._lastRecordedBufferHeight=e,this._scrollArea.style.height=this._lastRecordedBufferHeight+"px")}const s=this._bufferService.buffer.ydisp*this._currentRowHeight;this._viewportElement.scrollTop!==s&&(this._ignoreNextScrollEvent=!0,this._viewportElement.scrollTop=s),this._refreshAnimationFrame=null}syncScrollArea(s=!1){if(this._lastRecordedBufferLength!==this._bufferService.buffer.lines.length)return this._lastRecordedBufferLength=this._bufferService.buffer.lines.length,void this._refresh(s);this._lastRecordedViewportHeight===this._renderService.dimensions.css.canvas.height&&this._lastScrollTop===this._activeBuffer.ydisp*this._currentRowHeight&&this._renderDimensions.device.cell.height===this._currentDeviceCellHeight||this._refresh(s)}_handleScroll(s){if(this._lastScrollTop=this._viewportElement.scrollTop,!this._viewportElement.offsetParent)return;if(this._ignoreNextScrollEvent)return this._ignoreNextScrollEvent=!1,void this._onRequestScrollLines.fire({amount:0,suppressScrollEvent:!0});const e=Math.round(this._lastScrollTop/this._currentRowHeight)-this._bufferService.buffer.ydisp;this._onRequestScrollLines.fire({amount:e,suppressScrollEvent:!0})}_smoothScroll(){if(this._isDisposed||this._smoothScrollState.origin===-1||this._smoothScrollState.target===-1)return;const s=this._smoothScrollPercent();this._viewportElement.scrollTop=this._smoothScrollState.origin+Math.round(s*(this._smoothScrollState.target-this._smoothScrollState.origin)),s<1?this._coreBrowserService.window.requestAnimationFrame((()=>this._smoothScroll())):this._clearSmoothScrollState()}_smoothScrollPercent(){return this._optionsService.rawOptions.smoothScrollDuration&&this._smoothScrollState.startTime?Math.max(Math.min((Date.now()-this._smoothScrollState.startTime)/this._optionsService.rawOptions.smoothScrollDuration,1),0):1}_clearSmoothScrollState(){this._smoothScrollState.startTime=0,this._smoothScrollState.origin=-1,this._smoothScrollState.target=-1}_bubbleScroll(s,e){const i=this._viewportElement.scrollTop+this._lastRecordedViewportHeight;return!(e<0&&this._viewportElement.scrollTop!==0||e>0&&i0&&(i=k),a=""}}return{bufferElements:v,cursorElement:i}}getLinesScrolled(s){if(s.deltaY===0||s.shiftKey)return 0;let e=this._applyScrollModifier(s.deltaY,s);return s.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(e/=this._currentRowHeight+0,this._wheelPartialScroll+=e,e=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):s.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(e*=this._bufferService.rows),e}_applyScrollModifier(s,e){const i=this._optionsService.rawOptions.fastScrollModifier;return i==="alt"&&e.altKey||i==="ctrl"&&e.ctrlKey||i==="shift"&&e.shiftKey?s*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:s*this._optionsService.rawOptions.scrollSensitivity}handleTouchStart(s){this._lastTouchY=s.touches[0].pageY}handleTouchMove(s){const e=this._lastTouchY-s.touches[0].pageY;return this._lastTouchY=s.touches[0].pageY,e!==0&&(this._viewportElement.scrollTop+=e,this._bubbleScroll(s,e))}};r.Viewport=t=l([_(2,h.IBufferService),_(3,h.IOptionsService),_(4,d.ICharSizeService),_(5,d.IRenderService),_(6,d.ICoreBrowserService),_(7,d.IThemeService)],t)},3107:function(x,r,o){var l=this&&this.__decorate||function(h,t,s,e){var i,a=arguments.length,v=a<3?t:e===null?e=Object.getOwnPropertyDescriptor(t,s):e;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")v=Reflect.decorate(h,t,s,e);else for(var u=h.length-1;u>=0;u--)(i=h[u])&&(v=(a<3?i(v):a>3?i(t,s,v):i(t,s))||v);return a>3&&v&&Object.defineProperty(t,s,v),v},_=this&&this.__param||function(h,t){return function(s,e){t(s,e,h)}};Object.defineProperty(r,"__esModule",{value:!0}),r.BufferDecorationRenderer=void 0;const n=o(4725),d=o(844),f=o(2585);let g=r.BufferDecorationRenderer=class extends d.Disposable{constructor(h,t,s,e,i){super(),this._screenElement=h,this._bufferService=t,this._coreBrowserService=s,this._decorationService=e,this._renderService=i,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this.register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this.register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this.register(this._coreBrowserService.onDprChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this.register(this._decorationService.onDecorationRemoved((a=>this._removeDecoration(a)))),this.register((0,d.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){this._animationFrame===void 0&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const h of this._decorationService.decorations)this._renderDecoration(h);this._dimensionsChanged=!1}_renderDecoration(h){this._refreshStyle(h),this._dimensionsChanged&&this._refreshXPosition(h)}_createElement(h){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer",h?.options?.layer==="top"),t.style.width=`${Math.round((h.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(h.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(h.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const s=h.options.x??0;return s&&s>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(h,t),t}_refreshStyle(h){const t=h.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)h.element&&(h.element.style.display="none",h.onRenderEmitter.fire(h.element));else{let s=this._decorationElements.get(h);s||(s=this._createElement(h),h.element=s,this._decorationElements.set(h,s),this._container.appendChild(s),h.onDispose((()=>{this._decorationElements.delete(h),s.remove()}))),s.style.top=t*this._renderService.dimensions.css.cell.height+"px",s.style.display=this._altBufferIsActive?"none":"block",h.onRenderEmitter.fire(s)}}_refreshXPosition(h,t=h.element){if(!t)return;const s=h.options.x??0;(h.options.anchor||"left")==="right"?t.style.right=s?s*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=s?s*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(h){this._decorationElements.get(h)?.remove(),this._decorationElements.delete(h),h.dispose()}};r.BufferDecorationRenderer=g=l([_(1,f.IBufferService),_(2,n.ICoreBrowserService),_(3,f.IDecorationService),_(4,n.IRenderService)],g)},5871:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.ColorZoneStore=void 0,r.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(o){if(o.options.overviewRulerOptions){for(const l of this._zones)if(l.color===o.options.overviewRulerOptions.color&&l.position===o.options.overviewRulerOptions.position){if(this._lineIntersectsZone(l,o.marker.line))return;if(this._lineAdjacentToZone(l,o.marker.line,o.options.overviewRulerOptions.position))return void this._addLineToZone(l,o.marker.line)}if(this._zonePoolIndex=o.startBufferLine&&l<=o.endBufferLine}_lineAdjacentToZone(o,l,_){return l>=o.startBufferLine-this._linePadding[_||"full"]&&l<=o.endBufferLine+this._linePadding[_||"full"]}_addLineToZone(o,l){o.startBufferLine=Math.min(o.startBufferLine,l),o.endBufferLine=Math.max(o.endBufferLine,l)}}},5744:function(x,r,o){var l=this&&this.__decorate||function(i,a,v,u){var p,c=arguments.length,S=c<3?a:u===null?u=Object.getOwnPropertyDescriptor(a,v):u;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")S=Reflect.decorate(i,a,v,u);else for(var E=i.length-1;E>=0;E--)(p=i[E])&&(S=(c<3?p(S):c>3?p(a,v,S):p(a,v))||S);return c>3&&S&&Object.defineProperty(a,v,S),S},_=this&&this.__param||function(i,a){return function(v,u){a(v,u,i)}};Object.defineProperty(r,"__esModule",{value:!0}),r.OverviewRulerRenderer=void 0;const n=o(5871),d=o(4725),f=o(844),g=o(2585),h={full:0,left:0,center:0,right:0},t={full:0,left:0,center:0,right:0},s={full:0,left:0,center:0,right:0};let e=r.OverviewRulerRenderer=class extends f.Disposable{get _width(){return this._optionsService.options.overviewRulerWidth||0}constructor(i,a,v,u,p,c,S){super(),this._viewportElement=i,this._screenElement=a,this._bufferService=v,this._decorationService=u,this._renderService=p,this._optionsService=c,this._coreBrowserService=S,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement);const E=this._canvas.getContext("2d");if(!E)throw new Error("Ctx cannot be null");this._ctx=E,this._registerDecorationListeners(),this._registerBufferChangeListeners(),this._registerDimensionChangeListeners(),this.register((0,f.toDisposable)((()=>{this._canvas?.remove()})))}_registerDecorationListeners(){this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this.register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0))))}_registerBufferChangeListeners(){this.register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this.register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})))}_registerDimensionChangeListeners(){this.register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this.register(this._optionsService.onSpecificOptionChange("overviewRulerWidth",(()=>this._queueRefresh(!0)))),this.register(this._coreBrowserService.onDprChange((()=>this._queueRefresh(!0)))),this._queueRefresh(!0)}_refreshDrawConstants(){const i=Math.floor(this._canvas.width/3),a=Math.ceil(this._canvas.width/3);t.full=this._canvas.width,t.left=i,t.center=a,t.right=i,this._refreshDrawHeightConstants(),s.full=0,s.left=0,s.center=t.left,s.right=t.left+t.center}_refreshDrawHeightConstants(){h.full=Math.round(2*this._coreBrowserService.dpr);const i=this._canvas.height/this._bufferService.buffer.lines.length,a=Math.round(Math.max(Math.min(i,12),6)*this._coreBrowserService.dpr);h.left=a,h.center=a,h.right=a}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*h.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowserService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const a of this._decorationService.decorations)this._colorZoneStore.addDecoration(a);this._ctx.lineWidth=1;const i=this._colorZoneStore.zones;for(const a of i)a.position!=="full"&&this._renderColorZone(a);for(const a of i)a.position==="full"&&this._renderColorZone(a);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderColorZone(i){this._ctx.fillStyle=i.color,this._ctx.fillRect(s[i.position||"full"],Math.round((this._canvas.height-1)*(i.startBufferLine/this._bufferService.buffers.active.lines.length)-h[i.position||"full"]/2),t[i.position||"full"],Math.round((this._canvas.height-1)*((i.endBufferLine-i.startBufferLine)/this._bufferService.buffers.active.lines.length)+h[i.position||"full"]))}_queueRefresh(i,a){this._shouldUpdateDimensions=i||this._shouldUpdateDimensions,this._shouldUpdateAnchor=a||this._shouldUpdateAnchor,this._animationFrame===void 0&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};r.OverviewRulerRenderer=e=l([_(2,g.IBufferService),_(3,g.IDecorationService),_(4,d.IRenderService),_(5,g.IOptionsService),_(6,d.ICoreBrowserService)],e)},2950:function(x,r,o){var l=this&&this.__decorate||function(h,t,s,e){var i,a=arguments.length,v=a<3?t:e===null?e=Object.getOwnPropertyDescriptor(t,s):e;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")v=Reflect.decorate(h,t,s,e);else for(var u=h.length-1;u>=0;u--)(i=h[u])&&(v=(a<3?i(v):a>3?i(t,s,v):i(t,s))||v);return a>3&&v&&Object.defineProperty(t,s,v),v},_=this&&this.__param||function(h,t){return function(s,e){t(s,e,h)}};Object.defineProperty(r,"__esModule",{value:!0}),r.CompositionHelper=void 0;const n=o(4725),d=o(2585),f=o(2584);let g=r.CompositionHelper=class{get isComposing(){return this._isComposing}constructor(h,t,s,e,i,a){this._textarea=h,this._compositionView=t,this._bufferService=s,this._optionsService=e,this._coreService=i,this._renderService=a,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(h){this._compositionView.textContent=h.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(h){if(this._isComposing||this._isSendingComposition){if(h.keyCode===229||h.keyCode===16||h.keyCode===17||h.keyCode===18)return!1;this._finalizeComposition(!1)}return h.keyCode!==229||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(h){if(this._compositionView.classList.remove("active"),this._isComposing=!1,h){const t={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let s;this._isSendingComposition=!1,t.start+=this._dataAlreadySent.length,s=this._isComposing?this._textarea.value.substring(t.start,t.end):this._textarea.value.substring(t.start),s.length>0&&this._coreService.triggerDataEvent(s,!0)}}),0)}else{this._isSendingComposition=!1;const t=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(t,!0)}}_handleAnyTextareaChanges(){const h=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,s=t.replace(h,"");this._dataAlreadySent=s,t.length>h.length?this._coreService.triggerDataEvent(s,!0):t.lengththis.updateCompositionElements(!0)),0)}}};r.CompositionHelper=g=l([_(2,d.IBufferService),_(3,d.IOptionsService),_(4,d.ICoreService),_(5,n.IRenderService)],g)},9806:(x,r)=>{function o(l,_,n){const d=n.getBoundingClientRect(),f=l.getComputedStyle(n),g=parseInt(f.getPropertyValue("padding-left")),h=parseInt(f.getPropertyValue("padding-top"));return[_.clientX-d.left-g,_.clientY-d.top-h]}Object.defineProperty(r,"__esModule",{value:!0}),r.getCoords=r.getCoordsRelativeToElement=void 0,r.getCoordsRelativeToElement=o,r.getCoords=function(l,_,n,d,f,g,h,t,s){if(!g)return;const e=o(l,_,n);return e?(e[0]=Math.ceil((e[0]+(s?h/2:0))/h),e[1]=Math.ceil(e[1]/t),e[0]=Math.min(Math.max(e[0],1),d+(s?1:0)),e[1]=Math.min(Math.max(e[1],1),f),e):void 0}},9504:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.moveToCellSequence=void 0;const l=o(2584);function _(t,s,e,i){const a=t-n(t,e),v=s-n(s,e),u=Math.abs(a-v)-(function(p,c,S){let E=0;const k=p-n(p,S),L=c-n(c,S);for(let b=0;b=0&&ts?"A":"B"}function f(t,s,e,i,a,v){let u=t,p=s,c="";for(;u!==e||p!==i;)u+=a?1:-1,a&&u>v.cols-1?(c+=v.buffer.translateBufferLineToString(p,!1,t,u),u=0,t=0,p++):!a&&u<0&&(c+=v.buffer.translateBufferLineToString(p,!1,0,t+1),u=v.cols-1,t=u,p--);return c+v.buffer.translateBufferLineToString(p,!1,t,u)}function g(t,s){const e=s?"O":"[";return l.C0.ESC+e+t}function h(t,s){t=Math.floor(t);let e="";for(let i=0;i0?k-n(k,L):S;const M=k,B=(function(H,I,m,w,y,D){let O;return O=_(m,w,y,D).length>0?w-n(w,y):I,H=m&&Ot?"D":"C",h(Math.abs(a-t),g(u,i));u=v>s?"D":"C";const p=Math.abs(v-s);return h((function(c,S){return S.cols-c})(v>s?t:a,e)+(p-1)*e.cols+1+((v>s?a:t)-1),g(u,i))}},1296:function(x,r,o){var l=this&&this.__decorate||function(b,A,M,B){var H,I=arguments.length,m=I<3?A:B===null?B=Object.getOwnPropertyDescriptor(A,M):B;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")m=Reflect.decorate(b,A,M,B);else for(var w=b.length-1;w>=0;w--)(H=b[w])&&(m=(I<3?H(m):I>3?H(A,M,m):H(A,M))||m);return I>3&&m&&Object.defineProperty(A,M,m),m},_=this&&this.__param||function(b,A){return function(M,B){A(M,B,b)}};Object.defineProperty(r,"__esModule",{value:!0}),r.DomRenderer=void 0;const n=o(3787),d=o(2550),f=o(2223),g=o(6171),h=o(6052),t=o(4725),s=o(8055),e=o(8460),i=o(844),a=o(2585),v="xterm-dom-renderer-owner-",u="xterm-rows",p="xterm-fg-",c="xterm-bg-",S="xterm-focus",E="xterm-selection";let k=1,L=r.DomRenderer=class extends i.Disposable{constructor(b,A,M,B,H,I,m,w,y,D,O,F,$){super(),this._terminal=b,this._document=A,this._element=M,this._screenElement=B,this._viewportElement=H,this._helperContainer=I,this._linkifier2=m,this._charSizeService=y,this._optionsService=D,this._bufferService=O,this._coreBrowserService=F,this._themeService=$,this._terminalClass=k++,this._rowElements=[],this._selectionRenderModel=(0,h.createSelectionRenderModel)(),this.onRequestRedraw=this.register(new e.EventEmitter).event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add(u),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add(E),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,g.createRenderDimensions)(),this._updateDimensions(),this.register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._themeService.onChangeColors((z=>this._injectCss(z)))),this._injectCss(this._themeService.colors),this._rowFactory=w.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(v+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this.register(this._linkifier2.onShowLinkUnderline((z=>this._handleLinkHover(z)))),this.register(this._linkifier2.onHideLinkUnderline((z=>this._handleLinkLeave(z)))),this.register((0,i.toDisposable)((()=>{this._element.classList.remove(v+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new d.WidthCache(this._document,this._helperContainer),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const b=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*b,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*b),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/b),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/b),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const M of this._rowElements)M.style.width=`${this.dimensions.css.canvas.width}px`,M.style.height=`${this.dimensions.css.cell.height}px`,M.style.lineHeight=`${this.dimensions.css.cell.height}px`,M.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const A=`${this._terminalSelector} .${u} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=A,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(b){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let A=`${this._terminalSelector} .${u} { color: ${b.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;A+=`${this._terminalSelector} .${u} .xterm-dim { color: ${s.color.multiplyOpacity(b.foreground,.5).css};}`,A+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`;const M=`blink_underline_${this._terminalClass}`,B=`blink_bar_${this._terminalClass}`,H=`blink_block_${this._terminalClass}`;A+=`@keyframes ${M} { 50% { border-bottom-style: hidden; }}`,A+=`@keyframes ${B} { 50% { box-shadow: none; }}`,A+=`@keyframes ${H} { 0% { background-color: ${b.cursor.css}; color: ${b.cursorAccent.css}; } 50% { background-color: inherit; color: ${b.cursor.css}; }}`,A+=`${this._terminalSelector} .${u}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${M} 1s step-end infinite;}${this._terminalSelector} .${u}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${B} 1s step-end infinite;}${this._terminalSelector} .${u}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${H} 1s step-end infinite;}${this._terminalSelector} .${u} .xterm-cursor.xterm-cursor-block { background-color: ${b.cursor.css}; color: ${b.cursorAccent.css};}${this._terminalSelector} .${u} .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${b.cursor.css} !important; color: ${b.cursorAccent.css} !important;}${this._terminalSelector} .${u} .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${b.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .${u} .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${b.cursor.css} inset;}${this._terminalSelector} .${u} .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${b.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,A+=`${this._terminalSelector} .${E} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${E} div { position: absolute; background-color: ${b.selectionBackgroundOpaque.css};}${this._terminalSelector} .${E} div { position: absolute; background-color: ${b.selectionInactiveBackgroundOpaque.css};}`;for(const[I,m]of b.ansi.entries())A+=`${this._terminalSelector} .${p}${I} { color: ${m.css}; }${this._terminalSelector} .${p}${I}.xterm-dim { color: ${s.color.multiplyOpacity(m,.5).css}; }${this._terminalSelector} .${c}${I} { background-color: ${m.css}; }`;A+=`${this._terminalSelector} .${p}${f.INVERTED_DEFAULT_COLOR} { color: ${s.color.opaque(b.background).css}; }${this._terminalSelector} .${p}${f.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${s.color.multiplyOpacity(s.color.opaque(b.background),.5).css}; }${this._terminalSelector} .${c}${f.INVERTED_DEFAULT_COLOR} { background-color: ${b.foreground.css}; }`,this._themeStyleElement.textContent=A}_setDefaultSpacing(){const b=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${b}px`,this._rowFactory.defaultSpacing=b}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(b,A){for(let M=this._rowElements.length;M<=A;M++){const B=this._document.createElement("div");this._rowContainer.appendChild(B),this._rowElements.push(B)}for(;this._rowElements.length>A;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(b,A){this._refreshRowElements(b,A),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(S),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add(S),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(b,A,M){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(b,A,M),this.renderRows(0,this._bufferService.rows-1),!b||!A)return;this._selectionRenderModel.update(this._terminal,b,A,M);const B=this._selectionRenderModel.viewportStartRow,H=this._selectionRenderModel.viewportEndRow,I=this._selectionRenderModel.viewportCappedStartRow,m=this._selectionRenderModel.viewportCappedEndRow;if(I>=this._bufferService.rows||m<0)return;const w=this._document.createDocumentFragment();if(M){const y=b[0]>A[0];w.appendChild(this._createSelectionElement(I,y?A[0]:b[0],y?b[0]:A[0],m-I+1))}else{const y=B===I?b[0]:0,D=I===H?A[0]:this._bufferService.cols;w.appendChild(this._createSelectionElement(I,y,D));const O=m-I-1;if(w.appendChild(this._createSelectionElement(I+1,0,this._bufferService.cols,O)),I!==m){const F=H===m?A[0]:this._bufferService.cols;w.appendChild(this._createSelectionElement(m,0,F))}}this._selectionContainer.appendChild(w)}_createSelectionElement(b,A,M,B=1){const H=this._document.createElement("div"),I=A*this.dimensions.css.cell.width;let m=this.dimensions.css.cell.width*(M-A);return I+m>this.dimensions.css.canvas.width&&(m=this.dimensions.css.canvas.width-I),H.style.height=B*this.dimensions.css.cell.height+"px",H.style.top=b*this.dimensions.css.cell.height+"px",H.style.left=`${I}px`,H.style.width=`${m}px`,H}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const b of this._rowElements)b.replaceChildren()}renderRows(b,A){const M=this._bufferService.buffer,B=M.ybase+M.y,H=Math.min(M.x,this._bufferService.cols-1),I=this._optionsService.rawOptions.cursorBlink,m=this._optionsService.rawOptions.cursorStyle,w=this._optionsService.rawOptions.cursorInactiveStyle;for(let y=b;y<=A;y++){const D=y+M.ydisp,O=this._rowElements[y],F=M.lines.get(D);if(!O||!F)break;O.replaceChildren(...this._rowFactory.createRow(F,D,D===B,m,w,H,I,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${v}${this._terminalClass}`}_handleLinkHover(b){this._setCellUnderline(b.x1,b.x2,b.y1,b.y2,b.cols,!0)}_handleLinkLeave(b){this._setCellUnderline(b.x1,b.x2,b.y1,b.y2,b.cols,!1)}_setCellUnderline(b,A,M,B,H,I){M<0&&(b=0),B<0&&(A=0);const m=this._bufferService.rows-1;M=Math.max(Math.min(M,m),0),B=Math.max(Math.min(B,m),0),H=Math.min(H,this._bufferService.cols);const w=this._bufferService.buffer,y=w.ybase+w.y,D=Math.min(w.x,H-1),O=this._optionsService.rawOptions.cursorBlink,F=this._optionsService.rawOptions.cursorStyle,$=this._optionsService.rawOptions.cursorInactiveStyle;for(let z=M;z<=B;++z){const R=z+w.ydisp,C=this._rowElements[z],T=w.lines.get(R);if(!C||!T)break;C.replaceChildren(...this._rowFactory.createRow(T,R,R===y,F,$,D,O,this.dimensions.css.cell.width,this._widthCache,I?z===M?b:0:-1,I?(z===B?A:H)-1:-1))}}};r.DomRenderer=L=l([_(7,a.IInstantiationService),_(8,t.ICharSizeService),_(9,a.IOptionsService),_(10,a.IBufferService),_(11,t.ICoreBrowserService),_(12,t.IThemeService)],L)},3787:function(x,r,o){var l=this&&this.__decorate||function(u,p,c,S){var E,k=arguments.length,L=k<3?p:S===null?S=Object.getOwnPropertyDescriptor(p,c):S;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")L=Reflect.decorate(u,p,c,S);else for(var b=u.length-1;b>=0;b--)(E=u[b])&&(L=(k<3?E(L):k>3?E(p,c,L):E(p,c))||L);return k>3&&L&&Object.defineProperty(p,c,L),L},_=this&&this.__param||function(u,p){return function(c,S){p(c,S,u)}};Object.defineProperty(r,"__esModule",{value:!0}),r.DomRendererRowFactory=void 0;const n=o(2223),d=o(643),f=o(511),g=o(2585),h=o(8055),t=o(4725),s=o(4269),e=o(6171),i=o(3734);let a=r.DomRendererRowFactory=class{constructor(u,p,c,S,E,k,L){this._document=u,this._characterJoinerService=p,this._optionsService=c,this._coreBrowserService=S,this._coreService=E,this._decorationService=k,this._themeService=L,this._workCell=new f.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(u,p,c){this._selectionStart=u,this._selectionEnd=p,this._columnSelectMode=c}createRow(u,p,c,S,E,k,L,b,A,M,B){const H=[],I=this._characterJoinerService.getJoinedCharacters(p),m=this._themeService.colors;let w,y=u.getNoBgTrimmedLength();c&&y0&&J===I[0][0]){ee=!0;const Z=I.shift();K=new s.JoinedCellData(this._workCell,u.translateToString(!0,Z[0],Z[1]),Z[1]-Z[0]),ie=Z[1]-1,Q=K.getWidth()}const ae=this._isCellInSelection(J,p),_e=c&&J===k,fe=W&&J>=M&&J<=B;let ve=!1;this._decorationService.forEachDecorationAtCell(J,p,void 0,(Z=>{ve=!0}));let de=K.getChars()||d.WHITESPACE_CELL_CHAR;if(de===" "&&(K.isUnderline()||K.isOverline())&&(de=" "),P=Q*b-A.get(de,K.isBold(),K.isItalic()),w){if(D&&(ae&&T||!ae&&!T&&K.bg===F)&&(ae&&T&&m.selectionForeground||K.fg===$)&&K.extended.ext===z&&fe===R&&P===C&&!_e&&!ee&&!ve){K.isInvisible()?O+=d.WHITESPACE_CELL_CHAR:O+=de,D++;continue}D&&(w.textContent=O),w=this._document.createElement("span"),D=0,O=""}else w=this._document.createElement("span");if(F=K.bg,$=K.fg,z=K.extended.ext,R=fe,C=P,T=ae,ee&&k>=J&&k<=ie&&(k=J),!this._coreService.isCursorHidden&&_e&&this._coreService.isCursorInitialized){if(U.push("xterm-cursor"),this._coreBrowserService.isFocused)L&&U.push("xterm-cursor-blink"),U.push(S==="bar"?"xterm-cursor-bar":S==="underline"?"xterm-cursor-underline":"xterm-cursor-block");else if(E)switch(E){case"outline":U.push("xterm-cursor-outline");break;case"block":U.push("xterm-cursor-block");break;case"bar":U.push("xterm-cursor-bar");break;case"underline":U.push("xterm-cursor-underline")}}if(K.isBold()&&U.push("xterm-bold"),K.isItalic()&&U.push("xterm-italic"),K.isDim()&&U.push("xterm-dim"),O=K.isInvisible()?d.WHITESPACE_CELL_CHAR:K.getChars()||d.WHITESPACE_CELL_CHAR,K.isUnderline()&&(U.push(`xterm-underline-${K.extended.underlineStyle}`),O===" "&&(O=" "),!K.isUnderlineColorDefault()))if(K.isUnderlineColorRGB())w.style.textDecorationColor=`rgb(${i.AttributeData.toColorRGB(K.getUnderlineColor()).join(",")})`;else{let Z=K.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&K.isBold()&&Z<8&&(Z+=8),w.style.textDecorationColor=m.ansi[Z].css}K.isOverline()&&(U.push("xterm-overline"),O===" "&&(O=" ")),K.isStrikethrough()&&U.push("xterm-strikethrough"),fe&&(w.style.textDecoration="underline");let se=K.getFgColor(),he=K.getFgColorMode(),re=K.getBgColor(),ce=K.getBgColorMode();const pe=!!K.isInverse();if(pe){const Z=se;se=re,re=Z;const Ee=he;he=ce,ce=Ee}let ne,ue,oe,le=!1;switch(this._decorationService.forEachDecorationAtCell(J,p,void 0,(Z=>{Z.options.layer!=="top"&&le||(Z.backgroundColorRGB&&(ce=50331648,re=Z.backgroundColorRGB.rgba>>8&16777215,ne=Z.backgroundColorRGB),Z.foregroundColorRGB&&(he=50331648,se=Z.foregroundColorRGB.rgba>>8&16777215,ue=Z.foregroundColorRGB),le=Z.options.layer==="top")})),!le&&ae&&(ne=this._coreBrowserService.isFocused?m.selectionBackgroundOpaque:m.selectionInactiveBackgroundOpaque,re=ne.rgba>>8&16777215,ce=50331648,le=!0,m.selectionForeground&&(he=50331648,se=m.selectionForeground.rgba>>8&16777215,ue=m.selectionForeground)),le&&U.push("xterm-decoration-top"),ce){case 16777216:case 33554432:oe=m.ansi[re],U.push(`xterm-bg-${re}`);break;case 50331648:oe=h.channels.toColor(re>>16,re>>8&255,255&re),this._addStyle(w,`background-color:#${v((re>>>0).toString(16),"0",6)}`);break;default:pe?(oe=m.foreground,U.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):oe=m.background}switch(ne||K.isDim()&&(ne=h.color.multiplyOpacity(oe,.5)),he){case 16777216:case 33554432:K.isBold()&&se<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(se+=8),this._applyMinimumContrast(w,oe,m.ansi[se],K,ne,void 0)||U.push(`xterm-fg-${se}`);break;case 50331648:const Z=h.channels.toColor(se>>16&255,se>>8&255,255&se);this._applyMinimumContrast(w,oe,Z,K,ne,ue)||this._addStyle(w,`color:#${v(se.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(w,oe,m.foreground,K,ne,ue)||pe&&U.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}U.length&&(w.className=U.join(" "),U.length=0),_e||ee||ve?w.textContent=O:D++,P!==this.defaultSpacing&&(w.style.letterSpacing=`${P}px`),H.push(w),J=ie}return w&&D&&(w.textContent=O),H}_applyMinimumContrast(u,p,c,S,E,k){if(this._optionsService.rawOptions.minimumContrastRatio===1||(0,e.treatGlyphAsBackgroundColor)(S.getCode()))return!1;const L=this._getContrastCache(S);let b;if(E||k||(b=L.getColor(p.rgba,c.rgba)),b===void 0){const A=this._optionsService.rawOptions.minimumContrastRatio/(S.isDim()?2:1);b=h.color.ensureContrastRatio(E||p,k||c,A),L.setColor((E||p).rgba,(k||c).rgba,b??null)}return!!b&&(this._addStyle(u,`color:${b.css}`),!0)}_getContrastCache(u){return u.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(u,p){u.setAttribute("style",`${u.getAttribute("style")||""}${p};`)}_isCellInSelection(u,p){const c=this._selectionStart,S=this._selectionEnd;return!(!c||!S)&&(this._columnSelectMode?c[0]<=S[0]?u>=c[0]&&p>=c[1]&&u=c[1]&&u>=S[0]&&p<=S[1]:p>c[1]&&p=c[0]&&u=c[0])}};function v(u,p,c){for(;u.length{Object.defineProperty(r,"__esModule",{value:!0}),r.WidthCache=void 0,r.WidthCache=class{constructor(o,l){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=o.createElement("div"),this._container.classList.add("xterm-width-cache-measure-container"),this._container.setAttribute("aria-hidden","true"),this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const _=o.createElement("span");_.classList.add("xterm-char-measure-element");const n=o.createElement("span");n.classList.add("xterm-char-measure-element"),n.style.fontWeight="bold";const d=o.createElement("span");d.classList.add("xterm-char-measure-element"),d.style.fontStyle="italic";const f=o.createElement("span");f.classList.add("xterm-char-measure-element"),f.style.fontWeight="bold",f.style.fontStyle="italic",this._measureElements=[_,n,d,f],this._container.appendChild(_),this._container.appendChild(n),this._container.appendChild(d),this._container.appendChild(f),l.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(o,l,_,n){o===this._font&&l===this._fontSize&&_===this._weight&&n===this._weightBold||(this._font=o,this._fontSize=l,this._weight=_,this._weightBold=n,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${_}`,this._measureElements[1].style.fontWeight=`${n}`,this._measureElements[2].style.fontWeight=`${_}`,this._measureElements[3].style.fontWeight=`${n}`,this.clear())}get(o,l,_){let n=0;if(!l&&!_&&o.length===1&&(n=o.charCodeAt(0))<256){if(this._flat[n]!==-9999)return this._flat[n];const g=this._measure(o,0);return g>0&&(this._flat[n]=g),g}let d=o;l&&(d+="B"),_&&(d+="I");let f=this._holey.get(d);if(f===void 0){let g=0;l&&(g|=1),_&&(g|=2),f=this._measure(o,g),f>0&&this._holey.set(d,f)}return f}_measure(o,l){const _=this._measureElements[l];return _.textContent=o.repeat(32),_.offsetWidth/32}}},2223:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.TEXT_BASELINE=r.DIM_OPACITY=r.INVERTED_DEFAULT_COLOR=void 0;const l=o(6114);r.INVERTED_DEFAULT_COLOR=257,r.DIM_OPACITY=.5,r.TEXT_BASELINE=l.isFirefox||l.isLegacyEdge?"bottom":"ideographic"},6171:(x,r)=>{function o(_){return 57508<=_&&_<=57558}function l(_){return _>=128512&&_<=128591||_>=127744&&_<=128511||_>=128640&&_<=128767||_>=9728&&_<=9983||_>=9984&&_<=10175||_>=65024&&_<=65039||_>=129280&&_<=129535||_>=127462&&_<=127487}Object.defineProperty(r,"__esModule",{value:!0}),r.computeNextVariantOffset=r.createRenderDimensions=r.treatGlyphAsBackgroundColor=r.allowRescaling=r.isEmoji=r.isRestrictedPowerlineGlyph=r.isPowerlineGlyph=r.throwIfFalsy=void 0,r.throwIfFalsy=function(_){if(!_)throw new Error("value must not be falsy");return _},r.isPowerlineGlyph=o,r.isRestrictedPowerlineGlyph=function(_){return 57520<=_&&_<=57527},r.isEmoji=l,r.allowRescaling=function(_,n,d,f){return n===1&&d>Math.ceil(1.5*f)&&_!==void 0&&_>255&&!l(_)&&!o(_)&&!(function(g){return 57344<=g&&g<=63743})(_)},r.treatGlyphAsBackgroundColor=function(_){return o(_)||(function(n){return 9472<=n&&n<=9631})(_)},r.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},r.computeNextVariantOffset=function(_,n,d=0){return(_-(2*Math.round(n)-d))%(2*Math.round(n))}},6052:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.createSelectionRenderModel=void 0;class o{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(_,n,d,f=!1){if(this.selectionStart=n,this.selectionEnd=d,!n||!d||n[0]===d[0]&&n[1]===d[1])return void this.clear();const g=_.buffers.active.ydisp,h=n[1]-g,t=d[1]-g,s=Math.max(h,0),e=Math.min(t,_.rows-1);s>=_.rows||e<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=f,this.viewportStartRow=h,this.viewportEndRow=t,this.viewportCappedStartRow=s,this.viewportCappedEndRow=e,this.startCol=n[0],this.endCol=d[0])}isCellSelected(_,n,d){return!!this.hasSelection&&(d-=_.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?n>=this.startCol&&d>=this.viewportCappedStartRow&&n=this.viewportCappedStartRow&&n>=this.endCol&&d<=this.viewportCappedEndRow:d>this.viewportStartRow&&d=this.startCol&&n=this.startCol)}}r.createSelectionRenderModel=function(){return new o}},456:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.SelectionModel=void 0,r.SelectionModel=class{constructor(o){this._bufferService=o,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const o=this.selectionStart[0]+this.selectionStartLength;return o>this._bufferService.cols?o%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(o/this._bufferService.cols)-1]:[o%this._bufferService.cols,this.selectionStart[1]+Math.floor(o/this._bufferService.cols)]:[o,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const o=this.selectionStart[0]+this.selectionStartLength;return o>this._bufferService.cols?[o%this._bufferService.cols,this.selectionStart[1]+Math.floor(o/this._bufferService.cols)]:[Math.max(o,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const o=this.selectionStart,l=this.selectionEnd;return!(!o||!l)&&(o[1]>l[1]||o[1]===l[1]&&o[0]>l[0])}handleTrim(o){return this.selectionStart&&(this.selectionStart[1]-=o),this.selectionEnd&&(this.selectionEnd[1]-=o),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},428:function(x,r,o){var l=this&&this.__decorate||function(e,i,a,v){var u,p=arguments.length,c=p<3?i:v===null?v=Object.getOwnPropertyDescriptor(i,a):v;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")c=Reflect.decorate(e,i,a,v);else for(var S=e.length-1;S>=0;S--)(u=e[S])&&(c=(p<3?u(c):p>3?u(i,a,c):u(i,a))||c);return p>3&&c&&Object.defineProperty(i,a,c),c},_=this&&this.__param||function(e,i){return function(a,v){i(a,v,e)}};Object.defineProperty(r,"__esModule",{value:!0}),r.CharSizeService=void 0;const n=o(2585),d=o(8460),f=o(844);let g=r.CharSizeService=class extends f.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,i,a){super(),this._optionsService=a,this.width=0,this.height=0,this._onCharSizeChange=this.register(new d.EventEmitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this.register(new s(this._optionsService))}catch{this._measureStrategy=this.register(new t(e,i,this._optionsService))}this.register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};r.CharSizeService=g=l([_(2,n.IOptionsService)],g);class h extends f.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(i,a){i!==void 0&&i>0&&a!==void 0&&a>0&&(this._result.width=i,this._result.height=a)}}class t extends h{constructor(i,a,v){super(),this._document=i,this._parentElement=a,this._optionsService=v,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class s extends h{constructor(i){super(),this._optionsService=i,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const a=this._ctx.measureText("W");if(!("width"in a&&"fontBoundingBoxAscent"in a&&"fontBoundingBoxDescent"in a))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const i=this._ctx.measureText("W");return this._validateAndSet(i.width,i.fontBoundingBoxAscent+i.fontBoundingBoxDescent),this._result}}},4269:function(x,r,o){var l=this&&this.__decorate||function(s,e,i,a){var v,u=arguments.length,p=u<3?e:a===null?a=Object.getOwnPropertyDescriptor(e,i):a;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")p=Reflect.decorate(s,e,i,a);else for(var c=s.length-1;c>=0;c--)(v=s[c])&&(p=(u<3?v(p):u>3?v(e,i,p):v(e,i))||p);return u>3&&p&&Object.defineProperty(e,i,p),p},_=this&&this.__param||function(s,e){return function(i,a){e(i,a,s)}};Object.defineProperty(r,"__esModule",{value:!0}),r.CharacterJoinerService=r.JoinedCellData=void 0;const n=o(3734),d=o(643),f=o(511),g=o(2585);class h extends n.AttributeData{constructor(e,i,a){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=i,this._width=a}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}r.JoinedCellData=h;let t=r.CharacterJoinerService=class ye{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new f.CellData}register(e){const i={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(i),i.id}deregister(e){for(let i=0;i1){const L=this._getJoinedRanges(v,c,p,i,u);for(let b=0;b1){const k=this._getJoinedRanges(v,c,p,i,u);for(let L=0;L{Object.defineProperty(r,"__esModule",{value:!0}),r.CoreBrowserService=void 0;const l=o(844),_=o(8460),n=o(3656);class d extends l.Disposable{constructor(h,t,s){super(),this._textarea=h,this._window=t,this.mainDocument=s,this._isFocused=!1,this._cachedIsFocused=void 0,this._screenDprMonitor=new f(this._window),this._onDprChange=this.register(new _.EventEmitter),this.onDprChange=this._onDprChange.event,this._onWindowChange=this.register(new _.EventEmitter),this.onWindowChange=this._onWindowChange.event,this.register(this.onWindowChange((e=>this._screenDprMonitor.setWindow(e)))),this.register((0,_.forwardEvent)(this._screenDprMonitor.onDprChange,this._onDprChange)),this._textarea.addEventListener("focus",(()=>this._isFocused=!0)),this._textarea.addEventListener("blur",(()=>this._isFocused=!1))}get window(){return this._window}set window(h){this._window!==h&&(this._window=h,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return this._cachedIsFocused===void 0&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}r.CoreBrowserService=d;class f extends l.Disposable{constructor(h){super(),this._parentWindow=h,this._windowResizeListener=this.register(new l.MutableDisposable),this._onDprChange=this.register(new _.EventEmitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this.register((0,l.toDisposable)((()=>this.clearListener())))}setWindow(h){this._parentWindow=h,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,n.addDisposableDomListener)(this._parentWindow,"resize",(()=>this._setDprAndFireIfDiffers()))}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},779:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.LinkProviderService=void 0;const l=o(844);class _ extends l.Disposable{constructor(){super(),this.linkProviders=[],this.register((0,l.toDisposable)((()=>this.linkProviders.length=0)))}registerLinkProvider(d){return this.linkProviders.push(d),{dispose:()=>{const f=this.linkProviders.indexOf(d);f!==-1&&this.linkProviders.splice(f,1)}}}}r.LinkProviderService=_},8934:function(x,r,o){var l=this&&this.__decorate||function(g,h,t,s){var e,i=arguments.length,a=i<3?h:s===null?s=Object.getOwnPropertyDescriptor(h,t):s;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")a=Reflect.decorate(g,h,t,s);else for(var v=g.length-1;v>=0;v--)(e=g[v])&&(a=(i<3?e(a):i>3?e(h,t,a):e(h,t))||a);return i>3&&a&&Object.defineProperty(h,t,a),a},_=this&&this.__param||function(g,h){return function(t,s){h(t,s,g)}};Object.defineProperty(r,"__esModule",{value:!0}),r.MouseService=void 0;const n=o(4725),d=o(9806);let f=r.MouseService=class{constructor(g,h){this._renderService=g,this._charSizeService=h}getCoords(g,h,t,s,e){return(0,d.getCoords)(window,g,h,t,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,e)}getMouseReportCoords(g,h){const t=(0,d.getCoordsRelativeToElement)(window,g,h);if(this._charSizeService.hasValidSize)return t[0]=Math.min(Math.max(t[0],0),this._renderService.dimensions.css.canvas.width-1),t[1]=Math.min(Math.max(t[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(t[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(t[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(t[0]),y:Math.floor(t[1])}}};r.MouseService=f=l([_(0,n.IRenderService),_(1,n.ICharSizeService)],f)},3230:function(x,r,o){var l=this&&this.__decorate||function(e,i,a,v){var u,p=arguments.length,c=p<3?i:v===null?v=Object.getOwnPropertyDescriptor(i,a):v;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")c=Reflect.decorate(e,i,a,v);else for(var S=e.length-1;S>=0;S--)(u=e[S])&&(c=(p<3?u(c):p>3?u(i,a,c):u(i,a))||c);return p>3&&c&&Object.defineProperty(i,a,c),c},_=this&&this.__param||function(e,i){return function(a,v){i(a,v,e)}};Object.defineProperty(r,"__esModule",{value:!0}),r.RenderService=void 0;const n=o(6193),d=o(4725),f=o(8460),g=o(844),h=o(7226),t=o(2585);let s=r.RenderService=class extends g.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,i,a,v,u,p,c,S){super(),this._rowCount=e,this._charSizeService=v,this._renderer=this.register(new g.MutableDisposable),this._pausedResizeTask=new h.DebouncedIdleTask,this._observerDisposable=this.register(new g.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this.register(new f.EventEmitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this.register(new f.EventEmitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this.register(new f.EventEmitter),this.onRender=this._onRender.event,this._onRefreshRequest=this.register(new f.EventEmitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new n.RenderDebouncer(((E,k)=>this._renderRows(E,k)),c),this.register(this._renderDebouncer),this.register(c.onDprChange((()=>this.handleDevicePixelRatioChange()))),this.register(p.onResize((()=>this._fullRefresh()))),this.register(p.buffers.onBufferActivate((()=>this._renderer.value?.clear()))),this.register(a.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this.register(u.onDecorationRegistered((()=>this._fullRefresh()))),this.register(u.onDecorationRemoved((()=>this._fullRefresh()))),this.register(a.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],(()=>{this.clear(),this.handleResize(p.cols,p.rows),this._fullRefresh()}))),this.register(a.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(p.buffer.y,p.buffer.y,!0)))),this.register(S.onChangeColors((()=>this._fullRefresh()))),this._registerIntersectionObserver(c.window,i),this.register(c.onWindowChange((E=>this._registerIntersectionObserver(E,i))))}_registerIntersectionObserver(e,i){if("IntersectionObserver"in e){const a=new e.IntersectionObserver((v=>this._handleIntersectionChange(v[v.length-1])),{threshold:0});a.observe(i),this._observerDisposable.value=(0,g.toDisposable)((()=>a.disconnect()))}}_handleIntersectionChange(e){this._isPaused=e.isIntersecting===void 0?e.intersectionRatio===0:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,i,a=!1){this._isPaused?this._needsFullRefresh=!0:(a||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,i,this._rowCount))}_renderRows(e,i){this._renderer.value&&(e=Math.min(e,this._rowCount-1),i=Math.min(i,this._rowCount-1),this._renderer.value.renderRows(e,i),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:i}),this._onRender.fire({start:e,end:i}),this._isNextRenderRedrawOnly=!0)}resize(e,i){this._rowCount=i,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw((i=>this.refreshRows(i.start,i.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,i){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value?.handleResize(e,i))):this._renderer.value.handleResize(e,i),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,i,a){this._selectionState.start=e,this._selectionState.end=i,this._selectionState.columnSelectMode=a,this._renderer.value?.handleSelectionChanged(e,i,a)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};r.RenderService=s=l([_(2,t.IOptionsService),_(3,d.ICharSizeService),_(4,t.IDecorationService),_(5,t.IBufferService),_(6,d.ICoreBrowserService),_(7,d.IThemeService)],s)},9312:function(x,r,o){var l=this&&this.__decorate||function(c,S,E,k){var L,b=arguments.length,A=b<3?S:k===null?k=Object.getOwnPropertyDescriptor(S,E):k;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")A=Reflect.decorate(c,S,E,k);else for(var M=c.length-1;M>=0;M--)(L=c[M])&&(A=(b<3?L(A):b>3?L(S,E,A):L(S,E))||A);return b>3&&A&&Object.defineProperty(S,E,A),A},_=this&&this.__param||function(c,S){return function(E,k){S(E,k,c)}};Object.defineProperty(r,"__esModule",{value:!0}),r.SelectionService=void 0;const n=o(9806),d=o(9504),f=o(456),g=o(4725),h=o(8460),t=o(844),s=o(6114),e=o(4841),i=o(511),a=o(2585),v=" ",u=new RegExp(v,"g");let p=r.SelectionService=class extends t.Disposable{constructor(c,S,E,k,L,b,A,M,B){super(),this._element=c,this._screenElement=S,this._linkifier=E,this._bufferService=k,this._coreService=L,this._mouseService=b,this._optionsService=A,this._renderService=M,this._coreBrowserService=B,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new i.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this.register(new h.EventEmitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this.register(new h.EventEmitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this.register(new h.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this.register(new h.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=H=>this._handleMouseMove(H),this._mouseUpListener=H=>this._handleMouseUp(H),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((H=>this._handleTrim(H))),this.register(this._bufferService.buffers.onBufferActivate((H=>this._handleBufferActivate(H)))),this.enable(),this._model=new f.SelectionModel(this._bufferService),this._activeSelectionMode=0,this.register((0,t.toDisposable)((()=>{this._removeMouseDownListeners()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const c=this._model.finalSelectionStart,S=this._model.finalSelectionEnd;return!(!c||!S||c[0]===S[0]&&c[1]===S[1])}get selectionText(){const c=this._model.finalSelectionStart,S=this._model.finalSelectionEnd;if(!c||!S)return"";const E=this._bufferService.buffer,k=[];if(this._activeSelectionMode===3){if(c[0]===S[0])return"";const L=c[0]L.replace(u," "))).join(s.isWindows?`\r +`:` +`)}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(c){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),s.isLinux&&c&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:this._activeSelectionMode===3})}_isClickInSelection(c){const S=this._getMouseBufferCoords(c),E=this._model.finalSelectionStart,k=this._model.finalSelectionEnd;return!!(E&&k&&S)&&this._areCoordsInSelection(S,E,k)}isCellInSelection(c,S){const E=this._model.finalSelectionStart,k=this._model.finalSelectionEnd;return!(!E||!k)&&this._areCoordsInSelection([c,S],E,k)}_areCoordsInSelection(c,S,E){return c[1]>S[1]&&c[1]=S[0]&&c[0]=S[0]}_selectWordAtCursor(c,S){const E=this._linkifier.currentLink?.link?.range;if(E)return this._model.selectionStart=[E.start.x-1,E.start.y-1],this._model.selectionStartLength=(0,e.getRangeLength)(E,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const k=this._getMouseBufferCoords(c);return!!k&&(this._selectWordAt(k,S),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(c,S){this._model.clearSelection(),c=Math.max(c,0),S=Math.min(S,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,c],this._model.selectionEnd=[this._bufferService.cols,S],this.refresh(),this._onSelectionChange.fire()}_handleTrim(c){this._model.handleTrim(c)&&this.refresh()}_getMouseBufferCoords(c){const S=this._mouseService.getCoords(c,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(S)return S[0]--,S[1]--,S[1]+=this._bufferService.buffer.ydisp,S}_getMouseEventScrollAmount(c){let S=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,c,this._screenElement)[1];const E=this._renderService.dimensions.css.canvas.height;return S>=0&&S<=E?0:(S>E&&(S-=E),S=Math.min(Math.max(S,-50),50),S/=50,S/Math.abs(S)+Math.round(14*S))}shouldForceSelection(c){return s.isMac?c.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:c.shiftKey}handleMouseDown(c){if(this._mouseDownTimeStamp=c.timeStamp,(c.button!==2||!this.hasSelection)&&c.button===0){if(!this._enabled){if(!this.shouldForceSelection(c))return;c.stopPropagation()}c.preventDefault(),this._dragScrollAmount=0,this._enabled&&c.shiftKey?this._handleIncrementalClick(c):c.detail===1?this._handleSingleClick(c):c.detail===2?this._handleDoubleClick(c):c.detail===3&&this._handleTripleClick(c),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(c){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(c))}_handleSingleClick(c){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(c)?3:0,this._model.selectionStart=this._getMouseBufferCoords(c),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const S=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);S&&S.length!==this._model.selectionStart[0]&&S.hasWidth(this._model.selectionStart[0])===0&&this._model.selectionStart[0]++}_handleDoubleClick(c){this._selectWordAtCursor(c,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(c){const S=this._getMouseBufferCoords(c);S&&(this._activeSelectionMode=2,this._selectLineAt(S[1]))}shouldColumnSelect(c){return c.altKey&&!(s.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(c){if(c.stopImmediatePropagation(),!this._model.selectionStart)return;const S=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(c),!this._model.selectionEnd)return void this.refresh(!0);this._activeSelectionMode===2?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const E=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(this._activeSelectionMode!==3&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(c.ydisp+this._bufferService.rows,c.lines.length-1)):(this._activeSelectionMode!==3&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=c.ydisp),this.refresh()}}_handleMouseUp(c){const S=c.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&S<500&&c.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const E=this._mouseService.getCoords(c,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(E&&E[0]!==void 0&&E[1]!==void 0){const k=(0,d.moveToCellSequence)(E[0]-1,E[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(k,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const c=this._model.finalSelectionStart,S=this._model.finalSelectionEnd,E=!(!c||!S||c[0]===S[0]&&c[1]===S[1]);E?c&&S&&(this._oldSelectionStart&&this._oldSelectionEnd&&c[0]===this._oldSelectionStart[0]&&c[1]===this._oldSelectionStart[1]&&S[0]===this._oldSelectionEnd[0]&&S[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(c,S,E)):this._oldHasSelection&&this._fireOnSelectionChange(c,S,E)}_fireOnSelectionChange(c,S,E){this._oldSelectionStart=c,this._oldSelectionEnd=S,this._oldHasSelection=E,this._onSelectionChange.fire()}_handleBufferActivate(c){this.clearSelection(),this._trimListener.dispose(),this._trimListener=c.activeBuffer.lines.onTrim((S=>this._handleTrim(S)))}_convertViewportColToCharacterIndex(c,S){let E=S;for(let k=0;S>=k;k++){const L=c.loadCell(k,this._workCell).getChars().length;this._workCell.getWidth()===0?E--:L>1&&S!==k&&(E+=L-1)}return E}setSelection(c,S,E){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[c,S],this._model.selectionStartLength=E,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(c){this._isClickInSelection(c)||(this._selectWordAtCursor(c,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(c,S,E=!0,k=!0){if(c[0]>=this._bufferService.cols)return;const L=this._bufferService.buffer,b=L.lines.get(c[1]);if(!b)return;const A=L.translateBufferLineToString(c[1],!1);let M=this._convertViewportColToCharacterIndex(b,c[0]),B=M;const H=c[0]-M;let I=0,m=0,w=0,y=0;if(A.charAt(M)===" "){for(;M>0&&A.charAt(M-1)===" ";)M--;for(;B1&&(y+=z-1,B+=z-1);F>0&&M>0&&!this._isCharWordSeparator(b.loadCell(F-1,this._workCell));){b.loadCell(F-1,this._workCell);const R=this._workCell.getChars().length;this._workCell.getWidth()===0?(I++,F--):R>1&&(w+=R-1,M-=R-1),M--,F--}for(;$1&&(y+=R-1,B+=R-1),B++,$++}}B++;let D=M+H-I+w,O=Math.min(this._bufferService.cols,B-M+I+m-w-y);if(S||A.slice(M,B).trim()!==""){if(E&&D===0&&b.getCodePoint(0)!==32){const F=L.lines.get(c[1]-1);if(F&&b.isWrapped&&F.getCodePoint(this._bufferService.cols-1)!==32){const $=this._getWordAt([this._bufferService.cols-1,c[1]-1],!1,!0,!1);if($){const z=this._bufferService.cols-$.start;D-=z,O+=z}}}if(k&&D+O===this._bufferService.cols&&b.getCodePoint(this._bufferService.cols-1)!==32){const F=L.lines.get(c[1]+1);if(F?.isWrapped&&F.getCodePoint(0)!==32){const $=this._getWordAt([0,c[1]+1],!1,!1,!0);$&&(O+=$.length)}}return{start:D,length:O}}}_selectWordAt(c,S){const E=this._getWordAt(c,S);if(E){for(;E.start<0;)E.start+=this._bufferService.cols,c[1]--;this._model.selectionStart=[E.start,c[1]],this._model.selectionStartLength=E.length}}_selectToWordAt(c){const S=this._getWordAt(c,!0);if(S){let E=c[1];for(;S.start<0;)S.start+=this._bufferService.cols,E--;if(!this._model.areSelectionValuesReversed())for(;S.start+S.length>this._bufferService.cols;)S.length-=this._bufferService.cols,E++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?S.start:S.start+S.length,E]}}_isCharWordSeparator(c){return c.getWidth()!==0&&this._optionsService.rawOptions.wordSeparator.indexOf(c.getChars())>=0}_selectLineAt(c){const S=this._bufferService.buffer.getWrappedRangeForLine(c),E={start:{x:0,y:S.first},end:{x:this._bufferService.cols-1,y:S.last}};this._model.selectionStart=[0,S.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,e.getRangeLength)(E,this._bufferService.cols)}};r.SelectionService=p=l([_(3,a.IBufferService),_(4,a.ICoreService),_(5,g.IMouseService),_(6,a.IOptionsService),_(7,g.IRenderService),_(8,g.ICoreBrowserService)],p)},4725:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.ILinkProviderService=r.IThemeService=r.ICharacterJoinerService=r.ISelectionService=r.IRenderService=r.IMouseService=r.ICoreBrowserService=r.ICharSizeService=void 0;const l=o(8343);r.ICharSizeService=(0,l.createDecorator)("CharSizeService"),r.ICoreBrowserService=(0,l.createDecorator)("CoreBrowserService"),r.IMouseService=(0,l.createDecorator)("MouseService"),r.IRenderService=(0,l.createDecorator)("RenderService"),r.ISelectionService=(0,l.createDecorator)("SelectionService"),r.ICharacterJoinerService=(0,l.createDecorator)("CharacterJoinerService"),r.IThemeService=(0,l.createDecorator)("ThemeService"),r.ILinkProviderService=(0,l.createDecorator)("LinkProviderService")},6731:function(x,r,o){var l=this&&this.__decorate||function(p,c,S,E){var k,L=arguments.length,b=L<3?c:E===null?E=Object.getOwnPropertyDescriptor(c,S):E;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")b=Reflect.decorate(p,c,S,E);else for(var A=p.length-1;A>=0;A--)(k=p[A])&&(b=(L<3?k(b):L>3?k(c,S,b):k(c,S))||b);return L>3&&b&&Object.defineProperty(c,S,b),b},_=this&&this.__param||function(p,c){return function(S,E){c(S,E,p)}};Object.defineProperty(r,"__esModule",{value:!0}),r.ThemeService=r.DEFAULT_ANSI_COLORS=void 0;const n=o(7239),d=o(8055),f=o(8460),g=o(844),h=o(2585),t=d.css.toColor("#ffffff"),s=d.css.toColor("#000000"),e=d.css.toColor("#ffffff"),i=d.css.toColor("#000000"),a={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117};r.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const p=[d.css.toColor("#2e3436"),d.css.toColor("#cc0000"),d.css.toColor("#4e9a06"),d.css.toColor("#c4a000"),d.css.toColor("#3465a4"),d.css.toColor("#75507b"),d.css.toColor("#06989a"),d.css.toColor("#d3d7cf"),d.css.toColor("#555753"),d.css.toColor("#ef2929"),d.css.toColor("#8ae234"),d.css.toColor("#fce94f"),d.css.toColor("#729fcf"),d.css.toColor("#ad7fa8"),d.css.toColor("#34e2e2"),d.css.toColor("#eeeeec")],c=[0,95,135,175,215,255];for(let S=0;S<216;S++){const E=c[S/36%6|0],k=c[S/6%6|0],L=c[S%6];p.push({css:d.channels.toCss(E,k,L),rgba:d.channels.toRgba(E,k,L)})}for(let S=0;S<24;S++){const E=8+10*S;p.push({css:d.channels.toCss(E,E,E),rgba:d.channels.toRgba(E,E,E)})}return p})());let v=r.ThemeService=class extends g.Disposable{get colors(){return this._colors}constructor(p){super(),this._optionsService=p,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this.register(new f.EventEmitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:t,background:s,cursor:e,cursorAccent:i,selectionForeground:void 0,selectionBackgroundTransparent:a,selectionBackgroundOpaque:d.color.blend(s,a),selectionInactiveBackgroundTransparent:a,selectionInactiveBackgroundOpaque:d.color.blend(s,a),ansi:r.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this.register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this.register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(p={}){const c=this._colors;if(c.foreground=u(p.foreground,t),c.background=u(p.background,s),c.cursor=u(p.cursor,e),c.cursorAccent=u(p.cursorAccent,i),c.selectionBackgroundTransparent=u(p.selectionBackground,a),c.selectionBackgroundOpaque=d.color.blend(c.background,c.selectionBackgroundTransparent),c.selectionInactiveBackgroundTransparent=u(p.selectionInactiveBackground,c.selectionBackgroundTransparent),c.selectionInactiveBackgroundOpaque=d.color.blend(c.background,c.selectionInactiveBackgroundTransparent),c.selectionForeground=p.selectionForeground?u(p.selectionForeground,d.NULL_COLOR):void 0,c.selectionForeground===d.NULL_COLOR&&(c.selectionForeground=void 0),d.color.isOpaque(c.selectionBackgroundTransparent)&&(c.selectionBackgroundTransparent=d.color.opacity(c.selectionBackgroundTransparent,.3)),d.color.isOpaque(c.selectionInactiveBackgroundTransparent)&&(c.selectionInactiveBackgroundTransparent=d.color.opacity(c.selectionInactiveBackgroundTransparent,.3)),c.ansi=r.DEFAULT_ANSI_COLORS.slice(),c.ansi[0]=u(p.black,r.DEFAULT_ANSI_COLORS[0]),c.ansi[1]=u(p.red,r.DEFAULT_ANSI_COLORS[1]),c.ansi[2]=u(p.green,r.DEFAULT_ANSI_COLORS[2]),c.ansi[3]=u(p.yellow,r.DEFAULT_ANSI_COLORS[3]),c.ansi[4]=u(p.blue,r.DEFAULT_ANSI_COLORS[4]),c.ansi[5]=u(p.magenta,r.DEFAULT_ANSI_COLORS[5]),c.ansi[6]=u(p.cyan,r.DEFAULT_ANSI_COLORS[6]),c.ansi[7]=u(p.white,r.DEFAULT_ANSI_COLORS[7]),c.ansi[8]=u(p.brightBlack,r.DEFAULT_ANSI_COLORS[8]),c.ansi[9]=u(p.brightRed,r.DEFAULT_ANSI_COLORS[9]),c.ansi[10]=u(p.brightGreen,r.DEFAULT_ANSI_COLORS[10]),c.ansi[11]=u(p.brightYellow,r.DEFAULT_ANSI_COLORS[11]),c.ansi[12]=u(p.brightBlue,r.DEFAULT_ANSI_COLORS[12]),c.ansi[13]=u(p.brightMagenta,r.DEFAULT_ANSI_COLORS[13]),c.ansi[14]=u(p.brightCyan,r.DEFAULT_ANSI_COLORS[14]),c.ansi[15]=u(p.brightWhite,r.DEFAULT_ANSI_COLORS[15]),p.extendedAnsi){const S=Math.min(c.ansi.length-16,p.extendedAnsi.length);for(let E=0;E{Object.defineProperty(r,"__esModule",{value:!0}),r.CircularList=void 0;const l=o(8460),_=o(844);class n extends _.Disposable{constructor(f){super(),this._maxLength=f,this.onDeleteEmitter=this.register(new l.EventEmitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this.register(new l.EventEmitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this.register(new l.EventEmitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(f){if(this._maxLength===f)return;const g=new Array(f);for(let h=0;hthis._length)for(let g=this._length;g=f;t--)this._array[this._getCyclicIndex(t+h.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const t=this._length+h.length-this._maxLength;this._startIndex+=t,this._length=this._maxLength,this.onTrimEmitter.fire(t)}else this._length+=h.length}trimStart(f){f>this._length&&(f=this._length),this._startIndex+=f,this._length-=f,this.onTrimEmitter.fire(f)}shiftElements(f,g,h){if(!(g<=0)){if(f<0||f>=this._length)throw new Error("start argument out of range");if(f+h<0)throw new Error("Cannot shift elements in list beyond index 0");if(h>0){for(let s=g-1;s>=0;s--)this.set(f+s+h,this.get(f+s));const t=f+g+h-this._length;if(t>0)for(this._length+=t;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let t=0;t{Object.defineProperty(r,"__esModule",{value:!0}),r.clone=void 0,r.clone=function o(l,_=5){if(typeof l!="object")return l;const n=Array.isArray(l)?[]:{};for(const d in l)n[d]=_<=1?l[d]:l[d]&&o(l[d],_-1);return n}},8055:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.contrastRatio=r.toPaddedHex=r.rgba=r.rgb=r.css=r.color=r.channels=r.NULL_COLOR=void 0;let o=0,l=0,_=0,n=0;var d,f,g,h,t;function s(i){const a=i.toString(16);return a.length<2?"0"+a:a}function e(i,a){return i>>0},i.toColor=function(a,v,u,p){return{css:i.toCss(a,v,u,p),rgba:i.toRgba(a,v,u,p)}}})(d||(r.channels=d={})),(function(i){function a(v,u){return n=Math.round(255*u),[o,l,_]=t.toChannels(v.rgba),{css:d.toCss(o,l,_,n),rgba:d.toRgba(o,l,_,n)}}i.blend=function(v,u){if(n=(255&u.rgba)/255,n===1)return{css:u.css,rgba:u.rgba};const p=u.rgba>>24&255,c=u.rgba>>16&255,S=u.rgba>>8&255,E=v.rgba>>24&255,k=v.rgba>>16&255,L=v.rgba>>8&255;return o=E+Math.round((p-E)*n),l=k+Math.round((c-k)*n),_=L+Math.round((S-L)*n),{css:d.toCss(o,l,_),rgba:d.toRgba(o,l,_)}},i.isOpaque=function(v){return(255&v.rgba)==255},i.ensureContrastRatio=function(v,u,p){const c=t.ensureContrastRatio(v.rgba,u.rgba,p);if(c)return d.toColor(c>>24&255,c>>16&255,c>>8&255)},i.opaque=function(v){const u=(255|v.rgba)>>>0;return[o,l,_]=t.toChannels(u),{css:d.toCss(o,l,_),rgba:u}},i.opacity=a,i.multiplyOpacity=function(v,u){return n=255&v.rgba,a(v,n*u/255)},i.toColorRGB=function(v){return[v.rgba>>24&255,v.rgba>>16&255,v.rgba>>8&255]}})(f||(r.color=f={})),(function(i){let a,v;try{const u=document.createElement("canvas");u.width=1,u.height=1;const p=u.getContext("2d",{willReadFrequently:!0});p&&(a=p,a.globalCompositeOperation="copy",v=a.createLinearGradient(0,0,1,1))}catch{}i.toColor=function(u){if(u.match(/#[\da-f]{3,8}/i))switch(u.length){case 4:return o=parseInt(u.slice(1,2).repeat(2),16),l=parseInt(u.slice(2,3).repeat(2),16),_=parseInt(u.slice(3,4).repeat(2),16),d.toColor(o,l,_);case 5:return o=parseInt(u.slice(1,2).repeat(2),16),l=parseInt(u.slice(2,3).repeat(2),16),_=parseInt(u.slice(3,4).repeat(2),16),n=parseInt(u.slice(4,5).repeat(2),16),d.toColor(o,l,_,n);case 7:return{css:u,rgba:(parseInt(u.slice(1),16)<<8|255)>>>0};case 9:return{css:u,rgba:parseInt(u.slice(1),16)>>>0}}const p=u.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(p)return o=parseInt(p[1]),l=parseInt(p[2]),_=parseInt(p[3]),n=Math.round(255*(p[5]===void 0?1:parseFloat(p[5]))),d.toColor(o,l,_,n);if(!a||!v)throw new Error("css.toColor: Unsupported css format");if(a.fillStyle=v,a.fillStyle=u,typeof a.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(a.fillRect(0,0,1,1),[o,l,_,n]=a.getImageData(0,0,1,1).data,n!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:d.toRgba(o,l,_,n),css:u}}})(g||(r.css=g={})),(function(i){function a(v,u,p){const c=v/255,S=u/255,E=p/255;return .2126*(c<=.03928?c/12.92:Math.pow((c+.055)/1.055,2.4))+.7152*(S<=.03928?S/12.92:Math.pow((S+.055)/1.055,2.4))+.0722*(E<=.03928?E/12.92:Math.pow((E+.055)/1.055,2.4))}i.relativeLuminance=function(v){return a(v>>16&255,v>>8&255,255&v)},i.relativeLuminance2=a})(h||(r.rgb=h={})),(function(i){function a(u,p,c){const S=u>>24&255,E=u>>16&255,k=u>>8&255;let L=p>>24&255,b=p>>16&255,A=p>>8&255,M=e(h.relativeLuminance2(L,b,A),h.relativeLuminance2(S,E,k));for(;M0||b>0||A>0);)L-=Math.max(0,Math.ceil(.1*L)),b-=Math.max(0,Math.ceil(.1*b)),A-=Math.max(0,Math.ceil(.1*A)),M=e(h.relativeLuminance2(L,b,A),h.relativeLuminance2(S,E,k));return(L<<24|b<<16|A<<8|255)>>>0}function v(u,p,c){const S=u>>24&255,E=u>>16&255,k=u>>8&255;let L=p>>24&255,b=p>>16&255,A=p>>8&255,M=e(h.relativeLuminance2(L,b,A),h.relativeLuminance2(S,E,k));for(;M>>0}i.blend=function(u,p){if(n=(255&p)/255,n===1)return p;const c=p>>24&255,S=p>>16&255,E=p>>8&255,k=u>>24&255,L=u>>16&255,b=u>>8&255;return o=k+Math.round((c-k)*n),l=L+Math.round((S-L)*n),_=b+Math.round((E-b)*n),d.toRgba(o,l,_)},i.ensureContrastRatio=function(u,p,c){const S=h.relativeLuminance(u>>8),E=h.relativeLuminance(p>>8);if(e(S,E)>8));if(Ae(S,h.relativeLuminance(M>>8))?b:M}return b}const k=v(u,p,c),L=e(S,h.relativeLuminance(k>>8));if(Le(S,h.relativeLuminance(b>>8))?k:b}return k}},i.reduceLuminance=a,i.increaseLuminance=v,i.toChannels=function(u){return[u>>24&255,u>>16&255,u>>8&255,255&u]}})(t||(r.rgba=t={})),r.toPaddedHex=s,r.contrastRatio=e},8969:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.CoreTerminal=void 0;const l=o(844),_=o(2585),n=o(4348),d=o(7866),f=o(744),g=o(7302),h=o(6975),t=o(8460),s=o(1753),e=o(1480),i=o(7994),a=o(9282),v=o(5435),u=o(5981),p=o(2660);let c=!1;class S extends l.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this.register(new t.EventEmitter),this._onScroll.event((k=>{this._onScrollApi?.fire(k.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(k){for(const L in k)this.optionsService.options[L]=k[L]}constructor(k){super(),this._windowsWrappingHeuristics=this.register(new l.MutableDisposable),this._onBinary=this.register(new t.EventEmitter),this.onBinary=this._onBinary.event,this._onData=this.register(new t.EventEmitter),this.onData=this._onData.event,this._onLineFeed=this.register(new t.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this.register(new t.EventEmitter),this.onResize=this._onResize.event,this._onWriteParsed=this.register(new t.EventEmitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this.register(new t.EventEmitter),this._instantiationService=new n.InstantiationService,this.optionsService=this.register(new g.OptionsService(k)),this._instantiationService.setService(_.IOptionsService,this.optionsService),this._bufferService=this.register(this._instantiationService.createInstance(f.BufferService)),this._instantiationService.setService(_.IBufferService,this._bufferService),this._logService=this.register(this._instantiationService.createInstance(d.LogService)),this._instantiationService.setService(_.ILogService,this._logService),this.coreService=this.register(this._instantiationService.createInstance(h.CoreService)),this._instantiationService.setService(_.ICoreService,this.coreService),this.coreMouseService=this.register(this._instantiationService.createInstance(s.CoreMouseService)),this._instantiationService.setService(_.ICoreMouseService,this.coreMouseService),this.unicodeService=this.register(this._instantiationService.createInstance(e.UnicodeService)),this._instantiationService.setService(_.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(i.CharsetService),this._instantiationService.setService(_.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(p.OscLinkService),this._instantiationService.setService(_.IOscLinkService,this._oscLinkService),this._inputHandler=this.register(new v.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this.register((0,t.forwardEvent)(this._inputHandler.onLineFeed,this._onLineFeed)),this.register(this._inputHandler),this.register((0,t.forwardEvent)(this._bufferService.onResize,this._onResize)),this.register((0,t.forwardEvent)(this.coreService.onData,this._onData)),this.register((0,t.forwardEvent)(this.coreService.onBinary,this._onBinary)),this.register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom()))),this.register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this.register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this.register(this._bufferService.onScroll((L=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this.register(this._inputHandler.onScroll((L=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this.register(new u.WriteBuffer(((L,b)=>this._inputHandler.parse(L,b)))),this.register((0,t.forwardEvent)(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(k,L){this._writeBuffer.write(k,L)}writeSync(k,L){this._logService.logLevel<=_.LogLevelEnum.WARN&&!c&&(this._logService.warn("writeSync is unreliable and will be removed soon."),c=!0),this._writeBuffer.writeSync(k,L)}input(k,L=!0){this.coreService.triggerDataEvent(k,L)}resize(k,L){isNaN(k)||isNaN(L)||(k=Math.max(k,f.MINIMUM_COLS),L=Math.max(L,f.MINIMUM_ROWS),this._bufferService.resize(k,L))}scroll(k,L=!1){this._bufferService.scroll(k,L)}scrollLines(k,L,b){this._bufferService.scrollLines(k,L,b)}scrollPages(k){this.scrollLines(k*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(k){const L=k-this._bufferService.buffer.ydisp;L!==0&&this.scrollLines(L)}registerEscHandler(k,L){return this._inputHandler.registerEscHandler(k,L)}registerDcsHandler(k,L){return this._inputHandler.registerDcsHandler(k,L)}registerCsiHandler(k,L){return this._inputHandler.registerCsiHandler(k,L)}registerOscHandler(k,L){return this._inputHandler.registerOscHandler(k,L)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let k=!1;const L=this.optionsService.rawOptions.windowsPty;L&&L.buildNumber!==void 0&&L.buildNumber!==void 0?k=L.backend==="conpty"&&L.buildNumber<21376:this.optionsService.rawOptions.windowsMode&&(k=!0),k?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const k=[];k.push(this.onLineFeed(a.updateWindowsModeWrappedState.bind(null,this._bufferService))),k.push(this.registerCsiHandler({final:"H"},(()=>((0,a.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,l.toDisposable)((()=>{for(const L of k)L.dispose()}))}}}r.CoreTerminal=S},8460:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.runAndSubscribe=r.forwardEvent=r.EventEmitter=void 0,r.EventEmitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=o=>(this._listeners.push(o),{dispose:()=>{if(!this._disposed){for(let l=0;ll.fire(_)))},r.runAndSubscribe=function(o,l){return l(void 0),o((_=>l(_)))}},5435:function(x,r,o){var l=this&&this.__decorate||function(I,m,w,y){var D,O=arguments.length,F=O<3?m:y===null?y=Object.getOwnPropertyDescriptor(m,w):y;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")F=Reflect.decorate(I,m,w,y);else for(var $=I.length-1;$>=0;$--)(D=I[$])&&(F=(O<3?D(F):O>3?D(m,w,F):D(m,w))||F);return O>3&&F&&Object.defineProperty(m,w,F),F},_=this&&this.__param||function(I,m){return function(w,y){m(w,y,I)}};Object.defineProperty(r,"__esModule",{value:!0}),r.InputHandler=r.WindowsOptionsReportType=void 0;const n=o(2584),d=o(7116),f=o(2015),g=o(844),h=o(482),t=o(8437),s=o(8460),e=o(643),i=o(511),a=o(3734),v=o(2585),u=o(1480),p=o(6242),c=o(6351),S=o(5941),E={"(":0,")":1,"*":2,"+":3,"-":1,".":2},k=131072;function L(I,m){if(I>24)return m.setWinLines||!1;switch(I){case 1:return!!m.restoreWin;case 2:return!!m.minimizeWin;case 3:return!!m.setWinPosition;case 4:return!!m.setWinSizePixels;case 5:return!!m.raiseWin;case 6:return!!m.lowerWin;case 7:return!!m.refreshWin;case 8:return!!m.setWinSizeChars;case 9:return!!m.maximizeWin;case 10:return!!m.fullscreenWin;case 11:return!!m.getWinState;case 13:return!!m.getWinPosition;case 14:return!!m.getWinSizePixels;case 15:return!!m.getScreenSizePixels;case 16:return!!m.getCellSizePixels;case 18:return!!m.getWinSizeChars;case 19:return!!m.getScreenSizeChars;case 20:return!!m.getIconTitle;case 21:return!!m.getWinTitle;case 22:return!!m.pushTitle;case 23:return!!m.popTitle;case 24:return!!m.setWinLines}return!1}var b;(function(I){I[I.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",I[I.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"})(b||(r.WindowsOptionsReportType=b={}));let A=0;class M extends g.Disposable{getAttrData(){return this._curAttrData}constructor(m,w,y,D,O,F,$,z,R=new f.EscapeSequenceParser){super(),this._bufferService=m,this._charsetService=w,this._coreService=y,this._logService=D,this._optionsService=O,this._oscLinkService=F,this._coreMouseService=$,this._unicodeService=z,this._parser=R,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new h.StringToUtf32,this._utf8Decoder=new h.Utf8ToUtf32,this._workCell=new i.CellData,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=t.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=t.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this.register(new s.EventEmitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this.register(new s.EventEmitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this.register(new s.EventEmitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this.register(new s.EventEmitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this.register(new s.EventEmitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this.register(new s.EventEmitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this.register(new s.EventEmitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this.register(new s.EventEmitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this.register(new s.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this.register(new s.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this.register(new s.EventEmitter),this.onScroll=this._onScroll.event,this._onTitleChange=this.register(new s.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this.register(new s.EventEmitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this.register(this._parser),this._dirtyRowTracker=new B(this._bufferService),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((C=>this._activeBuffer=C.activeBuffer))),this._parser.setCsiHandlerFallback(((C,T)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(C),params:T.toArray()})})),this._parser.setEscHandlerFallback((C=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(C)})})),this._parser.setExecuteHandlerFallback((C=>{this._logService.debug("Unknown EXECUTE code: ",{code:C})})),this._parser.setOscHandlerFallback(((C,T,P)=>{this._logService.debug("Unknown OSC code: ",{identifier:C,action:T,data:P})})),this._parser.setDcsHandlerFallback(((C,T,P)=>{T==="HOOK"&&(P=P.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(C),action:T,payload:P})})),this._parser.setPrintHandler(((C,T,P)=>this.print(C,T,P))),this._parser.registerCsiHandler({final:"@"},(C=>this.insertChars(C))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(C=>this.scrollLeft(C))),this._parser.registerCsiHandler({final:"A"},(C=>this.cursorUp(C))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(C=>this.scrollRight(C))),this._parser.registerCsiHandler({final:"B"},(C=>this.cursorDown(C))),this._parser.registerCsiHandler({final:"C"},(C=>this.cursorForward(C))),this._parser.registerCsiHandler({final:"D"},(C=>this.cursorBackward(C))),this._parser.registerCsiHandler({final:"E"},(C=>this.cursorNextLine(C))),this._parser.registerCsiHandler({final:"F"},(C=>this.cursorPrecedingLine(C))),this._parser.registerCsiHandler({final:"G"},(C=>this.cursorCharAbsolute(C))),this._parser.registerCsiHandler({final:"H"},(C=>this.cursorPosition(C))),this._parser.registerCsiHandler({final:"I"},(C=>this.cursorForwardTab(C))),this._parser.registerCsiHandler({final:"J"},(C=>this.eraseInDisplay(C,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(C=>this.eraseInDisplay(C,!0))),this._parser.registerCsiHandler({final:"K"},(C=>this.eraseInLine(C,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(C=>this.eraseInLine(C,!0))),this._parser.registerCsiHandler({final:"L"},(C=>this.insertLines(C))),this._parser.registerCsiHandler({final:"M"},(C=>this.deleteLines(C))),this._parser.registerCsiHandler({final:"P"},(C=>this.deleteChars(C))),this._parser.registerCsiHandler({final:"S"},(C=>this.scrollUp(C))),this._parser.registerCsiHandler({final:"T"},(C=>this.scrollDown(C))),this._parser.registerCsiHandler({final:"X"},(C=>this.eraseChars(C))),this._parser.registerCsiHandler({final:"Z"},(C=>this.cursorBackwardTab(C))),this._parser.registerCsiHandler({final:"`"},(C=>this.charPosAbsolute(C))),this._parser.registerCsiHandler({final:"a"},(C=>this.hPositionRelative(C))),this._parser.registerCsiHandler({final:"b"},(C=>this.repeatPrecedingCharacter(C))),this._parser.registerCsiHandler({final:"c"},(C=>this.sendDeviceAttributesPrimary(C))),this._parser.registerCsiHandler({prefix:">",final:"c"},(C=>this.sendDeviceAttributesSecondary(C))),this._parser.registerCsiHandler({final:"d"},(C=>this.linePosAbsolute(C))),this._parser.registerCsiHandler({final:"e"},(C=>this.vPositionRelative(C))),this._parser.registerCsiHandler({final:"f"},(C=>this.hVPosition(C))),this._parser.registerCsiHandler({final:"g"},(C=>this.tabClear(C))),this._parser.registerCsiHandler({final:"h"},(C=>this.setMode(C))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(C=>this.setModePrivate(C))),this._parser.registerCsiHandler({final:"l"},(C=>this.resetMode(C))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(C=>this.resetModePrivate(C))),this._parser.registerCsiHandler({final:"m"},(C=>this.charAttributes(C))),this._parser.registerCsiHandler({final:"n"},(C=>this.deviceStatus(C))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(C=>this.deviceStatusPrivate(C))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(C=>this.softReset(C))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(C=>this.setCursorStyle(C))),this._parser.registerCsiHandler({final:"r"},(C=>this.setScrollRegion(C))),this._parser.registerCsiHandler({final:"s"},(C=>this.saveCursor(C))),this._parser.registerCsiHandler({final:"t"},(C=>this.windowOptions(C))),this._parser.registerCsiHandler({final:"u"},(C=>this.restoreCursor(C))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(C=>this.insertColumns(C))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(C=>this.deleteColumns(C))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(C=>this.selectProtected(C))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(C=>this.requestMode(C,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(C=>this.requestMode(C,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new p.OscHandler((C=>(this.setTitle(C),this.setIconName(C),!0)))),this._parser.registerOscHandler(1,new p.OscHandler((C=>this.setIconName(C)))),this._parser.registerOscHandler(2,new p.OscHandler((C=>this.setTitle(C)))),this._parser.registerOscHandler(4,new p.OscHandler((C=>this.setOrReportIndexedColor(C)))),this._parser.registerOscHandler(8,new p.OscHandler((C=>this.setHyperlink(C)))),this._parser.registerOscHandler(10,new p.OscHandler((C=>this.setOrReportFgColor(C)))),this._parser.registerOscHandler(11,new p.OscHandler((C=>this.setOrReportBgColor(C)))),this._parser.registerOscHandler(12,new p.OscHandler((C=>this.setOrReportCursorColor(C)))),this._parser.registerOscHandler(104,new p.OscHandler((C=>this.restoreIndexedColor(C)))),this._parser.registerOscHandler(110,new p.OscHandler((C=>this.restoreFgColor(C)))),this._parser.registerOscHandler(111,new p.OscHandler((C=>this.restoreBgColor(C)))),this._parser.registerOscHandler(112,new p.OscHandler((C=>this.restoreCursorColor(C)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const C in d.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:C},(()=>this.selectCharset("("+C))),this._parser.registerEscHandler({intermediates:")",final:C},(()=>this.selectCharset(")"+C))),this._parser.registerEscHandler({intermediates:"*",final:C},(()=>this.selectCharset("*"+C))),this._parser.registerEscHandler({intermediates:"+",final:C},(()=>this.selectCharset("+"+C))),this._parser.registerEscHandler({intermediates:"-",final:C},(()=>this.selectCharset("-"+C))),this._parser.registerEscHandler({intermediates:".",final:C},(()=>this.selectCharset("."+C))),this._parser.registerEscHandler({intermediates:"/",final:C},(()=>this.selectCharset("/"+C)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((C=>(this._logService.error("Parsing error: ",C),C))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new c.DcsHandler(((C,T)=>this.requestStatusString(C,T))))}_preserveStack(m,w,y,D){this._parseStack.paused=!0,this._parseStack.cursorStartX=m,this._parseStack.cursorStartY=w,this._parseStack.decodedLength=y,this._parseStack.position=D}_logSlowResolvingAsync(m){this._logService.logLevel<=v.LogLevelEnum.WARN&&Promise.race([m,new Promise(((w,y)=>setTimeout((()=>y("#SLOW_TIMEOUT")),5e3)))]).catch((w=>{if(w!=="#SLOW_TIMEOUT")throw w;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(m,w){let y,D=this._activeBuffer.x,O=this._activeBuffer.y,F=0;const $=this._parseStack.paused;if($){if(y=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,w))return this._logSlowResolvingAsync(y),y;D=this._parseStack.cursorStartX,O=this._parseStack.cursorStartY,this._parseStack.paused=!1,m.length>k&&(F=this._parseStack.position+k)}if(this._logService.logLevel<=v.LogLevelEnum.DEBUG&&this._logService.debug("parsing data"+(typeof m=="string"?` "${m}"`:` "${Array.prototype.map.call(m,(C=>String.fromCharCode(C))).join("")}"`),typeof m=="string"?m.split("").map((C=>C.charCodeAt(0))):m),this._parseBuffer.lengthk)for(let C=F;C0&&P.getWidth(this._activeBuffer.x-1)===2&&P.setCellFromCodepoint(this._activeBuffer.x-1,0,1,T);let U=this._parser.precedingJoinState;for(let W=w;Wz){if(R){const ie=P;let K=this._activeBuffer.x-ee;for(this._activeBuffer.x=ee,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),P=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),ee>0&&P instanceof t.BufferLine&&P.copyCellsFrom(ie,K,0,ee,!1);K=0;)P.setCellFromCodepoint(this._activeBuffer.x++,0,0,T)}else if(C&&(P.insertCells(this._activeBuffer.x,O-ee,this._activeBuffer.getNullCell(T)),P.getWidth(z-1)===2&&P.setCellFromCodepoint(z-1,e.NULL_CELL_CODE,e.NULL_CELL_WIDTH,T)),P.setCellFromCodepoint(this._activeBuffer.x++,D,O,T),O>0)for(;--O;)P.setCellFromCodepoint(this._activeBuffer.x++,0,0,T)}this._parser.precedingJoinState=U,this._activeBuffer.x0&&P.getWidth(this._activeBuffer.x)===0&&!P.hasContent(this._activeBuffer.x)&&P.setCellFromCodepoint(this._activeBuffer.x,0,1,T),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(m,w){return m.final!=="t"||m.prefix||m.intermediates?this._parser.registerCsiHandler(m,w):this._parser.registerCsiHandler(m,(y=>!L(y.params[0],this._optionsService.rawOptions.windowOptions)||w(y)))}registerDcsHandler(m,w){return this._parser.registerDcsHandler(m,new c.DcsHandler(w))}registerEscHandler(m,w){return this._parser.registerEscHandler(m,w)}registerOscHandler(m,w){return this._parser.registerOscHandler(m,new p.OscHandler(w))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(this._activeBuffer.x===0&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const m=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);m.hasWidth(this._activeBuffer.x)&&!m.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const m=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-m),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(m=this._bufferService.cols-1){this._activeBuffer.x=Math.min(m,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(m,w){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=m,this._activeBuffer.y=this._activeBuffer.scrollTop+w):(this._activeBuffer.x=m,this._activeBuffer.y=w),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(m,w){this._restrictCursor(),this._setCursor(this._activeBuffer.x+m,this._activeBuffer.y+w)}cursorUp(m){const w=this._activeBuffer.y-this._activeBuffer.scrollTop;return w>=0?this._moveCursor(0,-Math.min(w,m.params[0]||1)):this._moveCursor(0,-(m.params[0]||1)),!0}cursorDown(m){const w=this._activeBuffer.scrollBottom-this._activeBuffer.y;return w>=0?this._moveCursor(0,Math.min(w,m.params[0]||1)):this._moveCursor(0,m.params[0]||1),!0}cursorForward(m){return this._moveCursor(m.params[0]||1,0),!0}cursorBackward(m){return this._moveCursor(-(m.params[0]||1),0),!0}cursorNextLine(m){return this.cursorDown(m),this._activeBuffer.x=0,!0}cursorPrecedingLine(m){return this.cursorUp(m),this._activeBuffer.x=0,!0}cursorCharAbsolute(m){return this._setCursor((m.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(m){return this._setCursor(m.length>=2?(m.params[1]||1)-1:0,(m.params[0]||1)-1),!0}charPosAbsolute(m){return this._setCursor((m.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(m){return this._moveCursor(m.params[0]||1,0),!0}linePosAbsolute(m){return this._setCursor(this._activeBuffer.x,(m.params[0]||1)-1),!0}vPositionRelative(m){return this._moveCursor(0,m.params[0]||1),!0}hVPosition(m){return this.cursorPosition(m),!0}tabClear(m){const w=m.params[0];return w===0?delete this._activeBuffer.tabs[this._activeBuffer.x]:w===3&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(m){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let w=m.params[0]||1;for(;w--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(m){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let w=m.params[0]||1;for(;w--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(m){const w=m.params[0];return w===1&&(this._curAttrData.bg|=536870912),w!==2&&w!==0||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(m,w,y,D=!1,O=!1){const F=this._activeBuffer.lines.get(this._activeBuffer.ybase+m);F.replaceCells(w,y,this._activeBuffer.getNullCell(this._eraseAttrData()),O),D&&(F.isWrapped=!1)}_resetBufferLine(m,w=!1){const y=this._activeBuffer.lines.get(this._activeBuffer.ybase+m);y&&(y.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),w),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+m),y.isWrapped=!1)}eraseInDisplay(m,w=!1){let y;switch(this._restrictCursor(this._bufferService.cols),m.params[0]){case 0:for(y=this._activeBuffer.y,this._dirtyRowTracker.markDirty(y),this._eraseInBufferLine(y++,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,w);y=this._bufferService.cols&&(this._activeBuffer.lines.get(y+1).isWrapped=!1);y--;)this._resetBufferLine(y,w);this._dirtyRowTracker.markDirty(0);break;case 2:for(y=this._bufferService.rows,this._dirtyRowTracker.markDirty(y-1);y--;)this._resetBufferLine(y,w);this._dirtyRowTracker.markDirty(0);break;case 3:const D=this._activeBuffer.lines.length-this._bufferService.rows;D>0&&(this._activeBuffer.lines.trimStart(D),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-D,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-D,0),this._onScroll.fire(0))}return!0}eraseInLine(m,w=!1){switch(this._restrictCursor(this._bufferService.cols),m.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,w);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,w);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,w)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(m){this._restrictCursor();let w=m.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let R=z;for(let C=1;C0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(m){return m.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(m.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(m){return(this._optionsService.rawOptions.termName+"").indexOf(m)===0}setMode(m){for(let w=0;wQ?1:2,U=m.params[0];return W=U,J=w?U===2?4:U===4?P(F.modes.insertMode):U===12?3:U===20?P(T.convertEol):0:U===1?P(y.applicationCursorKeys):U===3?T.windowOptions.setWinLines?z===80?2:z===132?1:0:0:U===6?P(y.origin):U===7?P(y.wraparound):U===8?3:U===9?P(D==="X10"):U===12?P(T.cursorBlink):U===25?P(!F.isCursorHidden):U===45?P(y.reverseWraparound):U===66?P(y.applicationKeypad):U===67?4:U===1e3?P(D==="VT200"):U===1002?P(D==="DRAG"):U===1003?P(D==="ANY"):U===1004?P(y.sendFocus):U===1005?4:U===1006?P(O==="SGR"):U===1015?4:U===1016?P(O==="SGR_PIXELS"):U===1048?1:U===47||U===1047||U===1049?P(R===C):U===2004?P(y.bracketedPasteMode):0,F.triggerDataEvent(`${n.C0.ESC}[${w?"":"?"}${W};${J}$y`),!0;var W,J}_updateAttrColor(m,w,y,D,O){return w===2?(m|=50331648,m&=-16777216,m|=a.AttributeData.fromColorRGB([y,D,O])):w===5&&(m&=-50331904,m|=33554432|255&y),m}_extractColor(m,w,y){const D=[0,0,-1,0,0,0];let O=0,F=0;do{if(D[F+O]=m.params[w+F],m.hasSubParams(w+F)){const $=m.getSubParams(w+F);let z=0;do D[1]===5&&(O=1),D[F+z+1+O]=$[z];while(++z<$.length&&z+F+1+O=2||D[1]===2&&F+O>=5)break;D[1]&&(O=1)}while(++F+w5)&&(m=1),w.extended.underlineStyle=m,w.fg|=268435456,m===0&&(w.fg&=-268435457),w.updateExtended()}_processSGR0(m){m.fg=t.DEFAULT_ATTR_DATA.fg,m.bg=t.DEFAULT_ATTR_DATA.bg,m.extended=m.extended.clone(),m.extended.underlineStyle=0,m.extended.underlineColor&=-67108864,m.updateExtended()}charAttributes(m){if(m.length===1&&m.params[0]===0)return this._processSGR0(this._curAttrData),!0;const w=m.length;let y;const D=this._curAttrData;for(let O=0;O=30&&y<=37?(D.fg&=-50331904,D.fg|=16777216|y-30):y>=40&&y<=47?(D.bg&=-50331904,D.bg|=16777216|y-40):y>=90&&y<=97?(D.fg&=-50331904,D.fg|=16777224|y-90):y>=100&&y<=107?(D.bg&=-50331904,D.bg|=16777224|y-100):y===0?this._processSGR0(D):y===1?D.fg|=134217728:y===3?D.bg|=67108864:y===4?(D.fg|=268435456,this._processUnderline(m.hasSubParams(O)?m.getSubParams(O)[0]:1,D)):y===5?D.fg|=536870912:y===7?D.fg|=67108864:y===8?D.fg|=1073741824:y===9?D.fg|=2147483648:y===2?D.bg|=134217728:y===21?this._processUnderline(2,D):y===22?(D.fg&=-134217729,D.bg&=-134217729):y===23?D.bg&=-67108865:y===24?(D.fg&=-268435457,this._processUnderline(0,D)):y===25?D.fg&=-536870913:y===27?D.fg&=-67108865:y===28?D.fg&=-1073741825:y===29?D.fg&=2147483647:y===39?(D.fg&=-67108864,D.fg|=16777215&t.DEFAULT_ATTR_DATA.fg):y===49?(D.bg&=-67108864,D.bg|=16777215&t.DEFAULT_ATTR_DATA.bg):y===38||y===48||y===58?O+=this._extractColor(m,O,D):y===53?D.bg|=1073741824:y===55?D.bg&=-1073741825:y===59?(D.extended=D.extended.clone(),D.extended.underlineColor=-1,D.updateExtended()):y===100?(D.fg&=-67108864,D.fg|=16777215&t.DEFAULT_ATTR_DATA.fg,D.bg&=-67108864,D.bg|=16777215&t.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",y);return!0}deviceStatus(m){switch(m.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const w=this._activeBuffer.y+1,y=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${w};${y}R`)}return!0}deviceStatusPrivate(m){if(m.params[0]===6){const w=this._activeBuffer.y+1,y=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${w};${y}R`)}return!0}softReset(m){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=t.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(m){const w=m.params[0]||1;switch(w){case 1:case 2:this._optionsService.options.cursorStyle="block";break;case 3:case 4:this._optionsService.options.cursorStyle="underline";break;case 5:case 6:this._optionsService.options.cursorStyle="bar"}const y=w%2==1;return this._optionsService.options.cursorBlink=y,!0}setScrollRegion(m){const w=m.params[0]||1;let y;return(m.length<2||(y=m.params[1])>this._bufferService.rows||y===0)&&(y=this._bufferService.rows),y>w&&(this._activeBuffer.scrollTop=w-1,this._activeBuffer.scrollBottom=y-1,this._setCursor(0,0)),!0}windowOptions(m){if(!L(m.params[0],this._optionsService.rawOptions.windowOptions))return!0;const w=m.length>1?m.params[1]:0;switch(m.params[0]){case 14:w!==2&&this._onRequestWindowsOptionsReport.fire(b.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(b.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:w!==0&&w!==2||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),w!==0&&w!==1||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:w!==0&&w!==2||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),w!==0&&w!==1||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(m){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(m){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(m){return this._windowTitle=m,this._onTitleChange.fire(m),!0}setIconName(m){return this._iconName=m,!0}setOrReportIndexedColor(m){const w=[],y=m.split(";");for(;y.length>1;){const D=y.shift(),O=y.shift();if(/^\d+$/.exec(D)){const F=parseInt(D);if(H(F))if(O==="?")w.push({type:0,index:F});else{const $=(0,S.parseColor)(O);$&&w.push({type:1,index:F,color:$})}}}return w.length&&this._onColor.fire(w),!0}setHyperlink(m){const w=m.split(";");return!(w.length<2)&&(w[1]?this._createHyperlink(w[0],w[1]):!w[0]&&this._finishHyperlink())}_createHyperlink(m,w){this._getCurrentLinkId()&&this._finishHyperlink();const y=m.split(":");let D;const O=y.findIndex((F=>F.startsWith("id=")));return O!==-1&&(D=y[O].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:D,uri:w}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(m,w){const y=m.split(";");for(let D=0;D=this._specialColors.length);++D,++w)if(y[D]==="?")this._onColor.fire([{type:0,index:this._specialColors[w]}]);else{const O=(0,S.parseColor)(y[D]);O&&this._onColor.fire([{type:1,index:this._specialColors[w],color:O}])}return!0}setOrReportFgColor(m){return this._setOrReportSpecialColor(m,0)}setOrReportBgColor(m){return this._setOrReportSpecialColor(m,1)}setOrReportCursorColor(m){return this._setOrReportSpecialColor(m,2)}restoreIndexedColor(m){if(!m)return this._onColor.fire([{type:2}]),!0;const w=[],y=m.split(";");for(let D=0;D=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const m=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,m,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=t.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=t.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(m){return this._charsetService.setgLevel(m),!0}screenAlignmentPattern(){const m=new i.CellData;m.content=4194373,m.fg=this._curAttrData.fg,m.bg=this._curAttrData.bg,this._setCursor(0,0);for(let w=0;w(this._coreService.triggerDataEvent(`${n.C0.ESC}${O}${n.C0.ESC}\\`),!0))(m==='"q'?`P1$r${this._curAttrData.isProtected()?1:0}"q`:m==='"p'?'P1$r61;1"p':m==="r"?`P1$r${y.scrollTop+1};${y.scrollBottom+1}r`:m==="m"?"P1$r0m":m===" q"?`P1$r${{block:2,underline:4,bar:6}[D.cursorStyle]-(D.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(m,w){this._dirtyRowTracker.markRangeDirty(m,w)}}r.InputHandler=M;let B=class{constructor(I){this._bufferService=I,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(I){Ithis.end&&(this.end=I)}markRangeDirty(I,m){I>m&&(A=I,I=m,m=A),Ithis.end&&(this.end=m)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function H(I){return 0<=I&&I<256}B=l([_(0,v.IBufferService)],B)},844:(x,r)=>{function o(l){for(const _ of l)_.dispose();l.length=0}Object.defineProperty(r,"__esModule",{value:!0}),r.getDisposeArrayDisposable=r.disposeArray=r.toDisposable=r.MutableDisposable=r.Disposable=void 0,r.Disposable=class{constructor(){this._disposables=[],this._isDisposed=!1}dispose(){this._isDisposed=!0;for(const l of this._disposables)l.dispose();this._disposables.length=0}register(l){return this._disposables.push(l),l}unregister(l){const _=this._disposables.indexOf(l);_!==-1&&this._disposables.splice(_,1)}},r.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(l){this._isDisposed||l===this._value||(this._value?.dispose(),this._value=l)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}},r.toDisposable=function(l){return{dispose:l}},r.disposeArray=o,r.getDisposeArrayDisposable=function(l){return{dispose:()=>o(l)}}},1505:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.FourKeyMap=r.TwoKeyMap=void 0;class o{constructor(){this._data={}}set(_,n,d){this._data[_]||(this._data[_]={}),this._data[_][n]=d}get(_,n){return this._data[_]?this._data[_][n]:void 0}clear(){this._data={}}}r.TwoKeyMap=o,r.FourKeyMap=class{constructor(){this._data=new o}set(l,_,n,d,f){this._data.get(l,_)||this._data.set(l,_,new o),this._data.get(l,_).set(n,d,f)}get(l,_,n,d){return this._data.get(l,_)?.get(n,d)}clear(){this._data.clear()}}},6114:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.isChromeOS=r.isLinux=r.isWindows=r.isIphone=r.isIpad=r.isMac=r.getSafariVersion=r.isSafari=r.isLegacyEdge=r.isFirefox=r.isNode=void 0,r.isNode=typeof process<"u"&&"title"in process;const o=r.isNode?"node":navigator.userAgent,l=r.isNode?"node":navigator.platform;r.isFirefox=o.includes("Firefox"),r.isLegacyEdge=o.includes("Edge"),r.isSafari=/^((?!chrome|android).)*safari/i.test(o),r.getSafariVersion=function(){if(!r.isSafari)return 0;const _=o.match(/Version\/(\d+)/);return _===null||_.length<2?0:parseInt(_[1])},r.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(l),r.isIpad=l==="iPad",r.isIphone=l==="iPhone",r.isWindows=["Windows","Win16","Win32","WinCE"].includes(l),r.isLinux=l.indexOf("Linux")>=0,r.isChromeOS=/\bCrOS\b/.test(o)},6106:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.SortedList=void 0;let o=0;r.SortedList=class{constructor(l){this._getKey=l,this._array=[]}clear(){this._array.length=0}insert(l){this._array.length!==0?(o=this._search(this._getKey(l)),this._array.splice(o,0,l)):this._array.push(l)}delete(l){if(this._array.length===0)return!1;const _=this._getKey(l);if(_===void 0||(o=this._search(_),o===-1)||this._getKey(this._array[o])!==_)return!1;do if(this._array[o]===l)return this._array.splice(o,1),!0;while(++o=this._array.length)&&this._getKey(this._array[o])===l))do yield this._array[o];while(++o=this._array.length)&&this._getKey(this._array[o])===l))do _(this._array[o]);while(++o=_;){let d=_+n>>1;const f=this._getKey(this._array[d]);if(f>l)n=d-1;else{if(!(f0&&this._getKey(this._array[d-1])===l;)d--;return d}_=d+1}}return _}}},7226:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.DebouncedIdleTask=r.IdleTaskQueue=r.PriorityTaskQueue=void 0;const l=o(6114);class _{constructor(){this._tasks=[],this._i=0}enqueue(f){this._tasks.push(f),this._start()}flush(){for(;this._is)return t-g<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(t-g))}ms`),void this._start();t=s}this.clear()}}class n extends _{_requestCallback(f){return setTimeout((()=>f(this._createDeadline(16))))}_cancelCallback(f){clearTimeout(f)}_createDeadline(f){const g=Date.now()+f;return{timeRemaining:()=>Math.max(0,g-Date.now())}}}r.PriorityTaskQueue=n,r.IdleTaskQueue=!l.isNode&&"requestIdleCallback"in window?class extends _{_requestCallback(d){return requestIdleCallback(d)}_cancelCallback(d){cancelIdleCallback(d)}}:n,r.DebouncedIdleTask=class{constructor(){this._queue=new r.IdleTaskQueue}set(d){this._queue.clear(),this._queue.enqueue(d)}flush(){this._queue.flush()}}},9282:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.updateWindowsModeWrappedState=void 0;const l=o(643);r.updateWindowsModeWrappedState=function(_){const n=_.buffer.lines.get(_.buffer.ybase+_.buffer.y-1),d=n?.get(_.cols-1),f=_.buffer.lines.get(_.buffer.ybase+_.buffer.y);f&&d&&(f.isWrapped=d[l.CHAR_DATA_CODE_INDEX]!==l.NULL_CELL_CODE&&d[l.CHAR_DATA_CODE_INDEX]!==l.WHITESPACE_CELL_CODE)}},3734:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.ExtendedAttrs=r.AttributeData=void 0;class o{constructor(){this.fg=0,this.bg=0,this.extended=new l}static toColorRGB(n){return[n>>>16&255,n>>>8&255,255&n]}static fromColorRGB(n){return(255&n[0])<<16|(255&n[1])<<8|255&n[2]}clone(){const n=new o;return n.fg=this.fg,n.bg=this.bg,n.extended=this.extended.clone(),n}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return(50331648&this.fg)==50331648}isBgRGB(){return(50331648&this.bg)==50331648}isFgPalette(){return(50331648&this.fg)==16777216||(50331648&this.fg)==33554432}isBgPalette(){return(50331648&this.bg)==16777216||(50331648&this.bg)==33554432}isFgDefault(){return(50331648&this.fg)==0}isBgDefault(){return(50331648&this.bg)==0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?(50331648&this.extended.underlineColor)==50331648:this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?(50331648&this.extended.underlineColor)==16777216||(50331648&this.extended.underlineColor)==33554432:this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?(50331648&this.extended.underlineColor)==0:this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}r.AttributeData=o;class l{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(n){this._ext=n}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(n){this._ext&=-469762049,this._ext|=n<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(n){this._ext&=-67108864,this._ext|=67108863&n}get urlId(){return this._urlId}set urlId(n){this._urlId=n}get underlineVariantOffset(){const n=(3758096384&this._ext)>>29;return n<0?4294967288^n:n}set underlineVariantOffset(n){this._ext&=536870911,this._ext|=n<<29&3758096384}constructor(n=0,d=0){this._ext=0,this._urlId=0,this._ext=n,this._urlId=d}clone(){return new l(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}}r.ExtendedAttrs=l},9092:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.Buffer=r.MAX_BUFFER_SIZE=void 0;const l=o(6349),_=o(7226),n=o(3734),d=o(8437),f=o(4634),g=o(511),h=o(643),t=o(4863),s=o(7116);r.MAX_BUFFER_SIZE=4294967295,r.Buffer=class{constructor(e,i,a){this._hasScrollback=e,this._optionsService=i,this._bufferService=a,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=d.DEFAULT_ATTR_DATA.clone(),this.savedCharset=s.DEFAULT_CHARSET,this.markers=[],this._nullCell=g.CellData.fromCharData([0,h.NULL_CELL_CHAR,h.NULL_CELL_WIDTH,h.NULL_CELL_CODE]),this._whitespaceCell=g.CellData.fromCharData([0,h.WHITESPACE_CELL_CHAR,h.WHITESPACE_CELL_WIDTH,h.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new _.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new l.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,i){return new d.BufferLine(this._bufferService.cols,this.getNullCell(e),i)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&er.MAX_BUFFER_SIZE?r.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(this.lines.length===0){e===void 0&&(e=d.DEFAULT_ATTR_DATA);let i=this._rows;for(;i--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new l.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,i){const a=this.getNullCell(d.DEFAULT_ATTR_DATA);let v=0;const u=this._getCorrectBufferLength(i);if(u>this.lines.maxLength&&(this.lines.maxLength=u),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+p+1?(this.ybase--,p++,this.ydisp>0&&this.ydisp--):this.lines.push(new d.BufferLine(e,a)));else for(let c=this._rows;c>i;c--)this.lines.length>i+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(u0&&(this.lines.trimStart(c),this.ybase=Math.max(this.ybase-c,0),this.ydisp=Math.max(this.ydisp-c,0),this.savedY=Math.max(this.savedY-c,0)),this.lines.maxLength=u}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,i-1),p&&(this.y+=p),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=i-1,this._isReflowEnabled&&(this._reflow(e,i),this._cols>e))for(let p=0;p.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let i=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&e.backend==="conpty"&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,i){this._cols!==e&&(e>this._cols?this._reflowLarger(e,i):this._reflowSmaller(e,i))}_reflowLarger(e,i){const a=(0,f.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(d.DEFAULT_ATTR_DATA));if(a.length>0){const v=(0,f.reflowLargerCreateNewLayout)(this.lines,a);(0,f.reflowLargerApplyNewLayout)(this.lines,v.layout),this._reflowLargerAdjustViewport(e,i,v.countRemoved)}}_reflowLargerAdjustViewport(e,i,a){const v=this.getNullCell(d.DEFAULT_ATTR_DATA);let u=a;for(;u-- >0;)this.ybase===0?(this.y>0&&this.y--,this.lines.length=0;p--){let c=this.lines.get(p);if(!c||!c.isWrapped&&c.getTrimmedLength()<=e)continue;const S=[c];for(;c.isWrapped&&p>0;)c=this.lines.get(--p),S.unshift(c);const E=this.ybase+this.y;if(E>=p&&E0&&(v.push({start:p+S.length+u,newLines:M}),u+=M.length),S.push(...M);let B=L.length-1,H=L[B];H===0&&(B--,H=L[B]);let I=S.length-b-1,m=k;for(;I>=0;){const y=Math.min(m,H);if(S[B]===void 0)break;if(S[B].copyCellsFrom(S[I],m-y,H-y,y,!0),H-=y,H===0&&(B--,H=L[B]),m-=y,m===0){I--;const D=Math.max(I,0);m=(0,f.getWrappedLineTrimmedLength)(S,D,this._cols)}}for(let y=0;y0;)this.ybase===0?this.y0){const p=[],c=[];for(let B=0;B=0;B--)if(L&&L.start>E+b){for(let H=L.newLines.length-1;H>=0;H--)this.lines.set(B--,L.newLines[H]);B++,p.push({index:E+1,amount:L.newLines.length}),b+=L.newLines.length,L=v[++k]}else this.lines.set(B,c[E--]);let A=0;for(let B=p.length-1;B>=0;B--)p[B].index+=A,this.lines.onInsertEmitter.fire(p[B]),A+=p[B].amount;const M=Math.max(0,S+u-this.lines.maxLength);M>0&&this.lines.onTrimEmitter.fire(M)}}translateBufferLineToString(e,i,a=0,v){const u=this.lines.get(e);return u?u.translateToString(i,a,v):""}getWrappedRangeForLine(e){let i=e,a=e;for(;i>0&&this.lines.get(i).isWrapped;)i--;for(;a+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(e==null&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let i=0;i{i.line-=a,i.line<0&&i.dispose()}))),i.register(this.lines.onInsert((a=>{i.line>=a.index&&(i.line+=a.amount)}))),i.register(this.lines.onDelete((a=>{i.line>=a.index&&i.linea.index&&(i.line-=a.amount)}))),i.register(i.onDispose((()=>this._removeMarker(i)))),i}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},8437:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.BufferLine=r.DEFAULT_ATTR_DATA=void 0;const l=o(3734),_=o(511),n=o(643),d=o(482);r.DEFAULT_ATTR_DATA=Object.freeze(new l.AttributeData);let f=0;class g{constructor(t,s,e=!1){this.isWrapped=e,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*t);const i=s||_.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let a=0;a>22,2097152&s?this._combined[t].charCodeAt(this._combined[t].length-1):e]}set(t,s){this._data[3*t+1]=s[n.CHAR_DATA_ATTR_INDEX],s[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[t]=s[1],this._data[3*t+0]=2097152|t|s[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*t+0]=s[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|s[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(t){return this._data[3*t+0]>>22}hasWidth(t){return 12582912&this._data[3*t+0]}getFg(t){return this._data[3*t+1]}getBg(t){return this._data[3*t+2]}hasContent(t){return 4194303&this._data[3*t+0]}getCodePoint(t){const s=this._data[3*t+0];return 2097152&s?this._combined[t].charCodeAt(this._combined[t].length-1):2097151&s}isCombined(t){return 2097152&this._data[3*t+0]}getString(t){const s=this._data[3*t+0];return 2097152&s?this._combined[t]:2097151&s?(0,d.stringFromCodePoint)(2097151&s):""}isProtected(t){return 536870912&this._data[3*t+2]}loadCell(t,s){return f=3*t,s.content=this._data[f+0],s.fg=this._data[f+1],s.bg=this._data[f+2],2097152&s.content&&(s.combinedData=this._combined[t]),268435456&s.bg&&(s.extended=this._extendedAttrs[t]),s}setCell(t,s){2097152&s.content&&(this._combined[t]=s.combinedData),268435456&s.bg&&(this._extendedAttrs[t]=s.extended),this._data[3*t+0]=s.content,this._data[3*t+1]=s.fg,this._data[3*t+2]=s.bg}setCellFromCodepoint(t,s,e,i){268435456&i.bg&&(this._extendedAttrs[t]=i.extended),this._data[3*t+0]=s|e<<22,this._data[3*t+1]=i.fg,this._data[3*t+2]=i.bg}addCodepointToCell(t,s,e){let i=this._data[3*t+0];2097152&i?this._combined[t]+=(0,d.stringFromCodePoint)(s):2097151&i?(this._combined[t]=(0,d.stringFromCodePoint)(2097151&i)+(0,d.stringFromCodePoint)(s),i&=-2097152,i|=2097152):i=s|4194304,e&&(i&=-12582913,i|=e<<22),this._data[3*t+0]=i}insertCells(t,s,e){if((t%=this.length)&&this.getWidth(t-1)===2&&this.setCellFromCodepoint(t-1,0,1,e),s=0;--a)this.setCell(t+s+a,this.loadCell(t+a,i));for(let a=0;athis.length){if(this._data.buffer.byteLength>=4*e)this._data=new Uint32Array(this._data.buffer,0,e);else{const i=new Uint32Array(e);i.set(this._data),this._data=i}for(let i=this.length;i=t&&delete this._combined[u]}const a=Object.keys(this._extendedAttrs);for(let v=0;v=t&&delete this._extendedAttrs[u]}}return this.length=t,4*e*2=0;--t)if(4194303&this._data[3*t+0])return t+(this._data[3*t+0]>>22);return 0}getNoBgTrimmedLength(){for(let t=this.length-1;t>=0;--t)if(4194303&this._data[3*t+0]||50331648&this._data[3*t+2])return t+(this._data[3*t+0]>>22);return 0}copyCellsFrom(t,s,e,i,a){const v=t._data;if(a)for(let p=i-1;p>=0;p--){for(let c=0;c<3;c++)this._data[3*(e+p)+c]=v[3*(s+p)+c];268435456&v[3*(s+p)+2]&&(this._extendedAttrs[e+p]=t._extendedAttrs[s+p])}else for(let p=0;p=s&&(this._combined[c-s+e]=t._combined[c])}}translateToString(t,s,e,i){s=s??0,e=e??this.length,t&&(e=Math.min(e,this.getTrimmedLength())),i&&(i.length=0);let a="";for(;s>22||1}return i&&i.push(s),a}}r.BufferLine=g},4841:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.getRangeLength=void 0,r.getRangeLength=function(o,l){if(o.start.y>o.end.y)throw new Error(`Buffer range end (${o.end.x}, ${o.end.y}) cannot be before start (${o.start.x}, ${o.start.y})`);return l*(o.end.y-o.start.y)+(o.end.x-o.start.x+1)}},4634:(x,r)=>{function o(l,_,n){if(_===l.length-1)return l[_].getTrimmedLength();const d=!l[_].hasContent(n-1)&&l[_].getWidth(n-1)===1,f=l[_+1].getWidth(0)===2;return d&&f?n-1:n}Object.defineProperty(r,"__esModule",{value:!0}),r.getWrappedLineTrimmedLength=r.reflowSmallerGetNewLineLengths=r.reflowLargerApplyNewLayout=r.reflowLargerCreateNewLayout=r.reflowLargerGetLinesToRemove=void 0,r.reflowLargerGetLinesToRemove=function(l,_,n,d,f){const g=[];for(let h=0;h=h&&d0&&(c>i||e[c].getTrimmedLength()===0);c--)p++;p>0&&(g.push(h+e.length-p),g.push(p)),h+=e.length-1}return g},r.reflowLargerCreateNewLayout=function(l,_){const n=[];let d=0,f=_[d],g=0;for(let h=0;ho(l,e,_))).reduce(((s,e)=>s+e));let g=0,h=0,t=0;for(;ts&&(g-=s,h++);const e=l[h].getWidth(g-1)===2;e&&g--;const i=e?n-1:n;d.push(i),t+=i}return d},r.getWrappedLineTrimmedLength=o},5295:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.BufferSet=void 0;const l=o(8460),_=o(844),n=o(9092);class d extends _.Disposable{constructor(g,h){super(),this._optionsService=g,this._bufferService=h,this._onBufferActivate=this.register(new l.EventEmitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this.register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new n.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new n.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(g){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(g),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(g,h){this._normal.resize(g,h),this._alt.resize(g,h),this.setupTabStops(g)}setupTabStops(g){this._normal.setupTabStops(g),this._alt.setupTabStops(g)}}r.BufferSet=d},511:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.CellData=void 0;const l=o(482),_=o(643),n=o(3734);class d extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(g){const h=new d;return h.setFromCharData(g),h}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,l.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(g){this.fg=g[_.CHAR_DATA_ATTR_INDEX],this.bg=0;let h=!1;if(g[_.CHAR_DATA_CHAR_INDEX].length>2)h=!0;else if(g[_.CHAR_DATA_CHAR_INDEX].length===2){const t=g[_.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=t&&t<=56319){const s=g[_.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(t-55296)+s-56320+65536|g[_.CHAR_DATA_WIDTH_INDEX]<<22:h=!0}else h=!0}else this.content=g[_.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|g[_.CHAR_DATA_WIDTH_INDEX]<<22;h&&(this.combinedData=g[_.CHAR_DATA_CHAR_INDEX],this.content=2097152|g[_.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}r.CellData=d},643:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.WHITESPACE_CELL_CODE=r.WHITESPACE_CELL_WIDTH=r.WHITESPACE_CELL_CHAR=r.NULL_CELL_CODE=r.NULL_CELL_WIDTH=r.NULL_CELL_CHAR=r.CHAR_DATA_CODE_INDEX=r.CHAR_DATA_WIDTH_INDEX=r.CHAR_DATA_CHAR_INDEX=r.CHAR_DATA_ATTR_INDEX=r.DEFAULT_EXT=r.DEFAULT_ATTR=r.DEFAULT_COLOR=void 0,r.DEFAULT_COLOR=0,r.DEFAULT_ATTR=256|r.DEFAULT_COLOR<<9,r.DEFAULT_EXT=0,r.CHAR_DATA_ATTR_INDEX=0,r.CHAR_DATA_CHAR_INDEX=1,r.CHAR_DATA_WIDTH_INDEX=2,r.CHAR_DATA_CODE_INDEX=3,r.NULL_CELL_CHAR="",r.NULL_CELL_WIDTH=1,r.NULL_CELL_CODE=0,r.WHITESPACE_CELL_CHAR=" ",r.WHITESPACE_CELL_WIDTH=1,r.WHITESPACE_CELL_CODE=32},4863:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.Marker=void 0;const l=o(8460),_=o(844);class n{get id(){return this._id}constructor(f){this.line=f,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new l.EventEmitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,_.disposeArray)(this._disposables),this._disposables.length=0)}register(f){return this._disposables.push(f),f}}r.Marker=n,n._nextId=1},7116:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.DEFAULT_CHARSET=r.CHARSETS=void 0,r.CHARSETS={},r.DEFAULT_CHARSET=r.CHARSETS.B,r.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},r.CHARSETS.A={"#":"£"},r.CHARSETS.B=void 0,r.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},r.CHARSETS.C=r.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},r.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},r.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},r.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},r.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},r.CHARSETS.E=r.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},r.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},r.CHARSETS.H=r.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},r.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},2584:(x,r)=>{var o,l,_;Object.defineProperty(r,"__esModule",{value:!0}),r.C1_ESCAPED=r.C1=r.C0=void 0,(function(n){n.NUL="\0",n.SOH="",n.STX="",n.ETX="",n.EOT="",n.ENQ="",n.ACK="",n.BEL="\x07",n.BS="\b",n.HT=" ",n.LF=` +`,n.VT="\v",n.FF="\f",n.CR="\r",n.SO="",n.SI="",n.DLE="",n.DC1="",n.DC2="",n.DC3="",n.DC4="",n.NAK="",n.SYN="",n.ETB="",n.CAN="",n.EM="",n.SUB="",n.ESC="\x1B",n.FS="",n.GS="",n.RS="",n.US="",n.SP=" ",n.DEL=""})(o||(r.C0=o={})),(function(n){n.PAD="€",n.HOP="",n.BPH="‚",n.NBH="ƒ",n.IND="„",n.NEL="…",n.SSA="†",n.ESA="‡",n.HTS="ˆ",n.HTJ="‰",n.VTS="Š",n.PLD="‹",n.PLU="Œ",n.RI="",n.SS2="Ž",n.SS3="",n.DCS="",n.PU1="‘",n.PU2="’",n.STS="“",n.CCH="”",n.MW="•",n.SPA="–",n.EPA="—",n.SOS="˜",n.SGCI="™",n.SCI="š",n.CSI="›",n.ST="œ",n.OSC="",n.PM="ž",n.APC="Ÿ"})(l||(r.C1=l={})),(function(n){n.ST=`${o.ESC}\\`})(_||(r.C1_ESCAPED=_={}))},7399:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.evaluateKeyboardEvent=void 0;const l=o(2584),_={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};r.evaluateKeyboardEvent=function(n,d,f,g){const h={type:0,cancel:!1,key:void 0},t=(n.shiftKey?1:0)|(n.altKey?2:0)|(n.ctrlKey?4:0)|(n.metaKey?8:0);switch(n.keyCode){case 0:n.key==="UIKeyInputUpArrow"?h.key=d?l.C0.ESC+"OA":l.C0.ESC+"[A":n.key==="UIKeyInputLeftArrow"?h.key=d?l.C0.ESC+"OD":l.C0.ESC+"[D":n.key==="UIKeyInputRightArrow"?h.key=d?l.C0.ESC+"OC":l.C0.ESC+"[C":n.key==="UIKeyInputDownArrow"&&(h.key=d?l.C0.ESC+"OB":l.C0.ESC+"[B");break;case 8:h.key=n.ctrlKey?"\b":l.C0.DEL,n.altKey&&(h.key=l.C0.ESC+h.key);break;case 9:if(n.shiftKey){h.key=l.C0.ESC+"[Z";break}h.key=l.C0.HT,h.cancel=!0;break;case 13:h.key=n.altKey?l.C0.ESC+l.C0.CR:l.C0.CR,h.cancel=!0;break;case 27:h.key=l.C0.ESC,n.altKey&&(h.key=l.C0.ESC+l.C0.ESC),h.cancel=!0;break;case 37:if(n.metaKey)break;t?(h.key=l.C0.ESC+"[1;"+(t+1)+"D",h.key===l.C0.ESC+"[1;3D"&&(h.key=l.C0.ESC+(f?"b":"[1;5D"))):h.key=d?l.C0.ESC+"OD":l.C0.ESC+"[D";break;case 39:if(n.metaKey)break;t?(h.key=l.C0.ESC+"[1;"+(t+1)+"C",h.key===l.C0.ESC+"[1;3C"&&(h.key=l.C0.ESC+(f?"f":"[1;5C"))):h.key=d?l.C0.ESC+"OC":l.C0.ESC+"[C";break;case 38:if(n.metaKey)break;t?(h.key=l.C0.ESC+"[1;"+(t+1)+"A",f||h.key!==l.C0.ESC+"[1;3A"||(h.key=l.C0.ESC+"[1;5A")):h.key=d?l.C0.ESC+"OA":l.C0.ESC+"[A";break;case 40:if(n.metaKey)break;t?(h.key=l.C0.ESC+"[1;"+(t+1)+"B",f||h.key!==l.C0.ESC+"[1;3B"||(h.key=l.C0.ESC+"[1;5B")):h.key=d?l.C0.ESC+"OB":l.C0.ESC+"[B";break;case 45:n.shiftKey||n.ctrlKey||(h.key=l.C0.ESC+"[2~");break;case 46:h.key=t?l.C0.ESC+"[3;"+(t+1)+"~":l.C0.ESC+"[3~";break;case 36:h.key=t?l.C0.ESC+"[1;"+(t+1)+"H":d?l.C0.ESC+"OH":l.C0.ESC+"[H";break;case 35:h.key=t?l.C0.ESC+"[1;"+(t+1)+"F":d?l.C0.ESC+"OF":l.C0.ESC+"[F";break;case 33:n.shiftKey?h.type=2:n.ctrlKey?h.key=l.C0.ESC+"[5;"+(t+1)+"~":h.key=l.C0.ESC+"[5~";break;case 34:n.shiftKey?h.type=3:n.ctrlKey?h.key=l.C0.ESC+"[6;"+(t+1)+"~":h.key=l.C0.ESC+"[6~";break;case 112:h.key=t?l.C0.ESC+"[1;"+(t+1)+"P":l.C0.ESC+"OP";break;case 113:h.key=t?l.C0.ESC+"[1;"+(t+1)+"Q":l.C0.ESC+"OQ";break;case 114:h.key=t?l.C0.ESC+"[1;"+(t+1)+"R":l.C0.ESC+"OR";break;case 115:h.key=t?l.C0.ESC+"[1;"+(t+1)+"S":l.C0.ESC+"OS";break;case 116:h.key=t?l.C0.ESC+"[15;"+(t+1)+"~":l.C0.ESC+"[15~";break;case 117:h.key=t?l.C0.ESC+"[17;"+(t+1)+"~":l.C0.ESC+"[17~";break;case 118:h.key=t?l.C0.ESC+"[18;"+(t+1)+"~":l.C0.ESC+"[18~";break;case 119:h.key=t?l.C0.ESC+"[19;"+(t+1)+"~":l.C0.ESC+"[19~";break;case 120:h.key=t?l.C0.ESC+"[20;"+(t+1)+"~":l.C0.ESC+"[20~";break;case 121:h.key=t?l.C0.ESC+"[21;"+(t+1)+"~":l.C0.ESC+"[21~";break;case 122:h.key=t?l.C0.ESC+"[23;"+(t+1)+"~":l.C0.ESC+"[23~";break;case 123:h.key=t?l.C0.ESC+"[24;"+(t+1)+"~":l.C0.ESC+"[24~";break;default:if(!n.ctrlKey||n.shiftKey||n.altKey||n.metaKey)if(f&&!g||!n.altKey||n.metaKey)!f||n.altKey||n.ctrlKey||n.shiftKey||!n.metaKey?n.key&&!n.ctrlKey&&!n.altKey&&!n.metaKey&&n.keyCode>=48&&n.key.length===1?h.key=n.key:n.key&&n.ctrlKey&&(n.key==="_"&&(h.key=l.C0.US),n.key==="@"&&(h.key=l.C0.NUL)):n.keyCode===65&&(h.type=1);else{const s=_[n.keyCode],e=s?.[n.shiftKey?1:0];if(e)h.key=l.C0.ESC+e;else if(n.keyCode>=65&&n.keyCode<=90){const i=n.ctrlKey?n.keyCode-64:n.keyCode+32;let a=String.fromCharCode(i);n.shiftKey&&(a=a.toUpperCase()),h.key=l.C0.ESC+a}else if(n.keyCode===32)h.key=l.C0.ESC+(n.ctrlKey?l.C0.NUL:" ");else if(n.key==="Dead"&&n.code.startsWith("Key")){let i=n.code.slice(3,4);n.shiftKey||(i=i.toLowerCase()),h.key=l.C0.ESC+i,h.cancel=!0}}else n.keyCode>=65&&n.keyCode<=90?h.key=String.fromCharCode(n.keyCode-64):n.keyCode===32?h.key=l.C0.NUL:n.keyCode>=51&&n.keyCode<=55?h.key=String.fromCharCode(n.keyCode-51+27):n.keyCode===56?h.key=l.C0.DEL:n.keyCode===219?h.key=l.C0.ESC:n.keyCode===220?h.key=l.C0.FS:n.keyCode===221&&(h.key=l.C0.GS)}return h}},482:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.Utf8ToUtf32=r.StringToUtf32=r.utf32ToString=r.stringFromCodePoint=void 0,r.stringFromCodePoint=function(o){return o>65535?(o-=65536,String.fromCharCode(55296+(o>>10))+String.fromCharCode(o%1024+56320)):String.fromCharCode(o)},r.utf32ToString=function(o,l=0,_=o.length){let n="";for(let d=l;d<_;++d){let f=o[d];f>65535?(f-=65536,n+=String.fromCharCode(55296+(f>>10))+String.fromCharCode(f%1024+56320)):n+=String.fromCharCode(f)}return n},r.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(o,l){const _=o.length;if(!_)return 0;let n=0,d=0;if(this._interim){const f=o.charCodeAt(d++);56320<=f&&f<=57343?l[n++]=1024*(this._interim-55296)+f-56320+65536:(l[n++]=this._interim,l[n++]=f),this._interim=0}for(let f=d;f<_;++f){const g=o.charCodeAt(f);if(55296<=g&&g<=56319){if(++f>=_)return this._interim=g,n;const h=o.charCodeAt(f);56320<=h&&h<=57343?l[n++]=1024*(g-55296)+h-56320+65536:(l[n++]=g,l[n++]=h)}else g!==65279&&(l[n++]=g)}return n}},r.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(o,l){const _=o.length;if(!_)return 0;let n,d,f,g,h=0,t=0,s=0;if(this.interim[0]){let a=!1,v=this.interim[0];v&=(224&v)==192?31:(240&v)==224?15:7;let u,p=0;for(;(u=63&this.interim[++p])&&p<4;)v<<=6,v|=u;const c=(224&this.interim[0])==192?2:(240&this.interim[0])==224?3:4,S=c-p;for(;s=_)return 0;if(u=o[s++],(192&u)!=128){s--,a=!0;break}this.interim[p++]=u,v<<=6,v|=63&u}a||(c===2?v<128?s--:l[h++]=v:c===3?v<2048||v>=55296&&v<=57343||v===65279||(l[h++]=v):v<65536||v>1114111||(l[h++]=v)),this.interim.fill(0)}const e=_-4;let i=s;for(;i<_;){for(;!(!(i=_)return this.interim[0]=n,h;if(d=o[i++],(192&d)!=128){i--;continue}if(t=(31&n)<<6|63&d,t<128){i--;continue}l[h++]=t}else if((240&n)==224){if(i>=_)return this.interim[0]=n,h;if(d=o[i++],(192&d)!=128){i--;continue}if(i>=_)return this.interim[0]=n,this.interim[1]=d,h;if(f=o[i++],(192&f)!=128){i--;continue}if(t=(15&n)<<12|(63&d)<<6|63&f,t<2048||t>=55296&&t<=57343||t===65279)continue;l[h++]=t}else if((248&n)==240){if(i>=_)return this.interim[0]=n,h;if(d=o[i++],(192&d)!=128){i--;continue}if(i>=_)return this.interim[0]=n,this.interim[1]=d,h;if(f=o[i++],(192&f)!=128){i--;continue}if(i>=_)return this.interim[0]=n,this.interim[1]=d,this.interim[2]=f,h;if(g=o[i++],(192&g)!=128){i--;continue}if(t=(7&n)<<18|(63&d)<<12|(63&f)<<6|63&g,t<65536||t>1114111)continue;l[h++]=t}}return h}}},225:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.UnicodeV6=void 0;const l=o(1480),_=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],n=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let d;r.UnicodeV6=class{constructor(){if(this.version="6",!d){d=new Uint8Array(65536),d.fill(1),d[0]=0,d.fill(0,1,32),d.fill(0,127,160),d.fill(2,4352,4448),d[9001]=2,d[9002]=2,d.fill(2,11904,42192),d[12351]=1,d.fill(2,44032,55204),d.fill(2,63744,64256),d.fill(2,65040,65050),d.fill(2,65072,65136),d.fill(2,65280,65377),d.fill(2,65504,65511);for(let f=0;f<_.length;++f)d.fill(0,_[f][0],_[f][1]+1)}}wcwidth(f){return f<32?0:f<127?1:f<65536?d[f]:(function(g,h){let t,s=0,e=h.length-1;if(gh[e][1])return!1;for(;e>=s;)if(t=s+e>>1,g>h[t][1])s=t+1;else{if(!(g=131072&&f<=196605||f>=196608&&f<=262141?2:1}charProperties(f,g){let h=this.wcwidth(f),t=h===0&&g!==0;if(t){const s=l.UnicodeService.extractWidth(g);s===0?t=!1:s>h&&(h=s)}return l.UnicodeService.createPropertyValue(0,h,t)}}},5981:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.WriteBuffer=void 0;const l=o(8460),_=o(844);class n extends _.Disposable{constructor(f){super(),this._action=f,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this.register(new l.EventEmitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(f,g){if(g!==void 0&&this._syncCalls>g)return void(this._syncCalls=0);if(this._pendingData+=f.length,this._writeBuffer.push(f),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let h;for(this._isSyncWriting=!0;h=this._writeBuffer.shift();){this._action(h);const t=this._callbacks.shift();t&&t()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(f,g){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=f.length,this._writeBuffer.push(f),this._callbacks.push(g),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=f.length,this._writeBuffer.push(f),this._callbacks.push(g)}_innerWrite(f=0,g=!0){const h=f||Date.now();for(;this._writeBuffer.length>this._bufferOffset;){const t=this._writeBuffer[this._bufferOffset],s=this._action(t,g);if(s){const i=a=>Date.now()-h>=12?setTimeout((()=>this._innerWrite(0,a))):this._innerWrite(h,a);return void s.catch((a=>(queueMicrotask((()=>{throw a})),Promise.resolve(!1)))).then(i)}const e=this._callbacks[this._bufferOffset];if(e&&e(),this._bufferOffset++,this._pendingData-=t.length,Date.now()-h>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}r.WriteBuffer=n},5941:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.toRgbString=r.parseColor=void 0;const o=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,l=/^[\da-f]+$/;function _(n,d){const f=n.toString(16),g=f.length<2?"0"+f:f;switch(d){case 4:return f[0];case 8:return g;case 12:return(g+g).slice(0,3);default:return g+g}}r.parseColor=function(n){if(!n)return;let d=n.toLowerCase();if(d.indexOf("rgb:")===0){d=d.slice(4);const f=o.exec(d);if(f){const g=f[1]?15:f[4]?255:f[7]?4095:65535;return[Math.round(parseInt(f[1]||f[4]||f[7]||f[10],16)/g*255),Math.round(parseInt(f[2]||f[5]||f[8]||f[11],16)/g*255),Math.round(parseInt(f[3]||f[6]||f[9]||f[12],16)/g*255)]}}else if(d.indexOf("#")===0&&(d=d.slice(1),l.exec(d)&&[3,6,9,12].includes(d.length))){const f=d.length/3,g=[0,0,0];for(let h=0;h<3;++h){const t=parseInt(d.slice(f*h,f*h+f),16);g[h]=f===1?t<<4:f===2?t:f===3?t>>4:t>>8}return g}},r.toRgbString=function(n,d=16){const[f,g,h]=n;return`rgb:${_(f,d)}/${_(g,d)}/${_(h,d)}`}},5770:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.PAYLOAD_LIMIT=void 0,r.PAYLOAD_LIMIT=1e7},6351:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.DcsHandler=r.DcsParser=void 0;const l=o(482),_=o(8742),n=o(5770),d=[];r.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=d,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=d}registerHandler(g,h){this._handlers[g]===void 0&&(this._handlers[g]=[]);const t=this._handlers[g];return t.push(h),{dispose:()=>{const s=t.indexOf(h);s!==-1&&t.splice(s,1)}}}clearHandler(g){this._handlers[g]&&delete this._handlers[g]}setHandlerFallback(g){this._handlerFb=g}reset(){if(this._active.length)for(let g=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;g>=0;--g)this._active[g].unhook(!1);this._stack.paused=!1,this._active=d,this._ident=0}hook(g,h){if(this.reset(),this._ident=g,this._active=this._handlers[g]||d,this._active.length)for(let t=this._active.length-1;t>=0;t--)this._active[t].hook(h);else this._handlerFb(this._ident,"HOOK",h)}put(g,h,t){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(g,h,t);else this._handlerFb(this._ident,"PUT",(0,l.utf32ToString)(g,h,t))}unhook(g,h=!0){if(this._active.length){let t=!1,s=this._active.length-1,e=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,t=h,e=this._stack.fallThrough,this._stack.paused=!1),!e&&t===!1){for(;s>=0&&(t=this._active[s].unhook(g),t!==!0);s--)if(t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,t;s--}for(;s>=0;s--)if(t=this._active[s].unhook(!1),t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,t}else this._handlerFb(this._ident,"UNHOOK",g);this._active=d,this._ident=0}};const f=new _.Params;f.addParam(0),r.DcsHandler=class{constructor(g){this._handler=g,this._data="",this._params=f,this._hitLimit=!1}hook(g){this._params=g.length>1||g.params[0]?g.clone():f,this._data="",this._hitLimit=!1}put(g,h,t){this._hitLimit||(this._data+=(0,l.utf32ToString)(g,h,t),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(g){let h=!1;if(this._hitLimit)h=!1;else if(g&&(h=this._handler(this._data,this._params),h instanceof Promise))return h.then((t=>(this._params=f,this._data="",this._hitLimit=!1,t)));return this._params=f,this._data="",this._hitLimit=!1,h}}},2015:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.EscapeSequenceParser=r.VT500_TRANSITION_TABLE=r.TransitionTable=void 0;const l=o(844),_=o(8742),n=o(6242),d=o(6351);class f{constructor(s){this.table=new Uint8Array(s)}setDefault(s,e){this.table.fill(s<<4|e)}add(s,e,i,a){this.table[e<<8|s]=i<<4|a}addMany(s,e,i,a){for(let v=0;vc)),e=(p,c)=>s.slice(p,c),i=e(32,127),a=e(0,24);a.push(25),a.push.apply(a,e(28,32));const v=e(0,14);let u;for(u in t.setDefault(1,0),t.addMany(i,0,2,0),v)t.addMany([24,26,153,154],u,3,0),t.addMany(e(128,144),u,3,0),t.addMany(e(144,152),u,3,0),t.add(156,u,0,0),t.add(27,u,11,1),t.add(157,u,4,8),t.addMany([152,158,159],u,0,7),t.add(155,u,11,3),t.add(144,u,11,9);return t.addMany(a,0,3,0),t.addMany(a,1,3,1),t.add(127,1,0,1),t.addMany(a,8,0,8),t.addMany(a,3,3,3),t.add(127,3,0,3),t.addMany(a,4,3,4),t.add(127,4,0,4),t.addMany(a,6,3,6),t.addMany(a,5,3,5),t.add(127,5,0,5),t.addMany(a,2,3,2),t.add(127,2,0,2),t.add(93,1,4,8),t.addMany(i,8,5,8),t.add(127,8,5,8),t.addMany([156,27,24,26,7],8,6,0),t.addMany(e(28,32),8,0,8),t.addMany([88,94,95],1,0,7),t.addMany(i,7,0,7),t.addMany(a,7,0,7),t.add(156,7,0,0),t.add(127,7,0,7),t.add(91,1,11,3),t.addMany(e(64,127),3,7,0),t.addMany(e(48,60),3,8,4),t.addMany([60,61,62,63],3,9,4),t.addMany(e(48,60),4,8,4),t.addMany(e(64,127),4,7,0),t.addMany([60,61,62,63],4,0,6),t.addMany(e(32,64),6,0,6),t.add(127,6,0,6),t.addMany(e(64,127),6,0,0),t.addMany(e(32,48),3,9,5),t.addMany(e(32,48),5,9,5),t.addMany(e(48,64),5,0,6),t.addMany(e(64,127),5,7,0),t.addMany(e(32,48),4,9,5),t.addMany(e(32,48),1,9,2),t.addMany(e(32,48),2,9,2),t.addMany(e(48,127),2,10,0),t.addMany(e(48,80),1,10,0),t.addMany(e(81,88),1,10,0),t.addMany([89,90,92],1,10,0),t.addMany(e(96,127),1,10,0),t.add(80,1,11,9),t.addMany(a,9,0,9),t.add(127,9,0,9),t.addMany(e(28,32),9,0,9),t.addMany(e(32,48),9,9,12),t.addMany(e(48,60),9,8,10),t.addMany([60,61,62,63],9,9,10),t.addMany(a,11,0,11),t.addMany(e(32,128),11,0,11),t.addMany(e(28,32),11,0,11),t.addMany(a,10,0,10),t.add(127,10,0,10),t.addMany(e(28,32),10,0,10),t.addMany(e(48,60),10,8,10),t.addMany([60,61,62,63],10,0,11),t.addMany(e(32,48),10,9,12),t.addMany(a,12,0,12),t.add(127,12,0,12),t.addMany(e(28,32),12,0,12),t.addMany(e(32,48),12,9,12),t.addMany(e(48,64),12,0,11),t.addMany(e(64,127),12,12,13),t.addMany(e(64,127),10,12,13),t.addMany(e(64,127),9,12,13),t.addMany(a,13,13,13),t.addMany(i,13,13,13),t.add(127,13,0,13),t.addMany([27,156,24,26],13,14,0),t.add(g,0,2,0),t.add(g,8,5,8),t.add(g,6,0,6),t.add(g,11,0,11),t.add(g,13,13,13),t})();class h extends l.Disposable{constructor(s=r.VT500_TRANSITION_TABLE){super(),this._transitions=s,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new _.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,i,a)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,i)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this.register((0,l.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this.register(new n.OscParser),this._dcsParser=this.register(new d.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(s,e=[64,126]){let i=0;if(s.prefix){if(s.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=s.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(s.intermediates){if(s.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let v=0;vu||u>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=u}}if(s.final.length!==1)throw new Error("final must be a single byte");const a=s.final.charCodeAt(0);if(e[0]>a||a>e[1])throw new Error(`final must be in range ${e[0]} .. ${e[1]}`);return i<<=8,i|=a,i}identToString(s){const e=[];for(;s;)e.push(String.fromCharCode(255&s)),s>>=8;return e.reverse().join("")}setPrintHandler(s){this._printHandler=s}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(s,e){const i=this._identifier(s,[48,126]);this._escHandlers[i]===void 0&&(this._escHandlers[i]=[]);const a=this._escHandlers[i];return a.push(e),{dispose:()=>{const v=a.indexOf(e);v!==-1&&a.splice(v,1)}}}clearEscHandler(s){this._escHandlers[this._identifier(s,[48,126])]&&delete this._escHandlers[this._identifier(s,[48,126])]}setEscHandlerFallback(s){this._escHandlerFb=s}setExecuteHandler(s,e){this._executeHandlers[s.charCodeAt(0)]=e}clearExecuteHandler(s){this._executeHandlers[s.charCodeAt(0)]&&delete this._executeHandlers[s.charCodeAt(0)]}setExecuteHandlerFallback(s){this._executeHandlerFb=s}registerCsiHandler(s,e){const i=this._identifier(s);this._csiHandlers[i]===void 0&&(this._csiHandlers[i]=[]);const a=this._csiHandlers[i];return a.push(e),{dispose:()=>{const v=a.indexOf(e);v!==-1&&a.splice(v,1)}}}clearCsiHandler(s){this._csiHandlers[this._identifier(s)]&&delete this._csiHandlers[this._identifier(s)]}setCsiHandlerFallback(s){this._csiHandlerFb=s}registerDcsHandler(s,e){return this._dcsParser.registerHandler(this._identifier(s),e)}clearDcsHandler(s){this._dcsParser.clearHandler(this._identifier(s))}setDcsHandlerFallback(s){this._dcsParser.setHandlerFallback(s)}registerOscHandler(s,e){return this._oscParser.registerHandler(s,e)}clearOscHandler(s){this._oscParser.clearHandler(s)}setOscHandlerFallback(s){this._oscParser.setHandlerFallback(s)}setErrorHandler(s){this._errorHandler=s}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._parseStack.state!==0&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(s,e,i,a,v){this._parseStack.state=s,this._parseStack.handlers=e,this._parseStack.handlerPos=i,this._parseStack.transition=a,this._parseStack.chunkPos=v}parse(s,e,i){let a,v=0,u=0,p=0;if(this._parseStack.state)if(this._parseStack.state===2)this._parseStack.state=0,p=this._parseStack.chunkPos+1;else{if(i===void 0||this._parseStack.state===1)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const c=this._parseStack.handlers;let S=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(i===!1&&S>-1){for(;S>=0&&(a=c[S](this._params),a!==!0);S--)if(a instanceof Promise)return this._parseStack.handlerPos=S,a}this._parseStack.handlers=[];break;case 4:if(i===!1&&S>-1){for(;S>=0&&(a=c[S](),a!==!0);S--)if(a instanceof Promise)return this._parseStack.handlerPos=S,a}this._parseStack.handlers=[];break;case 6:if(v=s[this._parseStack.chunkPos],a=this._dcsParser.unhook(v!==24&&v!==26,i),a)return a;v===27&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(v=s[this._parseStack.chunkPos],a=this._oscParser.end(v!==24&&v!==26,i),a)return a;v===27&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,p=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=15&this._parseStack.transition}for(let c=p;c>4){case 2:for(let b=c+1;;++b){if(b>=e||(v=s[b])<32||v>126&&v=e||(v=s[b])<32||v>126&&v=e||(v=s[b])<32||v>126&&v=e||(v=s[b])<32||v>126&&v=0&&(a=S[E](this._params),a!==!0);E--)if(a instanceof Promise)return this._preserveStack(3,S,E,u,c),a;E<0&&this._csiHandlerFb(this._collect<<8|v,this._params),this.precedingJoinState=0;break;case 8:do switch(v){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(v-48)}while(++c47&&v<60);c--;break;case 9:this._collect<<=8,this._collect|=v;break;case 10:const k=this._escHandlers[this._collect<<8|v];let L=k?k.length-1:-1;for(;L>=0&&(a=k[L](),a!==!0);L--)if(a instanceof Promise)return this._preserveStack(4,k,L,u,c),a;L<0&&this._escHandlerFb(this._collect<<8|v),this.precedingJoinState=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|v,this._params);break;case 13:for(let b=c+1;;++b)if(b>=e||(v=s[b])===24||v===26||v===27||v>127&&v=e||(v=s[b])<32||v>127&&v{Object.defineProperty(r,"__esModule",{value:!0}),r.OscHandler=r.OscParser=void 0;const l=o(5770),_=o(482),n=[];r.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(d,f){this._handlers[d]===void 0&&(this._handlers[d]=[]);const g=this._handlers[d];return g.push(f),{dispose:()=>{const h=g.indexOf(f);h!==-1&&g.splice(h,1)}}}clearHandler(d){this._handlers[d]&&delete this._handlers[d]}setHandlerFallback(d){this._handlerFb=d}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(this._state===2)for(let d=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;d>=0;--d)this._active[d].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let d=this._active.length-1;d>=0;d--)this._active[d].start();else this._handlerFb(this._id,"START")}_put(d,f,g){if(this._active.length)for(let h=this._active.length-1;h>=0;h--)this._active[h].put(d,f,g);else this._handlerFb(this._id,"PUT",(0,_.utf32ToString)(d,f,g))}start(){this.reset(),this._state=1}put(d,f,g){if(this._state!==3){if(this._state===1)for(;f0&&this._put(d,f,g)}}end(d,f=!0){if(this._state!==0){if(this._state!==3)if(this._state===1&&this._start(),this._active.length){let g=!1,h=this._active.length-1,t=!1;if(this._stack.paused&&(h=this._stack.loopPosition-1,g=f,t=this._stack.fallThrough,this._stack.paused=!1),!t&&g===!1){for(;h>=0&&(g=this._active[h].end(d),g!==!0);h--)if(g instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=h,this._stack.fallThrough=!1,g;h--}for(;h>=0;h--)if(g=this._active[h].end(!1),g instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=h,this._stack.fallThrough=!0,g}else this._handlerFb(this._id,"END",d);this._active=n,this._id=-1,this._state=0}}},r.OscHandler=class{constructor(d){this._handler=d,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(d,f,g){this._hitLimit||(this._data+=(0,_.utf32ToString)(d,f,g),this._data.length>l.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(d){let f=!1;if(this._hitLimit)f=!1;else if(d&&(f=this._handler(this._data),f instanceof Promise))return f.then((g=>(this._data="",this._hitLimit=!1,g)));return this._data="",this._hitLimit=!1,f}}},8742:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.Params=void 0;const o=2147483647;class l{static fromArray(n){const d=new l;if(!n.length)return d;for(let f=Array.isArray(n[0])?1:0;f256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(n),this.length=0,this._subParams=new Int32Array(d),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(n),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const n=new l(this.maxLength,this.maxSubParamsLength);return n.params.set(this.params),n.length=this.length,n._subParams.set(this._subParams),n._subParamsLength=this._subParamsLength,n._subParamsIdx.set(this._subParamsIdx),n._rejectDigits=this._rejectDigits,n._rejectSubDigits=this._rejectSubDigits,n._digitIsSub=this._digitIsSub,n}toArray(){const n=[];for(let d=0;d>8,g=255&this._subParamsIdx[d];g-f>0&&n.push(Array.prototype.slice.call(this._subParams,f,g))}return n}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(n){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(n<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=n>o?o:n}}addSubParam(n){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(n<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=n>o?o:n,this._subParamsIdx[this.length-1]++}}hasSubParams(n){return(255&this._subParamsIdx[n])-(this._subParamsIdx[n]>>8)>0}getSubParams(n){const d=this._subParamsIdx[n]>>8,f=255&this._subParamsIdx[n];return f-d>0?this._subParams.subarray(d,f):null}getSubParamsAll(){const n={};for(let d=0;d>8,g=255&this._subParamsIdx[d];g-f>0&&(n[d]=this._subParams.slice(f,g))}return n}addDigit(n){let d;if(this._rejectDigits||!(d=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const f=this._digitIsSub?this._subParams:this.params,g=f[d-1];f[d-1]=~g?Math.min(10*g+n,o):n}}r.Params=l},5741:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.AddonManager=void 0,r.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let o=this._addons.length-1;o>=0;o--)this._addons[o].instance.dispose()}loadAddon(o,l){const _={instance:l,dispose:l.dispose,isDisposed:!1};this._addons.push(_),l.dispose=()=>this._wrappedAddonDispose(_),l.activate(o)}_wrappedAddonDispose(o){if(o.isDisposed)return;let l=-1;for(let _=0;_{Object.defineProperty(r,"__esModule",{value:!0}),r.BufferApiView=void 0;const l=o(3785),_=o(511);r.BufferApiView=class{constructor(n,d){this._buffer=n,this.type=d}init(n){return this._buffer=n,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(n){const d=this._buffer.lines.get(n);if(d)return new l.BufferLineApiView(d)}getNullCell(){return new _.CellData}}},3785:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.BufferLineApiView=void 0;const l=o(511);r.BufferLineApiView=class{constructor(_){this._line=_}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(_,n){if(!(_<0||_>=this._line.length))return n?(this._line.loadCell(_,n),n):this._line.loadCell(_,new l.CellData)}translateToString(_,n,d){return this._line.translateToString(_,n,d)}}},8285:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.BufferNamespaceApi=void 0;const l=o(8771),_=o(8460),n=o(844);class d extends n.Disposable{constructor(g){super(),this._core=g,this._onBufferChange=this.register(new _.EventEmitter),this.onBufferChange=this._onBufferChange.event,this._normal=new l.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new l.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}r.BufferNamespaceApi=d},7975:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.ParserApi=void 0,r.ParserApi=class{constructor(o){this._core=o}registerCsiHandler(o,l){return this._core.registerCsiHandler(o,(_=>l(_.toArray())))}addCsiHandler(o,l){return this.registerCsiHandler(o,l)}registerDcsHandler(o,l){return this._core.registerDcsHandler(o,((_,n)=>l(_,n.toArray())))}addDcsHandler(o,l){return this.registerDcsHandler(o,l)}registerEscHandler(o,l){return this._core.registerEscHandler(o,l)}addEscHandler(o,l){return this.registerEscHandler(o,l)}registerOscHandler(o,l){return this._core.registerOscHandler(o,l)}addOscHandler(o,l){return this.registerOscHandler(o,l)}}},7090:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.UnicodeApi=void 0,r.UnicodeApi=class{constructor(o){this._core=o}register(o){this._core.unicodeService.register(o)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(o){this._core.unicodeService.activeVersion=o}}},744:function(x,r,o){var l=this&&this.__decorate||function(t,s,e,i){var a,v=arguments.length,u=v<3?s:i===null?i=Object.getOwnPropertyDescriptor(s,e):i;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")u=Reflect.decorate(t,s,e,i);else for(var p=t.length-1;p>=0;p--)(a=t[p])&&(u=(v<3?a(u):v>3?a(s,e,u):a(s,e))||u);return v>3&&u&&Object.defineProperty(s,e,u),u},_=this&&this.__param||function(t,s){return function(e,i){s(e,i,t)}};Object.defineProperty(r,"__esModule",{value:!0}),r.BufferService=r.MINIMUM_ROWS=r.MINIMUM_COLS=void 0;const n=o(8460),d=o(844),f=o(5295),g=o(2585);r.MINIMUM_COLS=2,r.MINIMUM_ROWS=1;let h=r.BufferService=class extends d.Disposable{get buffer(){return this.buffers.active}constructor(t){super(),this.isUserScrolling=!1,this._onResize=this.register(new n.EventEmitter),this.onResize=this._onResize.event,this._onScroll=this.register(new n.EventEmitter),this.onScroll=this._onScroll.event,this.cols=Math.max(t.rawOptions.cols||0,r.MINIMUM_COLS),this.rows=Math.max(t.rawOptions.rows||0,r.MINIMUM_ROWS),this.buffers=this.register(new f.BufferSet(t,this))}resize(t,s){this.cols=t,this.rows=s,this.buffers.resize(t,s),this._onResize.fire({cols:t,rows:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(t,s=!1){const e=this.buffer;let i;i=this._cachedBlankLine,i&&i.length===this.cols&&i.getFg(0)===t.fg&&i.getBg(0)===t.bg||(i=e.getBlankLine(t,s),this._cachedBlankLine=i),i.isWrapped=s;const a=e.ybase+e.scrollTop,v=e.ybase+e.scrollBottom;if(e.scrollTop===0){const u=e.lines.isFull;v===e.lines.length-1?u?e.lines.recycle().copyFrom(i):e.lines.push(i.clone()):e.lines.splice(v+1,0,i.clone()),u?this.isUserScrolling&&(e.ydisp=Math.max(e.ydisp-1,0)):(e.ybase++,this.isUserScrolling||e.ydisp++)}else{const u=v-a+1;e.lines.shiftElements(a+1,u-1,-1),e.lines.set(v,i.clone())}this.isUserScrolling||(e.ydisp=e.ybase),this._onScroll.fire(e.ydisp)}scrollLines(t,s,e){const i=this.buffer;if(t<0){if(i.ydisp===0)return;this.isUserScrolling=!0}else t+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);const a=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+t,i.ybase),0),a!==i.ydisp&&(s||this._onScroll.fire(i.ydisp))}};r.BufferService=h=l([_(0,g.IOptionsService)],h)},7994:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.CharsetService=void 0,r.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(o){this.glevel=o,this.charset=this._charsets[o]}setgCharset(o,l){this._charsets[o]=l,this.glevel===o&&(this.charset=l)}}},1753:function(x,r,o){var l=this&&this.__decorate||function(i,a,v,u){var p,c=arguments.length,S=c<3?a:u===null?u=Object.getOwnPropertyDescriptor(a,v):u;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")S=Reflect.decorate(i,a,v,u);else for(var E=i.length-1;E>=0;E--)(p=i[E])&&(S=(c<3?p(S):c>3?p(a,v,S):p(a,v))||S);return c>3&&S&&Object.defineProperty(a,v,S),S},_=this&&this.__param||function(i,a){return function(v,u){a(v,u,i)}};Object.defineProperty(r,"__esModule",{value:!0}),r.CoreMouseService=void 0;const n=o(2585),d=o(8460),f=o(844),g={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:i=>i.button!==4&&i.action===1&&(i.ctrl=!1,i.alt=!1,i.shift=!1,!0)},VT200:{events:19,restrict:i=>i.action!==32},DRAG:{events:23,restrict:i=>i.action!==32||i.button!==3},ANY:{events:31,restrict:i=>!0}};function h(i,a){let v=(i.ctrl?16:0)|(i.shift?4:0)|(i.alt?8:0);return i.button===4?(v|=64,v|=i.action):(v|=3&i.button,4&i.button&&(v|=64),8&i.button&&(v|=128),i.action===32?v|=32:i.action!==0||a||(v|=3)),v}const t=String.fromCharCode,s={DEFAULT:i=>{const a=[h(i,!1)+32,i.col+32,i.row+32];return a[0]>255||a[1]>255||a[2]>255?"":`\x1B[M${t(a[0])}${t(a[1])}${t(a[2])}`},SGR:i=>{const a=i.action===0&&i.button!==4?"m":"M";return`\x1B[<${h(i,!0)};${i.col};${i.row}${a}`},SGR_PIXELS:i=>{const a=i.action===0&&i.button!==4?"m":"M";return`\x1B[<${h(i,!0)};${i.x};${i.y}${a}`}};let e=r.CoreMouseService=class extends f.Disposable{constructor(i,a){super(),this._bufferService=i,this._coreService=a,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._onProtocolChange=this.register(new d.EventEmitter),this.onProtocolChange=this._onProtocolChange.event;for(const v of Object.keys(g))this.addProtocol(v,g[v]);for(const v of Object.keys(s))this.addEncoding(v,s[v]);this.reset()}addProtocol(i,a){this._protocols[i]=a}addEncoding(i,a){this._encodings[i]=a}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return this._protocols[this._activeProtocol].events!==0}set activeProtocol(i){if(!this._protocols[i])throw new Error(`unknown protocol "${i}"`);this._activeProtocol=i,this._onProtocolChange.fire(this._protocols[i].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(i){if(!this._encodings[i])throw new Error(`unknown encoding "${i}"`);this._activeEncoding=i}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null}triggerMouseEvent(i){if(i.col<0||i.col>=this._bufferService.cols||i.row<0||i.row>=this._bufferService.rows||i.button===4&&i.action===32||i.button===3&&i.action!==32||i.button!==4&&(i.action===2||i.action===3)||(i.col++,i.row++,i.action===32&&this._lastEvent&&this._equalEvents(this._lastEvent,i,this._activeEncoding==="SGR_PIXELS"))||!this._protocols[this._activeProtocol].restrict(i))return!1;const a=this._encodings[this._activeEncoding](i);return a&&(this._activeEncoding==="DEFAULT"?this._coreService.triggerBinaryEvent(a):this._coreService.triggerDataEvent(a,!0)),this._lastEvent=i,!0}explainEvents(i){return{down:!!(1&i),up:!!(2&i),drag:!!(4&i),move:!!(8&i),wheel:!!(16&i)}}_equalEvents(i,a,v){if(v){if(i.x!==a.x||i.y!==a.y)return!1}else if(i.col!==a.col||i.row!==a.row)return!1;return i.button===a.button&&i.action===a.action&&i.ctrl===a.ctrl&&i.alt===a.alt&&i.shift===a.shift}};r.CoreMouseService=e=l([_(0,n.IBufferService),_(1,n.ICoreService)],e)},6975:function(x,r,o){var l=this&&this.__decorate||function(e,i,a,v){var u,p=arguments.length,c=p<3?i:v===null?v=Object.getOwnPropertyDescriptor(i,a):v;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")c=Reflect.decorate(e,i,a,v);else for(var S=e.length-1;S>=0;S--)(u=e[S])&&(c=(p<3?u(c):p>3?u(i,a,c):u(i,a))||c);return p>3&&c&&Object.defineProperty(i,a,c),c},_=this&&this.__param||function(e,i){return function(a,v){i(a,v,e)}};Object.defineProperty(r,"__esModule",{value:!0}),r.CoreService=void 0;const n=o(1439),d=o(8460),f=o(844),g=o(2585),h=Object.freeze({insertMode:!1}),t=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,origin:!1,reverseWraparound:!1,sendFocus:!1,wraparound:!0});let s=r.CoreService=class extends f.Disposable{constructor(e,i,a){super(),this._bufferService=e,this._logService=i,this._optionsService=a,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this.register(new d.EventEmitter),this.onData=this._onData.event,this._onUserInput=this.register(new d.EventEmitter),this.onUserInput=this._onUserInput.event,this._onBinary=this.register(new d.EventEmitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this.register(new d.EventEmitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(h),this.decPrivateModes=(0,n.clone)(t)}reset(){this.modes=(0,n.clone)(h),this.decPrivateModes=(0,n.clone)(t)}triggerDataEvent(e,i=!1){if(this._optionsService.rawOptions.disableStdin)return;const a=this._bufferService.buffer;i&&this._optionsService.rawOptions.scrollOnUserInput&&a.ybase!==a.ydisp&&this._onRequestScrollToBottom.fire(),i&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`,(()=>e.split("").map((v=>v.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`,(()=>e.split("").map((i=>i.charCodeAt(0))))),this._onBinary.fire(e))}};r.CoreService=s=l([_(0,g.IBufferService),_(1,g.ILogService),_(2,g.IOptionsService)],s)},9074:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.DecorationService=void 0;const l=o(8055),_=o(8460),n=o(844),d=o(6106);let f=0,g=0;class h extends n.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new d.SortedList((e=>e?.marker.line)),this._onDecorationRegistered=this.register(new _.EventEmitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this.register(new _.EventEmitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this.register((0,n.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const i=new t(e);if(i){const a=i.marker.onDispose((()=>i.dispose()));i.onDispose((()=>{i&&(this._decorations.delete(i)&&this._onDecorationRemoved.fire(i),a.dispose())})),this._decorations.insert(i),this._onDecorationRegistered.fire(i)}return i}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,i,a){let v=0,u=0;for(const p of this._decorations.getKeyIterator(i))v=p.options.x??0,u=v+(p.options.width??1),e>=v&&e{f=u.options.x??0,g=f+(u.options.width??1),e>=f&&e{Object.defineProperty(r,"__esModule",{value:!0}),r.InstantiationService=r.ServiceCollection=void 0;const l=o(2585),_=o(8343);class n{constructor(...f){this._entries=new Map;for(const[g,h]of f)this.set(g,h)}set(f,g){const h=this._entries.get(f);return this._entries.set(f,g),h}forEach(f){for(const[g,h]of this._entries.entries())f(g,h)}has(f){return this._entries.has(f)}get(f){return this._entries.get(f)}}r.ServiceCollection=n,r.InstantiationService=class{constructor(){this._services=new n,this._services.set(l.IInstantiationService,this)}setService(d,f){this._services.set(d,f)}getService(d){return this._services.get(d)}createInstance(d,...f){const g=(0,_.getServiceDependencies)(d).sort(((s,e)=>s.index-e.index)),h=[];for(const s of g){const e=this._services.get(s.id);if(!e)throw new Error(`[createInstance] ${d.name} depends on UNKNOWN service ${s.id}.`);h.push(e)}const t=g.length>0?g[0].index:f.length;if(f.length!==t)throw new Error(`[createInstance] First service dependency of ${d.name} at position ${t+1} conflicts with ${f.length} static arguments`);return new d(...f,...h)}}},7866:function(x,r,o){var l=this&&this.__decorate||function(t,s,e,i){var a,v=arguments.length,u=v<3?s:i===null?i=Object.getOwnPropertyDescriptor(s,e):i;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")u=Reflect.decorate(t,s,e,i);else for(var p=t.length-1;p>=0;p--)(a=t[p])&&(u=(v<3?a(u):v>3?a(s,e,u):a(s,e))||u);return v>3&&u&&Object.defineProperty(s,e,u),u},_=this&&this.__param||function(t,s){return function(e,i){s(e,i,t)}};Object.defineProperty(r,"__esModule",{value:!0}),r.traceCall=r.setTraceLogger=r.LogService=void 0;const n=o(844),d=o(2585),f={trace:d.LogLevelEnum.TRACE,debug:d.LogLevelEnum.DEBUG,info:d.LogLevelEnum.INFO,warn:d.LogLevelEnum.WARN,error:d.LogLevelEnum.ERROR,off:d.LogLevelEnum.OFF};let g,h=r.LogService=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(t){super(),this._optionsService=t,this._logLevel=d.LogLevelEnum.OFF,this._updateLogLevel(),this.register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),g=this}_updateLogLevel(){this._logLevel=f[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(t){for(let s=0;sJSON.stringify(u))).join(", ")})`);const v=i.apply(this,a);return g.trace(`GlyphRenderer#${i.name} return`,v),v}}},7302:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.OptionsService=r.DEFAULT_OPTIONS=void 0;const l=o(8460),_=o(844),n=o(6114);r.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"courier-new, courier, monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},rescaleOverlappingGlyphs:!1,rightClickSelectsWord:n.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRulerWidth:0};const d=["normal","bold","100","200","300","400","500","600","700","800","900"];class f extends _.Disposable{constructor(h){super(),this._onOptionChange=this.register(new l.EventEmitter),this.onOptionChange=this._onOptionChange.event;const t={...r.DEFAULT_OPTIONS};for(const s in h)if(s in t)try{const e=h[s];t[s]=this._sanitizeAndValidateOption(s,e)}catch(e){console.error(e)}this.rawOptions=t,this.options={...t},this._setupOptions(),this.register((0,_.toDisposable)((()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null})))}onSpecificOptionChange(h,t){return this.onOptionChange((s=>{s===h&&t(this.rawOptions[h])}))}onMultipleOptionChange(h,t){return this.onOptionChange((s=>{h.indexOf(s)!==-1&&t()}))}_setupOptions(){const h=s=>{if(!(s in r.DEFAULT_OPTIONS))throw new Error(`No option with key "${s}"`);return this.rawOptions[s]},t=(s,e)=>{if(!(s in r.DEFAULT_OPTIONS))throw new Error(`No option with key "${s}"`);e=this._sanitizeAndValidateOption(s,e),this.rawOptions[s]!==e&&(this.rawOptions[s]=e,this._onOptionChange.fire(s))};for(const s in this.rawOptions){const e={get:h.bind(this,s),set:t.bind(this,s)};Object.defineProperty(this.options,s,e)}}_sanitizeAndValidateOption(h,t){switch(h){case"cursorStyle":if(t||(t=r.DEFAULT_OPTIONS[h]),!(function(s){return s==="block"||s==="underline"||s==="bar"})(t))throw new Error(`"${t}" is not a valid value for ${h}`);break;case"wordSeparator":t||(t=r.DEFAULT_OPTIONS[h]);break;case"fontWeight":case"fontWeightBold":if(typeof t=="number"&&1<=t&&t<=1e3)break;t=d.includes(t)?t:r.DEFAULT_OPTIONS[h];break;case"cursorWidth":t=Math.floor(t);case"lineHeight":case"tabStopWidth":if(t<1)throw new Error(`${h} cannot be less than 1, value: ${t}`);break;case"minimumContrastRatio":t=Math.max(1,Math.min(21,Math.round(10*t)/10));break;case"scrollback":if((t=Math.min(t,4294967295))<0)throw new Error(`${h} cannot be less than 0, value: ${t}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(t<=0)throw new Error(`${h} cannot be less than or equal to 0, value: ${t}`);break;case"rows":case"cols":if(!t&&t!==0)throw new Error(`${h} must be numeric, value: ${t}`);break;case"windowsPty":t=t??{}}return t}}r.OptionsService=f},2660:function(x,r,o){var l=this&&this.__decorate||function(f,g,h,t){var s,e=arguments.length,i=e<3?g:t===null?t=Object.getOwnPropertyDescriptor(g,h):t;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")i=Reflect.decorate(f,g,h,t);else for(var a=f.length-1;a>=0;a--)(s=f[a])&&(i=(e<3?s(i):e>3?s(g,h,i):s(g,h))||i);return e>3&&i&&Object.defineProperty(g,h,i),i},_=this&&this.__param||function(f,g){return function(h,t){g(h,t,f)}};Object.defineProperty(r,"__esModule",{value:!0}),r.OscLinkService=void 0;const n=o(2585);let d=r.OscLinkService=class{constructor(f){this._bufferService=f,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(f){const g=this._bufferService.buffer;if(f.id===void 0){const a=g.addMarker(g.ybase+g.y),v={data:f,id:this._nextId++,lines:[a]};return a.onDispose((()=>this._removeMarkerFromLink(v,a))),this._dataByLinkId.set(v.id,v),v.id}const h=f,t=this._getEntryIdKey(h),s=this._entriesWithId.get(t);if(s)return this.addLineToLink(s.id,g.ybase+g.y),s.id;const e=g.addMarker(g.ybase+g.y),i={id:this._nextId++,key:this._getEntryIdKey(h),data:h,lines:[e]};return e.onDispose((()=>this._removeMarkerFromLink(i,e))),this._entriesWithId.set(i.key,i),this._dataByLinkId.set(i.id,i),i.id}addLineToLink(f,g){const h=this._dataByLinkId.get(f);if(h&&h.lines.every((t=>t.line!==g))){const t=this._bufferService.buffer.addMarker(g);h.lines.push(t),t.onDispose((()=>this._removeMarkerFromLink(h,t)))}}getLinkData(f){return this._dataByLinkId.get(f)?.data}_getEntryIdKey(f){return`${f.id};;${f.uri}`}_removeMarkerFromLink(f,g){const h=f.lines.indexOf(g);h!==-1&&(f.lines.splice(h,1),f.lines.length===0&&(f.data.id!==void 0&&this._entriesWithId.delete(f.key),this._dataByLinkId.delete(f.id)))}};r.OscLinkService=d=l([_(0,n.IBufferService)],d)},8343:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.createDecorator=r.getServiceDependencies=r.serviceRegistry=void 0;const o="di$target",l="di$dependencies";r.serviceRegistry=new Map,r.getServiceDependencies=function(_){return _[l]||[]},r.createDecorator=function(_){if(r.serviceRegistry.has(_))return r.serviceRegistry.get(_);const n=function(d,f,g){if(arguments.length!==3)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");(function(h,t,s){t[o]===t?t[l].push({id:h,index:s}):(t[l]=[{id:h,index:s}],t[o]=t)})(n,d,g)};return n.toString=()=>_,r.serviceRegistry.set(_,n),n}},2585:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.IDecorationService=r.IUnicodeService=r.IOscLinkService=r.IOptionsService=r.ILogService=r.LogLevelEnum=r.IInstantiationService=r.ICharsetService=r.ICoreService=r.ICoreMouseService=r.IBufferService=void 0;const l=o(8343);var _;r.IBufferService=(0,l.createDecorator)("BufferService"),r.ICoreMouseService=(0,l.createDecorator)("CoreMouseService"),r.ICoreService=(0,l.createDecorator)("CoreService"),r.ICharsetService=(0,l.createDecorator)("CharsetService"),r.IInstantiationService=(0,l.createDecorator)("InstantiationService"),(function(n){n[n.TRACE=0]="TRACE",n[n.DEBUG=1]="DEBUG",n[n.INFO=2]="INFO",n[n.WARN=3]="WARN",n[n.ERROR=4]="ERROR",n[n.OFF=5]="OFF"})(_||(r.LogLevelEnum=_={})),r.ILogService=(0,l.createDecorator)("LogService"),r.IOptionsService=(0,l.createDecorator)("OptionsService"),r.IOscLinkService=(0,l.createDecorator)("OscLinkService"),r.IUnicodeService=(0,l.createDecorator)("UnicodeService"),r.IDecorationService=(0,l.createDecorator)("DecorationService")},1480:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.UnicodeService=void 0;const l=o(8460),_=o(225);class n{static extractShouldJoin(f){return(1&f)!=0}static extractWidth(f){return f>>1&3}static extractCharKind(f){return f>>3}static createPropertyValue(f,g,h=!1){return(16777215&f)<<3|(3&g)<<1|(h?1:0)}constructor(){this._providers=Object.create(null),this._active="",this._onChange=new l.EventEmitter,this.onChange=this._onChange.event;const f=new _.UnicodeV6;this.register(f),this._active=f.version,this._activeProvider=f}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(f){if(!this._providers[f])throw new Error(`unknown Unicode version "${f}"`);this._active=f,this._activeProvider=this._providers[f],this._onChange.fire(f)}register(f){this._providers[f.version]=f}wcwidth(f){return this._activeProvider.wcwidth(f)}getStringCellWidth(f){let g=0,h=0;const t=f.length;for(let s=0;s=t)return g+this.wcwidth(e);const v=f.charCodeAt(s);56320<=v&&v<=57343?e=1024*(e-55296)+v-56320+65536:g+=this.wcwidth(v)}const i=this.charProperties(e,h);let a=n.extractWidth(i);n.extractShouldJoin(i)&&(a-=n.extractWidth(h)),g+=a,h=i}return g}charProperties(f,g){return this._activeProvider.charProperties(f,g)}}r.UnicodeService=n}},V={};function j(x){var r=V[x];if(r!==void 0)return r.exports;var o=V[x]={exports:{}};return G[x].call(o.exports,o,o.exports,j),o.exports}var q={};return(()=>{var x=q;Object.defineProperty(x,"__esModule",{value:!0}),x.Terminal=void 0;const r=j(9042),o=j(3236),l=j(844),_=j(5741),n=j(8285),d=j(7975),f=j(7090),g=["cols","rows"];class h extends l.Disposable{constructor(s){super(),this._core=this.register(new o.Terminal(s)),this._addonManager=this.register(new _.AddonManager),this._publicOptions={...this._core.options};const e=a=>this._core.options[a],i=(a,v)=>{this._checkReadonlyOptions(a),this._core.options[a]=v};for(const a in this._core.options){const v={get:e.bind(this,a),set:i.bind(this,a)};Object.defineProperty(this._publicOptions,a,v)}}_checkReadonlyOptions(s){if(g.includes(s))throw new Error(`Option "${s}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new d.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new f.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this.register(new n.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const s=this._core.coreService.decPrivateModes;let e="none";switch(this._core.coreMouseService.activeProtocol){case"X10":e="x10";break;case"VT200":e="vt200";break;case"DRAG":e="drag";break;case"ANY":e="any"}return{applicationCursorKeysMode:s.applicationCursorKeys,applicationKeypadMode:s.applicationKeypad,bracketedPasteMode:s.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:e,originMode:s.origin,reverseWraparoundMode:s.reverseWraparound,sendFocusMode:s.sendFocus,wraparoundMode:s.wraparound}}get options(){return this._publicOptions}set options(s){for(const e in s)this._publicOptions[e]=s[e]}blur(){this._core.blur()}focus(){this._core.focus()}input(s,e=!0){this._core.input(s,e)}resize(s,e){this._verifyIntegers(s,e),this._core.resize(s,e)}open(s){this._core.open(s)}attachCustomKeyEventHandler(s){this._core.attachCustomKeyEventHandler(s)}attachCustomWheelEventHandler(s){this._core.attachCustomWheelEventHandler(s)}registerLinkProvider(s){return this._core.registerLinkProvider(s)}registerCharacterJoiner(s){return this._checkProposedApi(),this._core.registerCharacterJoiner(s)}deregisterCharacterJoiner(s){this._checkProposedApi(),this._core.deregisterCharacterJoiner(s)}registerMarker(s=0){return this._verifyIntegers(s),this._core.registerMarker(s)}registerDecoration(s){return this._checkProposedApi(),this._verifyPositiveIntegers(s.x??0,s.width??0,s.height??0),this._core.registerDecoration(s)}hasSelection(){return this._core.hasSelection()}select(s,e,i){this._verifyIntegers(s,e,i),this._core.select(s,e,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(s,e){this._verifyIntegers(s,e),this._core.selectLines(s,e)}dispose(){super.dispose()}scrollLines(s){this._verifyIntegers(s),this._core.scrollLines(s)}scrollPages(s){this._verifyIntegers(s),this._core.scrollPages(s)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(s){this._verifyIntegers(s),this._core.scrollToLine(s)}clear(){this._core.clear()}write(s,e){this._core.write(s,e)}writeln(s,e){this._core.write(s),this._core.write(`\r +`,e)}paste(s){this._core.paste(s)}refresh(s,e){this._verifyIntegers(s,e),this._core.refresh(s,e)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(s){this._addonManager.loadAddon(this,s)}static get strings(){return r}_verifyIntegers(...s){for(const e of s)if(e===1/0||isNaN(e)||e%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...s){for(const e of s)if(e&&(e===1/0||isNaN(e)||e%1!=0||e<0))throw new Error("This API only accepts positive integers")}}x.Terminal=h})(),q})()))})(ge)),ge.exports}var We=Fe(),me={exports:{}},be;function Ne(){return be||(be=1,(function(N,X){(function(G,V){N.exports=V()})(self,(()=>(()=>{var G={};return(()=>{var V=G;Object.defineProperty(V,"__esModule",{value:!0}),V.FitAddon=void 0,V.FitAddon=class{activate(j){this._terminal=j}dispose(){}fit(){const j=this.proposeDimensions();if(!j||!this._terminal||isNaN(j.cols)||isNaN(j.rows))return;const q=this._terminal._core;this._terminal.rows===j.rows&&this._terminal.cols===j.cols||(q._renderService.clear(),this._terminal.resize(j.cols,j.rows))}proposeDimensions(){if(!this._terminal||!this._terminal.element||!this._terminal.element.parentElement)return;const j=this._terminal._core,q=j._renderService.dimensions;if(q.css.cell.width===0||q.css.cell.height===0)return;const x=this._terminal.options.scrollback===0?0:j.viewport.scrollBarWidth,r=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(r.getPropertyValue("height")),l=Math.max(0,parseInt(r.getPropertyValue("width"))),_=window.getComputedStyle(this._terminal.element),n=o-(parseInt(_.getPropertyValue("padding-top"))+parseInt(_.getPropertyValue("padding-bottom"))),d=l-(parseInt(_.getPropertyValue("padding-right"))+parseInt(_.getPropertyValue("padding-left")))-x;return{cols:Math.max(2,Math.floor(d/q.css.cell.width)),rows:Math.max(1,Math.floor(n/q.css.cell.height))}}}})(),G})()))})(me)),me.exports}var Ue=Ne();function $e({panes:N,socketRef:X,viewsRef:G,bodyRefs:V,pendingRef:j,setTitles:q}){Y.useEffect(()=>{for(const x of N){if(G.current.has(x))continue;const r=V.current.get(x);if(!r)continue;const o=new We.Terminal({fontFamily:getComputedStyle(document.body).fontFamily,fontSize:12,theme:{background:"#0b0b0d",foreground:"#e6e6ec"},cursorBlink:!0}),l=new Ue.FitAddon;o.loadAddon(l),o.onData(n=>X.current?.send(JSON.stringify({type:"input",pane:x,data:n}))),o.onTitleChange(n=>{const d=n.replace(/\s+/g," ").trim();d&&q(f=>({...f,[x]:d}))}),o.open(r),G.current.set(x,{term:o,fit:l});const _=j.current.get(x);if(_){for(const n of _)o.write(n);j.current.delete(x)}}for(const[x,r]of G.current)N.includes(x)||(r.term.dispose(),G.current.delete(x))},[N])}function je({pane:N,index:X,label:G,cellStyle:V,isActive:j,isZoomed:q,isDragged:x,isDropTarget:r,reorderable:o,onFocus:l,onToggleZoom:_,onClose:n,onPaneDragStart:d,onPaneDragMove:f,onPaneDragEnd:g,onPaneDragCancel:h,bodyRef:t}){const s=r?"border-accent ring-1 ring-accent":j?"border-accent":"border-ink-700";return te.jsxs("div",{"data-pane-id":N,onMouseDown:l,style:V,className:`min-h-0 min-w-0 flex-col overflow-hidden rounded-sm border ${s} ${x?"opacity-60":""}`,children:[te.jsxs("div",{onPointerDown:d,onPointerMove:f,onPointerUp:g,onPointerCancel:h,className:`flex shrink-0 items-center gap-1 select-none bg-ink-900 px-2 py-0.5 text-xs ${o?x?"cursor-grabbing touch-none":"cursor-grab touch-none":""}`,children:[te.jsx("span",{title:G,className:`min-w-0 flex-1 truncate ${j?"text-ink-50":"text-ink-400"}`,children:Me(G,Re)}),te.jsx("button",{onMouseDown:e=>e.stopPropagation(),onClick:_,"aria-pressed":q,title:q?"Restore the grid":"Zoom this terminal","aria-label":q?"Restore the grid":"Zoom this terminal",className:"flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:text-accent",children:te.jsx(we,{maximized:q})}),te.jsx("button",{onMouseDown:e=>e.stopPropagation(),onClick:n,title:"Close terminal","aria-label":`close terminal ${X+1}`,className:"flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:text-removed",children:te.jsx(De,{})})]}),te.jsx("div",{ref:t,className:"min-h-0 flex-1"})]})}function Ke({repo:N,maximized:X,onToggleMaximized:G}){const V=Y.useRef(null),j=Y.useRef(null),q=Y.useRef(new Map),x=Y.useRef(new Map),r=Y.useRef(new Map),o=Y.useRef(new Map),l=Y.useRef(new Map),_=Y.useRef(0),[n,d]=Y.useState([]),[f,g]=Y.useState(null),[h,t]=Y.useState(null),[s,e]=Y.useState({w:0,h:0}),[i,a]=Y.useState({});He({repo:N,socketRef:j,viewsRef:q,pendingRef:o,sentSizesRef:r,lastActiveByRepoRef:l,expectCreateRef:_,setPanes:d,setActive:g,setZoomed:t,setTitles:a}),$e({panes:n,socketRef:j,viewsRef:q,bodyRefs:x,pendingRef:o,setTitles:a}),Y.useEffect(()=>{for(const[B,H]of q.current){const I=x.current.get(B);if(!I||I.clientHeight===0||I.clientWidth===0)continue;H.fit.fit();const{rows:m,cols:w}=H.term,y=r.current.get(B);y&&y.rows===m&&y.cols===w||(r.current.set(B,{rows:m,cols:w}),j.current?.send(JSON.stringify({type:"resize",pane:B,rows:m,cols:w})))}},[n,h,s]),Y.useEffect(()=>{const B=V.current;if(!B)return;const H=new ResizeObserver(()=>{e({w:B.clientWidth,h:B.clientHeight})});return H.observe(B),()=>H.disconnect()},[]),Y.useEffect(()=>{if(f===null&&n.length>0){const B=l.current.get(N);g(B!==void 0&&n.includes(B)?B:n[n.length-1])}},[f,n,N]),Y.useEffect(()=>{f!==null&&q.current.get(f)?.term.focus()},[f]);const v=B=>{g(B),l.current.set(N,B)},u=()=>{const B=j.current;B&&(t(null),_.current+=1,B.send(JSON.stringify({type:"create",rows:24,cols:80})))},p=B=>{j.current?.send(JSON.stringify({type:"close",pane:B}))},{draggingPane:c,dragOverPane:S,reorderable:E,endPaneDrag:k,onPaneDragStart:L,onPaneDragMove:b,onPaneDragEnd:A}=Ie({panes:n,zoomed:h,onFocus:v,onReorder:B=>j.current?.send(JSON.stringify({type:"reorder",order:B}))}),M=Te(n.length,s.w>=s.h);return te.jsxs("section",{className:"flex min-h-0 flex-col border-t border-ink-700",children:[te.jsxs("div",{className:"flex shrink-0 items-center gap-2 bg-ink-900 px-2 py-1",children:[te.jsx("button",{onClick:u,title:"New terminal","aria-label":"New terminal",className:"ml-auto flex shrink-0 items-center rounded-sm px-1.5 py-0.5 text-ink-400 hover:text-accent",children:te.jsx(Le,{})}),te.jsx("button",{onClick:G,"aria-pressed":X,title:X?"Restore panel height":"Maximize the panel","aria-label":X?"Restore panel height":"Maximize the panel",className:"flex shrink-0 items-center rounded-sm px-1.5 py-0.5 text-ink-400 hover:text-accent",children:te.jsx(we,{maximized:X})})]}),te.jsxs("div",{className:"relative min-h-0 flex-1 overflow-hidden bg-ink-950 p-1",children:[n.length===0&&te.jsxs("p",{className:"p-3 text-ink-400",children:["No terminal open. Press ",te.jsx("span",{className:"text-accent",children:"+"})," above to start one."]}),te.jsx("div",{ref:V,className:"grid h-full gap-1",style:h!==null?{gridTemplateColumns:"1fr",gridTemplateRows:"1fr"}:{gridTemplateColumns:`repeat(${M.cols}, minmax(0, 1fr))`,gridTemplateRows:`repeat(${M.rows}, minmax(0, 1fr))`},children:n.map((B,H)=>{const I=i[B]??`term ${H+1}`,m=M.cells[H],w=h!==null?{display:B===h?"flex":"none"}:{display:"flex",gridColumn:`${m.colStart} / span ${m.colSpan}`,gridRow:`${m.row}`};return te.jsx(je,{pane:B,index:H,label:I,cellStyle:w,isActive:B===f,isZoomed:h===B,isDragged:c===B,isDropTarget:S===B,reorderable:E,onFocus:()=>v(B),onToggleZoom:()=>t(y=>y===B?null:B),onClose:()=>p(B),onPaneDragStart:y=>L(y,B),onPaneDragMove:b,onPaneDragEnd:A,onPaneDragCancel:k,bodyRef:y=>{y?x.current.set(B,y):x.current.delete(B)}},B)})})]})]})}export{Ke as TerminalPanel}; diff --git a/viewer-ui/dist/assets/index-BK2uPN-6.js b/viewer-ui/dist/assets/index-BK2uPN-6.js deleted file mode 100644 index 77563ba..0000000 --- a/viewer-ui/dist/assets/index-BK2uPN-6.js +++ /dev/null @@ -1,11 +0,0 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./Markdown-FGllwLLF.js","./Markdown-Dfs9RUU9.css"])))=>i.map(i=>d[i]); -(function(){const y=document.createElement("link").relList;if(y&&y.supports&&y.supports("modulepreload"))return;for(const b of document.querySelectorAll('link[rel="modulepreload"]'))d(b);new MutationObserver(b=>{for(const C of b)if(C.type==="childList")for(const O of C.addedNodes)O.tagName==="LINK"&&O.rel==="modulepreload"&&d(O)}).observe(document,{childList:!0,subtree:!0});function E(b){const C={};return b.integrity&&(C.integrity=b.integrity),b.referrerPolicy&&(C.referrerPolicy=b.referrerPolicy),b.crossOrigin==="use-credentials"?C.credentials="include":b.crossOrigin==="anonymous"?C.credentials="omit":C.credentials="same-origin",C}function d(b){if(b.ep)return;b.ep=!0;const C=E(b);fetch(b.href,C)}})();var Ff={exports:{}},ru={};var Pd;function Xh(){if(Pd)return ru;Pd=1;var f=Symbol.for("react.transitional.element"),y=Symbol.for("react.fragment");function E(d,b,C){var O=null;if(C!==void 0&&(O=""+C),b.key!==void 0&&(O=""+b.key),"key"in b){C={};for(var V in b)V!=="key"&&(C[V]=b[V])}else C=b;return b=C.ref,{$$typeof:f,type:d,key:O,ref:b!==void 0?b:null,props:C}}return ru.Fragment=y,ru.jsx=E,ru.jsxs=E,ru}var tm;function Gh(){return tm||(tm=1,Ff.exports=Xh()),Ff.exports}var r=Gh(),If={exports:{}},k={};var lm;function Lh(){if(lm)return k;lm=1;var f=Symbol.for("react.transitional.element"),y=Symbol.for("react.portal"),E=Symbol.for("react.fragment"),d=Symbol.for("react.strict_mode"),b=Symbol.for("react.profiler"),C=Symbol.for("react.consumer"),O=Symbol.for("react.context"),V=Symbol.for("react.forward_ref"),D=Symbol.for("react.suspense"),M=Symbol.for("react.memo"),H=Symbol.for("react.lazy"),Y=Symbol.for("react.activity"),I=Symbol.iterator;function yt(o){return o===null||typeof o!="object"?null:(o=I&&o[I]||o["@@iterator"],typeof o=="function"?o:null)}var mt={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},xt=Object.assign,el={};function Dt(o,A,R){this.props=o,this.context=A,this.refs=el,this.updater=R||mt}Dt.prototype.isReactComponent={},Dt.prototype.setState=function(o,A){if(typeof o!="object"&&typeof o!="function"&&o!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,o,A,"setState")},Dt.prototype.forceUpdate=function(o){this.updater.enqueueForceUpdate(this,o,"forceUpdate")};function Vt(){}Vt.prototype=Dt.prototype;function J(o,A,R){this.props=o,this.context=A,this.refs=el,this.updater=R||mt}var lt=J.prototype=new Vt;lt.constructor=J,xt(lt,Dt.prototype),lt.isPureReactComponent=!0;var al=Array.isArray;function Gt(){}var P={H:null,A:null,T:null,S:null},Lt=Object.prototype.hasOwnProperty;function il(o,A,R){var q=R.ref;return{$$typeof:f,type:o,key:A,ref:q!==void 0?q:null,props:R}}function Zl(o,A){return il(o.type,A,o.props)}function yl(o){return typeof o=="object"&&o!==null&&o.$$typeof===f}function It(o){var A={"=":"=0",":":"=2"};return"$"+o.replace(/[=:]/g,function(R){return A[R]})}var Vl=/\/+/g;function Rl(o,A){return typeof o=="object"&&o!==null&&o.key!=null?It(""+o.key):A.toString(36)}function nl(o){switch(o.status){case"fulfilled":return o.value;case"rejected":throw o.reason;default:switch(typeof o.status=="string"?o.then(Gt,Gt):(o.status="pending",o.then(function(A){o.status==="pending"&&(o.status="fulfilled",o.value=A)},function(A){o.status==="pending"&&(o.status="rejected",o.reason=A)})),o.status){case"fulfilled":return o.value;case"rejected":throw o.reason}}throw o}function z(o,A,R,q,X){var tt=typeof o;(tt==="undefined"||tt==="boolean")&&(o=null);var it=!1;if(o===null)it=!0;else switch(tt){case"bigint":case"string":case"number":it=!0;break;case"object":switch(o.$$typeof){case f:case y:it=!0;break;case H:return it=o._init,z(it(o._payload),A,R,q,X)}}if(it)return X=X(o),it=q===""?"."+Rl(o,0):q,al(X)?(R="",it!=null&&(R=it.replace(Vl,"$&/")+"/"),z(X,A,R,"",function(la){return la})):X!=null&&(yl(X)&&(X=Zl(X,R+(X.key==null||o&&o.key===X.key?"":(""+X.key).replace(Vl,"$&/")+"/")+it)),A.push(X)),1;it=0;var wt=q===""?".":q+":";if(al(o))for(var Tt=0;Tt>>1,$=z[ot];if(0>>1;otb(R,Z))q<$&&0>b(X,R)?(z[ot]=X,z[q]=Z,ot=q):(z[ot]=R,z[A]=Z,ot=A);else if(q<$&&0>b(X,Z))z[ot]=X,z[q]=Z,ot=q;else break t}}return U}function b(z,U){var Z=z.sortIndex-U.sortIndex;return Z!==0?Z:z.id-U.id}if(f.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var C=performance;f.unstable_now=function(){return C.now()}}else{var O=Date,V=O.now();f.unstable_now=function(){return O.now()-V}}var D=[],M=[],H=1,Y=null,I=3,yt=!1,mt=!1,xt=!1,el=!1,Dt=typeof setTimeout=="function"?setTimeout:null,Vt=typeof clearTimeout=="function"?clearTimeout:null,J=typeof setImmediate<"u"?setImmediate:null;function lt(z){for(var U=E(M);U!==null;){if(U.callback===null)d(M);else if(U.startTime<=z)d(M),U.sortIndex=U.expirationTime,y(D,U);else break;U=E(M)}}function al(z){if(xt=!1,lt(z),!mt)if(E(D)!==null)mt=!0,Gt||(Gt=!0,It());else{var U=E(M);U!==null&&nl(al,U.startTime-z)}}var Gt=!1,P=-1,Lt=5,il=-1;function Zl(){return el?!0:!(f.unstable_now()-ilz&&Zl());){var ot=Y.callback;if(typeof ot=="function"){Y.callback=null,I=Y.priorityLevel;var $=ot(Y.expirationTime<=z);if(z=f.unstable_now(),typeof $=="function"){Y.callback=$,lt(z),U=!0;break l}Y===E(D)&&d(D),lt(z)}else d(D);Y=E(D)}if(Y!==null)U=!0;else{var o=E(M);o!==null&&nl(al,o.startTime-z),U=!1}}break t}finally{Y=null,I=Z,yt=!1}U=void 0}}finally{U?It():Gt=!1}}}var It;if(typeof J=="function")It=function(){J(yl)};else if(typeof MessageChannel<"u"){var Vl=new MessageChannel,Rl=Vl.port2;Vl.port1.onmessage=yl,It=function(){Rl.postMessage(null)}}else It=function(){Dt(yl,0)};function nl(z,U){P=Dt(function(){z(f.unstable_now())},U)}f.unstable_IdlePriority=5,f.unstable_ImmediatePriority=1,f.unstable_LowPriority=4,f.unstable_NormalPriority=3,f.unstable_Profiling=null,f.unstable_UserBlockingPriority=2,f.unstable_cancelCallback=function(z){z.callback=null},f.unstable_forceFrameRate=function(z){0>z||125ot?(z.sortIndex=Z,y(M,z),E(D)===null&&z===E(M)&&(xt?(Vt(P),P=-1):xt=!0,nl(al,Z-ot))):(z.sortIndex=$,y(D,z),mt||yt||(mt=!0,Gt||(Gt=!0,It()))),z},f.unstable_shouldYield=Zl,f.unstable_wrapCallback=function(z){var U=I;return function(){var Z=I;I=U;try{return z.apply(this,arguments)}finally{I=Z}}}})(ls)),ls}var nm;function Zh(){return nm||(nm=1,ts.exports=Qh()),ts.exports}var es={exports:{}},ll={};var um;function Vh(){if(um)return ll;um=1;var f=ds();function y(D){var M="https://react.dev/errors/"+D;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(f)}catch(y){console.error(y)}}return f(),es.exports=Vh(),es.exports}var cm;function Kh(){if(cm)return ou;cm=1;var f=Zh(),y=ds(),E=wh();function d(t){var l="https://react.dev/errors/"+t;if(1$||(t.current=ot[$],ot[$]=null,$--)}function R(t,l){$++,ot[$]=t.current,t.current=l}var q=o(null),X=o(null),tt=o(null),it=o(null);function wt(t,l){switch(R(tt,l),R(X,t),R(q,null),l.nodeType){case 9:case 11:t=(t=l.documentElement)&&(t=t.namespaceURI)?Ed(t):0;break;default:if(t=l.tagName,l=l.namespaceURI)l=Ed(l),t=zd(l,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}A(q),R(q,t)}function Tt(){A(q),A(X),A(tt)}function la(t){t.memoizedState!==null&&R(it,t);var l=q.current,e=zd(l,t.type);l!==e&&(R(X,t),R(q,e))}function ea(t){X.current===t&&(A(q),A(X)),it.current===t&&(A(it),iu._currentValue=Z)}var xe,dn;function wl(t){if(xe===void 0)try{throw Error()}catch(e){var l=e.stack.trim().match(/\n( *(at )?)/);xe=l&&l[1]||"",dn=-1)":-1n||s[a]!==S[n]){var T=` -`+s[a].replace(" at new "," at ");return t.displayName&&T.includes("")&&(T=T.replace("",t.displayName)),T}while(1<=a&&0<=n);break}}}finally{mn=!1,Error.prepareStackTrace=e}return(e=t?t.displayName||t.name:"")?wl(e):""}function Xi(t,l){switch(t.tag){case 26:case 27:case 5:return wl(t.type);case 16:return wl("Lazy");case 13:return t.child!==l&&l!==null?wl("Suspense Fallback"):wl("Suspense");case 19:return wl("SuspenseList");case 0:case 15:return _a(t.type,!1);case 11:return _a(t.type.render,!1);case 1:return _a(t.type,!0);case 31:return wl("Activity");default:return""}}function du(t){try{var l="",e=null;do l+=Xi(t,e),e=t,t=t.return;while(t);return l}catch(a){return` -Error generating stack: `+a.message+` -`+a.stack}}var hn=Object.prototype.hasOwnProperty,Aa=f.unstable_scheduleCallback,Na=f.unstable_cancelCallback,mu=f.unstable_shouldYield,hu=f.unstable_requestPaint,Kt=f.unstable_now,Gi=f.unstable_getCurrentPriorityLevel,Ma=f.unstable_ImmediatePriority,vn=f.unstable_UserBlockingPriority,aa=f.unstable_NormalPriority,vu=f.unstable_LowPriority,yn=f.unstable_IdlePriority,na=f.log,yu=f.unstable_setDisableYieldValue,Ee=null,Pt=null;function Hl(t){if(typeof na=="function"&&yu(t),Pt&&typeof Pt.setStrictMode=="function")try{Pt.setStrictMode(Ee,t)}catch{}}var tl=Math.clz32?Math.clz32:ua,ja=Math.log,ze=Math.LN2;function ua(t){return t>>>=0,t===0?32:31-(ja(t)/ze|0)|0}var Kl=256,Te=262144,_e=4194304;function Pl(t){var l=t&42;if(l!==0)return l;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Oa(t,l,e){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,i=t.pingedLanes;t=t.warmLanes;var c=a&134217727;return c!==0?(a=c&~u,a!==0?n=Pl(a):(i&=c,i!==0?n=Pl(i):e||(e=c&~t,e!==0&&(n=Pl(e))))):(c=a&~u,c!==0?n=Pl(c):i!==0?n=Pl(i):e||(e=a&~t,e!==0&&(n=Pl(e)))),n===0?0:l!==0&&l!==n&&(l&u)===0&&(u=n&-n,e=l&-l,u>=e||u===32&&(e&4194048)!==0)?l:n}function Ae(t,l){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&l)===0}function Li(t,l){switch(t){case 1:case 2:case 4:case 8:case 64:return l+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return l+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function te(){var t=_e;return _e<<=1,(_e&62914560)===0&&(_e=4194304),t}function le(t){for(var l=[],e=0;31>e;e++)l.push(t);return l}function ia(t,l){t.pendingLanes|=l,l!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Ct(t,l,e,a,n,u){var i=t.pendingLanes;t.pendingLanes=e,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=e,t.entangledLanes&=e,t.errorRecoveryDisabledLanes&=e,t.shellSuspendCounter=0;var c=t.entanglements,s=t.expirationTimes,S=t.hiddenUpdates;for(e=i&~e;0"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var Um=/[\n"\\]/g;function Tl(t){return t.replace(Um,function(l){return"\\"+l.charCodeAt(0).toString(16)+" "})}function Vi(t,l,e,a,n,u,i,c){t.name="",i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?t.type=i:t.removeAttribute("type"),l!=null?i==="number"?(l===0&&t.value===""||t.value!=l)&&(t.value=""+sl(l)):t.value!==""+sl(l)&&(t.value=""+sl(l)):i!=="submit"&&i!=="reset"||t.removeAttribute("value"),l!=null?wi(t,i,sl(l)):e!=null?wi(t,i,sl(e)):a!=null&&t.removeAttribute("value"),n==null&&u!=null&&(t.defaultChecked=!!u),n!=null&&(t.checked=n&&typeof n!="function"&&typeof n!="symbol"),c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"?t.name=""+sl(c):t.removeAttribute("name")}function vs(t,l,e,a,n,u,i,c){if(u!=null&&typeof u!="function"&&typeof u!="symbol"&&typeof u!="boolean"&&(t.type=u),l!=null||e!=null){if(!(u!=="submit"&&u!=="reset"||l!=null)){ae(t);return}e=e!=null?""+sl(e):"",l=l!=null?""+sl(l):e,c||l===t.value||(t.value=l),t.defaultValue=l}a=a??n,a=typeof a!="function"&&typeof a!="symbol"&&!!a,t.checked=c?t.checked:!!a,t.defaultChecked=!!a,i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(t.name=i),ae(t)}function wi(t,l,e){l==="number"&&zu(t.ownerDocument)===t||t.defaultValue===""+e||(t.defaultValue=""+e)}function Ca(t,l,e,a){if(t=t.options,l){l={};for(var n=0;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Wi=!1;if(ue)try{var Tn={};Object.defineProperty(Tn,"passive",{get:function(){Wi=!0}}),window.addEventListener("test",Tn,Tn),window.removeEventListener("test",Tn,Tn)}catch{Wi=!1}var Ce=null,Fi=null,_u=null;function Es(){if(_u)return _u;var t,l=Fi,e=l.length,a,n="value"in Ce?Ce.value:Ce.textContent,u=n.length;for(t=0;t=Nn),Ms=" ",js=!1;function Os(t,l){switch(t){case"keyup":return c0.indexOf(l.keyCode)!==-1;case"keydown":return l.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Ds(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var qa=!1;function s0(t,l){switch(t){case"compositionend":return Ds(l);case"keypress":return l.which!==32?null:(js=!0,Ms);case"textInput":return t=l.data,t===Ms&&js?null:t;default:return null}}function r0(t,l){if(qa)return t==="compositionend"||!ec&&Os(t,l)?(t=Es(),_u=Fi=Ce=null,qa=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(l.ctrlKey||l.altKey||l.metaKey)||l.ctrlKey&&l.altKey){if(l.char&&1=l)return{node:e,offset:l-t};t=a}t:{for(;e;){if(e.nextSibling){e=e.nextSibling;break t}e=e.parentNode}e=void 0}e=Xs(e)}}function Ls(t,l){return t&&l?t===l?!0:t&&t.nodeType===3?!1:l&&l.nodeType===3?Ls(t,l.parentNode):"contains"in t?t.contains(l):t.compareDocumentPosition?!!(t.compareDocumentPosition(l)&16):!1:!1}function Qs(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var l=zu(t.document);l instanceof t.HTMLIFrameElement;){try{var e=typeof l.contentWindow.location.href=="string"}catch{e=!1}if(e)t=l.contentWindow;else break;l=zu(t.document)}return l}function uc(t){var l=t&&t.nodeName&&t.nodeName.toLowerCase();return l&&(l==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||l==="textarea"||t.contentEditable==="true")}var S0=ue&&"documentMode"in document&&11>=document.documentMode,Ba=null,ic=null,Dn=null,cc=!1;function Zs(t,l,e){var a=e.window===e?e.document:e.nodeType===9?e:e.ownerDocument;cc||Ba==null||Ba!==zu(a)||(a=Ba,"selectionStart"in a&&uc(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),Dn&&On(Dn,a)||(Dn=a,a=Si(ic,"onSelect"),0>=i,n-=i,$l=1<<32-tl(l)+n|e<F?(ut=L,L=null):ut=L.sibling;var rt=p(v,L,g[F],_);if(rt===null){L===null&&(L=ut);break}t&&L&&rt.alternate===null&&l(v,L),m=u(rt,m,F),st===null?w=rt:st.sibling=rt,st=rt,L=ut}if(F===g.length)return e(v,L),ft&&ce(v,F),w;if(L===null){for(;FF?(ut=L,L=null):ut=L.sibling;var ta=p(v,L,rt.value,_);if(ta===null){L===null&&(L=ut);break}t&&L&&ta.alternate===null&&l(v,L),m=u(ta,m,F),st===null?w=ta:st.sibling=ta,st=ta,L=ut}if(rt.done)return e(v,L),ft&&ce(v,F),w;if(L===null){for(;!rt.done;F++,rt=g.next())rt=N(v,rt.value,_),rt!==null&&(m=u(rt,m,F),st===null?w=rt:st.sibling=rt,st=rt);return ft&&ce(v,F),w}for(L=a(L);!rt.done;F++,rt=g.next())rt=x(L,v,F,rt.value,_),rt!==null&&(t&&rt.alternate!==null&&L.delete(rt.key===null?F:rt.key),m=u(rt,m,F),st===null?w=rt:st.sibling=rt,st=rt);return t&&L.forEach(function(Yh){return l(v,Yh)}),ft&&ce(v,F),w}function pt(v,m,g,_){if(typeof g=="object"&&g!==null&&g.type===xt&&g.key===null&&(g=g.props.children),typeof g=="object"&&g!==null){switch(g.$$typeof){case yt:t:{for(var w=g.key;m!==null;){if(m.key===w){if(w=g.type,w===xt){if(m.tag===7){e(v,m.sibling),_=n(m,g.props.children),_.return=v,v=_;break t}}else if(m.elementType===w||typeof w=="object"&&w!==null&&w.$$typeof===Lt&&ga(w)===m.type){e(v,m.sibling),_=n(m,g.props),Bn(_,g),_.return=v,v=_;break t}e(v,m);break}else l(v,m);m=m.sibling}g.type===xt?(_=da(g.props.children,v.mode,_,g.key),_.return=v,v=_):(_=Hu(g.type,g.key,g.props,null,v.mode,_),Bn(_,g),_.return=v,v=_)}return i(v);case mt:t:{for(w=g.key;m!==null;){if(m.key===w)if(m.tag===4&&m.stateNode.containerInfo===g.containerInfo&&m.stateNode.implementation===g.implementation){e(v,m.sibling),_=n(m,g.children||[]),_.return=v,v=_;break t}else{e(v,m);break}else l(v,m);m=m.sibling}_=hc(g,v.mode,_),_.return=v,v=_}return i(v);case Lt:return g=ga(g),pt(v,m,g,_)}if(nl(g))return B(v,m,g,_);if(It(g)){if(w=It(g),typeof w!="function")throw Error(d(150));return g=w.call(g),K(v,m,g,_)}if(typeof g.then=="function")return pt(v,m,Qu(g),_);if(g.$$typeof===J)return pt(v,m,Yu(v,g),_);Zu(v,g)}return typeof g=="string"&&g!==""||typeof g=="number"||typeof g=="bigint"?(g=""+g,m!==null&&m.tag===6?(e(v,m.sibling),_=n(m,g),_.return=v,v=_):(e(v,m),_=mc(g,v.mode,_),_.return=v,v=_),i(v)):e(v,m)}return function(v,m,g,_){try{qn=0;var w=pt(v,m,g,_);return Ja=null,w}catch(L){if(L===ka||L===Gu)throw L;var st=Sl(29,L,null,v.mode);return st.lanes=_,st.return=v,st}}}var pa=dr(!0),mr=dr(!1),Be=!1;function Ac(t){t.updateQueue={baseState:t.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Nc(t,l){t=t.updateQueue,l.updateQueue===t&&(l.updateQueue={baseState:t.baseState,firstBaseUpdate:t.firstBaseUpdate,lastBaseUpdate:t.lastBaseUpdate,shared:t.shared,callbacks:null})}function Ye(t){return{lane:t,tag:0,payload:null,callback:null,next:null}}function Xe(t,l,e){var a=t.updateQueue;if(a===null)return null;if(a=a.shared,(dt&2)!==0){var n=a.pending;return n===null?l.next=l:(l.next=n.next,n.next=l),a.pending=l,l=Ru(t),Ws(t,null,e),l}return Uu(t,a,l,e),Ru(t)}function Yn(t,l,e){if(l=l.updateQueue,l!==null&&(l=l.shared,(e&4194048)!==0)){var a=l.lanes;a&=t.pendingLanes,e|=a,l.lanes=e,gn(t,e)}}function Mc(t,l){var e=t.updateQueue,a=t.alternate;if(a!==null&&(a=a.updateQueue,e===a)){var n=null,u=null;if(e=e.firstBaseUpdate,e!==null){do{var i={lane:e.lane,tag:e.tag,payload:e.payload,callback:null,next:null};u===null?n=u=i:u=u.next=i,e=e.next}while(e!==null);u===null?n=u=l:u=u.next=l}else n=u=l;e={baseState:a.baseState,firstBaseUpdate:n,lastBaseUpdate:u,shared:a.shared,callbacks:a.callbacks},t.updateQueue=e;return}t=e.lastBaseUpdate,t===null?e.firstBaseUpdate=l:t.next=l,e.lastBaseUpdate=l}var jc=!1;function Xn(){if(jc){var t=Ka;if(t!==null)throw t}}function Gn(t,l,e,a){jc=!1;var n=t.updateQueue;Be=!1;var u=n.firstBaseUpdate,i=n.lastBaseUpdate,c=n.shared.pending;if(c!==null){n.shared.pending=null;var s=c,S=s.next;s.next=null,i===null?u=S:i.next=S,i=s;var T=t.alternate;T!==null&&(T=T.updateQueue,c=T.lastBaseUpdate,c!==i&&(c===null?T.firstBaseUpdate=S:c.next=S,T.lastBaseUpdate=s))}if(u!==null){var N=n.baseState;i=0,T=S=s=null,c=u;do{var p=c.lane&-536870913,x=p!==c.lane;if(x?(nt&p)===p:(a&p)===p){p!==0&&p===wa&&(jc=!0),T!==null&&(T=T.next={lane:0,tag:c.tag,payload:c.payload,callback:null,next:null});t:{var B=t,K=c;p=l;var pt=e;switch(K.tag){case 1:if(B=K.payload,typeof B=="function"){N=B.call(pt,N,p);break t}N=B;break t;case 3:B.flags=B.flags&-65537|128;case 0:if(B=K.payload,p=typeof B=="function"?B.call(pt,N,p):B,p==null)break t;N=Y({},N,p);break t;case 2:Be=!0}}p=c.callback,p!==null&&(t.flags|=64,x&&(t.flags|=8192),x=n.callbacks,x===null?n.callbacks=[p]:x.push(p))}else x={lane:p,tag:c.tag,payload:c.payload,callback:c.callback,next:null},T===null?(S=T=x,s=N):T=T.next=x,i|=p;if(c=c.next,c===null){if(c=n.shared.pending,c===null)break;x=c,c=x.next,x.next=null,n.lastBaseUpdate=x,n.shared.pending=null}}while(!0);T===null&&(s=N),n.baseState=s,n.firstBaseUpdate=S,n.lastBaseUpdate=T,u===null&&(n.shared.lanes=0),Ve|=i,t.lanes=i,t.memoizedState=N}}function hr(t,l){if(typeof t!="function")throw Error(d(191,t));t.call(l)}function vr(t,l){var e=t.callbacks;if(e!==null)for(t.callbacks=null,t=0;tu?u:8;var i=z.T,c={};z.T=c,Jc(t,!1,l,e);try{var s=n(),S=z.S;if(S!==null&&S(c,s),s!==null&&typeof s=="object"&&typeof s.then=="function"){var T=N0(s,a);Zn(t,l,T,zl(t))}else Zn(t,l,a,zl(t))}catch(N){Zn(t,l,{then:function(){},status:"rejected",reason:N},zl())}finally{U.p=u,i!==null&&c.types!==null&&(i.types=c.types),z.T=i}}function U0(){}function Kc(t,l,e,a){if(t.tag!==5)throw Error(d(476));var n=kr(t).queue;Kr(t,n,l,Z,e===null?U0:function(){return Jr(t),e(a)})}function kr(t){var l=t.memoizedState;if(l!==null)return l;l={memoizedState:Z,baseState:Z,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:oe,lastRenderedState:Z},next:null};var e={};return l.next={memoizedState:e,baseState:e,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:oe,lastRenderedState:e},next:null},t.memoizedState=l,t=t.alternate,t!==null&&(t.memoizedState=l),l}function Jr(t){var l=kr(t);l.next===null&&(l=t.alternate.memoizedState),Zn(t,l.next.queue,{},zl())}function kc(){return $t(iu)}function $r(){return Ot().memoizedState}function Wr(){return Ot().memoizedState}function R0(t){for(var l=t.return;l!==null;){switch(l.tag){case 24:case 3:var e=zl();t=Ye(e);var a=Xe(l,t,e);a!==null&&(vl(a,l,e),Yn(a,l,e)),l={cache:Ec()},t.payload=l;return}l=l.return}}function H0(t,l,e){var a=zl();e={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null},Pu(t)?Ir(l,e):(e=oc(t,l,e,a),e!==null&&(vl(e,t,a),Pr(e,l,a)))}function Fr(t,l,e){var a=zl();Zn(t,l,e,a)}function Zn(t,l,e,a){var n={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null};if(Pu(t))Ir(l,n);else{var u=t.alternate;if(t.lanes===0&&(u===null||u.lanes===0)&&(u=l.lastRenderedReducer,u!==null))try{var i=l.lastRenderedState,c=u(i,e);if(n.hasEagerState=!0,n.eagerState=c,gl(c,i))return Uu(t,l,n,0),bt===null&&Cu(),!1}catch{}if(e=oc(t,l,n,a),e!==null)return vl(e,t,a),Pr(e,l,a),!0}return!1}function Jc(t,l,e,a){if(a={lane:2,revertLane:Nf(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},Pu(t)){if(l)throw Error(d(479))}else l=oc(t,e,a,2),l!==null&&vl(l,t,2)}function Pu(t){var l=t.alternate;return t===W||l!==null&&l===W}function Ir(t,l){Wa=Ku=!0;var e=t.pending;e===null?l.next=l:(l.next=e.next,e.next=l),t.pending=l}function Pr(t,l,e){if((e&4194048)!==0){var a=l.lanes;a&=t.pendingLanes,e|=a,l.lanes=e,gn(t,e)}}var Vn={readContext:$t,use:$u,useCallback:At,useContext:At,useEffect:At,useImperativeHandle:At,useLayoutEffect:At,useInsertionEffect:At,useMemo:At,useReducer:At,useRef:At,useState:At,useDebugValue:At,useDeferredValue:At,useTransition:At,useSyncExternalStore:At,useId:At,useHostTransitionStatus:At,useFormState:At,useActionState:At,useOptimistic:At,useMemoCache:At,useCacheRefresh:At};Vn.useEffectEvent=At;var to={readContext:$t,use:$u,useCallback:function(t,l){return ul().memoizedState=[t,l===void 0?null:l],t},useContext:$t,useEffect:Br,useImperativeHandle:function(t,l,e){e=e!=null?e.concat([t]):null,Fu(4194308,4,Lr.bind(null,l,t),e)},useLayoutEffect:function(t,l){return Fu(4194308,4,t,l)},useInsertionEffect:function(t,l){Fu(4,2,t,l)},useMemo:function(t,l){var e=ul();l=l===void 0?null:l;var a=t();if(ba){Hl(!0);try{t()}finally{Hl(!1)}}return e.memoizedState=[a,l],a},useReducer:function(t,l,e){var a=ul();if(e!==void 0){var n=e(l);if(ba){Hl(!0);try{e(l)}finally{Hl(!1)}}}else n=l;return a.memoizedState=a.baseState=n,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:n},a.queue=t,t=t.dispatch=H0.bind(null,W,t),[a.memoizedState,t]},useRef:function(t){var l=ul();return t={current:t},l.memoizedState=t},useState:function(t){t=Lc(t);var l=t.queue,e=Fr.bind(null,W,l);return l.dispatch=e,[t.memoizedState,e]},useDebugValue:Vc,useDeferredValue:function(t,l){var e=ul();return wc(e,t,l)},useTransition:function(){var t=Lc(!1);return t=Kr.bind(null,W,t.queue,!0,!1),ul().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,l,e){var a=W,n=ul();if(ft){if(e===void 0)throw Error(d(407));e=e()}else{if(e=l(),bt===null)throw Error(d(349));(nt&127)!==0||xr(a,l,e)}n.memoizedState=e;var u={value:e,getSnapshot:l};return n.queue=u,Br(zr.bind(null,a,u,t),[t]),a.flags|=2048,Ia(9,{destroy:void 0},Er.bind(null,a,u,e,l),null),e},useId:function(){var t=ul(),l=bt.identifierPrefix;if(ft){var e=Wl,a=$l;e=(a&~(1<<32-tl(a)-1)).toString(32)+e,l="_"+l+"R_"+e,e=ku++,0<\/script>",u=u.removeChild(u.firstChild);break;case"select":u=typeof a.is=="string"?i.createElement("select",{is:a.is}):i.createElement("select"),a.multiple?u.multiple=!0:a.size&&(u.size=a.size);break;default:u=typeof a.is=="string"?i.createElement(n,{is:a.is}):i.createElement(n)}}u[Yt]=l,u[kt]=a;t:for(i=l.child;i!==null;){if(i.tag===5||i.tag===6)u.appendChild(i.stateNode);else if(i.tag!==4&&i.tag!==27&&i.child!==null){i.child.return=i,i=i.child;continue}if(i===l)break t;for(;i.sibling===null;){if(i.return===null||i.return===l)break t;i=i.return}i.sibling.return=i.return,i=i.sibling}l.stateNode=u;t:switch(Ft(u,n,a),n){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break t;case"img":a=!0;break t;default:a=!1}a&&me(l)}}return zt(l),sf(l,l.type,t===null?null:t.memoizedProps,l.pendingProps,e),null;case 6:if(t&&l.stateNode!=null)t.memoizedProps!==a&&me(l);else{if(typeof a!="string"&&l.stateNode===null)throw Error(d(166));if(t=tt.current,Za(l)){if(t=l.stateNode,e=l.memoizedProps,a=null,n=Jt,n!==null)switch(n.tag){case 27:case 5:a=n.memoizedProps}t[Yt]=l,t=!!(t.nodeValue===e||a!==null&&a.suppressHydrationWarning===!0||bd(t.nodeValue,e)),t||He(l,!0)}else t=pi(t).createTextNode(a),t[Yt]=l,l.stateNode=t}return zt(l),null;case 31:if(e=l.memoizedState,t===null||t.memoizedState!==null){if(a=Za(l),e!==null){if(t===null){if(!a)throw Error(d(318));if(t=l.memoizedState,t=t!==null?t.dehydrated:null,!t)throw Error(d(557));t[Yt]=l}else ma(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;zt(l),t=!1}else e=Sc(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=e),t=!0;if(!t)return l.flags&256?(bl(l),l):(bl(l),null);if((l.flags&128)!==0)throw Error(d(558))}return zt(l),null;case 13:if(a=l.memoizedState,t===null||t.memoizedState!==null&&t.memoizedState.dehydrated!==null){if(n=Za(l),a!==null&&a.dehydrated!==null){if(t===null){if(!n)throw Error(d(318));if(n=l.memoizedState,n=n!==null?n.dehydrated:null,!n)throw Error(d(317));n[Yt]=l}else ma(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;zt(l),n=!1}else n=Sc(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=n),n=!0;if(!n)return l.flags&256?(bl(l),l):(bl(l),null)}return bl(l),(l.flags&128)!==0?(l.lanes=e,l):(e=a!==null,t=t!==null&&t.memoizedState!==null,e&&(a=l.child,n=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(n=a.alternate.memoizedState.cachePool.pool),u=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(u=a.memoizedState.cachePool.pool),u!==n&&(a.flags|=2048)),e!==t&&e&&(l.child.flags|=8192),ni(l,l.updateQueue),zt(l),null);case 4:return Tt(),t===null&&Df(l.stateNode.containerInfo),zt(l),null;case 10:return se(l.type),zt(l),null;case 19:if(A(jt),a=l.memoizedState,a===null)return zt(l),null;if(n=(l.flags&128)!==0,u=a.rendering,u===null)if(n)Kn(a,!1);else{if(Nt!==0||t!==null&&(t.flags&128)!==0)for(t=l.child;t!==null;){if(u=wu(t),u!==null){for(l.flags|=128,Kn(a,!1),t=u.updateQueue,l.updateQueue=t,ni(l,t),l.subtreeFlags=0,t=e,e=l.child;e!==null;)Fs(e,t),e=e.sibling;return R(jt,jt.current&1|2),ft&&ce(l,a.treeForkCount),l.child}t=t.sibling}a.tail!==null&&Kt()>si&&(l.flags|=128,n=!0,Kn(a,!1),l.lanes=4194304)}else{if(!n)if(t=wu(u),t!==null){if(l.flags|=128,n=!0,t=t.updateQueue,l.updateQueue=t,ni(l,t),Kn(a,!0),a.tail===null&&a.tailMode==="hidden"&&!u.alternate&&!ft)return zt(l),null}else 2*Kt()-a.renderingStartTime>si&&e!==536870912&&(l.flags|=128,n=!0,Kn(a,!1),l.lanes=4194304);a.isBackwards?(u.sibling=l.child,l.child=u):(t=a.last,t!==null?t.sibling=u:l.child=u,a.last=u)}return a.tail!==null?(t=a.tail,a.rendering=t,a.tail=t.sibling,a.renderingStartTime=Kt(),t.sibling=null,e=jt.current,R(jt,n?e&1|2:e&1),ft&&ce(l,a.treeForkCount),t):(zt(l),null);case 22:case 23:return bl(l),Dc(),a=l.memoizedState!==null,t!==null?t.memoizedState!==null!==a&&(l.flags|=8192):a&&(l.flags|=8192),a?(e&536870912)!==0&&(l.flags&128)===0&&(zt(l),l.subtreeFlags&6&&(l.flags|=8192)):zt(l),e=l.updateQueue,e!==null&&ni(l,e.retryQueue),e=null,t!==null&&t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),a=null,l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(a=l.memoizedState.cachePool.pool),a!==e&&(l.flags|=2048),t!==null&&A(ya),null;case 24:return e=null,t!==null&&(e=t.memoizedState.cache),l.memoizedState.cache!==e&&(l.flags|=2048),se(Ut),zt(l),null;case 25:return null;case 30:return null}throw Error(d(156,l.tag))}function G0(t,l){switch(yc(l),l.tag){case 1:return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 3:return se(Ut),Tt(),t=l.flags,(t&65536)!==0&&(t&128)===0?(l.flags=t&-65537|128,l):null;case 26:case 27:case 5:return ea(l),null;case 31:if(l.memoizedState!==null){if(bl(l),l.alternate===null)throw Error(d(340));ma()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 13:if(bl(l),t=l.memoizedState,t!==null&&t.dehydrated!==null){if(l.alternate===null)throw Error(d(340));ma()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 19:return A(jt),null;case 4:return Tt(),null;case 10:return se(l.type),null;case 22:case 23:return bl(l),Dc(),t!==null&&A(ya),t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 24:return se(Ut),null;case 25:return null;default:return null}}function _o(t,l){switch(yc(l),l.tag){case 3:se(Ut),Tt();break;case 26:case 27:case 5:ea(l);break;case 4:Tt();break;case 31:l.memoizedState!==null&&bl(l);break;case 13:bl(l);break;case 19:A(jt);break;case 10:se(l.type);break;case 22:case 23:bl(l),Dc(),t!==null&&A(ya);break;case 24:se(Ut)}}function kn(t,l){try{var e=l.updateQueue,a=e!==null?e.lastEffect:null;if(a!==null){var n=a.next;e=n;do{if((e.tag&t)===t){a=void 0;var u=e.create,i=e.inst;a=u(),i.destroy=a}e=e.next}while(e!==n)}}catch(c){vt(l,l.return,c)}}function Qe(t,l,e){try{var a=l.updateQueue,n=a!==null?a.lastEffect:null;if(n!==null){var u=n.next;a=u;do{if((a.tag&t)===t){var i=a.inst,c=i.destroy;if(c!==void 0){i.destroy=void 0,n=l;var s=e,S=c;try{S()}catch(T){vt(n,s,T)}}}a=a.next}while(a!==u)}}catch(T){vt(l,l.return,T)}}function Ao(t){var l=t.updateQueue;if(l!==null){var e=t.stateNode;try{vr(l,e)}catch(a){vt(t,t.return,a)}}}function No(t,l,e){e.props=xa(t.type,t.memoizedProps),e.state=t.memoizedState;try{e.componentWillUnmount()}catch(a){vt(t,l,a)}}function Jn(t,l){try{var e=t.ref;if(e!==null){switch(t.tag){case 26:case 27:case 5:var a=t.stateNode;break;case 30:a=t.stateNode;break;default:a=t.stateNode}typeof e=="function"?t.refCleanup=e(a):e.current=a}}catch(n){vt(t,l,n)}}function Fl(t,l){var e=t.ref,a=t.refCleanup;if(e!==null)if(typeof a=="function")try{a()}catch(n){vt(t,l,n)}finally{t.refCleanup=null,t=t.alternate,t!=null&&(t.refCleanup=null)}else if(typeof e=="function")try{e(null)}catch(n){vt(t,l,n)}else e.current=null}function Mo(t){var l=t.type,e=t.memoizedProps,a=t.stateNode;try{t:switch(l){case"button":case"input":case"select":case"textarea":e.autoFocus&&a.focus();break t;case"img":e.src?a.src=e.src:e.srcSet&&(a.srcset=e.srcSet)}}catch(n){vt(t,t.return,n)}}function rf(t,l,e){try{var a=t.stateNode;fh(a,t.type,e,l),a[kt]=l}catch(n){vt(t,t.return,n)}}function jo(t){return t.tag===5||t.tag===3||t.tag===26||t.tag===27&&$e(t.type)||t.tag===4}function of(t){t:for(;;){for(;t.sibling===null;){if(t.return===null||jo(t.return))return null;t=t.return}for(t.sibling.return=t.return,t=t.sibling;t.tag!==5&&t.tag!==6&&t.tag!==18;){if(t.tag===27&&$e(t.type)||t.flags&2||t.child===null||t.tag===4)continue t;t.child.return=t,t=t.child}if(!(t.flags&2))return t.stateNode}}function df(t,l,e){var a=t.tag;if(a===5||a===6)t=t.stateNode,l?(e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e).insertBefore(t,l):(l=e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e,l.appendChild(t),e=e._reactRootContainer,e!=null||l.onclick!==null||(l.onclick=ne));else if(a!==4&&(a===27&&$e(t.type)&&(e=t.stateNode,l=null),t=t.child,t!==null))for(df(t,l,e),t=t.sibling;t!==null;)df(t,l,e),t=t.sibling}function ui(t,l,e){var a=t.tag;if(a===5||a===6)t=t.stateNode,l?e.insertBefore(t,l):e.appendChild(t);else if(a!==4&&(a===27&&$e(t.type)&&(e=t.stateNode),t=t.child,t!==null))for(ui(t,l,e),t=t.sibling;t!==null;)ui(t,l,e),t=t.sibling}function Oo(t){var l=t.stateNode,e=t.memoizedProps;try{for(var a=t.type,n=l.attributes;n.length;)l.removeAttributeNode(n[0]);Ft(l,a,e),l[Yt]=t,l[kt]=e}catch(u){vt(t,t.return,u)}}var he=!1,qt=!1,mf=!1,Do=typeof WeakSet=="function"?WeakSet:Set,Zt=null;function L0(t,l){if(t=t.containerInfo,Rf=Ai,t=Qs(t),uc(t)){if("selectionStart"in t)var e={start:t.selectionStart,end:t.selectionEnd};else t:{e=(e=t.ownerDocument)&&e.defaultView||window;var a=e.getSelection&&e.getSelection();if(a&&a.rangeCount!==0){e=a.anchorNode;var n=a.anchorOffset,u=a.focusNode;a=a.focusOffset;try{e.nodeType,u.nodeType}catch{e=null;break t}var i=0,c=-1,s=-1,S=0,T=0,N=t,p=null;l:for(;;){for(var x;N!==e||n!==0&&N.nodeType!==3||(c=i+n),N!==u||a!==0&&N.nodeType!==3||(s=i+a),N.nodeType===3&&(i+=N.nodeValue.length),(x=N.firstChild)!==null;)p=N,N=x;for(;;){if(N===t)break l;if(p===e&&++S===n&&(c=i),p===u&&++T===a&&(s=i),(x=N.nextSibling)!==null)break;N=p,p=N.parentNode}N=x}e=c===-1||s===-1?null:{start:c,end:s}}else e=null}e=e||{start:0,end:0}}else e=null;for(Hf={focusedElem:t,selectionRange:e},Ai=!1,Zt=l;Zt!==null;)if(l=Zt,t=l.child,(l.subtreeFlags&1028)!==0&&t!==null)t.return=l,Zt=t;else for(;Zt!==null;){switch(l=Zt,u=l.alternate,t=l.flags,l.tag){case 0:if((t&4)!==0&&(t=l.updateQueue,t=t!==null?t.events:null,t!==null))for(e=0;e title"))),Ft(u,a,e),u[Yt]=t,Mt(u),a=u;break t;case"link":var i=Bd("link","href",n).get(a+(e.href||""));if(i){for(var c=0;cpt&&(i=pt,pt=K,K=i);var v=Gs(c,K),m=Gs(c,pt);if(v&&m&&(x.rangeCount!==1||x.anchorNode!==v.node||x.anchorOffset!==v.offset||x.focusNode!==m.node||x.focusOffset!==m.offset)){var g=N.createRange();g.setStart(v.node,v.offset),x.removeAllRanges(),K>pt?(x.addRange(g),x.extend(m.node,m.offset)):(g.setEnd(m.node,m.offset),x.addRange(g))}}}}for(N=[],x=c;x=x.parentNode;)x.nodeType===1&&N.push({element:x,left:x.scrollLeft,top:x.scrollTop});for(typeof c.focus=="function"&&c.focus(),c=0;ce?32:e,z.T=null,e=bf,bf=null;var u=Ke,i=pe;if(Xt=0,an=Ke=null,pe=0,(dt&6)!==0)throw Error(d(331));var c=dt;if(dt|=4,Qo(u.current),Xo(u,u.current,i,e),dt=c,tu(0,!1),Pt&&typeof Pt.onPostCommitFiberRoot=="function")try{Pt.onPostCommitFiberRoot(Ee,u)}catch{}return!0}finally{U.p=n,z.T=a,id(t,l)}}function fd(t,l,e){l=Al(e,l),l=Ic(t.stateNode,l,2),t=Xe(t,l,2),t!==null&&(ia(t,2),Il(t))}function vt(t,l,e){if(t.tag===3)fd(t,t,e);else for(;l!==null;){if(l.tag===3){fd(l,t,e);break}else if(l.tag===1){var a=l.stateNode;if(typeof l.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(we===null||!we.has(a))){t=Al(e,t),e=fo(2),a=Xe(l,e,2),a!==null&&(so(e,a,l,t),ia(a,2),Il(a));break}}l=l.return}}function Tf(t,l,e){var a=t.pingCache;if(a===null){a=t.pingCache=new V0;var n=new Set;a.set(l,n)}else n=a.get(l),n===void 0&&(n=new Set,a.set(l,n));n.has(e)||(yf=!0,n.add(e),t=$0.bind(null,t,l,e),l.then(t,t))}function $0(t,l,e){var a=t.pingCache;a!==null&&a.delete(l),t.pingedLanes|=t.suspendedLanes&e,t.warmLanes&=~e,bt===t&&(nt&e)===e&&(Nt===4||Nt===3&&(nt&62914560)===nt&&300>Kt()-fi?(dt&2)===0&&nn(t,0):gf|=e,en===nt&&(en=0)),Il(t)}function sd(t,l){l===0&&(l=te()),t=oa(t,l),t!==null&&(ia(t,l),Il(t))}function W0(t){var l=t.memoizedState,e=0;l!==null&&(e=l.retryLane),sd(t,e)}function F0(t,l){var e=0;switch(t.tag){case 31:case 13:var a=t.stateNode,n=t.memoizedState;n!==null&&(e=n.retryLane);break;case 19:a=t.stateNode;break;case 22:a=t.stateNode._retryCache;break;default:throw Error(d(314))}a!==null&&a.delete(l),sd(t,e)}function I0(t,l){return Aa(t,l)}var vi=null,cn=null,_f=!1,yi=!1,Af=!1,Je=0;function Il(t){t!==cn&&t.next===null&&(cn===null?vi=cn=t:cn=cn.next=t),yi=!0,_f||(_f=!0,th())}function tu(t,l){if(!Af&&yi){Af=!0;do for(var e=!1,a=vi;a!==null;){if(t!==0){var n=a.pendingLanes;if(n===0)var u=0;else{var i=a.suspendedLanes,c=a.pingedLanes;u=(1<<31-tl(42|t)+1)-1,u&=n&~(i&~c),u=u&201326741?u&201326741|1:u?u|2:0}u!==0&&(e=!0,md(a,u))}else u=nt,u=Oa(a,a===bt?u:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(u&3)===0||Ae(a,u)||(e=!0,md(a,u));a=a.next}while(e);Af=!1}}function P0(){rd()}function rd(){yi=_f=!1;var t=0;Je!==0&&rh()&&(t=Je);for(var l=Kt(),e=null,a=vi;a!==null;){var n=a.next,u=od(a,l);u===0?(a.next=null,e===null?vi=n:e.next=n,n===null&&(cn=e)):(e=a,(t!==0||(u&3)!==0)&&(yi=!0)),a=n}Xt!==0&&Xt!==5||tu(t),Je!==0&&(Je=0)}function od(t,l){for(var e=t.suspendedLanes,a=t.pingedLanes,n=t.expirationTimes,u=t.pendingLanes&-62914561;0c)break;var T=s.transferSize,N=s.initiatorType;T&&xd(N)&&(s=s.responseEnd,i+=T*(s"u"?null:document;function Ud(t,l,e){var a=fn;if(a&&typeof l=="string"&&l){var n=Tl(l);n='link[rel="'+t+'"][href="'+n+'"]',typeof e=="string"&&(n+='[crossorigin="'+e+'"]'),Cd.has(n)||(Cd.add(n),t={rel:t,crossOrigin:e,href:l},a.querySelector(n)===null&&(l=a.createElement("link"),Ft(l,"link",t),Mt(l),a.head.appendChild(l)))}}function ph(t){be.D(t),Ud("dns-prefetch",t,null)}function bh(t,l){be.C(t,l),Ud("preconnect",t,l)}function xh(t,l,e){be.L(t,l,e);var a=fn;if(a&&t&&l){var n='link[rel="preload"][as="'+Tl(l)+'"]';l==="image"&&e&&e.imageSrcSet?(n+='[imagesrcset="'+Tl(e.imageSrcSet)+'"]',typeof e.imageSizes=="string"&&(n+='[imagesizes="'+Tl(e.imageSizes)+'"]')):n+='[href="'+Tl(t)+'"]';var u=n;switch(l){case"style":u=sn(t);break;case"script":u=rn(t)}Cl.has(u)||(t=Y({rel:"preload",href:l==="image"&&e&&e.imageSrcSet?void 0:t,as:l},e),Cl.set(u,t),a.querySelector(n)!==null||l==="style"&&a.querySelector(nu(u))||l==="script"&&a.querySelector(uu(u))||(l=a.createElement("link"),Ft(l,"link",t),Mt(l),a.head.appendChild(l)))}}function Eh(t,l){be.m(t,l);var e=fn;if(e&&t){var a=l&&typeof l.as=="string"?l.as:"script",n='link[rel="modulepreload"][as="'+Tl(a)+'"][href="'+Tl(t)+'"]',u=n;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":u=rn(t)}if(!Cl.has(u)&&(t=Y({rel:"modulepreload",href:t},l),Cl.set(u,t),e.querySelector(n)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(e.querySelector(uu(u)))return}a=e.createElement("link"),Ft(a,"link",t),Mt(a),e.head.appendChild(a)}}}function zh(t,l,e){be.S(t,l,e);var a=fn;if(a&&t){var n=De(a).hoistableStyles,u=sn(t);l=l||"default";var i=n.get(u);if(!i){var c={loading:0,preload:null};if(i=a.querySelector(nu(u)))c.loading=5;else{t=Y({rel:"stylesheet",href:t,"data-precedence":l},e),(e=Cl.get(u))&&Qf(t,e);var s=i=a.createElement("link");Mt(s),Ft(s,"link",t),s._p=new Promise(function(S,T){s.onload=S,s.onerror=T}),s.addEventListener("load",function(){c.loading|=1}),s.addEventListener("error",function(){c.loading|=2}),c.loading|=4,xi(i,l,a)}i={type:"stylesheet",instance:i,count:1,state:c},n.set(u,i)}}}function Th(t,l){be.X(t,l);var e=fn;if(e&&t){var a=De(e).hoistableScripts,n=rn(t),u=a.get(n);u||(u=e.querySelector(uu(n)),u||(t=Y({src:t,async:!0},l),(l=Cl.get(n))&&Zf(t,l),u=e.createElement("script"),Mt(u),Ft(u,"link",t),e.head.appendChild(u)),u={type:"script",instance:u,count:1,state:null},a.set(n,u))}}function _h(t,l){be.M(t,l);var e=fn;if(e&&t){var a=De(e).hoistableScripts,n=rn(t),u=a.get(n);u||(u=e.querySelector(uu(n)),u||(t=Y({src:t,async:!0,type:"module"},l),(l=Cl.get(n))&&Zf(t,l),u=e.createElement("script"),Mt(u),Ft(u,"link",t),e.head.appendChild(u)),u={type:"script",instance:u,count:1,state:null},a.set(n,u))}}function Rd(t,l,e,a){var n=(n=tt.current)?bi(n):null;if(!n)throw Error(d(446));switch(t){case"meta":case"title":return null;case"style":return typeof e.precedence=="string"&&typeof e.href=="string"?(l=sn(e.href),e=De(n).hoistableStyles,a=e.get(l),a||(a={type:"style",instance:null,count:0,state:null},e.set(l,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(e.rel==="stylesheet"&&typeof e.href=="string"&&typeof e.precedence=="string"){t=sn(e.href);var u=De(n).hoistableStyles,i=u.get(t);if(i||(n=n.ownerDocument||n,i={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},u.set(t,i),(u=n.querySelector(nu(t)))&&!u._p&&(i.instance=u,i.state.loading=5),Cl.has(t)||(e={rel:"preload",as:"style",href:e.href,crossOrigin:e.crossOrigin,integrity:e.integrity,media:e.media,hrefLang:e.hrefLang,referrerPolicy:e.referrerPolicy},Cl.set(t,e),u||Ah(n,t,e,i.state))),l&&a===null)throw Error(d(528,""));return i}if(l&&a!==null)throw Error(d(529,""));return null;case"script":return l=e.async,e=e.src,typeof e=="string"&&l&&typeof l!="function"&&typeof l!="symbol"?(l=rn(e),e=De(n).hoistableScripts,a=e.get(l),a||(a={type:"script",instance:null,count:0,state:null},e.set(l,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(d(444,t))}}function sn(t){return'href="'+Tl(t)+'"'}function nu(t){return'link[rel="stylesheet"]['+t+"]"}function Hd(t){return Y({},t,{"data-precedence":t.precedence,precedence:null})}function Ah(t,l,e,a){t.querySelector('link[rel="preload"][as="style"]['+l+"]")?a.loading=1:(l=t.createElement("link"),a.preload=l,l.addEventListener("load",function(){return a.loading|=1}),l.addEventListener("error",function(){return a.loading|=2}),Ft(l,"link",e),Mt(l),t.head.appendChild(l))}function rn(t){return'[src="'+Tl(t)+'"]'}function uu(t){return"script[async]"+t}function qd(t,l,e){if(l.count++,l.instance===null)switch(l.type){case"style":var a=t.querySelector('style[data-href~="'+Tl(e.href)+'"]');if(a)return l.instance=a,Mt(a),a;var n=Y({},e,{"data-href":e.href,"data-precedence":e.precedence,href:null,precedence:null});return a=(t.ownerDocument||t).createElement("style"),Mt(a),Ft(a,"style",n),xi(a,e.precedence,t),l.instance=a;case"stylesheet":n=sn(e.href);var u=t.querySelector(nu(n));if(u)return l.state.loading|=4,l.instance=u,Mt(u),u;a=Hd(e),(n=Cl.get(n))&&Qf(a,n),u=(t.ownerDocument||t).createElement("link"),Mt(u);var i=u;return i._p=new Promise(function(c,s){i.onload=c,i.onerror=s}),Ft(u,"link",a),l.state.loading|=4,xi(u,e.precedence,t),l.instance=u;case"script":return u=rn(e.src),(n=t.querySelector(uu(u)))?(l.instance=n,Mt(n),n):(a=e,(n=Cl.get(u))&&(a=Y({},e),Zf(a,n)),t=t.ownerDocument||t,n=t.createElement("script"),Mt(n),Ft(n,"link",a),t.head.appendChild(n),l.instance=n);case"void":return null;default:throw Error(d(443,l.type))}else l.type==="stylesheet"&&(l.state.loading&4)===0&&(a=l.instance,l.state.loading|=4,xi(a,e.precedence,t));return l.instance}function xi(t,l,e){for(var a=e.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),n=a.length?a[a.length-1]:null,u=n,i=0;i title"):null)}function Nh(t,l,e){if(e===1||l.itemProp!=null)return!1;switch(t){case"meta":case"title":return!0;case"style":if(typeof l.precedence!="string"||typeof l.href!="string"||l.href==="")break;return!0;case"link":if(typeof l.rel!="string"||typeof l.href!="string"||l.href===""||l.onLoad||l.onError)break;return l.rel==="stylesheet"?(t=l.disabled,typeof l.precedence=="string"&&t==null):!0;case"script":if(l.async&&typeof l.async!="function"&&typeof l.async!="symbol"&&!l.onLoad&&!l.onError&&l.src&&typeof l.src=="string")return!0}return!1}function Xd(t){return!(t.type==="stylesheet"&&(t.state.loading&3)===0)}function Mh(t,l,e,a){if(e.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(e.state.loading&4)===0){if(e.instance===null){var n=sn(a.href),u=l.querySelector(nu(n));if(u){l=u._p,l!==null&&typeof l=="object"&&typeof l.then=="function"&&(t.count++,t=zi.bind(t),l.then(t,t)),e.state.loading|=4,e.instance=u,Mt(u);return}u=l.ownerDocument||l,a=Hd(a),(n=Cl.get(n))&&Qf(a,n),u=u.createElement("link"),Mt(u);var i=u;i._p=new Promise(function(c,s){i.onload=c,i.onerror=s}),Ft(u,"link",a),e.instance=u}t.stylesheets===null&&(t.stylesheets=new Map),t.stylesheets.set(e,l),(l=e.state.preload)&&(e.state.loading&3)===0&&(t.count++,e=zi.bind(t),l.addEventListener("load",e),l.addEventListener("error",e))}}var Vf=0;function jh(t,l){return t.stylesheets&&t.count===0&&_i(t,t.stylesheets),0Vf?50:800)+l);return t.unsuspend=e,function(){t.unsuspend=null,clearTimeout(a),clearTimeout(n)}}:null}function zi(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)_i(this,this.stylesheets);else if(this.unsuspend){var t=this.unsuspend;this.unsuspend=null,t()}}}var Ti=null;function _i(t,l){t.stylesheets=null,t.unsuspend!==null&&(t.count++,Ti=new Map,l.forEach(Oh,t),Ti=null,zi.call(t))}function Oh(t,l){if(!(l.state.loading&4)){var e=Ti.get(t);if(e)var a=e.get(null);else{e=new Map,Ti.set(t,e);for(var n=t.querySelectorAll("link[data-precedence],style[data-precedence]"),u=0;u"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(f)}catch(y){console.error(y)}}return f(),Pf.exports=Kh(),Pf.exports}var Jh=kh();const $h="modulepreload",Wh=function(f,y){return new URL(f,y).href},sm={},pm=function(y,E,d){let b=Promise.resolve();if(E&&E.length>0){let M=function(H){return Promise.all(H.map(Y=>Promise.resolve(Y).then(I=>({status:"fulfilled",value:I}),I=>({status:"rejected",reason:I}))))};const O=document.getElementsByTagName("link"),V=document.querySelector("meta[property=csp-nonce]"),D=V?.nonce||V?.getAttribute("nonce");b=M(E.map(H=>{if(H=Wh(H,d),H in sm)return;sm[H]=!0;const Y=H.endsWith(".css"),I=Y?'[rel="stylesheet"]':"";if(d)for(let mt=O.length-1;mt>=0;mt--){const xt=O[mt];if(xt.href===H&&(!Y||xt.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${H}"]${I}`))return;const yt=document.createElement("link");if(yt.rel=Y?"stylesheet":$h,Y||(yt.as="script"),yt.crossOrigin="",yt.href=H,D&&yt.setAttribute("nonce",D),document.head.appendChild(yt),Y)return new Promise((mt,xt)=>{yt.addEventListener("load",mt),yt.addEventListener("error",()=>xt(new Error(`Unable to preload CSS for ${H}`)))})}))}function C(O){const V=new Event("vite:preloadError",{cancelable:!0});if(V.payload=O,window.dispatchEvent(V),!V.defaultPrevented)throw O}return b.then(O=>{for(const V of O||[])V.status==="rejected"&&C(V.reason);return y().catch(C)})},ms=2;class Ta extends Error{constructor(y,E){super(E),this.status=y}status}const rm=f=>f instanceof Ta&&f.status===401;class bm extends Error{constructor(y){super("connection lost — check your network",{cause:y}),this.name="NetworkError"}}const Fh=f=>f instanceof bm;async function Bi(f,y){try{return await fetch(f,y)}catch(E){throw new bm(E)}}async function Ll(f,y){const E=await Bi(f,{credentials:"same-origin",signal:y});if(!E.ok){let b=`request failed (${E.status})`;try{const C=await E.json();typeof C?.error=="string"&&(b=C.error)}catch{}throw new Ta(E.status,b)}const d=await E.json();if(d.version!==ms)throw new Ta(E.status,`this page is out of date (server protocol v${d.version}) — reload`);return d}async function Ui(f,y){const E=await Bi(f,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify(y)});if(!E.ok){let b=`request failed (${E.status})`;try{const C=await E.json();typeof C?.error=="string"&&(b=C.error)}catch{}throw new Ta(E.status,b)}const d=await E.json();if(d.version!==ms)throw new Ta(E.status,`this page is out of date (server protocol v${d.version}) — reload`);return d}const Ul=f=>new URLSearchParams(f).toString(),Bt={async login(f){const y=await Bi("/login",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({password:f}).toString()});if(!y.ok)throw new Ta(y.status,y.status===429?"too many attempts — wait a minute":"incorrect password")},repos:f=>Ll("/api/repos",f),setAccent:f=>Ui("/api/prefs",{accent:f}).then(y=>y.accent),setSidebarWidth:f=>Ui("/api/prefs",{sidebar_width:f}).then(y=>y.sidebar_width),status:f=>Ll(`/api/status?${Ul({repo:f})}`),tree:(f,y)=>Ll(`/api/tree?${Ul({repo:f,path:y})}`),treeSearch:(f,y)=>Ll(`/api/tree/search?${Ul({repo:f,q:y})}`),log:(f,y)=>Ll(`/api/log?${Ul(y?{repo:f,from:y.from,skip:String(y.skip)}:{repo:f})}`),diff:(f,y)=>Ll(`/api/diff?${Ul({repo:f,path:y})}`),file:(f,y)=>Ll(`/api/file?${Ul({repo:f,path:y})}`),commit:(f,y)=>Ll(`/api/commit?${Ul({repo:f,oid:y})}`),commitFiles:(f,y)=>Ll(`/api/commit/files?${Ul({repo:f,oid:y})}`),commitFileDiff:(f,y,E)=>Ll(`/api/commit/file-diff?${Ul({repo:f,oid:y,path:E})}`),browse:f=>Ll(`/api/browse${f?`?${Ul({path:f})}`:""}`),mkdir:(f,y)=>Ui("/api/mkdir",{path:f,name:y}).then(E=>E.path),open:f=>Ui("/api/repos",{path:f}).then(y=>y.repo),close:async f=>{const y=await Bi(`/api/repos?${Ul({repo:f})}`,{method:"DELETE",credentials:"same-origin"});if(!y.ok)throw new Ta(y.status,`could not close (${y.status})`)}};function Ih(f,y){const E=new EventSource(`/api/events?${Ul({repo:f})}`);return E.addEventListener("status",d=>{try{const b=JSON.parse(d.data);b.version===ms&&y(b)}catch{}}),()=>E.close()}function Ph({maximized:f}){return r.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:"h-4 w-4",children:f?r.jsxs(r.Fragment,{children:[r.jsx("path",{d:"M8 3v3a2 2 0 0 1-2 2H3"}),r.jsx("path",{d:"M21 8h-3a2 2 0 0 1-2-2V3"}),r.jsx("path",{d:"M3 16h3a2 2 0 0 1 2 2v3"}),r.jsx("path",{d:"M16 21v-3a2 2 0 0 1 2-2h3"})]}):r.jsxs(r.Fragment,{children:[r.jsx("path",{d:"M8 3H5a2 2 0 0 0-2 2v3"}),r.jsx("path",{d:"M21 8V5a2 2 0 0 0-2-2h-3"}),r.jsx("path",{d:"M3 16v3a2 2 0 0 0 2 2h3"}),r.jsx("path",{d:"M16 21h3a2 2 0 0 0 2-2v-3"})]})})}function xm({open:f}){return r.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:`h-3.5 w-3.5 shrink-0 transition-transform ${f?"rotate-90":""}`,children:r.jsx("path",{d:"m9 18 6-6-6-6"})})}function Yi({className:f="h-4 w-4"}){return r.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:`shrink-0 ${f}`,children:[r.jsx("path",{d:"M18 6 6 18"}),r.jsx("path",{d:"m6 6 12 12"})]})}function Em({className:f="h-4 w-4"}){return r.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:`shrink-0 ${f}`,children:[r.jsx("path",{d:"M5 12h14"}),r.jsx("path",{d:"M12 5v14"})]})}function tv(){return r.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:"h-4 w-4",children:[r.jsx("rect",{width:"18",height:"18",x:"3",y:"3",rx:"2"}),r.jsx("path",{d:"M12 3v18"})]})}function lv(){return r.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:"h-4 w-4",children:[r.jsx("path",{d:"M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"}),r.jsx("circle",{cx:"12",cy:"12",r:"3"})]})}function ev(){return r.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:"h-4 w-4",children:[r.jsx("circle",{cx:"11",cy:"11",r:"8"}),r.jsx("path",{d:"m21 21-4.3-4.3"})]})}const zm="nightcrow.viewer.diffLayout",Tm=768;function av(f){const y=[];let E=[],d=[];const b=()=>{const C=Math.max(E.length,d.length);for(let O=0;O{let E;try{E=window.matchMedia(`(min-width: ${Tm}px)`)}catch{return}const d=b=>y(b.matches);return E.addEventListener("change",d),()=>E.removeEventListener("change",d)},[]),f}function fv(){const[f,y]=j.useState(nv),E=cv(),d=j.useCallback(()=>{y(C=>{const O=C==="split"?"unified":"split";return uv(O),O})},[]);return{layout:f,effective:f==="split"&&E?"split":"unified",toggle:d}}const sv=[".md",".markdown"];function om(f){const y=f.toLowerCase();return sv.some(E=>y.endsWith(E))}function rv(f){return f.map(y=>y.map(E=>E.t).join("")).join(` -`)}const ov=5e3,_m=1e3;function dv(f,y){return f===void 0||f<=0?0:f-y}const mv=_m;function hv(f,y,E){const d=dv(y,E);return f===null||Math.abs(d-f)>=mv?d:f}function Am(f,y,E){if(f===void 0)return"cool";const d=Math.max(0,y-f);return d>=E?"cool":dAm(d,y,E)!=="cool")}const Hi=[{name:"yellow",color:"#d9a441"},{name:"cyan",color:"#03c4db"},{name:"green",color:"#77c47a"},{name:"magenta",color:"#dc8fd5"},{name:"blue",color:"#87acfd"}],Nm="nightcrow.viewer.accent";function qi(f){if(!Number.isFinite(f))return 0;const y=Hi.length;return(Math.trunc(f)%y+y)%y}function vv(){try{const f=localStorage.getItem(Nm);return f===null?0:qi(Number(f))}catch{return 0}}function mm(f){try{localStorage.setItem(Nm,String(f))}catch{}}function yv(){const[f,y]=j.useState(vv);j.useLayoutEffect(()=>{document.documentElement.style.setProperty("--color-accent",Hi[f].color)},[f]);const E=j.useCallback(()=>{y(b=>{const C=qi(b+1);return mm(C),Bt.setAccent(C).catch(()=>{}),C})},[]),d=j.useCallback(b=>{y(C=>{const O=qi(b);return O===C?C:(mm(O),O)})},[]);return{accent:Hi[f],next:Hi[qi(f+1)],cycle:E,adopt:d}}const Mm="nightcrow.sidebarWidth",is=280,jm=720,cs=460,Om=.5;function fs(f){return Math.min(Math.max(Math.round(f),is),jm)}function hm(f){let y=jm;try{y=Math.min(y,Math.round(window.innerWidth*Om))}catch{}return Math.min(Math.max(Math.round(f),is),Math.max(y,is))}function gv(){try{const f=Number(localStorage.getItem(Mm));return Number.isFinite(f)&&f>0?fs(f):cs}catch{return cs}}function Ri(f){try{localStorage.setItem(Mm,String(f))}catch{}}function Sv(){const[f,y]=j.useState(gv),E=j.useCallback(O=>{const V=hm(O);y(V),Ri(V)},[]),d=j.useCallback(O=>{const V=hm(O);y(V),Ri(V),Bt.setSidebarWidth(V).catch(()=>{})},[]),b=j.useCallback(()=>{const O=fs(cs);y(O),Ri(O),Bt.setSidebarWidth(O).catch(()=>{})},[]),C=j.useCallback(O=>{y(V=>{const D=fs(O);return D===V?V:(Ri(D),D)})},[]);return{width:f,resize:E,commit:d,reset:b,adopt:C}}let Ql=[],pv=1;const ss=new Set;function rs(){const f=Ql;ss.forEach(y=>y(f))}function bv(f){return ss.add(f),f(Ql),()=>{ss.delete(f)}}function vm(f){const y=Ql.filter(E=>E.id!==f);y.length!==Ql.length&&(Ql=y,rs())}function as(f,y){const E=Ql.findIndex(b=>b.kind===f&&b.message===y);if(E!==-1){const b=Ql[E];return Ql=Ql.map((C,O)=>O===E?{...C,bump:C.bump+1}:C),rs(),b.id}const d=pv++;return Ql=[...Ql,{id:d,kind:f,message:y,bump:0}].slice(-4),rs(),d}const os={error:f=>as("error",f),info:f=>as("info",f),success:f=>as("success",f)},xv=j.lazy(()=>pm(()=>import("./Terminal-C9AoFjwt.js"),[],import.meta.url).then(f=>({default:f.TerminalPanel}))),Ev=j.lazy(()=>pm(()=>import("./Markdown-FGllwLLF.js"),__vite__mapDeps([0,1]),import.meta.url).then(f=>({default:f.MarkdownView}))),ym=3e3,zv=180,Tv=3,_v=400;function Av(f){const y=Math.max(0,Math.floor(Date.now()/1e3-f));return y<60?`${y}s`:y<3600?`${Math.floor(y/60)}m`:y<86400?`${Math.floor(y/3600)}h`:y<86400*30?`${Math.floor(y/86400)}d`:y<86400*365?`${Math.floor(y/(86400*30))}mo`:`${Math.floor(y/(86400*365))}y`}function Nv(f,y){const E=[],d=(b,C)=>{for(const O of f[b]??[]){const V=b?`${b}/${O.name}`:O.name;E.push({path:V,name:O.name,is_dir:O.is_dir,depth:C}),O.is_dir&&y.has(V)&&d(V,C+1)}};return d("",0),E}function ns({path:f,from:y,className:E}){return r.jsx("span",{className:`whitespace-nowrap ${E??""}`,title:y?`${y} → ${f}`:f,children:y?`${y} → ${f}`:f})}const Mv={fresh:"text-accent font-bold",warm:"text-accent",cool:""};function jv(f,y,E){const[d,b]=j.useState(()=>Date.now()+E);return j.useEffect(()=>{if(y<=0||!f)return;const C=f.map(D=>D.mtime),O=Date.now()+E;if(b(O),!dm(C,O,y))return;const V=setInterval(()=>{const D=Date.now()+E;b(D),dm(C,D,y)||clearInterval(V)},_m);return()=>clearInterval(V)},[f,y,E]),d}function Dm(f){return f==="+"?"bg-added/10":f==="-"?"bg-removed/10":""}function Cm({line:f}){return r.jsxs(r.Fragment,{children:[r.jsx("span",{className:"text-ink-400 select-none",children:f.kind}),f.spans.map((y,E)=>r.jsx("span",{style:{color:y.c},children:y.t},E))]})}function Ov({line:f}){return f===null?r.jsx("div",{className:"whitespace-pre bg-ink-900/40 px-3",children:" "}):r.jsx("div",{className:`whitespace-pre px-3 ${Dm(f.kind)}`,children:r.jsx(Cm,{line:f})})}function gm({cells:f,border:y}){const E=y?"border-l border-ink-800":"";return r.jsx("div",{className:`min-w-0 flex-1 basis-1/2 overflow-x-auto ${E}`,children:r.jsx("div",{className:"w-max min-w-full",children:f.map((d,b)=>r.jsx(Ov,{line:d},b))})})}function Dv({lines:f}){const y=av(f);return r.jsxs("div",{className:"flex",children:[r.jsx(gm,{cells:y.map(E=>E.left),border:!1}),r.jsx(gm,{cells:y.map(E=>E.right),border:!0})]})}function Cv({diff:f,split:y}){return r.jsxs("div",{className:"p-1",children:[f.hunks.length===0&&r.jsx("p",{className:"p-3 text-ink-400",children:"No changes."}),f.hunks.map((E,d)=>r.jsxs("div",{className:"mb-2",children:[r.jsxs("div",{className:"bg-ink-850 px-3 py-0.5 text-ink-400",children:[E.file_path?`${E.file_path} `:"",E.header]}),y?r.jsx(Dv,{lines:E.lines}):E.lines.map((b,C)=>r.jsx("div",{className:`px-3 whitespace-pre ${Dm(b.kind)}`,children:r.jsx(Cm,{line:b})},C))]},d)),f.truncated&&r.jsx("p",{className:"p-3 text-accent",children:"Diff truncated — it exceeded the server's size ceiling."})]})}function us(f){return f==="?"?"text-ink-400":f==="D"?"text-removed":f==="A"?"text-added":"text-accent"}function Sm(){return r.jsx("div",{className:"flex h-full items-center justify-center p-6",children:r.jsxs("div",{className:"flex flex-col items-center gap-3 text-ink-400",children:[r.jsx(hs,{className:"h-12 w-12 animate-pulse"}),r.jsx("span",{className:"text-[0.72rem] tracking-[0.18em] uppercase",children:"Loading…"})]})})}function Uv(){const[f,y]=j.useState(null),[E,d]=j.useState([]),[b,C]=j.useState(null),[O,V]=j.useState("status"),[D,M]=j.useState(null),[H,Y]=j.useState([]),[I,yt]=j.useState(!1),[mt,xt]=j.useState(!1),el=j.useRef(null),Dt=j.useRef(!1),Vt=j.useRef(0),J=j.useCallback(()=>{Vt.current+=1,Dt.current=!1,Y([]),el.current=null,yt(!1),xt(!1)},[]),[lt,al]=j.useState(null),[Gt,P]=j.useState({}),[Lt,il]=j.useState(new Set),[Zl,yl]=j.useState([]),[It,Vl]=j.useState(!1),[Rl,nl]=j.useState(!1),[z,U]=j.useState(""),[Z,ot]=j.useState(!1),[$,o]=j.useState({kind:"empty"}),A=j.useRef($);A.current=$;const R=j.useRef(O);R.current=O;const q=j.useRef(H);q.current=H;const X=j.useRef(0),[tt,it]=j.useState(!1),[wt,Tt]=j.useState(!1),[la,ea]=j.useState({}),xe=b!=null&&la[b]||"none",dn=j.useCallback(h=>{b!=null&&ea(G=>{const Q=G[b]??"none",ct=typeof h=="function"?h(Q):h;return{...G,[b]:ct}})},[b]),[wl,mn]=j.useState(null),_a=wl?.enabled?wl.window_secs*1e3:0,[Xi,du]=j.useState(null),hn=jv(D?.files,_a,Xi??0),{accent:Aa,next:Na,cycle:mu,adopt:hu}=yv(),Kt=j.useRef(0),Gi=j.useCallback(()=>{Kt.current+=1,mu()},[mu]),{width:Ma,resize:vn,commit:aa,reset:vu,adopt:yn}=Sv(),na=j.useRef(0),yu=j.useCallback(h=>{na.current+=1,aa(h)},[aa]),Ee=j.useCallback(()=>{na.current+=1,vu()},[vu]),Pt=j.useRef(null),Hl=j.useRef(0),tl=j.useRef(0),ja=j.useRef(0),ze=j.useRef(!1),ua=j.useRef(!1),Kl=j.useRef(0),[Te,_e]=j.useState(!1),Pl=j.useCallback(h=>{if(h.button!==0||!h.isPrimary)return;const G=Pt.current?.getBoundingClientRect().left;G!==void 0&&(Hl.current=G,tl.current=h.clientX,ja.current=Ma,ze.current=!0,ua.current=!1,na.current+=1,_e(!0),h.currentTarget.setPointerCapture(h.pointerId),h.preventDefault())},[Ma]),Oa=j.useCallback(h=>{ze.current&&(!ua.current&&Math.abs(h.clientX-tl.current){if(!ze.current)return;if(ze.current=!1,_e(!1),ua.current){yu(ja.current),Kl.current=0;return}const h=Date.now();h-Kl.current<_v?(Kl.current=0,Ee()):Kl.current=h},[yu,Ee]),Li=j.useCallback(()=>{ze.current=!1,ua.current=!1,Kl.current=0,_e(!1)},[]),te=fv(),[le,ia]=j.useState(!0),Ct=j.useCallback(h=>{if(rm(h)){y(!1);return}os.error(h instanceof Error?h.message:"request failed")},[]),gu=j.useCallback(h=>{d(G=>G.some(Q=>Q.id===h.id)?G:[...G,h]),C(h.id),o({kind:"empty"}),V("status"),it(!1)},[]),gn=j.useCallback(async h=>{try{await Bt.close(h);const G=E.filter(Q=>Q.id!==h);d(G),C(Q=>Q===h?G[0]?.id??null:Q),ea(({[h]:Q,...ct})=>ct)}catch(G){Ct(G)}},[E,Ct]),[Sn,pn]=j.useState(0);j.useEffect(()=>{const h=()=>{document.visibilityState==="visible"&&pn(G=>G+1)};return document.addEventListener("visibilitychange",h),window.addEventListener("online",h),()=>{document.removeEventListener("visibilitychange",h),window.removeEventListener("online",h)}},[]),j.useEffect(()=>{let h=!1,G;const Q=new AbortController,ct=()=>{const cl=Kt.current,Qt=na.current;return Bt.repos(Q.signal).then(({repos:fl,hot:Bl,accent:sl,sidebar_width:xu,now_ms:Zi})=>{h||(mn(Bl),du(ae=>hv(ae,Zi,Date.now())),Kt.current===cl&&hu(sl),na.current===Qt&&!ze.current&&yn(xu),y(!0),Tt(!0),d(fl),C(ae=>ae&&fl.some(Eu=>Eu.id===ae)?ae:fl[0]?.id??null),h||(G=setTimeout(ct,ym)))}).catch(fl=>{h||(rm(fl)?(y(!1),Tt(!1)):Fh(fl)||Ct(fl),G=setTimeout(ct,ym))})};return ct(),()=>{h=!0,Q.abort(),G&&clearTimeout(G)}},[f,Ct,hu,yn,Sn]),j.useEffect(()=>{M(null)},[b,f]),j.useEffect(()=>{if(!(!b||!f))return Ih(b,M)},[b,f,Sn]),j.useEffect(()=>{if(!b||!D)return;const h=A.current;if(R.current!=="status"||h.kind!=="diff")return;const G=h.value.path;if(!D.files.some(Qt=>Qt.path===G)){o({kind:"empty"});return}const Q=X.current;let ct=!0;const cl=()=>{const Qt=A.current;return ct&&Q===X.current&&Qt.kind==="diff"&&Qt.value.path===G};return Bt.diff(b,G).then(Qt=>{cl()&&o({kind:"diff",value:Qt})}).catch(Qt=>{cl()&&Ct(Qt)}),()=>{ct=!1}},[D,b,Ct]);const Ne=j.useCallback(async()=>{if(!b||Dt.current)return;Dt.current=!0;const h=Vt.current;try{const G=el.current,Q=await Bt.log(b,G===null?void 0:{from:G,skip:q.current.length});if(h!==Vt.current)return;Y(ct=>[...ct,...Q.commits]),el.current=Q.head??null,yt(!Q.truncated||Q.head===void 0)}catch(G){h===Vt.current&&(Ct(G),xt(!0))}finally{h===Vt.current&&(Dt.current=!1)}},[b,Ct]);j.useEffect(()=>{!b||!f||O!=="log"||H.length===0&&!I&&!mt&&Ne()},[b,f,O,H.length,I,mt,Ne]);const bn=H.filter(h=>h.summary.toLowerCase().includes(z.toLowerCase())),Da=z!=="",ql=j.useRef(null);j.useEffect(()=>{const h=ql.current;if(!h)return;const G=new IntersectionObserver(Q=>{Q.some(ct=>ct.isIntersecting)&&Ne()},{root:h.closest("ul"),rootMargin:"400px"});return G.observe(h),()=>G.disconnect()},[Ne,I,mt,Da,lt,O,bn.length]),j.useEffect(()=>{P({}),il(new Set),X.current+=1,al(null),o({kind:"empty"}),J()},[b,J]),j.useEffect(()=>{!b||!f||O!=="tree"||Bt.tree(b,"").then(h=>P(G=>({...G,"":h.entries}))).catch(Ct)},[b,f,O,Ct]),j.useEffect(()=>{if(!b||!f||O!=="tree"||!Z||!z){yl([]),Vl(!1),nl(!1);return}nl(!0);let h=!0;const G=setTimeout(()=>{Bt.treeSearch(b,z).then(Q=>{h&&(yl(Q.matches),Vl(Q.truncated))}).catch(Q=>{h&&Ct(Q)}).finally(()=>{h&&nl(!1)})},zv);return()=>{h=!1,clearTimeout(G)}},[b,f,O,z,Z,Ct]);const Yt=h=>{if(!b)return;const G=++X.current;Bt.diff(b,h).then(Q=>{G===X.current&&o({kind:"diff",value:Q})}).catch(Q=>{G===X.current&&Ct(Q)})},kt=h=>{if(!b)return;const G=++X.current;Bt.file(b,h).then(Q=>{G===X.current&&o({kind:"file",value:Q})}).catch(Q=>{G===X.current&&Ct(Q)})},Me=h=>{if(!b)return;const G=++X.current;Bt.commit(b,h).then(Q=>{G===X.current&&o({kind:"diff",value:Q})}).catch(Q=>{G===X.current&&Ct(Q)})},xn=(h,G)=>{if(!b)return;const Q=++X.current;Bt.commitFileDiff(b,h,G).then(ct=>{Q===X.current&&o({kind:"diff",value:ct})}).catch(ct=>{Q===X.current&&Ct(ct)})},Qi=async h=>{if(!b)return;const G=++X.current;try{const Q=await Bt.commitFiles(b,h.oid);if(G!==X.current)return;if(al({commit:h,...Q}),Q.files.length===0){o({kind:"empty"});return}const ct=await Bt.commit(b,h.oid);G===X.current&&o({kind:"diff",value:ct})}catch(Q){G===X.current&&Ct(Q)}},Su=h=>{b&&Bt.tree(b,h).then(G=>P(Q=>({...Q,[h]:G.entries}))).catch(Ct)},pu=h=>{const G=!Lt.has(h);il(Q=>{const ct=new Set(Q);return ct.has(h)?ct.delete(h):ct.add(h),ct}),G&&!(h in Gt)&&Su(h)},ca=h=>{const G=h.split("/"),Q=[];let ct="";for(const cl of G)ct=ct?`${ct}/${cl}`:cl,Q.push(ct);il(cl=>{const Qt=new Set(cl);return Q.forEach(fl=>Qt.add(fl)),Qt}),Q.forEach(cl=>{cl in Gt||Su(cl)}),U(""),ot(!1)};if(f===null)return r.jsx(Sm,{});if(!f)return r.jsx(qv,{onSuccess:()=>y(!0)});if(!wt)return r.jsx(Sm,{});const En=E.find(h=>h.id===b),kl=z.toLowerCase(),je=(D?.files??[]).filter(h=>h.path.toLowerCase().includes(kl)),Oe=(lt?.files??[]).filter(h=>h.path.toLowerCase().includes(kl)||h.old_path?.toLowerCase().includes(kl)),De=new Set(H.slice(0,D?.tracking?.ahead??0).map(h=>h.oid)),Mt=O==="tree"&&Z&&z!=="",bu=Nv(Gt,Lt),Jl=xe==="files",ee=b?xe==="terminal"?"grid-rows-[auto_minmax(0,0fr)_minmax(0,1fr)_auto]":xe==="files"?"grid-rows-[auto_minmax(0,1fr)_minmax(0,0fr)_auto]":"grid-rows-[auto_minmax(0,11fr)_minmax(0,9fr)_auto]":"grid-rows-[auto_1fr]";return r.jsxs("div",{className:`nc-fade grid h-full ${ee}`,children:[r.jsxs("header",{className:"flex items-center gap-2 border-b border-ink-700 bg-ink-900 px-[12.8px] py-[8.8px]",children:[r.jsx(hs,{className:"h-[22px] w-[22px] shrink-0"}),r.jsx("span",{className:"text-[16px] font-medium tracking-[0.04em] text-ink-50",children:"nightcrow"}),r.jsx("span",{className:"hidden font-sans text-[10px] uppercase tracking-[0.18em] text-ink-400 sm:inline",children:"web viewer"}),r.jsx(Rv,{className:"md:hidden",repos:E,currentId:b,onSelect:h=>{C(h),o({kind:"empty"})},onCloseProject:gn,onOpenPicker:()=>it(!0)}),r.jsx("nav",{className:"-my-[8.8px] hidden items-stretch self-stretch overflow-x-auto pl-1 md:flex",children:E.map(h=>r.jsxs("div",{className:`flex items-center border-r border-ink-700 whitespace-nowrap ${h.id===b?"bg-ink-950 text-ink-50 shadow-[inset_0_2px_0_0_var(--color-accent)]":"text-ink-400 hover:bg-ink-850 hover:text-ink-200"}`,title:h.display_path,children:[r.jsx("button",{onClick:()=>{C(h.id),o({kind:"empty"})},className:"self-stretch pl-3 pr-1",children:h.name}),r.jsx("button",{onClick:G=>{G.stopPropagation(),gn(h.id)},title:"Close project","aria-label":`close ${h.name}`,className:"mr-1 flex h-5 w-5 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-removed",children:r.jsx(Yi,{className:"h-3.5 w-3.5"})})]},h.id))}),r.jsxs("button",{onClick:()=>it(!0),title:"Open a project",className:"hidden shrink-0 items-center gap-1 rounded-sm px-2 py-0.5 text-ink-400 hover:text-ink-200 md:inline-flex",children:[r.jsx(Em,{className:"h-3.5 w-3.5"}),"open"]}),r.jsx("button",{onClick:Gi,title:`Accent: ${Aa.name} (click for ${Na.name})`,"aria-label":`accent colour: ${Aa.name}, click for ${Na.name}`,className:"ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded-sm",children:r.jsx("span",{"aria-hidden":"true",className:"h-3 w-3 rounded-full bg-accent ring-1 ring-ink-600"})}),r.jsx("a",{href:"/logout",className:"pl-2 text-ink-400 hover:text-ink-200",children:"sign out"})]}),b?r.jsxs(r.Fragment,{children:[Te&&r.jsx("div",{className:"fixed inset-0 z-50 cursor-col-resize"}),r.jsxs("main",{className:`grid min-h-0 grid-cols-1 md:grid-cols-[var(--nc-sidebar)_1fr] ${Te?"select-none":""}`,style:{"--nc-sidebar":Jl?"0px":`min(${Ma}px, ${Om*100}vw)`},children:[r.jsxs("section",{ref:Pt,className:`relative min-h-0 flex-col overflow-hidden ${Jl?"hidden md:flex":"flex border-ink-700 md:border-r"}`,children:[!Jl&&r.jsx("div",{role:"separator","aria-orientation":"vertical","aria-label":"Resize the file sidebar (double-click to reset)",title:"Drag to resize · double-click to reset",onPointerDown:Pl,onPointerMove:Oa,onPointerUp:Ae,onPointerCancel:Li,onLostPointerCapture:Ae,className:`absolute -right-px top-0 z-10 hidden h-full w-1.5 cursor-col-resize touch-none md:block ${Te?"bg-accent":"hover:bg-accent"}`}),r.jsxs("div",{className:"flex shrink-0 items-stretch border-b border-ink-700 px-2",children:[["status","log","tree"].map(h=>r.jsx("button",{onClick:()=>{h!==O&&(X.current+=1,O==="log"&&(al(null),J()),V(h),o({kind:"empty"}))},"aria-current":h===O?"page":void 0,className:`-mb-px border-b-2 px-2 py-1 ${h===O?"border-accent text-ink-50":"border-transparent text-ink-400 hover:text-ink-200"}`,children:h},h)),r.jsx("button",{onClick:()=>{Z&&U(""),ot(h=>!h)},"aria-pressed":Z,title:Z?"Hide the filter":"Filter the list","aria-label":Z?"Hide the filter":"Filter the list",className:`my-1 ml-auto flex shrink-0 items-center rounded-sm px-1.5 hover:text-accent ${Z?"text-ink-50":"text-ink-400"}`,children:r.jsx(ev,{})})]}),Z&&r.jsx("input",{value:z,onChange:h=>U(h.target.value),placeholder:"filter…",autoFocus:!0,className:"mx-2 mb-1 shrink-0 rounded-sm bg-ink-850 px-2 py-1 outline-none placeholder:text-ink-400 focus:ring-1 focus:ring-accent"}),r.jsxs("ul",{className:"min-h-0 flex-1 overflow-auto",children:[O==="status"&&D===null&&r.jsx("li",{className:"px-3 py-2 text-ink-400",children:"Loading…"}),O==="status"&&D!==null&&je.map(h=>r.jsx("li",{children:r.jsxs("button",{onClick:()=>Yt(h.path),className:"flex w-max min-w-full gap-2 px-3 py-0.5 text-left hover:bg-ink-850",children:[r.jsxs("span",{className:"shrink-0",children:[r.jsx("span",{className:us(h.index),children:h.index===" "?" ":h.index}),r.jsx("span",{className:us(h.worktree),children:h.worktree===" "?" ":h.worktree})]}),r.jsx(ns,{path:h.path,from:h.old_path,className:Mv[Am(h.mtime,hn,_a)]})]})},h.path)),O==="log"&&!lt&&bn.map(h=>r.jsx("li",{children:r.jsxs("button",{onClick:()=>{Qi(h)},title:`${h.author} · ${h.summary}`,className:"flex w-max min-w-full items-baseline gap-2 px-3 py-0.5 text-left hover:bg-ink-850",children:[r.jsx("span",{className:"w-2 shrink-0 text-added",children:De.has(h.oid)?"↑":""}),r.jsx("span",{className:"shrink-0 text-accent",children:h.short_id}),r.jsx("span",{className:"w-10 shrink-0 text-right text-ink-400",children:Av(h.time)}),r.jsx("span",{className:"max-w-[6rem] shrink-0 truncate text-ink-400",children:h.author}),r.jsx("span",{className:"whitespace-nowrap",children:h.summary})]})},h.oid)),O==="log"&&!lt&&!I&&!mt&&!Da&&r.jsx("li",{ref:ql,className:"px-3 py-1 text-ink-400","aria-hidden":"true",children:"loading…"}),O==="log"&&!lt&&!I&&!mt&&Da&&r.jsxs("li",{className:"px-3 py-1 text-ink-400",children:["filtering ",H.length," loaded commits — clear the filter to load more"]}),O==="log"&&!lt&&mt&&r.jsx("li",{className:"px-3 py-1",children:r.jsx("button",{onClick:()=>xt(!1),className:"text-ink-400 hover:text-accent",children:"could not load more — retry"})}),O==="log"&<&&r.jsxs(r.Fragment,{children:[r.jsxs("li",{className:"sticky top-0 z-10 flex w-max min-w-full items-center gap-1 bg-ink-900 px-2 py-1 text-ink-400",children:[r.jsx("button",{onClick:()=>{X.current+=1,al(null),o({kind:"empty"})},className:"rounded-sm px-1 hover:text-accent",title:"Back to commit log",children:"< log"}),r.jsx("span",{className:"text-ink-600",children:"·"}),r.jsx("span",{className:"shrink-0 text-accent",children:lt.commit.short_id}),r.jsx("button",{onClick:()=>Me(lt.commit.oid),className:"rounded-sm px-1 hover:text-accent",title:"Show the complete commit diff",children:"all changes"})]}),Oe.map(h=>r.jsx("li",{children:r.jsxs("button",{onClick:()=>xn(lt.commit.oid,h.path),className:"flex w-max min-w-full gap-2 px-3 py-0.5 text-left hover:bg-ink-850",children:[r.jsx("span",{className:us(h.index),children:h.index}),r.jsx(ns,{path:h.path,from:h.old_path})]})},h.path)),lt.files.length===0&&r.jsx("li",{className:"px-3 py-2 text-ink-400",children:"No changed files."}),lt.files.length>0&&Oe.length===0&&r.jsx("li",{className:"px-3 py-2 text-ink-400",children:"No matching files."}),lt.truncated&&r.jsxs("li",{className:"px-3 py-1 text-accent",children:["Showing the first ",lt.files.length," files."]})]}),O==="tree"&&Mt&&r.jsxs(r.Fragment,{children:[Zl.map(h=>r.jsx("li",{children:r.jsx("button",{onClick:()=>{h.is_dir?ca(h.path):kt(h.path)},title:h.path,className:"w-max min-w-full whitespace-nowrap px-3 py-0.5 text-left hover:bg-ink-850",children:h.is_dir?r.jsxs("span",{className:"text-accent",children:[h.path,"/"]}):h.path})},h.path)),Zl.length===0&&r.jsx("li",{className:"px-3 py-0.5 text-ink-400",children:Rl?"searching…":"no matches"}),It&&r.jsxs("li",{className:"px-3 py-0.5 text-ink-400",children:["showing the first ",Zl.length," matches"]})]}),O==="tree"&&!Mt&&bu.map(h=>r.jsx("li",{children:r.jsxs("button",{onClick:()=>h.is_dir?pu(h.path):kt(h.path),title:h.path,style:{paddingLeft:`${h.depth*.75+.5}rem`},className:"flex w-max min-w-full items-center gap-1 py-0.5 pr-3 text-left hover:bg-ink-850",children:[h.is_dir?r.jsx(xm,{open:Lt.has(h.path)}):r.jsx("span",{className:"h-3.5 w-3.5 shrink-0"}),r.jsx("span",{className:`whitespace-nowrap ${h.is_dir?"text-accent":""}`,children:h.is_dir?`${h.name}/`:h.name})]})},h.path))]})]}),r.jsxs("section",{className:"flex min-h-0 min-w-0 flex-col",children:[r.jsxs("div",{className:"flex shrink-0 items-center gap-2 bg-ink-850 px-3 py-0.5 text-ink-400",children:[$.kind==="file"&&r.jsx(ns,{path:$.value.path}),r.jsxs("div",{className:"ml-auto flex shrink-0 items-center gap-1",children:[$.kind==="diff"&&r.jsx("button",{onClick:te.toggle,"aria-pressed":te.layout==="split",title:te.layout==="split"?"Switch to unified diff":"Switch to split diff","aria-label":te.layout==="split"?"Switch to unified diff":"Switch to split diff",className:`flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent ${te.layout==="split"?"text-accent":""}`,children:r.jsx(tv,{})}),$.kind==="file"&&om($.value.path)&&r.jsx("button",{onClick:()=>ia(h=>!h),"aria-pressed":le,title:le?"Show raw source":"Show rendered markdown","aria-label":le?"Show raw source":"Show rendered markdown",className:`flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent ${le?"text-accent":""}`,children:r.jsx(lv,{})}),r.jsx("button",{onClick:()=>dn(h=>h==="files"?"none":"files"),"aria-pressed":Jl,title:Jl?"Restore the layout":"Maximize the file pane","aria-label":Jl?"Restore the layout":"Maximize the file pane",className:"flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent",children:r.jsx(Ph,{maximized:Jl})})]})]}),r.jsxs("div",{className:"min-h-0 flex-1 overflow-auto",children:[$.kind==="empty"&&r.jsx("p",{className:"p-4 text-ink-400",children:D===null?"Loading…":"Select a file or commit."}),$.kind==="file"&&r.jsxs(r.Fragment,{children:[om($.value.path)&&le?r.jsx(j.Suspense,{fallback:r.jsx("p",{className:"p-4 text-ink-400",children:"Rendering…"}),children:r.jsx(Ev,{source:rv($.value.lines)})}):r.jsx("pre",{className:"p-3 whitespace-pre text-ink-200",children:$.value.lines.map((h,G)=>r.jsx("div",{children:h.length===0?" ":h.map((Q,ct)=>r.jsx("span",{style:{color:Q.c},children:Q.t},ct))},G))}),$.value.truncated&&r.jsx("p",{className:"p-3 text-accent",children:"File truncated — it exceeded the server's size ceiling."})]}),$.kind==="diff"&&r.jsx(Cv,{diff:$.value,split:te.effective==="split"})]})]})]}),b&&r.jsx(j.Suspense,{fallback:null,children:r.jsx(xv,{repo:b,maximized:xe==="terminal",onToggleMaximized:()=>dn(h=>h==="terminal"?"none":"terminal")})}),r.jsxs("footer",{className:"flex shrink-0 items-center gap-3 border-t border-ink-700 bg-ink-900 px-3 py-1 text-ink-400",children:[r.jsx("span",{className:"truncate",children:En?.display_path}),D?.branch&&r.jsx("span",{className:"text-accent",children:D.branch}),D?.tracking&&r.jsxs("span",{children:["↑",D.tracking.ahead," ↓",D.tracking.behind]}),r.jsx("span",{className:"ml-auto",children:D?r.jsx("span",{className:"text-added",children:"● live"}):"connecting…"})]})]}):r.jsx("div",{className:"flex items-center justify-center p-6 text-center text-ink-400",children:r.jsxs("span",{children:["No repository open. Click"," ",r.jsx("span",{className:"text-ink-200",children:"+ open"})," above to add one."]})}),tt&&r.jsx(Hv,{onClose:()=>it(!1),onOpened:gu})]})}function Rv({repos:f,currentId:y,onSelect:E,onCloseProject:d,onOpenPicker:b,className:C=""}){const[O,V]=j.useState(!1),D=j.useRef(null),M=f.find(H=>H.id===y);return j.useEffect(()=>{if(!O)return;const H=Y=>{Y.key==="Escape"&&(V(!1),D.current?.focus())};return document.addEventListener("keydown",H),()=>document.removeEventListener("keydown",H)},[O]),r.jsxs("div",{className:`relative ${C}`,children:[r.jsxs("button",{ref:D,onClick:()=>V(H=>!H),"aria-haspopup":"menu","aria-expanded":O,title:M?.display_path??"Select a project",className:"flex max-w-[9rem] items-center gap-1 rounded-sm bg-ink-700 py-0.5 pl-2 pr-1 text-ink-50",children:[r.jsx("span",{className:"truncate",children:M?.name??"No project"}),r.jsx(xm,{open:O})]}),O&&r.jsxs(r.Fragment,{children:[r.jsx("div",{className:"fixed inset-0 z-40",onClick:()=>V(!1)}),r.jsxs("div",{role:"menu",className:"absolute left-0 z-50 mt-1 max-h-[70vh] w-56 max-w-[80vw] overflow-y-auto rounded-md border border-ink-700 bg-ink-900 py-1 shadow-lg",children:[f.length===0&&r.jsx("p",{className:"px-3 py-1.5 text-ink-400",children:"No projects open."}),f.map(H=>r.jsxs("div",{className:`flex items-center ${H.id===y?"bg-ink-700 text-ink-50":"text-ink-200"}`,children:[r.jsx("button",{role:"menuitem",onClick:()=>{E(H.id),V(!1)},title:H.display_path,className:"min-w-0 flex-1 truncate py-1.5 pl-3 pr-1 text-left hover:text-accent",children:H.name}),r.jsx("button",{onClick:()=>d(H.id),"aria-label":`close ${H.name}`,title:"Close project",className:"mr-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:text-removed",children:r.jsx(Yi,{className:"h-3.5 w-3.5"})})]},H.id)),r.jsx("div",{className:"my-1 border-t border-ink-800"}),r.jsxs("button",{role:"menuitem",onClick:()=>{b(),V(!1)},className:"flex w-full items-center gap-1 px-3 py-1.5 text-left text-ink-400 hover:text-ink-200",children:[r.jsx(Em,{className:"h-3.5 w-3.5"}),"open"]})]})]})]})}function Hv({onClose:f,onOpened:y}){const[E,d]=j.useState(null),[b,C]=j.useState(null),[O,V]=j.useState(null),[D,M]=j.useState(!1),[H,Y]=j.useState(""),[I,yt]=j.useState(!1),[mt,xt]=j.useState(0);j.useEffect(()=>{let J=!1;return Bt.browse(E??void 0).then(lt=>{J||(C(lt),V(null))}).catch(lt=>{J||V(lt instanceof Error?lt.message:"could not browse")}),()=>{J=!0}},[E,mt]);const el=J=>d(`${b.path.replace(/\/$/,"")}/${J}`),Dt=async()=>{if(b){M(!0);try{y(await Bt.open(b.path))}catch(J){os.error(J instanceof Error?J.message:"could not open"),M(!1)}}},Vt=async()=>{if(!b)return;const J=H.trim();if(J){yt(!0);try{await Bt.mkdir(b.path,J),Y(""),xt(lt=>lt+1)}catch(lt){os.error(lt instanceof Error?lt.message:"could not create folder")}finally{yt(!1)}}};return r.jsx("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4",onClick:f,children:r.jsxs("div",{className:"flex max-h-[80vh] w-[34rem] max-w-full flex-col rounded-md border border-ink-700 bg-ink-900",onClick:J=>J.stopPropagation(),children:[r.jsxs("div",{className:"flex shrink-0 items-center gap-2 border-b border-ink-700 px-3 py-2",children:[r.jsx("span",{className:"font-medium text-ink-50",children:"Open a project"}),r.jsx("button",{onClick:f,"aria-label":"close",className:"ml-auto flex h-6 w-6 items-center justify-center rounded-sm text-ink-400 hover:text-ink-200",children:r.jsx(Yi,{})})]}),r.jsx("div",{className:"shrink-0 truncate border-b border-ink-700 px-3 py-1.5 text-ink-400",children:b?.path??"…"}),r.jsxs("ul",{className:"h-72 min-h-0 overflow-y-auto",children:[b?.parent&&r.jsx("li",{children:r.jsx("button",{onClick:()=>d(b.parent),className:"w-full px-3 py-1 text-left text-ink-400 hover:bg-ink-850",children:"../"})}),b?.entries.map(J=>r.jsx("li",{children:r.jsxs("button",{onClick:()=>el(J.name),className:"flex w-full items-center gap-2 px-3 py-1 text-left hover:bg-ink-850",children:[r.jsxs("span",{className:"truncate text-accent",children:[J.name,"/"]}),J.is_repo&&r.jsx("span",{className:"rounded-sm bg-ink-700 px-1 text-[0.65rem] text-ink-200",children:"git"})]})},J.name)),b&&b.entries.length===0&&r.jsx("li",{className:"px-3 py-1 text-ink-400",children:"No sub-folders."})]}),O&&r.jsx("p",{className:"shrink-0 px-3 py-1 text-removed",children:O}),r.jsxs("div",{className:"flex shrink-0 items-center gap-2 border-t border-ink-700 px-3 py-2",children:[r.jsx("input",{value:H,onChange:J=>Y(J.target.value),onKeyDown:J=>{J.key==="Enter"&&Vt()},placeholder:"New folder name","aria-label":"new folder name",className:"min-w-0 flex-1 rounded-sm border border-ink-700 bg-ink-950 px-2 py-1 text-ink-50 placeholder:text-ink-400 focus:border-ink-600 focus:outline-none"}),r.jsx("button",{onClick:Vt,disabled:!b||!H.trim()||I,className:"shrink-0 rounded-sm border border-ink-700 px-2 py-1 text-ink-200 hover:bg-ink-850 disabled:opacity-50",children:I?"Creating…":"Create"})]}),r.jsxs("div",{className:"flex shrink-0 items-center gap-2 border-t border-ink-700 px-3 py-2",children:[r.jsx("span",{className:"truncate text-ink-400",children:b?b.path:""}),r.jsx("button",{onClick:Dt,disabled:!b||D,className:"ml-auto shrink-0 rounded-md bg-ink-50 px-3 py-1 font-semibold text-ink-950 hover:bg-white disabled:opacity-50",children:D?"Opening…":"Open"})]})]})})}function hs({className:f}){return r.jsx("span",{className:`block overflow-hidden rounded-[20.7%] bg-accent ${f??""}`,children:r.jsx("img",{src:"/crow-mono.svg",alt:"","aria-hidden":"true",className:"h-full w-full"})})}function qv({onSuccess:f}){const[y,E]=j.useState(""),[d,b]=j.useState(null),[C,O]=j.useState(!1),V=async D=>{D.preventDefault(),O(!0),b(null);try{await Bt.login(y),f()}catch(M){b(M instanceof Error?M.message:"login failed")}finally{O(!1)}};return r.jsx("div",{className:"flex h-full items-center justify-center p-6",children:r.jsxs("form",{onSubmit:V,className:"w-[17rem] max-w-[86vw]",children:[r.jsx(hs,{className:"mx-auto mb-3 block h-10 w-10"}),r.jsx("h1",{className:"text-center text-lg font-medium tracking-wide text-ink-50",children:"nightcrow"}),r.jsx("p",{className:"mt-1 mb-5 text-center text-[0.62rem] tracking-[0.18em] text-ink-400 uppercase",children:"web viewer"}),d&&r.jsx("p",{className:"mb-2.5 text-center text-removed",children:d}),r.jsx("input",{type:"password",autoFocus:!0,value:y,onChange:D=>E(D.target.value),placeholder:"password",className:"mb-2 w-full rounded-md border border-ink-700 bg-ink-900 px-2.5 py-1.5 outline-none placeholder:text-ink-400 focus:border-accent focus:ring-[3px] focus:ring-accent/15"}),r.jsx("button",{type:"submit",disabled:C,className:"w-full rounded-md bg-ink-50 py-1.5 font-semibold text-ink-950 hover:bg-white disabled:opacity-50",children:C?"Signing in…":"Sign in"})]})})}const Bv={error:7e3,info:5e3,success:5e3},Yv={error:"text-removed",info:"text-accent",success:"text-added"};function Xv(){const[f,y]=j.useState([]);return j.useEffect(()=>bv(y),[]),f.length===0?null:r.jsx("div",{className:"pointer-events-none fixed right-3 top-3 z-[60] flex w-80 max-w-[calc(100vw-1.5rem)] flex-col gap-2","aria-live":"polite",children:f.map(E=>r.jsx(Gv,{toast:E},E.id))})}function Gv({toast:f}){const[y,E]=j.useState(!1);return j.useEffect(()=>{if(y)return;const d=setTimeout(()=>vm(f.id),Bv[f.kind]);return()=>clearTimeout(d)},[f.id,f.kind,f.bump,y]),r.jsxs("div",{role:f.kind==="error"?"alert":"status",className:"nc-fade pointer-events-auto flex items-start gap-2 rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-xs shadow-lg",onMouseEnter:()=>E(!0),onMouseLeave:()=>E(!1),children:[r.jsx("span",{className:`min-w-0 flex-1 break-words ${Yv[f.kind]}`,children:f.message}),r.jsx("button",{type:"button",onClick:()=>vm(f.id),"aria-label":"dismiss",className:"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-ink-200",children:r.jsx(Yi,{className:"h-3 w-3"})})]})}Jh.createRoot(document.getElementById("root")).render(r.jsxs(j.StrictMode,{children:[r.jsx(Uv,{}),r.jsx(Xv,{})]}));export{Ph as M,Em as P,Yi as X,r as j,j as r,os as t}; diff --git a/viewer-ui/dist/assets/index-CGiabjZL.css b/viewer-ui/dist/assets/index-CGiabjZL.css new file mode 100644 index 0000000..b4f8bdc --- /dev/null +++ b/viewer-ui/dist/assets/index-CGiabjZL.css @@ -0,0 +1 @@ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--radius-sm:.25rem;--radius-md:.375rem;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-ink-950:#0b0b0d;--color-ink-900:#121215;--color-ink-850:#17171b;--color-ink-800:#1d1d22;--color-ink-700:#2a2a31;--color-ink-600:#3a3a43;--color-ink-400:#6f6f7d;--color-ink-200:#a8a8b5;--color-ink-50:#e6e6ec;--color-accent:#d9a441;--color-added:#4ba36b;--color-removed:#c85f5f}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.top-0{top:0}.top-3{top:calc(var(--spacing) * 3)}.-right-px{right:-1px}.right-3{right:calc(var(--spacing) * 3)}.left-0{left:0}.z-10{z-index:10}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-auto{margin-inline:auto}.-my-\[8\.8px\]{margin-block:-8.8px}.my-1{margin-block:var(--spacing)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mr-1{margin-right:var(--spacing)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-72{height:calc(var(--spacing) * 72)}.h-\[22px\]{height:22px}.h-full{height:100%}.max-h-\[70vh\]{max-height:70vh}.max-h-\[80vh\]{max-height:80vh}.min-h-0{min-height:0}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-56{width:calc(var(--spacing) * 56)}.w-80{width:calc(var(--spacing) * 80)}.w-\[17rem\]{width:17rem}.w-\[22px\]{width:22px}.w-\[34rem\]{width:34rem}.w-full{width:100%}.w-max{width:max-content}.max-w-\[6rem\]{max-width:6rem}.max-w-\[9rem\]{max-width:9rem}.max-w-\[80vw\]{max-width:80vw}.max-w-\[86vw\]{max-width:86vw}.max-w-\[calc\(100vw-1\.5rem\)\]{max-width:calc(100vw - 1.5rem)}.max-w-full{max-width:100%}.min-w-0{min-width:0}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.basis-1\/2{flex-basis:50%}.rotate-90{rotate:90deg}.animate-pulse{animation:var(--animate-pulse)}.cursor-col-resize{cursor:col-resize}.cursor-grab{cursor:grab}.cursor-grabbing{cursor:grabbing}.touch-none{touch-action:none}.resize{resize:both}.columns-2{columns:2}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-rows-\[auto_1fr\]{grid-template-rows:auto 1fr}.grid-rows-\[auto_minmax\(0\,0fr\)_minmax\(0\,1fr\)_auto\]{grid-template-rows:auto minmax(0,0fr) minmax(0,1fr) auto}.grid-rows-\[auto_minmax\(0\,1fr\)_minmax\(0\,0fr\)_auto\]{grid-template-rows:auto minmax(0,1fr) minmax(0,0fr) auto}.grid-rows-\[auto_minmax\(0\,11fr\)_minmax\(0\,9fr\)_auto\]{grid-template-rows:auto minmax(0,11fr) minmax(0,9fr) auto}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-center{justify-content:center}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.self-stretch{align-self:stretch}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[20\.7\%\]{border-radius:20.7%}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-accent{border-color:var(--color-accent)}.border-ink-700{border-color:var(--color-ink-700)}.border-ink-800{border-color:var(--color-ink-800)}.border-transparent{border-color:#0000}.bg-accent{background-color:var(--color-accent)}.bg-added\/10{background-color:#4ba36b1a}@supports (color:color-mix(in lab,red,red)){.bg-added\/10{background-color:color-mix(in oklab,var(--color-added) 10%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black) 60%,transparent)}}.bg-ink-50{background-color:var(--color-ink-50)}.bg-ink-700{background-color:var(--color-ink-700)}.bg-ink-850{background-color:var(--color-ink-850)}.bg-ink-900{background-color:var(--color-ink-900)}.bg-ink-900\/40{background-color:#12121566}@supports (color:color-mix(in lab,red,red)){.bg-ink-900\/40{background-color:color-mix(in oklab,var(--color-ink-900) 40%,transparent)}}.bg-ink-950{background-color:var(--color-ink-950)}.bg-removed\/10{background-color:#c85f5f1a}@supports (color:color-mix(in lab,red,red)){.bg-removed\/10{background-color:color-mix(in oklab,var(--color-removed) 10%,transparent)}}.p-1{padding:var(--spacing)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-\[12\.8px\]{padding-inline:12.8px}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-\[8\.8px\]{padding-block:8.8px}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pl-1{padding-left:var(--spacing)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-sans{font-family:var(--font-sans)}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.62rem\]{font-size:.62rem}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.72rem\]{font-size:.72rem}.text-\[10px\]{font-size:10px}.text-\[16px\]{font-size:16px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.04em\]{--tw-tracking:.04em;letter-spacing:.04em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.text-accent{color:var(--color-accent)}.text-added{color:var(--color-added)}.text-ink-50{color:var(--color-ink-50)}.text-ink-200{color:var(--color-ink-200)}.text-ink-400{color:var(--color-ink-400)}.text-ink-600{color:var(--color-ink-600)}.text-ink-950{color:var(--color-ink-950)}.text-removed{color:var(--color-removed)}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.opacity-60{opacity:.6}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[inset_0_2px_0_0_var\(--color-accent\)\]{--tw-shadow:inset 0 2px 0 0 var(--tw-shadow-color,var(--color-accent));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-accent{--tw-ring-color:var(--color-accent)}.ring-ink-600{--tw-ring-color:var(--color-ink-600)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.placeholder\:text-ink-400::placeholder{color:var(--color-ink-400)}@media(hover:hover){.hover\:bg-accent:hover{background-color:var(--color-accent)}.hover\:bg-ink-700:hover{background-color:var(--color-ink-700)}.hover\:bg-ink-850:hover{background-color:var(--color-ink-850)}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-accent:hover{color:var(--color-accent)}.hover\:text-ink-200:hover{color:var(--color-ink-200)}.hover\:text-removed:hover{color:var(--color-removed)}}.focus\:border-accent:focus{border-color:var(--color-accent)}.focus\:border-ink-600:focus{border-color:var(--color-ink-600)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-\[3px\]:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-accent:focus{--tw-ring-color:var(--color-accent)}.focus\:ring-accent\/15:focus{--tw-ring-color:#d9a44126}@supports (color:color-mix(in lab,red,red)){.focus\:ring-accent\/15:focus{--tw-ring-color:color-mix(in oklab, var(--color-accent) 15%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:40rem){.sm\:inline{display:inline}}@media(min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:hidden{display:none}.md\:inline-flex{display:inline-flex}.md\:grid-cols-\[var\(--nc-sidebar\)_1fr\]{grid-template-columns:var(--nc-sidebar) 1fr}.md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}}}.xterm{cursor:text;-webkit-user-select:none;user-select:none;position:relative}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{z-index:5;position:absolute;top:0}.xterm .xterm-helper-textarea{opacity:0;z-index:-5;white-space:nowrap;resize:none;border:0;width:0;height:0;margin:0;padding:0;position:absolute;top:0;left:-9999em;overflow:hidden}.xterm .composition-view{color:#fff;white-space:nowrap;z-index:1;background:#000;display:none;position:absolute}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{cursor:default;background-color:#000;position:absolute;inset:0;overflow-y:scroll}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;top:0;left:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{visibility:hidden;line-height:normal;display:inline-block;position:absolute;top:0;left:-9999em}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{z-index:10;color:#0000;pointer-events:none;position:absolute;inset:0}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:#0000}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre}.xterm .live-region{width:1px;height:1px;position:absolute;left:-9999px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:underline double;text-decoration:underline double}.xterm-underline-3{-webkit-text-decoration:underline wavy;text-decoration:underline wavy}.xterm-underline-4{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.xterm-underline-5{-webkit-text-decoration:underline dashed;text-decoration:underline dashed}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:underline overline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;pointer-events:none;position:absolute;top:0;right:0}.xterm-decoration-top{z-index:2;position:relative}html,body,#root{height:100%}html{font-size:14px}body{background:var(--color-ink-950);color:var(--color-ink-50);font-family:var(--font-mono);margin:0;font-size:.85rem;line-height:1.4}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}*{scrollbar-width:thin;scrollbar-color:var(--color-ink-600) transparent}.nc-markdown{max-width:52rem;font-family:var(--font-sans);color:var(--color-ink-50);line-height:1.6}.nc-markdown h1,.nc-markdown h2,.nc-markdown h3,.nc-markdown h4,.nc-markdown h5,.nc-markdown h6{margin:1.4em 0 .6em;font-weight:600;line-height:1.25}.nc-markdown h1{border-bottom:1px solid var(--color-ink-700);padding-bottom:.3em;font-size:1.6em}.nc-markdown h2{border-bottom:1px solid var(--color-ink-800);padding-bottom:.25em;font-size:1.35em}.nc-markdown h3{font-size:1.15em}.nc-markdown h4{font-size:1em}.nc-markdown h5,.nc-markdown h6{color:var(--color-ink-200);font-size:.9em}.nc-markdown :first-child{margin-top:0}.nc-markdown p,.nc-markdown ul,.nc-markdown ol,.nc-markdown blockquote,.nc-markdown table,.nc-markdown pre{margin:.75em 0}.nc-markdown ul,.nc-markdown ol{padding-left:1.5em}.nc-markdown ul{list-style:outside}.nc-markdown ol{list-style:decimal}.nc-markdown li{margin:.25em 0}.nc-markdown li::marker{color:var(--color-ink-400)}.nc-markdown li:has(>input[type=checkbox]){margin-left:-1.2em;list-style:none}.nc-markdown a{color:var(--color-accent);text-underline-offset:2px;text-decoration:underline}.nc-markdown strong{font-weight:600}.nc-markdown em{font-style:italic}.nc-markdown blockquote{border-left:3px solid var(--color-ink-700);color:var(--color-ink-200);padding-left:1em}.nc-markdown hr{border:0;border-top:1px solid var(--color-ink-700);margin:1.5em 0}.nc-markdown img{max-width:100%}.nc-markdown :not(pre)>code{font-family:var(--font-mono);background:var(--color-ink-800);border-radius:3px;padding:.1em .35em;font-size:.9em}.nc-markdown pre{background:var(--color-ink-850);border:1px solid var(--color-ink-800);border-radius:4px;padding:.9em 1em;overflow-x:auto}.nc-markdown pre code{font-family:var(--font-mono);background:0 0;padding:0;font-size:.85em}.nc-markdown table{border-collapse:collapse;display:block;overflow-x:auto}.nc-markdown th,.nc-markdown td{border:1px solid var(--color-ink-700);text-align:left;padding:.4em .7em}.nc-markdown th{background:var(--color-ink-850);font-weight:600}@keyframes nc-fade-in{0%{opacity:0}to{opacity:1}}.nc-fade{animation:.16s ease-out nc-fade-in}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} diff --git a/viewer-ui/dist/assets/index-CwwCtU4T.css b/viewer-ui/dist/assets/index-CwwCtU4T.css deleted file mode 100644 index d204b8a..0000000 --- a/viewer-ui/dist/assets/index-CwwCtU4T.css +++ /dev/null @@ -1 +0,0 @@ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--radius-sm:.25rem;--radius-md:.375rem;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-ink-950:#0b0b0d;--color-ink-900:#121215;--color-ink-850:#17171b;--color-ink-800:#1d1d22;--color-ink-700:#2a2a31;--color-ink-600:#3a3a43;--color-ink-400:#6f6f7d;--color-ink-200:#a8a8b5;--color-ink-50:#e6e6ec;--color-accent:#d9a441;--color-added:#4ba36b;--color-removed:#c85f5f}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.top-0{top:0}.top-3{top:calc(var(--spacing) * 3)}.-right-px{right:-1px}.right-3{right:calc(var(--spacing) * 3)}.left-0{left:0}.z-10{z-index:10}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-auto{margin-inline:auto}.-my-\[8\.8px\]{margin-block:-8.8px}.my-1{margin-block:var(--spacing)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mr-1{margin-right:var(--spacing)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-72{height:calc(var(--spacing) * 72)}.h-\[22px\]{height:22px}.h-full{height:100%}.max-h-\[70vh\]{max-height:70vh}.max-h-\[80vh\]{max-height:80vh}.min-h-0{min-height:0}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-56{width:calc(var(--spacing) * 56)}.w-80{width:calc(var(--spacing) * 80)}.w-\[17rem\]{width:17rem}.w-\[22px\]{width:22px}.w-\[34rem\]{width:34rem}.w-full{width:100%}.w-max{width:max-content}.max-w-\[6rem\]{max-width:6rem}.max-w-\[9rem\]{max-width:9rem}.max-w-\[80vw\]{max-width:80vw}.max-w-\[86vw\]{max-width:86vw}.max-w-\[calc\(100vw-1\.5rem\)\]{max-width:calc(100vw - 1.5rem)}.max-w-full{max-width:100%}.min-w-0{min-width:0}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.basis-1\/2{flex-basis:50%}.rotate-90{rotate:90deg}.animate-pulse{animation:var(--animate-pulse)}.cursor-col-resize{cursor:col-resize}.cursor-grab{cursor:grab}.cursor-grabbing{cursor:grabbing}.touch-none{touch-action:none}.resize{resize:both}.columns-2{columns:2}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-rows-\[auto_1fr\]{grid-template-rows:auto 1fr}.grid-rows-\[auto_minmax\(0\,0fr\)_minmax\(0\,1fr\)_auto\]{grid-template-rows:auto minmax(0,0fr) minmax(0,1fr) auto}.grid-rows-\[auto_minmax\(0\,1fr\)_minmax\(0\,0fr\)_auto\]{grid-template-rows:auto minmax(0,1fr) minmax(0,0fr) auto}.grid-rows-\[auto_minmax\(0\,11fr\)_minmax\(0\,9fr\)_auto\]{grid-template-rows:auto minmax(0,11fr) minmax(0,9fr) auto}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-center{justify-content:center}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.self-stretch{align-self:stretch}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[20\.7\%\]{border-radius:20.7%}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-accent{border-color:var(--color-accent)}.border-ink-700{border-color:var(--color-ink-700)}.border-ink-800{border-color:var(--color-ink-800)}.border-transparent{border-color:#0000}.bg-accent{background-color:var(--color-accent)}.bg-added\/10{background-color:#4ba36b1a}@supports (color:color-mix(in lab,red,red)){.bg-added\/10{background-color:color-mix(in oklab,var(--color-added) 10%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black) 60%,transparent)}}.bg-ink-50{background-color:var(--color-ink-50)}.bg-ink-700{background-color:var(--color-ink-700)}.bg-ink-850{background-color:var(--color-ink-850)}.bg-ink-900{background-color:var(--color-ink-900)}.bg-ink-900\/40{background-color:#12121566}@supports (color:color-mix(in lab,red,red)){.bg-ink-900\/40{background-color:color-mix(in oklab,var(--color-ink-900) 40%,transparent)}}.bg-ink-950{background-color:var(--color-ink-950)}.bg-removed\/10{background-color:#c85f5f1a}@supports (color:color-mix(in lab,red,red)){.bg-removed\/10{background-color:color-mix(in oklab,var(--color-removed) 10%,transparent)}}.p-1{padding:var(--spacing)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-\[12\.8px\]{padding-inline:12.8px}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-\[8\.8px\]{padding-block:8.8px}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pl-1{padding-left:var(--spacing)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-sans{font-family:var(--font-sans)}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.62rem\]{font-size:.62rem}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.72rem\]{font-size:.72rem}.text-\[10px\]{font-size:10px}.text-\[16px\]{font-size:16px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.04em\]{--tw-tracking:.04em;letter-spacing:.04em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.text-accent{color:var(--color-accent)}.text-added{color:var(--color-added)}.text-ink-50{color:var(--color-ink-50)}.text-ink-200{color:var(--color-ink-200)}.text-ink-400{color:var(--color-ink-400)}.text-ink-600{color:var(--color-ink-600)}.text-ink-950{color:var(--color-ink-950)}.text-removed{color:var(--color-removed)}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.opacity-60{opacity:.6}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[inset_0_2px_0_0_var\(--color-accent\)\]{--tw-shadow:inset 0 2px 0 0 var(--tw-shadow-color,var(--color-accent));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-accent{--tw-ring-color:var(--color-accent)}.ring-ink-600{--tw-ring-color:var(--color-ink-600)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.placeholder\:text-ink-400::placeholder{color:var(--color-ink-400)}@media(hover:hover){.hover\:bg-accent:hover{background-color:var(--color-accent)}.hover\:bg-ink-700:hover{background-color:var(--color-ink-700)}.hover\:bg-ink-850:hover{background-color:var(--color-ink-850)}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-accent:hover{color:var(--color-accent)}.hover\:text-ink-200:hover{color:var(--color-ink-200)}.hover\:text-removed:hover{color:var(--color-removed)}}.focus\:border-accent:focus{border-color:var(--color-accent)}.focus\:border-ink-600:focus{border-color:var(--color-ink-600)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-\[3px\]:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-accent:focus{--tw-ring-color:var(--color-accent)}.focus\:ring-accent\/15:focus{--tw-ring-color:#d9a44126}@supports (color:color-mix(in lab,red,red)){.focus\:ring-accent\/15:focus{--tw-ring-color:color-mix(in oklab, var(--color-accent) 15%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:40rem){.sm\:inline{display:inline}}@media(min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:hidden{display:none}.md\:inline-flex{display:inline-flex}.md\:grid-cols-\[var\(--nc-sidebar\)_1fr\]{grid-template-columns:var(--nc-sidebar) 1fr}.md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}}}.xterm{cursor:text;-webkit-user-select:none;user-select:none;position:relative}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{z-index:5;position:absolute;top:0}.xterm .xterm-helper-textarea{opacity:0;z-index:-5;white-space:nowrap;resize:none;border:0;width:0;height:0;margin:0;padding:0;position:absolute;top:0;left:-9999em;overflow:hidden}.xterm .composition-view{color:#fff;white-space:nowrap;z-index:1;background:#000;display:none;position:absolute}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{cursor:default;background-color:#000;position:absolute;inset:0;overflow-y:scroll}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;top:0;left:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{visibility:hidden;line-height:normal;display:inline-block;position:absolute;top:0;left:-9999em}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{z-index:10;color:#0000;pointer-events:none;position:absolute;inset:0}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:#0000}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre}.xterm .live-region{width:1px;height:1px;position:absolute;left:-9999px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:underline double;text-decoration:underline double}.xterm-underline-3{-webkit-text-decoration:underline wavy;text-decoration:underline wavy}.xterm-underline-4{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.xterm-underline-5{-webkit-text-decoration:underline dashed;text-decoration:underline dashed}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:underline overline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;pointer-events:none;position:absolute;top:0;right:0}.xterm-decoration-top{z-index:2;position:relative}html,body,#root{height:100%}html{font-size:14px}body{background:var(--color-ink-950);color:var(--color-ink-50);font-family:var(--font-mono);margin:0;font-size:.85rem;line-height:1.4}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}*{scrollbar-width:thin;scrollbar-color:var(--color-ink-600) transparent}.nc-markdown{max-width:52rem;font-family:var(--font-sans);color:var(--color-ink-50);line-height:1.6}.nc-markdown h1,.nc-markdown h2,.nc-markdown h3,.nc-markdown h4,.nc-markdown h5,.nc-markdown h6{margin:1.4em 0 .6em;font-weight:600;line-height:1.25}.nc-markdown h1{border-bottom:1px solid var(--color-ink-700);padding-bottom:.3em;font-size:1.6em}.nc-markdown h2{border-bottom:1px solid var(--color-ink-800);padding-bottom:.25em;font-size:1.35em}.nc-markdown h3{font-size:1.15em}.nc-markdown h4{font-size:1em}.nc-markdown h5,.nc-markdown h6{color:var(--color-ink-200);font-size:.9em}.nc-markdown :first-child{margin-top:0}.nc-markdown p,.nc-markdown ul,.nc-markdown ol,.nc-markdown blockquote,.nc-markdown table,.nc-markdown pre{margin:.75em 0}.nc-markdown ul,.nc-markdown ol{padding-left:1.5em}.nc-markdown ul{list-style:outside}.nc-markdown ol{list-style:decimal}.nc-markdown li{margin:.25em 0}.nc-markdown li::marker{color:var(--color-ink-400)}.nc-markdown li:has(>input[type=checkbox]){margin-left:-1.2em;list-style:none}.nc-markdown a{color:var(--color-accent);text-underline-offset:2px;text-decoration:underline}.nc-markdown strong{font-weight:600}.nc-markdown em{font-style:italic}.nc-markdown blockquote{border-left:3px solid var(--color-ink-700);color:var(--color-ink-200);padding-left:1em}.nc-markdown hr{border:0;border-top:1px solid var(--color-ink-700);margin:1.5em 0}.nc-markdown img{max-width:100%}.nc-markdown :not(pre)>code{font-family:var(--font-mono);background:var(--color-ink-800);border-radius:3px;padding:.1em .35em;font-size:.9em}.nc-markdown pre{background:var(--color-ink-850);border:1px solid var(--color-ink-800);border-radius:4px;padding:.9em 1em;overflow-x:auto}.nc-markdown pre code{font-family:var(--font-mono);background:0 0;padding:0;font-size:.85em}.nc-markdown table{border-collapse:collapse;display:block;overflow-x:auto}.nc-markdown th,.nc-markdown td{border:1px solid var(--color-ink-700);text-align:left;padding:.4em .7em}.nc-markdown th{background:var(--color-ink-850);font-weight:600}@keyframes nc-fade-in{0%{opacity:0}to{opacity:1}}.nc-fade{animation:.16s ease-out nc-fade-in}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} diff --git a/viewer-ui/dist/assets/index-D8ORZvs2.js b/viewer-ui/dist/assets/index-D8ORZvs2.js new file mode 100644 index 0000000..45e002d --- /dev/null +++ b/viewer-ui/dist/assets/index-D8ORZvs2.js @@ -0,0 +1,11 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./Markdown-BYxaSXJW.js","./Markdown-Dfs9RUU9.css"])))=>i.map(i=>d[i]); +(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const z of document.querySelectorAll('link[rel="modulepreload"]'))s(z);new MutationObserver(z=>{for(const E of z)if(E.type==="childList")for(const _ of E.addedNodes)_.tagName==="LINK"&&_.rel==="modulepreload"&&s(_)}).observe(document,{childList:!0,subtree:!0});function y(z){const E={};return z.integrity&&(E.integrity=z.integrity),z.referrerPolicy&&(E.referrerPolicy=z.referrerPolicy),z.crossOrigin==="use-credentials"?E.credentials="include":z.crossOrigin==="anonymous"?E.credentials="omit":E.credentials="same-origin",E}function s(z){if(z.ep)return;z.ep=!0;const E=y(z);fetch(z.href,E)}})();var Df={exports:{}},Bn={};var wd;function Gh(){if(wd)return Bn;wd=1;var c=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function y(s,z,E){var _=null;if(E!==void 0&&(_=""+E),z.key!==void 0&&(_=""+z.key),"key"in z){E={};for(var H in z)H!=="key"&&(E[H]=z[H])}else E=z;return z=E.ref,{$$typeof:c,type:s,key:_,ref:z!==void 0?z:null,props:E}}return Bn.Fragment=r,Bn.jsx=y,Bn.jsxs=y,Bn}var kd;function Qh(){return kd||(kd=1,Df.exports=Gh()),Df.exports}var o=Qh(),Cf={exports:{}},tt={};var Kd;function Zh(){if(Kd)return tt;Kd=1;var c=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),y=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),z=Symbol.for("react.profiler"),E=Symbol.for("react.consumer"),_=Symbol.for("react.context"),H=Symbol.for("react.forward_ref"),M=Symbol.for("react.suspense"),v=Symbol.for("react.memo"),j=Symbol.for("react.lazy"),D=Symbol.for("react.activity"),R=Symbol.iterator;function Q(h){return h===null||typeof h!="object"?null:(h=R&&h[R]||h["@@iterator"],typeof h=="function"?h:null)}var nt={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},P=Object.assign,J={};function ut(h,O,q){this.props=h,this.context=O,this.refs=J,this.updater=q||nt}ut.prototype.isReactComponent={},ut.prototype.setState=function(h,O){if(typeof h!="object"&&typeof h!="function"&&h!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,h,O,"setState")},ut.prototype.forceUpdate=function(h){this.updater.enqueueForceUpdate(this,h,"forceUpdate")};function et(){}et.prototype=ut.prototype;function L(h,O,q){this.props=h,this.context=O,this.refs=J,this.updater=q||nt}var W=L.prototype=new et;W.constructor=L,P(W,ut.prototype),W.isPureReactComponent=!0;var rt=Array.isArray;function I(){}var Z={H:null,A:null,T:null,S:null},ot=Object.prototype.hasOwnProperty;function ht(h,O,q){var Y=q.ref;return{$$typeof:c,type:h,key:O,ref:Y!==void 0?Y:null,props:q}}function tl(h,O){return ht(h.type,O,h.props)}function Qt(h){return typeof h=="object"&&h!==null&&h.$$typeof===c}function jt(h){var O={"=":"=0",":":"=2"};return"$"+h.replace(/[=:]/g,function(q){return O[q]})}var wt=/\/+/g;function Ct(h,O){return typeof h=="object"&&h!==null&&h.key!=null?jt(""+h.key):O.toString(36)}function Zt(h){switch(h.status){case"fulfilled":return h.value;case"rejected":throw h.reason;default:switch(typeof h.status=="string"?h.then(I,I):(h.status="pending",h.then(function(O){h.status==="pending"&&(h.status="fulfilled",h.value=O)},function(O){h.status==="pending"&&(h.status="rejected",h.reason=O)})),h.status){case"fulfilled":return h.value;case"rejected":throw h.reason}}throw h}function T(h,O,q,Y,$){var k=typeof h;(k==="undefined"||k==="boolean")&&(h=null);var F=!1;if(h===null)F=!0;else switch(k){case"bigint":case"string":case"number":F=!0;break;case"object":switch(h.$$typeof){case c:case r:F=!0;break;case j:return F=h._init,T(F(h._payload),O,q,Y,$)}}if(F)return $=$(h),F=Y===""?"."+Ct(h,0):Y,rt($)?(q="",F!=null&&(q=F.replace(wt,"$&/")+"/"),T($,O,q,"",function(ql){return ql})):$!=null&&(Qt($)&&($=tl($,q+($.key==null||h&&h.key===$.key?"":(""+$.key).replace(wt,"$&/")+"/")+F)),O.push($)),1;F=0;var Bt=Y===""?".":Y+":";if(rt(h))for(var _t=0;_t>>1,vt=T[mt];if(0>>1;mtz(q,w))Yz($,q)?(T[mt]=$,T[Y]=w,mt=Y):(T[mt]=q,T[O]=w,mt=O);else if(Yz($,w))T[mt]=$,T[Y]=w,mt=Y;else break t}}return B}function z(T,B){var w=T.sortIndex-B.sortIndex;return w!==0?w:T.id-B.id}if(c.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var E=performance;c.unstable_now=function(){return E.now()}}else{var _=Date,H=_.now();c.unstable_now=function(){return _.now()-H}}var M=[],v=[],j=1,D=null,R=3,Q=!1,nt=!1,P=!1,J=!1,ut=typeof setTimeout=="function"?setTimeout:null,et=typeof clearTimeout=="function"?clearTimeout:null,L=typeof setImmediate<"u"?setImmediate:null;function W(T){for(var B=y(v);B!==null;){if(B.callback===null)s(v);else if(B.startTime<=T)s(v),B.sortIndex=B.expirationTime,r(M,B);else break;B=y(v)}}function rt(T){if(P=!1,W(T),!nt)if(y(M)!==null)nt=!0,I||(I=!0,jt());else{var B=y(v);B!==null&&Zt(rt,B.startTime-T)}}var I=!1,Z=-1,ot=5,ht=-1;function tl(){return J?!0:!(c.unstable_now()-htT&&tl());){var mt=D.callback;if(typeof mt=="function"){D.callback=null,R=D.priorityLevel;var vt=mt(D.expirationTime<=T);if(T=c.unstable_now(),typeof vt=="function"){D.callback=vt,W(T),B=!0;break l}D===y(M)&&s(M),W(T)}else s(M);D=y(M)}if(D!==null)B=!0;else{var h=y(v);h!==null&&Zt(rt,h.startTime-T),B=!1}}break t}finally{D=null,R=w,Q=!1}B=void 0}}finally{B?jt():I=!1}}}var jt;if(typeof L=="function")jt=function(){L(Qt)};else if(typeof MessageChannel<"u"){var wt=new MessageChannel,Ct=wt.port2;wt.port1.onmessage=Qt,jt=function(){Ct.postMessage(null)}}else jt=function(){ut(Qt,0)};function Zt(T,B){Z=ut(function(){T(c.unstable_now())},B)}c.unstable_IdlePriority=5,c.unstable_ImmediatePriority=1,c.unstable_LowPriority=4,c.unstable_NormalPriority=3,c.unstable_Profiling=null,c.unstable_UserBlockingPriority=2,c.unstable_cancelCallback=function(T){T.callback=null},c.unstable_forceFrameRate=function(T){0>T||125mt?(T.sortIndex=w,r(v,T),y(M)===null&&T===y(v)&&(P?(et(Z),Z=-1):P=!0,Zt(rt,w-mt))):(T.sortIndex=vt,r(M,T),nt||Q||(nt=!0,I||(I=!0,jt()))),T},c.unstable_shouldYield=tl,c.unstable_wrapCallback=function(T){var B=R;return function(){var w=R;R=B;try{return T.apply(this,arguments)}finally{R=w}}}})(Rf)),Rf}var Wd;function wh(){return Wd||(Wd=1,Hf.exports=Vh()),Hf.exports}var Bf={exports:{}},Pt={};var Fd;function kh(){if(Fd)return Pt;Fd=1;var c=wf();function r(M){var v="https://react.dev/errors/"+M;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(c)}catch(r){console.error(r)}}return c(),Bf.exports=kh(),Bf.exports}var Pd;function Jh(){if(Pd)return qn;Pd=1;var c=wh(),r=wf(),y=Kh();function s(t){var l="https://react.dev/errors/"+t;if(1vt||(t.current=mt[vt],mt[vt]=null,vt--)}function q(t,l){vt++,mt[vt]=t.current,t.current=l}var Y=h(null),$=h(null),k=h(null),F=h(null);function Bt(t,l){switch(q(k,l),q($,t),q(Y,null),l.nodeType){case 9:case 11:t=(t=l.documentElement)&&(t=t.namespaceURI)?hd(t):0;break;default:if(t=l.tagName,l=l.namespaceURI)l=hd(l),t=vd(l,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}O(Y),q(Y,t)}function _t(){O(Y),O($),O(k)}function ql(t){t.memoizedState!==null&&q(F,t);var l=Y.current,e=vd(l,t.type);l!==e&&(q($,t),q(Y,e))}function Yl(t){$.current===t&&(O(Y),O($)),F.current===t&&(O(F),Cn._currentValue=w)}var ce,Ln;function bl(t){if(ce===void 0)try{throw Error()}catch(e){var l=e.stack.trim().match(/\n( *(at )?)/);ce=l&&l[1]||"",Ln=-1)":-1n||d[a]!==b[n]){var A=` +`+d[a].replace(" at new "," at ");return t.displayName&&A.includes("")&&(A=A.replace("",t.displayName)),A}while(1<=a&&0<=n);break}}}finally{La=!1,Error.prepareStackTrace=e}return(e=t?t.displayName||t.name:"")?bl(e):""}function Xn(t,l){switch(t.tag){case 26:case 27:case 5:return bl(t.type);case 16:return bl("Lazy");case 13:return t.child!==l&&l!==null?bl("Suspense Fallback"):bl("Suspense");case 19:return bl("SuspenseList");case 0:case 15:return Xa(t.type,!1);case 11:return Xa(t.type.render,!1);case 1:return Xa(t.type,!0);case 31:return bl("Activity");default:return""}}function Gn(t){try{var l="",e=null;do l+=Xn(t,e),e=t,t=t.return;while(t);return l}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}var Ga=Object.prototype.hasOwnProperty,Qa=c.unstable_scheduleCallback,Fe=c.unstable_cancelCallback,Ie=c.unstable_shouldYield,Za=c.unstable_requestPaint,ll=c.unstable_now,oi=c.unstable_getCurrentPriorityLevel,Qn=c.unstable_ImmediatePriority,Zn=c.unstable_UserBlockingPriority,Pe=c.unstable_NormalPriority,di=c.unstable_LowPriority,Vn=c.unstable_IdlePriority,mi=c.log,hi=c.unstable_setDisableYieldValue,Ce=null,el=null;function pl(t){if(typeof mi=="function"&&hi(t),el&&typeof el.setStrictMode=="function")try{el.setStrictMode(Ce,t)}catch{}}var al=Math.clz32?Math.clz32:gi,vi=Math.log,yi=Math.LN2;function gi(t){return t>>>=0,t===0?32:31-(vi(t)/yi|0)|0}var ta=256,Ut=262144,wn=4194304;function Ue(t){var l=t&42;if(l!==0)return l;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function kn(t,l,e){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,i=t.pingedLanes;t=t.warmLanes;var f=a&134217727;return f!==0?(a=f&~u,a!==0?n=Ue(a):(i&=f,i!==0?n=Ue(i):e||(e=f&~t,e!==0&&(n=Ue(e))))):(f=a&~u,f!==0?n=Ue(f):i!==0?n=Ue(i):e||(e=a&~t,e!==0&&(n=Ue(e)))),n===0?0:l!==0&&l!==n&&(l&u)===0&&(u=n&-n,e=l&-l,u>=e||u===32&&(e&4194048)!==0)?l:n}function Va(t,l){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&l)===0}function Mm(t,l){switch(t){case 1:case 2:case 4:case 8:case 64:return l+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return l+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Jf(){var t=wn;return wn<<=1,(wn&62914560)===0&&(wn=4194304),t}function Si(t){for(var l=[],e=0;31>e;e++)l.push(t);return l}function wa(t,l){t.pendingLanes|=l,l!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function jm(t,l,e,a,n,u){var i=t.pendingLanes;t.pendingLanes=e,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=e,t.entangledLanes&=e,t.errorRecoveryDisabledLanes&=e,t.shellSuspendCounter=0;var f=t.entanglements,d=t.expirationTimes,b=t.hiddenUpdates;for(e=i&~e;0"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var Rm=/[\n"\\]/g;function El(t){return t.replace(Rm,function(l){return"\\"+l.charCodeAt(0).toString(16)+" "})}function Ti(t,l,e,a,n,u,i,f){t.name="",i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?t.type=i:t.removeAttribute("type"),l!=null?i==="number"?(l===0&&t.value===""||t.value!=l)&&(t.value=""+xl(l)):t.value!==""+xl(l)&&(t.value=""+xl(l)):i!=="submit"&&i!=="reset"||t.removeAttribute("value"),l!=null?_i(t,i,xl(l)):e!=null?_i(t,i,xl(e)):a!=null&&t.removeAttribute("value"),n==null&&u!=null&&(t.defaultChecked=!!u),n!=null&&(t.checked=n&&typeof n!="function"&&typeof n!="symbol"),f!=null&&typeof f!="function"&&typeof f!="symbol"&&typeof f!="boolean"?t.name=""+xl(f):t.removeAttribute("name")}function cs(t,l,e,a,n,u,i,f){if(u!=null&&typeof u!="function"&&typeof u!="symbol"&&typeof u!="boolean"&&(t.type=u),l!=null||e!=null){if(!(u!=="submit"&&u!=="reset"||l!=null)){zi(t);return}e=e!=null?""+xl(e):"",l=l!=null?""+xl(l):e,f||l===t.value||(t.value=l),t.defaultValue=l}a=a??n,a=typeof a!="function"&&typeof a!="symbol"&&!!a,t.checked=f?t.checked:!!a,t.defaultChecked=!!a,i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(t.name=i),zi(t)}function _i(t,l,e){l==="number"&&$n(t.ownerDocument)===t||t.defaultValue===""+e||(t.defaultValue=""+e)}function ia(t,l,e,a){if(t=t.options,l){l={};for(var n=0;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Oi=!1;if(wl)try{var $a={};Object.defineProperty($a,"passive",{get:function(){Oi=!0}}),window.addEventListener("test",$a,$a),window.removeEventListener("test",$a,$a)}catch{Oi=!1}var se=null,Di=null,Fn=null;function hs(){if(Fn)return Fn;var t,l=Di,e=l.length,a,n="value"in se?se.value:se.textContent,u=n.length;for(t=0;t=Ia),ps=" ",xs=!1;function Es(t,l){switch(t){case"keyup":return s0.indexOf(l.keyCode)!==-1;case"keydown":return l.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function zs(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var ra=!1;function o0(t,l){switch(t){case"compositionend":return zs(l);case"keypress":return l.which!==32?null:(xs=!0,ps);case"textInput":return t=l.data,t===ps&&xs?null:t;default:return null}}function d0(t,l){if(ra)return t==="compositionend"||!Bi&&Es(t,l)?(t=hs(),Fn=Di=se=null,ra=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(l.ctrlKey||l.altKey||l.metaKey)||l.ctrlKey&&l.altKey){if(l.char&&1=l)return{node:e,offset:l-t};t=a}t:{for(;e;){if(e.nextSibling){e=e.nextSibling;break t}e=e.parentNode}e=void 0}e=Ds(e)}}function Us(t,l){return t&&l?t===l?!0:t&&t.nodeType===3?!1:l&&l.nodeType===3?Us(t,l.parentNode):"contains"in t?t.contains(l):t.compareDocumentPosition?!!(t.compareDocumentPosition(l)&16):!1:!1}function Hs(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var l=$n(t.document);l instanceof t.HTMLIFrameElement;){try{var e=typeof l.contentWindow.location.href=="string"}catch{e=!1}if(e)t=l.contentWindow;else break;l=$n(t.document)}return l}function Li(t){var l=t&&t.nodeName&&t.nodeName.toLowerCase();return l&&(l==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||l==="textarea"||t.contentEditable==="true")}var p0=wl&&"documentMode"in document&&11>=document.documentMode,oa=null,Xi=null,en=null,Gi=!1;function Rs(t,l,e){var a=e.window===e?e.document:e.nodeType===9?e:e.ownerDocument;Gi||oa==null||oa!==$n(a)||(a=oa,"selectionStart"in a&&Li(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),en&&ln(en,a)||(en=a,a=Vu(Xi,"onSelect"),0>=i,n-=i,Ll=1<<32-al(l)+n|e<at?(st=G,G=null):st=G.sibling;var gt=p(g,G,S[at],N);if(gt===null){G===null&&(G=st);break}t&&G&>.alternate===null&&l(g,G),m=u(gt,m,at),yt===null?V=gt:yt.sibling=gt,yt=gt,G=st}if(at===S.length)return e(g,G),dt&&Kl(g,at),V;if(G===null){for(;atat?(st=G,G=null):st=G.sibling;var De=p(g,G,gt.value,N);if(De===null){G===null&&(G=st);break}t&&G&&De.alternate===null&&l(g,G),m=u(De,m,at),yt===null?V=De:yt.sibling=De,yt=De,G=st}if(gt.done)return e(g,G),dt&&Kl(g,at),V;if(G===null){for(;!gt.done;at++,gt=S.next())gt=C(g,gt.value,N),gt!==null&&(m=u(gt,m,at),yt===null?V=gt:yt.sibling=gt,yt=gt);return dt&&Kl(g,at),V}for(G=a(G);!gt.done;at++,gt=S.next())gt=x(G,g,at,gt.value,N),gt!==null&&(t&>.alternate!==null&&G.delete(gt.key===null?at:gt.key),m=u(gt,m,at),yt===null?V=gt:yt.sibling=gt,yt=gt);return t&&G.forEach(function(Xh){return l(g,Xh)}),dt&&Kl(g,at),V}function zt(g,m,S,N){if(typeof S=="object"&&S!==null&&S.type===P&&S.key===null&&(S=S.props.children),typeof S=="object"&&S!==null){switch(S.$$typeof){case Q:t:{for(var V=S.key;m!==null;){if(m.key===V){if(V=S.type,V===P){if(m.tag===7){e(g,m.sibling),N=n(m,S.props.children),N.return=g,g=N;break t}}else if(m.elementType===V||typeof V=="object"&&V!==null&&V.$$typeof===ot&&Ve(V)===m.type){e(g,m.sibling),N=n(m,S.props),sn(N,S),N.return=g,g=N;break t}e(g,m);break}else l(g,m);m=m.sibling}S.type===P?(N=Le(S.props.children,g.mode,N,S.key),N.return=g,g=N):(N=cu(S.type,S.key,S.props,null,g.mode,N),sn(N,S),N.return=g,g=N)}return i(g);case nt:t:{for(V=S.key;m!==null;){if(m.key===V)if(m.tag===4&&m.stateNode.containerInfo===S.containerInfo&&m.stateNode.implementation===S.implementation){e(g,m.sibling),N=n(m,S.children||[]),N.return=g,g=N;break t}else{e(g,m);break}else l(g,m);m=m.sibling}N=Ji(S,g.mode,N),N.return=g,g=N}return i(g);case ot:return S=Ve(S),zt(g,m,S,N)}if(Zt(S))return X(g,m,S,N);if(jt(S)){if(V=jt(S),typeof V!="function")throw Error(s(150));return S=V.call(S),K(g,m,S,N)}if(typeof S.then=="function")return zt(g,m,hu(S),N);if(S.$$typeof===L)return zt(g,m,ru(g,S),N);vu(g,S)}return typeof S=="string"&&S!==""||typeof S=="number"||typeof S=="bigint"?(S=""+S,m!==null&&m.tag===6?(e(g,m.sibling),N=n(m,S),N.return=g,g=N):(e(g,m),N=Ki(S,g.mode,N),N.return=g,g=N),i(g)):e(g,m)}return function(g,m,S,N){try{fn=0;var V=zt(g,m,S,N);return Ea=null,V}catch(G){if(G===xa||G===du)throw G;var yt=ml(29,G,null,g.mode);return yt.lanes=N,yt.return=g,yt}}}var ke=nr(!0),ur=nr(!1),he=!1;function ic(t){t.updateQueue={baseState:t.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function cc(t,l){t=t.updateQueue,l.updateQueue===t&&(l.updateQueue={baseState:t.baseState,firstBaseUpdate:t.firstBaseUpdate,lastBaseUpdate:t.lastBaseUpdate,shared:t.shared,callbacks:null})}function ve(t){return{lane:t,tag:0,payload:null,callback:null,next:null}}function ye(t,l,e){var a=t.updateQueue;if(a===null)return null;if(a=a.shared,(St&2)!==0){var n=a.pending;return n===null?l.next=l:(l.next=n.next,n.next=l),a.pending=l,l=iu(t),Qs(t,null,e),l}return uu(t,a,l,e),iu(t)}function rn(t,l,e){if(l=l.updateQueue,l!==null&&(l=l.shared,(e&4194048)!==0)){var a=l.lanes;a&=t.pendingLanes,e|=a,l.lanes=e,Wf(t,e)}}function fc(t,l){var e=t.updateQueue,a=t.alternate;if(a!==null&&(a=a.updateQueue,e===a)){var n=null,u=null;if(e=e.firstBaseUpdate,e!==null){do{var i={lane:e.lane,tag:e.tag,payload:e.payload,callback:null,next:null};u===null?n=u=i:u=u.next=i,e=e.next}while(e!==null);u===null?n=u=l:u=u.next=l}else n=u=l;e={baseState:a.baseState,firstBaseUpdate:n,lastBaseUpdate:u,shared:a.shared,callbacks:a.callbacks},t.updateQueue=e;return}t=e.lastBaseUpdate,t===null?e.firstBaseUpdate=l:t.next=l,e.lastBaseUpdate=l}var sc=!1;function on(){if(sc){var t=pa;if(t!==null)throw t}}function dn(t,l,e,a){sc=!1;var n=t.updateQueue;he=!1;var u=n.firstBaseUpdate,i=n.lastBaseUpdate,f=n.shared.pending;if(f!==null){n.shared.pending=null;var d=f,b=d.next;d.next=null,i===null?u=b:i.next=b,i=d;var A=t.alternate;A!==null&&(A=A.updateQueue,f=A.lastBaseUpdate,f!==i&&(f===null?A.firstBaseUpdate=b:f.next=b,A.lastBaseUpdate=d))}if(u!==null){var C=n.baseState;i=0,A=b=d=null,f=u;do{var p=f.lane&-536870913,x=p!==f.lane;if(x?(ft&p)===p:(a&p)===p){p!==0&&p===ba&&(sc=!0),A!==null&&(A=A.next={lane:0,tag:f.tag,payload:f.payload,callback:null,next:null});t:{var X=t,K=f;p=l;var zt=e;switch(K.tag){case 1:if(X=K.payload,typeof X=="function"){C=X.call(zt,C,p);break t}C=X;break t;case 3:X.flags=X.flags&-65537|128;case 0:if(X=K.payload,p=typeof X=="function"?X.call(zt,C,p):X,p==null)break t;C=D({},C,p);break t;case 2:he=!0}}p=f.callback,p!==null&&(t.flags|=64,x&&(t.flags|=8192),x=n.callbacks,x===null?n.callbacks=[p]:x.push(p))}else x={lane:p,tag:f.tag,payload:f.payload,callback:f.callback,next:null},A===null?(b=A=x,d=C):A=A.next=x,i|=p;if(f=f.next,f===null){if(f=n.shared.pending,f===null)break;x=f,f=x.next,x.next=null,n.lastBaseUpdate=x,n.shared.pending=null}}while(!0);A===null&&(d=C),n.baseState=d,n.firstBaseUpdate=b,n.lastBaseUpdate=A,u===null&&(n.shared.lanes=0),xe|=i,t.lanes=i,t.memoizedState=C}}function ir(t,l){if(typeof t!="function")throw Error(s(191,t));t.call(l)}function cr(t,l){var e=t.callbacks;if(e!==null)for(t.callbacks=null,t=0;tu?u:8;var i=T.T,f={};T.T=f,Mc(t,!1,l,e);try{var d=n(),b=T.S;if(b!==null&&b(f,d),d!==null&&typeof d=="object"&&typeof d.then=="function"){var A=j0(d,a);vn(t,l,A,Sl(t))}else vn(t,l,a,Sl(t))}catch(C){vn(t,l,{then:function(){},status:"rejected",reason:C},Sl())}finally{B.p=u,i!==null&&f.types!==null&&(i.types=f.types),T.T=i}}function R0(){}function Ac(t,l,e,a){if(t.tag!==5)throw Error(s(476));var n=Lr(t).queue;Yr(t,n,l,w,e===null?R0:function(){return Xr(t),e(a)})}function Lr(t){var l=t.memoizedState;if(l!==null)return l;l={memoizedState:w,baseState:w,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Fl,lastRenderedState:w},next:null};var e={};return l.next={memoizedState:e,baseState:e,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Fl,lastRenderedState:e},next:null},t.memoizedState=l,t=t.alternate,t!==null&&(t.memoizedState=l),l}function Xr(t){var l=Lr(t);l.next===null&&(l=t.alternate.memoizedState),vn(t,l.next.queue,{},Sl())}function Nc(){return Wt(Cn)}function Gr(){return Rt().memoizedState}function Qr(){return Rt().memoizedState}function B0(t){for(var l=t.return;l!==null;){switch(l.tag){case 24:case 3:var e=Sl();t=ve(e);var a=ye(l,t,e);a!==null&&(ol(a,l,e),rn(a,l,e)),l={cache:ec()},t.payload=l;return}l=l.return}}function q0(t,l,e){var a=Sl();e={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null},_u(t)?Vr(l,e):(e=wi(t,l,e,a),e!==null&&(ol(e,t,a),wr(e,l,a)))}function Zr(t,l,e){var a=Sl();vn(t,l,e,a)}function vn(t,l,e,a){var n={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null};if(_u(t))Vr(l,n);else{var u=t.alternate;if(t.lanes===0&&(u===null||u.lanes===0)&&(u=l.lastRenderedReducer,u!==null))try{var i=l.lastRenderedState,f=u(i,e);if(n.hasEagerState=!0,n.eagerState=f,dl(f,i))return uu(t,l,n,0),Tt===null&&nu(),!1}catch{}if(e=wi(t,l,n,a),e!==null)return ol(e,t,a),wr(e,l,a),!0}return!1}function Mc(t,l,e,a){if(a={lane:2,revertLane:cf(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},_u(t)){if(l)throw Error(s(479))}else l=wi(t,e,a,2),l!==null&&ol(l,t,2)}function _u(t){var l=t.alternate;return t===lt||l!==null&&l===lt}function Vr(t,l){Ta=Su=!0;var e=t.pending;e===null?l.next=l:(l.next=e.next,e.next=l),t.pending=l}function wr(t,l,e){if((e&4194048)!==0){var a=l.lanes;a&=t.pendingLanes,e|=a,l.lanes=e,Wf(t,e)}}var yn={readContext:Wt,use:xu,useCallback:Ot,useContext:Ot,useEffect:Ot,useImperativeHandle:Ot,useLayoutEffect:Ot,useInsertionEffect:Ot,useMemo:Ot,useReducer:Ot,useRef:Ot,useState:Ot,useDebugValue:Ot,useDeferredValue:Ot,useTransition:Ot,useSyncExternalStore:Ot,useId:Ot,useHostTransitionStatus:Ot,useFormState:Ot,useActionState:Ot,useOptimistic:Ot,useMemoCache:Ot,useCacheRefresh:Ot};yn.useEffectEvent=Ot;var kr={readContext:Wt,use:xu,useCallback:function(t,l){return nl().memoizedState=[t,l===void 0?null:l],t},useContext:Wt,useEffect:jr,useImperativeHandle:function(t,l,e){e=e!=null?e.concat([t]):null,zu(4194308,4,Ur.bind(null,l,t),e)},useLayoutEffect:function(t,l){return zu(4194308,4,t,l)},useInsertionEffect:function(t,l){zu(4,2,t,l)},useMemo:function(t,l){var e=nl();l=l===void 0?null:l;var a=t();if(Ke){pl(!0);try{t()}finally{pl(!1)}}return e.memoizedState=[a,l],a},useReducer:function(t,l,e){var a=nl();if(e!==void 0){var n=e(l);if(Ke){pl(!0);try{e(l)}finally{pl(!1)}}}else n=l;return a.memoizedState=a.baseState=n,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:n},a.queue=t,t=t.dispatch=q0.bind(null,lt,t),[a.memoizedState,t]},useRef:function(t){var l=nl();return t={current:t},l.memoizedState=t},useState:function(t){t=xc(t);var l=t.queue,e=Zr.bind(null,lt,l);return l.dispatch=e,[t.memoizedState,e]},useDebugValue:Tc,useDeferredValue:function(t,l){var e=nl();return _c(e,t,l)},useTransition:function(){var t=xc(!1);return t=Yr.bind(null,lt,t.queue,!0,!1),nl().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,l,e){var a=lt,n=nl();if(dt){if(e===void 0)throw Error(s(407));e=e()}else{if(e=l(),Tt===null)throw Error(s(349));(ft&127)!==0||mr(a,l,e)}n.memoizedState=e;var u={value:e,getSnapshot:l};return n.queue=u,jr(vr.bind(null,a,u,t),[t]),a.flags|=2048,Aa(9,{destroy:void 0},hr.bind(null,a,u,e,l),null),e},useId:function(){var t=nl(),l=Tt.identifierPrefix;if(dt){var e=Xl,a=Ll;e=(a&~(1<<32-al(a)-1)).toString(32)+e,l="_"+l+"R_"+e,e=bu++,0<\/script>",u=u.removeChild(u.firstChild);break;case"select":u=typeof a.is=="string"?i.createElement("select",{is:a.is}):i.createElement("select"),a.multiple?u.multiple=!0:a.size&&(u.size=a.size);break;default:u=typeof a.is=="string"?i.createElement(n,{is:a.is}):i.createElement(n)}}u[Jt]=l,u[ul]=a;t:for(i=l.child;i!==null;){if(i.tag===5||i.tag===6)u.appendChild(i.stateNode);else if(i.tag!==4&&i.tag!==27&&i.child!==null){i.child.return=i,i=i.child;continue}if(i===l)break t;for(;i.sibling===null;){if(i.return===null||i.return===l)break t;i=i.return}i.sibling.return=i.return,i=i.sibling}l.stateNode=u;t:switch(It(u,n,a),n){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break t;case"img":a=!0;break t;default:a=!1}a&&Pl(l)}}return Nt(l),Qc(l,l.type,t===null?null:t.memoizedProps,l.pendingProps,e),null;case 6:if(t&&l.stateNode!=null)t.memoizedProps!==a&&Pl(l);else{if(typeof a!="string"&&l.stateNode===null)throw Error(s(166));if(t=k.current,ga(l)){if(t=l.stateNode,e=l.memoizedProps,a=null,n=$t,n!==null)switch(n.tag){case 27:case 5:a=n.memoizedProps}t[Jt]=l,t=!!(t.nodeValue===e||a!==null&&a.suppressHydrationWarning===!0||dd(t.nodeValue,e)),t||de(l,!0)}else t=wu(t).createTextNode(a),t[Jt]=l,l.stateNode=t}return Nt(l),null;case 31:if(e=l.memoizedState,t===null||t.memoizedState!==null){if(a=ga(l),e!==null){if(t===null){if(!a)throw Error(s(318));if(t=l.memoizedState,t=t!==null?t.dehydrated:null,!t)throw Error(s(557));t[Jt]=l}else Xe(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;Nt(l),t=!1}else e=Ii(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=e),t=!0;if(!t)return l.flags&256?(vl(l),l):(vl(l),null);if((l.flags&128)!==0)throw Error(s(558))}return Nt(l),null;case 13:if(a=l.memoizedState,t===null||t.memoizedState!==null&&t.memoizedState.dehydrated!==null){if(n=ga(l),a!==null&&a.dehydrated!==null){if(t===null){if(!n)throw Error(s(318));if(n=l.memoizedState,n=n!==null?n.dehydrated:null,!n)throw Error(s(317));n[Jt]=l}else Xe(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;Nt(l),n=!1}else n=Ii(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=n),n=!0;if(!n)return l.flags&256?(vl(l),l):(vl(l),null)}return vl(l),(l.flags&128)!==0?(l.lanes=e,l):(e=a!==null,t=t!==null&&t.memoizedState!==null,e&&(a=l.child,n=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(n=a.alternate.memoizedState.cachePool.pool),u=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(u=a.memoizedState.cachePool.pool),u!==n&&(a.flags|=2048)),e!==t&&e&&(l.child.flags|=8192),Ou(l,l.updateQueue),Nt(l),null);case 4:return _t(),t===null&&of(l.stateNode.containerInfo),Nt(l),null;case 10:return $l(l.type),Nt(l),null;case 19:if(O(Ht),a=l.memoizedState,a===null)return Nt(l),null;if(n=(l.flags&128)!==0,u=a.rendering,u===null)if(n)Sn(a,!1);else{if(Dt!==0||t!==null&&(t.flags&128)!==0)for(t=l.child;t!==null;){if(u=gu(t),u!==null){for(l.flags|=128,Sn(a,!1),t=u.updateQueue,l.updateQueue=t,Ou(l,t),l.subtreeFlags=0,t=e,e=l.child;e!==null;)Zs(e,t),e=e.sibling;return q(Ht,Ht.current&1|2),dt&&Kl(l,a.treeForkCount),l.child}t=t.sibling}a.tail!==null&&ll()>Ru&&(l.flags|=128,n=!0,Sn(a,!1),l.lanes=4194304)}else{if(!n)if(t=gu(u),t!==null){if(l.flags|=128,n=!0,t=t.updateQueue,l.updateQueue=t,Ou(l,t),Sn(a,!0),a.tail===null&&a.tailMode==="hidden"&&!u.alternate&&!dt)return Nt(l),null}else 2*ll()-a.renderingStartTime>Ru&&e!==536870912&&(l.flags|=128,n=!0,Sn(a,!1),l.lanes=4194304);a.isBackwards?(u.sibling=l.child,l.child=u):(t=a.last,t!==null?t.sibling=u:l.child=u,a.last=u)}return a.tail!==null?(t=a.tail,a.rendering=t,a.tail=t.sibling,a.renderingStartTime=ll(),t.sibling=null,e=Ht.current,q(Ht,n?e&1|2:e&1),dt&&Kl(l,a.treeForkCount),t):(Nt(l),null);case 22:case 23:return vl(l),oc(),a=l.memoizedState!==null,t!==null?t.memoizedState!==null!==a&&(l.flags|=8192):a&&(l.flags|=8192),a?(e&536870912)!==0&&(l.flags&128)===0&&(Nt(l),l.subtreeFlags&6&&(l.flags|=8192)):Nt(l),e=l.updateQueue,e!==null&&Ou(l,e.retryQueue),e=null,t!==null&&t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),a=null,l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(a=l.memoizedState.cachePool.pool),a!==e&&(l.flags|=2048),t!==null&&O(Ze),null;case 24:return e=null,t!==null&&(e=t.memoizedState.cache),l.memoizedState.cache!==e&&(l.flags|=2048),$l(qt),Nt(l),null;case 25:return null;case 30:return null}throw Error(s(156,l.tag))}function Q0(t,l){switch(Wi(l),l.tag){case 1:return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 3:return $l(qt),_t(),t=l.flags,(t&65536)!==0&&(t&128)===0?(l.flags=t&-65537|128,l):null;case 26:case 27:case 5:return Yl(l),null;case 31:if(l.memoizedState!==null){if(vl(l),l.alternate===null)throw Error(s(340));Xe()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 13:if(vl(l),t=l.memoizedState,t!==null&&t.dehydrated!==null){if(l.alternate===null)throw Error(s(340));Xe()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 19:return O(Ht),null;case 4:return _t(),null;case 10:return $l(l.type),null;case 22:case 23:return vl(l),oc(),t!==null&&O(Ze),t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 24:return $l(qt),null;case 25:return null;default:return null}}function go(t,l){switch(Wi(l),l.tag){case 3:$l(qt),_t();break;case 26:case 27:case 5:Yl(l);break;case 4:_t();break;case 31:l.memoizedState!==null&&vl(l);break;case 13:vl(l);break;case 19:O(Ht);break;case 10:$l(l.type);break;case 22:case 23:vl(l),oc(),t!==null&&O(Ze);break;case 24:$l(qt)}}function bn(t,l){try{var e=l.updateQueue,a=e!==null?e.lastEffect:null;if(a!==null){var n=a.next;e=n;do{if((e.tag&t)===t){a=void 0;var u=e.create,i=e.inst;a=u(),i.destroy=a}e=e.next}while(e!==n)}}catch(f){pt(l,l.return,f)}}function be(t,l,e){try{var a=l.updateQueue,n=a!==null?a.lastEffect:null;if(n!==null){var u=n.next;a=u;do{if((a.tag&t)===t){var i=a.inst,f=i.destroy;if(f!==void 0){i.destroy=void 0,n=l;var d=e,b=f;try{b()}catch(A){pt(n,d,A)}}}a=a.next}while(a!==u)}}catch(A){pt(l,l.return,A)}}function So(t){var l=t.updateQueue;if(l!==null){var e=t.stateNode;try{cr(l,e)}catch(a){pt(t,t.return,a)}}}function bo(t,l,e){e.props=Je(t.type,t.memoizedProps),e.state=t.memoizedState;try{e.componentWillUnmount()}catch(a){pt(t,l,a)}}function pn(t,l){try{var e=t.ref;if(e!==null){switch(t.tag){case 26:case 27:case 5:var a=t.stateNode;break;case 30:a=t.stateNode;break;default:a=t.stateNode}typeof e=="function"?t.refCleanup=e(a):e.current=a}}catch(n){pt(t,l,n)}}function Gl(t,l){var e=t.ref,a=t.refCleanup;if(e!==null)if(typeof a=="function")try{a()}catch(n){pt(t,l,n)}finally{t.refCleanup=null,t=t.alternate,t!=null&&(t.refCleanup=null)}else if(typeof e=="function")try{e(null)}catch(n){pt(t,l,n)}else e.current=null}function po(t){var l=t.type,e=t.memoizedProps,a=t.stateNode;try{t:switch(l){case"button":case"input":case"select":case"textarea":e.autoFocus&&a.focus();break t;case"img":e.src?a.src=e.src:e.srcSet&&(a.srcset=e.srcSet)}}catch(n){pt(t,t.return,n)}}function Zc(t,l,e){try{var a=t.stateNode;rh(a,t.type,e,l),a[ul]=l}catch(n){pt(t,t.return,n)}}function xo(t){return t.tag===5||t.tag===3||t.tag===26||t.tag===27&&Ae(t.type)||t.tag===4}function Vc(t){t:for(;;){for(;t.sibling===null;){if(t.return===null||xo(t.return))return null;t=t.return}for(t.sibling.return=t.return,t=t.sibling;t.tag!==5&&t.tag!==6&&t.tag!==18;){if(t.tag===27&&Ae(t.type)||t.flags&2||t.child===null||t.tag===4)continue t;t.child.return=t,t=t.child}if(!(t.flags&2))return t.stateNode}}function wc(t,l,e){var a=t.tag;if(a===5||a===6)t=t.stateNode,l?(e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e).insertBefore(t,l):(l=e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e,l.appendChild(t),e=e._reactRootContainer,e!=null||l.onclick!==null||(l.onclick=Vl));else if(a!==4&&(a===27&&Ae(t.type)&&(e=t.stateNode,l=null),t=t.child,t!==null))for(wc(t,l,e),t=t.sibling;t!==null;)wc(t,l,e),t=t.sibling}function Du(t,l,e){var a=t.tag;if(a===5||a===6)t=t.stateNode,l?e.insertBefore(t,l):e.appendChild(t);else if(a!==4&&(a===27&&Ae(t.type)&&(e=t.stateNode),t=t.child,t!==null))for(Du(t,l,e),t=t.sibling;t!==null;)Du(t,l,e),t=t.sibling}function Eo(t){var l=t.stateNode,e=t.memoizedProps;try{for(var a=t.type,n=l.attributes;n.length;)l.removeAttributeNode(n[0]);It(l,a,e),l[Jt]=t,l[ul]=e}catch(u){pt(t,t.return,u)}}var te=!1,Xt=!1,kc=!1,zo=typeof WeakSet=="function"?WeakSet:Set,Kt=null;function Z0(t,l){if(t=t.containerInfo,hf=Iu,t=Hs(t),Li(t)){if("selectionStart"in t)var e={start:t.selectionStart,end:t.selectionEnd};else t:{e=(e=t.ownerDocument)&&e.defaultView||window;var a=e.getSelection&&e.getSelection();if(a&&a.rangeCount!==0){e=a.anchorNode;var n=a.anchorOffset,u=a.focusNode;a=a.focusOffset;try{e.nodeType,u.nodeType}catch{e=null;break t}var i=0,f=-1,d=-1,b=0,A=0,C=t,p=null;l:for(;;){for(var x;C!==e||n!==0&&C.nodeType!==3||(f=i+n),C!==u||a!==0&&C.nodeType!==3||(d=i+a),C.nodeType===3&&(i+=C.nodeValue.length),(x=C.firstChild)!==null;)p=C,C=x;for(;;){if(C===t)break l;if(p===e&&++b===n&&(f=i),p===u&&++A===a&&(d=i),(x=C.nextSibling)!==null)break;C=p,p=C.parentNode}C=x}e=f===-1||d===-1?null:{start:f,end:d}}else e=null}e=e||{start:0,end:0}}else e=null;for(vf={focusedElem:t,selectionRange:e},Iu=!1,Kt=l;Kt!==null;)if(l=Kt,t=l.child,(l.subtreeFlags&1028)!==0&&t!==null)t.return=l,Kt=t;else for(;Kt!==null;){switch(l=Kt,u=l.alternate,t=l.flags,l.tag){case 0:if((t&4)!==0&&(t=l.updateQueue,t=t!==null?t.events:null,t!==null))for(e=0;e title"))),It(u,a,e),u[Jt]=t,kt(u),a=u;break t;case"link":var i=jd("link","href",n).get(a+(e.href||""));if(i){for(var f=0;fzt&&(i=zt,zt=K,K=i);var g=Cs(f,K),m=Cs(f,zt);if(g&&m&&(x.rangeCount!==1||x.anchorNode!==g.node||x.anchorOffset!==g.offset||x.focusNode!==m.node||x.focusOffset!==m.offset)){var S=C.createRange();S.setStart(g.node,g.offset),x.removeAllRanges(),K>zt?(x.addRange(S),x.extend(m.node,m.offset)):(S.setEnd(m.node,m.offset),x.addRange(S))}}}}for(C=[],x=f;x=x.parentNode;)x.nodeType===1&&C.push({element:x,left:x.scrollLeft,top:x.scrollTop});for(typeof f.focus=="function"&&f.focus(),f=0;fe?32:e,T.T=null,e=Pc,Pc=null;var u=ze,i=ue;if(Vt=0,Da=ze=null,ue=0,(St&6)!==0)throw Error(s(331));var f=St;if(St|=4,Ho(u.current),Do(u,u.current,i,e),St=f,An(0,!1),el&&typeof el.onPostCommitFiberRoot=="function")try{el.onPostCommitFiberRoot(Ce,u)}catch{}return!0}finally{B.p=n,T.T=a,Io(t,l)}}function td(t,l,e){l=Tl(e,l),l=Cc(t.stateNode,l,2),t=ye(t,l,2),t!==null&&(wa(t,2),Ql(t))}function pt(t,l,e){if(t.tag===3)td(t,t,e);else for(;l!==null;){if(l.tag===3){td(l,t,e);break}else if(l.tag===1){var a=l.stateNode;if(typeof l.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(Ee===null||!Ee.has(a))){t=Tl(e,t),e=to(2),a=ye(l,e,2),a!==null&&(lo(e,a,l,t),wa(a,2),Ql(a));break}}l=l.return}}function af(t,l,e){var a=t.pingCache;if(a===null){a=t.pingCache=new k0;var n=new Set;a.set(l,n)}else n=a.get(l),n===void 0&&(n=new Set,a.set(l,n));n.has(e)||($c=!0,n.add(e),t=F0.bind(null,t,l,e),l.then(t,t))}function F0(t,l,e){var a=t.pingCache;a!==null&&a.delete(l),t.pingedLanes|=t.suspendedLanes&e,t.warmLanes&=~e,Tt===t&&(ft&e)===e&&(Dt===4||Dt===3&&(ft&62914560)===ft&&300>ll()-Hu?(St&2)===0&&Ca(t,0):Wc|=e,Oa===ft&&(Oa=0)),Ql(t)}function ld(t,l){l===0&&(l=Jf()),t=Ye(t,l),t!==null&&(wa(t,l),Ql(t))}function I0(t){var l=t.memoizedState,e=0;l!==null&&(e=l.retryLane),ld(t,e)}function P0(t,l){var e=0;switch(t.tag){case 31:case 13:var a=t.stateNode,n=t.memoizedState;n!==null&&(e=n.retryLane);break;case 19:a=t.stateNode;break;case 22:a=t.stateNode._retryCache;break;default:throw Error(s(314))}a!==null&&a.delete(l),ld(t,e)}function th(t,l){return Qa(t,l)}var Gu=null,Ha=null,nf=!1,Qu=!1,uf=!1,_e=0;function Ql(t){t!==Ha&&t.next===null&&(Ha===null?Gu=Ha=t:Ha=Ha.next=t),Qu=!0,nf||(nf=!0,eh())}function An(t,l){if(!uf&&Qu){uf=!0;do for(var e=!1,a=Gu;a!==null;){if(t!==0){var n=a.pendingLanes;if(n===0)var u=0;else{var i=a.suspendedLanes,f=a.pingedLanes;u=(1<<31-al(42|t)+1)-1,u&=n&~(i&~f),u=u&201326741?u&201326741|1:u?u|2:0}u!==0&&(e=!0,ud(a,u))}else u=ft,u=kn(a,a===Tt?u:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(u&3)===0||Va(a,u)||(e=!0,ud(a,u));a=a.next}while(e);uf=!1}}function lh(){ed()}function ed(){Qu=nf=!1;var t=0;_e!==0&&dh()&&(t=_e);for(var l=ll(),e=null,a=Gu;a!==null;){var n=a.next,u=ad(a,l);u===0?(a.next=null,e===null?Gu=n:e.next=n,n===null&&(Ha=e)):(e=a,(t!==0||(u&3)!==0)&&(Qu=!0)),a=n}Vt!==0&&Vt!==5||An(t),_e!==0&&(_e=0)}function ad(t,l){for(var e=t.suspendedLanes,a=t.pingedLanes,n=t.expirationTimes,u=t.pendingLanes&-62914561;0f)break;var A=d.transferSize,C=d.initiatorType;A&&md(C)&&(d=d.responseEnd,i+=A*(d"u"?null:document;function _d(t,l,e){var a=Ra;if(a&&typeof l=="string"&&l){var n=El(l);n='link[rel="'+t+'"][href="'+n+'"]',typeof e=="string"&&(n+='[crossorigin="'+e+'"]'),Td.has(n)||(Td.add(n),t={rel:t,crossOrigin:e,href:l},a.querySelector(n)===null&&(l=a.createElement("link"),It(l,"link",t),kt(l),a.head.appendChild(l)))}}function xh(t){ie.D(t),_d("dns-prefetch",t,null)}function Eh(t,l){ie.C(t,l),_d("preconnect",t,l)}function zh(t,l,e){ie.L(t,l,e);var a=Ra;if(a&&t&&l){var n='link[rel="preload"][as="'+El(l)+'"]';l==="image"&&e&&e.imageSrcSet?(n+='[imagesrcset="'+El(e.imageSrcSet)+'"]',typeof e.imageSizes=="string"&&(n+='[imagesizes="'+El(e.imageSizes)+'"]')):n+='[href="'+El(t)+'"]';var u=n;switch(l){case"style":u=Ba(t);break;case"script":u=qa(t)}Ol.has(u)||(t=D({rel:"preload",href:l==="image"&&e&&e.imageSrcSet?void 0:t,as:l},e),Ol.set(u,t),a.querySelector(n)!==null||l==="style"&&a.querySelector(On(u))||l==="script"&&a.querySelector(Dn(u))||(l=a.createElement("link"),It(l,"link",t),kt(l),a.head.appendChild(l)))}}function Th(t,l){ie.m(t,l);var e=Ra;if(e&&t){var a=l&&typeof l.as=="string"?l.as:"script",n='link[rel="modulepreload"][as="'+El(a)+'"][href="'+El(t)+'"]',u=n;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":u=qa(t)}if(!Ol.has(u)&&(t=D({rel:"modulepreload",href:t},l),Ol.set(u,t),e.querySelector(n)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(e.querySelector(Dn(u)))return}a=e.createElement("link"),It(a,"link",t),kt(a),e.head.appendChild(a)}}}function _h(t,l,e){ie.S(t,l,e);var a=Ra;if(a&&t){var n=na(a).hoistableStyles,u=Ba(t);l=l||"default";var i=n.get(u);if(!i){var f={loading:0,preload:null};if(i=a.querySelector(On(u)))f.loading=5;else{t=D({rel:"stylesheet",href:t,"data-precedence":l},e),(e=Ol.get(u))&&Ef(t,e);var d=i=a.createElement("link");kt(d),It(d,"link",t),d._p=new Promise(function(b,A){d.onload=b,d.onerror=A}),d.addEventListener("load",function(){f.loading|=1}),d.addEventListener("error",function(){f.loading|=2}),f.loading|=4,Ku(i,l,a)}i={type:"stylesheet",instance:i,count:1,state:f},n.set(u,i)}}}function Ah(t,l){ie.X(t,l);var e=Ra;if(e&&t){var a=na(e).hoistableScripts,n=qa(t),u=a.get(n);u||(u=e.querySelector(Dn(n)),u||(t=D({src:t,async:!0},l),(l=Ol.get(n))&&zf(t,l),u=e.createElement("script"),kt(u),It(u,"link",t),e.head.appendChild(u)),u={type:"script",instance:u,count:1,state:null},a.set(n,u))}}function Nh(t,l){ie.M(t,l);var e=Ra;if(e&&t){var a=na(e).hoistableScripts,n=qa(t),u=a.get(n);u||(u=e.querySelector(Dn(n)),u||(t=D({src:t,async:!0,type:"module"},l),(l=Ol.get(n))&&zf(t,l),u=e.createElement("script"),kt(u),It(u,"link",t),e.head.appendChild(u)),u={type:"script",instance:u,count:1,state:null},a.set(n,u))}}function Ad(t,l,e,a){var n=(n=k.current)?ku(n):null;if(!n)throw Error(s(446));switch(t){case"meta":case"title":return null;case"style":return typeof e.precedence=="string"&&typeof e.href=="string"?(l=Ba(e.href),e=na(n).hoistableStyles,a=e.get(l),a||(a={type:"style",instance:null,count:0,state:null},e.set(l,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(e.rel==="stylesheet"&&typeof e.href=="string"&&typeof e.precedence=="string"){t=Ba(e.href);var u=na(n).hoistableStyles,i=u.get(t);if(i||(n=n.ownerDocument||n,i={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},u.set(t,i),(u=n.querySelector(On(t)))&&!u._p&&(i.instance=u,i.state.loading=5),Ol.has(t)||(e={rel:"preload",as:"style",href:e.href,crossOrigin:e.crossOrigin,integrity:e.integrity,media:e.media,hrefLang:e.hrefLang,referrerPolicy:e.referrerPolicy},Ol.set(t,e),u||Mh(n,t,e,i.state))),l&&a===null)throw Error(s(528,""));return i}if(l&&a!==null)throw Error(s(529,""));return null;case"script":return l=e.async,e=e.src,typeof e=="string"&&l&&typeof l!="function"&&typeof l!="symbol"?(l=qa(e),e=na(n).hoistableScripts,a=e.get(l),a||(a={type:"script",instance:null,count:0,state:null},e.set(l,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(s(444,t))}}function Ba(t){return'href="'+El(t)+'"'}function On(t){return'link[rel="stylesheet"]['+t+"]"}function Nd(t){return D({},t,{"data-precedence":t.precedence,precedence:null})}function Mh(t,l,e,a){t.querySelector('link[rel="preload"][as="style"]['+l+"]")?a.loading=1:(l=t.createElement("link"),a.preload=l,l.addEventListener("load",function(){return a.loading|=1}),l.addEventListener("error",function(){return a.loading|=2}),It(l,"link",e),kt(l),t.head.appendChild(l))}function qa(t){return'[src="'+El(t)+'"]'}function Dn(t){return"script[async]"+t}function Md(t,l,e){if(l.count++,l.instance===null)switch(l.type){case"style":var a=t.querySelector('style[data-href~="'+El(e.href)+'"]');if(a)return l.instance=a,kt(a),a;var n=D({},e,{"data-href":e.href,"data-precedence":e.precedence,href:null,precedence:null});return a=(t.ownerDocument||t).createElement("style"),kt(a),It(a,"style",n),Ku(a,e.precedence,t),l.instance=a;case"stylesheet":n=Ba(e.href);var u=t.querySelector(On(n));if(u)return l.state.loading|=4,l.instance=u,kt(u),u;a=Nd(e),(n=Ol.get(n))&&Ef(a,n),u=(t.ownerDocument||t).createElement("link"),kt(u);var i=u;return i._p=new Promise(function(f,d){i.onload=f,i.onerror=d}),It(u,"link",a),l.state.loading|=4,Ku(u,e.precedence,t),l.instance=u;case"script":return u=qa(e.src),(n=t.querySelector(Dn(u)))?(l.instance=n,kt(n),n):(a=e,(n=Ol.get(u))&&(a=D({},e),zf(a,n)),t=t.ownerDocument||t,n=t.createElement("script"),kt(n),It(n,"link",a),t.head.appendChild(n),l.instance=n);case"void":return null;default:throw Error(s(443,l.type))}else l.type==="stylesheet"&&(l.state.loading&4)===0&&(a=l.instance,l.state.loading|=4,Ku(a,e.precedence,t));return l.instance}function Ku(t,l,e){for(var a=e.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),n=a.length?a[a.length-1]:null,u=n,i=0;i title"):null)}function jh(t,l,e){if(e===1||l.itemProp!=null)return!1;switch(t){case"meta":case"title":return!0;case"style":if(typeof l.precedence!="string"||typeof l.href!="string"||l.href==="")break;return!0;case"link":if(typeof l.rel!="string"||typeof l.href!="string"||l.href===""||l.onLoad||l.onError)break;return l.rel==="stylesheet"?(t=l.disabled,typeof l.precedence=="string"&&t==null):!0;case"script":if(l.async&&typeof l.async!="function"&&typeof l.async!="symbol"&&!l.onLoad&&!l.onError&&l.src&&typeof l.src=="string")return!0}return!1}function Dd(t){return!(t.type==="stylesheet"&&(t.state.loading&3)===0)}function Oh(t,l,e,a){if(e.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(e.state.loading&4)===0){if(e.instance===null){var n=Ba(a.href),u=l.querySelector(On(n));if(u){l=u._p,l!==null&&typeof l=="object"&&typeof l.then=="function"&&(t.count++,t=$u.bind(t),l.then(t,t)),e.state.loading|=4,e.instance=u,kt(u);return}u=l.ownerDocument||l,a=Nd(a),(n=Ol.get(n))&&Ef(a,n),u=u.createElement("link"),kt(u);var i=u;i._p=new Promise(function(f,d){i.onload=f,i.onerror=d}),It(u,"link",a),e.instance=u}t.stylesheets===null&&(t.stylesheets=new Map),t.stylesheets.set(e,l),(l=e.state.preload)&&(e.state.loading&3)===0&&(t.count++,e=$u.bind(t),l.addEventListener("load",e),l.addEventListener("error",e))}}var Tf=0;function Dh(t,l){return t.stylesheets&&t.count===0&&Fu(t,t.stylesheets),0Tf?50:800)+l);return t.unsuspend=e,function(){t.unsuspend=null,clearTimeout(a),clearTimeout(n)}}:null}function $u(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Fu(this,this.stylesheets);else if(this.unsuspend){var t=this.unsuspend;this.unsuspend=null,t()}}}var Wu=null;function Fu(t,l){t.stylesheets=null,t.unsuspend!==null&&(t.count++,Wu=new Map,l.forEach(Ch,t),Wu=null,$u.call(t))}function Ch(t,l){if(!(l.state.loading&4)){var e=Wu.get(t);if(e)var a=e.get(null);else{e=new Map,Wu.set(t,e);for(var n=t.querySelectorAll("link[data-precedence],style[data-precedence]"),u=0;u"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(c)}catch(r){console.error(r)}}return c(),Uf.exports=Jh(),Uf.exports}var Wh=$h();const om=2;class Yn extends Error{constructor(r,y){super(y),this.status=r}status}const dm=c=>c instanceof Yn&&c.status===401;class mm extends Error{constructor(r){super("connection lost — check your network",{cause:r}),this.name="NetworkError"}}const Fh=c=>c instanceof mm;async function si(c,r){try{return await fetch(c,r)}catch(y){throw new mm(y)}}async function hm(c){if(!c.ok){let y=`request failed (${c.status})`;try{const s=await c.json();typeof s?.error=="string"&&(y=s.error)}catch{}throw new Yn(c.status,y)}const r=await c.json();if(r.version!==om)throw new Yn(c.status,`this page is out of date (server protocol v${r.version}) — reload`);return r}async function Rl(c,r){const y=await si(c,{credentials:"same-origin",signal:r});return hm(y)}async function ui(c,r){const y=await si(c,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)});return hm(y)}const Dl=c=>new URLSearchParams(c).toString(),Gt={async login(c){const r=await si("/login",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({password:c}).toString()});if(!r.ok)throw new Yn(r.status,r.status===429?"too many attempts — wait a minute":"incorrect password")},repos:c=>Rl("/api/repos",c),setAccent:c=>ui("/api/prefs",{accent:c}).then(r=>r.accent),setSidebarWidth:c=>ui("/api/prefs",{sidebar_width:c}).then(r=>r.sidebar_width),status:c=>Rl(`/api/status?${Dl({repo:c})}`),tree:(c,r)=>Rl(`/api/tree?${Dl({repo:c,path:r})}`),treeSearch:(c,r)=>Rl(`/api/tree/search?${Dl({repo:c,q:r})}`),log:(c,r)=>Rl(`/api/log?${Dl(r?{repo:c,from:r.from,skip:String(r.skip)}:{repo:c})}`),diff:(c,r)=>Rl(`/api/diff?${Dl({repo:c,path:r})}`),file:(c,r)=>Rl(`/api/file?${Dl({repo:c,path:r})}`),commit:(c,r)=>Rl(`/api/commit?${Dl({repo:c,oid:r})}`),commitFiles:(c,r)=>Rl(`/api/commit/files?${Dl({repo:c,oid:r})}`),commitFileDiff:(c,r,y)=>Rl(`/api/commit/file-diff?${Dl({repo:c,oid:r,path:y})}`),browse:c=>Rl(`/api/browse${c?`?${Dl({path:c})}`:""}`),mkdir:(c,r)=>ui("/api/mkdir",{path:c,name:r}).then(y=>y.path),open:c=>ui("/api/repos",{path:c}).then(r=>r.repo),close:async c=>{const r=await si(`/api/repos?${Dl({repo:c})}`,{method:"DELETE",credentials:"same-origin"});if(!r.ok)throw new Yn(r.status,`could not close (${r.status})`)}};function Ih(c,r){const y=new EventSource(`/api/events?${Dl({repo:c})}`);return y.addEventListener("status",s=>{try{const z=JSON.parse(s.data);z.version===om&&r(z)}catch{}}),()=>y.close()}let Bl=[],Ph=1;const Yf=new Set;function Lf(){const c=Bl;Yf.forEach(r=>r(c))}function tv(c){return Yf.add(c),c(Bl),()=>{Yf.delete(c)}}function lm(c){const r=Bl.filter(y=>y.id!==c);r.length!==Bl.length&&(Bl=r,Lf())}function qf(c,r){const y=Bl.findIndex(z=>z.kind===c&&z.message===r);if(y!==-1){const z=Bl[y];return Bl=Bl.map((E,_)=>_===y?{...E,bump:E.bump+1}:E),Lf(),z.id}const s=Ph++;return Bl=[...Bl,{id:s,kind:c,message:r,bump:0}].slice(-4),Lf(),s}const Xf={error:c=>qf("error",c),info:c=>qf("info",c),success:c=>qf("success",c)},ci=[{name:"yellow",color:"#d9a441"},{name:"cyan",color:"#03c4db"},{name:"green",color:"#77c47a"},{name:"magenta",color:"#dc8fd5"},{name:"blue",color:"#87acfd"}],vm="nightcrow.viewer.accent";function fi(c){if(!Number.isFinite(c))return 0;const r=ci.length;return(Math.trunc(c)%r+r)%r}function lv(){try{const c=localStorage.getItem(vm);return c===null?0:fi(Number(c))}catch{return 0}}function em(c){try{localStorage.setItem(vm,String(c))}catch{}}function ev(){const[c,r]=U.useState(lv);U.useLayoutEffect(()=>{document.documentElement.style.setProperty("--color-accent",ci[c].color)},[c]);const y=U.useCallback(()=>{r(z=>{const E=fi(z+1);return em(E),Gt.setAccent(E).catch(()=>{}),E})},[]),s=U.useCallback(z=>{r(E=>{const _=fi(z);return _===E?E:(em(_),_)})},[]);return{accent:ci[c],next:ci[fi(c+1)],cycle:y,adopt:s}}const ym="nightcrow.sidebarWidth",Gf=280,gm=720,Qf=460,Sm=.5;function Zf(c){return Math.min(Math.max(Math.round(c),Gf),gm)}function am(c){let r=gm;try{r=Math.min(r,Math.round(window.innerWidth*Sm))}catch{}return Math.min(Math.max(Math.round(c),Gf),Math.max(r,Gf))}function av(){try{const c=Number(localStorage.getItem(ym));return Number.isFinite(c)&&c>0?Zf(c):Qf}catch{return Qf}}function ii(c){try{localStorage.setItem(ym,String(c))}catch{}}function nv(){const[c,r]=U.useState(av),y=U.useCallback(_=>{const H=am(_);r(H),ii(H)},[]),s=U.useCallback(_=>{const H=am(_);r(H),ii(H),Gt.setSidebarWidth(H).catch(()=>{})},[]),z=U.useCallback(()=>{const _=Zf(Qf);r(_),ii(_),Gt.setSidebarWidth(_).catch(()=>{})},[]),E=U.useCallback(_=>{r(H=>{const M=Zf(_);return M===H?H:(ii(M),M)})},[]);return{width:c,resize:y,commit:s,reset:z,adopt:E}}const uv=5e3,bm=1e3;function iv(c,r){return c===void 0||c<=0?0:c-r}const cv=bm;function fv(c,r,y){const s=iv(r,y);return c===null||Math.abs(s-c)>=cv?s:c}function pm(c,r,y){if(c===void 0)return"cool";const s=Math.max(0,r-c);return s>=y?"cool":spm(s,r,y)!=="cool")}const sv={fresh:"text-accent font-bold",warm:"text-accent",cool:""};function um(c,r,y){const[s,z]=U.useState(()=>Date.now()+y);return U.useEffect(()=>{if(r<=0||!c)return;const E=c.map(M=>M.mtime),_=Date.now()+y;if(z(_),!nm(E,_,r))return;const H=setInterval(()=>{const M=Date.now()+y;z(M),nm(E,M,r)||clearInterval(H)},bm);return()=>clearInterval(H)},[c,r,y]),s}const im=3e3;function rv({authed:c,setAuthed:r,handle:y,adoptAccent:s,adoptSidebarWidth:z,draggingRef:E,accentWrites:_,sidebarWrites:H,resumeTick:M}){const[v,j]=U.useState([]),[D,R]=U.useState(null),[Q,nt]=U.useState(null),[P,J]=U.useState(null),[ut,et]=U.useState(!1);return U.useEffect(()=>{let L=!1,W;const rt=new AbortController,I=()=>{const Z=_.current,ot=H.current;return Gt.repos(rt.signal).then(({repos:ht,hot:tl,accent:Qt,sidebar_width:jt,now_ms:wt})=>{L||(nt(tl),J(Ct=>fv(Ct,wt,Date.now())),_.current===Z&&s(Qt),H.current===ot&&!E.current&&z(jt),r(!0),et(!0),j(ht),R(Ct=>Ct&&ht.some(Zt=>Zt.id===Ct)?Ct:ht[0]?.id??null),L||(W=setTimeout(I,im)))}).catch(ht=>{L||(dm(ht)?(r(!1),et(!1)):Fh(ht)||y(ht),W=setTimeout(I,im))})};return I(),()=>{L=!0,rt.abort(),W&&clearTimeout(W)}},[c,r,y,s,z,M,_,H,E]),{repos:v,setRepos:j,repo:D,setRepo:R,hot:Q,clockSkewMs:P,reposLoaded:ut}}function ov({repo:c,authed:r,resumeTick:y,tab:s,pane:z,setPane:E,handle:_,paneRequestRef:H}){const[M,v]=U.useState(null),j=U.useRef(z);j.current=z;const D=U.useRef(s);return D.current=s,U.useEffect(()=>{v(null)},[c,r]),U.useEffect(()=>{if(!(!c||!r))return Ih(c,v)},[c,r,y]),U.useEffect(()=>{if(!c||!M)return;const R=j.current;if(D.current!=="status"||R.kind!=="diff")return;const Q=R.value.path;if(!M.files.some(ut=>ut.path===Q)){E({kind:"empty"});return}const nt=H.current;let P=!0;const J=()=>{const ut=j.current;return P&&nt===H.current&&ut.kind==="diff"&&ut.value.path===Q};return Gt.diff(c,Q).then(ut=>{J()&&E({kind:"diff",value:ut})}).catch(ut=>{J()&&_(ut)}),()=>{P=!1}},[M,c,_,j,D,H,E]),{status:M,paneRef:j,tabRef:D}}const dv=3,mv=400;function hv({sidebarRef:c,sidebarWidth:r,resizeSidebar:y,commitSidebarWidth:s,resetSidebarWidth:z,bumpSidebarWrites:E}){const _=U.useRef(0),H=U.useRef(0),M=U.useRef(0),v=U.useRef(!1),j=U.useRef(!1),D=U.useRef(0),[R,Q]=U.useState(!1),nt=U.useCallback(et=>{if(et.button!==0||!et.isPrimary)return;const L=c.current?.getBoundingClientRect().left;L!==void 0&&(_.current=L,H.current=et.clientX,M.current=r,v.current=!0,j.current=!1,E(),Q(!0),et.currentTarget.setPointerCapture(et.pointerId),et.preventDefault())},[r,c,E]),P=U.useCallback(et=>{v.current&&(!j.current&&Math.abs(et.clientX-H.current){if(!v.current)return;if(v.current=!1,Q(!1),j.current){s(M.current),D.current=0;return}const et=Date.now();et-D.current{v.current=!1,j.current=!1,D.current=0,Q(!1)},[]);return{draggingSidebar:R,onSidebarDragStart:nt,onSidebarDragMove:P,onSidebarDragEnd:J,onSidebarDragCancel:ut,draggingRef:v}}function vv({repo:c,handle:r,setPane:y,paneRequestRef:s,setCommitDrillDown:z}){const E=U.useCallback(j=>{if(!c)return;const D=s.current+=1;Gt.diff(c,j).then(R=>{D===s.current&&y({kind:"diff",value:R})}).catch(R=>{D===s.current&&r(R)})},[c,r,y,s]),_=U.useCallback(j=>{if(!c)return;const D=s.current+=1;Gt.file(c,j).then(R=>{D===s.current&&y({kind:"file",value:R})}).catch(R=>{D===s.current&&r(R)})},[c,r,y,s]),H=U.useCallback(j=>{if(!c)return;const D=s.current+=1;Gt.commit(c,j).then(R=>{D===s.current&&y({kind:"diff",value:R})}).catch(R=>{D===s.current&&r(R)})},[c,r,y,s]),M=U.useCallback((j,D)=>{if(!c)return;const R=s.current+=1;Gt.commitFileDiff(c,j,D).then(Q=>{R===s.current&&y({kind:"diff",value:Q})}).catch(Q=>{R===s.current&&r(Q)})},[c,r,y,s]),v=U.useCallback(async j=>{if(!c)return;const D=s.current+=1;try{const R=await Gt.commitFiles(c,j.oid);if(D!==s.current)return;if(z({commit:j,...R}),R.files.length===0){y({kind:"empty"});return}const Q=await Gt.commit(c,j.oid);D===s.current&&y({kind:"diff",value:Q})}catch(R){D===s.current&&r(R)}},[c,r,y,s,z]);return{openDiff:E,openFile:_,openCommit:H,openCommitFileDiff:M,openCommitFiles:v}}function yv({repo:c,authed:r,tab:y,filter:s,handle:z}){const[E,_]=U.useState([]),[H,M]=U.useState(!1),[v,j]=U.useState(!1),D=U.useRef(null),R=U.useRef(!1),Q=U.useRef(0),nt=U.useCallback(()=>{Q.current+=1,R.current=!1,_([]),D.current=null,M(!1),j(!1)},[]),[P,J]=U.useState(null),ut=U.useRef(E);ut.current=E;const et=U.useCallback(async()=>{if(!c||R.current)return;R.current=!0;const I=Q.current;try{const Z=D.current,ot=await Gt.log(c,Z===null?void 0:{from:Z,skip:ut.current.length});if(I!==Q.current)return;_(ht=>[...ht,...ot.commits]),D.current=ot.head??null,M(!ot.truncated||ot.head===void 0)}catch(Z){I===Q.current&&(z(Z),j(!0))}finally{I===Q.current&&(R.current=!1)}},[c,z]);U.useEffect(()=>{!c||!r||y!=="log"||E.length===0&&!H&&!v&&et()},[c,r,y,E.length,H,v,et]);const L=E.filter(I=>I.summary.toLowerCase().includes(s.toLowerCase())),W=s!=="",rt=U.useRef(null);return U.useEffect(()=>{const I=rt.current;if(!I)return;const Z=new IntersectionObserver(ot=>{ot.some(ht=>ht.isIntersecting)&&et()},{root:I.closest("ul"),rootMargin:"400px"});return Z.observe(I),()=>Z.disconnect()},[et,H,v,W,P,y,L.length]),{commits:E,logDone:H,logStalled:v,setLogStalled:j,commitDrillDown:P,setCommitDrillDown:J,resetLog:nt,logSentinelRef:rt,visibleCommits:L,logPagingPaused:W}}function gv(){const[c,r]=U.useState(0);return U.useEffect(()=>{const y=()=>{document.visibilityState==="visible"&&r(s=>s+1)};return document.addEventListener("visibilitychange",y),window.addEventListener("online",y),()=>{document.removeEventListener("visibilitychange",y),window.removeEventListener("online",y)}},[]),c}function Sv(c){const[r,y]=U.useState({}),s=U.useCallback(_=>{c!=null&&y(H=>{const M=H[c]??"none",v=typeof _=="function"?_(M):_;return{...H,[c]:v}})},[c]),z=c!=null&&r[c]||"none",E=U.useCallback(_=>{y(({[_]:H,...M})=>M)},[]);return{maximized:z,setMaximized:s,dropMaximized:E}}function bv({repos:c,setRepos:r,setRepo:y,setPane:s,setTab:z,setPickerOpen:E,dropMaximized:_,handle:H}){const M=U.useCallback(j=>{r(D=>D.some(R=>R.id===j.id)?D:[...D,j]),y(j.id),s({kind:"empty"}),z("status"),E(!1)},[r,y,s,z,E]),v=U.useCallback(async j=>{try{await Gt.close(j);const D=c.filter(R=>R.id!==j);r(D),y(R=>R===j?D[0]?.id??null:R),_(j)}catch(D){H(D)}},[c,r,y,_,H]);return{selectOpenedRepo:M,closeRepo:v}}function kf({className:c}){return o.jsx("span",{className:`block overflow-hidden rounded-[20.7%] bg-accent ${c??""}`,children:o.jsx("img",{src:"/crow-mono.svg",alt:"","aria-hidden":"true",className:"h-full w-full"})})}function pv({maximized:c}){return o.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:"h-4 w-4",children:c?o.jsxs(o.Fragment,{children:[o.jsx("path",{d:"M8 3v3a2 2 0 0 1-2 2H3"}),o.jsx("path",{d:"M21 8h-3a2 2 0 0 1-2-2V3"}),o.jsx("path",{d:"M3 16h3a2 2 0 0 1 2 2v3"}),o.jsx("path",{d:"M16 21v-3a2 2 0 0 1 2-2h3"})]}):o.jsxs(o.Fragment,{children:[o.jsx("path",{d:"M8 3H5a2 2 0 0 0-2 2v3"}),o.jsx("path",{d:"M21 8V5a2 2 0 0 0-2-2h-3"}),o.jsx("path",{d:"M3 16v3a2 2 0 0 0 2 2h3"}),o.jsx("path",{d:"M16 21h3a2 2 0 0 0 2-2v-3"})]})})}function xm({open:c}){return o.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:`h-3.5 w-3.5 shrink-0 transition-transform ${c?"rotate-90":""}`,children:o.jsx("path",{d:"m9 18 6-6-6-6"})})}function ri({className:c="h-4 w-4"}){return o.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:`shrink-0 ${c}`,children:[o.jsx("path",{d:"M18 6 6 18"}),o.jsx("path",{d:"m6 6 12 12"})]})}function Em({className:c="h-4 w-4"}){return o.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:`shrink-0 ${c}`,children:[o.jsx("path",{d:"M5 12h14"}),o.jsx("path",{d:"M12 5v14"})]})}function xv(){return o.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:"h-4 w-4",children:[o.jsx("rect",{width:"18",height:"18",x:"3",y:"3",rx:"2"}),o.jsx("path",{d:"M12 3v18"})]})}function Ev(){return o.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:"h-4 w-4",children:[o.jsx("path",{d:"M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"}),o.jsx("circle",{cx:"12",cy:"12",r:"3"})]})}function zv(){return o.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:"h-4 w-4",children:[o.jsx("circle",{cx:"11",cy:"11",r:"8"}),o.jsx("path",{d:"m21 21-4.3-4.3"})]})}function Tv({repos:c,currentId:r,onSelect:y,onCloseProject:s,onOpenPicker:z,className:E=""}){const[_,H]=U.useState(!1),M=U.useRef(null),v=c.find(j=>j.id===r);return U.useEffect(()=>{if(!_)return;const j=D=>{D.key==="Escape"&&(H(!1),M.current?.focus())};return document.addEventListener("keydown",j),()=>document.removeEventListener("keydown",j)},[_]),o.jsxs("div",{className:`relative ${E}`,children:[o.jsxs("button",{ref:M,onClick:()=>H(j=>!j),"aria-haspopup":"menu","aria-expanded":_,title:v?.display_path??"Select a project",className:"flex max-w-[9rem] items-center gap-1 rounded-sm bg-ink-700 py-0.5 pl-2 pr-1 text-ink-50",children:[o.jsx("span",{className:"truncate",children:v?.name??"No project"}),o.jsx(xm,{open:_})]}),_&&o.jsxs(o.Fragment,{children:[o.jsx("div",{className:"fixed inset-0 z-40",onClick:()=>H(!1)}),o.jsxs("div",{role:"menu",className:"absolute left-0 z-50 mt-1 max-h-[70vh] w-56 max-w-[80vw] overflow-y-auto rounded-md border border-ink-700 bg-ink-900 py-1 shadow-lg",children:[c.length===0&&o.jsx("p",{className:"px-3 py-1.5 text-ink-400",children:"No projects open."}),c.map(j=>o.jsxs("div",{className:`flex items-center ${j.id===r?"bg-ink-700 text-ink-50":"text-ink-200"}`,children:[o.jsx("button",{role:"menuitem",onClick:()=>{y(j.id),H(!1)},title:j.display_path,className:"min-w-0 flex-1 truncate py-1.5 pl-3 pr-1 text-left hover:text-accent",children:j.name}),o.jsx("button",{onClick:()=>s(j.id),"aria-label":`close ${j.name}`,title:"Close project",className:"mr-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:text-removed",children:o.jsx(ri,{className:"h-3.5 w-3.5"})})]},j.id)),o.jsx("div",{className:"my-1 border-t border-ink-800"}),o.jsxs("button",{role:"menuitem",onClick:()=>{z(),H(!1)},className:"flex w-full items-center gap-1 px-3 py-1.5 text-left text-ink-400 hover:text-ink-200",children:[o.jsx(Em,{className:"h-3.5 w-3.5"}),"open"]})]})]})]})}function _v({repos:c,repo:r,setRepo:y,setPane:s,closeRepo:z,setPickerOpen:E,accent:_,next:H,cycle:M}){return o.jsxs("header",{className:"flex items-center gap-2 border-b border-ink-700 bg-ink-900 px-[12.8px] py-[8.8px]",children:[o.jsx(kf,{className:"h-[22px] w-[22px] shrink-0"}),o.jsx("span",{className:"text-[16px] font-medium tracking-[0.04em] text-ink-50",children:"nightcrow"}),o.jsx("span",{className:"hidden font-sans text-[10px] uppercase tracking-[0.18em] text-ink-400 sm:inline",children:"web viewer"}),o.jsx(Tv,{className:"md:hidden",repos:c,currentId:r,onSelect:v=>{y(v),s()},onCloseProject:z,onOpenPicker:()=>E(!0)}),o.jsx("nav",{className:"-my-[8.8px] hidden items-stretch self-stretch overflow-x-auto pl-1 md:flex",children:c.map(v=>o.jsxs("div",{className:`flex items-center border-r border-ink-700 whitespace-nowrap ${v.id===r?"bg-ink-950 text-ink-50 shadow-[inset_0_2px_0_0_var(--color-accent)]":"text-ink-400 hover:bg-ink-850 hover:text-ink-200"}`,title:v.display_path,children:[o.jsx("button",{onClick:()=>{y(v.id),s()},className:"self-stretch pl-3 pr-1",children:v.name}),o.jsx("button",{onClick:j=>{j.stopPropagation(),z(v.id)},title:"Close project","aria-label":`close ${v.name}`,className:"mr-1 flex h-5 w-5 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-removed",children:o.jsx(ri,{className:"h-3.5 w-3.5"})})]},v.id))}),o.jsxs("button",{onClick:()=>E(!0),title:"Open a project",className:"hidden shrink-0 items-center gap-1 rounded-sm px-2 py-0.5 text-ink-400 hover:text-ink-200 md:inline-flex",children:[o.jsx(Em,{className:"h-3.5 w-3.5"}),"open"]}),o.jsx("button",{onClick:M,title:`Accent: ${_.name} (click for ${H.name})`,"aria-label":`accent colour: ${_.name}, click for ${H.name}`,className:"ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded-sm",children:o.jsx("span",{"aria-hidden":"true",className:"h-3 w-3 rounded-full bg-accent ring-1 ring-ink-600"})}),o.jsx("a",{href:"/logout",className:"pl-2 text-ink-400 hover:text-ink-200",children:"sign out"})]})}const Av="modulepreload",Nv=function(c,r){return new URL(c,r).href},cm={},zm=function(r,y,s){let z=Promise.resolve();if(y&&y.length>0){let v=function(j){return Promise.all(j.map(D=>Promise.resolve(D).then(R=>({status:"fulfilled",value:R}),R=>({status:"rejected",reason:R}))))};const _=document.getElementsByTagName("link"),H=document.querySelector("meta[property=csp-nonce]"),M=H?.nonce||H?.getAttribute("nonce");z=v(y.map(j=>{if(j=Nv(j,s),j in cm)return;cm[j]=!0;const D=j.endsWith(".css"),R=D?'[rel="stylesheet"]':"";if(s)for(let nt=_.length-1;nt>=0;nt--){const P=_[nt];if(P.href===j&&(!D||P.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${j}"]${R}`))return;const Q=document.createElement("link");if(Q.rel=D?"stylesheet":Av,D||(Q.as="script"),Q.crossOrigin="",Q.href=j,M&&Q.setAttribute("nonce",M),document.head.appendChild(Q),D)return new Promise((nt,P)=>{Q.addEventListener("load",nt),Q.addEventListener("error",()=>P(new Error(`Unable to preload CSS for ${j}`)))})}))}function E(_){const H=new Event("vite:preloadError",{cancelable:!0});if(H.payload=_,window.dispatchEvent(H),!H.defaultPrevented)throw _}return z.then(_=>{for(const H of _||[])H.status==="rejected"&&E(H.reason);return r().catch(E)})},Mv=180;function jv({repo:c,authed:r,tab:y,filter:s,filterOpen:z,handle:E}){const[_,H]=U.useState({}),[M,v]=U.useState(new Set),[j,D]=U.useState([]),[R,Q]=U.useState(!1),[nt,P]=U.useState(!1);U.useEffect(()=>{!c||!r||y!=="tree"||Gt.tree(c,"").then(L=>H(W=>({...W,"":L.entries}))).catch(E)},[c,r,y,E]),U.useEffect(()=>{if(!c||!r||y!=="tree"||!z||!s){D([]),Q(!1),P(!1);return}P(!0);let L=!0;const W=setTimeout(()=>{Gt.treeSearch(c,s).then(rt=>{L&&(D(rt.matches),Q(rt.truncated))}).catch(rt=>{L&&E(rt)}).finally(()=>{L&&P(!1)})},Mv);return()=>{L=!1,clearTimeout(W)}},[c,r,y,s,z,E]);const J=U.useCallback(L=>{c&&Gt.tree(c,L).then(W=>H(rt=>({...rt,[L]:W.entries}))).catch(E)},[c,E]),ut=U.useCallback(L=>{const W=!M.has(L);v(rt=>{const I=new Set(rt);return I.has(L)?I.delete(L):I.add(L),I}),W&&!(L in _)&&J(L)},[M,_,J]),et=U.useCallback(L=>{const W=L.split("/"),rt=[];let I="";for(const Z of W)I=I?`${I}/${Z}`:Z,rt.push(I);v(Z=>{const ot=new Set(Z);return rt.forEach(ht=>ot.add(ht)),ot}),rt.forEach(Z=>{Z in _||J(Z)})},[_,J]);return{treeChildren:_,setTreeChildren:H,treeExpanded:M,setTreeExpanded:v,treeMatches:j,treeTruncated:R,treeSearchLoading:nt,loadTreeChildren:J,toggleTreeDir:ut,revealTreeDir:et}}function Ov(c,r){const y=[],s=(z,E)=>{for(const _ of c[z]??[]){const H=z?`${z}/${_.name}`:_.name;y.push({path:H,name:_.name,is_dir:_.is_dir,depth:E}),_.is_dir&&r.has(H)&&s(H,E+1)}};return s("",0),y}function Kf({path:c,from:r,className:y}){return o.jsx("span",{className:`whitespace-nowrap ${y??""}`,title:r?`${r} → ${c}`:c,children:r?`${r} → ${c}`:c})}function Dv(c){const r=Math.max(0,Math.floor(Date.now()/1e3-c));return r<60?`${r}s`:r<3600?`${Math.floor(r/60)}m`:r<86400?`${Math.floor(r/3600)}h`:r<86400*30?`${Math.floor(r/86400)}d`:r<86400*365?`${Math.floor(r/(86400*30))}mo`:`${Math.floor(r/(86400*365))}y`}function Tm(c){return c==="+"?"bg-added/10":c==="-"?"bg-removed/10":""}function Vf(c){return c==="?"?"text-ink-400":c==="D"?"text-removed":c==="A"?"text-added":"text-accent"}function Cv({status:c,files:r,now:y,hotWindowMs:s,openDiff:z}){return c===null?o.jsx("li",{className:"px-3 py-2 text-ink-400",children:"Loading…"}):o.jsx(o.Fragment,{children:r.map(E=>o.jsx("li",{children:o.jsxs("button",{onClick:()=>z(E.path),className:"flex w-max min-w-full gap-2 px-3 py-0.5 text-left hover:bg-ink-850",children:[o.jsxs("span",{className:"shrink-0",children:[o.jsx("span",{className:Vf(E.index),children:E.index===" "?" ":E.index}),o.jsx("span",{className:Vf(E.worktree),children:E.worktree===" "?" ":E.worktree})]}),o.jsx(Kf,{path:E.path,from:E.old_path,className:sv[pm(E.mtime,y,s)]})]})},E.path))})}function Uv({visibleCommits:c,commits:r,aheadOids:y,commitDrillDown:s,visibleCommitFiles:z,logDone:E,logStalled:_,logPagingPaused:H,setLogStalled:M,logSentinelRef:v,openCommitFiles:j,openCommit:D,openCommitFileDiff:R,setCommitDrillDown:Q,setPaneEmpty:nt,bumpPaneRequest:P}){return o.jsxs(o.Fragment,{children:[!s&&c.map(J=>o.jsx("li",{children:o.jsxs("button",{onClick:()=>{j(J)},title:`${J.author} · ${J.summary}`,className:"flex w-max min-w-full items-baseline gap-2 px-3 py-0.5 text-left hover:bg-ink-850",children:[o.jsx("span",{className:"w-2 shrink-0 text-added",children:y.has(J.oid)?"↑":""}),o.jsx("span",{className:"shrink-0 text-accent",children:J.short_id}),o.jsx("span",{className:"w-10 shrink-0 text-right text-ink-400",children:Dv(J.time)}),o.jsx("span",{className:"max-w-[6rem] shrink-0 truncate text-ink-400",children:J.author}),o.jsx("span",{className:"whitespace-nowrap",children:J.summary})]})},J.oid)),!s&&!E&&!_&&!H&&o.jsx("li",{ref:v,className:"px-3 py-1 text-ink-400","aria-hidden":"true",children:"loading…"}),!s&&!E&&!_&&H&&o.jsxs("li",{className:"px-3 py-1 text-ink-400",children:["filtering ",r.length," loaded commits — clear the filter to load more"]}),!s&&_&&o.jsx("li",{className:"px-3 py-1",children:o.jsx("button",{onClick:()=>M(!1),className:"text-ink-400 hover:text-accent",children:"could not load more — retry"})}),s&&o.jsxs(o.Fragment,{children:[o.jsxs("li",{className:"sticky top-0 z-10 flex w-max min-w-full items-center gap-1 bg-ink-900 px-2 py-1 text-ink-400",children:[o.jsx("button",{onClick:()=>{P(),Q(null),nt()},className:"rounded-sm px-1 hover:text-accent",title:"Back to commit log",children:"< log"}),o.jsx("span",{className:"text-ink-600",children:"·"}),o.jsx("span",{className:"shrink-0 text-accent",children:s.commit.short_id}),o.jsx("button",{onClick:()=>D(s.commit.oid),className:"rounded-sm px-1 hover:text-accent",title:"Show the complete commit diff",children:"all changes"})]}),z.map(J=>o.jsx("li",{children:o.jsxs("button",{onClick:()=>R(s.commit.oid,J.path),className:"flex w-max min-w-full gap-2 px-3 py-0.5 text-left hover:bg-ink-850",children:[o.jsx("span",{className:Vf(J.index),children:J.index}),o.jsx(Kf,{path:J.path,from:J.old_path})]})},J.path)),s.files.length===0&&o.jsx("li",{className:"px-3 py-2 text-ink-400",children:"No changed files."}),s.files.length>0&&z.length===0&&o.jsx("li",{className:"px-3 py-2 text-ink-400",children:"No matching files."}),s.truncated&&o.jsxs("li",{className:"px-3 py-1 text-accent",children:["Showing the first ",s.files.length," files."]})]})]})}function Hv({treeSearching:c,treeMatches:r,treeTruncated:y,treeSearchLoading:s,treeRows:z,treeExpanded:E,openFile:_,revealTreeDir:H,toggleTreeDir:M}){return c?o.jsxs(o.Fragment,{children:[r.map(v=>o.jsx("li",{children:o.jsx("button",{onClick:()=>{v.is_dir?H(v.path):_(v.path)},title:v.path,className:"w-max min-w-full whitespace-nowrap px-3 py-0.5 text-left hover:bg-ink-850",children:v.is_dir?o.jsxs("span",{className:"text-accent",children:[v.path,"/"]}):v.path})},v.path)),r.length===0&&o.jsx("li",{className:"px-3 py-0.5 text-ink-400",children:s?"searching…":"no matches"}),y&&o.jsxs("li",{className:"px-3 py-0.5 text-ink-400",children:["showing the first ",r.length," matches"]})]}):o.jsx(o.Fragment,{children:z.map(v=>o.jsx("li",{children:o.jsxs("button",{onClick:()=>v.is_dir?M(v.path):_(v.path),title:v.path,style:{paddingLeft:`${v.depth*.75+.5}rem`},className:"flex w-max min-w-full items-center gap-1 py-0.5 pr-3 text-left hover:bg-ink-850",children:[v.is_dir?o.jsx(xm,{open:E.has(v.path)}):o.jsx("span",{className:"h-3.5 w-3.5 shrink-0"}),o.jsx("span",{className:`whitespace-nowrap ${v.is_dir?"text-accent":""}`,children:v.is_dir?`${v.name}/`:v.name})]})},v.path))})}function Rv(c){const{tab:r,setTab:y,filter:s,setFilter:z,filterOpen:E,setFilterOpen:_,status:H,files:M,now:v,hotWindowMs:j,setPane:D,openDiff:R,openFile:Q,openCommit:nt,openCommitFileDiff:P,openCommitFiles:J,repo:ut,authed:et,handle:L,sidebarRef:W,draggingSidebar:rt,onSidebarDragStart:I,onSidebarDragMove:Z,onSidebarDragEnd:ot,onSidebarDragCancel:ht,filesMax:tl,bumpPaneRequest:Qt,commits:jt,logDone:wt,logStalled:Ct,setLogStalled:Zt,commitDrillDown:T,setCommitDrillDown:B,resetLog:w,logSentinelRef:mt,visibleCommits:vt,logPagingPaused:h,aheadOids:O,visibleCommitFiles:q}=c,Y=jv({repo:ut,authed:et,tab:r,filter:s,filterOpen:E,handle:L}),$=r==="tree"&&E&&s!=="",k=Ov(Y.treeChildren,Y.treeExpanded);return o.jsxs("section",{ref:W,className:`relative min-h-0 flex-col overflow-hidden ${tl?"hidden md:flex":"flex border-ink-700 md:border-r"}`,children:[!tl&&o.jsx("div",{role:"separator","aria-orientation":"vertical","aria-label":"Resize the file sidebar (double-click to reset)",title:"Drag to resize · double-click to reset",onPointerDown:I,onPointerMove:Z,onPointerUp:ot,onPointerCancel:ht,onLostPointerCapture:ot,className:`absolute -right-px top-0 z-10 hidden h-full w-1.5 cursor-col-resize touch-none md:block ${rt?"bg-accent":"hover:bg-accent"}`}),o.jsxs("div",{className:"flex shrink-0 items-stretch border-b border-ink-700 px-2",children:[["status","log","tree"].map(F=>o.jsx("button",{onClick:()=>{F!==r&&(Qt(),r==="log"&&(B(null),w()),y(F),D({kind:"empty"}))},"aria-current":F===r?"page":void 0,className:`-mb-px border-b-2 px-2 py-1 ${F===r?"border-accent text-ink-50":"border-transparent text-ink-400 hover:text-ink-200"}`,children:F},F)),o.jsx("button",{onClick:()=>{E&&z(""),_(F=>!F)},"aria-pressed":E,title:E?"Hide the filter":"Filter the list","aria-label":E?"Hide the filter":"Filter the list",className:`my-1 ml-auto flex shrink-0 items-center rounded-sm px-1.5 hover:text-accent ${E?"text-ink-50":"text-ink-400"}`,children:o.jsx(zv,{})})]}),E&&o.jsx("input",{value:s,onChange:F=>z(F.target.value),placeholder:"filter…",autoFocus:!0,className:"mx-2 mb-1 shrink-0 rounded-sm bg-ink-850 px-2 py-1 outline-none placeholder:text-ink-400 focus:ring-1 focus:ring-accent"}),o.jsxs("ul",{className:"min-h-0 flex-1 overflow-auto",children:[r==="status"&&o.jsx(Cv,{status:H,files:M,now:v,hotWindowMs:j,openDiff:R}),r==="log"&&o.jsx(Uv,{visibleCommits:vt,commits:jt,aheadOids:O,commitDrillDown:T,visibleCommitFiles:q,logDone:wt,logStalled:Ct,logPagingPaused:h,setLogStalled:Zt,logSentinelRef:mt,openCommitFiles:J,openCommit:nt,openCommitFileDiff:P,setCommitDrillDown:B,setPaneEmpty:()=>D({kind:"empty"}),bumpPaneRequest:Qt}),r==="tree"&&o.jsx(Hv,{treeSearching:$,treeMatches:Y.treeMatches,treeTruncated:Y.treeTruncated,treeSearchLoading:Y.treeSearchLoading,treeRows:k,treeExpanded:Y.treeExpanded,openFile:Q,revealTreeDir:Y.revealTreeDir,toggleTreeDir:Y.toggleTreeDir})]})]})}const _m="nightcrow.viewer.diffLayout",Am=768;function Bv(c){const r=[];let y=[],s=[];const z=()=>{const E=Math.max(y.length,s.length);for(let _=0;_{let y;try{y=window.matchMedia(`(min-width: ${Am}px)`)}catch{return}const s=z=>r(z.matches);return y.addEventListener("change",s),()=>y.removeEventListener("change",s)},[]),c}function Gv(){const[c,r]=U.useState(qv),y=Xv(),s=U.useCallback(()=>{r(E=>{const _=E==="split"?"unified":"split";return Yv(_),_})},[]);return{layout:c,effective:c==="split"&&y?"split":"unified",toggle:s}}const Qv=[".md",".markdown"];function fm(c){const r=c.toLowerCase();return Qv.some(y=>r.endsWith(y))}function Zv(c){return c.map(r=>r.map(y=>y.t).join("")).join(` +`)}function Nm({line:c}){return o.jsxs(o.Fragment,{children:[o.jsx("span",{className:"text-ink-400 select-none",children:c.kind}),c.spans.map((r,y)=>o.jsx("span",{style:{color:r.c},children:r.t},y))]})}function Vv({line:c}){return c===null?o.jsx("div",{className:"whitespace-pre bg-ink-900/40 px-3",children:" "}):o.jsx("div",{className:`whitespace-pre px-3 ${Tm(c.kind)}`,children:o.jsx(Nm,{line:c})})}function sm({cells:c,border:r}){const y=r?"border-l border-ink-800":"";return o.jsx("div",{className:`min-w-0 flex-1 basis-1/2 overflow-x-auto ${y}`,children:o.jsx("div",{className:"w-max min-w-full",children:c.map((s,z)=>o.jsx(Vv,{line:s},z))})})}function wv({lines:c}){const r=Bv(c);return o.jsxs("div",{className:"flex",children:[o.jsx(sm,{cells:r.map(y=>y.left),border:!1}),o.jsx(sm,{cells:r.map(y=>y.right),border:!0})]})}function kv({diff:c,split:r}){return o.jsxs("div",{className:"p-1",children:[c.hunks.length===0&&o.jsx("p",{className:"p-3 text-ink-400",children:"No changes."}),c.hunks.map((y,s)=>o.jsxs("div",{className:"mb-2",children:[o.jsxs("div",{className:"bg-ink-850 px-3 py-0.5 text-ink-400",children:[y.file_path?`${y.file_path} `:"",y.header]}),r?o.jsx(wv,{lines:y.lines}):y.lines.map((z,E)=>o.jsx("div",{className:`px-3 whitespace-pre ${Tm(z.kind)}`,children:o.jsx(Nm,{line:z})},E))]},s)),c.truncated&&o.jsx("p",{className:"p-3 text-accent",children:"Diff truncated — it exceeded the server's size ceiling."})]})}const Kv=U.lazy(()=>zm(()=>import("./Markdown-BYxaSXJW.js"),__vite__mapDeps([0,1]),import.meta.url).then(c=>({default:c.MarkdownView})));function Jv({pane:c,mdRendered:r,setMdRendered:y,filesMax:s,setMaximized:z,status:E}){const _=Gv();return o.jsxs("section",{className:"flex min-h-0 min-w-0 flex-col",children:[o.jsxs("div",{className:"flex shrink-0 items-center gap-2 bg-ink-850 px-3 py-0.5 text-ink-400",children:[c.kind==="file"&&o.jsx(Kf,{path:c.value.path}),o.jsxs("div",{className:"ml-auto flex shrink-0 items-center gap-1",children:[c.kind==="diff"&&o.jsx("button",{onClick:_.toggle,"aria-pressed":_.layout==="split",title:_.layout==="split"?"Switch to unified diff":"Switch to split diff","aria-label":_.layout==="split"?"Switch to unified diff":"Switch to split diff",className:`flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent ${_.layout==="split"?"text-accent":""}`,children:o.jsx(xv,{})}),c.kind==="file"&&fm(c.value.path)&&o.jsx("button",{onClick:()=>y(H=>!H),"aria-pressed":r,title:r?"Show raw source":"Show rendered markdown","aria-label":r?"Show raw source":"Show rendered markdown",className:`flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent ${r?"text-accent":""}`,children:o.jsx(Ev,{})}),o.jsx("button",{onClick:()=>z(s?"none":"files"),"aria-pressed":s,title:s?"Restore the layout":"Maximize the file pane","aria-label":s?"Restore the layout":"Maximize the file pane",className:"flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent",children:o.jsx(pv,{maximized:s})})]})]}),o.jsxs("div",{className:"min-h-0 flex-1 overflow-auto",children:[c.kind==="empty"&&o.jsx("p",{className:"p-4 text-ink-400",children:E===null?"Loading…":"Select a file or commit."}),c.kind==="file"&&o.jsxs(o.Fragment,{children:[fm(c.value.path)&&r?o.jsx(U.Suspense,{fallback:o.jsx("p",{className:"p-4 text-ink-400",children:"Rendering…"}),children:o.jsx(Kv,{source:Zv(c.value.lines)})}):o.jsx("pre",{className:"p-3 whitespace-pre text-ink-200",children:c.value.lines.map((H,M)=>o.jsx("div",{children:H.length===0?" ":H.map((v,j)=>o.jsx("span",{style:{color:v.c},children:v.t},j))},M))}),c.value.truncated&&o.jsx("p",{className:"p-3 text-accent",children:"File truncated — it exceeded the server's size ceiling."})]}),c.kind==="diff"&&o.jsx(kv,{diff:c.value,split:_.effective==="split"})]})]})}const $v=U.lazy(()=>zm(()=>import("./Terminal-Dhwxs9P9.js"),[],import.meta.url).then(c=>({default:c.TerminalPanel})));function Wv(c){const{repo:r,current:y,status:s,files:z,now:E,hotWindowMs:_,pane:H,setPane:M,tab:v,setTab:j,filter:D,setFilter:R,filterOpen:Q,setFilterOpen:nt,openDiff:P,openFile:J,openCommit:ut,openCommitFileDiff:et,openCommitFiles:L,authed:W,handle:rt,sidebarWidth:I,sidebarRef:Z,draggingSidebar:ot,onSidebarDragStart:ht,onSidebarDragMove:tl,onSidebarDragEnd:Qt,onSidebarDragCancel:jt,filesMax:wt,bumpPaneRequest:Ct,commits:Zt,logDone:T,logStalled:B,setLogStalled:w,commitDrillDown:mt,setCommitDrillDown:vt,resetLog:h,logSentinelRef:O,visibleCommits:q,logPagingPaused:Y,aheadOids:$,visibleCommitFiles:k,mdRendered:F,setMdRendered:Bt,maximized:_t,setMaximized:ql}=c;return o.jsxs(o.Fragment,{children:[ot&&o.jsx("div",{className:"fixed inset-0 z-50 cursor-col-resize"}),o.jsxs("main",{className:`grid min-h-0 grid-cols-1 md:grid-cols-[var(--nc-sidebar)_1fr] ${ot?"select-none":""}`,style:{"--nc-sidebar":wt?"0px":`min(${I}px, ${Sm*100}vw)`},children:[o.jsx(Rv,{tab:v,setTab:j,filter:D,setFilter:R,filterOpen:Q,setFilterOpen:nt,status:s,files:z,now:E,hotWindowMs:_,setPane:M,openDiff:P,openFile:J,openCommit:ut,openCommitFileDiff:et,openCommitFiles:L,repo:r,authed:W,handle:rt,sidebarRef:Z,draggingSidebar:ot,onSidebarDragStart:ht,onSidebarDragMove:tl,onSidebarDragEnd:Qt,onSidebarDragCancel:jt,filesMax:wt,bumpPaneRequest:Ct,commits:Zt,logDone:T,logStalled:B,setLogStalled:w,commitDrillDown:mt,setCommitDrillDown:vt,resetLog:h,logSentinelRef:O,visibleCommits:q,logPagingPaused:Y,aheadOids:$,visibleCommitFiles:k}),o.jsx(Jv,{pane:H,mdRendered:F,setMdRendered:Bt,filesMax:wt,setMaximized:ql,status:s})]}),o.jsx(U.Suspense,{fallback:null,children:o.jsx($v,{repo:r,maximized:_t==="terminal",onToggleMaximized:()=>ql(Yl=>Yl==="terminal"?"none":"terminal")})}),o.jsxs("footer",{className:"flex shrink-0 items-center gap-3 border-t border-ink-700 bg-ink-900 px-3 py-1 text-ink-400",children:[o.jsx("span",{className:"truncate",children:y?.display_path}),s?.branch&&o.jsx("span",{className:"text-accent",children:s.branch}),s?.tracking&&o.jsxs("span",{children:["↑",s.tracking.ahead," ↓",s.tracking.behind]}),o.jsx("span",{className:"ml-auto",children:s?o.jsx("span",{className:"text-added",children:"● live"}):"connecting…"})]})]})}function Fv({onClose:c,onOpened:r}){const[y,s]=U.useState(null),[z,E]=U.useState(null),[_,H]=U.useState(null),[M,v]=U.useState(!1),[j,D]=U.useState(""),[R,Q]=U.useState(!1),[nt,P]=U.useState(0);U.useEffect(()=>{let L=!1;return Gt.browse(y??void 0).then(W=>{L||(E(W),H(null))}).catch(W=>{L||H(W instanceof Error?W.message:"could not browse")}),()=>{L=!0}},[y,nt]);const J=L=>s(`${z.path.replace(/\/$/,"")}/${L}`),ut=async()=>{if(z){v(!0);try{r(await Gt.open(z.path))}catch(L){Xf.error(L instanceof Error?L.message:"could not open"),v(!1)}}},et=async()=>{if(!z)return;const L=j.trim();if(L){Q(!0);try{await Gt.mkdir(z.path,L),D(""),P(W=>W+1)}catch(W){Xf.error(W instanceof Error?W.message:"could not create folder")}finally{Q(!1)}}};return o.jsx("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4",onClick:c,children:o.jsxs("div",{className:"flex max-h-[80vh] w-[34rem] max-w-full flex-col rounded-md border border-ink-700 bg-ink-900",onClick:L=>L.stopPropagation(),children:[o.jsxs("div",{className:"flex shrink-0 items-center gap-2 border-b border-ink-700 px-3 py-2",children:[o.jsx("span",{className:"font-medium text-ink-50",children:"Open a project"}),o.jsx("button",{onClick:c,"aria-label":"close",className:"ml-auto flex h-6 w-6 items-center justify-center rounded-sm text-ink-400 hover:text-ink-200",children:o.jsx(ri,{})})]}),o.jsx("div",{className:"shrink-0 truncate border-b border-ink-700 px-3 py-1.5 text-ink-400",children:z?.path??"…"}),o.jsxs("ul",{className:"h-72 min-h-0 overflow-y-auto",children:[z?.parent&&o.jsx("li",{children:o.jsx("button",{onClick:()=>s(z.parent),className:"w-full px-3 py-1 text-left text-ink-400 hover:bg-ink-850",children:"../"})}),z?.entries.map(L=>o.jsx("li",{children:o.jsxs("button",{onClick:()=>J(L.name),className:"flex w-full items-center gap-2 px-3 py-1 text-left hover:bg-ink-850",children:[o.jsxs("span",{className:"truncate text-accent",children:[L.name,"/"]}),L.is_repo&&o.jsx("span",{className:"rounded-sm bg-ink-700 px-1 text-[0.65rem] text-ink-200",children:"git"})]})},L.name)),z&&z.entries.length===0&&o.jsx("li",{className:"px-3 py-1 text-ink-400",children:"No sub-folders."})]}),_&&o.jsx("p",{className:"shrink-0 px-3 py-1 text-removed",children:_}),o.jsxs("div",{className:"flex shrink-0 items-center gap-2 border-t border-ink-700 px-3 py-2",children:[o.jsx("input",{value:j,onChange:L=>D(L.target.value),onKeyDown:L=>{L.key==="Enter"&&et()},placeholder:"New folder name","aria-label":"new folder name",className:"min-w-0 flex-1 rounded-sm border border-ink-700 bg-ink-950 px-2 py-1 text-ink-50 placeholder:text-ink-400 focus:border-ink-600 focus:outline-none"}),o.jsx("button",{onClick:et,disabled:!z||!j.trim()||R,className:"shrink-0 rounded-sm border border-ink-700 px-2 py-1 text-ink-200 hover:bg-ink-850 disabled:opacity-50",children:R?"Creating…":"Create"})]}),o.jsxs("div",{className:"flex shrink-0 items-center gap-2 border-t border-ink-700 px-3 py-2",children:[o.jsx("span",{className:"truncate text-ink-400",children:z?z.path:""}),o.jsx("button",{onClick:ut,disabled:!z||M,className:"ml-auto shrink-0 rounded-md bg-ink-50 px-3 py-1 font-semibold text-ink-950 hover:bg-white disabled:opacity-50",children:M?"Opening…":"Open"})]})]})})}function rm(){return o.jsx("div",{className:"flex h-full items-center justify-center p-6",children:o.jsxs("div",{className:"flex flex-col items-center gap-3 text-ink-400",children:[o.jsx(kf,{className:"h-12 w-12 animate-pulse"}),o.jsx("span",{className:"text-[0.72rem] tracking-[0.18em] uppercase",children:"Loading…"})]})})}function Iv({onSuccess:c}){const[r,y]=U.useState(""),[s,z]=U.useState(null),[E,_]=U.useState(!1),H=async M=>{M.preventDefault(),_(!0),z(null);try{await Gt.login(r),c()}catch(v){z(v instanceof Error?v.message:"login failed")}finally{_(!1)}};return o.jsx("div",{className:"flex h-full items-center justify-center p-6",children:o.jsxs("form",{onSubmit:H,className:"w-[17rem] max-w-[86vw]",children:[o.jsx(kf,{className:"mx-auto mb-3 block h-10 w-10"}),o.jsx("h1",{className:"text-center text-lg font-medium tracking-wide text-ink-50",children:"nightcrow"}),o.jsx("p",{className:"mt-1 mb-5 text-center text-[0.62rem] tracking-[0.18em] text-ink-400 uppercase",children:"web viewer"}),s&&o.jsx("p",{className:"mb-2.5 text-center text-removed",children:s}),o.jsx("input",{type:"password",autoFocus:!0,value:r,onChange:M=>y(M.target.value),placeholder:"password",className:"mb-2 w-full rounded-md border border-ink-700 bg-ink-900 px-2.5 py-1.5 outline-none placeholder:text-ink-400 focus:border-accent focus:ring-[3px] focus:ring-accent/15"}),o.jsx("button",{type:"submit",disabled:E,className:"w-full rounded-md bg-ink-50 py-1.5 font-semibold text-ink-950 hover:bg-white disabled:opacity-50",children:E?"Signing in…":"Sign in"})]})})}function Pv(){const[c,r]=U.useState(null),[y,s]=U.useState("status"),[z,E]=U.useState(""),[_,H]=U.useState(!1),[M,v]=U.useState({kind:"empty"}),j=U.useRef(0),D=U.useCallback(()=>{j.current+=1},[]),[R,Q]=U.useState(!1),{accent:nt,next:P,cycle:J,adopt:ut}=ev(),et=U.useRef(0),L=U.useCallback(()=>{et.current+=1,J()},[J]),{width:W,resize:rt,commit:I,reset:Z,adopt:ot}=nv(),ht=U.useRef(0),tl=U.useCallback(Ut=>{ht.current+=1,I(Ut)},[I]),Qt=U.useCallback(()=>{ht.current+=1,Z()},[Z]),jt=U.useCallback(()=>{ht.current+=1},[]),wt=U.useRef(null),[Ct,Zt]=U.useState(!0),T=U.useCallback(Ut=>{if(dm(Ut)){r(!1);return}Xf.error(Ut instanceof Error?Ut.message:"request failed")},[]),B=gv(),{draggingSidebar:w,onSidebarDragStart:mt,onSidebarDragMove:vt,onSidebarDragEnd:h,onSidebarDragCancel:O,draggingRef:q}=hv({sidebarRef:wt,sidebarWidth:W,resizeSidebar:rt,commitSidebarWidth:tl,resetSidebarWidth:Qt,bumpSidebarWrites:jt}),{repos:Y,setRepos:$,repo:k,setRepo:F,hot:Bt,clockSkewMs:_t,reposLoaded:ql}=rv({authed:c,setAuthed:r,handle:T,adoptAccent:ut,adoptSidebarWidth:ot,draggingRef:q,accentWrites:et,sidebarWrites:ht,resumeTick:B}),Yl=Bt?.enabled?Bt.window_secs*1e3:0;um(void 0,Yl,_t??0);const{status:ce}=ov({repo:k,authed:c,resumeTick:B,tab:y,pane:M,setPane:v,handle:T,paneRequestRef:j}),Ln=um(ce?.files,Yl,_t??0),{maximized:bl,setMaximized:La,dropMaximized:Xa}=Sv(k),{commits:Xn,logDone:Gn,logStalled:Ga,setLogStalled:Qa,commitDrillDown:Fe,setCommitDrillDown:Ie,resetLog:Za,logSentinelRef:ll,visibleCommits:oi,logPagingPaused:Qn}=yv({repo:k,authed:c,tab:y,filter:z,handle:T}),{openDiff:Zn,openFile:Pe,openCommit:di,openCommitFileDiff:Vn,openCommitFiles:mi}=vv({repo:k,handle:T,setPane:v,paneRequestRef:j,setCommitDrillDown:Ie}),{selectOpenedRepo:hi,closeRepo:Ce}=bv({repos:Y,setRepos:$,setRepo:F,setPane:v,setTab:s,setPickerOpen:Q,dropMaximized:Xa,handle:T});if(U.useEffect(()=>{D(),Ie(null),v({kind:"empty"}),Za()},[k,Za,D,Ie]),c===null)return o.jsx(rm,{});if(!c)return o.jsx(Iv,{onSuccess:()=>r(!0)});if(!ql)return o.jsx(rm,{});const el=Y.find(Ut=>Ut.id===k),pl=z.toLowerCase(),al=(ce?.files??[]).filter(Ut=>Ut.path.toLowerCase().includes(pl)),vi=(Fe?.files??[]).filter(Ut=>Ut.path.toLowerCase().includes(pl)||Ut.old_path?.toLowerCase().includes(pl)),yi=new Set(Xn.slice(0,ce?.tracking?.ahead??0).map(Ut=>Ut.oid)),gi=bl==="files",ta=k?bl==="terminal"?"grid-rows-[auto_minmax(0,0fr)_minmax(0,1fr)_auto]":bl==="files"?"grid-rows-[auto_minmax(0,1fr)_minmax(0,0fr)_auto]":"grid-rows-[auto_minmax(0,11fr)_minmax(0,9fr)_auto]":"grid-rows-[auto_1fr]";return o.jsxs("div",{className:`nc-fade grid h-full ${ta}`,children:[o.jsx(_v,{repos:Y,repo:k,setRepo:Ut=>{F(Ut),v({kind:"empty"})},setPane:()=>v({kind:"empty"}),closeRepo:Ce,setPickerOpen:Q,accent:nt,next:P,cycle:L}),k?o.jsx(Wv,{repo:k,repos:Y,current:el,status:ce,files:al,now:Ln,hotWindowMs:Yl,pane:M,setPane:v,tab:y,setTab:s,filter:z,setFilter:E,filterOpen:_,setFilterOpen:H,openDiff:Zn,openFile:Pe,openCommit:di,openCommitFileDiff:Vn,openCommitFiles:mi,authed:c,handle:T,sidebarWidth:W,sidebarRef:wt,draggingSidebar:w,onSidebarDragStart:mt,onSidebarDragMove:vt,onSidebarDragEnd:h,onSidebarDragCancel:O,filesMax:gi,bumpPaneRequest:D,commits:Xn,logDone:Gn,logStalled:Ga,setLogStalled:Qa,commitDrillDown:Fe,setCommitDrillDown:Ie,resetLog:Za,logSentinelRef:ll,visibleCommits:oi,logPagingPaused:Qn,aheadOids:yi,visibleCommitFiles:vi,mdRendered:Ct,setMdRendered:Zt,maximized:bl,setMaximized:La}):o.jsx("div",{className:"flex items-center justify-center p-6 text-center text-ink-400",children:o.jsxs("span",{children:["No repository open. Click"," ",o.jsx("span",{className:"text-ink-200",children:"+ open"})," above to add one."]})}),R&&o.jsx(Fv,{onClose:()=>Q(!1),onOpened:hi})]})}const ty={error:7e3,info:5e3,success:5e3},ly={error:"text-removed",info:"text-accent",success:"text-added"};function ey(){const[c,r]=U.useState([]);return U.useEffect(()=>tv(r),[]),c.length===0?null:o.jsx("div",{className:"pointer-events-none fixed right-3 top-3 z-[60] flex w-80 max-w-[calc(100vw-1.5rem)] flex-col gap-2","aria-live":"polite",children:c.map(y=>o.jsx(ay,{toast:y},y.id))})}function ay({toast:c}){const[r,y]=U.useState(!1);return U.useEffect(()=>{if(r)return;const s=setTimeout(()=>lm(c.id),ty[c.kind]);return()=>clearTimeout(s)},[c.id,c.kind,c.bump,r]),o.jsxs("div",{role:c.kind==="error"?"alert":"status",className:"nc-fade pointer-events-auto flex items-start gap-2 rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-xs shadow-lg",onMouseEnter:()=>y(!0),onMouseLeave:()=>y(!1),children:[o.jsx("span",{className:`min-w-0 flex-1 break-words ${ly[c.kind]}`,children:c.message}),o.jsx("button",{type:"button",onClick:()=>lm(c.id),"aria-label":"dismiss",className:"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-ink-200",children:o.jsx(ri,{className:"h-3 w-3"})})]})}Wh.createRoot(document.getElementById("root")).render(o.jsxs(U.StrictMode,{children:[o.jsx(Pv,{}),o.jsx(ey,{})]}));export{pv as M,Em as P,ri as X,o as j,U as r,Xf as t}; diff --git a/viewer-ui/dist/index.html b/viewer-ui/dist/index.html index db923b8..1dd776b 100644 --- a/viewer-ui/dist/index.html +++ b/viewer-ui/dist/index.html @@ -5,8 +5,8 @@ nightcrow - - + +
diff --git a/viewer-ui/src/App.tsx b/viewer-ui/src/App.tsx index b396960..141f226 100644 --- a/viewer-ui/src/App.tsx +++ b/viewer-ui/src/App.tsx @@ -1,466 +1,47 @@ -import { - Suspense, - lazy, - useCallback, - useEffect, - useRef, - useState, - type CSSProperties, - type PointerEvent as ReactPointerEvent, -} from "react"; -import { - api, - isNetworkError, - isUnauthorized, - subscribeStatus, - type Browse, - type ChangedFile, - type Commit, - type CommitFiles, - type Diff, - type DiffLine, - type FileView, - type HotConfig, - type Repo, - type Status, - type TreeEntry, - type TreeMatch, -} from "./api"; -import { - ChevronIcon, - MaximizeIcon, - PlusIcon, - PreviewIcon, - SearchIcon, - SplitViewIcon, - XIcon, -} from "./icons"; -import { splitHunkRows, useDiffLayout } from "./diffLayout"; -import { fileViewSource, isMarkdownPath } from "./fileView"; -import { - anyHot, - classifyHot, - HOT_TICK_MS, - nextClockOffset, - type HotStage, -} from "./hot"; -import { useAccent } from "./theme"; -import { MAX_SIDEBAR_VIEWPORT_FRACTION, useSidebarWidth } from "./sidebar"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { isUnauthorized } from "./api"; import { toast } from "./toast"; - -// Lazily loaded so `@xterm/xterm` (the bulk of the bundle) stays out of the -// initial chunk that paints the login screen and git viewer, arriving only once -// a repo is open and the terminal panel actually mounts. -const TerminalPanel = lazy(() => - import("./Terminal").then((m) => ({ default: m.TerminalPanel })), -); - -// Same reasoning as the terminal panel: react-markdown pulls in the remark / -// rehype / highlight.js pipeline, which has no business in the initial chunk. -// It arrives only when a markdown file is first rendered. -const MarkdownView = lazy(() => - import("./Markdown").then((m) => ({ default: m.MarkdownView })), -); - -/// How often the tab bar re-reads the served set. The payload is a handful of -/// short strings, and this only has to feel prompt when a tab opens. -const REPO_POLL_MS = 3000; - -/// Debounce for the recursive tree search: each keystroke hits the filesystem -/// on the backend, so wait for a pause in typing before firing. -const TREE_SEARCH_DEBOUNCE_MS = 180; - -/// Horizontal travel before a pointer press on the sidebar divider counts as a -/// resize. Below this, a click or a vertical-only wobble commits nothing, so it -/// cannot overwrite the stored width with the viewport-capped display value. -const SIDEBAR_DRAG_THRESHOLD_PX = 3; - -/// Window within which two clicks on the divider read as a double-click and -/// reset the sidebar to its default width. -const DOUBLE_CLICK_MS = 400; - -/// Compact relative age of a unix timestamp (seconds), matching the TUI's log -/// column (e.g. "3s", "5m", "2h", "4d", "6mo", "1y"). -function formatRelativeTime(ts: number): string { - const s = Math.max(0, Math.floor(Date.now() / 1000 - ts)); - if (s < 60) return `${s}s`; - if (s < 3600) return `${Math.floor(s / 60)}m`; - if (s < 86400) return `${Math.floor(s / 3600)}h`; - if (s < 86400 * 30) return `${Math.floor(s / 86400)}d`; - if (s < 86400 * 365) return `${Math.floor(s / (86400 * 30))}mo`; - return `${Math.floor(s / (86400 * 365))}y`; -} - -type Tab = "status" | "log" | "tree"; -/// Which panel, if any, has been given the whole work area. One value rather -/// than a flag per panel: only one can hold the space, and a pair of booleans -/// would admit a "both maximised" state that has no layout. -type Maximized = "none" | "terminal" | "files"; -type Pane = - | { kind: "diff"; value: Diff } - | { kind: "file"; value: FileView } - | { kind: "empty" }; - -interface CommitDrillDown extends CommitFiles { - commit: Commit; -} - -/// One visible row of the folder tree, flattened with its nesting depth for -/// indentation. -interface TreeRow { - path: string; - name: string; - is_dir: boolean; - depth: number; -} - -/// Flatten the lazily-cached tree into the rows to render: a depth-first walk -/// from the root that descends only into expanded directories whose children -/// have been fetched. An expanded directory whose children are still loading -/// simply shows no rows beneath it until they arrive. -function buildTreeRows( - children: Record, - expanded: Set, -): TreeRow[] { - const rows: TreeRow[] = []; - const walk = (dir: string, depth: number) => { - for (const entry of children[dir] ?? []) { - const path = dir ? `${dir}/${entry.name}` : entry.name; - rows.push({ path, name: entry.name, is_dir: entry.is_dir, depth }); - if (entry.is_dir && expanded.has(path)) walk(path, depth + 1); - } - }; - walk("", 0); - return rows; -} - -/** - * A path rendered in full, reachable by scrolling the list sideways. - * - * Truncating instead would cut the tail, which is the one part that tells two - * rows apart — `src/web/viewer/server.rs` and `src/web/viewer/terminal.rs` both - * become `src/web/viewer/…` in a narrow sidebar. `title` still carries the - * whole path so a hover answers without scrolling. - */ -function PathLabel({ - path, - from, - className, -}: { - path: string; - from?: string; - className?: string; -}) { - return ( - - {from ? `${from} → ${path}` : path} - - ); -} - -/** Recency styling for one status row, mirroring the TUI: the status letters - * keep their change colour at every stage — the change kind stays readable — - * and only the path carries the highlight, so a row does not shift as it - * fades. */ -const HOT_CLASS: Record = { - fresh: "text-accent font-bold", - warm: "text-accent", - cool: "", -}; - -/** The clock the recently-touched highlight is dated against. - * - * A file cools with time rather than with any event, so the list has to - * re-render on its own to fade. The ticking is bounded on both ends: it starts - * only when a snapshot actually contains a hot file, and stops itself once the - * last one cools — an idle repository re-renders nothing. Every snapshot is - * still dated on arrival, ticker or not, so a stopped clock never judges one. - * - * `windowMs <= 0` (the server's indicator turned off, or its config not yet - * loaded) never ticks; `classifyHot` reads everything as cool at that window. */ -function useHotClock( - files: ChangedFile[] | undefined, - windowMs: number, - offsetMs: number, -): number { - // Every reading is shifted onto the server's clock, because that is the clock - // the mtimes it is compared against were measured on. `offsetMs` is a - // dependency so a poll that refines it restarts the tick on the corrected - // clock rather than finishing the current fade on the old one. - const [now, setNow] = useState(() => Date.now() + offsetMs); - useEffect(() => { - if (windowMs <= 0 || !files) return; - const mtimes = files.map((f) => f.mtime); - // Date the snapshot before deciding whether it needs a ticker, not after. - // `now` stops advancing when the last file cools, so a snapshot arriving - // long afterwards — the tab left open, or another repository selected — - // would otherwise be measured against whenever the ticker last stopped, and - // a file touched around that moment would read as freshly touched forever. - const start = Date.now() + offsetMs; - setNow(start); - if (!anyHot(mtimes, start, windowMs)) return; - const id = setInterval(() => { - const tick = Date.now() + offsetMs; - setNow(tick); - if (!anyHot(mtimes, tick, windowMs)) clearInterval(id); - }, HOT_TICK_MS); - return () => clearInterval(id); - }, [files, windowMs, offsetMs]); - return now; -} - -/** Background tint for a changed line, shared by the unified and split views. */ -function diffLineBg(kind: string): string { - if (kind === "+") return "bg-added/10"; - if (kind === "-") return "bg-removed/10"; - return ""; -} - -/** The kind marker plus the highlighted content spans of one diff line. */ -function DiffLineContent({ line }: { line: DiffLine }) { - return ( - <> - {line.kind} - {line.spans.map((s, k) => ( - - {s.t} - - ))} - - ); -} - -/** One line within a split column; `null` renders a muted blank where this side - * has no counterpart, so the two columns stay row-aligned. Both cases carry one - * line's height (the blank via a non-breaking space) and fill the inner track's - * width so the change tint spans the whole line, overflow included. */ -function SplitCell({ line }: { line: DiffLine | null }) { - if (line === null) { - return
{" "}
; - } - return ( -
- -
- ); -} - -/** One fixed-half side of a split hunk: a stack of its lines that scrolls - * horizontally on its own, so a long line here never drags the other side. The - * inner `w-max min-w-full` track is as wide as this side's widest line (but at - * least the full column), giving every line a uniform width for the tint and a - * single shared scrollbar for the side. `border` draws the divider between the - * two halves (on the right side). */ -function SplitColumn({ - cells, - border, -}: { - cells: (DiffLine | null)[]; - border: boolean; -}) { - const divider = border ? "border-l border-ink-800" : ""; - return ( -
-
- {cells.map((line, i) => ( - - ))} -
-
- ); -} - -/** Side-by-side body for one hunk: removed lines on the left, added on the - * right, paired by `splitHunkRows`. The two halves are fixed at 50% each and - * scroll horizontally independently; equal per-line heights keep rows aligned - * across the seam. */ -function SplitHunk({ lines }: { lines: DiffLine[] }) { - const rows = splitHunkRows(lines); - return ( -
- r.left)} border={false} /> - r.right)} border={true} /> -
- ); -} - -/** The diff pane body. `split` picks the side-by-side layout; otherwise each - * line is stacked inline. Hunk headers are shared by both. */ -function DiffView({ diff, split }: { diff: Diff; split: boolean }) { - return ( -
- {diff.hunks.length === 0 && ( -

No changes.

- )} - {diff.hunks.map((h, i) => ( -
-
- {h.file_path ? `${h.file_path} ` : ""} - {h.header} -
- {split ? ( - - ) : ( - h.lines.map((line, j) => ( -
- -
- )) - )} -
- ))} - {diff.truncated && ( -

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

- )} -
- ); -} - -/** git status XY codes, coloured by how much attention each deserves. */ -function statusColor(code: string) { - if (code === "?") return "text-ink-400"; - if (code === "D") return "text-removed"; - if (code === "A") return "text-added"; - return "text-accent"; -} - -/// Centred, branded loading indicator shown before the first repo list settles -/// (both at session start and while an empty catalog may still be populating). -function LoadingSplash() { - return ( -
-
- - - Loading… - -
-
- ); -} +import { useAccent } from "./theme"; +import { useSidebarWidth } from "./sidebar"; +import { useHotClock } from "./useHotClock"; +import { useRepoPoll } from "./hooks/useRepoPoll"; +import { useStatus } from "./hooks/useStatus"; +import { useSidebarDrag } from "./hooks/useSidebarDrag"; +import { usePaneOpeners } from "./hooks/usePaneOpeners"; +import { useLog } from "./hooks/useLog"; +import { useResumeTick } from "./hooks/useResumeTick"; +import { useMaximized } from "./hooks/useMaximized"; +import { useRepoActions } from "./hooks/useRepoActions"; +import { Header } from "./components/Header"; +import { RepoShell } from "./components/RepoShell"; +import { FolderPicker } from "./components/FolderPicker"; +import { LoadingSplash } from "./components/LoadingSplash"; +import { Login } from "./components/Login"; +import type { Pane, Tab } from "./types"; export function App() { const [authed, setAuthed] = useState(null); - const [repos, setRepos] = useState([]); - const [repo, setRepo] = useState(null); const [tab, setTab] = useState("status"); - const [status, setStatus] = useState(null); - // The commit log, accumulated a page at a time. `logDone` is set once the - // server reports no more history. Everything below resets together — see - // `resetLog`. - const [commits, setCommits] = useState([]); - const [logDone, setLogDone] = useState(false); - // A page failed. Kept apart from `logDone`, which means the history ended: - // conflating them would report a blip as the end of the log, and the error - // toast fades on its own, leaving nothing behind to say the list is short. - // This replaces the sentinel with a retry, which also stops a failing request - // from firing again on every scroll. - const [logStalled, setLogStalled] = useState(false); - // The commit the server walked from, echoed back on every following request - // so the pages describe one history. A ref, not state: it changes once, when - // the first page establishes it, and a fetcher rebuilt at that moment would - // re-arm the paging observer with no new row to justify it. - const logAnchorRef = useRef(null); - // Guards against two page requests overlapping: the sentinel can re-enter the - // viewport while a fetch is still out. - const logLoadingRef = useRef(false); - // Invalidates a page still in flight when the log it belongs to is discarded - // (another repo, another tab). Same shape as `paneRequestRef`, kept separate - // because the two invalidate on different events. - const logRequestRef = useRef(0); - const resetLog = useCallback(() => { - logRequestRef.current += 1; - logLoadingRef.current = false; - setCommits([]); - logAnchorRef.current = null; - setLogDone(false); - setLogStalled(false); - }, []); - const [commitDrillDown, setCommitDrillDown] = - useState(null); - // Lazy folder tree, mirroring the TUI: children are cached per directory - // ("" is the root) and fetched on demand, and the set of expanded directories - // derives the visible rows. - const [treeChildren, setTreeChildren] = useState>( - {}, - ); - const [treeExpanded, setTreeExpanded] = useState>(new Set()); - const [treeMatches, setTreeMatches] = useState([]); - const [treeTruncated, setTreeTruncated] = useState(false); - const [treeSearchLoading, setTreeSearchLoading] = useState(false); const [filter, setFilter] = useState(""); const [filterOpen, setFilterOpen] = useState(false); const [pane, setPane] = useState({ kind: "empty" }); - // Latest pane/tab for the status-activity effect, which reacts to new status - // snapshots and must not re-run when the pane changes (that would loop on its - // own re-fetch). - const paneRef = useRef(pane); - paneRef.current = pane; - const tabRef = useRef(tab); - tabRef.current = tab; - // How many commits are held, read by the page fetcher as the next page's - // offset. A ref so appending a page does not rebuild the fetcher and re-fire - // the effect that calls it. - const commitsRef = useRef(commits); - commitsRef.current = commits; - // Invalidates every in-flight request that would land in the pane. Bumped - // when a new one starts and whenever the context they were opened from is - // left (another commit, another tab, another repository), so a slow response + // Invalidates in-flight pane requests on context change so a slow response // cannot overwrite what the user is looking at now. const paneRequestRef = useRef(0); + const bumpPaneRequest = useCallback(() => { + paneRequestRef.current += 1; + }, []); const [pickerOpen, setPickerOpen] = useState(false); - // False until the repo list has been fetched for the current session. Gates - // the loading splash so the window between logging in and the first repo - // response does not flash the "No repository open" empty state. - const [reposLoaded, setReposLoaded] = useState(false); - // Maximize is a per-project layout choice: each repo remembers whether its - // files pane, terminal, or neither was maximized, so switching projects - // restores that project's own layout rather than carrying one over. - const [maximizedByRepo, setMaximizedByRepo] = useState< - Record - >({}); - const maximized: Maximized = (repo != null && maximizedByRepo[repo]) || "none"; - const setMaximized = useCallback( - (next: Maximized | ((prev: Maximized) => Maximized)) => { - if (repo == null) return; - setMaximizedByRepo((prev) => { - const current = prev[repo] ?? "none"; - const value = typeof next === "function" ? next(current) : next; - return { ...prev, [repo]: value }; - }); - }, - [repo], - ); - // The server's `agent_indicator` settings, which arrive with the repo list. - // Until they do, nothing is hot: guessing a window would flash a highlight - // that the real config might have turned off. - const [hot, setHot] = useState(null); - const hotWindowMs = hot?.enabled ? hot.window_secs * 1000 : 0; - // How far this device's clock sits from the server's, refreshed by the same - // poll that delivers the config above. `null` until the first response, when - // there is nothing to correct by yet. - const [clockSkewMs, setClockSkewMs] = useState(null); - const now = useHotClock(status?.files, hotWindowMs, clockSkewMs ?? 0); - // Ahead of the login/loading early returns below, so the stored accent - // applies to those screens too and not just the main view. + // Ahead of the login/loading early returns so the stored accent applies + // to those screens too. const { accent, next, cycle: cycleAccent, adopt: adoptAccent } = useAccent(); - // Counts local accent changes, so the repo poll can tell its response apart - // from one that predates the user's click. + // Counts local accent changes so the repo poll can ignore stale responses. const accentWrites = useRef(0); const cycle = useCallback(() => { accentWrites.current += 1; cycleAccent(); }, [cycleAccent]); - // The file sidebar's width, dragged by the divider between it and the diff - // pane. Shared across devices like the accent, with the same in-flight guard. + // Sidebar width, shared across devices like the accent with the same guard. const { width: sidebarWidth, resize: resizeSidebar, @@ -480,603 +61,131 @@ export function App() { sidebarWrites.current += 1; resetSidebar(); }, [resetSidebar]); - // Dragging the divider between the sidebar and the diff pane. The new width - // is the pointer's distance from the sidebar's left edge, captured once at - // drag start so a mid-drag re-layout cannot move the origin under the pointer. - const sidebarRef = useRef(null); - const dragOriginRef = useRef(0); - const dragStartXRef = useRef(0); - const dragWidthRef = useRef(0); - // Synchronous drag gate. The state below drives the cursor and overlay, but - // the move guard and the once-only commit read this ref so neither a - // Strict-Mode double-invoke nor the duplicate pointerup/lost-capture pair can - // fire the write twice, and the first move is not lost to a stale state read. - const draggingRef = useRef(false); - // Whether the pointer actually moved between down and up. A bare click must - // not commit: after a window shrink the displayed width is `min(px, 50vw)` - // while the stored width is still `px`, so committing the click would persist - // the capped value and quietly overwrite the shared preference. - const dragMovedRef = useRef(false); - // Timestamp of the last no-move release, so two quick clicks on the divider - // read as a double-click and reset the width. Detected here rather than via a - // native `ondblclick` because the drag's `preventDefault` on pointerdown can - // suppress the synthesized click/dblclick events. - const lastClickRef = useRef(0); - const [draggingSidebar, setDraggingSidebar] = useState(false); - const onSidebarDragStart = useCallback( - (e: ReactPointerEvent) => { - // Primary button / first touch only, matching a native dblclick: a - // right- or middle-click must not start a drag or arm the reset. - if (e.button !== 0 || !e.isPrimary) return; - const left = sidebarRef.current?.getBoundingClientRect().left; - if (left === undefined) return; - dragOriginRef.current = left; - dragStartXRef.current = e.clientX; - dragWidthRef.current = sidebarWidth; - draggingRef.current = true; - dragMovedRef.current = false; - // Bump the write counter now, not only on release: a poll that left - // before the drag must not adopt the old server width mid-drag and snap - // the pane out from under the pointer. - sidebarWrites.current += 1; - setDraggingSidebar(true); - e.currentTarget.setPointerCapture(e.pointerId); - e.preventDefault(); - }, - [sidebarWidth], - ); - const onSidebarDragMove = useCallback( - (e: ReactPointerEvent) => { - if (!draggingRef.current) return; - // Ignore movement until the pointer has travelled horizontally: a - // vertical-only move or touch jitter must not count as a resize, or it - // would commit the clientX-derived (viewport-capped) width and overwrite - // the shared absolute preference without the user meaning to. - if ( - !dragMovedRef.current && - Math.abs(e.clientX - dragStartXRef.current) < SIDEBAR_DRAG_THRESHOLD_PX - ) { - return; - } - dragMovedRef.current = true; - dragWidthRef.current = e.clientX - dragOriginRef.current; - resizeSidebar(dragWidthRef.current); - }, - [resizeSidebar], - ); - // Fires on both pointerup and lost capture; the ref gate commits exactly once, - // and only when the pointer actually moved (a bare click stores nothing). - const onSidebarDragEnd = useCallback(() => { - if (!draggingRef.current) return; - draggingRef.current = false; - setDraggingSidebar(false); - if (dragMovedRef.current) { - commitSidebarWidth(dragWidthRef.current); - lastClickRef.current = 0; - return; - } - // No move: a second quick click resets the width to the default; a lone - // click just arms the next one. - const now = Date.now(); - if (now - lastClickRef.current < DOUBLE_CLICK_MS) { - lastClickRef.current = 0; - resetSidebarWidth(); - } else { - lastClickRef.current = now; - } - }, [commitSidebarWidth, resetSidebarWidth]); - // A cancelled gesture (OS takeover, focus loss — mostly touch/pen) must not - // persist its partial position. Clear the gate without committing; the - // following lost-capture event then no-ops, and the next poll reconciles the - // partial width back to the server's last committed value. - const onSidebarDragCancel = useCallback(() => { - draggingRef.current = false; - dragMovedRef.current = false; - // Also disarm the double-click: a cancelled gesture is not a completed - // click, so it must not pair with the next one into a reset. - lastClickRef.current = 0; - setDraggingSidebar(false); + const bumpSidebarWrites = useCallback(() => { + sidebarWrites.current += 1; }, []); - const diffLayout = useDiffLayout(); - // Markdown files open rendered; this toggles to their raw source. Session-only - // (not persisted like the diff layout) — the rendered view is the common case, - // so each pane starts there rather than remembering a one-off peek at source. + const sidebarRef = useRef(null); + // Markdown files open rendered; toggles to raw source. Session-only — + // rendered is the common case so each pane starts there. const [mdRendered, setMdRendered] = useState(true); - // A failed call is either "log back in" or a message worth showing as a toast. + // "log back in" or a toast-worthy message. const handle = useCallback((err: unknown) => { if (isUnauthorized(err)) { setAuthed(false); return; } - // A network error (the device slept, a blip) already carries a friendly - // message (`NetworkError`), so a user-initiated load that failed still gets - // readable feedback here. The self-healing repo poll suppresses these before - // they reach `handle`, so a passive suspend/resume stays silent. + // NetworkError already carries a friendly message; the self-healing repo + // poll suppresses these before they reach here. toast.error(err instanceof Error ? err.message : "request failed"); }, []); - // Focus a repository the folder picker just opened. The picker performs the - // open and hands back the repo; select it right away rather than waiting for - // the next repo poll to notice it. - const selectOpenedRepo = useCallback((opened: Repo) => { - setRepos((prev) => - prev.some((r) => r.id === opened.id) ? prev : [...prev, opened], - ); - setRepo(opened.id); - setPane({ kind: "empty" }); - setTab("status"); - setPickerOpen(false); - }, []); - - // Close a project. The tab disappears immediately; if it was the selected - // one, focus falls back to another repo (or the empty state). - const closeRepo = useCallback( - async (id: string) => { - try { - await api.close(id); - const remaining = repos.filter((r) => r.id !== id); - setRepos(remaining); - setRepo((current) => - current === id ? (remaining[0]?.id ?? null) : current, - ); - setMaximizedByRepo(({ [id]: _closed, ...rest }) => rest); - } catch (err) { - handle(err); - } - }, - [repos, handle], - ); - - // Bumped when the tab comes back after the device slept or the network - // returned. A mobile browser suspends the page and drops the in-flight poll, - // so an immediate re-poll on resume refreshes at once instead of waiting out - // the interval. The status stream reconnects on its own (EventSource retries). - const [resumeTick, setResumeTick] = useState(0); - useEffect(() => { - const wake = () => { - if (document.visibilityState === "visible") setResumeTick((t) => t + 1); - }; - document.addEventListener("visibilitychange", wake); - window.addEventListener("online", wake); - return () => { - document.removeEventListener("visibilitychange", wake); - window.removeEventListener("online", wake); - }; - }, []); - - // The catalog follows the TUI: a tab opened or closed there changes what is - // served. Poll it so the tab bar tracks that without a reload — status has - // its own live stream, but the repo *list* has no event source of its own. - useEffect(() => { - let cancelled = false; - let timer: ReturnType | undefined; - // Abort the in-flight poll on teardown so a request the device suspended - // mid-flight is dropped rather than left hanging: without this, every - // resume would start a fresh poll on top of an abandoned one. - const controller = new AbortController(); - const refresh = () => { - // A poll that left before the user cycled the accent carries the old - // colour. Applying it when it lands would flicker the swatch back for a - // poll interval, so responses older than the last local change drop - // their accent. Everything else in them is still current. - const writes = accentWrites.current; - const widthWrites = sidebarWrites.current; - return api - .repos(controller.signal) - .then(({ repos: list, hot, accent, sidebar_width, now_ms }) => { - if (cancelled) return; - setHot(hot); - setClockSkewMs((held) => nextClockOffset(held, now_ms, Date.now())); - if (accentWrites.current === writes) adoptAccent(accent); - // Same guard as the accent, plus one more: a poll must not snap the - // sidebar back to the old server width while a drag is live (it may - // have started after the counter bumped) or after one it predates. - if (sidebarWrites.current === widthWrites && !draggingRef.current) - adoptSidebarWidth(sidebar_width); - setAuthed(true); - // We now hold the authoritative list for this session; the initial - // splash can give way to the shell (or the empty-state prompt). - setReposLoaded(true); - setRepos(list); - // Keep the current selection when it survives; otherwise fall back to - // the first repo, so closing the active tab in the TUI does not leave - // the page pointing at an id the server no longer knows. - setRepo((current) => - current && list.some((r) => r.id === current) - ? current - : (list[0]?.id ?? null), - ); - if (!cancelled) timer = setTimeout(refresh, REPO_POLL_MS); - }) - .catch((err) => { - if (cancelled) return; - if (isUnauthorized(err)) { - // The session is gone; a later login re-runs this effect (authed is - // a dep) and reloads the list, so show the splash again until then. - setAuthed(false); - setReposLoaded(false); - } else if (!isNetworkError(err)) { - // A dropped connection here is expected (the device slept, a blip); - // this loop retries every interval and the resume nudge re-polls at - // once, so stay silent and let it self-heal. Real errors still show. - handle(err); - } - timer = setTimeout(refresh, REPO_POLL_MS); - }); - }; - - // Re-runs when `authed` flips true on login, giving an immediate repo fetch - // rather than waiting up to a poll interval — otherwise the post-login - // screen would sit on the empty state until the next tick. - refresh(); - return () => { - cancelled = true; - controller.abort(); - if (timer) clearTimeout(timer); - }; - }, [authed, handle, adoptAccent, adoptSidebarWidth, resumeTick]); - - // Clear the status when the repo changes or the session re-authenticates, so - // the pane shows "Loading…" for the new context — but NOT on a resume - // re-subscribe below (which excludes these deps), so a wake keeps the last - // snapshot on screen until the fresh one replays. - useEffect(() => { - setStatus(null); - }, [repo, authed]); - - // Live status. The server replays the latest snapshot on subscribe, so this - // both seeds the view and keeps it current — no separate initial fetch. - // Re-subscribed on resume too: a mobile browser can leave the EventSource shut - // after a suspend instead of reconnecting, so a fresh subscription guarantees - // the stream (and the snapshot replay) come back when the tab does. - useEffect(() => { - if (!repo || !authed) return; - return subscribeStatus(repo, setStatus); - }, [repo, authed, resumeTick]); + const resumeTick = useResumeTick(); - // Keep the status tab's open diff honest when the working tree changes under - // it (a commit lands, files are staged/edited): reload it in place if its file - // is still changed, drop it if the file left the list — the same rule the TUI - // applies on a status refresh. Log and tree panes show history or raw file - // contents, which working-tree activity does not invalidate, so they are left - // untouched. Keyed on `status` only; pane/tab are read through refs so the - // effect does not re-fire on its own re-fetch. - useEffect(() => { - if (!repo || !status) return; - const current = paneRef.current; - if (tabRef.current !== "status" || current.kind !== "diff") return; - const path = current.value.path; - if (!status.files.some((f) => f.path === path)) { - setPane({ kind: "empty" }); - return; - } - // Reads the request counter without bumping it: this refresh is on the - // pane the user is already looking at, so it yields to anything they open - // while it is in flight rather than invalidating their click. - const request = paneRequestRef.current; - // Two snapshots arriving close together would otherwise both reload the - // same path against the same counter, and the slower of the two could land - // last with the older content. The next snapshot re-runs this effect, so - // its cleanup is what retires the previous refresh. - let active = true; - // Three conditions, because the counter alone answers the wrong question. - // It says "no newer request has started", not "the pane is still the one - // being refreshed" — and those come apart: opening B raises the counter, - // then a snapshot arrives while A is still on screen, so this refresh of A - // captures *B's* number and outlives B's own response. Checking that the - // rendered pane is still this path is what keeps a background reload from - // undoing the file the user just clicked. - const stillOurs = () => { - const shown = paneRef.current; - return ( - active && - request === paneRequestRef.current && - shown.kind === "diff" && - shown.value.path === path - ); - }; - api - .diff(repo, path) - .then((v) => { - if (stillOurs()) setPane({ kind: "diff", value: v }); - }) - .catch((err) => { - if (stillOurs()) handle(err); - }); - return () => { - active = false; - }; - }, [status, repo, handle]); + const { + draggingSidebar, + onSidebarDragStart, + onSidebarDragMove, + onSidebarDragEnd, + onSidebarDragCancel, + draggingRef, + } = useSidebarDrag({ + sidebarRef, + sidebarWidth, + resizeSidebar, + commitSidebarWidth, + resetSidebarWidth, + bumpSidebarWrites, + }); - // Fetch one page of the log and append it. The first call (no anchor yet) - // establishes the anchor from the server's answer; later ones pin to it. - const loadLogPage = useCallback(async () => { - if (!repo || logLoadingRef.current) return; - logLoadingRef.current = true; - const request = logRequestRef.current; - try { - const anchor = logAnchorRef.current; - const page = await api.log( - repo, - anchor === null - ? undefined - : { from: anchor, skip: commitsRef.current.length }, - ); - if (request !== logRequestRef.current) return; - setCommits((held) => [...held, ...page.commits]); - logAnchorRef.current = page.head ?? null; - // No anchor to page from (an empty repository) is also the end of it. - setLogDone(!page.truncated || page.head === undefined); - } catch (err) { - if (request === logRequestRef.current) { - handle(err); - setLogStalled(true); - } - } finally { - if (request === logRequestRef.current) logLoadingRef.current = false; - } - }, [repo, handle]); + const { + repos, + setRepos, + repo, + setRepo, + hot, + clockSkewMs, + reposLoaded, + } = useRepoPoll({ + authed, + setAuthed, + handle, + adoptAccent, + adoptSidebarWidth, + draggingRef, + accentWrites, + sidebarWrites, + resumeTick, + }); - // Entering the log tab loads the first page; the sentinel below the list asks - // for the rest as it comes into view. - useEffect(() => { - if (!repo || !authed || tab !== "log") return; - if (commits.length === 0 && !logDone && !logStalled) void loadLogPage(); - // `commits.length` and not the ref: switching repositories runs this and - // the effect that empties the list in declaration order, so reading the ref - // here would see the previous repository's commits, decline to fetch, and - // leave an empty list nothing would refill — the sentinel that would - // normally rescue it is not rendered while a filter is up. Depending on the - // state instead re-runs this once the reset lands, whatever the order. - }, [repo, authed, tab, commits.length, logDone, logStalled, loadLogPage]); + const hotWindowMs = hot?.enabled ? hot.window_secs * 1000 : 0; + // The hot clock ticks against the status snapshot's mtimes; until a snapshot + // arrives it reads everything as cool. Kept ahead of the early returns so + // the hook order is stable across auth/loading states. + useHotClock(undefined, hotWindowMs, clockSkewMs ?? 0); + + const { status } = useStatus({ + repo, + authed, + resumeTick, + tab, + pane, + setPane, + handle, + paneRequestRef, + }); + const now = useHotClock(status?.files, hotWindowMs, clockSkewMs ?? 0); - // The commit rows the log tab renders. Derived up here, ahead of the sibling - // list filters below, because the paging observer keys on how many there are. - const visibleCommits = commits.filter((c) => - c.summary.toLowerCase().includes(filter.toLowerCase()), - ); - // A filter narrows the commits already loaded; it is not a server search. So - // it also stops the paging, rather than quietly walking the whole history a - // page at a time hunting for matches — which is what keying the observer on - // the rendered count alone would still do whenever a page happened to contain - // one. The list says so where the sentinel would have been. - const logPagingPaused = filter !== ""; + const { maximized, setMaximized, dropMaximized } = useMaximized(repo); - // Watch the row that sits under the last commit. `rootMargin` starts the - // fetch a screen early, so scrolling reaches loaded rows rather than the - // placeholder. The sentinel is only rendered while more history exists, so an - // exhausted log detaches this instead of polling. - // - // Rebuilt whenever the rendered list grows, because an observer reports - // *changes* in intersection and an appended page need not produce one — the - // sentinel can stay exactly where it is, in view, with history left to load. - // Re-observing re-reports the current state, continuing the paging until the - // sentinel is genuinely pushed out of view. - // - // Keyed on the *rendered* count rather than the loaded one, which is what - // stops a filter from running away with this: a page whose commits the filter - // hides adds no rows, so it does not re-arm, and the chain stops instead of - // walking the whole history a page at a time looking for a match. The log - // filter narrows what is loaded — the same contract the TUI's has. - const logSentinelRef = useRef(null); - useEffect(() => { - const sentinel = logSentinelRef.current; - if (!sentinel) return; - const observer = new IntersectionObserver( - (entries) => { - if (entries.some((e) => e.isIntersecting)) void loadLogPage(); - }, - { root: sentinel.closest("ul"), rootMargin: "400px" }, - ); - observer.observe(sentinel); - return () => observer.disconnect(); - }, [ - loadLogPage, + const { + commits, logDone, logStalled, - logPagingPaused, + setLogStalled, commitDrillDown, - tab, - visibleCommits.length, - ]); + setCommitDrillDown, + resetLog, + logSentinelRef, + visibleCommits, + logPagingPaused, + } = useLog({ repo, authed, tab, filter, handle }); + + const { openDiff, openFile, openCommit, openCommitFileDiff, openCommitFiles } = + usePaneOpeners({ + repo, + handle, + setPane, + paneRequestRef, + setCommitDrillDown, + }); - // Everything on screen below the header belongs to one repository; drop it - // when the repo changes. This effect rather than the click handlers is where - // it belongs, because not every switch comes from a click: closing the active - // project in the TUI drops it from the poll, and the list falls back to - // another repo on its own. Clearing only where the user clicked would leave - // that path showing the old repository's file in the pane. + const { selectOpenedRepo, closeRepo } = useRepoActions({ + repos, + setRepos, + setRepo, + setPane, + setTab, + setPickerOpen, + dropMaximized, + handle, + }); + + // Drop everything below the header when the repo changes — not every switch + // is a click (closing the active project in the TUI drops it from the poll). useEffect(() => { - setTreeChildren({}); - setTreeExpanded(new Set()); - paneRequestRef.current += 1; + bumpPaneRequest(); setCommitDrillDown(null); setPane({ kind: "empty" }); resetLog(); - }, [repo, resetLog]); + }, [repo, resetLog, bumpPaneRequest, setCommitDrillDown]); - // Load (and refresh) the root level whenever the tree tab is shown; deeper - // levels are fetched lazily as folders expand, and expansion state is kept - // across tab switches. - useEffect(() => { - if (!repo || !authed || tab !== "tree") return; - api - .tree(repo, "") - .then((r) => setTreeChildren((cache) => ({ ...cache, "": r.entries }))) - .catch(handle); - }, [repo, authed, tab, handle]); - - // Recursive tree search runs against the backend (unlike the status/log - // filters, which match an already-loaded list client-side), so it is debounced - // and only active while the filter box holds a query on the tree tab. - useEffect(() => { - if (!repo || !authed || tab !== "tree" || !filterOpen || !filter) { - setTreeMatches([]); - setTreeTruncated(false); - setTreeSearchLoading(false); - return; - } - // Mark loading up front so the debounce window shows "searching…" rather - // than a premature "no matches" before the first result lands. - setTreeSearchLoading(true); - // Guard against out-of-order responses: a slower earlier request must not - // overwrite a newer one's results, and nothing may update state after the - // query changed or the tab/repo was left. - let active = true; - const timer = setTimeout(() => { - api - .treeSearch(repo, filter) - .then((r) => { - if (!active) return; - setTreeMatches(r.matches); - setTreeTruncated(r.truncated); - }) - .catch((err) => { - if (active) handle(err); - }) - .finally(() => { - if (active) setTreeSearchLoading(false); - }); - }, TREE_SEARCH_DEBOUNCE_MS); - return () => { - active = false; - clearTimeout(timer); - }; - }, [repo, authed, tab, filter, filterOpen, handle]); - - const openDiff = (path: string) => { - if (!repo) return; - const request = ++paneRequestRef.current; - api - .diff(repo, path) - .then((v) => { - if (request === paneRequestRef.current) setPane({ kind: "diff", value: v }); - }) - .catch((err) => { - if (request === paneRequestRef.current) handle(err); - }); - }; - const openFile = (path: string) => { - if (!repo) return; - const request = ++paneRequestRef.current; - api - .file(repo, path) - .then((v) => { - if (request === paneRequestRef.current) setPane({ kind: "file", value: v }); - }) - .catch((err) => { - if (request === paneRequestRef.current) handle(err); - }); - }; - const openCommit = (oid: string) => { - if (!repo) return; - const request = ++paneRequestRef.current; - api - .commit(repo, oid) - .then((v) => { - if (request === paneRequestRef.current) setPane({ kind: "diff", value: v }); - }) - .catch((err) => { - if (request === paneRequestRef.current) handle(err); - }); - }; - const openCommitFileDiff = (oid: string, path: string) => { - if (!repo) return; - const request = ++paneRequestRef.current; - api - .commitFileDiff(repo, oid, path) - .then((v) => { - if (request === paneRequestRef.current) setPane({ kind: "diff", value: v }); - }) - .catch((err) => { - if (request === paneRequestRef.current) handle(err); - }); - }; - const openCommitFiles = async (commit: Commit) => { - if (!repo) return; - const request = ++paneRequestRef.current; - try { - const result = await api.commitFiles(repo, commit.oid); - if (request !== paneRequestRef.current) return; - setCommitDrillDown({ commit, ...result }); - if (result.files.length === 0) { - setPane({ kind: "empty" }); - return; - } - // Match the TUI's selection state: entering a commit drill-down keeps - // the complete commit diff visible. Choosing a row below narrows the - // pane to that file only. - const diff = await api.commit(repo, commit.oid); - if (request === paneRequestRef.current) { - setPane({ kind: "diff", value: diff }); - } - } catch (err) { - if (request === paneRequestRef.current) handle(err); - } - }; - - // Fetch one directory level into the cache (used the first time a folder is - // expanded or revealed). - const loadTreeChildren = (path: string) => { - if (!repo) return; - api - .tree(repo, path) - .then((r) => setTreeChildren((cache) => ({ ...cache, [path]: r.entries }))) - .catch(handle); - }; - const toggleTreeDir = (path: string) => { - const willExpand = !treeExpanded.has(path); - setTreeExpanded((prev) => { - const next = new Set(prev); - if (next.has(path)) next.delete(path); - else next.add(path); - return next; - }); - if (willExpand && !(path in treeChildren)) loadTreeChildren(path); - }; - // Reveal a path found by search: expand every ancestor directory (fetching - // levels as needed) and the directory itself, then leave the search view. - const revealTreeDir = (path: string) => { - const parts = path.split("/"); - const dirs: string[] = []; - let acc = ""; - for (const part of parts) { - acc = acc ? `${acc}/${part}` : part; - dirs.push(acc); - } - setTreeExpanded((prev) => { - const next = new Set(prev); - dirs.forEach((d) => next.add(d)); - return next; - }); - dirs.forEach((d) => { - if (!(d in treeChildren)) loadTreeChildren(d); - }); - setFilter(""); - setFilterOpen(false); - }; - - if (authed === null) { - // Initial load: determining the session and fetching the repo list. Show a - // centred, branded screen so the app fades in from here rather than the - // content snapping onto a blank page. - return ; - } - if (!authed) { - return setAuthed(true)} />; - } - if (!reposLoaded) { - // Authenticated but the repo list has not arrived yet (notably the moment - // right after login). Hold the splash rather than flash the empty state. - return ; - } - // An empty catalog is a real state (the TUI can run with no project open, and - // `serve` starts empty). Render the normal shell anyway — the header's - // "+ open" is the way in — rather than a separate full-screen prompt. + if (authed === null) return ; + if (!authed) return setAuthed(true)} />; + if (!reposLoaded) return ; + // Empty catalog is a real state — render the shell so the header's "+ open" + // is the way in. const current = repos.find((r) => r.id === repo); - // One filter box drives whichever tab is active; each list matches the query - // against its own natural field (path / commit text / entry name). const q = filter.toLowerCase(); const files = (status?.files ?? []).filter((f) => f.path.toLowerCase().includes(q), @@ -1085,19 +194,13 @@ export function App() { f.path.toLowerCase().includes(q) || f.old_path?.toLowerCase().includes(q), ); - // The log is newest-first, so the first `ahead` commits are the unpushed ones - // — mark them like the TUI does. + // Log is newest-first; first `ahead` commits are unpushed. const aheadOids = new Set( commits.slice(0, status?.tracking?.ahead ?? 0).map((c) => c.oid), ); - // The tree tab searches the whole repo server-side when the filter holds a - // query; otherwise it shows the lazily-expanded folder tree. - const treeSearching = tab === "tree" && filterOpen && filter !== ""; - const treeRows = buildTreeRows(treeChildren, treeExpanded); - // Maximising collapses the losing row to nothing rather than unmounting it, - // so the row count keeps matching the template and the panel comes back - // scrolled where it was. + // Maximising collapses the losing row to 0fr (not unmount) so the panel + // comes back scrolled where it was. const filesMax = maximized === "files"; const rows = !repo ? "grid-rows-[auto_1fr]" @@ -1110,601 +213,74 @@ export function App() { return (
- {/* Pinned in px to render identically to the web mirror's header - (src/web/frontend/app.html), which sits on a 16px root while this app - runs at 14px — matching rem values there lands 12.5% smaller and reads - as chrome rather than a wordmark. Deliberately opted out of the density - knob in index.css: the header is shared branding across both services, - not content that should thin out as the UI gets denser. The tag drops - to the sans stack, as the mirror does, so it reads as a label against - the mono wordmark rather than more of the same. */} -
- - nightcrow - - web viewer - - { - setRepo(id); - setPane({ kind: "empty" }); - }} - onCloseProject={closeRepo} - onOpenPicker={() => setPickerOpen(true)} - /> - {/* Editor tabs, after VS Code's: square, touching, and stretched to the - full height of the bar they sit in — a tab is a tab because it fills - its strip, not because it is a labelled box. The negative margins eat - the header's padding to reach that height, and the active one takes - the body colour so it reads as the near edge of the content below. - The accent marker is an inset shadow rather than a border, which - would shift the label down by its own width. - - VS Code also lets the active tab overlap the rule under the bar, so - the two areas merge outright. Not done here: this strip scrolls - sideways when enough projects are open, and a scroll container clips - both axes — the overlap would be cut off and could raise a vertical - scrollbar besides. */} - - {/* The plus is the same drawn mark the terminal panel's add button uses, - not the `+` character, so the app has one plus rather than two that - disagree on weight. Sized to the label beside it — the convention the - project tabs' close glyph already follows — rather than to the 16px - of an icon-only control. */} - - {/* Cycles rather than opening a picker, matching the TUI's - ` p`. The swatch is the current accent, so the control - doubles as the indicator. */} - - - sign out - -
+ {/* Pinned in px to match the web mirror's header (16px root vs this app's + 14px). Deliberately opted out of the density knob — the header is + shared branding, not content. */} +
{ + setRepo(id); + setPane({ kind: "empty" }); + }} + setPane={() => setPane({ kind: "empty" })} + closeRepo={closeRepo} + setPickerOpen={setPickerOpen} + accent={accent} + next={next} + cycle={cycle} + /> {repo ? ( - <> - {/* While the divider is dragged, this overlay holds the resize cursor - across the whole window and keeps a stray text selection from - starting. Pointer capture routes the move/up events to the handle - regardless, so the overlay is purely visual. */} - {draggingSidebar && ( -
- )} - {/* The width rides on a custom property so the responsive rule stays - declarative — below md the grid collapses to one column, leaving - the stacked layout untouched. Maximising the file pane drives the - property to zero rather than dropping the sidebar, so its content - is not torn down and rebuilt on every toggle. */} -
- {/* Two mechanisms collapse the sidebar when maximised, one per breakpoint. - At md+ it stays in the grid (md:flex) and the --nc-sidebar:0px column - hides it — display:none here would drop it from grid placement and - shift the file pane into the 0px track, collapsing it to nothing. - Below md the grid is a single column that ignores --nc-sidebar, so - there `hidden` is what removes it. */} -
- {/* Drag the divider to resize the sidebar, double-click to reset it. - A thin strip over the right border, only at md+ (below it the - layout is a single stacked column) and only when the pane is not - maximised. Pointer capture keeps the drag alive over the diff pane; - the overlay below carries the resize cursor across the whole window - while it lasts. */} - {!filesMax && ( -
- - {/* Header outside the scroll box, body inside — the same split the file - list on the left uses. Pinning it from inside the scroll box instead - would only hold it vertically, letting a long code line carry the - path off to the left; this holds it on both axes. */} - {/* `min-w-0` is load-bearing: a grid item defaults to min-width:auto, so - without it this column refuses to shrink below the widest line in the - pre and pushes the layout off-screen instead of scrolling inside. */} -
- {/* Always rendered, even with nothing open: it carries the maximise - control, and a header that came and went with the selection would - shift the pane under the cursor. Only a file view is labelled with - its path here — a diff already prints each file path in its hunk - headers, and a commit diff's `path` is the commit oid, which would - read as a bogus file name. */} -
- {pane.kind === "file" && } -
- {pane.kind === "diff" && ( - - )} - {pane.kind === "file" && isMarkdownPath(pane.value.path) && ( - - )} - -
-
-
- {pane.kind === "empty" && ( -

- {status === null ? "Loading…" : "Select a file or commit."} -

- )} - {pane.kind === "file" && ( - <> - {isMarkdownPath(pane.value.path) && mdRendered ? ( - Rendering…

} - > - -
- ) : ( -
-                    {pane.value.lines.map((line, i) => (
-                      
- {line.length === 0 - ? " " - : line.map((s, j) => ( - - {s.t} - - ))} -
- ))} -
- )} - {pane.value.truncated && ( -

- File truncated — it exceeded the server's size ceiling. -

- )} - - )} - {pane.kind === "diff" && ( - - )} -
-
-
- - {repo && ( - - - setMaximized((m) => (m === "terminal" ? "none" : "terminal")) - } - /> - - )} - -
- {current?.display_path} - {status?.branch && {status.branch}} - {status?.tracking && ( - - ↑{status.tracking.ahead} ↓{status.tracking.behind} - - )} - - {status ? ( - ● live - ) : ( - "connecting…" - )} - -
- + ) : (
@@ -1721,339 +297,4 @@ export function App() { )}
); -} - -/** Narrow-screen stand-in for the header's project tabs: a dropdown listing the - * open projects (tap to switch, × to close) plus "+ open". Rendered only below - * md (the caller hides it wider, where the tab row takes over). Closes on an - * outside click — a transparent backdrop, the same mechanism `FolderPicker`'s - * overlay uses — or on Escape, which returns focus to the trigger. */ -function ProjectMenu({ - repos, - currentId, - onSelect, - onCloseProject, - onOpenPicker, - className = "", -}: { - repos: Repo[]; - currentId: string | null; - onSelect: (id: string) => void; - onCloseProject: (id: string) => void; - onOpenPicker: () => void; - className?: string; -}) { - const [open, setOpen] = useState(false); - const triggerRef = useRef(null); - const current = repos.find((r) => r.id === currentId); - - useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") { - setOpen(false); - triggerRef.current?.focus(); - } - }; - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [open]); - - return ( -
- - {open && ( - <> -
setOpen(false)} /> -
- {repos.length === 0 && ( -

No projects open.

- )} - {repos.map((r) => ( -
- - -
- ))} -
- -
- - )} -
- ); -} - -/** A server-side folder browser (code-server style): navigate the machine the - * viewer runs on and open a directory as a project. The OS file dialog cannot - * do this — it would pick paths on the viewer's machine, not the server's. */ -function FolderPicker({ - onClose, - onOpened, -}: { - onClose: () => void; - onOpened: (repo: Repo) => void; -}) { - // `null` means "the server's default" (home); the server resolves it. - const [path, setPath] = useState(null); - const [dir, setDir] = useState(null); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - const [newName, setNewName] = useState(""); - const [creating, setCreating] = useState(false); - // Bumped to re-browse the current path without navigating (e.g. after a - // folder is created so it shows up in the listing). - const [reload, setReload] = useState(0); - - useEffect(() => { - let cancelled = false; - api - .browse(path ?? undefined) - .then((d) => { - if (!cancelled) { - setDir(d); - setError(null); - } - }) - .catch((err) => { - if (!cancelled) - setError(err instanceof Error ? err.message : "could not browse"); - }); - return () => { - cancelled = true; - }; - }, [path, reload]); - - const into = (name: string) => - setPath(`${dir!.path.replace(/\/$/, "")}/${name}`); - - const openHere = async () => { - if (!dir) return; - setBusy(true); - try { - onOpened(await api.open(dir.path)); - } catch (err) { - toast.error(err instanceof Error ? err.message : "could not open"); - setBusy(false); - } - }; - - // Create a folder in the directory being browsed and refresh the listing so - // it appears. Stays put rather than stepping in — the new folder is empty - // (not a git repo yet), so the user can keep browsing or step in manually. - const createFolder = async () => { - if (!dir) return; - const name = newName.trim(); - if (!name) return; - setCreating(true); - try { - await api.mkdir(dir.path, name); - setNewName(""); - setReload((n) => n + 1); - } catch (err) { - toast.error(err instanceof Error ? err.message : "could not create folder"); - } finally { - setCreating(false); - } - }; - - return ( -
-
e.stopPropagation()} - > -
- Open a project - -
-
- {dir?.path ?? "…"} -
-
    - {dir?.parent && ( -
  • - -
  • - )} - {dir?.entries.map((e) => ( -
  • - -
  • - ))} - {dir && dir.entries.length === 0 && ( -
  • No sub-folders.
  • - )} -
- {error &&

{error}

} -
- setNewName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") createFolder(); - }} - placeholder="New folder name" - aria-label="new folder name" - className="min-w-0 flex-1 rounded-sm border border-ink-700 bg-ink-950 px-2 py-1 text-ink-50 placeholder:text-ink-400 focus:border-ink-600 focus:outline-none" - /> - -
-
- - {dir ? dir.path : ""} - - -
-
-
- ); -} - -/** The nightcrow mark: a black crow on a rounded accent tile. Shared with the - * web mirror's login/header so the two services read as one product. The tile - * is `bg-accent` rather than a baked colour, so it follows the runtime accent - * as the TUI's splash logo does (`ui/splash.rs` colours it with `accent`); the - * crow itself is the shared `crow-mono.svg` silhouette (transparent - * background) overlaid on top. The favicon keeps its own off-white-tiled - * `crow.svg`, which reads better against an arbitrary browser tab bar. */ -function Mark({ className }: { className?: string }) { - return ( - - - - ); -} - -function Login({ onSuccess }: { onSuccess: () => void }) { - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - - const submit = async (event: React.FormEvent) => { - event.preventDefault(); - setBusy(true); - setError(null); - try { - await api.login(password); - onSuccess(); - } catch (err) { - setError(err instanceof Error ? err.message : "login failed"); - } finally { - setBusy(false); - } - }; - - return ( -
-
- -

- nightcrow -

-

- web viewer -

- {error &&

{error}

} - setPassword(e.target.value)} - placeholder="password" - className="mb-2 w-full rounded-md border border-ink-700 bg-ink-900 px-2.5 py-1.5 outline-none placeholder:text-ink-400 focus:border-accent focus:ring-[3px] focus:ring-accent/15" - /> - - -
- ); -} +} \ No newline at end of file diff --git a/viewer-ui/src/Terminal.tsx b/viewer-ui/src/Terminal.tsx index bbed216..759cd62 100644 --- a/viewer-ui/src/Terminal.tsx +++ b/viewer-ui/src/Terminal.tsx @@ -1,116 +1,10 @@ import { useEffect, useRef, useState, type CSSProperties } from "react"; -import { Terminal } from "@xterm/xterm"; -import { FitAddon } from "@xterm/addon-fit"; -import { MaximizeIcon, PlusIcon, XIcon } from "./icons"; -import { reconcileOrder, reorderByDrop } from "./paneOrder"; -import { toast } from "./toast"; - -interface PaneView { - term: Terminal; - fit: FitAddon; -} - -/// Pane titles are capped by display width (not character count) so a title of -/// wide CJK glyphs cannot overflow its cell header; the full title stays -/// reachable through the tooltip. Matches the viewer's label convention. -const TAB_TITLE_MAX_CELLS = 20; - -/// Pointer travel before a header press becomes a pane drag rather than a click -/// that just focuses the pane. Mirrors the sidebar divider's small dead zone. -const PANE_DRAG_THRESHOLD_PX = 4; - -function gcd(a: number, b: number): number { - while (b) [a, b] = [b, a % b]; - return a; -} - -/// Columns per row for `n` panes, mirroring the TUI's `grid_row_plan` -/// (src/ui/terminal_tab.rs): a balanced grid, with the two-pane case flipping to -/// stacked when the panel is taller than it is wide. -function rowPlan(n: number, wide: boolean): number[] { - switch (n) { - case 1: - return [1]; - case 2: - return wide ? [2] : [1, 1]; - case 3: - return [2, 1]; - case 4: - return [2, 2]; - case 5: - return [3, 2]; - case 6: - return [3, 3]; - case 7: - return [4, 3]; - default: - return [4, 4]; // 8 (the per-repo cap); also a sane fallback beyond it - } -} - -interface CellPlacement { - row: number; - colStart: number; - colSpan: number; -} - -/// Flatten `rowPlan` into a CSS-grid placement per pane. Rows can hold different -/// column counts (e.g. 3 = [2,1]); a shared column count (the LCM of the rows' -/// counts) lets each cell span evenly so every row fills the width. -function planLayout( - n: number, - wide: boolean, -): { cols: number; rows: number; cells: CellPlacement[] } { - const plan = rowPlan(n, wide); - const cols = plan.reduce((acc, c) => (acc * c) / gcd(acc, c), 1); - const cells: CellPlacement[] = []; - plan.forEach((count, r) => { - const span = cols / count; - for (let k = 0; k < count; k++) { - cells.push({ row: r + 1, colStart: k * span + 1, colSpan: span }); - } - }); - return { cols, rows: plan.length, cells }; -} - -/// True for code points that occupy two terminal cells. An approximation of the -/// common East Asian wide / fullwidth ranges — enough to keep CJK titles from -/// overflowing without pulling in a full Unicode width table. -function isWide(cp: number): boolean { - return ( - (cp >= 0x1100 && cp <= 0x115f) || - (cp >= 0x2e80 && cp <= 0x303e) || - (cp >= 0x3041 && cp <= 0x33ff) || - (cp >= 0x3400 && cp <= 0x4dbf) || - (cp >= 0x4e00 && cp <= 0x9fff) || - (cp >= 0xa000 && cp <= 0xa4cf) || - (cp >= 0xac00 && cp <= 0xd7a3) || - (cp >= 0xf900 && cp <= 0xfaff) || - (cp >= 0xfe30 && cp <= 0xfe4f) || - (cp >= 0xff00 && cp <= 0xff60) || - (cp >= 0xffe0 && cp <= 0xffe6) || - (cp >= 0x1f300 && cp <= 0x1faff) || - (cp >= 0x20000 && cp <= 0x3fffd) - ); -} - -/// Truncate `text` to at most `max` display cells, appending an ellipsis (which -/// costs one cell) when anything was dropped. -function truncateCells(text: string, max: number): string { - let width = 0; - for (const ch of text) width += isWide(ch.codePointAt(0) ?? 0) ? 2 : 1; - if (width <= max) return text; - - let used = 0; - let out = ""; - for (const ch of text) { - const cw = isWide(ch.codePointAt(0) ?? 0) ? 2 : 1; - if (used + cw > max - 1) break; - out += ch; - used += cw; - } - return `${out}…`; -} +import { MaximizeIcon, PlusIcon } from "./icons"; +import { planLayout, type PaneView } from "./terminalLayout"; +import { usePaneDrag } from "./usePaneDrag"; +import { useTerminalSocket } from "./useTerminalSocket"; +import { useTerminalViews } from "./useTerminalViews"; +import { TerminalCell } from "./TerminalCell"; /** * One WebSocket multiplexes every terminal for a repository. @@ -168,176 +62,32 @@ export function TerminalPanel({ // Per-pane title from the shell's OSC 0/2 sequence (parsed by xterm.js), so a // cell reads e.g. "claude" or "vim README" instead of a bare "term 2". const [titles, setTitles] = useState>({}); - // Pane drag-to-reorder. The id being dragged and the drop target live in refs - // (read on pointerup, free of stale-closure risk); the mirrored state only - // drives the drag styling. `draggingRef` flips once the pointer crosses the - // dead zone, separating a reorder from a plain header click. - const dragPaneRef = useRef(null); - const dragStartRef = useRef<{ x: number; y: number } | null>(null); - const dragOverRef = useRef(null); - const draggingRef = useRef(false); - const [draggingPane, setDraggingPane] = useState(null); - const [dragOverPane, setDragOverPane] = useState(null); - - // One socket per repo. Pane ids belong to a repository's own terminal hub, so - // switching repos must reset the pane list and dispose the old terminals — - // otherwise stale ids point at panes the new repo never created. - useEffect(() => { - let closedByUs = false; - let reconnectTimer: ReturnType | undefined; - - const disposeAll = () => { - viewsRef.current.forEach((view) => view.term.dispose()); - viewsRef.current.clear(); - pendingRef.current.clear(); - sentSizesRef.current.clear(); - }; - - const connect = () => { - // Each (re)connection starts from a clean slate and lets the server - // repopulate it: on connect the hub replays every live pane and its - // scrollback, so a browser refresh restores the terminals while a server - // restart (no panes to replay) correctly comes back empty. Keeping stale - // local panes would instead point at terminals the new socket never - // announced. - setPanes([]); - setActive(null); - setZoomed(null); - setTitles({}); - disposeAll(); - - const scheme = location.protocol === "https:" ? "wss:" : "ws:"; - const socket = new WebSocket( - `${scheme}//${location.host}/ws/term?repo=${encodeURIComponent(repo)}`, - ); - socket.binaryType = "arraybuffer"; - socketRef.current = socket; - - socket.onmessage = (event) => { - if (typeof event.data === "string") { - const message = JSON.parse(event.data); - if (message.type === "created") { - const pane = message.pane; - setPanes((current) => [...current, pane]); - if (expectCreateRef.current > 0) { - // A terminal this client just asked for: focus follows creation. - expectCreateRef.current -= 1; - setActive(pane); - lastActiveByRepoRef.current.set(repo, pane); - } else if (lastActiveByRepoRef.current.get(repo) === pane) { - // A replayed pane that was focused before switching away — restore - // it rather than letting focus land on the last replayed pane. - setActive(pane); - } - } else if (message.type === "exited") { - setPanes((current) => current.filter((p) => p !== message.pane)); - setActive((current) => (current === message.pane ? null : current)); - setZoomed((current) => - current === message.pane ? null : current, - ); - pendingRef.current.delete(message.pane); - sentSizesRef.current.delete(message.pane); - setTitles((current) => { - if (!(message.pane in current)) return current; - const next = { ...current }; - delete next[message.pane]; - return next; - }); - } else if (message.type === "reordered") { - // The hub's canonical order after a drag — this client's or another - // device's. Adopt it, reconciled against the panes we actually hold - // so a "created"/"exited" that raced it cannot desync the grid. - // active/zoomed are pane ids, so they survive the reorder untouched. - setPanes((current) => reconcileOrder(current, message.order)); - } else if (message.type === "error") { - // A create was refused (e.g. the per-repo cap); do not let the - // pending focus-follow attach to an unrelated later "created". - expectCreateRef.current = 0; - toast.error(message.message); - } - return; - } - const frame = new Uint8Array(event.data as ArrayBuffer); - if (frame.length < 4) return; - const pane = new DataView(frame.buffer).getUint32(0, true); - const bytes = frame.subarray(4); - const view = viewsRef.current.get(pane); - if (view) { - view.term.write(bytes); - } else { - // The view is created by a later effect; hold this until then. - const queue = pendingRef.current.get(pane) ?? []; - queue.push(bytes); - pendingRef.current.set(pane, queue); - } - }; - // Reconnect quietly. The control socket is always open — it is how a - // terminal gets created — so a drop with nothing running is not worth - // alarming the user about; just wait and retry. A restart thus heals - // into a clean, empty panel rather than a stuck error. - socket.onclose = () => { - if (closedByUs) return; - reconnectTimer = setTimeout(connect, 1000); - }; - }; - - connect(); - return () => { - closedByUs = true; - if (reconnectTimer) clearTimeout(reconnectTimer); - socketRef.current?.close(); - disposeAll(); - }; - }, [repo]); + useTerminalSocket({ + repo, + socketRef, + viewsRef, + pendingRef, + sentSizesRef, + lastActiveByRepoRef, + expectCreateRef, + setPanes, + setActive, + setZoomed, + setTitles, + }); // Materialise one xterm per pane, opened into that pane's cell body (rendered // below, keyed by pane so it survives grid reflows). `open()` runs once here; // dispose the views of panes that have gone away. - useEffect(() => { - for (const pane of panes) { - if (viewsRef.current.has(pane)) continue; - const body = bodyRefs.current.get(pane); - if (!body) continue; // its cell has not mounted yet; a later pass catches it - - const term = new Terminal({ - fontFamily: getComputedStyle(document.body).fontFamily, - fontSize: 12, - theme: { background: "#0b0b0d", foreground: "#e6e6ec" }, - cursorBlink: true, - }); - const fit = new FitAddon(); - term.loadAddon(fit); - term.onData((data) => - socketRef.current?.send(JSON.stringify({ type: "input", pane, data })), - ); - // xterm parses OSC 0/2 window-title sequences; mirror the latest non-empty - // one into the cell title. An empty title is ignored so the previous label - // (or the "term N" fallback) stands, matching the TUI. - term.onTitleChange((title) => { - const cleaned = title.replace(/\s+/g, " ").trim(); - if (!cleaned) return; - setTitles((current) => ({ ...current, [pane]: cleaned })); - }); - term.open(body); - viewsRef.current.set(pane, { term, fit }); - - // Flush any output (typically replayed scrollback) that arrived before - // this view existed, in order, so the restored screen is complete. - const queued = pendingRef.current.get(pane); - if (queued) { - for (const chunk of queued) term.write(chunk); - pendingRef.current.delete(pane); - } - } - - for (const [pane, view] of viewsRef.current) { - if (!panes.includes(pane)) { - view.term.dispose(); - viewsRef.current.delete(pane); - } - } - }, [panes]); + useTerminalViews({ + panes, + socketRef, + viewsRef, + bodyRefs, + pendingRef, + setTitles, + }); // Fit every visible pane to its cell and report the size to its PTY. Runs on // any layout change (pane added/removed, zoom toggled, panel resized). Hidden @@ -412,69 +162,21 @@ export function TerminalPanel({ socketRef.current?.send(JSON.stringify({ type: "close", pane })); }; - // Dragging a pane's header to a new slot. Pointer-based rather than HTML5 drag - // so it works with touch on a phone exactly as with a mouse, the same choice - // the sidebar divider makes. Order is authoritative on the server, so a drop - // sends the whole desired order and the grid follows the "reordered" echo. - const reorderable = zoomed === null && panes.length > 1; - - const endPaneDrag = () => { - dragPaneRef.current = null; - dragStartRef.current = null; - dragOverRef.current = null; - draggingRef.current = false; - setDraggingPane(null); - setDragOverPane(null); - }; - - const onPaneDragStart = (e: React.PointerEvent, pane: number) => { - // A press on the header's own buttons (zoom, close) is theirs — do not - // focus or start a drag, matching the pre-drag behaviour where those - // buttons stopped the focus press from propagating. - if ((e.target as HTMLElement).closest("button")) return; - focusPane(pane); - // Primary button / first touch only, and only when there is a grid to - // rearrange (more than one pane, not zoomed). - if (e.button !== 0 || !reorderable) return; - dragPaneRef.current = pane; - dragStartRef.current = { x: e.clientX, y: e.clientY }; - draggingRef.current = false; - e.currentTarget.setPointerCapture(e.pointerId); - }; - - const onPaneDragMove = (e: React.PointerEvent) => { - const dragged = dragPaneRef.current; - const start = dragStartRef.current; - if (dragged === null || start === null) return; - if ( - !draggingRef.current && - Math.hypot(e.clientX - start.x, e.clientY - start.y) < - PANE_DRAG_THRESHOLD_PX - ) { - return; - } - draggingRef.current = true; - setDraggingPane(dragged); - // Which cell is under the pointer. Pointer capture does not change hit - // testing, so this still finds the pane being hovered, not the dragged one. - const el = document - .elementFromPoint(e.clientX, e.clientY) - ?.closest("[data-pane-id]"); - const over = el ? Number(el.getAttribute("data-pane-id")) : null; - const target = over !== null && over !== dragged ? over : null; - dragOverRef.current = target; - setDragOverPane(target); - }; - - const onPaneDragEnd = () => { - const dragged = dragPaneRef.current; - const target = dragOverRef.current; - if (dragged !== null && draggingRef.current && target !== null) { - const order = reorderByDrop(panes, dragged, target); - socketRef.current?.send(JSON.stringify({ type: "reorder", order })); - } - endPaneDrag(); - }; + const { + draggingPane, + dragOverPane, + reorderable, + endPaneDrag, + onPaneDragStart, + onPaneDragMove, + onPaneDragEnd, + } = usePaneDrag({ + panes, + zoomed, + onFocus: focusPane, + onReorder: (order) => + socketRef.current?.send(JSON.stringify({ type: "reorder", order })), + }); const layout = planLayout(panes.length, size.w >= size.h); @@ -537,80 +239,36 @@ export function TerminalPanel({ gridColumn: `${cell.colStart} / span ${cell.colSpan}`, gridRow: `${cell.row}`, }; - const isDragged = draggingPane === pane; - const isDropTarget = dragOverPane === pane; - const borderClass = isDropTarget - ? "border-accent ring-1 ring-accent" - : pane === active - ? "border-accent" - : "border-ink-700"; return ( -
focusPane(pane)} - style={cellStyle} - className={`min-h-0 min-w-0 flex-col overflow-hidden rounded-sm border ${borderClass} ${ - isDragged ? "opacity-60" : "" - }`} - > -
onPaneDragStart(e, pane)} - onPointerMove={onPaneDragMove} - onPointerUp={onPaneDragEnd} - onPointerCancel={endPaneDrag} - className={`flex shrink-0 items-center gap-1 select-none bg-ink-900 px-2 py-0.5 text-xs ${ - reorderable - ? isDragged - ? "cursor-grabbing touch-none" - : "cursor-grab touch-none" - : "" - }`} - > - - {truncateCells(label, TAB_TITLE_MAX_CELLS)} - - - -
-
{ - if (node) bodyRefs.current.set(pane, node); - else bodyRefs.current.delete(pane); - }} - className="min-h-0 flex-1" - /> -
+ pane={pane} + index={index} + label={label} + cellStyle={cellStyle} + isActive={pane === active} + isZoomed={zoomed === pane} + isDragged={draggingPane === pane} + isDropTarget={dragOverPane === pane} + reorderable={reorderable} + onFocus={() => focusPane(pane)} + onToggleZoom={() => + setZoomed((z) => (z === pane ? null : pane)) + } + onClose={() => closePane(pane)} + onPaneDragStart={(e) => onPaneDragStart(e, pane)} + onPaneDragMove={onPaneDragMove} + onPaneDragEnd={onPaneDragEnd} + onPaneDragCancel={endPaneDrag} + bodyRef={(node) => { + if (node) bodyRefs.current.set(pane, node); + else bodyRefs.current.delete(pane); + }} + /> ); })}
); -} +} \ No newline at end of file diff --git a/viewer-ui/src/TerminalCell.tsx b/viewer-ui/src/TerminalCell.tsx new file mode 100644 index 0000000..5690d1c --- /dev/null +++ b/viewer-ui/src/TerminalCell.tsx @@ -0,0 +1,102 @@ +import type { CSSProperties } from "react"; +import { MaximizeIcon, XIcon } from "./icons"; +import { TAB_TITLE_MAX_CELLS, truncateCells } from "./terminalLayout"; + +interface TerminalCellProps { + pane: number; + index: number; + label: string; + cellStyle: CSSProperties; + isActive: boolean; + isZoomed: boolean; + isDragged: boolean; + isDropTarget: boolean; + reorderable: boolean; + onFocus: () => void; + onToggleZoom: () => void; + onClose: () => void; + onPaneDragStart: (e: React.PointerEvent) => void; + onPaneDragMove: (e: React.PointerEvent) => void; + onPaneDragEnd: () => void; + onPaneDragCancel: () => void; + bodyRef: (node: HTMLDivElement | null) => void; +} + +export function TerminalCell({ + pane, + index, + label, + cellStyle, + isActive, + isZoomed, + isDragged, + isDropTarget, + reorderable, + onFocus, + onToggleZoom, + onClose, + onPaneDragStart, + onPaneDragMove, + onPaneDragEnd, + onPaneDragCancel, + bodyRef, +}: TerminalCellProps) { + const borderClass = isDropTarget + ? "border-accent ring-1 ring-accent" + : isActive + ? "border-accent" + : "border-ink-700"; + return ( +
+
+ + {truncateCells(label, TAB_TITLE_MAX_CELLS)} + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/viewer-ui/src/api.ts b/viewer-ui/src/api.ts index 121652d..9991b64 100644 --- a/viewer-ui/src/api.ts +++ b/viewer-ui/src/api.ts @@ -1,246 +1,23 @@ -// Typed access to the viewer API. Mirrors src/web/viewer/dto.rs — the server -// owns the shape, this only describes it. - -export const PROTOCOL_VERSION = 2; - -/** A run of characters sharing a colour (server-side syntax highlighting). */ -export interface Span { - t: string; - c: string; -} - -export interface Repo { - id: string; - name: string; - display_path: string; -} - -export interface ChangedFile { - path: string; - old_path?: string; - index: string; - worktree: string; - /** Worktree mtime in Unix milliseconds, when the server could stat the file. - * Absent on a commit's file list, which describes history rather than the - * working tree. Measured against the *server's* clock, so date it against - * `now_ms` from the repo poll rather than this device's — see - * `nextClockOffset` in `hot.ts`. */ - mtime?: number; -} - -/** The server's `agent_indicator` settings, so the recently-touched highlight - * fades on the same window the TUI uses instead of a second local default. */ -export interface HotConfig { - enabled: boolean; - window_secs: number; -} - -/** What `GET /api/repos` answers: everything needed before the client can - * render. Named for its job rather than its route — the route also opens and - * closes repositories, but this payload is the session's bootstrap. Mirrors - * `ViewerBootstrapDto`. */ -export interface ViewerBootstrap { - repos: Repo[]; - hot: HotConfig; - /** Index into the accent presets, stored server-side so devices agree. */ - accent: number; - /** File-sidebar width in CSS px, stored server-side so devices agree. */ - sidebar_width: number; - /** The server's wall clock, for dating `ChangedFile.mtime`. */ - now_ms: number; -} - -export interface Status { - branch?: string; - head?: string; - tracking?: { ahead: number; behind: number }; - files: ChangedFile[]; - truncated: boolean; -} - -export interface Commit { - oid: string; - short_id: string; - summary: string; - author: string; - time: number; -} - -/** One page of the commit log. */ -export interface Log { - commits: Commit[]; - /** True when the history continues past this page — i.e. there is another - * page to ask for, not that anything was silently dropped. */ - truncated: boolean; - /** The commit the server's walk started from. Pass it back as `from` on the - * following pages so they describe the same history even if commits land in - * the meantime. Absent for a repository with no commits. */ - head?: string; -} - -export interface CommitFiles { - files: ChangedFile[]; - truncated: boolean; -} - -export interface TreeEntry { - name: string; - is_dir: boolean; -} - -export interface Tree { - path: string; - entries: TreeEntry[]; - truncated: boolean; -} - -/** One recursive tree-search hit: full repo-relative path plus its kind. */ -export interface TreeMatch { - path: string; - is_dir: boolean; -} - -export interface TreeSearch { - query: string; - matches: TreeMatch[]; - truncated: boolean; -} - -export interface DiffLine { - kind: string; - spans: Span[]; -} - -export interface DiffHunk { - header: string; - file_path?: string; - lines: DiffLine[]; -} - -export interface Diff { - path: string; - hunks: DiffHunk[]; - truncated: boolean; -} - -export interface FileView { - path: string; - lines: Span[][]; - truncated: boolean; -} - -export interface BrowseEntry { - name: string; - is_repo: boolean; -} - -export interface Browse { - path: string; - parent?: string; - entries: BrowseEntry[]; - truncated: boolean; -} - -export class ApiError extends Error { - constructor( - readonly status: number, - message: string, - ) { - super(message); - } -} - -/** A 401 means the session is gone; the caller re-renders the login screen. */ -export const isUnauthorized = (error: unknown) => - error instanceof ApiError && error.status === 401; - -/** A network-level failure — the device slept and dropped the connection, went - * offline, or the request was reset — rather than an HTTP response. `fetch` - * rejects these with a `TypeError` (the message varies by browser: "Failed to - * fetch" on Chrome, "Load failed" on Safari), while an HTTP error is wrapped as - * an `ApiError` above. These are transient: a poll or the event stream's - * reconnect recovers on its own. Wrapped in its own class at the fetch boundary - * so it is distinguishable from a `TypeError` thrown while *processing* a - * response (e.g. a malformed body) — that is a real defect, not a dropped - * connection, and must still surface. */ -export class NetworkError extends Error { - constructor(cause: unknown) { - // A public, friendly message rather than the browser's raw "Failed to - // fetch" / "Load failed": several UI paths (login, folder browsing/opening/ - // creation) show `err.message` directly, so the reason must read plainly. - // The original is kept as `cause` for debugging. - super("connection lost — check your network", { cause }); - this.name = "NetworkError"; - } -} - -export const isNetworkError = (error: unknown) => error instanceof NetworkError; - -/** `fetch`, but a network-level rejection becomes a [`NetworkError`]. Any HTTP - * response — including 4xx/5xx — resolves normally; only a failure to obtain a - * response at all is wrapped. */ -async function request(input: string, init?: RequestInit): Promise { - try { - return await fetch(input, init); - } catch (err) { - throw new NetworkError(err); - } -} - -async function get(path: string, signal?: AbortSignal): Promise { - const response = await request(path, { credentials: "same-origin", signal }); - if (!response.ok) { - // The server sends a fixed public message; there is no detail to surface. - let message = `request failed (${response.status})`; - try { - const body = await response.json(); - if (typeof body?.error === "string") message = body.error; - } catch { - // A non-JSON error body is not worth reporting beyond the status. - } - throw new ApiError(response.status, message); - } - const body = (await response.json()) as { version?: number } & T; - if (body.version !== PROTOCOL_VERSION) { - // Refuse rather than misread: a cached page from an older build must not - // guess at a payload whose fields may have changed meaning. - throw new ApiError( - response.status, - `this page is out of date (server protocol v${body.version}) — reload`, - ); - } - return body; -} - -async function post(path: string, payload: unknown): Promise { - const response = await request(path, { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - let message = `request failed (${response.status})`; - try { - const body = await response.json(); - if (typeof body?.error === "string") message = body.error; - } catch { - // A non-JSON error body is not worth reporting beyond the status. - } - throw new ApiError(response.status, message); - } - const body = (await response.json()) as { version?: number } & T; - if (body.version !== PROTOCOL_VERSION) { - throw new ApiError( - response.status, - `this page is out of date (server protocol v${body.version}) — reload`, - ); - } - return body; -} - -const query = (params: Record) => - new URLSearchParams(params).toString(); +// Public surface split into `./api/*` for size; re-exported here so existing +// imports (`./api`, `../api`) keep working unchanged. +export * from "./api/types"; +export * from "./api/errors"; + +import { PROTOCOL_VERSION } from "./api/types"; +import { ApiError } from "./api/errors"; +import { get, post, query, request } from "./api/client"; +import type { + Browse, + CommitFiles, + Diff, + FileView, + Log, + Repo, + Status, + Tree, + TreeSearch, + ViewerBootstrap, +} from "./api/types"; export const api = { async login(password: string): Promise { @@ -339,4 +116,4 @@ export function subscribeStatus( } }); return () => source.close(); -} +} \ No newline at end of file diff --git a/viewer-ui/src/api/client.ts b/viewer-ui/src/api/client.ts new file mode 100644 index 0000000..e57279d --- /dev/null +++ b/viewer-ui/src/api/client.ts @@ -0,0 +1,61 @@ +import { PROTOCOL_VERSION } from "./types"; +import { ApiError, NetworkError } from "./errors"; + +/** `fetch`, but a network-level rejection becomes a [`NetworkError`]. Any HTTP + * response — including 4xx/5xx — resolves normally; only a failure to obtain a + * response at all is wrapped. */ +export async function request( + input: string, + init?: RequestInit, +): Promise { + try { + return await fetch(input, init); + } catch (err) { + throw new NetworkError(err); + } +} + +/** Read a JSON response, enforcing the protocol version. Shared by `get` and + * `post`: both checked `response.ok`, parsed the error body, then re-parsed + * the success body and compared `version` — the duplication is folded here. */ +async function parseBody(response: Response): Promise { + if (!response.ok) { + // The server sends a fixed public message; there is no detail to surface. + let message = `request failed (${response.status})`; + try { + const body = await response.json(); + if (typeof body?.error === "string") message = body.error; + } catch { + // A non-JSON error body is not worth reporting beyond the status. + } + throw new ApiError(response.status, message); + } + const body = (await response.json()) as { version?: number } & T; + if (body.version !== PROTOCOL_VERSION) { + // Refuse rather than misread: a cached page from an older build must not + // guess at a payload whose fields may have changed meaning. + throw new ApiError( + response.status, + `this page is out of date (server protocol v${body.version}) — reload`, + ); + } + return body; +} + +export async function get(path: string, signal?: AbortSignal): Promise { + const response = await request(path, { credentials: "same-origin", signal }); + return parseBody(response); +} + +export async function post(path: string, payload: unknown): Promise { + const response = await request(path, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + return parseBody(response); +} + +export const query = (params: Record) => + new URLSearchParams(params).toString(); \ No newline at end of file diff --git a/viewer-ui/src/api/errors.ts b/viewer-ui/src/api/errors.ts new file mode 100644 index 0000000..ad493b9 --- /dev/null +++ b/viewer-ui/src/api/errors.ts @@ -0,0 +1,34 @@ +export class ApiError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +/** A 401 means the session is gone; the caller re-renders the login screen. */ +export const isUnauthorized = (error: unknown) => + error instanceof ApiError && error.status === 401; + +/** A network-level failure — the device slept and dropped the connection, went + * offline, or the request was reset — rather than an HTTP response. `fetch` + * rejects these with a `TypeError` (the message varies by browser: "Failed to + * fetch" on Chrome, "Load failed" on Safari), while an HTTP error is wrapped as + * an `ApiError` above. These are transient: a poll or the event stream's + * reconnect recovers on its own. Wrapped in its own class at the fetch boundary + * so it is distinguishable from a `TypeError` thrown while *processing* a + * response (e.g. a malformed body) — that is a real defect, not a dropped + * connection, and must still surface. */ +export class NetworkError extends Error { + constructor(cause: unknown) { + // A public, friendly message rather than the browser's raw "Failed to + // fetch" / "Load failed": several UI paths (login, folder browsing/opening/ + // creation) show `err.message` directly, so the reason must read plainly. + // The original is kept as `cause` for debugging. + super("connection lost — check your network", { cause }); + this.name = "NetworkError"; + } +} + +export const isNetworkError = (error: unknown) => error instanceof NetworkError; \ No newline at end of file diff --git a/viewer-ui/src/api/types.ts b/viewer-ui/src/api/types.ts new file mode 100644 index 0000000..f50edc6 --- /dev/null +++ b/viewer-ui/src/api/types.ts @@ -0,0 +1,142 @@ +// Typed access to the viewer API. Mirrors src/web/viewer/dto.rs — the server +// owns the shape, this only describes it. + +export const PROTOCOL_VERSION = 2; + +/** A run of characters sharing a colour (server-side syntax highlighting). */ +export interface Span { + t: string; + c: string; +} + +export interface Repo { + id: string; + name: string; + display_path: string; +} + +export interface ChangedFile { + path: string; + old_path?: string; + index: string; + worktree: string; + /** Worktree mtime in Unix milliseconds, when the server could stat the file. + * Absent on a commit's file list, which describes history rather than the + * working tree. Measured against the *server's* clock, so date it against + * `now_ms` from the repo poll rather than this device's — see + * `nextClockOffset` in `hot.ts`. */ + mtime?: number; +} + +/** The server's `agent_indicator` settings, so the recently-touched highlight + * fades on the same window the TUI uses instead of a second local default. */ +export interface HotConfig { + enabled: boolean; + window_secs: number; +} + +/** What `GET /api/repos` answers: everything needed before the client can + * render. Named for its job rather than its route — the route also opens and + * closes repositories, but this payload is the session's bootstrap. Mirrors + * `ViewerBootstrapDto`. */ +export interface ViewerBootstrap { + repos: Repo[]; + hot: HotConfig; + /** Index into the accent presets, stored server-side so devices agree. */ + accent: number; + /** File-sidebar width in CSS px, stored server-side so devices agree. */ + sidebar_width: number; + /** The server's wall clock, for dating `ChangedFile.mtime`. */ + now_ms: number; +} + +export interface Status { + branch?: string; + head?: string; + tracking?: { ahead: number; behind: number }; + files: ChangedFile[]; + truncated: boolean; +} + +export interface Commit { + oid: string; + short_id: string; + summary: string; + author: string; + time: number; +} + +/** One page of the commit log. */ +export interface Log { + commits: Commit[]; + /** True when the history continues past this page — i.e. there is another + * page to ask for, not that anything was silently dropped. */ + truncated: boolean; + /** The commit the server's walk started from. Pass it back as `from` on the + * following pages so they describe the same history even if commits land in + * the meantime. Absent for a repository with no commits. */ + head?: string; +} + +export interface CommitFiles { + files: ChangedFile[]; + truncated: boolean; +} + +export interface TreeEntry { + name: string; + is_dir: boolean; +} + +export interface Tree { + path: string; + entries: TreeEntry[]; + truncated: boolean; +} + +/** One recursive tree-search hit: full repo-relative path plus its kind. */ +export interface TreeMatch { + path: string; + is_dir: boolean; +} + +export interface TreeSearch { + query: string; + matches: TreeMatch[]; + truncated: boolean; +} + +export interface DiffLine { + kind: string; + spans: Span[]; +} + +export interface DiffHunk { + header: string; + file_path?: string; + lines: DiffLine[]; +} + +export interface Diff { + path: string; + hunks: DiffHunk[]; + truncated: boolean; +} + +export interface FileView { + path: string; + lines: Span[][]; + truncated: boolean; +} + +export interface BrowseEntry { + name: string; + is_repo: boolean; +} + +export interface Browse { + path: string; + parent?: string; + entries: BrowseEntry[]; + truncated: boolean; +} \ No newline at end of file diff --git a/viewer-ui/src/components/DiffView.tsx b/viewer-ui/src/components/DiffView.tsx new file mode 100644 index 0000000..9d5bb73 --- /dev/null +++ b/viewer-ui/src/components/DiffView.tsx @@ -0,0 +1,108 @@ +import { splitHunkRows } from "../diffLayout"; +import { diffLineBg } from "../utils"; +import type { Diff, DiffLine } from "../api"; + +/** The kind marker plus the highlighted content spans of one diff line. */ +function DiffLineContent({ line }: { line: DiffLine }) { + return ( + <> + {line.kind} + {line.spans.map((s, k) => ( + + {s.t} + + ))} + + ); +} + +/** One line within a split column; `null` renders a muted blank where this side + * has no counterpart, so the two columns stay row-aligned. Both cases carry one + * line's height (the blank via a non-breaking space) and fill the inner track's + * width so the change tint spans the whole line, overflow included. */ +function SplitCell({ line }: { line: DiffLine | null }) { + if (line === null) { + return
{" "}
; + } + return ( +
+ +
+ ); +} + +/** One fixed-half side of a split hunk: a stack of its lines that scrolls + * horizontally on its own, so a long line here never drags the other side. The + * inner `w-max min-w-full` track is as wide as this side's widest line (but at + * least the full column), giving every line a uniform width for the tint and a + * single shared scrollbar for the side. `border` draws the divider between the + * two halves (on the right side). */ +function SplitColumn({ + cells, + border, +}: { + cells: (DiffLine | null)[]; + border: boolean; +}) { + const divider = border ? "border-l border-ink-800" : ""; + return ( +
+
+ {cells.map((line, i) => ( + + ))} +
+
+ ); +} + +/** Side-by-side body for one hunk: removed lines on the left, added on the + * right, paired by `splitHunkRows`. The two halves are fixed at 50% each and + * scroll horizontally independently; equal per-line heights keep rows aligned + * across the seam. */ +function SplitHunk({ lines }: { lines: DiffLine[] }) { + const rows = splitHunkRows(lines); + return ( +
+ r.left)} border={false} /> + r.right)} border={true} /> +
+ ); +} + +/** The diff pane body. `split` picks the side-by-side layout; otherwise each + * line is stacked inline. Hunk headers are shared by both. */ +export function DiffView({ diff, split }: { diff: Diff; split: boolean }) { + return ( +
+ {diff.hunks.length === 0 && ( +

No changes.

+ )} + {diff.hunks.map((h, i) => ( +
+
+ {h.file_path ? `${h.file_path} ` : ""} + {h.header} +
+ {split ? ( + + ) : ( + h.lines.map((line, j) => ( +
+ +
+ )) + )} +
+ ))} + {diff.truncated && ( +

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

+ )} +
+ ); +} \ No newline at end of file diff --git a/viewer-ui/src/components/FilePane.tsx b/viewer-ui/src/components/FilePane.tsx new file mode 100644 index 0000000..47dc976 --- /dev/null +++ b/viewer-ui/src/components/FilePane.tsx @@ -0,0 +1,137 @@ +import { Suspense, lazy } from "react"; +import { useDiffLayout } from "../diffLayout"; +import { fileViewSource, isMarkdownPath } from "../fileView"; +import { MaximizeIcon, PreviewIcon, SplitViewIcon } from "../icons"; +import { DiffView } from "./DiffView"; +import { PathLabel } from "./PathLabel"; +import type { Status } from "../api"; +import type { Pane } from "../types"; + +// Same reasoning as the terminal panel: react-markdown pulls in the remark / +// rehype / highlight.js pipeline, which has no business in the initial chunk. +// It arrives only when a markdown file is first rendered. +const MarkdownView = lazy(() => + import("../Markdown").then((m) => ({ default: m.MarkdownView })), +); + +export interface FilePaneProps { + pane: Pane; + mdRendered: boolean; + setMdRendered: React.Dispatch>; + filesMax: boolean; + setMaximized: (next: "none" | "files" | "terminal") => void; + status: Status | null; +} + +export function FilePane({ + pane, + mdRendered, + setMdRendered, + filesMax, + setMaximized, + status, +}: FilePaneProps) { + const diffLayout = useDiffLayout(); + return ( +
+ {/* Always rendered, even with nothing open: it carries the maximise + control, and a header that came and went with the selection would + shift the pane under the cursor. Only a file view is labelled with + its path here — a diff already prints each file path in its hunk + headers, and a commit diff's `path` is the commit oid, which would + read as a bogus file name. */} +
+ {pane.kind === "file" && } +
+ {pane.kind === "diff" && ( + + )} + {pane.kind === "file" && isMarkdownPath(pane.value.path) && ( + + )} + +
+
+
+ {pane.kind === "empty" && ( +

+ {status === null ? "Loading…" : "Select a file or commit."} +

+ )} + {pane.kind === "file" && ( + <> + {isMarkdownPath(pane.value.path) && mdRendered ? ( + Rendering…

}> + +
+ ) : ( +
+                {pane.value.lines.map((line, i) => (
+                  
+ {line.length === 0 + ? " " + : line.map((s, j) => ( + + {s.t} + + ))} +
+ ))} +
+ )} + {pane.value.truncated && ( +

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

+ )} + + )} + {pane.kind === "diff" && ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/viewer-ui/src/components/FolderPicker.tsx b/viewer-ui/src/components/FolderPicker.tsx new file mode 100644 index 0000000..20e405d --- /dev/null +++ b/viewer-ui/src/components/FolderPicker.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from "react"; +import { api, type Browse, type Repo } from "../api"; +import { toast } from "../toast"; +import { XIcon } from "../icons"; + +/** A server-side folder browser (code-server style): navigate the machine the + * viewer runs on and open a directory as a project. The OS file dialog cannot + * do this — it would pick paths on the viewer's machine, not the server's. */ +export function FolderPicker({ + onClose, + onOpened, +}: { + onClose: () => void; + onOpened: (repo: Repo) => void; +}) { + // `null` means "the server's default" (home); the server resolves it. + const [path, setPath] = useState(null); + const [dir, setDir] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [newName, setNewName] = useState(""); + const [creating, setCreating] = useState(false); + // Bumped to re-browse the current path without navigating (e.g. after a + // folder is created so it shows up in the listing). + const [reload, setReload] = useState(0); + + useEffect(() => { + let cancelled = false; + api + .browse(path ?? undefined) + .then((d) => { + if (!cancelled) { + setDir(d); + setError(null); + } + }) + .catch((err) => { + if (!cancelled) + setError(err instanceof Error ? err.message : "could not browse"); + }); + return () => { + cancelled = true; + }; + }, [path, reload]); + + const into = (name: string) => + setPath(`${dir!.path.replace(/\/$/, "")}/${name}`); + + const openHere = async () => { + if (!dir) return; + setBusy(true); + try { + onOpened(await api.open(dir.path)); + } catch (err) { + toast.error(err instanceof Error ? err.message : "could not open"); + setBusy(false); + } + }; + + // Create a folder in the directory being browsed and refresh the listing so + // it appears. Stays put rather than stepping in — the new folder is empty + // (not a git repo yet), so the user can keep browsing or step in manually. + const createFolder = async () => { + if (!dir) return; + const name = newName.trim(); + if (!name) return; + setCreating(true); + try { + await api.mkdir(dir.path, name); + setNewName(""); + setReload((n) => n + 1); + } catch (err) { + toast.error(err instanceof Error ? err.message : "could not create folder"); + } finally { + setCreating(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +
+ Open a project + +
+
+ {dir?.path ?? "…"} +
+
    + {dir?.parent && ( +
  • + +
  • + )} + {dir?.entries.map((e) => ( +
  • + +
  • + ))} + {dir && dir.entries.length === 0 && ( +
  • No sub-folders.
  • + )} +
+ {error &&

{error}

} +
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") createFolder(); + }} + placeholder="New folder name" + aria-label="new folder name" + className="min-w-0 flex-1 rounded-sm border border-ink-700 bg-ink-950 px-2 py-1 text-ink-50 placeholder:text-ink-400 focus:border-ink-600 focus:outline-none" + /> + +
+
+ + {dir ? dir.path : ""} + + +
+
+
+ ); +} \ No newline at end of file diff --git a/viewer-ui/src/components/Header.tsx b/viewer-ui/src/components/Header.tsx new file mode 100644 index 0000000..460831b --- /dev/null +++ b/viewer-ui/src/components/Header.tsx @@ -0,0 +1,126 @@ +import { Mark } from "./Mark"; +import { ProjectMenu } from "./ProjectMenu"; +import { PlusIcon, XIcon } from "../icons"; +import type { Repo } from "../api"; + +export interface HeaderProps { + repos: Repo[]; + repo: string | null; + setRepo: (id: string) => void; + setPane: () => void; + closeRepo: (id: string) => void; + setPickerOpen: (open: boolean) => void; + accent: { name: string }; + next: { name: string }; + cycle: () => void; +} + +export function Header({ + repos, + repo, + setRepo, + setPane, + closeRepo, + setPickerOpen, + accent, + next, + cycle, +}: HeaderProps) { + return ( +
+ + nightcrow + + web viewer + + { + setRepo(id); + setPane(); + }} + onCloseProject={closeRepo} + onOpenPicker={() => setPickerOpen(true)} + /> + {/* Editor tabs, after VS Code's: square, touching, and stretched to the + full height of the bar they sit in — a tab is a tab because it fills + its strip, not because it is a labelled box. The negative margins eat + the header's padding to reach that height, and the active one takes + the body colour so it reads as the near edge of the content below. + The accent marker is an inset shadow rather than a border, which + would shift the label down by its own width. + + VS Code also lets the active tab overlap the rule under the bar, so + the two areas merge outright. Not done here: this strip scrolls + sideways when enough projects are open, and a scroll container clips + both axes — the overlap would be cut off and could raise a vertical + scrollbar besides. */} + + {/* The plus is the same drawn mark the terminal panel's add button uses, + not the `+` character, so the app has one plus rather than two that + disagree on weight. Sized to the label beside it — the convention the + project tabs' close glyph already follows — rather than to the 16px + of an icon-only control. */} + + {/* Cycles rather than opening a picker, matching the TUI's + ` p`. The swatch is the current accent, so the control + doubles as the indicator. */} + + + sign out + +
+ ); +} \ No newline at end of file diff --git a/viewer-ui/src/components/LoadingSplash.tsx b/viewer-ui/src/components/LoadingSplash.tsx new file mode 100644 index 0000000..5a1cb26 --- /dev/null +++ b/viewer-ui/src/components/LoadingSplash.tsx @@ -0,0 +1,16 @@ +import { Mark } from "./Mark"; + +/// Centred, branded loading indicator shown before the first repo list settles +/// (both at session start and while an empty catalog may still be populating). +export function LoadingSplash() { + return ( +
+
+ + + Loading… + +
+
+ ); +} \ No newline at end of file diff --git a/viewer-ui/src/components/LogList.tsx b/viewer-ui/src/components/LogList.tsx new file mode 100644 index 0000000..224c84e --- /dev/null +++ b/viewer-ui/src/components/LogList.tsx @@ -0,0 +1,158 @@ +import { PathLabel } from "./PathLabel"; +import { formatRelativeTime, statusColor } from "../utils"; +import type { Commit } from "../api"; +import type { CommitDrillDown } from "../hooks/useLog"; + +export interface LogListProps { + visibleCommits: Commit[]; + commits: Commit[]; + aheadOids: Set; + commitDrillDown: CommitDrillDown | null; + visibleCommitFiles: CommitDrillDown["files"]; + logDone: boolean; + logStalled: boolean; + logPagingPaused: boolean; + setLogStalled: (v: boolean | ((prev: boolean) => boolean)) => void; + logSentinelRef: React.RefObject; + openCommitFiles: (commit: Commit) => void; + openCommit: (oid: string) => void; + openCommitFileDiff: (oid: string, path: string) => void; + setCommitDrillDown: (v: CommitDrillDown | null) => void; + setPaneEmpty: () => void; + bumpPaneRequest: () => void; +} + +export function LogList({ + visibleCommits, + commits, + aheadOids, + commitDrillDown, + visibleCommitFiles, + logDone, + logStalled, + logPagingPaused, + setLogStalled, + logSentinelRef, + openCommitFiles, + openCommit, + openCommitFileDiff, + setCommitDrillDown, + setPaneEmpty, + bumpPaneRequest, +}: LogListProps) { + return ( + <> + {!commitDrillDown && + visibleCommits.map((c) => ( +
  • + +
  • + ))} + {/* Asks for the next page as it scrolls into view, the way the TUI + prefetches as the cursor nears the loaded tail. Rendered only + while there is more, so reaching the end of the history stops + the observer rather than leaving it to fire on every scroll. + Kept out of the drill-down, which lists one commit's files. */} + {!commitDrillDown && !logDone && !logStalled && !logPagingPaused && ( + + )} + {/* Says why the list stops where it does while a filter is up: the + query matches what is loaded, and more history is only a cleared + filter away. Without this the end of a filtered list is + indistinguishable from the end of the history. */} + {!commitDrillDown && !logDone && !logStalled && logPagingPaused && ( +
  • + filtering {commits.length} loaded commits — clear the filter to load + more +
  • + )} + {/* A failed page keeps its place in the list. The history did not + end here, and the error toast fades on its own, so without this + the list would simply look shorter than it is. */} + {!commitDrillDown && logStalled && ( +
  • + +
  • + )} + {commitDrillDown && ( + <> +
  • + + · + + {commitDrillDown.commit.short_id} + + +
  • + {visibleCommitFiles.map((f) => ( +
  • + +
  • + ))} + {commitDrillDown.files.length === 0 && ( +
  • No changed files.
  • + )} + {commitDrillDown.files.length > 0 && visibleCommitFiles.length === 0 && ( +
  • No matching files.
  • + )} + {commitDrillDown.truncated && ( +
  • + Showing the first {commitDrillDown.files.length} files. +
  • + )} + + )} + + ); +} \ No newline at end of file diff --git a/viewer-ui/src/components/Login.tsx b/viewer-ui/src/components/Login.tsx new file mode 100644 index 0000000..dd666fd --- /dev/null +++ b/viewer-ui/src/components/Login.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import { api } from "../api"; +import { Mark } from "./Mark"; + +export function Login({ onSuccess }: { onSuccess: () => void }) { + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + try { + await api.login(password); + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : "login failed"); + } finally { + setBusy(false); + } + }; + + return ( +
    +
    + +

    + nightcrow +

    +

    + web viewer +

    + {error &&

    {error}

    } + setPassword(e.target.value)} + placeholder="password" + className="mb-2 w-full rounded-md border border-ink-700 bg-ink-900 px-2.5 py-1.5 outline-none placeholder:text-ink-400 focus:border-accent focus:ring-[3px] focus:ring-accent/15" + /> + + +
    + ); +} \ No newline at end of file diff --git a/viewer-ui/src/components/Mark.tsx b/viewer-ui/src/components/Mark.tsx new file mode 100644 index 0000000..e4b3bde --- /dev/null +++ b/viewer-ui/src/components/Mark.tsx @@ -0,0 +1,16 @@ +/** The nightcrow mark: a black crow on a rounded accent tile. Shared with the + * web mirror's login/header so the two services read as one product. The tile + * is `bg-accent` rather than a baked colour, so it follows the runtime accent + * as the TUI's splash logo does (`ui/splash.rs` colours it with `accent`); the + * crow itself is the shared `crow-mono.svg` silhouette (transparent + * background) overlaid on top. The favicon keeps its own off-white-tiled + * `crow.svg`, which reads better against an arbitrary browser tab bar. */ +export function Mark({ className }: { className?: string }) { + return ( + + + + ); +} \ No newline at end of file diff --git a/viewer-ui/src/components/PathLabel.tsx b/viewer-ui/src/components/PathLabel.tsx new file mode 100644 index 0000000..d77f7a7 --- /dev/null +++ b/viewer-ui/src/components/PathLabel.tsx @@ -0,0 +1,26 @@ +/** + * A path rendered in full, reachable by scrolling the list sideways. + * + * Truncating instead would cut the tail, which is the one part that tells two + * rows apart — `src/web/viewer/server.rs` and `src/web/viewer/terminal.rs` both + * become `src/web/viewer/…` in a narrow sidebar. `title` still carries the + * whole path so a hover answers without scrolling. + */ +export function PathLabel({ + path, + from, + className, +}: { + path: string; + from?: string; + className?: string; +}) { + return ( + + {from ? `${from} → ${path}` : path} + + ); +} \ No newline at end of file diff --git a/viewer-ui/src/components/ProjectMenu.tsx b/viewer-ui/src/components/ProjectMenu.tsx new file mode 100644 index 0000000..dacd9f6 --- /dev/null +++ b/viewer-ui/src/components/ProjectMenu.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, useState } from "react"; +import { ChevronIcon, PlusIcon, XIcon } from "../icons"; +import type { Repo } from "../api"; + +/** Narrow-screen stand-in for the header's project tabs: a dropdown listing the + * open projects (tap to switch, × to close) plus "+ open". Rendered only below + * md (the caller hides it wider, where the tab row takes over). Closes on an + * outside click — a transparent backdrop, the same mechanism `FolderPicker`'s + * overlay uses — or on Escape, which returns focus to the trigger. */ +export function ProjectMenu({ + repos, + currentId, + onSelect, + onCloseProject, + onOpenPicker, + className = "", +}: { + repos: Repo[]; + currentId: string | null; + onSelect: (id: string) => void; + onCloseProject: (id: string) => void; + onOpenPicker: () => void; + className?: string; +}) { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const current = repos.find((r) => r.id === currentId); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + triggerRef.current?.focus(); + } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open]); + + return ( +
    + + {open && ( + <> +
    setOpen(false)} /> +
    + {repos.length === 0 && ( +

    No projects open.

    + )} + {repos.map((r) => ( +
    + + +
    + ))} +
    + +
    + + )} +
    + ); +} \ No newline at end of file diff --git a/viewer-ui/src/components/RepoShell.tsx b/viewer-ui/src/components/RepoShell.tsx new file mode 100644 index 0000000..405e181 --- /dev/null +++ b/viewer-ui/src/components/RepoShell.tsx @@ -0,0 +1,220 @@ +import { Suspense, lazy } from "react"; +import type { CSSProperties } from "react"; +import { MAX_SIDEBAR_VIEWPORT_FRACTION } from "../sidebar"; +import type { PointerEvent as ReactPointerEvent } from "react"; +import { Sidebar } from "./Sidebar"; +import { FilePane } from "./FilePane"; +import type { ChangedFile, Commit, Repo, Status } from "../api"; +import type { CommitDrillDown } from "../hooks/useLog"; +import type { Maximized, Pane, Tab } from "../types"; + +// Lazily loaded so `@xterm/xterm` (the bulk of the bundle) stays out of the +// initial chunk that paints the login screen and git viewer, arriving only once +// a repo is open and the terminal panel actually mounts. +const TerminalPanel = lazy(() => + import("../Terminal").then((m) => ({ default: m.TerminalPanel })), +); + +export interface RepoShellProps { + repo: string; + repos: Repo[]; + current: Repo | undefined; + status: Status | null; + files: ChangedFile[]; + now: number; + hotWindowMs: number; + pane: Pane; + setPane: React.Dispatch>; + tab: Tab; + setTab: (t: Tab) => void; + filter: string; + setFilter: (v: string) => void; + filterOpen: boolean; + setFilterOpen: React.Dispatch>; + openDiff: (path: string) => void; + openFile: (path: string) => void; + openCommit: (oid: string) => void; + openCommitFileDiff: (oid: string, path: string) => void; + openCommitFiles: (commit: Commit) => void; + authed: boolean | null; + handle: (err: unknown) => void; + sidebarWidth: number; + sidebarRef: React.RefObject; + draggingSidebar: boolean; + onSidebarDragStart: (e: ReactPointerEvent) => void; + onSidebarDragMove: (e: ReactPointerEvent) => void; + onSidebarDragEnd: () => void; + onSidebarDragCancel: () => void; + filesMax: boolean; + bumpPaneRequest: () => void; + commits: Commit[]; + logDone: boolean; + logStalled: boolean; + setLogStalled: (v: boolean | ((prev: boolean) => boolean)) => void; + commitDrillDown: CommitDrillDown | null; + setCommitDrillDown: (v: CommitDrillDown | null) => void; + resetLog: () => void; + logSentinelRef: React.RefObject; + visibleCommits: Commit[]; + logPagingPaused: boolean; + aheadOids: Set; + visibleCommitFiles: CommitDrillDown["files"]; + mdRendered: boolean; + setMdRendered: React.Dispatch>; + maximized: Maximized; + setMaximized: (next: Maximized | ((prev: Maximized) => Maximized)) => void; +} + +export function RepoShell(props: RepoShellProps) { + const { + repo, + current, + status, + files, + now, + hotWindowMs, + pane, + setPane, + tab, + setTab, + filter, + setFilter, + filterOpen, + setFilterOpen, + openDiff, + openFile, + openCommit, + openCommitFileDiff, + openCommitFiles, + authed, + handle, + sidebarWidth, + sidebarRef, + draggingSidebar, + onSidebarDragStart, + onSidebarDragMove, + onSidebarDragEnd, + onSidebarDragCancel, + filesMax, + bumpPaneRequest, + commits, + logDone, + logStalled, + setLogStalled, + commitDrillDown, + setCommitDrillDown, + resetLog, + logSentinelRef, + visibleCommits, + logPagingPaused, + aheadOids, + visibleCommitFiles, + mdRendered, + setMdRendered, + maximized, + setMaximized, + } = props; + + return ( + <> + {/* While the divider is dragged, this overlay holds the resize cursor + across the whole window and keeps a stray text selection from + starting. Pointer capture routes the move/up events to the handle + regardless, so the overlay is purely visual. */} + {draggingSidebar &&
    } + {/* The width rides on a custom property so the responsive rule stays + declarative — below md the grid collapses to one column, leaving + the stacked layout untouched. Maximising the file pane drives the + property to zero rather than dropping the sidebar, so its content + is not torn down and rebuilt on every toggle. */} +
    + + +
    + + + + setMaximized((m) => (m === "terminal" ? "none" : "terminal")) + } + /> + + +
    + {current?.display_path} + {status?.branch && {status.branch}} + {status?.tracking && ( + + ↑{status.tracking.ahead} ↓{status.tracking.behind} + + )} + + {status ? ● live : "connecting…"} + +
    + + ); +} \ No newline at end of file diff --git a/viewer-ui/src/components/Sidebar.tsx b/viewer-ui/src/components/Sidebar.tsx new file mode 100644 index 0000000..04faed1 --- /dev/null +++ b/viewer-ui/src/components/Sidebar.tsx @@ -0,0 +1,243 @@ +import type { PointerEvent as ReactPointerEvent } from "react"; +import { SearchIcon } from "../icons"; +import { useTree } from "../hooks/useTree"; +import { buildTreeRows } from "../tree"; +import { StatusList } from "./StatusList"; +import { LogList } from "./LogList"; +import { TreeList } from "./TreeList"; +import type { ChangedFile, Commit, Status } from "../api"; +import type { CommitDrillDown } from "../hooks/useLog"; +import type { Pane, Tab } from "../types"; + +export interface SidebarProps { + tab: Tab; + setTab: (t: Tab) => void; + filter: string; + setFilter: (v: string) => void; + filterOpen: boolean; + setFilterOpen: React.Dispatch>; + status: Status | null; + files: ChangedFile[]; + now: number; + hotWindowMs: number; + setPane: React.Dispatch>; + openDiff: (path: string) => void; + openFile: (path: string) => void; + openCommit: (oid: string) => void; + openCommitFileDiff: (oid: string, path: string) => void; + openCommitFiles: (commit: Commit) => void; + repo: string | null; + authed: boolean | null; + handle: (err: unknown) => void; + sidebarRef: React.RefObject; + draggingSidebar: boolean; + onSidebarDragStart: (e: ReactPointerEvent) => void; + onSidebarDragMove: (e: ReactPointerEvent) => void; + onSidebarDragEnd: () => void; + onSidebarDragCancel: () => void; + filesMax: boolean; + bumpPaneRequest: () => void; + // Log state owned by App (commitDrillDown is shared with openCommitFiles and + // the repo-change cleanup, so it lives above the sidebar). + commits: Commit[]; + logDone: boolean; + logStalled: boolean; + setLogStalled: (v: boolean | ((prev: boolean) => boolean)) => void; + commitDrillDown: CommitDrillDown | null; + setCommitDrillDown: (v: CommitDrillDown | null) => void; + resetLog: () => void; + logSentinelRef: React.RefObject; + visibleCommits: Commit[]; + logPagingPaused: boolean; + aheadOids: Set; + visibleCommitFiles: CommitDrillDown["files"]; +} + +export function Sidebar(props: SidebarProps) { + const { + tab, + setTab, + filter, + setFilter, + filterOpen, + setFilterOpen, + status, + files, + now, + hotWindowMs, + setPane, + openDiff, + openFile, + openCommit, + openCommitFileDiff, + openCommitFiles, + repo, + authed, + handle, + sidebarRef, + draggingSidebar, + onSidebarDragStart, + onSidebarDragMove, + onSidebarDragEnd, + onSidebarDragCancel, + filesMax, + bumpPaneRequest, + commits, + logDone, + logStalled, + setLogStalled, + commitDrillDown, + setCommitDrillDown, + resetLog, + logSentinelRef, + visibleCommits, + logPagingPaused, + aheadOids, + visibleCommitFiles, + } = props; + + const tree = useTree({ repo, authed, tab, filter, filterOpen, handle }); + const treeSearching = tab === "tree" && filterOpen && filter !== ""; + const treeRows = buildTreeRows(tree.treeChildren, tree.treeExpanded); + + return ( +
    + {/* Drag the divider to resize the sidebar, double-click to reset it. + A thin strip over the right border, only at md+ (below it the + layout is a single stacked column) and only when the pane is not + maximised. Pointer capture keeps the drag alive over the diff pane; + the overlay below carries the resize cursor across the whole window + while it lasts. */} + {!filesMax && ( +
    + ); +} \ No newline at end of file diff --git a/viewer-ui/src/components/StatusList.tsx b/viewer-ui/src/components/StatusList.tsx new file mode 100644 index 0000000..d510042 --- /dev/null +++ b/viewer-ui/src/components/StatusList.tsx @@ -0,0 +1,51 @@ +import { PathLabel } from "./PathLabel"; +import { HOT_CLASS } from "../useHotClock"; +import { classifyHot } from "../hot"; +import { statusColor } from "../utils"; +import type { ChangedFile, Status } from "../api"; + +export interface StatusListProps { + status: Status | null; + files: ChangedFile[]; + now: number; + hotWindowMs: number; + openDiff: (path: string) => void; +} + +export function StatusList({ + status, + files, + now, + hotWindowMs, + openDiff, +}: StatusListProps) { + if (status === null) { + return
  • Loading…
  • ; + } + return ( + <> + {files.map((f) => ( +
  • + +
  • + ))} + + ); +} \ No newline at end of file diff --git a/viewer-ui/src/components/TreeList.tsx b/viewer-ui/src/components/TreeList.tsx new file mode 100644 index 0000000..d3a0592 --- /dev/null +++ b/viewer-ui/src/components/TreeList.tsx @@ -0,0 +1,93 @@ +import { ChevronIcon } from "../icons"; +import type { TreeMatch } from "../api"; +import type { TreeRow } from "../tree"; + +export interface TreeListProps { + treeSearching: boolean; + treeMatches: TreeMatch[]; + treeTruncated: boolean; + treeSearchLoading: boolean; + treeRows: TreeRow[]; + treeExpanded: Set; + openFile: (path: string) => void; + revealTreeDir: (path: string) => void; + toggleTreeDir: (path: string) => void; +} + +export function TreeList({ + treeSearching, + treeMatches, + treeTruncated, + treeSearchLoading, + treeRows, + treeExpanded, + openFile, + revealTreeDir, + toggleTreeDir, +}: TreeListProps) { + if (treeSearching) { + return ( + <> + {treeMatches.map((m) => ( +
  • + +
  • + ))} + {treeMatches.length === 0 && ( +
  • + {treeSearchLoading ? "searching…" : "no matches"} +
  • + )} + {treeTruncated && ( +
  • + showing the first {treeMatches.length} matches +
  • + )} + + ); + } + return ( + <> + {treeRows.map((row) => ( +
  • + +
  • + ))} + + ); +} \ No newline at end of file diff --git a/viewer-ui/src/hooks/useLog.ts b/viewer-ui/src/hooks/useLog.ts new file mode 100644 index 0000000..ea4d1d0 --- /dev/null +++ b/viewer-ui/src/hooks/useLog.ts @@ -0,0 +1,179 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { api, type Commit, type CommitFiles } from "../api"; + +export interface CommitDrillDown extends CommitFiles { + commit: Commit; +} + +export interface UseLogArgs { + repo: string | null; + authed: boolean | null; + tab: "status" | "log" | "tree"; + filter: string; + handle: (err: unknown) => void; +} + +export interface UseLogResult { + commits: Commit[]; + logDone: boolean; + logStalled: boolean; + setLogStalled: (v: boolean | ((prev: boolean) => boolean)) => void; + commitDrillDown: CommitDrillDown | null; + setCommitDrillDown: (v: CommitDrillDown | null) => void; + resetLog: () => void; + logSentinelRef: React.RefObject; + visibleCommits: Commit[]; + logPagingPaused: boolean; +} + +export function useLog({ + repo, + authed, + tab, + filter, + handle, +}: UseLogArgs): UseLogResult { + // The commit log, accumulated a page at a time. `logDone` is set once the + // server reports no more history. Everything below resets together — see + // `resetLog`. + const [commits, setCommits] = useState([]); + const [logDone, setLogDone] = useState(false); + // A page failed. Kept apart from `logDone`, which means the history ended: + // conflating them would report a blip as the end of the log, and the error + // toast fades on its own, leaving nothing behind to say the list is short. + // This replaces the sentinel with a retry, which also stops a failing request + // from firing again on every scroll. + const [logStalled, setLogStalled] = useState(false); + // The commit the server walked from, echoed back on every following request + // so the pages describe one history. A ref, not state: it changes once, when + // the first page establishes it, and a fetcher rebuilt at that moment would + // re-arm the paging observer with no new row to justify it. + const logAnchorRef = useRef(null); + // Guards against two page requests overlapping: the sentinel can re-enter the + // viewport while a fetch is still out. + const logLoadingRef = useRef(false); + // Invalidates a page still in flight when the log it belongs to is discarded + // (another repo, another tab). Same shape as `paneRequestRef`, kept separate + // because the two invalidate on different events. + const logRequestRef = useRef(0); + const resetLog = useCallback(() => { + logRequestRef.current += 1; + logLoadingRef.current = false; + setCommits([]); + logAnchorRef.current = null; + setLogDone(false); + setLogStalled(false); + }, []); + const [commitDrillDown, setCommitDrillDown] = + useState(null); + // How many commits are held, read by the page fetcher as the next page's + // offset. A ref so appending a page does not rebuild the fetcher and re-fire + // the effect that calls it. + const commitsRef = useRef(commits); + commitsRef.current = commits; + + // Fetch one page of the log and append it. The first call (no anchor yet) + // establishes the anchor from the server's answer; later ones pin to it. + const loadLogPage = useCallback(async () => { + if (!repo || logLoadingRef.current) return; + logLoadingRef.current = true; + const request = logRequestRef.current; + try { + const anchor = logAnchorRef.current; + const page = await api.log( + repo, + anchor === null + ? undefined + : { from: anchor, skip: commitsRef.current.length }, + ); + if (request !== logRequestRef.current) return; + setCommits((held) => [...held, ...page.commits]); + logAnchorRef.current = page.head ?? null; + // No anchor to page from (an empty repository) is also the end of it. + setLogDone(!page.truncated || page.head === undefined); + } catch (err) { + if (request === logRequestRef.current) { + handle(err); + setLogStalled(true); + } + } finally { + if (request === logRequestRef.current) logLoadingRef.current = false; + } + }, [repo, handle]); + + // Entering the log tab loads the first page; the sentinel below the list asks + // for the rest as it comes into view. + useEffect(() => { + if (!repo || !authed || tab !== "log") return; + if (commits.length === 0 && !logDone && !logStalled) void loadLogPage(); + // `commits.length` and not the ref: switching repositories runs this and + // the effect that empties the list in declaration order, so reading the ref + // here would see the previous repository's commits, decline to fetch, and + // leave an empty list nothing would refill — the sentinel that would + // normally rescue it is not rendered while a filter is up. Depending on the + // state instead re-runs this once the reset lands, whatever the order. + }, [repo, authed, tab, commits.length, logDone, logStalled, loadLogPage]); + + // The commit rows the log tab renders. Derived up here, ahead of the sibling + // list filters below, because the paging observer keys on how many there are. + const visibleCommits = commits.filter((c) => + c.summary.toLowerCase().includes(filter.toLowerCase()), + ); + // A filter narrows the commits already loaded; it is not a server search. So + // it also stops the paging, rather than quietly walking the whole history a + // page at a time hunting for matches — which is what keying the observer on + // the rendered count alone would still do whenever a page happened to contain + // one. The list says so where the sentinel would have been. + const logPagingPaused = filter !== ""; + + // Watch the row that sits under the last commit. `rootMargin` starts the + // fetch a screen early, so scrolling reaches loaded rows rather than the + // placeholder. The sentinel is only rendered while more history exists, so an + // exhausted log detaches this instead of polling. + // + // Rebuilt whenever the rendered list grows, because an observer reports + // *changes* in intersection and an appended page need not produce one — the + // sentinel can stay exactly where it is, in view, with history left to load. + // Re-observing re-reports the current state, continuing the paging until the + // sentinel is genuinely pushed out of view. + // + // Keyed on the *rendered* count rather than the loaded one, which is what + // stops a filter from running away with this: a page whose commits the filter + // hides adds no rows, so it does not re-arm, and the chain stops instead of + // walking the whole history a page at a time looking for a match. The log + // filter narrows what is loaded — the same contract the TUI's has. + const logSentinelRef = useRef(null); + useEffect(() => { + const sentinel = logSentinelRef.current; + if (!sentinel) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) void loadLogPage(); + }, + { root: sentinel.closest("ul"), rootMargin: "400px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [ + loadLogPage, + logDone, + logStalled, + logPagingPaused, + commitDrillDown, + tab, + visibleCommits.length, + ]); + + return { + commits, + logDone, + logStalled, + setLogStalled, + commitDrillDown, + setCommitDrillDown, + resetLog, + logSentinelRef, + visibleCommits, + logPagingPaused, + }; +} \ No newline at end of file diff --git a/viewer-ui/src/hooks/useMaximized.ts b/viewer-ui/src/hooks/useMaximized.ts new file mode 100644 index 0000000..3afe4c5 --- /dev/null +++ b/viewer-ui/src/hooks/useMaximized.ts @@ -0,0 +1,28 @@ +import { useCallback, useState } from "react"; +import type { Maximized } from "../types"; + +// Maximize is a per-project layout choice: each repo remembers whether its +// files pane, terminal, or neither was maximized, so switching projects +// restores that project's own layout rather than carrying one over. +export function useMaximized(repo: string | null) { + const [maximizedByRepo, setMaximizedByRepo] = useState< + Record + >({}); + const setMaximized = useCallback( + (next: Maximized | ((prev: Maximized) => Maximized)) => { + if (repo == null) return; + setMaximizedByRepo((prev) => { + const current = prev[repo] ?? "none"; + const value = typeof next === "function" ? next(current) : next; + return { ...prev, [repo]: value }; + }); + }, + [repo], + ); + const maximized: Maximized = + (repo != null && maximizedByRepo[repo]) || "none"; + const dropMaximized = useCallback((id: string) => { + setMaximizedByRepo(({ [id]: _closed, ...rest }) => rest); + }, []); + return { maximized, setMaximized, dropMaximized }; +} \ No newline at end of file diff --git a/viewer-ui/src/hooks/usePaneOpeners.ts b/viewer-ui/src/hooks/usePaneOpeners.ts new file mode 100644 index 0000000..b6468a9 --- /dev/null +++ b/viewer-ui/src/hooks/usePaneOpeners.ts @@ -0,0 +1,115 @@ +import { useCallback } from "react"; +import { api, type Commit } from "../api"; +import type { CommitDrillDown } from "./useLog"; +import type { Pane } from "../types"; + +export interface UsePaneOpenersArgs { + repo: string | null; + handle: (err: unknown) => void; + setPane: React.Dispatch>; + paneRequestRef: React.MutableRefObject; + setCommitDrillDown: (v: CommitDrillDown | null) => void; +} + +export interface UsePaneOpenersResult { + openDiff: (path: string) => void; + openFile: (path: string) => void; + openCommit: (oid: string) => void; + openCommitFileDiff: (oid: string, path: string) => void; + openCommitFiles: (commit: Commit) => Promise; +} + +export function usePaneOpeners({ + repo, + handle, + setPane, + paneRequestRef, + setCommitDrillDown, +}: UsePaneOpenersArgs): UsePaneOpenersResult { + const openDiff = useCallback( + (path: string) => { + if (!repo) return; + const request = (paneRequestRef.current += 1); + api + .diff(repo, path) + .then((v) => { + if (request === paneRequestRef.current) setPane({ kind: "diff", value: v }); + }) + .catch((err) => { + if (request === paneRequestRef.current) handle(err); + }); + }, + [repo, handle, setPane, paneRequestRef], + ); + const openFile = useCallback( + (path: string) => { + if (!repo) return; + const request = (paneRequestRef.current += 1); + api + .file(repo, path) + .then((v) => { + if (request === paneRequestRef.current) setPane({ kind: "file", value: v }); + }) + .catch((err) => { + if (request === paneRequestRef.current) handle(err); + }); + }, + [repo, handle, setPane, paneRequestRef], + ); + const openCommit = useCallback( + (oid: string) => { + if (!repo) return; + const request = (paneRequestRef.current += 1); + api + .commit(repo, oid) + .then((v) => { + if (request === paneRequestRef.current) setPane({ kind: "diff", value: v }); + }) + .catch((err) => { + if (request === paneRequestRef.current) handle(err); + }); + }, + [repo, handle, setPane, paneRequestRef], + ); + const openCommitFileDiff = useCallback( + (oid: string, path: string) => { + if (!repo) return; + const request = (paneRequestRef.current += 1); + api + .commitFileDiff(repo, oid, path) + .then((v) => { + if (request === paneRequestRef.current) setPane({ kind: "diff", value: v }); + }) + .catch((err) => { + if (request === paneRequestRef.current) handle(err); + }); + }, + [repo, handle, setPane, paneRequestRef], + ); + const openCommitFiles = useCallback( + async (commit: Commit) => { + if (!repo) return; + const request = (paneRequestRef.current += 1); + try { + const result = await api.commitFiles(repo, commit.oid); + if (request !== paneRequestRef.current) return; + setCommitDrillDown({ commit, ...result }); + if (result.files.length === 0) { + setPane({ kind: "empty" }); + return; + } + // Match the TUI's selection state: entering a commit drill-down keeps + // the complete commit diff visible. Choosing a row below narrows the + // pane to that file only. + const diff = await api.commit(repo, commit.oid); + if (request === paneRequestRef.current) { + setPane({ kind: "diff", value: diff }); + } + } catch (err) { + if (request === paneRequestRef.current) handle(err); + } + }, + [repo, handle, setPane, paneRequestRef, setCommitDrillDown], + ); + return { openDiff, openFile, openCommit, openCommitFileDiff, openCommitFiles }; +} \ No newline at end of file diff --git a/viewer-ui/src/hooks/useRepoActions.ts b/viewer-ui/src/hooks/useRepoActions.ts new file mode 100644 index 0000000..c0d3646 --- /dev/null +++ b/viewer-ui/src/hooks/useRepoActions.ts @@ -0,0 +1,59 @@ +import { useCallback } from "react"; +import { api, type Repo } from "../api"; +import type { Pane, Tab } from "../types"; + +export interface UseRepoActionsArgs { + repos: Repo[]; + setRepos: React.Dispatch>; + setRepo: React.Dispatch>; + setPane: React.Dispatch>; + setTab: React.Dispatch>; + setPickerOpen: React.Dispatch>; + dropMaximized: (id: string) => void; + handle: (err: unknown) => void; +} + +export function useRepoActions({ + repos, + setRepos, + setRepo, + setPane, + setTab, + setPickerOpen, + dropMaximized, + handle, +}: UseRepoActionsArgs) { + // Focus a repository the folder picker just opened, rather than waiting for + // the next repo poll to notice it. + const selectOpenedRepo = useCallback( + (opened: Repo) => { + setRepos((prev) => + prev.some((r) => r.id === opened.id) ? prev : [...prev, opened], + ); + setRepo(opened.id); + setPane({ kind: "empty" }); + setTab("status"); + setPickerOpen(false); + }, + [setRepos, setRepo, setPane, setTab, setPickerOpen], + ); + + const closeRepo = useCallback( + async (id: string) => { + try { + await api.close(id); + const remaining = repos.filter((r) => r.id !== id); + setRepos(remaining); + setRepo((current) => + current === id ? (remaining[0]?.id ?? null) : current, + ); + dropMaximized(id); + } catch (err) { + handle(err); + } + }, + [repos, setRepos, setRepo, dropMaximized, handle], + ); + + return { selectOpenedRepo, closeRepo }; +} \ No newline at end of file diff --git a/viewer-ui/src/hooks/useRepoPoll.ts b/viewer-ui/src/hooks/useRepoPoll.ts new file mode 100644 index 0000000..562fbcd --- /dev/null +++ b/viewer-ui/src/hooks/useRepoPoll.ts @@ -0,0 +1,154 @@ +import { useEffect, useState } from "react"; +import { + api, + isNetworkError, + isUnauthorized, + type HotConfig, + type Repo, +} from "../api"; +import { nextClockOffset } from "../hot"; + +/// How often the tab bar re-reads the served set. The payload is a handful of +/// short strings, and this only has to feel prompt when a tab opens. +const REPO_POLL_MS = 3000; + +export interface UseRepoPollArgs { + authed: boolean | null; + setAuthed: React.Dispatch>; + handle: (err: unknown) => void; + adoptAccent: (accent: number) => void; + adoptSidebarWidth: (px: number) => void; + draggingRef: React.MutableRefObject; + accentWrites: React.MutableRefObject; + sidebarWrites: React.MutableRefObject; + resumeTick: number; +} + +export interface UseRepoPollResult { + repos: Repo[]; + setRepos: React.Dispatch>; + repo: string | null; + setRepo: React.Dispatch>; + hot: HotConfig | null; + clockSkewMs: number | null; + reposLoaded: boolean; +} + +export function useRepoPoll({ + authed, + setAuthed, + handle, + adoptAccent, + adoptSidebarWidth, + draggingRef, + accentWrites, + sidebarWrites, + resumeTick, +}: UseRepoPollArgs): UseRepoPollResult { + const [repos, setRepos] = useState([]); + const [repo, setRepo] = useState(null); + // The server's `agent_indicator` settings, which arrive with the repo list. + // Until they do, nothing is hot: guessing a window would flash a highlight + // that the real config might have turned off. + const [hot, setHot] = useState(null); + // How far this device's clock sits from the server's, refreshed by the same + // poll that delivers the config above. `null` until the first response, when + // there is nothing to correct by yet. + const [clockSkewMs, setClockSkewMs] = useState(null); + // False until the repo list has been fetched for the current session. Gates + // the loading splash so the window between logging in and the first repo + // response does not flash the "No repository open" empty state. + const [reposLoaded, setReposLoaded] = useState(false); + + // The catalog follows the TUI: a tab opened or closed there changes what is + // served. Poll it so the tab bar tracks that without a reload — status has + // its own live stream, but the repo *list* has no event source of its own. + useEffect(() => { + let cancelled = false; + let timer: ReturnType | undefined; + // Abort the in-flight poll on teardown so a request the device suspended + // mid-flight is dropped rather than left hanging: without this, every + // resume would start a fresh poll on top of an abandoned one. + const controller = new AbortController(); + const refresh = () => { + // A poll that left before the user cycled the accent carries the old + // colour. Applying it when it lands would flicker the swatch back for a + // poll interval, so responses older than the last local change drop + // their accent. Everything else in them is still current. + const writes = accentWrites.current; + const widthWrites = sidebarWrites.current; + return api + .repos(controller.signal) + .then(({ repos: list, hot, accent, sidebar_width, now_ms }) => { + if (cancelled) return; + setHot(hot); + setClockSkewMs((held) => nextClockOffset(held, now_ms, Date.now())); + if (accentWrites.current === writes) adoptAccent(accent); + // Same guard as the accent, plus one more: a poll must not snap the + // sidebar back to the old server width while a drag is live (it may + // have started after the counter bumped) or after one it predates. + if (sidebarWrites.current === widthWrites && !draggingRef.current) + adoptSidebarWidth(sidebar_width); + setAuthed(true); + // We now hold the authoritative list for this session; the initial + // splash can give way to the shell (or the empty-state prompt). + setReposLoaded(true); + setRepos(list); + // Keep the current selection when it survives; otherwise fall back to + // the first repo, so closing the active tab in the TUI does not leave + // the page pointing at an id the server no longer knows. + setRepo((current) => + current && list.some((r) => r.id === current) + ? current + : (list[0]?.id ?? null), + ); + if (!cancelled) timer = setTimeout(refresh, REPO_POLL_MS); + }) + .catch((err) => { + if (cancelled) return; + if (isUnauthorized(err)) { + // The session is gone; a later login re-runs this effect (authed is + // a dep) and reloads the list, so show the splash again until then. + setAuthed(false); + setReposLoaded(false); + } else if (!isNetworkError(err)) { + // A dropped connection here is expected (the device slept, a blip); + // this loop retries every interval and the resume nudge re-polls at + // once, so stay silent and let it self-heal. Real errors still show. + handle(err); + } + timer = setTimeout(refresh, REPO_POLL_MS); + }); + }; + + // Re-runs when `authed` flips true on login, giving an immediate repo fetch + // rather than waiting up to a poll interval — otherwise the post-login + // screen would sit on the empty state until the next tick. + refresh(); + return () => { + cancelled = true; + controller.abort(); + if (timer) clearTimeout(timer); + }; + }, [ + authed, + setAuthed, + handle, + adoptAccent, + adoptSidebarWidth, + resumeTick, + accentWrites, + sidebarWrites, + draggingRef, + ]); + + return { + repos, + setRepos, + repo, + setRepo, + hot, + clockSkewMs, + reposLoaded, + }; +} \ No newline at end of file diff --git a/viewer-ui/src/hooks/useResumeTick.ts b/viewer-ui/src/hooks/useResumeTick.ts new file mode 100644 index 0000000..2dcfc55 --- /dev/null +++ b/viewer-ui/src/hooks/useResumeTick.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; + +// Bumped when the tab comes back after the device slept or the network +// returned. A mobile browser suspends the page and drops the in-flight poll, +// so an immediate re-poll on resume refreshes at once instead of waiting out +// the interval. The status stream reconnects on its own (EventSource retries). +export function useResumeTick() { + const [resumeTick, setResumeTick] = useState(0); + useEffect(() => { + const wake = () => { + if (document.visibilityState === "visible") setResumeTick((t) => t + 1); + }; + document.addEventListener("visibilitychange", wake); + window.addEventListener("online", wake); + return () => { + document.removeEventListener("visibilitychange", wake); + window.removeEventListener("online", wake); + }; + }, []); + return resumeTick; +} \ No newline at end of file diff --git a/viewer-ui/src/hooks/useSidebarDrag.ts b/viewer-ui/src/hooks/useSidebarDrag.ts new file mode 100644 index 0000000..9d152b5 --- /dev/null +++ b/viewer-ui/src/hooks/useSidebarDrag.ts @@ -0,0 +1,143 @@ +import { useCallback, useRef, useState } from "react"; +import type { PointerEvent as ReactPointerEvent } from "react"; + +/// Horizontal travel before a pointer press on the sidebar divider counts as a +/// resize. Below this, a click or a vertical-only wobble commits nothing, so it +/// cannot overwrite the stored width with the viewport-capped display value. +const SIDEBAR_DRAG_THRESHOLD_PX = 3; + +/// Window within which two clicks on the divider read as a double-click and +/// reset the sidebar to its default width. +const DOUBLE_CLICK_MS = 400; + +export interface UseSidebarDragArgs { + sidebarRef: React.RefObject; + sidebarWidth: number; + resizeSidebar: (px: number) => void; + commitSidebarWidth: (px: number) => void; + resetSidebarWidth: () => void; + // Bumps App's write counter at drag start so a poll that left before the drag + // must not adopt the old server width mid-drag and snap the pane out from + // under the pointer. + bumpSidebarWrites: () => void; +} + +export interface UseSidebarDragResult { + draggingSidebar: boolean; + onSidebarDragStart: (e: ReactPointerEvent) => void; + onSidebarDragMove: (e: ReactPointerEvent) => void; + onSidebarDragEnd: () => void; + onSidebarDragCancel: () => void; + draggingRef: React.MutableRefObject; +} + +/** Dragging the divider between the sidebar and the diff pane. The new width + * is the pointer's distance from the sidebar's left edge, captured once at + * drag start so a mid-drag re-layout cannot move the origin under the pointer. */ +export function useSidebarDrag({ + sidebarRef, + sidebarWidth, + resizeSidebar, + commitSidebarWidth, + resetSidebarWidth, + bumpSidebarWrites, +}: UseSidebarDragArgs): UseSidebarDragResult { + const dragOriginRef = useRef(0); + const dragStartXRef = useRef(0); + const dragWidthRef = useRef(0); + // Synchronous drag gate. The state below drives the cursor and overlay, but + // the move guard and the once-only commit read this ref so neither a + // Strict-Mode double-invoke nor the duplicate pointerup/lost-capture pair can + // fire the write twice, and the first move is not lost to a stale state read. + const draggingRef = useRef(false); + // Whether the pointer actually moved between down and up. A bare click must + // not commit: after a window shrink the displayed width is `min(px, 50vw)` + // while the stored width is still `px`, so committing the click would persist + // the capped value and quietly overwrite the shared preference. + const dragMovedRef = useRef(false); + // Timestamp of the last no-move release, so two quick clicks on the divider + // read as a double-click and reset the width. Detected here rather than via a + // native `ondblclick` because the drag's `preventDefault` on pointerdown can + // suppress the synthesized click/dblclick events. + const lastClickRef = useRef(0); + const [draggingSidebar, setDraggingSidebar] = useState(false); + const onSidebarDragStart = useCallback( + (e: ReactPointerEvent) => { + // Primary button / first touch only, matching a native dblclick: a + // right- or middle-click must not start a drag or arm the reset. + if (e.button !== 0 || !e.isPrimary) return; + const left = sidebarRef.current?.getBoundingClientRect().left; + if (left === undefined) return; + dragOriginRef.current = left; + dragStartXRef.current = e.clientX; + dragWidthRef.current = sidebarWidth; + draggingRef.current = true; + dragMovedRef.current = false; + bumpSidebarWrites(); + setDraggingSidebar(true); + e.currentTarget.setPointerCapture(e.pointerId); + e.preventDefault(); + }, + [sidebarWidth, sidebarRef, bumpSidebarWrites], + ); + const onSidebarDragMove = useCallback( + (e: ReactPointerEvent) => { + if (!draggingRef.current) return; + // Ignore movement until the pointer has travelled horizontally: a + // vertical-only move or touch jitter must not count as a resize, or it + // would commit the clientX-derived (viewport-capped) width and overwrite + // the shared absolute preference without the user meaning to. + if ( + !dragMovedRef.current && + Math.abs(e.clientX - dragStartXRef.current) < SIDEBAR_DRAG_THRESHOLD_PX + ) { + return; + } + dragMovedRef.current = true; + dragWidthRef.current = e.clientX - dragOriginRef.current; + resizeSidebar(dragWidthRef.current); + }, + [resizeSidebar], + ); + // Fires on both pointerup and lost capture; the ref gate commits exactly once, + // and only when the pointer actually moved (a bare click stores nothing). + const onSidebarDragEnd = useCallback(() => { + if (!draggingRef.current) return; + draggingRef.current = false; + setDraggingSidebar(false); + if (dragMovedRef.current) { + commitSidebarWidth(dragWidthRef.current); + lastClickRef.current = 0; + return; + } + // No move: a second quick click resets the width to the default; a lone + // click just arms the next one. + const now = Date.now(); + if (now - lastClickRef.current < DOUBLE_CLICK_MS) { + lastClickRef.current = 0; + resetSidebarWidth(); + } else { + lastClickRef.current = now; + } + }, [commitSidebarWidth, resetSidebarWidth]); + // A cancelled gesture (OS takeover, focus loss — mostly touch/pen) must not + // persist its partial position. Clear the gate without committing; the + // following lost-capture event then no-ops, and the next poll reconciles the + // partial width back to the server's last committed value. + const onSidebarDragCancel = useCallback(() => { + draggingRef.current = false; + dragMovedRef.current = false; + // Also disarm the double-click: a cancelled gesture is not a completed + // click, so it must not pair with the next one into a reset. + lastClickRef.current = 0; + setDraggingSidebar(false); + }, []); + return { + draggingSidebar, + onSidebarDragStart, + onSidebarDragMove, + onSidebarDragEnd, + onSidebarDragCancel, + draggingRef, + }; +} \ No newline at end of file diff --git a/viewer-ui/src/hooks/useStatus.ts b/viewer-ui/src/hooks/useStatus.ts new file mode 100644 index 0000000..4bc1005 --- /dev/null +++ b/viewer-ui/src/hooks/useStatus.ts @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState } from "react"; +import { api, subscribeStatus, type Status } from "../api"; +import type { Pane, Tab } from "../types"; + +export type { Pane, Tab }; + +export interface UseStatusArgs { + repo: string | null; + authed: boolean | null; + resumeTick: number; + tab: Tab; + pane: Pane; + setPane: React.Dispatch>; + handle: (err: unknown) => void; + paneRequestRef: React.MutableRefObject; +} + +export interface UseStatusResult { + status: Status | null; + paneRef: React.MutableRefObject; + tabRef: React.MutableRefObject; +} + +export function useStatus({ + repo, + authed, + resumeTick, + tab, + pane, + setPane, + handle, + paneRequestRef, +}: UseStatusArgs): UseStatusResult { + const [status, setStatus] = useState(null); + // Latest pane/tab for the status-activity effect, which reacts to new status + // snapshots and must not re-run when the pane changes (that would loop on its + // own re-fetch). + const paneRef = useRef(pane); + paneRef.current = pane; + const tabRef = useRef(tab); + tabRef.current = tab; + + // Clear the status when the repo changes or the session re-authenticates, so + // the pane shows "Loading…" for the new context — but NOT on a resume + // re-subscribe below (which excludes these deps), so a wake keeps the last + // snapshot on screen until the fresh one replays. + useEffect(() => { + setStatus(null); + }, [repo, authed]); + + // Live status. The server replays the latest snapshot on subscribe, so this + // both seeds the view and keeps it current — no separate initial fetch. + // Re-subscribed on resume too: a mobile browser can leave the EventSource shut + // after a suspend instead of reconnecting, so a fresh subscription guarantees + // the stream (and the snapshot replay) come back when the tab does. + useEffect(() => { + if (!repo || !authed) return; + return subscribeStatus(repo, setStatus); + }, [repo, authed, resumeTick]); + + // Keep the status tab's open diff honest when the working tree changes under + // it (a commit lands, files are staged/edited): reload it in place if its file + // is still changed, drop it if the file left the list — the same rule the TUI + // applies on a status refresh. Log and tree panes show history or raw file + // contents, which working-tree activity does not invalidate, so they are left + // untouched. Keyed on `status` only; pane/tab are read through refs so the + // effect does not re-fire on its own re-fetch. + useEffect(() => { + if (!repo || !status) return; + const current = paneRef.current; + if (tabRef.current !== "status" || current.kind !== "diff") return; + const path = current.value.path; + if (!status.files.some((f) => f.path === path)) { + setPane({ kind: "empty" }); + return; + } + // Reads the request counter without bumping it: this refresh is on the + // pane the user is already looking at, so it yields to anything they open + // while it is in flight rather than invalidating their click. + const request = paneRequestRef.current; + // Two snapshots arriving close together would otherwise both reload the + // same path against the same counter, and the slower of the two could land + // last with the older content. The next snapshot re-runs this effect, so + // its cleanup is what retires the previous refresh. + let active = true; + // Three conditions, because the counter alone answers the wrong question. + // It says "no newer request has started", not "the pane is still the one + // being refreshed" — and those come apart: opening B raises the counter, + // then a snapshot arrives while A is still on screen, so this refresh of A + // captures *B's* number and outlives B's own response. Checking that the + // rendered pane is still this path is what keeps a background reload from + // undoing the file the user just clicked. + const stillOurs = () => { + const shown = paneRef.current; + return ( + active && + request === paneRequestRef.current && + shown.kind === "diff" && + shown.value.path === path + ); + }; + api + .diff(repo, path) + .then((v) => { + if (stillOurs()) setPane({ kind: "diff", value: v }); + }) + .catch((err) => { + if (stillOurs()) handle(err); + }); + return () => { + active = false; + }; + }, [status, repo, handle, paneRef, tabRef, paneRequestRef, setPane]); + + return { status, paneRef, tabRef }; +} \ No newline at end of file diff --git a/viewer-ui/src/hooks/useTree.ts b/viewer-ui/src/hooks/useTree.ts new file mode 100644 index 0000000..87be308 --- /dev/null +++ b/viewer-ui/src/hooks/useTree.ts @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useState } from "react"; +import { api, type TreeEntry, type TreeMatch } from "../api"; + +/// Debounce for the recursive tree search: each keystroke hits the filesystem +/// on the backend, so wait for a pause in typing before firing. +const TREE_SEARCH_DEBOUNCE_MS = 180; + +export interface UseTreeArgs { + repo: string | null; + authed: boolean | null; + tab: "status" | "log" | "tree"; + filter: string; + filterOpen: boolean; + handle: (err: unknown) => void; +} + +export interface UseTreeResult { + treeChildren: Record; + setTreeChildren: React.Dispatch< + React.SetStateAction> + >; + treeExpanded: Set; + setTreeExpanded: React.Dispatch>>; + treeMatches: TreeMatch[]; + treeTruncated: boolean; + treeSearchLoading: boolean; + loadTreeChildren: (path: string) => void; + toggleTreeDir: (path: string) => void; + revealTreeDir: (path: string) => void; +} + +export function useTree({ + repo, + authed, + tab, + filter, + filterOpen, + handle, +}: UseTreeArgs): UseTreeResult { + // Lazy folder tree, mirroring the TUI: children are cached per directory + // ("" is the root) and fetched on demand, and the set of expanded directories + // derives the visible rows. + const [treeChildren, setTreeChildren] = useState>( + {}, + ); + const [treeExpanded, setTreeExpanded] = useState>(new Set()); + const [treeMatches, setTreeMatches] = useState([]); + const [treeTruncated, setTreeTruncated] = useState(false); + const [treeSearchLoading, setTreeSearchLoading] = useState(false); + + // Load (and refresh) the root level whenever the tree tab is shown; deeper + // levels are fetched lazily as folders expand, and expansion state is kept + // across tab switches. + useEffect(() => { + if (!repo || !authed || tab !== "tree") return; + api + .tree(repo, "") + .then((r) => setTreeChildren((cache) => ({ ...cache, "": r.entries }))) + .catch(handle); + }, [repo, authed, tab, handle]); + + // Recursive tree search runs against the backend (unlike the status/log + // filters, which match an already-loaded list client-side), so it is debounced + // and only active while the filter box holds a query on the tree tab. + useEffect(() => { + if (!repo || !authed || tab !== "tree" || !filterOpen || !filter) { + setTreeMatches([]); + setTreeTruncated(false); + setTreeSearchLoading(false); + return; + } + // Mark loading up front so the debounce window shows "searching…" rather + // than a premature "no matches" before the first result lands. + setTreeSearchLoading(true); + // Guard against out-of-order responses: a slower earlier request must not + // overwrite a newer one's results, and nothing may update state after the + // query changed or the tab/repo was left. + let active = true; + const timer = setTimeout(() => { + api + .treeSearch(repo, filter) + .then((r) => { + if (!active) return; + setTreeMatches(r.matches); + setTreeTruncated(r.truncated); + }) + .catch((err) => { + if (active) handle(err); + }) + .finally(() => { + if (active) setTreeSearchLoading(false); + }); + }, TREE_SEARCH_DEBOUNCE_MS); + return () => { + active = false; + clearTimeout(timer); + }; + }, [repo, authed, tab, filter, filterOpen, handle]); + + // Fetch one directory level into the cache (used the first time a folder is + // expanded or revealed). + const loadTreeChildren = useCallback( + (path: string) => { + if (!repo) return; + api + .tree(repo, path) + .then((r) => setTreeChildren((cache) => ({ ...cache, [path]: r.entries }))) + .catch(handle); + }, + [repo, handle], + ); + const toggleTreeDir = useCallback( + (path: string) => { + const willExpand = !treeExpanded.has(path); + setTreeExpanded((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + if (willExpand && !(path in treeChildren)) loadTreeChildren(path); + }, + [treeExpanded, treeChildren, loadTreeChildren], + ); + // Reveal a path found by search: expand every ancestor directory (fetching + // levels as needed) and the directory itself, then leave the search view. + const revealTreeDir = useCallback( + (path: string) => { + const parts = path.split("/"); + const dirs: string[] = []; + let acc = ""; + for (const part of parts) { + acc = acc ? `${acc}/${part}` : part; + dirs.push(acc); + } + setTreeExpanded((prev) => { + const next = new Set(prev); + dirs.forEach((d) => next.add(d)); + return next; + }); + dirs.forEach((d) => { + if (!(d in treeChildren)) loadTreeChildren(d); + }); + }, + [treeChildren, loadTreeChildren], + ); + + return { + treeChildren, + setTreeChildren, + treeExpanded, + setTreeExpanded, + treeMatches, + treeTruncated, + treeSearchLoading, + loadTreeChildren, + toggleTreeDir, + revealTreeDir, + }; +} \ No newline at end of file diff --git a/viewer-ui/src/terminalLayout.ts b/viewer-ui/src/terminalLayout.ts new file mode 100644 index 0000000..fc91f3a --- /dev/null +++ b/viewer-ui/src/terminalLayout.ts @@ -0,0 +1,109 @@ +import type { Terminal } from "@xterm/xterm"; +import type { FitAddon } from "@xterm/addon-fit"; + +export interface PaneView { + term: Terminal; + fit: FitAddon; +} + +/// Pane titles are capped by display width (not character count) so a title of +/// wide CJK glyphs cannot overflow its cell header; the full title stays +/// reachable through the tooltip. Matches the viewer's label convention. +export const TAB_TITLE_MAX_CELLS = 20; + +/// Pointer travel before a header press becomes a pane drag rather than a click +/// that just focuses the pane. Mirrors the sidebar divider's small dead zone. +export const PANE_DRAG_THRESHOLD_PX = 4; + +export function gcd(a: number, b: number): number { + while (b) [a, b] = [b, a % b]; + return a; +} + +/// Columns per row for `n` panes, mirroring the TUI's `grid_row_plan` +/// (src/ui/terminal_tab.rs): a balanced grid, with the two-pane case flipping to +/// stacked when the panel is taller than it is wide. +export function rowPlan(n: number, wide: boolean): number[] { + switch (n) { + case 1: + return [1]; + case 2: + return wide ? [2] : [1, 1]; + case 3: + return [2, 1]; + case 4: + return [2, 2]; + case 5: + return [3, 2]; + case 6: + return [3, 3]; + case 7: + return [4, 3]; + default: + return [4, 4]; // 8 (the per-repo cap); also a sane fallback beyond it + } +} + +export interface CellPlacement { + row: number; + colStart: number; + colSpan: number; +} + +/// Flatten `rowPlan` into a CSS-grid placement per pane. Rows can hold different +/// column counts (e.g. 3 = [2,1]); a shared column count (the LCM of the rows' +/// counts) lets each cell span evenly so every row fills the width. +export function planLayout( + n: number, + wide: boolean, +): { cols: number; rows: number; cells: CellPlacement[] } { + const plan = rowPlan(n, wide); + const cols = plan.reduce((acc, c) => (acc * c) / gcd(acc, c), 1); + const cells: CellPlacement[] = []; + plan.forEach((count, r) => { + const span = cols / count; + for (let k = 0; k < count; k++) { + cells.push({ row: r + 1, colStart: k * span + 1, colSpan: span }); + } + }); + return { cols, rows: plan.length, cells }; +} + +/// True for code points that occupy two terminal cells. An approximation of the +/// common East Asian wide / fullwidth ranges — enough to keep CJK titles from +/// overflowing without pulling in a full Unicode width table. +export function isWide(cp: number): boolean { + return ( + (cp >= 0x1100 && cp <= 0x115f) || + (cp >= 0x2e80 && cp <= 0x303e) || + (cp >= 0x3041 && cp <= 0x33ff) || + (cp >= 0x3400 && cp <= 0x4dbf) || + (cp >= 0x4e00 && cp <= 0x9fff) || + (cp >= 0xa000 && cp <= 0xa4cf) || + (cp >= 0xac00 && cp <= 0xd7a3) || + (cp >= 0xf900 && cp <= 0xfaff) || + (cp >= 0xfe30 && cp <= 0xfe4f) || + (cp >= 0xff00 && cp <= 0xff60) || + (cp >= 0xffe0 && cp <= 0xffe6) || + (cp >= 0x1f300 && cp <= 0x1faff) || + (cp >= 0x20000 && cp <= 0x3fffd) + ); +} + +/// Truncate `text` to at most `max` display cells, appending an ellipsis (which +/// costs one cell) when anything was dropped. +export function truncateCells(text: string, max: number): string { + let width = 0; + for (const ch of text) width += isWide(ch.codePointAt(0) ?? 0) ? 2 : 1; + if (width <= max) return text; + + let used = 0; + let out = ""; + for (const ch of text) { + const cw = isWide(ch.codePointAt(0) ?? 0) ? 2 : 1; + if (used + cw > max - 1) break; + out += ch; + used += cw; + } + return `${out}…`; +} \ No newline at end of file diff --git a/viewer-ui/src/tree.ts b/viewer-ui/src/tree.ts new file mode 100644 index 0000000..6f23228 --- /dev/null +++ b/viewer-ui/src/tree.ts @@ -0,0 +1,30 @@ +import type { TreeEntry } from "./api"; + +/// One visible row of the folder tree, flattened with its nesting depth for +/// indentation. +export interface TreeRow { + path: string; + name: string; + is_dir: boolean; + depth: number; +} + +/// Flatten the lazily-cached tree into the rows to render: a depth-first walk +/// from the root that descends only into expanded directories whose children +/// have been fetched. An expanded directory whose children are still loading +/// simply shows no rows beneath it until they arrive. +export function buildTreeRows( + children: Record, + expanded: Set, +): TreeRow[] { + const rows: TreeRow[] = []; + const walk = (dir: string, depth: number) => { + for (const entry of children[dir] ?? []) { + const path = dir ? `${dir}/${entry.name}` : entry.name; + rows.push({ path, name: entry.name, is_dir: entry.is_dir, depth }); + if (entry.is_dir && expanded.has(path)) walk(path, depth + 1); + } + }; + walk("", 0); + return rows; +} \ No newline at end of file diff --git a/viewer-ui/src/types.ts b/viewer-ui/src/types.ts new file mode 100644 index 0000000..71e08c0 --- /dev/null +++ b/viewer-ui/src/types.ts @@ -0,0 +1,13 @@ +import type { Diff, FileView } from "./api"; + +export type Tab = "status" | "log" | "tree"; + +/// Which panel, if any, has been given the whole work area. One value rather +/// than a flag per panel: only one can hold the space, and a pair of booleans +/// would admit a "both maximised" state that has no layout. +export type Maximized = "none" | "terminal" | "files"; + +export type Pane = + | { kind: "diff"; value: Diff } + | { kind: "file"; value: FileView } + | { kind: "empty" }; \ No newline at end of file diff --git a/viewer-ui/src/useHotClock.ts b/viewer-ui/src/useHotClock.ts new file mode 100644 index 0000000..13b1d8b --- /dev/null +++ b/viewer-ui/src/useHotClock.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; +import { anyHot, HOT_TICK_MS, type HotStage } from "./hot"; +import type { ChangedFile } from "./api"; + +/** Recency styling for one status row, mirroring the TUI: the status letters + * keep their change colour at every stage — the change kind stays readable — + * and only the path carries the highlight, so a row does not shift as it + * fades. */ +export const HOT_CLASS: Record = { + fresh: "text-accent font-bold", + warm: "text-accent", + cool: "", +}; + +/** The clock the recently-touched highlight is dated against. + * + * A file cools with time rather than with any event, so the list has to + * re-render on its own to fade. The ticking is bounded on both ends: it starts + * only when a snapshot actually contains a hot file, and stops itself once the + * last one cools — an idle repository re-renders nothing. Every snapshot is + * still dated on arrival, ticker or not, so a stopped clock never judges one. + * + * `windowMs <= 0` (the server's indicator turned off, or its config not yet + * loaded) never ticks; `classifyHot` reads everything as cool at that window. */ +export function useHotClock( + files: ChangedFile[] | undefined, + windowMs: number, + offsetMs: number, +): number { + // Every reading is shifted onto the server's clock, because that is the clock + // the mtimes it is compared against were measured on. `offsetMs` is a + // dependency so a poll that refines it restarts the tick on the corrected + // clock rather than finishing the current fade on the old one. + const [now, setNow] = useState(() => Date.now() + offsetMs); + useEffect(() => { + if (windowMs <= 0 || !files) return; + const mtimes = files.map((f) => f.mtime); + // Date the snapshot before deciding whether it needs a ticker, not after. + // `now` stops advancing when the last file cools, so a snapshot arriving + // long afterwards — the tab left open, or another repository selected — + // would otherwise be measured against whenever the ticker last stopped, and + // a file touched around that moment would read as freshly touched forever. + const start = Date.now() + offsetMs; + setNow(start); + if (!anyHot(mtimes, start, windowMs)) return; + const id = setInterval(() => { + const tick = Date.now() + offsetMs; + setNow(tick); + if (!anyHot(mtimes, tick, windowMs)) clearInterval(id); + }, HOT_TICK_MS); + return () => clearInterval(id); + }, [files, windowMs, offsetMs]); + return now; +} \ No newline at end of file diff --git a/viewer-ui/src/usePaneDrag.ts b/viewer-ui/src/usePaneDrag.ts new file mode 100644 index 0000000..f16037e --- /dev/null +++ b/viewer-ui/src/usePaneDrag.ts @@ -0,0 +1,92 @@ +import { useRef, useState } from "react"; +import { reorderByDrop } from "./paneOrder"; +import { PANE_DRAG_THRESHOLD_PX } from "./terminalLayout"; + +interface UsePaneDragArgs { + panes: number[]; + zoomed: number | null; + onFocus: (pane: number) => void; + onReorder: (order: number[]) => void; +} + +/// Pane drag-to-reorder. The id being dragged and the drop target live in refs +/// (read on pointerup, free of stale-closure risk); the mirrored state only +/// drives the drag styling. `draggingRef` flips once the pointer crosses the +/// dead zone, separating a reorder from a plain header click. +export function usePaneDrag({ panes, zoomed, onFocus, onReorder }: UsePaneDragArgs) { + const dragPaneRef = useRef(null); + const dragStartRef = useRef<{ x: number; y: number } | null>(null); + const dragOverRef = useRef(null); + const draggingRef = useRef(false); + const [draggingPane, setDraggingPane] = useState(null); + const [dragOverPane, setDragOverPane] = useState(null); + + const reorderable = zoomed === null && panes.length > 1; + + const endPaneDrag = () => { + dragPaneRef.current = null; + dragStartRef.current = null; + dragOverRef.current = null; + draggingRef.current = false; + setDraggingPane(null); + setDragOverPane(null); + }; + + const onPaneDragStart = (e: React.PointerEvent, pane: number) => { + // A press on the header's own buttons (zoom, close) is theirs — do not + // focus or start a drag, matching the pre-drag behaviour where those + // buttons stopped the focus press from propagating. + if ((e.target as HTMLElement).closest("button")) return; + onFocus(pane); + // Primary button / first touch only, and only when there is a grid to + // rearrange (more than one pane, not zoomed). + if (e.button !== 0 || !reorderable) return; + dragPaneRef.current = pane; + dragStartRef.current = { x: e.clientX, y: e.clientY }; + draggingRef.current = false; + e.currentTarget.setPointerCapture(e.pointerId); + }; + + const onPaneDragMove = (e: React.PointerEvent) => { + const dragged = dragPaneRef.current; + const start = dragStartRef.current; + if (dragged === null || start === null) return; + if ( + !draggingRef.current && + Math.hypot(e.clientX - start.x, e.clientY - start.y) < + PANE_DRAG_THRESHOLD_PX + ) { + return; + } + draggingRef.current = true; + setDraggingPane(dragged); + // Which cell is under the pointer. Pointer capture does not change hit + // testing, so this still finds the pane being hovered, not the dragged one. + const el = document + .elementFromPoint(e.clientX, e.clientY) + ?.closest("[data-pane-id]"); + const over = el ? Number(el.getAttribute("data-pane-id")) : null; + const target = over !== null && over !== dragged ? over : null; + dragOverRef.current = target; + setDragOverPane(target); + }; + + const onPaneDragEnd = () => { + const dragged = dragPaneRef.current; + const target = dragOverRef.current; + if (dragged !== null && draggingRef.current && target !== null) { + onReorder(reorderByDrop(panes, dragged, target)); + } + endPaneDrag(); + }; + + return { + draggingPane, + dragOverPane, + reorderable, + endPaneDrag, + onPaneDragStart, + onPaneDragMove, + onPaneDragEnd, + }; +} \ No newline at end of file diff --git a/viewer-ui/src/useTerminalSocket.ts b/viewer-ui/src/useTerminalSocket.ts new file mode 100644 index 0000000..abf9ab0 --- /dev/null +++ b/viewer-ui/src/useTerminalSocket.ts @@ -0,0 +1,144 @@ +import { useEffect } from "react"; +import type { MutableRefObject } from "react"; +import { reconcileOrder } from "./paneOrder"; +import { toast } from "./toast"; +import type { PaneView } from "./terminalLayout"; + +interface UseTerminalSocketArgs { + repo: string; + socketRef: MutableRefObject; + viewsRef: MutableRefObject>; + pendingRef: MutableRefObject>; + sentSizesRef: MutableRefObject>; + lastActiveByRepoRef: MutableRefObject>; + expectCreateRef: MutableRefObject; + setPanes: React.Dispatch>; + setActive: React.Dispatch>; + setZoomed: React.Dispatch>; + setTitles: React.Dispatch>>; +} + +/// One WebSocket multiplexes every terminal for a repository. Pane ids belong +/// to a repository's own terminal hub, so switching repos must reset the pane +/// list and dispose the old terminals — otherwise stale ids point at panes the +/// new repo never created. +export function useTerminalSocket({ + repo, + socketRef, + viewsRef, + pendingRef, + sentSizesRef, + lastActiveByRepoRef, + expectCreateRef, + setPanes, + setActive, + setZoomed, + setTitles, +}: UseTerminalSocketArgs) { + useEffect(() => { + let closedByUs = false; + let reconnectTimer: ReturnType | undefined; + + const disposeAll = () => { + viewsRef.current.forEach((view) => view.term.dispose()); + viewsRef.current.clear(); + pendingRef.current.clear(); + sentSizesRef.current.clear(); + }; + + const connect = () => { + // Each (re)connection starts from a clean slate and lets the server + // repopulate it: on connect the hub replays every live pane and its + // scrollback, so a browser refresh restores the terminals while a server + // restart (no panes to replay) correctly comes back empty. Keeping stale + // local panes would instead point at terminals the new socket never + // announced. + setPanes([]); + setActive(null); + setZoomed(null); + setTitles({}); + disposeAll(); + + const scheme = location.protocol === "https:" ? "wss:" : "ws:"; + const socket = new WebSocket( + `${scheme}//${location.host}/ws/term?repo=${encodeURIComponent(repo)}`, + ); + socket.binaryType = "arraybuffer"; + socketRef.current = socket; + + socket.onmessage = (event) => { + if (typeof event.data === "string") { + const message = JSON.parse(event.data); + if (message.type === "created") { + const pane = message.pane; + setPanes((current) => [...current, pane]); + if (expectCreateRef.current > 0) { + // A terminal this client just asked for: focus follows creation. + expectCreateRef.current -= 1; + setActive(pane); + lastActiveByRepoRef.current.set(repo, pane); + } else if (lastActiveByRepoRef.current.get(repo) === pane) { + // A replayed pane that was focused before switching away — restore + // it rather than letting focus land on the last replayed pane. + setActive(pane); + } + } else if (message.type === "exited") { + setPanes((current) => current.filter((p) => p !== message.pane)); + setActive((current) => (current === message.pane ? null : current)); + setZoomed((current) => (current === message.pane ? null : current)); + pendingRef.current.delete(message.pane); + sentSizesRef.current.delete(message.pane); + setTitles((current) => { + if (!(message.pane in current)) return current; + const next = { ...current }; + delete next[message.pane]; + return next; + }); + } else if (message.type === "reordered") { + // The hub's canonical order after a drag — this client's or another + // device's. Adopt it, reconciled against the panes we actually hold + // so a "created"/"exited" that raced it cannot desync the grid. + // active/zoomed are pane ids, so they survive the reorder untouched. + setPanes((current) => reconcileOrder(current, message.order)); + } else if (message.type === "error") { + // A create was refused (e.g. the per-repo cap); do not let the + // pending focus-follow attach to an unrelated later "created". + expectCreateRef.current = 0; + toast.error(message.message); + } + return; + } + const frame = new Uint8Array(event.data as ArrayBuffer); + if (frame.length < 4) return; + const pane = new DataView(frame.buffer).getUint32(0, true); + const bytes = frame.subarray(4); + const view = viewsRef.current.get(pane); + if (view) { + view.term.write(bytes); + } else { + // The view is created by a later effect; hold this until then. + const queue = pendingRef.current.get(pane) ?? []; + queue.push(bytes); + pendingRef.current.set(pane, queue); + } + }; + // Reconnect quietly. The control socket is always open — it is how a + // terminal gets created — so a drop with nothing running is not worth + // alarming the user about; just wait and retry. A restart thus heals + // into a clean, empty panel rather than a stuck error. + socket.onclose = () => { + if (closedByUs) return; + reconnectTimer = setTimeout(connect, 1000); + }; + }; + + connect(); + + return () => { + closedByUs = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + socketRef.current?.close(); + disposeAll(); + }; + }, [repo]); +} \ No newline at end of file diff --git a/viewer-ui/src/useTerminalViews.ts b/viewer-ui/src/useTerminalViews.ts new file mode 100644 index 0000000..ebdec1e --- /dev/null +++ b/viewer-ui/src/useTerminalViews.ts @@ -0,0 +1,71 @@ +import { useEffect } from "react"; +import type { MutableRefObject } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import type { PaneView } from "./terminalLayout"; + +interface UseTerminalViewsArgs { + panes: number[]; + socketRef: MutableRefObject; + viewsRef: MutableRefObject>; + bodyRefs: MutableRefObject>; + pendingRef: MutableRefObject>; + setTitles: React.Dispatch>>; +} + +/// Materialise one xterm per pane, opened into that pane's cell body (rendered +/// keyed by pane so it survives grid reflows). `open()` runs once here; dispose +/// the views of panes that have gone away. +export function useTerminalViews({ + panes, + socketRef, + viewsRef, + bodyRefs, + pendingRef, + setTitles, +}: UseTerminalViewsArgs) { + useEffect(() => { + for (const pane of panes) { + if (viewsRef.current.has(pane)) continue; + const body = bodyRefs.current.get(pane); + if (!body) continue; // its cell has not mounted yet; a later pass catches it + + const term = new Terminal({ + fontFamily: getComputedStyle(document.body).fontFamily, + fontSize: 12, + theme: { background: "#0b0b0d", foreground: "#e6e6ec" }, + cursorBlink: true, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.onData((data) => + socketRef.current?.send(JSON.stringify({ type: "input", pane, data })), + ); + // xterm parses OSC 0/2 window-title sequences; mirror the latest non-empty + // one into the cell title. An empty title is ignored so the previous label + // (or the "term N" fallback) stands, matching the TUI. + term.onTitleChange((title) => { + const cleaned = title.replace(/\s+/g, " ").trim(); + if (!cleaned) return; + setTitles((current) => ({ ...current, [pane]: cleaned })); + }); + term.open(body); + viewsRef.current.set(pane, { term, fit }); + + // Flush any output (typically replayed scrollback) that arrived before + // this view existed, in order, so the restored screen is complete. + const queued = pendingRef.current.get(pane); + if (queued) { + for (const chunk of queued) term.write(chunk); + pendingRef.current.delete(pane); + } + } + + for (const [pane, view] of viewsRef.current) { + if (!panes.includes(pane)) { + view.term.dispose(); + viewsRef.current.delete(pane); + } + } + }, [panes]); +} \ No newline at end of file diff --git a/viewer-ui/src/utils.ts b/viewer-ui/src/utils.ts new file mode 100644 index 0000000..9578374 --- /dev/null +++ b/viewer-ui/src/utils.ts @@ -0,0 +1,26 @@ +/// Compact relative age of a unix timestamp (seconds), matching the TUI's log +/// column (e.g. "3s", "5m", "2h", "4d", "6mo", "1y"). +export function formatRelativeTime(ts: number): string { + const s = Math.max(0, Math.floor(Date.now() / 1000 - ts)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + if (s < 86400 * 30) return `${Math.floor(s / 86400)}d`; + if (s < 86400 * 365) return `${Math.floor(s / (86400 * 30))}mo`; + return `${Math.floor(s / (86400 * 365))}y`; +} + +/** Background tint for a changed line, shared by the unified and split views. */ +export function diffLineBg(kind: string): string { + if (kind === "+") return "bg-added/10"; + if (kind === "-") return "bg-removed/10"; + return ""; +} + +/** git status XY codes, coloured by how much attention each deserves. */ +export function statusColor(code: string) { + if (code === "?") return "text-ink-400"; + if (code === "D") return "text-removed"; + if (code === "A") return "text-added"; + return "text-accent"; +} \ No newline at end of file From dcda8a64bb1d431d6a2a6ce3d54e8322a1d1d534 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 25 Jul 2026 13:03:47 +0900 Subject: [PATCH 13/15] refactor: streamline source comments --- src/app.rs | 118 ++++--------- src/app/app_impl.rs | 70 +++----- src/app/auto_follow.rs | 28 +-- src/app/commit_log_apply.rs | 66 +++---- src/app/commit_log_fetch.rs | 82 +++------ src/app/commit_log_pagination.rs | 41 ++--- src/app/diff_load.rs | 87 +++------- src/app/file_view_load.rs | 25 +-- src/app/focus.rs | 67 +++----- src/app/log_nav.rs | 27 +-- src/app/navigation.rs | 20 +-- src/app/scroll.rs | 13 +- src/app/session_io.rs | 94 +++++----- src/app/snapshot_io.rs | 62 +++---- src/app/terminal_ctrl.rs | 85 +++------ src/app/tree.rs | 125 ++++---------- src/app/tree_nav.rs | 53 +++--- src/config/layout.rs | 45 +++-- src/config/panels.rs | 31 ++-- src/config/web.rs | 35 ++-- src/input/encode.rs | 27 ++- src/input/routing.rs | 69 ++++---- src/mouse.rs | 108 ++++-------- src/ui/commit_list.rs | 15 +- src/ui/diff_pane/highlight.rs | 14 +- src/ui/diff_pane/mod.rs | 47 ++--- src/ui/diff_pane/pane_impl.rs | 37 ++-- src/ui/diff_pane/search.rs | 14 +- src/ui/diff_viewer/file_view.rs | 7 +- src/ui/diff_viewer/mod.rs | 12 +- src/ui/diff_viewer/split_view.rs | 4 +- src/ui/file_list.rs | 10 +- src/ui/file_view.rs | 39 ++--- src/ui/hint_bar.rs | 39 ++--- src/ui/hint_text.rs | 31 ++-- src/ui/log_view/mod.rs | 41 ++--- src/ui/mod.rs | 9 +- src/ui/project_tab/mod.rs | 70 +++----- src/ui/search.rs | 8 +- src/ui/status_view.rs | 37 ++-- src/ui/terminal_tab/cells.rs | 14 +- src/ui/terminal_tab/layout.rs | 22 +-- src/ui/terminal_tab/mod.rs | 7 +- src/ui/terminal_tab/screen.rs | 10 +- src/ui/terminal_tab/tab_bar.rs | 28 ++- src/ui/tree_list.rs | 9 +- src/ui/tree_view/mod.rs | 113 +++++------- src/web/common/auth.rs | 7 +- src/web/common/conn.rs | 5 +- src/web/common/http.rs | 3 +- src/web/common/mod.rs | 4 +- src/web/common/sse.rs | 23 ++- src/web/frontend.rs | 4 +- src/web/mod.rs | 10 -- src/web/protocol/mod.rs | 43 ++--- src/web/server/mod.rs | 6 +- src/web/viewer/catalog/mod.rs | 9 +- src/web/viewer/dto/envelope.rs | 4 +- src/web/viewer/dto/log.rs | 7 +- src/web/viewer/dto/mod.rs | 12 +- src/web/viewer/dto/status.rs | 6 +- src/web/viewer/highlight.rs | 8 +- src/web/viewer/limits.rs | 58 +++---- src/web/viewer/mod.rs | 11 +- src/web/viewer/prefs.rs | 13 +- src/web/viewer/runtime/mod.rs | 1 - src/web/viewer/server/handlers.rs | 5 +- src/web/viewer/server/mod.rs | 16 +- src/web/viewer/server/mutations.rs | 5 +- src/web/viewer/server/routes.rs | 9 +- src/web/viewer/terminal/hub_run.rs | 20 ++- src/web/viewer/terminal/mod.rs | 7 +- src/workspace/mod.rs | 161 ++++++------------ src/workspace/repo_input.rs | 25 ++- ...kdown-BYxaSXJW.js => Markdown-D7nyj2B6.js} | 2 +- ...minal-Dhwxs9P9.js => Terminal-DSG5cVh6.js} | 2 +- viewer-ui/dist/assets/index-CGiabjZL.css | 1 - viewer-ui/dist/assets/index-DNzvsjIC.css | 1 + .../{index-D8ORZvs2.js => index-cnyyNRtX.js} | 4 +- viewer-ui/dist/index.html | 4 +- viewer-ui/src/App.tsx | 30 +--- viewer-ui/src/Markdown.tsx | 17 +- viewer-ui/src/Terminal.tsx | 68 +------- viewer-ui/src/Toaster.tsx | 9 +- viewer-ui/src/api.ts | 23 +-- viewer-ui/src/api/client.ts | 14 +- viewer-ui/src/api/errors.ts | 18 +- viewer-ui/src/api/types.ts | 33 +--- viewer-ui/src/components/DiffView.tsx | 19 +-- viewer-ui/src/components/FilePane.tsx | 12 +- viewer-ui/src/components/FolderPicker.tsx | 11 +- viewer-ui/src/components/Header.tsx | 23 +-- viewer-ui/src/components/LoadingSplash.tsx | 4 +- viewer-ui/src/components/LogList.tsx | 18 +- viewer-ui/src/components/Mark.tsx | 9 +- viewer-ui/src/components/PathLabel.tsx | 10 +- viewer-ui/src/components/ProjectMenu.tsx | 7 +- viewer-ui/src/components/RepoShell.tsx | 19 +-- viewer-ui/src/components/Sidebar.tsx | 28 +-- viewer-ui/src/components/TreeList.tsx | 6 +- viewer-ui/src/diffLayout.ts | 36 +--- viewer-ui/src/fileView.ts | 8 +- viewer-ui/src/hooks/useLog.ts | 63 +------ viewer-ui/src/hooks/useMaximized.ts | 6 +- viewer-ui/src/hooks/usePaneOpeners.ts | 6 +- viewer-ui/src/hooks/useRepoActions.ts | 5 +- viewer-ui/src/hooks/useRepoPoll.ts | 42 +---- viewer-ui/src/hooks/useResumeTick.ts | 7 +- viewer-ui/src/hooks/useSidebarDrag.ts | 49 +----- viewer-ui/src/hooks/useStatus.ts | 39 +---- viewer-ui/src/hooks/useTree.ts | 23 +-- viewer-ui/src/hot.ts | 45 +---- viewer-ui/src/icons.tsx | 46 +---- viewer-ui/src/paneOrder.ts | 20 +-- viewer-ui/src/sidebar.ts | 57 +------ viewer-ui/src/terminalLayout.ts | 24 +-- viewer-ui/src/theme.ts | 56 +----- viewer-ui/src/toast.ts | 13 +- viewer-ui/src/tree.ts | 8 +- viewer-ui/src/types.ts | 6 +- viewer-ui/src/useHotClock.ts | 27 +-- viewer-ui/src/usePaneDrag.ts | 14 +- viewer-ui/src/useTerminalSocket.ts | 27 +-- viewer-ui/src/useTerminalViews.ts | 13 +- viewer-ui/src/utils.ts | 7 +- 125 files changed, 1036 insertions(+), 2500 deletions(-) rename viewer-ui/dist/assets/{Markdown-BYxaSXJW.js => Markdown-D7nyj2B6.js} (99%) rename viewer-ui/dist/assets/{Terminal-Dhwxs9P9.js => Terminal-DSG5cVh6.js} (99%) delete mode 100644 viewer-ui/dist/assets/index-CGiabjZL.css create mode 100644 viewer-ui/dist/assets/index-DNzvsjIC.css rename viewer-ui/dist/assets/{index-D8ORZvs2.js => index-cnyyNRtX.js} (99%) diff --git a/src/app.rs b/src/app.rs index 5904a13..5a3544c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -34,14 +34,9 @@ use std::time::Instant; pub(crate) const LIST_PAGE_SIZE: usize = 10; pub(crate) const DIFF_PAGE_SIZE: usize = 20; -/// What raised a notice — the key its expiry is scoped to. -/// -/// Expiry used to be decided by matching the message text -/// (`msg.starts_with("git error:")`), which tied clearing to human-readable -/// prose and only ever covered the two kinds that happened to have a matching -/// arm: terminal, tree, and session messages were never cleared at all and sat -/// in the chrome until the repo was switched. Keying on the variant instead -/// means adding a kind forces a decision about when it goes away. +// Keying expiry on the variant (not message text) forces a decision about +// when each new kind goes away — the old text-match scheme left several kinds +// never cleared. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NoticeKind { Git, @@ -50,19 +45,16 @@ pub enum NoticeKind { Tree, Session, RepoInput, - /// A refused workspace-level action (tab cap reached, last tab closed). Project, } -/// A message shown in the chrome's notice row until its kind expires. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Notice { pub kind: NoticeKind, pub text: String, } -/// Caret-notation label for a leader chord. Free function so the empty -/// screen, which has no project to ask, can label its hints too. +// Free function so the empty screen (no project) can label its hints too. pub fn leader_label_of(leader: KeyEvent) -> String { match leader.code { crossterm::event::KeyCode::Char(c) if leader.modifiers.contains(KeyModifiers::CONTROL) => { @@ -78,7 +70,6 @@ pub enum ViewMode { #[default] Status, Log, - /// Read-only directory-tree navigator rooted at the workdir. Tree, } @@ -89,18 +80,11 @@ pub enum Focus { Terminal, } -/// State that drives the auto-follow behaviour: keep track of when the user -/// last navigated manually (so an active user is never hijacked) and the path -/// auto-follow last steered selection to (so it doesn't repeatedly assert the -/// same hot file). The behaviour config (`cfg_agent_indicator`) stays on -/// `App` because the file-list renderer also reads it. +// Auto-follow state: idle timer + last-steered path. Behaviour config stays +// on `cfg_agent_indicator` since the file-list renderer also reads it. #[derive(Default)] pub struct AutoFollow { - /// Wall-clock instant of the most recent user-driven selection change in - /// the file list. `None` means "idle since boot". pub last_manual_nav_at: Option, - /// Path the auto-follow last steered selection to. Prevents repeatedly - /// re-asserting selection on the same already-hot-and-selected file. pub followed_path: Option, } @@ -117,94 +101,52 @@ pub struct App { pub accent_idx: usize, pub tracking: Option, pub(crate) snapshot: SnapshotChannel, - /// Latest snapshot drained from the worker but not yet applied. Set by - /// `drain_snapshot` (which every project runs) and consumed by - /// `poll_snapshot` (which only the active project runs), so a background - /// project's git work is deferred until its tab is shown. + // Set by `drain_snapshot` (every project), consumed by `poll_snapshot` + // (active only) — a background project's git work defers until its tab shows. pub(crate) pending_snapshot: Option, - /// Filesystem watcher driving live refresh of the file-tree navigator. Only - /// active while in `ViewMode::Tree`; watches the expanded directories - /// (non-recursively) and triggers a cache re-read on change. Inert when the - /// OS watcher could not start, in which case refresh-on-entry is the - /// fallback. + // Filesystem watcher for live tree refresh; active only in `ViewMode::Tree`. + // Inert when the OS watcher couldn't start (refresh-on-entry is the fallback). pub(crate) tree_watch: crate::runtime::tree_watch::TreeWatcher, - /// Directories a watcher event touched but which have not been re-read - /// yet. Filled by `drain_tree_watcher` (which every project runs) and - /// consumed by `poll_tree_watcher` (only the active one), so a hidden - /// project's tree refreshes when its tab is shown rather than rereading - /// directories on the UI thread meanwhile. + // Watcher-touched directories not yet re-read. Filled by `drain_tree_watcher` + // (every project), consumed by `poll_tree_watcher` (active only) so a hidden + // project's tree refreshes when shown, not on the UI thread meanwhile. pub(crate) tree_dirty: std::collections::BTreeSet, - /// Set when events were dropped or could not be attributed, so the next - /// refresh must re-read everything instead of trusting `tree_dirty`. + // Set when events were dropped/unattributed: next refresh re-reads everything. pub(crate) tree_dirty_all: bool, - /// A saved file selection waiting on the first snapshot, with its diff - /// scroll. The only part of a session that cannot be applied on the spot: - /// it names a file the changed-file list has not delivered yet. Nothing - /// the user does can conflict with it, since an empty list offers nothing - /// to select. + // Saved selection waiting on the first snapshot. Cannot conflict with user + // input: an empty list offers nothing to select. pub(crate) pending_selection: Option<(String, usize)>, - /// Cached `git2::Repository` for synchronous loads (file diff, commit - /// diff, file blob, commit log). Opened lazily on first use; invalidated - /// in `change_repo`. The snapshot worker thread keeps its own handle — - /// `git2::Repository` is `!Send` and cannot be shared. + // Cached `git2::Repository` for sync loads. Opened lazily, invalidated in + // `change_repo`. The snapshot worker keeps its own handle (`!Send`). pub(crate) repo_cache: Option, pub cfg_agent_indicator: crate::config::AgentIndicatorConfig, - /// Behaviour config for the file-tree navigator (`.gitignore` filtering, - /// max expansion depth). Read by the tree navigation/preview methods. pub cfg_tree: crate::config::TreeConfig, - /// Commit-log page sizing, prefetch threshold, in-flight worker, and - /// the HEAD anchor used to detect external commits. See - /// `CommitLogPagination`. The Drop impl on the struct joins the - /// worker so a `change_repo` cannot leak the old-repo fetch. + // Drop impl joins the worker so `change_repo` can't leak the old-repo fetch. pub pagination: CommitLogPagination, - /// Auto-follow state (idle timer + last-steered path). Behaviour config - /// lives separately on `cfg_agent_indicator` since the file-list - /// renderer also reads it. pub auto_follow: AutoFollow, - /// True while the upper-left list panel (file list in Status mode, commit - /// list in Log mode) is rendered full-screen. Mutually exclusive with - /// `diff.fullscreen` and `terminal.fullscreen`. + // Mutually exclusive with `diff.fullscreen` and `terminal.fullscreen`. pub list_fullscreen: bool, - /// Current branch shorthand carried in the latest snapshot. `None` for - /// detached HEAD, unborn branches, or bare repos. Rendered in the top - /// header so the user always sees which branch the workdir tracks. + // `None` for detached HEAD / unborn branch / bare repo. pub branch_name: Option, - /// The configured leader (prefix) chord. Pressing it arms `prefix_armed`; - /// the next key is then interpreted as an app command (tmux-style). pub leader: KeyEvent, - /// True while the leader has been pressed and we are waiting for the - /// follow-up key. There is intentionally NO timeout: the prefix stays - /// armed until a follow-up key (mapped → run + disarm, unmapped → consume - /// + disarm) or `Esc`/`Ctrl+C` (cancel) resolves it. + // No timeout: stays armed until a follow-up key or `Esc`/`Ctrl+C` resolves it. pub prefix_armed: bool, - /// True while ` s` has armed pane-swap mode and we await the digit - /// that names the swap target. Mutually exclusive with `prefix_armed`: - /// arming this clears the prefix, so both are never set at once. Resolved by - /// the next key (digit → swap + disarm, `Esc`/`Ctrl+C` → cancel, anything - /// else → consume + disarm), with no timeout — same model as the prefix. + // Mutually exclusive with `prefix_armed` (arming this clears the prefix). + // Same no-timeout model as the prefix. pub awaiting_swap_target: bool, - /// The pane and button of a forwarded mouse press whose release has not - /// been seen yet. A release pairs with the press's pane, not the pane - /// under the pointer: drag reports are not forwarded, so the program - /// that saw the press must see the release even when the pointer moved - /// off the pane in between. Single slot — a second press before the - /// first release overwrites it (multi-button chords are not paired). - /// Pane, button, and pane-local cell of a forwarded press whose release - /// has not been seen. The cell is kept so the press can still be released - /// where it happened when no pointer position is available — switching - /// projects, for instance. + // A release pairs with the press's pane, not the pane under the pointer: + // drag reports aren't forwarded, so the program that saw the press must see + // the release. Single slot — a second press overwrites (no multi-button). pub pending_mouse_press: Option<( crate::backend::PaneId, crossterm::event::MouseButton, u16, u16, )>, - /// Mirror of `[mouse] enabled`. Gates only the hint bar's clickability - /// inversion — with capture off no mouse event ever arrives, so the - /// input path needs no check, but a label must not advertise a click - /// that cannot happen. + // Mirror of `[mouse] enabled`. Gates only the hint bar's clickability — + // with capture off no mouse event arrives, but a label must not lie. pub mouse_enabled: bool, } #[cfg(test)] -pub(crate) mod tests; +pub(crate) mod tests; \ No newline at end of file diff --git a/src/app/app_impl.rs b/src/app/app_impl.rs index c6063e1..f9cb360 100644 --- a/src/app/app_impl.rs +++ b/src/app/app_impl.rs @@ -4,9 +4,8 @@ use crate::runtime::snapshot::SnapshotChannel; use crossterm::event::KeyEvent; impl NoticeKind { - /// Prefix shown before the message, or `None` when the message already - /// reads on its own (a repo-input rejection, a session-restore note, or a - /// refused project action names its own subject). + // `None` when the message already names its own subject (repo-input + // rejection, session-restore note, refused project action). pub fn label(self) -> Option<&'static str> { match self { Self::Git => Some("git error"), @@ -26,7 +25,6 @@ impl Notice { } } - /// The single line to render, label included when the kind carries one. pub fn line(&self) -> String { match self.kind.label() { Some(label) => format!("{label}: {}", self.text), @@ -36,34 +34,27 @@ impl Notice { } impl App { - /// Raise a notice, replacing whatever was showing. The chrome has one - /// notice row, so the newest problem wins. + // The chrome has one notice row, so the newest problem wins. pub fn raise_notice(&mut self, kind: NoticeKind, text: impl Into) { self.notice = Some(Notice::new(kind, text)); } - /// Drop the current notice if it was raised by `kind`. Called from the - /// success path of each subsystem, so a resolved problem stops being - /// reported without clobbering an unrelated one that arrived since. + // Called from each subsystem's success path so a resolved problem stops + // being reported without clobbering an unrelated one that arrived since. pub fn clear_notice(&mut self, kind: NoticeKind) { if self.notice.as_ref().is_some_and(|n| n.kind == kind) { self.notice = None; } } - /// Drop the current notice because the user acted on the app itself. - /// - /// Deliberately *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 — the same - /// effectively-invisible failure this row exists to prevent. - /// Deliver a pending press's release to the pane that saw it, at the cell - /// the press landed on. - /// - /// 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 instead would leave that program in a drag or selection - /// state with no release ever coming. + // 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. + // + // 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 + // release ever coming. pub fn release_pending_press_in_place(&mut self) { if let Some((id, button, col, row)) = self.pending_mouse_press.take() { self.terminal.click_pane(id, button, false, col, row); @@ -98,9 +89,8 @@ impl App { tracking: None, snapshot, pending_snapshot: None, - // Start disabled; `main` upgrades to a live watcher after the parsed - // `[tree] live_watch` config is applied, so a `false` setting never - // spawns an OS watcher. + // `main` upgrades to a live watcher after applying `[tree] live_watch`, + // so a `false` setting never spawns an OS watcher. tree_watch: crate::runtime::tree_watch::TreeWatcher::disabled(), tree_dirty: Default::default(), tree_dirty_all: false, @@ -127,26 +117,21 @@ impl App { app } - /// True while the leader has been pressed and we await the follow-up key. - /// Drives the hint bar's `PREFIX` indicator. pub fn prefix_armed(&self) -> bool { self.prefix_armed } - /// Arm the prefix: the next key will be interpreted as an app command. pub fn arm_prefix(&mut self) { self.prefix_armed = true; } - /// Disarm the prefix, returning to normal pass-through routing. pub fn cancel_prefix(&mut self) { self.prefix_armed = false; } - /// Whether one of this project's search bars owns input right now. The - /// repo dialog is process-level, so the full modal test lives on - /// `Workspace::overlay_active`; both feed the key and mouse handlers so a - /// click can never reach behind a modal that swallows keystrokes. + // The repo dialog is process-level, so the full modal test lives on + // `Workspace::overlay_active`; both feed the key and mouse handlers so a + // click can never reach behind a modal that swallows keystrokes. pub fn search_overlay_active(&self) -> bool { self.status_view.search_active || self.tree_view.search_active @@ -155,37 +140,28 @@ impl App { || self.log_view.file_search_active } - /// True while ` s` armed pane-swap mode and we await the target - /// digit. Drives the hint bar's `SWAP` indicator. pub fn awaiting_swap_target(&self) -> bool { self.awaiting_swap_target } - /// Arm pane-swap mode: the next digit picks the pane to swap with the - /// active pane. Clears the prefix so the two follow-up states never overlap. + // Clears the prefix so the two follow-up states never overlap. pub fn begin_swap_target(&mut self) { self.prefix_armed = false; self.awaiting_swap_target = true; } - /// Disarm pane-swap mode without acting. pub fn cancel_swap_target(&mut self) { self.awaiting_swap_target = false; } - /// Caret-notation label for the configured leader chord, e.g. `^F` for - /// `Ctrl+F`. Leaders are always ctrl chords (see `config::parse_leader`), - /// so the control character maps cleanly to `^`; any non-ctrl key - /// falls back to printing its raw character. pub fn leader_label(&self) -> String { crate::app::leader_label_of(self.leader) } - /// True when `key` matches the configured leader chord. Any modifier beyond - /// the leader's own (Alt, Shift, Super, Hyper, Meta — enhanced keyboard - /// protocols report the latter three) makes it a different chord that passes - /// straight through to the PTY instead of being swallowed, so we compare the - /// full modifier set exactly. + // Any modifier beyond the leader's own (Alt, Shift, Super, Hyper, Meta — + // enhanced keyboard protocols report the latter three) makes it a different + // chord that passes straight through to the PTY, so compare the full + // modifier set exactly. pub fn is_leader_key(&self, key: KeyEvent) -> bool { key.code == self.leader.code && key.modifiers == self.leader.modifiers } diff --git a/src/app/auto_follow.rs b/src/app/auto_follow.rs index cca8602..780e3fc 100644 --- a/src/app/auto_follow.rs +++ b/src/app/auto_follow.rs @@ -2,17 +2,12 @@ use super::{App, Focus, ViewMode}; use std::time::{Duration, Instant, SystemTime}; impl App { - /// Mark that the user just navigated manually so auto-follow stays out - /// for a short grace period. Also clears the "we steered to this path" - /// memory — the user has taken back control. pub(crate) fn mark_user_navigated(&mut self) { self.auto_follow.last_manual_nav_at = Some(Instant::now()); self.auto_follow.followed_path = None; } - /// Decide whether the file list should auto-follow to a new hot file, - /// and perform the move if so. Returns `true` when selection changed. - /// Caller is responsible for refreshing the diff afterward. + // Returns `true` when selection changed; caller refreshes the diff. pub(crate) fn try_auto_follow(&mut self) -> bool { if !self.cfg_agent_indicator.enabled || !self.cfg_agent_indicator.auto_follow { return false; @@ -44,19 +39,12 @@ impl App { moved } - /// Path with the newest mtime among files that are still inside the - /// configured hot window and pass the current filter. Returns `None` - /// when no qualifying file exists. Tiebreak by path for stability. fn freshest_hot_path(&self) -> Option { if self.status_view.hot_table.is_empty() { return None; } let now = SystemTime::now(); let window = Duration::from_secs(self.cfg_agent_indicator.hot_window_secs); - // Walk the filtered index list and probe `hot_table` by path. The - // previous implementation built a per-tick `HashSet` of filtered - // paths inside `try_auto_follow`'s hot loop, which allocated every - // snapshot tick. Tiebreak by smaller path for stability. let mut best: Option<(&str, SystemTime)> = None; for &idx in self.filtered_indices() { let Some(file) = self.status_view.files.get(idx) else { @@ -66,13 +54,9 @@ impl App { continue; }; // `duration_since` returns Err when `mtime > now` (clock skew on - // NFS, VMs, files touched with a future stamp). Treating those as - // permanently in-window (`unwrap_or(true)`) would pin auto-follow - // to a single bogus file forever — no later real edit could beat - // the inflated timestamp in the `mtime > bm` comparison below. - // Drop future-stamped files from consideration entirely: clock - // skew is rare, recovery happens automatically once the real - // wall clock catches up. + // 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. let Ok(age) = now.duration_since(mtime) else { continue; }; @@ -90,8 +74,6 @@ impl App { best.map(|(p, _)| p.to_string()) } - /// Move the selection cursor to `path` if it exists in the unfiltered - /// status list. Returns whether selection actually changed. fn select_status_file_by_path(&mut self, path: &str) -> bool { if let Some(idx) = self.status_view.files.iter().position(|f| f.path == path) && self.status_view.selected != idx @@ -102,4 +84,4 @@ impl App { } false } -} +} \ No newline at end of file diff --git a/src/app/commit_log_apply.rs b/src/app/commit_log_apply.rs index f11d139..fdee2c2 100644 --- a/src/app/commit_log_apply.rs +++ b/src/app/commit_log_apply.rs @@ -15,11 +15,9 @@ impl App { } fn apply_tail_page(&mut self, msg: CommitLogPageMsg) { - // Stale-result check: the worker was launched with `skip` equal - // to the loaded count at the time. If the count has changed - // (HEAD refresh resetting pagination, repo switch landing - // before this reply, etc.), the page no longer concatenates - // safely onto the current list. + // Stale-result check: the worker was launched with `skip` equal to the + // loaded count at the time. If the count has changed (HEAD refresh, + // repo switch, etc.), the page no longer concatenates safely. if msg.skip != self.log_view.loaded_count { self.log_view.clear_pending(); return; @@ -27,9 +25,7 @@ impl App { match msg.result { Ok(page) => { self.log_view.append_page(page, msg.page_size); - // Chain another fetch immediately if the user is still - // sitting near the new tail; otherwise the next - // selection move would have to wait a tick. + // Chain another fetch if the user is still near the new tail. self.maybe_prefetch_commit_log(); } Err(e) => { @@ -39,12 +35,9 @@ impl App { } } - /// Apply a fresh page-0 fetch as a refresh: either prepend new head - /// commits onto the cached tail (fast-forward), or replace the list - /// outright (divergence, initial entry). Mirrors the merge that was - /// previously inline in `refresh_commit_log_after_head_change`, now - /// driven off a captured snapshot of the pre-spawn state so the - /// load itself can run on a worker thread. + // Either prepend new head commits onto the cached tail (fast-forward) or + // replace the list outright (divergence, initial entry). Driven off a + // captured snapshot of pre-spawn state so the load can run on a worker. fn apply_refresh_page( &mut self, msg: CommitLogPageMsg, @@ -61,13 +54,11 @@ impl App { } }; - // If the previous head still appears in the freshly fetched first - // page and the fresh tail lines up with the cached list, treat the - // change as a fast-forward / simple new commit: prepend the newer - // entries onto the existing list so all accumulated pages stay valid. - // A merge can interleave side-branch commits after the old head; in - // that case cached pages are no longer a contiguous prefix of the - // new revwalk, so reset to the freshly loaded first page instead. + // If the previous head still appears in the fresh first page and the + // fresh tail lines up with the cached list, fast-forward: prepend the + // newer entries so accumulated pages stay valid. A merge can interleave + // side-branch commits after the old head; then cached pages are no + // longer a contiguous prefix, so reset to the fresh first page. let prepend_idx = prior_head_oid.and_then(|oid| page.iter().position(|c| c.oid == oid)); let page_is_short = page.len() < page_size; let can_prepend = prepend_idx.is_some_and(|idx| { @@ -87,21 +78,18 @@ impl App { new_head_commits.append(&mut self.log_view.commits); self.log_view.commits = new_head_commits; self.log_view.loaded_count = self.log_view.commits.len(); - // `page_is_short` only describes the freshly fetched first page; - // it doesn't account for cached later pages. Preserve prior - // completion state and only promote to fully_loaded when the - // new revwalk demonstrably fits within one page. + // `page_is_short` only describes the fresh first page; preserve + // prior completion state and only promote to fully_loaded when the + // new revwalk fits within one page. if page_is_short && self.log_view.commits.len() <= page_size { self.log_view.fully_loaded = true; } self.log_view.commit_width_cache.set(None); - // Prepend bypasses `set_commits`, so the filter cache must be - // refreshed manually so an active search query still resolves - // against the newly merged head commits. + // Prepend bypasses `set_commits`, so refresh the filter cache + // manually so an active search query resolves against the new head. self.log_view.recompute_commit_filter(); self.log_view.clear_pending(); - // Slide the selection so the user keeps looking at the same - // commit even though new entries appeared above it. + // Slide selection so the user keeps looking at the same commit. if let Some(prior_oid) = prior_selected_oid && let Some(pos) = self .log_view @@ -112,11 +100,10 @@ impl App { self.log_view.selected = pos; } else { // `prior_selected_oid` was Some, so the cached list contained - // that oid. If the position lookup fails despite the list - // being a prefix of the new one — corruption, or a race we - // haven't accounted for — clamp to the new bounds so a - // downstream `commits.get(selected)` lands on the tail - // instead of returning None and clearing the diff pane. + // that oid. If the lookup fails despite the list being a prefix + // — corruption, or an unaccounted race — clamp to bounds so a + // downstream `commits.get(selected)` lands on the tail instead + // of returning None and clearing the diff pane. self.log_view.selected = self .log_view .selected @@ -130,13 +117,12 @@ impl App { .unwrap_or(0); } self.log_view.commit_scroll_x = 0; - // Anchor the head-oid sentinel to whatever we just loaded so - // ingest_snapshot doesn't immediately trigger another refresh. + // 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); - // Drill-down survives only if the commit it was opened on is still - // in the (possibly extended) list. Otherwise drop back to the - // commit-level diff. + // Drill-down survives only if the commit it was opened on is still in + // the (possibly extended) list. if self.log_view.drill_down && prior_selected_oid .is_none_or(|oid| !self.log_view.commits.iter().any(|c| c.oid == oid)) diff --git a/src/app/commit_log_fetch.rs b/src/app/commit_log_fetch.rs index 471f4f7..a4ba9b8 100644 --- a/src/app/commit_log_fetch.rs +++ b/src/app/commit_log_fetch.rs @@ -1,10 +1,8 @@ //! Background commit-log page fetcher. //! -//! `git2::Repository` is `!Send`, so the worker thread opens its own handle -//! via `Repository::discover` against `App::repo_path`. The result returns -//! to the main thread through `mpsc::channel`; the main loop polls -//! [`App::poll_commit_log_page_fetch`] each tick and appends or discards -//! the page. +//! `git2::Repository` is `!Send`, so the worker opens its own handle via +//! `Repository::discover`. The result returns through `mpsc::channel`; the +//! main loop polls `poll_commit_log_page_fetch` each tick. use std::sync::mpsc; use std::thread; @@ -17,13 +15,9 @@ use super::App; use super::ViewMode; use crate::git::diff::{CommitEntry, load_commit_log_page}; -/// Distinguishes the two ways a worker reply must be merged into the view. -/// -/// `Tail` is the prefetch case: extend the loaded list at the current tail. -/// `Refresh` is the head-anchor case: replace or prepend onto the existing -/// list, using the snapshot of selection / head oid captured at spawn time -/// so the post-load merge stays deterministic even if the user navigated -/// while the worker was running. +// `Tail` extends the loaded list at the current tail. `Refresh` replaces or +// prepends, using the selection/head oid captured at spawn time so the +// post-load merge stays deterministic even if the user navigated meanwhile. pub(crate) enum CommitLogFetchKind { Tail, Refresh { @@ -32,12 +26,9 @@ pub(crate) enum CommitLogFetchKind { }, } -/// One reply from a paged fetch worker. -/// -/// `skip` is the offset the worker was launched with: the main thread -/// uses it as a stale-result check before appending — if the loaded -/// commit count has changed between spawn and reply (HEAD refresh, -/// repo switch), the page is dropped. +// `skip` is the offset the worker was launched with: the main thread uses it +// 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 kind: CommitLogFetchKind, pub skip: usize, @@ -46,9 +37,6 @@ pub(crate) struct CommitLogPageMsg { } impl App { - /// Spawn a background worker that fetches the next page of the - /// commit log starting at `skip`. Returns immediately. If a fetch - /// is already pending or the log is fully loaded, this is a no-op. pub(crate) fn spawn_commit_log_page_fetch(&mut self, skip: usize) { if self.log_view.fully_loaded { return; @@ -59,10 +47,8 @@ impl App { self.launch_commit_log_worker(skip, CommitLogFetchKind::Tail); } - /// Spawn a worker that fetches page 0 to refresh the cached commit - /// list, capturing the prior selection/head oids so the merge at - /// reply time can preserve the user's view. Used both for initial - /// Log-mode entry (prior_*_oid = None) and for HEAD-change refresh. + // Used both for initial Log-mode entry (prior_*_oid = None) and for + // HEAD-change refresh. pub(crate) fn spawn_commit_log_refresh_fetch( &mut self, prior_selected_oid: Option, @@ -80,14 +66,11 @@ impl App { ); } - /// Shared spawn helper. Detaches any previous handle (no join on the - /// UI thread — the receiver-drop already signals the worker to exit - /// at next send, and an old handle that's mid-`load_commit_log_page` - /// must not stall the frame). Installs the new receiver+handle. + // Detaches any previous handle (no join on the UI thread — the + // 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()); - // Detach prior handle: the worker keeps running until it tries - // to send, then exits cleanly. self.pagination.handle.take(); let page_size = self.pagination.page_size; let repo_path = self.repo_path.clone(); @@ -108,9 +91,6 @@ impl App { self.pagination.handle = Some(handle); } - /// Drain any commit-log page reply that has arrived since the last - /// tick. Safe to call every loop iteration: returns without work if - /// no fetch is pending. pub(crate) fn poll_commit_log_page_fetch(&mut self) { let Some(rx) = self.pagination.page_rx.as_ref() else { return; @@ -118,9 +98,8 @@ impl App { match rx.try_recv() { Ok(msg) => { self.pagination.page_rx = None; - // Worker just sent → it is one statement away from - // returning. A short timed join reaps the OS thread now - // instead of waiting for `Drop`, and the timeout means a + // 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. if let Some(h) = self.pagination.handle.take() { try_timed_join(h, REAP_TIMEOUT); @@ -138,12 +117,10 @@ impl App { } } - /// Tear down any in-flight worker and clear the pending flag. - /// Called on repo switch (a discrete user action, not a per-tick - /// hot path), so a short timed join is affordable here. The - /// receiver-drop above flips the worker's next `tx.send` to Err and - /// the join completes in microseconds in the common case; the - /// timeout caps worst-case latency if a worker is wedged. + // Called on repo switch (a discrete user action, not a per-tick hot path), + // so a short timed join is affordable. The receiver-drop flips the + // 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() { @@ -152,10 +129,6 @@ impl App { self.log_view.clear_pending(); } - /// If the current Log view selection is within - /// `pagination.prefetch_threshold` rows of the loaded tail, start a - /// background page fetch from `loaded_count`. No-ops in Status mode, - /// drill-down, empty list, pending, and fully-loaded states. pub(crate) fn maybe_prefetch_commit_log(&mut self) { if self.mode != ViewMode::Log { return; @@ -163,11 +136,10 @@ impl App { if self.log_view.drill_down { return; } - // Pause tail prefetch while the commit-list search bar is open. - // A new page arriving mid-search would shift the filter cache - // and disturb the user's view; the gate is lifted by - // `cancel_log_search` / `confirm_log_search`, which re-call this - // helper on the way out. + // Pause tail prefetch while the commit-list search bar is open: a new + // page arriving mid-search would shift the filter cache and disturb + // the user's view. The gate is lifted by `cancel_log_search` / + // `confirm_log_search`, which re-call this helper on the way out. if self.log_view.commit_search_active { return; } @@ -179,16 +151,12 @@ impl App { } let loaded = self.log_view.loaded_count; let threshold = self.pagination.prefetch_threshold; - // Trigger when the user is close enough to the tail that the - // next handful of moves would scroll past the loaded range. if self.log_view.selected + threshold >= loaded { self.spawn_commit_log_page_fetch(loaded); } } - /// Block until any pending commit-log fetch has been drained and - /// applied. Test-only — production code polls each tick via the - /// main loop and never needs to wait. + // Test-only — production code polls each tick via the main loop. #[cfg(test)] pub(crate) fn flush_commit_log_fetch_for_test(&mut self, timeout: std::time::Duration) { let start = std::time::Instant::now(); diff --git a/src/app/commit_log_pagination.rs b/src/app/commit_log_pagination.rs index 8848d7b..85d8a38 100644 --- a/src/app/commit_log_pagination.rs +++ b/src/app/commit_log_pagination.rs @@ -1,8 +1,4 @@ //! Background commit-log page fetcher pagination state. -//! -//! Lifted off `App` so the page worker's lifecycle (receiver + JoinHandle) -//! lives in one place and the related config knobs and HEAD anchor don't -//! sprawl across `App`'s top-level fields. use std::sync::mpsc::Receiver; use std::thread::JoinHandle; @@ -11,38 +7,24 @@ use crate::util::{REAP_TIMEOUT, try_timed_join}; use super::commit_log_fetch::CommitLogPageMsg; -/// Owns the commit-log pagination state. The Drop impl mirrors -/// `SnapshotChannel` / `PtyPane`: dropping `page_rx` makes the worker's -/// `tx.send` fail at the next reply, then the JoinHandle is awaited so a -/// `change_repo` cannot leak the old-repo worker into the new view. +// Drop mirrors `SnapshotChannel`/`PtyPane`: dropping `page_rx` makes the +// 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 { - /// Commits loaded per page. Sourced from `LogConfig::commit_log_page_size`. pub page_size: usize, - /// Prefetch begins when the cursor is within this many rows of the - /// loaded tail. Sourced from `LogConfig::commit_log_prefetch_threshold`. pub prefetch_threshold: usize, - /// Receiver for the in-flight worker. `Some` while a fetch is pending; - /// cleared once drained or cancelled. pub(crate) page_rx: Option>, - /// JoinHandle for the in-flight worker, awaited on `Drop` so the - /// channel-close → tx.send-error → thread-exit sequence completes - /// before `Pagination` itself goes away. `cancel_commit_log_page_fetch` - /// deliberately does *not* join here: the UI tick can't afford to wait - /// for a worker that's mid-`load_commit_log_page`. The receiver-drop - /// already makes the worker's reply fail, so detaching the handle is - /// safe — the worst case is one extra OS thread until it finishes. + // `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>, - /// HEAD oid carried in the most recent snapshot. `ingest_snapshot` - /// compares this against the new snapshot's head to decide whether - /// to trigger `refresh_commit_log_after_head_change`. pub(crate) last_head_oid: Option, } impl CommitLogPagination { - /// Construct with the config-derived knobs and otherwise default state. - /// Used by `App::new` and the test fixture — `..Default::default()` - /// can't be used here because the type implements `Drop`. + // `..Default::default()` can't be used: the type implements `Drop`. pub fn with_config(page_size: usize, prefetch_threshold: usize) -> Self { Self { page_size, @@ -56,10 +38,9 @@ impl CommitLogPagination { impl Drop for CommitLogPagination { fn drop(&mut self) { - // Drop the receiver first so the worker's next `tx.send` fails - // and the loop exits; then await the thread with a bounded - // timeout — a worker stuck mid-`load_commit_log_page` on libgit2 - // must not freeze app shutdown / repo switch. + // 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 + // shutdown / repo switch. drop(self.page_rx.take()); if let Some(h) = self.handle.take() { try_timed_join(h, REAP_TIMEOUT); diff --git a/src/app/diff_load.rs b/src/app/diff_load.rs index 54e2d2a..2c82eaa 100644 --- a/src/app/diff_load.rs +++ b/src/app/diff_load.rs @@ -1,15 +1,11 @@ use super::{App, DiffPaneView, FileViewState, NoticeKind, ViewMode}; use crate::git::diff::{DiffHunk, load_file_diff, parse_hunk_new_start}; -/// Post-load behaviour for `apply_diff_result`. Replaces the prior 3-flag -/// signature where the combination of `reset_scroll` and `keep_scroll` was -/// hard to parse at call sites. +// Replaces a prior 3-flag signature where `reset_scroll`/`keep_scroll` were +// hard to parse at call sites. pub(crate) enum DiffApply<'a> { - /// Reset scroll/cursor to top after a successful load. Reset, - /// Keep the previous scroll position (for in-place refresh). KeepScroll(usize), - /// Reset scroll and additionally update the log diff title. ResetWithTitle(&'a str), } @@ -18,10 +14,10 @@ impl App { self.refresh_diff(true); } - /// Run `f` with the cached `git2::Repository`, opening it lazily on first - /// use. Cache is invalidated by `change_repo` so that follow-up calls open - /// a fresh handle for the new path. Errors from the open propagate so the - /// caller can surface them as a notice. + // Opens the cached `git2::Repository` lazily; invalidated by `change_repo`. + // Only drops the handle on errors suggesting the repo itself is gone (the + // motivating case: `rm -rf .git && git init` in the terminal pane) — normal + // data misses like "object not found" must not force a fresh `discover` walk. pub(crate) fn with_repo( &mut self, f: impl FnOnce(&git2::Repository) -> anyhow::Result, @@ -31,14 +27,7 @@ impl App { .map_err(|e| anyhow::anyhow!("not a git repository: {e}"))?; self.repo_cache = Some(repo); } - // unwrap is sound: we just inserted Some above when None. let result = f(self.repo_cache.as_ref().unwrap()); - // Only drop the cached handle when the error suggests the repo - // *itself* is gone or unreadable — a user doing `rm -rf .git && git - // init` in the terminal pane is the motivating case. Errors like - // "path not in commit" or "object not found" are normal data misses - // that shouldn't force a fresh `Repository::discover` walk on every - // subsequent call. if let Err(ref e) = result && let Some(git_err) = e.downcast_ref::() && matches!( @@ -52,9 +41,8 @@ impl App { } pub(crate) fn refresh_diff(&mut self, reset_scroll: bool) { - // Only the working-tree status view shows a file diff. Log drives the - // diff via its own loaders, and Tree shows raw file previews — neither - // should have a status diff loaded over it. + // Only Status shows a file diff; Log and Tree drive the diff via their + // own loaders and must not have a status diff loaded over them. if self.mode != ViewMode::Status { return; } @@ -76,10 +64,6 @@ impl App { self.apply_diff_result(result, mode); } - /// Centralizes the post-load shape used by every diff loader: on success - /// stash hunks, reset/restore scroll and search cursor, optionally update - /// the log title, and recompute diff search matches; on error clear state - /// but preserve the title so the user knows what failed. pub(crate) fn apply_diff_result( &mut self, result: anyhow::Result>, @@ -88,9 +72,6 @@ impl App { let reset_scroll = matches!(mode, DiffApply::Reset | DiffApply::ResetWithTitle(_)); match result { Ok(hunks) => { - // Clear any stale diff error from a previous failed load — - // keeping it would mislead the user about the current file's - // state. Notices of other kinds are left alone. self.clear_notice(NoticeKind::Diff); self.diff.hunks = hunks; self.diff.rebuild_lower_cache(); @@ -102,16 +83,12 @@ impl App { self.invalidate_file_view(); } DiffApply::KeepScroll(prev) => { - // New hunks may be shorter than the prior load, so - // clamp against the freshly assigned diff to avoid - // leaving an out-of-range scroll that misbehaves on - // the next navigation keystroke. + // Clamp against the new (possibly shorter) diff so + // scroll isn't left out of range for the next keystroke. self.diff.scroll = prev.min(self.diff.max_scroll()); - // If the file-overlay view is open, the anchor was - // computed against the previous hunks. After the - // diff is replaced, that anchor may point at the - // wrong row — recompute against the new hunks so - // the open file pane stays aligned with the diff. + // The file-overlay anchor was computed against the + // previous hunks; recompute so the open file pane stays + // aligned with the replaced diff. if self.diff.file_view.key.is_some() { self.diff.file_view.anchor_line = self.anchor_for_current_diff(); } @@ -122,19 +99,16 @@ impl App { } } Err(_) => { - // For a KeepScroll error (an in-place refresh of the same - // file) we keep the prior diff on screen: this is usually a - // transient race (mid-rename, slow git index update) and - // clearing would both flash an empty pane and leave `scroll` - // dangling past the now-empty `max_scroll`. The error is - // already surfaced as a notice by the loader. + // KeepScroll error (in-place refresh) keeps the prior diff: + // usually a transient race (mid-rename, slow index update) and + // clearing would flash an empty pane and dangle `scroll`. if !matches!(mode, DiffApply::KeepScroll(_)) { self.clear_diff_state(); } } } // Title belongs to the surrounding view, not the diff state — set it - // last so it survives both success and failure of the load. + // last so it survives both success and failure. if let DiffApply::ResetWithTitle(title) = mode { self.log_view.diff_title = title.to_string(); } @@ -146,11 +120,9 @@ impl App { self.diff.line_highlights.clear(); self.diff.cached_hunk_syntax.clear(); // Drop the entire search state, not just the match list: keeping the - // query alive after a content-discarding clear would (a) leave a - // ghost `[0/0]` counter visible in the title, and (b) cause the - // next file load's `recompute_matches` to apply the previous file's - // query to unrelated content. `search.clear` also flips `active` - // off so the search bar disappears in the same frame. + // query alive after a content-discarding clear would leave a ghost + // `[0/0]` counter and apply the previous file's query to unrelated + // content on the next load. self.diff.search.clear(); self.diff.scroll = 0; self.diff.scroll_x = 0; @@ -162,11 +134,6 @@ impl App { self.diff.file_view = FileViewState::default(); } - /// Pick the new-side starting line of the hunk currently visible at the - /// top of the diff viewport. Walks the flat hunk layout (one header row + - /// body rows per hunk) and returns the most recent hunk whose header was - /// reached at or before `self.diff.scroll`. Falls back to the first - /// parseable hunk when the scroll is past every hunk we could parse. pub(crate) fn anchor_for_current_diff(&self) -> Option { let scroll = self.diff.scroll; let mut offset = 0usize; @@ -183,13 +150,8 @@ impl App { chosen } - /// Reload the Log view's commit list after the snapshot worker detected a - /// HEAD oid change (new commit via the terminal pane, external push, - /// amend, branch switch). Captures the current selection/head oids and - /// spawns a background fetch; the merge happens in `apply_refresh_page` - /// when the worker replies so the UI tick never blocks on a 100-commit - /// revwalk. Selection-by-oid preservation, prepend-vs-reset detection, - /// and drill-down survival all live on that arrival path. + // Spawns a background fetch; the merge happens in `apply_refresh_page` when + // the worker replies so the UI tick never blocks on a 100-commit revwalk. pub(crate) fn refresh_commit_log_after_head_change(&mut self) { let prior_selected_oid = self .log_view @@ -198,9 +160,8 @@ impl App { .map(|c| c.oid); let prior_head_oid = self.log_view.commits.first().map(|c| c.oid); - // Any in-flight worker (tail prefetch or older refresh) was launched - // against state that no longer matches; drop it so only this fresh - // refresh's reply can land. + // Any in-flight worker was launched against state that no longer + // matches; drop it so only this refresh's reply can land. self.cancel_commit_log_page_fetch(); self.spawn_commit_log_refresh_fetch(prior_selected_oid, prior_head_oid); } diff --git a/src/app/file_view_load.rs b/src/app/file_view_load.rs index af01b48..2f25339 100644 --- a/src/app/file_view_load.rs +++ b/src/app/file_view_load.rs @@ -18,8 +18,6 @@ impl App { if row.is_dir { return None; } - // Tree files reuse the workdir-file key — the source is the - // same `load_workdir_file` loader as the status preview. Some(FileViewKey::Status(row.path)) } ViewMode::Log => { @@ -34,9 +32,6 @@ impl App { Some(FileViewKey::Commit { oid, path: file.path.clone(), - // Commit deltas carry their single status in the index - // column; `load_commit_file_blob` only needs the Deleted - // case to read from the parent tree. status: file.index, }) } @@ -63,11 +58,9 @@ impl App { match result { Ok(content) => { fv.set_content(content); - // Initial scroll: 2 lines of context above the hunk's new-side - // start line, converted from 1-based to 0-based. Clamp against - // `max_scroll` so a stale anchor past the current file length - // (file truncated since the diff was computed) doesn't open - // the file view on a blank region the user has to page back from. + // 2 lines of context above the hunk's new-side start (1-based + // → 0-based). Clamp so a stale anchor past the current file + // length doesn't open on a blank region. let initial = anchor .map(|n| n.saturating_sub(1).saturating_sub(2)) .unwrap_or(0); @@ -80,17 +73,14 @@ impl App { self.diff.file_view = fv; } - /// Whether `v` currently has a file to open. Mirrors the gates in - /// `toggle_diff_file_view` — Tree mode never toggles, and elsewhere the - /// key must resolve (log view needs a drill-down file selection) — so - /// the hint bar only advertises `v: view file` when a press would act. + // Mirrors the gates in `toggle_diff_file_view` so the hint bar only + // advertises `v: view file` when a press would act. pub(crate) fn can_open_file_view(&self) -> bool { self.mode != ViewMode::Tree && self.current_file_view_key().is_some() } pub fn toggle_diff_file_view(&mut self) { - // Tree mode's right pane is always the raw file preview; the diff and - // split views have no meaning there, so `v`/`s` are no-ops. + // Tree mode's right pane is always the raw file preview; `v`/`s` are no-ops. if self.mode == ViewMode::Tree { return; } @@ -109,9 +99,6 @@ impl App { self.diff.view = DiffPaneView::File; } - /// Toggle the side-by-side split view on or off. From any other view - /// (unified diff or file overlay) this switches into `Split`; pressing it - /// again returns to the unified diff. pub fn toggle_diff_split_view(&mut self) { if self.mode == ViewMode::Tree { return; diff --git a/src/app/focus.rs b/src/app/focus.rs index 4b96b3d..1cad6cc 100644 --- a/src/app/focus.rs +++ b/src/app/focus.rs @@ -6,17 +6,14 @@ impl App { self.clear_diff_state(); let from = self.mode; // Terminal/diff fullscreen hides the list pane, so a mode toggle there - // would flip state invisibly behind the zoomed pane. Reveal the result - // with the same policy as `focus_list` (F1). `list_fullscreen` is not - // part of this check: it already renders the mode's active list, so the - // swap is visible and the zoom should survive the toggle. + // would flip state invisibly. Reveal the result with `focus_list`'s + // policy. `list_fullscreen` is excluded: it already renders the mode's + // active list, so the swap is visible and the zoom should survive. let reveal_after_toggle = self.terminal.fullscreen.fills_body() || self.diff.fullscreen; match self.mode { - // ` l` from either Status or Tree enters the Log view. ViewMode::Status | ViewMode::Tree => { - // Leaving Tree (not just to Status): drop the filesystem watches - // so descriptors aren't held while the tree is hidden. Tree - // re-entry re-syncs them. + // Leaving Tree: drop filesystem watches so descriptors aren't + // held while the tree is hidden. Tree re-entry re-syncs them. if self.mode == ViewMode::Tree { self.clear_tree_watches(); } @@ -34,28 +31,26 @@ impl App { tracing::debug!(from = ?from, to = ?self.mode, "view mode toggled"); } - /// Switch into the Log view from the current mode. Shared by ` l` - /// from both Status and Tree. Reuses cached commit pages when they still - /// match the latest observed HEAD; otherwise refreshes in the background. + // 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(); self.log_view.commit_scroll_x = 0; - // Reuse cached pages on re-entry only while they still match - // the latest HEAD observed by the snapshot worker. Status mode - // intentionally does not refresh the hidden commit list, so a - // HEAD change there must invalidate the cache on the next entry. + // Reuse cached pages on re-entry only while they still match the + // latest HEAD observed by the snapshot worker. Status mode doesn't + // 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; 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() { - // First entry with no cached pages: spawn a background - // refresh fetch instead of loading on the UI thread. The - // diff pane stays empty until the worker replies via - // `apply_refresh_page`, which then loads the commit diff - // for the freshly populated selection. + // First entry with no cached pages: spawn a background refresh + // instead of loading on the UI thread. The diff pane stays empty + // until `apply_refresh_page` loads the commit diff for the fresh + // selection. self.cancel_commit_log_page_fetch(); self.spawn_commit_log_refresh_fetch(None, None); } else { @@ -64,9 +59,8 @@ impl App { } } - /// Toggle the file-tree navigator: ` b` enters Tree mode from - /// Status/Log and returns to Status from Tree. Mirrors `toggle_mode`'s - /// fullscreen-reveal policy so the swap is visible behind a zoomed pane. + // ` b` enters Tree from Status/Log and returns to Status from Tree. + // Mirrors `toggle_mode`'s fullscreen-reveal policy. pub fn toggle_tree_mode(&mut self) { let from = self.mode; let reveal_after_toggle = self.terminal.fullscreen.fills_body() || self.diff.fullscreen; @@ -82,8 +76,8 @@ impl App { } pub fn set_accent_index(&mut self, idx: usize) { - // Normalize on entry so we never persist out-of-range indices to the - // session file, even though `current_accent` would tolerate them. + // Normalize on entry so out-of-range indices never reach the session + // file, even though `current_accent` would tolerate them. self.accent_idx = idx % crate::config::Accent::ALL.len(); } @@ -167,15 +161,12 @@ impl App { } } - /// Cycle the terminal fullscreen state: `Off → Grid → Zoom → Off`. When - /// `Grid` would already show a single pane, `Zoom` looks identical to it - /// (`TerminalState::zoom_distinct_from_grid`), so the cycle collapses to - /// `Off → Grid → Off` to avoid a press that looks like a no-op. Entering - /// any body-filling state moves focus to the terminal and clears the - /// competing diff/list fullscreens. + // `Off → Grid → Zoom → Off`. When `Grid` would already show a single pane, + // `Zoom` looks identical, so the cycle collapses to `Off → Grid → Off` to + // avoid a press that looks like a no-op. Entering a body-filling state + // moves focus to the terminal and clears competing diff/list fullscreens. pub fn toggle_terminal_fullscreen(&mut self) { if self.terminal.panes.is_empty() { - // Nothing to show; keep (or force) the normal split view. self.terminal.fullscreen = TerminalFullscreen::Off; return; } @@ -215,21 +206,19 @@ impl App { } } - /// Jump focus to the file/commit list. Clears any fullscreen flag that - /// would otherwise hide this pane; `list_fullscreen` itself stays so a - /// user with the list already maximized keeps that view on F1. + // Clears any fullscreen that would hide this pane; `list_fullscreen` stays + // so a user with the list already maximized keeps that view on F1. pub fn focus_list(&mut self) { self.focus = Focus::FileList; self.diff.fullscreen = false; self.terminal.fullscreen = TerminalFullscreen::Off; } - /// Jump focus to the diff viewer. Mirror policy of `focus_list`: clears - /// the two competing fullscreens (`list_fullscreen`, `terminal.fullscreen`) - /// and leaves `diff.fullscreen` alone so F2 preserves a zoomed diff. + // Mirror of `focus_list`: clears the two competing fullscreens and leaves + // `diff.fullscreen` alone so F2 preserves a zoomed diff. pub fn focus_diff(&mut self) { self.focus = Focus::DiffViewer; self.list_fullscreen = false; self.terminal.fullscreen = TerminalFullscreen::Off; } -} +} \ No newline at end of file diff --git a/src/app/log_nav.rs b/src/app/log_nav.rs index acac0c4..ed8c3d6 100644 --- a/src/app/log_nav.rs +++ b/src/app/log_nav.rs @@ -2,22 +2,16 @@ use super::{App, LIST_PAGE_SIZE, ViewMode}; use crate::git::diff::load_commit_files; impl App { - /// Indices into `log_view.commits` that match the active commit search. - /// `0..len` when no query is set (mirrors `filtered_indices`). pub fn log_commit_filtered_indices(&self) -> &[usize] { &self.log_view.commits_filter_cache } - /// Indices into `log_view.commit_files` that match the active drill-down - /// file search. pub fn log_file_filtered_indices(&self) -> &[usize] { &self.log_view.commit_files_filter_cache } - /// Snap `log_view.selected` into the current commit filter. If the current - /// selection still matches, leave it; otherwise jump to the first match. - /// Returns whether selection changed so the caller can decide whether to - /// reload the diff. + // Returns whether selection changed so the caller can decide whether to + // reload the diff. fn sync_log_commit_selection_to_filter(&mut self) -> bool { let target = { let indices = self.log_commit_filtered_indices(); @@ -39,8 +33,6 @@ impl App { } } - /// Same as `sync_log_commit_selection_to_filter` but for the drill-down - /// file list. fn sync_log_file_selection_to_filter(&mut self) -> bool { let target = { let indices = self.log_file_filtered_indices(); @@ -80,8 +72,6 @@ impl App { } } - /// Open the `/` search bar in the active Log sub-view (commit list or - /// drill-down file list). The dispatch matches the visible upper pane. pub fn start_log_search(&mut self) { if self.log_view.drill_down { self.log_view.start_file_search(); @@ -112,7 +102,7 @@ impl App { if self.log_view.confirm_commit_search() { self.refresh_commit_diff_after_filter_change(); } - // Resume tail prefetch regardless of whether the query was empty: + // Resume prefetch regardless of whether the query was empty: // confirm hides the search bar in both branches, so the gate in // `maybe_prefetch_commit_log` no longer applies. self.maybe_prefetch_commit_log(); @@ -139,8 +129,7 @@ impl App { } } - /// Dispatches a navigation action to the appropriate log list (commit or file). - /// Returns `true` if the action was handled (i.e. we are in Log mode). + // Returns `true` if handled (i.e. we are in Log mode). pub(super) fn navigate_log_list(&mut self, commit_nav: fn(&mut Self), file_nav: fn(&mut Self)) -> bool { if self.mode != ViewMode::Log { return false; @@ -235,11 +224,8 @@ impl App { self.maybe_prefetch_commit_log(); } - /// Step the commit-list cursor by `delta` positions within the active - /// commit search filter. When the filter holds every commit (empty - /// query), this is identical to the previous `cursor_up`/`cursor_down` - /// behaviour because the cache is built as `0..len`. Returns whether the - /// selection actually moved so callers can decide to reload the diff. + // Returns whether the selection actually moved so callers can decide to + // reload the diff. pub(crate) fn move_log_commit_in_filter(&mut self, delta: isize) -> bool { let resolved = { let indices = self.log_commit_filtered_indices(); @@ -264,7 +250,6 @@ impl App { } } - /// Same as `move_log_commit_in_filter` but for the drill-down file list. pub(crate) fn move_log_file_in_filter(&mut self, delta: isize) -> bool { let resolved = { let indices = self.log_file_filtered_indices(); diff --git a/src/app/navigation.rs b/src/app/navigation.rs index c534388..57653e5 100644 --- a/src/app/navigation.rs +++ b/src/app/navigation.rs @@ -61,10 +61,9 @@ impl App { self.selected_filtered_status_file().map(|f| f.path.clone()) } - /// Borrow-only counterpart of `selected_filtered_status_path` so callers - /// that just need to read the path don't pay for an allocation. Uses - /// `binary_search` since `filter_cache` is built in ascending order by - /// `recompute_filter`. + // Borrow-only counterpart of `selected_filtered_status_path` so callers + // that just need to read don't allocate. `binary_search` works because + // `filter_cache` is built in ascending order by `recompute_filter`. pub fn selected_filtered_status_file(&self) -> Option<&ChangedFile> { if self .filtered_indices() @@ -93,10 +92,8 @@ impl App { false } else { self.status_view.selected = target; - // Match `move_selected_in_filter`: selection landing on a new - // file should drop the previous file's horizontal scroll so the - // newly-shown path starts from column 0 rather than scrolled - // mid-string. + // Match `move_selected_in_filter`: drop the previous file's + // horizontal scroll so the newly-shown path starts from column 0. self.status_view.file_scroll_x = 0; true } @@ -111,9 +108,6 @@ impl App { } } - /// Move `selected` by `delta` positions within the active filter view. - /// Handles both empty-query (full file list) and non-empty (filtered subset) - /// cases uniformly. pub(crate) fn move_selected_in_filter(&mut self, delta: isize) { // Resolve the new selection in a scoped block so the borrow on // filtered_indices does not outlive the mutating reload below. @@ -137,8 +131,8 @@ impl App { && (Some(new_pos) != pos || self.status_view.selected != new_selected) { // Mark only after confirming the selection actually changed so - // that bumping against either end of the list doesn't reset the - // auto-follow steered-path memory. + // bumping against either end doesn't reset the auto-follow + // steered-path memory. self.mark_user_navigated(); self.status_view.selected = new_selected; self.status_view.file_scroll_x = 0; diff --git a/src/app/scroll.rs b/src/app/scroll.rs index 79258b8..9171115 100644 --- a/src/app/scroll.rs +++ b/src/app/scroll.rs @@ -23,10 +23,9 @@ impl App { } fn upper_scroll_x_max(&self) -> usize { - // Cap at the longest visible entry's char width so we don't drift past - // the last column of any rendered row. Each branch consults a - // length-keyed `Cell` cache so repeated keystrokes don't re-walk the - // full list (and re-count chars per item) every press. + // Cap at the longest visible entry's char width. Each branch consults + // a length-keyed `Cell` cache so repeated keystrokes don't re-walk the + // full list every press. fn cached_max<'a, T: 'a>( cache: &Cell>, items: &'a [T], @@ -48,9 +47,9 @@ impl App { &self.status_view.files, |f| f.display_path().chars().count(), ), - // Tree rows are derived (not a stored slice), so cache the max by - // visible-row count directly rather than via `cached_max`. Width = - // indent (depth*2) + 2-char dir/file marker + name char count. + // Tree rows are derived (not a stored slice), so cache by + // visible-row count directly. Width = indent (depth*2) + 2-char + // dir/file marker + name char count. ViewMode::Tree => { let rows = self.tree_view.visible_rows(); let len = rows.len(); diff --git a/src/app/session_io.rs b/src/app/session_io.rs index fe520f5..994e5d7 100644 --- a/src/app/session_io.rs +++ b/src/app/session_io.rs @@ -4,11 +4,9 @@ use crate::runtime::terminal::TerminalFullscreen; use crate::session::SessionState; impl App { - /// The state to persist. - /// - /// The live view, except for a selection still waiting on its first - /// snapshot — quitting before that arrives would otherwise record "no file - /// selected" over the one the user actually left open. + // The live view, except for a selection still waiting on its first + // snapshot — quitting before that arrives would otherwise record "no file + // selected" over the one the user actually left open. pub fn session_to_save(&self) -> SessionState { let mut state = self.save_session(); if let Some((path, scroll)) = self.pending_selection.as_ref() { @@ -41,18 +39,16 @@ impl App { } } - /// Restore the pane/focus/fullscreen subset of a saved session. This needs - /// no loaded snapshot data, so it 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 on restart. Idempotent: `restore_session` re-applies it once the - /// snapshot arrives, which is a no-op against the same state. + // 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. pub(crate) fn restore_pane_focus(&mut self, state: &SessionState) { self.terminal.active = state .active_pane .min(self.terminal.panes.len().saturating_sub(1)); - // `visible_start` isn't persisted (MVP scope) — recompute it fresh - // from the restored active pane so the split-view window contains it. + // `visible_start` isn't persisted (MVP scope) — recompute from the + // restored active pane so the split-view window contains it. self.terminal.sync_visible_window(); if let Some(focus) = state.focus { if focus == Focus::Terminal && self.terminal.panes.is_empty() { @@ -61,8 +57,8 @@ impl App { self.focus = focus; } } - // Zoom is a transient "look at one pane" state; a restored fullscreen - // session collapses to `Grid` rather than persisting the zoom. + // Zoom is transient; a restored fullscreen session collapses to `Grid` + // rather than persisting the zoom. self.terminal.fullscreen = if state.terminal_fullscreen && !self.terminal.panes.is_empty() { TerminalFullscreen::Grid } else { @@ -83,25 +79,18 @@ impl App { } } - /// Apply a saved session. - /// - /// Runs as soon as the session is loaded, not on the first snapshot. Almost - /// none of it needs to wait: panes, focus and fullscreen need no data at - /// all, and Log and Tree read what they need directly — the commit log and - /// the directory listings — rather than from the git snapshot. - /// - /// Status mode's selection is the one exception, and only when the changed - /// files have not arrived yet. It is held in `pending_selection` until they - /// do. That deferral cannot collide with anything the user does meanwhile: - /// there is no way to pick a file out of a list that is still empty. + // 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. pub fn restore_session(&mut self, state: &SessionState) { - // Pane / focus / fullscreen restoration — independent of view mode. self.restore_pane_focus(state); self.set_accent_index(state.accent_idx); - // Mode-specific diff/scroll restoration. We avoid loading a workdir diff - // when the saved mode is Log — otherwise we'd waste a load and clamp the - // scroll against the wrong diff length. + // Avoid loading a workdir diff when the saved mode is Log — otherwise + // we'd waste a load and clamp the scroll against the wrong diff length. match state.mode { Some(ViewMode::Log) => self.restore_log_session(state), Some(ViewMode::Tree) => self.restore_tree_session(state), @@ -130,29 +119,26 @@ impl App { self.refresh_diff(true); self.diff.scroll = state.scroll.min(self.diff.max_scroll()); } - // If the saved file is no longer present, leave selected/scroll as they - // were after the initial snapshot — applying saved_scroll to a different - // file would jump the user to an unrelated location. + // If the saved file is gone, leave selected/scroll as they were after + // the initial snapshot — applying saved_scroll to a different file + // would jump the user to an unrelated location. } fn restore_tree_session(&mut self, state: &SessionState) { self.mode = ViewMode::Tree; // A status search started before this restore (e.g. `/` pressed while - // the default Status view was shown awaiting the first snapshot) would - // otherwise stay active and capture Tree keystrokes. Drop it; the diff - // search overlay is cleared by `clear_diff_state` below. + // the default Status view awaited the first snapshot) would otherwise + // stay active and capture Tree keystrokes. Drop it. self.status_view.cancel_search(); self.clear_diff_state(); // 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); - // Seed the expanded set from the saved session, then re-read the tree. // The session file is an on-disk boundary: drop any entry that isn't a - // safe repo-internal relative path so a hand-edited/corrupted `..` or - // absolute path can't drive a directory read outside the working tree. - // `refresh_tree_cache` reads the root and these directories top-down and - // prunes any that no longer exist on disk (moved/deleted between - // sessions), so a stale expansion can't surface a "tree error". + // 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". self.tree_view.expanded = state .tree_expanded .iter() @@ -160,8 +146,7 @@ impl App { .cloned() .collect(); self.refresh_tree_cache(); - // Restore the cursor by path when it still resolves to a visible row; - // otherwise leave it at the top. + // 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) { @@ -177,7 +162,7 @@ impl App { // 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 we mutate state. + // 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)) { @@ -193,8 +178,7 @@ impl App { self.log_view.selected = state .log_selected .min(self.log_view.commits.len().saturating_sub(1)); - // Same rationale as toggle_mode: avoid a same-tick HEAD-change-trigger - // reload on the very next snapshot. + // 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.mode = ViewMode::Log; @@ -204,9 +188,9 @@ impl App { self.load_commit_diff_for_selected(); } self.diff.scroll = state.scroll.min(self.diff.max_scroll()); - // Restored cursor may already sit close to the tail of the first - // page; kick off the next prefetch so the first key move doesn't - // bump into a not-yet-loaded boundary. + // Restored cursor may already sit close to the tail of the first page; + // kick off the next prefetch so the first key move doesn't bump into a + // not-yet-loaded boundary. self.maybe_prefetch_commit_log(); } @@ -214,10 +198,10 @@ 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 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. tracing::warn!( selected = self.log_view.selected, "drill-down restore: saved commit index is out of range" @@ -255,4 +239,4 @@ impl App { } } } -} +} \ No newline at end of file diff --git a/src/app/snapshot_io.rs b/src/app/snapshot_io.rs index e4fe553..36cff95 100644 --- a/src/app/snapshot_io.rs +++ b/src/app/snapshot_io.rs @@ -3,26 +3,19 @@ use std::collections::HashMap; use std::time::SystemTime; impl App { - /// Empty the worker's queue, keeping only the tail in `pending_snapshot`. - /// - /// Only the most recent message reflects current repo state, so a burst - /// (e.g. while the main loop was blocked by a synchronous git2 call) - /// collapses to one. Applying it is deliberately NOT done here: this half - /// touches no git state, so every open project can run it every tick to - /// keep its unbounded channel from growing, no matter which tab is shown. + // Only the most recent message reflects current repo state, so a burst + // 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) { while let Ok(msg) = self.snapshot.try_recv() { self.pending_snapshot = Some(msg); } } - /// Drain, then apply whatever is pending. - /// - /// Applying runs a full `refresh_diff`, so this is for the project on - /// screen only — doing it for hidden projects would put several - /// repositories' git diffs on the UI thread every tick. A background - /// project's snapshot waits in `pending_snapshot` and is applied on the - /// first tick after its tab comes forward. + // 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(); match self.pending_snapshot.take() { @@ -32,18 +25,16 @@ impl App { Some(SnapshotMsg::Err(e)) => { tracing::warn!(error = %e, "git snapshot failed"); self.raise_notice(NoticeKind::Git, e.to_string()); - // The pending restore is deliberately kept: the worker retries, - // and a later snapshot should still apply the saved selection. - // Saving is what must not be blocked by it — see - // `session_to_save`, which merges instead of skipping. + // Pending restore is kept: the worker retries, and a later + // snapshot should still apply the saved selection. Saving must + // not be blocked by it — see `session_to_save`, which merges. } None => {} } } - /// Apply a snapshot to app state. Split out from `poll_snapshot` so - /// tests can drive the merge/auto-follow logic with deterministic - /// mtimes instead of booting the background worker. + // Split out so tests can drive the merge/auto-follow logic with deterministic + // mtimes instead of booting the background worker. pub fn ingest_snapshot(&mut self, snapshot: RepoSnapshot, mtimes: HashMap) { // A selection waiting on this very snapshot stands in as the previous // path, so the ordinary "keep the cursor on the same file" machinery @@ -58,8 +49,6 @@ impl App { .as_ref() .map(|(path, _)| path.clone()) }); - // Capture the HEAD oid up front so the detection branch below stays - // independent of where the snapshot fields get moved out. let new_head = snapshot.head_oid; self.branch_name = snapshot.branch_name; self.status_view.set_files(snapshot.files); @@ -81,10 +70,8 @@ impl App { } self.clear_notice(NoticeKind::Git); - // Detect commits made through the terminal pane or external tools and - // refresh the Log view's cached commit list. 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. + // 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; if prior_head.is_some() && prior_head != new_head && self.mode == ViewMode::Log { @@ -100,23 +87,14 @@ impl App { } } - /// Update `hot_table` with the latest observed mtimes. Entries for - /// paths missing from the new snapshot are dropped; entries with a - /// strictly newer mtime are replaced (so a file edited twice within - /// the hot window re-arms its fade). A path whose previous mtime was - /// newer than the freshly observed one keeps its previous mtime — a - /// rename-from-stash can resurrect older mtimes for the same path - /// and must not demote a recent edit to cool. - /// - /// Updates the existing map in place instead of building a fresh - /// HashMap on every snapshot tick: the typical steady state has the - /// same path set tick after tick, so the prior strategy churned the - /// allocator on the UI poll path for no behavioural benefit. + // A path whose previous mtime was newer than the freshly observed one keeps + // its previous mtime — a rename-from-stash can resurrect older mtimes for + // the same path and must not demote a recent edit to cool. Updates in place + // instead of rebuilding the HashMap every tick: the steady state has the + // same path set tick after tick. pub(crate) fn merge_hot_table(&mut self, mtimes: HashMap) { let table = &mut self.status_view.hot_table; - // Drop entries that are no longer in the snapshot. table.retain(|path, _| mtimes.contains_key(path)); - // Upsert: insert new paths, keep the newer mtime when both exist. for (path, new_mtime) in mtimes { table .entry(path) @@ -128,4 +106,4 @@ impl App { .or_insert(new_mtime); } } -} +} \ No newline at end of file diff --git a/src/app/terminal_ctrl.rs b/src/app/terminal_ctrl.rs index 79ca24e..ce5e8e9 100644 --- a/src/app/terminal_ctrl.rs +++ b/src/app/terminal_ctrl.rs @@ -1,26 +1,11 @@ -//! App-level wrappers around `TerminalState` that mix in cross-cutting state -//! (focus, status line, fullscreen flags). The pure terminal logic — event -//! drain, pane create/close, emulator bookkeeping — lives on -//! `TerminalState` in `runtime/terminal.rs`; this module exists for the -//! handful of actions that have to coordinate that logic with the rest of -//! `App`'s state machine. +//! App-level wrappers around `TerminalState` mixing in cross-cutting state +//! (focus, status line, fullscreen flags). Pure terminal logic lives on +//! `TerminalState` in `runtime/terminal.rs`. use super::{App, Focus, NoticeKind}; use crate::runtime::terminal::TerminalFullscreen; impl App { - /// Open the panes nightcrow starts with. With no reserved - /// `startup_commands`, this keeps the historical single empty-shell - /// behaviour. Otherwise it opens one pane per command, runs each command - /// immediately, and labels the tab with the command's `name` (falling - /// back to the command text). - /// - /// On a fresh launch the input focus lands on the first pane so a cockpit - /// user can type into the startup program (or the empty shell) right away. - /// A restored session overrides this later in `restore_session`, so the - /// last active pane/focus still wins on restart. When no pane could be - /// created (no backend, or `create_pane` failed), focus stays on the file - /// list — there is no terminal to focus. pub(crate) fn ensure_initial_terminal( &mut self, startup_commands: &[crate::config::StartupCommand], @@ -40,9 +25,8 @@ impl App { } } } - // Start at the top of the reserved set rather than the last pane - // created, and put input focus on it so keystrokes reach the terminal - // program immediately on first launch. + // Focus the first pane so keystrokes reach the terminal on first launch. + // A restored session overrides this later in `restore_session`. if !self.terminal.panes.is_empty() { self.terminal.active = 0; self.terminal.sync_visible_window(); @@ -51,9 +35,8 @@ impl App { } pub fn poll_terminal(&mut self) { - // `TerminalState::poll` only signals which panes the backend exited; - // re-clamping focus and fullscreen when one of them was the active - // pane is cross-cutting and stays here. + // `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() { self.clamp_active_pane_after_removal(); } @@ -65,14 +48,9 @@ impl App { self.raise_notice(NoticeKind::Terminal, e.to_string()); return; } - // Opening a pane succeeded, so a previous failure no longer describes - // the terminal's state. self.clear_notice(NoticeKind::Terminal); - // `create_pane` already made the new pane the active one within - // `TerminalState`; move the app-level focus onto it too so the user - // lands in the freshly opened terminal instead of staying on the - // file list or diff viewer. Drop competing fullscreen flags for the - // same reason `switch_pane` does — keep focus, render, and hints in sync. + // `create_pane` made the new pane active; move app focus onto it and + // drop competing fullscreen so focus/render/hints stay in sync. self.focus = Focus::Terminal; self.diff.fullscreen = false; self.list_fullscreen = false; @@ -84,21 +62,15 @@ impl App { } } - /// Whether ` w` may close the active pane: terminal focus only. - /// Without it the active pane is deliberately rendered indistinguishable - /// from the others (see `terminal_tab::render`), so the close target - /// would be invisible. Single source for the key gate - /// (`main::handle_global_action`) and the hint rows, so an advertised - /// hint can never disagree with what the key does. + // Without terminal focus the active pane is rendered indistinguishable + // from the others, so the close target would be invisible. Single source + // for the key gate and the hint rows so they can never disagree. pub fn can_close_pane(&self) -> bool { self.focus == Focus::Terminal } - /// Whether ` s` may arm pane-swap mode: close's terminal-focus - /// scope (the active pane is the swap's first operand) plus a second - /// pane to swap with — with fewer, every target digit would be a no-op. - /// Single source for the key gate and the hint rows, like - /// `can_close_pane`. + // close's terminal-focus scope plus a second pane to swap with — fewer + // and every target digit would be a no-op. Single source like `can_close_pane`. pub fn can_swap_panes(&self) -> bool { self.focus == Focus::Terminal && self.terminal.panes.len() > 1 } @@ -107,51 +79,36 @@ impl App { if self.terminal.panes.is_empty() { self.terminal.active = 0; self.terminal.fullscreen = TerminalFullscreen::Off; - // Only redirect focus when it was actually on the terminal — - // otherwise an externally-exited last pane (Ctrl+D in the only - // shell while the user was reading the diff) would yank focus - // away from where the user was working. + // Only redirect focus when it was on the terminal — otherwise an + // externally-exited last pane would yank focus from where the user + // was working. if self.focus == Focus::Terminal { self.focus = Focus::DiffViewer; } } else { self.terminal.active = self.terminal.active.min(self.terminal.panes.len() - 1); - // Closing a pane while zoomed can leave `Zoom` in a state where it's - // indistinguishable from `Grid`. Normalize down so the held state - // matches the invariant the cycle keeps (Zoom only when distinct). + // Normalize a `Zoom` that's now indistinguishable from `Grid` so + // the held state matches the invariant the cycle keeps. if self.terminal.fullscreen == TerminalFullscreen::Zoom && !self.terminal.zoom_distinct_from_grid() { self.terminal.fullscreen = TerminalFullscreen::Grid; } } - // The pane count (and possibly `active`) just changed — re-clamp the - // split-view window so it still points at real panes. self.terminal.sync_visible_window(); } - /// Move terminal focus to pane `idx`. This is a focus jump, not a tab - /// switch: every visible pane keeps rendering, this only changes which - /// one is active (cursor, input, and the accent-bordered cell). pub fn switch_pane(&mut self, idx: usize) { if idx < self.terminal.panes.len() { self.terminal.active = idx; self.terminal.sync_visible_window(); self.focus = Focus::Terminal; - // Pressing F1..=F10 is a request to interact with a terminal pane; - // drop any competing fullscreen so focus, render, and hints stay - // in sync (otherwise a zoomed diff/list would persist while focus - // moves away). + // Drop competing fullscreen so focus/render/hints stay in sync. self.diff.fullscreen = false; self.list_fullscreen = false; } } - /// Swap the active pane with pane `idx`, moving focus so it follows the - /// active pane to its new slot. Like `switch_pane`, this drops competing - /// fullscreen and puts input focus on the terminal so the reordered pane is - /// the one the user is now driving. No-op when `idx` is out of range or - /// equals the active pane. pub fn swap_active_pane_with(&mut self, idx: usize) { if self.terminal.swap_active_with(idx) { self.focus = Focus::Terminal; @@ -163,4 +120,4 @@ impl App { pub fn active_screen(&self) -> Option> { self.terminal.active_screen() } -} +} \ No newline at end of file diff --git a/src/app/tree.rs b/src/app/tree.rs index dd56674..1af8357 100644 --- a/src/app/tree.rs +++ b/src/app/tree.rs @@ -1,69 +1,42 @@ //! `App` methods for the read-only file-tree navigator (`ViewMode::Tree`). //! -//! Directory I/O is synchronous and performed here on the UI thread (one level -//! per expansion via `crate::git::tree::read_children`); the git-status -//! snapshot worker is never involved. Selecting a file row loads its raw -//! contents into the existing file-view pane (`DiffPaneView::File`) — the same -//! surface used by the status/commit file preview, so no new render path is -//! introduced. +//! 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. use super::{App, DiffPaneView, FileViewKey, FileViewState, NoticeKind, ViewMode}; use std::collections::BTreeSet; impl App { - /// Enter Tree mode: load the root level, clamp the cursor, and preview the - /// selected row. Safe to call repeatedly (the root read is cached). pub fn enter_tree_mode(&mut self) { self.mode = ViewMode::Tree; - // A commit-log page fetch spawned in Log mode could still be in flight; - // its reply loads a commit diff over `self.diff`, which would clobber - // the Tree file preview a tick later. Cancel it on entry so only Tree - // controls the diff pane while this mode is active. + // A Log-mode page fetch in flight would clobber the Tree preview a tick + // later; cancel so only Tree controls the diff pane while active. self.cancel_commit_log_page_fetch(); - // Drop a lingering status-search overlay so its modal key handler can't - // keep capturing keystrokes after the mode switch. (`clear_diff_state` - // below clears the diff-search overlay.) This closes the case where a - // search started before a pending session restore would otherwise route - // Tree keys into the hidden search handler. + // Drop lingering search overlays so their modal handlers can't capture + // Tree keystrokes after the mode switch. self.status_view.cancel_search(); - // A prior Tree session could be re-entered with a stale search overlay; - // start clean so the first keystroke is plain navigation. self.tree_view.cancel_search(); - // Drop any diff/file-view state from the prior mode so the right pane - // starts clean; `preview_tree_selected` repopulates it. self.clear_diff_state(); - // Re-read from disk so a directory created/moved/renamed/deleted while - // away from Tree mode is reflected — the per-directory cache is - // otherwise only cleared on a repo switch, so structural changes never - // surfaced here. The same re-read is what the filesystem watcher runs on - // each change while Tree mode stays open. + // Re-read from disk so structural changes while away from Tree show up + // (the per-directory cache is otherwise only cleared on repo switch). self.refresh_tree_preserving_cursor(); } - /// Re-read the tree from disk while keeping the cursor on the same path. The - /// re-read can shift the row set (a directory created/moved/deleted changes - /// which rows are visible), so the path under the cursor is captured first - /// and the cursor restored to it afterwards; if that path is gone the cursor - /// falls back to a clamped index. Shared by Tree-mode entry, session - /// restore, and the watcher-driven live refresh. pub(crate) fn refresh_tree_preserving_cursor(&mut self) { self.refresh_tree_preserving_cursor_scoped(None); } - /// As above, re-reading only the listings named by `invalidate` (see - /// `refresh_tree_cache_scoped`). pub(crate) fn refresh_tree_preserving_cursor_scoped( &mut self, invalidate: Option<&BTreeSet>, ) { let prev_path = self.tree_view.selected_path(); self.refresh_tree_cache_scoped(invalidate); - // The filtered view renders from the search index, not the directory - // cache, so refreshing only the cache would leave a created or deleted - // file missing from the results and the match count wrong until the - // query changed. The rebuild walks the cache rather than the disk — - // only the invalidated listings above are re-read — so its cost is - // proportional to what changed, not to the size of the tree. + // The filtered view renders from the search index, not the cache, so + // refreshing only the cache would leave results stale until the query + // changed. The rebuild walks the cache (only invalidated listings + // re-read), so cost is proportional to what changed. if self.tree_view.search_active { self.build_tree_index(); self.tree_view.recompute_filter(); @@ -79,26 +52,14 @@ impl App { self.preview_tree_selected(); } - /// Drop the per-directory cache and re-read the root plus every currently - /// expanded directory from disk, so structural changes made while away from - /// Tree mode become visible. Expanded directories are re-read top-down - /// (shallowest first) so each parent listing is available before its - /// children are checked. A directory that no longer appears in its freshly - /// read parent listing — e.g. one that was moved or deleted — is dropped - /// from the expanded set instead of being re-read, so a vanished directory - /// cannot surface a spurious "tree error" in the status bar. pub(crate) fn refresh_tree_cache(&mut self) { self.refresh_tree_cache_scoped(None); } - /// Re-read directory listings, dropping either the whole cache or only the - /// listings named by `invalidate`. - /// - /// The scoped form is what a watcher event uses: everything else stays - /// cached, so the rebuild that follows touches disk only for the - /// directories that actually changed. The expansion-pruning loop below is - /// identical either way — `ensure_tree_children` is a no-op for a listing - /// still in the cache. + // Scoped form is what a watcher event uses: everything else stays cached, + // so the rebuild touches disk only for changed directories. The + // expansion-pruning loop is identical either way — `ensure_tree_children` + // is a no-op for a listing still in the cache. pub(crate) fn refresh_tree_cache_scoped(&mut self, invalidate: Option<&BTreeSet>) { match invalidate { None => self.tree_view.cache.clear(), @@ -127,75 +88,58 @@ impl App { } self.tree_view.expanded = kept; self.tree_view.row_width_cache.set(None); - // The expansion set may have changed (pruned vanished dirs); reconcile - // the watch set so the watcher tracks exactly the visible directories. self.sync_tree_watches(); } - /// Reconcile the filesystem watcher to the directories currently visible in - /// Tree mode (the root plus every expanded directory). No-op when live - /// watching is disabled or the working tree cannot be resolved. pub(crate) fn sync_tree_watches(&mut self) { if !self.cfg_tree.live_watch { return; } // A filename search matches the whole tree, not just what is expanded, - // so a file created in a collapsed directory changes the results and - // has to produce an event. The cache keys are exactly the directories - // `build_tree_index` walked, which is the set the results come from. + // so a file created in a collapsed directory must produce an event. let mut desired: BTreeSet = if self.tree_view.search_active { self.tree_view.cache.keys().cloned().collect() } else { self.tree_view.expanded.iter().cloned().collect() }; - // The root is always watched while Tree mode is open so top-level - // creations/removals are caught even with nothing expanded. + // Root is always watched so top-level creations/removals are caught + // even with nothing expanded. desired.insert(String::new()); if let Some(workdir) = self.tree_workdir() { self.tree_watch.sync(&workdir, &desired); } } - /// Tear down all tree watches (reconcile to the empty set). Called on exit - /// from Tree mode so watch descriptors aren't held while the tree is hidden. pub(crate) fn clear_tree_watches(&mut self) { if let Some(workdir) = self.tree_workdir() { self.tree_watch.sync(&workdir, &BTreeSet::new()); } } - /// Absolute working-tree root, or `None` for a bare repo / open failure. fn tree_workdir(&mut self) -> Option { self.with_repo(|repo| Ok(repo.workdir().map(|w| w.to_path_buf()))) .ok() .flatten() } - /// Drain filesystem-watcher events and, while Tree mode is open, re-read the - /// tree if anything changed. Called every main-loop tick alongside the other - /// pollers; cheap when idle (a non-blocking channel drain). - /// Empty the watcher's event queue, remembering only that something - /// changed. - /// - /// The cheap half: no directory is reread and no file is previewed. Every - /// project runs this each tick so OS events cannot pile up behind a hidden - /// tab, while the rereading waits for that tab to come forward. + // 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) { let changes = self.tree_watch.drain_changed(); if changes.is_empty() { return; } if changes.unknown { - // Events may have been dropped, so no set of directories can be - // trusted to be complete — fall back to re-reading everything. + // Events may have been dropped — no directory set can be trusted + // complete; fall back to re-reading everything. self.tree_dirty_all = true; } self.tree_dirty.extend(changes.dirs); } - /// Drain, then act on it: reread the expanded directories and re-preview - /// the selection. Only the project on screen does this — several - /// repositories rereading directories per tick would stall the active tab. + // Only the project on screen does this — several repos rereading per tick + // would stall the active tab. pub fn poll_tree_watcher(&mut self) { self.drain_tree_watcher(); if self.mode != ViewMode::Tree || (self.tree_dirty.is_empty() && !self.tree_dirty_all) { @@ -206,7 +150,6 @@ impl App { self.refresh_tree_preserving_cursor_scoped(if all { None } else { Some(&dirs) }); } - /// Leave Tree mode back to the working-tree status view. pub fn exit_tree_to_status(&mut self) { self.tree_view.cancel_search(); self.clear_tree_watches(); @@ -215,15 +158,12 @@ impl App { self.refresh_diff(true); } - /// Ensure the root directory's children are loaded into the cache. pub(crate) fn ensure_tree_root(&mut self) { self.ensure_tree_children(""); } - /// Load the immediate children of `dir` (repo-relative) into the cache if - /// not already present. A read error caches an empty listing and surfaces - /// the message in the status bar so a single unreadable directory cannot - /// wedge navigation. + // A read error caches an empty listing and surfaces the message so a + // single unreadable directory can't wedge navigation. pub(crate) fn ensure_tree_children(&mut self, dir: &str) { if self.tree_view.cache.contains_key(dir) { return; @@ -246,16 +186,13 @@ impl App { Err(e) => { tracing::warn!(error = %e, dir = %dir, "failed to read tree directory"); self.raise_notice(NoticeKind::Tree, e.to_string()); - // Cache an empty listing so we don't retry the failing read on - // every keystroke; a repo change / refresh clears the cache. + // Cache empty so we don't retry the failing read on every + // keystroke; a repo change / refresh clears the cache. self.tree_view.cache.insert(dir.to_string(), Vec::new()); } } } - /// Load the raw contents of the selected file row into the file-view pane. - /// Directory rows (and an empty tree) show a blank pane — there is no diff - /// in this mode, so the right pane is always the file overlay. pub(crate) fn preview_tree_selected(&mut self) { let selected = self.tree_view.selected; let row = self.tree_view.visible_rows().into_iter().nth(selected); diff --git a/src/app/tree_nav.rs b/src/app/tree_nav.rs index 885b280..79e88d3 100644 --- a/src/app/tree_nav.rs +++ b/src/app/tree_nav.rs @@ -2,8 +2,6 @@ use super::{App, LIST_PAGE_SIZE}; use crate::ui::tree_view::{TreeIndexEntry, parent_path}; impl App { - /// Move the tree cursor by `delta` rows within the visible list, clamping - /// at both ends, and preview the new row. fn move_tree_selection(&mut self, delta: isize) { let len = self.tree_view.visible_rows().len(); if len == 0 { @@ -36,9 +34,6 @@ impl App { self.move_tree_selection(LIST_PAGE_SIZE as isize); } - /// Expand the selected directory row (lazily reading its children). No-op - /// on file rows, already-expanded directories, or expansion past the - /// configured `max_depth`. pub fn tree_expand(&mut self) { let selected = self.tree_view.selected; let Some(row) = self.tree_view.visible_rows().into_iter().nth(selected) else { @@ -52,15 +47,14 @@ impl App { } self.ensure_tree_children(&row.path); self.tree_view.expanded.insert(row.path); - // Visible rows changed: a same-row-count expand/collapse elsewhere - // could otherwise reuse a stale horizontal-scroll width bound. + // Visible rows changed: drop a stale horizontal-scroll width bound. self.tree_view.row_width_cache.set(None); // A newly expanded directory becomes visible — start watching it. self.sync_tree_watches(); } - /// Collapse the selected directory if expanded; otherwise move the cursor - /// up to its parent directory row (so repeated `Left` walks back out). + // Collapse the selected directory if expanded; otherwise move the cursor + // up to its parent directory row (so repeated `Left` walks back out). pub fn tree_collapse(&mut self) { let rows = self.tree_view.visible_rows(); let Some(row) = rows.get(self.tree_view.selected) else { @@ -68,9 +62,9 @@ impl App { }; if row.is_dir && self.tree_view.expanded.contains(&row.path) { let path = row.path.clone(); - // Drop the directory and every descendant from the expanded set so - // re-expanding it later starts collapsed rather than restoring a - // deep open subtree the user explicitly closed. + // Drop the directory and every descendant so re-expanding later + // starts collapsed rather than restoring a deep subtree the user + // explicitly closed. let prefix = format!("{path}/"); self.tree_view .expanded @@ -90,8 +84,8 @@ impl App { } } - /// Enter toggles a directory open/closed; on a file row it (re)loads the - /// preview, mirroring selection behaviour. + // Enter toggles a directory open/closed; on a file row it (re)loads the + // preview, mirroring selection behaviour. pub fn tree_toggle(&mut self) { let selected = self.tree_view.selected; let Some(row) = self.tree_view.visible_rows().into_iter().nth(selected) else { @@ -108,9 +102,8 @@ impl App { } } - /// Open the filename-search overlay: walk the whole tree once to build the - /// search index, then keep showing the (still unfiltered) view until the - /// user types a query. + // Walk the whole tree once to build the search index, then keep showing + // the (still unfiltered) view until the user types a query. pub fn start_tree_search(&mut self) { self.build_tree_index(); self.tree_view.search_active = true; @@ -135,8 +128,8 @@ impl App { self.reset_tree_selection_after_filter(); } - /// Close the overlay without changing the expansion state; the cursor stays - /// on whatever row maps into the now-unfiltered view. + // Close the overlay without changing the expansion state; the cursor stays + // on whatever row maps into the now-unfiltered view. pub fn cancel_tree_search(&mut self) { self.tree_view.cancel_search(); // Back to watching only what is expanded: the wider set existed for @@ -148,9 +141,9 @@ impl App { self.preview_tree_selected(); } - /// Confirm the current selection: reveal it in the normal expansion-based - /// view by expanding all of its ancestor directories, close the overlay, - /// and move the cursor onto that path. An empty query collapses to a cancel. + // Reveal the current selection in the normal expansion-based view by + // expanding all of its ancestor directories, close the overlay, and move + // the cursor onto that path. An empty query collapses to a cancel. pub fn confirm_tree_search(&mut self) { if self.tree_view.search_query.is_empty() { self.cancel_tree_search(); @@ -181,10 +174,9 @@ impl App { self.preview_tree_selected(); } - /// After a query change the row set shifts, so pin the cursor to the first - /// *matching* row (skipping the ancestor directories pulled in only to - /// connect the path) and re-preview it. Falls back to the first row when - /// nothing matches the basename directly. + // After a query change the row set shifts, so pin the cursor to the first + // *matching* row (skipping ancestor directories pulled in only to connect + // the path). Falls back to the first row when nothing matches directly. fn reset_tree_selection_after_filter(&mut self) { self.tree_view.scroll_x = 0; let rows = self.tree_view.visible_rows(); @@ -202,11 +194,8 @@ impl App { self.preview_tree_selected(); } - /// Walk the entire tree once (depth-capped at `max_depth`, gitignore applied - /// via the same guarded reader used for lazy expansion), populating the - /// per-directory cache and a flat search index. Synchronous on the UI thread - /// like the per-level reads — one keystroke triggers it, then all filtering - /// is in-memory. + // Synchronous on the UI thread like the per-level reads — one keystroke + // triggers it, then all filtering is in-memory. pub(crate) fn build_tree_index(&mut self) { self.ensure_tree_root(); let max_depth = self.cfg_tree.max_depth; @@ -230,7 +219,7 @@ impl App { path: path.clone(), }); // Descend only while the next level stays within max_depth, - // mirroring the expand guard (`depth + 1 > max_depth` blocks). + // mirroring the expand guard. if entry.is_dir && depth < max_depth { stack.push((path, depth + 1)); } diff --git a/src/config/layout.rs b/src/config/layout.rs index 41902ad..d6d7d2c 100644 --- a/src/config/layout.rs +++ b/src/config/layout.rs @@ -2,14 +2,14 @@ use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; -/// Default leader chord literal. `Ctrl+F` is a one-handed left-hand chord that -/// avoids tmux's own `Ctrl+B` prefix (so nightcrow can run inside tmux) AND the -/// Ctrl chords that an inner Claude Code pane reserves (`Ctrl+G` = external -/// editor, plus `Ctrl+O/R/S/T/L/…`). It also dodges terminal flow control -/// (`Ctrl+Q`/`Ctrl+S` = XON/XOFF) and the shell signals `Ctrl+C/D/Z`. Its only -/// collision is `Ctrl+F` as forward-char (readline) / page-forward (vim), which -/// users almost always reach via the arrow keys / PageDown instead; when needed -/// it stays reachable via ``. +/// Default leader chord. `Ctrl+F` is a one-handed left-hand chord that avoids +/// tmux's `Ctrl+B` (so nightcrow can run inside tmux) AND the Ctrl chords an +/// inner Claude Code pane reserves (`Ctrl+G` = external editor, plus +/// `Ctrl+O/R/S/T/L/…`). It also dodges terminal flow control (`Ctrl+Q`/`Ctrl+S` +/// = XON/XOFF) and the shell signals `Ctrl+C/D/Z`. Its only collision is +/// `Ctrl+F` as forward-char (readline) / page-forward (vim), which users +/// almost always reach via the arrow keys / PageDown instead; when needed it +/// stays reachable via ``. pub(super) const DEFAULT_LEADER: &str = "ctrl+f"; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -42,9 +42,9 @@ pub enum Accent { } // Compile-time guard: a future refactor must not shrink `Accent::ALL` to -// empty. `from_index` would otherwise rely on a runtime fallback we'd -// rather not exercise. `const` items don't accept `_` inside an `impl` -// block, so this lives at module scope. +// empty, or `from_index` would rely on a runtime fallback we'd rather not +// exercise. `const` items don't accept `_` inside an `impl` block, so this +// lives at module scope. const _: () = assert!(!Accent::ALL.is_empty(), "Accent::ALL must be non-empty"); impl Accent { @@ -79,8 +79,6 @@ impl Accent { pub fn from_index(idx: usize) -> Accent { // The compile-time guard above keeps `len > 0`, so `% len` is sound. - // `get(...).copied()` is the same value as direct indexing here; the - // form matches the explicit non-panicking pattern used for `index`. Self::ALL .get(idx % Self::ALL.len()) .copied() @@ -104,8 +102,8 @@ impl ThemeConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct InputConfig { - /// The leader (prefix) chord. Every nightcrow app command is reached by - /// pressing this key, then a follow-up key (tmux-style). Accepts a single + /// The leader (prefix) chord. Every app command is reached by pressing + /// this key, then a follow-up (tmux-style). Accepts a single /// `ctrl+` chord; the parser rejects anything that doubles as a /// no-prefix reserved key (F1..F10, Shift+arrows, Shift+PgUp/PgDn). pub leader: String, @@ -120,7 +118,6 @@ 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 @@ -146,20 +143,20 @@ pub fn parse_leader(spec: &str) -> Result { "input.leader \"{spec}\" must use an ascii letter after ctrl+ \ (e.g. ctrl+b; ctrl+1, ctrl+-, ctrl+space are not allowed)" ); - // Terminals send Ctrl+I as Tab (0x09) and Ctrl+M as Enter/CR (0x0d), so - // crossterm surfaces those as KeyCode::Tab / KeyCode::Enter — never the - // Char('i')/Char('m') + CONTROL event that is_leader_key looks for. Such a - // leader could be armed but never recognized, so reject it up front. + // Terminals send Ctrl+I as Tab and Ctrl+M as Enter, so crossterm surfaces + // those as KeyCode::Tab / KeyCode::Enter — never the Char + CONTROL event + // is_leader_key looks for. Such a leader could be armed but never + // recognized, so reject it up front. anyhow::ensure!( !matches!(c, 'i' | 'm'), "input.leader \"{spec}\" is not usable: terminals deliver Ctrl+I as Tab \ 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 via the xterm - // convention. 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. 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. Ok(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)) } diff --git a/src/config/panels.rs b/src/config/panels.rs index 0678e83..8ce3ce7 100644 --- a/src/config/panels.rs +++ b/src/config/panels.rs @@ -3,16 +3,14 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct AgentIndicatorConfig { - /// Show the "recently touched" marker next to files in the status panel. pub enabled: bool, /// Seconds within which a file is considered hot after its mtime. /// Must be >= 3 so the bright→normal fade transition stays observable. pub hot_window_secs: u64, /// When idle (no manual navigation for >=2s), move selection to the - /// freshest hot file. Opt-in: set to `true` so the file list follows - /// whichever file was most recently touched on disk — useful when an - /// agent, build script, or external process is editing files in a - /// neighbouring pane. + /// freshest hot file. Opt-in: the file list follows whichever file was + /// most recently touched on disk — useful when an agent or external + /// process is editing files in a neighbouring pane. pub auto_follow: bool, } @@ -30,18 +28,15 @@ impl Default for AgentIndicatorConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct TreeConfig { - /// Hide paths matched by `.gitignore` (e.g. `target/`, `node_modules/`). - /// On by default so the tree doesn't explode into build artifacts; set to - /// `false` to browse every file on disk. + /// Hide paths matched by `.gitignore`. On by default so the tree doesn't + /// explode into build artifacts. pub respect_gitignore: bool, - /// Maximum directory depth the navigator will expand into. A guard against - /// pathologically deep trees; expansion past this depth is a no-op. Must be - /// in 1..=1024. + /// Maximum directory depth the navigator will expand into. Guard against + /// pathologically deep trees; must be in 1..=1024. pub max_depth: usize, /// Watch expanded directories for filesystem changes and refresh the tree - /// live while Tree mode is open. On by default; only the visible (expanded) - /// directories are watched, non-recursively. Set to `false` to fall back to - /// refreshing only on Tree-mode entry — useful on very large trees or + /// live while Tree mode is open. Only visible (expanded) directories are + /// watched, non-recursively. Set to `false` on very large trees or /// filesystems where native watching is costly or unsupported. pub live_watch: bool, } @@ -60,11 +55,9 @@ impl Default for TreeConfig { #[serde(default)] pub struct MouseConfig { /// Capture the mouse so clicks reach mouse-aware pane programs and wheel - /// scrolls move the pane under the pointer. While captured, the outer - /// terminal only performs its own text selection with Shift held — the - /// standard override every major terminal honors. Set to `false` to give - /// the mouse back to the outer terminal entirely (plain-drag selection, - /// no click forwarding). + /// scrolls the pane under the pointer. While captured, the outer terminal + /// only does its own text selection with Shift held. Set to `false` to + /// give the mouse back to the outer terminal entirely. pub enabled: bool, } diff --git a/src/config/web.rs b/src/config/web.rs index d327dc4..a209c3d 100644 --- a/src/config/web.rs +++ b/src/config/web.rs @@ -27,8 +27,8 @@ pub struct WebMirrorConfig { /// Enable the web mirror. Off by default — turning it on exposes live /// view+control of this nightcrow over the network, so it is opt-in. pub enabled: bool, - /// Address to bind. Defaults to loopback (`127.0.0.1`); set to `0.0.0.0` - /// only deliberately, and prefer an SSH tunnel / reverse proxy for remote + /// Address to bind. Defaults to loopback; set to `0.0.0.0` only + /// deliberately, and prefer an SSH tunnel / reverse proxy for remote /// access since the server speaks plain HTTP (no built-in TLS). pub bind: String, /// TCP port for the web server. @@ -97,11 +97,10 @@ impl WebViewerConfig { } } -/// Generate a random, human-readable password from the OS RNG. -/// -/// Uses a 55-character unambiguous alphabet. The modulo reduction introduces a -/// negligible bias (256 mod 55) that is immaterial for a locally-scoped dev -/// credential; `getrandom` is the same OS entropy source Argon2 salts use. +/// Generate a random, human-readable password from the OS RNG. The modulo +/// reduction introduces a negligible bias (256 mod 55) that is immaterial for +/// a locally-scoped dev credential; `getrandom` is the same OS entropy source +/// Argon2 salts use. pub fn generate_password() -> Result { let mut bytes = [0u8; GENERATED_PASSWORD_LEN]; getrandom::fill(&mut bytes) @@ -113,14 +112,13 @@ pub fn generate_password() -> Result { } /// Ensure the enabled web server 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. +/// 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. pub fn ensure_web_mirror_password( cfg: &mut super::Config, path: &std::path::Path, @@ -135,10 +133,9 @@ pub fn ensure_web_mirror_password( Ok(Some(password)) } -/// Same bootstrap for the viewer's own `[web_viewer]` credential. -/// -/// The viewer gets a *separate* password rather than sharing the mirror's: the -/// two servers already run on separate ports with separate cookies, and one +/// Same bootstrap for the viewer's own `[web_viewer]` credential. The viewer +/// gets a *separate* password rather than sharing the mirror's: the two +/// servers already run on separate ports with separate cookies, and one /// credential granting both would make that separation cosmetic. pub fn ensure_web_viewer_password( cfg: &mut super::Config, diff --git a/src/input/encode.rs b/src/input/encode.rs index a3d5b61..41ef5c1 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -9,11 +9,9 @@ pub fn encode_key(key: KeyEvent) -> Option> { 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): screen/tmux/emacs/less use this - // Ctrl+? → 0x7F (DEL): xterm convention + // `c.to_ascii_uppercase() - '@' < 32` range and need explicit + // xterm-convention mappings: Ctrl+Space → NUL (formula wraps + // because ' ' < '@'), Ctrl+/ → 0x1F (US), Ctrl+? → 0x7F (DEL). let b = match c { ' ' => Some(0x00), '/' => Some(0x1F), @@ -25,8 +23,7 @@ pub fn encode_key(key: KeyEvent) -> Option> { }; if let Some(b) = b { // Ctrl+Alt+Char encodes as ESC + control byte (matches - // readline / Emacs expectations). Without the prefix - // programs like Emacs would see plain Ctrl+Char. + // readline / Emacs expectations). return Some(if alt { vec![0x1b, b] } else { vec![b] }); } } @@ -63,20 +60,17 @@ pub fn encode_key(key: KeyEvent) -> Option> { const SGR_WHEEL_UP: u8 = 64; /// Encode a mouse wheel notch as an SGR (1006) mouse report. `col`/`row` are -/// 1-based cell coordinates; the pane's centre is a good choice, since a TUI -/// may route the event by which of its regions the pointer sits over. -/// -/// 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`. +/// 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`. 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() } /// Encode a horizontal wheel notch as an SGR (1006) mouse report: button 66 -/// is wheel-left, 67 wheel-right. `col`/`row` are 1-based pane-local cells. -/// Horizontal wheel has no scrollback or arrow-key analog, so — unlike the -/// vertical encoder — this only ever targets a pane that claimed the mouse. +/// is wheel-left, 67 wheel-right. Horizontal wheel has no scrollback or +/// arrow-key analog, so this only ever targets a pane that claimed the mouse. pub fn encode_wheel_horizontal(left: bool, col: u16, row: u16) -> Vec { let button: u8 = if left { SGR_WHEEL_UP + 2 @@ -89,8 +83,7 @@ pub fn encode_wheel_horizontal(left: bool, col: u16, row: u16) -> Vec { /// 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 the legacy X10 encoding, which collapses every release to -/// button 3 and could not tell the program which button went up. +/// 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 461905c..9239264 100644 --- a/src/input/routing.rs +++ b/src/input/routing.rs @@ -1,17 +1,15 @@ use crate::input::Action; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -/// Classify a key with NO leader prefix in play. App commands are no longer -/// reachable here (they moved behind the leader — see `prefix_action`); only -/// the modifier-required reserved keys and the bare navigation keys remain. -/// -/// Reserved no-prefix keys are safe global shortcuts because they cannot be -/// confused with prompt text: F-keys are distinct across terminals, and the -/// Shift+arrow / Shift+PgUp/PgDn chords carry a modifier. +/// Classify a key with NO leader prefix in play. App commands live behind the +/// leader (see `prefix_action`); only modifier-required reserved keys and bare +/// navigation keys remain. The reserved chords are safe global shortcuts +/// because they cannot be confused with prompt text: F-keys are distinct +/// across terminals, and Shift+arrow / Shift+PgUp/PgDn carry a modifier. pub fn map_key(event: KeyEvent) -> Action { // Match reserved chords on their EXACT modifier set so any extra modifier // falls through to the PTY: Shift+arrow must be shift-only (not - // Ctrl+Shift+arrow), and the bare F-keys / arrows must carry no modifier at + // Ctrl+Shift+arrow), and bare F-keys / arrows must carry no modifier at // all — including Super/Hyper/Meta, so e.g. Super+F3 passes straight // through instead of triggering a focus jump. let shift_only = event.modifiers == KeyModifiers::SHIFT; @@ -24,36 +22,30 @@ pub fn map_key(event: KeyEvent) -> Action { KeyCode::Down if shift_only => Action::TermScrollLineDown, KeyCode::PageUp if shift_only => Action::TermScrollUp, KeyCode::PageDown if shift_only => Action::TermScrollDown, - // F-keys are universally distinct across terminals (no kitty protocol - // dependency), so they own the one jump that has no other single-key - // route: `F1`..`F10` select project tabs `0`..`9`. Panes and the - // list/diff focus stay on the leader digits, which are layout-aware - // (see `prefix_action` / `prefix_action_fullscreen`); project tabs are - // deliberately NOT layout-aware, so the same F-key reaches the same - // project in split view and in fullscreen alike. + // F-keys own the one jump with no single-key route: `F1`..`F10` select + // project tabs `0`..`9`. Project tabs are deliberately NOT layout-aware + // (unlike the leader digits), so the same F-key reaches the same + // project in split view and fullscreen alike. KeyCode::F(n @ 1..=10) if no_mods => Action::SwitchProject(n as usize - 1), KeyCode::Up if no_mods => Action::Up, KeyCode::Down if no_mods => Action::Down, KeyCode::PageUp if no_mods => Action::PageUp, KeyCode::PageDown if no_mods => Action::PageDown, - // j/k are intentionally NOT mapped here so they remain plain - // characters when Focus::Terminal forwards them to the PTY. The - // upper-pane handler interprets them as navigation explicitly via - // `is_vim_navigation_key`. + // j/k are intentionally NOT mapped here so they remain plain characters + // when Focus::Terminal forwards them to the PTY. The upper-pane handler + // interprets them as navigation via `is_vim_navigation_key`. _ => Action::None, } } /// Classify the single follow-up key pressed after the leader. Returns the -/// app `Action` the leader chord maps to, or `Action::None` for an unmapped -/// follow-up (which the dispatcher consumes and drops). -/// -/// 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 entirely — they select project tabs — so -/// the two never collide and neither needs to leave room for the other. +/// app `Action`, or `Action::None` for an unmapped follow-up (which the +/// dispatcher consumes and drops). 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. pub fn prefix_action(event: KeyEvent) -> Action { match event.code { KeyCode::Char(c) => match c.to_ascii_lowercase() { @@ -78,14 +70,13 @@ pub fn prefix_action(event: KeyEvent) -> Action { } } -/// Leader follow-up mapping used 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 that would only make sense with the viewer on screen. `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 exactly as in -/// `prefix_action`, so `f` (exit fullscreen), `t`, `w`, `s`, etc. are unchanged. +/// 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`. pub fn prefix_action_fullscreen(event: KeyEvent) -> Action { if let KeyCode::Char(c @ '0'..='9') = event.code { return match c { @@ -96,9 +87,9 @@ pub fn prefix_action_fullscreen(event: KeyEvent) -> Action { prefix_action(event) } -/// Returns `Some(Action::Up | Action::Down)` for the vim-style j/k navigation -/// keys (no modifiers), and `None` otherwise. Used by upper-pane handlers so -/// that terminal pass-through is unaffected by changes in `map_key`. +/// `Some(Action::Up | Action::Down)` for bare vim-style j/k, else `None`. +/// Used by upper-pane handlers so terminal pass-through is unaffected by +/// changes in `map_key`. pub fn vim_navigation_action(key: KeyEvent) -> Option { if !key.modifiers.is_empty() { return None; diff --git a/src/mouse.rs b/src/mouse.rs index fdf9d95..0f2095b 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -5,8 +5,8 @@ use crate::workspace::Workspace; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; -/// Route one mouse event. The project tab row is the only target that exists -/// with no project open, so it is resolved before the per-project handler. +/// Route one mouse event. The project tab row is the only target with no +/// project open, so it is resolved before the per-project handler. pub(crate) fn dispatch_mouse( ws: &mut Workspace, tabs: crate::ui::Chrome<'_>, @@ -20,8 +20,6 @@ pub(crate) fn dispatch_mouse( // dialog opened in between: no drag reports are forwarded, so that program // cannot track the pointer itself, and a swallowed release leaves // `pending_mouse_press` set for a later unrelated release to match. - // `handle_mouse` resolves releases before its own modal guard for exactly - // this reason, so the dialog must not swallow them ahead of it either. let is_release = matches!(mouse.kind, MouseEventKind::Up(_)); if ws.repo_input.active && !is_release { return KeyOutcome::Continue; @@ -35,8 +33,8 @@ pub(crate) fn dispatch_mouse( if let Some(idx) = crate::ui::project_tab_at(tabs, screen, mouse.column, mouse.row) { return KeyOutcome::Project(ProjectRequest::Switch(idx)); } - // The open hint is the one action the empty screen offers, so a - // click on it does what its key does. + // The open hint is the empty screen's one action; a click does + // what its key does. let leader_label = crate::app::leader_label_of(ws_leader); let armed = ws.prefix_armed(); match crate::ui::empty_hint_click_at( @@ -49,8 +47,7 @@ pub(crate) fn dispatch_mouse( ) { Some(crate::ui::HintClick::Plain('o')) | Some(crate::ui::HintClick::Leader('o')) => { // Disarm like the key path: an armed prefix left standing - // would consume the next key as a stale follow-up once the - // dialog closes. + // would consume the next key as a stale follow-up. ws.cancel_prefix(); KeyOutcome::Project(ProjectRequest::OpenDialog) } @@ -60,23 +57,13 @@ pub(crate) fn dispatch_mouse( } } -/// Route a captured mouse event to the pane under the pointer. -/// -/// A button press focuses that pane (mirroring a jump key), and press and -/// release are forwarded — via `click_pane` — only to a program that asked -/// for mouse reports. A release pairs with the press's pane rather than the -/// pane under the pointer (see `release_pending_press`). Wheel notches -/// scroll the pane under the pointer, not the active one, through the same -/// sink logic as the scroll keys. A left press outside pane content can -/// focus an upper panel, jump to a pane via its tab (or a `+N` hidden -/// marker), or run a hint-bar shortcut — the latter dispatched as -/// synthesized keypresses so a click and the named key take the same code -/// path (hence the `KeyOutcome` return, e.g. for `r: redraw`). While -/// pane-swap mode is armed, a left click names the swap target instead, -/// mirroring the digit follow-up. Presses on anything else (borders, -/// header) are dropped, and drag/motion reports are not forwarded at all: -/// inner-program text selection stays with the outer terminal's -/// Shift+drag. +/// 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 (dispatched as +/// synthesized keypresses so click and key share the path). In swap mode a +/// left click names the swap target. Drag/motion reports are not forwarded — +/// inner-program text selection stays with the outer terminal's Shift+drag. pub(crate) fn handle_mouse( app: &mut App, tabs: crate::ui::Chrome<'_>, @@ -85,28 +72,22 @@ pub(crate) fn handle_mouse( layout: &crate::config::LayoutConfig, ) -> KeyOutcome { // Releases route by the pending press, not the pointer, so they must be - // handled before the hit test — the pointer may have left the pane (or - // every pane) between press and release. They also bypass the modal - // guard below: the press happened before the modal opened, and the - // program that saw it must still see the release — swallowing it would - // leave the pending slot stale for a later unrelated release. + // handled before the hit test — the pointer may have left the pane. They + // also bypass the modal guard: the press happened before the modal opened, + // and swallowing the release would leave the pending slot stale. if let MouseEventKind::Up(_) = mouse.kind { release_pending_press(app, screen, layout, mouse.column, mouse.row); return KeyOutcome::Continue; } - // Modal overlays (repo-switch dialog, every search bar) own all other - // input while open — same rule the key handler enforces: a click behind - // a modal must not move focus or reach a pane. + // Modal overlays own all other input while open — same rule as the key + // handler: a click behind a modal must not move focus or reach a pane. if app.search_overlay_active() { return KeyOutcome::Continue; } - // Pane-swap mode: a press names the swap target the way a digit does — - // a left click on a pane or its tab swaps the active pane with it, and - // any other press consumes-and-disarms, mirroring the key follow-up - // (`handle_swap_target_followup`). Without this branch a click would - // change the active pane while leaving swap mode armed, so a later - // digit would swap the wrong pane. Wheel events fall through, like a - // paste: they don't name a pane and don't disturb the armed state. + // Pane-swap mode: a press names the swap target the way a digit does. + // Without this branch a click would change the active pane while leaving + // swap mode armed, so a later digit would swap the wrong pane. Wheel + // events fall through (like a paste): they don't name a pane. if app.awaiting_swap_target() && let MouseEventKind::Down(button) = mouse.kind { @@ -122,13 +103,9 @@ pub(crate) fn handle_mouse( return KeyOutcome::Continue; } let Some((id, rect)) = crate::ui::pane_at(app, screen, layout, mouse.column, mouse.row) else { - // Not a terminal cell: a press can still focus an upper panel - // (file/commit/tree list or diff viewer) in the normal split layout, - // or run a shortcut named on the bottom hint row. if let MouseEventKind::Down(button) = mouse.kind { - // The project tab row is checked first: it sits above the body, so - // no panel hit test can claim it, and a tab click is the pointer - // equivalent of its F-key. + // The project tab row sits above the body, so no panel hit test + // can claim it; a tab click is the pointer equivalent of its F-key. if button == crossterm::event::MouseButton::Left && let Some(idx) = crate::ui::project_tab_at(tabs, screen, mouse.column, mouse.row) { @@ -153,8 +130,7 @@ pub(crate) fn handle_mouse( } return KeyOutcome::Continue; }; - // 1-based pane-local cell, as SGR reports expect. In-bounds by - // construction: `pane_at` only returns a rect containing the cell. + // 1-based pane-local cell, as SGR reports expect. let col = mouse.column - rect.x + 1; let row = mouse.row - rect.y + 1; match mouse.kind { @@ -172,9 +148,8 @@ pub(crate) fn handle_mouse( app.terminal .scroll_pane(id, false, WHEEL_LINES_PER_NOTCH, Some((col, row))); } - // Horizontal wheel has no scrollback fallback; it reaches only a - // pane whose program asked for wheel reports (trackpads and tilt - // wheels in e.g. a full-screen TUI with horizontal panes). + // Horizontal wheel has no scrollback fallback; it reaches only a pane + // that asked for wheel reports. MouseEventKind::ScrollLeft => { app.terminal.wheel_horizontal_pane(id, true, col, row); } @@ -186,13 +161,9 @@ pub(crate) fn handle_mouse( KeyOutcome::Continue } -/// Run a clicked hint-bar shortcut by synthesizing the keypress(es) its -/// label names, so a click and the real key share every guard and dispatch -/// path in `handle_key` — a hint click can never do something the named key -/// would not. `Arm` hints press the leader chord alone (the armed row then -/// offers clickable follow-ups); `Leader` hints press the leader chord first -/// (arming the prefix) and the follow-up second; `Plain` hints press one -/// bare key. +/// Run a clicked hint-bar shortcut by synthesizing the keypress(es) its label +/// names, so a click and the real key share every guard and dispatch path in +/// `handle_key` — a hint click can never do something the named key would not. fn dispatch_hint_click(app: &mut App, click: crate::ui::HintClick) -> KeyOutcome { let plain = |c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE); match click { @@ -213,18 +184,11 @@ fn dispatch_hint_click(app: &mut App, click: crate::ui::HintClick) -> KeyOutcome } /// Deliver a button release to the pane that received the matching press. -/// -/// A program that saw an SGR press must see the release even when the -/// pointer moved off the pane in between (no drag reports are forwarded, so -/// it cannot track the pointer itself) — and a pane the pointer merely ends -/// up over must NOT receive a release it never got a press for. The release -/// carries the *stored* press button, not the one crossterm reported: -/// legacy encodings don't identify the button on release, so some -/// platforms report every `Up` as `Left`, and trusting that would strand a -/// right/middle press without its release. Chords were never paired (the -/// slot is single), so any release closes the pending press. The release -/// cell is clamped into the pressed pane's current rect. If that pane was -/// closed or hidden since the press, the release is dropped. +/// 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. fn release_pending_press( app: &mut App, screen: Rect, @@ -243,8 +207,8 @@ fn release_pending_press( return; }; // An extreme resize between press and release can shrink the pane to a - // zero-sized rect, which would invert the clamp bounds below (`clamp` - // panics when min > max). + // zero-sized rect, which would invert the clamp bounds (`clamp` panics + // when min > max). if rect.width == 0 || rect.height == 0 { return; } diff --git a/src/ui/commit_list.rs b/src/ui/commit_list.rs index 22f3568..9ad83f2 100644 --- a/src/ui/commit_list.rs +++ b/src/ui/commit_list.rs @@ -177,8 +177,8 @@ fn render_file_list(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let title_base = truncate_title(&commit_summary, title_budget(list_area.width)); let title = if show_search && total_count > 0 { - // Drop the trailing space the base title carries so the count - // suffix sits flush against the summary text. + // Drop the trailing space the base title carries so the count suffix + // sits flush against the summary text. format!("{} ({match_count}/{total_count}) ", title_base.trim_end()) } else { title_base @@ -200,12 +200,11 @@ 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`. 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. fn title_budget(width: u16) -> usize { (width as usize).saturating_sub(2) } diff --git a/src/ui/diff_pane/highlight.rs b/src/ui/diff_pane/highlight.rs index 5055e3c..5fa4ff2 100644 --- a/src/ui/diff_pane/highlight.rs +++ b/src/ui/diff_pane/highlight.rs @@ -1,19 +1,19 @@ /// 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 over the +/// whole document for state recovery. #[derive(Debug, Clone)] pub struct HighlightSegment { pub rgb: (u8, u8, u8), pub text: String, } -/// 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. +/// 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. 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 0601358..4cc18b5 100644 --- a/src/ui/diff_pane/mod.rs +++ b/src/ui/diff_pane/mod.rs @@ -18,18 +18,16 @@ pub enum DiffPaneView { #[default] Diff, File, - /// Side-by-side diff: removed lines on the left, added lines on the right, - /// context lines mirrored on both sides. Falls back to the unified `Diff` - /// renderer when the pane is too narrow to split usefully. + /// Side-by-side diff: removed left, added right, context mirrored. Falls + /// back to the unified `Diff` renderer when the pane is too narrow. Split, } /// One row of the side-by-side layout. `Header` carries the hunk index whose -/// `@@ ... @@` header spans the full width; `Body` carries the (hunk, line) -/// coordinates shown on each side, with `None` marking a blank padding cell -/// where one side has no counterpart line. Coordinates index into -/// `DiffPane::hunks` (and the matching `line_highlights`) so the renderer can -/// reuse the prebuilt highlight cache without re-running syntect. +/// `@@ ... @@` 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. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SplitRow { Header(usize), @@ -39,33 +37,25 @@ pub enum SplitRow { }, } -/// All state for the diff viewer pane: the loaded hunks, scroll cursors, -/// search state, and the optional file-content overlay. Lifted out of App -/// so renderers and navigation handlers operate on a self-contained value. +/// All state for the diff viewer pane: hunks, scroll cursors, search state, +/// and the optional file-content overlay. #[derive(Default)] pub struct DiffPane { pub hunks: Vec, /// Lowercased copy of each `DiffLine::content` aligned with `hunks`. - /// `hunks_lines_lower[i][j]` corresponds to `hunks[i].lines[j].content`. - /// Built once per diff load so per-keystroke search does not re-lowercase - /// the entire diff. Header lines are never searched and are not cached. + /// 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 + /// 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 - /// every frame. + /// change so the renderer skips the full-document state-recovery pass. pub line_highlights: Vec>>, - /// Syntax name (`SyntaxReference::name`) resolved per hunk at the time - /// `line_highlights` was built. Stored as a per-hunk vector because a - /// single commit diff can touch files of different types and each hunk - /// needs its own highlighter state. Empty means the cache is unbuilt - /// or invalidated. + /// 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. pub cached_hunk_syntax: Vec, - /// Sum of `line.content.len()` across all hunk lines at the time - /// `line_highlights` was built. Pairs with the shape check so a hunk - /// replacement that happens to preserve the same line counts still - /// invalidates the cache. Belt-and-braces on top of the existing - /// `rebuild_lower_cache` invariant. + /// 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, @@ -73,8 +63,7 @@ pub struct DiffPane { pub view: DiffPaneView, pub file_view: FileViewState, /// True while the diff pane is rendered full-screen (hint bar excluded). - /// Toggled by `Ctrl+F` while focus is on `DiffViewer`; mutually exclusive - /// with `TerminalPane::fullscreen`. + /// Mutually exclusive with `TerminalPane::fullscreen`. pub fullscreen: bool, } diff --git a/src/ui/diff_pane/pane_impl.rs b/src/ui/diff_pane/pane_impl.rs index 094d499..0ff5c5d 100644 --- a/src/ui/diff_pane/pane_impl.rs +++ b/src/ui/diff_pane/pane_impl.rs @@ -8,14 +8,12 @@ impl DiffPane { } /// Largest legal `scroll` value: one less than the total row count, or 0 - /// when there are no rows. Callers clamp restored scroll positions and - /// page-down ends against this bound. + /// when there are no rows. pub fn max_scroll(&self) -> usize { self.line_count().saturating_sub(1) } - /// Move the active horizontal scroll target (diff or file view, depending - /// on `self.view`) left by one tab stop. + /// Move the active horizontal scroll target left by one tab stop. pub fn scroll_left(&mut self) { let target = self.scroll_x_target_mut(); *target = target.saturating_sub(4); @@ -39,9 +37,8 @@ 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 on - /// both sides. Cheap to recompute: it only walks line kinds and stores - /// coordinates, never copying content. + /// shorter run padded with blank cells), and context lines are mirrored. + /// Cheap to recompute: it only walks line kinds and stores coordinates. pub fn split_rows(&self) -> Vec { let mut rows = Vec::new(); for (hi, hunk) in self.hunks.iter().enumerate() { @@ -110,17 +107,11 @@ impl DiffPane { /// Rebuild `search.matches` against the current query, using /// `hunks_lines_lower` so per-keystroke search is just a substring scan - /// over precomputed strings. - /// - /// `scroll_to_match` selects the post-rebuild behaviour: - /// - `true`: jump the viewport to the current cursor's match (used after - /// a keystroke where the user explicitly drove the search). - /// - `false`: keep the viewport pinned and re-anchor `cursor` to the - /// match nearest to the current scroll. Without this, a content-only - /// refresh (e.g. background snapshot tick while a query is active) - /// would leave the "current match" indicator at a stale row far from - /// where the user is reading, so the next `n`/`p` would jump - /// unexpectedly. + /// 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). pub fn recompute_matches(&mut self, scroll_to_match: bool) { self.search.matches.clear(); if self.search.query.is_empty() { @@ -203,14 +194,11 @@ impl DiffPane { .collect(); self.hunks_lines_lower.push(lines); } - // Highlight cache shape is keyed by hunks; invalidate so the renderer - // rebuilds it on next frame against the active syntax. self.line_highlights.clear(); self.cached_hunk_syntax.clear(); } /// Rebuild the lowercased line cache iff its shape diverges from `hunks`. - /// Cheap path for callers that aren't sure whether the cache is current. pub fn ensure_lower_cache(&mut self) { let shape_matches = self.hunks_lines_lower.len() == self.hunks.len() && self @@ -227,9 +215,8 @@ impl DiffPane { /// 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, when there is no single "current" file). Rebuilds when - /// the cache shape, content size, or any per-hunk syntax diverges from - /// the cached state. + /// plain text). Rebuilds when the cache shape, content size, or any + /// per-hunk syntax diverges. pub fn ensure_highlight_cache( &mut self, ss: &syntect::parsing::SyntaxSet, @@ -278,7 +265,7 @@ impl DiffPane { )); current_syntax_name = syntax.name.clone(); } - // Safe: just assigned in the line above when None. + // Safe: just assigned above when None. let (hl_new, hl_old) = hl_pair.as_mut().unwrap(); let mut per_hunk: Vec> = Vec::with_capacity(hunk.lines.len()); diff --git a/src/ui/diff_pane/search.rs b/src/ui/diff_pane/search.rs index cb9e94b..79c5bbe 100644 --- a/src/ui/diff_pane/search.rs +++ b/src/ui/diff_pane/search.rs @@ -59,8 +59,8 @@ impl DiffSearch { return None; } // Defensive clamp: `recompute_matches(false)` re-anchors `cursor` to - // the nearest match, but a stale cursor can otherwise survive into - // here through code paths that mutate `matches` without re-anchoring. + // the nearest match, but a stale cursor can otherwise survive here + // through code paths that mutate `matches` without re-anchoring. if self.cursor >= self.matches.len() { self.cursor = 0; } else { @@ -82,11 +82,11 @@ impl DiffSearch { } } -/// Return the index of the match in `matches` whose flat row is closest to -/// `scroll`. Ties prefer the smaller flat row (i.e. the one already on or -/// above the cursor) so a content refresh during reading never jumps the -/// "current match" past where the user is looking. `matches` must be sorted -/// ascending and non-empty. +/// Index of the match in `matches` whose flat row is closest to `scroll`. +/// Ties prefer the smaller flat row (the one already on or above the cursor) +/// so a content refresh during reading never jumps the "current match" past +/// where the user is looking. `matches` must be sorted ascending and +/// non-empty. pub(crate) fn nearest_match_index(matches: &[usize], scroll: usize) -> usize { debug_assert!(!matches.is_empty()); match matches.binary_search(&scroll) { diff --git a/src/ui/diff_viewer/file_view.rs b/src/ui/diff_viewer/file_view.rs index 8fa8dd7..ac49328 100644 --- a/src/ui/diff_viewer/file_view.rs +++ b/src/ui/diff_viewer/file_view.rs @@ -78,10 +78,9 @@ pub(crate) fn render_file_view( let fv = &app.diff.file_view; let total = fv.line_count(); let width = total.to_string().len(); - // 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 so a stale - // total_lines can never produce an out-of-range start. + // 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. let max_scroll = total .saturating_sub(1) .min(fv.line_highlights.len().saturating_sub(1)); diff --git a/src/ui/diff_viewer/mod.rs b/src/ui/diff_viewer/mod.rs index f50ec2d..f55fa57 100644 --- a/src/ui/diff_viewer/mod.rs +++ b/src/ui/diff_viewer/mod.rs @@ -18,8 +18,8 @@ use syntect::highlighting::ThemeSet; use syntect::parsing::SyntaxSet; /// Minimum pane width (columns) for the side-by-side split layout. Below this -/// each half is too narrow to read, so `Split` view transparently falls back -/// to the unified diff renderer. +/// each half is too narrow to read, so `Split` view falls back to the unified +/// diff renderer. const MIN_SPLIT_WIDTH: u16 = 80; pub(crate) fn rgb_to_color(rgb: (u8, u8, u8)) -> Color { @@ -66,8 +66,6 @@ pub fn render( // Build the syntect highlight cache once per (hunks × per-hunk syntax) // so the visible-window walk below stays bounded even on large diffs. - // Each hunk carries its own file_path now, so commit diffs that touch - // multiple file types stop rendering as plain text. app.diff.ensure_highlight_cache(ss, ts); let current_match = app.diff.search.current_match(); @@ -173,9 +171,9 @@ 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. + // 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", }; lines.push(Line::from(Span::styled( diff --git a/src/ui/diff_viewer/split_view.rs b/src/ui/diff_viewer/split_view.rs index 4545848..b122cfe 100644 --- a/src/ui/diff_viewer/split_view.rs +++ b/src/ui/diff_viewer/split_view.rs @@ -74,8 +74,8 @@ pub(crate) fn render_split_view( let scroll_x = app.diff.scroll_x.min(u16::MAX as usize) as u16; let left_para = Paragraph::new(left_lines).scroll((0, scroll_x)); - // A left border on the right column draws the vertical divider between the - // two halves and indents the new-side content by one cell. + // A left border on the right column draws the vertical divider between + // the two halves and indents the new-side content by one cell. let right_para = Paragraph::new(right_lines) .block( Block::default() diff --git a/src/ui/file_list.rs b/src/ui/file_list.rs index 723d523..a9c73e0 100644 --- a/src/ui/file_list.rs +++ b/src/ui/file_list.rs @@ -65,8 +65,8 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { 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. + // 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,9 @@ 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 (added/modified/…) 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 — no leading glyph — so transitions between + // stages don't shift the row width. 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 9dc64e1..5409b55 100644 --- a/src/ui/file_view.rs +++ b/src/ui/file_view.rs @@ -20,24 +20,23 @@ pub struct FileViewState { 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 the - /// visible window instead of re-highlighting the whole file. + /// 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` means the cache is - /// unbuilt or invalidated (e.g. on content reload). + /// 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 (`FileViewState::max_scroll` is called - /// from each j/k/PgUp/PgDn handler). + /// full file on every scroll keystroke (`max_scroll` is called from each + /// j/k/PgUp/PgDn handler). pub(crate) total_lines: usize, - /// Byte length of `content` at the time `line_highlights` was built. - /// Combined with `total_lines` it lets `ensure_highlight_cache` notice - /// in-place content edits that happen to keep the line count constant - /// (line counts alone are too coarse a fingerprint). + /// 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). 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 - /// the whole file. Cleared whenever `content` changes. + /// `ensure_lower_cache` so per-keystroke file search avoids re-lowercasing. pub(crate) lines_lower: Vec, } @@ -46,13 +45,12 @@ impl FileViewState { self.total_lines } - /// Replace the rendered content. Keeps `total_lines` and the highlight + /// 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` against the new max and - /// drops any prior error so an in-place reload of the same `FileViewState` - /// never lands on a row past the new file length or keeps a "load failed" - /// banner over fresh content. + /// 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. pub fn set_content(&mut self, content: String) { self.total_lines = if content.is_empty() { 0 @@ -60,8 +58,8 @@ impl FileViewState { content.lines().count() }; self.content = content; - // Highlights are content-derived: stale entries would either index - // past `total_lines` or render the previous file's colors. + // 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; @@ -96,8 +94,7 @@ impl FileViewState { /// Ensure `line_highlights` matches the current `content` and supplied /// syntax. Rebuilds when the line count diverges or the syntax name - /// changed since the last build. Builds once per file load; the renderer - /// only slices the visible window from the cache afterward. + /// changed since the last build. pub fn ensure_highlight_cache( &mut self, ss: &syntect::parsing::SyntaxSet, diff --git a/src/ui/hint_bar.rs b/src/ui/hint_bar.rs index 2e02226..4dad9d6 100644 --- a/src/ui/hint_bar.rs +++ b/src/ui/hint_bar.rs @@ -44,14 +44,14 @@ pub(crate) fn segment_click(keyspec: &str) -> Option { } /// Build the styled spans for a hint legend, inverting (`REVERSED`) every -/// clickable segment — the whole `key: description` label, matching the -/// click target exactly — so the bar itself shows which hints respond to a -/// click. Consumes the same literal and `" | "` segmentation as -/// `hint_click_at` — and decides clickability with the same `segment_click` -/// — so an inverted label can never disagree with the hit test. Only styles -/// change; the rendered text (and thus every column offset) stays identical. -/// `mark_clickable` is `[mouse] enabled`: with capture off a click can never -/// arrive, so no label may advertise one. +/// clickable segment — the whole `key: description` label, matching the click +/// target exactly — so the bar itself shows which hints respond to a click. +/// Consumes the same literal and `" | "` segmentation as `hint_click_at` and +/// decides clickability with the same `segment_click`, so an inverted label +/// can never disagree with the hit test. Only styles change; the rendered +/// text (and thus every column offset) stays identical. `mark_clickable` is +/// `[mouse] enabled`: with capture off a click can never arrive, so no label +/// may advertise one. pub(crate) fn hint_spans(text: &str, leader: &str, mark_clickable: bool) -> Vec> { let base = Style::default().fg(Color::DarkGray); let inverted = base.add_modifier(Modifier::REVERSED); @@ -67,10 +67,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, so the affordance covers exactly what responds. - // 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. + // Leading whitespace stays 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() { @@ -86,8 +85,8 @@ pub(crate) fn hint_spans(text: &str, leader: &str, mark_clickable: bool) -> Vec< pub(crate) fn render_hint_bar<'a>(app: &'a App, chrome: Chrome<'a>, accent: Color) -> Paragraph<'a> { if chrome.repo_input.active { - // A rejected path is reported on the notice row directly above, so - // this row stays a plain input line. + // A rejected path is reported on the notice row above, so this row + // stays a plain input line. return Paragraph::new(Line::from(vec![ Span::styled("repo: ", Style::default().fg(accent)), Span::raw(chrome.repo_input.buf.as_str()), @@ -111,8 +110,8 @@ pub(crate) fn render_hint_bar<'a>(app: &'a App, chrome: Chrome<'a>, accent: Colo } if app.awaiting_swap_target() { // The swap-target digits follow the same layout-aware mapping as the - // focus jumps (see `main::resolve_prefix_action`): `1-8` while the - // terminal fills the body, `3-9,0` in the split view. + // focus jumps: `1-8` while the terminal fills the body, `3-9,0` in + // the split view. let digits = if app.terminal.fullscreen.fills_body() { "1-8" } else { @@ -132,9 +131,8 @@ pub(crate) fn render_hint_bar<'a>(app: &'a App, chrome: Chrome<'a>, accent: Colo ), ])); } - // `` in the hint literal resolves to the configured leader chord - // (e.g. `^F`) so the footer always names the actual key to press rather - // than an abstract word. + // `` resolves to the configured leader chord (e.g. `^F`) so the + // footer names the actual key to press rather than an abstract word. Paragraph::new(Line::from(hint_spans( normal_hint_literal(app), &app.leader_label(), @@ -211,8 +209,7 @@ pub(crate) fn hint_click_at( } // Row selection mirrors `render_hint_bar`'s branch order exactly, or a - // click would resolve against a row the user isn't looking at. Notices no - // longer appear here (they own the row above), so they don't feature. + // click would resolve against a row the user isn't looking at. let (chip, text) = if chrome.repo_input.active { return None; } else if app.prefix_armed() { diff --git a/src/ui/hint_text.rs b/src/ui/hint_text.rs index 3de54cc..365d753 100644 --- a/src/ui/hint_text.rs +++ b/src/ui/hint_text.rs @@ -8,15 +8,14 @@ pub(crate) const EMPTY_HINT_ARMED: &str = " o: open project | q: quit | esc: can 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 (see `main::resolve_prefix_action`). + // `3-9,0` jump to panes. let digits = if app.terminal.fullscreen.fills_body() { "1-8: pane" } else { "1-9: focus/pane" }; - // `w`/`s` only act under their availability predicates (see - // `App::can_close_pane`/`can_swap_panes`), so only advertise them there — - // a hint for a no-op key would lie. + // `w`/`s` only act under their availability predicates, so only advertise + // them there — a hint for a no-op key would lie. let close = if app.can_close_pane() { "w: close pane | " } else { @@ -27,25 +26,23 @@ pub(crate) fn prefix_armed_hint_text(app: &App) -> String { } else { "" }; - // The view toggles name their destination from the current mode, matching - // the normal legends, instead of a generic `log/status` label. + // The view toggles name their destination from the current mode. let (log_toggle, tree_toggle) = match app.mode { ViewMode::Log => ("l: status view", "b: tree view"), ViewMode::Status => ("l: log view", "b: tree view"), ViewMode::Tree => ("l: log view", "b: status view"), }; - // `x` is advertised unconditionally, unlike `w`/`s` above: refusing to - // close the last project reports why on the notice row, so the key always - // produces a visible result rather than silently doing nothing. + // `x` is advertised unconditionally: refusing to close the last project + // reports why on the notice row, so the key always produces a visible + // result. format!( " t: new pane | {close}{swap}{log_toggle} | {tree_toggle} | f: fullscreen | o: open project | x: close project | p: theme | r: redraw | q: quit | {digits} | esc: cancel" ) } /// 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. +/// 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 @@ -80,8 +77,7 @@ pub(crate) fn normal_hint_literal(app: &App) -> &'static str { } else if app.can_open_file_view() { " f: exit zoom | j/k: scroll | v: view file | s: split | /: search | pgup/pgdn: page | q: quit" } else { - // No file target for `v` (log view browsing commits, or nothing - // selected) — a hint for a no-op key would lie. + // No file target for `v` — a hint for a no-op key would lie. " f: exit zoom | j/k: scroll | s: split | /: search | pgup/pgdn: page | q: quit" }; return hint; @@ -149,16 +145,15 @@ pub(crate) fn normal_hint_literal(app: &App) -> &'static str { } else if !app.diff.search.query.is_empty() { " n: next match | shift+n: prev match | /: new search | esc: clear" } else if app.can_open_file_view() { - // The `l` toggle names its destination (Tree mode never - // reaches these arms — its right pane is always the file view). + // The `l` toggle names its destination (Tree mode never reaches + // these arms — its right pane is always the file view). if app.mode == ViewMode::Log { " shift+←/→: cycle | j/k: scroll | pgup/pgdn: scroll | v: view file | s: split | /: search | t: new pane | f: zoom | l: status view | b: tree view | o: open project | q: quit" } else { " shift+←/→: cycle | j/k: scroll | pgup/pgdn: scroll | v: view file | s: split | /: search | t: new pane | f: zoom | l: log view | b: tree view | o: open project | q: quit" } } else { - // No file target for `v` (log view browsing commits, or - // nothing selected) — a hint for a no-op key would lie. + // No file target for `v` — a hint for a no-op key would lie. if app.mode == ViewMode::Log { " shift+←/→: cycle | j/k: scroll | pgup/pgdn: scroll | s: split | /: search | t: new pane | f: zoom | l: status view | b: tree view | o: open project | q: quit" } else { diff --git a/src/ui/log_view/mod.rs b/src/ui/log_view/mod.rs index 7850319..5c85238 100644 --- a/src/ui/log_view/mod.rs +++ b/src/ui/log_view/mod.rs @@ -17,13 +17,11 @@ pub struct LogView { pub(crate) commit_width_cache: Cell>, /// Memoized longest-path char width for `commit_files`. pub(crate) commit_files_width_cache: Cell>, - /// Count of commits currently loaded. Maintained in lockstep with - /// `commits.len()` by the pagination helpers; kept as a discrete field so - /// the worker channel can compare against an expected `skip` when results - /// arrive (drop pages produced from a stale view). + /// Count of commits currently loaded. Kept as a discrete field (in lockstep + /// with `commits.len()`) so the worker channel can compare against an + /// expected `skip` when results arrive and drop stale pages. pub(crate) loaded_count: usize, - /// A background page fetch is in flight. Guards against issuing duplicate - /// requests for the same tail. + /// A background page fetch is in flight. Guards against duplicate requests. pub(crate) pending_fetch: bool, /// The previous fetch returned fewer entries than requested, so no further /// pages exist. Cleared by `reset_pagination`. @@ -36,8 +34,8 @@ pub struct LogView { pub commit_search_query: SearchQuery, pub commit_search_active: bool, pub(crate) commits_filter_cache: Vec, - /// Drill-down file-list incremental search. Same shape as the commit - /// search above; indices reference `commit_files`. + /// 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, @@ -57,17 +55,16 @@ impl LogView { self.recompute_commit_filter(); } - /// Install a freshly-fetched first page. Resets pagination state via - /// `set_commits` and computes `fully_loaded` from the page length so the - /// callsite doesn't have to repeat the short-page sentinel logic. + /// Install a freshly-fetched first page. Computes `fully_loaded` from the + /// page length so the callsite doesn't repeat the short-page sentinel. 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; } - /// Append a freshly-fetched page to the tail. `page_size` is the limit - /// the caller asked for: a short result means we've reached the end. + /// Append a freshly-fetched page to the tail. A short result means we've + /// reached the end. pub(crate) fn append_page(&mut self, mut page: Vec, page_size: usize) { let received = page.len(); if received > 0 { @@ -82,9 +79,8 @@ impl LogView { } } - /// Mark a fetch as in flight. Returns `true` if the flag transitioned, - /// `false` if a fetch was already pending so the caller should not spawn - /// another worker. + /// Mark a fetch as in flight. Returns `false` if one was already pending + /// so the caller should not spawn another worker. pub(crate) fn mark_pending(&mut self) -> bool { if self.pending_fetch { return false; @@ -110,15 +106,15 @@ impl LogView { /// Exit drill-down so the upper pane shows the commit list again. Clears /// the file list and resets file-side cursors/scroll so a later drill-in - /// starts from a clean state. + /// starts clean. 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 any file-list search state so a later drill-in starts fresh - // and does not carry the previous commit's query into the new view. + // 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(); @@ -126,8 +122,7 @@ impl LogView { /// Refresh `commits_filter_cache` from `commits` and the current query. /// Callers must invoke this after mutating `commits` or - /// `commit_search_query`; otherwise the cache will diverge from state. - /// Mirrors `StatusView::recompute_filter`. + /// `commit_search_query`; otherwise the cache will diverge. pub(crate) fn recompute_commit_filter(&mut self) { self.commits_filter_cache.clear(); if self.commit_search_query.is_empty() { @@ -173,8 +168,8 @@ impl LogView { } /// Hide the commit-list search bar. Returns `true` when the query was - /// empty and the call therefore collapsed to a cancel (so the caller - /// knows to refresh the diff for the now-unfiltered list). + /// empty and the call collapsed to a cancel (so the caller knows to + /// refresh the diff for the now-unfiltered list). pub fn confirm_commit_search(&mut self) -> bool { if self.commit_search_query.is_empty() { self.cancel_commit_search(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c9916fd..e9a35c2 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -87,8 +87,9 @@ 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 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. let hint = if chrome.repo_input.active { Line::from(vec![ Span::styled("repo: ", Style::default().fg(accent)), @@ -123,8 +124,8 @@ pub fn draw( // 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. + // 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); diff --git a/src/ui/project_tab/mod.rs b/src/ui/project_tab/mod.rs index a505174..57a0f60 100644 --- a/src/ui/project_tab/mod.rs +++ b/src/ui/project_tab/mod.rs @@ -1,10 +1,9 @@ -//! The project tab row across the top of the screen. -//! -//! Mirrors `terminal_tab`'s tab bar deliberately: one `tab_segments` builder -//! feeding both the renderer and the click hit-test, so a label and its click -//! box can never disagree. The two rows differ only in what they address — -//! panes below, projects above — and in their key legends, which come from -//! different axes (leader digits for panes, bare F-keys for projects). +//! The project tab row across the top of the screen. Mirrors +//! `terminal_tab`'s tab bar: one `tab_segments` builder feeds both the +//! renderer and the click hit-test, so a label and its click box can never +//! disagree. The two rows differ only in what they address — panes below, +//! projects above — and in their key legends (leader digits for panes, bare +//! F-keys for projects). use ratatui::{ layout::Rect, @@ -14,22 +13,18 @@ use ratatui::{ }; /// Per-tab character budget for the project name. Shorter than the pane -/// budget: up to ten tabs share one row, where panes cap at eight and carry -/// shorter titles. +/// budget: up to ten tabs share one row. const TAB_TITLE_MAX_CHARS: usize = 14; -/// Width of a `+N` overflow marker. Exactly one digit is enough: at most +/// Width of a `+N` overflow marker. One digit is enough: at most /// `MAX_PROJECTS` (10) tabs exist, so at most 9 can be hidden on one side. const MARKER_WIDTH: u16 = 4; -/// The name shown for a repo path — its final component, which is what -/// distinguishes sibling checkouts (`~/work/api` vs `~/work/web`). -/// -/// Goes through `Path` rather than splitting on `/` so a Windows path -/// (`C:\work\api`) yields `api` too; splitting by hand would render the whole -/// path there and waste the tab's width. Falls back to the path itself when it -/// has no final component (a filesystem root), and to a placeholder when it is -/// empty — a blank tab would look unclickable. +/// The name shown for a repo path — its final component, which distinguishes +/// sibling checkouts (`~/work/api` vs `~/work/web`). Goes through `Path` +/// rather than splitting on `/` so a Windows path (`C:\work\api`) yields +/// `api` too. Falls back to the path itself for a filesystem root, and to a +/// placeholder when empty — a blank tab would look unclickable. pub(crate) fn tab_label(repo_path: &str) -> String { let path = std::path::Path::new(repo_path); let name = path @@ -53,12 +48,10 @@ fn truncate(s: &str, max: usize) -> String { out } -/// 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 — unlike panes, whose digit legend shifts -/// with the layout. Projects past the tenth have no key, so they carry no -/// legend rather than implying an unbound one. +/// 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 +/// no legend rather than implying an unbound one. fn tab_texts(repo_paths: &[String]) -> Vec { repo_paths .iter() @@ -74,11 +67,10 @@ fn tab_texts(repo_paths: &[String]) -> 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 -/// simply 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 instead, and what is dropped is replaced by a `+N` marker +/// 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. fn visible_window(widths: &[u16], width: u16, active: usize) -> std::ops::Range { let n = widths.len(); @@ -96,9 +88,8 @@ fn visible_window(widths: &[u16], width: u16, active: usize) -> std::ops::Range< used.saturating_add(markers * MARKER_WIDTH) <= width }; - // Grow right first, then left, until neither side can take another tab. - // Right-first keeps the common case (active near the front) showing the - // projects that follow it. + // 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; if hi < n && fits(used + widths[hi], lo, hi + 1) { @@ -119,10 +110,8 @@ fn visible_window(widths: &[u16], width: u16, active: usize) -> std::ops::Range< /// Build the row's segments: rendered text paired with the project each one /// selects. Single source for `render` and `tab_at`, so the hit boxes always -/// match what is on screen. -/// -/// A `+N` marker selects the nearest project hidden on its side, so the -/// overflow is reachable by pointer as well as by F-key. +/// match what is on screen. A `+N` marker selects the nearest project hidden +/// on its side, so the overflow is reachable by pointer as well as by F-key. fn tab_segments(repo_paths: &[String], active: usize, width: u16) -> Vec<(String, usize)> { let texts = tab_texts(repo_paths); let widths: Vec = texts.iter().map(|t| Span::raw(t).width() as u16).collect(); @@ -145,12 +134,9 @@ fn tab_segments(repo_paths: &[String], active: usize, width: u16) -> Vec<(String 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, matching the app-wide convention that -/// accent means "this is the one in play". +/// 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. pub(crate) fn render( repo_paths: &[String], active: usize, diff --git a/src/ui/search.rs b/src/ui/search.rs index cb5275a..36c72c2 100644 --- a/src/ui/search.rs +++ b/src/ui/search.rs @@ -1,6 +1,4 @@ -/// Search-input string paired with its lowercased form. The two used to live -/// as independent `String` fields on `DiffSearch` and `StatusView`, which -/// meant every mutation site had to remember to re-lowercase. Bundling the +/// 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()`. @@ -39,8 +37,8 @@ impl SearchQuery { self.lower.clear(); } - /// Replace the query wholesale. Used by tests to seed an initial query - /// without char-by-char push; runtime callers always go through push/pop. + /// Replace the query wholesale. Used by tests to seed an initial query; + /// runtime callers always go through push/pop. #[cfg(test)] pub fn set(&mut self, s: impl Into) { self.raw = s.into(); diff --git a/src/ui/status_view.rs b/src/ui/status_view.rs index 0d7e567..2e36112 100644 --- a/src/ui/status_view.rs +++ b/src/ui/status_view.rs @@ -11,15 +11,13 @@ pub struct StatusView { pub file_scroll_x: usize, pub search_query: SearchQuery, pub search_active: bool, - /// Indices into `files` that match the current `search_query`. - /// Recomputed only when `files` or the query changes (see - /// `App::recompute_status_filter`). Read-only for renderers. + /// 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 by the agent-aware focus indicator to decide whether a file - /// is currently "hot" (recently touched). Entries for paths missing - /// from the latest snapshot are dropped each tick so the map stays - /// bounded by the working-tree change count. + /// 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. pub hot_table: HashMap, /// Memoized longest-path char width, keyed by `files.len()`. Used by /// `upper_scroll_x_max` so the right-arrow keystroke does not walk every @@ -32,8 +30,7 @@ pub struct StatusView { impl StatusView { /// Replace `files` and invalidate the path-width cache so a same-length /// snapshot whose contents changed (e.g. a file rename) does not leave a - /// stale right-scroll bound. Length-keyed invalidation alone misses the - /// rename case; clearing the cell on every assignment closes that hole. + /// stale right-scroll bound. pub(crate) fn set_files(&mut self, files: Vec) { self.files = files; self.path_width_cache.set(None); @@ -43,9 +40,8 @@ impl StatusView { self.search_query.clear(); } - /// Refresh `filter_cache` from `files` and the current query. Callers must - /// invoke this after mutating `files` or `search_query`; otherwise the - /// cache will diverge from state. + /// Refresh `filter_cache` from `files` and the current query. Callers + /// must invoke this after mutating `files` or `search_query`. pub(crate) fn recompute_filter(&mut self) { self.filter_cache.clear(); if self.search_query.is_empty() { @@ -74,9 +70,8 @@ impl StatusView { } /// Hide the search bar. Returns `true` when the query was empty and the - /// call therefore collapsed to a cancel (the caller should refresh the - /// diff in that case so a stale selection from the empty-filter state is - /// re-pinned). + /// call collapsed to a cancel (the caller should refresh the diff so a + /// stale selection from the empty-filter state is re-pinned). pub fn confirm_search(&mut self) -> bool { if self.search_query.is_empty() { self.cancel_search(); @@ -102,10 +97,10 @@ impl StatusView { pub struct RepoInput { pub active: bool, pub buf: String, - /// Whether `buf` is still the untouched current-repo path the dialog - /// opened with. The first typed character replaces it rather than - /// appending, so switching to an unrelated path doesn't start with - /// backspacing the whole prefill; Backspace clears the flag instead, which - /// keeps the text and enters ordinary editing (the sub-directory case). + /// Whether `buf` is still the untouched prefill the dialog opened with. + /// The first typed character replaces it rather than appending, so + /// switching to an unrelated path doesn't start with backspacing the + /// whole prefill; Backspace clears the flag instead, keeping the text + /// and entering ordinary editing (the sub-directory case). pub prefilled: bool, } diff --git a/src/ui/terminal_tab/cells.rs b/src/ui/terminal_tab/cells.rs index d7255a7..713eb8a 100644 --- a/src/ui/terminal_tab/cells.rs +++ b/src/ui/terminal_tab/cells.rs @@ -19,10 +19,10 @@ pub(crate) struct VisiblePaneCell { } /// Lay out every currently visible pane inside `content_area` (the terminal -/// body, i.e. below the tab row). This is the 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. +/// 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. pub(crate) fn visible_pane_cells(app: &App, content_area: Rect) -> Vec { let pane_count = app.terminal.panes.len(); let visible = visible_range( @@ -58,9 +58,9 @@ pub(crate) fn visible_pane_cells(app: &App, content_area: Rect) -> Vec Vec<(PaneId, Rect)> { let Some((_, content_area)) = terminal_layout(area) else { return Vec::new(); diff --git a/src/ui/terminal_tab/layout.rs b/src/ui/terminal_tab/layout.rs index 2a4530f..79e26bb 100644 --- a/src/ui/terminal_tab/layout.rs +++ b/src/ui/terminal_tab/layout.rs @@ -7,17 +7,17 @@ use ratatui::{ /// The terminal pane draws only top/bottom borders, never the left/right `│`. /// With side bars, selecting terminal output to copy picks up a `│` glyph on /// every wrapped row; dropping them lets the content run edge-to-edge so a -/// copy is clean. Top stays for the title + focus tint, bottom for separation. +/// copy is clean. pub(crate) const TERMINAL_BORDERS: Borders = Borders::TOP.union(Borders::BOTTOM); /// Per-tab character budget for the title (excluding the jump-key hint and -/// surrounding padding). Anything longer is truncated with a trailing ellipsis -/// so long OSC-set titles can't push neighboring tabs off the row. +/// surrounding padding). Anything longer is truncated with a trailing +/// ellipsis so long OSC-set titles can't push neighboring tabs off the row. pub(crate) const TAB_TITLE_MAX_CHARS: usize = 20; -/// Number of panes reachable by a leader-digit jump key. Panes past -/// this index have no jump-key hint in the tab bar (only focus cycling -/// reaches them). Tied to `MAX_VISIBLE_FULLSCREEN` by reference (not just by +/// Number of panes reachable by a leader-digit jump key. Panes past this +/// index have no jump-key hint in the tab bar (only focus cycling reaches +/// them). Tied to `MAX_VISIBLE_FULLSCREEN` by reference (not just by /// convention) so the two can never silently drift apart. pub(crate) const JUMP_KEY_PANE_COUNT: usize = MAX_VISIBLE_FULLSCREEN; @@ -50,11 +50,11 @@ pub(crate) fn terminal_layout(area: Rect) -> Option<(Rect, Rect)> { /// 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. +/// 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. 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 215f161..8bbfe08 100644 --- a/src/ui/terminal_tab/mod.rs +++ b/src/ui/terminal_tab/mod.rs @@ -73,10 +73,9 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) -> Option let is_active = i == app.terminal.active; if cell.bordered { // `accent` means "this is where your keystrokes go right now" — - // reserved for Focus::Terminal, matching FileList/DiffViewer. - // 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. + // 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. let pane_border_style = if is_active && focused { Style::default().fg(accent) } else { diff --git a/src/ui/terminal_tab/screen.rs b/src/ui/terminal_tab/screen.rs index b270839..0c313e1 100644 --- a/src/ui/terminal_tab/screen.rs +++ b/src/ui/terminal_tab/screen.rs @@ -30,11 +30,11 @@ pub(crate) fn build_screen_lines(app: &App, pane_id: PaneId, rows: u16, cols: u1 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. - if cell.is_wide_spacer() { + // 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. + if cell.is_wide_spacer() { continue; } style = cell_to_style(&cell); diff --git a/src/ui/terminal_tab/tab_bar.rs b/src/ui/terminal_tab/tab_bar.rs index cc97862..d30e6de 100644 --- a/src/ui/terminal_tab/tab_bar.rs +++ b/src/ui/terminal_tab/tab_bar.rs @@ -47,10 +47,9 @@ pub(crate) fn tab_segments(app: &App, visible: std::ops::Range) -> Vec<(S )]; } // While the terminal fills the body the upper viewer is hidden, so - // ` 1..8` address panes 0..7 directly (see - // `input::prefix_action_fullscreen`); label the tabs with those digits. - // In the split view the digits `1`/`2` belong to the list/diff, so the - // pane legend stays on `F3..F10` there. + // ` 1..8` address panes 0..7 directly; label the tabs with those + // digits. In the split view the digits `1`/`2` belong to the list/diff, + // so the pane legend stays on `F3..F10` there. let fullscreen = app.terminal.fullscreen.fills_body(); let hidden_before = visible.start; let hidden_after = app.terminal.panes.len().saturating_sub(visible.end); @@ -64,12 +63,11 @@ pub(crate) fn tab_segments(app: &App, visible: std::ops::Range) -> Vec<(S segments.extend(app.terminal.panes[visible.clone()].iter().enumerate().map( |(offset, pane)| { let i = visible.start + offset; - // Panes 0..=7 carry a jump key, so show it as a key legend: - // ` 1..8` in fullscreen, ` 3..9,0` in the split - // view (the digit row is layout-aware — see `prefix_action`). - // 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. + // 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. let title = truncate_tab_title(&pane.title, TAB_TITLE_MAX_CHARS); let label = if i < JUMP_KEY_PANE_COUNT { // Split view runs 3,4..9 then wraps to 0 for the eighth pane. @@ -94,11 +92,11 @@ pub(crate) fn tab_segments(app: &App, visible: std::ops::Range) -> Vec<(S segments } -/// The pane index a click at screen cell `(x, y)` on the tab bar should -/// jump to: a tab targets its own pane, a `+N` marker the nearest hidden -/// pane on its side. `None` off the tab row, past the last segment, or on -/// the no-panes legend. `area` is the full terminal widget Rect, exactly -/// what `render` receives. +/// The pane index a click at screen cell `(x, y)` on the tab bar should jump +/// to: a tab targets its own pane, a `+N` marker the nearest hidden pane on +/// its side. `None` off the tab row, past the last segment, or on the +/// no-panes legend. `area` is the full terminal widget Rect, exactly what +/// `render` receives. pub(crate) fn tab_target_at(app: &App, area: Rect, x: u16, y: u16) -> Option { let (tab_area, _) = terminal_layout(area)?; if !tab_area.contains(Position { x, y }) { diff --git a/src/ui/tree_list.rs b/src/ui/tree_list.rs index 9fa945d..95d899d 100644 --- a/src/ui/tree_list.rs +++ b/src/ui/tree_list.rs @@ -1,5 +1,4 @@ //! 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 @@ -15,8 +14,8 @@ use ratatui::{ }; // 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. +// when collapsed, a down chevron when expanded. 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,8 +24,8 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let focused = app.focus == Focus::FileList; 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. + // 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. 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() diff --git a/src/ui/tree_view/mod.rs b/src/ui/tree_view/mod.rs index b8515ef..b041e8a 100644 --- a/src/ui/tree_view/mod.rs +++ b/src/ui/tree_view/mod.rs @@ -1,20 +1,18 @@ //! State for the read-only file-tree navigator (`ViewMode::Tree`). -//! //! `TreeView` holds a per-directory child cache plus the set of expanded -//! directories. The visible row list is *derived* from those two on demand -//! (`visible_rows`) rather than stored, so expansion state and the flattened -//! view can never drift. All directory I/O lives in `App` (`app/tree.rs`), -//! which populates `cache` lazily; this module is pure given a populated cache, -//! which keeps the flattening logic unit-testable without a filesystem. +//! directories; the visible row list is *derived* from those on demand +//! (`visible_rows`), so expansion state and the flattened view can never +//! drift. All directory I/O lives in `App` (`app/tree.rs`); this module is +//! pure given a populated cache, which keeps the flattening logic +//! unit-testable without a filesystem. use crate::git::tree::TreeEntry; use crate::ui::SearchQuery; use std::cell::Cell; use std::collections::{BTreeSet, HashMap, HashSet}; -/// One flattened, currently-visible tree row. `path` is repo-relative (the key -/// used for previews, expansion, and selection restore); `depth` drives -/// indentation; `expanded` is only ever `true` for directories. +/// One flattened, currently-visible tree row. `path` is repo-relative; +/// `expanded` is only ever `true` for directories. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VisibleRow { pub path: String, @@ -24,11 +22,8 @@ pub struct VisibleRow { pub expanded: bool, } -/// One entry in the flat filename-search index: the repo-relative `path` and -/// the lowercased basename used for case-insensitive substring matching. (A -/// row's directory flag is read from the cache during rendering, so it is not -/// stored here.) Built once when search opens (see `App::build_tree_index`) and -/// discarded when it closes. +/// One entry in the flat filename-search index. Built once when search opens +/// (see `App::build_tree_index`) and discarded when it closes. #[derive(Debug, Clone)] pub(crate) struct TreeIndexEntry { pub path: String, @@ -37,54 +32,38 @@ pub(crate) struct TreeIndexEntry { #[derive(Default)] pub struct TreeView { - /// Index into the current `visible_rows()` list. pub selected: usize, - /// Horizontal scroll offset (chars) for long rows, mirroring the file/log - /// lists. Reset to 0 whenever the selection moves to a new row. + /// Horizontal scroll offset (chars). Reset to 0 when the selection moves. pub scroll_x: usize, - /// Repo-relative directory paths that are currently expanded. The root - /// (`""`) is implicitly expanded and is never stored here. + /// Repo-relative expanded directory paths. The root (`""`) is implicitly + /// expanded and never stored here. pub expanded: BTreeSet, - /// Lazily-populated children, keyed by repo-relative directory path - /// (`""` for the root). A directory absent from this map has not been read - /// yet; an entry with an empty vec is a directory that was read and is - /// genuinely empty (or fully filtered). + /// Lazily-populated children, keyed by repo-relative directory path (`""` + /// for the root). Absent = unread; empty vec = read and genuinely empty. pub cache: HashMap>, - /// Memoized longest visible-row char width, keyed by row count. Mirrors - /// `StatusView::path_width_cache`; invalidated implicitly because the key - /// is the row count, and any structural change that matters here also - /// changes how many rows are visible. + /// Memoized longest visible-row char width, keyed by row count. Invalidated + /// implicitly because structural changes also change the row count. pub(crate) row_width_cache: Cell>, - /// Whether the filename-search overlay is open. While active *and* the - /// query is non-empty (`search_filtering`), `visible_rows` returns the - /// filtered tree — matching entries plus the ancestor directories needed to - /// reach them — instead of the expansion-based view. + /// While active *and* the query is non-empty (`search_filtering`), + /// `visible_rows` returns the filtered tree instead of the expansion view. pub search_active: bool, pub search_query: SearchQuery, - /// Flat index of every entry under the root (within `max_depth`, gitignore - /// applied), built once when search opens. Empty while search is closed. + /// Flat index of every entry under the root, built when search opens. pub(crate) index: Vec, - /// Repo-relative paths to display while filtering: every matching entry - /// plus all of its ancestor directories. Recomputed on each query change. + /// Repo-relative paths to display while filtering: matches plus ancestors. show_set: HashSet, - /// Count of entries matching the current query (numerator of the `(m/n)` - /// title badge). + /// Count of entries matching the current query (`(m/n)` badge numerator). pub(crate) match_count: usize, } impl TreeView { - /// Reset everything except config — used when switching repositories so a - /// previous workdir's cache/expansion never leaks into the new tree. - /// Whether the search overlay is open with a non-empty query, i.e. the - /// filtered view is in effect. An open overlay with an empty query still - /// shows the normal expansion-based view (so the tree does not explode - /// before the user types). + /// 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. pub fn search_filtering(&self) -> bool { self.search_active && !self.search_query.is_empty() } - /// Close the search overlay and drop all transient search state. Safe to - /// call when search is already closed. pub fn cancel_search(&mut self) { self.search_active = false; self.search_query.clear(); @@ -95,12 +74,12 @@ impl TreeView { } /// Recompute `show_set`/`match_count` from `index` and the current query. - /// Each match contributes itself and every ancestor directory so the - /// filtered view renders an unbroken path from the root down to each hit. + /// 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) { // 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` are disjoint fields but 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() { @@ -117,8 +96,8 @@ impl TreeView { self.show_set.clear(); for path in matches { if self.show_set.insert(path.clone()) { - // Walk ancestors; stop as soon as one is already present, since - // its own ancestors were added on a prior insert. + // Stop at the first ancestor already present — its own + // ancestors were added on a prior insert. let mut p = path.as_str(); while let Some(parent) = parent_path(p) { if !self.show_set.insert(parent.to_string()) { @@ -130,10 +109,9 @@ impl TreeView { } } - /// Derive the flattened list of currently-visible rows from the cache and - /// expansion set. Only expanded, cached directories contribute children, so - /// this never triggers I/O and never walks an unexpanded subtree. While - /// filtering, the row list is restricted to `show_set` instead. + /// 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 { let mut rows = Vec::new(); if self.search_filtering() { @@ -144,9 +122,9 @@ impl TreeView { rows } - /// Filtered variant of `push_children`: include only entries present in - /// `show_set` (matches and their ancestors), rendering every kept directory - /// as expanded so the full path to each match is visible. + /// Filtered variant of `push_children`: include only `show_set` entries, + /// rendering 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 { return; @@ -205,8 +183,8 @@ impl TreeView { .map(|r| r.path.clone()) } - /// Clamp `selected` to the current row count so a collapse or refresh that - /// shrinks the list can never leave the cursor past the end. + /// Clamp `selected` to the row count 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; @@ -216,19 +194,16 @@ impl TreeView { } } -/// Parent directory of a repo-relative path, or `None` when the path is a -/// top-level entry (whose parent is the root, which has no selectable row). +/// Parent directory of a repo-relative path, or `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 produced during -/// normal navigation always are (they're built from `read_dir` entry names), -/// but a restored session is read from disk — a boundary where a hand-edited -/// or corrupted `tree_expanded` entry containing `..`, a leading `/`, or a -/// drive prefix would otherwise let the tree read directories outside the -/// working tree. Used to filter restored expansion paths before any directory -/// read happens. +/// 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. pub fn is_safe_rel_path(rel: &str) -> bool { use std::path::Component; !rel.is_empty() diff --git a/src/web/common/auth.rs b/src/web/common/auth.rs index c267e68..78841c9 100644 --- a/src/web/common/auth.rs +++ b/src/web/common/auth.rs @@ -129,10 +129,9 @@ impl RateLimiter { } } - /// Record an attempt at time `now` and report whether it is allowed. Prunes - /// timestamps older than an hour, then enforces the per-minute and per-hour - /// caps. A rejected attempt is still recorded so a flood cannot reset the - /// window by simply retrying. + /// Record an attempt at time `now` and report whether it is allowed. A + /// rejected attempt is still recorded so a flood cannot reset the window + /// by simply retrying. pub fn check_and_record(&self, now: Instant) -> bool { let mut attempts = self.attempts.lock().expect("rate limiter mutex poisoned"); attempts.retain(|t| now.duration_since(*t) < Duration::from_secs(3600)); diff --git a/src/web/common/conn.rs b/src/web/common/conn.rs index f81b81f..46e61cb 100644 --- a/src/web/common/conn.rs +++ b/src/web/common/conn.rs @@ -30,9 +30,8 @@ pub const HEAD_READ_TIMEOUT: Duration = Duration::from_secs(15); /// those starve the accept loop. This is the deadline that actually ends it. pub const REQUEST_DEADLINE: Duration = Duration::from_secs(30); -/// Read the request head (up to CRLFCRLF) plus any declared body. -/// -/// Both ceilings are enforced while reading, not after, so a client that never +/// Read the request head (up to CRLFCRLF) plus any declared body. Both +/// ceilings are enforced while reading, not after, so a client that never /// sends a terminator cannot grow the buffer without bound. pub fn read_request(stream: &mut TcpStream) -> Result<(RequestHead, String)> { stream.set_read_timeout(Some(HEAD_READ_TIMEOUT)).ok(); diff --git a/src/web/common/http.rs b/src/web/common/http.rs index f3fe2fa..0d4c4f0 100644 --- a/src/web/common/http.rs +++ b/src/web/common/http.rs @@ -87,8 +87,7 @@ pub fn parse_request_head(text: &str) -> Result { if method.is_empty() || target.is_empty() { bail!("malformed HTTP request line: {request_line:?}"); } - // Split the query off so routing matches on the path alone. Both halves are - // bounded by the caller's head-size cap. + // Split the query off so routing matches on the path alone. let (path, query) = match target.split_once('?') { Some((p, q)) => (p.to_string(), q.to_string()), None => (target, String::new()), diff --git a/src/web/common/mod.rs b/src/web/common/mod.rs index c24b46b..5c0a5dd 100644 --- a/src/web/common/mod.rs +++ b/src/web/common/mod.rs @@ -2,9 +2,7 @@ //! //! 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. The mirror is the -//! only consumer today; the planned viewer (`docs/web-viewer-plan.md`) is a -//! second server that shares exactly this layer and nothing above it. +//! but nothing about 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 8fd8b62..1893428 100644 --- a/src/web/common/sse.rs +++ b/src/web/common/sse.rs @@ -2,17 +2,17 @@ //! //! 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. +//! 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`. +//! Generic over [`Write`] so the framing is unit-testable against a buffer +//! and the same code drives a real `TcpStream`. //! -//! Nothing routes an SSE response yet — the viewer's `/api/events` is step 6 of -//! `docs/web-viewer-plan.md`, while this framing is step 2. The module is built -//! and tested ahead of its caller so each step stays small, hence the blanket -//! dead-code allowance; drop it once a route constructs an `SseStream`. +//! Nothing routes an SSE response yet — the viewer's `/api/events` is step 6 +//! of `docs/web-viewer-plan.md`, while this framing is step 2. The module is +//! built and tested ahead of its caller so each step stays small, hence the +//! blanket dead-code allowance; drop it once a route constructs an `SseStream`. #![allow(dead_code)] use std::io::{self, Write}; @@ -69,9 +69,8 @@ impl SseStream { frame.push_str("event: "); frame.push_str(event); frame.push('\n'); - // `\r\n`, `\n` and `\r` all terminate a line in the SSE grammar, so each - // has to become its own `data:` field. A payload with no newline at all - // still produces exactly one. + // `\r\n`, `\n` and `\r` all terminate a line in the SSE grammar, so + // each has to become its own `data:` field. for line in data.split("\r\n").flat_map(|l| l.split(['\n', '\r'])) { frame.push_str("data: "); frame.push_str(line); diff --git a/src/web/frontend.rs b/src/web/frontend.rs index 99e81e6..a764d57 100644 --- a/src/web/frontend.rs +++ b/src/web/frontend.rs @@ -1,7 +1,5 @@ //! Embedded frontend assets. Bundled into the binary so the server is -//! self-contained and works offline. The terminal page and its vendored -//! xterm.js renderer are fleshed out in the frontend step; the login page is -//! self-contained here. +//! self-contained and works offline. use crate::web::common::html_escape; diff --git a/src/web/mod.rs b/src/web/mod.rs index cea2868..361e261 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,15 +1,5 @@ //! Web mirror: serve a live, controllable view of this nightcrow over HTTP so a //! browser and the local terminal drive the same session. -//! -//! - [`common`] holds the server-agnostic primitives: password verification, -//! session tokens, login rate limiting, HTTP request/response framing, and -//! connection accounting. -//! - [`protocol`] encodes screen frames (ratatui `Buffer` → ANSI) and decodes -//! browser input (JSON → crossterm events). -//! - `server` runs a synchronous WebSocket/HTTP server on background threads and -//! exchanges frames/input with the main loop over channels — the `App` itself -//! is never shared across threads. -//! - `frontend` holds the embedded page assets. pub mod protocol; diff --git a/src/web/protocol/mod.rs b/src/web/protocol/mod.rs index 22eaea4..786e41c 100644 --- a/src/web/protocol/mod.rs +++ b/src/web/protocol/mod.rs @@ -1,15 +1,11 @@ //! Wire protocol for the web mirror: server→browser screen frames and //! browser→server input events. //! -//! **Output** re-uses ratatui's own `CrosstermBackend` to turn a `Buffer` -//! (full frame) or a `Buffer`→`Buffer` diff (incremental) into ANSI. The bytes -//! are therefore byte-identical to what the local terminal receives, and each -//! encoded chunk self-terminates with a style reset (crossterm's `draw` -//! appends one), so chunks concatenate cleanly on a single xterm.js instance. -//! -//! **Input** decodes a small JSON envelope (`{"t":"key"|"mouse"|"paste",…}`) -//! into a crossterm `KeyEvent`/`MouseEvent`/paste string, so browser input runs -//! through the exact same `handle_key`/`handle_mouse`/`handle_paste` routing as +//! Output re-uses ratatui's own `CrosstermBackend`, so the bytes are +//! byte-identical to what the local terminal receives, and each chunk +//! self-terminates with a style reset so chunks concatenate cleanly on a +//! single xterm.js instance. Input decodes a small JSON envelope into +//! crossterm events, so browser input runs through the exact same routing as //! local input — a web action can never diverge from the equivalent keypress. use crossterm::event::{KeyEvent, MouseEvent}; @@ -26,17 +22,15 @@ pub enum WebInputEvent { Paste(String), } -/// Encode a full repaint of `current` for a freshly connected client. -/// -/// Clears the screen first (so a reconnecting xterm.js drops any stale -/// content), then paints every non-blank cell. Blank default cells are omitted -/// because a cleared terminal already shows them. +/// Encode a full repaint of `current` for a freshly connected client. Clears +/// first so a reconnecting xterm.js drops stale content, then paints every +/// non-blank cell. pub fn encode_full_frame(current: &Buffer) -> Vec { let blank = Buffer::empty(*current.area()); let updates = blank.diff(current); let mut out = Vec::new(); - // Hide the cursor for the duration of the repaint so it doesn't visibly - // chase the painted cells; `encode_cursor` re-shows it at the right spot. + // Hide the cursor for the duration of the repaint so it doesn't chase the + // painted cells; `encode_cursor` re-shows it at the right spot. out.extend_from_slice(b"\x1b[?25l"); { let mut backend = CrosstermBackend::new(&mut out); @@ -49,9 +43,8 @@ pub fn encode_full_frame(current: &Buffer) -> Vec { } /// Encode the incremental update needed to bring a client from `previous` to -/// `current`. Returns an empty Vec when nothing changed (caller skips the send) -/// or when the two buffers have different dimensions — a size change is not a -/// cell-level diff and must be handled by re-sending a full frame instead. +/// `current`. Returns empty when nothing changed or the buffers differ in +/// dimensions — a size change is not a cell-level diff and needs a full frame. pub fn encode_update(previous: &Buffer, current: &Buffer) -> Vec { if previous.area() != current.area() { return Vec::new(); @@ -68,14 +61,10 @@ pub fn encode_update(previous: &Buffer, current: &Buffer) -> Vec { out } -/// Encode the trailing cursor state for a frame chunk. -/// -/// The cell buffer carries no cursor — ratatui applies it to the local terminal -/// directly — so every chunk sent to a browser ends with an explicit park: -/// either a move to `cursor` plus show, or a hide when the frame has no cursor -/// (terminal panel unfocused, scrolled back, or not rendered at all). Without -/// this the browser would keep the cursor wherever the last painted cell left -/// it. Coordinates are absolute screen cells; ANSI is 1-based, the buffer 0. +/// Encode the trailing cursor state for a frame chunk. The cell buffer carries +/// no cursor, so every chunk ends with an explicit park: move+show, or hide +/// when the frame has no cursor. Coordinates are absolute screen cells; ANSI +/// is 1-based, the buffer 0. pub fn encode_cursor(cursor: Option) -> Vec { match cursor { Some(p) => format!("\x1b[{};{}H\x1b[?25h", p.y as u32 + 1, p.x as u32 + 1).into_bytes(), diff --git a/src/web/server/mod.rs b/src/web/server/mod.rs index 3148527..2eed2b2 100644 --- a/src/web/server/mod.rs +++ b/src/web/server/mod.rs @@ -26,9 +26,9 @@ use std::time::Duration; /// Poll interval for the per-client loop: bounds added output latency while /// letting the same thread service both socket reads and queued writes. pub(super) const WS_POLL_TIMEOUT: Duration = Duration::from_millis(10); -/// Live connections allowed at once. Each one costs a thread, so an unbounded -/// accept loop lets anything that can reach the port exhaust the process. -/// A browser session needs a handful; this leaves room for several of them. +/// Live connections allowed at once. Each one costs a thread, so an +/// unbounded accept loop lets anything that can reach the port exhaust the +/// process. pub(super) const MAX_CONNECTIONS: usize = 64; /// Handle owned by the main loop. Drop stops nothing (threads live until the diff --git a/src/web/viewer/catalog/mod.rs b/src/web/viewer/catalog/mod.rs index 49dcb3e..cebb9c5 100644 --- a/src/web/viewer/catalog/mod.rs +++ b/src/web/viewer/catalog/mod.rs @@ -145,11 +145,10 @@ impl Catalog { .map(|e| e.to_dto()) } - /// 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. + /// 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(); diff --git a/src/web/viewer/dto/envelope.rs b/src/web/viewer/dto/envelope.rs index 97b7b11..c2c7f11 100644 --- a/src/web/viewer/dto/envelope.rs +++ b/src/web/viewer/dto/envelope.rs @@ -63,8 +63,8 @@ pub struct ViewerBootstrapDto { /// Index into the viewer's accent presets, stored server-side so every /// device agrees. pub accent: usize, - /// File-sidebar width in CSS px, stored server-side like the accent so every - /// device opens at the same split. + /// File-sidebar width in CSS px, stored server-side like the accent so + /// every device opens at the same split. pub sidebar_width: u32, /// This server's wall clock, for dating [`super::ChangedFileDto::mtime`]. pub now_ms: u64, diff --git a/src/web/viewer/dto/log.rs b/src/web/viewer/dto/log.rs index 26b0a4a..7838b49 100644 --- a/src/web/viewer/dto/log.rs +++ b/src/web/viewer/dto/log.rs @@ -41,9 +41,10 @@ pub struct LogDto { pub head: Option, } -/// Changed paths in one historical commit. The row shape intentionally matches -/// [`ChangedFileDto`], so the browser renders status and commit drill-down -/// lists consistently (including rename sources and XY-style status columns). +/// Changed paths in one historical commit. The row shape intentionally +/// matches [`ChangedFileDto`], so the browser renders status and commit +/// drill-down lists consistently (including rename sources and XY-style +/// status columns). #[derive(Debug, Clone, Serialize)] pub struct CommitFilesDto { pub files: Vec, diff --git a/src/web/viewer/dto/mod.rs b/src/web/viewer/dto/mod.rs index 00636cd..8d310de 100644 --- a/src/web/viewer/dto/mod.rs +++ b/src/web/viewer/dto/mod.rs @@ -1,14 +1,14 @@ //! The viewer's wire format. //! -//! Internal git types are never serialized directly. They carry fields that -//! exist only to make the TUI fast — `search_lower`, `summary_lower` — and -//! types like `Oid` whose serde shape is libgit2's business, not a protocol -//! decision. Every payload below is an explicit whitelist built by hand, so +//! 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. //! -//! [`PROTOCOL_VERSION`] rides on every response. A cached page from an older -//! build can then refuse to interpret a newer payload rather than misread it. +//! [`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. mod diff; mod envelope; diff --git a/src/web/viewer/dto/status.rs b/src/web/viewer/dto/status.rs index 32c32ce..64efe0f 100644 --- a/src/web/viewer/dto/status.rs +++ b/src/web/viewer/dto/status.rs @@ -4,9 +4,9 @@ use serde::Serialize; use std::collections::HashMap; use std::time::SystemTime; -/// One navigable directory in the "open a project" folder picker. Directories -/// only — files are not openable as projects. `is_repo` flags a git worktree so -/// the picker can mark it. +/// One navigable directory in the "open a project" folder picker. +/// Directories only — files are not openable as projects. `is_repo` flags a +/// git worktree so the picker can mark it. #[derive(Debug, Serialize)] pub struct BrowseEntryDto { pub name: String, diff --git a/src/web/viewer/highlight.rs b/src/web/viewer/highlight.rs index da1cd25..aa701b6 100644 --- a/src/web/viewer/highlight.rs +++ b/src/web/viewer/highlight.rs @@ -40,11 +40,11 @@ pub struct Highlighter { } impl Highlighter { - /// Highlight one source line into coloured spans. `raw` must not include a - /// trailing newline. + /// Highlight one source line into coloured spans. `raw` must not include + /// a trailing newline. pub fn line(&mut self, raw: &str) -> Vec { - // syntect wants a trailing newline to terminate a line; strip it back - // off the segments so span text matches the source exactly. + // syntect wants a trailing newline to terminate a line; strip it + // back off the segments so span text matches the source exactly. let with_nl = format!("{raw}\n"); match self.hl.highlight_line(&with_nl, self.ss) { Ok(ranges) => ranges diff --git a/src/web/viewer/limits.rs b/src/web/viewer/limits.rs index f2587da..a8d59f7 100644 --- a/src/web/viewer/limits.rs +++ b/src/web/viewer/limits.rs @@ -1,22 +1,17 @@ //! Ceilings on everything the viewer will serialize or hold open. //! -//! Every route reads from a repository whose size the server does not control: -//! a commit log can be a million entries, a generated file a gigabyte, a -//! `node_modules` directory a hundred thousand children. Without a ceiling, one -//! request turns into an unbounded allocation and a response no browser can -//! render. Each limit below is the point past which more data stops being -//! useful to a human reading a diff. +//! Every route reads from a repository whose size the server does not control. +//! Without a ceiling, one request turns into an unbounded allocation and a +//! response no browser can render. Each limit below is the point past which +//! more data stops being useful to a human reading a diff. //! -//! Truncation is always *reported* — [`Capped::truncated`] rides along into the +//! Truncation is always reported — [`Capped::truncated`] rides along into the //! DTO so the UI can say "showing the first N", never silently imply it showed //! everything. -/// Commits returned by one page of `/api/log`. -/// -/// Matches the TUI's `commit_log_page_size` default so both surfaces move -/// through a history at the same pace. It was 200 while the endpoint answered -/// once and stopped; now that the client asks for the next page as it reaches -/// the tail, a smaller page paints sooner and costs nothing to repeat. +/// Commits returned by one page of `/api/log`. Matches the TUI's +/// `commit_log_page_size` default so both surfaces move through history at the +/// same pace. 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 @@ -29,17 +24,15 @@ pub const MAX_COMMIT_FILES: usize = 2_000; /// Entries returned for one directory level of `/api/tree`. pub const MAX_TREE_ENTRIES: usize = 2_000; /// Depth cap for the recursive `/api/tree/search` walk. Matches the TUI tree's -/// default `max_depth` (`config.rs`) so both surfaces reach the same files. +/// `max_depth` (`config.rs`). pub const MAX_TREE_SEARCH_DEPTH: usize = 64; /// Entries one `/api/tree/search` walk may inspect before it stops and reports -/// the listing as incomplete. Bounds filesystem work per request (the TUI has no -/// equivalent cap because it walks once, in-process, for a single local user). +/// the listing as incomplete. Bounds filesystem work per request. pub const MAX_TREE_SEARCH_VISITS: usize = 100_000; /// Matches returned by one `/api/tree/search` request. pub const MAX_TREE_SEARCH_RESULTS: usize = 500; -/// Longest accepted `/api/tree/search` query. A filename substring never needs -/// to be large; anything past this is rejected at the boundary rather than -/// lowercased and matched against every basename. +/// Longest accepted `/api/tree/search` query. Anything past this is rejected +/// at the boundary rather than matched against every basename. pub const MAX_TREE_SEARCH_QUERY_BYTES: usize = 256; /// Changed files reported in one status payload. pub const MAX_STATUS_FILES: usize = 2_000; @@ -52,18 +45,13 @@ pub const MAX_DIFF_LINES: usize = 20_000; pub const MAX_SSE_PAYLOAD_BYTES: usize = 1024 * 1024; /// Terminals one repository may hold open at once. Each is a real process. pub const MAX_PTYS_PER_REPO: usize = 8; -/// Raw PTY bytes retained per terminal to replay to a client that (re)connects, -/// so a browser refresh restores the screen instead of a blank pane. Bounded so -/// a long-running terminal cannot grow without limit (up to MAX_PTYS_PER_REPO of -/// these per repo). The oldest bytes are dropped first. -/// +/// Raw PTY bytes retained per terminal to replay to a (re)connecting client. /// Restore is best-effort, not an exact snapshot: replaying only a byte-window -/// means a terminal mode set before that window — entering the alternate screen, -/// a persistent SGR — can be lost, and a truncated escape sequence at the front -/// is clipped. In practice a full-screen program repaints on the resize that -/// every client sends right after connecting, and shells re-emit their prompt -/// styling; a true fix would need server-side VT emulation, which this viewer -/// deliberately does not do (xterm.js is the only emulator). +/// means a terminal mode set before that window (alternate screen, persistent +/// SGR) can be lost. A full-screen program repaints on the resize every client +/// sends right after connecting; a true fix would need server-side VT +/// emulation, which this viewer deliberately does not do (xterm.js is the only +/// emulator). pub const MAX_TERMINAL_SCROLLBACK_BYTES: usize = 256 * 1024; /// Live connections the viewer's accept loop will hold. Separate from the /// mirror's cap: they are different servers on different ports. @@ -96,12 +84,10 @@ impl Capped { } } -/// Cut `text` to at most `max_bytes`, never splitting a UTF-8 character. -/// -/// Returns the kept prefix and whether anything was dropped. Byte-slicing a -/// `String` at an arbitrary index panics mid-character, so the cut is walked -/// back to the nearest boundary — 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 multi-byte character +/// straddling the limit 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/mod.rs b/src/web/viewer/mod.rs index 8f343c2..369c8ed 100644 --- a/src/web/viewer/mod.rs +++ b/src/web/viewer/mod.rs @@ -1,13 +1,6 @@ //! Web viewer: a native browser UI for the git panel and terminals, served as -//! a second HTTP server independent of the mirror. -//! -//! Unlike the mirror, this does not reflect the TUI's screen. It reads the same -//! `git`/`runtime`/`backend` layers the TUI reads and renders them as DOM, and -//! it owns its own terminals rather than sharing the TUI's. Nothing here -//! touches `App`, `ui`, or `input`, which is what lets the server run headless -//! (`nightcrow serve`) as well as alongside a running TUI. -//! -//! See `docs/web-viewer-plan.md` for the full design and its rationale. +//! a second HTTP server independent of the mirror. Nothing here touches `App`, +//! `ui`, or `input`, which lets the server run headless (`nightcrow serve`). #![allow(dead_code)] // Wired up at step 6; see the module docs above. diff --git a/src/web/viewer/prefs.rs b/src/web/viewer/prefs.rs index 16c3dd4..7f994e2 100644 --- a/src/web/viewer/prefs.rs +++ b/src/web/viewer/prefs.rs @@ -1,15 +1,14 @@ //! Viewer preferences that follow the user rather than the browser. //! //! The accent lives here, not in `localStorage`, because the viewer is reached -//! from several devices — phone, laptop, tablet — and a per-browser copy means -//! picking the colour again on each one. It is stored in `~/.nightcrow/`, next -//! to the workspace file, so the viewer never writes inside a repository it is -//! only reading. +//! from several devices — phone, laptop, tablet — and a per-browser copy +//! means picking the colour again on each one. It is stored in +//! `~/.nightcrow/`, next to the workspace file, so the viewer never writes +//! inside a repository it is only reading. //! //! Deliberately *not* the TUI's setting: `[theme]` and the TUI's per-repo -//! `accent_idx` (`session.rs`) stay untouched, keeping the viewer's separation -//! from the TUI (own port, own cookie, own password) intact. This is the -//! viewer's own preference, shared across the viewer's own clients. +//! `accent_idx` (`session.rs`) stay untouched, keeping the viewer's +//! separation from the TUI (own port, own cookie, own password) intact. use crate::config::Accent; use serde::{Deserialize, Serialize}; diff --git a/src/web/viewer/runtime/mod.rs b/src/web/viewer/runtime/mod.rs index 45641df..c01494c 100644 --- a/src/web/viewer/runtime/mod.rs +++ b/src/web/viewer/runtime/mod.rs @@ -62,7 +62,6 @@ pub struct Subscription { impl Subscription { /// Wait up to `timeout` for an update, returning the newest one pending. - /// /// `None` means nothing arrived in time — the caller should send a /// heartbeat and come back, which is how a dead socket gets noticed. pub fn next_update(&self, timeout: Duration) -> Option { diff --git a/src/web/viewer/server/handlers.rs b/src/web/viewer/server/handlers.rs index 9dd7c6e..ae69e46 100644 --- a/src/web/viewer/server/handlers.rs +++ b/src/web/viewer/server/handlers.rs @@ -95,10 +95,7 @@ pub(super) fn optional_oid( } /// A non-negative count query parameter, defaulting to zero when absent. -/// -/// Deliberately unbounded — see the note beside [`limits::MAX_LOG_PAGE`]. A -/// negative or non-numeric value still fails to parse, so the guard that -/// matters is here. +/// Deliberately unbounded — see the note beside [`limits::MAX_LOG_PAGE`]. pub(super) fn optional_count( head: &crate::web::common::http::RequestHead, name: &str, diff --git a/src/web/viewer/server/mod.rs b/src/web/viewer/server/mod.rs index 7951c15..51d444b 100644 --- a/src/web/viewer/server/mod.rs +++ b/src/web/viewer/server/mod.rs @@ -44,9 +44,8 @@ use std::time::Duration; /// session for one must not authenticate against the other. pub const VIEWER_SESSION_COOKIE: &str = "nightcrow_viewer_session"; -/// How long an idle SSE stream waits before sending a heartbeat. Short enough -/// that a dead socket is noticed promptly, since a write is the only way to -/// find out. +/// How long an idle SSE stream waits before sending a heartbeat. A write is +/// the only way to find out a socket is dead. pub(super) const SSE_HEARTBEAT: Duration = Duration::from_secs(15); /// Read timeout on a terminal socket. Bounds how long queued output waits @@ -68,14 +67,11 @@ pub struct ViewerState { /// (which owns that file). pub(super) persist: bool, /// The TUI's recently-touched settings, served to the client so the file - /// list fades on the same window the TUI does instead of a second, silently - /// diverging default. `auto_follow` is not sent: it moves the TUI's - /// selection, which is a keyboard-driven notion the viewer has no analogue - /// for. + /// list fades on the same window the TUI does. `auto_follow` is not sent: + /// it moves the TUI's selection, which the viewer has no analogue for. pub(super) hot: crate::config::AgentIndicatorConfig, - /// Viewer preferences shared by every client (see `prefs.rs`). Unlike the - /// catalog's `persist`, this is always written: the file is the viewer's - /// own and no TUI owns it. + /// Viewer preferences shared by every client (see `prefs.rs`). Always + /// written: the file is the viewer's own and no TUI owns it. pub(super) prefs: PrefsStore, } diff --git a/src/web/viewer/server/mutations.rs b/src/web/viewer/server/mutations.rs index 25643a7..3947da5 100644 --- a/src/web/viewer/server/mutations.rs +++ b/src/web/viewer/server/mutations.rs @@ -154,7 +154,6 @@ pub(super) fn handle_mkdir(body: &str) -> Vec { } /// Close a repository named by the `repo` id and return the updated set. -/// /// Idempotent from the client's view: an unknown id is a 404, a known one is /// removed and its runtime/terminals stopped by the catalog rebuild. pub(super) fn handle_close_repo(head: &RequestHead, state: &ViewerState) -> Vec { @@ -200,9 +199,7 @@ pub(super) fn lookup_repo(head: &RequestHead, state: &ViewerState) -> Result 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/routes.rs b/src/web/viewer/server/routes.rs index 0daf138..aa7588a 100644 --- a/src/web/viewer/server/routes.rs +++ b/src/web/viewer/server/routes.rs @@ -22,13 +22,8 @@ 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. - // - // The recently-touched window keeps the browser on the TUI's - // schedule; `now_ms` is the clock its `mtime`s were measured on, - // which a phone or laptop viewing remotely need not share; the - // accent is stored server-side so every device shows the same one. + // reaches every device within one interval, and `/api/status` — + // a hot, deduplicated stream — stays free of configuration. let prefs = state.prefs.get(); let bootstrap = ViewerBootstrapDto::new( state.catalog.list(), diff --git a/src/web/viewer/terminal/hub_run.rs b/src/web/viewer/terminal/hub_run.rs index 95f4c5e..6fd626f 100644 --- a/src/web/viewer/terminal/hub_run.rs +++ b/src/web/viewer/terminal/hub_run.rs @@ -151,16 +151,18 @@ impl TerminalHub { } } - /// Reorder the live panes to match `order` and tell every client the result. + /// 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 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. 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/web/viewer/terminal/mod.rs b/src/web/viewer/terminal/mod.rs index 2c8e888..1f9836d 100644 --- a/src/web/viewer/terminal/mod.rs +++ b/src/web/viewer/terminal/mod.rs @@ -84,9 +84,10 @@ impl TerminalHub { /// eligible for broadcasts: one `Created` plus one scrollback `Output` per /// live pane. Done under the state lock so this snapshot cannot interleave /// with the worker's append-and-broadcast (see [`Shared`]); the client - /// therefore receives every pane's history 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. + /// therefore receives every pane's history 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. pub fn connect(self: &Arc) -> TerminalSession { let id = self.next_client_id.fetch_add(1, Ordering::AcqRel); let (tx, rx) = mpsc::sync_channel(CLIENT_QUEUE_DEPTH); diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 2028b62..7f4282b 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -1,28 +1,9 @@ -//! The set of projects open in one nightcrow process. -//! -//! `App` holds everything scoped to a single repository — the git views, the -//! snapshot worker, the cached `git2::Repository`, and the terminal panes -//! rooted at that workdir. `Workspace` is the layer above it: a list of those -//! per-repo states plus the index of the one currently on screen. -//! -//! Anything scoped to a repository lives on `App`; anything process-wide -//! belongs here. Switching tabs is therefore a cheap index change that touches -//! no project state at all, and there is no operation that repoints a project -//! at another repo — closing the tab drops the `App`, and its own types tear -//! the worker and the panes down. That is why nothing here needs a -//! field-by-field reset list to keep in sync. -//! -//! The list may be empty. A bare launch starts that way and closing the last -//! tab returns to it, so `active()` yields an `Option` and the open-repo -//! dialog lives here rather than on a project — with none open, raising it is -//! the only thing left to do. -//! -//! Only the active project is rendered, routed input, and resized, but every -//! project *drains* its queues each tick (see the loop in `main`): the snapshot -//! worker and PTY reader keep producing into unbounded channels whether or not -//! a tab is on screen. Applying a snapshot is active-only, since that runs a -//! full diff. A hidden project's panes hold their last size the same way a -//! hidden pane does. +//! 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. Every project drains its queues each tick whether or not it is on +//! screen; snapshots apply to the active one only. mod repo_input; @@ -33,37 +14,23 @@ use crate::session::{MAX_REMEMBERED, RepoSession, SessionState, WorkspaceState}; use crate::ui::status_view::RepoInput; use crossterm::event::KeyEvent; -/// Upper bound on open projects, matching the F1..F10 switch keys. Panes cap -/// at 8 for the same reason: a tab you cannot reach by key is a tab that is -/// hard to find, so the key space sets the limit rather than memory. +/// Upper bound on open projects, matching the F1..F10 switch keys. A tab you +/// cannot reach by key is hard to find, so the key space sets the limit. pub const MAX_PROJECTS: usize = 10; pub struct Workspace { - /// Open projects in tab order. May be empty — nightcrow starts that way - /// and every tab can be closed, so `active` is only meaningful when this - /// is non-empty. projects: Vec, - /// Index into `projects` of the project being rendered. active: usize, - /// The open-repo dialog. - /// - /// Process-level rather than per-project because it has to work with no - /// project open — which is exactly when it matters most, since opening one - /// is the only thing to do from there. + /// Lives at process level because it must work with no project open. pub repo_input: RepoInput, - /// Notice shown while no project is open. A project owns its own notice - /// (its row also carries its repo identity), so this slot is only read on - /// the empty screen. + /// Notice shown only on the empty screen; a project owns its own. empty_notice: Option, - /// The configured leader chord, kept here so the empty screen can label - /// and recognise it with no project to ask. leader: KeyEvent, - /// Prefix armed on the empty screen. A project has its own flag; the two - /// never both apply, since a key is dispatched to exactly one of them. + /// Prefix armed on the empty screen; a project has its own flag, and a key + /// is dispatched to exactly one of them. empty_prefix_armed: bool, - /// View state for repositories that are not open right now — read at - /// startup, added to as tabs close. Open projects are not in here; their - /// state is read off the `App` when the file is written. + /// View state for repos not currently open. Open projects are read live + /// off the `App` at save time rather than stored here. remembered: Vec, } @@ -86,8 +53,6 @@ impl Workspace { self.remembered = sessions; } - /// The saved view state for `repo`, whether it comes from a closed tab - /// this run or from the file read at startup. pub fn session_for(&self, repo: &str) -> Option<&SessionState> { self.remembered .iter() @@ -95,18 +60,16 @@ impl Workspace { .map(|s| &s.state) } - /// Everything to write out: the open tabs, which was in front, and every - /// repository's view state — the open ones read live, the rest as - /// remembered. Open projects go first so the least-recently-used eviction - /// never drops a tab that is currently on screen. + /// Open projects go first so the least-recently-used eviction never drops + /// a tab that is currently on screen. pub fn to_persisted(&self) -> WorkspaceState { let mut persisted = WorkspaceState { repos: self.projects.iter().map(|p| p.repo_path.clone()).collect(), active: self.active, sessions: Vec::new(), }; - // `remember` inserts at the front, so applying the remembered entries - // first and the open ones last leaves the open ones foremost. + // `remember` inserts at the front, so remembered entries go first and + // open ones last to leave the open ones foremost. for entry in self.remembered.iter().rev() { persisted.remember(&entry.repo, entry.state.clone()); } @@ -116,8 +79,8 @@ impl Workspace { persisted } - /// Matches only the bare leader chord; any extra modifier (Alt/Shift/Super/ - /// Hyper/Meta) is a different chord and passes through. See `App::is_leader_key`. + /// Matches only the bare leader chord; any extra modifier passes through. + /// See `App::is_leader_key`. pub fn is_leader_key(&self, key: KeyEvent) -> bool { key.code == self.leader.code && key.modifiers == self.leader.modifiers } @@ -148,14 +111,13 @@ impl Workspace { } /// The active project and the dialog together, borrowed from disjoint - /// fields in one call so a frame can render both without the borrow - /// checker seeing an overlap. + /// fields so a frame can render both without a borrow-checker conflict. pub fn render_parts(&mut self) -> (Option<&mut App>, &RepoInput) { (self.projects.get_mut(self.active), &self.repo_input) } - /// Raise a notice on the active project, or on the empty screen when - /// there is none. Callers do not have to know which case they are in. + /// Raise a notice on the active project or the empty screen; callers need + /// not know which case they are in. pub fn raise_notice(&mut self, kind: NoticeKind, text: impl Into) { match self.projects.get_mut(self.active) { Some(project) => project.raise_notice(kind, text), @@ -180,8 +142,7 @@ impl Workspace { self.empty_notice.as_ref() } - /// All open projects in tab order, for rendering the tab row and for - /// end-of-run session saves. + /// All open projects in tab order, for the tab row and end-of-run saves. pub fn projects(&self) -> &[App] { &self.projects } @@ -190,35 +151,28 @@ impl Workspace { self.active } - /// All open projects, mutably — for the per-tick polling that every - /// project needs whether or not it is on screen. + /// All open projects, mutably — for per-tick polling of every project. pub fn projects_mut(&mut self) -> &mut [App] { &mut self.projects } - /// Whether another project would exceed `MAX_PROJECTS`. Checked before - /// *building* a project, since construction spawns PTYs and runs the - /// configured startup commands — work that must not happen for a tab that - /// `add` is about to refuse. + /// Checked before *building* a project: construction spawns PTYs and runs + /// startup commands, which must not happen for a tab `add` will refuse. pub fn is_full(&self) -> bool { self.projects.len() >= MAX_PROJECTS } - /// Open `project` in a new tab and make it active. - /// - /// Returns `false` when already at `MAX_PROJECTS`, leaving the workspace - /// untouched; the caller reports that to the user rather than silently - /// dropping the request. + /// Open `project` in a new tab and make it active. Returns `false` at + /// `MAX_PROJECTS`, leaving the workspace untouched. pub fn add(&mut self, project: App) -> bool { if self.projects.len() >= MAX_PROJECTS { return false; } - // Whatever the empty screen was reporting is answered by this: it is - // no longer the empty screen. Left standing, a stale message would - // reappear the moment the last tab was closed again. + // The empty screen's notice is answered by this; left standing it + // would reappear when the last tab closes again. self.empty_notice = None; - // Same reasoning as `switch`: the outgoing project's press can no - // longer be paired, since the release will be routed to the new one. + // The outgoing project's press can no longer be paired — the release + // will route to the new project (same reasoning as `switch`). if let Some(previous) = self.projects.get_mut(self.active) { previous.release_pending_press_in_place(); } @@ -227,21 +181,17 @@ impl Workspace { true } - /// Close the active project and focus its neighbour, leaving the - /// workspace empty when it was the last one. - /// - /// Returns `false` only when there was nothing to close. - /// - /// Dropping the removed `App` is what tears the project down — its - /// `SnapshotChannel` joins the worker thread and its `TerminalState` kills - /// the panes' child processes. There is no field-by-field reset to keep in - /// sync, which is why closing a tab is the way to leave a repo. + /// Close the active project and focus its neighbour, leaving the workspace + /// empty when it was the last. Returns `false` only when nothing was open. + /// Dropping the `App` tears the project down (worker join, child kills); + /// there is no field-by-field reset, which is why closing is the only way + /// to leave a repo. pub fn close_active(&mut self) -> bool { if self.projects.is_empty() { return false; } - // Carry the closing project's view state over, or reopening the repo - // would restore whatever it looked like at the last shutdown instead. + // Carry the closing project's view state, or reopening would restore + // the last-shutdown snapshot instead. let closing = self.projects.remove(self.active); self.remembered.retain(|s| s.repo != closing.repo_path); self.remembered.insert( @@ -251,39 +201,34 @@ impl Workspace { state: closing.session_to_save(), }, ); - // Capped here as well as on the way to disk: a long-lived process that - // opens and closes many repositories would otherwise hold every - // session it had ever seen, tree expansion paths and all, and rescan - // that history on every save. + // Capped here as on disk: a long-lived process opening and closing + // many repos would otherwise rescan the whole history every save. self.remembered.truncate(MAX_REMEMBERED); - // Focus the tab that slid into this slot; closing the rightmost tab - // falls back to its left neighbour. Saturates to 0 when now empty. + // Closing the rightmost tab falls back to its left neighbour; + // saturates to 0 when now empty. self.active = self.active.min(self.projects.len().saturating_sub(1)); true } - /// Focus the project at `index`. Out-of-range indices are ignored so a - /// key or click naming an absent tab is inert rather than a panic. + /// Out-of-range indices are ignored so a key or click naming an absent + /// tab is inert rather than a panic. pub fn switch(&mut self, index: usize) { if index >= self.projects.len() || index == self.active { return; } - // Safe to index: the bound check above proved `active` is in range - // whenever `projects` is non-empty, and an empty list fails it. // A press still awaiting its release can no longer be paired: the - // release will be routed to the newly active project. Deliver it to - // the pane that saw the press instead of dropping the record — that - // program's 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 + // 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. self.projects[self.active].release_pending_press_in_place(); self.active = index; } - /// Index of the project already open on `repo_path`, if any. Lets the - /// caller focus an open project instead of opening a second tab onto the - /// same repo — two tabs sharing a workdir would show identical git state - /// while racing each other's snapshot workers. + /// Lets the caller focus an open project instead of opening a second tab + /// onto the same repo — two tabs sharing a workdir would show identical + /// git state while racing each other's snapshot workers. pub fn index_of_repo(&self, repo_path: &str) -> Option { self.projects.iter().position(|p| p.repo_path == repo_path) } diff --git a/src/workspace/repo_input.rs b/src/workspace/repo_input.rs index 65f18cd..96b7469 100644 --- a/src/workspace/repo_input.rs +++ b/src/workspace/repo_input.rs @@ -1,12 +1,11 @@ use super::Workspace; use crate::app::NoticeKind; -/// The outcome of confirming the repo-path dialog. -/// -/// Opening is not carried out here: it builds a whole new `App`, which needs -/// config the project does not carry, and it has to check whether another tab -/// already holds that repo. So the accepted path is handed back and the -/// caller, which owns the workspace, does the opening. +/// The outcome of confirming the repo-path dialog. Opening is not carried out +/// here: it builds a whole new `App`, which needs config the project does not +/// carry, and it has to check whether another tab already holds that repo. So +/// the accepted path is handed back and the caller, which owns the workspace, +/// does the opening. #[derive(Debug, PartialEq, Eq)] pub enum RepoInputResult { /// Validation failed. The dialog stays open with the text intact and a @@ -21,12 +20,10 @@ pub enum RepoInputResult { const REPO_INPUT_MAX_BYTES: usize = 4096; impl Workspace { - /// Open the dialog that adds a project tab. - /// - /// Prefilled with the active project's repo path: a sibling checkout is - /// the common case, and the shared prefix is most of what the user would - /// retype. With no project open there is nothing to prefill, so the dialog - /// starts empty. + /// Open the dialog that adds a project tab. Prefilled with the active + /// project's repo path: a sibling checkout is the common case, and the + /// shared prefix is most of what the user would retype. With no project + /// open there is nothing to prefill, so the dialog starts empty. pub fn start_repo_input(&mut self) { self.repo_input.buf = self .active() @@ -80,8 +77,8 @@ impl Workspace { } pub fn repo_input_push(&mut self, ch: char) { - // Typing over an untouched prefill replaces it: the dialog opens on the - // current repo path, and a user heading somewhere unrelated would + // Typing over an untouched prefill replaces it: the dialog opens on + // the current repo path, and a user heading somewhere unrelated would // otherwise have to backspace all of it first. A paste lands here one // char at a time, so only its first char clears. if self.repo_input.prefilled { diff --git a/viewer-ui/dist/assets/Markdown-BYxaSXJW.js b/viewer-ui/dist/assets/Markdown-D7nyj2B6.js similarity index 99% rename from viewer-ui/dist/assets/Markdown-BYxaSXJW.js rename to viewer-ui/dist/assets/Markdown-D7nyj2B6.js index 570d789..06c0770 100644 --- a/viewer-ui/dist/assets/Markdown-BYxaSXJW.js +++ b/viewer-ui/dist/assets/Markdown-D7nyj2B6.js @@ -1,4 +1,4 @@ -import{j as vn}from"./index-D8ORZvs2.js";function dr(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}function Lo(e,n){const t={};return(e[e.length-1]===""?[...e,""]:e).join((t.padRight?" ":"")+","+(t.padLeft===!1?"":" ")).trim()}const Po=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Bo=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Fo={};function Hr(e,n){return(Fo.jsx?Bo:Po).test(e)}const zo=/[ \t\n\f\r]/g;function Uo(e){return typeof e=="object"?e.type==="text"?Gr(e.value):!1:Gr(e)}function Gr(e){return e.replace(zo,"")===""}class Vn{constructor(n,t,r){this.normal=t,this.property=n,r&&(this.space=r)}}Vn.prototype.normal={};Vn.prototype.property={};Vn.prototype.space=void 0;function Gi(e,n){const t={},r={};for(const i of e)Object.assign(t,i.property),Object.assign(r,i.normal);return new Vn(t,r,n)}function Jt(e){return e.toLowerCase()}class Me{constructor(n,t){this.attribute=t,this.property=n}}Me.prototype.attribute="";Me.prototype.booleanish=!1;Me.prototype.boolean=!1;Me.prototype.commaOrSpaceSeparated=!1;Me.prototype.commaSeparated=!1;Me.prototype.defined=!1;Me.prototype.mustUseProperty=!1;Me.prototype.number=!1;Me.prototype.overloadedBoolean=!1;Me.prototype.property="";Me.prototype.spaceSeparated=!1;Me.prototype.space=void 0;let $o=0;const Y=_n(),ke=_n(),er=_n(),A=_n(),fe=_n(),bn=_n(),ze=_n();function _n(){return 2**++$o}const nr=Object.freeze(Object.defineProperty({__proto__:null,boolean:Y,booleanish:ke,commaOrSpaceSeparated:ze,commaSeparated:bn,number:A,overloadedBoolean:er,spaceSeparated:fe},Symbol.toStringTag,{value:"Module"})),Mt=Object.keys(nr);class pr extends Me{constructor(n,t,r,i){let o=-1;if(super(n,t),Kr(this,"space",i),typeof r=="number")for(;++o4&&t.slice(0,4)==="data"&&Wo.test(n)){if(n.charAt(4)==="-"){const o=n.slice(5).replace(qr,Zo);r="data"+o.charAt(0).toUpperCase()+o.slice(1)}else{const o=n.slice(4);if(!qr.test(o)){let a=o.replace(qo,Yo);a.charAt(0)!=="-"&&(a="-"+a),n="data"+a}}i=pr}return new i(r,n)}function Yo(e){return"-"+e.toLowerCase()}function Zo(e){return e.charAt(1).toUpperCase()}const Xo=Gi([Ki,Ho,Vi,Yi,Zi],"html"),fr=Gi([Ki,Go,Vi,Yi,Zi],"svg");function Qo(e){return e.join(" ").trim()}var Sn={},Dt,Wr;function jo(){if(Wr)return Dt;Wr=1;var e=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g,n=/\n/g,t=/^\s*/,r=/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/,i=/^:\s*/,o=/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/,a=/^[;\s]*/,s=/^\s+|\s+$/g,c=` +import{j as vn}from"./index-cnyyNRtX.js";function dr(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}function Lo(e,n){const t={};return(e[e.length-1]===""?[...e,""]:e).join((t.padRight?" ":"")+","+(t.padLeft===!1?"":" ")).trim()}const Po=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Bo=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Fo={};function Hr(e,n){return(Fo.jsx?Bo:Po).test(e)}const zo=/[ \t\n\f\r]/g;function Uo(e){return typeof e=="object"?e.type==="text"?Gr(e.value):!1:Gr(e)}function Gr(e){return e.replace(zo,"")===""}class Vn{constructor(n,t,r){this.normal=t,this.property=n,r&&(this.space=r)}}Vn.prototype.normal={};Vn.prototype.property={};Vn.prototype.space=void 0;function Gi(e,n){const t={},r={};for(const i of e)Object.assign(t,i.property),Object.assign(r,i.normal);return new Vn(t,r,n)}function Jt(e){return e.toLowerCase()}class Me{constructor(n,t){this.attribute=t,this.property=n}}Me.prototype.attribute="";Me.prototype.booleanish=!1;Me.prototype.boolean=!1;Me.prototype.commaOrSpaceSeparated=!1;Me.prototype.commaSeparated=!1;Me.prototype.defined=!1;Me.prototype.mustUseProperty=!1;Me.prototype.number=!1;Me.prototype.overloadedBoolean=!1;Me.prototype.property="";Me.prototype.spaceSeparated=!1;Me.prototype.space=void 0;let $o=0;const Y=_n(),ke=_n(),er=_n(),A=_n(),fe=_n(),bn=_n(),ze=_n();function _n(){return 2**++$o}const nr=Object.freeze(Object.defineProperty({__proto__:null,boolean:Y,booleanish:ke,commaOrSpaceSeparated:ze,commaSeparated:bn,number:A,overloadedBoolean:er,spaceSeparated:fe},Symbol.toStringTag,{value:"Module"})),Mt=Object.keys(nr);class pr extends Me{constructor(n,t,r,i){let o=-1;if(super(n,t),Kr(this,"space",i),typeof r=="number")for(;++o4&&t.slice(0,4)==="data"&&Wo.test(n)){if(n.charAt(4)==="-"){const o=n.slice(5).replace(qr,Zo);r="data"+o.charAt(0).toUpperCase()+o.slice(1)}else{const o=n.slice(4);if(!qr.test(o)){let a=o.replace(qo,Yo);a.charAt(0)!=="-"&&(a="-"+a),n="data"+a}}i=pr}return new i(r,n)}function Yo(e){return"-"+e.toLowerCase()}function Zo(e){return e.charAt(1).toUpperCase()}const Xo=Gi([Ki,Ho,Vi,Yi,Zi],"html"),fr=Gi([Ki,Go,Vi,Yi,Zi],"svg");function Qo(e){return e.join(" ").trim()}var Sn={},Dt,Wr;function jo(){if(Wr)return Dt;Wr=1;var e=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g,n=/\n/g,t=/^\s*/,r=/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/,i=/^:\s*/,o=/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/,a=/^[;\s]*/,s=/^\s+|\s+$/g,c=` `,l="/",d="*",u="",f="comment",p="declaration";function g(y,E){if(typeof y!="string")throw new TypeError("First argument must be a string");if(!y)return[];E=E||{};var N=1,x=1;function O(D){var v=D.match(n);v&&(N+=v.length);var Z=D.lastIndexOf(c);x=~Z?D.length-Z:x+D.length}function R(){var D={line:N,column:x};return function(v){return v.position=new k(D),H(),v}}function k(D){this.start=D,this.end={line:N,column:x},this.source=E.source}k.prototype.content=y;function U(D){var v=new Error(E.source+":"+N+":"+x+": "+D);if(v.reason=D,v.filename=E.source,v.line=N,v.column=x,v.source=y,!E.silent)throw v}function $(D){var v=D.exec(y);if(v){var Z=v[0];return O(Z),y=y.slice(Z.length),v}}function H(){$(t)}function w(D){var v;for(D=D||[];v=P();)v!==!1&&D.push(v);return D}function P(){var D=R();if(!(l!=y.charAt(0)||d!=y.charAt(1))){for(var v=2;u!=y.charAt(v)&&(d!=y.charAt(v)||l!=y.charAt(v+1));)++v;if(v+=2,u===y.charAt(v-1))return U("End of comment missing");var Z=y.slice(2,v-2);return x+=2,O(Z),y=y.slice(v),x+=2,D({type:f,comment:Z})}}function B(){var D=R(),v=$(r);if(v){if(P(),!$(i))return U("property missing ':'");var Z=$(o),oe=D({type:p,property:_(v[0].replace(e,u)),value:Z?_(Z[0].replace(e,u)):u});return $(a),oe}}function J(){var D=[];w(D);for(var v;v=B();)v!==!1&&(D.push(v),w(D));return D}return H(),J()}function _(y){return y?y.replace(s,u):u}return Dt=g,Dt}var Vr;function Jo(){if(Vr)return Sn;Vr=1;var e=Sn&&Sn.__importDefault||function(r){return r&&r.__esModule?r:{default:r}};Object.defineProperty(Sn,"__esModule",{value:!0}),Sn.default=t;const n=e(jo());function t(r,i){let o=null;if(!r||typeof r!="string")return o;const a=(0,n.default)(r),s=typeof i=="function";return a.forEach(c=>{if(c.type!=="declaration")return;const{property:l,value:d}=c;s?i(l,d,c):d&&(o=o||{},o[l]=d)}),o}return Sn}var Bn={},Yr;function es(){if(Yr)return Bn;Yr=1,Object.defineProperty(Bn,"__esModule",{value:!0}),Bn.camelCase=void 0;var e=/^--[a-zA-Z0-9_-]+$/,n=/-([a-z])/g,t=/^[^-]+$/,r=/^-(webkit|moz|ms|o|khtml)-/,i=/^-(ms)-/,o=function(l){return!l||t.test(l)||e.test(l)},a=function(l,d){return d.toUpperCase()},s=function(l,d){return"".concat(d,"-")},c=function(l,d){return d===void 0&&(d={}),o(l)?l:(l=l.toLowerCase(),d.reactCompat?l=l.replace(i,s):l=l.replace(r,s),l.replace(n,a))};return Bn.camelCase=c,Bn}var Fn,Zr;function ns(){if(Zr)return Fn;Zr=1;var e=Fn&&Fn.__importDefault||function(i){return i&&i.__esModule?i:{default:i}},n=e(Jo()),t=es();function r(i,o){var a={};return!i||typeof i!="string"||(0,n.default)(i,function(s,c){s&&c&&(a[(0,t.camelCase)(s,o)]=c)}),a}return r.default=r,Fn=r,Fn}var ts=ns();const rs=dr(ts),Xi=Qi("end"),gr=Qi("start");function Qi(e){return n;function n(t){const r=t&&t.position&&t.position[e]||{};if(typeof r.line=="number"&&r.line>0&&typeof r.column=="number"&&r.column>0)return{line:r.line,column:r.column,offset:typeof r.offset=="number"&&r.offset>-1?r.offset:void 0}}}function is(e){const n=gr(e),t=Xi(e);if(n&&t)return{start:n,end:t}}function Hn(e){return!e||typeof e!="object"?"":"position"in e||"type"in e?Xr(e.position):"start"in e||"end"in e?Xr(e):"line"in e||"column"in e?tr(e):""}function tr(e){return Qr(e&&e.line)+":"+Qr(e&&e.column)}function Xr(e){return tr(e&&e.start)+"-"+tr(e&&e.end)}function Qr(e){return e&&typeof e=="number"?e:1}class Ae extends Error{constructor(n,t,r){super(),typeof t=="string"&&(r=t,t=void 0);let i="",o={},a=!1;if(t&&("line"in t&&"column"in t?o={place:t}:"start"in t&&"end"in t?o={place:t}:"type"in t?o={ancestors:[t],place:t.position}:o={...t}),typeof n=="string"?i=n:!o.cause&&n&&(a=!0,i=n.message,o.cause=n),!o.ruleId&&!o.source&&typeof r=="string"){const c=r.indexOf(":");c===-1?o.ruleId=r:(o.source=r.slice(0,c),o.ruleId=r.slice(c+1))}if(!o.place&&o.ancestors&&o.ancestors){const c=o.ancestors[o.ancestors.length-1];c&&(o.place=c.position)}const s=o.place&&"start"in o.place?o.place.start:o.place;this.ancestors=o.ancestors||void 0,this.cause=o.cause||void 0,this.column=s?s.column:void 0,this.fatal=void 0,this.file="",this.message=i,this.line=s?s.line:void 0,this.name=Hn(o.place)||"1:1",this.place=o.place||void 0,this.reason=this.message,this.ruleId=o.ruleId||void 0,this.source=o.source||void 0,this.stack=a&&o.cause&&typeof o.cause.stack=="string"?o.cause.stack:"",this.actual=void 0,this.expected=void 0,this.note=void 0,this.url=void 0}}Ae.prototype.file="";Ae.prototype.name="";Ae.prototype.reason="";Ae.prototype.message="";Ae.prototype.stack="";Ae.prototype.column=void 0;Ae.prototype.line=void 0;Ae.prototype.ancestors=void 0;Ae.prototype.cause=void 0;Ae.prototype.fatal=void 0;Ae.prototype.place=void 0;Ae.prototype.ruleId=void 0;Ae.prototype.source=void 0;const mr={}.hasOwnProperty,as=new Map,os=/[A-Z]/g,ss=new Set(["table","tbody","thead","tfoot","tr"]),ls=new Set(["td","th"]),ji="https://github.com/syntax-tree/hast-util-to-jsx-runtime";function cs(e,n){if(!n||n.Fragment===void 0)throw new TypeError("Expected `Fragment` in options");const t=n.filePath||void 0;let r;if(n.development){if(typeof n.jsxDEV!="function")throw new TypeError("Expected `jsxDEV` in options when `development: true`");r=bs(t,n.jsxDEV)}else{if(typeof n.jsx!="function")throw new TypeError("Expected `jsx` in production options");if(typeof n.jsxs!="function")throw new TypeError("Expected `jsxs` in production options");r=hs(t,n.jsx,n.jsxs)}const i={Fragment:n.Fragment,ancestors:[],components:n.components||{},create:r,elementAttributeNameCase:n.elementAttributeNameCase||"react",evaluater:n.createEvaluater?n.createEvaluater():void 0,filePath:t,ignoreInvalidStyle:n.ignoreInvalidStyle||!1,passKeys:n.passKeys!==!1,passNode:n.passNode||!1,schema:n.space==="svg"?fr:Xo,stylePropertyNameCase:n.stylePropertyNameCase||"dom",tableCellAlignToStyle:n.tableCellAlignToStyle!==!1},o=Ji(i,e,void 0);return o&&typeof o!="string"?o:i.create(e,i.Fragment,{children:o||void 0},void 0)}function Ji(e,n,t){if(n.type==="element")return us(e,n,t);if(n.type==="mdxFlowExpression"||n.type==="mdxTextExpression")return ds(e,n);if(n.type==="mdxJsxFlowElement"||n.type==="mdxJsxTextElement")return fs(e,n,t);if(n.type==="mdxjsEsm")return ps(e,n);if(n.type==="root")return gs(e,n,t);if(n.type==="text")return ms(e,n)}function us(e,n,t){const r=e.schema;let i=r;n.tagName.toLowerCase()==="svg"&&r.space==="html"&&(i=fr,e.schema=i),e.ancestors.push(n);const o=na(e,n.tagName,!1),a=Es(e,n);let s=br(e,n);return ss.has(n.tagName)&&(s=s.filter(function(c){return typeof c=="string"?!Uo(c):!0})),ea(e,a,o,n),hr(a,s),e.ancestors.pop(),e.schema=r,e.create(n,o,a,t)}function ds(e,n){if(n.data&&n.data.estree&&e.evaluater){const r=n.data.estree.body[0];return r.type,e.evaluater.evaluateExpression(r.expression)}qn(e,n.position)}function ps(e,n){if(n.data&&n.data.estree&&e.evaluater)return e.evaluater.evaluateProgram(n.data.estree);qn(e,n.position)}function fs(e,n,t){const r=e.schema;let i=r;n.name==="svg"&&r.space==="html"&&(i=fr,e.schema=i),e.ancestors.push(n);const o=n.name===null?e.Fragment:na(e,n.name,!0),a=_s(e,n),s=br(e,n);return ea(e,a,o,n),hr(a,s),e.ancestors.pop(),e.schema=r,e.create(n,o,a,t)}function gs(e,n,t){const r={};return hr(r,br(e,n)),e.create(n,e.Fragment,r,t)}function ms(e,n){return n.value}function ea(e,n,t,r){typeof t!="string"&&t!==e.Fragment&&e.passNode&&(n.node=r)}function hr(e,n){if(n.length>0){const t=n.length>1?n:n[0];t&&(e.children=t)}}function hs(e,n,t){return r;function r(i,o,a,s){const l=Array.isArray(a.children)?t:n;return s?l(o,a,s):l(o,a)}}function bs(e,n){return t;function t(r,i,o,a){const s=Array.isArray(o.children),c=gr(r);return n(i,o,a,s,{columnNumber:c?c.column-1:void 0,fileName:e,lineNumber:c?c.line:void 0},void 0)}}function Es(e,n){const t={};let r,i;for(i in n.properties)if(i!=="children"&&mr.call(n.properties,i)){const o=ys(e,i,n.properties[i]);if(o){const[a,s]=o;e.tableCellAlignToStyle&&a==="align"&&typeof s=="string"&&ls.has(n.tagName)?r=s:t[a]=s}}if(r){const o=t.style||(t.style={});o[e.stylePropertyNameCase==="css"?"text-align":"textAlign"]=r}return t}function _s(e,n){const t={};for(const r of n.attributes)if(r.type==="mdxJsxExpressionAttribute")if(r.data&&r.data.estree&&e.evaluater){const o=r.data.estree.body[0];o.type;const a=o.expression;a.type;const s=a.properties[0];s.type,Object.assign(t,e.evaluater.evaluateExpression(s.argument))}else qn(e,n.position);else{const i=r.name;let o;if(r.value&&typeof r.value=="object")if(r.value.data&&r.value.data.estree&&e.evaluater){const s=r.value.data.estree.body[0];s.type,o=e.evaluater.evaluateExpression(s.expression)}else qn(e,n.position);else o=r.value===null?!0:r.value;t[i]=o}return t}function br(e,n){const t=[];let r=-1;const i=e.passKeys?new Map:as;for(;++ri?0:i+n:n=n>i?i:n,t=t>0?t:0,r.length<1e4)a=Array.from(r),a.unshift(n,t),e.splice(...a);else for(t&&e.splice(n,t);o0?(Ue(e,e.length,0,n),e):n}const ei={}.hasOwnProperty;function ra(e){const n={};let t=-1;for(;++t13&&t<32||t>126&&t<160||t>55295&&t<57344||t>64975&&t<65008||(t&65535)===65535||(t&65535)===65534||t>1114111?"�":String.fromCodePoint(t)}function qe(e){return e.replace(/[\t\n\r ]+/g," ").replace(/^ | $/g,"").toLowerCase().toUpperCase()}const Ie=pn(/[A-Za-z]/),Te=pn(/[\dA-Za-z]/),Cs=pn(/[#-'*+\--9=?A-Z^-~]/);function gt(e){return e!==null&&(e<32||e===127)}const rr=pn(/\d/),Os=pn(/[\dA-Fa-f]/),Is=pn(/[!-/:-@[-`{-~]/);function q(e){return e!==null&&e<-2}function ge(e){return e!==null&&(e<0||e===32)}function te(e){return e===-2||e===-1||e===32}const yt=pn(new RegExp("\\p{P}|\\p{S}","u")),En=pn(/\s/);function pn(e){return n;function n(t){return t!==null&&t>-1&&e.test(String.fromCharCode(t))}}function In(e){const n=[];let t=-1,r=0,i=0;for(;++t55295&&o<57344){const s=e.charCodeAt(t+1);o<56320&&s>56319&&s<57344?(a=String.fromCharCode(o,s),i=1):a="�"}else a=String.fromCharCode(o);a&&(n.push(e.slice(r,t),encodeURIComponent(a)),r=t+i+1,a=""),i&&(t+=i,i=0)}return n.join("")+e.slice(r)}function ie(e,n,t,r){const i=r?r-1:Number.POSITIVE_INFINITY;let o=0;return a;function a(c){return te(c)?(e.enter(t),s(c)):n(c)}function s(c){return te(c)&&o++a))return;const U=n.events.length;let $=U,H,w;for(;$--;)if(n.events[$][0]==="exit"&&n.events[$][1].type==="chunkFlow"){if(H){w=n.events[$][1].end;break}H=!0}for(E(r),k=U;kx;){const R=t[O];n.containerState=R[1],R[0].exit.call(n,e)}t.length=x}function N(){i.write([null]),o=void 0,i=void 0,n.containerState._closeFlow=void 0}}function Ps(e,n,t){return ie(e,e.attempt(this.parser.constructs.document,n,t),"linePrefix",this.parser.constructs.disable.null.includes("codeIndented")?void 0:4)}function Cn(e){if(e===null||ge(e)||En(e))return 1;if(yt(e))return 2}function kt(e,n,t){const r=[];let i=-1;for(;++i1&&e[t][1].end.offset-e[t][1].start.offset>1?2:1;const u={...e[r][1].end},f={...e[t][1].start};ti(u,-c),ti(f,c),a={type:c>1?"strongSequence":"emphasisSequence",start:u,end:{...e[r][1].end}},s={type:c>1?"strongSequence":"emphasisSequence",start:{...e[t][1].start},end:f},o={type:c>1?"strongText":"emphasisText",start:{...e[r][1].end},end:{...e[t][1].start}},i={type:c>1?"strong":"emphasis",start:{...a.start},end:{...s.end}},e[r][1].end={...a.start},e[t][1].start={...s.end},l=[],e[r][1].end.offset-e[r][1].start.offset&&(l=He(l,[["enter",e[r][1],n],["exit",e[r][1],n]])),l=He(l,[["enter",i,n],["enter",a,n],["exit",a,n],["enter",o,n]]),l=He(l,kt(n.parser.constructs.insideSpan.null,e.slice(r+1,t),n)),l=He(l,[["exit",o,n],["enter",s,n],["exit",s,n],["exit",i,n]]),e[t][1].end.offset-e[t][1].start.offset?(d=2,l=He(l,[["enter",e[t][1],n],["exit",e[t][1],n]])):d=0,Ue(e,r-1,t-r+3,l),t=r+l.length-d-2;break}}for(t=-1;++t0&&te(k)?ie(e,N,"linePrefix",o+1)(k):N(k)}function N(k){return k===null||q(k)?e.check(ri,_,O)(k):(e.enter("codeFlowValue"),x(k))}function x(k){return k===null||q(k)?(e.exit("codeFlowValue"),N(k)):(e.consume(k),x)}function O(k){return e.exit("codeFenced"),n(k)}function R(k,U,$){let H=0;return w;function w(v){return k.enter("lineEnding"),k.consume(v),k.exit("lineEnding"),P}function P(v){return k.enter("codeFencedFence"),te(v)?ie(k,B,"linePrefix",r.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(v):B(v)}function B(v){return v===s?(k.enter("codeFencedFenceSequence"),J(v)):$(v)}function J(v){return v===s?(H++,k.consume(v),J):H>=a?(k.exit("codeFencedFenceSequence"),te(v)?ie(k,D,"whitespace")(v):D(v)):$(v)}function D(v){return v===null||q(v)?(k.exit("codeFencedFence"),U(v)):$(v)}}}function Ys(e,n,t){const r=this;return i;function i(a){return a===null?t(a):(e.enter("lineEnding"),e.consume(a),e.exit("lineEnding"),o)}function o(a){return r.parser.lazy[r.now().line]?t(a):n(a)}}const Pt={name:"codeIndented",tokenize:Xs},Zs={partial:!0,tokenize:Qs};function Xs(e,n,t){const r=this;return i;function i(l){return e.enter("codeIndented"),ie(e,o,"linePrefix",5)(l)}function o(l){const d=r.events[r.events.length-1];return d&&d[1].type==="linePrefix"&&d[2].sliceSerialize(d[1],!0).length>=4?a(l):t(l)}function a(l){return l===null?c(l):q(l)?e.attempt(Zs,a,c)(l):(e.enter("codeFlowValue"),s(l))}function s(l){return l===null||q(l)?(e.exit("codeFlowValue"),a(l)):(e.consume(l),s)}function c(l){return e.exit("codeIndented"),n(l)}}function Qs(e,n,t){const r=this;return i;function i(a){return r.parser.lazy[r.now().line]?t(a):q(a)?(e.enter("lineEnding"),e.consume(a),e.exit("lineEnding"),i):ie(e,o,"linePrefix",5)(a)}function o(a){const s=r.events[r.events.length-1];return s&&s[1].type==="linePrefix"&&s[2].sliceSerialize(s[1],!0).length>=4?n(a):q(a)?i(a):t(a)}}const js={name:"codeText",previous:el,resolve:Js,tokenize:nl};function Js(e){let n=e.length-4,t=3,r,i;if((e[t][1].type==="lineEnding"||e[t][1].type==="space")&&(e[n][1].type==="lineEnding"||e[n][1].type==="space")){for(r=t;++r=this.left.length+this.right.length)throw new RangeError("Cannot access index `"+n+"` in a splice buffer of size `"+(this.left.length+this.right.length)+"`");return nthis.left.length?this.right.slice(this.right.length-r+this.left.length,this.right.length-n+this.left.length).reverse():this.left.slice(n).concat(this.right.slice(this.right.length-r+this.left.length).reverse())}splice(n,t,r){const i=t||0;this.setCursor(Math.trunc(n));const o=this.right.splice(this.right.length-i,Number.POSITIVE_INFINITY);return r&&zn(this.left,r),o.reverse()}pop(){return this.setCursor(Number.POSITIVE_INFINITY),this.left.pop()}push(n){this.setCursor(Number.POSITIVE_INFINITY),this.left.push(n)}pushMany(n){this.setCursor(Number.POSITIVE_INFINITY),zn(this.left,n)}unshift(n){this.setCursor(0),this.right.push(n)}unshiftMany(n){this.setCursor(0),zn(this.right,n.reverse())}setCursor(n){if(!(n===this.left.length||n>this.left.length&&this.right.length===0||n<0&&this.left.length===0))if(n=4?n(a):e.interrupt(r.parser.constructs.flow,t,n)(a)}}function ca(e,n,t,r,i,o,a,s,c){const l=c||Number.POSITIVE_INFINITY;let d=0;return u;function u(E){return E===60?(e.enter(r),e.enter(i),e.enter(o),e.consume(E),e.exit(o),f):E===null||E===32||E===41||gt(E)?t(E):(e.enter(r),e.enter(a),e.enter(s),e.enter("chunkString",{contentType:"string"}),_(E))}function f(E){return E===62?(e.enter(o),e.consume(E),e.exit(o),e.exit(i),e.exit(r),n):(e.enter(s),e.enter("chunkString",{contentType:"string"}),p(E))}function p(E){return E===62?(e.exit("chunkString"),e.exit(s),f(E)):E===null||E===60||q(E)?t(E):(e.consume(E),E===92?g:p)}function g(E){return E===60||E===62||E===92?(e.consume(E),p):p(E)}function _(E){return!d&&(E===null||E===41||ge(E))?(e.exit("chunkString"),e.exit(s),e.exit(a),e.exit(r),n(E)):d999||p===null||p===91||p===93&&!c||p===94&&!s&&"_hiddenFootnoteSupport"in a.parser.constructs?t(p):p===93?(e.exit(o),e.enter(i),e.consume(p),e.exit(i),e.exit(r),n):q(p)?(e.enter("lineEnding"),e.consume(p),e.exit("lineEnding"),d):(e.enter("chunkString",{contentType:"string"}),u(p))}function u(p){return p===null||p===91||p===93||q(p)||s++>999?(e.exit("chunkString"),d(p)):(e.consume(p),c||(c=!te(p)),p===92?f:u)}function f(p){return p===91||p===92||p===93?(e.consume(p),s++,u):u(p)}}function da(e,n,t,r,i,o){let a;return s;function s(f){return f===34||f===39||f===40?(e.enter(r),e.enter(i),e.consume(f),e.exit(i),a=f===40?41:f,c):t(f)}function c(f){return f===a?(e.enter(i),e.consume(f),e.exit(i),e.exit(r),n):(e.enter(o),l(f))}function l(f){return f===a?(e.exit(o),c(a)):f===null?t(f):q(f)?(e.enter("lineEnding"),e.consume(f),e.exit("lineEnding"),ie(e,l,"linePrefix")):(e.enter("chunkString",{contentType:"string"}),d(f))}function d(f){return f===a||f===null||q(f)?(e.exit("chunkString"),l(f)):(e.consume(f),f===92?u:d)}function u(f){return f===a||f===92?(e.consume(f),d):d(f)}}function Gn(e,n){let t;return r;function r(i){return q(i)?(e.enter("lineEnding"),e.consume(i),e.exit("lineEnding"),t=!0,r):te(i)?ie(e,r,t?"linePrefix":"lineSuffix")(i):n(i)}}const cl={name:"definition",tokenize:dl},ul={partial:!0,tokenize:pl};function dl(e,n,t){const r=this;let i;return o;function o(p){return e.enter("definition"),a(p)}function a(p){return ua.call(r,e,s,t,"definitionLabel","definitionLabelMarker","definitionLabelString")(p)}function s(p){return i=qe(r.sliceSerialize(r.events[r.events.length-1][1]).slice(1,-1)),p===58?(e.enter("definitionMarker"),e.consume(p),e.exit("definitionMarker"),c):t(p)}function c(p){return ge(p)?Gn(e,l)(p):l(p)}function l(p){return ca(e,d,t,"definitionDestination","definitionDestinationLiteral","definitionDestinationLiteralMarker","definitionDestinationRaw","definitionDestinationString")(p)}function d(p){return e.attempt(ul,u,u)(p)}function u(p){return te(p)?ie(e,f,"whitespace")(p):f(p)}function f(p){return p===null||q(p)?(e.exit("definition"),r.parser.defined.push(i),n(p)):t(p)}}function pl(e,n,t){return r;function r(s){return ge(s)?Gn(e,i)(s):t(s)}function i(s){return da(e,o,t,"definitionTitle","definitionTitleMarker","definitionTitleString")(s)}function o(s){return te(s)?ie(e,a,"whitespace")(s):a(s)}function a(s){return s===null||q(s)?n(s):t(s)}}const fl={name:"hardBreakEscape",tokenize:gl};function gl(e,n,t){return r;function r(o){return e.enter("hardBreakEscape"),e.consume(o),i}function i(o){return q(o)?(e.exit("hardBreakEscape"),n(o)):t(o)}}const ml={name:"headingAtx",resolve:hl,tokenize:bl};function hl(e,n){let t=e.length-2,r=3,i,o;return e[r][1].type==="whitespace"&&(r+=2),t-2>r&&e[t][1].type==="whitespace"&&(t-=2),e[t][1].type==="atxHeadingSequence"&&(r===t-1||t-4>r&&e[t-2][1].type==="whitespace")&&(t-=r+1===t?2:4),t>r&&(i={type:"atxHeadingText",start:e[r][1].start,end:e[t][1].end},o={type:"chunkText",start:e[r][1].start,end:e[t][1].end,contentType:"text"},Ue(e,r,t-r+1,[["enter",i,n],["enter",o,n],["exit",o,n],["exit",i,n]])),e}function bl(e,n,t){let r=0;return i;function i(d){return e.enter("atxHeading"),o(d)}function o(d){return e.enter("atxHeadingSequence"),a(d)}function a(d){return d===35&&r++<6?(e.consume(d),a):d===null||ge(d)?(e.exit("atxHeadingSequence"),s(d)):t(d)}function s(d){return d===35?(e.enter("atxHeadingSequence"),c(d)):d===null||q(d)?(e.exit("atxHeading"),n(d)):te(d)?ie(e,s,"whitespace")(d):(e.enter("atxHeadingText"),l(d))}function c(d){return d===35?(e.consume(d),c):(e.exit("atxHeadingSequence"),s(d))}function l(d){return d===null||d===35||ge(d)?(e.exit("atxHeadingText"),s(d)):(e.consume(d),l)}}const El=["address","article","aside","base","basefont","blockquote","body","caption","center","col","colgroup","dd","details","dialog","dir","div","dl","dt","fieldset","figcaption","figure","footer","form","frame","frameset","h1","h2","h3","h4","h5","h6","head","header","hr","html","iframe","legend","li","link","main","menu","menuitem","nav","noframes","ol","optgroup","option","p","param","search","section","summary","table","tbody","td","tfoot","th","thead","title","tr","track","ul"],ai=["pre","script","style","textarea"],_l={concrete:!0,name:"htmlFlow",resolveTo:wl,tokenize:xl},yl={partial:!0,tokenize:Nl},kl={partial:!0,tokenize:Sl};function wl(e){let n=e.length;for(;n--&&!(e[n][0]==="enter"&&e[n][1].type==="htmlFlow"););return n>1&&e[n-2][1].type==="linePrefix"&&(e[n][1].start=e[n-2][1].start,e[n+1][1].start=e[n-2][1].start,e.splice(n-2,2)),e}function xl(e,n,t){const r=this;let i,o,a,s,c;return l;function l(b){return d(b)}function d(b){return e.enter("htmlFlow"),e.enter("htmlFlowData"),e.consume(b),u}function u(b){return b===33?(e.consume(b),f):b===47?(e.consume(b),o=!0,_):b===63?(e.consume(b),i=3,r.interrupt?n:m):Ie(b)?(e.consume(b),a=String.fromCharCode(b),y):t(b)}function f(b){return b===45?(e.consume(b),i=2,p):b===91?(e.consume(b),i=5,s=0,g):Ie(b)?(e.consume(b),i=4,r.interrupt?n:m):t(b)}function p(b){return b===45?(e.consume(b),r.interrupt?n:m):t(b)}function g(b){const ve="CDATA[";return b===ve.charCodeAt(s++)?(e.consume(b),s===ve.length?r.interrupt?n:B:g):t(b)}function _(b){return Ie(b)?(e.consume(b),a=String.fromCharCode(b),y):t(b)}function y(b){if(b===null||b===47||b===62||ge(b)){const ve=b===47,$e=a.toLowerCase();return!ve&&!o&&ai.includes($e)?(i=1,r.interrupt?n(b):B(b)):El.includes(a.toLowerCase())?(i=6,ve?(e.consume(b),E):r.interrupt?n(b):B(b)):(i=7,r.interrupt&&!r.parser.lazy[r.now().line]?t(b):o?N(b):x(b))}return b===45||Te(b)?(e.consume(b),a+=String.fromCharCode(b),y):t(b)}function E(b){return b===62?(e.consume(b),r.interrupt?n:B):t(b)}function N(b){return te(b)?(e.consume(b),N):w(b)}function x(b){return b===47?(e.consume(b),w):b===58||b===95||Ie(b)?(e.consume(b),O):te(b)?(e.consume(b),x):w(b)}function O(b){return b===45||b===46||b===58||b===95||Te(b)?(e.consume(b),O):R(b)}function R(b){return b===61?(e.consume(b),k):te(b)?(e.consume(b),R):x(b)}function k(b){return b===null||b===60||b===61||b===62||b===96?t(b):b===34||b===39?(e.consume(b),c=b,U):te(b)?(e.consume(b),k):$(b)}function U(b){return b===c?(e.consume(b),c=null,H):b===null||q(b)?t(b):(e.consume(b),U)}function $(b){return b===null||b===34||b===39||b===47||b===60||b===61||b===62||b===96||ge(b)?R(b):(e.consume(b),$)}function H(b){return b===47||b===62||te(b)?x(b):t(b)}function w(b){return b===62?(e.consume(b),P):t(b)}function P(b){return b===null||q(b)?B(b):te(b)?(e.consume(b),P):t(b)}function B(b){return b===45&&i===2?(e.consume(b),Z):b===60&&i===1?(e.consume(b),oe):b===62&&i===4?(e.consume(b),ce):b===63&&i===3?(e.consume(b),m):b===93&&i===5?(e.consume(b),de):q(b)&&(i===6||i===7)?(e.exit("htmlFlowData"),e.check(yl,pe,J)(b)):b===null||q(b)?(e.exit("htmlFlowData"),J(b)):(e.consume(b),B)}function J(b){return e.check(kl,D,pe)(b)}function D(b){return e.enter("lineEnding"),e.consume(b),e.exit("lineEnding"),v}function v(b){return b===null||q(b)?J(b):(e.enter("htmlFlowData"),B(b))}function Z(b){return b===45?(e.consume(b),m):B(b)}function oe(b){return b===47?(e.consume(b),a="",X):B(b)}function X(b){if(b===62){const ve=a.toLowerCase();return ai.includes(ve)?(e.consume(b),ce):B(b)}return Ie(b)&&a.length<8?(e.consume(b),a+=String.fromCharCode(b),X):B(b)}function de(b){return b===93?(e.consume(b),m):B(b)}function m(b){return b===62?(e.consume(b),ce):b===45&&i===2?(e.consume(b),m):B(b)}function ce(b){return b===null||q(b)?(e.exit("htmlFlowData"),pe(b)):(e.consume(b),ce)}function pe(b){return e.exit("htmlFlow"),n(b)}}function Sl(e,n,t){const r=this;return i;function i(a){return q(a)?(e.enter("lineEnding"),e.consume(a),e.exit("lineEnding"),o):t(a)}function o(a){return r.parser.lazy[r.now().line]?t(a):n(a)}}function Nl(e,n,t){return r;function r(i){return e.enter("lineEnding"),e.consume(i),e.exit("lineEnding"),e.attempt(Yn,n,t)}}const Tl={name:"htmlText",tokenize:Al};function Al(e,n,t){const r=this;let i,o,a;return s;function s(m){return e.enter("htmlText"),e.enter("htmlTextData"),e.consume(m),c}function c(m){return m===33?(e.consume(m),l):m===47?(e.consume(m),R):m===63?(e.consume(m),x):Ie(m)?(e.consume(m),$):t(m)}function l(m){return m===45?(e.consume(m),d):m===91?(e.consume(m),o=0,g):Ie(m)?(e.consume(m),N):t(m)}function d(m){return m===45?(e.consume(m),p):t(m)}function u(m){return m===null?t(m):m===45?(e.consume(m),f):q(m)?(a=u,oe(m)):(e.consume(m),u)}function f(m){return m===45?(e.consume(m),p):u(m)}function p(m){return m===62?Z(m):m===45?f(m):u(m)}function g(m){const ce="CDATA[";return m===ce.charCodeAt(o++)?(e.consume(m),o===ce.length?_:g):t(m)}function _(m){return m===null?t(m):m===93?(e.consume(m),y):q(m)?(a=_,oe(m)):(e.consume(m),_)}function y(m){return m===93?(e.consume(m),E):_(m)}function E(m){return m===62?Z(m):m===93?(e.consume(m),E):_(m)}function N(m){return m===null||m===62?Z(m):q(m)?(a=N,oe(m)):(e.consume(m),N)}function x(m){return m===null?t(m):m===63?(e.consume(m),O):q(m)?(a=x,oe(m)):(e.consume(m),x)}function O(m){return m===62?Z(m):x(m)}function R(m){return Ie(m)?(e.consume(m),k):t(m)}function k(m){return m===45||Te(m)?(e.consume(m),k):U(m)}function U(m){return q(m)?(a=U,oe(m)):te(m)?(e.consume(m),U):Z(m)}function $(m){return m===45||Te(m)?(e.consume(m),$):m===47||m===62||ge(m)?H(m):t(m)}function H(m){return m===47?(e.consume(m),Z):m===58||m===95||Ie(m)?(e.consume(m),w):q(m)?(a=H,oe(m)):te(m)?(e.consume(m),H):Z(m)}function w(m){return m===45||m===46||m===58||m===95||Te(m)?(e.consume(m),w):P(m)}function P(m){return m===61?(e.consume(m),B):q(m)?(a=P,oe(m)):te(m)?(e.consume(m),P):H(m)}function B(m){return m===null||m===60||m===61||m===62||m===96?t(m):m===34||m===39?(e.consume(m),i=m,J):q(m)?(a=B,oe(m)):te(m)?(e.consume(m),B):(e.consume(m),D)}function J(m){return m===i?(e.consume(m),i=void 0,v):m===null?t(m):q(m)?(a=J,oe(m)):(e.consume(m),J)}function D(m){return m===null||m===34||m===39||m===60||m===61||m===96?t(m):m===47||m===62||ge(m)?H(m):(e.consume(m),D)}function v(m){return m===47||m===62||ge(m)?H(m):t(m)}function Z(m){return m===62?(e.consume(m),e.exit("htmlTextData"),e.exit("htmlText"),n):t(m)}function oe(m){return e.exit("htmlTextData"),e.enter("lineEnding"),e.consume(m),e.exit("lineEnding"),X}function X(m){return te(m)?ie(e,de,"linePrefix",r.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(m):de(m)}function de(m){return e.enter("htmlTextData"),a(m)}}const yr={name:"labelEnd",resolveAll:Il,resolveTo:Rl,tokenize:Ml},vl={tokenize:Dl},Cl={tokenize:Ll},Ol={tokenize:Pl};function Il(e){let n=-1;const t=[];for(;++n=3&&(l===null||q(l))?(e.exit("thematicBreak"),n(l)):t(l)}function c(l){return l===i?(e.consume(l),r++,c):(e.exit("thematicBreakSequence"),te(l)?ie(e,s,"whitespace")(l):s(l))}}const Re={continuation:{tokenize:Wl},exit:Yl,name:"list",tokenize:ql},Gl={partial:!0,tokenize:Zl},Kl={partial:!0,tokenize:Vl};function ql(e,n,t){const r=this,i=r.events[r.events.length-1];let o=i&&i[1].type==="linePrefix"?i[2].sliceSerialize(i[1],!0).length:0,a=0;return s;function s(p){const g=r.containerState.type||(p===42||p===43||p===45?"listUnordered":"listOrdered");if(g==="listUnordered"?!r.containerState.marker||p===r.containerState.marker:rr(p)){if(r.containerState.type||(r.containerState.type=g,e.enter(g,{_container:!0})),g==="listUnordered")return e.enter("listItemPrefix"),p===42||p===45?e.check(ft,t,l)(p):l(p);if(!r.interrupt||p===49)return e.enter("listItemPrefix"),e.enter("listItemValue"),c(p)}return t(p)}function c(p){return rr(p)&&++a<10?(e.consume(p),c):(!r.interrupt||a<2)&&(r.containerState.marker?p===r.containerState.marker:p===41||p===46)?(e.exit("listItemValue"),l(p)):t(p)}function l(p){return e.enter("listItemMarker"),e.consume(p),e.exit("listItemMarker"),r.containerState.marker=r.containerState.marker||p,e.check(Yn,r.interrupt?t:d,e.attempt(Gl,f,u))}function d(p){return r.containerState.initialBlankLine=!0,o++,f(p)}function u(p){return te(p)?(e.enter("listItemPrefixWhitespace"),e.consume(p),e.exit("listItemPrefixWhitespace"),f):t(p)}function f(p){return r.containerState.size=o+r.sliceSerialize(e.exit("listItemPrefix"),!0).length,n(p)}}function Wl(e,n,t){const r=this;return r.containerState._closeFlow=void 0,e.check(Yn,i,o);function i(s){return r.containerState.furtherBlankLines=r.containerState.furtherBlankLines||r.containerState.initialBlankLine,ie(e,n,"listItemIndent",r.containerState.size+1)(s)}function o(s){return r.containerState.furtherBlankLines||!te(s)?(r.containerState.furtherBlankLines=void 0,r.containerState.initialBlankLine=void 0,a(s)):(r.containerState.furtherBlankLines=void 0,r.containerState.initialBlankLine=void 0,e.attempt(Kl,n,a)(s))}function a(s){return r.containerState._closeFlow=!0,r.interrupt=void 0,ie(e,e.attempt(Re,n,t),"linePrefix",r.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(s)}}function Vl(e,n,t){const r=this;return ie(e,i,"listItemIndent",r.containerState.size+1);function i(o){const a=r.events[r.events.length-1];return a&&a[1].type==="listItemIndent"&&a[2].sliceSerialize(a[1],!0).length===r.containerState.size?n(o):t(o)}}function Yl(e){e.exit(this.containerState.type)}function Zl(e,n,t){const r=this;return ie(e,i,"listItemPrefixWhitespace",r.parser.constructs.disable.null.includes("codeIndented")?void 0:5);function i(o){const a=r.events[r.events.length-1];return!te(o)&&a&&a[1].type==="listItemPrefixWhitespace"?n(o):t(o)}}const oi={name:"setextUnderline",resolveTo:Xl,tokenize:Ql};function Xl(e,n){let t=e.length,r,i,o;for(;t--;)if(e[t][0]==="enter"){if(e[t][1].type==="content"){r=t;break}e[t][1].type==="paragraph"&&(i=t)}else e[t][1].type==="content"&&e.splice(t,1),!o&&e[t][1].type==="definition"&&(o=t);const a={type:"setextHeading",start:{...e[r][1].start},end:{...e[e.length-1][1].end}};return e[i][1].type="setextHeadingText",o?(e.splice(i,0,["enter",a,n]),e.splice(o+1,0,["exit",e[r][1],n]),e[r][1].end={...e[o][1].end}):e[r][1]=a,e.push(["exit",a,n]),e}function Ql(e,n,t){const r=this;let i;return o;function o(l){let d=r.events.length,u;for(;d--;)if(r.events[d][1].type!=="lineEnding"&&r.events[d][1].type!=="linePrefix"&&r.events[d][1].type!=="content"){u=r.events[d][1].type==="paragraph";break}return!r.parser.lazy[r.now().line]&&(r.interrupt||u)?(e.enter("setextHeadingLine"),i=l,a(l)):t(l)}function a(l){return e.enter("setextHeadingLineSequence"),s(l)}function s(l){return l===i?(e.consume(l),s):(e.exit("setextHeadingLineSequence"),te(l)?ie(e,c,"lineSuffix")(l):c(l))}function c(l){return l===null||q(l)?(e.exit("setextHeadingLine"),n(l)):t(l)}}const jl={tokenize:Jl};function Jl(e){const n=this,t=e.attempt(Yn,r,e.attempt(this.parser.constructs.flowInitial,i,ie(e,e.attempt(this.parser.constructs.flow,i,e.attempt(il,i)),"linePrefix")));return t;function r(o){if(o===null){e.consume(o);return}return e.enter("lineEndingBlank"),e.consume(o),e.exit("lineEndingBlank"),n.currentConstruct=void 0,t}function i(o){if(o===null){e.consume(o);return}return e.enter("lineEnding"),e.consume(o),e.exit("lineEnding"),n.currentConstruct=void 0,t}}const ec={resolveAll:fa()},nc=pa("string"),tc=pa("text");function pa(e){return{resolveAll:fa(e==="text"?rc:void 0),tokenize:n};function n(t){const r=this,i=this.parser.constructs[e],o=t.attempt(i,a,s);return a;function a(d){return l(d)?o(d):s(d)}function s(d){if(d===null){t.consume(d);return}return t.enter("data"),t.consume(d),c}function c(d){return l(d)?(t.exit("data"),o(d)):(t.consume(d),c)}function l(d){if(d===null)return!0;const u=i[d];let f=-1;if(u)for(;++f-1){const s=a[0];typeof s=="string"?a[0]=s.slice(r):a.shift()}o>0&&a.push(e[i].slice(0,o))}return a}function hc(e,n){let t=-1;const r=[];let i;for(;++t0){const Pe=W.tokenStack[W.tokenStack.length-1];(Pe[1]||li).call(W,void 0,Pe[0])}for(I.position={start:dn(S.length>0?S[0][1].start:{line:1,column:1,offset:0}),end:dn(S.length>0?S[S.length-2][1].end:{line:1,column:1,offset:0})},se=-1;++seq*x/Ae(q,x),1),j=[];return G.forEach((q,x)=>{const r=V/q;for(let o=0;o=4352&&N<=4447||N>=11904&&N<=12350||N>=12353&&N<=13311||N>=13312&&N<=19903||N>=19968&&N<=40959||N>=40960&&N<=42191||N>=44032&&N<=55203||N>=63744&&N<=64255||N>=65072&&N<=65103||N>=65280&&N<=65376||N>=65504&&N<=65510||N>=127744&&N<=129791||N>=131072&&N<=262141}function Me(N,X){let G=0;for(const q of N)G+=Se(q.codePointAt(0)??0)?2:1;if(G<=X)return N;let V=0,j="";for(const q of N){const x=Se(q.codePointAt(0)??0)?2:1;if(V+x>X-1)break;j+=q,V+=x}return`${j}…`}function Oe(N,X,G){if(X===G)return N;const V=N.indexOf(X),j=N.indexOf(G);if(V===-1||j===-1)return N;const q=N.filter(o=>o!==X),x=q.indexOf(G),r=V1,f=()=>{j.current=null,q.current=null,x.current=null,r.current=!1,l(null),n(null)};return{draggingPane:o,dragOverPane:_,reorderable:d,endPaneDrag:f,onPaneDragStart:(s,e)=>{s.target.closest("button")||(G(e),!(s.button!==0||!d)&&(j.current=e,q.current={x:s.clientX,y:s.clientY},r.current=!1,s.currentTarget.setPointerCapture(s.pointerId)))},onPaneDragMove:s=>{const e=j.current,i=q.current;if(e===null||i===null||!r.current&&Math.hypot(s.clientX-i.x,s.clientY-i.y){const s=j.current,e=x.current;s!==null&&r.current&&e!==null&&V(Oe(N,s,e)),f()}}}function He({repo:N,socketRef:X,viewsRef:G,pendingRef:V,sentSizesRef:j,lastActiveByRepoRef:q,expectCreateRef:x,setPanes:r,setActive:o,setZoomed:l,setTitles:_}){Y.useEffect(()=>{let n=!1,d;const f=()=>{G.current.forEach(h=>h.term.dispose()),G.current.clear(),V.current.clear(),j.current.clear()},g=()=>{r([]),o(null),l(null),_({}),f();const h=location.protocol==="https:"?"wss:":"ws:",t=new WebSocket(`${h}//${location.host}/ws/term?repo=${encodeURIComponent(N)}`);t.binaryType="arraybuffer",X.current=t,t.onmessage=s=>{if(typeof s.data=="string"){const u=JSON.parse(s.data);if(u.type==="created"){const p=u.pane;r(c=>[...c,p]),x.current>0?(x.current-=1,o(p),q.current.set(N,p)):q.current.get(N)===p&&o(p)}else u.type==="exited"?(r(p=>p.filter(c=>c!==u.pane)),o(p=>p===u.pane?null:p),l(p=>p===u.pane?null:p),V.current.delete(u.pane),j.current.delete(u.pane),_(p=>{if(!(u.pane in p))return p;const c={...p};return delete c[u.pane],c})):u.type==="reordered"?r(p=>Pe(p,u.order)):u.type==="error"&&(x.current=0,ke.error(u.message));return}const e=new Uint8Array(s.data);if(e.length<4)return;const i=new DataView(e.buffer).getUint32(0,!0),a=e.subarray(4),v=G.current.get(i);if(v)v.term.write(a);else{const u=V.current.get(i)??[];u.push(a),V.current.set(i,u)}},t.onclose=()=>{n||(d=setTimeout(g,1e3))}};return g(),()=>{n=!0,d&&clearTimeout(d),X.current?.close(),f()}},[N])}var ge={exports:{}},Ce;function Fe(){return Ce||(Ce=1,(function(N,X){(function(G,V){N.exports=V()})(globalThis,(()=>(()=>{var G={4567:function(x,r,o){var l=this&&this.__decorate||function(e,i,a,v){var u,p=arguments.length,c=p<3?i:v===null?v=Object.getOwnPropertyDescriptor(i,a):v;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")c=Reflect.decorate(e,i,a,v);else for(var S=e.length-1;S>=0;S--)(u=e[S])&&(c=(p<3?u(c):p>3?u(i,a,c):u(i,a))||c);return p>3&&c&&Object.defineProperty(i,a,c),c},_=this&&this.__param||function(e,i){return function(a,v){i(a,v,e)}};Object.defineProperty(r,"__esModule",{value:!0}),r.AccessibilityManager=void 0;const n=o(9042),d=o(9924),f=o(844),g=o(4725),h=o(2585),t=o(3656);let s=r.AccessibilityManager=class extends f.Disposable{constructor(e,i,a,v){super(),this._terminal=e,this._coreBrowserService=a,this._renderService=v,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let u=0;uthis._handleBoundaryFocus(u,0),this._bottomBoundaryFocusListener=u=>this._handleBoundaryFocus(u,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new d.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((u=>this._handleResize(u.rows)))),this.register(this._terminal.onRender((u=>this._refreshRows(u.start,u.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((u=>this._handleChar(u)))),this.register(this._terminal.onLineFeed((()=>this._handleChar(` +import{r as Y,t as ke,j as te,M as we,X as De,P as Le}from"./index-cnyyNRtX.js";const Re=20,xe=4;function Ae(N,X){for(;X;)[N,X]=[X,N%X];return N}function Be(N,X){switch(N){case 1:return[1];case 2:return X?[2]:[1,1];case 3:return[2,1];case 4:return[2,2];case 5:return[3,2];case 6:return[3,3];case 7:return[4,3];default:return[4,4]}}function Te(N,X){const G=Be(N,X),V=G.reduce((q,x)=>q*x/Ae(q,x),1),j=[];return G.forEach((q,x)=>{const r=V/q;for(let o=0;o=4352&&N<=4447||N>=11904&&N<=12350||N>=12353&&N<=13311||N>=13312&&N<=19903||N>=19968&&N<=40959||N>=40960&&N<=42191||N>=44032&&N<=55203||N>=63744&&N<=64255||N>=65072&&N<=65103||N>=65280&&N<=65376||N>=65504&&N<=65510||N>=127744&&N<=129791||N>=131072&&N<=262141}function Me(N,X){let G=0;for(const q of N)G+=Se(q.codePointAt(0)??0)?2:1;if(G<=X)return N;let V=0,j="";for(const q of N){const x=Se(q.codePointAt(0)??0)?2:1;if(V+x>X-1)break;j+=q,V+=x}return`${j}…`}function Oe(N,X,G){if(X===G)return N;const V=N.indexOf(X),j=N.indexOf(G);if(V===-1||j===-1)return N;const q=N.filter(o=>o!==X),x=q.indexOf(G),r=V1,f=()=>{j.current=null,q.current=null,x.current=null,r.current=!1,l(null),n(null)};return{draggingPane:o,dragOverPane:_,reorderable:d,endPaneDrag:f,onPaneDragStart:(s,e)=>{s.target.closest("button")||(G(e),!(s.button!==0||!d)&&(j.current=e,q.current={x:s.clientX,y:s.clientY},r.current=!1,s.currentTarget.setPointerCapture(s.pointerId)))},onPaneDragMove:s=>{const e=j.current,i=q.current;if(e===null||i===null||!r.current&&Math.hypot(s.clientX-i.x,s.clientY-i.y){const s=j.current,e=x.current;s!==null&&r.current&&e!==null&&V(Oe(N,s,e)),f()}}}function He({repo:N,socketRef:X,viewsRef:G,pendingRef:V,sentSizesRef:j,lastActiveByRepoRef:q,expectCreateRef:x,setPanes:r,setActive:o,setZoomed:l,setTitles:_}){Y.useEffect(()=>{let n=!1,d;const f=()=>{G.current.forEach(h=>h.term.dispose()),G.current.clear(),V.current.clear(),j.current.clear()},g=()=>{r([]),o(null),l(null),_({}),f();const h=location.protocol==="https:"?"wss:":"ws:",t=new WebSocket(`${h}//${location.host}/ws/term?repo=${encodeURIComponent(N)}`);t.binaryType="arraybuffer",X.current=t,t.onmessage=s=>{if(typeof s.data=="string"){const u=JSON.parse(s.data);if(u.type==="created"){const p=u.pane;r(c=>[...c,p]),x.current>0?(x.current-=1,o(p),q.current.set(N,p)):q.current.get(N)===p&&o(p)}else u.type==="exited"?(r(p=>p.filter(c=>c!==u.pane)),o(p=>p===u.pane?null:p),l(p=>p===u.pane?null:p),V.current.delete(u.pane),j.current.delete(u.pane),_(p=>{if(!(u.pane in p))return p;const c={...p};return delete c[u.pane],c})):u.type==="reordered"?r(p=>Pe(p,u.order)):u.type==="error"&&(x.current=0,ke.error(u.message));return}const e=new Uint8Array(s.data);if(e.length<4)return;const i=new DataView(e.buffer).getUint32(0,!0),a=e.subarray(4),v=G.current.get(i);if(v)v.term.write(a);else{const u=V.current.get(i)??[];u.push(a),V.current.set(i,u)}},t.onclose=()=>{n||(d=setTimeout(g,1e3))}};return g(),()=>{n=!0,d&&clearTimeout(d),X.current?.close(),f()}},[N])}var ge={exports:{}},Ce;function Fe(){return Ce||(Ce=1,(function(N,X){(function(G,V){N.exports=V()})(globalThis,(()=>(()=>{var G={4567:function(x,r,o){var l=this&&this.__decorate||function(e,i,a,v){var u,p=arguments.length,c=p<3?i:v===null?v=Object.getOwnPropertyDescriptor(i,a):v;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")c=Reflect.decorate(e,i,a,v);else for(var S=e.length-1;S>=0;S--)(u=e[S])&&(c=(p<3?u(c):p>3?u(i,a,c):u(i,a))||c);return p>3&&c&&Object.defineProperty(i,a,c),c},_=this&&this.__param||function(e,i){return function(a,v){i(a,v,e)}};Object.defineProperty(r,"__esModule",{value:!0}),r.AccessibilityManager=void 0;const n=o(9042),d=o(9924),f=o(844),g=o(4725),h=o(2585),t=o(3656);let s=r.AccessibilityManager=class extends f.Disposable{constructor(e,i,a,v){super(),this._terminal=e,this._coreBrowserService=a,this._renderService=v,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let u=0;uthis._handleBoundaryFocus(u,0),this._bottomBoundaryFocusListener=u=>this._handleBoundaryFocus(u,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new d.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((u=>this._handleResize(u.rows)))),this.register(this._terminal.onRender((u=>this._refreshRows(u.start,u.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((u=>this._handleChar(u)))),this.register(this._terminal.onLineFeed((()=>this._handleChar(` `)))),this.register(this._terminal.onA11yTab((u=>this._handleTab(u)))),this.register(this._terminal.onKey((u=>this._handleKey(u.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,t.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,f.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let i=0;i0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,e===` `&&(this._liveRegionLineCount++,this._liveRegionLineCount===21&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),new RegExp("\\p{Control}","u").test(e)||this._charsToConsume.push(e)}_refreshRows(e,i){this._liveRegionDebouncer.refresh(e,i,this._terminal.rows)}_renderRows(e,i){const a=this._terminal.buffer,v=a.lines.length.toString();for(let u=e;u<=i;u++){const p=a.lines.get(a.ydisp+u),c=[],S=p?.translateToString(!0,void 0,void 0,c)||"",E=(a.ydisp+u+1).toString(),k=this._rowElements[u];k&&(S.length===0?(k.innerText=" ",this._rowColumns.set(k,[0,1])):(k.textContent=S,this._rowColumns.set(k,c)),k.setAttribute("aria-posinset",E),k.setAttribute("aria-setsize",v))}this._announceCharacters()}_announceCharacters(){this._charsToAnnounce.length!==0&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,i){const a=e.target,v=this._rowElements[i===0?1:this._rowElements.length-2];if(a.getAttribute("aria-posinset")===(i===0?"1":`${this._terminal.buffer.lines.length}`)||e.relatedTarget!==v)return;let u,p;if(i===0?(u=a,p=this._rowElements.pop(),this._rowContainer.removeChild(p)):(u=this._rowElements.shift(),p=a,this._rowContainer.removeChild(u)),u.removeEventListener("focus",this._topBoundaryFocusListener),p.removeEventListener("focus",this._bottomBoundaryFocusListener),i===0){const c=this._createAccessibilityTreeNode();this._rowElements.unshift(c),this._rowContainer.insertAdjacentElement("afterbegin",c)}else{const c=this._createAccessibilityTreeNode();this._rowElements.push(c),this._rowContainer.appendChild(c)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(i===0?-1:1),this._rowElements[i===0?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(this._rowElements.length===0)return;const e=document.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let i={node:e.anchorNode,offset:e.anchorOffset},a={node:e.focusNode,offset:e.focusOffset};if((i.node.compareDocumentPosition(a.node)&Node.DOCUMENT_POSITION_PRECEDING||i.node===a.node&&i.offset>a.offset)&&([i,a]=[a,i]),i.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(i={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(i.node))return;const v=this._rowElements.slice(-1)[0];if(a.node.compareDocumentPosition(v)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(a={node:v,offset:v.textContent?.length??0}),!this._rowContainer.contains(a.node))return;const u=({node:S,offset:E})=>{const k=S instanceof Text?S.parentNode:S;let L=parseInt(k?.getAttribute("aria-posinset"),10)-1;if(isNaN(L))return console.warn("row is invalid. Race condition?"),null;const b=this._rowColumns.get(k);if(!b)return console.warn("columns is null. Race condition?"),null;let A=E=this._terminal.cols&&(++L,A=0),{row:L,column:A}},p=u(i),c=u(a);if(p&&c){if(p.row>c.row||p.row===c.row&&p.column>=c.column)throw new Error("invalid range");this._terminal.select(p.column,p.row,(c.row-p.row)*this._terminal.cols-p.column+c.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let i=this._rowContainer.children.length;ie;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){this._accessibilityContainer.style.width=`${this._renderService.dimensions.css.canvas.width}px`,this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function o(d){return d.replace(/\r?\n/g,"\r")}function l(d,f){return f?"\x1B[200~"+d+"\x1B[201~":d}function _(d,f,g,h){d=l(d=o(d),g.decPrivateModes.bracketedPasteMode&&h.rawOptions.ignoreBracketedPasteMode!==!0),g.triggerDataEvent(d,!0),f.value=""}function n(d,f,g){const h=g.getBoundingClientRect(),t=d.clientX-h.left-10,s=d.clientY-h.top-10;f.style.width="20px",f.style.height="20px",f.style.left=`${t}px`,f.style.top=`${s}px`,f.style.zIndex="1000",f.focus()}Object.defineProperty(r,"__esModule",{value:!0}),r.rightClickHandler=r.moveTextAreaUnderMouseCursor=r.paste=r.handlePasteEvent=r.copyHandler=r.bracketTextForPaste=r.prepareTextForTerminal=void 0,r.prepareTextForTerminal=o,r.bracketTextForPaste=l,r.copyHandler=function(d,f){d.clipboardData&&d.clipboardData.setData("text/plain",f.selectionText),d.preventDefault()},r.handlePasteEvent=function(d,f,g,h){d.stopPropagation(),d.clipboardData&&_(d.clipboardData.getData("text/plain"),f,g,h)},r.paste=_,r.moveTextAreaUnderMouseCursor=n,r.rightClickHandler=function(d,f,g,h,t){n(d,f,g),t&&h.rightClickSelect(d),f.value=h.selectionText,f.select()}},7239:(x,r,o)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.ColorContrastCache=void 0;const l=o(1505);r.ColorContrastCache=class{constructor(){this._color=new l.TwoKeyMap,this._css=new l.TwoKeyMap}setCss(_,n,d){this._css.set(_,n,d)}getCss(_,n){return this._css.get(_,n)}setColor(_,n,d){this._color.set(_,n,d)}getColor(_,n){return this._color.get(_,n)}clear(){this._color.clear(),this._css.clear()}}},3656:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.addDisposableDomListener=void 0,r.addDisposableDomListener=function(o,l,_,n){o.addEventListener(l,_,n);let d=!1;return{dispose:()=>{d||(d=!0,o.removeEventListener(l,_,n))}}}},3551:function(x,r,o){var l=this&&this.__decorate||function(s,e,i,a){var v,u=arguments.length,p=u<3?e:a===null?a=Object.getOwnPropertyDescriptor(e,i):a;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")p=Reflect.decorate(s,e,i,a);else for(var c=s.length-1;c>=0;c--)(v=s[c])&&(p=(u<3?v(p):u>3?v(e,i,p):v(e,i))||p);return u>3&&p&&Object.defineProperty(e,i,p),p},_=this&&this.__param||function(s,e){return function(i,a){e(i,a,s)}};Object.defineProperty(r,"__esModule",{value:!0}),r.Linkifier=void 0;const n=o(3656),d=o(8460),f=o(844),g=o(2585),h=o(4725);let t=r.Linkifier=class extends f.Disposable{get currentLink(){return this._currentLink}constructor(s,e,i,a,v){super(),this._element=s,this._mouseService=e,this._renderService=i,this._bufferService=a,this._linkProviderService=v,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this.register(new d.EventEmitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this.register(new d.EventEmitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this.register((0,f.getDisposeArrayDisposable)(this._linkCacheDisposables)),this.register((0,f.toDisposable)((()=>{this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()}))),this.register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0}))),this.register((0,n.addDisposableDomListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this.register((0,n.addDisposableDomListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(s){this._lastMouseEvent=s;const e=this._positionFromMouseEvent(s,this._element,this._mouseService);if(!e)return;this._isMouseOut=!1;const i=s.composedPath();for(let a=0;a{a?.forEach((v=>{v.link.dispose&&v.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=s.y);let i=!1;for(const[a,v]of this._linkProviderService.linkProviders.entries())e?this._activeProviderReplies?.get(a)&&(i=this._checkLinkProviderResult(a,s,i)):v.provideLinks(s.y,(u=>{if(this._isMouseOut)return;const p=u?.map((c=>({link:c})));this._activeProviderReplies?.set(a,p),i=this._checkLinkProviderResult(a,s,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(s.y,this._activeProviderReplies)}))}_removeIntersectingLinks(s,e){const i=new Set;for(let a=0;as?this._bufferService.cols:p.link.range.end.x;for(let E=c;E<=S;E++){if(i.has(E)){v.splice(u--,1);break}i.add(E)}}}}_checkLinkProviderResult(s,e,i){if(!this._activeProviderReplies)return i;const a=this._activeProviderReplies.get(s);let v=!1;for(let u=0;uthis._linkAtPosition(p.link,e)));u&&(i=!0,this._handleNewLink(u))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let u=0;uthis._linkAtPosition(c.link,e)));if(p){i=!0,this._handleNewLink(p);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(s){if(!this._currentLink)return;const e=this._positionFromMouseEvent(s,this._element,this._mouseService);e&&this._mouseDownLink===this._currentLink&&this._linkAtPosition(this._currentLink.link,e)&&this._currentLink.link.activate(s,this._currentLink.link.text)}_clearCurrentLink(s,e){this._currentLink&&this._lastMouseEvent&&(!s||!e||this._currentLink.link.range.start.y>=s&&this._currentLink.link.range.end.y<=e)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,f.disposeArray)(this._linkCacheDisposables))}_handleNewLink(s){if(!this._lastMouseEvent)return;const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._linkAtPosition(s.link,e)&&(this._currentLink=s,this._currentLink.state={decorations:{underline:s.link.decorations===void 0||s.link.decorations.underline,pointerCursor:s.link.decorations===void 0||s.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,s.link,this._lastMouseEvent),s.link.decorations={},Object.defineProperties(s.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:i=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==i&&(this._currentLink.state.decorations.pointerCursor=i,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",i))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:i=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==i&&(this._currentLink.state.decorations.underline=i,this._currentLink.state.isHovered&&this._fireUnderlineEvent(s.link,i))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((i=>{if(!this._currentLink)return;const a=i.start===0?0:i.start+1+this._bufferService.buffer.ydisp,v=this._bufferService.buffer.ydisp+1+i.end;if(this._currentLink.link.range.start.y>=a&&this._currentLink.link.range.end.y<=v&&(this._clearCurrentLink(a,v),this._lastMouseEvent)){const u=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);u&&this._askForLink(u,!1)}}))))}_linkHover(s,e,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(e,!0),this._currentLink.state.decorations.pointerCursor&&s.classList.add("xterm-cursor-pointer")),e.hover&&e.hover(i,e.text)}_fireUnderlineEvent(s,e){const i=s.range,a=this._bufferService.buffer.ydisp,v=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-a-1,i.end.x,i.end.y-a-1,void 0);(e?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(v)}_linkLeave(s,e,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(e,!1),this._currentLink.state.decorations.pointerCursor&&s.classList.remove("xterm-cursor-pointer")),e.leave&&e.leave(i,e.text)}_linkAtPosition(s,e){const i=s.range.start.y*this._bufferService.cols+s.range.start.x,a=s.range.end.y*this._bufferService.cols+s.range.end.x,v=e.y*this._bufferService.cols+e.x;return i<=v&&v<=a}_positionFromMouseEvent(s,e,i){const a=i.getCoords(s,e,this._bufferService.cols,this._bufferService.rows);if(a)return{x:a[0],y:a[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(s,e,i,a,v){return{x1:s,y1:e,x2:i,y2:a,cols:this._bufferService.cols,fg:v}}};r.Linkifier=t=l([_(1,h.IMouseService),_(2,h.IRenderService),_(3,g.IBufferService),_(4,h.ILinkProviderService)],t)},9042:(x,r)=>{Object.defineProperty(r,"__esModule",{value:!0}),r.tooMuchOutput=r.promptLabel=void 0,r.promptLabel="Terminal input",r.tooMuchOutput="Too much output to announce, navigate to rows manually to read"},3730:function(x,r,o){var l=this&&this.__decorate||function(h,t,s,e){var i,a=arguments.length,v=a<3?t:e===null?e=Object.getOwnPropertyDescriptor(t,s):e;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")v=Reflect.decorate(h,t,s,e);else for(var u=h.length-1;u>=0;u--)(i=h[u])&&(v=(a<3?i(v):a>3?i(t,s,v):i(t,s))||v);return a>3&&v&&Object.defineProperty(t,s,v),v},_=this&&this.__param||function(h,t){return function(s,e){t(s,e,h)}};Object.defineProperty(r,"__esModule",{value:!0}),r.OscLinkProvider=void 0;const n=o(511),d=o(2585);let f=r.OscLinkProvider=class{constructor(h,t,s){this._bufferService=h,this._optionsService=t,this._oscLinkService=s}provideLinks(h,t){const s=this._bufferService.buffer.lines.get(h-1);if(!s)return void t(void 0);const e=[],i=this._optionsService.rawOptions.linkHandler,a=new n.CellData,v=s.getTrimmedLength();let u=-1,p=-1,c=!1;for(let S=0;Si?i.activate(b,A,k):g(0,A),hover:(b,A)=>i?.hover?.(b,A,k),leave:(b,A)=>i?.leave?.(b,A,k)})}c=!1,a.hasExtendedAttrs()&&a.extended.urlId?(p=S,u=a.extended.urlId):(p=-1,u=-1)}}t(e)}};function g(h,t){if(confirm(`Do you want to navigate to ${t}? diff --git a/viewer-ui/dist/assets/index-CGiabjZL.css b/viewer-ui/dist/assets/index-CGiabjZL.css deleted file mode 100644 index b4f8bdc..0000000 --- a/viewer-ui/dist/assets/index-CGiabjZL.css +++ /dev/null @@ -1 +0,0 @@ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--radius-sm:.25rem;--radius-md:.375rem;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-ink-950:#0b0b0d;--color-ink-900:#121215;--color-ink-850:#17171b;--color-ink-800:#1d1d22;--color-ink-700:#2a2a31;--color-ink-600:#3a3a43;--color-ink-400:#6f6f7d;--color-ink-200:#a8a8b5;--color-ink-50:#e6e6ec;--color-accent:#d9a441;--color-added:#4ba36b;--color-removed:#c85f5f}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.top-0{top:0}.top-3{top:calc(var(--spacing) * 3)}.-right-px{right:-1px}.right-3{right:calc(var(--spacing) * 3)}.left-0{left:0}.z-10{z-index:10}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-auto{margin-inline:auto}.-my-\[8\.8px\]{margin-block:-8.8px}.my-1{margin-block:var(--spacing)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mr-1{margin-right:var(--spacing)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-72{height:calc(var(--spacing) * 72)}.h-\[22px\]{height:22px}.h-full{height:100%}.max-h-\[70vh\]{max-height:70vh}.max-h-\[80vh\]{max-height:80vh}.min-h-0{min-height:0}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-56{width:calc(var(--spacing) * 56)}.w-80{width:calc(var(--spacing) * 80)}.w-\[17rem\]{width:17rem}.w-\[22px\]{width:22px}.w-\[34rem\]{width:34rem}.w-full{width:100%}.w-max{width:max-content}.max-w-\[6rem\]{max-width:6rem}.max-w-\[9rem\]{max-width:9rem}.max-w-\[80vw\]{max-width:80vw}.max-w-\[86vw\]{max-width:86vw}.max-w-\[calc\(100vw-1\.5rem\)\]{max-width:calc(100vw - 1.5rem)}.max-w-full{max-width:100%}.min-w-0{min-width:0}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.basis-1\/2{flex-basis:50%}.rotate-90{rotate:90deg}.animate-pulse{animation:var(--animate-pulse)}.cursor-col-resize{cursor:col-resize}.cursor-grab{cursor:grab}.cursor-grabbing{cursor:grabbing}.touch-none{touch-action:none}.resize{resize:both}.columns-2{columns:2}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-rows-\[auto_1fr\]{grid-template-rows:auto 1fr}.grid-rows-\[auto_minmax\(0\,0fr\)_minmax\(0\,1fr\)_auto\]{grid-template-rows:auto minmax(0,0fr) minmax(0,1fr) auto}.grid-rows-\[auto_minmax\(0\,1fr\)_minmax\(0\,0fr\)_auto\]{grid-template-rows:auto minmax(0,1fr) minmax(0,0fr) auto}.grid-rows-\[auto_minmax\(0\,11fr\)_minmax\(0\,9fr\)_auto\]{grid-template-rows:auto minmax(0,11fr) minmax(0,9fr) auto}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-center{justify-content:center}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.self-stretch{align-self:stretch}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[20\.7\%\]{border-radius:20.7%}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-accent{border-color:var(--color-accent)}.border-ink-700{border-color:var(--color-ink-700)}.border-ink-800{border-color:var(--color-ink-800)}.border-transparent{border-color:#0000}.bg-accent{background-color:var(--color-accent)}.bg-added\/10{background-color:#4ba36b1a}@supports (color:color-mix(in lab,red,red)){.bg-added\/10{background-color:color-mix(in oklab,var(--color-added) 10%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black) 60%,transparent)}}.bg-ink-50{background-color:var(--color-ink-50)}.bg-ink-700{background-color:var(--color-ink-700)}.bg-ink-850{background-color:var(--color-ink-850)}.bg-ink-900{background-color:var(--color-ink-900)}.bg-ink-900\/40{background-color:#12121566}@supports (color:color-mix(in lab,red,red)){.bg-ink-900\/40{background-color:color-mix(in oklab,var(--color-ink-900) 40%,transparent)}}.bg-ink-950{background-color:var(--color-ink-950)}.bg-removed\/10{background-color:#c85f5f1a}@supports (color:color-mix(in lab,red,red)){.bg-removed\/10{background-color:color-mix(in oklab,var(--color-removed) 10%,transparent)}}.p-1{padding:var(--spacing)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-\[12\.8px\]{padding-inline:12.8px}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-\[8\.8px\]{padding-block:8.8px}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pl-1{padding-left:var(--spacing)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-sans{font-family:var(--font-sans)}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.62rem\]{font-size:.62rem}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.72rem\]{font-size:.72rem}.text-\[10px\]{font-size:10px}.text-\[16px\]{font-size:16px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.04em\]{--tw-tracking:.04em;letter-spacing:.04em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.text-accent{color:var(--color-accent)}.text-added{color:var(--color-added)}.text-ink-50{color:var(--color-ink-50)}.text-ink-200{color:var(--color-ink-200)}.text-ink-400{color:var(--color-ink-400)}.text-ink-600{color:var(--color-ink-600)}.text-ink-950{color:var(--color-ink-950)}.text-removed{color:var(--color-removed)}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.opacity-60{opacity:.6}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[inset_0_2px_0_0_var\(--color-accent\)\]{--tw-shadow:inset 0 2px 0 0 var(--tw-shadow-color,var(--color-accent));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-accent{--tw-ring-color:var(--color-accent)}.ring-ink-600{--tw-ring-color:var(--color-ink-600)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.placeholder\:text-ink-400::placeholder{color:var(--color-ink-400)}@media(hover:hover){.hover\:bg-accent:hover{background-color:var(--color-accent)}.hover\:bg-ink-700:hover{background-color:var(--color-ink-700)}.hover\:bg-ink-850:hover{background-color:var(--color-ink-850)}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-accent:hover{color:var(--color-accent)}.hover\:text-ink-200:hover{color:var(--color-ink-200)}.hover\:text-removed:hover{color:var(--color-removed)}}.focus\:border-accent:focus{border-color:var(--color-accent)}.focus\:border-ink-600:focus{border-color:var(--color-ink-600)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-\[3px\]:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-accent:focus{--tw-ring-color:var(--color-accent)}.focus\:ring-accent\/15:focus{--tw-ring-color:#d9a44126}@supports (color:color-mix(in lab,red,red)){.focus\:ring-accent\/15:focus{--tw-ring-color:color-mix(in oklab, var(--color-accent) 15%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:40rem){.sm\:inline{display:inline}}@media(min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:hidden{display:none}.md\:inline-flex{display:inline-flex}.md\:grid-cols-\[var\(--nc-sidebar\)_1fr\]{grid-template-columns:var(--nc-sidebar) 1fr}.md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}}}.xterm{cursor:text;-webkit-user-select:none;user-select:none;position:relative}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{z-index:5;position:absolute;top:0}.xterm .xterm-helper-textarea{opacity:0;z-index:-5;white-space:nowrap;resize:none;border:0;width:0;height:0;margin:0;padding:0;position:absolute;top:0;left:-9999em;overflow:hidden}.xterm .composition-view{color:#fff;white-space:nowrap;z-index:1;background:#000;display:none;position:absolute}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{cursor:default;background-color:#000;position:absolute;inset:0;overflow-y:scroll}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;top:0;left:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{visibility:hidden;line-height:normal;display:inline-block;position:absolute;top:0;left:-9999em}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{z-index:10;color:#0000;pointer-events:none;position:absolute;inset:0}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:#0000}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre}.xterm .live-region{width:1px;height:1px;position:absolute;left:-9999px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:underline double;text-decoration:underline double}.xterm-underline-3{-webkit-text-decoration:underline wavy;text-decoration:underline wavy}.xterm-underline-4{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.xterm-underline-5{-webkit-text-decoration:underline dashed;text-decoration:underline dashed}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:underline overline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;pointer-events:none;position:absolute;top:0;right:0}.xterm-decoration-top{z-index:2;position:relative}html,body,#root{height:100%}html{font-size:14px}body{background:var(--color-ink-950);color:var(--color-ink-50);font-family:var(--font-mono);margin:0;font-size:.85rem;line-height:1.4}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}*{scrollbar-width:thin;scrollbar-color:var(--color-ink-600) transparent}.nc-markdown{max-width:52rem;font-family:var(--font-sans);color:var(--color-ink-50);line-height:1.6}.nc-markdown h1,.nc-markdown h2,.nc-markdown h3,.nc-markdown h4,.nc-markdown h5,.nc-markdown h6{margin:1.4em 0 .6em;font-weight:600;line-height:1.25}.nc-markdown h1{border-bottom:1px solid var(--color-ink-700);padding-bottom:.3em;font-size:1.6em}.nc-markdown h2{border-bottom:1px solid var(--color-ink-800);padding-bottom:.25em;font-size:1.35em}.nc-markdown h3{font-size:1.15em}.nc-markdown h4{font-size:1em}.nc-markdown h5,.nc-markdown h6{color:var(--color-ink-200);font-size:.9em}.nc-markdown :first-child{margin-top:0}.nc-markdown p,.nc-markdown ul,.nc-markdown ol,.nc-markdown blockquote,.nc-markdown table,.nc-markdown pre{margin:.75em 0}.nc-markdown ul,.nc-markdown ol{padding-left:1.5em}.nc-markdown ul{list-style:outside}.nc-markdown ol{list-style:decimal}.nc-markdown li{margin:.25em 0}.nc-markdown li::marker{color:var(--color-ink-400)}.nc-markdown li:has(>input[type=checkbox]){margin-left:-1.2em;list-style:none}.nc-markdown a{color:var(--color-accent);text-underline-offset:2px;text-decoration:underline}.nc-markdown strong{font-weight:600}.nc-markdown em{font-style:italic}.nc-markdown blockquote{border-left:3px solid var(--color-ink-700);color:var(--color-ink-200);padding-left:1em}.nc-markdown hr{border:0;border-top:1px solid var(--color-ink-700);margin:1.5em 0}.nc-markdown img{max-width:100%}.nc-markdown :not(pre)>code{font-family:var(--font-mono);background:var(--color-ink-800);border-radius:3px;padding:.1em .35em;font-size:.9em}.nc-markdown pre{background:var(--color-ink-850);border:1px solid var(--color-ink-800);border-radius:4px;padding:.9em 1em;overflow-x:auto}.nc-markdown pre code{font-family:var(--font-mono);background:0 0;padding:0;font-size:.85em}.nc-markdown table{border-collapse:collapse;display:block;overflow-x:auto}.nc-markdown th,.nc-markdown td{border:1px solid var(--color-ink-700);text-align:left;padding:.4em .7em}.nc-markdown th{background:var(--color-ink-850);font-weight:600}@keyframes nc-fade-in{0%{opacity:0}to{opacity:1}}.nc-fade{animation:.16s ease-out nc-fade-in}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} diff --git a/viewer-ui/dist/assets/index-DNzvsjIC.css b/viewer-ui/dist/assets/index-DNzvsjIC.css new file mode 100644 index 0000000..1252bd7 --- /dev/null +++ b/viewer-ui/dist/assets/index-DNzvsjIC.css @@ -0,0 +1 @@ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:system-ui, sans-serif;--font-mono:ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--radius-sm:.25rem;--radius-md:.375rem;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-ink-950:#0b0b0d;--color-ink-900:#121215;--color-ink-850:#17171b;--color-ink-800:#1d1d22;--color-ink-700:#2a2a31;--color-ink-600:#3a3a43;--color-ink-400:#6f6f7d;--color-ink-200:#a8a8b5;--color-ink-50:#e6e6ec;--color-accent:#d9a441;--color-added:#4ba36b;--color-removed:#c85f5f}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.top-0{top:0}.top-3{top:calc(var(--spacing) * 3)}.-right-px{right:-1px}.right-3{right:calc(var(--spacing) * 3)}.left-0{left:0}.z-10{z-index:10}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-auto{margin-inline:auto}.-my-\[8\.8px\]{margin-block:-8.8px}.my-1{margin-block:var(--spacing)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mr-1{margin-right:var(--spacing)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-72{height:calc(var(--spacing) * 72)}.h-\[22px\]{height:22px}.h-full{height:100%}.max-h-\[70vh\]{max-height:70vh}.max-h-\[80vh\]{max-height:80vh}.min-h-0{min-height:0}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-56{width:calc(var(--spacing) * 56)}.w-80{width:calc(var(--spacing) * 80)}.w-\[17rem\]{width:17rem}.w-\[22px\]{width:22px}.w-\[34rem\]{width:34rem}.w-full{width:100%}.w-max{width:max-content}.max-w-\[6rem\]{max-width:6rem}.max-w-\[9rem\]{max-width:9rem}.max-w-\[80vw\]{max-width:80vw}.max-w-\[86vw\]{max-width:86vw}.max-w-\[calc\(100vw-1\.5rem\)\]{max-width:calc(100vw - 1.5rem)}.max-w-full{max-width:100%}.min-w-0{min-width:0}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.basis-1\/2{flex-basis:50%}.rotate-90{rotate:90deg}.animate-pulse{animation:var(--animate-pulse)}.cursor-col-resize{cursor:col-resize}.cursor-grab{cursor:grab}.cursor-grabbing{cursor:grabbing}.touch-none{touch-action:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-rows-\[auto_1fr\]{grid-template-rows:auto 1fr}.grid-rows-\[auto_minmax\(0\,0fr\)_minmax\(0\,1fr\)_auto\]{grid-template-rows:auto minmax(0,0fr) minmax(0,1fr) auto}.grid-rows-\[auto_minmax\(0\,1fr\)_minmax\(0\,0fr\)_auto\]{grid-template-rows:auto minmax(0,1fr) minmax(0,0fr) auto}.grid-rows-\[auto_minmax\(0\,11fr\)_minmax\(0\,9fr\)_auto\]{grid-template-rows:auto minmax(0,11fr) minmax(0,9fr) auto}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-center{justify-content:center}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.self-stretch{align-self:stretch}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[20\.7\%\]{border-radius:20.7%}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-accent{border-color:var(--color-accent)}.border-ink-700{border-color:var(--color-ink-700)}.border-ink-800{border-color:var(--color-ink-800)}.border-transparent{border-color:#0000}.bg-accent{background-color:var(--color-accent)}.bg-added\/10{background-color:#4ba36b1a}@supports (color:color-mix(in lab,red,red)){.bg-added\/10{background-color:color-mix(in oklab,var(--color-added) 10%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black) 60%,transparent)}}.bg-ink-50{background-color:var(--color-ink-50)}.bg-ink-700{background-color:var(--color-ink-700)}.bg-ink-850{background-color:var(--color-ink-850)}.bg-ink-900{background-color:var(--color-ink-900)}.bg-ink-900\/40{background-color:#12121566}@supports (color:color-mix(in lab,red,red)){.bg-ink-900\/40{background-color:color-mix(in oklab,var(--color-ink-900) 40%,transparent)}}.bg-ink-950{background-color:var(--color-ink-950)}.bg-removed\/10{background-color:#c85f5f1a}@supports (color:color-mix(in lab,red,red)){.bg-removed\/10{background-color:color-mix(in oklab,var(--color-removed) 10%,transparent)}}.p-1{padding:var(--spacing)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-\[12\.8px\]{padding-inline:12.8px}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-\[8\.8px\]{padding-block:8.8px}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pl-1{padding-left:var(--spacing)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-sans{font-family:var(--font-sans)}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.62rem\]{font-size:.62rem}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.72rem\]{font-size:.72rem}.text-\[10px\]{font-size:10px}.text-\[16px\]{font-size:16px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.04em\]{--tw-tracking:.04em;letter-spacing:.04em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.text-accent{color:var(--color-accent)}.text-added{color:var(--color-added)}.text-ink-50{color:var(--color-ink-50)}.text-ink-200{color:var(--color-ink-200)}.text-ink-400{color:var(--color-ink-400)}.text-ink-600{color:var(--color-ink-600)}.text-ink-950{color:var(--color-ink-950)}.text-removed{color:var(--color-removed)}.uppercase{text-transform:uppercase}.opacity-60{opacity:.6}.shadow-\[inset_0_2px_0_0_var\(--color-accent\)\]{--tw-shadow:inset 0 2px 0 0 var(--tw-shadow-color,var(--color-accent));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-accent{--tw-ring-color:var(--color-accent)}.ring-ink-600{--tw-ring-color:var(--color-ink-600)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.placeholder\:text-ink-400::placeholder{color:var(--color-ink-400)}@media(hover:hover){.hover\:bg-accent:hover{background-color:var(--color-accent)}.hover\:bg-ink-700:hover{background-color:var(--color-ink-700)}.hover\:bg-ink-850:hover{background-color:var(--color-ink-850)}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-accent:hover{color:var(--color-accent)}.hover\:text-ink-200:hover{color:var(--color-ink-200)}.hover\:text-removed:hover{color:var(--color-removed)}}.focus\:border-accent:focus{border-color:var(--color-accent)}.focus\:border-ink-600:focus{border-color:var(--color-ink-600)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-\[3px\]:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-accent:focus{--tw-ring-color:var(--color-accent)}.focus\:ring-accent\/15:focus{--tw-ring-color:#d9a44126}@supports (color:color-mix(in lab,red,red)){.focus\:ring-accent\/15:focus{--tw-ring-color:color-mix(in oklab, var(--color-accent) 15%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:40rem){.sm\:inline{display:inline}}@media(min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:hidden{display:none}.md\:inline-flex{display:inline-flex}.md\:grid-cols-\[var\(--nc-sidebar\)_1fr\]{grid-template-columns:var(--nc-sidebar) 1fr}.md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}}}.xterm{cursor:text;-webkit-user-select:none;user-select:none;position:relative}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{z-index:5;position:absolute;top:0}.xterm .xterm-helper-textarea{opacity:0;z-index:-5;white-space:nowrap;resize:none;border:0;width:0;height:0;margin:0;padding:0;position:absolute;top:0;left:-9999em;overflow:hidden}.xterm .composition-view{color:#fff;white-space:nowrap;z-index:1;background:#000;display:none;position:absolute}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{cursor:default;background-color:#000;position:absolute;inset:0;overflow-y:scroll}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;top:0;left:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{visibility:hidden;line-height:normal;display:inline-block;position:absolute;top:0;left:-9999em}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{z-index:10;color:#0000;pointer-events:none;position:absolute;inset:0}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:#0000}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre}.xterm .live-region{width:1px;height:1px;position:absolute;left:-9999px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:underline double;text-decoration:underline double}.xterm-underline-3{-webkit-text-decoration:underline wavy;text-decoration:underline wavy}.xterm-underline-4{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.xterm-underline-5{-webkit-text-decoration:underline dashed;text-decoration:underline dashed}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:underline overline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;pointer-events:none;position:absolute;top:0;right:0}.xterm-decoration-top{z-index:2;position:relative}html,body,#root{height:100%}html{font-size:14px}body{background:var(--color-ink-950);color:var(--color-ink-50);font-family:var(--font-mono);margin:0;font-size:.85rem;line-height:1.4}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}*{scrollbar-width:thin;scrollbar-color:var(--color-ink-600) transparent}.nc-markdown{max-width:52rem;font-family:var(--font-sans);color:var(--color-ink-50);line-height:1.6}.nc-markdown h1,.nc-markdown h2,.nc-markdown h3,.nc-markdown h4,.nc-markdown h5,.nc-markdown h6{margin:1.4em 0 .6em;font-weight:600;line-height:1.25}.nc-markdown h1{border-bottom:1px solid var(--color-ink-700);padding-bottom:.3em;font-size:1.6em}.nc-markdown h2{border-bottom:1px solid var(--color-ink-800);padding-bottom:.25em;font-size:1.35em}.nc-markdown h3{font-size:1.15em}.nc-markdown h4{font-size:1em}.nc-markdown h5,.nc-markdown h6{color:var(--color-ink-200);font-size:.9em}.nc-markdown :first-child{margin-top:0}.nc-markdown p,.nc-markdown ul,.nc-markdown ol,.nc-markdown blockquote,.nc-markdown table,.nc-markdown pre{margin:.75em 0}.nc-markdown ul,.nc-markdown ol{padding-left:1.5em}.nc-markdown ul{list-style:outside}.nc-markdown ol{list-style:decimal}.nc-markdown li{margin:.25em 0}.nc-markdown li::marker{color:var(--color-ink-400)}.nc-markdown li:has(>input[type=checkbox]){margin-left:-1.2em;list-style:none}.nc-markdown a{color:var(--color-accent);text-underline-offset:2px;text-decoration:underline}.nc-markdown strong{font-weight:600}.nc-markdown em{font-style:italic}.nc-markdown blockquote{border-left:3px solid var(--color-ink-700);color:var(--color-ink-200);padding-left:1em}.nc-markdown hr{border:0;border-top:1px solid var(--color-ink-700);margin:1.5em 0}.nc-markdown img{max-width:100%}.nc-markdown :not(pre)>code{font-family:var(--font-mono);background:var(--color-ink-800);border-radius:3px;padding:.1em .35em;font-size:.9em}.nc-markdown pre{background:var(--color-ink-850);border:1px solid var(--color-ink-800);border-radius:4px;padding:.9em 1em;overflow-x:auto}.nc-markdown pre code{font-family:var(--font-mono);background:0 0;padding:0;font-size:.85em}.nc-markdown table{border-collapse:collapse;display:block;overflow-x:auto}.nc-markdown th,.nc-markdown td{border:1px solid var(--color-ink-700);text-align:left;padding:.4em .7em}.nc-markdown th{background:var(--color-ink-850);font-weight:600}@keyframes nc-fade-in{0%{opacity:0}to{opacity:1}}.nc-fade{animation:.16s ease-out nc-fade-in}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} diff --git a/viewer-ui/dist/assets/index-D8ORZvs2.js b/viewer-ui/dist/assets/index-cnyyNRtX.js similarity index 99% rename from viewer-ui/dist/assets/index-D8ORZvs2.js rename to viewer-ui/dist/assets/index-cnyyNRtX.js index 45e002d..568b7bb 100644 --- a/viewer-ui/dist/assets/index-D8ORZvs2.js +++ b/viewer-ui/dist/assets/index-cnyyNRtX.js @@ -1,4 +1,4 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./Markdown-BYxaSXJW.js","./Markdown-Dfs9RUU9.css"])))=>i.map(i=>d[i]); +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./Markdown-D7nyj2B6.js","./Markdown-Dfs9RUU9.css"])))=>i.map(i=>d[i]); (function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const z of document.querySelectorAll('link[rel="modulepreload"]'))s(z);new MutationObserver(z=>{for(const E of z)if(E.type==="childList")for(const _ of E.addedNodes)_.tagName==="LINK"&&_.rel==="modulepreload"&&s(_)}).observe(document,{childList:!0,subtree:!0});function y(z){const E={};return z.integrity&&(E.integrity=z.integrity),z.referrerPolicy&&(E.referrerPolicy=z.referrerPolicy),z.crossOrigin==="use-credentials"?E.credentials="include":z.crossOrigin==="anonymous"?E.credentials="omit":E.credentials="same-origin",E}function s(z){if(z.ep)return;z.ep=!0;const E=y(z);fetch(z.href,E)}})();var Df={exports:{}},Bn={};var wd;function Gh(){if(wd)return Bn;wd=1;var c=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function y(s,z,E){var _=null;if(E!==void 0&&(_=""+E),z.key!==void 0&&(_=""+z.key),"key"in z){E={};for(var H in z)H!=="key"&&(E[H]=z[H])}else E=z;return z=E.ref,{$$typeof:c,type:s,key:_,ref:z!==void 0?z:null,props:E}}return Bn.Fragment=r,Bn.jsx=y,Bn.jsxs=y,Bn}var kd;function Qh(){return kd||(kd=1,Df.exports=Gh()),Df.exports}var o=Qh(),Cf={exports:{}},tt={};var Kd;function Zh(){if(Kd)return tt;Kd=1;var c=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),y=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),z=Symbol.for("react.profiler"),E=Symbol.for("react.consumer"),_=Symbol.for("react.context"),H=Symbol.for("react.forward_ref"),M=Symbol.for("react.suspense"),v=Symbol.for("react.memo"),j=Symbol.for("react.lazy"),D=Symbol.for("react.activity"),R=Symbol.iterator;function Q(h){return h===null||typeof h!="object"?null:(h=R&&h[R]||h["@@iterator"],typeof h=="function"?h:null)}var nt={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},P=Object.assign,J={};function ut(h,O,q){this.props=h,this.context=O,this.refs=J,this.updater=q||nt}ut.prototype.isReactComponent={},ut.prototype.setState=function(h,O){if(typeof h!="object"&&typeof h!="function"&&h!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,h,O,"setState")},ut.prototype.forceUpdate=function(h){this.updater.enqueueForceUpdate(this,h,"forceUpdate")};function et(){}et.prototype=ut.prototype;function L(h,O,q){this.props=h,this.context=O,this.refs=J,this.updater=q||nt}var W=L.prototype=new et;W.constructor=L,P(W,ut.prototype),W.isPureReactComponent=!0;var rt=Array.isArray;function I(){}var Z={H:null,A:null,T:null,S:null},ot=Object.prototype.hasOwnProperty;function ht(h,O,q){var Y=q.ref;return{$$typeof:c,type:h,key:O,ref:Y!==void 0?Y:null,props:q}}function tl(h,O){return ht(h.type,O,h.props)}function Qt(h){return typeof h=="object"&&h!==null&&h.$$typeof===c}function jt(h){var O={"=":"=0",":":"=2"};return"$"+h.replace(/[=:]/g,function(q){return O[q]})}var wt=/\/+/g;function Ct(h,O){return typeof h=="object"&&h!==null&&h.key!=null?jt(""+h.key):O.toString(36)}function Zt(h){switch(h.status){case"fulfilled":return h.value;case"rejected":throw h.reason;default:switch(typeof h.status=="string"?h.then(I,I):(h.status="pending",h.then(function(O){h.status==="pending"&&(h.status="fulfilled",h.value=O)},function(O){h.status==="pending"&&(h.status="rejected",h.reason=O)})),h.status){case"fulfilled":return h.value;case"rejected":throw h.reason}}throw h}function T(h,O,q,Y,$){var k=typeof h;(k==="undefined"||k==="boolean")&&(h=null);var F=!1;if(h===null)F=!0;else switch(k){case"bigint":case"string":case"number":F=!0;break;case"object":switch(h.$$typeof){case c:case r:F=!0;break;case j:return F=h._init,T(F(h._payload),O,q,Y,$)}}if(F)return $=$(h),F=Y===""?"."+Ct(h,0):Y,rt($)?(q="",F!=null&&(q=F.replace(wt,"$&/")+"/"),T($,O,q,"",function(ql){return ql})):$!=null&&(Qt($)&&($=tl($,q+($.key==null||h&&h.key===$.key?"":(""+$.key).replace(wt,"$&/")+"/")+F)),O.push($)),1;F=0;var Bt=Y===""?".":Y+":";if(rt(h))for(var _t=0;_t>>1,vt=T[mt];if(0>>1;mtz(q,w))Yz($,q)?(T[mt]=$,T[Y]=w,mt=Y):(T[mt]=q,T[O]=w,mt=O);else if(Yz($,w))T[mt]=$,T[Y]=w,mt=Y;else break t}}return B}function z(T,B){var w=T.sortIndex-B.sortIndex;return w!==0?w:T.id-B.id}if(c.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var E=performance;c.unstable_now=function(){return E.now()}}else{var _=Date,H=_.now();c.unstable_now=function(){return _.now()-H}}var M=[],v=[],j=1,D=null,R=3,Q=!1,nt=!1,P=!1,J=!1,ut=typeof setTimeout=="function"?setTimeout:null,et=typeof clearTimeout=="function"?clearTimeout:null,L=typeof setImmediate<"u"?setImmediate:null;function W(T){for(var B=y(v);B!==null;){if(B.callback===null)s(v);else if(B.startTime<=T)s(v),B.sortIndex=B.expirationTime,r(M,B);else break;B=y(v)}}function rt(T){if(P=!1,W(T),!nt)if(y(M)!==null)nt=!0,I||(I=!0,jt());else{var B=y(v);B!==null&&Zt(rt,B.startTime-T)}}var I=!1,Z=-1,ot=5,ht=-1;function tl(){return J?!0:!(c.unstable_now()-htT&&tl());){var mt=D.callback;if(typeof mt=="function"){D.callback=null,R=D.priorityLevel;var vt=mt(D.expirationTime<=T);if(T=c.unstable_now(),typeof vt=="function"){D.callback=vt,W(T),B=!0;break l}D===y(M)&&s(M),W(T)}else s(M);D=y(M)}if(D!==null)B=!0;else{var h=y(v);h!==null&&Zt(rt,h.startTime-T),B=!1}}break t}finally{D=null,R=w,Q=!1}B=void 0}}finally{B?jt():I=!1}}}var jt;if(typeof L=="function")jt=function(){L(Qt)};else if(typeof MessageChannel<"u"){var wt=new MessageChannel,Ct=wt.port2;wt.port1.onmessage=Qt,jt=function(){Ct.postMessage(null)}}else jt=function(){ut(Qt,0)};function Zt(T,B){Z=ut(function(){T(c.unstable_now())},B)}c.unstable_IdlePriority=5,c.unstable_ImmediatePriority=1,c.unstable_LowPriority=4,c.unstable_NormalPriority=3,c.unstable_Profiling=null,c.unstable_UserBlockingPriority=2,c.unstable_cancelCallback=function(T){T.callback=null},c.unstable_forceFrameRate=function(T){0>T||125mt?(T.sortIndex=w,r(v,T),y(M)===null&&T===y(v)&&(P?(et(Z),Z=-1):P=!0,Zt(rt,w-mt))):(T.sortIndex=vt,r(M,T),nt||Q||(nt=!0,I||(I=!0,jt()))),T},c.unstable_shouldYield=tl,c.unstable_wrapCallback=function(T){var B=R;return function(){var w=R;R=B;try{return T.apply(this,arguments)}finally{R=w}}}})(Rf)),Rf}var Wd;function wh(){return Wd||(Wd=1,Hf.exports=Vh()),Hf.exports}var Bf={exports:{}},Pt={};var Fd;function kh(){if(Fd)return Pt;Fd=1;var c=wf();function r(M){var v="https://react.dev/errors/"+M;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(c)}catch(r){console.error(r)}}return c(),Bf.exports=kh(),Bf.exports}var Pd;function Jh(){if(Pd)return qn;Pd=1;var c=wh(),r=wf(),y=Kh();function s(t){var l="https://react.dev/errors/"+t;if(1vt||(t.current=mt[vt],mt[vt]=null,vt--)}function q(t,l){vt++,mt[vt]=t.current,t.current=l}var Y=h(null),$=h(null),k=h(null),F=h(null);function Bt(t,l){switch(q(k,l),q($,t),q(Y,null),l.nodeType){case 9:case 11:t=(t=l.documentElement)&&(t=t.namespaceURI)?hd(t):0;break;default:if(t=l.tagName,l=l.namespaceURI)l=hd(l),t=vd(l,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}O(Y),q(Y,t)}function _t(){O(Y),O($),O(k)}function ql(t){t.memoizedState!==null&&q(F,t);var l=Y.current,e=vd(l,t.type);l!==e&&(q($,t),q(Y,e))}function Yl(t){$.current===t&&(O(Y),O($)),F.current===t&&(O(F),Cn._currentValue=w)}var ce,Ln;function bl(t){if(ce===void 0)try{throw Error()}catch(e){var l=e.stack.trim().match(/\n( *(at )?)/);ce=l&&l[1]||"",Ln=-1)":-1>>=0,t===0?32:31-(vi(t)/yi|0)|0}var ta=256,Ut=262144,wn=4194304;function Ue(t){var l=t&42;if(l!==0)return l;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function kn(t,l,e){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,i=t.pingedLanes;t=t.warmLanes;var f=a&134217727;return f!==0?(a=f&~u,a!==0?n=Ue(a):(i&=f,i!==0?n=Ue(i):e||(e=f&~t,e!==0&&(n=Ue(e))))):(f=a&~u,f!==0?n=Ue(f):i!==0?n=Ue(i):e||(e=a&~t,e!==0&&(n=Ue(e)))),n===0?0:l!==0&&l!==n&&(l&u)===0&&(u=n&-n,e=l&-l,u>=e||u===32&&(e&4194048)!==0)?l:n}function Va(t,l){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&l)===0}function Mm(t,l){switch(t){case 1:case 2:case 4:case 8:case 64:return l+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return l+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Jf(){var t=wn;return wn<<=1,(wn&62914560)===0&&(wn=4194304),t}function Si(t){for(var l=[],e=0;31>e;e++)l.push(t);return l}function wa(t,l){t.pendingLanes|=l,l!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function jm(t,l,e,a,n,u){var i=t.pendingLanes;t.pendingLanes=e,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=e,t.entangledLanes&=e,t.errorRecoveryDisabledLanes&=e,t.shellSuspendCounter=0;var f=t.entanglements,d=t.expirationTimes,b=t.hiddenUpdates;for(e=i&~e;0"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var Rm=/[\n"\\]/g;function El(t){return t.replace(Rm,function(l){return"\\"+l.charCodeAt(0).toString(16)+" "})}function Ti(t,l,e,a,n,u,i,f){t.name="",i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?t.type=i:t.removeAttribute("type"),l!=null?i==="number"?(l===0&&t.value===""||t.value!=l)&&(t.value=""+xl(l)):t.value!==""+xl(l)&&(t.value=""+xl(l)):i!=="submit"&&i!=="reset"||t.removeAttribute("value"),l!=null?_i(t,i,xl(l)):e!=null?_i(t,i,xl(e)):a!=null&&t.removeAttribute("value"),n==null&&u!=null&&(t.defaultChecked=!!u),n!=null&&(t.checked=n&&typeof n!="function"&&typeof n!="symbol"),f!=null&&typeof f!="function"&&typeof f!="symbol"&&typeof f!="boolean"?t.name=""+xl(f):t.removeAttribute("name")}function cs(t,l,e,a,n,u,i,f){if(u!=null&&typeof u!="function"&&typeof u!="symbol"&&typeof u!="boolean"&&(t.type=u),l!=null||e!=null){if(!(u!=="submit"&&u!=="reset"||l!=null)){zi(t);return}e=e!=null?""+xl(e):"",l=l!=null?""+xl(l):e,f||l===t.value||(t.value=l),t.defaultValue=l}a=a??n,a=typeof a!="function"&&typeof a!="symbol"&&!!a,t.checked=f?t.checked:!!a,t.defaultChecked=!!a,i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(t.name=i),zi(t)}function _i(t,l,e){l==="number"&&$n(t.ownerDocument)===t||t.defaultValue===""+e||(t.defaultValue=""+e)}function ia(t,l,e,a){if(t=t.options,l){l={};for(var n=0;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Oi=!1;if(wl)try{var $a={};Object.defineProperty($a,"passive",{get:function(){Oi=!0}}),window.addEventListener("test",$a,$a),window.removeEventListener("test",$a,$a)}catch{Oi=!1}var se=null,Di=null,Fn=null;function hs(){if(Fn)return Fn;var t,l=Di,e=l.length,a,n="value"in se?se.value:se.textContent,u=n.length;for(t=0;t=Ia),ps=" ",xs=!1;function Es(t,l){switch(t){case"keyup":return s0.indexOf(l.keyCode)!==-1;case"keydown":return l.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function zs(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var ra=!1;function o0(t,l){switch(t){case"compositionend":return zs(l);case"keypress":return l.which!==32?null:(xs=!0,ps);case"textInput":return t=l.data,t===ps&&xs?null:t;default:return null}}function d0(t,l){if(ra)return t==="compositionend"||!Bi&&Es(t,l)?(t=hs(),Fn=Di=se=null,ra=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(l.ctrlKey||l.altKey||l.metaKey)||l.ctrlKey&&l.altKey){if(l.char&&1=l)return{node:e,offset:l-t};t=a}t:{for(;e;){if(e.nextSibling){e=e.nextSibling;break t}e=e.parentNode}e=void 0}e=Ds(e)}}function Us(t,l){return t&&l?t===l?!0:t&&t.nodeType===3?!1:l&&l.nodeType===3?Us(t,l.parentNode):"contains"in t?t.contains(l):t.compareDocumentPosition?!!(t.compareDocumentPosition(l)&16):!1:!1}function Hs(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var l=$n(t.document);l instanceof t.HTMLIFrameElement;){try{var e=typeof l.contentWindow.location.href=="string"}catch{e=!1}if(e)t=l.contentWindow;else break;l=$n(t.document)}return l}function Li(t){var l=t&&t.nodeName&&t.nodeName.toLowerCase();return l&&(l==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||l==="textarea"||t.contentEditable==="true")}var p0=wl&&"documentMode"in document&&11>=document.documentMode,oa=null,Xi=null,en=null,Gi=!1;function Rs(t,l,e){var a=e.window===e?e.document:e.nodeType===9?e:e.ownerDocument;Gi||oa==null||oa!==$n(a)||(a=oa,"selectionStart"in a&&Li(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),en&&ln(en,a)||(en=a,a=Vu(Xi,"onSelect"),0>=i,n-=i,Ll=1<<32-al(l)+n|e<at?(st=G,G=null):st=G.sibling;var gt=p(g,G,S[at],N);if(gt===null){G===null&&(G=st);break}t&&G&>.alternate===null&&l(g,G),m=u(gt,m,at),yt===null?V=gt:yt.sibling=gt,yt=gt,G=st}if(at===S.length)return e(g,G),dt&&Kl(g,at),V;if(G===null){for(;atat?(st=G,G=null):st=G.sibling;var De=p(g,G,gt.value,N);if(De===null){G===null&&(G=st);break}t&&G&&De.alternate===null&&l(g,G),m=u(De,m,at),yt===null?V=De:yt.sibling=De,yt=De,G=st}if(gt.done)return e(g,G),dt&&Kl(g,at),V;if(G===null){for(;!gt.done;at++,gt=S.next())gt=C(g,gt.value,N),gt!==null&&(m=u(gt,m,at),yt===null?V=gt:yt.sibling=gt,yt=gt);return dt&&Kl(g,at),V}for(G=a(G);!gt.done;at++,gt=S.next())gt=x(G,g,at,gt.value,N),gt!==null&&(t&>.alternate!==null&&G.delete(gt.key===null?at:gt.key),m=u(gt,m,at),yt===null?V=gt:yt.sibling=gt,yt=gt);return t&&G.forEach(function(Xh){return l(g,Xh)}),dt&&Kl(g,at),V}function zt(g,m,S,N){if(typeof S=="object"&&S!==null&&S.type===P&&S.key===null&&(S=S.props.children),typeof S=="object"&&S!==null){switch(S.$$typeof){case Q:t:{for(var V=S.key;m!==null;){if(m.key===V){if(V=S.type,V===P){if(m.tag===7){e(g,m.sibling),N=n(m,S.props.children),N.return=g,g=N;break t}}else if(m.elementType===V||typeof V=="object"&&V!==null&&V.$$typeof===ot&&Ve(V)===m.type){e(g,m.sibling),N=n(m,S.props),sn(N,S),N.return=g,g=N;break t}e(g,m);break}else l(g,m);m=m.sibling}S.type===P?(N=Le(S.props.children,g.mode,N,S.key),N.return=g,g=N):(N=cu(S.type,S.key,S.props,null,g.mode,N),sn(N,S),N.return=g,g=N)}return i(g);case nt:t:{for(V=S.key;m!==null;){if(m.key===V)if(m.tag===4&&m.stateNode.containerInfo===S.containerInfo&&m.stateNode.implementation===S.implementation){e(g,m.sibling),N=n(m,S.children||[]),N.return=g,g=N;break t}else{e(g,m);break}else l(g,m);m=m.sibling}N=Ji(S,g.mode,N),N.return=g,g=N}return i(g);case ot:return S=Ve(S),zt(g,m,S,N)}if(Zt(S))return X(g,m,S,N);if(jt(S)){if(V=jt(S),typeof V!="function")throw Error(s(150));return S=V.call(S),K(g,m,S,N)}if(typeof S.then=="function")return zt(g,m,hu(S),N);if(S.$$typeof===L)return zt(g,m,ru(g,S),N);vu(g,S)}return typeof S=="string"&&S!==""||typeof S=="number"||typeof S=="bigint"?(S=""+S,m!==null&&m.tag===6?(e(g,m.sibling),N=n(m,S),N.return=g,g=N):(e(g,m),N=Ki(S,g.mode,N),N.return=g,g=N),i(g)):e(g,m)}return function(g,m,S,N){try{fn=0;var V=zt(g,m,S,N);return Ea=null,V}catch(G){if(G===xa||G===du)throw G;var yt=ml(29,G,null,g.mode);return yt.lanes=N,yt.return=g,yt}}}var ke=nr(!0),ur=nr(!1),he=!1;function ic(t){t.updateQueue={baseState:t.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function cc(t,l){t=t.updateQueue,l.updateQueue===t&&(l.updateQueue={baseState:t.baseState,firstBaseUpdate:t.firstBaseUpdate,lastBaseUpdate:t.lastBaseUpdate,shared:t.shared,callbacks:null})}function ve(t){return{lane:t,tag:0,payload:null,callback:null,next:null}}function ye(t,l,e){var a=t.updateQueue;if(a===null)return null;if(a=a.shared,(St&2)!==0){var n=a.pending;return n===null?l.next=l:(l.next=n.next,n.next=l),a.pending=l,l=iu(t),Qs(t,null,e),l}return uu(t,a,l,e),iu(t)}function rn(t,l,e){if(l=l.updateQueue,l!==null&&(l=l.shared,(e&4194048)!==0)){var a=l.lanes;a&=t.pendingLanes,e|=a,l.lanes=e,Wf(t,e)}}function fc(t,l){var e=t.updateQueue,a=t.alternate;if(a!==null&&(a=a.updateQueue,e===a)){var n=null,u=null;if(e=e.firstBaseUpdate,e!==null){do{var i={lane:e.lane,tag:e.tag,payload:e.payload,callback:null,next:null};u===null?n=u=i:u=u.next=i,e=e.next}while(e!==null);u===null?n=u=l:u=u.next=l}else n=u=l;e={baseState:a.baseState,firstBaseUpdate:n,lastBaseUpdate:u,shared:a.shared,callbacks:a.callbacks},t.updateQueue=e;return}t=e.lastBaseUpdate,t===null?e.firstBaseUpdate=l:t.next=l,e.lastBaseUpdate=l}var sc=!1;function on(){if(sc){var t=pa;if(t!==null)throw t}}function dn(t,l,e,a){sc=!1;var n=t.updateQueue;he=!1;var u=n.firstBaseUpdate,i=n.lastBaseUpdate,f=n.shared.pending;if(f!==null){n.shared.pending=null;var d=f,b=d.next;d.next=null,i===null?u=b:i.next=b,i=d;var A=t.alternate;A!==null&&(A=A.updateQueue,f=A.lastBaseUpdate,f!==i&&(f===null?A.firstBaseUpdate=b:f.next=b,A.lastBaseUpdate=d))}if(u!==null){var C=n.baseState;i=0,A=b=d=null,f=u;do{var p=f.lane&-536870913,x=p!==f.lane;if(x?(ft&p)===p:(a&p)===p){p!==0&&p===ba&&(sc=!0),A!==null&&(A=A.next={lane:0,tag:f.tag,payload:f.payload,callback:null,next:null});t:{var X=t,K=f;p=l;var zt=e;switch(K.tag){case 1:if(X=K.payload,typeof X=="function"){C=X.call(zt,C,p);break t}C=X;break t;case 3:X.flags=X.flags&-65537|128;case 0:if(X=K.payload,p=typeof X=="function"?X.call(zt,C,p):X,p==null)break t;C=D({},C,p);break t;case 2:he=!0}}p=f.callback,p!==null&&(t.flags|=64,x&&(t.flags|=8192),x=n.callbacks,x===null?n.callbacks=[p]:x.push(p))}else x={lane:p,tag:f.tag,payload:f.payload,callback:f.callback,next:null},A===null?(b=A=x,d=C):A=A.next=x,i|=p;if(f=f.next,f===null){if(f=n.shared.pending,f===null)break;x=f,f=x.next,x.next=null,n.lastBaseUpdate=x,n.shared.pending=null}}while(!0);A===null&&(d=C),n.baseState=d,n.firstBaseUpdate=b,n.lastBaseUpdate=A,u===null&&(n.shared.lanes=0),xe|=i,t.lanes=i,t.memoizedState=C}}function ir(t,l){if(typeof t!="function")throw Error(s(191,t));t.call(l)}function cr(t,l){var e=t.callbacks;if(e!==null)for(t.callbacks=null,t=0;tu?u:8;var i=T.T,f={};T.T=f,Mc(t,!1,l,e);try{var d=n(),b=T.S;if(b!==null&&b(f,d),d!==null&&typeof d=="object"&&typeof d.then=="function"){var A=j0(d,a);vn(t,l,A,Sl(t))}else vn(t,l,a,Sl(t))}catch(C){vn(t,l,{then:function(){},status:"rejected",reason:C},Sl())}finally{B.p=u,i!==null&&f.types!==null&&(i.types=f.types),T.T=i}}function R0(){}function Ac(t,l,e,a){if(t.tag!==5)throw Error(s(476));var n=Lr(t).queue;Yr(t,n,l,w,e===null?R0:function(){return Xr(t),e(a)})}function Lr(t){var l=t.memoizedState;if(l!==null)return l;l={memoizedState:w,baseState:w,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Fl,lastRenderedState:w},next:null};var e={};return l.next={memoizedState:e,baseState:e,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Fl,lastRenderedState:e},next:null},t.memoizedState=l,t=t.alternate,t!==null&&(t.memoizedState=l),l}function Xr(t){var l=Lr(t);l.next===null&&(l=t.alternate.memoizedState),vn(t,l.next.queue,{},Sl())}function Nc(){return Wt(Cn)}function Gr(){return Rt().memoizedState}function Qr(){return Rt().memoizedState}function B0(t){for(var l=t.return;l!==null;){switch(l.tag){case 24:case 3:var e=Sl();t=ve(e);var a=ye(l,t,e);a!==null&&(ol(a,l,e),rn(a,l,e)),l={cache:ec()},t.payload=l;return}l=l.return}}function q0(t,l,e){var a=Sl();e={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null},_u(t)?Vr(l,e):(e=wi(t,l,e,a),e!==null&&(ol(e,t,a),wr(e,l,a)))}function Zr(t,l,e){var a=Sl();vn(t,l,e,a)}function vn(t,l,e,a){var n={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null};if(_u(t))Vr(l,n);else{var u=t.alternate;if(t.lanes===0&&(u===null||u.lanes===0)&&(u=l.lastRenderedReducer,u!==null))try{var i=l.lastRenderedState,f=u(i,e);if(n.hasEagerState=!0,n.eagerState=f,dl(f,i))return uu(t,l,n,0),Tt===null&&nu(),!1}catch{}if(e=wi(t,l,n,a),e!==null)return ol(e,t,a),wr(e,l,a),!0}return!1}function Mc(t,l,e,a){if(a={lane:2,revertLane:cf(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},_u(t)){if(l)throw Error(s(479))}else l=wi(t,e,a,2),l!==null&&ol(l,t,2)}function _u(t){var l=t.alternate;return t===lt||l!==null&&l===lt}function Vr(t,l){Ta=Su=!0;var e=t.pending;e===null?l.next=l:(l.next=e.next,e.next=l),t.pending=l}function wr(t,l,e){if((e&4194048)!==0){var a=l.lanes;a&=t.pendingLanes,e|=a,l.lanes=e,Wf(t,e)}}var yn={readContext:Wt,use:xu,useCallback:Ot,useContext:Ot,useEffect:Ot,useImperativeHandle:Ot,useLayoutEffect:Ot,useInsertionEffect:Ot,useMemo:Ot,useReducer:Ot,useRef:Ot,useState:Ot,useDebugValue:Ot,useDeferredValue:Ot,useTransition:Ot,useSyncExternalStore:Ot,useId:Ot,useHostTransitionStatus:Ot,useFormState:Ot,useActionState:Ot,useOptimistic:Ot,useMemoCache:Ot,useCacheRefresh:Ot};yn.useEffectEvent=Ot;var kr={readContext:Wt,use:xu,useCallback:function(t,l){return nl().memoizedState=[t,l===void 0?null:l],t},useContext:Wt,useEffect:jr,useImperativeHandle:function(t,l,e){e=e!=null?e.concat([t]):null,zu(4194308,4,Ur.bind(null,l,t),e)},useLayoutEffect:function(t,l){return zu(4194308,4,t,l)},useInsertionEffect:function(t,l){zu(4,2,t,l)},useMemo:function(t,l){var e=nl();l=l===void 0?null:l;var a=t();if(Ke){pl(!0);try{t()}finally{pl(!1)}}return e.memoizedState=[a,l],a},useReducer:function(t,l,e){var a=nl();if(e!==void 0){var n=e(l);if(Ke){pl(!0);try{e(l)}finally{pl(!1)}}}else n=l;return a.memoizedState=a.baseState=n,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:n},a.queue=t,t=t.dispatch=q0.bind(null,lt,t),[a.memoizedState,t]},useRef:function(t){var l=nl();return t={current:t},l.memoizedState=t},useState:function(t){t=xc(t);var l=t.queue,e=Zr.bind(null,lt,l);return l.dispatch=e,[t.memoizedState,e]},useDebugValue:Tc,useDeferredValue:function(t,l){var e=nl();return _c(e,t,l)},useTransition:function(){var t=xc(!1);return t=Yr.bind(null,lt,t.queue,!0,!1),nl().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,l,e){var a=lt,n=nl();if(dt){if(e===void 0)throw Error(s(407));e=e()}else{if(e=l(),Tt===null)throw Error(s(349));(ft&127)!==0||mr(a,l,e)}n.memoizedState=e;var u={value:e,getSnapshot:l};return n.queue=u,jr(vr.bind(null,a,u,t),[t]),a.flags|=2048,Aa(9,{destroy:void 0},hr.bind(null,a,u,e,l),null),e},useId:function(){var t=nl(),l=Tt.identifierPrefix;if(dt){var e=Xl,a=Ll;e=(a&~(1<<32-al(a)-1)).toString(32)+e,l="_"+l+"R_"+e,e=bu++,0<\/script>",u=u.removeChild(u.firstChild);break;case"select":u=typeof a.is=="string"?i.createElement("select",{is:a.is}):i.createElement("select"),a.multiple?u.multiple=!0:a.size&&(u.size=a.size);break;default:u=typeof a.is=="string"?i.createElement(n,{is:a.is}):i.createElement(n)}}u[Jt]=l,u[ul]=a;t:for(i=l.child;i!==null;){if(i.tag===5||i.tag===6)u.appendChild(i.stateNode);else if(i.tag!==4&&i.tag!==27&&i.child!==null){i.child.return=i,i=i.child;continue}if(i===l)break t;for(;i.sibling===null;){if(i.return===null||i.return===l)break t;i=i.return}i.sibling.return=i.return,i=i.sibling}l.stateNode=u;t:switch(It(u,n,a),n){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break t;case"img":a=!0;break t;default:a=!1}a&&Pl(l)}}return Nt(l),Qc(l,l.type,t===null?null:t.memoizedProps,l.pendingProps,e),null;case 6:if(t&&l.stateNode!=null)t.memoizedProps!==a&&Pl(l);else{if(typeof a!="string"&&l.stateNode===null)throw Error(s(166));if(t=k.current,ga(l)){if(t=l.stateNode,e=l.memoizedProps,a=null,n=$t,n!==null)switch(n.tag){case 27:case 5:a=n.memoizedProps}t[Jt]=l,t=!!(t.nodeValue===e||a!==null&&a.suppressHydrationWarning===!0||dd(t.nodeValue,e)),t||de(l,!0)}else t=wu(t).createTextNode(a),t[Jt]=l,l.stateNode=t}return Nt(l),null;case 31:if(e=l.memoizedState,t===null||t.memoizedState!==null){if(a=ga(l),e!==null){if(t===null){if(!a)throw Error(s(318));if(t=l.memoizedState,t=t!==null?t.dehydrated:null,!t)throw Error(s(557));t[Jt]=l}else Xe(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;Nt(l),t=!1}else e=Ii(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=e),t=!0;if(!t)return l.flags&256?(vl(l),l):(vl(l),null);if((l.flags&128)!==0)throw Error(s(558))}return Nt(l),null;case 13:if(a=l.memoizedState,t===null||t.memoizedState!==null&&t.memoizedState.dehydrated!==null){if(n=ga(l),a!==null&&a.dehydrated!==null){if(t===null){if(!n)throw Error(s(318));if(n=l.memoizedState,n=n!==null?n.dehydrated:null,!n)throw Error(s(317));n[Jt]=l}else Xe(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;Nt(l),n=!1}else n=Ii(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=n),n=!0;if(!n)return l.flags&256?(vl(l),l):(vl(l),null)}return vl(l),(l.flags&128)!==0?(l.lanes=e,l):(e=a!==null,t=t!==null&&t.memoizedState!==null,e&&(a=l.child,n=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(n=a.alternate.memoizedState.cachePool.pool),u=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(u=a.memoizedState.cachePool.pool),u!==n&&(a.flags|=2048)),e!==t&&e&&(l.child.flags|=8192),Ou(l,l.updateQueue),Nt(l),null);case 4:return _t(),t===null&&of(l.stateNode.containerInfo),Nt(l),null;case 10:return $l(l.type),Nt(l),null;case 19:if(O(Ht),a=l.memoizedState,a===null)return Nt(l),null;if(n=(l.flags&128)!==0,u=a.rendering,u===null)if(n)Sn(a,!1);else{if(Dt!==0||t!==null&&(t.flags&128)!==0)for(t=l.child;t!==null;){if(u=gu(t),u!==null){for(l.flags|=128,Sn(a,!1),t=u.updateQueue,l.updateQueue=t,Ou(l,t),l.subtreeFlags=0,t=e,e=l.child;e!==null;)Zs(e,t),e=e.sibling;return q(Ht,Ht.current&1|2),dt&&Kl(l,a.treeForkCount),l.child}t=t.sibling}a.tail!==null&&ll()>Ru&&(l.flags|=128,n=!0,Sn(a,!1),l.lanes=4194304)}else{if(!n)if(t=gu(u),t!==null){if(l.flags|=128,n=!0,t=t.updateQueue,l.updateQueue=t,Ou(l,t),Sn(a,!0),a.tail===null&&a.tailMode==="hidden"&&!u.alternate&&!dt)return Nt(l),null}else 2*ll()-a.renderingStartTime>Ru&&e!==536870912&&(l.flags|=128,n=!0,Sn(a,!1),l.lanes=4194304);a.isBackwards?(u.sibling=l.child,l.child=u):(t=a.last,t!==null?t.sibling=u:l.child=u,a.last=u)}return a.tail!==null?(t=a.tail,a.rendering=t,a.tail=t.sibling,a.renderingStartTime=ll(),t.sibling=null,e=Ht.current,q(Ht,n?e&1|2:e&1),dt&&Kl(l,a.treeForkCount),t):(Nt(l),null);case 22:case 23:return vl(l),oc(),a=l.memoizedState!==null,t!==null?t.memoizedState!==null!==a&&(l.flags|=8192):a&&(l.flags|=8192),a?(e&536870912)!==0&&(l.flags&128)===0&&(Nt(l),l.subtreeFlags&6&&(l.flags|=8192)):Nt(l),e=l.updateQueue,e!==null&&Ou(l,e.retryQueue),e=null,t!==null&&t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),a=null,l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(a=l.memoizedState.cachePool.pool),a!==e&&(l.flags|=2048),t!==null&&O(Ze),null;case 24:return e=null,t!==null&&(e=t.memoizedState.cache),l.memoizedState.cache!==e&&(l.flags|=2048),$l(qt),Nt(l),null;case 25:return null;case 30:return null}throw Error(s(156,l.tag))}function Q0(t,l){switch(Wi(l),l.tag){case 1:return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 3:return $l(qt),_t(),t=l.flags,(t&65536)!==0&&(t&128)===0?(l.flags=t&-65537|128,l):null;case 26:case 27:case 5:return Yl(l),null;case 31:if(l.memoizedState!==null){if(vl(l),l.alternate===null)throw Error(s(340));Xe()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 13:if(vl(l),t=l.memoizedState,t!==null&&t.dehydrated!==null){if(l.alternate===null)throw Error(s(340));Xe()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 19:return O(Ht),null;case 4:return _t(),null;case 10:return $l(l.type),null;case 22:case 23:return vl(l),oc(),t!==null&&O(Ze),t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 24:return $l(qt),null;case 25:return null;default:return null}}function go(t,l){switch(Wi(l),l.tag){case 3:$l(qt),_t();break;case 26:case 27:case 5:Yl(l);break;case 4:_t();break;case 31:l.memoizedState!==null&&vl(l);break;case 13:vl(l);break;case 19:O(Ht);break;case 10:$l(l.type);break;case 22:case 23:vl(l),oc(),t!==null&&O(Ze);break;case 24:$l(qt)}}function bn(t,l){try{var e=l.updateQueue,a=e!==null?e.lastEffect:null;if(a!==null){var n=a.next;e=n;do{if((e.tag&t)===t){a=void 0;var u=e.create,i=e.inst;a=u(),i.destroy=a}e=e.next}while(e!==n)}}catch(f){pt(l,l.return,f)}}function be(t,l,e){try{var a=l.updateQueue,n=a!==null?a.lastEffect:null;if(n!==null){var u=n.next;a=u;do{if((a.tag&t)===t){var i=a.inst,f=i.destroy;if(f!==void 0){i.destroy=void 0,n=l;var d=e,b=f;try{b()}catch(A){pt(n,d,A)}}}a=a.next}while(a!==u)}}catch(A){pt(l,l.return,A)}}function So(t){var l=t.updateQueue;if(l!==null){var e=t.stateNode;try{cr(l,e)}catch(a){pt(t,t.return,a)}}}function bo(t,l,e){e.props=Je(t.type,t.memoizedProps),e.state=t.memoizedState;try{e.componentWillUnmount()}catch(a){pt(t,l,a)}}function pn(t,l){try{var e=t.ref;if(e!==null){switch(t.tag){case 26:case 27:case 5:var a=t.stateNode;break;case 30:a=t.stateNode;break;default:a=t.stateNode}typeof e=="function"?t.refCleanup=e(a):e.current=a}}catch(n){pt(t,l,n)}}function Gl(t,l){var e=t.ref,a=t.refCleanup;if(e!==null)if(typeof a=="function")try{a()}catch(n){pt(t,l,n)}finally{t.refCleanup=null,t=t.alternate,t!=null&&(t.refCleanup=null)}else if(typeof e=="function")try{e(null)}catch(n){pt(t,l,n)}else e.current=null}function po(t){var l=t.type,e=t.memoizedProps,a=t.stateNode;try{t:switch(l){case"button":case"input":case"select":case"textarea":e.autoFocus&&a.focus();break t;case"img":e.src?a.src=e.src:e.srcSet&&(a.srcset=e.srcSet)}}catch(n){pt(t,t.return,n)}}function Zc(t,l,e){try{var a=t.stateNode;rh(a,t.type,e,l),a[ul]=l}catch(n){pt(t,t.return,n)}}function xo(t){return t.tag===5||t.tag===3||t.tag===26||t.tag===27&&Ae(t.type)||t.tag===4}function Vc(t){t:for(;;){for(;t.sibling===null;){if(t.return===null||xo(t.return))return null;t=t.return}for(t.sibling.return=t.return,t=t.sibling;t.tag!==5&&t.tag!==6&&t.tag!==18;){if(t.tag===27&&Ae(t.type)||t.flags&2||t.child===null||t.tag===4)continue t;t.child.return=t,t=t.child}if(!(t.flags&2))return t.stateNode}}function wc(t,l,e){var a=t.tag;if(a===5||a===6)t=t.stateNode,l?(e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e).insertBefore(t,l):(l=e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e,l.appendChild(t),e=e._reactRootContainer,e!=null||l.onclick!==null||(l.onclick=Vl));else if(a!==4&&(a===27&&Ae(t.type)&&(e=t.stateNode,l=null),t=t.child,t!==null))for(wc(t,l,e),t=t.sibling;t!==null;)wc(t,l,e),t=t.sibling}function Du(t,l,e){var a=t.tag;if(a===5||a===6)t=t.stateNode,l?e.insertBefore(t,l):e.appendChild(t);else if(a!==4&&(a===27&&Ae(t.type)&&(e=t.stateNode),t=t.child,t!==null))for(Du(t,l,e),t=t.sibling;t!==null;)Du(t,l,e),t=t.sibling}function Eo(t){var l=t.stateNode,e=t.memoizedProps;try{for(var a=t.type,n=l.attributes;n.length;)l.removeAttributeNode(n[0]);It(l,a,e),l[Jt]=t,l[ul]=e}catch(u){pt(t,t.return,u)}}var te=!1,Xt=!1,kc=!1,zo=typeof WeakSet=="function"?WeakSet:Set,Kt=null;function Z0(t,l){if(t=t.containerInfo,hf=Iu,t=Hs(t),Li(t)){if("selectionStart"in t)var e={start:t.selectionStart,end:t.selectionEnd};else t:{e=(e=t.ownerDocument)&&e.defaultView||window;var a=e.getSelection&&e.getSelection();if(a&&a.rangeCount!==0){e=a.anchorNode;var n=a.anchorOffset,u=a.focusNode;a=a.focusOffset;try{e.nodeType,u.nodeType}catch{e=null;break t}var i=0,f=-1,d=-1,b=0,A=0,C=t,p=null;l:for(;;){for(var x;C!==e||n!==0&&C.nodeType!==3||(f=i+n),C!==u||a!==0&&C.nodeType!==3||(d=i+a),C.nodeType===3&&(i+=C.nodeValue.length),(x=C.firstChild)!==null;)p=C,C=x;for(;;){if(C===t)break l;if(p===e&&++b===n&&(f=i),p===u&&++A===a&&(d=i),(x=C.nextSibling)!==null)break;C=p,p=C.parentNode}C=x}e=f===-1||d===-1?null:{start:f,end:d}}else e=null}e=e||{start:0,end:0}}else e=null;for(vf={focusedElem:t,selectionRange:e},Iu=!1,Kt=l;Kt!==null;)if(l=Kt,t=l.child,(l.subtreeFlags&1028)!==0&&t!==null)t.return=l,Kt=t;else for(;Kt!==null;){switch(l=Kt,u=l.alternate,t=l.flags,l.tag){case 0:if((t&4)!==0&&(t=l.updateQueue,t=t!==null?t.events:null,t!==null))for(e=0;e title"))),It(u,a,e),u[Jt]=t,kt(u),a=u;break t;case"link":var i=jd("link","href",n).get(a+(e.href||""));if(i){for(var f=0;fzt&&(i=zt,zt=K,K=i);var g=Cs(f,K),m=Cs(f,zt);if(g&&m&&(x.rangeCount!==1||x.anchorNode!==g.node||x.anchorOffset!==g.offset||x.focusNode!==m.node||x.focusOffset!==m.offset)){var S=C.createRange();S.setStart(g.node,g.offset),x.removeAllRanges(),K>zt?(x.addRange(S),x.extend(m.node,m.offset)):(S.setEnd(m.node,m.offset),x.addRange(S))}}}}for(C=[],x=f;x=x.parentNode;)x.nodeType===1&&C.push({element:x,left:x.scrollLeft,top:x.scrollTop});for(typeof f.focus=="function"&&f.focus(),f=0;fe?32:e,T.T=null,e=Pc,Pc=null;var u=ze,i=ue;if(Vt=0,Da=ze=null,ue=0,(St&6)!==0)throw Error(s(331));var f=St;if(St|=4,Ho(u.current),Do(u,u.current,i,e),St=f,An(0,!1),el&&typeof el.onPostCommitFiberRoot=="function")try{el.onPostCommitFiberRoot(Ce,u)}catch{}return!0}finally{B.p=n,T.T=a,Io(t,l)}}function td(t,l,e){l=Tl(e,l),l=Cc(t.stateNode,l,2),t=ye(t,l,2),t!==null&&(wa(t,2),Ql(t))}function pt(t,l,e){if(t.tag===3)td(t,t,e);else for(;l!==null;){if(l.tag===3){td(l,t,e);break}else if(l.tag===1){var a=l.stateNode;if(typeof l.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(Ee===null||!Ee.has(a))){t=Tl(e,t),e=to(2),a=ye(l,e,2),a!==null&&(lo(e,a,l,t),wa(a,2),Ql(a));break}}l=l.return}}function af(t,l,e){var a=t.pingCache;if(a===null){a=t.pingCache=new k0;var n=new Set;a.set(l,n)}else n=a.get(l),n===void 0&&(n=new Set,a.set(l,n));n.has(e)||($c=!0,n.add(e),t=F0.bind(null,t,l,e),l.then(t,t))}function F0(t,l,e){var a=t.pingCache;a!==null&&a.delete(l),t.pingedLanes|=t.suspendedLanes&e,t.warmLanes&=~e,Tt===t&&(ft&e)===e&&(Dt===4||Dt===3&&(ft&62914560)===ft&&300>ll()-Hu?(St&2)===0&&Ca(t,0):Wc|=e,Oa===ft&&(Oa=0)),Ql(t)}function ld(t,l){l===0&&(l=Jf()),t=Ye(t,l),t!==null&&(wa(t,l),Ql(t))}function I0(t){var l=t.memoizedState,e=0;l!==null&&(e=l.retryLane),ld(t,e)}function P0(t,l){var e=0;switch(t.tag){case 31:case 13:var a=t.stateNode,n=t.memoizedState;n!==null&&(e=n.retryLane);break;case 19:a=t.stateNode;break;case 22:a=t.stateNode._retryCache;break;default:throw Error(s(314))}a!==null&&a.delete(l),ld(t,e)}function th(t,l){return Qa(t,l)}var Gu=null,Ha=null,nf=!1,Qu=!1,uf=!1,_e=0;function Ql(t){t!==Ha&&t.next===null&&(Ha===null?Gu=Ha=t:Ha=Ha.next=t),Qu=!0,nf||(nf=!0,eh())}function An(t,l){if(!uf&&Qu){uf=!0;do for(var e=!1,a=Gu;a!==null;){if(t!==0){var n=a.pendingLanes;if(n===0)var u=0;else{var i=a.suspendedLanes,f=a.pingedLanes;u=(1<<31-al(42|t)+1)-1,u&=n&~(i&~f),u=u&201326741?u&201326741|1:u?u|2:0}u!==0&&(e=!0,ud(a,u))}else u=ft,u=kn(a,a===Tt?u:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(u&3)===0||Va(a,u)||(e=!0,ud(a,u));a=a.next}while(e);uf=!1}}function lh(){ed()}function ed(){Qu=nf=!1;var t=0;_e!==0&&dh()&&(t=_e);for(var l=ll(),e=null,a=Gu;a!==null;){var n=a.next,u=ad(a,l);u===0?(a.next=null,e===null?Gu=n:e.next=n,n===null&&(Ha=e)):(e=a,(t!==0||(u&3)!==0)&&(Qu=!0)),a=n}Vt!==0&&Vt!==5||An(t),_e!==0&&(_e=0)}function ad(t,l){for(var e=t.suspendedLanes,a=t.pingedLanes,n=t.expirationTimes,u=t.pendingLanes&-62914561;0f)break;var A=d.transferSize,C=d.initiatorType;A&&md(C)&&(d=d.responseEnd,i+=A*(d"u"?null:document;function _d(t,l,e){var a=Ra;if(a&&typeof l=="string"&&l){var n=El(l);n='link[rel="'+t+'"][href="'+n+'"]',typeof e=="string"&&(n+='[crossorigin="'+e+'"]'),Td.has(n)||(Td.add(n),t={rel:t,crossOrigin:e,href:l},a.querySelector(n)===null&&(l=a.createElement("link"),It(l,"link",t),kt(l),a.head.appendChild(l)))}}function xh(t){ie.D(t),_d("dns-prefetch",t,null)}function Eh(t,l){ie.C(t,l),_d("preconnect",t,l)}function zh(t,l,e){ie.L(t,l,e);var a=Ra;if(a&&t&&l){var n='link[rel="preload"][as="'+El(l)+'"]';l==="image"&&e&&e.imageSrcSet?(n+='[imagesrcset="'+El(e.imageSrcSet)+'"]',typeof e.imageSizes=="string"&&(n+='[imagesizes="'+El(e.imageSizes)+'"]')):n+='[href="'+El(t)+'"]';var u=n;switch(l){case"style":u=Ba(t);break;case"script":u=qa(t)}Ol.has(u)||(t=D({rel:"preload",href:l==="image"&&e&&e.imageSrcSet?void 0:t,as:l},e),Ol.set(u,t),a.querySelector(n)!==null||l==="style"&&a.querySelector(On(u))||l==="script"&&a.querySelector(Dn(u))||(l=a.createElement("link"),It(l,"link",t),kt(l),a.head.appendChild(l)))}}function Th(t,l){ie.m(t,l);var e=Ra;if(e&&t){var a=l&&typeof l.as=="string"?l.as:"script",n='link[rel="modulepreload"][as="'+El(a)+'"][href="'+El(t)+'"]',u=n;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":u=qa(t)}if(!Ol.has(u)&&(t=D({rel:"modulepreload",href:t},l),Ol.set(u,t),e.querySelector(n)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(e.querySelector(Dn(u)))return}a=e.createElement("link"),It(a,"link",t),kt(a),e.head.appendChild(a)}}}function _h(t,l,e){ie.S(t,l,e);var a=Ra;if(a&&t){var n=na(a).hoistableStyles,u=Ba(t);l=l||"default";var i=n.get(u);if(!i){var f={loading:0,preload:null};if(i=a.querySelector(On(u)))f.loading=5;else{t=D({rel:"stylesheet",href:t,"data-precedence":l},e),(e=Ol.get(u))&&Ef(t,e);var d=i=a.createElement("link");kt(d),It(d,"link",t),d._p=new Promise(function(b,A){d.onload=b,d.onerror=A}),d.addEventListener("load",function(){f.loading|=1}),d.addEventListener("error",function(){f.loading|=2}),f.loading|=4,Ku(i,l,a)}i={type:"stylesheet",instance:i,count:1,state:f},n.set(u,i)}}}function Ah(t,l){ie.X(t,l);var e=Ra;if(e&&t){var a=na(e).hoistableScripts,n=qa(t),u=a.get(n);u||(u=e.querySelector(Dn(n)),u||(t=D({src:t,async:!0},l),(l=Ol.get(n))&&zf(t,l),u=e.createElement("script"),kt(u),It(u,"link",t),e.head.appendChild(u)),u={type:"script",instance:u,count:1,state:null},a.set(n,u))}}function Nh(t,l){ie.M(t,l);var e=Ra;if(e&&t){var a=na(e).hoistableScripts,n=qa(t),u=a.get(n);u||(u=e.querySelector(Dn(n)),u||(t=D({src:t,async:!0,type:"module"},l),(l=Ol.get(n))&&zf(t,l),u=e.createElement("script"),kt(u),It(u,"link",t),e.head.appendChild(u)),u={type:"script",instance:u,count:1,state:null},a.set(n,u))}}function Ad(t,l,e,a){var n=(n=k.current)?ku(n):null;if(!n)throw Error(s(446));switch(t){case"meta":case"title":return null;case"style":return typeof e.precedence=="string"&&typeof e.href=="string"?(l=Ba(e.href),e=na(n).hoistableStyles,a=e.get(l),a||(a={type:"style",instance:null,count:0,state:null},e.set(l,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(e.rel==="stylesheet"&&typeof e.href=="string"&&typeof e.precedence=="string"){t=Ba(e.href);var u=na(n).hoistableStyles,i=u.get(t);if(i||(n=n.ownerDocument||n,i={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},u.set(t,i),(u=n.querySelector(On(t)))&&!u._p&&(i.instance=u,i.state.loading=5),Ol.has(t)||(e={rel:"preload",as:"style",href:e.href,crossOrigin:e.crossOrigin,integrity:e.integrity,media:e.media,hrefLang:e.hrefLang,referrerPolicy:e.referrerPolicy},Ol.set(t,e),u||Mh(n,t,e,i.state))),l&&a===null)throw Error(s(528,""));return i}if(l&&a!==null)throw Error(s(529,""));return null;case"script":return l=e.async,e=e.src,typeof e=="string"&&l&&typeof l!="function"&&typeof l!="symbol"?(l=qa(e),e=na(n).hoistableScripts,a=e.get(l),a||(a={type:"script",instance:null,count:0,state:null},e.set(l,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(s(444,t))}}function Ba(t){return'href="'+El(t)+'"'}function On(t){return'link[rel="stylesheet"]['+t+"]"}function Nd(t){return D({},t,{"data-precedence":t.precedence,precedence:null})}function Mh(t,l,e,a){t.querySelector('link[rel="preload"][as="style"]['+l+"]")?a.loading=1:(l=t.createElement("link"),a.preload=l,l.addEventListener("load",function(){return a.loading|=1}),l.addEventListener("error",function(){return a.loading|=2}),It(l,"link",e),kt(l),t.head.appendChild(l))}function qa(t){return'[src="'+El(t)+'"]'}function Dn(t){return"script[async]"+t}function Md(t,l,e){if(l.count++,l.instance===null)switch(l.type){case"style":var a=t.querySelector('style[data-href~="'+El(e.href)+'"]');if(a)return l.instance=a,kt(a),a;var n=D({},e,{"data-href":e.href,"data-precedence":e.precedence,href:null,precedence:null});return a=(t.ownerDocument||t).createElement("style"),kt(a),It(a,"style",n),Ku(a,e.precedence,t),l.instance=a;case"stylesheet":n=Ba(e.href);var u=t.querySelector(On(n));if(u)return l.state.loading|=4,l.instance=u,kt(u),u;a=Nd(e),(n=Ol.get(n))&&Ef(a,n),u=(t.ownerDocument||t).createElement("link"),kt(u);var i=u;return i._p=new Promise(function(f,d){i.onload=f,i.onerror=d}),It(u,"link",a),l.state.loading|=4,Ku(u,e.precedence,t),l.instance=u;case"script":return u=qa(e.src),(n=t.querySelector(Dn(u)))?(l.instance=n,kt(n),n):(a=e,(n=Ol.get(u))&&(a=D({},e),zf(a,n)),t=t.ownerDocument||t,n=t.createElement("script"),kt(n),It(n,"link",a),t.head.appendChild(n),l.instance=n);case"void":return null;default:throw Error(s(443,l.type))}else l.type==="stylesheet"&&(l.state.loading&4)===0&&(a=l.instance,l.state.loading|=4,Ku(a,e.precedence,t));return l.instance}function Ku(t,l,e){for(var a=e.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),n=a.length?a[a.length-1]:null,u=n,i=0;i title"):null)}function jh(t,l,e){if(e===1||l.itemProp!=null)return!1;switch(t){case"meta":case"title":return!0;case"style":if(typeof l.precedence!="string"||typeof l.href!="string"||l.href==="")break;return!0;case"link":if(typeof l.rel!="string"||typeof l.href!="string"||l.href===""||l.onLoad||l.onError)break;return l.rel==="stylesheet"?(t=l.disabled,typeof l.precedence=="string"&&t==null):!0;case"script":if(l.async&&typeof l.async!="function"&&typeof l.async!="symbol"&&!l.onLoad&&!l.onError&&l.src&&typeof l.src=="string")return!0}return!1}function Dd(t){return!(t.type==="stylesheet"&&(t.state.loading&3)===0)}function Oh(t,l,e,a){if(e.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(e.state.loading&4)===0){if(e.instance===null){var n=Ba(a.href),u=l.querySelector(On(n));if(u){l=u._p,l!==null&&typeof l=="object"&&typeof l.then=="function"&&(t.count++,t=$u.bind(t),l.then(t,t)),e.state.loading|=4,e.instance=u,kt(u);return}u=l.ownerDocument||l,a=Nd(a),(n=Ol.get(n))&&Ef(a,n),u=u.createElement("link"),kt(u);var i=u;i._p=new Promise(function(f,d){i.onload=f,i.onerror=d}),It(u,"link",a),e.instance=u}t.stylesheets===null&&(t.stylesheets=new Map),t.stylesheets.set(e,l),(l=e.state.preload)&&(e.state.loading&3)===0&&(t.count++,e=$u.bind(t),l.addEventListener("load",e),l.addEventListener("error",e))}}var Tf=0;function Dh(t,l){return t.stylesheets&&t.count===0&&Fu(t,t.stylesheets),0Tf?50:800)+l);return t.unsuspend=e,function(){t.unsuspend=null,clearTimeout(a),clearTimeout(n)}}:null}function $u(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Fu(this,this.stylesheets);else if(this.unsuspend){var t=this.unsuspend;this.unsuspend=null,t()}}}var Wu=null;function Fu(t,l){t.stylesheets=null,t.unsuspend!==null&&(t.count++,Wu=new Map,l.forEach(Ch,t),Wu=null,$u.call(t))}function Ch(t,l){if(!(l.state.loading&4)){var e=Wu.get(t);if(e)var a=e.get(null);else{e=new Map,Wu.set(t,e);for(var n=t.querySelectorAll("link[data-precedence],style[data-precedence]"),u=0;u"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(c)}catch(r){console.error(r)}}return c(),Uf.exports=Jh(),Uf.exports}var Wh=$h();const om=2;class Yn extends Error{constructor(r,y){super(y),this.status=r}status}const dm=c=>c instanceof Yn&&c.status===401;class mm extends Error{constructor(r){super("connection lost — check your network",{cause:r}),this.name="NetworkError"}}const Fh=c=>c instanceof mm;async function si(c,r){try{return await fetch(c,r)}catch(y){throw new mm(y)}}async function hm(c){if(!c.ok){let y=`request failed (${c.status})`;try{const s=await c.json();typeof s?.error=="string"&&(y=s.error)}catch{}throw new Yn(c.status,y)}const r=await c.json();if(r.version!==om)throw new Yn(c.status,`this page is out of date (server protocol v${r.version}) — reload`);return r}async function Rl(c,r){const y=await si(c,{credentials:"same-origin",signal:r});return hm(y)}async function ui(c,r){const y=await si(c,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)});return hm(y)}const Dl=c=>new URLSearchParams(c).toString(),Gt={async login(c){const r=await si("/login",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({password:c}).toString()});if(!r.ok)throw new Yn(r.status,r.status===429?"too many attempts — wait a minute":"incorrect password")},repos:c=>Rl("/api/repos",c),setAccent:c=>ui("/api/prefs",{accent:c}).then(r=>r.accent),setSidebarWidth:c=>ui("/api/prefs",{sidebar_width:c}).then(r=>r.sidebar_width),status:c=>Rl(`/api/status?${Dl({repo:c})}`),tree:(c,r)=>Rl(`/api/tree?${Dl({repo:c,path:r})}`),treeSearch:(c,r)=>Rl(`/api/tree/search?${Dl({repo:c,q:r})}`),log:(c,r)=>Rl(`/api/log?${Dl(r?{repo:c,from:r.from,skip:String(r.skip)}:{repo:c})}`),diff:(c,r)=>Rl(`/api/diff?${Dl({repo:c,path:r})}`),file:(c,r)=>Rl(`/api/file?${Dl({repo:c,path:r})}`),commit:(c,r)=>Rl(`/api/commit?${Dl({repo:c,oid:r})}`),commitFiles:(c,r)=>Rl(`/api/commit/files?${Dl({repo:c,oid:r})}`),commitFileDiff:(c,r,y)=>Rl(`/api/commit/file-diff?${Dl({repo:c,oid:r,path:y})}`),browse:c=>Rl(`/api/browse${c?`?${Dl({path:c})}`:""}`),mkdir:(c,r)=>ui("/api/mkdir",{path:c,name:r}).then(y=>y.path),open:c=>ui("/api/repos",{path:c}).then(r=>r.repo),close:async c=>{const r=await si(`/api/repos?${Dl({repo:c})}`,{method:"DELETE",credentials:"same-origin"});if(!r.ok)throw new Yn(r.status,`could not close (${r.status})`)}};function Ih(c,r){const y=new EventSource(`/api/events?${Dl({repo:c})}`);return y.addEventListener("status",s=>{try{const z=JSON.parse(s.data);z.version===om&&r(z)}catch{}}),()=>y.close()}let Bl=[],Ph=1;const Yf=new Set;function Lf(){const c=Bl;Yf.forEach(r=>r(c))}function tv(c){return Yf.add(c),c(Bl),()=>{Yf.delete(c)}}function lm(c){const r=Bl.filter(y=>y.id!==c);r.length!==Bl.length&&(Bl=r,Lf())}function qf(c,r){const y=Bl.findIndex(z=>z.kind===c&&z.message===r);if(y!==-1){const z=Bl[y];return Bl=Bl.map((E,_)=>_===y?{...E,bump:E.bump+1}:E),Lf(),z.id}const s=Ph++;return Bl=[...Bl,{id:s,kind:c,message:r,bump:0}].slice(-4),Lf(),s}const Xf={error:c=>qf("error",c),info:c=>qf("info",c),success:c=>qf("success",c)},ci=[{name:"yellow",color:"#d9a441"},{name:"cyan",color:"#03c4db"},{name:"green",color:"#77c47a"},{name:"magenta",color:"#dc8fd5"},{name:"blue",color:"#87acfd"}],vm="nightcrow.viewer.accent";function fi(c){if(!Number.isFinite(c))return 0;const r=ci.length;return(Math.trunc(c)%r+r)%r}function lv(){try{const c=localStorage.getItem(vm);return c===null?0:fi(Number(c))}catch{return 0}}function em(c){try{localStorage.setItem(vm,String(c))}catch{}}function ev(){const[c,r]=U.useState(lv);U.useLayoutEffect(()=>{document.documentElement.style.setProperty("--color-accent",ci[c].color)},[c]);const y=U.useCallback(()=>{r(z=>{const E=fi(z+1);return em(E),Gt.setAccent(E).catch(()=>{}),E})},[]),s=U.useCallback(z=>{r(E=>{const _=fi(z);return _===E?E:(em(_),_)})},[]);return{accent:ci[c],next:ci[fi(c+1)],cycle:y,adopt:s}}const ym="nightcrow.sidebarWidth",Gf=280,gm=720,Qf=460,Sm=.5;function Zf(c){return Math.min(Math.max(Math.round(c),Gf),gm)}function am(c){let r=gm;try{r=Math.min(r,Math.round(window.innerWidth*Sm))}catch{}return Math.min(Math.max(Math.round(c),Gf),Math.max(r,Gf))}function av(){try{const c=Number(localStorage.getItem(ym));return Number.isFinite(c)&&c>0?Zf(c):Qf}catch{return Qf}}function ii(c){try{localStorage.setItem(ym,String(c))}catch{}}function nv(){const[c,r]=U.useState(av),y=U.useCallback(_=>{const H=am(_);r(H),ii(H)},[]),s=U.useCallback(_=>{const H=am(_);r(H),ii(H),Gt.setSidebarWidth(H).catch(()=>{})},[]),z=U.useCallback(()=>{const _=Zf(Qf);r(_),ii(_),Gt.setSidebarWidth(_).catch(()=>{})},[]),E=U.useCallback(_=>{r(H=>{const M=Zf(_);return M===H?H:(ii(M),M)})},[]);return{width:c,resize:y,commit:s,reset:z,adopt:E}}const uv=5e3,bm=1e3;function iv(c,r){return c===void 0||c<=0?0:c-r}const cv=bm;function fv(c,r,y){const s=iv(r,y);return c===null||Math.abs(s-c)>=cv?s:c}function pm(c,r,y){if(c===void 0)return"cool";const s=Math.max(0,r-c);return s>=y?"cool":spm(s,r,y)!=="cool")}const sv={fresh:"text-accent font-bold",warm:"text-accent",cool:""};function um(c,r,y){const[s,z]=U.useState(()=>Date.now()+y);return U.useEffect(()=>{if(r<=0||!c)return;const E=c.map(M=>M.mtime),_=Date.now()+y;if(z(_),!nm(E,_,r))return;const H=setInterval(()=>{const M=Date.now()+y;z(M),nm(E,M,r)||clearInterval(H)},bm);return()=>clearInterval(H)},[c,r,y]),s}const im=3e3;function rv({authed:c,setAuthed:r,handle:y,adoptAccent:s,adoptSidebarWidth:z,draggingRef:E,accentWrites:_,sidebarWrites:H,resumeTick:M}){const[v,j]=U.useState([]),[D,R]=U.useState(null),[Q,nt]=U.useState(null),[P,J]=U.useState(null),[ut,et]=U.useState(!1);return U.useEffect(()=>{let L=!1,W;const rt=new AbortController,I=()=>{const Z=_.current,ot=H.current;return Gt.repos(rt.signal).then(({repos:ht,hot:tl,accent:Qt,sidebar_width:jt,now_ms:wt})=>{L||(nt(tl),J(Ct=>fv(Ct,wt,Date.now())),_.current===Z&&s(Qt),H.current===ot&&!E.current&&z(jt),r(!0),et(!0),j(ht),R(Ct=>Ct&&ht.some(Zt=>Zt.id===Ct)?Ct:ht[0]?.id??null),L||(W=setTimeout(I,im)))}).catch(ht=>{L||(dm(ht)?(r(!1),et(!1)):Fh(ht)||y(ht),W=setTimeout(I,im))})};return I(),()=>{L=!0,rt.abort(),W&&clearTimeout(W)}},[c,r,y,s,z,M,_,H,E]),{repos:v,setRepos:j,repo:D,setRepo:R,hot:Q,clockSkewMs:P,reposLoaded:ut}}function ov({repo:c,authed:r,resumeTick:y,tab:s,pane:z,setPane:E,handle:_,paneRequestRef:H}){const[M,v]=U.useState(null),j=U.useRef(z);j.current=z;const D=U.useRef(s);return D.current=s,U.useEffect(()=>{v(null)},[c,r]),U.useEffect(()=>{if(!(!c||!r))return Ih(c,v)},[c,r,y]),U.useEffect(()=>{if(!c||!M)return;const R=j.current;if(D.current!=="status"||R.kind!=="diff")return;const Q=R.value.path;if(!M.files.some(ut=>ut.path===Q)){E({kind:"empty"});return}const nt=H.current;let P=!0;const J=()=>{const ut=j.current;return P&&nt===H.current&&ut.kind==="diff"&&ut.value.path===Q};return Gt.diff(c,Q).then(ut=>{J()&&E({kind:"diff",value:ut})}).catch(ut=>{J()&&_(ut)}),()=>{P=!1}},[M,c,_,j,D,H,E]),{status:M,paneRef:j,tabRef:D}}const dv=3,mv=400;function hv({sidebarRef:c,sidebarWidth:r,resizeSidebar:y,commitSidebarWidth:s,resetSidebarWidth:z,bumpSidebarWrites:E}){const _=U.useRef(0),H=U.useRef(0),M=U.useRef(0),v=U.useRef(!1),j=U.useRef(!1),D=U.useRef(0),[R,Q]=U.useState(!1),nt=U.useCallback(et=>{if(et.button!==0||!et.isPrimary)return;const L=c.current?.getBoundingClientRect().left;L!==void 0&&(_.current=L,H.current=et.clientX,M.current=r,v.current=!0,j.current=!1,E(),Q(!0),et.currentTarget.setPointerCapture(et.pointerId),et.preventDefault())},[r,c,E]),P=U.useCallback(et=>{v.current&&(!j.current&&Math.abs(et.clientX-H.current){if(!v.current)return;if(v.current=!1,Q(!1),j.current){s(M.current),D.current=0;return}const et=Date.now();et-D.current{v.current=!1,j.current=!1,D.current=0,Q(!1)},[]);return{draggingSidebar:R,onSidebarDragStart:nt,onSidebarDragMove:P,onSidebarDragEnd:J,onSidebarDragCancel:ut,draggingRef:v}}function vv({repo:c,handle:r,setPane:y,paneRequestRef:s,setCommitDrillDown:z}){const E=U.useCallback(j=>{if(!c)return;const D=s.current+=1;Gt.diff(c,j).then(R=>{D===s.current&&y({kind:"diff",value:R})}).catch(R=>{D===s.current&&r(R)})},[c,r,y,s]),_=U.useCallback(j=>{if(!c)return;const D=s.current+=1;Gt.file(c,j).then(R=>{D===s.current&&y({kind:"file",value:R})}).catch(R=>{D===s.current&&r(R)})},[c,r,y,s]),H=U.useCallback(j=>{if(!c)return;const D=s.current+=1;Gt.commit(c,j).then(R=>{D===s.current&&y({kind:"diff",value:R})}).catch(R=>{D===s.current&&r(R)})},[c,r,y,s]),M=U.useCallback((j,D)=>{if(!c)return;const R=s.current+=1;Gt.commitFileDiff(c,j,D).then(Q=>{R===s.current&&y({kind:"diff",value:Q})}).catch(Q=>{R===s.current&&r(Q)})},[c,r,y,s]),v=U.useCallback(async j=>{if(!c)return;const D=s.current+=1;try{const R=await Gt.commitFiles(c,j.oid);if(D!==s.current)return;if(z({commit:j,...R}),R.files.length===0){y({kind:"empty"});return}const Q=await Gt.commit(c,j.oid);D===s.current&&y({kind:"diff",value:Q})}catch(R){D===s.current&&r(R)}},[c,r,y,s,z]);return{openDiff:E,openFile:_,openCommit:H,openCommitFileDiff:M,openCommitFiles:v}}function yv({repo:c,authed:r,tab:y,filter:s,handle:z}){const[E,_]=U.useState([]),[H,M]=U.useState(!1),[v,j]=U.useState(!1),D=U.useRef(null),R=U.useRef(!1),Q=U.useRef(0),nt=U.useCallback(()=>{Q.current+=1,R.current=!1,_([]),D.current=null,M(!1),j(!1)},[]),[P,J]=U.useState(null),ut=U.useRef(E);ut.current=E;const et=U.useCallback(async()=>{if(!c||R.current)return;R.current=!0;const I=Q.current;try{const Z=D.current,ot=await Gt.log(c,Z===null?void 0:{from:Z,skip:ut.current.length});if(I!==Q.current)return;_(ht=>[...ht,...ot.commits]),D.current=ot.head??null,M(!ot.truncated||ot.head===void 0)}catch(Z){I===Q.current&&(z(Z),j(!0))}finally{I===Q.current&&(R.current=!1)}},[c,z]);U.useEffect(()=>{!c||!r||y!=="log"||E.length===0&&!H&&!v&&et()},[c,r,y,E.length,H,v,et]);const L=E.filter(I=>I.summary.toLowerCase().includes(s.toLowerCase())),W=s!=="",rt=U.useRef(null);return U.useEffect(()=>{const I=rt.current;if(!I)return;const Z=new IntersectionObserver(ot=>{ot.some(ht=>ht.isIntersecting)&&et()},{root:I.closest("ul"),rootMargin:"400px"});return Z.observe(I),()=>Z.disconnect()},[et,H,v,W,P,y,L.length]),{commits:E,logDone:H,logStalled:v,setLogStalled:j,commitDrillDown:P,setCommitDrillDown:J,resetLog:nt,logSentinelRef:rt,visibleCommits:L,logPagingPaused:W}}function gv(){const[c,r]=U.useState(0);return U.useEffect(()=>{const y=()=>{document.visibilityState==="visible"&&r(s=>s+1)};return document.addEventListener("visibilitychange",y),window.addEventListener("online",y),()=>{document.removeEventListener("visibilitychange",y),window.removeEventListener("online",y)}},[]),c}function Sv(c){const[r,y]=U.useState({}),s=U.useCallback(_=>{c!=null&&y(H=>{const M=H[c]??"none",v=typeof _=="function"?_(M):_;return{...H,[c]:v}})},[c]),z=c!=null&&r[c]||"none",E=U.useCallback(_=>{y(({[_]:H,...M})=>M)},[]);return{maximized:z,setMaximized:s,dropMaximized:E}}function bv({repos:c,setRepos:r,setRepo:y,setPane:s,setTab:z,setPickerOpen:E,dropMaximized:_,handle:H}){const M=U.useCallback(j=>{r(D=>D.some(R=>R.id===j.id)?D:[...D,j]),y(j.id),s({kind:"empty"}),z("status"),E(!1)},[r,y,s,z,E]),v=U.useCallback(async j=>{try{await Gt.close(j);const D=c.filter(R=>R.id!==j);r(D),y(R=>R===j?D[0]?.id??null:R),_(j)}catch(D){H(D)}},[c,r,y,_,H]);return{selectOpenedRepo:M,closeRepo:v}}function kf({className:c}){return o.jsx("span",{className:`block overflow-hidden rounded-[20.7%] bg-accent ${c??""}`,children:o.jsx("img",{src:"/crow-mono.svg",alt:"","aria-hidden":"true",className:"h-full w-full"})})}function pv({maximized:c}){return o.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:"h-4 w-4",children:c?o.jsxs(o.Fragment,{children:[o.jsx("path",{d:"M8 3v3a2 2 0 0 1-2 2H3"}),o.jsx("path",{d:"M21 8h-3a2 2 0 0 1-2-2V3"}),o.jsx("path",{d:"M3 16h3a2 2 0 0 1 2 2v3"}),o.jsx("path",{d:"M16 21v-3a2 2 0 0 1 2-2h3"})]}):o.jsxs(o.Fragment,{children:[o.jsx("path",{d:"M8 3H5a2 2 0 0 0-2 2v3"}),o.jsx("path",{d:"M21 8V5a2 2 0 0 0-2-2h-3"}),o.jsx("path",{d:"M3 16v3a2 2 0 0 0 2 2h3"}),o.jsx("path",{d:"M16 21h3a2 2 0 0 0 2-2v-3"})]})})}function xm({open:c}){return o.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:`h-3.5 w-3.5 shrink-0 transition-transform ${c?"rotate-90":""}`,children:o.jsx("path",{d:"m9 18 6-6-6-6"})})}function ri({className:c="h-4 w-4"}){return o.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:`shrink-0 ${c}`,children:[o.jsx("path",{d:"M18 6 6 18"}),o.jsx("path",{d:"m6 6 12 12"})]})}function Em({className:c="h-4 w-4"}){return o.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:`shrink-0 ${c}`,children:[o.jsx("path",{d:"M5 12h14"}),o.jsx("path",{d:"M12 5v14"})]})}function xv(){return o.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:"h-4 w-4",children:[o.jsx("rect",{width:"18",height:"18",x:"3",y:"3",rx:"2"}),o.jsx("path",{d:"M12 3v18"})]})}function Ev(){return o.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:"h-4 w-4",children:[o.jsx("path",{d:"M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"}),o.jsx("circle",{cx:"12",cy:"12",r:"3"})]})}function zv(){return o.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round","aria-hidden":"true",focusable:"false",className:"h-4 w-4",children:[o.jsx("circle",{cx:"11",cy:"11",r:"8"}),o.jsx("path",{d:"m21 21-4.3-4.3"})]})}function Tv({repos:c,currentId:r,onSelect:y,onCloseProject:s,onOpenPicker:z,className:E=""}){const[_,H]=U.useState(!1),M=U.useRef(null),v=c.find(j=>j.id===r);return U.useEffect(()=>{if(!_)return;const j=D=>{D.key==="Escape"&&(H(!1),M.current?.focus())};return document.addEventListener("keydown",j),()=>document.removeEventListener("keydown",j)},[_]),o.jsxs("div",{className:`relative ${E}`,children:[o.jsxs("button",{ref:M,onClick:()=>H(j=>!j),"aria-haspopup":"menu","aria-expanded":_,title:v?.display_path??"Select a project",className:"flex max-w-[9rem] items-center gap-1 rounded-sm bg-ink-700 py-0.5 pl-2 pr-1 text-ink-50",children:[o.jsx("span",{className:"truncate",children:v?.name??"No project"}),o.jsx(xm,{open:_})]}),_&&o.jsxs(o.Fragment,{children:[o.jsx("div",{className:"fixed inset-0 z-40",onClick:()=>H(!1)}),o.jsxs("div",{role:"menu",className:"absolute left-0 z-50 mt-1 max-h-[70vh] w-56 max-w-[80vw] overflow-y-auto rounded-md border border-ink-700 bg-ink-900 py-1 shadow-lg",children:[c.length===0&&o.jsx("p",{className:"px-3 py-1.5 text-ink-400",children:"No projects open."}),c.map(j=>o.jsxs("div",{className:`flex items-center ${j.id===r?"bg-ink-700 text-ink-50":"text-ink-200"}`,children:[o.jsx("button",{role:"menuitem",onClick:()=>{y(j.id),H(!1)},title:j.display_path,className:"min-w-0 flex-1 truncate py-1.5 pl-3 pr-1 text-left hover:text-accent",children:j.name}),o.jsx("button",{onClick:()=>s(j.id),"aria-label":`close ${j.name}`,title:"Close project",className:"mr-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:text-removed",children:o.jsx(ri,{className:"h-3.5 w-3.5"})})]},j.id)),o.jsx("div",{className:"my-1 border-t border-ink-800"}),o.jsxs("button",{role:"menuitem",onClick:()=>{z(),H(!1)},className:"flex w-full items-center gap-1 px-3 py-1.5 text-left text-ink-400 hover:text-ink-200",children:[o.jsx(Em,{className:"h-3.5 w-3.5"}),"open"]})]})]})]})}function _v({repos:c,repo:r,setRepo:y,setPane:s,closeRepo:z,setPickerOpen:E,accent:_,next:H,cycle:M}){return o.jsxs("header",{className:"flex items-center gap-2 border-b border-ink-700 bg-ink-900 px-[12.8px] py-[8.8px]",children:[o.jsx(kf,{className:"h-[22px] w-[22px] shrink-0"}),o.jsx("span",{className:"text-[16px] font-medium tracking-[0.04em] text-ink-50",children:"nightcrow"}),o.jsx("span",{className:"hidden font-sans text-[10px] uppercase tracking-[0.18em] text-ink-400 sm:inline",children:"web viewer"}),o.jsx(Tv,{className:"md:hidden",repos:c,currentId:r,onSelect:v=>{y(v),s()},onCloseProject:z,onOpenPicker:()=>E(!0)}),o.jsx("nav",{className:"-my-[8.8px] hidden items-stretch self-stretch overflow-x-auto pl-1 md:flex",children:c.map(v=>o.jsxs("div",{className:`flex items-center border-r border-ink-700 whitespace-nowrap ${v.id===r?"bg-ink-950 text-ink-50 shadow-[inset_0_2px_0_0_var(--color-accent)]":"text-ink-400 hover:bg-ink-850 hover:text-ink-200"}`,title:v.display_path,children:[o.jsx("button",{onClick:()=>{y(v.id),s()},className:"self-stretch pl-3 pr-1",children:v.name}),o.jsx("button",{onClick:j=>{j.stopPropagation(),z(v.id)},title:"Close project","aria-label":`close ${v.name}`,className:"mr-1 flex h-5 w-5 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-removed",children:o.jsx(ri,{className:"h-3.5 w-3.5"})})]},v.id))}),o.jsxs("button",{onClick:()=>E(!0),title:"Open a project",className:"hidden shrink-0 items-center gap-1 rounded-sm px-2 py-0.5 text-ink-400 hover:text-ink-200 md:inline-flex",children:[o.jsx(Em,{className:"h-3.5 w-3.5"}),"open"]}),o.jsx("button",{onClick:M,title:`Accent: ${_.name} (click for ${H.name})`,"aria-label":`accent colour: ${_.name}, click for ${H.name}`,className:"ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded-sm",children:o.jsx("span",{"aria-hidden":"true",className:"h-3 w-3 rounded-full bg-accent ring-1 ring-ink-600"})}),o.jsx("a",{href:"/logout",className:"pl-2 text-ink-400 hover:text-ink-200",children:"sign out"})]})}const Av="modulepreload",Nv=function(c,r){return new URL(c,r).href},cm={},zm=function(r,y,s){let z=Promise.resolve();if(y&&y.length>0){let v=function(j){return Promise.all(j.map(D=>Promise.resolve(D).then(R=>({status:"fulfilled",value:R}),R=>({status:"rejected",reason:R}))))};const _=document.getElementsByTagName("link"),H=document.querySelector("meta[property=csp-nonce]"),M=H?.nonce||H?.getAttribute("nonce");z=v(y.map(j=>{if(j=Nv(j,s),j in cm)return;cm[j]=!0;const D=j.endsWith(".css"),R=D?'[rel="stylesheet"]':"";if(s)for(let nt=_.length-1;nt>=0;nt--){const P=_[nt];if(P.href===j&&(!D||P.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${j}"]${R}`))return;const Q=document.createElement("link");if(Q.rel=D?"stylesheet":Av,D||(Q.as="script"),Q.crossOrigin="",Q.href=j,M&&Q.setAttribute("nonce",M),document.head.appendChild(Q),D)return new Promise((nt,P)=>{Q.addEventListener("load",nt),Q.addEventListener("error",()=>P(new Error(`Unable to preload CSS for ${j}`)))})}))}function E(_){const H=new Event("vite:preloadError",{cancelable:!0});if(H.payload=_,window.dispatchEvent(H),!H.defaultPrevented)throw _}return z.then(_=>{for(const H of _||[])H.status==="rejected"&&E(H.reason);return r().catch(E)})},Mv=180;function jv({repo:c,authed:r,tab:y,filter:s,filterOpen:z,handle:E}){const[_,H]=U.useState({}),[M,v]=U.useState(new Set),[j,D]=U.useState([]),[R,Q]=U.useState(!1),[nt,P]=U.useState(!1);U.useEffect(()=>{!c||!r||y!=="tree"||Gt.tree(c,"").then(L=>H(W=>({...W,"":L.entries}))).catch(E)},[c,r,y,E]),U.useEffect(()=>{if(!c||!r||y!=="tree"||!z||!s){D([]),Q(!1),P(!1);return}P(!0);let L=!0;const W=setTimeout(()=>{Gt.treeSearch(c,s).then(rt=>{L&&(D(rt.matches),Q(rt.truncated))}).catch(rt=>{L&&E(rt)}).finally(()=>{L&&P(!1)})},Mv);return()=>{L=!1,clearTimeout(W)}},[c,r,y,s,z,E]);const J=U.useCallback(L=>{c&&Gt.tree(c,L).then(W=>H(rt=>({...rt,[L]:W.entries}))).catch(E)},[c,E]),ut=U.useCallback(L=>{const W=!M.has(L);v(rt=>{const I=new Set(rt);return I.has(L)?I.delete(L):I.add(L),I}),W&&!(L in _)&&J(L)},[M,_,J]),et=U.useCallback(L=>{const W=L.split("/"),rt=[];let I="";for(const Z of W)I=I?`${I}/${Z}`:Z,rt.push(I);v(Z=>{const ot=new Set(Z);return rt.forEach(ht=>ot.add(ht)),ot}),rt.forEach(Z=>{Z in _||J(Z)})},[_,J]);return{treeChildren:_,setTreeChildren:H,treeExpanded:M,setTreeExpanded:v,treeMatches:j,treeTruncated:R,treeSearchLoading:nt,loadTreeChildren:J,toggleTreeDir:ut,revealTreeDir:et}}function Ov(c,r){const y=[],s=(z,E)=>{for(const _ of c[z]??[]){const H=z?`${z}/${_.name}`:_.name;y.push({path:H,name:_.name,is_dir:_.is_dir,depth:E}),_.is_dir&&r.has(H)&&s(H,E+1)}};return s("",0),y}function Kf({path:c,from:r,className:y}){return o.jsx("span",{className:`whitespace-nowrap ${y??""}`,title:r?`${r} → ${c}`:c,children:r?`${r} → ${c}`:c})}function Dv(c){const r=Math.max(0,Math.floor(Date.now()/1e3-c));return r<60?`${r}s`:r<3600?`${Math.floor(r/60)}m`:r<86400?`${Math.floor(r/3600)}h`:r<86400*30?`${Math.floor(r/86400)}d`:r<86400*365?`${Math.floor(r/(86400*30))}mo`:`${Math.floor(r/(86400*365))}y`}function Tm(c){return c==="+"?"bg-added/10":c==="-"?"bg-removed/10":""}function Vf(c){return c==="?"?"text-ink-400":c==="D"?"text-removed":c==="A"?"text-added":"text-accent"}function Cv({status:c,files:r,now:y,hotWindowMs:s,openDiff:z}){return c===null?o.jsx("li",{className:"px-3 py-2 text-ink-400",children:"Loading…"}):o.jsx(o.Fragment,{children:r.map(E=>o.jsx("li",{children:o.jsxs("button",{onClick:()=>z(E.path),className:"flex w-max min-w-full gap-2 px-3 py-0.5 text-left hover:bg-ink-850",children:[o.jsxs("span",{className:"shrink-0",children:[o.jsx("span",{className:Vf(E.index),children:E.index===" "?" ":E.index}),o.jsx("span",{className:Vf(E.worktree),children:E.worktree===" "?" ":E.worktree})]}),o.jsx(Kf,{path:E.path,from:E.old_path,className:sv[pm(E.mtime,y,s)]})]})},E.path))})}function Uv({visibleCommits:c,commits:r,aheadOids:y,commitDrillDown:s,visibleCommitFiles:z,logDone:E,logStalled:_,logPagingPaused:H,setLogStalled:M,logSentinelRef:v,openCommitFiles:j,openCommit:D,openCommitFileDiff:R,setCommitDrillDown:Q,setPaneEmpty:nt,bumpPaneRequest:P}){return o.jsxs(o.Fragment,{children:[!s&&c.map(J=>o.jsx("li",{children:o.jsxs("button",{onClick:()=>{j(J)},title:`${J.author} · ${J.summary}`,className:"flex w-max min-w-full items-baseline gap-2 px-3 py-0.5 text-left hover:bg-ink-850",children:[o.jsx("span",{className:"w-2 shrink-0 text-added",children:y.has(J.oid)?"↑":""}),o.jsx("span",{className:"shrink-0 text-accent",children:J.short_id}),o.jsx("span",{className:"w-10 shrink-0 text-right text-ink-400",children:Dv(J.time)}),o.jsx("span",{className:"max-w-[6rem] shrink-0 truncate text-ink-400",children:J.author}),o.jsx("span",{className:"whitespace-nowrap",children:J.summary})]})},J.oid)),!s&&!E&&!_&&!H&&o.jsx("li",{ref:v,className:"px-3 py-1 text-ink-400","aria-hidden":"true",children:"loading…"}),!s&&!E&&!_&&H&&o.jsxs("li",{className:"px-3 py-1 text-ink-400",children:["filtering ",r.length," loaded commits — clear the filter to load more"]}),!s&&_&&o.jsx("li",{className:"px-3 py-1",children:o.jsx("button",{onClick:()=>M(!1),className:"text-ink-400 hover:text-accent",children:"could not load more — retry"})}),s&&o.jsxs(o.Fragment,{children:[o.jsxs("li",{className:"sticky top-0 z-10 flex w-max min-w-full items-center gap-1 bg-ink-900 px-2 py-1 text-ink-400",children:[o.jsx("button",{onClick:()=>{P(),Q(null),nt()},className:"rounded-sm px-1 hover:text-accent",title:"Back to commit log",children:"< log"}),o.jsx("span",{className:"text-ink-600",children:"·"}),o.jsx("span",{className:"shrink-0 text-accent",children:s.commit.short_id}),o.jsx("button",{onClick:()=>D(s.commit.oid),className:"rounded-sm px-1 hover:text-accent",title:"Show the complete commit diff",children:"all changes"})]}),z.map(J=>o.jsx("li",{children:o.jsxs("button",{onClick:()=>R(s.commit.oid,J.path),className:"flex w-max min-w-full gap-2 px-3 py-0.5 text-left hover:bg-ink-850",children:[o.jsx("span",{className:Vf(J.index),children:J.index}),o.jsx(Kf,{path:J.path,from:J.old_path})]})},J.path)),s.files.length===0&&o.jsx("li",{className:"px-3 py-2 text-ink-400",children:"No changed files."}),s.files.length>0&&z.length===0&&o.jsx("li",{className:"px-3 py-2 text-ink-400",children:"No matching files."}),s.truncated&&o.jsxs("li",{className:"px-3 py-1 text-accent",children:["Showing the first ",s.files.length," files."]})]})]})}function Hv({treeSearching:c,treeMatches:r,treeTruncated:y,treeSearchLoading:s,treeRows:z,treeExpanded:E,openFile:_,revealTreeDir:H,toggleTreeDir:M}){return c?o.jsxs(o.Fragment,{children:[r.map(v=>o.jsx("li",{children:o.jsx("button",{onClick:()=>{v.is_dir?H(v.path):_(v.path)},title:v.path,className:"w-max min-w-full whitespace-nowrap px-3 py-0.5 text-left hover:bg-ink-850",children:v.is_dir?o.jsxs("span",{className:"text-accent",children:[v.path,"/"]}):v.path})},v.path)),r.length===0&&o.jsx("li",{className:"px-3 py-0.5 text-ink-400",children:s?"searching…":"no matches"}),y&&o.jsxs("li",{className:"px-3 py-0.5 text-ink-400",children:["showing the first ",r.length," matches"]})]}):o.jsx(o.Fragment,{children:z.map(v=>o.jsx("li",{children:o.jsxs("button",{onClick:()=>v.is_dir?M(v.path):_(v.path),title:v.path,style:{paddingLeft:`${v.depth*.75+.5}rem`},className:"flex w-max min-w-full items-center gap-1 py-0.5 pr-3 text-left hover:bg-ink-850",children:[v.is_dir?o.jsx(xm,{open:E.has(v.path)}):o.jsx("span",{className:"h-3.5 w-3.5 shrink-0"}),o.jsx("span",{className:`whitespace-nowrap ${v.is_dir?"text-accent":""}`,children:v.is_dir?`${v.name}/`:v.name})]})},v.path))})}function Rv(c){const{tab:r,setTab:y,filter:s,setFilter:z,filterOpen:E,setFilterOpen:_,status:H,files:M,now:v,hotWindowMs:j,setPane:D,openDiff:R,openFile:Q,openCommit:nt,openCommitFileDiff:P,openCommitFiles:J,repo:ut,authed:et,handle:L,sidebarRef:W,draggingSidebar:rt,onSidebarDragStart:I,onSidebarDragMove:Z,onSidebarDragEnd:ot,onSidebarDragCancel:ht,filesMax:tl,bumpPaneRequest:Qt,commits:jt,logDone:wt,logStalled:Ct,setLogStalled:Zt,commitDrillDown:T,setCommitDrillDown:B,resetLog:w,logSentinelRef:mt,visibleCommits:vt,logPagingPaused:h,aheadOids:O,visibleCommitFiles:q}=c,Y=jv({repo:ut,authed:et,tab:r,filter:s,filterOpen:E,handle:L}),$=r==="tree"&&E&&s!=="",k=Ov(Y.treeChildren,Y.treeExpanded);return o.jsxs("section",{ref:W,className:`relative min-h-0 flex-col overflow-hidden ${tl?"hidden md:flex":"flex border-ink-700 md:border-r"}`,children:[!tl&&o.jsx("div",{role:"separator","aria-orientation":"vertical","aria-label":"Resize the file sidebar (double-click to reset)",title:"Drag to resize · double-click to reset",onPointerDown:I,onPointerMove:Z,onPointerUp:ot,onPointerCancel:ht,onLostPointerCapture:ot,className:`absolute -right-px top-0 z-10 hidden h-full w-1.5 cursor-col-resize touch-none md:block ${rt?"bg-accent":"hover:bg-accent"}`}),o.jsxs("div",{className:"flex shrink-0 items-stretch border-b border-ink-700 px-2",children:[["status","log","tree"].map(F=>o.jsx("button",{onClick:()=>{F!==r&&(Qt(),r==="log"&&(B(null),w()),y(F),D({kind:"empty"}))},"aria-current":F===r?"page":void 0,className:`-mb-px border-b-2 px-2 py-1 ${F===r?"border-accent text-ink-50":"border-transparent text-ink-400 hover:text-ink-200"}`,children:F},F)),o.jsx("button",{onClick:()=>{E&&z(""),_(F=>!F)},"aria-pressed":E,title:E?"Hide the filter":"Filter the list","aria-label":E?"Hide the filter":"Filter the list",className:`my-1 ml-auto flex shrink-0 items-center rounded-sm px-1.5 hover:text-accent ${E?"text-ink-50":"text-ink-400"}`,children:o.jsx(zv,{})})]}),E&&o.jsx("input",{value:s,onChange:F=>z(F.target.value),placeholder:"filter…",autoFocus:!0,className:"mx-2 mb-1 shrink-0 rounded-sm bg-ink-850 px-2 py-1 outline-none placeholder:text-ink-400 focus:ring-1 focus:ring-accent"}),o.jsxs("ul",{className:"min-h-0 flex-1 overflow-auto",children:[r==="status"&&o.jsx(Cv,{status:H,files:M,now:v,hotWindowMs:j,openDiff:R}),r==="log"&&o.jsx(Uv,{visibleCommits:vt,commits:jt,aheadOids:O,commitDrillDown:T,visibleCommitFiles:q,logDone:wt,logStalled:Ct,logPagingPaused:h,setLogStalled:Zt,logSentinelRef:mt,openCommitFiles:J,openCommit:nt,openCommitFileDiff:P,setCommitDrillDown:B,setPaneEmpty:()=>D({kind:"empty"}),bumpPaneRequest:Qt}),r==="tree"&&o.jsx(Hv,{treeSearching:$,treeMatches:Y.treeMatches,treeTruncated:Y.treeTruncated,treeSearchLoading:Y.treeSearchLoading,treeRows:k,treeExpanded:Y.treeExpanded,openFile:Q,revealTreeDir:Y.revealTreeDir,toggleTreeDir:Y.toggleTreeDir})]})]})}const _m="nightcrow.viewer.diffLayout",Am=768;function Bv(c){const r=[];let y=[],s=[];const z=()=>{const E=Math.max(y.length,s.length);for(let _=0;_{let y;try{y=window.matchMedia(`(min-width: ${Am}px)`)}catch{return}const s=z=>r(z.matches);return y.addEventListener("change",s),()=>y.removeEventListener("change",s)},[]),c}function Gv(){const[c,r]=U.useState(qv),y=Xv(),s=U.useCallback(()=>{r(E=>{const _=E==="split"?"unified":"split";return Yv(_),_})},[]);return{layout:c,effective:c==="split"&&y?"split":"unified",toggle:s}}const Qv=[".md",".markdown"];function fm(c){const r=c.toLowerCase();return Qv.some(y=>r.endsWith(y))}function Zv(c){return c.map(r=>r.map(y=>y.t).join("")).join(` -`)}function Nm({line:c}){return o.jsxs(o.Fragment,{children:[o.jsx("span",{className:"text-ink-400 select-none",children:c.kind}),c.spans.map((r,y)=>o.jsx("span",{style:{color:r.c},children:r.t},y))]})}function Vv({line:c}){return c===null?o.jsx("div",{className:"whitespace-pre bg-ink-900/40 px-3",children:" "}):o.jsx("div",{className:`whitespace-pre px-3 ${Tm(c.kind)}`,children:o.jsx(Nm,{line:c})})}function sm({cells:c,border:r}){const y=r?"border-l border-ink-800":"";return o.jsx("div",{className:`min-w-0 flex-1 basis-1/2 overflow-x-auto ${y}`,children:o.jsx("div",{className:"w-max min-w-full",children:c.map((s,z)=>o.jsx(Vv,{line:s},z))})})}function wv({lines:c}){const r=Bv(c);return o.jsxs("div",{className:"flex",children:[o.jsx(sm,{cells:r.map(y=>y.left),border:!1}),o.jsx(sm,{cells:r.map(y=>y.right),border:!0})]})}function kv({diff:c,split:r}){return o.jsxs("div",{className:"p-1",children:[c.hunks.length===0&&o.jsx("p",{className:"p-3 text-ink-400",children:"No changes."}),c.hunks.map((y,s)=>o.jsxs("div",{className:"mb-2",children:[o.jsxs("div",{className:"bg-ink-850 px-3 py-0.5 text-ink-400",children:[y.file_path?`${y.file_path} `:"",y.header]}),r?o.jsx(wv,{lines:y.lines}):y.lines.map((z,E)=>o.jsx("div",{className:`px-3 whitespace-pre ${Tm(z.kind)}`,children:o.jsx(Nm,{line:z})},E))]},s)),c.truncated&&o.jsx("p",{className:"p-3 text-accent",children:"Diff truncated — it exceeded the server's size ceiling."})]})}const Kv=U.lazy(()=>zm(()=>import("./Markdown-BYxaSXJW.js"),__vite__mapDeps([0,1]),import.meta.url).then(c=>({default:c.MarkdownView})));function Jv({pane:c,mdRendered:r,setMdRendered:y,filesMax:s,setMaximized:z,status:E}){const _=Gv();return o.jsxs("section",{className:"flex min-h-0 min-w-0 flex-col",children:[o.jsxs("div",{className:"flex shrink-0 items-center gap-2 bg-ink-850 px-3 py-0.5 text-ink-400",children:[c.kind==="file"&&o.jsx(Kf,{path:c.value.path}),o.jsxs("div",{className:"ml-auto flex shrink-0 items-center gap-1",children:[c.kind==="diff"&&o.jsx("button",{onClick:_.toggle,"aria-pressed":_.layout==="split",title:_.layout==="split"?"Switch to unified diff":"Switch to split diff","aria-label":_.layout==="split"?"Switch to unified diff":"Switch to split diff",className:`flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent ${_.layout==="split"?"text-accent":""}`,children:o.jsx(xv,{})}),c.kind==="file"&&fm(c.value.path)&&o.jsx("button",{onClick:()=>y(H=>!H),"aria-pressed":r,title:r?"Show raw source":"Show rendered markdown","aria-label":r?"Show raw source":"Show rendered markdown",className:`flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent ${r?"text-accent":""}`,children:o.jsx(Ev,{})}),o.jsx("button",{onClick:()=>z(s?"none":"files"),"aria-pressed":s,title:s?"Restore the layout":"Maximize the file pane","aria-label":s?"Restore the layout":"Maximize the file pane",className:"flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent",children:o.jsx(pv,{maximized:s})})]})]}),o.jsxs("div",{className:"min-h-0 flex-1 overflow-auto",children:[c.kind==="empty"&&o.jsx("p",{className:"p-4 text-ink-400",children:E===null?"Loading…":"Select a file or commit."}),c.kind==="file"&&o.jsxs(o.Fragment,{children:[fm(c.value.path)&&r?o.jsx(U.Suspense,{fallback:o.jsx("p",{className:"p-4 text-ink-400",children:"Rendering…"}),children:o.jsx(Kv,{source:Zv(c.value.lines)})}):o.jsx("pre",{className:"p-3 whitespace-pre text-ink-200",children:c.value.lines.map((H,M)=>o.jsx("div",{children:H.length===0?" ":H.map((v,j)=>o.jsx("span",{style:{color:v.c},children:v.t},j))},M))}),c.value.truncated&&o.jsx("p",{className:"p-3 text-accent",children:"File truncated — it exceeded the server's size ceiling."})]}),c.kind==="diff"&&o.jsx(kv,{diff:c.value,split:_.effective==="split"})]})]})}const $v=U.lazy(()=>zm(()=>import("./Terminal-Dhwxs9P9.js"),[],import.meta.url).then(c=>({default:c.TerminalPanel})));function Wv(c){const{repo:r,current:y,status:s,files:z,now:E,hotWindowMs:_,pane:H,setPane:M,tab:v,setTab:j,filter:D,setFilter:R,filterOpen:Q,setFilterOpen:nt,openDiff:P,openFile:J,openCommit:ut,openCommitFileDiff:et,openCommitFiles:L,authed:W,handle:rt,sidebarWidth:I,sidebarRef:Z,draggingSidebar:ot,onSidebarDragStart:ht,onSidebarDragMove:tl,onSidebarDragEnd:Qt,onSidebarDragCancel:jt,filesMax:wt,bumpPaneRequest:Ct,commits:Zt,logDone:T,logStalled:B,setLogStalled:w,commitDrillDown:mt,setCommitDrillDown:vt,resetLog:h,logSentinelRef:O,visibleCommits:q,logPagingPaused:Y,aheadOids:$,visibleCommitFiles:k,mdRendered:F,setMdRendered:Bt,maximized:_t,setMaximized:ql}=c;return o.jsxs(o.Fragment,{children:[ot&&o.jsx("div",{className:"fixed inset-0 z-50 cursor-col-resize"}),o.jsxs("main",{className:`grid min-h-0 grid-cols-1 md:grid-cols-[var(--nc-sidebar)_1fr] ${ot?"select-none":""}`,style:{"--nc-sidebar":wt?"0px":`min(${I}px, ${Sm*100}vw)`},children:[o.jsx(Rv,{tab:v,setTab:j,filter:D,setFilter:R,filterOpen:Q,setFilterOpen:nt,status:s,files:z,now:E,hotWindowMs:_,setPane:M,openDiff:P,openFile:J,openCommit:ut,openCommitFileDiff:et,openCommitFiles:L,repo:r,authed:W,handle:rt,sidebarRef:Z,draggingSidebar:ot,onSidebarDragStart:ht,onSidebarDragMove:tl,onSidebarDragEnd:Qt,onSidebarDragCancel:jt,filesMax:wt,bumpPaneRequest:Ct,commits:Zt,logDone:T,logStalled:B,setLogStalled:w,commitDrillDown:mt,setCommitDrillDown:vt,resetLog:h,logSentinelRef:O,visibleCommits:q,logPagingPaused:Y,aheadOids:$,visibleCommitFiles:k}),o.jsx(Jv,{pane:H,mdRendered:F,setMdRendered:Bt,filesMax:wt,setMaximized:ql,status:s})]}),o.jsx(U.Suspense,{fallback:null,children:o.jsx($v,{repo:r,maximized:_t==="terminal",onToggleMaximized:()=>ql(Yl=>Yl==="terminal"?"none":"terminal")})}),o.jsxs("footer",{className:"flex shrink-0 items-center gap-3 border-t border-ink-700 bg-ink-900 px-3 py-1 text-ink-400",children:[o.jsx("span",{className:"truncate",children:y?.display_path}),s?.branch&&o.jsx("span",{className:"text-accent",children:s.branch}),s?.tracking&&o.jsxs("span",{children:["↑",s.tracking.ahead," ↓",s.tracking.behind]}),o.jsx("span",{className:"ml-auto",children:s?o.jsx("span",{className:"text-added",children:"● live"}):"connecting…"})]})]})}function Fv({onClose:c,onOpened:r}){const[y,s]=U.useState(null),[z,E]=U.useState(null),[_,H]=U.useState(null),[M,v]=U.useState(!1),[j,D]=U.useState(""),[R,Q]=U.useState(!1),[nt,P]=U.useState(0);U.useEffect(()=>{let L=!1;return Gt.browse(y??void 0).then(W=>{L||(E(W),H(null))}).catch(W=>{L||H(W instanceof Error?W.message:"could not browse")}),()=>{L=!0}},[y,nt]);const J=L=>s(`${z.path.replace(/\/$/,"")}/${L}`),ut=async()=>{if(z){v(!0);try{r(await Gt.open(z.path))}catch(L){Xf.error(L instanceof Error?L.message:"could not open"),v(!1)}}},et=async()=>{if(!z)return;const L=j.trim();if(L){Q(!0);try{await Gt.mkdir(z.path,L),D(""),P(W=>W+1)}catch(W){Xf.error(W instanceof Error?W.message:"could not create folder")}finally{Q(!1)}}};return o.jsx("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4",onClick:c,children:o.jsxs("div",{className:"flex max-h-[80vh] w-[34rem] max-w-full flex-col rounded-md border border-ink-700 bg-ink-900",onClick:L=>L.stopPropagation(),children:[o.jsxs("div",{className:"flex shrink-0 items-center gap-2 border-b border-ink-700 px-3 py-2",children:[o.jsx("span",{className:"font-medium text-ink-50",children:"Open a project"}),o.jsx("button",{onClick:c,"aria-label":"close",className:"ml-auto flex h-6 w-6 items-center justify-center rounded-sm text-ink-400 hover:text-ink-200",children:o.jsx(ri,{})})]}),o.jsx("div",{className:"shrink-0 truncate border-b border-ink-700 px-3 py-1.5 text-ink-400",children:z?.path??"…"}),o.jsxs("ul",{className:"h-72 min-h-0 overflow-y-auto",children:[z?.parent&&o.jsx("li",{children:o.jsx("button",{onClick:()=>s(z.parent),className:"w-full px-3 py-1 text-left text-ink-400 hover:bg-ink-850",children:"../"})}),z?.entries.map(L=>o.jsx("li",{children:o.jsxs("button",{onClick:()=>J(L.name),className:"flex w-full items-center gap-2 px-3 py-1 text-left hover:bg-ink-850",children:[o.jsxs("span",{className:"truncate text-accent",children:[L.name,"/"]}),L.is_repo&&o.jsx("span",{className:"rounded-sm bg-ink-700 px-1 text-[0.65rem] text-ink-200",children:"git"})]})},L.name)),z&&z.entries.length===0&&o.jsx("li",{className:"px-3 py-1 text-ink-400",children:"No sub-folders."})]}),_&&o.jsx("p",{className:"shrink-0 px-3 py-1 text-removed",children:_}),o.jsxs("div",{className:"flex shrink-0 items-center gap-2 border-t border-ink-700 px-3 py-2",children:[o.jsx("input",{value:j,onChange:L=>D(L.target.value),onKeyDown:L=>{L.key==="Enter"&&et()},placeholder:"New folder name","aria-label":"new folder name",className:"min-w-0 flex-1 rounded-sm border border-ink-700 bg-ink-950 px-2 py-1 text-ink-50 placeholder:text-ink-400 focus:border-ink-600 focus:outline-none"}),o.jsx("button",{onClick:et,disabled:!z||!j.trim()||R,className:"shrink-0 rounded-sm border border-ink-700 px-2 py-1 text-ink-200 hover:bg-ink-850 disabled:opacity-50",children:R?"Creating…":"Create"})]}),o.jsxs("div",{className:"flex shrink-0 items-center gap-2 border-t border-ink-700 px-3 py-2",children:[o.jsx("span",{className:"truncate text-ink-400",children:z?z.path:""}),o.jsx("button",{onClick:ut,disabled:!z||M,className:"ml-auto shrink-0 rounded-md bg-ink-50 px-3 py-1 font-semibold text-ink-950 hover:bg-white disabled:opacity-50",children:M?"Opening…":"Open"})]})]})})}function rm(){return o.jsx("div",{className:"flex h-full items-center justify-center p-6",children:o.jsxs("div",{className:"flex flex-col items-center gap-3 text-ink-400",children:[o.jsx(kf,{className:"h-12 w-12 animate-pulse"}),o.jsx("span",{className:"text-[0.72rem] tracking-[0.18em] uppercase",children:"Loading…"})]})})}function Iv({onSuccess:c}){const[r,y]=U.useState(""),[s,z]=U.useState(null),[E,_]=U.useState(!1),H=async M=>{M.preventDefault(),_(!0),z(null);try{await Gt.login(r),c()}catch(v){z(v instanceof Error?v.message:"login failed")}finally{_(!1)}};return o.jsx("div",{className:"flex h-full items-center justify-center p-6",children:o.jsxs("form",{onSubmit:H,className:"w-[17rem] max-w-[86vw]",children:[o.jsx(kf,{className:"mx-auto mb-3 block h-10 w-10"}),o.jsx("h1",{className:"text-center text-lg font-medium tracking-wide text-ink-50",children:"nightcrow"}),o.jsx("p",{className:"mt-1 mb-5 text-center text-[0.62rem] tracking-[0.18em] text-ink-400 uppercase",children:"web viewer"}),s&&o.jsx("p",{className:"mb-2.5 text-center text-removed",children:s}),o.jsx("input",{type:"password",autoFocus:!0,value:r,onChange:M=>y(M.target.value),placeholder:"password",className:"mb-2 w-full rounded-md border border-ink-700 bg-ink-900 px-2.5 py-1.5 outline-none placeholder:text-ink-400 focus:border-accent focus:ring-[3px] focus:ring-accent/15"}),o.jsx("button",{type:"submit",disabled:E,className:"w-full rounded-md bg-ink-50 py-1.5 font-semibold text-ink-950 hover:bg-white disabled:opacity-50",children:E?"Signing in…":"Sign in"})]})})}function Pv(){const[c,r]=U.useState(null),[y,s]=U.useState("status"),[z,E]=U.useState(""),[_,H]=U.useState(!1),[M,v]=U.useState({kind:"empty"}),j=U.useRef(0),D=U.useCallback(()=>{j.current+=1},[]),[R,Q]=U.useState(!1),{accent:nt,next:P,cycle:J,adopt:ut}=ev(),et=U.useRef(0),L=U.useCallback(()=>{et.current+=1,J()},[J]),{width:W,resize:rt,commit:I,reset:Z,adopt:ot}=nv(),ht=U.useRef(0),tl=U.useCallback(Ut=>{ht.current+=1,I(Ut)},[I]),Qt=U.useCallback(()=>{ht.current+=1,Z()},[Z]),jt=U.useCallback(()=>{ht.current+=1},[]),wt=U.useRef(null),[Ct,Zt]=U.useState(!0),T=U.useCallback(Ut=>{if(dm(Ut)){r(!1);return}Xf.error(Ut instanceof Error?Ut.message:"request failed")},[]),B=gv(),{draggingSidebar:w,onSidebarDragStart:mt,onSidebarDragMove:vt,onSidebarDragEnd:h,onSidebarDragCancel:O,draggingRef:q}=hv({sidebarRef:wt,sidebarWidth:W,resizeSidebar:rt,commitSidebarWidth:tl,resetSidebarWidth:Qt,bumpSidebarWrites:jt}),{repos:Y,setRepos:$,repo:k,setRepo:F,hot:Bt,clockSkewMs:_t,reposLoaded:ql}=rv({authed:c,setAuthed:r,handle:T,adoptAccent:ut,adoptSidebarWidth:ot,draggingRef:q,accentWrites:et,sidebarWrites:ht,resumeTick:B}),Yl=Bt?.enabled?Bt.window_secs*1e3:0;um(void 0,Yl,_t??0);const{status:ce}=ov({repo:k,authed:c,resumeTick:B,tab:y,pane:M,setPane:v,handle:T,paneRequestRef:j}),Ln=um(ce?.files,Yl,_t??0),{maximized:bl,setMaximized:La,dropMaximized:Xa}=Sv(k),{commits:Xn,logDone:Gn,logStalled:Ga,setLogStalled:Qa,commitDrillDown:Fe,setCommitDrillDown:Ie,resetLog:Za,logSentinelRef:ll,visibleCommits:oi,logPagingPaused:Qn}=yv({repo:k,authed:c,tab:y,filter:z,handle:T}),{openDiff:Zn,openFile:Pe,openCommit:di,openCommitFileDiff:Vn,openCommitFiles:mi}=vv({repo:k,handle:T,setPane:v,paneRequestRef:j,setCommitDrillDown:Ie}),{selectOpenedRepo:hi,closeRepo:Ce}=bv({repos:Y,setRepos:$,setRepo:F,setPane:v,setTab:s,setPickerOpen:Q,dropMaximized:Xa,handle:T});if(U.useEffect(()=>{D(),Ie(null),v({kind:"empty"}),Za()},[k,Za,D,Ie]),c===null)return o.jsx(rm,{});if(!c)return o.jsx(Iv,{onSuccess:()=>r(!0)});if(!ql)return o.jsx(rm,{});const el=Y.find(Ut=>Ut.id===k),pl=z.toLowerCase(),al=(ce?.files??[]).filter(Ut=>Ut.path.toLowerCase().includes(pl)),vi=(Fe?.files??[]).filter(Ut=>Ut.path.toLowerCase().includes(pl)||Ut.old_path?.toLowerCase().includes(pl)),yi=new Set(Xn.slice(0,ce?.tracking?.ahead??0).map(Ut=>Ut.oid)),gi=bl==="files",ta=k?bl==="terminal"?"grid-rows-[auto_minmax(0,0fr)_minmax(0,1fr)_auto]":bl==="files"?"grid-rows-[auto_minmax(0,1fr)_minmax(0,0fr)_auto]":"grid-rows-[auto_minmax(0,11fr)_minmax(0,9fr)_auto]":"grid-rows-[auto_1fr]";return o.jsxs("div",{className:`nc-fade grid h-full ${ta}`,children:[o.jsx(_v,{repos:Y,repo:k,setRepo:Ut=>{F(Ut),v({kind:"empty"})},setPane:()=>v({kind:"empty"}),closeRepo:Ce,setPickerOpen:Q,accent:nt,next:P,cycle:L}),k?o.jsx(Wv,{repo:k,repos:Y,current:el,status:ce,files:al,now:Ln,hotWindowMs:Yl,pane:M,setPane:v,tab:y,setTab:s,filter:z,setFilter:E,filterOpen:_,setFilterOpen:H,openDiff:Zn,openFile:Pe,openCommit:di,openCommitFileDiff:Vn,openCommitFiles:mi,authed:c,handle:T,sidebarWidth:W,sidebarRef:wt,draggingSidebar:w,onSidebarDragStart:mt,onSidebarDragMove:vt,onSidebarDragEnd:h,onSidebarDragCancel:O,filesMax:gi,bumpPaneRequest:D,commits:Xn,logDone:Gn,logStalled:Ga,setLogStalled:Qa,commitDrillDown:Fe,setCommitDrillDown:Ie,resetLog:Za,logSentinelRef:ll,visibleCommits:oi,logPagingPaused:Qn,aheadOids:yi,visibleCommitFiles:vi,mdRendered:Ct,setMdRendered:Zt,maximized:bl,setMaximized:La}):o.jsx("div",{className:"flex items-center justify-center p-6 text-center text-ink-400",children:o.jsxs("span",{children:["No repository open. Click"," ",o.jsx("span",{className:"text-ink-200",children:"+ open"})," above to add one."]})}),R&&o.jsx(Fv,{onClose:()=>Q(!1),onOpened:hi})]})}const ty={error:7e3,info:5e3,success:5e3},ly={error:"text-removed",info:"text-accent",success:"text-added"};function ey(){const[c,r]=U.useState([]);return U.useEffect(()=>tv(r),[]),c.length===0?null:o.jsx("div",{className:"pointer-events-none fixed right-3 top-3 z-[60] flex w-80 max-w-[calc(100vw-1.5rem)] flex-col gap-2","aria-live":"polite",children:c.map(y=>o.jsx(ay,{toast:y},y.id))})}function ay({toast:c}){const[r,y]=U.useState(!1);return U.useEffect(()=>{if(r)return;const s=setTimeout(()=>lm(c.id),ty[c.kind]);return()=>clearTimeout(s)},[c.id,c.kind,c.bump,r]),o.jsxs("div",{role:c.kind==="error"?"alert":"status",className:"nc-fade pointer-events-auto flex items-start gap-2 rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-xs shadow-lg",onMouseEnter:()=>y(!0),onMouseLeave:()=>y(!1),children:[o.jsx("span",{className:`min-w-0 flex-1 break-words ${ly[c.kind]}`,children:c.message}),o.jsx("button",{type:"button",onClick:()=>lm(c.id),"aria-label":"dismiss",className:"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-ink-200",children:o.jsx(ri,{className:"h-3 w-3"})})]})}Wh.createRoot(document.getElementById("root")).render(o.jsxs(U.StrictMode,{children:[o.jsx(Pv,{}),o.jsx(ey,{})]}));export{pv as M,Em as P,ri as X,o as j,U as r,Xf as t}; +`)}function Nm({line:c}){return o.jsxs(o.Fragment,{children:[o.jsx("span",{className:"text-ink-400 select-none",children:c.kind}),c.spans.map((r,y)=>o.jsx("span",{style:{color:r.c},children:r.t},y))]})}function Vv({line:c}){return c===null?o.jsx("div",{className:"whitespace-pre bg-ink-900/40 px-3",children:" "}):o.jsx("div",{className:`whitespace-pre px-3 ${Tm(c.kind)}`,children:o.jsx(Nm,{line:c})})}function sm({cells:c,border:r}){const y=r?"border-l border-ink-800":"";return o.jsx("div",{className:`min-w-0 flex-1 basis-1/2 overflow-x-auto ${y}`,children:o.jsx("div",{className:"w-max min-w-full",children:c.map((s,z)=>o.jsx(Vv,{line:s},z))})})}function wv({lines:c}){const r=Bv(c);return o.jsxs("div",{className:"flex",children:[o.jsx(sm,{cells:r.map(y=>y.left),border:!1}),o.jsx(sm,{cells:r.map(y=>y.right),border:!0})]})}function kv({diff:c,split:r}){return o.jsxs("div",{className:"p-1",children:[c.hunks.length===0&&o.jsx("p",{className:"p-3 text-ink-400",children:"No changes."}),c.hunks.map((y,s)=>o.jsxs("div",{className:"mb-2",children:[o.jsxs("div",{className:"bg-ink-850 px-3 py-0.5 text-ink-400",children:[y.file_path?`${y.file_path} `:"",y.header]}),r?o.jsx(wv,{lines:y.lines}):y.lines.map((z,E)=>o.jsx("div",{className:`px-3 whitespace-pre ${Tm(z.kind)}`,children:o.jsx(Nm,{line:z})},E))]},s)),c.truncated&&o.jsx("p",{className:"p-3 text-accent",children:"Diff truncated — it exceeded the server's size ceiling."})]})}const Kv=U.lazy(()=>zm(()=>import("./Markdown-D7nyj2B6.js"),__vite__mapDeps([0,1]),import.meta.url).then(c=>({default:c.MarkdownView})));function Jv({pane:c,mdRendered:r,setMdRendered:y,filesMax:s,setMaximized:z,status:E}){const _=Gv();return o.jsxs("section",{className:"flex min-h-0 min-w-0 flex-col",children:[o.jsxs("div",{className:"flex shrink-0 items-center gap-2 bg-ink-850 px-3 py-0.5 text-ink-400",children:[c.kind==="file"&&o.jsx(Kf,{path:c.value.path}),o.jsxs("div",{className:"ml-auto flex shrink-0 items-center gap-1",children:[c.kind==="diff"&&o.jsx("button",{onClick:_.toggle,"aria-pressed":_.layout==="split",title:_.layout==="split"?"Switch to unified diff":"Switch to split diff","aria-label":_.layout==="split"?"Switch to unified diff":"Switch to split diff",className:`flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent ${_.layout==="split"?"text-accent":""}`,children:o.jsx(xv,{})}),c.kind==="file"&&fm(c.value.path)&&o.jsx("button",{onClick:()=>y(H=>!H),"aria-pressed":r,title:r?"Show raw source":"Show rendered markdown","aria-label":r?"Show raw source":"Show rendered markdown",className:`flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent ${r?"text-accent":""}`,children:o.jsx(Ev,{})}),o.jsx("button",{onClick:()=>z(s?"none":"files"),"aria-pressed":s,title:s?"Restore the layout":"Maximize the file pane","aria-label":s?"Restore the layout":"Maximize the file pane",className:"flex shrink-0 items-center rounded-sm px-1.5 py-0.5 hover:text-accent",children:o.jsx(pv,{maximized:s})})]})]}),o.jsxs("div",{className:"min-h-0 flex-1 overflow-auto",children:[c.kind==="empty"&&o.jsx("p",{className:"p-4 text-ink-400",children:E===null?"Loading…":"Select a file or commit."}),c.kind==="file"&&o.jsxs(o.Fragment,{children:[fm(c.value.path)&&r?o.jsx(U.Suspense,{fallback:o.jsx("p",{className:"p-4 text-ink-400",children:"Rendering…"}),children:o.jsx(Kv,{source:Zv(c.value.lines)})}):o.jsx("pre",{className:"p-3 whitespace-pre text-ink-200",children:c.value.lines.map((H,M)=>o.jsx("div",{children:H.length===0?" ":H.map((v,j)=>o.jsx("span",{style:{color:v.c},children:v.t},j))},M))}),c.value.truncated&&o.jsx("p",{className:"p-3 text-accent",children:"File truncated — it exceeded the server's size ceiling."})]}),c.kind==="diff"&&o.jsx(kv,{diff:c.value,split:_.effective==="split"})]})]})}const $v=U.lazy(()=>zm(()=>import("./Terminal-DSG5cVh6.js"),[],import.meta.url).then(c=>({default:c.TerminalPanel})));function Wv(c){const{repo:r,current:y,status:s,files:z,now:E,hotWindowMs:_,pane:H,setPane:M,tab:v,setTab:j,filter:D,setFilter:R,filterOpen:Q,setFilterOpen:nt,openDiff:P,openFile:J,openCommit:ut,openCommitFileDiff:et,openCommitFiles:L,authed:W,handle:rt,sidebarWidth:I,sidebarRef:Z,draggingSidebar:ot,onSidebarDragStart:ht,onSidebarDragMove:tl,onSidebarDragEnd:Qt,onSidebarDragCancel:jt,filesMax:wt,bumpPaneRequest:Ct,commits:Zt,logDone:T,logStalled:B,setLogStalled:w,commitDrillDown:mt,setCommitDrillDown:vt,resetLog:h,logSentinelRef:O,visibleCommits:q,logPagingPaused:Y,aheadOids:$,visibleCommitFiles:k,mdRendered:F,setMdRendered:Bt,maximized:_t,setMaximized:ql}=c;return o.jsxs(o.Fragment,{children:[ot&&o.jsx("div",{className:"fixed inset-0 z-50 cursor-col-resize"}),o.jsxs("main",{className:`grid min-h-0 grid-cols-1 md:grid-cols-[var(--nc-sidebar)_1fr] ${ot?"select-none":""}`,style:{"--nc-sidebar":wt?"0px":`min(${I}px, ${Sm*100}vw)`},children:[o.jsx(Rv,{tab:v,setTab:j,filter:D,setFilter:R,filterOpen:Q,setFilterOpen:nt,status:s,files:z,now:E,hotWindowMs:_,setPane:M,openDiff:P,openFile:J,openCommit:ut,openCommitFileDiff:et,openCommitFiles:L,repo:r,authed:W,handle:rt,sidebarRef:Z,draggingSidebar:ot,onSidebarDragStart:ht,onSidebarDragMove:tl,onSidebarDragEnd:Qt,onSidebarDragCancel:jt,filesMax:wt,bumpPaneRequest:Ct,commits:Zt,logDone:T,logStalled:B,setLogStalled:w,commitDrillDown:mt,setCommitDrillDown:vt,resetLog:h,logSentinelRef:O,visibleCommits:q,logPagingPaused:Y,aheadOids:$,visibleCommitFiles:k}),o.jsx(Jv,{pane:H,mdRendered:F,setMdRendered:Bt,filesMax:wt,setMaximized:ql,status:s})]}),o.jsx(U.Suspense,{fallback:null,children:o.jsx($v,{repo:r,maximized:_t==="terminal",onToggleMaximized:()=>ql(Yl=>Yl==="terminal"?"none":"terminal")})}),o.jsxs("footer",{className:"flex shrink-0 items-center gap-3 border-t border-ink-700 bg-ink-900 px-3 py-1 text-ink-400",children:[o.jsx("span",{className:"truncate",children:y?.display_path}),s?.branch&&o.jsx("span",{className:"text-accent",children:s.branch}),s?.tracking&&o.jsxs("span",{children:["↑",s.tracking.ahead," ↓",s.tracking.behind]}),o.jsx("span",{className:"ml-auto",children:s?o.jsx("span",{className:"text-added",children:"● live"}):"connecting…"})]})]})}function Fv({onClose:c,onOpened:r}){const[y,s]=U.useState(null),[z,E]=U.useState(null),[_,H]=U.useState(null),[M,v]=U.useState(!1),[j,D]=U.useState(""),[R,Q]=U.useState(!1),[nt,P]=U.useState(0);U.useEffect(()=>{let L=!1;return Gt.browse(y??void 0).then(W=>{L||(E(W),H(null))}).catch(W=>{L||H(W instanceof Error?W.message:"could not browse")}),()=>{L=!0}},[y,nt]);const J=L=>s(`${z.path.replace(/\/$/,"")}/${L}`),ut=async()=>{if(z){v(!0);try{r(await Gt.open(z.path))}catch(L){Xf.error(L instanceof Error?L.message:"could not open"),v(!1)}}},et=async()=>{if(!z)return;const L=j.trim();if(L){Q(!0);try{await Gt.mkdir(z.path,L),D(""),P(W=>W+1)}catch(W){Xf.error(W instanceof Error?W.message:"could not create folder")}finally{Q(!1)}}};return o.jsx("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4",onClick:c,children:o.jsxs("div",{className:"flex max-h-[80vh] w-[34rem] max-w-full flex-col rounded-md border border-ink-700 bg-ink-900",onClick:L=>L.stopPropagation(),children:[o.jsxs("div",{className:"flex shrink-0 items-center gap-2 border-b border-ink-700 px-3 py-2",children:[o.jsx("span",{className:"font-medium text-ink-50",children:"Open a project"}),o.jsx("button",{onClick:c,"aria-label":"close",className:"ml-auto flex h-6 w-6 items-center justify-center rounded-sm text-ink-400 hover:text-ink-200",children:o.jsx(ri,{})})]}),o.jsx("div",{className:"shrink-0 truncate border-b border-ink-700 px-3 py-1.5 text-ink-400",children:z?.path??"…"}),o.jsxs("ul",{className:"h-72 min-h-0 overflow-y-auto",children:[z?.parent&&o.jsx("li",{children:o.jsx("button",{onClick:()=>s(z.parent),className:"w-full px-3 py-1 text-left text-ink-400 hover:bg-ink-850",children:"../"})}),z?.entries.map(L=>o.jsx("li",{children:o.jsxs("button",{onClick:()=>J(L.name),className:"flex w-full items-center gap-2 px-3 py-1 text-left hover:bg-ink-850",children:[o.jsxs("span",{className:"truncate text-accent",children:[L.name,"/"]}),L.is_repo&&o.jsx("span",{className:"rounded-sm bg-ink-700 px-1 text-[0.65rem] text-ink-200",children:"git"})]})},L.name)),z&&z.entries.length===0&&o.jsx("li",{className:"px-3 py-1 text-ink-400",children:"No sub-folders."})]}),_&&o.jsx("p",{className:"shrink-0 px-3 py-1 text-removed",children:_}),o.jsxs("div",{className:"flex shrink-0 items-center gap-2 border-t border-ink-700 px-3 py-2",children:[o.jsx("input",{value:j,onChange:L=>D(L.target.value),onKeyDown:L=>{L.key==="Enter"&&et()},placeholder:"New folder name","aria-label":"new folder name",className:"min-w-0 flex-1 rounded-sm border border-ink-700 bg-ink-950 px-2 py-1 text-ink-50 placeholder:text-ink-400 focus:border-ink-600 focus:outline-none"}),o.jsx("button",{onClick:et,disabled:!z||!j.trim()||R,className:"shrink-0 rounded-sm border border-ink-700 px-2 py-1 text-ink-200 hover:bg-ink-850 disabled:opacity-50",children:R?"Creating…":"Create"})]}),o.jsxs("div",{className:"flex shrink-0 items-center gap-2 border-t border-ink-700 px-3 py-2",children:[o.jsx("span",{className:"truncate text-ink-400",children:z?z.path:""}),o.jsx("button",{onClick:ut,disabled:!z||M,className:"ml-auto shrink-0 rounded-md bg-ink-50 px-3 py-1 font-semibold text-ink-950 hover:bg-white disabled:opacity-50",children:M?"Opening…":"Open"})]})]})})}function rm(){return o.jsx("div",{className:"flex h-full items-center justify-center p-6",children:o.jsxs("div",{className:"flex flex-col items-center gap-3 text-ink-400",children:[o.jsx(kf,{className:"h-12 w-12 animate-pulse"}),o.jsx("span",{className:"text-[0.72rem] tracking-[0.18em] uppercase",children:"Loading…"})]})})}function Iv({onSuccess:c}){const[r,y]=U.useState(""),[s,z]=U.useState(null),[E,_]=U.useState(!1),H=async M=>{M.preventDefault(),_(!0),z(null);try{await Gt.login(r),c()}catch(v){z(v instanceof Error?v.message:"login failed")}finally{_(!1)}};return o.jsx("div",{className:"flex h-full items-center justify-center p-6",children:o.jsxs("form",{onSubmit:H,className:"w-[17rem] max-w-[86vw]",children:[o.jsx(kf,{className:"mx-auto mb-3 block h-10 w-10"}),o.jsx("h1",{className:"text-center text-lg font-medium tracking-wide text-ink-50",children:"nightcrow"}),o.jsx("p",{className:"mt-1 mb-5 text-center text-[0.62rem] tracking-[0.18em] text-ink-400 uppercase",children:"web viewer"}),s&&o.jsx("p",{className:"mb-2.5 text-center text-removed",children:s}),o.jsx("input",{type:"password",autoFocus:!0,value:r,onChange:M=>y(M.target.value),placeholder:"password",className:"mb-2 w-full rounded-md border border-ink-700 bg-ink-900 px-2.5 py-1.5 outline-none placeholder:text-ink-400 focus:border-accent focus:ring-[3px] focus:ring-accent/15"}),o.jsx("button",{type:"submit",disabled:E,className:"w-full rounded-md bg-ink-50 py-1.5 font-semibold text-ink-950 hover:bg-white disabled:opacity-50",children:E?"Signing in…":"Sign in"})]})})}function Pv(){const[c,r]=U.useState(null),[y,s]=U.useState("status"),[z,E]=U.useState(""),[_,H]=U.useState(!1),[M,v]=U.useState({kind:"empty"}),j=U.useRef(0),D=U.useCallback(()=>{j.current+=1},[]),[R,Q]=U.useState(!1),{accent:nt,next:P,cycle:J,adopt:ut}=ev(),et=U.useRef(0),L=U.useCallback(()=>{et.current+=1,J()},[J]),{width:W,resize:rt,commit:I,reset:Z,adopt:ot}=nv(),ht=U.useRef(0),tl=U.useCallback(Ut=>{ht.current+=1,I(Ut)},[I]),Qt=U.useCallback(()=>{ht.current+=1,Z()},[Z]),jt=U.useCallback(()=>{ht.current+=1},[]),wt=U.useRef(null),[Ct,Zt]=U.useState(!0),T=U.useCallback(Ut=>{if(dm(Ut)){r(!1);return}Xf.error(Ut instanceof Error?Ut.message:"request failed")},[]),B=gv(),{draggingSidebar:w,onSidebarDragStart:mt,onSidebarDragMove:vt,onSidebarDragEnd:h,onSidebarDragCancel:O,draggingRef:q}=hv({sidebarRef:wt,sidebarWidth:W,resizeSidebar:rt,commitSidebarWidth:tl,resetSidebarWidth:Qt,bumpSidebarWrites:jt}),{repos:Y,setRepos:$,repo:k,setRepo:F,hot:Bt,clockSkewMs:_t,reposLoaded:ql}=rv({authed:c,setAuthed:r,handle:T,adoptAccent:ut,adoptSidebarWidth:ot,draggingRef:q,accentWrites:et,sidebarWrites:ht,resumeTick:B}),Yl=Bt?.enabled?Bt.window_secs*1e3:0;um(void 0,Yl,_t??0);const{status:ce}=ov({repo:k,authed:c,resumeTick:B,tab:y,pane:M,setPane:v,handle:T,paneRequestRef:j}),Ln=um(ce?.files,Yl,_t??0),{maximized:bl,setMaximized:La,dropMaximized:Xa}=Sv(k),{commits:Xn,logDone:Gn,logStalled:Ga,setLogStalled:Qa,commitDrillDown:Fe,setCommitDrillDown:Ie,resetLog:Za,logSentinelRef:ll,visibleCommits:oi,logPagingPaused:Qn}=yv({repo:k,authed:c,tab:y,filter:z,handle:T}),{openDiff:Zn,openFile:Pe,openCommit:di,openCommitFileDiff:Vn,openCommitFiles:mi}=vv({repo:k,handle:T,setPane:v,paneRequestRef:j,setCommitDrillDown:Ie}),{selectOpenedRepo:hi,closeRepo:Ce}=bv({repos:Y,setRepos:$,setRepo:F,setPane:v,setTab:s,setPickerOpen:Q,dropMaximized:Xa,handle:T});if(U.useEffect(()=>{D(),Ie(null),v({kind:"empty"}),Za()},[k,Za,D,Ie]),c===null)return o.jsx(rm,{});if(!c)return o.jsx(Iv,{onSuccess:()=>r(!0)});if(!ql)return o.jsx(rm,{});const el=Y.find(Ut=>Ut.id===k),pl=z.toLowerCase(),al=(ce?.files??[]).filter(Ut=>Ut.path.toLowerCase().includes(pl)),vi=(Fe?.files??[]).filter(Ut=>Ut.path.toLowerCase().includes(pl)||Ut.old_path?.toLowerCase().includes(pl)),yi=new Set(Xn.slice(0,ce?.tracking?.ahead??0).map(Ut=>Ut.oid)),gi=bl==="files",ta=k?bl==="terminal"?"grid-rows-[auto_minmax(0,0fr)_minmax(0,1fr)_auto]":bl==="files"?"grid-rows-[auto_minmax(0,1fr)_minmax(0,0fr)_auto]":"grid-rows-[auto_minmax(0,11fr)_minmax(0,9fr)_auto]":"grid-rows-[auto_1fr]";return o.jsxs("div",{className:`nc-fade grid h-full ${ta}`,children:[o.jsx(_v,{repos:Y,repo:k,setRepo:Ut=>{F(Ut),v({kind:"empty"})},setPane:()=>v({kind:"empty"}),closeRepo:Ce,setPickerOpen:Q,accent:nt,next:P,cycle:L}),k?o.jsx(Wv,{repo:k,repos:Y,current:el,status:ce,files:al,now:Ln,hotWindowMs:Yl,pane:M,setPane:v,tab:y,setTab:s,filter:z,setFilter:E,filterOpen:_,setFilterOpen:H,openDiff:Zn,openFile:Pe,openCommit:di,openCommitFileDiff:Vn,openCommitFiles:mi,authed:c,handle:T,sidebarWidth:W,sidebarRef:wt,draggingSidebar:w,onSidebarDragStart:mt,onSidebarDragMove:vt,onSidebarDragEnd:h,onSidebarDragCancel:O,filesMax:gi,bumpPaneRequest:D,commits:Xn,logDone:Gn,logStalled:Ga,setLogStalled:Qa,commitDrillDown:Fe,setCommitDrillDown:Ie,resetLog:Za,logSentinelRef:ll,visibleCommits:oi,logPagingPaused:Qn,aheadOids:yi,visibleCommitFiles:vi,mdRendered:Ct,setMdRendered:Zt,maximized:bl,setMaximized:La}):o.jsx("div",{className:"flex items-center justify-center p-6 text-center text-ink-400",children:o.jsxs("span",{children:["No repository open. Click"," ",o.jsx("span",{className:"text-ink-200",children:"+ open"})," above to add one."]})}),R&&o.jsx(Fv,{onClose:()=>Q(!1),onOpened:hi})]})}const ty={error:7e3,info:5e3,success:5e3},ly={error:"text-removed",info:"text-accent",success:"text-added"};function ey(){const[c,r]=U.useState([]);return U.useEffect(()=>tv(r),[]),c.length===0?null:o.jsx("div",{className:"pointer-events-none fixed right-3 top-3 z-[60] flex w-80 max-w-[calc(100vw-1.5rem)] flex-col gap-2","aria-live":"polite",children:c.map(y=>o.jsx(ay,{toast:y},y.id))})}function ay({toast:c}){const[r,y]=U.useState(!1);return U.useEffect(()=>{if(r)return;const s=setTimeout(()=>lm(c.id),ty[c.kind]);return()=>clearTimeout(s)},[c.id,c.kind,c.bump,r]),o.jsxs("div",{role:c.kind==="error"?"alert":"status",className:"nc-fade pointer-events-auto flex items-start gap-2 rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-xs shadow-lg",onMouseEnter:()=>y(!0),onMouseLeave:()=>y(!1),children:[o.jsx("span",{className:`min-w-0 flex-1 break-words ${ly[c.kind]}`,children:c.message}),o.jsx("button",{type:"button",onClick:()=>lm(c.id),"aria-label":"dismiss",className:"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:bg-ink-700 hover:text-ink-200",children:o.jsx(ri,{className:"h-3 w-3"})})]})}Wh.createRoot(document.getElementById("root")).render(o.jsxs(U.StrictMode,{children:[o.jsx(Pv,{}),o.jsx(ey,{})]}));export{pv as M,Em as P,ri as X,o as j,U as r,Xf as t}; diff --git a/viewer-ui/dist/index.html b/viewer-ui/dist/index.html index 1dd776b..9151b3f 100644 --- a/viewer-ui/dist/index.html +++ b/viewer-ui/dist/index.html @@ -5,8 +5,8 @@ nightcrow - - + +
    diff --git a/viewer-ui/src/App.tsx b/viewer-ui/src/App.tsx index 141f226..27e6f63 100644 --- a/viewer-ui/src/App.tsx +++ b/viewer-ui/src/App.tsx @@ -25,23 +25,18 @@ export function App() { const [filter, setFilter] = useState(""); const [filterOpen, setFilterOpen] = useState(false); const [pane, setPane] = useState({ kind: "empty" }); - // Invalidates in-flight pane requests on context change so a slow response - // cannot overwrite what the user is looking at now. + // Prevent stale pane responses from overwriting the new context. const paneRequestRef = useRef(0); const bumpPaneRequest = useCallback(() => { paneRequestRef.current += 1; }, []); const [pickerOpen, setPickerOpen] = useState(false); - // Ahead of the login/loading early returns so the stored accent applies - // to those screens too. const { accent, next, cycle: cycleAccent, adopt: adoptAccent } = useAccent(); - // Counts local accent changes so the repo poll can ignore stale responses. const accentWrites = useRef(0); const cycle = useCallback(() => { accentWrites.current += 1; cycleAccent(); }, [cycleAccent]); - // Sidebar width, shared across devices like the accent with the same guard. const { width: sidebarWidth, resize: resizeSidebar, @@ -65,18 +60,13 @@ export function App() { sidebarWrites.current += 1; }, []); const sidebarRef = useRef(null); - // Markdown files open rendered; toggles to raw source. Session-only — - // rendered is the common case so each pane starts there. const [mdRendered, setMdRendered] = useState(true); - // "log back in" or a toast-worthy message. const handle = useCallback((err: unknown) => { if (isUnauthorized(err)) { setAuthed(false); return; } - // NetworkError already carries a friendly message; the self-healing repo - // poll suppresses these before they reach here. toast.error(err instanceof Error ? err.message : "request failed"); }, []); @@ -119,9 +109,6 @@ export function App() { }); const hotWindowMs = hot?.enabled ? hot.window_secs * 1000 : 0; - // The hot clock ticks against the status snapshot's mtimes; until a snapshot - // arrives it reads everything as cool. Kept ahead of the early returns so - // the hook order is stable across auth/loading states. useHotClock(undefined, hotWindowMs, clockSkewMs ?? 0); const { status } = useStatus({ @@ -171,8 +158,6 @@ export function App() { handle, }); - // Drop everything below the header when the repo changes — not every switch - // is a click (closing the active project in the TUI drops it from the poll). useEffect(() => { bumpPaneRequest(); setCommitDrillDown(null); @@ -183,8 +168,6 @@ export function App() { if (authed === null) return ; if (!authed) return setAuthed(true)} />; if (!reposLoaded) return ; - // Empty catalog is a real state — render the shell so the header's "+ open" - // is the way in. const current = repos.find((r) => r.id === repo); const q = filter.toLowerCase(); const files = (status?.files ?? []).filter((f) => @@ -194,13 +177,10 @@ export function App() { f.path.toLowerCase().includes(q) || f.old_path?.toLowerCase().includes(q), ); - // Log is newest-first; first `ahead` commits are unpushed. const aheadOids = new Set( commits.slice(0, status?.tracking?.ahead ?? 0).map((c) => c.oid), ); - // Maximising collapses the losing row to 0fr (not unmount) so the panel - // comes back scrolled where it was. const filesMax = maximized === "files"; const rows = !repo ? "grid-rows-[auto_1fr]" @@ -208,14 +188,10 @@ export function App() { ? "grid-rows-[auto_minmax(0,0fr)_minmax(0,1fr)_auto]" : maximized === "files" ? "grid-rows-[auto_minmax(0,1fr)_minmax(0,0fr)_auto]" - : // 55/45 split, matching the TUI's default layout.upper_pct. - "grid-rows-[auto_minmax(0,11fr)_minmax(0,9fr)_auto]"; + : "grid-rows-[auto_minmax(0,11fr)_minmax(0,9fr)_auto]"; return (
    - {/* Pinned in px to match the web mirror's header (16px root vs this app's - 14px). Deliberately opted out of the density knob — the header is - shared branding, not content. */}
    ); -} \ No newline at end of file +} diff --git a/viewer-ui/src/Markdown.tsx b/viewer-ui/src/Markdown.tsx index 5931daa..9641e78 100644 --- a/viewer-ui/src/Markdown.tsx +++ b/viewer-ui/src/Markdown.tsx @@ -1,23 +1,10 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeHighlight from "rehype-highlight"; -// highlight.js token colours for fenced code. Imported here (not in index.css) -// so it rides this lazily-loaded chunk instead of the initial bundle, and stays -// self-hosted under the viewer's `default-src 'self'` CSP. `.nc-markdown pre` -// owns the block's box; this file only paints the tokens inside it. +// Keep the token stylesheet in the lazy markdown chunk and self-hosted for CSP. import "highlight.js/styles/github-dark.css"; -/** - * Rendered markdown for the file pane, lazily loaded (see `App.tsx`). Uses - * `react-markdown`, which builds React elements from the parsed AST rather than - * injecting an HTML string — so there is no `dangerouslySetInnerHTML` surface - * and nothing to sanitise. `remark-gfm` adds GitHub's tables/tasklists/etc.; - * `rehype-highlight` colours fenced code with highlight.js (also AST-based). - * - * Styling lives under `.nc-markdown` in `index.css`; only the wrapper class is - * set here. Links are forced to open in a new tab with `noopener` so a rendered - * document can never script the opener. - */ +/** AST-based rendering avoids HTML injection; links cannot script the opener. */ export function MarkdownView({ source }: { source: string }) { return (
    diff --git a/viewer-ui/src/Terminal.tsx b/viewer-ui/src/Terminal.tsx index 759cd62..b1bc444 100644 --- a/viewer-ui/src/Terminal.tsx +++ b/viewer-ui/src/Terminal.tsx @@ -6,20 +6,6 @@ import { useTerminalSocket } from "./useTerminalSocket"; import { useTerminalViews } from "./useTerminalViews"; import { TerminalCell } from "./TerminalCell"; -/** - * One WebSocket multiplexes every terminal for a repository. - * - * Output arrives as binary frames tagged with a 4-byte little-endian pane id - * (see src/web/viewer/terminal.rs) — binary rather than JSON because PTY reads - * routinely split a multi-byte sequence, and decoding early would corrupt it - * before xterm.js could reassemble it. Bytes are handed to xterm.js untouched. - * - * Panes render simultaneously in a balanced split-view grid (mirroring the - * TUI), not tabs. Every pane's cell stays mounted for its lifetime — xterm's - * `open()` runs once per instance and re-opening a detached element renders - * blank — so reflowing the grid only restyles the (stable, keyed) cells, and - * zooming a pane toggles the others' `display` rather than unmounting them. - */ export function TerminalPanel({ repo, maximized, @@ -32,35 +18,19 @@ export function TerminalPanel({ const containerRef = useRef(null); const socketRef = useRef(null); const viewsRef = useRef(new Map()); - // The DOM element xterm is opened into, per pane, registered by each cell. const bodyRefs = useRef(new Map()); - // Last size reported to each PTY, so a reflow that leaves rows/cols unchanged - // does not spam resize frames. + // Avoid redundant PTY resize frames. const sentSizesRef = useRef(new Map()); - // Output for a pane whose xterm view does not exist yet. A pane's view is - // materialised in a later effect (after its "created" updates React state), - // but the replayed scrollback arrives on the socket immediately after that - // message — buffer it here and flush when the view is opened, or it is lost. + // Buffer scrollback received before the corresponding xterm exists. const pendingRef = useRef(new Map()); - // The pane a client last focused, per repo. This panel instance is reused - // across project switches (it is not keyed by repo), so this survives the - // reconnect and lets us restore the selection instead of jumping to the last - // replayed pane. + // Restore focus when returning to a repository. const lastActiveByRepoRef = useRef(new Map()); - // Count of creates this client has requested but not yet seen announced. - // Focus follows only these — not panes replayed on reconnect, startup - // terminals, or another browser's creates. + // Focus only panes created by this client, not replayed panes. const expectCreateRef = useRef(0); const [panes, setPanes] = useState([]); const [active, setActive] = useState(null); - // When set, this pane fills the whole panel and the rest are hidden — the - // web equivalent of the TUI's zoom mode. const [zoomed, setZoomed] = useState(null); - // Panel dimensions, tracked so the two-pane split can flip between side-by-side - // and stacked and so a resize refits every visible pane. const [size, setSize] = useState({ w: 0, h: 0 }); - // Per-pane title from the shell's OSC 0/2 sequence (parsed by xterm.js), so a - // cell reads e.g. "claude" or "vim README" instead of a bare "term 2". const [titles, setTitles] = useState>({}); useTerminalSocket({ @@ -77,9 +47,6 @@ export function TerminalPanel({ setTitles, }); - // Materialise one xterm per pane, opened into that pane's cell body (rendered - // below, keyed by pane so it survives grid reflows). `open()` runs once here; - // dispose the views of panes that have gone away. useTerminalViews({ panes, socketRef, @@ -89,10 +56,7 @@ export function TerminalPanel({ setTitles, }); - // Fit every visible pane to its cell and report the size to its PTY. Runs on - // any layout change (pane added/removed, zoom toggled, panel resized). Hidden - // or collapsed cells (zoomed-out, or the panel shrunk to nothing) report zero - // size and are skipped — fitting them would SIGWINCH the shell to garbage. + // Fit visible panes after layout changes; never resize hidden cells to zero. useEffect(() => { for (const [pane, view] of viewsRef.current) { const body = bodyRefs.current.get(pane); @@ -108,8 +72,6 @@ export function TerminalPanel({ } }, [panes, zoomed, size]); - // Track the panel's size so the two-pane split can pick its orientation and a - // resize refits every pane. useEffect(() => { const container = containerRef.current; if (!container) return; @@ -120,9 +82,6 @@ export function TerminalPanel({ return () => observer.disconnect(); }, []); - // When nothing is selected but panes exist — after a close, or after a - // reconnect reset — pick one: the repo's remembered pane if it is still here, - // otherwise the last. useEffect(() => { if (active === null && panes.length > 0) { const remembered = lastActiveByRepoRef.current.get(repo); @@ -134,13 +93,10 @@ export function TerminalPanel({ } }, [active, panes, repo]); - // Give the keyboard to the active pane. useEffect(() => { if (active !== null) viewsRef.current.get(active)?.term.focus(); }, [active]); - // Select a pane and remember it as this repo's focus, so returning to the - // project restores it. const focusPane = (pane: number) => { setActive(pane); lastActiveByRepoRef.current.set(repo, pane); @@ -149,15 +105,11 @@ export function TerminalPanel({ const create = () => { const socket = socketRef.current; if (!socket) return; - // Show the new pane in the grid rather than under whatever was zoomed. setZoomed(null); - // Focus should follow this create when its "created" comes back. expectCreateRef.current += 1; socket.send(JSON.stringify({ type: "create", rows: 24, cols: 80 })); }; - // Ask the server to kill the PTY. The pane is removed when the resulting - // "exited" broadcast arrives, so every client stays in step. const closePane = (pane: number) => { socketRef.current?.send(JSON.stringify({ type: "close", pane })); }; @@ -183,11 +135,6 @@ export function TerminalPanel({ return (
    - {/* The panel's controls sit together at the trailing edge, the way an - editor keeps a pane's actions. No label: beside the maximise button - it reads as one of a pair of controls rather than a stray word, and - the panel it adds to is the thing it points at. `aria-label` is what - names it, an icon having no text of its own. */} - {/* No Escape shortcut to leave: Escape belongs to whatever is running - in the PTY, and stealing it would break vim and every TUI below it. - The button is the way out. */}
    ); -} \ No newline at end of file +} diff --git a/viewer-ui/src/Toaster.tsx b/viewer-ui/src/Toaster.tsx index 12f91e3..dc36489 100644 --- a/viewer-ui/src/Toaster.tsx +++ b/viewer-ui/src/Toaster.tsx @@ -7,16 +7,12 @@ import { type ToastKind, } from "./toast"; -// Errors linger longer than confirmations, since a failure is the one a reader -// most needs time to catch. const DURATION_MS: Record = { error: 7000, info: 5000, success: 5000, }; -// Reuse the TUI-carried palette: red for errors, green for success, accent -// (amber) for neutral info — the same tokens the inline notices used. const TEXT: Record = { error: "text-removed", info: "text-accent", @@ -31,8 +27,7 @@ export function Toaster() { return (
    ); -} \ No newline at end of file +} diff --git a/viewer-ui/src/components/Header.tsx b/viewer-ui/src/components/Header.tsx index 460831b..a7be6e4 100644 --- a/viewer-ui/src/components/Header.tsx +++ b/viewer-ui/src/components/Header.tsx @@ -44,19 +44,6 @@ export function Header({ onCloseProject={closeRepo} onOpenPicker={() => setPickerOpen(true)} /> - {/* Editor tabs, after VS Code's: square, touching, and stretched to the - full height of the bar they sit in — a tab is a tab because it fills - its strip, not because it is a labelled box. The negative margins eat - the header's padding to reach that height, and the active one takes - the body colour so it reads as the near edge of the content below. - The accent marker is an inset shadow rather than a border, which - would shift the label down by its own width. - - VS Code also lets the active tab overlap the rule under the bar, so - the two areas merge outright. Not done here: this strip scrolls - sideways when enough projects are open, and a scroll container clips - both axes — the overlap would be cut off and could raise a vertical - scrollbar besides. */} - {/* The plus is the same drawn mark the terminal panel's add button uses, - not the `+` character, so the app has one plus rather than two that - disagree on weight. Sized to the label beside it — the convention the - project tabs' close glyph already follows — rather than to the 16px - of an icon-only control. */} - {/* Cycles rather than opening a picker, matching the TUI's - ` p`. The swatch is the current accent, so the control - doubles as the indicator. */}
    ); -} \ No newline at end of file +} diff --git a/viewer-ui/src/components/LoadingSplash.tsx b/viewer-ui/src/components/LoadingSplash.tsx index 5a1cb26..12b24cb 100644 --- a/viewer-ui/src/components/LoadingSplash.tsx +++ b/viewer-ui/src/components/LoadingSplash.tsx @@ -1,7 +1,5 @@ import { Mark } from "./Mark"; -/// Centred, branded loading indicator shown before the first repo list settles -/// (both at session start and while an empty catalog may still be populating). export function LoadingSplash() { return (
    @@ -13,4 +11,4 @@ export function LoadingSplash() {
    ); -} \ No newline at end of file +} diff --git a/viewer-ui/src/components/LogList.tsx b/viewer-ui/src/components/LogList.tsx index 224c84e..54aba17 100644 --- a/viewer-ui/src/components/LogList.tsx +++ b/viewer-ui/src/components/LogList.tsx @@ -50,7 +50,6 @@ export function LogList({ title={`${c.author} · ${c.summary}`} className="flex w-max min-w-full items-baseline gap-2 px-3 py-0.5 text-left hover:bg-ink-850" > - {/* ↑ marks unpushed commits, like the TUI's ahead marker. */} {aheadOids.has(c.oid) ? "↑" : ""} @@ -58,9 +57,6 @@ export function LogList({ {formatRelativeTime(c.time)} - {/* Author stays a fixed column so summaries line up, the - same cap the TUI applies at 10 chars; `title` carries - the full name. */} {c.author} @@ -68,29 +64,17 @@ export function LogList({ ))} - {/* Asks for the next page as it scrolls into view, the way the TUI - prefetches as the cursor nears the loaded tail. Rendered only - while there is more, so reaching the end of the history stops - the observer rather than leaving it to fire on every scroll. - Kept out of the drill-down, which lists one commit's files. */} {!commitDrillDown && !logDone && !logStalled && !logPagingPaused && ( )} - {/* Says why the list stops where it does while a filter is up: the - query matches what is loaded, and more history is only a cleared - filter away. Without this the end of a filtered list is - indistinguishable from the end of the history. */} {!commitDrillDown && !logDone && !logStalled && logPagingPaused && (
  • filtering {commits.length} loaded commits — clear the filter to load more
  • )} - {/* A failed page keeps its place in the list. The history did not - end here, and the error toast fades on its own, so without this - the list would simply look shorter than it is. */} {!commitDrillDown && logStalled && (
  • ); -} \ No newline at end of file +} diff --git a/viewer-ui/src/components/RepoShell.tsx b/viewer-ui/src/components/RepoShell.tsx index 405e181..bbb69de 100644 --- a/viewer-ui/src/components/RepoShell.tsx +++ b/viewer-ui/src/components/RepoShell.tsx @@ -8,9 +8,7 @@ import type { ChangedFile, Commit, Repo, Status } from "../api"; import type { CommitDrillDown } from "../hooks/useLog"; import type { Maximized, Pane, Tab } from "../types"; -// Lazily loaded so `@xterm/xterm` (the bulk of the bundle) stays out of the -// initial chunk that paints the login screen and git viewer, arriving only once -// a repo is open and the terminal panel actually mounts. +// Keep xterm out of the initial login and git-viewer bundle. const TerminalPanel = lazy(() => import("../Terminal").then((m) => ({ default: m.TerminalPanel })), ); @@ -117,25 +115,14 @@ export function RepoShell(props: RepoShellProps) { return ( <> - {/* While the divider is dragged, this overlay holds the resize cursor - across the whole window and keeps a stray text selection from - starting. Pointer capture routes the move/up events to the handle - regardless, so the overlay is purely visual. */} + {/* Keep the resize cursor and prevent selection during dragging. */} {draggingSidebar &&
    } - {/* The width rides on a custom property so the responsive rule stays - declarative — below md the grid collapses to one column, leaving - the stacked layout untouched. Maximising the file pane drives the - property to zero rather than dropping the sidebar, so its content - is not torn down and rebuilt on every toggle. */}
    ); -} \ No newline at end of file +} diff --git a/viewer-ui/src/components/Sidebar.tsx b/viewer-ui/src/components/Sidebar.tsx index 04faed1..e1ea8bd 100644 --- a/viewer-ui/src/components/Sidebar.tsx +++ b/viewer-ui/src/components/Sidebar.tsx @@ -37,8 +37,6 @@ export interface SidebarProps { onSidebarDragCancel: () => void; filesMax: boolean; bumpPaneRequest: () => void; - // Log state owned by App (commitDrillDown is shared with openCommitFiles and - // the repo-change cleanup, so it lives above the sidebar). commits: Commit[]; logDone: boolean; logStalled: boolean; @@ -107,12 +105,6 @@ export function Sidebar(props: SidebarProps) { filesMax ? "hidden md:flex" : "flex border-ink-700 md:border-r" }`} > - {/* Drag the divider to resize the sidebar, double-click to reset it. - A thin strip over the right border, only at md+ (below it the - layout is a single stacked column) and only when the pane is not - maximised. Pointer capture keeps the drag alive over the diff pane; - the overlay below carries the resize cursor across the whole window - while it lasts. */} {!filesMax && (
    )} - {/* Panel tabs, after VS Code's PROBLEMS/OUTPUT/TERMINAL row: no fill, - just an underline on the active one, sitting on the rule that - separates the row from the list it labels. The tabs overlap that - rule by a pixel (`-mb-px`) so the marker replaces it rather than - stacking a second line under it. */}
    {(["status", "log", "tree"] as Tab[]).map((t) => (