From 2772e6878a5f4ab02b361dacb5723f0119ffbfeb Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Fri, 16 Oct 2026 16:30:07 +0200 Subject: [PATCH] refactor: move embedded tests into dedicated test directory --- TODO.md | 4 +- docs/development.md | 5 +- src/battery.rs | 232 +- src/blame.rs | 376 +- src/chrome.rs | 184 +- src/clipboard.rs | 282 +- src/diff.rs | 2562 +---- src/editor.rs | 15235 +--------------------------- src/files.rs | 990 +- src/forge.rs | 429 +- src/gutter.rs | 429 +- src/lsp.rs | 248 +- src/main.rs | 50 +- src/markdown.rs | 147 +- src/panes.rs | 127 +- src/repository.rs | 508 +- src/resolve.rs | 477 +- src/review.rs | 522 +- src/review_cli.rs | 112 +- src/ruby.rs | 2301 +---- src/session.rs | 3657 +------ src/snippet.rs | 123 +- src/syntax.rs | 1132 +-- src/syntax/languages.rs | 77 +- src/terminal_colors.rs | 277 +- src/tui.rs | 15295 +---------------------------- src/wrap.rs | 137 +- tests/unit/battery.rs | 197 + tests/unit/battery_macos.rs | 32 + tests/unit/blame.rs | 368 + tests/unit/chrome.rs | 180 + tests/unit/cli_args.rs | 46 + tests/unit/clipboard.rs | 192 + tests/unit/clipboard_macos.rs | 81 + tests/unit/diff.rs | 2561 +++++ tests/unit/editor.rs | 15220 ++++++++++++++++++++++++++++ tests/unit/files.rs | 982 ++ tests/unit/forge.rs | 425 + tests/unit/gutter.rs | 425 + tests/unit/lsp.rs | 242 + tests/unit/markdown.rs | 143 + tests/unit/panes.rs | 123 + tests/unit/repository.rs | 503 + tests/unit/resolve.rs | 325 + tests/unit/resolve_projection.rs | 143 + tests/unit/review.rs | 518 + tests/unit/review_cli.rs | 107 + tests/unit/ruby.rs | 2280 +++++ tests/unit/session.rs | 3650 +++++++ tests/unit/snippet.rs | 119 + tests/unit/syntax.rs | 1125 +++ tests/unit/syntax_languages.rs | 73 + tests/unit/terminal_colors.rs | 273 + tests/unit/tui.rs | 15226 ++++++++++++++++++++++++++++ tests/unit/wrap.rs | 133 + 55 files changed, 45752 insertions(+), 45858 deletions(-) create mode 100644 tests/unit/battery.rs create mode 100644 tests/unit/battery_macos.rs create mode 100644 tests/unit/blame.rs create mode 100644 tests/unit/chrome.rs create mode 100644 tests/unit/cli_args.rs create mode 100644 tests/unit/clipboard.rs create mode 100644 tests/unit/clipboard_macos.rs create mode 100644 tests/unit/diff.rs create mode 100644 tests/unit/editor.rs create mode 100644 tests/unit/files.rs create mode 100644 tests/unit/forge.rs create mode 100644 tests/unit/gutter.rs create mode 100644 tests/unit/lsp.rs create mode 100644 tests/unit/markdown.rs create mode 100644 tests/unit/panes.rs create mode 100644 tests/unit/repository.rs create mode 100644 tests/unit/resolve.rs create mode 100644 tests/unit/resolve_projection.rs create mode 100644 tests/unit/review.rs create mode 100644 tests/unit/review_cli.rs create mode 100644 tests/unit/ruby.rs create mode 100644 tests/unit/session.rs create mode 100644 tests/unit/snippet.rs create mode 100644 tests/unit/syntax.rs create mode 100644 tests/unit/syntax_languages.rs create mode 100644 tests/unit/terminal_colors.rs create mode 100644 tests/unit/tui.rs create mode 100644 tests/unit/wrap.rs diff --git a/TODO.md b/TODO.md index ee21200..53a7382 100644 --- a/TODO.md +++ b/TODO.md @@ -29,8 +29,8 @@ - preserve filename arguments, configured storage, and incompatible data; no MCP, agent replies, or automatic dispatch. - [x] add an option for three pane left/base/right view for conflict resolution. - alternative pane layout as left/right with base underneath (`config.resolve_history_layout = "base_below"`) -- [ ] pull out tests from implementation code, and place them in a dedicated test folder. - - maybe a test folder sibling to `src` could make sense? 🤔 +- [x] pull out tests from implementation code, and place them in a dedicated test folder. + - private unit tests live in `tests/unit/`, alongside the existing `tests/*.rs` integration suites. - [ ] diff view: GitHub code review integration after the local review UX is usable. - publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes. - preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission. diff --git a/docs/development.md b/docs/development.md index cda1170..1bc1b81 100644 --- a/docs/development.md +++ b/docs/development.md @@ -12,6 +12,7 @@ cargo test -p crossterm color_scheme_notifications cargo test -p crossterm --doc cargo clippy --all-targets -- -D warnings cargo fmt --package vamp --check +rustfmt --edition 2024 --check tests/unit/battery_macos.rs tests/unit/clipboard_macos.rs for file in src/ruby_*.rb examples/config.rb examples/plugins/greeting.rb; do ruby -c "$file" || exit done @@ -34,7 +35,9 @@ Rust embeds the Ruby implementation at compile time: [`ruby_values.rb`](../src/r ## Test organization -Rust tests live alongside their modules; native LSP integration checks are in [`tests/lsp.rs`](../tests/lsp.rs). +Rust tests live under [`tests/`](../tests/). Private unit tests are in [`tests/unit/`](../tests/unit/), linked from their owning modules with `#[cfg(test)]` and `#[path]`; nested macOS modules use `include!` to retain their private/platform scope. Root `tests/*.rs` files remain Cargo integration targets, including native LSP checks in [`tests/lsp.rs`](../tests/lsp.rs). + +`cargo test` runs both groups; `cargo test --lib` and `cargo test --bin vamp` select the library and CLI unit suites. Do not name a unit file `tests/unit/main.rs`: Cargo would discover it as a separate integration target. - Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity. - Visual-command checks preserve directed characterwise/linewise ranges and multiple carets through command cancellation and plugin dispatch, exercise remapped command entry, and submit `:diff.comment` on either historical side. Live PTY smoke selects lines, cancels a prompt, then runs `:diff.comment` directly and verifies the saved original range without using the palette. diff --git a/src/battery.rs b/src/battery.rs index 5fcfcf5..65b30ef 100644 --- a/src/battery.rs +++ b/src/battery.rs @@ -137,8 +137,6 @@ mod macos { fn CFBooleanGetValue(boolean: Object) -> u8; fn CFNumberGetTypeID() -> usize; fn CFNumberGetValue(number: Object, kind: isize, value: *mut c_void) -> u8; - #[cfg(test)] - fn CFNumberCreate(allocator: Object, kind: isize, value: Object) -> Object; } // Only Copy/Create results belong here; power source descriptions are borrowed. @@ -219,34 +217,7 @@ mod macos { #[cfg(test)] mod tests { - use super::*; - - #[test] - fn native_values_are_type_checked_and_numbers_must_be_exact_and_nonnegative() { - assert!(Owned::new(std::ptr::null()).is_none()); - unsafe { - assert_eq!(number(std::ptr::null()), None); - let text = Owned::string(c"27").unwrap(); - assert_eq!(number(text.0), None); - for value in [-1_i64, 0, 100] { - let object = Owned::new(CFNumberCreate( - std::ptr::null(), - 4, - (&value as *const i64).cast(), - )) - .unwrap(); - assert_eq!(number(object.0), u64::try_from(value).ok()); - } - let fraction = 1.5_f64; - let object = Owned::new(CFNumberCreate( - std::ptr::null(), - 6, - (&fraction as *const f64).cast(), - )) - .unwrap(); - assert_eq!(number(object.0), None); - } - } + include!("../tests/unit/battery_macos.rs"); } } @@ -341,202 +312,5 @@ mod windows { } #[cfg(test)] -mod tests { - use super::*; - use std::{ - fs, - path::PathBuf, - sync::atomic::{AtomicBool, AtomicU64, Ordering}, - }; - - #[test] - fn percentages_reject_invalid_data_and_include_both_endpoints() { - assert_eq!(percentage(0, 100), Some(0)); - assert_eq!(percentage(100, 100), Some(100)); - assert_eq!(percentage(1, 3), Some(33)); - assert_eq!(percentage(u64::MAX, u64::MAX), Some(100)); - assert_eq!(percentage(0, 0), None); - assert_eq!(percentage(101, 100), None); - assert_eq!(windows_charge(0, 4), Some(0)); - assert_eq!(windows_charge(100, 8), Some(100)); - assert_eq!(windows_charge(101, 0), None); - assert_eq!(windows_charge(255, 0), None); - assert_eq!(windows_charge(50, 128), None); - assert_eq!(windows_charge(50, 255), None); - } - - fn worker(samples: Vec>) -> (Battery, Receiver<()>, Arc) { - struct ReaderDropped(Arc); - impl Drop for ReaderDropped { - fn drop(&mut self) { - self.0.store(true, Ordering::Release); - } - } - let dropped = Arc::new(AtomicBool::new(false)); - let reader = ReaderDropped(Arc::clone(&dropped)); - let mut samples = samples.into_iter(); - let (ready, published) = mpsc::channel(); - let battery = Battery::start_with( - move || { - let _ = &reader; - samples - .next() - .expect("test provides every requested sample") - }, - move |wake| { - ready.send(()).unwrap(); - // Explicit channel messages replace the production clock entirely. - wake.recv().is_ok() - }, - ) - .unwrap(); - (battery, published, dropped) - } - - #[test] - fn worker_reads_immediately_replaces_stale_samples_and_clears_invalid_data() { - let (mut battery, published, dropped) = worker(vec![Some(0), Some(100), None, Some(101)]); - published.recv().unwrap(); - assert_eq!(battery.poll(), Some(Some(0))); - assert_eq!(battery.poll(), None); - battery.wake.as_ref().unwrap().send(()).unwrap(); - published.recv().unwrap(); - battery.wake.as_ref().unwrap().send(()).unwrap(); - published.recv().unwrap(); - assert_eq!(battery.poll(), Some(None)); - assert_eq!(battery.poll(), None); - battery.wake.as_ref().unwrap().send(()).unwrap(); - published.recv().unwrap(); - assert_eq!(battery.poll(), Some(None)); - drop(battery); - assert!(dropped.load(Ordering::Acquire)); - } - - #[test] - fn dropping_interrupts_wait_and_joins_even_with_an_unread_sample() { - let (battery, published, dropped) = worker(vec![Some(50)]); - published.recv().unwrap(); - drop(battery); - assert!(dropped.load(Ordering::Acquire)); - } - - static FIXTURE_ID: AtomicU64 = AtomicU64::new(0); - - struct Fixture(PathBuf); - - impl Fixture { - fn new() -> Self { - let path = std::env::temp_dir().join(format!( - "vamp-battery-{}-{}", - std::process::id(), - FIXTURE_ID.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir(&path).unwrap(); - Self(path) - } - - fn source(&self, name: &str, fields: &[(&str, &str)]) { - let path = self.0.join(name); - fs::create_dir_all(&path).unwrap(); - for (key, value) in fields { - fs::write(path.join(key), value).unwrap(); - } - } - - fn read(&self) -> Option { - linux::read_charge(&self.0) - } - } - - impl Drop for Fixture { - fn drop(&mut self) { - fs::remove_dir_all(&self.0).unwrap(); - } - } - - #[test] - fn sysfs_reads_present_system_batteries_but_not_external_or_absent_sources() { - let fixture = Fixture::new(); - assert_eq!(fixture.read(), None); - fixture.source("AC", &[("type", "Mains"), ("capacity", "100")]); - fixture.source( - "mouse", - &[ - ("type", "Battery"), - ("scope", "Device"), - ("present", "1"), - ("capacity", "100"), - ], - ); - fixture.source( - "absent", - &[("type", "Battery"), ("present", "0"), ("capacity", "100")], - ); - assert_eq!(fixture.read(), None); - fixture.source( - "BAT0", - &[ - ("type", "Battery\n"), - ("scope", "System\n"), - ("present", "1\n"), - ("capacity", "27\n"), - ], - ); - assert_eq!(fixture.read(), Some(27)); - fixture.source("BAT0", &[("capacity", "0")]); - assert_eq!(fixture.read(), Some(0)); - fixture.source("BAT0", &[("capacity", "100")]); - assert_eq!(fixture.read(), Some(100)); - for invalid in ["101", "-1", "255", "garbage", ""] { - fixture.source("BAT0", &[("capacity", invalid)]); - assert_eq!(fixture.read(), None); - } - fs::remove_file(fixture.0.join("BAT0/capacity")).unwrap(); - assert_eq!(fixture.read(), None); - } - - #[test] - fn sysfs_uses_matching_actual_energy_or_charge_totals_only() { - let energy = Fixture::new(); - energy.source( - "BAT0", - &[ - ("type", "Battery"), - ("present", "1"), - ("energy_now", "27"), - ("energy_full", "100"), - ], - ); - assert_eq!(energy.read(), Some(27)); - energy.source("BAT0", &[("energy_full", "0")]); - assert_eq!(energy.read(), None); - energy.source("BAT0", &[("energy_full", "26")]); - assert_eq!(energy.read(), None); - energy.source("BAT0", &[("energy_full", "garbage")]); - assert_eq!(energy.read(), None); - let charge = Fixture::new(); - charge.source( - "BAT0", - &[ - ("type", "Battery"), - ("present", "1"), - ("charge_now", "15"), - ("charge_full", "20"), - ], - ); - assert_eq!(charge.read(), Some(75)); - charge.source("BAT0", &[("energy_now", "5")]); - assert_eq!(charge.read(), None); - let design = Fixture::new(); - design.source( - "BAT0", - &[ - ("type", "Battery"), - ("present", "1"), - ("energy_now", "20"), - ("energy_full_design", "100"), - ], - ); - assert_eq!(design.read(), None); - } -} +#[path = "../tests/unit/battery.rs"] +mod tests; diff --git a/src/blame.rs b/src/blame.rs index c95867e..4e7e98d 100644 --- a/src/blame.rs +++ b/src/blame.rs @@ -505,377 +505,5 @@ fn parse(bytes: &[u8], rows: usize) -> Result>>, Strin } #[cfg(all(test, unix))] -mod tests { - use super::*; - - fn command(root: &Path, program: &str, args: &[&str]) -> String { - let mut command = Command::new(program); - if program == "jj" { - command.args([ - "--config=user.name=\"Jj Author\"", - "--config=user.email=\"jj@example.test\"", - ]); - command.args([ - "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", - "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", - ]); - } - let output = command - .args(args) - .current_dir(root) - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_SYSTEM", "/dev/null") - .env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z") - .env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z") - .env("JJ_CONFIG", "/dev/null") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).unwrap().trim().into() - } - fn fixture() -> (Temporary, PathBuf, String) { - let temp = Temporary::new().unwrap(); - command(&temp.0, "git", &["init", "--quiet", "--template="]); - command(&temp.0, "git", &["config", "user.name", "Blame Author"]); - command( - &temp.0, - "git", - &["config", "user.email", "blame@example.test"], - ); - let path = temp.0.join("-literal [file].txt"); - fs::write(&path, "first\nsecond\n").unwrap(); - command(&temp.0, "git", &["add", "--", "-literal [file].txt"]); - command( - &temp.0, - "git", - &["commit", "--quiet", "-m", "Original summary"], - ); - let commit = command(&temp.0, "git", &["rev-parse", "HEAD"]); - (temp, path, commit) - } - #[test] - fn committed_line_has_native_author_summary_and_commit() { - let (_temp, path, commit) = fixture(); - let rows = read_file(&path, "first\nsecond\n", &|| false) - .unwrap() - .unwrap(); - assert_eq!(rows.len(), 2); - assert!(Arc::ptr_eq( - rows[0].as_ref().unwrap(), - rows[1].as_ref().unwrap() - )); - assert_eq!( - **rows[1].as_ref().unwrap(), - LineBlame { - commit, - author: "Blame Author".into(), - summary: "Original summary".into(), - uncommitted: false - } - ); - } - #[test] - fn unsaved_changed_line_is_uncommitted_without_metadata() { - let (_temp, path, _) = fixture(); - let rows = read_file(&path, "changed\nsecond\n", &|| false) - .unwrap() - .unwrap(); - assert_eq!( - **rows[0].as_ref().unwrap(), - LineBlame { - commit: String::new(), - author: String::new(), - summary: String::new(), - uncommitted: true - } - ); - assert_eq!(fs::read_to_string(path).unwrap(), "first\nsecond\n"); - } - #[test] - fn inserted_line_preserves_following_original_attribution() { - let (_temp, path, commit) = fixture(); - let contents = "inserted\nfirst\nsecond\n"; - let rows = read_file(&path, contents, &|| false).unwrap().unwrap(); - assert!(rows[0].as_ref().unwrap().uncommitted); - assert_eq!(rows[2].as_ref().unwrap().commit, commit); - assert_eq!(rows.len(), 3); - assert_eq!(read_file(&path, "", &|| false).unwrap(), None); - } - #[test] - fn unsaved_insertions_and_deletions_keep_shifted_commit_attribution() { - let (temp, path, original) = fixture(); - fs::write(&path, "first\nmiddle\nsecond\ntail\n").unwrap(); - command(&temp.0, "git", &["add", "--", "-literal [file].txt"]); - command(&temp.0, "git", &["commit", "--quiet", "-m", "Added lines"]); - let added = command(&temp.0, "git", &["rev-parse", "HEAD"]); - let rows = read_file( - &path, - "unsaved\nfirst\nsecond\nunsaved tail\ntail\n", - &|| false, - ) - .unwrap() - .unwrap(); - assert!(rows[0].as_ref().unwrap().uncommitted); - assert_eq!(rows[1].as_ref().unwrap().commit, original); - assert_eq!(rows[2].as_ref().unwrap().commit, original); - assert!(Arc::ptr_eq( - rows[1].as_ref().unwrap(), - rows[2].as_ref().unwrap() - )); - assert!(rows[3].as_ref().unwrap().uncommitted); - assert!(Arc::ptr_eq( - rows[0].as_ref().unwrap(), - rows[3].as_ref().unwrap() - )); - assert_eq!(rows[4].as_ref().unwrap().commit, added); - assert_eq!(rows[4].as_ref().unwrap().summary, "Added lines"); - assert_eq!( - fs::read_to_string(path).unwrap(), - "first\nmiddle\nsecond\ntail\n" - ); - } - #[test] - fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() { - let temp = Temporary::new().unwrap(); - let path = temp.0.join("file"); - assert_eq!(read_file(&path, "text", &|| false).unwrap(), None); - fs::write(&path, "text").unwrap(); - assert_eq!(read_file(&path, "text", &|| false).unwrap(), None); - command(&temp.0, "git", &["init", "--quiet", "--template="]); - assert_eq!(read_file(&path, "text", &|| false).unwrap(), None); - let (_committed, tracked, _) = fixture(); - let untracked = tracked.parent().unwrap().join("untracked"); - fs::write(&untracked, "text").unwrap(); - assert_eq!(read_file(&untracked, "text", &|| false).unwrap(), None); - let link = temp.0.join("link"); - std::os::unix::fs::symlink(tracked, &link).unwrap(); - assert_eq!( - read_file(&link, "first\nsecond\n", &|| false).unwrap(), - None - ); - } - #[test] - fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() { - let temp = Temporary::new().unwrap(); - command(&temp.0, "jj", &["git", "init", "--no-colocate", "."]); - let path = temp.0.join("file"); - fs::write(&path, "original\n").unwrap(); - assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None); - command( - &temp.0, - "jj", - &[ - "--config=user.name=\"Jj Author\"", - "--config=user.email=\"jj@example.test\"", - "describe", - "-m", - "Jj summary", - ], - ); - command( - &temp.0, - "jj", - &[ - "--config=user.name=\"Jj Author\"", - "--config=user.email=\"jj@example.test\"", - "new", - ], - ); - let parent = command( - &temp.0, - "jj", - &[ - "--ignore-working-copy", - "log", - "--no-graph", - "-r", - "@-", - "-T", - "commit_id", - ], - ); - let operation = command( - &temp.0, - "jj", - &[ - "--ignore-working-copy", - "op", - "log", - "--no-graph", - "-n", - "1", - "-T", - "id", - ], - ); - let rows = read_file(&path, "original\n", &|| false).unwrap().unwrap(); - let line = rows[0].as_ref().unwrap(); - assert_eq!(line.commit, parent); - assert_eq!(line.author, "Jj Author"); - assert_eq!(line.summary, "Jj summary"); - fs::write(&path, "dirty on disk\n").unwrap(); - let shifted = read_file(&path, "inserted\noriginal\n", &|| false) - .unwrap() - .unwrap(); - assert!(shifted[0].as_ref().unwrap().uncommitted); - assert_eq!(shifted[1].as_ref().unwrap().commit, parent); - assert!( - read_file(&path, "unsaved\n", &|| false).unwrap().unwrap()[0] - .as_ref() - .unwrap() - .uncommitted - ); - assert_eq!( - command( - &temp.0, - "jj", - &[ - "--ignore-working-copy", - "op", - "log", - "--no-graph", - "-n", - "1", - "-T", - "id" - ] - ), - operation - ); - assert!(!temp.0.join(".git").exists()); - command(&temp.0, "jj", &["new", "root()"]); - fs::write(temp.0.join("other"), "sibling\n").unwrap(); - command(&temp.0, "jj", &["describe", "-m", "Sibling"]); - command(&temp.0, "jj", &["new", &parent, "@"]); - assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None); - } - #[test] - fn incremental_groups_share_metadata_across_ranges_and_repeated_commits() { - let first = "1111111111111111111111111111111111111111"; - let second = "2222222222222222222222222222222222222222"; - let output = format!( - "{first} 5 3 2\nauthor First\nsummary First summary\nfilename file\n\ - {second} 8 2 1\nauthor Second\nsummary Second summary\nfilename file\n\ - {first} 1 1 1\nfilename old-file\n" - ); - let rows = parse(output.as_bytes(), 4).unwrap(); - assert_eq!(rows[0].as_ref().unwrap().commit, first); - assert_eq!(rows[1].as_ref().unwrap().author, "Second"); - assert_eq!(rows[1].as_ref().unwrap().commit, second); - assert!(Arc::ptr_eq( - rows[0].as_ref().unwrap(), - rows[2].as_ref().unwrap() - )); - assert!(Arc::ptr_eq( - rows[2].as_ref().unwrap(), - rows[3].as_ref().unwrap() - )); - } - #[test] - fn malformed_incremental_groups_never_echo_metadata() { - let commit = "1111111111111111111111111111111111111111"; - let metadata = "author secret\nsummary secret\nfilename secret\n"; - let valid = format!("{commit} 1 1 1\n{metadata}"); - for output in [ - "secret metadata".into(), - "\u{fffd}secret metadata".into(), - format!("{commit} 1 2 1\n{metadata}"), - format!("{commit} 1 1 2\n{metadata}"), - format!("{commit} 0 1 1\n{metadata}"), - format!("{commit} 1 1 0\n{metadata}"), - format!("{commit} 1 1 1 extra\n{metadata}"), - format!("{commit} 1 1 1\nauthor secret\nfilename secret\n"), - format!( - "{commit} 1 1 1\nauthor secret\nauthor secret\nsummary secret\nfilename secret\n" - ), - format!("{commit} 1 1 1\nauthor secret\nsummary secret\n\tsecret source\n"), - format!("{valid}{commit} 2 1 1\nfilename secret\n"), - format!( - "{commit} 1 1 1\nauthor secret\nsummary secret\n{commit} 2 1 1\nfilename secret\n" - ), - format!("{commit} {} 1 2\n{metadata}", usize::MAX), - ] { - assert_eq!( - parse(output.as_bytes(), 1).unwrap_err(), - "Invalid Git blame metadata" - ); - } - assert_eq!( - parse(valid.as_bytes(), 2).unwrap_err(), - "Invalid Git blame metadata" - ); - assert_eq!( - parse(b"\xffsecret metadata", 1).unwrap_err(), - "Invalid Git blame metadata" - ); - } - #[test] - fn cancelled_commands_kill_and_reap_pipe_holding_children() { - let temp = Temporary::new().unwrap(); - let ready = temp.0.join("ready"); - let cancellation = Arc::new(AtomicBool::new(false)); - let stop = Arc::clone(&cancellation); - let gate = ready.clone(); - let worker = thread::spawn(move || { - let mut command = Command::new("sh"); - command - .args([ - "-c", - "sleep 600 & printf ready > \"$1\"; wait", - "vamp-blame-test", - ]) - .arg(gate); - run(command, &[], &|| stop.load(Ordering::Acquire)) - }); - while !ready.exists() && !worker.is_finished() { - thread::yield_now(); - } - assert!(ready.exists()); - cancellation.store(true, Ordering::Release); - assert_eq!(worker.join().unwrap().unwrap_err(), "Blame cancelled"); - } - #[test] - fn worker_keeps_only_latest_request_and_bounds_input() { - let (_temp, path, commit) = fixture(); - let mut worker = BlameWorker::start().unwrap(); - for id in 0..20 { - worker - .request(id, path.clone(), "first\nsecond\n".into()) - .unwrap(); - } - loop { - if let Some((id, result)) = worker.poll() { - assert_eq!(id, 19); - let rows = result.unwrap().unwrap(); - assert_eq!(rows.len(), 2); - assert_eq!(rows[1].as_ref().unwrap().commit, commit); - assert!(Arc::ptr_eq( - rows[0].as_ref().unwrap(), - rows[1].as_ref().unwrap() - )); - break; - } - thread::yield_now(); - } - assert_eq!( - worker - .request(20, path, "x".repeat(LIMIT + 1)) - .unwrap_err() - .kind(), - io::ErrorKind::InvalidInput - ); - } - #[test] - fn subprocess_output_is_bounded() { - let mut command = Command::new("sh"); - command.args(["-c", "dd if=/dev/zero bs=1048576 count=17 2>/dev/null"]); - assert_eq!( - run(command, &[], &|| false).unwrap_err(), - "Blame output exceeds 16 MiB" - ); - } -} +#[path = "../tests/unit/blame.rs"] +mod tests; diff --git a/src/chrome.rs b/src/chrome.rs index b8d704f..7998dff 100644 --- a/src/chrome.rs +++ b/src/chrome.rs @@ -363,185 +363,5 @@ pub fn labels<'de, D: Deserializer<'de>>( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn popup_partial_options_keep_command_and_search_independent() { - let popup: PopupConfig = serde_json::from_value(serde_json::json!({ - "command": {"anchor": "top_right"} - })) - .unwrap(); - assert_eq!(popup.command.anchor, PopupAnchor::TopRight); - assert_eq!(popup.search.anchor, PopupAnchor::BottomLeft); - let popup: PopupConfig = serde_json::from_value(serde_json::json!({ - "command": {}, "search": {"anchor": "center_left"} - })) - .unwrap(); - assert_eq!(popup.command.anchor, PopupAnchor::BottomLeft); - assert_eq!(popup.search.anchor, PopupAnchor::CenterLeft); - } - - #[test] - fn malformed_popup_options_are_rejected_at_the_boundary() { - for value in [ - serde_json::json!(null), - serde_json::json!([]), - serde_json::json!(7), - serde_json::json!({"selector": {}}), - ] { - assert!( - serde_json::from_value::(value.clone()).is_err(), - "{value}" - ); - } - for options in [ - serde_json::json!(null), - serde_json::json!([]), - serde_json::json!("center"), - serde_json::json!({"anchor": null}), - serde_json::json!({"anchor": 7}), - serde_json::json!({"anchor": true}), - serde_json::json!({"anchor": []}), - serde_json::json!({"anchor": {}}), - serde_json::json!({"anchor": "middle"}), - serde_json::json!({"anchor": "TopLeft"}), - serde_json::json!({"width": 72}), - serde_json::json!({"anchor": "center", "callback": "run"}), - ] { - for mode in ["command", "search"] { - let mut popup = serde_json::json!({}); - popup[mode] = options.clone(); - assert!( - serde_json::from_value::(popup).is_err(), - "{mode}: {options}" - ); - } - } - } - - #[derive(Deserialize)] - struct Lines { - #[serde(deserialize_with = "top")] - top: ChromeLine, - #[serde(deserialize_with = "bottom")] - bottom: ChromeLine, - } - - #[test] - fn omitted_options_inherit_and_explicit_groups_replace_defaults() { - let lines: Lines = serde_json::from_value(serde_json::json!({ - "top": {"right": ["battery"], "tabs": {"title": "T{index}:{path}{modified}"}, "battery": {"padding": 0}}, - "bottom": {"left": [], "center": ["workspace"]} - })).unwrap(); - assert_eq!(lines.top.left, ["tabs"]); - assert_eq!(lines.top.segment("battery").padding, 0); - assert!(lines.bottom.left.is_empty()); - assert_eq!(lines.bottom.right, ["language", "lsp", "position"]); - assert_eq!(lines.bottom.segment("workspace"), &SegmentConfig::default()); - } - - #[test] - fn both_positions_accept_all_builtins_and_custom_styles() { - let names = [ - "tabs", - "mode", - "pending", - "buffer", - "diagnostics", - "path", - "selections", - "count", - "language", - "lsp", - "position", - "battery", - "workspace", - ]; - let options = serde_json::json!({ - "left": names, - "center": ["language"], - "right": ["tabs"], - "tabs": {"title": "{index}:{name}:{path}{modified}", "padding": 0}, - "mode": {"leading_edge": "<", "trailing_edge": ">"}, - "workspace": {"first_leading_edge": ""} - }); - let lines: Lines = serde_json::from_value(serde_json::json!({ - "top": options, - "bottom": options, - })) - .unwrap(); - for line in [&lines.top, &lines.bottom] { - assert_eq!(line.left, names); - assert_eq!(line.center, ["language"]); - assert_eq!(line.right, ["tabs"]); - assert_eq!(line.segment("tabs").padding, 0); - assert_eq!(line.segment("mode").leading_edge, "<"); - assert_eq!( - line.segment("workspace").first_leading_edge.as_deref(), - Some("") - ); - } - } - - #[test] - fn view_placeholder_is_valid_in_every_segment_and_bar() { - for name in [ - "tabs", - "mode", - "pending", - "buffer", - "diagnostics", - "path", - "selections", - "count", - "language", - "lsp", - "position", - "battery", - "workspace", - ] { - let options = serde_json::json!({ - "left": [name], - (name): {"title": "{index}:{view}{name}:{path}{modified}"} - }); - let lines: Lines = serde_json::from_value(serde_json::json!({ - "top": options, - "bottom": options, - })) - .unwrap(); - for line in [&lines.top, &lines.bottom] { - assert_eq!( - line.segment(name).title, - "{index}:{view}{name}:{path}{modified}" - ); - } - } - } - - #[test] - fn malformed_chrome_is_rejected_at_the_boundary() { - for options in [ - serde_json::json!({"left": ["bad-name"]}), - serde_json::json!({"left": null}), - serde_json::json!({"tabs": {"padding": 65}}), - serde_json::json!({"tabs": {"padding": null}}), - serde_json::json!({"tabs": {"margin": 65}}), - serde_json::json!({"tabs": {"margin": -1}}), - serde_json::json!({"tabs": {"margin": null}}), - serde_json::json!({"tabs": {"margin": "1"}}), - serde_json::json!({"tabs": {"margin": 1.5}}), - serde_json::json!({"tabs": {"leading_edge": "\n"}}), - serde_json::json!({"tabs": {"title": "{unknown}"}}), - serde_json::json!({"tabs": {"title": "{name"}}), - serde_json::json!({"tabs": {"title": "name}"}}), - serde_json::json!({"tabs": {"colour": "red"}}), - ] { - for position in ["top", "bottom"] { - let mut frame = serde_json::json!({"top": {}, "bottom": {}}); - frame[position] = options.clone(); - assert!(serde_json::from_value::(frame).is_err()); - } - } - } -} +#[path = "../tests/unit/chrome.rs"] +mod tests; diff --git a/src/clipboard.rs b/src/clipboard.rs index 07ff087..0b82766 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -324,91 +324,7 @@ mod macos { #[cfg(test)] mod tests { - use super::*; - unsafe extern "C" { - fn PasteboardSynchronize(pasteboard: Object) -> u32; - fn PasteboardGetItemIdentifier( - pasteboard: Object, - index: isize, - item: *mut usize, - ) -> i32; - fn PasteboardCopyItemFlavorData( - pasteboard: Object, - item: usize, - flavor: Object, - data: *mut Object, - ) -> i32; - fn CFDataGetBytePtr(data: Object) -> *const u8; - fn CFDataGetLength(data: Object) -> isize; - } - #[test] - fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() { - // A private pasteboard leaves the user's clipboard untouched. - let _pool = unsafe { AutoreleasePool(objc_autoreleasePoolPush()) }; - let name = unsafe { - Owned::new(CFStringCreateWithCString( - ptr::null(), - c"vamp-native-clipboard-regression".as_ptr(), - 0x0800_0100, - )) - .unwrap() - }; - let board = unsafe { - let mut board = ptr::null(); - check(PasteboardCreate(name.0, &mut board)).unwrap(); - Owned::new(board).unwrap() - }; - let reader = unsafe { - let send: unsafe extern "C" fn(Object, Object, Object) -> Object = - std::mem::transmute(objc_msgSend as unsafe extern "C" fn()); - send( - objc_getClass(c"NSPasteboard".as_ptr()), - sel_registerName(c"pasteboardWithName:".as_ptr()), - name.0, - ) - }; - for text in [ - "", - "é e\u{301} 👨‍💻\n\0tail", - "{\\rtf1 plain text}", - "%!PS-Adobe-3.0\nplain", - ] { - write_to(name.0, text).unwrap(); - assert_eq!(read_from(reader).unwrap(), text); - unsafe { - PasteboardSynchronize(board.0); - let mut item = 0; - check(PasteboardGetItemIdentifier(board.0, 1, &mut item)).unwrap(); - let flavor = Owned::new(CFStringCreateWithCString( - ptr::null(), - c"public.utf8-plain-text".as_ptr(), - 0x0800_0100, - )) - .unwrap(); - let mut data = ptr::null(); - check(PasteboardCopyItemFlavorData( - board.0, item, flavor.0, &mut data, - )) - .unwrap(); - let data = Owned::new(data).unwrap(); - let length = CFDataGetLength(data.0) as usize; - let bytes = if length == 0 { - &[] - } else { - std::slice::from_raw_parts(CFDataGetBytePtr(data.0), length) - }; - assert_eq!(bytes, text.as_bytes()); - } - } - let maximum = "x".repeat(super::super::MAX_CLIPBOARD_BYTES); - write_to(name.0, &maximum).unwrap(); - assert_eq!(read_from(reader).unwrap(), maximum); - write_to(name.0, &(maximum + "x")).unwrap(); - assert_eq!( - read_from(reader).unwrap_err(), - "System clipboard text exceeds 4 MiB" - ); - } + include!("../tests/unit/clipboard_macos.rs"); } } @@ -625,197 +541,5 @@ unsafe extern "C" { } #[cfg(all(test, any(target_os = "macos", target_os = "linux")))] -mod tests { - use super::*; - use std::fs; - use std::os::unix::fs::PermissionsExt; - use std::sync::atomic::{AtomicU64, Ordering}; - - static FIXTURE_ID: AtomicU64 = AtomicU64::new(0); - - struct Fixture(PathBuf); - - impl Fixture { - fn new() -> Self { - let path = std::env::temp_dir().join(format!( - "vamp-clipboard-{}-{}", - std::process::id(), - FIXTURE_ID.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir(&path).unwrap(); - Self(path) - } - - fn command(&self, name: &str, body: &str) -> PathBuf { - let path = self.0.join(name); - fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); - path - } - - fn clipboard(&self, text: &[u8]) -> SystemClipboard { - let state = self.0.join("state"); - fs::write(&state, text).unwrap(); - let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''")); - SystemClipboard::with_commands( - self.command("read", &format!("exec /bin/cat {state}")), - self.command("write", &format!("exec /bin/cat > {state}")), - ) - } - } - - impl Drop for Fixture { - fn drop(&mut self) { - fs::remove_dir_all(&self.0).unwrap(); - } - } - - #[test] - fn construction_does_not_probe_or_spawn() { - assert!(SystemClipboard::new().commands.is_none()); - } - - #[test] - fn round_trips_unicode_whitespace_and_large_pipes_exactly() { - let fixture = Fixture::new(); - let mut clipboard = fixture.clipboard(b""); - for text in ["", " héllo\t世界🙂\r\n\n", &"é\n".repeat(300_000)] { - clipboard.write(text).unwrap(); - assert_eq!(fs::read(fixture.0.join("state")).unwrap(), text.as_bytes()); - assert_eq!(clipboard.read().unwrap(), text); - } - } - - #[test] - fn rejects_oversize_and_invalid_utf8_without_leaking_contents() { - let fixture = Fixture::new(); - let mut clipboard = fixture.clipboard(&[0xff, b'\n']); - assert_eq!( - clipboard.read().unwrap_err(), - "System clipboard is not valid UTF-8" - ); - let maximum = "é".repeat(MAX_CLIPBOARD_BYTES / 2); - clipboard.write(&maximum).unwrap(); - assert_eq!(clipboard.read().unwrap(), maximum); - let too_large = format!("{maximum}x"); - assert_eq!( - clipboard.write(&too_large).unwrap_err(), - "System clipboard text exceeds 4 MiB" - ); - assert_eq!( - fs::read(fixture.0.join("state")).unwrap(), - maximum.as_bytes() - ); - fs::write(fixture.0.join("state"), &too_large).unwrap(); - assert_eq!( - clipboard.read().unwrap_err(), - "System clipboard text exceeds 4 MiB" - ); - let mut incomplete = vec![b'x'; MAX_CLIPBOARD_BYTES - 1]; - incomplete.push(0xc3); - fs::write(fixture.0.join("state"), incomplete).unwrap(); - assert_eq!( - clipboard.read().unwrap_err(), - "System clipboard is not valid UTF-8" - ); - } - - #[test] - fn command_failures_and_stderr_cannot_leak_clipboard_text() { - let fixture = Fixture::new(); - let failed = fixture.command( - "failed", - "printf 'private clipboard payload' >&2\nprintf 'private clipboard payload'\nexit 23", - ); - let mut clipboard = SystemClipboard::with_commands(failed.clone(), failed); - let error = clipboard.read().unwrap_err(); - assert!(error.contains("failed")); - assert!(!error.contains("private clipboard payload")); - let error = clipboard.write("private clipboard payload").unwrap_err(); - assert!(!error.contains("private clipboard payload")); - let missing = fixture.0.join("not-an-executable"); - let mut clipboard = SystemClipboard::with_commands(missing.clone(), missing); - assert!(clipboard.read().unwrap_err().contains("Cannot start")); - assert!( - clipboard - .write("secret") - .unwrap_err() - .contains("Cannot start") - ); - } - - #[test] - fn ignored_stderr_does_not_block_large_writes() { - let fixture = Fixture::new(); - let mut clipboard = fixture.clipboard(b""); - let state = fixture.0.join("state"); - let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''")); - clipboard - .commands - .as_mut() - .unwrap() - .as_mut() - .unwrap() - .writer - .as_mut() - .unwrap() - .program = fixture.command( - "noisy", - &format!("/bin/dd if=/dev/zero bs=65536 count=8 >&2\nexec /bin/cat > {state}"), - ); - let text = "λ\n".repeat(300_000); - clipboard.write(&text).unwrap(); - assert_eq!(clipboard.read().unwrap(), text); - } - - #[test] - fn failed_process_cleanup_kills_and_reaps_a_running_child() { - use std::io::Read; - use std::os::unix::process::CommandExt; - use std::process::{Command, Stdio}; - - unsafe extern "C" { - fn waitpid(pid: i32, status: *mut i32, options: i32) -> i32; - } - - let fixture = Fixture::new(); - let hung = fixture.command("running", "printf R\nwhile :; do :; done"); - let mut command = Command::new(hung); - command - .process_group(0) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - let mut child = command.spawn().unwrap(); - let pid = child.id() as i32; - let mut ready = [0]; - child - .stdout - .as_mut() - .unwrap() - .read_exact(&mut ready) - .unwrap(); - assert_eq!(ready, [b'R']); - drop(ClipboardProcess(Some(child))); - let mut status = 0; - // SAFETY: waitpid's output pointer is valid; WNOHANG cannot block this check. - assert_eq!(unsafe { waitpid(pid, &mut status, 1) }, -1); - assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(10)); - } - - #[test] - fn immediate_injected_timeout_terminates_nonterminating_commands() { - let fixture = Fixture::new(); - let hung = fixture.command("hung", "while :; do :; done"); - let mut clipboard = SystemClipboard::with_commands(hung.clone(), hung); - // No assertion depends on wall-clock speed or a deadline remaining in the future. - clipboard.timeout = Duration::ZERO; - assert_eq!( - clipboard.read().unwrap_err(), - "System clipboard command timed out" - ); - assert_eq!( - clipboard.write("text").unwrap_err(), - "System clipboard command timed out" - ); - } -} +#[path = "../tests/unit/clipboard.rs"] +mod tests; diff --git a/src/diff.rs b/src/diff.rs index b4982f9..3fac378 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -2502,2563 +2502,5 @@ fn update_eof_notice(file: &mut ChangedFile) { } #[cfg(test)] -mod tests { - use super::*; - use crate::editor::{Editor, Effect, Mode}; - std::thread_local! { - pub(super) static WIDTH_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; - } - - #[test] - fn resolver_equal_text_retains_native_rows_and_cached_viewport() { - for text in ["", "\n", "same\n", "same"] { - let lines: Vec<_> = text.split('\n').map(str::to_owned).collect(); - let mut state = DiffState::new(); - state.set_resolver_text(&lines, &lines); - assert_eq!(state.pane_layout(), PaneLayout::Split); - assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); - assert_eq!( - state.comparison.as_ref().unwrap().files[0].path, - Path::new("resolution.txt") - ); - assert_eq!(state.files.as_ref().unwrap().entries().len(), 1); - for row in 0..lines.len() { - assert!(state.source_display_row(Focus::Before, row).is_some()); - assert!(state.source_display_row(Focus::After, row).is_some()); - } - let id = state.comparison_id; - state.top = 1; - state.left_before = 3; - state.left_after = 4; - state.set_resolver_text(&lines, &lines); - assert_eq!(state.comparison_id, id); - assert_eq!((state.top, state.left_before, state.left_after), (1, 3, 4)); - } - } - - #[test] - fn resolver_catalog_reveals_the_current_native_file() { - let mut state = DiffState::new(); - state.set_resolver_paths( - PathBuf::new(), - vec!["binary.bin".into(), "first.txt".into(), "second.txt".into()], - 1, - ); - let files = state.files.as_ref().unwrap(); - assert_eq!( - files.entries().nth(files.selected).unwrap().path, - Path::new("first.txt") - ); - assert!(!files.is_conflicted(Path::new("first.txt"))); - state - .files - .as_mut() - .unwrap() - .set_conflicted(Path::new("first.txt"), true); - state.set_resolver_paths( - PathBuf::new(), - vec!["binary.bin".into(), "first.txt".into(), "second.txt".into()], - 2, - ); - let files = state.files.as_ref().unwrap(); - assert_eq!( - files.entries().nth(files.selected).unwrap().path, - Path::new("second.txt") - ); - assert!(files.is_conflicted(Path::new("first.txt"))); - assert!(!files.is_conflicted(Path::new("second.txt"))); - } - - fn excerpt_state(len: usize, changed: &[usize]) -> DiffState { - let mut state = DiffState::new(); - state.phase = Phase::View; - state.focus = Focus::Before; - state.rows = (0..len) - .map(|index| AlignedRow { - before: Some(index), - after: Some(index + 3), - kind: if changed.contains(&index) { - RowKind::Changed - } else { - RowKind::Equal - }, - changes: Vec::new(), - }) - .collect(); - state.reset_excerpts(); - state - } - - #[test] - fn excerpts_cover_context_and_hidden_rows_once_without_reindexing() { - let state = excerpt_state(100, &[30, 70]); - assert_eq!(state.excerpt_ranges, [(20, 41), (60, 81)]); - assert_eq!( - state.display_rows[0], - DisplayRow::Fold { start: 0, end: 20 } - ); - assert_eq!( - state.display_rows[22], - DisplayRow::Fold { start: 41, end: 60 } - ); - assert_eq!( - state.display_rows[44], - DisplayRow::Fold { - start: 81, - end: 100 - } - ); - let mut covered = Vec::new(); - for row in &state.display_rows { - match *row { - DisplayRow::Line(index) => { - covered.push(index); - assert_eq!(state.rows[index].before, Some(index)); - assert_eq!(state.rows[index].after, Some(index + 3)); - } - DisplayRow::Fold { start, end } => { - assert!( - state.rows[start..end] - .iter() - .all(|row| row.kind == RowKind::Equal) - ); - covered.extend(start..end); - } - } - } - assert_eq!(covered, (0..100).collect::>()); - for index in [30, 70] { - assert_eq!( - state - .display_rows - .iter() - .filter(|&&row| row == DisplayRow::Line(index)) - .count(), - 1 - ); - } - } - - #[test] - fn zero_context_boundaries_adjacent_changes_and_touching_ranges() { - let mut state = excerpt_state(60, &[0, 1, 30, 59]); - state.set_context_options(0, 15); - assert_eq!(state.excerpt_ranges, [(0, 2), (30, 31), (59, 60)]); - assert_eq!( - state.display_rows, - [ - DisplayRow::Line(0), - DisplayRow::Line(1), - DisplayRow::Fold { start: 2, end: 30 }, - DisplayRow::Line(30), - DisplayRow::Fold { start: 31, end: 59 }, - DisplayRow::Line(59), - ] - ); - assert_eq!(excerpt_state(60, &[20, 41]).excerpt_ranges, [(10, 52)]); - assert_eq!( - excerpt_state(3, &[0, 1, 2]).display_rows, - [ - DisplayRow::Line(0), - DisplayRow::Line(1), - DisplayRow::Line(2), - ] - ); - } - - #[test] - fn expansion_anchors_source_rows_merges_and_clamps_both_directions() { - let mut state = excerpt_state(100, &[30, 70]); - state.top = 11; - state.handle(Input::Char('[')); - assert_eq!(state.excerpt_ranges, [(5, 41), (60, 81)]); - assert_eq!(state.display_rows[state.top], DisplayRow::Line(30)); - state.handle(Input::Char('[')); - assert_eq!(state.excerpt_ranges, [(0, 41), (60, 81)]); - state.handle(Input::Char(']')); - assert_eq!(state.excerpt_ranges, [(0, 56), (60, 81)]); - state.handle(Input::Char(']')); - assert_eq!(state.excerpt_ranges, [(0, 81)]); - assert_eq!(state.display_rows[state.top], DisplayRow::Line(30)); - state.handle(Input::Char(']')); - assert_eq!(state.excerpt_ranges, [(0, 96)]); - state.handle(Input::Char(']')); - state.handle(Input::Char(']')); - assert_eq!(state.excerpt_ranges, [(0, 100)]); - assert_eq!(state.display_rows[state.top], DisplayRow::Line(30)); - } - - #[test] - fn folds_choose_following_excerpt_or_previous_at_end() { - let mut state = excerpt_state(100, &[30, 70]); - state.handle(Input::Char('[')); - assert_eq!(state.display_rows[state.top], DisplayRow::Line(5)); - state.top = state - .display_rows - .iter() - .position(|&row| row == (DisplayRow::Fold { start: 41, end: 60 })) - .unwrap(); - state.handle(Input::Char('[')); - assert_eq!(state.excerpt_ranges, [(5, 41), (45, 81)]); - assert_eq!(state.display_rows[state.top], DisplayRow::Line(45)); - state.handle(Input::End); - state.handle(Input::Char(']')); - assert_eq!(state.excerpt_ranges, [(5, 41), (45, 96)]); - assert_eq!(state.display_rows[state.top], DisplayRow::Line(81)); - } - - #[test] - fn navigation_uses_display_length_and_custom_increment() { - let mut state = excerpt_state(100, &[30]); - state.handle(Input::End); - state.handle(Input::Down); - assert_eq!(state.top, 22); - state.handle(Input::Up); - assert_eq!(state.top, 21); - state.handle(Input::Home); - state.handle(Input::Up); - assert_eq!(state.top, 0); - state.set_context_options(0, 2); - state.handle(Input::Char('[')); - assert_eq!(state.excerpt_ranges, [(28, 31)]); - assert_eq!(state.display_rows[state.top], DisplayRow::Line(28)); - state.handle(Input::Char(']')); - assert_eq!(state.excerpt_ranges, [(28, 33)]); - } - - #[test] - fn metadata_empty_and_wholly_added_removed_files_preserve_alignment() { - let mut state = DiffState::new(); - let mut comparison = comparison(); - comparison.files[0].hunks.clear(); - comparison.files[0].after = comparison.files[0].before.clone(); - state.comparison = Some(comparison); - state.align_file(); - assert!(state.excerpt_ranges.is_empty()); - assert!(state.display_rows.is_empty()); - assert_eq!(state.rows.len(), 5); - state.file = 1; - state.align_file(); - assert_eq!( - state.display_rows, - [DisplayRow::Line(0), DisplayRow::Line(1)] - ); - assert!( - state - .rows - .iter() - .all(|row| row.kind == RowKind::Added && row.before.is_none()) - ); - let file = &mut state.comparison.as_mut().unwrap().files[1]; - std::mem::swap(&mut file.before, &mut file.after); - file.hunks[0].before_len = 2; - file.hunks[0].after_len = 0; - state.align_file(); - assert_eq!( - state.display_rows, - [DisplayRow::Line(0), DisplayRow::Line(1)] - ); - assert!( - state - .rows - .iter() - .all(|row| row.kind == RowKind::Removed && row.after.is_none()) - ); - state.file = 2; - state.align_file(); - assert!(state.rows.is_empty()); - assert!(state.display_rows.is_empty()); - assert!(state.comparison.as_ref().unwrap().files[2].notice.is_some()); - } - - fn revision( - target: RevisionTarget, - label: &str, - description: &str, - category: Category, - ) -> Revision { - Revision { - target, - label: label.into(), - description: description.into(), - category, - } - } - - fn catalog() -> Catalog { - Catalog { - kind: RepoKind::Git, - entries: vec![ - revision( - RevisionTarget::Working, - "Working checkout", - "", - Category::Commits, - ), - revision( - RevisionTarget::Commit("abcdef123".into()), - "abcdef123", - "Fix 界 e\u{301}", - Category::Commits, - ), - revision( - RevisionTarget::Commit("fedcba321".into()), - "fedcba321", - "Another change", - Category::Commits, - ), - revision( - RevisionTarget::FetchRemote("origin".into()), - "Load history from origin", - "", - Category::Commits, - ), - revision( - RevisionTarget::Commit("branch-tip".into()), - "topic/branch", - "", - Category::Branches, - ), - revision( - RevisionTarget::Remote { - remote: "origin".into(), - reference: "refs/heads/topic".into(), - }, - "topic@origin", - "", - Category::Branches, - ), - revision( - RevisionTarget::Commit("tag-tip".into()), - "release", - "", - Category::Tags, - ), - ], - notice: None, - } - } - - fn pull_page(closed: bool, rows: &[(u64, &str, &str)], has_more: bool) -> Outcome { - Outcome::PullRequestPage { - closed, - result: Ok(PullRequestPage { - kind: RepoKind::Git, - remote: "origin".into(), - has_more, - entries: rows - .iter() - .map(|&(number, title, date)| { - ( - revision( - RevisionTarget::PullRequest { - remote: "origin".into(), - number, - }, - &format!("#{number} {title}"), - "Alice · main ← topic", - if closed { - Category::Closed - } else { - Category::Open - }, - ), - date.into(), - ) - }) - .collect(), - }), - } - } - - #[test] - fn pull_request_categories_keep_query_and_select_loaded_rows_before_completion() { - let mut state = DiffState::pull_requests("origin".into()); - assert_eq!( - state.categories(), - [Category::Open, Category::Closed, Category::All] - ); - state.take_request(); - state.handle(Input::Char('\t')); - for ch in "FIX üni Alice".chars() { - state.handle(Input::Char(ch)); - } - state.apply_result( - 1, - Ok(pull_page( - true, - &[(2, "Fix Ünicode", "2026-01-02T00:00:00Z")], - true, - )), - ); - assert!(state.loading); - assert!(state.pull_request_partial()); - assert_eq!(state.entries().next().unwrap().label, "#2 Fix Ünicode"); - state.handle(Input::Char('\t')); - state.apply_result( - 1, - Ok(pull_page( - false, - &[ - (1, "Fix Ünicode", "2026-01-03T00:00:00Z"), - (3, "Other work", "2026-01-01T00:00:00Z"), - ], - true, - )), - ); - assert_eq!( - state - .entries() - .map(|entry| entry.label.as_str()) - .collect::>(), - ["#1 Fix Ünicode", "#2 Fix Ünicode"] - ); - state.handle(Input::Char('\t')); - assert_eq!(state.query, "FIX üni Alice"); - state.comparison_mode = ComparisonMode::Snapshots; - state.handle(Input::Enter); - assert_eq!(state.phase, Phase::After); - assert!(state.before.is_none() && state.after.is_none()); - assert_eq!(state.comparison_mode, ComparisonMode::BranchChanges); - assert!(matches!( - state.take_request(), - Some(Operation::ResolvePullRequest(Revision { - target: RevisionTarget::PullRequest { number: 1, .. }, - .. - })) - )); - } - - #[test] - fn pull_request_all_streams_sort_deduplicate_and_preserve_selection_identity() { - let mut state = DiffState::pull_requests("origin".into()); - state.take_request(); - state.handle(Input::BackTab); - state.apply_result( - 1, - Ok(pull_page( - false, - &[ - (1, "First", "2026-01-01T00:00:00Z"), - (2, "Second", "2026-01-02T00:00:00Z"), - ], - true, - )), - ); - state.handle(Input::End); - assert_eq!( - state.entries().nth(state.selected).unwrap().label, - "#1 First" - ); - state.apply_result(1, Ok(pull_page(true, &[], true))); - assert!(state.pull_request_partial()); - state.apply_result( - 1, - Ok(pull_page( - true, - &[(3, "Newest", "2026-01-03T00:00:00Z")], - true, - )), - ); - assert_eq!( - state.entries().nth(state.selected).unwrap().label, - "#1 First" - ); - state.apply_result( - 1, - Ok(pull_page( - false, - &[(2, "Second updated", "2026-01-02T00:00:00Z")], - false, - )), - ); - assert_eq!(state.entries().len(), 3); - assert_eq!( - state - .entries() - .map(|entry| entry.label.as_str()) - .collect::>(), - ["#3 Newest", "#2 Second updated", "#1 First"] - ); - assert!(state.pull_request_partial()); - state.apply_result(1, Ok(pull_page(true, &[], false))); - assert!(!state.pull_request_partial()); - assert!(!state.loading); - assert_eq!( - state.entries().nth(state.selected).unwrap().label, - "#1 First" - ); - } - - #[test] - fn pull_request_late_category_errors_retry_without_losing_rows_or_other_results() { - let mut editor = editor(); - editor.execute("pull_requests origin"); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result( - id, - Ok(pull_page( - false, - &[(1, "Open", "2026-01-01T00:00:00Z")], - true, - )), - ); - editor.apply_diff_result( - id, - Ok(pull_page( - true, - &[(2, "Closed", "2026-01-02T00:00:00Z")], - true, - )), - ); - editor.handle(Input::Char('\t')); - editor.apply_diff_result( - id, - Ok(Outcome::PullRequestPage { - closed: true, - result: Err("offline".into()), - }), - ); - let diff = editor.diff().unwrap(); - assert!(diff.pull_request_notice().unwrap().contains("offline")); - assert_eq!(diff.entries().next().unwrap().label, "#2 Closed"); - assert!(diff.pull_request_partial()); - editor.handle(Input::Ctrl('r')); - assert_eq!(editor.diff().unwrap().pull_request_retries, [0, 1]); - assert!(editor.take_diff_request().is_none()); - editor.apply_diff_result( - id, - Ok(pull_page( - false, - &[(3, "More open", "2026-01-03T00:00:00Z")], - false, - )), - ); - assert_eq!( - editor.diff().unwrap().entries().next().unwrap().label, - "#2 Closed" - ); - editor.apply_diff_result(id, Ok(pull_page(true, &[], false))); - assert!(!editor.diff().unwrap().pull_request_partial()); - editor.handle(Input::Enter); - let (_, operation) = editor.take_diff_request().unwrap(); - assert!(matches!(operation, Operation::ResolvePullRequest(_))); - assert!(!editor.apply_diff_result(id, Ok(pull_page(false, &[], false)))); - } - - #[test] - fn pull_request_loading_retry_escape_and_reopen_reject_stale_results() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("unsaved source".into())]) - .unwrap(); - editor.execute("pull_requests origin"); - let (first, _) = editor.take_diff_request().unwrap(); - editor.handle(Input::Enter); - assert!(editor.take_diff_request().is_none()); - text(&mut editor, "alice"); - editor.apply_diff_result( - first, - Ok(Outcome::PullRequestPage { - closed: false, - result: Err("API unavailable".into()), - }), - ); - editor.handle(Input::Enter); - assert_eq!(editor.diff().unwrap().pull_request_retries, [1, 0]); - assert!(editor.take_diff_request().is_none()); - assert!(editor.apply_diff_result( - first, - Ok(pull_page( - false, - &[ - (1, "Open", "2026-01-02T00:00:00Z"), - (2, "Other open", "2026-01-01T00:00:00Z"), - ], - true - )) - )); - editor.handle(Input::Enter); - let (resolve, operation) = editor.take_diff_request().unwrap(); - assert!(matches!(operation, Operation::ResolvePullRequest(_))); - assert!(!editor.apply_diff_result(first, Ok(pull_page(true, &[], false)))); - editor.apply_diff_result(resolve, Err("Historical head no longer retained".into())); - assert!(editor.diff().unwrap().before.is_none()); - assert!(editor.diff().unwrap().after.is_none()); - editor.handle(Input::Down); - editor.handle(Input::Enter); - let (retry, operation) = editor.take_diff_request().unwrap(); - assert!(matches!( - operation, - Operation::ResolvePullRequest(Revision { - target: RevisionTarget::PullRequest { number: 2, .. }, - .. - }) - )); - editor.handle(Input::Escape); - assert!(editor.diff().is_none()); - assert!(!editor.apply_diff_result(retry, Ok(Outcome::Comparison(comparison())))); - assert_eq!(editor.lines(), ["unsaved source"]); - assert!(editor.dirty()); - editor.execute("pull_requests"); - let (reopened, _) = editor.take_diff_request().unwrap(); - assert!(!editor.apply_diff_result(first, Ok(pull_page(false, &[], false)))); - assert!(editor.apply_diff_result(reopened, Ok(pull_page(false, &[], false)))); - assert!(editor.apply_diff_result(reopened, Ok(pull_page(true, &[], false)))); - editor.handle(Input::Enter); - assert!(editor.take_diff_request().is_none()); - } - - #[test] - fn resolved_pull_request_modes_reuse_pinned_endpoints_without_resolving_again() { - let mut state = DiffState::pull_requests("origin".into()); - state.take_request(); - state.apply_result( - 1, - Ok(pull_page( - false, - &[(1, "Open", "2026-01-01T00:00:00Z")], - true, - )), - ); - state.handle(Input::Enter); - state.take_request(); - let before = revision( - RevisionTarget::RemoteCommit { - remote: "origin".into(), - reference: "refs/heads/main".into(), - id: "base-sha".into(), - }, - "main", - "", - Category::Open, - ); - let after = revision( - RevisionTarget::RemoteCommit { - remote: "origin".into(), - reference: "refs/pull/1/head".into(), - id: "head-sha".into(), - }, - "#1 Open", - "", - Category::Open, - ); - state.apply_result( - 2, - Ok(Outcome::ResolvedPullRequest { - before: before.clone(), - after: after.clone(), - }), - ); - assert!(matches!( - state.take_request(), - Some(Operation::Compare { - mode: ComparisonMode::BranchChanges, - .. - }) - )); - state.apply_result(3, Err("No common ancestor".into())); - assert_eq!(state.before.as_ref(), Some(&before)); - assert_eq!(state.after.as_ref(), Some(&after)); - state.toggle_comparison_mode().unwrap(); - let Some(Operation::Compare { - before: pinned_before, - after: pinned_after, - mode, - .. - }) = state.take_request() - else { - panic!("expected ordinary pinned comparison"); - }; - assert_eq!( - (pinned_before, pinned_after, mode), - (before, after, ComparisonMode::Snapshots) - ); - } - - fn editor() -> Editor { - Editor::open(None, vec![]).unwrap() - } - - fn open(editor: &mut Editor) { - assert!(editor.execute("diff").is_none()); - let (id, operation) = editor.take_diff_request().unwrap(); - assert!(matches!(operation, Operation::Discover)); - assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog())))); - } - - fn text(editor: &mut Editor, text: &str) { - for ch in text.chars() { - editor.handle(Input::Char(ch)); - } - } - - fn choose_pair(editor: &mut Editor) -> u64 { - editor.handle(Input::Down); - editor.handle(Input::Enter); - assert_eq!(editor.diff().unwrap().phase, Phase::After); - editor.handle(Input::Enter); - let (id, operation) = editor.take_diff_request().unwrap(); - match operation { - Operation::Compare { before, after, .. } => { - assert_eq!(before.target, RevisionTarget::Commit("abcdef123".into())); - assert_eq!(after.target, RevisionTarget::Working); - } - _ => panic!("expected compare"), - } - id - } - - fn comparison() -> Comparison { - Comparison { - revisions: ["abcdef123".into(), "working:snapshot".into()], - before: "abcdef123".into(), - after: "Working checkout".into(), - root: PathBuf::new(), - editable_paths: BTreeSet::new(), - files: vec![ - ChangedFile { - path: "renamed.txt".into(), - old_path: Some("old.txt".into()), - status: FileStatus::Renamed, - loaded: true, - before: vec![ - "same".into(), - "old".into(), - "tail".into(), - "deleted".into(), - "end".into(), - ], - after: vec![ - "same".into(), - "new".into(), - "extra".into(), - "tail".into(), - "end".into(), - ], - before_eof: false, - after_eof: false, - hunks: vec![ - Hunk { - before_start: 1, - before_len: 1, - after_start: 1, - after_len: 2, - }, - Hunk { - before_start: 3, - before_len: 1, - after_start: 4, - after_len: 0, - }, - ], - notice: None, - }, - ChangedFile { - path: "added.txt".into(), - old_path: None, - status: FileStatus::Added, - loaded: true, - before: vec![], - after: vec!["a".into(), "b".into()], - before_eof: false, - after_eof: false, - hunks: vec![Hunk { - before_start: 0, - before_len: 0, - after_start: 0, - after_len: 2, - }], - notice: None, - }, - ChangedFile { - path: "removed.bin".into(), - old_path: None, - status: FileStatus::Removed, - loaded: true, - before: vec![], - after: vec![], - before_eof: false, - after_eof: false, - hunks: vec![], - notice: Some("Binary file".into()), - }, - ], - } - } - - #[test] - fn status_layout_cycles_only_present_panes_and_normalizes_selection() { - let mut state = live_state(vec!["old".into()], vec!["new".into()], true); - state.comparison = Some(comparison()); - state.files = Some(FilePicker::from_paths( - PathBuf::new(), - [ - (0, "renamed.txt".into()), - (1, "added.txt".into()), - (2, "removed.bin".into()), - ], - )); - for (path, layout, first, last) in [ - ( - "renamed.txt", - PaneLayout::Split, - Focus::Before, - Focus::After, - ), - ("added.txt", PaneLayout::After, Focus::After, Focus::After), - ( - "removed.bin", - PaneLayout::Before, - Focus::Before, - Focus::Before, - ), - ] { - state.files_visible = true; - state.focus = Focus::Files; - assert!(state.select_file(Path::new(path))); - assert_eq!(state.pane_layout(), layout); - state.handle(Input::Char('\t')); - assert_eq!(state.focus, first); - state.handle(Input::BackTab); - assert_eq!(state.focus, Focus::Files); - state.handle(Input::BackTab); - assert_eq!(state.focus, last); - state.handle(Input::Char('\t')); - assert_eq!(state.focus, Focus::Files); - state.files_visible = false; - state.normalize_focus(); - assert_eq!(state.focus, first); - state.handle(Input::BackTab); - assert_eq!(state.focus, last); - state.handle(Input::Char('\t')); - assert_eq!(state.focus, first); - } - state.focus = Focus::Before; - assert!(state.select_file(Path::new("added.txt"))); - assert_eq!(state.focus, Focus::After); - assert!(state.select_file(Path::new("removed.bin"))); - assert_eq!(state.focus, Focus::Before); - assert!(state.select_file(Path::new("renamed.txt"))); - assert_eq!(state.focus, Focus::Before); - } - - #[test] - fn rename_layout_and_editability_include_eof_changes() { - let mut state = live_state(vec!["same".into()], vec!["same".into()], true); - state.comparison.as_mut().unwrap().files[0].status = FileStatus::Renamed; - assert_eq!(state.pane_layout(), PaneLayout::Empty); - assert!(!state.after_editable()); - state.normalize_focus(); - assert_eq!(state.focus, Focus::Before); - state.files_visible = false; - for input in [Input::Char('\t'), Input::BackTab] { - state.handle(input); - assert_eq!(state.focus, Focus::Before); - } - state.comparison.as_mut().unwrap().files[0].after_eof = false; - assert_eq!(state.pane_layout(), PaneLayout::Split); - assert!(state.after_editable()); - state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into(); - assert!(state.after_editable()); - state.comparison.as_mut().unwrap().files[0].status = FileStatus::Removed; - assert!(!state.after_editable()); - state.comparison.as_mut().unwrap().files[0].status = FileStatus::Added; - assert!(state.after_editable()); - let file = &mut state.comparison.as_mut().unwrap().files[0]; - file.before.clear(); - file.after.clear(); - file.notice = Some("empty file".into()); - state.normalize_focus(); - assert_eq!(state.pane_layout(), PaneLayout::After); - assert_eq!(state.focus, Focus::After); - assert!(state.after_editable()); - assert_eq!( - state.comparison.as_ref().unwrap().files[0] - .notice - .as_deref(), - Some("empty file") - ); - } - - #[test] - fn opening_diff_file_focuses_after_when_present() { - for view_mode in [ViewMode::Split, ViewMode::Unified] { - for presentation in [FilePresentation::Docked, FilePresentation::Floating] { - for status in [ - FileStatus::Modified, - FileStatus::Renamed, - FileStatus::Added, - FileStatus::Removed, - ] { - let mut editor = editor(); - editor.set_diff_display_options(view_mode, crate::panes::SplitAxis::Vertical); - open(&mut editor); - let id = choose_pair(&mut editor); - let mut changes = comparison(); - let index = match status { - FileStatus::Modified | FileStatus::Renamed => 0, - FileStatus::Added => 1, - FileStatus::Removed => 2, - }; - let mut file = changes.files.remove(index); - file.status = status; - let expected_text = file.after.join("\n"); - changes.files = vec![file]; - editor.apply_diff_result(id, Ok(Outcome::Comparison(changes))); - if presentation == FilePresentation::Floating { - text(&mut editor, " o"); - } - editor.handle(Input::Enter); - let diff = editor.diff().unwrap(); - assert_eq!( - diff.focus, - if status == FileStatus::Removed { - Focus::Before - } else { - Focus::After - }, - "{view_mode:?} {presentation:?} {status:?}", - ); - assert!(!diff.files.as_ref().unwrap().focused); - assert_eq!(diff.files_visible, presentation == FilePresentation::Docked); - if status != FileStatus::Removed { - assert_eq!(editor.context().text, expected_text); - } - } - } - } - } - - #[test] - fn added_file_picker_cancel_close_and_mouse_focus_use_after() { - let mut editor = editor(); - open(&mut editor); - let id = choose_pair(&mut editor); - let mut changes = comparison(); - changes.files = vec![changes.files.remove(1)]; - editor.apply_diff_result(id, Ok(Outcome::Comparison(changes))); - editor.handle(Input::Escape); - assert_eq!(editor.diff().unwrap().focus, Focus::After); - text(&mut editor, " e"); - editor.handle(Input::Ctrl('w')); - assert_eq!(editor.diff().unwrap().focus, Focus::After); - assert!(!editor.diff().unwrap().files_visible); - editor.scroll_diff(Focus::Before, true); - assert_eq!(editor.diff().unwrap().focus, Focus::After); - editor.handle(Input::BackTab); - assert_eq!(editor.diff().unwrap().focus, Focus::After); - } - - #[test] - fn revision_matches_rank_later_contiguous_boundaries_and_tight_subsequences() { - assert_eq!(fuzzy_match("r---f---s/topic/rfs", "rfs").unwrap()[0], 2); - assert!(fuzzy_match("topic/rfs", "rfs") < fuzzy_match("topicxrfs", "rfs")); - assert!(fuzzy_match("r-f-s", "rfs") < fuzzy_match("r---f---s", "rfs")); - assert!(fuzzy_match("über-parser", "üpr").is_some()); - assert!(fuzzy_match("parser", "srp").is_none()); - } - - #[test] - fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() { - for phase in [Phase::Before, Phase::After] { - for (category_index, &category) in RepoKind::Jujutsu.categories().iter().enumerate() { - let mut state = DiffState::new(); - state.apply_result( - 1, - Ok(Outcome::Catalog(Catalog { - kind: RepoKind::Jujutsu, - entries: vec![ - revision( - RevisionTarget::Working, - "Working checkout", - "", - Category::Revisions, - ), - revision( - RevisionTarget::Commit("description-target".into()), - "unrelated", - "sm/fin-1205-pilot-assignment-schema", - category, - ), - revision( - RevisionTarget::Commit("immutable-fuzzy-target".into()), - "sm/fin-1205-pilot-assignment-schema", - "", - category, - ), - revision( - RevisionTarget::Commit("immutable-exact-target".into()), - "f1205pas", - "", - category, - ), - ], - notice: None, - })), - ); - state.phase = phase; - state.category = category_index; - state.query = "F1205PAS".into(); - state.refilter(); - assert_eq!( - state - .entries() - .skip(1) - .map(|entry| &entry.target) - .collect::>(), - [ - &RevisionTarget::Commit("immutable-exact-target".into()), - &RevisionTarget::Commit("immutable-fuzzy-target".into()), - &RevisionTarget::Commit("description-target".into()), - ] - ); - } - } - } - - #[test] - fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() { - let mut editor = editor(); - open(&mut editor); - text(&mut editor, "FIX 界 e\u{301}"); - assert_eq!( - editor - .diff() - .unwrap() - .entries() - .map(|entry| entry.label.as_str()) - .collect::>(), - ["Working checkout", "abcdef123"] - ); - editor.handle(Input::Backspace); - assert_eq!(editor.diff().unwrap().query, "FIX 界 "); - editor.handle(Input::Char('\t')); - assert_eq!(editor.diff().unwrap().category, 1); - assert_eq!(editor.diff().unwrap().entries().len(), 1); - editor.handle(Input::Ctrl('u')); - assert_eq!(editor.diff().unwrap().entries().len(), 3); - editor.handle(Input::BackTab); - assert_eq!(editor.diff().unwrap().category, 0); - editor.handle(Input::BackTab); - assert_eq!(editor.diff().unwrap().category, 2); - assert_eq!(editor.diff().unwrap().entries().len(), 2); - editor.handle(Input::Char('\t')); - assert_eq!(editor.diff().unwrap().category, 0); - editor - .bind("selection", "x", Some("core.down".into())) - .unwrap(); - editor.handle(Input::Char('x')); - assert_eq!(editor.diff().unwrap().selected, 1); - assert!(editor.diff().unwrap().query.is_empty()); - editor.bind("selection", "ctrl+j", None).unwrap(); - editor.handle(Input::Ctrl('j')); - assert_eq!(editor.diff().unwrap().selected, 1); - } - - #[test] - fn selector_word_and_line_deletion_preserve_source_in_both_stages() { - for after in [false, true] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("source text".into())]) - .unwrap(); - let source = editor.context().text; - let revision = editor.text_revision(); - let carets = editor.carets(); - open(&mut editor); - if after { - editor.handle(Input::Enter); - } - let phase = if after { Phase::After } else { Phase::Before }; - text(&mut editor, "FIX missing"); - assert_eq!(editor.diff().unwrap().entries().len(), 1); - editor.handle(Input::AltBackspace); - assert_eq!(editor.diff().unwrap().query, "FIX "); - assert_eq!(editor.diff().unwrap().entries().len(), 2); - editor.handle(Input::SuperBackspace); - assert!(editor.diff().unwrap().query.is_empty()); - assert_eq!( - editor - .diff() - .unwrap() - .entries() - .map(|entry| entry.label.as_str()) - .collect::>(), - [ - "Working checkout", - "abcdef123", - "fedcba321", - "Load history from origin" - ] - ); - for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] { - editor.handle(input); - } - assert_eq!(editor.diff().unwrap().phase, phase); - assert_eq!(editor.context().text, source); - assert_eq!(editor.text_revision(), revision); - assert_eq!(editor.carets(), carets); - } - } - - #[test] - fn loader_preserves_after_step_query_category_and_selection_then_compares() { - let mut editor = editor(); - open(&mut editor); - editor.handle(Input::Down); - editor.handle(Input::Enter); - text(&mut editor, "origin"); - editor.handle(Input::Down); - editor.handle(Input::Enter); - let (id, operation) = editor.take_diff_request().unwrap(); - assert!(matches!(operation, Operation::FetchRemote(remote) if remote == "origin")); - assert_eq!(editor.diff().unwrap().phase, Phase::After); - assert!(editor.diff().unwrap().loading); - editor.handle(Input::Enter); - assert!(editor.take_diff_request().is_none()); - let mut loaded = catalog(); - loaded.entries.push(revision( - RevisionTarget::Commit("remote-commit".into()), - "remote-commit", - "origin remote history", - Category::Commits, - )); - assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(loaded)))); - let state = editor.diff().unwrap(); - assert_eq!(state.phase, Phase::After); - assert_eq!(state.query, "origin"); - assert_eq!(state.category, 0); - assert_eq!( - state.entries().nth(state.selected).unwrap().target, - RevisionTarget::FetchRemote("origin".into()) - ); - assert_eq!(state.entries().len(), 3); - let remote_position = state - .entries() - .position(|entry| matches!(&entry.target, RevisionTarget::Commit(id) if id == "remote-commit")) - .unwrap(); - editor.handle(Input::Home); - for _ in 0..remote_position { - editor.handle(Input::Down); - } - editor.handle(Input::Enter); - let (id, operation) = editor.take_diff_request().unwrap(); - assert!(matches!(operation, Operation::Compare { after, .. } - if after.target == RevisionTarget::Commit("remote-commit".into()))); - assert!(editor.apply_diff_result(id, Err("comparison failed".into()))); - assert_eq!(editor.diff().unwrap().phase, Phase::After); - assert_eq!(editor.diff().unwrap().query, "origin"); - assert!(editor.diff().unwrap().error.is_some()); - editor.handle(Input::Enter); - assert!(matches!( - editor.take_diff_request(), - Some((_, Operation::Compare { .. })) - )); - } - - #[test] - fn selector_escape_closes_each_stage_and_rejects_late_results() { - for after in [false, true] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("unsaved source".into())]) - .unwrap(); - let buffer = editor.buffer_id(); - open(&mut editor); - let request = if after { - Some(choose_pair(&mut editor)) - } else { - text(&mut editor, "query"); - None - }; - editor.handle(Input::Escape); - assert!(editor.diff().is_none()); - assert_eq!(editor.buffer_id(), buffer); - assert_eq!(editor.lines(), ["unsaved source"]); - assert!(editor.dirty()); - if let Some(id) = request { - assert!(!editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())))); - assert!(editor.diff().is_none()); - } - } - } - - #[test] - fn selector_printable_input_precedes_normal_bindings_but_not_selection_overrides() { - for after in [false, true] { - let mut editor = editor(); - open(&mut editor); - if after { - editor.handle(Input::Enter); - } - editor - .bind("normal", "x", Some("core.command".into())) - .unwrap(); - for ch in ":_| x".chars() { - editor.handle(Input::Char(ch)); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.pane_layout().len(), 1); - } - assert_eq!(editor.diff().unwrap().query, ":_| x"); - editor.handle(Input::Ctrl('u')); - editor - .bind("selection", ":", Some("core.down".into())) - .unwrap(); - editor.handle(Input::Char(':')); - assert_eq!(editor.diff().unwrap().selected, 1); - assert!(editor.diff().unwrap().query.is_empty()); - } - let mut editor = editor(); - open(&mut editor); - editor.handle(Input::Ctrl('p')); - editor.handle(Input::Char(':')); - assert_eq!(editor.palette().unwrap().query, ":"); - assert_eq!(editor.mode(), Mode::Normal); - editor.handle(Input::Escape); - assert!(editor.palette().is_none()); - assert!(editor.diff().is_some()); - } - - #[test] - fn file_to_directory_change_opens_both_historical_paths() { - let mut editor = editor(); - open(&mut editor); - let id = choose_pair(&mut editor); - let mut changes = comparison(); - changes.files[0].path = "x".into(); - changes.files[1].path = "x/y".into(); - changes.files.truncate(2); - editor.apply_diff_result(id, Ok(Outcome::Comparison(changes))); - let picker = editor.diff().unwrap().files.as_ref().unwrap(); - assert!(!picker.entries().nth(picker.selected).unwrap().is_dir); - editor.handle(Input::Enter); - assert_eq!(editor.diff().unwrap().file, 0); - text(&mut editor, " e"); - editor.handle(Input::Char('i')); - text(&mut editor, "x/y"); - editor.handle(Input::Enter); - assert_eq!(editor.diff().unwrap().file, 1); - assert_eq!(editor.diff().unwrap().focus, Focus::After); - } - - #[test] - fn changed_tree_filters_navigates_and_opens_without_touching_buffers() { - let mut editor = editor(); - let original = editor.context().text; - let carets = editor.carets(); - let revision = editor.text_revision(); - open(&mut editor); - let id = choose_pair(&mut editor); - let mut changes = comparison(); - changes.files[0].path = "src/deep/renamed.txt".into(); - changes.files[1].path = "src/deep/added.txt".into(); - changes.files[2].path = "archive/gone/removed.bin".into(); - editor.apply_diff_result(id, Ok(Outcome::Comparison(changes))); - let picker = editor.diff().unwrap().files.as_ref().unwrap(); - assert!(picker.focused && !picker.searching); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - std::path::Path::new("src/deep/renamed.txt") - ); - assert!(picker.entries().any(|row| row.label == "src/deep")); - editor.bind("tree", "z", Some("core.left".into())).unwrap(); - editor.handle(Input::Char('z')); - let picker = editor.diff().unwrap().files.as_ref().unwrap(); - assert!(picker.entries().nth(picker.selected).unwrap().is_dir); - editor.handle(Input::Enter); - let picker = editor.diff().unwrap().files.as_ref().unwrap(); - assert!(!picker.entries().nth(picker.selected).unwrap().expanded); - assert_eq!(editor.diff().unwrap().file, 0); - editor.handle(Input::Char('l')); - editor.handle(Input::Char('l')); - editor - .bind("selection", "ctrl+n", Some("core.down".into())) - .unwrap(); - editor.handle(Input::Ctrl('n')); - assert_eq!(editor.diff().unwrap().file, 0); - editor.handle(Input::Char('i')); - text(&mut editor, "removed.bin"); - editor.handle(Input::Enter); - let state = editor.diff().unwrap(); - assert_eq!(state.file, 2); - assert_eq!(state.focus, Focus::Before); - assert!(!state.files.as_ref().unwrap().focused); - assert!(state.rows.is_empty()); - editor.handle(Input::BackTab); - editor.handle(Input::Escape); - assert!(!editor.diff().unwrap().files.as_ref().unwrap().searching); - editor.handle(Input::Ctrl('u')); - assert!( - editor - .diff() - .unwrap() - .files - .as_ref() - .unwrap() - .query - .is_empty() - ); - editor.handle(Input::Ctrl('t')); - assert_eq!( - editor.diff().unwrap().files.as_ref().unwrap().view, - FileView::List - ); - editor.handle(Input::Ctrl('t')); - assert_eq!( - editor.diff().unwrap().files.as_ref().unwrap().view, - FileView::Tree - ); - editor.handle(Input::Ctrl('r')); - editor.handle(Input::Char('i')); - text(&mut editor, "removed.bin"); - editor.handle(Input::Enter); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); - assert_eq!(editor.context().text, original); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.text_revision(), revision); - editor.handle(Input::Escape); - assert!(editor.diff().is_some()); - editor.execute("diff.close"); - assert!(editor.diff().is_none()); - } - - #[test] - fn explorer_actions_preserve_review_and_ctrl_e_uses_normal_bindings() { - let mut editor = editor(); - open(&mut editor); - let id = choose_pair(&mut editor); - editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))); - editor - .bind("tree", "ctrl+e", Some("core.down".into())) - .unwrap(); - editor.handle(Input::Ctrl('e')); - assert!(editor.diff().unwrap().files_visible); - assert_eq!( - editor - .diff() - .unwrap() - .files - .as_ref() - .unwrap() - .entries() - .nth(editor.diff().unwrap().files.as_ref().unwrap().selected) - .unwrap() - .path, - Path::new("added.txt") - ); - editor.handle(Input::Ctrl('w')); - assert!(editor.diff().is_some()); - assert!(!editor.diff().unwrap().files_visible); - text(&mut editor, " e"); - assert!(editor.diff().unwrap().files_visible); - assert_eq!(editor.diff().unwrap().focus, Focus::Files); - assert_eq!( - editor.diff().unwrap().files.as_ref().unwrap().presentation, - crate::files::FilePresentation::Docked - ); - editor.handle(Input::Ctrl('w')); - text(&mut editor, " o"); - assert_eq!( - editor.diff().unwrap().files.as_ref().unwrap().presentation, - crate::files::FilePresentation::Floating - ); - editor.handle(Input::Escape); - editor.handle(Input::Escape); - assert!(editor.diff().is_some()); - editor.execute("diff.close"); - assert!(editor.diff().is_none()); - } - - #[test] - fn explorer_toggle_is_not_a_revision_selector_control() { - let mut editor = editor(); - open(&mut editor); - editor.handle(Input::Ctrl('e')); - assert_eq!(editor.diff().unwrap().selected, 0); - editor - .bind("selection", "ctrl+e", Some("core.down".into())) - .unwrap(); - for phase in [Phase::Before, Phase::After] { - assert_eq!(editor.diff().unwrap().phase, phase); - editor.handle(Input::Ctrl('e')); - let state = editor.diff().unwrap(); - assert!(state.files_visible); - assert_eq!(state.selected, 1); - assert_eq!(state.phase, phase); - editor.handle(Input::Enter); - } - } - - #[test] - fn aligned_hunks_navigation_shared_top_independent_horizontal_and_file_reset() { - let mut editor = editor(); - open(&mut editor); - let id = choose_pair(&mut editor); - assert!(editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())))); - assert_eq!( - editor - .diff() - .unwrap() - .rows - .iter() - .map(|row| (row.before, row.after, row.kind)) - .collect::>(), - [ - (Some(0), Some(0), RowKind::Equal), - (Some(1), Some(1), RowKind::Changed), - (None, Some(2), RowKind::Added), - (Some(2), Some(3), RowKind::Equal), - (Some(3), None, RowKind::Removed), - (Some(4), Some(4), RowKind::Equal), - ] - ); - editor.handle(Input::Char('\t')); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); - editor.handle(Input::Down); - editor.handle(Input::Right); - assert_eq!(editor.cursor().row, 1); - assert_eq!(editor.cursor().column, 1); - editor.scroll_diff(Focus::Before, true); - editor.set_diff_viewport(id, 0, 1, 1, 0); - editor.handle(Input::Char('\t')); - assert_eq!(editor.diff().unwrap().focus, Focus::After); - editor.handle(Input::Down); - editor.handle(Input::Right); - assert_eq!(editor.cursor().row, 2); - assert_eq!(editor.diff().unwrap().top, 1); - editor.set_diff_viewport(id, 0, 2, 1, 1); - assert_eq!(editor.diff().unwrap().left_after, 1); - assert_eq!(editor.diff().unwrap().left_before, 1); - editor.handle(Input::End); - assert_eq!(editor.diff().unwrap().top, 2); - editor.handle(Input::BackTab); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); - editor.handle(Input::BackTab); - editor.handle(Input::Down); - assert_eq!(editor.diff().unwrap().file, 0); - editor.handle(Input::Enter); - let state = editor.diff().unwrap(); - assert_eq!(state.file, 1); - assert_eq!((state.top, state.left_before, state.left_after), (0, 0, 0)); - assert!( - state - .rows - .iter() - .all(|row| row.before.is_none() && row.kind == RowKind::Added) - ); - editor.handle(Input::BackTab); - editor.handle(Input::Down); - editor.handle(Input::Enter); - assert_eq!(editor.diff().unwrap().file, 2); - assert!(editor.diff().unwrap().rows.is_empty()); - assert!( - editor.diff().unwrap().comparison.as_ref().unwrap().files[2] - .notice - .is_some() - ); - editor.handle(Input::BackTab); - editor.handle(Input::Down); - editor.handle(Input::Enter); - assert_eq!(editor.diff().unwrap().file, 0); - } - - #[test] - fn comparison_identity_comes_from_accepted_requests_not_working_labels() { - let mut editor = editor(); - open(&mut editor); - let first = choose_pair(&mut editor); - assert!(editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison())))); - assert_eq!(editor.diff().unwrap().comparison_id, Some(first)); - editor.execute("diff.close"); - open(&mut editor); - assert_eq!(editor.diff().unwrap().comparison_id, None); - let second = choose_pair(&mut editor); - assert!(second > first); - assert!(!editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison())))); - assert_eq!(editor.diff().unwrap().comparison_id, None); - assert!(editor.apply_diff_result(second, Ok(Outcome::Comparison(comparison())))); - assert_eq!(editor.diff().unwrap().comparison_id, Some(second)); - } - - #[test] - fn closing_diff_rejects_pending_file_details_even_after_reopening() { - let mut editor = editor(); - open(&mut editor); - let comparison_id = choose_pair(&mut editor); - let mut catalog = comparison(); - let detail = catalog.files.remove(0); - catalog.files.clear(); - catalog.files.push(ChangedFile { - path: detail.path.clone(), - old_path: detail.old_path.clone(), - status: detail.status, - loaded: false, - before: vec![], - after: vec![], - before_eof: false, - after_eof: false, - hunks: vec![], - notice: None, - }); - editor.apply_diff_result(comparison_id, Ok(Outcome::Comparison(catalog))); - let (file_request, _) = editor.take_diff_request().unwrap(); - editor.execute("diff.close"); - assert!(!editor.apply_diff_result( - file_request, - Ok(Outcome::File { - comparison_id, - file_index: 0, - file: detail, - }) - )); - assert!(editor.diff().is_none()); - open(&mut editor); - assert!(editor.diff().unwrap().comparison.is_none()); - } - - #[test] - fn explicit_remote_ref_loader_preserves_selector_position_without_choosing_revision() { - let mut state = DiffState::new(); - let mut initial = catalog(); - let loader = initial - .entries - .iter_mut() - .find(|entry| matches!(entry.target, RevisionTarget::FetchRemote(_))) - .unwrap(); - loader.target = RevisionTarget::ListRemote("origin".into()); - loader.label = "Load refs from origin".into(); - state.apply_result(1, Ok(Outcome::Catalog(initial))); - state.phase = Phase::After; - state.query = "origin".into(); - state.refilter(); - state.selected = 1; - state.choose(); - assert!( - matches!(state.take_request(), Some(Operation::ListRemote(remote)) if remote == "origin") - ); - let mut loaded = catalog(); - let loader = loaded - .entries - .iter_mut() - .find(|entry| matches!(entry.target, RevisionTarget::FetchRemote(_))) - .unwrap(); - loader.target = RevisionTarget::ListRemote("origin".into()); - loader.label = "Load refs from origin".into(); - loaded.entries.push(revision( - RevisionTarget::Remote { - remote: "origin".into(), - reference: "refs/heads/new".into(), - }, - "new@origin", - "", - Category::Branches, - )); - state.apply_result(2, Ok(Outcome::Catalog(loaded))); - assert_eq!(state.phase, Phase::After); - assert_eq!(state.query, "origin"); - assert_eq!(state.category, 0); - assert_eq!(state.selected, 1); - assert!(state.after.is_none()); - assert!(!state.loading); - state.category = 1; - state.refilter(); - assert!( - state - .entries() - .any(|entry| matches!(entry.target, RevisionTarget::Remote { .. })) - ); - } - - #[test] - fn discovery_and_fetch_errors_are_retryable_and_stale_responses_cannot_reopen() { - let mut editor = editor(); - editor.execute("diff"); - let (old, _) = editor.take_diff_request().unwrap(); - editor.handle(Input::Escape); - assert!(!editor.apply_diff_result(old, Ok(Outcome::Catalog(catalog())))); - editor.execute("diff"); - let (id, _) = editor.take_diff_request().unwrap(); - assert!(id > old); - assert!(!editor.apply_diff_result(old, Err("stale".into()))); - assert!(editor.diff().unwrap().loading); - assert!(editor.apply_diff_result(id, Err("discovery failed".into()))); - assert!(editor.diff().unwrap().error.is_some()); - editor.handle(Input::Enter); - let (retry, operation) = editor.take_diff_request().unwrap(); - assert!(matches!(operation, Operation::Discover)); - assert!(retry > id); - assert!(editor.apply_diff_result(retry, Ok(Outcome::Catalog(catalog())))); - text(&mut editor, "origin"); - editor.handle(Input::Down); - editor.handle(Input::Enter); - let (fetch, _) = editor.take_diff_request().unwrap(); - assert!(editor.apply_diff_result(fetch, Err("fetch failed".into()))); - assert_eq!(editor.diff().unwrap().entries().len(), 2); - assert_eq!(editor.diff().unwrap().query, "origin"); - editor.handle(Input::Ctrl('u')); - editor.handle(Input::Home); - editor.handle(Input::Enter); - assert_eq!(editor.diff().unwrap().phase, Phase::After); - } - - #[test] - fn immutable_diff_and_selectors_preserve_dirty_buffers_carets_and_undo() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("original\\nsecond".into())]) - .unwrap(); - editor.handle(Input::Char('i')); - text(&mut editor, "unsaved"); - editor.handle(Input::Escape); - editor.handle(Input::Right); - let original = editor.context().text; - let carets = editor.carets(); - let revision = editor.text_revision(); - let buffers = editor - .buffers() - .map(|buffer| (buffer.id, buffer.dirty)) - .collect::>(); - editor - .bind("normal", "x", Some("core.diff".into())) - .unwrap(); - assert!(editor.handle(Input::Char('x')).is_none()); - let (id, _) = editor.take_diff_request().unwrap(); - assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog())))); - for input in [ - Input::Char('i'), - Input::Char('d'), - Input::Delete, - Input::Paste("bad\\n:q!".into()), - ] { - assert!(editor.handle(input).is_none()); - } - assert!(editor.execute("bdelete!").is_none()); - assert!(editor.apply(vec![Effect::Replace("bad".into())]).is_err()); - assert_eq!(editor.context().text, original); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.text_revision(), revision); - assert_eq!( - editor - .buffers() - .map(|buffer| (buffer.id, buffer.dirty)) - .collect::>(), - buffers - ); - editor.handle(Input::Escape); - assert!(editor.diff().is_none()); - assert_eq!(editor.mode(), Mode::Normal); - open(&mut editor); - let id = choose_pair(&mut editor); - editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))); - for input in [ - Input::Char('i'), - Input::Char('u'), - Input::Char('d'), - Input::Paste("bad".into()), - Input::Delete, - Input::Backspace, - Input::Enter, - ] { - editor.handle(input); - } - editor.handle(Input::Escape); - editor.handle(Input::Escape); - assert!(editor.diff().is_some()); - editor.execute("diff.close"); - assert_eq!(editor.context().text, original); - assert_eq!(editor.carets(), carets); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "original\\nsecond"); - } - - #[test] - fn jujutsu_categories_empty_comparison_and_command_mode_entry_are_complete() { - let mut editor = editor(); - editor.handle(Input::Char(':')); - text(&mut editor, "diff"); - editor.handle(Input::Enter); - assert_eq!(editor.mode(), Mode::Normal); - let (id, _) = editor.take_diff_request().unwrap(); - let mut catalog = catalog(); - catalog.kind = RepoKind::Jujutsu; - catalog.entries[0].category = Category::Revisions; - catalog.entries[1].category = Category::Revisions; - catalog.entries[4].category = Category::Bookmarks; - assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog)))); - assert_eq!(editor.diff().unwrap().entries().len(), 2); - editor.handle(Input::Char('\t')); - assert_eq!( - editor.diff().unwrap().entries().nth(1).unwrap().category, - Category::Bookmarks - ); - editor.handle(Input::Home); - editor.handle(Input::Enter); - editor.handle(Input::Enter); - let (id, _) = editor.take_diff_request().unwrap(); - assert!(editor.apply_diff_result( - id, - Ok(Outcome::Comparison(Comparison { - revisions: ["working:snapshot".into(), "working:snapshot".into()], - before: "Working".into(), - after: "Working".into(), - files: vec![], - root: PathBuf::new(), - editable_paths: BTreeSet::new(), - })) - )); - assert_eq!(editor.diff().unwrap().phase, Phase::View); - assert!(editor.diff().unwrap().rows.is_empty()); - for input in [Input::Down, Input::End, Input::Up, Input::Right, Input::End] { - editor.handle(input); - } - assert_eq!(editor.diff().unwrap().top, 0); - assert_eq!(editor.diff().unwrap().file, 0); - editor.handle(Input::Escape); - assert!(editor.diff().is_some()); - editor.execute("diff.close"); - assert!(editor.diff().is_none()); - } - - fn live_state(before: Vec, after: Vec, eof: bool) -> DiffState { - let mut state = DiffState::new(); - state.phase = Phase::View; - state.focus = Focus::After; - state.after = Some(revision( - RevisionTarget::Working, - "Working", - "", - Category::Commits, - )); - state.comparison = Some(Comparison { - revisions: ["base".into(), "working:snapshot".into()], - root: PathBuf::new(), - before: "base".into(), - after: "Working".into(), - editable_paths: BTreeSet::from([PathBuf::from("file")]), - files: vec![ChangedFile { - path: "file".into(), - old_path: None, - status: FileStatus::Modified, - hunks: line_hunks(&before, eof, &after, eof), - before, - after, - before_eof: eof, - after_eof: eof, - notice: None, - loaded: true, - }], - }); - state.files = Some(FilePicker::from_paths( - PathBuf::new(), - [(0, PathBuf::from("file"))], - )); - state.align_file(); - state - } - - #[test] - fn working_refresh_preserves_noops_errors_and_view_state_but_invalidates_old_identity() { - let mut state = live_state(vec!["old".into()], vec!["new".into()], false); - state.loading = false; - state.pending = None; - state.request_kind = None; - state.comparison_id = Some(7); - state.word_wrap = true; - state.view_mode = ViewMode::Unified; - state.comparison_mode = ComparisonMode::Snapshots; - state.split_axis = crate::panes::SplitAxis::Horizontal; - state.files_visible = false; - state.reviewed_files.insert(0); - state.error = Some("previous notice".into()); - let files = state.files.as_mut().unwrap(); - files.set_view(FileView::Tree); - files.start_search(); - files.query = "file".into(); - files.refilter(); - state.queue_checkout_refresh(true); - assert!(matches!( - state.take_request(), - Some(Operation::RefreshCheckout { - comparison_id: 7, - .. - }) - )); - assert!(state.apply_result(8, Ok(Outcome::CheckoutRefresh(None)))); - assert!(state.error.is_none()); - assert_eq!(state.comparison_id, Some(7)); - assert_eq!(state.reviewed_files, BTreeSet::from([0])); - - state.queue_checkout_refresh(true); - state.take_request(); - assert!(state.apply_result(9, Err("refresh failed".into()))); - assert_eq!(state.comparison_id, Some(7)); - assert_eq!(state.comparison.as_ref().unwrap().files[0].after, ["new"]); - assert_eq!(state.reviewed_files, BTreeSet::from([0])); - assert_eq!(state.error.as_deref(), Some("refresh failed")); - - state.queue_checkout_refresh(true); - state.take_request(); - let mut replacement = live_state(vec!["old".into()], vec!["ahead".into()], false) - .comparison - .take() - .unwrap(); - replacement.files.insert(0, comparison().files.remove(0)); - assert!(state.apply_result(10, Ok(Outcome::CheckoutRefresh(Some(replacement))))); - assert_eq!(state.comparison_id, Some(10)); - assert_eq!(state.file, 1); - assert_eq!(state.focus, Focus::After); - assert!(state.word_wrap && !state.files_visible); - assert_eq!(state.split_axis, crate::panes::SplitAxis::Horizontal); - assert_eq!(state.view_mode, ViewMode::Unified); - assert_eq!(state.comparison_mode, ComparisonMode::Snapshots); - assert!(state.reviewed_files.is_empty()); - let files = state.files.as_ref().unwrap(); - assert_eq!(files.query, "file"); - assert_eq!(files.view, FileView::Tree); - assert!(files.searching); - assert_eq!( - files.entries().nth(files.selected).unwrap().path, - Path::new("file") - ); - assert!(!state.apply_result(7, Ok(Outcome::CheckoutRefresh(None)))); - assert_eq!(state.comparison_id, Some(10)); - assert_eq!(state.file, 1); - state.after.as_mut().unwrap().target = RevisionTarget::Commit("historical".into()); - state.queue_checkout_refresh(false); - assert!(state.request_queued()); - assert!(matches!( - state.take_request(), - Some(Operation::RefreshCheckout { - follow_working: false, - .. - }) - )); - assert!(!state.apply_result(11, Ok(Outcome::CheckoutRefresh(None)))); - state.reviewed_files.insert(1); - state.queue_checkout_refresh(false); - state.take_request(); - let mut demoted = live_state(vec!["old".into()], vec!["ahead".into()], false) - .comparison - .take() - .unwrap(); - demoted.files.insert(0, comparison().files.remove(0)); - demoted.editable_paths.clear(); - assert!(state.apply_result(12, Ok(Outcome::CheckoutRefresh(Some(demoted))))); - assert_eq!(state.reviewed_files, BTreeSet::from([1])); - assert!(!state.live_after_available()); - state - .comparison - .as_mut() - .unwrap() - .editable_paths - .insert("file".into()); - state.refresh_after(&["dirty".into()], 1); - assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["dirty"]); - state.queue_checkout_refresh(false); - state.take_request(); - assert!(state.apply_result(13, Err("checkout unavailable".into()))); - assert!(!state.live_after_available()); - assert_eq!(state.comparison_id, Some(12)); - assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["ahead"]); - state.after.as_mut().unwrap().target = RevisionTarget::Working; - state.queue_checkout_refresh(false); - assert!(!state.request_queued()); - state.load_file(0); - state.queue_checkout_refresh(true); - assert!(matches!( - state.pending, - Some(Operation::LoadFile { file_index: 0, .. }) - )); - } - - #[test] - fn reviewed_files_use_highlighted_catalog_indices_not_sorted_or_filtered_rows() { - let mut state = DiffState::new(); - let mut changes = comparison(); - changes.files[0].path = "nested/z.txt".into(); - changes.files[1].path = "nested/a.txt".into(); - state.request_kind = Some(RequestKind::Comparison); - state.apply_result(1, Ok(Outcome::Comparison(changes))); - for view in [FileView::List, FileView::Tree] { - let files = state.files.as_mut().unwrap(); - files.set_view(view); - files.clear_filter(); - files.start_search(); - for ch in "nested/a".chars() { - files.handle_filter_input(&Input::Char(ch)); - } - files.reveal_path(Path::new("nested/a.txt")); - assert_eq!(state.file, 0); - assert_eq!(state.toggle_reviewed(), Ok(true)); - assert_eq!(state.reviewed_files, BTreeSet::from([1])); - assert_eq!(state.toggle_reviewed(), Ok(false)); - assert!(state.reviewed_files.is_empty()); - } - let files = state.files.as_mut().unwrap(); - files.clear_filter(); - files.finish_search(); - let selected = files.entries().position(|entry| entry.is_dir).unwrap(); - files.selected = selected; - assert_eq!( - state.toggle_reviewed(), - Err("Select a changed file, not a directory") - ); - assert!(state.reviewed_files.is_empty()); - let files = state.files.as_mut().unwrap(); - files.start_search(); - for ch in "missing".chars() { - files.handle_filter_input(&Input::Char(ch)); - } - assert_eq!(state.toggle_reviewed(), Err("No changed file is selected")); - state.files = Some(FilePicker::from_paths( - PathBuf::new(), - [(0, PathBuf::from("ordinary.txt"))], - )); - assert_eq!(state.toggle_reviewed(), Err("No changed file is selected")); - state.focus = Focus::Before; - assert_eq!(state.toggle_reviewed(), Ok(true)); - assert_eq!(state.reviewed_files, BTreeSet::from([0])); - assert!(state.select_file_index(2)); - state.focus = Focus::Before; - assert_eq!(state.toggle_reviewed(), Ok(true)); - assert_eq!(state.reviewed_files, BTreeSet::from([0, 2])); - } - - #[test] - fn reviewed_files_survive_lazy_details_and_views_but_reset_on_comparison() { - let mut state = DiffState::new(); - assert_eq!(state.toggle_reviewed(), Err("No comparison is open")); - let mut changes = comparison(); - changes.files[0].loaded = false; - changes.files[0].before.clear(); - changes.files[0].after.clear(); - state.request_kind = Some(RequestKind::Comparison); - state.apply_result(1, Ok(Outcome::Comparison(changes))); - assert_eq!(state.toggle_reviewed(), Ok(true)); - assert_eq!(state.reviewed_files, BTreeSet::from([0])); - let file = comparison().files.remove(0); - state.apply_result( - 2, - Ok(Outcome::File { - comparison_id: 1, - file_index: 0, - file, - }), - ); - assert!(state.selected_file_loaded()); - state.set_view_mode(ViewMode::Unified); - state.set_view_mode(ViewMode::Split); - state.split_axis = crate::panes::SplitAxis::Horizontal; - assert!(state.select_file_index(1)); - assert_eq!(state.reviewed_files, BTreeSet::from([0])); - state.before = Some(revision( - RevisionTarget::Commit("base".into()), - "base", - "", - Category::Commits, - )); - state.after = Some(revision( - RevisionTarget::Commit("head".into()), - "head", - "", - Category::Commits, - )); - state.toggle_comparison_mode().unwrap(); - assert!(state.reviewed_files.is_empty()); - assert_eq!(state.toggle_reviewed(), Err("No comparison is open")); - state.apply_result(3, Ok(Outcome::Comparison(comparison()))); - assert!(state.reviewed_files.is_empty()); - assert_eq!(state.toggle_reviewed(), Ok(true)); - state.request_kind = Some(RequestKind::Comparison); - state.apply_result(4, Ok(Outcome::Comparison(comparison()))); - assert!(state.reviewed_files.is_empty()); - } - - #[test] - fn working_review_marks_clear_only_for_changed_bytes_or_eof() { - for (after, eof, lines) in [ - (vec!["changed".into()], false, vec!["changed".into()]), - ( - vec!["changed".into()], - true, - vec!["changed".into(), String::new()], - ), - (Vec::new(), false, vec![String::new()]), - ] { - let mut state = live_state(vec!["base".into()], after, eof); - assert_eq!(state.toggle_reviewed(), Ok(true)); - state.refresh_after(&lines, 1); - assert_eq!(state.reviewed_files, BTreeSet::from([0])); - state.refresh_after(&lines, 2); - assert_eq!(state.reviewed_files, BTreeSet::from([0])); - state.refresh_after(&["edited".into()], 3); - assert!(state.reviewed_files.is_empty()); - assert_eq!(state.toggle_reviewed(), Ok(true)); - state.refresh_after(&["edited".into(), String::new()], 4); - assert!(state.reviewed_files.is_empty()); - assert_eq!(state.toggle_reviewed(), Ok(true)); - state.refresh_after(&["edited".into()], 5); - assert!(state.reviewed_files.is_empty()); - } - let mut state = live_state(vec!["base".into()], vec!["changed".into()], false); - state.comparison.as_mut().unwrap().files[0].loaded = false; - assert_eq!(state.toggle_reviewed(), Ok(true)); - state.refresh_after(&["edited".into()], 1); - assert_eq!(state.reviewed_files, BTreeSet::from([0])); - } - - #[test] - fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() { - let mut state = live_state( - ["head", "old one", "old two", "tail"] - .map(str::to_owned) - .to_vec(), - ["head", "new one", "new two", "extra", "tail"] - .map(str::to_owned) - .to_vec(), - false, - ); - assert_eq!(state.view_mode, ViewMode::Split); - assert_eq!(state.split_axis, crate::panes::SplitAxis::Vertical); - state.top = state.after_display_row(2).unwrap(); - let excerpts = state.excerpt_ranges.clone(); - state.set_view_mode(ViewMode::Unified); - let rows: Vec<_> = (0..state.display_rows.len()) - .map(|display| { - ( - state.display_focus(display), - state.display_source_row(Focus::Before, display), - state.display_source_row(Focus::After, display), - ) - }) - .collect(); - assert_eq!( - rows, - [ - (None, Some(0), Some(0)), - (Some(Focus::Before), Some(1), None), - (Some(Focus::Before), Some(2), None), - (Some(Focus::After), None, Some(1)), - (Some(Focus::After), None, Some(2)), - (Some(Focus::After), None, Some(3)), - (None, Some(3), Some(4)), - ] - ); - assert_eq!(state.top, state.after_display_row(2).unwrap()); - assert_eq!( - state.corresponding_row(Focus::Before, Focus::After, 2), - Some(2) - ); - assert!(state.after_editable()); - for focus in [Focus::Before, Focus::After] { - for source in state.source_rows(focus) { - let display = state.source_display_row(focus, source).unwrap(); - assert_eq!(state.display_source_row(focus, display), Some(source)); - } - } - state.set_view_mode(ViewMode::Split); - assert_eq!(state.excerpt_ranges, excerpts); - assert_eq!(state.focus, Focus::After); - assert_eq!(state.top, state.after_display_row(2).unwrap()); - assert_eq!(state.display_rows.len(), 5); - } - - #[test] - fn unified_live_reflow_preserves_removed_top_and_expanded_source_excerpts() { - let base: Vec<_> = (0..40).map(|row| format!("line {row}")).collect(); - let mut lines = base.clone(); - lines[20] = "new line".into(); - let mut state = live_state(base, lines.clone(), false); - state.set_context_options(1, 3); - state.refresh_after(&lines, 1); - state.set_view_mode(ViewMode::Unified); - state.top = state.source_display_row(Focus::Before, 20).unwrap(); - state.expand_source_excerpt(Focus::After, false, 20); - assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20)); - assert!(state.source_display_row(Focus::After, 16).is_some()); - lines.insert(0, "inserted".into()); - state.refresh_after(&lines, 2); - assert_eq!(state.display_focus(state.top), Some(Focus::Before)); - assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20)); - assert!(state.source_display_row(Focus::After, 17).is_some()); - state.reveal_source_row(Focus::After, 35); - let visible: Vec<_> = state.source_rows(Focus::After).collect(); - state.set_view_mode(ViewMode::Split); - state.set_view_mode(ViewMode::Unified); - assert_eq!(state.source_rows(Focus::After).collect::>(), visible); - assert_eq!(state.display_source_row(Focus::After, state.top), Some(35)); - } - - #[test] - fn character_ranges_follow_insertions_deletions_unicode_and_live_edits() { - for (before, after, expected) in [ - ("ab", "a界b", vec![[1..1, 1..2]]), - ("a界b", "ab", vec![[1..2, 1..1]]), - ("xe\u{301}👩‍🚀z", "xe\u{300}👩‍🔬z", vec![[1..3, 1..3]]), - ] { - let state = live_state(vec![before.into()], vec![after.into()], false); - assert_eq!(state.rows[0].changes, expected); - } - let mut state = live_state(vec!["abc".into()], vec!["aXbc".into()], false); - state.refresh_after(&["aXYbc".into()], 1); - assert_eq!(state.rows[0].changes, vec![[1..1, 1..3]]); - state.refresh_after(&["abYZc".into()], 2); - assert_eq!(state.rows[0].changes, vec![[2..2, 2..4]]); - state.refresh_after(&["abc".into()], 3); - assert_eq!(state.rows[0].kind, RowKind::Equal); - assert!(state.rows[0].changes.is_empty()); - } - - #[test] - fn character_highlights_merge_incidental_matches_but_preserve_meaningful_gaps() { - let mut cases = vec![ - ("a..c".into(), "b..d".into(), vec![[0..4, 0..4]]), - ("base".into(), "edited".into(), vec![[0..4, 0..6]]), - ( - "a...c".into(), - "b...d".into(), - vec![[0..1, 0..1], [4..5, 4..5]], - ), - ( - "a界e\u{301}c".into(), - "b界e\u{301}d".into(), - vec![[0..4, 0..4]], - ), - ]; - for (size, gap, merge) in [ - (60, " = ".to_owned(), false), - (70, " = ".to_owned(), true), - (70, "x".repeat(20), true), - (70, "unchanged_expression".to_owned(), true), - (70, "unchanged_expression_".to_owned(), false), - (70, " ".repeat(500), true), - (70, " ".repeat(501), false), - ] { - let end = 2 * size + gap.len(); - let second = size + gap.len(); - cases.push(( - format!("{}{}{}", "a".repeat(size), gap, "c".repeat(size)), - format!("{}{}{}", "b".repeat(size), gap, "d".repeat(size)), - if merge { - vec![[0..end, 0..end]] - } else { - vec![[0..size, 0..size], [second..end, second..end]] - }, - )); - } - cases.push(( - format!("aaa = {} = {}", "c".repeat(70), "e".repeat(70)), - format!("bbb = {} = {}", "d".repeat(70), "f".repeat(70)), - vec![[0..149, 0..149]], - )); - for (before, after, expected) in cases { - let mut state = live_state(vec![before], vec![after.clone()], false); - assert_eq!(state.rows[0].changes, expected, "{after:?}"); - state.refresh_after(&[format!("{after}!")], 1); - state.refresh_after(std::slice::from_ref(&after), 2); - assert_eq!(state.rows[0].changes, expected, "live edit: {after:?}"); - } - } - - #[test] - fn reverting_loaded_file_keeps_unloaded_changes_selectable() { - let mut state = live_state(vec!["base".into()], vec!["changed".into()], false); - state.comparison_id = Some(17); - state.pending = None; - state.comparison.as_mut().unwrap().files.push(ChangedFile { - path: "unloaded.txt".into(), - old_path: None, - status: FileStatus::Modified, - loaded: false, - before: vec![], - after: vec![], - before_eof: false, - after_eof: false, - hunks: vec![], - notice: None, - }); - state.refresh_after(&["base".into()], 1); - assert_eq!( - state - .files - .as_ref() - .unwrap() - .entries() - .map(|entry| entry.path) - .collect::>(), - [Path::new("unloaded.txt")], - ); - assert!(state.select_file(Path::new("unloaded.txt"))); - assert!(matches!( - state.take_request(), - Some(Operation::LoadFile { - comparison_id: 17, - file_index: 1 - }) - )); - } - - #[test] - fn live_width_cache_scans_only_changed_lines_and_shrinks_after_deletion() { - let base = vec!["original".into(), "medium".into(), "short".into()]; - let mut lines = vec!["界\tz".into(), "medium".into(), "short".into()]; - let mut state = live_state(base, lines.clone(), false); - state.refresh_after(&lines, 1); - assert_eq!(state.after_widths, [5, 6, 5]); - lines[0] = "界界\t🙂".into(); - WIDTH_SCANS.set(0); - state.refresh_after(&lines, 2); - assert_eq!(WIDTH_SCANS.get(), 1); - assert_eq!(state.max_after, 10); - state.left_after = 9; - lines[0] = "界".into(); - WIDTH_SCANS.set(0); - state.refresh_after(&lines, 3); - assert_eq!(WIDTH_SCANS.get(), 1); - assert_eq!(state.max_after, 6); - assert_eq!(state.left_after, 6); - lines.remove(1); - WIDTH_SCANS.set(0); - state.refresh_after(&lines, 4); - assert_eq!(WIDTH_SCANS.get(), 0); - assert_eq!(state.after_widths, [2, 5]); - assert_eq!(state.max_after, 5); - lines.insert(1, "巨大\t🙂".into()); - WIDTH_SCANS.set(0); - state.refresh_after(&lines, 5); - assert_eq!(WIDTH_SCANS.get(), 1); - assert_eq!(state.after_widths, [2, 10, 5]); - lines = vec!["".into()]; - state.refresh_after(&lines, 6); - assert_eq!(state.after_widths, [0]); - assert_eq!(state.max_after, 0); - assert_eq!(state.left_after, 0); - } - - #[test] - fn stable_line_edits_keep_fresh_hunks_expansion_and_scroll_anchor_until_revert() { - let base = (0..100) - .map(|row| format!("line {row}")) - .collect::>(); - let mut lines = base.clone(); - lines[30] = "first change".into(); - let mut state = live_state(base.clone(), lines.clone(), true); - state.set_context_options(2, 5); - lines.push(String::new()); - state.refresh_after(&lines, 1); - state.top = state.after_display_row(30).unwrap(); - state.handle(Input::Char('[')); - let display = state.display_rows.clone(); - let excerpts = state.excerpt_ranges.clone(); - let top = state.top; - lines[30].push('!'); - let file = &state.comparison.as_ref().unwrap().files[0]; - assert!(stable_after_edit( - file, - &lines, - changed_line_range(&file.after, &lines) - )); - WIDTH_SCANS.set(0); - state.refresh_after(&lines, 2); - assert_eq!(WIDTH_SCANS.get(), 1); - assert_eq!(state.display_rows, display); - assert_eq!(state.excerpt_ranges, excerpts); - assert_eq!(state.top, top); - let file = &state.comparison.as_ref().unwrap().files[0]; - let hunk_ranges = |hunks: &[Hunk]| { - hunks - .iter() - .map(|hunk| { - ( - hunk.before_start, - hunk.before_len, - hunk.after_start, - hunk.after_len, - ) - }) - .collect::>() - }; - assert_eq!( - hunk_ranges(&file.hunks), - hunk_ranges(&line_hunks(&base, true, live_content(&lines, true), true)) - ); - lines[30].clone_from(&base[30]); - assert!(!stable_after_edit( - file, - &lines, - changed_line_range(&file.after, &lines) - )); - state.refresh_after(&lines, 3); - assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); - assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal)); - assert!(state.after_display_row(23).is_some()); - assert_eq!(state.top, state.after_display_row(30).unwrap()); - assert_eq!(state.files.as_ref().unwrap().entries().len(), 0); - lines[50] = "changed again".into(); - state.refresh_after(&lines, 4); - assert_eq!(state.files.as_ref().unwrap().entries().len(), 1); - assert!(state.after_display_row(23).is_some()); - assert!(state.after_display_row(50).is_some()); - } - - #[test] - fn live_reflow_keeps_separate_hunks_and_expanded_context_after_revert() { - let base = (0..100) - .map(|row| format!("line {row}")) - .collect::>(); - let mut changed = base.clone(); - changed[30] = "first change".into(); - changed[70] = "second change".into(); - let mut state = live_state(base.clone(), changed.clone(), true); - state.set_context_options(2, 5); - changed.push(String::new()); - state.refresh_after(&changed, 1); - assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 2); - assert!( - state - .rows - .last() - .is_some_and(|row| row.after == Some(100) && row.kind == RowKind::Equal) - ); - state.top = state.after_display_row(30).unwrap(); - state.handle(Input::Char('[')); - assert!(state.after_display_row(23).is_some()); - changed.insert(10, "inserted outside excerpts".into()); - state.refresh_after(&changed, 2); - assert!(state.after_display_row(24).is_some()); - assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 3); - assert!( - state.display_rows.iter().any( - |row| matches!(row, DisplayRow::Fold { start, end } if *start < 60 && *end > 40) - ) - ); - let mut reverted = base; - reverted.push(String::new()); - state.refresh_after(&reverted, 3); - assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); - assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal)); - assert!(state.after_display_row(23).is_some()); - assert!(state.after_display_row(90).is_none()); - assert_eq!(state.files.as_ref().unwrap().entries().len(), 0); - assert_eq!(state.file, 0); - reverted[50] = "changed again".into(); - state.refresh_after(&reverted, 4); - assert_eq!(state.files.as_ref().unwrap().entries().len(), 1); - assert!(state.after_display_row(50).is_some()); - } - - #[test] - fn live_empty_and_eof_rows_match_buffer_without_false_eof_highlights() { - let mut state = live_state(vec!["same".into()], vec!["changed".into()], true); - state.refresh_after(&["same".into(), "".into()], 1); - assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); - assert_eq!(state.rows.len(), 2); - assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal)); - assert!(state.after_display_row(0).is_some()); - assert!(state.after_display_row(1).is_none()); - state.refresh_after(&["same".into()], 2); - assert_eq!(state.rows[0].kind, RowKind::Changed); - assert!( - state.comparison.as_ref().unwrap().files[0] - .notice - .as_ref() - .unwrap() - .contains("After missing") - ); - state.refresh_after(&["".into()], 3); - assert!(state.after_display_row(0).is_some()); - assert_eq!(state.comparison.as_ref().unwrap().files[0].after, [""]); - let mut empty = live_state(vec![], vec!["text".into()], false); - empty.refresh_after(&["".into()], 1); - assert!(empty.comparison.as_ref().unwrap().files[0].hunks.is_empty()); - assert_eq!(empty.rows.len(), 1); - assert_eq!(empty.rows[0].kind, RowKind::Equal); - assert_eq!(empty.after_display_row(0), Some(0)); - empty.after.as_mut().unwrap().target = RevisionTarget::Commit("historic".into()); - empty.comparison.as_mut().unwrap().editable_paths.clear(); - empty.refresh_after(&["must not apply".into()], 2); - assert_eq!(empty.comparison.as_ref().unwrap().files[0].after, [""]); - empty.handle(Input::Escape); - assert_eq!(empty.phase, Phase::View); - } - - #[test] - fn lazy_selection_waits_for_details_rejects_stale_files_and_reuses_loaded_cache() { - let mut state = DiffState::new(); - state.pending = None; - state.request_kind = Some(RequestKind::Comparison); - let mut catalog = comparison(); - catalog.files.truncate(2); - let details: Vec<_> = catalog.files.drain(..).collect(); - for detail in &details { - catalog.files.push(ChangedFile { - path: detail.path.clone(), - old_path: detail.old_path.clone(), - status: detail.status, - loaded: false, - before: vec![], - after: vec![], - before_eof: false, - after_eof: false, - hunks: vec![], - notice: None, - }); - } - state.apply_result(41, Ok(Outcome::Comparison(catalog))); - assert!(!state.selected_file_loaded()); - assert!(state.rows.is_empty() && state.display_rows.is_empty()); - assert_eq!(state.pane_layout(), PaneLayout::Empty); - assert!(!state.after_editable()); - assert!(matches!( - state.take_request(), - Some(Operation::LoadFile { - comparison_id: 41, - file_index: 0, - }) - )); - assert!(state.select_file(Path::new("added.txt"))); - assert!(matches!( - state.take_request(), - Some(Operation::LoadFile { - comparison_id: 41, - file_index: 1, - }) - )); - let mut details = details.into_iter(); - let first = details.next().unwrap(); - let second = details.next().unwrap(); - state.apply_result( - 42, - Ok(Outcome::File { - comparison_id: 40, - file_index: 1, - file: first, - }), - ); - assert!(state.loading && !state.selected_file_loaded()); - state.apply_result( - 43, - Ok(Outcome::File { - comparison_id: 41, - file_index: 1, - file: second, - }), - ); - assert!(state.selected_file_loaded()); - assert_eq!(state.pane_layout(), PaneLayout::After); - assert!(!state.rows.is_empty()); - assert!(state.select_file(Path::new("renamed.txt"))); - assert!(state.loading); - assert!(state.select_file(Path::new("added.txt"))); - assert!(!state.loading && state.selected_file_loaded()); - assert!(state.take_request().is_none()); - state.apply_result(44, Err("cancelled old load".into())); - assert!(state.error.is_none() && state.selected_file_loaded()); - } - - #[test] - fn failed_lazy_file_load_can_be_retried_without_reopening_comparison() { - let mut state = DiffState::new(); - state.pending = None; - state.request_kind = Some(RequestKind::Comparison); - let mut catalog = comparison(); - catalog.files.truncate(1); - catalog.files[0].loaded = false; - state.apply_result(7, Ok(Outcome::Comparison(catalog))); - state.take_request(); - state.apply_result(8, Err("cannot read blob".into())); - assert_eq!(state.error.as_deref(), Some("cannot read blob")); - assert!(!state.selected_file_loaded()); - assert!(state.select_file(Path::new("renamed.txt"))); - assert!(state.loading && state.error.is_none()); - assert!(matches!( - state.take_request(), - Some(Operation::LoadFile { - comparison_id: 7, - file_index: 0, - }) - )); - } - - #[test] - fn mode_action_keeps_filter_input_and_rejects_superseded_comparisons() { - let mut editor = editor(); - open(&mut editor); - editor - .bind("normal", "m", Some("core.diff.mode.toggle".into())) - .unwrap(); - text(&mut editor, "m"); - assert_eq!(editor.diff().unwrap().query, "m"); - assert_eq!( - editor.diff().unwrap().comparison_mode, - ComparisonMode::BranchChanges - ); - editor.handle(Input::Ctrl('u')); - editor.handle(Input::Down); - editor.handle(Input::Enter); - editor.handle(Input::Down); - editor.handle(Input::Enter); - let (old, _) = editor.take_diff_request().unwrap(); - editor.execute("diff.mode.toggle"); - assert!(!editor.apply_diff_result(old, Err("superseded".into()))); - let (current, operation) = editor.take_diff_request().unwrap(); - assert!(matches!( - operation, - Operation::Compare { - mode: ComparisonMode::Snapshots, - .. - } - )); - assert!(editor.apply_diff_result(current, Err("retryable".into()))); - assert_eq!(editor.diff().unwrap().phase, Phase::After); - assert_eq!(editor.diff().unwrap().error.as_deref(), Some("retryable")); - editor.execute("diff.mode.toggle"); - let (_, operation) = editor.take_diff_request().unwrap(); - assert!(matches!( - operation, - Operation::Compare { - mode: ComparisonMode::BranchChanges, - .. - } - )); - assert!(editor.diff().unwrap().error.is_none()); - } - - #[test] - fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() { - use crate::review::{Anchor, Side}; - let mut state = live_state(vec!["old".into()], vec!["new".into()], true); - let anchor = Anchor { - revision: "working:snapshot".into(), - path: "file".into(), - side: Side::After, - start: crate::plugin::TextPosition { row: 0, column: 0 }, - end: crate::plugin::TextPosition { row: 1, column: 0 }, - content: "new\n".into(), - }; - assert!(state.review_anchor_current(&anchor)); - state.live_files.insert(0); - assert!(!state.review_anchor_current(&anchor)); - state.comparison.as_mut().unwrap().files[0] - .after - .push(String::new()); - assert!(state.review_anchor_current(&anchor)); - state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into(); - assert!(!state.review_anchor_current(&anchor)); - state.comparison.as_mut().unwrap().files[0].old_path = Some("original".into()); - let before = Anchor { - revision: "base".into(), - path: "original".into(), - side: Side::Before, - content: "old\n".into(), - ..anchor - }; - assert!(state.review_anchor_current(&before)); - state.comparison.as_mut().unwrap().files[0].loaded = false; - assert!(!state.review_anchor_current(&before)); - state.comparison.as_mut().unwrap().files[0].loaded = true; - state.comparison.as_mut().unwrap().revisions[0] = "other".into(); - assert!(!state.review_anchor_current(&before)); - } -} +#[path = "../tests/unit/diff.rs"] +mod tests; diff --git a/src/editor.rs b/src/editor.rs index 0f21438..22786c7 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -13158,15236 +13158,5 @@ fn atomic_save( #[cfg(test)] // These fixtures intentionally contain ranges, not lists of indices. #[allow(clippy::single_range_in_vec_init)] -mod tests { - use super::*; - use crate::plugin::CompletionEdit; - use std::collections::BTreeSet; - - #[test] - fn resolver_native_inputs_are_readonly_and_dirty_shared_results_survive_close() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("draft\n".into())]) - .unwrap(); - let result = editor.buffer_id(); - editor.open_resolver(); - let tab = editor.tab_id(); - editor.resolver_requests.clear(); - let resolver = editor.resolvers.get_mut(&tab).unwrap(); - resolver.loading = false; - resolver.files.push(ResolverFile { - path: "file.txt".into(), - draft_buffer: result, - status: ResolverStatus::Unsaved, - jj_conflicted: true, - text: true, - whole_file_choice: None, - error: None, - seed_source: 0, - projection: None, - diff: DiffState::new(), - comparison_key: None, - sources: vec![ResolverSource { - label: "Side #1".into(), - text: "input\n".into(), - base: false, - }], - }); - editor.select_resolver_file(0); - let source = editor.resolver().unwrap().alternative_pane.unwrap(); - editor.focus_resolver_pane(source); - for command in ["w", "w! /tmp/vamp-readonly-must-not-write", "buffer_delete"] { - editor.execute(command); - assert_eq!(editor.lines(), ["input", ""]); - } - assert!( - editor - .apply(vec![Effect::Insert("blocked".into())]) - .is_err() - ); - for input in [Input::Char('i'), Input::Char('u'), Input::Char('U')] { - editor.handle(input); - } - assert_eq!(editor.lines(), ["input", ""]); - editor.handle(Input::Char('/')); - editor.handle(Input::Char('i')); - editor.handle(Input::Enter); - assert_eq!(editor.active.view.search_query, "i"); - editor.execute("resolve.files"); - assert!(editor.file_picker().unwrap().focused); - editor.execute("resolve.close"); - assert_eq!(editor.tab_id(), tab); - editor.execute("pane.force_close"); - assert_eq!(editor.tab_id(), tab); - assert_eq!(editor.buffer(result).unwrap().lines, ["draft", ""]); - editor.execute("tab.previous"); - assert_eq!(editor.buffer_id(), result); - assert!(editor.dirty()); - } - - #[test] - fn resolver_loading_has_no_bound_diff_buffer() { - let mut editor = editor(); - let source = editor.buffer_id(); - editor.open_resolver(); - assert!(editor.resolver().unwrap().loading); - assert!(editor.diff().is_none()); - assert!(editor.diff_buffer_focus().is_none()); - editor.refresh_live_diff(); - assert!(editor.diff_buffer_focus().is_none()); - editor.execute("resolve.close"); - assert_eq!(editor.buffer_id(), source); - editor.refresh_live_diff(); - assert!(editor.diff_buffer_focus().is_none()); - } - - #[test] - fn resolver_rebuilding_panes_revokes_cached_diff_binding_until_ready() { - let mut editor = resolver_editor("draft", &[("draft", false), ("alternative", false)]); - let draft = editor.buffer_id(); - let alternative = editor - .resolvers - .get_mut(&editor.tab_id) - .unwrap() - .alternative_pane - .take(); - assert!(editor.diff().is_none()); - assert!(editor.diff_buffer_focus().is_none()); - editor.refresh_live_diff(); - assert_eq!(editor.buffer_id(), draft); - editor - .resolvers - .get_mut(&editor.tab_id) - .unwrap() - .alternative_pane = alternative; - editor.refresh_live_diff(); - assert_eq!(editor.diff_buffer_focus(), Some(Focus::Before)); - editor.execute("resolve.history"); - assert!(editor.diff().is_none()); - assert!(editor.diff_buffer_focus().is_none()); - editor.refresh_live_diff(); - editor.execute("resolve.draft"); - assert_eq!(editor.buffer_id(), draft); - assert_eq!(editor.diff_buffer_focus(), Some(Focus::Before)); - } - - #[test] - fn resolver_shares_native_excerpt_search_motions_and_explicit_writable_side() { - let directory = Directory::new(); - let mut before: Vec<_> = (0..90).map(|row| format!("row {row}")).collect(); - before[20] = "needle hidden".into(); - before[70] = "needle visible".into(); - let mut after = before.clone(); - for row in [10, 70] { - after[row].push_str(" alternative"); - } - let draft = before.join("\n"); - let alternative = after.join("\n"); - let mut resolver = resolver_editor(&draft, &[(&draft, false), (&alternative, false)]); - let mut ordinary = editor(); - historical_diff(&mut ordinary, &directory.0, before, after); - for editor in [&mut resolver, &mut ordinary] { - editor.set_diff_context_options(1, 2); - editor - .bind("normal", "M", Some("core.down".into())) - .unwrap(); - type_text(editor, "11gMM"); - assert_eq!(editor.cursor().row, 69); - type_text(editor, "/needle"); - editor.handle(Input::Enter); - assert_eq!(editor.cursor().row, 70); - let rows = editor.diff().unwrap().source_rows(Focus::Before).count(); - let cursor = editor.cursor(); - editor.handle(Input::Char(']')); - assert!(editor.diff().unwrap().source_rows(Focus::Before).count() > rows); - assert_eq!(editor.cursor(), cursor); - type_text(editor, "21g"); - assert_eq!(editor.cursor().row, 20); - assert!( - editor - .diff() - .unwrap() - .source_display_row(Focus::Before, 20) - .is_some() - ); - } - assert_eq!( - resolver.diff().unwrap().display_rows, - ordinary.diff().unwrap().display_rows - ); - assert!(resolver.diff_editing()); - assert!(resolver.diff().unwrap().side_editable(Focus::Before)); - assert!(!resolver.diff().unwrap().side_editable(Focus::After)); - let draft_buffer = resolver.buffer_id(); - resolver.handle(Input::Char('\t')); - assert_eq!(resolver.diff_buffer_focus(), Some(Focus::After)); - assert!(!resolver.diff_editing()); - let text = resolver.context().text; - type_text(&mut resolver, "ix"); - assert_eq!(resolver.context().text, text); - resolver.handle(Input::BackTab); - assert_eq!(resolver.buffer_id(), draft_buffer); - assert!(resolver.diff_editing()); - } - - #[test] - fn resolver_shared_layout_remaps_and_wheel_preserve_native_draft_identity() { - let draft = (0..50) - .map(|row| format!("draft {row}\n")) - .collect::(); - let alternative = draft.replace("draft 20", "alternative 20"); - let mut editor = resolver_editor(&draft, &[(&draft, false), (&alternative, false)]); - let buffer = editor.buffer_id(); - let cursor = editor.cursor(); - for (key, action) in [ - ("Q", "core.diff.view.toggle"), - ("W", "core.diff.orientation.toggle"), - ("E", "core.word_wrap.toggle"), - ] { - editor.bind("normal", key, Some(action.into())).unwrap(); - } - editor.handle(Input::Char('W')); - assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); - editor.handle(Input::Char('Q')); - assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); - editor.handle(Input::Char('E')); - assert!(editor.word_wrap()); - editor.handle(Input::Char('i')); - editor.handle(Input::Paste("native ".into())); - let insert_cursor = editor.cursor(); - let diff = editor.diff().unwrap(); - assert!(diff.follow_caret); - editor.set_diff_viewport( - diff.comparison_id.unwrap(), - diff.file, - diff.top, - diff.left_before, - diff.left_after, - ); - assert!(editor.diff().unwrap().follow_caret); - editor.scroll_diff(Focus::Before, true); - assert_eq!(editor.mode(), Mode::Insert); - assert_eq!(editor.cursor(), insert_cursor); - assert_eq!(editor.buffer_id(), buffer); - assert!(!editor.diff().unwrap().follow_caret); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, draft); - assert_eq!(editor.cursor(), cursor); - editor.set_diff_pane_bounds([ - (Focus::Files, PaneRect::default()), - ( - Focus::Before, - PaneRect { - x: 0, - y: 0, - width: 80, - height: 10, - }, - ), - ( - Focus::After, - PaneRect { - x: 0, - y: 11, - width: 80, - height: 10, - }, - ), - ]); - editor.execute("pane.focus_down"); - assert_eq!(editor.diff_buffer_focus(), Some(Focus::After)); - editor.execute("pane.focus_up"); - assert_eq!(editor.buffer_id(), buffer); - assert!(editor.diff_editing()); - } - - fn resolver_editor(draft: &str, sources: &[(&str, bool)]) -> Editor { - let mut editor = editor(); - editor.apply(vec![Effect::Replace(draft.into())]).unwrap(); - let draft_buffer = editor.buffer_id(); - editor.open_resolver(); - editor.resolver_requests.clear(); - let resolver = editor.resolvers.get_mut(&editor.tab_id).unwrap(); - resolver.loading = false; - resolver.files.push(ResolverFile { - path: "conflict.txt".into(), - draft_buffer, - status: ResolverStatus::Unsaved, - jj_conflicted: true, - text: true, - whole_file_choice: None, - error: None, - seed_source: sources.iter().position(|(_, base)| !base).unwrap_or(0), - projection: None, - diff: DiffState::new(), - comparison_key: None, - sources: sources - .iter() - .enumerate() - .map(|(index, (text, base))| ResolverSource { - label: format!("Source {}", index + 1), - text: (*text).into(), - base: *base, - }) - .collect(), - }); - editor.select_resolver_file(0); - editor - } - - #[test] - fn resolver_explorer_retains_last_jj_conflict_state_until_confirmation() { - let mut editor = resolver_editor("draft", &[("draft", false), ("other", false)]); - let path = Path::new("conflict.txt"); - let marker = |editor: &Editor| { - editor - .resolver_workspace_diff() - .unwrap() - .files - .as_ref() - .unwrap() - .is_conflicted(path) - }; - assert_eq!( - editor.resolver().unwrap().files[0].status, - ResolverStatus::Unsaved - ); - assert!(marker(&editor)); - for confirmed in [ - crate::resolve::Status::Unresolved, - crate::resolve::Status::Resolved, - crate::resolve::Status::Unresolved, - ] { - editor.resolvers.get_mut(&editor.tab_id).unwrap().files[0].apply_jj_status(confirmed); - editor.refresh_resolver_status(); - let conflicted = confirmed == crate::resolve::Status::Unresolved; - assert_eq!(marker(&editor), conflicted); - assert_eq!( - editor.resolver().unwrap().files[0].status, - ResolverStatus::Unsaved - ); - - editor.resolvers.get_mut(&editor.tab_id).unwrap().files[0].status = - ResolverStatus::Checking; - editor.refresh_resolver_status(); - assert_eq!( - editor.resolver().unwrap().files[0].status, - ResolverStatus::Checking - ); - assert_eq!(marker(&editor), conflicted); - - editor.resolvers.get_mut(&editor.tab_id).unwrap().files[0] - .apply_jj_status(crate::resolve::Status::Unconfirmed); - assert_eq!( - editor.resolver().unwrap().files[0].status, - ResolverStatus::Unconfirmed - ); - editor.active.saved_lines.clone_from(&editor.active.lines); - editor.refresh_resolver_status(); - assert_eq!( - editor.resolver().unwrap().files[0].status, - ResolverStatus::Unconfirmed - ); - assert_eq!(marker(&editor), conflicted); - editor.apply(vec![Effect::Insert(" edit".into())]).unwrap(); - editor.refresh_resolver_status(); - assert_eq!( - editor.resolver().unwrap().files[0].status, - ResolverStatus::Unsaved - ); - assert_eq!(marker(&editor), conflicted); - - editor.select_resolver_file(0); - assert_eq!(marker(&editor), conflicted); - } - } - - #[test] - fn resolver_takes_only_current_diff_hunk_and_undo_preserves_native_newlines() { - for (draft, alternative, chosen) in [ - ( - "before\nleft\nshared\ntail\n", - "before\nright\nshared\nother\n", - "before\nright\nshared\ntail\n", - ), - ("a\n", "a", "a"), - ("a", "a\n", "a\n"), - ("a\nlast", "a\n", "a\n"), - ("", "new", "new"), - ("old", "", ""), - ("\n", "", ""), - ] { - let mut editor = resolver_editor(draft, &[(draft, false), (alternative, false)]); - editor.execute("resolve.use_source 2"); - assert_eq!(editor.context().text, chosen); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, draft); - } - } - - #[test] - fn resolver_history_selects_every_side_and_base_without_moving_draft_or_viewport() { - let draft = (0..40) - .map(|row| format!("draft {row}\n")) - .collect::(); - let mut editor = resolver_editor( - &draft, - &[ - ("base one", true), - (&draft, false), - ("base two", true), - ("alternative one", false), - ("alternative two", false), - ], - ); - editor.active.view.targets = vec![Target::caret(Cursor { row: 20, column: 3 })]; - let diff = editor.diff().unwrap(); - editor.set_diff_viewport(diff.comparison_id.unwrap(), diff.file, 8, 2, 4); - let cursor = editor.cursor(); - let buffer = editor.buffer_id(); - let id = editor.resolver_diff().unwrap().comparison_id; - assert!(editor.file_picker().is_none()); - editor.execute("word_wrap.toggle"); - assert!(editor.word_wrap()); - editor.execute("resolve.history"); - assert_eq!(editor.resolver().unwrap().view, ResolverView::History); - assert!( - editor - .apply(vec![Effect::Insert("blocked".into())]) - .is_err() - ); - editor.execute("resolve.source 5"); - assert_eq!(editor.lines(), ["alternative two"]); - assert_eq!(editor.resolver().unwrap().history_sources[0], 4); - editor.execute("resolve.base 2"); - assert_eq!(editor.lines(), ["base two"]); - assert_eq!(editor.resolver().unwrap().selected_base, Some(2)); - editor.execute("resolve.draft"); - assert_eq!(editor.context().text, draft); - assert_eq!(editor.cursor(), cursor); - assert_eq!(editor.buffer_id(), buffer); - let diff = editor.resolver_diff().unwrap(); - assert_eq!(diff.comparison_id, id); - assert_eq!((diff.top, diff.left_before, diff.left_after), (8, 2, 4)); - assert!(editor.word_wrap()); - editor.handle(Input::Char('\t')); - assert_eq!(editor.resolver_buffer_focus(), Some(Focus::After)); - editor.handle(Input::Char('\t')); - assert_eq!(editor.resolver_buffer_focus(), Some(Focus::Before)); - assert!(!editor.file_picker().is_some_and(|picker| picker.focused)); - editor.execute("resolve.files"); - assert!(editor.file_picker().unwrap().focused); - editor.execute("resolve.files"); - assert!(editor.file_picker().is_none()); - } - - #[test] - fn resolver_equal_choice_keeps_draft_editable_and_decision_navigation_wraps() { - let mut editor = resolver_editor( - "left\nsame\nlast\n", - &[ - ("left\nsame\nlast\n", false), - ("right\nsame\nother\n", false), - ], - ); - assert_eq!(editor.cursor().row, 0); - editor.execute("resolve.next"); - assert_eq!(editor.cursor().row, 2); - editor.execute("resolve.next"); - assert_eq!(editor.cursor().row, 0); - editor.execute("resolve.previous"); - assert_eq!(editor.cursor().row, 2); - editor.execute("resolve.use_source 2"); - editor.execute("resolve.next"); - editor.execute("resolve.use_source 2"); - let diff = editor.resolver_diff().unwrap(); - assert!(diff.comparison.as_ref().unwrap().files[0].hunks.is_empty()); - assert!( - diff.source_display_row(Focus::Before, editor.cursor().row) - .is_some() - ); - editor.handle(Input::Char('i')); - editor.handle(Input::Paste("native ".into())); - editor.handle(Input::Escape); - assert!(editor.context().text.starts_with("native right")); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "right\nsame\nother\n"); - } - - #[test] - fn resolver_file_switches_restore_native_draft_and_refresh_readonly_alternative() { - let mut editor = resolver_editor( - "first\n", - &[("first\n", false), ("first alternative\n", false)], - ); - editor.active.view.targets = vec![Target::caret(Cursor { row: 0, column: 2 })]; - let cursor = editor.cursor(); - let comparison = editor.resolver_diff().unwrap().comparison_id; - let buffer = editor.next_buffer_id; - editor.next_buffer_id += 1; - editor.parked.insert( - buffer, - Buffer::from_text(buffer, None, None, "second\n", None).unwrap(), - ); - let resolver = editor.resolvers.get_mut(&editor.tab_id).unwrap(); - resolver.files.push(ResolverFile { - path: "second.txt".into(), - draft_buffer: buffer, - status: ResolverStatus::Unresolved, - jj_conflicted: true, - text: true, - whole_file_choice: None, - error: None, - seed_source: 0, - projection: None, - diff: DiffState::new(), - comparison_key: None, - sources: ["second\n", "second alternative\n"] - .into_iter() - .map(|text| ResolverSource { - label: text.into(), - text: text.into(), - base: false, - }) - .collect(), - }); - editor.select_resolver_file(0); - let rows = editor.diff().unwrap().display_rows.clone(); - editor - .bind("normal", "P", Some("core.file_picker".into())) - .unwrap(); - editor.handle(Input::Char('P')); - type_text(&mut editor, "second"); - editor.handle(Input::Enter); - assert_eq!(editor.resolver().unwrap().current_file, 1); - assert_eq!(editor.diff().unwrap().file, 1); - assert_eq!( - editor - .diff() - .unwrap() - .comparison - .as_ref() - .unwrap() - .files - .len(), - 2 - ); - assert_eq!(editor.context().text, "second\n"); - assert_ne!(editor.resolver_diff().unwrap().comparison_id, comparison); - editor.handle(Input::Char('P')); - editor.handle(Input::Ctrl('u')); - type_text(&mut editor, "conflict"); - editor.handle(Input::Enter); - assert_eq!(editor.resolver().unwrap().current_file, 0); - assert_eq!(editor.diff().unwrap().display_rows, rows); - assert_eq!(editor.context().text, "first\n"); - assert_eq!(editor.cursor(), cursor); - assert_eq!(editor.resolver_diff().unwrap().comparison_id, comparison); - let pane = editor.resolver().unwrap().alternative_pane.unwrap(); - editor.focus_resolver_pane(pane); - assert_eq!(editor.context().text, "first alternative\n"); - } - - struct Directory(PathBuf); - impl Directory { - fn new() -> Self { - let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); - let path = - std::env::temp_dir().join(format!("vamp-editor-test-{}-{id}", std::process::id())); - fs::create_dir(&path).unwrap(); - Self(path) - } - fn path(&self, name: &str) -> PathBuf { - self.0.join(name) - } - } - impl Drop for Directory { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - fn editor() -> Editor { - let mut editor = Editor::open(None, vec!["text".into()]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - editor - } - #[test] - fn copy_line_action_and_y_prefix_respect_bindings_and_cancellation() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("one\ntwo".into())]) - .unwrap(); - editor - .bind("normal", "q", Some("core.copy_line".into())) - .unwrap(); - assert!( - editor - .bind("visual", "q", Some("core.copy_line".into())) - .is_err() - ); - assert!( - editor - .bind("insert", "q", Some("core.copy_line".into())) - .is_err() - ); - type_text(&mut editor, "q"); - assert_eq!(editor.clipboard, ["one\n"]); - type_text(&mut editor, "2y3"); - assert_eq!(editor.line_operator_pending(), Some('y')); - assert_eq!(editor.count(), Some(6)); - editor.handle(Input::Escape); - assert_eq!(editor.line_operator_pending(), None); - assert_eq!(editor.count(), None); - editor - .bind("normal", "y", Some("core.down".into())) - .unwrap(); - type_text(&mut editor, "y"); - assert_eq!(editor.cursor().row, 1); - assert_eq!(editor.line_operator_pending(), None); - editor.bind("normal", "y", None).unwrap(); - type_text(&mut editor, "yy"); - assert_eq!(editor.clipboard, ["one\n"]); - } - - #[test] - fn copy_line_restores_overlapping_multicarets_and_preserves_redo_and_repeat() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("one\ntwo\nthree".into())]) - .unwrap(); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - editor.active.view.targets = vec![ - Target { - anchor: Cursor { row: 0, column: 1 }, - active: Cursor { row: 0, column: 1 }, - preferred_columns: Some((1, 1)), - }, - Target { - anchor: Cursor { row: 1, column: 2 }, - active: Cursor { row: 1, column: 2 }, - preferred_columns: Some((2, 2)), - }, - ]; - editor.active.view.primary = 1; - let targets = editor.active.view.targets.clone(); - let history = format!( - "{:?}", - (&editor.active.undo, &editor.active.redo, &editor.last_edit) - ); - let revision = editor.text_revision(); - type_text(&mut editor, "2yy"); - assert_eq!(editor.clipboard, ["one\ntwo\nthree\n"]); - assert!(editor.clipboard_linewise); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.active.view.primary, 1); - assert_eq!(editor.mode(), Mode::Normal); - assert!(!editor.active.view.linewise); - assert_eq!(editor.text_revision(), revision); - assert_eq!( - format!( - "{:?}", - (&editor.active.undo, &editor.active.redo, &editor.last_edit) - ), - history - ); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "Xone\ntwo\nthree"); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "XXone\ntwo\nthree"); - } - - fn type_text(editor: &mut Editor, text: &str) { - for ch in text.chars() { - editor.handle(if ch == '\n' { - Input::Enter - } else { - Input::Char(ch) - }); - } - } - - #[test] - fn external_reload_clamps_shared_views_and_restarts_clean_insert_history() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "one\ntwo\nthree").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor - .apply(vec![Effect::Replace("old edit".into())]) - .unwrap(); - editor.handle(Input::Char('u')); - type_text(&mut editor, "gb$"); - editor.execute("tab.new"); - editor.handle(Input::Char('|')); - let pane = editor.pane_id(); - let revision = editor.text_revision(); - fs::write(&path, "é").unwrap(); - assert!(editor.refresh_files()); - assert_eq!(editor.lines(), ["é"]); - assert!(!editor.dirty()); - assert!(editor.text_revision() > revision); - assert_eq!(editor.cursor(), Cursor::default()); - editor.handle(Input::Alt('h')); - assert_ne!(editor.pane_id(), pane); - assert_eq!(editor.cursor(), Cursor::default()); - editor.execute("tab.previous"); - assert_eq!(editor.cursor(), Cursor::default()); - editor.handle(Input::Char('U')); - assert_eq!(editor.lines(), ["é"]); - editor.handle(Input::Char('i')); - fs::write(&path, "😀ZZ").unwrap(); - assert!(editor.refresh_files()); - assert_eq!(editor.mode(), Mode::Insert); - type_text(&mut editor, "X"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), ["😀ZZ"]); - assert!(!editor.dirty()); - } - - #[test] - fn external_dirty_writes_require_force_even_without_a_poll() { - for (normal, force, quits) in [ - ("w", "w!", false), - ("save", "force_save", false), - ("wq", "wq!", true), - ("save_and_quit", "force_save_and_quit", true), - ] { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "baseline").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor - .apply(vec![Effect::Replace("local edits".into())]) - .unwrap(); - fs::write(&path, "external").unwrap(); - assert!(!editor.refresh_files()); - editor.execute(normal); - assert_eq!(fs::read_to_string(&path).unwrap(), "external"); - assert_eq!(editor.lines(), ["local edits"]); - assert!(editor.dirty()); - assert!(!editor.should_quit()); - assert!(editor.message().contains(":w!")); - editor.execute(force); - assert_eq!(fs::read_to_string(&path).unwrap(), "local edits"); - assert!(!editor.dirty()); - assert_eq!(editor.should_quit(), quits); - } - } - - #[test] - fn external_deletion_is_not_a_new_file_and_invalid_reload_preserves_text() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - fs::remove_file(&path).unwrap(); - assert!(editor.refresh_files()); - assert!(!editor.refresh_files()); - editor.execute("w"); - assert!(!path.exists()); - editor.execute("w!"); - assert_eq!(fs::read_to_string(&path).unwrap(), ""); - fs::write(&path, b"\xff").unwrap(); - let revision = editor.text_revision(); - assert!(editor.refresh_files()); - assert_eq!(editor.text_revision(), revision); - assert_eq!(editor.lines(), [""]); - editor.execute("wq"); - assert!(!editor.should_quit()); - assert_eq!(fs::read(&path).unwrap(), b"\xff"); - fs::write(&path, "recovered\r\n").unwrap(); - assert!(editor.refresh_files()); - assert_eq!(editor.lines(), ["recovered", ""]); - assert!(!editor.dirty()); - let missing = directory.path("new.txt"); - let mut new = Editor::open(Some(missing.clone()), vec![]).unwrap(); - new.execute("w"); - assert_eq!(fs::read_to_string(missing).unwrap(), ""); - } - - #[test] - fn external_same_text_keeps_history_and_newline_normalization_allows_save() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "same\r\n").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor - .apply(vec![Effect::Replace("old edit".into())]) - .unwrap(); - editor.handle(Input::Char('u')); - let revision = editor.text_revision(); - fs::write(&path, "same\n").unwrap(); - assert!(!editor.refresh_files()); - assert_eq!(editor.text_revision(), revision); - editor.handle(Input::Char('U')); - assert_eq!(editor.lines(), ["old edit"]); - editor.execute("w"); - assert_eq!(fs::read_to_string(path).unwrap(), "old edit"); - assert!(!editor.dirty()); - } - - #[cfg(unix)] - #[test] - fn external_atomic_replacement_with_same_size_and_mtime_reloads() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - let replacement = directory.path("replacement"); - let fixed = std::time::UNIX_EPOCH + std::time::Duration::from_secs(123); - fs::write(&path, "before").unwrap(); - fs::File::open(&path).unwrap().set_modified(fixed).unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - fs::write(&replacement, "after!").unwrap(); - fs::File::open(&replacement) - .unwrap() - .set_modified(fixed) - .unwrap(); - fs::rename(&replacement, &path).unwrap(); - assert!(editor.refresh_files()); - assert_eq!(editor.lines(), ["after!"]); - } - - #[test] - fn external_reload_refreshes_working_diff_but_not_historical_snapshots() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "disk").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - working_diff(&mut editor, &directory.0, &["file.txt"]); - fs::write(&path, "external working").unwrap(); - assert!(editor.refresh_files()); - assert_eq!( - editor.diff().unwrap().comparison.as_ref().unwrap().files[0].after, - ["external working"], - ); - editor.execute("diff.close"); - historical_diff( - &mut editor, - &directory.0, - vec!["before".into()], - vec!["after".into()], - ); - let revision = editor.text_revision(); - fs::write(&path, "new disk").unwrap(); - assert!(editor.refresh_files()); - assert_eq!(editor.lines(), ["before"]); - assert_eq!(editor.text_revision(), revision); - editor.execute("w!"); - assert_eq!(fs::read_to_string(path).unwrap(), "new disk"); - } - - #[test] - fn guarded_restart_closes_historical_views_without_losing_dirty_source_history() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "disk").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor - .apply(vec![Effect::Replace("unsaved".into())]) - .unwrap(); - let dirty = editor.buffer_id(); - historical_diff( - &mut editor, - &directory.0, - vec!["before".into()], - vec!["after".into()], - ); - editor.execute("tab.new"); - let snapshot = editor.buffer_id(); - assert!(editor.active.diff_snapshot.is_some()); - editor.execute("restart"); - assert!(!editor.should_restart()); - assert!(!editor.has_buffer(snapshot)); - assert_eq!(editor.buffer_id(), dirty); - assert_eq!(editor.lines(), ["unsaved"]); - assert!(editor.diff().is_none()); - assert!(editor.tabs.values().all(|tab| tab.diff().is_none())); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), ["disk"]); - assert!(!editor.dirty()); - assert_eq!(fs::read_to_string(path).unwrap(), "disk"); - editor.execute("restart"); - assert!(editor.should_restart()); - assert!(editor.should_quit()); - } - - #[test] - fn notification_escape_preserves_carets_then_ordinary_escape_collapses_them() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abcd\nabcd".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 1), (1, 1)], 1); - assert!(editor.notification_visible()); - editor.set_message("Written"); - editor.handle(Input::Right); - let targets = editor.active.view.targets.clone(); - let revision = editor.text_revision(); - let history = (editor.active.undo.len(), editor.active.redo.len()); - editor.handle(Input::Escape); - assert!(!editor.notification_visible()); - assert_eq!(editor.message(), "Written"); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.active.view.primary, 1); - assert_eq!(editor.text_revision(), revision); - assert_eq!( - (editor.active.undo.len(), editor.active.redo.len()), - history - ); - editor.handle(Input::Escape); - assert_eq!(editor.selection_count(), 1); - editor.set_message("Written"); - assert!(editor.notification_visible()); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - assert!(editor.notification_visible()); - assert_eq!(editor.message(), "Written"); - editor.handle(Input::Char('v')); - editor.handle(Input::Escape); - assert!(editor.notification_visible()); - for prompt in [':', '/'] { - editor.handle(Input::Char(prompt)); - editor.handle(Input::Char('x')); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), Mode::Normal); - assert!(editor.notification_visible()); - } - } - - #[test] - fn notification_escape_waits_for_prefixes_and_focused_overlays() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abcd\nabcd".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 1), (1, 1)], 1); - editor.set_message("Written"); - let targets = editor.active.view.targets.clone(); - for prefix in ["f", "g", "2d", "4", " b"] { - type_text(&mut editor, prefix); - editor.handle(Input::Escape); - assert!(editor.notification_visible(), "{prefix}"); - assert_eq!(editor.active.view.targets, targets, "{prefix}"); - assert_eq!(editor.count(), None); - } - editor.show_startup_diagnostic("startup".into()); - editor.handle(Input::Escape); - assert!(editor.startup_diagnostic().is_none()); - assert!(editor.notification_visible()); - editor.show_hover("hover".into()).unwrap(); - editor.handle(Input::Escape); - assert!(editor.hover().is_none()); - assert!(editor.notification_visible()); - editor.handle(Input::Ctrl('p')); - editor.handle(Input::Escape); - assert!(editor.palette().is_none()); - assert!(editor.notification_visible()); - editor.open_file_picker(FilePresentation::Docked); - editor.handle(Input::Char('i')); - assert!(editor.file_picker().unwrap().searching); - editor.handle(Input::Escape); - assert!(editor.file_picker().unwrap().focused); - assert!(editor.notification_visible()); - editor.handle(Input::Escape); - assert!(!editor.file_picker().unwrap().focused); - assert!(editor.notification_visible()); - assert_eq!(editor.active.view.targets, targets); - editor.handle(Input::Escape); - assert!(!editor.notification_visible()); - assert_eq!(editor.active.view.targets, targets); - } - - #[test] - fn docked_picker_leaders_replace_or_blur_consistently_without_stealing_filter_text() { - let directory = Directory::new(); - fs::write(directory.path("file.txt"), "source").unwrap(); - for comparison in [false, true] { - let mut editor = editor(); - editor.project_root = directory.0.clone(); - if comparison { - historical_diff( - &mut editor, - &directory.0, - vec!["before".into()], - vec!["after".into()], - ); - } - type_text(&mut editor, " e"); - assert!(editor.file_picker().unwrap().focused); - assert!(!editor.file_picker().unwrap().searching); - type_text(&mut editor, " o"); - assert_eq!( - editor.file_picker().unwrap().presentation, - FilePresentation::Floating - ); - assert!(editor.file_picker().unwrap().searching); - editor.handle(Input::Escape); - editor.handle(Input::Escape); - type_text(&mut editor, " e"); - let selected = editor.file_picker().unwrap().selected; - editor.handle(Input::Escape); - let escape_focus = editor.diff().map(|diff| diff.focus); - type_text(&mut editor, " e"); - assert!(editor.file_picker().unwrap().focused); - type_text(&mut editor, " e"); - assert!(!editor.file_picker().unwrap().focused); - assert_eq!( - editor.file_picker().unwrap().presentation, - FilePresentation::Docked - ); - assert_eq!(editor.file_picker().unwrap().selected, selected); - assert_eq!(editor.diff().map(|diff| diff.focus), escape_focus); - type_text(&mut editor, " e"); - editor.handle(Input::Char('i')); - type_text(&mut editor, " o e"); - assert_eq!(editor.file_picker().unwrap().query, " o e"); - assert!(editor.file_picker().unwrap().searching); - assert_eq!( - editor.file_picker().unwrap().presentation, - FilePresentation::Docked - ); - } - } - - #[test] - fn focused_text_fields_keep_printable_normal_bindings_as_text() { - let directory = Directory::new(); - fs::write(directory.path("sample.txt"), "text").unwrap(); - for field in [ - "floating", - "docked", - "diff_tree", - "diff_list", - "palette", - "command", - "search", - ] { - let mut editor = editor(); - editor.project_root = directory.0.clone(); - editor - .bind("normal", "x", Some("core.pane.split_vertical".into())) - .unwrap(); - match field { - "floating" => editor.open_file_picker(FilePresentation::Floating), - "docked" => { - editor.open_file_picker(FilePresentation::Docked); - editor.handle(Input::Char('i')); - } - "diff_tree" | "diff_list" => { - historical_diff( - &mut editor, - &directory.0, - vec!["before".into()], - vec!["after".into()], - ); - if field == "diff_list" { - editor.set_file_explorer_view("list").unwrap(); - } - editor.open_file_picker(FilePresentation::Docked); - if field == "diff_tree" { - editor.handle(Input::Char('i')); - } - } - "palette" => { - editor.handle(Input::Ctrl('p')); - } - "command" => { - editor.handle(Input::Char(':')); - } - "search" => { - editor.handle(Input::Char('/')); - } - _ => unreachable!(), - } - let document = editor.lines().to_vec(); - editor.handle(Input::Char(':')); - editor.handle(Input::Char('x')); - let query = match field { - "palette" => editor.palette().unwrap().query.as_str(), - "command" | "search" => editor.command_line(), - _ => editor.file_picker().unwrap().query.as_str(), - }; - assert_eq!(query, ":x", "{field}"); - assert_eq!(editor.pane_layout().len(), 1, "{field}"); - assert_eq!(editor.lines(), document, "{field}"); - } - } - - #[test] - fn empty_notification_does_not_swallow_escape_but_cursor_diagnostic_does() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abcd\nabcd".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 1), (1, 1)], 1); - editor.handle(Input::Escape); - assert_eq!(editor.selection_count(), 1); - assert!(editor.notification_visible()); - editor - .replace_diagnostics( - "fixture", - editor.buffer_id(), - editor.text_revision(), - vec![annotation((1, 0), (1, 3), "diagnostic")], - ) - .unwrap(); - set_carets(&mut editor, &[(0, 1), (1, 1)], 1); - editor.handle(Input::Escape); - assert!(!editor.notification_visible()); - assert_eq!(editor.selection_count(), 2); - assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "diagnostic"); - editor.handle(Input::Escape); - assert_eq!(editor.selection_count(), 1); - } - - #[test] - fn hidden_pane_interactions_cannot_reopen_or_dismiss_focused_notifications() { - let mut editor = editor(); - let hidden = editor.pane_id(); - editor.execute("pane.split_vertical"); - editor.set_message("focused"); - editor.handle(Input::Escape); - assert!(!editor.notification_visible()); - editor.with_pane(hidden, |pane| pane.set_message("background")); - assert_eq!(editor.message(), "focused"); - assert!(!editor.notification_visible()); - editor.with_render_pane(hidden, |pane| { - assert!(pane.message().is_empty()); - }); - assert!(!editor.notification_visible()); - editor.set_message("focused"); - editor.with_pane(hidden, |pane| { - pane.set_message("background"); - pane.handle(Input::Escape); - }); - assert!(editor.notification_visible()); - assert_eq!(editor.message(), "focused"); - editor.execute("tab.new"); - editor.set_message("new tab"); - editor.handle(Input::Escape); - editor.with_pane(hidden, |pane| pane.set_message("hidden tab")); - assert_eq!(editor.message(), "new tab"); - assert!(!editor.notification_visible()); - } - - #[test] - fn buffer_activation_preserves_last_operation_and_dismissal() { - let directory = Directory::new(); - let path = directory.path("opened.txt"); - fs::write(&path, "text").unwrap(); - let mut editor = editor(); - editor.set_message("Written"); - editor.execute(&format!("edit {}", path.display())); - assert_eq!(editor.message(), "Written"); - editor.handle(Input::Escape); - for command in ["buffer 1", "bnext", "bprev", "buffer 1"] { - editor.execute(command); - assert_eq!(editor.message(), "Written"); - assert!(!editor.notification_visible()); - } - editor.execute("buffer 999"); - assert!(editor.message().contains("No buffer")); - assert!(editor.notification_visible()); - } - - #[test] - fn native_lsp_cache_is_independent_presentation_metadata() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("document".into())]) - .unwrap(); - type_text(&mut editor, "vl"); - editor.set_message("Written"); - editor.notification_visible = false; - editor - .set_label("lsp".into(), "configured label".into()) - .unwrap(); - let targets = editor.active.view.targets.clone(); - let revision = editor.text_revision(); - let history = (editor.active.undo.len(), editor.active.redo.len()); - assert!(editor.lsp_names().is_empty()); - assert_eq!(editor.lsp_label(), ""); - assert!(editor.set_lsp_names(vec!["rust-analyzer".into(), "界 server".into()])); - assert_eq!(editor.lsp_names(), ["rust-analyzer", "界 server"]); - assert_eq!(editor.lsp_label(), "rust-analyzer + 界 server"); - assert!(!editor.set_lsp_names(vec!["rust-analyzer".into(), "界 server".into()])); - assert!(editor.set_lsp_names(vec!["unsafe\nname".into()])); - assert_eq!(editor.lsp_label(), "unsafe�name"); - assert!(editor.set_lsp_names(Vec::new())); - assert_eq!(editor.lsp_label(), ""); - assert_eq!(editor.label("lsp"), "configured label"); - assert_eq!(editor.message(), "Written"); - assert!(!editor.notification_visible()); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.text_revision(), revision); - assert_eq!( - (editor.active.undo.len(), editor.active.redo.len()), - history - ); - } - - #[test] - fn pane_focus_remaps_do_not_steal_printable_picker_filter_input() { - let directory = Directory::new(); - fs::write(directory.path("hello.txt"), "alpha").unwrap(); - let mut editor = editor(); - editor.project_root = directory.0.clone(); - editor.handle(Input::Char('|')); - editor - .bind("normal", "h", Some("core.pane.focus_left".into())) - .unwrap(); - editor.open_file_picker(FilePresentation::Floating); - editor.handle(Input::Char('h')); - assert_eq!(editor.pane_id(), 2); - assert_eq!(editor.file_picker().unwrap().query, "h"); - } - - #[test] - fn pane_focus_bindings_escape_focused_explorer_without_changing_documents() { - let directory = Directory::new(); - fs::write(directory.path("a.txt"), "alpha").unwrap(); - fs::write(directory.path("b.txt"), "beta").unwrap(); - let mut editor = editor(); - editor.project_root = directory.0.clone(); - editor.handle(Input::Char('|')); - editor.handle(Input::Char('_')); - let original = editor.lines().to_vec(); - for (key, pane) in [('k', 2), ('h', 1), ('l', 2), ('j', 3)] { - editor.open_file_picker(FilePresentation::Docked); - assert!(editor.file_picker().unwrap().focused); - editor.handle(Input::Alt(key)); - assert_eq!(editor.pane_id(), pane); - assert!(!editor.file_picker().unwrap().focused); - assert_eq!(editor.lines(), original); - } - } - - #[test] - fn explorer_navigation_overrides_take_precedence_over_pane_focus() { - for context in ["tree", "selection"] { - for target in [None, Some("core.down")] { - let directory = Directory::new(); - fs::write(directory.path("a.txt"), "alpha").unwrap(); - fs::write(directory.path("b.txt"), "beta").unwrap(); - let mut editor = editor(); - editor.project_root = directory.0.clone(); - editor.handle(Input::Char('|')); - editor - .bind(context, "alt+h", target.map(str::to_owned)) - .unwrap(); - editor.open_file_picker(FilePresentation::Docked); - editor.handle(Input::Alt('h')); - assert_eq!(editor.pane_id(), 2); - let picker = editor.file_picker().unwrap(); - assert!(picker.focused); - assert_eq!(picker.selected, usize::from(target.is_some())); - } - } - } - - #[test] - fn nested_panes_inherit_views_and_focus_geometrically_without_touching_ctrl_j() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abc\ndef\nghi".into())]) - .unwrap(); - set_carets(&mut editor, &[(1, 1)], 0); - editor.handle(Input::Char('|')); - assert_eq!(editor.pane_id(), 2); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); - editor.handle(Input::Char('_')); - assert_eq!(editor.pane_layout().len(), 3); - editor.handle(Input::Alt('k')); - assert_eq!(editor.pane_id(), 2); - editor.handle(Input::Alt('h')); - assert_eq!(editor.pane_id(), 1); - editor.handle(Input::Alt('l')); - assert_eq!(editor.pane_id(), 2); - assert_eq!(parse_key("alt+j").unwrap(), Input::Alt('j')); - assert_eq!(parse_key("option+K").unwrap(), Input::Alt('k')); - editor.handle(Input::Char('i')); - editor.handle(Input::Ctrl('j')); - assert_eq!(editor.lines(), ["abc", "d", "ef", "ghi"]); - editor.handle(Input::Escape); - } - - #[test] - fn panes_share_edits_saved_state_and_undo_but_rebase_independent_visual_views() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abcdef".into())]) - .unwrap(); - editor.handle(Input::Char('|')); - set_ranges( - &mut editor, - &[(Cursor { row: 0, column: 2 }, Cursor { row: 0, column: 4 })], - 0, - ); - let selected = editor.selections(); - let selected_cursor = editor.cursor(); - editor.execute("pane.focus_left"); - set_carets(&mut editor, &[(0, 0)], 0); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - assert_eq!(editor.documents().count(), 1); - let focused = editor.pane_id(); - let lines_pointer = editor.lines().as_ptr(); - editor.with_render_pane(2, |pane| { - assert!(!pane.render_pane_focused()); - assert_eq!(pane.mode(), Mode::Visual); - assert_eq!(pane.lines().as_ptr(), lines_pointer); - assert_eq!(pane.cursor().column, selected_cursor.column + 1); - assert_eq!( - pane.selections()[0].start.column, - selected[0].start.column + 1 - ); - assert!(pane.dirty()); - }); - assert_eq!(editor.pane_id(), focused); - editor.execute("pane.focus_right"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), ["abcdef"]); - editor.execute("pane.focus_left"); - editor.handle(Input::Char('U')); - assert_eq!(editor.lines(), ["Xabcdef"]); - let directory = Directory::new(); - editor.execute(&format!("save {}", directory.path("saved.txt").display())); - assert!(!editor.dirty()); - editor.with_render_pane(2, |pane| assert!(!pane.dirty())); - editor.apply(vec![Effect::Replace("z".into())]).unwrap(); - editor.with_render_pane(2, |pane| assert_eq!(pane.cursor(), Cursor::default())); - } - - #[test] - fn panes_keep_modes_search_and_wrap_local_and_close_exclusive_clean_buffers() { - let directory = Directory::new(); - let path = directory.path("clean.txt"); - fs::write(&path, "one\ntwo\nthree").unwrap(); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - editor.handle(Input::Char('|')); - submit_search(&mut editor, "two"); - editor.execute("word_wrap.toggle"); - editor.execute("pane.focus_left"); - assert!(!editor.word_wrap()); - assert!(editor.active.view.search_query.is_empty()); - editor.handle(Input::Char('i')); - type_text(&mut editor, "X"); - editor.execute("pane.focus_right"); - assert_eq!(editor.active.view.search_query, "two"); - assert!(editor.word_wrap()); - assert_eq!(editor.mode(), Mode::Normal); - editor.execute("pane.focus_left"); - assert_eq!(editor.mode(), Mode::Insert); - type_text(&mut editor, "Y"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), ["Xone", "two", "three"]); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), ["one", "two", "three"]); - editor.execute(&format!("edit {}", directory.path("second.txt").display())); - let exclusive = editor.buffer_id(); - editor.execute("pane.close"); - assert_eq!(editor.pane_layout().len(), 1); - assert!(!editor.has_buffer(exclusive)); - assert_eq!(editor.buffer_id(), 1); - } - - #[test] - fn tabs_preserve_panes_explorers_and_independent_views_of_shared_history() { - let directory = Directory::new(); - let mut editor = editor(); - editor.project_root = directory.0.clone(); - editor - .apply(vec![Effect::Replace("abcdef".into())]) - .unwrap(); - editor.execute("pane.split_vertical"); - let source_pane = editor.pane_id(); - set_carets(&mut editor, &[(0, 2)], 0); - editor.open_file_picker(FilePresentation::Docked); - let layout = format!("{:?}", editor.pane_layout()); - editor.execute("tab.new"); - let new_pane = editor.pane_id(); - assert_ne!(source_pane, new_pane); - assert_eq!(editor.tab_index(), 1); - assert_eq!(editor.tab_count(), 2); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.buffers().count(), 1); - assert_eq!(editor.pane_layout().len(), 1); - assert!(editor.file_picker().is_none()); - set_carets(&mut editor, &[(0, 4)], 0); - editor.handle(Input::CtrlAlt('h')); - assert_eq!(editor.pane_id(), source_pane); - assert_eq!(format!("{:?}", editor.pane_layout()), layout); - assert_eq!(editor.cursor().column, 2); - assert!(editor.file_picker().unwrap().focused); - editor.handle(Input::CtrlAlt('l')); - assert_eq!(editor.pane_id(), new_pane); - assert_eq!(editor.cursor().column, 4); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - editor.handle(Input::CtrlAlt('l')); - assert_eq!(editor.tab_index(), 0); - assert_eq!(editor.lines(), ["abcdXef"]); - assert_eq!(editor.cursor().column, 2); - editor.blur_file_picker(); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), ["abcdef"]); - editor.handle(Input::CtrlAlt('h')); - assert_eq!(editor.tab_index(), 1); - assert_eq!(editor.lines(), ["abcdef"]); - assert_eq!(editor.pane_buffer_id(source_pane), Some(1)); - assert_eq!(editor.tab_buffer(0).unwrap().id, 1); - assert_eq!(editor.tab_buffer(1).unwrap().id, 1); - assert!(editor.tab_buffer(2).is_none()); - } - - #[test] - fn tab_close_guards_final_dirty_owners_and_force_keeps_shared_documents() { - let directory = Directory::new(); - let mut editor = editor(); - editor.apply(vec![Effect::Insert("shared".into())]).unwrap(); - editor.execute("tab.new"); - editor.execute("tab.force_close"); - assert_eq!(editor.tab_count(), 1); - assert!(editor.has_buffer(1)); - assert!(editor.dirty()); - editor.execute("tab.new"); - editor.execute(&format!( - "edit {}", - directory.path("exclusive.txt").display() - )); - let exclusive = editor.buffer_id(); - editor - .apply(vec![Effect::Insert("exclusive".into())]) - .unwrap(); - editor.execute("pane.split_horizontal"); - editor.execute("tab.close"); - assert_eq!(editor.tab_count(), 2); - assert!(editor.message().contains("tab.force_close")); - editor.execute("pane.close"); - assert_eq!(editor.pane_layout().len(), 1); - editor.execute("pane.close"); - assert_eq!(editor.tab_count(), 2); - editor.execute("pane.force_close"); - assert_eq!(editor.tab_count(), 1); - assert!(editor.has_buffer(1)); - assert!(!editor.has_buffer(exclusive)); - assert_eq!(editor.lines(), ["shared"]); - editor.execute("tab.close"); - assert!(!editor.should_quit()); - editor.execute("tab.force_close"); - assert!(editor.should_quit()); - } - - #[test] - fn closing_tab_checks_dirty_diff_return_buffer_even_when_live_buffer_is_shared() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "base").unwrap(); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - editor.execute("tab.new"); - editor.execute(&format!("edit {}", directory.path("return.txt").display())); - let return_buffer = editor.buffer_id(); - editor - .apply(vec![Effect::Insert("dirty return".into())]) - .unwrap(); - working_diff(&mut editor, &directory.0, &["file.txt"]); - editor.diff.as_mut().unwrap().focus = Focus::Before; - editor.bind_diff_buffer(); - assert!(!editor.dirty()); - editor.execute("tab.close"); - assert_eq!(editor.tab_count(), 2); - assert!(editor.message().contains("unsaved") || editor.message().contains("Unsaved")); - editor.execute("tab.force_close"); - assert_eq!(editor.tab_count(), 1); - assert!(editor.has_buffer(1)); - assert!(!editor.has_buffer(return_buffer)); - assert_eq!(editor.lines(), ["base"]); - } - - #[test] - fn tab_modifiers_aliases_catalog_and_palette_follow_pane_conventions() { - for key in ["ctrl+alt+h", "CTRL+Option+H", "alt+ctrl+h", "option+ctrl+h"] { - assert_eq!(parse_key(key).unwrap(), Input::CtrlAlt('h')); - } - for key in [ - "ctrl+shift+h", - "ctrl+alt+shift+h", - "alt+alt+h", - "ctrl+alt+left", - ] { - assert!(parse_key(key).is_err(), "{key}"); - } - assert_eq!(display_key(&Input::CtrlAlt('h')), "Ctrl+Alt+h"); - let mut editor = editor(); - for command in [ - "tab.new", - "tab.previous", - "tab.next", - "tab.close", - "tab.force_close", - ] { - assert!(core_command(command).is_some()); - assert!(Action::named(Mode::Normal, &format!("core.{command}")).is_some()); - assert!(Action::named(Mode::Insert, &format!("core.{command}")).is_none()); - } - editor.execute("tab.new extra"); - assert_eq!(editor.tab_count(), 1); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core.tab.new"); - editor.handle(Input::Enter); - assert_eq!(editor.tab_count(), 2); - editor.handle(Input::Char('i')); - editor.handle(Input::CtrlAlt('h')); - assert_eq!(editor.tab_index(), 1); - editor.handle(Input::Escape); - editor.bind("normal", "option+ctrl+h", None).unwrap(); - editor.handle(Input::CtrlAlt('h')); - assert_eq!(editor.tab_index(), 1); - editor - .bind("normal", "ctrl+alt+h", Some("core.tab.previous".into())) - .unwrap(); - editor.handle(Input::CtrlAlt('h')); - assert_eq!(editor.tab_index(), 0); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core.tab.force_close"); - editor.handle(Input::Enter); - assert_eq!(editor.command_line(), "tab.force_close "); - assert_eq!(editor.tab_count(), 2); - editor.handle(Input::Enter); - assert_eq!(editor.tab_count(), 1); - } - - #[test] - fn hidden_tab_diff_results_restore_visible_insert_interaction_and_route_request_ids() { - use crate::diff::{ - Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, - }; - let mut editor = editor(); - editor.execute("diff"); - let first_tab = editor.tab_id(); - let (first_request, _) = editor.take_diff_request().unwrap(); - editor.execute("pane.split_vertical"); - let hidden_focused_pane = editor.pane_id(); - editor.execute("tab.new"); - editor.execute("diff"); - let second_tab = editor.tab_id(); - let (second_request, _) = editor.take_diff_request().unwrap(); - assert!(second_request > first_request); - editor.execute("tab.new"); - let visible_tab = editor.tab_id(); - let visible_pane = editor.pane_id(); - type_text(&mut editor, "iX"); - let cursor = editor.cursor(); - editor.set_message("visible"); - let catalog = || { - Outcome::Catalog(Catalog { - kind: RepoKind::Git, - entries: [ - RevisionTarget::Commit("base".into()), - RevisionTarget::Working, - ] - .into_iter() - .map(|target| Revision { - target, - label: "Revision".into(), - description: String::new(), - category: Category::Commits, - }) - .collect(), - notice: None, - }) - }; - assert!(editor.apply_diff_result(second_request, Ok(catalog()))); - assert!(editor.apply_diff_result(first_request, Ok(catalog()))); - assert!(!editor.apply_diff_result(first_request, Ok(catalog()))); - assert_eq!(editor.tab_id(), visible_tab); - assert_eq!(editor.pane_id(), visible_pane); - assert_eq!(editor.cursor(), cursor); - assert_eq!(editor.mode(), Mode::Insert); - assert_eq!(editor.message(), "visible"); - assert_eq!(editor.lines(), ["X"]); - assert!(editor.diff().is_none()); - assert!(!editor.tab_diff(first_tab).unwrap().loading); - assert!(!editor.tab_diff(second_tab).unwrap().loading); - let owner = editor.tabs[&first_tab].diff_owner.unwrap(); - let (comparison_request, _) = editor.with_pane(owner, |editor| { - editor.handle(Input::Enter); - editor.handle(Input::Down); - editor.handle(Input::Enter); - editor.take_diff_request().unwrap() - }); - let before = vec!["base".to_owned()]; - let after = vec!["changed".to_owned()]; - let hunks = crate::diff::line_hunks(&before, false, &after, false); - assert!(editor.apply_diff_result( - comparison_request, - Ok(Outcome::Comparison(Comparison { - root: editor.project_root().to_owned(), - revisions: ["base".into(), "working-snapshot".into()], - before: "base".into(), - after: "working".into(), - editable_paths: Default::default(), - files: vec![ChangedFile { - loaded: true, - path: "file.txt".into(), - old_path: None, - status: FileStatus::Modified, - before, - after, - before_eof: false, - after_eof: false, - hunks, - notice: None, - }], - })) - )); - assert_eq!(editor.tab_id(), visible_tab); - assert_eq!(editor.pane_id(), visible_pane); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.cursor(), cursor); - assert_eq!(editor.mode(), Mode::Insert); - assert_eq!(editor.message(), "visible"); - assert_ne!(editor.pane_buffer_id(owner), Some(1)); - assert_eq!(editor.tabs[&first_tab].pane_id, hidden_focused_pane); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), [""]); - } - - #[test] - fn tab_snapshots_remain_readonly_and_other_comparisons_survive_release() { - let directory = Directory::new(); - let mut editor = editor(); - historical_diff( - &mut editor, - &directory.0, - vec!["base".into()], - vec!["first".into()], - ); - let first_tab = editor.tab_id(); - let first_snapshot = editor.buffer_id(); - let first_comparison = editor.diff().unwrap().comparison_id; - editor.execute("tab.new"); - assert_eq!(editor.buffer_id(), first_snapshot); - assert!(editor.diff().is_none()); - assert!( - editor - .apply(vec![Effect::Insert("rejected".into())]) - .is_err() - ); - editor.execute("buffer 1"); - historical_diff( - &mut editor, - &directory.0, - vec!["base".into()], - vec!["second".into()], - ); - let second_snapshot = editor.buffer_id(); - assert_ne!(editor.diff().unwrap().comparison_id, first_comparison); - editor.execute("tab.new"); - assert_eq!(editor.buffer_id(), second_snapshot); - editor.execute("tab.previous"); - editor.execute("diff.close"); - assert!(editor.tab_diff(first_tab).is_some()); - assert!(editor.has_buffer(first_snapshot)); - assert!(editor.has_buffer(second_snapshot)); - editor.execute("tab.next"); - assert_eq!(editor.buffer_id(), second_snapshot); - assert!(editor.mutation_blocked()); - editor.execute("tab.next"); - assert_eq!(editor.buffer_id(), first_snapshot); - assert_eq!(editor.diff().unwrap().comparison_id, first_comparison); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); - } - - #[test] - fn hidden_tab_comparisons_refresh_shared_edits_and_deletion_closes_all_owners() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "base").unwrap(); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - working_diff(&mut editor, &directory.0, &["file.txt"]); - editor.diff.as_mut().unwrap().focus = Focus::After; - editor.bind_diff_buffer(); - let first_tab = editor.tab_id(); - editor.execute("tab.new"); - working_diff(&mut editor, &directory.0, &["file.txt"]); - editor.diff.as_mut().unwrap().focus = Focus::After; - editor.bind_diff_buffer(); - let second_tab = editor.tab_id(); - editor.execute("tab.new"); - editor - .apply(vec![Effect::Insert("changed".into())]) - .unwrap(); - let revision = editor.text_revision(); - assert_eq!( - editor.tab_diff(first_tab).unwrap().live_revision, - Some(revision) - ); - assert_eq!( - editor.tab_diff(second_tab).unwrap().live_revision, - Some(revision) - ); - editor.execute("tab.close"); - assert_eq!(editor.tab_count(), 2); - assert!(editor.has_buffer(1)); - editor.execute("bdelete! 1"); - assert!(!editor.has_buffer(1)); - assert!(editor.tab_diff(first_tab).is_none()); - assert!(editor.tab_diff(second_tab).is_none()); - editor.execute("tab.next"); - assert!(editor.diff().is_none()); - assert!(editor.has_buffer(editor.buffer_id())); - } - - #[test] - fn pane_views_survive_buffer_switches_and_deletion_replaces_every_reference() { - let directory = Directory::new(); - let path = directory.path("other.txt"); - fs::write(&path, "other").unwrap(); - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abcdef".into())]) - .unwrap(); - editor.handle(Input::Char('|')); - set_carets(&mut editor, &[(0, 4)], 0); - editor.execute(&format!("edit {}", path.display())); - let other = editor.buffer_id(); - set_carets(&mut editor, &[(0, 2)], 0); - editor.execute("buffer 1"); - assert_eq!(editor.cursor().column, 4); - editor.execute("pane.focus_left"); - assert_eq!(editor.cursor().column, 0); - editor.execute(&format!("buffer {other}")); - assert_eq!(editor.cursor().column, 0); - editor.execute("pane.focus_right"); - editor.execute(&format!("buffer {other}")); - assert_eq!(editor.cursor().column, 2); - editor.execute(&format!("bdelete {other}")); - assert!(!editor.has_buffer(other)); - assert_eq!(editor.pane_buffer_id(1), Some(1)); - assert_eq!(editor.pane_buffer_id(2), Some(1)); - editor.with_render_pane(1, |pane| assert_eq!(pane.buffer_id(), 1)); - } - - #[test] - fn pane_close_protects_exclusive_and_hidden_dirty_buffers_but_retains_shared_edits() { - for force in [false, true] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("unsaved".into())]) - .unwrap(); - editor.handle(Input::Char('|')); - editor.execute(if force { - "pane.force_close" - } else { - "pane.close" - }); - assert_eq!(editor.pane_layout().len(), 1); - assert_eq!(editor.lines(), ["unsaved"]); - assert!(editor.dirty()); - editor.execute("pane.close"); - assert!(!editor.should_quit()); - editor.execute("pane.force_close"); - assert!(editor.should_quit()); - } - let directory = Directory::new(); - let path = directory.path("clean.txt"); - fs::write(&path, "clean").unwrap(); - let mut editor = editor(); - editor.handle(Input::Char('|')); - editor.execute(&format!("edit {}", path.display())); - let exclusive = editor.buffer_id(); - editor - .apply(vec![Effect::Insert("changed".into())]) - .unwrap(); - editor.execute("pane.close"); - assert_eq!(editor.pane_layout().len(), 2); - assert!(editor.message().contains("pane.force_close")); - editor.execute("pane.force_close"); - assert_eq!(editor.pane_layout().len(), 1); - assert!(!editor.has_buffer(exclusive)); - editor - .apply(vec![Effect::Insert("hidden dirty".into())]) - .unwrap(); - editor.execute(&format!("edit {}", path.display())); - editor.execute("pane.close"); - assert!(!editor.should_quit()); - editor.execute("pane.force_close"); - assert!(editor.should_quit()); - } - - fn close_pane_using(editor: &mut Editor, binding: Option<&str>, force: bool) { - let command = if force { - "pane.force_close" - } else { - "pane.close" - }; - if let Some(binding) = binding { - editor - .bind("normal", binding, Some(format!("core.{command}"))) - .unwrap(); - for key in binding.split_whitespace() { - editor.handle(parse_key(key).unwrap()); - } - } else { - editor.execute(command); - } - } - - #[test] - fn pane_close_targets_focused_docked_lists_without_closing_dirty_documents_or_tabs() { - let directory = Directory::new(); - fs::create_dir(directory.path("dir")).unwrap(); - let path = directory.path("dir/file.txt"); - fs::write(&path, "base").unwrap(); - for comparison in [false, true] { - for another_tab in [false, true] { - for force in [false, true] { - for binding in [ - None, - Some("ctrl+x"), - Some("alt+x"), - Some("ctrl+alt+x"), - Some("delete"), - Some("ctrl+w c"), - ] { - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.project_root = directory.0.clone(); - if another_tab { - editor.execute("tab.new"); - } - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - if comparison { - working_diff(&mut editor, &directory.0, &["dir/file.txt"]); - editor.handle(Input::Char('\t')); - editor.handle(Input::Char('\t')); - assert_eq!(editor.diff().unwrap().focus, Focus::After); - } - let owner = (editor.tab_id(), editor.pane_id(), editor.buffer_id()); - let tabs = editor.tab_count(); - let buffers = editor.buffer_count(); - let revision = editor.text_revision(); - let targets = editor.active.view.targets.clone(); - let history = (editor.active.undo.len(), editor.active.redo.len()); - let comparison_id = editor.diff().and_then(|diff| diff.comparison_id); - editor.act(Action::FileExplorer); - type_text(&mut editor, "ifile"); - editor.handle(Input::Escape); - let selected = editor.file_picker().unwrap().selected; - let rows = editor - .file_picker() - .unwrap() - .entries() - .map(|row| (row.path.to_owned(), row.expanded)) - .collect::>(); - - close_pane_using(&mut editor, binding, force); - - assert!(!editor.should_quit()); - assert_eq!(editor.sequence_pending(), None); - assert!(editor.file_picker().is_none()); - assert_eq!( - (editor.tab_id(), editor.pane_id(), editor.buffer_id()), - owner - ); - assert_eq!(editor.pane_layout().len(), 1); - assert_eq!(editor.tab_count(), tabs); - assert_eq!(editor.buffer_count(), buffers); - assert_eq!(editor.lines(), ["Xbase"]); - assert!(editor.dirty()); - assert_eq!(editor.text_revision(), revision); - assert_eq!(editor.active.view.targets, targets); - assert_eq!( - (editor.active.undo.len(), editor.active.redo.len()), - history - ); - if comparison { - let diff = editor.diff().unwrap(); - assert_eq!(diff.comparison_id, comparison_id); - assert_eq!(diff.focus, Focus::After); - assert!(!diff.files_visible); - assert!(!diff.files.as_ref().unwrap().focused); - editor.act(Action::FileExplorer); - let picker = editor.file_picker().unwrap(); - assert_eq!(picker.query, "file"); - assert_eq!(picker.selected, selected); - assert_eq!( - picker - .entries() - .map(|row| (row.path.to_owned(), row.expanded)) - .collect::>(), - rows - ); - editor.handle(Input::Ctrl('w')); - if binding == Some("ctrl+w c") { - assert!(editor.file_picker().is_some()); - assert!(editor.sequence_pending().is_some()); - editor.handle(Input::Char('c')); - } - assert!(editor.file_picker().is_none()); - assert_eq!(editor.diff().unwrap().focus, Focus::After); - } else { - editor.act(Action::FileExplorer); - assert_eq!(editor.file_picker().unwrap().query, ""); - editor.handle(Input::Ctrl('w')); - if binding == Some("ctrl+w c") { - assert!(editor.file_picker().is_some()); - assert!(editor.sequence_pending().is_some()); - editor.handle(Input::Char('c')); - } - assert!(editor.file_picker().is_none()); - } - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), ["base"]); - } - } - } - } - } - - #[test] - fn pane_close_does_not_target_unfocused_docked_lists() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "base").unwrap(); - for comparison in [false, true] { - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.project_root = directory.0.clone(); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - editor.execute("pane.split_vertical"); - if comparison { - working_diff(&mut editor, &directory.0, &["file.txt"]); - } - editor.act(Action::FileExplorer); - editor.handle(Input::Escape); - assert!(!editor.file_picker().unwrap().focused); - editor.execute("pane.close"); - assert_eq!(editor.pane_layout().len(), 1); - assert_eq!(editor.pane_id(), 1); - assert!(!editor.should_quit()); - assert_eq!(editor.lines(), ["Xbase"]); - assert!(editor.dirty()); - assert!(editor.diff().is_none()); - if comparison { - assert!(editor.file_picker().is_none()); - } else { - assert!(!editor.file_picker().unwrap().focused); - } - } - } - - #[test] - fn floating_pane_close_closes_underlying_panes_and_retains_shared_dirty_buffers() { - let directory = Directory::new(); - let base = directory.path("base.txt"); - let other = directory.path("other.txt"); - fs::write(&base, "base").unwrap(); - fs::write(&other, "other").unwrap(); - for comparison in [false, true] { - for shared in [false, true] { - for force in [false, true] { - let mut editor = Editor::open(Some(base.clone()), vec![]).unwrap(); - editor.project_root = directory.0.clone(); - if shared { - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - } - editor.execute("pane.split_vertical"); - if !shared { - editor.execute(&format!("edit {}", other.display())); - } - let source = editor.buffer_id(); - if comparison { - let file = if shared { "base.txt" } else { "other.txt" }; - working_diff(&mut editor, &directory.0, &[file]); - editor.handle(Input::Char('\t')); - editor.handle(Input::Char('\t')); - } - editor.act(Action::FilePicker); - type_text(&mut editor, "txt"); - close_pane_using(&mut editor, Some("alt+x"), force); - assert_eq!(editor.pane_layout().len(), 1); - assert_eq!(editor.pane_id(), 1); - assert!(!editor.should_quit()); - assert_eq!(editor.has_buffer(source), shared); - assert_eq!(editor.lines(), [if shared { "Xbase" } else { "base" }]); - assert_eq!(editor.dirty(), shared); - assert!(editor.diff().is_none()); - if comparison { - assert!(editor.file_picker().is_none()); - } else { - let picker = editor.file_picker().unwrap(); - assert!(picker.focused); - assert_eq!(picker.presentation, FilePresentation::Floating); - assert_eq!(picker.query, "txt"); - } - } - } - } - } - - #[test] - fn floating_pane_close_refuses_dirty_loss_then_force_closes_without_dismissing_first() { - let directory = Directory::new(); - let base = directory.path("base.txt"); - let other = directory.path("other.txt"); - fs::write(&base, "base").unwrap(); - fs::write(&other, "other").unwrap(); - for comparison in [false, true] { - for binding in [ - None, - Some("ctrl+x"), - Some("alt+x"), - Some("ctrl+alt+x"), - Some("delete"), - Some("ctrl+w c"), - ] { - let mut editor = Editor::open(Some(base.clone()), vec![]).unwrap(); - editor.project_root = directory.0.clone(); - editor.execute("pane.split_vertical"); - editor.execute(&format!("edit {}", other.display())); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - let source = editor.buffer_id(); - if comparison { - working_diff(&mut editor, &directory.0, &["other.txt"]); - editor.handle(Input::Char('\t')); - editor.handle(Input::Char('\t')); - } - let comparison_id = editor.diff().and_then(|diff| diff.comparison_id); - let revision = editor.text_revision(); - let targets = editor.active.view.targets.clone(); - let history = (editor.active.undo.len(), editor.active.redo.len()); - editor.act(Action::FilePicker); - type_text(&mut editor, "other"); - let selected = editor.file_picker().unwrap().selected; - - close_pane_using(&mut editor, binding, false); - - assert_eq!(editor.pane_layout().len(), 2); - assert_eq!(editor.pane_id(), 2); - assert_eq!(editor.buffer_id(), source); - assert!(!editor.should_quit()); - assert_eq!(editor.sequence_pending(), None); - assert_eq!(editor.lines(), ["Xother"]); - assert!(editor.dirty()); - assert_eq!(editor.text_revision(), revision); - assert_eq!(editor.active.view.targets, targets); - assert_eq!( - (editor.active.undo.len(), editor.active.redo.len()), - history - ); - let picker = editor.file_picker().unwrap(); - assert!(picker.focused); - assert!(picker.searching); - assert_eq!(picker.presentation, FilePresentation::Floating); - assert_eq!(picker.query, "other"); - assert_eq!(picker.selected, selected); - assert!(editor.message().contains("pane.force_close")); - assert_eq!( - editor.diff().and_then(|diff| diff.comparison_id), - comparison_id - ); - - close_pane_using(&mut editor, binding, true); - - assert_eq!(editor.pane_layout().len(), 1); - assert_eq!(editor.pane_id(), 1); - assert!(!editor.has_buffer(source)); - assert_eq!(editor.lines(), ["base"]); - assert!(!editor.should_quit()); - assert!(editor.diff().is_none()); - assert_eq!(editor.file_picker().is_some(), !comparison); - } - } - } - - #[test] - fn floating_pane_close_reaches_tab_and_last_pane_guards() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "base").unwrap(); - for another_tab in [false, true] { - for dirty in [false, true] { - let mut editor = project_editor(&directory); - if another_tab { - editor.execute("tab.new"); - } - editor.execute(&format!("edit {}", path.display())); - let source = editor.buffer_id(); - let tab = editor.tab_id(); - if dirty { - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - } - editor.act(Action::FilePicker); - type_text(&mut editor, "file"); - close_pane_using(&mut editor, Some("ctrl+x"), false); - if dirty { - assert_eq!(editor.tab_id(), tab); - assert_eq!(editor.buffer_id(), source); - assert!(editor.has_buffer(source)); - assert!(!editor.should_quit()); - assert!(editor.file_picker().unwrap().focused); - assert_eq!(editor.file_picker().unwrap().query, "file"); - close_pane_using(&mut editor, Some("ctrl+x"), true); - } - assert_eq!(editor.should_quit(), !another_tab); - assert_eq!(editor.tab_count(), 1); - if another_tab { - assert_ne!(editor.tab_id(), tab); - assert!(!editor.has_buffer(source)); - assert!(editor.file_picker().is_none()); - } else { - assert!(editor.file_picker().unwrap().focused); - } - } - } - } - - #[test] - fn pane_close_bindings_preserve_picker_filter_and_escape_priority() { - let directory = Directory::new(); - fs::write(directory.path("xz.txt"), "base").unwrap(); - for comparison in [false, true] { - for presentation in [FilePresentation::Floating, FilePresentation::Docked] { - for view in ["tree", "list"] { - let mut editor = project_editor(&directory); - editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); - if comparison { - historical_diff( - &mut editor, - &directory.0, - vec!["before".into()], - vec!["after".into()], - ); - } - editor - .bind("normal", "x", Some("core.pane.close".into())) - .unwrap(); - editor - .bind("normal", "z", Some("core.pane.force_close".into())) - .unwrap(); - editor.set_file_picker_view(view).unwrap(); - editor.set_file_explorer_view(view).unwrap(); - editor.act(match presentation { - FilePresentation::Floating => Action::FilePicker, - FilePresentation::Docked => Action::FileExplorer, - }); - if presentation == FilePresentation::Docked && view == "tree" { - editor.handle(Input::Char('i')); - } - let text = editor.lines().to_vec(); - type_text(&mut editor, "xz"); - assert_eq!(editor.file_picker().unwrap().query, "xz"); - assert_eq!(editor.pane_layout().len(), 1); - assert_eq!(editor.tab_count(), 1); - assert!(!editor.should_quit()); - assert_eq!(editor.lines(), text); - editor.handle(Input::Escape); - if view == "tree" { - assert!(editor.file_picker().unwrap().focused); - assert!(!editor.file_picker().unwrap().searching); - editor.handle(Input::Escape); - } - match presentation { - FilePresentation::Floating => assert!(editor.file_picker().is_none()), - FilePresentation::Docked => { - assert!(!editor.file_picker().unwrap().focused); - } - } - assert!(editor.has_buffer(1)); - assert!(!editor.should_quit()); - } - } - } - } - - #[test] - fn pane_close_bindings_yield_to_ordinary_tree_and_selection_overrides() { - let directory = Directory::new(); - fs::write(directory.path("a.txt"), "a").unwrap(); - fs::write(directory.path("b.txt"), "b").unwrap(); - for presentation in [FilePresentation::Floating, FilePresentation::Docked] { - for context in ["tree", "selection"] { - for target in [None, Some("core.down")] { - for force in [false, true] { - let mut editor = project_editor(&directory); - editor - .bind( - "normal", - "ctrl+x", - Some(if force { - "core.pane.force_close".into() - } else { - "core.pane.close".into() - }), - ) - .unwrap(); - editor - .bind(context, "ctrl+x", target.map(str::to_owned)) - .unwrap(); - editor.act(match presentation { - FilePresentation::Floating => Action::FilePicker, - FilePresentation::Docked => Action::FileExplorer, - }); - editor.handle(Input::Ctrl('x')); - let picker = editor.file_picker().unwrap(); - assert!(picker.focused); - assert_eq!(picker.selected, usize::from(target.is_some())); - assert_eq!(editor.pane_layout().len(), 1); - assert!(!editor.should_quit()); - } - } - } - } - } - - #[test] - fn render_swaps_restore_focus_on_panics_and_isolate_caret_reveal_requests() { - let mut editor = editor(); - editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); - editor.handle(Input::Char('|')); - let inactive_revision = editor.with_render_pane(1, Editor::caret_reveal_revision); - let inactive_cursor = editor.with_render_pane(1, Editor::cursor); - editor.handle(Input::Home); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - assert_eq!( - editor.with_render_pane(1, Editor::caret_reveal_revision), - inactive_revision - ); - assert_eq!( - editor.with_render_pane(1, Editor::cursor).column, - inactive_cursor.column + 1 - ); - let cursor = editor.cursor(); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - editor.with_render_pane(1, |_| panic!("render failed")); - })); - assert!(result.is_err()); - assert_eq!(editor.pane_id(), 2); - assert_eq!(editor.cursor(), cursor); - assert!(editor.render_pane_focused()); - } - - #[test] - fn diff_panes_share_working_edits_and_focus_internal_regions_before_workspace() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "base\n").unwrap(); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - working_diff(&mut editor, &directory.0, &["file.txt"]); - editor.diff.as_mut().unwrap().focus = Focus::After; - editor.bind_diff_buffer(); - let working = editor.buffer_id(); - editor.handle(Input::Char('|')); - assert!(editor.diff().is_none()); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - assert_eq!( - editor.workspace_diff().unwrap().live_revision, - Some(editor.text_revision()) - ); - editor.set_diff_pane_bounds([ - ( - Focus::Files, - PaneRect { - x: 0, - y: 0, - width: 0, - height: 0, - }, - ), - ( - Focus::Before, - PaneRect { - x: 0, - y: 0, - width: 30, - height: 40, - }, - ), - ( - Focus::After, - PaneRect { - x: 30, - y: 0, - width: 30, - height: 40, - }, - ), - ]); - editor.handle(Input::Alt('h')); - assert_eq!(editor.pane_id(), 1); - assert_eq!(editor.diff().unwrap().focus, Focus::After); - editor.handle(Input::Alt('h')); - assert_eq!(editor.pane_id(), 1); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); - let snapshot = editor.buffer_id(); - editor.execute("pane.split_horizontal"); - assert_eq!(editor.buffer_id(), snapshot); - assert!(editor.diff().is_none()); - assert!( - editor - .apply(vec![Effect::Insert("rejected".into())]) - .is_err() - ); - editor.execute("diff.close"); - assert!(editor.has_buffer(snapshot)); - assert!(editor.has_buffer(working)); - assert_eq!(editor.pane_id(), 3); - assert_eq!(editor.buffer_id(), snapshot); - editor.with_render_pane(3, |pane| { - assert_eq!(pane.buffer_id(), snapshot); - assert!(pane.mutation_blocked()); - assert_eq!(pane.lines(), ["base", ""]); - }); - editor.execute("pane.close"); - assert!(!editor.has_buffer(snapshot)); - assert!(editor.has_buffer(working)); - assert_eq!(editor.pane_layout().len(), 2); - } - - #[test] - fn diff_results_bind_the_owning_pane_without_disturbing_focused_document_or_mode() { - use crate::diff::{Catalog, Category, RepoKind, Revision}; - let directory = Directory::new(); - let path = directory.path("other.txt"); - fs::write(&path, "other").unwrap(); - let mut editor = editor(); - editor.execute("diff"); - editor.execute("pane.split_vertical"); - editor.execute(&format!("edit {}", path.display())); - type_text(&mut editor, "iX"); - let focused_buffer = editor.buffer_id(); - let focused_cursor = editor.cursor(); - let (id, _) = editor.take_diff_request().unwrap(); - assert!(editor.apply_diff_result( - id, - Ok(Outcome::Catalog(Catalog { - kind: RepoKind::Git, - entries: vec![Revision { - target: RevisionTarget::Working, - label: "Working".into(), - description: String::new(), - category: Category::Commits, - }], - notice: None, - })) - )); - assert_eq!(editor.pane_id(), 2); - assert_eq!(editor.buffer_id(), focused_buffer); - assert_eq!(editor.cursor(), focused_cursor); - assert_eq!(editor.mode(), Mode::Insert); - assert_eq!(editor.lines(), ["Xother"]); - assert!(editor.diff().is_none()); - editor.with_render_pane(1, |pane| assert!(pane.diff().is_some())); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), ["other"]); - } - - #[test] - fn destructive_pane_close_from_palette_requires_command_confirmation() { - let mut editor = editor(); - editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core.pane.force_close"); - editor.handle(Input::Enter); - assert_eq!(editor.mode(), Mode::Command); - assert!(!editor.should_quit()); - assert_eq!(editor.command_line(), "pane.force_close "); - editor.handle(Input::Enter); - assert!(editor.should_quit()); - } - - #[test] - fn palette_pane_close_keeps_focused_dock_target_through_force_confirmation() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "base").unwrap(); - for comparison in [false, true] { - for force in [false, true] { - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.project_root = directory.0.clone(); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - let source = editor.buffer_id(); - let revision = editor.text_revision(); - let history = (editor.active.undo.len(), editor.active.redo.len()); - if comparison { - working_diff(&mut editor, &directory.0, &["file.txt"]); - } - let owner = (editor.tab_id(), editor.pane_id()); - let comparison_id = editor.diff().and_then(|diff| diff.comparison_id); - if !editor.file_picker().is_some_and(|picker| picker.focused) { - editor.act(Action::FileExplorer); - } - editor.handle(Input::Ctrl('p')); - assert!(editor.pane_close_picker_origin.is_some()); - assert!(editor.palette().is_some()); - assert!(!editor.file_picker().unwrap().focused); - let buffers = editor.buffer_count(); - type_text( - &mut editor, - if force { - "core.pane.force_close" - } else { - "core.pane.close" - }, - ); - editor.handle(Input::Enter); - if force { - assert_eq!(editor.mode(), Mode::Command); - assert_eq!(editor.command_line(), "pane.force_close "); - assert!(editor.file_picker().is_some()); - assert!(editor.pane_close_picker_origin.is_some()); - assert!(!editor.should_quit()); - editor.handle(Input::Char(' ')); - editor.handle(Input::Backspace); - editor.handle(Input::Backspace); - assert!(editor.pane_close_picker_origin.is_some()); - editor.handle(Input::Enter); - } - assert_eq!((editor.tab_id(), editor.pane_id()), owner); - assert_eq!(editor.tab_count(), 1); - assert_eq!(editor.pane_layout().len(), 1); - assert_eq!(editor.buffer_count(), buffers); - assert!(!editor.should_quit()); - assert!(editor.palette().is_none()); - assert!(editor.file_picker().is_none()); - assert_eq!(editor.pane_close_picker_origin, None); - let source = editor.buffer(source).unwrap(); - assert_eq!(source.lines, ["Xbase"]); - assert!(source.dirty()); - assert_eq!(source.text_revision, revision); - assert_eq!((source.undo.len(), source.redo.len()), history); - assert_eq!( - editor.diff().and_then(|diff| diff.comparison_id), - comparison_id - ); - } - } - } - - #[test] - fn palette_pane_close_origin_clears_on_cancel_edit_or_unrelated_action() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "base").unwrap(); - for comparison in [false, true] { - for cancellation in [ - "escape", - "toggle", - "unrelated", - "force_escape", - "force_other_command", - "invalid_args", - ] { - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.project_root = directory.0.clone(); - editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); - if comparison { - working_diff(&mut editor, &directory.0, &["file.txt"]); - } - if !editor.file_picker().is_some_and(|picker| picker.focused) { - editor.act(Action::FileExplorer); - } - editor.handle(Input::Ctrl('p')); - assert!(editor.pane_close_picker_origin.is_some()); - assert!(editor.palette().is_some()); - match cancellation { - "escape" => { - editor.handle(Input::Escape); - } - "toggle" => { - editor.handle(Input::Ctrl('p')); - } - "unrelated" => { - type_text(&mut editor, "core.word_wrap.toggle"); - editor.handle(Input::Enter); - } - "force_escape" | "force_other_command" => { - type_text(&mut editor, "core.pane.force_close"); - editor.handle(Input::Enter); - assert!(editor.pane_close_picker_origin.is_some()); - if cancellation == "force_escape" { - editor.handle(Input::Escape); - } else { - for _ in 0..editor.command_line().chars().count() { - editor.handle(Input::Backspace); - } - type_text(&mut editor, "word_wrap.toggle"); - editor.handle(Input::Enter); - } - } - "invalid_args" => { - editor.execute("pane.close unexpected"); - } - _ => unreachable!(), - } - assert_eq!(editor.pane_close_picker_origin, None, "{cancellation}"); - assert!( - editor.file_picker().is_some(), - "comparison={comparison}, cancellation={cancellation}" - ); - assert!(!editor.should_quit()); - editor.execute("pane.force_close"); - assert!(editor.should_quit(), "{cancellation}"); - assert!( - editor.file_picker().is_some(), - "comparison={comparison}, cancellation={cancellation}" - ); - } - } - } - - #[test] - fn document_and_floating_palette_origins_never_intercept_pane_close() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "base").unwrap(); - for comparison in [false, true] { - for presentation in [FilePresentation::Docked, FilePresentation::Floating] { - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.project_root = directory.0.clone(); - editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); - let source = editor.buffer_id(); - if comparison { - working_diff(&mut editor, &directory.0, &["file.txt"]); - } - editor.act(match presentation { - FilePresentation::Docked => Action::FileExplorer, - FilePresentation::Floating => Action::FilePicker, - }); - if presentation == FilePresentation::Docked { - editor.handle(Input::Escape); - } - editor.handle(Input::Ctrl('p')); - assert_eq!(editor.pane_close_picker_origin, None); - assert!(editor.palette().is_some()); - type_text(&mut editor, "core.pane.close"); - editor.handle(Input::Enter); - assert!(!editor.should_quit()); - assert!(editor.palette().is_none()); - let source = editor.buffer(source).unwrap(); - assert_eq!(source.lines, ["dirtybase"]); - assert!(source.dirty()); - assert_eq!(editor.pane_layout().len(), 1); - assert_eq!( - editor.file_picker().is_some(), - presentation == FilePresentation::Docked - ); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core.pane.force_close"); - editor.handle(Input::Enter); - assert_eq!(editor.mode(), Mode::Command); - assert!(!editor.should_quit()); - editor.handle(Input::Enter); - assert!(editor.should_quit()); - } - } - } - - #[test] - fn palette_pane_close_origin_is_render_local_and_expires_on_owner_changes() { - let directory = Directory::new(); - fs::write(directory.path("file.txt"), "base").unwrap(); - for owner_change in ["none", "pane", "tab", "hidden", "floating"] { - let mut editor = project_editor(&directory); - editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); - editor.execute("pane.split_vertical"); - editor.act(Action::FileExplorer); - editor.handle(Input::Ctrl('p')); - let origin = editor.pane_close_picker_origin; - assert!(origin.is_some()); - editor.with_render_pane(1, |pane| { - assert!(pane.palette().is_none()); - assert_eq!(pane.pane_close_picker_origin, None); - }); - assert_eq!(editor.pane_close_picker_origin, origin); - match owner_change { - "none" => {} - "pane" => editor.select_pane(1), - "tab" => { - editor.execute("tab.new"); - } - "hidden" => editor.file_picker = None, - "floating" => { - editor.file_picker.as_mut().unwrap().presentation = FilePresentation::Floating; - } - _ => unreachable!(), - } - if owner_change == "none" { - type_text(&mut editor, "core.pane.close"); - editor.handle(Input::Enter); - assert_eq!(editor.pane_layout().len(), 2); - assert!(editor.file_picker().is_none()); - } else { - editor.execute("pane.force_close"); - assert_eq!( - editor.pane_layout().len(), - if owner_change == "tab" { 2 } else { 1 } - ); - assert_eq!(editor.pane_close_picker_origin, None); - assert_eq!(editor.lines(), ["dirty"]); - } - assert!(!editor.should_quit()); - } - } - - #[test] - fn palette_pane_close_rejects_a_replaced_comparison_origin() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "base").unwrap(); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); - let source = editor.buffer_id(); - working_diff(&mut editor, &directory.0, &["file.txt"]); - editor.handle(Input::Ctrl('p')); - assert!(editor.pane_close_picker_origin.is_some()); - assert!(editor.palette().is_some()); - let diff = editor.diff.as_mut().unwrap(); - diff.comparison_id = diff.comparison_id.map(|id| id + 1); - type_text(&mut editor, "core.pane.close"); - editor.handle(Input::Enter); - assert!(!editor.should_quit()); - assert!(editor.palette().is_none()); - assert_eq!(editor.pane_layout().len(), 1); - assert_eq!(editor.tab_count(), 1); - let source = editor.buffer(source).unwrap(); - assert_eq!(source.lines, ["dirtybase"]); - assert!(source.dirty()); - assert!(editor.file_picker().is_some()); - assert_eq!(editor.pane_close_picker_origin, None); - } - - #[test] - fn closing_comparison_checks_the_dirty_working_buffer_not_its_readonly_snapshot() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "base").unwrap(); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - editor.handle(Input::Char('|')); - editor.execute(&format!("edit {}", directory.path("other.txt").display())); - editor.execute("pane.focus_left"); - editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); - working_diff(&mut editor, &directory.0, &["file.txt"]); - editor.handle(Input::Char('\t')); - assert!(!editor.dirty()); - editor.execute("pane.close"); - assert_eq!(editor.pane_layout().len(), 2); - assert!(editor.message().contains("unsaved")); - editor.execute("pane.force_close"); - assert_eq!(editor.pane_layout().len(), 1); - assert!(!editor.has_buffer(1)); - } - - #[test] - fn diff_preserves_parked_buffers_visual_carets_and_insert_history() { - let directory = Directory::new(); - let a = directory.path("a.txt"); - let b = directory.path("b.txt"); - fs::write(&a, "alpha").unwrap(); - fs::write(&b, "beta").unwrap(); - let mut editor = Editor::open(Some(a), vec![]).unwrap(); - type_text(&mut editor, "iunsaved"); - editor.handle(Input::Escape); - editor.execute(&format!("edit {}", b.display())); - type_text(&mut editor, "iother"); - editor.handle(Input::Escape); - type_text(&mut editor, "vl"); - let carets = editor.carets(); - let selections = editor.selections(); - let documents = editor - .documents() - .map(|document| (document.id, document.revision, document.lines.to_vec())) - .collect::>(); - editor.execute("diff"); - for input in [ - Input::Paste("bad".into()), - Input::Delete, - Input::Char('d'), - Input::Enter, - ] { - editor.handle(input); - } - type_text(&mut editor, ":unknown\n"); - editor.handle(Input::Ctrl('p')); - editor.handle(Input::Escape); - assert!(editor.diff().is_some()); - editor.handle(Input::Escape); - assert!(editor.diff().is_none()); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.selections(), selections); - assert_eq!( - editor - .documents() - .map(|document| (document.id, document.revision, document.lines.to_vec())) - .collect::>(), - documents - ); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "beta"); - editor.execute("buffer 1"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "alpha"); - - editor.handle(Input::Char('i')); - type_text(&mut editor, "start"); - let caret = editor.cursor(); - editor.execute("diff"); - editor.handle(Input::Paste("ignored".into())); - type_text(&mut editor, ":unfinished"); - editor - .apply(vec![Effect::Message("message without edits".into())]) - .unwrap(); - editor.handle(Input::Escape); - assert!(editor.diff().is_none()); - assert_eq!(editor.mode(), Mode::Insert); - assert_eq!(editor.cursor(), caret); - type_text(&mut editor, "finish"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "alpha"); - } - - fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathBuf, String)]> { - use crate::diff::{ - Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, - }; - editor.execute("diff"); - let (id, _) = editor.take_diff_request().unwrap(); - let revision = |target, label: &str| Revision { - target, - label: label.into(), - description: String::new(), - category: Category::Commits, - }; - editor.apply_diff_result( - id, - Ok(Outcome::Catalog(Catalog { - kind: RepoKind::Git, - entries: vec![ - revision(RevisionTarget::Commit("base".into()), "base"), - revision(RevisionTarget::Working, "Working"), - ], - notice: None, - })), - ); - editor.handle(Input::Down); - editor.handle(Input::Enter); - editor.handle(Input::Enter); - let (id, operation) = editor.take_diff_request().unwrap(); - let Operation::Compare { buffers, .. } = operation else { - panic!("comparison request") - }; - let files = paths - .iter() - .map(|path| { - let text = fs::read_to_string(root.join(path)).unwrap(); - let lines: Vec = text.lines().map(str::to_owned).collect(); - ChangedFile { - loaded: true, - path: PathBuf::from(*path), - old_path: None, - status: FileStatus::Modified, - before: lines.clone(), - after: lines, - before_eof: text.ends_with('\n'), - after_eof: text.ends_with('\n'), - hunks: Vec::new(), - notice: None, - } - }) - .collect(); - assert!(editor.apply_diff_result( - id, - Ok(Outcome::Comparison(Comparison { - root: root.canonicalize().unwrap(), - revisions: ["base".into(), "working-snapshot".into()], - before: "base".into(), - after: "Working".into(), - editable_paths: paths.iter().map(|path| PathBuf::from(*path)).collect(), - files, - })) - )); - buffers - } - - fn historical_diff(editor: &mut Editor, root: &Path, before: Vec, after: Vec) { - use crate::diff::{ - Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, - }; - editor.execute("diff"); - let (id, _) = editor.take_diff_request().unwrap(); - let revision = |id: &str| Revision { - target: RevisionTarget::Commit(id.into()), - label: id.into(), - description: String::new(), - category: Category::Commits, - }; - editor.apply_diff_result( - id, - Ok(Outcome::Catalog(Catalog { - kind: RepoKind::Git, - entries: vec![revision("base"), revision("head")], - notice: None, - })), - ); - editor.handle(Input::Enter); - editor.handle(Input::Down); - editor.handle(Input::Enter); - let (id, _) = editor.take_diff_request().unwrap(); - let hunks = crate::diff::line_hunks(&before, false, &after, false); - editor.apply_diff_result( - id, - Ok(Outcome::Comparison(Comparison { - root: root.to_owned(), - revisions: ["base".into(), "head".into()], - before: "base".into(), - after: "head".into(), - editable_paths: Default::default(), - files: vec![ChangedFile { - loaded: true, - path: "file.txt".into(), - old_path: None, - status: FileStatus::Modified, - before, - after, - before_eof: false, - after_eof: false, - hunks, - notice: None, - }], - })), - ); - if editor.review_panel().is_some() { - return; - } - editor.handle(Input::Char('\t')); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); - assert!(editor.diff_interacting()); - } - - #[test] - fn sqlite_reviews_autosave_restore_select_and_keep_exports_independent() { - use crate::review::{Review, Store, Verdict}; - let directory = Directory::new(); - let root = directory.0.canonicalize().unwrap(); - let storage = root.join("data"); - let mut first = editor(); - first.set_review_directory(storage.clone()); - historical_diff( - &mut first, - &root, - vec!["p界e\u{301}z".into()], - vec!["changed".into()], - ); - type_text(&mut first, "lvl:diff.comment"); - first.handle(Input::Enter); - type_text(&mut first, "iFirst feedback"); - first.handle(Input::Escape); - assert!( - Store::open(&storage) - .unwrap() - .list(&root) - .unwrap() - .is_empty() - ); - first.handle(Input::Escape); - let id = first - .workspace_diff() - .unwrap() - .local_review - .document - .storage_id - .unwrap(); - let store = Store::open(&storage).unwrap(); - let persisted = store.load(&root, id).unwrap(); - assert_eq!(persisted.comments[0].body, "First feedback"); - assert_eq!( - persisted.comments[0].anchor.start, - TextPosition { row: 0, column: 1 } - ); - assert_eq!( - persisted.comments[0].anchor.end, - TextPosition { row: 0, column: 3 } - ); - first.execute("diff.review.summary"); - type_text(&mut first, "iSummary"); - first.handle(Input::Escape); - first.execute("diff.review.verdict changes_requested"); - let persisted = store.load(&root, id).unwrap(); - assert_eq!(persisted.summary, "Summary"); - assert_eq!(persisted.verdict, Some(Verdict::ChangesRequested)); - let exported = root.join("shared.json"); - first.execute(&format!("diff.review.save {}", exported.display())); - first.execute("diff.review.edit 1"); - type_text(&mut first, "A revised"); - first.handle(Input::Escape); - first.handle(Input::Escape); - assert_eq!( - store.load(&root, id).unwrap().comments[0].body, - "First feedback revised" - ); - assert_eq!( - Review::load(&exported, &root).unwrap().comments[0].body, - "First feedback" - ); - let mut reopened = editor(); - reopened.set_review_directory(storage.clone()); - historical_diff( - &mut reopened, - &root, - vec!["different revision".into()], - vec!["changed".into()], - ); - let restored = &reopened.workspace_diff().unwrap().local_review.document; - assert_eq!(restored.storage_id, Some(id)); - assert_eq!(restored.comments[0].body, "First feedback revised"); - assert!( - !reopened - .workspace_diff() - .unwrap() - .review_anchor_current(&restored.comments[0].anchor) - ); - reopened.execute("diff.review.new"); - let second_id = reopened - .workspace_diff() - .unwrap() - .local_review - .document - .storage_id - .unwrap(); - assert_ne!(id, second_id); - let mut choosing = editor(); - choosing.set_review_directory(storage); - historical_diff(&mut choosing, &root, vec!["old".into()], vec!["new".into()]); - assert_eq!( - choosing - .workspace_diff() - .unwrap() - .local_review - .choices - .iter() - .map(|(id, _)| *id) - .collect::>(), - std::collections::BTreeSet::from([id, second_id]) - ); - choosing.handle(Input::Enter); - assert_eq!( - choosing - .workspace_diff() - .unwrap() - .local_review - .document - .storage_id, - Some(second_id) - ); - choosing.execute(&format!("diff.review.open {id}")); - assert_eq!( - choosing - .workspace_diff() - .unwrap() - .local_review - .document - .storage_id, - Some(id) - ); - choosing.execute("diff.review.delete 1"); - assert!(store.load(&root, id).unwrap().comments.is_empty()); - choosing.execute(&format!("diff.review.open {}", exported.display())); - let imported_id = choosing - .workspace_diff() - .unwrap() - .local_review - .document - .storage_id - .unwrap(); - assert_ne!(imported_id, id); - assert_eq!( - store.load(&root, imported_id).unwrap().comments[0].body, - "First feedback" - ); - } - - #[test] - fn review_resolution_persists_without_hiding_comments_or_changing_stale_anchors() { - use crate::review::{Review, Store}; - let directory = Directory::new(); - let root = directory.0.canonicalize().unwrap(); - let storage = root.join("data"); - let mut first = editor(); - first.set_review_directory(storage.clone()); - historical_diff(&mut first, &root, vec!["old".into()], vec!["new".into()]); - first.execute("diff.comment"); - type_text(&mut first, "iFeedback"); - first.handle(Input::Escape); - assert!( - first - .review_panel_title() - .unwrap() - .contains(" · unresolved · ") - ); - first.execute("diff.review.resolve"); - assert!( - first - .context() - .text - .contains("Comment #1 · resolved · current") - ); - let store = Store::open(&storage).unwrap(); - let document = &first.workspace_diff().unwrap().local_review.document; - let id = document.storage_id.unwrap(); - let anchor = document.comments[0].anchor.clone(); - assert!(store.load(&root, id).unwrap().comments[0].resolved); - first.execute("diff.review.next"); - assert_eq!(first.context().text, "Feedback"); - assert!( - first - .review_panel_title() - .unwrap() - .contains(" · resolved · ") - ); - let export = root.join("resolved.json"); - first.execute(&format!("diff.review.save {}", export.display())); - assert!(Review::load(&export, &root).unwrap().comments[0].resolved); - let mut stale = editor(); - stale.set_review_directory(storage.clone()); - historical_diff( - &mut stale, - &root, - vec!["changed".into()], - vec!["new".into()], - ); - stale.execute("diff.review.overview"); - assert!( - stale - .context() - .text - .contains("Comment #1 · resolved · stale") - ); - first.execute("diff.review.unresolve 1"); - assert!(!store.load(&root, id).unwrap().comments[0].resolved); - stale.execute("diff.review.unresolve 1"); - assert!(stale.current_review_dirty()); - assert!( - !stale - .workspace_diff() - .unwrap() - .local_review - .document - .comments[0] - .resolved - ); - assert_eq!( - stale - .workspace_diff() - .unwrap() - .local_review - .document - .comments[0] - .anchor, - anchor - ); - assert!(stale.message().contains("changed in another session")); - first.execute("diff.review.resolve"); - assert!( - first - .context() - .text - .contains("Comment #1 · resolved · current") - ); - first.execute("diff.review.unresolve"); - assert!( - first - .context() - .text - .contains("Comment #1 · unresolved · current") - ); - first.execute(&format!("diff.review.open {}", export.display())); - let imported = &first.workspace_diff().unwrap().local_review.document; - assert_ne!(imported.storage_id, Some(id)); - assert!( - store - .load(&root, imported.storage_id.unwrap()) - .unwrap() - .comments[0] - .resolved - ); - } - - #[test] - fn sqlite_review_conflicts_and_write_failures_keep_dirty_drafts() { - use crate::review::Store; - let directory = Directory::new(); - let root = directory.0.canonicalize().unwrap(); - let storage = root.join("data"); - let mut first = editor(); - first.set_review_directory(storage.clone()); - historical_diff(&mut first, &root, vec!["old".into()], vec!["new".into()]); - first.execute("diff.comment"); - type_text(&mut first, "iStored feedback"); - first.handle(Input::Escape); - first.handle(Input::Escape); - let id = first - .workspace_diff() - .unwrap() - .local_review - .document - .storage_id - .unwrap(); - let mut stale = editor(); - stale.set_review_directory(storage.clone()); - historical_diff(&mut stale, &root, vec!["old".into()], vec!["new".into()]); - let version = stale - .workspace_diff() - .unwrap() - .local_review - .document - .storage_revision; - first.execute("diff.review.verdict looks_good"); - stale.execute("diff.review.verdict changes_requested"); - assert!(stale.current_review_dirty()); - assert_eq!( - stale - .workspace_diff() - .unwrap() - .local_review - .document - .storage_revision, - version - ); - assert_eq!( - Store::open(&storage) - .unwrap() - .load(&root, id) - .unwrap() - .verdict, - Some(crate::review::Verdict::LooksGood) - ); - let blocked = root.join("not-a-directory"); - fs::write(&blocked, "preserve").unwrap(); - first.set_review_directory(blocked.clone()); - first.execute("diff.review.edit 1"); - type_text(&mut first, "A unsaved"); - first.handle(Input::Escape); - first.handle(Input::Escape); - assert!(first.current_review_dirty()); - assert_eq!( - first - .workspace_diff() - .unwrap() - .local_review - .document - .comments[0] - .body, - "Stored feedback unsaved" - ); - assert_eq!(fs::read_to_string(blocked).unwrap(), "preserve"); - assert_eq!( - Store::open(&storage) - .unwrap() - .load(&root, id) - .unwrap() - .comments[0] - .body, - "Stored feedback" - ); - } - - #[test] - fn local_review_native_composition_roundtrip_and_draft_lifecycle() { - use crate::review::{Review, Side, Verdict}; - let directory = Directory::new(); - let root = directory.0.canonicalize().unwrap(); - let path = root.join("feedback.json"); - let mut editor = editor(); - historical_diff( - &mut editor, - &root, - vec!["before".into(), "removed".into()], - vec!["after".into(), "added".into()], - ); - let owner = editor.pane_id(); - let source = editor.context().text; - editor.execute("diff.comment"); - assert_ne!(editor.pane_id(), owner); - assert!( - editor - .review_panel_title() - .unwrap() - .starts_with("Comment #1") - ); - type_text(&mut editor, "iExplain the removed branch"); - editor.handle(Input::Escape); - editor.execute("diff.review.summary"); - type_text(&mut editor, "iPlease simplify the branch"); - editor.handle(Input::Escape); - editor.execute("diff.review.verdict changes_requested"); - assert!(editor.context().text.contains("Changes requested")); - editor.execute(&format!("diff.review.save {}", path.display())); - let review = Review::load(&path, &root).unwrap(); - assert_eq!(review.comments[0].body, "Explain the removed branch"); - assert_eq!(review.comments[0].anchor.side, Side::Before); - assert_eq!(review.comments[0].anchor.content, source); - assert_eq!(review.summary, "Please simplify the branch"); - assert_eq!(review.verdict, Some(Verdict::ChangesRequested)); - assert!(!editor.local_reviews_dirty()); - editor.execute("diff.review.edit 1"); - // Native undo applies to comment text, never to the reviewed source. - type_text(&mut editor, "A!"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "Explain the removed branch"); - editor.execute("w"); - editor.execute("diff.close"); - assert!(editor.workspace_diff().is_none()); - historical_diff( - &mut editor, - &root, - vec!["before".into(), "removed".into()], - vec!["after".into(), "added".into()], - ); - editor.execute(&format!("diff.review.open {}", path.display())); - assert!(editor.context().text.contains("Explain the removed branch")); - editor.execute("diff.review.next"); - assert_eq!(editor.context().text, "Explain the removed branch"); - editor.execute("diff.review.delete"); - assert!(editor.context().text.starts_with("# Local review")); - editor.execute("w"); - assert!(Review::load(&path, &root).unwrap().comments.is_empty()); - assert_eq!( - editor - .workspace_diff() - .unwrap() - .comparison - .as_ref() - .unwrap() - .files[0] - .before, - ["before", "removed"] - ); - } - - #[test] - fn precise_review_ranges_capture_graphemes_and_navigate_to_their_start() { - for focus in [Focus::Before, Focus::After] { - for keys in ["lvl", "llvh", "llvj"] { - let directory = Directory::new(); - let root = directory.0.canonicalize().unwrap(); - let mut editor = editor(); - historical_diff( - &mut editor, - &root, - vec!["p界e\u{301}z".into(), "before".into()], - vec!["q界e\u{301}z".into(), "after!".into()], - ); - editor.diff.as_mut().unwrap().focus = focus; - editor.bind_diff_buffer(); - let owner = editor.pane_id(); - type_text(&mut editor, keys); - type_text(&mut editor, ":diff.comment"); - editor.handle(Input::Enter); - let diff = editor.workspace_diff().unwrap(); - let anchor = &diff.local_review.document.comments[0].anchor; - let (start, end) = if keys == "llvj" { - ( - TextPosition { row: 0, column: 2 }, - TextPosition { row: 1, column: 3 }, - ) - } else { - ( - TextPosition { row: 0, column: 1 }, - TextPosition { row: 0, column: 3 }, - ) - }; - assert_eq!(anchor.start, start); - assert_eq!(anchor.end, end); - assert!(diff.review_anchor_current(anchor)); - editor.handle(Input::Escape); - assert_eq!(editor.pane_id(), owner); - assert_eq!( - editor.cursor(), - Cursor { - row: start.row, - column: start.column - } - ); - } - } - for text in ["last", "one\nlast", "one\n", ""] { - let directory = Directory::new(); - let root = directory.0.canonicalize().unwrap(); - let mut editor = editor(); - historical_diff(&mut editor, &root, vec!["before".into()], split_lines(text)); - editor.diff.as_mut().unwrap().focus = Focus::After; - editor.bind_diff_buffer(); - editor.execute("diff.comment"); - let anchor = &editor - .workspace_diff() - .unwrap() - .local_review - .document - .comments[0] - .anchor; - assert_eq!(anchor.start, TextPosition { row: 0, column: 0 }); - assert_eq!( - anchor.end, - if text.contains('\n') { - TextPosition { row: 1, column: 0 } - } else { - TextPosition { - row: 0, - column: grapheme_count(text), - } - } - ); - } - } - - #[test] - fn visual_commands_preserve_directed_ranges_on_cancel_and_plugin_dispatch() { - for shape in ["vll", "vhh", "Vj"] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace( - "abcdef\nsecond\nabcdef\nfourth".into(), - )]) - .unwrap(); - editor.register(plugin("inspect", &["inspect"])).unwrap(); - set_carets(&mut editor, &[(0, 2), (2, 2)], 1); - type_text(&mut editor, shape); - let selections = editor.selections(); - let carets = editor.carets(); - let linewise = editor.active.view.linewise; - type_text(&mut editor, ":inspect"); - assert_eq!(editor.mode(), Mode::Command); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.active.view.linewise, linewise); - editor - .bind("visual", ",", Some("core.command".into())) - .unwrap(); - editor.handle(Input::Char(',')); - type_text(&mut editor, "inspect"); - editor - .handle(Input::Enter) - .expect("selection-aware plugin invocation"); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.active.view.linewise, linewise); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), Mode::Normal); - } - } - - #[test] - fn local_review_visual_commands_and_palette_preserve_both_side_ranges() { - use crate::review::Side; - for (focus, palette) in [ - (Focus::Before, false), - (Focus::After, false), - (Focus::Before, true), - (Focus::After, true), - ] { - let directory = Directory::new(); - let root = directory.0.canonicalize().unwrap(); - let mut editor = editor(); - historical_diff( - &mut editor, - &root, - vec!["old first".into(), "old middle".into(), "old last".into()], - vec!["new first".into(), "new middle".into(), "new last".into()], - ); - editor.diff.as_mut().unwrap().focus = focus; - editor.bind_diff_buffer(); - type_text(&mut editor, "Vj"); - if palette { - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "diff.comment"); - } else { - type_text(&mut editor, ":diff.comment"); - } - editor.handle(Input::Enter); - let diff = editor.workspace_diff().unwrap(); - let anchor = diff.local_review.document.comments[0].anchor.clone(); - assert_eq!(anchor.start, TextPosition { row: 0, column: 0 }); - assert_eq!(anchor.end, TextPosition { row: 2, column: 0 }); - assert_eq!( - anchor.side, - if focus == Focus::Before { - Side::Before - } else { - Side::After - } - ); - assert!(diff.review_anchor_current(&anchor)); - let source_pane = editor.diff_owner.unwrap(); - editor.select_pane(source_pane); - editor - .diff - .as_mut() - .unwrap() - .comparison - .as_mut() - .unwrap() - .revisions[anchor.side.index()] = "changed-snapshot".into(); - editor.execute("diff.review.overview"); - assert!(editor.context().text.contains("stale")); - editor.execute("diff.review.edit 1"); - assert_eq!( - editor - .workspace_diff() - .unwrap() - .local_review - .document - .comments[0] - .anchor, - anchor - ); - } - } - - #[test] - fn local_review_guards_metadata_and_hidden_native_drafts_against_loss() { - let directory = Directory::new(); - let root = directory.0.canonicalize().unwrap(); - let mut editor = editor(); - historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]); - editor.execute("diff.comment"); - type_text(&mut editor, "iUnsaved feedback"); - editor.handle(Input::Escape); - editor.execute("diff.review.overview"); - let panel = editor.pane_id(); - let source_buffer = editor.diff_return_buffer.unwrap(); - editor.delete_buffer(source_buffer, false); - assert!(editor.has_buffer(source_buffer)); - assert!(editor.workspace_diff().is_some()); - editor.select_pane(editor.diff_owner.unwrap()); - fs::write(root.join("other.txt"), "other document").unwrap(); - editor.execute(&format!("edit {}", root.join("other.txt").display())); - assert!(editor.workspace_diff().is_some()); - assert_eq!(editor.context().text, "old"); - editor.select_pane(panel); - editor.execute("diff.close"); - assert!(editor.workspace_diff().is_some()); - editor.execute("tab.close"); - assert!(!editor.should_quit()); - editor.execute("restart"); - assert!(!editor.should_restart()); - editor.execute("q"); - assert!(!editor.should_quit()); - let invalid = root.join("missing/review.json"); - editor.execute(&format!("diff.review.save {}", invalid.display())); - assert!(editor.local_reviews_dirty()); - assert_eq!( - editor - .workspace_diff() - .unwrap() - .local_review - .document - .comments[0] - .body, - "Unsaved feedback" - ); - let path = root.join("review.json"); - editor.execute(&format!("diff.review.save {}", path.display())); - assert!(!editor.local_reviews_dirty()); - fs::write(&path, "external change").unwrap(); - editor.execute("diff.review.verdict looks_good"); - editor.execute("w"); - assert_eq!(fs::read_to_string(&path).unwrap(), "external change"); - assert!(editor.local_reviews_dirty()); - } - - #[test] - fn working_refresh_routes_hidden_owners_and_keeps_obsolete_shared_snapshots_readonly() { - let directory = Directory::new(); - fs::write(directory.path("file.txt"), "working").unwrap(); - let mut editor = editor(); - working_diff(&mut editor, &directory.0, &["file.txt"]); - editor.diff.as_mut().unwrap().focus = Focus::Before; - editor.bind_diff_buffer(); - let old_snapshot = editor.buffer_id(); - let old_comparison = editor.diff().unwrap().comparison_id.unwrap(); - let owner = editor.pane_id(); - let hidden_tab = editor.tab_id(); - editor.execute("tab.new"); - let visible_tab = editor.tab_id(); - let visible_pane = editor.pane_id(); - let cursor = editor.cursor(); - editor.queue_checkout_refreshes(true); - let (tab, request, operation) = editor.take_next_diff_request().unwrap(); - assert_eq!(tab, hidden_tab); - assert!( - matches!(operation, Operation::RefreshCheckout { comparison_id, .. } - if comparison_id == old_comparison) - ); - assert!(!editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(None)))); - assert_eq!( - editor.tab_diff(hidden_tab).unwrap().comparison_id, - Some(old_comparison) - ); - assert_eq!(editor.tab_id(), visible_tab); - assert_eq!(editor.pane_id(), visible_pane); - assert_eq!(editor.buffer_id(), old_snapshot); - assert_eq!(editor.cursor(), cursor); - - editor.queue_checkout_refreshes(true); - let (_, request, _) = editor.take_next_diff_request().unwrap(); - let replacement = editor.with_pane(owner, |editor| { - editor.diff.as_mut().unwrap().comparison.take().unwrap() - }); - assert!(editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(replacement))))); - assert_eq!( - editor.tab_diff(hidden_tab).unwrap().comparison_id, - Some(request) - ); - assert_eq!(editor.tab_id(), visible_tab); - assert_eq!(editor.buffer_id(), old_snapshot); - assert_eq!(editor.cursor(), cursor); - assert!(editor.active.diff_snapshot.is_some()); - assert!( - editor - .apply(vec![Effect::Insert("rejected".into())]) - .is_err() - ); - assert!(!editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(None)))); - - editor.with_pane(owner, |editor| { - assert_ne!(editor.buffer_id(), old_snapshot); - editor.switch_buffer(old_snapshot); - editor.leave_diff_on_navigation(); - assert!(editor.diff().is_none()); - assert!(editor.active.diff_snapshot.is_some()); - assert!(editor.mutation_blocked()); - }); - } - - #[test] - fn bookmark_refresh_reloads_paths_and_preserves_view_until_lazy_content_arrives() { - use crate::diff::{ChangedFile, Comparison, FileStatus}; - let directory = Directory::new(); - let mut editor = editor(); - historical_diff( - &mut editor, - &directory.0, - vec!["old before".into(); 4], - vec!["old after".into(); 4], - ); - editor.diff.as_mut().unwrap().after.as_mut().unwrap().target = RevisionTarget::Bookmark { - name: "topic".into(), - remote: None, - }; - editor.diff.as_mut().unwrap().focus = Focus::After; - editor.bind_diff_buffer(); - editor.handle(Input::Char('j')); - editor.diff.as_mut().unwrap().top = 2; - editor.diff.as_mut().unwrap().left_after = 3; - editor.diff.as_mut().unwrap().reviewed_files.insert(0); - editor.diff.as_mut().unwrap().view_mode = ViewMode::Unified; - let old_comparison = editor.diff().unwrap().comparison_id.unwrap(); - let old_snapshot = editor.buffer_id(); - let cursor = editor.cursor(); - let file = |path: &str, loaded| ChangedFile { - path: path.into(), - old_path: None, - status: FileStatus::Modified, - loaded, - before: if loaded { - vec!["new before".into(); 3] - } else { - vec![] - }, - after: if loaded { - vec!["new after".into(); 3] - } else { - vec![] - }, - before_eof: false, - after_eof: false, - hunks: if loaded { - crate::diff::line_hunks( - &vec!["new before".into(); 3], - false, - &vec!["new after".into(); 3], - false, - ) - } else { - vec![] - }, - notice: None, - }; - editor.queue_checkout_refreshes(false); - let (_, refresh, _) = editor.take_next_diff_request().unwrap(); - assert!(editor.apply_diff_result( - refresh, - Ok(Outcome::BookmarkRefresh(Comparison { - root: directory.0.clone(), - revisions: ["base".into(), "topic-snapshot".into()], - before: "base".into(), - after: "topic".into(), - editable_paths: Default::default(), - files: vec![file("a-new.txt", false), file("file.txt", false)], - })) - )); - let diff = editor.diff().unwrap(); - assert_eq!(diff.file, 1); - assert_eq!(diff.focus, Focus::After); - assert_eq!(diff.view_mode, ViewMode::Unified); - assert!(diff.reviewed_files.is_empty()); - assert!(!diff.selected_file_loaded()); - assert!(!diff.comparison.as_ref().unwrap().files[0].loaded); - assert_eq!(editor.buffer_id(), old_snapshot); - let (_, load, operation) = editor.take_next_diff_request().unwrap(); - assert!( - matches!(operation, Operation::LoadFile { comparison_id, file_index: 1 } if comparison_id == refresh) - ); - assert!(editor.apply_diff_result( - load, - Ok(Outcome::File { - comparison_id: refresh, - file_index: 1, - file: file("file.txt", true), - }) - )); - assert_eq!(editor.lines(), ["new after"; 3]); - assert_eq!(editor.cursor(), cursor); - assert_eq!(editor.diff().unwrap().top, 2); - assert_eq!(editor.diff().unwrap().left_after, 3); - assert_ne!(editor.buffer_id(), old_snapshot); - assert!(editor.mutation_blocked()); - assert!(!editor.apply_diff_result(refresh, Ok(Outcome::CheckoutRefresh(None)))); - assert_ne!(editor.diff().unwrap().comparison_id, Some(old_comparison)); - } - - #[test] - fn bookmark_refresh_errors_recover_and_hidden_replacements_preserve_dirty_buffers() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "original").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - working_diff(&mut editor, &directory.0, &["file.txt"]); - editor.diff.as_mut().unwrap().after.as_mut().unwrap().target = RevisionTarget::Bookmark { - name: "topic".into(), - remote: None, - }; - editor.diff.as_mut().unwrap().focus = Focus::After; - editor.bind_diff_buffer(); - let dirty_buffer = editor.buffer_id(); - type_text(&mut editor, "ix"); - editor.diff.as_mut().unwrap().reviewed_files.insert(0); - let comparison_id = editor.diff().unwrap().comparison_id; - editor.queue_checkout_refreshes(false); - let (_, request, _) = editor.take_next_diff_request().unwrap(); - assert!(editor.apply_diff_result(request, Err("bookmark is conflicted".into()))); - assert_eq!(editor.diff().unwrap().comparison_id, comparison_id); - assert_eq!(editor.lines(), ["original"]); - assert!(editor.diff().unwrap().reviewed_files.contains(&0)); - assert_eq!(editor.buffer(dirty_buffer).unwrap().lines, ["xoriginal"]); - assert!(editor.buffer(dirty_buffer).unwrap().dirty()); - editor.queue_checkout_refreshes(false); - let (_, recovery, _) = editor.take_next_diff_request().unwrap(); - assert!(editor.apply_diff_result(recovery, Ok(Outcome::CheckoutRefresh(None)))); - assert!(editor.diff().unwrap().error.is_none()); - assert_eq!(editor.diff().unwrap().comparison_id, comparison_id); - assert!(editor.diff().unwrap().reviewed_files.contains(&0)); - - let hidden_tab = editor.tab_id(); - let owner = editor.pane_id(); - let old_snapshot = editor.buffer_id(); - editor.execute("tab.new"); - let visible_tab = editor.tab_id(); - editor.queue_checkout_refreshes(false); - let (tab, request, _) = editor.take_next_diff_request().unwrap(); - assert_eq!(tab, hidden_tab); - let mut replacement = editor.with_pane(owner, |editor| { - editor.diff.as_mut().unwrap().comparison.take().unwrap() - }); - replacement.files[0].after = vec!["moved bookmark".into()]; - assert!(editor.apply_diff_result(request, Ok(Outcome::BookmarkRefresh(replacement)))); - assert_eq!(editor.tab_id(), visible_tab); - assert_eq!(editor.buffer_id(), old_snapshot); - assert_eq!(editor.lines(), ["original"]); - assert!(editor.mutation_blocked()); - assert_eq!(editor.buffer(dirty_buffer).unwrap().lines, ["xoriginal"]); - assert!(editor.buffer(dirty_buffer).unwrap().dirty()); - editor.with_pane(owner, |editor| { - assert_eq!(editor.lines(), ["moved bookmark"]); - assert!(editor.diff().unwrap().reviewed_files.is_empty()); - assert!(editor.mutation_blocked()); - }); - assert_eq!(fs::read_to_string(&path).unwrap(), "original"); - } - - #[test] - fn completed_working_refresh_serializes_lazy_selection_and_invalidates_pending_navigation() { - for request_kind in [ - "selection", - "after_selection", - "navigation", - "noop_navigation", - ] { - let directory = Directory::new(); - fs::write(directory.path("file.txt"), "working").unwrap(); - let mut editor = editor(); - working_diff(&mut editor, &directory.0, &["file.txt"]); - editor.diff.as_mut().unwrap().focus = Focus::Before; - editor.bind_diff_buffer(); - let old_snapshot = editor.buffer_id(); - let mut target = lazy_lsp_target(&mut editor); - let old_comparison = editor.diff().unwrap().comparison_id.unwrap(); - let virtual_root = editor.diff().unwrap().snapshot_roots[0].clone().unwrap(); - editor.queue_checkout_refreshes(true); - let (_, refresh, _) = editor.take_next_diff_request().unwrap(); - let loaded_before = target.before.clone(); - let loaded_after = target.after.clone(); - target.loaded = false; - let replacement = crate::diff::Comparison { - root: directory.0.clone(), - revisions: ["base".into(), "ahead-snapshot".into()], - before: "base".into(), - after: "ahead".into(), - editable_paths: Default::default(), - files: vec![target], - }; - let selection = request_kind.ends_with("selection"); - let focus = if request_kind == "after_selection" { - Focus::After - } else { - Focus::Before - }; - if selection { - assert!(editor.diff.as_mut().unwrap().select_file_index(1)); - editor.diff.as_mut().unwrap().focus = focus; - } else { - editor - .navigate_lsp_to(virtual_root.join("nested/old.txt"), lsp_point(1, 4), None) - .unwrap(); - assert!(editor.pending_lsp_navigation.is_some()); - assert_eq!(editor.diff().unwrap().file, 0); - } - assert!(editor.take_next_diff_request().is_none()); - let changed = request_kind != "noop_navigation"; - let outcome = if changed { Some(replacement) } else { None }; - assert_eq!( - editor.apply_diff_result(refresh, Ok(Outcome::CheckoutRefresh(outcome))), - changed - ); - if changed { - assert!(editor.pending_lsp_navigation.is_none()); - assert_eq!(editor.diff().unwrap().comparison_id, Some(refresh)); - assert_eq!(editor.diff().unwrap().file, 0); - assert_eq!(editor.diff().unwrap().focus, focus); - } else { - assert!(editor.pending_lsp_navigation.is_some()); - assert_eq!(editor.diff().unwrap().comparison_id, Some(old_comparison)); - } - let (_, load, operation) = editor.take_next_diff_request().unwrap(); - assert!( - matches!(operation, Operation::LoadFile { comparison_id, file_index } - if comparison_id == if changed { refresh } else { old_comparison } - && file_index == if changed { 0 } else { 1 }) - ); - if !changed { - assert_eq!( - editor.pending_lsp_navigation.as_ref().unwrap().request, - Some(load) - ); - } - if changed { - let file = crate::diff::ChangedFile { - path: "nested/new.txt".into(), - old_path: Some("nested/old.txt".into()), - status: crate::diff::FileStatus::Renamed, - loaded: true, - hunks: crate::diff::line_hunks(&loaded_before, false, &loaded_after, false), - before: loaded_before, - after: loaded_after, - before_eof: false, - after_eof: false, - notice: None, - }; - assert!(editor.apply_diff_result( - load, - Ok(Outcome::File { - comparison_id: refresh, - file_index: 0, - file, - }) - )); - assert_eq!(editor.diff().unwrap().focus, focus); - assert!(!editor.parked.contains_key(&old_snapshot)); - } - } - } - - fn lsp_point(row: usize, column: usize) -> Utf16Range { - Utf16Range { - start: (row, column), - end: (row, column), - } - } - - fn lazy_lsp_target(editor: &mut Editor) -> crate::diff::ChangedFile { - use crate::diff::{ChangedFile, FileStatus}; - let before = vec!["context".into(), "😀e\u{301} before".into()]; - let after = vec!["context".into(), "😀e\u{301} after".into()]; - let hunks = crate::diff::line_hunks(&before, false, &after, false); - let diff = editor.diff.as_mut().unwrap(); - let comparison = diff.comparison.as_mut().unwrap(); - comparison.files.push(ChangedFile { - path: "nested/new.txt".into(), - old_path: Some("nested/old.txt".into()), - status: FileStatus::Renamed, - loaded: false, - before: Vec::new(), - after: Vec::new(), - before_eof: false, - after_eof: false, - hunks: Vec::new(), - notice: None, - }); - diff.files = Some(FilePicker::from_paths( - comparison.root.clone(), - comparison - .files - .iter() - .enumerate() - .map(|(index, file)| (index, file.path.clone())), - )); - ChangedFile { - path: "nested/new.txt".into(), - old_path: Some("nested/old.txt".into()), - status: FileStatus::Renamed, - loaded: true, - before, - after, - before_eof: false, - after_eof: false, - hunks, - notice: None, - } - } - - #[test] - fn lsp_symbol_extent_reveals_exact_omitted_declaration_without_losing_comparison() { - let directory = Directory::new(); - let mut editor = editor(); - let before: Vec<_> = (0..70).map(|row| format!("line {row}")).collect(); - let mut after = before.clone(); - after[60] = "changed body".into(); - historical_diff(&mut editor, &directory.0, before, after); - editor.set_diff_context_options(1, 3); - let comparison = editor.diff().unwrap().comparison_id; - let snapshot = editor.buffer_id(); - let path = editor.active.snapshot_document_path.clone().unwrap(); - assert!( - editor - .diff() - .unwrap() - .source_display_row(Focus::Before, 4) - .is_none() - ); - editor - .navigate_lsp_to( - path.clone(), - lsp_point(4, 2), - Some(Utf16Range { - start: (4, 0), - end: (61, 0), - }), - ) - .unwrap(); - assert_eq!(editor.tab_count(), 1); - assert_eq!(editor.buffer_id(), snapshot); - assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); - assert_eq!(editor.diff().unwrap().comparison_id, comparison); - assert!( - editor - .diff() - .unwrap() - .source_display_row(Focus::Before, 4) - .is_some() - ); - editor.refresh_live_diff(); - assert_eq!(editor.cursor().row, 4); - editor - .navigate_lsp_to(path, lsp_point(20, 1), None) - .unwrap(); - assert_eq!(editor.tab_count(), 2); - assert!(editor.diff().is_none()); - assert_eq!(editor.buffer_id(), snapshot); - assert!(editor.mutation_blocked()); - editor.execute("tab.previous"); - assert_eq!(editor.diff().unwrap().comparison_id, comparison); - assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); - } - - #[test] - fn lazy_lsp_navigation_is_precise_and_source_departures_cancel_permanently() { - for departure in [ - "none", - "caret", - "side", - "tab", - "pane", - "close", - "replace", - "supersede", - ] { - let directory = Directory::new(); - let mut editor = editor(); - historical_diff( - &mut editor, - &directory.0, - vec!["context".into(), "before".into()], - vec!["context".into(), "after".into()], - ); - let file = lazy_lsp_target(&mut editor); - let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); - let source = editor.buffer_id(); - let virtual_root = editor.diff().unwrap().snapshot_roots[0].clone().unwrap(); - editor - .navigate_lsp_to(virtual_root.join("nested/old.txt"), lsp_point(1, 4), None) - .unwrap(); - assert_eq!(editor.buffer_id(), source); - assert_eq!(editor.diff().unwrap().file, 0); - let (id, operation) = editor.take_diff_request().unwrap(); - assert!( - matches!(operation, Operation::LoadFile { comparison_id: owner, file_index: 1 } - if owner == comparison_id) - ); - match departure { - "none" => {} - "caret" => { - editor.handle(Input::Right); - editor.handle(Input::Left); - } - "side" => { - editor.scroll_diff(Focus::After, true); - editor.scroll_diff(Focus::Before, false); - } - "tab" => { - editor.execute("tab.new"); - editor.execute("tab.previous"); - } - "pane" => { - editor.execute("pane.split_vertical"); - } - "close" => { - editor.execute("diff.close"); - } - "replace" => { - editor.execute("diff"); - } - "supersede" => { - let path = editor.active.snapshot_document_path.clone().unwrap(); - editor.navigate_lsp_to(path, lsp_point(1, 0), None).unwrap(); - } - _ => unreachable!(), - } - let location = ( - editor.tab_id, - editor.pane_id, - editor.buffer_id(), - editor.cursor(), - ); - let selected = editor - .workspace_diff() - .map(|diff| (diff.comparison_id, diff.file, diff.focus)); - let tabs = editor.tab_count(); - editor.apply_diff_result( - id, - Ok(Outcome::File { - comparison_id, - file_index: 1, - file, - }), - ); - if departure == "none" { - assert_eq!(editor.diff().unwrap().file, 1); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); - assert_eq!( - editor.path(), - Some(directory.path("nested/old.txt").as_path()) - ); - assert_eq!( - editor.active.snapshot_document_path.as_ref(), - Some(&virtual_root.join("nested/old.txt")) - ); - assert_eq!(editor.tab_count(), 1); - } else { - assert_eq!( - ( - editor.tab_id, - editor.pane_id, - editor.buffer_id(), - editor.cursor() - ), - location, - "{departure}" - ); - assert_eq!( - editor - .workspace_diff() - .map(|diff| (diff.comparison_id, diff.file, diff.focus)), - selected, - "{departure}" - ); - assert_eq!(editor.tab_count(), tabs, "{departure}"); - } - assert!(editor.pending_lsp_navigation.is_none()); - } - } - - #[test] - fn lazy_lsp_preflight_validates_both_ranges_before_committing_a_target() { - for invalid_target_range in [false, true] { - let directory = Directory::new(); - let mut editor = editor(); - historical_diff( - &mut editor, - &directory.0, - vec!["context".into(), "before".into()], - vec!["context".into(), "after".into()], - ); - let file = lazy_lsp_target(&mut editor); - let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); - let source = editor.jump_location(); - let documents = editor.interaction_documents().count(); - let invalid = Utf16Range { - start: (1, 0), - end: (99, 0), - }; - editor - .navigate_lsp_to( - directory.path("nested/old.txt"), - if invalid_target_range { - lsp_point(1, 0) - } else { - invalid - }, - invalid_target_range.then_some(invalid), - ) - .unwrap(); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result( - id, - Ok(Outcome::File { - comparison_id, - file_index: 1, - file, - }), - ); - assert_eq!(editor.jump_location(), source); - assert_eq!(editor.diff().unwrap().file, 0); - assert_eq!(editor.tab_count(), 1); - assert_eq!(editor.interaction_documents().count(), documents); - assert!(editor.message().contains("definition failed")); - } - } - - #[test] - fn superseded_lazy_results_cannot_consume_the_new_queued_navigation() { - let directory = Directory::new(); - let mut editor = editor(); - historical_diff( - &mut editor, - &directory.0, - vec!["context".into(), "before".into()], - vec!["context".into(), "after".into()], - ); - let file = lazy_lsp_target(&mut editor); - let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); - let path = directory.path("nested/old.txt"); - let source = editor.jump_location(); - editor - .navigate_lsp_to(path.clone(), lsp_point(1, 0), None) - .unwrap(); - let (old, _) = editor.take_diff_request().unwrap(); - editor.navigate_lsp_to(path, lsp_point(1, 4), None).unwrap(); - assert!(!editor.apply_diff_result(old, Err("old load failed".into()))); - assert_eq!(editor.jump_location(), source); - assert!(editor.pending_lsp_navigation.is_some()); - let (current, _) = editor.take_diff_request().unwrap(); - assert_ne!(current, old); - assert!(!editor.apply_diff_result(old, Err("late old load failed".into()))); - assert!(editor.pending_lsp_navigation.is_some()); - editor.apply_diff_result( - current, - Ok(Outcome::File { - comparison_id, - file_index: 1, - file, - }), - ); - assert_eq!(editor.diff().unwrap().file, 1); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); - assert!(editor.pending_lsp_navigation.is_none()); - assert!(!editor.message().contains("old load failed")); - } - - #[test] - fn lazy_renamed_before_target_uses_shared_dirty_working_after_hunks() { - let directory = Directory::new(); - fs::create_dir(directory.path("nested")).unwrap(); - let path = directory.path("nested/new.txt"); - fs::write(&path, "context\nbefore").unwrap(); - let mut editor = editor(); - historical_diff( - &mut editor, - &directory.0, - vec!["context".into(), "source before".into()], - vec!["context".into(), "source after".into()], - ); - let mut file = lazy_lsp_target(&mut editor); - file.before = vec!["context".into(), "before".into()]; - file.after = file.before.clone(); - file.hunks.clear(); - let diff = editor.diff.as_mut().unwrap(); - diff.after.as_mut().unwrap().target = RevisionTarget::Working; - diff.comparison - .as_mut() - .unwrap() - .editable_paths - .insert("nested/new.txt".into()); - let comparison_id = diff.comparison_id.unwrap(); - let mut live = Buffer::open( - editor.next_buffer_id, - Some(path.clone()), - Some(path.canonicalize().unwrap()), - false, - ) - .unwrap(); - let mut lines = live.lines.clone(); - lines[1] = "dirty after".into(); - live.replace_lines(lines); - let live_id = live.id; - editor.next_buffer_id += 1; - editor.parked.insert(live_id, live); - editor - .navigate_lsp_to(directory.path("nested/old.txt"), lsp_point(1, 0), None) - .unwrap(); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result( - id, - Ok(Outcome::File { - comparison_id, - file_index: 1, - file, - }), - ); - assert_eq!(editor.tab_count(), 1); - assert_eq!(editor.diff().unwrap().file, 1); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); - assert_eq!(editor.lines(), ["context", "before"]); - assert_eq!(editor.diff().unwrap().live_buffer, Some(live_id)); - assert!( - editor - .diff() - .unwrap() - .source_range_changed(1, Focus::Before, 1, 1) - ); - assert!(editor.mutation_blocked()); - assert_eq!( - editor.interaction_document_root(editor.buffer_id()), - directory.0.as_path() - ); - assert_eq!(editor.diff_root(editor.tab_id()), directory.0); - editor.handle(Input::Char('\t')); - assert_eq!(editor.buffer_id(), live_id); - assert!(editor.diff_editing()); - editor.navigate_lsp_to(path, lsp_point(1, 0), None).unwrap(); - assert_eq!(editor.tab_count(), 1); - assert_eq!(editor.diff().unwrap().comparison_id, Some(comparison_id)); - assert_eq!(editor.buffer_id(), live_id); - assert_eq!(editor.lines(), ["context", "dirty after"]); - assert!(editor.dirty()); - } - - #[test] - fn diff_documents_dismiss_notifications_but_files_and_selectors_keep_escape() { - let directory = Directory::new(); - let mut editor = editor(); - historical_diff( - &mut editor, - &directory.0, - vec!["before".into(), "second".into()], - vec!["after".into(), "second".into()], - ); - set_carets(&mut editor, &[(0, 1), (1, 1)], 1); - editor.set_message("operation"); - editor.handle(Input::Escape); - assert!(!editor.notification_visible()); - assert_eq!(editor.selection_count(), 2); - assert!(editor.diff_interacting()); - editor.handle(Input::Escape); - assert_eq!(editor.selection_count(), 1); - editor.set_message("operation"); - editor.diff.as_mut().unwrap().focus = Focus::Files; - editor.handle(Input::Escape); - assert!(editor.notification_visible()); - assert_ne!(editor.diff().unwrap().focus, Focus::Files); - editor.execute("diff.close"); - editor.execute("diff"); - editor.handle(Input::Char('q')); - assert_eq!(editor.diff().unwrap().query, "q"); - editor.handle(Input::Escape); - assert!(editor.diff().is_none()); - assert!(editor.notification_visible()); - } - - #[test] - fn readonly_native_panes_allow_selection_yank_search_and_multicarets() { - for focus in [Focus::Before, Focus::After] { - let directory = Directory::new(); - let mut editor = editor(); - let lines = ["alpha beta alpha", "second line", "third line"] - .map(str::to_owned) - .to_vec(); - let mut after = lines.clone(); - after[2] = "third changed".into(); - historical_diff(&mut editor, &directory.0, lines.clone(), after.clone()); - if focus == Focus::After { - editor.handle(Input::Char('\t')); - } - let id = editor.buffer_id(); - type_text(&mut editor, "2w"); - assert_eq!(editor.cursor().column, 11); - let targets = editor.active.view.targets.clone(); - let revision = editor.text_revision(); - type_text(&mut editor, "yy"); - assert_eq!(editor.clipboard, ["alpha beta alpha\n"]); - assert!(editor.clipboard_linewise); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.text_revision(), revision); - editor.handle(Input::Char('p')); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.text_revision(), revision); - type_text(&mut editor, "0v4ly"); - assert_eq!(editor.clipboard, ["alpha"]); - assert_eq!(editor.mode(), Mode::Normal); - type_text(&mut editor, "Vjy"); - assert!(editor.clipboard_linewise); - assert_eq!(editor.clipboard, ["alpha beta alpha\nsecond line\n"]); - type_text(&mut editor, "gt/alpha\nn"); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 11 }); - editor.handle(Input::Char('n')); - assert_eq!(editor.cursor(), Cursor::default()); - editor.handle(Input::Ctrl('e')); - assert_eq!(editor.selection_count(), 2); - editor.handle(Input::Escape); - type_text(&mut editor, "gtv4l"); - editor.handle(Input::Ctrl('a')); - assert_eq!(editor.selection_count(), 2); - assert_eq!( - editor.lines(), - if focus == Focus::Before { - &lines - } else { - &after - } - ); - assert_eq!(editor.buffer_id(), id); - assert_eq!(editor.buffer_count(), 1); - assert_eq!(editor.documents().count(), 1); - assert!(!editor.dirty()); - } - } - - #[test] - fn readonly_native_mutation_rejection_is_atomic_and_cannot_write_disk() { - for focus in [Focus::Before, Focus::After] { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "disk").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - type_text(&mut editor, "iunsaved"); - editor.handle(Input::Escape); - let original_id = editor.buffer_id(); - historical_diff( - &mut editor, - &directory.0, - vec!["snapshot text".into()], - vec!["historic text".into()], - ); - if focus == Focus::After { - editor.handle(Input::Char('\t')); - } - editor - .bind("normal", "z", Some("core.insert".into())) - .unwrap(); - editor - .bind("normal", "leader d", Some("core.delete_line".into())) - .unwrap(); - editor - .bind("normal", "Q", Some("core.save".into())) - .unwrap(); - let text = editor.lines().to_vec(); - let revision = editor.text_revision(); - for keys in [ - "i", "a", "o", "O", "dd", "cc", "x", "J", "u", ".", "p", "z", " d", "Q", - ] { - type_text(&mut editor, keys); - assert_eq!(editor.mode(), Mode::Normal, "{keys}"); - assert_eq!(editor.lines(), text, "{keys}"); - } - editor.handle(Input::Ctrl('r')); - editor.handle(Input::Paste("literal paste".into())); - type_text(&mut editor, "vl"); - let selections = editor.selections(); - for keys in ["d", "c", "p", "."] { - type_text(&mut editor, keys); - assert_eq!(editor.selections(), selections, "{keys}"); - assert_eq!(editor.lines(), text, "{keys}"); - } - for effect in [ - Effect::Insert("plugin".into()), - Effect::Replace("plugin".into()), - ] { - assert!( - editor - .apply(vec![Effect::Message("must not leak".into()), effect]) - .is_err() - ); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.lines(), text); - } - for command in [ - "w".to_owned(), - format!("w {}", path.display()), - format!("wq {}", path.display()), - "save_and_quit".into(), - ] { - editor.execute(&command); - assert_eq!(fs::read_to_string(&path).unwrap(), "disk"); - assert!(!editor.should_quit()); - } - assert_eq!(editor.text_revision(), revision); - let snapshot_id = editor.buffer_id(); - editor.execute("diff.close"); - assert_eq!(editor.buffer_id(), original_id); - assert_eq!(editor.lines(), ["unsaveddisk"]); - assert!(!editor.has_buffer(snapshot_id)); - assert_eq!(editor.parked.len(), 0); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), ["disk"]); - } - } - - #[test] - fn diff_focus_maps_aligned_rows_and_retains_display_column_through_short_lines() { - let directory = Directory::new(); - let mut editor = editor(); - let before = ["start", "removed", "anchor", "\t界abcdef", "tail"] - .map(str::to_owned) - .to_vec(); - let after = ["start", "added one", "added two", "anchor", "\t界a", "tail"] - .map(str::to_owned) - .to_vec(); - historical_diff(&mut editor, &directory.0, before, after); - type_text(&mut editor, "3j6l"); - let original = editor.cursor(); - assert_eq!(original, Cursor { row: 3, column: 6 }); - editor.handle(Input::Char('\t')); - assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); - editor.handle(Input::BackTab); - assert_eq!(editor.cursor(), original); - editor.scroll_diff(Focus::After, true); - assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); - editor.scroll_diff(Focus::Before, false); - assert_eq!(editor.cursor(), original); - editor.handle(Input::BackTab); - assert_eq!(editor.diff().unwrap().focus, Focus::Files); - editor.handle(Input::Escape); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); - assert_eq!(editor.cursor(), original); - type_text(&mut editor, "gtj"); - editor.handle(Input::Char('\t')); - assert_eq!(editor.cursor().row, 1); - type_text(&mut editor, "gt2j"); - assert_eq!(editor.cursor().row, 2); - assert!( - editor - .diff() - .unwrap() - .rows - .iter() - .any(|row| row.after == Some(2) && row.before.is_none()) - ); - editor.handle(Input::BackTab); - assert_eq!(editor.cursor().row, 1); - } - - #[test] - fn readonly_excerpts_skip_hidden_rows_for_motions_search_clones_and_expand_only_normal() { - let directory = Directory::new(); - let mut editor = editor(); - let before: Vec<_> = (0..60).map(|row| format!("line {row}")).collect(); - let mut after = before.clone(); - after[10] = "changed 10".into(); - after[40] = "changed 40".into(); - editor.set_diff_context_options(1, 3); - historical_diff(&mut editor, &directory.0, before, after); - for focus in [Focus::Before, Focus::After] { - if editor.diff().unwrap().focus != focus { - editor.handle(Input::Char('\t')); - } - type_text(&mut editor, "gt3j"); - assert_eq!(editor.cursor().row, 39); - type_text(&mut editor, "gt2j"); - assert_eq!(editor.cursor().row, 11); - editor.handle(Input::Ctrl('e')); - assert_eq!( - editor - .carets() - .iter() - .map(|cursor| cursor.row) - .collect::>(), - [11, 39] - ); - editor.handle(Input::Escape); - type_text(&mut editor, "/line 20\n"); - assert!(editor.message().starts_with("Not found:")); - type_text(&mut editor, "gtv]"); - assert_eq!(editor.mode(), Mode::Visual); - assert!( - editor - .diff() - .unwrap() - .source_display_row(focus, 12) - .is_none() - ); - editor.handle(Input::Escape); - type_text(&mut editor, "gt]"); - assert!( - editor - .diff() - .unwrap() - .source_display_row(focus, 12) - .is_some() - ); - // Rebuild the excerpt configuration before checking the other pane. - editor.set_diff_context_options(2, 3); - editor.set_diff_context_options(1, 3); - } - } - - #[test] - fn readonly_snapshots_keep_pane_selection_and_safe_document_identity_for_navigation() { - let directory = Directory::new(); - let mut editor = editor(); - let before = ["alpha beta", "second line", "before tail"] - .map(str::to_owned) - .to_vec(); - let after = ["alpha beta", "second line", "after tail"] - .map(str::to_owned) - .to_vec(); - historical_diff(&mut editor, &directory.0, before, after); - let before_id = editor.buffer_id(); - let source_path = editor.path().unwrap().to_owned(); - let snapshot_path = editor - .interaction_documents() - .find(|document| document.id == before_id) - .unwrap() - .path - .unwrap() - .to_owned(); - assert_ne!(snapshot_path, source_path); - assert!(!snapshot_path.exists()); - assert!(editor.active.identity.is_none()); - type_text(&mut editor, "v3l"); - editor.handle(Input::Ctrl('e')); - let anchor = editor.active.view.targets[editor.active.view.primary].anchor; - let carets = editor.selection_count(); - editor.scroll_diff(Focus::After, true); - assert_eq!(editor.mode(), Mode::Normal); - editor.scroll_diff(Focus::Before, false); - assert_eq!(editor.buffer_id(), before_id); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selection_count(), carets); - assert_eq!( - editor.active.view.targets[editor.active.view.primary].anchor, - anchor - ); - editor - .navigate_to(snapshot_path, TextPosition { row: 1, column: 2 }) - .unwrap(); - assert!(editor.diff_interacting()); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); - editor - .register(Feature { - name: "readonly_fixture".into(), - required_capabilities: vec![], - core: false, - commands: vec!["readonly_fixture.command".into()], - }) - .unwrap(); - editor - .bind("normal", "R", Some("readonly_fixture.command".into())) - .unwrap(); - let invocation = editor.handle(Input::Char('R')).unwrap(); - assert_eq!(invocation.plugin, "readonly_fixture"); - assert!( - editor - .apply(vec![Effect::Insert("blocked".into())]) - .is_err() - ); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core.word_end"); - editor.handle(Input::Enter); - assert_eq!(editor.cursor().column, 5); - editor.execute("diff.close"); - assert!( - editor - .interaction_documents() - .all(|document| document.id != before_id) - ); - } - - #[test] - fn no_native_diff_pane_cannot_mutate_hidden_live_buffer() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "original").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - working_diff(&mut editor, &directory.0, &["file.txt"]); - let file = &mut editor - .diff - .as_mut() - .unwrap() - .comparison - .as_mut() - .unwrap() - .files[0]; - file.status = crate::diff::FileStatus::Renamed; - file.old_path = Some("old.txt".into()); - file.before = file.after.clone(); - file.before_eof = file.after_eof; - file.hunks.clear(); - editor.handle(Input::Char('\t')); - assert!(!editor.diff_interacting()); - type_text(&mut editor, "iddccx"); - editor - .apply(vec![Effect::Insert("blocked".into())]) - .unwrap_err(); - editor.execute(&format!("w {}", path.display())); - assert_eq!(fs::read_to_string(path).unwrap(), "original"); - editor.execute("diff.close"); - assert_eq!(editor.lines(), ["original"]); - } - - #[test] - fn readonly_follow_link_and_jump_use_native_navigation_without_snapshot_identity() { - let directory = Directory::new(); - let mut editor = editor(); - let before = ["[go](#section)", "# Section", "before"] - .map(str::to_owned) - .to_vec(); - let after = ["[go](#section)", "# Section", "after"] - .map(str::to_owned) - .to_vec(); - historical_diff(&mut editor, &directory.0, before, after); - editor.active.path = Some(directory.path("file.md")); - type_text(&mut editor, "gf"); - assert_eq!(editor.cursor().row, 1); - assert!(editor.diff_interacting()); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.cursor().row, 0); - assert!(editor.diff_interacting()); - let target = directory.path("target.md"); - fs::write(&target, "# Target\n").unwrap(); - editor.execute("diff.close"); - historical_diff( - &mut editor, - &directory.0, - vec!["[go](target.md)".into()], - vec!["[after](target.md)".into()], - ); - editor.active.path = Some(directory.path("file.md")); - type_text(&mut editor, "gf"); - assert!(editor.diff().is_none()); - assert_eq!(editor.path(), Some(target.as_path())); - assert_eq!(editor.lines(), ["# Target", ""]); - } - - #[test] - fn editable_diff_brackets_expand_caret_excerpt_without_moving_source_anchor() { - for selected in [30, 50] { - let directory = Directory::new(); - let path = directory.path("file.txt"); - let before: Vec = (0..80).map(|row| format!("line {row}")).collect(); - fs::write(&path, before.join("\n")).unwrap(); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - let mut after = before; - for row in [10, 30, 50] { - after[row] = format!("changed {row}"); - } - editor - .apply(vec![Effect::Replace(after.join("\n"))]) - .unwrap(); - editor.set_diff_context_options(1, 4); - working_diff(&mut editor, &directory.0, &["file.txt"]); - editor.handle(Input::BackTab); - assert!(editor.diff_editing()); - editor.active.view.targets = vec![Target::caret(Cursor { - row: selected, - column: 2, - })]; - let top = editor.diff().unwrap().after_display_row(9).unwrap(); - editor.diff.as_mut().unwrap().top = top; - let cursor = editor.cursor(); - let visible = |editor: &Editor| { - editor - .diff() - .unwrap() - .source_rows(Focus::After) - .collect::>() - }; - assert_eq!( - visible(&editor), - (9..12).chain(29..32).chain(49..52).collect::>() - ); - editor.handle(Input::Char('[')); - editor.handle(Input::Char(']')); - let expected = if selected == 30 { - (9..12).chain(25..36).chain(49..52).collect::>() - } else { - (9..12).chain(29..32).chain(45..56).collect::>() - }; - assert_eq!(visible(&editor), expected); - assert_eq!(editor.cursor(), cursor); - assert_eq!( - editor.diff().unwrap().after_display_row(9), - Some(editor.diff().unwrap().top) - ); - if selected == 30 { - // The caret keeps targeting the second excerpt after it merges - // with the third, rather than expanding the first excerpt. - for _ in 0..5 { - editor.handle(Input::Char(']')); - } - assert_eq!(visible(&editor), (9..12).chain(25..56).collect::>()); - assert_eq!(editor.cursor(), cursor); - assert_eq!( - editor.diff().unwrap().after_display_row(9), - Some(editor.diff().unwrap().top) - ); - } - } - } - - #[test] - fn working_diff_reuses_dirty_buffers_and_normal_edit_undo_save_paths() { - let directory = Directory::new(); - let a = directory.path("a.txt"); - let b = directory.path("b.txt"); - fs::write(&a, "alpha\n").unwrap(); - fs::write(&b, "beta").unwrap(); - let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); - type_text(&mut editor, "iunsaved"); - editor.handle(Input::Escape); - editor.execute(&format!("edit {}", b.display())); - type_text(&mut editor, "iother"); - editor.handle(Input::Escape); - let overlays = working_diff(&mut editor, &directory.0, &["a.txt", "b.txt"]); - assert_eq!(overlays.len(), 2); - assert!(overlays.contains(&(a.canonicalize().unwrap(), "unsavedalpha\n".into()))); - assert!(overlays.contains(&(b.canonicalize().unwrap(), "otherbeta".into()))); - assert_eq!(editor.buffer_count(), 2); - assert_eq!(editor.buffer_id(), 1); - assert!(editor.diff_editable()); - assert!(!editor.diff_editing()); - editor.handle(Input::BackTab); - assert!(editor.diff_editing()); - type_text(&mut editor, "0i[]"); - editor.handle(Input::Char('\t')); - editor.handle(Input::Escape); - assert_eq!(editor.lines()[0], "[]\tunsavedalpha"); - assert!(editor.diff().is_some()); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines()[0], "unsavedalpha"); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), ["alpha", ""]); - let row = editor.cursor().row; - assert!(editor.diff().unwrap().after_display_row(row).is_some()); - type_text(&mut editor, "ccreplacement"); - editor.handle(Input::Escape); - editor.execute("w"); - assert_eq!(fs::read_to_string(&a).unwrap(), "replacement\n"); - assert!(!editor.dirty()); - assert_eq!(editor.buffer(2).unwrap().lines, ["otherbeta"]); - editor.execute("diff.close"); - assert!(editor.diff().is_none()); - editor.execute("buffer 2"); - assert!(editor.dirty()); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), ["beta"]); - for refresh_error in [false, true] { - let path = directory.path("file.txt"); - fs::write(&path, "original").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - historical_diff( - &mut editor, - &directory.0, - vec!["before".into()], - vec!["original".into()], - ); - let endpoints = ( - editor.diff().unwrap().before.clone(), - editor.diff().unwrap().after.clone(), - ); - editor.queue_checkout_refreshes(false); - let (_, request, _) = editor.take_next_diff_request().unwrap(); - let mut promoted = editor.diff.as_mut().unwrap().comparison.take().unwrap(); - promoted.editable_paths.insert("file.txt".into()); - editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(promoted)))); - editor.diff.as_mut().unwrap().focus = Focus::After; - editor.bind_diff_buffer(); - assert!(editor.diff_editing()); - let live = editor.buffer_id(); - type_text(&mut editor, "ix"); - assert_eq!(editor.mode(), Mode::Insert); - let comparison_id = editor.diff().unwrap().comparison_id; - editor.queue_checkout_refreshes(false); - let (_, request, _) = editor.take_next_diff_request().unwrap(); - if refresh_error { - assert!(editor.apply_diff_result(request, Err("checkout unavailable".into()))); - assert_eq!(editor.diff().unwrap().comparison_id, comparison_id); - } else { - let mut demoted = editor.diff.as_mut().unwrap().comparison.take().unwrap(); - demoted.editable_paths.clear(); - demoted.files[0].after = vec!["original".into()]; - demoted.files[0].after_eof = false; - demoted.files[0].hunks = crate::diff::line_hunks( - &demoted.files[0].before, - false, - &demoted.files[0].after, - false, - ); - assert!( - editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(demoted))),) - ); - } - assert!(!editor.diff_editable()); - assert_eq!(editor.lines(), ["original"]); - assert_eq!(editor.buffer(live).unwrap().lines, ["xoriginal"]); - assert!(editor.buffer(live).unwrap().dirty()); - assert_eq!(editor.buffer(live).unwrap().view.mode, Mode::Normal); - assert!( - editor - .apply(vec![Effect::Insert("blocked".into())]) - .is_err() - ); - editor.execute("w"); - assert_eq!(fs::read_to_string(&path).unwrap(), "original"); - editor.queue_checkout_refreshes(false); - let (_, request, _) = editor.take_next_diff_request().unwrap(); - let mut promoted = editor.diff.as_mut().unwrap().comparison.take().unwrap(); - promoted.editable_paths.insert("file.txt".into()); - editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(promoted)))); - assert!(editor.diff_editing()); - assert!(editor.diff().unwrap().error.is_none()); - assert_eq!(editor.buffer_id(), live); - assert_eq!(editor.lines(), ["xoriginal"]); - assert_eq!( - ( - editor.diff().unwrap().before.clone(), - editor.diff().unwrap().after.clone(), - ), - endpoints, - ); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), ["original"]); - } - } - - #[test] - fn diff_review_actions_preserve_readonly_text_history_and_parked_tabs() { - let directory = Directory::new(); - let mut editor = editor(); - editor.execute("diff.review.toggle"); - assert_eq!(editor.message(), "No comparison is open"); - historical_diff( - &mut editor, - &directory.0, - vec!["before".into(), "second".into()], - vec!["after".into(), "second".into()], - ); - editor - .bind("normal", "ctrl+y", Some("core.diff.review.toggle".into())) - .unwrap(); - let text = editor.lines().to_vec(); - let carets = editor.carets(); - let revision = editor.text_revision(); - let undo = editor.active.undo.len(); - let redo = editor.active.redo.len(); - let dirty = editor.dirty(); - assert!(editor.mutation_blocked()); - editor.handle(Input::Ctrl('y')); - assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0])); - editor.execute("diff.review.toggle unexpected"); - assert!(editor.message().contains("takes no arguments")); - assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0])); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "diff.review.toggle"); - editor.handle(Input::Enter); - assert!(editor.diff().unwrap().reviewed_files.is_empty()); - assert_eq!(editor.lines(), text); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.text_revision(), revision); - assert_eq!(editor.active.undo.len(), undo); - assert_eq!(editor.active.redo.len(), redo); - assert_eq!(editor.dirty(), dirty); - assert!(!directory.path("file.txt").exists()); - editor.handle(Input::Char('\t')); - assert_eq!(editor.diff().unwrap().focus, Focus::After); - assert!(editor.mutation_blocked()); - editor.execute(":diff.review.toggle"); - editor.execute("diff.view.toggle"); - editor.execute("diff.view.toggle"); - editor.execute("diff.orientation.toggle"); - let first_tab = editor.tab_id(); - editor.execute("tab.new"); - historical_diff( - &mut editor, - &directory.0, - vec!["base".into()], - vec!["different".into()], - ); - assert!(editor.diff().unwrap().reviewed_files.is_empty()); - editor.execute("tab.previous"); - assert_eq!(editor.tab_id(), first_tab); - assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0])); - editor.execute("diff.close"); - historical_diff( - &mut editor, - &directory.0, - vec!["before".into()], - vec!["after".into()], - ); - assert!(editor.diff().unwrap().reviewed_files.is_empty()); - } - - #[test] - fn diff_review_c_toggles_only_while_browsing_and_respects_overrides() { - let directory = Directory::new(); - let mut editor = editor(); - historical_diff( - &mut editor, - &directory.0, - vec!["before".into()], - vec!["after".into()], - ); - editor.handle(Input::BackTab); - assert_eq!(editor.diff().unwrap().focus, Focus::Files); - editor.handle(Input::Char('c')); - assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0])); - editor.handle(Input::Char('c')); - assert!(editor.diff().unwrap().reviewed_files.is_empty()); - editor.handle(Input::Char('i')); - editor.handle(Input::Char('c')); - assert_eq!(editor.diff().unwrap().files.as_ref().unwrap().query, "c"); - assert!(editor.diff().unwrap().reviewed_files.is_empty()); - editor.handle(Input::Escape); - editor.handle(Input::Ctrl('u')); - editor.bind("normal", "c", None).unwrap(); - editor.handle(Input::Char('c')); - assert!(editor.diff().unwrap().reviewed_files.is_empty()); - } - - #[test] - fn diff_review_files_action_targets_highlight_without_stealing_filter_text() { - let directory = Directory::new(); - fs::write(directory.path("z.txt"), "z").unwrap(); - fs::write(directory.path("a.txt"), "a").unwrap(); - let mut editor = editor(); - working_diff(&mut editor, &directory.0, &["z.txt", "a.txt"]); - editor - .bind("normal", "ctrl+y", Some("core.diff.review.toggle".into())) - .unwrap(); - editor - .bind("normal", "Q", Some("core.diff.review.toggle".into())) - .unwrap(); - let files = editor.diff.as_mut().unwrap().files.as_mut().unwrap(); - files.set_view(FileView::List); - files.reveal_path(Path::new("a.txt")); - editor.handle(Input::Ctrl('y')); - assert_eq!(editor.diff().unwrap().file, 0); - assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); - editor.handle(Input::Char('Q')); - assert_eq!(editor.file_picker().unwrap().query, "Q"); - assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); - editor.handle(Input::Ctrl('u')); - let files = editor.diff.as_mut().unwrap().files.as_mut().unwrap(); - files.set_view(FileView::Tree); - files.start_search(); - editor.handle(Input::Char('Q')); - assert_eq!(editor.file_picker().unwrap().query, "Q"); - assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); - editor.handle(Input::Ctrl('y')); - assert_eq!(editor.message(), "No changed file is selected"); - assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); - editor.file_picker = Some( - FilePicker::scan( - directory.0.clone(), - FilePresentation::Docked, - FileView::List, - ) - .unwrap(), - ); - editor.execute("diff.review.toggle"); - assert_eq!(editor.message(), "Select a file in the comparison"); - assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); - } - - #[test] - fn working_review_marks_survive_save_and_clear_only_the_edited_file() { - let directory = Directory::new(); - let first = directory.path("first.txt"); - fs::write(&first, "base\n").unwrap(); - fs::write(directory.path("second.txt"), "second").unwrap(); - let mut editor = Editor::open(Some(first.clone()), vec![]).unwrap(); - working_diff(&mut editor, &directory.0, &["first.txt", "second.txt"]); - editor.execute("diff.review.toggle"); - editor - .diff - .as_mut() - .unwrap() - .files - .as_mut() - .unwrap() - .reveal_path(Path::new("second.txt")); - editor.execute("diff.review.toggle"); - assert_eq!( - editor.diff().unwrap().reviewed_files, - BTreeSet::from([0, 1]) - ); - editor.handle(Input::BackTab); - assert!(editor.diff_editing()); - editor.execute("w"); - assert_eq!( - editor.diff().unwrap().reviewed_files, - BTreeSet::from([0, 1]) - ); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); - editor.execute("diff.review.toggle"); - editor.execute("w"); - assert_eq!( - editor.diff().unwrap().reviewed_files, - BTreeSet::from([0, 1]) - ); - assert_eq!(fs::read_to_string(&first).unwrap(), "Xbase\n"); - editor.handle(Input::Char('u')); - assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); - } - - #[test] - fn diff_display_defaults_tabs_and_remaps_preserve_native_snapshot_carets() { - let directory = Directory::new(); - let mut editor = editor(); - editor.execute("diff.view.toggle"); - assert_eq!(editor.message(), "No comparison is open"); - editor.set_diff_display_options(ViewMode::Unified, SplitAxis::Horizontal); - editor.execute("diff"); - type_text(&mut editor, " v"); - assert_eq!(editor.diff().unwrap().query, " v"); - assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); - historical_diff( - &mut editor, - &directory.0, - vec!["old one".into(), "old two".into()], - vec!["new one".into(), "new two".into()], - ); - let first_tab = editor.tab_id(); - let buffer = editor.buffer_id(); - editor.handle(Input::Char('j')); - let caret = editor.cursor(); - let text = editor.lines().to_vec(); - assert!(!editor.diff_editing()); - assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); - editor.execute("diff.orientation.toggle"); - assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); - editor - .bind("normal", "Q", Some("core.diff.view.toggle".into())) - .unwrap(); - assert!( - editor - .bind("insert", "Q", Some("core.diff.view.toggle".into())) - .is_err() - ); - editor.handle(Input::Char('Q')); - assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); - editor - .bind("normal", "W", Some("core.diff.orientation.toggle".into())) - .unwrap(); - editor.handle(Input::Char('W')); - assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Vertical); - type_text(&mut editor, " v"); - assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); - assert_eq!(editor.buffer_id(), buffer); - assert_eq!(editor.cursor(), caret); - assert_eq!(editor.lines(), text); - editor.set_diff_display_options(ViewMode::Split, SplitAxis::Horizontal); - assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "diff.view.toggle"); - assert!( - editor - .palette() - .unwrap() - .entries - .iter() - .any(|entry| entry.name == "core.diff.view.toggle" && entry.enabled) - ); - editor.handle(Input::Enter); - assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); - editor.execute("tab.new"); - historical_diff( - &mut editor, - &directory.0, - vec!["old".into()], - vec!["new".into()], - ); - assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); - assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); - editor.execute("tab.previous"); - assert_eq!(editor.tab_id(), first_tab); - assert_eq!(editor.buffer_id(), buffer); - assert_eq!(editor.cursor(), caret); - assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Vertical); - } - - #[test] - fn diff_display_actions_keep_working_edits_files_and_filter_input_native() { - let directory = Directory::new(); - let first = directory.path("first.txt"); - fs::write(&first, "base").unwrap(); - fs::write(directory.path("second.txt"), "second").unwrap(); - let mut editor = Editor::open(Some(first), vec![]).unwrap(); - let working = editor.buffer_id(); - working_diff(&mut editor, &directory.0, &["first.txt", "second.txt"]); - assert_eq!(editor.diff().unwrap().focus, Focus::Files); - type_text(&mut editor, " s"); - assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); - type_text(&mut editor, " v"); - assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); - editor.handle(Input::Char('i')); - type_text(&mut editor, " v"); - assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); - assert_eq!(editor.file_picker().unwrap().query, " v"); - editor.handle(Input::Escape); - editor.handle(Input::BackTab); - assert!(editor.diff_editing()); - assert_eq!(editor.buffer_id(), working); - type_text(&mut editor, "i dv"); - assert_eq!(editor.lines(), [" dvbase"]); - editor.handle(Input::Escape); - assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); - assert!(editor.diff().unwrap().after_display_row(0).is_some()); - let caret = editor.cursor(); - type_text(&mut editor, " v"); - assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); - assert_eq!(editor.cursor(), caret); - assert!(editor.diff_editing()); - assert!(editor.diff.as_mut().unwrap().select_file_index(1)); - editor.bind_diff_buffer(); - assert_eq!(editor.diff().unwrap().file, 1); - assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); - assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); - assert_eq!(editor.diff().unwrap().focus, Focus::After); - type_text(&mut editor, " v"); - assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); - assert!(editor.diff_editing()); - assert_eq!(editor.lines(), ["second"]); - } - - #[test] - fn word_wrap_toggle_retains_buffers_and_readonly_diff_state() { - let directory = Directory::new(); - let mut editor = editor(); - assert!(!editor.word_wrap()); - editor - .bind("normal", "W", Some("core.word_wrap.toggle".into())) - .unwrap(); - editor.handle(Input::Char('W')); - assert!(editor.word_wrap()); - let original = editor.buffer_id(); - editor.execute(&format!("edit {}", directory.path("other").display())); - assert!(!editor.word_wrap()); - editor.execute(&format!("buffer {original}")); - assert!(editor.word_wrap()); - editor.execute("word_wrap.toggle unexpected"); - assert!(editor.word_wrap()); - editor.execute("word_wrap.toggle"); - assert!(!editor.word_wrap()); - editor.execute("word_wrap.toggle"); - historical_diff( - &mut editor, - &directory.0, - vec!["alpha beta".into()], - vec!["alpha changed".into()], - ); - assert!(editor.word_wrap()); - editor - .bind("visual", "W", Some("core.word_wrap.toggle".into())) - .unwrap(); - type_text(&mut editor, "v2l"); - let text = editor.lines().to_vec(); - let selections = editor.selections(); - let cursor = editor.cursor(); - let revision = editor.active.text_revision; - let history = (editor.active.undo.len(), editor.active.redo.len()); - let comparison = editor.diff().unwrap().comparison_id; - editor.execute("word_wrap.toggle unexpected"); - assert!(editor.word_wrap()); - editor.handle(Input::Char('W')); - assert!(!editor.word_wrap()); - assert_eq!(editor.lines(), text); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.cursor(), cursor); - assert_eq!(editor.active.text_revision, revision); - assert_eq!( - (editor.active.undo.len(), editor.active.redo.len()), - history - ); - assert_eq!(editor.diff().unwrap().comparison_id, comparison); - assert_eq!(editor.diff().unwrap().phase, Phase::View); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "word_wrap.toggle"); - editor.handle(Input::Enter); - assert!(editor.word_wrap()); - editor.execute("diff.close"); - assert!(editor.word_wrap()); - } - - #[test] - fn diff_readonly_panes_dispatch_prompts_palette_and_picker_remaps_without_edits() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "base").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - working_diff(&mut editor, &directory.0, &["file.txt"]); - editor.handle(Input::Char('\t')); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); - type_text(&mut editor, "idd"); - assert_eq!(editor.lines(), ["base"]); - editor.execute("w"); - assert_eq!(fs::read_to_string(&path).unwrap(), "base"); - assert!( - editor - .apply(vec![Effect::Insert("blocked".into())]) - .is_err() - ); - editor - .bind("normal", "ctrl+e", Some("core.right".into())) - .unwrap(); - editor.handle(Input::Ctrl('e')); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); - editor - .bind("normal", "leader f", Some("core.file_picker".into())) - .unwrap(); - type_text(&mut editor, " f"); - assert_eq!( - editor.file_picker().unwrap().presentation, - FilePresentation::Floating - ); - editor.handle(Input::Escape); - editor.handle(Input::Escape); - assert!(editor.file_picker().is_none()); - assert!(editor.diff().is_some()); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "diff.close"); - editor.handle(Input::Enter); - assert!(editor.diff().is_none()); - editor.execute("diff"); - type_text(&mut editor, ":diffclose\n"); - assert!(editor.diff().is_some()); - editor.execute("diff.close"); - assert!(editor.diff().is_none()); - } - - #[test] - fn diff_wheel_scroll_does_not_move_or_edit_insert_caret_and_plugin_edits_refresh() { - let directory = Directory::new(); - let path = directory.path("file.txt"); - fs::write(&path, "first\nsecond\n").unwrap(); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - working_diff(&mut editor, &directory.0, &["file.txt"]); - editor.handle(Input::BackTab); - editor.handle(Input::Char('i')); - let caret = editor.cursor(); - editor.scroll_diff(Focus::After, true); - assert_eq!(editor.cursor(), caret); - assert!(!editor.diff().unwrap().follow_caret); - assert_eq!(editor.lines(), ["first", "second", ""]); - editor.apply(vec![Effect::Insert("plugin".into())]).unwrap(); - assert_eq!( - editor.diff().unwrap().comparison.as_ref().unwrap().files[0].after, - editor.lines() - ); - assert_eq!( - editor.diff().unwrap().live_revision, - Some(editor.text_revision()) - ); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), ["first", "second", ""]); - editor.execute("diff.close"); - } - - #[cfg(unix)] - #[test] - fn working_diff_rejects_symlinks_and_paths_escaping_repository_root() { - use std::os::unix::fs::symlink; - let directory = Directory::new(); - let outside = Directory::new(); - let main = directory.path("main.txt"); - let target = outside.path("target.txt"); - fs::write(&main, "main").unwrap(); - fs::write(&target, "outside").unwrap(); - symlink(&target, directory.path("link.txt")).unwrap(); - symlink(&outside.0, directory.path("escape")).unwrap(); - let mut editor = Editor::open(Some(main), vec![]).unwrap(); - for path in ["link.txt", "escape/target.txt"] { - working_diff(&mut editor, &directory.0, &[path]); - assert!(!editor.diff_editable()); - assert_eq!(editor.buffer_count(), 1); - editor.handle(Input::BackTab); - type_text(&mut editor, "iX"); - editor.execute("w"); - assert_eq!(fs::read_to_string(&target).unwrap(), "outside"); - editor.execute("diff.close"); - assert_eq!(editor.lines(), ["main"]); - } - } - - #[test] - fn selecting_removed_or_historical_diff_file_never_opens_an_unrelated_buffer() { - let directory = Directory::new(); - let main = directory.path("main.txt"); - let other = directory.path("other.txt"); - fs::write(&main, "main").unwrap(); - fs::write(&other, "other").unwrap(); - let mut editor = Editor::open(Some(main), vec![]).unwrap(); - working_diff(&mut editor, &directory.0, &["main.txt", "other.txt"]); - let diff = editor.diff.as_mut().unwrap(); - let comparison = diff.comparison.as_mut().unwrap(); - comparison.editable_paths.remove(Path::new("other.txt")); - comparison.files[1].status = crate::diff::FileStatus::Removed; - comparison.files[1].after.clear(); - assert!(diff.select_file(Path::new("other.txt"))); - editor.handle(Input::BackTab); - assert!(!editor.diff_editable()); - assert_eq!(editor.buffer_count(), 1); - type_text(&mut editor, "iX"); - editor.execute("w"); - assert_eq!(fs::read_to_string(&other).unwrap(), "other"); - let diff = editor.diff.as_mut().unwrap(); - diff.after.as_mut().unwrap().target = RevisionTarget::Commit("old".into()); - assert!(diff.select_file(Path::new("main.txt"))); - editor.handle(Input::Char('i')); - assert!(!editor.diff_editable()); - assert_eq!(editor.buffer_count(), 1); - editor.execute("diff.close"); - assert_eq!(editor.lines(), ["main"]); - } - - #[test] - fn line_actions_counted_delete_restores_caret_and_pastes_whole_rows() { - let mut editor = editor(); - let source = "zero\n αone\n\tlast\nend"; - editor.apply(vec![Effect::Replace(source.into())]).unwrap(); - type_text(&mut editor, "jll2dd"); - assert_eq!(editor.context().text, "zero\nend"); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - type_text(&mut editor, "u"); - assert_eq!(editor.context().text, source); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); - type_text(&mut editor, "UP"); - assert_eq!(editor.context().text, source); - } - - #[test] - fn line_actions_counted_change_groups_typing_and_repeats_resolved_rows() { - let mut editor = editor(); - let source = "keep\n one\ntwo\nend"; - editor.apply(vec![Effect::Replace(source.into())]).unwrap(); - type_text(&mut editor, "j2cc"); - assert_eq!(editor.context().text, "keep\n\nend"); - assert_eq!(editor.mode(), Mode::Insert); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - type_text(&mut editor, "new"); - editor.handle(Input::Escape); - type_text(&mut editor, "u"); - assert_eq!(editor.context().text, source); - type_text(&mut editor, "Uj."); - assert_eq!(editor.context().text, "keep\nnew\nnew"); - assert_eq!(editor.mode(), Mode::Normal); - type_text(&mut editor, "u"); - assert_eq!(editor.context().text, "keep\nnew\nend"); - } - - #[test] - fn line_actions_eof_empty_rows_and_repeat_keep_the_requested_count() { - for (source, keys, expected) in [ - ("a\nb", "jdd", "a"), - ("a\nb\n", "jdd", "a\n"), - ("a\nb\n", "gbdd", "a\nb"), - ("a", "dd", ""), - ("", "dd", ""), - ("a\nb\nc", "2dd", "c"), - ("a\nb", "jcc", "a\n"), - ] { - let mut editor = editor(); - editor.apply(vec![Effect::Replace(source.into())]).unwrap(); - type_text(&mut editor, keys); - assert_eq!(editor.context().text, expected, "{keys} on {source:?}"); - if editor.mode() == Mode::Insert { - editor.handle(Input::Escape); - } - if source != expected { - type_text(&mut editor, "u"); - assert_eq!(editor.context().text, source); - } - } - let directory = Directory::new(); - let target = directory.path("repeat"); - fs::write(&target, "1\n2\n3\n4\n5").unwrap(); - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a\nb\nc".into())]) - .unwrap(); - type_text(&mut editor, "gb3dd"); - assert_eq!(editor.context().text, "a\nb"); - editor.execute(&format!("edit {}", target.display())); - type_text(&mut editor, "."); - assert_eq!(editor.context().text, "4\n5"); - type_text(&mut editor, "u"); - assert_eq!(editor.context().text, "1\n2\n3\n4\n5"); - type_text(&mut editor, &format!("{}dd", usize::MAX)); - assert_eq!(editor.context().text, ""); - } - - #[test] - fn line_actions_merge_overlaps_but_keep_adjacent_change_carets() { - let mut editor = editor(); - let source = "aa\nbb\ncc\ndd\nee"; - editor.apply(vec![Effect::Replace(source.into())]).unwrap(); - editor.handle(Input::Ctrl('e')); - let carets = editor.carets(); - type_text(&mut editor, "2dd"); - assert_eq!(editor.context().text, "dd\nee"); - type_text(&mut editor, "u"); - assert_eq!(editor.context().text, source); - assert_eq!(editor.carets(), carets); - type_text(&mut editor, "UP"); - assert_eq!(editor.context().text, source); - editor.handle(Input::Escape); - editor.handle(Input::Ctrl('e')); - type_text(&mut editor, "ccX"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "X\nX\ncc\ndd\nee"); - type_text(&mut editor, "u"); - assert_eq!(editor.context().text, source); - } - - #[test] - fn line_actions_prefix_cancellation_bindings_and_insert_literals_are_independent() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) - .unwrap(); - editor.handle(Input::Ctrl('e')); - let carets = editor.carets(); - type_text(&mut editor, "2d"); - editor.handle(Input::Escape); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.context().text, "a\nb\nc\nd"); - editor.handle(Input::Escape); - type_text(&mut editor, "gtdj"); - assert_eq!(editor.cursor().row, 1); - editor.bind("normal", "d", None).unwrap(); - type_text(&mut editor, "dd"); - assert_eq!(editor.context().text, "a\nb\nc\nd"); - editor - .bind("normal", "z", Some("core.delete_line".into())) - .unwrap(); - type_text(&mut editor, "2z"); - assert_eq!(editor.context().text, "a\nd"); - editor - .bind("normal", "q", Some("core.change_line".into())) - .unwrap(); - type_text(&mut editor, "qccdd"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "a\nccdd"); - } - fn project_editor(directory: &Directory) -> Editor { - let mut editor = editor(); - editor.project_root = directory.0.clone(); - editor - } - - #[test] - fn jump_history_follows_links_and_restores_primary_grapheme_locations() { - let directory = Directory::new(); - let source = directory.path("source.md"); - let target = directory.path("target.md"); - fs::write(&source, "intro\n[go](target.md#destination)").unwrap(); - fs::write(&target, "intro\n\n# Destination").unwrap(); - let mut editor = Editor::open(Some(source), vec![]).unwrap(); - type_text(&mut editor, "jl"); - let departure = editor.cursor(); - let source_id = editor.buffer_id(); - type_text(&mut editor, "gf"); - let target_id = editor.buffer_id(); - assert_ne!(source_id, target_id); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - assert_eq!(editor.jumps.len(), 2); - fs::remove_file(target).unwrap(); - editor.handle(Input::Ctrl('O')); - assert_eq!(editor.buffer_id(), source_id); - assert_eq!(editor.cursor(), departure); - editor.handle(Input::Ctrl('I')); - assert_eq!(editor.buffer_id(), target_id); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - assert_eq!(editor.lines(), ["intro", "", "# Destination"]); - assert_eq!(editor.buffer_count(), 2); - } - - #[test] - fn jump_history_records_same_file_fragments_once() { - for link in ["#destination", "source.md#destination"] { - let directory = Directory::new(); - let source = directory.path("source.md"); - fs::write(&source, format!("[go]({link})\n\n# Destination")).unwrap(); - let mut editor = Editor::open(Some(source), vec![]).unwrap(); - type_text(&mut editor, "gf"); - assert_eq!(editor.buffer_count(), 1); - assert_eq!(editor.cursor().row, 2); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.cursor(), Cursor::default()); - let targets = editor.active.view.targets.clone(); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.active.view.targets, targets); - editor.handle(Input::Char('\t')); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - } - } - - #[test] - fn jump_history_records_edit_buffer_neighbors_and_picker_transactions() { - let directory = Directory::new(); - let a = directory.path("a.txt"); - let b = directory.path("b.txt"); - fs::write(&a, "alpha").unwrap(); - fs::write(&b, "bravo").unwrap(); - fs::write(directory.path("c.txt"), "charlie").unwrap(); - let mut editor = Editor::open(Some(a), vec![]).unwrap(); - editor.project_root = directory.0.clone(); - type_text(&mut editor, "ll"); - editor.execute(&format!("edit {}", b.display())); - assert_eq!(editor.jumps.len(), 2); - editor.execute("bnext"); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.cursor().column, 2); - editor.execute("bprev"); - assert_eq!(editor.buffer_id(), 2); - editor.execute("buffer 1"); - assert_eq!(editor.jumps.len(), 5); - editor.execute("buffer 1"); - assert_eq!(editor.jumps.len(), 5); - editor.set_file_picker_view("list").unwrap(); - type_text(&mut editor, " oc.txt\n"); - assert_eq!(editor.buffer_id(), 3); - assert_eq!(editor.jumps.len(), 6); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.cursor().column, 2); - type_text(&mut editor, "3"); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.buffer_id(), 2); - type_text(&mut editor, "4"); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.buffer_id(), 3); - } - - #[test] - fn jump_history_records_search_without_losing_query_or_highlight_invariants() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("hit\n中👩‍💻 hit\nhit".into())]) - .unwrap(); - type_text(&mut editor, "/hit\nn"); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 3 }); - type_text(&mut editor, "nN"); - assert_eq!(editor.jumps.len(), 4); - editor.handle(Input::Right); - let departure = editor.cursor(); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - assert!(editor.search_match().is_none()); - assert_eq!(editor.active.view.search_query, "hit"); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.cursor(), departure); - type_text(&mut editor, "2N"); - assert_eq!(editor.cursor(), Cursor::default()); - } - - #[test] - fn jump_history_saves_roaming_departures_in_both_directions() { - let directory = Directory::new(); - let a = directory.path("a.txt"); - let b = directory.path("b.txt"); - fs::write(&a, "abcdef").unwrap(); - fs::write(&b, "first\nabcdef").unwrap(); - let mut editor = Editor::open(Some(a), vec![]).unwrap(); - editor - .navigate_to(b, TextPosition { row: 1, column: 0 }) - .unwrap(); - type_text(&mut editor, "ll"); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.buffer_id(), 1); - type_text(&mut editor, "lll"); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.buffer_id(), 2); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); - } - - #[test] - fn jump_history_branches_only_after_successful_distinct_jumps() { - let directory = Directory::new(); - let path = directory.path("text.txt"); - fs::write(&path, "zero\none\ntwo\nthree").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - for row in [1, 2] { - editor - .navigate_to(path.clone(), TextPosition { row, column: 0 }) - .unwrap(); - } - editor.handle(Input::Ctrl('o')); - editor - .navigate_to(path.clone(), TextPosition { row: 1, column: 0 }) - .unwrap(); - editor - .navigate_to( - directory.path("missing"), - TextPosition { row: 0, column: 0 }, - ) - .unwrap_err(); - editor.execute("buffer 999"); - editor.execute("edit"); - editor.execute("follow_link"); - type_text(&mut editor, "/missing\n"); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.cursor().row, 2); - editor.handle(Input::Ctrl('o')); - editor.handle(Input::Right); - editor - .navigate_to(path, TextPosition { row: 3, column: 0 }) - .unwrap(); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.cursor().row, 3); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); - } - - #[test] - fn jump_history_is_bounded_and_counts_stop_at_available_locations() { - let directory = Directory::new(); - let path = directory.path("text.txt"); - fs::write( - &path, - (0..=120) - .map(|n| n.to_string()) - .collect::>() - .join("\n"), - ) - .unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - for row in 1..=120 { - editor - .navigate_to(path.clone(), TextPosition { row, column: 0 }) - .unwrap(); - } - type_text(&mut editor, "2"); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.cursor().row, 118); - type_text(&mut editor, &usize::MAX.to_string()); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.cursor().row, 21); - let targets = editor.active.view.targets.clone(); - let revision = editor.text_revision(); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.text_revision(), revision); - type_text(&mut editor, &usize::MAX.to_string()); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.cursor().row, 120); - } - - #[test] - fn jump_history_skips_closed_buffers_without_reopening_paths() { - let directory = Directory::new(); - let mut editor = editor(); - for name in ["b", "c"] { - let path = directory.path(name); - fs::write(&path, name).unwrap(); - editor.execute(&format!("edit {}", path.display())); - } - editor.execute("bdelete 2"); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.buffer_id(), 1); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.buffer_id(), 3); - assert_eq!(editor.buffer_count(), 2); - editor.execute("bdelete"); - let targets = editor.active.view.targets.clone(); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.buffer_count(), 1); - assert_eq!(editor.active.view.targets, targets); - } - - #[test] - fn deleting_older_jump_buffers_preserves_the_current_position_and_forward_branch() { - let directory = Directory::new(); - let mut editor = editor(); - for name in ["b", "c"] { - let path = directory.path(name); - fs::write(&path, name).unwrap(); - editor - .navigate_to(path, TextPosition { row: 0, column: 0 }) - .unwrap(); - } - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.context().text, "b"); - editor.execute("bdelete 1"); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.context().text, "c"); - editor.handle(Input::Ctrl('o')); - let path = directory.path("d"); - fs::write(&path, "d").unwrap(); - editor - .navigate_to(path, TextPosition { row: 0, column: 0 }) - .unwrap(); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.context().text, "d"); - type_text(&mut editor, &usize::MAX.to_string()); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.context().text, "b"); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.context().text, "d"); - } - - #[test] - fn jump_history_clamps_edited_locations_and_preserves_dirty_text_and_undo() { - let directory = Directory::new(); - let a = directory.path("a.txt"); - let b = directory.path("b.txt"); - fs::write(&a, "original\nabcdef").unwrap(); - fs::write(&b, "other").unwrap(); - let mut editor = Editor::open(Some(a), vec![]).unwrap(); - type_text(&mut editor, "jlllll"); - editor - .navigate_to(b, TextPosition { row: 0, column: 0 }) - .unwrap(); - editor - .parked - .get_mut(&1) - .unwrap() - .replace_lines(vec!["中👩‍💻".into()]); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); - assert!(editor.dirty()); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - let text = editor.context().text; - let undo = editor.active.undo.len(); - let revision = editor.text_revision(); - editor.handle(Input::Ctrl('i')); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.context().text, text); - assert_eq!(editor.active.undo.len(), undo); - assert_eq!(editor.text_revision(), revision); - assert!(editor.dirty()); - type_text(&mut editor, "u"); - assert_eq!(editor.context().text, "中👩‍💻"); - let redo = editor.active.redo.len(); - editor.handle(Input::Ctrl('i')); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.active.redo.len(), redo); - type_text(&mut editor, "U"); - assert_eq!(editor.context().text, text); - } - - #[test] - fn jump_history_distinguishes_tabs_and_panes_at_identical_buffer_coordinates() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("shared".into())]) - .unwrap(); - let source = editor.jump_location(); - editor.execute("tab.new"); - let tab = editor.tab_id(); - let tab_pane = editor.pane_id(); - editor.record_jump(source); - editor.execute("pane.split_vertical"); - let pane = editor.pane_id(); - editor.record_jump(JumpLocation { - tab_id: tab, - pane_id: tab_pane, - ..editor.jump_location() - }); - editor.handle(Input::Ctrl('o')); - assert_eq!((editor.tab_id(), editor.pane_id()), (tab, tab_pane)); - editor.handle(Input::Ctrl('o')); - assert_eq!( - (editor.tab_id(), editor.pane_id()), - (source.tab_id, source.pane_id) - ); - type_text(&mut editor, "2"); - editor.handle(Input::Ctrl('i')); - assert_eq!((editor.tab_id(), editor.pane_id()), (tab, pane)); - assert_eq!(editor.context().text, "shared"); - assert!(editor.dirty()); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - } - - #[test] - fn jump_history_closed_tabs_and_panes_use_surviving_ordinary_buffer_views() { - for close in ["tab.close", "pane.close"] { - let directory = Directory::new(); - let path = directory.path("target.txt"); - fs::write(&path, "target").unwrap(); - let mut editor = editor(); - editor.execute(if close == "tab.close" { - "tab.new" - } else { - "pane.split_vertical" - }); - let closed = (editor.tab_id(), editor.pane_id()); - editor - .navigate_to(path.clone(), TextPosition { row: 0, column: 2 }) - .unwrap(); - editor.handle(Input::Ctrl('o')); - editor.execute(close); - fs::remove_file(&path).unwrap(); - editor.handle(Input::Ctrl('i')); - assert_ne!((editor.tab_id(), editor.pane_id()), closed); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); - assert_eq!(editor.context().text, "target"); - assert_eq!(editor.tab_count(), 1); - assert_eq!(editor.pane_layout().len(), 1); - assert_eq!(editor.buffer_count(), 2); - } - } - - #[test] - fn jump_history_closed_comparison_falls_back_without_reopening_or_losing_readonly_identity() { - let directory = Directory::new(); - let mut editor = editor(); - historical_diff( - &mut editor, - &directory.0, - vec!["before".into()], - vec!["after".into()], - ); - let snapshot = editor.buffer_id(); - let document = editor.active.snapshot_document_path.clone(); - let source = editor.jump_location(); - editor.execute("tab.new"); - editor.record_jump(source); - editor.execute("tab.previous"); - editor.execute("diff.close"); - editor.execute("tab.next"); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.tab_id(), source.tab_id); - assert_eq!(editor.buffer_id(), snapshot); - assert_eq!(editor.active.snapshot_document_path, document); - assert!(editor.diff().is_none()); - type_text(&mut editor, "ix"); - assert_eq!(editor.context().text, "before"); - assert!(!editor.dirty()); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.tab_count(), 2); - assert_ne!(editor.tab_id(), source.tab_id); - } - - #[test] - fn jump_history_never_replaces_a_new_comparison_in_an_old_destination_view() { - let directory = Directory::new(); - let target = directory.path("external.txt"); - fs::write(&target, "external").unwrap(); - let mut editor = editor(); - fs::write(directory.path("file.txt"), "working source").unwrap(); - working_diff(&mut editor, &directory.0, &["file.txt"]); - let source_buffer = editor.buffer_id(); - editor - .navigate_lsp_to(target, lsp_point(0, 2), None) - .unwrap(); - let external_tab = editor.tab_id(); - let external_buffer = editor.buffer_id(); - editor.execute("tab.previous"); - editor.execute("diff.close"); - historical_diff( - &mut editor, - &directory.0, - vec!["new before".into()], - vec!["new after".into()], - ); - let comparison = editor.diff().unwrap().comparison_id; - let snapshot = editor.buffer_id(); - let cursor = editor.cursor(); - editor.execute("tab.next"); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.tab_id(), external_tab); - assert_eq!(editor.buffer_id(), source_buffer); - assert_eq!(editor.context().text, "working source"); - assert!(editor.diff().is_none()); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.buffer_id(), external_buffer); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); - editor.execute("tab.previous"); - assert_eq!(editor.diff().unwrap().comparison_id, comparison); - assert_eq!(editor.buffer_id(), snapshot); - assert_eq!(editor.cursor(), cursor); - assert_eq!(editor.context().text, "new before"); - } - - #[test] - fn jump_history_skips_missing_contexts_without_mutating_the_only_comparison() { - let directory = Directory::new(); - let path = directory.path("old.txt"); - fs::write(&path, "old").unwrap(); - let mut editor = editor(); - editor - .navigate_to(path, TextPosition { row: 0, column: 1 }) - .unwrap(); - let source_buffer = editor.buffer_id(); - historical_diff( - &mut editor, - &directory.0, - vec!["before".into()], - vec!["after".into()], - ); - let comparison = editor.diff().unwrap().comparison_id; - let snapshot = editor.buffer_id(); - let cursor = editor.cursor(); - type_text(&mut editor, &usize::MAX.to_string()); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.buffer_id(), snapshot); - assert_eq!(editor.cursor(), cursor); - assert_eq!(editor.diff().unwrap().comparison_id, comparison); - assert!(editor.has_buffer(source_buffer)); - assert_eq!(editor.tab_count(), 1); - } - - #[test] - fn comparison_tab_identity_includes_selector_and_parked_pane_states() { - let mut editor = editor(); - assert!(!editor.tab_is_diff(0)); - assert!(!editor.tab_is_diff(1)); - editor.execute("diff"); - assert!(editor.tab_is_diff(0)); - editor.execute("pane.split_vertical"); - assert!(editor.diff().is_none()); - assert!(editor.tab_is_diff(0)); - editor.execute("tab.new"); - assert!(editor.tab_is_diff(0)); - assert!(!editor.tab_is_diff(1)); - editor.execute("tab.previous"); - editor.execute("diff.close"); - assert!(!editor.tab_is_diff(0)); - } - - #[test] - fn jump_history_skips_entries_clamped_to_the_current_location() { - let directory = Directory::new(); - let path = directory.path("text.txt"); - fs::write(&path, "zero\none\ntwo").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - for row in [1, 2] { - editor - .navigate_to(path.clone(), TextPosition { row, column: 0 }) - .unwrap(); - } - editor.apply(vec![Effect::Replace("x".into())]).unwrap(); - let targets = editor.active.view.targets.clone(); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.cursor(), Cursor::default()); - } - - #[test] - fn jump_history_counts_revisit_older_entries_at_the_same_coordinates() { - let directory = Directory::new(); - let path = directory.path("text.txt"); - fs::write(&path, "zero\none").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - for row in [1, 0] { - editor - .navigate_to(path.clone(), TextPosition { row, column: 0 }) - .unwrap(); - } - type_text(&mut editor, "2"); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.cursor().row, 0); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.cursor().row, 1); - } - - #[test] - fn failed_jump_traversal_preserves_multiple_carets_search_and_edit_state() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("hit\nhit".into())]) - .unwrap(); - type_text(&mut editor, "/hit\nn"); - editor.handle(Input::Ctrl('k')); - assert_eq!(editor.carets().len(), 2); - let targets = editor.active.view.targets.clone(); - let primary = editor.active.view.primary; - let search = editor.search_match(); - let undo = editor.active.undo.len(); - let revision = editor.text_revision(); - let timeline = editor.jumps.clone(); - for input in [Input::Ctrl('i'), Input::Ctrl('o')] { - editor.handle(input); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.active.view.primary, primary); - assert_eq!(editor.search_match(), search); - assert_eq!(editor.active.view.search_query, "hit"); - assert_eq!(editor.active.undo.len(), undo); - assert_eq!(editor.text_revision(), revision); - assert_eq!(editor.jumps, timeline); - assert_eq!(editor.mode(), Mode::Normal); - assert!(editor.dirty()); - } - } - - #[test] - fn ordinary_motions_edits_and_failed_jumps_do_not_seed_history() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("alpha beta".into())]) - .unwrap(); - type_text(&mut editor, "wlhiX"); - editor.handle(Input::Escape); - type_text(&mut editor, "uU"); - editor.execute("follow_link"); - editor.execute("bnext"); - editor.execute("buffer 999"); - let targets = editor.active.view.targets.clone(); - let revision = editor.text_revision(); - editor.handle(Input::Ctrl('o')); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.text_revision(), revision); - } - - #[test] - fn jump_keys_honor_independent_tab_bindings_and_normal_mode_eligibility() { - let directory = Directory::new(); - let path = directory.path("text.txt"); - fs::write(&path, "zero\none\ntwo").unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor - .navigate_to(path, TextPosition { row: 1, column: 0 }) - .unwrap(); - editor - .bind("normal", "ctrl+o", Some("core.right".into())) - .unwrap(); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); - editor - .bind("normal", "leader [", Some("core.jump_back".into())) - .unwrap(); - type_text(&mut editor, " ["); - assert_eq!(editor.cursor(), Cursor::default()); - editor - .bind("normal", "ctrl+i", Some("core.right".into())) - .unwrap(); - editor.handle(Input::Ctrl('I')); - assert_eq!(editor.cursor().column, 1); - editor.handle(Input::Char('\t')); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); - for mode in ["insert", "visual", "command", "search"] { - for action in ["core.jump_back", "core.jump_forward"] { - assert!(editor.bind(mode, "z", Some(action.into())).is_err()); - } - } - editor.handle(Input::Char('i')); - editor.handle(Input::Ctrl('i')); - editor.handle(Input::Char('\t')); - assert_eq!(editor.lines()[1], "o\t\tne"); - assert_eq!(editor.mode(), Mode::Insert); - editor.handle(Input::Ctrl('p')); - for action in ["core.jump_back", "core.jump_forward"] { - assert!(!palette_entry(&editor, action).enabled); - } - editor.handle(Input::Escape); - editor.handle(Input::Escape); - editor.handle(Input::Ctrl('p')); - assert!(palette_entry(&editor, "core.jump_back").enabled); - assert!(palette_entry(&editor, "core.jump_forward").enabled); - assert!(!palette_entry(&editor, "core.jump_back").bindings.is_empty()); - } - - #[test] - fn follow_link_resolves_relative_paths_from_source_not_cwd_or_project_root() { - let directory = Directory::new(); - let nested = directory.path("notes"); - fs::create_dir(&nested).unwrap(); - let source = nested.join("source.md"); - let target = directory.path("Target Name.MD"); - fs::write(&source, "[go](../Target%20Name.MD#target-heading)").unwrap(); - fs::write(&target, "intro\n\n# Target heading\nbody").unwrap(); - let mut editor = Editor::open(Some(source), vec![]).unwrap(); - assert_ne!(std::env::current_dir().unwrap(), nested); - editor.handle(Input::Char('g')); - assert!(editor.g_pending()); - editor.handle(Input::Char('f')); - assert!(!editor.g_pending()); - assert_eq!(editor.buffer_id(), 2); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - assert_eq!(editor.context().text, "intro\n\n# Target heading\nbody"); - assert_eq!(editor.buffer_count(), 2); - } - - #[test] - fn follow_link_reference_and_duplicate_heading_use_native_ex_command() { - let directory = Directory::new(); - let source = directory.path("source.md"); - let target = directory.path("target.md"); - fs::write( - &source, - "[go][section]\n\n[section]: target.md#hello-world-1", - ) - .unwrap(); - fs::write( - &target, - "# Hello [world](ignored.md)\n\nHello *world*\n=============\n", - ) - .unwrap(); - let mut editor = Editor::open(Some(source), vec![]).unwrap(); - type_text(&mut editor, ":follow_link\n"); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - assert_eq!(editor.lines()[0], "# Hello [world](ignored.md)"); - } - - #[test] - fn follow_link_reuses_dirty_target_even_after_disk_file_disappears() { - let directory = Directory::new(); - let source = directory.path("source.md"); - let target = directory.path("target.md"); - fs::write(&source, "[go](target.md#dirty-heading)").unwrap(); - fs::write(&target, "# Disk heading").unwrap(); - let mut editor = Editor::open(Some(source), vec![]).unwrap(); - editor.execute(&format!("edit {}", target.display())); - let target_id = editor.buffer_id(); - editor - .apply(vec![Effect::Replace("unsaved\n\n# Dirty heading".into())]) - .unwrap(); - let revision = editor.text_revision(); - editor.execute("buffer 1"); - fs::remove_file(&target).unwrap(); - editor - .bind("normal", "z", Some("core.follow_link".into())) - .unwrap(); - assert_eq!(editor.handle(Input::Char('z')), None); - assert_eq!(editor.buffer_id(), target_id); - assert_eq!(editor.buffer_count(), 2); - assert_eq!(editor.context().text, "unsaved\n\n# Dirty heading"); - assert_eq!(editor.text_revision(), revision); - assert!(editor.dirty()); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - } - - #[test] - fn follow_link_failures_leave_source_carets_selections_text_and_buffer_ids_untouched() { - let directory = Directory::new(); - fs::write(directory.path("target.md"), "# Existing").unwrap(); - for (filename, text, command) in [ - ("source.md", "[x](missing.md)", "follow_link"), - ("source.md", "[x](target.md#missing)", "follow_link"), - ("source.md", "[x](#missing)", "follow_link"), - ("source.md", "[missing][undefined]", "follow_link"), - ("source.md", "[x](https://example.com)", "follow_link"), - ("source.md", "not a link", "follow_link"), - ("source.txt", "[x](target.md)", "follow_link"), - ("source.md", "[x](target.md)", "follow_link extra"), - ] { - let source = directory.path(filename); - fs::write(&source, text).unwrap(); - let mut editor = Editor::open(Some(source), vec![]).unwrap(); - type_text(&mut editor, "vl"); - let carets = editor.carets(); - let selections = editor.selections(); - let revision = editor.text_revision(); - assert_eq!(editor.execute(command), None); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.buffer_count(), 1); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.context().text, text); - assert_eq!(editor.text_revision(), revision); - assert!(!editor.message().is_empty()); - if command == "follow_link" { - editor.handle(Input::Escape); - let carets = editor.carets(); - type_text(&mut editor, "gf"); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.buffer_count(), 1); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.context().text, text); - } - editor.execute(&format!("edit {}", directory.path("target.md").display())); - assert_eq!(editor.buffer_id(), 2); - } - } - - #[test] - fn follow_link_same_document_fragments_work_without_named_buffer() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace( - "[go](#hello-world)\n\n# Hello **world**\n".into(), - )]) - .unwrap(); - let revision = editor.text_revision(); - editor - .bind("normal", "leader f", Some("core.follow_link".into())) - .unwrap(); - type_text(&mut editor, " f"); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - assert_eq!(editor.buffer_count(), 1); - assert_eq!(editor.path(), None); - assert_eq!(editor.text_revision(), revision); - assert_eq!( - editor.context().text, - "[go](#hello-world)\n\n# Hello **world**\n" - ); - editor.handle(Input::Home); - editor.handle(Input::Up); - editor.handle(Input::Up); - editor - .apply(vec![Effect::Replace("[go](target.md)".into())]) - .unwrap(); - editor.execute("follow_link"); - assert_eq!(editor.buffer_count(), 1); - assert_eq!(editor.cursor(), Cursor::default()); - assert!(editor.message().contains("named")); - } - - #[test] - fn follow_link_explicit_same_file_path_reuses_current_dirty_buffer() { - let directory = Directory::new(); - let source = directory.path("source.md"); - fs::write(&source, "# Old disk heading").unwrap(); - let mut editor = Editor::open(Some(source), vec![]).unwrap(); - let text = "[go](source.md#dirty-heading)\n\n# Dirty heading"; - editor.apply(vec![Effect::Replace(text.into())]).unwrap(); - let revision = editor.text_revision(); - type_text(&mut editor, "gf"); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.buffer_count(), 1); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - assert_eq!(editor.context().text, text); - assert_eq!(editor.text_revision(), revision); - assert!(editor.dirty()); - } - - #[test] - fn follow_link_uses_only_primary_caret_once() { - let directory = Directory::new(); - let source = directory.path("source.md"); - let target = directory.path("target.md"); - fs::write(&source, "[bad](missing.md)\n[go](target.md)").unwrap(); - fs::write(&target, "# Target").unwrap(); - let mut editor = Editor::open(Some(source), vec![]).unwrap(); - editor.handle(Input::Ctrl('e')); - assert_eq!(editor.carets().len(), 2); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - type_text(&mut editor, "gf"); - assert_eq!(editor.buffer_id(), 2); - assert_eq!(editor.buffer_count(), 2); - assert_eq!(editor.carets(), vec![Cursor::default()]); - assert_eq!(editor.lines(), ["# Target"]); - } - - #[test] - fn follow_link_accepts_nonmarkdown_targets_without_fragments() { - let directory = Directory::new(); - let source = directory.path("source.md"); - let target = directory.path("main.rs"); - fs::write(&source, "[code](main.rs)").unwrap(); - fs::write(&target, "fn main() {}").unwrap(); - let mut editor = Editor::open(Some(source), vec![]).unwrap(); - type_text(&mut editor, "gf"); - assert_eq!(editor.cursor(), Cursor::default()); - assert_eq!(editor.count(), None); - assert_eq!(editor.buffer_count(), 2); - assert_eq!(editor.lines(), ["fn main() {}"]); - } - - #[test] - fn counted_g_reveals_exact_hidden_source_rows_in_readonly_diffs() { - let directory = Directory::new(); - let mut editor = editor(); - let before: Vec<_> = (0..80).map(|row| format!("row {row}")).collect(); - let mut after = before.clone(); - after[40] = "changed".into(); - historical_diff(&mut editor, &directory.0, before, after); - editor.diff.as_mut().unwrap().set_context_options(0, 1); - let text = editor.lines().to_vec(); - type_text(&mut editor, "10g"); - assert_eq!(editor.cursor().row, 9); - let focus = editor.diff_buffer_focus().unwrap(); - assert!( - editor - .diff() - .unwrap() - .source_display_row(focus, 9) - .is_some() - ); - assert!(editor.mutation_blocked()); - assert_eq!(editor.lines(), text); - type_text(&mut editor, "79g"); - assert_eq!(editor.cursor().row, 78); - assert!( - editor - .diff() - .unwrap() - .source_display_row(focus, 78) - .is_some() - ); - } - - #[test] - fn document_navigation_moves_all_carets_to_first_nonblank_without_edit_history() { - let mut editor = editor(); - let text = " e\u{301}top\nmiddle\n\t 界bottom"; - editor.apply(vec![Effect::Replace(text.into())]).unwrap(); - type_text(&mut editor, "j"); - editor.handle(Input::Ctrl('e')); - assert_eq!(editor.selection_count(), 2); - type_text(&mut editor, "gt"); - assert_eq!(editor.carets(), vec![Cursor { row: 0, column: 2 }]); - type_text(&mut editor, "gb"); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 2 }); - assert_eq!(editor.context().text, text); - type_text(&mut editor, "u"); - assert_eq!(editor.context().text, ""); - - for (text, last_row) in [("", 0), ("\n", 1), ("top\n", 1)] { - editor.apply(vec![Effect::Replace(text.into())]).unwrap(); - type_text(&mut editor, "gb"); - assert_eq!( - editor.cursor(), - Cursor { - row: last_row, - column: 0 - } - ); - type_text(&mut editor, "gt"); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); - assert_eq!(editor.context().text, text); - } - } - - #[test] - fn document_navigation_extends_both_visual_shapes_and_escape_cancels_prefix_only() { - for selector in ['v', 'V'] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace(" top\nmiddle\n\t end".into())]) - .unwrap(); - type_text(&mut editor, "jl"); - editor.handle(Input::Char(selector)); - type_text(&mut editor, "gb"); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 2 }); - assert_eq!(editor.visual_linewise(), selector == 'V'); - assert_eq!( - editor.selections(), - vec![if selector == 'V' { - Selection { - start: Cursor { row: 1, column: 0 }, - end: Cursor { row: 2, column: 5 }, - } - } else { - Selection { - start: Cursor { row: 1, column: 1 }, - end: Cursor { row: 2, column: 3 }, - } - }] - ); - let selected = editor.selections(); - type_text(&mut editor, "g"); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selections(), selected); - type_text(&mut editor, "gt"); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); - assert_eq!( - editor.selections(), - vec![if selector == 'V' { - Selection { - start: Cursor { row: 0, column: 0 }, - end: Cursor { row: 2, column: 0 }, - } - } else { - Selection { - start: Cursor { row: 0, column: 2 }, - end: Cursor { row: 1, column: 2 }, - } - }] - ); - assert_eq!(editor.context().text, " top\nmiddle\n\t end"); - } - } - - #[test] - fn document_navigation_prefix_respects_bindings_and_literal_insert_input() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("top\nmiddle\nbottom".into())]) - .unwrap(); - type_text(&mut editor, "jv"); - editor - .bind("visual", "g", Some("core.right".into())) - .unwrap(); - type_text(&mut editor, "gb"); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - assert_eq!(editor.mode(), Mode::Visual); - editor.handle(Input::Escape); - editor.bind("normal", "g", None).unwrap(); - type_text(&mut editor, "gt"); - assert_eq!(editor.cursor().row, 1); - type_text(&mut editor, "igtgb"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "top\ngtgbmiddle\nbottom"); - } - - #[test] - fn document_navigation_actions_remap_and_yield_to_g_as_leader() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("top\nmiddle\nbottom".into())]) - .unwrap(); - editor.set_leader("g").unwrap(); - editor - .bind("normal", "leader t", Some("core.document_end".into())) - .unwrap(); - type_text(&mut editor, "gt"); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - editor - .bind("normal", "h", Some("core.document_start".into())) - .unwrap(); - type_text(&mut editor, "h"); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); - editor - .bind("normal", "g", Some("core.document_end".into())) - .unwrap(); - type_text(&mut editor, "g"); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - type_text(&mut editor, "igtgb"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "top\nmiddle\ngtgbbottom"); - } - - #[test] - fn native_gf_prefix_yields_to_configured_g_commands_and_preserves_counts() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) - .unwrap(); - editor.handle(Input::Ctrl('p')); - assert_eq!( - palette_entry(&editor, "core.follow_link").bindings, - vec!["gf"] - ); - assert!(palette_entry(&editor, "core.follow_link").enabled); - editor.handle(Input::Escape); - editor - .bind("normal", "g", Some("core.down".into())) - .unwrap(); - type_text(&mut editor, "2g"); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - assert_eq!(editor.count(), None); - assert!(!editor.g_pending()); - editor.handle(Input::Ctrl('p')); - assert!( - palette_entry(&editor, "core.follow_link") - .bindings - .is_empty() - ); - editor.handle(Input::Escape); - editor.bind("normal", "g", None).unwrap(); - type_text(&mut editor, "g"); - assert!(!editor.g_pending()); - editor.register(plugin("fixture", &["greet"])).unwrap(); - editor.bind("normal", "g", Some("greet".into())).unwrap(); - type_text(&mut editor, "3"); - assert_eq!( - editor.handle(Input::Char('g')), - Some(Invocation { - plugin: "fixture".into(), - command: "greet".into(), - args: String::new(), - }) - ); - assert_eq!(editor.count(), None); - assert!(!editor.g_pending()); - for mode in ["insert", "visual", "command", "search"] { - assert!( - editor - .bind(mode, "z", Some("core.follow_link".into())) - .is_err() - ); - } - editor.handle(Input::Char('v')); - editor.handle(Input::Ctrl('p')); - assert!(!palette_entry(&editor, "core.follow_link").enabled); - assert!( - palette_entry(&editor, "core.follow_link") - .bindings - .is_empty() - ); - } - - #[test] - fn native_g_prefix_cancels_on_escape_intervening_input_commands_and_lifecycle() { - let directory = Directory::new(); - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a\nb\nc".into())]) - .unwrap(); - type_text(&mut editor, "g"); - editor.handle(Input::Escape); - assert!(!editor.g_pending()); - assert_eq!(editor.count(), None); - type_text(&mut editor, "gj"); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - assert!(!editor.g_pending()); - type_text(&mut editor, "gi"); - assert_eq!(editor.mode(), Mode::Insert); - assert!(!editor.g_pending()); - editor.handle(Input::Char('f')); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "a\nfb\nc"); - for command in ["plugins", "bnext", "bprev", "buffer 1", "bdelete!"] { - editor.handle(Input::Char('g')); - assert!(editor.g_pending()); - editor.execute(command); - assert!(!editor.g_pending()); - } - editor.handle(Input::Char('g')); - editor.handle(Input::Paste("f".into())); - assert!(!editor.g_pending()); - editor.handle(Input::Char('g')); - editor - .navigate_to( - directory.path("missing.md"), - TextPosition { row: 0, column: 0 }, - ) - .unwrap_err(); - assert!(!editor.g_pending()); - editor.handle(Input::Char('g')); - editor - .bind("normal", "z", Some("core.follow_link".into())) - .unwrap(); - assert!(!editor.g_pending()); - editor.set_leader("g").unwrap(); - editor.handle(Input::Char('g')); - assert!(!editor.g_pending()); - assert_eq!(editor.sequence_pending(), Some("g")); - editor.handle(Input::Escape); - editor.handle(Input::Ctrl('p')); - assert_eq!( - palette_entry(&editor, "core.follow_link").bindings, - vec!["z"] - ); - } - - #[test] - fn semantic_snapshots_are_borrowed_buffer_local_and_invalidated_by_text_and_path() { - use crate::plugin::SemanticTokenKind; - let directory = Directory::new(); - let a = directory.path("a.py"); - let b = directory.path("b.py"); - fs::write(&a, "e\u{301}界").unwrap(); - fs::write(&b, "other").unwrap(); - let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - let tokens: Arc<[SemanticToken]> = Arc::from([SemanticToken { - start: TextPosition { row: 0, column: 0 }, - end: TextPosition { row: 0, column: 2 }, - kind: SemanticTokenKind::Variable, - modifiers: 1023, - }]); - assert!( - editor - .replace_semantic_tokens(1, 0, Some(tokens.clone())) - .unwrap() - ); - assert!(std::ptr::eq(editor.semantic_tokens(), tokens.as_ref())); - assert!(!editor.dirty()); - assert!(editor.active.undo.is_empty()); - editor.execute(&format!("edit {}", b.display())); - assert!(editor.semantic_tokens().is_empty()); - assert!( - editor - .replace_semantic_tokens(2, 0, Some(tokens.clone())) - .unwrap() - ); - editor.execute("buffer 1"); - assert!(std::ptr::eq(editor.semantic_tokens(), tokens.as_ref())); - assert!( - !editor - .replace_semantic_tokens(1, 0, Some(tokens.clone())) - .unwrap() - ); - editor.execute("w"); - assert_eq!(editor.semantic_tokens(), tokens.as_ref()); - editor.execute(&format!("w {}", directory.path("renamed.py").display())); - assert_eq!(editor.text_revision(), 0); - assert!(editor.semantic_tokens().is_empty()); - assert!( - editor - .replace_semantic_tokens(1, 0, Some(tokens.clone())) - .unwrap() - ); - editor.handle(Input::Char('x')); - assert!(editor.semantic_tokens().is_empty()); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), ["e\u{301}界"]); - assert!(editor.semantic_tokens().is_empty()); - assert!( - !editor - .replace_semantic_tokens(1, 0, Some(tokens.clone())) - .unwrap() - ); - editor.execute("buffer 2"); - assert_eq!(editor.semantic_tokens(), tokens.as_ref()); - editor.clear_semantic_tokens(); - assert!(editor.semantic_tokens().is_empty()); - editor.execute("buffer 1"); - assert!(editor.semantic_tokens().is_empty()); - } - - #[test] - fn semantic_colors_follow_untouched_text_and_drop_overlapping_edits() { - use crate::plugin::SemanticTokenKind; - let position = |(row, column)| TextPosition { row, column }; - for (before, after, start, end, expected) in [ - ( - "a value z", - "aa value z", - (0, 2), - (0, 7), - Some(((0, 3), (0, 8))), - ), - ( - "a value z", - "value z", - (0, 2), - (0, 7), - Some(((0, 0), (0, 5))), - ), - ( - "a value z", - "a \nvalue z", - (0, 2), - (0, 7), - Some(((1, 0), (1, 5))), - ), - ( - "a\nvalue z", - "avalue z", - (1, 0), - (1, 5), - Some(((0, 1), (0, 6))), - ), - ( - "e\u{301}value", - "e\u{301}界value", - (0, 1), - (0, 6), - Some(((0, 2), (0, 7))), - ), - ( - "x\nvalue", - "x\n👩‍🚀value", - (1, 0), - (1, 5), - Some(((1, 1), (1, 6))), - ), - ( - "head\nvalue\ntail\nlast", - "head\n界\nvalue\ntail\nlast", - (1, 0), - (1, 5), - Some(((2, 0), (2, 5))), - ), - ( - "head\n界\nvalue\ntail", - "head\nvalue\ntail", - (2, 0), - (2, 5), - Some(((1, 0), (1, 5))), - ), - ( - "value z", - "value \nz", - (0, 0), - (0, 5), - Some(((0, 0), (0, 5))), - ), - ("value", "value!", (0, 0), (0, 5), Some(((0, 0), (0, 5)))), - ("value", "valXue", (0, 0), (0, 5), None), - ("value", "", (0, 0), (0, 5), None), - ("value", "VALUE", (0, 0), (0, 5), None), - ("evalue", "e\u{301}value", (0, 0), (0, 1), None), - ] { - let mut editor = editor(); - editor.apply(vec![Effect::Replace(before.into())]).unwrap(); - let revision = editor.text_revision(); - let token = SemanticToken { - start: position(start), - end: position(end), - kind: SemanticTokenKind::Function, - modifiers: 5, - }; - let original: Arc<[SemanticToken]> = Arc::from([token]); - editor - .replace_semantic_tokens(editor.buffer_id(), revision, Some(original.clone())) - .unwrap(); - editor.apply(vec![Effect::Replace(after.into())]).unwrap(); - let expected: Vec<_> = expected - .map(|(start, end)| SemanticToken { - start: position(start), - end: position(end), - ..token - }) - .into_iter() - .collect(); - assert_eq!( - editor.semantic_tokens(), - expected, - "{before:?} -> {after:?}" - ); - assert!( - !editor - .replace_semantic_tokens(editor.buffer_id(), revision, Some(original)) - .unwrap() - ); - assert_eq!(editor.semantic_tokens(), expected); - } - } - - #[test] - fn semantic_validation_rejects_malformed_live_tokens_but_ignores_stale_and_unloaded() { - use crate::plugin::SemanticTokenKind; - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("e\u{301}界\nx".into())]) - .unwrap(); - let revision = editor.text_revision(); - let token = SemanticToken { - start: TextPosition { row: 0, column: 0 }, - end: TextPosition { row: 0, column: 2 }, - kind: SemanticTokenKind::Variable, - modifiers: 0, - }; - let invalid = [ - SemanticToken { - end: TextPosition { row: 0, column: 3 }, - ..token - }, - SemanticToken { - end: token.start, - ..token - }, - SemanticToken { - end: TextPosition { row: 1, column: 1 }, - ..token - }, - SemanticToken { - start: TextPosition { row: 9, column: 0 }, - end: TextPosition { row: 9, column: 1 }, - ..token - }, - SemanticToken { - modifiers: 1024, - ..token - }, - ]; - for token in invalid { - assert!( - editor - .validate_semantic_tokens(1, revision, Some(&[token])) - .is_err() - ); - assert!( - !editor - .validate_semantic_tokens(1, revision - 1, Some(&[token])) - .unwrap() - ); - assert!( - !editor - .validate_semantic_tokens(999, revision, Some(&[token])) - .unwrap() - ); - } - let later = SemanticToken { - start: TextPosition { row: 0, column: 1 }, - ..token - }; - assert!( - editor - .validate_semantic_tokens(1, revision, Some(&[later, token])) - .is_err() - ); - // Raw adjacent LSP tokens may map outward to the same grapheme. - assert!( - editor - .validate_semantic_tokens(1, revision, Some(&[token, token, later])) - .unwrap() - ); - let oversized = vec![token; MAX_EDIT_BYTES / std::mem::size_of::() + 1]; - assert!( - editor - .validate_semantic_tokens(1, revision, Some(&oversized)) - .is_err() - ); - let tokens: Arc<[SemanticToken]> = Arc::from([token]); - editor - .replace_semantic_tokens(1, revision, Some(tokens)) - .unwrap(); - assert!(editor.replace_semantic_tokens(1, revision, None).unwrap()); - assert!(editor.semantic_tokens().is_empty()); - } - - #[test] - fn semantic_column_bounds_preserve_ascii_tabs_and_unicode_graphemes() { - use crate::plugin::SemanticTokenKind; - for text in ["a\tb", "e\u{301}界👩‍🚀"] { - let mut editor = editor(); - editor.apply(vec![Effect::Replace(text.into())]).unwrap(); - let token = SemanticToken { - start: TextPosition { row: 0, column: 0 }, - end: TextPosition { row: 0, column: 3 }, - kind: SemanticTokenKind::Variable, - modifiers: 0, - }; - editor - .replace_semantic_tokens(1, editor.text_revision(), Some(Arc::from([token]))) - .unwrap(); - let outside = SemanticToken { - end: TextPosition { row: 0, column: 4 }, - ..token - }; - assert!( - editor - .replace_semantic_tokens(1, editor.text_revision(), Some(Arc::from([outside]))) - .is_err(), - "{text:?}", - ); - assert_eq!(editor.semantic_tokens(), &[token]); - } - } - - #[test] - fn hover_scroll_and_escape_preserve_carets_and_edits_dismiss_normally() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("alpha\nbeta".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 1), (1, 2)], 1); - let targets = editor.active.view.targets.clone(); - let primary = editor.active.view.primary; - let text = editor.context().text; - let revision = editor.text_revision(); - let history = (editor.active.undo.len(), editor.active.redo.len()); - for command in [ - ":lsp_hover", - ":lsp_definition", - ":lsp_diagnostics", - ":lsp.hover unexpected extra", - ":lsp.definition unexpected extra", - ":lsp.diagnostics unexpected extra", - ] { - assert!(editor.execute(command).is_none(), "{command}"); - } - editor - .bind("normal", "K", Some("core.lsp.hover".into())) - .unwrap(); - type_text(&mut editor, "9"); - assert_eq!( - editor.handle(Input::Char('K')), - Some(Invocation { - plugin: "core".into(), - command: "lsp.hover".into(), - args: String::new(), - }) - ); - assert_eq!(editor.count(), None); - editor - .show_hover("界e\u{301}👩‍🚀\tend\r\nsecond\nlast".into()) - .unwrap(); - assert!(editor.show_hover("\u{1b}[31m".into()).is_err()); - assert_eq!(editor.hover().unwrap().lines().nth(1), Some("second")); - for input in [ - Input::Down, - Input::Ctrl('j'), - Input::Down, - Input::Right, - Input::Ctrl('l'), - ] { - assert_eq!(editor.handle(input), None); - } - assert_eq!( - (editor.hover().unwrap().top, editor.hover().unwrap().left), - (2, 2) - ); - editor.handle(Input::Escape); - assert!(editor.hover().is_none()); - assert!(editor.notification_visible()); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.active.view.primary, primary); - assert_eq!(editor.context().text, text); - assert_eq!(editor.text_revision(), revision); - assert_eq!( - (editor.active.undo.len(), editor.active.redo.len()), - history - ); - assert_eq!(editor.mode(), Mode::Normal); - editor.handle(Input::Escape); - assert!(!editor.notification_visible()); - assert_eq!(editor.active.view.targets, targets); - editor.handle(Input::Escape); - assert_eq!(editor.selection_count(), 1); - editor.show_hover("documentation".into()).unwrap(); - editor.handle(Input::Char('i')); - assert!(editor.hover().is_none()); - assert_eq!(editor.mode(), Mode::Insert); - editor.handle(Input::Char('X')); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "alpha\nbeXta"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, text); - editor.show_hover("documentation".into()).unwrap(); - editor.apply(vec![Effect::Replace("new".into())]).unwrap(); - assert!(editor.hover().is_none()); - } - - #[cfg(unix)] - mod system_clipboard { - use super::*; - use std::os::unix::fs::PermissionsExt; - - struct Fixture(Directory); - - impl Fixture { - fn new() -> Self { - let fixture = Self(Directory::new()); - for name in ["state", "reads", "writes"] { - fs::write(fixture.0.path(name), b"").unwrap(); - } - let quoted = |name: &str| { - format!( - "'{}'", - fixture - .0 - .path(name) - .display() - .to_string() - .replace('\'', "'\\''") - ) - }; - let reader = format!( - "#!/bin/sh\nprintf . >> {}\n[ ! -f {} ] || exit 23\nexec /bin/cat {}\n", - quoted("reads"), - quoted("fail-read"), - quoted("state"), - ); - let writer = format!( - "#!/bin/sh\nprintf . >> {}\n[ ! -f {} ] || exit 23\n/bin/cat > {} || exit 24\nexec /bin/mv {} {}\n", - quoted("writes"), - quoted("fail-write"), - quoted("next"), - quoted("next"), - quoted("state"), - ); - for (name, script) in [("reader", reader), ("writer", writer)] { - let path = fixture.0.path(name); - fs::write(&path, script).unwrap(); - fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); - } - fixture - } - - fn editor(&self) -> Editor { - let mut editor = Editor::open(None, vec!["text".into()]).unwrap(); - editor.set_system_clipboard(SystemClipboard::with_commands( - self.0.path("reader"), - self.0.path("writer"), - )); - editor - } - - fn bytes(&self, name: &str) -> Vec { - fs::read(self.0.path(name)).unwrap() - } - - fn external(&self, text: impl AsRef<[u8]>) { - fs::write(self.0.path("state"), text).unwrap(); - } - - fn fail(&self, name: &str, enabled: bool) { - let path = self.0.path(name); - if enabled { - fs::write(path, b"").unwrap(); - } else { - fs::remove_file(path).unwrap(); - } - } - } - - #[test] - fn line_actions_clipboard_failure_preserves_carets_history_and_repeat() { - let fixture = Fixture::new(); - fixture.external("keep"); - let mut editor = fixture.editor(); - editor - .apply(vec![Effect::Replace("one\ntwo\nthree".into())]) - .unwrap(); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - type_text(&mut editor, "l"); - editor.handle(Input::Ctrl('e')); - let carets = editor.carets(); - let revision = editor.text_revision(); - fixture.fail("fail-write", true); - for keys in [ - "yy", "2y3y", "2dd", "cc", "C", "D", "Y", "s", "d2w", "d2b", "c2w", "c2b", - ] { - type_text(&mut editor, keys); - assert_eq!(editor.context().text, "Xone\ntwo\nthree"); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.text_revision(), revision); - assert_eq!(fixture.bytes("state"), b"keep"); - } - type_text(&mut editor, "u"); - assert_eq!(editor.context().text, "one\ntwo\nthree"); - type_text(&mut editor, "U."); - assert_eq!(editor.context().text, "XXone\ntXwo\nthree"); - } - - #[test] - fn copy_line_publication_failure_keeps_owned_shape_register_and_full_state() { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor - .apply(vec![Effect::Replace("one\ntwo\n".into())]) - .unwrap(); - fixture.fail("fail-write", true); - let initial = state(&editor); - type_text(&mut editor, "yy"); - assert_eq!(state(&editor), initial); - assert!(editor.owned_system_clipboard.is_none()); - fixture.fail("fail-write", false); - type_text(&mut editor, "yy"); - assert_eq!(fixture.bytes("state"), b"one\n"); - assert_eq!( - editor.owned_system_clipboard, - Some(("one\n".into(), vec!["one\n".into()], true)) - ); - editor.handle(Input::Char('j')); - fixture.fail("fail-write", true); - let before = state(&editor); - type_text(&mut editor, "2yy"); - assert_eq!(state(&editor), before); - assert_eq!(fixture.bytes("state"), b"one\n"); - fixture.fail("fail-write", false); - type_text(&mut editor, "jyy"); - assert_eq!(fixture.bytes("state"), b"\n"); - assert_eq!( - editor.owned_system_clipboard, - Some(("\n".into(), vec!["\n".into()], true)) - ); - } - - // Includes every replay-owned state, not the intentionally changed error message. - fn state(editor: &Editor) -> String { - format!( - "{:?}", - ( - ( - &editor.active.lines, - &editor.active.view.targets, - editor.active.view.primary, - editor.active.view.mode, - editor.active.view.linewise, - editor.active.text_revision, - &editor.active.view.search_query, - editor.active.view.search_match, - &editor.active.undo, - &editor.active.redo, - ), - ( - &editor.active.view.insert_start, - &editor.active.view.pending_edit, - &editor.active.view.visual_motions, - &editor.clipboard, - editor.clipboard_linewise, - &editor.owned_system_clipboard, - &editor.last_edit, - editor.clipboard_mode, - ), - ), - ) - } - - #[test] - fn visual_system_paste_replaces_only_after_successful_read_without_publishing() { - for (key, action) in [ - ('p', None), - ('P', None), - ('z', Some("core.paste_system_before")), - ('z', Some("core.paste_system_after")), - ] { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - if let Some(action) = action { - editor.bind("visual", "z", Some(action.into())).unwrap(); - } - editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); - fixture.external("X"); - type_text(&mut editor, "vl"); - fixture.fail("fail-read", true); - fixture.fail("fail-write", true); - let before = state(&editor); - editor.handle(Input::Char(key)); - assert_eq!(state(&editor), before); - fixture.fail("fail-read", false); - editor.handle(Input::Char(key)); - assert_eq!(editor.context().text, "Xc"); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(fixture.bytes("state"), b"X"); - assert!(fixture.bytes("writes").is_empty()); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "abc"); - } - } - - #[test] - fn linewise_owned_system_bytes_keep_shape_external_changes_do_not_guess_it() { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor.apply(vec![Effect::Replace("row".into())]).unwrap(); - type_text(&mut editor, "Vy"); - assert_eq!(fixture.bytes("state"), b"row\n"); - editor.apply(vec![Effect::Replace("X".into())]).unwrap(); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "row\nX"); - fixture.external("row\n"); - editor.apply(vec![Effect::Replace("YZ".into())]).unwrap(); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "YZ\nrow"); - fixture.external("other\n"); - editor.apply(vec![Effect::Replace("YZ".into())]).unwrap(); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "Yother\nZ"); - assert!(editor.owned_system_clipboard.is_none()); - editor.handle(Input::Char('u')); - fixture.external("row\n"); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "Yrow\nZ"); - } - - #[test] - fn linewise_empty_final_row_publication_failures_leave_selection_unchanged() { - for key in ['y', 'd', 'c'] { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor.apply(vec![Effect::Replace("a\n".into())]).unwrap(); - type_text(&mut editor, "jV"); - fixture.fail("fail-write", true); - let before = state(&editor); - editor.handle(Input::Char(key)); - assert_eq!(state(&editor), before); - assert!(editor.visual_linewise()); - assert_eq!(fixture.bytes("state"), b""); - fixture.fail("fail-write", false); - editor.handle(Input::Char(key)); - assert_eq!(fixture.bytes("state"), b"\n"); - assert_eq!(editor.context().text, if key == 'd' { "a" } else { "a\n" }); - } - } - - #[test] - fn linewise_repeat_publication_failure_restores_shape_and_recipe_atomically() { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor - .apply(vec![Effect::Replace("a\nb\nc".into())]) - .unwrap(); - type_text(&mut editor, "Vd"); - editor - .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) - .unwrap(); - type_text(&mut editor, "Vj"); - fixture.fail("fail-write", true); - let before = state(&editor); - editor.handle(Input::Char('.')); - assert_eq!(state(&editor), before); - assert_eq!(fixture.bytes("state"), b"a\n"); - assert!(editor.visual_linewise()); - fixture.fail("fail-write", false); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "cc"); - assert_eq!(fixture.bytes("state"), b"aa\nbb\n"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "aa\nbb\ncc"); - assert_eq!(editor.mode(), Mode::Normal); - } - - #[test] - fn linewise_internal_shape_is_independent_of_owned_system_shape() { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor.set_clipboard_mode("separate").unwrap(); - editor - .apply(vec![Effect::Replace("inside".into())]) - .unwrap(); - type_text(&mut editor, "Vy"); - editor - .bind("visual", "q", Some("core.copy_system".into())) - .unwrap(); - editor.apply(vec![Effect::Replace("out".into())]).unwrap(); - type_text(&mut editor, "veq"); - assert_eq!(fixture.bytes("state"), b"out"); - editor.apply(vec![Effect::Replace("XY".into())]).unwrap(); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "XY\ninside"); - editor.apply(vec![Effect::Replace("XY".into())]).unwrap(); - editor.set_clipboard_mode("system").unwrap(); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "XoutY"); - } - - #[test] - fn linewise_dot_paste_keeps_captured_shape_after_metadata_loss_and_read_failure() { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor.apply(vec![Effect::Replace("row".into())]).unwrap(); - type_text(&mut editor, "Vyp"); - assert_eq!(editor.context().text, "row\nrow"); - editor.apply(vec![Effect::Replace("AB".into())]).unwrap(); - fixture.fail("fail-read", true); - let before = state(&editor); - editor.handle(Input::Char('p')); - assert_eq!(state(&editor), before); - fixture.fail("fail-read", false); - fixture.external(""); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "AB"); - assert!(editor.owned_system_clipboard.is_none()); - let reads = fixture.bytes("reads").len(); - fixture.fail("fail-read", true); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "AB\nrow"); - assert_eq!(fixture.bytes("reads").len(), reads); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "AB"); - } - - #[test] - fn default_system_native_copy_cut_change_exports_exact_selected_text() { - for key in ['y', 'd', 'x', 'c'] { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - assert_eq!(editor.clipboard_mode(), ClipboardMode::System); - editor.clipboard = vec!["internal".into()]; - editor - .apply(vec![Effect::Replace("a界e\u{301}z\n👩‍🚀tail\n".into())]) - .unwrap(); - set_ranges( - &mut editor, - &[(Cursor { row: 0, column: 1 }, Cursor { row: 1, column: 1 })], - 0, - ); - let undo = editor.active.undo.len(); - editor.handle(Input::Char(key)); - assert_eq!(fixture.bytes("state"), "界e\u{301}z\n👩‍🚀".as_bytes()); - assert_eq!(fixture.bytes("writes").len(), 1); - assert_eq!(fixture.bytes("reads").len(), 0); - assert_eq!(editor.clipboard, ["internal"]); - match key { - 'y' => { - assert_eq!(editor.context().text, "a界e\u{301}z\n👩‍🚀tail\n"); - assert_eq!(editor.active.undo.len(), undo); - } - 'c' => { - assert_eq!(editor.mode(), Mode::Insert); - assert!(editor.active.view.pending_edit.is_some()); - type_text(&mut editor, "Q"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "aQtail\n"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "a界e\u{301}z\n👩‍🚀tail\n"); - } - _ => { - assert_eq!(editor.context().text, "atail\n"); - assert_eq!(editor.active.undo.len(), undo + 1); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "a界e\u{301}z\n👩‍🚀tail\n"); - } - } - } - } - - #[test] - fn system_paste_before_after_normalizes_valid_external_text_and_records_it() { - for (key, action, expected) in [ - ('P', None, "界\t\n:q!\nab"), - ('p', None, "a界\t\n:q!\nb"), - ('z', Some("core.paste_system_before"), "界\t\n:q!\nab"), - ('z', Some("core.paste_system_after"), "a界\t\n:q!\nb"), - ] { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - if let Some(action) = action { - editor.set_clipboard_mode("separate").unwrap(); - editor.bind("normal", "z", Some(action.into())).unwrap(); - } - editor.apply(vec![Effect::Replace("ab".into())]).unwrap(); - editor.clipboard = vec!["stale".into()]; - fixture.external("界\t\r\n:q!\r"); - editor.handle(Input::Char(key)); - assert_eq!(editor.context().text, expected); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.clipboard, ["stale"]); - assert_eq!(fixture.bytes("state"), "界\t\r\n:q!\r".as_bytes()); - assert_eq!(fixture.bytes("writes").len(), 0); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "ab"); - assert!(!editor.should_quit()); - } - } - - #[test] - fn own_fragments_pair_external_multiline_broadcasts_and_empty_never_uses_internal() { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor - .apply(vec![Effect::Replace("ab\nxyz".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - type_text(&mut editor, "vey"); - assert_eq!(fixture.bytes("state"), b"ab\nxyz"); - assert_eq!( - editor.owned_system_clipboard.as_ref().unwrap().1, - ["ab", "xyz"] - ); - editor - .apply(vec![Effect::Replace("1\n2\n3".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); - let before = state(&editor); - editor.handle(Input::Char('P')); - assert_eq!(state(&editor), before); - assert!(editor.message().contains("fragment count")); - editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "ab1\nxyz2"); - editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - fixture.external("left\nright\n"); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "left\nright\n1\nleft\nright\n2"); - assert!(editor.owned_system_clipboard.is_none()); - editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - fixture.external("ab\nxyz"); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "ab\nxyz1\nab\nxyz2"); - editor.clipboard = vec!["stale".into()]; - fixture.external(""); - let before = state(&editor); - editor.handle(Input::Char('p')); - assert_eq!(state(&editor), before); - assert!(editor.message().contains("empty")); - } - - #[test] - fn separate_and_explicit_system_actions_are_independent_and_mode_checked() { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor.set_clipboard_mode("SePaRaTe").unwrap(); - assert_eq!(editor.clipboard_mode(), ClipboardMode::Separate); - for mode in ["", "invalid", " system "] { - assert!(editor.set_clipboard_mode(mode).is_err()); - assert_eq!(editor.clipboard_mode(), ClipboardMode::Separate); - } - fixture.external("outside"); - fixture.fail("fail-read", true); - fixture.fail("fail-write", true); - editor - .apply(vec![Effect::Replace("inside".into())]) - .unwrap(); - type_text(&mut editor, "veyP"); - assert_eq!(editor.context().text, "insideinside"); - assert_eq!(editor.clipboard, ["inside"]); - assert_eq!(fixture.bytes("reads").len(), 0); - assert_eq!(fixture.bytes("writes").len(), 0); - fixture.fail("fail-read", false); - fixture.fail("fail-write", false); - for action in ["core.paste_system_before", "core.paste_system_after"] { - assert!(editor.bind("insert", "z", Some(action.into())).is_err()); - editor.bind("normal", "z", Some(action.into())).unwrap(); - } - assert!( - editor - .bind("normal", "q", Some("core.copy_system".into())) - .is_err() - ); - assert!( - editor - .bind("insert", "q", Some("core.copy_system".into())) - .is_err() - ); - editor - .bind("visual", "q", Some("core.copy_system".into())) - .unwrap(); - editor - .apply(vec![Effect::Replace("system".into())]) - .unwrap(); - editor.handle(Input::Home); - type_text(&mut editor, "veq"); - assert_eq!(fixture.bytes("state"), b"system"); - assert_eq!(editor.clipboard, ["inside"]); - editor.apply(vec![Effect::Replace("!".into())]).unwrap(); - editor.handle(Input::Char('z')); - assert_eq!(editor.context().text, "!system"); - assert_eq!(editor.clipboard, ["inside"]); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "!systeinsidem"); - assert_eq!(fixture.bytes("state"), b"system"); - editor.set_clipboard_mode("SYSTEM").unwrap(); - editor.handle(Input::Ctrl('p')); - let entries = &editor.palette().unwrap().entries; - for name in [ - "core.copy_system", - "core.paste_system_before", - "core.paste_system_after", - ] { - let entry = entries.iter().find(|entry| entry.name == name).unwrap(); - assert_eq!(entry.enabled, name != "core.copy_system"); - } - } - - #[test] - fn provider_and_invalid_text_errors_preserve_all_editing_state() { - for key in ['y', 'd', 'x', 'c'] { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor - .apply(vec![Effect::Replace("ab\ncd".into())]) - .unwrap(); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - editor.clipboard = vec!["internal".into()]; - editor - .store_clipboard(vec!["owned".into()], true, false) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - type_text(&mut editor, "ve"); - fixture.fail("fail-write", true); - let before = state(&editor); - editor.handle(Input::Char(key)); - assert_eq!(state(&editor), before); - assert_eq!(fixture.bytes("state"), b"owned"); - assert!(!editor.message().is_empty()); - } - for invalid in [ - b"valid\nbad\0".to_vec(), - b"\x1b[31m".to_vec(), - b"\xff".to_vec(), - vec![b'x'; MAX_EDIT_BYTES + 1], - ] { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor - .apply(vec![Effect::Replace("original".into())]) - .unwrap(); - editor - .store_clipboard(vec!["owned".into()], true, false) - .unwrap(); - editor.clipboard = vec!["internal".into()]; - fixture.external(invalid); - let before = state(&editor); - editor.handle(Input::Char('p')); - assert_eq!(state(&editor), before); - assert!(!editor.message().is_empty()); - } - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - fixture.fail("fail-read", true); - let before = state(&editor); - editor.handle(Input::Char('P')); - assert_eq!(state(&editor), before); - assert!(!editor.message().is_empty()); - } - - #[test] - fn separate_cut_change_and_copy_never_invoke_system_transport() { - for key in ['y', 'd', 'x', 'c'] { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor.set_clipboard_mode("separate").unwrap(); - fixture.external("outside"); - fixture.fail("fail-read", true); - fixture.fail("fail-write", true); - editor - .apply(vec![Effect::Replace("inside".into())]) - .unwrap(); - type_text(&mut editor, "ve"); - editor.handle(Input::Char(key)); - assert_eq!(editor.clipboard, ["inside"]); - assert_eq!( - editor.context().text, - if key == 'y' { "inside" } else { "" } - ); - assert_eq!( - editor.mode(), - if key == 'c' { - Mode::Insert - } else { - Mode::Normal - } - ); - assert_eq!(fixture.bytes("state"), b"outside"); - assert_eq!(fixture.bytes("reads").len(), 0); - assert_eq!(fixture.bytes("writes").len(), 0); - } - } - - #[test] - fn empty_visual_copy_and_change_preserve_both_registers() { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor.clipboard = vec!["internal".into()]; - editor - .store_clipboard(vec!["owned".into()], true, false) - .unwrap(); - fixture.fail("fail-write", true); - for key in ['y', 'd', 'c'] { - editor.active.view.mode = Mode::Visual; - editor.handle(Input::Char(key)); - assert_eq!(editor.clipboard, ["internal"]); - assert_eq!(fixture.bytes("state"), b"owned"); - assert_eq!(fixture.bytes("writes").len(), 1); - assert_eq!(editor.context().text, ""); - if key == 'c' { - assert_eq!(editor.mode(), Mode::Insert); - type_text(&mut editor, "X"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - } - } - } - - #[test] - fn registers_and_mode_are_session_wide_across_buffers() { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor.set_clipboard_mode("separate").unwrap(); - editor - .apply(vec![Effect::Replace("inside".into())]) - .unwrap(); - type_text(&mut editor, "vey"); - editor.set_clipboard_mode("system").unwrap(); - editor - .apply(vec![Effect::Replace("ab\nxyz".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - type_text(&mut editor, "vey"); - let other = fixture.0.path("other"); - fs::write(&other, "1\n2").unwrap(); - editor.execute(&format!("edit {}", other.display())); - assert_eq!(editor.clipboard_mode(), ClipboardMode::System); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "ab1\nxyz2"); - editor.set_clipboard_mode("separate").unwrap(); - editor.execute("buffer 1"); - assert_eq!(editor.clipboard_mode(), ClipboardMode::Separate); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "insideab\nxyz"); - assert_eq!(fixture.bytes("state"), b"ab\nxyz"); - } - - #[test] - fn dot_paste_uses_captured_fragments_without_reading_or_publishing() { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor - .apply(vec![Effect::Replace("ab\nxyz".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - type_text(&mut editor, "vey"); - editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.handle(Input::Char('P')); - fixture.external("external"); - fixture.fail("fail-read", true); - fixture.fail("fail-write", true); - editor - .apply(vec![Effect::Replace("3\n4\n5".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); - let before = state(&editor); - editor.handle(Input::Char('.')); - assert_eq!(state(&editor), before); - editor.set_clipboard_mode("separate").unwrap(); - editor.clipboard = vec!["other".into()]; - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "ab3\nxyz4\n5"); - assert_eq!(editor.clipboard, ["other"]); - assert_eq!(fixture.bytes("reads").len(), 1); - assert_eq!(fixture.bytes("writes").len(), 1); - assert_eq!(fixture.bytes("state"), b"external"); - } - - #[test] - fn dot_selection_stages_publication_and_rolls_back_step_or_provider_failures() { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor - .apply(vec![Effect::Replace("ab\ncd".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - type_text(&mut editor, "vc"); - editor.handle(Input::Backspace); - type_text(&mut editor, "X"); - editor.handle(Input::Escape); - let original_clipboard = fixture.bytes("state"); - editor - .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); - editor.handle(Input::Char('v')); - let before = state(&editor); - editor.handle(Input::Char('.')); - assert_eq!(state(&editor), before); - assert_eq!(fixture.bytes("writes").len(), 1); - assert_eq!(fixture.bytes("state"), original_clipboard); - assert!(editor.message().contains("count")); - editor - .apply(vec![Effect::Replace("aa\nbb".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.handle(Input::Char('v')); - editor.active.view.search_query = "aa".into(); - editor.active.view.search_match = Some(SearchMatch { - start: Cursor::default(), - end_column: 2, - }); - fixture.fail("fail-write", true); - let before = state(&editor); - editor.handle(Input::Char('.')); - assert_eq!(state(&editor), before); - assert_eq!(fixture.bytes("state"), original_clipboard); - assert_eq!(fixture.bytes("writes").len(), 2); - fixture.fail("fail-write", false); - let undo = editor.active.undo.len(); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "XaXb"); - assert_eq!(fixture.bytes("state"), b"a\nb"); - assert_eq!(fixture.bytes("writes").len(), 3); - assert_eq!(editor.active.undo.len(), undo + 1); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "aa\nbb"); - assert_eq!(fixture.bytes("state"), b"a\nb"); - } - - #[test] - fn dot_unequal_spans_refuses_before_publication_and_preserves_recipe() { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor - .apply(vec![Effect::Replace("ab\nxyz".into())]) - .unwrap(); - set_ranges( - &mut editor, - &[ - (Cursor::default(), Cursor { row: 0, column: 2 }), - (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), - ], - 1, - ); - editor.handle(Input::Char('d')); - editor - .apply(vec![Effect::Replace("12\n345".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - let before = state(&editor); - editor.handle(Input::Char('.')); - assert_eq!(state(&editor), before); - assert_eq!(fixture.bytes("writes").len(), 1); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "\n"); - assert_eq!(fixture.bytes("state"), b"12\n345"); - assert_eq!(fixture.bytes("writes").len(), 2); - } - - #[test] - fn dot_cut_follows_current_policy_without_copying_plain_deletes_or_plugin_edits() { - let fixture = Fixture::new(); - let mut editor = fixture.editor(); - editor.set_clipboard_mode("separate").unwrap(); - editor.apply(vec![Effect::Replace("first".into())]).unwrap(); - type_text(&mut editor, "ved"); - assert_eq!(editor.clipboard, ["first"]); - assert_eq!(fixture.bytes("writes").len(), 0); - editor.set_clipboard_mode("system").unwrap(); - editor - .apply(vec![Effect::Replace("second".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, ""); - assert_eq!(fixture.bytes("state"), b"second"); - assert_eq!(editor.clipboard, ["first"]); - editor.set_clipboard_mode("separate").unwrap(); - editor.apply(vec![Effect::Replace("third".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('.')); - assert_eq!(editor.clipboard, ["third"]); - assert_eq!(fixture.bytes("state"), b"second"); - editor.set_clipboard_mode("system").unwrap(); - editor.apply(vec![Effect::Replace("plain".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - type_text(&mut editor, "x."); - type_text(&mut editor, "iX"); - editor.handle(Input::Backspace); - editor.handle(Input::Delete); - editor.handle(Input::Escape); - editor.handle(Input::Char('.')); - assert_eq!(fixture.bytes("writes").len(), 1); - assert_eq!(fixture.bytes("reads").len(), 0); - } - } - - #[test] - fn floating_default_tree_starts_filtering_then_escape_navigates_before_close() { - let directory = Directory::new(); - fs::create_dir(directory.path("dir")).unwrap(); - fs::write(directory.path("dir/first.txt"), "first").unwrap(); - fs::write(directory.path("dir/second.txt"), "second").unwrap(); - let mut editor = project_editor(&directory); - type_text(&mut editor, " osecond.txt"); - let picker = editor.file_picker().unwrap(); - assert_eq!(picker.view, FileView::Tree); - assert!(picker.focused && picker.searching); - assert_eq!(picker.query, "second.txt"); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - Path::new("dir/second.txt") - ); - editor.handle(Input::Escape); - let picker = editor.file_picker().unwrap(); - assert!(picker.focused && !picker.searching); - assert_eq!(picker.query, "second.txt"); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - Path::new("dir/second.txt") - ); - editor.handle(Input::Char('i')); - assert!(editor.file_picker().unwrap().searching); - assert_eq!(editor.file_picker().unwrap().query, "second.txt"); - editor.handle(Input::Escape); - editor.handle(Input::Char('h')); - editor.handle(Input::Char('h')); - let picker = editor.file_picker().unwrap(); - assert_eq!(picker.entries().len(), 1); - assert_eq!(picker.entries().next().unwrap().path, Path::new("dir")); - assert!(!picker.entries().next().unwrap().expanded); - assert_eq!(picker.query, "second.txt"); - editor.handle(Input::Char('l')); - assert!( - editor - .file_picker() - .unwrap() - .entries() - .next() - .unwrap() - .expanded - ); - editor.handle(Input::Escape); - assert!(editor.file_picker().is_none()); - assert_eq!(editor.lines(), &[String::new()]); - editor.set_file_picker_view("list").unwrap(); - type_text(&mut editor, " osecond.txt"); - assert_eq!(editor.file_picker().unwrap().view, FileView::List); - editor.handle(Input::Enter); - assert_eq!(editor.lines(), &["second"]); - assert!(editor.file_picker().is_none()); - } - - #[test] - fn filter_escape_resume_and_clear_do_not_edit_underlying_buffers_or_history() { - let directory = Directory::new(); - fs::create_dir_all(directory.path("src/deep")).unwrap(); - fs::write(directory.path("src/deep/needle.txt"), "needle").unwrap(); - fs::write(directory.path("src/other.txt"), "other").unwrap(); - let selected_path = |editor: &Editor| { - let picker = editor.file_picker().unwrap(); - picker - .entries() - .nth(picker.selected) - .unwrap() - .path - .to_owned() - }; - for presentation in [FilePresentation::Floating, FilePresentation::Docked] { - for mode in [ - Mode::Normal, - Mode::Insert, - Mode::Command, - Mode::Search, - Mode::Visual, - ] { - let mut editor = project_editor(&directory); - editor - .apply(vec![Effect::Replace("alpha\nbeta".into())]) - .unwrap(); - editor - .apply(vec![Effect::Replace("changed".into())]) - .unwrap(); - editor.act(Action::Undo); - set_carets(&mut editor, &[(0, 1), (1, 2)], 1); - editor.begin_insert(); - editor.active.view.mode = mode; - editor.active.view.command_line = "original prompt".into(); - editor.open_file_picker(presentation); - if !editor.file_picker().unwrap().searching { - editor.handle(Input::Char('i')); - } - editor.count = Some(7); - let snapshot = - |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); - let lines = editor.lines().to_owned(); - let targets = editor.active.view.targets.clone(); - let primary = editor.active.view.primary; - let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); - let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); - let insert = editor.active.view.insert_start.as_ref().map(snapshot); - let buffer = editor.buffer_id(); - let buffers = editor.buffer_count(); - type_text(&mut editor, "needle"); - let expected = PathBuf::from("src/deep/needle.txt"); - assert_eq!(selected_path(&editor), expected); - editor.handle(Input::Escape); - assert!(!editor.file_picker().unwrap().searching); - assert_eq!(editor.file_picker().unwrap().query, "needle"); - assert_eq!(selected_path(&editor), expected); - editor.handle(Input::Char('i')); - assert!(editor.file_picker().unwrap().searching); - assert_eq!(editor.file_picker().unwrap().query, "needle"); - assert_eq!(selected_path(&editor), expected); - editor.handle(Input::Ctrl('U')); - assert!(editor.file_picker().unwrap().searching); - assert!(editor.file_picker().unwrap().query.is_empty()); - assert_eq!(selected_path(&editor), expected); - type_text(&mut editor, "needle"); - editor.handle(Input::Escape); - editor.handle(Input::Ctrl('u')); - assert!(!editor.file_picker().unwrap().searching); - assert!(editor.file_picker().unwrap().query.is_empty()); - assert_eq!(selected_path(&editor), expected); - editor.handle(Input::Ctrl('u')); - assert_eq!(selected_path(&editor), expected); - editor.handle(Input::Char('i')); - type_text(&mut editor, "missing"); - editor.handle(Input::Ctrl('j')); - editor.handle(Input::Ctrl('k')); - editor.handle(Input::Escape); - assert_eq!(editor.file_picker().unwrap().entries().len(), 0); - editor.handle(Input::Ctrl('u')); - assert_eq!(selected_path(&editor), expected); - assert!(editor.file_picker().unwrap().focused); - assert!(!editor.file_picker().unwrap().searching); - assert_eq!(editor.mode(), mode); - assert_eq!(editor.lines(), lines); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.active.view.primary, primary); - assert_eq!(editor.active.view.command_line, "original prompt"); - assert_eq!(editor.count(), Some(7)); - assert_eq!(editor.buffer_id(), buffer); - assert_eq!(editor.buffer_count(), buffers); - assert_eq!( - editor.active.undo.iter().map(&snapshot).collect::>(), - undo - ); - assert_eq!( - editor.active.redo.iter().map(&snapshot).collect::>(), - redo - ); - assert_eq!( - editor.active.view.insert_start.as_ref().map(snapshot), - insert - ); - editor.handle(Input::Escape); - match presentation { - FilePresentation::Floating => assert!(editor.file_picker().is_none()), - FilePresentation::Docked => { - assert!(!editor.file_picker().unwrap().focused); - editor.open_file_picker(presentation); - assert!(!editor.file_picker().unwrap().searching); - assert_eq!(selected_path(&editor), expected); - } - } - } - } - } - - #[test] - fn filtered_folder_keys_and_directory_accept_work_in_both_presentations() { - let directory = Directory::new(); - fs::create_dir_all(directory.path("src/deep")).unwrap(); - fs::write(directory.path("src/deep/needle.txt"), "needle").unwrap(); - fs::write(directory.path("src/other.txt"), "other").unwrap(); - for presentation in [FilePresentation::Floating, FilePresentation::Docked] { - let mut editor = project_editor(&directory); - editor.open_file_picker(presentation); - if !editor.file_picker().unwrap().searching { - editor.handle(Input::Char('i')); - } - type_text(&mut editor, "needle"); - editor.handle(Input::Ctrl('h')); - let picker = editor.file_picker().unwrap(); - assert!(picker.searching); - assert_eq!(picker.query, "needle"); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - Path::new("src/deep") - ); - assert!(picker.entries().nth(picker.selected).unwrap().expanded); - editor.handle(Input::Ctrl('l')); - let picker = editor.file_picker().unwrap(); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - Path::new("src/deep/needle.txt") - ); - editor.handle(Input::Escape); - editor.handle(Input::Char('h')); - editor.handle(Input::Char('h')); - let picker = editor.file_picker().unwrap(); - assert!(!picker.searching); - assert_eq!(picker.query, "needle"); - assert_eq!(picker.entries().len(), 2); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - Path::new("src/deep") - ); - assert!(!picker.entries().nth(picker.selected).unwrap().expanded); - editor.handle(Input::Char('l')); - assert!( - editor - .file_picker() - .unwrap() - .entries() - .nth(1) - .unwrap() - .expanded - ); - editor.handle(Input::Char('l')); - editor.handle(Input::Char('h')); - editor.handle(Input::Enter); - assert!( - !editor - .file_picker() - .unwrap() - .entries() - .nth(1) - .unwrap() - .expanded - ); - editor.handle(Input::Enter); - assert!( - editor - .file_picker() - .unwrap() - .entries() - .nth(1) - .unwrap() - .expanded - ); - editor.handle(Input::Char('i')); - editor.handle(Input::Enter); - let picker = editor.file_picker().unwrap(); - assert!(!picker.searching && picker.focused); - assert_eq!(picker.query, "needle"); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - Path::new("src/deep") - ); - assert!(!picker.entries().nth(picker.selected).unwrap().expanded); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Ctrl('r')); - editor.handle(Input::Ctrl('t')); - editor.handle(Input::Ctrl('t')); - let picker = editor.file_picker().unwrap(); - assert!(!picker.searching && picker.focused); - assert_eq!(picker.query, "needle"); - assert!(!picker.entries().nth(picker.selected).unwrap().expanded); - if presentation == FilePresentation::Docked { - editor.handle(Input::Escape); - editor.open_file_picker(presentation); - assert!(!editor.file_picker().unwrap().searching); - assert_eq!(editor.file_picker().unwrap().query, "needle"); - } - editor.open_file_picker(FilePresentation::Floating); - assert!(editor.file_picker().unwrap().searching); - assert_eq!(editor.file_picker().unwrap().query, "needle"); - } - } - - #[test] - fn filter_clear_respects_navigation_bindings_and_preserves_idle_dock_queries() { - let directory = Directory::new(); - for name in ["a.txt", "b.txt"] { - fs::write(directory.path(name), "text").unwrap(); - } - for view in [FileView::List, FileView::Tree] { - let mut editor = project_editor(&directory); - editor.open_file_picker(FilePresentation::Docked); - editor.file_picker.as_mut().unwrap().set_view(view); - if view == FileView::Tree { - editor.handle(Input::Char('i')); - } - type_text(&mut editor, "txt"); - editor - .bind("selection", "ctrl+u", Some("core.down".into())) - .unwrap(); - editor.handle(Input::Ctrl('u')); - assert_eq!(editor.file_picker().unwrap().query, "txt"); - assert_eq!(editor.file_picker().unwrap().selected, 1); - editor.bind("selection", "ctrl+u", None).unwrap(); - editor.handle(Input::Ctrl('u')); - assert_eq!(editor.file_picker().unwrap().query, "txt"); - editor.selection_bindings.remove(&Input::Ctrl('u')); - if view == FileView::Tree { - editor.bind("tree", "ctrl+u", None).unwrap(); - editor.handle(Input::Ctrl('u')); - assert_eq!(editor.file_picker().unwrap().query, "txt"); - editor.tree_bindings.remove(&Input::Ctrl('u')); - } - editor.handle(Input::Ctrl('u')); - assert!(editor.file_picker().unwrap().query.is_empty()); - assert_eq!(editor.file_picker().unwrap().selected, 1); - assert_eq!( - editor.file_picker().unwrap().searching, - view == FileView::Tree - ); - type_text(&mut editor, "txt"); - editor.handle(Input::Char('\t')); - editor.handle(Input::Ctrl('u')); - assert_eq!(editor.file_picker().unwrap().query, "txt"); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core"); - editor.handle(Input::Ctrl('u')); - assert!(editor.palette().unwrap().query.is_empty()); - assert_eq!(editor.file_picker().unwrap().query, "txt"); - } - } - - #[test] - fn selection_navigation_wraps_without_touching_any_underlying_mode() { - let directory = Directory::new(); - for name in ["a.txt", "b.txt", "c.txt"] { - fs::write(directory.path(name), "file text").unwrap(); - } - for surface in [ - None, - Some(FilePresentation::Floating), - Some(FilePresentation::Docked), - ] { - for mode in [ - Mode::Normal, - Mode::Insert, - Mode::Command, - Mode::Search, - Mode::Visual, - ] { - let mut editor = project_editor(&directory); - editor - .apply(vec![Effect::Replace("alpha\nbeta".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 1), (1, 2)], 1); - editor.active.view.mode = mode; - editor.active.view.command_line = "original prompt".into(); - if let Some(presentation) = surface { - editor.open_file_picker(presentation); - editor - .file_picker - .as_mut() - .unwrap() - .set_view(FileView::List); - type_text(&mut editor, "txt"); - } else { - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core"); - } - let list = |editor: &Editor| { - if let Some(picker) = editor.file_picker() { - ( - picker.query.clone(), - picker.selected, - picker.entries().len(), - ) - } else { - let palette = editor.palette().unwrap(); - ( - palette.query.clone(), - palette.selected, - palette.entries.len(), - ) - } - }; - let (query, _, len) = list(&editor); - assert!(len > 1); - let snapshot = - |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); - let text = editor.context().text; - let targets = editor.active.view.targets.clone(); - let primary = editor.active.view.primary; - let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); - let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); - let insert = editor.active.view.insert_start.as_ref().map(snapshot); - editor.count = Some(7); - for (input, selected) in [ - (Input::Ctrl('K'), len - 1), - (Input::Ctrl('J'), 0), - (Input::Ctrl('j'), 1), - (Input::Ctrl('k'), 0), - (Input::Up, len - 1), - (Input::Down, 0), - (Input::Ctrl('n'), 1), - ] { - assert_eq!(editor.handle(input), None); - assert_eq!(list(&editor), (query.clone(), selected, len)); - } - assert_eq!(editor.mode(), mode); - assert_eq!(editor.context().text, text); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.active.view.primary, primary); - assert_eq!(editor.active.view.command_line, "original prompt"); - assert_eq!(editor.count(), Some(7)); - assert_eq!( - editor.active.undo.iter().map(&snapshot).collect::>(), - undo - ); - assert_eq!( - editor.active.redo.iter().map(&snapshot).collect::>(), - redo - ); - assert_eq!( - editor.active.view.insert_start.as_ref().map(snapshot), - insert - ); - type_text(&mut editor, "missing"); - assert_eq!(list(&editor).2, 0); - for input in [Input::Ctrl('j'), Input::Ctrl('k'), Input::Enter] { - assert_eq!(editor.handle(input), None); - assert_eq!(list(&editor).1, 0); - assert_eq!(editor.context().text, text); - assert_eq!(editor.mode(), mode); - } - } - } - } - - #[test] - fn selection_remaps_and_disables_are_shared_and_invalid_edits_are_atomic() { - let directory = Directory::new(); - for name in ["a.txt", "b.txt", "c.txt"] { - fs::write(directory.path(name), "file text").unwrap(); - } - for surface in [ - None, - Some(FilePresentation::Floating), - Some(FilePresentation::Docked), - ] { - let mut editor = project_editor(&directory); - assert_eq!( - editor.selection_navigation_hint(), - "Ctrl+j/Ctrl+n/Down next · Ctrl+k/Up previous" - ); - type_text(&mut editor, "3 b"); - let pending = editor.sequence_pending().unwrap().to_owned(); - editor - .bind("selection", "x", Some("core.down".into())) - .unwrap(); - editor - .bind("selection", "z", Some("core.up".into())) - .unwrap(); - editor.bind("selection", "ctrl+j", None).unwrap(); - editor.bind("selection", "!", None).unwrap(); - assert_eq!(editor.count(), Some(3)); - assert_eq!(editor.sequence_pending(), Some(pending.as_str())); - assert_eq!( - editor.selection_navigation_hint(), - "Ctrl+n/Down/x next · Ctrl+k/Up/z previous" - ); - let hint = editor.selection_navigation_hint().to_owned(); - for (mode, key, target) in [ - ("selection", "x", Some("core.delete")), - ("selection", "x", Some("core.palette")), - ("selection", "x", Some("w")), - ("selection", "leader x", Some("core.down")), - ("selection", "LEADER x", None), - ("selection", "x z", Some("core.up")), - ("invalid", "x", Some("core.up")), - ] { - assert!(editor.bind(mode, key, target.map(str::to_owned)).is_err()); - assert_eq!(editor.selection_navigation_hint(), hint); - assert_eq!(editor.count(), Some(3)); - assert_eq!(editor.sequence_pending(), Some(pending.as_str())); - } - editor.cancel_sequence(); - // Editing bindings cannot disable or remap list bindings. - editor - .bind("normal", "x", Some("core.insert".into())) - .unwrap(); - editor.bind("normal", "ctrl+k", None).unwrap(); - if let Some(presentation) = surface { - editor.open_file_picker(presentation); - editor - .file_picker - .as_mut() - .unwrap() - .set_view(FileView::List); - } else { - editor.handle(Input::Ctrl('p')); - } - let selected = |editor: &Editor| { - if let Some(picker) = editor.file_picker() { - picker.selected - } else { - editor.palette().unwrap().selected - } - }; - for (input, expected) in [ - (Input::Char('x'), 1), - (Input::Ctrl('j'), 1), - (Input::Char('!'), 1), - (Input::Char('z'), 0), - (Input::Char('x'), 1), - (Input::Ctrl('k'), 0), - ] { - editor.handle(input); - assert_eq!(selected(&editor), expected); - assert_eq!(editor.mode(), Mode::Normal); - } - // Updating the map does not rebuild/reset the selected list. - editor.handle(Input::Char('x')); - editor - .bind("selection", "ctrl+p", Some("core.down".into())) - .unwrap(); - assert_eq!(selected(&editor), 1); - editor.handle(Input::Ctrl('p')); - assert_eq!(selected(&editor), 2); - editor.bind("selection", "backspace", None).unwrap(); - type_text(&mut editor, "a"); - editor.handle(Input::Backspace); - let query = if let Some(picker) = editor.file_picker() { - &picker.query - } else { - &editor.palette().unwrap().query - }; - assert_eq!(query, "a"); - for key in ["down", "ctrl+n", "x", "ctrl+p"] { - editor.bind("selection", key, None).unwrap(); - } - assert_eq!( - editor.selection_navigation_hint(), - "unbound next · Ctrl+k/Up/z previous" - ); - editor.handle(Input::Escape); - editor.handle(Input::Char('x')); - assert_eq!(editor.mode(), Mode::Insert); - } - } - - #[test] - fn ctrl_j_is_distinct_from_enter_and_preserves_editor_defaults() { - assert_eq!(parse_key("CTRL+J").unwrap(), Input::Ctrl('j')); - for (key, input) in [("ctrl+m", Input::Enter), ("ctrl+h", Input::Ctrl('h'))] { - assert_eq!(parse_key(key).unwrap(), input); - } - let mut editor = editor(); - editor - .bind("normal", "ctrl+j", Some("core.insert".into())) - .unwrap(); - editor.handle(Input::Enter); - assert_eq!(editor.mode(), Mode::Normal); - editor.handle(Input::Ctrl('j')); - assert_eq!(editor.mode(), Mode::Insert); - editor.bind("insert", "enter", None).unwrap(); - type_text(&mut editor, "one"); - editor.handle(Input::Enter); - editor.handle(Input::Ctrl('j')); - type_text(&mut editor, "two"); - assert_eq!(editor.context().text, "one\ntwo"); - editor.handle(Input::Escape); - editor.register(plugin("fixture", &["greet"])).unwrap(); - type_text(&mut editor, ":greet arguments"); - let invocation = editor.handle(Input::Ctrl('j')).unwrap(); - assert_eq!(invocation.command, "greet"); - assert_eq!(invocation.args, "arguments"); - assert_eq!(editor.mode(), Mode::Normal); - type_text(&mut editor, "/two"); - editor.handle(Input::Ctrl('j')); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.active.view.search_query, "two"); - assert_eq!(editor.cursor().row, 1); - editor - .bind("visual", "ctrl+j", Some("core.up".into())) - .unwrap(); - editor.handle(Input::Char('v')); - editor.handle(Input::Ctrl('j')); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.cursor().row, 0); - } - - #[test] - fn ctrl_j_navigates_lists_while_enter_accepts() { - let directory = Directory::new(); - fs::write(directory.path("a.txt"), "first").unwrap(); - fs::write(directory.path("b.txt"), "second").unwrap(); - for presentation in [FilePresentation::Floating, FilePresentation::Docked] { - let mut editor = project_editor(&directory); - editor.open_file_picker(presentation); - editor.handle(Input::Ctrl('j')); - assert_eq!(editor.file_picker().unwrap().selected, 1); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Enter); - assert_eq!(editor.context().text, "second"); - assert_eq!(editor.path(), Some(directory.path("b.txt").as_path())); - } - let mut editor = editor(); - editor - .register(plugin("fixture", &["greet_one", "greet_two"])) - .unwrap(); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "greet"); - editor.handle(Input::Ctrl('j')); - assert_eq!(editor.palette().unwrap().selected, 1); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.active.view.command_line, ""); - editor.handle(Input::Enter); - assert!(editor.palette().is_none()); - assert_eq!(editor.mode(), Mode::Command); - assert_eq!(editor.active.view.command_line, "greet_two "); - } - - #[test] - fn file_actions_default_leaders_palette_and_side_configuration() { - let directory = Directory::new(); - fs::write(directory.path("file.txt"), "abcdef").unwrap(); - for visual in [false, true] { - let mut editor = project_editor(&directory); - editor - .apply(vec![Effect::Replace("abcdef".into())]) - .unwrap(); - if visual { - type_text(&mut editor, "vl"); - } - let mode = editor.mode(); - let selections = editor.selections(); - let carets = editor.carets(); - for (suffix, presentation) in [ - ("3 o", FilePresentation::Floating), - ("2 e", FilePresentation::Docked), - ] { - type_text(&mut editor, suffix); - let picker = editor.file_picker().unwrap(); - assert_eq!(picker.presentation, presentation); - assert!(picker.focused); - assert_eq!(editor.mode(), mode); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.count(), None); - assert_eq!(editor.sequence_pending(), None); - editor.handle(Input::Ctrl('w')); - assert!(editor.file_picker().is_none()); - } - editor.handle(Input::Ctrl('p')); - for (name, binding) in [ - ("core.file_picker", "Space o"), - ("core.file_explorer", "Space e"), - ] { - let entry = palette_entry(&editor, name); - assert!(entry.enabled); - assert_eq!(entry.bindings, vec![binding]); - } - editor.handle(Input::Escape); - assert_eq!(editor.file_explorer_side(), DockSide::Right); - for (name, side) in [ - ("LEFT", DockSide::Left), - ("rIgHt", DockSide::Right), - ("top", DockSide::Top), - ("BOTTOM", DockSide::Bottom), - ] { - editor.set_file_explorer_side(name).unwrap(); - assert_eq!(editor.file_explorer_side(), side); - } - assert!(editor.set_file_explorer_side("middle").is_err()); - assert_eq!(editor.file_explorer_side(), DockSide::Bottom); - assert!( - editor - .bind("insert", "ctrl+o", Some("core.file_picker".into())) - .is_err() - ); - assert!( - editor - .bind("command", "ctrl+e", Some("core.file_explorer".into())) - .is_err() - ); - } - } - - #[test] - fn picker_focus_literal_input_navigation_and_empty_selection() { - let directory = Directory::new(); - for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] { - fs::write(directory.path(name), "text").unwrap(); - } - let mut editor = project_editor(&directory); - editor.set_file_picker_view("list").unwrap(); - type_text(&mut editor, " o"); - editor.handle(Input::Ctrl('K')); - assert_eq!(editor.file_picker().unwrap().selected, 2); - editor.handle(Input::Ctrl('N')); - assert_eq!(editor.file_picker().unwrap().selected, 0); - editor.handle(Input::Up); - editor.handle(Input::Down); - assert_eq!(editor.file_picker().unwrap().selected, 0); - type_text(&mut editor, "界 SPACE [E\u{301}]"); - assert_eq!(editor.file_picker().unwrap().entries().len(), 1); - assert_eq!(editor.context().text, ""); - assert_eq!(editor.count(), None); - assert_eq!(editor.sequence_pending(), None); - let query = editor.file_picker().unwrap().query.clone(); - editor.handle(Input::Paste("ignored 5 b n".into())); - assert_eq!(editor.file_picker().unwrap().query, query); - editor.handle(Input::Enter); - assert!(editor.file_picker().is_none()); - assert_eq!( - editor.path(), - Some(directory.path("a space 界[e\u{301}].txt").as_path()) - ); - assert_eq!(editor.context().text, "text"); - type_text(&mut editor, " o"); - type_text(&mut editor, "e\u{301}👩‍💻"); - editor.handle(Input::Backspace); - assert_eq!(editor.file_picker().unwrap().query, "e\u{301}"); - editor.handle(Input::Backspace); - assert_eq!(editor.file_picker().unwrap().query, ""); - editor.handle(Input::Backspace); - type_text(&mut editor, "missing 123"); - let id = editor.buffer_id(); - editor.handle(Input::Up); - editor.handle(Input::Down); - editor.handle(Input::Enter); - assert_eq!(editor.buffer_id(), id); - assert_eq!(editor.file_picker().unwrap().selected, 0); - assert!(editor.file_picker().unwrap().focused); - editor.handle(Input::Escape); - let empty = Directory::new(); - editor.project_root = empty.0.clone(); - type_text(&mut editor, " e"); - editor.handle(Input::Up); - editor.handle(Input::Down); - editor.handle(Input::Ctrl('j')); - editor.handle(Input::Ctrl('k')); - editor.handle(Input::Enter); - assert_eq!(editor.buffer_id(), id); - assert_eq!(editor.file_picker().unwrap().entries().len(), 0); - } - - #[test] - fn picker_conversion_reuses_query_and_scan_refreshes_only_on_request() { - let directory = Directory::new(); - fs::write(directory.path("keep.txt"), "text").unwrap(); - let mut editor = project_editor(&directory); - type_text(&mut editor, " eikeep"); - editor.handle(Input::Char('\t')); - assert!(!editor.file_picker().unwrap().focused); - fs::write(directory.path("keep new.txt"), "new").unwrap(); - type_text(&mut editor, " o"); - let picker = editor.file_picker().unwrap(); - assert_eq!(picker.presentation, FilePresentation::Floating); - assert_eq!(picker.query, "keep"); - assert_eq!(picker.entries().len(), 1); - editor.open_file_picker(FilePresentation::Docked); - assert_eq!(editor.file_picker().unwrap().query, "keep"); - editor.handle(Input::Ctrl('R')); - assert_eq!(editor.file_picker().unwrap().entries().len(), 2); - assert_eq!(editor.file_picker().unwrap().query, "keep"); - editor.handle(Input::Char('\t')); - assert!(!editor.file_picker().unwrap().focused); - type_text(&mut editor, " e"); - assert!(editor.file_picker().unwrap().focused); - assert_eq!(editor.file_picker().unwrap().query, "keep"); - fs::remove_file(directory.path("keep new.txt")).unwrap(); - editor.handle(Input::Ctrl('r')); - assert_eq!(editor.file_picker().unwrap().entries().len(), 1); - editor.handle(Input::Ctrl('w')); - assert!(editor.file_picker().is_none()); - type_text(&mut editor, " o"); - assert_eq!(editor.file_picker().unwrap().query, ""); - editor.handle(Input::Escape); - assert!(editor.file_picker().unwrap().focused); - editor.handle(Input::Escape); - assert!(editor.file_picker().is_none()); - } - - #[test] - fn picker_open_reuses_loaded_dirty_buffer_and_dock_survives_lifecycle() { - let directory = Directory::new(); - let a = directory.path("a space 界.txt"); - let b = directory.path("b.txt"); - fs::write(&a, "original").unwrap(); - fs::write(&b, "other").unwrap(); - let mut editor = project_editor(&directory); - editor.execute(&format!("edit {}", a.display())); - editor.apply(vec![Effect::Replace("dirty".into())]).unwrap(); - type_text(&mut editor, "vl"); - let selections = editor.selections(); - let id = editor.buffer_id(); - editor.execute(&format!("edit {}", b.display())); - fs::write(&a, b"\xff").unwrap(); - type_text(&mut editor, " eia space 界"); - editor.handle(Input::Enter); - assert_eq!(editor.buffer_id(), id); - assert_eq!(editor.buffer_count(), 3); - assert_eq!(editor.context().text, "dirty"); - assert!(editor.dirty()); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selections(), selections); - assert!(!editor.file_picker().unwrap().focused); - assert_eq!(editor.file_picker().unwrap().query, "a space 界"); - editor.execute("bnext"); - assert!(editor.file_picker().is_some()); - editor.execute("bdelete"); - assert!(editor.file_picker().is_some()); - editor.execute("buffer 1"); - assert_eq!( - editor.file_picker().unwrap().presentation, - FilePresentation::Docked - ); - assert_eq!(editor.file_picker().unwrap().query, "a space 界"); - } - - #[test] - fn failed_picker_selection_preserves_visual_state_and_insert_group() { - let directory = Directory::new(); - let bad = directory.path("bad.txt"); - fs::write(&bad, b"\xff").unwrap(); - let mut editor = project_editor(&directory); - editor - .apply(vec![Effect::Replace("abcdef".into())]) - .unwrap(); - type_text(&mut editor, "llvhh"); - let selections = editor.selections(); - let carets = editor.carets(); - let undo = editor.active.undo.len(); - type_text(&mut editor, " obad"); - editor.handle(Input::Enter); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.context().text, "abcdef"); - assert_eq!(editor.active.undo.len(), undo); - assert_eq!(editor.next_buffer_id, 2); - assert_eq!(editor.buffer_count(), 1); - assert!(editor.file_picker().unwrap().focused); - assert!( - editor - .file_picker() - .unwrap() - .error - .as_ref() - .unwrap() - .contains("Open failed") - ); - editor.handle(Input::Down); - assert!(editor.file_picker().unwrap().error.is_none()); - editor.handle(Input::Enter); - editor.handle(Input::Backspace); - assert!(editor.file_picker().unwrap().error.is_none()); - editor.handle(Input::Escape); - editor.handle(Input::Escape); - editor.handle(Input::Escape); - editor.handle(Input::Char('i')); - type_text(&mut editor, "X"); - editor.open_file_picker(FilePresentation::Docked); - editor.handle(Input::Enter); - assert_eq!(editor.mode(), Mode::Insert); - assert!(editor.active.view.insert_start.is_some()); - assert!(editor.file_picker().unwrap().error.is_some()); - fs::remove_file(&bad).unwrap(); - editor.handle(Input::Enter); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.mode(), Mode::Insert); - assert!(editor.file_picker().unwrap().error.is_some()); - fs::create_dir(&bad).unwrap(); - editor.handle(Input::Enter); - assert_eq!(editor.buffer_count(), 1); - assert!(editor.file_picker().unwrap().focused); - editor.handle(Input::Escape); - assert!(!editor.file_picker().unwrap().focused); - type_text(&mut editor, "Y"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "abcdef"); - assert!(editor.file_picker().is_some()); - } - - #[test] - fn picker_palette_and_external_commands_are_mutually_exclusive() { - let directory = Directory::new(); - fs::write(directory.path("file.txt"), "text").unwrap(); - let mut editor = project_editor(&directory); - editor.handle(Input::Ctrl('p')); - editor.open_file_picker(FilePresentation::Docked); - assert!(editor.palette().is_none()); - editor.handle(Input::Ctrl('p')); - assert!(editor.palette().is_some()); - assert!(!editor.file_picker().unwrap().focused); - type_text(&mut editor, "file picker"); - editor.handle(Input::Enter); - assert!(editor.palette().is_none()); - assert_eq!( - editor.file_picker().unwrap().presentation, - FilePresentation::Floating - ); - assert!(editor.file_picker().unwrap().focused); - editor.handle(Input::Ctrl('p')); - assert!(editor.file_picker().is_none()); - assert!(editor.palette().is_some()); - editor.handle(Input::Escape); - type_text(&mut editor, " o"); - editor.execute("buffers"); - assert!(editor.file_picker().is_none()); - editor.handle(Input::Escape); - type_text(&mut editor, " eifile"); - editor.execute("buffers"); - assert!(!editor.file_picker().unwrap().focused); - assert_eq!(editor.file_picker().unwrap().query, "file"); - editor.handle(Input::Escape); - type_text(&mut editor, " e"); - editor.handle(Input::Ctrl('w')); - assert!(editor.file_picker().is_none()); - } - - #[test] - fn unfocused_dock_keeps_editor_counts_leaders_palette_and_literal_input() { - let directory = Directory::new(); - fs::write(directory.path("file.txt"), "text").unwrap(); - let mut editor = project_editor(&directory); - editor - .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) - .unwrap(); - type_text(&mut editor, " eifile"); - editor.handle(Input::Char('\t')); - type_text(&mut editor, "2j"); - assert_eq!(editor.cursor().row, 2); - assert_eq!(editor.file_picker().unwrap().query, "file"); - type_text(&mut editor, "3 b"); - assert_eq!(editor.sequence_pending(), Some("Space b")); - assert_eq!(editor.count(), Some(3)); - editor.handle(Input::Escape); - type_text(&mut editor, "i o e123"); - editor.handle(Input::Paste(" literal".into())); - assert!(editor.context().text.contains(" o e123 literal")); - assert_eq!(editor.file_picker().unwrap().query, "file"); - editor.handle(Input::Escape); - type_text(&mut editor, ": o e123"); - assert_eq!(editor.mode(), Mode::Command); - assert_eq!(editor.command_line(), " o e123"); - editor.handle(Input::Escape); - type_text(&mut editor, "/ o e123"); - assert_eq!(editor.mode(), Mode::Search); - assert_eq!(editor.command_line(), " o e123"); - editor.handle(Input::Escape); - type_text(&mut editor, " "); - assert!(editor.palette().is_some()); - assert!(!editor.file_picker().unwrap().focused); - } - - #[test] - fn canceling_picker_does_not_commit_insert_and_root_failure_is_not_empty_success() { - let directory = Directory::new(); - let mut editor = project_editor(&directory); - type_text(&mut editor, "iX"); - editor.open_file_picker(FilePresentation::Floating); - editor.handle(Input::Paste("ignored".into())); - editor.handle(Input::Escape); - assert!(editor.file_picker().unwrap().focused); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), Mode::Insert); - assert!(editor.active.view.insert_start.is_some()); - assert_eq!(editor.context().text, "X"); - type_text(&mut editor, "Y"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - fs::remove_dir_all(&directory.0).unwrap(); - type_text(&mut editor, " o"); - assert!(editor.file_picker().is_none()); - assert!(editor.message().contains("File scan failed")); - assert_eq!(editor.buffer_count(), 1); - } - - #[cfg(unix)] - #[test] - fn picker_opens_raw_paths_even_when_sanitized_display_labels_collide() { - let directory = Directory::new(); - let names = [PathBuf::from("same\n.txt"), PathBuf::from("same\t.txt")]; - fs::write(directory.0.join(&names[0]), "first").unwrap(); - fs::write(directory.0.join(&names[1]), "second").unwrap(); - let mut editor = project_editor(&directory); - for (name, text) in names.iter().zip(["first", "second"]) { - type_text(&mut editor, " osame"); - let picker = editor.file_picker.as_mut().unwrap(); - assert_eq!(picker.entries().len(), 2); - assert_eq!( - picker.entries().next().unwrap().label, - picker.entries().last().unwrap().label, - ); - assert!( - picker - .entries() - .all(|entry| !entry.label.chars().any(char::is_control)) - ); - let selected = picker - .entries() - .position(|entry| entry.path == name.as_path()) - .unwrap(); - picker.selected = selected; - editor.handle(Input::Enter); - assert_eq!(editor.path(), Some(directory.0.join(name).as_path())); - assert_eq!(editor.context().text, text); - } - assert_eq!(editor.buffer_count(), 3); - } - - #[test] - fn file_view_preferences_are_independent_atomic_and_applied_to_current_presentation() { - let directory = Directory::new(); - fs::create_dir(directory.path("folder")).unwrap(); - fs::write(directory.path("file.txt"), "text").unwrap(); - let mut editor = project_editor(&directory); - editor.open_file_picker(FilePresentation::Docked); - assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); - editor.set_file_picker_view("TREE").unwrap(); - assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); - editor.set_file_explorer_view("LiSt").unwrap(); - assert_eq!(editor.file_picker().unwrap().view, FileView::List); - for invalid in ["", "flat", " tree "] { - assert!(editor.set_file_picker_view(invalid).is_err()); - assert!(editor.set_file_explorer_view(invalid).is_err()); - assert_eq!(editor.file_picker_view(), FileView::Tree); - assert_eq!(editor.file_explorer_view(), FileView::List); - assert_eq!(editor.file_picker().unwrap().view, FileView::List); - } - editor.handle(Input::Ctrl('t')); - assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); - assert_eq!(editor.file_explorer_view(), FileView::List); - editor.handle(Input::Char('\t')); - editor.open_file_picker(FilePresentation::Docked); - assert_eq!(editor.file_picker().unwrap().view, FileView::List); - editor.open_file_picker(FilePresentation::Floating); - assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); - assert_eq!( - editor.file_picker().unwrap().presentation, - FilePresentation::Floating - ); - editor.set_file_explorer_view("tree").unwrap(); - editor.set_file_picker_view("list").unwrap(); - assert_eq!(editor.file_picker().unwrap().view, FileView::List); - assert_eq!(editor.file_picker().unwrap().entries().len(), 1); - } - - #[test] - fn tree_hjkl_navigates_normal_and_control_hjkl_navigates_filter_without_changing_text() { - let directory = Directory::new(); - fs::create_dir(directory.path("dir")).unwrap(); - fs::write(directory.path("dir/hjkl-a.txt"), "a").unwrap(); - fs::write(directory.path("dir/hjkl-b.txt"), "b").unwrap(); - for presentation in [FilePresentation::Floating, FilePresentation::Docked] { - let mut editor = project_editor(&directory); - editor.set_file_picker_view("tree").unwrap(); - editor.open_file_picker(presentation); - if presentation == FilePresentation::Floating { - editor.handle(Input::Escape); - } - let selected = |editor: &Editor| { - let picker = editor.file_picker().unwrap(); - picker - .entries() - .nth(picker.selected) - .unwrap() - .path - .to_owned() - }; - editor.handle(Input::Char('l')); - assert!( - editor - .file_picker() - .unwrap() - .entries() - .next() - .unwrap() - .expanded - ); - editor.handle(Input::Char('l')); - assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); - editor.handle(Input::Char('j')); - assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); - editor.handle(Input::Char('k')); - assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); - editor.handle(Input::Char('h')); - assert_eq!(selected(&editor), Path::new("dir")); - assert!( - editor - .file_picker() - .unwrap() - .entries() - .next() - .unwrap() - .expanded - ); - editor.handle(Input::Char('h')); - assert!( - !editor - .file_picker() - .unwrap() - .entries() - .next() - .unwrap() - .expanded - ); - editor.handle(Input::Char('i')); - type_text(&mut editor, "hjkl"); - assert_eq!(editor.file_picker().unwrap().query, "hjkl"); - assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); - editor.handle(Input::Ctrl('j')); - assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); - editor.handle(Input::Ctrl('k')); - assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); - editor.handle(Input::Ctrl('h')); - assert_eq!(selected(&editor), Path::new("dir")); - editor.handle(Input::Ctrl('l')); - assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); - assert_eq!(editor.file_picker().unwrap().query, "hjkl"); - editor.handle(Input::Escape); - assert!(editor.file_picker().unwrap().focused); - assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); - assert_eq!(editor.file_picker().unwrap().query, "hjkl"); - editor.handle(Input::Ctrl('u')); - assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); - editor.handle(Input::Char('h')); - editor.handle(Input::Char('h')); - assert!( - !editor - .file_picker() - .unwrap() - .entries() - .next() - .unwrap() - .expanded - ); - editor.bind("tree", "j", Some("core.up".into())).unwrap(); - editor.bind("tree", "k", None).unwrap(); - editor.handle(Input::Char('l')); - editor.handle(Input::Char('j')); - assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); - editor.handle(Input::Char('k')); - assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); - editor.handle(Input::Char('i')); - type_text(&mut editor, "jk"); - assert_eq!(editor.file_picker().unwrap().query, "jk"); - assert_eq!(editor.lines(), &[String::new()]); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.cursor(), Cursor::default()); - } - } - - #[test] - fn tree_browse_and_search_have_separate_input_and_two_stage_escape() { - let directory = Directory::new(); - fs::create_dir_all(directory.path("folder/deep")).unwrap(); - fs::write(directory.path("folder/deep/needle.txt"), "text").unwrap(); - fs::write(directory.path("folder/other.txt"), "other").unwrap(); - for presentation in [FilePresentation::Floating, FilePresentation::Docked] { - for mode in [ - Mode::Normal, - Mode::Visual, - Mode::Insert, - Mode::Command, - Mode::Search, - ] { - let mut editor = project_editor(&directory); - editor - .apply(vec![Effect::Replace("alpha\nbeta".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 1), (1, 2)], 1); - editor.active.view.mode = mode; - editor.active.view.command_line = "underlying".into(); - let targets = editor.active.view.targets.clone(); - let text = editor.context().text; - let undo = editor.active.undo.len(); - editor.set_file_picker_view("tree").unwrap(); - editor.open_file_picker(presentation); - if presentation == FilePresentation::Floating { - editor.handle(Input::Escape); - } - type_text(&mut editor, "123 b xyz"); - editor.handle(Input::Paste("ignored".into())); - editor.handle(Input::Backspace); - assert_eq!(editor.file_picker().unwrap().query, ""); - assert_eq!(editor.count(), None); - assert_eq!(editor.sequence_pending(), None); - editor.handle(Input::Enter); - assert_eq!(editor.buffer_count(), 1); - assert!( - editor - .file_picker() - .unwrap() - .entries() - .next() - .unwrap() - .expanded - ); - editor.handle(Input::Ctrl('l')); - assert_eq!( - editor.file_picker().unwrap().entries().nth(1).unwrap().path, - Path::new("folder/deep") - ); - assert_eq!(editor.file_picker().unwrap().selected, 1); - editor.handle(Input::Ctrl('h')); - assert_eq!(editor.file_picker().unwrap().selected, 0); - type_text(&mut editor, "ideep/"); - assert!(editor.file_picker().unwrap().searching); - assert_eq!(editor.file_picker().unwrap().query, "deep/"); - editor.handle(Input::Ctrl('H')); - assert_eq!(editor.file_picker().unwrap().query, "deep/"); - editor.handle(Input::Backspace); - assert_eq!(editor.file_picker().unwrap().query, "deep"); - type_text(&mut editor, "👩‍💻"); - editor.handle(Input::Backspace); - assert_eq!(editor.file_picker().unwrap().query, "deep"); - let before = editor - .file_picker() - .unwrap() - .entries() - .map(|row| row.path.to_owned()) - .collect::>(); - let selected_before = editor.file_picker().unwrap().selected; - editor.handle(Input::Escape); - let picker = editor.file_picker().unwrap(); - assert!(picker.focused); - assert!(!picker.searching); - assert_eq!(picker.query, "deep"); - assert_eq!(picker.selected, selected_before); - assert_eq!( - picker - .entries() - .map(|row| row.path.to_owned()) - .collect::>(), - before - ); - editor.handle(Input::Escape); - match presentation { - FilePresentation::Floating => assert!(editor.file_picker().is_none()), - FilePresentation::Docked => assert!(!editor.file_picker().unwrap().focused), - } - assert_eq!(editor.mode(), mode); - assert_eq!(editor.active.view.command_line, "underlying"); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.context().text, text); - assert_eq!(editor.active.undo.len(), undo); - } - } - } - - #[test] - fn tree_binding_precedence_is_modal_and_navigation_preferences_are_atomic() { - let directory = Directory::new(); - fs::create_dir(directory.path("folder")).unwrap(); - fs::write(directory.path("folder/a.txt"), "a").unwrap(); - fs::write(directory.path("folder/b.txt"), "b").unwrap(); - fs::write(directory.path("z.txt"), "z").unwrap(); - let mut editor = project_editor(&directory); - editor - .bind("selection", "x", Some("core.down".into())) - .unwrap(); - editor.bind("tree", "x", Some("core.right".into())).unwrap(); - editor - .bind("tree", "ctrl+j", Some("core.left".into())) - .unwrap(); - editor.bind("tree", "ctrl+k", None).unwrap(); - type_text(&mut editor, "3 b"); - let pending = editor.sequence_pending().unwrap().to_owned(); - let hint = editor.tree_navigation_hint.clone(); - for (key, target) in [ - ("x", Some("core.delete")), - ("x", Some("core.palette")), - ("x", Some("w")), - ("leader x", Some("core.right")), - ("x y", Some("core.left")), - ] { - assert!(editor.bind("tree", key, target.map(str::to_owned)).is_err()); - assert_eq!(editor.tree_navigation_hint, hint); - assert_eq!(editor.count(), Some(3)); - assert_eq!(editor.sequence_pending(), Some(pending.as_str())); - } - editor.open_file_picker(FilePresentation::Docked); - editor.handle(Input::Char('x')); - assert_eq!(editor.file_picker().unwrap().selected, 0); - assert!( - editor - .file_picker() - .unwrap() - .entries() - .next() - .unwrap() - .expanded - ); - editor.handle(Input::Char('x')); - assert_eq!(editor.file_picker().unwrap().selected, 1); - editor.handle(Input::Ctrl('k')); - assert_eq!(editor.file_picker().unwrap().selected, 1); - editor.handle(Input::Ctrl('j')); - assert_eq!(editor.file_picker().unwrap().selected, 0); - editor.handle(Input::Char('i')); - editor.handle(Input::Char('x')); - assert_eq!(editor.file_picker().unwrap().selected, 1); - assert_eq!(editor.file_picker().unwrap().query, ""); - editor.handle(Input::Ctrl('j')); - assert_eq!(editor.file_picker().unwrap().selected, 0); - editor.handle(Input::Ctrl('h')); - assert_eq!(editor.file_picker().unwrap().selected, 0); - editor.handle(Input::Ctrl('t')); - assert_eq!(editor.file_picker().unwrap().view, FileView::List); - assert_eq!( - editor.file_navigation_hint(), - editor.selection_navigation_hint() - ); - editor.handle(Input::Char('x')); - assert_eq!(editor.file_picker().unwrap().selected, 1); - editor.handle(Input::Ctrl('w')); - editor.handle(Input::Char('x')); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Ctrl('p')); - let selected = editor.palette().unwrap().selected; - editor.handle(Input::Char('x')); - assert_eq!(editor.palette().unwrap().selected, selected + 1); - editor.handle(Input::Ctrl('h')); - assert_eq!(editor.palette().unwrap().query, ""); - } - - #[test] - fn ctrl_h_is_not_backspace_unless_explicitly_bound() { - assert_eq!(parse_key("CTRL+H").unwrap(), Input::Ctrl('h')); - assert_eq!(parse_key("backspace").unwrap(), Input::Backspace); - for mode in [ - Mode::Normal, - Mode::Visual, - Mode::Insert, - Mode::Command, - Mode::Search, - ] { - let mut editor = editor(); - editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); - set_carets(&mut editor, &[(0, 2)], 0); - editor.active.view.mode = mode; - editor.active.view.command_line = "query".into(); - let targets = editor.active.view.targets.clone(); - let undo = editor.active.undo.len(); - editor.handle(Input::Ctrl('h')); - assert_eq!(editor.context().text, "abc"); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.active.undo.len(), undo); - assert_eq!(editor.active.view.command_line, "query"); - assert_eq!(editor.mode(), mode); - } - let mut editor = editor(); - type_text(&mut editor, "iabc"); - editor.handle(Input::Ctrl('h')); - assert_eq!(editor.context().text, "abc"); - editor - .bind("insert", "ctrl+h", Some("core.backspace".into())) - .unwrap(); - editor.handle(Input::Ctrl('h')); - assert_eq!(editor.context().text, "ab"); - editor.bind("insert", "ctrl+h", None).unwrap(); - editor.handle(Input::Ctrl('h')); - assert_eq!(editor.context().text, "ab"); - editor.handle(Input::Backspace); - assert_eq!(editor.context().text, "a"); - - let directory = Directory::new(); - fs::write(directory.path("a.txt"), "text").unwrap(); - let mut editor = project_editor(&directory); - editor.open_file_picker(FilePresentation::Floating); - type_text(&mut editor, "a"); - editor.handle(Input::Ctrl('h')); - assert_eq!(editor.file_picker().unwrap().query, "a"); - editor.handle(Input::Backspace); - assert_eq!(editor.file_picker().unwrap().query, ""); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core"); - editor.handle(Input::Ctrl('h')); - assert_eq!(editor.palette().unwrap().query, "core"); - editor.handle(Input::Backspace); - assert_eq!(editor.palette().unwrap().query, "cor"); - } - - #[test] - fn modified_backspace_preserves_suffixes_and_uses_unicode_boundaries() { - for (key, action, prefix, suffix, expected) in [ - ( - "option+backspace", - "core.backspace_word", - "alpha βeta ", - "SUFFIX\nlast", - "alpha SUFFIX\nlast", - ), - ( - "alt+backspace", - "core.backspace_word", - "foo don't", - "!", - "foo !", - ), - ( - "alt+backspace", - "core.backspace_word", - "alpha e\u{301}", - " tail", - "alpha tail", - ), - ( - "alt+backspace", - "core.backspace_word", - "alpha 👩‍🚀", - " tail", - "alpha tail", - ), - ( - "alt+backspace", - "core.backspace_word", - "one word\n ", - "tail", - "one tail", - ), - ( - "cmd+backspace", - "core.backspace_line", - "first\n βeta ", - "SUFFIX\nlast", - "first\nSUFFIX\nlast", - ), - ( - "super+backspace", - "core.backspace_line", - "first\n", - "tail", - "first\ntail", - ), - ("alt+backspace", "core.backspace_word", "", "tail", "tail"), - ] { - let mut editor = editor(); - editor.bind("insert", key, Some(action.into())).unwrap(); - let original = format!("{prefix}{suffix}"); - editor - .apply(vec![Effect::Replace(original.clone())]) - .unwrap(); - let row = prefix.bytes().filter(|&byte| byte == b'\n').count(); - let column = prefix.rsplit('\n').next().unwrap().graphemes(true).count(); - set_carets(&mut editor, &[(row, column)], 0); - let undo = editor.active.undo.len(); - let revision = editor.text_revision(); - editor.handle(Input::Char('i')); - editor.handle(parse_key(key).unwrap()); - assert_eq!(editor.context().text, expected, "{prefix:?}"); - editor.handle(Input::Escape); - if original == expected { - assert_eq!(editor.text_revision(), revision); - assert_eq!(editor.active.undo.len(), undo); - } else { - assert_eq!(editor.active.undo.len(), undo + 1); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, original); - assert_eq!(editor.cursor(), Cursor { row, column }); - } - } - } - - #[test] - fn modified_backspace_merges_overlapping_carets_and_repeats_new_boundaries() { - for (key, action, expected) in [ - ( - "alt+backspace", - "core.backspace_word", - "foo tail\nkeep row", - ), - ("cmd+backspace", "core.backspace_line", " tail\n row"), - ] { - let mut editor = editor(); - editor.bind("insert", key, Some(action.into())).unwrap(); - let original = "foo elephant tail\nkeep second row"; - editor - .apply(vec![Effect::Replace(original.into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 5), (0, 12), (1, 11)], 1); - let carets = editor.carets(); - editor.handle(Input::Char('i')); - editor.handle(parse_key(key).unwrap()); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, expected); - assert_eq!(editor.selection_count(), 2); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, original); - assert_eq!(editor.carets(), carets); - - let next = "foo hippopotamus tail\nkeep longer row"; - editor.apply(vec![Effect::Replace(next.into())]).unwrap(); - set_carets(&mut editor, &[(0, 5), (0, 16), (1, 11)], 1); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, expected); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, next); - } - } - - #[test] - fn modified_backspace_queries_never_edit_the_underlying_buffer() { - for opening in [Input::Char(':'), Input::Char('/'), Input::Ctrl('p')] { - let mut editor = editor(); - for mode in ["command", "search"] { - editor - .bind(mode, "alt+backspace", Some("core.backspace_word".into())) - .unwrap(); - editor - .bind(mode, "cmd+backspace", Some("core.backspace_line".into())) - .unwrap(); - } - editor - .apply(vec![Effect::Replace("buffer text".into())]) - .unwrap(); - let revision = editor.text_revision(); - let carets = editor.carets(); - editor.handle(opening); - type_text(&mut editor, "discard this query"); - editor.handle(Input::Ctrl('u')); - assert_eq!( - editor - .palette() - .map_or(editor.command_line(), |palette| palette.query.as_str()), - "" - ); - type_text(&mut editor, "alpha βeta "); - editor.handle(Input::AltBackspace); - assert_eq!( - editor - .palette() - .map_or(editor.command_line(), |palette| palette.query.as_str()), - "alpha " - ); - editor.handle(Input::SuperBackspace); - assert_eq!( - editor - .palette() - .map_or(editor.command_line(), |palette| palette.query.as_str()), - "" - ); - assert_eq!(editor.context().text, "buffer text"); - assert_eq!(editor.text_revision(), revision); - assert_eq!(editor.carets(), carets); - } - } - - #[test] - fn ctrl_u_deletes_line_prefixes_and_honors_disabled_and_remapped_bindings() { - let mut editor = editor(); - let original = "first\nalpha βeta tail\nlast"; - editor - .apply(vec![Effect::Replace(original.into())]) - .unwrap(); - set_carets(&mut editor, &[(1, 11)], 0); - editor.handle(Input::Char('i')); - editor.handle(Input::Ctrl('u')); - assert_eq!(editor.context().text, "first\ntail\nlast"); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - assert_eq!(editor.mode(), Mode::Insert); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, original); - - editor.bind("insert", "ctrl+u", None).unwrap(); - editor.handle(Input::Char('i')); - editor.handle(Input::Ctrl('u')); - assert_eq!(editor.context().text, original); - editor - .bind("insert", "ctrl+u", Some("core.backspace_word".into())) - .unwrap(); - editor.handle(Input::Ctrl('u')); - assert_eq!(editor.context().text, "first\nalpha tail\nlast"); - - editor.handle(Input::Escape); - for (opening, mode) in [(':', "command"), ('/', "search")] { - editor.bind(mode, "ctrl+u", None).unwrap(); - editor.handle(Input::Char(opening)); - type_text(&mut editor, "alpha beta"); - editor.handle(Input::Ctrl('u')); - assert_eq!(editor.command_line(), "alpha beta"); - editor - .bind(mode, "ctrl+u", Some("core.backspace_word".into())) - .unwrap(); - editor.handle(Input::Ctrl('u')); - assert_eq!(editor.command_line(), "alpha "); - assert_eq!(editor.context().text, "first\nalpha tail\nlast"); - editor.handle(Input::Escape); - } - } - - #[test] - fn tree_leaf_search_opens_direct_match_and_directory_search_enters_browse() { - let directory = Directory::new(); - fs::create_dir_all(directory.path("src/module")).unwrap(); - fs::write(directory.path("src/module/needle.txt"), "needle").unwrap(); - fs::write(directory.path("src/sibling.txt"), "sibling").unwrap(); - for presentation in [FilePresentation::Floating, FilePresentation::Docked] { - let mut editor = project_editor(&directory); - editor.set_file_picker_view("tree").unwrap(); - editor.open_file_picker(presentation); - if presentation == FilePresentation::Docked { - editor.handle(Input::Char('i')); - } - type_text(&mut editor, "needle.txt"); - let picker = editor.file_picker().unwrap(); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - Path::new("src/module/needle.txt") - ); - editor.handle(Input::Enter); - assert_eq!(editor.context().text, "needle"); - assert_eq!( - editor.path(), - Some(directory.path("src/module/needle.txt").as_path()) - ); - editor.open_file_picker(presentation); - editor.handle(Input::Ctrl('w')); - editor.open_file_picker(presentation); - if presentation == FilePresentation::Docked { - editor.handle(Input::Char('i')); - } - type_text(&mut editor, "module"); - assert!( - editor - .file_picker() - .unwrap() - .entries() - .nth(editor.file_picker().unwrap().selected) - .unwrap() - .is_dir - ); - let id = editor.buffer_id(); - editor.handle(Input::Enter); - let picker = editor.file_picker().unwrap(); - assert_eq!(editor.buffer_id(), id); - assert!(!picker.searching); - assert_eq!(picker.query, "module"); - let selected = picker.entries().nth(picker.selected).unwrap(); - assert_eq!(selected.path, Path::new("src/module")); - assert!(!selected.expanded); - assert!( - !picker - .entries() - .any(|row| row.path == Path::new("src/module/needle.txt")) - ); - editor.handle(Input::Enter); - let picker = editor.file_picker().unwrap(); - assert_eq!(picker.query, "module"); - assert!(picker.entries().nth(picker.selected).unwrap().expanded); - assert!( - picker - .entries() - .any(|row| row.path == Path::new("src/module/needle.txt")) - ); - assert_eq!(editor.buffer_count(), 2); - } - } - - #[cfg(unix)] - #[test] - fn tree_search_opens_raw_nested_paths_and_deduplicates_dirty_buffers() { - let directory = Directory::new(); - fs::create_dir_all(directory.path("dir\n/deep")).unwrap(); - let a = directory.path("dir\n/deep/same\n.txt"); - let b = directory.path("dir\n/deep/same\t.txt"); - fs::write(&a, "first").unwrap(); - fs::write(&b, "second").unwrap(); - for presentation in [FilePresentation::Floating, FilePresentation::Docked] { - fs::write(&a, "first").unwrap(); - let mut editor = project_editor(&directory); - editor.set_file_picker_view("tree").unwrap(); - editor.edit_buffer(a.clone(), false).unwrap(); - editor - .apply(vec![Effect::Replace("dirty first".into())]) - .unwrap(); - type_text(&mut editor, "vl"); - let selections = editor.selections(); - let id = editor.buffer_id(); - editor.edit_buffer(b.clone(), false).unwrap(); - fs::write(&a, b"\xff").unwrap(); - editor.open_file_picker(presentation); - if presentation == FilePresentation::Docked { - editor.handle(Input::Char('i')); - } - type_text(&mut editor, "same"); - let picker = editor.file_picker.as_mut().unwrap(); - let labels = picker - .entries() - .filter(|row| !row.is_dir) - .map(|row| row.label) - .collect::>(); - assert_eq!(labels.len(), 2); - assert_eq!(labels[0], labels[1]); - assert!( - picker - .entries() - .all(|row| !row.label.chars().any(char::is_control)) - ); - let selected = picker - .entries() - .position(|row| picker.root.join(row.path) == a) - .unwrap(); - picker.selected = selected; - editor.handle(Input::Enter); - assert_eq!(editor.path(), Some(a.as_path())); - assert_eq!(editor.buffer_id(), id); - assert_eq!(editor.buffer_count(), 3); - assert_eq!(editor.context().text, "dirty first"); - assert!(editor.dirty()); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selections(), selections); - match presentation { - FilePresentation::Floating => assert!(editor.file_picker().is_none()), - FilePresentation::Docked => { - assert!(!editor.file_picker().unwrap().focused); - assert!(editor.file_picker().unwrap().searching); - assert_eq!(editor.file_picker().unwrap().query, "same"); - } - } - } - } - - #[test] - fn typed_quit_checks_all_buffers_and_force_quit_is_immediate_from_keys() { - let directory = Directory::new(); - let mut editor = editor(); - editor - .bind("normal", "Q", Some("core.quit".into())) - .unwrap(); - editor - .bind("normal", "Z", Some("core.force_quit".into())) - .unwrap(); - editor - .apply(vec![Effect::Replace("unsaved".into())]) - .unwrap(); - editor.execute(&format!("edit {}", directory.path("other").display())); - editor.handle(Input::Char('Q')); - assert!(!editor.should_quit()); - assert!(editor.message().contains("Unsaved changes")); - editor.handle(Input::Char('Z')); - assert!(editor.should_quit()); - } - - #[test] - fn typed_save_and_quit_refuses_dirty_parked_buffers_and_save_failure() { - let directory = Directory::new(); - let path = directory.path("current"); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor - .bind("normal", "Z", Some("core.save_and_quit".into())) - .unwrap(); - editor.apply(vec![Effect::Replace("first".into())]).unwrap(); - editor.execute(&format!("edit {}", directory.path("other").display())); - editor - .apply(vec![Effect::Replace("second".into())]) - .unwrap(); - editor.handle(Input::Char('Z')); - assert!(!editor.should_quit()); - assert!(!editor.dirty()); - assert!(editor.message().contains("other buffers")); - editor.execute("buffer 1"); - editor.handle(Input::Char('Z')); - assert!(editor.should_quit()); - assert_eq!(fs::read_to_string(path).unwrap(), "first"); - - let mut unnamed = Editor::open(None, vec![]).unwrap(); - unnamed - .bind("normal", "Z", Some("core.save_and_quit".into())) - .unwrap(); - unnamed - .apply(vec![Effect::Replace("unsaved".into())]) - .unwrap(); - unnamed.handle(Input::Char('Z')); - assert!(!unnamed.should_quit()); - assert!(unnamed.dirty()); - } - - #[test] - fn required_argument_actions_prompt_and_share_ex_execution() { - let directory = Directory::new(); - let path = directory.path("file with spaces"); - fs::write(&path, "opened").unwrap(); - let mut editor = editor(); - assert!(editor.bind("normal", "E", Some("edit".into())).is_err()); - editor - .bind("normal", "E", Some("core.edit".into())) - .unwrap(); - editor.handle(Input::Char('E')); - assert_eq!(editor.mode(), Mode::Command); - assert_eq!(editor.command_line(), "edit "); - type_text(&mut editor, &path.to_string_lossy()); - editor.handle(Input::Enter); - assert_eq!(editor.context().text, "opened"); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core.buffer"); - editor.handle(Input::Enter); - assert_eq!(editor.mode(), Mode::Command); - assert_eq!(editor.command_line(), "buffer "); - type_text(&mut editor, "1"); - editor.handle(Input::Enter); - assert_eq!(editor.buffer_id(), 1); - editor.execute("edit"); - assert_eq!(editor.message(), "Usage: :edit path"); - } - - #[test] - fn force_delete_palette_requires_submission_before_discarding_dirty_buffer() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("unsaved".into())]) - .unwrap(); - let dirty_id = editor.buffer_id(); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "bdelete!"); - editor.handle(Input::Enter); - assert_eq!(editor.mode(), Mode::Command); - assert!(editor.has_buffer(dirty_id)); - assert!(editor.dirty()); - editor.handle(Input::Enter); - assert!(!editor.has_buffer(dirty_id)); - } - - #[test] - fn built_in_alias_bindings_require_typed_actions() { - let mut editor = editor(); - for entry in CORE_ACTIONS.iter().filter(|entry| entry.ex.is_some()) { - assert!( - editor - .bind("normal", "Z", Some(entry.ex.unwrap().into())) - .is_err() - ); - assert!(editor.bind("normal", "Z", Some(entry.name.into())).is_ok()); - } - } - - #[test] - fn buffer_picker_searches_sanitized_paths_and_preserves_dirty_history_on_accept_or_cancel() { - let directory = Directory::new(); - let mut editor = editor(); - let unnamed = editor.buffer_id(); - let first = directory.path("Älpha\nnote.txt"); - let second = directory.path("Älpha\tnote.txt"); - fs::write(&first, "first").unwrap(); - fs::write(&second, "second").unwrap(); - editor.execute(&format!("edit {}", first.display())); - let first_id = editor.buffer_id(); - editor - .apply(vec![Effect::Replace("first unsaved".into())]) - .unwrap(); - editor.execute(&format!("edit {}", second.display())); - let second_id = editor.buffer_id(); - editor - .apply(vec![Effect::Replace("second unsaved".into())]) - .unwrap(); - editor.execute(&format!("buffer {unnamed}")); - editor.bind("selection", "ctrl+j", None).unwrap(); - editor - .bind("selection", "ctrl+x", Some("core.down".into())) - .unwrap(); - - type_text(&mut editor, "3 bl"); - assert_eq!(editor.count(), None); - assert_eq!(editor.sequence_pending(), None); - type_text(&mut editor, "NOTE ÄLPHA"); - let picker = editor.palette().unwrap(); - assert_eq!(picker.entries.len(), 2); - assert!( - picker - .entries - .iter() - .all(|entry| !entry.label.chars().any(char::is_control)) - ); - editor.handle(Input::Ctrl('j')); - editor.handle(Input::Enter); - assert_eq!(editor.buffer_id(), first_id); - assert_eq!(editor.context().text, "first unsaved"); - assert!(editor.dirty()); - - editor.execute("buffers"); - type_text(&mut editor, "ÄLPHA NOTE"); - editor.handle(Input::Ctrl('x')); - editor.handle(Input::Enter); - assert_eq!(editor.buffer_id(), second_id); - assert_eq!(editor.context().text, "second unsaved"); - let cursor = editor.cursor(); - editor.execute("buffers"); - type_text(&mut editor, ":_|no-match"); - editor.handle(Input::Enter); - assert_eq!(editor.buffer_id(), second_id); - assert_eq!(editor.palette().unwrap().query, ":_|no-match"); - assert_eq!(editor.cursor(), cursor); - editor.handle(Input::Ctrl('u')); - type_text(&mut editor, "e\u{301}👩‍🚀"); - editor.handle(Input::Backspace); - assert_eq!(editor.palette().unwrap().query, "e\u{301}"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "second unsaved"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "second"); - assert!(!editor.dirty()); - - editor.execute("buffers"); - type_text(&mut editor, "no name"); - editor.handle(Input::Enter); - assert_eq!(editor.buffer_id(), unnamed); - editor.execute(&format!("buffer {first_id}")); - assert_eq!(editor.context().text, "first unsaved"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "first"); - assert_eq!(fs::read_to_string(&second).unwrap(), "second"); - } - - #[test] - fn buffer_picker_in_diff_excludes_snapshots_and_switches_only_the_focused_pane() { - let directory = Directory::new(); - let first = directory.path("file.txt"); - let second = directory.path("other.txt"); - fs::write(&first, "live first").unwrap(); - fs::write(&second, "live second").unwrap(); - let mut editor = Editor::open(Some(first), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - let first_id = editor.buffer_id(); - editor.execute("tab.new"); - let other_tab = editor.tab_id(); - editor.execute(&format!("edit {}", second.display())); - let second_id = editor.buffer_id(); - editor - .apply(vec![Effect::Replace("hidden unsaved".into())]) - .unwrap(); - editor.execute("tab.previous"); - let source_tab = editor.tab_id(); - let source_pane = editor.pane_id(); - editor.execute("pane.split_vertical"); - let focused = editor.pane_id(); - historical_diff( - &mut editor, - &directory.0, - vec!["before".into()], - vec!["after".into()], - ); - let snapshot = editor.buffer_id(); - let comparison = editor.diff().unwrap().comparison_id; - let cursor = editor.cursor(); - - editor.execute("buffers"); - type_text(&mut editor, "file.txt"); - assert_eq!(editor.palette().unwrap().entries.len(), 1); - editor.handle(Input::Escape); - assert_eq!(editor.buffer_id(), snapshot); - assert_eq!(editor.diff().unwrap().comparison_id, comparison); - assert_eq!(editor.cursor(), cursor); - editor.execute("buffers"); - type_text(&mut editor, "other.txt"); - editor.handle(Input::Enter); - assert_eq!(editor.buffer_id(), second_id); - assert_eq!(editor.context().text, "hidden unsaved"); - assert_eq!(editor.tab_id(), source_tab); - assert_eq!(editor.pane_id(), focused); - assert!(editor.diff().is_none()); - assert_eq!(editor.panes[&source_pane].buffer_id, first_id); - editor.execute("tab.next"); - assert_eq!(editor.tab_id(), other_tab); - assert_eq!(editor.buffer_id(), second_id); - assert_eq!(editor.context().text, "hidden unsaved"); - } - - #[test] - fn default_leader_routes_save_switch_list_delete_and_open_palette() { - let directory = Directory::new(); - let path = directory.path("first"); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - type_text(&mut editor, "ihello"); - editor.handle(Input::Escape); - type_text(&mut editor, " w"); - assert_eq!(fs::read_to_string(&path).unwrap(), "hello"); - assert!(!editor.dirty()); - editor.execute(&format!("edit {}", directory.path("second").display())); - type_text(&mut editor, " b"); - assert_eq!(editor.sequence_pending(), Some("Space b")); - editor.handle(Input::Char('n')); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.sequence_pending(), None); - type_text(&mut editor, " bp"); - assert_eq!(editor.buffer_id(), 2); - type_text(&mut editor, " bd"); - assert!(!editor.has_buffer(2)); - assert_eq!(editor.buffer_id(), 1); - for mode in [Mode::Normal, Mode::Visual] { - if mode == Mode::Visual { - editor.handle(Input::Char('v')); - } - editor.handle(Input::Char(' ')); - assert_eq!(editor.sequence_pending(), Some("Space")); - editor.handle(Input::Char(' ')); - assert!(editor.palette().is_some()); - assert_eq!(editor.mode(), mode); - assert_eq!(editor.sequence_pending(), None); - editor.handle(Input::Escape); - } - } - - #[test] - fn nested_leader_dispatches_plugin_once_with_count_and_multiple_carets() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("aa\nbb".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.register(plugin("fixture", &["greet"])).unwrap(); - editor - .bind("normal", "LeAdEr g ctrl+r 7 shift+i", Some("greet".into())) - .unwrap(); - type_text(&mut editor, "8 g"); - editor.handle(Input::Ctrl('R')); - assert_eq!(editor.sequence_pending(), Some("Space g Ctrl+r")); - editor.handle(Input::Char('7')); - assert_eq!(editor.count(), Some(8)); - assert_eq!(editor.sequence_pending(), Some("Space g Ctrl+r 7")); - assert_eq!( - editor.handle(Input::Char('I')), - Some(Invocation { - plugin: "fixture".into(), - command: "greet".into(), - args: String::new(), - }) - ); - assert_eq!(editor.selection_count(), 2); - assert_eq!(editor.context().text, "aa\nbb"); - assert_eq!(editor.count(), None); - assert_eq!(editor.sequence_pending(), None); - editor.handle(Input::Ctrl('p')); - assert_eq!( - palette_entry(&editor, "greet").bindings, - vec!["Space g Ctrl+r 7 I"] - ); - } - - #[test] - fn custom_leader_is_symbolic_and_ordinary_trigger_bindings_preempt_by_mode() { - let mut editor = editor(); - type_text(&mut editor, "3 b"); - editor.set_leader("comma").unwrap(); - assert_eq!(editor.sequence_pending(), None); - assert_eq!(editor.count(), None); - editor.handle(Input::Ctrl('p')); - assert_eq!( - palette_entry(&editor, "core.buffer_next").bindings, - vec![", b n"] - ); - editor - .bind("normal", ",", Some("core.command".into())) - .unwrap(); - assert!( - palette_entry(&editor, "core.buffer_next") - .bindings - .is_empty() - ); - editor.set_leader(",").unwrap(); - editor.handle(Input::Escape); - editor.handle(Input::Char(',')); - assert_eq!(editor.mode(), Mode::Command); - assert_eq!(editor.sequence_pending(), None); - editor.handle(Input::Escape); - editor.handle(Input::Char('v')); - type_text(&mut editor, ", "); - assert!(editor.palette().is_some()); - assert_eq!(editor.mode(), Mode::Visual); - editor.handle(Input::Escape); - editor.handle(Input::Escape); - editor.bind("normal", ",", None).unwrap(); - type_text(&mut editor, "4,"); - assert_eq!(editor.count(), None); - assert_eq!(editor.sequence_pending(), None); - editor.set_leader("\\").unwrap(); - type_text(&mut editor, "\\b"); - assert_eq!(editor.sequence_pending(), Some("\\ b")); - editor.handle(Input::Char('n')); - assert_eq!(editor.sequence_pending(), None); - editor.handle(Input::Ctrl('p')); - assert_eq!( - palette_entry(&editor, "core.buffer_next").bindings, - vec!["\\ b n"] - ); - } - - #[test] - fn modified_key_sequences_preserve_counts_and_cancel_without_edits() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) - .unwrap(); - editor - .bind("normal", "ctrl+w ctrl+j 0", Some("core.down".into())) - .unwrap(); - editor - .bind("visual", "ctrl+w j", Some("core.down".into())) - .unwrap(); - editor.handle(Input::Char('2')); - editor.handle(Input::Ctrl('w')); - editor.handle(Input::Ctrl('j')); - editor.handle(Input::Char('0')); - assert_eq!(editor.cursor().row, 2); - assert_eq!(editor.count(), None); - editor.handle(Input::Ctrl('w')); - editor.handle(Input::Char('i')); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.cursor().row, 2); - assert_eq!(editor.sequence_pending(), None); - editor.handle(Input::Char('v')); - let selections = editor.selections().to_vec(); - editor.handle(Input::Char('3')); - editor.handle(Input::Ctrl('w')); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.count(), None); - editor.handle(Input::Ctrl('w')); - editor.handle(Input::Paste("ignored".into())); - assert_eq!(editor.sequence_pending(), None); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.lines(), &["a", "b", "c", "d"]); - } - - #[test] - fn sequence_prefix_conflicts_and_invalid_replacements_are_atomic() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a\nb\nc".into())]) - .unwrap(); - editor - .bind("normal", "ctrl+w j", Some("core.down".into())) - .unwrap(); - for (key, target) in [ - ("ctrl+w", Some("core.up")), - ("ctrl+w j k", None), - ("ctrl+w j", Some("missing")), - ("esc j", Some("core.down")), - ("2 j", Some("core.down")), - ("space j", Some("core.down")), - ("ctrl+i j", Some("core.down")), - ] { - assert!( - editor - .bind("normal", key, target.map(str::to_owned)) - .is_err(), - "{key}" - ); - } - assert!(editor.set_leader("ctrl+w").is_err()); - editor.handle(Input::Ctrl('w')); - editor.handle(Input::Char('j')); - assert_eq!(editor.cursor().row, 1); - editor.bind("normal", "ctrl+w j", None).unwrap(); - assert!( - editor - .bind("normal", "ctrl+w j k", Some("core.down".into())) - .is_err() - ); - editor.handle(Input::Ctrl('w')); - editor.handle(Input::Char('j')); - assert_eq!(editor.cursor().row, 1); - assert_eq!(editor.sequence_pending(), None); - editor.bind("normal", "ctrl+x", None).unwrap(); - assert!( - editor - .bind("normal", "ctrl+x j", Some("core.down".into())) - .is_err() - ); - } - - #[test] - fn modified_key_sequences_focus_panes_without_replacing_the_leader() { - let directory = Directory::new(); - let mut editor = editor(); - editor.project_root = directory.0.clone(); - editor - .apply(vec![Effect::Replace("first\nsecond".into())]) - .unwrap(); - editor - .bind("normal", "ctrl+w j", Some("core.pane.focus_down".into())) - .unwrap(); - editor - .bind("normal", "ctrl+w h", Some("core.pane.focus_left".into())) - .unwrap(); - editor - .bind("normal", "leader z", Some("core.down".into())) - .unwrap(); - editor.handle(Input::Char('|')); - editor.handle(Input::Char('_')); - editor.handle(Input::Alt('k')); - assert_eq!(editor.pane_id(), 2); - editor.handle(Input::Ctrl('w')); - assert_eq!(editor.pane_id(), 2); - assert_eq!(editor.sequence_pending(), Some("Ctrl+w")); - editor.handle(Input::Char('j')); - assert_eq!(editor.pane_id(), 3); - assert_eq!(editor.sequence_pending(), None); - type_text(&mut editor, " z"); - assert_eq!(editor.cursor().row, 1); - editor.open_file_picker(FilePresentation::Docked); - editor.handle(Input::Ctrl('w')); - editor.handle(Input::Char('h')); - assert_eq!(editor.pane_id(), 1); - assert!(!editor.file_picker().unwrap().focused); - assert_eq!(editor.lines(), &["first", "second"]); - } - - #[test] - fn leader_prefix_conflicts_and_invalid_replacements_are_atomic() { - let mut editor = editor(); - assert!(editor.bind("normal", "leader b", None).is_err()); - editor - .bind("normal", "leader z", Some("core.down".into())) - .unwrap(); - assert!(editor.bind("normal", "leader z n", None).is_err()); - assert!( - editor - .bind("normal", "leader z", Some("missing".into())) - .is_err() - ); - editor - .apply(vec![Effect::Replace("a\nb\nc".into())]) - .unwrap(); - type_text(&mut editor, " z"); - assert_eq!(editor.cursor().row, 1); - editor.bind("normal", "leader z", None).unwrap(); - assert!( - editor - .bind("normal", "leader z n", Some("core.down".into())) - .is_err() - ); - type_text(&mut editor, "2 z"); - assert_eq!(editor.cursor().row, 1); - assert_eq!(editor.count(), None); - editor.bind("normal", "leader g n", None).unwrap(); - assert!(editor.bind("normal", "leader g", None).is_err()); - for key in ["leader", "leader ctrl+", "leader b ctrl++r", "leader "] { - assert!(editor.bind("normal", key, None).is_err(), "{key}"); - } - editor - .bind("normal", " LEADER\tz ", Some("core.up".into())) - .unwrap(); - type_text(&mut editor, " z"); - assert_eq!(editor.cursor().row, 0); - } - - #[test] - fn leader_validation_preserves_choice_and_reuses_action_mode_eligibility() { - let mut editor = editor(); - editor.register(plugin("fixture", &["greet"])).unwrap(); - for mode in ["insert", "command", "search", "unknown"] { - assert!(editor.bind(mode, "leader z", None).is_err(), "{mode}"); - } - assert!( - editor - .bind("visual", "leader z", Some("core.insert".into())) - .is_err() - ); - assert!( - editor - .bind("visual", "leader z", Some("greet".into())) - .is_err() - ); - editor - .bind("visual", "leader z", Some("core.right".into())) - .unwrap(); - editor.bind("normal", "comma", None).unwrap(); - editor.bind("visual", "\\", None).unwrap(); - for key in [ - "", "esc", "ESC", "0", "9", "i", "ctrl+p", ",", "\\", "leader b", "alt+j", - ] { - assert!(editor.set_leader(key).is_err(), "{key}"); - assert_eq!(editor.leader, Input::Char(' ')); - } - editor.bind("insert", "界", None).unwrap(); - editor.set_leader("界").unwrap(); - editor.handle(Input::Char('界')); - assert_eq!(editor.sequence_pending(), Some("界")); - editor.handle(Input::Escape); - editor.set_leader("space").unwrap(); - editor.bind("normal", "space", None).unwrap(); - editor.set_leader("SPACE").unwrap(); - assert_eq!(editor.leader, Input::Char(' ')); - } - - #[test] - fn leader_escape_unknown_and_paste_cancel_count_without_changing_selections() { - for visual in [false, true] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abcd\nabcd".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 1), (1, 1)], 1); - if visual { - editor - .bind("visual", "leader b n", Some("core.down".into())) - .unwrap(); - type_text(&mut editor, "vl"); - } - let mode = editor.mode(); - let carets = editor.carets(); - let selections = editor.selections(); - for prefix in [" b", "3 b"] { - type_text(&mut editor, prefix); - assert_eq!(editor.sequence_pending(), Some("Space b")); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), mode); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.count(), None); - assert_eq!(editor.sequence_pending(), None); - } - type_text(&mut editor, "4 b"); - editor.handle(Input::Char('x')); - assert_eq!(editor.context().text, "abcd\nabcd"); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.mode(), mode); - assert_eq!(editor.count(), None); - assert_eq!(editor.sequence_pending(), None); - type_text(&mut editor, "5 b"); - editor.handle(Input::Paste("n".into())); - assert_eq!(editor.context().text, "abcd\nabcd"); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.count(), None); - assert_eq!(editor.sequence_pending(), None); - editor.handle(Input::Escape); - if !visual { - assert!(!editor.notification_visible()); - assert_eq!(editor.selection_count(), 2); - editor.handle(Input::Escape); - } - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.selection_count(), if visual { 2 } else { 1 }); - } - } - - #[test] - fn leader_digit_suffix_uses_pending_count_for_remapped_motion_not_new_count() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace(["abcd"; 12].join("\n"))]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor - .bind("normal", "leader g 0 2", Some("core.down".into())) - .unwrap(); - type_text(&mut editor, "3 g0"); - assert_eq!(editor.count(), Some(3)); - assert_eq!(editor.sequence_pending(), Some("Space g 0")); - editor.handle(Input::Char('2')); - assert_eq!( - editor.carets(), - vec![Cursor { row: 3, column: 0 }, Cursor { row: 4, column: 0 }] - ); - assert_eq!(editor.count(), None); - assert_eq!(editor.sequence_pending(), None); - editor - .bind("visual", "leader 7", Some("core.right".into())) - .unwrap(); - editor.handle(Input::Char('v')); - type_text(&mut editor, "2 7"); - assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selection_count(), 2); - assert_eq!(editor.count(), None); - } - - #[test] - fn leader_keys_are_literal_in_insert_prompts_and_palette_queries() { - for (key, ch) in [("space", ' '), ("comma", ',')] { - for mode_key in ['i', ':', '/'] { - let mut editor = editor(); - editor.set_leader(key).unwrap(); - editor.handle(Input::Char(mode_key)); - let text = format!("{ch}b n{ch}"); - type_text(&mut editor, &text); - assert_eq!(editor.sequence_pending(), None); - assert_eq!(editor.count(), None); - if mode_key == 'i' { - assert_eq!(editor.context().text, text); - assert_eq!(editor.mode(), Mode::Insert); - } else { - assert_eq!(editor.command_line(), text); - assert_eq!( - editor.mode(), - if mode_key == ':' { - Mode::Command - } else { - Mode::Search - } - ); - } - let mode = editor.mode(); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core word next "); - assert_eq!(editor.palette().unwrap().query, "core word next "); - assert_eq!(editor.sequence_pending(), None); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), mode); - } - } - } - - #[test] - fn leader_routes_retire_with_plugins_and_binding_changes_cancel_pending() { - let mut editor = editor(); - editor.register(plugin("fixture", &["greet"])).unwrap(); - editor.bind("normal", "g", Some("greet".into())).unwrap(); - editor - .bind("normal", "leader g n", Some("greet".into())) - .unwrap(); - type_text(&mut editor, "3 g"); - editor - .bind("normal", "leader g n", Some("core.down".into())) - .unwrap(); - assert_eq!(editor.sequence_pending(), None); - assert_eq!(editor.count(), None); - type_text(&mut editor, "2 g"); - editor.bind("normal", "z", None).unwrap(); - assert_eq!(editor.sequence_pending(), None); - assert_eq!(editor.count(), None); - editor - .bind("normal", "leader g n", Some("greet".into())) - .unwrap(); - type_text(&mut editor, "4 g"); - editor.disable_plugin("fixture", "retired"); - assert_eq!(editor.sequence_pending(), None); - assert_eq!(editor.count(), None); - assert_eq!(editor.handle(Input::Char('g')), None); - type_text(&mut editor, " g"); - assert_eq!(editor.sequence_pending(), None); - assert!( - editor - .bind("normal", "leader g n", Some("greet".into())) - .is_err() - ); - editor.handle(Input::Ctrl('p')); - assert!( - !editor - .palette() - .unwrap() - .entries - .iter() - .any(|entry| entry.name == "greet") - ); - } - - #[test] - fn pending_leader_never_survives_external_commands_or_buffer_transitions() { - let directory = Directory::new(); - let mut editor = editor(); - editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); - editor.execute(&format!("edit {}", directory.path("other").display())); - editor.set_leader(",").unwrap(); - for command in ["buffer 1", "buffer 1", "bnext", "bprev", "buffer 999"] { - type_text(&mut editor, "3,b"); - assert_eq!(editor.sequence_pending(), Some(", b")); - editor.execute(command); - assert_eq!(editor.sequence_pending(), None); - assert_eq!(editor.count(), None); - editor.handle(Input::Char(',')); - assert_eq!(editor.sequence_pending(), Some(",")); - editor.handle(Input::Escape); - } - editor.execute("buffer 1"); - type_text(&mut editor, "vl"); - let selections = editor.selections(); - type_text(&mut editor, "2,"); - editor.execute("buffer 2"); - editor.execute("buffer 1"); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.sequence_pending(), None); - assert_eq!(editor.count(), None); - editor.handle(Input::Char(',')); - editor - .apply(vec![Effect::Replace("replacement".into())]) - .unwrap(); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.sequence_pending(), None); - editor - .bind("normal", "leader i", Some("core.insert".into())) - .unwrap(); - type_text(&mut editor, ",i"); - assert_eq!(editor.mode(), Mode::Insert); - assert_eq!(editor.sequence_pending(), None); - } - - #[test] - fn counted_cursor_motions_apply_to_all_carets_and_clamp_through_short_rows() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace( - "e\u{301}界👩‍🚀z\n\nlong\nfour\naa\nbbbb\ncccc".into(), - )]) - .unwrap(); - set_carets(&mut editor, &[(0, 3), (4, 1)], 1); - let undo = editor.active.undo.len(); - type_text(&mut editor, "2j"); - assert_eq!( - editor.carets(), - vec![Cursor { row: 2, column: 0 }, Cursor { row: 6, column: 1 }] - ); - assert_eq!(editor.cursor(), Cursor { row: 6, column: 1 }); - type_text(&mut editor, "100k"); - assert_eq!(editor.carets(), vec![Cursor::default()]); - type_text(&mut editor, "2l"); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); - type_text(&mut editor, "9h"); - assert_eq!(editor.cursor(), Cursor::default()); - type_text(&mut editor, "999999999999999999999999999999999999"); - assert_eq!(editor.count(), Some(usize::MAX)); - editor.handle(Input::Down); - assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 }); - assert_eq!(editor.count(), None); - assert_eq!(editor.active.undo.len(), undo); - assert_eq!( - editor.lines(), - &["e\u{301}界👩‍🚀z", "", "long", "four", "aa", "bbbb", "cccc"] - ); - } - - #[test] - fn counted_words_match_repeated_unicode_motions_without_intermediate_selection_merging() { - let text = "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail\n"; - for key in ['w', 'b', 'e', 'W', 'B', 'E'] { - for positions in [&[(0, 0), (3, 0)][..], &[(0, 8), (3, 8)][..]] { - let mut counted = editor(); - counted.apply(vec![Effect::Replace(text.into())]).unwrap(); - set_carets(&mut counted, positions, 1); - let mut repeated = editor(); - repeated.apply(vec![Effect::Replace(text.into())]).unwrap(); - set_carets(&mut repeated, positions, 1); - type_text(&mut counted, &format!("3{key}")); - for _ in 0..3 { - repeated.handle(Input::Char(key)); - } - assert_eq!(counted.carets(), repeated.carets(), "{key} {positions:?}"); - assert_eq!(counted.cursor(), repeated.cursor(), "{key} {positions:?}"); - } - } - let mut editor = editor(); - editor - .apply(vec![Effect::Replace( - "one two three\none longer third".into(), - )]) - .unwrap(); - editor.handle(Input::Ctrl('e')); - editor.handle(Input::Char('v')); - type_text(&mut editor, "2e"); - assert_eq!( - editor.selections(), - vec![ - Selection { - start: Cursor { row: 0, column: 0 }, - end: Cursor { row: 0, column: 7 } - }, - Selection { - start: Cursor { row: 1, column: 0 }, - end: Cursor { row: 1, column: 10 } - }, - ] - ); - editor.handle(Input::Char('c')); - type_text(&mut editor, "item"); - editor.handle(Input::Escape); - assert_eq!(editor.lines(), &["item three", "item third"]); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), &["one two three", "one longer third"]); - assert_eq!(editor.selection_count(), 2); - type_text(&mut editor, &format!("{}w", usize::MAX)); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 15 }); - } - - #[test] - fn count_digits_zero_and_escape_preserve_modal_editing_and_custom_bindings() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace(["abc"; 12].join("\n"))]) - .unwrap(); - type_text(&mut editor, "10j"); - assert_eq!(editor.cursor(), Cursor { row: 10, column: 0 }); - editor.handle(Input::End); - type_text(&mut editor, "0"); - assert_eq!(editor.cursor().column, 0); - editor.handle(Input::Ctrl('k')); - editor.handle(Input::Char('v')); - let selected = editor.selections(); - type_text(&mut editor, "4"); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selections(), selected); - assert_eq!(editor.count(), None); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.selection_count(), 2); - editor.bind("normal", "2", None).unwrap(); - type_text(&mut editor, "42"); - assert_eq!(editor.count(), None); - editor - .bind("normal", "3", Some("core.down".into())) - .unwrap(); - type_text(&mut editor, "3"); - assert_eq!(editor.cursor().row, 10); - assert_eq!(editor.count(), None); - type_text(&mut editor, "5i12"); - editor.handle(Input::Escape); - assert_eq!(editor.lines()[10], "12abc"); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines()[10], "abc"); - editor.handle(Input::Char('/')); - type_text(&mut editor, "123"); - assert_eq!(editor.command_line(), "123"); - assert_eq!(editor.count(), None); - } - - #[test] - fn counted_search_wraps_per_target_and_unrelated_actions_execute_once() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("cat cat cat\ncat cat cat".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - submit_search(&mut editor, "cat"); - editor.handle(Input::Char('n')); - type_text(&mut editor, "2n"); - assert_eq!( - editor.carets(), - vec![Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 0 }] - ); - type_text(&mut editor, "2N"); - assert_eq!( - editor.carets(), - vec![Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 4 }] - ); - editor.register(plugin("once", &["once"])).unwrap(); - editor.bind("normal", "g", Some("once".into())).unwrap(); - type_text(&mut editor, "8"); - let invocation = editor.handle(Input::Char('g')).unwrap(); - assert_eq!(invocation.command, "once"); - assert_eq!(editor.count(), None); - type_text(&mut editor, "7"); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core.word_next"); - editor.handle(Input::Enter); - assert_eq!( - editor.carets(), - vec![Cursor { row: 0, column: 8 }, Cursor { row: 1, column: 8 }] - ); - assert_eq!(editor.count(), None); - type_text(&mut editor, "9"); - editor.handle(Input::Paste("ignored".into())); - editor.handle(Input::Char('h')); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 7 }); - } - fn submit_search(editor: &mut Editor, query: &str) { - editor.handle(Input::Char('/')); - type_text(editor, query); - editor.handle(Input::Enter); - } - fn plugin(name: &str, commands: &[&str]) -> Feature { - Feature { - name: name.into(), - required_capabilities: vec!["text".into()], - commands: commands.iter().map(|name| (*name).into()).collect(), - core: false, - } - } - - #[test] - fn character_seek_counts_are_strict_line_local_and_atomic_when_missing() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("xaxaax\nx\n\nx".into())]) - .unwrap(); - let undo = editor.active.undo.len(); - for (column, keys, expected) in [ - (0, "fx", 2), - (0, "2fx", 5), - (0, "3fx", 0), - (5, "Fx", 2), - (5, "2Fx", 0), - (5, "3Fx", 5), - (2, "fz", 2), - (0, "999999999999999999999999fx", 0), - ] { - set_carets(&mut editor, &[(0, column)], 0); - type_text(&mut editor, keys); - assert_eq!( - editor.cursor(), - Cursor { - row: 0, - column: expected - } - ); - assert_eq!(editor.count(), None); - assert_eq!(editor.seek_pending(), None); - } - for row in [1, 2, 3] { - set_carets(&mut editor, &[(row, 0)], 0); - type_text(&mut editor, "fxFx"); - assert_eq!(editor.cursor(), Cursor { row, column: 0 }); - } - assert_eq!(editor.active.undo.len(), undo); - } - - #[test] - fn character_seek_consumes_literal_targets_before_bindings_counts_and_prefixes() { - for target in ['0', '2', 'g', 'f', 'F', '[', ']', ' ', '\t'] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace(format!("a{target}b{target}"))]) - .unwrap(); - let key = display_key(&Input::Char(target)); - editor - .bind("normal", &key, Some("core.right".into())) - .unwrap(); - editor - .bind("normal", "z", Some("core.seek_forward".into())) - .unwrap(); - type_text(&mut editor, "2z"); - editor.handle(Input::Char(target)); - assert_eq!(editor.cursor().column, 3); - assert_eq!(editor.count(), None); - assert!(!editor.g_pending()); - assert!(editor.sequence_pending().is_none()); - assert!(editor.seek_pending().is_none()); - } - let mut editor = editor(); - editor.apply(vec![Effect::Replace("axbx".into())]).unwrap(); - editor - .bind("normal", "f", Some("core.right".into())) - .unwrap(); - type_text(&mut editor, "f"); - assert_eq!(editor.cursor().column, 1); - assert!(editor.seek_pending().is_none()); - editor - .bind("normal", "leader f", Some("core.seek_forward".into())) - .unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - type_text(&mut editor, "2 fx"); - assert_eq!(editor.cursor().column, 3); - for mode in ["insert", "command", "search", "selection", "tree"] { - assert!( - editor - .bind(mode, "z", Some("core.seek_forward".into())) - .is_err() - ); - assert!( - editor - .bind(mode, "z", Some("core.seek_backward".into())) - .is_err() - ); - } - editor.apply(vec![Effect::Replace(String::new())]).unwrap(); - type_text(&mut editor, "ifFgf"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "fFgf"); - } - - #[test] - fn character_seek_unicode_scalars_land_on_graphemes_for_all_carets_and_visual_shapes() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace( - "界e\u{301}👩‍🚀e\u{301}x\n\naxax".into(), - )]) - .unwrap(); - type_text(&mut editor, "f\u{301}"); - assert_eq!(editor.cursor().column, 1); - type_text(&mut editor, "f🚀"); - assert_eq!(editor.cursor().column, 2); - type_text(&mut editor, "F\u{301}"); - assert_eq!(editor.cursor().column, 1); - type_text(&mut editor, "F\u{301}"); - assert_eq!(editor.cursor().column, 1); // The current grapheme is excluded. - for selector in ["", "v", "V"] { - set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); - type_text(&mut editor, selector); - type_text(&mut editor, "2fx"); - assert_eq!( - editor.carets(), - vec![ - Cursor { row: 0, column: 0 }, - Cursor { row: 1, column: 0 }, - Cursor { row: 2, column: 3 }, - ] - ); - assert_eq!(editor.visual_linewise(), selector == "V"); - } - } - - #[test] - fn character_seek_escape_and_lifecycle_cancel_pending_without_changing_selection() { - let directory = Directory::new(); - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("axbx\nnext".into())]) - .unwrap(); - for selector in ["", "v", "V"] { - set_carets(&mut editor, &[(0, 0)], 0); - type_text(&mut editor, selector); - let mode = editor.mode(); - let targets = editor.active.view.targets.clone(); - type_text(&mut editor, "2f"); - assert_eq!(editor.seek_pending(), Some('f')); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), mode); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.count(), None); - assert_eq!(editor.seek_pending(), None); - } - set_carets(&mut editor, &[(0, 0)], 0); - type_text(&mut editor, "2f"); - editor.handle(Input::Right); - assert_eq!(editor.cursor().column, 1); - assert_eq!(editor.seek_pending(), None); - type_text(&mut editor, "2f"); - editor.handle(Input::Paste("x".into())); - assert_eq!(editor.cursor().column, 1); - assert_eq!(editor.seek_pending(), None); - for command in ["plugins", "bnext", "bprev", "buffer 1"] { - type_text(&mut editor, "f"); - editor.execute(command); - assert_eq!(editor.seek_pending(), None); - } - type_text(&mut editor, "f"); - editor - .bind("normal", "z", Some("core.right".into())) - .unwrap(); - assert_eq!(editor.seek_pending(), None); - type_text(&mut editor, "f"); - editor - .bind("selection", "z", Some("core.down".into())) - .unwrap(); - assert_eq!(editor.seek_pending(), None); - type_text(&mut editor, "f"); - editor.set_leader(",").unwrap(); - assert_eq!(editor.seek_pending(), None); - type_text(&mut editor, "f"); - editor - .navigate_to( - directory.path("missing"), - TextPosition { row: 0, column: 0 }, - ) - .unwrap_err(); - assert_eq!(editor.seek_pending(), None); - type_text(&mut editor, "f"); - editor.execute("palette"); - assert_eq!(editor.seek_pending(), None); - editor.handle(Input::Escape); - type_text(&mut editor, "f"); - editor - .apply(vec![Effect::Replace("replacement".into())]) - .unwrap(); - assert_eq!(editor.seek_pending(), None); - } - - #[test] - fn character_seek_visual_edit_replay_resolves_target_direction_and_count_fresh() { - for (seek, original, replacement, column) in [ - ("2fx", "axbxTAIL", "aLONGxbLONGxTAIL", 0), - ("2Fx", "xaxbTAIL", "xLONGxaLONGbTAIL", 11), - ] { - for change in [false, true] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace(original.into())]) - .unwrap(); - let start = if seek == "2Fx" { 3 } else { 0 }; - set_carets(&mut editor, &[(0, start)], 0); - type_text( - &mut editor, - &format!("v{seek}{}", if change { "c!" } else { "d" }), - ); - if change { - editor.handle(Input::Escape); - } - editor - .bind("visual", "f", Some("core.left".into())) - .unwrap(); - editor - .bind("visual", "F", Some("core.right".into())) - .unwrap(); - editor - .bind("visual", "x", Some("core.line_end".into())) - .unwrap(); - editor - .apply(vec![Effect::Replace(replacement.into())]) - .unwrap(); - set_carets(&mut editor, &[(0, column)], 0); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, if change { "!TAIL" } else { "TAIL" }); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.seek_pending(), None); - } - } - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("ax\nkeep".into())]) - .unwrap(); - type_text(&mut editor, "Vfxd"); - editor - .apply(vec![Effect::Replace("alongx\nkeep".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "keep"); - assert!(matches!( - &editor.last_edit.as_ref().unwrap().start, - EditStart::Selection { - selector: SelectionIntent::Motions { linewise: true, .. }, - .. - } - )); - } - - #[test] - fn readonly_diff_character_seek_consumes_brackets_and_tab_before_pane_dispatch() { - let directory = Directory::new(); - let mut editor = editor(); - historical_diff( - &mut editor, - &directory.0, - vec!["a[b]\tc".into()], - vec!["a[b]\td".into()], - ); - for focus in [Focus::Before, Focus::After] { - if editor.diff().unwrap().focus != focus { - editor.handle(Input::Char('\t')); - } - let text = editor.context().text; - let rows = editor.diff().unwrap().display_rows.clone(); - type_text(&mut editor, "f["); - assert_eq!(editor.cursor().column, 1); - type_text(&mut editor, "f]"); - assert_eq!(editor.cursor().column, 3); - type_text(&mut editor, "f\t"); - assert_eq!(editor.cursor().column, 4); - type_text(&mut editor, "F["); - assert_eq!(editor.cursor().column, 1); - assert_eq!(editor.diff().unwrap().focus, focus); - assert_eq!(editor.diff().unwrap().display_rows, rows); - assert_eq!(editor.context().text, text); - assert!(editor.active.undo.is_empty()); - } - } - - #[test] - fn normal_edits_merge_overlapping_ranges_and_replay_without_source_keys() { - for (keys, positions, expected) in [ - ("C", [(0, 0), (0, 4)], "!\nkeep"), - ("D", [(0, 0), (0, 4)], "\nkeep"), - ("4s", [(0, 0), (0, 2)], "!o three four\nkeep"), - ("d2w", [(0, 0), (0, 4)], "four\nkeep"), - ("d2b", [(0, 8), (0, 14)], "four\nkeep"), - ("c2w", [(0, 0), (0, 4)], "!four\nkeep"), - ("c2b", [(0, 8), (0, 14)], "!four\nkeep"), - ("4rX", [(0, 0), (0, 2)], "XXXXXXo three four\nkeep"), - ] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("one two three four\nkeep".into())]) - .unwrap(); - set_carets(&mut editor, &positions, 1); - let original = editor.active.view.targets.clone(); - let undo = editor.active.undo.len(); - type_text(&mut editor, keys); - if editor.mode() == Mode::Insert { - type_text(&mut editor, "!"); - editor.handle(Input::Escape); - } - assert_eq!(editor.context().text, expected, "{keys}"); - assert_eq!(editor.active.undo.len(), undo + 1, "{keys}"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "one two three four\nkeep", "{keys}"); - assert_eq!(editor.active.view.targets, original, "{keys}"); - assert_eq!(editor.active.view.primary, 1, "{keys}"); - for key in ["C", "D", "s", "r", "w", "b"] { - editor - .bind("normal", key, Some("core.right".into())) - .unwrap(); - } - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, expected, "{keys}"); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.selection_count(), 1); - } - } - - #[test] - fn normal_yank_merges_suffix_fragments_without_merging_carets_or_history() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abcdef\nkeep".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 1), (0, 3)], 1); - let targets = editor.active.view.targets.clone(); - let undo = editor.active.undo.len(); - editor.handle(Input::Char('Y')); - assert_eq!(editor.clipboard, ["bcdef"]); - assert!(!editor.clipboard_linewise); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.active.view.primary, 1); - assert_eq!(editor.active.undo.len(), undo); - assert!(editor.last_edit.is_none()); - } - - #[test] - fn readonly_normal_edit_actions_and_pending_replace_preserve_document_and_history() { - let directory = Directory::new(); - let mut editor = editor(); - historical_diff( - &mut editor, - &directory.0, - vec!["one two three".into()], - vec!["four five six".into()], - ); - for focus in [Focus::Before, Focus::After] { - if editor.diff().unwrap().focus != focus { - editor.handle(Input::Char('\t')); - } - let text = editor.context().text; - let targets = editor.active.view.targets.clone(); - let undo = editor.active.undo.len(); - let clipboard = editor.clipboard.clone(); - for keys in ["C", "D", "s", "2r", "d2w", "d2b", "c2w", "c2b"] { - type_text(&mut editor, keys); - assert_eq!(editor.context().text, text, "{keys}"); - assert_eq!(editor.active.view.targets, targets, "{keys}"); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.count(), None); - assert_eq!(editor.line_operator_pending(), None); - assert_eq!(editor.clipboard, clipboard); - assert_eq!(editor.active.undo.len(), undo); - } - // A pending replacement must still guard mutation if editability changed. - editor.line_pending = Some(('r', 2)); - editor.handle(Input::Char('X')); - assert_eq!(editor.context().text, text); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.active.undo.len(), undo); - assert_eq!(editor.line_operator_pending(), None); - editor.handle(Input::Char('Y')); - assert_eq!(editor.clipboard, [text]); - assert!(!editor.clipboard_linewise); - } - } - - #[test] - fn readonly_visual_replacement_preserves_snapshot_selections_and_history() { - let directory = Directory::new(); - let mut editor = editor(); - historical_diff( - &mut editor, - &directory.0, - vec!["one two three".into()], - vec!["four five six".into()], - ); - for focus in [Focus::Before, Focus::After] { - if editor.diff().unwrap().focus != focus { - editor.handle(Input::Char('\t')); - } - type_text(&mut editor, "vl"); - let text = editor.context().text; - let targets = editor.active.view.targets.clone(); - let undo = editor.active.undo.len(); - let clipboard = editor.clipboard.clone(); - for pending in [false, true] { - if pending { - editor.line_pending = Some(('r', 1)); - } else { - editor.handle(Input::Char('r')); - } - editor.handle(Input::Char('X')); - assert_eq!(editor.context().text, text); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.line_operator_pending(), None); - assert_eq!(editor.clipboard, clipboard); - assert_eq!(editor.active.undo.len(), undo); - } - editor.handle(Input::Escape); - } - } - - fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) { - editor.active.view.mode = Mode::Normal; - editor.active.view.visual_motions = None; - editor.active.view.targets = positions - .iter() - .map(|&(row, column)| Target::caret(Cursor { row, column })) - .collect(); - editor.active.view.primary = primary; - editor.normalize_targets(); - } - - fn set_ranges(editor: &mut Editor, ranges: &[(Cursor, Cursor)], primary: usize) { - editor.active.view.mode = Mode::Visual; - editor.active.view.visual_motions = None; - editor.active.view.targets = ranges - .iter() - .map(|&(start, end)| editor.range_target(Selection { start, end }, true)) - .collect(); - editor.active.view.primary = primary; - editor.normalize_targets(); - } - - #[test] - fn linewise_selection_preserves_bidirectional_endpoints_and_selector_toggles() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a界z\nb\nccc\n".into())]) - .unwrap(); - type_text(&mut editor, "jjlVkh"); - assert!(editor.visual_linewise()); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - assert_eq!(editor.linewise_rows().collect::>(), [1..=2]); - assert_eq!( - editor.selections(), - [Selection { - start: Cursor { row: 1, column: 0 }, - end: Cursor { row: 3, column: 0 }, - }] - ); - editor.handle(Input::Char('v')); - assert!(!editor.visual_linewise()); - assert_eq!(editor.selections()[0].end, Cursor { row: 2, column: 2 }); - editor.handle(Input::Char('V')); - assert_eq!(editor.linewise_rows().collect::>(), [1..=2]); - editor.handle(Input::Char('V')); - assert_eq!(editor.mode(), Mode::Normal); - assert!(!editor.visual_linewise()); - type_text(&mut editor, "Vj"); - assert_eq!(editor.linewise_rows().collect::>(), [1..=2]); - editor.handle(Input::Escape); - assert!(editor.linewise_rows().next().is_none()); - type_text(&mut editor, "vv"); - assert_eq!(editor.mode(), Mode::Normal); - } - - #[test] - fn linewise_parking_cloning_merging_and_row_insertions_preserve_primary() { - let directory = Directory::new(); - let other = directory.path("other"); - fs::write(&other, "other").unwrap(); - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abc\ndef\nghi\n".into())]) - .unwrap(); - type_text(&mut editor, "lV"); - editor.handle(Input::Ctrl('e')); - assert_eq!(editor.linewise_rows().collect::>(), [0..=0, 1..=1]); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); - editor.execute(&format!("edit {}", other.display())); - assert!(!editor.visual_linewise()); - editor.execute("buffer 1"); - assert!(editor.visual_linewise()); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); - editor.handle(Input::Char('j')); - assert_eq!(editor.selection_count(), 1); - assert_eq!(editor.linewise_rows().collect::>(), [0..=2]); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 }); - editor.handle(Input::Char('v')); - assert_eq!(editor.selections()[0].start, Cursor { row: 0, column: 1 }); - editor.handle(Input::Char('V')); - editor.handle(Input::Char('A')); - type_text(&mut editor, "!"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "abc!\ndef!\nghi!\n"); - assert_eq!(editor.selection_count(), 3); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "abc\ndef\nghi\n"); - set_carets(&mut editor, &[(2, 0)], 0); - type_text(&mut editor, "Vj"); - editor.handle(Input::Ctrl('l')); - assert_eq!( - editor.carets(), - [Cursor { row: 2, column: 0 }, Cursor { row: 3, column: 0 }] - ); - assert!(!editor.visual_linewise()); - } - - #[test] - fn linewise_paste_inserts_above_or_below_rows_across_eof_and_empty_rows() { - for (text, row, key, expected) in [ - ("x\ny", 0, 'p', "x\n a\n\ny"), - ("x\ny", 0, 'P', " a\n\nx\ny"), - ("x\ny", 1, 'p', "x\ny\n a\n"), - ("x\ny", 1, 'P', "x\n a\n\ny"), - ("x\n", 1, 'p', "x\n\n a\n"), - ("x\n", 1, 'P', "x\n a\n\n"), - ("", 0, 'p', "\n a\n"), - ("", 0, 'P', " a\n\n"), - ] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace(" a\n\nlast".into())]) - .unwrap(); - type_text(&mut editor, "Vjy"); - editor.apply(vec![Effect::Replace(text.into())]).unwrap(); - set_carets(&mut editor, &[(row, 1)], 0); - editor.handle(Input::Char(key)); - assert_eq!(editor.context().text, expected, "{text:?} {key}"); - let first = if key == 'p' { row + 1 } else { row }; - assert_eq!( - editor.cursor(), - Cursor { - row: first, - column: 1 - } - ); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, text); - assert_eq!(editor.mode(), Mode::Normal); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, expected); - } - } - - #[test] - fn linewise_empty_final_row_is_a_real_clipboard_row_and_cut_target() { - let mut editor = editor(); - editor.apply(vec![Effect::Replace("a\n".into())]).unwrap(); - type_text(&mut editor, "jVy"); - editor.apply(vec![Effect::Replace("z".into())]).unwrap(); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "z\n"); - editor.handle(Input::Char('u')); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "\nz"); - editor.apply(vec![Effect::Replace("a\n".into())]).unwrap(); - type_text(&mut editor, "jVd"); - assert_eq!(editor.context().text, "a"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "a\n"); - assert!(!editor.visual_linewise()); - type_text(&mut editor, "VcQ"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "a\nQ"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "a\n"); - } - - #[test] - fn linewise_cut_and_change_handle_eof_all_rows_and_group_typing_history() { - for (text, keys, expected) in [ - ("a\nbb\nccc", "jVjd", "a"), - ("a\nbb\nccc", "jVjcQ", "a\nQ"), - ("a\nbb\nccc", "V2jd", ""), - ("a\nbb\nccc", "V2jcQ", "Q"), - ("a\nbb\n", "V2jd", ""), - ("a\nbb\n", "V2jcQ", "Q"), - ("a\nbb\nccc", "VjcQ", "Q\nccc"), - ("", "VcQ", "Q"), - ] { - let mut editor = editor(); - editor.apply(vec![Effect::Replace(text.into())]).unwrap(); - type_text(&mut editor, keys); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, expected, "{keys}"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, text); - assert_eq!(editor.mode(), Mode::Normal); - assert!(!editor.visual_linewise()); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, expected); - } - } - - #[test] - fn linewise_multitarget_cut_change_and_paste_share_one_transaction() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a\nbb\nccc".into())]) - .unwrap(); - set_carets(&mut editor, &[(1, 1), (2, 1)], 1); - type_text(&mut editor, "Vd"); - assert_eq!(editor.context().text, "a"); - assert_eq!(editor.selection_count(), 1); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "a\nbb\nccc"); - assert_eq!(editor.cursor().row, 2); - type_text(&mut editor, "VcQ"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "a\nQ\nQ"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "a\nbb\nccc"); - editor.apply(vec![Effect::Replace("x\ny".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "bb\nx\nccc\ny"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "x\ny"); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "x\ny"); - assert!(editor.message().contains("count")); - } - - #[test] - fn linewise_paste_merges_identical_same_row_targets_and_refuses_conflicts() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("one\ntwo".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - type_text(&mut editor, "Vy"); - editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0), (0, 2)], 1); - let carets = editor.carets(); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "abcd"); - assert_eq!(editor.carets(), carets); - assert!(editor.message().contains("Conflicting")); - set_carets(&mut editor, &[(0, 0)], 0); - type_text(&mut editor, "Vy"); - set_carets(&mut editor, &[(0, 0), (0, 2)], 1); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "abcd\nabcd"); - assert_eq!(editor.selection_count(), 1); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "abcd"); - assert_eq!(editor.carets(), carets); - } - - #[test] - fn linewise_counted_search_and_occurrences_keep_shape_rules() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("hit a\nz\nhit b\nz\nhit c\n".into())]) - .unwrap(); - submit_search(&mut editor, "hit"); - editor.handle(Input::Char('n')); - assert_eq!(editor.cursor().row, 2); - type_text(&mut editor, "Vn"); - assert_eq!(editor.linewise_rows().collect::>(), [2..=4]); - type_text(&mut editor, "2N"); - assert_eq!(editor.linewise_rows().collect::>(), [0..=2]); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); - editor.handle(Input::Escape); - editor - .apply(vec![Effect::Replace("same\nsame\nlast".into())]) - .unwrap(); - editor.handle(Input::Char('V')); - editor.handle(Input::Ctrl('n')); - assert_eq!(editor.mode(), Mode::Visual); - assert!(!editor.visual_linewise()); - assert_eq!(editor.selection_count(), 2); - assert_eq!( - editor.selections(), - [ - Selection { - start: Cursor { row: 0, column: 0 }, - end: Cursor { row: 1, column: 0 } - }, - Selection { - start: Cursor { row: 1, column: 0 }, - end: Cursor { row: 2, column: 0 } - }, - ] - ); - } - - #[test] - fn linewise_dot_retargets_row_spans_and_captures_paste_shape() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a\nbb\nccc\nlast".into())]) - .unwrap(); - type_text(&mut editor, "Vjd"); - assert_eq!(editor.context().text, "ccc\nlast"); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "ccc\nlast"); - editor - .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) - .unwrap(); - type_text(&mut editor, "jVkcX"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "X\nc\nd"); - type_text(&mut editor, "jj."); - assert_eq!(editor.context().text, "X\nX"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "X\nc\nd"); - type_text(&mut editor, "V."); - assert_eq!(editor.context().text, "X\nc\nX"); - editor.apply(vec![Effect::Replace("row".into())]).unwrap(); - type_text(&mut editor, "Vyp"); - assert_eq!(editor.context().text, "row\nrow"); - editor.handle(Input::Char('u')); - type_text(&mut editor, "vy"); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "row\nrow"); - } - - #[test] - fn repeat_visual_word_change_resolves_fresh_words_for_every_caret_and_ignores_remaps() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("foo\nlonger".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - type_text(&mut editor, "vecitem"); - editor.handle(Input::Escape); - editor - .bind("visual", "e", Some("core.right".into())) - .unwrap(); - editor - .apply(vec![Effect::Replace("tiny\nextraordinary".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - let undo = editor.active.undo.len(); - type_text(&mut editor, "9."); - assert_eq!(editor.context().text, "item\nitem"); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.selection_count(), 2); - assert_eq!(editor.cursor().row, 1); - assert_eq!(editor.count(), None); - assert_eq!(editor.clipboard, vec!["tiny", "extraordinary"]); - assert_eq!(editor.active.undo.len(), undo + 1); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "tiny\nextraordinary"); - assert_eq!( - editor.carets(), - vec![Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 0 }] - ); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "item\nitem"); - editor.handle(Input::Char('u')); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "item\nitem"); - } - - #[test] - fn repeat_visual_current_ranges_supersede_unequal_recorded_spans_and_cardinality() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abcd\nlonger".into())]) - .unwrap(); - set_ranges( - &mut editor, - &[ - (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), - (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 5 }), - ], - 1, - ); - type_text(&mut editor, "cQ"); - editor.handle(Input::Escape); - editor - .apply(vec![Effect::Replace("alpha beta\nsecond\nthird".into())]) - .unwrap(); - set_ranges( - &mut editor, - &[ - (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 10 }), - (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 6 }), - (Cursor { row: 2, column: 0 }, Cursor { row: 2, column: 5 }), - ], - 2, - ); - let undo = editor.active.undo.len(); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "Q\nQ\nQ"); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.selection_count(), 3); - assert_eq!(editor.cursor().row, 2); - assert_eq!(editor.active.undo.len(), undo + 1); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "alpha beta\nsecond\nthird"); - } - - #[test] - fn repeat_cut_retargets_fresh_unequal_words_without_recreating_cloned_carets() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("cat\nlongword".into())]) - .unwrap(); - type_text(&mut editor, "ve"); - editor.handle(Input::Ctrl('e')); - editor.handle(Input::Char('d')); - editor - .apply(vec![Effect::Replace("small\nhugeword\nlast".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); - // Cloning invalidated the selector; both recorded spans have length three. - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "ll\neword\nt"); - assert_eq!(editor.selection_count(), 3); - editor - .apply(vec![Effect::Replace("cat\nlongword".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - type_text(&mut editor, "ved"); - editor - .apply(vec![Effect::Replace("tiny\nenormous".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "\n"); - assert_eq!(editor.clipboard, vec!["tiny", "enormous"]); - } - - #[test] - fn repeat_relative_spans_count_graphemes_and_newlines_and_keep_zero_spans_empty() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a界\n👩‍🚀x".into())]) - .unwrap(); - set_ranges( - &mut editor, - &[(Cursor { row: 0, column: 1 }, Cursor { row: 1, column: 1 })], - 0, - ); - editor.handle(Input::Char('d')); - editor - .apply(vec![Effect::Replace("e\u{301}Z\n\n👨‍💻tail".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "\n👨‍💻tail"); - assert_eq!(editor.clipboard, vec!["e\u{301}Z\n"]); - editor.apply(vec![Effect::Replace("".into())]).unwrap(); - set_ranges(&mut editor, &[(Cursor::default(), Cursor::default())], 0); - type_text(&mut editor, "cQ"); - editor.handle(Input::Escape); - editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "Qabc"); - } - - #[test] - fn repeat_unequal_span_distribution_refuses_atomically_and_recipe_survives() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abcd\nlonger".into())]) - .unwrap(); - set_ranges( - &mut editor, - &[ - (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), - (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), - ], - 1, - ); - editor.handle(Input::Char('d')); - editor - .apply(vec![Effect::Replace("1234\n56789".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 1)], 0); - let undo = editor.active.undo.len(); - let redo = editor.active.redo.len(); - let clipboard = editor.clipboard.clone(); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "1234\n56789"); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.active.undo.len(), undo); - assert_eq!(editor.active.redo.len(), redo); - assert_eq!(editor.clipboard, clipboard); - assert!(editor.message().contains("count")); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "34\n89"); - } - - #[test] - fn repeat_insert_placements_recalculate_for_current_multi_caret_lines() { - for key in ['i', 'I', 'a', 'A', 'o', 'O'] { - let mut recorded = editor(); - recorded - .apply(vec![Effect::Replace("short".into())]) - .unwrap(); - recorded.handle(Input::Char(key)); - type_text(&mut recorded, "X\nY"); - recorded.handle(Input::Escape); - let text = " alpha\n\tbeta\nz"; - recorded.apply(vec![Effect::Replace(text.into())]).unwrap(); - set_carets(&mut recorded, &[(0, 2), (1, 1), (2, 0)], 1); - let mut expected = editor(); - expected.apply(vec![Effect::Replace(text.into())]).unwrap(); - set_carets(&mut expected, &[(0, 2), (1, 1), (2, 0)], 1); - expected.handle(Input::Char(key)); - type_text(&mut expected, "X\nY"); - expected.handle(Input::Escape); - let undo = recorded.active.undo.len(); - recorded.handle(Input::Char('.')); - assert_eq!(recorded.context().text, expected.context().text, "{key}"); - assert_eq!(recorded.carets(), expected.carets(), "{key}"); - assert_eq!(recorded.cursor(), expected.cursor(), "{key}"); - assert_eq!(recorded.mode(), Mode::Normal); - assert_eq!(recorded.active.undo.len(), undo + 1); - recorded.handle(Input::Char('u')); - assert_eq!(recorded.context().text, text, "{key}"); - assert_eq!(recorded.selection_count(), 3); - } - } - - #[test] - fn repeat_visual_line_insert_uses_current_carets_without_rebuilding_old_row_targets() { - for (key, expected) in [('I', " !alpha\n !beta"), ('A', " alpha!\n beta!")] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a\nb\nc".into())]) - .unwrap(); - type_text(&mut editor, "v2j"); - editor.handle(Input::Char(key)); - type_text(&mut editor, "!"); - editor.handle(Input::Escape); - assert_eq!(editor.selection_count(), 3); - editor - .apply(vec![Effect::Replace(" alpha\n beta".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 2), (1, 2)], 1); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, expected); - assert_eq!(editor.selection_count(), 2); - assert_eq!(editor.cursor().row, 1); - } - } - - #[test] - fn repeat_counted_visual_motion_uses_resolved_action_and_normalizes_overlaps() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("one two three".into())]) - .unwrap(); - type_text(&mut editor, "v2ecQ"); - editor.handle(Input::Escape); - editor - .bind("visual", "e", Some("core.left".into())) - .unwrap(); - editor - .apply(vec![Effect::Replace("a enormous tail".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (0, 2)], 1); - let undo = editor.active.undo.len(); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "Q"); - assert_eq!(editor.selection_count(), 1); - assert_eq!(editor.active.undo.len(), undo + 1); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "a enormous tail"); - assert_eq!( - editor.carets(), - vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }] - ); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); - } - - #[test] - fn repeat_insert_native_motion_delete_backspace_and_newline_are_one_edit() { - fn edit(editor: &mut Editor) { - editor.handle(Input::Char('i')); - editor.handle(Input::Right); - type_text(editor, "XY"); - editor.handle(Input::Left); - editor.handle(Input::Backspace); - editor.handle(Input::Delete); - editor.handle(Input::Enter); - type_text(editor, "Z"); - editor.handle(Input::Right); // Trailing navigation is not part of the edit. - editor.handle(Input::Escape); - } - let mut recorded = editor(); - recorded - .apply(vec![Effect::Replace("abcdef".into())]) - .unwrap(); - edit(&mut recorded); - recorded - .apply(vec![Effect::Replace("123456\nlongertext".into())]) - .unwrap(); - set_carets(&mut recorded, &[(0, 1), (1, 2)], 1); - let mut expected = editor(); - expected - .apply(vec![Effect::Replace("123456\nlongertext".into())]) - .unwrap(); - set_carets(&mut expected, &[(0, 1), (1, 2)], 1); - edit(&mut expected); - let undo = recorded.active.undo.len(); - recorded.handle(Input::Char('.')); - assert_eq!(recorded.context().text, expected.context().text); - assert_eq!(recorded.active.undo.len(), undo + 1); - recorded.handle(Input::Char('u')); - assert_eq!(recorded.context().text, "123456\nlongertext"); - } - - #[test] - fn repeat_omits_noop_insert_deletions_and_preserves_partial_noop_carets() { - let mut editor = editor(); - editor.handle(Input::Char('i')); - editor.handle(Input::Backspace); - editor.handle(Input::Delete); - type_text(&mut editor, "X"); - editor.handle(Input::Escape); - editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); - set_carets(&mut editor, &[(0, 2)], 0); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "abXcd"); - editor.apply(vec![Effect::Replace("a\nab".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 1)], 1); - editor.handle(Input::Char('i')); - editor.handle(Input::Backspace); - type_text(&mut editor, "X"); - editor.handle(Input::Escape); - editor - .apply(vec![Effect::Replace("cde\nfgh".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 2), (1, 2)], 1); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "cdXe\nfXh"); - } - - #[test] - fn repeat_partial_deletion_mismatch_rolls_back_visual_change_and_clipboard() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("ab\ncd".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - type_text(&mut editor, "vc"); - editor.handle(Input::Backspace); - type_text(&mut editor, "X"); - editor.handle(Input::Escape); - editor - .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); - editor.handle(Input::Char('v')); - let ranges = editor.selections(); - let targets = editor.active.view.targets.clone(); - let primary = editor.active.view.primary; - let clipboard = editor.clipboard.clone(); - let undo = editor.active.undo.len(); - let redo = editor.active.redo.len(); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "aa\nbb\ncc"); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selections(), ranges); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.active.view.primary, primary); - assert_eq!(editor.clipboard, clipboard); - assert_eq!(editor.active.undo.len(), undo); - assert_eq!(editor.active.redo.len(), redo); - assert!(editor.message().contains("count")); - editor.handle(Input::Escape); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "XaXb\ncc"); - } - - #[test] - fn repeat_typed_scalars_keep_right_neighbor_combining_and_zwj_boundaries() { - for (typed, neighbor, result) in - [("ab", "\u{301}", "a\u{301}b"), ("👩x", "\u{200d}🚀", "👩‍🚀x")] - { - let mut editor = editor(); - editor.handle(Input::Char('i')); - type_text(&mut editor, typed); - editor.handle(Input::Escape); - editor - .apply(vec![Effect::Replace(neighbor.into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - let undo = editor.active.undo.len(); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, result); - assert_eq!(editor.active.undo.len(), undo + 1); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, neighbor); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, result); - } - } - - #[test] - fn repeat_literal_paste_is_bulk_literal_and_keeps_original_typing_groups() { - let mut editor = editor(); - type_text(&mut editor, "iX"); - editor.handle(Input::Paste("ab\r\n:q!".into())); - type_text(&mut editor, "Y"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "Xab\n:q!Y"); - for expected in ["Xab\n:q!", "X", ""] { - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, expected); - } - editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "Yfresh"); - type_text(&mut editor, "i"); - editor.handle(Input::Paste("ab".into())); - editor.handle(Input::Escape); - editor - .apply(vec![Effect::Replace("\u{301}".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "ab\u{301}"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "\u{301}"); - editor.handle(Input::Char('i')); - editor.handle(Input::Paste(":q!\r\n".into())); - editor.handle(Input::Escape); - editor - .apply(vec![Effect::Replace("target".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, ":q!\ntarget"); - assert!(!editor.should_quit()); - } - - #[test] - fn repeat_clipboard_captures_fragments_pairs_broadcasts_and_refuses_mismatch() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("ab\nxyz".into())]) - .unwrap(); - set_ranges( - &mut editor, - &[ - (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), - (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), - ], - 1, - ); - editor.handle(Input::Char('y')); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.handle(Input::Char('P')); - editor.apply(vec![Effect::Replace("OTHER".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - type_text(&mut editor, "vey"); - assert_eq!(editor.clipboard, vec!["OTHER"]); - editor - .apply(vec![Effect::Replace("1\n2\n3".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); - let undo = editor.active.undo.len(); - let targets = editor.active.view.targets.clone(); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "1\n2\n3"); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.active.undo.len(), undo); - assert_eq!(editor.clipboard, vec!["OTHER"]); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "ab1\nxyz2\n3"); - assert_eq!(editor.clipboard, vec!["OTHER"]); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "1\n2\n3"); - editor.clipboard = vec!["!".into()]; - editor.handle(Input::Char('p')); - editor - .apply(vec![Effect::Replace("a\nb\nc".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 1); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "a!\nb!\nc!"); - } - - #[test] - fn repeat_join_and_normal_delete_are_native_and_noops_keep_the_previous_recipe() { - let mut editor = editor(); - editor.apply(vec![Effect::Replace("a\n b".into())]).unwrap(); - editor.handle(Input::Char('J')); - editor - .apply(vec![Effect::Replace("Done.\n again\nlast".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - let undo = editor.active.undo.len(); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "Done. again\nlast"); - assert_eq!(editor.active.undo.len(), undo + 1); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "Done.\n again\nlast"); - editor.apply(vec![Effect::Replace("".into())]).unwrap(); - editor.handle(Input::Char('J')); - editor.handle(Input::Char('x')); - editor - .apply(vec![Effect::Replace("one\n two".into())]) - .unwrap(); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "one two"); - editor.handle(Input::Char('x')); - editor - .apply(vec![Effect::Replace("界e\u{301}\n👩‍🚀z".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "e\u{301}\nz"); - assert_eq!(editor.selection_count(), 2); - } - - #[test] - fn repeat_identical_change_empty_insert_rejected_paste_and_history_keep_recipe_and_redo() { - let mut editor = editor(); - editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); - type_text(&mut editor, "iX"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - type_text(&mut editor, "vecabc"); - editor.handle(Input::Escape); - set_carets(&mut editor, &[(0, 0)], 0); - type_text(&mut editor, "i"); - editor.handle(Input::Backspace); - editor.handle(Input::Escape); - let redo = editor.active.redo.len(); - editor.handle(Input::Char('i')); - editor.handle(Input::Paste("bad\0".into())); - editor.handle(Input::Escape); - assert_eq!(editor.active.redo.len(), redo); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "Xabc"); - editor.handle(Input::Char('u')); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "Xabc"); - } - - #[test] - fn repeat_is_session_wide_and_plugin_effects_commit_but_never_record_native_intent() { - let directory = Directory::new(); - let other = directory.path("other"); - fs::write(&other, "base").unwrap(); - let mut editor = editor(); - type_text(&mut editor, "iN"); - editor.execute(&format!("edit {}", other.display())); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "Nbase"); - editor.register(plugin("fixture", &["greet"])).unwrap(); - assert_eq!(editor.execute("greet").unwrap().command, "greet"); - editor - .apply(vec![ - Effect::Replace("plugin".into()), - Effect::Insert("!".into()), - ]) - .unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - assert_eq!(editor.handle(Input::Char('.')), None); - assert_eq!(editor.context().text, "N!plugin"); - editor.handle(Input::Char('i')); - type_text(&mut editor, "T"); - editor.apply(vec![Effect::Insert("P".into())]).unwrap(); - editor.handle(Input::Escape); - editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "Tfresh"); - editor.execute("buffer 1"); - assert_eq!(editor.context().text, "N"); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "TN"); - } - - #[test] - fn repeat_action_no_recipe_remaps_and_literal_queries_do_not_record_or_execute_edits() { - let directory = Directory::new(); - fs::write(directory.path("file.txt"), "file").unwrap(); - let mut editor = project_editor(&directory); - editor.handle(Input::Char('.')); - assert_eq!(editor.message(), "Nothing to repeat"); - assert_eq!(editor.context().text, ""); - assert!(editor.active.undo.is_empty()); - editor - .bind("normal", "r", Some("core.repeat_last_edit".into())) - .unwrap(); - editor - .bind("visual", "r", Some("core.repeat_last_edit".into())) - .unwrap(); - assert!( - editor - .bind("insert", "r", Some("core.repeat_last_edit".into())) - .is_err() - ); - type_text(&mut editor, "iX"); - editor.open_file_picker(FilePresentation::Docked); - editor.handle(Input::Char('i')); - type_text(&mut editor, "."); - assert_eq!(editor.file_picker().unwrap().query, "."); - assert_eq!(editor.context().text, "X"); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "."); - assert_eq!(editor.palette().unwrap().query, "."); - editor.handle(Input::Escape); - type_text(&mut editor, "Y"); - editor.handle(Input::Escape); - editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('r')); - assert_eq!(editor.context().text, "XYfresh"); - type_text(&mut editor, "i."); - editor.handle(Input::Escape); - editor - .apply(vec![Effect::Replace("target".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('r')); - assert_eq!(editor.context().text, ".target"); - type_text(&mut editor, "/."); - assert_eq!(editor.command_line(), "."); - editor.handle(Input::Escape); - type_text(&mut editor, ":."); - assert_eq!(editor.command_line(), "."); - editor.handle(Input::Escape); - editor.apply(vec![Effect::Replace("again".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0)], 0); - editor.handle(Input::Char('r')); - assert_eq!(editor.context().text, ".again"); - } - - #[test] - fn buffers_keep_independent_history_saved_text_carets_primary_and_search() { - let directory = Directory::new(); - let a = directory.path("a"); - let b = directory.path("b"); - fs::write(&a, "cat cat\ncat cat").unwrap(); - fs::write(&b, "dog dog\ndog dog").unwrap(); - let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - editor.handle(Input::Char('i')); - type_text(&mut editor, "X"); - editor.handle(Input::Escape); - submit_search(&mut editor, "cat"); - let a_carets = editor.carets(); - let a_primary = editor.cursor(); - let a_match = editor.search_match(); - editor.execute("w"); - assert!(!editor.dirty()); - - editor.execute(&format!("edit {}", b.display())); - assert_eq!(editor.buffer_id(), 2); - set_carets(&mut editor, &[(0, 0), (1, 0)], 0); - submit_search(&mut editor, "dog"); - editor.handle(Input::Char('n')); - let b_carets = editor.carets(); - let b_primary = editor.cursor(); - editor.handle(Input::Char('i')); - type_text(&mut editor, "Y"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "dog dog\ndog dog"); - assert_eq!(editor.carets(), b_carets); - assert_eq!(editor.cursor(), b_primary); - assert!(!editor.dirty()); - - editor.execute("buffer 1"); - assert_eq!(editor.context().text, "Xcat cat\nXcat cat"); - assert_eq!(editor.carets(), a_carets); - assert_eq!(editor.cursor(), a_primary); - assert_eq!(editor.search_match(), a_match); - editor.handle(Input::Char('n')); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 5 }); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "cat cat\ncat cat"); - assert!(editor.dirty()); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "Xcat cat\nXcat cat"); - assert!(!editor.dirty()); - editor.execute("buffer 2"); - editor.handle(Input::Char('n')); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "dog Ydog\ndog Ydog"); - assert!(editor.dirty()); - editor.execute("w"); - assert!(!editor.dirty()); - assert_eq!(fs::read_to_string(&a).unwrap(), "Xcat cat\nXcat cat"); - assert_eq!(fs::read_to_string(&b).unwrap(), "dog Ydog\ndog Ydog"); - editor.execute("buffer 1"); - assert!(!editor.dirty()); - } - - #[test] - fn explicit_switch_preserves_directed_visual_ranges_and_bound_switch_restores_them() { - let directory = Directory::new(); - let a = directory.path("a"); - let b = directory.path("b"); - fs::write(&a, "abcdef\nabcdef").unwrap(); - fs::write(&b, "other").unwrap(); - let mut editor = Editor::open(Some(a), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - set_carets(&mut editor, &[(0, 4), (1, 4)], 1); - type_text(&mut editor, "vhh"); - let carets = editor.carets(); - let primary = editor.cursor(); - let ranges = editor.selections(); - editor.execute(&format!("edit {}", b.display())); - assert_eq!(editor.mode(), Mode::Normal); - editor - .bind("normal", "g", Some("core.buffer_previous".into())) - .unwrap(); - editor.handle(Input::Char('g')); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.cursor(), primary); - assert_eq!(editor.selections(), ranges); - editor.execute("bnext"); - editor.execute("buffer 1"); - assert_eq!(editor.mode(), Mode::Visual); - editor.handle(Input::Char('h')); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); - assert_eq!(editor.selections()[1].end.column, 5); - } - - #[test] - fn switching_commits_insert_groups_and_never_restores_transient_prompts() { - let directory = Directory::new(); - let a = directory.path("a"); - let b = directory.path("b"); - fs::write(&a, "base").unwrap(); - fs::write(&b, "other").unwrap(); - let mut editor = Editor::open(Some(a), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - editor.handle(Input::Char('i')); - type_text(&mut editor, "XY"); - editor.execute(&format!("edit {}", b.display())); - editor.execute("buffer 1"); - assert_eq!(editor.mode(), Mode::Normal); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "base"); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "XYbase"); - for prompt in [':', '/'] { - editor.handle(Input::Char(prompt)); - type_text(&mut editor, "unfinished"); - editor.execute("buffer 2"); - assert_eq!(editor.command_line(), ""); - editor.execute("buffer 1"); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.command_line(), ""); - } - } - - #[test] - fn failed_opens_and_invalid_buffer_arguments_leave_editing_state_untouched() { - let directory = Directory::new(); - let bad = directory.path("bad"); - fs::write(&bad, b"\xff").unwrap(); - let controls = directory.path("controls"); - fs::write(&controls, "bad\0").unwrap(); - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abcdef".into())]) - .unwrap(); - type_text(&mut editor, "llvhh"); - let ranges = editor.selections(); - let carets = editor.carets(); - let cursor = editor.cursor(); - let undo = editor.active.undo.len(); - let commands = [ - format!("edit {}", bad.display()), - format!("edit {}", controls.display()), - format!("edit {}", directory.0.display()), - "edit".into(), - "buffer".into(), - "buffer 0".into(), - "buffer -1".into(), - "buffer 99".into(), - "buffer 1 2".into(), - "bnext 1".into(), - "bprev 1".into(), - "buffers 1".into(), - "bdelete nope".into(), - "bdelete! 99".into(), - "bdelete 999999999999999999999999999999".into(), - ]; - for command in commands { - editor.execute(&command); - assert_eq!(editor.buffer_count(), 1); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.context().text, "abcdef"); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selections(), ranges); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.cursor(), cursor); - assert_eq!(editor.active.undo.len(), undo); - } - editor.handle(Input::Escape); - editor.handle(Input::Char('i')); - type_text(&mut editor, "X"); - editor.execute(&format!("edit {}", bad.display())); - assert_eq!(editor.mode(), Mode::Insert); - type_text(&mut editor, "Y"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "abcdef"); - } - - #[test] - fn stable_buffer_ids_wrap_close_inactive_and_replace_the_last_buffer() { - let directory = Directory::new(); - let mut editor = editor(); - let a = directory.path("new a"); - let b = directory.path("new b"); - editor.execute(&format!("edit {}", a.display())); - editor.execute(&format!("edit {}", b.display())); - assert_eq!( - editor.buffers().map(|buffer| buffer.id).collect::>(), - vec![1, 2, 3], - ); - let info = editor.buffers().find(|buffer| buffer.id == 3).unwrap(); - assert_eq!(info.path, Some(b.as_path())); - assert!(info.active); - assert!(!info.dirty); - editor.execute("bdelete 2"); - assert_eq!(editor.buffer_id(), 3); - assert!(!editor.has_buffer(2)); - assert_eq!(editor.buffer_count(), 2); - editor.execute("bnext"); - assert_eq!(editor.buffer_id(), 1); - editor.execute("bprev"); - assert_eq!(editor.buffer_id(), 3); - editor.execute("bdelete"); - assert_eq!(editor.buffer_id(), 1); - editor.execute("bdelete"); - assert_eq!(editor.buffer_id(), 4); - assert_eq!(editor.buffer_count(), 1); - assert_eq!(editor.path(), None); - assert_eq!(editor.context().text, ""); - assert!(!editor.dirty()); - assert!(!editor.should_quit()); - editor.execute("bnext"); - editor.execute("bprev"); - assert_eq!(editor.buffer_id(), 4); - editor.execute(&format!("edit {}", a.display())); - assert_eq!(editor.buffer_id(), 5); - assert_eq!(editor.buffer_count(), 2); - editor.execute("buffer 2"); - assert_eq!(editor.buffer_id(), 5); - } - - #[test] - fn hidden_dirty_buffers_block_quit_write_quit_and_unforced_deletion() { - let directory = Directory::new(); - let a = directory.path("a"); - let b = directory.path("b"); - fs::write(&a, "a").unwrap(); - fs::write(&b, "b").unwrap(); - let mut editor = Editor::open(Some(a), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - editor - .apply(vec![Effect::Replace("dirty a".into())]) - .unwrap(); - editor.execute(&format!("edit {}", b.display())); - assert!(!editor.dirty()); - editor.execute("q"); - assert!(!editor.should_quit()); - editor - .apply(vec![Effect::Replace("saved b".into())]) - .unwrap(); - editor.execute("wq"); - assert!(!editor.should_quit()); - assert!(!editor.dirty()); - assert_eq!(fs::read_to_string(&b).unwrap(), "saved b"); - editor.execute("bdelete 1"); - assert!(editor.has_buffer(1)); - assert_eq!(editor.buffer_id(), 2); - editor.execute("buffer 1"); - assert_eq!(editor.context().text, "dirty a"); - editor.execute("bdelete"); - assert_eq!(editor.buffer_id(), 1); - editor.execute("bdelete!"); - assert_eq!(editor.buffer_id(), 2); - editor.execute("q"); - assert!(editor.should_quit()); - - let mut forced = Editor::open(Some(b), vec![]).unwrap(); - forced.set_clipboard_mode("separate").unwrap(); - forced.apply(vec![Effect::Replace("dirty".into())]).unwrap(); - forced.execute(&format!("edit {}", directory.path("new").display())); - forced.execute("q! arguments"); - assert!(!forced.should_quit()); - forced.execute("bdelete! 1"); - assert_eq!(forced.buffer_id(), 2); - assert!(!forced.has_buffer(1)); - forced - .apply(vec![Effect::Replace("also dirty".into())]) - .unwrap(); - forced.execute("bdelete!"); - assert_eq!(forced.buffer_id(), 3); - assert!(!forced.dirty()); - forced - .apply(vec![Effect::Replace("quit dirty".into())]) - .unwrap(); - forced.execute(&format!("edit {}", directory.path("another").display())); - forced.execute("q!"); - assert!(forced.should_quit()); - } - - #[test] - fn loaded_paths_refuse_save_collisions_before_touching_files_or_saved_state() { - let directory = Directory::new(); - let a = directory.path("a"); - let b = directory.path("b"); - fs::write(&a, "a").unwrap(); - fs::write(&b, "b").unwrap(); - let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - editor.execute(&format!("edit {}", b.display())); - editor - .apply(vec![Effect::Replace("dirty b".into())]) - .unwrap(); - for command in ["w", "w!", "wq", "wq!"] { - editor.execute(&format!("{command} {}", directory.0.join("./a").display())); - assert!(editor.message().contains("belongs to buffer 1")); - assert_eq!(editor.path(), Some(b.as_path())); - assert!(editor.dirty()); - assert!(!editor.should_quit()); - assert_eq!(fs::read_to_string(&a).unwrap(), "a"); - assert_eq!(fs::read_to_string(&b).unwrap(), "b"); - assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2); - } - editor.execute(&format!("w {}", directory.0.join("./b").display())); - assert!(!editor.dirty()); - assert_eq!(fs::read_to_string(&b).unwrap(), "dirty b"); - let new = directory.path("missing"); - editor.execute(&format!("edit {}", new.display())); - editor.execute("buffer 2"); - editor.execute(&format!("w {}", new.display())); - assert!(editor.message().contains("belongs to buffer 3")); - assert!(!new.exists()); - assert!(!editor.dirty()); - } - - #[test] - fn clipboard_bindings_plugins_and_palette_catalog_are_session_wide() { - let directory = Directory::new(); - let mut editor = editor(); - editor.register(plugin("fixture", &["greet"])).unwrap(); - editor.bind("normal", "g", Some("greet".into())).unwrap(); - editor - .bind("normal", "z", Some("core.buffer_next".into())) - .unwrap(); - editor - .apply(vec![Effect::Replace("copied".into())]) - .unwrap(); - type_text(&mut editor, "v$y"); - editor.execute(&format!("edit {}", directory.path("new").display())); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "copied"); - assert_eq!(editor.handle(Input::Char('g')).unwrap().plugin, "fixture"); - editor.handle(Input::Char('z')); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.handle(Input::Char('g')).unwrap().command, "greet"); - editor.handle(Input::Ctrl('p')); - assert_eq!( - palette_entry(&editor, "core.buffer_next").bindings, - vec!["Space b n", "z"] - ); - editor.disable_plugin("fixture", "failure"); - assert!( - !editor - .palette() - .unwrap() - .entries - .iter() - .any(|entry| entry.name == "greet") - ); - editor.handle(Input::Escape); - editor.execute("buffer 2"); - assert_eq!(editor.handle(Input::Char('g')), None); - editor.execute("plugins"); - assert!(!editor.message().contains("fixture")); - assert!(editor.message().contains("bdelete!")); - } - - #[cfg(unix)] - #[test] - fn canonical_aliases_reuse_dirty_buffers_and_new_paths_are_normalized() { - use std::os::unix::fs::symlink; - let directory = Directory::new(); - let path = directory.path("file"); - let link = directory.path("file link"); - fs::write(&path, "disk").unwrap(); - symlink(&path, &link).unwrap(); - let mut editor = Editor::open(Some(link.clone()), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - editor - .apply(vec![Effect::Replace("dirty memory".into())]) - .unwrap(); - editor.execute(&format!("edit {}", directory.path("other").display())); - fs::write(&path, "changed externally").unwrap(); - editor.execute(&format!("edit {}", path.display())); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.buffer_count(), 2); - assert_eq!(editor.path(), Some(link.as_path())); - assert_eq!(editor.context().text, "dirty memory"); - assert!(editor.dirty()); - editor.execute("w"); - assert!(editor.message().contains("symbolic link")); - assert!(editor.dirty()); - assert_eq!(fs::read_to_string(&path).unwrap(), "changed externally"); - editor.execute(&format!("w {}", path.display())); - assert!(editor.dirty()); - assert_eq!(editor.path(), Some(link.as_path())); - assert_eq!(fs::read_to_string(&path).unwrap(), "changed externally"); - editor.execute(&format!("w! {}", path.display())); - assert!(!editor.dirty()); - assert_eq!(editor.path(), Some(path.as_path())); - - let alias = directory.path("directory link"); - symlink(&directory.0, &alias).unwrap(); - let new = alias.join("new"); - editor.execute(&format!("edit {}", new.display())); - assert_eq!(editor.buffer_id(), 3); - editor - .apply(vec![Effect::Replace("new memory".into())]) - .unwrap(); - editor.execute("buffer 1"); - editor.execute(&format!( - "edit {}", - directory.0.join("absent/../new").display() - )); - assert_eq!(editor.buffer_id(), 3); - assert_eq!(editor.buffer_count(), 3); - assert_eq!(editor.context().text, "new memory"); - assert_eq!(editor.path(), Some(new.as_path())); - editor.execute(&format!("w {}", link.display())); - assert!(editor.message().contains("belongs to buffer 1")); - assert!(editor.dirty()); - assert_eq!(fs::read_to_string(&path).unwrap(), "dirty memory"); - assert!(!new.exists()); - } - - fn palette_entry<'a>(editor: &'a Editor, name: &str) -> &'a PaletteEntry { - editor - .palette() - .unwrap() - .entries - .iter() - .find(|entry| entry.name == name) - .unwrap() - } - - #[test] - fn palette_cancellation_preserves_every_underlying_mode_and_insert_group() { - for mode in [ - Mode::Normal, - Mode::Insert, - Mode::Command, - Mode::Search, - Mode::Visual, - ] { - for cancel in [Input::Escape, Input::Ctrl('p')] { - let mut editor = editor(); - let original = "alpha beta\nalpha beta"; - editor - .apply(vec![Effect::Replace(original.into())]) - .unwrap(); - editor.apply(vec![Effect::Insert("extra".into())]).unwrap(); - editor.undo(); - submit_search(&mut editor, "alpha"); - set_carets(&mut editor, &[(0, 0), (1, 0)], 1); - match mode { - Mode::Normal => {} - Mode::Insert => { - editor.handle(Input::Char('i')); - type_text(&mut editor, "X"); - } - Mode::Command => { - editor.handle(Input::Char(':')); - type_text(&mut editor, "w output"); - } - Mode::Search => { - editor.handle(Input::Char('/')); - type_text(&mut editor, "alpha e\u{301}"); - } - Mode::Visual => { - editor.handle(Input::Char('v')); - editor.handle(Input::Right); - } - } - let snapshot = - |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); - let lines = editor.active.lines.clone(); - let targets = editor.active.view.targets.clone(); - let primary = editor.active.view.primary; - let selections = editor.selections(); - let prompt = editor.active.view.command_line.clone(); - let search = editor.active.view.search_query.clone(); - let found = editor.active.view.search_match; - let message = editor.message.clone(); - let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); - let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); - let insert = editor.active.view.insert_start.as_ref().map(snapshot); - - editor.handle(Input::Ctrl('p')); - assert!(editor.palette().is_some()); - editor.handle(Input::Ctrl('j')); - editor.handle(Input::Ctrl('k')); - type_text(&mut editor, "query Δe\u{301}👩‍🚀 "); - editor.handle(Input::Backspace); - editor.handle(Input::Backspace); - assert_eq!(editor.palette().unwrap().query, "query Δe\u{301}"); - editor.handle(Input::Paste("ignored\n:q!".into())); - editor.handle(Input::Down); - editor.handle(Input::Ctrl('k')); - editor.handle(cancel); - - assert!(editor.palette().is_none()); - assert_eq!(editor.mode(), mode); - assert_eq!(editor.active.lines, lines); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.active.view.primary, primary); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.active.view.command_line, prompt); - assert_eq!(editor.active.view.search_query, search); - assert_eq!(editor.active.view.search_match, found); - assert_eq!(editor.message, message); - assert_eq!( - editor.active.undo.iter().map(&snapshot).collect::>(), - undo - ); - assert_eq!( - editor.active.redo.iter().map(&snapshot).collect::>(), - redo - ); - assert_eq!( - editor.active.view.insert_start.as_ref().map(snapshot), - insert - ); - if mode == Mode::Insert { - type_text(&mut editor, "Y"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, original); - } - } - } - } - - #[test] - fn palette_filters_all_tokens_and_wraps_without_executing_empty_results() { - let mut editor = editor(); - editor - .register(plugin("Älpha Tools", &["greet_one", "greet_two"])) - .unwrap(); - editor.handle(Input::Ctrl('p')); - let order: Vec<_> = editor - .palette() - .unwrap() - .entries - .iter() - .map(|entry| (entry.label.clone(), entry.name.clone())) - .collect(); - assert!(order.windows(2).all(|pair| pair[0] <= pair[1])); - assert!( - !editor - .palette() - .unwrap() - .entries - .iter() - .any(|entry| entry.name == "core.palette") - ); - type_text(&mut editor, "GREET ÄLPHA"); - assert_eq!(editor.palette().unwrap().entries.len(), 2); - assert_eq!(editor.palette().unwrap().entries[0].name, "greet_one"); - editor.handle(Input::Up); - assert_eq!(editor.palette().unwrap().selected, 1); - editor.handle(Input::Ctrl('n')); - assert_eq!(editor.palette().unwrap().selected, 0); - editor.handle(Input::Down); - assert_eq!(editor.palette().unwrap().selected, 1); - editor.handle(Input::Ctrl('k')); - assert_eq!(editor.palette().unwrap().selected, 0); - editor.handle(Input::Down); - editor.handle(Input::Char(' ')); - assert_eq!(editor.palette().unwrap().selected, 0); - type_text(&mut editor, "missing"); - assert!(editor.palette().unwrap().entries.is_empty()); - for input in [ - Input::Enter, - Input::Up, - Input::Down, - Input::Ctrl('j'), - Input::Ctrl('k'), - Input::Ctrl('n'), - ] { - assert_eq!(editor.handle(input), None); - assert_eq!(editor.palette().unwrap().selected, 0); - } - while !editor.palette().unwrap().query.is_empty() { - editor.handle(Input::Backspace); - } - type_text(&mut editor, "e\u{301}👩‍🚀"); - editor.handle(Input::Backspace); - assert_eq!(editor.palette().unwrap().query, "e\u{301}"); - editor.handle(Input::Backspace); - assert_eq!(editor.palette().unwrap().query, ""); - assert!(!editor.palette().unwrap().entries.is_empty()); - editor.handle(Input::Ctrl('i')); - assert_eq!(editor.palette().unwrap().query, ""); - } - - #[test] - fn palette_catalog_shares_binding_eligibility_and_keeps_disabled_rows() { - for mode in [Mode::Insert, Mode::Command, Mode::Search, Mode::Visual] { - let mut editor = editor(); - editor.active.view.mode = mode; - editor.active.view.command_line = "untouched".into(); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core.undo"); - let entry = palette_entry(&editor, "core.undo"); - assert!(!entry.enabled); - assert_eq!(entry.modes, vec![Mode::Normal]); - assert!(entry.bindings.is_empty()); - editor.handle(Input::Ctrl('j')); - editor.handle(Input::Ctrl('k')); - assert_eq!(editor.palette().unwrap().query, "core.undo"); - assert_eq!(editor.handle(Input::Enter), None); - assert!(editor.palette().is_some()); - assert_eq!(editor.mode(), mode); - assert_eq!(editor.command_line(), "untouched"); - while !editor.palette().unwrap().query.is_empty() { - editor.handle(Input::Backspace); - } - type_text(&mut editor, "q!"); - assert!(!palette_entry(&editor, "core.force_quit").enabled); - editor.handle(Input::Enter); - assert!(!editor.should_quit()); - assert!(editor.palette().is_some()); - } - } - - #[test] - fn palette_effective_bindings_refresh_with_canonical_terminal_names() { - let mut editor = editor(); - editor.handle(Input::Ctrl('p')); - assert_eq!(palette_entry(&editor, "core.insert").bindings, vec!["i"]); - editor.bind("normal", "i", None).unwrap(); - assert!(palette_entry(&editor, "core.insert").bindings.is_empty()); - for key in [ - "shift+i", "ctrl+m", "ctrl+h", "ctrl+i", "space", "ctrl+r", "界", - ] { - editor - .bind("normal", key, Some("core.insert".into())) - .unwrap(); - } - editor - .bind("normal", "ctrl+j", Some("core.insert".into())) - .unwrap(); - assert!( - palette_entry(&editor, "core.insert_first_nonblank") - .bindings - .is_empty() - ); - editor.bind("normal", "ENTER", None).unwrap(); - assert!( - !palette_entry(&editor, "core.insert") - .bindings - .iter() - .any(|key| key == "Enter") - ); - editor - .bind("normal", "界", Some("core.undo".into())) - .unwrap(); - assert_eq!( - palette_entry(&editor, "core.undo").bindings, - vec!["u", "界"] - ); - assert!( - !palette_entry(&editor, "core.insert") - .bindings - .iter() - .any(|key| key == "界") - ); - assert!( - editor - .bind("insert", "g", Some("core.undo".into())) - .is_err() - ); - editor.handle(Input::Escape); - editor.bind("normal", "ctrl+p", None).unwrap(); - editor - .bind("normal", "ctrl+f", Some("core.palette".into())) - .unwrap(); - editor.handle(Input::Ctrl('p')); - assert!(editor.palette().is_none()); - editor.handle(Input::Ctrl('f')); - assert!(editor.palette().is_some()); - editor.handle(Input::Ctrl('p')); - assert!(editor.palette().is_none()); - } - - #[test] - fn palette_registered_commands_refresh_and_retire_with_plugins() { - let mut editor = editor(); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "fixture"); - assert!(editor.palette().unwrap().entries.is_empty()); - editor.register(plugin("fixture", &["greet"])).unwrap(); - assert_eq!(editor.palette().unwrap().entries.len(), 1); - assert_eq!(palette_entry(&editor, "greet").owner, "fixture"); - assert!(palette_entry(&editor, "greet").enabled); - editor.bind("normal", "g", Some("greet".into())).unwrap(); - assert_eq!(palette_entry(&editor, "greet").bindings, vec!["g"]); - editor.disable_plugin("fixture", "retired"); - assert!(editor.palette().unwrap().entries.is_empty()); - editor.handle(Input::Enter); - assert!(editor.palette().is_some()); - editor.handle(Input::Escape); - assert_eq!(editor.handle(Input::Char('g')), None); - assert!(editor.bind("normal", "g", Some("greet".into())).is_err()); - } - - #[test] - fn palette_commands_require_prompt_submission_and_dispatch_arguments_once() { - let mut editor = editor(); - editor.register(plugin("fixture", &["greet"])).unwrap(); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "greet"); - assert_eq!(editor.handle(Input::Enter), None); - assert!(editor.palette().is_none()); - assert_eq!(editor.mode(), Mode::Command); - assert_eq!(editor.command_line(), "greet "); - type_text(&mut editor, "界!"); - assert_eq!( - editor.handle(Input::Enter), - Some(Invocation { - plugin: "fixture".into(), - command: "greet".into(), - args: "界!".into(), - }) - ); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.handle(Input::Enter), None); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "q!"); - editor.handle(Input::Enter); - assert!(!editor.should_quit()); - editor.handle(Input::Enter); - assert!(editor.should_quit()); - } - - #[test] - fn palette_actions_dispatch_visual_change_insert_newline_and_original_prompts() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("alpha beta".into())]) - .unwrap(); - set_ranges( - &mut editor, - &[(Cursor::default(), Cursor { row: 0, column: 5 })], - 0, - ); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core.change"); - assert!(palette_entry(&editor, "core.change").enabled); - editor.handle(Input::Enter); - assert!(editor.palette().is_none()); - assert_eq!(editor.mode(), Mode::Insert); - assert_eq!(editor.context().text, " beta"); - type_text(&mut editor, "Ω"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "Ω beta"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "alpha beta"); - - let mut insertion = self::editor(); - insertion.handle(Input::Char('i')); - type_text(&mut insertion, "A"); - insertion.handle(Input::Ctrl('p')); - type_text(&mut insertion, "core.newline"); - insertion.handle(Input::Enter); - assert_eq!(insertion.mode(), Mode::Insert); - assert_eq!(insertion.lines(), &["A", ""]); - type_text(&mut insertion, "B"); - insertion.handle(Input::Escape); - insertion.handle(Input::Char('u')); - assert_eq!(insertion.context().text, ""); - insertion.handle(Input::Char('U')); - assert_eq!(insertion.context().text, "A\nB"); - - editor.handle(Input::Char(':')); - type_text(&mut editor, "plugins"); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core.submit"); - editor.handle(Input::Enter); - assert_eq!(editor.mode(), Mode::Normal); - assert!(editor.message().starts_with("Capabilities: ")); - assert_eq!(editor.command_line(), ""); - editor.handle(Input::Char('/')); - type_text(&mut editor, "beta"); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core.submit"); - editor.handle(Input::Enter); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.active.view.search_query, "beta"); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 6 }); - } - - #[test] - fn palette_survives_rejected_effects_but_external_execution_closes_it() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("alpha beta".into())]) - .unwrap(); - editor.handle(Input::Char('v')); - editor.handle(Input::Right); - let targets = editor.active.view.targets.clone(); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core.change"); - let undo = editor.active.undo.len(); - assert!( - editor - .apply(vec![ - Effect::Message("changed".into()), - Effect::Insert("\0".into()) - ]) - .is_err() - ); - assert_eq!(editor.palette().unwrap().query, "core.change"); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.active.view.targets, targets); - assert_eq!(editor.active.undo.len(), undo); - editor.apply(vec![Effect::Message("ok".into())]).unwrap(); - assert_eq!(editor.palette().unwrap().query, "core.change"); - assert_eq!(editor.active.view.targets, targets); - editor.execute("plugins"); - assert!(editor.palette().is_none()); - assert_eq!(editor.mode(), Mode::Normal); - - editor.handle(Input::Char('v')); - editor.handle(Input::Ctrl('p')); - type_text(&mut editor, "core.change"); - editor.apply(vec![Effect::Insert("!".into())]).unwrap(); - assert_eq!(editor.mode(), Mode::Normal); - assert!(!palette_entry(&editor, "core.change").enabled); - } - - #[test] - fn visual_change_replaces_reversed_multiline_graphemes_in_one_undo_group() { - let mut editor = editor(); - let original = "pre界e\u{301}\n\n👩‍🚀tail\npost"; - editor - .apply(vec![Effect::Replace(original.into())]) - .unwrap(); - submit_search(&mut editor, "👩‍🚀"); - editor.handle(Input::Char('v')); - editor.handle(Input::Up); - editor.handle(Input::Up); - for _ in 0..3 { - editor.handle(Input::Right); - } - editor.handle(Input::Char('c')); - assert_eq!(editor.mode(), Mode::Insert); - assert!(editor.selections().is_empty()); - assert_eq!(editor.search_match(), None); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); - assert_eq!(editor.context().text, "pretail\npost"); - editor.handle(Input::Paste("\0".into())); - type_text(&mut editor, "α\nβ"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "preα\nβtail\npost"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, original); - assert_eq!(editor.mode(), Mode::Normal); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "preα\nβtail\npost"); - } - - #[test] - fn visual_change_at_eof_and_empty_ranges_preserves_clipboard() { - let mut editor = editor(); - editor.apply(vec![Effect::Replace("abX".into())]).unwrap(); - editor.handle(Input::End); - editor.handle(Input::Char('v')); - editor.handle(Input::Char('c')); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); - type_text(&mut editor, "YZ"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "abYZ"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "abX"); - editor.apply(vec![Effect::Replace("".into())]).unwrap(); - editor.handle(Input::Char('v')); - assert_eq!(editor.selections()[0].start, editor.selections()[0].end); - editor.handle(Input::Char('c')); - type_text(&mut editor, "new"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "X"); - } - - #[test] - fn visual_change_with_identical_replacement_preserves_redo() { - let mut editor = editor(); - editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); - editor.handle(Input::Char('i')); - type_text(&mut editor, "X"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - editor.handle(Input::Char('v')); - editor.handle(Input::Char('e')); - editor.handle(Input::Char('c')); - type_text(&mut editor, "abc"); - editor.handle(Input::Escape); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "Xabc"); - } - - #[test] - fn visual_ranges_copy_both_directions_and_cut_and_paste_multiline_graphemes() { - let mut editor = editor(); - let text = "a界e\u{301}z\n\n👩‍🚀tail\n"; - editor.apply(vec![Effect::Replace(text.into())]).unwrap(); - editor.handle(Input::Right); - editor.handle(Input::Char('v')); - editor.handle(Input::Right); - assert_eq!( - editor.selections(), - vec![Selection { - start: Cursor { row: 0, column: 1 }, - end: Cursor { row: 0, column: 3 }, - }] - ); - editor.handle(Input::Left); - editor.handle(Input::Left); - assert_eq!( - editor.selections()[0], - Selection { - start: Cursor { row: 0, column: 0 }, - end: Cursor { row: 0, column: 2 }, - } - ); - editor.handle(Input::Down); - assert_eq!(editor.selections()[0].end, Cursor { row: 2, column: 0 }); - let selected = editor.selections(); - editor.handle(Input::Paste(":q!\n".into())); - assert_eq!(editor.selections(), selected); - editor.handle(Input::Down); - editor.handle(Input::Char('d')); - assert!(editor.selections().is_empty()); - assert_eq!(editor.context().text, "atail\n"); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, text); - editor.handle(Input::Char('u')); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, text); - assert_eq!(editor.mode(), Mode::Normal); - } - - #[test] - fn clipboard_copy_preserves_redo_and_survives_history_and_grapheme_merging() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("\u{301}".into())]) - .unwrap(); - editor.handle(Input::Char('v')); - editor.handle(Input::Char('y')); - editor.handle(Input::Char('u')); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "\u{301}"); - editor.apply(vec![Effect::Replace("a".into())]).unwrap(); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "a\u{301}"); - assert_eq!(editor.cursor(), Cursor::default()); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "a"); - } - - #[test] - fn visual_empty_lines_include_real_newlines_and_row_split_excludes_exclusive_end() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("ab\n\ncd\n".into())]) - .unwrap(); - editor.handle(Input::Down); - editor.handle(Input::Char('v')); - assert_eq!( - editor.selections(), - vec![Selection { - start: Cursor { row: 1, column: 0 }, - end: Cursor { row: 2, column: 0 }, - }] - ); - editor.handle(Input::Ctrl('l')); - assert_eq!(editor.carets(), vec![Cursor { row: 1, column: 0 }]); - editor.handle(Input::Char('v')); - editor.handle(Input::Delete); - assert_eq!(editor.context().text, "ab\ncd\n"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "ab\n\ncd\n"); - } - - #[test] - fn batch_insert_newline_backspace_and_delete_use_prebuffer_offsets() { - let mut editor = editor(); - editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); - set_carets(&mut editor, &[(0, 0), (0, 2)], 1); - editor.handle(Input::Char('i')); - type_text(&mut editor, "X"); - assert_eq!(editor.context().text, "XabXcd"); - editor.handle(Input::Enter); - assert_eq!(editor.context().text, "X\nabX\ncd"); - editor.handle(Input::Backspace); - assert_eq!(editor.context().text, "XabXcd"); - editor.handle(Input::Delete); - assert_eq!(editor.context().text, "XbXd"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "abcd"); - assert_eq!( - editor.carets(), - vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }] - ); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); - } - - #[test] - fn visual_insert_and_append_split_ragged_rows_without_padding() { - for (key, expected) in [('I', " !aa\n!\n !zzz"), ('A', " aa!\n!\n zzz!")] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace(" aa\n\n zzz".into())]) - .unwrap(); - editor.handle(Input::Char('v')); - editor.handle(Input::Down); - editor.handle(Input::Down); - editor.handle(Input::Char(key)); - assert_eq!(editor.mode(), Mode::Insert); - assert_eq!(editor.selection_count(), 3); - type_text(&mut editor, "!"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, expected); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, " aa\n\n zzz"); - } - } - - #[test] - fn unequal_word_changes_and_typing_share_undo_and_restore_primary_carets() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("foo\nlonger".into())]) - .unwrap(); - editor.handle(Input::Ctrl('e')); - editor.handle(Input::Char('v')); - editor.handle(Input::Char('e')); - assert_eq!( - editor - .selections() - .iter() - .map(|range| range.end.column) - .collect::>(), - vec![3, 6] - ); - let before = editor.carets(); - let primary = editor.cursor(); - editor.handle(Input::Char('c')); - type_text(&mut editor, "X"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "X\nX"); - assert_eq!(editor.clipboard, vec!["foo", "longer"]); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "foo\nlonger"); - assert_eq!(editor.carets(), before); - assert_eq!(editor.cursor(), primary); - assert_eq!(editor.mode(), Mode::Normal); - assert!(editor.selections().is_empty()); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "X\nX"); - } - - #[test] - fn unicode_overlap_merges_but_adjacency_preserves_distinct_fragments() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a\u{301}👩‍🚀界z".into())]) - .unwrap(); - editor.active.view.mode = Mode::Visual; - editor.active.view.targets = vec![ - editor.range_target( - Selection { - start: Cursor { row: 0, column: 0 }, - end: Cursor { row: 0, column: 2 }, - }, - true, - ), - editor.range_target( - Selection { - start: Cursor { row: 0, column: 1 }, - end: Cursor { row: 0, column: 3 }, - }, - false, - ), - editor.range_target( - Selection { - start: Cursor { row: 0, column: 3 }, - end: Cursor { row: 0, column: 4 }, - }, - true, - ), - ]; - editor.active.view.primary = 1; - editor.normalize_targets(); - assert_eq!(editor.selection_count(), 2); - assert_eq!(editor.selections()[0].end.column, 3); - assert_eq!(editor.cursor().column, 0); - editor.handle(Input::Char('d')); - assert_eq!(editor.context().text, ""); - assert_eq!(editor.clipboard, vec!["a\u{301}👩‍🚀界", "z"]); - assert_eq!(editor.selection_count(), 1); - editor.handle(Input::Char('u')); - assert_eq!( - editor.carets(), - vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 3 }] - ); - } - - #[test] - fn clipboard_pairs_in_document_order_broadcasts_one_and_refuses_mismatch_atomically() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("ab\nxyzq".into())]) - .unwrap(); - set_ranges( - &mut editor, - &[ - (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 1 }), - (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), - ], - 1, - ); - editor.handle(Input::Char('d')); - assert_eq!(editor.context().text, "b\nq"); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "ab\nxyzq"); - editor.handle(Input::Char('u')); - editor.handle(Input::Escape); - assert!(!editor.notification_visible()); - assert_eq!(editor.selection_count(), 2); - editor.handle(Input::Escape); - assert_eq!(editor.selection_count(), 1); - let before = editor.snapshot(); - let undo = editor.active.undo.len(); - let redo = editor.active.redo.len(); - editor.handle(Input::Char('p')); - assert_eq!(editor.active.lines, before.lines); - assert_eq!(editor.active.view.targets, before.targets); - assert_eq!(editor.active.view.primary, before.primary); - assert_eq!( - (editor.active.undo.len(), editor.active.redo.len()), - (undo, redo) - ); - assert!(editor.message().contains("fragment count")); - editor.clipboard = vec!["!".into()]; - set_carets(&mut editor, &[(0, 0), (1, 0)], 0); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "!b\n!q"); - } - - #[test] - fn literal_multiline_occurrences_skip_wrap_select_all_and_escape_in_two_stages() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("α\nβ α\nβ α\nβ".into())]) - .unwrap(); - set_ranges( - &mut editor, - &[(Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 1 })], - 0, - ); - editor.handle(Input::Ctrl('n')); - assert_eq!(editor.selection_count(), 2); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - editor.handle(Input::Ctrl('s')); - assert_eq!(editor.selection_count(), 2); - assert_eq!(editor.cursor(), Cursor { row: 3, column: 0 }); - editor.handle(Input::Ctrl('n')); - assert_eq!(editor.selection_count(), 3); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - editor.handle(Input::Ctrl('a')); - assert_eq!(editor.selection_count(), 3); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.selection_count(), 3); - let primary = editor.cursor(); - editor.handle(Input::Escape); - assert_eq!(editor.carets(), vec![primary]); - } - - #[test] - fn occurrences_reject_grapheme_intersections_and_gather_nonoverlapping_literals() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("aa aa aaa a\u{301}a".into())]) - .unwrap(); - editor.handle(Input::Char('v')); - editor.handle(Input::Right); - editor.handle(Input::Ctrl('a')); - assert_eq!( - editor - .selections() - .iter() - .map(|range| range.start.column) - .collect::>(), - vec![0, 3, 6] - ); - assert_eq!(editor.cursor().column, 1); - editor.handle(Input::Ctrl('n')); - assert_eq!(editor.selection_count(), 3); - } - - #[test] - fn vertical_clones_keep_display_columns_through_tabs_wide_and_short_rows() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a\t界z\nx\n123456z".into())]) - .unwrap(); - editor.handle(Input::End); - editor.handle(Input::Ctrl('e')); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - editor.handle(Input::Ctrl('e')); - assert_eq!(editor.selection_count(), 3); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 6 }); - editor.handle(Input::Char('i')); - type_text(&mut editor, "!"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "a\t界!z\n!x\n123456!z"); - assert_eq!(editor.selection_count(), 3); - editor.handle(Input::Escape); - assert_eq!(editor.selection_count(), 1); - } - - #[test] - fn visual_clones_preserve_directed_ranges_and_motion_targets_every_range() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abc\nabcd\nabcde".into())]) - .unwrap(); - editor.handle(Input::Right); - editor.handle(Input::Char('v')); - editor.handle(Input::Right); - editor.handle(Input::Ctrl('e')); - assert_eq!(editor.selection_count(), 2); - editor.handle(Input::Ctrl('e')); - assert_eq!(editor.selection_count(), 3); - editor.handle(Input::Left); - assert!( - editor - .selections() - .iter() - .all(|range| range.start.column == 1 && range.end.column == 2) - ); - editor.handle(Input::Char('c')); - type_text(&mut editor, "!"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "a!c\na!cd\na!cde"); - } - - #[test] - fn batch_open_deduplicates_rows_and_join_uses_original_neighbor_rows() { - for (key, expected) in [('o', "abc\n\nxyz\n"), ('O', "\nabc\n\nxyz")] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abc\nxyz".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (0, 2), (1, 1)], 2); - editor.handle(Input::Char(key)); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, expected); - assert_eq!(editor.selection_count(), 2); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "abc\nxyz"); - assert_eq!(editor.selection_count(), 3); - } - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a\n b\n c".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 1)], 1); - editor.handle(Input::Char('J')); - assert_eq!(editor.context().text, "a b c"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "a\n b\n c"); - assert_eq!(editor.selection_count(), 2); - } - - #[test] - fn plugin_text_effects_collapse_selection_but_presentation_effects_do_not() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) - .unwrap(); - editor.register(plugin("sample", &["sample"])).unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 1); - assert_eq!(editor.execute("sample").unwrap().command, "sample"); - assert_eq!(editor.selection_count(), 3); - editor.handle(Input::Char('v')); - let ranges = editor.selections(); - assert!(editor.apply(vec![Effect::Insert("\0".into())]).is_err()); - editor - .apply(vec![ - Effect::Message("ok".into()), - Effect::Label { - name: "battery".into(), - text: "ready".into(), - }, - ]) - .unwrap(); - assert_eq!(editor.label("battery"), "ready"); - assert_eq!(editor.selections(), ranges); - assert_eq!(editor.mode(), Mode::Visual); - editor.apply(vec![Effect::Insert("!".into())]).unwrap(); - assert_eq!(editor.context().text, "aa\n!bb\ncc"); - assert_eq!(editor.selection_count(), 1); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "aa\nbb\ncc"); - assert_eq!(editor.selection_count(), 3); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - assert_eq!(editor.mode(), Mode::Normal); - } - - #[test] - fn literal_paste_broadcasts_and_retains_separate_typing_history_for_all_carets() { - let mut editor = editor(); - editor.apply(vec![Effect::Replace("a\nb".into())]).unwrap(); - editor.handle(Input::Ctrl('e')); - editor.handle(Input::Char('i')); - type_text(&mut editor, "x"); - editor.handle(Input::Paste("界e\u{301}\r\n".into())); - type_text(&mut editor, "z"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "x界e\u{301}\nza\nx界e\u{301}\nzb"); - for expected in ["x界e\u{301}\na\nx界e\u{301}\nb", "xa\nxb", "a\nb"] { - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, expected); - assert_eq!(editor.selection_count(), 2); - } - } - - #[test] - fn caret_motions_and_search_apply_to_all_targets_and_collapse_duplicates() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("cat cat\ncat cat".into())]) - .unwrap(); - set_carets(&mut editor, &[(0, 0), (1, 0), (1, 0)], 2); - assert_eq!(editor.selection_count(), 2); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - submit_search(&mut editor, "cat"); - editor.handle(Input::Char('n')); - assert_eq!( - editor.carets(), - vec![Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 4 }] - ); - editor.handle(Input::Char('b')); - assert!(editor.carets().iter().all(|cursor| cursor.column == 0)); - editor.handle(Input::Up); - assert_eq!(editor.carets(), vec![Cursor::default()]); - } - - #[test] - fn word_motions_distinguish_punctuation_cross_empty_lines_and_preserve_history() { - let mut editor = editor(); - let text = "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail"; - editor.apply(vec![Effect::Replace(text.into())]).unwrap(); - for (key, row, column) in [ - ('w', 0, 7), - ('w', 0, 10), - ('w', 0, 15), - ('w', 1, 0), - ('w', 3, 0), - ('w', 3, 5), - ('w', 3, 9), - ('w', 3, 12), - ('w', 3, 12), - ('b', 3, 9), - ('b', 3, 5), - ('b', 3, 0), - ('b', 1, 0), - ('b', 0, 15), - ('b', 0, 10), - ('b', 0, 7), - ('b', 0, 0), - ('b', 0, 0), - ('e', 0, 6), - ('e', 0, 9), - ('e', 0, 12), - ('e', 0, 17), - ('e', 3, 3), - ('e', 3, 7), - ('e', 3, 12), - ('e', 3, 12), - ] { - editor.handle(Input::Char(key)); - assert_eq!(editor.cursor(), Cursor { row, column }, "{key}"); - } - assert_eq!(editor.context().text, text); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, text); - } - - #[test] - fn big_word_motions_use_only_whitespace_and_handle_buffer_edges() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace( - "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail".into(), - )]) - .unwrap(); - for (key, row, column) in [ - ('W', 0, 15), - ('W', 1, 0), - ('W', 3, 0), - ('W', 3, 5), - ('W', 3, 9), - ('W', 3, 12), - ('B', 3, 9), - ('B', 3, 5), - ('B', 3, 0), - ('B', 1, 0), - ('B', 0, 15), - ('B', 0, 0), - ('B', 0, 0), - ('E', 0, 12), - ('E', 0, 17), - ('E', 3, 3), - ('E', 3, 7), - ('E', 3, 12), - ('E', 3, 12), - ] { - editor.handle(Input::Char(key)); - assert_eq!(editor.cursor(), Cursor { row, column }, "{key}"); - } - for (text, next, end) in [("", 0, 0), (" ", 2, 2), ("word ", 6, 3)] { - editor.apply(vec![Effect::Replace(text.into())]).unwrap(); - editor.handle(Input::Home); - editor.handle(Input::Char('e')); - assert_eq!( - editor.cursor(), - Cursor { - row: 0, - column: end - } - ); - editor.handle(Input::Char('w')); - assert_eq!( - editor.cursor(), - Cursor { - row: 0, - column: next - } - ); - editor.handle(Input::Char('b')); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); - } - editor - .apply(vec![Effect::Replace("one\u{2003}two\u{a0}three\n".into())]) - .unwrap(); - editor.handle(Input::Home); - editor.handle(Input::Char('w')); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); - editor.handle(Input::Char('w')); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 }); - editor.handle(Input::Char('w')); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - editor.handle(Input::Char('e')); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - editor.handle(Input::Char('b')); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 }); - } - - #[test] - fn search_wraps_both_ways_and_cancel_failure_and_navigation_preserve_edits() { - let mut editor = editor(); - let text = "zero cat\ncat cat\nCAT\n"; - editor.apply(vec![Effect::Replace(text.into())]).unwrap(); - submit_search(&mut editor, "cat"); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); - assert_eq!(editor.search_match().unwrap().end_column, 8); - editor.handle(Input::Char('n')); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - editor.handle(Input::Char('n')); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 }); - editor.handle(Input::Char('n')); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); - editor.handle(Input::Char('N')); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 }); - editor.handle(Input::Char('N')); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - - let cursor = editor.cursor(); - let active = editor.search_match(); - editor.handle(Input::Char('/')); - type_text(&mut editor, "other界e\u{301}"); - editor.handle(Input::Backspace); - assert_eq!(editor.command_line(), "other界"); - editor.handle(Input::Paste(":q!\n".into())); - assert_eq!(editor.command_line(), "other界"); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.cursor(), cursor); - assert_eq!(editor.search_match(), active); - assert!(editor.command_line().is_empty()); - submit_search(&mut editor, ""); - assert_eq!(editor.cursor(), cursor); - - let cursor = editor.cursor(); - submit_search(&mut editor, "missing"); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.cursor(), cursor); - assert_eq!(editor.search_match(), None); - editor.handle(Input::Char('N')); - assert_eq!(editor.cursor(), cursor); - assert!(!editor.should_quit()); - assert_eq!(editor.context().text, text); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, text); - } - - #[test] - fn search_is_literal_overlapping_and_grapheme_safe_and_rechecks_after_edits() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("e\u{301} e 👩‍🚀 👩 aa... aaa".into())]) - .unwrap(); - submit_search(&mut editor, "e"); - assert_eq!(editor.cursor().column, 2); - submit_search(&mut editor, "\u{301}"); - assert_eq!(editor.cursor().column, 2); - assert_eq!(editor.search_match(), None); - submit_search(&mut editor, "👩"); - assert_eq!(editor.cursor().column, 6); - submit_search(&mut editor, "👩‍🚀"); - assert_eq!(editor.cursor().column, 4); - assert_eq!(editor.search_match().unwrap().end_column, 5); - submit_search(&mut editor, "."); - assert_eq!(editor.cursor().column, 10); - editor.handle(Input::Char('n')); - assert_eq!(editor.cursor().column, 11); - submit_search(&mut editor, "aa"); - assert_eq!(editor.cursor().column, 14); - editor.handle(Input::Char('n')); - assert_eq!(editor.cursor().column, 15); - editor.handle(Input::Char('n')); - assert_eq!(editor.cursor().column, 8); - submit_search(&mut editor, "e\u{301}"); - assert_eq!(editor.cursor().column, 0); - assert_eq!(editor.search_match().unwrap().end_column, 1); - - let active = editor.search_match(); - assert!(editor.apply(vec![Effect::Replace("\0".into())]).is_err()); - assert_eq!(editor.search_match(), active); - editor.handle(Input::Char('x')); - assert_eq!(editor.search_match(), None); - editor.handle(Input::Char('n')); - assert_eq!(editor.search_match(), None); - editor.handle(Input::Char('u')); - assert_eq!(editor.search_match(), None); - editor.handle(Input::Char('n')); - assert_eq!(editor.cursor().column, 0); - assert_eq!(editor.search_match().unwrap().end_column, 1); - editor - .apply(vec![Effect::Replace("e\u{301}".into())]) - .unwrap(); - assert_eq!(editor.search_match(), None); - editor.handle(Input::Char('n')); - assert_eq!(editor.cursor().column, 0); - assert_eq!(editor.search_match().unwrap().end_column, 1); - } - - #[test] - fn join_lines_matches_vim_spacing_and_grapheme_cursor_positions() { - for (original, expected, column) in [ - ("alpha\n \tbeta\ntail", "alpha beta\ntail", 5), - ("alpha \n\tbeta", "alpha beta", 7), - ("alpha\t\n beta", "alpha\tbeta", 6), - ("\n beta", "beta", 0), - ("alpha\n \t", "alpha", 4), - ("\n", "", 0), - ("alpha\n )tail", "alpha)tail", 5), - ("Sentence.\n next", "Sentence. next", 9), - ("界e\u{301}\n next", "界e\u{301} next", 2), - ("x \n\u{301}y", "x \u{301}y", 1), - ] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace(original.into())]) - .unwrap(); - editor.handle(Input::Char('J')); - assert_eq!(editor.context().text, expected); - assert_eq!(editor.cursor(), Cursor { row: 0, column }); - assert_eq!(editor.mode(), Mode::Normal); - } - } - - #[test] - fn join_is_one_undo_action_on_the_current_row_and_restores_cursor() { - let mut editor = editor(); - let original = "above\nalpha\n beta\nbelow"; - editor - .apply(vec![Effect::Replace(original.into())]) - .unwrap(); - editor.handle(Input::Down); - editor.handle(Input::Right); - let before = editor.cursor(); - editor.handle(Input::Char('J')); - assert_eq!(editor.context().text, "above\nalpha beta\nbelow"); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 5 }); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, original); - assert_eq!(editor.cursor(), before); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "above\nalpha beta\nbelow"); - editor.handle(Input::Char('x')); - assert_eq!(editor.context().text, "above\nalphabeta\nbelow"); - } - - #[test] - fn join_at_eof_preserves_history_and_insert_mode_j_is_literal() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("single".into())]) - .unwrap(); - editor.handle(Input::Char('u')); - editor.handle(Input::Char('J')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "single"); - editor.handle(Input::Char('J')); - assert_eq!(editor.context().text, "single"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Char('U')); - editor.handle(Input::Char('i')); - editor.handle(Input::Char('J')); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "Jsingle"); - } - - #[test] - fn uppercase_u_redoes_and_control_r_no_longer_does() { - let mut editor = editor(); - editor.handle(Input::Char('i')); - type_text(&mut editor, "changed"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Ctrl('r')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "changed"); - } - - #[test] - fn uppercase_insert_keys_target_line_positions_and_preserve_undo_cursor() { - for (original, key, expected) in [ - (" \t界e\u{301} tail", 'I', " \tX界e\u{301} tail"), - (" \t ", 'I', " \t X"), - ("", 'I', "X"), - (" \t界e\u{301} tail", 'A', " \t界e\u{301} tailX"), - ("", 'A', "X"), - ] { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace(original.into())]) - .unwrap(); - editor.handle(if key == 'I' { Input::End } else { Input::Right }); - let before = editor.cursor(); - editor.handle(Input::Char(key)); - assert_eq!(editor.mode(), Mode::Insert); - type_text(&mut editor, "X"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, expected); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, original); - assert_eq!(editor.cursor(), before); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, expected); - } - } - - #[test] - fn paste_is_ignored_in_normal_and_command_modes_without_dispatching() { - let mut editor = editor(); - assert_eq!(editor.handle(Input::Paste(":q!\r\n".into())), None); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.context().text, ""); - assert!(!editor.dirty()); - assert!(!editor.should_quit()); - - editor.handle(Input::Char(':')); - type_text(&mut editor, "q"); - assert_eq!(editor.handle(Input::Paste("\rq!\n\0".into())), None); - assert_eq!(editor.mode(), Mode::Command); - assert_eq!(editor.command_line(), "q"); - assert_eq!(editor.context().text, ""); - assert!(!editor.should_quit()); - editor.handle(Input::Enter); - assert!(editor.should_quit()); - } - - #[test] - fn pasted_unicode_and_newlines_are_literal_and_a_separate_undo_action() { - let mut editor = editor(); - editor.handle(Input::Char('i')); - type_text(&mut editor, "a"); - assert_eq!( - editor.handle(Input::Paste("界e\u{301}\t\r\n:q!\r".into())), - None - ); - assert_eq!(editor.mode(), Mode::Insert); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - type_text(&mut editor, "z"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "a界e\u{301}\t\n:q!\nz"); - assert!(!editor.should_quit()); - - for expected in ["a界e\u{301}\t\n:q!\n", "a", ""] { - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, expected); - } - for expected in ["a", "a界e\u{301}\t\n:q!\n", "a界e\u{301}\t\n:q!\nz"] { - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, expected); - } - } - - #[test] - fn rejected_paste_preserves_text_cursor_mode_and_pending_insert_history() { - for text in [ - "valid prefix\ninvalid\0".to_owned(), - "\u{1b}[31m".to_owned(), - "x".repeat(MAX_EDIT_BYTES + 1), - ] { - let mut editor = editor(); - editor.handle(Input::Char('i')); - type_text(&mut editor, "before"); - let cursor = editor.cursor(); - editor.handle(Input::Paste(text)); - assert_eq!(editor.context().text, "before"); - assert_eq!(editor.cursor(), cursor); - assert_eq!(editor.mode(), Mode::Insert); - assert!( - editor.message().contains("Unsupported control character") - || editor.message().contains("Paste exceeds 4 MiB") - ); - type_text(&mut editor, "after"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "beforeafter"); - } - } - - #[test] - fn insert_sessions_and_open_lines_are_single_undo_actions() { - let mut editor = editor(); - editor.handle(Input::Char('i')); - type_text(&mut editor, "hello\n世界"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "hello\n世界"); - assert!(editor.dirty()); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - assert!(!editor.dirty()); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "hello\n世界"); - editor.handle(Input::Char('O')); - type_text(&mut editor, "above"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "hello\nabove\n世界"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "hello\n世界"); - editor.handle(Input::Char('o')); - type_text(&mut editor, "below"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "hello\n世界\nbelow"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "hello\n世界"); - } - - #[test] - fn grapheme_navigation_and_deletion_do_not_split_unicode() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a\u{301}👩‍💻界".into())]) - .unwrap(); - editor.handle(Input::Char('l')); - assert_eq!(editor.cursor().column, 1); - editor.handle(Input::Char('x')); - assert_eq!(editor.context().text, "a\u{301}界"); - editor.handle(Input::Char('0')); - editor.handle(Input::Delete); - assert_eq!(editor.context().text, "界"); - editor.handle(Input::Char('a')); - type_text(&mut editor, "👨‍👩‍👧"); - editor.handle(Input::Backspace); - assert_eq!(editor.context().text, "界"); - editor.handle(Input::Escape); - assert_eq!(editor.cursor().column, 0); - assert_eq!(editor.context().column, 1); - } - - #[test] - fn insertion_merges_combining_graphemes_and_joins_lines() { - let mut editor = editor(); - editor.handle(Input::Char('i')); - type_text(&mut editor, "a\u{301}\nb"); - editor.handle(Input::Home); - editor.handle(Input::Backspace); - assert_eq!(editor.context().text, "a\u{301}b"); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); - editor.handle(Input::Enter); - editor.handle(Input::Up); - editor.handle(Input::End); - editor.handle(Input::Delete); - assert_eq!(editor.context().text, "a\u{301}b"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - } - - #[test] - fn cursor_is_clamped_on_short_and_empty_lines() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("long\nx\n".into())]) - .unwrap(); - editor.handle(Input::End); - assert_eq!(editor.cursor().column, 3); - editor.handle(Input::Down); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - editor.handle(Input::Char('j')); - editor.handle(Input::Char('l')); - editor.handle(Input::Char('x')); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - assert_eq!(editor.context().row, 3); - editor.handle(Input::Char('i')); - editor.handle(Input::Left); - editor.handle(Input::Delete); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "long\nx\n"); - } - - #[test] - fn registry_rejects_incompatible_duplicate_and_forged_core_atomically() { - let mut editor = editor(); - let mut feature = plugin("graphics", &["draw"]); - feature.required_capabilities.push("graphics".into()); - assert!(editor.register(feature).is_err()); - assert!(editor.bind("normal", "g", Some("draw".into())).is_err()); - assert!( - editor - .register(plugin("duplicate", &["newcmd", "w"])) - .is_err() - ); - assert!(editor.bind("normal", "g", Some("newcmd".into())).is_err()); - assert!( - editor - .register(plugin("repeat", &["same", "same"])) - .is_err() - ); - assert!(editor.register(plugin("invalid", &["bad name"])).is_err()); - for name in [ - ".hover", - "lsp.", - "lsp..hover", - "lsp.3hover", - "lsp.hover!extra", - ] { - assert!( - editor - .register(plugin("invalid", &["newcmd", name])) - .is_err() - ); - assert!(editor.bind("normal", "g", Some("newcmd".into())).is_err()); - } - assert!(editor.register(plugin("", &["emptyname"])).is_err()); - let mut feature = plugin("forged", &["override"]); - feature.core = true; - assert!(editor.register(feature).is_err()); - editor.register(plugin("working", &["greet"])).unwrap(); - assert!(editor.register(plugin("working", &["different"])).is_err()); - assert!(editor.register(plugin("other", &["greet"])).is_err()); - assert!(editor.register(plugin("collision", &["plugins"])).is_err()); - editor.disable_plugin("core", "attempt"); - editor.execute("q"); - assert!(editor.should_quit()); - } - - #[test] - fn readable_key_aliases_share_bindings_and_invalid_chords_leave_them_intact() { - let mut editor = editor(); - editor.bind("normal", "shift+i", None).unwrap(); - editor.handle(Input::Char('I')); - assert_eq!(editor.mode(), Mode::Normal); - editor - .bind("normal", "I", Some("core.insert".into())) - .unwrap(); - editor.handle(Input::Char('I')); - assert_eq!(editor.mode(), Mode::Insert); - - editor - .bind("insert", "plus", Some("core.newline".into())) - .unwrap(); - editor.bind("insert", "+", None).unwrap(); - editor.handle(Input::Char('+')); - type_text(&mut editor, "界"); - assert_eq!(editor.lines(), &["界"]); - editor - .bind("insert", "plus", Some("core.newline".into())) - .unwrap(); - editor.handle(Input::Char('+')); - assert_eq!(editor.lines(), &["界", ""]); - - editor - .bind("insert", "shift+j", Some("core.normal".into())) - .unwrap(); - for key in [ - "", - "", - "", - "ctrl+", - "+r", - "ctrl++r", - "ctrl+shift+j", - "shift+1", - "cmd+j", - "ctrl+界", - ] { - assert!( - editor - .bind("insert", key, Some("core.newline".into())) - .is_err(), - "{key}" - ); - } - editor.handle(Input::Char('J')); - assert_eq!(editor.mode(), Mode::Normal); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), &[String::new()]); - } - - #[test] - fn plugin_bindings_dispatch_only_normal_mode_and_retire_with_owner() { - let mut editor = editor(); - editor.register(plugin("greeting", &["greet"])).unwrap(); - assert!(editor.bind("insert", "g", Some("greet".into())).is_err()); - editor.bind("normal", "g", Some("greet".into())).unwrap(); - assert_eq!( - editor.handle(Input::Char('g')), - Some(Invocation { - plugin: "greeting".into(), - command: "greet".into(), - args: String::new(), - }) - ); - editor.handle(Input::Char('i')); - assert_eq!(editor.handle(Input::Char('g')), None); - assert_eq!(editor.context().text, "g"); - editor.handle(Input::Escape); - editor.disable_plugin("greeting", "crashed\u{1b}[31m"); - assert_eq!(editor.handle(Input::Char('g')), None); - assert!(editor.bind("normal", "g", Some("greet".into())).is_err()); - assert!(!editor.message().contains('\u{1b}')); - } - - #[test] - fn command_mode_dispatches_arguments_and_backspaces_whole_graphemes() { - let mut editor = editor(); - editor.register(plugin("greeting", &["greet"])).unwrap(); - editor.handle(Input::Char(':')); - type_text(&mut editor, "greet a\u{301}"); - editor.handle(Input::Backspace); - assert_eq!(editor.command_line(), "greet "); - type_text(&mut editor, "world"); - assert_eq!( - editor.handle(Input::Enter), - Some(Invocation { - plugin: "greeting".into(), - command: "greet".into(), - args: "world".into(), - }) - ); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.command_line(), ""); - editor.handle(Input::Char(':')); - type_text(&mut editor, "q!"); - editor.handle(Input::Escape); - assert!(!editor.should_quit()); - assert_eq!(editor.command_line(), ""); - } - - #[test] - fn chrome_installation_and_invalid_label_batches_are_atomic() { - let mut editor = editor(); - editor.set_label("battery".into(), "ready".into()).unwrap(); - let mut top = ChromeLine::top(); - top.left = vec!["mode".into(), "language".into()]; - top.center.push("workspace".into()); - let mut bottom = ChromeLine::bottom(); - bottom.left = vec!["tabs".into()]; - editor - .set_chrome_config( - top.clone(), - bottom.clone(), - BTreeMap::from([ - ("battery".into(), "ready".into()), - ("workspace".into(), "tools\n".into()), - ]), - ) - .unwrap(); - assert_eq!(editor.label("workspace"), "tools�"); - assert_eq!( - editor.top_bar_config().segment("workspace"), - &crate::chrome::SegmentConfig::default() - ); - let installed_top = editor.top_bar_config().clone(); - let installed_bottom = editor.bottom_bar_config().clone(); - assert_eq!(installed_top.left, ["mode", "language"]); - assert_eq!(installed_bottom.left, ["tabs"]); - top.right.push("uninstalled".into()); - bottom.center.push("uninstalled".into()); - let mut invalid_top = top.clone(); - invalid_top.segments.get_mut("tabs").unwrap().title = "{missing}".into(); - let mut invalid_bottom = bottom.clone(); - invalid_bottom.segments.get_mut("mode").unwrap().padding = 65; - for (top, bottom, labels) in [ - (invalid_top, bottom.clone(), BTreeMap::new()), - (top.clone(), invalid_bottom, BTreeMap::new()), - ( - top, - bottom, - BTreeMap::from([("bad-name".into(), "invalid".into())]), - ), - ] { - assert!(editor.set_chrome_config(top, bottom, labels).is_err()); - assert_eq!(editor.top_bar_config(), &installed_top); - assert_eq!(editor.bottom_bar_config(), &installed_bottom); - assert_eq!(editor.label("battery"), "ready"); - assert_eq!(editor.label("workspace"), "tools�"); - } - assert!( - editor - .apply(vec![ - Effect::Message("must not leak".into()), - Effect::Label { - name: "battery".into(), - text: "changed".into() - }, - Effect::Insert("must not edit".into()), - Effect::Label { - name: "bad-name".into(), - text: "invalid".into() - }, - ]) - .is_err() - ); - assert_eq!(editor.label("battery"), "ready"); - assert_eq!(editor.message(), ""); - assert_eq!(editor.context().text, ""); - assert_eq!(editor.text_revision(), 0); - } - - #[test] - fn native_battery_cache_validates_changes_without_editing_or_replacing_labels() { - let mut editor = editor(); - editor.handle(Input::Char('i')); - editor.handle(Input::Paste("document".into())); - editor.handle(Input::Escape); - editor.handle(Input::Char('v')); - let text = editor.context().text; - let selection = editor.selections(); - let revision = editor.text_revision(); - let history = (editor.active.undo.len(), editor.active.redo.len()); - assert_eq!(editor.battery_label(), ""); - assert!(!editor.set_battery_charge(None)); - assert!(!editor.set_battery_charge(Some(101))); - assert!(editor.set_battery_charge(Some(27))); - assert_eq!(editor.battery_label(), "bat 27%"); - assert!(!editor.set_battery_charge(Some(27))); - assert_eq!(editor.label("battery"), ""); - - editor.set_label("battery".into(), "custom".into()).unwrap(); - assert!(editor.set_battery_charge(Some(0))); - assert_eq!(editor.battery_label(), "bat 0%"); - assert_eq!(editor.label("battery"), "custom"); - assert!(editor.set_battery_charge(Some(100))); - assert_eq!(editor.battery_label(), "bat 100%"); - assert!(editor.set_battery_charge(Some(255))); - assert_eq!(editor.battery_label(), ""); - assert!(!editor.set_battery_charge(None)); - assert_eq!(editor.label("battery"), "custom"); - assert_eq!(editor.context().text, text); - assert_eq!(editor.selections(), selection); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.text_revision(), revision); - assert_eq!( - (editor.active.undo.len(), editor.active.redo.len()), - history - ); - - assert!(editor.set_battery_charge(Some(28))); - editor.execute("tab.new"); - assert_eq!(editor.battery_label(), "bat 28%"); - editor.set_label("battery".into(), String::new()).unwrap(); - assert_eq!(editor.battery_label(), "bat 28%"); - assert_eq!(editor.label("battery"), ""); - assert!(editor.set_battery_charge(None)); - assert_eq!(editor.battery_label(), ""); - } - - #[test] - fn named_labels_sanitize_and_survive_tabs_until_independently_cleared() { - let mut editor = editor(); - assert_eq!(editor.label("battery"), ""); - assert!( - editor - .set_label("battery".into(), "界 e\u{301} 👩‍🚀".into()) - .unwrap() - ); - assert!( - !editor - .set_label("battery".into(), "界 e\u{301} 👩‍🚀".into()) - .unwrap() - ); - assert!( - editor - .set_label("battery".into(), "界\0\t\r\n\u{1b}\u{85}👩‍🚀".into()) - .unwrap() - ); - editor - .set_label("workspace".into(), "tools".into()) - .unwrap(); - assert_eq!(editor.label("battery"), "界������👩‍🚀"); - editor.set_message("transient"); - editor.execute("tab.new"); - assert_eq!(editor.label("battery"), "界������👩‍🚀"); - editor.execute("tab.previous"); - assert_eq!(editor.label("workspace"), "tools"); - assert!(editor.set_label("battery".into(), String::new()).unwrap()); - editor.execute("tab.next"); - assert_eq!(editor.label("battery"), ""); - assert!(!editor.set_label("battery".into(), String::new()).unwrap()); - assert_eq!(editor.label("workspace"), "tools"); - } - - #[test] - fn readonly_diff_accepts_atomic_label_updates_without_editing_state() { - let directory = Directory::new(); - let mut editor = editor(); - historical_diff( - &mut editor, - &directory.0, - vec!["base".into()], - vec!["head".into()], - ); - editor.handle(Input::Char('v')); - let selection = editor.selections(); - let revision = editor.text_revision(); - let text = editor.context().text; - let history = (editor.active.undo.len(), editor.active.redo.len()); - editor - .apply(vec![ - Effect::Label { - name: "battery".into(), - text: "界\n\u{1b}".into(), - }, - Effect::Message("read only".into()), - ]) - .unwrap(); - assert_eq!(editor.label("battery"), "界��"); - assert_eq!(editor.message(), "read only"); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selections(), selection); - assert_eq!(editor.text_revision(), revision); - assert_eq!(editor.context().text, text); - assert_eq!( - (editor.active.undo.len(), editor.active.redo.len()), - history - ); - assert!( - editor - .apply(vec![ - Effect::Label { - name: "battery".into(), - text: "must not leak".into() - }, - Effect::Insert("rejected".into()), - ]) - .is_err() - ); - assert_eq!(editor.label("battery"), "界��"); - editor - .apply(vec![Effect::Label { - name: "battery".into(), - text: String::new(), - }]) - .unwrap(); - assert_eq!(editor.label("battery"), ""); - } - - #[test] - fn plugin_effects_validate_before_any_mutation_and_group_undo() { - let mut editor = editor(); - editor.set_message("unchanged"); - editor.handle(Input::Escape); - assert!(!editor.notification_visible()); - editor - .set_label("battery".into(), "unchanged".into()) - .unwrap(); - assert!( - editor - .apply(vec![ - Effect::Label { - name: "battery".into(), - text: "must not leak".into() - }, - Effect::Insert("good".into()), - Effect::Message("bad\u{1b}".into()) - ]) - .is_err() - ); - assert_eq!(editor.context().text, ""); - assert_eq!(editor.message(), "unchanged"); - assert!(!editor.notification_visible()); - assert_eq!(editor.label("battery"), "unchanged"); - assert!(!editor.dirty()); - assert!( - editor - .apply(vec![ - Effect::Label { - name: "battery".into(), - text: "x".into() - }, - Effect::Insert("a".repeat(MAX_EDIT_BYTES)) - ]) - .is_err() - ); - assert_eq!(editor.context().text, ""); - assert_eq!(editor.label("battery"), "unchanged"); - editor - .apply(vec![ - Effect::Insert("one".into()), - Effect::Insert("\ntwo".into()), - Effect::Message("ok".into()), - Effect::Label { - name: "battery".into(), - text: "updated".into(), - }, - ]) - .unwrap(); - assert_eq!(editor.context().text, "one\ntwo"); - assert_eq!(editor.message(), "ok"); - assert!(editor.notification_visible()); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - assert_eq!(editor.label("battery"), "updated"); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "one\ntwo"); - editor - .apply(vec![Effect::Replace("replacement".into())]) - .unwrap(); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "one\ntwo"); - } - - #[test] - fn plugin_effect_transactions_preserve_insert_sessions() { - let mut editor = editor(); - editor.handle(Input::Char('i')); - type_text(&mut editor, "before"); - let cursor = editor.cursor(); - let undo_len = editor.active.undo.len(); - editor - .apply(vec![ - Effect::Label { - name: "battery".into(), - text: "typing".into(), - }, - Effect::Message("notice".into()), - ]) - .unwrap(); - assert_eq!(editor.active.undo.len(), undo_len); - assert!( - editor - .apply(vec![Effect::Replace("invalid\0".into())]) - .is_err() - ); - assert_eq!(editor.mode(), Mode::Insert); - assert_eq!(editor.cursor(), cursor); - type_text(&mut editor, "after"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Char('i')); - type_text(&mut editor, "a"); - editor - .apply(vec![ - Effect::Insert("b".into()), - Effect::Message("plugin".into()), - ]) - .unwrap(); - type_text(&mut editor, "c"); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "abc"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "ab"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "a"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - } - - #[test] - fn no_op_edits_preserve_redo_but_new_edits_clear_it() { - let mut editor = editor(); - editor.apply(vec![Effect::Insert("a".into())]).unwrap(); - editor.handle(Input::Char('u')); - editor.handle(Input::Char('i')); - editor.handle(Input::Escape); - editor - .apply(vec![Effect::Message("notice".into())]) - .unwrap(); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "a"); - editor.handle(Input::Char('u')); - editor.apply(vec![Effect::Insert("b".into())]).unwrap(); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "b"); - } - - #[test] - fn saves_preserve_empty_files_lf_trailing_newlines_and_dirty_history() { - let directory = Directory::new(); - for (name, text) in [ - ("empty", ""), - ("no-newline", "hello"), - ("newline", "hello\n"), - ("two-newlines", "hello\n\n"), - ] { - let path = directory.path(name); - fs::write(&path, text).unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - editor.execute("w"); - assert_eq!(fs::read_to_string(&path).unwrap(), text); - assert!(!editor.dirty()); - } - let path = directory.path("new file"); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - editor.handle(Input::Char('i')); - type_text(&mut editor, "saved\n"); - editor.handle(Input::Escape); - editor.execute("q"); - assert!(!editor.should_quit()); - editor.execute("w"); - assert_eq!(fs::read_to_string(&path).unwrap(), "saved\n"); - assert!(!editor.dirty()); - editor.handle(Input::Char('u')); - assert!(editor.dirty()); - editor.handle(Input::Char('U')); - assert!(!editor.dirty()); - editor.execute("q"); - assert!(editor.should_quit()); - } - - #[test] - fn save_as_and_failed_wq_keep_path_dirty_state_and_quit_correct() { - let directory = Directory::new(); - let mut editor = editor(); - editor - .apply(vec![Effect::Insert("unsaved".into())]) - .unwrap(); - editor.execute("w"); - assert!(editor.dirty()); - let bad = directory.path("absent/file"); - editor.execute(&format!("wq {}", bad.display())); - assert!(editor.dirty()); - assert!(!editor.should_quit()); - assert_eq!(editor.path(), None); - let path = directory.path("save as.txt"); - editor.execute(&format!("w {}", path.display())); - assert_eq!(editor.path(), Some(path.as_path())); - assert!(!editor.dirty()); - editor.apply(vec![Effect::Insert("new".into())]).unwrap(); - editor.execute("q!"); - assert!(editor.should_quit()); - assert_eq!(fs::read_to_string(path).unwrap(), "unsaved"); - } - - #[test] - fn loaded_controls_are_rejected_and_cr_line_endings_are_normalized() { - let directory = Directory::new(); - let path = directory.path("control"); - for text in ["nul\0", "escape\u{1b}[31m", "backspace\u{8}", "c1\u{85}"] { - fs::write(&path, text).unwrap(); - assert!( - matches!(Editor::open(Some(path.clone()), vec![]), Err(error) if error.kind() == io::ErrorKind::InvalidData) - ); - } - fs::write(&path, "a\t界\r\nb\rc\n").unwrap(); - let editor = Editor::open(Some(path), vec![]).unwrap(); - assert_eq!(editor.context().text, "a\t界\nb\nc\n"); - assert!(!editor.dirty()); - assert!(Editor::open(Some(directory.0.clone()), vec![]).is_err()); - } - - #[cfg(unix)] - #[test] - fn saves_preserve_permissions_and_reject_symlinks_without_replacing_them() { - use std::os::unix::fs::{PermissionsExt, symlink}; - let directory = Directory::new(); - let path = directory.path("original"); - fs::write(&path, "original").unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - editor - .apply(vec![Effect::Replace("changed".into())]) - .unwrap(); - editor.execute("w"); - assert_eq!( - fs::metadata(&path).unwrap().permissions().mode() & 0o777, - 0o640 - ); - let link = directory.path("link"); - symlink(&path, &link).unwrap(); - let mut editor = Editor::open(Some(link.clone()), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - editor - .apply(vec![Effect::Replace("not saved".into())]) - .unwrap(); - for command in ["wq", "wq!"] { - editor.execute(command); - assert!(editor.dirty()); - assert!(!editor.should_quit()); - } - assert!( - fs::symlink_metadata(&link) - .unwrap() - .file_type() - .is_symlink() - ); - assert_eq!(fs::read_to_string(&path).unwrap(), "changed"); - assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2); - } - - fn annotation(start: (usize, usize), end: (usize, usize), message: &str) -> Diagnostic { - Diagnostic { - start: TextPosition { - row: start.0, - column: start.1, - }, - end: TextPosition { - row: end.0, - column: end.1, - }, - severity: crate::plugin::DiagnosticSeverity::Warning, - message: message.into(), - } - } - - #[test] - fn annotations_are_owner_scoped_atomic_and_grapheme_bounded() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("a😀e\u{301}\n".into())]) - .unwrap(); - let id = editor.buffer_id(); - let revision = editor.text_revision(); - let first = annotation((0, 1), (0, 3), "first"); - editor - .replace_diagnostics("one", id, revision, vec![first.clone()]) - .unwrap(); - editor - .replace_diagnostics("two", id, revision, vec![annotation((1, 0), (1, 0), "end")]) - .unwrap(); - for invalid in [ - annotation((0, 0), (0, 4), "scalar column"), - annotation((0, 3), (0, 2), "reversed"), - annotation((2, 0), (2, 0), "missing row"), - annotation((0, 0), (0, 1), "control\u{1b}"), - ] { - assert!( - editor - .replace_diagnostics("one", id, revision, vec![first.clone(), invalid]) - .is_err() - ); - assert_eq!(editor.diagnostics().count(), 2); - assert_eq!( - editor.active.diagnostics["one"].items.as_slice(), - std::slice::from_ref(&first) - ); - } - assert!( - !editor - .replace_diagnostics( - "one", - id, - revision - 1, - vec![annotation((99, 0), (99, 0), "stale")] - ) - .unwrap() - ); - assert!( - !editor - .replace_diagnostics("one", 999, revision, vec![first.clone()]) - .unwrap() - ); - editor.active.view.targets = vec![Target::caret(Cursor { row: 0, column: 1 })]; - assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "first"); - editor.active.view.targets = vec![Target::caret(Cursor { row: 1, column: 0 })]; - assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "end"); - editor.clear_plugin_diagnostics("one"); - assert_eq!(editor.diagnostics().count(), 1); - } - - #[test] - fn annotations_do_not_split_typing_undo_and_clear_on_text_change() { - let mut editor = editor(); - editor.handle(Input::Char('i')); - editor.handle(Input::Char('a')); - let revision = editor.text_revision(); - editor - .replace_diagnostics( - "one", - editor.buffer_id(), - revision, - vec![annotation((0, 0), (0, 1), "typed")], - ) - .unwrap(); - assert!(editor.active.view.insert_start.is_some()); - assert!(editor.active.undo.is_empty()); - editor.handle(Input::Char('b')); - assert_eq!(editor.diagnostics().count(), 0); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.lines(), [""]); - assert!(editor.active.undo.is_empty()); - } - - #[test] - fn navigation_reuses_dirty_hidden_text_and_failure_preserves_source() { - let directory = Directory::new(); - let source = directory.path("source"); - let target = directory.path("target"); - fs::write(&source, "source").unwrap(); - fs::write(&target, "a😀e\u{301}\n").unwrap(); - let mut editor = Editor::open(Some(source.clone()), vec!["text".into()]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - editor.execute(&format!("edit {}", target.display())); - editor - .apply(vec![Effect::Replace("dirty😀e\u{301}\n".into())]) - .unwrap(); - let target_id = editor.buffer_id(); - editor.execute("buffer 1"); - editor.handle(Input::Char('i')); - editor.handle(Input::Char('!')); - let before = editor.context().text; - let cursor = editor.cursor(); - let undo = editor.active.undo.len(); - assert!( - editor - .navigate_to( - directory.path("missing"), - TextPosition { row: 0, column: 0 } - ) - .is_err() - ); - assert_eq!(editor.buffer_id(), 1); - assert_eq!(editor.context().text, before); - assert_eq!(editor.cursor(), cursor); - assert_eq!(editor.mode(), Mode::Insert); - assert_eq!(editor.active.undo.len(), undo); - assert!(editor.active.view.insert_start.is_some()); - editor - .navigate_to( - target, - TextPosition { - row: 99, - column: 99, - }, - ) - .unwrap(); - assert_eq!(editor.buffer_id(), target_id); - assert_eq!(editor.lines(), ["dirty😀e\u{301}", ""]); - assert_eq!(editor.buffer_count(), 2); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(fs::read_to_string(source).unwrap(), "source"); - } - fn completion_item(start: (usize, usize), end: (usize, usize), text: &str) -> CompletionItem { - CompletionItem { - label: text.into(), - detail: None, - primary_edit: 0, - snippet: None, - edits: vec![CompletionEdit { - start: TextPosition { - row: start.0, - column: start.1, - }, - end: TextPosition { - row: end.0, - column: end.1, - }, - text: text.into(), - }], - } - } - - #[test] - fn completion_cycles_from_original_spans_and_restores_pending_typing() { - let mut editor = editor(); - type_text(&mut editor, "iabcd"); - let revision = editor.text_revision(); - let steps = editor - .active - .view - .pending_edit - .as_ref() - .unwrap() - .steps - .len(); - editor - .show_completion(vec![ - completion_item((0, 2), (0, 4), "X"), - completion_item((0, 0), (0, 1), "Q\nZ"), - ]) - .unwrap(); - assert_eq!(editor.context().text, "abX"); - assert_eq!(editor.completion().unwrap().selected, 1); - editor.handle(Input::Ctrl('n')); - assert_eq!(editor.context().text, "Q\nZbcd"); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); - editor.handle(Input::Ctrl('j')); - assert_eq!(editor.context().text, "abcd"); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); - assert_eq!(editor.completion().unwrap().selected, 0); - assert_eq!( - editor - .active - .view - .pending_edit - .as_ref() - .unwrap() - .steps - .len(), - steps - ); - assert!(editor.text_revision() > revision); - editor.handle(Input::Ctrl('k')); - assert_eq!(editor.context().text, "Q\nZbcd"); - editor.handle(Input::Char('!')); - assert!(editor.completion().is_none()); - assert_eq!(editor.context().text, "Q\nZ!bcd"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - } - - #[test] - fn completion_same_span_noop_preview_and_original_preserve_redo() { - let mut editor = editor(); - type_text(&mut editor, "iold"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.active.redo.len(), 1); - editor.handle(Input::Char('i')); - editor - .show_completion(vec![ - completion_item((0, 0), (0, 0), "one"), - completion_item((0, 0), (0, 0), "one"), - ]) - .unwrap(); - let revision = editor.text_revision(); - editor.handle(Input::Ctrl('n')); - assert_eq!(editor.context().text, "one"); - assert_eq!(editor.text_revision(), revision); - editor.handle(Input::Ctrl('n')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Escape); - assert_eq!(editor.active.redo.len(), 1); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "old"); - } - - #[test] - fn completion_noncycle_keys_keep_standard_insert_semantics() { - for (key, expected, mode) in [ - (Input::Char('!'), "completed!", Mode::Insert), - (Input::Enter, "completed\n", Mode::Insert), - (Input::Char('\t'), "completed\t", Mode::Insert), - (Input::Escape, "completed", Mode::Normal), - (Input::Left, "completed", Mode::Insert), - (Input::Ctrl('z'), "completed", Mode::Insert), - ] { - let mut editor = editor(); - type_text(&mut editor, "iprefix"); - editor - .show_completion(vec![completion_item((0, 0), (0, 6), "completed")]) - .unwrap(); - editor.handle(key); - assert!(editor.completion().is_none()); - assert_eq!(editor.context().text, expected); - assert_eq!(editor.mode(), mode); - if mode == Mode::Insert { - editor.handle(Input::Escape); - } - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - } - } - - #[test] - fn completion_request_and_cycle_use_effective_insert_binding() { - let mut editor = editor(); - type_text(&mut editor, "iab"); - let steps = editor - .active - .view - .pending_edit - .as_ref() - .unwrap() - .steps - .len(); - assert_eq!( - editor.handle(Input::Ctrl('n')), - Some(Invocation { - plugin: "core".into(), - command: "lsp.completion".into(), - args: String::new(), - }) - ); - assert_eq!(editor.mode(), Mode::Insert); - assert_eq!( - editor - .active - .view - .pending_edit - .as_ref() - .unwrap() - .steps - .len(), - steps - ); - assert!(editor.active.undo.is_empty()); - editor - .show_completion(vec![ - completion_item((0, 0), (0, 2), "first"), - completion_item((0, 0), (0, 2), "second"), - ]) - .unwrap(); - editor.bind("insert", "Ctrl+n", None).unwrap(); - editor - .bind("insert", "Ctrl+x", Some("core.lsp.completion".into())) - .unwrap(); - editor.handle(Input::Ctrl('x')); - assert_eq!(editor.context().text, "second"); - editor.handle(Input::Ctrl('n')); - assert!(editor.completion().is_none()); - assert_eq!(editor.context().text, "second"); - } - - #[test] - fn completion_dot_rebases_only_final_choice_and_refuses_out_of_bounds_atomically() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("left ab".into())]) - .unwrap(); - editor.handle(Input::Char('A')); - editor - .show_completion(vec![ - completion_item((0, 5), (0, 7), "wrong"), - completion_item((0, 5), (0, 7), "final"), - ]) - .unwrap(); - editor.handle(Input::Ctrl('n')); - editor.handle(Input::Escape); - editor - .apply(vec![Effect::Replace("different zz".into())]) - .unwrap(); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "different final"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "different zz"); - editor.apply(vec![Effect::Replace("x".into())]).unwrap(); - let revision = editor.text_revision(); - let cursor = editor.cursor(); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "x"); - assert_eq!(editor.cursor(), cursor); - assert_eq!(editor.text_revision(), revision); - assert!(editor.message().contains("outside")); - editor - .apply(vec![Effect::Replace("new yy".into())]) - .unwrap(); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "new final"); - } - - #[test] - fn completion_additional_edits_translate_unicode_secondary_carets() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("e\u{301}x\n👩‍🚀z".into())]) - .unwrap(); - editor.handle(Input::Ctrl('e')); - let undo_carets = editor.carets(); - editor.handle(Input::Char('A')); - let original = editor.carets(); - let mut item = completion_item((1, 0), (1, 2), "界\n好"); - item.edits.push(CompletionEdit { - start: TextPosition { row: 0, column: 0 }, - end: TextPosition { row: 0, column: 1 }, - text: "prefix".into(), - }); - editor.show_completion(vec![item]).unwrap(); - assert_eq!(editor.context().text, "prefixx\n界\n好"); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 }); - assert_eq!( - editor.carets(), - [Cursor { row: 0, column: 7 }, Cursor { row: 2, column: 1 },] - ); - editor.handle(Input::Ctrl('n')); - assert_eq!(editor.context().text, "e\u{301}x\n👩‍🚀z"); - assert_eq!(editor.carets(), original); - editor.handle(Input::Ctrl('k')); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "e\u{301}x\n👩‍🚀z"); - assert_eq!(editor.carets(), undo_carets); - } - - #[test] - fn completion_validation_rejects_entire_set_before_any_mutation() { - let mut editor = editor(); - type_text(&mut editor, "iabc"); - let good = completion_item((0, 0), (0, 3), "valid"); - let mut bad_primary = good.clone(); - bad_primary.primary_edit = 1; - let mut overlap = good.clone(); - overlap.edits.push(overlap.edits[0].clone()); - for invalid in [ - bad_primary, - overlap, - completion_item((1, 0), (1, 0), "bad"), - completion_item((0, 4), (0, 4), "bad"), - completion_item((0, 2), (0, 1), "bad"), - completion_item((0, 0), (0, 3), "\0"), - completion_item((0, 0), (0, 3), &"x".repeat(MAX_EDIT_BYTES + 1)), - ] { - let revision = editor.text_revision(); - let steps = editor - .active - .view - .pending_edit - .as_ref() - .unwrap() - .steps - .len(); - assert!(editor.show_completion(vec![good.clone(), invalid]).is_err()); - assert_eq!(editor.context().text, "abc"); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); - assert_eq!(editor.text_revision(), revision); - assert_eq!( - editor - .active - .view - .pending_edit - .as_ref() - .unwrap() - .steps - .len(), - steps - ); - assert!(editor.completion().is_none()); - assert!(editor.active.undo.is_empty()); - } - let revision = editor.text_revision(); - editor.show_completion(Vec::new()).unwrap(); - assert_eq!(editor.context().text, "abc"); - assert_eq!(editor.text_revision(), revision); - assert_eq!(editor.mode(), Mode::Insert); - assert!(editor.completion().is_none()); - assert_eq!(editor.message(), "No completions"); - } - - #[test] - fn completion_overlay_command_buffer_and_paste_boundaries_commit_displayed_choice() { - for boundary in ["palette", "startup", "command", "paste", "rejected_paste"] { - let mut editor = editor(); - type_text(&mut editor, "iab"); - editor - .show_completion(vec![completion_item((0, 0), (0, 2), "chosen")]) - .unwrap(); - match boundary { - "palette" => { - editor.handle(Input::Ctrl('p')); - } - "startup" => editor.show_startup_diagnostic("notice".into()), - "command" => { - editor.execute("plugins"); - } - "paste" => { - editor.handle(Input::Paste("!".into())); - } - _ => { - editor.handle(Input::Paste("\0".into())); - } - } - assert!(editor.completion().is_none()); - assert_eq!( - editor.context().text, - if boundary == "paste" { - "chosen!" - } else { - "chosen" - } - ); - if boundary == "palette" || boundary == "startup" { - editor.handle(Input::Escape); - } - if editor.mode() == Mode::Insert { - editor.handle(Input::Escape); - } - if boundary == "paste" { - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "chosen"); - } - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - } - let directory = Directory::new(); - let other = directory.path("other"); - fs::write(&other, "other").unwrap(); - let mut editor = editor(); - type_text(&mut editor, "iab"); - editor - .show_completion(vec![completion_item((0, 0), (0, 2), "chosen")]) - .unwrap(); - editor.execute(&format!("edit {}", other.display())); - assert!(editor.completion().is_none()); - editor.execute("buffer 1"); - assert_eq!(editor.context().text, "chosen"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - } - #[test] - fn completion_dot_additional_edits_require_compatible_context_and_replay_atomically() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("use\nab".into())]) - .unwrap(); - type_text(&mut editor, "jA"); - let mut item = completion_item((1, 0), (1, 2), "done"); - item.edits.push(CompletionEdit { - start: TextPosition { row: 0, column: 0 }, - end: TextPosition { row: 0, column: 3 }, - text: "import".into(), - }); - editor.show_completion(vec![item]).unwrap(); - editor.handle(Input::Escape); - editor - .apply(vec![Effect::Replace("use\nzz".into())]) - .unwrap(); - type_text(&mut editor, "j."); - assert_eq!(editor.context().text, "import\ndone"); - editor - .apply(vec![Effect::Replace("bad\nzz".into())]) - .unwrap(); - type_text(&mut editor, "j"); - let before = editor.context().text; - let carets = editor.carets(); - let revision = editor.text_revision(); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, before); - assert_eq!(editor.carets(), carets); - assert_eq!(editor.text_revision(), revision); - assert!(editor.message().contains("context changed")); - } - - fn snippet_item( - start: (usize, usize), - end: (usize, usize), - text: &str, - stops: Vec<(u32, Vec>)>, - ) -> CompletionItem { - let mut item = completion_item(start, end, text); - item.snippet = Some(crate::plugin::CompletionSnippet { - tabstops: stops - .into_iter() - .map(|(index, ranges)| SnippetTabstop { - index, - ancestors: vec![Vec::new(); ranges.len()], - ranges, - }) - .collect(), - }); - item - } - - fn nested_stop( - mut item: CompletionItem, - index: u32, - ancestors: Vec>, - ) -> CompletionItem { - item.snippet - .as_mut() - .unwrap() - .tabstops - .iter_mut() - .find(|stop| stop.index == index) - .unwrap() - .ancestors = ancestors; - item - } - - #[test] - fn snippet_arguments_navigation_undo_redo_and_dot_use_resolved_text() { - let directory = Directory::new(); - let mut editor = editor(); - editor.set_tab_spaces(NonZeroU8::new(2), BTreeMap::new()); - type_text(&mut editor, "ica"); - editor - .show_completion(vec![snippet_item( - (0, 0), - (0, 2), - "call(arg, count)", - vec![(1, vec![5..8]), (2, vec![10..15]), (0, vec![16..16])], - )]) - .unwrap(); - assert_eq!(editor.context().text, "call(arg, count)"); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); - assert_eq!( - editor.snippet_selections(), - [Selection { - start: Cursor { row: 0, column: 5 }, - end: Cursor { row: 0, column: 8 }, - }] - ); - type_text(&mut editor, "item"); - assert!(editor.completion().is_none()); - assert_eq!(editor.context().text, "call(item, count)"); - editor.handle(Input::Char('\t')); - type_text(&mut editor, "3"); - editor.handle(Input::BackTab); - type_text(&mut editor, "foo"); - editor.handle(Input::Char('\t')); - type_text(&mut editor, "5"); - editor.handle(Input::Char('\t')); - assert!(editor.snippet_selections().is_empty()); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 12 }); - type_text(&mut editor, ";\n\ttail"); - editor.handle(Input::Escape); - let resolved = "call(foo, 5);\n tail"; - let caret = editor.cursor(); - assert_eq!(editor.context().text, resolved); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, resolved); - assert_eq!(editor.cursor(), caret); - editor.execute(&format!("edit {}", directory.path("repeat").display())); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, resolved); - assert_eq!(editor.cursor(), caret); - assert!(editor.completion().is_none()); - assert!(editor.snippet_selections().is_empty()); - } - - #[test] - fn snippet_empty_stops_unicode_multiline_and_mirrors_track_argument_edits() { - let mut editor = editor(); - editor.handle(Input::Char('i')); - editor - .show_completion(vec![snippet_item( - (0, 0), - (0, 0), - "f(, 😀,\ne\u{301}, 😀)", - vec![ - (1, vec![2..2]), - (2, vec![4..8, 15..19]), - (3, vec![10..13]), - (0, vec![20..20]), - ], - )]) - .unwrap(); - assert_eq!( - editor.snippet_selections()[0].start, - editor.snippet_selections()[0].end - ); - type_text(&mut editor, "中"); - editor.handle(Input::Char('\t')); - type_text(&mut editor, "🦀x"); - editor.handle(Input::Backspace); - assert_eq!(editor.context().text, "f(中, 🦀,\ne\u{301}, 🦀)"); - assert_eq!(editor.snippet_selections().len(), 2); - editor.handle(Input::Char('\t')); - type_text(&mut editor, "a\u{301}"); - assert_eq!(editor.context().text, "f(中, 🦀,\na\u{301}, 🦀)"); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); - editor.handle(Input::BackTab); - editor.handle(Input::Delete); - assert_eq!(editor.context().text, "f(中, ,\na\u{301}, )"); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), Mode::Normal); - assert!(editor.snippet_selections().is_empty()); - } - - #[test] - fn modified_backspace_snippets_stay_inside_arguments_and_update_mirrors() { - let mut editor = editor(); - editor - .bind( - "insert", - "alt+backspace", - Some("core.backspace_word".into()), - ) - .unwrap(); - editor - .bind( - "insert", - "cmd+backspace", - Some("core.backspace_line".into()), - ) - .unwrap(); - editor.handle(Input::Char('i')); - editor - .show_completion(vec![snippet_item( - (0, 0), - (0, 0), - "f(one, one)", - vec![(1, vec![2..5, 7..10]), (0, vec![11..11])], - )]) - .unwrap(); - type_text(&mut editor, "alpha βeta"); - editor.handle(Input::AltBackspace); - assert_eq!(editor.context().text, "f(alpha , alpha )"); - editor.handle(Input::SuperBackspace); - assert_eq!(editor.context().text, "f(, )"); - editor.handle(Input::SuperBackspace); - editor.handle(Input::AltBackspace); - assert_eq!(editor.context().text, "f(, )"); - type_text(&mut editor, "keep"); - assert_eq!(editor.context().text, "f(keep, keep)"); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - } - - #[test] - fn snippet_nested_mirrors_update_parents_and_replacing_parent_retires_children() { - let mut editor = editor(); - editor.handle(Input::Char('i')); - editor - .show_completion(vec![nested_stop( - snippet_item( - (0, 0), - (0, 0), - "f(outer inner, outer inner, last)", - vec![ - (1, vec![2..13, 15..26]), - (2, vec![8..13, 21..26]), - (3, vec![28..32]), - (0, vec![33..33]), - ], - ), - 2, - vec![vec![1]; 2], - )]) - .unwrap(); - editor.handle(Input::Char('\t')); - type_text(&mut editor, "word"); - assert_eq!(editor.context().text, "f(outer word, outer word, last)"); - editor.handle(Input::BackTab); - type_text(&mut editor, "body"); - editor.handle(Input::Char('\t')); - type_text(&mut editor, "end"); - editor.handle(Input::Char('\t')); - assert_eq!(editor.context().text, "f(body, body, end)"); - assert!(editor.snippet_selections().is_empty()); - - let mut editor = self::editor(); - editor.handle(Input::Char('i')); - editor - .show_completion(vec![nested_stop( - snippet_item( - (0, 0), - (0, 0), - "f(foo, last)", - vec![ - (1, vec![2..5]), - (2, vec![2..5]), - (3, vec![7..11]), - (0, vec![12..12]), - ], - ), - 2, - vec![vec![1]], - )]) - .unwrap(); - type_text(&mut editor, "value"); - editor.handle(Input::Char('\t')); - type_text(&mut editor, "end"); - assert_eq!(editor.context().text, "f(value, end)"); - } - - #[test] - fn snippet_cycling_restores_carets_and_argument_edits_never_broadcast_to_secondaries() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("ab\nzz".into())]) - .unwrap(); - editor.handle(Input::Ctrl('e')); - let undo_carets = editor.carets(); - editor.handle(Input::Char('A')); - let original = editor.carets(); - let mut item = snippet_item( - (1, 0), - (1, 2), - "f(arg)", - vec![(1, vec![2..5]), (0, vec![6..6])], - ); - item.edits.push(CompletionEdit { - start: TextPosition { row: 0, column: 0 }, - end: TextPosition { row: 0, column: 1 }, - text: "prefix".into(), - }); - editor.show_completion(vec![item]).unwrap(); - assert_eq!( - editor.carets(), - [Cursor { row: 0, column: 7 }, Cursor { row: 1, column: 2 },] - ); - editor.handle(Input::Ctrl('n')); - assert_eq!(editor.context().text, "ab\nzz"); - assert_eq!(editor.carets(), original); - assert!(editor.snippet_selections().is_empty()); - editor.handle(Input::Ctrl('k')); - type_text(&mut editor, "long"); - assert_eq!(editor.context().text, "prefixb\nf(long)"); - assert_eq!(editor.carets()[0], Cursor { row: 0, column: 7 }); - editor.handle(Input::Escape); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "ab\nzz"); - assert_eq!(editor.carets(), undo_carets); - } - - #[test] - fn snippet_keys_respect_disabled_remapped_bindings_and_tab_remains_literal_outside() { - let mut editor = editor(); - type_text(&mut editor, "i\t"); - assert_eq!(editor.context().text, "\t"); - editor.bind("insert", "Tab", None).unwrap(); - editor - .show_completion(vec![snippet_item( - (0, 0), - (0, 1), - "f(one, two)", - vec![(1, vec![2..5]), (2, vec![7..10]), (0, vec![11..11])], - )]) - .unwrap(); - editor.handle(Input::Char('\t')); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); - editor - .bind("insert", "Ctrl+f", Some("core.snippet.next".into())) - .unwrap(); - editor - .bind("insert", "Shift+Tab", Some("core.snippet.previous".into())) - .unwrap(); - editor.handle(Input::Ctrl('f')); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 7 }); - editor.handle(Input::BackTab); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); - editor - .bind("insert", "Tab", Some("core.right".into())) - .unwrap(); - editor.handle(Input::Char('\t')); - assert!(editor.snippet_selections().is_empty()); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); - type_text(&mut editor, "x"); - assert_eq!(editor.context().text, "f(oxne, two)"); - } - - #[test] - fn snippet_validation_is_atomic_and_newline_offsets_are_normalized() { - let mut editor = editor(); - type_text(&mut editor, "iab"); - for stops in [ - vec![(1, vec![2..3]), (0, vec![8..8])], - vec![(2, vec![1..5]), (1, vec![5..7]), (0, vec![8..8])], - vec![(1, vec![1..6]), (2, vec![5..7]), (0, vec![8..8])], - vec![(1, vec![1..5, 5..7]), (0, vec![8..8])], - vec![(1, vec![1..5])], - ] { - let before = editor.context().text; - let carets = editor.carets(); - let item = snippet_item((0, 0), (0, 2), "(😀ab)", stops); - assert!(editor.show_completion(vec![item]).is_err()); - assert_eq!(editor.context().text, before); - assert_eq!(editor.carets(), carets); - assert!(editor.completion().is_none()); - } - let before = editor.context().text; - let carets = editor.carets(); - assert!( - editor - .show_completion(vec![snippet_item( - (0, 0), - (0, 2), - "f(\r\narg)", - vec![(1, vec![3..6]), (0, vec![7..7])], - )]) - .is_err() - ); - assert_eq!(editor.context().text, before); - assert_eq!(editor.carets(), carets); - editor - .show_completion(vec![snippet_item( - (0, 0), - (0, 2), - "f(\narg)", - vec![(1, vec![3..6]), (0, vec![7..7])], - )]) - .unwrap(); - assert_eq!(editor.context().text, "f(\narg)"); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); - type_text(&mut editor, "界"); - assert_eq!(editor.context().text, "f(\n界)"); - } - - #[test] - fn snippet_motion_overlay_command_plugin_and_paste_boundaries_end_navigation() { - for boundary in [ - "motion", - "overlay", - "command", - "plugin", - "paste", - "invalid_paste", - ] { - let mut editor = editor(); - editor.handle(Input::Char('i')); - editor - .show_completion(vec![snippet_item( - (0, 0), - (0, 0), - "f(arg)", - vec![(1, vec![2..5]), (0, vec![6..6])], - )]) - .unwrap(); - match boundary { - "motion" => { - editor.handle(Input::Right); - } - "overlay" => { - editor.handle(Input::Ctrl('p')); - } - "command" => { - editor.execute("plugins"); - } - "plugin" => { - editor - .apply(vec![Effect::Replace("replacement".into())]) - .unwrap(); - } - "paste" => { - editor.handle(Input::Paste("literal".into())); - } - "invalid_paste" => { - editor.handle(Input::Paste("\0".into())); - } - _ => unreachable!(), - } - assert!(editor.snippet_selections().is_empty(), "{boundary}"); - assert!(editor.completion().is_none(), "{boundary}"); - } - } - - #[test] - fn snippet_final_caret_in_middle_replays_before_later_ordinary_typing() { - let directory = Directory::new(); - let mut editor = editor(); - editor.handle(Input::Char('i')); - editor - .show_completion(vec![snippet_item( - (0, 0), - (0, 0), - "f(arg) tail", - vec![(1, vec![2..5]), (0, vec![6..6])], - )]) - .unwrap(); - type_text(&mut editor, "X"); - editor.handle(Input::Char('\t')); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); - type_text(&mut editor, "!"); - editor.handle(Input::Escape); - let caret = editor.cursor(); - assert_eq!(editor.context().text, "f(X)! tail"); - editor.execute(&format!("edit {}", directory.path("repeat").display())); - editor.handle(Input::Char('.')); - assert_eq!(editor.context().text, "f(X)! tail"); - assert_eq!(editor.cursor(), caret); - } - - #[test] - fn snippet_adjacent_empty_stops_remain_independent_when_typed() { - let mut editor = editor(); - editor.handle(Input::Char('i')); - editor - .show_completion(vec![snippet_item( - (0, 0), - (0, 0), - "f()", - vec![(1, vec![2..2]), (2, vec![2..2]), (0, vec![3..3])], - )]) - .unwrap(); - type_text(&mut editor, "one"); - editor.handle(Input::Char('\t')); - type_text(&mut editor, "two"); - editor.handle(Input::BackTab); - type_text(&mut editor, "first"); - editor.handle(Input::Char('\t')); - type_text(&mut editor, "second"); - assert_eq!(editor.context().text, "f(firstsecond)"); - } - - #[test] - fn snippet_ancestry_distinguishes_nested_empty_boundary_from_adjacent_root() { - for nested in [false, true] { - let mut editor = editor(); - editor.handle(Input::Char('i')); - let item = snippet_item( - (0, 0), - (0, 0), - "f(foo)", - vec![(1, vec![2..5]), (2, vec![5..5]), (0, vec![6..6])], - ); - let item = if nested { - nested_stop(item, 2, vec![vec![1]]) - } else { - item - }; - editor.show_completion(vec![item]).unwrap(); - type_text(&mut editor, "X"); - editor.handle(Input::Char('\t')); - if nested { - assert!(editor.snippet_selections().is_empty()); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); - } else { - assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); - assert_eq!(editor.snippet_selections().len(), 1); - type_text(&mut editor, "Y"); - editor.handle(Input::BackTab); - type_text(&mut editor, "Z"); - assert_eq!(editor.context().text, "f(ZY)"); - } - } - let mut editor = editor(); - editor.handle(Input::Char('i')); - editor - .show_completion(vec![nested_stop( - snippet_item( - (0, 0), - (0, 0), - "f(foo)", - vec![(1, vec![2..5]), (2, vec![5..5]), (0, vec![6..6])], - ), - 2, - vec![vec![1]], - )]) - .unwrap(); - editor.handle(Input::Char('\t')); - type_text(&mut editor, "X"); - editor.handle(Input::BackTab); - type_text(&mut editor, "Y"); - editor.handle(Input::Char('\t')); - assert_eq!(editor.context().text, "f(Y)"); - assert!(editor.snippet_selections().is_empty()); - } - - #[test] - fn snippet_replacing_parent_preserves_unrelated_child_mirror_occurrences() { - let mut editor = editor(); - editor.handle(Input::Char('i')); - editor - .show_completion(vec![nested_stop( - snippet_item( - (0, 0), - (0, 0), - "f(foo, foo)", - vec![(1, vec![2..5]), (2, vec![2..5, 7..10]), (0, vec![11..11])], - ), - 2, - vec![vec![1], vec![]], - )]) - .unwrap(); - type_text(&mut editor, "X"); - editor.handle(Input::Char('\t')); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); - type_text(&mut editor, "Y"); - assert_eq!(editor.context().text, "f(X, Y)"); - } - - #[test] - fn snippet_coincident_empty_mirror_multiplicity_survives_typing_and_deletion() { - let mut editor = editor(); - editor.handle(Input::Char('i')); - editor - .show_completion(vec![snippet_item( - (0, 0), - (0, 0), - "f()", - vec![(1, vec![2..2, 2..2]), (0, vec![3..3])], - )]) - .unwrap(); - type_text(&mut editor, "xy"); - assert_eq!(editor.context().text, "f(xyxy)"); - editor.handle(Input::Backspace); - assert_eq!(editor.context().text, "f(xx)"); - editor.handle(Input::Backspace); - assert_eq!(editor.context().text, "f()"); - type_text(&mut editor, "z"); - assert_eq!(editor.context().text, "f(zz)"); - editor.handle(Input::Char('\t')); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); - assert!(editor.snippet_selections().is_empty()); - editor.handle(Input::Char('\t')); - assert_eq!(editor.context().text, "f(zz)\t"); - } - - #[test] - fn snippet_ancestry_validation_rejects_misalignment_unknown_ids_cycles_and_false_containment() { - let mut editor = editor(); - editor.handle(Input::Char('i')); - let item = || { - snippet_item( - (0, 0), - (0, 0), - "f(foo, bar)", - vec![(1, vec![2..5]), (2, vec![7..10]), (0, vec![11..11])], - ) - }; - let mut misaligned = item(); - misaligned.snippet.as_mut().unwrap().tabstops[0] - .ancestors - .clear(); - let unknown = nested_stop(item(), 2, vec![vec![999]]); - let containment = nested_stop(item(), 2, vec![vec![1]]); - let cycle = nested_stop(nested_stop(item(), 1, vec![vec![2]]), 2, vec![vec![1]]); - for candidate in [misaligned, unknown, containment, cycle] { - assert!(editor.show_completion(vec![candidate]).is_err()); - assert_eq!(editor.context().text, ""); - assert_eq!(editor.cursor(), Cursor::default()); - assert!(editor.completion().is_none()); - } - } -} +#[path = "../tests/unit/editor.rs"] +mod tests; diff --git a/src/files.rs b/src/files.rs index 9b665cf..ded1c0f 100644 --- a/src/files.rs +++ b/src/files.rs @@ -1068,991 +1068,5 @@ impl FilePicker { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn virtual_catalog_compacts_and_retains_file_directory_collisions() { - let mut picker = FilePicker::from_paths( - PathBuf::new(), - ["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"] - .map(PathBuf::from) - .into_iter() - .enumerate(), - ); - assert_eq!( - picker - .entries() - .map(|row| row.label.to_owned()) - .collect::>(), - ["src/deep", "x", "x"] - ); - picker.reveal_path(Path::new("x")); - assert!(!picker.entries().nth(picker.selected).unwrap().is_dir); - assert_eq!(picker.accept(), Some(PathBuf::from("x"))); - picker.reveal_path(Path::new("x/y")); - assert_eq!(picker.accept(), Some(PathBuf::from("x/y"))); - picker.reveal_path(Path::new("src/deep/deleted.rs")); - assert_eq!(picker.accept(), Some(PathBuf::from("src/deep/deleted.rs"))); - assert_eq!(picker.entries().filter(|row| !row.is_dir).count(), 3); - let expected_index = |row: &FileRow<'_>| { - if row.is_dir { - None - } else { - Some(match row.path.to_str().unwrap() { - "x" => 0, - "x/y" => 1, - "src/deep/deleted.rs" => 2, - path => panic!("unexpected path {path}"), - }) - } - }; - for view in [FileView::List, FileView::Tree] { - picker.set_view(view); - for query in ["", "x", "deleted"] { - picker.query = query.into(); - picker.refilter(); - let rows: Vec<_> = picker.entries().collect(); - assert!(!rows.is_empty()); - for row in rows { - assert_eq!(row.catalog_index, expected_index(&row)); - assert!(row.status.is_none() && row.old_path.is_none()); - } - } - } - } - use std::sync::atomic::{AtomicU64, Ordering}; - - static TEMP_ID: AtomicU64 = AtomicU64::new(0); - struct Directory(PathBuf); - impl Directory { - fn new() -> Self { - let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); - let path = - std::env::temp_dir().join(format!("vamp-files-test-{}-{id}", std::process::id())); - fs::create_dir(&path).unwrap(); - Self(path) - } - fn put(&self, name: &str, text: &str) { - let path = self.0.join(name); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, text).unwrap(); - } - fn scan(&self) -> FilePicker { - FilePicker::scan(self.0.clone(), FilePresentation::Floating, FileView::List).unwrap() - } - } - impl Drop for Directory { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - fn paths(picker: &FilePicker) -> Vec { - picker - .entries() - .map(|entry| entry.path.to_owned()) - .collect() - } - - #[test] - fn fuzzy_path_tokens_preserve_tree_context_selection_and_open_paths() { - let directory = Directory::new(); - let names = [ - "src/fuzzy_parser.rs", - "src/über_parser.rs", - "src/other.rs", - "tests/fuzzy_parser.rs", - ]; - for name in names { - directory.put(name, ""); - } - for mut picker in [ - directory.scan(), - FilePicker::from_paths( - directory.0.clone(), - names.map(PathBuf::from).into_iter().enumerate(), - ), - ] { - for view in [FileView::Tree, FileView::List] { - picker.set_view(view); - for (query, expected) in [ - ("sr/fp RS", "src/fuzzy_parser.rs"), - ("ÜPR SRC", "src/über_parser.rs"), - ] { - picker.query = query.into(); - picker.refilter(); - assert_eq!( - picker - .entries() - .filter(|row| !row.is_dir) - .map(|row| row.path) - .collect::>(), - [Path::new(expected)] - ); - assert_eq!(selected_path(&picker), Path::new(expected)); - assert_eq!(picker.accept(), Some(directory.0.join(expected))); - if view == FileView::Tree { - assert_eq!(paths(&picker), [Path::new("src"), Path::new(expected)]); - } - } - picker.query = "rs/ü".into(); - picker.refilter(); - assert!(picker.entries().next().is_none()); - picker.clear_filter(); - assert_eq!(selected_path(&picker), Path::new("src/über_parser.rs")); - } - } - } - - #[test] - fn scanned_files_and_directories_have_no_catalog_index() { - let directory = Directory::new(); - directory.put("nested/file.txt", ""); - let mut picker = directory.scan(); - for view in [FileView::List, FileView::Tree] { - picker.set_view(view); - picker.reveal_path(Path::new("nested/file.txt")); - assert!(picker.entries().all(|row| row.catalog_index.is_none())); - assert!( - picker - .entries() - .all(|row| row.status.is_none() && row.old_path.is_none()) - ); - } - } - - fn repository_change( - path: &str, - status: FileStatus, - old_path: Option<&str>, - ) -> WorkingFileChange { - WorkingFileChange { - path: path.into(), - status, - old_path: old_path.map(PathBuf::from), - } - } - - #[test] - fn repository_metadata_keeps_clean_files_selection_and_filtered_views() { - let directory = Directory::new(); - for name in ["src/clean.rs", "src/modified.rs", "added.rs", "renamed.rs"] { - directory.put(name, ""); - } - let mut picker = directory.scan(); - picker.presentation = FilePresentation::Docked; - picker.set_view(FileView::Tree); - picker.reveal_path(Path::new("src/clean.rs")); - picker.query = "src".into(); - picker.refilter(); - picker.focused = false; - let changes = vec![ - repository_change("src/modified.rs", FileStatus::Modified, None), - repository_change("added.rs", FileStatus::Added, None), - repository_change("renamed.rs", FileStatus::Renamed, Some("old.rs")), - repository_change("vanished/deep/deleted.rs", FileStatus::Removed, None), - ]; - assert!(picker.set_repository_changes(changes.clone())); - assert!(!picker.set_repository_changes(changes)); - assert_eq!(selected_path(&picker), Path::new("src/clean.rs")); - assert_eq!(picker.query, "src"); - assert!(!picker.focused); - assert_eq!(picker.presentation, FilePresentation::Docked); - for view in [FileView::List, FileView::Tree] { - picker.set_view(view); - for query in ["", "src", "modified.rs", "deleted.rs"] { - picker.query = query.into(); - picker.refilter(); - let rows: Vec<_> = picker.entries().collect(); - assert!(!rows.is_empty()); - for row in rows { - assert!(row.catalog_index.is_none()); - if row.is_dir || row.path == Path::new("src/clean.rs") { - assert!(row.status.is_none() && row.old_path.is_none()); - } else { - let status = match row.path.to_str().unwrap() { - "src/modified.rs" => FileStatus::Modified, - "added.rs" => FileStatus::Added, - "renamed.rs" => FileStatus::Renamed, - "vanished/deep/deleted.rs" => FileStatus::Removed, - other => panic!("unexpected {other}"), - }; - assert_eq!(row.status, Some(status)); - assert_eq!( - row.old_path, - (status == FileStatus::Renamed).then(|| Path::new("old.rs")) - ); - } - } - if query.is_empty() || query == "src" { - assert!( - picker - .entries() - .any(|row| row.path == Path::new("src/clean.rs")) - ); - } - } - } - picker.set_view(FileView::List); - picker.clear_filter(); - picker.reveal_path(Path::new("src/clean.rs")); - assert!(picker.set_repository_changes(Vec::new())); - assert_eq!(selected_path(&picker), Path::new("src/clean.rs")); - assert_eq!(picker.entries().len(), 4); - assert!(picker.nodes.iter().all(|node| !node.virtual_entry)); - assert!( - picker - .entries() - .all(|row| row.status.is_none() && row.old_path.is_none()) - ); - assert!(!picker.set_repository_changes(Vec::new())); - } - - #[test] - fn deleted_catalog_leaves_survive_refresh_and_restored_files_survive_clear() { - let directory = Directory::new(); - directory.put("keep.txt", ""); - let mut picker = directory.scan(); - let deleted = Path::new("lost/deep/deleted.txt"); - picker.set_repository_changes(vec![repository_change( - "lost/deep/deleted.txt", - FileStatus::Removed, - None, - )]); - picker.set_view(FileView::Tree); - assert_eq!( - picker.entries().find(|row| row.is_dir).unwrap().label, - "lost/deep" - ); - picker.reveal_path(deleted); - picker.query = "deleted".into(); - picker.refilter(); - picker.start_search(); - picker.focused = false; - picker.refresh(); - assert_eq!(selected_path(&picker), deleted); - assert!(picker.searching && !picker.focused); - assert_eq!(picker.query, "deleted"); - assert_eq!(picker.accept(), Some(directory.0.join(deleted))); - assert!(!directory.0.join(deleted).exists()); - assert_eq!( - picker.entries().find(|row| !row.is_dir).unwrap().status, - Some(FileStatus::Removed) - ); - directory.put("lost/deep/deleted.txt", "restored"); - assert!(picker.set_repository_changes(vec![repository_change( - "lost/deep/deleted.txt", - FileStatus::Removed, - None, - )])); - picker.refresh(); - assert_eq!(selected_path(&picker), deleted); - assert!(picker.nodes.iter().all(|node| !node.virtual_entry)); - assert!(picker.set_repository_changes(Vec::new())); - assert_eq!(selected_path(&picker), deleted); - assert_eq!(picker.accept(), Some(directory.0.join(deleted))); - assert_eq!( - fs::read_to_string(directory.0.join(deleted)).unwrap(), - "restored" - ); - assert!(picker.entries().all(|row| row.status.is_none())); - fs::remove_dir_all(directory.0.join("lost")).unwrap(); - picker.refresh(); - picker.set_repository_changes(vec![repository_change( - "lost/deep/deleted.txt", - FileStatus::Removed, - None, - )]); - picker.reveal_path(deleted); - picker.set_repository_changes(Vec::new()); - assert!(picker.entries().len() == 0); - picker.clear_filter(); - assert_eq!(paths(&picker), [PathBuf::from("keep.txt")]); - assert!(picker.nodes.iter().all(|node| !node.virtual_entry)); - } - - #[test] - fn repository_metadata_cannot_introduce_unsafe_or_existing_excluded_paths() { - let directory = Directory::new(); - directory.put(".gitignore", "ignored/\n"); - directory.put("ignored/hidden.txt", ""); - directory.put("directory/keep.txt", ""); - let mut picker = directory.scan(); - let mut changes: Vec<_> = [ - "", - ".", - "../outside.txt", - "directory/../../outside.txt", - ".git/secret", - "nested/.jj/secret", - "/outside.txt", - "directory", - "ignored/hidden.txt", - ] - .into_iter() - .map(|path| repository_change(path, FileStatus::Removed, None)) - .collect(); - #[cfg(unix)] - { - std::os::unix::fs::symlink(&directory.0, directory.0.join("link")).unwrap(); - changes.push(repository_change( - "link/missing.txt", - FileStatus::Removed, - None, - )); - } - changes.push(repository_change("missing.txt", FileStatus::Removed, None)); - picker.set_repository_changes(changes); - assert_eq!( - paths(&picker), - [".gitignore", "directory/keep.txt", "missing.txt"].map(PathBuf::from) - ); - assert_eq!( - picker.entries().filter(|row| row.status.is_some()).count(), - 1 - ); - assert_eq!( - picker.entries().last().unwrap().status, - Some(FileStatus::Removed) - ); - assert!(!directory.0.join("missing.txt").exists()); - } - - #[test] - fn nested_ignore_negation_hidden_files_and_vcs_metadata() { - let directory = Directory::new(); - directory.put(".gitignore", "*.log\nignored/\n"); - directory.put("nested/.gitignore", "!keep.log\nlocal.txt\n"); - for name in [ - "gone.log", - "nested/gone.log", - "nested/keep.log", - "nested/local.txt", - "nested/ok.txt", - "ignored/hidden.txt", - ".hidden", - ".git/objects/secret", - "nested/.git/secret", - ".jj/secret", - "nested/.jj/secret", - ] { - directory.put(name, "text"); - } - let picker = directory.scan(); - assert_eq!( - paths(&picker), - [ - ".gitignore", - ".hidden", - "nested/.gitignore", - "nested/keep.log", - "nested/ok.txt" - ] - .map(PathBuf::from) - ); - assert!(picker.error.is_none()); - } - - #[test] - fn literal_unicode_tokens_grapheme_deletion_and_navigation() { - let directory = Directory::new(); - for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] { - directory.put(name, "text"); - } - let mut picker = directory.scan(); - picker.navigate(false); - assert_eq!(picker.selected, 2); - picker.navigate(true); - assert_eq!(picker.selected, 0); - picker.query = "界 SPACE [E\u{301}]".into(); - picker.refilter(); - assert_eq!(paths(&picker), [PathBuf::from("a space 界[e\u{301}].txt")]); - picker.query = "e\u{301}👩‍💻".into(); - assert!(picker.handle_filter_input(&crate::editor::Input::Backspace)); - assert_eq!(picker.query, "e\u{301}"); - assert!(picker.handle_filter_input(&crate::editor::Input::Backspace)); - assert_eq!(picker.query, ""); - assert!(picker.handle_filter_input(&crate::editor::Input::Backspace)); - assert_eq!(picker.entries().len(), 3); - picker.query = "no match".into(); - picker.refilter(); - for forward in [true, false] { - picker.navigate(forward); - assert_eq!(picker.selected, 0); - assert_eq!(picker.entries().len(), 0); - } - } - - #[test] - fn word_and_line_deletion_refilter_only_editable_queries() { - use crate::editor::Input; - - for view in [FileView::List, FileView::Tree] { - let mut picker = FilePicker::from_paths( - PathBuf::new(), - ["alpha one.txt", "alpha two.txt", "beta.txt"] - .map(PathBuf::from) - .into_iter() - .enumerate(), - ); - picker.set_view(view); - picker.query = "alpha missing".into(); - picker.refilter(); - assert_eq!(picker.entries().len(), 0); - if view == FileView::Tree { - for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] { - assert!(!picker.handle_filter_input(&input)); - assert_eq!(picker.query, "alpha missing"); - } - picker.start_search(); - } - assert!(picker.handle_filter_input(&Input::AltBackspace)); - assert_eq!(picker.query, "alpha "); - assert_eq!(picker.entries().len(), 2); - assert!(picker.handle_filter_input(&Input::SuperBackspace)); - assert_eq!(picker.query, ""); - assert_eq!(picker.entries().len(), 3); - for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] { - assert!(picker.handle_filter_input(&input)); - assert_eq!(picker.entries().len(), 3); - } - } - } - - #[test] - fn empty_scan_refresh_and_failure_preserve_reachable_state() { - let directory = Directory::new(); - let mut picker = directory.scan(); - picker.navigate(false); - assert!(picker.handle_filter_input(&crate::editor::Input::Backspace)); - assert_eq!(picker.entries().len(), 0); - directory.put("new.txt", "text"); - picker.query = "new".into(); - picker.refresh(); - assert_eq!(picker.query, "new"); - assert_eq!(paths(&picker), [PathBuf::from("new.txt")]); - fs::remove_dir_all(&directory.0).unwrap(); - picker.refresh(); - assert_eq!(paths(&picker), [PathBuf::from("new.txt")]); - assert!(picker.error.as_ref().unwrap().contains("Refresh failed")); - assert!( - FilePicker::scan( - directory.0.clone(), - FilePresentation::Docked, - FileView::Tree - ) - .is_err() - ); - } - - #[test] - fn malformed_ignore_warning_survives_query_and_navigation() { - let directory = Directory::new(); - directory.put(".gitignore", "[z-a]\n"); - directory.put("ok.txt", "text"); - let mut picker = directory.scan(); - assert!(picker.error.as_ref().unwrap().contains("Incomplete scan")); - picker.query = "ok".into(); - picker.refilter(); - assert_eq!(paths(&picker), [PathBuf::from("ok.txt")]); - picker.navigate(true); - assert!(picker.error.as_ref().unwrap().contains("Incomplete scan")); - directory.put(".gitignore", ""); - picker.refresh(); - assert!(picker.error.is_none()); - } - - #[test] - fn only_project_ignore_rules_apply_and_non_directory_roots_fail() { - let directory = Directory::new(); - directory.put(".gitignore", "*.txt\n"); - directory.put("project/visible.txt", "text"); - let root = directory.0.join("project"); - let picker = FilePicker::scan(root, FilePresentation::Floating, FileView::List).unwrap(); - assert_eq!(paths(&picker), [PathBuf::from("visible.txt")]); - assert!( - FilePicker::scan( - directory.0.join("project/visible.txt"), - FilePresentation::Docked, - FileView::Tree, - ) - .is_err() - ); - } - - #[cfg(unix)] - #[test] - fn symlink_loops_and_file_links_are_not_followed_and_raw_paths_survive() { - use std::os::unix::fs::symlink; - let directory = Directory::new(); - directory.put("real.txt", "text"); - symlink(&directory.0, directory.0.join("loop")).unwrap(); - symlink(directory.0.join("real.txt"), directory.0.join("alias.txt")).unwrap(); - let raw = PathBuf::from("raw界\n.txt"); - fs::write(directory.0.join(&raw), "text").unwrap(); - let picker = directory.scan(); - assert_eq!(picker.entries().len(), 2); - let entry = picker - .entries() - .find(|entry| entry.path == raw.as_path()) - .unwrap(); - assert!(!entry.label.chars().any(char::is_control)); - assert_eq!( - fs::read_to_string(directory.0.join(entry.path)).unwrap(), - "text" - ); - } - - fn tree(directory: &Directory) -> FilePicker { - FilePicker::scan( - directory.0.clone(), - FilePresentation::Docked, - FileView::Tree, - ) - .unwrap() - } - - fn select(picker: &mut FilePicker, path: &str) { - let selected = picker - .entries() - .position(|row| row.path == Path::new(path)) - .unwrap(); - picker.selected = selected; - } - - fn selected_path(picker: &FilePicker) -> &Path { - picker.entries().nth(picker.selected).unwrap().path - } - - #[test] - fn tree_orders_folders_first_keeps_empty_dirs_and_compacts_only_directory_chains() { - let directory = Directory::new(); - directory.put("a.txt", ""); - directory.put("folder/only.txt", ""); - directory.put("chain/one/two/a.txt", ""); - directory.put("chain/one/two/z.txt", ""); - fs::create_dir(directory.0.join("empty")).unwrap(); - let mut picker = tree(&directory); - assert_eq!(picker.view, FileView::Tree); - assert!(!picker.searching); - assert_eq!( - picker - .entries() - .map(|row| (row.label, row.is_dir, row.depth)) - .collect::>(), - [ - ("chain/one/two", true, 0), - ("empty", true, 0), - ("folder", true, 0), - ("a.txt", false, 0) - ] - ); - assert_eq!(selected_path(&picker), Path::new("chain/one/two")); - picker.right(); - assert_eq!( - picker - .entries() - .map(|row| (row.label, row.depth)) - .collect::>(), - [ - ("chain/one/two", 0), - ("a.txt", 1), - ("z.txt", 1), - ("empty", 0), - ("folder", 0), - ("a.txt", 0) - ] - ); - picker.right(); - assert_eq!(selected_path(&picker), Path::new("chain/one/two/a.txt")); - let rows = paths(&picker); - picker.right(); - assert_eq!(paths(&picker), rows); - picker.left(); - assert_eq!(selected_path(&picker), Path::new("chain/one/two")); - picker.left(); - assert!(!picker.entries().next().unwrap().expanded); - picker.left(); - assert_eq!(picker.selected, 0); - select(&mut picker, "folder"); - assert!(picker.accept().is_none()); - assert_eq!( - picker.entries().nth(picker.selected + 1).unwrap().label, - "only.txt" - ); - picker.right(); - assert_eq!(picker.accept(), Some(directory.0.join("folder/only.txt"))); - select(&mut picker, "empty"); - picker.right(); - assert!(picker.entries().nth(picker.selected).unwrap().expanded); - picker.right(); - assert_eq!(selected_path(&picker), Path::new("empty")); - } - - #[test] - fn tree_search_reveals_ancestors_without_mutating_saved_expansion_or_compaction() { - let directory = Directory::new(); - directory.put("src/module/deep/needle.txt", ""); - directory.put("src/module/sibling.txt", ""); - directory.put("src/root.txt", ""); - directory.put("other/keep.txt", ""); - let mut picker = tree(&directory); - select(&mut picker, "other"); - picker.right(); - select(&mut picker, "src"); - let expanded = picker.expanded.clone(); - picker.start_search(); - picker.query = "NEEDLE".into(); - picker.refilter(); - assert_eq!( - picker - .entries() - .map(|row| (row.label, row.depth)) - .collect::>(), - [("src", 0), ("module", 1), ("deep", 2), ("needle.txt", 3)] - ); - assert_eq!( - selected_path(&picker), - Path::new("src/module/deep/needle.txt") - ); - assert_eq!(picker.expanded, expanded); - picker.left(); - let filtered = paths(&picker); - picker.finish_search(); - assert_eq!(picker.query, "NEEDLE"); - assert_eq!(paths(&picker), filtered); - assert_eq!(selected_path(&picker), Path::new("src/module/deep")); - assert_eq!(picker.expanded, expanded); - picker.start_search(); - picker.query = "deep".into(); - picker.refilter(); - select(&mut picker, "src/module/deep"); - assert!(picker.accept().is_none()); - assert!(!picker.searching); - assert_eq!(picker.query, "deep"); - assert_eq!(selected_path(&picker), Path::new("src/module/deep")); - assert!(!picker.entries().nth(picker.selected).unwrap().expanded); - assert_eq!(picker.expanded, expanded); - picker.right(); - assert!(picker.entries().nth(picker.selected).unwrap().expanded); - assert_eq!(picker.query, "deep"); - - let chain = Directory::new(); - chain.put("one/two/three/a.txt", ""); - chain.put("one/two/three/b.txt", ""); - let mut picker = tree(&chain); - picker.start_search(); - picker.query = "a.txt".into(); - picker.refilter(); - assert_eq!( - picker.entries().map(|row| row.label).collect::>(), - ["one/two/three", "a.txt"] - ); - } - - #[test] - fn view_switches_restore_directory_choices_and_reveal_explicit_file_choices() { - let directory = Directory::new(); - directory.put("src/a.txt", ""); - directory.put("src/b.txt", ""); - directory.put("z.txt", ""); - let mut picker = tree(&directory); - picker.right(); - let open = paths(&picker); - picker.set_view(FileView::List); - assert_eq!( - paths(&picker), - ["src/a.txt", "src/b.txt", "z.txt"].map(PathBuf::from) - ); - picker.set_view(FileView::Tree); - assert_eq!(selected_path(&picker), Path::new("src")); - assert_eq!(paths(&picker), open); - picker.left(); - picker.toggle_view(); - picker.navigate(true); - assert_eq!(selected_path(&picker), Path::new("src/b.txt")); - picker.toggle_view(); - assert_eq!(selected_path(&picker), Path::new("src/b.txt")); - assert!(picker.entries().next().unwrap().expanded); - picker.left(); - picker.toggle_view(); - picker.toggle_view(); - assert_eq!(selected_path(&picker), Path::new("src")); - } - - #[test] - fn filtering_and_refresh_preserve_raw_selection_and_surviving_expansion() { - let directory = Directory::new(); - directory.put("src/a.txt", ""); - directory.put("src/b.txt", ""); - directory.put("z.txt", ""); - let mut picker = directory.scan(); - select(&mut picker, "src/b.txt"); - picker.query = "absent".into(); - picker.refilter(); - assert_eq!(picker.entries().len(), 0); - picker.query.clear(); - picker.refilter(); - assert_eq!(selected_path(&picker), Path::new("src/b.txt")); - picker.set_view(FileView::Tree); - assert_eq!(selected_path(&picker), Path::new("src/b.txt")); - fs::remove_file(directory.0.join("src/b.txt")).unwrap(); - directory.put("src/c.txt", ""); - picker.refresh(); - assert_eq!(selected_path(&picker), Path::new("src")); - assert!(picker.entries().next().unwrap().expanded); - assert!(paths(&picker).contains(&PathBuf::from("src/c.txt"))); - fs::remove_dir_all(directory.0.join("src")).unwrap(); - picker.refresh(); - assert_eq!(selected_path(&picker), Path::new("z.txt")); - assert!(picker.expanded.is_empty()); - picker.toggle_view(); - picker.toggle_view(); - assert_eq!(selected_path(&picker), Path::new("z.txt")); - } - - #[test] - fn tree_search_subfocus_and_expansion_survive_view_toggles_and_refresh() { - let directory = Directory::new(); - directory.put("src/deep/a.txt", ""); - directory.put("src/other.txt", ""); - let mut picker = tree(&directory); - picker.start_search(); - assert!(picker.searching); - assert_eq!(picker.entries().len(), 4); - assert!(picker.expanded.is_empty()); - picker.toggle_view(); - assert_eq!(picker.view, FileView::List); - picker.toggle_view(); - assert!(picker.searching); - assert!(picker.expanded.is_empty()); - picker.query = "a.txt".into(); - picker.refilter(); - assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); - picker.refresh(); - assert!(picker.searching); - assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); - let filtered = paths(&picker); - picker.finish_search(); - assert!(!picker.searching); - assert_eq!(picker.query, "a.txt"); - assert_eq!(paths(&picker), filtered); - assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); - assert!(picker.expanded.is_empty()); - picker.toggle_view(); - picker.refresh(); - picker.toggle_view(); - assert!(!picker.searching); - assert_eq!(picker.query, "a.txt"); - assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); - picker.start_search(); - assert!(picker.searching); - assert_eq!(picker.query, "a.txt"); - assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); - } - - #[test] - fn clearing_filter_preserves_mode_focus_current_path_and_empty_result_fallback() { - let directory = Directory::new(); - directory.put("src/deep/needle.txt", ""); - directory.put("src/other.txt", ""); - directory.put("other/keep.txt", ""); - for (view, searching) in [ - (FileView::Tree, false), - (FileView::Tree, true), - (FileView::List, false), - ] { - let mut picker = tree(&directory); - select(&mut picker, "other"); - picker.right(); - let expanded = picker.expanded.clone(); - picker.set_view(view); - if view == FileView::Tree { - picker.start_search(); - } - picker.query = "needle".into(); - picker.refilter(); - if !searching && view == FileView::Tree { - picker.finish_search(); - } - let expected = Path::new("src/deep/needle.txt"); - assert_eq!(selected_path(&picker), expected); - picker.clear_filter(); - assert!(picker.query.is_empty()); - assert_eq!(picker.searching, searching); - assert!(picker.focused); - assert_eq!(selected_path(&picker), expected); - assert!(picker.expanded.is_superset(&expanded)); - assert!(paths(&picker).contains(&PathBuf::from("other/keep.txt"))); - let rows = paths(&picker); - let expanded = picker.expanded.clone(); - picker.clear_filter(); - assert_eq!(paths(&picker), rows); - assert_eq!(picker.expanded, expanded); - assert_eq!(selected_path(&picker), expected); - assert_eq!(picker.searching, searching); - - picker.query = "absent".into(); - picker.refilter(); - picker.navigate(true); - picker.navigate(false); - assert!(picker.rows.is_empty()); - picker.refresh(); - assert!(picker.rows.is_empty()); - picker.clear_filter(); - assert_eq!(selected_path(&picker), expected); - assert_eq!(picker.searching, searching); - assert!(picker.focused); - } - - let empty = Directory::new(); - let mut picker = tree(&empty); - picker.start_search(); - picker.query = "absent".into(); - picker.refilter(); - picker.finish_search(); - picker.clear_filter(); - assert!(picker.rows.is_empty()); - assert_eq!(picker.selected, 0); - assert!(!picker.searching); - assert!(picker.focused); - } - - #[test] - fn filtered_navigation_toggles_context_folders_and_query_edits_reveal_matches() { - let directory = Directory::new(); - directory.put("src/deep/needle.txt", ""); - directory.put("src/other.txt", ""); - let mut picker = tree(&directory); - picker.start_search(); - picker.query = "needle".into(); - picker.refilter(); - let expanded = picker.expanded.clone(); - picker.left(); - assert_eq!(selected_path(&picker), Path::new("src/deep")); - picker.right(); - assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt")); - assert_eq!(picker.expanded, expanded); - picker.finish_search(); - picker.left(); - picker.left(); - assert_eq!(selected_path(&picker), Path::new("src/deep")); - assert!(!picker.entries().nth(picker.selected).unwrap().expanded); - assert_eq!(picker.query, "needle"); - assert!(!paths(&picker).contains(&PathBuf::from("src/deep/needle.txt"))); - picker.start_search(); - picker.finish_search(); - assert_eq!(selected_path(&picker), Path::new("src/deep")); - assert!(!picker.entries().nth(picker.selected).unwrap().expanded); - picker.right(); - assert!(picker.entries().nth(picker.selected).unwrap().expanded); - picker.right(); - assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt")); - picker.left(); - assert!(picker.accept().is_none()); - assert!(!picker.entries().nth(picker.selected).unwrap().expanded); - assert!(picker.accept().is_none()); - assert!(picker.entries().nth(picker.selected).unwrap().expanded); - picker.start_search(); - assert!(picker.accept().is_none()); - assert!(!picker.searching); - assert_eq!(picker.query, "needle"); - assert_eq!(selected_path(&picker), Path::new("src/deep")); - assert!(!picker.entries().nth(picker.selected).unwrap().expanded); - picker.start_search(); - picker.query.push_str(".txt"); - picker.refilter(); - assert!(picker.filter_collapsed.is_empty()); - assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt")); - picker.query.clear(); - picker.refilter(); - picker.finish_search(); - assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt")); - } - - #[test] - fn filtered_collapse_survives_refresh_compaction_and_view_switching_by_raw_path() { - let directory = Directory::new(); - directory.put("one/a.txt", ""); - directory.put("one/two/needle.txt", ""); - let mut picker = tree(&directory); - picker.start_search(); - picker.query = "needle".into(); - picker.refilter(); - picker.finish_search(); - picker.left(); - picker.left(); - picker.left(); - picker.left(); - assert_eq!(selected_path(&picker), Path::new("one")); - assert_eq!(paths(&picker), [PathBuf::from("one")]); - picker.toggle_view(); - assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt")); - picker.toggle_view(); - assert_eq!(paths(&picker), [PathBuf::from("one")]); - assert!(!picker.searching); - fs::remove_file(directory.0.join("one/a.txt")).unwrap(); - picker.focused = false; - picker.refresh(); - assert_eq!(selected_path(&picker), Path::new("one/two")); - assert_eq!(paths(&picker), [PathBuf::from("one/two")]); - assert!(!picker.searching && !picker.focused); - assert_eq!(picker.query, "needle"); - directory.put("one/a.txt", ""); - picker.refresh(); - assert_eq!(selected_path(&picker), Path::new("one")); - assert_eq!(paths(&picker), [PathBuf::from("one")]); - picker.right(); - picker.right(); - assert_eq!(selected_path(&picker), Path::new("one/two")); - assert!(!picker.entries().nth(picker.selected).unwrap().expanded); - picker.right(); - assert!(picker.entries().nth(picker.selected).unwrap().expanded); - picker.right(); - assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt")); - fs::remove_file(directory.0.join("one/two/needle.txt")).unwrap(); - picker.refresh(); - assert!(picker.entries().len() == 0); - picker.clear_filter(); - assert_eq!(selected_path(&picker), Path::new("one/two")); - assert!(!picker.searching && !picker.focused); - } - - #[test] - fn refresh_preserves_expansion_when_directory_chains_merge_and_split() { - let directory = Directory::new(); - directory.put("one/a.txt", ""); - directory.put("one/two/b.txt", ""); - let mut picker = tree(&directory); - picker.right(); - assert_eq!( - paths(&picker), - ["one", "one/two", "one/a.txt"].map(PathBuf::from) - ); - fs::remove_file(directory.0.join("one/a.txt")).unwrap(); - picker.refresh(); - assert_eq!(selected_path(&picker), Path::new("one/two")); - assert_eq!( - paths(&picker), - ["one/two", "one/two/b.txt"].map(PathBuf::from) - ); - assert!(picker.entries().next().unwrap().expanded); - directory.put("one/a.txt", ""); - picker.refresh(); - assert_eq!(selected_path(&picker), Path::new("one/two")); - assert_eq!( - paths(&picker), - ["one", "one/two", "one/a.txt"].map(PathBuf::from) - ); - assert!(picker.entries().next().unwrap().expanded); - assert!(!picker.entries().nth(1).unwrap().expanded); - fs::remove_file(directory.0.join("one/a.txt")).unwrap(); - picker.refresh(); - picker.left(); - assert_eq!(paths(&picker), [PathBuf::from("one/two")]); - directory.put("one/a.txt", ""); - picker.refresh(); - assert_eq!(paths(&picker), [PathBuf::from("one")]); - assert!(!picker.entries().next().unwrap().expanded); - } -} +#[path = "../tests/unit/files.rs"] +mod tests; diff --git a/src/forge.rs b/src/forge.rs index aca2368..3ad9d52 100644 --- a/src/forge.rs +++ b/src/forge.rs @@ -812,430 +812,5 @@ pub(crate) fn resolve( } #[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn parse(url: &str, provider: Option<&str>) -> Result { - Forge::parse(url, provider, |destination, _| { - Ok(destination.rsplit('@').next().unwrap().to_owned()) - }) - } - - #[test] - fn pages_are_bounded_newest_first_summaries_without_historical_metadata() { - for (provider, url, query) in [ - ( - Provider::Github, - "https://github.com/owner/repo", - "state=open&per_page=50&sort=created&direction=desc&page=3", - ), - ( - Provider::Gitlab, - "https://gitlab.com/owner/repo", - "state=opened&scope=all&per_page=50&order_by=created_at&sort=desc&page=3", - ), - ( - Provider::Gitea, - "https://codeberg.org/owner/repo", - "state=open&limit=50&page=3", - ), - ] { - let forge = parse(url, None).unwrap(); - let mut calls = 0; - let page = forge.page_with(false, 3, |url| { - calls += 1; - assert_eq!(url, format!("{}?{query}", forge.endpoint)); - Ok(json!([ - {"number": 1, "iid": 1, "title": "Older", "state": if provider == Provider::Gitlab { "opened" } else { "open" }, - "user": null, "author": null, "merged_at": null, "merged": false, "created_at": "2024-01-01T01:00:00+01:00"}, - {"number": 2, "iid": 2, "title": "Newer", "state": if provider == Provider::Gitlab { "opened" } else { "open" }, - "user": {"login": "author"}, "author": {"username": "author"}, "merged_at": null, "merged": false, - "created_at": "2024-01-01T00:00:00.001Z", "diff_refs": null, "head": {"sha": "not-a-commit"}} - ])) - }).unwrap(); - assert_eq!(calls, 1); - assert_eq!( - page.entries.iter().map(|p| p.number).collect::>(), - [2, 1] - ); - assert_eq!(page.entries[0].description, "author · open"); - assert!(page.entries[1].description.contains("deleted user")); - assert!( - page.has_more, - "A short, server-capped page is not exhaustion" - ); - let empty = forge.page_with(false, 4, |_| Ok(json!([]))).unwrap(); - assert!(empty.entries.is_empty() && !empty.has_more); - assert!( - forge - .page_with(false, 0, |_| panic!("Page zero must not request")) - .is_err() - ); - } - } - - #[test] - fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() { - let forge = parse("https://gitlab.com/group/repo", None).unwrap(); - let mut all = Vec::new(); - for page in 1..=3 { - let mut states = Vec::new(); - let result = forge - .page_with(true, page, |url| { - assert!(url.ends_with(&format!("&page={page}"))); - let state = if url.contains("?state=closed&") { - "closed" - } else { - assert!(url.contains("?state=merged&")); - "merged" - }; - states.push(state); - let (id, date) = match (state, page) { - ("closed", 1) => (1, "2024-01-01T00:00:00Z"), - ("merged", 1) => (3, "2024-03-01T00:00:00Z"), - ("merged", 2) => (2, "2024-02-01T00:00:00Z"), - _ => return Ok(json!([])), - }; - Ok(json!([{"iid": id, "title": state, "state": state, - "author": null, "created_at": date, "diff_refs": null}])) - }) - .unwrap(); - assert_eq!(states, ["closed", "merged"]); - assert_eq!(result.has_more, page != 3); - assert!(result.entries.iter().all(|p| p.closed)); - if page == 1 { - assert_eq!( - result.entries.iter().map(|p| p.number).collect::>(), - [3, 1] - ); - } - // The second merged page still precedes the first closed page. - all.extend(result.entries); - } - all.sort_unstable_by(|a, b| b.created_at.cmp(&a.created_at)); - assert_eq!(all.iter().map(|p| p.number).collect::>(), [3, 2, 1]); - let raced = forge.page_with(true, 1, |url| { - Ok(json!([{"iid": 7, "title": "Raced merge", "state": if url.contains("?state=merged&") { "merged" } else { "closed" }, - "author": null, "created_at": "2024-01-01T00:00:00Z"}])) - }).unwrap(); - assert_eq!(raced.entries.len(), 1); - assert!(raced.entries[0].description.contains("merged")); - } - - #[test] - fn timestamps_normalize_offsets_precision_and_calendar_boundaries() { - for (input, expected) in [ - ( - "2024-03-01T00:30:12.5+01:00", - "2024-02-29T23:30:12.500000000Z", - ), - ( - "2023-12-31T23:30:00-01:00", - "2024-01-01T00:30:00.000000000Z", - ), - ( - "2024-01-01T00:00:00.000000001Z", - "2024-01-01T00:00:00.000000001Z", - ), - ] { - assert_eq!(created_at(&json!({"created_at": input})).unwrap(), expected); - } - for input in [ - "", - "not a date", - "2023-02-29T00:00:00Z", - "2024-13-01T00:00:00Z", - "2024-01-01T24:00:00Z", - "2024-01-01T00:00:00", - "2024-01-01T00:00:00+24:00", - "2024-01-01T00:00:00.Z", - "2024-01-01T00:00:00.1234567890Z", - "0001-01-01T00:00:00+01:00", - "9999-12-31T23:00:00-02:00", - ] { - assert!( - created_at(&json!({"created_at": input})).is_err(), - "{input}" - ); - } - } - - #[test] - fn resolution_reads_only_selected_detail_and_exact_preserved_head() { - for (url, provider) in [ - ("https://github.com/owner/repo", Provider::Github), - ("https://gitlab.com/group/repo", Provider::Gitlab), - ("https://codeberg.org/owner/repo", Provider::Gitea), - ] { - let forge = parse(url, None).unwrap(); - let mut calls = Vec::new(); - let pr = forge.resolve_with(7, |url| { - calls.push(url.to_owned()); - if url == format!("{}/7", forge.endpoint) { - Ok(json!({"number": 7, "iid": 7, "title": "Selected", "state": if provider == Provider::Gitlab { "merged" } else { "closed" }, - "merged_at": "2024-01-01T00:00:00Z", "merged": true, "user": null, "author": null, - "base": {"ref": "main", "sha": "1".repeat(40)}, "head": {"ref": "topic", "sha": "2".repeat(40)}, - "merge_base": "3".repeat(40), "target_branch": "main", "source_branch": "topic", - "diff_refs": {"start_sha": null, "base_sha": "4".repeat(40), "head_sha": "5".repeat(40)}, - "url": "https://evil.example/detail", "references": "https://evil.example/refs"})) - } else { - assert_eq!(url, format!("{}/git/refs/pull/7/head", forge.endpoint.strip_suffix("/pulls").unwrap())); - Ok(json!([{"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "6".repeat(40)}}])) - } - }).unwrap(); - assert_eq!(calls.len(), if provider == Provider::Gitea { 2 } else { 1 }); - let revisions = pr.revisions.unwrap(); - assert_eq!( - revisions.base_sha, - match provider { - Provider::Github => "1", - Provider::Gitlab => "4", - Provider::Gitea => "3", - } - .repeat(40) - ); - assert_eq!( - revisions.head_sha, - match provider { - Provider::Github => "2", - Provider::Gitlab => "5", - Provider::Gitea => "6", - } - .repeat(40) - ); - assert!( - forge - .resolve_with(0, |_| panic!("Zero must not request")) - .is_err() - ); - let mut calls = 0; - assert!( - forge - .resolve_with(7, |_| { - calls += 1; - Ok(json!({"number": 8, "iid": 8})) - }) - .is_err() - ); - assert_eq!( - calls, 1, - "Reject wrong detail identity before reading historical refs" - ); - } - } - - #[test] - fn gitea_preserved_heads_reject_missing_ambiguous_or_foreign_refs() { - let reference = - json!({"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "a".repeat(40)}}); - assert_eq!(gitea_head(&reference, 7).unwrap(), "a".repeat(40)); - assert_eq!( - gitea_head(&json!([reference.clone()]), 7).unwrap(), - "a".repeat(40) - ); - for bad in [ - json!([]), - json!([reference.clone(), reference.clone()]), - json!({"ref": "refs/pull/8/head", "object": reference["object"]}), - json!({"ref": "refs/pull/7/head", "object": {"type": "tag", "sha": "a".repeat(40)}}), - json!({"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "0".repeat(40)}}), - ] { - assert!(gitea_head(&bad, 7).is_err()); - } - } - - #[test] - fn ssh_aliases_use_effective_hostname_and_preserve_user_matching() { - use std::{fs, io::Write, path::PathBuf}; - struct Config(PathBuf); - impl Drop for Config { - fn drop(&mut self) { - let _ = fs::remove_file(&self.0); - } - } - let path = std::env::temp_dir().join(format!("vamp-forge-ssh-{}.conf", std::process::id())); - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - .unwrap(); - let config = Config(path); - file.write_all(b"Host gitea-personal\n HostName gitea.example.org\n Port 2222\nHost github-personal\n HostName ssh.github.com\nHost user-match\n Port 2222\nMatch originalhost user-match user git\n HostName gitlab.example.org\n").unwrap(); - for (url, expected) in [ - ( - "git@gitea-personal:owner/repo.git", - "https://gitea.example.org/api/v1/repos/owner/repo/pulls", - ), - ( - "ssh://git@gitea-personal:2200/owner/repo.git", - "https://gitea.example.org/api/v1/repos/owner/repo/pulls", - ), - ( - "git@github-personal:owner/repo.git", - "https://api.github.com/repos/owner/repo/pulls", - ), - ( - "git@user-match:group/sub/repo.git", - "https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests", - ), - ] { - let forge = Forge::parse(url, None, |destination, port| { - let mut command = Command::new("ssh"); - command.arg("-F").arg(&config.0); - ssh_hostname(command, destination, port, &AtomicBool::new(false)) - }) - .unwrap(); - assert_eq!(forge.endpoint, expected); - } - assert!( - Forge::parse("git@alias:owner/repo", Some("gitea"), |_, _| Ok( - "evil.example/path".into() - )) - .is_err() - ); - assert!( - Forge::parse( - "ssh://user:secret@alias/owner/repo", - Some("gitea"), - |_, _| { panic!("Password-bearing SSH URLs must be rejected before starting SSH") } - ) - .is_err() - ); - assert_eq!( - Forge::parse("https://github.com/owner/repo", None, |_, _| panic!( - "HTTPS must not invoke SSH" - )) - .unwrap() - .host, - "github.com" - ); - } - - #[test] - fn remote_urls_preserve_namespaces_and_keep_ssh_ports_out_of_https() { - let gitlab = parse("ssh://git@gitlab.example.org:2222/group/sub/repo.git", None).unwrap(); - assert_eq!( - gitlab.endpoint, - "https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests" - ); - assert_eq!( - parse("git@github.com:owner/repo.git", None) - .unwrap() - .endpoint, - "https://api.github.com/repos/owner/repo/pulls" - ); - assert_eq!( - parse( - "https://user:secret@forge.example:8443/owner/repo.git", - Some("gitea") - ) - .unwrap() - .endpoint, - "https://forge.example:8443/api/v1/repos/owner/repo/pulls" - ); - assert!( - parse("https://forge.example/owner/repo", None) - .err() - .unwrap() - .contains("git config remote..vamp-forge github") - ); - for url in [ - "https://github.com/owner/repo?token=secret", - "https://github.com/owner/%2e%2e", - "https://github.com/owner/repo%0a", - "https://github.com:0/owner/repo", - "file:///owner/repo", - ] { - assert!(parse(url, Some("github")).is_err(), "{url}"); - } - } - - #[test] - fn fork_pull_requests_pin_historical_shas_not_merge_commits_or_response_urls() { - let value = json!({"number": 7, "title": "Merged fork", "state": "closed", "merged_at": "2026-01-01T00:00:00Z", "merged": true, - "user": {"login": "author"}, "base": {"ref": "release/next", "sha": "1".repeat(40)}, - "head": {"ref": "feature", "label": "fork:topic", "sha": "2".repeat(40), "repo": null}, "merge_base": "4".repeat(40), "merge_commit_sha": "3".repeat(40), "url": "https://evil.example/token"}); - for provider in [Provider::Github, Provider::Gitea] { - let pr = pull_request(provider, &value, Some("5".repeat(40))).unwrap(); - let revisions = pr.revisions.as_ref().unwrap(); - assert_eq!( - (revisions.base_ref.as_str(), revisions.head_ref.as_str()), - ("refs/heads/release/next", "refs/pull/7/head") - ); - assert_eq!( - revisions.base_sha, - if provider == Provider::Gitea { - "4" - } else { - "1" - } - .repeat(40) - ); - assert_eq!( - revisions.head_sha, - if provider == Provider::Gitea { - "5" - } else { - "2" - } - .repeat(40) - ); - if provider == Provider::Gitea { - let mut deleted = value.clone(); - deleted["head"]["sha"] = Value::String(String::new()); - assert_eq!( - pull_request(provider, &deleted, Some("5".repeat(40))) - .unwrap() - .revisions - .unwrap() - .head_sha, - "5".repeat(40) - ); - let unavailable = pull_request(provider, &deleted, None).unwrap(); - assert!(unavailable.revisions.is_err()); - } - } - } - - #[test] - fn gitlab_uses_target_start_sha_and_rejects_unprepared_or_unsafe_metadata() { - let mut value = json!({"iid": 9, "title": "MR", "state": "merged", "author": {"username": "author"}, - "target_branch": "main", "source_branch": "topic", "diff_refs": {"base_sha": "1".repeat(40), "start_sha": "2".repeat(40), "head_sha": "3".repeat(40)}}); - let pr = pull_request(Provider::Gitlab, &value, None).unwrap(); - let revisions = pr.revisions.unwrap(); - assert_eq!(revisions.base_sha, "2".repeat(40)); - assert_eq!(revisions.head_ref, "refs/merge-requests/9/head"); - value["diff_refs"]["start_sha"] = Value::Null; - assert_eq!( - pull_request(Provider::Gitlab, &value, None) - .unwrap() - .revisions - .unwrap() - .base_sha, - "1".repeat(40) - ); - value["target_branch"] = json!("main:refs/heads/injected"); - assert!( - pull_request(Provider::Gitlab, &value, None) - .unwrap() - .revisions - .is_err() - ); - value["target_branch"] = json!("main"); - value["diff_refs"]["head_sha"] = json!("HEAD"); - assert!( - pull_request(Provider::Gitlab, &value, None) - .unwrap() - .revisions - .is_err() - ); - value["diff_refs"] = Value::Null; - assert!( - pull_request(Provider::Gitlab, &value, None) - .unwrap() - .revisions - .is_err() - ); - } -} +#[path = "../tests/unit/forge.rs"] +mod tests; diff --git a/src/gutter.rs b/src/gutter.rs index 7cf9bf8..247e0b8 100644 --- a/src/gutter.rs +++ b/src/gutter.rs @@ -456,430 +456,5 @@ fn changes(before: &str, after: &str) -> Vec> { } #[cfg(all(test, unix))] -mod tests { - use super::*; - use std::ffi::OsString; - - static NEXT_TEST: AtomicU64 = AtomicU64::new(0); - struct Temporary(PathBuf); - impl Temporary { - fn new() -> Self { - let path = std::env::temp_dir().join(format!( - "vamp-gutter-test-{}-{}", - std::process::id(), - NEXT_TEST.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir(&path).unwrap(); - Self(path) - } - } - impl Drop for Temporary { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - fn command(root: &Path, program: &str, args: &[&str]) -> String { - let mut command = Command::new(program); - if program == "jj" { - command.args([ - "--config=user.name=\"Gutter Author\"", - "--config=user.email=\"gutter@example.test\"", - "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", - "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", - ]); - } - let output = command - .args(args) - .current_dir(root) - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_SYSTEM", "/dev/null") - .env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z") - .env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z") - .env("JJ_CONFIG", "/dev/null") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).unwrap().trim().into() - } - fn git_fixture() -> (Temporary, PathBuf) { - let temp = Temporary::new(); - command(&temp.0, "git", &["init", "--quiet", "--template="]); - command(&temp.0, "git", &["config", "user.name", "Gutter Author"]); - command( - &temp.0, - "git", - &["config", "user.email", "gutter@example.test"], - ); - let path = temp.0.join("-literal [file]\n\t\".txt"); - fs::write(&path, "first\nsecond\nthird\n").unwrap(); - command( - &temp.0, - "git", - &["add", "--", path.file_name().unwrap().to_str().unwrap()], - ); - command(&temp.0, "git", &["commit", "--quiet", "-m", "Baseline"]); - (temp, path) - } - - #[test] - fn maps_replacements_surplus_deletions_empty_and_eof_rows() { - use Change::*; - for (before, after, expected) in [ - ("first\nsecond\n", "first\nsecond\n", vec![None, None, None]), - ( - "first\r\nsecond\r\n", - "first\nsecond\n", - vec![None, None, None], - ), - ("first\rsecond\r", "first\nsecond\n", vec![None, None, None]), - ( - "first\r\nsecond\r\nthird\r\n", - "first\nchanged\nthird\n", - vec![None, Some(Modified), None, None], - ), - ( - "first\nsecond\nthird\n", - "first\rchanged\rthird\r", - vec![None, Some(Modified), None, None], - ), - ("first\n", "first\nnew\n", vec![None, Some(Added), None]), - ( - "old\n", - "changed\nsurplus\n", - vec![Some(Modified), Some(Added), None], - ), - ( - "old\nextra\nlast\n", - "changed\nlast\n", - vec![Some(Modified), None, None], - ), - ( - "first\nremoved\nlast\n", - "first\nlast\n", - vec![None, Some(Removed), None], - ), - ("first\nremoved\n", "first\n", vec![None, Some(Removed)]), - ("first\nremoved", "first", vec![Some(Modified)]), - ("first\nremoved", "first\n", vec![None, Some(Removed)]), - ("removed\n", "", vec![Some(Removed)]), - ("", "new\n", vec![Some(Added), None]), - ("", "", vec![None]), - ("same", "same\n", vec![Some(Modified), None]), - ] { - assert_eq!(changes(before, after), expected, "{before:?} -> {after:?}"); - } - } - - #[test] - fn git_crlf_baseline_matches_normalized_editor_rows() { - let (temp, _) = git_fixture(); - let path = temp.0.join("crlf"); - fs::write(&path, "first\r\nsecond\r\nthird\r\n").unwrap(); - command(&temp.0, "git", &["add", "--", "crlf"]); - command( - &temp.0, - "git", - &["commit", "--quiet", "-m", "CRLF baseline"], - ); - assert_eq!( - read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(), - vec![None, None, None, None] - ); - assert_eq!( - read_file(&path, "first\nchanged\nthird\n", &|| false).unwrap(), - vec![None, Some(Change::Modified), None, None] - ); - } - - #[test] - fn git_head_includes_staged_disk_and_unsaved_edits_without_writes() { - let (temp, path) = git_fixture(); - fs::write(&path, "staged\nsecond\nthird\n").unwrap(); - command( - &temp.0, - "git", - &["add", "--", path.file_name().unwrap().to_str().unwrap()], - ); - fs::write(&path, "staged\ndisk\nthird\n").unwrap(); - let index = fs::read(temp.0.join(".git/index")).unwrap(); - let head = command(&temp.0, "git", &["rev-parse", "HEAD"]); - assert_eq!( - read_file(&path, "staged\ndisk\nunsaved\n", &|| false).unwrap(), - vec![ - Some(Change::Modified), - Some(Change::Modified), - Some(Change::Modified), - None - ] - ); - assert_eq!(fs::read(temp.0.join(".git/index")).unwrap(), index); - assert_eq!(command(&temp.0, "git", &["rev-parse", "HEAD"]), head); - assert_eq!(fs::read_to_string(&path).unwrap(), "staged\ndisk\nthird\n"); - fs::remove_file(&path).unwrap(); - assert_eq!( - read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(), - vec![None, None, None, None] - ); - assert_eq!( - read_file(&temp.0.join("new/named"), "unsaved\n", &|| false).unwrap(), - vec![Some(Change::Added), None] - ); - let untracked = temp.0.join("untracked"); - fs::write(&untracked, "unsaved\n").unwrap(); - assert_eq!( - read_file(&untracked, "unsaved\n", &|| false).unwrap(), - vec![Some(Change::Added), None] - ); - } - - #[test] - fn unborn_untracked_missing_ignored_metadata_binary_and_symlink_paths() { - let temp = Temporary::new(); - let path = temp.0.join("new/named"); - assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty()); - command(&temp.0, "git", &["init", "--quiet", "--template="]); - assert_eq!( - read_file(&path, "unsaved\n", &|| false).unwrap(), - vec![Some(Change::Added), None] - ); - let disk = temp.0.join("untracked"); - fs::write(&disk, "disk\n").unwrap(); - assert_eq!( - read_file(&disk, "unsaved\n", &|| false).unwrap(), - vec![Some(Change::Added), None] - ); - fs::write(temp.0.join(".gitignore"), "new/\nuntracked\n").unwrap(); - assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty()); - assert!(read_file(&disk, "unsaved\n", &|| false).unwrap().is_empty()); - assert!( - read_file(&temp.0.join(".git/config"), "text\n", &|| false) - .unwrap() - .is_empty() - ); - assert!( - read_file(&disk, "binary\0text", &|| false) - .unwrap() - .is_empty() - ); - std::os::unix::fs::symlink(&disk, temp.0.join("link")).unwrap(); - assert!( - read_file(&temp.0.join("link"), "text\n", &|| false) - .unwrap() - .is_empty() - ); - std::os::unix::fs::symlink(&temp.0, temp.0.join("linked")).unwrap(); - assert!( - read_file(&temp.0.join("linked/untracked"), "text\n", &|| false) - .unwrap() - .is_empty() - ); - } - - #[test] - fn git_non_utf8_filename_is_literal() { - use std::os::unix::ffi::OsStringExt; - let (temp, _) = git_fixture(); - let name = OsString::from_vec(b"non-utf8-\xff".to_vec()); - let path = temp.0.join(&name); - match fs::write(&path, "old\n") { - Ok(()) => {} - // APFS rejects non-UTF-8 names before repository code can read them. - Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => return, - Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"), - } - let mut add = git(&temp.0); - add.args(["add", "--"]).arg(&name); - checked(add, &|| false).unwrap(); - command(&temp.0, "git", &["commit", "--quiet", "-m", "Byte path"]); - assert_eq!( - read_file(&path, "changed\n", &|| false).unwrap(), - vec![Some(Change::Modified), None] - ); - } - - #[test] - fn jj_parent_root_colocation_ignore_and_merge_queries_are_read_only() { - for colocate in ["--colocate", "--no-colocate"] { - let temp = Temporary::new(); - command(&temp.0, "jj", &["git", "init", colocate, "."]); - let name = "-literal [file]\n\t\".txt"; - let path = temp.0.join(name); - fs::write(&path, "original\n").unwrap(); - assert_eq!( - read_file(&path, "unsaved\n", &|| false).unwrap(), - vec![Some(Change::Added), None] - ); - fs::write(temp.0.join(".gitignore"), "ignored\n").unwrap(); - assert!( - read_file(&temp.0.join("ignored"), "text\n", &|| false) - .unwrap() - .is_empty() - ); - command(&temp.0, "jj", &["describe", "-m", "Baseline"]); - command(&temp.0, "jj", &["new"]); - let operation = command( - &temp.0, - "jj", - &[ - "--ignore-working-copy", - "op", - "log", - "--no-graph", - "-n", - "1", - "-T", - "id", - ], - ); - let store = store_for_jj(&temp.0).unwrap(); - let refs = fs::read(store.join("HEAD")).unwrap(); - fs::write(&path, "disk edit\n").unwrap(); - assert_eq!( - read_file(&path, "inserted\noriginal\n", &|| false).unwrap(), - vec![Some(Change::Added), None, None] - ); - assert_eq!(fs::read(store.join("HEAD")).unwrap(), refs); - assert_eq!( - command( - &temp.0, - "jj", - &[ - "--ignore-working-copy", - "op", - "log", - "--no-graph", - "-n", - "1", - "-T", - "id", - ] - ), - operation - ); - let parent = command( - &temp.0, - "jj", - &[ - "--ignore-working-copy", - "log", - "--no-graph", - "-r", - "@-", - "-T", - "commit_id", - ], - ); - command(&temp.0, "jj", &["new", "root()"]); - fs::write(temp.0.join("sibling"), "sibling\n").unwrap(); - command(&temp.0, "jj", &["describe", "-m", "Sibling"]); - command(&temp.0, "jj", &["new", &parent, "@"]); - assert!( - read_file(&path, "original\n", &|| false) - .unwrap() - .is_empty() - ); - } - } - - #[test] - fn worker_retains_only_latest_batch_and_file_errors_keep_good_siblings() { - let (_temp, path) = git_fixture(); - let mut worker = Worker::start().unwrap(); - let oversized: Arc = "x".repeat(FILE_LIMIT + 1).into(); - for id in 0..20 { - worker - .request( - id, - vec![ - (5, path.clone(), "first\nchanged\nthird\n".into()), - (8, path.clone(), Arc::clone(&oversized)), - ], - None, - ) - .unwrap(); - } - loop { - if let Some((id, result, picker)) = worker.poll() { - assert!(picker.is_none()); - assert_eq!(id, 19); - assert_eq!(result[0].0, 5); - assert_eq!( - result[0].1.as_ref().unwrap(), - &vec![None, Some(Change::Modified), None, None] - ); - assert_eq!(result[1].0, 8); - assert_eq!( - result[1].1.as_ref().unwrap_err(), - "Gutter file exceeds 16 MiB" - ); - break; - } - thread::yield_now(); - } - assert_eq!( - worker - .request( - 20, - vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())], - None - ) - .unwrap_err() - .kind(), - io::ErrorKind::InvalidInput - ); - } - - #[test] - fn picker_status_is_optional_and_errors_do_not_discard_line_results() { - let (temp, path) = git_fixture(); - let mut worker = Worker::start().unwrap(); - worker - .request( - 1, - vec![], - Some(( - temp.0.clone(), - vec![(path.clone(), "dirty buffer\n".into())], - )), - ) - .unwrap(); - loop { - if let Some((id, lines, picker)) = worker.poll() { - assert_eq!(id, 1); - assert!(lines.is_empty()); - let picker = picker.unwrap(); - assert_eq!(picker.root, temp.0); - let changes = picker.result.unwrap(); - assert_eq!(changes.len(), 1); - assert_eq!(changes[0].path, path.strip_prefix(&temp.0).unwrap()); - assert_eq!(changes[0].status, crate::diff::FileStatus::Modified); - break; - } - thread::yield_now(); - } - worker - .request( - 2, - vec![(4, path.clone(), "first\nchanged\nthird\n".into())], - Some(( - temp.0.clone(), - vec![(path, "x".repeat(FILE_LIMIT + 1).into())], - )), - ) - .unwrap(); - loop { - if let Some((id, lines, picker)) = worker.poll() { - assert_eq!(id, 2); - assert_eq!(lines[0].0, 4); - assert!(lines[0].1.is_ok()); - assert!(picker.unwrap().result.unwrap_err().contains("16 MiB")); - break; - } - thread::yield_now(); - } - } -} +#[path = "../tests/unit/gutter.rs"] +mod tests; diff --git a/src/lsp.rs b/src/lsp.rs index d8d438f..7928c4a 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -2993,249 +2993,5 @@ fn uri_path(uri: &str) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn native_definitions_preserve_snapshot_identity_and_utf16_until_navigation() { - let source = Source { - buffer_id: 7, - revision: 3, - row: 0, - column: 0, - request_id: 11, - }; - let path = Path::new("/vamp-immutable-snapshot/Before/renamed/λ.rs"); - let span = json!({ - "start": {"line": 4, "character": 5}, - "end": {"line": 4, "character": 8} - }); - for location in [ - json!({"uri": file_uri(path).unwrap(), "range": span}), - json!({"targetUri": file_uri(path).unwrap(), - "targetRange": span, "targetSelectionRange": span}), - ] { - let effects = Runtime::definition_effects(&source, &location).unwrap(); - let expected_target_range = location.get("targetUri").map(|_| Utf16Range { - start: (4, 5), - end: (4, 8), - }); - assert!(matches!( - effects.as_slice(), - [UpdateEffect::NavigateUtf16 { - buffer_id: 7, revision: 3, path: target, - range: Utf16Range { start: (4, 5), end: (4, 8) }, target_range, - }] if target == path && *target_range == expected_target_range - )); - } - for invalid in [ - json!({"start": {"line": 4, "character": 8}, - "end": {"line": 4, "character": 5}}), - json!({"start": {"line": -1, "character": 0}, - "end": {"line": 0, "character": 0}}), - ] { - for location in [ - json!({"uri": file_uri(path).unwrap(), "range": invalid}), - json!({"targetUri": file_uri(path).unwrap(), - "targetRange": invalid, "targetSelectionRange": span}), - json!({"targetUri": file_uri(path).unwrap(), - "targetRange": span, "targetSelectionRange": invalid}), - ] { - assert!(Runtime::definition_effects(&source, &location).is_err()); - } - } - assert!( - serde_json::from_value::(json!({ - "type": "navigate_utf16", "buffer_id": 7, "revision": 3, - "path": path, "range": {"start": [4,5], "end": [4,8]}, - "target_range": null - })) - .is_err() - ); - } - - #[test] - fn semantic_decoder_validates_raw_coordinates_before_grapheme_rounding() { - let options = semantic_options(Some(&json!({ - "full": true, - "legend": {"tokenTypes": ["custom", "function"], - "tokenModifiers": ["custom", "readonly", "declaration", "async"]} - }))) - .unwrap() - .unwrap(); - let tokens = decode_semantic_tokens( - "A😀e\u{301}Z\r\n\r\nx", - &json!({"data": [ - 0, 1, 2, 1, 2, - 0, 2, 1, 1, 12, - 0, 1, 1, 1, 0, - 0, 1, 999, 0, 0, - 1, 0, 1, 1, 0, - 1, 0, 0, 1, 0, - 0, 0, 100, 1, 0 - ]}), - &options, - ) - .unwrap() - .unwrap(); - assert_eq!(tokens.len(), 4); - assert_eq!(tokens[0].start, TextPosition { row: 0, column: 1 }); - assert_eq!(tokens[0].end, TextPosition { row: 0, column: 2 }); - assert_eq!(tokens[0].modifiers, SemanticTokenModifier::Readonly as u16); - assert_eq!(tokens[1].start, TextPosition { row: 0, column: 2 }); - assert_eq!(tokens[1].end, TextPosition { row: 0, column: 3 }); - assert_eq!( - tokens[1].modifiers, - SemanticTokenModifier::Declaration as u16 | SemanticTokenModifier::Async as u16 - ); - assert_eq!(tokens[2].start, tokens[1].start); - assert_eq!(tokens[3].end, TextPosition { row: 2, column: 1 }); - for data in [ - json!([0, 0, 1, 1]), - json!([-1, 0, 1, 1, 0]), - json!([0, 0.5, 1, 1, 0]), - json!([0, 0, 1, 1, 2147483648u64]), - json!([0, 0, 1, 65536, 0]), - json!([0, 0, 1, 2, 0]), - json!([0, 0, 1, 1, 16]), - json!([0, 2, 1, 0, 0]), - json!([0, 1, 1, 1, 0]), - json!([0, 7, 1, 1, 0]), - json!([2, 0, 1, 1, 0]), - json!([0, 1, 2, 1, 0, 0, 1, 1, 1, 0]), - ] { - assert!( - decode_semantic_tokens("A😀e\u{301}Z\n", &json!({"data": data}), &options).is_err(), - "{data}" - ); - } - let count = MAX_BODY / std::mem::size_of::() + 1; - assert!( - decode_semantic_tokens("x", &json!({"data": vec![0; count * 5]}), &options).is_err() - ); - } - - #[test] - fn unicode_positions_and_local_uri_boundaries() { - let text = "A😀e\u{301}Z\n"; - let (start, end) = range( - text, - &json!({"start": {"line": 0, "character": 2}, "end": {"line": 0, "character": 4}}), - ) - .unwrap(); - assert_eq!(start, TextPosition { row: 0, column: 1 }); - assert_eq!(end, TextPosition { row: 0, column: 3 }); - assert_eq!( - outbound_position(text, 0, 3).unwrap(), - json!({"line": 0, "character": 5}) - ); - assert_eq!( - position(text, &json!({"line": 1, "character": 999}), false).unwrap(), - TextPosition { row: 1, column: 0 } - ); - assert!( - range( - text, - &json!({"start": {"line": 0, "character": 3}, "end": {"line": 0, "character": 2}}) - ) - .is_err() - ); - let path = Path::new("/tmp/source space λ😀.rs"); - assert_eq!(uri_path(&file_uri(path).unwrap()).unwrap(), path); - assert_eq!( - uri_path("file://localhost/tmp/a%23b").unwrap(), - Path::new("/tmp/a#b") - ); - for uri in [ - "https://example.invalid/a", - "file://remote/tmp/a", - "file:///tmp/%", - "file:///tmp/%00", - "file:///tmp/%FF", - "file:///tmp/a?query", - ] { - assert!(uri_path(uri).is_err(), "{uri}"); - } - } - - #[test] - fn hover_contents_keep_lines_and_reject_malformed_values() { - let source = "A😀e\u{301}Z\r\n"; - for kind in ["plaintext", "markdown"] { - assert_eq!( - hover_text( - &json!({ - "contents": {"kind": kind, "value": "😀e\u{301}\r\n\tcode\rnext\u{1b}\u{85}"}, - "range": {"start": {"line": 0, "character": 2}, - "end": {"line": 0, "character": 4}} - }), - source, - ) - .unwrap(), - Some("😀e\u{301}\n code\nnext ".into()), - ); - } - for contents in [ - json!("line\ncode"), - json!({"language": "rust", "value": "line\ncode"}), - ] { - assert_eq!( - hover_text(&json!({"contents": contents}), source).unwrap(), - Some("line\ncode".into()), - ); - } - assert_eq!( - hover_text( - &json!({"contents": ["first", {"language": "rust", "value": "fn main() {\n}"}]}), - source, - ) - .unwrap(), - Some("first\n\nfn main() {\n}".into()), - ); - for value in [ - Value::Null, - json!({"contents": ""}), - json!({"contents": []}), - ] { - assert_eq!(hover_text(&value, source).unwrap(), None); - } - for value in [ - json!({}), - json!({"contents": {"kind": "html", "value": "x"}}), - json!({"contents": {"language": "rust", "value": false}}), - json!({"contents": [{"kind": "markdown", "value": "x"}]}), - json!({"contents": "x", "range": {"start": {"line": 1, "character": 0}, - "end": {"line": 0, "character": 0}}}), - json!({"contents": "\t".repeat(MAX_BODY / 4 + 1)}), - ] { - assert!(hover_text(&value, source).is_err()); - } - } - - #[test] - fn completion_text_limit_counts_the_atomic_batch_not_sort_metadata() { - let source = Source { - buffer_id: 1, - revision: 0, - row: 0, - column: 0, - request_id: 1, - }; - let at_limit = json!([{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}]); - assert_eq!( - decode_completion("", &at_limit, &source, None).unwrap()[0].edits[0] - .text - .len(), - MAX_BODY - 1 - ); - let over_limit = json!([ - {"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}, - {"label": "y"} - ]); - assert!( - decode_completion("", &over_limit, &source, None) - .unwrap_err() - .contains("4 MiB") - ); - } -} +#[path = "../tests/unit/lsp.rs"] +mod tests; diff --git a/src/main.rs b/src/main.rs index 4c3f76e..3e56d1d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -254,51 +254,5 @@ fn main() { } #[cfg(test)] -mod tests { - use super::*; - - fn parse(args: &[&str]) -> Result, String> { - parse_options(args.iter().map(std::ffi::OsString::from)) - } - - #[test] - fn review_commands_preserve_filenames_and_validate_arguments() { - assert_eq!( - parse(&["review"]).unwrap().unwrap().file, - Some("review".into()) - ); - assert_eq!( - parse(&["--", "--review"]).unwrap().unwrap().file, - Some("--review".into()) - ); - assert!( - parse(&["--no-config", "--review", "list", "--repo", ".", "--json"]) - .unwrap() - .unwrap() - .review - .is_some() - ); - assert!( - parse(&["--review", "show", "1", "--all", "--no-config"]) - .unwrap() - .unwrap() - .review - .is_some() - ); - for args in [ - vec!["--review"], - vec!["--review", "show"], - vec!["--review", "show", "0"], - vec!["--review", "show", "-1"], - vec!["--review", "show", "abc"], - vec!["--review", "list", "--all"], - vec!["--review", "list", "--repo"], - vec!["--review", "list", "--repo", ".", "--repo", "."], - vec!["--review", "list", "extra"], - vec!["--review", "list", "--", "extra"], - vec!["--review", "list", "--config", "x", "--no-config"], - ] { - assert!(parse(&args).is_err(), "{args:?}"); - } - } -} +#[path = "../tests/unit/cli_args.rs"] +mod tests; diff --git a/src/markdown.rs b/src/markdown.rs index 2635f46..010cf83 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -560,148 +560,5 @@ fn slug(title: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn at(text: &str, needle: &str) -> Result { - let byte = text.find(needle).unwrap(); - let prefix = &text[..byte]; - let row = prefix.bytes().filter(|byte| *byte == b'\n').count(); - let column = prefix.rsplit('\n').next().unwrap().graphemes(true).count(); - link_at(text, TextPosition { row, column }) - } - - fn target(path: Option<&str>, fragment: Option<&str>) -> LinkTarget { - LinkTarget { - path: path.map(PathBuf::from), - fragment: fragment.map(str::to_owned), - } - } - - #[test] - fn follows_inline_labels_destinations_and_grapheme_columns() { - let text = "👩‍💻 [go **there**](<../My%20Notes/%E6%97%A5%E6%9C%AC\\(1\\).md#caf%C3%A9>)"; - let expected = target(Some("../My Notes/日本(1).md"), Some("café")); - assert_eq!(at(text, "there").unwrap(), expected); - assert_eq!(at(text, "My%20").unwrap(), expected); - assert_eq!( - at("[absolute](/Tmp/Note.md)", "absolute").unwrap(), - target(Some("/Tmp/Note.md"), None) - ); - assert_eq!( - at("[here](#title)", "here").unwrap(), - target(None, Some("title")) - ); - assert_eq!(at("[top]()", "top").unwrap(), target(None, None)); - assert_eq!( - at("[here](#title)", "[here").unwrap(), - target(None, Some("title")) - ); - assert_eq!( - at("[file](literal%23name.md)", "file").unwrap(), - target(Some("literal#name.md"), None) - ); - assert_eq!( - at("[file](My\\[Notes\\].md)", "file").unwrap(), - target(Some("My[Notes].md"), None) - ); - } - - #[test] - fn resolves_all_reference_forms_and_multiline_block_definitions() { - let text = "[full][ A B ] [a b][] [A B]\n\n> [a b]:\n> \n> \"A title\"\n\n[a b]: ignored.md\n"; - let expected = target(Some("My Notes.md"), Some("title")); - for needle in ["full", " A B ", "a b][]", "A B]"] { - assert_eq!(at(text, needle).unwrap(), expected); - } - assert_eq!( - at("[Straße]\n\n[STRASSE]: file.md", "Straße").unwrap(), - target(Some("file.md"), None) - ); - let text = "> [multi\n> line][target]\n\n[target]: other.md\n"; - assert_eq!(at(text, "line]").unwrap(), target(Some("other.md"), None)); - } - - #[test] - fn rejects_images_code_plaintext_and_external_urls() { - for (text, needle) in [ - ("![image](file.md)", "image"), - ("`[code](file.md)`", "code"), - ("```md\n[code](file.md)\n```", "file"), - (" [code](file.md)", "code"), - ("plain file.md text", "file"), - ("[web](https://example.com)", "web"), - ("[web](//example.com/a)", "web"), - ("[web](mailto:a@example.com)", "web"), - ("", "example"), - ] { - assert!(at(text, needle).is_err(), "{text}"); - } - assert!( - at("[missing]", "missing") - .unwrap_err() - .contains("not defined") - ); - assert!( - at("[bad](file%ZZ.md)", "bad") - .unwrap_err() - .contains("percent") - ); - assert!( - at("[bad](file%FF.md)", "bad") - .unwrap_err() - .contains("UTF-8") - ); - assert!(at("[bad](#)", "bad").unwrap_err().contains("empty")); - } - - #[test] - fn locates_visible_unicode_headings_setext_and_unique_slugs() { - let text = "# **Café** [日本](destination.md) `code` ###\n\n> ## Café 日本 code\n\nCafé 日本 code\n====\n\n# Café 日本 code-1\n\n# Cafe\u{301} _name_ ![alt](hidden.md)\n\n```md\n# Hidden\n```\n"; - assert_eq!(heading_row(text, "café-日本-code").unwrap(), 0); - assert_eq!(heading_row(text, "café-日本-code-1").unwrap(), 2); - assert_eq!(heading_row(text, "café-日本-code-2").unwrap(), 4); - assert_eq!(heading_row(text, "café-日本-code-1-1").unwrap(), 7); - assert_eq!(heading_row(text, "cafe\u{301}-name-alt").unwrap(), 9); - assert!(heading_row(text, "hidden").is_err()); - assert!(heading_row(text, "destinationmd").is_err()); - assert!(heading_row(text, "").is_err()); - assert_eq!( - heading_row("# [Heading][ref]\n\n[ref]: ignored.md", "heading").unwrap(), - 0 - ); - assert_eq!( - heading_row("# ![Alt][ref]\n\n[ref]: ignored.md", "alt").unwrap(), - 0 - ); - assert_eq!(heading_row("# ![Alt][missing]", "altmissing").unwrap(), 0); - } - - #[test] - fn renders_entities_or_reports_the_explicit_entity_ceiling() { - assert_eq!( - at("[file](a&b c.md)", "file").unwrap(), - target(Some("a&b c.md"), None) - ); - assert_eq!( - at("[file](<name>)", "file").unwrap(), - target(Some(""), None) - ); - assert!( - at("[file](a©b.md)", "file") - .unwrap_err() - .contains("Unsupported Markdown HTML entity") - ); - assert_eq!(heading_row("# A & B é\n", "a--b-é").unwrap(), 0); - assert!( - heading_row("# ©\n", "copy") - .unwrap_err() - .contains("Unsupported Markdown HTML entity") - ); - assert_eq!(heading_row("# ©\n# Valid\n", "valid").unwrap(), 1); - assert_eq!( - heading_row("> First\n> Second\n> ------\n", "first-second").unwrap(), - 0 - ); - } -} +#[path = "../tests/unit/markdown.rs"] +mod tests; diff --git a/src/panes.rs b/src/panes.rs index 89b9164..11e9029 100644 --- a/src/panes.rs +++ b/src/panes.rs @@ -248,128 +248,5 @@ pub fn directional_neighbor( } #[cfg(test)] -mod tests { - use super::*; - - fn rect(x: u16, y: u16, width: u16, height: u16) -> PaneRect { - PaneRect { - x, - y, - width, - height, - } - } - - fn leaves(tree: &PaneTree, bounds: PaneRect) -> Vec<(usize, PaneRect)> { - let mut leaves = Vec::new(); - tree.visit_rects(bounds, |id, rect| leaves.push((id, rect))); - leaves - } - - #[test] - fn nested_layout_partitions_bounds_and_preserves_ids() { - let bounds = rect(3, 5, 11, 9); - let mut tree = PaneTree::new(10); - assert!(tree.split(10, 20, SplitAxis::Vertical)); - assert!(tree.split(20, 30, SplitAxis::Horizontal)); - assert!(!tree.split(99, 40, SplitAxis::Horizontal)); - assert!(!tree.split(10, 30, SplitAxis::Horizontal)); - assert!(!tree.split(10, 10, SplitAxis::Horizontal)); - assert_eq!(tree.len(), 3); - let panes = leaves(&tree, bounds); - assert_eq!( - panes, - vec![ - (10, rect(3, 5, 5, 9)), - (20, rect(8, 5, 6, 4)), - (30, rect(8, 9, 6, 5)) - ] - ); - let mut cells = std::collections::HashSet::new(); - for (_, pane) in panes { - for y in pane.y..pane.y + pane.height { - for x in pane.x..pane.x + pane.width { - assert!(cells.insert((x, y)), "overlapping pane cell"); - } - } - } - assert_eq!( - cells.len(), - usize::from(bounds.width) * usize::from(bounds.height) - ); - assert!(leaves(&tree, rect(0, 0, 0, 10)).is_empty()); - assert!(leaves(&tree, rect(0, 0, 10, 0)).is_empty()); - } - - #[test] - fn closing_expands_nested_sibling_and_refuses_last() { - let bounds = rect(0, 0, 20, 12); - let mut tree = PaneTree::new(1); - tree.split(1, 2, SplitAxis::Vertical); - tree.split(2, 3, SplitAxis::Horizontal); - assert_eq!(tree.close(1), Some(2)); - assert_eq!( - leaves(&tree, bounds), - vec![(2, rect(0, 0, 20, 6)), (3, rect(0, 6, 20, 6))] - ); - assert_eq!(tree.close(99), None); - assert_eq!(tree.close(2), Some(3)); - assert_eq!(leaves(&tree, bounds), vec![(3, bounds)]); - assert_eq!(tree.close(3), None); - assert_eq!(tree.len(), 1); - assert!(!tree.contains(1)); - assert!(tree.contains(3)); - } - - #[test] - fn directional_focus_handles_unequal_nested_regions() { - let bounds = rect(0, 0, 20, 12); - let mut tree = PaneTree::new(1); - tree.split(1, 2, SplitAxis::Vertical); - tree.split(2, 3, SplitAxis::Horizontal); - tree.split(3, 4, SplitAxis::Horizontal); - assert_eq!(tree.neighbor(1, PaneDirection::Right, bounds), Some(3)); - assert_eq!(tree.neighbor(2, PaneDirection::Down, bounds), Some(3)); - assert_eq!(tree.neighbor(3, PaneDirection::Down, bounds), Some(4)); - assert_eq!(tree.neighbor(4, PaneDirection::Up, bounds), Some(3)); - assert_eq!(tree.neighbor(4, PaneDirection::Left, bounds), Some(1)); - assert_eq!(tree.neighbor(1, PaneDirection::Left, bounds), None); - assert_eq!(tree.neighbor(99, PaneDirection::Right, bounds), None); - assert_eq!( - tree.neighbor(1, PaneDirection::Right, rect(0, 0, 0, 0)), - None - ); - } - - #[test] - fn navigation_prefers_overlap_then_edge_and_breaks_ties_by_id() { - let current = rect(10, 10, 10, 10); - let candidates = [ - (1, rect(20, 0, 5, 5)), - (2, rect(25, 10, 5, 10)), - (4, rect(22, 10, 5, 10)), - (3, rect(22, 10, 5, 10)), - (0, rect(20, 10, 0, 10)), - ]; - assert_eq!( - directional_neighbor(current, PaneDirection::Right, candidates.into_iter()), - Some(3) - ); - assert_eq!( - directional_neighbor(current, PaneDirection::Right, candidates.into_iter().rev()), - Some(3) - ); - assert_eq!( - directional_neighbor( - current, - PaneDirection::Up, - [(7, rect(10, 0, 10, 5))].into_iter() - ), - Some(7) - ); - assert_eq!( - directional_neighbor(current, PaneDirection::Left, candidates.into_iter()), - None - ); - } -} +#[path = "../tests/unit/panes.rs"] +mod tests; diff --git a/src/repository.rs b/src/repository.rs index 8e3962e..425a31e 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -527,15 +527,6 @@ fn working_conflict_baseline( }) } -#[cfg(test)] -fn working_file_changes( - root: &Path, - buffers: &[(PathBuf, Arc)], - cancelled: &dyn Fn() -> bool, -) -> Result, String> { - WorkingFileReader::default().read(root, buffers, cancelled) -} - fn working_file_changes_cached( root: &Path, buffers: &[(PathBuf, Arc)], @@ -3032,500 +3023,5 @@ fn eof_label(file: Option<&FileData>) -> &'static str { } #[cfg(all(test, unix))] -mod tests { - use super::*; - - fn fixture_command(root: &Path, program: &str, args: &[&str]) -> String { - let mut command = Command::new(program); - if program == "jj" { - command.args([ - "--config=user.name=\"Picker Author\"", - "--config=user.email=\"picker@example.test\"", - "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", - "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", - ]); - } - let output = command - .args(args) - .current_dir(root) - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_SYSTEM", "/dev/null") - .env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z") - .env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z") - .env("JJ_CONFIG", "/dev/null") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).unwrap().trim().into() - } - - fn picker_git_fixture() -> Temporary { - let temp = Temporary::new().unwrap(); - fixture_command(&temp.0, "git", &["init", "--quiet", "--template="]); - fixture_command(&temp.0, "git", &["config", "user.name", "Picker Author"]); - fixture_command( - &temp.0, - "git", - &["config", "user.email", "picker@example.test"], - ); - temp - } - - fn changes_by_path( - root: &Path, - buffers: &[(PathBuf, Arc)], - ) -> BTreeMap { - working_file_changes(root, buffers, &|| false) - .unwrap() - .into_iter() - .map(|change| (change.path.clone(), change)) - .collect() - } - - #[test] - fn working_git_changes_cover_disk_index_buffers_binary_exact_renames_and_read_only_queries() { - use std::os::unix::{ffi::OsStringExt, fs::PermissionsExt}; - let temp = picker_git_fixture(); - let root = &temp.0; - for (name, contents) in [ - (".gitignore", "ignored*\n"), - ("modified", "original\r\n"), - ("unchanged", "same\n"), - ("removed", "deleted\n"), - ("old", "rename content\n"), - ("duplicate-one", "duplicate\n"), - ("duplicate-two", "duplicate\n"), - ("mode", "mode\n"), - ("binary", "before\0binary"), - ] { - fs::write(root.join(name), contents).unwrap(); - } - let non_utf8 = PathBuf::from(OsString::from_vec(b"non-utf8-\xff".to_vec())); - let non_utf8 = match fs::write(root.join(&non_utf8), "before\n") { - Ok(()) => Some(non_utf8), - Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => None, - Err(error) => panic!("cannot create byte filename: {error}"), - }; - fixture_command(root, "git", &["add", "--all"]); - fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); - fs::write(root.join("modified"), "staged\n").unwrap(); - fixture_command(root, "git", &["add", "--", "modified"]); - fs::write(root.join("modified"), "unstaged\n").unwrap(); - fs::write(root.join("binary"), "after\0binary").unwrap(); - if let Some(name) = &non_utf8 { - fs::write(root.join(name), "after\n").unwrap(); - } - fs::remove_file(root.join("removed")).unwrap(); - let renamed = "-new [file]\n\t\".txt"; - fs::rename(root.join("old"), root.join(renamed)).unwrap(); - fs::remove_file(root.join("duplicate-one")).unwrap(); - fs::remove_file(root.join("duplicate-two")).unwrap(); - fs::write(root.join("new-duplicate-one"), "duplicate\n").unwrap(); - fs::write(root.join("new-duplicate-two"), "duplicate\n").unwrap(); - fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap(); - fs::write(root.join("added"), "added\n").unwrap(); - fs::write(root.join("ignored"), "ignored\n").unwrap(); - fs::write(root.join("ignored-tracked"), "forced\n").unwrap(); - fixture_command(root, "git", &["add", "-f", "--", "ignored-tracked"]); - std::os::unix::fs::symlink(root.join("modified"), root.join("link")).unwrap(); - let index = fs::read(root.join(".git/index")).unwrap(); - let head = fs::read(root.join(".git/HEAD")).unwrap(); - let disk = changes_by_path(root, &[]); - for name in ["modified", "binary", "mode"] { - assert_eq!(disk[Path::new(name)].status, FileStatus::Modified); - } - if let Some(name) = &non_utf8 { - assert_eq!(disk[name].status, FileStatus::Modified); - } - assert_eq!(disk[Path::new("removed")].status, FileStatus::Removed); - assert_eq!(disk[Path::new(renamed)].status, FileStatus::Renamed); - assert_eq!( - disk[Path::new(renamed)].old_path.as_deref(), - Some(Path::new("old")) - ); - for name in [ - "added", - "ignored-tracked", - "new-duplicate-one", - "new-duplicate-two", - ] { - assert_eq!(disk[Path::new(name)].status, FileStatus::Added); - } - for name in ["duplicate-one", "duplicate-two"] { - assert_eq!(disk[Path::new(name)].status, FileStatus::Removed); - } - for name in ["unchanged", "old", "ignored", "link"] { - assert!(!disk.contains_key(Path::new(name)), "{name}"); - } - let overlays = changes_by_path( - root, - &[ - (root.join("modified"), "original\n".into()), - (root.join("unchanged"), "dirty\n".into()), - (root.join("removed"), "deleted\n".into()), - (root.join(renamed), "dirty renamed\n".into()), - (root.join("mode"), "mode\n".into()), - (root.join("ignored-buffer"), "ignored\n".into()), - ], - ); - assert!(!overlays.contains_key(Path::new("modified"))); - assert!(!overlays.contains_key(Path::new("ignored-buffer"))); - assert_eq!( - overlays[Path::new("unchanged")].status, - FileStatus::Modified - ); - assert_eq!(overlays[Path::new("removed")].status, FileStatus::Removed); - assert_eq!(overlays[Path::new(renamed)].status, FileStatus::Renamed); - assert_eq!(overlays[Path::new("mode")].status, FileStatus::Modified); - let aliases = Temporary::new().unwrap(); - let alias = aliases.0.join("checkout"); - std::os::unix::fs::symlink(root, &alias).unwrap(); - let aliased = changes_by_path(root, &[(alias.join("unchanged"), "dirty\n".into())]); - assert_eq!(aliased[Path::new("unchanged")].status, FileStatus::Modified); - fixture_command(root, "git", &["config", "core.filemode", "0"]); - assert!( - !changes_by_path(root, &[(root.join("mode"), "mode\n".into())]) - .contains_key(Path::new("mode")) - ); - fixture_command(root, "git", &["config", "core.autocrlf", "1"]); - fs::write(root.join("unchanged"), "same\r\n").unwrap(); - assert!(!changes_by_path(root, &[]).contains_key(Path::new("unchanged"))); - fixture_command(root, "git", &["config", "--unset", "core.autocrlf"]); - fixture_command(root, "git", &["config", "core.filemode", "true"]); - fs::write(root.join("unchanged"), "same\n").unwrap(); - assert_eq!(fs::read(root.join(".git/index")).unwrap(), index); - assert_eq!(fs::read(root.join(".git/HEAD")).unwrap(), head); - assert_eq!( - fs::read_to_string(root.join("modified")).unwrap(), - "unstaged\n" - ); - assert!(!root.join("removed").exists()); - } - - #[test] - fn working_git_subdirectory_cross_boundary_moves_worktrees_and_unsafe_roots() { - let temp = picker_git_fixture(); - let root = &temp.0; - fs::create_dir(root.join("sub")).unwrap(); - fs::write(root.join("sub/outgoing"), "out\n").unwrap(); - fs::write(root.join("incoming"), "in\n").unwrap(); - fixture_command(root, "git", &["add", "--all"]); - fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); - fs::rename(root.join("sub/outgoing"), root.join("outgoing")).unwrap(); - fs::rename(root.join("incoming"), root.join("sub/incoming")).unwrap(); - let changes = changes_by_path(&root.join("sub"), &[]); - assert_eq!(changes.len(), 2); - assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Removed); - assert_eq!(changes[Path::new("incoming")].status, FileStatus::Added); - assert_eq!(changes[Path::new("incoming")].old_path, None); - std::os::unix::fs::symlink(root.join("sub"), root.join("linked")).unwrap(); - assert!(changes_by_path(&root.join("linked"), &[]).is_empty()); - assert!(changes_by_path(&root.join(".git"), &[]).is_empty()); - let unrelated = Temporary::new().unwrap(); - assert!(changes_by_path(&unrelated.0, &[]).is_empty()); - let worktree = temp.0.join("worktree"); - fixture_command( - root, - "git", - &["worktree", "add", "--quiet", "--detach", "worktree", "HEAD"], - ); - fs::write(worktree.join("sub/outgoing"), "worktree edit\n").unwrap(); - let changes = changes_by_path(&worktree.join("sub"), &[]); - assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Modified); - } - - #[test] - fn working_unborn_git_never_runs_filters_external_diff_textconv_or_hooks() { - let temp = picker_git_fixture(); - let root = &temp.0; - fs::write(root.join(".gitignore"), "ignored\n").unwrap(); - fs::write(root.join("tracked"), "tracked\n").unwrap(); - fs::write(root.join("untracked"), "untracked\n").unwrap(); - fs::write(root.join("ignored"), "ignored\n").unwrap(); - fixture_command(root, "git", &["add", "--", ".gitignore", "tracked"]); - let changes = changes_by_path(root, &[(root.join("named"), "not saved\n".into())]); - for name in [".gitignore", "tracked", "untracked", "named"] { - assert_eq!(changes[Path::new(name)].status, FileStatus::Added); - } - assert!(!changes.contains_key(Path::new("ignored"))); - assert!(!root.join("named").exists()); - fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); - fs::write( - root.join(".gitattributes"), - "tracked filter=evil diff=evil\n", - ) - .unwrap(); - let evil = "sh -c 'touch executed; cat'"; - for key in [ - "filter.evil.clean", - "filter.evil.process", - "diff.evil.command", - "diff.evil.textconv", - ] { - fixture_command(root, "git", &["config", key, evil]); - } - fixture_command(root, "git", &["config", "filter.evil.required", "true"]); - fs::write(root.join("tracked"), "changed\n").unwrap(); - assert_eq!( - changes_by_path(root, &[])[Path::new("tracked")].status, - FileStatus::Modified - ); - assert_eq!( - changes_by_path(root, &[(root.join("tracked"), "tracked\n".into())]) - .get(Path::new("tracked")), - None - ); - assert!(!root.join("executed").exists()); - assert!( - working_file_changes(root, &[], &|| true) - .unwrap_err() - .contains("cancelled") - ); - assert!( - working_file_changes( - root, - &[(root.join("tracked"), "x".repeat(FILE_LIMIT + 1).into())], - &|| false - ) - .unwrap_err() - .contains("16 MiB") - ); - } - - #[test] - fn working_jj_root_parent_colocation_and_merge_queries_do_not_snapshot() { - for colocate in ["--colocate", "--no-colocate"] { - let temp = Temporary::new().unwrap(); - let root = &temp.0; - fixture_command(root, "jj", &["git", "init", colocate, "."]); - fs::write(root.join("file"), "baseline\n").unwrap(); - assert_eq!( - changes_by_path(root, &[])[Path::new("file")].status, - FileStatus::Added - ); - fs::write(root.join(".gitignore"), "ignored\n").unwrap(); - fixture_command(root, "jj", &["describe", "-m", "Baseline"]); - fixture_command(root, "jj", &["new"]); - let operation = fixture_command( - root, - "jj", - &[ - "--ignore-working-copy", - "op", - "log", - "--no-graph", - "-n", - "1", - "-T", - "id", - ], - ); - let parent = fixture_command( - root, - "jj", - &[ - "--ignore-working-copy", - "log", - "--no-graph", - "-r", - "@-", - "-T", - "commit_id", - ], - ); - let store = store_for_jj(root).unwrap(); - let head = fs::read(store.join("HEAD")).unwrap(); - let index = fs::read(store.join("index")).ok(); - fs::write(root.join("file"), "disk\n").unwrap(); - fs::write(root.join("new"), "new\n").unwrap(); - fs::write(root.join("ignored"), "ignored\n").unwrap(); - let changes = changes_by_path(root, &[]); - assert_eq!(changes[Path::new("file")].status, FileStatus::Modified); - assert_eq!(changes[Path::new("new")].status, FileStatus::Added); - assert!(!changes.contains_key(Path::new("ignored"))); - assert!( - !changes_by_path(root, &[(root.join("file"), "baseline\n".into())]) - .contains_key(Path::new("file")) - ); - assert_eq!(fs::read(store.join("HEAD")).unwrap(), head); - assert_eq!(fs::read(store.join("index")).ok(), index); - assert_eq!( - fixture_command( - root, - "jj", - &[ - "--ignore-working-copy", - "op", - "log", - "--no-graph", - "-n", - "1", - "-T", - "id" - ] - ), - operation - ); - fixture_command(root, "jj", &["new", "root()"]); - fs::write(root.join("sibling"), "sibling\n").unwrap(); - fixture_command(root, "jj", &["describe", "-m", "Sibling"]); - fixture_command(root, "jj", &["new", &parent, "@"]); - assert!(changes_by_path(root, &[]).is_empty()); - } - } - - #[test] - fn working_jj_conflicted_parent_uses_cli_projection_without_snapshotting() { - for colocate in ["--colocate", "--no-colocate"] { - let temp = Temporary::new().unwrap(); - let root = &temp.0; - fixture_command(root, "jj", &["git", "init", colocate, "."]); - fs::write(root.join("conflicted"), "base\n").unwrap(); - fixture_command(root, "jj", &["describe", "-m", "Base"]); - let base = fixture_command( - root, - "jj", - &[ - "--ignore-working-copy", - "log", - "--no-graph", - "-r", - "@", - "-T", - "commit_id", - ], - ); - fixture_command(root, "jj", &["new"]); - fs::write(root.join("conflicted"), "left\n").unwrap(); - fixture_command(root, "jj", &["describe", "-m", "Left"]); - let left = fixture_command( - root, - "jj", - &[ - "--ignore-working-copy", - "log", - "--no-graph", - "-r", - "@", - "-T", - "commit_id", - ], - ); - fixture_command(root, "jj", &["new", &base]); - fs::write(root.join("conflicted"), "right\n").unwrap(); - fixture_command(root, "jj", &["describe", "-m", "Right"]); - let right = fixture_command( - root, - "jj", - &[ - "--ignore-working-copy", - "log", - "--no-graph", - "-r", - "@", - "-T", - "commit_id", - ], - ); - fixture_command(root, "jj", &["new", &left, &right]); - fixture_command(root, "jj", &["new"]); - let projected = fs::read_to_string(root.join("conflicted")).unwrap(); - assert!(projected.contains("<<<<<<<")); - let operation = fixture_command( - root, - "jj", - &[ - "--ignore-working-copy", - "op", - "log", - "--no-graph", - "-n", - "1", - "-T", - "id", - ], - ); - let mut reader = WorkingFileReader::default(); - assert!(reader.read(root, &[], &|| false).unwrap().is_empty()); - fs::write(root.join("conflicted"), "resolved in working file\n").unwrap(); - let changes = reader.read(root, &[], &|| false).unwrap(); - assert_eq!(changes.len(), 1); - assert_eq!(changes[0].path, Path::new("conflicted")); - assert_eq!(changes[0].status, FileStatus::Modified); - assert!( - reader - .read( - root, - &[(root.join("conflicted"), projected.into())], - &|| false - ) - .unwrap() - .is_empty() - ); - assert_eq!( - fs::read_to_string(root.join("conflicted")).unwrap(), - "resolved in working file\n" - ); - assert_eq!( - fixture_command( - root, - "jj", - &[ - "--ignore-working-copy", - "op", - "log", - "--no-graph", - "-n", - "1", - "-T", - "id" - ] - ), - operation - ); - } - } - - #[test] - fn cancellation_kills_pipe_holding_children_and_drains_both_streams() { - let temp = Temporary::new().unwrap(); - let ready = temp.0.join("ready"); - let cancelled = Arc::new(AtomicBool::new(false)); - let worker_cancelled = Arc::clone(&cancelled); - let gate = ready.clone(); - let (sent, received) = mpsc::channel(); - let worker = thread::spawn(move || { - let mut command = Command::new("sh"); - command - .args([ - "-c", - "sleep 600 & printf ready > \"$1\"; wait", - "vamp-diff-test", - ]) - .arg(gate); - let result = run(command, vec![], &worker_cancelled).map(|_| ()); - sent.send(result).unwrap(); - }); - // File handshake, not a wall-clock scheduling assumption. - while !ready.exists() { - if let Ok(result) = received.try_recv() { - panic!("Process exited before the child was ready: {result:?}"); - } - thread::yield_now(); - } - cancelled.store(true, Ordering::Release); - assert!(received.recv().unwrap().unwrap_err().contains("cancelled")); - worker.join().unwrap(); - - let mut command = Command::new("sh"); - command.args(["-c", "i=0; while [ \"$i\" -lt 10000 ]; do printf 'line\\n'; printf 'error\\n' >&2; i=$((i+1)); done"]); - let result = run(command, vec![], &AtomicBool::new(false)).unwrap(); - assert_eq!(result.code, Some(0)); - assert_eq!(result.stdout.len(), 50_000); - assert_eq!(result.stderr.len(), 60_000); - } -} +#[path = "../tests/unit/repository.rs"] +mod tests; diff --git a/src/resolve.rs b/src/resolve.rs index 4f1ff5c..21167bb 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -1050,478 +1050,9 @@ pub fn project(text: &str, source: &Source) -> Result { } #[cfg(all(test, unix))] -mod tests { - use super::*; - struct Fixture(PathBuf); - impl Fixture { - fn new() -> Self { - let path = std::env::temp_dir().join(format!( - "vamp-resolver-test-{}-{}", - std::process::id(), - NEXT.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir(&path).unwrap(); - Self(path) - } - fn jj(&self, args: &[&str]) -> String { - let output = Command::new("jj") - .current_dir(&self.0) - .env("JJ_CONFIG", "/dev/null") - .args([ - "--config=user.name=Resolver", - "--config=user.email=resolver@example.test", - "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", - "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", - ]) - .args(args) - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).unwrap() - } - fn conflict(&self) { - self.jj(&["git", "init", "--no-colocate", "."]); - self.jj(&["config", "set", "--repo", "user.name", "Resolver"]); - self.jj(&[ - "config", - "set", - "--repo", - "user.email", - "resolver@example.test", - ]); - self.jj(&[ - "config", - "set", - "--repo", - "debug.commit-timestamp", - "\"2001-01-01T00:00:00Z\"", - ]); - self.jj(&[ - "config", - "set", - "--repo", - "debug.operation-timestamp", - "\"2001-01-01T00:00:00Z\"", - ]); - fs::write(self.0.join("file"), "base\n").unwrap(); - self.jj(&["bookmark", "create", "base"]); - self.jj(&["new", "base"]); - fs::write(self.0.join("file"), "left\n").unwrap(); - self.jj(&["bookmark", "create", "left"]); - self.jj(&["new", "base"]); - fs::write(self.0.join("file"), "right\n").unwrap(); - self.jj(&["bookmark", "create", "right"]); - self.jj(&["new", "left", "right"]); - } - } - impl Drop for Fixture { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - #[test] - fn discovery_is_readonly_and_save_uses_jj_authority_and_stale_guards() { - let fixture = Fixture::new(); - fixture.conflict(); - let operation = fixture.jj(&[ - "--ignore-working-copy", - "op", - "log", - "--no-graph", - "-n", - "1", - "-T", - "id", - ]); - let workspace = load(&fixture.0, &|| false).unwrap(); - assert_eq!(workspace.files.len(), 1); - let file = &workspace.files[0]; - assert!(file.sources.iter().any(|s| s.base && s.bytes == b"base\n")); - assert!(file.sources.iter().any(|s| !s.base && s.bytes == b"left\n")); - assert!( - file.sources - .iter() - .any(|s| !s.base && s.bytes == b"right\n") - ); - for bookmark in ["base", "left", "right"] { - assert!( - file.sources - .iter() - .any(|s| s.label.contains(&format!("[{bookmark}]"))) - ); - } - assert_eq!( - operation, - fixture.jj(&[ - "--ignore-working-copy", - "op", - "log", - "--no-graph", - "-n", - "1", - "-T", - "id" - ]) - ); - let blocks = regions(file.result.as_ref().unwrap()); - assert_eq!(blocks.len(), 1); - for source in &file.sources { - assert_eq!(blocks[0].choose(source).unwrap().as_bytes(), source.bytes); - } - let mut changed = blocks[0].clone(); - changed.sides.pop(); - assert!(changed.choose(&file.sources[0]).is_err()); - fs::write(fixture.0.join("file"), "external\n").unwrap(); - assert!( - save(&workspace, 0, Resolution::Text("lost\n".into()), &|| false) - .unwrap_err() - .contains("externally") - ); - fs::write(fixture.0.join("file"), file.result.as_ref().unwrap()).unwrap(); - fixture.jj(&["new", "base"]); - let before = fs::read(fixture.0.join("file")).unwrap(); - assert!( - save( - &workspace, - 0, - Resolution::Text("wrong checkout\n".into()), - &|| false - ) - .unwrap_err() - .contains("checkout changed") - ); - assert_eq!(fs::read(fixture.0.join("file")).unwrap(), before); - } - #[test] - fn text_choice_is_confirmed_by_jj() { - let fixture = Fixture::new(); - fixture.conflict(); - let mut workspace = load(&fixture.0, &|| false).unwrap(); - let cancelled_after_write = - || fs::read(fixture.0.join("file")).is_ok_and(|bytes| bytes == b"draft\n"); - let saved = save( - &workspace, - 0, - Resolution::Text("draft\n".into()), - &cancelled_after_write, - ) - .unwrap(); - assert_eq!(saved.file.status, Status::Unconfirmed); - assert!(saved.confirmation.is_some()); - assert_eq!(fs::read(fixture.0.join("file")).unwrap(), b"draft\n"); - assert_eq!( - entries(&fixture.0, &workspace.checkout.commit, &|| false).unwrap(), - [PathBuf::from("file")] - ); - workspace.checkout = saved.checkout; - workspace.files[0] = saved.file; - let outcome = save( - &workspace, - 0, - Resolution::Text("resolved\n".into()), - &|| false, - ) - .unwrap(); - assert_eq!( - outcome.file.status, - Status::Resolved, - "{:?}", - outcome.confirmation - ); - assert_eq!(fs::read(fixture.0.join("file")).unwrap(), b"resolved\n"); - assert!( - entries(&fixture.0, &outcome.checkout.commit, &|| false) - .unwrap() - .is_empty() - ); - } - #[test] - fn all_three_sides_and_bases_are_loaded_and_directory_children_are_protected() { - let fixture = Fixture::new(); - fixture.conflict(); - fixture.jj(&["new", "base"]); - fs::write(fixture.0.join("file"), "third\n").unwrap(); - fixture.jj(&["bookmark", "create", "third"]); - fixture.jj(&["new", "left", "right", "third"]); - let workspace = load(&fixture.0, &|| false).unwrap(); - let file = &workspace.files[0]; - assert_eq!(file.sources.iter().filter(|s| !s.base).count(), 3); - assert_eq!(file.sources.iter().filter(|s| s.base).count(), 2); - assert!(file.sources.iter().any(|s| s.bytes == b"third\n")); - let blocks = regions(file.result.as_ref().unwrap()); - for source in &file.sources { - assert_eq!(blocks[0].choose(source).unwrap().as_bytes(), source.bytes); - } - fs::remove_file(fixture.0.join("file")).unwrap(); - fs::create_dir(fixture.0.join("file")).unwrap(); - fs::write(fixture.0.join("file/untracked"), b"keep my draft").unwrap(); - let workspace = load(&fixture.0, &|| false).unwrap(); - assert!( - save(&workspace, 0, Resolution::Source(0), &|| false) - .unwrap_err() - .contains("untracked or modified children") - ); - assert_eq!( - fs::read(fixture.0.join("file/untracked")).unwrap(), - b"keep my draft" - ); - } - #[test] - fn binary_symlink_and_deleted_file_choices_preserve_types_and_bytes() { - let fixture = Fixture::new(); - fixture.conflict(); - fixture.jj(&["edit", "left"]); - fs::write(fixture.0.join("binary"), b"left\0\xff").unwrap(); - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(fixture.0.join("binary"), fs::Permissions::from_mode(0o755)) - .unwrap(); - } - std::os::unix::fs::symlink("left-target", fixture.0.join("link")).unwrap(); - fixture.jj(&["new", "right"]); - fs::write(fixture.0.join("binary"), b"right\0\xfe").unwrap(); - std::os::unix::fs::symlink("right-target", fixture.0.join("link")).unwrap(); - fixture.jj(&["bookmark", "create", "right-extra"]); - fixture.jj(&["new", "left", "right-extra"]); - let mut workspace = load(&fixture.0, &|| false).unwrap(); - for (name, kind, bytes) in [ - ("binary", FileKind::Binary, b"left\0\xff".as_slice()), - ("link", FileKind::Symlink, b"left-target".as_slice()), - ] { - let file = workspace - .files - .iter() - .position(|f| f.path == Path::new(name)) - .unwrap(); - assert!(workspace.files[file].result.is_none()); - let source = workspace.files[file] - .sources - .iter() - .position(|s| !s.base && s.kind == kind && s.bytes == bytes) - .unwrap(); - assert!( - save( - &workspace, - file, - Resolution::Text("not allowed".into()), - &|| false - ) - .is_err() - ); - let outcome = save(&workspace, file, Resolution::Source(source), &|| false).unwrap(); - assert_eq!( - outcome.file.status, - Status::Resolved, - "{:?}", - outcome.confirmation - ); - workspace.checkout = outcome.checkout; - workspace.files[file] = outcome.file; - if kind == FileKind::Symlink { - assert_eq!( - fs::read_link(fixture.0.join(name)).unwrap(), - Path::new("left-target") - ); - } else { - assert_eq!(fs::read(fixture.0.join(name)).unwrap(), bytes); - } - if kind == FileKind::Binary { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - fs::metadata(fixture.0.join(name)) - .unwrap() - .permissions() - .mode() - & 0o111, - 0o111 - ); - } - } - // add/add conflicts retain an absent base as an explicit deletion choice. - let file = workspace - .files - .iter() - .position(|f| f.path == Path::new("binary")) - .unwrap(); - let source = workspace.files[file] - .sources - .iter() - .position(|s| s.kind == FileKind::Deleted) - .unwrap(); - let outcome = save(&workspace, file, Resolution::Source(source), &|| false).unwrap(); - assert_eq!( - outcome.file.status, - Status::Resolved, - "{:?}", - outcome.confirmation - ); - assert!(!fixture.0.join("binary").exists()); - } - #[test] - fn marker_styles_widths_multisides_and_missing_eol() { - let snapshot = "before\n<<<<<<<<<<< conflict 1 of 1\n+++++++++++ a\nleft\n----------- base\nold\n+++++++++++ b\nright\n----------- base2\nold2\n+++++++++++ c\nthird\n>>>>>>>>>>> conflict 1 of 1 ends\nafter\n"; - let parsed = regions(snapshot); - assert_eq!(parsed[0].lines, 1..13); - assert_eq!(parsed[0].sides, ["left\n", "right\n", "third\n"]); - assert_eq!(parsed[0].bases, ["old\n", "old2\n"]); - let prefixed = format!("<<<<<<< literal marker\n{snapshot}"); - assert_eq!(regions(&prefixed).len(), 1); - assert_eq!(regions(&prefixed)[0].lines, 2..14); - let diff = "<<<<<<< conflict 1 of 1\n%%%%%%% diff from: base\n^^^^^^^ to: left\n-old\n+left\n+++++++ right\nright\n>>>>>>> conflict 1 of 1 ends\n"; - assert_eq!(regions(diff)[0].sides, ["left\n", "right\n"]); - let git = "<<<<<<< left\nleft\n||||||| base\nold\n=======\nright\n>>>>>>> right"; - assert_eq!(regions(git)[0].sides, ["left", "right"]); - assert!(regions("<<<<<<< unfinished\nleft\n").is_empty()); - } -} +#[path = "../tests/unit/resolve.rs"] +mod tests; #[cfg(test)] -mod projection_tests { - use super::*; - - fn test_sources(sides: usize) -> Vec { - (0..sides * 2 - 1) - .map(|index| Source { - label: format!("source {index}"), - base: index % 2 == 1, - kind: FileKind::Text, - bytes: b"historical full file; must never replace working text\n".to_vec(), - tree_id: String::new(), - term: index / 2, - sides, - bases: sides - 1, - node: Node::Absent, - }) - .collect() - } - - fn snapshot(left: &str, base: &str, right: &str) -> String { - format!( - "<<<<<<< conflict\n+++++++ side 1\n{left}------- base\n{base}+++++++ side 2\n{right}>>>>>>> conflict ends\n" - ) - } - - #[test] - fn current_working_projections_keep_partial_resolutions_and_multiple_regions() { - let sources = test_sources(2); - let raw = format!( - "unsaved heading\nalready manually resolved\n{}auto-merged middle\n<<<<<<< conflict\n%%%%%%% Changes from base to side 1\n\\\\\\\\\\\\\\ continuation\n-old two\n+left two\n+++++++ side 2\nright two\n>>>>>>> conflict ends\nunsaved tail", - snapshot("left one\n", "old one\n", "right one\n") - ); - for (index, first, second) in [ - (0, "left one", "left two"), - (1, "old one", "old two"), - (2, "right one", "right two"), - ] { - assert_eq!( - project(&raw, &sources[index]).unwrap(), - format!( - "unsaved heading\nalready manually resolved\n{first}\nauto-merged middle\n{second}\nunsaved tail" - ) - ); - } - } - - #[test] - fn alternatives_are_frozen_from_the_same_working_text_as_the_seed() { - let sources = test_sources(2); - let raw = format!( - "unsaved heading\n{}auto-merged middle\n{}unsaved tail", - snapshot("left-one\n", "old-one\n", "right-one\n"), - snapshot("left-two\n", "old-two\n", "right-two\n") - ); - let projection = Projection::new(&raw, &sources, 0).unwrap(); - let mut draft = projection.seed().to_owned(); - draft = draft.replace("unsaved heading", "later draft edit"); - draft.push_str("\nnew draft ending\n"); - assert_eq!( - projection.alternative(2).unwrap(), - "unsaved heading\nright-one\nauto-merged middle\nright-two\nunsaved tail" - ); - assert_eq!( - projection.alternative(1).unwrap(), - "unsaved heading\nold-one\nauto-merged middle\nold-two\nunsaved tail" - ); - assert_eq!( - projection.seed(), - "unsaved heading\nleft-one\nauto-merged middle\nleft-two\nunsaved tail" - ); - assert_eq!( - draft, - "later draft edit\nleft-one\nauto-merged middle\nleft-two\nunsaved tail\nnew draft ending\n" - ); - } - - #[test] - fn all_multiway_sides_and_bases_project_the_same_current_context() { - let sources = test_sources(3); - let raw = "unsaved α\n<<<<<<<<<<< conflict\n+++++++++++ side 1\none\n----------- base 1\nold one\n+++++++++++ side 2\ntwo\n----------- base 2\nold two\n+++++++++++ side 3\nthree\n>>>>>>>>>>> conflict ends\nunsaved ω"; - let projection = Projection::new(raw, &sources, 0).unwrap(); - for (index, choice) in ["one", "old one", "two", "old two", "three"] - .into_iter() - .enumerate() - { - let expected = format!("unsaved α\n{choice}\nunsaved ω"); - assert_eq!(project(raw, &sources[index]).unwrap(), expected); - assert_eq!(projection.alternative(index).unwrap(), expected); - } - assert_eq!( - Projection::new(raw, &sources, 4).unwrap().seed(), - "unsaved α\nthree\nunsaved ω" - ); - } - - #[test] - fn projections_preserve_mixed_eof_and_crlf_terms() { - let sources = test_sources(2); - for eol in ["\n", "\r\n"] { - let choices = [ - format!("left{eol}"), - "old".to_owned(), - format!("right{eol}{eol}"), - ]; - // jj appends one separator EOL to *each* term when any lacks an EOL, - // and omits the EOL on the final marker. Existing term EOLs remain. - let raw = format!( - "heading{eol}<<<<<<< conflict{eol}+++++++ side 1{eol}{}{eol}------- base{eol}{}{eol}+++++++ side 2{eol}{}{eol}>>>>>>> conflict ends", - choices[0], choices[1], choices[2] - ); - let projection = Projection::new(&raw, &sources, 0).unwrap(); - for (index, choice) in choices.iter().enumerate() { - assert_eq!( - project(&raw, &sources[index]).unwrap(), - format!("heading{eol}{choice}") - ); - assert_eq!( - projection.alternative(index).unwrap(), - format!("heading{eol}{choice}") - ); - } - } - } - - #[test] - fn empty_choices_markerfree_text_and_invalid_choices_are_handled() { - let mut sources = test_sources(2); - sources[1].kind = FileKind::Deleted; - let raw = format!("before\n{}after\n", snapshot("", "", "new\n")); - let projection = Projection::new(&raw, &sources, 0).unwrap(); - assert_eq!(projection.seed(), "before\nafter\n"); - assert_eq!(projection.alternative(1).unwrap(), "before\nafter\n"); - assert_eq!(projection.alternative(2).unwrap(), "before\nnew\nafter\n"); - let resolved = Projection::new("manually resolved, no final EOL", &sources, 0).unwrap(); - assert_eq!( - resolved.alternative(2).unwrap(), - "manually resolved, no final EOL" - ); - assert!(resolved.alternative(sources.len()).is_err()); - assert!(Projection::new(&raw, &sources, sources.len()).is_err()); - assert!(Projection::new(&raw, &test_sources(3), 0).is_err()); - sources[0].kind = FileKind::Binary; - assert!(project(&raw, &sources[0]).is_err()); - } -} +#[path = "../tests/unit/resolve_projection.rs"] +mod projection_tests; diff --git a/src/review.rs b/src/review.rs index 73d23fe..6ac20cf 100644 --- a/src/review.rs +++ b/src/review.rs @@ -579,523 +579,5 @@ fn check_file(path: &Path, expected: Option<&[u8]>) -> Result<(), String> { } #[cfg(test)] -mod tests { - use super::*; - - struct Directory(PathBuf); - impl Drop for Directory { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - fn directory() -> Directory { - let directory = Directory(std::env::temp_dir().join(format!( - "vamp-review-test-{}-{}", - std::process::id(), - NEXT_TEMP.fetch_add(1, Ordering::Relaxed), - ))); - fs::create_dir(&directory.0).unwrap(); - directory - } - - #[test] - fn database_restarts_preserve_identity_and_exact_stale_anchors() { - let directory = directory(); - let root = directory.0.canonicalize().unwrap(); - let storage = root.join("storage"); - let mut review = Review::new(root.clone()); - review.summary = "Unicode review\nMore context".into(); - review.verdict = Some(Verdict::ChangesRequested); - review.comments.push(Comment { - id: 7, - anchor: Anchor { - revision: "working:old-snapshot".into(), - path: "日本語.rs".into(), - side: Side::Before, - start: crate::plugin::TextPosition { row: 0, column: 1 }, - end: crate::plugin::TextPosition { row: 1, column: 1 }, - content: "a👩‍🔬e\u{301}\r\n界x\n".into(), - }, - body: "Please preserve 👩‍🔬".into(), - resolved: true, - }); - { - let mut store = Store::open(&storage).unwrap(); - store.save(&mut review).unwrap(); - assert!(review.storage_id.is_some()); - assert_eq!(review.storage_revision, 1); - } - let mut store = Store::open(&storage).unwrap(); - let id = review.storage_id.unwrap(); - assert_eq!( - store.list(&root).unwrap(), - vec![(id, "Unicode review".into())] - ); - let mut loaded = store.load(&root, id).unwrap(); - assert_eq!(loaded.storage_id, Some(id)); - assert_eq!(loaded.storage_revision, 1); - assert_eq!(loaded.comments, review.comments); - assert_eq!(loaded.verdict, review.verdict); - assert!(!loaded.comments[0].anchor.matches( - "working:new-snapshot", - Path::new("日本語.rs"), - Side::Before, - "changed\n", - )); - let path = root.join("export.json"); - loaded.save(&path).unwrap(); - let exported = fs::read(&path).unwrap(); - let json: serde_json::Value = serde_json::from_slice(&exported).unwrap(); - assert_eq!(json["comments"][0]["resolved"], true); - assert_eq!(json["version"], 3); - assert!(json.get("storage_id").is_none()); - assert!(json.get("storage_revision").is_none()); - loaded.summary = "Database only".into(); - store.save(&mut loaded).unwrap(); - assert_eq!(loaded.storage_id, Some(id)); - assert_eq!(loaded.storage_revision, 2); - assert_eq!(fs::read(&path).unwrap(), exported); - let mut imported = Review::load(&path, &root).unwrap(); - assert_eq!(imported.storage_id, None); - assert_eq!(imported.storage_revision, 0); - store.save(&mut imported).unwrap(); - assert_ne!(imported.storage_id, Some(id)); - assert_eq!(store.list(&root).unwrap().len(), 2); - } - - #[test] - fn database_repository_identity_is_canonical_and_independent() { - let first = directory(); - let second = directory(); - let first_root = first.0.canonicalize().unwrap(); - let second_root = second.0.canonicalize().unwrap(); - let mut store = Store::open(&first_root.join("storage")).unwrap(); - let mut a = Review::new(first_root.clone()); - let mut b = Review::new(second_root.clone()); - store.save(&mut a).unwrap(); - store.save(&mut b).unwrap(); - assert_ne!(a.storage_id, b.storage_id); - assert_eq!(store.list(&first_root).unwrap().len(), 1); - assert_eq!(store.list(&second_root).unwrap().len(), 1); - assert!(store.load(&second_root, a.storage_id.unwrap()).is_err()); - let id = a.storage_id; - let revision = a.storage_revision; - a.root = second_root; - assert!(store.save(&mut a).is_err()); - assert_eq!((a.storage_id, a.storage_revision), (id, revision)); - #[cfg(unix)] - { - let alias = first_root.join("alias"); - std::os::unix::fs::symlink(&first_root, &alias).unwrap(); - assert_eq!( - store.list(&alias).unwrap(), - store.list(&first_root).unwrap() - ); - } - } - - #[test] - fn database_conflicts_and_locked_writes_keep_drafts_and_identity() { - let directory = directory(); - let root = directory.0.canonicalize().unwrap(); - let storage = root.join("storage"); - let mut first = Store::open(&storage).unwrap(); - let mut second = Store::open(&storage).unwrap(); - let mut review = Review::new(root.clone()); - first.save(&mut review).unwrap(); - let id = review.storage_id.unwrap(); - let mut draft = second.load(&root, id).unwrap(); - review.summary = "First writer".into(); - first.save(&mut review).unwrap(); - draft.summary = "Second writer draft".into(); - assert!( - second - .save(&mut draft) - .unwrap_err() - .contains("another session") - ); - assert_eq!(draft.summary, "Second writer draft"); - assert_eq!((draft.storage_id, draft.storage_revision), (Some(id), 1)); - assert_eq!(second.load(&root, id).unwrap().summary, "First writer"); - first.connection.execute_batch("BEGIN IMMEDIATE").unwrap(); - let mut fresh = Review::new(root.clone()); - assert!(second.save(&mut fresh).unwrap_err().contains("locked")); - assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0)); - first.connection.execute_batch("ROLLBACK").unwrap(); - second.save(&mut fresh).unwrap(); - assert!(fresh.storage_id.is_some()); - } - - #[test] - fn incompatible_database_schemas_are_preserved() { - for schema in [ - "CREATE TABLE other (value TEXT); PRAGMA user_version = 42", - "CREATE TABLE other (value TEXT); PRAGMA user_version = 1", - "", - ] { - let directory = directory(); - let path = directory.0.join("reviews.sqlite3"); - let connection = Connection::open(&path).unwrap(); - connection.execute_batch(schema).unwrap(); - drop(connection); - let original = fs::read(&path).unwrap(); - assert!(Store::open(&directory.0).err().unwrap().contains("schema")); - assert_eq!(fs::read(&path).unwrap(), original); - } - } - - #[test] - fn malformed_or_mismatched_database_documents_are_rejected() { - let other = directory(); - let directory = directory(); - let root = directory.0.canonicalize().unwrap(); - let mut store = Store::open(&root.join("storage")).unwrap(); - let mut review = Review::new(root.clone()); - store.save(&mut review).unwrap(); - let id = review.storage_id.unwrap(); - let mut wrong_root = serde_json::to_value(&review).unwrap(); - wrong_root["root"] = serde_json::json!(other.0.canonicalize().unwrap()); - let mut invalid_version = serde_json::to_value(&review).unwrap(); - invalid_version["version"] = 1.into(); - let mut inherited_identity = serde_json::to_value(&review).unwrap(); - inherited_identity["storage_id"] = id.into(); - for document in [ - "{".to_owned(), - serde_json::to_string(&wrong_root).unwrap(), - serde_json::to_string(&invalid_version).unwrap(), - serde_json::to_string(&inherited_identity).unwrap(), - ] { - store - .connection - .execute( - "UPDATE reviews SET document = ?1 WHERE id = ?2", - params![document, id], - ) - .unwrap(); - assert!(store.load(&root, id).is_err()); - assert!(store.list(&root).is_err()); - } - store - .connection - .execute_batch("PRAGMA ignore_check_constraints = ON") - .unwrap(); - store - .connection - .execute( - "UPDATE reviews SET document = CAST(zeroblob(?1) AS TEXT) WHERE id = ?2", - params![LIMIT as i64 + 1, id], - ) - .unwrap(); - assert!(store.load(&root, id).unwrap_err().contains("64 MiB")); - assert!(store.list(&root).unwrap_err().contains("64 MiB")); - } - - #[test] - fn failed_database_and_export_writes_preserve_metadata_and_documents() { - let directory = directory(); - let root = directory.0.canonicalize().unwrap(); - let mut store = Store::open(&root.join("storage")).unwrap(); - let mut review = Review::new(root.clone()); - store.save(&mut review).unwrap(); - let identity = (review.storage_id, review.storage_revision); - store - .connection - .execute_batch("PRAGMA query_only = ON") - .unwrap(); - review.summary = "Unsaved draft".into(); - assert!(store.save(&mut review).is_err()); - assert_eq!((review.storage_id, review.storage_revision), identity); - assert_eq!(review.summary, "Unsaved draft"); - assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, ""); - let mut fresh = Review::new(root.clone()); - assert!(store.save(&mut fresh).is_err()); - assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0)); - store - .connection - .execute_batch("PRAGMA query_only = OFF") - .unwrap(); - review.summary = "x".repeat(LIMIT); - assert!(store.save(&mut review).unwrap_err().contains("64 MiB")); - assert!( - review - .save(&root.join("oversized.json")) - .unwrap_err() - .contains("64 MiB") - ); - assert!(!root.join("oversized.json").exists()); - assert_eq!((review.storage_id, review.storage_revision), identity); - assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, ""); - let blocked = root.join("not-a-directory"); - fs::write(&blocked, b"preserve").unwrap(); - assert!(Store::open(&blocked).is_err()); - assert_eq!(fs::read(&blocked).unwrap(), b"preserve"); - } - - #[cfg(unix)] - #[test] - fn database_files_are_private_and_target_symlinks_are_refused() { - use std::os::unix::fs::PermissionsExt; - let directory = directory(); - let storage = directory.0.join("storage"); - drop(Store::open(&storage).unwrap()); - let path = storage.join("reviews.sqlite3"); - assert_eq!( - fs::metadata(&storage).unwrap().permissions().mode() & 0o777, - 0o700 - ); - assert_eq!( - fs::metadata(&path).unwrap().permissions().mode() & 0o777, - 0o600 - ); - let original = fs::read(&path).unwrap(); - let links = directory.0.join("links"); - fs::create_dir(&links).unwrap(); - std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap(); - assert!(Store::open(&links).err().unwrap().contains("symbolic link")); - assert_eq!(fs::read(&path).unwrap(), original); - } - - #[test] - fn default_directory_uses_absolute_xdg_then_absolute_home() { - let resolve = |xdg: Option<&str>, home: Option<&str>| { - directory_from(xdg.map(OsStr::new), home.map(OsStr::new)) - }; - assert_eq!( - resolve(Some("/data"), Some("/home/me")).unwrap(), - Path::new("/data/vamp/reviews") - ); - for xdg in [None, Some("relative"), Some("")] { - assert_eq!( - resolve(xdg, Some("/home/me")).unwrap(), - Path::new("/home/me/.local/share/vamp/reviews") - ); - } - assert_eq!( - resolve(Some("/data"), None).unwrap(), - Path::new("/data/vamp/reviews") - ); - assert!(resolve(None, None).is_err()); - assert!(resolve(None, Some("relative")).is_err()); - assert!(resolve(None, Some("")).is_err()); - } - - #[test] - fn persistence_validates_anchors_and_preserves_files_on_failure() { - let directory = directory(); - let root = directory.0.canonicalize().unwrap(); - let path = root.join("review.json"); - let mut review = Review::new(root.clone()); - review.summary = "Local review".into(); - review.verdict = Some(Verdict::ChangesRequested); - review.comments.push(Comment { - id: 1, - anchor: Anchor { - revision: "working:immutable".into(), - path: "source.rs".into(), - side: Side::After, - start: crate::plugin::TextPosition { row: 0, column: 0 }, - end: crate::plugin::TextPosition { row: 2, column: 0 }, - content: "first\nlast\n".into(), - }, - body: "Please adjust this".into(), - resolved: false, - }); - review.save(&path).unwrap(); - let mut loaded = Review::load(&path, &root).unwrap(); - assert_eq!(loaded.comments, review.comments); - assert_eq!(loaded.verdict, review.verdict); - let anchor = &loaded.comments[0].anchor; - assert!(anchor.matches( - "working:immutable", - Path::new("source.rs"), - Side::After, - "first\nlast\n" - )); - assert!(!anchor.matches( - "working:other", - Path::new("source.rs"), - Side::After, - "first\nlast\n" - )); - assert!(anchor.matches_lines( - "working:immutable", - Path::new("source.rs"), - Side::After, - &["first".into(), "last".into()], - true - )); - assert!(!anchor.matches_lines( - "working:immutable", - Path::new("source.rs"), - Side::After, - &["first".into(), "last".into()], - false - )); - loaded.summary = "Updated".into(); - loaded.save(&path).unwrap(); - assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated"); - assert!(Review::new(root.clone()).save(&path).is_err()); - let valid = fs::read(&path).unwrap(); - for (field, value) in [ - ("version", serde_json::json!(2)), - ("root", serde_json::json!(root.join("other"))), - ( - "comments", - serde_json::json!([loaded.comments[0], loaded.comments[0]]), - ), - ] { - let mut invalid = serde_json::to_value(&loaded).unwrap(); - invalid[field] = value; - fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap(); - assert!(Review::load(&path, &root).is_err()); - } - for (field, value) in [ - ("path", serde_json::json!("../outside")), - ("path", serde_json::json!("/absolute")), - ("path", serde_json::json!(".git/config")), - ("start", serde_json::json!({"row": 2, "column": 1})), - ("start", serde_json::json!({"row": 3, "column": 0})), - ("end", serde_json::json!({"row": 3, "column": 0})), - ("revision", serde_json::json!("")), - ] { - let mut invalid = serde_json::to_value(&loaded).unwrap(); - invalid["comments"][0]["anchor"][field] = value; - fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap(); - assert!(Review::load(&path, &root).is_err()); - } - fs::create_dir(root.join("other")).unwrap(); - fs::write(&path, &valid).unwrap(); - assert!(Review::load(&path, &root.join("other")).is_err()); - fs::write(&path, b"external edit").unwrap(); - assert!(loaded.save(&path).is_err()); - assert_eq!(fs::read(&path).unwrap(), b"external edit"); - assert_eq!(loaded.summary, "Updated"); - let invalid = root.join("invalid.json"); - loaded.comments[0].anchor.start.row = usize::MAX; - assert!(loaded.save(&invalid).is_err()); - assert!(!invalid.exists()); - let large = root.join("large.json"); - fs::File::create(&large) - .unwrap() - .set_len(LIMIT as u64 + 1) - .unwrap(); - assert!(Review::load(&large, &root).is_err()); - #[cfg(unix)] - { - let link = root.join("link.json"); - std::os::unix::fs::symlink(&path, &link).unwrap(); - assert!(Review::load(&link, &root).is_err()); - assert!(review.save(&link).is_err()); - assert_eq!(fs::read(&path).unwrap(), b"external edit"); - } - } - - #[test] - fn stored_version_two_is_rejected_without_rewriting_it() { - let directory = directory(); - let root = directory.0.canonicalize().unwrap(); - let mut store = Store::open(&root.join("storage")).unwrap(); - let mut review = Review::new(root.clone()); - store.save(&mut review).unwrap(); - let mut old = serde_json::to_value(&review).unwrap(); - old["version"] = 2.into(); - let document = serde_json::to_string(&old).unwrap(); - store - .connection - .execute( - "UPDATE reviews SET document = ?1 WHERE id = ?2", - params![document, review.storage_id.unwrap()], - ) - .unwrap(); - assert!( - store - .load(&root, review.storage_id.unwrap()) - .unwrap_err() - .contains("Unsupported review version 2") - ); - assert!( - store - .list(&root) - .unwrap_err() - .contains("Unsupported review version 2") - ); - let preserved: String = store - .connection - .query_row( - "SELECT document FROM reviews WHERE id = ?1", - [review.storage_id.unwrap()], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(preserved, document); - } - - #[test] - fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() { - use crate::plugin::TextPosition; - let directory = directory(); - let root = directory.0.canonicalize().unwrap(); - let path = root.join("review.json"); - let mut review = Review::new(root.clone()); - review.comments.push(Comment { - id: 1, - anchor: Anchor { - revision: "working:immutable".into(), - path: "source.rs".into(), - side: Side::After, - start: TextPosition { row: 0, column: 1 }, - end: TextPosition { row: 1, column: 1 }, - content: "a👩‍🔬e\u{301}\n界x\n".into(), - }, - body: "Exact text".into(), - resolved: false, - }); - review.save(&path).unwrap(); - let mut loaded = Review::load(&path, &root).unwrap(); - assert_eq!(loaded.comments, review.comments); - assert_eq!(loaded.comments[0].anchor.rows(), 0..=1); - let valid = serde_json::to_value(&review).unwrap(); - let mut missing_resolution = valid.clone(); - missing_resolution["comments"][0] - .as_object_mut() - .unwrap() - .remove("resolved"); - for version in [2, 3] { - missing_resolution["version"] = version.into(); - let bytes = serde_json::to_vec(&missing_resolution).unwrap(); - fs::write(&path, &bytes).unwrap(); - let error = Review::load(&path, &root).unwrap_err(); - assert!(error.contains(if version == 2 { - "Unsupported review version 2" - } else { - "missing field `resolved`" - })); - assert_eq!(fs::read(&path).unwrap(), bytes); - } - for (field, value) in [ - ("start", serde_json::json!({"row": 0, "column": 4})), - ("end", serde_json::json!({"row": 1, "column": 3})), - ("end", serde_json::json!({"row": 2, "column": 1})), - ("end", serde_json::json!({"row": 3, "column": 0})), - ("end", serde_json::json!({"row": 0, "column": 0})), - ("start", serde_json::json!({"row": -1, "column": 0})), - ( - "start", - serde_json::json!({"row": 0, "column": 0, "extra": 0}), - ), - ("start_line", serde_json::json!(1)), - ] { - let mut invalid = valid.clone(); - invalid["comments"][0]["anchor"][field] = value; - fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap(); - assert!(Review::load(&path, &root).is_err(), "{field}"); - } - let mut wrong_version = valid; - wrong_version["version"] = 1.into(); - fs::write(&path, serde_json::to_vec(&wrong_version).unwrap()).unwrap(); - assert!(Review::load(&path, &root).is_err()); - loaded.comments[0].anchor.end = TextPosition { row: 0, column: 0 }; - assert!(loaded.save(&root.join("backwards.json")).is_err()); - } -} +#[path = "../tests/unit/review.rs"] +mod tests; diff --git a/src/review_cli.rs b/src/review_cli.rs index d7d7abf..29b2985 100644 --- a/src/review_cli.rs +++ b/src/review_cli.rs @@ -194,113 +194,5 @@ fn print_review(review: &Value) { } #[cfg(test)] -mod tests { - use super::*; - use vamp::{ - plugin::TextPosition, - review::{Comment, Side}, - }; - - #[test] - fn output_filters_resolution_and_extracts_exact_graphemes() { - let mut review = Review::new(PathBuf::new()); - review.storage_id = Some(7); - review.storage_revision = 2; - review.comments = [false, true] - .into_iter() - .enumerate() - .map(|(id, resolved)| Comment { - id: id as u64 + 1, - body: "fix".into(), - resolved, - anchor: Anchor { - revision: "commit".into(), - path: "a.rs".into(), - side: Side::After, - start: TextPosition { row: 0, column: 1 }, - end: TextPosition { row: 1, column: 1 }, - content: "xe\u{301}👩‍💻\n界z\n".into(), - }, - }) - .collect(); - let output = envelope(&review, false, true); - assert_eq!(output["comments"].as_array().unwrap().len(), 1); - assert_eq!(output["comments"][0]["excerpt"], "e\u{301}👩‍💻\n界"); - assert!(output["comments"][0]["anchor"].get("content").is_none()); - assert_eq!(output["counts"]["resolved"], 1); - assert_eq!( - envelope(&review, true, true)["comments"] - .as_array() - .unwrap() - .len(), - 2 - ); - } - - #[test] - fn absent_storage_is_not_created_and_incompatible_storage_is_preserved() { - let directory = - std::env::temp_dir().join(format!("vamp-review-cli-{}", std::process::id())); - std::fs::create_dir(&directory).unwrap(); - let absent = directory.join("absent"); - assert!(Store::open_read_only(&absent).unwrap().is_none()); - assert!(!absent.exists()); - let path = directory.join("reviews.sqlite3"); - std::fs::write(&path, b"incompatible").unwrap(); - assert!(Store::open_read_only(&directory).is_err()); - assert_eq!(std::fs::read(&path).unwrap(), b"incompatible"); - std::fs::remove_file(&path).unwrap(); - let connection = rusqlite::Connection::open(&path).unwrap(); - connection - .execute_batch("CREATE TABLE incompatible (value TEXT); PRAGMA user_version = 42") - .unwrap(); - drop(connection); - let before = std::fs::read(&path).unwrap(); - assert!( - Store::open_read_only(&directory) - .err() - .unwrap() - .contains("schema") - ); - assert_eq!(std::fs::read(&path).unwrap(), before); - std::fs::remove_dir_all(directory).unwrap(); - } - - #[test] - fn existing_storage_is_read_only_and_unchanged() { - let directory = - std::env::temp_dir().join(format!("vamp-review-cli-existing-{}", std::process::id())); - let mut store = Store::open(&directory).unwrap(); - let mut review = Review::new(directory.clone()); - review.summary = "stored".into(); - store.save(&mut review).unwrap(); - drop(store); - let path = directory.join("reviews.sqlite3"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); - } - let before = std::fs::read(&path).unwrap(); - let mut store = Store::open_read_only(&directory).unwrap().unwrap(); - let mut loaded = store.load(&directory, review.storage_id.unwrap()).unwrap(); - assert_eq!(loaded.summary, "stored"); - loaded.summary = "changed".into(); - assert!(store.save(&mut loaded).is_err()); - drop(store); - assert_eq!(std::fs::read(&path).unwrap(), before); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, - 0o640 - ); - let links = directory.join("links"); - std::fs::create_dir(&links).unwrap(); - std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap(); - assert!(Store::open_read_only(&links).is_err()); - } - std::fs::remove_dir_all(directory).unwrap(); - } -} +#[path = "../tests/unit/review_cli.rs"] +mod tests; diff --git a/src/ruby.rs b/src/ruby.rs index 4d420b8..c0fa546 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -968,2302 +968,5 @@ fn read_frame(reader: &mut impl BufRead) -> Result, String> { } #[cfg(test)] -mod tests { - use crate::{editor::Input, session::EditorSession}; - - use super::*; - use std::sync::atomic::{AtomicU64, Ordering}; - - static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1); - - struct Fixture(PathBuf); - - impl Fixture { - fn new() -> Self { - let path = env::temp_dir().join(format!( - "vamp-ruby-{}-{}", - std::process::id(), - NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) - )); - std::fs::create_dir(&path).unwrap(); - Self(path) - } - - fn file(&self, name: &str, text: &str) -> PathBuf { - let path = self.0.join(name); - std::fs::write(&path, text).unwrap(); - path - } - - fn plugin(&self, text: &str) -> (RubyPlugin, Feature) { - self.plugin_with_options(text, &serde_json::json!({})) - } - - fn plugin_with_options( - &self, - text: &str, - options: &serde_json::Value, - ) -> (RubyPlugin, Feature) { - RubyPlugin::start(&self.file("plugin.rb", text), options).unwrap_or_else(|error| { - panic!("Actual Ruby runtime required (VAMP_RUBY or ruby): {error}") - }) - } - } - - impl Drop for Fixture { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - - fn context() -> Context { - Context { - buffer_id: 7, - revision: 11, - root: "/project".into(), - text: "α\nhello".into(), - row: 2, - column: 3, - mode: "NORMAL".into(), - path: Some("buffer.txt".into()), - } - } - - #[test] - fn review_directory_resolves_config_paths_and_keeps_session_startup_lazy() { - let fixture = Fixture::new(); - let absolute = fixture.0.join("absolute-reviews"); - for (value, expected) in [ - ( - r#""relative-reviews""#.to_owned(), - Some(fixture.0.join("relative-reviews")), - ), - (serde_json::to_string(&absolute).unwrap(), Some(absolute)), - ("nil".to_owned(), None), - ] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.review_directory = {value} }}"), - ); - assert_eq!(load_config(&path).unwrap().review_directory, expected); - let session = EditorSession::open(None, vec![], Some(&path)).unwrap(); - assert!(session.editor().startup_diagnostic().is_none()); - if let Some(directory) = expected { - assert!( - !directory.exists(), - "startup created {}", - directory.display() - ); - } - } - let path = fixture.file("config.rb", "Vamp.configure { |_| }"); - assert_eq!(load_config(&path).unwrap().review_directory, None); - for value in [ - "7", - "false", - "[]", - "{}", - ":reviews", - r#""""#, - r#""bad\0path""#, - ] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.review_directory = {value} }}"), - ); - let error = load_config(&path).unwrap_err(); - assert!(error.contains("review_directory"), "{value}: {error}"); - } - } - - #[test] - fn review_directory_wire_field_is_required_nullable_and_validated() { - let mut frame = serde_json::json!({ - "version": 1, "type": "config", "plugins": [], "lsp": {}, "bindings": [], - "review_directory": null, "leader": "space", - "file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree", - "clipboard_mode": "system", "git_blame": true, "git_gutter": "bar", - "top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}}, "labels": {}, - "scroll_distance": 0, "scroll_step_lines": null, "highlight_trailing_whitespace": false, - "whitespace_characters": {}, "whitespace_scope": "all", - "tab_spaces": null, "language_tab_spaces": {}, - "diff_context_lines": 10, "diff_expand_lines": 15, - "diff_view_mode": "split", "diff_split_orientation": "vertical", - "resolve_history_layout": "columns", - "diff_follow_working_copy": true - }); - for value in [serde_json::Value::Null, serde_json::json!("reviews")] { - frame["review_directory"] = value; - assert!(serde_json::from_value::(frame.clone()).is_ok()); - } - for value in [ - serde_json::json!(""), - serde_json::json!("bad\0path"), - serde_json::json!(7), - serde_json::json!(false), - serde_json::json!([]), - serde_json::json!({}), - ] { - frame["review_directory"] = value; - assert!(serde_json::from_value::(frame.clone()).is_err()); - } - frame.as_object_mut().unwrap().remove("review_directory"); - assert!(serde_json::from_value::(frame).is_err()); - } - - #[test] - fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() { - let fixture = Fixture::new(); - let config = fixture.file( - "config.rb", - r#" -Vamp.configure do |c| - c.tab_spaces = 4 - c.language_tab_spaces.merge!("ruby" => 2, "makefile" => nil, "json" => 1, "yaml" => 255, "typescriptreact" => 5) - c.language_tab_spaces["cpp"] = 3 -end -"#, - ); - let widest = " ".repeat(255); - for (name, prefix, inserted) in [ - ("default.rs", "", "\t"), - ("buffer.rs", "", " "), - ("buffer.rb", "", " "), - ("Gemfile", "", " "), - ("script", "#!/usr/bin/env ruby\n", " "), - ("Makefile", "", "\t"), - ("plain.txt", "", " "), - ("buffer.json", "", " "), - ("buffer.yaml", "", widest.as_str()), - ("buffer.cpp", "", " "), - ("buffer.tsx", "", " "), - ] { - let source = format!("{prefix}αx\nβy"); - let path = fixture.file(name, &source); - let config = (name != "default.rs").then_some(config.as_path()); - let mut session = EditorSession::open(Some(path), vec![], config).unwrap(); - assert!(session.editor().startup_diagnostic().is_none()); - if !prefix.is_empty() { - session.handle(Input::Char('j')); - } - session.handle(Input::Ctrl('e')); - for input in [ - Input::Char('l'), - Input::Char('i'), - Input::Char('\t'), - Input::Ctrl('i'), - Input::Escape, - ] { - session.handle(input); - } - let expected = format!("{prefix}α{inserted}{inserted}x\nβ{inserted}{inserted}y"); - assert_eq!(session.editor().context().text, expected, "{name}"); - session.handle(Input::Char('u')); - assert_eq!(session.editor().context().text, source, "{name}"); - session.handle(Input::Char('U')); - assert_eq!(session.editor().context().text, expected, "{name}"); - session.handle(Input::Char('u')); - session.handle(Input::Char('.')); - assert_eq!(session.editor().context().text, expected, "{name}"); - session.handle(Input::Char('i')); - session.handle(Input::End); - session.handle(Input::Paste("\t".into())); - assert_eq!( - session.editor().context().text, - format!("{prefix}α{inserted}{inserted}x\t\nβ{inserted}{inserted}y\t"), - "{name}" - ); - } - } - - #[test] - fn tab_spaces_reject_invalid_widths_and_language_maps() { - let fixture = Fixture::new(); - for value in ["0", "-1", "256", "1.5", "true", r#""4""#, "[]", "{}"] { - for assignment in [ - format!("c.tab_spaces = {value}"), - format!(r#"c.language_tab_spaces = {{ "ruby" => {value} }}"#), - format!(r#"c.language_tab_spaces.merge!("ruby" => {value})"#), - format!(r#"c.language_tab_spaces["ruby"] = {value}"#), - ] { - let config = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |c| {assignment} }}"), - ); - let error = load_config(&config).unwrap_err(); - assert!( - error.contains("tab_spaces must be nil or an Integer"), - "{assignment}: {error}" - ); - } - } - for value in [ - "nil", - "[]", - r#"{ "" => 2 }"#, - r#"{ :ruby => 2 }"#, - r#"{ "\n" => 2 }"#, - ] { - let config = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |c| c.language_tab_spaces = {value} }}"), - ); - assert!(load_config(&config).is_err(), "{value}"); - } - } - - #[test] - fn language_tab_space_mutations_reject_invalid_keys_before_serialization() { - let fixture = Fixture::new(); - for key in [r#""""#, ":ruby", r#""\n""#, "7"] { - for assignment in [ - format!("c.language_tab_spaces.merge!({key} => 2)"), - format!("c.language_tab_spaces[{key}] = 2"), - ] { - let config = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |c| {assignment} }}"), - ); - let error = load_config(&config).unwrap_err(); - assert!(error.contains("Tab language"), "{assignment}: {error}"); - } - } - } - - #[test] - fn actual_ruby_dsl_context_queued_effects_and_output_isolation() { - let fixture = Fixture::new(); - let (mut plugin, feature) = fixture.plugin(r#" -puts "startup noise" -STDOUT.write "direct startup noise\n" -warn "warning" -Vamp.plugin "greeting", requires: ["text"] -Vamp.command "greet" do |editor, args| - puts "invocation noise" - STDOUT.write "direct invocation noise\n" - raise "bad context" unless editor.text == "α\nhello" && editor.row == 2 && editor.column == 3 && editor.mode == "NORMAL" && editor.path == "buffer.txt" - raise "bad source" unless editor.buffer_id == 7 && editor.revision == 11 && editor.root == "/project" && editor.request_id > 0 - editor.insert "Hello #{args}!" - editor.replace editor.text - editor.message "Greeting inserted" - editor.label :battery, "界 e\u0301 👩‍🚀\t\n\e" - editor.label "battery", "" -end -"#); - assert_eq!(feature.name, "greeting"); - assert_eq!(feature.required_capabilities, ["text"]); - assert_eq!(feature.commands, ["greet"]); - assert!(!feature.core); - assert!(!plugin.is_async()); - for _ in 0..2 { - let effects = plugin.invoke("greet", "friend", &context()).unwrap(); - assert!( - matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message), Effect::Label { name, text: label }, Effect::Label { name: cleared_name, text: cleared }] - if text == "Hello friend!" && replacement == "α\nhello" && message == "Greeting inserted" - && name == "battery" && cleared_name == "battery" - && label == "界 e\u{301} 👩‍🚀\t\n\u{1b}" && cleared.is_empty()) - ); - } - plugin.disable(); - plugin.disable(); - assert!( - plugin - .invoke("greet", "", &context()) - .unwrap_err() - .contains("disabled") - ); - assert!( - plugin.host.child.is_none() - && plugin.host.reader.is_none() - && plugin.host.writer.is_none() - ); - } - - #[test] - fn generated_core_actions_execute_through_ruby_configuration() { - use crate::{ - editor::{Input, Mode}, - session::EditorSession, - }; - - let fixture = Fixture::new(); - let config = fixture.file( - "config.rb", - r#"Vamp.configure do |config| - config.clipboard_mode = "separate" - config.bind "Q", config.core.quit - config.bind "F", config.core.force_quit - config.bind "W", config.core.save - config.bind "X", config.core.save_and_quit - config.bind "E", config.core.edit - config.bind "B", config.core.buffer - config.bind "P", config.core.plugins - config.bind "N", config.core.buffer_next - config.bind "V", config.core.buffer_previous - config.bind "D", config.core.buffer_delete - config.bind "Z", config.core.buffer_force_delete - config.bind "R", config.core.diff - config.bind "ctrl+t", config.core.diff.close - config.bind "H", config.core.word_wrap.toggle - raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? && - config.core.diff.close.frozen? && config.core.diff.name.frozen? && - config.core.diff.close.name.frozen? -end"#, - ); - let mut review = EditorSession::open(None, vec!["text".into()], Some(&config)).unwrap(); - assert!(!review.editor().word_wrap()); - review.handle(Input::Char('H')); - assert!(review.editor().word_wrap()); - review.execute("word_wrap.toggle unexpected"); - assert!(review.editor().word_wrap()); - review.execute("word_wrap.toggle"); - assert!(!review.editor().word_wrap()); - review.handle(Input::Char('R')); - review.handle(Input::Escape); - assert!(review.editor().diff().is_none()); - review.handle(Input::Char('R')); - assert!(review.editor().diff().is_some()); - review.handle(Input::Ctrl('t')); - assert!(review.editor().diff().is_none()); - for (key, dirty, quit, saved) in [ - ('Q', false, true, false), - ('Q', true, false, false), - ('F', true, true, false), - ('W', true, false, true), - ('X', true, true, true), - ] { - let buffer = fixture.file("buffer.txt", "base"); - let mut session = - EditorSession::open(Some(buffer.clone()), vec!["text".into()], Some(&config)) - .unwrap(); - assert!(session.editor().startup_diagnostic().is_none()); - if dirty { - for input in [Input::Char('i'), Input::Char('!'), Input::Escape] { - session.handle(input); - } - } - session.handle(Input::Char(key)); - assert_eq!(session.editor().should_quit(), quit, "{key}"); - assert_eq!( - std::fs::read_to_string(&buffer).unwrap(), - if saved { "!base" } else { "base" }, - "{key}" - ); - assert_eq!(session.editor().dirty(), dirty && !saved, "{key}"); - } - - let buffer = fixture.file("buffer.txt", "base"); - let second = fixture.file("second.txt", "second"); - let mut session = - EditorSession::open(Some(buffer), vec!["text".into()], Some(&config)).unwrap(); - assert!(session.editor().startup_diagnostic().is_none()); - session.handle(Input::Char('E')); - assert_eq!(session.editor().mode(), Mode::Command); - assert_eq!(session.editor().command_line(), "edit "); - for ch in second.to_string_lossy().chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Enter); - assert_eq!(session.editor().lines(), &["second"]); - session.handle(Input::Char('B')); - assert_eq!(session.editor().mode(), Mode::Command); - assert_eq!(session.editor().command_line(), "buffer "); - session.handle(Input::Char('1')); - session.handle(Input::Enter); - assert_eq!(session.editor().buffer_id(), 1); - assert_eq!(session.editor().lines(), &["base"]); - session.handle(Input::Char('P')); - assert!(session.editor().message().contains("text")); - session.handle(Input::Char('N')); - assert_eq!(session.editor().buffer_id(), 2); - session.handle(Input::Char('V')); - assert_eq!(session.editor().buffer_id(), 1); - session.handle(Input::Char('D')); - assert!(!session.editor().has_buffer(1)); - assert_eq!(session.editor().buffer_id(), 2); - for input in [Input::Char('i'), Input::Char('!'), Input::Escape] { - session.handle(input); - } - session.handle(Input::Char('D')); - assert!(session.editor().has_buffer(2)); - session.handle(Input::Char('Z')); - assert!(!session.editor().has_buffer(2)); - assert_eq!(std::fs::read_to_string(second).unwrap(), "second"); - } - - #[test] - fn raw_builtin_aliases_are_not_external_binding_commands() { - let fixture = Fixture::new(); - for name in [ - "quit", - "save", - "buffer_next", - "q", - "w", - "bnext", - "q!", - "wq", - "bprev", - "bdelete", - "bdelete!", - "core.quit", - ] { - let config = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.bind 'K', '{name}' }}"), - ); - assert!(load_config(&config).is_err(), "{name}"); - } - } - - #[test] - fn plugin_commands_cannot_shadow_canonical_commands_or_aliases() { - let fixture = Fixture::new(); - for name in [ - "quit", - "save", - "buffer_next", - "q", - "w", - "bnext", - "q!", - "wq", - "bprev", - "bdelete", - "bdelete!", - ] { - let path = fixture.file( - "plugin.rb", - &format!("Vamp.plugin 'shadow'\nVamp.command '{name}' do |editor, args|\nend"), - ); - assert!( - RubyPlugin::start(&path, &serde_json::json!({})).is_err(), - "plugin shadowed built-in command {name}" - ); - } - } - - #[test] - fn config_resolves_relative_paths_and_rejects_invalid_types_and_core() { - let fixture = Fixture::new(); - let config = fixture.file( - "config.rb", - r#" -puts "configuration noise" -Vamp.configure do |config| - config.plugin "plugins/greeting.rb" - config.bind "g", "greet" -end -"#, - ); - let loaded = load_config(&config).unwrap(); - assert_eq!( - loaded.plugins[0].path, - fixture.0.join("plugins/greeting.rb") - ); - assert_eq!(loaded.plugins[0].options, serde_json::json!({})); - let invalid = fixture.file("invalid.rb", "Vamp.configure { |config| config.plugin 7 }"); - assert!( - load_config(&invalid) - .unwrap_err() - .contains("must be a String") - ); - for target in [ - "7", - "{}", - "config.core.lsp", - "config.core.lsp.hover.tap { |action| action.name = 'core.delete' }", - "config.core.lsp.tap { |namespace| namespace.define_singleton_method(:bogus) { namespace.hover } }.bogus", - "config.core.diff.tap { |action| action.name = 'core.delete' }", - "config.core.diff.tap { |action| action.define_singleton_method(:bogus) { action.close } }.bogus", - "config.core.diff.close.tap { |action| action.name = 'core.delete' }", - ] { - let invalid = fixture.file( - "invalid_action.rb", - &format!("Vamp.configure {{ |config| config.bind 'K', {target} }}"), - ); - assert!(load_config(&invalid).is_err(), "{target}"); - } - let invalid = fixture.file("core.rb", "Vamp.plugin 'evil', core: true"); - assert!(RubyPlugin::start(&invalid, &serde_json::json!({})).is_err()); - let invalid = fixture.file("syntax.rb", "this is not valid ruby ("); - assert!(RubyPlugin::start(&invalid, &serde_json::json!({})).is_err()); - } - - #[test] - fn config_leader_defaults_custom_keys_validation_and_required_wire_field() { - let fixture = Fixture::new(); - let defaults = Config::default(); - assert_eq!(defaults.leader, "space"); - assert!(defaults.plugins.is_empty() && defaults.bindings.is_empty()); - assert!(defaults.lsp.is_empty()); - let path = fixture.file("config.rb", "Vamp.configure { |_| }"); - assert_eq!(load_config(&path).unwrap().leader, "space"); - for (source, key) in [ - (r#"Vamp.configure { |config| config.leader = "," }"#, ","), - (r#"Vamp.configure { |config| config.leader = "\\" }"#, "\\"), - ] { - let path = fixture.file("config.rb", source); - assert_eq!(load_config(&path).unwrap().leader, key); - } - for (value, error) in [ - ("7", "leader key must be a String"), - ("nil", "leader key must be a String"), - (r#""""#, "leader key must not be empty"), - ] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.leader = {value} }}"), - ); - assert!(load_config(&path).unwrap_err().contains(error), "{value}"); - } - for frame in [ - r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#, - r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#, - r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#, - ] { - assert!(serde_json::from_str::(frame).is_err(), "{frame}"); - } - } - - #[test] - fn git_gutter_config_modes_default_and_rejection() { - use crate::gutter::GutterStyle; - let fixture = Fixture::new(); - let path = fixture.file("config.rb", "Vamp.configure { |_config| }"); - assert_eq!(load_config(&path).unwrap().git_gutter, GutterStyle::Bar); - for (name, expected) in [ - ("bar", GutterStyle::Bar), - ("symbols", GutterStyle::Symbols), - ("line_numbers", GutterStyle::LineNumbers), - ("none", GutterStyle::None), - ] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.git_gutter = {name:?} }}"), - ); - assert_eq!(load_config(&path).unwrap().git_gutter, expected); - } - for value in ["\"unsupported\"", "\"\"", "false", "7", ":bar"] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.git_gutter = {value} }}"), - ); - assert!(load_config(&path).is_err(), "{value}"); - } - } - - #[test] - fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() { - let fixture = Fixture::new(); - let config = fixture.file("config.rb", "Vamp.configure { |_config| }"); - assert!(load_config(&config).unwrap().git_blame); - let config = fixture.file("config.rb", r#" -Vamp.configure do |config| - config.git_blame = false - config.bind "G", config.core.git_blame.toggle - raise "mutable blame action" unless config.core.git_blame.frozen? && config.core.git_blame.toggle.frozen? -end -"#); - let mut session = EditorSession::open(None, vec![], Some(&config)).unwrap(); - assert!(!session.editor().git_blame()); - session.handle(Input::Char('G')); - assert!(session.editor().git_blame()); - session.execute("git_blame.toggle extra"); - assert!(session.editor().git_blame()); - session.execute("git_blame.toggle"); - assert!(!session.editor().git_blame()); - session.handle(Input::Ctrl('p')); - for ch in "git blame".chars() { - session.handle(Input::Char(ch)); - } - assert_eq!(session.editor().palette().unwrap().entries.len(), 1); - session.handle(Input::Enter); - assert!(session.editor().git_blame()); - for value in ["nil", "0", r#""false""#, "[]", "{}"] { - let config = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.git_blame = {value} }}"), - ); - assert!( - load_config(&config) - .unwrap_err() - .contains("git_blame must be true or false") - ); - } - let mut frame = serde_json::json!({ - "type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [], - "review_directory": null, - "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", - "file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true, - "git_gutter": "bar", - "scroll_distance": 0, - "highlight_trailing_whitespace": false, - "whitespace_characters": {}, - "whitespace_scope": "all", - "tab_spaces": null, "language_tab_spaces": {}, - "top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}}, - "labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15, - "diff_view_mode": "split", "diff_split_orientation": "vertical", - "resolve_history_layout": "columns", - "diff_follow_working_copy": true - }); - assert!(serde_json::from_value::(frame.clone()).is_ok()); - for value in [ - serde_json::Value::Null, - serde_json::json!(0), - serde_json::json!("false"), - ] { - frame["git_blame"] = value; - assert!(serde_json::from_value::(frame.clone()).is_err()); - } - frame.as_object_mut().unwrap().remove("git_blame"); - assert!(serde_json::from_value::(frame).is_err()); - } - - #[test] - fn trailing_whitespace_configuration_rejects_non_booleans() { - let fixture = Fixture::new(); - for value in ["nil", "0", "\"true\"", "[]", "{}"] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |c| c.highlight_trailing_whitespace = {value} }}"), - ); - assert!( - load_config(&path) - .unwrap_err() - .contains("highlight_trailing_whitespace must be true or false") - ); - } - } - - #[test] - fn whitespace_characters_validate_real_ruby_configuration() { - let fixture = Fixture::new(); - for value in [ - "nil", - "false", - "[]", - r#"{ space: "·" }"#, - r#"{ " " => 1 }"#, - r#"{ "" => "." }"#, - r#"{ " " => "." }"#, - r#"{ " " => ".." }"#, - r#"{ "x" => "." }"#, - r#"{ "\u000b" => "." }"#, - r#"{ "\r" => "." }"#, - r#"{ "\u000c" => "." }"#, - r#"{ "\u0085" => "." }"#, - r#"{ "\u200b" => "." }"#, - r#"{ " " => "\t" }"#, - r#"{ " " => "\n" }"#, - r#"{ " " => "\u0301" }"#, - r#"{ " " => "界" }"#, - ] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |c| c.whitespace_characters = {value} }}"), - ); - assert!(load_config(&path).is_err(), "{value}"); - } - let path = fixture.file( - "config.rb", - r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\n" => "↵", "\u00a0" => "⍽", "\u3000" => "_" } }"#, - ); - assert_eq!( - load_config(&path).unwrap().whitespace_characters, - BTreeMap::from([ - (' ', '·'), - ('\t', '→'), - ('\n', '↵'), - ('\u{a0}', '⍽'), - ('\u{3000}', '_'), - ]), - ); - } - - #[test] - fn whitespace_scope_validates_ruby_atomically_and_requires_valid_wire_values() { - use crate::editor::WhitespaceScope; - - let fixture = Fixture::new(); - let path = fixture.file("config.rb", "Vamp.configure {}"); - assert_eq!( - load_config(&path).unwrap().whitespace_scope, - WhitespaceScope::All - ); - for (value, expected) in [ - ("all", WhitespaceScope::All), - ("leading", WhitespaceScope::Leading), - ] { - let path = fixture.file( - "config.rb", - &format!(r#"Vamp.configure {{ |c| c.whitespace_scope = "{value}" }}"#), - ); - assert_eq!(load_config(&path).unwrap().whitespace_scope, expected); - } - for value in [ - "nil", - "false", - "0", - "[]", - "{}", - ":all", - r#""""#, - r#""unknown""#, - ] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |c| c.whitespace_scope = {value} }}"), - ); - assert!(load_config(&path).is_err(), "{value}"); - let path = fixture.file( - "config.rb", - &format!( - r#"Vamp.configure do |c| - c.whitespace_scope = "leading" - begin - c.whitespace_scope = {value} - raise "invalid scope accepted" - rescue TypeError, ArgumentError - end -end"#, - ), - ); - assert_eq!( - load_config(&path).unwrap().whitespace_scope, - WhitespaceScope::Leading - ); - } - let mut frame = serde_json::json!({ - "type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [], - "review_directory": null, - "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", - "file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true, - "git_gutter": "bar", "scroll_distance": 0, "highlight_trailing_whitespace": false, - "whitespace_characters": {}, "whitespace_scope": "all", - "tab_spaces": null, "language_tab_spaces": {}, - "top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}}, - "labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15, - "diff_view_mode": "split", "diff_split_orientation": "vertical", - "resolve_history_layout": "columns", - "diff_follow_working_copy": true - }); - for value in ["all", "leading"] { - frame["whitespace_scope"] = serde_json::json!(value); - assert!(serde_json::from_value::(frame.clone()).is_ok()); - } - for value in [ - serde_json::Value::Null, - serde_json::json!(false), - serde_json::json!(0), - serde_json::json!([]), - serde_json::json!({}), - serde_json::json!(""), - serde_json::json!("unknown"), - ] { - frame["whitespace_scope"] = value; - assert!(serde_json::from_value::(frame.clone()).is_err()); - } - frame.as_object_mut().unwrap().remove("whitespace_scope"); - assert!(serde_json::from_value::(frame).is_err()); - } - - #[test] - fn working_diff_tracking_rejects_non_boolean_configuration() { - let fixture = Fixture::new(); - for value in ["nil", "0", r#""false""#, "[]", "{}"] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.diff_follow_working_copy = {value} }}"), - ); - assert!( - load_config(&path) - .unwrap_err() - .contains("diff_follow_working_copy must be true or false") - ); - } - } - - #[test] - fn chrome_configuration_normalizes_ruby_values_and_retains_unspecified_defaults() { - let fixture = Fixture::new(); - let path = fixture.file( - "config.rb", - r#" -Vamp.configure do |config| - config.bar.top = {left: [:tabs], center: ["workspace"], right: [:battery], - tabs: {title: "T{index}:{path}{modified}", padding: 0, first_leading_edge: ""}, - battery: {leading_edge: "界", trailing_edge: ""}} - config.bar.bottom = {"left" => [], center: [:workspace]} - config.labels = {battery: "bat 38%", "workspace" => "界 e\u0301 👩‍🚀\t\n\e"} -end -"#, - ); - let loaded = load_config(&path).unwrap(); - assert_eq!(loaded.top_bar.left, ["tabs"]); - assert_eq!(loaded.top_bar.center, ["workspace"]); - assert_eq!(loaded.top_bar.right, ["battery"]); - assert_eq!( - loaded.top_bar.segment("tabs").title, - "T{index}:{path}{modified}" - ); - assert_eq!(loaded.top_bar.segment("tabs").padding, 0); - assert_eq!(loaded.top_bar.segment("battery").leading_edge, "界"); - assert_eq!( - loaded.top_bar.segment("workspace"), - &crate::chrome::SegmentConfig::default() - ); - assert!(loaded.bottom_bar.left.is_empty()); - assert_eq!(loaded.labels["battery"], "bat 38%"); - assert_eq!(loaded.labels["workspace"], "界 e\u{301} 👩‍🚀\t\n\u{1b}"); - } - - #[test] - fn popup_configuration_accepts_ruby_symbol_and_string_anchors_independently() { - use crate::chrome::PopupAnchor; - - let fixture = Fixture::new(); - let anchors = [ - ("top_left", PopupAnchor::TopLeft), - ("top_center", PopupAnchor::TopCenter), - ("top_right", PopupAnchor::TopRight), - ("center_left", PopupAnchor::CenterLeft), - ("center", PopupAnchor::Center), - ("center_right", PopupAnchor::CenterRight), - ("bottom_left", PopupAnchor::BottomLeft), - ("bottom_center", PopupAnchor::BottomCenter), - ("bottom_right", PopupAnchor::BottomRight), - ]; - for (index, (command_name, command_anchor)) in anchors.iter().enumerate() { - let (search_name, search_anchor) = anchors[(index + 1) % anchors.len()]; - let path = fixture.file( - "config.rb", - &format!( - "Vamp.configure do |config|\n\ - config.popup.command = {{anchor: :{command_name}}}\n\ - config.popup.search = {{'anchor' => '{search_name}'}}\n\ - end" - ), - ); - let loaded = load_config(&path).unwrap(); - assert_eq!(loaded.popup.command.anchor, *command_anchor); - assert_eq!(loaded.popup.search.anchor, search_anchor); - } - } - - #[test] - fn popup_configuration_rejects_unknown_options_anchors_callbacks_and_types() { - let fixture = Fixture::new(); - for (value, expected) in [ - ("[]", "must be a Hash"), - ("nil", "must be a Hash"), - ("7", "must be a Hash"), - ("{width: 72}", "unknown popup option"), - ("{callback: -> { 'center' }}", "unknown popup option"), - ("{anchor: :middle}", "unknown popup anchor"), - ("{anchor: 'TopLeft'}", "unknown popup anchor"), - ("{anchor: nil}", "must be a String"), - ("{anchor: 7}", "must be a String"), - ("{anchor: true}", "must be a String"), - ("{anchor: []}", "must be a String"), - ("{anchor: {}}", "must be a String"), - ("{anchor: -> { :center }}", "must be a String"), - ( - "{anchor: :center, 'anchor' => :top_left}", - "duplicate chrome key", - ), - ("{7 => :center}", "chrome key must be a String"), - ] { - for mode in ["command", "search"] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.popup.{mode} = {value} }}"), - ); - let error = load_config(&path).unwrap_err(); - assert!(error.contains(expected), "{mode}={value}: {error}"); - } - } - let path = fixture.file("config.rb", "Vamp.configure { |config| config.popup = {} }"); - assert!(load_config(&path).unwrap_err().contains("undefined method")); - } - - #[test] - fn bar_namespace_is_stable_and_accepts_swapped_builtins() { - let fixture = Fixture::new(); - let path = fixture.file( - "config.rb", - r#" -Vamp.configure do |config| - bar = config.bar - raise "bar namespace must be stable" unless bar.equal?(config.bar) - bar.top = {left: [:mode, :language], right: [], mode: {padding: 0}} - config.bar.bottom = {left: [:tabs], right: [], tabs: {title: "{name}{modified}"}} -end -"#, - ); - let loaded = load_config(&path).unwrap(); - assert_eq!(loaded.top_bar.left, ["mode", "language"]); - assert_eq!(loaded.top_bar.segment("mode").padding, 0); - assert_eq!(loaded.top_bar.segment("language").padding, 1); - assert_eq!(loaded.bottom_bar.left, ["tabs"]); - assert_eq!(loaded.bottom_bar.segment("tabs").title, "{name}{modified}"); - - for setter in ["bar", "tabline", "statusline"] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.{setter} = {{}} }}"), - ); - let error = load_config(&path).unwrap_err(); - assert!(error.contains("undefined method"), "{setter}: {error}"); - } - } - - #[test] - fn chrome_configuration_rejects_invalid_types_names_and_templates() { - let fixture = Fixture::new(); - for (setting, value, expected) in [ - ("bar.top", "[]", "must be a Hash"), - ("bar.bottom", "[]", "must be a Hash"), - ("bar.top", "{left: :tabs}", "must be an Array"), - ("bar.top", "{left: [7]}", "must be a String"), - ("bar.top", "{left: [:bad_name!]}", "must match"), - ( - "bar.bottom", - "{tabs: {title: '{unknown}'}}", - "unknown title placeholder", - ), - ("bar.top", r#"{mode: {leading_edge: "\n"}}"#, "single-line"), - ("bar.top", "{language: {padding: 65}}", "between 0 and 64"), - ("bar.bottom", "{tabs: {padding: '1'}}", "must be an Integer"), - ("bar.top", "{lsp: {margin: 65}}", "between 0 and 64"), - ("bar.bottom", "{lsp: {margin: -1}}", "between 0 and 64"), - ("bar.top", "{lsp: {margin: '1'}}", "must be an Integer"), - ("bar.bottom", "{lsp: {margin: 1.5}}", "must be an Integer"), - ("bar.top", "{lsp: {margin: nil}}", "must be an Integer"), - ("bar.top", "{mode: {title: nil}}", "must be a String"), - ( - "bar.bottom", - "{tabs: {color: 'red'}}", - "unknown chrome style option", - ), - ( - "bar.top", - "{left: [], 'left' => []}", - "duplicate chrome key", - ), - ("labels", "[]", "must be a Hash"), - ("labels", "{battery: 7}", "label text must be a String"), - ("labels", "{'bad-name' => ''}", "must match"), - ] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.{setting} = {value} }}"), - ); - let error = load_config(&path).unwrap_err(); - assert!(error.contains(expected), "{setting}={value}: {error}"); - } - } - - #[test] - fn label_wire_effects_require_name_and_string_text() { - for frame in [ - serde_json::json!({"type": "label", "text": ""}), - serde_json::json!({"type": "label", "name": 7, "text": ""}), - serde_json::json!({"type": "label", "name": "battery"}), - serde_json::json!({"type": "label", "name": "battery", "text": null}), - serde_json::json!({"type": "label", "name": "battery", "text": 7}), - serde_json::json!({"type": "label", "name": "battery", "text": "", "extra": true}), - ] { - assert!(serde_json::from_value::(frame.clone()).is_err()); - assert!(serde_json::from_value::(frame).is_err()); - } - } - - #[test] - fn config_diff_excerpt_counts_accept_custom_values_and_reject_invalid_values() { - let fixture = Fixture::new(); - for (context, expand) in [(0, 7), (3, 1), (usize::MAX, usize::MAX)] { - let path = fixture.file( - "config.rb", - &format!( - "Vamp.configure {{ |config| config.diff_context_lines = {context}; config.diff_expand_lines = {expand} }}" - ), - ); - let loaded = load_config(&path).unwrap(); - assert_eq!( - (loaded.diff_context_lines, loaded.diff_expand_lines), - (context, expand) - ); - } - let frame = serde_json::json!({ - "version": 1, "type": "config", "plugins": [], "lsp": {}, - "review_directory": null, - "bindings": [], "leader": "space", "file_explorer_side": "right", - "file_picker_view": "tree", "file_explorer_view": "tree", - "top_bar": {}, "bottom_bar": {}, "labels": {}, - "scroll_distance": 0, - "highlight_trailing_whitespace": false, - "whitespace_characters": {}, - "whitespace_scope": "all", - "tab_spaces": null, "language_tab_spaces": {}, - "popup": {"command": {}, "search": {}}, - "clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7, - "diff_view_mode": "split", "diff_split_orientation": "vertical", - "resolve_history_layout": "columns", - "diff_follow_working_copy": true - }); - for setting in ["diff_context_lines", "diff_expand_lines"] { - for value in [ - "-1", - "1.5", - "\"3\"", - "true", - "false", - "nil", - "18446744073709551616", - "0", - ] { - if setting == "diff_context_lines" && value == "0" { - continue; - } - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.{setting} = {value} }}"), - ); - let error = load_config(&path).unwrap_err(); - assert!( - error.contains(setting) && error.contains("must be"), - "{value}: {error}" - ); - - let mut invalid_frame = frame.clone(); - invalid_frame[setting] = - serde_json::from_str(if value == "nil" { "null" } else { value }).unwrap(); - let error = serde_json::from_value::(invalid_frame) - .err() - .unwrap(); - assert!(error.to_string().contains(setting), "{value}: {error}"); - } - } - } - - #[test] - fn config_scroll_step_lines_controls_counted_scrolling_and_rejects_invalid_values() { - let fixture = Fixture::new(); - for (value, expected) in [ - ("nil", 30), - ("1", 3), - ("7", 21), - ("18446744073709551615", isize::MAX), - ] { - let path = fixture.file( - "config.rb", - &format!( - "Vamp.configure do |config| - config.scroll_step_lines = {value} - config.bind \"alt+j\", config.core.scroll_step_down - config.bind \"alt+k\", config.core.scroll_step_up -end" - ), - ); - let mut session = - crate::session::EditorSession::open(None, vec![], Some(&path)).unwrap(); - session.handle(crate::editor::Input::Char('3')); - session.handle(crate::editor::Input::Alt('j')); - assert_eq!( - session.editor().take_viewport_scroll(20), - expected, - "{value}" - ); - session.handle(crate::editor::Input::Char('3')); - session.handle(crate::editor::Input::Alt('k')); - let up = if expected == isize::MAX { - isize::MIN - } else { - -expected - }; - assert_eq!(session.editor().take_viewport_scroll(20), up, "{value}"); - } - for value in [ - "0", - "-1", - "1.5", - "\"3\"", - "true", - "false", - "18446744073709551616", - ] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.scroll_step_lines = {value} }}"), - ); - let error = load_config(&path).unwrap_err(); - assert!( - error.contains("scroll_step_lines") && error.contains("must be"), - "{value}: {error}" - ); - } - } - - #[test] - fn config_scroll_distance_accepts_nil_and_counts_and_rejects_invalid_values() { - let fixture = Fixture::new(); - let frame = serde_json::json!({ - "version": 1, "type": "config", "plugins": [], "lsp": {}, - "review_directory": null, - "bindings": [], "leader": "space", "file_explorer_side": "right", - "file_picker_view": "tree", "file_explorer_view": "tree", - "clipboard_mode": "system", "git_blame": true, "git_gutter": "bar", - "highlight_trailing_whitespace": false, - "whitespace_characters": {}, - "whitespace_scope": "all", - "tab_spaces": null, "language_tab_spaces": {}, - "top_bar": {}, "bottom_bar": {}, "labels": {}, - "popup": {"command": {}, "search": {}}, - "diff_context_lines": 10, "diff_expand_lines": 15, - "diff_view_mode": "split", "diff_split_orientation": "vertical", - "resolve_history_layout": "columns", - "diff_follow_working_copy": true, "scroll_distance": 0 - }); - for distance in [None, Some(0), Some(7), Some(usize::MAX)] { - let value = distance.map_or_else(|| "nil".into(), |value| value.to_string()); - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"), - ); - assert_eq!(load_config(&path).unwrap().scroll_distance, distance); - let mut valid_frame = frame.clone(); - valid_frame["scroll_distance"] = serde_json::json!(distance); - let response = serde_json::from_value::(valid_frame).unwrap(); - let Response::Config(config) = response else { - panic!("expected a configuration frame"); - }; - assert_eq!(config.scroll_distance, distance); - } - for value in [ - "-1", - "1.5", - "\"3\"", - "true", - "false", - "18446744073709551616", - ] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"), - ); - let error = load_config(&path).unwrap_err(); - assert!( - error.contains("scroll_distance") && error.contains("must be"), - "{value}: {error}" - ); - let mut invalid_frame = frame.clone(); - invalid_frame["scroll_distance"] = serde_json::from_str(value).unwrap(); - let error = serde_json::from_value::(invalid_frame) - .err() - .unwrap(); - assert!( - error.to_string().contains("scroll_distance"), - "{value}: {error}" - ); - } - } - - #[test] - fn lsp_configuration_is_disabled_unless_explicitly_enabled() { - let fixture = Fixture::new(); - for source in [ - "Vamp.configure { |_| }", - r#"Vamp.configure do |config| - config.lsp["rust"] = false - config.lsp["python"] = false - config.lsp["not-a-preset"] = false -end"#, - ] { - let path = fixture.file("config.rb", source); - let config = load_config(&path).unwrap(); - assert!(config.lsp.is_empty()); - assert!(config.plugins.is_empty()); - } - } - - #[test] - fn lsp_presets_custom_languages_and_overrides_reach_native_configuration() { - let fixture = Fixture::new(); - let path = fixture.file( - "config.rb", - r#"Vamp.configure do |config| - config.lsp["rust"] = true - config.lsp["python"] = true - config.lsp["ruby"] = true -end"#, - ); - let config = load_config(&path).unwrap(); - assert_eq!(config.lsp.len(), 4); - for (language, command, extension) in [ - ("rust", &["rust-analyzer"][..], "rs"), - ("ruby", &["solargraph", "stdio"][..], "rb"), - ] { - let server = config - .lsp - .iter() - .find(|server| server.language_id == language) - .unwrap(); - assert_eq!(server.command, command); - assert_eq!(server.extensions, [extension]); - assert_eq!(server.initialization_options, serde_json::json!({})); - assert_eq!(server.settings, serde_json::json!({})); - } - let python: Vec<_> = config - .lsp - .iter() - .filter(|server| server.language_id == "python") - .collect(); - assert_eq!(python.len(), 2); - for (server, command, initialization_options) in [ - ( - python[0], - ["ty", "server"], - serde_json::json!({"experimental": {"useUv": "off"}}), - ), - (python[1], ["ruff", "server"], serde_json::json!({})), - ] { - assert_eq!(server.command, command); - assert_eq!(server.extensions, ["py"]); - assert_eq!(server.initialization_options, initialization_options); - assert_eq!(server.settings, serde_json::json!({})); - } - - let path = fixture.file( - "config.rb", - r#"Vamp.configure do |config| - config.lsp["rust"] = { - extensions: ["rs", "ron"], - initialization_options: {cargo: {features: ["α"]}}, - settings: {"rust-analyzer" => {check: {command: "clippy"}}} - } - config.lsp["python"] = {command: ["custom-python-server", "--stdio"]} - config.lsp["ruby"] = {command: ["bundle", "exec", "solargraph", "stdio"]} - config.lsp["my-language.c++"] = { - command: ["custom server", "--flag", ""], - extensions: ["custom", "لغة"], - settings: {nested: {enabled: true, limit: 3, unset: nil}} - } -end"#, - ); - let config = load_config(&path).unwrap(); - let rust = config - .lsp - .iter() - .find(|server| server.language_id == "rust") - .unwrap(); - assert_eq!(rust.command, ["rust-analyzer"]); - assert_eq!(rust.extensions, ["rs", "ron"]); - assert_eq!( - rust.initialization_options, - serde_json::json!({"cargo": {"features": ["α"]}}) - ); - assert_eq!( - rust.settings, - serde_json::json!({"rust-analyzer": {"check": {"command": "clippy"}}}) - ); - let python = config - .lsp - .iter() - .find(|server| server.language_id == "python") - .unwrap(); - assert_eq!(python.command, ["custom-python-server", "--stdio"]); - assert_eq!(python.extensions, ["py"]); - assert_eq!(python.initialization_options, serde_json::json!({})); - assert_eq!(python.settings, serde_json::json!({})); - assert_eq!( - config - .lsp - .iter() - .filter(|server| server.language_id == "python") - .count(), - 1 - ); - let ruby = config - .lsp - .iter() - .find(|server| server.language_id == "ruby") - .unwrap(); - assert_eq!(ruby.command, ["bundle", "exec", "solargraph", "stdio"]); - assert_eq!(ruby.extensions, ["rb"]); - let custom = config - .lsp - .iter() - .find(|server| server.language_id == "my-language.c++") - .unwrap(); - assert_eq!(custom.command, ["custom server", "--flag", ""]); - assert_eq!(custom.extensions, ["custom", "لغة"]); - assert_eq!(custom.initialization_options, serde_json::json!({})); - assert_eq!( - custom.settings, - serde_json::json!({"nested": {"enabled": true, "limit": 3, "unset": null}}) - ); - } - - #[test] - fn lsp_python_common_options_and_ordered_server_overrides() { - let fixture = Fixture::new(); - let path = fixture.file( - "config.rb", - r#"Vamp.configure do |config| - config.lsp["python"] = { - extensions: ["py", "pyi"], - initialization_options: {diagnosticMode: "workspace"}, - settings: {shared: true} - } -end"#, - ); - let config = load_config(&path).unwrap(); - assert_eq!(config.lsp.len(), 2); - for (server, command) in config - .lsp - .iter() - .zip([["ty", "server"], ["ruff", "server"]]) - { - assert_eq!(server.language_id, "python"); - assert_eq!(server.command, command); - assert_eq!(server.extensions, ["py", "pyi"]); - assert_eq!( - server.initialization_options, - serde_json::json!({"diagnosticMode": "workspace"}) - ); - assert_eq!(server.settings, serde_json::json!({"shared": true})); - } - let path = fixture.file( - "config.rb", - r#"Vamp.configure do |config| - config.lsp["python"] = {servers: [ - {command: ["type-server", "--stdio"], initialization_options: {experimental: {useUv: "off"}}, settings: {first: true}}, - {command: ["lint-server"], initialization_options: {settings: {lint: {enable: true}}}} - ]} - config.lsp["custom"] = {extensions: ["custom"], servers: [ - {command: ["custom-first"]}, {command: ["custom-second"], settings: {second: true}} - ]} -end"#, - ); - let config = load_config(&path).unwrap(); - for (language, commands, extensions) in [ - ( - "python", - [&["type-server", "--stdio"][..], &["lint-server"][..]], - ["py"], - ), - ( - "custom", - [&["custom-first"][..], &["custom-second"][..]], - ["custom"], - ), - ] { - let servers: Vec<_> = config - .lsp - .iter() - .filter(|server| server.language_id == language) - .collect(); - assert_eq!(servers.len(), 2); - for (server, command) in servers.iter().zip(commands) { - assert_eq!(server.command, command); - assert_eq!(server.extensions, extensions); - } - } - let python: Vec<_> = config - .lsp - .iter() - .filter(|server| server.language_id == "python") - .collect(); - assert_eq!( - python[0].initialization_options, - serde_json::json!({"experimental": {"useUv": "off"}}) - ); - assert_eq!(python[0].settings, serde_json::json!({"first": true})); - assert_eq!( - python[1].initialization_options, - serde_json::json!({"settings": {"lint": {"enable": true}}}) - ); - assert_eq!(python[1].settings, serde_json::json!({})); - let custom: Vec<_> = config - .lsp - .iter() - .filter(|server| server.language_id == "custom") - .collect(); - assert_eq!(custom[0].initialization_options, serde_json::json!({})); - assert_eq!(custom[1].settings, serde_json::json!({"second": true})); - let path = fixture.file( - "config.rb", - r#"Vamp.configure { |config| config.lsp["python"] = {settings: {shared: true}} }"#, - ); - let config = load_config(&path).unwrap(); - assert_eq!( - config.lsp[0].initialization_options, - serde_json::json!({"experimental": {"useUv": "off"}}) - ); - assert_eq!(config.lsp[1].initialization_options, serde_json::json!({})); - } - - #[test] - fn lsp_server_lists_reject_invalid_shapes_and_ambiguous_options_locally() { - let fixture = Fixture::new(); - for (options, error) in [ - ("{servers: nil}", "nonempty array"), - ("{servers: {command: ['server']}}", "nonempty array"), - ("{servers: []}", "nonempty array"), - ( - "{servers: [true]}", - "servers[0] must be a configuration object", - ), - ("{servers: [{}]}", "servers[0] requires command argv"), - ("{servers: [{command: []}]}", "servers[0] command must be"), - ( - "{servers: [{command: ['server'], extensions: ['py']}]}", - "servers[0] has unknown option", - ), - ( - "{servers: [{command: ['server'], servers: []}]}", - "servers[0] has unknown option", - ), - ( - "{servers: [{command: ['server'], initialization_options: []}]}", - "servers[0] initialization_options must be", - ), - ( - "{servers: [{command: ['server']}, {command: ['other'], settings: nil}]}", - "servers[1] settings must be", - ), - ( - "{servers: [{command: ['server']}], command: ['other']}", - "cannot be combined", - ), - ( - "{servers: [{command: ['server']}], initialization_options: {}}", - "cannot be combined", - ), - ( - "{servers: [{command: ['server']}], settings: {}}", - "cannot be combined", - ), - ] { - let path = fixture.file( - "config.rb", - &format!("Vamp.configure {{ |config| config.lsp[\"python\"] = {options} }}"), - ); - let actual = load_config(&path).unwrap_err(); - assert!( - actual.starts_with("Ruby config: LSP language \"python\""), - "{actual}" - ); - assert!(actual.contains(error), "{options}: {actual}"); - } - } - - #[test] - fn lsp_server_limit_is_enforced_at_configuration_startup() { - let fixture = Fixture::new(); - for count in [16, 17] { - let path = fixture.file( - "config.rb", - &format!( - "Vamp.configure {{ |config| config.lsp[\"python\"] = {{servers: Array.new({count}) {{ {{command: [\"server\"]}} }} }} }}" - ), - ); - let result = load_config(&path); - if count == 16 { - assert_eq!(result.unwrap().lsp.len(), 16); - } else { - assert_eq!( - result.unwrap_err(), - "Ruby config: LSP configuration supports at most 16 configured servers" - ); - } - } - } - - #[test] - fn lsp_configuration_rejects_invalid_keys_values_and_json_options() { - let fixture = Fixture::new(); - for assignment in [ - r#"config.lsp[:rust] = true"#, - r#"config.lsp[7] = false"#, - r#"config.lsp[""] = false"#, - r#"config.lsp["bad\nkey"] = false"#, - r#"config.lsp["\xFF".b] = false"#, - r#"config.lsp["rust"] = nil"#, - r#"config.lsp["rust"] = []"#, - r#"config.lsp["rust"] = "yes""#, - r#"config.lsp["custom"] = {}"#, - r#"config.lsp["custom"] = {command: ["server"]}"#, - r#"config.lsp["custom"] = {servers: [{command: ["server"]}]}"#, - r#"config.lsp["custom"] = {extensions: ["custom"]}"#, - r#"config.lsp["rust"] = {command: "server"}"#, - r#"config.lsp["rust"] = {command: []}"#, - r#"config.lsp["rust"] = {command: [""]}"#, - r#"config.lsp["rust"] = {command: ["server", 7]}"#, - r#"config.lsp["rust"] = {command: ["server", "bad\0argument"]}"#, - r#"config.lsp["rust"] = {extensions: "rs"}"#, - r#"config.lsp["rust"] = {extensions: []}"#, - r#"config.lsp["rust"] = {extensions: [""]}"#, - r#"config.lsp["rust"] = {extensions: [7]}"#, - r#"config.lsp["rust"] = {extensions: [".rs"]}"#, - r#"config.lsp["rust"] = {extensions: ["path/rs"]}"#, - r#"config.lsp["rust"] = {extensions: ["path\\rs"]}"#, - r#"config.lsp["rust"] = {extensions: ["r s"]}"#, - r#"config.lsp["rust"] = {extensions: ["r\u0001s"]}"#, - r#"config.lsp["rust"] = {initialization_options: []}"#, - r#"config.lsp["rust"] = {settings: nil}"#, - r#"config.lsp["rust"] = {settings: {bad: Object.new}}"#, - r#"config.lsp["rust"] = {settings: {bad: Float::NAN}}"#, - r#"config.lsp["rust"] = {settings: {7 => true}}"#, - r#"config.lsp["rust"] = {unknown: true}"#, - ] { - let path = fixture.file( - "invalid.rb", - &format!("Vamp.configure {{ |config| {assignment} }}"), - ); - assert!(load_config(&path).is_err(), "{assignment}"); - } - let path = fixture.file( - "invalid.rb", - r#"Vamp.configure { |config| config.lsp["unsupported-language"] = true }"#, - ); - let error = load_config(&path).unwrap_err(); - assert!(!error.contains("Malformed Ruby protocol frame"), "{error}"); - assert!( - error.contains("\"unsupported-language\"") - && error.contains("command") - && error.contains("extensions"), - "{error}" - ); - } - - #[test] - fn lsp_wire_configuration_is_resolved_after_decoding() { - let mut frame = serde_json::json!({ - "version": 1, "type": "config", "plugins": [], "lsp": {}, - "review_directory": null, - "bindings": [], "leader": "space", "file_explorer_side": "right", - "file_picker_view": "tree", "file_explorer_view": "tree", - "git_blame": true, - "git_gutter": "bar", - "scroll_distance": 0, - "highlight_trailing_whitespace": false, - "whitespace_characters": {}, - "whitespace_scope": "all", - "tab_spaces": null, "language_tab_spaces": {}, - "top_bar": {}, "bottom_bar": {}, "labels": {}, - "popup": {"command": {}, "search": {}}, - "clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15, - "diff_view_mode": "split", "diff_split_orientation": "vertical", - "resolve_history_layout": "columns", - "diff_follow_working_copy": true - }); - let Response::Config(config) = serde_json::from_value::(frame.clone()).unwrap() - else { - panic!("expected config frame"); - }; - assert!(crate::lsp_config::resolve(config.lsp).unwrap().is_empty()); - frame["lsp"] = serde_json::json!({"rust": true}); - let Response::Config(config) = serde_json::from_value::(frame.clone()).unwrap() - else { - panic!("expected config frame"); - }; - assert_eq!( - crate::lsp_config::resolve(config.lsp).unwrap()[0].command, - ["rust-analyzer"] - ); - for value in [ - serde_json::json!(null), - serde_json::json!([]), - serde_json::json!({"": false}), - serde_json::json!({"bad\u{0085}key": false}), - serde_json::json!({"rust": null}), - serde_json::json!({"python": []}), - serde_json::json!({"unknown": true}), - serde_json::json!({"custom": {}}), - serde_json::json!({"rust": {"command": ["tool", "bad\0argument"]}}), - serde_json::json!({"rust": {"extensions": ["bad\nextension"]}}), - serde_json::json!({"rust": {"initialization_options": []}}), - serde_json::json!({"rust": {"settings": "not-an-object"}}), - serde_json::json!({"rust": {"unexpected": true}}), - serde_json::json!({"python": {"servers": []}}), - serde_json::json!({"python": {"servers": [null]}}), - serde_json::json!({"python": {"servers": [{}]}}), - serde_json::json!({"python": {"servers": [{"command": ["server"], "extensions": ["py"]}]}}), - serde_json::json!({"python": {"servers": [{"command": ["server"]}], "settings": {}}}), - ] { - frame["lsp"] = value; - let Response::Config(config) = - serde_json::from_value::(frame.clone()).unwrap() - else { - panic!("expected config frame"); - }; - assert!(crate::lsp_config::resolve(config.lsp).is_err(), "{frame}"); - } - let mut languages: serde_json::Map = (0..14) - .map(|index| { - ( - format!("custom-{index}"), - serde_json::json!({"command": ["server"], "extensions": ["custom"]}), - ) - }) - .collect(); - languages.insert("python".into(), serde_json::json!(true)); - frame["lsp"] = serde_json::Value::Object(languages); - let Response::Config(config) = serde_json::from_value::(frame.clone()).unwrap() - else { - panic!("expected config frame"); - }; - assert_eq!(crate::lsp_config::resolve(config.lsp).unwrap().len(), 16); - frame["lsp"]["ruby"] = serde_json::json!(true); - let Response::Config(config) = serde_json::from_value::(frame.clone()).unwrap() - else { - panic!("expected config frame"); - }; - assert_eq!( - crate::lsp_config::resolve(config.lsp).unwrap_err(), - "LSP configuration supports at most 16 configured servers" - ); - frame.as_object_mut().unwrap().remove("lsp"); - assert!(serde_json::from_value::(frame).is_err()); - } - - #[test] - fn malformed_config_wire_remains_a_protocol_error() { - let fixture = Fixture::new(); - for mutation in [ - "return '['", - "frame.delete('lsp')", - "frame.delete('top_bar')", - "frame.delete('bottom_bar')", - "frame.delete('labels')", - "frame.delete('popup')", - "frame['popup'] = nil", - "frame['popup'] = []", - "frame['popup'] = {'selector' => {}}", - "frame['popup'] = {'command' => nil}", - "frame['popup'] = {'search' => 7}", - "frame['popup'] = {'command' => {'anchor' => nil}}", - "frame['popup'] = {'search' => {'anchor' => 'middle'}}", - "frame['popup'] = {'command' => {'width' => 72}}", - "frame['top_bar'] = 7", - "frame['bottom_bar'] = []", - "frame['labels'] = {'battery' => 7}", - "frame['bottom_bar'] = {'tabs' => {'title' => '{unknown}'}}", - "frame['top_bar'] = {'mode' => {'leading_edge' => \"\\n\"}}", - "frame['top_bar'] = {'left' => nil}", - "frame['bottom_bar'] = {'left' => ['bad-name']}", - "frame['bottom_bar'] = {'tabs' => {'padding' => '1'}}", - "frame['top_bar'] = {'language' => {'color' => 'red'}}", - "frame['labels'] = {'bad-name' => ''}", - "frame['leader'] = 7", - "frame['unexpected'] = true", - ] { - let source = format!( - r#" -Vamp.configure {{ |config| config.lsp["ruby"] = true }} -module JSON - class << self - alias_method :original_generate, :generate - def generate(frame) - if frame['type'] == 'config' - {mutation} - end - original_generate(frame) - end - end -end -"# - ); - let path = fixture.file("config.rb", &source); - let error = load_config(&path).unwrap_err(); - assert!( - error.starts_with("Malformed Ruby protocol frame: "), - "{mutation}: {error}" - ); - } - } - - #[test] - fn crash_hang_callback_failure_and_fresh_host_recovery() { - let fixture = Fixture::new(); - for callback in [ - "exit! 19", - "sleep 10", - "editor.insert 'must not escape'; raise 'callback failed'", - "editor.insert 7", - ] { - let (mut plugin, _) = fixture.plugin(&format!( - "Vamp.plugin 'failure'\nVamp.command('fail') {{ |editor, args| {callback} }}" - )); - let error = plugin.invoke("fail", "", &context()).unwrap_err(); - if callback == "sleep 10" { - assert!(error.contains("timeout"), "{error}"); - } - assert!( - plugin.host.child.is_none() - && plugin.host.reader.is_none() - && plugin.host.writer.is_none() - ); - assert!( - plugin - .invoke("fail", "", &context()) - .unwrap_err() - .contains("disabled") - ); - let (mut recovered, _) = fixture.plugin( - "Vamp.plugin 'recovered'\nVamp.command('ok') { |editor, args| editor.message 'alive' }"); - assert!( - matches!(&recovered.invoke("ok", "", &context()).unwrap()[..], - [Effect::Message(message)] if message == "alive") - ); - } - } - - #[test] - fn nonstandard_callback_exceptions_are_reported_without_effects() { - let fixture = Fixture::new(); - for (callback, expected) in [ - ("exit 19", "SystemExit: exit"), - ("raise Interrupt, 'interrupted'", "Interrupt: interrupted"), - ] { - let (mut plugin, _) = fixture.plugin(&format!( - "Vamp.plugin 'failure'\nVamp.command('fail') {{ |editor, _| editor.insert 'must not escape'; {callback} }}" - )); - let error = plugin.invoke("fail", "", &context()).unwrap_err(); - assert!(error.contains(expected), "{error}"); - assert!( - plugin - .invoke("fail", "", &context()) - .unwrap_err() - .contains("disabled") - ); - } - } - - #[test] - fn startup_and_config_hangs_are_bounded() { - let fixture = Fixture::new(); - let path = fixture.file("hang.rb", "sleep 10"); - assert!( - RubyPlugin::start(&path, &serde_json::json!({})) - .err() - .unwrap() - .contains("timeout") - ); - assert!(load_config(&path).unwrap_err().contains("timeout")); - } - - #[test] - fn invalid_protocol_version_shape_effect_and_request_id_shutdown() { - let fixture = Fixture::new(); - for mutation in [ - "frame['version'] = 2", - "frame['id'] += 1", - "frame['effects'] = [{'type' => 'delete', 'text' => 'x'}]", - "frame['effects'] = [{'type' => 'insert', 'text' => 7}]", - "frame['unexpected'] = true", - ] { - let source = format!( - r#" -Vamp.plugin 'protocol' -Vamp.command('ok') {{ |editor, args| editor.insert 'x' }} -module JSON - class << self - alias_method :original_generate, :generate - def generate(frame) - if frame['type'] == 'result' - {mutation} - end - original_generate(frame) - end - end -end -"# - ); - let (mut plugin, _) = fixture.plugin(&source); - assert!(plugin.invoke("ok", "", &context()).is_err(), "{mutation}"); - assert!(plugin.host.child.is_none()); - } - } - - #[test] - fn bounded_frames_and_stalled_writes_shutdown() { - let fixture = Fixture::new(); - let (mut plugin, _) = fixture.plugin( - "Vamp.plugin 'large'\nVamp.command('large') { |editor, args| editor.insert 'x' * (4 * 1024 * 1024 + 1) }"); - assert!(plugin.invoke("large", "", &context()).is_err()); - assert!(plugin.host.child.is_none()); - - // Stop Ruby reading the request pipe after it sends the manifest. - let (mut plugin, _) = fixture.plugin( - r#" -Vamp.plugin 'blocked' -Vamp.command('ok') { |editor, args| editor.message 'ok' } -class IO - def gets(*args) - sleep 10 - end -end -"#, - ); - let mut big_context = context(); - big_context.text = "x".repeat(2 * 1024 * 1024); - assert!( - plugin - .invoke("ok", "", &big_context) - .unwrap_err() - .contains("timeout") - ); - assert!(plugin.host.child.is_none()); - - let (mut plugin, _) = fixture.plugin( - "Vamp.plugin 'request'\nVamp.command('ok') { |editor, args| editor.message 'ok' }", - ); - big_context.text = "x".repeat(MAX_FRAME + 1); - assert!( - plugin - .invoke("ok", "", &big_context) - .unwrap_err() - .contains("4 MiB") - ); - assert!(plugin.host.child.is_none()); - - for payload in ["'not json'", "'x' * (4 * 1024 * 1024 + 1)"] { - let source = format!( - r#" -Vamp.plugin 'bad_frame' -Vamp.command('ok') {{ |editor, args| editor.message 'ok' }} -class IO - alias_method :original_write, :write - def write(payload) - if payload.include?('"type":"result"') - original_write({payload}) - else - original_write(payload) - end - end -end -"# - ); - let (mut plugin, _) = fixture.plugin(&source); - assert!(plugin.invoke("ok", "", &context()).is_err()); - assert!(plugin.host.child.is_none()); - } - } - - #[cfg(unix)] - #[test] - fn inherited_pipes_do_not_prevent_drop_or_timeout_cleanup() { - let fixture = Fixture::new(); - let (mut plugin, _) = fixture.plugin( - r#" -Vamp.plugin 'forked' -Vamp.command('hang') do |editor, args| - fork { sleep 10 } - sleep 10 -end -"#, - ); - assert!( - plugin - .invoke("hang", "", &context()) - .unwrap_err() - .contains("timeout") - ); - assert!(plugin.host.reader.is_none() && plugin.host.writer.is_none()); - let (plugin, _) = fixture.plugin("Vamp.plugin 'drop'\nfork { sleep 10 }"); - drop(plugin); - } - - fn document(event: crate::plugin::DocumentEventKind) -> DocumentEvent { - DocumentEvent { - event, - document: crate::plugin::DocumentSnapshot { - id: 7, - revision: 11, - path: Some("buffer.txt".into()), - root: "/project".into(), - text: "α\nhello".into(), - }, - } - } - - fn collect_until( - plugin: &mut RubyPlugin, - ready: impl Fn(&[PluginEvent]) -> bool, - ) -> Vec { - let deadline = Instant::now() + STARTUP_TIMEOUT; - let mut events = Vec::new(); - loop { - events.extend(plugin.poll().unwrap()); - if ready(&events) { - return events; - } - assert!( - Instant::now() < deadline, - "Ruby event fixture did not respond" - ); - thread::sleep(Duration::from_millis(1)); - } - } - - #[test] - fn asynchronous_documents_background_updates_and_results_share_framed_transport() { - use crate::plugin::{DiagnosticSeverity, DocumentEventKind}; - let fixture = Fixture::new(); - let (mut plugin, feature) = fixture.plugin_with_options( - r##" -Vamp.plugin "async", requires: ["text", "diagnostics"], async: true -raise "options" unless Vamp.options["nested"]["items"] == ["α", 3, true] -raise "mutable options" unless Vamp.options.frozen? && Vamp.options["nested"].frozen? && - Vamp.options["nested"]["items"].frozen? && Vamp.options["nested"]["items"][0].frozen? -begin - Vamp.options["nested"]["items"] << "bad" - raise "mutation escaped" -rescue FrozenError -end -events = [] -Vamp.on_document do |event| - doc = event["document"] - raise "bad snapshot" unless doc["id"] == 7 && doc["revision"] == 11 && - doc["root"] == "/project" && doc["text"] == "α\nhello" && doc["path"] == "buffer.txt" - raise "mutable document" unless event.frozen? && doc.frozen? && doc["text"].frozen? - events << event["event"] -end -Vamp.command "ask" do |editor, _| - raise "document ordering" unless events == %w[open change save close] - raise "wrong request" unless editor.request_id > 0 - Thread.new do - puts "background output is not protocol" - Vamp.publish [ - {type: "diagnostics", buffer_id: editor.buffer_id, revision: editor.revision, - items: [{start: {row: 1, column: 0}, end: {row: 1, column: 2}, - severity: "warning", message: "actual background diagnostic"}]}, - {type: "navigate", buffer_id: editor.buffer_id, revision: editor.revision, - path: "/project/target α.txt", row: 1, column: 2} - ], request_id: editor.request_id - end - workers = 4.times.map do |worker| - Thread.new { 3.times { |item| Vamp.publish [{type: "message", text: "#{worker}:#{item}:α"}] } } - end - workers.each(&:join) - editor.message "queued result" -end -"##, - &serde_json::json!({"nested": {"items": ["α", 3, true]}}), - ); - assert!(plugin.is_async()); - assert_eq!(feature.required_capabilities, ["text", "diagnostics"]); - for event in [ - DocumentEventKind::Open, - DocumentEventKind::Change, - DocumentEventKind::Save, - DocumentEventKind::Close, - ] { - plugin.notify_document(&document(event)).unwrap(); - } - let id = plugin.queue_invoke("ask", "", &context()).unwrap(); - let events = collect_until(&mut plugin, |events| { - events.iter().any(|event| matches!(event, PluginEvent::Response { id: response_id, .. } if *response_id == id)) && - events.iter().filter(|event| matches!(event, PluginEvent::Update(_))).count() == 13 - }); - assert!(events.iter().any(|event| matches!(event, - PluginEvent::Response { id: response_id, effects } if *response_id == id && - matches!(&effects[..], [Effect::Message(text)] if text == "queued result")))); - let update = events - .iter() - .find_map(|event| match event { - PluginEvent::Update(update) if update.request_id == Some(id) => Some(update), - _ => None, - }) - .unwrap(); - assert!(matches!(&update.effects[..], - [UpdateEffect::Diagnostics { buffer_id: 7, revision: 11, items }, - UpdateEffect::Navigate { buffer_id: 7, revision: 11, path, row: 1, column: 2 }] - if items.len() == 1 && items[0].severity == DiagnosticSeverity::Warning && - items[0].message == "actual background diagnostic" && - path == Path::new("/project/target α.txt"))); - assert!(plugin.pending.is_empty()); - } - - #[test] - fn asynchronous_invocation_does_not_wait_for_callback_or_result() { - let fixture = Fixture::new(); - let (mut plugin, _) = fixture.plugin( - r#" -Vamp.plugin "blocked", async: true -Vamp.command "block" do |editor, _| - Vamp.publish [{type: "message", text: "callback entered"}], request_id: editor.request_id - sleep 10 -end -"#, - ); - let id = plugin.queue_invoke("block", "", &context()).unwrap(); - let events = collect_until(&mut plugin, |events| !events.is_empty()); - assert!(matches!(&events[..], [PluginEvent::Update(update)] - if update.request_id == Some(id))); - assert!(plugin.pending.contains(&id)); - assert!(plugin.poll().unwrap().is_empty()); - plugin.disable(); - assert!( - plugin.host.child.is_none() - && plugin.host.reader.is_none() - && plugin.host.writer.is_none() - ); - } - - #[test] - fn plugin_options_config_transport_validation_and_required_wire_fields() { - let fixture = Fixture::new(); - let path = fixture.file("config.rb", - r#"Vamp.configure { |config| config.plugin "plugin.rb", options: {nested: {argv: ["tool", "α space"]}} }"#); - let config = load_config(&path).unwrap(); - assert_eq!( - config.plugins[0].options, - serde_json::json!({"nested": {"argv": ["tool", "α space"]}}) - ); - for value in [ - "[]", - "nil", - "{bad: Object.new}", - "{bad: Float::NAN}", - "{7 => 'bad'}", - ] { - let path = fixture.file( - "invalid.rb", - &format!( - "Vamp.configure {{ |config| config.plugin 'plugin.rb', options: {value} }}" - ), - ); - assert!(load_config(&path).is_err(), "{value}"); - } - let plugin_path = fixture.file("plugin.rb", "Vamp.plugin 'options'"); - assert!(RubyPlugin::start(&plugin_path, &serde_json::json!([])).is_err()); - for frame in [ - r#"{"path":"p.rb"}"#, - r#"{"path":"p.rb","options":null}"#, - r#"{"path":"p.rb","options":[]}"#, - r#"{"path":7,"options":{}}"#, - r#"{"path":"p.rb","options":{},"extra":true}"#, - ] { - assert!( - serde_json::from_str::(frame).is_err(), - "{frame}" - ); - } - for frame in [ - r#"{"type":"manifest","version":1,"name":"p","requires":[],"commands":[]}"#, - r#"{"type":"manifest","version":1,"name":"p","requires":[],"commands":[],"async":"yes"}"#, - ] { - assert!(serde_json::from_str::(frame).is_err(), "{frame}"); - } - } - - #[test] - fn malformed_async_updates_and_unknown_response_ids_retire_host() { - let fixture = Fixture::new(); - for mutation in [ - "frame['version'] = 2", - "frame['request_id'] = 'bad'", - "frame['request_id'] = 0", - "frame['request_id'] = 999", - "frame['effects'] = [{'type' => 'message', 'text' => 7}]", - "frame['effects'] = [{'type' => 'navigate', 'buffer_id' => 7, 'revision' => 11, 'path' => '/x', 'row' => -1, 'column' => 0}]", - "frame['effects'] = [{'type' => 'diagnostics', 'buffer_id' => 7, 'revision' => 11, 'items' => [{'start' => {'row' => 0, 'column' => 0}, 'end' => {'row' => 0, 'column' => 1}, 'severity' => 'fatal', 'message' => 'x'}]}]", - "frame['unexpected'] = true", - "frame.replace({'type' => 'result', 'version' => 1, 'id' => 999, 'effects' => []})", - ] { - let (mut plugin, _) = fixture.plugin(&format!(r#" -Vamp.plugin 'protocol', async: true -Vamp.command('ask') {{ |editor, _| Vamp.publish [{{type: 'message', text: 'valid'}}], request_id: editor.request_id }} -module JSON - class << self - alias_method :original_generate, :generate - def generate(frame) - {mutation} if frame['type'] == 'update' - original_generate(frame) - end - end -end -"#)); - plugin.queue_invoke("ask", "", &context()).unwrap(); - let deadline = Instant::now() + STARTUP_TIMEOUT; - loop { - if plugin.poll().is_err() { - break; - } - assert!(Instant::now() < deadline, "{mutation}"); - thread::sleep(Duration::from_millis(1)); - } - assert!( - plugin.host.child.is_none() - && plugin.host.reader.is_none() - && plugin.host.writer.is_none(), - "{mutation}" - ); - } - } - - #[test] - fn asynchronous_queue_overflow_and_crash_are_explicit_and_cleaned_up() { - let fixture = Fixture::new(); - let (mut plugin, _) = fixture.plugin( - r#" -Vamp.plugin 'blocked', async: true -class IO - def gets(*args) - sleep 10 - end -end -"#, - ); - let mut event = document(crate::plugin::DocumentEventKind::Open); - event.document.text = "x".repeat(128 * 1024); - let error = (0..MAX_QUEUE + 2) - .find_map(|_| plugin.notify_document(&event).err()) - .unwrap(); - assert!(error.contains("queue is full"), "{error}"); - assert!( - plugin.host.child.is_none() - && plugin.host.reader.is_none() - && plugin.host.writer.is_none() - ); - - let (mut plugin, _) = fixture.plugin( - r#" -Vamp.plugin 'flood', async: true -Vamp.command('flood') { |_, _| 256.times { Vamp.publish [{type: 'message', text: 'flood'}] } } -"#, - ); - plugin.queue_invoke("flood", "", &context()).unwrap(); - let deadline = Instant::now() + STARTUP_TIMEOUT; - while plugin.host.failure.get().is_none() { - assert!( - Instant::now() < deadline, - "flood did not overflow bounded reader" - ); - thread::sleep(Duration::from_millis(1)); - } - assert!(plugin.poll().unwrap_err().contains("queue is full")); - assert!(plugin.host.child.is_none()); - - let (mut plugin, _) = fixture - .plugin("Vamp.plugin 'crash', async: true\nVamp.command('crash') { |_, _| exit! 19 }"); - plugin.queue_invoke("crash", "", &context()).unwrap(); - let deadline = Instant::now() + STARTUP_TIMEOUT; - loop { - if plugin.poll().is_err() { - break; - } - assert!(Instant::now() < deadline); - thread::sleep(Duration::from_millis(1)); - } - assert!( - plugin.host.child.is_none() - && plugin.host.reader.is_none() - && plugin.host.writer.is_none() - ); - } - - #[test] - fn shutdown_hook_runs_for_shutdown_frame_without_protocol_corruption() { - let fixture = Fixture::new(); - let marker = fixture.0.join("shutdown"); - let (mut plugin, _) = fixture.plugin_with_options( - "Vamp.plugin 'shutdown', async: true\nVamp.on_shutdown { File.write(Vamp.options['marker'], 'stopped') }", - &serde_json::json!({"marker": marker}), - ); - plugin - .host - .queue(&serde_json::json!({"type": "shutdown", "version": 1})) - .unwrap(); - let deadline = Instant::now() + STARTUP_TIMEOUT; - while plugin - .host - .child - .as_mut() - .unwrap() - .try_wait() - .unwrap() - .is_none() - { - assert!(Instant::now() < deadline); - thread::sleep(Duration::from_millis(1)); - } - assert_eq!(std::fs::read_to_string(marker).unwrap(), "stopped"); - plugin.disable(); - assert!( - plugin.host.child.is_none() - && plugin.host.reader.is_none() - && plugin.host.writer.is_none() - ); - } - - #[test] - fn ruby_rejects_invalid_document_context_and_sync_async_dsl_usage() { - let fixture = Fixture::new(); - for declaration in [ - "Vamp.plugin 'bad', async: 'yes'", - "Vamp.plugin 'bad'\nVamp.on_document { |_| }", - "Vamp.plugin 'bad'\nVamp.publish []", - ] { - let path = fixture.file("bad.rb", declaration); - assert!(RubyPlugin::start(&path, &serde_json::json!({})).is_err()); - } - for (field, value) in [ - ("root", serde_json::json!(7)), - ("buffer_id", serde_json::json!(-1)), - ("revision", serde_json::json!("old")), - ] { - let (mut plugin, _) = fixture.plugin( - "Vamp.plugin 'context', async: true\nVamp.command('ask') { |editor, _| editor.message 'must not escape' }"); - let mut frame = serde_json::json!({ - "version": 1, "type": "invoke", "id": 1, "command": "ask", "args": "", - "context": context(), - }); - frame["context"][field] = value; - plugin.pending.insert(1); - plugin.next_id = 2; - plugin.host.queue(&frame).unwrap(); - let deadline = Instant::now() + STARTUP_TIMEOUT; - loop { - match plugin.poll() { - Err(error) => { - assert!(error.contains("context"), "{error}"); - break; - } - Ok(events) => assert!(events.is_empty()), - } - assert!(Instant::now() < deadline); - thread::sleep(Duration::from_millis(1)); - } - assert!(plugin.host.child.is_none()); - } - let (mut plugin, _) = fixture.plugin( - "Vamp.plugin 'document', async: true\nVamp.on_document { |_| raise 'must not invoke invalid snapshot' }"); - plugin.host.queue(&serde_json::json!({ - "version": 1, "type": "document", "event": "open", - "document": {"id": 7, "revision": "old", "path": null, "root": "/project", "text": "text"}, - })).unwrap(); - let deadline = Instant::now() + STARTUP_TIMEOUT; - loop { - match plugin.poll() { - Err(error) => { - assert!(error.contains("invalid document snapshot"), "{error}"); - break; - } - Ok(events) => assert!(events.is_empty()), - } - assert!(Instant::now() < deadline); - thread::sleep(Duration::from_millis(1)); - } - assert!( - plugin.host.child.is_none() - && plugin.host.reader.is_none() - && plugin.host.writer.is_none() - ); - } -} +#[path = "../tests/unit/ruby.rs"] +mod tests; diff --git a/src/session.rs b/src/session.rs index 127631c..84211d5 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1806,3658 +1806,5 @@ impl EditorSession { } #[cfg(test)] -mod tests { - use super::*; - use std::{ - fs, - sync::atomic::{AtomicU64, Ordering}, - }; - - #[test] - fn resolver_load_errors_return_through_real_session_without_touching_the_draft() { - let fixture = Fixture::new(); - let path = fixture.file("buffer.txt", "disk\n"); - let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); - session - .editor - .apply(vec![Effect::Replace("draft\n".into())]) - .unwrap(); - let buffer = session.editor.buffer_id(); - session.execute("resolve"); - assert!(session.editor.resolver().unwrap().loading); - let outcome = session - .resolver_worker - .as_ref() - .unwrap() - .results - .recv_timeout(Duration::from_secs(5)) - .unwrap(); - session.editor.apply_resolver_outcome(outcome); - assert!(!session.editor.resolver().unwrap().loading); - assert!(session.editor.resolver().unwrap().error.is_some()); - session.execute("resolve.close"); - assert!( - session - .editor - .documents() - .any(|document| document.id == buffer - && document.dirty - && document.lines == ["draft", ""]) - ); - assert_eq!( - fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), - "disk\n" - ); - } - - #[test] - fn pull_request_pages_stream_concurrently_and_retry_only_the_failed_category() { - use crate::diff::{PullRequestPage, RepoKind}; - let (outgoing, results) = mpsc::channel(); - let (started, starts) = mpsc::channel(); - let (open, open_gate) = mpsc::channel::>(); - let (closed, closed_gate) = mpsc::channel::>(); - let mut gates = [Some(open_gate), Some(closed_gate)]; - let cancelled = Arc::new(AtomicBool::new(false)); - let fetches = std::array::from_fn(|index| { - let gate = gates[index].take().unwrap(); - let started = started.clone(); - let cancelled = Arc::clone(&cancelled); - move |page| { - started.send((index, page)).unwrap(); - let has_more = loop { - match gate.try_recv() { - Ok(result) => break result?, - Err(mpsc::TryRecvError::Empty) if !cancelled.load(Ordering::Acquire) => { - thread::yield_now() - } - Err(_) => return Err("cancelled".into()), - } - }; - Ok(PullRequestPage { - kind: RepoKind::Git, - remote: "origin".into(), - entries: vec![], - has_more, - }) - } - }); - let mut worker = PullRequestWorker::start_with(7, outgoing, cancelled, fetches).unwrap(); - let mut first = [ - starts.recv_timeout(Duration::from_secs(2)).unwrap(), - starts.recv_timeout(Duration::from_secs(2)).unwrap(), - ]; - first.sort_unstable(); - assert_eq!(first, [(0, 1), (1, 1)]); - let receive = |worker: &mut PullRequestWorker| { - let (id, result) = results.recv_timeout(Duration::from_secs(2)).unwrap(); - assert_eq!(id, 7); - let Ok(DiffOutcome::PullRequestPage { closed, result }) = result else { - panic!("expected independently published page"); - }; - worker.complete(closed, &result); - (closed, result) - }; - closed.send(Ok(true)).unwrap(); - let (category, page) = receive(&mut worker); - assert!(category && page.unwrap().has_more); - // Open's first request remains blocked while the active Closed category advances. - worker.update(1, [0, 0]).unwrap(); - assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), (1, 2)); - closed.send(Err("late failure".into())).unwrap(); - assert_eq!(receive(&mut worker).1.unwrap_err(), "late failure"); - worker.update(1, [0, 0]).unwrap(); - assert!(starts.try_recv().is_err()); - worker.update(1, [0, 1]).unwrap(); - assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), (1, 2)); - closed.send(Ok(false)).unwrap(); - assert!(!receive(&mut worker).1.unwrap().has_more); - open.send(Ok(false)).unwrap(); - assert!(!receive(&mut worker).0); - worker.update(2, [0, 1]).unwrap(); - assert!(starts.try_recv().is_err()); - } - - #[test] - fn closing_pull_request_listing_cancels_both_outstanding_reads() { - let (outgoing, _results) = mpsc::channel(); - let (started, starts) = mpsc::channel(); - let cancelled = Arc::new(AtomicBool::new(false)); - let fetches = std::array::from_fn(|index| { - let started = started.clone(); - let cancelled = Arc::clone(&cancelled); - move |_| { - started.send(index).unwrap(); - while !cancelled.load(Ordering::Acquire) { - thread::yield_now(); - } - Err("cancelled".into()) - } - }); - let worker = - PullRequestWorker::start_with(1, outgoing, Arc::clone(&cancelled), fetches).unwrap(); - starts.recv_timeout(Duration::from_secs(2)).unwrap(); - starts.recv_timeout(Duration::from_secs(2)).unwrap(); - drop(worker); - assert!(cancelled.load(Ordering::Acquire)); - } - - #[test] - fn normal_picker_tracks_unsaved_text_and_head_with_line_gutter_disabled() { - use crate::diff::FileStatus; - let fixture = Fixture::new(); - fixture.file("buffer.txt", "base\n"); - fixture.file("clean.txt", "clean\n"); - let git = |args: &[&str]| { - let output = std::process::Command::new("git") - .args(args) - .current_dir(&fixture.0) - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_AUTHOR_NAME", "Author") - .env("GIT_AUTHOR_EMAIL", "author@example.com") - .env("GIT_COMMITTER_NAME", "Author") - .env("GIT_COMMITTER_EMAIL", "author@example.com") - .env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z") - .env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - }; - git(&["init", "-q", "--template="]); - git(&["add", "buffer.txt", "clean.txt"]); - git(&["commit", "-qm", "baseline"]); - fixture.file("buffer.txt", "disk\n"); - fixture.file("added.txt", "new\n"); - let mut session = fixture.open( - "Vamp.configure do |config|\nconfig.git_gutter = \"none\"\nconfig.git_blame = false\nend\n", - ); - session.editor.set_project_root(fixture.0.clone()); - let now = session.last_file_refresh; - let open = |session: &mut EditorSession| { - session.handle(Input::Char(' ')); - session.handle(Input::Char('o')); - }; - let status = |session: &EditorSession| { - session - .editor - .normal_file_picker() - .unwrap() - .entries() - .find(|row| row.path == Path::new("buffer.txt")) - .unwrap() - .status - }; - let ready = |session: &mut EditorSession, clock: Instant, expected| { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - session.poll_gutter_at(clock); - let picker = session.editor.normal_file_picker().unwrap(); - let status = picker - .entries() - .find(|row| row.path == Path::new("buffer.txt")) - .expect("normal picker retained the buffer file") - .status; - if !session.gutter_pending && status == expected { - assert!( - picker.entries().any(|row| { - row.path == Path::new("clean.txt") && row.status.is_none() - }) - ); - assert!(picker.entries().any(|row| { - row.path == Path::new("added.txt") && row.status == Some(FileStatus::Added) - })); - break; - } - assert!( - Instant::now() < deadline, - "normal picker status did not settle: {}", - session.editor.message() - ); - thread::sleep(Duration::from_millis(5)); - } - }; - let settle = |session: &mut EditorSession| { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - session.poll_gutter_at(now); - if !session.gutter_pending { - break; - } - assert!( - Instant::now() < deadline, - "background file status did not settle" - ); - thread::sleep(Duration::from_millis(5)); - } - }; - settle(&mut session); - open(&mut session); - assert_eq!( - status(&session), - Some(FileStatus::Modified), - "first open must reuse status loaded while the picker was closed", - ); - ready(&mut session, now, Some(FileStatus::Modified)); - session.handle(Input::Ctrl('w')); - settle(&mut session); - open(&mut session); - assert_eq!( - status(&session), - Some(FileStatus::Modified), - "reopening must retain completed status", - ); - let request = session.gutter_request_id; - session.poll_gutter_at(now); - assert_eq!( - session.gutter_request_id, request, - "opening must not restart status work" - ); - session.handle(Input::Ctrl('w')); - session.poll_gutter_at(now); - session - .editor - .apply(vec![Effect::Replace("base\n".into())]) - .unwrap(); - assert!(session.editor.dirty()); - settle(&mut session); - open(&mut session); - assert_eq!( - status(&session), - None, - "closed-picker edits must refresh the cache" - ); - ready(&mut session, now, None); - assert_eq!( - fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), - "disk\n" - ); - session.handle(Input::Ctrl('w')); - session.poll_gutter_at(now); - session.handle(Input::Char('u')); - settle(&mut session); - open(&mut session); - assert_eq!(status(&session), Some(FileStatus::Modified)); - ready(&mut session, now, Some(FileStatus::Modified)); - git(&["add", "buffer.txt"]); - git(&["commit", "-qm", "new baseline"]); - ready(&mut session, now + Duration::from_secs(1), None); - assert!(!session.apply_picker_result( - session.gutter_request_id.wrapping_sub(1), - crate::gutter::PickerChanges { - root: fixture.0.clone(), - result: Ok(Vec::new()), - }, - )); - assert!(!session.apply_picker_result( - session.gutter_request_id, - crate::gutter::PickerChanges { - root: fixture.0.join("elsewhere"), - result: Ok(Vec::new()), - }, - )); - session.handle(Input::Ctrl('w')); - session.execute("tab.new"); - open(&mut session); - assert_eq!(status(&session), None); - assert!( - session - .editor - .normal_file_picker() - .unwrap() - .entries() - .any(|row| row.path == Path::new("added.txt") - && row.status == Some(FileStatus::Added)) - ); - } - - #[test] - fn gutter_results_follow_buffer_identity_and_reject_stale_edits() { - use crate::gutter::Change; - let fixture = Fixture::new(); - let first_path = fixture.file("first.txt", "first\nsecond"); - let second_path = fixture.file("second.txt", "other"); - let mut session = EditorSession::open(Some(first_path), vec![], None).unwrap(); - let now = session.last_gutter_refresh.unwrap(); - let first_buffer = session.editor.buffer_id(); - let first_request = session.gutter_request_id; - assert!(session.apply_gutter_result( - first_request, - vec![(first_buffer, Ok(vec![None, Some(Change::Modified)]))], - )); - session.editor.handle(Input::Down); - assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); - session - .editor - .execute(&format!("edit {}", second_path.display())); - session.poll_gutter_at(now); - let second_buffer = session.editor.buffer_id(); - let second_request = session.gutter_request_id; - assert_ne!(second_request, first_request); - assert!(!session.apply_gutter_result( - first_request, - vec![(first_buffer, Ok(vec![Some(Change::Removed), None]))], - )); - assert!(session.apply_gutter_result( - second_request, - vec![ - (first_buffer, Ok(vec![None, Some(Change::Modified)])), - (second_buffer, Ok(vec![Some(Change::Added)])), - ], - )); - assert_eq!(session.editor.gutter_change(0), Some(Change::Added)); - session.editor.execute(&format!("buffer {first_buffer}")); - assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); - session.editor.handle(Input::Char('i')); - session.editor.handle(Input::Char('x')); - assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); - session.editor.handle(Input::Char('y')); - assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); - session.editor.handle(Input::Backspace); - assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); - session.editor.handle(Input::Escape); - session.editor.handle(Input::Char('u')); - assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); - session.editor.handle(Input::Char('U')); - assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); - assert!(!session.apply_gutter_result( - second_request, - vec![(first_buffer, Ok(vec![None, Some(Change::Removed)]))], - )); - assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); - session.poll_gutter_at(now); - assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); - assert!(session.apply_gutter_result( - session.gutter_request_id, - vec![(first_buffer, Ok(vec![None, Some(Change::Added)]))], - )); - assert_eq!(session.editor.gutter_change(1), Some(Change::Added)); - session.poll_gutter_at(now + Duration::from_secs(1)); - assert!(session.apply_gutter_result( - session.gutter_request_id, - vec![(first_buffer, Ok(vec![None, None]))], - )); - assert_eq!(session.editor.gutter_change(1), None); - session.editor.execute(&format!("buffer {second_buffer}")); - assert_eq!(session.editor.gutter_change(0), Some(Change::Added)); - } - - #[test] - fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_following() { - use crate::diff::{Catalog, Category, Comparison, RepoKind, Revision, RevisionTarget}; - let fixture = Fixture::new(); - fixture.file("buffer.txt", "base"); - for (working, bookmark) in [(true, false), (false, false), (true, true), (false, true)] { - let mut session = fixture.open(""); - session.editor.execute("diff"); - let (id, _) = session.editor.take_diff_request().unwrap(); - session.editor.apply_diff_result( - id, - Ok(DiffOutcome::Catalog(Catalog { - kind: RepoKind::Git, - notice: None, - entries: [ - RevisionTarget::Working, - if bookmark { - RevisionTarget::Bookmark { - name: "base".into(), - remote: None, - } - } else { - RevisionTarget::Commit("base".into()) - }, - ] - .into_iter() - .map(|target| Revision { - target, - label: "revision".into(), - description: String::new(), - category: Category::Commits, - }) - .collect(), - })), - ); - session.editor.handle(Input::Down); - session.editor.handle(Input::Enter); - if !working { - session.editor.handle(Input::Down); - } - session.editor.handle(Input::Enter); - let (id, _) = session.editor.take_diff_request().unwrap(); - session.editor.apply_diff_result( - id, - Ok(DiffOutcome::Comparison(Comparison { - revisions: ["base".into(), "after".into()], - root: fixture.0.clone(), - before: "base".into(), - after: "after".into(), - files: Vec::new(), - editable_paths: Default::default(), - })), - ); - let now = session.last_diff_refresh; - for (tick, follow) in [(1, false), (2, true)] { - session.diff_follow_working_copy = follow; - session.refresh_checkout_diffs_at(now + Duration::from_millis(tick * 1000 - 1)); - assert!(session.editor.take_next_diff_request().is_none()); - session.refresh_checkout_diffs_at(now + Duration::from_secs(tick)); - let request = session.editor.take_next_diff_request(); - assert_eq!(request.is_some(), !working || follow || bookmark); - if let Some((_, refresh, operation)) = request { - assert!( - matches!(operation, DiffOperation::RefreshCheckout { comparison_id, follow_working, .. } - if comparison_id == id && follow_working == follow) - ); - assert!( - !session - .editor - .apply_diff_result(refresh, Ok(DiffOutcome::CheckoutRefresh(None))) - ); - } - } - } - } - - #[test] - fn blame_sources_are_deduplicated_and_stale_results_cannot_reappear() { - let fixture = Fixture::new(); - let path = fixture.file("source.txt", "first\nsecond\nthird"); - let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); - assert!(session.editor.git_blame()); - let first = session.blame_request_id; - let original = Arc::new(crate::blame::LineBlame { - commit: "0123456789".into(), - author: "Author".into(), - summary: "Original".into(), - uncommitted: false, - }); - let second = Arc::new(crate::blame::LineBlame { - commit: "0123456789".into(), - author: "Author".into(), - summary: "Second".into(), - uncommitted: false, - }); - let blame = || { - Ok(Some(vec![ - Some(original.clone()), - Some(second.clone()), - Some(second.clone()), - ])) - }; - session.handle(Input::Down); - assert_eq!(session.blame_request_id, first); - session.blame_worker = None; - assert!(session.apply_blame_result(first, blame())); - assert_eq!( - session.editor.line_blame(), - Some("Author · Second (01234567)") - ); - let label = session.editor.line_blame().unwrap().as_ptr(); - session.handle(Input::Down); - assert_eq!(session.editor.line_blame().unwrap().as_ptr(), label); - assert!(!session.poll_blame()); - session.handle(Input::Up); - session.handle(Input::Up); - assert_eq!( - session.editor.line_blame(), - Some("Author · Original (01234567)") - ); - assert_eq!(session.blame_request_id, first); - session.execute("tab.new"); - assert_eq!(session.blame_request_id, first); - assert_eq!( - session.editor.line_blame(), - Some("Author · Original (01234567)") - ); - let other = fixture.file("other.txt", "other"); - session.execute(&format!("edit {}", other.display())); - assert!(session.editor.line_blame().is_none()); - assert!(!session.apply_blame_result(first, blame())); - session.execute("buffer 1"); - assert!(!session.apply_blame_result(first, blame())); - let source = session.editor.blame_source().unwrap(); - let before_edit = session.blame_request_id; - session.handle(Input::Char('i')); - session.handle(Input::Char('x')); - assert_ne!(session.blame_request_id, before_edit); - assert!(!session.apply_blame_result(before_edit, blame())); - assert!(!session.editor.set_line_blame(source, original.clone())); - assert!(session.editor.line_blame().is_none()); - session.handle(Input::Escape); - session.execute("git_blame.toggle"); - assert!(!session.editor.git_blame()); - assert!(session.blame_worker.is_none()); - assert!(session.blame_source.is_none()); - assert!(session.editor.line_blame().is_none()); - assert!(!session.apply_blame_result(session.blame_request_id, blame())); - session.poll_blame(); - assert!(session.blame_worker.is_none()); - let config = fixture.file( - "config.rb", - "Vamp.configure { |config| config.git_blame = false }", - ); - let mut disabled = EditorSession::open(Some(path), vec![], Some(&config)).unwrap(); - assert!(!disabled.editor.git_blame()); - assert!(disabled.blame_worker.is_none()); - disabled.poll_blame(); - assert!(disabled.blame_worker.is_none()); - } - - #[test] - fn blame_navigation_uses_one_real_file_result_and_refreshes_its_baseline() { - let fixture = Fixture::new(); - let path = fixture.file("source.txt", "first\nsecond\n"); - let git = |args: &[&str]| { - let output = std::process::Command::new("git") - .args(args) - .current_dir(&fixture.0) - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_AUTHOR_NAME", "Author") - .env("GIT_AUTHOR_EMAIL", "author@example.com") - .env("GIT_COMMITTER_NAME", "Author") - .env("GIT_COMMITTER_EMAIL", "author@example.com") - .env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z") - .env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - }; - git(&["init", "-q"]); - git(&["add", "source.txt"]); - git(&["commit", "-qm", "Original"]); - fixture.file("source.txt", "first\nchanged\n"); - git(&["add", "source.txt"]); - git(&["commit", "-qm", "Second"]); - let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); - let ready = |session: &mut EditorSession| { - let deadline = Instant::now() + Duration::from_secs(10); - while session.blame_lines.is_none() { - assert!(Instant::now() < deadline, "file attribution did not finish"); - session.poll(); - thread::sleep(Duration::from_millis(5)); - } - }; - let first = session.blame_request_id; - session.handle(Input::Down); - assert_eq!(session.blame_request_id, first); - ready(&mut session); - assert!(session.editor.line_blame().unwrap().contains("Second")); - session.handle(Input::Up); - assert!(session.editor.line_blame().unwrap().contains("Original")); - session.handle(Input::Down); - assert!(session.editor.line_blame().unwrap().contains("Second")); - session.handle(Input::Down); - assert!(session.editor.line_blame().is_none()); - assert_eq!(session.blame_request_id, first); - session.handle(Input::Up); - session.handle(Input::Char('i')); - session.handle(Input::Char('x')); - assert_ne!(session.blame_request_id, first); - ready(&mut session); - assert_eq!(session.editor.line_blame(), Some("Uncommitted")); - session.handle(Input::Escape); - let edited = session.blame_request_id; - session.execute("save"); - assert_ne!(session.blame_request_id, edited); - ready(&mut session); - git(&["add", "source.txt"]); - git(&["commit", "-qm", "Saved"]); - let saved = session.blame_request_id; - session.refresh_blame(); - assert_ne!(session.blame_request_id, saved); - ready(&mut session); - assert!(session.editor.line_blame().unwrap().contains("Saved")); - fixture.file("source.txt", "first\nxchanged\n"); - let refreshed = session.blame_request_id; - assert!(!session.editor.refresh_files()); - session.synchronize_documents(); - assert_ne!(session.blame_request_id, refreshed); - ready(&mut session); - session.handle(Input::Char('i')); - session.handle(Input::Char('x')); - ready(&mut session); - assert!(session.editor.dirty()); - fs::remove_file(path).unwrap(); - assert!(session.editor.refresh_files()); - session.synchronize_documents(); - assert!(session.blame_source.is_none()); - assert!(session.editor.line_blame().is_none()); - } - - #[test] - fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() { - use crate::plugin::Utf16Range; - - let fixture = Fixture::new(); - let source = fixture.file("buffer.txt", "source"); - let target = fixture.file("target.txt", "A😀e\u{301}Z\n"); - let mut session = EditorSession::open(Some(source.clone()), vec![], None).unwrap(); - session.dispatch(Invocation { - plugin: "core".into(), - command: "lsp.definition".into(), - args: String::new(), - }); - let buffer_id = session.editor.buffer_id(); - let revision = session.editor.text_revision(); - let request_id = session.lsp_request_id; - let span = Utf16Range { - start: (0, 5), - end: (0, 6), - }; - let invalid = Utf16Range { - end: (9, 0), - ..span - }; - let update = |range, target_range| PluginUpdate { - request_id: Some(request_id), - effects: vec![UpdateEffect::NavigateUtf16 { - buffer_id, - revision, - path: target.clone(), - range, - target_range, - }], - }; - for (range, target_range) in [(invalid, None), (span, Some(invalid))] { - assert!( - session - .apply_update("core", update(range, target_range)) - .unwrap() - ); - assert_eq!(session.editor.buffer_id(), buffer_id); - assert_eq!(session.editor.path(), Some(source.as_path())); - assert_eq!(session.editor.buffer_count(), 1); - assert_eq!(session.editor.tab_count(), 1); - assert_eq!(session.editor.cursor(), Cursor::default()); - assert!(session.editor.message().contains("definition failed")); - } - assert!(session.apply_update("fixture", update(span, None)).is_err()); - assert_eq!(session.editor.buffer_id(), buffer_id); - assert!( - session - .apply_update("core", update(span, Some(span))) - .unwrap() - ); - assert_eq!(session.editor.path(), Some(target.as_path())); - assert_eq!(session.editor.cursor(), Cursor { row: 0, column: 3 }); - assert_eq!(session.editor.tab_count(), 1); - session.handle(Input::Ctrl('o')); - assert_eq!(session.editor.path(), Some(source.as_path())); - assert_eq!(session.editor.cursor(), Cursor::default()); - } - - static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); - - #[test] - fn render_swaps_preserve_session_sources_and_pending_effects_do_not_cross_panes() { - let fixture = Fixture::new(); - let path = fixture.file("buffer.txt", "source"); - let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); - let source = InvocationSource { - pane_id: session.editor.pane_id(), - buffer_id: session.editor.buffer_id(), - revision: session.editor.text_revision(), - }; - session - .requests - .entry("fixture".into()) - .or_default() - .pending - .insert(1, source); - session.requests.entry("fixture".into()).or_default().latest = Some((1, source)); - session.handle(Input::Char('|')); - assert_eq!(session.editor.buffer_id(), source.buffer_id); - assert_eq!(session.editor.cursor(), Cursor::default()); - assert!(!session.request_is_current("fixture", Some(1), source)); - assert!( - !session - .apply_response("fixture", 1, vec![Effect::Insert("wrong pane".into())]) - .unwrap() - ); - session.requests.entry("core".into()).or_default().latest = Some(( - 2, - InvocationSource { - pane_id: session.editor.pane_id(), - ..source - }, - )); - session.lsp_source_cursor = Some(session.editor.cursor()); - session.lsp_source_mode = session.editor.mode(); - session.lsp_source_view = Some(( - session.editor.tab_id(), - session.editor.diff().and_then(|diff| diff.comparison_id), - )); - session.editor_mut().with_render_pane(1, |pane| { - assert_eq!(pane.pane_id(), 1); - assert_eq!(pane.lines(), ["source"]); - }); - session.synchronize_documents(); - assert!(session.requests["core"].latest.is_some()); - assert_eq!(session.editor.pane_id(), 2); - session.handle(Input::Alt('h')); - assert!(session.requests["core"].latest.is_none()); - assert_eq!(session.editor.lines(), ["source"]); - } - - #[test] - fn native_semantic_batches_are_atomic_and_malformed_updates_clear_hidden_overlays() { - use crate::plugin::{SemanticToken, SemanticTokenKind}; - use std::sync::Arc; - let fixture = Fixture::new(); - fixture.file("buffer.txt", "source"); - let target = fixture.file("target.txt", "target"); - let mut session = fixture.open(""); - let token = SemanticToken { - start: TextPosition { row: 0, column: 0 }, - end: TextPosition { row: 0, column: 1 }, - kind: SemanticTokenKind::Variable, - modifiers: 0, - }; - let tokens: Arc<[SemanticToken]> = Arc::from([token]); - let semantic = |buffer_id, revision, tokens| UpdateEffect::SemanticTokens { - buffer_id, - revision, - tokens, - }; - let update = |effects| PluginUpdate { - request_id: None, - effects, - }; - session - .apply_update("core", update(vec![semantic(1, 0, Some(tokens.clone()))])) - .unwrap(); - session.execute(&format!("edit {}", target.display())); - session - .apply_update("core", update(vec![semantic(2, 0, Some(tokens.clone()))])) - .unwrap(); - let diagnostic = crate::plugin::Diagnostic { - start: token.start, - end: token.end, - severity: crate::plugin::DiagnosticSeverity::Warning, - message: "unchanged".into(), - }; - session - .editor - .replace_diagnostics("plugin", 2, 0, vec![diagnostic.clone()]) - .unwrap(); - let malformed: Arc<[SemanticToken]> = Arc::from([SemanticToken { - end: TextPosition { row: 99, column: 0 }, - ..token - }]); - let before_message = session.editor.message().to_owned(); - let result = session.apply_update( - "core", - update(vec![ - UpdateEffect::Message { - text: "must not apply".into(), - }, - UpdateEffect::Diagnostics { - buffer_id: 2, - revision: 0, - items: vec![diagnostic.clone()], - }, - semantic(1, 0, Some(tokens.clone())), - semantic(2, 0, Some(malformed.clone())), - ]), - ); - assert!(result.is_err()); - assert_eq!(session.editor.message(), before_message); - assert!(session.editor.semantic_tokens().is_empty()); - assert_eq!(session.editor.diagnostics().count(), 1); - session.execute("buffer 1"); - assert!(session.editor.semantic_tokens().is_empty()); - assert!( - session - .apply_update( - "core", - update(vec![ - semantic(999, 0, Some(malformed.clone())), - semantic(1, 99, Some(malformed)), - semantic(1, 0, Some(tokens.clone())), - ]) - ) - .unwrap() - ); - assert!(std::ptr::eq( - session.editor.semantic_tokens(), - tokens.as_ref() - )); - assert_eq!(session.editor.lines(), ["source"]); - assert_eq!(session.editor.text_revision(), 0); - assert!(!session.editor.dirty()); - assert!( - session - .apply_update("core", update(vec![semantic(1, 0, None)])) - .unwrap() - ); - assert!(session.editor.semantic_tokens().is_empty()); - } - - struct Fixture(PathBuf); - - impl Fixture { - fn new() -> Self { - let path = std::env::temp_dir().join(format!( - "vamp-session-{}-{}", - std::process::id(), - NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed), - )); - fs::create_dir(&path).unwrap(); - Self(path) - } - - fn file(&self, name: &str, text: &str) -> PathBuf { - let path = self.0.join(name); - fs::write(&path, text).unwrap(); - path - } - - fn open(&self, config: &str) -> EditorSession { - let config = self.file("config.rb", config); - let mut session = EditorSession::open( - Some(self.0.join("buffer.txt")), - vec!["text".into()], - Some(&config), - ) - .unwrap(); - session.editor.set_review_directory(self.0.join("reviews")); - // Legacy editing scenarios exercise the internal register, never the user's desktop. - session.editor.set_clipboard_mode("separate").unwrap(); - session - } - - #[cfg(unix)] - fn system_session(&self, config: &str) -> EditorSession { - use std::os::unix::fs::PermissionsExt; - let config = self.file("config.rb", config); - let mut session = EditorSession::open( - Some(self.0.join("buffer.txt")), - vec!["text".into()], - Some(&config), - ) - .unwrap(); - self.file("system.txt", ""); - let reader = self.file("clip-read", "#!/bin/sh\n[ -e \"${0%/*}/read.fail\" ] && exit 13\nexec /bin/cat \"${0%/*}/system.txt\"\n"); - let writer = self.file("clip-write", "#!/bin/sh\n[ -e \"${0%/*}/write.fail\" ] && exit 13\nexec /bin/cat > \"${0%/*}/system.txt\"\n"); - fs::set_permissions(&reader, fs::Permissions::from_mode(0o700)).unwrap(); - fs::set_permissions(&writer, fs::Permissions::from_mode(0o700)).unwrap(); - session - .editor - .set_system_clipboard(crate::clipboard::SystemClipboard::with_commands( - reader, writer, - )); - session - } - - fn healthy(&self) { - self.file( - "healthy.rb", - r#" -Vamp.plugin "healthy", requires: ["text"] -Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" } -"#, - ); - } - fn asynchronous(&self, file: Option) -> EditorSession { - self.file( - "generic.rb", - r#" -Vamp.plugin "generic_fixture", requires: ["text", "diagnostics"], async: true -log_mutex = Mutex.new -record = ->(value) { log_mutex.synchronize { File.open(Vamp.options.fetch("log"), "a") { |file| file.puts JSON.generate(value) } } } -Vamp.on_document { |event| record.call(event) } -Vamp.command("fixture_barrier") { |editor, args| editor.message args } -Vamp.command("fixture_replace") { |editor, args| editor.replace args } -Vamp.command("fixture_crash") { |_editor, _args| Process.exit!(7) } -["fixture_publish", "fixture_hold"].each do |name| - Vamp.command(name) do |editor, args| - payload = JSON.parse(args) - effects = payload.fetch("effects") - effects.each do |effect| - next unless ["diagnostics", "navigate"].include?(effect["type"]) - effect["buffer_id"] ||= editor.buffer_id - effect["revision"] ||= editor.revision - end - record.call({"invoke" => name, "buffer_id" => editor.buffer_id, "revision" => editor.revision, - "row" => editor.row, "column" => editor.column, "request_id" => editor.request_id}) - if name == "fixture_hold" - Thread.new do - gate = payload.fetch("gate") - sleep 0.002 until File.exist?(gate) - Vamp.publish(effects + [{"type" => "message", "text" => "delivered #{gate}"}], request_id: editor.request_id) - end - else - Vamp.publish(effects, request_id: editor.request_id) - end - end -end -"#, - ); - let log = self.0.join("events.jsonl"); - self.file("events.jsonl", ""); - let config = self.file( - "async-config.rb", - &format!( - "Vamp.configure {{ |config| config.clipboard_mode = \"separate\"; config.plugin \"generic.rb\", options: {{log: {}}} }}", - serde_json::to_string(&log.to_string_lossy()).unwrap(), - ), - ); - EditorSession::open( - file, - vec!["text".into(), "diagnostics".into()], - Some(&config), - ) - .unwrap() - } - - fn events(&self) -> Vec { - fs::read_to_string(self.0.join("events.jsonl")) - .unwrap() - .lines() - .map(|line| serde_json::from_str(line).unwrap()) - .collect() - } - } - - impl Drop for Fixture { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - #[cfg(unix)] - #[test] - fn invalid_clipboard_policy_retains_system_default_and_valid_bindings() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "abc"); - let mut session = fixture.system_session( - r#" -Vamp.configure do |config| - config.clipboard_mode = "combined" - config.bind "n", config.core.right -end -"#, - ); - assert_eq!( - session.editor().clipboard_mode(), - crate::clipboard::ClipboardMode::System - ); - session.handle(Input::Char('n')); - session.handle(Input::Char('v')); - session.handle(Input::Char('y')); - assert_eq!( - fs::read_to_string(fixture.0.join("system.txt")).unwrap(), - "b" - ); - assert_eq!(session.editor().lines(), &["abc"]); - } - - #[cfg(unix)] - #[test] - fn default_system_clipboard_tracks_external_text_and_preserves_cut_failures() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "alpha beta"); - let mut session = fixture.system_session(""); - assert_eq!( - session.editor().clipboard_mode(), - crate::clipboard::ClipboardMode::System - ); - fixture.file("system.txt", "external"); - session.handle(Input::Char('P')); - assert_eq!(session.editor().lines(), &["externalalpha beta"]); - session.handle(Input::Char('u')); - for ch in "vey".chars() { - session.handle(Input::Char(ch)); - } - assert_eq!( - fs::read_to_string(fixture.0.join("system.txt")).unwrap(), - "alpha" - ); - fixture.file("system.txt", "outside"); - session.handle(Input::Home); - session.handle(Input::Char('P')); - assert_eq!(session.editor().lines(), &["outsidealpha beta"]); - fixture.file("system.txt", "fresh"); - session.handle(Input::Home); - session.handle(Input::Char('.')); - assert_eq!(session.editor().lines(), &["outsideoutsidealpha beta"]); - session.handle(Input::Char('u')); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["alpha beta"]); - session.handle(Input::Home); - session.handle(Input::Char('v')); - session.handle(Input::Char('e')); - let selection = session.editor().selections(); - fixture.file("write.fail", ""); - session.handle(Input::Char('c')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); - assert_eq!(session.editor().selections(), selection); - assert_eq!(session.editor().lines(), &["alpha beta"]); - assert_eq!( - fs::read_to_string(fixture.0.join("system.txt")).unwrap(), - "fresh" - ); - fs::remove_file(fixture.0.join("write.fail")).unwrap(); - session.handle(Input::Escape); - session.handle(Input::Home); - fixture.file("system.txt", "\u{1b}"); - session.handle(Input::Char('P')); - assert_eq!(session.editor().lines(), &["alpha beta"]); - assert_eq!(session.editor().cursor(), crate::editor::Cursor::default()); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - } - - #[cfg(unix)] - #[test] - fn separate_clipboards_keep_internal_and_explicit_system_routes_independent() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "alpha beta"); - let mut session = fixture.system_session( - r#" -Vamp.configure do |config| - config.clipboard_mode = "separate" - config.bind "ctrl+s", config.core.copy_system, mode: config.modes.visual - config.bind "ctrl+g", config.core.paste_system_before -end -"#, - ); - fixture.file("system.txt", "outside"); - for ch in "vey".chars() { - session.handle(Input::Char(ch)); - } - assert_eq!( - fs::read_to_string(fixture.0.join("system.txt")).unwrap(), - "outside" - ); - for ch in "wve".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Ctrl('s')); - assert_eq!( - fs::read_to_string(fixture.0.join("system.txt")).unwrap(), - "beta" - ); - session.handle(Input::Home); - session.handle(Input::Char('P')); - assert_eq!(session.editor().lines(), &["alphaalpha beta"]); - session.handle(Input::Char('u')); - session.handle(Input::Home); - session.handle(Input::Ctrl('g')); - assert_eq!(session.editor().lines(), &["betaalpha beta"]); - session.handle(Input::Char('u')); - session.handle(Input::Home); - session.handle(Input::Char('v')); - session.handle(Input::Char('e')); - fixture.file("write.fail", ""); - session.handle(Input::Char('d')); - assert_eq!(session.editor().lines(), &[" beta"]); - assert_eq!( - fs::read_to_string(fixture.0.join("system.txt")).unwrap(), - "beta" - ); - } - - #[test] - fn selection_bindings_remap_palette_navigation_without_changing_editing_bindings() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "one two three\none two three"); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.bind "ctrl+j", nil, mode: config.modes.selection - config.bind "ctrl+f", config.core.down, mode: config.modes.selection - config.bind "ctrl+b", config.core.up, mode: config.modes.selection -end -"#, - ); - session.handle(Input::Down); - session.handle(Input::Ctrl('p')); - for ch in "core.word".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Ctrl('j')); - assert_eq!(session.editor().palette().unwrap().selected, 0); - assert_eq!( - session.editor().cursor(), - crate::editor::Cursor { row: 1, column: 0 } - ); - session.handle(Input::Ctrl('f')); - assert_eq!( - session.editor().palette().unwrap().entries[1].name, - "core.word_next" - ); - assert_eq!(session.editor().palette().unwrap().selected, 1); - session.handle(Input::Ctrl('b')); - assert_eq!(session.editor().palette().unwrap().selected, 0); - session.handle(Input::Ctrl('f')); - session.handle(Input::Enter); - assert!(session.editor().palette().is_none()); - assert_eq!( - session.editor().cursor(), - crate::editor::Cursor { row: 1, column: 4 } - ); - session.handle(Input::Ctrl('k')); - assert_eq!(session.editor().selection_count(), 2); - assert_eq!( - session.editor().lines(), - &["one two three", "one two three"] - ); - } - - #[test] - fn configured_picker_presentations_preserve_editing_and_docked_focus() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "alpha"); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.file_explorer_side = "left" - config.bind "ctrl+o", config.core.file_picker - config.bind "ctrl+e", config.core.file_explorer -end -"#, - ); - assert_eq!( - session.editor().file_explorer_side(), - crate::files::DockSide::Left - ); - session.handle(Input::Ctrl('e')); - assert_eq!( - session.editor().file_picker().unwrap().presentation, - crate::files::FilePresentation::Docked - ); - assert_eq!( - session.editor().file_picker().unwrap().view, - crate::files::FileView::Tree - ); - assert!(!session.editor().file_picker().unwrap().searching); - session.handle(Input::Char('i')); - for ch in "dir e\u{301}".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Backspace); - assert_eq!(session.editor().file_picker().unwrap().query, "dir "); - session.handle(Input::Ctrl('h')); - assert_eq!(session.editor().file_picker().unwrap().query, "dir "); - session.handle(Input::Escape); - assert!(!session.editor().file_picker().unwrap().searching); - assert!(session.editor().file_picker().unwrap().focused); - assert_eq!(session.editor().file_picker().unwrap().query, "dir "); - session.handle(Input::Ctrl('u')); - assert_eq!(session.editor().file_picker().unwrap().query, ""); - assert!(!session.editor().file_picker().unwrap().searching); - session.handle(Input::Char('i')); - for ch in "dir ".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Ctrl('u')); - assert_eq!(session.editor().file_picker().unwrap().query, ""); - assert!(session.editor().file_picker().unwrap().searching); - for ch in "dir ".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Escape); - assert_eq!(session.editor().file_picker().unwrap().query, "dir "); - session.handle(Input::Escape); - assert!(!session.editor().file_picker().unwrap().focused); - session.handle(Input::Char('i')); - for ch in " a b ".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Escape); - assert_eq!(session.editor().lines(), &[" a b alpha"]); - session.handle(Input::Ctrl('o')); - assert_eq!( - session.editor().file_picker().unwrap().presentation, - crate::files::FilePresentation::Floating - ); - assert_eq!( - session.editor().file_picker().unwrap().view, - crate::files::FileView::Tree - ); - assert_eq!(session.editor().file_picker().unwrap().query, "dir "); - assert!(session.editor().file_picker().unwrap().searching); - session.handle(Input::Escape); - assert!(session.editor().file_picker().unwrap().focused); - session.handle(Input::Escape); - assert!(session.editor().file_picker().is_none()); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["alpha"]); - } - - #[test] - fn configured_flat_dock_and_tree_picker_keep_control_h_distinct_from_backspace() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "alpha"); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.file_explorer_view = "list" - config.file_picker_view = "tree" - config.bind "ctrl+f", config.core.right, mode: config.modes.tree -end -"#, - ); - session.handle(Input::Char(' ')); - session.handle(Input::Char('e')); - assert_eq!( - session.editor().file_picker().unwrap().view, - crate::files::FileView::List - ); - for ch in "e\u{301}".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Ctrl('h')); - assert_eq!(session.editor().file_picker().unwrap().query, "e\u{301}"); - session.handle(Input::Backspace); - assert_eq!(session.editor().file_picker().unwrap().query, ""); - session.handle(Input::Ctrl('w')); - session.handle(Input::Char(' ')); - session.handle(Input::Char('o')); - assert_eq!( - session.editor().file_picker().unwrap().view, - crate::files::FileView::Tree - ); - assert!(session.editor().file_picker().unwrap().searching); - session.handle(Input::Char('x')); - session.handle(Input::Ctrl('h')); - assert_eq!(session.editor().file_picker().unwrap().query, "x"); - session.handle(Input::Escape); - assert!(!session.editor().file_picker().unwrap().searching); - assert!(session.editor().file_picker().unwrap().focused); - assert_eq!(session.editor().file_picker().unwrap().query, "x"); - session.handle(Input::Ctrl('t')); - assert_eq!( - session.editor().file_picker().unwrap().view, - crate::files::FileView::List - ); - session.handle(Input::Escape); - assert!(session.editor().file_picker().is_none()); - session.handle(Input::Char('i')); - session.handle(Input::Char('x')); - session.handle(Input::Ctrl('h')); - assert_eq!(session.editor().lines(), &["xalpha"]); - session.handle(Input::Backspace); - assert_eq!(session.editor().lines(), &["alpha"]); - } - - #[test] - fn invalid_explorer_preferences_preserve_defaults_and_valid_bindings() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "abc"); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.file_explorer_side = "diagonal" - config.file_explorer_view = "grid" - config.file_picker_view = "grid" - config.bind "n", config.core.right -end -"#, - ); - assert_eq!( - session.editor().file_explorer_side(), - crate::files::DockSide::Right - ); - assert_eq!( - session.editor().file_explorer_view(), - crate::files::FileView::Tree - ); - session.handle(Input::Char('n')); - assert_eq!( - session.editor().cursor(), - crate::editor::Cursor { row: 0, column: 1 } - ); - session.handle(Input::Char('i')); - session.handle(Input::Char('!')); - session.handle(Input::Escape); - assert_eq!(session.editor().lines(), &["a!bc"]); - } - - #[test] - fn diff_display_preferences_survive_reopening() { - use crate::{diff::ViewMode, panes::SplitAxis}; - let fixture = Fixture::new(); - fixture.file("buffer.txt", "abc"); - for (mode, orientation, expected_mode, expected_axis) in [ - ( - "unified", - "horizontal", - ViewMode::Unified, - SplitAxis::Horizontal, - ), - ( - "split", - "horizontal", - ViewMode::Split, - SplitAxis::Horizontal, - ), - ("split", "vertical", ViewMode::Split, SplitAxis::Vertical), - ] { - let mut session = fixture.open(&format!( - r#"Vamp.configure do |config| - config.diff_view_mode = "{mode}" - config.diff_split_orientation = "{orientation}" - config.bind "n", config.core.right -end"# - )); - session.handle(Input::Char('n')); - assert_eq!(session.editor().cursor().column, 1); - for _ in 0..2 { - session.execute("diff"); - let diff = session.editor().diff().unwrap(); - assert_eq!(diff.view_mode, expected_mode); - assert_eq!(diff.split_axis, expected_axis); - session.execute("diff.close"); - assert!(session.editor().diff().is_none()); - assert_eq!(session.editor().lines(), &["abc"]); - } - } - } - - #[test] - fn no_config_uses_space_leader_for_palette_and_write() { - let fixture = Fixture::new(); - let buffer = fixture.file("buffer.txt", "base"); - let mut session = - EditorSession::open(Some(buffer.clone()), vec!["text".into()], None).unwrap(); - session.handle(Input::Char(' ')); - assert_eq!(session.editor().sequence_pending(), Some("Space")); - session.handle(Input::Char(' ')); - assert!(session.editor().palette().is_some()); - assert!(session.editor().sequence_pending().is_none()); - session.handle(Input::Escape); - session.handle(Input::Char('i')); - session.handle(Input::Char('!')); - session.handle(Input::Escape); - session.handle(Input::Char(' ')); - session.handle(Input::Char('w')); - assert_eq!(fs::read_to_string(buffer).unwrap(), "!base"); - assert!(!session.editor().dirty()); - } - - #[test] - fn ruby_leader_sequences_share_one_config_and_plugin_host_across_buffers() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "alpha"); - let second = fixture.file("second.txt", "beta"); - fixture.file( - "shared.rb", - r##" -Vamp.plugin "shared", requires: ["text"] -calls = 0 -Vamp.command("stamp") do |editor, _| - calls += 1 - editor.insert "#{calls}:#{File.basename(editor.path)}:" -end -Vamp.command("fail") { |_, _| raise "shared host failure" } -"##, - ); - let mut session = fixture.open( - r#" -File.open("loads.txt", "a") { |file| file.puts "loaded" } -Vamp.configure do |config| - config.leader = "," - config.plugin "shared.rb" - config.bind "leader z n", "stamp" - config.bind "leader z f", "fail" -end -"#, - ); - session.handle(Input::Char(',')); - session.handle(Input::Char('z')); - assert_eq!(session.editor().sequence_pending(), Some(", z")); - assert_eq!(session.editor().lines(), &["alpha"]); - session.handle(Input::Char('n')); - assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]); - assert!(session.editor().sequence_pending().is_none()); - session.handle(Input::Char(',')); - session.handle(Input::Char('z')); - session.execute(&format!("edit {}", second.display())); - assert!(session.editor().sequence_pending().is_none()); - for key in [',', 'z', 'n'] { - session.handle(Input::Char(key)); - } - assert_eq!(session.editor().lines(), &["2:second.txt:beta"]); - for key in [',', 'b', 'p'] { - session.handle(Input::Char(key)); - } - assert_eq!(session.editor().buffer_id(), 1); - session.handle(Input::Home); - for key in [',', 'z', 'n'] { - session.handle(Input::Char(key)); - } - assert_eq!( - session.editor().lines(), - &["3:buffer.txt:1:buffer.txt:alpha"] - ); - assert_eq!( - fs::read_to_string(fixture.0.join("loads.txt")).unwrap(), - "loaded\n" - ); - for key in [',', 'z', 'f'] { - session.handle(Input::Char(key)); - } - assert!( - session - .editor() - .message() - .contains("Plugin shared disabled:") - ); - for key in [',', 'z', 'n'] { - session.handle(Input::Char(key)); - } - assert_eq!( - session.editor().lines(), - &["3:buffer.txt:1:buffer.txt:alpha"] - ); - session.handle(Input::Ctrl('p')); - assert!( - !session - .editor() - .palette() - .unwrap() - .entries - .iter() - .any(|entry| entry.owner == "shared") - ); - } - - #[test] - fn ruby_disabled_leader_suffix_and_cancellation_keep_counts_and_selections_safe() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "abcdef"); - fixture.healthy(); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.leader = "," - config.plugin "healthy.rb" - config.bind "leader z n", "healthy" - config.bind "leader z x", nil - config.bind "leader z h", config.core.left, mode: config.modes.visual -end -"#, - ); - for last in ['x', 'd'] { - for key in ['3', ',', 'z', last] { - session.handle(Input::Char(key)); - } - assert!(session.editor().sequence_pending().is_none()); - assert_eq!(session.editor().lines(), &["abcdef"]); - session.handle(Input::Char('l')); - } - assert_eq!(session.editor().cursor().column, 2); - for key in ['3', ',', 'z'] { - session.handle(Input::Char(key)); - } - session.handle(Input::Paste("n:q!\n".into())); - assert!(session.editor().sequence_pending().is_none()); - session.handle(Input::Char('l')); - assert_eq!(session.editor().cursor().column, 3); - assert_eq!(session.editor().lines(), &["abcdef"]); - session.handle(Input::Char('v')); - let selected = session.editor().selections(); - for key in ['3', ',', 'z'] { - session.handle(Input::Char(key)); - } - session.handle(Input::Escape); - assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); - assert_eq!(session.editor().selections(), selected); - assert!(session.editor().sequence_pending().is_none()); - session.handle(Input::Char('h')); - assert_eq!(session.editor().cursor().column, 2); - assert_eq!(session.editor().lines(), &["abcdef"]); - assert!(!session.editor().should_quit()); - } - - #[test] - fn invalid_backend_leader_retains_space_and_installs_valid_config_bindings() { - for key in ["not-a-key", "esc", "7", "i"] { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "base"); - fixture.healthy(); - let mut session = fixture.open(&format!( - r#" -Vamp.configure do |config| - config.leader = "{key}" - config.plugin "healthy.rb" - config.bind "ctrl+f", config.core.right - config.bind "leader z n", "healthy" -end -"#, - )); - assert!( - session - .editor() - .message() - .contains(&format!("leader {key:?}:")) - ); - assert!(!session.editor().message().contains("config disabled")); - session.handle(Input::Ctrl('f')); - assert_eq!(session.editor().cursor().column, 1); - session.handle(Input::Char(' ')); - assert_eq!(session.editor().sequence_pending(), Some("Space")); - session.handle(Input::Char('z')); - session.handle(Input::Char('n')); - assert_eq!(session.editor().lines(), &["healthy "]); - } - } - - #[test] - fn buffers_share_config_and_plugin_host_but_keep_history_and_protect_hidden_edits() { - let fixture = Fixture::new(); - let first = fixture.file("buffer.txt", "alpha"); - let second = fixture.file("second.txt", "beta"); - fixture.file( - "shared.rb", - r##" -Vamp.plugin "shared", requires: ["text"] -calls = 0 -Vamp.command("stamp") do |editor, _| - calls += 1 - editor.insert "#{calls}:#{File.basename(editor.path)}:" -end -Vamp.command("fail") { |_, _| raise "shared host failure" } -"##, - ); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.plugin "shared.rb" - config.bind "g", "stamp" -end -"#, - ); - session.handle(Input::Char('g')); - assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]); - session.execute(&format!("edit {}", second.display())); - assert_eq!(session.editor().buffer_id(), 2); - session.handle(Input::Char('g')); - assert_eq!(session.editor().lines(), &["2:second.txt:beta"]); - session.execute("bdelete"); - assert_eq!(session.editor().buffer_id(), 2); - assert_eq!(session.editor().buffer_count(), 2); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["beta"]); - session.execute("q"); - assert!(!session.editor().should_quit()); - session.execute("wq"); - assert!(!session.editor().should_quit()); - assert_eq!(fs::read_to_string(&first).unwrap(), "alpha"); - assert_eq!(fs::read_to_string(&second).unwrap(), "beta"); - session.execute("buffer 1"); - assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["alpha"]); - session.handle(Input::Char('g')); - assert_eq!(session.editor().lines(), &["3:buffer.txt:alpha"]); - session.execute("w"); - assert_eq!(fs::read_to_string(&first).unwrap(), "3:buffer.txt:alpha"); - session.execute("bnext"); - assert_eq!(session.editor().lines(), &["beta"]); - session.execute("fail"); - assert!(session.editor().message().contains("disabled")); - session.execute("buffer 1"); - session.handle(Input::Char('g')); - assert_eq!(session.editor().lines(), &["3:buffer.txt:alpha"]); - session.execute("q"); - assert!(session.editor().should_quit()); - } - - #[test] - fn typed_palette_binding_prepares_arguments_and_retires_failed_plugin_commands() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "base"); - fixture.file( - "palette.rb", - r#" -Vamp.plugin "palette_fixture", requires: ["text"] -Vamp.command("palette_insert") { |editor, args| editor.insert args } -Vamp.command("palette_fail") { |_, _| raise "palette fixture failure" } -"#, - ); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.plugin "palette.rb" - config.bind "ctrl+p", nil - config.bind "ctrl+f", config.core.palette -end -"#, - ); - session.handle(Input::Ctrl('p')); - assert!(session.editor().palette().is_none()); - session.handle(Input::Ctrl('f')); - for ch in "palette_insert".chars() { - session.handle(Input::Char(ch)); - } - assert_eq!( - session.editor().palette().unwrap().entries[0].owner, - "palette_fixture" - ); - session.handle(Input::Enter); - assert_eq!(session.editor().mode(), crate::editor::Mode::Command); - assert_eq!(session.editor().command_line(), "palette_insert "); - assert_eq!(session.editor().lines(), &["base"]); - for ch in "界!".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Enter); - assert_eq!(session.editor().lines(), &["界!base"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["base"]); - session.handle(Input::Ctrl('f')); - for ch in "palette_fail".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Enter); - session.handle(Input::Enter); - assert!(session.editor().message().contains("disabled")); - session.handle(Input::Ctrl('f')); - assert!( - !session - .editor() - .palette() - .unwrap() - .entries - .iter() - .any(|entry| entry.owner == "palette_fixture") - ); - session.handle(Input::Escape); - assert_eq!(session.editor().lines(), &["base"]); - } - - #[test] - fn configured_linewise_change_and_paste_preserve_rows_and_one_replacement_history() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", " alpha\n界beta\ntail"); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.clipboard_mode = "separate" - config.bind "V", nil - config.bind "s", config.core.select_linewise - config.bind "s", config.core.select_linewise, mode: config.modes.visual - config.bind "r", config.core.change, mode: config.modes.visual -end -"#, - ); - for key in "llsjrnew".chars() { - session.handle(Input::Char(key)); - } - session.handle(Input::Escape); - assert_eq!(session.editor().lines(), &["new", "tail"]); - session.handle(Input::Char('p')); - assert_eq!( - session.editor().lines(), - &["new", " alpha", "界beta", "tail"] - ); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["new", "tail"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &[" alpha", "界beta", "tail"]); - session.handle(Input::Char('U')); - assert_eq!(session.editor().lines(), &["new", "tail"]); - } - - #[test] - fn configured_selection_actions_edit_together_and_invoke_plugins_once() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", " alpha\nb\n gamma"); - fixture.file( - "once.rb", - r#" -Vamp.plugin "once", requires: ["text"] -Vamp.command("once") { |editor, _| editor.insert "!" } -"#, - ); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.plugin "once.rb" - config.bind "r", config.core.select_lines, mode: config.modes.visual - config.bind "z", config.core.clone_below - config.bind "f", "once" -end -"#, - ); - session.handle(Input::Char('v')); - session.handle(Input::Char('j')); - session.handle(Input::Char('j')); - session.handle(Input::Char('r')); - assert_eq!(session.editor().selection_count(), 3); - session.handle(Input::Char('A')); - session.handle(Input::Char('?')); - session.handle(Input::Escape); - assert_eq!(session.editor().lines(), &[" alpha?", "b?", " gamma?"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &[" alpha", "b", " gamma"]); - assert_eq!(session.editor().selection_count(), 3); - session.handle(Input::Char('f')); - assert_eq!(session.editor().selection_count(), 1); - assert_eq!( - session - .editor() - .lines() - .iter() - .map(|line| line.matches('!').count()) - .sum::(), - 1 - ); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &[" alpha", "b", " gamma"]); - assert_eq!(session.editor().selection_count(), 3); - } - - #[test] - fn startup_rejection_installs_no_commands_and_keeps_other_plugins_and_core() { - let fixture = Fixture::new(); - fixture.healthy(); - fixture.file( - "graphics.rb", - r#" -Vamp.plugin "graphics", requires: ["graphics"] -Vamp.command("draw") { |editor, args| editor.insert "incompatible" } -"#, - ); - fixture.file( - "collision.rb", - r#" -Vamp.plugin "collision", requires: ["text"] -Vamp.command("leaked") { |editor, args| editor.insert "collision" } -Vamp.command("q") { |editor, args| editor.insert "override" } -"#, - ); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.plugin "graphics.rb" - config.plugin "collision.rb" - config.plugin "missing.rb" - config.plugin "healthy.rb" - config.bind "g", "healthy" -end -"#, - ); - for command in ["draw", "leaked"] { - session.execute(command); - assert_eq!(session.editor().lines(), &[String::new()]); - } - session.handle(Input::Char('g')); - assert_eq!(session.editor().lines(), &["healthy "]); - session.execute("w"); - assert_eq!( - fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), - "healthy " - ); - session.execute("q"); - assert!(session.editor().should_quit()); - } - - #[test] - fn rejected_effects_discard_entire_transaction_and_retire_commands_and_bindings() { - let fixture = Fixture::new(); - fixture.healthy(); - fixture.file( - "invalid.rb", - r#" -Vamp.plugin "invalid", requires: ["text"] -Vamp.command "invalid" do |editor, args| - editor.insert "partial" - editor.message "\e" -end -Vamp.command("sibling") { |editor, args| editor.insert "must be retired too" } -"#, - ); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.plugin "invalid.rb" - config.plugin "healthy.rb" - config.bind "g", "invalid" -end -"#, - ); - session.handle(Input::Char('g')); - assert_eq!(session.editor().lines(), &[String::new()]); - assert!(!session.editor().dirty()); - assert!( - session - .editor() - .message() - .contains("Plugin invalid disabled: Unsupported control character") - ); - let message = session.editor().message().to_owned(); - session.handle(Input::Char('g')); - assert_eq!(session.editor().message(), message); - for command in ["invalid", "sibling"] { - session.execute(command); - assert_eq!( - session.editor().message(), - format!("Unknown command: {command}") - ); - } - session.execute("healthy survivor"); - assert_eq!(session.editor().lines(), &["healthy survivor"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &[String::new()]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().message(), "Nothing to undo"); - } - - #[test] - fn callback_crash_and_timeout_retire_plugin_without_losing_core_editing() { - for failure in ["raise 'callback failed'", "Process.exit!(7)", "sleep 30"] { - let fixture = Fixture::new(); - fixture.healthy(); - fixture.file( - "failing.rb", - &format!( - r#" -Vamp.plugin "failing", requires: ["text"] -Vamp.command "fail" do |editor, args| - editor.insert "never commit" - {failure} -end -"# - ), - ); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.plugin "failing.rb" - config.plugin "healthy.rb" - config.bind "g", "fail" -end -"#, - ); - session.execute("fail"); - assert_eq!(session.editor().lines(), &[String::new()]); - assert!( - session - .editor() - .message() - .contains("Plugin failing disabled:") - ); - session.execute("fail"); - assert_eq!(session.editor().message(), "Unknown command: fail"); - session.handle(Input::Char('g')); - assert_eq!(session.editor().lines(), &[String::new()]); - session.execute("healthy survivor"); - session.execute("w"); - session.handle(Input::Char('i')); - session.handle(Input::Char('x')); - session.handle(Input::Escape); - session.execute("q"); - assert!(!session.editor().should_quit()); - session.execute("wq"); - assert!(session.editor().should_quit()); - assert_eq!( - fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), - "xhealthy survivor" - ); - } - } - - #[test] - fn configured_bindings_preserve_modal_editing_history_and_literal_paste() { - let fixture = Fixture::new(); - fixture.healthy(); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.plugin "healthy.rb" - config.bind "n", config.core.insert_first_nonblank - config.bind "i", nil - config.bind "ctrl+r", config.core.redo - config.bind "ctrl+c", config.core.normal, mode: config.modes.insert - config.bind "esc", nil, mode: config.modes.insert - config.bind "!", config.core.newline, mode: config.modes.insert - config.bind "shift+x", nil, mode: config.modes.insert - config.bind ":", nil - config.bind "space", config.core.command - config.bind "enter", nil, mode: config.modes.command - config.bind "ctrl+x", config.core.submit, mode: config.modes.command - config.bind "ctrl+c", config.core.normal, mode: config.modes.command - config.bind "ctrl+h", config.core.backspace, mode: config.modes.command - config.bind "g", config.core.undo - config.bind "g", "healthy" - config.bind "n", config.core.newline - config.bind "unknown", config.core.undo - config.bind "ctrl+f", "healthy", mode: config.modes.insert -end -"#, - ); - session.handle(Input::Char('i')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - session.handle(Input::Char('n')); - for ch in "界Xab!".chars() { - session.handle(Input::Char(ch)); - } - assert_eq!(session.editor().lines(), &["界ab", ""]); - session.handle(Input::Escape); - assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); - session.handle(Input::Paste("X!".into())); - assert_eq!(session.editor().lines(), &["界ab", "X!"]); - session.handle(Input::Ctrl('C')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["界ab", ""]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &[String::new()]); - session.handle(Input::Ctrl('r')); - session.handle(Input::Ctrl('r')); - assert_eq!(session.editor().lines(), &["界ab", "X!"]); - session.handle(Input::Char(':')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - session.handle(Input::Char(' ')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Command); - assert!(session.editor().sequence_pending().is_none()); - for ch in "w界".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Backspace); - assert_eq!(session.editor().command_line(), "w"); - session.handle(Input::Enter); - assert_eq!(session.editor().mode(), crate::editor::Mode::Command); - session.handle(Input::Ctrl('x')); - assert_eq!( - fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), - "界ab\nX!" - ); - session.handle(Input::Char(' ')); - session.handle(Input::Char('q')); - session.handle(Input::Ctrl('c')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - assert!(session.editor().command_line().is_empty()); - assert!(!session.editor().should_quit()); - session.handle(Input::Char('g')); - assert_eq!(session.editor().lines(), &["healthy "]); - session.handle(Input::Char(' ')); - session.handle(Input::Char('q')); - session.handle(Input::Ctrl('x')); - assert!(!session.editor().should_quit()); - session.handle(Input::Char(' ')); - session.handle(Input::Char('q')); - session.handle(Input::Char('!')); - session.handle(Input::Ctrl('x')); - assert!(session.editor().should_quit()); - } - - #[test] - fn default_dot_replaces_fresh_words_and_each_repeat_has_one_undo() { - let fixture = Fixture::new(); - let buffer = fixture.file("buffer.txt", "old elephant zebra"); - let mut session = EditorSession::open(Some(buffer), vec!["text".into()], None).unwrap(); - session.editor.set_clipboard_mode("separate").unwrap(); - for key in ['v', 'e', 'c'] { - session.handle(Input::Char(key)); - } - for ch in "item".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Escape); - assert_eq!(session.editor().lines(), &["item elephant zebra"]); - for key in ['w', '3', '.'] { - session.handle(Input::Char(key)); - } - assert_eq!(session.editor().lines(), &["item item zebra"]); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["item elephant zebra"]); - session.handle(Input::Home); - for key in ['w', 'w', 'v', 'e', '.'] { - session.handle(Input::Char(key)); - } - assert_eq!(session.editor().lines(), &["item elephant item"]); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["item elephant zebra"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["old elephant zebra"]); - assert!(!session.editor().dirty()); - } - - #[test] - fn typed_repeat_remaps_use_resolved_motions_and_current_visual_ranges() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "old enormous tail end"); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.bind ".", nil - config.bind ".", nil, mode: config.modes.visual - config.bind "r", config.core.repeat_last_edit - config.bind "r", config.core.repeat_last_edit, mode: config.modes.visual - config.bind "e", config.core.left, mode: config.modes.visual - config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual -end -"#, - ); - session.handle(Input::Char('v')); - session.handle(Input::Ctrl('f')); - session.handle(Input::Char('c')); - for ch in "item".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Escape); - session.handle(Input::Char('w')); - session.handle(Input::Char('.')); - assert_eq!(session.editor().lines(), &["item enormous tail end"]); - session.handle(Input::Char('r')); - assert_eq!(session.editor().lines(), &["item item tail end"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["item enormous tail end"]); - session.handle(Input::Home); - for key in ['w', 'v', '2'] { - session.handle(Input::Char(key)); - } - session.handle(Input::Ctrl('f')); - session.handle(Input::Char('.')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); - assert_eq!(session.editor().lines(), &["item enormous tail end"]); - session.handle(Input::Char('r')); - assert_eq!(session.editor().lines(), &["item item end"]); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["item enormous tail end"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["old enormous tail end"]); - } - - #[test] - fn repeat_recipe_crosses_buffers_but_pending_insert_and_undo_stay_per_buffer() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "alpha"); - let second = fixture.file("second.txt", "beta"); - let mut session = fixture.open(""); - session.handle(Input::Char('i')); - for ch in "one".chars() { - session.handle(Input::Char(ch)); - } - session.execute(&format!("edit {}", second.display())); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - assert_eq!(session.editor().lines(), &["beta"]); - session.handle(Input::Char('.')); - assert_eq!(session.editor().lines(), &["onebeta"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["beta"]); - session.execute("buffer 1"); - assert_eq!(session.editor().lines(), &["onealpha"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["alpha"]); - session.handle(Input::Char('U')); - assert_eq!(session.editor().lines(), &["onealpha"]); - session.execute("buffer 2"); - assert_eq!(session.editor().lines(), &["beta"]); - session.handle(Input::Char('U')); - assert_eq!(session.editor().lines(), &["onebeta"]); - session.handle(Input::Home); - session.handle(Input::Char('i')); - for ch in "two".chars() { - session.handle(Input::Char(ch)); - } - session.execute("buffer 1"); - session.handle(Input::Home); - session.handle(Input::Char('.')); - assert_eq!(session.editor().lines(), &["twoonealpha"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["onealpha"]); - session.execute("buffer 2"); - assert_eq!(session.editor().lines(), &["twoonebeta"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["onebeta"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["beta"]); - } - - #[test] - fn plugin_effects_commit_pending_typing_without_replacing_or_reinvoking_native_recipe() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "base"); - fixture.file( - "effects.rb", - r##" -Vamp.plugin "effects", requires: ["text"] -calls = 0 -Vamp.command("effect") do |editor, args| - calls += 1 - File.open("calls.txt", "a") { |file| file.puts "#{calls} #{args}" } - case args - when "replace" - editor.replace "plugin" - when "insert" - editor.insert "?" - end - editor.message "calls=#{calls}" -end -"##, - ); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.plugin "effects.rb" -end -"#, - ); - session.handle(Input::Char('i')); - session.handle(Input::Char('!')); - session.execute("effect replace"); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - assert_eq!(session.editor().lines(), &["plugin"]); - assert_eq!(session.editor().message(), "calls=1"); - session.handle(Input::Home); - session.handle(Input::Char('.')); - assert_eq!(session.editor().lines(), &["!plugin"]); - assert_eq!( - fs::read_to_string(fixture.0.join("calls.txt")).unwrap(), - "1 replace\n" - ); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["plugin"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["!base"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["base"]); - session.handle(Input::Home); - session.execute("effect insert"); - assert_eq!(session.editor().lines(), &["?base"]); - assert_eq!(session.editor().message(), "calls=2"); - session.handle(Input::Home); - session.handle(Input::Char('.')); - assert_eq!(session.editor().lines(), &["!?base"]); - assert_eq!( - fs::read_to_string(fixture.0.join("calls.txt")).unwrap(), - "1 replace\n2 insert\n" - ); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["?base"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["base"]); - session.execute("effect message"); - assert_eq!(session.editor().message(), "calls=3"); - session.handle(Input::Home); - session.handle(Input::Char('.')); - assert_eq!(session.editor().lines(), &["!base"]); - assert_eq!( - fs::read_to_string(fixture.0.join("calls.txt")).unwrap(), - "1 replace\n2 insert\n3 message\n" - ); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["base"]); - } - - #[test] - fn explicit_save_commits_pending_change_and_repeat_does_not_save_again() { - let fixture = Fixture::new(); - let buffer = fixture.file("buffer.txt", "old elephant"); - let mut session = fixture.open(""); - for key in ['v', 'e', 'c'] { - session.handle(Input::Char(key)); - } - for ch in "item".chars() { - session.handle(Input::Char(ch)); - } - session.execute("w"); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - assert_eq!(fs::read_to_string(&buffer).unwrap(), "item elephant"); - session.handle(Input::Home); - session.handle(Input::Char('w')); - session.handle(Input::Char('.')); - assert_eq!(session.editor().lines(), &["item item"]); - assert!(session.editor().dirty()); - assert_eq!(fs::read_to_string(&buffer).unwrap(), "item elephant"); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["item elephant"]); - assert!(!session.editor().dirty()); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["old elephant"]); - } - - #[test] - fn dots_in_insert_and_focused_queries_are_literal_and_excluded_from_recipe() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "alpha"); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.file_explorer_view = "list" - config.file_picker_view = "list" - config.bind "ctrl+f", config.core.file_explorer - config.bind "ctrl+o", config.core.file_picker -end -"#, - ); - session.handle(Input::Char('i')); - session.handle(Input::Char('X')); - session.handle(Input::Escape); - session.handle(Input::Home); - session.handle(Input::Ctrl('f')); - for ch in ".query".chars() { - session.handle(Input::Char(ch)); - } - assert_eq!(session.editor().file_picker().unwrap().query, ".query"); - assert_eq!(session.editor().lines(), &["Xalpha"]); - session.handle(Input::Escape); - assert!(!session.editor().file_picker().unwrap().focused); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - session.handle(Input::Ctrl('o')); - session.handle(Input::Ctrl('u')); - session.handle(Input::Char('.')); - assert_eq!(session.editor().file_picker().unwrap().query, "."); - assert_eq!(session.editor().lines(), &["Xalpha"]); - session.handle(Input::Escape); - assert!(session.editor().file_picker().is_none()); - session.handle(Input::Char('.')); - assert_eq!(session.editor().lines(), &["XXalpha"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["Xalpha"]); - session.handle(Input::Home); - session.handle(Input::Char('i')); - session.handle(Input::Char('.')); - session.handle(Input::Ctrl('p')); - assert!( - session - .editor() - .palette() - .unwrap() - .entries - .iter() - .any(|entry| entry.name == "core.repeat_last_edit") - ); - session.handle(Input::Char('.')); - assert_eq!(session.editor().palette().unwrap().query, "."); - assert_eq!(session.editor().lines(), &[".Xalpha"]); - session.handle(Input::Escape); - session.handle(Input::Char('!')); - session.handle(Input::Escape); - assert_eq!(session.editor().lines(), &[".!Xalpha"]); - for prompt in [':', '/'] { - session.handle(Input::Char(prompt)); - session.handle(Input::Char('.')); - assert_eq!(session.editor().command_line(), "."); - assert_eq!(session.editor().lines(), &[".!Xalpha"]); - session.handle(Input::Escape); - } - session.handle(Input::Home); - session.handle(Input::Char('.')); - assert_eq!(session.editor().lines(), &[".!.!Xalpha"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &[".!Xalpha"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["Xalpha"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["alpha"]); - } - - #[test] - fn repeated_clipboard_paste_uses_captured_text_after_a_different_yank() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "one two three"); - let mut session = fixture.open(""); - for key in ['v', 'e', 'y'] { - session.handle(Input::Char(key)); - } - session.handle(Input::Home); - session.handle(Input::Char('P')); - assert_eq!(session.editor().lines(), &["oneone two three"]); - for key in ['w', 'v', 'e', 'y'] { - session.handle(Input::Char(key)); - } - session.handle(Input::Home); - session.handle(Input::Char('.')); - assert_eq!(session.editor().lines(), &["oneoneone two three"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["oneone two three"]); - session.handle(Input::Home); - session.handle(Input::Char('P')); - assert_eq!(session.editor().lines(), &["twooneone two three"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["oneone two three"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["one two three"]); - } - - #[test] - fn literal_paste_repeats_without_dispatch_and_preserves_native_input_groups() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "seed"); - let mut session = fixture.open(""); - session.handle(Input::Char('i')); - session.handle(Input::Char('a')); - session.handle(Input::Paste(".:q!\r\n界".into())); - session.handle(Input::Char('b')); - session.handle(Input::Escape); - assert_eq!(session.editor().lines(), &["a.:q!", "界bseed"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["a.:q!", "界seed"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["aseed"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["seed"]); - session.handle(Input::Home); - session.handle(Input::Char('.')); - assert_eq!(session.editor().lines(), &["bseed"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["seed"]); - session.handle(Input::Home); - session.handle(Input::Char('i')); - session.handle(Input::Paste(".:q!\r\n界".into())); - session.handle(Input::Escape); - assert_eq!(session.editor().lines(), &[".:q!", "界seed"]); - session.handle(Input::Home); - session.handle(Input::Char('.')); - assert_eq!(session.editor().lines(), &[".:q!", ".:q!", "界界seed"]); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - assert!(!session.editor().should_quit()); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &[".:q!", "界seed"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["seed"]); - } - - #[test] - fn typed_visual_change_remaps_c_and_command_execution_commits_one_replacement() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "abc DEF tail"); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.bind "c", nil, mode: config.modes.visual - config.bind "ctrl+r", config.core.change, mode: config.modes.visual - config.bind "ctrl+v", config.core.select - config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual -end -"#, - ); - session.handle(Input::Char('c')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - assert!(!session.editor().dirty()); - session.handle(Input::Ctrl('v')); - session.handle(Input::Ctrl('f')); - session.handle(Input::Char('c')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); - assert_eq!(session.editor().lines(), &["abc DEF tail"]); - session.handle(Input::Ctrl('r')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); - assert_eq!(session.editor().cursor().column, 0); - assert_eq!(session.editor().lines(), &[" DEF tail"]); - for ch in "cZ".chars() { - session.handle(Input::Char(ch)); - } - session.execute("w"); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - assert_eq!( - fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), - "cZ DEF tail" - ); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["abc DEF tail"]); - session.handle(Input::Char('P')); - assert_eq!(session.editor().lines(), &["ababcc DEF tail"]); - } - - #[test] - fn typed_visual_actions_copy_cut_paste_and_cancel_without_splitting_history() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "one two"); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.bind "v", nil - config.bind "ctrl+v", config.core.select - config.bind "ctrl+c", config.core.copy, mode: config.modes.visual - config.bind "ctrl+x", config.core.cut, mode: config.modes.visual - config.bind "ctrl+g", config.core.normal, mode: config.modes.visual - config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual - config.bind "ctrl+b", config.core.paste_before - config.bind "ctrl+p", config.core.paste_after -end -"#, - ); - session.handle(Input::Char('v')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - session.handle(Input::Ctrl('v')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); - session.handle(Input::Ctrl('f')); - assert_eq!(session.editor().selections()[0].end.column, 3); - session.handle(Input::Ctrl('c')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - assert!(!session.editor().dirty()); - session.handle(Input::Ctrl('p')); - assert_eq!(session.editor().lines(), &["oonene two"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["one two"]); - session.handle(Input::Ctrl('v')); - session.handle(Input::Ctrl('f')); - session.handle(Input::Ctrl('x')); - assert_eq!(session.editor().lines(), &[" two"]); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - session.handle(Input::Ctrl('b')); - assert_eq!(session.editor().lines(), &["one two"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &[" two"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["one two"]); - assert!(!session.editor().dirty()); - session.handle(Input::Ctrl('v')); - session.handle(Input::Ctrl('g')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - assert!(session.editor().selections().is_empty()); - } - - #[test] - fn typed_word_motions_remap_keys_and_keep_insert_typing_in_one_undo_group() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "foo...bar baz"); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.bind "w", nil - config.bind "ctrl+f", config.core.word_next - config.bind "ctrl+p", config.core.word_previous - config.bind "ctrl+e", config.core.word_end - config.bind "ctrl+a", config.core.big_word_next - config.bind "ctrl+z", config.core.big_word_previous - config.bind "ctrl+t", config.core.big_word_end - config.bind "ctrl+b", config.core.word_previous, mode: config.modes.insert -end -"#, - ); - session.handle(Input::Char('w')); - assert_eq!(session.editor().cursor().column, 0); - for (key, column) in [ - ('f', 3), - ('e', 5), - ('f', 6), - ('p', 3), - ('z', 0), - ('a', 10), - ('t', 12), - ('z', 10), - ('z', 0), - ] { - session.handle(Input::Ctrl(key)); - assert_eq!(session.editor().cursor().column, column, "{key}"); - } - assert!(!session.editor().dirty()); - session.handle(Input::Char('i')); - for ch in "wbeWBE".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Ctrl('b')); - assert_eq!(session.editor().cursor().column, 0); - session.handle(Input::Char('X')); - session.handle(Input::Escape); - assert_eq!(session.editor().lines(), &["XwbeWBEfoo...bar baz"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["foo...bar baz"]); - assert!(!session.editor().dirty()); - } - - #[test] - fn typed_search_bindings_drive_navigation_and_the_search_prompt_only() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "cat\ncat\ncat"); - let mut session = fixture.open( - r#" -Vamp.configure do |config| - config.bind "ctrl+f", config.core.search - config.bind "ctrl+n", config.core.search_next - config.bind "ctrl+p", config.core.search_previous - config.bind "ctrl+x", config.core.submit, mode: config.modes.search - config.bind "ctrl+c", config.core.normal, mode: config.modes.search - config.bind "ctrl+h", config.core.backspace, mode: config.modes.search - config.bind "enter", nil, mode: config.modes.search - config.bind "/", nil -end -"#, - ); - session.handle(Input::Char('/')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - session.handle(Input::Ctrl('f')); - assert_eq!(session.editor().mode(), crate::editor::Mode::Search); - for ch in "cat界".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Backspace); - session.handle(Input::Enter); - assert_eq!(session.editor().mode(), crate::editor::Mode::Search); - session.handle(Input::Ctrl('x')); - assert_eq!(session.editor().cursor().row, 0); - session.handle(Input::Ctrl('n')); - assert_eq!(session.editor().cursor().row, 1); - session.handle(Input::Ctrl('n')); - assert_eq!(session.editor().cursor().row, 2); - session.handle(Input::Ctrl('p')); - assert_eq!(session.editor().cursor().row, 1); - session.handle(Input::Ctrl('f')); - session.handle(Input::Char('q')); - session.handle(Input::Ctrl('c')); - session.handle(Input::Ctrl('n')); - assert_eq!(session.editor().cursor().row, 2); - assert!(!session.editor().dirty()); - assert!(!session.editor().should_quit()); - } - - #[test] - fn typed_config_errors_discard_partial_bindings_and_leave_default_editing_available() { - let fixture = Fixture::new(); - for (expression, error) in [ - ("config.core.reod", "NoMethodError"), - ("config.modes.insret", "NoMethodError"), - ("config.leader = 7", "leader key must be a String"), - ("config.leader = nil", "leader key must be a String"), - (r#"config.leader = """#, "leader key must not be empty"), - ( - r#"config.resolve_history_layout = "invalid""#, - "resolve history layout must be columns or base_below", - ), - ( - "config.resolve_history_layout = 7", - "resolve history layout must be a String", - ), - ( - "config.resolve_history_layout = nil", - "resolve history layout must be a String", - ), - ( - r#"config.resolve_history_layout = """#, - "resolve history layout must not be empty", - ), - ( - r#"config.diff_view_mode = "invalid""#, - "diff view mode must be split or unified", - ), - ( - r#"config.diff_split_orientation = "invalid""#, - "diff split orientation must be vertical or horizontal", - ), - ( - r#"config.diff_split_orientation = "left_right""#, - "diff split orientation must be vertical or horizontal", - ), - ( - r#"config.diff_split_orientation = "above_below""#, - "diff split orientation must be vertical or horizontal", - ), - ( - "config.diff_view_mode = 7", - "diff view mode must be a String", - ), - ( - "config.diff_view_mode = nil", - "diff view mode must be a String", - ), - ( - r#"config.diff_view_mode = """#, - "diff view mode must not be empty", - ), - ( - "config.diff_split_orientation = 7", - "diff split orientation must be a String", - ), - ( - "config.diff_split_orientation = nil", - "diff split orientation must be a String", - ), - ( - r#"config.diff_split_orientation = """#, - "diff split orientation must not be empty", - ), - ( - "config.file_explorer_side = 7", - "file explorer side must be a String", - ), - ( - "config.file_explorer_side = nil", - "file explorer side must be a String", - ), - ( - r#"config.file_explorer_side = """#, - "file explorer side must not be empty", - ), - ( - "config.file_picker_view = 7", - "file picker view must be a String", - ), - ( - "config.file_picker_view = nil", - "file picker view must be a String", - ), - ( - r#"config.file_picker_view = """#, - "file picker view must not be empty", - ), - ( - "config.file_explorer_view = 7", - "file explorer view must be a String", - ), - ( - "config.file_explorer_view = nil", - "file explorer view must be a String", - ), - ( - r#"config.file_explorer_view = """#, - "file explorer view must not be empty", - ), - ( - "config.clipboard_mode = 7", - "clipboard mode must be a String", - ), - ( - "config.clipboard_mode = nil", - "clipboard mode must be a String", - ), - ( - r#"config.clipboard_mode = """#, - "clipboard mode must not be empty", - ), - (r#"config.bind "n", "core.insert""#, "TypeError"), - (r#"config.bind "n", nil, mode: "insert""#, "TypeError"), - (r#"config.bind "n", config.modes.insert"#, "TypeError"), - ( - r#"config.bind "n", nil, mode: config.core.insert"#, - "TypeError", - ), - (r#"config.core.redo.name = "core.undo""#, "FrozenError"), - ( - r#"config.core.redo.name.replace("core.undo")"#, - "FrozenError", - ), - (r#"config.modes.insert.name = "normal""#, "FrozenError"), - ( - r#"config.modes.insert.name.replace("normal")"#, - "FrozenError", - ), - ] { - let mut session = fixture.open(&format!( - "Vamp.configure do |config|\n config.leader = \",\"\n config.bind \"i\", nil\n {expression}\nend" - )); - assert!( - session - .editor() - .startup_diagnostic() - .unwrap() - .lines - .iter() - .any(|line| line.contains(error)), - "{expression}" - ); - session.handle(Input::Enter); - session.handle(Input::Char('i')); - session.handle(Input::Char('界')); - session.handle(Input::Escape); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - assert_eq!(session.editor().lines(), &["界"], "{expression}"); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &[String::new()], "{expression}"); - session.handle(Input::Char(' ')); - assert_eq!( - session.editor().sequence_pending(), - Some("Space"), - "{expression}" - ); - session.handle(Input::Char(' ')); - assert!(session.editor().palette().is_some(), "{expression}"); - session.handle(Input::Escape); - } - } - - #[test] - fn rejected_config_diagnostic_persists_until_dismissed_and_default_editing_survives() { - for (config, reason, dismiss) in [ - ("raise 'broken config'", "broken config", Input::Enter), - ( - "Vamp.configure { |config| config.lsp['unsupported-language'] = true }", - "unsupported-language", - Input::Escape, - ), - ] { - let fixture = Fixture::new(); - let mut session = fixture.open(config); - let diagnostic = session - .editor() - .startup_diagnostic() - .expect("config failure must be visible"); - let text = diagnostic.lines.join("\n"); - assert!(text.contains(&fixture.0.join("config.rb").display().to_string())); - assert!(text.contains(reason)); - session.editor.set_message("unrelated background message"); - session.poll(); - for input in [ - Input::Char('i'), - Input::Paste("accidental edit".into()), - Input::Ctrl('p'), - ] { - session.handle(input); - } - assert_eq!( - session - .editor() - .startup_diagnostic() - .unwrap() - .lines - .join("\n"), - text - ); - assert_eq!(session.editor().mode(), Mode::Normal); - assert_eq!(session.editor().lines(), &[""]); - session.handle(dismiss); - assert!(session.editor().startup_diagnostic().is_none()); - session.handle(Input::Char('i')); - session.handle(Input::Char('x')); - session.handle(Input::Escape); - session.execute("wq"); - assert!(session.editor().should_quit()); - assert_eq!( - fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), - "x" - ); - } - } - - fn drain_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) { - // Process-I/O timeout, not a date-dependent assertion. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); - loop { - session.poll(); - if ready(session) { - return; - } - assert!( - std::time::Instant::now() < deadline, - "plugin did not settle: {}", - session.editor().message() - ); - std::thread::sleep(std::time::Duration::from_millis(2)); - } - } - - fn barrier(session: &mut EditorSession, message: &str) { - session.execute(&format!("fixture_barrier {message}")); - drain_until(session, |session| session.editor().message() == message); - } - - fn diagnostic_effect(id: usize, revision: u64, message: &str) -> serde_json::Value { - serde_json::json!({ - "type": "diagnostics", "buffer_id": id, "revision": revision, - "items": [{"start": {"row": 0, "column": 0}, "end": {"row": 0, "column": 1}, - "severity": "warning", "message": message}] - }) - } - - fn publish(session: &mut EditorSession, effects: Vec) { - session.execute(&format!( - "fixture_publish {}", - serde_json::json!({"effects": effects}) - )); - } - - fn hold(session: &mut EditorSession, gate: &Path, effects: Vec) { - session.execute(&format!( - "fixture_hold {}", - serde_json::json!({"gate": gate, "effects": effects}) - )); - } - - fn release(session: &mut EditorSession, gate: &Path) { - fs::write(gate, "").unwrap(); - let delivered = format!("delivered {}", gate.display()); - drain_until(session, |session| session.editor().message() == delivered); - } - - #[test] - fn startup_chrome_installs_named_values_without_editing_the_document() { - let fixture = Fixture::new(); - fixture.file("buffer.txt", "source"); - let session = fixture.open( - r#" -Vamp.configure do |config| - config.bar.top = {left: [:mode, :language], right: [:battery]} - config.bar.bottom = {left: [:tabs], center: [:workspace]} - config.labels = {battery: "bat 38%", workspace: "tools\n"} -end -"#, - ); - assert_eq!(session.editor.label("battery"), "bat 38%"); - assert_eq!(session.editor.label("workspace"), "tools�"); - assert_eq!(session.editor.top_bar_config().left, ["mode", "language"]); - assert_eq!(session.editor.top_bar_config().right, ["battery"]); - assert_eq!(session.editor.bottom_bar_config().left, ["tabs"]); - assert_eq!(session.editor.bottom_bar_config().center, ["workspace"]); - assert_eq!(session.editor.lines(), ["source"]); - assert_eq!(session.editor.text_revision(), 0); - assert!(!session.editor.dirty()); - } - - #[test] - fn asynchronous_result_labels_outlive_the_source_without_applying_stale_edits() { - let fixture = Fixture::new(); - let target = fixture.file("other.txt", ""); - let mut session = EditorSession::open(None, vec![], None).unwrap(); - let source = InvocationSource { - buffer_id: session.editor.buffer_id(), - pane_id: session.editor.pane_id(), - revision: session.editor.text_revision(), - }; - session.editor.execute("tab.new"); - session - .editor - .execute(&format!("edit {}", target.display())); - assert_ne!(session.editor.buffer_id(), source.buffer_id); - session.editor.handle(Input::Char('i')); - let message = session.editor.message().to_owned(); - for (id, effects) in [ - ( - 1, - vec![ - Effect::Insert("stale".into()), - Effect::Message("stale message".into()), - Effect::Label { - name: "battery".into(), - text: "bat 38%".into(), - }, - ], - ), - ( - 2, - vec![ - Effect::Label { - name: "battery".into(), - text: "must not leak".into(), - }, - Effect::Label { - name: "bad-name".into(), - text: "invalid".into(), - }, - ], - ), - ] { - session - .requests - .entry("fixture".into()) - .or_default() - .pending - .insert(id, source); - let result = session.apply_response("fixture", id, effects); - if id == 1 { - assert!(result.unwrap()); - } else { - assert!(result.is_err()); - } - assert_eq!(session.editor.label("battery"), "bat 38%"); - assert_eq!(session.editor.lines(), [""]); - assert_eq!(session.editor.message(), message); - assert_eq!(session.editor.mode(), Mode::Insert); - assert_eq!(session.editor.text_revision(), 0); - assert!(!session.editor.dirty()); - } - session.editor.handle(Input::Char('x')); - session.editor.handle(Input::Escape); - session.editor.handle(Input::Char('u')); - assert_eq!(session.editor.lines(), [""]); - assert_eq!(session.editor.label("battery"), "bat 38%"); - } - - #[test] - fn named_labels_survive_tab_switches_and_remain_atomic_in_readonly_views() { - let fixture = Fixture::new(); - let mut session = fixture.asynchronous(None); - publish( - &mut session, - vec![serde_json::json!({ - "type": "label", "name": "battery", "text": "bat 37%" - })], - ); - drain_until(&mut session, |session| { - session.editor().label("battery") == "bat 37%" - }); - let gate = fixture.0.join("label.gate"); - hold( - &mut session, - &gate, - vec![serde_json::json!({ - "type": "label", "name": "battery", "text": "bat 38%" - })], - ); - barrier(&mut session, "label request received"); - session.execute("tab.new"); - release(&mut session, &gate); - assert_eq!(session.editor().label("battery"), "bat 38%"); - session.execute("tab.previous"); - assert_eq!(session.editor().label("battery"), "bat 38%"); - let update = |text: &str| PluginUpdate { - request_id: None, - effects: vec![UpdateEffect::Label { - name: "battery".into(), - text: text.into(), - }], - }; - assert!( - !session - .apply_update("generic_fixture", update("bat 38%")) - .unwrap() - ); - let rejected = PluginUpdate { - request_id: None, - effects: vec![ - UpdateEffect::Label { - name: "battery".into(), - text: "must not leak".into(), - }, - UpdateEffect::SemanticTokens { - buffer_id: session.editor().buffer_id(), - revision: session.editor().text_revision(), - tokens: None, - }, - ], - }; - assert!(session.apply_update("generic_fixture", rejected).is_err()); - assert_eq!(session.editor().label("battery"), "bat 38%"); - let message = session.editor.message().to_owned(); - for effects in [ - vec![ - UpdateEffect::Message { - text: "must not leak".into(), - }, - UpdateEffect::Label { - name: "battery".into(), - text: "changed".into(), - }, - UpdateEffect::Label { - name: "bad-name".into(), - text: "invalid".into(), - }, - ], - vec![ - UpdateEffect::Label { - name: "battery".into(), - text: "x".repeat(2 * 1024 * 1024), - }, - UpdateEffect::Label { - name: "workspace".into(), - text: "x".repeat(2 * 1024 * 1024), - }, - ], - ] { - assert!( - session - .apply_update( - "generic_fixture", - PluginUpdate { - request_id: None, - effects - } - ) - .is_err() - ); - assert_eq!(session.editor.label("battery"), "bat 38%"); - assert_eq!(session.editor.label("workspace"), ""); - assert_eq!(session.editor.message(), message); - } - session.editor.execute("diff"); - assert!(session.editor.mutation_blocked()); - assert!( - session - .apply_update("generic_fixture", update("bat\t39%\n")) - .unwrap() - ); - assert_eq!(session.editor().label("battery"), "bat�39%�"); - assert_eq!(session.editor().lines(), [""]); - assert!(session.apply_update("generic_fixture", update("")).unwrap()); - assert_eq!(session.editor().label("battery"), ""); - } - - #[test] - fn idle_poll_refreshes_clean_and_parked_files_on_the_interval_but_keeps_dirty_text() { - let fixture = Fixture::new(); - let active = fixture.file("buffer.txt", "old"); - let parked = fixture.file("parked.txt", "parked"); - let mut session = fixture.open(""); - session.execute(&format!("edit {}", parked.display())); - session.execute("buffer 1"); - let now = session.last_file_refresh; - let revision = session.editor().text_revision(); - fs::write(&active, "external replacement").unwrap(); - fs::write(&parked, "external parked replacement").unwrap(); - - session.poll_at(now + Duration::from_millis(249)); - assert_eq!(session.editor().lines(), &["old"]); - session.dispatch(Invocation { - plugin: "core".into(), - command: "lsp.definition".into(), - args: String::new(), - }); - assert!(session.requests["core"].latest.is_some()); - assert!(session.poll_at(now + Duration::from_millis(250))); - assert!(session.requests["core"].latest.is_none()); - assert_eq!(session.editor().lines(), &["external replacement"]); - assert!(session.editor().text_revision() > revision); - let documents: Vec<_> = session.editor().documents().collect(); - assert_eq!(documents[1].lines, ["external parked replacement"]); - - session.handle(Input::Char('x')); - let dirty = session.editor().lines().to_vec(); - let revision = session.editor().text_revision(); - fs::write(&active, "second external replacement").unwrap(); - session.poll_at(now + Duration::from_millis(500)); - assert_eq!(session.editor().lines(), dirty.as_slice()); - assert_eq!(session.editor().text_revision(), revision); - assert_eq!( - fs::read_to_string(&active).unwrap(), - "second external replacement" - ); - } - - #[test] - fn clean_external_reload_notifies_async_plugin_with_change_without_save() { - let fixture = Fixture::new(); - let file = fixture.file("buffer.txt", "old"); - let mut session = fixture.asynchronous(Some(file.clone())); - barrier(&mut session, "opened"); - let now = session.last_file_refresh; - let revision = session.editor().text_revision(); - fs::write(&file, "updated external text").unwrap(); - assert!(session.poll_at(now + Duration::from_millis(250))); - assert_eq!(session.editor().lines(), &["updated external text"]); - let updated_revision = session.editor().text_revision(); - assert!(updated_revision > revision); - assert_eq!( - session.documents[&session.editor().buffer_id()] - .snapshot - .revision, - updated_revision - ); - barrier(&mut session, "reload delivered"); - - let events = fixture.events(); - let changes: Vec<_> = events - .iter() - .filter(|event| event["event"] == "change") - .collect(); - assert_eq!(changes.len(), 1); - assert_eq!(changes[0]["document"]["text"], "updated external text"); - assert_eq!(changes[0]["document"]["revision"], updated_revision); - assert!(!events.iter().any(|event| event["event"] == "save")); - } - - #[test] - fn async_documents_include_unnamed_hidden_versions_real_saves_renames_and_closes() { - let fixture = Fixture::new(); - let mut session = fixture.asynchronous(None); - barrier(&mut session, "initial"); - let initial = fixture.events(); - assert_eq!(initial[0]["event"], "open"); - assert_eq!(initial[0]["document"]["id"], 1); - assert_eq!(initial[0]["document"]["revision"], 0); - assert_eq!(initial[0]["document"]["path"], serde_json::Value::Null); - assert_eq!(initial[0]["document"]["text"], ""); - assert_eq!( - initial[0]["document"]["root"], - session.editor().project_root().to_string_lossy().as_ref() - ); - session.handle(Input::Char('i')); - for input in [ - Input::Char('😀'), - Input::Char('e'), - Input::Char('\u{301}'), - Input::Enter, - ] { - session.handle(input); - } - session.handle(Input::Escape); - let revision = session.editor().text_revision(); - session.handle(Input::Char('h')); - session.handle(Input::Char('l')); - let renamed = fixture.0.join("saved unicode 😀.txt"); - session.execute(&format!("w {}", renamed.display())); - session.execute("w"); - session.execute(&format!("w {}", fixture.0.join("missing/failed").display())); - barrier(&mut session, "saved"); - assert_eq!(session.editor().text_revision(), revision); - assert_eq!( - session.editor().documents().next().unwrap().save_revision, - 2 - ); - assert_eq!(fs::read_to_string(&renamed).unwrap(), "😀e\u{301}\n"); - let events = fixture.events(); - assert_eq!( - events - .iter() - .filter(|event| event["event"] == "change") - .count(), - revision as usize - ); - let kinds: Vec<_> = events - .iter() - .filter_map(|event| event["event"].as_str()) - .collect(); - assert_eq!(&kinds[kinds.len() - 4..], ["close", "open", "save", "save"]); - let reopened = events - .iter() - .find(|event| event["event"] == "open" && !event["document"]["path"].is_null()) - .unwrap(); - assert_eq!(reopened["document"]["revision"], revision); - assert_eq!(reopened["document"]["text"], "😀e\u{301}\n"); - let target = fixture.file("target.txt", "target"); - session.execute(&format!("edit {}", target.display())); - session.handle(Input::Char('x')); - session.execute("buffer 1"); - let documents: Vec<_> = session.editor().documents().collect(); - assert_eq!( - documents - .iter() - .map(|document| document.id) - .collect::>(), - [1, 2] - ); - assert_eq!(documents[1].lines, ["arget"]); - assert_eq!(documents[1].revision, 1); - assert_eq!(documents[1].save_revision, 0); - session.execute("bdelete! 2"); - barrier(&mut session, "closed"); - let events = fixture.events(); - let closed = events - .iter() - .find(|event| event["event"] == "close" && event["document"]["id"] == 2) - .unwrap(); - assert_eq!(closed["document"]["text"], "arget"); - assert_eq!(closed["document"]["revision"], 1); - session.execute("q!"); - barrier(&mut session, "quit flushed"); - assert!(session.editor().should_quit()); - let events = fixture.events(); - let final_close = events - .iter() - .rev() - .find(|event| event["event"] == "close") - .unwrap(); - assert_eq!(final_close["document"]["id"], 1); - assert_eq!( - final_close["document"]["path"], - renamed.to_string_lossy().as_ref() - ); - } - - #[test] - fn async_annotations_and_messages_preserve_insert_group_and_ignore_stale_closed_sources() { - let fixture = Fixture::new(); - let file = fixture.file("buffer.txt", "abc"); - let mut session = fixture.asynchronous(Some(file)); - let gate = fixture.0.join("annotations-ready"); - hold(&mut session, &gate, vec![diagnostic_effect(1, 1, "live")]); - session.handle(Input::Char('i')); - session.handle(Input::Char('x')); - release(&mut session, &gate); - assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); - assert_eq!( - session - .editor() - .diagnostic_at_cursor() - .map(|diagnostic| diagnostic.message.as_str()), - None - ); - assert_eq!(session.editor().diagnostics().count(), 0); - session.handle(Input::Char('y')); - assert_eq!(session.editor().diagnostics().count(), 0); - session.handle(Input::Escape); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), ["abc"]); - let current = session.editor().text_revision(); - let mut stale = diagnostic_effect(1, 0, "stale"); - stale["items"][0]["end"]["row"] = serde_json::json!(999); - publish( - &mut session, - vec![ - stale, - diagnostic_effect(999, current, "closed"), - serde_json::json!({"type":"message","text":"stale checked"}), - ], - ); - drain_until(&mut session, |session| { - session.editor().message() == "stale checked" - }); - assert_eq!(session.editor().diagnostics().count(), 0); - assert!(session.plugins.contains_key("generic_fixture")); - let target = fixture.file("closed.txt", "closed"); - session.execute(&format!("edit {}", target.display())); - let gate = fixture.0.join("closed-ready"); - hold( - &mut session, - &gate, - vec![diagnostic_effect(2, 0, "late close")], - ); - session.execute("bdelete 2"); - release(&mut session, &gate); - assert_eq!(session.editor().buffer_id(), 1); - assert_eq!(session.editor().diagnostics().count(), 0); - assert!(session.plugins.contains_key("generic_fixture")); - } - - #[test] - fn invalid_annotation_batches_retire_owner_and_clear_hidden_annotations_only() { - let fixture = Fixture::new(); - let file = fixture.file("buffer.txt", "source"); - let target = fixture.file("target.txt", "target"); - let mut session = fixture.asynchronous(Some(file)); - session.execute(&format!("edit {}", target.display())); - session.execute("buffer 1"); - publish( - &mut session, - vec![ - diagnostic_effect(1, 0, "source"), - diagnostic_effect(2, 0, "hidden"), - ], - ); - drain_until(&mut session, |session| { - session.editor().diagnostics().count() == 1 - }); - let other = crate::plugin::Diagnostic { - start: TextPosition { row: 0, column: 0 }, - end: TextPosition { row: 0, column: 1 }, - severity: crate::plugin::DiagnosticSeverity::Hint, - message: "other owner".into(), - }; - session - .editor - .replace_diagnostics("other", 1, 0, vec![other]) - .unwrap(); - let mut invalid = diagnostic_effect(1, 0, "bad range"); - invalid["items"][0]["end"]["column"] = serde_json::json!(999); - publish( - &mut session, - vec![diagnostic_effect(2, 0, "must not commit"), invalid], - ); - drain_until(&mut session, |session| { - !session.plugins.contains_key("generic_fixture") - }); - assert!( - session - .editor() - .message() - .contains("Diagnostic position is outside") - ); - assert_eq!(session.editor().diagnostics().count(), 1); - assert_eq!( - session.editor().diagnostic_at_cursor().unwrap().message, - "other owner" - ); - session.execute("buffer 2"); - assert_eq!(session.editor().diagnostics().count(), 0); - session.execute("fixture_publish {}"); - assert!(session.editor().message().contains("Unknown command")); - session.handle(Input::Char('x')); - assert_eq!(session.editor().lines(), ["arget"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), ["target"]); - } - - fn navigation_effect(path: &Path) -> serde_json::Value { - serde_json::json!({"type":"navigate", "path":path, "row":0, "column":1}) - } - - #[test] - fn async_navigation_reuses_dirty_target_and_reports_open_failure_without_retiring() { - let fixture = Fixture::new(); - let source = fixture.file("buffer.txt", "source"); - let target = fixture.file("dirty 😀 target.txt", "a😀e\u{301}"); - let mut session = fixture.asynchronous(Some(source)); - session.execute(&format!("edit {}", target.display())); - session.handle(Input::Char('i')); - session.handle(Input::Char('!')); - session.handle(Input::Escape); - session.execute("buffer 1"); - publish(&mut session, vec![navigation_effect(&target)]); - drain_until(&mut session, |session| session.editor().buffer_id() == 2); - assert_eq!(session.editor().lines(), ["!a😀e\u{301}"]); - assert_eq!( - session.editor().cursor(), - crate::editor::Cursor { row: 0, column: 1 } - ); - assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - assert_eq!(session.editor().buffer_count(), 2); - assert_eq!(fs::read_to_string(&target).unwrap(), "a😀e\u{301}"); - let gate = fixture.0.join("missing-ready"); - hold( - &mut session, - &gate, - vec![navigation_effect(&fixture.0.join("missing"))], - ); - fs::write(&gate, "").unwrap(); - drain_until(&mut session, |session| { - session - .requests - .get("generic_fixture") - .is_some_and(|requests| requests.pending.is_empty()) - && session.editor().message().starts_with("delivered ") - }); - assert_eq!(session.editor().buffer_id(), 2); - assert_eq!(session.editor().lines(), ["!a😀e\u{301}"]); - assert!(session.plugins.contains_key("generic_fixture")); - // A standalone failure message remains visible when no later plugin message overwrites it. - publish( - &mut session, - vec![navigation_effect(&fixture.0.join("missing"))], - ); - drain_until(&mut session, |session| { - session.editor().message().starts_with("Navigation failed:") - }); - assert_eq!(session.editor().buffer_id(), 2); - assert_eq!(session.editor().buffer_count(), 2); - assert!(session.plugins.contains_key("generic_fixture")); - } - - #[test] - fn async_navigation_drops_superseded_changed_or_inactive_source_replies() { - for guard in ["superseded", "changed", "inactive", "closed"] { - let fixture = Fixture::new(); - let source = fixture.file("buffer.txt", "source"); - let target = fixture.file("target.txt", "target"); - let mut session = fixture.asynchronous(Some(source)); - let gate = fixture.0.join("navigation-ready"); - hold(&mut session, &gate, vec![navigation_effect(&target)]); - match guard { - "superseded" => barrier(&mut session, "new request"), - "changed" => { - session.handle(Input::Char('i')); - session.handle(Input::Char('x')); - } - "inactive" => { - let other = fixture.file("other.txt", "other"); - session.execute(&format!("edit {}", other.display())); - } - "closed" => session.execute("bdelete 1"), - _ => unreachable!(), - } - let active = session.editor().buffer_id(); - let text = session.editor().context().text; - release(&mut session, &gate); - assert_eq!(session.editor().buffer_id(), active, "{guard}"); - assert_eq!(session.editor().context().text, text, "{guard}"); - assert!(session.plugins.contains_key("generic_fixture"), "{guard}"); - assert!( - !session - .editor() - .buffers() - .any(|buffer| buffer.path == Some(target.as_path())), - "{guard}" - ); - let invokes: Vec<_> = fixture - .events() - .into_iter() - .filter(|event| event["invoke"] == "fixture_hold") - .collect(); - assert_eq!(invokes.len(), 1); - assert_eq!(invokes[0]["buffer_id"], 1); - assert_eq!(invokes[0]["revision"], 0); - assert_eq!(invokes[0]["row"], 1); - assert_eq!(invokes[0]["column"], 1); - } - } - - #[test] - fn stale_async_text_response_and_dead_host_never_break_native_typing() { - let fixture = Fixture::new(); - let file = fixture.file("buffer.txt", "source"); - let mut session = fixture.asynchronous(Some(file)); - session.execute("fixture_replace BAD"); - session.handle(Input::Char('i')); - session.handle(Input::Char('x')); - drain_until(&mut session, |session| { - session - .requests - .get("generic_fixture") - .is_some_and(|requests| requests.pending.is_empty()) - }); - assert_eq!(session.editor().lines(), ["xsource"]); - assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); - session.handle(Input::Char('y')); - session.handle(Input::Escape); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), ["source"]); - let revision = session.editor().text_revision(); - publish( - &mut session, - vec![diagnostic_effect(1, revision, "before crash")], - ); - drain_until(&mut session, |session| { - session.editor().diagnostics().count() == 1 - }); - session.execute("fixture_crash"); - session.handle(Input::Char('i')); - session.handle(Input::Char('z')); - drain_until(&mut session, |session| { - !session.plugins.contains_key("generic_fixture") - }); - assert_eq!(session.editor().diagnostics().count(), 0); - assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); - session.handle(Input::Char('q')); - session.handle(Input::Escape); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), ["source"]); - assert!(!session.editor().should_quit()); - session.execute("plugins"); - assert!(session.editor().message().contains("core [core]")); - } - - #[test] - fn async_navigation_invokes_once_at_primary_with_multiple_carets() { - let fixture = Fixture::new(); - let source = fixture.file("buffer.txt", "abc\nabc"); - let target = fixture.file("target.txt", "target"); - let mut session = fixture.asynchronous(Some(source)); - session.handle(Input::Char('l')); - session.handle(Input::Ctrl('e')); - assert_eq!(session.editor().selection_count(), 2); - assert_eq!( - session.editor().cursor(), - crate::editor::Cursor { row: 1, column: 1 } - ); - let gate = fixture.0.join("primary-ready"); - hold(&mut session, &gate, vec![navigation_effect(&target)]); - release(&mut session, &gate); - assert_eq!(session.editor().path(), Some(target.as_path())); - assert_eq!(session.editor().selection_count(), 1); - let invokes: Vec<_> = fixture - .events() - .into_iter() - .filter(|event| event["invoke"] == "fixture_hold") - .collect(); - assert_eq!(invokes.len(), 1); - assert_eq!(invokes[0]["row"], 2); - assert_eq!(invokes[0]["column"], 2); - assert_eq!(invokes[0]["revision"], 0); - } - - #[test] - fn pending_plugin_edits_do_not_modify_diff_buffers_or_disable_the_plugin() { - let fixture = Fixture::new(); - let source = fixture.file("buffer.txt", "original"); - let started = fixture.0.join("started"); - let release = fixture.0.join("release"); - fixture.file( - "delayed.rb", - r#"Vamp.plugin "delayed", requires: ["text"], async: true -Vamp.command "slow" do |editor, _| - File.write(Vamp.options.fetch("started"), "ready") - sleep 0.001 until File.exist?(Vamp.options.fetch("release")) - editor.insert "late edit" -end -Vamp.command "ping" do |editor, _| - editor.message "still alive" -end -"#, - ); - let config = fixture.file( - "config.rb", - &format!( - "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.plugin 'delayed.rb', options: {{started: {}, release: {}}}\nend\n", - serde_json::to_string(&started.to_string_lossy()).unwrap(), - serde_json::to_string(&release.to_string_lossy()).unwrap(), - ), - ); - let mut session = - EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap(); - session.execute("slow"); - drain_until(&mut session, |_| started.exists()); - session.execute("diff"); - assert!(session.editor().diff().is_some()); - fs::write(&release, "release").unwrap(); - // Wait for transport consumption, then assert the consumer-visible result. - drain_until(&mut session, |session| { - session - .requests - .get("delayed") - .is_none_or(|requests| requests.pending.is_empty()) - }); - assert_eq!(session.editor().context().text, "original"); - assert_eq!(fs::read_to_string(&source).unwrap(), "original"); - session.execute("diff.close"); - session.execute("ping"); - drain_until(&mut session, |session| { - session.editor().message() == "still alive" - }); - assert_eq!(session.editor().context().text, "original"); - } - - #[test] - fn async_responses_edit_only_current_bound_working_after_and_refresh_projection() { - use crate::diff::{ - Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, - RevisionTarget, - }; - let fixture = Fixture::new(); - fixture.file("buffer.txt", "base"); - let mut session = fixture.open(""); - session.editor.execute("diff"); - let (id, _) = session.editor.take_diff_request().unwrap(); - let revision = |target, label: &str| Revision { - target, - label: label.into(), - description: String::new(), - category: Category::Commits, - }; - session.editor.apply_diff_result( - id, - Ok(DiffOutcome::Catalog(Catalog { - kind: RepoKind::Git, - notice: None, - entries: vec![ - revision(RevisionTarget::Commit("base".into()), "base"), - revision(RevisionTarget::Working, "Working"), - ], - })), - ); - for input in [Input::Down, Input::Enter, Input::Enter] { - session.editor.handle(input); - } - let (id, _) = session.editor.take_diff_request().unwrap(); - session.editor.apply_diff_result( - id, - Ok(DiffOutcome::Comparison(Comparison { - revisions: ["base".into(), "working:snapshot".into()], - root: fixture.0.canonicalize().unwrap(), - before: "base".into(), - after: "Working".into(), - editable_paths: [PathBuf::from("buffer.txt")].into_iter().collect(), - files: vec![ChangedFile { - path: PathBuf::from("buffer.txt"), - old_path: None, - status: FileStatus::Modified, - loaded: true, - before: vec!["base".into()], - after: vec!["base".into()], - before_eof: false, - after_eof: false, - hunks: Vec::new(), - notice: None, - }], - })), - ); - session.editor.handle(Input::BackTab); - let source = InvocationSource { - buffer_id: session.editor.buffer_id(), - pane_id: session.editor.pane_id(), - revision: session.editor.text_revision(), - }; - session - .requests - .entry("fixture".into()) - .or_default() - .pending - .insert(1, source); - assert!( - session - .apply_response("fixture", 1, vec![Effect::Insert("accepted".into())]) - .unwrap() - ); - assert_eq!(session.editor.lines(), ["acceptedbase"]); - let diff = session.editor.diff().unwrap(); - assert_eq!(diff.live_revision, Some(session.editor.text_revision())); - assert_eq!( - diff.comparison.as_ref().unwrap().files[0].after, - session.editor.lines() - ); - - session - .requests - .get_mut("fixture") - .unwrap() - .pending - .insert(2, source); - assert!( - !session - .apply_response("fixture", 2, vec![Effect::Insert("stale".into())]) - .unwrap() - ); - session.editor.scroll_diff(Focus::Before, false); - let readonly = InvocationSource { - buffer_id: session.editor.buffer_id(), - pane_id: session.editor.pane_id(), - revision: session.editor.text_revision(), - }; - session - .requests - .get_mut("fixture") - .unwrap() - .pending - .insert(3, readonly); - assert!( - !session - .apply_response("fixture", 3, vec![Effect::Insert("readonly".into())]) - .unwrap() - ); - assert_eq!(session.editor.lines(), ["base"]); - assert!( - session - .editor - .documents() - .any(|document| document.lines == ["acceptedbase"]) - ); - session - .requests - .get_mut("fixture") - .unwrap() - .pending - .insert(4, readonly); - assert!( - session - .apply_response("fixture", 4, vec![Effect::Message("allowed".into())]) - .unwrap() - ); - assert_eq!(session.editor.message(), "allowed"); - session.editor.execute("diff.close"); - assert!(session.editor.dirty()); - } -} +#[path = "../tests/unit/session.rs"] +mod tests; diff --git a/src/snippet.rs b/src/snippet.rs index 1da39b8..69526b5 100644 --- a/src/snippet.rs +++ b/src/snippet.rs @@ -362,124 +362,5 @@ impl Render<'_> { #[cfg(test)] // These fixtures intentionally contain ranges, not lists of indices. #[allow(clippy::single_range_in_vec_init)] -mod tests { - use super::*; - - #[test] - fn grammar_expands_nested_mirrors_choices_variables_and_normalized_offsets() { - let (text, snippet) = expand( - "call($1, ${1:λ${2:x}}, ${3|a\\,b,c|}, ${UNKNOWN:${TM_LINE_NUMBER}})\r\n$0\\$", - |name| (name == "TM_LINE_NUMBER").then(|| "4".to_owned()), - ) - .unwrap(); - assert_eq!(text, "call(λx, λx, a,b, 4)\n$"); - assert_eq!( - snippet.tabstops, - vec![ - SnippetTabstop { - index: 1, - ranges: vec![5..8, 10..13], - ancestors: vec![vec![], vec![]] - }, - SnippetTabstop { - index: 2, - ranges: vec![7..8, 12..13], - ancestors: vec![vec![1], vec![1]] - }, - SnippetTabstop { - index: 3, - ranges: vec![15..18], - ancestors: vec![vec![]] - }, - SnippetTabstop { - index: 0, - ranges: vec![23..23], - ancestors: vec![vec![]] - }, - ] - ); - let (text, snippet) = expand("call($1, ${2})", |_| None).unwrap(); - assert_eq!(text, "call(, )"); - assert_eq!( - snippet.tabstops, - vec![ - SnippetTabstop { - index: 1, - ranges: vec![5..5], - ancestors: vec![vec![]] - }, - SnippetTabstop { - index: 2, - ranges: vec![7..7], - ancestors: vec![vec![]] - }, - SnippetTabstop { - index: 0, - ranges: vec![8..8], - ancestors: vec![vec![]] - }, - ] - ); - assert_eq!( - expand("${KNOWN:${1:hidden}}$1", |name| { - (name == "KNOWN").then(|| "shown".to_owned()) - }) - .unwrap() - .0, - "shown" - ); - let (text, snippet) = expand("call(${CTX})$1", |name| { - (name == "CTX").then(|| "a\r\nb\rc".to_owned()) - }) - .unwrap(); - assert_eq!(text, "call(a\nb\nc)"); - assert_eq!(snippet.tabstops[0].ranges, [11..11]); - assert_eq!( - expand("${1|a\\|b,c|}\\}\\\\", |_| None).unwrap().0, - "a|b}\\" - ); - for input in ["${1", "${1:$1}", "${1:a}${1:b}$1", "${1:e}\u{301}"] { - if input == "${1:a}${1:b}$1" { - assert_eq!(expand(input, |_| None).unwrap().0, "aaa"); - } else { - assert_eq!(expand(input, |_| None), Err(Error::Invalid)); - } - } - let (text, snippet) = expand("${1}$1", |_| None).unwrap(); - assert!(text.is_empty()); - assert_eq!( - snippet.tabstops[0], - SnippetTabstop { - index: 1, - ranges: vec![0..0, 0..0], - ancestors: vec![vec![], vec![]], - } - ); - for input in [ - "$UNKNOWN", - "${TM_FILENAME/(.*)/$1/}", - "${1/a/b/}", - "${1:a$0b}", - "$0$0", - "${0:arg}", - "${0:$1}", - ] { - assert_eq!(expand(input, |_| None), Err(Error::Unsupported)); - } - assert_eq!( - expand(&"$1".repeat(MAX_STOPS + 1), |_| None), - Err(Error::Limit) - ); - assert_eq!( - expand( - &format!( - "{}x{}", - "${1:".repeat(MAX_DEPTH + 1), - "}".repeat(MAX_DEPTH + 1) - ), - |_| None - ), - Err(Error::Limit) - ); - } -} +#[path = "../tests/unit/snippet.rs"] +mod tests; diff --git a/src/syntax.rs b/src/syntax.rs index 9490da9..6b2ec2a 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -1562,1133 +1562,5 @@ fn append_global( } #[cfg(test)] -mod tests { - use super::*; - - fn lines(source: &str) -> Vec { - source.split('\n').map(str::to_owned).collect() - } - - #[test] - fn scopes_follow_real_declarations_and_each_sources_own_tree() { - let original = "impl State {\n fn old() {\n call();\n }\n}\n\nfn next() {\n call();\n}\n"; - let before = cache(original, "old.rs"); - let mut live = cache(&original.replace("old()", "renamed()"), "new.rs"); - assert_eq!( - before.scope_at(2), - Some(ScopeContext { - start_row: 1, - end_row: 4, - name: Point::new(1, 7), - label: "impl State › old", - }) - ); - assert_eq!(before.scope_at(3).unwrap().label, "impl State › old"); - assert_eq!(before.scope_at(4).unwrap().label, "impl State"); - assert_eq!(before.scope_at(5), None); - assert_eq!( - before.scope_at(6), - Some(ScopeContext { - start_row: 6, - end_row: 9, - name: Point::new(6, 3), - label: "next", - }) - ); - assert_eq!(before.scope_at(9), None); - assert_eq!(before.scope_at(usize::MAX), None); - let changed = lines(&original.replace("old()", "updated()")); - live.update_visible(&changed, Some(Path::new("new.rs")), 1, &[2..3]); - assert_eq!(live.scope_at(2).unwrap().label, "impl State › updated"); - assert_eq!(before.scope_at(2).unwrap().label, "impl State › old"); - let counts = live.parse_counts(); - live.update_visible(&changed, Some(Path::new("new.rs")), 2, &[6..9]); - assert_eq!(live.parse_counts(), counts); - live.update_visible(&changed, Some(Path::new("renamed.txt")), 2, &[0..1]); - assert_eq!(live.scope_at(2), None); - assert_eq!(before.scope_at(2).unwrap().label, "impl State › old"); - let incomplete = cache("impl State {\n fn run() {\n let = ;\n }\n}", "a.rs"); - assert_eq!(incomplete.scope_at(2).unwrap().label, "impl State › run"); - - let python = cache( - "class State:\n @decorator\n def run(self):\n work()\n\n def next(self):\n work()\n", - "a.py", - ); - assert_eq!( - python.scope_at(1), - Some(ScopeContext { - start_row: 1, - end_row: 4, - name: Point::new(2, 8), - label: "State › run", - }) - ); - assert_eq!(python.scope_at(4).unwrap().label, "State"); - assert_eq!(python.scope_at(5).unwrap().label, "State › next"); - assert_eq!(python.scope_at(7), None); - - for (path, source, expected) in [ - ( - "a.rs", - "trait State {\n fn run(&self) {\n work();\n }\n}", - Some("State › run"), - ), - ( - "a.rb", - "class State\n def run\n work\n end\nend", - Some("State › run"), - ), - ( - "a.js", - "function outer() {\n function inner() {\n work();\n }\n}", - Some("outer › inner"), - ), - ( - "a.ts", - "class State {\n run = () => {\n work();\n };\n}", - Some("State › run"), - ), - ( - "a.tsx", - "const View = () => {\n work();\n return
;\n};", - Some("View"), - ), - ( - "a.js", - "const handlers = { run: () => {\n work();\n} };", - Some("run"), - ), - ( - "a.js", - "State.run = () => {\n work();\n};", - Some("State.run"), - ), - ( - "a.go", - "package main\nfunc (s *State) run() {\n work()\n}", - Some("*State › run"), - ), - ( - "a.go", - "package main\nfunc run() {\n work()\n}", - Some("run"), - ), - ( - "a.c", - "int run(void) {\n work();\n return 7;\n}", - Some("run"), - ), - ( - "a.cpp", - "class State {\n void run() {\n work();\n }\n};", - Some("State › run"), - ), - ( - "a.cpp", - "void State::run() {\n work();\n}", - Some("State::run"), - ), - ("a.sh", "run() {\n work\n}", Some("run")), - ( - "a.zig", - "const State = struct {\n pub fn run() void {\n work();\n }\n};", - Some("State › run"), - ), - ("a.md", "```rust\nfn run() {\n work();\n}\n```", Some("run")), - ( - "a.html", - "", - Some("run"), - ), - ("a.js", "use(() => {\n work();\n});", None), - ( - "a.js", - "const handlers = { [key]: () => {\n work();\n} };", - None, - ), - ("a.txt", "fn run() {\n work();\n}", None), - ("a.unknown", "fn run() {\n work();\n}", None), - ("a.json", "{\"work\": \"function run() {}\"}", None), - ("a.yaml", "work: function run() {}", None), - ] { - let syntax = cache(source, path); - let row = source[..source.find("work").unwrap()] - .bytes() - .filter(|byte| *byte == b'\n') - .count(); - assert_eq!( - syntax.scope_at(row).map(|scope| scope.label), - expected, - "{path}: {source}" - ); - if let Some(scope) = syntax.scope_at(row) { - assert!(scope.start_row <= row && row < scope.end_row, "{path}"); - } - } - } - - fn source_range(source: &str) -> Range { - Range { - start_byte: 0, - end_byte: source.len(), - start_point: Point::new(0, 0), - end_point: point_at(source, source.len()), - } - } - - #[test] - fn string_newlines_remain_literal_across_visible_row_caching_and_edits() { - let source = "text = \"\"\"a\n\nb\"\"\"\nplain\n"; - let input = lines(source); - let path = Path::new("strings.py"); - let mut cache = SyntaxCache::new(); - let includes_newline = |cache: &SyntaxCache, row: usize, length: usize| { - cache - .string_spans(row) - .iter() - .any(|span| span.start_byte <= length && span.end_byte > length) - }; - cache.update_visible(&input, Some(path), 0, std::slice::from_ref(&(0..1))); - assert!(includes_newline(&cache, 0, input[0].len())); - cache.update_visible(&input, Some(path), 0, std::slice::from_ref(&(1..4))); - assert!(includes_newline(&cache, 0, input[0].len())); - assert!(includes_newline(&cache, 1, input[1].len())); - assert!(!includes_newline(&cache, 2, input[2].len())); - assert!(!includes_newline(&cache, 3, input[3].len())); - for (row, line) in input.iter().enumerate() { - assert!( - cache - .line(row) - .iter() - .all(|span| span.end_byte <= line.len()) - ); - } - cache.update(&lines("# a\n\nb\nplain\n"), Some(path), 1); - assert!(!includes_newline(&cache, 0, 3)); - assert!(!includes_newline(&cache, 1, 0)); - } - - #[test] - fn visible_windows_match_full_colors_after_edits_and_scrolling_without_reparsing() { - let cases = [ - ( - "a.rs", - "fn main() {\n/* start\ninside é 👩‍💻\nend */\nlet n = 42;\nlet text = \"unfinished", - ), - ("a.py", "text = '''first\ninside é\nlast'''\nanswer = 42"), - ( - "a.js", - "const action = () => 42;\nfunction hello(require) {\n require('local');\n action;\n}\nrequire('global');\naction;", - ), - ( - "a.md", - "# Heading **bold**\n\n```rust\nfn main() {\n let n = 42;\n}\n```\n\n```python\ndef hello():\n return 7\n```\n\n```unknown\nfn plain() {}\n```", - ), - ( - "a.md", - "> # Heading **bold**\n>\n> ```rust\n> fn main() {\n> let n = 42;\n> }\n> ```", - ), - ( - "a.md", - "```html\n\n```", - ), - ( - "a.html", - "
é
\n\n", - ), - ( - "a.js", - "const page = html`
${value}
\n`;", - ), - ]; - for (path, original) in cases { - let mut visible = SyntaxCache::new(); - for (revision, source) in [ - original.to_owned(), - original.replace("42", "123"), - format!("\n{original}"), - original.replace("() => 42", "42"), - ] - .iter() - .enumerate() - { - let input = lines(source); - let middle = input.len() / 2; - visible.update_visible( - &input, - Some(Path::new(path)), - revision as u64, - std::slice::from_ref(&(middle..middle + 1)), - ); - let full = cache(source, path); - assert_eq!(visible.error(), None, "{path}"); - assert_eq!( - visible.line(middle), - full.line(middle), - "{path} revision {revision}" - ); - assert!( - visible - .lines - .iter() - .enumerate() - .all(|(row, spans)| row == middle || spans.is_empty()) - ); - let parses = visible.parse_counts(); - let context = visible.context.as_ref().unwrap().captures.as_ptr(); - // Unsorted, overlapping and out-of-bounds windows use original source rows. - for row in (0..input.len()).rev() { - visible.update_visible( - &input, - Some(Path::new(path)), - revision as u64, - &[ - row..row + 1, - middle..middle + 1, - row..row + 1, - input.len()..usize::MAX, - ], - ); - assert_eq!( - visible.line(row), - full.line(row), - "{path} revision {revision} row {row}" - ); - assert_eq!( - visible.parse_counts(), - parses, - "{path}: scrolling reparsed a tree" - ); - assert_eq!(visible.context.as_ref().unwrap().captures.as_ptr(), context); - } - } - } - } - - #[test] - fn visible_highlight_collection_stays_bounded_by_the_requested_rows() { - let mut input: Vec<_> = (0..1000) - .map(|row| format!("fn function_{row}() {{ let value = 42; }}")) - .collect(); - let path = Path::new("large.rs"); - let mut visible = SyntaxCache::new(); - visible.update_visible(&input, Some(path), 0, std::slice::from_ref(&(500..502))); - assert_eq!(visible.parses, 1); - assert!(visible.captures.len() < 50); - assert!(visible.highlights.len() < 30); - assert!(visible.lines[..500].iter().all(Vec::is_empty)); - assert!(visible.lines[502..].iter().all(Vec::is_empty)); - input[500] = "fn changed() { let value = \"é 👩‍💻\"; }".to_owned(); - visible.update_visible(&input, Some(path), 1, std::slice::from_ref(&(500..502))); - assert_eq!(visible.incremental_parses, 1); - assert!(visible.captures.len() < 50); - let mut full = SyntaxCache::new(); - full.update(&input, Some(path), 1); - assert!(full.captures.len() > 1000); - for row in 500..502 { - assert_eq!(visible.line(row), full.line(row)); - } - let parses = visible.parse_counts(); - visible.update_visible(&input, Some(path), 1, std::slice::from_ref(&(950..952))); - assert_eq!(visible.parse_counts(), parses); - assert!(visible.captures.len() < 50); - for row in 950..952 { - assert_eq!(visible.line(row), full.line(row)); - } - } - - #[test] - fn visible_cache_supports_full_updates_and_same_revision_path_changes() { - let input = lines("const element =
;\nconst answer = 42;"); - let mut visible = SyntaxCache::new(); - visible.update_visible( - &input, - Some(Path::new("a.ts")), - 0, - std::slice::from_ref(&(1..2)), - ); - let parses = visible.parse_counts(); - visible.update(&input, Some(Path::new("a.ts")), 0); - assert_eq!(visible.parse_counts(), parses); - assert_fresh(&visible, &input.join("\n"), "a.ts"); - visible.update_visible( - &input, - Some(Path::new("a.tsx")), - 0, - std::slice::from_ref(&(0..1)), - ); - assert_eq!(visible.parses, parses.0 + 1); - assert_eq!(visible.line(0), cache(&input.join("\n"), "a.tsx").line(0)); - assert!(visible.line(1).is_empty()); - visible.update_text(&input.join("\n"), Path::new("a.tsx")); - assert_fresh(&visible, &input.join("\n"), "a.tsx"); - visible.update_visible( - &input, - Some(Path::new("a.txt")), - 0, - std::slice::from_ref(&(0..2)), - ); - assert_eq!(visible.language(), None); - assert!(visible.lines.iter().all(Vec::is_empty)); - } - - fn cache(source: &str, path: &str) -> SyntaxCache { - let mut cache = SyntaxCache::new(); - cache.update(&lines(source), Some(Path::new(path)), 0); - assert_eq!(cache.error(), None, "{path}"); - cache - } - - fn kind_at(cache: &SyntaxCache, row: usize, byte: usize) -> Option { - cache - .line(row) - .iter() - .find_map(|span| (span.start_byte <= byte && byte < span.end_byte).then_some(span.kind)) - } - - fn assert_token(cache: &SyntaxCache, source: &str, row: usize, token: &str, kind: SyntaxKind) { - let line = source.split('\n').nth(row).unwrap(); - let start = line.find(token).unwrap(); - for (byte, _) in line[start..start + token.len()].char_indices() { - assert_eq!( - kind_at(cache, row, start + byte), - Some(kind), - "{token:?} in {}", - cache.language().unwrap() - ); - } - } - - fn assert_fresh(cache: &SyntaxCache, source: &str, path: &str) { - let fresh = self::cache(source, path); - assert_eq!(cache.language(), fresh.language()); - assert_eq!(cache.error(), fresh.error()); - assert_eq!( - cache.tree.as_ref().map(|tree| tree.root_node().to_sexp()), - fresh.tree.as_ref().map(|tree| tree.root_node().to_sexp()) - ); - let source = lines(source); - for (row, line) in source.iter().enumerate() { - assert_eq!(cache.line(row), fresh.line(row), "row {row}"); - let mut previous = 0; - for span in cache.line(row) { - assert!(previous <= span.start_byte && span.start_byte < span.end_byte); - assert!(span.end_byte <= line.len()); - assert!( - line.is_char_boundary(span.start_byte) && line.is_char_boundary(span.end_byte) - ); - previous = span.end_byte; - } - } - } - - #[test] - fn every_bundled_query_compiles_and_common_languages_color_real_tokens() { - for language in languages::all() { - configuration(language).unwrap_or_else(|error| panic!("{}: {error}", language.name)); - } - let cases = [ - ( - "a.rs", - "Rust", - "fn main() { let x = 42; let s = \"hi\"; /* note */ }", - "fn", - SyntaxKind::Keyword, - ), - ( - "a.rb", - "Ruby", - "def hello\n 42\nend", - "def", - SyntaxKind::Keyword, - ), - ( - "a.py", - "Python", - "def hello():\n return 42", - "def", - SyntaxKind::Keyword, - ), - ( - "a.json", - "JSON", - "{\"n\":42,\"yes\":true}", - "42", - SyntaxKind::Number, - ), - ( - "a.sh", - "Bash", - "echo \"hi\" # note", - "hi", - SyntaxKind::String, - ), - ( - "Makefile", - "Makefile", - "# note\nall:\n\techo hi", - "note", - SyntaxKind::Comment, - ), - ( - "a.md", - "Markdown", - "# Heading\n\n**bold**", - "Heading", - SyntaxKind::Markup, - ), - ( - "a.js", - "JavaScript", - "function hello() { return 42; }", - "function", - SyntaxKind::Keyword, - ), - ( - "a.ts", - "TypeScript", - "function hello(): number { return 42; }", - "function", - SyntaxKind::Keyword, - ), - ( - "a.html", - "HTML", - "
text
", - "div", - SyntaxKind::Markup, - ), - ( - "a.css", - "CSS", - "body { width: 42px; }", - "42", - SyntaxKind::Number, - ), - ( - "a.yaml", - "YAML", - "message: \"hi\"\ncount: 42", - "hi", - SyntaxKind::String, - ), - ( - "Cargo.toml", - "TOML", - "message = \"hi\"\ncount = 42", - "hi", - SyntaxKind::String, - ), - ( - "a.c", - "C", - "int main(void) { return 42; }", - "return", - SyntaxKind::Keyword, - ), - ( - "a.cpp", - "C++", - "int main() { return 42; }", - "return", - SyntaxKind::Keyword, - ), - ( - "a.go", - "Go", - "package main\nfunc main() { return }", - "package", - SyntaxKind::Keyword, - ), - ]; - for (path, language, source, token, kind) in cases { - let cache = self::cache(source, path); - assert_eq!(cache.language(), Some(language)); - assert_token(&cache, source, 0, token, kind); - assert_fresh(&cache, source, path); - } - } - - #[test] - fn semantic_colors_and_text_predicates_preserve_the_ui_contract() { - let source = - "fn main() { let n = 42; let f = 3.25; let b = true; let s = \"hello\"; /* note */ }"; - let cache = self::cache(source, "a.rs"); - for (token, kind) in [ - ("fn", SyntaxKind::Keyword), - ("let", SyntaxKind::Keyword), - ("main", SyntaxKind::Function), - ("42", SyntaxKind::Number), - ("3.25", SyntaxKind::Number), - ("true", SyntaxKind::Keyword), - ("hello", SyntaxKind::String), - ("note", SyntaxKind::Comment), - ] { - assert_token(&cache, source, 0, token, kind); - } - let source = "const foo = 42; const UPPER = 7; foo; UPPER;"; - let cache = self::cache(source, "a.js"); - assert_eq!(kind_at(&cache, 0, source.find("foo").unwrap()), None); - // JavaScript's bundled constant predicate must not color every identifier. - assert_ne!( - kind_at(&cache, 0, source.find("foo;").unwrap()), - Some(SyntaxKind::Keyword) - ); - } - - #[test] - fn zig_highlights_literals_builtins_and_predicate_matched_identifiers() { - let source = r#"const std = @import("std"); -const MAX_COUNT: u32 = 42; -pub fn main() void { - const local = @as(u32, 7); - const value = Point; - const text = - \\first é - \\second - ; - // note -}"#; - let cache = self::cache(source, "main.zig"); - for (row, token, kind) in [ - (0, "const", SyntaxKind::Keyword), - (0, "@import", SyntaxKind::Keyword), - (0, "\"std\"", SyntaxKind::String), - (1, "MAX_COUNT", SyntaxKind::Keyword), - (1, "u32", SyntaxKind::Type), - (1, "42", SyntaxKind::Number), - (2, "pub", SyntaxKind::Keyword), - (2, "fn", SyntaxKind::Keyword), - (2, "main", SyntaxKind::Function), - (2, "void", SyntaxKind::Type), - (3, "@as", SyntaxKind::Function), - (4, "Point", SyntaxKind::Type), - (6, "first é", SyntaxKind::String), - (7, "second", SyntaxKind::String), - (9, "note", SyntaxKind::Comment), - ] { - assert_token(&cache, source, row, token, kind); - } - let local = source.lines().nth(3).unwrap().find("local").unwrap(); - assert_eq!(kind_at(&cache, 3, local), None); - } - - #[test] - fn filenames_shebang_save_as_and_tsx_use_actual_grammar_identity() { - let source = lines("\u{feff}#!/usr/bin/env python3\nreturn 42"); - let mut cache = SyntaxCache::new(); - cache.update(&source, None, 0); - assert_eq!(cache.language(), Some("Python")); - cache.update(&source, Some(Path::new("a.RS")), 0); - assert_eq!(cache.language(), Some("Rust")); - assert_eq!(cache.parses, 2); - cache.update(&source, Some(Path::new("renamed.rs")), 0); - assert_eq!(cache.parses, 2); - cache.update(&lines("all:\n\techo hi"), Some(Path::new("GNUmakefile")), 1); - assert_eq!(cache.language(), Some("Makefile")); - let source = "const element =
;"; - cache.update(&lines(source), Some(Path::new("a.ts")), 2); - let typescript = cache.syntax.unwrap(); - let parses = cache.parses; - cache.update(&lines(source), Some(Path::new("a.tsx")), 2); - assert_eq!(cache.language(), Some("TypeScript")); - assert!(!std::ptr::eq(typescript, cache.syntax.unwrap())); - assert_eq!(cache.parses, parses + 1); - assert_token(&cache, source, 0, "div", SyntaxKind::Markup); - assert_fresh(&cache, source, "a.tsx"); - let mut script = SyntaxCache::new(); - script.update(&lines("#!/usr/bin/env python3\nreturn 42"), None, 0); - assert_eq!(script.language(), Some("Python")); - script.update(&lines("#!/usr/bin/env bash\necho \"hi\""), None, 1); - assert_eq!(script.language(), Some("Bash")); - assert_token( - &script, - "#!/usr/bin/env bash\necho \"hi\"", - 1, - "hi", - SyntaxKind::String, - ); - } - - #[test] - fn unknown_and_former_non_common_languages_are_honest_plain_text() { - for path in [ - "a.unknown", - "a.txt", - "a.hs", - "a.java", - "a.php", - "a.swift", - "a.sql", - ] { - let cache = self::cache("fn main() { \"hi\"; 42; }", path); - assert_eq!(cache.language(), None, "{path}"); - assert!(cache.lines.iter().all(Vec::is_empty)); - assert_eq!(cache.parses, 0); - } - } - - #[test] - fn unchanged_revision_and_same_source_do_not_allocate_or_parse() { - let mut cache = self::cache("fn main() {}\n", "a.rs"); - let source = lines("fn main() {}\n"); - let source_pointer = cache.source.as_ptr(); - let scratch_pointer = cache.next_source.as_ptr(); - let tree_id = cache.tree.as_ref().unwrap().root_node().id(); - cache.update(&source, Some(Path::new("a.rs")), 0); - assert_eq!(cache.source.as_ptr(), source_pointer); - assert_eq!(cache.next_source.as_ptr(), scratch_pointer); - assert_eq!(cache.tree.as_ref().unwrap().root_node().id(), tree_id); - cache.update(&source, Some(Path::new("a.rs")), 1); - cache.update(&source, Some(Path::new("b.rs")), 1); - assert_eq!(cache.parses, 1); - assert_eq!(cache.incremental_parses, 0); - assert_eq!(cache.tree.as_ref().unwrap().root_node().id(), tree_id); - assert!(cache.line(1).is_empty()); - cache.update(&source, Some(Path::new("b.txt")), 1); - assert_eq!(cache.language(), None); - assert!(cache.tree.is_none()); - assert!(cache.lines.iter().all(Vec::is_empty)); - cache.update(&source, Some(Path::new("b.rs")), 1); - assert_eq!(cache.parses, 2); - assert_eq!(cache.incremental_parses, 0); - } - - #[test] - fn unicode_minimal_edits_use_byte_points_and_never_split_codepoints() { - let old = "x\n\t\"é 👩‍💻\"\n"; - let new = "x\n\t\"ê 👩‍💻\"\n"; - let edit = minimal_edit(old, new); - assert_eq!(edit.start_byte, 4); - assert_eq!(edit.start_position, Point::new(1, 2)); - assert_eq!(edit.old_end_byte, 6); - assert_eq!(edit.new_end_byte, 6); - assert_eq!(edit.old_end_position, Point::new(1, 4)); - let edit = minimal_edit("a\nβ\n", "a\n\nβ\n"); - assert_eq!(edit.start_position, Point::new(1, 0)); - assert_eq!(edit.old_end_position, Point::new(1, 0)); - assert_eq!(edit.new_end_position, Point::new(2, 0)); - let edit = minimal_edit("α", ""); - assert_eq!( - (edit.start_byte, edit.old_end_byte, edit.new_end_byte), - (0, 2, 0) - ); - let edit = minimal_edit("xα", "xѱ"); - assert_eq!( - (edit.start_byte, edit.old_end_byte, edit.new_end_byte), - (1, 3, 3) - ); - assert_eq!(edit.new_end_position, Point::new(0, 3)); - } - - #[test] - fn incremental_unicode_newline_insert_remove_and_undo_match_fresh_trees() { - let original = - "fn main() {\n\tlet text = \"é é 👩‍💻\";\n\t/* nested /* inner */ note */\n}\n"; - let mut cache = self::cache(original, "a.rs"); - let edits = [ - original.replace("é", "ê"), - original.replace("let text", "\nlet text"), - original.replace("\n\t/* nested /* inner */ note */", ""), - original.replace("👩‍💻", "👩‍🚀"), - original.replace("\";", "\";\n\tlet n = 42;"), - original.to_owned(), - String::new(), - original.to_owned(), - ]; - for (revision, source) in edits.iter().enumerate() { - cache.update(&lines(source), Some(Path::new("a.rs")), revision as u64 + 1); - assert_fresh(&cache, source, "a.rs"); - } - assert_eq!(cache.parses, edits.len() + 1); - assert_eq!(cache.incremental_parses, edits.len()); - } - - #[test] - fn unfinished_source_and_multiline_offscreen_state_remain_highlighted() { - let source = - "fn main() {\n/* start\ninside 👩‍💻\nend */\nlet n = 42;\nlet text = \"unfinished"; - let cache = self::cache(source, "a.rs"); - assert!(cache.tree.as_ref().unwrap().root_node().has_error()); - assert_token(&cache, source, 0, "fn", SyntaxKind::Keyword); - assert_token(&cache, source, 2, "inside", SyntaxKind::Comment); - assert_token(&cache, source, 4, "42", SyntaxKind::Number); - assert_eq!(cache.error(), None); - let source = "text = '''first\ninside\nlast'''\nanswer = 42"; - let cache = self::cache(source, "a.py"); - assert_token(&cache, source, 1, "inside", SyntaxKind::String); - assert_token(&cache, source, 3, "42", SyntaxKind::Number); - } - - #[test] - fn markdown_heading_markers_share_title_colors_without_coloring_plain_hashes() { - let source = "# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six\n\nSetext\n======\n\nplain # text"; - let cache = self::cache(source, "headings.md"); - for row in 0..6 { - assert_token( - &cache, - source, - row, - &"#".repeat(row + 1), - SyntaxKind::Markup, - ); - } - assert_token(&cache, source, 0, "One", SyntaxKind::Markup); - assert_token(&cache, source, 7, "Setext", SyntaxKind::Markup); - assert_token(&cache, source, 8, "======", SyntaxKind::Markup); - assert_eq!(kind_at(&cache, 10, 6), None); - } - - #[test] - fn markdown_list_markers_color_bullets_and_numbers_without_coloring_plain_text() { - let source = "- item\n - nested\n 12. ordered\n\n+ item\n\n* item\n\n1. ordered\n2. next\n\n3) ordered\n\n> - quoted\n> 4. ordered\n\nplain - text and 1. text\n\n\\- escaped"; - let mut cache = self::cache(source, "lists.md"); - for (row, marker) in [ - (0, "-"), - (1, "-"), - (2, "12."), - (4, "+"), - (6, "*"), - (8, "1."), - (9, "2."), - (11, "3)"), - (13, "-"), - (14, "4."), - ] { - assert_token(&cache, source, row, marker, SyntaxKind::Markup); - } - assert_eq!(kind_at(&cache, 0, 2), None); - assert_eq!(kind_at(&cache, 16, 6), None); - assert_eq!(kind_at(&cache, 16, 17), None); - assert_ne!(kind_at(&cache, 18, 1), Some(SyntaxKind::Markup)); - - let edited = source.replacen("- item", "plain - item", 1); - cache.update(&lines(&edited), Some(Path::new("lists.md")), 1); - assert_eq!(kind_at(&cache, 0, 6), None); - assert_fresh(&cache, &edited, "lists.md"); - } - - #[test] - fn markdown_inline_and_fences_inject_known_languages_and_update_incrementally() { - let original = "A **bold** and `code` link.\n\n```rust\nfn main() { let n = 42; }\n```\n\n```python\ndef hello():\n return 7\n```\n\n```not-a-language\nfn not_rust() { 99; }\n```"; - let mut cache = self::cache(original, "a.md"); - assert_token(&cache, original, 0, "bold", SyntaxKind::Markup); - assert_token(&cache, original, 0, "code", SyntaxKind::Markup); - assert_token(&cache, original, 3, "fn", SyntaxKind::Keyword); - assert_token(&cache, original, 3, "main", SyntaxKind::Function); - assert_token(&cache, original, 3, "42", SyntaxKind::Number); - assert_token(&cache, original, 7, "def", SyntaxKind::Keyword); - assert_ne!(kind_at(&cache, 12, 0), Some(SyntaxKind::Keyword)); - let parses = cache.injection_parses; - let source = original.replace("42", "123"); - cache.update(&lines(&source), Some(Path::new("a.md")), 1); - assert_token(&cache, &source, 3, "123", SyntaxKind::Number); - assert_fresh(&cache, &source, "a.md"); - assert!(cache.injection_parses > parses); - let source = source.replace("```rust", "```unknown"); - cache.update(&lines(&source), Some(Path::new("a.md")), 2); - assert_ne!(kind_at(&cache, 3, 0), Some(SyntaxKind::Keyword)); - assert_fresh(&cache, &source, "a.md"); - } - - #[test] - fn markdown_injections_keep_anonymous_syntax_but_not_named_block_continuations() { - let source = - "> # Heading **bold**\n>\n> ```rust\n> fn main() {\n> let n = 3.25;\n> }\n> ```"; - let cache = self::cache(source, "a.md"); - assert_token(&cache, source, 0, "Heading", SyntaxKind::Markup); - assert_token(&cache, source, 0, "bold", SyntaxKind::Markup); - assert_token(&cache, source, 3, "main", SyntaxKind::Function); - assert_token(&cache, source, 4, "3.25", SyntaxKind::Number); - let rust = languages::by_name("rust").unwrap(); - let layer = cache - .injections - .iter() - .find(|layer| std::ptr::eq(layer.syntax, rust)) - .unwrap(); - for range in &layer.ranges { - assert!(!source[range.start_byte..range.end_byte].contains('>')); - } - let braces = [source.find('{').unwrap(), source.find('}').unwrap()]; - for byte in braces { - assert!( - layer - .ranges - .iter() - .any(|range| range.start_byte <= byte && byte < range.end_byte) - ); - } - assert_fresh(&cache, source, "a.md"); - } - - #[test] - fn html_script_style_injections_override_host_and_reuse_unchanged_trees() { - let original = "
hello
\n\n"; - let mut cache = self::cache(original, "a.html"); - assert_token(&cache, original, 0, "div", SyntaxKind::Markup); - assert_token(&cache, original, 1, "function", SyntaxKind::Keyword); - assert_token(&cache, original, 1, "hello", SyntaxKind::Function); - assert_token(&cache, original, 1, "42", SyntaxKind::Number); - assert_token(&cache, original, 2, "7", SyntaxKind::Number); - let injection_parses = cache.injection_parses; - let source = original.replace("
hello", "
world"); - cache.update(&lines(&source), Some(Path::new("a.html")), 1); - assert_eq!(cache.injection_parses, injection_parses); - assert_fresh(&cache, &source, "a.html"); - let source = source.replace("42", "123"); - cache.update(&lines(&source), Some(Path::new("a.html")), 2); - assert_token(&cache, &source, 1, "123", SyntaxKind::Number); - assert_fresh(&cache, &source, "a.html"); - // Inserting at an included range's exact end must reparse, not merely edit its old tree. - let source = source.replace("return 123; }", "return 123; } function added() {}"); - cache.update(&lines(&source), Some(Path::new("a.html")), 3); - assert_token(&cache, &source, 1, "added", SyntaxKind::Function); - assert_fresh(&cache, &source, "a.html"); - } - - #[test] - fn local_definitions_disable_builtin_captures_and_propagate_highlights() { - let source = "function hello(require) { require('local'); }\nrequire('global');"; - let cache = self::cache(source, "a.js"); - let local = source.find("require('local')").unwrap(); - assert_eq!(kind_at(&cache, 0, local), None); - assert_token(&cache, source, 1, "require", SyntaxKind::Function); - let source = "const action = () => 42;\naction;"; - let cache = self::cache(source, "a.js"); - assert_token(&cache, source, 0, "action", SyntaxKind::Function); - assert_token(&cache, source, 1, "action", SyntaxKind::Function); - } - - #[test] - fn overlapping_capture_specificity_priority_and_plain_injection_are_nonoverlapping() { - let source = "αβγ"; - let mut spans = vec![Vec::new()]; - let mut events = Vec::new(); - let highlights = vec![ - Highlight { - start: 0, - end: 6, - kind: Some(SyntaxKind::String), - depth: 0, - priority: 0, - pattern: 99, - }, - Highlight { - start: 2, - end: 4, - kind: Some(SyntaxKind::Function), - depth: 0, - priority: 0, - pattern: 1, - }, - Highlight { - start: 4, - end: 6, - kind: None, - depth: 1, - priority: i32::MIN, - pattern: 0, - }, - Highlight { - start: 0, - end: 2, - kind: Some(SyntaxKind::Keyword), - depth: 0, - priority: 10, - pattern: 0, - }, - ]; - render_spans( - source, - &[0], - &mut spans, - &mut vec![Vec::new(); 1], - &highlights, - &mut events, - &[source_range(source)], - ); - assert_eq!( - spans[0], - vec![ - SyntaxSpan { - start_byte: 0, - end_byte: 2, - kind: SyntaxKind::Keyword - }, - SyntaxSpan { - start_byte: 2, - end_byte: 4, - kind: SyntaxKind::Function - }, - ] - ); - } - - #[test] - fn local_initializer_visibility_positive_predicates_and_noninheriting_scopes() { - let syntax = languages::by_name("javascript").unwrap(); - let locals = r#" - (program) @local.scope - ((function_declaration) @local.scope (#set! local.scope-inherits false)) - (variable_declarator name: (identifier) @local.definition value: (_) @local.definition-value) - (identifier) @local.reference - "#; - let source_query = format!( - "{locals}\n{}", - r#" - ((identifier) @function (#is-not? local)) - ((identifier) @string (#is? local)) - (variable_declarator name: (identifier) @type) - "# - ); - let query = Query::new(&syntax.language, &source_query).unwrap(); - let highlights_start = (0..query.pattern_count()) - .filter(|pattern| query.start_byte_for_pattern(*pattern) < locals.len() + 1) - .count(); - let configuration = Configuration::new(syntax, query, 0, highlights_start); - let source = "const value = value;\nvalue;\nfunction inner() { value; }"; - let cache = self::cache(source, "a.js"); - let mut highlights = Vec::new(); - collect_layer( - &configuration, - cache.tree.as_ref().unwrap(), - source, - &[source_range(source)], - &[source_range(source)], - &mut None, - 0, - None, - &mut QueryCursor::new(), - &mut Vec::new(), - &mut highlights, - ) - .unwrap(); - let mut spans = vec![Vec::new(); 3]; - let starts = [ - 0, - source.find("\nvalue;").unwrap() + 1, - source.find("\nfunction").unwrap() + 1, - ]; - render_spans( - source, - &starts, - &mut spans, - &mut vec![Vec::new(); 3], - &highlights, - &mut Vec::new(), - &[source_range(source)], - ); - let kind = |row: usize, byte: usize| { - spans[row].iter().find_map(|span| { - (span.start_byte <= byte && byte < span.end_byte).then_some(span.kind) - }) - }; - assert_eq!(kind(0, 6), Some(SyntaxKind::Type)); - assert_eq!(kind(0, 14), Some(SyntaxKind::Function)); - assert_eq!(kind(1, 0), Some(SyntaxKind::Type)); - assert_eq!(kind(2, 19), Some(SyntaxKind::Function)); - let mut context = None; - for row in [1, 2] { - let window = [Range { - start_byte: starts[row], - end_byte: starts.get(row + 1).copied().unwrap_or(source.len()), - start_point: Point::new(row, 0), - end_point: point_at(source, starts.get(row + 1).copied().unwrap_or(source.len())), - }]; - let mut visible_highlights = Vec::new(); - collect_layer( - &configuration, - cache.tree.as_ref().unwrap(), - source, - &[source_range(source)], - &window, - &mut context, - 0, - None, - &mut QueryCursor::new(), - &mut Vec::new(), - &mut visible_highlights, - ) - .unwrap(); - let mut visible_spans = vec![Vec::new(); 3]; - render_spans( - source, - &starts, - &mut visible_spans, - &mut vec![Vec::new(); 3], - &visible_highlights, - &mut Vec::new(), - &window, - ); - assert_eq!(visible_spans[row], spans[row]); - assert!( - visible_spans - .iter() - .enumerate() - .all(|(other, spans)| other == row || spans.is_empty()) - ); - } - } - - #[test] - fn grammar_and_unsupported_predicate_errors_are_not_silent_empty_queries() { - static INVALID: LazyLock<[LanguageSpec; 2]> = LazyLock::new(|| { - let language = languages::by_name("rust").unwrap(); - [ - "(not_a_rust_node) @keyword", - "((identifier) @function (#unsupported? @function))", - ] - .map(|query| LanguageSpec { - id: language.id, - name: "invalid test grammar query", - language: language.language.clone(), - highlights: query, - injections: "", - locals: "", - }) - }); - for syntax in INVALID.iter() { - assert!(compile_configuration(syntax).is_err()); - } - } - - #[test] - fn combined_tagged_templates_and_nested_markdown_html_keep_real_injections() { - let source = "const page = html`
${value}
`;"; - let cache = self::cache(source, "a.js"); - assert_token(&cache, source, 0, "div", SyntaxKind::Markup); - assert_token(&cache, source, 0, "inside", SyntaxKind::Function); - assert_token(&cache, source, 0, "42", SyntaxKind::Number); - assert_fresh(&cache, source, "a.js"); - let source = "```html\n\n```"; - let cache = self::cache(source, "a.md"); - assert_token(&cache, source, 1, "nested", SyntaxKind::Function); - assert_token(&cache, source, 1, "7", SyntaxKind::Number); - assert_fresh(&cache, source, "a.md"); - let source = "fn main() { println!(\"{}\", 42); }"; - let cache = self::cache(source, "a.rs"); - assert_token(&cache, source, 0, "42", SyntaxKind::Number); - assert!(cache.injections.len() <= 16); - assert_fresh(&cache, source, "a.rs"); - } - - #[test] - fn parser_state_is_isolated_between_buffers() { - let mut first = self::cache("/* open\ninside", "a.rs"); - let second = self::cache("fn main() { let n = 42; }", "b.rs"); - first.update( - &lines("/* open */\nfn other() {}"), - Some(Path::new("a.rs")), - 1, - ); - assert_token( - &second, - "fn main() { let n = 42; }", - 0, - "fn", - SyntaxKind::Keyword, - ); - assert_eq!(second.parses, 1); - assert_eq!(first.incremental_parses, 1); - } -} +#[path = "../tests/unit/syntax.rs"] +mod tests; diff --git a/src/syntax/languages.rs b/src/syntax/languages.rs index 5b66cdf..9c628dc 100644 --- a/src/syntax/languages.rs +++ b/src/syntax/languages.rs @@ -407,78 +407,5 @@ fn detect_shebang(line: &str) -> Option<&'static LanguageSpec> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn selected_files_and_auxiliary_grammars_are_registered() { - for (filename, language) in [ - ("main.rs", "Rust"), - ("Gemfile", "Ruby"), - ("main.py", "Python"), - ("package.json", "JSON"), - (".bashrc", "Bash"), - ("GNUmakefile", "Makefile"), - ("README.md", "Markdown"), - ("main.jsx", "JavaScript"), - ("main.ts", "TypeScript"), - ("index.html", "HTML"), - ("style.css", "CSS"), - ("config.yml", "YAML"), - ("Cargo.toml", "TOML"), - ("Cargo.lock", "TOML"), - ("main.c", "C"), - ("main.cpp", "C++"), - ("main.C", "C++"), - ("main.go", "Go"), - ("main.zig", "Zig"), - ] { - assert_eq!( - detect(Some(Path::new(filename)), None).map(|spec| spec.name), - Some(language) - ); - } - let ts = detect(Some(Path::new("main.ts")), None).unwrap(); - let tsx = detect(Some(Path::new("main.tsx")), None).unwrap(); - assert_eq!(ts.name, tsx.name); - assert!(!std::ptr::eq(ts, tsx)); - assert_ne!(ts.language, tsx.language); - assert!(std::ptr::eq(tsx, by_name("TSX").unwrap())); - assert!(by_name("markdown_inline").is_some()); - for filename in [ - "notes.txt", - "main.hs", - "main.scala", - "main.lua", - "Dockerfile", - ] { - assert!(detect(Some(Path::new(filename)), None).is_none()); - } - } - - #[test] - fn shebangs_support_bom_env_options_and_filename_precedence() { - for (line, language) in [ - ("#!/usr/bin/python3", "Python"), - ("\u{feff}#!/usr/bin/env -S python3.12 -u", "Python"), - ("#!/usr/bin/env -u PYTHONHOME FOO=bar python3", "Python"), - ("#!/usr/bin/ruby3.3", "Ruby"), - ("#!/bin/bash", "Bash"), - ("#! /usr/bin/env node", "JavaScript"), - ] { - assert_eq!( - detect(None, Some(line)).map(|spec| spec.name), - Some(language) - ); - } - assert_eq!( - detect(Some(Path::new("main.rs")), Some("#!/bin/bash")) - .unwrap() - .name, - "Rust" - ); - assert!(detect(None, Some("#!/usr/bin/python-not-real")).is_none()); - assert!(detect(None, Some("#!/usr/bin/perl")).is_none()); - assert!(detect(None, Some("plain text")).is_none()); - } -} +#[path = "../../tests/unit/syntax_languages.rs"] +mod tests; diff --git a/src/terminal_colors.rs b/src/terminal_colors.rs index 4c80353..6aa3697 100644 --- a/src/terminal_colors.rs +++ b/src/terminal_colors.rs @@ -295,278 +295,5 @@ fn hex_value(component: &[u8]) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind}; - - fn key(code: KeyCode, modifiers: KeyModifiers) -> Event { - Event::Key(KeyEvent::new(code, modifiers)) - } - - fn bytes(collector: &mut TerminalColors, text: &str, now: Instant) { - for ch in text.chars() { - let modifiers = if ch.is_ascii_uppercase() { - KeyModifiers::SHIFT - } else { - KeyModifiers::NONE - }; - collector.push(key(KeyCode::Char(ch), modifiers), now); - } - } - - fn report(collector: &mut TerminalColors, body: &str, now: Instant) { - collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), now); - bytes(collector, body, now); - collector.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL), now); - } - - fn drain(collector: &mut TerminalColors) -> Vec { - std::iter::from_fn(|| collector.pop()).collect() - } - - #[test] - fn fragmented_triple_accepts_bel_and_both_st_encodings() { - let now = Instant::now(); - let mut collector = TerminalColors::default(); - collector.query(&mut Vec::new(), now).unwrap(); - collector.push(key(KeyCode::Esc, KeyModifiers::NONE), now); - collector.push( - key(KeyCode::Char(']'), KeyModifiers::NONE), - now + Duration::from_millis(1), - ); - bytes( - &mut collector, - "11;rgb:0/12/345", - now + Duration::from_millis(2), - ); - collector.push( - key(KeyCode::Esc, KeyModifiers::NONE), - now + Duration::from_millis(3), - ); - collector.push( - key(KeyCode::Char('\\'), KeyModifiers::NONE), - now + Duration::from_millis(4), - ); - assert_eq!(collector.colors, None); - report( - &mut collector, - "4;2;rgb:f/ffff/ABCD", - now + Duration::from_millis(5), - ); - collector.push( - key(KeyCode::Char(']'), KeyModifiers::ALT), - now + Duration::from_millis(6), - ); - bytes( - &mut collector, - "4;1;#FF1234", - now + Duration::from_millis(7), - ); - collector.push( - key(KeyCode::Char('\\'), KeyModifiers::ALT), - now + Duration::from_millis(8), - ); - assert_eq!( - collector.colors, - Some(ThemeColors { - background: [0, 18, 52], - red: [255, 18, 52], - green: [255, 255, 171], - }) - ); - assert!(drain(&mut collector).is_empty()); - } - - #[test] - fn focus_refresh_atomically_replaces_light_and_dark_triples() { - let now = Instant::now(); - let mut collector = TerminalColors::default(); - let palettes = [ - ("11;#FDF6E3", [253, 246, 227]), - ("11;rgb:0000/0000/0000", [0, 0, 0]), - ]; - for (cycle, (background, expected)) in palettes.into_iter().enumerate() { - let time = now + Duration::from_secs(cycle as u64); - let focus = Event::FocusGained; - collector.push(focus.clone(), time); - assert_eq!(collector.pop(), Some(focus)); - let previous = collector.colors; - collector.query(&mut Vec::new(), time).unwrap(); - report(&mut collector, "4;1;rgb:ff/00/00", time); - report(&mut collector, background, time); - assert_eq!(collector.colors, previous); - report(&mut collector, "4;2;rgb:00/ff/00", time); - assert_eq!( - collector.colors, - Some(ThemeColors { - background: expected, - red: [255, 0, 0], - green: [0, 255, 0], - }) - ); - assert!(drain(&mut collector).is_empty()); - } - } - - #[test] - fn periodic_refresh_detects_silent_change_without_overlapping_queries() { - let now = Instant::now(); - let mut collector = TerminalColors::default(); - let mut output = Vec::new(); - assert!(collector.refresh_due(now)); - collector.query(&mut output, now).unwrap(); - report(&mut collector, "11;#101010", now); - report(&mut collector, "4;1;#FF0000", now); - report(&mut collector, "4;2;#00FF00", now); - let previous = collector.colors; - assert!(previous.is_some()); - - // The terminal changes its palette without emitting a notification. - let refresh = now + Duration::from_secs(1); - assert!(!collector.refresh_due(refresh - Duration::from_nanos(1))); - assert!(collector.refresh_due(refresh)); - collector.query(&mut output, refresh).unwrap(); - report(&mut collector, "11;#EEEEEE", refresh); - report(&mut collector, "4;1;#AA1122", refresh); - assert_eq!(collector.colors, previous); - let typed = key(KeyCode::Char('i'), KeyModifiers::NONE); - collector.push(typed.clone(), refresh); - assert_eq!(drain(&mut collector), vec![typed]); - - // Even an overdue probe must wait until the incomplete query expires. - let retry = refresh + Duration::from_secs(1); - assert!(!collector.refresh_due(retry)); - collector.expire(retry); - assert_eq!(collector.colors, None); - assert!(collector.refresh_due(retry)); - collector.query(&mut output, retry).unwrap(); - report(&mut collector, "11;#EEEEEE", retry); - report(&mut collector, "4;1;#AA1122", retry); - assert_eq!(collector.colors, None); - report(&mut collector, "4;2;#22BB33", retry); - assert_eq!( - collector.colors, - Some(ThemeColors { - background: [238, 238, 238], - red: [170, 17, 34], - green: [34, 187, 51], - }) - ); - assert!(drain(&mut collector).is_empty()); - assert!(!collector.refresh_due(retry + Duration::from_millis(999))); - assert!(collector.refresh_due(retry + Duration::from_secs(1))); - } - - #[test] - fn failed_refresh_clears_stale_colors_and_consumes_late_partial_reply() { - let now = Instant::now(); - let mut collector = TerminalColors::default(); - collector.query(&mut Vec::new(), now).unwrap(); - report(&mut collector, "11;#101010", now); - report(&mut collector, "4;1;#FF0000", now); - report(&mut collector, "4;2;#00FF00", now); - assert!(collector.colors.is_some()); - let refresh = now + Duration::from_secs(1); - collector.query(&mut Vec::new(), refresh).unwrap(); - report(&mut collector, "11;#EEEEEE", refresh); - collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), refresh); - bytes(&mut collector, "4;1;rgb:ffff/", refresh); - collector.expire(refresh + QUERY_TIMEOUT); - assert_eq!(collector.colors, None); - let late = refresh + Duration::from_secs(1); - bytes(&mut collector, "0000/0000", late); - collector.push(key(KeyCode::Esc, KeyModifiers::NONE), late); - collector.expire(late + Duration::from_secs(1)); - collector.push( - key(KeyCode::Char('\\'), KeyModifiers::NONE), - late + Duration::from_secs(1), - ); - report(&mut collector, "4;2;#00FF00", late + Duration::from_secs(1)); - assert_eq!(collector.colors, None); - assert!(drain(&mut collector).is_empty()); - let typed = key(KeyCode::Char('i'), KeyModifiers::NONE); - collector.push(typed.clone(), late + Duration::from_secs(2)); - assert_eq!(collector.pop(), Some(typed)); - } - - #[test] - fn unrelated_events_and_rejected_candidates_replay_exactly() { - let now = Instant::now(); - let mut collector = TerminalColors::default(); - collector.query(&mut Vec::new(), now).unwrap(); - let events = vec![ - key(KeyCode::Char(']'), KeyModifiers::ALT), - key(KeyCode::Char('4'), KeyModifiers::NONE), - key(KeyCode::Char(';'), KeyModifiers::NONE), - key(KeyCode::Char('3'), KeyModifiers::NONE), - key(KeyCode::Char('g'), KeyModifiers::CONTROL), - key(KeyCode::Char(']'), KeyModifiers::ALT | KeyModifiers::SHIFT), - Event::Key(KeyEvent::new_with_kind( - KeyCode::Char(']'), - KeyModifiers::ALT, - KeyEventKind::Release, - )), - Event::Key(KeyEvent::new_with_kind( - KeyCode::Char('A'), - KeyModifiers::SHIFT, - KeyEventKind::Repeat, - )), - Event::Key(KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::CAPS_LOCK, - }), - Event::Mouse(MouseEvent { - kind: MouseEventKind::Moved, - column: 2, - row: 3, - modifiers: KeyModifiers::CONTROL, - }), - Event::FocusLost, - Event::Resize(80, 24), - key(KeyCode::Char(']'), KeyModifiers::ALT), - Event::Paste("\x1b]11;rgb:ff/ff/ff\x07".into()), - Event::FocusGained, - ]; - for event in &events { - collector.push(event.clone(), now); - } - assert_eq!(drain(&mut collector), events); - assert_eq!(collector.colors, None); - } - - #[test] - fn malformed_or_oversized_reports_and_lone_escape_do_not_eat_input() { - let now = Instant::now(); - let mut collector = TerminalColors::default(); - for body in [ - "11;rgb:12345/0/0", - "11;rgb:1//3", - "11;#12345", - "11;rgb:1/2/3/4", - ] { - let mut expected = vec![key(KeyCode::Char(']'), KeyModifiers::ALT)]; - expected.extend( - body.chars() - .map(|ch| key(KeyCode::Char(ch), KeyModifiers::NONE)), - ); - expected.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL)); - for event in &expected { - collector.push(event.clone(), now); - } - assert_eq!(drain(&mut collector), expected); - } - let escape = key(KeyCode::Esc, KeyModifiers::NONE); - collector.push(escape.clone(), now); - assert_eq!(collector.pop(), None); - collector.expire(now + ESCAPE_TIMEOUT); - assert_eq!(collector.pop(), Some(escape)); - assert_eq!(collector.pop(), None); - let start = key(KeyCode::Char(']'), KeyModifiers::ALT); - collector.push(start.clone(), now + Duration::from_secs(1)); - collector.expire(now + Duration::from_secs(1) + QUERY_TIMEOUT); - assert_eq!(collector.pop(), Some(start)); - assert_eq!(collector.pop(), None); - } -} +#[path = "../tests/unit/terminal_colors.rs"] +mod tests; diff --git a/src/tui.rs b/src/tui.rs index f7310bb..5300ecd 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -2266,23 +2266,6 @@ fn draw_resolver_choices(frame: &mut Frame, resolver: &ResolverWorkspace, area: frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area); } -#[cfg(test)] -fn draw( - frame: &mut Frame, - editor: &Editor, - top: &mut usize, - left: &mut usize, - syntax: &mut SyntaxCache, - document: &mut DocumentViewport, - panels: &mut WheelPanels, -) { - let area = frame.area(); - let workspace = draw_bars(frame, editor, area, syntax); - draw_document( - frame, editor, workspace, workspace, top, left, syntax, document, panels, - ); -} - #[allow(clippy::too_many_arguments)] fn draw_document( frame: &mut Frame, @@ -6490,15279 +6473,5 @@ fn semantic_style(token: &SemanticToken) -> Style { #[cfg(test)] // These fixtures intentionally contain ranges, not lists of indices. #[allow(clippy::single_range_in_vec_init)] -mod tests { - use super::*; - use crate::plugin::TextPosition; - use std::{ - fs, - path::PathBuf, - sync::atomic::{AtomicUsize, Ordering}, - }; - - struct Directory(PathBuf); - - impl Directory { - fn new() -> Self { - static NEXT_ID: AtomicUsize = AtomicUsize::new(0); - let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); - let path = - std::env::temp_dir().join(format!("vamp-tui-test-{}-{id}", std::process::id())); - fs::create_dir(&path).unwrap(); - Self(path) - } - - fn file(&self, name: &str, text: &str) -> PathBuf { - let path = self.0.join(name); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, text).unwrap(); - path - } - } - - impl Drop for Directory { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - #[allow(clippy::too_many_arguments)] - fn rendered_whitespace_line( - source: &str, - left: usize, - width: u16, - characters: &BTreeMap, - scope: WhitespaceScope, - strings: &[SyntaxSpan], - newline: bool, - sanitize_controls: bool, - ) -> ratatui::buffer::Buffer { - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 1)).unwrap(); - terminal - .draw(|frame| { - let area = frame.area(); - frame.render_widget( - Paragraph::new(visible_text( - source, - left, - usize::from(width), - sanitize_controls, - )) - .style( - Style::default() - .fg(Color::Green) - .bg(Color::Yellow) - .add_modifier(Modifier::UNDERLINED), - ), - area, - ); - paint_whitespace( - frame, - source, - area, - left, - characters, - scope, - strings, - newline, - sanitize_controls, - ); - }) - .unwrap(); - terminal.backend().buffer().clone() - } - - #[test] - fn whitespace_scope_uses_the_source_prefix_and_dims_only_marker_cells() { - let characters = BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{3000}', '_'), ('\n', '↵')]); - for (scope, source, left, width, expected) in [ - (WhitespaceScope::All, "\t x \t ", 0, 10, "→ ·x·→·↵"), - (WhitespaceScope::Leading, "\t x \t ", 0, 10, "→ ·x ↵"), - (WhitespaceScope::Leading, "\t \u{3000}", 0, 8, "→ ·_ ↵"), - (WhitespaceScope::Leading, " x ", 5, 4, "·x ↵"), - (WhitespaceScope::Leading, "x ", 1, 3, " ↵"), - (WhitespaceScope::Leading, "", 0, 1, "↵"), - ] { - let buffer = - rendered_whitespace_line(source, left, width, &characters, scope, &[], true, false); - for (x, symbol) in expected.chars().enumerate() { - let cell = &buffer[(x as u16, 0)]; - assert_eq!( - cell.symbol(), - symbol.to_string(), - "{scope:?}, {source:?}, left={left}" - ); - let marker = ['·', '→', '_', '↵'].contains(&symbol); - assert_eq!(cell.modifier.contains(Modifier::DIM), marker); - assert_eq!(cell.fg, if marker { Color::Reset } else { Color::Green }); - assert_eq!(cell.bg, Color::Yellow); - assert!(cell.modifier.contains(Modifier::UNDERLINED)); - } - } - for (source, left, newline, expected) in [ - ("x", 0, false, "x "), - ("x", 1, true, "↵ "), - ("x", 2, true, " "), - ] { - let buffer = rendered_whitespace_line( - source, - left, - 2, - &characters, - WhitespaceScope::All, - &[], - newline, - false, - ); - assert_eq!( - buffer - .content - .iter() - .map(|cell| cell.symbol()) - .collect::(), - expected, - ); - } - } - - #[test] - fn buffer_newline_markers_use_final_wrap_caret_cells_not_soft_wrap_or_eof() { - let directory = Directory::new(); - for tail in ["last", ""] { - for scope in [WhitespaceScope::All, WhitespaceScope::Leading] { - let source = format!("abcd\n\n x \n{tail}"); - let path = directory.file("newlines.txt", &source); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')])); - editor.set_whitespace_scope(scope); - editor.set_highlight_trailing_whitespace(true); - editor.execute("word_wrap.toggle"); - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(6, 12)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let area = views.document.geometry.unwrap().1; - let area = Rect::new( - area.right() - views.document.text_width as u16, - area.y, - views.document.text_width as u16, - area.height, - ); - assert_eq!(area.width, 4); - let buffer = terminal.backend().buffer(); - for row in [1, 2, 4] { - let cell = &buffer[(area.x, area.y + row)]; - assert_eq!(cell.symbol(), "↵"); - assert!(cell.modifier.contains(Modifier::DIM)); - assert_eq!(cell.fg, Color::Reset); - assert_ne!(cell.bg, Color::Red); - } - assert_eq!( - buffer - .content - .iter() - .filter(|cell| cell.symbol() == "↵") - .count(), - 3, - ); - assert_eq!(buffer[(area.x + 3, area.y)].symbol(), "d"); - assert!( - !buffer[(area.x + 3, area.y)] - .modifier - .contains(Modifier::DIM) - ); - assert_eq!( - buffer[(area.x, area.y + 5)].symbol(), - if tail.is_empty() { " " } else { "l" } - ); - for offset in [0, 1] { - assert_eq!(buffer[(area.x + offset, area.y + 3)].symbol(), "·"); - } - let trailing = &buffer[(area.x + 3, area.y + 3)]; - assert_eq!( - trailing.symbol(), - if scope == WhitespaceScope::All { - "·" - } else { - " " - } - ); - assert_eq!(trailing.bg, Color::Red); - assert_eq!( - trailing.modifier.contains(Modifier::DIM), - scope == WhitespaceScope::All - ); - editor.handle(Input::Char('V')); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let newline = &terminal.backend().buffer()[(area.x, area.y + 1)]; - assert_eq!(newline.symbol(), "↵"); - assert_eq!(newline.bg, Color::Blue); - assert!(newline.modifier.contains(Modifier::DIM)); - assert_eq!(editor.lines().join("\n"), source); - assert!(!editor.dirty()); - assert_eq!(fs::read_to_string(path).unwrap(), source); - } - } - } - - #[test] - fn historical_buffer_markers_retain_real_final_newlines_and_scope() { - let mut file = syntax_diff_file("history.txt", "old\n tail ", "new\n tail "); - file.before_eof = true; - file.after_eof = true; - file.hunks = crate::diff::line_hunks(&file.before, true, &file.after, true); - let mut editor = readonly_diff_editor(file); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')])); - editor.set_whitespace_scope(WhitespaceScope::Leading); - editor.set_highlight_trailing_whitespace(true); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); - let mut views = BufferViewports::default(); - render_native_diff(&mut terminal, &mut views, &mut editor); - let path = editor - .interaction_documents() - .find(|document| document.id == editor.buffer_id()) - .unwrap() - .path - .unwrap() - .to_owned(); - editor - .navigate_lsp_to(path, definition_point(1, 0), None) - .unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(editor.diff().is_none()); - assert!(editor.mutation_blocked()); - let area = views.document.geometry.unwrap().1; - let area = Rect::new( - area.right() - views.document.text_width as u16, - area.y, - views.document.text_width as u16, - area.height, - ); - let buffer = terminal.backend().buffer(); - for (x, y, symbol) in [(3, 0, "↵"), (0, 1, "·"), (1, 1, "·"), (7, 1, "↵")] { - let cell = &buffer[(area.x + x, area.y + y)]; - assert_eq!(cell.symbol(), symbol); - assert!(cell.modifier.contains(Modifier::DIM)); - } - assert_eq!(buffer[(area.x + 6, area.y + 1)].symbol(), " "); - assert_eq!(buffer[(area.x + 6, area.y + 1)].bg, Color::Red); - assert!( - !buffer[(area.x + 6, area.y + 1)] - .modifier - .contains(Modifier::DIM) - ); - assert_eq!(buffer[(area.x, area.y + 2)].symbol(), " "); - assert_eq!(editor.lines().join("\n"), "old\n tail \n"); - assert!(!editor.dirty()); - } - - #[test] - fn diff_newline_markers_cover_both_sides_without_hatch_or_soft_wrap_markers() { - for mode in [ViewMode::Split, ViewMode::Unified] { - for wrapped in [false, true] { - for (before_eof, after_eof) in [(false, true), (true, false)] { - for scope in [WhitespaceScope::All, WhitespaceScope::Leading] { - let length = if wrapped { 220 } else { 60 }; - let before = format!(" old{} \t", "x".repeat(length)); - let after = format!("\0 new{} \t\n \nlast", "y".repeat(length)); - let mut file = syntax_diff_file("newlines.txt", &before, &after); - file.before_eof = before_eof; - file.after_eof = after_eof; - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_diff_display_options(mode, SplitAxis::Vertical); - install_readonly_diff(&mut editor, file); - editor.set_whitespace_characters(BTreeMap::from([ - (' ', '␣'), - ('\t', '→'), - ('\n', '↵'), - ])); - editor.set_whitespace_scope(scope); - editor.set_highlight_trailing_whitespace(true); - if wrapped { - editor.execute("word_wrap.toggle"); - } - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(240, 40)).unwrap(); - let mut views = BufferViewports::default(); - render_native_diff(&mut terminal, &mut views, &mut editor); - let buffer = terminal.backend().buffer(); - let geometry = views.panels.diff.as_ref().unwrap(); - let mut text_cells = Vec::new(); - for (region, &width) in - geometry.regions[1..].iter().zip(&geometry.text_widths) - { - let inner = diff_inner(region.0); - for y in inner.y..inner.bottom() { - for x in inner.right() - width..inner.right() { - text_cells.push(&buffer[(x, y)]); - } - } - } - // The unwrapped pane must expose each line end too. - if !wrapped { - assert!( - views - .panels - .diff - .as_ref() - .unwrap() - .text_widths - .iter() - .filter(|&&width| width > 0) - .all(|&width| width >= 72) - ); - } - assert_eq!( - text_cells - .iter() - .filter(|cell| cell.symbol() == "↵") - .count(), - 2 + usize::from(before_eof) + usize::from(after_eof), - "{mode:?}, wrap={wrapped}, before={before_eof}, after={after_eof}", - ); - for cell in text_cells - .iter() - .filter(|cell| ["␣", "→", "↵"].contains(&cell.symbol())) - { - assert!(cell.modifier.contains(Modifier::DIM)); - assert_eq!(cell.fg, Color::Reset); - } - for cell in text_cells.iter().filter(|cell| cell.symbol() == "↵") { - assert_ne!(cell.bg, Color::Red); - } - assert!(text_cells.iter().any(|cell| cell.symbol() == "�")); - assert_eq!( - text_cells - .iter() - .filter(|cell| cell.symbol() == "→") - .count(), - if scope == WhitespaceScope::All { 2 } else { 0 }, - ); - assert_eq!( - text_cells - .iter() - .filter(|cell| cell.symbol() == "␣") - .count(), - if scope == WhitespaceScope::All { 8 } else { 4 }, - ); - if mode == ViewMode::Split { - assert!(buffer.content.iter().any(|cell| cell.symbol() == "╱")); - } - } - } - } - } - } - - #[test] - fn exact_width_diff_newlines_use_the_existing_extra_caret_segment() { - for mode in [ViewMode::Split, ViewMode::Unified] { - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap(); - let mut reference = Editor::open(None, vec![]).unwrap(); - reference.set_diff_display_options(mode, SplitAxis::Vertical); - install_readonly_diff(&mut reference, syntax_diff_file("exact.txt", "old", "new")); - let mut views = BufferViewports::default(); - render_native_diff(&mut terminal, &mut views, &mut reference); - let mut widths = views.panels.diff.as_ref().unwrap().text_widths; - if mode == ViewMode::Unified { - let width = widths[0].max(widths[1]); - widths.fill(width); - } - let mut file = syntax_diff_file( - "exact.txt", - &"o".repeat(usize::from(widths[0])), - &"n".repeat(usize::from(widths[1])), - ); - file.before_eof = true; - file.after_eof = true; - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_diff_display_options(mode, SplitAxis::Vertical); - install_readonly_diff(&mut editor, file); - editor.set_whitespace_characters(BTreeMap::from([('\n', '↵')])); - editor.execute("word_wrap.toggle"); - let mut views = BufferViewports::default(); - render_native_diff(&mut terminal, &mut views, &mut editor); - let buffer = terminal.backend().buffer(); - let newlines: Vec<_> = buffer - .content - .iter() - .enumerate() - .filter_map(|(index, cell)| (cell.symbol() == "↵").then_some(index)) - .collect(); - assert_eq!(newlines.len(), 2, "{mode:?}: {}", screen_text(&terminal)); - for index in newlines { - let x = (index % 80) as u16; - let y = (index / 80) as u16; - let symbol = buffer[(x, y - 1)].symbol(); - assert!(["o", "n"].contains(&symbol)); - let width = widths[usize::from(symbol == "n")]; - for offset in 0..width { - assert_eq!(buffer[(x + offset, y - 1)].symbol(), symbol); - } - assert!(buffer.content[index].modifier.contains(Modifier::DIM)); - } - } - } - - #[test] - fn literal_newlines_stay_unmarked_in_buffers_and_each_diff_source() { - let directory = Directory::new(); - let closed = "text = \"\"\"a b\nc d\"\"\""; - let second = "text = \"\"\"e f\ng h\"\"\""; - for source in [closed, second] { - let mut editor = Editor::open( - Some(directory.file("literal.py", &format!("{source}\n"))), - vec![], - ) - .unwrap(); - editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')])); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!( - terminal - .backend() - .buffer() - .content - .iter() - .filter(|cell| cell.symbol() == "↵") - .count(), - 1, - ); - assert!(screen_text(&terminal).contains(if source == closed { - "c d\"\"\"↵" - } else { - "g h\"\"\"↵" - })); - } - for mode in [ViewMode::Split, ViewMode::Unified] { - let mut file = syntax_diff_file("literal.py", closed, second); - file.before_eof = true; - file.after_eof = true; - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_diff_display_options(mode, SplitAxis::Vertical); - install_readonly_diff(&mut editor, file); - editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')])); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap(); - let mut views = BufferViewports::default(); - render_native_diff(&mut terminal, &mut views, &mut editor); - assert_eq!( - terminal - .backend() - .buffer() - .content - .iter() - .filter(|cell| cell.symbol() == "↵") - .count(), - 2, - "{mode:?}: {}", - screen_text(&terminal), - ); - for expected in ["a b", "c d\"\"\"↵", "e f", "g h\"\"\"↵"] { - assert!(screen_text(&terminal).contains(expected)); - } - } - } - - #[test] - fn whitespace_markers_preserve_multiline_raw_and_injected_strings() { - let directory = Directory::new(); - for (path, source, expected) in [ - ( - "raw.rs", - "let text = r#\"a b\"#; ", - &["let·text·=·r#\"a b\"#;·"][..], - ), - ( - "unicode.rs", - "let text = \"α β\"; ", - &["let·text·=·\"α β\";·"][..], - ), - ( - "multiline.py", - "text = \"\"\"a b\nc d\"\"\" ", - &["text·=·\"\"\"a b", "c d\"\"\"·"][..], - ), - ( - "template.js", - "const text = `a b ${ x + y }`; ", - &["const·text·=·`a b ${ x + y }`;·"][..], - ), - ( - "injected.cpp", - "auto text = R\"cpp(int x = 1;)cpp\"; ", - &["auto·text·=·R\"cpp(int x = 1;)cpp\";·"][..], - ), - ( - "heredoc.rb", - "text = <<~TEXT\n a b\nTEXT\n", - &["text·=·<<~TEXT", " a b"][..], - ), - ] { - let mut editor = Editor::open(Some(directory.file(path, source)), vec![]).unwrap(); - editor.set_whitespace_characters(BTreeMap::from([(' ', '·')])); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let rendered: String = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect(); - for expected in expected { - assert!(rendered.contains(expected), "{path}: {rendered}"); - } - } - let characters = - BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{a0}', '⍽'), ('\u{3000}', '_')]); - for (source, left, width, cells) in [ - ( - "let text = \"α β\"; ", - 12, - 3, - vec![(0, "α"), (1, " "), (2, "β")], - ), - ( - "let s = \"\t \u{3000}\u{a0} \"; ", - 0, - 20, - vec![ - (3, "·"), - (5, "·"), - (7, "·"), - (9, " "), - (10, " "), - (11, " "), - (12, " "), - (13, "\u{3000}"), - (15, "\u{a0}"), - (16, " "), - (19, "·"), - ], - ), - ( - "let s = \"\t \u{3000}\u{a0} \"; ", - 10, - 7, - vec![ - (0, " "), - (1, " "), - (2, " "), - (3, "\u{3000}"), - (5, "\u{a0}"), - (6, " "), - ], - ), - ] { - let mut syntax = SyntaxCache::new(); - syntax.update_text(source, std::path::Path::new("strings.rs")); - let buffer = rendered_whitespace_line( - source, - left, - width, - &characters, - WhitespaceScope::All, - syntax.string_spans(0), - false, - false, - ); - for (x, symbol) in cells { - assert_eq!( - buffer[(x, 0)].symbol(), - symbol, - "{source:?}, left={left}, x={x}" - ); - assert_eq!( - buffer[(x, 0)].modifier.contains(Modifier::DIM), - symbol == "·" - ); - } - } - } - - #[test] - fn whitespace_markers_leave_string_contents_literal_in_buffers_and_diffs() { - let directory = Directory::new(); - let source = "let text = \"a b\"; "; - let mut editor = Editor::open(Some(directory.file("strings.rs", source)), vec![]).unwrap(); - editor.set_whitespace_characters(BTreeMap::from([(' ', '·')])); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let rendered: String = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect(); - assert!(rendered.contains("let·text·=·\"a b\";·"), "{rendered}"); - let area = views.document.geometry.unwrap().1; - let area = Rect::new( - area.right() - views.document.text_width as u16, - area.y, - views.document.text_width as u16, - area.height, - ); - for column in 11..16 { - let cell = &terminal.backend().buffer()[(area.x + column, area.y)]; - assert_eq!(cell.fg, Color::Green); - assert!(!cell.modifier.contains(Modifier::DIM)); - } - let marker = &terminal.backend().buffer()[(area.x + 3, area.y)]; - assert_eq!(marker.fg, Color::Reset); - assert!(marker.modifier.contains(Modifier::DIM)); - editor - .apply(vec![crate::editor::Effect::Replace( - "let text = a + b; ".into(), - )]) - .unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let rendered: String = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect(); - assert!(rendered.contains("let·text·=·a·+·b;·"), "{rendered}"); - - for mode in [ViewMode::Split, ViewMode::Unified] { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_diff_display_options(mode, SplitAxis::Vertical); - install_readonly_diff( - &mut editor, - syntax_diff_file("strings.rs", source, "\0let text = \"c d\"; "), - ); - editor.set_whitespace_characters(BTreeMap::from([(' ', '·')])); - let mut views = BufferViewports::default(); - render_native_diff(&mut terminal, &mut views, &mut editor); - let rendered: String = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect(); - for expected in ["let·text·=·\"a b\";·", "�let·text·=·\"c d\";·"] { - assert!(rendered.contains(expected), "{mode:?}: {rendered}"); - } - for (index, cell) in terminal.backend().buffer().content.iter().enumerate() { - if cell.symbol() == "\"" { - let literal_space = &terminal.backend().buffer().content[index + 2]; - // Opening quotes are followed by a letter and its literal space. - if literal_space.symbol() == " " { - assert_eq!(literal_space.fg, Color::Green); - assert!(!literal_space.modifier.contains(Modifier::DIM)); - } - } - } - } - } - - #[test] - fn whitespace_characters_preserve_source_geometry_and_overlay_styles() { - let characters = - BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{a0}', '⍽'), ('\u{3000}', '_')]); - let source = "a \tb\u{a0}\u{3000} c"; - for (source, left, width, expected) in [ - ( - source, - 0, - 10, - vec!["a", "·", "→", " ", "b", "⍽", "_", " ", "·", "c"], - ), - (source, 3, 6, vec![" ", "b", "⍽", "_", " ", "·"]), - (source, 7, 2, vec![" ", "·"]), - (source, 6, 1, vec!["_"]), - (" \u{301}", 0, 1, vec![" \u{301}"]), - ("", 0, 1, vec![" "]), - ] { - let buffer = rendered_whitespace_line( - source, - left, - width, - &characters, - WhitespaceScope::All, - &[], - false, - false, - ); - for (x, symbol) in expected.into_iter().enumerate() { - let cell = &buffer[(x as u16, 0)]; - assert_eq!(cell.symbol(), symbol, "{source:?}, left={left}, x={x}"); - let marker = ["·", "→", "⍽", "_"].contains(&symbol); - assert_eq!(cell.modifier.contains(Modifier::DIM), marker); - assert!(cell.modifier.contains(Modifier::UNDERLINED)); - assert_eq!(cell.fg, if marker { Color::Reset } else { Color::Green }); - assert_eq!(cell.bg, Color::Yellow); - } - } - - let directory = Directory::new(); - let source = "a b\t \u{3000}\n \t\n\nabcdefg "; - let path = directory.file("markers.txt", source); - let config = directory.file( - "config.rb", - r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\u3000" => "_" }; c.highlight_trailing_whitespace = true; c.git_gutter = "none" }"#, - ); - let mut session = EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap(); - session.execute("word_wrap.toggle"); - let token = semantic_token( - 0, - 0, - 6, - SemanticTokenKind::Type, - SemanticTokenModifier::Deprecated as u16, - ); - let editor = session.editor_mut(); - editor - .replace_semantic_tokens( - editor.buffer_id(), - editor.text_revision(), - Some(Arc::from([token])), - ) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 10)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, session.editor_mut())) - .unwrap(); - let buffer = terminal.backend().buffer(); - for (x, y, marker) in [ - (3, 0, "·"), - (5, 0, "→"), - (6, 0, "·"), - (7, 0, "_"), - (2, 1, "·"), - (3, 1, "→"), - (9, 3, "·"), - (2, 4, "·"), - ] { - assert_eq!(buffer[(x, y)].symbol(), marker, "({x}, {y})"); - assert!(buffer[(x, y)].modifier.contains(Modifier::DIM)); - if y == 0 { - assert_eq!(buffer[(x, y)].fg, Color::Reset); - assert!(buffer[(x, y)].modifier.contains(Modifier::CROSSED_OUT)); - } - } - assert_eq!(buffer[(8, 0)].symbol(), " "); - assert!(!buffer[(8, 0)].modifier.contains(Modifier::DIM)); - assert!(!buffer[(4, 0)].modifier.contains(Modifier::DIM)); - assert_eq!(buffer[(4, 0)].fg, semantic_style(&token).fg.unwrap()); - assert!(buffer[(4, 0)].modifier.contains(Modifier::CROSSED_OUT)); - assert!(!buffer[(2, 2)].modifier.contains(Modifier::DIM)); - assert_eq!(buffer[(7, 0)].bg, Color::Red); - assert_eq!(buffer[(2, 2)].symbol(), " "); - session.handle(Input::End); - session.handle(Input::Char('v')); - session.handle(Input::Left); - terminal - .draw(|frame| views.draw(frame, session.editor_mut())) - .unwrap(); - assert_eq!(terminal.backend().buffer()[(6, 0)].symbol(), "·"); - assert_eq!(terminal.backend().buffer()[(6, 0)].bg, Color::Blue); - assert!( - terminal.backend().buffer()[(6, 0)] - .modifier - .contains(Modifier::DIM) - ); - assert_eq!(session.editor().lines().join("\n"), source); - assert!(!session.editor().dirty()); - assert_eq!(fs::read_to_string(path).unwrap(), source); - - session - .editor_mut() - .set_whitespace_characters(BTreeMap::new()); - terminal - .draw(|frame| views.draw(frame, session.editor_mut())) - .unwrap(); - assert_eq!(terminal.backend().buffer()[(6, 0)].symbol(), " "); - assert_eq!(terminal.backend().buffer()[(7, 0)].symbol(), "\u{3000}"); - } - - #[test] - fn trailing_whitespace_is_opt_in_and_preserves_wrapping_and_selection() { - let directory = Directory::new(); - let source = "a b\t \u{3000}\n \t\n\nabcdefg "; - let path = directory.file("whitespace.txt", source); - let config = directory.file( - "config.rb", - "Vamp.configure { |c| c.highlight_trailing_whitespace = true; c.git_gutter = 'none' }", - ); - let mut session = EditorSession::open(Some(path), vec![], Some(&config)).unwrap(); - session.execute("word_wrap.toggle"); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 10)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, session.editor_mut())) - .unwrap(); - let buffer = terminal.backend().buffer(); - for (y, range) in [(0, 5..9), (1, 2..6), (3, 9..10), (4, 2..3)] { - for x in 2..10 { - // The backend skips the covered cell of a wide grapheme. - if (x, y) == (8, 0) { - continue; - } - assert_eq!( - buffer[(x, y)].bg == Color::Red, - range.contains(&x), - "({x}, {y})" - ); - } - } - assert_eq!(buffer[(2, 2)].bg, Color::Reset); - session.handle(Input::End); - session.handle(Input::Char('v')); - session.handle(Input::Left); - terminal - .draw(|frame| views.draw(frame, session.editor_mut())) - .unwrap(); - assert_eq!(terminal.backend().buffer()[(6, 0)].bg, Color::Blue); - assert_eq!(session.editor().lines().join("\n"), source); - assert!(!session.editor().dirty()); - - let mut editor = Editor::open(None, vec![]).unwrap(); - editor - .apply(vec![crate::editor::Effect::Replace("x \t".into())]) - .unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_ne!(terminal.backend().buffer()[(7, 0)].bg, Color::Red); - } - - #[test] - fn trailing_whitespace_covers_both_diff_sides_and_unified_rows() { - for mode in [ViewMode::Split, ViewMode::Unified] { - for wrapped in [false, true] { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_diff_display_options(mode, crate::panes::SplitAxis::Vertical); - install_readonly_diff( - &mut editor, - syntax_diff_file("whitespace.txt", "old \t", "new \t"), - ); - editor.set_highlight_trailing_whitespace(true); - editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\t', '→')])); - if wrapped { - editor.execute("word_wrap.toggle"); - } - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); - let mut views = BufferViewports::default(); - render_native_diff(&mut terminal, &mut views, &mut editor); - let buffer = terminal.backend().buffer(); - for word in ["old", "new"] { - let start = buffer - .content - .iter() - .enumerate() - .find_map(|(index, cell)| { - (cell.symbol() == &word[..1] - && buffer - .content - .get(index + 1) - .is_some_and(|cell| cell.symbol() == &word[1..2]) - && buffer - .content - .get(index + 2) - .is_some_and(|cell| cell.symbol() == &word[2..3])) - .then_some(index) - }) - .unwrap(); - for offset in 3..8 { - assert_eq!( - buffer.content[start + offset].bg, - Color::Red, - "{mode:?} {wrapped} {word}" - ); - } - assert_ne!(buffer.content[start + 8].bg, Color::Red); - assert_eq!(buffer.content[start + 3].symbol(), "·"); - assert_eq!(buffer.content[start + 4].symbol(), "→"); - } - } - } - } - - #[test] - fn scroll_distance_follows_document_and_diff_visual_rows_and_columns() { - let directory = Directory::new(); - let text = (0..80) - .map(|row| format!("row-{row:02} {}", "x".repeat(160))) - .collect::>() - .join("\n"); - for diff in [false, true] { - for wrapped in [false, true] { - for distance in [Some(0), Some(2), None, Some(usize::MAX)] { - let mut editor = if diff { - let mut editor = readonly_diff_editor(syntax_diff_file( - "history.txt", - &text, - &text.replace('x', "y"), - )); - editor.handle(Input::Char('\t')); - editor - } else { - Editor::open(Some(directory.file("document.txt", &text)), vec![]).unwrap() - }; - editor.set_scroll_distance(distance); - if wrapped { - editor.execute("word_wrap.toggle"); - } - let mut views = BufferViewports::default(); - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(80, 16)).unwrap(); - let render = |terminal: &mut Terminal, - views: &mut BufferViewports, - editor: &mut Editor| { - if diff { - render_native_diff(terminal, views, editor) - } else { - terminal.draw(|frame| views.draw(frame, editor)).unwrap(); - terminal.get_cursor_position().unwrap() - } - }; - render(&mut terminal, &mut views, &mut editor); - for _ in 0..30 { - editor.handle(Input::Down); - render(&mut terminal, &mut views, &mut editor); - } - let viewport = if diff { - diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0) - } else { - views.document.geometry.unwrap().1 - }; - let height = usize::from(viewport.height); - let expected = if distance.is_none_or(|distance| distance >= height / 2) { - height / 2 - } else { - height - distance.unwrap() - 1 - }; - let caret = terminal.get_cursor_position().unwrap(); - assert_eq!( - usize::from(caret.y - viewport.y), - expected, - "diff={diff}, wrap={wrapped}, distance={distance:?}" - ); - if !wrapped && distance == Some(2) { - editor.handle(Input::Up); - let reversed = render(&mut terminal, &mut views, &mut editor); - assert_eq!(reversed.y + 1, caret.y); - } - if !wrapped { - for _ in 0..120 { - editor.handle(Input::Right); - render(&mut terminal, &mut views, &mut editor); - } - let left = if diff { - editor.diff().unwrap().left_before - } else { - views.offsets[&editor.buffer_id()].1 - }; - let width = if diff { - usize::from(views.panels.diff.as_ref().unwrap().text_widths[0]) - } else { - views.document.text_width - }; - let expected = if distance.is_none_or(|distance| distance >= width / 2) { - width / 2 - } else { - width - distance.unwrap() - 1 - }; - assert_eq!(editor.cursor().column - left, expected); - } - if distance == Some(usize::MAX) { - terminal.backend_mut().resize(60, 12); - terminal.resize(Rect::new(0, 0, 60, 12)).unwrap(); - let caret = render(&mut terminal, &mut views, &mut editor); - let viewport = if diff { - diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0) - } else { - views.document.geometry.unwrap().1 - }; - assert_eq!(caret.y - viewport.y, viewport.height / 2); - } - } - } - } - } - - fn screen_text(terminal: &Terminal) -> String { - let buffer = terminal.backend().buffer(); - let area = buffer.area; - (area.y..area.bottom()) - .map(|y| { - (area.x..area.right()) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - }) - .collect::>() - .join("\n") - } - - #[test] - fn resolver_renders_aligned_native_draft_alternative_and_separate_history() { - let directory = Directory::new(); - let jj = |args: &[&str]| { - let output = std::process::Command::new("jj") - .current_dir(&directory.0) - .env("JJ_CONFIG", "/dev/null") - .args([ - "--config=user.name=Resolver", - "--config=user.email=resolver@example.test", - "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", - "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", - ]) - .args(args) - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).unwrap().trim().to_owned() - }; - let text = |side: &str| { - format!( - "value = {side};\n{}{}last = {side};\n", - if side == "third" { - "third-only third-only third-only third-only third-only third-only third-only\n" - } else { - "" - }, - (0..60) - .map(|row| format!("context {row:02}\n")) - .collect::() - ) - }; - jj(&["git", "init", "--no-colocate", "."]); - jj(&["config", "set", "--repo", "user.name", "Resolver"]); - jj(&[ - "config", - "set", - "--repo", - "user.email", - "resolver@example.test", - ]); - for path in ["first.txt", "second.txt"] { - directory.file(path, &text("base")); - } - fs::write(directory.0.join("binary.bin"), b"base\0").unwrap(); - jj(&["describe", "-m", "base"]); - let base = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); - jj(&["new", &base]); - for path in ["first.txt", "second.txt"] { - directory.file(path, &text("left")); - } - fs::write(directory.0.join("binary.bin"), b"left\0").unwrap(); - jj(&["describe", "-m", "left"]); - let left = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); - jj(&["new", &base]); - for path in ["first.txt", "second.txt"] { - directory.file(path, &text("right")); - } - fs::write(directory.0.join("binary.bin"), b"right\0").unwrap(); - jj(&["describe", "-m", "right"]); - let right = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); - jj(&["new", &base]); - for path in ["first.txt", "second.txt"] { - directory.file(path, &text("third")); - } - fs::write(directory.0.join("binary.bin"), b"third\0").unwrap(); - jj(&["describe", "-m", "third"]); - let third = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); - jj(&["new", &left, &right, &third, "-m", "conflicts"]); - let operation = || { - jj(&[ - "--ignore-working-copy", - "op", - "log", - "--no-graph", - "-n", - "1", - "-T", - "id", - ]) - }; - let before_browsing = operation(); - let config = directory.file( - "config.rb", - "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n", - ); - let mut session = EditorSession::open( - Some(directory.0.join("first.txt")), - vec!["text".into()], - Some(&config), - ) - .unwrap(); - let ready = |session: &mut EditorSession| { - for _ in 0..15_000 { - session.poll(); - if session.editor().resolver().is_some_and(|resolver| { - !resolver.loading - && !resolver - .files - .iter() - .any(|file| file.status == ResolverStatus::Checking) - }) { - assert!( - session.editor().resolver().unwrap().error.is_none(), - "{}", - session.editor().message() - ); - return; - } - std::thread::sleep(Duration::from_millis(1)); - } - panic!("Resolver did not settle: {}", session.editor().message()); - }; - session.handle(Input::Char('/')); - for ch in "context 00".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Enter); - session.handle(Input::Home); - session.handle(Input::Char('i')); - session.handle(Input::Paste("opening ".into())); - session.handle(Input::Escape); - session.execute("resolve"); - ready(&mut session); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); - let mut views = BufferViewports::default(); - views.set_theme(Some(DARK_DIFF_THEME)); - let render = |terminal: &mut Terminal, - views: &mut BufferViewports, - session: &mut EditorSession| { - terminal - .draw(|frame| views.draw_session(frame, session)) - .unwrap(); - terminal.get_cursor_position().unwrap() - }; - let workspace = |terminal: &Terminal, - views: &BufferViewports, - editor: &Editor| { - let syntax = &views.syntax[&editor.buffer_id()]; - let visible = [editor.top_bar_config(), editor.bottom_bar_config()] - .map(|line| chrome_visible(editor, &chrome_groups(editor, line, syntax))); - document_layout(terminal.backend().buffer().area, visible)[1] - }; - let caret = render(&mut terminal, &mut views, &mut session); - let resolver = session.editor().resolver().unwrap(); - assert_eq!(resolver.files.len(), 3); - let current_file = resolver.current_file; - let draft_pane = resolver.draft_pane; - let alternative_pane = resolver.alternative_pane.unwrap(); - let alternative_index = resolver.selected_source; - let original_draft = session.editor().lines().to_vec(); - let geometry = views.panels.diff.as_ref().unwrap(); - let draft = geometry.regions[1].0; - let alternative = geometry.regions[2].0; - assert!(draft.contains(caret)); - assert_eq!(views.rects.len(), 2); - assert_eq!((draft.y, draft.height), (alternative.y, alternative.height)); - assert_eq!(draft.right(), alternative.x); - assert!(draft.height > 20); - assert!(views.panels.files.is_none()); - let screen = screen_text(&terminal); - assert!(screen.contains("unchanged lines hidden")); - assert!(!screen.contains("context 30")); - assert!(!screen.contains("<<<<<<<") && !screen.contains("%%%%%%%")); - let line_at = |terminal: &Terminal, area: Rect, y: u16| { - (area.x..area.right()) - .map(|x| terminal.backend().buffer()[(x, y)].symbol()) - .collect::() - }; - let left_heading = line_at(&terminal, draft, draft.y); - let right_heading = line_at(&terminal, alternative, alternative.y); - assert!(left_heading.contains("Resolution") && left_heading.contains("editable")); - assert!(right_heading.contains("Other version") && right_heading.contains("read-only")); - for heading in [&left_heading, &right_heading] { - assert!(!heading.contains("Branch changes")); - assert!( - !heading.contains(resolver.files[current_file].path.to_string_lossy().as_ref()) - ); - assert!(!heading.split_whitespace().any(|word| word == "M")); - } - assert!( - line_at(&terminal, alternative, alternative.bottom() - 1) - .contains(&resolver.files[current_file].sources[alternative_index].label) - ); - assert!( - line_at(&terminal, draft, draft.bottom() - 1) - .chars() - .all(|ch| !ch.is_alphanumeric()), - "The output must not repeat its seed source metadata" - ); - for (side, area) in [draft, alternative].into_iter().enumerate() { - let content = diff_inner(area); - let x = content.right() - geometry.text_widths[side]; - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(x, content.y)].symbol(), "v"); - assert_eq!( - buffer[(x, content.y)].bg, - views.panels.diff_colors.row[side] - ); - assert_eq!( - buffer[(x + 8, content.y)].bg, - views.panels.diff_colors.character[side] - ); - } - assert_ne!( - line_at(&terminal, diff_inner(draft), draft.y + 1), - line_at(&terminal, diff_inner(alternative), alternative.y + 1) - ); - for area in [draft, alternative] { - assert!( - line_at(&terminal, diff_inner(area), area.y + 2).contains("opening context 00"), - "Preexisting unsaved working-buffer edits must appear in both projections" - ); - } - let diff = session.editor().diff().unwrap(); - let file = &diff.comparison.as_ref().unwrap().files[diff.file]; - let mut reference_file = syntax_diff_file( - &file.path.to_string_lossy(), - &file.before.join("\n"), - &file.after.join("\n"), - ); - reference_file.before_eof = file.before_eof; - reference_file.after_eof = file.after_eof; - reference_file.hunks = crate::diff::line_hunks( - &reference_file.before, - reference_file.before_eof, - &reference_file.after, - reference_file.after_eof, - ); - let mut reference = Editor::open(None, vec![]).unwrap(); - for _ in 1..session.editor().tab_count() { - reference.execute("tab.new"); - } - reference - .set_chrome_config( - session.editor().top_bar_config().clone(), - session.editor().bottom_bar_config().clone(), - BTreeMap::new(), - ) - .unwrap(); - install_readonly_diff(&mut reference, reference_file); - reference.handle(Input::Ctrl('w')); - let cursor = session.editor().cursor(); - for ch in format!("{}g", cursor.row + 1).chars() { - reference.handle(Input::Char(ch)); - } - reference.handle(Input::Home); - for _ in 0..cursor.column { - reference.handle(Input::Right); - } - assert_eq!(reference.cursor(), cursor); - assert_eq!( - reference.diff_buffer_focus(), - session.editor().diff_buffer_focus() - ); - let mut reference_terminal = - Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); - let mut reference_views = BufferViewports::default(); - reference_views.set_theme(Some(DARK_DIFF_THEME)); - for command in [ - None, - Some("diff.orientation.toggle"), - Some("diff.view.toggle"), - Some("diff.view.toggle"), - Some("diff.orientation.toggle"), - ] { - if let Some(command) = command { - session.execute(command); - reference.execute(command); - } - render(&mut terminal, &mut views, &mut session); - render_native_diff( - &mut reference_terminal, - &mut reference_views, - &mut reference, - ); - let actual = views.panels.diff.as_ref().unwrap(); - let expected = reference_views.panels.diff.as_ref().unwrap(); - assert_eq!(actual.view_mode, expected.view_mode); - assert_eq!(actual.split_axis, expected.split_axis); - assert_eq!(actual.text_widths, expected.text_widths); - for (side, ((actual_pane, _), (expected_pane, _))) in actual.regions[1..] - .iter() - .zip(&expected.regions[1..]) - .enumerate() - { - assert_eq!( - ( - actual_pane.x, - actual_pane.y, - actual_pane.width, - actual_pane.height - ), - ( - expected_pane.x, - expected_pane.y, - expected_pane.width, - expected_pane.height - ) - ); - let actual_text = Rect { - x: diff_inner(*actual_pane).right() - actual.text_widths[side], - width: actual.text_widths[side], - ..diff_inner(*actual_pane) - }; - let expected_text = Rect { - x: diff_inner(*expected_pane).right() - expected.text_widths[side], - width: expected.text_widths[side], - ..diff_inner(*expected_pane) - }; - for row in 0..actual_text.height { - assert_eq!( - line_at(&terminal, actual_text, actual_text.y + row), - line_at(&reference_terminal, expected_text, expected_text.y + row,), - "{command:?}, row {row}, full rows: {:?} / {:?}", - line_at(&terminal, diff_inner(*actual_pane), actual_text.y + row), - line_at( - &reference_terminal, - diff_inner(*expected_pane), - expected_text.y + row, - ), - ); - } - } - } - let folded_context_rows = screen_text(&terminal).matches("context").count(); - session.handle(Input::Char(']')); - reference.handle(Input::Char(']')); - render(&mut terminal, &mut views, &mut session); - render_native_diff( - &mut reference_terminal, - &mut reference_views, - &mut reference, - ); - let folds = |editor: &Editor| { - editor - .diff() - .unwrap() - .display_rows - .iter() - .filter_map(|row| match row { - DisplayRow::Fold { start, end } => Some((*start, *end)), - _ => None, - }) - .collect::>() - }; - assert_eq!(folds(session.editor()), folds(&reference)); - let expanded_context_rows = screen_text(&terminal).matches("context").count(); - assert!(expanded_context_rows > folded_context_rows); - assert_eq!( - expanded_context_rows, - screen_text(&reference_terminal).matches("context").count() - ); - let third_source = session.editor().resolver().unwrap().files[current_file] - .sources - .iter() - .position(|source| !source.base && source.text.starts_with("value = third;")) - .unwrap(); - session.execute(&format!("resolve.source {}", third_source + 1)); - render(&mut terminal, &mut views, &mut session); - assert!( - line_at(&terminal, diff_inner(alternative), alternative.y + 1) - .contains("value = third;") - ); - let right_content = diff_inner(alternative); - assert!( - (right_content.y..right_content.bottom()) - .any(|y| { line_at(&terminal, right_content, y).contains("opening context 00") }) - ); - session.execute("resolve.draft"); - assert_eq!(session.editor().lines(), original_draft); - session.execute(&format!("resolve.source {}", alternative_index + 1)); - session.execute("resolve.draft"); - - session.handle(Input::Char('\t')); - let caret = render(&mut terminal, &mut views, &mut session); - assert_eq!(session.editor().pane_id(), alternative_pane); - assert!(alternative.contains(caret)); - let readonly = session.editor().lines().to_vec(); - session.handle(Input::Char('i')); - session.handle(Input::Char('X')); - session.handle(Input::Escape); - assert_eq!(session.editor().lines(), readonly); - session.execute("resolve.draft"); - session.handle(Input::Home); - for _ in 0..8 { - session.handle(Input::Right); - } - session.handle(Input::Char('i')); - session.handle(Input::Paste("\ndraft-only".into())); - session.handle(Input::Escape); - render(&mut terminal, &mut views, &mut session); - let geometry = views.panels.diff.as_ref().unwrap(); - let right = diff_inner(geometry.regions[2].0); - assert!( - (right.y..right.bottom()) - .any(|y| { line_at(&terminal, right, y).chars().all(|ch| ch == '╱') }), - "The shorter alternative must render an aligned gap" - ); - let context_rows: Vec<_> = geometry.regions[1..] - .iter() - .map(|(area, _)| { - let content = diff_inner(*area); - (content.y..content.bottom()) - .find(|&y| line_at(&terminal, content, y).contains("context 00")) - .unwrap() - }) - .collect(); - assert_eq!(context_rows[0], context_rows[1]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), original_draft); - session.handle(Input::Down); - session.handle(Input::Home); - session.handle(Input::Char('i')); - session.handle(Input::Paste("manual ".into())); - session.handle(Input::Escape); - render(&mut terminal, &mut views, &mut session); - let geometry = views.panels.diff.as_ref().unwrap(); - let left = diff_inner(geometry.regions[1].0); - let right = diff_inner(geometry.regions[2].0); - let left_heading = line_at(&terminal, geometry.regions[1].0, geometry.regions[1].0.y); - let right_heading = line_at(&terminal, geometry.regions[2].0, geometry.regions[2].0.y); - assert!(left_heading.contains("unsaved")); - assert!(!right_heading.contains("unsaved")); - assert!(right_heading.contains("read-only")); - assert!(line_at(&terminal, left, left.y + 1).contains("manual opening context 00")); - assert!(line_at(&terminal, right, right.y + 1).contains("opening context 00")); - assert!(!line_at(&terminal, right, right.y + 1).contains("manual")); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), original_draft); - session.handle(Input::Up); - session.execute(&format!("resolve.use_source {}", alternative_index + 1)); - render(&mut terminal, &mut views, &mut session); - let taken = session.editor().lines().to_vec(); - assert_ne!(taken[0], original_draft[0]); - assert_eq!( - taken[61], original_draft[61], - "Taking a decision must not replace another hunk" - ); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), original_draft); - - session.handle(Input::Char(']')); - render(&mut terminal, &mut views, &mut session); - assert!(screen_text(&terminal).contains("context 10")); - let cursor = session.editor().cursor(); - let before_scroll = views.panels.diff.as_ref().unwrap().viewport.unwrap().2; - assert!(views.scroll( - &mut session, - document_wheel( - MouseEventKind::ScrollDown, - alternative.x + 1, - alternative.y + 1, - ) - )); - render(&mut terminal, &mut views, &mut session); - assert!(views.panels.diff.as_ref().unwrap().viewport.unwrap().2 > before_scroll); - assert_eq!(session.editor().pane_id(), alternative_pane); - assert_eq!(session.editor().diff_buffer_focus(), Some(Focus::After)); - assert_eq!(session.editor().cursor(), cursor); - let geometry = views.panels.diff.as_ref().unwrap(); - assert!( - line_at(&terminal, diff_inner(geometry.regions[1].0), draft.y + 1).contains("context") - ); - assert!( - line_at( - &terminal, - diff_inner(geometry.regions[2].0), - alternative.y + 1 - ) - .contains("context") - ); - - session.execute("resolve.draft"); - let search_row = session - .editor() - .lines() - .iter() - .position(|line| line == "context 30") - .unwrap(); - for ch in format!("{}g", search_row + 1).chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Char('/')); - for ch in "context 30".chars() { - session.handle(Input::Char(ch)); - } - session.handle(Input::Enter); - let searched_caret = render(&mut terminal, &mut views, &mut session); - assert!(screen_text(&terminal).contains("context 30")); - assert_eq!( - terminal.backend().buffer()[(searched_caret.x, searched_caret.y)].bg, - Color::Yellow, - "source {:?} ({:?}), terminal {searched_caret:?}, caret {:?}, viewport {:?}, focus {:?}", - session.editor().cursor(), - session.editor().lines()[session.editor().cursor().row], - views.panels.caret, - views.panels.diff.as_ref().unwrap().viewport, - session.editor().diff_buffer_focus(), - ); - session.handle(Input::Char('v')); - session.handle(Input::Right); - render(&mut terminal, &mut views, &mut session); - assert_eq!( - terminal.backend().buffer()[(searched_caret.x, searched_caret.y)].bg, - Color::Blue - ); - session.handle(Input::Escape); - session.handle(Input::Left); - let searched_caret = render(&mut terminal, &mut views, &mut session); - let draft_cursor = session.editor().cursor(); - let draft_viewport = views.panels.diff.as_ref().unwrap().viewport; - session.execute("resolve.history"); - render(&mut terminal, &mut views, &mut session); - assert_eq!(views.rects.len(), 3); - for (key, title) in [ - ('e', "Explorer · Tree NORMAL"), - ('o', "Files · Tree INSERT"), - ] { - session.handle(Input::Char(' ')); - session.handle(Input::Char(key)); - let picker_caret = render(&mut terminal, &mut views, &mut session); - let panel = views.panels.files.unwrap().0; - assert!(panel.contains(picker_caret)); - assert!(screen_text(&terminal).contains(title)); - if key == 'e' { - assert_eq!(views.panels.diff.as_ref().unwrap().regions[0].0, panel); - assert!(views.rects.iter().all(|(_, area)| area.right() <= panel.x)); - } else { - assert_eq!( - panel, - floating_panel(workspace(&terminal, &views, session.editor())) - ); - } - session.handle(Input::Ctrl('w')); - render(&mut terminal, &mut views, &mut session); - assert!(views.panels.files.is_none()); - assert_eq!(views.rects.len(), 3); - } - assert!( - views - .rects - .iter() - .all(|(pane, _)| *pane != draft_pane && *pane != alternative_pane) - ); - let source_rows: Vec<_> = views - .rects - .iter() - .map(|(_, area)| line_at(&terminal, *area, area.y)) - .collect(); - assert!(source_rows.iter().any(|row| row.contains("base"))); - assert!(source_rows.iter().any(|row| row.contains("left"))); - assert!(source_rows.iter().any(|row| row.contains("right"))); - let history = &views.history[&views.history_key.unwrap()]; - for (column, (_, content)) in views.rects.iter().enumerate() { - let x = content.right() - history.widths[column] as u16; - let side = usize::from(column != 1); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(x, content.y)].symbol(), "v"); - assert_eq!( - buffer[(x, content.y)].bg, - views.panels.diff_colors.row[side] - ); - assert_eq!( - buffer[(x + 8, content.y)].bg, - views.panels.diff_colors.character[side] - ); - assert_eq!( - buffer[(x, content.y + 1)].bg, - Color::Reset, - "Shared historical context must not be highlighted as changed" - ); - } - let readonly = session.editor().lines().to_vec(); - session.handle(Input::Char('i')); - session.handle(Input::Char('X')); - session.handle(Input::Escape); - assert_eq!(session.editor().lines(), readonly); - let resolver = session.editor().resolver().unwrap(); - let right_history = resolver.history_panes[resolver.history_sources[1]]; - session.editor_mut().focus_resolver_pane(right_history); - session.execute(&format!("resolve.source {}", third_source + 1)); - render(&mut terminal, &mut views, &mut session); - assert!( - views - .rects - .iter() - .any(|(_, area)| { line_at(&terminal, *area, area.y).contains("value = third;") }) - ); - let history_areas = [views.rects[0].1, views.rects[1].1, views.rects[2].1]; - for content in history_areas.into_iter().take(2) { - assert!( - line_at(&terminal, content, content.y + 1) - .chars() - .all(|ch| ch == '╱'), - "The historical input insertion must leave aligned gaps in input/base" - ); - } - assert!( - line_at(&terminal, history_areas[2], history_areas[2].y + 1).contains("third-only") - ); - let context_rows: Vec<_> = history_areas - .into_iter() - .map(|content| { - (content.y..content.bottom()) - .find(|&y| line_at(&terminal, content, y).contains("context 00")) - .unwrap() - }) - .collect(); - assert!(context_rows.iter().all(|row| *row == context_rows[0])); - session.handle(Input::Down); - session.handle(Input::End); - session.execute("word_wrap.toggle"); - let history_caret = render(&mut terminal, &mut views, &mut session); - assert!(history_areas[2].contains(history_caret)); - let history = &views.history[&views.history_key.unwrap()]; - assert!( - history.visible[2] - .iter() - .any(|row| row.position.continuation > 0) - ); - let continuation = history.visible[2] - .iter() - .position(|row| row.position.continuation > 0) - .unwrap(); - for content in history_areas.into_iter().take(2) { - assert!( - line_at(&terminal, content, content.y + continuation as u16) - .chars() - .all(|ch| ch == '╱') - ); - } - session.execute("word_wrap.toggle"); - session.handle(Input::Home); - session.handle(Input::Up); - render(&mut terminal, &mut views, &mut session); - let history_pane = session.editor().pane_id(); - let history_cursor = session.editor().cursor(); - assert!(views.scroll( - &mut session, - document_wheel( - MouseEventKind::ScrollDown, - history_areas[1].x, - history_areas[1].y - ) - )); - render(&mut terminal, &mut views, &mut session); - for content in history_areas { - assert!(line_at(&terminal, content, content.y).contains("context 01")); - } - assert_eq!(session.editor().pane_id(), history_pane); - assert_eq!(session.editor().cursor(), history_cursor); - assert!(views.scroll( - &mut session, - document_wheel( - MouseEventKind::ScrollUp, - history_areas[1].x, - history_areas[1].y - ) - )); - session.execute("resolve.base"); - terminal.backend_mut().resize(44, 20); - let caret = render(&mut terminal, &mut views, &mut session); - assert_eq!(views.rects.len(), 1); - assert!(views.rects[0].1.contains(caret)); - assert!(screen_text(&terminal).contains("value = base;")); - terminal.backend_mut().resize(120, 30); - session.execute("resolve.draft"); - let restored_caret = render(&mut terminal, &mut views, &mut session); - assert_eq!(session.editor().cursor(), draft_cursor); - assert_eq!(views.panels.diff.as_ref().unwrap().viewport, draft_viewport); - assert_eq!(restored_caret, searched_caret); - { - let config = directory.file( - "base-below.rb", - "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.resolve_history_layout = 'base_below'\nend\n", - ); - let mut bottom = EditorSession::open( - Some(directory.0.join("first.txt")), - vec!["text".into()], - Some(&config), - ) - .unwrap(); - bottom.execute("resolve"); - ready(&mut bottom); - let draft = bottom.editor().context().text; - bottom.execute("resolve.history"); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); - let mut views = BufferViewports::default(); - render(&mut terminal, &mut views, &mut bottom); - let resolver = bottom.editor().resolver().unwrap(); - let left = resolver.history_panes[resolver.history_sources[0]]; - let right = resolver.history_panes[resolver.history_sources[1]]; - let base = resolver.history_panes[resolver.selected_base.unwrap()]; - assert_eq!(views.rects.len(), 3); - assert_eq!( - views - .rects - .iter() - .map(|(pane, _)| *pane) - .collect::>(), - [left, right, base] - ); - let [left_area, right_area, base_area] = - std::array::from_fn(|index| views.rects[index].1); - assert_eq!(left_area.right(), right_area.x); - assert_eq!(left_area.y, right_area.y); - assert_eq!(base_area.x, left_area.x); - assert_eq!(base_area.right(), right_area.right()); - assert!(base_area.y >= left_area.bottom()); - for (area, value) in [ - (left_area, "value = left;"), - (right_area, "value = right;"), - (base_area, "value = base;"), - ] { - assert!(line_at(&terminal, area, area.y).contains(value)); - } - bottom.handle(Input::Char('\t')); - assert_eq!(bottom.editor().pane_id(), right); - bottom.handle(Input::Char('\t')); - assert_eq!(bottom.editor().pane_id(), base); - bottom.handle(Input::BackTab); - assert_eq!(bottom.editor().pane_id(), right); - bottom.execute("pane.focus_down"); - let caret = render(&mut terminal, &mut views, &mut bottom); - assert_eq!(bottom.editor().pane_id(), base); - assert!(base_area.contains(caret)); - assert!(views.scroll( - &mut bottom, - document_wheel(MouseEventKind::ScrollDown, base_area.x, base_area.y) - )); - render(&mut terminal, &mut views, &mut bottom); - for area in [left_area, right_area, base_area] { - assert!(line_at(&terminal, area, area.y).contains("context 02")); - } - let readonly = bottom.editor().context().text; - bottom.handle(Input::Char('i')); - bottom.handle(Input::Paste("must not edit base".into())); - bottom.handle(Input::Escape); - assert_eq!(bottom.editor().context().text, readonly); - terminal.backend_mut().resize(44, 20); - render(&mut terminal, &mut views, &mut bottom); - assert_eq!(views.rects.len(), 1); - assert_eq!(views.rects[0].0, base); - terminal.backend_mut().resize(120, 6); - render(&mut terminal, &mut views, &mut bottom); - assert_eq!(views.rects.len(), 1); - assert_eq!(views.rects[0].0, base); - bottom.execute("resolve.draft"); - assert_eq!(bottom.editor().context().text, draft); - } - - for (key, action) in [ - ("ctrl+e", "core.file_explorer"), - ("ctrl+o", "core.file_picker"), - ] { - session - .editor_mut() - .bind("normal", key, Some(action.into())) - .unwrap(); - } - session.editor_mut().set_file_explorer_view("list").unwrap(); - for key in ["ctrl+j", "ctrl+n", "down"] { - session.editor_mut().bind("selection", key, None).unwrap(); - } - session - .editor_mut() - .bind("selection", "ctrl+t", Some("core.down".into())) - .unwrap(); - session.handle(Input::Ctrl('e')); - let picker_caret = render(&mut terminal, &mut views, &mut session); - let dock = views.panels.files.unwrap().0; - assert_eq!( - dock, - dock_layout( - workspace(&terminal, &views, session.editor()), - DockSide::Right, - true - ) - .1 - .unwrap() - ); - assert_eq!(views.panels.diff.as_ref().unwrap().regions[0].0, dock); - assert!(dock.contains(picker_caret)); - assert!(screen_text(&terminal).contains("Explorer · List")); - let selected = session.editor().file_picker().unwrap().selected; - assert_eq!( - session - .editor() - .file_picker() - .unwrap() - .entries() - .nth(selected) - .unwrap() - .path, - session.editor().resolver().unwrap().files[current_file] - .path - .as_path(), - ); - session.handle(Input::Ctrl('t')); - let moved_caret = render(&mut terminal, &mut views, &mut session); - assert_ne!(session.editor().file_picker().unwrap().selected, selected); - assert_eq!(moved_caret, picker_caret); - let selected_row = (dock.y..dock.bottom()) - .find(|&row| line_at(&terminal, dock, row).contains("second.txt")) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(dock.x + 1, selected_row)].bg, - Color::Blue - ); - for ch in "second".chars() { - session.handle(Input::Char(ch)); - } - render(&mut terminal, &mut views, &mut session); - assert!(screen_text(&terminal).contains("second.txt")); - assert!(!screen_text(&terminal).contains("binary.bin")); - session.handle(Input::Enter); - render(&mut terminal, &mut views, &mut session); - let resolver = session.editor().resolver().unwrap(); - assert_eq!( - resolver.files[resolver.current_file].path, - std::path::Path::new("second.txt") - ); - session.handle(Input::Ctrl('o')); - session.handle(Input::Ctrl('u')); - for ch in "first".chars() { - session.handle(Input::Char(ch)); - } - render(&mut terminal, &mut views, &mut session); - let floating = views.panels.files.unwrap().0; - assert_eq!( - floating, - floating_panel(workspace(&terminal, &views, session.editor())) - ); - assert!(screen_text(&terminal).contains("Files · Tree INSERT")); - session.handle(Input::Enter); - session.execute("resolve.draft"); - render(&mut terminal, &mut views, &mut session); - assert_eq!( - session.editor().resolver().unwrap().current_file, - current_file - ); - assert!(views.panels.files.is_none()); - assert_eq!( - operation(), - before_browsing, - "Rendering/navigation must not snapshot jj" - ); - - session.handle(Input::Home); - session.handle(Input::Char('i')); - session.handle(Input::Paste("wrapped ".repeat(12))); - session.handle(Input::Escape); - session.execute("word_wrap.toggle"); - terminal.backend_mut().resize(44, 20); - let caret = render(&mut terminal, &mut views, &mut session); - let geometry = views.panels.diff.as_ref().unwrap(); - assert!(geometry.regions[1].0.contains(caret)); - assert!( - views - .panels - .diff_wrap - .as_ref() - .unwrap() - .rows - .iter() - .any(|row| row.position.continuation > 0) - ); - session.handle(Input::Home); - let caret = render(&mut terminal, &mut views, &mut session); - let geometry = views.panels.diff.as_ref().unwrap(); - assert!(geometry.regions[1].0.contains(caret)); - let content = diff_inner(geometry.regions[1].0); - assert!( - (content.y..content.bottom()) - .filter(|&y| { line_at(&terminal, content, y).contains("wrapped") }) - .count() - > 1, - "cursor {:?}, source {:?}, wrapped frame:\n{}", - session.editor().cursor(), - session.editor().lines()[session.editor().cursor().row], - screen_text(&terminal), - ); - let right = diff_inner(geometry.regions[2].0); - assert!( - (right.y..right.bottom()).all(|y| !line_at(&terminal, right, y).contains("wrapped")) - ); - session.handle(Input::Char('u')); - session.execute("word_wrap.toggle"); - session.execute("w"); - ready(&mut session); - terminal.backend_mut().resize(120, 30); - render(&mut terminal, &mut views, &mut session); - let resolver = session.editor().resolver().unwrap(); - assert_eq!( - resolver.files[current_file].status, - ResolverStatus::Resolved - ); - let binary = resolver - .files - .iter() - .position(|file| file.path == std::path::Path::new("binary.bin")) - .unwrap(); - for (width, height) in [(19, 12), (4, 4), (1, 1), (0, 0)] { - terminal.backend_mut().resize(width, height); - render(&mut terminal, &mut views, &mut session); - for (_, area) in &views.rects { - assert!(area.right() <= width && area.bottom() <= height); - } - } - terminal.backend_mut().resize(120, 30); - session.editor_mut().select_resolver_file(binary); - render(&mut terminal, &mut views, &mut session); - assert!(screen_text(&terminal).contains("Whole-file conflict")); - assert!(views.rects.is_empty()); - let resolver = session.editor().resolver().unwrap(); - let side = resolver.files[binary] - .sources - .iter() - .position(|source| !source.base) - .unwrap() - + 1; - session.execute(&format!("resolve.use_source {side}")); - render(&mut terminal, &mut views, &mut session); - assert!(screen_text(&terminal).contains("Selected:")); - for (width, height) in [(4, 4), (1, 1), (0, 0)] { - terminal.backend_mut().resize(width, height); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - } - } - - fn diff_catalog() -> crate::diff::Catalog { - use crate::diff::{Catalog, Category, RepoKind, Revision, RevisionTarget}; - let mut entries = vec![Revision { - target: RevisionTarget::Working, - label: "Working checkout".into(), - description: "On-disk files".into(), - category: Category::Commits, - }]; - entries.extend((0..20).map(|index| Revision { - target: RevisionTarget::Commit(format!("commit-{index:02}")), - label: format!("commit-{index:02}"), - description: if index == 19 { - "needle description".into() - } else { - "ordinary change".into() - }, - category: Category::Commits, - })); - entries.push(Revision { - target: RevisionTarget::Commit("branch-tip".into()), - label: "feature-branch".into(), - description: "branch description".into(), - category: Category::Branches, - }); - Catalog { - kind: RepoKind::Git, - entries, - notice: None, - } - } - - fn syntax_diff_file(path: &str, before: &str, after: &str) -> ChangedFile { - let before: Vec = before.split('\n').map(str::to_owned).collect(); - let after: Vec = after.split('\n').map(str::to_owned).collect(); - ChangedFile { - loaded: true, - before_eof: false, - after_eof: false, - path: path.into(), - old_path: None, - status: FileStatus::Modified, - hunks: vec![crate::diff::Hunk { - before_start: 0, - before_len: before.len(), - after_start: 0, - after_len: after.len(), - }], - before, - after, - notice: None, - } - } - - #[test] - fn diff_scope_anchor_keeps_nearest_top_when_declarations_reclaim_rows() { - let before = (0..4) - .map(|index| { - format!( - "impl Container{index} {{\n fn run(&self) {{\n old_call();\n keep();\n }}\n}}\n" - ) - }) - .collect::(); - let after = before - .replace("Container", "Renamed") - .replace("old_call", "new_call"); - let mut diff = diff_comparison(vec![syntax_diff_file("scope.rs", &before, &after)]); - diff.focus = Focus::After; - let file = &diff.comparison.as_ref().unwrap().files[0]; - let areas = split_rect(pane_rect(Rect::new(0, 0, 120, 35)), SplitAxis::Horizontal) - .map(terminal_rect); - let mut syntax = DiffSyntaxCache::default(); - syntax.update( - diff.comparison_id.unwrap(), - 0, - file, - &file.after, - None, - None, - ); - let mut viewport = DiffWrapViewport::default(); - let cursor = Cursor { row: 15, column: 7 }; - viewport.update( - &diff, - file, - areas, - Some((Focus::After, cursor)), - Some(15), - Some(0), - &syntax, - ); - let position = viewport - .caret_position(&diff, file, Focus::After, cursor) - .unwrap(); - assert_eq!(viewport.screen_row(position), Some(15)); - assert_eq!( - diff.display_source_row(Focus::After, viewport.top.display), - Some(2), - "Keep the nearest enclosing function's sticky context, not earlier declarations" - ); - } - - #[test] - fn diff_scope_anchor_stops_at_first_target_across_unified_visibility_changes() { - let before = (0..12) - .map(|index| { - format!( - "impl Container{index} {{\n fn run(&self) {{\n old_call();\n keep();\n }}\n}}\n" - ) - }) - .collect::(); - let after = before - .replace("Container", "Renamed") - .replace("old_call", "new_call"); - let mut file = syntax_diff_file("scope.rs", &before, &after); - file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); - let mut diff = diff_comparison(vec![file]); - diff.set_context_options(3, 15); - diff.set_view_mode(ViewMode::Unified); - diff.focus = Focus::Before; - let file = &diff.comparison.as_ref().unwrap().files[0]; - let mut syntax = DiffSyntaxCache::default(); - syntax.update( - diff.comparison_id.unwrap(), - 0, - file, - &file.after, - None, - None, - ); - let mut viewport = DiffWrapViewport::default(); - viewport.update( - &diff, - file, - [Rect::new(0, 0, 120, 35); 2], - Some((Focus::Before, Cursor { row: 12, column: 7 })), - Some(16), - Some(0), - &syntax, - ); - assert_eq!( - diff.display_source_row(Focus::After, viewport.top.display), - Some(2), - "Do not move past the first anchor when an earlier declaration hides two labels" - ); - assert_eq!(viewport.rows[0].scope, [false, true]); - } - - #[test] - fn diff_scope_horizontal_scroll_keeps_source_rows_fixed() { - let before = - "keep_previous_code();\nfn review() {\n old_call_with_a_long_argument_name();\n}\n"; - let after = before.replace("old_call", "new_call"); - for (mode, axis, focus) in [ - (ViewMode::Split, SplitAxis::Vertical, Focus::After), - (ViewMode::Split, SplitAxis::Horizontal, Focus::After), - (ViewMode::Unified, SplitAxis::Vertical, Focus::Before), - (ViewMode::Unified, SplitAxis::Vertical, Focus::After), - ] { - let mut file = syntax_diff_file("scope.rs", before, &after); - file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); - let mut diff = diff_comparison(vec![file]); - diff.files_visible = false; - diff.split_axis = axis; - diff.set_view_mode(mode); - diff.focus = focus; - let declaration = diff.source_display_row(Focus::After, 1).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); - let mut panels = WheelPanels::default(); - // A declaration at the top has no room for an overlay; leave its border intact. - for top in [0, declaration] { - diff.top = top; - let mut original = None; - let mut borders = [None, None]; - for lefts in [[0, 0], [8, 0], [0, 8], [8, 8], [0, 0]] { - [diff.left_before, diff.left_after] = lefts; - render_diff(&mut terminal, &diff, &mut panels); - let viewport = panels.diff_wrap.as_ref().unwrap(); - let source_rows = viewport - .rows - .iter() - .enumerate() - .filter(|(_, row)| row.scope == [false; 2]) - .map(|(screen, row)| (screen, row.position)) - .collect::>(); - if let Some(original) = &original { - assert_eq!( - &source_rows, original, - "Horizontal scrolling must not move source rows: {mode:?}, {axis:?}" - ); - } else { - original = Some(source_rows); - } - let screen = viewport - .screen_row(DiffWrapPosition { - display: declaration, - continuation: 0, - }) - .unwrap(); - for (pane, left) in lefts.into_iter().enumerate() { - if mode == ViewMode::Unified && (focus == Focus::Before) != (pane == 0) { - continue; - } - let area = diff_inner(panels.diff.as_ref().unwrap().regions[pane + 1].0); - let above = pane_screen( - &terminal, - Rect::new(area.x, area.y + screen as u16 - 1, area.width, 1), - ); - assert_eq!( - above.contains("review"), - left > 0 && screen > 0, - "Clipped names belong above the declaration, without adding a row" - ); - if screen == 0 { - let border = borders[pane].get_or_insert_with(|| above.clone()); - assert_eq!( - &above, border, - "Horizontal scrolling must preserve the border" - ); - } else if left > 0 { - assert_eq!( - above.trim(), - if mode == ViewMode::Unified { - "± review" - } else { - "› review" - }, - "The overlay must clear source text and its line number" - ); - } - } - } - } - } - } - - #[test] - fn diff_scope_overlay_preserves_visible_declaration_above() { - let before = "pub fn old_name() {\n shared_call_with_a_long_argument_name();\n}\n"; - let after = "fn new_name() {\n shared_call_with_a_long_argument_name();\n}\n"; - for focus in [Focus::Before, Focus::After] { - let mut file = syntax_diff_file("scope.rs", before, after); - file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); - let mut diff = diff_comparison(vec![file]); - diff.files_visible = false; - diff.set_view_mode(ViewMode::Unified); - diff.focus = focus; - diff.top = diff.source_display_row(Focus::Before, 0).unwrap(); - diff.left_before = 4; - diff.left_after = 4; - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - let pane = usize::from(focus == Focus::After); - let area = diff_inner(panels.diff.as_ref().unwrap().regions[pane + 1].0); - let first = pane_screen(&terminal, Rect::new(area.x, area.y, area.width, 1)); - assert!( - first.contains("fn old_name()"), - "Visible declaration was covered: {first}" - ); - assert!(!first.contains("+ new_name")); - } - } - - #[test] - fn diff_scope_sticky_row_keeps_adjacent_declaration_numbered() { - let after = "\n#[allow(clippy::too_many_arguments)]\nfn draw_diff_scope_labels(\n frame: &mut Frame,\n diff: &DiffState,\n) {\n work();\n}\n"; - for (mode, axis, wrap) in [ - (ViewMode::Split, SplitAxis::Vertical, false), - (ViewMode::Split, SplitAxis::Horizontal, false), - (ViewMode::Split, SplitAxis::Vertical, true), - (ViewMode::Unified, SplitAxis::Vertical, false), - (ViewMode::Unified, SplitAxis::Vertical, true), - ] { - let mut file = syntax_diff_file("tui.rs", "", after); - file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); - let mut diff = diff_comparison(vec![file]); - diff.files_visible = false; - diff.focus = Focus::After; - diff.split_axis = axis; - diff.word_wrap = wrap; - diff.set_view_mode(mode); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 24)).unwrap(); - let mut panels = WheelPanels::default(); - for source in [0, 1, 2, 3, 4, 3, 2] { - diff.top = diff.source_display_row(Focus::After, source).unwrap(); - render_diff(&mut terminal, &diff, &mut panels); - let area = diff_inner(panels.diff.as_ref().unwrap().regions[2].0); - let text = pane_screen(&terminal, area); - if source <= 3 { - let declaration = text - .lines() - .find(|line| line.contains("fn draw_diff_scope_labels(")); - assert!( - declaration.is_some_and(|line| line.contains("3+")), - "The declaration must keep its source number, not become an unnumbered sticky label at top source {source}:\n{text}" - ); - } else { - assert!(text.lines().any(|line| { - line.contains("draw_diff_scope_labels") && !line.contains("fn ") - })); - } - } - } - } - - #[test] - fn diff_scope_labels_follow_declaration_visibility() { - let before = "@decorate()\nclass PolicyActiveStatusEnum(BaseEnum):\n ACTIVE = 1\n EXPIRED = 2\n FUTURE = 3\n UNKNOWN = 4\n"; - let after = before.replace(" FUTURE", " CANCELLED = 5\n FUTURE"); - for (mode, axis, wrap) in [ - (ViewMode::Split, SplitAxis::Vertical, false), - (ViewMode::Split, SplitAxis::Horizontal, true), - (ViewMode::Unified, SplitAxis::Vertical, true), - ] { - let mut file = syntax_diff_file("status.py", before, &after); - file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); - let mut diff = diff_comparison(vec![file]); - diff.files_visible = false; - diff.split_axis = axis; - diff.word_wrap = wrap; - diff.set_view_mode(mode); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 32)).unwrap(); - let mut panels = WheelPanels::default(); - for (source, visible) in [(0, false), (2, false), (3, true), (1, false), (2, false)] { - diff.top = diff.source_display_row(Focus::After, source).unwrap(); - render_diff(&mut terminal, &diff, &mut panels); - let text = screen_text(&terminal); - let count = ["› PolicyActiveStatusEnum", "± PolicyActiveStatusEnum"] - .iter() - .map(|label| text.matches(label).count()) - .sum::(); - assert_eq!( - count, - if visible { - if mode == ViewMode::Split { 2 } else { 1 } - } else { - 0 - } - ); - assert!(text.contains("EXPIRED = 2")); - } - } - let before = "def old_name():\n shared()\n tail()\n"; - let after = before.replace("old_name", "new_name"); - let mut file = syntax_diff_file("renamed.py", before, &after); - file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); - let mut diff = diff_comparison(vec![file]); - diff.files_visible = false; - diff.set_view_mode(ViewMode::Unified); - diff.top = diff.source_display_row(Focus::After, 0).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - let text = screen_text(&terminal); - assert!(text.contains("def new_name():")); - assert!(text.contains("− old_name")); - assert!(!text.contains("+ new_name")); - } - - #[test] - fn diff_scope_labels_do_not_repeat_across_hunks_in_one_excerpt() { - let before = "def retry_cancellation_email():\n first()\n shared_one()\n old()\n shared_two()\n shared_three()\n tail()\n"; - let after = "def retry_cancellation_email():\n first()\n inserted_one()\n inserted_two()\n shared_one()\n shared_two()\n inserted_three()\n shared_three()\n tail()\n"; - for mode in [ViewMode::Split, ViewMode::Unified] { - let mut file = syntax_diff_file("retry.py", before, after); - file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); - let mut diff = diff_comparison(vec![file]); - diff.files_visible = false; - diff.set_view_mode(mode); - diff.top = diff.source_display_row(Focus::After, 4).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 40)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - if mode == ViewMode::Split { - for top in [diff.top, diff.source_display_row(Focus::After, 3).unwrap()] { - diff.top = top; - render_diff(&mut terminal, &diff, &mut panels); - for side in [1, 2] { - let area = panels.diff.as_ref().unwrap().regions[side].0; - assert_eq!( - pane_screen(&terminal, area) - .matches("› retry_cancellation_email") - .count(), - 1 - ); - } - } - diff.set_context_options(0, 15); - render_diff(&mut terminal, &diff, &mut panels); - for (side, expected) in [(1, 1), (2, 2)] { - let area = panels.diff.as_ref().unwrap().regions[side].0; - assert_eq!( - pane_screen(&terminal, area) - .matches("› retry_cancellation_email") - .count(), - expected - ); - } - } else { - assert_eq!( - screen_text(&terminal) - .matches("± retry_cancellation_email") - .count(), - 1 - ); - assert!(!screen_text(&terminal).contains("+ retry_cancellation_email")); - assert!(!screen_text(&terminal).contains("− retry_cancellation_email")); - } - } - } - - #[test] - fn diff_scope_labels_identify_multiple_scopes_and_sticky_renamed_sides() { - let before = "impl Review {\n fn old_name(&self) {\n let first = 1;\n let second = 2;\n }\n fn next(&self) {\n let third = 3;\n }\n}"; - let after = before - .replace("old_name", "new_name") - .replace("first = 1", "first = 9"); - for axis in [SplitAxis::Vertical, SplitAxis::Horizontal] { - for wrapping in [false, true] { - let mut diff = diff_comparison(vec![syntax_diff_file("scope.rs", before, &after)]); - diff.files_visible = false; - diff.split_axis = axis; - diff.word_wrap = wrapping; - diff.top = diff.source_display_row(Focus::Before, 3).unwrap(); - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(160, 48)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - let rows = &panels.diff_wrap.as_ref().unwrap().rows; - for (side, name, other) in - [(0, "old_name", "new_name"), (1, "new_name", "old_name")] - { - let area = diff_inner(panels.diff.as_ref().unwrap().regions[side + 1].0); - let labels = rows - .iter() - .enumerate() - .filter(|(_, row)| row.scope[side]) - .map(|(screen, _)| { - (area.x..area.right()) - .map(|x| { - terminal.backend().buffer()[(x, area.y + screen as u16)] - .symbol() - }) - .collect::() - }) - .collect::>(); - assert!(labels.iter().any(|label| label.contains(name))); - assert!(!labels.iter().any(|label| label.contains("next"))); - assert!(pane_screen(&terminal, area).contains("fn next")); - assert!(!labels.iter().any(|label| label.contains(other))); - } - let syntax = panels.diff_syntax.as_ref().unwrap(); - let parses = (syntax.before.parse_counts(), syntax.after.parse_counts()); - diff.top = diff.source_display_row(Focus::Before, 3).unwrap(); - render_diff(&mut terminal, &diff, &mut panels); - let rows = &panels.diff_wrap.as_ref().unwrap().rows; - assert_eq!(rows[0].scope, [true; 2]); - assert_eq!(rows[1].scope, [false; 2]); - assert_eq!(rows[0].position, rows[1].position); - let first = diff_screen_rows(&diff, diff.top, 48, Some(rows)) - .next() - .unwrap(); - assert_eq!((first.0, first.1), (1, diff.top)); - let syntax = panels.diff_syntax.as_ref().unwrap(); - assert_eq!( - (syntax.before.parse_counts(), syntax.after.parse_counts()), - parses - ); - } - } - let mut narrow = diff_comparison(vec![syntax_diff_file("scope.rs", before, &after)]); - narrow.files_visible = false; - narrow.top = narrow.source_display_row(Focus::Before, 3).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 30)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &narrow, &mut panels); - assert!(screen_text(&terminal).contains("› old_name")); - assert!(screen_text(&terminal).contains("› new_name")); - let diff = diff_comparison(vec![syntax_diff_file("scope.txt", before, &after)]); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - assert!( - panels - .diff_wrap - .as_ref() - .unwrap() - .rows - .iter() - .all(|row| row.scope == [false; 2]) - ); - } - - #[test] - fn diff_syntax_refreshes_same_path_working_snapshots_without_live_buffer_styles() { - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); - let mut panels = WheelPanels::default(); - for (source, foreground) in [ - ("fn main() {}", Color::Magenta), - ("// current snapshot", Color::DarkGray), - ("const VALUE: u8 = 3;", Color::Magenta), - ] { - let mut diff = - diff_comparison(vec![syntax_diff_file("working.rs", "fn old() {}", source)]); - diff.files_visible = false; - render_diff(&mut terminal, &diff, &mut panels); - let source_row = panels - .diff_wrap - .as_ref() - .unwrap() - .rows - .iter() - .position(|row| row.scope == [false; 2]) - .unwrap() as u16; - let after = panels.diff.as_ref().unwrap().regions[2].0; - let cell = &terminal.backend().buffer()[(after.x + 4, after.y + 1 + source_row)]; - assert_eq!(cell.fg, foreground, "{source}"); - let before = panels.diff.as_ref().unwrap().regions[1].0; - let cell = &terminal.backend().buffer()[(before.x + 4, before.y + 1 + source_row)]; - assert_eq!(cell.fg, Color::Magenta); - } - } - - #[test] - fn diff_syntax_uses_each_historic_path_shebang_and_injected_language() { - let mut file = syntax_diff_file("new.rs", "return 42", "fn main() {}"); - file.old_path = Some("old.py".into()); - let mut syntax = DiffSyntaxCache::default(); - syntax.update(1, 0, &file, &file.after, None, None); - assert_eq!(syntax.before.language(), Some("Python")); - assert_eq!(syntax.after.language(), Some("Rust")); - assert_eq!(syntax.before.line(0)[0].kind, SyntaxKind::Keyword); - assert_eq!(syntax.after.line(0)[0].kind, SyntaxKind::Keyword); - file.path = "unknown.extension".into(); - syntax.update(1, 0, &file, &file.after, None, None); - assert_eq!(syntax.after.language(), None); - assert!(syntax.after.line(0).is_empty()); - - let mut file = syntax_diff_file( - "script", - "```rust\nfn old() {}\n```", - "#!/usr/bin/env python3\nreturn 42", - ); - file.old_path = Some("history.md".into()); - let mut diff = diff_comparison(vec![file]); - diff.files_visible = false; - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - let syntax = panels.diff_syntax.as_ref().unwrap(); - assert_eq!(syntax.before.language(), Some("Markdown")); - assert_eq!(syntax.after.language(), Some("Python")); - assert!(syntax.before.parse_counts().1 > 0); - assert_eq!(syntax.before.line(1)[0].kind, SyntaxKind::Keyword); - assert_eq!(syntax.after.line(1)[0].kind, SyntaxKind::Keyword); - for (side, region) in [ - panels.diff.as_ref().unwrap().regions[1].0, - panels.diff.as_ref().unwrap().regions[2].0, - ] - .into_iter() - .enumerate() - { - let focus = if side == 0 { - Focus::Before - } else { - Focus::After - }; - let screen = panels - .diff_wrap - .as_ref() - .unwrap() - .rows - .iter() - .position(|row| { - row.scope == [false; 2] - && diff.display_source_row(focus, row.position.display) == Some(1) - }) - .unwrap(); - let cell = &terminal.backend().buffer()[(region.x + 4, region.y + 1 + screen as u16)]; - assert_eq!(cell.fg, Color::Magenta); - } - } - - #[test] - fn diff_syntax_parses_selected_snapshots_not_scroll_focus_or_redraw() { - let first = syntax_diff_file("a.rs", "fn old() {}\n// old", "fn main() {}\n// new"); - let second = syntax_diff_file("b.rs", "// prior file", "// selected file"); - let mut diff = diff_comparison(vec![first, second]); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - let syntax = panels.diff_syntax.as_ref().unwrap(); - let counts = (syntax.before.parse_counts(), syntax.after.parse_counts()); - assert_eq!(counts, ((1, 0), (1, 0))); - for focus in [Focus::Before, Focus::After, Focus::Files] { - diff.focus = focus; - diff.top = 1; - diff.left_before = 1; - diff.left_after = 2; - render_diff(&mut terminal, &diff, &mut panels); - let syntax = panels.diff_syntax.as_ref().unwrap(); - assert_eq!( - (syntax.before.parse_counts(), syntax.after.parse_counts()), - counts - ); - let after = panels.diff.as_ref().unwrap().regions[2].0; - assert_eq!( - terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg, - Color::DarkGray, - ); - } - diff.files - .as_mut() - .unwrap() - .reveal_path(std::path::Path::new("b.rs")); - diff.handle(Input::Enter); - render_diff(&mut terminal, &diff, &mut panels); - let syntax = panels.diff_syntax.as_ref().unwrap(); - assert_eq!(syntax.before.parse_counts(), (2, 0)); - assert_eq!(syntax.after.parse_counts(), (2, 0)); - let after = panels.diff.as_ref().unwrap().regions[2].0; - assert_eq!( - terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg, - Color::DarkGray, - ); - let mut editor = Editor::open(None, vec![]).unwrap(); - let mut views = BufferViewports { - panels, - ..BufferViewports::default() - }; - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(views.panels.diff_syntax.is_none()); - } - - fn diff_comparison(files: Vec) -> DiffState { - static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let mut diff = DiffState::new(); - diff.take_request(); - diff.apply_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); - diff.handle(Input::Enter); - diff.handle(Input::Enter); - diff.take_request(); - diff.apply_result( - id, - Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { - root: std::path::PathBuf::new(), - editable_paths: Default::default(), - before: "old revision".into(), - after: "Working checkout".into(), - revisions: ["before-snapshot".into(), "after-snapshot".into()], - files, - })), - ); - diff - } - - fn deletion_file() -> ChangedFile { - ChangedFile { - loaded: true, - before_eof: false, - after_eof: false, - path: "deleted-lines.txt".into(), - old_path: None, - status: FileStatus::Modified, - before: ["keep", "gone", "tail"].map(String::from).to_vec(), - after: ["keep", "tail"].map(String::from).to_vec(), - hunks: vec![crate::diff::Hunk { - before_start: 1, - before_len: 1, - after_start: 1, - after_len: 0, - }], - notice: None, - } - } - - #[test] - fn diff_borders_keep_full_revisions_and_each_panes_own_editability() { - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap(); - let mut panels = WheelPanels::default(); - for case in ["working", "checked_out", "unlisted", "historical", "binary"] { - let mut file = syntax_diff_file("new.rs", "fn old() {}", "fn new() {}"); - file.status = FileStatus::Renamed; - file.old_path = Some("old.rs".into()); - if case == "binary" { - file.notice = Some("Binary or non-UTF-8 file; text comparison unavailable".into()); - } - let mut diff = diff_comparison(vec![file]); - diff.files_visible = false; - let comparison = diff.comparison.as_mut().unwrap(); - comparison.before = "abcdef0123456789 (baseline)".into(); - comparison.after = "fedcba9876543210 (destination)".into(); - if matches!(case, "working" | "checked_out" | "binary") { - comparison.editable_paths.insert("new.rs".into()); - } - if matches!(case, "historical" | "checked_out") { - diff.after.as_mut().unwrap().target = - crate::diff::RevisionTarget::Commit("fedcba9876543210".into()); - } - let writable = matches!(case, "working" | "checked_out"); - assert_eq!(diff.after_editable(), writable, "{case}"); - for focus in [Focus::Before, Focus::After] { - diff.focus = focus; - render_diff(&mut terminal, &diff, &mut panels); - let regions = panels.diff.as_ref().unwrap().regions; - for (index, label, path, state) in [ - (1, "abcdef0123456789 (baseline)", "old.rs", "read-only"), - ( - 2, - "fedcba9876543210 (destination)", - "new.rs", - if writable { "editable" } else { "read-only" }, - ), - ] { - let pane = regions[index].0; - let top = pane_screen(&terminal, Rect::new(pane.x, pane.y, pane.width, 1)); - let bottom = pane_screen( - &terminal, - Rect::new(pane.x, pane.bottom() - 1, pane.width, 1), - ); - assert!(top.contains(path), "{case}: {top}"); - assert!( - pane_screen(&terminal, Rect::new(pane.right() - 13, pane.y, 12, 1),) - .contains(state), - "{case}: {top}", - ); - assert!( - bottom.find(label).is_some_and(|offset| { - UnicodeWidthStr::width(&bottom[..offset]) <= 2 - }), - "{bottom}", - ); - assert!(!top.contains(label)); - assert!(!bottom.contains('→')); - assert_eq!(screen_text(&terminal).matches(label).count(), 1); - assert_eq!( - terminal.backend().buffer()[(pane.x, pane.y)].fg, - if regions[index].1 == focus { - Color::Cyan - } else { - Color::DarkGray - }, - ); - } - assert!(!screen_text(&terminal).contains("Native buffer bindings")); - assert!(!screen_text(&terminal).contains("focus ·")); - } - } - } - - #[test] - fn diff_workspace_reclaims_header_and_footer_without_hiding_notices() { - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); - let mut panels = WheelPanels::default(); - let mut diff = diff_comparison(vec![deletion_file()]); - for notice in [None, Some("comparison failed"), Some("loading")] { - diff.error = notice - .filter(|notice| *notice != "loading") - .map(str::to_owned); - diff.loading = notice == Some("loading"); - render_diff(&mut terminal, &diff, &mut panels); - let area = terminal.backend().buffer().area; - let regions = panels.diff.as_ref().unwrap().regions; - for (pane, _) in regions { - assert_eq!(pane.y, area.y + u16::from(notice.is_some())); - assert_eq!(pane.bottom(), area.bottom()); - } - if let Some(notice) = notice { - let row = pane_screen(&terminal, Rect::new(area.x, area.y, area.width, 1)); - assert!(row.contains(if notice == "loading" { - "Loading file" - } else { - notice - })); - } - let [_, before, after] = regions.map(|(pane, _)| pane); - assert_eq!(before.right(), after.x); - assert_eq!( - diff_inner(before).height, - area.height - 2 - u16::from(notice.is_some()), - ); - } - } - - #[test] - fn diff_border_titles_clip_unicode_without_overwriting_state_or_text() { - let mut file = syntax_diff_file( - &format!("界e\u{301}👩‍🚀\n\t{}", "long-path".repeat(12)), - "old", - "new", - ); - file.old_path = Some(format!("e\u{301}界👩‍🚀\u{1b}{}", "old-path".repeat(12)).into()); - let mut diff = diff_comparison(vec![file]); - diff.files_visible = false; - let comparison = diff.comparison.as_mut().unwrap(); - comparison.before = "before 界e\u{301}👩‍🚀\n\t\u{1b}".into(); - comparison.after = "after e\u{301}界👩‍🚀\r\u{7f}".into(); - let mut panels = WheelPanels::default(); - for (width, height) in [(180, 10), (64, 8), (36, 6), (8, 4), (3, 3), (1, 1)] { - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); - render_diff(&mut terminal, &diff, &mut panels); - for (pane, _) in panels.diff.as_ref().unwrap().regions.into_iter().skip(1) { - assert!(pane.right() <= width && pane.bottom() <= height); - let buffer = terminal.backend().buffer(); - for y in pane.y..pane.bottom() { - for x in pane.x..pane.right() { - let symbol = buffer[(x, y)].symbol(); - assert!(!symbol.chars().any(char::is_control), "{symbol:?}"); - assert!(!symbol.starts_with('\u{301}'), "{symbol:?}"); - assert!(!symbol.starts_with('\u{200d}'), "{symbol:?}"); - if symbol.contains('👩') || symbol.contains('🚀') { - assert_eq!(symbol, "👩‍🚀"); - } - } - } - if width >= 36 { - assert!( - pane_screen(&terminal, Rect::new(pane.right() - 13, pane.y, 12, 1)) - .contains("read-only") - ); - let inner = diff_inner(pane); - assert_eq!(buffer[(inner.x, inner.y)].symbol(), "1"); - assert_eq!( - buffer[(inner.x + 3, inner.y)].symbol(), - if pane.x == 0 { "o" } else { "n" }, - ); - } - if width == 180 { - let bottom = pane_screen( - &terminal, - Rect::new(pane.x, pane.bottom() - 1, pane.width, 1), - ); - for grapheme in [ - if pane.x == 0 { "before" } else { "after" }, - "界", - "e\u{301}", - "👩‍🚀", - ] { - assert!(bottom.contains(grapheme), "{bottom}"); - } - assert_eq!(bottom.matches('�').count(), if pane.x == 0 { 3 } else { 2 }); - } - } - } - } - - #[test] - fn diff_status_layouts_hide_absent_panes_and_leave_unchanged_renames_blank() { - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 12)).unwrap(); - let mut panels = WheelPanels { - diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)), - ..WheelPanels::default() - }; - for status in [FileStatus::Added, FileStatus::Removed] { - let added = status == FileStatus::Added; - let mut file = deletion_file(); - file.status = status; - file.before = if added { - vec![] - } else { - vec!["removed content".into()] - }; - file.after = if added { - vec!["added content".into()] - } else { - vec![] - }; - file.hunks = vec![crate::diff::Hunk { - before_start: 0, - before_len: usize::from(!added), - after_start: 0, - after_len: usize::from(added), - }]; - let mut diff = diff_comparison(vec![file]); - diff.files_visible = false; - diff.comparison - .as_mut() - .unwrap() - .editable_paths - .insert("deleted-lines.txt".into()); - diff.focus = if added { Focus::After } else { Focus::Before }; - diff.left_after = 2; - render_diff(&mut terminal, &diff, &mut panels); - let geometry = panels.diff.as_ref().unwrap(); - let present = geometry.regions[if added { 2 } else { 1 }].0; - assert_eq!( - geometry.regions[if added { 1 } else { 2 }].0, - Rect::default() - ); - assert_eq!(present.x, 0); - assert_eq!(present.width, 160); - assert_eq!(geometry.viewport.unwrap().3, 0); - assert_eq!(geometry.viewport.unwrap().4, 2); - assert_eq!(geometry.regions[if added { 2 } else { 1 }].1, diff.focus); - assert_eq!( - terminal.backend().buffer()[(present.x, present.y)].fg, - Color::Cyan - ); - for x in present.x + 1..present.right() - 1 { - assert_eq!( - terminal.backend().buffer()[(x, present.y + 1)].bg, - Color::Reset - ); - } - let text = screen_text(&terminal); - assert!(text.contains(if added { - "ded content" - } else { - "removed content" - })); - assert!(text.contains(if added { "After · A" } else { "Before · D" })); - assert!(!text.contains(if added { "Before ·" } else { "After ·" })); - let bottom = pane_screen( - &terminal, - Rect::new(present.x, present.bottom() - 1, present.width, 1), - ); - assert!(bottom.contains("old revision → Working checkout")); - assert_eq!(text.matches("old revision").count(), 1); - assert_eq!(text.matches("Working checkout").count(), 1); - assert!( - pane_screen(&terminal, Rect::new(present.right() - 13, present.y, 12, 1),) - .contains(if added { "editable" } else { "read-only" }) - ); - assert!(!text.contains('╱')); - } - let mut empty_added = deletion_file(); - empty_added.status = FileStatus::Added; - empty_added.before.clear(); - empty_added.after.clear(); - empty_added.hunks.clear(); - empty_added.notice = Some("metadata-only addition".into()); - let mut diff = diff_comparison(vec![empty_added]); - diff.files_visible = false; - diff.focus = Focus::After; - render_diff(&mut terminal, &diff, &mut panels); - let geometry = panels.diff.as_ref().unwrap(); - assert_eq!(geometry.regions[1].0, Rect::default()); - assert_eq!(geometry.regions[2].0.width, 160); - assert!(screen_text(&terminal).contains("metadata-only addition")); - let pane = geometry.regions[2].0; - assert!( - pane_screen( - &terminal, - Rect::new(pane.x, pane.bottom() - 1, pane.width, 1), - ) - .contains("old revision → Working checkout") - ); - - let mut renamed = deletion_file(); - renamed.status = FileStatus::Renamed; - renamed.old_path = Some("old/path.txt".into()); - renamed.path = "new/path.txt".into(); - renamed.before = vec!["unchanged rename content".into()]; - renamed.after = renamed.before.clone(); - renamed.hunks.clear(); - let mut diff = diff_comparison(vec![renamed]); - render_diff(&mut terminal, &diff, &mut panels); - assert!(screen_text(&terminal).contains("old/path.txt -> new/path.txt")); - assert_eq!( - diff.files.as_mut().unwrap().accept(), - Some("new/path.txt".into()) - ); - diff.files_visible = false; - render_diff(&mut terminal, &diff, &mut panels); - let geometry = panels.diff.as_ref().unwrap(); - let blank = geometry.regions[1].0; - assert_eq!(blank.width, 160); - assert_eq!(geometry.regions[2].0, Rect::default()); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(blank.x, blank.y)].symbol(), "┌"); - for y in blank.y + 1..blank.bottom() - 1 { - for x in blank.x + 1..blank.right() - 1 { - assert_eq!(buffer[(x, y)].symbol(), " "); - assert_eq!(buffer[(x, y)].bg, Color::Reset); - } - } - let text = screen_text(&terminal); - assert!( - pane_screen( - &terminal, - Rect::new(blank.x, blank.bottom() - 1, blank.width, 1), - ) - .contains("old revision → Working checkout") - ); - assert!(!text.contains("unchanged rename content")); - assert!(!text.contains('╱')); - - let mut modified = diff_comparison(vec![deletion_file()]); - modified.files_visible = false; - render_diff(&mut terminal, &modified, &mut panels); - let regions = panels.diff.as_ref().unwrap().regions; - assert_eq!(regions[1].0.width, 80); - assert_eq!(regions[2].0.width, 80); - let text = screen_text(&terminal); - assert!(text.contains("Before · M")); - assert!(text.contains("After · M")); - } - - fn render_diff( - terminal: &mut Terminal, - diff: &DiffState, - panels: &mut WheelPanels, - ) { - terminal - .draw(|frame| { - panels.begin_frame(); - draw_diff(frame, diff, frame.area(), "↑/↓", "h/j/k/l", panels, None); - }) - .unwrap(); - } - - fn commented_diff() -> DiffState { - use crate::review::{Anchor, Comment, Side}; - let before = format!( - "{}\nold e\u{301}界 second\nkeep", - "old e\u{301}界 ".repeat(12) - ); - let after = format!( - "{}\nnew e\u{301}界 second\nkeep", - "new e\u{301}界 ".repeat(12) - ); - let mut file = syntax_diff_file("new.txt", &before, &after); - file.hunks = - crate::diff::line_hunks(&file.before, file.before_eof, &file.after, file.after_eof); - file.old_path = Some("old.txt".into()); - let mut diff = diff_comparison(vec![file]); - diff.files_visible = false; - diff.local_review.document.root = diff.comparison.as_ref().unwrap().root.clone(); - diff.local_review.document.comments = vec![ - Comment { - id: 1, - anchor: Anchor { - revision: "before-snapshot".into(), - path: "old.txt".into(), - side: Side::Before, - start: crate::plugin::TextPosition { row: 0, column: 0 }, - end: crate::plugin::TextPosition { row: 2, column: 0 }, - content: before, - }, - body: "Before range".into(), - resolved: false, - }, - Comment { - id: 2, - anchor: Anchor { - revision: "after-snapshot".into(), - path: "new.txt".into(), - side: Side::After, - start: crate::plugin::TextPosition { row: 0, column: 0 }, - end: crate::plugin::TextPosition { row: 1, column: 0 }, - content: after, - }, - body: "After line".into(), - resolved: false, - }, - ]; - diff.local_review.selected = Some(1); - diff - } - - #[test] - fn local_review_highlights_exact_graphemes_newlines_clips_and_wrap_continuations() { - use crate::plugin::TextPosition; - use crate::review::Side; - - for mode in [ViewMode::Split, ViewMode::Unified] { - for side in [Side::Before, Side::After] { - for (wrapping, horizontal) in [(false, 0), (false, 6), (true, 0)] { - for (start, end) in [((0, 4), (0, 6)), ((0, 5), (1, 5)), ((0, 84), (1, 0))] { - let mut diff = commented_diff(); - diff.set_view_mode(mode); - diff.top = 0; - diff.word_wrap = wrapping; - diff.left_before = horizontal; - diff.left_after = horizontal; - let comment = diff - .local_review - .document - .comments - .iter_mut() - .find(|comment| comment.anchor.side == side) - .unwrap(); - comment.anchor.start = TextPosition { - row: start.0, - column: start.1, - }; - comment.anchor.end = TextPosition { - row: end.0, - column: end.1, - }; - let anchor = comment.anchor.clone(); - diff.local_review.selected = Some(comment.id); - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(90, 40)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - let file = &diff.comparison.as_ref().unwrap().files[0]; - let wrapped = panels - .diff_wrap - .as_ref() - .map(|viewport| viewport.rows.as_slice()); - let buffer = terminal.backend().buffer(); - for (area, focus) in - panels.diff.as_ref().unwrap().regions.into_iter().skip(1) - { - let content = diff_inner(area); - let gutter = diff_gutter(&diff, file, content.width as usize, focus); - let width = content.width as usize - gutter; - for (screen, display, segments) in - diff_screen_rows(&diff, 0, content.height, wrapped) - { - let mut expected = vec![false; width]; - if let DisplayRow::Line(aligned) = diff.display_rows[display] { - let row = &diff.rows[aligned]; - let actual_side = if mode == ViewMode::Unified { - diff.display_focus(display).unwrap_or(focus) - } else { - focus - }; - let anchor_side = if side == Side::Before { - Focus::Before - } else { - Focus::After - }; - if actual_side == anchor_side - || (mode == ViewMode::Unified && row.kind == RowKind::Equal) - { - let source_row = if side == Side::Before { - row.before - } else { - row.after - }; - if let Some(source_row) = source_row { - let line = if side == Side::Before { - &file.before[source_row] - } else { - &file.after[source_row] - }; - let segment = segments.and_then(|segments| { - segments[usize::from(actual_side == Focus::After)] - }); - let left = - segment.map_or(horizontal, |segment| segment.start); - let paint_width = segment.map_or(width, |segment| { - segment_paint_width( - segment, - wrapped.unwrap()[screen].final_segments - [usize::from(actual_side == Focus::After)], - width, - ) - }); - for column in 0..=line.graphemes(true).count() { - let position = TextPosition { - row: source_row, - column, - }; - let cell_start = display_column(line, column); - let cell_end = - if column == line.graphemes(true).count() { - cell_start + 1 - } else { - display_column(line, column + 1) - }; - if anchor.start <= position - && position < anchor.end - && cell_start >= left - && cell_end <= left + paint_width - { - expected[cell_start - left..cell_end - left] - .fill(true); - } - } - } - } - } - let mut continuation = 0; - for (column, expected) in expected.into_iter().enumerate() { - if continuation > 0 { - continuation -= 1; - continue; - } - continuation = UnicodeWidthStr::width( - buffer[( - content.x + gutter as u16 + column as u16, - content.y + screen as u16, - )] - .symbol(), - ) - .saturating_sub(1); - assert_eq!( - buffer[( - content.x + gutter as u16 + column as u16, - content.y + screen as u16, - )] - .bg - == Color::Magenta, - expected, - "{mode:?} {side:?} wrap={wrapping} left={horizontal} \ - {start:?}..{end:?}, screen={screen} column={column}" - ); - } - } - } - } - } - } - } - } - - #[test] - fn local_review_markers_preserve_diff_signs_ranges_and_wrapped_geometry() { - for mode in [ViewMode::Split, ViewMode::Unified] { - for wrapping in [false, true] { - let mut diff = commented_diff(); - diff.set_view_mode(mode); - diff.top = 0; - diff.word_wrap = wrapping; - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(90, 40)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - let buffer = terminal.backend().buffer(); - let markers: Vec<_> = buffer - .content - .iter() - .enumerate() - .filter(|(_, cell)| matches!(cell.symbol(), "●" | "○")) - .collect(); - assert_eq!(markers.len(), 3, "{mode:?} {wrapping}"); - assert_eq!( - markers - .iter() - .filter(|(_, cell)| cell.symbol() == "●") - .count(), - 2 - ); - for &(offset, cell) in &markers { - assert_eq!(cell.fg, Color::Magenta); - assert_eq!(cell.modifier.contains(Modifier::BOLD), cell.symbol() == "●"); - assert_eq!( - buffer.content[offset - 1].symbol(), - if cell.symbol() == "●" { "-" } else { "+" } - ); - } - if wrapping { - let rows = &panels.diff_wrap.as_ref().unwrap().rows; - assert!(rows.iter().any(|row| row.position.continuation > 0)); - for (area, _) in panels.diff.as_ref().unwrap().regions.into_iter().skip(1) { - let area = diff_inner(area); - for (screen, row) in rows.iter().take(area.height as usize).enumerate() { - if row.position.continuation > 0 { - for x in area.x..area.right() { - assert!(!matches!( - buffer[(x, area.y + screen as u16)].symbol(), - "●" | "○" - )); - } - } - } - } - } - } - } - } - - #[test] - fn local_review_markers_reject_stale_identity_text_and_rename_paths() { - for mode in [ViewMode::Split, ViewMode::Unified] { - for mismatch in ["revision", "content", "path", "side", "root"] { - let mut diff = commented_diff(); - diff.set_view_mode(mode); - diff.local_review.document.comments.truncate(1); - let anchor = &mut diff.local_review.document.comments[0].anchor; - match mismatch { - "revision" => anchor.revision.push_str("-newer"), - "content" => anchor.content.push('\n'), - "path" => anchor.path = "new.txt".into(), - "side" => anchor.side = crate::review::Side::After, - "root" => diff.local_review.document.root = "another-repository".into(), - _ => unreachable!(), - } - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap(); - render_diff(&mut terminal, &diff, &mut WheelPanels::default()); - assert!( - terminal - .backend() - .buffer() - .content - .iter() - .all(|cell| !matches!(cell.symbol(), "●" | "○")), - "{mode:?} {mismatch}" - ); - assert!( - terminal - .backend() - .buffer() - .content - .iter() - .all(|cell| cell.bg != Color::Magenta), - "{mode:?} {mismatch}" - ); - } - } - } - - #[test] - fn unified_context_shows_comments_from_either_snapshot_side() { - for side in [crate::review::Side::Before, crate::review::Side::After] { - let mut diff = commented_diff(); - diff.set_view_mode(ViewMode::Unified); - diff.local_review - .document - .comments - .retain(|comment| comment.anchor.side == side); - let comment = &mut diff.local_review.document.comments[0]; - comment.anchor.start = crate::plugin::TextPosition { row: 2, column: 1 }; - comment.anchor.end = crate::plugin::TextPosition { row: 2, column: 3 }; - diff.local_review.selected = Some(comment.id); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap(); - render_diff(&mut terminal, &diff, &mut WheelPanels::default()); - assert!( - screen_text(&terminal).contains("●keep"), - "{side:?}: {}", - screen_text(&terminal) - ); - let buffer = terminal.backend().buffer(); - let marker = buffer - .content - .iter() - .position(|cell| cell.symbol() == "●") - .unwrap(); - for offset in 1..=4 { - assert_eq!( - buffer.content[marker + offset].bg == Color::Magenta, - (2..=3).contains(&offset), - "{side:?}: source column {}", - offset - 1 - ); - } - } - } - - #[test] - fn local_review_markers_yield_to_text_on_narrow_surfaces() { - for mode in [ViewMode::Split, ViewMode::Unified] { - for (width, height) in [(1, 1), (2, 2), (3, 3), (8, 4), (16, 8), (30, 12)] { - let mut diff = commented_diff(); - diff.set_view_mode(mode); - diff.word_wrap = true; - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - for (area, focus) in panels - .diff - .as_ref() - .into_iter() - .flat_map(|geometry| geometry.regions.into_iter().skip(1)) - { - assert!(area.right() <= width && area.bottom() <= height); - let content = diff_inner(area); - let file = &diff.comparison.as_ref().unwrap().files[0]; - let gutter = diff_gutter(&diff, file, content.width as usize, focus); - for y in content.y..content.bottom() { - for x in content.x..content.right() { - if matches!(terminal.backend().buffer()[(x, y)].symbol(), "●" | "○") - { - assert!(gutter > 0); - assert_eq!(x, content.x + gutter as u16 - 1); - } - } - } - } - } - } - } - - const LIGHT_DIFF_THEME: ThemeColors = ThemeColors { - background: [250, 245, 240], - red: [210, 30, 50], - green: [20, 150, 60], - }; - const DARK_DIFF_THEME: ThemeColors = ThemeColors { - background: [20, 25, 30], - red: [220, 60, 80], - green: [40, 180, 100], - }; - - #[test] - fn character_diff_backgrounds_preserve_common_unicode_and_syntax_across_viewports() { - for theme in [Some(LIGHT_DIFF_THEME), Some(DARK_DIFF_THEME), None] { - let mut diff = diff_comparison(vec![syntax_diff_file( - "changes.rs", - "let 界e\u{301} = old + old;", - "let 界e\u{301} = new + new;", - )]); - diff.files_visible = false; - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); - let mut panels = WheelPanels { - diff_colors: DiffColors::new(theme), - ..WheelPanels::default() - }; - render_diff(&mut terminal, &diff, &mut panels); - let row = panels.diff_colors.row; - let character = panels.diff_colors.character; - if let Some(theme) = theme { - for (index, accent) in [theme.red, theme.green].into_iter().enumerate() { - let Color::Rgb(r, g, b) = row[index] else { - panic!("theme row background must be RGB"); - }; - let Color::Rgb(cr, cg, cb) = character[index] else { - panic!("theme character background must be RGB"); - }; - for ((common, changed), (background, accent)) in [r, g, b] - .into_iter() - .zip([cr, cg, cb]) - .zip(theme.background.into_iter().zip(accent)) - { - assert!( - (background.min(accent)..=background.max(accent)).contains(&common) - ); - assert!( - (background.min(accent)..=background.max(accent)).contains(&changed) - ); - if background != accent { - assert!(changed.abs_diff(accent) < common.abs_diff(accent)); - } - } - } - } else { - assert_eq!(row, [Color::Reset; 2]); - assert_eq!(character, [Color::Reset; 2]); - } - for index in 0..2 { - let pane = panels.diff.as_ref().unwrap().regions[index + 1].0; - let buffer = terminal.backend().buffer(); - for column in [0, 3, 4, 6, 8, 14, 19] { - assert_eq!(buffer[(pane.x + 4 + column, pane.y + 1)].bg, row[index]); - } - for column in [10, 11, 12, 16, 17, 18] { - let cell = &buffer[(pane.x + 4 + column, pane.y + 1)]; - assert_eq!(cell.bg, character[index]); - assert_eq!(cell.fg, Color::Reset); - } - assert_eq!(buffer[(pane.x + 4, pane.y + 1)].fg, Color::Magenta); - assert_eq!(buffer[(pane.x + 8, pane.y + 1)].symbol(), "界"); - assert_eq!(buffer[(pane.x + 10, pane.y + 1)].symbol(), "e\u{301}"); - } - diff.left_before = 11; - diff.left_after = 11; - render_diff(&mut terminal, &diff, &mut panels); - let after = panels.diff.as_ref().unwrap().regions[2].0; - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), "e"); - assert_eq!(buffer[(after.x + 4, after.y + 1)].bg, character[1]); - assert_eq!(buffer[(after.x + 7, after.y + 1)].bg, row[1]); - diff.word_wrap = true; - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 12)).unwrap(); - render_diff(&mut terminal, &diff, &mut panels); - let after = panels.diff.as_ref().unwrap().regions[2].0; - let buffer = terminal.backend().buffer(); - let mut words = 0; - for y in after.y + 1..after.bottom() - 1 { - for x in after.x + 4..after.right().saturating_sub(3) { - if buffer[(x, y)].symbol() == "n" - && buffer[(x + 1, y)].symbol() == "e" - && buffer[(x + 2, y)].symbol() == "w" - { - words += 1; - for column in x..x + 3 { - assert_eq!(buffer[(column, y)].bg, character[1]); - assert_eq!(buffer[(column, y)].fg, Color::Reset); - } - } - } - } - assert_eq!(words, 2); - } - } - - #[test] - fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage() { - let mut diff = DiffState::new(); - diff.take_request(); - diff.apply_result(1, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap(); - let mut panels = WheelPanels::default(); - diff.handle(Input::End); - render_diff(&mut terminal, &diff, &mut panels); - let text = screen_text(&terminal); - assert!(text.contains("Choose Before")); - assert!(text.contains("Tab categories")); - assert!(text.contains("Enter select")); - assert!(text.contains("Working checkout · pinned")); - assert!(text.contains("> commit-19")); - assert!(!text.contains("commit-00")); - for ch in "needle".chars() { - diff.handle(Input::Char(ch)); - } - diff.handle(Input::Down); - render_diff(&mut terminal, &diff, &mut panels); - let text = screen_text(&terminal); - assert!(text.contains("> needle")); - assert!(text.contains("Working checkout · pinned")); - assert!(text.contains("> commit-19")); - diff.handle(Input::Enter); - render_diff(&mut terminal, &diff, &mut panels); - let text = screen_text(&terminal); - assert!(text.contains("Choose After")); - assert!(text.contains("Branch changes · Ctrl+b toggle · Before: commit-19")); - assert!(screen_text(&terminal).contains("Tab categories")); - assert!(screen_text(&terminal).contains("Enter select")); - diff.handle(Input::Ctrl('u')); - diff.handle(Input::Char('\t')); - render_diff(&mut terminal, &diff, &mut panels); - let text = screen_text(&terminal); - assert!(text.contains("feature-branch")); - assert!(!text.contains("ordinary change")); - assert!(text.contains("Working checkout · pinned")); - let buffer = terminal.backend().buffer(); - assert!( - buffer - .content - .iter() - .any(|cell| cell.symbol() == "B" && cell.bg == Color::Cyan) - ); - } - - #[test] - fn pull_request_picker_renders_all_rows_without_a_pinned_offset() { - use crate::diff::{Category, PullRequestPage, RepoKind, Revision, RevisionTarget}; - for (width, height) in [(100, 16), (44, 12)] { - let mut diff = DiffState::pull_requests("origin".into()); - diff.take_request(); - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - assert!(screen_text(&terminal).contains("loading")); - assert!(screen_text(&terminal).contains("[partial]")); - diff.apply_result(1, Err("offline".into())); - render_diff(&mut terminal, &diff, &mut panels); - assert!(screen_text(&terminal).contains("offline")); - assert!(!screen_text(&terminal).contains("No matching pull requests")); - diff.handle(Input::Enter); - diff.take_request(); - diff.apply_result( - 2, - Ok(crate::diff::Outcome::PullRequestPage { - closed: false, - result: Ok(PullRequestPage { - kind: RepoKind::Git, - remote: "origin".into(), - entries: (0..20) - .map(|index| { - ( - Revision { - target: RevisionTarget::PullRequest { - remote: "origin".into(), - number: index, - }, - label: format!("#{index:02} Pull request"), - description: "open · main ← topic".into(), - category: Category::Open, - }, - format!("2026-01-{:02}T00:00:00Z", 20 - index), - ) - }) - .collect(), - has_more: true, - }), - }), - ); - render_diff(&mut terminal, &diff, &mut panels); - let text = screen_text(&terminal); - assert!(text.contains("Pull requests")); - assert!(text.contains("Open") && text.contains("Closed") && text.contains("All")); - assert!(text.contains("> #00 Pull request")); - assert!(!text.contains("pinned")); - assert!(text.contains("[partial]")); - diff.handle(Input::End); - render_diff(&mut terminal, &diff, &mut panels); - let text = screen_text(&terminal); - assert!(text.contains("> #19 Pull request")); - assert!(!text.contains("#00 Pull request")); - diff.handle(Input::Home); - render_diff(&mut terminal, &diff, &mut panels); - assert!(screen_text(&terminal).contains("> #00 Pull request")); - diff.handle(Input::Char('\t')); - diff.apply_result( - 2, - Ok(crate::diff::Outcome::PullRequestPage { - closed: true, - result: Ok(PullRequestPage { - kind: RepoKind::Git, - remote: "origin".into(), - entries: vec![], - has_more: false, - }), - }), - ); - render_diff(&mut terminal, &diff, &mut panels); - assert!(screen_text(&terminal).contains("No matching pull requests")); - } - } - - #[test] - fn diff_excerpts_render_original_lines_and_expand_without_losing_changes() { - let before: Vec<_> = (0..80) - .map(|index| format!("let row_{index:03} = {index};")) - .collect(); - let mut after = before.clone(); - after[30] = "let first_change = 900;".into(); - after[60] = "let second_change = 901;".into(); - let mut diff = diff_comparison(vec![ChangedFile { - loaded: true, - before_eof: false, - after_eof: false, - path: "excerpts.rs".into(), - old_path: None, - status: FileStatus::Modified, - before, - after, - hunks: [30, 60] - .map(|index| crate::diff::Hunk { - before_start: index, - before_len: 1, - after_start: index, - after_len: 1, - }) - .into(), - notice: None, - }]); - diff.set_context_options(1, 3); - diff.files_visible = false; - diff.focus = Focus::Before; - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 24)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - let text = screen_text(&terminal); - for visible in [ - "30 let row_029", - "31- let row_030", - "31+ let first_change", - "61+ let second_change", - ] { - assert!(text.contains(visible), "missing {visible}: {text}"); - } - for hidden in ["row_028", "row_032", "row_058", "row_062"] { - assert!(!text.contains(hidden), "unexpected {hidden}: {text}"); - } - let before_area = panels.diff.as_ref().unwrap().regions[1].0; - let buffer = terminal.backend().buffer(); - assert_eq!( - buffer[(before_area.x + 5, before_area.y + 3)].fg, - Color::Magenta - ); - diff.handle(Input::Char('[')); - diff.handle(Input::Home); - render_diff(&mut terminal, &diff, &mut panels); - let text = screen_text(&terminal); - assert!(text.contains("row_026")); - assert!(!text.contains("row_025")); - assert!(text.contains("first_change")); - assert!(text.contains("second_change")); - } - - #[test] - fn diff_view_aligns_deletion_holes_numbers_colors_and_shared_vertical_scroll() { - let mut diff = diff_comparison(vec![deletion_file()]); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); - let mut panels = WheelPanels { - diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)), - ..WheelPanels::default() - }; - render_diff(&mut terminal, &diff, &mut panels); - let geometry = panels.diff.as_ref().unwrap(); - let before = geometry.regions[1].0; - let after = geometry.regions[2].0; - let buffer = terminal.backend().buffer(); - let y = before.y + 2; - assert_eq!(buffer[(before.x + 4, y)].symbol(), "g"); - assert_eq!(buffer[(before.x + 4, y)].fg, Color::Reset); - assert_eq!(buffer[(before.x + 2, y)].symbol(), "-"); - for x in before.x + 1..before.right() - 1 { - assert_eq!(buffer[(x, y + 1)].bg, Color::Reset); - } - let gap_symbols: Vec<_> = (after.x + 1..after.right() - 1) - .map(|x| { - let cell = &buffer[(x, y)]; - assert_eq!(cell.symbol(), "╱"); - assert_eq!(cell.bg, Color::Reset); - assert_eq!(cell.fg, buffer[(after.x + 1, y + 1)].fg); - cell.symbol().to_owned() - }) - .collect(); - assert_eq!(buffer[(before.x + 1, y + 1)].symbol(), "3"); - assert_eq!(buffer[(after.x + 1, y + 1)].symbol(), "2"); - assert_eq!(buffer[(before.x + 4, y + 1)].symbol(), "t"); - assert_eq!(buffer[(after.x + 4, y + 1)].symbol(), "t"); - diff.handle(Input::Char('\t')); - diff.handle(Input::Down); - render_diff(&mut terminal, &diff, &mut panels); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "g"); - for (x, symbol) in (after.x + 1..after.right() - 1).zip(gap_symbols) { - assert_eq!(buffer[(x, after.y + 1)].symbol(), symbol); - } - assert_eq!(buffer[(before.x, before.y)].fg, Color::Cyan); - assert_eq!(buffer[(after.x, after.y)].fg, Color::DarkGray); - assert_eq!(diff.focus, Focus::Before); - } - - #[test] - fn diff_row_tint_covers_blank_lines_and_wrapped_gutters() { - for wrapped in [false, true] { - let mut diff = diff_comparison(vec![syntax_diff_file( - "changed.txt", - "old old old old old old old old old old old old old old old old old old old old\n", - "new\n", - )]); - diff.files_visible = false; - diff.word_wrap = wrapped; - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 20)).unwrap(); - let mut panels = WheelPanels { - diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)), - ..WheelPanels::default() - }; - render_diff(&mut terminal, &diff, &mut panels); - for side in 0..2 { - let area = diff_inner(panels.diff.as_ref().unwrap().regions[side + 1].0); - let buffer = terminal.backend().buffer(); - let empty = (area.y..area.bottom()) - .find(|&y| buffer[(area.x, y)].symbol() == "2") - .unwrap(); - for y in area.y..=empty { - for x in area.x..area.x + 3 { - assert_eq!( - buffer[(x, y)].bg, - panels.diff_colors.row[side], - "{wrapped} {side} {x} {y}" - ); - } - } - for x in area.x..area.right() { - assert_eq!(buffer[(x, empty)].bg, panels.diff_colors.row[side]); - } - } - } - } - - #[test] - fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() { - let mut diff = diff_comparison(vec![ChangedFile { - loaded: true, - before_eof: false, - after_eof: false, - path: "added.txt".into(), - old_path: None, - status: FileStatus::Modified, - before: vec![], - after: vec!["first addition".into(), String::new()], - hunks: vec![crate::diff::Hunk { - before_start: 0, - before_len: 0, - after_start: 0, - after_len: 2, - }], - notice: None, - }]); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); - let mut panels = WheelPanels { - diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)), - ..WheelPanels::default() - }; - render_diff(&mut terminal, &diff, &mut panels); - let geometry = panels.diff.as_ref().unwrap(); - let before = geometry.regions[1].0; - let after = geometry.regions[2].0; - let buffer = terminal.backend().buffer(); - for offset in 0..2 { - let y = before.y + 1 + offset; - for x in before.x + 1..before.right() - 1 { - let cell = &buffer[(x, y)]; - assert_eq!(cell.symbol(), "╱"); - assert_eq!(cell.bg, Color::Reset); - } - assert_eq!(buffer[(after.x + 1, y)].symbol(), (offset + 1).to_string()); - assert_eq!(buffer[(after.x + 2, y)].symbol(), "+"); - assert_eq!(buffer[(after.x + 2, y)].fg, Color::Green); - assert_eq!(buffer[(after.x + 4, y)].fg, Color::Reset); - for x in after.x + 1..after.right() - 1 { - if offset == 1 && x >= after.x + 4 { - assert_eq!(buffer[(x, y)].symbol(), " "); - } - } - } - diff.top = 1; - diff.left_before = 1; - render_diff(&mut terminal, &diff, &mut panels); - let buffer = terminal.backend().buffer(); - for x in before.x + 1..before.right() - 1 { - assert_eq!(buffer[(x, before.y + 1)].symbol(), "╱"); - } - assert_eq!(buffer[(before.x, before.y + 1)].symbol(), "│"); - assert_eq!(buffer[(before.right() - 1, before.y + 1)].symbol(), "│"); - } - - #[test] - fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() { - let mut diff = diff_comparison(vec![ChangedFile { - loaded: true, - before_eof: false, - after_eof: false, - path: "changed.rs".into(), - old_path: None, - status: FileStatus::Modified, - before: [ - "fn old() {", - "\tlet word = \"界e\u{301}old\";", - "\tlet count = 42; //\u{1b}note", - "", - "}", - ] - .map(String::from) - .to_vec(), - after: [ - "fn new() {", - "\tlet word = \"界e\u{301}new\";", - "\tlet count = 24; //\u{1b}note", - "", - "}", - ] - .map(String::from) - .to_vec(), - hunks: vec![crate::diff::Hunk { - before_start: 0, - before_len: 5, - after_start: 0, - after_len: 5, - }], - notice: None, - }]); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 12)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - let geometry = panels.diff.as_ref().unwrap(); - let before = geometry.regions[1].0; - let after = geometry.regions[2].0; - let source_row = panels - .diff_wrap - .as_ref() - .unwrap() - .rows - .iter() - .position(|row| row.scope == [false; 2]) - .unwrap() as u16; - let buffer = terminal.backend().buffer(); - for pane in [before, after] { - for (column, row, foreground) in [ - (0, 0, Color::Magenta), - (3, 0, Color::LightBlue), - (4, 1, Color::Magenta), - (15, 1, Color::Green), - (16, 1, Color::Green), - (18, 1, Color::Green), - (16, 2, Color::Yellow), - (22, 2, Color::DarkGray), - (23, 2, Color::DarkGray), - ] { - assert_eq!( - buffer[(pane.x + 4 + column, pane.y + 1 + source_row + row)].fg, - foreground - ); - } - assert_eq!( - buffer[(pane.x + 20, pane.y + 2 + source_row)].symbol(), - "界" - ); - assert_eq!( - buffer[(pane.x + 22, pane.y + 2 + source_row)].symbol(), - "e\u{301}" - ); - assert_eq!(buffer[(pane.x + 26, pane.y + 3 + source_row)].symbol(), "�"); - assert_eq!(buffer[(pane.x + 27, pane.y + 3 + source_row)].symbol(), "n"); - for x in pane.x + 4..pane.right() - 1 { - assert_eq!(buffer[(x, pane.y + 4 + source_row)].symbol(), " "); - assert_eq!(buffer[(x, pane.y + 4 + source_row)].fg, Color::Reset); - } - } - diff.top = 1; - diff.left_before = 17; - diff.left_after = 16; - render_diff(&mut terminal, &diff, &mut panels); - let source_row = panels - .diff_wrap - .as_ref() - .unwrap() - .rows - .iter() - .position(|row| row.scope == [false; 2]) - .unwrap() as u16; - let buffer = terminal.backend().buffer(); - assert_eq!( - buffer[(before.x + 4, before.y + 1 + source_row)].symbol(), - " " - ); - assert_eq!( - buffer[(before.x + 4, before.y + 1 + source_row)].fg, - Color::Reset - ); - assert_eq!( - buffer[(before.x + 5, before.y + 1 + source_row)].symbol(), - "e\u{301}" - ); - assert_eq!( - buffer[(before.x + 5, before.y + 1 + source_row)].fg, - Color::Green - ); - assert_eq!( - buffer[(after.x + 4, after.y + 1 + source_row)].symbol(), - "界" - ); - assert_eq!( - buffer[(after.x + 4, after.y + 1 + source_row)].fg, - Color::Green - ); - assert_eq!( - buffer[(after.x + 6, after.y + 1 + source_row)].symbol(), - "e\u{301}" - ); - assert_eq!( - buffer[(after.x + 6, after.y + 1 + source_row)].fg, - Color::Green - ); - } - - #[test] - fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() { - let added = ChangedFile { - loaded: true, - before_eof: false, - after_eof: false, - path: "new-name.txt".into(), - old_path: Some("old-name.txt".into()), - status: FileStatus::Renamed, - before: vec!["\t界e\u{301}OLD".into()], - after: vec!["\t界e\u{301}NEW".into()], - hunks: vec![crate::diff::Hunk { - before_start: 0, - before_len: 1, - after_start: 0, - after_len: 1, - }], - notice: None, - }; - let mut diff = diff_comparison(vec![deletion_file(), added]); - diff.handle(Input::Down); - diff.handle(Input::Enter); - diff.handle(Input::BackTab); - for _ in 0..4 { - diff.handle(Input::Right); - } - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - let geometry = panels.diff.as_ref().unwrap(); - let before = geometry.regions[1].0; - let after = geometry.regions[2].0; - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "界"); - assert_eq!(buffer[(before.x + 6, before.y + 1)].symbol(), "e\u{301}"); - assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), " "); - assert_eq!(buffer[(after.x + 8, after.y + 1)].symbol(), "界"); - assert_eq!(buffer[(after.x + 8, after.y + 1)].fg, Color::Reset); - diff.handle(Input::Char('\t')); - render_diff(&mut terminal, &diff, &mut panels); - assert_eq!(diff.focus, Focus::After); - assert_eq!( - terminal.backend().buffer()[(after.x, after.y)].fg, - Color::Cyan - ); - } - - #[test] - fn diff_reuses_right_explorer_compact_tree_and_tracks_browse_focus() { - let mut leaf = deletion_file(); - leaf.path = "chain/a/b/leaf.txt".into(); - let mut root = deletion_file(); - root.path = "root.txt".into(); - let mut diff = diff_comparison(vec![leaf, root]); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - let regions = panels.diff.as_ref().unwrap().regions; - let navigator = regions[0].0; - let before = regions[1].0; - let after = regions[2].0; - let workspace = terminal.backend().buffer().area; - let (panes, dock) = dock_layout(workspace, DockSide::Right, true); - assert_eq!(Some(navigator), dock); - assert_eq!(before.x, panes.x); - assert_eq!(before.right(), after.x); - assert_eq!(after.right(), navigator.x); - assert_eq!(navigator.right(), workspace.right()); - assert_eq!(panels.files.unwrap().0, navigator); - let tree_text = |terminal: &Terminal| { - let buffer = terminal.backend().buffer(); - (navigator.y..navigator.bottom()) - .map(|y| { - (navigator.x..navigator.right()) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - }) - .collect::>() - .join("\n") - }; - let text = tree_text(&terminal); - assert!(text.contains("Explorer · Tree")); - assert!(text.contains("chain/a/b/")); - assert!(text.contains("leaf.txt")); - assert!(!text.contains("chain/a/b/leaf.txt")); - let cursor = terminal.get_cursor_position().unwrap(); - assert!(navigator.contains(cursor)); - assert_eq!( - terminal.backend().buffer()[(cursor.x, cursor.y)].bg, - Color::Blue - ); - assert_eq!( - terminal.backend().buffer()[(navigator.x, navigator.y)].fg, - Color::Cyan - ); - diff.handle(Input::Left); - diff.handle(Input::Enter); - render_diff(&mut terminal, &diff, &mut panels); - let text = tree_text(&terminal); - assert!(!text.contains("leaf.txt")); - assert!(text.contains("chain/a/b/")); - assert_eq!(diff.file, 0); - diff.handle(Input::Char('\t')); - render_diff(&mut terminal, &diff, &mut panels); - assert!(!diff.files.as_ref().unwrap().focused); - assert_eq!( - terminal.backend().buffer()[(navigator.x, navigator.y)].fg, - Color::DarkGray - ); - assert_eq!( - terminal.backend().buffer()[(before.x, before.y)].fg, - Color::Cyan - ); - } - - #[test] - fn changed_file_markers_follow_catalog_indices_in_every_explorer_view() { - let files: Vec<_> = [ - ("z.txt", FileStatus::Added), - ("chain/a/m.txt", FileStatus::Modified), - ("d.txt", FileStatus::Removed), - ("r.txt", FileStatus::Renamed), - ] - .into_iter() - .map(|(path, status)| { - let mut file = deletion_file(); - file.path = path.into(); - file.status = status; - file.old_path = (status == FileStatus::Renamed).then(|| "old\nname.txt".into()); - file - }) - .collect(); - let mut picker = FilePicker::from_paths( - PathBuf::new(), - files - .iter() - .enumerate() - .map(|(index, file)| (index, file.path.clone())), - ); - picker.reveal_path(std::path::Path::new("chain/a/m.txt")); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap(); - for view in [FileView::Tree, FileView::List] { - picker.set_view(view); - for focused in [true, false] { - picker.focused = focused; - for index in 0..files.len() { - picker.reveal_path(&files[index].path); - let reviewed = BTreeSet::from([index]); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - frame.area(), - "", - &mut WheelPanels::default(), - Some((&files, &reviewed)), - ); - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - for (catalog_index, (file, color)) in files - .iter() - .zip([Color::Green, Color::Yellow, Color::Red, Color::Cyan]) - .enumerate() - { - let path = if view == FileView::Tree { - std::path::Path::new(file.path.file_name().unwrap()) - } else { - file.path.as_path() - }; - let expected = if file.status == FileStatus::Renamed { - format!("R old�name.txt -> {}", file.path.to_string_lossy()) - } else { - format!("{} {}", diff_status(file.status), path.to_string_lossy()) - }; - let (x, y) = (0..16) - .find_map(|y| { - let row = (0..100) - .map(|x| buffer[(x, y)].symbol()) - .collect::(); - row.find(&expected).map(|offset| { - (UnicodeWidthStr::width(&row[..offset]) as u16, y) - }) - }) - .unwrap_or_else(|| panic!("missing {expected}")); - assert_eq!(buffer[(x, y)].fg, color); - assert_eq!( - buffer[(x - 2, y)].symbol(), - if catalog_index == index { "✓" } else { " " } - ); - assert_eq!(buffer[(x - 1, y)].symbol(), " "); - let depth = picker - .entries() - .find(|entry| entry.catalog_index == Some(catalog_index)) - .unwrap() - .depth; - assert_eq!( - x, - 3 + if view == FileView::Tree { - depth as u16 * 2 - } else { - 0 - } - ); - if catalog_index == index { - assert_eq!(buffer[(x - 2, y)].fg, Color::Green); - assert_eq!( - buffer[(x - 2, y)].bg, - if focused { - Color::Blue - } else { - Color::DarkGray - } - ); - } - } - for row in screen_text(&terminal) - .lines() - .filter(|row| row.contains("chain/a/")) - { - if view == FileView::Tree { - assert!(!row.contains('✓')); - } - } - assert_eq!(picker.accept(), Some(files[index].path.clone())); - assert!(picker.query.is_empty()); - } - } - } - // A reverted first file disappears without shifting the remaining status markers. - picker.replace_paths( - files - .iter() - .enumerate() - .skip(1) - .map(|(index, file)| (index, file.path.clone())), - ); - for view in [FileView::Tree, FileView::List] { - picker.set_view(view); - picker.query = "m.txt".into(); - picker.refilter(); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - frame.area(), - "", - &mut WheelPanels::default(), - Some((&files, &BTreeSet::from([1]))), - ); - }) - .unwrap(); - let text = screen_text(&terminal); - assert_eq!(text.matches('✓').count(), 1); - assert!(!text.contains("R r.txt")); - assert_eq!( - picker - .entries() - .find(|entry| !entry.is_dir) - .unwrap() - .catalog_index, - Some(1) - ); - } - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - frame.area(), - "", - &mut WheelPanels::default(), - None, - ); - }) - .unwrap(); - assert!(!screen_text(&terminal).contains("M chain/a/m.txt")); - assert!(!screen_text(&terminal).contains('✓')); - } - - #[test] - fn collapsed_diff_folders_reflect_all_catalog_descendants() { - let files: Vec<_> = [ - "src/top.rs", - "src/deep/leaf.rs", - "src/deep/other.rs", - "srcish/unreviewed.rs", - "src", - "compact/a/b/only.rs", - ] - .into_iter() - .map(|path| { - let mut file = deletion_file(); - file.path = path.into(); - file - }) - .collect(); - let paths = || { - files - .iter() - .enumerate() - .map(|(index, file)| (index, file.path.clone())) - }; - let mut picker = FilePicker::from_paths(PathBuf::new(), paths()); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 18)).unwrap(); - let mut check = |picker: &FilePicker, reviewed: &BTreeSet, label: &str, marked| { - terminal - .draw(|frame| { - draw_file_list( - frame, - picker, - frame.area(), - "", - &mut WheelPanels::default(), - Some((&files, reviewed)), - ); - }) - .unwrap(); - let text = screen_text(&terminal); - let row = text.lines().find(|row| row.contains(label)).unwrap(); - assert_eq!(row.contains('✓'), marked, "{row}"); - if marked { - let buffer = terminal.backend().buffer(); - let y = text.lines().position(|row| row.contains(label)).unwrap() as u16; - let x = (0..100).find(|&x| buffer[(x, y)].symbol() == "✓").unwrap(); - assert_eq!(buffer[(x, y)].fg, Color::Green); - } - }; - let mut reviewed = BTreeSet::from([0, 1]); - check(&picker, &reviewed, "src/", false); - reviewed.insert(2); - check(&picker, &reviewed, "src/", true); - check(&picker, &reviewed, "srcish/", false); - check(&picker, &reviewed, "compact/a/b/", false); - reviewed.insert(5); - check(&picker, &reviewed, "compact/a/b/", true); - - let selected = picker - .entries() - .position(|row| row.is_dir && row.path == std::path::Path::new("src")) - .unwrap(); - picker.selected = selected; - picker.right(); - check(&picker, &reviewed, "src/", false); - check(&picker, &reviewed, "deep/", true); - - reviewed.remove(&2); - picker.query = "leaf.rs".into(); - picker.refilter(); - let selected = picker - .entries() - .position(|row| row.is_dir && row.path == std::path::Path::new("src/deep")) - .unwrap(); - picker.selected = selected; - picker.accept(); - check(&picker, &reviewed, "deep/", false); - reviewed.insert(2); - check(&picker, &reviewed, "deep/", true); - reviewed.remove(&2); - check(&picker, &reviewed, "deep/", false); - - // A reverted file no longer in the picker must not block its folder's mark. - picker.replace_paths(paths().filter(|(index, _)| *index != 2)); - check(&picker, &reviewed, "deep/", true); - picker.accept(); - check(&picker, &reviewed, "deep/", false); - } - - #[test] - fn normal_file_markers_keep_clean_rows_and_match_diff_colors_in_every_view() { - use crate::repository::WorkingFileChange; - - let directory = Directory::new(); - let changes: Vec<_> = [ - ("z.txt", FileStatus::Added), - ("chain/a/m.txt", FileStatus::Modified), - ("gone/deep/d.txt", FileStatus::Removed), - ("r.txt", FileStatus::Renamed), - ] - .into_iter() - .map(|(path, status)| { - if status != FileStatus::Removed { - directory.file(path, ""); - } - WorkingFileChange { - path: path.into(), - status, - old_path: (status == FileStatus::Renamed).then(|| "old\nname.txt".into()), - } - }) - .collect(); - directory.file("clean.txt", ""); - let mut picker = FilePicker::scan( - directory.0.clone(), - FilePresentation::Floating, - FileView::List, - ) - .unwrap(); - picker.set_repository_changes(changes.clone()); - for change in &changes { - picker.reveal_path(&change.path); - } - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); - for presentation in [FilePresentation::Floating, FilePresentation::Docked] { - picker.presentation = presentation; - for view in [FileView::Tree, FileView::List] { - picker.set_view(view); - for focused in [true, false] { - picker.focused = focused; - for selected in changes - .iter() - .map(|change| change.path.as_path()) - .chain(std::iter::once(std::path::Path::new("clean.txt"))) - { - picker.reveal_path(selected); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - frame.area(), - "", - &mut WheelPanels::default(), - None, - ); - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains("clean.txt")); - assert!(!text.contains('✓')); - assert!(!text.contains("old\nname.txt")); - let buffer = terminal.backend().buffer(); - for (change, color) in changes.iter().zip([ - Color::Green, - Color::Yellow, - Color::Red, - Color::Cyan, - ]) { - let path = if view == FileView::Tree { - std::path::Path::new(change.path.file_name().unwrap()) - } else { - change.path.as_path() - }; - let expected = if change.status == FileStatus::Renamed { - "R old�name.txt -> r.txt".to_owned() - } else { - format!("{} {}", diff_status(change.status), path.to_string_lossy()) - }; - let (x, y) = (0..24) - .find_map(|y| { - let row = (0..100) - .map(|x| buffer[(x, y)].symbol()) - .collect::(); - row.find(&expected).map(|offset| { - (UnicodeWidthStr::width(&row[..offset]) as u16, y) - }) - }) - .unwrap_or_else(|| panic!("missing {expected}")); - assert_eq!(buffer[(x, y)].fg, color); - assert_eq!( - buffer[(x, y)].bg, - if change.path == selected { - if focused { - Color::Blue - } else { - Color::DarkGray - } - } else { - Color::Reset - } - ); - } - let clean_row = text.lines().find(|row| row.contains("clean.txt")).unwrap(); - assert!(clean_row.contains( - if selected == std::path::Path::new("clean.txt") { - "> clean.txt" - } else { - " clean.txt" - } - )); - } - for query in ["m.txt", "clean"] { - picker.query = query.into(); - picker.refilter(); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - frame.area(), - "", - &mut WheelPanels::default(), - None, - ); - }) - .unwrap(); - let text = screen_text(&terminal); - if query == "m.txt" { - assert!(text.contains("M ")); - assert!(!text.contains("clean.txt")); - } else { - assert!(text.contains("> clean.txt")); - assert!(!text.contains("M ")); - } - assert!(!text.contains('✓')); - picker.clear_filter(); - } - } - } - } - picker.refresh(); - assert!( - picker - .entries() - .any(|row| row.status == Some(FileStatus::Renamed)) - ); - picker.set_repository_changes(Vec::new()); - picker.set_view(FileView::List); - picker.reveal_path(std::path::Path::new("clean.txt")); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - frame.area(), - "", - &mut WheelPanels::default(), - None, - ); - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains("r.txt")); - assert!(!text.contains("old�name.txt")); - assert!(!text.contains("d.txt")); - assert!(!text.contains('✓')); - } - - #[test] - fn native_explorer_marks_only_confirmed_conflicts() { - let directory = Directory::new(); - directory.file("file.txt", ""); - let mut picker = FilePicker::scan( - directory.0.clone(), - FilePresentation::Floating, - FileView::List, - ) - .unwrap(); - picker.set_repository_changes(vec![crate::repository::WorkingFileChange { - path: "file.txt".into(), - status: FileStatus::Modified, - old_path: None, - }]); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); - for view in [FileView::List, FileView::Tree] { - picker.set_view(view); - for conflicted in [true, false] { - picker.set_conflicted(std::path::Path::new("file.txt"), conflicted); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - frame.area(), - "", - &mut WheelPanels::default(), - None, - ); - }) - .unwrap(); - let text = screen_text(&terminal); - let row = text.lines().find(|row| row.contains("file.txt")).unwrap(); - assert!(row.contains(if conflicted { - "C file.txt" - } else { - "M file.txt" - })); - if conflicted { - let buffer = terminal.backend().buffer(); - let (x, y) = (0..buffer.area.height) - .find_map(|y| { - (0..buffer.area.width) - .find(|&x| buffer[(x, y)].symbol() == "C") - .map(|x| (x, y)) - }) - .unwrap(); - assert_eq!(buffer[(x, y)].fg, Color::Yellow); - } - } - } - } - - #[test] - fn normal_file_names_stay_put_when_status_arrives_or_clears() { - let directory = Directory::new(); - directory.file("file.txt", ""); - let mut picker = FilePicker::scan( - directory.0.clone(), - FilePresentation::Floating, - FileView::List, - ) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); - for view in [FileView::List, FileView::Tree] { - picker.set_view(view); - let mut columns = Vec::new(); - for status in [None, Some(FileStatus::Modified), None] { - picker.set_repository_changes( - status - .into_iter() - .map(|status| crate::repository::WorkingFileChange { - path: "file.txt".into(), - status, - old_path: None, - }) - .collect(), - ); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - frame.area(), - "", - &mut WheelPanels::default(), - None, - ); - }) - .unwrap(); - let text = screen_text(&terminal); - columns.push(text.lines().find_map(|row| row.find("file.txt")).unwrap()); - } - assert_eq!(columns[0], columns[1], "status arrival moved the filename"); - assert_eq!(columns[1], columns[2], "clearing status moved the filename"); - } - } - - #[test] - fn normal_status_prefix_clips_without_review_columns_or_painting_outside_tiny_bounds() { - let directory = Directory::new(); - directory.file("界.txt", ""); - let mut picker = FilePicker::scan( - directory.0.clone(), - FilePresentation::Floating, - FileView::List, - ) - .unwrap(); - picker.set_repository_changes(vec![crate::repository::WorkingFileChange { - path: "界.txt".into(), - status: FileStatus::Modified, - old_path: None, - }]); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 4)).unwrap(); - for focused in [true, false] { - picker.focused = focused; - for height in 0..=3 { - for width in 0..=8 { - terminal - .draw(|frame| { - frame.render_widget( - Paragraph::new( - "XXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX", - ), - frame.area(), - ); - draw_file_list( - frame, - &picker, - Rect::new(0, 0, width, height), - "", - &mut WheelPanels::default(), - None, - ); - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - for y in 0..4 { - for x in 0..12 { - if x >= width || y >= height { - assert_eq!(buffer[(x, y)].symbol(), "X"); - } - } - } - if height < 2 { - continue; - } - for (x, symbol) in "> M ".chars().take(usize::from(width)).enumerate() { - assert_eq!(buffer[(x as u16, 1)].symbol(), symbol.to_string()); - } - if width >= 3 { - assert_eq!(buffer[(2, 1)].fg, Color::Yellow); - assert_eq!( - buffer[(2, 1)].bg, - if focused { - Color::Blue - } else { - Color::DarkGray - } - ); - } - if width >= 6 { - assert_eq!(buffer[(4, 1)].symbol(), "界"); - } - } - } - } - } - - #[test] - fn hiding_diff_explorer_reclaims_workspace_and_restores_its_geometry() { - let mut diff = diff_comparison(vec![deletion_file()]); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); - let mut panels = WheelPanels::default(); - render_diff(&mut terminal, &diff, &mut panels); - let original = panels.diff.as_ref().unwrap().regions; - diff.files_visible = false; - diff.focus = Focus::Before; - diff.files.as_mut().unwrap().focused = false; - render_diff(&mut terminal, &diff, &mut panels); - let hidden = panels.diff.as_ref().unwrap().regions; - assert_eq!(hidden[0].0, Rect::default()); - assert!(panels.files.is_none()); - assert_eq!(hidden[1].0.x, 0); - assert_eq!(hidden[1].0.right(), hidden[2].0.x); - assert_eq!(hidden[2].0.right(), 120); - assert!(hidden[1].0.width + hidden[2].0.width > original[1].0.width + original[2].0.width); - assert!(!screen_text(&terminal).contains("Explorer")); - diff.files_visible = true; - diff.focus = Focus::Files; - diff.files.as_mut().unwrap().focused = true; - render_diff(&mut terminal, &diff, &mut panels); - assert_eq!(panels.diff.as_ref().unwrap().regions, original); - assert_eq!(panels.files.unwrap().0, original[0].0); - } - - #[test] - fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() { - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); - let mut panels = WheelPanels::default(); - let mut diff = DiffState::new(); - render_diff(&mut terminal, &diff, &mut panels); - assert!(screen_text(&terminal).contains("Loading repository")); - diff.take_request(); - diff.apply_result(1, Err("permission denied".into())); - render_diff(&mut terminal, &diff, &mut panels); - assert!(screen_text(&terminal).contains("permission denied")); - let empty = diff_comparison(vec![]); - render_diff(&mut terminal, &empty, &mut panels); - assert!(screen_text(&terminal).contains("No changed files")); - assert!(screen_text(&terminal).contains("Explorer · Tree")); - let binary = diff_comparison(vec![ChangedFile { - loaded: true, - before_eof: false, - after_eof: false, - path: "image.bin".into(), - old_path: None, - status: FileStatus::Added, - before: vec![], - after: vec![], - hunks: vec![], - notice: Some("Binary file; text comparison unavailable".into()), - }]); - render_diff(&mut terminal, &binary, &mut panels); - assert!(screen_text(&terminal).contains("Binary file")); - let blank = diff_comparison(vec![ChangedFile { - loaded: true, - before_eof: false, - after_eof: false, - path: "empty.txt".into(), - old_path: None, - status: FileStatus::Added, - before: vec![], - after: vec![], - hunks: vec![], - notice: None, - }]); - render_diff(&mut terminal, &blank, &mut panels); - assert!(screen_text(&terminal).contains("Empty file")); - let mut unloaded_file = deletion_file(); - unloaded_file.loaded = false; - let mut unloaded = diff_comparison(vec![unloaded_file]); - unloaded.loading = false; - render_diff(&mut terminal, &unloaded, &mut panels); - assert!(screen_text(&terminal).contains("File details unavailable")); - for state in [&empty, &binary, &blank, &unloaded] { - render_diff(&mut terminal, state, &mut panels); - let pane = panels - .diff - .as_ref() - .unwrap() - .regions - .into_iter() - .skip(1) - .find(|(pane, _)| *pane != Rect::default()) - .unwrap() - .0; - assert!( - pane_screen( - &terminal, - Rect::new(pane.x, pane.bottom() - 1, pane.width, 1), - ) - .contains("old revision → Working checkout") - ); - assert!( - pane_screen(&terminal, Rect::new(pane.right() - 13, pane.y, 12, 1),) - .contains("read-only") - ); - } - for (width, height) in [(1, 1), (2, 2), (3, 3), (8, 4), (39, 5)] { - let mut tiny = - Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); - for state in [&diff, &empty, &binary, &blank, &unloaded] { - render_diff(&mut tiny, state, &mut panels); - if let Some(geometry) = &panels.diff { - for (rect, _) in geometry.regions { - assert!(rect.right() <= width && rect.bottom() <= height); - } - let [navigator, before, after] = geometry.regions.map(|(rect, _)| rect); - if before.width > 0 && after.width > 0 { - assert!(before.right() <= after.x); - } - if navigator.width > 0 { - assert!(before.right().max(after.right()) <= navigator.x); - } - } - } - } - } - - #[test] - fn immutable_diff_draw_path_preserves_document_until_explicit_close() { - let directory = Directory::new(); - let mut editor = Editor::open( - Some(directory.file("source.txt", "original\nsecond")), - vec![], - ) - .unwrap(); - let original = editor.lines().to_vec(); - let cursor = editor.cursor(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - editor.execute("diff"); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert!(screen_text(&terminal).contains("Choose Before")); - assert!(viewports.document.geometry.is_none()); - editor.handle(Input::Enter); - editor.handle(Input::Enter); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result( - id, - Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { - root: std::path::PathBuf::new(), - editable_paths: Default::default(), - before: "old revision".into(), - after: "Working checkout".into(), - revisions: ["before-snapshot".into(), "after-snapshot".into()], - files: vec![deletion_file()], - })), - ); - editor.handle(Input::Paste("must not edit".into())); - editor.handle(Input::Char('i')); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert!(screen_text(&terminal).contains("deleted-lines.txt")); - assert!(viewports.document.geometry.is_none()); - editor.handle(Input::Escape); - assert!(editor.diff().is_some()); - editor.execute("diff.close"); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert!(screen_text(&terminal).contains("original")); - assert!(viewports.document.geometry.is_some()); - assert_eq!(editor.lines(), original); - assert_eq!(editor.cursor(), cursor); - } - - fn pane_screen(terminal: &Terminal, area: Rect) -> String { - let buffer = terminal.backend().buffer(); - (area.y..area.bottom()) - .map(|y| { - (area.x..area.right()) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - }) - .collect::>() - .join("\n") - } - - #[test] - fn tab_bar_shows_shared_dirty_titles_and_reserves_document_and_overlay_rows() { - let directory = Directory::new(); - let mut editor = - Editor::open(Some(directory.file("a\tname.txt", "DOCUMENT")), vec![]).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(views.document.geometry.unwrap().1.y, 0); - assert!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)).contains("DOCUMENT")); - - editor.execute("tab.new"); - editor.handle(Input::Char('i')); - editor.handle(Input::Paste("changed ".into())); - editor.handle(Input::Escape); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let titles = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); - assert!(titles.contains("1 a�name.txt [+]"), "{titles}"); - assert!(titles.contains("2 a�name.txt [+]"), "{titles}"); - assert_eq!(views.document.geometry.unwrap().1.y, 1); - assert!(terminal.get_cursor_position().unwrap().y >= 1); - assert!(pane_screen(&terminal, views.rects[0].1).contains("changed DOCUMENT")); - - editor.handle(Input::Ctrl('p')); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)), titles); - assert!(terminal.get_cursor_position().unwrap().y >= 1); - assert!(screen_text(&terminal).contains("Commands")); - } - - fn configure_chrome( - editor: &mut Editor, - update: impl FnOnce(&mut ChromeLine, &mut ChromeLine), - ) { - let mut top = editor.top_bar_config().clone(); - let mut bottom = editor.bottom_bar_config().clone(); - update(&mut top, &mut bottom); - editor - .set_chrome_config(top, bottom, BTreeMap::new()) - .unwrap(); - } - - #[test] - fn home_abbreviation_matches_components_and_only_changes_display() { - use std::path::Path; - let home = Path::new("/users/λ"); - for (path, expected) in [ - ("/users/λ", "~"), - ("/users/λ/", "~"), - ("/users/λ/src/file.rs", "~/src/file.rs"), - ("/users/λ-other/file.rs", "/users/λ-other/file.rs"), - ("/else/users/λ/file.rs", "/else/users/λ/file.rs"), - ("users/λ/file.rs", "users/λ/file.rs"), - ] { - assert_eq!(abbreviate_home(Path::new(path), Some(home)), expected); - assert_eq!(abbreviate_home(Path::new(path), None), path); - } - let directory = Directory::new(); - let path = directory.file("λ\t{index}.rs", "source"); - let editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - let groups = chrome_groups(&editor, editor.bottom_bar_config(), &SyntaxCache::new()); - let title = chrome_title("{path}", 2, Some(&path), false, None); - assert_eq!(title, display_text(&display_path(&path))); - assert!(groups.iter().flatten().any(|item| item.text == title)); - assert_eq!(editor.path(), Some(path.as_path())); - assert_eq!(std::fs::read_to_string(&path).unwrap(), "source"); - } - - #[test] - fn native_lsp_names_are_borrowed_in_either_bar_and_ignore_configured_labels() { - let mut editor = Editor::open(None, vec![]).unwrap(); - configure_chrome(&mut editor, |top, bottom| { - for line in [top, bottom] { - line.left = vec!["lsp".into()]; - line.center.clear(); - line.right.clear(); - } - }); - editor - .set_label("lsp".into(), "not a running server".into()) - .unwrap(); - for line in [editor.top_bar_config(), editor.bottom_bar_config()] { - assert!(chrome_groups(&editor, line, &SyntaxCache::new())[0].is_empty()); - } - editor.set_lsp_names(vec!["server one".into(), "server two".into()]); - for line in [editor.top_bar_config(), editor.bottom_bar_config()] { - let groups = chrome_groups(&editor, line, &SyntaxCache::new()); - assert_eq!(groups[0].len(), 1); - assert!(matches!( - groups[0][0].text, - Cow::Borrowed("server one + server two") - )); - } - } - - #[test] - fn bottom_position_has_only_a_leading_margin_and_lsp_has_none() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_lsp_names(vec!["server".into()]); - configure_chrome(&mut editor, |top, bottom| { - top.left.clear(); - top.center.clear(); - top.right = vec!["position".into()]; - bottom.left.clear(); - bottom.center.clear(); - bottom.right = vec!["lsp".into(), "position".into()]; - }); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 2)).unwrap(); - terminal - .draw(|frame| { - for (line, y) in [ - (editor.top_bar_config(), 0), - (editor.bottom_bar_config(), 1), - ] { - let groups = chrome_groups(&editor, line, &SyntaxCache::new()); - let start = 40 - chrome_group_width(&groups[2]) as u16; - if y == 1 { - let gap = start + groups[2][0].width as u16; - draw_chrome(frame, groups, Rect::new(0, y, 40, 1)); - assert_eq!(frame.buffer_mut()[(start, y)].bg, Color::Cyan); - assert_eq!(frame.buffer_mut()[(gap, y)].symbol(), " "); - assert_eq!(frame.buffer_mut()[(gap, y)].bg, Color::Reset); - assert_eq!(frame.buffer_mut()[(gap + 1, y)].symbol(), "░"); - } else { - draw_chrome(frame, groups, Rect::new(0, y, 40, 1)); - assert_eq!(frame.buffer_mut()[(start, y)].symbol(), "░"); - } - assert_eq!(frame.buffer_mut()[(39, y)].bg, Color::Cyan); - } - }) - .unwrap(); - } - - #[test] - fn bar_margins_surround_styling_and_keep_anchors_and_narrow_content() { - let directory = Directory::new(); - let config = directory.file( - "margins.rb", - r#" -Vamp.configure do |config| - options = {left: [:badge], center: [:lsp], right: [:badge], - badge: {padding: 0, margin: 2, leading_edge: "<", trailing_edge: ">"}, lsp: {padding: 0, margin: 1}} - config.bar.top = options - config.bar.bottom = options -end -"#, - ); - let config = crate::ruby::load_config(&config).unwrap(); - let mut editor = Editor::open(None, vec![]).unwrap(); - editor - .set_chrome_config(config.top_bar, config.bottom_bar, config.labels) - .unwrap(); - editor.set_label("badge".into(), "界".into()).unwrap(); - editor.set_lsp_names(vec!["server".into()]); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(41, 3)).unwrap(); - terminal - .draw(|frame| { - for (line, y) in [ - (editor.top_bar_config(), 0), - (editor.bottom_bar_config(), 2), - ] { - draw_chrome( - frame, - chrome_groups(&editor, line, &SyntaxCache::new()), - Rect::new(0, y, 41, 1), - ); - } - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - for y in [0, 2] { - for x in [0, 1, 6, 7, 16, 23, 33, 34, 39, 40] { - assert_eq!(buffer[(x, y)].symbol(), " "); - assert_eq!(buffer[(x, y)].bg, Color::Reset); - } - for x in [3, 36] { - assert_eq!(buffer[(x, y)].symbol(), "界"); - assert_eq!(buffer[(x, y)].bg, Color::Cyan); - } - for (x, symbol) in [(2, "<"), (5, ">"), (35, "<"), (38, ">")] { - assert_eq!(buffer[(x, y)].symbol(), symbol); - assert_eq!(buffer[(x, y)].fg, Color::Cyan); - } - for (offset, ch) in "server".chars().enumerate() { - assert_eq!(buffer[(17 + offset as u16, y)].symbol(), ch.to_string()); - assert_eq!(buffer[(17 + offset as u16, y)].bg, Color::Cyan); - } - } - configure_chrome(&mut editor, |top, bottom| { - for line in [top, bottom] { - line.left = vec!["lsp".into()]; - line.center.clear(); - line.right.clear(); - let lsp = line.segments.get_mut("lsp").unwrap(); - lsp.margin = [64; 2]; - lsp.padding = 64; - } - }); - let mut narrow = Terminal::new(ratatui::backend::TestBackend::new(6, 2)).unwrap(); - narrow - .draw(|frame| { - for (line, y) in [ - (editor.top_bar_config(), 0), - (editor.bottom_bar_config(), 1), - ] { - draw_chrome( - frame, - chrome_groups(&editor, line, &SyntaxCache::new()), - Rect::new(0, y, 6, 1), - ); - } - }) - .unwrap(); - for y in [0, 1] { - assert_eq!(pane_screen(&narrow, Rect::new(0, y, 6, 1)), "server"); - } - editor.set_lsp_names(Vec::new()); - for line in [editor.top_bar_config(), editor.bottom_bar_config()] { - assert!(chrome_groups(&editor, line, &SyntaxCache::new())[0].is_empty()); - } - } - - #[test] - fn floating_fields_use_border_titles_and_inner_horizontal_padding() { - for (prefix, title) in [(':', "Command"), ('/', "Search")] { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.handle(Input::Char(prefix)); - for ch in "needle".chars() { - editor.handle(Input::Char(ch)); - } - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); - let workspace = Rect::new(3, 2, 74, 8); - let mut panel = Rect::default(); - terminal - .draw(|frame| { - panel = draw_floating_input(frame, &editor, workspace).unwrap(); - }) - .unwrap(); - assert_eq!(panel.x, workspace.x); - assert_eq!(panel.bottom(), workspace.bottom()); - assert!( - pane_screen(&terminal, Rect::new(panel.x, panel.y, panel.width, 1)).contains(title) - ); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(panel.x + 1, panel.y + 1)].symbol(), " "); - assert_eq!(buffer[(panel.x + 2, panel.y + 1)].symbol(), "n"); - assert_eq!(buffer[(panel.right() - 2, panel.y + 1)].symbol(), " "); - assert_eq!( - terminal.get_cursor_position().unwrap(), - Position::new(panel.x + 8, panel.y + 1) - ); - assert_eq!( - pane_screen( - &terminal, - Rect::new(panel.x + 1, panel.y + 1, panel.width - 2, 1) - ) - .trim(), - "needle" - ); - } - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_message("operation complete"); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); - let mut panel = Rect::default(); - terminal - .draw(|frame| { - panel = draw_notification(frame, &editor, frame.area()).unwrap(); - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(panel.x + 1, panel.y + 1)].symbol(), " "); - assert_eq!(buffer[(panel.x + 2, panel.y + 1)].symbol(), "o"); - assert_eq!(buffer[(panel.right() - 2, panel.y + 1)].symbol(), " "); - assert_eq!(panel.right(), 80); - assert_eq!(panel.bottom(), 12); - } - - #[test] - fn configured_floating_inputs_paint_each_anchor_independently_inside_the_workspace() { - let anchors = [ - (PopupAnchor::TopLeft, 0, 0), - (PopupAnchor::TopCenter, 14, 0), - (PopupAnchor::TopRight, 29, 0), - (PopupAnchor::CenterLeft, 0, 5), - (PopupAnchor::Center, 14, 5), - (PopupAnchor::CenterRight, 29, 5), - (PopupAnchor::BottomLeft, 0, 10), - (PopupAnchor::BottomCenter, 14, 10), - (PopupAnchor::BottomRight, 29, 10), - ]; - for (index, &(command_anchor, command_x, command_y)) in anchors.iter().enumerate() { - let (search_anchor, search_x, search_y) = anchors[(index + 1) % anchors.len()]; - for (prefix, title, x, y) in [ - (':', "Command", command_x, command_y), - ('/', "Search", search_x, search_y), - ] { - let mut editor = Editor::open(None, vec![]).unwrap(); - let mut popup = *editor.popup_config(); - popup.command.anchor = command_anchor; - popup.search.anchor = search_anchor; - editor.set_popup_config(popup); - editor.set_message("hidden notification"); - editor.handle(Input::Char(prefix)); - for ch in "needle".chars() { - editor.handle(Input::Char(ch)); - } - for (width, height) in [ - (101, 13), - (12, 3), - (3, 2), - (2, 1), - (1, 1), - (0, 1), - (1, 0), - (0, 0), - ] { - let workspace = Rect::new(5, 4, width, height); - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap(); - let mut panels = WheelPanels::default(); - terminal - .draw(|frame| { - frame.render_widget( - Paragraph::new(vec![Line::from(".".repeat(120)); 24]), - frame.area(), - ); - draw_overlays(frame, &editor, workspace, None, &mut panels); - }) - .unwrap(); - assert!(panels.notification.is_none()); - if width == 0 || height == 0 { - assert!(panels.input.is_none()); - assert_eq!( - terminal.backend().buffer()[(workspace.x, workspace.y)].symbol(), - "." - ); - continue; - } - let expected = if width == 101 { - Rect::new(workspace.x + x, workspace.y + y, 72, 3) - } else { - workspace - }; - let panel = panels.input.unwrap(); - assert_eq!( - panel, expected, - "{prefix} {command_anchor:?} {search_anchor:?}" - ); - assert_eq!(panel.intersection(workspace), panel); - let cursor = terminal.get_cursor_position().unwrap(); - assert!(panel.contains(cursor)); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(0, 0)].symbol(), "."); - assert_eq!(buffer[(cursor.x, cursor.y)].symbol(), " "); - if width >= 12 { - assert_eq!(buffer[(panel.x, panel.y)].symbol(), "┌"); - assert_eq!(buffer[(panel.right() - 1, panel.y)].symbol(), "┐"); - assert_eq!(buffer[(panel.x, panel.bottom() - 1)].symbol(), "└"); - assert_eq!( - buffer[(panel.right() - 1, panel.bottom() - 1)].symbol(), - "┘" - ); - assert!( - pane_screen(&terminal, Rect::new(panel.x, panel.y, panel.width, 1)) - .contains(title) - ); - assert_eq!(buffer[(panel.x + 1, panel.y + 1)].symbol(), " "); - assert_eq!(buffer[(panel.x + 2, panel.y + 1)].symbol(), "n"); - assert_eq!( - pane_screen( - &terminal, - Rect::new(panel.x + 1, panel.y + 1, panel.width - 2, 1) - ) - .trim(), - "needle" - ); - assert_eq!(cursor, Position::new(panel.x + 8, panel.y + 1)); - } else { - let cursor_x = u16::from(width > 1); - assert_eq!(cursor, Position::new(panel.x + cursor_x, panel.y)); - if width == 2 { - assert_eq!(buffer[(panel.x, panel.y)].symbol(), "e"); - } - } - } - } - } - } - - #[test] - fn floating_inputs_share_workspace_geometry_across_documents_splits_and_diffs() { - for context in ["document", "split", "diff", "split-diff", "selector"] { - for prefix in [':', '/'] { - if context == "selector" && prefix == '/' { - continue; - } - let mut editor = Editor::open(None, vec![]).unwrap(); - editor - .apply(vec![crate::editor::Effect::Replace("DOCUMENT".into())]) - .unwrap(); - if context.starts_with("split") { - editor.handle(Input::Char('|')); - } - if context.ends_with("diff") { - install_readonly_diff(&mut editor, deletion_file()); - editor.handle(Input::Char('\t')); - assert!(editor.diff_interacting()); - } else if context == "selector" { - editor.execute("diff"); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); - } - editor.set_message("saved notification"); - if context == "selector" { - editor - .bind("normal", "ctrl+t", Some("core.command".into())) - .unwrap(); - editor.handle(Input::Ctrl('t')); - } else { - editor.handle(Input::Char(prefix)); - } - let query = format!("{}TAIL", "界e\u{301}👩‍🚀".repeat(30)); - for ch in query.chars() { - editor.handle(Input::Char(ch)); - } - assert!(matches!(editor.mode(), Mode::Command | Mode::Search)); - assert_eq!(editor.command_line(), query); - let before = (editor.lines().to_vec(), editor.cursor(), editor.carets()); - let mut views = BufferViewports::default(); - for (width, height) in [(100, 9), (12, 3), (3, 2), (1, 1), (0, 1), (1, 0)] { - let area = Rect::new(3, 2, width, height); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(110, 16), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(views.panels.notification.is_none()); - if width == 0 || height == 0 { - assert!(views.panels.input.is_none()); - assert!(views.document.geometry.is_none()); - continue; - } - let panel = views.panels.input.unwrap(); - let cursor = terminal.get_cursor_position().unwrap(); - assert_eq!(panel.intersection(area), panel); - assert!( - panel.contains(cursor), - "{context} {area:?} {panel:?} {cursor:?}" - ); - assert!(panel.width <= 72 && panel.height <= 3); - assert_eq!( - terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), - " " - ); - if width >= 12 { - assert!(pane_screen(&terminal, panel).contains("TAIL")); - } - assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " "); - assert_eq!( - before, - (editor.lines().to_vec(), editor.cursor(), editor.carets()) - ); - } - editor.handle(Input::Escape); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.message(), "saved notification"); - } - } - } - - #[test] - fn cancelling_floating_inputs_restores_obscured_document_cells() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor - .apply(vec![crate::editor::Effect::Replace( - vec!["DOCUMENT".repeat(15); 20].join("\n"), - )]) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let baseline = screen_text(&terminal); - let geometry = views.document.geometry; - let caret = views.panels.caret; - for prefix in [':', '/'] { - editor.handle(Input::Char(prefix)); - for ch in "long input 界e\u{301}👩‍🚀".chars() { - editor.handle(Input::Char(ch)); - } - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(views.panels.input.is_some()); - assert_eq!(views.document.geometry, geometry); - assert_eq!(views.panels.caret, caret); - editor.handle(Input::Escape); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(views.panels.input.is_none()); - assert_eq!(screen_text(&terminal), baseline); - assert_eq!(views.document.geometry, geometry); - assert_eq!(views.panels.caret, caret); - } - } - - #[test] - fn notification_popup_persists_blocks_wheels_and_restores_real_document_carets() { - let mut session = EditorSession::open(None, vec![], None).unwrap(); - session.editor_mut().set_message(""); - session - .editor_mut() - .apply(vec![crate::editor::Effect::Replace( - "DOCUMENT\n".repeat(20), - )]) - .unwrap(); - session.handle(Input::Ctrl('e')); - session.handle(Input::Ctrl('e')); - assert_eq!(session.editor().selection_count(), 3); - let area = Rect::new(3, 2, 100, 12); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(110, 18), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - let baseline = screen_text(&terminal); - let geometry = views.document.geometry; - let caret = terminal.get_cursor_position().unwrap(); - let carets = session.editor().carets(); - let offsets = views.offsets.clone(); - let pane = session.editor().pane_id(); - session - .editor_mut() - .set_message("write failed: permission denied"); - for _ in 0..3 { - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - let popup = views.panels.notification.unwrap(); - assert_eq!(popup.right(), area.right()); - assert_eq!(popup.bottom(), views.rects[0].1.bottom()); - assert!(popup.bottom() < area.bottom()); - assert!(popup.x > geometry.unwrap().1.x); - assert!(pane_screen(&terminal, popup).contains("write failed: permission denied")); - assert_eq!(views.document.geometry, geometry); - assert_eq!(terminal.get_cursor_position().unwrap(), caret); - for kind in [ - MouseEventKind::ScrollUp, - MouseEventKind::ScrollDown, - MouseEventKind::ScrollLeft, - MouseEventKind::ScrollRight, - ] { - assert!(views.scroll(&mut session, document_wheel(kind, popup.x, popup.y))); - } - assert_eq!(views.offsets, offsets); - assert_eq!(session.editor().pane_id(), pane); - assert_eq!(session.editor().carets(), carets); - } - session.handle(Input::Escape); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(views.panels.notification.is_none()); - assert_eq!(session.editor().carets(), carets); - assert_eq!( - session.editor().message(), - "write failed: permission denied" - ); - assert_eq!(screen_text(&terminal), baseline); - session.handle(Input::Escape); - assert_eq!(session.editor().selection_count(), 1); - session.editor_mut().set_message("new operation"); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(views.panels.notification.is_some()); - - for prefix in [':', '/'] { - session.handle(Input::Char(prefix)); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - let input = views.panels.input.unwrap(); - assert!(views.panels.notification.is_none()); - let caret = terminal.get_cursor_position().unwrap(); - let mode = session.editor().mode(); - for kind in [ - MouseEventKind::ScrollUp, - MouseEventKind::ScrollDown, - MouseEventKind::ScrollLeft, - MouseEventKind::ScrollRight, - ] { - assert!(views.scroll(&mut session, document_wheel(kind, input.x, input.y))); - } - assert_eq!(views.offsets, offsets); - assert_eq!(session.editor().mode(), mode); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap(), caret); - session.handle(Input::Escape); - } - } - - #[test] - fn notification_hides_covered_hardware_cursor_without_changing_its_document_geometry() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor - .apply(vec![crate::editor::Effect::Replace("DOCUMENT".into())]) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 4)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let caret = views.panels.caret; - let geometry = views.document.geometry; - assert!(terminal.backend().cursor_visible()); - editor.set_message("A long operation notification wraps over the document"); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!( - views - .panels - .notification - .unwrap() - .contains(caret.unwrap().1) - ); - assert!(!terminal.backend().cursor_visible()); - assert_eq!(views.panels.caret, caret); - assert_eq!(views.document.geometry, geometry); - editor.handle(Input::Escape); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(terminal.backend().cursor_visible()); - assert_eq!(terminal.get_cursor_position().unwrap(), caret.unwrap().1); - assert_eq!(views.document.geometry, geometry); - } - - #[test] - fn notifications_yield_to_existing_overlays_and_stay_bounded_when_tiny() { - for overlay in [ - "palette", - "hover", - "completion", - "files", - "startup", - "selector", - ] { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_message("LAST OPERATION"); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(views.panels.notification.is_some()); - match overlay { - "palette" => { - editor.handle(Input::Ctrl('p')); - } - "hover" => { - editor.show_hover("Hover details".into()).unwrap(); - } - "completion" => { - use crate::plugin::{CompletionEdit, CompletionItem, TextPosition}; - editor.handle(Input::Char('i')); - editor - .show_completion(vec![CompletionItem { - label: "completion".into(), - detail: None, - primary_edit: 0, - snippet: None, - edits: vec![CompletionEdit { - start: TextPosition { row: 0, column: 0 }, - end: TextPosition { row: 0, column: 0 }, - text: "completion".into(), - }], - }]) - .unwrap(); - } - "files" => { - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('o')); - } - "startup" => editor.show_startup_diagnostic("Configuration error".into()), - _ => { - editor.execute("diff"); - } - } - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(views.panels.notification.is_none(), "{overlay}"); - assert!( - !screen_text(&terminal).contains("LAST OPERATION"), - "{overlay}" - ); - } - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_message(format!("{}\tEND", "界e\u{301} operation ".repeat(50))); - for (width, height) in [(100, 20), (12, 3), (3, 2), (1, 1), (0, 1), (1, 0)] { - let area = Rect::new(3, 2, width, height); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(110, 26), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - terminal - .draw(|frame| { - let popup = draw_notification(frame, &editor, area); - if width == 0 || height == 0 { - assert!(popup.is_none()); - } else { - let popup = popup.unwrap(); - assert_eq!(popup.intersection(area), popup); - assert_eq!(popup.right(), area.right()); - assert_eq!(popup.bottom(), area.bottom()); - assert!(popup.width <= 60 && popup.height <= 8); - } - }) - .unwrap(); - assert!(!screen_text(&terminal).contains("END")); - } - } - - #[test] - fn comparison_titles_follow_loaded_counts_and_each_tabs_context() { - let directory = Directory::new(); - let path = directory.file("source.rs", "fn main() {}"); - for phase in ["loading", "before", "after", "unloaded", "view"] { - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.execute("tab.new"); - editor.execute("diff"); - if phase != "loading" { - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); - } - if matches!(phase, "after" | "unloaded" | "view") { - editor.handle(Input::Down); - editor.handle(Input::Enter); - } - if matches!(phase, "unloaded" | "view") { - editor.handle(Input::Down); - editor.handle(Input::Enter); - let (id, _) = editor.take_diff_request().unwrap(); - let mut file = syntax_diff_file( - "source.rs", - "old\ncontext\nremove", - "new\ncontext\nextra\nmore", - ); - file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); - if phase == "unloaded" { - file.loaded = false; - file.before.clear(); - file.after.clear(); - file.hunks.clear(); - } - editor.apply_diff_result( - id, - Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { - root: directory.0.clone(), - editable_paths: Default::default(), - before: "baseline".into(), - after: "historic".into(), - revisions: ["before-snapshot".into(), "after-snapshot".into()], - files: vec![file], - })), - ); - } - let view = if phase == "view" { - "Diff · +3 -2 · " - } else { - "Diff · " - }; - let default_tabs = editor.top_bar_config().segment("tabs").clone(); - for (template, ordinary, prefix, uses_view) in [ - (None, "1 source.rs", "2 ", true), - (Some("{index} {name}{modified}"), "1 source.rs", "2 ", false), - ( - Some("T{index}:{view}{name}{modified}"), - "T1:source.rs", - "T2:", - true, - ), - ] { - configure_chrome(&mut editor, |top, bottom| { - top.segments.insert("tabs".into(), default_tabs.clone()); - if let Some(template) = template { - top.segments.get_mut("tabs").unwrap().title = template.into(); - } - bottom.left = vec!["mode".into()]; - bottom.center.clear(); - bottom.right.clear(); - bottom.segments.get_mut("mode").unwrap().title = "Current:{view}{name}".into(); - }); - for comparison_active in [true, false] { - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(160, 2)).unwrap(); - terminal - .draw(|frame| { - for (line, y) in [ - (editor.top_bar_config(), 0), - (editor.bottom_bar_config(), 1), - ] { - draw_chrome( - frame, - chrome_groups(&editor, line, &SyntaxCache::new()), - Rect::new(0, y, 160, 1), - ); - } - }) - .unwrap(); - let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1)); - assert!(titles.contains(ordinary), "{phase}: {titles}"); - let comparison = - format!("{prefix}{}source.rs", if uses_view { view } else { "" }); - assert!(titles.contains(&comparison), "{phase}: {titles}"); - assert_eq!( - titles.matches("Diff · ").count(), - usize::from(uses_view), - "{phase}: {titles}" - ); - let current = pane_screen(&terminal, Rect::new(0, 1, 160, 1)); - let expected = if comparison_active { - format!("Current:{view}source.rs") - } else { - "Current:source.rs".into() - }; - assert!(current.contains(&expected), "{phase}: {current}"); - editor.execute(if comparison_active { - "tab.previous" - } else { - "tab.next" - }); - } - } - } - } - - #[test] - fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() { - let directory = Directory::new(); - let path = directory.file("live.rs", "new"); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - editor.execute("diff"); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); - editor.handle(Input::Down); - editor.handle(Input::Enter); - editor.handle(Input::Enter); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result( - id, - Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { - root: directory.0.clone(), - editable_paths: ["live.rs".into()].into(), - before: "baseline".into(), - after: "Working checkout".into(), - revisions: ["before-snapshot".into(), "after-snapshot".into()], - files: vec![syntax_diff_file("live.rs", "old", "new")], - })), - ); - editor.handle(Input::Char('\t')); - editor.handle(Input::Char('\t')); - assert!(editor.diff_editing()); - editor.execute("tab.new"); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); - let mut views = BufferViewports::default(); - for (inputs, counts) in [ - (vec![], "+1 -1"), - ( - vec![ - Input::Char('i'), - Input::Paste("extra\n".into()), - Input::Escape, - ], - "+2 -1", - ), - (vec![Input::Char('u')], "+1 -1"), - (vec![Input::Char('U')], "+2 -1"), - ] { - for input in inputs { - editor.handle(input); - } - editor.refresh_live_diff(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let titles = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); - assert!( - titles.contains(&format!("1 Diff · {counts} · live.rs")), - "{titles}" - ); - assert!(titles.contains("2 live.rs"), "{titles}"); - } - } - - #[test] - fn tab_titles_update_templates_without_reinterpreting_file_names() { - let directory = Directory::new(); - let path = directory.file("λ{index}\t.rs", "source"); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.execute("tab.new"); - editor.handle(Input::Char('i')); - editor.handle(Input::Paste("changed ".into())); - editor.handle(Input::Escape); - configure_chrome(&mut editor, |top, _| { - let tabs = top.segments.get_mut("tabs").unwrap(); - tabs.title = "T{index}:{name}{modified}".into(); - tabs.padding = 0; - tabs.leading_edge.clear(); - tabs.trailing_edge.clear(); - }); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 3)).unwrap(); - terminal - .draw(|frame| { - draw_chrome( - frame, - chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), - frame.area(), - ); - }) - .unwrap(); - let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1)); - assert!(titles.contains("T1:λ{index}�.rs [+]"), "{titles}"); - assert!(titles.contains("T2:λ{index}�.rs [+]"), "{titles}"); - - configure_chrome(&mut editor, |top, _| { - top.segments.get_mut("tabs").unwrap().title = "{index}:{path}{modified}".into(); - }); - terminal - .draw(|frame| { - draw_chrome( - frame, - chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), - frame.area(), - ); - }) - .unwrap(); - let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1)); - let expected = display_text(&format!("2:{} [+]", path.display())); - assert!(titles.contains(&expected), "{titles}"); - assert!(!titles.contains("T2:"), "{titles}"); - } - - #[test] - fn chrome_anchors_center_on_the_full_row_and_preserve_right_order() { - let mut editor = Editor::open(None, vec![]).unwrap(); - configure_chrome(&mut editor, |top, bottom| { - top.left = vec!["tabs".into(), "left".into()]; - top.center = vec!["middle".into()]; - top.right = vec!["one".into(), "two".into()]; - bottom.left = vec!["left".into()]; - bottom.center = top.center.clone(); - bottom.right = top.right.clone(); - for line in [top, bottom] { - for name in ["left", "middle", "one", "two"] { - line.segments.insert( - name.into(), - SegmentConfig { - title: String::new(), - padding: 0, - margin: [0; 2], - leading_edge: String::new(), - trailing_edge: String::new(), - first_leading_edge: None, - }, - ); - } - } - }); - for (name, text) in [ - ("left", "LEFTEND"), - ("middle", "MID"), - ("one", "A"), - ("two", "BC"), - ] { - editor.set_label(name.into(), text.into()).unwrap(); - } - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(51, 4)).unwrap(); - let syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw_chrome( - frame, - chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), - frame.area(), - ); - draw_chrome( - frame, - chrome_groups(&editor, editor.bottom_bar_config(), &syntax), - Rect::new(3, 2, 45, 1), - ); - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - for y in [0, 2] { - assert_eq!(buffer[(24, y)].symbol(), "M"); - assert_eq!(buffer[(25, y)].symbol(), "I"); - assert_eq!(buffer[(26, y)].symbol(), "D"); - } - assert!(pane_screen(&terminal, Rect::new(0, 0, 51, 1)).ends_with("ABC")); - assert!(pane_screen(&terminal, Rect::new(3, 2, 45, 1)).ends_with("ABC")); - - editor.set_label("left".into(), "L".repeat(30)).unwrap(); - terminal - .draw(|frame| { - draw_chrome( - frame, - chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), - frame.area(), - ); - draw_chrome( - frame, - chrome_groups(&editor, editor.bottom_bar_config(), &syntax), - Rect::new(3, 2, 45, 1), - ); - }) - .unwrap(); - for (y, x, width) in [(0, 0, 51), (2, 3, 45)] { - let row = pane_screen(&terminal, Rect::new(x, y, width, 1)); - assert!(!row.contains("MID"), "{row}"); - assert!(row.ends_with("ABC"), "{row}"); - } - } - - #[test] - fn active_tabs_keep_their_anchor_and_text_when_optional_groups_collide() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.execute("tab.new"); - configure_chrome(&mut editor, |top, bottom| { - top.left = vec!["prefix".into()]; - top.center = vec!["tabs".into()]; - top.right = vec!["suffix".into()]; - let tabs = top.segments.get_mut("tabs").unwrap(); - tabs.title = "{index}:active".into(); - tabs.padding = 64; - tabs.leading_edge = "界".repeat(8); - tabs.trailing_edge = "界".repeat(8); - *bottom = top.clone(); - }); - editor - .set_label("prefix".into(), "LEFT".repeat(32)) - .unwrap(); - editor - .set_label("suffix".into(), "RIGHT".repeat(32)) - .unwrap(); - for width in [40, 8, 1, 0] { - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 2)).unwrap(); - terminal - .draw(|frame| { - for (y, line) in [editor.top_bar_config(), editor.bottom_bar_config()] - .into_iter() - .enumerate() - { - draw_chrome( - frame, - chrome_groups(&editor, line, &SyntaxCache::new()), - Rect::new(0, y as u16, width, 1), - ); - } - }) - .unwrap(); - for y in 0..2 { - if width == 0 { - continue; - } - let row = pane_screen(&terminal, Rect::new(0, y, width, 1)); - assert!( - row.trim() - .starts_with(if width == 1 { "2" } else { "2:active" }), - "{row}" - ); - assert_eq!( - terminal.backend().buffer()[((width - width.min(8)) / 2, y)].symbol(), - "2" - ); - assert!(!row.contains("LEFT") && !row.contains("RIGHT"), "{row}"); - assert!(!row.contains('界'), "{row}"); - } - } - configure_chrome(&mut editor, |top, bottom| { - let tabs = top.segments.get_mut("tabs").unwrap(); - tabs.padding = 0; - tabs.leading_edge.clear(); - tabs.trailing_edge.clear(); - *bottom = top.clone(); - }); - editor - .set_label("prefix".into(), "LEFT".repeat(32)) - .unwrap(); - editor - .set_label("suffix".into(), "RIGHT".repeat(32)) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 2)).unwrap(); - terminal - .draw(|frame| { - for (y, line) in [editor.top_bar_config(), editor.bottom_bar_config()] - .into_iter() - .enumerate() - { - draw_chrome( - frame, - chrome_groups(&editor, line, &SyntaxCache::new()), - Rect::new(0, y as u16, 40, 1), - ); - } - }) - .unwrap(); - for y in 0..2 { - let row = pane_screen(&terminal, Rect::new(0, y, 40, 1)); - assert_eq!(&row[16..24], "2:active"); - } - } - - #[test] - fn chrome_uses_cell_widths_for_independent_edges_and_internal_padding() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.execute("tab.new"); - configure_chrome(&mut editor, |top, bottom| { - top.right = vec!["badge".into()]; - top.segments.insert( - "tabs".into(), - SegmentConfig { - title: "{index}".into(), - padding: 2, - margin: [0; 2], - leading_edge: "界▌".into(), - trailing_edge: "▐界".into(), - first_leading_edge: Some("<".into()), - }, - ); - top.segments.insert( - "badge".into(), - SegmentConfig { - title: String::new(), - padding: 2, - margin: [0; 2], - leading_edge: "界(".into(), - trailing_edge: ")界".into(), - first_leading_edge: None, - }, - ); - bottom.left.clear(); - bottom.center.clear(); - bottom.right = vec!["badge".into()]; - bottom - .segments - .insert("badge".into(), top.segments["badge"].clone()); - }); - editor.set_label("badge".into(), "e\u{301}".into()).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 3)).unwrap(); - let syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw_chrome( - frame, - chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), - frame.area(), - ); - draw_chrome( - frame, - chrome_groups(&editor, editor.bottom_bar_config(), &syntax), - Rect::new(0, 2, 40, 1), - ); - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - for (x, text) in [ - (2, "1"), - (5, "界"), - (7, "▌"), - (10, "2"), - (13, "▐"), - (14, "界"), - (29, "界"), - (31, "("), - (34, "e\u{301}"), - (37, ")"), - (38, "界"), - ] { - assert_eq!(buffer[(x, 0)].symbol(), text, "cell {x}"); - } - for (x, text) in [ - (29, "界"), - (31, "("), - (34, "e\u{301}"), - (37, ")"), - (38, "界"), - ] { - assert_eq!(buffer[(x, 2)].symbol(), text, "status cell {x}"); - } - for x in [32, 33, 35, 36] { - assert_eq!(buffer[(x, 2)].symbol(), " "); - assert_eq!(buffer[(x, 2)].bg, Color::Cyan); - } - assert_eq!(buffer[(2, 0)].bg, Color::Reset); - assert_eq!(buffer[(10, 0)].bg, Color::Cyan); - assert_eq!(buffer[(5, 0)].bg, Color::Reset); - for x in [8, 9, 11, 12, 32, 33, 35, 36] { - assert_eq!(buffer[(x, 0)].symbol(), " "); - assert_eq!(buffer[(x, 0)].bg, Color::Cyan); - } - editor.execute("tab.previous"); - terminal - .draw(|frame| { - draw_chrome( - frame, - chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), - frame.area(), - ); - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(0, 0)].symbol(), "<"); - assert_eq!(buffer[(3, 0)].symbol(), "1"); - assert_eq!(buffer[(3, 0)].bg, Color::Cyan); - assert_eq!(buffer[(11, 0)].symbol(), "2"); - assert_eq!(buffer[(11, 0)].bg, Color::Reset); - } - - #[test] - fn empty_bar_content_reclaims_both_rows_even_while_prompting() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.execute("tab.new"); - configure_chrome(&mut editor, |top, bottom| { - top.left.clear(); - top.center = vec!["empty".into()]; - top.right.clear(); - top.segments.insert( - "empty".into(), - SegmentConfig { - title: "Not visible without a value".into(), - padding: 64, - margin: [0; 2], - leading_edge: "░▒▓".into(), - trailing_edge: "▓▒░".into(), - first_leading_edge: None, - }, - ); - *bottom = top.clone(); - }); - let mut syntax = SyntaxCache::new(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); - terminal - .draw(|frame| { - let workspace = draw_bars(frame, &editor, frame.area(), &mut syntax); - assert_eq!(workspace, frame.area()); - }) - .unwrap(); - editor.set_label("empty".into(), "present".into()).unwrap(); - terminal - .draw(|frame| { - let workspace = draw_bars(frame, &editor, frame.area(), &mut syntax); - assert_eq!(workspace, Rect::new(0, 1, 40, 3)); - }) - .unwrap(); - for y in [0, 4] { - let title = pane_screen(&terminal, Rect::new(0, y, 40, 1)); - assert!(title.contains("Not visible without a value"), "{title}"); - assert!(!title.contains('▓'), "{title}"); - } - editor.handle(Input::Char(':')); - terminal - .draw(|frame| { - let workspace = draw_bars(frame, &editor, frame.area(), &mut syntax); - assert_eq!(workspace, Rect::new(0, 1, 40, 3)); - }) - .unwrap(); - } - - #[test] - fn tiny_bars_yield_to_document_and_prompt_identically_in_either_position() { - let directory = Directory::new(); - let path = directory.file("source.txt", "DOCUMENT"); - for segment in ["tabs", "mode"] { - for at_top in [false, true] { - for prompting in [false, true] { - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.execute("tab.new"); - configure_chrome(&mut editor, |top, bottom| { - for line in [&mut *top, &mut *bottom] { - line.left.clear(); - line.center.clear(); - line.right.clear(); - } - let line = if at_top { top } else { bottom }; - line.left = vec![segment.into()]; - }); - if prompting { - editor.handle(Input::Char(':')); - } - for height in 1..=4 { - let area = Rect::new(3, 2, 80, height); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(100, 10), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let bar_text = match segment { - "tabs" => "2 source.txt", - _ if prompting => "COMMAND", - _ => "NORMAL", - }; - let visible = height >= 2; - assert_eq!(screen_text(&terminal).contains(bar_text), visible); - let cursor = terminal.get_cursor_position().unwrap(); - assert!( - area.contains(cursor), - "{segment} {at_top} prompting={prompting} {area:?}: {cursor:?}" - ); - let viewport = views.document.geometry.unwrap().1; - assert_eq!(viewport.height, height - u16::from(visible)); - if prompting { - let input = views.panels.input.unwrap(); - assert!(input.contains(cursor)); - } else { - assert!(viewport.contains(cursor)); - assert!(pane_screen(&terminal, viewport).contains("DOCUMENT")); - } - if visible { - let y = if at_top { area.y } else { area.bottom() - 1 }; - assert!( - pane_screen(&terminal, Rect::new(area.x, y, area.width, 1)) - .contains(bar_text) - ); - } - } - } - } - } - } - - #[test] - fn tabs_only_singletons_hide_in_either_bar_and_reappear_with_another_tab() { - let mut editor = Editor::open(None, vec![]).unwrap(); - configure_chrome(&mut editor, |top, bottom| *bottom = top.clone()); - let mut views = BufferViewports::default(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(views.rects[0].1, Rect::new(0, 0, 80, 12)); - assert!(views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, 0),)); - - editor.execute("tab.new"); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(views.rects[0].1, Rect::new(0, 1, 80, 10)); - for y in [0, 11] { - assert!( - !views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, y),) - ); - } - let caret = terminal.get_cursor_position().unwrap(); - assert!(views.rects[0].1.contains(caret)); - for y in [0, 11] { - let bar = pane_screen(&terminal, Rect::new(0, y, 80, 1)); - assert!( - bar.contains("1 [No Name]") && bar.contains("2 [No Name]"), - "{bar}" - ); - } - editor.execute("tab.close"); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(views.rects[0].1, Rect::new(0, 0, 80, 12)); - assert!(!screen_text(&terminal).contains("[No Name]")); - for y in [0, 11] { - assert!( - views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, y),) - ); - } - } - - #[test] - fn swapped_bars_render_active_metadata_and_language_on_the_first_paint() { - let directory = Directory::new(); - let path = directory.file("active.rs", "fn main() {}"); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.execute("tab.new"); - editor.handle(Input::Char('i')); - editor.handle(Input::Paste("// changed\n".into())); - editor.handle(Input::Escape); - configure_chrome(&mut editor, std::mem::swap); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 12)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let top = pane_screen(&terminal, Rect::new(0, 0, 240, 1)); - assert!(top.contains("NORMAL"), "{top}"); - assert!(top.contains(&path.display().to_string()), "{top}"); - assert!(top.contains("[+]") && top.contains("Rust"), "{top}"); - let cursor = editor.cursor(); - assert!( - top.contains(&format!("{}:{}", cursor.row + 1, cursor.column + 1)), - "{top}" - ); - let bottom = pane_screen(&terminal, Rect::new(0, 11, 240, 1)); - assert!( - bottom.contains("1 active.rs [+]") && bottom.contains("2 active.rs [+]"), - "{bottom}" - ); - assert_eq!(views.document.geometry.unwrap().1.y, 1); - let before = views.rects[0].1; - editor.handle(Input::Char(':')); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(views.rects[0].1, before); - let input = views.panels.input.unwrap(); - assert!(input.contains(terminal.get_cursor_position().unwrap())); - assert_eq!(pane_screen(&terminal, Rect::new(0, 11, 240, 1)), bottom); - } - - #[test] - fn both_bars_bound_palettes_diff_selectors_docks_and_diff_prompts() { - let directory = Directory::new(); - let mut editor = - Editor::open(Some(directory.file("source.rs", "fn main() {}")), vec![]).unwrap(); - editor.execute("tab.new"); - configure_chrome(&mut editor, |top, bottom| { - std::mem::swap(top, bottom); - top.left = vec!["mode".into(), "language".into()]; - top.center = vec!["workspace".into()]; - top.right = vec!["position".into()]; - bottom.right = vec!["battery".into()]; - }); - editor - .set_label("workspace".into(), "WORKSPACE".into()) - .unwrap(); - editor.set_label("battery".into(), "BOTTOM".into()).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); - let mut views = BufferViewports::default(); - let assert_bars = |terminal: &Terminal, - views: &BufferViewports| { - assert!(pane_screen(terminal, Rect::new(0, 0, 120, 1)).contains("WORKSPACE")); - let bottom_y = 29; - assert!(pane_screen(terminal, Rect::new(0, bottom_y, 120, 1)).contains("BOTTOM")); - for (_, rect) in &views.rects { - assert!(rect.y >= 1 && rect.bottom() <= bottom_y); - } - if let Some(geometry) = &views.panels.diff { - for (rect, _) in geometry.regions { - if rect.height > 0 { - assert!(rect.y >= 1 && rect.bottom() <= bottom_y); - } - } - } - if let Some((rect, _, _)) = views.panels.files { - assert!(rect.y >= 1 && rect.bottom() <= bottom_y); - } - }; - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)).contains("Rust")); - assert_bars(&terminal, &views); - editor.handle(Input::Ctrl('p')); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(screen_text(&terminal).contains("Commands")); - assert_bars(&terminal, &views); - editor.handle(Input::Escape); - - editor.execute("diff"); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(screen_text(&terminal).contains("Choose Before")); - assert!(views.document.geometry.is_none()); - assert_bars(&terminal, &views); - editor.handle(Input::Enter); - editor.handle(Input::Enter); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result( - id, - Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { - root: directory.0.clone(), - editable_paths: Default::default(), - before: "baseline".into(), - after: "Working checkout".into(), - revisions: ["before-snapshot".into(), "after-snapshot".into()], - files: vec![deletion_file()], - })), - ); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(views.panels.files.is_some()); - assert_bars(&terminal, &views); - editor.handle(Input::Char(':')); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let cursor = terminal.get_cursor_position().unwrap(); - assert!(Rect::new(0, 1, 120, 28).contains(cursor), "{cursor:?}"); - assert_bars(&terminal, &views); - editor.handle(Input::Escape); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('o')); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!( - editor.file_picker().unwrap().presentation, - FilePresentation::Floating - ); - assert_bars(&terminal, &views); - } - - #[test] - fn narrow_tab_bar_keeps_the_active_title_visible_and_handles_empty_areas() { - let directory = Directory::new(); - let mut editor = Editor::open( - Some(directory.file("a-very-long-inactive-title.txt", "first")), - vec![], - ) - .unwrap(); - editor.execute("tab.new"); - editor.execute("tab.new"); - editor.execute(&format!( - "edit {}", - directory.file("active.txt", "ACTIVE").display() - )); - editor.set_battery_charge(Some(27)); - let mut views = BufferViewports::default(); - for (width, height) in [(12, 9), (7, 9), (1, 9), (0, 9), (12, 1), (12, 0)] { - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - if width > 0 && height >= 3 { - let title = pane_screen(&terminal, Rect::new(0, 0, width, 1)); - assert!(title.trim_start().starts_with('3'), "{title}"); - assert!(!title.contains("bat 27%"), "{title}"); - } - if width > 0 && height == 1 { - let (_, viewport) = views.document.geometry.unwrap(); - assert!(viewport.contains(terminal.get_cursor_position().unwrap())); - assert!(pane_screen(&terminal, viewport).contains("ACTIVE")); - } - } - - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.execute("tab.new"); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let titles = pane_screen(&terminal, Rect::new(0, 0, 40, 1)); - assert!(titles.contains("1 [No Name]"), "{titles}"); - assert!(titles.contains("2 [No Name]"), "{titles}"); - } - - #[test] - fn inline_blame_sanitizes_metadata_preserves_text_and_hides_stale_labels() { - let directory = Directory::new(); - let mut editor = Editor::open( - Some(directory.file("source.txt", "界 e\u{301}\nsecond")), - vec![], - ) - .unwrap(); - let source = editor.blame_source().unwrap(); - assert!(editor.set_line_blame( - source, - Arc::new(crate::blame::LineBlame { - commit: "0123456789abcdef".into(), - author: "A\tuthor".into(), - summary: "Summary\n\u{1b}done".into(), - uncommitted: false, - }) - )); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 8)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let screen = screen_text(&terminal); - assert!( - screen.contains("界") && screen.contains("e\u{301}"), - "{screen}" - ); - assert!( - screen.contains("A�uthor · Summary��done (01234567)"), - "{screen}" - ); - let caret = terminal.get_cursor_position().unwrap(); - let cell = terminal.backend().buffer()[(caret.x, caret.y)].clone(); - editor.handle(Input::Down); - assert!(editor.line_blame().is_none()); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(!screen_text(&terminal).contains("Summary")); - editor.handle(Input::Up); - assert_eq!( - editor.line_blame(), - Some("A�uthor · Summary��done (01234567)") - ); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(screen_text(&terminal).contains("A�uthor · Summary��done (01234567)")); - assert_eq!(terminal.get_cursor_position().unwrap(), caret); - assert_eq!(terminal.backend().buffer()[(caret.x, caret.y)], cell); - } - - #[test] - fn inline_blame_uses_last_wrap_continuation_and_never_overflows_narrow_panes() { - let directory = Directory::new(); - let mut editor = - Editor::open(Some(directory.file("source.txt", &"x".repeat(60))), vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor.execute("word_wrap.toggle"); - let source = editor.blame_source().unwrap(); - editor.set_line_blame( - source, - Arc::new(crate::blame::LineBlame { - commit: String::new(), - author: String::new(), - summary: String::new(), - uncommitted: true, - }), - ); - let mut views = BufferViewports::default(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 8)).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let caret = terminal.get_cursor_position().unwrap(); - assert!(!pane_screen(&terminal, Rect::new(0, caret.y, 50, 1)).contains("Uncommitted")); - assert!(pane_screen(&terminal, Rect::new(0, caret.y + 1, 50, 1)).contains("Uncommitted")); - let cell = terminal.backend().buffer()[(16, caret.y + 1)].clone(); - assert_eq!(cell.symbol(), "U"); - assert_eq!(cell.fg, Color::DarkGray); - let mut narrow = Terminal::new(ratatui::backend::TestBackend::new(12, 8)).unwrap(); - narrow.draw(|frame| views.draw(frame, &mut editor)).unwrap(); - assert!(!screen_text(&narrow).contains("Uncommitted")); - assert!(screen_text(&narrow).contains("xxx")); - editor.execute("git_blame.toggle"); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(!screen_text(&terminal).contains("Uncommitted")); - let mut historical = readonly_diff_editor(deletion_file()); - assert!(historical.blame_source().is_none()); - historical.execute("tab.new"); - assert!(historical.diff().is_none()); - assert!(historical.blame_source().is_none()); - } - - #[test] - fn inline_blame_disappears_when_wheel_scroll_hides_the_primary_caret() { - let directory = Directory::new(); - let text = (0..40) - .map(|row| format!("row {row}")) - .collect::>() - .join("\n"); - let mut session = - EditorSession::open(Some(directory.file("source.txt", &text)), vec![], None).unwrap(); - let source = session.editor().blame_source().unwrap(); - session.editor_mut().set_line_blame( - source, - Arc::new(crate::blame::LineBlame { - commit: String::new(), - author: String::new(), - summary: String::new(), - uncommitted: true, - }), - ); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 8)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(screen_text(&terminal).contains("Uncommitted")); - let area = views.document.geometry.unwrap().1; - assert!(views.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, area.x + 2, area.y + 2) - )); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(!screen_text(&terminal).contains("Uncommitted")); - assert_eq!(session.editor().cursor().row, 0); - } - - #[test] - fn default_native_battery_and_custom_override_redraw_through_clear() { - let directory = Directory::new(); - let mut editor = - Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let original = screen_text(&terminal); - assert_eq!(views.document.geometry.unwrap().1.y, 0); - assert_eq!(editor.tab_count(), 1); - assert!(!screen_text(&terminal).contains("bat ")); - assert!(editor.set_battery_charge(Some(27))); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); - assert!(title.trim_end().ends_with("bat 27%"), "{title}"); - assert_eq!(views.document.geometry.unwrap().1.y, 1); - - editor - .set_label("battery".into(), "電池 e\u{301}\t90%".into()) - .unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); - assert!(title.contains("1 source.txt"), "{title}"); - assert!(title.contains("電") && title.contains("池"), "{title}"); - assert!(title.contains("e\u{301}�90%"), "{title}"); - assert!(!title.contains("bat 27%"), "{title}"); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(78, 0)].symbol(), "%"); - assert_eq!(buffer[(79, 0)].symbol(), " "); - assert_eq!(views.document.geometry.unwrap().1.y, 1); - assert!(pane_screen(&terminal, views.rects[0].1).contains("DOCUMENT")); - assert!(terminal.get_cursor_position().unwrap().y >= 1); - - editor.set_label("battery".into(), "live".into()).unwrap(); - assert!(editor.set_battery_charge(Some(28))); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!( - pane_screen(&terminal, Rect::new(0, 0, 80, 1)) - .trim_end() - .ends_with("live") - ); - assert!(!screen_text(&terminal).contains("90%")); - editor.set_label("battery".into(), String::new()).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); - assert!(title.trim_end().ends_with("bat 28%"), "{title}"); - assert!(!title.contains("live"), "{title}"); - assert_eq!(views.document.geometry.unwrap().1.y, 1); - assert!(editor.set_battery_charge(None)); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(views.document.geometry.unwrap().1.y, 0); - assert_eq!(screen_text(&terminal), original); - } - - #[test] - fn native_battery_renders_at_each_configured_anchor_in_either_bar() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_battery_charge(Some(27)); - for at_top in [true, false] { - for anchor in 0..3 { - configure_chrome(&mut editor, |top, bottom| { - for line in [&mut *top, &mut *bottom] { - line.left.clear(); - line.center.clear(); - line.right.clear(); - } - let line = if at_top { top } else { bottom }; - [&mut line.left, &mut line.center, &mut line.right][anchor] - .push("battery".into()); - line.segments.insert( - "battery".into(), - SegmentConfig { - padding: 0, - ..SegmentConfig::default() - }, - ); - }); - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(48, 6)).unwrap(); - terminal - .draw(|frame| { - draw_bars(frame, &editor, frame.area(), &mut SyntaxCache::new()); - }) - .unwrap(); - let y = if at_top { 0 } else { 5 }; - let row = pane_screen(&terminal, Rect::new(0, y, 48, 1)); - assert_eq!(row.trim(), "bat 27%"); - let x = [0, 20, 41][anchor]; - assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), "b"); - } - } - configure_chrome(&mut editor, |top, bottom| { - top.right.clear(); - bottom.right.clear(); - }); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 6)).unwrap(); - terminal - .draw(|frame| { - let workspace = draw_bars(frame, &editor, frame.area(), &mut SyntaxCache::new()); - assert_eq!(workspace, frame.area()); - }) - .unwrap(); - assert!(!screen_text(&terminal).contains("bat 27%")); - } - - #[test] - fn custom_value_keeps_a_gap_and_yields_to_the_full_active_title() { - let directory = Directory::new(); - let mut editor = Editor::open( - Some(directory.file("a-very-long-inactive-title.txt", "first")), - vec![], - ) - .unwrap(); - editor.execute("tab.new"); - editor.execute("tab.new"); - editor.execute(&format!( - "edit {}", - directory.file("active.txt", "ACTIVE").display() - )); - configure_chrome(&mut editor, |top, _| { - let tabs = top.segments.get_mut("tabs").unwrap(); - tabs.leading_edge = "<<<".into(); - tabs.trailing_edge = ">>>".into(); - top.segments.get_mut("battery").unwrap().leading_edge = "<<<".into(); - }); - editor.set_label("battery".into(), "90%".into()).unwrap(); - for width in [28, 29, 80] { - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 2)).unwrap(); - terminal - .draw(|frame| { - let area = frame.area(); - draw_chrome( - frame, - chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), - area, - ); - }) - .unwrap(); - let title = pane_screen(&terminal, Rect::new(0, 0, width, 1)); - assert!(title.contains("3 active.txt"), "{title}"); - if width == 28 { - assert!(!title.contains("90%"), "{title}"); - } else { - assert!(title.ends_with(" <<< 90% "), "{title}"); - } - } - editor - .set_label("battery".into(), "界".repeat(1024)) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 2)).unwrap(); - terminal - .draw(|frame| { - let area = frame.area(); - draw_chrome( - frame, - chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), - area, - ); - }) - .unwrap(); - let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); - assert!(title.contains("3 active.txt"), "{title}"); - assert!(!title.contains('界'), "{title}"); - } - - #[test] - fn custom_value_stays_above_split_overlays_and_diff_geometry() { - let directory = Directory::new(); - let mut editor = - Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap(); - editor.execute("pane.split_vertical"); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let original = views.rects.clone(); - editor.set_label("battery".into(), "live".into()).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - for ((id, before), (after_id, after)) in original.iter().zip(&views.rects) { - assert_eq!(id, after_id); - assert_eq!( - *after, - Rect::new(before.x, before.y + 1, before.width, before.height - 1) - ); - } - let title = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); - editor.handle(Input::Ctrl('p')); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(screen_text(&terminal).contains("Commands")); - assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title); - - let mut editor = readonly_diff_editor(deletion_file()); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let regions = views.panels.diff.as_ref().unwrap().regions; - editor.set_label("battery".into(), "live".into()).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - for ((before, focus), (after, after_focus)) in regions - .into_iter() - .zip(views.panels.diff.as_ref().unwrap().regions) - { - assert_eq!(focus, after_focus); - if before == Rect::default() { - assert_eq!(after, before); - } else { - assert_eq!( - after, - Rect::new(before.x, before.y + 1, before.width, before.height - 1) - ); - } - } - assert_eq!(views.panels.files.unwrap().0.y, views.rects[0].1.y); - let title = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('o')); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title); - } - - #[test] - fn tab_switches_restore_independent_pane_viewports_and_retire_only_closed_panes() { - let directory = Directory::new(); - let text = (0..80) - .map(|row| format!("row-{row:02} {}", "long ".repeat(30))) - .collect::>() - .join("\n"); - let mut session = - EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap(); - let buffer = session.editor().buffer_id(); - session.execute("tab.new"); - let other = session.editor().pane_id(); - session.execute("tab.previous"); - session.execute("pane.split_vertical"); - let split = session.editor().pane_id(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - let area = views.rects.iter().find(|(id, _)| *id == split).unwrap().1; - assert_eq!(area.y, 1); - assert!(!views.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 2, 0) - )); - for _ in 0..2 { - for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollRight] { - assert!(views.scroll(&mut session, document_wheel(kind, area.x + 2, area.y + 2))); - } - } - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - let split_offset = views.offsets[&buffer]; - assert!(split_offset.0 > 0 && split_offset.1 > 0); - - session.execute("tab.next"); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(views.rects.len(), 1); - assert_eq!(views.parked[&split].offsets[&buffer], split_offset); - for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollRight] { - assert!(views.scroll(&mut session, document_wheel(kind, 2, 2))); - } - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - let other_offset = views.offsets[&buffer]; - assert_ne!(other_offset, split_offset); - - session.execute("tab.previous"); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(session.editor().pane_id(), split); - assert_eq!(views.rects.len(), 2); - assert_eq!(views.offsets[&buffer], split_offset); - assert_eq!(views.parked[&other].offsets[&buffer], other_offset); - session.execute("tab.next"); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(views.offsets[&buffer], other_offset); - session.execute("tab.close"); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(!views.parked.contains_key(&other)); - assert_eq!(views.offsets[&buffer], split_offset); - } - - #[test] - fn hidden_tab_diff_caches_do_not_poll_colors_or_report_stale_viewports() { - let directory = Directory::new(); - let mut session = EditorSession::open( - Some(directory.file("source.txt", "WORKSPACE")), - vec![], - None, - ) - .unwrap(); - let source = (0..80) - .map(|row| format!("old-{row:02} {}", "x ".repeat(30))) - .collect::>() - .join("\n"); - install_readonly_diff( - session.editor_mut(), - syntax_diff_file("history.txt", &source, &source.replace("old", "new")), - ); - let owner = session.editor().pane_id(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(views.diff_visible); - let (id, file, _, _, _) = views.panels.diff.as_ref().unwrap().viewport.unwrap(); - session.set_diff_viewport(id, file, 24, 7, 8); - session.execute("tab.new"); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(!views.diff_visible); - assert!(views.parked[&owner].panels.diff.is_some()); - assert!(views.rects.iter().all(|(id, _)| *id != owner)); - - session.execute("tab.previous"); - let diff = session.editor().diff().unwrap(); - assert_eq!((diff.top, diff.left_before, diff.left_after), (24, 7, 8)); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(views.diff_visible); - - session.execute("diff"); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(views.panels.diff.as_ref().unwrap().regions[0].0.y >= 1); - } - - #[test] - fn explorer_dock_reserves_space_outside_all_workspace_panes() { - let directory = Directory::new(); - let mut editor = - Editor::open(Some(directory.file("shared.txt", "first\nsecond")), vec![]).unwrap(); - editor.set_project_root(directory.0.clone()); - editor.handle(Input::Char('|')); - editor.handle(Input::Char('_')); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('e')); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let dock = views.panels.files.unwrap().0; - assert!(views.rects.iter().all(|(_, pane)| pane.right() <= dock.x)); - assert_eq!(dock.right(), 120); - assert_eq!(dock.y, 0); - assert_eq!( - Some(dock.bottom()), - views.rects.iter().map(|(_, pane)| pane.bottom()).max() - ); - assert_eq!(dock.height + 1, terminal.backend().buffer().area.height); - } - - #[test] - fn native_diff_dock_and_panes_fill_the_workspace_below_only_configured_chrome() { - for (width, height) in [(120, 30), (32, 9)] { - for chrome in [false, true] { - let mut editor = readonly_diff_editor(deletion_file()); - if chrome { - editor.set_label("battery".into(), "live".into()).unwrap(); - } - let mut views = BufferViewports::default(); - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let dock = views.panels.files.unwrap().0; - let workspace = views.rects[0].1; - let regions = views.panels.diff.as_ref().unwrap().regions; - assert_eq!(dock.y, workspace.y); - assert_eq!(dock.right(), width); - assert_eq!(dock.bottom(), workspace.bottom()); - for (pane, _) in regions.into_iter().skip(1) { - assert_eq!(pane.y, workspace.y); - assert_eq!(pane.bottom(), workspace.bottom()); - assert_eq!(pane.y, u16::from(chrome)); - } - } - } - } - - #[test] - fn revision_selector_overlays_and_clears_workspace_pane_borders() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.handle(Input::Char('|')); - editor.execute("diff"); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(screen_text(&terminal).contains("Choose Before")); - let results = views.panels.diff.as_ref().unwrap().regions[0].0; - assert!(results.x < 60 && results.right() > 60); - for x in [59, 60] { - assert_eq!(terminal.backend().buffer()[(x, 20)].symbol(), " "); - } - } - - #[test] - fn diff_floating_picker_overlays_the_whole_workspace() { - let directory = Directory::new(); - let mut editor = - Editor::open(Some(directory.file("source.txt", "workspace")), vec![]).unwrap(); - editor.handle(Input::Char('|')); - install_readonly_diff(&mut editor, syntax_diff_file("history.txt", "old", "new")); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('o')); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let panel = views.panels.files.unwrap().0; - assert!(panel.x < 60 && panel.right() > 60); - assert!(panel.y >= views.rects[0].1.y); - assert!(panel.bottom() <= views.rects[0].1.bottom()); - } - - #[test] - fn nested_workspace_panes_share_edits_and_undo_but_not_carets() { - let directory = Directory::new(); - let mut editor = Editor::open( - Some(directory.file("shared.rs", "fn original() {}\nsecond")), - vec![], - ) - .unwrap(); - let root = editor.pane_id(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 22)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - editor.handle(Input::Char('|')); - let right = editor.pane_id(); - editor.handle(Input::Char('_')); - let bottom = editor.pane_id(); - assert_ne!(root, right); - assert_ne!(right, bottom); - editor.handle(Input::Right); - assert_eq!( - editor.with_render_pane(root, |view| view.cursor()), - Cursor::default() - ); - assert_eq!( - editor.with_render_pane(right, |view| view.cursor()), - Cursor::default() - ); - assert_eq!(editor.cursor().column, 1); - editor.handle(Input::Home); - editor.handle(Input::Char('i')); - editor.handle(Input::Paste("SHARED ".into())); - editor.handle(Input::Escape); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(views.rects.len(), 3); - for (_, area) in &views.rects { - assert!(pane_screen(&terminal, *area).contains("SHARED")); - } - let focused = views.rects.iter().find(|(id, _)| *id == bottom).unwrap().1; - assert!(focused.contains(terminal.get_cursor_position().unwrap())); - assert_eq!( - terminal.backend().buffer()[(focused.x, focused.y)].fg, - Color::Cyan - ); - assert_eq!(views.syntax.len(), 1); - editor.handle(Input::Char('u')); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(!screen_text(&terminal).contains("SHARED")); - editor.execute("pane.close"); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(views.rects.len(), 2); - assert!(!views.parked.contains_key(&bottom)); - } - - #[test] - fn keyboard_viewport_scroll_preserves_targets_and_reveals_on_motion() { - let directory = Directory::new(); - let text = (0..80) - .map(|row| format!("row-{row:02} {}", "word ".repeat(24))) - .collect::>() - .join("\n"); - for diff in [false, true] { - for wrapped in [false, true] { - for visual in [false, true] { - let mut editor = if diff { - readonly_diff_editor(syntax_diff_file( - "rows.txt", - &text, - &text.replace("word", "text"), - )) - } else { - Editor::open(Some(directory.file("rows.txt", &text)), vec![]).unwrap() - }; - if wrapped { - editor.execute("word_wrap.toggle"); - } - editor - .bind("normal", "alt+j", Some("core.scroll_down".into())) - .unwrap(); - editor - .bind("normal", "alt+k", Some("core.scroll_up".into())) - .unwrap(); - if diff { - editor.handle(Input::Char('\t')); - } - editor.handle(Input::Ctrl('e')); - if visual { - editor.handle(Input::Char('v')); - editor.handle(Input::Right); - } - let buffer = editor.buffer_id(); - let carets = editor.carets(); - let selections = editor.selections(); - let cursor = editor.cursor(); - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(80, 14)).unwrap(); - let mut views = BufferViewports::default(); - let render = |terminal: &mut Terminal, - views: &mut BufferViewports, - editor: &mut Editor| { - terminal.draw(|frame| views.draw(frame, editor)).unwrap(); - if diff { - let (id, file, top, before, after) = - views.panels.diff.as_ref().unwrap().viewport.unwrap(); - editor.set_diff_viewport(id, file, top, before, after); - } - }; - let offset = |views: &BufferViewports| { - if diff { - let top = views.panels.diff_wrap.as_ref().unwrap().top; - (top.display, top.continuation) - } else { - ( - views.offsets[&buffer].0, - *views.document.continuations.get(&buffer).unwrap_or(&0), - ) - } - }; - render(&mut terminal, &mut views, &mut editor); - let initial = offset(&views); - let height = if diff { - views.panels.diff_wrap.as_ref().unwrap().heights[0] - } else { - usize::from(views.document.geometry.unwrap().1.height) - }; - editor.handle(Input::Ctrl('d')); - render(&mut terminal, &mut views, &mut editor); - let scrolled = offset(&views); - assert!( - scrolled > initial, - "diff={diff}, wrap={wrapped}, visual={visual}" - ); - if !wrapped { - assert_eq!(scrolled, (initial.0 + height / 2, 0)); - } - assert_eq!(editor.carets(), carets); - assert_eq!(editor.selections(), selections); - render(&mut terminal, &mut views, &mut editor); - assert_eq!(offset(&views), scrolled); - editor.handle(Input::Ctrl('u')); - render(&mut terminal, &mut views, &mut editor); - assert_eq!(offset(&views), initial); - editor.handle(Input::Ctrl('u')); - render(&mut terminal, &mut views, &mut editor); - assert_eq!(offset(&views), initial); - editor.set_scroll_step_lines(std::num::NonZeroUsize::new(3)); - editor.handle(Input::Char('2')); - editor.handle(Input::Ctrl('d')); - render(&mut terminal, &mut views, &mut editor); - if !wrapped { - assert_eq!(offset(&views), (initial.0 + 6, 0)); - } - assert_eq!(editor.carets(), carets); - assert_eq!(editor.selections(), selections); - editor.handle(Input::Char('2')); - editor.handle(Input::Ctrl('u')); - render(&mut terminal, &mut views, &mut editor); - assert_eq!(offset(&views), initial); - if !visual { - editor.handle(Input::Char('3')); - editor.handle(Input::Alt('j')); - render(&mut terminal, &mut views, &mut editor); - assert!(offset(&views) > initial); - if !wrapped { - assert_eq!(offset(&views), (initial.0 + 3, 0)); - } - editor.handle(Input::Char('3')); - editor.handle(Input::Alt('k')); - render(&mut terminal, &mut views, &mut editor); - assert_eq!(offset(&views), initial); - } - editor.handle(Input::Ctrl('d')); - render(&mut terminal, &mut views, &mut editor); - editor.handle(Input::Home); - render(&mut terminal, &mut views, &mut editor); - assert!(views.panels.caret.is_some()); - assert_eq!(editor.cursor().row, cursor.row); - } - } - } - } - - #[test] - fn wheel_targets_inactive_workspace_pane_and_split_inherits_offsets() { - let directory = Directory::new(); - let text = (0..80) - .map(|row| format!("row-{row:02} {}", "long ".repeat(30))) - .collect::>() - .join("\n"); - let mut session = - EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap(); - let root = session.editor().pane_id(); - let buffer = session.editor().buffer_id(); - for _ in 0..3 { - session.handle(Input::Right); - } - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(views.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 2, 1) - )); - session.handle(Input::Char('|')); - let focused = session.editor().pane_id(); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(views.offsets[&buffer], views.parked[&root].offsets[&buffer]); - let focused_offset = views.offsets[&buffer]; - let root_area = views.rects.iter().find(|(id, _)| *id == root).unwrap().1; - let cursor = session.editor().cursor(); - assert!(views.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, root_area.x + 2, root_area.y + 2) - )); - assert!(views.scroll( - &mut session, - document_wheel( - MouseEventKind::ScrollRight, - root_area.x + 2, - root_area.y + 2 - ) - )); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(session.editor().pane_id(), focused); - assert_eq!(session.editor().cursor(), cursor); - assert_eq!(views.offsets[&buffer], focused_offset); - assert!(views.parked[&root].offsets[&buffer].0 > focused_offset.0); - assert!(views.parked[&root].offsets[&buffer].1 > focused_offset.1); - let inactive_offset = views.parked[&root].offsets[&buffer]; - let inactive_cursor = session - .editor_mut() - .with_render_pane(root, |view| view.cursor()); - // Even a motion that leaves the focused caret in place requests reveal. - session.handle(Input::Home); - session.handle(Input::Home); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset); - session.handle(Input::Down); - session.handle(Input::End); - session.handle(Input::Char('i')); - session.handle(Input::Paste(" typed".into())); - session.handle(Input::Escape); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!( - session - .editor_mut() - .with_render_pane(root, |view| view.cursor()), - inactive_cursor - ); - assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset); - session.handle(Input::Up); - session.handle(Input::Home); - session.handle(Input::Char('i')); - session.handle(Input::Paste("PREFIX ".into())); - session.handle(Input::Escape); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - let rebased = session - .editor_mut() - .with_render_pane(root, |view| view.cursor()); - assert_eq!(rebased.row, inactive_cursor.row); - assert!(rebased.column > inactive_cursor.column); - assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset); - let focused_after_typing = views.offsets[&buffer]; - session.handle(Input::Alt('h')); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(session.editor().pane_id(), root); - assert!(views.offsets[&buffer].0 > focused_offset.0); - assert_eq!( - views.parked[&focused].offsets[&buffer], - focused_after_typing - ); - } - - #[test] - fn wrapped_workspace_panes_scroll_independently_and_keep_toggle_anchor() { - let directory = Directory::new(); - let text = (0..40) - .map(|row| format!("row-{row:02} {}", "word ".repeat(35))) - .collect::>() - .join("\n"); - let mut session = - EditorSession::open(Some(directory.file("wrap.txt", &text)), vec![], None).unwrap(); - session.execute("word_wrap.toggle"); - let root = session.editor().pane_id(); - let buffer = session.editor().buffer_id(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(70, 24)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(views.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 2, 1) - )); - session.handle(Input::Char('|')); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - let inherited = ( - views.offsets[&buffer].0, - views.document.continuations[&buffer], - ); - assert_eq!( - inherited, - ( - views.parked[&root].offsets[&buffer].0, - views.parked[&root].document.continuations[&buffer] - ) - ); - let root_area = views.rects.iter().find(|(id, _)| *id == root).unwrap().1; - assert!(views.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, root_area.x + 2, root_area.y + 2) - )); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!( - ( - views.offsets[&buffer].0, - views.document.continuations[&buffer] - ), - inherited - ); - assert_ne!( - ( - views.parked[&root].offsets[&buffer].0, - views.parked[&root].document.continuations[&buffer] - ), - inherited - ); - session.handle(Input::Right); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - let caret = terminal.get_cursor_position().unwrap(); - session.execute("word_wrap.toggle"); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap().y, caret.y); - } - - #[test] - fn comparison_owner_coexists_with_workspace_and_inactive_diff_wheel_keeps_focus() { - let directory = Directory::new(); - let mut session = EditorSession::open( - Some(directory.file("source.txt", "WORKSPACE\nsecond")), - vec![], - None, - ) - .unwrap(); - let source = (0..80) - .map(|row| format!("old-{row:02} {}", "x ".repeat(30))) - .collect::>() - .join("\n"); - let after = source.replace("old", "new"); - let workspace = session.editor().buffer_id(); - install_readonly_diff( - session.editor_mut(), - syntax_diff_file("history.txt", &source, &after), - ); - let owner = session.editor().pane_id(); - session.execute("pane.split_horizontal"); - let focused = session.editor().pane_id(); - session.execute(&format!("buffer {workspace}")); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 34)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(session.editor().diff().is_none()); - assert!(screen_text(&terminal).contains("WORKSPACE")); - assert!(screen_text(&terminal).contains("Before")); - assert!(screen_text(&terminal).contains("After")); - let geometry = views.parked[&owner].panels.diff.as_ref().unwrap(); - let before = geometry.regions[1].0; - let cursor = session.editor().cursor(); - let offsets = views.offsets.clone(); - assert!(views.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1) - )); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(session.editor().pane_id(), focused); - assert_eq!(session.editor().cursor(), cursor); - assert_eq!(views.offsets, offsets); - assert!( - session - .editor_mut() - .with_render_pane(owner, |view| view.diff().unwrap().top > 0) - ); - let area = views.rects.iter().find(|(id, _)| *id == focused).unwrap().1; - assert!(area.contains(terminal.get_cursor_position().unwrap())); - } - - fn definition_point(row: usize, column: usize) -> crate::plugin::Utf16Range { - crate::plugin::Utf16Range { - start: (row, column), - end: (row, column), - } - } - - #[test] - fn lsp_definitions_center_in_actual_document_panes_once_and_preserve_dirty_targets() { - for (layout, target_kind, row) in [ - ("single", "same", 0), - ("single", "same", 2), - ("single", "new", 50), - ("split", "same", 50), - ("dock", "dirty", 50), - ("single", "same", 99), - ] { - let directory = Directory::new(); - let text = (0..100) - .map(|row| format!("definition_{row:03}")) - .collect::>() - .join("\n"); - let source = directory.file("source.txt", &text); - let target = if target_kind == "same" { - source.clone() - } else { - directory.file("target.txt", &text) - }; - let mut editor = Editor::open(Some(source.clone()), vec![]).unwrap(); - if target_kind == "dirty" { - editor - .navigate_to(target.clone(), TextPosition { row: 0, column: 0 }) - .unwrap(); - editor.handle(Input::Char('i')); - editor.handle(Input::Paste("UNSAVED ".into())); - editor.handle(Input::Escape); - editor - .navigate_to(source, TextPosition { row: 0, column: 0 }) - .unwrap(); - } - if layout == "split" { - editor.execute("pane.split_horizontal"); - } else if layout == "dock" { - editor.set_project_root(directory.0.clone()); - editor.set_file_explorer_side("bottom").unwrap(); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('e')); - editor.handle(Input::Escape); - } - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - editor - .navigate_lsp_to(target.clone(), definition_point(row, 0), None) - .unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let viewport = views.document.geometry.unwrap().1; - if layout != "single" { - assert!(viewport.height < 22, "{layout}"); - } - let centered = viewport.y + (row as u16).min(viewport.height / 2); - assert_eq!( - terminal.get_cursor_position().unwrap().y, - centered, - "{layout}/{target_kind}/{row}" - ); - assert_eq!(editor.cursor().row, row); - assert_eq!(editor.path(), Some(target.as_path())); - if target_kind == "dirty" { - assert!(editor.lines()[0].starts_with("UNSAVED ")); - assert_eq!(fs::read_to_string(&target).unwrap(), text); - } - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap().y, centered); - if row == 50 { - editor.handle(Input::Down); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap().y, centered + 1); - let before = ( - views.offsets.clone(), - screen_text(&terminal), - editor.cursor(), - ); - for (path, range) in [ - (target.clone(), definition_point(1000, 0)), - (directory.0.join("missing.txt"), definition_point(0, 0)), - ] { - assert!(editor.navigate_lsp_to(path, range, None).is_err()); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(views.offsets, before.0); - assert_eq!(screen_text(&terminal), before.1); - assert_eq!(editor.cursor(), before.2); - assert_eq!(terminal.get_cursor_position().unwrap().y, centered + 1); - } - } - } - } - - #[test] - fn lsp_definition_centers_wrapped_utf16_grapheme_destination_visually() { - let directory = Directory::new(); - let mut lines = (0..80) - .map(|row| format!("row_{row:03}")) - .collect::>(); - lines[40] = format!("{}😀e\u{301}ZTAIL", "a".repeat(80)); - let path = directory.file("wrapped.txt", &lines.join("\n")); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - editor.execute("word_wrap.toggle"); - let mut views = BufferViewports::default(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 12)).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - editor - .navigate_lsp_to(path, definition_point(40, 84), None) - .unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let viewport = views.document.geometry.unwrap().1; - let caret = terminal.get_cursor_position().unwrap(); - assert_eq!( - editor.cursor(), - Cursor { - row: 40, - column: 82 - } - ); - assert_eq!(caret.y, viewport.y + viewport.height / 2); - assert_eq!( - terminal.backend().buffer()[(caret.x, caret.y)].symbol(), - "Z" - ); - assert_eq!(editor.lines(), lines); - } - - #[test] - fn markdown_search_and_plugin_navigation_keep_ordinary_caret_following() { - for navigation in ["markdown", "search", "plugin"] { - let directory = Directory::new(); - let mut lines = (0..80) - .map(|row| format!("row_{row:03}")) - .collect::>(); - lines[0] = "[destination](#destination)".into(); - lines[50] = "# Destination".into(); - let path = directory.file("links.md", &lines.join("\n")); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - let mut views = BufferViewports::default(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 16)).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - match navigation { - "markdown" => { - editor.execute("follow_link"); - } - "search" => { - editor.handle(Input::Char('/')); - for ch in "Destination".chars() { - editor.handle(Input::Char(ch)); - } - editor.handle(Input::Enter); - } - "plugin" => editor - .navigate_to(path, TextPosition { row: 50, column: 0 }) - .unwrap(), - _ => unreachable!(), - } - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let viewport = views.document.geometry.unwrap().1; - assert_eq!(editor.cursor().row, 50, "{navigation}"); - assert_eq!( - terminal.get_cursor_position().unwrap().y, - viewport.bottom() - 1, - "{navigation}" - ); - } - } - - #[test] - fn lsp_definition_centers_revealed_diff_symbol_in_each_actual_focused_pane() { - for (mode, axis, focus) in [ - ( - ViewMode::Split, - crate::panes::SplitAxis::Vertical, - Focus::Before, - ), - ( - ViewMode::Split, - crate::panes::SplitAxis::Horizontal, - Focus::After, - ), - ( - ViewMode::Unified, - crate::panes::SplitAxis::Vertical, - Focus::After, - ), - ] { - let before = (0..90) - .map(|row| format!("line_{row:03}")) - .collect::>(); - let mut after = before.clone(); - for row in 10..31 { - after[row] = format!("changed_{row:03}"); - } - after[70] = "changed_body".into(); - let mut file = syntax_diff_file("symbol.txt", &before.join("\n"), &after.join("\n")); - file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_diff_display_options(mode, axis); - editor.set_diff_context_options(1, 3); - install_readonly_diff(&mut editor, file); - editor.handle(Input::Char('\t')); - if focus == Focus::After { - editor.handle(Input::Char('\t')); - } - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); - let mut views = BufferViewports::default(); - render_native_diff(&mut terminal, &mut views, &mut editor); - let diff = editor.diff().unwrap(); - let comparison = diff.comparison_id; - let side = usize::from(focus == Focus::After); - let path = diff.snapshot_roots[side] - .as_ref() - .unwrap() - .join("symbol.txt"); - assert!(diff.source_display_row(focus, 40).is_none()); - editor - .navigate_lsp_to( - path.clone(), - definition_point(40, 2), - Some(crate::plugin::Utf16Range { - start: (40, 0), - end: (71, 0), - }), - ) - .unwrap(); - let caret = render_native_diff(&mut terminal, &mut views, &mut editor); - let geometry = views.panels.diff.as_ref().unwrap(); - let pane = diff_inner(geometry.regions[if focus == Focus::Before { 1 } else { 2 }].0); - assert_eq!( - caret.y, - pane.y + pane.height / 2, - "{mode:?}/{axis:?}/{focus:?}" - ); - assert!(pane.contains(caret)); - assert_eq!(editor.cursor(), Cursor { row: 40, column: 2 }); - assert_eq!(editor.diff().unwrap().comparison_id, comparison); - assert!( - editor - .diff() - .unwrap() - .source_display_row(focus, 40) - .is_some() - ); - assert!(editor.mutation_blocked()); - editor.handle(Input::Down); - assert!(render_native_diff(&mut terminal, &mut views, &mut editor).y > caret.y); - editor - .navigate_lsp_to(path, definition_point(80, 1), None) - .unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let viewport = views.document.geometry.unwrap().1; - assert!(editor.diff().is_none()); - assert_eq!(editor.tab_count(), 2); - assert_eq!( - terminal.get_cursor_position().unwrap().y, - viewport.y + viewport.height / 2 - ); - assert!(editor.mutation_blocked()); - } - } - - #[test] - fn deferred_lsp_definition_centers_only_successful_current_diff_results() { - for outcome in ["success", "stale", "failed"] { - let directory = Directory::new(); - let before = (0..90) - .map(|row| format!("line_{row:03}")) - .collect::>(); - let mut after = before.clone(); - for row in 10..31 { - after[row] = format!("changed_{row:03}"); - } - after[70] = "changed_body".into(); - let mut source = syntax_diff_file("source.txt", &before.join("\n"), &after.join("\n")); - source.hunks = crate::diff::line_hunks(&source.before, false, &source.after, false); - let mut target = syntax_diff_file("target.txt", &before.join("\n"), &after.join("\n")); - target.hunks = crate::diff::line_hunks(&target.before, false, &target.after, false); - directory.file("target.txt", &after.join("\n")); - let mut unloaded = syntax_diff_file("target.txt", "", ""); - unloaded.loaded = false; - unloaded.before.clear(); - unloaded.after.clear(); - unloaded.hunks.clear(); - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_diff_context_options(1, 3); - editor.execute("diff"); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); - editor.handle(Input::Down); - editor.handle(Input::Enter); - editor.handle(Input::Down); - editor.handle(Input::Enter); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result( - id, - Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { - root: directory.0.clone(), - editable_paths: Default::default(), - before: "baseline".into(), - after: "historic".into(), - revisions: ["before-snapshot".into(), "after-snapshot".into()], - files: vec![source, unloaded], - })), - ); - editor.handle(Input::Char('\t')); - let mut views = BufferViewports::default(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); - render_native_diff(&mut terminal, &mut views, &mut editor); - let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); - editor - .navigate_lsp_to( - directory.0.join("target.txt"), - definition_point(40, 2), - Some(crate::plugin::Utf16Range { - start: (40, 0), - end: (71, 0), - }), - ) - .unwrap(); - let (id, _) = editor.take_diff_request().unwrap(); - if outcome == "stale" { - editor.handle(Input::Right); - editor.handle(Input::Left); - } - let before_caret = render_native_diff(&mut terminal, &mut views, &mut editor); - let before_pane = diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0); - let before_viewport = views.panels.diff.as_ref().unwrap().viewport; - let before_buffer = editor.buffer_id(); - let before_source = editor.cursor(); - editor.apply_diff_result( - id, - if outcome == "failed" { - Err("definition file unavailable".into()) - } else { - Ok(crate::diff::Outcome::File { - comparison_id, - file_index: 1, - file: target, - }) - }, - ); - let caret = render_native_diff(&mut terminal, &mut views, &mut editor); - if outcome == "success" { - let pane = diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0); - assert_eq!(editor.cursor(), Cursor { row: 40, column: 2 }); - assert_eq!(editor.diff().unwrap().file, 1); - assert_eq!(caret.y, pane.y + pane.height / 2); - editor.handle(Input::Down); - assert!(render_native_diff(&mut terminal, &mut views, &mut editor).y > caret.y); - } else { - assert_eq!(editor.buffer_id(), before_buffer, "{outcome}"); - assert_eq!(editor.cursor(), before_source, "{outcome}"); - assert_eq!(editor.diff().unwrap().file, 0, "{outcome}"); - assert_eq!( - views.panels.diff.as_ref().unwrap().viewport, - before_viewport, - "{outcome}" - ); - let pane = diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0); - assert_eq!( - (caret.x - pane.x, caret.y - pane.y), - ( - before_caret.x - before_pane.x, - before_caret.y - before_pane.y - ), - "{outcome}" - ); - assert_eq!( - render_native_diff(&mut terminal, &mut views, &mut editor), - caret - ); - } - } - } - - fn readonly_diff_editor(file: ChangedFile) -> Editor { - let mut editor = Editor::open(None, vec![]).unwrap(); - install_readonly_diff(&mut editor, file); - editor - } - - fn install_readonly_diff(editor: &mut Editor, file: ChangedFile) { - editor.set_clipboard_mode("separate").unwrap(); - editor.execute("diff"); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); - editor.handle(Input::Down); - editor.handle(Input::Enter); - editor.handle(Input::Down); - editor.handle(Input::Enter); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result( - id, - Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { - root: editor.project_root().to_path_buf(), - editable_paths: Default::default(), - before: "baseline".into(), - after: "historic".into(), - revisions: ["before-snapshot".into(), "after-snapshot".into()], - files: vec![file], - })), - ); - } - - fn render_native_diff( - terminal: &mut Terminal, - views: &mut BufferViewports, - editor: &mut Editor, - ) -> Position { - terminal.draw(|frame| views.draw(frame, editor)).unwrap(); - let (id, file, top, before, after) = views.panels.diff.as_ref().unwrap().viewport.unwrap(); - editor.set_diff_viewport(id, file, top, before, after); - terminal.get_cursor_position().unwrap() - } - - #[test] - fn command_input_keeps_characterwise_and_linewise_selection_highlights() { - for context in ["document", "split", "unified"] { - for selector in ['v', 'V'] { - let mut editor = if context == "document" { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor - .apply(vec![crate::editor::Effect::Replace( - "alpha beta\nsecond line".into(), - )]) - .unwrap(); - editor - } else { - let mut editor = readonly_diff_editor(syntax_diff_file( - "file.txt", - "alpha beta\nold line", - "alpha beta\nnew line", - )); - if context == "unified" { - editor.execute("diff.view.toggle"); - } - editor.handle(Input::Char('\t')); - editor - }; - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(140, 24)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let source = terminal.get_cursor_position().unwrap(); - editor.handle(Input::Char(selector)); - editor.handle(Input::Right); - let ranges = editor.selections(); - editor.handle(Input::Char(':')); - for ch in "diff.comment".chars() { - editor.handle(Input::Char(ch)); - } - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(editor.mode(), Mode::Command); - assert_eq!(editor.selections(), ranges); - let buffer = terminal.backend().buffer(); - assert_eq!( - buffer[(source.x, source.y)].bg, - Color::Blue, - "{context} {selector}" - ); - assert_eq!( - buffer[(source.x + if selector == 'V' { 9 } else { 1 }, source.y)].bg, - Color::Blue, - "{context} {selector}" - ); - editor.handle(Input::Escape); - assert_eq!(editor.selections(), ranges); - } - } - } - - #[test] - fn native_visual_selection_overrides_selected_review_range_background() { - for unified in [false, true] { - let mut editor = readonly_diff_editor(syntax_diff_file( - "file.txt", - "old e\u{301}界 text", - "new e\u{301}界 text", - )); - if unified { - editor.execute("diff.view.toggle"); - } - editor.handle(Input::Char('\t')); - editor.execute("diff.comment"); - editor.handle(Input::Escape); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap(); - let mut views = BufferViewports::default(); - let source = render_native_diff(&mut terminal, &mut views, &mut editor); - assert_eq!( - terminal.backend().buffer()[(source.x, source.y)].bg, - Color::Magenta - ); - editor.handle(Input::Char('v')); - editor.handle(Input::Right); - render_native_diff(&mut terminal, &mut views, &mut editor); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(source.x, source.y)].bg, Color::Blue); - assert!(buffer.content.iter().any(|cell| cell.bg == Color::Magenta)); - } - } - - #[test] - fn review_editor_floats_over_the_diff_without_changing_workspace_layout() { - for unified in [false, true] { - let mut editor = readonly_diff_editor(syntax_diff_file( - "file.txt", - "old first\nold last", - "new first\nnew last", - )); - if unified { - editor.execute("diff.view.toggle"); - } - editor.handle(Input::Char('\t')); - let owner = editor.pane_id(); - let source_text = editor.context().text; - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 30)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let source_area = views - .rects - .iter() - .find(|(pane, _)| *pane == owner) - .unwrap() - .1; - editor.execute("diff.comment"); - let panel = editor.review_panel().expect("focused floating review"); - assert_eq!(editor.pane_layout().len(), 1); - assert!(!editor.pane_layout().contains(panel)); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(views.rects[0], (panel, floating_panel(source_area))); - assert_eq!( - views - .rects - .iter() - .find(|(pane, _)| *pane == owner) - .unwrap() - .1, - source_area - ); - let area = views.rects[0].1; - let cursor = terminal.get_cursor_position().unwrap(); - assert!(area.contains(cursor)); - for input in [ - Input::Char('i'), - Input::Paste("Retained draft".into()), - Input::Escape, - ] { - editor.handle(input); - } - assert_eq!(editor.context().text, "Retained draft"); - assert_eq!(editor.review_panel(), Some(panel)); - editor.handle(Input::Escape); - assert!(editor.review_panel().is_none()); - assert_eq!(editor.pane_id(), owner); - assert_eq!(editor.context().text, source_text); - editor.execute("diff.review.edit 1"); - assert_eq!( - editor.context().text, - "Retained draft", - "{}", - editor.message() - ); - assert_eq!(editor.review_panel(), Some(panel)); - editor.execute("tab.new"); - editor.execute("tab.previous"); - editor.execute("diff.review.edit 1"); - assert_eq!(editor.review_panel(), Some(panel)); - assert_eq!(editor.context().text, "Retained draft"); - editor.execute("pane.close"); - assert!(editor.review_panel().is_none()); - assert_eq!(editor.pane_layout().len(), 1); - } - } - - #[test] - fn scope_projection_keeps_unified_wrapped_caret_and_selection_source_only() { - let before = format!( - "fn old_name() {{\n // {}\n}}\nfn second() {{}}\n", - "word ".repeat(45) - ); - let after = before.replace("old_name", "new_name"); - for axis in [SplitAxis::Vertical, SplitAxis::Horizontal] { - let mut editor = readonly_diff_editor(syntax_diff_file("scope.rs", &before, &after)); - if axis == SplitAxis::Horizontal { - editor.execute("diff.orientation.toggle"); - } - editor.handle(Input::Char('\t')); - editor.handle(Input::Down); - editor.handle(Input::Home); - editor.handle(Input::Char('v')); - editor.handle(Input::End); - let source = editor.cursor(); - let selections = editor.selections(); - let text = editor.lines().to_vec(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap(); - let mut views = BufferViewports::default(); - for command in [ - "", - "word_wrap.toggle", - "diff.view.toggle", - "word_wrap.toggle", - "diff.view.toggle", - ] { - if !command.is_empty() { - editor.execute(command); - } - let caret = render_native_diff(&mut terminal, &mut views, &mut editor); - assert_eq!(editor.cursor(), source); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.lines(), text); - let geometry = views.panels.diff.as_ref().unwrap(); - let diff = editor.diff().unwrap(); - let active = usize::from(diff.focus == Focus::After); - let area = diff_inner(geometry.regions[active + 1].0); - assert!(area.contains(caret)); - let rows = &views.panels.diff_wrap.as_ref().unwrap().rows; - let caret_row = usize::from(caret.y - area.y); - assert_eq!(rows[caret_row].scope, [false; 2]); - assert_eq!( - diff.display_source_row(diff.focus, rows[caret_row].position.display), - Some(source.row) - ); - for (screen, row) in rows.iter().take(usize::from(area.height)).enumerate() { - if row.scope == [false; 2] { - continue; - } - let y = area.y + screen as u16; - for x in area.x..area.right() { - assert_ne!(terminal.backend().buffer()[(x, y)].bg, Color::Blue); - } - for x in area.x..area.right() - geometry.text_widths[active] { - assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), " "); - } - } - } - } - } - - #[test] - fn tiny_horizontal_scope_diff_keeps_active_source_visible() { - let mut editor = readonly_diff_editor(syntax_diff_file( - "small.rs", - "fn old_name() {}", - "fn new_name() {}", - )); - editor.execute("diff.orientation.toggle"); - editor.handle(Input::Char('\t')); - editor.handle(Input::Char('\t')); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 9)).unwrap(); - let mut views = BufferViewports::default(); - let caret = render_native_diff(&mut terminal, &mut views, &mut editor); - let area = diff_inner(views.panels.diff.as_ref().unwrap().regions[2].0); - assert!( - area.contains(caret), - "source caret {caret:?} outside {area:?}" - ); - assert_eq!( - views.panels.diff_wrap.as_ref().unwrap().rows[usize::from(caret.y - area.y)].scope, - [false; 2] - ); - } - - #[test] - fn modified_sequences_navigate_diff_files_and_readonly_panes() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.handle(Input::Char('|')); - install_readonly_diff( - &mut editor, - syntax_diff_file("history.txt", "before", "after"), - ); - editor - .bind("normal", "ctrl+w h", Some("core.pane.focus_left".into())) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); - let mut views = BufferViewports::default(); - render_native_diff(&mut terminal, &mut views, &mut editor); - assert_eq!(editor.diff().unwrap().focus, Focus::Files); - editor.handle(Input::Ctrl('w')); - assert_eq!(editor.sequence_pending(), Some("Ctrl+w")); - assert!(editor.diff().unwrap().files_visible); - editor.handle(Input::Char('h')); - assert_eq!(editor.diff().unwrap().focus, Focus::After); - render_native_diff(&mut terminal, &mut views, &mut editor); - editor.handle(Input::Ctrl('w')); - editor.handle(Input::Char('h')); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); - render_native_diff(&mut terminal, &mut views, &mut editor); - editor.handle(Input::Ctrl('w')); - editor.handle(Input::Char('h')); - assert_eq!(editor.pane_id(), 1); - assert_eq!(editor.sequence_pending(), None); - } - - #[test] - fn wrap_toggle_preserves_caret_screen_row_in_normal_buffers() { - let directory = Directory::new(); - let text = (0..35) - .map(|row| format!("line{row} {}", "word ".repeat(15))) - .collect::>() - .join("\n"); - let mut editor = Editor::open(Some(directory.file("anchor.txt", &text)), vec![]).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 10)).unwrap(); - let mut views = BufferViewports::default(); - for _ in 0..12 { - editor.handle(Input::Down); - } - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - for _ in 0..3 { - editor.handle(Input::Up); - } - for _ in 0..30 { - editor.handle(Input::Right); - } - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let row = terminal.get_cursor_position().unwrap().y; - let source = editor.cursor(); - for _ in 0..4 { - editor.execute("word_wrap.toggle"); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap().y, row); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap().y, row); - assert_eq!(editor.cursor(), source); - assert_eq!(editor.lines().join("\n"), text); - } - for _ in 0..35 { - editor.handle(Input::Down); - } - editor.handle(Input::Home); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - editor.execute("word_wrap.toggle"); - editor.handle(Input::Right); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let near_eof_row = terminal.get_cursor_position().unwrap().y; - let near_eof_source = editor.cursor(); - editor.execute("word_wrap.toggle"); - for _ in 0..2 { - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap().y, near_eof_row); - assert_eq!(editor.cursor(), near_eof_source); - } - } - - #[test] - fn wrap_toggle_preserves_caret_screen_row_in_diff_buffers() { - let before = (0..35) - .map(|row| format!("old{row} {}", "word ".repeat(15))) - .collect::>() - .join("\n"); - let after = before.replace("old", "new"); - let mut editor = readonly_diff_editor(syntax_diff_file("anchor.txt", &before, &after)); - editor.handle(Input::Char('\t')); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); - let mut views = BufferViewports::default(); - for _ in 0..12 { - editor.handle(Input::Down); - } - render_native_diff(&mut terminal, &mut views, &mut editor); - for _ in 0..3 { - editor.handle(Input::Up); - } - for _ in 0..30 { - editor.handle(Input::Right); - } - let row = render_native_diff(&mut terminal, &mut views, &mut editor).y; - let source = editor.cursor(); - for _ in 0..4 { - editor.execute("word_wrap.toggle"); - assert_eq!( - render_native_diff(&mut terminal, &mut views, &mut editor).y, - row - ); - assert_eq!( - render_native_diff(&mut terminal, &mut views, &mut editor).y, - row - ); - assert_eq!(editor.cursor(), source); - } - } - - #[test] - fn horizontal_diff_split_keeps_after_caret_in_smaller_bottom_pane() { - let before = (0..35) - .map(|row| format!("old{row}")) - .collect::>() - .join("\n"); - let after = before.replace("old", "new"); - let mut editor = readonly_diff_editor(syntax_diff_file("axis.txt", &before, &after)); - editor.handle(Input::Char('\t')); - editor.handle(Input::Char('\t')); - for _ in 0..20 { - editor.handle(Input::Down); - } - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 17)).unwrap(); - let mut views = BufferViewports::default(); - render_native_diff(&mut terminal, &mut views, &mut editor); - editor.execute("diff.orientation.toggle"); - let caret = render_native_diff(&mut terminal, &mut views, &mut editor); - let geometry = views.panels.diff.as_ref().unwrap(); - assert_eq!(geometry.regions[1].0.x, geometry.regions[2].0.x); - assert!(geometry.regions[1].0.bottom() <= geometry.regions[2].0.y); - assert!(diff_inner(geometry.regions[2].0).contains(caret)); - editor.execute("word_wrap.toggle"); - let caret = render_native_diff(&mut terminal, &mut views, &mut editor); - assert!(diff_inner(views.panels.diff.as_ref().unwrap().regions[2].0).contains(caret)); - } - - #[test] - fn unified_diff_renders_ordered_coordinates_and_switches_logical_focus() { - let mut file = syntax_diff_file("unified.txt", "same\nold\nend", "same\nnew\nend"); - file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); - let mut editor = readonly_diff_editor(file); - editor.execute("diff.view.toggle"); - editor.handle(Input::Char('\t')); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap(); - let mut views = BufferViewports::default(); - render_native_diff(&mut terminal, &mut views, &mut editor); - let geometry = views.panels.diff.as_ref().unwrap(); - assert_eq!(geometry.regions[2].0, Rect::default()); - let area = diff_inner(geometry.regions[1].0); - let buffer = terminal.backend().buffer(); - let lines = (area.y..area.bottom()) - .map(|y| { - (area.x..area.right()) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - }) - .collect::>(); - let tokens: Vec> = lines[..4] - .iter() - .map(|line| line.split_whitespace().collect()) - .collect(); - assert_eq!( - tokens, - [ - vec!["1", "1", "same"], - vec!["2", "-", "old"], - vec!["2+", "new"], - vec!["3", "3", "end"] - ] - ); - assert!(!lines.iter().any(|line| line.contains('╱'))); - editor.handle(Input::Char('\t')); - render_native_diff(&mut terminal, &mut views, &mut editor); - let geometry = views.panels.diff.as_ref().unwrap(); - assert_eq!(geometry.regions[1].0, Rect::default()); - assert_eq!(diff_inner(geometry.regions[2].0), area); - } - - #[test] - fn unified_wrapping_uses_only_the_display_rows_source_side() { - let before = format!("{}\nend", "old ".repeat(40)); - let mut file = syntax_diff_file("wrap.txt", &before, "new\nend"); - file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); - let mut editor = readonly_diff_editor(file); - editor.execute("diff.view.toggle"); - editor.execute("word_wrap.toggle"); - editor.handle(Input::Char('\t')); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap(); - let mut views = BufferViewports::default(); - render_native_diff(&mut terminal, &mut views, &mut editor); - let wrap = views.panels.diff_wrap.as_ref().unwrap(); - let removed = wrap - .rows - .iter() - .filter(|row| row.position.display == 0) - .collect::>(); - assert!(removed.len() > 1); - assert!( - removed - .iter() - .all(|row| row.segments[0].is_some() && row.segments[1].is_none()) - ); - let added = wrap - .rows - .iter() - .filter(|row| row.position.display == 1) - .collect::>(); - assert_eq!(added.len(), 1); - assert!(added[0].segments[0].is_none()); - assert!(added[0].segments[1].is_some()); - } - - #[test] - fn wrapped_diff_aligns_continuations_and_preserves_native_selection_and_caret() { - let before = format!("// {}BEFORE_END\nsentinel", "old ".repeat(70)); - let after = format!("// {}AFTER_END\nsentinel", "new ".repeat(20)); - let mut editor = readonly_diff_editor(syntax_diff_file("history.rs", &before, &after)); - editor.execute("word_wrap.toggle"); - editor.handle(Input::Char('\t')); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(140, 20)).unwrap(); - let mut views = BufferViewports::default(); - render_native_diff(&mut terminal, &mut views, &mut editor); - let geometry = views.panels.diff.as_ref().unwrap(); - let before_area = diff_inner(geometry.regions[1].0); - let after_area = diff_inner(geometry.regions[2].0); - let buffer = terminal.backend().buffer(); - let find_sentinel = |area: Rect| { - (area.y..area.bottom()) - .find(|y| { - (area.x..area.right()) - .map(|x| buffer[(x, *y)].symbol()) - .collect::() - .contains("sentinel") - }) - .unwrap() - }; - assert_eq!(find_sentinel(before_area), find_sentinel(after_area)); - editor.handle(Input::End); - let caret = render_native_diff(&mut terminal, &mut views, &mut editor); - assert!(before_area.contains(caret)); - assert_eq!( - terminal.backend().buffer()[(caret.x, caret.y)].symbol(), - "D" - ); - assert_eq!( - terminal.backend().buffer()[(caret.x, caret.y)].fg, - Color::DarkGray - ); - editor.handle(Input::Char('v')); - editor.handle(Input::Home); - render_native_diff(&mut terminal, &mut views, &mut editor); - let wrap = views.panels.diff_wrap.as_ref().unwrap(); - let continuation = wrap - .rows - .iter() - .position(|row| row.position.display == 0 && row.position.continuation == 1) - .unwrap(); - let x = before_area.x - + (before_area.width - views.panels.diff.as_ref().unwrap().text_widths[0]); - assert_eq!( - terminal.backend().buffer()[(x, before_area.y + continuation as u16)].bg, - Color::Blue - ); - let selection = editor.selections(); - let text = editor.lines().to_vec(); - editor.execute("word_wrap.toggle"); - render_native_diff(&mut terminal, &mut views, &mut editor); - assert!(!editor.word_wrap()); - assert!( - views - .panels - .diff_wrap - .as_ref() - .unwrap() - .rows - .iter() - .all(|row| row.position.continuation == 0) - ); - assert_eq!(editor.selections(), selection); - assert_eq!(editor.lines(), text); - assert_eq!(editor.mode(), Mode::Visual); - } - - #[test] - fn readonly_diff_panes_render_native_caret_visual_search_and_paired_focus() { - let mut editor = readonly_diff_editor(syntax_diff_file( - "history.rs", - "fn old() {}\nlet value = \"old\";", - "fn new() {}\nlet value = \"new\";", - )); - editor.handle(Input::Char('\t')); - assert!(editor.diff_interacting()); - assert!(!editor.diff_editing()); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); - let mut views = BufferViewports::default(); - views.set_theme(Some(LIGHT_DIFF_THEME)); - let caret = render_native_diff(&mut terminal, &mut views, &mut editor); - let before = views.panels.diff.as_ref().unwrap().regions[1].0; - assert!(before.contains(caret)); - assert_eq!( - terminal.backend().buffer()[(caret.x, caret.y)].symbol(), - "f" - ); - assert_eq!( - terminal.backend().buffer()[(caret.x, caret.y)].fg, - Color::Magenta - ); - editor.handle(Input::Char('v')); - editor.handle(Input::Right); - render_native_diff(&mut terminal, &mut views, &mut editor); - assert_eq!( - terminal.backend().buffer()[(caret.x, caret.y)].bg, - Color::Blue - ); - editor.handle(Input::Escape); - editor.handle(Input::Down); - editor.handle(Input::Home); - editor.handle(Input::Char('V')); - render_native_diff(&mut terminal, &mut views, &mut editor); - assert_eq!( - terminal.backend().buffer()[(caret.x, caret.y + 1)].bg, - Color::Blue - ); - assert_eq!( - terminal.backend().buffer()[(caret.x + 16, caret.y + 1)].bg, - Color::Blue - ); - editor.handle(Input::Escape); - editor.handle(Input::Char('/')); - for ch in "value".chars() { - editor.handle(Input::Char(ch)); - } - render_native_diff(&mut terminal, &mut views, &mut editor); - editor.handle(Input::Enter); - let searched = render_native_diff(&mut terminal, &mut views, &mut editor); - assert!(before.contains(searched)); - assert_eq!( - terminal.backend().buffer()[(searched.x, searched.y)].bg, - Color::Yellow - ); - assert_eq!( - terminal.backend().buffer()[(searched.x, searched.y)].fg, - Color::Black - ); - let before_row = editor - .diff() - .unwrap() - .source_display_row(Focus::Before, editor.cursor().row) - .unwrap(); - let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column); - editor.handle(Input::Char('\t')); - assert!(editor.diff_interacting()); - assert!(!editor.diff_editing()); - let switched = render_native_diff(&mut terminal, &mut views, &mut editor); - let after = views.panels.diff.as_ref().unwrap().regions[2].0; - assert!(after.contains(switched)); - assert_eq!(switched.y, searched.y); - assert_eq!(switched.x - after.x, searched.x - before.x); - assert_eq!( - editor - .diff() - .unwrap() - .source_display_row(Focus::After, editor.cursor().row), - Some(before_row) - ); - assert_eq!( - display_column(&editor.lines()[editor.cursor().row], editor.cursor().column), - column - ); - editor.handle(Input::Right); - let moved = render_native_diff(&mut terminal, &mut views, &mut editor); - editor.handle(Input::Char('\t')); - editor.handle(Input::Char('\t')); - assert_eq!(editor.diff().unwrap().focus, Focus::Before); - let returned = render_native_diff(&mut terminal, &mut views, &mut editor); - assert!(before.contains(returned)); - assert_eq!(returned.y, moved.y); - assert_eq!(returned.x - before.x, moved.x - after.x); - assert_eq!(returned.x, searched.x + 1); - } - - #[test] - fn readonly_diff_viewport_retains_both_offsets_on_direction_and_focus_reversal() { - let before = (0..80) - .map(|row| format!("old row {row:03} {}", "x".repeat(100))) - .collect::>() - .join("\n"); - let after = before.replace("old row", "new row"); - let mut editor = readonly_diff_editor(syntax_diff_file("history.txt", &before, &after)); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); - let mut views = BufferViewports::default(); - let mut retained_before_left = 0; - for focus in [Focus::Before, Focus::After] { - editor.handle(Input::Char('\t')); - assert_eq!(editor.diff().unwrap().focus, focus); - assert!(editor.diff_interacting()); - assert!(!editor.diff_editing()); - render_native_diff(&mut terminal, &mut views, &mut editor); - for _ in 0..40 { - editor.handle(Input::Down); - render_native_diff(&mut terminal, &mut views, &mut editor); - } - let top = editor.diff().unwrap().top; - assert!(top > 0); - let bottom = render_native_diff(&mut terminal, &mut views, &mut editor); - editor.handle(Input::Up); - let reversed = render_native_diff(&mut terminal, &mut views, &mut editor); - assert_eq!(editor.diff().unwrap().top, top); - assert_eq!(reversed.y + 1, bottom.y); - while editor - .diff() - .unwrap() - .source_display_row(focus, editor.cursor().row) - .unwrap() - > top - { - editor.handle(Input::Up); - render_native_diff(&mut terminal, &mut views, &mut editor); - assert_eq!(editor.diff().unwrap().top, top); - } - editor.handle(Input::Up); - render_native_diff(&mut terminal, &mut views, &mut editor); - assert!(editor.diff().unwrap().top < top); - for _ in 0..80 { - editor.handle(Input::Right); - render_native_diff(&mut terminal, &mut views, &mut editor); - } - let left = if focus == Focus::Before { - editor.diff().unwrap().left_before - } else { - editor.diff().unwrap().left_after - }; - assert!(left > 0); - let right = render_native_diff(&mut terminal, &mut views, &mut editor); - editor.handle(Input::Left); - let reversed = render_native_diff(&mut terminal, &mut views, &mut editor); - let offsets = views.panels.diff.as_ref().unwrap().viewport.unwrap(); - assert_eq!( - if focus == Focus::Before { - offsets.3 - } else { - offsets.4 - }, - left - ); - assert_eq!(reversed.x + 1, right.x); - while editor.cursor().column > left { - editor.handle(Input::Left); - render_native_diff(&mut terminal, &mut views, &mut editor); - let diff = editor.diff().unwrap(); - assert_eq!( - if focus == Focus::Before { - diff.left_before - } else { - diff.left_after - }, - left - ); - } - editor.handle(Input::Left); - render_native_diff(&mut terminal, &mut views, &mut editor); - if focus == Focus::Before { - retained_before_left = editor.diff().unwrap().left_before; - assert!(retained_before_left < left); - } else { - assert!(editor.diff().unwrap().left_after < left); - assert_eq!(editor.diff().unwrap().left_before, retained_before_left); - } - } - } - - #[test] - fn diff_semantic_colors_stay_buffer_local_across_focus_changes() { - for writable in [false, true] { - let directory = Directory::new(); - let path = directory.file("live.rs", "let value = 2;"); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - editor.execute("diff"); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); - editor.handle(Input::Down); - editor.handle(Input::Enter); - editor.handle(Input::Enter); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result( - id, - Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { - root: directory.0.clone(), - editable_paths: if writable { - [std::path::PathBuf::from("live.rs")].into() - } else { - Default::default() - }, - before: "baseline".into(), - after: "Working checkout".into(), - revisions: ["before-snapshot".into(), "after-snapshot".into()], - files: vec![syntax_diff_file( - "live.rs", - "let value = 1;", - "let value = 2;", - )], - })), - ); - editor.handle(Input::Char('\t')); - assert_eq!(editor.diff_buffer_focus(), Some(Focus::Before)); - editor - .replace_semantic_tokens( - editor.buffer_id(), - editor.text_revision(), - Some(Arc::from([semantic_token( - 0, - 4, - 9, - SemanticTokenKind::Parameter, - 0, - )])), - ) - .unwrap(); - editor.handle(Input::Char('\t')); - editor - .replace_semantic_tokens( - editor.buffer_id(), - editor.text_revision(), - Some(Arc::from([semantic_token( - 0, - 4, - 9, - SemanticTokenKind::Function, - SemanticTokenModifier::Declaration as u16, - )])), - ) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); - let mut views = BufferViewports::default(); - views.set_theme(Some(LIGHT_DIFF_THEME)); - for _ in 0..4 { - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let regions = views.panels.diff.as_ref().unwrap().regions; - for (index, color, bold) in - [(1, Color::LightCyan, false), (2, Color::LightBlue, true)] - { - let pane = regions[index].0; - let cell = &terminal.backend().buffer()[(pane.x + 8, pane.y + 1)]; - assert_eq!(cell.symbol(), "v"); - assert_eq!(cell.fg, color); - assert_eq!(cell.modifier.contains(ratatui::style::Modifier::BOLD), bold); - } - editor.handle(Input::Char('\t')); - } - if writable { - while editor.diff_buffer_focus() != Some(Focus::After) - || editor.diff().unwrap().focus != Focus::After - { - editor.handle(Input::Char('\t')); - } - assert!(editor.diff_editing()); - editor.handle(Input::Char('A')); - assert_eq!(editor.mode(), Mode::Insert); - for input in " // typing".chars().map(Input::Char).chain([ - Input::Escape, - Input::Char('u'), - Input::Char('U'), - ]) { - editor.handle(input.clone()); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let regions = views.panels.diff.as_ref().unwrap().regions; - for (index, color) in [(1, Color::LightCyan), (2, Color::LightBlue)] { - let pane = regions[index].0; - let cell = &terminal.backend().buffer()[(pane.x + 8, pane.y + 1)]; - assert_eq!(cell.symbol(), "v"); - assert_eq!(cell.fg, color, "semantic color flashed after {input:?}"); - } - } - assert_eq!(editor.lines(), ["let value = 2; // typing"]); - } - editor - .replace_semantic_tokens(editor.buffer_id(), editor.text_revision(), None) - .unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let regions = views.panels.diff.as_ref().unwrap().regions; - let before = regions[1].0; - let after = regions[2].0; - assert_eq!( - terminal.backend().buffer()[(before.x + 8, before.y + 1)].fg, - Color::LightCyan - ); - let value = &terminal.backend().buffer()[(after.x + 8, after.y + 1)]; - assert_eq!(value.fg, Color::Reset); - assert!(!value.modifier.contains(ratatui::style::Modifier::BOLD)); - assert_eq!( - terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg, - Color::Magenta - ); - } - } - - #[test] - fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt() { - let directory = Directory::new(); - let path = directory.file("live.rs", "let value = \"new\";"); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - editor.set_clipboard_mode("separate").unwrap(); - editor.execute("diff"); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); - editor.handle(Input::Down); - editor.handle(Input::Enter); - editor.handle(Input::Enter); - let (id, _) = editor.take_diff_request().unwrap(); - editor.apply_diff_result( - id, - Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { - root: directory.0.clone(), - editable_paths: [std::path::PathBuf::from("live.rs")].into(), - before: "baseline".into(), - after: "Working checkout".into(), - revisions: ["before-snapshot".into(), "after-snapshot".into()], - files: vec![syntax_diff_file( - "live.rs", - "let value = \"old\";", - "let value = \"new\";", - )], - })), - ); - editor.handle(Input::Char('\t')); - editor.handle(Input::Char('\t')); - assert!(editor.diff_editing()); - editor.handle(Input::Char('c')); - editor.handle(Input::Char('c')); - editor.handle(Input::Paste("// live comment".into())); - editor.handle(Input::Escape); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let after = views.panels.diff.as_ref().unwrap().regions[2].0; - let caret = terminal.get_cursor_position().unwrap(); - assert!(after.contains(caret)); - let text_x = after.x + 4; - assert_eq!(terminal.backend().buffer()[(text_x, caret.y)].symbol(), "/"); - assert_eq!( - terminal.backend().buffer()[(text_x, caret.y)].fg, - Color::DarkGray - ); - editor.handle(Input::Home); - editor.handle(Input::Char('v')); - editor.handle(Input::Right); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(text_x, caret.y)].bg, - Color::Blue - ); - editor.handle(Input::Escape); - editor.handle(Input::Char(':')); - for ch in "diff.close".chars() { - editor.handle(Input::Char(ch)); - } - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - editor.handle(Input::Enter); - assert!(editor.diff().is_none()); - assert_eq!(editor.lines(), ["// live comment"]); - assert!(editor.dirty()); - } - - #[test] - fn session_diff_draw_retains_rendered_offsets_until_caret_crosses_edges() { - let directory = Directory::new(); - let original = (0..80) - .map(|row| format!("old row {row:03} {}", "x".repeat(100))) - .collect::>() - .join("\n"); - let path = directory.file("source.txt", &original); - let git = |args: &[&str]| { - let output = std::process::Command::new("git") - .args(args) - .current_dir(&directory.0) - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_AUTHOR_NAME", "Diff fixture") - .env("GIT_AUTHOR_EMAIL", "fixture@example.invalid") - .env("GIT_COMMITTER_NAME", "Diff fixture") - .env("GIT_COMMITTER_EMAIL", "fixture@example.invalid") - .env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z") - .env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z") - .output() - .unwrap(); - assert!(output.status.success(), "{output:?}"); - }; - git(&["init", "-q"]); - git(&["add", "."]); - git(&["commit", "-qm", "base"]); - directory.file("source.txt", &original.replace("old row", "new row")); - let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); - let ready = |session: &mut EditorSession, phase| { - let deadline = std::time::Instant::now() + Duration::from_secs(10); - loop { - session.poll(); - let diff = session.editor().diff().unwrap(); - assert!(diff.error.is_none(), "{:?}", diff.error); - if diff.phase == phase && !diff.loading { - break; - } - assert!(std::time::Instant::now() < deadline, "{diff:?}"); - std::thread::sleep(Duration::from_millis(1)); - } - }; - session.execute("diff"); - ready(&mut session, Phase::Before); - session.handle(Input::Down); - session.handle(Input::Enter); - session.handle(Input::Enter); - ready(&mut session, Phase::View); - session.handle(Input::Char('\t')); - session.handle(Input::Char('\t')); - assert!(session.editor().diff_editing()); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); - let mut views = BufferViewports::default(); - let mut render = |session: &mut EditorSession| { - terminal - .draw(|frame| views.draw_session(frame, session)) - .unwrap(); - terminal.get_cursor_position().unwrap() - }; - render(&mut session); - for _ in 0..40 { - session.handle(Input::Down); - render(&mut session); - } - let top = session.editor().diff().unwrap().top; - assert!(top > 0); - let bottom_caret = render(&mut session); - session.handle(Input::Up); - let reversed_caret = render(&mut session); - assert_eq!(session.editor().diff().unwrap().top, top); - assert_eq!(reversed_caret.y + 1, bottom_caret.y); - while session - .editor() - .diff() - .unwrap() - .after_display_row(session.editor().cursor().row) - .unwrap() - > top - { - session.handle(Input::Up); - render(&mut session); - assert_eq!(session.editor().diff().unwrap().top, top); - } - session.handle(Input::Up); - render(&mut session); - assert!(session.editor().diff().unwrap().top < top); - for _ in 0..80 { - session.handle(Input::Right); - render(&mut session); - } - let left = session.editor().diff().unwrap().left_after; - assert!(left > 0); - let right_caret = render(&mut session); - session.handle(Input::Left); - let reversed_caret = render(&mut session); - assert_eq!(session.editor().diff().unwrap().left_after, left); - assert_eq!(reversed_caret.x + 1, right_caret.x); - while session.editor().cursor().column > left { - session.handle(Input::Left); - render(&mut session); - assert_eq!(session.editor().diff().unwrap().left_after, left); - } - session.handle(Input::Left); - render(&mut session); - assert!(session.editor().diff().unwrap().left_after < left); - } - - #[test] - fn diff_wheel_routes_right_tree_and_both_panes_without_touching_document() { - let directory = Directory::new(); - let path = directory.file("source.txt", &["original"; 60].join("\n")); - let git = |args: &[&str]| { - let output = std::process::Command::new("git") - .args(args) - .current_dir(&directory.0) - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_AUTHOR_NAME", "Diff fixture") - .env("GIT_AUTHOR_EMAIL", "fixture@example.invalid") - .env("GIT_COMMITTER_NAME", "Diff fixture") - .env("GIT_COMMITTER_EMAIL", "fixture@example.invalid") - .env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z") - .env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z") - .output() - .unwrap(); - assert!(output.status.success(), "{:?}", output); - }; - git(&["init", "-q"]); - for index in 0..8 { - directory.file( - &format!("file-{index}.txt"), - &format!("{}\nsecond\nthird\n", "before.".repeat(30)), - ); - } - git(&["add", "."]); - git(&["commit", "-qm", "base"]); - git(&["commit", "--allow-empty", "-qm", "unmatched checkout"]); - for index in 0..8 { - directory.file( - &format!("file-{index}.txt"), - &format!("{}\nsecond\nthird\n", "after.".repeat(30)), - ); - } - let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); - let original = session.editor().lines().to_vec(); - let cursor = session.editor().cursor(); - let ready = |session: &mut EditorSession, phase| { - let deadline = std::time::Instant::now() + Duration::from_secs(10); - loop { - session.poll(); - let diff = session.editor().diff().unwrap(); - assert!(diff.error.is_none(), "{:?}", diff.error); - if diff.phase == phase && !diff.loading { - break; - } - assert!(std::time::Instant::now() < deadline, "{diff:?}"); - std::thread::sleep(Duration::from_millis(1)); - } - }; - session.execute("diff"); - ready(&mut session, Phase::Before); - session.handle(Input::Enter); - session.handle(Input::Down); - session.handle(Input::Down); - session.handle(Input::Enter); - ready(&mut session, Phase::View); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - let regions = viewports.panels.diff.as_ref().unwrap().regions; - let tree = regions[0].0; - let before = regions[1].0; - let after = regions[2].0; - let initial_focus = session.editor().diff().unwrap().focus; - let initial_cursor = session.editor().cursor(); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollRight, before.x + 1, before.y + 1) - )); - terminal - .draw(|frame| viewports.draw_session(frame, &mut session)) - .unwrap(); - assert!(screen_text(&terminal).contains("ore.before.")); - assert_eq!(session.editor().diff().unwrap().left_before, 3); - assert_eq!(session.editor().diff().unwrap().left_after, 3); - let mut shift = document_wheel(MouseEventKind::ScrollDown, after.x + 1, after.y + 1); - shift.modifiers = KeyModifiers::SHIFT; - assert!(viewports.scroll(&mut session, shift)); - terminal - .draw(|frame| viewports.draw_session(frame, &mut session)) - .unwrap(); - assert!(screen_text(&terminal).contains("after.after.")); - assert_eq!(session.editor().diff().unwrap().left_before, 6); - assert_eq!(session.editor().diff().unwrap().left_after, 6); - assert_eq!(session.editor().diff().unwrap().top, 0); - assert_eq!(session.editor().diff().unwrap().focus, initial_focus); - assert_eq!(session.editor().cursor(), initial_cursor); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, tree.x + 1, tree.y + 1), - )); - let diff = session.editor().diff().unwrap(); - assert_eq!(diff.focus, Focus::Files); - assert_eq!(diff.files.as_ref().unwrap().selected, 3); - assert_eq!(diff.file, 0); - session.handle(Input::Enter); - assert_eq!(session.editor().diff().unwrap().file, 3); - ready(&mut session, Phase::View); - terminal - .draw(|frame| viewports.draw_session(frame, &mut session)) - .unwrap(); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1), - )); - let diff = session.editor().diff().unwrap(); - assert_eq!(diff.focus, Focus::Before); - assert_eq!(diff.top, 2); - assert_eq!(diff.file, 3); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollUp, after.x + 1, after.y + 1), - )); - let diff = session.editor().diff().unwrap(); - assert_eq!(diff.focus, Focus::After); - assert_eq!(diff.top, 0); - assert_eq!(diff.file, 3); - let selected = diff.files.as_ref().unwrap().selected; - session.handle(Input::Char(' ')); - session.handle(Input::Char('e')); - session.handle(Input::Ctrl('w')); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, tree.x + 1, tree.y + 1), - )); - let diff = session.editor().diff().unwrap(); - assert_eq!(diff.focus, Focus::After); - assert_eq!(diff.files.as_ref().unwrap().selected, selected); - assert!(!diff.files.as_ref().unwrap().focused); - assert!(viewports.panels.files.is_none()); - assert!(viewports.document.geometry.is_none()); - session.execute("diff.close"); - assert_eq!(session.editor().lines(), original); - assert_eq!(session.editor().cursor(), cursor); - } - - #[test] - fn diff_wheel_cannot_scroll_the_hidden_document_even_outside_the_picker() { - let directory = Directory::new(); - let path = directory.file("rows.txt", &["original"; 60].join("\n")); - let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - let id = session.editor().buffer_id(); - let offset = viewports.offsets[&id]; - let cursor = session.editor().cursor(); - session.execute("diff"); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 0, 0) - )); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 60, 15) - )); - assert_eq!(viewports.offsets[&id], offset); - assert_eq!(session.editor().cursor(), cursor); - assert!(viewports.document.geometry.is_none()); - session.execute("diff.close"); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(viewports.document.geometry.is_some()); - } - - #[test] - fn completion_windows_selected_unicode_without_overwriting_gutter_dock_or_cursor() { - let mut completion = Completion { - items: (0..20) - .map(|index| crate::plugin::CompletionItem { - label: if index == 19 { - "界e\u{301}👩‍🚀\u{1b}".into() - } else { - format!("item-{index:02}") - }, - detail: Some("\tTAIL".into()), - primary_edit: 0, - edits: vec![], - snippet: None, - }) - .collect(), - selected: 20, - }; - for (width, height) in [(14, 6), (9, 1), (5, 1)] { - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); - let workspace = Rect::new(2, 0, width - 4, height); - let caret = Position::new(2, 0); - let mut panel = None; - completion.selected = 20; - terminal - .draw(|frame| { - let rows: Vec = (0..height) - .map(|_| Line::from(format!("GG{}EE", ".".repeat(usize::from(width - 4))))) - .collect(); - frame.render_widget(Paragraph::new(rows), frame.area()); - frame.set_cursor_position(caret); - panel = draw_completion( - frame, - &completion, - workspace, - caret, - &mut WheelPanels::default(), - ); - }) - .unwrap(); - let mut panel = panel.unwrap(); - let selected_y = panel.bottom() - 1; - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(panel.x, selected_y)].bg, Color::Cyan); - assert!(!screen_text(&terminal).contains("item-00")); - for y in 0..height { - for x in [0, 1] { - assert_eq!(buffer[(x, y)].symbol(), "G"); - } - for x in [width - 2, width - 1] { - assert_eq!(buffer[(x, y)].symbol(), "E"); - } - } - if workspace.width >= 5 { - assert_eq!(buffer[(panel.x, selected_y)].symbol(), "界"); - assert_eq!(buffer[(panel.x + 2, selected_y)].symbol(), "e\u{301}"); - assert_eq!(buffer[(panel.x + 3, selected_y)].symbol(), "👩‍🚀"); - } else { - assert_eq!(buffer[(panel.x, selected_y)].symbol(), " "); - } - if workspace.width >= 6 { - assert_eq!(buffer[(panel.x + 5, selected_y)].symbol(), "�"); - } - assert_eq!(terminal.get_cursor_position().unwrap(), caret); - completion.selected = 0; - terminal - .draw(|frame| { - frame.set_cursor_position(caret); - panel = draw_completion( - frame, - &completion, - workspace, - caret, - &mut WheelPanels::default(), - ) - .unwrap(); - }) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(panel.x, panel.y)].symbol(), - "O" - ); - assert_eq!( - terminal.backend().buffer()[(panel.x, panel.y)].bg, - Color::Cyan - ); - assert_eq!(terminal.get_cursor_position().unwrap(), caret); - } - } - - #[test] - fn snippet_highlight_tracks_argument_graphemes_without_filling_the_row() { - use crate::plugin::{ - CompletionEdit, CompletionItem, CompletionSnippet, SnippetTabstop, TextPosition, - }; - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor.handle(Input::Char('i')); - editor - .show_completion(vec![CompletionItem { - label: "call".into(), - detail: None, - primary_edit: 0, - edits: vec![CompletionEdit { - start: TextPosition { row: 0, column: 0 }, - end: TextPosition { row: 0, column: 0 }, - text: "call(界e\u{301}, tail)".into(), - }], - snippet: Some(CompletionSnippet { - tabstops: vec![ - SnippetTabstop { - index: 1, - ranges: vec![5..11], - ancestors: vec![vec![]], - }, - SnippetTabstop { - index: 2, - ranges: vec![13..17], - ancestors: vec![vec![]], - }, - SnippetTabstop { - index: 0, - ranges: vec![18..18], - ancestors: vec![vec![]], - }, - ], - }), - }]) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(28, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - for (key, selected, caret) in [ - (None, 7..10, Position::new(7, 0)), - (Some(Input::Char('\t')), 12..16, Position::new(12, 0)), - ] { - if let Some(key) = key { - editor.handle(key); - } - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let buffer = terminal.backend().buffer(); - // A wide glyph's leading cell owns its terminal style. - for x in (0..28).filter(|x| *x != 8) { - assert_eq!( - buffer[(x, 0)].bg == Color::Blue, - selected.contains(&x), - "column {x}, selection {:?}, screen:\n{}", - editor.snippet_selections(), - screen_text(&terminal), - ); - } - assert_eq!(terminal.get_cursor_position().unwrap(), caret); - } - editor.handle(Input::Escape); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - for x in 0..28 { - assert_ne!(terminal.backend().buffer()[(x, 0)].bg, Color::Blue); - } - } - - #[test] - fn hover_shows_more_documentation_with_markdown_and_fenced_code_colors() { - let mut editor = Editor::open(None, vec![]).unwrap(); - let title = format!("# {}TAIL", "Heading ".repeat(11)); - let details = (5..60) - .map(|row| format!("detail-{row:02}")) - .collect::>() - .join("\n"); - editor - .show_hover(format!( - "{title}\n\n```rust\nfn answer() -> i32 {{ 42 }}\n```\n{details}" - )) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 48)).unwrap(); - let mut panels = WheelPanels::default(); - terminal - .draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels)) - .unwrap(); - assert!(screen_text(&terminal).contains("TAIL")); - assert!(screen_text(&terminal).contains("detail-36")); - assert!(!screen_text(&terminal).contains("detail-37")); - let (panel, _) = panels.hover.unwrap(); - let (x, y) = (panel.x + 1, panel.y + 1); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(x, y)].fg, Color::Cyan); - assert_eq!(buffer[(x, y + 3)].symbol(), "f"); - assert_eq!(buffer[(x, y + 3)].fg, Color::Magenta); - assert_eq!(buffer[(x + 6, y + 3)].fg, Color::LightBlue); - assert_eq!(buffer[(x + 21, y + 3)].fg, Color::Yellow); - - for _ in 0..3 { - editor.handle(Input::Down); - } - editor.handle(Input::Right); - terminal - .draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels)) - .unwrap(); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(x, y)].symbol(), "n"); - assert_eq!(buffer[(x, y)].fg, Color::Magenta); - assert_eq!(buffer[(x + 20, y)].fg, Color::Yellow); - - editor.show_hover("# Replacement".into()).unwrap(); - terminal - .draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels)) - .unwrap(); - assert!(screen_text(&terminal).contains("# Replacement")); - assert!(!screen_text(&terminal).contains("answer")); - } - - #[test] - fn hover_scroll_renders_whole_graphemes_in_tiny_workspace() { - let mut editor = Editor::open(None, vec![]).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(6, 1)).unwrap(); - let mut syntax = SyntaxCache::new(); - let (mut top, mut left) = (0, 0); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ); - }) - .unwrap(); - let document_cursor = terminal.get_cursor_position().unwrap(); - editor.show_hover("x界e\u{301}👩‍🚀z\nlast".into()).unwrap(); - for _ in 0..2 { - editor.handle(Input::Right); - } - let mut panels = WheelPanels::default(); - let rendered = terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut panels, - ) - }) - .unwrap(); - let origin = panels.hover.unwrap().0; - assert_eq!(rendered.buffer[(origin.x, origin.y)].symbol(), "e\u{301}"); - assert_eq!(rendered.buffer[(origin.x + 1, origin.y)].symbol(), "👩‍🚀"); - assert_eq!(rendered.buffer[(origin.x + 3, origin.y)].symbol(), "z"); - assert_eq!(terminal.get_cursor_position().unwrap(), document_cursor); - assert_eq!((top, left), (0, 0)); - editor.handle(Input::Down); - editor.handle(Input::Home); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert!(screen_text(&terminal).trim().ends_with("last")); - for (width, height) in [(1, 1), (2, 2), (3, 3), (4, 4)] { - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - } - assert_eq!(editor.cursor(), crate::editor::Cursor::default()); - assert_eq!(editor.lines(), &[""]); - } - - fn annotation( - start: (usize, usize), - end: (usize, usize), - severity: DiagnosticSeverity, - message: &str, - ) -> Diagnostic { - Diagnostic { - start: crate::plugin::TextPosition { - row: start.0, - column: start.1, - }, - end: crate::plugin::TextPosition { - row: end.0, - column: end.1, - }, - severity, - message: message.into(), - } - } - - fn semantic_token( - row: usize, - start: usize, - end: usize, - kind: SemanticTokenKind, - modifiers: u16, - ) -> SemanticToken { - SemanticToken { - start: crate::plugin::TextPosition { row, column: start }, - end: crate::plugin::TextPosition { row, column: end }, - kind, - modifiers, - } - } - - #[test] - fn diagnostic_underlines_preserve_syntax_and_whole_graphemes_at_viewport_edges() { - let directory = Directory::new(); - let source = "#a\t界e\u{301}👩‍🚀z"; - let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor - .replace_diagnostics( - "fixture", - editor.buffer_id(), - editor.text_revision(), - vec![ - annotation((0, 2), (0, 6), DiagnosticSeverity::Warning, "range"), - annotation((0, 4), (0, 5), DiagnosticSeverity::Error, "combining"), - ], - ) - .unwrap(); - let mut syntax = SyntaxCache::new(); - for (offset, width, caret, underlines) in [ - ( - 2, - 5, - 4, - vec![ - Some(Color::Yellow), - Some(Color::Yellow), - Some(Color::Yellow), - Some(Color::Yellow), - Some(Color::Red), - ], - ), - (5, 2, 4, vec![None, Some(Color::Red)]), - (7, 2, 5, vec![Some(Color::Yellow), Some(Color::Yellow)]), - (8, 2, 6, vec![None, None]), - ( - 0, - 5, - 2, - vec![None, None, Some(Color::Yellow), Some(Color::Yellow), None], - ), - ] { - editor.handle(Input::Home); - for _ in 0..caret { - editor.handle(Input::Right); - } - if editor.notification_visible() && editor.diagnostic_at_cursor().is_some() { - editor.handle(Input::Escape); - } - let area = Rect::new(3, 2, width + 2, 4); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(20, 8), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - let (mut top, mut left) = (0, offset); - let rendered = terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap() - .buffer - .clone(); - assert_eq!(left, offset); - for (cell, underline) in underlines.into_iter().enumerate() { - let position = (area.x + 2 + cell as u16, area.y); - assert_eq!( - rendered[position].modifier.contains(Modifier::UNDERLINED), - underline.is_some(), - "left={offset}, cell={cell}", - ); - if let Some(color) = underline { - assert_eq!(rendered[position].underline_color, color); - assert_eq!(rendered[position].fg, Color::DarkGray); - } - assert_eq!(rendered[position].bg, Color::Reset); - } - assert_eq!(rendered[(area.x, area.y)].fg, Color::Red); - assert_eq!( - terminal.backend().buffer()[(0, 0)].modifier, - Modifier::empty() - ); - assert_eq!(editor.lines(), &[source]); - } - } - - #[test] - fn diagnostics_render_newlines_anchors_counts_and_messages_without_hiding_core_errors() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor - .apply(vec![crate::editor::Effect::Replace( - "abc\n\nxy\nlast".into(), - )]) - .unwrap(); - editor - .replace_diagnostics( - "fixture", - editor.buffer_id(), - editor.text_revision(), - vec![ - annotation((0, 2), (0, 2), DiagnosticSeverity::Error, "point"), - annotation((0, 1), (3, 0), DiagnosticSeverity::Warning, "multiline"), - annotation((3, 4), (3, 4), DiagnosticSeverity::Information, "end"), - annotation((3, 0), (3, 4), DiagnosticSeverity::Hint, "hint"), - ], - ) - .unwrap(); - editor.handle(Input::Home); - editor.handle(Input::Right); - editor.handle(Input::Right); - editor.set_message("write failed: permission denied"); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 7)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let buffer = terminal.backend().buffer(); - for (position, color) in [ - ((3, 0), Color::Yellow), - ((4, 0), Color::Red), - ((5, 0), Color::Yellow), - ((2, 1), Color::Yellow), - ((2, 2), Color::Yellow), - ((3, 2), Color::Yellow), - ((4, 2), Color::Yellow), - ((2, 3), Color::DarkGray), - ((6, 3), Color::Cyan), - ] { - assert!(buffer[position].modifier.contains(Modifier::UNDERLINED)); - assert_eq!(buffer[position].underline_color, color); - } - for (row, color) in [ - (0, Color::Red), - (1, Color::Yellow), - (2, Color::Yellow), - (3, Color::Cyan), - ] { - assert_eq!(buffer[(0, row)].fg, color); - } - assert!(!buffer[(6, 0)].modifier.contains(Modifier::UNDERLINED)); - let text = screen_text(&terminal); - assert!(text.contains("E:1 W:1 I:1 H:1"), "{text}"); - assert!( - text.contains("write failed: permission denied | E: point"), - "{text}" - ); - assert_eq!( - terminal.get_cursor_position().unwrap(), - ratatui::layout::Position::new(4, 0), - ); - - editor.handle(Input::Char('i')); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(editor.diagnostics().count(), 0); - assert!(!screen_text(&terminal).contains("E:1")); - assert!(!screen_text(&terminal).contains("E: point")); - assert_ne!(terminal.backend().buffer()[(0, 0)].fg, Color::Red); - assert!( - !terminal.backend().buffer()[(4, 0)] - .modifier - .contains(Modifier::UNDERLINED) - ); - for message in ["intermediate", "latest"] { - editor - .replace_diagnostics( - "fixture", - editor.buffer_id(), - editor.text_revision(), - vec![annotation( - (0, 0), - (0, 3), - DiagnosticSeverity::Error, - message, - )], - ) - .unwrap(); - } - editor.handle(Input::Escape); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert!(screen_text(&terminal).contains("E:1")); - assert!(screen_text(&terminal).contains("E: latest")); - assert!(!screen_text(&terminal).contains("intermediate")); - assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Red); - assert!( - terminal.backend().buffer()[(3, 0)] - .modifier - .contains(Modifier::UNDERLINED) - ); - editor.handle(Input::Char('i')); - editor.handle(Input::Char('!')); - assert_eq!(editor.mode(), Mode::Insert); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(editor.diagnostics().count(), 0); - assert!(!screen_text(&terminal).contains("E:1")); - assert!( - !terminal.backend().buffer()[(4, 0)] - .modifier - .contains(Modifier::UNDERLINED) - ); - editor.handle(Input::Escape); - assert_eq!(editor.diagnostics().count(), 0); - } - - #[test] - fn idle_session_updates_redraw_annotations_and_navigation_while_native_editing_continues() { - let directory = Directory::new(); - let source = directory.file("source.txt", "broken TARGET"); - let target_text = format!("{}{}TARGET", "# line\n".repeat(15), "#".repeat(40)); - let target = directory.file("target.py", &target_text); - directory.file( - "semantic.rb", - r#" -Vamp.plugin "semantic", requires: ["text", "diagnostics"], async: true -work = Queue.new -Vamp.on_document { |event| work << ["document", event] } -Vamp.command "fixture_definition" do |editor, _| - raise "No target reference" unless editor.text.include?("TARGET") - work << ["definition", editor.buffer_id, editor.revision, editor.request_id] -end -Thread.new do - documents = {} - loop do - kind, *args = work.pop - if kind == "document" - event = args[0] - document = event.fetch("document") - if event.fetch("event") == "close" - documents.delete(document.fetch("id")) - next - end - documents[document.fetch("id")] = document - next unless ["open", "change"].include?(event.fetch("event")) - items = [] - document.fetch("text").split("\n", -1).each_with_index do |line, row| - graphemes = line.each_grapheme_cluster.to_a - graphemes.each_cons(6).with_index do |token, column| - next unless token.join == "broken" - items << {start: {row: row, column: column}, - end: {row: row, column: column + 6}, - severity: "error", message: "Unresolved token"} - end - end - Vamp.publish([{type: "diagnostics", buffer_id: document.fetch("id"), - revision: document.fetch("revision"), items: items}, - {type: "message", text: "diagnostic revision #{document.fetch('revision')}"}]) - else - target = documents.values.find { |document| document["path"] == Vamp.options.fetch("target") } - raise "Target document is not open" unless target - lines = target.fetch("text").split("\n", -1) - row = lines.index { |line| line.include?("TARGET") } - raise "Target definition missing" unless row - column = lines[row].each_grapheme_cluster.to_a.each_cons(6).find_index { |token| token.join == "TARGET" } - Vamp.publish([{type: "navigate", buffer_id: args[0], revision: args[1], - path: target.fetch("path"), row: row, column: column}], - request_id: args[2]) - end - end -end -"#, - ); - let config = directory.file( - "config.rb", - &format!( - "Vamp.configure do |config|\n config.clipboard_mode = \"separate\"\n config.plugin \"semantic.rb\", options: {{target: {}}}\nend\n", - serde_json::to_string(&target.to_string_lossy()).unwrap(), - ), - ); - let mut session = EditorSession::open( - Some(source), - CAPABILITIES - .iter() - .map(|capability| (*capability).into()) - .collect(), - Some(&config), - ) - .unwrap(); - session - .editor_mut() - .set_gutter_style(crate::gutter::GutterStyle::None); - let source_id = session.editor().buffer_id(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(18, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - - fn poll_until( - session: &mut EditorSession, - terminal: &mut Terminal, - viewports: &mut BufferViewports, - ready: impl Fn(&Editor) -> bool, - ) { - for _ in 0..2_000 { - if session.poll() { - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - } - if ready(session.editor()) { - return; - } - std::thread::sleep(Duration::from_millis(1)); - } - panic!( - "async fixture did not update editor: {}", - session.editor().message() - ); - } - - session.handle(Input::Char('A')); - session.handle(Input::Char('!')); - assert_eq!(session.editor().mode(), Mode::Insert); - assert_eq!(session.editor().lines(), &["broken TARGET!"]); - poll_until(&mut session, &mut terminal, &mut viewports, |editor| { - editor.message() == "diagnostic revision 1" - }); - assert_eq!(session.editor().diagnostics().count(), 0); - assert!(!screen_text(&terminal).contains("E:1")); - assert!( - !terminal.backend().buffer()[(2, 0)] - .modifier - .contains(Modifier::UNDERLINED) - ); - assert_eq!(session.editor().mode(), Mode::Insert); - session.handle(Input::Escape); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(screen_text(&terminal).contains("E:1")); - assert!( - terminal.backend().buffer()[(2, 0)] - .modifier - .contains(Modifier::UNDERLINED) - ); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), &["broken TARGET"]); - assert_eq!(session.editor().diagnostics().count(), 0); - - session.execute(&format!("edit {}", target.display())); - let target_id = session.editor().buffer_id(); - session.handle(Input::Char('i')); - session.handle(Input::Char('!')); - session.handle(Input::Escape); - session.execute(&format!("buffer {source_id}")); - session.execute("fixture_definition"); - session.handle(Input::Char('l')); - assert_eq!(session.editor().buffer_id(), source_id); - poll_until(&mut session, &mut terminal, &mut viewports, |editor| { - editor.buffer_id() == target_id - }); - assert_eq!(session.editor().buffer_count(), 2); - assert!(session.editor().dirty()); - assert_eq!(session.editor().lines()[0], "!# line"); - assert_eq!(session.editor().cursor().row, 15); - assert_eq!(session.editor().cursor().column, 40); - assert_eq!(session.editor().mode(), Mode::Normal); - if session.editor().notification_visible() && !session.editor().message().is_empty() { - session.handle(Input::Escape); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - } - let text_viewport = viewports.panels.caret.unwrap().0; - let offsets = viewports.offsets[&target_id]; - assert_eq!( - offsets.0, - session.editor().cursor().row + 1 - usize::from(text_viewport.height) - ); - assert!(offsets.1 > 0); - assert!(text_viewport.contains(terminal.get_cursor_position().unwrap())); - let cursor = terminal.get_cursor_position().unwrap(); - assert_eq!( - terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), - "T" - ); - assert_eq!( - terminal.backend().buffer()[(cursor.x, cursor.y)].fg, - Color::DarkGray - ); - assert_eq!(fs::read_to_string(target).unwrap(), target_text); - } - - #[test] - fn syntax_tokens_refresh_after_native_edits_and_undo_without_changing_text_on_draw() { - let directory = Directory::new(); - let path = directory.file("number.rs", "42"); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor.set_clipboard_mode("separate").unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); - let revision = editor.text_revision(); - editor.handle(Input::Right); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(editor.text_revision(), revision); - assert_eq!(editor.lines(), &["42"]); - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); - - editor.handle(Input::Home); - editor.handle(Input::Char('v')); - editor.handle(Input::Right); - editor.handle(Input::Char('c')); - for ch in "\"ok\"".chars() { - editor.handle(Input::Char(ch)); - } - editor.handle(Input::Escape); - assert_eq!(editor.lines(), &["\"ok\""]); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); - assert!(editor.text_revision() > revision); - editor.handle(Input::Char('u')); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(editor.lines(), &["42"]); - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); - - editor - .apply(vec![crate::editor::Effect::Replace( - "fn main() { let n = 42; let s = \"ok\"; } // note".into(), - )]) - .unwrap(); - editor.handle(Input::Home); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let source = &editor.lines()[0]; - for (token, color) in [ - ("fn", Color::Magenta), - ("main", Color::LightBlue), - ("let", Color::Magenta), - ("42", Color::Yellow), - ("\"ok\"", Color::Green), - ] { - let x = 2 + source.find(token).unwrap() as u16; - assert_eq!(buffer[(x, 0)].fg, color, "{token}"); - assert_eq!(buffer[(x, 0)].bg, Color::Reset); - } - } - - #[test] - fn syntax_continuations_above_viewport_refresh_when_the_opening_line_changes() { - let directory = Directory::new(); - for (name, before, after, color) in [ - ( - "comment.rs", - "/*\nhidden\nvisible\n*/", - "//\nhidden\nvisible\n//", - Color::DarkGray, - ), - ( - "string.py", - "text = \"\"\"\nhidden\nvisible\n\"\"\"", - "text = \"\"\nhidden\nvisible\n\"\"", - Color::Green, - ), - ] { - let path = directory.file(name, before); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - editor.handle(Input::Down); - editor.handle(Input::Down); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 3)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let (viewport, caret) = viewports.panels.caret.unwrap(); - assert!(viewports.offsets[&editor.buffer_id()].0 > 0); - assert!(viewport.contains(caret)); - assert_eq!( - terminal.backend().buffer()[(caret.x, caret.y)].symbol(), - "v" - ); - assert_eq!( - terminal.backend().buffer()[(caret.x, caret.y)].fg, - color, - "{name}" - ); - editor - .apply(vec![crate::editor::Effect::Replace(after.into())]) - .unwrap(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(caret.x, caret.y)].symbol(), - "v" - ); - assert_eq!( - terminal.backend().buffer()[(caret.x, caret.y)].fg, - Color::Reset, - "{name}" - ); - editor.handle(Input::Char('u')); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!( - terminal.backend().buffer()[(caret.x, caret.y)].fg, - color, - "{name}" - ); - } - } - - #[test] - fn semantic_roles_remain_distinct_when_values_and_fields_are_readonly() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor - .apply(vec![crate::editor::Effect::Replace( - "ns Type local frozen field fixed param frozenparam".into(), - )]) - .unwrap(); - let readonly = SemanticTokenModifier::Readonly as u16; - editor - .replace_semantic_tokens( - editor.buffer_id(), - editor.text_revision(), - Some(Arc::from([ - semantic_token(0, 0, 2, SemanticTokenKind::Namespace, 0), - semantic_token(0, 3, 7, SemanticTokenKind::Type, 0), - semantic_token(0, 8, 13, SemanticTokenKind::Variable, 0), - semantic_token(0, 14, 20, SemanticTokenKind::Variable, readonly), - semantic_token(0, 21, 26, SemanticTokenKind::Property, 0), - semantic_token(0, 27, 32, SemanticTokenKind::Property, readonly), - semantic_token(0, 33, 38, SemanticTokenKind::Parameter, 0), - semantic_token(0, 39, 50, SemanticTokenKind::Parameter, readonly), - ])), - ) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 4)).unwrap(); - terminal - .draw(|frame| BufferViewports::default().draw(frame, &mut editor)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let foregrounds = [0, 3, 8, 14, 21, 27, 33, 39].map(|column| buffer[(column + 2, 0)].fg); - assert_ne!(foregrounds[0], foregrounds[1]); - assert_ne!(foregrounds[1], foregrounds[2]); - assert_ne!(foregrounds[2], foregrounds[4]); - assert_eq!(foregrounds[2], foregrounds[3]); - assert_eq!(foregrounds[4], foregrounds[5]); - assert_eq!(foregrounds[6], foregrounds[7]); - for (column, is_readonly) in [ - (0, false), - (3, false), - (8, false), - (14, true), - (21, false), - (27, true), - (33, false), - (39, true), - ] { - assert_eq!( - buffer[(column + 2, 0)].modifier.contains(Modifier::ITALIC), - is_readonly, - ); - } - } - - #[test] - fn semantic_colors_modifiers_and_syntax_fallback_cover_whole_graphemes_and_clipped_tabs() { - let directory = Directory::new(); - let source = "#a\t界e\u{301}👩‍🚀z"; - let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor.set_clipboard_mode("separate").unwrap(); - editor - .replace_semantic_tokens( - editor.buffer_id(), - editor.text_revision(), - Some(Arc::from([ - semantic_token( - 0, - 0, - 1, - SemanticTokenKind::Comment, - SemanticTokenModifier::Documentation as u16, - ), - semantic_token( - 0, - 2, - 6, - SemanticTokenKind::Property, - SemanticTokenModifier::Readonly as u16 - | SemanticTokenModifier::Definition as u16 - | SemanticTokenModifier::Deprecated as u16, - ), - semantic_token(0, 2, 3, SemanticTokenKind::Variable, 0), - ])), - ) - .unwrap(); - editor - .replace_diagnostics( - "fixture", - editor.buffer_id(), - editor.text_revision(), - vec![annotation( - (0, 2), - (0, 6), - DiagnosticSeverity::Warning, - "semantic", - )], - ) - .unwrap(); - let mut syntax = SyntaxCache::new(); - for (offset, width, caret, coverage) in [ - (2, 5, 4, vec![true; 5]), - (5, 2, 4, vec![false, true]), - (7, 2, 5, vec![true; 2]), - (8, 2, 6, vec![false; 2]), - (0, 5, 2, vec![false, false, true, true, false]), - ] { - editor.handle(Input::Home); - for _ in 0..caret { - editor.handle(Input::Right); - } - if editor.notification_visible() && editor.diagnostic_at_cursor().is_some() { - editor.handle(Input::Escape); - } - let area = Rect::new(3, 2, width + 2, 4); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(20, 8), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - let (mut top, mut left) = (0, offset); - let rendered = terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap() - .buffer - .clone(); - assert_eq!(left, offset); - let text_area = Rect::new(area.x + 2, area.y, width, 1); - let mut expected = ratatui::buffer::Buffer::empty(text_area); - use ratatui::widgets::Widget; - Paragraph::new(visible_line(source, offset, usize::from(width))) - .render(text_area, &mut expected); - let buffer = terminal.backend().buffer(); - for (cell, semantic_cell) in coverage.into_iter().enumerate() { - let position = (text_area.x + cell as u16, text_area.y); - assert_eq!(buffer[position].symbol(), expected[position].symbol()); - assert_eq!(rendered[position].bg, Color::Reset); - assert_eq!( - rendered[position].modifier.contains(Modifier::BOLD), - semantic_cell - ); - assert_eq!( - rendered[position].modifier.contains(Modifier::CROSSED_OUT), - semantic_cell - ); - assert_eq!( - rendered[position].modifier.contains(Modifier::UNDERLINED), - semantic_cell - ); - if semantic_cell { - assert_eq!(rendered[position].underline_color, Color::Yellow); - } - assert_eq!( - rendered[position].modifier.contains(Modifier::ITALIC), - semantic_cell || (offset == 0 && cell == 0) - ); - } - assert_eq!(buffer[(area.x, area.y)].fg, Color::Yellow); - assert_eq!(buffer[(0, 0)].fg, Color::Reset); - assert_eq!(editor.lines(), &[source]); - } - editor - .replace_semantic_tokens(editor.buffer_id(), editor.text_revision(), None) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 4)).unwrap(); - let (mut top, mut left) = (0, 0); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::DarkGray); - assert!( - !terminal.backend().buffer()[(2, 0)] - .modifier - .contains(Modifier::ITALIC) - ); - } - - #[test] - fn search_visual_selection_and_secondary_carets_override_semantic_foreground() { - let directory = Directory::new(); - let mut editor = Editor::open( - Some(directory.file("overlays.py", "# word\n# word\n# word")), - vec![], - ) - .unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor - .replace_semantic_tokens( - editor.buffer_id(), - editor.text_revision(), - Some(Arc::from( - (0..3) - .map(|row| { - semantic_token( - row, - 2, - 6, - SemanticTokenKind::Function, - SemanticTokenModifier::Declaration as u16, - ) - }) - .collect::>(), - )), - ) - .unwrap(); - editor - .replace_diagnostics( - "fixture", - editor.buffer_id(), - editor.text_revision(), - vec![annotation( - (0, 2), - (2, 6), - DiagnosticSeverity::Error, - "overlays", - )], - ) - .unwrap(); - editor.handle(Input::Char('/')); - for ch in "word".chars() { - editor.handle(Input::Char(ch)); - } - editor.handle(Input::Enter); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let found = editor.search_match().unwrap(); - let y = found.start.row as u16; - for x in 4..8 { - assert_eq!(terminal.backend().buffer()[(x, y)].fg, Color::Black); - assert_eq!(terminal.backend().buffer()[(x, y)].bg, Color::Yellow); - assert!( - terminal.backend().buffer()[(x, y)] - .modifier - .contains(Modifier::UNDERLINED) - ); - } - editor.handle(Input::Char('v')); - editor.handle(Input::Char('e')); - editor.handle(Input::Ctrl('a')); - assert_eq!(editor.selection_count(), 3); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let buffer = terminal.backend().buffer(); - for selection in editor.selections() { - let y = selection.start.row as u16; - let start = - display_column(&editor.lines()[selection.start.row], selection.start.column); - let end = display_column(&editor.lines()[selection.end.row], selection.end.column); - for column in start..end { - let x = 2 + column as u16; - if buffer[(x, y)].bg == Color::Magenta { - assert_eq!(buffer[(x, y)].fg, Color::Black); - } else { - assert_eq!(buffer[(x, y)].fg, Color::White); - assert_eq!(buffer[(x, y)].bg, Color::Blue); - } - assert!(buffer[(x, y)].modifier.contains(Modifier::UNDERLINED)); - } - assert_eq!(buffer[(2, y)].fg, Color::DarkGray); - } - for caret in editor - .carets() - .into_iter() - .filter(|caret| *caret != editor.cursor()) - { - let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16; - assert_eq!(buffer[(x, caret.row as u16)].fg, Color::Black); - assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta); - assert!( - buffer[(x, caret.row as u16)] - .modifier - .contains(Modifier::UNDERLINED) - ); - } - } - - #[test] - fn syntax_redetects_saved_paths_without_text_changes_and_plain_text_keeps_default_color() { - let directory = Directory::new(); - let mut editor = - Editor::open(Some(directory.file("source.rs", "let value = 42;")), vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta); - let revision = editor.text_revision(); - for name in ["source.vampunknown", "source.txt", "again.rs"] { - editor.execute(&format!("w {}", directory.0.join(name).display())); - assert_eq!(editor.text_revision(), revision); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - if name.ends_with(".rs") { - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta); - assert_eq!( - viewports.syntax[&editor.buffer_id()].language(), - Some("Rust") - ); - } else { - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Reset); - assert!(viewports.syntax[&editor.buffer_id()].line(0).is_empty()); - assert!(viewports.syntax[&editor.buffer_id()].language().is_none()); - } - assert_eq!(editor.lines(), &["let value = 42;"]); - } - } - - #[test] - fn syntax_caches_stay_independent_across_buffer_edits_and_retire_closed_ids() { - let directory = Directory::new(); - let mut editor = Editor::open(Some(directory.file("a.rs", "42")), vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - let a_id = editor.buffer_id(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); - editor.execute(&format!( - "edit {}", - directory.file("b.py", "# note").display() - )); - let b_id = editor.buffer_id(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::DarkGray); - editor - .apply(vec![crate::editor::Effect::Replace("\"ok\"".into())]) - .unwrap(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); - editor.execute(&format!("buffer {a_id}")); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); - assert_eq!(viewports.syntax[&a_id].language(), Some("Rust")); - assert_eq!(viewports.syntax[&b_id].language(), Some("Python")); - editor.execute(&format!("buffer {b_id}")); - editor.execute(&format!("bdelete {a_id}")); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); - assert!(!viewports.syntax.contains_key(&a_id)); - assert!(!viewports.offsets.contains_key(&a_id)); - assert_eq!(viewports.syntax.len(), 1); - } - - #[test] - fn diff_control_i_restores_jump_location_without_cycling_panes() { - for unified in [false, true] { - for focus in [Focus::Before, Focus::After] { - for code in [KeyCode::Char('i'), KeyCode::Tab] { - let mut editor = readonly_diff_editor(syntax_diff_file( - "jump.txt", - "old\nmiddle\ntarget", - "new\nmiddle\ntarget", - )); - if unified { - editor.execute("diff.view.toggle"); - } - for _ in 0..3 { - if editor.diff().unwrap().focus == focus { - break; - } - editor.handle(Input::Char('\t')); - } - assert_eq!(editor.diff().unwrap().focus, focus); - let buffer = editor.buffer_id(); - for ch in "/target".chars() { - editor.handle(Input::Char(ch)); - } - editor.handle(Input::Enter); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.cursor(), Cursor::default()); - editor.handle(translate(KeyEvent::new(code, KeyModifiers::CONTROL)).unwrap()); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - assert_eq!(editor.diff().unwrap().focus, focus); - assert_eq!(editor.buffer_id(), buffer); - assert!(!editor.dirty()); - editor.handle( - translate(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)).unwrap(), - ); - assert_ne!(editor.diff().unwrap().focus, focus); - for _ in 0..3 { - if editor.diff().unwrap().focus == Focus::Files { - break; - } - editor.handle(Input::Char('\t')); - } - assert_eq!(editor.diff().unwrap().focus, Focus::Files); - editor.handle(Input::Ctrl('o')); - assert_eq!(editor.cursor(), Cursor::default()); - assert_eq!(editor.diff().unwrap().focus, focus); - for _ in 0..3 { - if editor.diff().unwrap().focus == Focus::Files { - break; - } - editor.handle(Input::Char('\t')); - } - assert_eq!(editor.diff().unwrap().focus, Focus::Files); - editor.handle(translate(KeyEvent::new(code, KeyModifiers::CONTROL)).unwrap()); - assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); - assert_eq!(editor.diff().unwrap().focus, Focus::Files); - } - } - } - } - - #[test] - fn translate_distinguishes_control_j_from_enter_and_normalizes_control_letters() { - for (code, modifiers, expected) in [ - ( - KeyCode::Char('j'), - KeyModifiers::CONTROL, - Some(Input::Ctrl('j')), - ), - ( - KeyCode::Char('J'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - Some(Input::Ctrl('j')), - ), - ( - KeyCode::Char('k'), - KeyModifiers::CONTROL, - Some(Input::Ctrl('k')), - ), - ( - KeyCode::Char('h'), - KeyModifiers::CONTROL, - Some(Input::Ctrl('h')), - ), - ( - KeyCode::Char('l'), - KeyModifiers::CONTROL, - Some(Input::Ctrl('l')), - ), - ( - KeyCode::Backspace, - KeyModifiers::NONE, - Some(Input::Backspace), - ), - (KeyCode::Enter, KeyModifiers::NONE, Some(Input::Enter)), - (KeyCode::Enter, KeyModifiers::CONTROL, Some(Input::Enter)), - ( - KeyCode::Char('J'), - KeyModifiers::SHIFT, - Some(Input::Char('J')), - ), - (KeyCode::Char('j'), KeyModifiers::ALT, Some(Input::Alt('j'))), - ( - KeyCode::Char('H'), - KeyModifiers::ALT | KeyModifiers::SHIFT, - Some(Input::Alt('h')), - ), - ( - KeyCode::Char('j'), - KeyModifiers::ALT | KeyModifiers::CONTROL, - Some(Input::CtrlAlt('j')), - ), - ( - KeyCode::Char('H'), - KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, - Some(Input::CtrlAlt('h')), - ), - ( - KeyCode::Char('L'), - KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, - Some(Input::CtrlAlt('l')), - ), - ( - KeyCode::Char('j'), - KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER, - None, - ), - ( - KeyCode::Left, - KeyModifiers::CONTROL | KeyModifiers::ALT, - None, - ), - (KeyCode::Char('j'), KeyModifiers::SUPER, None), - (KeyCode::Left, KeyModifiers::ALT, None), - ] { - assert_eq!(translate(KeyEvent::new(code, modifiers)), expected); - } - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_modified_backspace_edits_words_and_line_prefix_from_terminal_events() { - let directory = Directory::new(); - let path = directory.file("input.txt", "before\nalpha βeta SUFFIX\nlast"); - let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); - session.handle(Input::Down); - session.handle(Input::Home); - session.handle(Input::Char('i')); - for _ in 0..13 { - session.handle(Input::Right); - } - session.handle(translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)).unwrap()); - assert_eq!(session.editor().lines(), ["before", "alpha SUFFIX", "last"]); - session.handle(translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::SUPER)).unwrap()); - assert_eq!(session.editor().lines(), ["before", "SUFFIX", "last"]); - - for modifiers in [ - KeyModifiers::META, - KeyModifiers::HYPER, - KeyModifiers::ALT | KeyModifiers::CONTROL, - KeyModifiers::ALT | KeyModifiers::SHIFT, - KeyModifiers::SUPER | KeyModifiers::CONTROL, - KeyModifiers::SUPER | KeyModifiers::SHIFT, - KeyModifiers::SUPER | KeyModifiers::ALT, - KeyModifiers::SUPER | KeyModifiers::META, - ] { - assert_eq!( - translate(KeyEvent::new(KeyCode::Backspace, modifiers)), - None - ); - } - assert_eq!( - translate(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER)), - None - ); - } - - #[cfg(not(target_os = "macos"))] - #[test] - fn modified_backspace_keeps_non_macos_terminal_defaults() { - for modifiers in [KeyModifiers::ALT, KeyModifiers::SUPER] { - assert_eq!( - translate(KeyEvent::new(KeyCode::Backspace, modifiers)), - None - ); - } - assert_eq!( - translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL)), - Some(Input::Backspace) - ); - } - - #[test] - fn wheel_routes_to_floating_and_unfocused_docked_files_without_changing_selection() { - let directory = Directory::new(); - let config = directory.file( - "config.rb", - "Vamp.configure do |config|\n config.file_picker_view = 'list'\n config.file_explorer_view = 'list'\nend\n", - ); - for (key, focused) in [('o', true), ('e', true), ('e', false)] { - let mut session = EditorSession::open(None, vec![], Some(&config)).unwrap(); - session.handle(Input::Char(' ')); - session.handle(Input::Char(key)); - if !focused { - session.handle(Input::Char('\t')); - } - let picker = session.editor().file_picker().unwrap(); - assert!(picker.entries().len() > 5); - let selected = picker.selected; - let selected_path = picker.entries().nth(selected).unwrap().path.to_owned(); - let query = picker.query.clone(); - let carets = session.editor().carets(); - let revision = session.editor().text_revision(); - let mut panels = WheelPanels::default(); - let area = Rect::new(3, 2, 40, 6); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 12)).unwrap(); - terminal - .draw(|frame| draw_file_list(frame, picker, area, "", &mut panels, None)) - .unwrap(); - let mouse = MouseEvent { - kind: MouseEventKind::ScrollDown, - column: area.x, - row: area.y, - modifiers: KeyModifiers::NONE, - }; - assert!(!panels.scroll( - &mut session, - MouseEvent { - column: 0, - row: 0, - ..mouse - } - )); - assert!(panels.scroll(&mut session, mouse)); - assert_eq!(panels.manual_files.as_ref().unwrap().top, 3); - terminal - .draw(|frame| { - panels.begin_frame(); - draw_file_list( - frame, - session.editor().file_picker().unwrap(), - area, - "", - &mut panels, - None, - ); - }) - .unwrap(); - let picker = session.editor().file_picker().unwrap(); - let text = screen_text(&terminal); - assert!( - text.contains(picker.entries().nth(3).unwrap().label), - "{text}" - ); - assert!( - !text.contains(&format!( - "> {}", - picker.entries().nth(selected).unwrap().label - )), - "{text}" - ); - assert_eq!(picker.selected, selected); - assert_eq!(picker.entries().nth(selected).unwrap().path, selected_path); - assert_eq!(picker.query, query); - assert_eq!(picker.focused, focused); - assert_eq!(session.editor().carets(), carets); - assert_eq!(session.editor().text_revision(), revision); - let entry_count = picker.entries().len(); - assert!(!panels.scroll( - &mut session, - MouseEvent { - kind: MouseEventKind::Moved, - ..mouse - } - )); - for _ in 0..entry_count { - assert!(panels.scroll(&mut session, mouse)); - } - let (_, height, _) = panels.files.unwrap(); - assert_eq!( - panels.manual_files.as_ref().unwrap().top, - session.editor().file_picker().unwrap().entries().len() - height - ); - terminal - .draw(|frame| { - draw_file_list( - frame, - session.editor().file_picker().unwrap(), - area, - "", - &mut panels, - None, - ) - }) - .unwrap(); - assert_eq!(panels.files.unwrap().2, entry_count - height); - panels.follow_files(); - session.handle(Input::Ctrl('p')); - assert!(session.editor().palette().is_some()); - assert!(panels.scroll(&mut session, mouse)); - assert!(panels.manual_files.is_none()); - session.handle(Input::Escape); - session.handle(Input::Char(' ')); - session.handle(Input::Char(key)); - session.handle(Input::Ctrl('w')); - assert!(session.editor().file_picker().is_none()); - assert!(!panels.scroll(&mut session, mouse)); - } - } - - #[test] - fn manually_scrolled_tree_does_not_place_cursor_or_highlight_offscreen_selection() { - let directory = Directory::new(); - for index in 0..12 { - directory.file(&format!("file-{index:02}"), ""); - } - let mut picker = FilePicker::scan( - directory.0.clone(), - FilePresentation::Docked, - FileView::Tree, - ) - .unwrap(); - picker.selected = 11; - let mut panels = WheelPanels { - manual_files: Some(FileWheelOffset { - top: 0, - root: picker.root.clone(), - query: picker.query.clone(), - state: FileWheelOffset::state(&picker), - }), - ..WheelPanels::default() - }; - let area = Rect::new(3, 2, 40, 6); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 12)).unwrap(); - terminal - .draw(|frame| { - frame.set_cursor_position((59, 11)); - draw_file_list(frame, &picker, area, "", &mut panels, None); - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains("file-00"), "{text}"); - assert!(!text.contains("file-11"), "{text}"); - assert_eq!( - terminal.get_cursor_position().unwrap(), - ratatui::layout::Position::new(59, 11) - ); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - assert_ne!(terminal.backend().buffer()[(x, y)].bg, Color::Blue); - } - } - picker.navigate(false); - terminal - .draw(|frame| draw_file_list(frame, &picker, area, "", &mut panels, None)) - .unwrap(); - assert!(panels.manual_files.is_none()); - assert!(screen_text(&terminal).contains("file-10")); - } - - #[test] - fn picker_viewport_preserves_direction_and_uses_visual_row_margins() { - for row_height in [1, 2] { - let mut viewport = None; - let height = 4 * row_height; - for selected in 0..8 { - PickerViewport::follow(&mut viewport, 1, selected, height, row_height, Some(0)); - } - for selected in (4..8).rev() { - assert_eq!( - PickerViewport::follow(&mut viewport, 1, selected, height, row_height, Some(0)), - 4, - ); - } - assert_eq!( - PickerViewport::follow(&mut viewport, 1, 3, height, row_height, Some(0)), - 3, - ); - assert_eq!( - PickerViewport::follow(&mut viewport, 2, 3, height, row_height, Some(0)), - 0, - ); - for distance in [None, Some(usize::MAX)] { - assert_eq!( - PickerViewport::follow(&mut viewport, 2, 7, height, row_height, distance), - 5, - ); - assert_eq!( - PickerViewport::follow(&mut viewport, 2, 0, height, row_height, distance), - 0, - ); - } - } - let mut viewport = None; - assert_eq!( - PickerViewport::follow(&mut viewport, 1, 7, 8, 2, Some(1)), - 5 - ); - assert_eq!( - PickerViewport::follow(&mut viewport, 1, 6, 8, 2, Some(1)), - 5 - ); - assert_eq!( - PickerViewport::follow(&mut viewport, 1, 7, 8, 2, Some(3)), - 5 - ); - assert_eq!(PickerViewport::follow(&mut viewport, 1, 7, 7, 2, None), 6); - assert_eq!(PickerViewport::follow(&mut viewport, 1, 7, 3, 1, None), 6); - assert_eq!(PickerViewport::follow(&mut viewport, 1, 7, 0, 1, None), 6); - } - - #[test] - fn file_viewport_keeps_manual_wheel_until_selection_or_catalog_changes() { - let directory = Directory::new(); - for index in 0..12 { - directory.file(&format!("file-{index:02}"), ""); - } - let mut picker = FilePicker::scan( - directory.0.clone(), - FilePresentation::Docked, - FileView::List, - ) - .unwrap(); - let mut panels = WheelPanels::default(); - let area = Rect::new(0, 0, 40, 8); - picker.selected = 7; - assert_eq!(panels.file_top(&picker, area, 5), 3); - picker.selected = 6; - assert_eq!(panels.file_top(&picker, area, 5), 3); - panels.picker_scroll.distance = None; - panels.manual_files = Some(FileWheelOffset { - top: 0, - root: picker.root.clone(), - query: picker.query.clone(), - state: FileWheelOffset::state(&picker), - }); - picker.focused = !picker.focused; - assert_eq!(panels.file_top(&picker, area, 3), 0); - assert!(panels.manual_files.is_some()); - picker.selected = 7; - assert_eq!(panels.file_top(&picker, area, 5), 5); - assert!(panels.manual_files.is_none()); - panels.manual_files = Some(FileWheelOffset { - top: 0, - root: picker.root.clone(), - query: picker.query.clone(), - state: FileWheelOffset::state(&picker), - }); - std::fs::rename(directory.0.join("file-00"), directory.0.join("new-file")).unwrap(); - picker = FilePicker::scan( - directory.0.clone(), - FilePresentation::Docked, - FileView::List, - ) - .unwrap(); - picker.selected = 7; - assert_eq!(picker.entries().len(), 12); - assert_eq!(panels.file_top(&picker, area, 5), 5); - assert!(panels.manual_files.is_none()); - panels.begin_frame(); - panels.begin_frame(); - assert!(panels.picker_scroll.files.is_none()); - } - - #[test] - fn file_list_window_follows_control_j_k_selection() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_file_picker_view("list").unwrap(); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('o')); - let count = editor.file_picker().unwrap().entries().len(); - assert!(count > 1); - for _ in 0..count - 1 { - editor.handle( - translate(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)).unwrap(), - ); - } - assert_eq!(editor.file_picker().unwrap().selected, count - 1); - editor.handle(translate(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)).unwrap()); - assert_eq!(editor.file_picker().unwrap().selected, count - 2); - let picker = editor.file_picker().unwrap(); - let label = picker.entries().nth(picker.selected).unwrap().label; - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 9)).unwrap(); - terminal - .draw(|frame| { - draw_file_list( - frame, - picker, - frame.area(), - editor.file_navigation_hint(), - &mut WheelPanels::default(), - None, - ) - }) - .unwrap(); - let text = screen_text(&terminal); - assert!( - text.lines() - .any(|row| row.contains(label) && row.contains('>')), - "{text}" - ); - let buffer = terminal.backend().buffer(); - let selected_y = (3..8).find(|&y| buffer[(1, y)].symbol() == ">").unwrap(); - assert_eq!(buffer[(1, selected_y)].bg, Color::Blue); - } - - #[test] - fn explorer_wraps_only_root_path_and_reserves_file_rows_in_short_panes() { - let root = "/a/very/long/path/to/project/root"; - let picker = FilePicker::from_paths(root.into(), [(0, "entry.txt".into())]); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 12)).unwrap(); - let mut panels = WheelPanels::default(); - terminal - .draw(|frame| draw_file_list(frame, &picker, frame.area(), "", &mut panels, None)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let path = (2..4) - .map(|y| (1..19).map(|x| buffer[(x, y)].symbol()).collect::()) - .collect::(); - assert_eq!(path.trim_end(), root); - assert!( - screen_text(&terminal) - .lines() - .nth(4) - .unwrap() - .contains("entry.txt") - ); - terminal.backend_mut().resize(20, 6); - terminal - .draw(|frame| draw_file_list(frame, &picker, frame.area(), "", &mut panels, None)) - .unwrap(); - assert!(screen_text(&terminal).contains("entry.txt")); - } - - #[test] - fn list_footers_show_remapped_and_disabled_selection_keys() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_file_explorer_view("list").unwrap(); - editor.set_file_picker_view("list").unwrap(); - for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] { - editor.bind("selection", key, None).unwrap(); - } - editor - .bind("selection", "x", Some("core.down".into())) - .unwrap(); - let hint = editor.selection_navigation_hint().to_owned(); - assert_eq!(hint, "x next · unbound previous"); - editor.set_file_explorer_side("bottom").unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - for key in ['o', 'e'] { - editor.handle(Input::Char(' ')); - editor.handle(Input::Char(key)); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains(&hint), "{text}"); - assert!(!text.contains("Ctrl+j"), "{text}"); - editor.handle(Input::Escape); - } - editor.handle(Input::Ctrl('p')); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains(&hint), "{text}"); - assert!(!text.contains("Ctrl+j"), "{text}"); - } - - #[test] - fn file_list_scrolls_selection_and_sanitizes_display_without_losing_paths() { - let directory = Directory::new(); - for index in 0..18 { - directory.file(&format!("file-{index:02}"), "text"); - } - let special = directory.file("界e\u{301} space\nname", "text"); - let editor = Editor::open(None, vec![]).unwrap(); - let mut picker = FilePicker::scan( - directory.0.clone(), - FilePresentation::Floating, - FileView::List, - ) - .unwrap(); - assert!( - picker - .entries() - .any(|entry| directory.0.join(entry.path) == special) - ); - let count = picker.entries().len(); - picker.selected = count - 1; - let label = picker.entries().last().unwrap().label; - assert_eq!(label, "界e\u{301} space�name"); - picker.query = "long\t界e\u{301}z".into(); - let area = Rect::new(3, 2, 24, 9); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(40, 15), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - area, - editor.selection_navigation_hint(), - &mut WheelPanels::default(), - None, - ) - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(!text.contains("file-00"), "{text}"); - let cursor = terminal.get_cursor_position().unwrap(); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(area.x + 7, area.y + 1)].symbol(), "�"); - assert_eq!(buffer[(area.x + 8, area.y + 1)].symbol(), "界"); - assert_eq!(buffer[(area.x + 10, area.y + 1)].symbol(), "e\u{301}"); - let selected_y = (area.y + 2..area.bottom() - 1) - .find(|&y| buffer[(area.x + 1, y)].symbol() == ">") - .unwrap(); - let selected_x = (area.x + 2..area.right()) - .find(|&x| buffer[(x, selected_y)].symbol() == "界") - .unwrap(); - assert_eq!(buffer[(selected_x + 2, selected_y)].symbol(), "e\u{301}"); - assert_eq!(buffer[(area.x + 1, selected_y)].bg, Color::Blue); - assert!( - buffer[(area.x + 1, selected_y)] - .modifier - .contains(Modifier::BOLD) - ); - assert_eq!(cursor.y, area.y + 1); - assert_eq!(buffer[(0, 0)].symbol(), " "); - } - - #[test] - fn file_list_renders_root_empty_filter_errors_and_idle_focus() { - let directory = Directory::new(); - let editor = Editor::open(None, vec![]).unwrap(); - let mut picker = FilePicker::scan( - directory.0.clone(), - FilePresentation::Docked, - FileView::List, - ) - .unwrap(); - let area = Rect::new(0, 0, 100, 12); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - area, - editor.selection_navigation_hint(), - &mut WheelPanels::default(), - None, - ) - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains("Explorer"), "{text}"); - assert!( - text.contains(&directory.0.to_string_lossy().to_string()), - "{text}" - ); - assert!(text.contains("No project files"), "{text}"); - assert!(text.contains("Esc/Tab"), "{text}"); - picker.query = "missing".into(); - picker.error = Some("Unreadable\npath\u{1b}".into()); - picker.focused = false; - terminal - .draw(|frame| { - frame.set_cursor_position((99, 11)); - draw_file_list( - frame, - &picker, - area, - editor.selection_navigation_hint(), - &mut WheelPanels::default(), - None, - ); - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains("No matching files"), "{text}"); - assert!(text.contains("Unreadable�path�"), "{text}"); - assert!(text.contains("Editor focus"), "{text}"); - assert_eq!(terminal.backend().buffer()[(1, 3)].fg, Color::Red); - assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::DarkGray); - assert_eq!( - terminal.get_cursor_position().unwrap(), - ratatui::layout::Position::new(99, 11) - ); - picker.presentation = FilePresentation::Floating; - picker.focused = true; - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - area, - editor.selection_navigation_hint(), - &mut WheelPanels::default(), - None, - ) - }) - .unwrap(); - assert!(screen_text(&terminal).contains("Files")); - assert!(screen_text(&terminal).contains("Esc/^W")); - assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Cyan); - picker.set_view(FileView::Tree); - picker.finish_search(); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - area, - editor.file_navigation_hint(), - &mut WheelPanels::default(), - None, - ) - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains("No matching files"), "{text}"); - assert!(text.contains("filter: missing"), "{text}"); - assert_eq!( - terminal.get_cursor_position().unwrap(), - ratatui::layout::Position::new(1, 1) - ); - picker.focused = false; - terminal - .draw(|frame| { - frame.set_cursor_position((99, 11)); - draw_file_list( - frame, - &picker, - area, - editor.file_navigation_hint(), - &mut WheelPanels::default(), - None, - ); - }) - .unwrap(); - assert_eq!( - terminal.get_cursor_position().unwrap(), - ratatui::layout::Position::new(99, 11) - ); - } - - #[test] - fn file_query_cursor_and_dock_layout_survive_tiny_offset_resizes() { - let directory = Directory::new(); - let editor = Editor::open(None, vec![]).unwrap(); - let mut picker = FilePicker::scan( - directory.0.clone(), - FilePresentation::Docked, - FileView::List, - ) - .unwrap(); - picker.query = "long界e\u{301}z".into(); - for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] { - let area = Rect::new(3, 2, width, height); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(24, 12), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - area, - editor.selection_navigation_hint(), - &mut WheelPanels::default(), - None, - ) - }) - .unwrap(); - let cursor = terminal.get_cursor_position().unwrap(); - assert!(area.contains(cursor), "{area:?}: {cursor:?}"); - let bordered = width >= 4 && height >= 4; - let query_x = area.x + u16::from(bordered); - let query_y = area.y + u16::from(bordered); - let query_width = width - 2 * u16::from(bordered); - assert_eq!(cursor.x, query_x + query_width - 1); - assert_eq!(cursor.y, query_y); - let query = format!("> {}", picker.query); - let offset = - display_column(&query, usize::MAX).saturating_sub(usize::from(query_width) - 1); - let mut expected = - ratatui::buffer::Buffer::empty(Rect::new(query_x, query_y, query_width, 1)); - use ratatui::widgets::Widget; - Paragraph::new(visible_line(&query, offset, usize::from(query_width))) - .render(expected.area, &mut expected); - for x in query_x..query_x + query_width { - assert_eq!( - terminal.backend().buffer()[(x, query_y)].symbol(), - expected[(x, query_y)].symbol() - ); - } - assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " "); - for side in [ - DockSide::Left, - DockSide::Right, - DockSide::Top, - DockSide::Bottom, - ] { - for focused in [false, true] { - let (editor, panel) = dock_layout(area, side, focused); - for rect in [Some(editor), panel].into_iter().flatten() { - assert!(rect.x >= area.x && rect.y >= area.y); - assert!(rect.right() <= area.right() && rect.bottom() <= area.bottom()); - } - if focused { - assert!(panel.is_some()); - } else { - assert!(panel.is_none()); - } - } - } - } - for (width, height) in [(0, 1), (1, 0), (0, 0)] { - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - frame.area(), - editor.selection_navigation_hint(), - &mut WheelPanels::default(), - None, - ) - }) - .unwrap(); - assert!( - dock_layout(Rect::new(3, 2, width, height), DockSide::Right, true) - .1 - .is_none() - ); - } - } - - #[test] - fn tree_rows_render_directory_chains_hierarchy_and_basename_files_in_both_presentations() { - use std::path::Path; - let directory = Directory::new(); - directory.file("chain/a/b/leaf.txt", ""); - directory.file("mixed/nested/inner.txt", ""); - directory.file("mixed/sibling.txt", ""); - directory.file("single/only.txt", ""); - directory.file("root.txt", ""); - fs::create_dir(directory.0.join("empty")).unwrap(); - let editor = Editor::open(None, vec![]).unwrap(); - for presentation in [FilePresentation::Floating, FilePresentation::Docked] { - let mut picker = - FilePicker::scan(directory.0.clone(), presentation, FileView::Tree).unwrap(); - assert_eq!(picker.entries().next().unwrap().label, "chain/a/b"); - for path in ["chain/a/b", "mixed", "mixed/nested", "single"] { - let selected = picker - .entries() - .position(|row| row.path == Path::new(path)) - .unwrap(); - picker.selected = selected; - picker.right(); - } - let area = Rect::new(3, 2, 100, 22); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(110, 28), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - area, - editor.file_navigation_hint(), - &mut WheelPanels::default(), - None, - ) - }) - .unwrap(); - let text = screen_text(&terminal); - for label in [ - " chain/a/b/", - " leaf.txt", - " empty/", - " mixed/", - " nested/", - " inner.txt", - " sibling.txt", - " only.txt", - " root.txt", - ] { - assert!(text.contains(label), "{label}: {text}"); - } - assert!(!text.contains('▸') && !text.contains('▾'), "{text}"); - let buffer = terminal.backend().buffer(); - for (index, entry) in picker.entries().enumerate() { - let y = area.y + 3 + index as u16; - assert_eq!( - buffer[(area.x + 1, y)].fg, - if entry.is_dir { - Color::Cyan - } else { - Color::Reset - }, - ); - } - assert!(!text.contains("single/only.txt"), "{text}"); - assert!(!text.contains("mixed/nested/inner.txt"), "{text}"); - let cursor = terminal.get_cursor_position().unwrap(); - assert_eq!( - terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), - ">" - ); - assert_eq!( - terminal.backend().buffer()[(cursor.x, cursor.y)].bg, - Color::Blue - ); - picker.set_view(FileView::List); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - area, - editor.file_navigation_hint(), - &mut WheelPanels::default(), - None, - ) - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains("chain/a/b/leaf.txt"), "{text}"); - assert!(text.contains("single/only.txt"), "{text}"); - assert!(!text.contains("▸"), "{text}"); - assert_eq!(terminal.get_cursor_position().unwrap().y, area.y + 1); - } - } - - #[test] - fn tree_browse_cursor_tracks_window_and_bounds_empty_search_and_tiny_offset_panels() { - let directory = Directory::new(); - for index in 0..18 { - directory.file(&format!("dir-{index:02}/file.txt"), ""); - } - let editor = Editor::open(None, vec![]).unwrap(); - let mut picker = FilePicker::scan( - directory.0.clone(), - FilePresentation::Docked, - FileView::Tree, - ) - .unwrap(); - for _ in 0..17 { - picker.navigate(true); - } - picker.right(); - picker.right(); - let long_query = "long界e\u{301}z".repeat(20); - for (width, height) in [(100, 9), (12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (100, 9)] { - let area = Rect::new(3, 2, width, height); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(110, 16), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - area, - editor.file_navigation_hint(), - &mut WheelPanels::default(), - None, - ) - }) - .unwrap(); - let cursor = terminal.get_cursor_position().unwrap(); - assert!(area.contains(cursor), "{area:?}: {cursor:?}"); - let has_results = height > if width >= 4 && height >= 4 { 3 } else { 1 }; - if has_results { - assert_eq!( - terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), - ">" - ); - } else { - assert_eq!(cursor, ratatui::layout::Position::new(area.x, area.y)); - } - if width == 100 { - let text = screen_text(&terminal); - assert!(text.contains("file.txt"), "{text}"); - assert!(!text.contains("dir-00"), "{text}"); - } - for query in ["missing", long_query.as_str()] { - picker.start_search(); - picker.query = query.into(); - picker.refilter(); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - area, - editor.file_navigation_hint(), - &mut WheelPanels::default(), - None, - ) - }) - .unwrap(); - let cursor = terminal.get_cursor_position().unwrap(); - assert!(area.contains(cursor), "{area:?}: {cursor:?}"); - assert_eq!(cursor.y, area.y + u16::from(width >= 4 && height >= 4)); - picker.finish_search(); - assert_eq!(picker.query, query); - assert_eq!(picker.entries().len(), 0); - terminal - .draw(|frame| { - draw_file_list( - frame, - &picker, - area, - editor.file_navigation_hint(), - &mut WheelPanels::default(), - None, - ) - }) - .unwrap(); - let cursor = terminal.get_cursor_position().unwrap(); - assert!(area.contains(cursor), "{area:?}: {cursor:?}"); - let query_x = area.x + u16::from(width >= 4 && height >= 4); - assert_eq!(cursor.x, query_x); - let query_y = area.y + u16::from(width >= 4 && height >= 4); - let query_width = width - 2 * u16::from(width >= 4 && height >= 4); - let header = format!("filter: {query} · ^U clear"); - let mut expected = - ratatui::buffer::Buffer::empty(Rect::new(query_x, query_y, query_width, 1)); - use ratatui::widgets::Widget; - Paragraph::new(visible_line(&header, 0, usize::from(query_width))) - .render(expected.area, &mut expected); - let mut continuation = 0; - for x in query_x..query_x + query_width { - if continuation > 0 { - continuation -= 1; - continue; - } - continuation = expected[(x, query_y)].symbol().width().saturating_sub(1); - assert_eq!( - terminal.backend().buffer()[(x, query_y)].symbol(), - expected[(x, query_y)].symbol() - ); - } - if width == 100 { - assert!(screen_text(&terminal).contains("filter: "), "{query}"); - assert!( - screen_text(&terminal).contains("No matching files"), - "{query}" - ); - } - picker.clear_filter(); - assert!(!picker.searching); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - std::path::Path::new("dir-17/file.txt") - ); - } - } - } - - #[test] - fn tree_filter_escape_resume_and_clear_keep_selection_and_cursor_ownership() { - use std::path::Path; - let directory = Directory::new(); - directory.file("src/inner/needle.txt", ""); - directory.file("src/other.txt", ""); - directory.file("root.txt", ""); - for presentation in [FilePresentation::Floating, FilePresentation::Docked] { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_project_root(directory.0.clone()); - editor.set_file_picker_view("tree").unwrap(); - editor - .apply(vec![crate::editor::Effect::Replace("unchanged".into())]) - .unwrap(); - editor.handle(Input::Char('v')); - let mode = editor.mode(); - let selections = editor.selections(); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char(if presentation == FilePresentation::Floating { - 'o' - } else { - 'e' - })); - if !editor.file_picker().unwrap().searching { - editor.handle(Input::Char('i')); - } - let query = "src/inner/needle.txt"; - for ch in query.chars() { - editor.handle(Input::Char(ch)); - } - editor.handle(Input::Ctrl('h')); - let picker = editor.file_picker().unwrap(); - assert_eq!(picker.query, query); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - Path::new("src/inner") - ); - editor.handle(Input::Ctrl('l')); - let picker = editor.file_picker().unwrap(); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - Path::new(query) - ); - let filtered: Vec<_> = picker - .entries() - .map(|row| (row.path.to_owned(), row.expanded)) - .collect(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let query_cursor = terminal.get_cursor_position().unwrap(); - assert!(screen_text(&terminal).contains(&format!("> {query}"))); - assert!(screen_text(&terminal).contains("^U clear")); - editor.handle(Input::Escape); - let picker = editor.file_picker().unwrap(); - assert!(picker.focused && !picker.searching); - assert_eq!(picker.query, query); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - Path::new(query) - ); - assert_eq!( - picker - .entries() - .map(|row| (row.path.to_owned(), row.expanded)) - .collect::>(), - filtered - ); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains(&format!("filter: {query}")), "{text}"); - assert!( - text.contains("needle.txt") && !text.contains("other.txt"), - "{text}" - ); - let selected_cursor = terminal.get_cursor_position().unwrap(); - assert_ne!(selected_cursor.y, query_cursor.y); - assert_eq!( - terminal.backend().buffer()[(selected_cursor.x, selected_cursor.y)].symbol(), - ">" - ); - assert_eq!( - terminal.backend().buffer()[(selected_cursor.x, selected_cursor.y)].bg, - Color::Blue - ); - editor.handle(Input::Char('i')); - assert_eq!(editor.file_picker().unwrap().query, query); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap(), query_cursor); - editor.handle(Input::Escape); - editor.handle(Input::Ctrl('u')); - let picker = editor.file_picker().unwrap(); - assert!(picker.focused && !picker.searching && picker.query.is_empty()); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - Path::new(query) - ); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let cursor = terminal.get_cursor_position().unwrap(); - assert_eq!( - terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), - ">" - ); - assert!(screen_text(&terminal).contains("other.txt")); - editor.handle(Input::Char('i')); - for ch in query.chars() { - editor.handle(Input::Char(ch)); - } - editor.handle(Input::Ctrl('u')); - let picker = editor.file_picker().unwrap(); - assert!(picker.focused && picker.searching && picker.query.is_empty()); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - Path::new(query) - ); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let cursor = terminal.get_cursor_position().unwrap(); - assert_eq!(cursor.y, query_cursor.y); - assert_eq!(cursor.x, query_cursor.x - query.len() as u16); - editor.handle(Input::Ctrl('t')); - for ch in query.chars() { - editor.handle(Input::Char(ch)); - } - editor.handle(Input::Ctrl('u')); - let picker = editor.file_picker().unwrap(); - assert_eq!(picker.view, FileView::List); - assert!(picker.focused && picker.query.is_empty()); - assert_eq!( - picker.entries().nth(picker.selected).unwrap().path, - Path::new(query) - ); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap(), cursor); - editor.handle(Input::Ctrl('t')); - editor.handle(Input::Escape); - editor.handle(Input::Escape); - if presentation == FilePresentation::Floating { - assert!(editor.file_picker().is_none()); - } else { - assert!(!editor.file_picker().unwrap().focused); - } - assert_eq!(editor.mode(), mode); - assert_eq!(editor.selections(), selections); - assert_eq!(editor.lines(), ["unchanged"]); - } - } - - #[test] - fn explorer_retains_insert_filter_through_focus_and_view_switches() { - let directory = Directory::new(); - directory.file("src/inner/needle.txt", ""); - directory.file("src/other.txt", ""); - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_project_root(directory.0.clone()); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('e')); - editor.handle(Input::Char('i')); - editor.handle(Input::Char('n')); - editor.handle(Input::Char('\t')); - assert!(!editor.file_picker().unwrap().focused); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('e')); - assert!(editor.file_picker().unwrap().searching); - assert_eq!(editor.file_picker().unwrap().query, "n"); - editor.handle(Input::Ctrl('t')); - assert_eq!(editor.file_picker().unwrap().view, FileView::List); - assert_eq!(editor.file_picker().unwrap().query, "n"); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert!(screen_text(&terminal).contains("List")); - assert_eq!(terminal.get_cursor_position().unwrap().y, 1); - editor.handle(Input::Ctrl('t')); - assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); - assert!(editor.file_picker().unwrap().searching); - assert_eq!(editor.file_picker().unwrap().query, "n"); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap().y, 1); - editor.handle(Input::Escape); - assert!(editor.file_picker().unwrap().focused); - assert!(!editor.file_picker().unwrap().searching); - assert_eq!(editor.file_picker().unwrap().query, "n"); - editor.handle(Input::Ctrl('t')); - editor.handle(Input::Ctrl('t')); - assert!(!editor.file_picker().unwrap().searching); - assert_eq!(editor.file_picker().unwrap().query, "n"); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert!(screen_text(&terminal).contains("filter: n")); - let cursor = terminal.get_cursor_position().unwrap(); - assert_eq!( - terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), - ">" - ); - } - - #[test] - fn configured_views_and_view_toggle_render_in_both_presentations_with_scoped_hints() { - let directory = Directory::new(); - directory.file("folder/file.txt", ""); - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_project_root(directory.0.clone()); - editor.set_file_picker_view("tree").unwrap(); - editor.set_file_explorer_view("list").unwrap(); - editor.set_file_explorer_side("bottom").unwrap(); - editor.bind("tree", "ctrl+l", None).unwrap(); - editor.bind("tree", "right", None).unwrap(); - editor.bind("tree", "x", Some("core.right".into())).unwrap(); - for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] { - editor.bind("selection", key, None).unwrap(); - } - editor - .bind("selection", "j", Some("core.down".into())) - .unwrap(); - editor - .bind("selection", "k", Some("core.up".into())) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(200, 30)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - for (key, expected) in [('o', FileView::Tree), ('e', FileView::List)] { - editor.handle(Input::Char(' ')); - editor.handle(Input::Char(key)); - assert_eq!(editor.file_picker().unwrap().view, expected); - if expected == FileView::Tree { - assert!(editor.file_picker().unwrap().searching); - editor.handle(Input::Escape); - } - for view in [ - expected, - if expected == FileView::Tree { - FileView::List - } else { - FileView::Tree - }, - ] { - assert_eq!(editor.file_picker().unwrap().view, view); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains(editor.file_navigation_hint()), "{text}"); - if view == FileView::Tree { - assert_ne!( - editor.file_navigation_hint(), - editor.selection_navigation_hint() - ); - let cursor = terminal.get_cursor_position().unwrap(); - assert_eq!( - terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), - ">" - ); - editor.handle(Input::Char('x')); - assert_eq!(editor.file_picker().unwrap().query, ""); - assert!( - editor - .file_picker() - .unwrap() - .entries() - .next() - .unwrap() - .expanded - ); - let picker = editor.file_picker().unwrap(); - let was_file = !picker.entries().nth(picker.selected).unwrap().is_dir; - editor.handle(Input::Ctrl('h')); - if was_file { - let picker = editor.file_picker().unwrap(); - assert!(picker.entries().nth(picker.selected).unwrap().is_dir); - assert!(picker.entries().next().unwrap().expanded); - editor.handle(Input::Ctrl('h')); - } - assert!( - !editor - .file_picker() - .unwrap() - .entries() - .next() - .unwrap() - .expanded - ); - } else { - assert_eq!( - editor.file_navigation_hint(), - editor.selection_navigation_hint() - ); - assert!(text.contains("folder/file.txt"), "{text}"); - } - editor.handle(Input::Ctrl('t')); - } - editor.handle(Input::Escape); - } - editor.handle(Input::Ctrl('p')); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert!(screen_text(&terminal).contains(editor.selection_navigation_hint())); - } - - const PANE_CLOSE_CONFIG: &str = r#" -Vamp.configure do |config| - config.file_explorer_view = "list" - config.file_explorer_side = "right" - config.file_picker_view = "tree" - config.bind "ctrl+o", config.core.file_picker - config.bind "ctrl+x", config.core.pane.close - config.bind "ctrl+y", config.core.pane.force_close -end -"#; - - #[test] - fn bound_pane_close_reclaims_focused_dock_without_losing_dirty_source_or_diff() { - let directory = Directory::new(); - let path = directory.file("source.txt", "SOURCE\nsecond"); - let config = directory.file("config.rb", PANE_CLOSE_CONFIG); - for comparison in [false, true] { - for key in ['x', 'y'] { - let mut session = - EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap(); - session.editor_mut().set_project_root(directory.0.clone()); - let source = session.editor().buffer_id(); - session.handle(Input::Char('i')); - session.handle(Input::Paste("DIRTY ".into())); - session.handle(Input::Escape); - let text = session.editor().lines().to_vec(); - if comparison { - install_readonly_diff( - session.editor_mut(), - syntax_diff_file("history.txt", "OLD", "NEW"), - ); - } else { - session.handle(Input::Char(' ')); - session.handle(Input::Char('e')); - } - let pane = session.editor().pane_id(); - let tab = session.editor().tab_id(); - let buffers = session.editor().buffer_count(); - let comparison_id = session.editor().diff().and_then(|diff| diff.comparison_id); - let picker = session.editor().file_picker().unwrap(); - assert!(picker.focused); - assert_eq!(picker.presentation, FilePresentation::Docked); - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(120, 20)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - let dock = views.panels.files.unwrap().0; - let workspace = views.rects[0].1; - let regions = views.panels.diff.as_ref().map(|geometry| geometry.regions); - assert_eq!(workspace.width + dock.width, 120); - assert!(dock.contains(terminal.get_cursor_position().unwrap())); - - session.handle( - translate(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL)).unwrap(), - ); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(!session.editor().should_quit(), "{comparison} Ctrl+{key}"); - assert_eq!(session.editor().pane_id(), pane); - assert_eq!(session.editor().tab_id(), tab); - assert_eq!(session.editor().pane_layout().len(), 1); - assert_eq!(session.editor().tab_count(), 1); - assert_eq!(session.editor().buffer_count(), buffers); - assert!(session.editor().file_picker().is_none()); - assert!(views.panels.files.is_none()); - assert_eq!(views.rects[0].1.width, 120); - assert!(!screen_text(&terminal).contains("Explorer")); - assert!( - session - .editor() - .buffers() - .find(|buffer| buffer.id == source) - .unwrap() - .dirty - ); - assert_eq!( - session - .editor() - .documents() - .find(|document| document.id == source) - .unwrap() - .lines, - text - ); - let (caret_area, caret) = views.panels.caret.unwrap(); - assert!(caret_area.contains(caret)); - assert_eq!(terminal.get_cursor_position().unwrap(), caret); - if comparison { - let diff = session.editor().diff().unwrap(); - assert_eq!(diff.comparison_id, comparison_id); - assert!(!diff.files_visible); - assert!(!diff.files.as_ref().unwrap().focused); - let expanded = views.panels.diff.as_ref().unwrap().regions; - assert_eq!(expanded[0].0, Rect::default()); - for index in [1, 2] { - assert!(expanded[index].0.width > regions.unwrap()[index].0.width); - assert_eq!( - terminal.backend().buffer()[(expanded[index].0.x, expanded[index].0.y)] - .symbol(), - "┌" - ); - } - let screen = screen_text(&terminal); - assert!(screen.contains("Before") && screen.contains("After")); - assert!(screen.contains("OLD") && screen.contains("NEW")); - session.execute("diff.close"); - } - assert_eq!(session.editor().buffer_id(), source); - assert_eq!(session.editor().lines(), text); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), ["SOURCE", "second"]); - assert!(!session.editor().dirty()); - } - } - } - - #[test] - fn bound_pane_close_with_unfocused_dock_removes_editor_leaf_not_the_list() { - let directory = Directory::new(); - let path = directory.file("source.txt", "SOURCE"); - let config = directory.file("config.rb", PANE_CLOSE_CONFIG); - for comparison in [false, true] { - let mut session = - EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap(); - session.editor_mut().set_project_root(directory.0.clone()); - let root = session.editor().pane_id(); - let source = session.editor().buffer_id(); - session.handle(Input::Char('i')); - session.handle(Input::Paste("DIRTY ".into())); - session.handle(Input::Escape); - session.execute("pane.split_vertical"); - let closing = session.editor().pane_id(); - if comparison { - install_readonly_diff( - session.editor_mut(), - syntax_diff_file("history.txt", "OLD", "NEW"), - ); - } else { - session.handle(Input::Char(' ')); - session.handle(Input::Char('e')); - } - session.handle(Input::Char('\t')); - assert!(!session.editor().file_picker().unwrap().focused); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 20)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(views.rects.len(), 2); - let dock = views.panels.files.unwrap().0; - session.handle(Input::Ctrl('x')); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(!session.editor().should_quit()); - assert_eq!(session.editor().pane_id(), root); - assert!(!session.editor().pane_layout().contains(closing)); - assert_eq!(views.rects.len(), 1); - assert_eq!(session.editor().buffer_id(), source); - assert_eq!(session.editor().lines(), ["DIRTY SOURCE"]); - assert!(session.editor().dirty()); - assert!(session.editor().diff().is_none()); - assert!(!screen_text(&terminal).contains("Before")); - if comparison { - assert!(views.panels.files.is_none()); - assert_eq!(views.rects[0].1.width, 120); - } else { - assert!(!session.editor().file_picker().unwrap().focused); - assert_eq!(views.panels.files.unwrap().0, dock); - assert_eq!(views.rects[0].1.width + dock.width, 120); - assert!(screen_text(&terminal).contains("Explorer")); - } - assert!( - views.rects[0] - .1 - .contains(terminal.get_cursor_position().unwrap()) - ); - } - } - - #[test] - fn bound_pane_close_under_floating_picker_obeys_dirty_guard_force_and_escape() { - let directory = Directory::new(); - let source = directory.file("source.txt", "SURVIVING DOCUMENT"); - let owner = directory.file("owner.txt", "POPUP OWNER"); - let config = directory.file("config.rb", PANE_CLOSE_CONFIG); - for comparison in [false, true] { - for dirty in [false, true] { - let mut session = - EditorSession::open(Some(source.clone()), vec![], Some(&config)).unwrap(); - session.editor_mut().set_project_root(directory.0.clone()); - let root = session.editor().pane_id(); - let source_buffer = session.editor().buffer_id(); - session.execute("tab.new"); - session.execute(&format!("edit {}", owner.display())); - let owner_buffer = session.editor().buffer_id(); - let owner_pane = session.editor().pane_id(); - let owner_tab = session.editor().tab_id(); - if dirty { - session.handle(Input::Char('i')); - session.handle(Input::Paste("DIRTY ".into())); - session.handle(Input::Escape); - } - let text = session.editor().lines().to_vec(); - if comparison { - install_readonly_diff( - session.editor_mut(), - syntax_diff_file("history.txt", "OLD", "NEW"), - ); - } - let comparison_id = session.editor().diff().and_then(|diff| diff.comparison_id); - session.handle(Input::Ctrl('o')); - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(120, 20)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - let popup = views.panels.files.unwrap().0; - assert_eq!( - session.editor().file_picker().unwrap().presentation, - FilePresentation::Floating - ); - assert!(popup.contains(terminal.get_cursor_position().unwrap())); - session.handle(Input::Ctrl('x')); - if dirty { - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(session.editor().tab_count(), 2); - assert_eq!(session.editor().tab_id(), owner_tab); - assert_eq!(session.editor().pane_id(), owner_pane); - assert!(!session.editor().should_quit()); - assert!(session.editor().message().contains("Unsaved changes")); - assert!(session.editor().file_picker().unwrap().focused); - assert_eq!(views.panels.files.unwrap().0, popup); - assert!(popup.contains(terminal.get_cursor_position().unwrap())); - assert_eq!( - session - .editor() - .documents() - .find(|document| document.id == owner_buffer) - .unwrap() - .lines, - text - ); - session.handle(Input::Escape); - assert!(session.editor().file_picker().unwrap().focused); - assert!(!session.editor().file_picker().unwrap().searching); - session.handle(Input::Escape); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(session.editor().file_picker().is_none()); - assert!(views.panels.files.is_none()); - assert_eq!(session.editor().pane_id(), owner_pane); - assert_eq!(session.editor().tab_count(), 2); - assert_eq!( - session.editor().diff().and_then(|diff| diff.comparison_id), - comparison_id - ); - if comparison { - assert!(screen_text(&terminal).contains("Before")); - } else { - assert!(screen_text(&terminal).contains("DIRTY POPUP OWNER")); - } - session.handle(Input::Ctrl('o')); - session.handle(Input::Ctrl('y')); - } - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(!session.editor().should_quit()); - assert_eq!(session.editor().tab_count(), 1); - assert_eq!(session.editor().pane_id(), root); - assert_eq!(session.editor().buffer_id(), source_buffer); - assert!(!session.editor().has_buffer(owner_buffer)); - assert_eq!(session.editor().lines(), ["SURVIVING DOCUMENT"]); - assert!(session.editor().diff().is_none()); - assert!(session.editor().file_picker().is_none()); - assert!(views.panels.files.is_none()); - assert_eq!(views.rects.len(), 1); - assert_eq!(views.rects[0].1.width, 120); - assert!(screen_text(&terminal).contains("SURVIVING DOCUMENT")); - let (caret_area, caret) = views.panels.caret.unwrap(); - assert!(caret_area.contains(caret)); - assert_eq!(terminal.get_cursor_position().unwrap(), caret); - } - } - } - - #[test] - fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor - .apply(vec![crate::editor::Effect::Replace( - vec!["word ".repeat(40); 20].join("\n"), - )]) - .unwrap(); - editor.handle(Input::Char('v')); - editor.handle(Input::Ctrl('a')); - assert!(editor.selection_count() > 1); - let area = Rect::new(3, 2, 90, 26); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(100, 32), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('e')); - assert!(editor.file_picker().unwrap().focused); - for (name, side) in [ - ("left", DockSide::Left), - ("right", DockSide::Right), - ("top", DockSide::Top), - ("bottom", DockSide::Bottom), - ] { - editor.set_file_explorer_side(name).unwrap(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let workspace = Rect::new(area.x, area.y, area.width, area.height - 1); - let (editor_area, panel) = dock_layout(workspace, side, true); - let panel = panel.unwrap(); - assert_eq!(editor_area.area() + panel.area(), workspace.area()); - assert_eq!(editor_area.intersection(panel).area(), 0); - match side { - DockSide::Left => assert_eq!(panel.right(), editor_area.x), - DockSide::Right => assert_eq!(editor_area.right(), panel.x), - DockSide::Top => assert_eq!(panel.bottom(), editor_area.y), - DockSide::Bottom => assert_eq!(editor_area.bottom(), panel.y), - } - assert!(panel.contains(terminal.get_cursor_position().unwrap())); - editor.handle(Input::Escape); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert!(editor_area.contains(terminal.get_cursor_position().unwrap())); - let buffer = terminal.backend().buffer(); - for y in panel.y..panel.bottom() { - for x in panel.x..panel.right() { - assert!( - !matches!( - buffer[(x, y)].bg, - Color::Blue | Color::Magenta | Color::Yellow - ), - "{side:?}: {x},{y}" - ); - } - } - assert_eq!(buffer[(editor_area.x + 3, editor_area.y)].symbol(), "w"); - assert!(matches!( - buffer[(editor_area.x + 3, editor_area.y)].bg, - Color::Blue | Color::Magenta - )); - for y in editor_area.y..editor_area.bottom() { - for x in editor_area.x..editor_area.x + 3 { - assert_eq!(buffer[(x, y)].bg, Color::Reset); - } - } - assert_eq!(buffer[(0, 0)].symbol(), " "); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('e')); - assert!(editor.file_picker().unwrap().focused); - } - } - - #[test] - fn floating_picker_clears_a_centered_overlay_without_moving_editor_regions() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor - .apply(vec![crate::editor::Effect::Replace( - vec!["x".repeat(120); 30].join("\n"), - )]) - .unwrap(); - let area = Rect::new(3, 2, 110, 26); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(120, 32), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let before = (top, left); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('o')); - let mut panels = WheelPanels::default(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut panels, - ) - }) - .unwrap(); - let panel = panels.files.unwrap().0; - assert_eq!(panel.x - area.x, area.right() - panel.right()); - assert!(panel.y >= area.y && panel.bottom() < area.bottom()); - assert_eq!((top, left), before); - assert_eq!( - terminal.get_cursor_position().unwrap(), - ratatui::layout::Position::new(panel.x + 3, panel.y + 1) - ); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(panel.x, panel.y)].fg, Color::Cyan); - assert_eq!(buffer[(panel.right() - 2, panel.y + 1)].symbol(), " "); - assert_eq!(buffer[(area.x + 3, area.y)].symbol(), "x"); - assert_eq!(buffer[(0, 0)].symbol(), " "); - } - - #[test] - fn startup_diagnostic_scrolls_long_unicode_reasons_and_dismisses_without_editing() { - let directory = Directory::new(); - let source = directory.file("buffer.txt", "keep"); - let config = directory.file("config.rb", "raise \"#{'x' * 160}界END\\nlast reason\""); - let mut session = - EditorSession::open(Some(source), vec!["text".into()], Some(&config)).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(110, 26)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains("Configuration error")); - assert!(text.contains("config.rb")); - assert!(text.contains("default settings and bindings")); - assert!(text.contains("last reason")); - assert!(!text.contains("界END")); - for _ in 0..100 { - session.handle(Input::Right); - } - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - let scrolled = screen_text(&terminal); - assert!( - scrolled.contains("界") && scrolled.contains("END"), - "{scrolled}" - ); - for (width, height) in [(4, 4), (1, 1)] { - let mut tiny = - Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); - tiny.draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - } - session.handle(Input::Enter); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(!screen_text(&terminal).contains("Configuration error")); - assert_eq!(session.editor().lines(), &["keep"]); - assert_eq!(session.editor().cursor(), crate::editor::Cursor::default()); - } - - #[test] - fn picker_conversion_focus_palette_and_closure_preserve_buffer_viewport_cache() { - let directory = Directory::new(); - let a = directory.file("a", &vec!["a".repeat(80); 30].join("\n")); - let b = directory.file("b", "other"); - let mut editor = Editor::open(Some(a), vec![]).unwrap(); - let a_id = editor.buffer_id(); - for _ in 0..20 { - editor.handle(Input::Down); - } - editor.handle(Input::End); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 10)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let before = viewports.offsets[&a_id]; - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('o')); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(viewports.offsets[&a_id], before); - assert!(screen_text(&terminal).contains("Files")); - editor.handle(Input::Escape); - assert!(editor.file_picker().unwrap().focused); - editor.handle(Input::Escape); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(viewports.offsets[&a_id], before); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('e')); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert!(screen_text(&terminal).contains("Explorer")); - editor.handle(Input::Escape); - let docked = editor.file_picker().unwrap(); - assert!(!docked.focused); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let dock_offsets = viewports.offsets[&a_id]; - editor.handle(Input::Ctrl('p')); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert!(screen_text(&terminal).contains("Commands")); - assert_eq!(terminal.get_cursor_position().unwrap().y, 1); - assert_eq!(viewports.offsets[&a_id], dock_offsets); - editor.handle(Input::Escape); - editor.execute(&format!("edit {}", b.display())); - let b_id = editor.buffer_id(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert!(editor.file_picker().is_some()); - assert_eq!(viewports.offsets[&b_id], (0, 0)); - editor.execute(&format!("buffer {a_id}")); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(viewports.offsets[&a_id], dock_offsets); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('o')); - assert_eq!( - editor.file_picker().unwrap().presentation, - FilePresentation::Floating - ); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert!(screen_text(&terminal).contains("Files")); - editor.handle(Input::Ctrl('w')); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert!(editor.file_picker().is_none()); - assert_eq!(viewports.offsets[&b_id], (0, 0)); - assert!(screen_text(&terminal).contains("a")); - } - - #[test] - fn docked_search_highlight_and_cursor_survive_focus_and_offset_resizes() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor - .apply(vec![crate::editor::Effect::Replace( - "0123456789界e\u{301}z\nsecond".into(), - )]) - .unwrap(); - editor.handle(Input::Char('/')); - editor.handle(Input::Char('界')); - editor.handle(Input::Enter); - assert!(editor.search_match().is_some()); - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('e')); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(100, 32), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(Rect::new(3, 2, 90, 26)), - }, - ) - .unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - for (name, side) in [ - ("left", DockSide::Left), - ("right", DockSide::Right), - ("top", DockSide::Top), - ("bottom", DockSide::Bottom), - ] { - editor.set_file_explorer_side(name).unwrap(); - for (width, height) in [ - (90, 26), - (12, 6), - (8, 2), - (1, 1), - (1, 2), - (0, 1), - (1, 0), - (90, 26), - ] { - let area = Rect::new(3, 2, width, height); - terminal.resize(area).unwrap(); - let area = terminal.get_frame().area(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - if width > 0 && height > 0 { - let workspace = - Rect::new(area.x, area.y, width, height - u16::from(height >= 2)); - let (_, panel) = dock_layout(workspace, side, true); - let cursor = terminal.get_cursor_position().unwrap(); - assert!( - panel.unwrap().contains(cursor), - "{side:?} {area:?} {panel:?}: {cursor:?}" - ); - } - editor.handle(Input::Escape); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - if width > 0 && height > 0 { - let cursor = terminal.get_cursor_position().unwrap(); - assert!(area.contains(cursor), "{side:?} {area:?}: {cursor:?}"); - let workspace = - Rect::new(area.x, area.y, width, height - u16::from(height >= 2)); - let (editor_area, panel) = dock_layout(workspace, side, false); - assert!(editor_area.contains(cursor), "{side:?}: {cursor:?}"); - let buffer = terminal.backend().buffer(); - if width == 90 { - let mut yellow = 0; - for y in editor_area.y..editor_area.bottom() { - for x in editor_area.x..editor_area.right() { - yellow += usize::from(buffer[(x, y)].bg == Color::Yellow); - } - } - assert!(yellow > 0); - } - if let Some(panel) = panel { - for y in panel.y..panel.bottom() { - for x in panel.x..panel.right() { - assert_ne!(buffer[(x, y)].bg, Color::Yellow); - } - } - } - assert_eq!(buffer[(0, 0)].symbol(), " "); - } - editor.handle(Input::Char(' ')); - editor.handle(Input::Char('e')); - assert!(editor.file_picker().unwrap().focused); - } - } - } - - #[test] - fn palette_advertises_effective_leader_sequences_after_remapping() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_leader(",").unwrap(); - editor.handle(Input::Ctrl('p')); - for ch in "bnext".chars() { - editor.handle(Input::Char(ch)); - } - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert!(screen_text(&terminal).contains(", b n")); - assert!(!screen_text(&terminal).contains("Space b n")); - editor - .bind("normal", ",", Some("core.command".into())) - .unwrap(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert!(screen_text(&terminal).contains("unbound")); - assert!(!screen_text(&terminal).contains(", b n")); - } - - fn document_wheel(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { - MouseEvent { - kind, - column, - row, - modifiers: KeyModifiers::NONE, - } - } - - #[test] - fn horizontal_wheel_preserves_selection_bounds_display_cells_and_survives_redraw() { - let directory = Directory::new(); - let text = format!("abc\t界e\u{301}{}", "xyz".repeat(20)); - let path = directory.file("wide.txt", &text); - let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); - session - .editor_mut() - .set_gutter_style(crate::gutter::GutterStyle::None); - session.handle(Input::Char('v')); - session.handle(Input::Char('l')); - let cursor = session.editor().cursor(); - let selections = session.editor().selections(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw_session(frame, &mut session)) - .unwrap(); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollRight, 0, 0) - )); - terminal - .draw(|frame| viewports.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(terminal.backend().buffer()[(3, 0)].symbol(), "界"); - assert_eq!(terminal.backend().buffer()[(5, 0)].symbol(), "e\u{301}"); - let mut shift = document_wheel(MouseEventKind::ScrollDown, 0, 0); - shift.modifiers = KeyModifiers::SHIFT; - assert!(viewports.scroll(&mut session, shift)); - terminal - .draw(|frame| viewports.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(viewports.offsets[&session.editor().buffer_id()], (0, 6)); - for _ in 0..100 { - viewports.scroll(&mut session, shift); - } - terminal - .draw(|frame| viewports.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!( - viewports.offsets[&session.editor().buffer_id()].1, - display_column(&text, usize::MAX) + 1 - viewports.document.text_width - ); - assert_eq!(session.editor().cursor(), cursor); - assert_eq!(session.editor().selections(), selections); - assert_eq!(session.editor().lines(), std::slice::from_ref(&text)); - terminal.backend_mut().resize(100, 6); - terminal - .draw(|frame| viewports.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(viewports.offsets[&session.editor().buffer_id()], (0, 0)); - assert!(screen_text(&terminal).contains("abc")); - session.handle(Input::Escape); - session.handle(Input::Char('l')); - terminal - .draw(|frame| viewports.draw_session(frame, &mut session)) - .unwrap(); - assert!(terminal.backend().cursor_visible()); - } - - #[test] - fn wheel_scrolls_rendered_document_three_rows_without_changing_any_selection() { - let directory = Directory::new(); - let path = directory.file( - "rows.txt", - &(0..12) - .map(|row| format!("abc row-{row:02}")) - .collect::>() - .join("\n"), - ); - let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); - session.handle(Input::Char('v')); - session.handle(Input::Char('e')); - session.handle(Input::Ctrl('a')); - assert_eq!(session.editor().selection_count(), 12); - let carets = session.editor().carets(); - let selections = session.editor().selections(); - let cursor = session.editor().cursor(); - let revision = session.editor().text_revision(); - let text = session.editor().lines().to_vec(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(terminal.backend().cursor_visible()); - let height = usize::from(viewports.document.geometry.unwrap().1.height); - let limit = session.editor().lines().len().saturating_sub(height); - let mut top = 0usize; - for kind in [ - MouseEventKind::ScrollDown, - MouseEventKind::ScrollDown, - MouseEventKind::ScrollDown, - MouseEventKind::ScrollDown, - MouseEventKind::ScrollUp, - MouseEventKind::ScrollUp, - MouseEventKind::ScrollUp, - MouseEventKind::ScrollUp, - ] { - top = if kind == MouseEventKind::ScrollDown { - top.saturating_add(3).min(limit) - } else { - top.saturating_sub(3) - }; - // The gutter belongs to the rendered document hit region too. - assert!(viewports.scroll(&mut session, document_wheel(kind, 0, 0))); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, top); - assert!( - screen_text(&terminal) - .lines() - .next() - .unwrap() - .contains(&format!("row-{top:02}")) - ); - assert_eq!( - terminal.backend().cursor_visible(), - cursor.row >= top && cursor.row < top + height - ); - assert_eq!(session.editor().cursor(), cursor); - assert_eq!(session.editor().carets(), carets); - assert_eq!(session.editor().selections(), selections); - assert_eq!(session.editor().text_revision(), revision); - assert_eq!(session.editor().lines(), text); - } - assert!(!viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 0, 5) - )); - assert!(!viewports.scroll(&mut session, document_wheel(MouseEventKind::Moved, 0, 0))); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 0, 0) - )); - session.handle(Input::Escape); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!( - screen_text(&terminal) - .lines() - .next() - .unwrap() - .contains("row-00") - ); - assert!(terminal.backend().cursor_visible()); - } - - #[test] - fn queued_scrolls_coalesce_but_preserve_reversals_and_motion_barriers() { - let directory = Directory::new(); - let text = (0..100) - .map(|row| format!("row-{row}\n")) - .collect::(); - for (first, repeated, barrier) in [ - ( - Input::Ctrl('u'), - 'u', - KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), - ), - ( - Input::Ctrl('d'), - 'd', - KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), - ), - ( - Input::Down, - 'j', - KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), - ), - ] { - let mut session = - EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap(); - let mut views = BufferViewports::default(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - let modifiers = if repeated == 'j' { - KeyModifiers::NONE - } else { - KeyModifiers::CONTROL - }; - let mut events = std::iter::repeat_n( - Event::Key(KeyEvent::new(KeyCode::Char(repeated), modifiers)), - 7, - ) - .chain([Event::Key(barrier)]); - assert_eq!( - keys_ready(&mut session, first, || Ok(events.next())).unwrap(), - Some(Event::Key(barrier)) - ); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - if repeated == 'u' { - assert_eq!(views.offsets[&session.editor().buffer_id()].0, 0); - session.handle(Input::Ctrl('d')); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!( - views.offsets[&session.editor().buffer_id()].0, - usize::from(views.document.geometry.unwrap().1.height) / 2 - ); - } else if repeated == 'd' { - assert_eq!(views.offsets[&session.editor().buffer_id()].0, 40); - session.handle(Input::Home); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert_eq!(views.offsets[&session.editor().buffer_id()].0, 0); - } else { - assert_eq!(session.editor().cursor().row, 8); - } - } - } - - #[test] - fn queued_navigation_preserves_counted_motions_visual_ranges_and_remaps() { - let directory = Directory::new(); - let mut rows = (0..100) - .map(|row| format!("row-{row:03}")) - .collect::>(); - rows[1].clear(); - let text = rows.join("\n"); - let path = directory.file("rows.txt", &text); - for mode in [Mode::Normal, Mode::Visual] { - let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); - session - .editor_mut() - .bind("normal", "z", Some("core.down".into())) - .unwrap(); - session - .editor_mut() - .bind("visual", "z", Some("core.down".into())) - .unwrap(); - session.handle(Input::End); - if mode == Mode::Visual { - session.handle(Input::Char('v')); - } - session.handle(Input::Char('3')); - let key = |code| Event::Key(KeyEvent::new(code, KeyModifiers::NONE)); - let mut events = std::iter::once(key(KeyCode::Right)) - .chain(std::iter::repeat_n(key(KeyCode::Down), 18)) - .chain([ - key(KeyCode::Up), - Event::Key(KeyEvent::new_with_kind( - KeyCode::Char('z'), - KeyModifiers::NONE, - KeyEventKind::Repeat, - )), - key(KeyCode::Char('i')), - key(KeyCode::Char('x')), - ]); - let pending = keys_ready(&mut session, Input::Down, || Ok(events.next())).unwrap(); - assert_eq!(session.editor().cursor(), Cursor { row: 21, column: 1 }); - assert_eq!(session.editor().mode(), mode); - assert_eq!(session.editor().lines(), rows); - if mode == Mode::Visual { - assert_eq!( - session.editor().selections(), - vec![Selection { - start: Cursor { row: 0, column: 6 }, - end: Cursor { row: 21, column: 2 }, - }] - ); - } - assert_eq!(pending, Some(key(KeyCode::Char('i')))); - assert_eq!(events.next(), Some(key(KeyCode::Char('x')))); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 8)).unwrap(); - let mut views = BufferViewports::default(); - terminal - .draw(|frame| views.draw_session(frame, &mut session)) - .unwrap(); - assert!(screen_text(&terminal).contains("row-021")); - assert!(terminal.backend().cursor_visible()); - } - } - - #[test] - fn queued_navigation_stops_at_event_binding_and_batch_boundaries() { - let directory = Directory::new(); - let text = (0..100) - .map(|row| format!("row-{row:03}\n")) - .collect::(); - let path = directory.file("rows.txt", &text); - let down = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - for barrier in [ - Event::Resize(40, 12), - Event::Paste("literal".into()), - Event::Mouse(document_wheel(MouseEventKind::ScrollDown, 1, 1)), - Event::Key(KeyEvent::new_with_kind( - KeyCode::Down, - KeyModifiers::NONE, - KeyEventKind::Release, - )), - Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)), - Event::Key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)), - Event::Key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)), - ] { - let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); - session - .editor_mut() - .bind("normal", "j", Some("core.insert".into())) - .unwrap(); - session - .editor_mut() - .bind("normal", "z", Some("core.pane.split_horizontal".into())) - .unwrap(); - let mut events = [barrier.clone(), down.clone()].into_iter(); - let pending = keys_ready(&mut session, Input::Down, || Ok(events.next())).unwrap(); - assert_eq!(pending, Some(barrier)); - assert_eq!(events.next(), Some(down.clone())); - assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 }); - assert_eq!(session.editor().mode(), Mode::Normal); - assert_eq!(session.editor().pane_layout().len(), 1); - assert_eq!(session.editor().lines().join("\n"), text); - } - let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); - let mut events = std::iter::repeat_n(down, 80); - assert_eq!( - keys_ready(&mut session, Input::Down, || Ok(events.next())).unwrap(), - None - ); - assert_eq!(session.editor().cursor(), Cursor { row: 64, column: 0 }); - assert_eq!(events.count(), 17); - } - - #[test] - fn queued_typing_preserves_graphemes_history_and_mode_barriers() { - let directory = Directory::new(); - let path = directory.file("input.txt", "seed"); - let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); - session.handle(Input::Char('i')); - let mut events = [ - Event::Key(KeyEvent::new(KeyCode::Char('界'), KeyModifiers::NONE)), - Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), - Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), - ] - .into_iter(); - let pending = keys_ready(&mut session, Input::Char('a'), || Ok(events.next())).unwrap(); - assert_eq!(session.editor().lines(), ["a界seed"]); - assert_eq!(session.editor().mode(), Mode::Insert); - assert!(matches!(pending, Some(Event::Key(key)) if key.code == KeyCode::Esc)); - session.handle(Input::Escape); - let Some(Event::Key(key)) = events.next() else { - panic!("remaining command") - }; - session.handle(translate(key).unwrap()); - assert_eq!(session.editor().lines(), ["aseed"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), ["a界seed"]); - session.handle(Input::Char('u')); - assert_eq!(session.editor().lines(), ["seed"]); - } - - #[test] - fn queued_wheel_input_renders_latest_view_without_crossing_key_or_resize_barriers() { - let directory = Directory::new(); - let text = (0..24) - .map(|row| format!("row-{row:03}\n")) - .collect::(); - let path = directory.file("rows.txt", &text); - let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - let down = Event::Mouse(document_wheel(MouseEventKind::ScrollDown, 1, 1)); - let up = Event::Mouse(document_wheel(MouseEventKind::ScrollUp, 1, 1)); - for barrier in [ - Event::Key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)), - Event::Resize(40, 12), - ] { - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - let mut queued = [ - down.clone(), - up.clone(), - down.clone(), - barrier.clone(), - down.clone(), - ] - .into_iter(); - let (redraw, pending) = viewports - .scroll_ready( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 1, 1), - || Ok(queued.next()), - ) - .unwrap(); - assert!(redraw); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!( - screen_text(&terminal) - .lines() - .next() - .unwrap() - .contains("row-006") - ); - assert_eq!(pending, Some(barrier)); - assert_eq!(queued.next(), Some(down.clone())); - assert_eq!(session.editor().cursor(), Cursor::default()); - assert_eq!(session.editor().mode(), Mode::Normal); - assert!(!terminal.backend().cursor_visible()); - session.handle(Input::Up); - } - - assert_eq!(session.editor().lines().join("\n"), text); - } - - #[test] - fn no_op_navigation_restores_caret_following_through_core_dispatch() { - let directory = Directory::new(); - let path = directory.file("rows.txt", &["row"; 12].join("\n")); - let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - for motion in [Input::Char('k'), Input::Up, Input::Home] { - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 2, 0) - )); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(!terminal.backend().cursor_visible()); - session.handle(motion); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert_eq!(session.editor().cursor(), Cursor::default()); - assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0); - assert!(terminal.backend().cursor_visible()); - } - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - // A remapped motion and a disabled default demonstrate that intent is - // emitted by the resolved core action, not frontend key spelling. - editor.bind("normal", "x", Some("core.up".into())).unwrap(); - editor.bind("normal", "k", None).unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - for navigation in 0..3 { - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let buffer_id = editor.buffer_id(); - viewports.offsets.get_mut(&buffer_id).unwrap().0 = 3; - viewports.document.manual = Some(ManualViewport { - buffer_id, - revision: editor.text_revision(), - caret_reveal_revision: editor.caret_reveal_revision(), - mode: editor.mode(), - cursor: editor.cursor(), - carets: editor.carets(), - selections: editor.selections(), - }); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert!(!terminal.backend().cursor_visible()); - editor.handle(Input::Char('k')); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert!(!terminal.backend().cursor_visible()); - match navigation { - 0 => { - editor.handle(Input::Char('x')); - } - 1 => editor - .navigate_to( - path.clone(), - crate::plugin::TextPosition { row: 0, column: 0 }, - ) - .unwrap(), - 2 => { - editor.execute(&format!("buffer {buffer_id}")); - } - _ => unreachable!(), - } - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(editor.cursor(), Cursor::default()); - assert_eq!(viewports.offsets[&buffer_id].0, 0); - assert!(terminal.backend().cursor_visible()); - assert!( - screen_text(&terminal) - .lines() - .next() - .unwrap() - .contains("row") - ); - } - } - - #[test] - fn manual_document_view_survives_idle_resize_and_palette_then_follows_navigation_and_edits() { - let directory = Directory::new(); - let path = directory.file( - "rows.txt", - &(0..20) - .map(|row| format!("row-{row:02}")) - .collect::>() - .join("\n"), - ); - let other = directory.file("other.txt", "other"); - let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); - session - .editor_mut() - .set_gutter_style(crate::gutter::GutterStyle::None); - let buffer_id = session.editor().buffer_id(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - for _ in 0..2 { - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 4, 0) - )); - } - session.poll(); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!( - screen_text(&terminal) - .lines() - .next() - .unwrap() - .contains("row-06") - ); - assert!(!terminal.backend().cursor_visible()); - terminal.backend_mut().resize(30, 8); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!( - screen_text(&terminal) - .lines() - .next() - .unwrap() - .contains("row-06") - ); - session.handle(Input::Ctrl('p')); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 4, 0) - )); - session.handle(Input::Escape); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!( - screen_text(&terminal) - .lines() - .next() - .unwrap() - .contains("row-06") - ); - assert_eq!(session.editor().cursor(), Cursor::default()); - session.handle(Input::Down); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!( - screen_text(&terminal) - .lines() - .next() - .unwrap() - .contains("row-01") - ); - assert!(terminal.backend().cursor_visible()); - - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 4, 0) - )); - session.handle(Input::Char('i')); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(terminal.backend().cursor_visible()); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 4, 0) - )); - session.handle(Input::Char('z')); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(terminal.backend().cursor_visible()); - session.handle(Input::Escape); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 4, 0) - )); - session.execute(&format!("edit {}", other.display())); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(screen_text(&terminal).starts_with("1 other")); - session.execute(&format!("buffer {buffer_id}")); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(terminal.backend().cursor_visible()); - assert!( - screen_text(&terminal) - .lines() - .next() - .unwrap() - .contains("zrow-01") - ); - } - - #[test] - fn manual_document_view_hides_horizontal_offscreen_caret_and_clamps_tiny_viewports() { - let directory = Directory::new(); - let path = directory.file("long.txt", "abcdefghijk"); - let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); - session - .editor_mut() - .set_gutter_style(crate::gutter::GutterStyle::None); - for _ in 0..8 { - session.handle(Input::Right); - } - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 2, 0) - )); - terminal.backend_mut().resize(8, 6); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(!terminal.backend().cursor_visible()); - assert!(screen_text(&terminal).starts_with("1 abcdef")); - assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 8 }); - session.handle(Input::Left); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(terminal.backend().cursor_visible()); - assert_eq!(terminal.backend().cursor_position().x, 7); - for (width, height) in [(1, 1), (2, 2), (0, 0)] { - terminal.backend_mut().resize(width, height); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert_eq!( - viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 0, 0) - ), - width > 0 && height > 0, - ); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0); - } - } - - #[test] - fn wrapped_document_renders_continuations_selection_and_insertion_caret() { - let directory = Directory::new(); - let source = "abcdefghijklmnop\ntail"; - let path = directory.file("wrapped.txt", source); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor.execute("word_wrap.toggle"); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let screen = screen_text(&terminal); - let rows: Vec<_> = screen.lines().collect(); - assert_eq!(rows[0], "1 abcdefgh"); - assert_eq!(rows[1], " ijklmnop"); - assert_eq!(rows[2], " "); - assert_eq!(rows[3], "1 tail "); - editor.handle(Input::Char('v')); - for _ in 0..10 { - editor.handle(Input::Right); - } - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(4, 1)); - assert_eq!(terminal.backend().buffer()[(2, 1)].bg, Color::Blue); - assert_eq!(terminal.backend().buffer()[(3, 1)].bg, Color::Blue); - editor.handle(Input::Escape); - editor.handle(Input::End); - editor.handle(Input::Char('a')); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(2, 2)); - assert_eq!(editor.lines().join("\n"), source); - assert!(!editor.dirty()); - } - - #[test] - fn wrapped_wheel_moves_visual_rows_and_resize_and_toggle_restore_following() { - let directory = Directory::new(); - let source = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let path = directory.file("wrapped.txt", source); - let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); - let id = session.editor().buffer_id(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - for _ in 0..12 { - session.handle(Input::Right); - } - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - let original_left = viewports.offsets[&id].1; - assert!(original_left > 0); - session.execute("word_wrap.toggle"); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollRight, 4, 0) - )); - assert_eq!(viewports.offsets[&id].1, original_left); - assert!(viewports.document.manual.is_none()); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 4, 0) - )); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(!terminal.backend().cursor_visible()); - assert!(viewports.scroll( - &mut session, - document_wheel(MouseEventKind::ScrollDown, 4, 0) - )); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - terminal.backend_mut().resize(14, 6); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert!(viewports.document.manual.is_none()); - assert!(terminal.backend().cursor_visible()); - assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 12 }); - session.execute("word_wrap.toggle"); - terminal - .draw(|frame| viewports.draw(frame, session.editor_mut())) - .unwrap(); - assert_eq!(viewports.offsets[&id].1, original_left); - assert_eq!(session.editor().lines().join("\n"), source); - } - - #[test] - fn wrapped_unicode_uses_original_tab_stops_and_queries_visible_source_rows() { - let directory = Directory::new(); - let source = format!("#a\t界e\u{301} z\n{}", vec!["# unseen"; 100].join("\n")); - let path = directory.file("wrapped.py", &source); - let mut editor = Editor::open(Some(path), vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor - .replace_semantic_tokens( - editor.buffer_id(), - editor.text_revision(), - Some(Arc::from([semantic_token( - 0, - 3, - 5, - SemanticTokenKind::Property, - 0, - )])), - ) - .unwrap(); - editor - .replace_diagnostics( - "fixture", - editor.buffer_id(), - editor.text_revision(), - vec![annotation( - (0, 2), - (0, 5), - DiagnosticSeverity::Warning, - "wrapped range", - )], - ) - .unwrap(); - editor.execute("word_wrap.toggle"); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(viewports.document.text_width, 4); - let visible = viewports - .document - .visible - .iter() - .map(|&(row, part, _)| (row, part)) - .collect::>(); - assert!(visible.starts_with(&[(0, 0), (0, 1), (0, 2), (1, 0)])); - assert_eq!( - visible.len(), - usize::from(viewports.document.geometry.unwrap().1.height) - ); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(4, 0)].symbol(), "#"); - assert_eq!(buffer[(6, 0)].symbol(), " "); - assert_eq!(buffer[(4, 1)].symbol(), "界"); - assert_eq!(buffer[(6, 1)].symbol(), "e\u{301}"); - for x in [4, 6] { - assert_eq!(buffer[(x, 1)].fg, Color::Blue); - assert!(buffer[(x, 1)].modifier.contains(Modifier::UNDERLINED)); - assert_eq!(buffer[(x, 1)].underline_color, Color::Yellow); - } - assert_eq!(buffer[(4, 2)].fg, Color::DarkGray); - assert!(viewports.syntax[&editor.buffer_id()].line(2).is_empty()); - assert_eq!(buffer[(4, 2)].symbol(), "z"); - assert_eq!(editor.lines().join("\n"), source); - editor.handle(Input::Down); - assert_eq!(editor.cursor().row, 1); - } - - #[test] - fn buffer_switches_restore_independent_vertical_and_horizontal_viewports() { - let directory = Directory::new(); - let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n")); - let b = directory.file("b", &vec!["b".repeat(40); 20].join("\n")); - let mut editor = Editor::open(Some(a), vec![]).unwrap(); - let a_id = editor.buffer_id(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - for _ in 0..14 { - editor.handle(Input::Down); - } - editor.handle(Input::End); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let initial = viewports.offsets[&a_id]; - assert_eq!( - initial.0, - editor.cursor().row + 1 - usize::from(viewports.document.geometry.unwrap().1.height) - ); - assert_eq!( - initial.1, - display_column(&editor.lines()[editor.cursor().row], editor.cursor().column) + 1 - - viewports.document.text_width - ); - for _ in 0..2 { - editor.handle(Input::Up); - } - for _ in 0..3 { - editor.handle(Input::Left); - } - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let a_viewport = viewports.offsets[&a_id]; - let a_cursor = terminal.get_cursor_position().unwrap(); - assert_eq!(a_viewport, initial); - - editor.execute(&format!("edit {}", b.display())); - let b_id = editor.buffer_id(); - assert_ne!(a_id, b_id); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(viewports.offsets[&b_id], (0, 0)); - for _ in 0..8 { - editor.handle(Input::Down); - } - editor.handle(Input::End); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - editor.handle(Input::Up); - editor.handle(Input::Left); - editor.handle(Input::Left); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let b_viewport = viewports.offsets[&b_id]; - let b_cursor = terminal.get_cursor_position().unwrap(); - - editor.execute(&format!("buffer {a_id}")); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(viewports.offsets[&a_id], a_viewport); - assert_eq!(terminal.get_cursor_position().unwrap(), a_cursor); - assert_eq!( - terminal.backend().buffer()[(a_cursor.x, a_cursor.y)].symbol(), - "a" - ); - editor.execute("bnext"); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(editor.buffer_id(), b_id); - assert_eq!(viewports.offsets[&b_id], b_viewport); - assert_eq!(terminal.get_cursor_position().unwrap(), b_cursor); - } - - #[test] - fn closing_inactive_buffer_retires_its_viewport_without_moving_active_buffer() { - let directory = Directory::new(); - let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n")); - let b = directory.file("b", &vec!["b".repeat(40); 20].join("\n")); - let c = directory.file("c", "new buffer"); - let mut editor = Editor::open(Some(a), vec![]).unwrap(); - let a_id = editor.buffer_id(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - for _ in 0..14 { - editor.handle(Input::Down); - } - editor.handle(Input::End); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - editor.execute(&format!("edit {}", b.display())); - let b_id = editor.buffer_id(); - for _ in 0..8 { - editor.handle(Input::Down); - } - editor.handle(Input::End); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let b_viewport = viewports.offsets[&b_id]; - let b_cursor = terminal.get_cursor_position().unwrap(); - assert_eq!(viewports.offsets.len(), 2); - - editor.execute(&format!("bdelete {a_id}")); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(editor.buffer_id(), b_id); - assert!(!editor.has_buffer(a_id)); - assert!(!viewports.offsets.contains_key(&a_id)); - assert_eq!(viewports.offsets.len(), 1); - assert_eq!(viewports.offsets[&b_id], b_viewport); - assert_eq!(terminal.get_cursor_position().unwrap(), b_cursor); - editor.execute(&format!("edit {}", c.display())); - let c_id = editor.buffer_id(); - assert!(c_id > b_id); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(viewports.offsets[&c_id], (0, 0)); - assert!(!viewports.offsets.contains_key(&a_id)); - } - - #[test] - fn restoring_buffer_after_narrow_resize_clamps_caret_inside_text_region() { - let directory = Directory::new(); - let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n")); - let b = directory.file("b", "other"); - let mut editor = Editor::open(Some(a), vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - let a_id = editor.buffer_id(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 8)).unwrap(); - let mut viewports = BufferViewports::default(); - for _ in 0..14 { - editor.handle(Input::Down); - } - editor.handle(Input::End); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert_eq!( - viewports.offsets[&a_id].0, - editor.cursor().row + 1 - usize::from(viewports.document.geometry.unwrap().1.height) - ); - editor.execute(&format!("edit {}", b.display())); - let b_id = editor.buffer_id(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - terminal.backend_mut().resize(4, 4); - terminal - .resize(ratatui::layout::Rect::new(0, 0, 4, 4)) - .unwrap(); - editor.execute(&format!("buffer {a_id}")); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let viewport = viewports.document.geometry.unwrap().1; - assert_eq!( - viewports.offsets[&a_id], - (editor.cursor().row + 1 - usize::from(viewport.height), 49,) - ); - assert_eq!(viewports.offsets[&b_id], (0, 0)); - assert!(viewport.contains(terminal.get_cursor_position().unwrap())); - - for (width, height) in [(1, 1), (24, 8)] { - let area = ratatui::layout::Rect::new(0, 0, width, height); - terminal.backend_mut().resize(width, height); - terminal.resize(area).unwrap(); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let cursor = terminal.get_cursor_position().unwrap(); - assert!(area.contains(cursor), "{area:?}: {cursor:?}"); - assert_eq!( - terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), - "a" - ); - assert_eq!(viewports.offsets[&b_id], (0, 0)); - } - } - - #[test] - fn status_identifies_active_buffer_and_restored_visual_selection_count() { - let directory = Directory::new(); - let a = directory.file("a", "word\nword\nword"); - let b = directory.file("b", "other"); - let mut editor = Editor::open(Some(a), vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - let a_id = editor.buffer_id(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 6)).unwrap(); - let mut viewports = BufferViewports::default(); - editor.handle(Input::Char('v')); - editor.handle(Input::Ctrl('a')); - assert_eq!(editor.selection_count(), 3); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - assert!(!screen_text(&terminal).contains("[buffer")); - editor.execute(&format!("edit {}", b.display())); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let text = screen_text(&terminal); - assert!(!text.contains("3 selections"), "{text}"); - editor.execute(&format!("buffer {a_id}")); - terminal - .draw(|frame| viewports.draw(frame, &mut editor)) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains("3 selections"), "{text}"); - let (top, left) = viewports.offsets[&a_id]; - for selection in editor.selections() { - let x = 2 + (selection.start.column - left) as u16; - let y = (selection.start.row - top) as u16; - assert!(matches!( - terminal.backend().buffer()[(x, y)].bg, - Color::Blue | Color::Magenta - )); - } - } - - #[test] - fn pending_motion_count_is_visible_and_escape_cancels_without_moving() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor - .apply(vec![crate::editor::Effect::Replace(["row"; 12].join("\n"))]) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 6)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - editor.handle(Input::Char('1')); - editor.handle(Input::Char('0')); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert!(screen_text(&terminal).contains("count: 10")); - editor.handle(Input::Escape); - editor.handle(Input::Down); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert!(!screen_text(&terminal).contains("count:")); - assert_eq!( - terminal.get_cursor_position().unwrap(), - ratatui::layout::Position::new(3, 1) - ); - } - - #[test] - fn git_gutter_modes_colors_geometry_wrap_scroll_and_tiny_panes() { - use crate::gutter::{Change, GutterStyle}; - let directory = Directory::new(); - let path = directory.file("gutter.txt", "abcdefghijklmnop\nsecond\nthird\nfourth"); - let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); - assert!(editor.set_gutter_changes( - editor.buffer_id(), - editor.text_revision(), - &path, - vec![ - Some(Change::Added), - Some(Change::Removed), - Some(Change::Modified), - None - ], - )); - for (mode, marker) in [ - (GutterStyle::Bar, "▌"), - (GutterStyle::Symbols, "+"), - (GutterStyle::LineNumbers, ""), - (GutterStyle::None, ""), - ] { - editor.set_gutter_style(mode); - let mut views = BufferViewports::default(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let viewport = views.panels.caret.unwrap().0; - let markers = matches!(mode, GutterStyle::Bar | GutterStyle::Symbols); - assert_eq!(viewport.x, if markers { 4 } else { 2 }); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(viewport.x - 1, viewport.y)].symbol(), " "); - if markers { - assert_eq!(buffer[(1, viewport.y)].symbol(), " "); - assert_eq!(buffer[(2, viewport.y)].symbol(), marker); - for (offset, color) in [Color::Green, Color::Red, Color::Yellow] - .into_iter() - .enumerate() - { - assert_eq!(buffer[(1, viewport.y + offset as u16)].symbol(), " "); - assert_eq!(buffer[(2, viewport.y + offset as u16)].fg, color); - } - if mode == GutterStyle::Symbols { - assert_eq!(buffer[(2, viewport.y + 1)].symbol(), "-"); - assert_eq!(buffer[(2, viewport.y + 2)].symbol(), "~"); - } - } else if mode == GutterStyle::LineNumbers { - assert_eq!(buffer[(0, viewport.y)].fg, Color::Cyan); - assert_eq!(buffer[(0, viewport.y + 1)].fg, Color::Red); - assert_eq!(buffer[(0, viewport.y + 2)].fg, Color::Yellow); - } - assert_eq!(buffer[(viewport.x, viewport.y)].symbol(), "a"); - } - editor - .replace_diagnostics( - "fixture", - editor.buffer_id(), - editor.text_revision(), - vec![ - annotation((0, 0), (0, 1), DiagnosticSeverity::Error, "current"), - annotation((1, 0), (1, 1), DiagnosticSeverity::Warning, "warning"), - annotation((2, 0), (2, 1), DiagnosticSeverity::Error, "error"), - ], - ) - .unwrap(); - editor.set_gutter_style(GutterStyle::LineNumbers); - let mut views = BufferViewports::default(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let viewport = views.panels.caret.unwrap().0; - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(0, viewport.y)].fg, Color::Cyan); - assert!(buffer[(0, viewport.y)].modifier.contains(Modifier::BOLD)); - assert_eq!(buffer[(0, viewport.y + 1)].fg, Color::Yellow); - assert_eq!(buffer[(0, viewport.y + 2)].fg, Color::Red); - assert_eq!(buffer[(0, viewport.y + 3)].fg, Color::DarkGray); - editor - .replace_diagnostics( - "fixture", - editor.buffer_id(), - editor.text_revision(), - vec![], - ) - .unwrap(); - editor.set_gutter_style(GutterStyle::Symbols); - editor.handle(Input::End); - let mut scrolled_views = BufferViewports::default(); - let mut scrolled_terminal = - Terminal::new(ratatui::backend::TestBackend::new(8, 4)).unwrap(); - scrolled_terminal - .draw(|frame| scrolled_views.draw(frame, &mut editor)) - .unwrap(); - assert!(scrolled_views.offsets[&editor.buffer_id()].1 > 0); - let text_area = scrolled_views.panels.caret.unwrap().0; - assert_eq!( - scrolled_terminal.backend().buffer()[(2, text_area.y)].symbol(), - "+" - ); - assert_eq!(text_area.x, 4); - editor.handle(Input::Home); - editor.execute("word_wrap.toggle"); - let mut views = BufferViewports::default(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 8)).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let viewport = views.panels.caret.unwrap().0; - for (screen, &(_, continuation, _)) in views.document.visible.iter().enumerate() { - if continuation > 0 { - for x in 0..viewport.x { - assert_eq!( - terminal.backend().buffer()[(x, viewport.y + screen as u16)].symbol(), - " " - ); - } - } - } - editor.execute("word_wrap.toggle"); - editor.handle(Input::Down); - editor.handle(Input::Down); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 3)).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let viewport = views.panels.caret.unwrap().0; - for (screen, &(row, _, _)) in views.document.visible.iter().enumerate() { - let expected = match row { - 0 => "+", - 1 => "-", - 2 => "~", - _ => " ", - }; - assert_eq!( - terminal.backend().buffer()[(2, viewport.y + screen as u16)].symbol(), - expected - ); - } - for width in [1, 2, 3, 4] { - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 3)).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert_eq!(views.panels.caret.unwrap().0.x, 0); - } - editor.execute("pane.split_vertical"); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 8)).unwrap(); - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - assert!(screen_text(&terminal).contains('~')); - } - - #[test] - fn gutter_tracks_scrolled_rows_without_scrolling_with_text() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor - .apply(vec![crate::editor::Effect::Replace( - "0123456789界z\n".repeat(11), - )]) - .unwrap(); - for _ in 0..10 { - editor.handle(Input::Down); - } - editor.handle(Input::End); - let area = ratatui::layout::Rect::new(3, 2, 8, 5); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(20, 10), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - let mut views = BufferViewports::default(); - for input in [ - None, - Some(Input::Down), - Some(Input::Up), - Some(Input::Up), - Some(Input::Ctrl('e')), - ] { - if let Some(input) = input { - editor.handle(input); - } - terminal - .draw(|frame| views.draw(frame, &mut editor)) - .unwrap(); - let viewport = views.panels.caret.unwrap().0; - assert_eq!(viewport.height, area.height - 1); - assert_eq!(viewport.x, area.x + 3); - let (top, left) = views.offsets[&editor.buffer_id()]; - assert_eq!(views.document.visible[0].0, top); - let buffer = terminal.backend().buffer(); - for (screen_row, &(row, _, _)) in views.document.visible.iter().enumerate() { - let y = viewport.y + screen_row as u16; - let number: String = (area.x..viewport.x) - .map(|x| buffer[(x, y)].symbol()) - .collect(); - let expected = if row == editor.cursor().row { - row + 1 - } else { - row.abs_diff(editor.cursor().row) - }; - assert_eq!(number, format!("{expected:>2} ")); - assert_eq!(buffer[(area.x, y)].bg, Color::Reset); - assert_eq!( - buffer[(area.x, y)].modifier.contains(Modifier::BOLD), - row == editor.cursor().row - ); - } - let cursor = terminal.get_cursor_position().unwrap(); - assert!(viewport.contains(cursor)); - assert_eq!( - cursor.x, - viewport.x - + (display_column(&editor.lines()[editor.cursor().row], editor.cursor().column) - - left) as u16 - ); - assert_eq!(cursor.y, viewport.y + (editor.cursor().row - top) as u16); - } - assert_eq!(editor.selection_count(), 2); - } - - #[test] - fn gutter_resizes_at_digit_boundaries_and_yields_to_narrow_text() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - for count in [9usize, 10, 100] { - editor - .apply(vec![crate::editor::Effect::Replace( - vec!["x"; count].join("\n"), - )]) - .unwrap(); - let digits = count.ilog10() as u16 + 1; - let gutter_width = digits + 1; - for width in [12, gutter_width, gutter_width + 1, 1] { - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(width, 4)).unwrap(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let cursor = terminal.get_cursor_position().unwrap(); - let buffer = terminal.backend().buffer(); - let text_x = if width > gutter_width { - gutter_width - } else { - 0 - }; - assert_eq!(buffer[(text_x, 0)].symbol(), "x"); - assert_eq!(cursor, ratatui::layout::Position::new(text_x, 0)); - if text_x > 0 { - let number: String = (0..text_x).map(|x| buffer[(x, 0)].symbol()).collect(); - assert_eq!( - number, - format!("{:>digits$} ", 1, digits = usize::from(digits)) - ); - } - } - } - editor - .apply(vec![crate::editor::Effect::Replace(String::new())]) - .unwrap(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 5)).unwrap(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(0, 0)].symbol(), "1"); - assert_eq!(buffer[(0, 1)].symbol(), " "); - assert_eq!(buffer[(0, 2)].symbol(), " "); - assert_eq!( - terminal.get_cursor_position().unwrap(), - ratatui::layout::Position::new(2, 0) - ); - } - - #[test] - fn palette_renders_query_no_matches_and_restores_underlying_prompt() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.handle(Input::Char('/')); - for ch in "saved search".chars() { - editor.handle(Input::Char(ch)); - } - editor.handle(Input::Ctrl('p')); - for ch in "no-such-command".chars() { - editor.handle(Input::Char(ch)); - } - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains("> no-such-command"), "{text}"); - assert!(text.contains("No matching commands"), "{text}"); - assert!(text.contains("SEARCH"), "{text}"); - assert_eq!(terminal.get_cursor_position().unwrap().y, 1); - editor.handle(Input::Enter); - assert!(editor.palette().is_some()); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), Mode::Search); - assert_eq!(editor.command_line(), "saved search"); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - } - - #[test] - fn palette_window_follows_keyboard_selection() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.handle(Input::Ctrl('p')); - for _ in 0..20 { - editor.handle(Input::Ctrl('j')); - } - editor.handle(Input::Ctrl('k')); - editor.handle(Input::Ctrl('n')); - let palette = editor.palette().unwrap(); - assert_eq!(palette.selected, 20); - let selected = &palette.entries[palette.selected]; - let first_name = palette.entries[0].name.clone(); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains(&selected.name), "{text}"); - assert!(!text.contains(&format!("({first_name})")), "{text}"); - let buffer = terminal.backend().buffer(); - let selected_y = (2..10).find(|&y| buffer[(1, y)].symbol() == ">").unwrap(); - assert_eq!(buffer[(1, selected_y)].bg, Color::Blue); - assert!(buffer[(1, selected_y)].modifier.contains(Modifier::BOLD)); - assert_eq!(terminal.get_cursor_position().unwrap().y, 1); - } - - #[test] - fn palette_shows_effective_bindings_and_dimmed_mode_ineligible_commands() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor - .register(crate::editor::Feature { - name: "sample".into(), - required_capabilities: vec![], - commands: vec!["greet".into()], - core: false, - }) - .unwrap(); - editor.bind("normal", "g", Some("greet".into())).unwrap(); - editor.bind("normal", "g", None).unwrap(); - editor - .bind("normal", "ctrl+x", Some("greet".into())) - .unwrap(); - editor.handle(Input::Ctrl('p')); - for ch in "greet".chars() { - editor.handle(Input::Char(ch)); - } - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains("sample | Ctrl+x | NORMAL"), "{text}"); - assert!(!text.contains("unbound"), "{text}"); - editor.handle(Input::Escape); - editor.handle(Input::Char('i')); - editor.handle(Input::Ctrl('p')); - for ch in "greet".chars() { - editor.handle(Input::Char(ch)); - } - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let text = screen_text(&terminal); - assert!(text.contains("sample | unbound | NORMAL"), "{text}"); - assert!(text.contains("INSERT"), "{text}"); - let cell = &terminal.backend().buffer()[(1, 2)]; - assert!(cell.modifier.contains(Modifier::DIM)); - assert_eq!(cell.bg, Color::Blue); - editor.handle(Input::Enter); - assert!(editor.palette().is_some()); - assert_eq!(editor.mode(), Mode::Insert); - } - - #[test] - fn buffer_picker_keeps_selected_status_and_paths_visible_after_resize_and_filter() { - let mut editor = Editor::open(None, vec![]).unwrap(); - for id in 2..=12 { - editor.execute(&format!("edit picker-{id}.txt")); - } - editor - .apply(vec![crate::editor::Effect::Replace("unsaved".into())]) - .unwrap(); - editor.execute("buffers"); - editor.handle(Input::Ctrl('k')); - for (width, height) in [(80, 12), (32, 6), (1, 1), (4, 4), (80, 12)] { - let area = Rect::new(3, 2, width, height); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(90, 30), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - terminal - .draw(|frame| { - draw_palette( - frame, - editor.palette().unwrap(), - area, - editor.mode(), - editor.selection_navigation_hint(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert!(area.contains(terminal.get_cursor_position().unwrap())); - if width >= 32 { - let buffer = terminal.backend().buffer(); - let selected_y = (area.y..area.bottom()) - .find(|&y| { - pane_screen(&terminal, Rect::new(area.x, y, width, 1)) - .contains("picker-12.txt") - }) - .unwrap(); - assert_eq!(buffer[(area.x + 4, selected_y)].fg, Color::Cyan); - assert_eq!(buffer[(area.x + 8, selected_y)].symbol(), "+"); - assert_eq!(buffer[(area.x + 8, selected_y)].fg, Color::Yellow); - assert_eq!(buffer[(area.x + 10, selected_y)].bg, Color::Blue); - assert_eq!( - buffer[(area.x + 6, selected_y)].fg, - buffer[(area.x + 10, selected_y)].fg, - ); - assert!( - pane_screen(&terminal, Rect::new(area.x, selected_y, width, 1)) - .contains("picker-12.txt") - ); - } - } - editor.handle(Input::Char('2')); - editor.handle(Input::Ctrl('j')); - editor.handle(Input::Enter); - assert_eq!(editor.buffer_id(), 12); - assert_eq!(editor.context().text, "unsaved"); - assert!(editor.dirty()); - } - - #[test] - fn palette_query_scrolls_graphemes_tabs_and_wide_cells_on_tiny_offset_areas() { - let editor = Editor::open(None, vec![]).unwrap(); - let palette = Palette { - query: "long\t界e\u{301}z".into(), - entries: vec![], - selected: 0, - buffers: false, - }; - for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] { - let area = ratatui::layout::Rect::new(3, 2, width, height); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(24, 12), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - terminal - .draw(|frame| { - draw_palette( - frame, - &palette, - area, - Mode::Visual, - editor.selection_navigation_hint(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let cursor = terminal.get_cursor_position().unwrap(); - assert!(area.contains(cursor), "{area:?}: {cursor:?}"); - let bordered = height >= 4 && width >= 4; - let query_x = area.x + u16::from(bordered); - let query_y = area.y + u16::from(bordered); - let query_width = width - 2 * u16::from(bordered); - assert_eq!(cursor.x, query_x + query_width - 1); - assert_eq!(cursor.y, query_y); - let query = format!("> {}", palette.query); - let offset = - display_column(&query, usize::MAX).saturating_sub(usize::from(query_width) - 1); - let mut expected = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new( - query_x, - query_y, - query_width, - 1, - )); - use ratatui::widgets::Widget; - Paragraph::new(visible_line(&query, offset, usize::from(query_width))) - .render(expected.area, &mut expected); - for x in query_x..query_x + query_width { - assert_eq!( - terminal.backend().buffer()[(x, query_y)].symbol(), - expected[(x, query_y)].symbol() - ); - } - assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " "); - } - for (width, height) in [(0, 1), (1, 0), (0, 0)] { - let mut terminal = - Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); - terminal - .draw(|frame| { - let area = frame.area(); - draw_palette( - frame, - &palette, - area, - Mode::Normal, - editor.selection_navigation_hint(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - } - } - - #[test] - fn opening_palette_does_not_scroll_underlying_editor_viewport() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor - .apply(vec![crate::editor::Effect::Replace( - "first\nsecond\n0123456789界z".into(), - )]) - .unwrap(); - editor.handle(Input::Down); - editor.handle(Input::Down); - editor.handle(Input::End); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 4)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let viewport = (top, left); - editor.handle(Input::Ctrl('p')); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert_eq!((top, left), viewport); - editor.handle(Input::Ctrl('p')); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert_eq!((top, left), viewport); - let cursor = terminal.get_cursor_position().unwrap(); - assert_eq!( - terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), - "z" - ); - } - - #[test] - fn layout_keeps_scrolled_content_and_prompts_inside_offset_regions() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor - .apply(vec![crate::editor::Effect::Replace( - "first\nsecond\n0123456789界z".into(), - )]) - .unwrap(); - editor.handle(Input::Down); - editor.handle(Input::Down); - editor.handle(Input::End); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - for prompting in [false, true] { - if prompting { - editor.handle(Input::Char('/')); - for ch in "long query".chars() { - editor.handle(Input::Char(ch)); - } - } - for height in [4, 2, 1, 4] { - let area = ratatui::layout::Rect::new(3, 2, 8, height); - let mut terminal = Terminal::with_options( - ratatui::backend::TestBackend::new(20, 10), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(area), - }, - ) - .unwrap(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let cursor = terminal.get_cursor_position().unwrap(); - assert!(area.contains(cursor), "{area:?}: {cursor:?}"); - let buffer = terminal.backend().buffer(); - if prompting { - assert_eq!(buffer[(cursor.x, cursor.y)].symbol(), " "); - } else { - assert_eq!(buffer[(cursor.x, cursor.y)].symbol(), "z"); - } - assert_eq!(buffer[(0, 0)].symbol(), " "); - } - } - } - - #[test] - fn tabs_and_wide_graphemes_scroll_in_terminal_cells() { - let text = "a\t界e\u{301}z"; - assert_eq!(display_column(text, 4), 7); - assert_eq!(visible_line(text, 2, 5), " 界e\u{301}"); - assert_eq!(visible_line(text, 5, 2), " e\u{301}"); - } - - #[test] - fn selection_set_renders_all_ranges_and_counts_offscreen_targets() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor - .apply(vec![crate::editor::Effect::Replace( - "界\n界\n界\n界\n界".into(), - )]) - .unwrap(); - editor.handle(Input::Home); - editor.handle(Input::Char('v')); - editor.handle(Input::Ctrl('a')); - assert_eq!(editor.selection_count(), 5); - assert_eq!(editor.selections().len(), 5); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 4)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - for selection in editor.selections() { - if selection.start.row >= top && selection.start.row < top + 2 { - let y = (selection.start.row - top) as u16; - assert!(matches!(buffer[(2, y)].bg, Color::Blue | Color::Magenta)); - } - } - assert_eq!( - terminal.get_cursor_position().unwrap(), - ratatui::layout::Position::new(2, (editor.cursor().row - top) as u16) - ); - } - - #[test] - fn secondary_carets_render_on_empty_lines_and_at_line_end() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor - .apply(vec![crate::editor::Effect::Replace("a\n\n界".into())]) - .unwrap(); - editor.handle(Input::Home); - editor.handle(Input::Ctrl('e')); - editor.handle(Input::Ctrl('e')); - assert_eq!(editor.selection_count(), 3); - editor.handle(Input::Char('A')); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 5)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - for caret in editor.carets() { - let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16; - assert_eq!(buffer[(x, caret.row as u16)].symbol(), " "); - if caret != editor.cursor() { - assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta); - } - } - assert_eq!( - terminal.get_cursor_position().unwrap(), - ratatui::layout::Position::new( - 2 + display_column(&editor.lines()[editor.cursor().row], editor.cursor().column) - as u16, - editor.cursor().row as u16 - ) - ); - } - - #[test] - fn linewise_selection_highlights_copied_text_and_newlines_without_filling_rows() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor - .apply(vec![crate::editor::Effect::Replace( - "\t界e\u{301} \n\n".into(), - )]) - .unwrap(); - editor.handle(Input::Char('V')); - editor.handle(Input::Down); - editor.handle(Input::Down); - let before = (editor.lines().to_vec(), editor.cursor(), editor.carets()); - for width in [16, 3, 1] { - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 6)).unwrap(); - let (mut top, mut left) = (0, 0); - let frame = terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut SyntaxCache::new(), - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let buffer = frame.buffer; - let gutter = if width > 2 { 2 } else { 0 }; - for row in 0..3 { - for x in 0..gutter { - assert_ne!(buffer[(x, row)].bg, Color::Blue); - } - let selected_width = if row == 0 { 10 } else { 1 }; - for x in gutter..width { - if x - gutter < selected_width { - assert_eq!( - buffer[(x, row)].bg, - Color::Blue, - "width {width}, row {row}, x {x}" - ); - } else { - assert_ne!(buffer[(x, row)].bg, Color::Blue); - } - } - } - for x in 0..width { - assert_ne!(buffer[(x, 3)].bg, Color::Blue); - } - } - assert_eq!( - before, - (editor.lines().to_vec(), editor.cursor(), editor.carets()) - ); - editor.handle(Input::Up); - editor.handle(Input::Up); - editor.handle(Input::End); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(5, 3)).unwrap(); - let (mut top, mut left) = (0, 0); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut SyntaxCache::new(), - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert!(left > 0); - let buffer = terminal.backend().buffer(); - for x in 0..2 { - assert_ne!(buffer[(x, 0)].bg, Color::Blue); - } - for x in 2..5 { - assert_eq!(buffer[(x, 0)].bg, Color::Blue); - } - } - - #[test] - fn visual_selection_highlights_graphemes_newlines_and_clipped_ranges_over_search() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor - .apply(vec![crate::editor::Effect::Replace( - "a\t界e\u{301}z\n\nxy".into(), - )]) - .unwrap(); - editor.handle(Input::Char('/')); - for ch in "界e\u{301}".chars() { - editor.handle(Input::Char(ch)); - } - editor.handle(Input::Enter); - editor.handle(Input::Char('v')); - editor.handle(Input::Left); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 6)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - for x in [1, 2, 3, 4] { - assert_eq!(buffer[(2 + x, 0)].bg, Color::Blue); - } - assert_eq!(buffer[(6, 0)].symbol(), "界"); - assert_eq!(buffer[(8, 0)].bg, Color::Yellow); - editor.handle(Input::Down); - editor.handle(Input::Down); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(10, 0)].symbol(), " "); - assert_eq!(buffer[(10, 0)].bg, Color::Blue); - assert_eq!(buffer[(2, 1)].bg, Color::Blue); - assert_eq!(buffer[(2, 2)].bg, Color::Blue); - assert_ne!(buffer[(3, 2)].bg, Color::Blue); - - editor.handle(Input::Up); - editor.handle(Input::Up); - editor.handle(Input::End); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(7, 6)).unwrap(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - assert_eq!(left, 4); - assert_eq!(buffer[(2, 0)].symbol(), "界"); - assert_eq!(buffer[(4, 0)].symbol(), "e\u{301}"); - assert_eq!(buffer[(6, 0)].symbol(), " "); - for x in [0, 2, 3, 4] { - assert_eq!(buffer[(2 + x, 0)].bg, Color::Blue); - } - assert_ne!(buffer[(2, 1)].bg, Color::Blue); - editor.handle(Input::Escape); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(2, 0)].bg, Color::Yellow); - assert_eq!(buffer[(4, 0)].bg, Color::Yellow); - assert_ne!(buffer[(5, 0)].bg, Color::Blue); - assert_ne!(buffer[(6, 0)].bg, Color::Blue); - } - - #[test] - fn search_highlights_all_occurrences_without_moving_on_first_star() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - configure_chrome(&mut editor, |top, bottom| { - for bar in [top, bottom] { - bar.left.clear(); - bar.center.clear(); - bar.right.clear(); - } - }); - editor - .apply(vec![crate::editor::Effect::Replace( - "cat cat_cat cat\ncat".into(), - )]) - .unwrap(); - editor.handle(Input::Char('*')); - assert_eq!(editor.cursor(), Cursor::default()); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 6)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ); - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - for (row, start, end) in [(0, 0, 3), (0, 12, 15), (1, 0, 3)] { - for column in start..end { - assert_eq!(buffer[(column + 2, row)].bg, Color::Yellow); - } - } - assert_ne!(buffer[(6, 0)].bg, Color::Yellow); - editor.handle(Input::Char('/')); - for ch in "cat".chars() { - editor.handle(Input::Char(ch)); - } - editor.handle(Input::Enter); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ); - }) - .unwrap(); - for column in [0, 4, 8, 12] { - assert_eq!( - terminal.backend().buffer()[(column + 2, 0)].bg, - Color::Yellow - ); - } - } - - #[test] - fn active_search_highlight_uses_display_cells_and_clips_with_horizontal_scroll() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.set_gutter_style(crate::gutter::GutterStyle::None); - editor - .apply(vec![crate::editor::Effect::Replace( - "a\t界e\u{301}zz".into(), - )]) - .unwrap(); - editor.handle(Input::Char('/')); - for ch in "\t界e\u{301}".chars() { - editor.handle(Input::Char(ch)); - } - editor.handle(Input::Enter); - editor.handle(Input::End); - let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(7, 5)).unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - assert_eq!(left, 4); - assert_eq!(buffer[(2, 0)].symbol(), "界"); - assert_eq!(buffer[(4, 0)].symbol(), "e\u{301}"); - for x in [0, 2] { - assert_eq!(buffer[(2 + x, 0)].bg, Color::Yellow); - } - for x in 3..5 { - assert_ne!(buffer[(2 + x, 0)].bg, Color::Yellow); - } - editor.handle(Input::Char('/')); - for ch in "界界".chars() { - editor.handle(Input::Char(ch)); - } - let mut panels = WheelPanels::default(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut panels, - ) - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - let input = panels.input.unwrap(); - let content = Rect::new(input.x + 2, input.y + 1, input.width - 4, 1); - assert_eq!(buffer[(content.x, content.y)].symbol(), "界"); - editor.handle(Input::Backspace); - editor.handle(Input::Backspace); - editor.handle(Input::Char('\t')); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut panels, - ) - }) - .unwrap(); - assert!( - panels - .input - .unwrap() - .contains(terminal.get_cursor_position().unwrap()) - ); - assert!(content.contains(terminal.get_cursor_position().unwrap())); - } - - #[test] - fn terminal_can_present_previous_frame_while_next_frame_is_computed() { - use std::{cell::Cell, rc::Rc}; - - struct Output { - bytes: Vec, - synchronized: Rc>, - } - impl io::Write for Output { - fn write(&mut self, bytes: &[u8]) -> io::Result { - self.bytes.extend_from_slice(bytes); - Ok(bytes.len()) - } - fn flush(&mut self) -> io::Result<()> { - for command in self.bytes.split(|byte| *byte == b'\x1b') { - if command.starts_with(b"[?2026h") { - self.synchronized.set(true); - } else if command.starts_with(b"[?2026l") { - self.synchronized.set(false); - } - } - self.bytes.clear(); - Ok(()) - } - } - let synchronized = Rc::new(Cell::new(false)); - let mut output = Output { - bytes: Vec::new(), - synchronized: Rc::clone(&synchronized), - }; - let mut terminal = Terminal::with_options( - CrosstermBackend::new(Vec::new()), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)), - }, - ) - .unwrap(); - for text in ["first frame", "second frame", "third frame"] { - draw_frame(&mut terminal, &mut output, false, |frame| { - assert!( - !synchronized.get(), - "terminal presentation suspended during frame computation" - ); - frame.render_widget(Paragraph::new(text), frame.area()); - }) - .unwrap(); - assert!(!synchronized.get()); - } - } - - #[test] - fn scrolling_never_paints_with_an_exposed_cursor_even_without_synchronized_updates() { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor - .apply(vec![crate::editor::Effect::Replace( - (0..40).map(|row| format!("row {row}\n")).collect(), - )]) - .unwrap(); - let mut output = Vec::new(); - let mut terminal = Terminal::with_options( - CrosstermBackend::new(Vec::new()), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)), - }, - ) - .unwrap(); - let mut viewports = BufferViewports::default(); - for key in std::iter::repeat_n('j', 20).chain(std::iter::repeat_n('k', 20)) { - editor.handle(Input::Char(key)); - draw_frame(&mut terminal, &mut output, false, |frame| { - viewports.draw(frame, &mut editor) - }) - .unwrap(); - } - drop(terminal); - let output = std::str::from_utf8(&output).unwrap(); - for supports_sync in [false, true] { - let (mut visible, mut synchronized) = (true, false); - let mut screen = [[b' '; 20]; 5]; - let (mut x, mut y) = (0, 0); - for escape in output.split('\x1b').skip(1) { - let rest = escape.strip_prefix('[').unwrap(); - let end = rest.find(|ch| ('@'..='~').contains(&ch)).unwrap(); - let (command, text) = rest.split_at(end + 1); - match command { - "?25h" => visible = true, - "?25l" => visible = false, - "?2026h" => synchronized = supports_sync, - "?2026l" => synchronized = false, - _ if command.ends_with('H') => { - let (row, column) = command[..command.len() - 1].split_once(';').unwrap(); - y = row.parse::().unwrap() - 1; - x = column.parse::().unwrap() - 1; - } - _ => {} - } - for byte in text.bytes().filter(|&byte| byte >= b' ' && byte != 127) { - assert!( - if supports_sync { - synchronized - } else { - !visible - }, - "partial frame exposed during redraw (sync={supports_sync}): {text:?}", - ); - if y < screen.len() && x < screen[y].len() { - screen[y][x] = byte; - } - x += 1; - } - } - assert!(visible); - assert!(!synchronized); - for (row, expected) in [(0, "row 0"), (1, "row 1"), (2, "row 2")] { - let line = std::str::from_utf8(&screen[row]).unwrap().trim_end(); - assert!(line.ends_with(expected), "{line:?}"); - } - assert_eq!(screen[y][x], b'r', "caret was not restored to the source"); - } - } - - #[cfg(unix)] - #[test] - fn terminal_session_scopes_the_callers_panic_hook() { - use std::io::Write; - const CHILD: &str = "VAMP_TERMINAL_HOOK_PROBE"; - if let Ok(mode) = std::env::var(CHILD) { - std::panic::set_hook(Box::new(|info| { - assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap()); - println!("CALLER_HOOK"); - eprintln!("{info}"); - })); - if mode == "entry" { - let mut editor = EditorSession::open(None, vec![], None).unwrap(); - assert!(TerminalSession::run(false, || run(&mut editor, false)).is_err()); - } else { - let result = panic::catch_unwind(AssertUnwindSafe(|| { - TerminalSession::run(false, || { - assert!(crossterm::terminal::is_raw_mode_enabled().unwrap()); - match mode.as_str() { - "normal" => Ok(()), - "error" => Err(io::Error::from(io::ErrorKind::BrokenPipe)), - "panic" => { - let mut terminal = Terminal::with_options( - CrosstermBackend::new(Vec::new()), - ratatui::TerminalOptions { - viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)), - }, - )?; - draw_frame(&mut terminal, &mut io::stdout(), false, |_| { - panic::panic_any(73_u64) - }) - } - "keys" => { - let mut editor = Editor::open(None, vec![]).unwrap(); - editor.handle(Input::Ctrl('p')); - let original = editor.lines().to_vec(); - println!("READING_KEYS"); - io::stdout().flush()?; - for (index, expected) in [ - Input::Ctrl('j'), - Input::Ctrl('k'), - Input::Ctrl('j'), - Input::Ctrl('k'), - Input::Ctrl('h'), - Input::Ctrl('l'), - Input::Backspace, - Input::Backspace, - Input::Enter, - Input::Enter, - ] - .into_iter() - .enumerate() - { - let Event::Key(key) = event::read()? else { - panic!("expected a keyboard event"); - }; - let input = translate(key).unwrap(); - assert_eq!(input, expected); - if index == 4 { - for ch in "ab".chars() { - editor.handle(Input::Char(ch)); - } - } else if index == 8 { - for ch in "q!".chars() { - editor.handle(Input::Char(ch)); - } - } - editor.handle(input); - match index { - 0 | 2 => assert_eq!(editor.palette().unwrap().selected, 1), - 1 | 3 => assert_eq!(editor.palette().unwrap().selected, 0), - 4 | 5 => assert_eq!(editor.palette().unwrap().query, "ab"), - 6 => assert_eq!(editor.palette().unwrap().query, "a"), - 7 => assert_eq!(editor.palette().unwrap().query, ""), - 8 => { - assert!(editor.palette().is_none()); - assert_eq!(editor.mode(), Mode::Command); - assert!(!editor.should_quit()); - } - 9 => assert!(editor.should_quit()), - _ => unreachable!(), - } - assert_eq!(editor.lines(), original); - } - Ok(()) - } - _ => unreachable!(), - } - }) - })); - match mode.as_str() { - "normal" | "keys" => result.unwrap().unwrap(), - "error" => assert_eq!( - result.unwrap().unwrap_err().kind(), - io::ErrorKind::BrokenPipe - ), - "panic" => assert_eq!(result.unwrap_err().downcast_ref::(), Some(&73)), - _ => unreachable!(), - } - } - assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap()); - println!("AFTER_TERMINAL_RETURN"); - let _ = panic::catch_unwind(|| panic!("outside the terminal session")); - return; - } - - use std::os::unix::process::CommandExt; - use std::process::{Command, Stdio}; - unsafe extern "C" { - fn setsid() -> i32; - } - let mut command = Command::new(std::env::current_exe().unwrap()); - command - .args([ - "--exact", - "tui::tests::terminal_session_scopes_the_callers_panic_hook", - "--nocapture", - "--color", - "never", - ]) - .env(CHILD, "entry") - .stdin(Stdio::null()); - // SAFETY: setsid is async-signal-safe and detaches the child from any - // controlling terminal without touching inherited Rust state. - unsafe { - command.pre_exec(|| { - if setsid() == -1 { - Err(io::Error::last_os_error()) - } else { - Ok(()) - } - }); - } - let check = |output: std::process::Output, mode: &str| { - assert!(output.status.success(), "{mode}: {output:?}"); - let stdout = String::from_utf8(output.stdout).unwrap(); - let (before, after) = stdout.split_once("AFTER_TERMINAL_RETURN").unwrap(); - let entered = usize::from(mode != "entry"); - assert_eq!( - before.matches("\x1b[?1049h").count(), - entered, - "{mode}: {before:?}" - ); - assert_eq!( - before.matches("\x1b[?1049l").count(), - entered, - "{mode}: {before:?}" - ); - for sequence in ["\x1b[>1u", "\x1b[<1u"] { - assert_eq!( - before.matches(sequence).count(), - entered, - "{mode}: {before:?}" - ); - } - for protocol in [1000, 1002, 1003, 1015, 1006] { - assert_eq!( - before.matches(&format!("\x1b[?{protocol}h")).count(), - entered, - "{mode}: mouse capture was not enabled: {before:?}", - ); - assert_eq!( - before.matches(&format!("\x1b[?{protocol}l")).count(), - entered, - "{mode}: mouse capture was not restored: {before:?}", - ); - } - if mode == "panic" { - let end = before.find("\x1b[?2026l").unwrap(); - assert!(end < before.find("CALLER_HOOK").unwrap()); - assert!( - before.find("\x1b[?1049l").unwrap() < before.find("CALLER_HOOK").unwrap(), - "{before:?}", - ); - assert!( - before.find("\x1b[?1006l").unwrap() < before.find("CALLER_HOOK").unwrap(), - "{before:?}", - ); - } - assert!(after.contains("CALLER_HOOK"), "{after:?}"); - assert!( - !after.contains('\u{1b}'), - "terminal hook outlived its session: {after:?}" - ); - }; - check(command.output().unwrap(), "entry"); - - // Ruby's standard-library PTY gives each positive-entry scenario a real - // terminal, keeping process-global hooks isolated from the test runner. - for mode in ["normal", "error", "panic", "keys"] { - let output = - Command::new(std::env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into())) - .args([ - "-rpty", - "-rtimeout", - "-e", - r#" -PTY.spawn(*ARGV) do |output, input, pid| - sent_keys = false - captured = +"" - begin - Timeout.timeout(10) do - loop do - chunk = output.readpartial(4096) - STDOUT.write(chunk) - captured << chunk - if !sent_keys && captured.include?("READING_KEYS") - input.write("\e[106;5u\e[107;5u\n\v\b\f\x7f\x7f\e[13u\r") - input.flush - sent_keys = true - end - end - end - rescue EOFError, Errno::EIO - rescue Timeout::Error - Process.kill("TERM", pid) - Process.wait(pid) - abort "terminal lifecycle probe timed out" - ensure - input.close - end - Process.wait(pid) - exit($?.exitstatus || 1) -end -"#, - "--", - ]) - .arg(std::env::current_exe().unwrap()) - .args([ - "--exact", - "tui::tests::terminal_session_scopes_the_callers_panic_hook", - "--nocapture", - "--color", - "never", - ]) - .env(CHILD, mode) - .output() - .unwrap(); - check(output, mode); - } - } -} +#[path = "../tests/unit/tui.rs"] +mod tests; diff --git a/src/wrap.rs b/src/wrap.rs index 397b25a..ad8f10c 100644 --- a/src/wrap.rs +++ b/src/wrap.rs @@ -92,138 +92,5 @@ pub(crate) fn caret_segment(segments: &[Segment], column: usize) -> usize { } #[cfg(test)] -mod tests { - use super::*; - - fn layout(text: &str, width: usize) -> Vec { - let mut cache = WrapCache::default(); - cache.prepare((1, 0), width); - cache.segments(0, text).to_vec() - } - - #[test] - fn words_and_long_words_preserve_every_source_cell() { - assert_eq!( - layout("one two three", 8), - vec![Segment { start: 0, end: 8 }, Segment { start: 8, end: 13 },] - ); - let segments = layout("abcdefgh", 3); - assert_eq!( - segments, - vec![ - Segment { start: 0, end: 3 }, - Segment { start: 3, end: 6 }, - Segment { start: 6, end: 8 }, - ] - ); - assert_eq!(caret_segment(&segments, 3), 1); - assert_eq!(caret_segment(&segments, 8), 2); - } - - #[test] - fn long_words_do_not_wrap_indentation_onto_an_empty_visual_row() { - for source in [ - " abcdefghijk", - "\tabcdefghijk", - "head abcdefghijk", - ] { - let expanded = source.replace('\t', " "); - let segments = layout(source, 8); - assert_eq!(segments.first().unwrap().start, 0); - assert_eq!(segments.last().unwrap().end, expanded.len()); - assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start)); - for segment in segments - .iter() - .filter(|segment| segment.start < segment.end) - { - assert!( - !expanded[segment.start..segment.end].trim().is_empty(), - "whitespace-only visual row {segment:?} in {source:?}" - ); - } - } - } - - #[test] - fn tabs_use_source_stops_and_unicode_graphemes_remain_intact() { - assert_eq!( - layout("ab\t界e\u{301} z", 4), - vec![ - Segment { start: 0, end: 4 }, - Segment { start: 4, end: 8 }, - Segment { start: 8, end: 9 }, - ] - ); - assert_eq!( - layout("界界界", 3), - vec![ - Segment { start: 0, end: 2 }, - Segment { start: 2, end: 4 }, - Segment { start: 4, end: 6 }, - ] - ); - assert_eq!( - layout(" \t ", 3), - vec![Segment { start: 0, end: 3 }, Segment { start: 3, end: 5 },] - ); - } - - #[test] - fn narrow_tabs_preserve_all_expanded_cells_at_original_stops() { - assert_eq!( - layout("\tX", 2), - [ - Segment { start: 0, end: 2 }, - Segment { start: 2, end: 4 }, - Segment { start: 4, end: 5 }, - ] - ); - assert_eq!( - layout("a\tX", 2), - [ - Segment { start: 0, end: 1 }, - Segment { start: 1, end: 3 }, - Segment { start: 3, end: 5 }, - Segment { start: 5, end: 5 }, - ] - ); - for source in ["\t", "a\t", "ab\tX", "\t\tX"] { - let segments = layout(source, 1); - assert!( - segments - .iter() - .all(|segment| segment.end - segment.start <= 1) - ); - assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start)); - let end = source.graphemes(true).fold(0, |column, grapheme| { - column - + if grapheme == "\t" { - TAB_WIDTH - column % TAB_WIDTH - } else { - 1 - } - }); - assert_eq!(segments.last().unwrap(), &Segment { start: end, end }); - } - } - - #[test] - fn full_lines_have_an_insertion_cell_and_cache_invalidation_is_lazy() { - let mut cache = WrapCache::default(); - cache.prepare((7, 2), 4); - assert_eq!( - cache.segments(90, "abcd"), - [Segment { start: 0, end: 4 }, Segment { start: 4, end: 4 },] - ); - assert_eq!(caret_segment(cache.segments(90, "abcd"), 4), 1); - assert_eq!(cache.rows.len(), 1); - cache.prepare((7, 2), 4); - assert_eq!(cache.rows.len(), 1); - cache.prepare((7, 2), 8); - assert!(cache.rows.is_empty()); - cache.segments(90, "abcd"); - cache.prepare((7, 3), 8); - assert!(cache.rows.is_empty()); - assert_eq!(layout("", 0), [Segment { start: 0, end: 0 }]); - } -} +#[path = "../tests/unit/wrap.rs"] +mod tests; diff --git a/tests/unit/battery.rs b/tests/unit/battery.rs new file mode 100644 index 0000000..5c7ffa2 --- /dev/null +++ b/tests/unit/battery.rs @@ -0,0 +1,197 @@ +use super::*; +use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicBool, AtomicU64, Ordering}, +}; + +#[test] +fn percentages_reject_invalid_data_and_include_both_endpoints() { + assert_eq!(percentage(0, 100), Some(0)); + assert_eq!(percentage(100, 100), Some(100)); + assert_eq!(percentage(1, 3), Some(33)); + assert_eq!(percentage(u64::MAX, u64::MAX), Some(100)); + assert_eq!(percentage(0, 0), None); + assert_eq!(percentage(101, 100), None); + assert_eq!(windows_charge(0, 4), Some(0)); + assert_eq!(windows_charge(100, 8), Some(100)); + assert_eq!(windows_charge(101, 0), None); + assert_eq!(windows_charge(255, 0), None); + assert_eq!(windows_charge(50, 128), None); + assert_eq!(windows_charge(50, 255), None); +} + +fn worker(samples: Vec>) -> (Battery, Receiver<()>, Arc) { + struct ReaderDropped(Arc); + impl Drop for ReaderDropped { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } + } + let dropped = Arc::new(AtomicBool::new(false)); + let reader = ReaderDropped(Arc::clone(&dropped)); + let mut samples = samples.into_iter(); + let (ready, published) = mpsc::channel(); + let battery = Battery::start_with( + move || { + let _ = &reader; + samples + .next() + .expect("test provides every requested sample") + }, + move |wake| { + ready.send(()).unwrap(); + // Explicit channel messages replace the production clock entirely. + wake.recv().is_ok() + }, + ) + .unwrap(); + (battery, published, dropped) +} + +#[test] +fn worker_reads_immediately_replaces_stale_samples_and_clears_invalid_data() { + let (mut battery, published, dropped) = worker(vec![Some(0), Some(100), None, Some(101)]); + published.recv().unwrap(); + assert_eq!(battery.poll(), Some(Some(0))); + assert_eq!(battery.poll(), None); + battery.wake.as_ref().unwrap().send(()).unwrap(); + published.recv().unwrap(); + battery.wake.as_ref().unwrap().send(()).unwrap(); + published.recv().unwrap(); + assert_eq!(battery.poll(), Some(None)); + assert_eq!(battery.poll(), None); + battery.wake.as_ref().unwrap().send(()).unwrap(); + published.recv().unwrap(); + assert_eq!(battery.poll(), Some(None)); + drop(battery); + assert!(dropped.load(Ordering::Acquire)); +} + +#[test] +fn dropping_interrupts_wait_and_joins_even_with_an_unread_sample() { + let (battery, published, dropped) = worker(vec![Some(50)]); + published.recv().unwrap(); + drop(battery); + assert!(dropped.load(Ordering::Acquire)); +} + +static FIXTURE_ID: AtomicU64 = AtomicU64::new(0); + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "vamp-battery-{}-{}", + std::process::id(), + FIXTURE_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + + fn source(&self, name: &str, fields: &[(&str, &str)]) { + let path = self.0.join(name); + fs::create_dir_all(&path).unwrap(); + for (key, value) in fields { + fs::write(path.join(key), value).unwrap(); + } + } + + fn read(&self) -> Option { + linux::read_charge(&self.0) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} + +#[test] +fn sysfs_reads_present_system_batteries_but_not_external_or_absent_sources() { + let fixture = Fixture::new(); + assert_eq!(fixture.read(), None); + fixture.source("AC", &[("type", "Mains"), ("capacity", "100")]); + fixture.source( + "mouse", + &[ + ("type", "Battery"), + ("scope", "Device"), + ("present", "1"), + ("capacity", "100"), + ], + ); + fixture.source( + "absent", + &[("type", "Battery"), ("present", "0"), ("capacity", "100")], + ); + assert_eq!(fixture.read(), None); + fixture.source( + "BAT0", + &[ + ("type", "Battery\n"), + ("scope", "System\n"), + ("present", "1\n"), + ("capacity", "27\n"), + ], + ); + assert_eq!(fixture.read(), Some(27)); + fixture.source("BAT0", &[("capacity", "0")]); + assert_eq!(fixture.read(), Some(0)); + fixture.source("BAT0", &[("capacity", "100")]); + assert_eq!(fixture.read(), Some(100)); + for invalid in ["101", "-1", "255", "garbage", ""] { + fixture.source("BAT0", &[("capacity", invalid)]); + assert_eq!(fixture.read(), None); + } + fs::remove_file(fixture.0.join("BAT0/capacity")).unwrap(); + assert_eq!(fixture.read(), None); +} + +#[test] +fn sysfs_uses_matching_actual_energy_or_charge_totals_only() { + let energy = Fixture::new(); + energy.source( + "BAT0", + &[ + ("type", "Battery"), + ("present", "1"), + ("energy_now", "27"), + ("energy_full", "100"), + ], + ); + assert_eq!(energy.read(), Some(27)); + energy.source("BAT0", &[("energy_full", "0")]); + assert_eq!(energy.read(), None); + energy.source("BAT0", &[("energy_full", "26")]); + assert_eq!(energy.read(), None); + energy.source("BAT0", &[("energy_full", "garbage")]); + assert_eq!(energy.read(), None); + let charge = Fixture::new(); + charge.source( + "BAT0", + &[ + ("type", "Battery"), + ("present", "1"), + ("charge_now", "15"), + ("charge_full", "20"), + ], + ); + assert_eq!(charge.read(), Some(75)); + charge.source("BAT0", &[("energy_now", "5")]); + assert_eq!(charge.read(), None); + let design = Fixture::new(); + design.source( + "BAT0", + &[ + ("type", "Battery"), + ("present", "1"), + ("energy_now", "20"), + ("energy_full_design", "100"), + ], + ); + assert_eq!(design.read(), None); +} diff --git a/tests/unit/battery_macos.rs b/tests/unit/battery_macos.rs new file mode 100644 index 0000000..bc9ce8a --- /dev/null +++ b/tests/unit/battery_macos.rs @@ -0,0 +1,32 @@ +unsafe extern "C" { + fn CFNumberCreate(allocator: Object, kind: isize, value: Object) -> Object; +} + +use super::*; + +#[test] +fn native_values_are_type_checked_and_numbers_must_be_exact_and_nonnegative() { + assert!(Owned::new(std::ptr::null()).is_none()); + unsafe { + assert_eq!(number(std::ptr::null()), None); + let text = Owned::string(c"27").unwrap(); + assert_eq!(number(text.0), None); + for value in [-1_i64, 0, 100] { + let object = Owned::new(CFNumberCreate( + std::ptr::null(), + 4, + (&value as *const i64).cast(), + )) + .unwrap(); + assert_eq!(number(object.0), u64::try_from(value).ok()); + } + let fraction = 1.5_f64; + let object = Owned::new(CFNumberCreate( + std::ptr::null(), + 6, + (&fraction as *const f64).cast(), + )) + .unwrap(); + assert_eq!(number(object.0), None); + } +} diff --git a/tests/unit/blame.rs b/tests/unit/blame.rs new file mode 100644 index 0000000..82ea0e6 --- /dev/null +++ b/tests/unit/blame.rs @@ -0,0 +1,368 @@ +use super::*; + +fn command(root: &Path, program: &str, args: &[&str]) -> String { + let mut command = Command::new(program); + if program == "jj" { + command.args([ + "--config=user.name=\"Jj Author\"", + "--config=user.email=\"jj@example.test\"", + ]); + command.args([ + "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", + "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", + ]); + } + let output = command + .args(args) + .current_dir(root) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z") + .env("JJ_CONFIG", "/dev/null") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().into() +} +fn fixture() -> (Temporary, PathBuf, String) { + let temp = Temporary::new().unwrap(); + command(&temp.0, "git", &["init", "--quiet", "--template="]); + command(&temp.0, "git", &["config", "user.name", "Blame Author"]); + command( + &temp.0, + "git", + &["config", "user.email", "blame@example.test"], + ); + let path = temp.0.join("-literal [file].txt"); + fs::write(&path, "first\nsecond\n").unwrap(); + command(&temp.0, "git", &["add", "--", "-literal [file].txt"]); + command( + &temp.0, + "git", + &["commit", "--quiet", "-m", "Original summary"], + ); + let commit = command(&temp.0, "git", &["rev-parse", "HEAD"]); + (temp, path, commit) +} +#[test] +fn committed_line_has_native_author_summary_and_commit() { + let (_temp, path, commit) = fixture(); + let rows = read_file(&path, "first\nsecond\n", &|| false) + .unwrap() + .unwrap(); + assert_eq!(rows.len(), 2); + assert!(Arc::ptr_eq( + rows[0].as_ref().unwrap(), + rows[1].as_ref().unwrap() + )); + assert_eq!( + **rows[1].as_ref().unwrap(), + LineBlame { + commit, + author: "Blame Author".into(), + summary: "Original summary".into(), + uncommitted: false + } + ); +} +#[test] +fn unsaved_changed_line_is_uncommitted_without_metadata() { + let (_temp, path, _) = fixture(); + let rows = read_file(&path, "changed\nsecond\n", &|| false) + .unwrap() + .unwrap(); + assert_eq!( + **rows[0].as_ref().unwrap(), + LineBlame { + commit: String::new(), + author: String::new(), + summary: String::new(), + uncommitted: true + } + ); + assert_eq!(fs::read_to_string(path).unwrap(), "first\nsecond\n"); +} +#[test] +fn inserted_line_preserves_following_original_attribution() { + let (_temp, path, commit) = fixture(); + let contents = "inserted\nfirst\nsecond\n"; + let rows = read_file(&path, contents, &|| false).unwrap().unwrap(); + assert!(rows[0].as_ref().unwrap().uncommitted); + assert_eq!(rows[2].as_ref().unwrap().commit, commit); + assert_eq!(rows.len(), 3); + assert_eq!(read_file(&path, "", &|| false).unwrap(), None); +} +#[test] +fn unsaved_insertions_and_deletions_keep_shifted_commit_attribution() { + let (temp, path, original) = fixture(); + fs::write(&path, "first\nmiddle\nsecond\ntail\n").unwrap(); + command(&temp.0, "git", &["add", "--", "-literal [file].txt"]); + command(&temp.0, "git", &["commit", "--quiet", "-m", "Added lines"]); + let added = command(&temp.0, "git", &["rev-parse", "HEAD"]); + let rows = read_file( + &path, + "unsaved\nfirst\nsecond\nunsaved tail\ntail\n", + &|| false, + ) + .unwrap() + .unwrap(); + assert!(rows[0].as_ref().unwrap().uncommitted); + assert_eq!(rows[1].as_ref().unwrap().commit, original); + assert_eq!(rows[2].as_ref().unwrap().commit, original); + assert!(Arc::ptr_eq( + rows[1].as_ref().unwrap(), + rows[2].as_ref().unwrap() + )); + assert!(rows[3].as_ref().unwrap().uncommitted); + assert!(Arc::ptr_eq( + rows[0].as_ref().unwrap(), + rows[3].as_ref().unwrap() + )); + assert_eq!(rows[4].as_ref().unwrap().commit, added); + assert_eq!(rows[4].as_ref().unwrap().summary, "Added lines"); + assert_eq!( + fs::read_to_string(path).unwrap(), + "first\nmiddle\nsecond\ntail\n" + ); +} +#[test] +fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() { + let temp = Temporary::new().unwrap(); + let path = temp.0.join("file"); + assert_eq!(read_file(&path, "text", &|| false).unwrap(), None); + fs::write(&path, "text").unwrap(); + assert_eq!(read_file(&path, "text", &|| false).unwrap(), None); + command(&temp.0, "git", &["init", "--quiet", "--template="]); + assert_eq!(read_file(&path, "text", &|| false).unwrap(), None); + let (_committed, tracked, _) = fixture(); + let untracked = tracked.parent().unwrap().join("untracked"); + fs::write(&untracked, "text").unwrap(); + assert_eq!(read_file(&untracked, "text", &|| false).unwrap(), None); + let link = temp.0.join("link"); + std::os::unix::fs::symlink(tracked, &link).unwrap(); + assert_eq!( + read_file(&link, "first\nsecond\n", &|| false).unwrap(), + None + ); +} +#[test] +fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() { + let temp = Temporary::new().unwrap(); + command(&temp.0, "jj", &["git", "init", "--no-colocate", "."]); + let path = temp.0.join("file"); + fs::write(&path, "original\n").unwrap(); + assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None); + command( + &temp.0, + "jj", + &[ + "--config=user.name=\"Jj Author\"", + "--config=user.email=\"jj@example.test\"", + "describe", + "-m", + "Jj summary", + ], + ); + command( + &temp.0, + "jj", + &[ + "--config=user.name=\"Jj Author\"", + "--config=user.email=\"jj@example.test\"", + "new", + ], + ); + let parent = command( + &temp.0, + "jj", + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@-", + "-T", + "commit_id", + ], + ); + let operation = command( + &temp.0, + "jj", + &[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id", + ], + ); + let rows = read_file(&path, "original\n", &|| false).unwrap().unwrap(); + let line = rows[0].as_ref().unwrap(); + assert_eq!(line.commit, parent); + assert_eq!(line.author, "Jj Author"); + assert_eq!(line.summary, "Jj summary"); + fs::write(&path, "dirty on disk\n").unwrap(); + let shifted = read_file(&path, "inserted\noriginal\n", &|| false) + .unwrap() + .unwrap(); + assert!(shifted[0].as_ref().unwrap().uncommitted); + assert_eq!(shifted[1].as_ref().unwrap().commit, parent); + assert!( + read_file(&path, "unsaved\n", &|| false).unwrap().unwrap()[0] + .as_ref() + .unwrap() + .uncommitted + ); + assert_eq!( + command( + &temp.0, + "jj", + &[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id" + ] + ), + operation + ); + assert!(!temp.0.join(".git").exists()); + command(&temp.0, "jj", &["new", "root()"]); + fs::write(temp.0.join("other"), "sibling\n").unwrap(); + command(&temp.0, "jj", &["describe", "-m", "Sibling"]); + command(&temp.0, "jj", &["new", &parent, "@"]); + assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None); +} +#[test] +fn incremental_groups_share_metadata_across_ranges_and_repeated_commits() { + let first = "1111111111111111111111111111111111111111"; + let second = "2222222222222222222222222222222222222222"; + let output = format!( + "{first} 5 3 2\nauthor First\nsummary First summary\nfilename file\n\ + {second} 8 2 1\nauthor Second\nsummary Second summary\nfilename file\n\ + {first} 1 1 1\nfilename old-file\n" + ); + let rows = parse(output.as_bytes(), 4).unwrap(); + assert_eq!(rows[0].as_ref().unwrap().commit, first); + assert_eq!(rows[1].as_ref().unwrap().author, "Second"); + assert_eq!(rows[1].as_ref().unwrap().commit, second); + assert!(Arc::ptr_eq( + rows[0].as_ref().unwrap(), + rows[2].as_ref().unwrap() + )); + assert!(Arc::ptr_eq( + rows[2].as_ref().unwrap(), + rows[3].as_ref().unwrap() + )); +} +#[test] +fn malformed_incremental_groups_never_echo_metadata() { + let commit = "1111111111111111111111111111111111111111"; + let metadata = "author secret\nsummary secret\nfilename secret\n"; + let valid = format!("{commit} 1 1 1\n{metadata}"); + for output in [ + "secret metadata".into(), + "\u{fffd}secret metadata".into(), + format!("{commit} 1 2 1\n{metadata}"), + format!("{commit} 1 1 2\n{metadata}"), + format!("{commit} 0 1 1\n{metadata}"), + format!("{commit} 1 1 0\n{metadata}"), + format!("{commit} 1 1 1 extra\n{metadata}"), + format!("{commit} 1 1 1\nauthor secret\nfilename secret\n"), + format!("{commit} 1 1 1\nauthor secret\nauthor secret\nsummary secret\nfilename secret\n"), + format!("{commit} 1 1 1\nauthor secret\nsummary secret\n\tsecret source\n"), + format!("{valid}{commit} 2 1 1\nfilename secret\n"), + format!("{commit} 1 1 1\nauthor secret\nsummary secret\n{commit} 2 1 1\nfilename secret\n"), + format!("{commit} {} 1 2\n{metadata}", usize::MAX), + ] { + assert_eq!( + parse(output.as_bytes(), 1).unwrap_err(), + "Invalid Git blame metadata" + ); + } + assert_eq!( + parse(valid.as_bytes(), 2).unwrap_err(), + "Invalid Git blame metadata" + ); + assert_eq!( + parse(b"\xffsecret metadata", 1).unwrap_err(), + "Invalid Git blame metadata" + ); +} +#[test] +fn cancelled_commands_kill_and_reap_pipe_holding_children() { + let temp = Temporary::new().unwrap(); + let ready = temp.0.join("ready"); + let cancellation = Arc::new(AtomicBool::new(false)); + let stop = Arc::clone(&cancellation); + let gate = ready.clone(); + let worker = thread::spawn(move || { + let mut command = Command::new("sh"); + command + .args([ + "-c", + "sleep 600 & printf ready > \"$1\"; wait", + "vamp-blame-test", + ]) + .arg(gate); + run(command, &[], &|| stop.load(Ordering::Acquire)) + }); + while !ready.exists() && !worker.is_finished() { + thread::yield_now(); + } + assert!(ready.exists()); + cancellation.store(true, Ordering::Release); + assert_eq!(worker.join().unwrap().unwrap_err(), "Blame cancelled"); +} +#[test] +fn worker_keeps_only_latest_request_and_bounds_input() { + let (_temp, path, commit) = fixture(); + let mut worker = BlameWorker::start().unwrap(); + for id in 0..20 { + worker + .request(id, path.clone(), "first\nsecond\n".into()) + .unwrap(); + } + loop { + if let Some((id, result)) = worker.poll() { + assert_eq!(id, 19); + let rows = result.unwrap().unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[1].as_ref().unwrap().commit, commit); + assert!(Arc::ptr_eq( + rows[0].as_ref().unwrap(), + rows[1].as_ref().unwrap() + )); + break; + } + thread::yield_now(); + } + assert_eq!( + worker + .request(20, path, "x".repeat(LIMIT + 1)) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); +} +#[test] +fn subprocess_output_is_bounded() { + let mut command = Command::new("sh"); + command.args(["-c", "dd if=/dev/zero bs=1048576 count=17 2>/dev/null"]); + assert_eq!( + run(command, &[], &|| false).unwrap_err(), + "Blame output exceeds 16 MiB" + ); +} diff --git a/tests/unit/chrome.rs b/tests/unit/chrome.rs new file mode 100644 index 0000000..44c41b8 --- /dev/null +++ b/tests/unit/chrome.rs @@ -0,0 +1,180 @@ +use super::*; + +#[test] +fn popup_partial_options_keep_command_and_search_independent() { + let popup: PopupConfig = serde_json::from_value(serde_json::json!({ + "command": {"anchor": "top_right"} + })) + .unwrap(); + assert_eq!(popup.command.anchor, PopupAnchor::TopRight); + assert_eq!(popup.search.anchor, PopupAnchor::BottomLeft); + let popup: PopupConfig = serde_json::from_value(serde_json::json!({ + "command": {}, "search": {"anchor": "center_left"} + })) + .unwrap(); + assert_eq!(popup.command.anchor, PopupAnchor::BottomLeft); + assert_eq!(popup.search.anchor, PopupAnchor::CenterLeft); +} + +#[test] +fn malformed_popup_options_are_rejected_at_the_boundary() { + for value in [ + serde_json::json!(null), + serde_json::json!([]), + serde_json::json!(7), + serde_json::json!({"selector": {}}), + ] { + assert!( + serde_json::from_value::(value.clone()).is_err(), + "{value}" + ); + } + for options in [ + serde_json::json!(null), + serde_json::json!([]), + serde_json::json!("center"), + serde_json::json!({"anchor": null}), + serde_json::json!({"anchor": 7}), + serde_json::json!({"anchor": true}), + serde_json::json!({"anchor": []}), + serde_json::json!({"anchor": {}}), + serde_json::json!({"anchor": "middle"}), + serde_json::json!({"anchor": "TopLeft"}), + serde_json::json!({"width": 72}), + serde_json::json!({"anchor": "center", "callback": "run"}), + ] { + for mode in ["command", "search"] { + let mut popup = serde_json::json!({}); + popup[mode] = options.clone(); + assert!( + serde_json::from_value::(popup).is_err(), + "{mode}: {options}" + ); + } + } +} + +#[derive(Deserialize)] +struct Lines { + #[serde(deserialize_with = "top")] + top: ChromeLine, + #[serde(deserialize_with = "bottom")] + bottom: ChromeLine, +} + +#[test] +fn omitted_options_inherit_and_explicit_groups_replace_defaults() { + let lines: Lines = serde_json::from_value(serde_json::json!({ + "top": {"right": ["battery"], "tabs": {"title": "T{index}:{path}{modified}"}, "battery": {"padding": 0}}, + "bottom": {"left": [], "center": ["workspace"]} + })).unwrap(); + assert_eq!(lines.top.left, ["tabs"]); + assert_eq!(lines.top.segment("battery").padding, 0); + assert!(lines.bottom.left.is_empty()); + assert_eq!(lines.bottom.right, ["language", "lsp", "position"]); + assert_eq!(lines.bottom.segment("workspace"), &SegmentConfig::default()); +} + +#[test] +fn both_positions_accept_all_builtins_and_custom_styles() { + let names = [ + "tabs", + "mode", + "pending", + "buffer", + "diagnostics", + "path", + "selections", + "count", + "language", + "lsp", + "position", + "battery", + "workspace", + ]; + let options = serde_json::json!({ + "left": names, + "center": ["language"], + "right": ["tabs"], + "tabs": {"title": "{index}:{name}:{path}{modified}", "padding": 0}, + "mode": {"leading_edge": "<", "trailing_edge": ">"}, + "workspace": {"first_leading_edge": ""} + }); + let lines: Lines = serde_json::from_value(serde_json::json!({ + "top": options, + "bottom": options, + })) + .unwrap(); + for line in [&lines.top, &lines.bottom] { + assert_eq!(line.left, names); + assert_eq!(line.center, ["language"]); + assert_eq!(line.right, ["tabs"]); + assert_eq!(line.segment("tabs").padding, 0); + assert_eq!(line.segment("mode").leading_edge, "<"); + assert_eq!( + line.segment("workspace").first_leading_edge.as_deref(), + Some("") + ); + } +} + +#[test] +fn view_placeholder_is_valid_in_every_segment_and_bar() { + for name in [ + "tabs", + "mode", + "pending", + "buffer", + "diagnostics", + "path", + "selections", + "count", + "language", + "lsp", + "position", + "battery", + "workspace", + ] { + let options = serde_json::json!({ + "left": [name], + (name): {"title": "{index}:{view}{name}:{path}{modified}"} + }); + let lines: Lines = serde_json::from_value(serde_json::json!({ + "top": options, + "bottom": options, + })) + .unwrap(); + for line in [&lines.top, &lines.bottom] { + assert_eq!( + line.segment(name).title, + "{index}:{view}{name}:{path}{modified}" + ); + } + } +} + +#[test] +fn malformed_chrome_is_rejected_at_the_boundary() { + for options in [ + serde_json::json!({"left": ["bad-name"]}), + serde_json::json!({"left": null}), + serde_json::json!({"tabs": {"padding": 65}}), + serde_json::json!({"tabs": {"padding": null}}), + serde_json::json!({"tabs": {"margin": 65}}), + serde_json::json!({"tabs": {"margin": -1}}), + serde_json::json!({"tabs": {"margin": null}}), + serde_json::json!({"tabs": {"margin": "1"}}), + serde_json::json!({"tabs": {"margin": 1.5}}), + serde_json::json!({"tabs": {"leading_edge": "\n"}}), + serde_json::json!({"tabs": {"title": "{unknown}"}}), + serde_json::json!({"tabs": {"title": "{name"}}), + serde_json::json!({"tabs": {"title": "name}"}}), + serde_json::json!({"tabs": {"colour": "red"}}), + ] { + for position in ["top", "bottom"] { + let mut frame = serde_json::json!({"top": {}, "bottom": {}}); + frame[position] = options.clone(); + assert!(serde_json::from_value::(frame).is_err()); + } + } +} diff --git a/tests/unit/cli_args.rs b/tests/unit/cli_args.rs new file mode 100644 index 0000000..0e0374c --- /dev/null +++ b/tests/unit/cli_args.rs @@ -0,0 +1,46 @@ +use super::*; + +fn parse(args: &[&str]) -> Result, String> { + parse_options(args.iter().map(std::ffi::OsString::from)) +} + +#[test] +fn review_commands_preserve_filenames_and_validate_arguments() { + assert_eq!( + parse(&["review"]).unwrap().unwrap().file, + Some("review".into()) + ); + assert_eq!( + parse(&["--", "--review"]).unwrap().unwrap().file, + Some("--review".into()) + ); + assert!( + parse(&["--no-config", "--review", "list", "--repo", ".", "--json"]) + .unwrap() + .unwrap() + .review + .is_some() + ); + assert!( + parse(&["--review", "show", "1", "--all", "--no-config"]) + .unwrap() + .unwrap() + .review + .is_some() + ); + for args in [ + vec!["--review"], + vec!["--review", "show"], + vec!["--review", "show", "0"], + vec!["--review", "show", "-1"], + vec!["--review", "show", "abc"], + vec!["--review", "list", "--all"], + vec!["--review", "list", "--repo"], + vec!["--review", "list", "--repo", ".", "--repo", "."], + vec!["--review", "list", "extra"], + vec!["--review", "list", "--", "extra"], + vec!["--review", "list", "--config", "x", "--no-config"], + ] { + assert!(parse(&args).is_err(), "{args:?}"); + } +} diff --git a/tests/unit/clipboard.rs b/tests/unit/clipboard.rs new file mode 100644 index 0000000..428a4fb --- /dev/null +++ b/tests/unit/clipboard.rs @@ -0,0 +1,192 @@ +use super::*; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::sync::atomic::{AtomicU64, Ordering}; + +static FIXTURE_ID: AtomicU64 = AtomicU64::new(0); + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "vamp-clipboard-{}-{}", + std::process::id(), + FIXTURE_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + + fn command(&self, name: &str, body: &str) -> PathBuf { + let path = self.0.join(name); + fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + path + } + + fn clipboard(&self, text: &[u8]) -> SystemClipboard { + let state = self.0.join("state"); + fs::write(&state, text).unwrap(); + let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''")); + SystemClipboard::with_commands( + self.command("read", &format!("exec /bin/cat {state}")), + self.command("write", &format!("exec /bin/cat > {state}")), + ) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} + +#[test] +fn construction_does_not_probe_or_spawn() { + assert!(SystemClipboard::new().commands.is_none()); +} + +#[test] +fn round_trips_unicode_whitespace_and_large_pipes_exactly() { + let fixture = Fixture::new(); + let mut clipboard = fixture.clipboard(b""); + for text in ["", " héllo\t世界🙂\r\n\n", &"é\n".repeat(300_000)] { + clipboard.write(text).unwrap(); + assert_eq!(fs::read(fixture.0.join("state")).unwrap(), text.as_bytes()); + assert_eq!(clipboard.read().unwrap(), text); + } +} + +#[test] +fn rejects_oversize_and_invalid_utf8_without_leaking_contents() { + let fixture = Fixture::new(); + let mut clipboard = fixture.clipboard(&[0xff, b'\n']); + assert_eq!( + clipboard.read().unwrap_err(), + "System clipboard is not valid UTF-8" + ); + let maximum = "é".repeat(MAX_CLIPBOARD_BYTES / 2); + clipboard.write(&maximum).unwrap(); + assert_eq!(clipboard.read().unwrap(), maximum); + let too_large = format!("{maximum}x"); + assert_eq!( + clipboard.write(&too_large).unwrap_err(), + "System clipboard text exceeds 4 MiB" + ); + assert_eq!( + fs::read(fixture.0.join("state")).unwrap(), + maximum.as_bytes() + ); + fs::write(fixture.0.join("state"), &too_large).unwrap(); + assert_eq!( + clipboard.read().unwrap_err(), + "System clipboard text exceeds 4 MiB" + ); + let mut incomplete = vec![b'x'; MAX_CLIPBOARD_BYTES - 1]; + incomplete.push(0xc3); + fs::write(fixture.0.join("state"), incomplete).unwrap(); + assert_eq!( + clipboard.read().unwrap_err(), + "System clipboard is not valid UTF-8" + ); +} + +#[test] +fn command_failures_and_stderr_cannot_leak_clipboard_text() { + let fixture = Fixture::new(); + let failed = fixture.command( + "failed", + "printf 'private clipboard payload' >&2\nprintf 'private clipboard payload'\nexit 23", + ); + let mut clipboard = SystemClipboard::with_commands(failed.clone(), failed); + let error = clipboard.read().unwrap_err(); + assert!(error.contains("failed")); + assert!(!error.contains("private clipboard payload")); + let error = clipboard.write("private clipboard payload").unwrap_err(); + assert!(!error.contains("private clipboard payload")); + let missing = fixture.0.join("not-an-executable"); + let mut clipboard = SystemClipboard::with_commands(missing.clone(), missing); + assert!(clipboard.read().unwrap_err().contains("Cannot start")); + assert!( + clipboard + .write("secret") + .unwrap_err() + .contains("Cannot start") + ); +} + +#[test] +fn ignored_stderr_does_not_block_large_writes() { + let fixture = Fixture::new(); + let mut clipboard = fixture.clipboard(b""); + let state = fixture.0.join("state"); + let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''")); + clipboard + .commands + .as_mut() + .unwrap() + .as_mut() + .unwrap() + .writer + .as_mut() + .unwrap() + .program = fixture.command( + "noisy", + &format!("/bin/dd if=/dev/zero bs=65536 count=8 >&2\nexec /bin/cat > {state}"), + ); + let text = "λ\n".repeat(300_000); + clipboard.write(&text).unwrap(); + assert_eq!(clipboard.read().unwrap(), text); +} + +#[test] +fn failed_process_cleanup_kills_and_reaps_a_running_child() { + use std::io::Read; + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + + unsafe extern "C" { + fn waitpid(pid: i32, status: *mut i32, options: i32) -> i32; + } + + let fixture = Fixture::new(); + let hung = fixture.command("running", "printf R\nwhile :; do :; done"); + let mut command = Command::new(hung); + command + .process_group(0) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = command.spawn().unwrap(); + let pid = child.id() as i32; + let mut ready = [0]; + child + .stdout + .as_mut() + .unwrap() + .read_exact(&mut ready) + .unwrap(); + assert_eq!(ready, [b'R']); + drop(ClipboardProcess(Some(child))); + let mut status = 0; + // SAFETY: waitpid's output pointer is valid; WNOHANG cannot block this check. + assert_eq!(unsafe { waitpid(pid, &mut status, 1) }, -1); + assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(10)); +} + +#[test] +fn immediate_injected_timeout_terminates_nonterminating_commands() { + let fixture = Fixture::new(); + let hung = fixture.command("hung", "while :; do :; done"); + let mut clipboard = SystemClipboard::with_commands(hung.clone(), hung); + // No assertion depends on wall-clock speed or a deadline remaining in the future. + clipboard.timeout = Duration::ZERO; + assert_eq!( + clipboard.read().unwrap_err(), + "System clipboard command timed out" + ); + assert_eq!( + clipboard.write("text").unwrap_err(), + "System clipboard command timed out" + ); +} diff --git a/tests/unit/clipboard_macos.rs b/tests/unit/clipboard_macos.rs new file mode 100644 index 0000000..2734778 --- /dev/null +++ b/tests/unit/clipboard_macos.rs @@ -0,0 +1,81 @@ +use super::*; +unsafe extern "C" { + fn PasteboardSynchronize(pasteboard: Object) -> u32; + fn PasteboardGetItemIdentifier(pasteboard: Object, index: isize, item: *mut usize) -> i32; + fn PasteboardCopyItemFlavorData( + pasteboard: Object, + item: usize, + flavor: Object, + data: *mut Object, + ) -> i32; + fn CFDataGetBytePtr(data: Object) -> *const u8; + fn CFDataGetLength(data: Object) -> isize; +} +#[test] +fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() { + // A private pasteboard leaves the user's clipboard untouched. + let _pool = unsafe { AutoreleasePool(objc_autoreleasePoolPush()) }; + let name = unsafe { + Owned::new(CFStringCreateWithCString( + ptr::null(), + c"vamp-native-clipboard-regression".as_ptr(), + 0x0800_0100, + )) + .unwrap() + }; + let board = unsafe { + let mut board = ptr::null(); + check(PasteboardCreate(name.0, &mut board)).unwrap(); + Owned::new(board).unwrap() + }; + let reader = unsafe { + let send: unsafe extern "C" fn(Object, Object, Object) -> Object = + std::mem::transmute(objc_msgSend as unsafe extern "C" fn()); + send( + objc_getClass(c"NSPasteboard".as_ptr()), + sel_registerName(c"pasteboardWithName:".as_ptr()), + name.0, + ) + }; + for text in [ + "", + "é e\u{301} 👨‍💻\n\0tail", + "{\\rtf1 plain text}", + "%!PS-Adobe-3.0\nplain", + ] { + write_to(name.0, text).unwrap(); + assert_eq!(read_from(reader).unwrap(), text); + unsafe { + PasteboardSynchronize(board.0); + let mut item = 0; + check(PasteboardGetItemIdentifier(board.0, 1, &mut item)).unwrap(); + let flavor = Owned::new(CFStringCreateWithCString( + ptr::null(), + c"public.utf8-plain-text".as_ptr(), + 0x0800_0100, + )) + .unwrap(); + let mut data = ptr::null(); + check(PasteboardCopyItemFlavorData( + board.0, item, flavor.0, &mut data, + )) + .unwrap(); + let data = Owned::new(data).unwrap(); + let length = CFDataGetLength(data.0) as usize; + let bytes = if length == 0 { + &[] + } else { + std::slice::from_raw_parts(CFDataGetBytePtr(data.0), length) + }; + assert_eq!(bytes, text.as_bytes()); + } + } + let maximum = "x".repeat(super::super::MAX_CLIPBOARD_BYTES); + write_to(name.0, &maximum).unwrap(); + assert_eq!(read_from(reader).unwrap(), maximum); + write_to(name.0, &(maximum + "x")).unwrap(); + assert_eq!( + read_from(reader).unwrap_err(), + "System clipboard text exceeds 4 MiB" + ); +} diff --git a/tests/unit/diff.rs b/tests/unit/diff.rs new file mode 100644 index 0000000..d8cbc8f --- /dev/null +++ b/tests/unit/diff.rs @@ -0,0 +1,2561 @@ +use super::*; +use crate::editor::{Editor, Effect, Mode}; +std::thread_local! { + pub(super) static WIDTH_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[test] +fn resolver_equal_text_retains_native_rows_and_cached_viewport() { + for text in ["", "\n", "same\n", "same"] { + let lines: Vec<_> = text.split('\n').map(str::to_owned).collect(); + let mut state = DiffState::new(); + state.set_resolver_text(&lines, &lines); + assert_eq!(state.pane_layout(), PaneLayout::Split); + assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); + assert_eq!( + state.comparison.as_ref().unwrap().files[0].path, + Path::new("resolution.txt") + ); + assert_eq!(state.files.as_ref().unwrap().entries().len(), 1); + for row in 0..lines.len() { + assert!(state.source_display_row(Focus::Before, row).is_some()); + assert!(state.source_display_row(Focus::After, row).is_some()); + } + let id = state.comparison_id; + state.top = 1; + state.left_before = 3; + state.left_after = 4; + state.set_resolver_text(&lines, &lines); + assert_eq!(state.comparison_id, id); + assert_eq!((state.top, state.left_before, state.left_after), (1, 3, 4)); + } +} + +#[test] +fn resolver_catalog_reveals_the_current_native_file() { + let mut state = DiffState::new(); + state.set_resolver_paths( + PathBuf::new(), + vec!["binary.bin".into(), "first.txt".into(), "second.txt".into()], + 1, + ); + let files = state.files.as_ref().unwrap(); + assert_eq!( + files.entries().nth(files.selected).unwrap().path, + Path::new("first.txt") + ); + assert!(!files.is_conflicted(Path::new("first.txt"))); + state + .files + .as_mut() + .unwrap() + .set_conflicted(Path::new("first.txt"), true); + state.set_resolver_paths( + PathBuf::new(), + vec!["binary.bin".into(), "first.txt".into(), "second.txt".into()], + 2, + ); + let files = state.files.as_ref().unwrap(); + assert_eq!( + files.entries().nth(files.selected).unwrap().path, + Path::new("second.txt") + ); + assert!(files.is_conflicted(Path::new("first.txt"))); + assert!(!files.is_conflicted(Path::new("second.txt"))); +} + +fn excerpt_state(len: usize, changed: &[usize]) -> DiffState { + let mut state = DiffState::new(); + state.phase = Phase::View; + state.focus = Focus::Before; + state.rows = (0..len) + .map(|index| AlignedRow { + before: Some(index), + after: Some(index + 3), + kind: if changed.contains(&index) { + RowKind::Changed + } else { + RowKind::Equal + }, + changes: Vec::new(), + }) + .collect(); + state.reset_excerpts(); + state +} + +#[test] +fn excerpts_cover_context_and_hidden_rows_once_without_reindexing() { + let state = excerpt_state(100, &[30, 70]); + assert_eq!(state.excerpt_ranges, [(20, 41), (60, 81)]); + assert_eq!( + state.display_rows[0], + DisplayRow::Fold { start: 0, end: 20 } + ); + assert_eq!( + state.display_rows[22], + DisplayRow::Fold { start: 41, end: 60 } + ); + assert_eq!( + state.display_rows[44], + DisplayRow::Fold { + start: 81, + end: 100 + } + ); + let mut covered = Vec::new(); + for row in &state.display_rows { + match *row { + DisplayRow::Line(index) => { + covered.push(index); + assert_eq!(state.rows[index].before, Some(index)); + assert_eq!(state.rows[index].after, Some(index + 3)); + } + DisplayRow::Fold { start, end } => { + assert!( + state.rows[start..end] + .iter() + .all(|row| row.kind == RowKind::Equal) + ); + covered.extend(start..end); + } + } + } + assert_eq!(covered, (0..100).collect::>()); + for index in [30, 70] { + assert_eq!( + state + .display_rows + .iter() + .filter(|&&row| row == DisplayRow::Line(index)) + .count(), + 1 + ); + } +} + +#[test] +fn zero_context_boundaries_adjacent_changes_and_touching_ranges() { + let mut state = excerpt_state(60, &[0, 1, 30, 59]); + state.set_context_options(0, 15); + assert_eq!(state.excerpt_ranges, [(0, 2), (30, 31), (59, 60)]); + assert_eq!( + state.display_rows, + [ + DisplayRow::Line(0), + DisplayRow::Line(1), + DisplayRow::Fold { start: 2, end: 30 }, + DisplayRow::Line(30), + DisplayRow::Fold { start: 31, end: 59 }, + DisplayRow::Line(59), + ] + ); + assert_eq!(excerpt_state(60, &[20, 41]).excerpt_ranges, [(10, 52)]); + assert_eq!( + excerpt_state(3, &[0, 1, 2]).display_rows, + [ + DisplayRow::Line(0), + DisplayRow::Line(1), + DisplayRow::Line(2), + ] + ); +} + +#[test] +fn expansion_anchors_source_rows_merges_and_clamps_both_directions() { + let mut state = excerpt_state(100, &[30, 70]); + state.top = 11; + state.handle(Input::Char('[')); + assert_eq!(state.excerpt_ranges, [(5, 41), (60, 81)]); + assert_eq!(state.display_rows[state.top], DisplayRow::Line(30)); + state.handle(Input::Char('[')); + assert_eq!(state.excerpt_ranges, [(0, 41), (60, 81)]); + state.handle(Input::Char(']')); + assert_eq!(state.excerpt_ranges, [(0, 56), (60, 81)]); + state.handle(Input::Char(']')); + assert_eq!(state.excerpt_ranges, [(0, 81)]); + assert_eq!(state.display_rows[state.top], DisplayRow::Line(30)); + state.handle(Input::Char(']')); + assert_eq!(state.excerpt_ranges, [(0, 96)]); + state.handle(Input::Char(']')); + state.handle(Input::Char(']')); + assert_eq!(state.excerpt_ranges, [(0, 100)]); + assert_eq!(state.display_rows[state.top], DisplayRow::Line(30)); +} + +#[test] +fn folds_choose_following_excerpt_or_previous_at_end() { + let mut state = excerpt_state(100, &[30, 70]); + state.handle(Input::Char('[')); + assert_eq!(state.display_rows[state.top], DisplayRow::Line(5)); + state.top = state + .display_rows + .iter() + .position(|&row| row == (DisplayRow::Fold { start: 41, end: 60 })) + .unwrap(); + state.handle(Input::Char('[')); + assert_eq!(state.excerpt_ranges, [(5, 41), (45, 81)]); + assert_eq!(state.display_rows[state.top], DisplayRow::Line(45)); + state.handle(Input::End); + state.handle(Input::Char(']')); + assert_eq!(state.excerpt_ranges, [(5, 41), (45, 96)]); + assert_eq!(state.display_rows[state.top], DisplayRow::Line(81)); +} + +#[test] +fn navigation_uses_display_length_and_custom_increment() { + let mut state = excerpt_state(100, &[30]); + state.handle(Input::End); + state.handle(Input::Down); + assert_eq!(state.top, 22); + state.handle(Input::Up); + assert_eq!(state.top, 21); + state.handle(Input::Home); + state.handle(Input::Up); + assert_eq!(state.top, 0); + state.set_context_options(0, 2); + state.handle(Input::Char('[')); + assert_eq!(state.excerpt_ranges, [(28, 31)]); + assert_eq!(state.display_rows[state.top], DisplayRow::Line(28)); + state.handle(Input::Char(']')); + assert_eq!(state.excerpt_ranges, [(28, 33)]); +} + +#[test] +fn metadata_empty_and_wholly_added_removed_files_preserve_alignment() { + let mut state = DiffState::new(); + let mut comparison = comparison(); + comparison.files[0].hunks.clear(); + comparison.files[0].after = comparison.files[0].before.clone(); + state.comparison = Some(comparison); + state.align_file(); + assert!(state.excerpt_ranges.is_empty()); + assert!(state.display_rows.is_empty()); + assert_eq!(state.rows.len(), 5); + state.file = 1; + state.align_file(); + assert_eq!( + state.display_rows, + [DisplayRow::Line(0), DisplayRow::Line(1)] + ); + assert!( + state + .rows + .iter() + .all(|row| row.kind == RowKind::Added && row.before.is_none()) + ); + let file = &mut state.comparison.as_mut().unwrap().files[1]; + std::mem::swap(&mut file.before, &mut file.after); + file.hunks[0].before_len = 2; + file.hunks[0].after_len = 0; + state.align_file(); + assert_eq!( + state.display_rows, + [DisplayRow::Line(0), DisplayRow::Line(1)] + ); + assert!( + state + .rows + .iter() + .all(|row| row.kind == RowKind::Removed && row.after.is_none()) + ); + state.file = 2; + state.align_file(); + assert!(state.rows.is_empty()); + assert!(state.display_rows.is_empty()); + assert!(state.comparison.as_ref().unwrap().files[2].notice.is_some()); +} + +fn revision( + target: RevisionTarget, + label: &str, + description: &str, + category: Category, +) -> Revision { + Revision { + target, + label: label.into(), + description: description.into(), + category, + } +} + +fn catalog() -> Catalog { + Catalog { + kind: RepoKind::Git, + entries: vec![ + revision( + RevisionTarget::Working, + "Working checkout", + "", + Category::Commits, + ), + revision( + RevisionTarget::Commit("abcdef123".into()), + "abcdef123", + "Fix 界 e\u{301}", + Category::Commits, + ), + revision( + RevisionTarget::Commit("fedcba321".into()), + "fedcba321", + "Another change", + Category::Commits, + ), + revision( + RevisionTarget::FetchRemote("origin".into()), + "Load history from origin", + "", + Category::Commits, + ), + revision( + RevisionTarget::Commit("branch-tip".into()), + "topic/branch", + "", + Category::Branches, + ), + revision( + RevisionTarget::Remote { + remote: "origin".into(), + reference: "refs/heads/topic".into(), + }, + "topic@origin", + "", + Category::Branches, + ), + revision( + RevisionTarget::Commit("tag-tip".into()), + "release", + "", + Category::Tags, + ), + ], + notice: None, + } +} + +fn pull_page(closed: bool, rows: &[(u64, &str, &str)], has_more: bool) -> Outcome { + Outcome::PullRequestPage { + closed, + result: Ok(PullRequestPage { + kind: RepoKind::Git, + remote: "origin".into(), + has_more, + entries: rows + .iter() + .map(|&(number, title, date)| { + ( + revision( + RevisionTarget::PullRequest { + remote: "origin".into(), + number, + }, + &format!("#{number} {title}"), + "Alice · main ← topic", + if closed { + Category::Closed + } else { + Category::Open + }, + ), + date.into(), + ) + }) + .collect(), + }), + } +} + +#[test] +fn pull_request_categories_keep_query_and_select_loaded_rows_before_completion() { + let mut state = DiffState::pull_requests("origin".into()); + assert_eq!( + state.categories(), + [Category::Open, Category::Closed, Category::All] + ); + state.take_request(); + state.handle(Input::Char('\t')); + for ch in "FIX üni Alice".chars() { + state.handle(Input::Char(ch)); + } + state.apply_result( + 1, + Ok(pull_page( + true, + &[(2, "Fix Ünicode", "2026-01-02T00:00:00Z")], + true, + )), + ); + assert!(state.loading); + assert!(state.pull_request_partial()); + assert_eq!(state.entries().next().unwrap().label, "#2 Fix Ünicode"); + state.handle(Input::Char('\t')); + state.apply_result( + 1, + Ok(pull_page( + false, + &[ + (1, "Fix Ünicode", "2026-01-03T00:00:00Z"), + (3, "Other work", "2026-01-01T00:00:00Z"), + ], + true, + )), + ); + assert_eq!( + state + .entries() + .map(|entry| entry.label.as_str()) + .collect::>(), + ["#1 Fix Ünicode", "#2 Fix Ünicode"] + ); + state.handle(Input::Char('\t')); + assert_eq!(state.query, "FIX üni Alice"); + state.comparison_mode = ComparisonMode::Snapshots; + state.handle(Input::Enter); + assert_eq!(state.phase, Phase::After); + assert!(state.before.is_none() && state.after.is_none()); + assert_eq!(state.comparison_mode, ComparisonMode::BranchChanges); + assert!(matches!( + state.take_request(), + Some(Operation::ResolvePullRequest(Revision { + target: RevisionTarget::PullRequest { number: 1, .. }, + .. + })) + )); +} + +#[test] +fn pull_request_all_streams_sort_deduplicate_and_preserve_selection_identity() { + let mut state = DiffState::pull_requests("origin".into()); + state.take_request(); + state.handle(Input::BackTab); + state.apply_result( + 1, + Ok(pull_page( + false, + &[ + (1, "First", "2026-01-01T00:00:00Z"), + (2, "Second", "2026-01-02T00:00:00Z"), + ], + true, + )), + ); + state.handle(Input::End); + assert_eq!( + state.entries().nth(state.selected).unwrap().label, + "#1 First" + ); + state.apply_result(1, Ok(pull_page(true, &[], true))); + assert!(state.pull_request_partial()); + state.apply_result( + 1, + Ok(pull_page( + true, + &[(3, "Newest", "2026-01-03T00:00:00Z")], + true, + )), + ); + assert_eq!( + state.entries().nth(state.selected).unwrap().label, + "#1 First" + ); + state.apply_result( + 1, + Ok(pull_page( + false, + &[(2, "Second updated", "2026-01-02T00:00:00Z")], + false, + )), + ); + assert_eq!(state.entries().len(), 3); + assert_eq!( + state + .entries() + .map(|entry| entry.label.as_str()) + .collect::>(), + ["#3 Newest", "#2 Second updated", "#1 First"] + ); + assert!(state.pull_request_partial()); + state.apply_result(1, Ok(pull_page(true, &[], false))); + assert!(!state.pull_request_partial()); + assert!(!state.loading); + assert_eq!( + state.entries().nth(state.selected).unwrap().label, + "#1 First" + ); +} + +#[test] +fn pull_request_late_category_errors_retry_without_losing_rows_or_other_results() { + let mut editor = editor(); + editor.execute("pull_requests origin"); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result( + id, + Ok(pull_page( + false, + &[(1, "Open", "2026-01-01T00:00:00Z")], + true, + )), + ); + editor.apply_diff_result( + id, + Ok(pull_page( + true, + &[(2, "Closed", "2026-01-02T00:00:00Z")], + true, + )), + ); + editor.handle(Input::Char('\t')); + editor.apply_diff_result( + id, + Ok(Outcome::PullRequestPage { + closed: true, + result: Err("offline".into()), + }), + ); + let diff = editor.diff().unwrap(); + assert!(diff.pull_request_notice().unwrap().contains("offline")); + assert_eq!(diff.entries().next().unwrap().label, "#2 Closed"); + assert!(diff.pull_request_partial()); + editor.handle(Input::Ctrl('r')); + assert_eq!(editor.diff().unwrap().pull_request_retries, [0, 1]); + assert!(editor.take_diff_request().is_none()); + editor.apply_diff_result( + id, + Ok(pull_page( + false, + &[(3, "More open", "2026-01-03T00:00:00Z")], + false, + )), + ); + assert_eq!( + editor.diff().unwrap().entries().next().unwrap().label, + "#2 Closed" + ); + editor.apply_diff_result(id, Ok(pull_page(true, &[], false))); + assert!(!editor.diff().unwrap().pull_request_partial()); + editor.handle(Input::Enter); + let (_, operation) = editor.take_diff_request().unwrap(); + assert!(matches!(operation, Operation::ResolvePullRequest(_))); + assert!(!editor.apply_diff_result(id, Ok(pull_page(false, &[], false)))); +} + +#[test] +fn pull_request_loading_retry_escape_and_reopen_reject_stale_results() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("unsaved source".into())]) + .unwrap(); + editor.execute("pull_requests origin"); + let (first, _) = editor.take_diff_request().unwrap(); + editor.handle(Input::Enter); + assert!(editor.take_diff_request().is_none()); + text(&mut editor, "alice"); + editor.apply_diff_result( + first, + Ok(Outcome::PullRequestPage { + closed: false, + result: Err("API unavailable".into()), + }), + ); + editor.handle(Input::Enter); + assert_eq!(editor.diff().unwrap().pull_request_retries, [1, 0]); + assert!(editor.take_diff_request().is_none()); + assert!(editor.apply_diff_result( + first, + Ok(pull_page( + false, + &[ + (1, "Open", "2026-01-02T00:00:00Z"), + (2, "Other open", "2026-01-01T00:00:00Z"), + ], + true + )) + )); + editor.handle(Input::Enter); + let (resolve, operation) = editor.take_diff_request().unwrap(); + assert!(matches!(operation, Operation::ResolvePullRequest(_))); + assert!(!editor.apply_diff_result(first, Ok(pull_page(true, &[], false)))); + editor.apply_diff_result(resolve, Err("Historical head no longer retained".into())); + assert!(editor.diff().unwrap().before.is_none()); + assert!(editor.diff().unwrap().after.is_none()); + editor.handle(Input::Down); + editor.handle(Input::Enter); + let (retry, operation) = editor.take_diff_request().unwrap(); + assert!(matches!( + operation, + Operation::ResolvePullRequest(Revision { + target: RevisionTarget::PullRequest { number: 2, .. }, + .. + }) + )); + editor.handle(Input::Escape); + assert!(editor.diff().is_none()); + assert!(!editor.apply_diff_result(retry, Ok(Outcome::Comparison(comparison())))); + assert_eq!(editor.lines(), ["unsaved source"]); + assert!(editor.dirty()); + editor.execute("pull_requests"); + let (reopened, _) = editor.take_diff_request().unwrap(); + assert!(!editor.apply_diff_result(first, Ok(pull_page(false, &[], false)))); + assert!(editor.apply_diff_result(reopened, Ok(pull_page(false, &[], false)))); + assert!(editor.apply_diff_result(reopened, Ok(pull_page(true, &[], false)))); + editor.handle(Input::Enter); + assert!(editor.take_diff_request().is_none()); +} + +#[test] +fn resolved_pull_request_modes_reuse_pinned_endpoints_without_resolving_again() { + let mut state = DiffState::pull_requests("origin".into()); + state.take_request(); + state.apply_result( + 1, + Ok(pull_page( + false, + &[(1, "Open", "2026-01-01T00:00:00Z")], + true, + )), + ); + state.handle(Input::Enter); + state.take_request(); + let before = revision( + RevisionTarget::RemoteCommit { + remote: "origin".into(), + reference: "refs/heads/main".into(), + id: "base-sha".into(), + }, + "main", + "", + Category::Open, + ); + let after = revision( + RevisionTarget::RemoteCommit { + remote: "origin".into(), + reference: "refs/pull/1/head".into(), + id: "head-sha".into(), + }, + "#1 Open", + "", + Category::Open, + ); + state.apply_result( + 2, + Ok(Outcome::ResolvedPullRequest { + before: before.clone(), + after: after.clone(), + }), + ); + assert!(matches!( + state.take_request(), + Some(Operation::Compare { + mode: ComparisonMode::BranchChanges, + .. + }) + )); + state.apply_result(3, Err("No common ancestor".into())); + assert_eq!(state.before.as_ref(), Some(&before)); + assert_eq!(state.after.as_ref(), Some(&after)); + state.toggle_comparison_mode().unwrap(); + let Some(Operation::Compare { + before: pinned_before, + after: pinned_after, + mode, + .. + }) = state.take_request() + else { + panic!("expected ordinary pinned comparison"); + }; + assert_eq!( + (pinned_before, pinned_after, mode), + (before, after, ComparisonMode::Snapshots) + ); +} + +fn editor() -> Editor { + Editor::open(None, vec![]).unwrap() +} + +fn open(editor: &mut Editor) { + assert!(editor.execute("diff").is_none()); + let (id, operation) = editor.take_diff_request().unwrap(); + assert!(matches!(operation, Operation::Discover)); + assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog())))); +} + +fn text(editor: &mut Editor, text: &str) { + for ch in text.chars() { + editor.handle(Input::Char(ch)); + } +} + +fn choose_pair(editor: &mut Editor) -> u64 { + editor.handle(Input::Down); + editor.handle(Input::Enter); + assert_eq!(editor.diff().unwrap().phase, Phase::After); + editor.handle(Input::Enter); + let (id, operation) = editor.take_diff_request().unwrap(); + match operation { + Operation::Compare { before, after, .. } => { + assert_eq!(before.target, RevisionTarget::Commit("abcdef123".into())); + assert_eq!(after.target, RevisionTarget::Working); + } + _ => panic!("expected compare"), + } + id +} + +fn comparison() -> Comparison { + Comparison { + revisions: ["abcdef123".into(), "working:snapshot".into()], + before: "abcdef123".into(), + after: "Working checkout".into(), + root: PathBuf::new(), + editable_paths: BTreeSet::new(), + files: vec![ + ChangedFile { + path: "renamed.txt".into(), + old_path: Some("old.txt".into()), + status: FileStatus::Renamed, + loaded: true, + before: vec![ + "same".into(), + "old".into(), + "tail".into(), + "deleted".into(), + "end".into(), + ], + after: vec![ + "same".into(), + "new".into(), + "extra".into(), + "tail".into(), + "end".into(), + ], + before_eof: false, + after_eof: false, + hunks: vec![ + Hunk { + before_start: 1, + before_len: 1, + after_start: 1, + after_len: 2, + }, + Hunk { + before_start: 3, + before_len: 1, + after_start: 4, + after_len: 0, + }, + ], + notice: None, + }, + ChangedFile { + path: "added.txt".into(), + old_path: None, + status: FileStatus::Added, + loaded: true, + before: vec![], + after: vec!["a".into(), "b".into()], + before_eof: false, + after_eof: false, + hunks: vec![Hunk { + before_start: 0, + before_len: 0, + after_start: 0, + after_len: 2, + }], + notice: None, + }, + ChangedFile { + path: "removed.bin".into(), + old_path: None, + status: FileStatus::Removed, + loaded: true, + before: vec![], + after: vec![], + before_eof: false, + after_eof: false, + hunks: vec![], + notice: Some("Binary file".into()), + }, + ], + } +} + +#[test] +fn status_layout_cycles_only_present_panes_and_normalizes_selection() { + let mut state = live_state(vec!["old".into()], vec!["new".into()], true); + state.comparison = Some(comparison()); + state.files = Some(FilePicker::from_paths( + PathBuf::new(), + [ + (0, "renamed.txt".into()), + (1, "added.txt".into()), + (2, "removed.bin".into()), + ], + )); + for (path, layout, first, last) in [ + ( + "renamed.txt", + PaneLayout::Split, + Focus::Before, + Focus::After, + ), + ("added.txt", PaneLayout::After, Focus::After, Focus::After), + ( + "removed.bin", + PaneLayout::Before, + Focus::Before, + Focus::Before, + ), + ] { + state.files_visible = true; + state.focus = Focus::Files; + assert!(state.select_file(Path::new(path))); + assert_eq!(state.pane_layout(), layout); + state.handle(Input::Char('\t')); + assert_eq!(state.focus, first); + state.handle(Input::BackTab); + assert_eq!(state.focus, Focus::Files); + state.handle(Input::BackTab); + assert_eq!(state.focus, last); + state.handle(Input::Char('\t')); + assert_eq!(state.focus, Focus::Files); + state.files_visible = false; + state.normalize_focus(); + assert_eq!(state.focus, first); + state.handle(Input::BackTab); + assert_eq!(state.focus, last); + state.handle(Input::Char('\t')); + assert_eq!(state.focus, first); + } + state.focus = Focus::Before; + assert!(state.select_file(Path::new("added.txt"))); + assert_eq!(state.focus, Focus::After); + assert!(state.select_file(Path::new("removed.bin"))); + assert_eq!(state.focus, Focus::Before); + assert!(state.select_file(Path::new("renamed.txt"))); + assert_eq!(state.focus, Focus::Before); +} + +#[test] +fn rename_layout_and_editability_include_eof_changes() { + let mut state = live_state(vec!["same".into()], vec!["same".into()], true); + state.comparison.as_mut().unwrap().files[0].status = FileStatus::Renamed; + assert_eq!(state.pane_layout(), PaneLayout::Empty); + assert!(!state.after_editable()); + state.normalize_focus(); + assert_eq!(state.focus, Focus::Before); + state.files_visible = false; + for input in [Input::Char('\t'), Input::BackTab] { + state.handle(input); + assert_eq!(state.focus, Focus::Before); + } + state.comparison.as_mut().unwrap().files[0].after_eof = false; + assert_eq!(state.pane_layout(), PaneLayout::Split); + assert!(state.after_editable()); + state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into(); + assert!(state.after_editable()); + state.comparison.as_mut().unwrap().files[0].status = FileStatus::Removed; + assert!(!state.after_editable()); + state.comparison.as_mut().unwrap().files[0].status = FileStatus::Added; + assert!(state.after_editable()); + let file = &mut state.comparison.as_mut().unwrap().files[0]; + file.before.clear(); + file.after.clear(); + file.notice = Some("empty file".into()); + state.normalize_focus(); + assert_eq!(state.pane_layout(), PaneLayout::After); + assert_eq!(state.focus, Focus::After); + assert!(state.after_editable()); + assert_eq!( + state.comparison.as_ref().unwrap().files[0] + .notice + .as_deref(), + Some("empty file") + ); +} + +#[test] +fn opening_diff_file_focuses_after_when_present() { + for view_mode in [ViewMode::Split, ViewMode::Unified] { + for presentation in [FilePresentation::Docked, FilePresentation::Floating] { + for status in [ + FileStatus::Modified, + FileStatus::Renamed, + FileStatus::Added, + FileStatus::Removed, + ] { + let mut editor = editor(); + editor.set_diff_display_options(view_mode, crate::panes::SplitAxis::Vertical); + open(&mut editor); + let id = choose_pair(&mut editor); + let mut changes = comparison(); + let index = match status { + FileStatus::Modified | FileStatus::Renamed => 0, + FileStatus::Added => 1, + FileStatus::Removed => 2, + }; + let mut file = changes.files.remove(index); + file.status = status; + let expected_text = file.after.join("\n"); + changes.files = vec![file]; + editor.apply_diff_result(id, Ok(Outcome::Comparison(changes))); + if presentation == FilePresentation::Floating { + text(&mut editor, " o"); + } + editor.handle(Input::Enter); + let diff = editor.diff().unwrap(); + assert_eq!( + diff.focus, + if status == FileStatus::Removed { + Focus::Before + } else { + Focus::After + }, + "{view_mode:?} {presentation:?} {status:?}", + ); + assert!(!diff.files.as_ref().unwrap().focused); + assert_eq!(diff.files_visible, presentation == FilePresentation::Docked); + if status != FileStatus::Removed { + assert_eq!(editor.context().text, expected_text); + } + } + } + } +} + +#[test] +fn added_file_picker_cancel_close_and_mouse_focus_use_after() { + let mut editor = editor(); + open(&mut editor); + let id = choose_pair(&mut editor); + let mut changes = comparison(); + changes.files = vec![changes.files.remove(1)]; + editor.apply_diff_result(id, Ok(Outcome::Comparison(changes))); + editor.handle(Input::Escape); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + text(&mut editor, " e"); + editor.handle(Input::Ctrl('w')); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + assert!(!editor.diff().unwrap().files_visible); + editor.scroll_diff(Focus::Before, true); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + editor.handle(Input::BackTab); + assert_eq!(editor.diff().unwrap().focus, Focus::After); +} + +#[test] +fn revision_matches_rank_later_contiguous_boundaries_and_tight_subsequences() { + assert_eq!(fuzzy_match("r---f---s/topic/rfs", "rfs").unwrap()[0], 2); + assert!(fuzzy_match("topic/rfs", "rfs") < fuzzy_match("topicxrfs", "rfs")); + assert!(fuzzy_match("r-f-s", "rfs") < fuzzy_match("r---f---s", "rfs")); + assert!(fuzzy_match("über-parser", "üpr").is_some()); + assert!(fuzzy_match("parser", "srp").is_none()); +} + +#[test] +fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() { + for phase in [Phase::Before, Phase::After] { + for (category_index, &category) in RepoKind::Jujutsu.categories().iter().enumerate() { + let mut state = DiffState::new(); + state.apply_result( + 1, + Ok(Outcome::Catalog(Catalog { + kind: RepoKind::Jujutsu, + entries: vec![ + revision( + RevisionTarget::Working, + "Working checkout", + "", + Category::Revisions, + ), + revision( + RevisionTarget::Commit("description-target".into()), + "unrelated", + "sm/fin-1205-pilot-assignment-schema", + category, + ), + revision( + RevisionTarget::Commit("immutable-fuzzy-target".into()), + "sm/fin-1205-pilot-assignment-schema", + "", + category, + ), + revision( + RevisionTarget::Commit("immutable-exact-target".into()), + "f1205pas", + "", + category, + ), + ], + notice: None, + })), + ); + state.phase = phase; + state.category = category_index; + state.query = "F1205PAS".into(); + state.refilter(); + assert_eq!( + state + .entries() + .skip(1) + .map(|entry| &entry.target) + .collect::>(), + [ + &RevisionTarget::Commit("immutable-exact-target".into()), + &RevisionTarget::Commit("immutable-fuzzy-target".into()), + &RevisionTarget::Commit("description-target".into()), + ] + ); + } + } +} + +#[test] +fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() { + let mut editor = editor(); + open(&mut editor); + text(&mut editor, "FIX 界 e\u{301}"); + assert_eq!( + editor + .diff() + .unwrap() + .entries() + .map(|entry| entry.label.as_str()) + .collect::>(), + ["Working checkout", "abcdef123"] + ); + editor.handle(Input::Backspace); + assert_eq!(editor.diff().unwrap().query, "FIX 界 "); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().category, 1); + assert_eq!(editor.diff().unwrap().entries().len(), 1); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.diff().unwrap().entries().len(), 3); + editor.handle(Input::BackTab); + assert_eq!(editor.diff().unwrap().category, 0); + editor.handle(Input::BackTab); + assert_eq!(editor.diff().unwrap().category, 2); + assert_eq!(editor.diff().unwrap().entries().len(), 2); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().category, 0); + editor + .bind("selection", "x", Some("core.down".into())) + .unwrap(); + editor.handle(Input::Char('x')); + assert_eq!(editor.diff().unwrap().selected, 1); + assert!(editor.diff().unwrap().query.is_empty()); + editor.bind("selection", "ctrl+j", None).unwrap(); + editor.handle(Input::Ctrl('j')); + assert_eq!(editor.diff().unwrap().selected, 1); +} + +#[test] +fn selector_word_and_line_deletion_preserve_source_in_both_stages() { + for after in [false, true] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("source text".into())]) + .unwrap(); + let source = editor.context().text; + let revision = editor.text_revision(); + let carets = editor.carets(); + open(&mut editor); + if after { + editor.handle(Input::Enter); + } + let phase = if after { Phase::After } else { Phase::Before }; + text(&mut editor, "FIX missing"); + assert_eq!(editor.diff().unwrap().entries().len(), 1); + editor.handle(Input::AltBackspace); + assert_eq!(editor.diff().unwrap().query, "FIX "); + assert_eq!(editor.diff().unwrap().entries().len(), 2); + editor.handle(Input::SuperBackspace); + assert!(editor.diff().unwrap().query.is_empty()); + assert_eq!( + editor + .diff() + .unwrap() + .entries() + .map(|entry| entry.label.as_str()) + .collect::>(), + [ + "Working checkout", + "abcdef123", + "fedcba321", + "Load history from origin" + ] + ); + for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] { + editor.handle(input); + } + assert_eq!(editor.diff().unwrap().phase, phase); + assert_eq!(editor.context().text, source); + assert_eq!(editor.text_revision(), revision); + assert_eq!(editor.carets(), carets); + } +} + +#[test] +fn loader_preserves_after_step_query_category_and_selection_then_compares() { + let mut editor = editor(); + open(&mut editor); + editor.handle(Input::Down); + editor.handle(Input::Enter); + text(&mut editor, "origin"); + editor.handle(Input::Down); + editor.handle(Input::Enter); + let (id, operation) = editor.take_diff_request().unwrap(); + assert!(matches!(operation, Operation::FetchRemote(remote) if remote == "origin")); + assert_eq!(editor.diff().unwrap().phase, Phase::After); + assert!(editor.diff().unwrap().loading); + editor.handle(Input::Enter); + assert!(editor.take_diff_request().is_none()); + let mut loaded = catalog(); + loaded.entries.push(revision( + RevisionTarget::Commit("remote-commit".into()), + "remote-commit", + "origin remote history", + Category::Commits, + )); + assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(loaded)))); + let state = editor.diff().unwrap(); + assert_eq!(state.phase, Phase::After); + assert_eq!(state.query, "origin"); + assert_eq!(state.category, 0); + assert_eq!( + state.entries().nth(state.selected).unwrap().target, + RevisionTarget::FetchRemote("origin".into()) + ); + assert_eq!(state.entries().len(), 3); + let remote_position = state + .entries() + .position( + |entry| matches!(&entry.target, RevisionTarget::Commit(id) if id == "remote-commit"), + ) + .unwrap(); + editor.handle(Input::Home); + for _ in 0..remote_position { + editor.handle(Input::Down); + } + editor.handle(Input::Enter); + let (id, operation) = editor.take_diff_request().unwrap(); + assert!(matches!(operation, Operation::Compare { after, .. } + if after.target == RevisionTarget::Commit("remote-commit".into()))); + assert!(editor.apply_diff_result(id, Err("comparison failed".into()))); + assert_eq!(editor.diff().unwrap().phase, Phase::After); + assert_eq!(editor.diff().unwrap().query, "origin"); + assert!(editor.diff().unwrap().error.is_some()); + editor.handle(Input::Enter); + assert!(matches!( + editor.take_diff_request(), + Some((_, Operation::Compare { .. })) + )); +} + +#[test] +fn selector_escape_closes_each_stage_and_rejects_late_results() { + for after in [false, true] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("unsaved source".into())]) + .unwrap(); + let buffer = editor.buffer_id(); + open(&mut editor); + let request = if after { + Some(choose_pair(&mut editor)) + } else { + text(&mut editor, "query"); + None + }; + editor.handle(Input::Escape); + assert!(editor.diff().is_none()); + assert_eq!(editor.buffer_id(), buffer); + assert_eq!(editor.lines(), ["unsaved source"]); + assert!(editor.dirty()); + if let Some(id) = request { + assert!(!editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())))); + assert!(editor.diff().is_none()); + } + } +} + +#[test] +fn selector_printable_input_precedes_normal_bindings_but_not_selection_overrides() { + for after in [false, true] { + let mut editor = editor(); + open(&mut editor); + if after { + editor.handle(Input::Enter); + } + editor + .bind("normal", "x", Some("core.command".into())) + .unwrap(); + for ch in ":_| x".chars() { + editor.handle(Input::Char(ch)); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.pane_layout().len(), 1); + } + assert_eq!(editor.diff().unwrap().query, ":_| x"); + editor.handle(Input::Ctrl('u')); + editor + .bind("selection", ":", Some("core.down".into())) + .unwrap(); + editor.handle(Input::Char(':')); + assert_eq!(editor.diff().unwrap().selected, 1); + assert!(editor.diff().unwrap().query.is_empty()); + } + let mut editor = editor(); + open(&mut editor); + editor.handle(Input::Ctrl('p')); + editor.handle(Input::Char(':')); + assert_eq!(editor.palette().unwrap().query, ":"); + assert_eq!(editor.mode(), Mode::Normal); + editor.handle(Input::Escape); + assert!(editor.palette().is_none()); + assert!(editor.diff().is_some()); +} + +#[test] +fn file_to_directory_change_opens_both_historical_paths() { + let mut editor = editor(); + open(&mut editor); + let id = choose_pair(&mut editor); + let mut changes = comparison(); + changes.files[0].path = "x".into(); + changes.files[1].path = "x/y".into(); + changes.files.truncate(2); + editor.apply_diff_result(id, Ok(Outcome::Comparison(changes))); + let picker = editor.diff().unwrap().files.as_ref().unwrap(); + assert!(!picker.entries().nth(picker.selected).unwrap().is_dir); + editor.handle(Input::Enter); + assert_eq!(editor.diff().unwrap().file, 0); + text(&mut editor, " e"); + editor.handle(Input::Char('i')); + text(&mut editor, "x/y"); + editor.handle(Input::Enter); + assert_eq!(editor.diff().unwrap().file, 1); + assert_eq!(editor.diff().unwrap().focus, Focus::After); +} + +#[test] +fn changed_tree_filters_navigates_and_opens_without_touching_buffers() { + let mut editor = editor(); + let original = editor.context().text; + let carets = editor.carets(); + let revision = editor.text_revision(); + open(&mut editor); + let id = choose_pair(&mut editor); + let mut changes = comparison(); + changes.files[0].path = "src/deep/renamed.txt".into(); + changes.files[1].path = "src/deep/added.txt".into(); + changes.files[2].path = "archive/gone/removed.bin".into(); + editor.apply_diff_result(id, Ok(Outcome::Comparison(changes))); + let picker = editor.diff().unwrap().files.as_ref().unwrap(); + assert!(picker.focused && !picker.searching); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + std::path::Path::new("src/deep/renamed.txt") + ); + assert!(picker.entries().any(|row| row.label == "src/deep")); + editor.bind("tree", "z", Some("core.left".into())).unwrap(); + editor.handle(Input::Char('z')); + let picker = editor.diff().unwrap().files.as_ref().unwrap(); + assert!(picker.entries().nth(picker.selected).unwrap().is_dir); + editor.handle(Input::Enter); + let picker = editor.diff().unwrap().files.as_ref().unwrap(); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + assert_eq!(editor.diff().unwrap().file, 0); + editor.handle(Input::Char('l')); + editor.handle(Input::Char('l')); + editor + .bind("selection", "ctrl+n", Some("core.down".into())) + .unwrap(); + editor.handle(Input::Ctrl('n')); + assert_eq!(editor.diff().unwrap().file, 0); + editor.handle(Input::Char('i')); + text(&mut editor, "removed.bin"); + editor.handle(Input::Enter); + let state = editor.diff().unwrap(); + assert_eq!(state.file, 2); + assert_eq!(state.focus, Focus::Before); + assert!(!state.files.as_ref().unwrap().focused); + assert!(state.rows.is_empty()); + editor.handle(Input::BackTab); + editor.handle(Input::Escape); + assert!(!editor.diff().unwrap().files.as_ref().unwrap().searching); + editor.handle(Input::Ctrl('u')); + assert!( + editor + .diff() + .unwrap() + .files + .as_ref() + .unwrap() + .query + .is_empty() + ); + editor.handle(Input::Ctrl('t')); + assert_eq!( + editor.diff().unwrap().files.as_ref().unwrap().view, + FileView::List + ); + editor.handle(Input::Ctrl('t')); + assert_eq!( + editor.diff().unwrap().files.as_ref().unwrap().view, + FileView::Tree + ); + editor.handle(Input::Ctrl('r')); + editor.handle(Input::Char('i')); + text(&mut editor, "removed.bin"); + editor.handle(Input::Enter); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + assert_eq!(editor.context().text, original); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.text_revision(), revision); + editor.handle(Input::Escape); + assert!(editor.diff().is_some()); + editor.execute("diff.close"); + assert!(editor.diff().is_none()); +} + +#[test] +fn explorer_actions_preserve_review_and_ctrl_e_uses_normal_bindings() { + let mut editor = editor(); + open(&mut editor); + let id = choose_pair(&mut editor); + editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))); + editor + .bind("tree", "ctrl+e", Some("core.down".into())) + .unwrap(); + editor.handle(Input::Ctrl('e')); + assert!(editor.diff().unwrap().files_visible); + assert_eq!( + editor + .diff() + .unwrap() + .files + .as_ref() + .unwrap() + .entries() + .nth(editor.diff().unwrap().files.as_ref().unwrap().selected) + .unwrap() + .path, + Path::new("added.txt") + ); + editor.handle(Input::Ctrl('w')); + assert!(editor.diff().is_some()); + assert!(!editor.diff().unwrap().files_visible); + text(&mut editor, " e"); + assert!(editor.diff().unwrap().files_visible); + assert_eq!(editor.diff().unwrap().focus, Focus::Files); + assert_eq!( + editor.diff().unwrap().files.as_ref().unwrap().presentation, + crate::files::FilePresentation::Docked + ); + editor.handle(Input::Ctrl('w')); + text(&mut editor, " o"); + assert_eq!( + editor.diff().unwrap().files.as_ref().unwrap().presentation, + crate::files::FilePresentation::Floating + ); + editor.handle(Input::Escape); + editor.handle(Input::Escape); + assert!(editor.diff().is_some()); + editor.execute("diff.close"); + assert!(editor.diff().is_none()); +} + +#[test] +fn explorer_toggle_is_not_a_revision_selector_control() { + let mut editor = editor(); + open(&mut editor); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.diff().unwrap().selected, 0); + editor + .bind("selection", "ctrl+e", Some("core.down".into())) + .unwrap(); + for phase in [Phase::Before, Phase::After] { + assert_eq!(editor.diff().unwrap().phase, phase); + editor.handle(Input::Ctrl('e')); + let state = editor.diff().unwrap(); + assert!(state.files_visible); + assert_eq!(state.selected, 1); + assert_eq!(state.phase, phase); + editor.handle(Input::Enter); + } +} + +#[test] +fn aligned_hunks_navigation_shared_top_independent_horizontal_and_file_reset() { + let mut editor = editor(); + open(&mut editor); + let id = choose_pair(&mut editor); + assert!(editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())))); + assert_eq!( + editor + .diff() + .unwrap() + .rows + .iter() + .map(|row| (row.before, row.after, row.kind)) + .collect::>(), + [ + (Some(0), Some(0), RowKind::Equal), + (Some(1), Some(1), RowKind::Changed), + (None, Some(2), RowKind::Added), + (Some(2), Some(3), RowKind::Equal), + (Some(3), None, RowKind::Removed), + (Some(4), Some(4), RowKind::Equal), + ] + ); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + editor.handle(Input::Down); + editor.handle(Input::Right); + assert_eq!(editor.cursor().row, 1); + assert_eq!(editor.cursor().column, 1); + editor.scroll_diff(Focus::Before, true); + editor.set_diff_viewport(id, 0, 1, 1, 0); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + editor.handle(Input::Down); + editor.handle(Input::Right); + assert_eq!(editor.cursor().row, 2); + assert_eq!(editor.diff().unwrap().top, 1); + editor.set_diff_viewport(id, 0, 2, 1, 1); + assert_eq!(editor.diff().unwrap().left_after, 1); + assert_eq!(editor.diff().unwrap().left_before, 1); + editor.handle(Input::End); + assert_eq!(editor.diff().unwrap().top, 2); + editor.handle(Input::BackTab); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + editor.handle(Input::BackTab); + editor.handle(Input::Down); + assert_eq!(editor.diff().unwrap().file, 0); + editor.handle(Input::Enter); + let state = editor.diff().unwrap(); + assert_eq!(state.file, 1); + assert_eq!((state.top, state.left_before, state.left_after), (0, 0, 0)); + assert!( + state + .rows + .iter() + .all(|row| row.before.is_none() && row.kind == RowKind::Added) + ); + editor.handle(Input::BackTab); + editor.handle(Input::Down); + editor.handle(Input::Enter); + assert_eq!(editor.diff().unwrap().file, 2); + assert!(editor.diff().unwrap().rows.is_empty()); + assert!( + editor.diff().unwrap().comparison.as_ref().unwrap().files[2] + .notice + .is_some() + ); + editor.handle(Input::BackTab); + editor.handle(Input::Down); + editor.handle(Input::Enter); + assert_eq!(editor.diff().unwrap().file, 0); +} + +#[test] +fn comparison_identity_comes_from_accepted_requests_not_working_labels() { + let mut editor = editor(); + open(&mut editor); + let first = choose_pair(&mut editor); + assert!(editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison())))); + assert_eq!(editor.diff().unwrap().comparison_id, Some(first)); + editor.execute("diff.close"); + open(&mut editor); + assert_eq!(editor.diff().unwrap().comparison_id, None); + let second = choose_pair(&mut editor); + assert!(second > first); + assert!(!editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison())))); + assert_eq!(editor.diff().unwrap().comparison_id, None); + assert!(editor.apply_diff_result(second, Ok(Outcome::Comparison(comparison())))); + assert_eq!(editor.diff().unwrap().comparison_id, Some(second)); +} + +#[test] +fn closing_diff_rejects_pending_file_details_even_after_reopening() { + let mut editor = editor(); + open(&mut editor); + let comparison_id = choose_pair(&mut editor); + let mut catalog = comparison(); + let detail = catalog.files.remove(0); + catalog.files.clear(); + catalog.files.push(ChangedFile { + path: detail.path.clone(), + old_path: detail.old_path.clone(), + status: detail.status, + loaded: false, + before: vec![], + after: vec![], + before_eof: false, + after_eof: false, + hunks: vec![], + notice: None, + }); + editor.apply_diff_result(comparison_id, Ok(Outcome::Comparison(catalog))); + let (file_request, _) = editor.take_diff_request().unwrap(); + editor.execute("diff.close"); + assert!(!editor.apply_diff_result( + file_request, + Ok(Outcome::File { + comparison_id, + file_index: 0, + file: detail, + }) + )); + assert!(editor.diff().is_none()); + open(&mut editor); + assert!(editor.diff().unwrap().comparison.is_none()); +} + +#[test] +fn explicit_remote_ref_loader_preserves_selector_position_without_choosing_revision() { + let mut state = DiffState::new(); + let mut initial = catalog(); + let loader = initial + .entries + .iter_mut() + .find(|entry| matches!(entry.target, RevisionTarget::FetchRemote(_))) + .unwrap(); + loader.target = RevisionTarget::ListRemote("origin".into()); + loader.label = "Load refs from origin".into(); + state.apply_result(1, Ok(Outcome::Catalog(initial))); + state.phase = Phase::After; + state.query = "origin".into(); + state.refilter(); + state.selected = 1; + state.choose(); + assert!( + matches!(state.take_request(), Some(Operation::ListRemote(remote)) if remote == "origin") + ); + let mut loaded = catalog(); + let loader = loaded + .entries + .iter_mut() + .find(|entry| matches!(entry.target, RevisionTarget::FetchRemote(_))) + .unwrap(); + loader.target = RevisionTarget::ListRemote("origin".into()); + loader.label = "Load refs from origin".into(); + loaded.entries.push(revision( + RevisionTarget::Remote { + remote: "origin".into(), + reference: "refs/heads/new".into(), + }, + "new@origin", + "", + Category::Branches, + )); + state.apply_result(2, Ok(Outcome::Catalog(loaded))); + assert_eq!(state.phase, Phase::After); + assert_eq!(state.query, "origin"); + assert_eq!(state.category, 0); + assert_eq!(state.selected, 1); + assert!(state.after.is_none()); + assert!(!state.loading); + state.category = 1; + state.refilter(); + assert!( + state + .entries() + .any(|entry| matches!(entry.target, RevisionTarget::Remote { .. })) + ); +} + +#[test] +fn discovery_and_fetch_errors_are_retryable_and_stale_responses_cannot_reopen() { + let mut editor = editor(); + editor.execute("diff"); + let (old, _) = editor.take_diff_request().unwrap(); + editor.handle(Input::Escape); + assert!(!editor.apply_diff_result(old, Ok(Outcome::Catalog(catalog())))); + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + assert!(id > old); + assert!(!editor.apply_diff_result(old, Err("stale".into()))); + assert!(editor.diff().unwrap().loading); + assert!(editor.apply_diff_result(id, Err("discovery failed".into()))); + assert!(editor.diff().unwrap().error.is_some()); + editor.handle(Input::Enter); + let (retry, operation) = editor.take_diff_request().unwrap(); + assert!(matches!(operation, Operation::Discover)); + assert!(retry > id); + assert!(editor.apply_diff_result(retry, Ok(Outcome::Catalog(catalog())))); + text(&mut editor, "origin"); + editor.handle(Input::Down); + editor.handle(Input::Enter); + let (fetch, _) = editor.take_diff_request().unwrap(); + assert!(editor.apply_diff_result(fetch, Err("fetch failed".into()))); + assert_eq!(editor.diff().unwrap().entries().len(), 2); + assert_eq!(editor.diff().unwrap().query, "origin"); + editor.handle(Input::Ctrl('u')); + editor.handle(Input::Home); + editor.handle(Input::Enter); + assert_eq!(editor.diff().unwrap().phase, Phase::After); +} + +#[test] +fn immutable_diff_and_selectors_preserve_dirty_buffers_carets_and_undo() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("original\\nsecond".into())]) + .unwrap(); + editor.handle(Input::Char('i')); + text(&mut editor, "unsaved"); + editor.handle(Input::Escape); + editor.handle(Input::Right); + let original = editor.context().text; + let carets = editor.carets(); + let revision = editor.text_revision(); + let buffers = editor + .buffers() + .map(|buffer| (buffer.id, buffer.dirty)) + .collect::>(); + editor + .bind("normal", "x", Some("core.diff".into())) + .unwrap(); + assert!(editor.handle(Input::Char('x')).is_none()); + let (id, _) = editor.take_diff_request().unwrap(); + assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog())))); + for input in [ + Input::Char('i'), + Input::Char('d'), + Input::Delete, + Input::Paste("bad\\n:q!".into()), + ] { + assert!(editor.handle(input).is_none()); + } + assert!(editor.execute("bdelete!").is_none()); + assert!(editor.apply(vec![Effect::Replace("bad".into())]).is_err()); + assert_eq!(editor.context().text, original); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.text_revision(), revision); + assert_eq!( + editor + .buffers() + .map(|buffer| (buffer.id, buffer.dirty)) + .collect::>(), + buffers + ); + editor.handle(Input::Escape); + assert!(editor.diff().is_none()); + assert_eq!(editor.mode(), Mode::Normal); + open(&mut editor); + let id = choose_pair(&mut editor); + editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))); + for input in [ + Input::Char('i'), + Input::Char('u'), + Input::Char('d'), + Input::Paste("bad".into()), + Input::Delete, + Input::Backspace, + Input::Enter, + ] { + editor.handle(input); + } + editor.handle(Input::Escape); + editor.handle(Input::Escape); + assert!(editor.diff().is_some()); + editor.execute("diff.close"); + assert_eq!(editor.context().text, original); + assert_eq!(editor.carets(), carets); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "original\\nsecond"); +} + +#[test] +fn jujutsu_categories_empty_comparison_and_command_mode_entry_are_complete() { + let mut editor = editor(); + editor.handle(Input::Char(':')); + text(&mut editor, "diff"); + editor.handle(Input::Enter); + assert_eq!(editor.mode(), Mode::Normal); + let (id, _) = editor.take_diff_request().unwrap(); + let mut catalog = catalog(); + catalog.kind = RepoKind::Jujutsu; + catalog.entries[0].category = Category::Revisions; + catalog.entries[1].category = Category::Revisions; + catalog.entries[4].category = Category::Bookmarks; + assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog)))); + assert_eq!(editor.diff().unwrap().entries().len(), 2); + editor.handle(Input::Char('\t')); + assert_eq!( + editor.diff().unwrap().entries().nth(1).unwrap().category, + Category::Bookmarks + ); + editor.handle(Input::Home); + editor.handle(Input::Enter); + editor.handle(Input::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + assert!(editor.apply_diff_result( + id, + Ok(Outcome::Comparison(Comparison { + revisions: ["working:snapshot".into(), "working:snapshot".into()], + before: "Working".into(), + after: "Working".into(), + files: vec![], + root: PathBuf::new(), + editable_paths: BTreeSet::new(), + })) + )); + assert_eq!(editor.diff().unwrap().phase, Phase::View); + assert!(editor.diff().unwrap().rows.is_empty()); + for input in [Input::Down, Input::End, Input::Up, Input::Right, Input::End] { + editor.handle(input); + } + assert_eq!(editor.diff().unwrap().top, 0); + assert_eq!(editor.diff().unwrap().file, 0); + editor.handle(Input::Escape); + assert!(editor.diff().is_some()); + editor.execute("diff.close"); + assert!(editor.diff().is_none()); +} + +fn live_state(before: Vec, after: Vec, eof: bool) -> DiffState { + let mut state = DiffState::new(); + state.phase = Phase::View; + state.focus = Focus::After; + state.after = Some(revision( + RevisionTarget::Working, + "Working", + "", + Category::Commits, + )); + state.comparison = Some(Comparison { + revisions: ["base".into(), "working:snapshot".into()], + root: PathBuf::new(), + before: "base".into(), + after: "Working".into(), + editable_paths: BTreeSet::from([PathBuf::from("file")]), + files: vec![ChangedFile { + path: "file".into(), + old_path: None, + status: FileStatus::Modified, + hunks: line_hunks(&before, eof, &after, eof), + before, + after, + before_eof: eof, + after_eof: eof, + notice: None, + loaded: true, + }], + }); + state.files = Some(FilePicker::from_paths( + PathBuf::new(), + [(0, PathBuf::from("file"))], + )); + state.align_file(); + state +} + +#[test] +fn working_refresh_preserves_noops_errors_and_view_state_but_invalidates_old_identity() { + let mut state = live_state(vec!["old".into()], vec!["new".into()], false); + state.loading = false; + state.pending = None; + state.request_kind = None; + state.comparison_id = Some(7); + state.word_wrap = true; + state.view_mode = ViewMode::Unified; + state.comparison_mode = ComparisonMode::Snapshots; + state.split_axis = crate::panes::SplitAxis::Horizontal; + state.files_visible = false; + state.reviewed_files.insert(0); + state.error = Some("previous notice".into()); + let files = state.files.as_mut().unwrap(); + files.set_view(FileView::Tree); + files.start_search(); + files.query = "file".into(); + files.refilter(); + state.queue_checkout_refresh(true); + assert!(matches!( + state.take_request(), + Some(Operation::RefreshCheckout { + comparison_id: 7, + .. + }) + )); + assert!(state.apply_result(8, Ok(Outcome::CheckoutRefresh(None)))); + assert!(state.error.is_none()); + assert_eq!(state.comparison_id, Some(7)); + assert_eq!(state.reviewed_files, BTreeSet::from([0])); + + state.queue_checkout_refresh(true); + state.take_request(); + assert!(state.apply_result(9, Err("refresh failed".into()))); + assert_eq!(state.comparison_id, Some(7)); + assert_eq!(state.comparison.as_ref().unwrap().files[0].after, ["new"]); + assert_eq!(state.reviewed_files, BTreeSet::from([0])); + assert_eq!(state.error.as_deref(), Some("refresh failed")); + + state.queue_checkout_refresh(true); + state.take_request(); + let mut replacement = live_state(vec!["old".into()], vec!["ahead".into()], false) + .comparison + .take() + .unwrap(); + replacement.files.insert(0, comparison().files.remove(0)); + assert!(state.apply_result(10, Ok(Outcome::CheckoutRefresh(Some(replacement))))); + assert_eq!(state.comparison_id, Some(10)); + assert_eq!(state.file, 1); + assert_eq!(state.focus, Focus::After); + assert!(state.word_wrap && !state.files_visible); + assert_eq!(state.split_axis, crate::panes::SplitAxis::Horizontal); + assert_eq!(state.view_mode, ViewMode::Unified); + assert_eq!(state.comparison_mode, ComparisonMode::Snapshots); + assert!(state.reviewed_files.is_empty()); + let files = state.files.as_ref().unwrap(); + assert_eq!(files.query, "file"); + assert_eq!(files.view, FileView::Tree); + assert!(files.searching); + assert_eq!( + files.entries().nth(files.selected).unwrap().path, + Path::new("file") + ); + assert!(!state.apply_result(7, Ok(Outcome::CheckoutRefresh(None)))); + assert_eq!(state.comparison_id, Some(10)); + assert_eq!(state.file, 1); + state.after.as_mut().unwrap().target = RevisionTarget::Commit("historical".into()); + state.queue_checkout_refresh(false); + assert!(state.request_queued()); + assert!(matches!( + state.take_request(), + Some(Operation::RefreshCheckout { + follow_working: false, + .. + }) + )); + assert!(!state.apply_result(11, Ok(Outcome::CheckoutRefresh(None)))); + state.reviewed_files.insert(1); + state.queue_checkout_refresh(false); + state.take_request(); + let mut demoted = live_state(vec!["old".into()], vec!["ahead".into()], false) + .comparison + .take() + .unwrap(); + demoted.files.insert(0, comparison().files.remove(0)); + demoted.editable_paths.clear(); + assert!(state.apply_result(12, Ok(Outcome::CheckoutRefresh(Some(demoted))))); + assert_eq!(state.reviewed_files, BTreeSet::from([1])); + assert!(!state.live_after_available()); + state + .comparison + .as_mut() + .unwrap() + .editable_paths + .insert("file".into()); + state.refresh_after(&["dirty".into()], 1); + assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["dirty"]); + state.queue_checkout_refresh(false); + state.take_request(); + assert!(state.apply_result(13, Err("checkout unavailable".into()))); + assert!(!state.live_after_available()); + assert_eq!(state.comparison_id, Some(12)); + assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["ahead"]); + state.after.as_mut().unwrap().target = RevisionTarget::Working; + state.queue_checkout_refresh(false); + assert!(!state.request_queued()); + state.load_file(0); + state.queue_checkout_refresh(true); + assert!(matches!( + state.pending, + Some(Operation::LoadFile { file_index: 0, .. }) + )); +} + +#[test] +fn reviewed_files_use_highlighted_catalog_indices_not_sorted_or_filtered_rows() { + let mut state = DiffState::new(); + let mut changes = comparison(); + changes.files[0].path = "nested/z.txt".into(); + changes.files[1].path = "nested/a.txt".into(); + state.request_kind = Some(RequestKind::Comparison); + state.apply_result(1, Ok(Outcome::Comparison(changes))); + for view in [FileView::List, FileView::Tree] { + let files = state.files.as_mut().unwrap(); + files.set_view(view); + files.clear_filter(); + files.start_search(); + for ch in "nested/a".chars() { + files.handle_filter_input(&Input::Char(ch)); + } + files.reveal_path(Path::new("nested/a.txt")); + assert_eq!(state.file, 0); + assert_eq!(state.toggle_reviewed(), Ok(true)); + assert_eq!(state.reviewed_files, BTreeSet::from([1])); + assert_eq!(state.toggle_reviewed(), Ok(false)); + assert!(state.reviewed_files.is_empty()); + } + let files = state.files.as_mut().unwrap(); + files.clear_filter(); + files.finish_search(); + let selected = files.entries().position(|entry| entry.is_dir).unwrap(); + files.selected = selected; + assert_eq!( + state.toggle_reviewed(), + Err("Select a changed file, not a directory") + ); + assert!(state.reviewed_files.is_empty()); + let files = state.files.as_mut().unwrap(); + files.start_search(); + for ch in "missing".chars() { + files.handle_filter_input(&Input::Char(ch)); + } + assert_eq!(state.toggle_reviewed(), Err("No changed file is selected")); + state.files = Some(FilePicker::from_paths( + PathBuf::new(), + [(0, PathBuf::from("ordinary.txt"))], + )); + assert_eq!(state.toggle_reviewed(), Err("No changed file is selected")); + state.focus = Focus::Before; + assert_eq!(state.toggle_reviewed(), Ok(true)); + assert_eq!(state.reviewed_files, BTreeSet::from([0])); + assert!(state.select_file_index(2)); + state.focus = Focus::Before; + assert_eq!(state.toggle_reviewed(), Ok(true)); + assert_eq!(state.reviewed_files, BTreeSet::from([0, 2])); +} + +#[test] +fn reviewed_files_survive_lazy_details_and_views_but_reset_on_comparison() { + let mut state = DiffState::new(); + assert_eq!(state.toggle_reviewed(), Err("No comparison is open")); + let mut changes = comparison(); + changes.files[0].loaded = false; + changes.files[0].before.clear(); + changes.files[0].after.clear(); + state.request_kind = Some(RequestKind::Comparison); + state.apply_result(1, Ok(Outcome::Comparison(changes))); + assert_eq!(state.toggle_reviewed(), Ok(true)); + assert_eq!(state.reviewed_files, BTreeSet::from([0])); + let file = comparison().files.remove(0); + state.apply_result( + 2, + Ok(Outcome::File { + comparison_id: 1, + file_index: 0, + file, + }), + ); + assert!(state.selected_file_loaded()); + state.set_view_mode(ViewMode::Unified); + state.set_view_mode(ViewMode::Split); + state.split_axis = crate::panes::SplitAxis::Horizontal; + assert!(state.select_file_index(1)); + assert_eq!(state.reviewed_files, BTreeSet::from([0])); + state.before = Some(revision( + RevisionTarget::Commit("base".into()), + "base", + "", + Category::Commits, + )); + state.after = Some(revision( + RevisionTarget::Commit("head".into()), + "head", + "", + Category::Commits, + )); + state.toggle_comparison_mode().unwrap(); + assert!(state.reviewed_files.is_empty()); + assert_eq!(state.toggle_reviewed(), Err("No comparison is open")); + state.apply_result(3, Ok(Outcome::Comparison(comparison()))); + assert!(state.reviewed_files.is_empty()); + assert_eq!(state.toggle_reviewed(), Ok(true)); + state.request_kind = Some(RequestKind::Comparison); + state.apply_result(4, Ok(Outcome::Comparison(comparison()))); + assert!(state.reviewed_files.is_empty()); +} + +#[test] +fn working_review_marks_clear_only_for_changed_bytes_or_eof() { + for (after, eof, lines) in [ + (vec!["changed".into()], false, vec!["changed".into()]), + ( + vec!["changed".into()], + true, + vec!["changed".into(), String::new()], + ), + (Vec::new(), false, vec![String::new()]), + ] { + let mut state = live_state(vec!["base".into()], after, eof); + assert_eq!(state.toggle_reviewed(), Ok(true)); + state.refresh_after(&lines, 1); + assert_eq!(state.reviewed_files, BTreeSet::from([0])); + state.refresh_after(&lines, 2); + assert_eq!(state.reviewed_files, BTreeSet::from([0])); + state.refresh_after(&["edited".into()], 3); + assert!(state.reviewed_files.is_empty()); + assert_eq!(state.toggle_reviewed(), Ok(true)); + state.refresh_after(&["edited".into(), String::new()], 4); + assert!(state.reviewed_files.is_empty()); + assert_eq!(state.toggle_reviewed(), Ok(true)); + state.refresh_after(&["edited".into()], 5); + assert!(state.reviewed_files.is_empty()); + } + let mut state = live_state(vec!["base".into()], vec!["changed".into()], false); + state.comparison.as_mut().unwrap().files[0].loaded = false; + assert_eq!(state.toggle_reviewed(), Ok(true)); + state.refresh_after(&["edited".into()], 1); + assert_eq!(state.reviewed_files, BTreeSet::from([0])); +} + +#[test] +fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() { + let mut state = live_state( + ["head", "old one", "old two", "tail"] + .map(str::to_owned) + .to_vec(), + ["head", "new one", "new two", "extra", "tail"] + .map(str::to_owned) + .to_vec(), + false, + ); + assert_eq!(state.view_mode, ViewMode::Split); + assert_eq!(state.split_axis, crate::panes::SplitAxis::Vertical); + state.top = state.after_display_row(2).unwrap(); + let excerpts = state.excerpt_ranges.clone(); + state.set_view_mode(ViewMode::Unified); + let rows: Vec<_> = (0..state.display_rows.len()) + .map(|display| { + ( + state.display_focus(display), + state.display_source_row(Focus::Before, display), + state.display_source_row(Focus::After, display), + ) + }) + .collect(); + assert_eq!( + rows, + [ + (None, Some(0), Some(0)), + (Some(Focus::Before), Some(1), None), + (Some(Focus::Before), Some(2), None), + (Some(Focus::After), None, Some(1)), + (Some(Focus::After), None, Some(2)), + (Some(Focus::After), None, Some(3)), + (None, Some(3), Some(4)), + ] + ); + assert_eq!(state.top, state.after_display_row(2).unwrap()); + assert_eq!( + state.corresponding_row(Focus::Before, Focus::After, 2), + Some(2) + ); + assert!(state.after_editable()); + for focus in [Focus::Before, Focus::After] { + for source in state.source_rows(focus) { + let display = state.source_display_row(focus, source).unwrap(); + assert_eq!(state.display_source_row(focus, display), Some(source)); + } + } + state.set_view_mode(ViewMode::Split); + assert_eq!(state.excerpt_ranges, excerpts); + assert_eq!(state.focus, Focus::After); + assert_eq!(state.top, state.after_display_row(2).unwrap()); + assert_eq!(state.display_rows.len(), 5); +} + +#[test] +fn unified_live_reflow_preserves_removed_top_and_expanded_source_excerpts() { + let base: Vec<_> = (0..40).map(|row| format!("line {row}")).collect(); + let mut lines = base.clone(); + lines[20] = "new line".into(); + let mut state = live_state(base, lines.clone(), false); + state.set_context_options(1, 3); + state.refresh_after(&lines, 1); + state.set_view_mode(ViewMode::Unified); + state.top = state.source_display_row(Focus::Before, 20).unwrap(); + state.expand_source_excerpt(Focus::After, false, 20); + assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20)); + assert!(state.source_display_row(Focus::After, 16).is_some()); + lines.insert(0, "inserted".into()); + state.refresh_after(&lines, 2); + assert_eq!(state.display_focus(state.top), Some(Focus::Before)); + assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20)); + assert!(state.source_display_row(Focus::After, 17).is_some()); + state.reveal_source_row(Focus::After, 35); + let visible: Vec<_> = state.source_rows(Focus::After).collect(); + state.set_view_mode(ViewMode::Split); + state.set_view_mode(ViewMode::Unified); + assert_eq!(state.source_rows(Focus::After).collect::>(), visible); + assert_eq!(state.display_source_row(Focus::After, state.top), Some(35)); +} + +#[test] +fn character_ranges_follow_insertions_deletions_unicode_and_live_edits() { + for (before, after, expected) in [ + ("ab", "a界b", vec![[1..1, 1..2]]), + ("a界b", "ab", vec![[1..2, 1..1]]), + ("xe\u{301}👩‍🚀z", "xe\u{300}👩‍🔬z", vec![[1..3, 1..3]]), + ] { + let state = live_state(vec![before.into()], vec![after.into()], false); + assert_eq!(state.rows[0].changes, expected); + } + let mut state = live_state(vec!["abc".into()], vec!["aXbc".into()], false); + state.refresh_after(&["aXYbc".into()], 1); + assert_eq!(state.rows[0].changes, vec![[1..1, 1..3]]); + state.refresh_after(&["abYZc".into()], 2); + assert_eq!(state.rows[0].changes, vec![[2..2, 2..4]]); + state.refresh_after(&["abc".into()], 3); + assert_eq!(state.rows[0].kind, RowKind::Equal); + assert!(state.rows[0].changes.is_empty()); +} + +#[test] +fn character_highlights_merge_incidental_matches_but_preserve_meaningful_gaps() { + let mut cases = vec![ + ("a..c".into(), "b..d".into(), vec![[0..4, 0..4]]), + ("base".into(), "edited".into(), vec![[0..4, 0..6]]), + ( + "a...c".into(), + "b...d".into(), + vec![[0..1, 0..1], [4..5, 4..5]], + ), + ( + "a界e\u{301}c".into(), + "b界e\u{301}d".into(), + vec![[0..4, 0..4]], + ), + ]; + for (size, gap, merge) in [ + (60, " = ".to_owned(), false), + (70, " = ".to_owned(), true), + (70, "x".repeat(20), true), + (70, "unchanged_expression".to_owned(), true), + (70, "unchanged_expression_".to_owned(), false), + (70, " ".repeat(500), true), + (70, " ".repeat(501), false), + ] { + let end = 2 * size + gap.len(); + let second = size + gap.len(); + cases.push(( + format!("{}{}{}", "a".repeat(size), gap, "c".repeat(size)), + format!("{}{}{}", "b".repeat(size), gap, "d".repeat(size)), + if merge { + vec![[0..end, 0..end]] + } else { + vec![[0..size, 0..size], [second..end, second..end]] + }, + )); + } + cases.push(( + format!("aaa = {} = {}", "c".repeat(70), "e".repeat(70)), + format!("bbb = {} = {}", "d".repeat(70), "f".repeat(70)), + vec![[0..149, 0..149]], + )); + for (before, after, expected) in cases { + let mut state = live_state(vec![before], vec![after.clone()], false); + assert_eq!(state.rows[0].changes, expected, "{after:?}"); + state.refresh_after(&[format!("{after}!")], 1); + state.refresh_after(std::slice::from_ref(&after), 2); + assert_eq!(state.rows[0].changes, expected, "live edit: {after:?}"); + } +} + +#[test] +fn reverting_loaded_file_keeps_unloaded_changes_selectable() { + let mut state = live_state(vec!["base".into()], vec!["changed".into()], false); + state.comparison_id = Some(17); + state.pending = None; + state.comparison.as_mut().unwrap().files.push(ChangedFile { + path: "unloaded.txt".into(), + old_path: None, + status: FileStatus::Modified, + loaded: false, + before: vec![], + after: vec![], + before_eof: false, + after_eof: false, + hunks: vec![], + notice: None, + }); + state.refresh_after(&["base".into()], 1); + assert_eq!( + state + .files + .as_ref() + .unwrap() + .entries() + .map(|entry| entry.path) + .collect::>(), + [Path::new("unloaded.txt")], + ); + assert!(state.select_file(Path::new("unloaded.txt"))); + assert!(matches!( + state.take_request(), + Some(Operation::LoadFile { + comparison_id: 17, + file_index: 1 + }) + )); +} + +#[test] +fn live_width_cache_scans_only_changed_lines_and_shrinks_after_deletion() { + let base = vec!["original".into(), "medium".into(), "short".into()]; + let mut lines = vec!["界\tz".into(), "medium".into(), "short".into()]; + let mut state = live_state(base, lines.clone(), false); + state.refresh_after(&lines, 1); + assert_eq!(state.after_widths, [5, 6, 5]); + lines[0] = "界界\t🙂".into(); + WIDTH_SCANS.set(0); + state.refresh_after(&lines, 2); + assert_eq!(WIDTH_SCANS.get(), 1); + assert_eq!(state.max_after, 10); + state.left_after = 9; + lines[0] = "界".into(); + WIDTH_SCANS.set(0); + state.refresh_after(&lines, 3); + assert_eq!(WIDTH_SCANS.get(), 1); + assert_eq!(state.max_after, 6); + assert_eq!(state.left_after, 6); + lines.remove(1); + WIDTH_SCANS.set(0); + state.refresh_after(&lines, 4); + assert_eq!(WIDTH_SCANS.get(), 0); + assert_eq!(state.after_widths, [2, 5]); + assert_eq!(state.max_after, 5); + lines.insert(1, "巨大\t🙂".into()); + WIDTH_SCANS.set(0); + state.refresh_after(&lines, 5); + assert_eq!(WIDTH_SCANS.get(), 1); + assert_eq!(state.after_widths, [2, 10, 5]); + lines = vec!["".into()]; + state.refresh_after(&lines, 6); + assert_eq!(state.after_widths, [0]); + assert_eq!(state.max_after, 0); + assert_eq!(state.left_after, 0); +} + +#[test] +fn stable_line_edits_keep_fresh_hunks_expansion_and_scroll_anchor_until_revert() { + let base = (0..100) + .map(|row| format!("line {row}")) + .collect::>(); + let mut lines = base.clone(); + lines[30] = "first change".into(); + let mut state = live_state(base.clone(), lines.clone(), true); + state.set_context_options(2, 5); + lines.push(String::new()); + state.refresh_after(&lines, 1); + state.top = state.after_display_row(30).unwrap(); + state.handle(Input::Char('[')); + let display = state.display_rows.clone(); + let excerpts = state.excerpt_ranges.clone(); + let top = state.top; + lines[30].push('!'); + let file = &state.comparison.as_ref().unwrap().files[0]; + assert!(stable_after_edit( + file, + &lines, + changed_line_range(&file.after, &lines) + )); + WIDTH_SCANS.set(0); + state.refresh_after(&lines, 2); + assert_eq!(WIDTH_SCANS.get(), 1); + assert_eq!(state.display_rows, display); + assert_eq!(state.excerpt_ranges, excerpts); + assert_eq!(state.top, top); + let file = &state.comparison.as_ref().unwrap().files[0]; + let hunk_ranges = |hunks: &[Hunk]| { + hunks + .iter() + .map(|hunk| { + ( + hunk.before_start, + hunk.before_len, + hunk.after_start, + hunk.after_len, + ) + }) + .collect::>() + }; + assert_eq!( + hunk_ranges(&file.hunks), + hunk_ranges(&line_hunks(&base, true, live_content(&lines, true), true)) + ); + lines[30].clone_from(&base[30]); + assert!(!stable_after_edit( + file, + &lines, + changed_line_range(&file.after, &lines) + )); + state.refresh_after(&lines, 3); + assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); + assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal)); + assert!(state.after_display_row(23).is_some()); + assert_eq!(state.top, state.after_display_row(30).unwrap()); + assert_eq!(state.files.as_ref().unwrap().entries().len(), 0); + lines[50] = "changed again".into(); + state.refresh_after(&lines, 4); + assert_eq!(state.files.as_ref().unwrap().entries().len(), 1); + assert!(state.after_display_row(23).is_some()); + assert!(state.after_display_row(50).is_some()); +} + +#[test] +fn live_reflow_keeps_separate_hunks_and_expanded_context_after_revert() { + let base = (0..100) + .map(|row| format!("line {row}")) + .collect::>(); + let mut changed = base.clone(); + changed[30] = "first change".into(); + changed[70] = "second change".into(); + let mut state = live_state(base.clone(), changed.clone(), true); + state.set_context_options(2, 5); + changed.push(String::new()); + state.refresh_after(&changed, 1); + assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 2); + assert!( + state + .rows + .last() + .is_some_and(|row| row.after == Some(100) && row.kind == RowKind::Equal) + ); + state.top = state.after_display_row(30).unwrap(); + state.handle(Input::Char('[')); + assert!(state.after_display_row(23).is_some()); + changed.insert(10, "inserted outside excerpts".into()); + state.refresh_after(&changed, 2); + assert!(state.after_display_row(24).is_some()); + assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 3); + assert!( + state + .display_rows + .iter() + .any(|row| matches!(row, DisplayRow::Fold { start, end } if *start < 60 && *end > 40)) + ); + let mut reverted = base; + reverted.push(String::new()); + state.refresh_after(&reverted, 3); + assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); + assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal)); + assert!(state.after_display_row(23).is_some()); + assert!(state.after_display_row(90).is_none()); + assert_eq!(state.files.as_ref().unwrap().entries().len(), 0); + assert_eq!(state.file, 0); + reverted[50] = "changed again".into(); + state.refresh_after(&reverted, 4); + assert_eq!(state.files.as_ref().unwrap().entries().len(), 1); + assert!(state.after_display_row(50).is_some()); +} + +#[test] +fn live_empty_and_eof_rows_match_buffer_without_false_eof_highlights() { + let mut state = live_state(vec!["same".into()], vec!["changed".into()], true); + state.refresh_after(&["same".into(), "".into()], 1); + assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); + assert_eq!(state.rows.len(), 2); + assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal)); + assert!(state.after_display_row(0).is_some()); + assert!(state.after_display_row(1).is_none()); + state.refresh_after(&["same".into()], 2); + assert_eq!(state.rows[0].kind, RowKind::Changed); + assert!( + state.comparison.as_ref().unwrap().files[0] + .notice + .as_ref() + .unwrap() + .contains("After missing") + ); + state.refresh_after(&["".into()], 3); + assert!(state.after_display_row(0).is_some()); + assert_eq!(state.comparison.as_ref().unwrap().files[0].after, [""]); + let mut empty = live_state(vec![], vec!["text".into()], false); + empty.refresh_after(&["".into()], 1); + assert!(empty.comparison.as_ref().unwrap().files[0].hunks.is_empty()); + assert_eq!(empty.rows.len(), 1); + assert_eq!(empty.rows[0].kind, RowKind::Equal); + assert_eq!(empty.after_display_row(0), Some(0)); + empty.after.as_mut().unwrap().target = RevisionTarget::Commit("historic".into()); + empty.comparison.as_mut().unwrap().editable_paths.clear(); + empty.refresh_after(&["must not apply".into()], 2); + assert_eq!(empty.comparison.as_ref().unwrap().files[0].after, [""]); + empty.handle(Input::Escape); + assert_eq!(empty.phase, Phase::View); +} + +#[test] +fn lazy_selection_waits_for_details_rejects_stale_files_and_reuses_loaded_cache() { + let mut state = DiffState::new(); + state.pending = None; + state.request_kind = Some(RequestKind::Comparison); + let mut catalog = comparison(); + catalog.files.truncate(2); + let details: Vec<_> = catalog.files.drain(..).collect(); + for detail in &details { + catalog.files.push(ChangedFile { + path: detail.path.clone(), + old_path: detail.old_path.clone(), + status: detail.status, + loaded: false, + before: vec![], + after: vec![], + before_eof: false, + after_eof: false, + hunks: vec![], + notice: None, + }); + } + state.apply_result(41, Ok(Outcome::Comparison(catalog))); + assert!(!state.selected_file_loaded()); + assert!(state.rows.is_empty() && state.display_rows.is_empty()); + assert_eq!(state.pane_layout(), PaneLayout::Empty); + assert!(!state.after_editable()); + assert!(matches!( + state.take_request(), + Some(Operation::LoadFile { + comparison_id: 41, + file_index: 0, + }) + )); + assert!(state.select_file(Path::new("added.txt"))); + assert!(matches!( + state.take_request(), + Some(Operation::LoadFile { + comparison_id: 41, + file_index: 1, + }) + )); + let mut details = details.into_iter(); + let first = details.next().unwrap(); + let second = details.next().unwrap(); + state.apply_result( + 42, + Ok(Outcome::File { + comparison_id: 40, + file_index: 1, + file: first, + }), + ); + assert!(state.loading && !state.selected_file_loaded()); + state.apply_result( + 43, + Ok(Outcome::File { + comparison_id: 41, + file_index: 1, + file: second, + }), + ); + assert!(state.selected_file_loaded()); + assert_eq!(state.pane_layout(), PaneLayout::After); + assert!(!state.rows.is_empty()); + assert!(state.select_file(Path::new("renamed.txt"))); + assert!(state.loading); + assert!(state.select_file(Path::new("added.txt"))); + assert!(!state.loading && state.selected_file_loaded()); + assert!(state.take_request().is_none()); + state.apply_result(44, Err("cancelled old load".into())); + assert!(state.error.is_none() && state.selected_file_loaded()); +} + +#[test] +fn failed_lazy_file_load_can_be_retried_without_reopening_comparison() { + let mut state = DiffState::new(); + state.pending = None; + state.request_kind = Some(RequestKind::Comparison); + let mut catalog = comparison(); + catalog.files.truncate(1); + catalog.files[0].loaded = false; + state.apply_result(7, Ok(Outcome::Comparison(catalog))); + state.take_request(); + state.apply_result(8, Err("cannot read blob".into())); + assert_eq!(state.error.as_deref(), Some("cannot read blob")); + assert!(!state.selected_file_loaded()); + assert!(state.select_file(Path::new("renamed.txt"))); + assert!(state.loading && state.error.is_none()); + assert!(matches!( + state.take_request(), + Some(Operation::LoadFile { + comparison_id: 7, + file_index: 0, + }) + )); +} + +#[test] +fn mode_action_keeps_filter_input_and_rejects_superseded_comparisons() { + let mut editor = editor(); + open(&mut editor); + editor + .bind("normal", "m", Some("core.diff.mode.toggle".into())) + .unwrap(); + text(&mut editor, "m"); + assert_eq!(editor.diff().unwrap().query, "m"); + assert_eq!( + editor.diff().unwrap().comparison_mode, + ComparisonMode::BranchChanges + ); + editor.handle(Input::Ctrl('u')); + editor.handle(Input::Down); + editor.handle(Input::Enter); + editor.handle(Input::Down); + editor.handle(Input::Enter); + let (old, _) = editor.take_diff_request().unwrap(); + editor.execute("diff.mode.toggle"); + assert!(!editor.apply_diff_result(old, Err("superseded".into()))); + let (current, operation) = editor.take_diff_request().unwrap(); + assert!(matches!( + operation, + Operation::Compare { + mode: ComparisonMode::Snapshots, + .. + } + )); + assert!(editor.apply_diff_result(current, Err("retryable".into()))); + assert_eq!(editor.diff().unwrap().phase, Phase::After); + assert_eq!(editor.diff().unwrap().error.as_deref(), Some("retryable")); + editor.execute("diff.mode.toggle"); + let (_, operation) = editor.take_diff_request().unwrap(); + assert!(matches!( + operation, + Operation::Compare { + mode: ComparisonMode::BranchChanges, + .. + } + )); + assert!(editor.diff().unwrap().error.is_none()); +} + +#[test] +fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() { + use crate::review::{Anchor, Side}; + let mut state = live_state(vec!["old".into()], vec!["new".into()], true); + let anchor = Anchor { + revision: "working:snapshot".into(), + path: "file".into(), + side: Side::After, + start: crate::plugin::TextPosition { row: 0, column: 0 }, + end: crate::plugin::TextPosition { row: 1, column: 0 }, + content: "new\n".into(), + }; + assert!(state.review_anchor_current(&anchor)); + state.live_files.insert(0); + assert!(!state.review_anchor_current(&anchor)); + state.comparison.as_mut().unwrap().files[0] + .after + .push(String::new()); + assert!(state.review_anchor_current(&anchor)); + state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into(); + assert!(!state.review_anchor_current(&anchor)); + state.comparison.as_mut().unwrap().files[0].old_path = Some("original".into()); + let before = Anchor { + revision: "base".into(), + path: "original".into(), + side: Side::Before, + content: "old\n".into(), + ..anchor + }; + assert!(state.review_anchor_current(&before)); + state.comparison.as_mut().unwrap().files[0].loaded = false; + assert!(!state.review_anchor_current(&before)); + state.comparison.as_mut().unwrap().files[0].loaded = true; + state.comparison.as_mut().unwrap().revisions[0] = "other".into(); + assert!(!state.review_anchor_current(&before)); +} diff --git a/tests/unit/editor.rs b/tests/unit/editor.rs new file mode 100644 index 0000000..e60cc57 --- /dev/null +++ b/tests/unit/editor.rs @@ -0,0 +1,15220 @@ +use super::*; +use crate::plugin::CompletionEdit; +use std::collections::BTreeSet; + +#[test] +fn resolver_native_inputs_are_readonly_and_dirty_shared_results_survive_close() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("draft\n".into())]) + .unwrap(); + let result = editor.buffer_id(); + editor.open_resolver(); + let tab = editor.tab_id(); + editor.resolver_requests.clear(); + let resolver = editor.resolvers.get_mut(&tab).unwrap(); + resolver.loading = false; + resolver.files.push(ResolverFile { + path: "file.txt".into(), + draft_buffer: result, + status: ResolverStatus::Unsaved, + jj_conflicted: true, + text: true, + whole_file_choice: None, + error: None, + seed_source: 0, + projection: None, + diff: DiffState::new(), + comparison_key: None, + sources: vec![ResolverSource { + label: "Side #1".into(), + text: "input\n".into(), + base: false, + }], + }); + editor.select_resolver_file(0); + let source = editor.resolver().unwrap().alternative_pane.unwrap(); + editor.focus_resolver_pane(source); + for command in ["w", "w! /tmp/vamp-readonly-must-not-write", "buffer_delete"] { + editor.execute(command); + assert_eq!(editor.lines(), ["input", ""]); + } + assert!( + editor + .apply(vec![Effect::Insert("blocked".into())]) + .is_err() + ); + for input in [Input::Char('i'), Input::Char('u'), Input::Char('U')] { + editor.handle(input); + } + assert_eq!(editor.lines(), ["input", ""]); + editor.handle(Input::Char('/')); + editor.handle(Input::Char('i')); + editor.handle(Input::Enter); + assert_eq!(editor.active.view.search_query, "i"); + editor.execute("resolve.files"); + assert!(editor.file_picker().unwrap().focused); + editor.execute("resolve.close"); + assert_eq!(editor.tab_id(), tab); + editor.execute("pane.force_close"); + assert_eq!(editor.tab_id(), tab); + assert_eq!(editor.buffer(result).unwrap().lines, ["draft", ""]); + editor.execute("tab.previous"); + assert_eq!(editor.buffer_id(), result); + assert!(editor.dirty()); +} + +#[test] +fn resolver_loading_has_no_bound_diff_buffer() { + let mut editor = editor(); + let source = editor.buffer_id(); + editor.open_resolver(); + assert!(editor.resolver().unwrap().loading); + assert!(editor.diff().is_none()); + assert!(editor.diff_buffer_focus().is_none()); + editor.refresh_live_diff(); + assert!(editor.diff_buffer_focus().is_none()); + editor.execute("resolve.close"); + assert_eq!(editor.buffer_id(), source); + editor.refresh_live_diff(); + assert!(editor.diff_buffer_focus().is_none()); +} + +#[test] +fn resolver_rebuilding_panes_revokes_cached_diff_binding_until_ready() { + let mut editor = resolver_editor("draft", &[("draft", false), ("alternative", false)]); + let draft = editor.buffer_id(); + let alternative = editor + .resolvers + .get_mut(&editor.tab_id) + .unwrap() + .alternative_pane + .take(); + assert!(editor.diff().is_none()); + assert!(editor.diff_buffer_focus().is_none()); + editor.refresh_live_diff(); + assert_eq!(editor.buffer_id(), draft); + editor + .resolvers + .get_mut(&editor.tab_id) + .unwrap() + .alternative_pane = alternative; + editor.refresh_live_diff(); + assert_eq!(editor.diff_buffer_focus(), Some(Focus::Before)); + editor.execute("resolve.history"); + assert!(editor.diff().is_none()); + assert!(editor.diff_buffer_focus().is_none()); + editor.refresh_live_diff(); + editor.execute("resolve.draft"); + assert_eq!(editor.buffer_id(), draft); + assert_eq!(editor.diff_buffer_focus(), Some(Focus::Before)); +} + +#[test] +fn resolver_shares_native_excerpt_search_motions_and_explicit_writable_side() { + let directory = Directory::new(); + let mut before: Vec<_> = (0..90).map(|row| format!("row {row}")).collect(); + before[20] = "needle hidden".into(); + before[70] = "needle visible".into(); + let mut after = before.clone(); + for row in [10, 70] { + after[row].push_str(" alternative"); + } + let draft = before.join("\n"); + let alternative = after.join("\n"); + let mut resolver = resolver_editor(&draft, &[(&draft, false), (&alternative, false)]); + let mut ordinary = editor(); + historical_diff(&mut ordinary, &directory.0, before, after); + for editor in [&mut resolver, &mut ordinary] { + editor.set_diff_context_options(1, 2); + editor + .bind("normal", "M", Some("core.down".into())) + .unwrap(); + type_text(editor, "11gMM"); + assert_eq!(editor.cursor().row, 69); + type_text(editor, "/needle"); + editor.handle(Input::Enter); + assert_eq!(editor.cursor().row, 70); + let rows = editor.diff().unwrap().source_rows(Focus::Before).count(); + let cursor = editor.cursor(); + editor.handle(Input::Char(']')); + assert!(editor.diff().unwrap().source_rows(Focus::Before).count() > rows); + assert_eq!(editor.cursor(), cursor); + type_text(editor, "21g"); + assert_eq!(editor.cursor().row, 20); + assert!( + editor + .diff() + .unwrap() + .source_display_row(Focus::Before, 20) + .is_some() + ); + } + assert_eq!( + resolver.diff().unwrap().display_rows, + ordinary.diff().unwrap().display_rows + ); + assert!(resolver.diff_editing()); + assert!(resolver.diff().unwrap().side_editable(Focus::Before)); + assert!(!resolver.diff().unwrap().side_editable(Focus::After)); + let draft_buffer = resolver.buffer_id(); + resolver.handle(Input::Char('\t')); + assert_eq!(resolver.diff_buffer_focus(), Some(Focus::After)); + assert!(!resolver.diff_editing()); + let text = resolver.context().text; + type_text(&mut resolver, "ix"); + assert_eq!(resolver.context().text, text); + resolver.handle(Input::BackTab); + assert_eq!(resolver.buffer_id(), draft_buffer); + assert!(resolver.diff_editing()); +} + +#[test] +fn resolver_shared_layout_remaps_and_wheel_preserve_native_draft_identity() { + let draft = (0..50) + .map(|row| format!("draft {row}\n")) + .collect::(); + let alternative = draft.replace("draft 20", "alternative 20"); + let mut editor = resolver_editor(&draft, &[(&draft, false), (&alternative, false)]); + let buffer = editor.buffer_id(); + let cursor = editor.cursor(); + for (key, action) in [ + ("Q", "core.diff.view.toggle"), + ("W", "core.diff.orientation.toggle"), + ("E", "core.word_wrap.toggle"), + ] { + editor.bind("normal", key, Some(action.into())).unwrap(); + } + editor.handle(Input::Char('W')); + assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); + editor.handle(Input::Char('Q')); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); + editor.handle(Input::Char('E')); + assert!(editor.word_wrap()); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste("native ".into())); + let insert_cursor = editor.cursor(); + let diff = editor.diff().unwrap(); + assert!(diff.follow_caret); + editor.set_diff_viewport( + diff.comparison_id.unwrap(), + diff.file, + diff.top, + diff.left_before, + diff.left_after, + ); + assert!(editor.diff().unwrap().follow_caret); + editor.scroll_diff(Focus::Before, true); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.cursor(), insert_cursor); + assert_eq!(editor.buffer_id(), buffer); + assert!(!editor.diff().unwrap().follow_caret); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, draft); + assert_eq!(editor.cursor(), cursor); + editor.set_diff_pane_bounds([ + (Focus::Files, PaneRect::default()), + ( + Focus::Before, + PaneRect { + x: 0, + y: 0, + width: 80, + height: 10, + }, + ), + ( + Focus::After, + PaneRect { + x: 0, + y: 11, + width: 80, + height: 10, + }, + ), + ]); + editor.execute("pane.focus_down"); + assert_eq!(editor.diff_buffer_focus(), Some(Focus::After)); + editor.execute("pane.focus_up"); + assert_eq!(editor.buffer_id(), buffer); + assert!(editor.diff_editing()); +} + +fn resolver_editor(draft: &str, sources: &[(&str, bool)]) -> Editor { + let mut editor = editor(); + editor.apply(vec![Effect::Replace(draft.into())]).unwrap(); + let draft_buffer = editor.buffer_id(); + editor.open_resolver(); + editor.resolver_requests.clear(); + let resolver = editor.resolvers.get_mut(&editor.tab_id).unwrap(); + resolver.loading = false; + resolver.files.push(ResolverFile { + path: "conflict.txt".into(), + draft_buffer, + status: ResolverStatus::Unsaved, + jj_conflicted: true, + text: true, + whole_file_choice: None, + error: None, + seed_source: sources.iter().position(|(_, base)| !base).unwrap_or(0), + projection: None, + diff: DiffState::new(), + comparison_key: None, + sources: sources + .iter() + .enumerate() + .map(|(index, (text, base))| ResolverSource { + label: format!("Source {}", index + 1), + text: (*text).into(), + base: *base, + }) + .collect(), + }); + editor.select_resolver_file(0); + editor +} + +#[test] +fn resolver_explorer_retains_last_jj_conflict_state_until_confirmation() { + let mut editor = resolver_editor("draft", &[("draft", false), ("other", false)]); + let path = Path::new("conflict.txt"); + let marker = |editor: &Editor| { + editor + .resolver_workspace_diff() + .unwrap() + .files + .as_ref() + .unwrap() + .is_conflicted(path) + }; + assert_eq!( + editor.resolver().unwrap().files[0].status, + ResolverStatus::Unsaved + ); + assert!(marker(&editor)); + for confirmed in [ + crate::resolve::Status::Unresolved, + crate::resolve::Status::Resolved, + crate::resolve::Status::Unresolved, + ] { + editor.resolvers.get_mut(&editor.tab_id).unwrap().files[0].apply_jj_status(confirmed); + editor.refresh_resolver_status(); + let conflicted = confirmed == crate::resolve::Status::Unresolved; + assert_eq!(marker(&editor), conflicted); + assert_eq!( + editor.resolver().unwrap().files[0].status, + ResolverStatus::Unsaved + ); + + editor.resolvers.get_mut(&editor.tab_id).unwrap().files[0].status = + ResolverStatus::Checking; + editor.refresh_resolver_status(); + assert_eq!( + editor.resolver().unwrap().files[0].status, + ResolverStatus::Checking + ); + assert_eq!(marker(&editor), conflicted); + + editor.resolvers.get_mut(&editor.tab_id).unwrap().files[0] + .apply_jj_status(crate::resolve::Status::Unconfirmed); + assert_eq!( + editor.resolver().unwrap().files[0].status, + ResolverStatus::Unconfirmed + ); + editor.active.saved_lines.clone_from(&editor.active.lines); + editor.refresh_resolver_status(); + assert_eq!( + editor.resolver().unwrap().files[0].status, + ResolverStatus::Unconfirmed + ); + assert_eq!(marker(&editor), conflicted); + editor.apply(vec![Effect::Insert(" edit".into())]).unwrap(); + editor.refresh_resolver_status(); + assert_eq!( + editor.resolver().unwrap().files[0].status, + ResolverStatus::Unsaved + ); + assert_eq!(marker(&editor), conflicted); + + editor.select_resolver_file(0); + assert_eq!(marker(&editor), conflicted); + } +} + +#[test] +fn resolver_takes_only_current_diff_hunk_and_undo_preserves_native_newlines() { + for (draft, alternative, chosen) in [ + ( + "before\nleft\nshared\ntail\n", + "before\nright\nshared\nother\n", + "before\nright\nshared\ntail\n", + ), + ("a\n", "a", "a"), + ("a", "a\n", "a\n"), + ("a\nlast", "a\n", "a\n"), + ("", "new", "new"), + ("old", "", ""), + ("\n", "", ""), + ] { + let mut editor = resolver_editor(draft, &[(draft, false), (alternative, false)]); + editor.execute("resolve.use_source 2"); + assert_eq!(editor.context().text, chosen); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, draft); + } +} + +#[test] +fn resolver_history_selects_every_side_and_base_without_moving_draft_or_viewport() { + let draft = (0..40) + .map(|row| format!("draft {row}\n")) + .collect::(); + let mut editor = resolver_editor( + &draft, + &[ + ("base one", true), + (&draft, false), + ("base two", true), + ("alternative one", false), + ("alternative two", false), + ], + ); + editor.active.view.targets = vec![Target::caret(Cursor { row: 20, column: 3 })]; + let diff = editor.diff().unwrap(); + editor.set_diff_viewport(diff.comparison_id.unwrap(), diff.file, 8, 2, 4); + let cursor = editor.cursor(); + let buffer = editor.buffer_id(); + let id = editor.resolver_diff().unwrap().comparison_id; + assert!(editor.file_picker().is_none()); + editor.execute("word_wrap.toggle"); + assert!(editor.word_wrap()); + editor.execute("resolve.history"); + assert_eq!(editor.resolver().unwrap().view, ResolverView::History); + assert!( + editor + .apply(vec![Effect::Insert("blocked".into())]) + .is_err() + ); + editor.execute("resolve.source 5"); + assert_eq!(editor.lines(), ["alternative two"]); + assert_eq!(editor.resolver().unwrap().history_sources[0], 4); + editor.execute("resolve.base 2"); + assert_eq!(editor.lines(), ["base two"]); + assert_eq!(editor.resolver().unwrap().selected_base, Some(2)); + editor.execute("resolve.draft"); + assert_eq!(editor.context().text, draft); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.buffer_id(), buffer); + let diff = editor.resolver_diff().unwrap(); + assert_eq!(diff.comparison_id, id); + assert_eq!((diff.top, diff.left_before, diff.left_after), (8, 2, 4)); + assert!(editor.word_wrap()); + editor.handle(Input::Char('\t')); + assert_eq!(editor.resolver_buffer_focus(), Some(Focus::After)); + editor.handle(Input::Char('\t')); + assert_eq!(editor.resolver_buffer_focus(), Some(Focus::Before)); + assert!(!editor.file_picker().is_some_and(|picker| picker.focused)); + editor.execute("resolve.files"); + assert!(editor.file_picker().unwrap().focused); + editor.execute("resolve.files"); + assert!(editor.file_picker().is_none()); +} + +#[test] +fn resolver_equal_choice_keeps_draft_editable_and_decision_navigation_wraps() { + let mut editor = resolver_editor( + "left\nsame\nlast\n", + &[ + ("left\nsame\nlast\n", false), + ("right\nsame\nother\n", false), + ], + ); + assert_eq!(editor.cursor().row, 0); + editor.execute("resolve.next"); + assert_eq!(editor.cursor().row, 2); + editor.execute("resolve.next"); + assert_eq!(editor.cursor().row, 0); + editor.execute("resolve.previous"); + assert_eq!(editor.cursor().row, 2); + editor.execute("resolve.use_source 2"); + editor.execute("resolve.next"); + editor.execute("resolve.use_source 2"); + let diff = editor.resolver_diff().unwrap(); + assert!(diff.comparison.as_ref().unwrap().files[0].hunks.is_empty()); + assert!( + diff.source_display_row(Focus::Before, editor.cursor().row) + .is_some() + ); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste("native ".into())); + editor.handle(Input::Escape); + assert!(editor.context().text.starts_with("native right")); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "right\nsame\nother\n"); +} + +#[test] +fn resolver_file_switches_restore_native_draft_and_refresh_readonly_alternative() { + let mut editor = resolver_editor( + "first\n", + &[("first\n", false), ("first alternative\n", false)], + ); + editor.active.view.targets = vec![Target::caret(Cursor { row: 0, column: 2 })]; + let cursor = editor.cursor(); + let comparison = editor.resolver_diff().unwrap().comparison_id; + let buffer = editor.next_buffer_id; + editor.next_buffer_id += 1; + editor.parked.insert( + buffer, + Buffer::from_text(buffer, None, None, "second\n", None).unwrap(), + ); + let resolver = editor.resolvers.get_mut(&editor.tab_id).unwrap(); + resolver.files.push(ResolverFile { + path: "second.txt".into(), + draft_buffer: buffer, + status: ResolverStatus::Unresolved, + jj_conflicted: true, + text: true, + whole_file_choice: None, + error: None, + seed_source: 0, + projection: None, + diff: DiffState::new(), + comparison_key: None, + sources: ["second\n", "second alternative\n"] + .into_iter() + .map(|text| ResolverSource { + label: text.into(), + text: text.into(), + base: false, + }) + .collect(), + }); + editor.select_resolver_file(0); + let rows = editor.diff().unwrap().display_rows.clone(); + editor + .bind("normal", "P", Some("core.file_picker".into())) + .unwrap(); + editor.handle(Input::Char('P')); + type_text(&mut editor, "second"); + editor.handle(Input::Enter); + assert_eq!(editor.resolver().unwrap().current_file, 1); + assert_eq!(editor.diff().unwrap().file, 1); + assert_eq!( + editor + .diff() + .unwrap() + .comparison + .as_ref() + .unwrap() + .files + .len(), + 2 + ); + assert_eq!(editor.context().text, "second\n"); + assert_ne!(editor.resolver_diff().unwrap().comparison_id, comparison); + editor.handle(Input::Char('P')); + editor.handle(Input::Ctrl('u')); + type_text(&mut editor, "conflict"); + editor.handle(Input::Enter); + assert_eq!(editor.resolver().unwrap().current_file, 0); + assert_eq!(editor.diff().unwrap().display_rows, rows); + assert_eq!(editor.context().text, "first\n"); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.resolver_diff().unwrap().comparison_id, comparison); + let pane = editor.resolver().unwrap().alternative_pane.unwrap(); + editor.focus_resolver_pane(pane); + assert_eq!(editor.context().text, "first alternative\n"); +} + +struct Directory(PathBuf); +impl Directory { + fn new() -> Self { + let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("vamp-editor-test-{}-{id}", std::process::id())); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} +impl Drop for Directory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn editor() -> Editor { + let mut editor = Editor::open(None, vec!["text".into()]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + editor +} +#[test] +fn copy_line_action_and_y_prefix_respect_bindings_and_cancellation() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("one\ntwo".into())]) + .unwrap(); + editor + .bind("normal", "q", Some("core.copy_line".into())) + .unwrap(); + assert!( + editor + .bind("visual", "q", Some("core.copy_line".into())) + .is_err() + ); + assert!( + editor + .bind("insert", "q", Some("core.copy_line".into())) + .is_err() + ); + type_text(&mut editor, "q"); + assert_eq!(editor.clipboard, ["one\n"]); + type_text(&mut editor, "2y3"); + assert_eq!(editor.line_operator_pending(), Some('y')); + assert_eq!(editor.count(), Some(6)); + editor.handle(Input::Escape); + assert_eq!(editor.line_operator_pending(), None); + assert_eq!(editor.count(), None); + editor + .bind("normal", "y", Some("core.down".into())) + .unwrap(); + type_text(&mut editor, "y"); + assert_eq!(editor.cursor().row, 1); + assert_eq!(editor.line_operator_pending(), None); + editor.bind("normal", "y", None).unwrap(); + type_text(&mut editor, "yy"); + assert_eq!(editor.clipboard, ["one\n"]); +} + +#[test] +fn copy_line_restores_overlapping_multicarets_and_preserves_redo_and_repeat() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("one\ntwo\nthree".into())]) + .unwrap(); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + editor.active.view.targets = vec![ + Target { + anchor: Cursor { row: 0, column: 1 }, + active: Cursor { row: 0, column: 1 }, + preferred_columns: Some((1, 1)), + }, + Target { + anchor: Cursor { row: 1, column: 2 }, + active: Cursor { row: 1, column: 2 }, + preferred_columns: Some((2, 2)), + }, + ]; + editor.active.view.primary = 1; + let targets = editor.active.view.targets.clone(); + let history = format!( + "{:?}", + (&editor.active.undo, &editor.active.redo, &editor.last_edit) + ); + let revision = editor.text_revision(); + type_text(&mut editor, "2yy"); + assert_eq!(editor.clipboard, ["one\ntwo\nthree\n"]); + assert!(editor.clipboard_linewise); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, 1); + assert_eq!(editor.mode(), Mode::Normal); + assert!(!editor.active.view.linewise); + assert_eq!(editor.text_revision(), revision); + assert_eq!( + format!( + "{:?}", + (&editor.active.undo, &editor.active.redo, &editor.last_edit) + ), + history + ); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "Xone\ntwo\nthree"); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "XXone\ntwo\nthree"); +} + +fn type_text(editor: &mut Editor, text: &str) { + for ch in text.chars() { + editor.handle(if ch == '\n' { + Input::Enter + } else { + Input::Char(ch) + }); + } +} + +#[test] +fn external_reload_clamps_shared_views_and_restarts_clean_insert_history() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "one\ntwo\nthree").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor + .apply(vec![Effect::Replace("old edit".into())]) + .unwrap(); + editor.handle(Input::Char('u')); + type_text(&mut editor, "gb$"); + editor.execute("tab.new"); + editor.handle(Input::Char('|')); + let pane = editor.pane_id(); + let revision = editor.text_revision(); + fs::write(&path, "é").unwrap(); + assert!(editor.refresh_files()); + assert_eq!(editor.lines(), ["é"]); + assert!(!editor.dirty()); + assert!(editor.text_revision() > revision); + assert_eq!(editor.cursor(), Cursor::default()); + editor.handle(Input::Alt('h')); + assert_ne!(editor.pane_id(), pane); + assert_eq!(editor.cursor(), Cursor::default()); + editor.execute("tab.previous"); + assert_eq!(editor.cursor(), Cursor::default()); + editor.handle(Input::Char('U')); + assert_eq!(editor.lines(), ["é"]); + editor.handle(Input::Char('i')); + fs::write(&path, "😀ZZ").unwrap(); + assert!(editor.refresh_files()); + assert_eq!(editor.mode(), Mode::Insert); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["😀ZZ"]); + assert!(!editor.dirty()); +} + +#[test] +fn external_dirty_writes_require_force_even_without_a_poll() { + for (normal, force, quits) in [ + ("w", "w!", false), + ("save", "force_save", false), + ("wq", "wq!", true), + ("save_and_quit", "force_save_and_quit", true), + ] { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "baseline").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor + .apply(vec![Effect::Replace("local edits".into())]) + .unwrap(); + fs::write(&path, "external").unwrap(); + assert!(!editor.refresh_files()); + editor.execute(normal); + assert_eq!(fs::read_to_string(&path).unwrap(), "external"); + assert_eq!(editor.lines(), ["local edits"]); + assert!(editor.dirty()); + assert!(!editor.should_quit()); + assert!(editor.message().contains(":w!")); + editor.execute(force); + assert_eq!(fs::read_to_string(&path).unwrap(), "local edits"); + assert!(!editor.dirty()); + assert_eq!(editor.should_quit(), quits); + } +} + +#[test] +fn external_deletion_is_not_a_new_file_and_invalid_reload_preserves_text() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + fs::remove_file(&path).unwrap(); + assert!(editor.refresh_files()); + assert!(!editor.refresh_files()); + editor.execute("w"); + assert!(!path.exists()); + editor.execute("w!"); + assert_eq!(fs::read_to_string(&path).unwrap(), ""); + fs::write(&path, b"\xff").unwrap(); + let revision = editor.text_revision(); + assert!(editor.refresh_files()); + assert_eq!(editor.text_revision(), revision); + assert_eq!(editor.lines(), [""]); + editor.execute("wq"); + assert!(!editor.should_quit()); + assert_eq!(fs::read(&path).unwrap(), b"\xff"); + fs::write(&path, "recovered\r\n").unwrap(); + assert!(editor.refresh_files()); + assert_eq!(editor.lines(), ["recovered", ""]); + assert!(!editor.dirty()); + let missing = directory.path("new.txt"); + let mut new = Editor::open(Some(missing.clone()), vec![]).unwrap(); + new.execute("w"); + assert_eq!(fs::read_to_string(missing).unwrap(), ""); +} + +#[test] +fn external_same_text_keeps_history_and_newline_normalization_allows_save() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "same\r\n").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor + .apply(vec![Effect::Replace("old edit".into())]) + .unwrap(); + editor.handle(Input::Char('u')); + let revision = editor.text_revision(); + fs::write(&path, "same\n").unwrap(); + assert!(!editor.refresh_files()); + assert_eq!(editor.text_revision(), revision); + editor.handle(Input::Char('U')); + assert_eq!(editor.lines(), ["old edit"]); + editor.execute("w"); + assert_eq!(fs::read_to_string(path).unwrap(), "old edit"); + assert!(!editor.dirty()); +} + +#[cfg(unix)] +#[test] +fn external_atomic_replacement_with_same_size_and_mtime_reloads() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + let replacement = directory.path("replacement"); + let fixed = std::time::UNIX_EPOCH + std::time::Duration::from_secs(123); + fs::write(&path, "before").unwrap(); + fs::File::open(&path).unwrap().set_modified(fixed).unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + fs::write(&replacement, "after!").unwrap(); + fs::File::open(&replacement) + .unwrap() + .set_modified(fixed) + .unwrap(); + fs::rename(&replacement, &path).unwrap(); + assert!(editor.refresh_files()); + assert_eq!(editor.lines(), ["after!"]); +} + +#[test] +fn external_reload_refreshes_working_diff_but_not_historical_snapshots() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "disk").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + fs::write(&path, "external working").unwrap(); + assert!(editor.refresh_files()); + assert_eq!( + editor.diff().unwrap().comparison.as_ref().unwrap().files[0].after, + ["external working"], + ); + editor.execute("diff.close"); + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["after".into()], + ); + let revision = editor.text_revision(); + fs::write(&path, "new disk").unwrap(); + assert!(editor.refresh_files()); + assert_eq!(editor.lines(), ["before"]); + assert_eq!(editor.text_revision(), revision); + editor.execute("w!"); + assert_eq!(fs::read_to_string(path).unwrap(), "new disk"); +} + +#[test] +fn guarded_restart_closes_historical_views_without_losing_dirty_source_history() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "disk").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor + .apply(vec![Effect::Replace("unsaved".into())]) + .unwrap(); + let dirty = editor.buffer_id(); + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["after".into()], + ); + editor.execute("tab.new"); + let snapshot = editor.buffer_id(); + assert!(editor.active.diff_snapshot.is_some()); + editor.execute("restart"); + assert!(!editor.should_restart()); + assert!(!editor.has_buffer(snapshot)); + assert_eq!(editor.buffer_id(), dirty); + assert_eq!(editor.lines(), ["unsaved"]); + assert!(editor.diff().is_none()); + assert!(editor.tabs.values().all(|tab| tab.diff().is_none())); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["disk"]); + assert!(!editor.dirty()); + assert_eq!(fs::read_to_string(path).unwrap(), "disk"); + editor.execute("restart"); + assert!(editor.should_restart()); + assert!(editor.should_quit()); +} + +#[test] +fn notification_escape_preserves_carets_then_ordinary_escape_collapses_them() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcd\nabcd".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 1), (1, 1)], 1); + assert!(editor.notification_visible()); + editor.set_message("Written"); + editor.handle(Input::Right); + let targets = editor.active.view.targets.clone(); + let revision = editor.text_revision(); + let history = (editor.active.undo.len(), editor.active.redo.len()); + editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + assert_eq!(editor.message(), "Written"); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, 1); + assert_eq!(editor.text_revision(), revision); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + history + ); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 1); + editor.set_message("Written"); + assert!(editor.notification_visible()); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + assert!(editor.notification_visible()); + assert_eq!(editor.message(), "Written"); + editor.handle(Input::Char('v')); + editor.handle(Input::Escape); + assert!(editor.notification_visible()); + for prompt in [':', '/'] { + editor.handle(Input::Char(prompt)); + editor.handle(Input::Char('x')); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Normal); + assert!(editor.notification_visible()); + } +} + +#[test] +fn notification_escape_waits_for_prefixes_and_focused_overlays() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcd\nabcd".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 1), (1, 1)], 1); + editor.set_message("Written"); + let targets = editor.active.view.targets.clone(); + for prefix in ["f", "g", "2d", "4", " b"] { + type_text(&mut editor, prefix); + editor.handle(Input::Escape); + assert!(editor.notification_visible(), "{prefix}"); + assert_eq!(editor.active.view.targets, targets, "{prefix}"); + assert_eq!(editor.count(), None); + } + editor.show_startup_diagnostic("startup".into()); + editor.handle(Input::Escape); + assert!(editor.startup_diagnostic().is_none()); + assert!(editor.notification_visible()); + editor.show_hover("hover".into()).unwrap(); + editor.handle(Input::Escape); + assert!(editor.hover().is_none()); + assert!(editor.notification_visible()); + editor.handle(Input::Ctrl('p')); + editor.handle(Input::Escape); + assert!(editor.palette().is_none()); + assert!(editor.notification_visible()); + editor.open_file_picker(FilePresentation::Docked); + editor.handle(Input::Char('i')); + assert!(editor.file_picker().unwrap().searching); + editor.handle(Input::Escape); + assert!(editor.file_picker().unwrap().focused); + assert!(editor.notification_visible()); + editor.handle(Input::Escape); + assert!(!editor.file_picker().unwrap().focused); + assert!(editor.notification_visible()); + assert_eq!(editor.active.view.targets, targets); + editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + assert_eq!(editor.active.view.targets, targets); +} + +#[test] +fn docked_picker_leaders_replace_or_blur_consistently_without_stealing_filter_text() { + let directory = Directory::new(); + fs::write(directory.path("file.txt"), "source").unwrap(); + for comparison in [false, true] { + let mut editor = editor(); + editor.project_root = directory.0.clone(); + if comparison { + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["after".into()], + ); + } + type_text(&mut editor, " e"); + assert!(editor.file_picker().unwrap().focused); + assert!(!editor.file_picker().unwrap().searching); + type_text(&mut editor, " o"); + assert_eq!( + editor.file_picker().unwrap().presentation, + FilePresentation::Floating + ); + assert!(editor.file_picker().unwrap().searching); + editor.handle(Input::Escape); + editor.handle(Input::Escape); + type_text(&mut editor, " e"); + let selected = editor.file_picker().unwrap().selected; + editor.handle(Input::Escape); + let escape_focus = editor.diff().map(|diff| diff.focus); + type_text(&mut editor, " e"); + assert!(editor.file_picker().unwrap().focused); + type_text(&mut editor, " e"); + assert!(!editor.file_picker().unwrap().focused); + assert_eq!( + editor.file_picker().unwrap().presentation, + FilePresentation::Docked + ); + assert_eq!(editor.file_picker().unwrap().selected, selected); + assert_eq!(editor.diff().map(|diff| diff.focus), escape_focus); + type_text(&mut editor, " e"); + editor.handle(Input::Char('i')); + type_text(&mut editor, " o e"); + assert_eq!(editor.file_picker().unwrap().query, " o e"); + assert!(editor.file_picker().unwrap().searching); + assert_eq!( + editor.file_picker().unwrap().presentation, + FilePresentation::Docked + ); + } +} + +#[test] +fn focused_text_fields_keep_printable_normal_bindings_as_text() { + let directory = Directory::new(); + fs::write(directory.path("sample.txt"), "text").unwrap(); + for field in [ + "floating", + "docked", + "diff_tree", + "diff_list", + "palette", + "command", + "search", + ] { + let mut editor = editor(); + editor.project_root = directory.0.clone(); + editor + .bind("normal", "x", Some("core.pane.split_vertical".into())) + .unwrap(); + match field { + "floating" => editor.open_file_picker(FilePresentation::Floating), + "docked" => { + editor.open_file_picker(FilePresentation::Docked); + editor.handle(Input::Char('i')); + } + "diff_tree" | "diff_list" => { + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["after".into()], + ); + if field == "diff_list" { + editor.set_file_explorer_view("list").unwrap(); + } + editor.open_file_picker(FilePresentation::Docked); + if field == "diff_tree" { + editor.handle(Input::Char('i')); + } + } + "palette" => { + editor.handle(Input::Ctrl('p')); + } + "command" => { + editor.handle(Input::Char(':')); + } + "search" => { + editor.handle(Input::Char('/')); + } + _ => unreachable!(), + } + let document = editor.lines().to_vec(); + editor.handle(Input::Char(':')); + editor.handle(Input::Char('x')); + let query = match field { + "palette" => editor.palette().unwrap().query.as_str(), + "command" | "search" => editor.command_line(), + _ => editor.file_picker().unwrap().query.as_str(), + }; + assert_eq!(query, ":x", "{field}"); + assert_eq!(editor.pane_layout().len(), 1, "{field}"); + assert_eq!(editor.lines(), document, "{field}"); + } +} + +#[test] +fn empty_notification_does_not_swallow_escape_but_cursor_diagnostic_does() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcd\nabcd".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 1), (1, 1)], 1); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 1); + assert!(editor.notification_visible()); + editor + .replace_diagnostics( + "fixture", + editor.buffer_id(), + editor.text_revision(), + vec![annotation((1, 0), (1, 3), "diagnostic")], + ) + .unwrap(); + set_carets(&mut editor, &[(0, 1), (1, 1)], 1); + editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "diagnostic"); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 1); +} + +#[test] +fn hidden_pane_interactions_cannot_reopen_or_dismiss_focused_notifications() { + let mut editor = editor(); + let hidden = editor.pane_id(); + editor.execute("pane.split_vertical"); + editor.set_message("focused"); + editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + editor.with_pane(hidden, |pane| pane.set_message("background")); + assert_eq!(editor.message(), "focused"); + assert!(!editor.notification_visible()); + editor.with_render_pane(hidden, |pane| { + assert!(pane.message().is_empty()); + }); + assert!(!editor.notification_visible()); + editor.set_message("focused"); + editor.with_pane(hidden, |pane| { + pane.set_message("background"); + pane.handle(Input::Escape); + }); + assert!(editor.notification_visible()); + assert_eq!(editor.message(), "focused"); + editor.execute("tab.new"); + editor.set_message("new tab"); + editor.handle(Input::Escape); + editor.with_pane(hidden, |pane| pane.set_message("hidden tab")); + assert_eq!(editor.message(), "new tab"); + assert!(!editor.notification_visible()); +} + +#[test] +fn buffer_activation_preserves_last_operation_and_dismissal() { + let directory = Directory::new(); + let path = directory.path("opened.txt"); + fs::write(&path, "text").unwrap(); + let mut editor = editor(); + editor.set_message("Written"); + editor.execute(&format!("edit {}", path.display())); + assert_eq!(editor.message(), "Written"); + editor.handle(Input::Escape); + for command in ["buffer 1", "bnext", "bprev", "buffer 1"] { + editor.execute(command); + assert_eq!(editor.message(), "Written"); + assert!(!editor.notification_visible()); + } + editor.execute("buffer 999"); + assert!(editor.message().contains("No buffer")); + assert!(editor.notification_visible()); +} + +#[test] +fn native_lsp_cache_is_independent_presentation_metadata() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("document".into())]) + .unwrap(); + type_text(&mut editor, "vl"); + editor.set_message("Written"); + editor.notification_visible = false; + editor + .set_label("lsp".into(), "configured label".into()) + .unwrap(); + let targets = editor.active.view.targets.clone(); + let revision = editor.text_revision(); + let history = (editor.active.undo.len(), editor.active.redo.len()); + assert!(editor.lsp_names().is_empty()); + assert_eq!(editor.lsp_label(), ""); + assert!(editor.set_lsp_names(vec!["rust-analyzer".into(), "界 server".into()])); + assert_eq!(editor.lsp_names(), ["rust-analyzer", "界 server"]); + assert_eq!(editor.lsp_label(), "rust-analyzer + 界 server"); + assert!(!editor.set_lsp_names(vec!["rust-analyzer".into(), "界 server".into()])); + assert!(editor.set_lsp_names(vec!["unsafe\nname".into()])); + assert_eq!(editor.lsp_label(), "unsafe�name"); + assert!(editor.set_lsp_names(Vec::new())); + assert_eq!(editor.lsp_label(), ""); + assert_eq!(editor.label("lsp"), "configured label"); + assert_eq!(editor.message(), "Written"); + assert!(!editor.notification_visible()); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.text_revision(), revision); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + history + ); +} + +#[test] +fn pane_focus_remaps_do_not_steal_printable_picker_filter_input() { + let directory = Directory::new(); + fs::write(directory.path("hello.txt"), "alpha").unwrap(); + let mut editor = editor(); + editor.project_root = directory.0.clone(); + editor.handle(Input::Char('|')); + editor + .bind("normal", "h", Some("core.pane.focus_left".into())) + .unwrap(); + editor.open_file_picker(FilePresentation::Floating); + editor.handle(Input::Char('h')); + assert_eq!(editor.pane_id(), 2); + assert_eq!(editor.file_picker().unwrap().query, "h"); +} + +#[test] +fn pane_focus_bindings_escape_focused_explorer_without_changing_documents() { + let directory = Directory::new(); + fs::write(directory.path("a.txt"), "alpha").unwrap(); + fs::write(directory.path("b.txt"), "beta").unwrap(); + let mut editor = editor(); + editor.project_root = directory.0.clone(); + editor.handle(Input::Char('|')); + editor.handle(Input::Char('_')); + let original = editor.lines().to_vec(); + for (key, pane) in [('k', 2), ('h', 1), ('l', 2), ('j', 3)] { + editor.open_file_picker(FilePresentation::Docked); + assert!(editor.file_picker().unwrap().focused); + editor.handle(Input::Alt(key)); + assert_eq!(editor.pane_id(), pane); + assert!(!editor.file_picker().unwrap().focused); + assert_eq!(editor.lines(), original); + } +} + +#[test] +fn explorer_navigation_overrides_take_precedence_over_pane_focus() { + for context in ["tree", "selection"] { + for target in [None, Some("core.down")] { + let directory = Directory::new(); + fs::write(directory.path("a.txt"), "alpha").unwrap(); + fs::write(directory.path("b.txt"), "beta").unwrap(); + let mut editor = editor(); + editor.project_root = directory.0.clone(); + editor.handle(Input::Char('|')); + editor + .bind(context, "alt+h", target.map(str::to_owned)) + .unwrap(); + editor.open_file_picker(FilePresentation::Docked); + editor.handle(Input::Alt('h')); + assert_eq!(editor.pane_id(), 2); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused); + assert_eq!(picker.selected, usize::from(target.is_some())); + } + } +} + +#[test] +fn nested_panes_inherit_views_and_focus_geometrically_without_touching_ctrl_j() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abc\ndef\nghi".into())]) + .unwrap(); + set_carets(&mut editor, &[(1, 1)], 0); + editor.handle(Input::Char('|')); + assert_eq!(editor.pane_id(), 2); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); + editor.handle(Input::Char('_')); + assert_eq!(editor.pane_layout().len(), 3); + editor.handle(Input::Alt('k')); + assert_eq!(editor.pane_id(), 2); + editor.handle(Input::Alt('h')); + assert_eq!(editor.pane_id(), 1); + editor.handle(Input::Alt('l')); + assert_eq!(editor.pane_id(), 2); + assert_eq!(parse_key("alt+j").unwrap(), Input::Alt('j')); + assert_eq!(parse_key("option+K").unwrap(), Input::Alt('k')); + editor.handle(Input::Char('i')); + editor.handle(Input::Ctrl('j')); + assert_eq!(editor.lines(), ["abc", "d", "ef", "ghi"]); + editor.handle(Input::Escape); +} + +#[test] +fn panes_share_edits_saved_state_and_undo_but_rebase_independent_visual_views() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcdef".into())]) + .unwrap(); + editor.handle(Input::Char('|')); + set_ranges( + &mut editor, + &[(Cursor { row: 0, column: 2 }, Cursor { row: 0, column: 4 })], + 0, + ); + let selected = editor.selections(); + let selected_cursor = editor.cursor(); + editor.execute("pane.focus_left"); + set_carets(&mut editor, &[(0, 0)], 0); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + assert_eq!(editor.documents().count(), 1); + let focused = editor.pane_id(); + let lines_pointer = editor.lines().as_ptr(); + editor.with_render_pane(2, |pane| { + assert!(!pane.render_pane_focused()); + assert_eq!(pane.mode(), Mode::Visual); + assert_eq!(pane.lines().as_ptr(), lines_pointer); + assert_eq!(pane.cursor().column, selected_cursor.column + 1); + assert_eq!( + pane.selections()[0].start.column, + selected[0].start.column + 1 + ); + assert!(pane.dirty()); + }); + assert_eq!(editor.pane_id(), focused); + editor.execute("pane.focus_right"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["abcdef"]); + editor.execute("pane.focus_left"); + editor.handle(Input::Char('U')); + assert_eq!(editor.lines(), ["Xabcdef"]); + let directory = Directory::new(); + editor.execute(&format!("save {}", directory.path("saved.txt").display())); + assert!(!editor.dirty()); + editor.with_render_pane(2, |pane| assert!(!pane.dirty())); + editor.apply(vec![Effect::Replace("z".into())]).unwrap(); + editor.with_render_pane(2, |pane| assert_eq!(pane.cursor(), Cursor::default())); +} + +#[test] +fn panes_keep_modes_search_and_wrap_local_and_close_exclusive_clean_buffers() { + let directory = Directory::new(); + let path = directory.path("clean.txt"); + fs::write(&path, "one\ntwo\nthree").unwrap(); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.handle(Input::Char('|')); + submit_search(&mut editor, "two"); + editor.execute("word_wrap.toggle"); + editor.execute("pane.focus_left"); + assert!(!editor.word_wrap()); + assert!(editor.active.view.search_query.is_empty()); + editor.handle(Input::Char('i')); + type_text(&mut editor, "X"); + editor.execute("pane.focus_right"); + assert_eq!(editor.active.view.search_query, "two"); + assert!(editor.word_wrap()); + assert_eq!(editor.mode(), Mode::Normal); + editor.execute("pane.focus_left"); + assert_eq!(editor.mode(), Mode::Insert); + type_text(&mut editor, "Y"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["Xone", "two", "three"]); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["one", "two", "three"]); + editor.execute(&format!("edit {}", directory.path("second.txt").display())); + let exclusive = editor.buffer_id(); + editor.execute("pane.close"); + assert_eq!(editor.pane_layout().len(), 1); + assert!(!editor.has_buffer(exclusive)); + assert_eq!(editor.buffer_id(), 1); +} + +#[test] +fn tabs_preserve_panes_explorers_and_independent_views_of_shared_history() { + let directory = Directory::new(); + let mut editor = editor(); + editor.project_root = directory.0.clone(); + editor + .apply(vec![Effect::Replace("abcdef".into())]) + .unwrap(); + editor.execute("pane.split_vertical"); + let source_pane = editor.pane_id(); + set_carets(&mut editor, &[(0, 2)], 0); + editor.open_file_picker(FilePresentation::Docked); + let layout = format!("{:?}", editor.pane_layout()); + editor.execute("tab.new"); + let new_pane = editor.pane_id(); + assert_ne!(source_pane, new_pane); + assert_eq!(editor.tab_index(), 1); + assert_eq!(editor.tab_count(), 2); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.buffers().count(), 1); + assert_eq!(editor.pane_layout().len(), 1); + assert!(editor.file_picker().is_none()); + set_carets(&mut editor, &[(0, 4)], 0); + editor.handle(Input::CtrlAlt('h')); + assert_eq!(editor.pane_id(), source_pane); + assert_eq!(format!("{:?}", editor.pane_layout()), layout); + assert_eq!(editor.cursor().column, 2); + assert!(editor.file_picker().unwrap().focused); + editor.handle(Input::CtrlAlt('l')); + assert_eq!(editor.pane_id(), new_pane); + assert_eq!(editor.cursor().column, 4); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + editor.handle(Input::CtrlAlt('l')); + assert_eq!(editor.tab_index(), 0); + assert_eq!(editor.lines(), ["abcdXef"]); + assert_eq!(editor.cursor().column, 2); + editor.blur_file_picker(); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["abcdef"]); + editor.handle(Input::CtrlAlt('h')); + assert_eq!(editor.tab_index(), 1); + assert_eq!(editor.lines(), ["abcdef"]); + assert_eq!(editor.pane_buffer_id(source_pane), Some(1)); + assert_eq!(editor.tab_buffer(0).unwrap().id, 1); + assert_eq!(editor.tab_buffer(1).unwrap().id, 1); + assert!(editor.tab_buffer(2).is_none()); +} + +#[test] +fn tab_close_guards_final_dirty_owners_and_force_keeps_shared_documents() { + let directory = Directory::new(); + let mut editor = editor(); + editor.apply(vec![Effect::Insert("shared".into())]).unwrap(); + editor.execute("tab.new"); + editor.execute("tab.force_close"); + assert_eq!(editor.tab_count(), 1); + assert!(editor.has_buffer(1)); + assert!(editor.dirty()); + editor.execute("tab.new"); + editor.execute(&format!( + "edit {}", + directory.path("exclusive.txt").display() + )); + let exclusive = editor.buffer_id(); + editor + .apply(vec![Effect::Insert("exclusive".into())]) + .unwrap(); + editor.execute("pane.split_horizontal"); + editor.execute("tab.close"); + assert_eq!(editor.tab_count(), 2); + assert!(editor.message().contains("tab.force_close")); + editor.execute("pane.close"); + assert_eq!(editor.pane_layout().len(), 1); + editor.execute("pane.close"); + assert_eq!(editor.tab_count(), 2); + editor.execute("pane.force_close"); + assert_eq!(editor.tab_count(), 1); + assert!(editor.has_buffer(1)); + assert!(!editor.has_buffer(exclusive)); + assert_eq!(editor.lines(), ["shared"]); + editor.execute("tab.close"); + assert!(!editor.should_quit()); + editor.execute("tab.force_close"); + assert!(editor.should_quit()); +} + +#[test] +fn closing_tab_checks_dirty_diff_return_buffer_even_when_live_buffer_is_shared() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.execute("tab.new"); + editor.execute(&format!("edit {}", directory.path("return.txt").display())); + let return_buffer = editor.buffer_id(); + editor + .apply(vec![Effect::Insert("dirty return".into())]) + .unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.diff.as_mut().unwrap().focus = Focus::Before; + editor.bind_diff_buffer(); + assert!(!editor.dirty()); + editor.execute("tab.close"); + assert_eq!(editor.tab_count(), 2); + assert!(editor.message().contains("unsaved") || editor.message().contains("Unsaved")); + editor.execute("tab.force_close"); + assert_eq!(editor.tab_count(), 1); + assert!(editor.has_buffer(1)); + assert!(!editor.has_buffer(return_buffer)); + assert_eq!(editor.lines(), ["base"]); +} + +#[test] +fn tab_modifiers_aliases_catalog_and_palette_follow_pane_conventions() { + for key in ["ctrl+alt+h", "CTRL+Option+H", "alt+ctrl+h", "option+ctrl+h"] { + assert_eq!(parse_key(key).unwrap(), Input::CtrlAlt('h')); + } + for key in [ + "ctrl+shift+h", + "ctrl+alt+shift+h", + "alt+alt+h", + "ctrl+alt+left", + ] { + assert!(parse_key(key).is_err(), "{key}"); + } + assert_eq!(display_key(&Input::CtrlAlt('h')), "Ctrl+Alt+h"); + let mut editor = editor(); + for command in [ + "tab.new", + "tab.previous", + "tab.next", + "tab.close", + "tab.force_close", + ] { + assert!(core_command(command).is_some()); + assert!(Action::named(Mode::Normal, &format!("core.{command}")).is_some()); + assert!(Action::named(Mode::Insert, &format!("core.{command}")).is_none()); + } + editor.execute("tab.new extra"); + assert_eq!(editor.tab_count(), 1); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.tab.new"); + editor.handle(Input::Enter); + assert_eq!(editor.tab_count(), 2); + editor.handle(Input::Char('i')); + editor.handle(Input::CtrlAlt('h')); + assert_eq!(editor.tab_index(), 1); + editor.handle(Input::Escape); + editor.bind("normal", "option+ctrl+h", None).unwrap(); + editor.handle(Input::CtrlAlt('h')); + assert_eq!(editor.tab_index(), 1); + editor + .bind("normal", "ctrl+alt+h", Some("core.tab.previous".into())) + .unwrap(); + editor.handle(Input::CtrlAlt('h')); + assert_eq!(editor.tab_index(), 0); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.tab.force_close"); + editor.handle(Input::Enter); + assert_eq!(editor.command_line(), "tab.force_close "); + assert_eq!(editor.tab_count(), 2); + editor.handle(Input::Enter); + assert_eq!(editor.tab_count(), 1); +} + +#[test] +fn hidden_tab_diff_results_restore_visible_insert_interaction_and_route_request_ids() { + use crate::diff::{Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision}; + let mut editor = editor(); + editor.execute("diff"); + let first_tab = editor.tab_id(); + let (first_request, _) = editor.take_diff_request().unwrap(); + editor.execute("pane.split_vertical"); + let hidden_focused_pane = editor.pane_id(); + editor.execute("tab.new"); + editor.execute("diff"); + let second_tab = editor.tab_id(); + let (second_request, _) = editor.take_diff_request().unwrap(); + assert!(second_request > first_request); + editor.execute("tab.new"); + let visible_tab = editor.tab_id(); + let visible_pane = editor.pane_id(); + type_text(&mut editor, "iX"); + let cursor = editor.cursor(); + editor.set_message("visible"); + let catalog = || { + Outcome::Catalog(Catalog { + kind: RepoKind::Git, + entries: [ + RevisionTarget::Commit("base".into()), + RevisionTarget::Working, + ] + .into_iter() + .map(|target| Revision { + target, + label: "Revision".into(), + description: String::new(), + category: Category::Commits, + }) + .collect(), + notice: None, + }) + }; + assert!(editor.apply_diff_result(second_request, Ok(catalog()))); + assert!(editor.apply_diff_result(first_request, Ok(catalog()))); + assert!(!editor.apply_diff_result(first_request, Ok(catalog()))); + assert_eq!(editor.tab_id(), visible_tab); + assert_eq!(editor.pane_id(), visible_pane); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.message(), "visible"); + assert_eq!(editor.lines(), ["X"]); + assert!(editor.diff().is_none()); + assert!(!editor.tab_diff(first_tab).unwrap().loading); + assert!(!editor.tab_diff(second_tab).unwrap().loading); + let owner = editor.tabs[&first_tab].diff_owner.unwrap(); + let (comparison_request, _) = editor.with_pane(owner, |editor| { + editor.handle(Input::Enter); + editor.handle(Input::Down); + editor.handle(Input::Enter); + editor.take_diff_request().unwrap() + }); + let before = vec!["base".to_owned()]; + let after = vec!["changed".to_owned()]; + let hunks = crate::diff::line_hunks(&before, false, &after, false); + assert!(editor.apply_diff_result( + comparison_request, + Ok(Outcome::Comparison(Comparison { + root: editor.project_root().to_owned(), + revisions: ["base".into(), "working-snapshot".into()], + before: "base".into(), + after: "working".into(), + editable_paths: Default::default(), + files: vec![ChangedFile { + loaded: true, + path: "file.txt".into(), + old_path: None, + status: FileStatus::Modified, + before, + after, + before_eof: false, + after_eof: false, + hunks, + notice: None, + }], + })) + )); + assert_eq!(editor.tab_id(), visible_tab); + assert_eq!(editor.pane_id(), visible_pane); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.message(), "visible"); + assert_ne!(editor.pane_buffer_id(owner), Some(1)); + assert_eq!(editor.tabs[&first_tab].pane_id, hidden_focused_pane); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), [""]); +} + +#[test] +fn tab_snapshots_remain_readonly_and_other_comparisons_survive_release() { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["base".into()], + vec!["first".into()], + ); + let first_tab = editor.tab_id(); + let first_snapshot = editor.buffer_id(); + let first_comparison = editor.diff().unwrap().comparison_id; + editor.execute("tab.new"); + assert_eq!(editor.buffer_id(), first_snapshot); + assert!(editor.diff().is_none()); + assert!( + editor + .apply(vec![Effect::Insert("rejected".into())]) + .is_err() + ); + editor.execute("buffer 1"); + historical_diff( + &mut editor, + &directory.0, + vec!["base".into()], + vec!["second".into()], + ); + let second_snapshot = editor.buffer_id(); + assert_ne!(editor.diff().unwrap().comparison_id, first_comparison); + editor.execute("tab.new"); + assert_eq!(editor.buffer_id(), second_snapshot); + editor.execute("tab.previous"); + editor.execute("diff.close"); + assert!(editor.tab_diff(first_tab).is_some()); + assert!(editor.has_buffer(first_snapshot)); + assert!(editor.has_buffer(second_snapshot)); + editor.execute("tab.next"); + assert_eq!(editor.buffer_id(), second_snapshot); + assert!(editor.mutation_blocked()); + editor.execute("tab.next"); + assert_eq!(editor.buffer_id(), first_snapshot); + assert_eq!(editor.diff().unwrap().comparison_id, first_comparison); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); +} + +#[test] +fn hidden_tab_comparisons_refresh_shared_edits_and_deletion_closes_all_owners() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.diff.as_mut().unwrap().focus = Focus::After; + editor.bind_diff_buffer(); + let first_tab = editor.tab_id(); + editor.execute("tab.new"); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.diff.as_mut().unwrap().focus = Focus::After; + editor.bind_diff_buffer(); + let second_tab = editor.tab_id(); + editor.execute("tab.new"); + editor + .apply(vec![Effect::Insert("changed".into())]) + .unwrap(); + let revision = editor.text_revision(); + assert_eq!( + editor.tab_diff(first_tab).unwrap().live_revision, + Some(revision) + ); + assert_eq!( + editor.tab_diff(second_tab).unwrap().live_revision, + Some(revision) + ); + editor.execute("tab.close"); + assert_eq!(editor.tab_count(), 2); + assert!(editor.has_buffer(1)); + editor.execute("bdelete! 1"); + assert!(!editor.has_buffer(1)); + assert!(editor.tab_diff(first_tab).is_none()); + assert!(editor.tab_diff(second_tab).is_none()); + editor.execute("tab.next"); + assert!(editor.diff().is_none()); + assert!(editor.has_buffer(editor.buffer_id())); +} + +#[test] +fn pane_views_survive_buffer_switches_and_deletion_replaces_every_reference() { + let directory = Directory::new(); + let path = directory.path("other.txt"); + fs::write(&path, "other").unwrap(); + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcdef".into())]) + .unwrap(); + editor.handle(Input::Char('|')); + set_carets(&mut editor, &[(0, 4)], 0); + editor.execute(&format!("edit {}", path.display())); + let other = editor.buffer_id(); + set_carets(&mut editor, &[(0, 2)], 0); + editor.execute("buffer 1"); + assert_eq!(editor.cursor().column, 4); + editor.execute("pane.focus_left"); + assert_eq!(editor.cursor().column, 0); + editor.execute(&format!("buffer {other}")); + assert_eq!(editor.cursor().column, 0); + editor.execute("pane.focus_right"); + editor.execute(&format!("buffer {other}")); + assert_eq!(editor.cursor().column, 2); + editor.execute(&format!("bdelete {other}")); + assert!(!editor.has_buffer(other)); + assert_eq!(editor.pane_buffer_id(1), Some(1)); + assert_eq!(editor.pane_buffer_id(2), Some(1)); + editor.with_render_pane(1, |pane| assert_eq!(pane.buffer_id(), 1)); +} + +#[test] +fn pane_close_protects_exclusive_and_hidden_dirty_buffers_but_retains_shared_edits() { + for force in [false, true] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("unsaved".into())]) + .unwrap(); + editor.handle(Input::Char('|')); + editor.execute(if force { + "pane.force_close" + } else { + "pane.close" + }); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.lines(), ["unsaved"]); + assert!(editor.dirty()); + editor.execute("pane.close"); + assert!(!editor.should_quit()); + editor.execute("pane.force_close"); + assert!(editor.should_quit()); + } + let directory = Directory::new(); + let path = directory.path("clean.txt"); + fs::write(&path, "clean").unwrap(); + let mut editor = editor(); + editor.handle(Input::Char('|')); + editor.execute(&format!("edit {}", path.display())); + let exclusive = editor.buffer_id(); + editor + .apply(vec![Effect::Insert("changed".into())]) + .unwrap(); + editor.execute("pane.close"); + assert_eq!(editor.pane_layout().len(), 2); + assert!(editor.message().contains("pane.force_close")); + editor.execute("pane.force_close"); + assert_eq!(editor.pane_layout().len(), 1); + assert!(!editor.has_buffer(exclusive)); + editor + .apply(vec![Effect::Insert("hidden dirty".into())]) + .unwrap(); + editor.execute(&format!("edit {}", path.display())); + editor.execute("pane.close"); + assert!(!editor.should_quit()); + editor.execute("pane.force_close"); + assert!(editor.should_quit()); +} + +fn close_pane_using(editor: &mut Editor, binding: Option<&str>, force: bool) { + let command = if force { + "pane.force_close" + } else { + "pane.close" + }; + if let Some(binding) = binding { + editor + .bind("normal", binding, Some(format!("core.{command}"))) + .unwrap(); + for key in binding.split_whitespace() { + editor.handle(parse_key(key).unwrap()); + } + } else { + editor.execute(command); + } +} + +#[test] +fn pane_close_targets_focused_docked_lists_without_closing_dirty_documents_or_tabs() { + let directory = Directory::new(); + fs::create_dir(directory.path("dir")).unwrap(); + let path = directory.path("dir/file.txt"); + fs::write(&path, "base").unwrap(); + for comparison in [false, true] { + for another_tab in [false, true] { + for force in [false, true] { + for binding in [ + None, + Some("ctrl+x"), + Some("alt+x"), + Some("ctrl+alt+x"), + Some("delete"), + Some("ctrl+w c"), + ] { + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + if another_tab { + editor.execute("tab.new"); + } + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + if comparison { + working_diff(&mut editor, &directory.0, &["dir/file.txt"]); + editor.handle(Input::Char('\t')); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + } + let owner = (editor.tab_id(), editor.pane_id(), editor.buffer_id()); + let tabs = editor.tab_count(); + let buffers = editor.buffer_count(); + let revision = editor.text_revision(); + let targets = editor.active.view.targets.clone(); + let history = (editor.active.undo.len(), editor.active.redo.len()); + let comparison_id = editor.diff().and_then(|diff| diff.comparison_id); + editor.act(Action::FileExplorer); + type_text(&mut editor, "ifile"); + editor.handle(Input::Escape); + let selected = editor.file_picker().unwrap().selected; + let rows = editor + .file_picker() + .unwrap() + .entries() + .map(|row| (row.path.to_owned(), row.expanded)) + .collect::>(); + + close_pane_using(&mut editor, binding, force); + + assert!(!editor.should_quit()); + assert_eq!(editor.sequence_pending(), None); + assert!(editor.file_picker().is_none()); + assert_eq!( + (editor.tab_id(), editor.pane_id(), editor.buffer_id()), + owner + ); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.tab_count(), tabs); + assert_eq!(editor.buffer_count(), buffers); + assert_eq!(editor.lines(), ["Xbase"]); + assert!(editor.dirty()); + assert_eq!(editor.text_revision(), revision); + assert_eq!(editor.active.view.targets, targets); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + history + ); + if comparison { + let diff = editor.diff().unwrap(); + assert_eq!(diff.comparison_id, comparison_id); + assert_eq!(diff.focus, Focus::After); + assert!(!diff.files_visible); + assert!(!diff.files.as_ref().unwrap().focused); + editor.act(Action::FileExplorer); + let picker = editor.file_picker().unwrap(); + assert_eq!(picker.query, "file"); + assert_eq!(picker.selected, selected); + assert_eq!( + picker + .entries() + .map(|row| (row.path.to_owned(), row.expanded)) + .collect::>(), + rows + ); + editor.handle(Input::Ctrl('w')); + if binding == Some("ctrl+w c") { + assert!(editor.file_picker().is_some()); + assert!(editor.sequence_pending().is_some()); + editor.handle(Input::Char('c')); + } + assert!(editor.file_picker().is_none()); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + } else { + editor.act(Action::FileExplorer); + assert_eq!(editor.file_picker().unwrap().query, ""); + editor.handle(Input::Ctrl('w')); + if binding == Some("ctrl+w c") { + assert!(editor.file_picker().is_some()); + assert!(editor.sequence_pending().is_some()); + editor.handle(Input::Char('c')); + } + assert!(editor.file_picker().is_none()); + } + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["base"]); + } + } + } + } +} + +#[test] +fn pane_close_does_not_target_unfocused_docked_lists() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + for comparison in [false, true] { + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + editor.execute("pane.split_vertical"); + if comparison { + working_diff(&mut editor, &directory.0, &["file.txt"]); + } + editor.act(Action::FileExplorer); + editor.handle(Input::Escape); + assert!(!editor.file_picker().unwrap().focused); + editor.execute("pane.close"); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.pane_id(), 1); + assert!(!editor.should_quit()); + assert_eq!(editor.lines(), ["Xbase"]); + assert!(editor.dirty()); + assert!(editor.diff().is_none()); + if comparison { + assert!(editor.file_picker().is_none()); + } else { + assert!(!editor.file_picker().unwrap().focused); + } + } +} + +#[test] +fn floating_pane_close_closes_underlying_panes_and_retains_shared_dirty_buffers() { + let directory = Directory::new(); + let base = directory.path("base.txt"); + let other = directory.path("other.txt"); + fs::write(&base, "base").unwrap(); + fs::write(&other, "other").unwrap(); + for comparison in [false, true] { + for shared in [false, true] { + for force in [false, true] { + let mut editor = Editor::open(Some(base.clone()), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + if shared { + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + } + editor.execute("pane.split_vertical"); + if !shared { + editor.execute(&format!("edit {}", other.display())); + } + let source = editor.buffer_id(); + if comparison { + let file = if shared { "base.txt" } else { "other.txt" }; + working_diff(&mut editor, &directory.0, &[file]); + editor.handle(Input::Char('\t')); + editor.handle(Input::Char('\t')); + } + editor.act(Action::FilePicker); + type_text(&mut editor, "txt"); + close_pane_using(&mut editor, Some("alt+x"), force); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.pane_id(), 1); + assert!(!editor.should_quit()); + assert_eq!(editor.has_buffer(source), shared); + assert_eq!(editor.lines(), [if shared { "Xbase" } else { "base" }]); + assert_eq!(editor.dirty(), shared); + assert!(editor.diff().is_none()); + if comparison { + assert!(editor.file_picker().is_none()); + } else { + let picker = editor.file_picker().unwrap(); + assert!(picker.focused); + assert_eq!(picker.presentation, FilePresentation::Floating); + assert_eq!(picker.query, "txt"); + } + } + } + } +} + +#[test] +fn floating_pane_close_refuses_dirty_loss_then_force_closes_without_dismissing_first() { + let directory = Directory::new(); + let base = directory.path("base.txt"); + let other = directory.path("other.txt"); + fs::write(&base, "base").unwrap(); + fs::write(&other, "other").unwrap(); + for comparison in [false, true] { + for binding in [ + None, + Some("ctrl+x"), + Some("alt+x"), + Some("ctrl+alt+x"), + Some("delete"), + Some("ctrl+w c"), + ] { + let mut editor = Editor::open(Some(base.clone()), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + editor.execute("pane.split_vertical"); + editor.execute(&format!("edit {}", other.display())); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + let source = editor.buffer_id(); + if comparison { + working_diff(&mut editor, &directory.0, &["other.txt"]); + editor.handle(Input::Char('\t')); + editor.handle(Input::Char('\t')); + } + let comparison_id = editor.diff().and_then(|diff| diff.comparison_id); + let revision = editor.text_revision(); + let targets = editor.active.view.targets.clone(); + let history = (editor.active.undo.len(), editor.active.redo.len()); + editor.act(Action::FilePicker); + type_text(&mut editor, "other"); + let selected = editor.file_picker().unwrap().selected; + + close_pane_using(&mut editor, binding, false); + + assert_eq!(editor.pane_layout().len(), 2); + assert_eq!(editor.pane_id(), 2); + assert_eq!(editor.buffer_id(), source); + assert!(!editor.should_quit()); + assert_eq!(editor.sequence_pending(), None); + assert_eq!(editor.lines(), ["Xother"]); + assert!(editor.dirty()); + assert_eq!(editor.text_revision(), revision); + assert_eq!(editor.active.view.targets, targets); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + history + ); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused); + assert!(picker.searching); + assert_eq!(picker.presentation, FilePresentation::Floating); + assert_eq!(picker.query, "other"); + assert_eq!(picker.selected, selected); + assert!(editor.message().contains("pane.force_close")); + assert_eq!( + editor.diff().and_then(|diff| diff.comparison_id), + comparison_id + ); + + close_pane_using(&mut editor, binding, true); + + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.pane_id(), 1); + assert!(!editor.has_buffer(source)); + assert_eq!(editor.lines(), ["base"]); + assert!(!editor.should_quit()); + assert!(editor.diff().is_none()); + assert_eq!(editor.file_picker().is_some(), !comparison); + } + } +} + +#[test] +fn floating_pane_close_reaches_tab_and_last_pane_guards() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + for another_tab in [false, true] { + for dirty in [false, true] { + let mut editor = project_editor(&directory); + if another_tab { + editor.execute("tab.new"); + } + editor.execute(&format!("edit {}", path.display())); + let source = editor.buffer_id(); + let tab = editor.tab_id(); + if dirty { + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + } + editor.act(Action::FilePicker); + type_text(&mut editor, "file"); + close_pane_using(&mut editor, Some("ctrl+x"), false); + if dirty { + assert_eq!(editor.tab_id(), tab); + assert_eq!(editor.buffer_id(), source); + assert!(editor.has_buffer(source)); + assert!(!editor.should_quit()); + assert!(editor.file_picker().unwrap().focused); + assert_eq!(editor.file_picker().unwrap().query, "file"); + close_pane_using(&mut editor, Some("ctrl+x"), true); + } + assert_eq!(editor.should_quit(), !another_tab); + assert_eq!(editor.tab_count(), 1); + if another_tab { + assert_ne!(editor.tab_id(), tab); + assert!(!editor.has_buffer(source)); + assert!(editor.file_picker().is_none()); + } else { + assert!(editor.file_picker().unwrap().focused); + } + } + } +} + +#[test] +fn pane_close_bindings_preserve_picker_filter_and_escape_priority() { + let directory = Directory::new(); + fs::write(directory.path("xz.txt"), "base").unwrap(); + for comparison in [false, true] { + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + for view in ["tree", "list"] { + let mut editor = project_editor(&directory); + editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); + if comparison { + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["after".into()], + ); + } + editor + .bind("normal", "x", Some("core.pane.close".into())) + .unwrap(); + editor + .bind("normal", "z", Some("core.pane.force_close".into())) + .unwrap(); + editor.set_file_picker_view(view).unwrap(); + editor.set_file_explorer_view(view).unwrap(); + editor.act(match presentation { + FilePresentation::Floating => Action::FilePicker, + FilePresentation::Docked => Action::FileExplorer, + }); + if presentation == FilePresentation::Docked && view == "tree" { + editor.handle(Input::Char('i')); + } + let text = editor.lines().to_vec(); + type_text(&mut editor, "xz"); + assert_eq!(editor.file_picker().unwrap().query, "xz"); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.tab_count(), 1); + assert!(!editor.should_quit()); + assert_eq!(editor.lines(), text); + editor.handle(Input::Escape); + if view == "tree" { + assert!(editor.file_picker().unwrap().focused); + assert!(!editor.file_picker().unwrap().searching); + editor.handle(Input::Escape); + } + match presentation { + FilePresentation::Floating => assert!(editor.file_picker().is_none()), + FilePresentation::Docked => { + assert!(!editor.file_picker().unwrap().focused); + } + } + assert!(editor.has_buffer(1)); + assert!(!editor.should_quit()); + } + } + } +} + +#[test] +fn pane_close_bindings_yield_to_ordinary_tree_and_selection_overrides() { + let directory = Directory::new(); + fs::write(directory.path("a.txt"), "a").unwrap(); + fs::write(directory.path("b.txt"), "b").unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + for context in ["tree", "selection"] { + for target in [None, Some("core.down")] { + for force in [false, true] { + let mut editor = project_editor(&directory); + editor + .bind( + "normal", + "ctrl+x", + Some(if force { + "core.pane.force_close".into() + } else { + "core.pane.close".into() + }), + ) + .unwrap(); + editor + .bind(context, "ctrl+x", target.map(str::to_owned)) + .unwrap(); + editor.act(match presentation { + FilePresentation::Floating => Action::FilePicker, + FilePresentation::Docked => Action::FileExplorer, + }); + editor.handle(Input::Ctrl('x')); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused); + assert_eq!(picker.selected, usize::from(target.is_some())); + assert_eq!(editor.pane_layout().len(), 1); + assert!(!editor.should_quit()); + } + } + } + } +} + +#[test] +fn render_swaps_restore_focus_on_panics_and_isolate_caret_reveal_requests() { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); + editor.handle(Input::Char('|')); + let inactive_revision = editor.with_render_pane(1, Editor::caret_reveal_revision); + let inactive_cursor = editor.with_render_pane(1, Editor::cursor); + editor.handle(Input::Home); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + assert_eq!( + editor.with_render_pane(1, Editor::caret_reveal_revision), + inactive_revision + ); + assert_eq!( + editor.with_render_pane(1, Editor::cursor).column, + inactive_cursor.column + 1 + ); + let cursor = editor.cursor(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + editor.with_render_pane(1, |_| panic!("render failed")); + })); + assert!(result.is_err()); + assert_eq!(editor.pane_id(), 2); + assert_eq!(editor.cursor(), cursor); + assert!(editor.render_pane_focused()); +} + +#[test] +fn diff_panes_share_working_edits_and_focus_internal_regions_before_workspace() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base\n").unwrap(); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.diff.as_mut().unwrap().focus = Focus::After; + editor.bind_diff_buffer(); + let working = editor.buffer_id(); + editor.handle(Input::Char('|')); + assert!(editor.diff().is_none()); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + assert_eq!( + editor.workspace_diff().unwrap().live_revision, + Some(editor.text_revision()) + ); + editor.set_diff_pane_bounds([ + ( + Focus::Files, + PaneRect { + x: 0, + y: 0, + width: 0, + height: 0, + }, + ), + ( + Focus::Before, + PaneRect { + x: 0, + y: 0, + width: 30, + height: 40, + }, + ), + ( + Focus::After, + PaneRect { + x: 30, + y: 0, + width: 30, + height: 40, + }, + ), + ]); + editor.handle(Input::Alt('h')); + assert_eq!(editor.pane_id(), 1); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + editor.handle(Input::Alt('h')); + assert_eq!(editor.pane_id(), 1); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + let snapshot = editor.buffer_id(); + editor.execute("pane.split_horizontal"); + assert_eq!(editor.buffer_id(), snapshot); + assert!(editor.diff().is_none()); + assert!( + editor + .apply(vec![Effect::Insert("rejected".into())]) + .is_err() + ); + editor.execute("diff.close"); + assert!(editor.has_buffer(snapshot)); + assert!(editor.has_buffer(working)); + assert_eq!(editor.pane_id(), 3); + assert_eq!(editor.buffer_id(), snapshot); + editor.with_render_pane(3, |pane| { + assert_eq!(pane.buffer_id(), snapshot); + assert!(pane.mutation_blocked()); + assert_eq!(pane.lines(), ["base", ""]); + }); + editor.execute("pane.close"); + assert!(!editor.has_buffer(snapshot)); + assert!(editor.has_buffer(working)); + assert_eq!(editor.pane_layout().len(), 2); +} + +#[test] +fn diff_results_bind_the_owning_pane_without_disturbing_focused_document_or_mode() { + use crate::diff::{Catalog, Category, RepoKind, Revision}; + let directory = Directory::new(); + let path = directory.path("other.txt"); + fs::write(&path, "other").unwrap(); + let mut editor = editor(); + editor.execute("diff"); + editor.execute("pane.split_vertical"); + editor.execute(&format!("edit {}", path.display())); + type_text(&mut editor, "iX"); + let focused_buffer = editor.buffer_id(); + let focused_cursor = editor.cursor(); + let (id, _) = editor.take_diff_request().unwrap(); + assert!(editor.apply_diff_result( + id, + Ok(Outcome::Catalog(Catalog { + kind: RepoKind::Git, + entries: vec![Revision { + target: RevisionTarget::Working, + label: "Working".into(), + description: String::new(), + category: Category::Commits, + }], + notice: None, + })) + )); + assert_eq!(editor.pane_id(), 2); + assert_eq!(editor.buffer_id(), focused_buffer); + assert_eq!(editor.cursor(), focused_cursor); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.lines(), ["Xother"]); + assert!(editor.diff().is_none()); + editor.with_render_pane(1, |pane| assert!(pane.diff().is_some())); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["other"]); +} + +#[test] +fn destructive_pane_close_from_palette_requires_command_confirmation() { + let mut editor = editor(); + editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.pane.force_close"); + editor.handle(Input::Enter); + assert_eq!(editor.mode(), Mode::Command); + assert!(!editor.should_quit()); + assert_eq!(editor.command_line(), "pane.force_close "); + editor.handle(Input::Enter); + assert!(editor.should_quit()); +} + +#[test] +fn palette_pane_close_keeps_focused_dock_target_through_force_confirmation() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + for comparison in [false, true] { + for force in [false, true] { + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + let source = editor.buffer_id(); + let revision = editor.text_revision(); + let history = (editor.active.undo.len(), editor.active.redo.len()); + if comparison { + working_diff(&mut editor, &directory.0, &["file.txt"]); + } + let owner = (editor.tab_id(), editor.pane_id()); + let comparison_id = editor.diff().and_then(|diff| diff.comparison_id); + if !editor.file_picker().is_some_and(|picker| picker.focused) { + editor.act(Action::FileExplorer); + } + editor.handle(Input::Ctrl('p')); + assert!(editor.pane_close_picker_origin.is_some()); + assert!(editor.palette().is_some()); + assert!(!editor.file_picker().unwrap().focused); + let buffers = editor.buffer_count(); + type_text( + &mut editor, + if force { + "core.pane.force_close" + } else { + "core.pane.close" + }, + ); + editor.handle(Input::Enter); + if force { + assert_eq!(editor.mode(), Mode::Command); + assert_eq!(editor.command_line(), "pane.force_close "); + assert!(editor.file_picker().is_some()); + assert!(editor.pane_close_picker_origin.is_some()); + assert!(!editor.should_quit()); + editor.handle(Input::Char(' ')); + editor.handle(Input::Backspace); + editor.handle(Input::Backspace); + assert!(editor.pane_close_picker_origin.is_some()); + editor.handle(Input::Enter); + } + assert_eq!((editor.tab_id(), editor.pane_id()), owner); + assert_eq!(editor.tab_count(), 1); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.buffer_count(), buffers); + assert!(!editor.should_quit()); + assert!(editor.palette().is_none()); + assert!(editor.file_picker().is_none()); + assert_eq!(editor.pane_close_picker_origin, None); + let source = editor.buffer(source).unwrap(); + assert_eq!(source.lines, ["Xbase"]); + assert!(source.dirty()); + assert_eq!(source.text_revision, revision); + assert_eq!((source.undo.len(), source.redo.len()), history); + assert_eq!( + editor.diff().and_then(|diff| diff.comparison_id), + comparison_id + ); + } + } +} + +#[test] +fn palette_pane_close_origin_clears_on_cancel_edit_or_unrelated_action() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + for comparison in [false, true] { + for cancellation in [ + "escape", + "toggle", + "unrelated", + "force_escape", + "force_other_command", + "invalid_args", + ] { + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); + if comparison { + working_diff(&mut editor, &directory.0, &["file.txt"]); + } + if !editor.file_picker().is_some_and(|picker| picker.focused) { + editor.act(Action::FileExplorer); + } + editor.handle(Input::Ctrl('p')); + assert!(editor.pane_close_picker_origin.is_some()); + assert!(editor.palette().is_some()); + match cancellation { + "escape" => { + editor.handle(Input::Escape); + } + "toggle" => { + editor.handle(Input::Ctrl('p')); + } + "unrelated" => { + type_text(&mut editor, "core.word_wrap.toggle"); + editor.handle(Input::Enter); + } + "force_escape" | "force_other_command" => { + type_text(&mut editor, "core.pane.force_close"); + editor.handle(Input::Enter); + assert!(editor.pane_close_picker_origin.is_some()); + if cancellation == "force_escape" { + editor.handle(Input::Escape); + } else { + for _ in 0..editor.command_line().chars().count() { + editor.handle(Input::Backspace); + } + type_text(&mut editor, "word_wrap.toggle"); + editor.handle(Input::Enter); + } + } + "invalid_args" => { + editor.execute("pane.close unexpected"); + } + _ => unreachable!(), + } + assert_eq!(editor.pane_close_picker_origin, None, "{cancellation}"); + assert!( + editor.file_picker().is_some(), + "comparison={comparison}, cancellation={cancellation}" + ); + assert!(!editor.should_quit()); + editor.execute("pane.force_close"); + assert!(editor.should_quit(), "{cancellation}"); + assert!( + editor.file_picker().is_some(), + "comparison={comparison}, cancellation={cancellation}" + ); + } + } +} + +#[test] +fn document_and_floating_palette_origins_never_intercept_pane_close() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + for comparison in [false, true] { + for presentation in [FilePresentation::Docked, FilePresentation::Floating] { + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); + let source = editor.buffer_id(); + if comparison { + working_diff(&mut editor, &directory.0, &["file.txt"]); + } + editor.act(match presentation { + FilePresentation::Docked => Action::FileExplorer, + FilePresentation::Floating => Action::FilePicker, + }); + if presentation == FilePresentation::Docked { + editor.handle(Input::Escape); + } + editor.handle(Input::Ctrl('p')); + assert_eq!(editor.pane_close_picker_origin, None); + assert!(editor.palette().is_some()); + type_text(&mut editor, "core.pane.close"); + editor.handle(Input::Enter); + assert!(!editor.should_quit()); + assert!(editor.palette().is_none()); + let source = editor.buffer(source).unwrap(); + assert_eq!(source.lines, ["dirtybase"]); + assert!(source.dirty()); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!( + editor.file_picker().is_some(), + presentation == FilePresentation::Docked + ); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.pane.force_close"); + editor.handle(Input::Enter); + assert_eq!(editor.mode(), Mode::Command); + assert!(!editor.should_quit()); + editor.handle(Input::Enter); + assert!(editor.should_quit()); + } + } +} + +#[test] +fn palette_pane_close_origin_is_render_local_and_expires_on_owner_changes() { + let directory = Directory::new(); + fs::write(directory.path("file.txt"), "base").unwrap(); + for owner_change in ["none", "pane", "tab", "hidden", "floating"] { + let mut editor = project_editor(&directory); + editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); + editor.execute("pane.split_vertical"); + editor.act(Action::FileExplorer); + editor.handle(Input::Ctrl('p')); + let origin = editor.pane_close_picker_origin; + assert!(origin.is_some()); + editor.with_render_pane(1, |pane| { + assert!(pane.palette().is_none()); + assert_eq!(pane.pane_close_picker_origin, None); + }); + assert_eq!(editor.pane_close_picker_origin, origin); + match owner_change { + "none" => {} + "pane" => editor.select_pane(1), + "tab" => { + editor.execute("tab.new"); + } + "hidden" => editor.file_picker = None, + "floating" => { + editor.file_picker.as_mut().unwrap().presentation = FilePresentation::Floating; + } + _ => unreachable!(), + } + if owner_change == "none" { + type_text(&mut editor, "core.pane.close"); + editor.handle(Input::Enter); + assert_eq!(editor.pane_layout().len(), 2); + assert!(editor.file_picker().is_none()); + } else { + editor.execute("pane.force_close"); + assert_eq!( + editor.pane_layout().len(), + if owner_change == "tab" { 2 } else { 1 } + ); + assert_eq!(editor.pane_close_picker_origin, None); + assert_eq!(editor.lines(), ["dirty"]); + } + assert!(!editor.should_quit()); + } +} + +#[test] +fn palette_pane_close_rejects_a_replaced_comparison_origin() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); + let source = editor.buffer_id(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.handle(Input::Ctrl('p')); + assert!(editor.pane_close_picker_origin.is_some()); + assert!(editor.palette().is_some()); + let diff = editor.diff.as_mut().unwrap(); + diff.comparison_id = diff.comparison_id.map(|id| id + 1); + type_text(&mut editor, "core.pane.close"); + editor.handle(Input::Enter); + assert!(!editor.should_quit()); + assert!(editor.palette().is_none()); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.tab_count(), 1); + let source = editor.buffer(source).unwrap(); + assert_eq!(source.lines, ["dirtybase"]); + assert!(source.dirty()); + assert!(editor.file_picker().is_some()); + assert_eq!(editor.pane_close_picker_origin, None); +} + +#[test] +fn closing_comparison_checks_the_dirty_working_buffer_not_its_readonly_snapshot() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.handle(Input::Char('|')); + editor.execute(&format!("edit {}", directory.path("other.txt").display())); + editor.execute("pane.focus_left"); + editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.handle(Input::Char('\t')); + assert!(!editor.dirty()); + editor.execute("pane.close"); + assert_eq!(editor.pane_layout().len(), 2); + assert!(editor.message().contains("unsaved")); + editor.execute("pane.force_close"); + assert_eq!(editor.pane_layout().len(), 1); + assert!(!editor.has_buffer(1)); +} + +#[test] +fn diff_preserves_parked_buffers_visual_carets_and_insert_history() { + let directory = Directory::new(); + let a = directory.path("a.txt"); + let b = directory.path("b.txt"); + fs::write(&a, "alpha").unwrap(); + fs::write(&b, "beta").unwrap(); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + type_text(&mut editor, "iunsaved"); + editor.handle(Input::Escape); + editor.execute(&format!("edit {}", b.display())); + type_text(&mut editor, "iother"); + editor.handle(Input::Escape); + type_text(&mut editor, "vl"); + let carets = editor.carets(); + let selections = editor.selections(); + let documents = editor + .documents() + .map(|document| (document.id, document.revision, document.lines.to_vec())) + .collect::>(); + editor.execute("diff"); + for input in [ + Input::Paste("bad".into()), + Input::Delete, + Input::Char('d'), + Input::Enter, + ] { + editor.handle(input); + } + type_text(&mut editor, ":unknown\n"); + editor.handle(Input::Ctrl('p')); + editor.handle(Input::Escape); + assert!(editor.diff().is_some()); + editor.handle(Input::Escape); + assert!(editor.diff().is_none()); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.selections(), selections); + assert_eq!( + editor + .documents() + .map(|document| (document.id, document.revision, document.lines.to_vec())) + .collect::>(), + documents + ); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "beta"); + editor.execute("buffer 1"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "alpha"); + + editor.handle(Input::Char('i')); + type_text(&mut editor, "start"); + let caret = editor.cursor(); + editor.execute("diff"); + editor.handle(Input::Paste("ignored".into())); + type_text(&mut editor, ":unfinished"); + editor + .apply(vec![Effect::Message("message without edits".into())]) + .unwrap(); + editor.handle(Input::Escape); + assert!(editor.diff().is_none()); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.cursor(), caret); + type_text(&mut editor, "finish"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "alpha"); +} + +fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathBuf, String)]> { + use crate::diff::{Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision}; + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + let revision = |target, label: &str| Revision { + target, + label: label.into(), + description: String::new(), + category: Category::Commits, + }; + editor.apply_diff_result( + id, + Ok(Outcome::Catalog(Catalog { + kind: RepoKind::Git, + entries: vec![ + revision(RevisionTarget::Commit("base".into()), "base"), + revision(RevisionTarget::Working, "Working"), + ], + notice: None, + })), + ); + editor.handle(Input::Down); + editor.handle(Input::Enter); + editor.handle(Input::Enter); + let (id, operation) = editor.take_diff_request().unwrap(); + let Operation::Compare { buffers, .. } = operation else { + panic!("comparison request") + }; + let files = paths + .iter() + .map(|path| { + let text = fs::read_to_string(root.join(path)).unwrap(); + let lines: Vec = text.lines().map(str::to_owned).collect(); + ChangedFile { + loaded: true, + path: PathBuf::from(*path), + old_path: None, + status: FileStatus::Modified, + before: lines.clone(), + after: lines, + before_eof: text.ends_with('\n'), + after_eof: text.ends_with('\n'), + hunks: Vec::new(), + notice: None, + } + }) + .collect(); + assert!(editor.apply_diff_result( + id, + Ok(Outcome::Comparison(Comparison { + root: root.canonicalize().unwrap(), + revisions: ["base".into(), "working-snapshot".into()], + before: "base".into(), + after: "Working".into(), + editable_paths: paths.iter().map(|path| PathBuf::from(*path)).collect(), + files, + })) + )); + buffers +} + +fn historical_diff(editor: &mut Editor, root: &Path, before: Vec, after: Vec) { + use crate::diff::{Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision}; + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + let revision = |id: &str| Revision { + target: RevisionTarget::Commit(id.into()), + label: id.into(), + description: String::new(), + category: Category::Commits, + }; + editor.apply_diff_result( + id, + Ok(Outcome::Catalog(Catalog { + kind: RepoKind::Git, + entries: vec![revision("base"), revision("head")], + notice: None, + })), + ); + editor.handle(Input::Enter); + editor.handle(Input::Down); + editor.handle(Input::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + let hunks = crate::diff::line_hunks(&before, false, &after, false); + editor.apply_diff_result( + id, + Ok(Outcome::Comparison(Comparison { + root: root.to_owned(), + revisions: ["base".into(), "head".into()], + before: "base".into(), + after: "head".into(), + editable_paths: Default::default(), + files: vec![ChangedFile { + loaded: true, + path: "file.txt".into(), + old_path: None, + status: FileStatus::Modified, + before, + after, + before_eof: false, + after_eof: false, + hunks, + notice: None, + }], + })), + ); + if editor.review_panel().is_some() { + return; + } + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + assert!(editor.diff_interacting()); +} + +#[test] +fn sqlite_reviews_autosave_restore_select_and_keep_exports_independent() { + use crate::review::{Review, Store, Verdict}; + let directory = Directory::new(); + let root = directory.0.canonicalize().unwrap(); + let storage = root.join("data"); + let mut first = editor(); + first.set_review_directory(storage.clone()); + historical_diff( + &mut first, + &root, + vec!["p界e\u{301}z".into()], + vec!["changed".into()], + ); + type_text(&mut first, "lvl:diff.comment"); + first.handle(Input::Enter); + type_text(&mut first, "iFirst feedback"); + first.handle(Input::Escape); + assert!( + Store::open(&storage) + .unwrap() + .list(&root) + .unwrap() + .is_empty() + ); + first.handle(Input::Escape); + let id = first + .workspace_diff() + .unwrap() + .local_review + .document + .storage_id + .unwrap(); + let store = Store::open(&storage).unwrap(); + let persisted = store.load(&root, id).unwrap(); + assert_eq!(persisted.comments[0].body, "First feedback"); + assert_eq!( + persisted.comments[0].anchor.start, + TextPosition { row: 0, column: 1 } + ); + assert_eq!( + persisted.comments[0].anchor.end, + TextPosition { row: 0, column: 3 } + ); + first.execute("diff.review.summary"); + type_text(&mut first, "iSummary"); + first.handle(Input::Escape); + first.execute("diff.review.verdict changes_requested"); + let persisted = store.load(&root, id).unwrap(); + assert_eq!(persisted.summary, "Summary"); + assert_eq!(persisted.verdict, Some(Verdict::ChangesRequested)); + let exported = root.join("shared.json"); + first.execute(&format!("diff.review.save {}", exported.display())); + first.execute("diff.review.edit 1"); + type_text(&mut first, "A revised"); + first.handle(Input::Escape); + first.handle(Input::Escape); + assert_eq!( + store.load(&root, id).unwrap().comments[0].body, + "First feedback revised" + ); + assert_eq!( + Review::load(&exported, &root).unwrap().comments[0].body, + "First feedback" + ); + let mut reopened = editor(); + reopened.set_review_directory(storage.clone()); + historical_diff( + &mut reopened, + &root, + vec!["different revision".into()], + vec!["changed".into()], + ); + let restored = &reopened.workspace_diff().unwrap().local_review.document; + assert_eq!(restored.storage_id, Some(id)); + assert_eq!(restored.comments[0].body, "First feedback revised"); + assert!( + !reopened + .workspace_diff() + .unwrap() + .review_anchor_current(&restored.comments[0].anchor) + ); + reopened.execute("diff.review.new"); + let second_id = reopened + .workspace_diff() + .unwrap() + .local_review + .document + .storage_id + .unwrap(); + assert_ne!(id, second_id); + let mut choosing = editor(); + choosing.set_review_directory(storage); + historical_diff(&mut choosing, &root, vec!["old".into()], vec!["new".into()]); + assert_eq!( + choosing + .workspace_diff() + .unwrap() + .local_review + .choices + .iter() + .map(|(id, _)| *id) + .collect::>(), + std::collections::BTreeSet::from([id, second_id]) + ); + choosing.handle(Input::Enter); + assert_eq!( + choosing + .workspace_diff() + .unwrap() + .local_review + .document + .storage_id, + Some(second_id) + ); + choosing.execute(&format!("diff.review.open {id}")); + assert_eq!( + choosing + .workspace_diff() + .unwrap() + .local_review + .document + .storage_id, + Some(id) + ); + choosing.execute("diff.review.delete 1"); + assert!(store.load(&root, id).unwrap().comments.is_empty()); + choosing.execute(&format!("diff.review.open {}", exported.display())); + let imported_id = choosing + .workspace_diff() + .unwrap() + .local_review + .document + .storage_id + .unwrap(); + assert_ne!(imported_id, id); + assert_eq!( + store.load(&root, imported_id).unwrap().comments[0].body, + "First feedback" + ); +} + +#[test] +fn review_resolution_persists_without_hiding_comments_or_changing_stale_anchors() { + use crate::review::{Review, Store}; + let directory = Directory::new(); + let root = directory.0.canonicalize().unwrap(); + let storage = root.join("data"); + let mut first = editor(); + first.set_review_directory(storage.clone()); + historical_diff(&mut first, &root, vec!["old".into()], vec!["new".into()]); + first.execute("diff.comment"); + type_text(&mut first, "iFeedback"); + first.handle(Input::Escape); + assert!( + first + .review_panel_title() + .unwrap() + .contains(" · unresolved · ") + ); + first.execute("diff.review.resolve"); + assert!( + first + .context() + .text + .contains("Comment #1 · resolved · current") + ); + let store = Store::open(&storage).unwrap(); + let document = &first.workspace_diff().unwrap().local_review.document; + let id = document.storage_id.unwrap(); + let anchor = document.comments[0].anchor.clone(); + assert!(store.load(&root, id).unwrap().comments[0].resolved); + first.execute("diff.review.next"); + assert_eq!(first.context().text, "Feedback"); + assert!( + first + .review_panel_title() + .unwrap() + .contains(" · resolved · ") + ); + let export = root.join("resolved.json"); + first.execute(&format!("diff.review.save {}", export.display())); + assert!(Review::load(&export, &root).unwrap().comments[0].resolved); + let mut stale = editor(); + stale.set_review_directory(storage.clone()); + historical_diff( + &mut stale, + &root, + vec!["changed".into()], + vec!["new".into()], + ); + stale.execute("diff.review.overview"); + assert!( + stale + .context() + .text + .contains("Comment #1 · resolved · stale") + ); + first.execute("diff.review.unresolve 1"); + assert!(!store.load(&root, id).unwrap().comments[0].resolved); + stale.execute("diff.review.unresolve 1"); + assert!(stale.current_review_dirty()); + assert!( + !stale + .workspace_diff() + .unwrap() + .local_review + .document + .comments[0] + .resolved + ); + assert_eq!( + stale + .workspace_diff() + .unwrap() + .local_review + .document + .comments[0] + .anchor, + anchor + ); + assert!(stale.message().contains("changed in another session")); + first.execute("diff.review.resolve"); + assert!( + first + .context() + .text + .contains("Comment #1 · resolved · current") + ); + first.execute("diff.review.unresolve"); + assert!( + first + .context() + .text + .contains("Comment #1 · unresolved · current") + ); + first.execute(&format!("diff.review.open {}", export.display())); + let imported = &first.workspace_diff().unwrap().local_review.document; + assert_ne!(imported.storage_id, Some(id)); + assert!( + store + .load(&root, imported.storage_id.unwrap()) + .unwrap() + .comments[0] + .resolved + ); +} + +#[test] +fn sqlite_review_conflicts_and_write_failures_keep_dirty_drafts() { + use crate::review::Store; + let directory = Directory::new(); + let root = directory.0.canonicalize().unwrap(); + let storage = root.join("data"); + let mut first = editor(); + first.set_review_directory(storage.clone()); + historical_diff(&mut first, &root, vec!["old".into()], vec!["new".into()]); + first.execute("diff.comment"); + type_text(&mut first, "iStored feedback"); + first.handle(Input::Escape); + first.handle(Input::Escape); + let id = first + .workspace_diff() + .unwrap() + .local_review + .document + .storage_id + .unwrap(); + let mut stale = editor(); + stale.set_review_directory(storage.clone()); + historical_diff(&mut stale, &root, vec!["old".into()], vec!["new".into()]); + let version = stale + .workspace_diff() + .unwrap() + .local_review + .document + .storage_revision; + first.execute("diff.review.verdict looks_good"); + stale.execute("diff.review.verdict changes_requested"); + assert!(stale.current_review_dirty()); + assert_eq!( + stale + .workspace_diff() + .unwrap() + .local_review + .document + .storage_revision, + version + ); + assert_eq!( + Store::open(&storage) + .unwrap() + .load(&root, id) + .unwrap() + .verdict, + Some(crate::review::Verdict::LooksGood) + ); + let blocked = root.join("not-a-directory"); + fs::write(&blocked, "preserve").unwrap(); + first.set_review_directory(blocked.clone()); + first.execute("diff.review.edit 1"); + type_text(&mut first, "A unsaved"); + first.handle(Input::Escape); + first.handle(Input::Escape); + assert!(first.current_review_dirty()); + assert_eq!( + first + .workspace_diff() + .unwrap() + .local_review + .document + .comments[0] + .body, + "Stored feedback unsaved" + ); + assert_eq!(fs::read_to_string(blocked).unwrap(), "preserve"); + assert_eq!( + Store::open(&storage) + .unwrap() + .load(&root, id) + .unwrap() + .comments[0] + .body, + "Stored feedback" + ); +} + +#[test] +fn local_review_native_composition_roundtrip_and_draft_lifecycle() { + use crate::review::{Review, Side, Verdict}; + let directory = Directory::new(); + let root = directory.0.canonicalize().unwrap(); + let path = root.join("feedback.json"); + let mut editor = editor(); + historical_diff( + &mut editor, + &root, + vec!["before".into(), "removed".into()], + vec!["after".into(), "added".into()], + ); + let owner = editor.pane_id(); + let source = editor.context().text; + editor.execute("diff.comment"); + assert_ne!(editor.pane_id(), owner); + assert!( + editor + .review_panel_title() + .unwrap() + .starts_with("Comment #1") + ); + type_text(&mut editor, "iExplain the removed branch"); + editor.handle(Input::Escape); + editor.execute("diff.review.summary"); + type_text(&mut editor, "iPlease simplify the branch"); + editor.handle(Input::Escape); + editor.execute("diff.review.verdict changes_requested"); + assert!(editor.context().text.contains("Changes requested")); + editor.execute(&format!("diff.review.save {}", path.display())); + let review = Review::load(&path, &root).unwrap(); + assert_eq!(review.comments[0].body, "Explain the removed branch"); + assert_eq!(review.comments[0].anchor.side, Side::Before); + assert_eq!(review.comments[0].anchor.content, source); + assert_eq!(review.summary, "Please simplify the branch"); + assert_eq!(review.verdict, Some(Verdict::ChangesRequested)); + assert!(!editor.local_reviews_dirty()); + editor.execute("diff.review.edit 1"); + // Native undo applies to comment text, never to the reviewed source. + type_text(&mut editor, "A!"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "Explain the removed branch"); + editor.execute("w"); + editor.execute("diff.close"); + assert!(editor.workspace_diff().is_none()); + historical_diff( + &mut editor, + &root, + vec!["before".into(), "removed".into()], + vec!["after".into(), "added".into()], + ); + editor.execute(&format!("diff.review.open {}", path.display())); + assert!(editor.context().text.contains("Explain the removed branch")); + editor.execute("diff.review.next"); + assert_eq!(editor.context().text, "Explain the removed branch"); + editor.execute("diff.review.delete"); + assert!(editor.context().text.starts_with("# Local review")); + editor.execute("w"); + assert!(Review::load(&path, &root).unwrap().comments.is_empty()); + assert_eq!( + editor + .workspace_diff() + .unwrap() + .comparison + .as_ref() + .unwrap() + .files[0] + .before, + ["before", "removed"] + ); +} + +#[test] +fn precise_review_ranges_capture_graphemes_and_navigate_to_their_start() { + for focus in [Focus::Before, Focus::After] { + for keys in ["lvl", "llvh", "llvj"] { + let directory = Directory::new(); + let root = directory.0.canonicalize().unwrap(); + let mut editor = editor(); + historical_diff( + &mut editor, + &root, + vec!["p界e\u{301}z".into(), "before".into()], + vec!["q界e\u{301}z".into(), "after!".into()], + ); + editor.diff.as_mut().unwrap().focus = focus; + editor.bind_diff_buffer(); + let owner = editor.pane_id(); + type_text(&mut editor, keys); + type_text(&mut editor, ":diff.comment"); + editor.handle(Input::Enter); + let diff = editor.workspace_diff().unwrap(); + let anchor = &diff.local_review.document.comments[0].anchor; + let (start, end) = if keys == "llvj" { + ( + TextPosition { row: 0, column: 2 }, + TextPosition { row: 1, column: 3 }, + ) + } else { + ( + TextPosition { row: 0, column: 1 }, + TextPosition { row: 0, column: 3 }, + ) + }; + assert_eq!(anchor.start, start); + assert_eq!(anchor.end, end); + assert!(diff.review_anchor_current(anchor)); + editor.handle(Input::Escape); + assert_eq!(editor.pane_id(), owner); + assert_eq!( + editor.cursor(), + Cursor { + row: start.row, + column: start.column + } + ); + } + } + for text in ["last", "one\nlast", "one\n", ""] { + let directory = Directory::new(); + let root = directory.0.canonicalize().unwrap(); + let mut editor = editor(); + historical_diff(&mut editor, &root, vec!["before".into()], split_lines(text)); + editor.diff.as_mut().unwrap().focus = Focus::After; + editor.bind_diff_buffer(); + editor.execute("diff.comment"); + let anchor = &editor + .workspace_diff() + .unwrap() + .local_review + .document + .comments[0] + .anchor; + assert_eq!(anchor.start, TextPosition { row: 0, column: 0 }); + assert_eq!( + anchor.end, + if text.contains('\n') { + TextPosition { row: 1, column: 0 } + } else { + TextPosition { + row: 0, + column: grapheme_count(text), + } + } + ); + } +} + +#[test] +fn visual_commands_preserve_directed_ranges_on_cancel_and_plugin_dispatch() { + for shape in ["vll", "vhh", "Vj"] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace( + "abcdef\nsecond\nabcdef\nfourth".into(), + )]) + .unwrap(); + editor.register(plugin("inspect", &["inspect"])).unwrap(); + set_carets(&mut editor, &[(0, 2), (2, 2)], 1); + type_text(&mut editor, shape); + let selections = editor.selections(); + let carets = editor.carets(); + let linewise = editor.active.view.linewise; + type_text(&mut editor, ":inspect"); + assert_eq!(editor.mode(), Mode::Command); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.active.view.linewise, linewise); + editor + .bind("visual", ",", Some("core.command".into())) + .unwrap(); + editor.handle(Input::Char(',')); + type_text(&mut editor, "inspect"); + editor + .handle(Input::Enter) + .expect("selection-aware plugin invocation"); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.active.view.linewise, linewise); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Normal); + } +} + +#[test] +fn local_review_visual_commands_and_palette_preserve_both_side_ranges() { + use crate::review::Side; + for (focus, palette) in [ + (Focus::Before, false), + (Focus::After, false), + (Focus::Before, true), + (Focus::After, true), + ] { + let directory = Directory::new(); + let root = directory.0.canonicalize().unwrap(); + let mut editor = editor(); + historical_diff( + &mut editor, + &root, + vec!["old first".into(), "old middle".into(), "old last".into()], + vec!["new first".into(), "new middle".into(), "new last".into()], + ); + editor.diff.as_mut().unwrap().focus = focus; + editor.bind_diff_buffer(); + type_text(&mut editor, "Vj"); + if palette { + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "diff.comment"); + } else { + type_text(&mut editor, ":diff.comment"); + } + editor.handle(Input::Enter); + let diff = editor.workspace_diff().unwrap(); + let anchor = diff.local_review.document.comments[0].anchor.clone(); + assert_eq!(anchor.start, TextPosition { row: 0, column: 0 }); + assert_eq!(anchor.end, TextPosition { row: 2, column: 0 }); + assert_eq!( + anchor.side, + if focus == Focus::Before { + Side::Before + } else { + Side::After + } + ); + assert!(diff.review_anchor_current(&anchor)); + let source_pane = editor.diff_owner.unwrap(); + editor.select_pane(source_pane); + editor + .diff + .as_mut() + .unwrap() + .comparison + .as_mut() + .unwrap() + .revisions[anchor.side.index()] = "changed-snapshot".into(); + editor.execute("diff.review.overview"); + assert!(editor.context().text.contains("stale")); + editor.execute("diff.review.edit 1"); + assert_eq!( + editor + .workspace_diff() + .unwrap() + .local_review + .document + .comments[0] + .anchor, + anchor + ); + } +} + +#[test] +fn local_review_guards_metadata_and_hidden_native_drafts_against_loss() { + let directory = Directory::new(); + let root = directory.0.canonicalize().unwrap(); + let mut editor = editor(); + historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]); + editor.execute("diff.comment"); + type_text(&mut editor, "iUnsaved feedback"); + editor.handle(Input::Escape); + editor.execute("diff.review.overview"); + let panel = editor.pane_id(); + let source_buffer = editor.diff_return_buffer.unwrap(); + editor.delete_buffer(source_buffer, false); + assert!(editor.has_buffer(source_buffer)); + assert!(editor.workspace_diff().is_some()); + editor.select_pane(editor.diff_owner.unwrap()); + fs::write(root.join("other.txt"), "other document").unwrap(); + editor.execute(&format!("edit {}", root.join("other.txt").display())); + assert!(editor.workspace_diff().is_some()); + assert_eq!(editor.context().text, "old"); + editor.select_pane(panel); + editor.execute("diff.close"); + assert!(editor.workspace_diff().is_some()); + editor.execute("tab.close"); + assert!(!editor.should_quit()); + editor.execute("restart"); + assert!(!editor.should_restart()); + editor.execute("q"); + assert!(!editor.should_quit()); + let invalid = root.join("missing/review.json"); + editor.execute(&format!("diff.review.save {}", invalid.display())); + assert!(editor.local_reviews_dirty()); + assert_eq!( + editor + .workspace_diff() + .unwrap() + .local_review + .document + .comments[0] + .body, + "Unsaved feedback" + ); + let path = root.join("review.json"); + editor.execute(&format!("diff.review.save {}", path.display())); + assert!(!editor.local_reviews_dirty()); + fs::write(&path, "external change").unwrap(); + editor.execute("diff.review.verdict looks_good"); + editor.execute("w"); + assert_eq!(fs::read_to_string(&path).unwrap(), "external change"); + assert!(editor.local_reviews_dirty()); +} + +#[test] +fn working_refresh_routes_hidden_owners_and_keeps_obsolete_shared_snapshots_readonly() { + let directory = Directory::new(); + fs::write(directory.path("file.txt"), "working").unwrap(); + let mut editor = editor(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.diff.as_mut().unwrap().focus = Focus::Before; + editor.bind_diff_buffer(); + let old_snapshot = editor.buffer_id(); + let old_comparison = editor.diff().unwrap().comparison_id.unwrap(); + let owner = editor.pane_id(); + let hidden_tab = editor.tab_id(); + editor.execute("tab.new"); + let visible_tab = editor.tab_id(); + let visible_pane = editor.pane_id(); + let cursor = editor.cursor(); + editor.queue_checkout_refreshes(true); + let (tab, request, operation) = editor.take_next_diff_request().unwrap(); + assert_eq!(tab, hidden_tab); + assert!( + matches!(operation, Operation::RefreshCheckout { comparison_id, .. } + if comparison_id == old_comparison) + ); + assert!(!editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(None)))); + assert_eq!( + editor.tab_diff(hidden_tab).unwrap().comparison_id, + Some(old_comparison) + ); + assert_eq!(editor.tab_id(), visible_tab); + assert_eq!(editor.pane_id(), visible_pane); + assert_eq!(editor.buffer_id(), old_snapshot); + assert_eq!(editor.cursor(), cursor); + + editor.queue_checkout_refreshes(true); + let (_, request, _) = editor.take_next_diff_request().unwrap(); + let replacement = editor.with_pane(owner, |editor| { + editor.diff.as_mut().unwrap().comparison.take().unwrap() + }); + assert!(editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(replacement))))); + assert_eq!( + editor.tab_diff(hidden_tab).unwrap().comparison_id, + Some(request) + ); + assert_eq!(editor.tab_id(), visible_tab); + assert_eq!(editor.buffer_id(), old_snapshot); + assert_eq!(editor.cursor(), cursor); + assert!(editor.active.diff_snapshot.is_some()); + assert!( + editor + .apply(vec![Effect::Insert("rejected".into())]) + .is_err() + ); + assert!(!editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(None)))); + + editor.with_pane(owner, |editor| { + assert_ne!(editor.buffer_id(), old_snapshot); + editor.switch_buffer(old_snapshot); + editor.leave_diff_on_navigation(); + assert!(editor.diff().is_none()); + assert!(editor.active.diff_snapshot.is_some()); + assert!(editor.mutation_blocked()); + }); +} + +#[test] +fn bookmark_refresh_reloads_paths_and_preserves_view_until_lazy_content_arrives() { + use crate::diff::{ChangedFile, Comparison, FileStatus}; + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["old before".into(); 4], + vec!["old after".into(); 4], + ); + editor.diff.as_mut().unwrap().after.as_mut().unwrap().target = RevisionTarget::Bookmark { + name: "topic".into(), + remote: None, + }; + editor.diff.as_mut().unwrap().focus = Focus::After; + editor.bind_diff_buffer(); + editor.handle(Input::Char('j')); + editor.diff.as_mut().unwrap().top = 2; + editor.diff.as_mut().unwrap().left_after = 3; + editor.diff.as_mut().unwrap().reviewed_files.insert(0); + editor.diff.as_mut().unwrap().view_mode = ViewMode::Unified; + let old_comparison = editor.diff().unwrap().comparison_id.unwrap(); + let old_snapshot = editor.buffer_id(); + let cursor = editor.cursor(); + let file = |path: &str, loaded| ChangedFile { + path: path.into(), + old_path: None, + status: FileStatus::Modified, + loaded, + before: if loaded { + vec!["new before".into(); 3] + } else { + vec![] + }, + after: if loaded { + vec!["new after".into(); 3] + } else { + vec![] + }, + before_eof: false, + after_eof: false, + hunks: if loaded { + crate::diff::line_hunks( + &vec!["new before".into(); 3], + false, + &vec!["new after".into(); 3], + false, + ) + } else { + vec![] + }, + notice: None, + }; + editor.queue_checkout_refreshes(false); + let (_, refresh, _) = editor.take_next_diff_request().unwrap(); + assert!(editor.apply_diff_result( + refresh, + Ok(Outcome::BookmarkRefresh(Comparison { + root: directory.0.clone(), + revisions: ["base".into(), "topic-snapshot".into()], + before: "base".into(), + after: "topic".into(), + editable_paths: Default::default(), + files: vec![file("a-new.txt", false), file("file.txt", false)], + })) + )); + let diff = editor.diff().unwrap(); + assert_eq!(diff.file, 1); + assert_eq!(diff.focus, Focus::After); + assert_eq!(diff.view_mode, ViewMode::Unified); + assert!(diff.reviewed_files.is_empty()); + assert!(!diff.selected_file_loaded()); + assert!(!diff.comparison.as_ref().unwrap().files[0].loaded); + assert_eq!(editor.buffer_id(), old_snapshot); + let (_, load, operation) = editor.take_next_diff_request().unwrap(); + assert!( + matches!(operation, Operation::LoadFile { comparison_id, file_index: 1 } if comparison_id == refresh) + ); + assert!(editor.apply_diff_result( + load, + Ok(Outcome::File { + comparison_id: refresh, + file_index: 1, + file: file("file.txt", true), + }) + )); + assert_eq!(editor.lines(), ["new after"; 3]); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.diff().unwrap().top, 2); + assert_eq!(editor.diff().unwrap().left_after, 3); + assert_ne!(editor.buffer_id(), old_snapshot); + assert!(editor.mutation_blocked()); + assert!(!editor.apply_diff_result(refresh, Ok(Outcome::CheckoutRefresh(None)))); + assert_ne!(editor.diff().unwrap().comparison_id, Some(old_comparison)); +} + +#[test] +fn bookmark_refresh_errors_recover_and_hidden_replacements_preserve_dirty_buffers() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "original").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.diff.as_mut().unwrap().after.as_mut().unwrap().target = RevisionTarget::Bookmark { + name: "topic".into(), + remote: None, + }; + editor.diff.as_mut().unwrap().focus = Focus::After; + editor.bind_diff_buffer(); + let dirty_buffer = editor.buffer_id(); + type_text(&mut editor, "ix"); + editor.diff.as_mut().unwrap().reviewed_files.insert(0); + let comparison_id = editor.diff().unwrap().comparison_id; + editor.queue_checkout_refreshes(false); + let (_, request, _) = editor.take_next_diff_request().unwrap(); + assert!(editor.apply_diff_result(request, Err("bookmark is conflicted".into()))); + assert_eq!(editor.diff().unwrap().comparison_id, comparison_id); + assert_eq!(editor.lines(), ["original"]); + assert!(editor.diff().unwrap().reviewed_files.contains(&0)); + assert_eq!(editor.buffer(dirty_buffer).unwrap().lines, ["xoriginal"]); + assert!(editor.buffer(dirty_buffer).unwrap().dirty()); + editor.queue_checkout_refreshes(false); + let (_, recovery, _) = editor.take_next_diff_request().unwrap(); + assert!(editor.apply_diff_result(recovery, Ok(Outcome::CheckoutRefresh(None)))); + assert!(editor.diff().unwrap().error.is_none()); + assert_eq!(editor.diff().unwrap().comparison_id, comparison_id); + assert!(editor.diff().unwrap().reviewed_files.contains(&0)); + + let hidden_tab = editor.tab_id(); + let owner = editor.pane_id(); + let old_snapshot = editor.buffer_id(); + editor.execute("tab.new"); + let visible_tab = editor.tab_id(); + editor.queue_checkout_refreshes(false); + let (tab, request, _) = editor.take_next_diff_request().unwrap(); + assert_eq!(tab, hidden_tab); + let mut replacement = editor.with_pane(owner, |editor| { + editor.diff.as_mut().unwrap().comparison.take().unwrap() + }); + replacement.files[0].after = vec!["moved bookmark".into()]; + assert!(editor.apply_diff_result(request, Ok(Outcome::BookmarkRefresh(replacement)))); + assert_eq!(editor.tab_id(), visible_tab); + assert_eq!(editor.buffer_id(), old_snapshot); + assert_eq!(editor.lines(), ["original"]); + assert!(editor.mutation_blocked()); + assert_eq!(editor.buffer(dirty_buffer).unwrap().lines, ["xoriginal"]); + assert!(editor.buffer(dirty_buffer).unwrap().dirty()); + editor.with_pane(owner, |editor| { + assert_eq!(editor.lines(), ["moved bookmark"]); + assert!(editor.diff().unwrap().reviewed_files.is_empty()); + assert!(editor.mutation_blocked()); + }); + assert_eq!(fs::read_to_string(&path).unwrap(), "original"); +} + +#[test] +fn completed_working_refresh_serializes_lazy_selection_and_invalidates_pending_navigation() { + for request_kind in [ + "selection", + "after_selection", + "navigation", + "noop_navigation", + ] { + let directory = Directory::new(); + fs::write(directory.path("file.txt"), "working").unwrap(); + let mut editor = editor(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.diff.as_mut().unwrap().focus = Focus::Before; + editor.bind_diff_buffer(); + let old_snapshot = editor.buffer_id(); + let mut target = lazy_lsp_target(&mut editor); + let old_comparison = editor.diff().unwrap().comparison_id.unwrap(); + let virtual_root = editor.diff().unwrap().snapshot_roots[0].clone().unwrap(); + editor.queue_checkout_refreshes(true); + let (_, refresh, _) = editor.take_next_diff_request().unwrap(); + let loaded_before = target.before.clone(); + let loaded_after = target.after.clone(); + target.loaded = false; + let replacement = crate::diff::Comparison { + root: directory.0.clone(), + revisions: ["base".into(), "ahead-snapshot".into()], + before: "base".into(), + after: "ahead".into(), + editable_paths: Default::default(), + files: vec![target], + }; + let selection = request_kind.ends_with("selection"); + let focus = if request_kind == "after_selection" { + Focus::After + } else { + Focus::Before + }; + if selection { + assert!(editor.diff.as_mut().unwrap().select_file_index(1)); + editor.diff.as_mut().unwrap().focus = focus; + } else { + editor + .navigate_lsp_to(virtual_root.join("nested/old.txt"), lsp_point(1, 4), None) + .unwrap(); + assert!(editor.pending_lsp_navigation.is_some()); + assert_eq!(editor.diff().unwrap().file, 0); + } + assert!(editor.take_next_diff_request().is_none()); + let changed = request_kind != "noop_navigation"; + let outcome = if changed { Some(replacement) } else { None }; + assert_eq!( + editor.apply_diff_result(refresh, Ok(Outcome::CheckoutRefresh(outcome))), + changed + ); + if changed { + assert!(editor.pending_lsp_navigation.is_none()); + assert_eq!(editor.diff().unwrap().comparison_id, Some(refresh)); + assert_eq!(editor.diff().unwrap().file, 0); + assert_eq!(editor.diff().unwrap().focus, focus); + } else { + assert!(editor.pending_lsp_navigation.is_some()); + assert_eq!(editor.diff().unwrap().comparison_id, Some(old_comparison)); + } + let (_, load, operation) = editor.take_next_diff_request().unwrap(); + assert!( + matches!(operation, Operation::LoadFile { comparison_id, file_index } + if comparison_id == if changed { refresh } else { old_comparison } + && file_index == if changed { 0 } else { 1 }) + ); + if !changed { + assert_eq!( + editor.pending_lsp_navigation.as_ref().unwrap().request, + Some(load) + ); + } + if changed { + let file = crate::diff::ChangedFile { + path: "nested/new.txt".into(), + old_path: Some("nested/old.txt".into()), + status: crate::diff::FileStatus::Renamed, + loaded: true, + hunks: crate::diff::line_hunks(&loaded_before, false, &loaded_after, false), + before: loaded_before, + after: loaded_after, + before_eof: false, + after_eof: false, + notice: None, + }; + assert!(editor.apply_diff_result( + load, + Ok(Outcome::File { + comparison_id: refresh, + file_index: 0, + file, + }) + )); + assert_eq!(editor.diff().unwrap().focus, focus); + assert!(!editor.parked.contains_key(&old_snapshot)); + } + } +} + +fn lsp_point(row: usize, column: usize) -> Utf16Range { + Utf16Range { + start: (row, column), + end: (row, column), + } +} + +fn lazy_lsp_target(editor: &mut Editor) -> crate::diff::ChangedFile { + use crate::diff::{ChangedFile, FileStatus}; + let before = vec!["context".into(), "😀e\u{301} before".into()]; + let after = vec!["context".into(), "😀e\u{301} after".into()]; + let hunks = crate::diff::line_hunks(&before, false, &after, false); + let diff = editor.diff.as_mut().unwrap(); + let comparison = diff.comparison.as_mut().unwrap(); + comparison.files.push(ChangedFile { + path: "nested/new.txt".into(), + old_path: Some("nested/old.txt".into()), + status: FileStatus::Renamed, + loaded: false, + before: Vec::new(), + after: Vec::new(), + before_eof: false, + after_eof: false, + hunks: Vec::new(), + notice: None, + }); + diff.files = Some(FilePicker::from_paths( + comparison.root.clone(), + comparison + .files + .iter() + .enumerate() + .map(|(index, file)| (index, file.path.clone())), + )); + ChangedFile { + path: "nested/new.txt".into(), + old_path: Some("nested/old.txt".into()), + status: FileStatus::Renamed, + loaded: true, + before, + after, + before_eof: false, + after_eof: false, + hunks, + notice: None, + } +} + +#[test] +fn lsp_symbol_extent_reveals_exact_omitted_declaration_without_losing_comparison() { + let directory = Directory::new(); + let mut editor = editor(); + let before: Vec<_> = (0..70).map(|row| format!("line {row}")).collect(); + let mut after = before.clone(); + after[60] = "changed body".into(); + historical_diff(&mut editor, &directory.0, before, after); + editor.set_diff_context_options(1, 3); + let comparison = editor.diff().unwrap().comparison_id; + let snapshot = editor.buffer_id(); + let path = editor.active.snapshot_document_path.clone().unwrap(); + assert!( + editor + .diff() + .unwrap() + .source_display_row(Focus::Before, 4) + .is_none() + ); + editor + .navigate_lsp_to( + path.clone(), + lsp_point(4, 2), + Some(Utf16Range { + start: (4, 0), + end: (61, 0), + }), + ) + .unwrap(); + assert_eq!(editor.tab_count(), 1); + assert_eq!(editor.buffer_id(), snapshot); + assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); + assert_eq!(editor.diff().unwrap().comparison_id, comparison); + assert!( + editor + .diff() + .unwrap() + .source_display_row(Focus::Before, 4) + .is_some() + ); + editor.refresh_live_diff(); + assert_eq!(editor.cursor().row, 4); + editor + .navigate_lsp_to(path, lsp_point(20, 1), None) + .unwrap(); + assert_eq!(editor.tab_count(), 2); + assert!(editor.diff().is_none()); + assert_eq!(editor.buffer_id(), snapshot); + assert!(editor.mutation_blocked()); + editor.execute("tab.previous"); + assert_eq!(editor.diff().unwrap().comparison_id, comparison); + assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); +} + +#[test] +fn lazy_lsp_navigation_is_precise_and_source_departures_cancel_permanently() { + for departure in [ + "none", + "caret", + "side", + "tab", + "pane", + "close", + "replace", + "supersede", + ] { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["context".into(), "before".into()], + vec!["context".into(), "after".into()], + ); + let file = lazy_lsp_target(&mut editor); + let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); + let source = editor.buffer_id(); + let virtual_root = editor.diff().unwrap().snapshot_roots[0].clone().unwrap(); + editor + .navigate_lsp_to(virtual_root.join("nested/old.txt"), lsp_point(1, 4), None) + .unwrap(); + assert_eq!(editor.buffer_id(), source); + assert_eq!(editor.diff().unwrap().file, 0); + let (id, operation) = editor.take_diff_request().unwrap(); + assert!( + matches!(operation, Operation::LoadFile { comparison_id: owner, file_index: 1 } + if owner == comparison_id) + ); + match departure { + "none" => {} + "caret" => { + editor.handle(Input::Right); + editor.handle(Input::Left); + } + "side" => { + editor.scroll_diff(Focus::After, true); + editor.scroll_diff(Focus::Before, false); + } + "tab" => { + editor.execute("tab.new"); + editor.execute("tab.previous"); + } + "pane" => { + editor.execute("pane.split_vertical"); + } + "close" => { + editor.execute("diff.close"); + } + "replace" => { + editor.execute("diff"); + } + "supersede" => { + let path = editor.active.snapshot_document_path.clone().unwrap(); + editor.navigate_lsp_to(path, lsp_point(1, 0), None).unwrap(); + } + _ => unreachable!(), + } + let location = ( + editor.tab_id, + editor.pane_id, + editor.buffer_id(), + editor.cursor(), + ); + let selected = editor + .workspace_diff() + .map(|diff| (diff.comparison_id, diff.file, diff.focus)); + let tabs = editor.tab_count(); + editor.apply_diff_result( + id, + Ok(Outcome::File { + comparison_id, + file_index: 1, + file, + }), + ); + if departure == "none" { + assert_eq!(editor.diff().unwrap().file, 1); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); + assert_eq!( + editor.path(), + Some(directory.path("nested/old.txt").as_path()) + ); + assert_eq!( + editor.active.snapshot_document_path.as_ref(), + Some(&virtual_root.join("nested/old.txt")) + ); + assert_eq!(editor.tab_count(), 1); + } else { + assert_eq!( + ( + editor.tab_id, + editor.pane_id, + editor.buffer_id(), + editor.cursor() + ), + location, + "{departure}" + ); + assert_eq!( + editor + .workspace_diff() + .map(|diff| (diff.comparison_id, diff.file, diff.focus)), + selected, + "{departure}" + ); + assert_eq!(editor.tab_count(), tabs, "{departure}"); + } + assert!(editor.pending_lsp_navigation.is_none()); + } +} + +#[test] +fn lazy_lsp_preflight_validates_both_ranges_before_committing_a_target() { + for invalid_target_range in [false, true] { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["context".into(), "before".into()], + vec!["context".into(), "after".into()], + ); + let file = lazy_lsp_target(&mut editor); + let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); + let source = editor.jump_location(); + let documents = editor.interaction_documents().count(); + let invalid = Utf16Range { + start: (1, 0), + end: (99, 0), + }; + editor + .navigate_lsp_to( + directory.path("nested/old.txt"), + if invalid_target_range { + lsp_point(1, 0) + } else { + invalid + }, + invalid_target_range.then_some(invalid), + ) + .unwrap(); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result( + id, + Ok(Outcome::File { + comparison_id, + file_index: 1, + file, + }), + ); + assert_eq!(editor.jump_location(), source); + assert_eq!(editor.diff().unwrap().file, 0); + assert_eq!(editor.tab_count(), 1); + assert_eq!(editor.interaction_documents().count(), documents); + assert!(editor.message().contains("definition failed")); + } +} + +#[test] +fn superseded_lazy_results_cannot_consume_the_new_queued_navigation() { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["context".into(), "before".into()], + vec!["context".into(), "after".into()], + ); + let file = lazy_lsp_target(&mut editor); + let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); + let path = directory.path("nested/old.txt"); + let source = editor.jump_location(); + editor + .navigate_lsp_to(path.clone(), lsp_point(1, 0), None) + .unwrap(); + let (old, _) = editor.take_diff_request().unwrap(); + editor.navigate_lsp_to(path, lsp_point(1, 4), None).unwrap(); + assert!(!editor.apply_diff_result(old, Err("old load failed".into()))); + assert_eq!(editor.jump_location(), source); + assert!(editor.pending_lsp_navigation.is_some()); + let (current, _) = editor.take_diff_request().unwrap(); + assert_ne!(current, old); + assert!(!editor.apply_diff_result(old, Err("late old load failed".into()))); + assert!(editor.pending_lsp_navigation.is_some()); + editor.apply_diff_result( + current, + Ok(Outcome::File { + comparison_id, + file_index: 1, + file, + }), + ); + assert_eq!(editor.diff().unwrap().file, 1); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); + assert!(editor.pending_lsp_navigation.is_none()); + assert!(!editor.message().contains("old load failed")); +} + +#[test] +fn lazy_renamed_before_target_uses_shared_dirty_working_after_hunks() { + let directory = Directory::new(); + fs::create_dir(directory.path("nested")).unwrap(); + let path = directory.path("nested/new.txt"); + fs::write(&path, "context\nbefore").unwrap(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["context".into(), "source before".into()], + vec!["context".into(), "source after".into()], + ); + let mut file = lazy_lsp_target(&mut editor); + file.before = vec!["context".into(), "before".into()]; + file.after = file.before.clone(); + file.hunks.clear(); + let diff = editor.diff.as_mut().unwrap(); + diff.after.as_mut().unwrap().target = RevisionTarget::Working; + diff.comparison + .as_mut() + .unwrap() + .editable_paths + .insert("nested/new.txt".into()); + let comparison_id = diff.comparison_id.unwrap(); + let mut live = Buffer::open( + editor.next_buffer_id, + Some(path.clone()), + Some(path.canonicalize().unwrap()), + false, + ) + .unwrap(); + let mut lines = live.lines.clone(); + lines[1] = "dirty after".into(); + live.replace_lines(lines); + let live_id = live.id; + editor.next_buffer_id += 1; + editor.parked.insert(live_id, live); + editor + .navigate_lsp_to(directory.path("nested/old.txt"), lsp_point(1, 0), None) + .unwrap(); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result( + id, + Ok(Outcome::File { + comparison_id, + file_index: 1, + file, + }), + ); + assert_eq!(editor.tab_count(), 1); + assert_eq!(editor.diff().unwrap().file, 1); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + assert_eq!(editor.lines(), ["context", "before"]); + assert_eq!(editor.diff().unwrap().live_buffer, Some(live_id)); + assert!( + editor + .diff() + .unwrap() + .source_range_changed(1, Focus::Before, 1, 1) + ); + assert!(editor.mutation_blocked()); + assert_eq!( + editor.interaction_document_root(editor.buffer_id()), + directory.0.as_path() + ); + assert_eq!(editor.diff_root(editor.tab_id()), directory.0); + editor.handle(Input::Char('\t')); + assert_eq!(editor.buffer_id(), live_id); + assert!(editor.diff_editing()); + editor.navigate_lsp_to(path, lsp_point(1, 0), None).unwrap(); + assert_eq!(editor.tab_count(), 1); + assert_eq!(editor.diff().unwrap().comparison_id, Some(comparison_id)); + assert_eq!(editor.buffer_id(), live_id); + assert_eq!(editor.lines(), ["context", "dirty after"]); + assert!(editor.dirty()); +} + +#[test] +fn diff_documents_dismiss_notifications_but_files_and_selectors_keep_escape() { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["before".into(), "second".into()], + vec!["after".into(), "second".into()], + ); + set_carets(&mut editor, &[(0, 1), (1, 1)], 1); + editor.set_message("operation"); + editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + assert_eq!(editor.selection_count(), 2); + assert!(editor.diff_interacting()); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 1); + editor.set_message("operation"); + editor.diff.as_mut().unwrap().focus = Focus::Files; + editor.handle(Input::Escape); + assert!(editor.notification_visible()); + assert_ne!(editor.diff().unwrap().focus, Focus::Files); + editor.execute("diff.close"); + editor.execute("diff"); + editor.handle(Input::Char('q')); + assert_eq!(editor.diff().unwrap().query, "q"); + editor.handle(Input::Escape); + assert!(editor.diff().is_none()); + assert!(editor.notification_visible()); +} + +#[test] +fn readonly_native_panes_allow_selection_yank_search_and_multicarets() { + for focus in [Focus::Before, Focus::After] { + let directory = Directory::new(); + let mut editor = editor(); + let lines = ["alpha beta alpha", "second line", "third line"] + .map(str::to_owned) + .to_vec(); + let mut after = lines.clone(); + after[2] = "third changed".into(); + historical_diff(&mut editor, &directory.0, lines.clone(), after.clone()); + if focus == Focus::After { + editor.handle(Input::Char('\t')); + } + let id = editor.buffer_id(); + type_text(&mut editor, "2w"); + assert_eq!(editor.cursor().column, 11); + let targets = editor.active.view.targets.clone(); + let revision = editor.text_revision(); + type_text(&mut editor, "yy"); + assert_eq!(editor.clipboard, ["alpha beta alpha\n"]); + assert!(editor.clipboard_linewise); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.text_revision(), revision); + editor.handle(Input::Char('p')); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.text_revision(), revision); + type_text(&mut editor, "0v4ly"); + assert_eq!(editor.clipboard, ["alpha"]); + assert_eq!(editor.mode(), Mode::Normal); + type_text(&mut editor, "Vjy"); + assert!(editor.clipboard_linewise); + assert_eq!(editor.clipboard, ["alpha beta alpha\nsecond line\n"]); + type_text(&mut editor, "gt/alpha\nn"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 11 }); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor(), Cursor::default()); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.selection_count(), 2); + editor.handle(Input::Escape); + type_text(&mut editor, "gtv4l"); + editor.handle(Input::Ctrl('a')); + assert_eq!(editor.selection_count(), 2); + assert_eq!( + editor.lines(), + if focus == Focus::Before { + &lines + } else { + &after + } + ); + assert_eq!(editor.buffer_id(), id); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.documents().count(), 1); + assert!(!editor.dirty()); + } +} + +#[test] +fn readonly_native_mutation_rejection_is_atomic_and_cannot_write_disk() { + for focus in [Focus::Before, Focus::After] { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "disk").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + type_text(&mut editor, "iunsaved"); + editor.handle(Input::Escape); + let original_id = editor.buffer_id(); + historical_diff( + &mut editor, + &directory.0, + vec!["snapshot text".into()], + vec!["historic text".into()], + ); + if focus == Focus::After { + editor.handle(Input::Char('\t')); + } + editor + .bind("normal", "z", Some("core.insert".into())) + .unwrap(); + editor + .bind("normal", "leader d", Some("core.delete_line".into())) + .unwrap(); + editor + .bind("normal", "Q", Some("core.save".into())) + .unwrap(); + let text = editor.lines().to_vec(); + let revision = editor.text_revision(); + for keys in [ + "i", "a", "o", "O", "dd", "cc", "x", "J", "u", ".", "p", "z", " d", "Q", + ] { + type_text(&mut editor, keys); + assert_eq!(editor.mode(), Mode::Normal, "{keys}"); + assert_eq!(editor.lines(), text, "{keys}"); + } + editor.handle(Input::Ctrl('r')); + editor.handle(Input::Paste("literal paste".into())); + type_text(&mut editor, "vl"); + let selections = editor.selections(); + for keys in ["d", "c", "p", "."] { + type_text(&mut editor, keys); + assert_eq!(editor.selections(), selections, "{keys}"); + assert_eq!(editor.lines(), text, "{keys}"); + } + for effect in [ + Effect::Insert("plugin".into()), + Effect::Replace("plugin".into()), + ] { + assert!( + editor + .apply(vec![Effect::Message("must not leak".into()), effect]) + .is_err() + ); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.lines(), text); + } + for command in [ + "w".to_owned(), + format!("w {}", path.display()), + format!("wq {}", path.display()), + "save_and_quit".into(), + ] { + editor.execute(&command); + assert_eq!(fs::read_to_string(&path).unwrap(), "disk"); + assert!(!editor.should_quit()); + } + assert_eq!(editor.text_revision(), revision); + let snapshot_id = editor.buffer_id(); + editor.execute("diff.close"); + assert_eq!(editor.buffer_id(), original_id); + assert_eq!(editor.lines(), ["unsaveddisk"]); + assert!(!editor.has_buffer(snapshot_id)); + assert_eq!(editor.parked.len(), 0); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["disk"]); + } +} + +#[test] +fn diff_focus_maps_aligned_rows_and_retains_display_column_through_short_lines() { + let directory = Directory::new(); + let mut editor = editor(); + let before = ["start", "removed", "anchor", "\t界abcdef", "tail"] + .map(str::to_owned) + .to_vec(); + let after = ["start", "added one", "added two", "anchor", "\t界a", "tail"] + .map(str::to_owned) + .to_vec(); + historical_diff(&mut editor, &directory.0, before, after); + type_text(&mut editor, "3j6l"); + let original = editor.cursor(); + assert_eq!(original, Cursor { row: 3, column: 6 }); + editor.handle(Input::Char('\t')); + assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); + editor.handle(Input::BackTab); + assert_eq!(editor.cursor(), original); + editor.scroll_diff(Focus::After, true); + assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); + editor.scroll_diff(Focus::Before, false); + assert_eq!(editor.cursor(), original); + editor.handle(Input::BackTab); + assert_eq!(editor.diff().unwrap().focus, Focus::Files); + editor.handle(Input::Escape); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + assert_eq!(editor.cursor(), original); + type_text(&mut editor, "gtj"); + editor.handle(Input::Char('\t')); + assert_eq!(editor.cursor().row, 1); + type_text(&mut editor, "gt2j"); + assert_eq!(editor.cursor().row, 2); + assert!( + editor + .diff() + .unwrap() + .rows + .iter() + .any(|row| row.after == Some(2) && row.before.is_none()) + ); + editor.handle(Input::BackTab); + assert_eq!(editor.cursor().row, 1); +} + +#[test] +fn readonly_excerpts_skip_hidden_rows_for_motions_search_clones_and_expand_only_normal() { + let directory = Directory::new(); + let mut editor = editor(); + let before: Vec<_> = (0..60).map(|row| format!("line {row}")).collect(); + let mut after = before.clone(); + after[10] = "changed 10".into(); + after[40] = "changed 40".into(); + editor.set_diff_context_options(1, 3); + historical_diff(&mut editor, &directory.0, before, after); + for focus in [Focus::Before, Focus::After] { + if editor.diff().unwrap().focus != focus { + editor.handle(Input::Char('\t')); + } + type_text(&mut editor, "gt3j"); + assert_eq!(editor.cursor().row, 39); + type_text(&mut editor, "gt2j"); + assert_eq!(editor.cursor().row, 11); + editor.handle(Input::Ctrl('e')); + assert_eq!( + editor + .carets() + .iter() + .map(|cursor| cursor.row) + .collect::>(), + [11, 39] + ); + editor.handle(Input::Escape); + type_text(&mut editor, "/line 20\n"); + assert!(editor.message().starts_with("Not found:")); + type_text(&mut editor, "gtv]"); + assert_eq!(editor.mode(), Mode::Visual); + assert!( + editor + .diff() + .unwrap() + .source_display_row(focus, 12) + .is_none() + ); + editor.handle(Input::Escape); + type_text(&mut editor, "gt]"); + assert!( + editor + .diff() + .unwrap() + .source_display_row(focus, 12) + .is_some() + ); + // Rebuild the excerpt configuration before checking the other pane. + editor.set_diff_context_options(2, 3); + editor.set_diff_context_options(1, 3); + } +} + +#[test] +fn readonly_snapshots_keep_pane_selection_and_safe_document_identity_for_navigation() { + let directory = Directory::new(); + let mut editor = editor(); + let before = ["alpha beta", "second line", "before tail"] + .map(str::to_owned) + .to_vec(); + let after = ["alpha beta", "second line", "after tail"] + .map(str::to_owned) + .to_vec(); + historical_diff(&mut editor, &directory.0, before, after); + let before_id = editor.buffer_id(); + let source_path = editor.path().unwrap().to_owned(); + let snapshot_path = editor + .interaction_documents() + .find(|document| document.id == before_id) + .unwrap() + .path + .unwrap() + .to_owned(); + assert_ne!(snapshot_path, source_path); + assert!(!snapshot_path.exists()); + assert!(editor.active.identity.is_none()); + type_text(&mut editor, "v3l"); + editor.handle(Input::Ctrl('e')); + let anchor = editor.active.view.targets[editor.active.view.primary].anchor; + let carets = editor.selection_count(); + editor.scroll_diff(Focus::After, true); + assert_eq!(editor.mode(), Mode::Normal); + editor.scroll_diff(Focus::Before, false); + assert_eq!(editor.buffer_id(), before_id); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selection_count(), carets); + assert_eq!( + editor.active.view.targets[editor.active.view.primary].anchor, + anchor + ); + editor + .navigate_to(snapshot_path, TextPosition { row: 1, column: 2 }) + .unwrap(); + assert!(editor.diff_interacting()); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); + editor + .register(Feature { + name: "readonly_fixture".into(), + required_capabilities: vec![], + core: false, + commands: vec!["readonly_fixture.command".into()], + }) + .unwrap(); + editor + .bind("normal", "R", Some("readonly_fixture.command".into())) + .unwrap(); + let invocation = editor.handle(Input::Char('R')).unwrap(); + assert_eq!(invocation.plugin, "readonly_fixture"); + assert!( + editor + .apply(vec![Effect::Insert("blocked".into())]) + .is_err() + ); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.word_end"); + editor.handle(Input::Enter); + assert_eq!(editor.cursor().column, 5); + editor.execute("diff.close"); + assert!( + editor + .interaction_documents() + .all(|document| document.id != before_id) + ); +} + +#[test] +fn no_native_diff_pane_cannot_mutate_hidden_live_buffer() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "original").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + let file = &mut editor + .diff + .as_mut() + .unwrap() + .comparison + .as_mut() + .unwrap() + .files[0]; + file.status = crate::diff::FileStatus::Renamed; + file.old_path = Some("old.txt".into()); + file.before = file.after.clone(); + file.before_eof = file.after_eof; + file.hunks.clear(); + editor.handle(Input::Char('\t')); + assert!(!editor.diff_interacting()); + type_text(&mut editor, "iddccx"); + editor + .apply(vec![Effect::Insert("blocked".into())]) + .unwrap_err(); + editor.execute(&format!("w {}", path.display())); + assert_eq!(fs::read_to_string(path).unwrap(), "original"); + editor.execute("diff.close"); + assert_eq!(editor.lines(), ["original"]); +} + +#[test] +fn readonly_follow_link_and_jump_use_native_navigation_without_snapshot_identity() { + let directory = Directory::new(); + let mut editor = editor(); + let before = ["[go](#section)", "# Section", "before"] + .map(str::to_owned) + .to_vec(); + let after = ["[go](#section)", "# Section", "after"] + .map(str::to_owned) + .to_vec(); + historical_diff(&mut editor, &directory.0, before, after); + editor.active.path = Some(directory.path("file.md")); + type_text(&mut editor, "gf"); + assert_eq!(editor.cursor().row, 1); + assert!(editor.diff_interacting()); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor().row, 0); + assert!(editor.diff_interacting()); + let target = directory.path("target.md"); + fs::write(&target, "# Target\n").unwrap(); + editor.execute("diff.close"); + historical_diff( + &mut editor, + &directory.0, + vec!["[go](target.md)".into()], + vec!["[after](target.md)".into()], + ); + editor.active.path = Some(directory.path("file.md")); + type_text(&mut editor, "gf"); + assert!(editor.diff().is_none()); + assert_eq!(editor.path(), Some(target.as_path())); + assert_eq!(editor.lines(), ["# Target", ""]); +} + +#[test] +fn editable_diff_brackets_expand_caret_excerpt_without_moving_source_anchor() { + for selected in [30, 50] { + let directory = Directory::new(); + let path = directory.path("file.txt"); + let before: Vec = (0..80).map(|row| format!("line {row}")).collect(); + fs::write(&path, before.join("\n")).unwrap(); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + let mut after = before; + for row in [10, 30, 50] { + after[row] = format!("changed {row}"); + } + editor + .apply(vec![Effect::Replace(after.join("\n"))]) + .unwrap(); + editor.set_diff_context_options(1, 4); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.handle(Input::BackTab); + assert!(editor.diff_editing()); + editor.active.view.targets = vec![Target::caret(Cursor { + row: selected, + column: 2, + })]; + let top = editor.diff().unwrap().after_display_row(9).unwrap(); + editor.diff.as_mut().unwrap().top = top; + let cursor = editor.cursor(); + let visible = |editor: &Editor| { + editor + .diff() + .unwrap() + .source_rows(Focus::After) + .collect::>() + }; + assert_eq!( + visible(&editor), + (9..12).chain(29..32).chain(49..52).collect::>() + ); + editor.handle(Input::Char('[')); + editor.handle(Input::Char(']')); + let expected = if selected == 30 { + (9..12).chain(25..36).chain(49..52).collect::>() + } else { + (9..12).chain(29..32).chain(45..56).collect::>() + }; + assert_eq!(visible(&editor), expected); + assert_eq!(editor.cursor(), cursor); + assert_eq!( + editor.diff().unwrap().after_display_row(9), + Some(editor.diff().unwrap().top) + ); + if selected == 30 { + // The caret keeps targeting the second excerpt after it merges + // with the third, rather than expanding the first excerpt. + for _ in 0..5 { + editor.handle(Input::Char(']')); + } + assert_eq!(visible(&editor), (9..12).chain(25..56).collect::>()); + assert_eq!(editor.cursor(), cursor); + assert_eq!( + editor.diff().unwrap().after_display_row(9), + Some(editor.diff().unwrap().top) + ); + } + } +} + +#[test] +fn working_diff_reuses_dirty_buffers_and_normal_edit_undo_save_paths() { + let directory = Directory::new(); + let a = directory.path("a.txt"); + let b = directory.path("b.txt"); + fs::write(&a, "alpha\n").unwrap(); + fs::write(&b, "beta").unwrap(); + let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); + type_text(&mut editor, "iunsaved"); + editor.handle(Input::Escape); + editor.execute(&format!("edit {}", b.display())); + type_text(&mut editor, "iother"); + editor.handle(Input::Escape); + let overlays = working_diff(&mut editor, &directory.0, &["a.txt", "b.txt"]); + assert_eq!(overlays.len(), 2); + assert!(overlays.contains(&(a.canonicalize().unwrap(), "unsavedalpha\n".into()))); + assert!(overlays.contains(&(b.canonicalize().unwrap(), "otherbeta".into()))); + assert_eq!(editor.buffer_count(), 2); + assert_eq!(editor.buffer_id(), 1); + assert!(editor.diff_editable()); + assert!(!editor.diff_editing()); + editor.handle(Input::BackTab); + assert!(editor.diff_editing()); + type_text(&mut editor, "0i[]"); + editor.handle(Input::Char('\t')); + editor.handle(Input::Escape); + assert_eq!(editor.lines()[0], "[]\tunsavedalpha"); + assert!(editor.diff().is_some()); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines()[0], "unsavedalpha"); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["alpha", ""]); + let row = editor.cursor().row; + assert!(editor.diff().unwrap().after_display_row(row).is_some()); + type_text(&mut editor, "ccreplacement"); + editor.handle(Input::Escape); + editor.execute("w"); + assert_eq!(fs::read_to_string(&a).unwrap(), "replacement\n"); + assert!(!editor.dirty()); + assert_eq!(editor.buffer(2).unwrap().lines, ["otherbeta"]); + editor.execute("diff.close"); + assert!(editor.diff().is_none()); + editor.execute("buffer 2"); + assert!(editor.dirty()); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["beta"]); + for refresh_error in [false, true] { + let path = directory.path("file.txt"); + fs::write(&path, "original").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["original".into()], + ); + let endpoints = ( + editor.diff().unwrap().before.clone(), + editor.diff().unwrap().after.clone(), + ); + editor.queue_checkout_refreshes(false); + let (_, request, _) = editor.take_next_diff_request().unwrap(); + let mut promoted = editor.diff.as_mut().unwrap().comparison.take().unwrap(); + promoted.editable_paths.insert("file.txt".into()); + editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(promoted)))); + editor.diff.as_mut().unwrap().focus = Focus::After; + editor.bind_diff_buffer(); + assert!(editor.diff_editing()); + let live = editor.buffer_id(); + type_text(&mut editor, "ix"); + assert_eq!(editor.mode(), Mode::Insert); + let comparison_id = editor.diff().unwrap().comparison_id; + editor.queue_checkout_refreshes(false); + let (_, request, _) = editor.take_next_diff_request().unwrap(); + if refresh_error { + assert!(editor.apply_diff_result(request, Err("checkout unavailable".into()))); + assert_eq!(editor.diff().unwrap().comparison_id, comparison_id); + } else { + let mut demoted = editor.diff.as_mut().unwrap().comparison.take().unwrap(); + demoted.editable_paths.clear(); + demoted.files[0].after = vec!["original".into()]; + demoted.files[0].after_eof = false; + demoted.files[0].hunks = crate::diff::line_hunks( + &demoted.files[0].before, + false, + &demoted.files[0].after, + false, + ); + assert!( + editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(demoted))),) + ); + } + assert!(!editor.diff_editable()); + assert_eq!(editor.lines(), ["original"]); + assert_eq!(editor.buffer(live).unwrap().lines, ["xoriginal"]); + assert!(editor.buffer(live).unwrap().dirty()); + assert_eq!(editor.buffer(live).unwrap().view.mode, Mode::Normal); + assert!( + editor + .apply(vec![Effect::Insert("blocked".into())]) + .is_err() + ); + editor.execute("w"); + assert_eq!(fs::read_to_string(&path).unwrap(), "original"); + editor.queue_checkout_refreshes(false); + let (_, request, _) = editor.take_next_diff_request().unwrap(); + let mut promoted = editor.diff.as_mut().unwrap().comparison.take().unwrap(); + promoted.editable_paths.insert("file.txt".into()); + editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(promoted)))); + assert!(editor.diff_editing()); + assert!(editor.diff().unwrap().error.is_none()); + assert_eq!(editor.buffer_id(), live); + assert_eq!(editor.lines(), ["xoriginal"]); + assert_eq!( + ( + editor.diff().unwrap().before.clone(), + editor.diff().unwrap().after.clone(), + ), + endpoints, + ); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["original"]); + } +} + +#[test] +fn diff_review_actions_preserve_readonly_text_history_and_parked_tabs() { + let directory = Directory::new(); + let mut editor = editor(); + editor.execute("diff.review.toggle"); + assert_eq!(editor.message(), "No comparison is open"); + historical_diff( + &mut editor, + &directory.0, + vec!["before".into(), "second".into()], + vec!["after".into(), "second".into()], + ); + editor + .bind("normal", "ctrl+y", Some("core.diff.review.toggle".into())) + .unwrap(); + let text = editor.lines().to_vec(); + let carets = editor.carets(); + let revision = editor.text_revision(); + let undo = editor.active.undo.len(); + let redo = editor.active.redo.len(); + let dirty = editor.dirty(); + assert!(editor.mutation_blocked()); + editor.handle(Input::Ctrl('y')); + assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0])); + editor.execute("diff.review.toggle unexpected"); + assert!(editor.message().contains("takes no arguments")); + assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0])); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "diff.review.toggle"); + editor.handle(Input::Enter); + assert!(editor.diff().unwrap().reviewed_files.is_empty()); + assert_eq!(editor.lines(), text); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.text_revision(), revision); + assert_eq!(editor.active.undo.len(), undo); + assert_eq!(editor.active.redo.len(), redo); + assert_eq!(editor.dirty(), dirty); + assert!(!directory.path("file.txt").exists()); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + assert!(editor.mutation_blocked()); + editor.execute(":diff.review.toggle"); + editor.execute("diff.view.toggle"); + editor.execute("diff.view.toggle"); + editor.execute("diff.orientation.toggle"); + let first_tab = editor.tab_id(); + editor.execute("tab.new"); + historical_diff( + &mut editor, + &directory.0, + vec!["base".into()], + vec!["different".into()], + ); + assert!(editor.diff().unwrap().reviewed_files.is_empty()); + editor.execute("tab.previous"); + assert_eq!(editor.tab_id(), first_tab); + assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0])); + editor.execute("diff.close"); + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["after".into()], + ); + assert!(editor.diff().unwrap().reviewed_files.is_empty()); +} + +#[test] +fn diff_review_c_toggles_only_while_browsing_and_respects_overrides() { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["after".into()], + ); + editor.handle(Input::BackTab); + assert_eq!(editor.diff().unwrap().focus, Focus::Files); + editor.handle(Input::Char('c')); + assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0])); + editor.handle(Input::Char('c')); + assert!(editor.diff().unwrap().reviewed_files.is_empty()); + editor.handle(Input::Char('i')); + editor.handle(Input::Char('c')); + assert_eq!(editor.diff().unwrap().files.as_ref().unwrap().query, "c"); + assert!(editor.diff().unwrap().reviewed_files.is_empty()); + editor.handle(Input::Escape); + editor.handle(Input::Ctrl('u')); + editor.bind("normal", "c", None).unwrap(); + editor.handle(Input::Char('c')); + assert!(editor.diff().unwrap().reviewed_files.is_empty()); +} + +#[test] +fn diff_review_files_action_targets_highlight_without_stealing_filter_text() { + let directory = Directory::new(); + fs::write(directory.path("z.txt"), "z").unwrap(); + fs::write(directory.path("a.txt"), "a").unwrap(); + let mut editor = editor(); + working_diff(&mut editor, &directory.0, &["z.txt", "a.txt"]); + editor + .bind("normal", "ctrl+y", Some("core.diff.review.toggle".into())) + .unwrap(); + editor + .bind("normal", "Q", Some("core.diff.review.toggle".into())) + .unwrap(); + let files = editor.diff.as_mut().unwrap().files.as_mut().unwrap(); + files.set_view(FileView::List); + files.reveal_path(Path::new("a.txt")); + editor.handle(Input::Ctrl('y')); + assert_eq!(editor.diff().unwrap().file, 0); + assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); + editor.handle(Input::Char('Q')); + assert_eq!(editor.file_picker().unwrap().query, "Q"); + assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); + editor.handle(Input::Ctrl('u')); + let files = editor.diff.as_mut().unwrap().files.as_mut().unwrap(); + files.set_view(FileView::Tree); + files.start_search(); + editor.handle(Input::Char('Q')); + assert_eq!(editor.file_picker().unwrap().query, "Q"); + assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); + editor.handle(Input::Ctrl('y')); + assert_eq!(editor.message(), "No changed file is selected"); + assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); + editor.file_picker = Some( + FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::List, + ) + .unwrap(), + ); + editor.execute("diff.review.toggle"); + assert_eq!(editor.message(), "Select a file in the comparison"); + assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); +} + +#[test] +fn working_review_marks_survive_save_and_clear_only_the_edited_file() { + let directory = Directory::new(); + let first = directory.path("first.txt"); + fs::write(&first, "base\n").unwrap(); + fs::write(directory.path("second.txt"), "second").unwrap(); + let mut editor = Editor::open(Some(first.clone()), vec![]).unwrap(); + working_diff(&mut editor, &directory.0, &["first.txt", "second.txt"]); + editor.execute("diff.review.toggle"); + editor + .diff + .as_mut() + .unwrap() + .files + .as_mut() + .unwrap() + .reveal_path(Path::new("second.txt")); + editor.execute("diff.review.toggle"); + assert_eq!( + editor.diff().unwrap().reviewed_files, + BTreeSet::from([0, 1]) + ); + editor.handle(Input::BackTab); + assert!(editor.diff_editing()); + editor.execute("w"); + assert_eq!( + editor.diff().unwrap().reviewed_files, + BTreeSet::from([0, 1]) + ); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); + editor.execute("diff.review.toggle"); + editor.execute("w"); + assert_eq!( + editor.diff().unwrap().reviewed_files, + BTreeSet::from([0, 1]) + ); + assert_eq!(fs::read_to_string(&first).unwrap(), "Xbase\n"); + editor.handle(Input::Char('u')); + assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([1])); +} + +#[test] +fn diff_display_defaults_tabs_and_remaps_preserve_native_snapshot_carets() { + let directory = Directory::new(); + let mut editor = editor(); + editor.execute("diff.view.toggle"); + assert_eq!(editor.message(), "No comparison is open"); + editor.set_diff_display_options(ViewMode::Unified, SplitAxis::Horizontal); + editor.execute("diff"); + type_text(&mut editor, " v"); + assert_eq!(editor.diff().unwrap().query, " v"); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); + historical_diff( + &mut editor, + &directory.0, + vec!["old one".into(), "old two".into()], + vec!["new one".into(), "new two".into()], + ); + let first_tab = editor.tab_id(); + let buffer = editor.buffer_id(); + editor.handle(Input::Char('j')); + let caret = editor.cursor(); + let text = editor.lines().to_vec(); + assert!(!editor.diff_editing()); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); + editor.execute("diff.orientation.toggle"); + assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); + editor + .bind("normal", "Q", Some("core.diff.view.toggle".into())) + .unwrap(); + assert!( + editor + .bind("insert", "Q", Some("core.diff.view.toggle".into())) + .is_err() + ); + editor.handle(Input::Char('Q')); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); + editor + .bind("normal", "W", Some("core.diff.orientation.toggle".into())) + .unwrap(); + editor.handle(Input::Char('W')); + assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Vertical); + type_text(&mut editor, " v"); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); + assert_eq!(editor.buffer_id(), buffer); + assert_eq!(editor.cursor(), caret); + assert_eq!(editor.lines(), text); + editor.set_diff_display_options(ViewMode::Split, SplitAxis::Horizontal); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "diff.view.toggle"); + assert!( + editor + .palette() + .unwrap() + .entries + .iter() + .any(|entry| entry.name == "core.diff.view.toggle" && entry.enabled) + ); + editor.handle(Input::Enter); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); + editor.execute("tab.new"); + historical_diff( + &mut editor, + &directory.0, + vec!["old".into()], + vec!["new".into()], + ); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); + assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); + editor.execute("tab.previous"); + assert_eq!(editor.tab_id(), first_tab); + assert_eq!(editor.buffer_id(), buffer); + assert_eq!(editor.cursor(), caret); + assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Vertical); +} + +#[test] +fn diff_display_actions_keep_working_edits_files_and_filter_input_native() { + let directory = Directory::new(); + let first = directory.path("first.txt"); + fs::write(&first, "base").unwrap(); + fs::write(directory.path("second.txt"), "second").unwrap(); + let mut editor = Editor::open(Some(first), vec![]).unwrap(); + let working = editor.buffer_id(); + working_diff(&mut editor, &directory.0, &["first.txt", "second.txt"]); + assert_eq!(editor.diff().unwrap().focus, Focus::Files); + type_text(&mut editor, " s"); + assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); + type_text(&mut editor, " v"); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); + editor.handle(Input::Char('i')); + type_text(&mut editor, " v"); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); + assert_eq!(editor.file_picker().unwrap().query, " v"); + editor.handle(Input::Escape); + editor.handle(Input::BackTab); + assert!(editor.diff_editing()); + assert_eq!(editor.buffer_id(), working); + type_text(&mut editor, "i dv"); + assert_eq!(editor.lines(), [" dvbase"]); + editor.handle(Input::Escape); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); + assert!(editor.diff().unwrap().after_display_row(0).is_some()); + let caret = editor.cursor(); + type_text(&mut editor, " v"); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); + assert_eq!(editor.cursor(), caret); + assert!(editor.diff_editing()); + assert!(editor.diff.as_mut().unwrap().select_file_index(1)); + editor.bind_diff_buffer(); + assert_eq!(editor.diff().unwrap().file, 1); + assert_eq!(editor.diff().unwrap().split_axis, SplitAxis::Horizontal); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Split); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + type_text(&mut editor, " v"); + assert_eq!(editor.diff().unwrap().view_mode, ViewMode::Unified); + assert!(editor.diff_editing()); + assert_eq!(editor.lines(), ["second"]); +} + +#[test] +fn word_wrap_toggle_retains_buffers_and_readonly_diff_state() { + let directory = Directory::new(); + let mut editor = editor(); + assert!(!editor.word_wrap()); + editor + .bind("normal", "W", Some("core.word_wrap.toggle".into())) + .unwrap(); + editor.handle(Input::Char('W')); + assert!(editor.word_wrap()); + let original = editor.buffer_id(); + editor.execute(&format!("edit {}", directory.path("other").display())); + assert!(!editor.word_wrap()); + editor.execute(&format!("buffer {original}")); + assert!(editor.word_wrap()); + editor.execute("word_wrap.toggle unexpected"); + assert!(editor.word_wrap()); + editor.execute("word_wrap.toggle"); + assert!(!editor.word_wrap()); + editor.execute("word_wrap.toggle"); + historical_diff( + &mut editor, + &directory.0, + vec!["alpha beta".into()], + vec!["alpha changed".into()], + ); + assert!(editor.word_wrap()); + editor + .bind("visual", "W", Some("core.word_wrap.toggle".into())) + .unwrap(); + type_text(&mut editor, "v2l"); + let text = editor.lines().to_vec(); + let selections = editor.selections(); + let cursor = editor.cursor(); + let revision = editor.active.text_revision; + let history = (editor.active.undo.len(), editor.active.redo.len()); + let comparison = editor.diff().unwrap().comparison_id; + editor.execute("word_wrap.toggle unexpected"); + assert!(editor.word_wrap()); + editor.handle(Input::Char('W')); + assert!(!editor.word_wrap()); + assert_eq!(editor.lines(), text); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.active.text_revision, revision); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + history + ); + assert_eq!(editor.diff().unwrap().comparison_id, comparison); + assert_eq!(editor.diff().unwrap().phase, Phase::View); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "word_wrap.toggle"); + editor.handle(Input::Enter); + assert!(editor.word_wrap()); + editor.execute("diff.close"); + assert!(editor.word_wrap()); +} + +#[test] +fn diff_readonly_panes_dispatch_prompts_palette_and_picker_remaps_without_edits() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + type_text(&mut editor, "idd"); + assert_eq!(editor.lines(), ["base"]); + editor.execute("w"); + assert_eq!(fs::read_to_string(&path).unwrap(), "base"); + assert!( + editor + .apply(vec![Effect::Insert("blocked".into())]) + .is_err() + ); + editor + .bind("normal", "ctrl+e", Some("core.right".into())) + .unwrap(); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + editor + .bind("normal", "leader f", Some("core.file_picker".into())) + .unwrap(); + type_text(&mut editor, " f"); + assert_eq!( + editor.file_picker().unwrap().presentation, + FilePresentation::Floating + ); + editor.handle(Input::Escape); + editor.handle(Input::Escape); + assert!(editor.file_picker().is_none()); + assert!(editor.diff().is_some()); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "diff.close"); + editor.handle(Input::Enter); + assert!(editor.diff().is_none()); + editor.execute("diff"); + type_text(&mut editor, ":diffclose\n"); + assert!(editor.diff().is_some()); + editor.execute("diff.close"); + assert!(editor.diff().is_none()); +} + +#[test] +fn diff_wheel_scroll_does_not_move_or_edit_insert_caret_and_plugin_edits_refresh() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "first\nsecond\n").unwrap(); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.handle(Input::BackTab); + editor.handle(Input::Char('i')); + let caret = editor.cursor(); + editor.scroll_diff(Focus::After, true); + assert_eq!(editor.cursor(), caret); + assert!(!editor.diff().unwrap().follow_caret); + assert_eq!(editor.lines(), ["first", "second", ""]); + editor.apply(vec![Effect::Insert("plugin".into())]).unwrap(); + assert_eq!( + editor.diff().unwrap().comparison.as_ref().unwrap().files[0].after, + editor.lines() + ); + assert_eq!( + editor.diff().unwrap().live_revision, + Some(editor.text_revision()) + ); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["first", "second", ""]); + editor.execute("diff.close"); +} + +#[cfg(unix)] +#[test] +fn working_diff_rejects_symlinks_and_paths_escaping_repository_root() { + use std::os::unix::fs::symlink; + let directory = Directory::new(); + let outside = Directory::new(); + let main = directory.path("main.txt"); + let target = outside.path("target.txt"); + fs::write(&main, "main").unwrap(); + fs::write(&target, "outside").unwrap(); + symlink(&target, directory.path("link.txt")).unwrap(); + symlink(&outside.0, directory.path("escape")).unwrap(); + let mut editor = Editor::open(Some(main), vec![]).unwrap(); + for path in ["link.txt", "escape/target.txt"] { + working_diff(&mut editor, &directory.0, &[path]); + assert!(!editor.diff_editable()); + assert_eq!(editor.buffer_count(), 1); + editor.handle(Input::BackTab); + type_text(&mut editor, "iX"); + editor.execute("w"); + assert_eq!(fs::read_to_string(&target).unwrap(), "outside"); + editor.execute("diff.close"); + assert_eq!(editor.lines(), ["main"]); + } +} + +#[test] +fn selecting_removed_or_historical_diff_file_never_opens_an_unrelated_buffer() { + let directory = Directory::new(); + let main = directory.path("main.txt"); + let other = directory.path("other.txt"); + fs::write(&main, "main").unwrap(); + fs::write(&other, "other").unwrap(); + let mut editor = Editor::open(Some(main), vec![]).unwrap(); + working_diff(&mut editor, &directory.0, &["main.txt", "other.txt"]); + let diff = editor.diff.as_mut().unwrap(); + let comparison = diff.comparison.as_mut().unwrap(); + comparison.editable_paths.remove(Path::new("other.txt")); + comparison.files[1].status = crate::diff::FileStatus::Removed; + comparison.files[1].after.clear(); + assert!(diff.select_file(Path::new("other.txt"))); + editor.handle(Input::BackTab); + assert!(!editor.diff_editable()); + assert_eq!(editor.buffer_count(), 1); + type_text(&mut editor, "iX"); + editor.execute("w"); + assert_eq!(fs::read_to_string(&other).unwrap(), "other"); + let diff = editor.diff.as_mut().unwrap(); + diff.after.as_mut().unwrap().target = RevisionTarget::Commit("old".into()); + assert!(diff.select_file(Path::new("main.txt"))); + editor.handle(Input::Char('i')); + assert!(!editor.diff_editable()); + assert_eq!(editor.buffer_count(), 1); + editor.execute("diff.close"); + assert_eq!(editor.lines(), ["main"]); +} + +#[test] +fn line_actions_counted_delete_restores_caret_and_pastes_whole_rows() { + let mut editor = editor(); + let source = "zero\n αone\n\tlast\nend"; + editor.apply(vec![Effect::Replace(source.into())]).unwrap(); + type_text(&mut editor, "jll2dd"); + assert_eq!(editor.context().text, "zero\nend"); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, source); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); + type_text(&mut editor, "UP"); + assert_eq!(editor.context().text, source); +} + +#[test] +fn line_actions_counted_change_groups_typing_and_repeats_resolved_rows() { + let mut editor = editor(); + let source = "keep\n one\ntwo\nend"; + editor.apply(vec![Effect::Replace(source.into())]).unwrap(); + type_text(&mut editor, "j2cc"); + assert_eq!(editor.context().text, "keep\n\nend"); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + type_text(&mut editor, "new"); + editor.handle(Input::Escape); + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, source); + type_text(&mut editor, "Uj."); + assert_eq!(editor.context().text, "keep\nnew\nnew"); + assert_eq!(editor.mode(), Mode::Normal); + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, "keep\nnew\nend"); +} + +#[test] +fn line_actions_eof_empty_rows_and_repeat_keep_the_requested_count() { + for (source, keys, expected) in [ + ("a\nb", "jdd", "a"), + ("a\nb\n", "jdd", "a\n"), + ("a\nb\n", "gbdd", "a\nb"), + ("a", "dd", ""), + ("", "dd", ""), + ("a\nb\nc", "2dd", "c"), + ("a\nb", "jcc", "a\n"), + ] { + let mut editor = editor(); + editor.apply(vec![Effect::Replace(source.into())]).unwrap(); + type_text(&mut editor, keys); + assert_eq!(editor.context().text, expected, "{keys} on {source:?}"); + if editor.mode() == Mode::Insert { + editor.handle(Input::Escape); + } + if source != expected { + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, source); + } + } + let directory = Directory::new(); + let target = directory.path("repeat"); + fs::write(&target, "1\n2\n3\n4\n5").unwrap(); + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\nb\nc".into())]) + .unwrap(); + type_text(&mut editor, "gb3dd"); + assert_eq!(editor.context().text, "a\nb"); + editor.execute(&format!("edit {}", target.display())); + type_text(&mut editor, "."); + assert_eq!(editor.context().text, "4\n5"); + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, "1\n2\n3\n4\n5"); + type_text(&mut editor, &format!("{}dd", usize::MAX)); + assert_eq!(editor.context().text, ""); +} + +#[test] +fn line_actions_merge_overlaps_but_keep_adjacent_change_carets() { + let mut editor = editor(); + let source = "aa\nbb\ncc\ndd\nee"; + editor.apply(vec![Effect::Replace(source.into())]).unwrap(); + editor.handle(Input::Ctrl('e')); + let carets = editor.carets(); + type_text(&mut editor, "2dd"); + assert_eq!(editor.context().text, "dd\nee"); + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, source); + assert_eq!(editor.carets(), carets); + type_text(&mut editor, "UP"); + assert_eq!(editor.context().text, source); + editor.handle(Input::Escape); + editor.handle(Input::Ctrl('e')); + type_text(&mut editor, "ccX"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "X\nX\ncc\ndd\nee"); + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, source); +} + +#[test] +fn line_actions_prefix_cancellation_bindings_and_insert_literals_are_independent() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) + .unwrap(); + editor.handle(Input::Ctrl('e')); + let carets = editor.carets(); + type_text(&mut editor, "2d"); + editor.handle(Input::Escape); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.context().text, "a\nb\nc\nd"); + editor.handle(Input::Escape); + type_text(&mut editor, "gtdj"); + assert_eq!(editor.cursor().row, 1); + editor.bind("normal", "d", None).unwrap(); + type_text(&mut editor, "dd"); + assert_eq!(editor.context().text, "a\nb\nc\nd"); + editor + .bind("normal", "z", Some("core.delete_line".into())) + .unwrap(); + type_text(&mut editor, "2z"); + assert_eq!(editor.context().text, "a\nd"); + editor + .bind("normal", "q", Some("core.change_line".into())) + .unwrap(); + type_text(&mut editor, "qccdd"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "a\nccdd"); +} +fn project_editor(directory: &Directory) -> Editor { + let mut editor = editor(); + editor.project_root = directory.0.clone(); + editor +} + +#[test] +fn jump_history_follows_links_and_restores_primary_grapheme_locations() { + let directory = Directory::new(); + let source = directory.path("source.md"); + let target = directory.path("target.md"); + fs::write(&source, "intro\n[go](target.md#destination)").unwrap(); + fs::write(&target, "intro\n\n# Destination").unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + type_text(&mut editor, "jl"); + let departure = editor.cursor(); + let source_id = editor.buffer_id(); + type_text(&mut editor, "gf"); + let target_id = editor.buffer_id(); + assert_ne!(source_id, target_id); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.jumps.len(), 2); + fs::remove_file(target).unwrap(); + editor.handle(Input::Ctrl('O')); + assert_eq!(editor.buffer_id(), source_id); + assert_eq!(editor.cursor(), departure); + editor.handle(Input::Ctrl('I')); + assert_eq!(editor.buffer_id(), target_id); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.lines(), ["intro", "", "# Destination"]); + assert_eq!(editor.buffer_count(), 2); +} + +#[test] +fn jump_history_records_same_file_fragments_once() { + for link in ["#destination", "source.md#destination"] { + let directory = Directory::new(); + let source = directory.path("source.md"); + fs::write(&source, format!("[go]({link})\n\n# Destination")).unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + type_text(&mut editor, "gf"); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.cursor().row, 2); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor(), Cursor::default()); + let targets = editor.active.view.targets.clone(); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.active.view.targets, targets); + editor.handle(Input::Char('\t')); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + } +} + +#[test] +fn jump_history_records_edit_buffer_neighbors_and_picker_transactions() { + let directory = Directory::new(); + let a = directory.path("a.txt"); + let b = directory.path("b.txt"); + fs::write(&a, "alpha").unwrap(); + fs::write(&b, "bravo").unwrap(); + fs::write(directory.path("c.txt"), "charlie").unwrap(); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + editor.project_root = directory.0.clone(); + type_text(&mut editor, "ll"); + editor.execute(&format!("edit {}", b.display())); + assert_eq!(editor.jumps.len(), 2); + editor.execute("bnext"); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.cursor().column, 2); + editor.execute("bprev"); + assert_eq!(editor.buffer_id(), 2); + editor.execute("buffer 1"); + assert_eq!(editor.jumps.len(), 5); + editor.execute("buffer 1"); + assert_eq!(editor.jumps.len(), 5); + editor.set_file_picker_view("list").unwrap(); + type_text(&mut editor, " oc.txt\n"); + assert_eq!(editor.buffer_id(), 3); + assert_eq!(editor.jumps.len(), 6); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.cursor().column, 2); + type_text(&mut editor, "3"); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.buffer_id(), 2); + type_text(&mut editor, "4"); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.buffer_id(), 3); +} + +#[test] +fn jump_history_records_search_without_losing_query_or_highlight_invariants() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("hit\n中👩‍💻 hit\nhit".into())]) + .unwrap(); + type_text(&mut editor, "/hit\nn"); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 3 }); + type_text(&mut editor, "nN"); + assert_eq!(editor.jumps.len(), 4); + editor.handle(Input::Right); + let departure = editor.cursor(); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert!(editor.search_match().is_none()); + assert_eq!(editor.active.view.search_query, "hit"); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.cursor(), departure); + type_text(&mut editor, "2N"); + assert_eq!(editor.cursor(), Cursor::default()); +} + +#[test] +fn jump_history_saves_roaming_departures_in_both_directions() { + let directory = Directory::new(); + let a = directory.path("a.txt"); + let b = directory.path("b.txt"); + fs::write(&a, "abcdef").unwrap(); + fs::write(&b, "first\nabcdef").unwrap(); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + editor + .navigate_to(b, TextPosition { row: 1, column: 0 }) + .unwrap(); + type_text(&mut editor, "ll"); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.buffer_id(), 1); + type_text(&mut editor, "lll"); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.buffer_id(), 2); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); +} + +#[test] +fn jump_history_branches_only_after_successful_distinct_jumps() { + let directory = Directory::new(); + let path = directory.path("text.txt"); + fs::write(&path, "zero\none\ntwo\nthree").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + for row in [1, 2] { + editor + .navigate_to(path.clone(), TextPosition { row, column: 0 }) + .unwrap(); + } + editor.handle(Input::Ctrl('o')); + editor + .navigate_to(path.clone(), TextPosition { row: 1, column: 0 }) + .unwrap(); + editor + .navigate_to( + directory.path("missing"), + TextPosition { row: 0, column: 0 }, + ) + .unwrap_err(); + editor.execute("buffer 999"); + editor.execute("edit"); + editor.execute("follow_link"); + type_text(&mut editor, "/missing\n"); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.cursor().row, 2); + editor.handle(Input::Ctrl('o')); + editor.handle(Input::Right); + editor + .navigate_to(path, TextPosition { row: 3, column: 0 }) + .unwrap(); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.cursor().row, 3); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); +} + +#[test] +fn jump_history_is_bounded_and_counts_stop_at_available_locations() { + let directory = Directory::new(); + let path = directory.path("text.txt"); + fs::write( + &path, + (0..=120) + .map(|n| n.to_string()) + .collect::>() + .join("\n"), + ) + .unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + for row in 1..=120 { + editor + .navigate_to(path.clone(), TextPosition { row, column: 0 }) + .unwrap(); + } + type_text(&mut editor, "2"); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor().row, 118); + type_text(&mut editor, &usize::MAX.to_string()); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor().row, 21); + let targets = editor.active.view.targets.clone(); + let revision = editor.text_revision(); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.text_revision(), revision); + type_text(&mut editor, &usize::MAX.to_string()); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.cursor().row, 120); +} + +#[test] +fn jump_history_skips_closed_buffers_without_reopening_paths() { + let directory = Directory::new(); + let mut editor = editor(); + for name in ["b", "c"] { + let path = directory.path(name); + fs::write(&path, name).unwrap(); + editor.execute(&format!("edit {}", path.display())); + } + editor.execute("bdelete 2"); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.buffer_id(), 1); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.buffer_id(), 3); + assert_eq!(editor.buffer_count(), 2); + editor.execute("bdelete"); + let targets = editor.active.view.targets.clone(); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.active.view.targets, targets); +} + +#[test] +fn deleting_older_jump_buffers_preserves_the_current_position_and_forward_branch() { + let directory = Directory::new(); + let mut editor = editor(); + for name in ["b", "c"] { + let path = directory.path(name); + fs::write(&path, name).unwrap(); + editor + .navigate_to(path, TextPosition { row: 0, column: 0 }) + .unwrap(); + } + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.context().text, "b"); + editor.execute("bdelete 1"); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.context().text, "c"); + editor.handle(Input::Ctrl('o')); + let path = directory.path("d"); + fs::write(&path, "d").unwrap(); + editor + .navigate_to(path, TextPosition { row: 0, column: 0 }) + .unwrap(); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.context().text, "d"); + type_text(&mut editor, &usize::MAX.to_string()); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.context().text, "b"); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.context().text, "d"); +} + +#[test] +fn jump_history_clamps_edited_locations_and_preserves_dirty_text_and_undo() { + let directory = Directory::new(); + let a = directory.path("a.txt"); + let b = directory.path("b.txt"); + fs::write(&a, "original\nabcdef").unwrap(); + fs::write(&b, "other").unwrap(); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + type_text(&mut editor, "jlllll"); + editor + .navigate_to(b, TextPosition { row: 0, column: 0 }) + .unwrap(); + editor + .parked + .get_mut(&1) + .unwrap() + .replace_lines(vec!["中👩‍💻".into()]); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); + assert!(editor.dirty()); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + let text = editor.context().text; + let undo = editor.active.undo.len(); + let revision = editor.text_revision(); + editor.handle(Input::Ctrl('i')); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.context().text, text); + assert_eq!(editor.active.undo.len(), undo); + assert_eq!(editor.text_revision(), revision); + assert!(editor.dirty()); + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, "中👩‍💻"); + let redo = editor.active.redo.len(); + editor.handle(Input::Ctrl('i')); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.active.redo.len(), redo); + type_text(&mut editor, "U"); + assert_eq!(editor.context().text, text); +} + +#[test] +fn jump_history_distinguishes_tabs_and_panes_at_identical_buffer_coordinates() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("shared".into())]) + .unwrap(); + let source = editor.jump_location(); + editor.execute("tab.new"); + let tab = editor.tab_id(); + let tab_pane = editor.pane_id(); + editor.record_jump(source); + editor.execute("pane.split_vertical"); + let pane = editor.pane_id(); + editor.record_jump(JumpLocation { + tab_id: tab, + pane_id: tab_pane, + ..editor.jump_location() + }); + editor.handle(Input::Ctrl('o')); + assert_eq!((editor.tab_id(), editor.pane_id()), (tab, tab_pane)); + editor.handle(Input::Ctrl('o')); + assert_eq!( + (editor.tab_id(), editor.pane_id()), + (source.tab_id, source.pane_id) + ); + type_text(&mut editor, "2"); + editor.handle(Input::Ctrl('i')); + assert_eq!((editor.tab_id(), editor.pane_id()), (tab, pane)); + assert_eq!(editor.context().text, "shared"); + assert!(editor.dirty()); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); +} + +#[test] +fn jump_history_closed_tabs_and_panes_use_surviving_ordinary_buffer_views() { + for close in ["tab.close", "pane.close"] { + let directory = Directory::new(); + let path = directory.path("target.txt"); + fs::write(&path, "target").unwrap(); + let mut editor = editor(); + editor.execute(if close == "tab.close" { + "tab.new" + } else { + "pane.split_vertical" + }); + let closed = (editor.tab_id(), editor.pane_id()); + editor + .navigate_to(path.clone(), TextPosition { row: 0, column: 2 }) + .unwrap(); + editor.handle(Input::Ctrl('o')); + editor.execute(close); + fs::remove_file(&path).unwrap(); + editor.handle(Input::Ctrl('i')); + assert_ne!((editor.tab_id(), editor.pane_id()), closed); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); + assert_eq!(editor.context().text, "target"); + assert_eq!(editor.tab_count(), 1); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.buffer_count(), 2); + } +} + +#[test] +fn jump_history_closed_comparison_falls_back_without_reopening_or_losing_readonly_identity() { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["after".into()], + ); + let snapshot = editor.buffer_id(); + let document = editor.active.snapshot_document_path.clone(); + let source = editor.jump_location(); + editor.execute("tab.new"); + editor.record_jump(source); + editor.execute("tab.previous"); + editor.execute("diff.close"); + editor.execute("tab.next"); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.tab_id(), source.tab_id); + assert_eq!(editor.buffer_id(), snapshot); + assert_eq!(editor.active.snapshot_document_path, document); + assert!(editor.diff().is_none()); + type_text(&mut editor, "ix"); + assert_eq!(editor.context().text, "before"); + assert!(!editor.dirty()); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.tab_count(), 2); + assert_ne!(editor.tab_id(), source.tab_id); +} + +#[test] +fn jump_history_never_replaces_a_new_comparison_in_an_old_destination_view() { + let directory = Directory::new(); + let target = directory.path("external.txt"); + fs::write(&target, "external").unwrap(); + let mut editor = editor(); + fs::write(directory.path("file.txt"), "working source").unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + let source_buffer = editor.buffer_id(); + editor + .navigate_lsp_to(target, lsp_point(0, 2), None) + .unwrap(); + let external_tab = editor.tab_id(); + let external_buffer = editor.buffer_id(); + editor.execute("tab.previous"); + editor.execute("diff.close"); + historical_diff( + &mut editor, + &directory.0, + vec!["new before".into()], + vec!["new after".into()], + ); + let comparison = editor.diff().unwrap().comparison_id; + let snapshot = editor.buffer_id(); + let cursor = editor.cursor(); + editor.execute("tab.next"); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.tab_id(), external_tab); + assert_eq!(editor.buffer_id(), source_buffer); + assert_eq!(editor.context().text, "working source"); + assert!(editor.diff().is_none()); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.buffer_id(), external_buffer); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); + editor.execute("tab.previous"); + assert_eq!(editor.diff().unwrap().comparison_id, comparison); + assert_eq!(editor.buffer_id(), snapshot); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.context().text, "new before"); +} + +#[test] +fn jump_history_skips_missing_contexts_without_mutating_the_only_comparison() { + let directory = Directory::new(); + let path = directory.path("old.txt"); + fs::write(&path, "old").unwrap(); + let mut editor = editor(); + editor + .navigate_to(path, TextPosition { row: 0, column: 1 }) + .unwrap(); + let source_buffer = editor.buffer_id(); + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["after".into()], + ); + let comparison = editor.diff().unwrap().comparison_id; + let snapshot = editor.buffer_id(); + let cursor = editor.cursor(); + type_text(&mut editor, &usize::MAX.to_string()); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.buffer_id(), snapshot); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.diff().unwrap().comparison_id, comparison); + assert!(editor.has_buffer(source_buffer)); + assert_eq!(editor.tab_count(), 1); +} + +#[test] +fn comparison_tab_identity_includes_selector_and_parked_pane_states() { + let mut editor = editor(); + assert!(!editor.tab_is_diff(0)); + assert!(!editor.tab_is_diff(1)); + editor.execute("diff"); + assert!(editor.tab_is_diff(0)); + editor.execute("pane.split_vertical"); + assert!(editor.diff().is_none()); + assert!(editor.tab_is_diff(0)); + editor.execute("tab.new"); + assert!(editor.tab_is_diff(0)); + assert!(!editor.tab_is_diff(1)); + editor.execute("tab.previous"); + editor.execute("diff.close"); + assert!(!editor.tab_is_diff(0)); +} + +#[test] +fn jump_history_skips_entries_clamped_to_the_current_location() { + let directory = Directory::new(); + let path = directory.path("text.txt"); + fs::write(&path, "zero\none\ntwo").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + for row in [1, 2] { + editor + .navigate_to(path.clone(), TextPosition { row, column: 0 }) + .unwrap(); + } + editor.apply(vec![Effect::Replace("x".into())]).unwrap(); + let targets = editor.active.view.targets.clone(); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.cursor(), Cursor::default()); +} + +#[test] +fn jump_history_counts_revisit_older_entries_at_the_same_coordinates() { + let directory = Directory::new(); + let path = directory.path("text.txt"); + fs::write(&path, "zero\none").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + for row in [1, 0] { + editor + .navigate_to(path.clone(), TextPosition { row, column: 0 }) + .unwrap(); + } + type_text(&mut editor, "2"); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor().row, 0); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.cursor().row, 1); +} + +#[test] +fn failed_jump_traversal_preserves_multiple_carets_search_and_edit_state() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("hit\nhit".into())]) + .unwrap(); + type_text(&mut editor, "/hit\nn"); + editor.handle(Input::Ctrl('k')); + assert_eq!(editor.carets().len(), 2); + let targets = editor.active.view.targets.clone(); + let primary = editor.active.view.primary; + let search = editor.search_match(); + let undo = editor.active.undo.len(); + let revision = editor.text_revision(); + let timeline = editor.jumps.clone(); + for input in [Input::Ctrl('i'), Input::Ctrl('o')] { + editor.handle(input); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, primary); + assert_eq!(editor.search_match(), search); + assert_eq!(editor.active.view.search_query, "hit"); + assert_eq!(editor.active.undo.len(), undo); + assert_eq!(editor.text_revision(), revision); + assert_eq!(editor.jumps, timeline); + assert_eq!(editor.mode(), Mode::Normal); + assert!(editor.dirty()); + } +} + +#[test] +fn ordinary_motions_edits_and_failed_jumps_do_not_seed_history() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("alpha beta".into())]) + .unwrap(); + type_text(&mut editor, "wlhiX"); + editor.handle(Input::Escape); + type_text(&mut editor, "uU"); + editor.execute("follow_link"); + editor.execute("bnext"); + editor.execute("buffer 999"); + let targets = editor.active.view.targets.clone(); + let revision = editor.text_revision(); + editor.handle(Input::Ctrl('o')); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.text_revision(), revision); +} + +#[test] +fn jump_keys_honor_independent_tab_bindings_and_normal_mode_eligibility() { + let directory = Directory::new(); + let path = directory.path("text.txt"); + fs::write(&path, "zero\none\ntwo").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor + .navigate_to(path, TextPosition { row: 1, column: 0 }) + .unwrap(); + editor + .bind("normal", "ctrl+o", Some("core.right".into())) + .unwrap(); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); + editor + .bind("normal", "leader [", Some("core.jump_back".into())) + .unwrap(); + type_text(&mut editor, " ["); + assert_eq!(editor.cursor(), Cursor::default()); + editor + .bind("normal", "ctrl+i", Some("core.right".into())) + .unwrap(); + editor.handle(Input::Ctrl('I')); + assert_eq!(editor.cursor().column, 1); + editor.handle(Input::Char('\t')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); + for mode in ["insert", "visual", "command", "search"] { + for action in ["core.jump_back", "core.jump_forward"] { + assert!(editor.bind(mode, "z", Some(action.into())).is_err()); + } + } + editor.handle(Input::Char('i')); + editor.handle(Input::Ctrl('i')); + editor.handle(Input::Char('\t')); + assert_eq!(editor.lines()[1], "o\t\tne"); + assert_eq!(editor.mode(), Mode::Insert); + editor.handle(Input::Ctrl('p')); + for action in ["core.jump_back", "core.jump_forward"] { + assert!(!palette_entry(&editor, action).enabled); + } + editor.handle(Input::Escape); + editor.handle(Input::Escape); + editor.handle(Input::Ctrl('p')); + assert!(palette_entry(&editor, "core.jump_back").enabled); + assert!(palette_entry(&editor, "core.jump_forward").enabled); + assert!(!palette_entry(&editor, "core.jump_back").bindings.is_empty()); +} + +#[test] +fn follow_link_resolves_relative_paths_from_source_not_cwd_or_project_root() { + let directory = Directory::new(); + let nested = directory.path("notes"); + fs::create_dir(&nested).unwrap(); + let source = nested.join("source.md"); + let target = directory.path("Target Name.MD"); + fs::write(&source, "[go](../Target%20Name.MD#target-heading)").unwrap(); + fs::write(&target, "intro\n\n# Target heading\nbody").unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + assert_ne!(std::env::current_dir().unwrap(), nested); + editor.handle(Input::Char('g')); + assert!(editor.g_pending()); + editor.handle(Input::Char('f')); + assert!(!editor.g_pending()); + assert_eq!(editor.buffer_id(), 2); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.context().text, "intro\n\n# Target heading\nbody"); + assert_eq!(editor.buffer_count(), 2); +} + +#[test] +fn follow_link_reference_and_duplicate_heading_use_native_ex_command() { + let directory = Directory::new(); + let source = directory.path("source.md"); + let target = directory.path("target.md"); + fs::write( + &source, + "[go][section]\n\n[section]: target.md#hello-world-1", + ) + .unwrap(); + fs::write( + &target, + "# Hello [world](ignored.md)\n\nHello *world*\n=============\n", + ) + .unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + type_text(&mut editor, ":follow_link\n"); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.lines()[0], "# Hello [world](ignored.md)"); +} + +#[test] +fn follow_link_reuses_dirty_target_even_after_disk_file_disappears() { + let directory = Directory::new(); + let source = directory.path("source.md"); + let target = directory.path("target.md"); + fs::write(&source, "[go](target.md#dirty-heading)").unwrap(); + fs::write(&target, "# Disk heading").unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + editor.execute(&format!("edit {}", target.display())); + let target_id = editor.buffer_id(); + editor + .apply(vec![Effect::Replace("unsaved\n\n# Dirty heading".into())]) + .unwrap(); + let revision = editor.text_revision(); + editor.execute("buffer 1"); + fs::remove_file(&target).unwrap(); + editor + .bind("normal", "z", Some("core.follow_link".into())) + .unwrap(); + assert_eq!(editor.handle(Input::Char('z')), None); + assert_eq!(editor.buffer_id(), target_id); + assert_eq!(editor.buffer_count(), 2); + assert_eq!(editor.context().text, "unsaved\n\n# Dirty heading"); + assert_eq!(editor.text_revision(), revision); + assert!(editor.dirty()); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); +} + +#[test] +fn follow_link_failures_leave_source_carets_selections_text_and_buffer_ids_untouched() { + let directory = Directory::new(); + fs::write(directory.path("target.md"), "# Existing").unwrap(); + for (filename, text, command) in [ + ("source.md", "[x](missing.md)", "follow_link"), + ("source.md", "[x](target.md#missing)", "follow_link"), + ("source.md", "[x](#missing)", "follow_link"), + ("source.md", "[missing][undefined]", "follow_link"), + ("source.md", "[x](https://example.com)", "follow_link"), + ("source.md", "not a link", "follow_link"), + ("source.txt", "[x](target.md)", "follow_link"), + ("source.md", "[x](target.md)", "follow_link extra"), + ] { + let source = directory.path(filename); + fs::write(&source, text).unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + type_text(&mut editor, "vl"); + let carets = editor.carets(); + let selections = editor.selections(); + let revision = editor.text_revision(); + assert_eq!(editor.execute(command), None); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.context().text, text); + assert_eq!(editor.text_revision(), revision); + assert!(!editor.message().is_empty()); + if command == "follow_link" { + editor.handle(Input::Escape); + let carets = editor.carets(); + type_text(&mut editor, "gf"); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.context().text, text); + } + editor.execute(&format!("edit {}", directory.path("target.md").display())); + assert_eq!(editor.buffer_id(), 2); + } +} + +#[test] +fn follow_link_same_document_fragments_work_without_named_buffer() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace( + "[go](#hello-world)\n\n# Hello **world**\n".into(), + )]) + .unwrap(); + let revision = editor.text_revision(); + editor + .bind("normal", "leader f", Some("core.follow_link".into())) + .unwrap(); + type_text(&mut editor, " f"); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.path(), None); + assert_eq!(editor.text_revision(), revision); + assert_eq!( + editor.context().text, + "[go](#hello-world)\n\n# Hello **world**\n" + ); + editor.handle(Input::Home); + editor.handle(Input::Up); + editor.handle(Input::Up); + editor + .apply(vec![Effect::Replace("[go](target.md)".into())]) + .unwrap(); + editor.execute("follow_link"); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.cursor(), Cursor::default()); + assert!(editor.message().contains("named")); +} + +#[test] +fn follow_link_explicit_same_file_path_reuses_current_dirty_buffer() { + let directory = Directory::new(); + let source = directory.path("source.md"); + fs::write(&source, "# Old disk heading").unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + let text = "[go](source.md#dirty-heading)\n\n# Dirty heading"; + editor.apply(vec![Effect::Replace(text.into())]).unwrap(); + let revision = editor.text_revision(); + type_text(&mut editor, "gf"); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.context().text, text); + assert_eq!(editor.text_revision(), revision); + assert!(editor.dirty()); +} + +#[test] +fn follow_link_uses_only_primary_caret_once() { + let directory = Directory::new(); + let source = directory.path("source.md"); + let target = directory.path("target.md"); + fs::write(&source, "[bad](missing.md)\n[go](target.md)").unwrap(); + fs::write(&target, "# Target").unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.carets().len(), 2); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + type_text(&mut editor, "gf"); + assert_eq!(editor.buffer_id(), 2); + assert_eq!(editor.buffer_count(), 2); + assert_eq!(editor.carets(), vec![Cursor::default()]); + assert_eq!(editor.lines(), ["# Target"]); +} + +#[test] +fn follow_link_accepts_nonmarkdown_targets_without_fragments() { + let directory = Directory::new(); + let source = directory.path("source.md"); + let target = directory.path("main.rs"); + fs::write(&source, "[code](main.rs)").unwrap(); + fs::write(&target, "fn main() {}").unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + type_text(&mut editor, "gf"); + assert_eq!(editor.cursor(), Cursor::default()); + assert_eq!(editor.count(), None); + assert_eq!(editor.buffer_count(), 2); + assert_eq!(editor.lines(), ["fn main() {}"]); +} + +#[test] +fn counted_g_reveals_exact_hidden_source_rows_in_readonly_diffs() { + let directory = Directory::new(); + let mut editor = editor(); + let before: Vec<_> = (0..80).map(|row| format!("row {row}")).collect(); + let mut after = before.clone(); + after[40] = "changed".into(); + historical_diff(&mut editor, &directory.0, before, after); + editor.diff.as_mut().unwrap().set_context_options(0, 1); + let text = editor.lines().to_vec(); + type_text(&mut editor, "10g"); + assert_eq!(editor.cursor().row, 9); + let focus = editor.diff_buffer_focus().unwrap(); + assert!( + editor + .diff() + .unwrap() + .source_display_row(focus, 9) + .is_some() + ); + assert!(editor.mutation_blocked()); + assert_eq!(editor.lines(), text); + type_text(&mut editor, "79g"); + assert_eq!(editor.cursor().row, 78); + assert!( + editor + .diff() + .unwrap() + .source_display_row(focus, 78) + .is_some() + ); +} + +#[test] +fn document_navigation_moves_all_carets_to_first_nonblank_without_edit_history() { + let mut editor = editor(); + let text = " e\u{301}top\nmiddle\n\t 界bottom"; + editor.apply(vec![Effect::Replace(text.into())]).unwrap(); + type_text(&mut editor, "j"); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.selection_count(), 2); + type_text(&mut editor, "gt"); + assert_eq!(editor.carets(), vec![Cursor { row: 0, column: 2 }]); + type_text(&mut editor, "gb"); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 2 }); + assert_eq!(editor.context().text, text); + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, ""); + + for (text, last_row) in [("", 0), ("\n", 1), ("top\n", 1)] { + editor.apply(vec![Effect::Replace(text.into())]).unwrap(); + type_text(&mut editor, "gb"); + assert_eq!( + editor.cursor(), + Cursor { + row: last_row, + column: 0 + } + ); + type_text(&mut editor, "gt"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); + assert_eq!(editor.context().text, text); + } +} + +#[test] +fn document_navigation_extends_both_visual_shapes_and_escape_cancels_prefix_only() { + for selector in ['v', 'V'] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace(" top\nmiddle\n\t end".into())]) + .unwrap(); + type_text(&mut editor, "jl"); + editor.handle(Input::Char(selector)); + type_text(&mut editor, "gb"); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 2 }); + assert_eq!(editor.visual_linewise(), selector == 'V'); + assert_eq!( + editor.selections(), + vec![if selector == 'V' { + Selection { + start: Cursor { row: 1, column: 0 }, + end: Cursor { row: 2, column: 5 }, + } + } else { + Selection { + start: Cursor { row: 1, column: 1 }, + end: Cursor { row: 2, column: 3 }, + } + }] + ); + let selected = editor.selections(); + type_text(&mut editor, "g"); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), selected); + type_text(&mut editor, "gt"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); + assert_eq!( + editor.selections(), + vec![if selector == 'V' { + Selection { + start: Cursor { row: 0, column: 0 }, + end: Cursor { row: 2, column: 0 }, + } + } else { + Selection { + start: Cursor { row: 0, column: 2 }, + end: Cursor { row: 1, column: 2 }, + } + }] + ); + assert_eq!(editor.context().text, " top\nmiddle\n\t end"); + } +} + +#[test] +fn document_navigation_prefix_respects_bindings_and_literal_insert_input() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("top\nmiddle\nbottom".into())]) + .unwrap(); + type_text(&mut editor, "jv"); + editor + .bind("visual", "g", Some("core.right".into())) + .unwrap(); + type_text(&mut editor, "gb"); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + assert_eq!(editor.mode(), Mode::Visual); + editor.handle(Input::Escape); + editor.bind("normal", "g", None).unwrap(); + type_text(&mut editor, "gt"); + assert_eq!(editor.cursor().row, 1); + type_text(&mut editor, "igtgb"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "top\ngtgbmiddle\nbottom"); +} + +#[test] +fn document_navigation_actions_remap_and_yield_to_g_as_leader() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("top\nmiddle\nbottom".into())]) + .unwrap(); + editor.set_leader("g").unwrap(); + editor + .bind("normal", "leader t", Some("core.document_end".into())) + .unwrap(); + type_text(&mut editor, "gt"); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + editor + .bind("normal", "h", Some("core.document_start".into())) + .unwrap(); + type_text(&mut editor, "h"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); + editor + .bind("normal", "g", Some("core.document_end".into())) + .unwrap(); + type_text(&mut editor, "g"); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + type_text(&mut editor, "igtgb"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "top\nmiddle\ngtgbbottom"); +} + +#[test] +fn native_gf_prefix_yields_to_configured_g_commands_and_preserves_counts() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) + .unwrap(); + editor.handle(Input::Ctrl('p')); + assert_eq!( + palette_entry(&editor, "core.follow_link").bindings, + vec!["gf"] + ); + assert!(palette_entry(&editor, "core.follow_link").enabled); + editor.handle(Input::Escape); + editor + .bind("normal", "g", Some("core.down".into())) + .unwrap(); + type_text(&mut editor, "2g"); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.count(), None); + assert!(!editor.g_pending()); + editor.handle(Input::Ctrl('p')); + assert!( + palette_entry(&editor, "core.follow_link") + .bindings + .is_empty() + ); + editor.handle(Input::Escape); + editor.bind("normal", "g", None).unwrap(); + type_text(&mut editor, "g"); + assert!(!editor.g_pending()); + editor.register(plugin("fixture", &["greet"])).unwrap(); + editor.bind("normal", "g", Some("greet".into())).unwrap(); + type_text(&mut editor, "3"); + assert_eq!( + editor.handle(Input::Char('g')), + Some(Invocation { + plugin: "fixture".into(), + command: "greet".into(), + args: String::new(), + }) + ); + assert_eq!(editor.count(), None); + assert!(!editor.g_pending()); + for mode in ["insert", "visual", "command", "search"] { + assert!( + editor + .bind(mode, "z", Some("core.follow_link".into())) + .is_err() + ); + } + editor.handle(Input::Char('v')); + editor.handle(Input::Ctrl('p')); + assert!(!palette_entry(&editor, "core.follow_link").enabled); + assert!( + palette_entry(&editor, "core.follow_link") + .bindings + .is_empty() + ); +} + +#[test] +fn native_g_prefix_cancels_on_escape_intervening_input_commands_and_lifecycle() { + let directory = Directory::new(); + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\nb\nc".into())]) + .unwrap(); + type_text(&mut editor, "g"); + editor.handle(Input::Escape); + assert!(!editor.g_pending()); + assert_eq!(editor.count(), None); + type_text(&mut editor, "gj"); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + assert!(!editor.g_pending()); + type_text(&mut editor, "gi"); + assert_eq!(editor.mode(), Mode::Insert); + assert!(!editor.g_pending()); + editor.handle(Input::Char('f')); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "a\nfb\nc"); + for command in ["plugins", "bnext", "bprev", "buffer 1", "bdelete!"] { + editor.handle(Input::Char('g')); + assert!(editor.g_pending()); + editor.execute(command); + assert!(!editor.g_pending()); + } + editor.handle(Input::Char('g')); + editor.handle(Input::Paste("f".into())); + assert!(!editor.g_pending()); + editor.handle(Input::Char('g')); + editor + .navigate_to( + directory.path("missing.md"), + TextPosition { row: 0, column: 0 }, + ) + .unwrap_err(); + assert!(!editor.g_pending()); + editor.handle(Input::Char('g')); + editor + .bind("normal", "z", Some("core.follow_link".into())) + .unwrap(); + assert!(!editor.g_pending()); + editor.set_leader("g").unwrap(); + editor.handle(Input::Char('g')); + assert!(!editor.g_pending()); + assert_eq!(editor.sequence_pending(), Some("g")); + editor.handle(Input::Escape); + editor.handle(Input::Ctrl('p')); + assert_eq!( + palette_entry(&editor, "core.follow_link").bindings, + vec!["z"] + ); +} + +#[test] +fn semantic_snapshots_are_borrowed_buffer_local_and_invalidated_by_text_and_path() { + use crate::plugin::SemanticTokenKind; + let directory = Directory::new(); + let a = directory.path("a.py"); + let b = directory.path("b.py"); + fs::write(&a, "e\u{301}界").unwrap(); + fs::write(&b, "other").unwrap(); + let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + let tokens: Arc<[SemanticToken]> = Arc::from([SemanticToken { + start: TextPosition { row: 0, column: 0 }, + end: TextPosition { row: 0, column: 2 }, + kind: SemanticTokenKind::Variable, + modifiers: 1023, + }]); + assert!( + editor + .replace_semantic_tokens(1, 0, Some(tokens.clone())) + .unwrap() + ); + assert!(std::ptr::eq(editor.semantic_tokens(), tokens.as_ref())); + assert!(!editor.dirty()); + assert!(editor.active.undo.is_empty()); + editor.execute(&format!("edit {}", b.display())); + assert!(editor.semantic_tokens().is_empty()); + assert!( + editor + .replace_semantic_tokens(2, 0, Some(tokens.clone())) + .unwrap() + ); + editor.execute("buffer 1"); + assert!(std::ptr::eq(editor.semantic_tokens(), tokens.as_ref())); + assert!( + !editor + .replace_semantic_tokens(1, 0, Some(tokens.clone())) + .unwrap() + ); + editor.execute("w"); + assert_eq!(editor.semantic_tokens(), tokens.as_ref()); + editor.execute(&format!("w {}", directory.path("renamed.py").display())); + assert_eq!(editor.text_revision(), 0); + assert!(editor.semantic_tokens().is_empty()); + assert!( + editor + .replace_semantic_tokens(1, 0, Some(tokens.clone())) + .unwrap() + ); + editor.handle(Input::Char('x')); + assert!(editor.semantic_tokens().is_empty()); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["e\u{301}界"]); + assert!(editor.semantic_tokens().is_empty()); + assert!( + !editor + .replace_semantic_tokens(1, 0, Some(tokens.clone())) + .unwrap() + ); + editor.execute("buffer 2"); + assert_eq!(editor.semantic_tokens(), tokens.as_ref()); + editor.clear_semantic_tokens(); + assert!(editor.semantic_tokens().is_empty()); + editor.execute("buffer 1"); + assert!(editor.semantic_tokens().is_empty()); +} + +#[test] +fn semantic_colors_follow_untouched_text_and_drop_overlapping_edits() { + use crate::plugin::SemanticTokenKind; + let position = |(row, column)| TextPosition { row, column }; + for (before, after, start, end, expected) in [ + ( + "a value z", + "aa value z", + (0, 2), + (0, 7), + Some(((0, 3), (0, 8))), + ), + ( + "a value z", + "value z", + (0, 2), + (0, 7), + Some(((0, 0), (0, 5))), + ), + ( + "a value z", + "a \nvalue z", + (0, 2), + (0, 7), + Some(((1, 0), (1, 5))), + ), + ( + "a\nvalue z", + "avalue z", + (1, 0), + (1, 5), + Some(((0, 1), (0, 6))), + ), + ( + "e\u{301}value", + "e\u{301}界value", + (0, 1), + (0, 6), + Some(((0, 2), (0, 7))), + ), + ( + "x\nvalue", + "x\n👩‍🚀value", + (1, 0), + (1, 5), + Some(((1, 1), (1, 6))), + ), + ( + "head\nvalue\ntail\nlast", + "head\n界\nvalue\ntail\nlast", + (1, 0), + (1, 5), + Some(((2, 0), (2, 5))), + ), + ( + "head\n界\nvalue\ntail", + "head\nvalue\ntail", + (2, 0), + (2, 5), + Some(((1, 0), (1, 5))), + ), + ( + "value z", + "value \nz", + (0, 0), + (0, 5), + Some(((0, 0), (0, 5))), + ), + ("value", "value!", (0, 0), (0, 5), Some(((0, 0), (0, 5)))), + ("value", "valXue", (0, 0), (0, 5), None), + ("value", "", (0, 0), (0, 5), None), + ("value", "VALUE", (0, 0), (0, 5), None), + ("evalue", "e\u{301}value", (0, 0), (0, 1), None), + ] { + let mut editor = editor(); + editor.apply(vec![Effect::Replace(before.into())]).unwrap(); + let revision = editor.text_revision(); + let token = SemanticToken { + start: position(start), + end: position(end), + kind: SemanticTokenKind::Function, + modifiers: 5, + }; + let original: Arc<[SemanticToken]> = Arc::from([token]); + editor + .replace_semantic_tokens(editor.buffer_id(), revision, Some(original.clone())) + .unwrap(); + editor.apply(vec![Effect::Replace(after.into())]).unwrap(); + let expected: Vec<_> = expected + .map(|(start, end)| SemanticToken { + start: position(start), + end: position(end), + ..token + }) + .into_iter() + .collect(); + assert_eq!( + editor.semantic_tokens(), + expected, + "{before:?} -> {after:?}" + ); + assert!( + !editor + .replace_semantic_tokens(editor.buffer_id(), revision, Some(original)) + .unwrap() + ); + assert_eq!(editor.semantic_tokens(), expected); + } +} + +#[test] +fn semantic_validation_rejects_malformed_live_tokens_but_ignores_stale_and_unloaded() { + use crate::plugin::SemanticTokenKind; + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("e\u{301}界\nx".into())]) + .unwrap(); + let revision = editor.text_revision(); + let token = SemanticToken { + start: TextPosition { row: 0, column: 0 }, + end: TextPosition { row: 0, column: 2 }, + kind: SemanticTokenKind::Variable, + modifiers: 0, + }; + let invalid = [ + SemanticToken { + end: TextPosition { row: 0, column: 3 }, + ..token + }, + SemanticToken { + end: token.start, + ..token + }, + SemanticToken { + end: TextPosition { row: 1, column: 1 }, + ..token + }, + SemanticToken { + start: TextPosition { row: 9, column: 0 }, + end: TextPosition { row: 9, column: 1 }, + ..token + }, + SemanticToken { + modifiers: 1024, + ..token + }, + ]; + for token in invalid { + assert!( + editor + .validate_semantic_tokens(1, revision, Some(&[token])) + .is_err() + ); + assert!( + !editor + .validate_semantic_tokens(1, revision - 1, Some(&[token])) + .unwrap() + ); + assert!( + !editor + .validate_semantic_tokens(999, revision, Some(&[token])) + .unwrap() + ); + } + let later = SemanticToken { + start: TextPosition { row: 0, column: 1 }, + ..token + }; + assert!( + editor + .validate_semantic_tokens(1, revision, Some(&[later, token])) + .is_err() + ); + // Raw adjacent LSP tokens may map outward to the same grapheme. + assert!( + editor + .validate_semantic_tokens(1, revision, Some(&[token, token, later])) + .unwrap() + ); + let oversized = vec![token; MAX_EDIT_BYTES / std::mem::size_of::() + 1]; + assert!( + editor + .validate_semantic_tokens(1, revision, Some(&oversized)) + .is_err() + ); + let tokens: Arc<[SemanticToken]> = Arc::from([token]); + editor + .replace_semantic_tokens(1, revision, Some(tokens)) + .unwrap(); + assert!(editor.replace_semantic_tokens(1, revision, None).unwrap()); + assert!(editor.semantic_tokens().is_empty()); +} + +#[test] +fn semantic_column_bounds_preserve_ascii_tabs_and_unicode_graphemes() { + use crate::plugin::SemanticTokenKind; + for text in ["a\tb", "e\u{301}界👩‍🚀"] { + let mut editor = editor(); + editor.apply(vec![Effect::Replace(text.into())]).unwrap(); + let token = SemanticToken { + start: TextPosition { row: 0, column: 0 }, + end: TextPosition { row: 0, column: 3 }, + kind: SemanticTokenKind::Variable, + modifiers: 0, + }; + editor + .replace_semantic_tokens(1, editor.text_revision(), Some(Arc::from([token]))) + .unwrap(); + let outside = SemanticToken { + end: TextPosition { row: 0, column: 4 }, + ..token + }; + assert!( + editor + .replace_semantic_tokens(1, editor.text_revision(), Some(Arc::from([outside]))) + .is_err(), + "{text:?}", + ); + assert_eq!(editor.semantic_tokens(), &[token]); + } +} + +#[test] +fn hover_scroll_and_escape_preserve_carets_and_edits_dismiss_normally() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("alpha\nbeta".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 1), (1, 2)], 1); + let targets = editor.active.view.targets.clone(); + let primary = editor.active.view.primary; + let text = editor.context().text; + let revision = editor.text_revision(); + let history = (editor.active.undo.len(), editor.active.redo.len()); + for command in [ + ":lsp_hover", + ":lsp_definition", + ":lsp_diagnostics", + ":lsp.hover unexpected extra", + ":lsp.definition unexpected extra", + ":lsp.diagnostics unexpected extra", + ] { + assert!(editor.execute(command).is_none(), "{command}"); + } + editor + .bind("normal", "K", Some("core.lsp.hover".into())) + .unwrap(); + type_text(&mut editor, "9"); + assert_eq!( + editor.handle(Input::Char('K')), + Some(Invocation { + plugin: "core".into(), + command: "lsp.hover".into(), + args: String::new(), + }) + ); + assert_eq!(editor.count(), None); + editor + .show_hover("界e\u{301}👩‍🚀\tend\r\nsecond\nlast".into()) + .unwrap(); + assert!(editor.show_hover("\u{1b}[31m".into()).is_err()); + assert_eq!(editor.hover().unwrap().lines().nth(1), Some("second")); + for input in [ + Input::Down, + Input::Ctrl('j'), + Input::Down, + Input::Right, + Input::Ctrl('l'), + ] { + assert_eq!(editor.handle(input), None); + } + assert_eq!( + (editor.hover().unwrap().top, editor.hover().unwrap().left), + (2, 2) + ); + editor.handle(Input::Escape); + assert!(editor.hover().is_none()); + assert!(editor.notification_visible()); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, primary); + assert_eq!(editor.context().text, text); + assert_eq!(editor.text_revision(), revision); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + history + ); + assert_eq!(editor.mode(), Mode::Normal); + editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + assert_eq!(editor.active.view.targets, targets); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 1); + editor.show_hover("documentation".into()).unwrap(); + editor.handle(Input::Char('i')); + assert!(editor.hover().is_none()); + assert_eq!(editor.mode(), Mode::Insert); + editor.handle(Input::Char('X')); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "alpha\nbeXta"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, text); + editor.show_hover("documentation".into()).unwrap(); + editor.apply(vec![Effect::Replace("new".into())]).unwrap(); + assert!(editor.hover().is_none()); +} + +#[cfg(unix)] +mod system_clipboard { + use super::*; + use std::os::unix::fs::PermissionsExt; + + struct Fixture(Directory); + + impl Fixture { + fn new() -> Self { + let fixture = Self(Directory::new()); + for name in ["state", "reads", "writes"] { + fs::write(fixture.0.path(name), b"").unwrap(); + } + let quoted = |name: &str| { + format!( + "'{}'", + fixture + .0 + .path(name) + .display() + .to_string() + .replace('\'', "'\\''") + ) + }; + let reader = format!( + "#!/bin/sh\nprintf . >> {}\n[ ! -f {} ] || exit 23\nexec /bin/cat {}\n", + quoted("reads"), + quoted("fail-read"), + quoted("state"), + ); + let writer = format!( + "#!/bin/sh\nprintf . >> {}\n[ ! -f {} ] || exit 23\n/bin/cat > {} || exit 24\nexec /bin/mv {} {}\n", + quoted("writes"), + quoted("fail-write"), + quoted("next"), + quoted("next"), + quoted("state"), + ); + for (name, script) in [("reader", reader), ("writer", writer)] { + let path = fixture.0.path(name); + fs::write(&path, script).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + fixture + } + + fn editor(&self) -> Editor { + let mut editor = Editor::open(None, vec!["text".into()]).unwrap(); + editor.set_system_clipboard(SystemClipboard::with_commands( + self.0.path("reader"), + self.0.path("writer"), + )); + editor + } + + fn bytes(&self, name: &str) -> Vec { + fs::read(self.0.path(name)).unwrap() + } + + fn external(&self, text: impl AsRef<[u8]>) { + fs::write(self.0.path("state"), text).unwrap(); + } + + fn fail(&self, name: &str, enabled: bool) { + let path = self.0.path(name); + if enabled { + fs::write(path, b"").unwrap(); + } else { + fs::remove_file(path).unwrap(); + } + } + } + + #[test] + fn line_actions_clipboard_failure_preserves_carets_history_and_repeat() { + let fixture = Fixture::new(); + fixture.external("keep"); + let mut editor = fixture.editor(); + editor + .apply(vec![Effect::Replace("one\ntwo\nthree".into())]) + .unwrap(); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + type_text(&mut editor, "l"); + editor.handle(Input::Ctrl('e')); + let carets = editor.carets(); + let revision = editor.text_revision(); + fixture.fail("fail-write", true); + for keys in [ + "yy", "2y3y", "2dd", "cc", "C", "D", "Y", "s", "d2w", "d2b", "c2w", "c2b", + ] { + type_text(&mut editor, keys); + assert_eq!(editor.context().text, "Xone\ntwo\nthree"); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.text_revision(), revision); + assert_eq!(fixture.bytes("state"), b"keep"); + } + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, "one\ntwo\nthree"); + type_text(&mut editor, "U."); + assert_eq!(editor.context().text, "XXone\ntXwo\nthree"); + } + + #[test] + fn copy_line_publication_failure_keeps_owned_shape_register_and_full_state() { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor + .apply(vec![Effect::Replace("one\ntwo\n".into())]) + .unwrap(); + fixture.fail("fail-write", true); + let initial = state(&editor); + type_text(&mut editor, "yy"); + assert_eq!(state(&editor), initial); + assert!(editor.owned_system_clipboard.is_none()); + fixture.fail("fail-write", false); + type_text(&mut editor, "yy"); + assert_eq!(fixture.bytes("state"), b"one\n"); + assert_eq!( + editor.owned_system_clipboard, + Some(("one\n".into(), vec!["one\n".into()], true)) + ); + editor.handle(Input::Char('j')); + fixture.fail("fail-write", true); + let before = state(&editor); + type_text(&mut editor, "2yy"); + assert_eq!(state(&editor), before); + assert_eq!(fixture.bytes("state"), b"one\n"); + fixture.fail("fail-write", false); + type_text(&mut editor, "jyy"); + assert_eq!(fixture.bytes("state"), b"\n"); + assert_eq!( + editor.owned_system_clipboard, + Some(("\n".into(), vec!["\n".into()], true)) + ); + } + + // Includes every replay-owned state, not the intentionally changed error message. + fn state(editor: &Editor) -> String { + format!( + "{:?}", + ( + ( + &editor.active.lines, + &editor.active.view.targets, + editor.active.view.primary, + editor.active.view.mode, + editor.active.view.linewise, + editor.active.text_revision, + &editor.active.view.search_query, + editor.active.view.search_match, + &editor.active.undo, + &editor.active.redo, + ), + ( + &editor.active.view.insert_start, + &editor.active.view.pending_edit, + &editor.active.view.visual_motions, + &editor.clipboard, + editor.clipboard_linewise, + &editor.owned_system_clipboard, + &editor.last_edit, + editor.clipboard_mode, + ), + ), + ) + } + + #[test] + fn visual_system_paste_replaces_only_after_successful_read_without_publishing() { + for (key, action) in [ + ('p', None), + ('P', None), + ('z', Some("core.paste_system_before")), + ('z', Some("core.paste_system_after")), + ] { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + if let Some(action) = action { + editor.bind("visual", "z", Some(action.into())).unwrap(); + } + editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); + fixture.external("X"); + type_text(&mut editor, "vl"); + fixture.fail("fail-read", true); + fixture.fail("fail-write", true); + let before = state(&editor); + editor.handle(Input::Char(key)); + assert_eq!(state(&editor), before); + fixture.fail("fail-read", false); + editor.handle(Input::Char(key)); + assert_eq!(editor.context().text, "Xc"); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(fixture.bytes("state"), b"X"); + assert!(fixture.bytes("writes").is_empty()); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "abc"); + } + } + + #[test] + fn linewise_owned_system_bytes_keep_shape_external_changes_do_not_guess_it() { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor.apply(vec![Effect::Replace("row".into())]).unwrap(); + type_text(&mut editor, "Vy"); + assert_eq!(fixture.bytes("state"), b"row\n"); + editor.apply(vec![Effect::Replace("X".into())]).unwrap(); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, "row\nX"); + fixture.external("row\n"); + editor.apply(vec![Effect::Replace("YZ".into())]).unwrap(); + editor.handle(Input::Char('p')); + assert_eq!(editor.context().text, "YZ\nrow"); + fixture.external("other\n"); + editor.apply(vec![Effect::Replace("YZ".into())]).unwrap(); + editor.handle(Input::Char('p')); + assert_eq!(editor.context().text, "Yother\nZ"); + assert!(editor.owned_system_clipboard.is_none()); + editor.handle(Input::Char('u')); + fixture.external("row\n"); + editor.handle(Input::Char('p')); + assert_eq!(editor.context().text, "Yrow\nZ"); + } + + #[test] + fn linewise_empty_final_row_publication_failures_leave_selection_unchanged() { + for key in ['y', 'd', 'c'] { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor.apply(vec![Effect::Replace("a\n".into())]).unwrap(); + type_text(&mut editor, "jV"); + fixture.fail("fail-write", true); + let before = state(&editor); + editor.handle(Input::Char(key)); + assert_eq!(state(&editor), before); + assert!(editor.visual_linewise()); + assert_eq!(fixture.bytes("state"), b""); + fixture.fail("fail-write", false); + editor.handle(Input::Char(key)); + assert_eq!(fixture.bytes("state"), b"\n"); + assert_eq!(editor.context().text, if key == 'd' { "a" } else { "a\n" }); + } + } + + #[test] + fn linewise_repeat_publication_failure_restores_shape_and_recipe_atomically() { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor + .apply(vec![Effect::Replace("a\nb\nc".into())]) + .unwrap(); + type_text(&mut editor, "Vd"); + editor + .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) + .unwrap(); + type_text(&mut editor, "Vj"); + fixture.fail("fail-write", true); + let before = state(&editor); + editor.handle(Input::Char('.')); + assert_eq!(state(&editor), before); + assert_eq!(fixture.bytes("state"), b"a\n"); + assert!(editor.visual_linewise()); + fixture.fail("fail-write", false); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "cc"); + assert_eq!(fixture.bytes("state"), b"aa\nbb\n"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "aa\nbb\ncc"); + assert_eq!(editor.mode(), Mode::Normal); + } + + #[test] + fn linewise_internal_shape_is_independent_of_owned_system_shape() { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor.set_clipboard_mode("separate").unwrap(); + editor + .apply(vec![Effect::Replace("inside".into())]) + .unwrap(); + type_text(&mut editor, "Vy"); + editor + .bind("visual", "q", Some("core.copy_system".into())) + .unwrap(); + editor.apply(vec![Effect::Replace("out".into())]).unwrap(); + type_text(&mut editor, "veq"); + assert_eq!(fixture.bytes("state"), b"out"); + editor.apply(vec![Effect::Replace("XY".into())]).unwrap(); + editor.handle(Input::Char('p')); + assert_eq!(editor.context().text, "XY\ninside"); + editor.apply(vec![Effect::Replace("XY".into())]).unwrap(); + editor.set_clipboard_mode("system").unwrap(); + editor.handle(Input::Char('p')); + assert_eq!(editor.context().text, "XoutY"); + } + + #[test] + fn linewise_dot_paste_keeps_captured_shape_after_metadata_loss_and_read_failure() { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor.apply(vec![Effect::Replace("row".into())]).unwrap(); + type_text(&mut editor, "Vyp"); + assert_eq!(editor.context().text, "row\nrow"); + editor.apply(vec![Effect::Replace("AB".into())]).unwrap(); + fixture.fail("fail-read", true); + let before = state(&editor); + editor.handle(Input::Char('p')); + assert_eq!(state(&editor), before); + fixture.fail("fail-read", false); + fixture.external(""); + editor.handle(Input::Char('p')); + assert_eq!(editor.context().text, "AB"); + assert!(editor.owned_system_clipboard.is_none()); + let reads = fixture.bytes("reads").len(); + fixture.fail("fail-read", true); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "AB\nrow"); + assert_eq!(fixture.bytes("reads").len(), reads); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "AB"); + } + + #[test] + fn default_system_native_copy_cut_change_exports_exact_selected_text() { + for key in ['y', 'd', 'x', 'c'] { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + assert_eq!(editor.clipboard_mode(), ClipboardMode::System); + editor.clipboard = vec!["internal".into()]; + editor + .apply(vec![Effect::Replace("a界e\u{301}z\n👩‍🚀tail\n".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[(Cursor { row: 0, column: 1 }, Cursor { row: 1, column: 1 })], + 0, + ); + let undo = editor.active.undo.len(); + editor.handle(Input::Char(key)); + assert_eq!(fixture.bytes("state"), "界e\u{301}z\n👩‍🚀".as_bytes()); + assert_eq!(fixture.bytes("writes").len(), 1); + assert_eq!(fixture.bytes("reads").len(), 0); + assert_eq!(editor.clipboard, ["internal"]); + match key { + 'y' => { + assert_eq!(editor.context().text, "a界e\u{301}z\n👩‍🚀tail\n"); + assert_eq!(editor.active.undo.len(), undo); + } + 'c' => { + assert_eq!(editor.mode(), Mode::Insert); + assert!(editor.active.view.pending_edit.is_some()); + type_text(&mut editor, "Q"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "aQtail\n"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "a界e\u{301}z\n👩‍🚀tail\n"); + } + _ => { + assert_eq!(editor.context().text, "atail\n"); + assert_eq!(editor.active.undo.len(), undo + 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "a界e\u{301}z\n👩‍🚀tail\n"); + } + } + } + } + + #[test] + fn system_paste_before_after_normalizes_valid_external_text_and_records_it() { + for (key, action, expected) in [ + ('P', None, "界\t\n:q!\nab"), + ('p', None, "a界\t\n:q!\nb"), + ('z', Some("core.paste_system_before"), "界\t\n:q!\nab"), + ('z', Some("core.paste_system_after"), "a界\t\n:q!\nb"), + ] { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + if let Some(action) = action { + editor.set_clipboard_mode("separate").unwrap(); + editor.bind("normal", "z", Some(action.into())).unwrap(); + } + editor.apply(vec![Effect::Replace("ab".into())]).unwrap(); + editor.clipboard = vec!["stale".into()]; + fixture.external("界\t\r\n:q!\r"); + editor.handle(Input::Char(key)); + assert_eq!(editor.context().text, expected); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.clipboard, ["stale"]); + assert_eq!(fixture.bytes("state"), "界\t\r\n:q!\r".as_bytes()); + assert_eq!(fixture.bytes("writes").len(), 0); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "ab"); + assert!(!editor.should_quit()); + } + } + + #[test] + fn own_fragments_pair_external_multiline_broadcasts_and_empty_never_uses_internal() { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor + .apply(vec![Effect::Replace("ab\nxyz".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + type_text(&mut editor, "vey"); + assert_eq!(fixture.bytes("state"), b"ab\nxyz"); + assert_eq!( + editor.owned_system_clipboard.as_ref().unwrap().1, + ["ab", "xyz"] + ); + editor + .apply(vec![Effect::Replace("1\n2\n3".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); + let before = state(&editor); + editor.handle(Input::Char('P')); + assert_eq!(state(&editor), before); + assert!(editor.message().contains("fragment count")); + editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, "ab1\nxyz2"); + editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + fixture.external("left\nright\n"); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, "left\nright\n1\nleft\nright\n2"); + assert!(editor.owned_system_clipboard.is_none()); + editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + fixture.external("ab\nxyz"); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, "ab\nxyz1\nab\nxyz2"); + editor.clipboard = vec!["stale".into()]; + fixture.external(""); + let before = state(&editor); + editor.handle(Input::Char('p')); + assert_eq!(state(&editor), before); + assert!(editor.message().contains("empty")); + } + + #[test] + fn separate_and_explicit_system_actions_are_independent_and_mode_checked() { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor.set_clipboard_mode("SePaRaTe").unwrap(); + assert_eq!(editor.clipboard_mode(), ClipboardMode::Separate); + for mode in ["", "invalid", " system "] { + assert!(editor.set_clipboard_mode(mode).is_err()); + assert_eq!(editor.clipboard_mode(), ClipboardMode::Separate); + } + fixture.external("outside"); + fixture.fail("fail-read", true); + fixture.fail("fail-write", true); + editor + .apply(vec![Effect::Replace("inside".into())]) + .unwrap(); + type_text(&mut editor, "veyP"); + assert_eq!(editor.context().text, "insideinside"); + assert_eq!(editor.clipboard, ["inside"]); + assert_eq!(fixture.bytes("reads").len(), 0); + assert_eq!(fixture.bytes("writes").len(), 0); + fixture.fail("fail-read", false); + fixture.fail("fail-write", false); + for action in ["core.paste_system_before", "core.paste_system_after"] { + assert!(editor.bind("insert", "z", Some(action.into())).is_err()); + editor.bind("normal", "z", Some(action.into())).unwrap(); + } + assert!( + editor + .bind("normal", "q", Some("core.copy_system".into())) + .is_err() + ); + assert!( + editor + .bind("insert", "q", Some("core.copy_system".into())) + .is_err() + ); + editor + .bind("visual", "q", Some("core.copy_system".into())) + .unwrap(); + editor + .apply(vec![Effect::Replace("system".into())]) + .unwrap(); + editor.handle(Input::Home); + type_text(&mut editor, "veq"); + assert_eq!(fixture.bytes("state"), b"system"); + assert_eq!(editor.clipboard, ["inside"]); + editor.apply(vec![Effect::Replace("!".into())]).unwrap(); + editor.handle(Input::Char('z')); + assert_eq!(editor.context().text, "!system"); + assert_eq!(editor.clipboard, ["inside"]); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, "!systeinsidem"); + assert_eq!(fixture.bytes("state"), b"system"); + editor.set_clipboard_mode("SYSTEM").unwrap(); + editor.handle(Input::Ctrl('p')); + let entries = &editor.palette().unwrap().entries; + for name in [ + "core.copy_system", + "core.paste_system_before", + "core.paste_system_after", + ] { + let entry = entries.iter().find(|entry| entry.name == name).unwrap(); + assert_eq!(entry.enabled, name != "core.copy_system"); + } + } + + #[test] + fn provider_and_invalid_text_errors_preserve_all_editing_state() { + for key in ['y', 'd', 'x', 'c'] { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor + .apply(vec![Effect::Replace("ab\ncd".into())]) + .unwrap(); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + editor.clipboard = vec!["internal".into()]; + editor + .store_clipboard(vec!["owned".into()], true, false) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + type_text(&mut editor, "ve"); + fixture.fail("fail-write", true); + let before = state(&editor); + editor.handle(Input::Char(key)); + assert_eq!(state(&editor), before); + assert_eq!(fixture.bytes("state"), b"owned"); + assert!(!editor.message().is_empty()); + } + for invalid in [ + b"valid\nbad\0".to_vec(), + b"\x1b[31m".to_vec(), + b"\xff".to_vec(), + vec![b'x'; MAX_EDIT_BYTES + 1], + ] { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor + .apply(vec![Effect::Replace("original".into())]) + .unwrap(); + editor + .store_clipboard(vec!["owned".into()], true, false) + .unwrap(); + editor.clipboard = vec!["internal".into()]; + fixture.external(invalid); + let before = state(&editor); + editor.handle(Input::Char('p')); + assert_eq!(state(&editor), before); + assert!(!editor.message().is_empty()); + } + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + fixture.fail("fail-read", true); + let before = state(&editor); + editor.handle(Input::Char('P')); + assert_eq!(state(&editor), before); + assert!(!editor.message().is_empty()); + } + + #[test] + fn separate_cut_change_and_copy_never_invoke_system_transport() { + for key in ['y', 'd', 'x', 'c'] { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor.set_clipboard_mode("separate").unwrap(); + fixture.external("outside"); + fixture.fail("fail-read", true); + fixture.fail("fail-write", true); + editor + .apply(vec![Effect::Replace("inside".into())]) + .unwrap(); + type_text(&mut editor, "ve"); + editor.handle(Input::Char(key)); + assert_eq!(editor.clipboard, ["inside"]); + assert_eq!( + editor.context().text, + if key == 'y' { "inside" } else { "" } + ); + assert_eq!( + editor.mode(), + if key == 'c' { + Mode::Insert + } else { + Mode::Normal + } + ); + assert_eq!(fixture.bytes("state"), b"outside"); + assert_eq!(fixture.bytes("reads").len(), 0); + assert_eq!(fixture.bytes("writes").len(), 0); + } + } + + #[test] + fn empty_visual_copy_and_change_preserve_both_registers() { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor.clipboard = vec!["internal".into()]; + editor + .store_clipboard(vec!["owned".into()], true, false) + .unwrap(); + fixture.fail("fail-write", true); + for key in ['y', 'd', 'c'] { + editor.active.view.mode = Mode::Visual; + editor.handle(Input::Char(key)); + assert_eq!(editor.clipboard, ["internal"]); + assert_eq!(fixture.bytes("state"), b"owned"); + assert_eq!(fixture.bytes("writes").len(), 1); + assert_eq!(editor.context().text, ""); + if key == 'c' { + assert_eq!(editor.mode(), Mode::Insert); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + } + } + } + + #[test] + fn registers_and_mode_are_session_wide_across_buffers() { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor.set_clipboard_mode("separate").unwrap(); + editor + .apply(vec![Effect::Replace("inside".into())]) + .unwrap(); + type_text(&mut editor, "vey"); + editor.set_clipboard_mode("system").unwrap(); + editor + .apply(vec![Effect::Replace("ab\nxyz".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + type_text(&mut editor, "vey"); + let other = fixture.0.path("other"); + fs::write(&other, "1\n2").unwrap(); + editor.execute(&format!("edit {}", other.display())); + assert_eq!(editor.clipboard_mode(), ClipboardMode::System); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, "ab1\nxyz2"); + editor.set_clipboard_mode("separate").unwrap(); + editor.execute("buffer 1"); + assert_eq!(editor.clipboard_mode(), ClipboardMode::Separate); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, "insideab\nxyz"); + assert_eq!(fixture.bytes("state"), b"ab\nxyz"); + } + + #[test] + fn dot_paste_uses_captured_fragments_without_reading_or_publishing() { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor + .apply(vec![Effect::Replace("ab\nxyz".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + type_text(&mut editor, "vey"); + editor.apply(vec![Effect::Replace("1\n2".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('P')); + fixture.external("external"); + fixture.fail("fail-read", true); + fixture.fail("fail-write", true); + editor + .apply(vec![Effect::Replace("3\n4\n5".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); + let before = state(&editor); + editor.handle(Input::Char('.')); + assert_eq!(state(&editor), before); + editor.set_clipboard_mode("separate").unwrap(); + editor.clipboard = vec!["other".into()]; + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "ab3\nxyz4\n5"); + assert_eq!(editor.clipboard, ["other"]); + assert_eq!(fixture.bytes("reads").len(), 1); + assert_eq!(fixture.bytes("writes").len(), 1); + assert_eq!(fixture.bytes("state"), b"external"); + } + + #[test] + fn dot_selection_stages_publication_and_rolls_back_step_or_provider_failures() { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor + .apply(vec![Effect::Replace("ab\ncd".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + type_text(&mut editor, "vc"); + editor.handle(Input::Backspace); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + let original_clipboard = fixture.bytes("state"); + editor + .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); + editor.handle(Input::Char('v')); + let before = state(&editor); + editor.handle(Input::Char('.')); + assert_eq!(state(&editor), before); + assert_eq!(fixture.bytes("writes").len(), 1); + assert_eq!(fixture.bytes("state"), original_clipboard); + assert!(editor.message().contains("count")); + editor + .apply(vec![Effect::Replace("aa\nbb".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('v')); + editor.active.view.search_query = "aa".into(); + editor.active.view.search_match = Some(SearchMatch { + start: Cursor::default(), + end_column: 2, + }); + fixture.fail("fail-write", true); + let before = state(&editor); + editor.handle(Input::Char('.')); + assert_eq!(state(&editor), before); + assert_eq!(fixture.bytes("state"), original_clipboard); + assert_eq!(fixture.bytes("writes").len(), 2); + fixture.fail("fail-write", false); + let undo = editor.active.undo.len(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "XaXb"); + assert_eq!(fixture.bytes("state"), b"a\nb"); + assert_eq!(fixture.bytes("writes").len(), 3); + assert_eq!(editor.active.undo.len(), undo + 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "aa\nbb"); + assert_eq!(fixture.bytes("state"), b"a\nb"); + } + + #[test] + fn dot_unequal_spans_refuses_before_publication_and_preserves_recipe() { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor + .apply(vec![Effect::Replace("ab\nxyz".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[ + (Cursor::default(), Cursor { row: 0, column: 2 }), + (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), + ], + 1, + ); + editor.handle(Input::Char('d')); + editor + .apply(vec![Effect::Replace("12\n345".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + let before = state(&editor); + editor.handle(Input::Char('.')); + assert_eq!(state(&editor), before); + assert_eq!(fixture.bytes("writes").len(), 1); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "\n"); + assert_eq!(fixture.bytes("state"), b"12\n345"); + assert_eq!(fixture.bytes("writes").len(), 2); + } + + #[test] + fn dot_cut_follows_current_policy_without_copying_plain_deletes_or_plugin_edits() { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + editor.set_clipboard_mode("separate").unwrap(); + editor.apply(vec![Effect::Replace("first".into())]).unwrap(); + type_text(&mut editor, "ved"); + assert_eq!(editor.clipboard, ["first"]); + assert_eq!(fixture.bytes("writes").len(), 0); + editor.set_clipboard_mode("system").unwrap(); + editor + .apply(vec![Effect::Replace("second".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, ""); + assert_eq!(fixture.bytes("state"), b"second"); + assert_eq!(editor.clipboard, ["first"]); + editor.set_clipboard_mode("separate").unwrap(); + editor.apply(vec![Effect::Replace("third".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.clipboard, ["third"]); + assert_eq!(fixture.bytes("state"), b"second"); + editor.set_clipboard_mode("system").unwrap(); + editor.apply(vec![Effect::Replace("plain".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + type_text(&mut editor, "x."); + type_text(&mut editor, "iX"); + editor.handle(Input::Backspace); + editor.handle(Input::Delete); + editor.handle(Input::Escape); + editor.handle(Input::Char('.')); + assert_eq!(fixture.bytes("writes").len(), 1); + assert_eq!(fixture.bytes("reads").len(), 0); + } +} + +#[test] +fn floating_default_tree_starts_filtering_then_escape_navigates_before_close() { + let directory = Directory::new(); + fs::create_dir(directory.path("dir")).unwrap(); + fs::write(directory.path("dir/first.txt"), "first").unwrap(); + fs::write(directory.path("dir/second.txt"), "second").unwrap(); + let mut editor = project_editor(&directory); + type_text(&mut editor, " osecond.txt"); + let picker = editor.file_picker().unwrap(); + assert_eq!(picker.view, FileView::Tree); + assert!(picker.focused && picker.searching); + assert_eq!(picker.query, "second.txt"); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("dir/second.txt") + ); + editor.handle(Input::Escape); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused && !picker.searching); + assert_eq!(picker.query, "second.txt"); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("dir/second.txt") + ); + editor.handle(Input::Char('i')); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "second.txt"); + editor.handle(Input::Escape); + editor.handle(Input::Char('h')); + editor.handle(Input::Char('h')); + let picker = editor.file_picker().unwrap(); + assert_eq!(picker.entries().len(), 1); + assert_eq!(picker.entries().next().unwrap().path, Path::new("dir")); + assert!(!picker.entries().next().unwrap().expanded); + assert_eq!(picker.query, "second.txt"); + editor.handle(Input::Char('l')); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + editor.handle(Input::Escape); + assert!(editor.file_picker().is_none()); + assert_eq!(editor.lines(), &[String::new()]); + editor.set_file_picker_view("list").unwrap(); + type_text(&mut editor, " osecond.txt"); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + editor.handle(Input::Enter); + assert_eq!(editor.lines(), &["second"]); + assert!(editor.file_picker().is_none()); +} + +#[test] +fn filter_escape_resume_and_clear_do_not_edit_underlying_buffers_or_history() { + let directory = Directory::new(); + fs::create_dir_all(directory.path("src/deep")).unwrap(); + fs::write(directory.path("src/deep/needle.txt"), "needle").unwrap(); + fs::write(directory.path("src/other.txt"), "other").unwrap(); + let selected_path = |editor: &Editor| { + let picker = editor.file_picker().unwrap(); + picker + .entries() + .nth(picker.selected) + .unwrap() + .path + .to_owned() + }; + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + for mode in [ + Mode::Normal, + Mode::Insert, + Mode::Command, + Mode::Search, + Mode::Visual, + ] { + let mut editor = project_editor(&directory); + editor + .apply(vec![Effect::Replace("alpha\nbeta".into())]) + .unwrap(); + editor + .apply(vec![Effect::Replace("changed".into())]) + .unwrap(); + editor.act(Action::Undo); + set_carets(&mut editor, &[(0, 1), (1, 2)], 1); + editor.begin_insert(); + editor.active.view.mode = mode; + editor.active.view.command_line = "original prompt".into(); + editor.open_file_picker(presentation); + if !editor.file_picker().unwrap().searching { + editor.handle(Input::Char('i')); + } + editor.count = Some(7); + let snapshot = + |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); + let lines = editor.lines().to_owned(); + let targets = editor.active.view.targets.clone(); + let primary = editor.active.view.primary; + let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); + let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); + let insert = editor.active.view.insert_start.as_ref().map(snapshot); + let buffer = editor.buffer_id(); + let buffers = editor.buffer_count(); + type_text(&mut editor, "needle"); + let expected = PathBuf::from("src/deep/needle.txt"); + assert_eq!(selected_path(&editor), expected); + editor.handle(Input::Escape); + assert!(!editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "needle"); + assert_eq!(selected_path(&editor), expected); + editor.handle(Input::Char('i')); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "needle"); + assert_eq!(selected_path(&editor), expected); + editor.handle(Input::Ctrl('U')); + assert!(editor.file_picker().unwrap().searching); + assert!(editor.file_picker().unwrap().query.is_empty()); + assert_eq!(selected_path(&editor), expected); + type_text(&mut editor, "needle"); + editor.handle(Input::Escape); + editor.handle(Input::Ctrl('u')); + assert!(!editor.file_picker().unwrap().searching); + assert!(editor.file_picker().unwrap().query.is_empty()); + assert_eq!(selected_path(&editor), expected); + editor.handle(Input::Ctrl('u')); + assert_eq!(selected_path(&editor), expected); + editor.handle(Input::Char('i')); + type_text(&mut editor, "missing"); + editor.handle(Input::Ctrl('j')); + editor.handle(Input::Ctrl('k')); + editor.handle(Input::Escape); + assert_eq!(editor.file_picker().unwrap().entries().len(), 0); + editor.handle(Input::Ctrl('u')); + assert_eq!(selected_path(&editor), expected); + assert!(editor.file_picker().unwrap().focused); + assert!(!editor.file_picker().unwrap().searching); + assert_eq!(editor.mode(), mode); + assert_eq!(editor.lines(), lines); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, primary); + assert_eq!(editor.active.view.command_line, "original prompt"); + assert_eq!(editor.count(), Some(7)); + assert_eq!(editor.buffer_id(), buffer); + assert_eq!(editor.buffer_count(), buffers); + assert_eq!( + editor.active.undo.iter().map(&snapshot).collect::>(), + undo + ); + assert_eq!( + editor.active.redo.iter().map(&snapshot).collect::>(), + redo + ); + assert_eq!( + editor.active.view.insert_start.as_ref().map(snapshot), + insert + ); + editor.handle(Input::Escape); + match presentation { + FilePresentation::Floating => assert!(editor.file_picker().is_none()), + FilePresentation::Docked => { + assert!(!editor.file_picker().unwrap().focused); + editor.open_file_picker(presentation); + assert!(!editor.file_picker().unwrap().searching); + assert_eq!(selected_path(&editor), expected); + } + } + } + } +} + +#[test] +fn filtered_folder_keys_and_directory_accept_work_in_both_presentations() { + let directory = Directory::new(); + fs::create_dir_all(directory.path("src/deep")).unwrap(); + fs::write(directory.path("src/deep/needle.txt"), "needle").unwrap(); + fs::write(directory.path("src/other.txt"), "other").unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + let mut editor = project_editor(&directory); + editor.open_file_picker(presentation); + if !editor.file_picker().unwrap().searching { + editor.handle(Input::Char('i')); + } + type_text(&mut editor, "needle"); + editor.handle(Input::Ctrl('h')); + let picker = editor.file_picker().unwrap(); + assert!(picker.searching); + assert_eq!(picker.query, "needle"); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("src/deep") + ); + assert!(picker.entries().nth(picker.selected).unwrap().expanded); + editor.handle(Input::Ctrl('l')); + let picker = editor.file_picker().unwrap(); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("src/deep/needle.txt") + ); + editor.handle(Input::Escape); + editor.handle(Input::Char('h')); + editor.handle(Input::Char('h')); + let picker = editor.file_picker().unwrap(); + assert!(!picker.searching); + assert_eq!(picker.query, "needle"); + assert_eq!(picker.entries().len(), 2); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("src/deep") + ); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + editor.handle(Input::Char('l')); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .nth(1) + .unwrap() + .expanded + ); + editor.handle(Input::Char('l')); + editor.handle(Input::Char('h')); + editor.handle(Input::Enter); + assert!( + !editor + .file_picker() + .unwrap() + .entries() + .nth(1) + .unwrap() + .expanded + ); + editor.handle(Input::Enter); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .nth(1) + .unwrap() + .expanded + ); + editor.handle(Input::Char('i')); + editor.handle(Input::Enter); + let picker = editor.file_picker().unwrap(); + assert!(!picker.searching && picker.focused); + assert_eq!(picker.query, "needle"); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("src/deep") + ); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Ctrl('r')); + editor.handle(Input::Ctrl('t')); + editor.handle(Input::Ctrl('t')); + let picker = editor.file_picker().unwrap(); + assert!(!picker.searching && picker.focused); + assert_eq!(picker.query, "needle"); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + if presentation == FilePresentation::Docked { + editor.handle(Input::Escape); + editor.open_file_picker(presentation); + assert!(!editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "needle"); + } + editor.open_file_picker(FilePresentation::Floating); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "needle"); + } +} + +#[test] +fn filter_clear_respects_navigation_bindings_and_preserves_idle_dock_queries() { + let directory = Directory::new(); + for name in ["a.txt", "b.txt"] { + fs::write(directory.path(name), "text").unwrap(); + } + for view in [FileView::List, FileView::Tree] { + let mut editor = project_editor(&directory); + editor.open_file_picker(FilePresentation::Docked); + editor.file_picker.as_mut().unwrap().set_view(view); + if view == FileView::Tree { + editor.handle(Input::Char('i')); + } + type_text(&mut editor, "txt"); + editor + .bind("selection", "ctrl+u", Some("core.down".into())) + .unwrap(); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.file_picker().unwrap().query, "txt"); + assert_eq!(editor.file_picker().unwrap().selected, 1); + editor.bind("selection", "ctrl+u", None).unwrap(); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.file_picker().unwrap().query, "txt"); + editor.selection_bindings.remove(&Input::Ctrl('u')); + if view == FileView::Tree { + editor.bind("tree", "ctrl+u", None).unwrap(); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.file_picker().unwrap().query, "txt"); + editor.tree_bindings.remove(&Input::Ctrl('u')); + } + editor.handle(Input::Ctrl('u')); + assert!(editor.file_picker().unwrap().query.is_empty()); + assert_eq!(editor.file_picker().unwrap().selected, 1); + assert_eq!( + editor.file_picker().unwrap().searching, + view == FileView::Tree + ); + type_text(&mut editor, "txt"); + editor.handle(Input::Char('\t')); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.file_picker().unwrap().query, "txt"); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core"); + editor.handle(Input::Ctrl('u')); + assert!(editor.palette().unwrap().query.is_empty()); + assert_eq!(editor.file_picker().unwrap().query, "txt"); + } +} + +#[test] +fn selection_navigation_wraps_without_touching_any_underlying_mode() { + let directory = Directory::new(); + for name in ["a.txt", "b.txt", "c.txt"] { + fs::write(directory.path(name), "file text").unwrap(); + } + for surface in [ + None, + Some(FilePresentation::Floating), + Some(FilePresentation::Docked), + ] { + for mode in [ + Mode::Normal, + Mode::Insert, + Mode::Command, + Mode::Search, + Mode::Visual, + ] { + let mut editor = project_editor(&directory); + editor + .apply(vec![Effect::Replace("alpha\nbeta".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 1), (1, 2)], 1); + editor.active.view.mode = mode; + editor.active.view.command_line = "original prompt".into(); + if let Some(presentation) = surface { + editor.open_file_picker(presentation); + editor + .file_picker + .as_mut() + .unwrap() + .set_view(FileView::List); + type_text(&mut editor, "txt"); + } else { + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core"); + } + let list = |editor: &Editor| { + if let Some(picker) = editor.file_picker() { + ( + picker.query.clone(), + picker.selected, + picker.entries().len(), + ) + } else { + let palette = editor.palette().unwrap(); + ( + palette.query.clone(), + palette.selected, + palette.entries.len(), + ) + } + }; + let (query, _, len) = list(&editor); + assert!(len > 1); + let snapshot = + |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); + let text = editor.context().text; + let targets = editor.active.view.targets.clone(); + let primary = editor.active.view.primary; + let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); + let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); + let insert = editor.active.view.insert_start.as_ref().map(snapshot); + editor.count = Some(7); + for (input, selected) in [ + (Input::Ctrl('K'), len - 1), + (Input::Ctrl('J'), 0), + (Input::Ctrl('j'), 1), + (Input::Ctrl('k'), 0), + (Input::Up, len - 1), + (Input::Down, 0), + (Input::Ctrl('n'), 1), + ] { + assert_eq!(editor.handle(input), None); + assert_eq!(list(&editor), (query.clone(), selected, len)); + } + assert_eq!(editor.mode(), mode); + assert_eq!(editor.context().text, text); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, primary); + assert_eq!(editor.active.view.command_line, "original prompt"); + assert_eq!(editor.count(), Some(7)); + assert_eq!( + editor.active.undo.iter().map(&snapshot).collect::>(), + undo + ); + assert_eq!( + editor.active.redo.iter().map(&snapshot).collect::>(), + redo + ); + assert_eq!( + editor.active.view.insert_start.as_ref().map(snapshot), + insert + ); + type_text(&mut editor, "missing"); + assert_eq!(list(&editor).2, 0); + for input in [Input::Ctrl('j'), Input::Ctrl('k'), Input::Enter] { + assert_eq!(editor.handle(input), None); + assert_eq!(list(&editor).1, 0); + assert_eq!(editor.context().text, text); + assert_eq!(editor.mode(), mode); + } + } + } +} + +#[test] +fn selection_remaps_and_disables_are_shared_and_invalid_edits_are_atomic() { + let directory = Directory::new(); + for name in ["a.txt", "b.txt", "c.txt"] { + fs::write(directory.path(name), "file text").unwrap(); + } + for surface in [ + None, + Some(FilePresentation::Floating), + Some(FilePresentation::Docked), + ] { + let mut editor = project_editor(&directory); + assert_eq!( + editor.selection_navigation_hint(), + "Ctrl+j/Ctrl+n/Down next · Ctrl+k/Up previous" + ); + type_text(&mut editor, "3 b"); + let pending = editor.sequence_pending().unwrap().to_owned(); + editor + .bind("selection", "x", Some("core.down".into())) + .unwrap(); + editor + .bind("selection", "z", Some("core.up".into())) + .unwrap(); + editor.bind("selection", "ctrl+j", None).unwrap(); + editor.bind("selection", "!", None).unwrap(); + assert_eq!(editor.count(), Some(3)); + assert_eq!(editor.sequence_pending(), Some(pending.as_str())); + assert_eq!( + editor.selection_navigation_hint(), + "Ctrl+n/Down/x next · Ctrl+k/Up/z previous" + ); + let hint = editor.selection_navigation_hint().to_owned(); + for (mode, key, target) in [ + ("selection", "x", Some("core.delete")), + ("selection", "x", Some("core.palette")), + ("selection", "x", Some("w")), + ("selection", "leader x", Some("core.down")), + ("selection", "LEADER x", None), + ("selection", "x z", Some("core.up")), + ("invalid", "x", Some("core.up")), + ] { + assert!(editor.bind(mode, key, target.map(str::to_owned)).is_err()); + assert_eq!(editor.selection_navigation_hint(), hint); + assert_eq!(editor.count(), Some(3)); + assert_eq!(editor.sequence_pending(), Some(pending.as_str())); + } + editor.cancel_sequence(); + // Editing bindings cannot disable or remap list bindings. + editor + .bind("normal", "x", Some("core.insert".into())) + .unwrap(); + editor.bind("normal", "ctrl+k", None).unwrap(); + if let Some(presentation) = surface { + editor.open_file_picker(presentation); + editor + .file_picker + .as_mut() + .unwrap() + .set_view(FileView::List); + } else { + editor.handle(Input::Ctrl('p')); + } + let selected = |editor: &Editor| { + if let Some(picker) = editor.file_picker() { + picker.selected + } else { + editor.palette().unwrap().selected + } + }; + for (input, expected) in [ + (Input::Char('x'), 1), + (Input::Ctrl('j'), 1), + (Input::Char('!'), 1), + (Input::Char('z'), 0), + (Input::Char('x'), 1), + (Input::Ctrl('k'), 0), + ] { + editor.handle(input); + assert_eq!(selected(&editor), expected); + assert_eq!(editor.mode(), Mode::Normal); + } + // Updating the map does not rebuild/reset the selected list. + editor.handle(Input::Char('x')); + editor + .bind("selection", "ctrl+p", Some("core.down".into())) + .unwrap(); + assert_eq!(selected(&editor), 1); + editor.handle(Input::Ctrl('p')); + assert_eq!(selected(&editor), 2); + editor.bind("selection", "backspace", None).unwrap(); + type_text(&mut editor, "a"); + editor.handle(Input::Backspace); + let query = if let Some(picker) = editor.file_picker() { + &picker.query + } else { + &editor.palette().unwrap().query + }; + assert_eq!(query, "a"); + for key in ["down", "ctrl+n", "x", "ctrl+p"] { + editor.bind("selection", key, None).unwrap(); + } + assert_eq!( + editor.selection_navigation_hint(), + "unbound next · Ctrl+k/Up/z previous" + ); + editor.handle(Input::Escape); + editor.handle(Input::Char('x')); + assert_eq!(editor.mode(), Mode::Insert); + } +} + +#[test] +fn ctrl_j_is_distinct_from_enter_and_preserves_editor_defaults() { + assert_eq!(parse_key("CTRL+J").unwrap(), Input::Ctrl('j')); + for (key, input) in [("ctrl+m", Input::Enter), ("ctrl+h", Input::Ctrl('h'))] { + assert_eq!(parse_key(key).unwrap(), input); + } + let mut editor = editor(); + editor + .bind("normal", "ctrl+j", Some("core.insert".into())) + .unwrap(); + editor.handle(Input::Enter); + assert_eq!(editor.mode(), Mode::Normal); + editor.handle(Input::Ctrl('j')); + assert_eq!(editor.mode(), Mode::Insert); + editor.bind("insert", "enter", None).unwrap(); + type_text(&mut editor, "one"); + editor.handle(Input::Enter); + editor.handle(Input::Ctrl('j')); + type_text(&mut editor, "two"); + assert_eq!(editor.context().text, "one\ntwo"); + editor.handle(Input::Escape); + editor.register(plugin("fixture", &["greet"])).unwrap(); + type_text(&mut editor, ":greet arguments"); + let invocation = editor.handle(Input::Ctrl('j')).unwrap(); + assert_eq!(invocation.command, "greet"); + assert_eq!(invocation.args, "arguments"); + assert_eq!(editor.mode(), Mode::Normal); + type_text(&mut editor, "/two"); + editor.handle(Input::Ctrl('j')); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.active.view.search_query, "two"); + assert_eq!(editor.cursor().row, 1); + editor + .bind("visual", "ctrl+j", Some("core.up".into())) + .unwrap(); + editor.handle(Input::Char('v')); + editor.handle(Input::Ctrl('j')); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.cursor().row, 0); +} + +#[test] +fn ctrl_j_navigates_lists_while_enter_accepts() { + let directory = Directory::new(); + fs::write(directory.path("a.txt"), "first").unwrap(); + fs::write(directory.path("b.txt"), "second").unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + let mut editor = project_editor(&directory); + editor.open_file_picker(presentation); + editor.handle(Input::Ctrl('j')); + assert_eq!(editor.file_picker().unwrap().selected, 1); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Enter); + assert_eq!(editor.context().text, "second"); + assert_eq!(editor.path(), Some(directory.path("b.txt").as_path())); + } + let mut editor = editor(); + editor + .register(plugin("fixture", &["greet_one", "greet_two"])) + .unwrap(); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "greet"); + editor.handle(Input::Ctrl('j')); + assert_eq!(editor.palette().unwrap().selected, 1); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.active.view.command_line, ""); + editor.handle(Input::Enter); + assert!(editor.palette().is_none()); + assert_eq!(editor.mode(), Mode::Command); + assert_eq!(editor.active.view.command_line, "greet_two "); +} + +#[test] +fn file_actions_default_leaders_palette_and_side_configuration() { + let directory = Directory::new(); + fs::write(directory.path("file.txt"), "abcdef").unwrap(); + for visual in [false, true] { + let mut editor = project_editor(&directory); + editor + .apply(vec![Effect::Replace("abcdef".into())]) + .unwrap(); + if visual { + type_text(&mut editor, "vl"); + } + let mode = editor.mode(); + let selections = editor.selections(); + let carets = editor.carets(); + for (suffix, presentation) in [ + ("3 o", FilePresentation::Floating), + ("2 e", FilePresentation::Docked), + ] { + type_text(&mut editor, suffix); + let picker = editor.file_picker().unwrap(); + assert_eq!(picker.presentation, presentation); + assert!(picker.focused); + assert_eq!(editor.mode(), mode); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.count(), None); + assert_eq!(editor.sequence_pending(), None); + editor.handle(Input::Ctrl('w')); + assert!(editor.file_picker().is_none()); + } + editor.handle(Input::Ctrl('p')); + for (name, binding) in [ + ("core.file_picker", "Space o"), + ("core.file_explorer", "Space e"), + ] { + let entry = palette_entry(&editor, name); + assert!(entry.enabled); + assert_eq!(entry.bindings, vec![binding]); + } + editor.handle(Input::Escape); + assert_eq!(editor.file_explorer_side(), DockSide::Right); + for (name, side) in [ + ("LEFT", DockSide::Left), + ("rIgHt", DockSide::Right), + ("top", DockSide::Top), + ("BOTTOM", DockSide::Bottom), + ] { + editor.set_file_explorer_side(name).unwrap(); + assert_eq!(editor.file_explorer_side(), side); + } + assert!(editor.set_file_explorer_side("middle").is_err()); + assert_eq!(editor.file_explorer_side(), DockSide::Bottom); + assert!( + editor + .bind("insert", "ctrl+o", Some("core.file_picker".into())) + .is_err() + ); + assert!( + editor + .bind("command", "ctrl+e", Some("core.file_explorer".into())) + .is_err() + ); + } +} + +#[test] +fn picker_focus_literal_input_navigation_and_empty_selection() { + let directory = Directory::new(); + for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] { + fs::write(directory.path(name), "text").unwrap(); + } + let mut editor = project_editor(&directory); + editor.set_file_picker_view("list").unwrap(); + type_text(&mut editor, " o"); + editor.handle(Input::Ctrl('K')); + assert_eq!(editor.file_picker().unwrap().selected, 2); + editor.handle(Input::Ctrl('N')); + assert_eq!(editor.file_picker().unwrap().selected, 0); + editor.handle(Input::Up); + editor.handle(Input::Down); + assert_eq!(editor.file_picker().unwrap().selected, 0); + type_text(&mut editor, "界 SPACE [E\u{301}]"); + assert_eq!(editor.file_picker().unwrap().entries().len(), 1); + assert_eq!(editor.context().text, ""); + assert_eq!(editor.count(), None); + assert_eq!(editor.sequence_pending(), None); + let query = editor.file_picker().unwrap().query.clone(); + editor.handle(Input::Paste("ignored 5 b n".into())); + assert_eq!(editor.file_picker().unwrap().query, query); + editor.handle(Input::Enter); + assert!(editor.file_picker().is_none()); + assert_eq!( + editor.path(), + Some(directory.path("a space 界[e\u{301}].txt").as_path()) + ); + assert_eq!(editor.context().text, "text"); + type_text(&mut editor, " o"); + type_text(&mut editor, "e\u{301}👩‍💻"); + editor.handle(Input::Backspace); + assert_eq!(editor.file_picker().unwrap().query, "e\u{301}"); + editor.handle(Input::Backspace); + assert_eq!(editor.file_picker().unwrap().query, ""); + editor.handle(Input::Backspace); + type_text(&mut editor, "missing 123"); + let id = editor.buffer_id(); + editor.handle(Input::Up); + editor.handle(Input::Down); + editor.handle(Input::Enter); + assert_eq!(editor.buffer_id(), id); + assert_eq!(editor.file_picker().unwrap().selected, 0); + assert!(editor.file_picker().unwrap().focused); + editor.handle(Input::Escape); + let empty = Directory::new(); + editor.project_root = empty.0.clone(); + type_text(&mut editor, " e"); + editor.handle(Input::Up); + editor.handle(Input::Down); + editor.handle(Input::Ctrl('j')); + editor.handle(Input::Ctrl('k')); + editor.handle(Input::Enter); + assert_eq!(editor.buffer_id(), id); + assert_eq!(editor.file_picker().unwrap().entries().len(), 0); +} + +#[test] +fn picker_conversion_reuses_query_and_scan_refreshes_only_on_request() { + let directory = Directory::new(); + fs::write(directory.path("keep.txt"), "text").unwrap(); + let mut editor = project_editor(&directory); + type_text(&mut editor, " eikeep"); + editor.handle(Input::Char('\t')); + assert!(!editor.file_picker().unwrap().focused); + fs::write(directory.path("keep new.txt"), "new").unwrap(); + type_text(&mut editor, " o"); + let picker = editor.file_picker().unwrap(); + assert_eq!(picker.presentation, FilePresentation::Floating); + assert_eq!(picker.query, "keep"); + assert_eq!(picker.entries().len(), 1); + editor.open_file_picker(FilePresentation::Docked); + assert_eq!(editor.file_picker().unwrap().query, "keep"); + editor.handle(Input::Ctrl('R')); + assert_eq!(editor.file_picker().unwrap().entries().len(), 2); + assert_eq!(editor.file_picker().unwrap().query, "keep"); + editor.handle(Input::Char('\t')); + assert!(!editor.file_picker().unwrap().focused); + type_text(&mut editor, " e"); + assert!(editor.file_picker().unwrap().focused); + assert_eq!(editor.file_picker().unwrap().query, "keep"); + fs::remove_file(directory.path("keep new.txt")).unwrap(); + editor.handle(Input::Ctrl('r')); + assert_eq!(editor.file_picker().unwrap().entries().len(), 1); + editor.handle(Input::Ctrl('w')); + assert!(editor.file_picker().is_none()); + type_text(&mut editor, " o"); + assert_eq!(editor.file_picker().unwrap().query, ""); + editor.handle(Input::Escape); + assert!(editor.file_picker().unwrap().focused); + editor.handle(Input::Escape); + assert!(editor.file_picker().is_none()); +} + +#[test] +fn picker_open_reuses_loaded_dirty_buffer_and_dock_survives_lifecycle() { + let directory = Directory::new(); + let a = directory.path("a space 界.txt"); + let b = directory.path("b.txt"); + fs::write(&a, "original").unwrap(); + fs::write(&b, "other").unwrap(); + let mut editor = project_editor(&directory); + editor.execute(&format!("edit {}", a.display())); + editor.apply(vec![Effect::Replace("dirty".into())]).unwrap(); + type_text(&mut editor, "vl"); + let selections = editor.selections(); + let id = editor.buffer_id(); + editor.execute(&format!("edit {}", b.display())); + fs::write(&a, b"\xff").unwrap(); + type_text(&mut editor, " eia space 界"); + editor.handle(Input::Enter); + assert_eq!(editor.buffer_id(), id); + assert_eq!(editor.buffer_count(), 3); + assert_eq!(editor.context().text, "dirty"); + assert!(editor.dirty()); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), selections); + assert!(!editor.file_picker().unwrap().focused); + assert_eq!(editor.file_picker().unwrap().query, "a space 界"); + editor.execute("bnext"); + assert!(editor.file_picker().is_some()); + editor.execute("bdelete"); + assert!(editor.file_picker().is_some()); + editor.execute("buffer 1"); + assert_eq!( + editor.file_picker().unwrap().presentation, + FilePresentation::Docked + ); + assert_eq!(editor.file_picker().unwrap().query, "a space 界"); +} + +#[test] +fn failed_picker_selection_preserves_visual_state_and_insert_group() { + let directory = Directory::new(); + let bad = directory.path("bad.txt"); + fs::write(&bad, b"\xff").unwrap(); + let mut editor = project_editor(&directory); + editor + .apply(vec![Effect::Replace("abcdef".into())]) + .unwrap(); + type_text(&mut editor, "llvhh"); + let selections = editor.selections(); + let carets = editor.carets(); + let undo = editor.active.undo.len(); + type_text(&mut editor, " obad"); + editor.handle(Input::Enter); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.context().text, "abcdef"); + assert_eq!(editor.active.undo.len(), undo); + assert_eq!(editor.next_buffer_id, 2); + assert_eq!(editor.buffer_count(), 1); + assert!(editor.file_picker().unwrap().focused); + assert!( + editor + .file_picker() + .unwrap() + .error + .as_ref() + .unwrap() + .contains("Open failed") + ); + editor.handle(Input::Down); + assert!(editor.file_picker().unwrap().error.is_none()); + editor.handle(Input::Enter); + editor.handle(Input::Backspace); + assert!(editor.file_picker().unwrap().error.is_none()); + editor.handle(Input::Escape); + editor.handle(Input::Escape); + editor.handle(Input::Escape); + editor.handle(Input::Char('i')); + type_text(&mut editor, "X"); + editor.open_file_picker(FilePresentation::Docked); + editor.handle(Input::Enter); + assert_eq!(editor.mode(), Mode::Insert); + assert!(editor.active.view.insert_start.is_some()); + assert!(editor.file_picker().unwrap().error.is_some()); + fs::remove_file(&bad).unwrap(); + editor.handle(Input::Enter); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.mode(), Mode::Insert); + assert!(editor.file_picker().unwrap().error.is_some()); + fs::create_dir(&bad).unwrap(); + editor.handle(Input::Enter); + assert_eq!(editor.buffer_count(), 1); + assert!(editor.file_picker().unwrap().focused); + editor.handle(Input::Escape); + assert!(!editor.file_picker().unwrap().focused); + type_text(&mut editor, "Y"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "abcdef"); + assert!(editor.file_picker().is_some()); +} + +#[test] +fn picker_palette_and_external_commands_are_mutually_exclusive() { + let directory = Directory::new(); + fs::write(directory.path("file.txt"), "text").unwrap(); + let mut editor = project_editor(&directory); + editor.handle(Input::Ctrl('p')); + editor.open_file_picker(FilePresentation::Docked); + assert!(editor.palette().is_none()); + editor.handle(Input::Ctrl('p')); + assert!(editor.palette().is_some()); + assert!(!editor.file_picker().unwrap().focused); + type_text(&mut editor, "file picker"); + editor.handle(Input::Enter); + assert!(editor.palette().is_none()); + assert_eq!( + editor.file_picker().unwrap().presentation, + FilePresentation::Floating + ); + assert!(editor.file_picker().unwrap().focused); + editor.handle(Input::Ctrl('p')); + assert!(editor.file_picker().is_none()); + assert!(editor.palette().is_some()); + editor.handle(Input::Escape); + type_text(&mut editor, " o"); + editor.execute("buffers"); + assert!(editor.file_picker().is_none()); + editor.handle(Input::Escape); + type_text(&mut editor, " eifile"); + editor.execute("buffers"); + assert!(!editor.file_picker().unwrap().focused); + assert_eq!(editor.file_picker().unwrap().query, "file"); + editor.handle(Input::Escape); + type_text(&mut editor, " e"); + editor.handle(Input::Ctrl('w')); + assert!(editor.file_picker().is_none()); +} + +#[test] +fn unfocused_dock_keeps_editor_counts_leaders_palette_and_literal_input() { + let directory = Directory::new(); + fs::write(directory.path("file.txt"), "text").unwrap(); + let mut editor = project_editor(&directory); + editor + .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) + .unwrap(); + type_text(&mut editor, " eifile"); + editor.handle(Input::Char('\t')); + type_text(&mut editor, "2j"); + assert_eq!(editor.cursor().row, 2); + assert_eq!(editor.file_picker().unwrap().query, "file"); + type_text(&mut editor, "3 b"); + assert_eq!(editor.sequence_pending(), Some("Space b")); + assert_eq!(editor.count(), Some(3)); + editor.handle(Input::Escape); + type_text(&mut editor, "i o e123"); + editor.handle(Input::Paste(" literal".into())); + assert!(editor.context().text.contains(" o e123 literal")); + assert_eq!(editor.file_picker().unwrap().query, "file"); + editor.handle(Input::Escape); + type_text(&mut editor, ": o e123"); + assert_eq!(editor.mode(), Mode::Command); + assert_eq!(editor.command_line(), " o e123"); + editor.handle(Input::Escape); + type_text(&mut editor, "/ o e123"); + assert_eq!(editor.mode(), Mode::Search); + assert_eq!(editor.command_line(), " o e123"); + editor.handle(Input::Escape); + type_text(&mut editor, " "); + assert!(editor.palette().is_some()); + assert!(!editor.file_picker().unwrap().focused); +} + +#[test] +fn canceling_picker_does_not_commit_insert_and_root_failure_is_not_empty_success() { + let directory = Directory::new(); + let mut editor = project_editor(&directory); + type_text(&mut editor, "iX"); + editor.open_file_picker(FilePresentation::Floating); + editor.handle(Input::Paste("ignored".into())); + editor.handle(Input::Escape); + assert!(editor.file_picker().unwrap().focused); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Insert); + assert!(editor.active.view.insert_start.is_some()); + assert_eq!(editor.context().text, "X"); + type_text(&mut editor, "Y"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + fs::remove_dir_all(&directory.0).unwrap(); + type_text(&mut editor, " o"); + assert!(editor.file_picker().is_none()); + assert!(editor.message().contains("File scan failed")); + assert_eq!(editor.buffer_count(), 1); +} + +#[cfg(unix)] +#[test] +fn picker_opens_raw_paths_even_when_sanitized_display_labels_collide() { + let directory = Directory::new(); + let names = [PathBuf::from("same\n.txt"), PathBuf::from("same\t.txt")]; + fs::write(directory.0.join(&names[0]), "first").unwrap(); + fs::write(directory.0.join(&names[1]), "second").unwrap(); + let mut editor = project_editor(&directory); + for (name, text) in names.iter().zip(["first", "second"]) { + type_text(&mut editor, " osame"); + let picker = editor.file_picker.as_mut().unwrap(); + assert_eq!(picker.entries().len(), 2); + assert_eq!( + picker.entries().next().unwrap().label, + picker.entries().last().unwrap().label, + ); + assert!( + picker + .entries() + .all(|entry| !entry.label.chars().any(char::is_control)) + ); + let selected = picker + .entries() + .position(|entry| entry.path == name.as_path()) + .unwrap(); + picker.selected = selected; + editor.handle(Input::Enter); + assert_eq!(editor.path(), Some(directory.0.join(name).as_path())); + assert_eq!(editor.context().text, text); + } + assert_eq!(editor.buffer_count(), 3); +} + +#[test] +fn file_view_preferences_are_independent_atomic_and_applied_to_current_presentation() { + let directory = Directory::new(); + fs::create_dir(directory.path("folder")).unwrap(); + fs::write(directory.path("file.txt"), "text").unwrap(); + let mut editor = project_editor(&directory); + editor.open_file_picker(FilePresentation::Docked); + assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); + editor.set_file_picker_view("TREE").unwrap(); + assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); + editor.set_file_explorer_view("LiSt").unwrap(); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + for invalid in ["", "flat", " tree "] { + assert!(editor.set_file_picker_view(invalid).is_err()); + assert!(editor.set_file_explorer_view(invalid).is_err()); + assert_eq!(editor.file_picker_view(), FileView::Tree); + assert_eq!(editor.file_explorer_view(), FileView::List); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + } + editor.handle(Input::Ctrl('t')); + assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); + assert_eq!(editor.file_explorer_view(), FileView::List); + editor.handle(Input::Char('\t')); + editor.open_file_picker(FilePresentation::Docked); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + editor.open_file_picker(FilePresentation::Floating); + assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); + assert_eq!( + editor.file_picker().unwrap().presentation, + FilePresentation::Floating + ); + editor.set_file_explorer_view("tree").unwrap(); + editor.set_file_picker_view("list").unwrap(); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + assert_eq!(editor.file_picker().unwrap().entries().len(), 1); +} + +#[test] +fn tree_hjkl_navigates_normal_and_control_hjkl_navigates_filter_without_changing_text() { + let directory = Directory::new(); + fs::create_dir(directory.path("dir")).unwrap(); + fs::write(directory.path("dir/hjkl-a.txt"), "a").unwrap(); + fs::write(directory.path("dir/hjkl-b.txt"), "b").unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + let mut editor = project_editor(&directory); + editor.set_file_picker_view("tree").unwrap(); + editor.open_file_picker(presentation); + if presentation == FilePresentation::Floating { + editor.handle(Input::Escape); + } + let selected = |editor: &Editor| { + let picker = editor.file_picker().unwrap(); + picker + .entries() + .nth(picker.selected) + .unwrap() + .path + .to_owned() + }; + editor.handle(Input::Char('l')); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + editor.handle(Input::Char('l')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); + editor.handle(Input::Char('j')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); + editor.handle(Input::Char('k')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); + editor.handle(Input::Char('h')); + assert_eq!(selected(&editor), Path::new("dir")); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + editor.handle(Input::Char('h')); + assert!( + !editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + editor.handle(Input::Char('i')); + type_text(&mut editor, "hjkl"); + assert_eq!(editor.file_picker().unwrap().query, "hjkl"); + assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); + editor.handle(Input::Ctrl('j')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); + editor.handle(Input::Ctrl('k')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); + editor.handle(Input::Ctrl('h')); + assert_eq!(selected(&editor), Path::new("dir")); + editor.handle(Input::Ctrl('l')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); + assert_eq!(editor.file_picker().unwrap().query, "hjkl"); + editor.handle(Input::Escape); + assert!(editor.file_picker().unwrap().focused); + assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); + assert_eq!(editor.file_picker().unwrap().query, "hjkl"); + editor.handle(Input::Ctrl('u')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-a.txt")); + editor.handle(Input::Char('h')); + editor.handle(Input::Char('h')); + assert!( + !editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + editor.bind("tree", "j", Some("core.up".into())).unwrap(); + editor.bind("tree", "k", None).unwrap(); + editor.handle(Input::Char('l')); + editor.handle(Input::Char('j')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); + editor.handle(Input::Char('k')); + assert_eq!(selected(&editor), Path::new("dir/hjkl-b.txt")); + editor.handle(Input::Char('i')); + type_text(&mut editor, "jk"); + assert_eq!(editor.file_picker().unwrap().query, "jk"); + assert_eq!(editor.lines(), &[String::new()]); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.cursor(), Cursor::default()); + } +} + +#[test] +fn tree_browse_and_search_have_separate_input_and_two_stage_escape() { + let directory = Directory::new(); + fs::create_dir_all(directory.path("folder/deep")).unwrap(); + fs::write(directory.path("folder/deep/needle.txt"), "text").unwrap(); + fs::write(directory.path("folder/other.txt"), "other").unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + for mode in [ + Mode::Normal, + Mode::Visual, + Mode::Insert, + Mode::Command, + Mode::Search, + ] { + let mut editor = project_editor(&directory); + editor + .apply(vec![Effect::Replace("alpha\nbeta".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 1), (1, 2)], 1); + editor.active.view.mode = mode; + editor.active.view.command_line = "underlying".into(); + let targets = editor.active.view.targets.clone(); + let text = editor.context().text; + let undo = editor.active.undo.len(); + editor.set_file_picker_view("tree").unwrap(); + editor.open_file_picker(presentation); + if presentation == FilePresentation::Floating { + editor.handle(Input::Escape); + } + type_text(&mut editor, "123 b xyz"); + editor.handle(Input::Paste("ignored".into())); + editor.handle(Input::Backspace); + assert_eq!(editor.file_picker().unwrap().query, ""); + assert_eq!(editor.count(), None); + assert_eq!(editor.sequence_pending(), None); + editor.handle(Input::Enter); + assert_eq!(editor.buffer_count(), 1); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + editor.handle(Input::Ctrl('l')); + assert_eq!( + editor.file_picker().unwrap().entries().nth(1).unwrap().path, + Path::new("folder/deep") + ); + assert_eq!(editor.file_picker().unwrap().selected, 1); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.file_picker().unwrap().selected, 0); + type_text(&mut editor, "ideep/"); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "deep/"); + editor.handle(Input::Ctrl('H')); + assert_eq!(editor.file_picker().unwrap().query, "deep/"); + editor.handle(Input::Backspace); + assert_eq!(editor.file_picker().unwrap().query, "deep"); + type_text(&mut editor, "👩‍💻"); + editor.handle(Input::Backspace); + assert_eq!(editor.file_picker().unwrap().query, "deep"); + let before = editor + .file_picker() + .unwrap() + .entries() + .map(|row| row.path.to_owned()) + .collect::>(); + let selected_before = editor.file_picker().unwrap().selected; + editor.handle(Input::Escape); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused); + assert!(!picker.searching); + assert_eq!(picker.query, "deep"); + assert_eq!(picker.selected, selected_before); + assert_eq!( + picker + .entries() + .map(|row| row.path.to_owned()) + .collect::>(), + before + ); + editor.handle(Input::Escape); + match presentation { + FilePresentation::Floating => assert!(editor.file_picker().is_none()), + FilePresentation::Docked => assert!(!editor.file_picker().unwrap().focused), + } + assert_eq!(editor.mode(), mode); + assert_eq!(editor.active.view.command_line, "underlying"); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.context().text, text); + assert_eq!(editor.active.undo.len(), undo); + } + } +} + +#[test] +fn tree_binding_precedence_is_modal_and_navigation_preferences_are_atomic() { + let directory = Directory::new(); + fs::create_dir(directory.path("folder")).unwrap(); + fs::write(directory.path("folder/a.txt"), "a").unwrap(); + fs::write(directory.path("folder/b.txt"), "b").unwrap(); + fs::write(directory.path("z.txt"), "z").unwrap(); + let mut editor = project_editor(&directory); + editor + .bind("selection", "x", Some("core.down".into())) + .unwrap(); + editor.bind("tree", "x", Some("core.right".into())).unwrap(); + editor + .bind("tree", "ctrl+j", Some("core.left".into())) + .unwrap(); + editor.bind("tree", "ctrl+k", None).unwrap(); + type_text(&mut editor, "3 b"); + let pending = editor.sequence_pending().unwrap().to_owned(); + let hint = editor.tree_navigation_hint.clone(); + for (key, target) in [ + ("x", Some("core.delete")), + ("x", Some("core.palette")), + ("x", Some("w")), + ("leader x", Some("core.right")), + ("x y", Some("core.left")), + ] { + assert!(editor.bind("tree", key, target.map(str::to_owned)).is_err()); + assert_eq!(editor.tree_navigation_hint, hint); + assert_eq!(editor.count(), Some(3)); + assert_eq!(editor.sequence_pending(), Some(pending.as_str())); + } + editor.open_file_picker(FilePresentation::Docked); + editor.handle(Input::Char('x')); + assert_eq!(editor.file_picker().unwrap().selected, 0); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + editor.handle(Input::Char('x')); + assert_eq!(editor.file_picker().unwrap().selected, 1); + editor.handle(Input::Ctrl('k')); + assert_eq!(editor.file_picker().unwrap().selected, 1); + editor.handle(Input::Ctrl('j')); + assert_eq!(editor.file_picker().unwrap().selected, 0); + editor.handle(Input::Char('i')); + editor.handle(Input::Char('x')); + assert_eq!(editor.file_picker().unwrap().selected, 1); + assert_eq!(editor.file_picker().unwrap().query, ""); + editor.handle(Input::Ctrl('j')); + assert_eq!(editor.file_picker().unwrap().selected, 0); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.file_picker().unwrap().selected, 0); + editor.handle(Input::Ctrl('t')); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + assert_eq!( + editor.file_navigation_hint(), + editor.selection_navigation_hint() + ); + editor.handle(Input::Char('x')); + assert_eq!(editor.file_picker().unwrap().selected, 1); + editor.handle(Input::Ctrl('w')); + editor.handle(Input::Char('x')); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Ctrl('p')); + let selected = editor.palette().unwrap().selected; + editor.handle(Input::Char('x')); + assert_eq!(editor.palette().unwrap().selected, selected + 1); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.palette().unwrap().query, ""); +} + +#[test] +fn ctrl_h_is_not_backspace_unless_explicitly_bound() { + assert_eq!(parse_key("CTRL+H").unwrap(), Input::Ctrl('h')); + assert_eq!(parse_key("backspace").unwrap(), Input::Backspace); + for mode in [ + Mode::Normal, + Mode::Visual, + Mode::Insert, + Mode::Command, + Mode::Search, + ] { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); + set_carets(&mut editor, &[(0, 2)], 0); + editor.active.view.mode = mode; + editor.active.view.command_line = "query".into(); + let targets = editor.active.view.targets.clone(); + let undo = editor.active.undo.len(); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.context().text, "abc"); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.undo.len(), undo); + assert_eq!(editor.active.view.command_line, "query"); + assert_eq!(editor.mode(), mode); + } + let mut editor = editor(); + type_text(&mut editor, "iabc"); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.context().text, "abc"); + editor + .bind("insert", "ctrl+h", Some("core.backspace".into())) + .unwrap(); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.context().text, "ab"); + editor.bind("insert", "ctrl+h", None).unwrap(); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.context().text, "ab"); + editor.handle(Input::Backspace); + assert_eq!(editor.context().text, "a"); + + let directory = Directory::new(); + fs::write(directory.path("a.txt"), "text").unwrap(); + let mut editor = project_editor(&directory); + editor.open_file_picker(FilePresentation::Floating); + type_text(&mut editor, "a"); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.file_picker().unwrap().query, "a"); + editor.handle(Input::Backspace); + assert_eq!(editor.file_picker().unwrap().query, ""); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core"); + editor.handle(Input::Ctrl('h')); + assert_eq!(editor.palette().unwrap().query, "core"); + editor.handle(Input::Backspace); + assert_eq!(editor.palette().unwrap().query, "cor"); +} + +#[test] +fn modified_backspace_preserves_suffixes_and_uses_unicode_boundaries() { + for (key, action, prefix, suffix, expected) in [ + ( + "option+backspace", + "core.backspace_word", + "alpha βeta ", + "SUFFIX\nlast", + "alpha SUFFIX\nlast", + ), + ( + "alt+backspace", + "core.backspace_word", + "foo don't", + "!", + "foo !", + ), + ( + "alt+backspace", + "core.backspace_word", + "alpha e\u{301}", + " tail", + "alpha tail", + ), + ( + "alt+backspace", + "core.backspace_word", + "alpha 👩‍🚀", + " tail", + "alpha tail", + ), + ( + "alt+backspace", + "core.backspace_word", + "one word\n ", + "tail", + "one tail", + ), + ( + "cmd+backspace", + "core.backspace_line", + "first\n βeta ", + "SUFFIX\nlast", + "first\nSUFFIX\nlast", + ), + ( + "super+backspace", + "core.backspace_line", + "first\n", + "tail", + "first\ntail", + ), + ("alt+backspace", "core.backspace_word", "", "tail", "tail"), + ] { + let mut editor = editor(); + editor.bind("insert", key, Some(action.into())).unwrap(); + let original = format!("{prefix}{suffix}"); + editor + .apply(vec![Effect::Replace(original.clone())]) + .unwrap(); + let row = prefix.bytes().filter(|&byte| byte == b'\n').count(); + let column = prefix.rsplit('\n').next().unwrap().graphemes(true).count(); + set_carets(&mut editor, &[(row, column)], 0); + let undo = editor.active.undo.len(); + let revision = editor.text_revision(); + editor.handle(Input::Char('i')); + editor.handle(parse_key(key).unwrap()); + assert_eq!(editor.context().text, expected, "{prefix:?}"); + editor.handle(Input::Escape); + if original == expected { + assert_eq!(editor.text_revision(), revision); + assert_eq!(editor.active.undo.len(), undo); + } else { + assert_eq!(editor.active.undo.len(), undo + 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, original); + assert_eq!(editor.cursor(), Cursor { row, column }); + } + } +} + +#[test] +fn modified_backspace_merges_overlapping_carets_and_repeats_new_boundaries() { + for (key, action, expected) in [ + ( + "alt+backspace", + "core.backspace_word", + "foo tail\nkeep row", + ), + ("cmd+backspace", "core.backspace_line", " tail\n row"), + ] { + let mut editor = editor(); + editor.bind("insert", key, Some(action.into())).unwrap(); + let original = "foo elephant tail\nkeep second row"; + editor + .apply(vec![Effect::Replace(original.into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 5), (0, 12), (1, 11)], 1); + let carets = editor.carets(); + editor.handle(Input::Char('i')); + editor.handle(parse_key(key).unwrap()); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, expected); + assert_eq!(editor.selection_count(), 2); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, original); + assert_eq!(editor.carets(), carets); + + let next = "foo hippopotamus tail\nkeep longer row"; + editor.apply(vec![Effect::Replace(next.into())]).unwrap(); + set_carets(&mut editor, &[(0, 5), (0, 16), (1, 11)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, expected); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, next); + } +} + +#[test] +fn modified_backspace_queries_never_edit_the_underlying_buffer() { + for opening in [Input::Char(':'), Input::Char('/'), Input::Ctrl('p')] { + let mut editor = editor(); + for mode in ["command", "search"] { + editor + .bind(mode, "alt+backspace", Some("core.backspace_word".into())) + .unwrap(); + editor + .bind(mode, "cmd+backspace", Some("core.backspace_line".into())) + .unwrap(); + } + editor + .apply(vec![Effect::Replace("buffer text".into())]) + .unwrap(); + let revision = editor.text_revision(); + let carets = editor.carets(); + editor.handle(opening); + type_text(&mut editor, "discard this query"); + editor.handle(Input::Ctrl('u')); + assert_eq!( + editor + .palette() + .map_or(editor.command_line(), |palette| palette.query.as_str()), + "" + ); + type_text(&mut editor, "alpha βeta "); + editor.handle(Input::AltBackspace); + assert_eq!( + editor + .palette() + .map_or(editor.command_line(), |palette| palette.query.as_str()), + "alpha " + ); + editor.handle(Input::SuperBackspace); + assert_eq!( + editor + .palette() + .map_or(editor.command_line(), |palette| palette.query.as_str()), + "" + ); + assert_eq!(editor.context().text, "buffer text"); + assert_eq!(editor.text_revision(), revision); + assert_eq!(editor.carets(), carets); + } +} + +#[test] +fn ctrl_u_deletes_line_prefixes_and_honors_disabled_and_remapped_bindings() { + let mut editor = editor(); + let original = "first\nalpha βeta tail\nlast"; + editor + .apply(vec![Effect::Replace(original.into())]) + .unwrap(); + set_carets(&mut editor, &[(1, 11)], 0); + editor.handle(Input::Char('i')); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.context().text, "first\ntail\nlast"); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + assert_eq!(editor.mode(), Mode::Insert); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, original); + + editor.bind("insert", "ctrl+u", None).unwrap(); + editor.handle(Input::Char('i')); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.context().text, original); + editor + .bind("insert", "ctrl+u", Some("core.backspace_word".into())) + .unwrap(); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.context().text, "first\nalpha tail\nlast"); + + editor.handle(Input::Escape); + for (opening, mode) in [(':', "command"), ('/', "search")] { + editor.bind(mode, "ctrl+u", None).unwrap(); + editor.handle(Input::Char(opening)); + type_text(&mut editor, "alpha beta"); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.command_line(), "alpha beta"); + editor + .bind(mode, "ctrl+u", Some("core.backspace_word".into())) + .unwrap(); + editor.handle(Input::Ctrl('u')); + assert_eq!(editor.command_line(), "alpha "); + assert_eq!(editor.context().text, "first\nalpha tail\nlast"); + editor.handle(Input::Escape); + } +} + +#[test] +fn tree_leaf_search_opens_direct_match_and_directory_search_enters_browse() { + let directory = Directory::new(); + fs::create_dir_all(directory.path("src/module")).unwrap(); + fs::write(directory.path("src/module/needle.txt"), "needle").unwrap(); + fs::write(directory.path("src/sibling.txt"), "sibling").unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + let mut editor = project_editor(&directory); + editor.set_file_picker_view("tree").unwrap(); + editor.open_file_picker(presentation); + if presentation == FilePresentation::Docked { + editor.handle(Input::Char('i')); + } + type_text(&mut editor, "needle.txt"); + let picker = editor.file_picker().unwrap(); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("src/module/needle.txt") + ); + editor.handle(Input::Enter); + assert_eq!(editor.context().text, "needle"); + assert_eq!( + editor.path(), + Some(directory.path("src/module/needle.txt").as_path()) + ); + editor.open_file_picker(presentation); + editor.handle(Input::Ctrl('w')); + editor.open_file_picker(presentation); + if presentation == FilePresentation::Docked { + editor.handle(Input::Char('i')); + } + type_text(&mut editor, "module"); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .nth(editor.file_picker().unwrap().selected) + .unwrap() + .is_dir + ); + let id = editor.buffer_id(); + editor.handle(Input::Enter); + let picker = editor.file_picker().unwrap(); + assert_eq!(editor.buffer_id(), id); + assert!(!picker.searching); + assert_eq!(picker.query, "module"); + let selected = picker.entries().nth(picker.selected).unwrap(); + assert_eq!(selected.path, Path::new("src/module")); + assert!(!selected.expanded); + assert!( + !picker + .entries() + .any(|row| row.path == Path::new("src/module/needle.txt")) + ); + editor.handle(Input::Enter); + let picker = editor.file_picker().unwrap(); + assert_eq!(picker.query, "module"); + assert!(picker.entries().nth(picker.selected).unwrap().expanded); + assert!( + picker + .entries() + .any(|row| row.path == Path::new("src/module/needle.txt")) + ); + assert_eq!(editor.buffer_count(), 2); + } +} + +#[cfg(unix)] +#[test] +fn tree_search_opens_raw_nested_paths_and_deduplicates_dirty_buffers() { + let directory = Directory::new(); + fs::create_dir_all(directory.path("dir\n/deep")).unwrap(); + let a = directory.path("dir\n/deep/same\n.txt"); + let b = directory.path("dir\n/deep/same\t.txt"); + fs::write(&a, "first").unwrap(); + fs::write(&b, "second").unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + fs::write(&a, "first").unwrap(); + let mut editor = project_editor(&directory); + editor.set_file_picker_view("tree").unwrap(); + editor.edit_buffer(a.clone(), false).unwrap(); + editor + .apply(vec![Effect::Replace("dirty first".into())]) + .unwrap(); + type_text(&mut editor, "vl"); + let selections = editor.selections(); + let id = editor.buffer_id(); + editor.edit_buffer(b.clone(), false).unwrap(); + fs::write(&a, b"\xff").unwrap(); + editor.open_file_picker(presentation); + if presentation == FilePresentation::Docked { + editor.handle(Input::Char('i')); + } + type_text(&mut editor, "same"); + let picker = editor.file_picker.as_mut().unwrap(); + let labels = picker + .entries() + .filter(|row| !row.is_dir) + .map(|row| row.label) + .collect::>(); + assert_eq!(labels.len(), 2); + assert_eq!(labels[0], labels[1]); + assert!( + picker + .entries() + .all(|row| !row.label.chars().any(char::is_control)) + ); + let selected = picker + .entries() + .position(|row| picker.root.join(row.path) == a) + .unwrap(); + picker.selected = selected; + editor.handle(Input::Enter); + assert_eq!(editor.path(), Some(a.as_path())); + assert_eq!(editor.buffer_id(), id); + assert_eq!(editor.buffer_count(), 3); + assert_eq!(editor.context().text, "dirty first"); + assert!(editor.dirty()); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), selections); + match presentation { + FilePresentation::Floating => assert!(editor.file_picker().is_none()), + FilePresentation::Docked => { + assert!(!editor.file_picker().unwrap().focused); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "same"); + } + } + } +} + +#[test] +fn typed_quit_checks_all_buffers_and_force_quit_is_immediate_from_keys() { + let directory = Directory::new(); + let mut editor = editor(); + editor + .bind("normal", "Q", Some("core.quit".into())) + .unwrap(); + editor + .bind("normal", "Z", Some("core.force_quit".into())) + .unwrap(); + editor + .apply(vec![Effect::Replace("unsaved".into())]) + .unwrap(); + editor.execute(&format!("edit {}", directory.path("other").display())); + editor.handle(Input::Char('Q')); + assert!(!editor.should_quit()); + assert!(editor.message().contains("Unsaved changes")); + editor.handle(Input::Char('Z')); + assert!(editor.should_quit()); +} + +#[test] +fn typed_save_and_quit_refuses_dirty_parked_buffers_and_save_failure() { + let directory = Directory::new(); + let path = directory.path("current"); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor + .bind("normal", "Z", Some("core.save_and_quit".into())) + .unwrap(); + editor.apply(vec![Effect::Replace("first".into())]).unwrap(); + editor.execute(&format!("edit {}", directory.path("other").display())); + editor + .apply(vec![Effect::Replace("second".into())]) + .unwrap(); + editor.handle(Input::Char('Z')); + assert!(!editor.should_quit()); + assert!(!editor.dirty()); + assert!(editor.message().contains("other buffers")); + editor.execute("buffer 1"); + editor.handle(Input::Char('Z')); + assert!(editor.should_quit()); + assert_eq!(fs::read_to_string(path).unwrap(), "first"); + + let mut unnamed = Editor::open(None, vec![]).unwrap(); + unnamed + .bind("normal", "Z", Some("core.save_and_quit".into())) + .unwrap(); + unnamed + .apply(vec![Effect::Replace("unsaved".into())]) + .unwrap(); + unnamed.handle(Input::Char('Z')); + assert!(!unnamed.should_quit()); + assert!(unnamed.dirty()); +} + +#[test] +fn required_argument_actions_prompt_and_share_ex_execution() { + let directory = Directory::new(); + let path = directory.path("file with spaces"); + fs::write(&path, "opened").unwrap(); + let mut editor = editor(); + assert!(editor.bind("normal", "E", Some("edit".into())).is_err()); + editor + .bind("normal", "E", Some("core.edit".into())) + .unwrap(); + editor.handle(Input::Char('E')); + assert_eq!(editor.mode(), Mode::Command); + assert_eq!(editor.command_line(), "edit "); + type_text(&mut editor, &path.to_string_lossy()); + editor.handle(Input::Enter); + assert_eq!(editor.context().text, "opened"); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.buffer"); + editor.handle(Input::Enter); + assert_eq!(editor.mode(), Mode::Command); + assert_eq!(editor.command_line(), "buffer "); + type_text(&mut editor, "1"); + editor.handle(Input::Enter); + assert_eq!(editor.buffer_id(), 1); + editor.execute("edit"); + assert_eq!(editor.message(), "Usage: :edit path"); +} + +#[test] +fn force_delete_palette_requires_submission_before_discarding_dirty_buffer() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("unsaved".into())]) + .unwrap(); + let dirty_id = editor.buffer_id(); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "bdelete!"); + editor.handle(Input::Enter); + assert_eq!(editor.mode(), Mode::Command); + assert!(editor.has_buffer(dirty_id)); + assert!(editor.dirty()); + editor.handle(Input::Enter); + assert!(!editor.has_buffer(dirty_id)); +} + +#[test] +fn built_in_alias_bindings_require_typed_actions() { + let mut editor = editor(); + for entry in CORE_ACTIONS.iter().filter(|entry| entry.ex.is_some()) { + assert!( + editor + .bind("normal", "Z", Some(entry.ex.unwrap().into())) + .is_err() + ); + assert!(editor.bind("normal", "Z", Some(entry.name.into())).is_ok()); + } +} + +#[test] +fn buffer_picker_searches_sanitized_paths_and_preserves_dirty_history_on_accept_or_cancel() { + let directory = Directory::new(); + let mut editor = editor(); + let unnamed = editor.buffer_id(); + let first = directory.path("Älpha\nnote.txt"); + let second = directory.path("Älpha\tnote.txt"); + fs::write(&first, "first").unwrap(); + fs::write(&second, "second").unwrap(); + editor.execute(&format!("edit {}", first.display())); + let first_id = editor.buffer_id(); + editor + .apply(vec![Effect::Replace("first unsaved".into())]) + .unwrap(); + editor.execute(&format!("edit {}", second.display())); + let second_id = editor.buffer_id(); + editor + .apply(vec![Effect::Replace("second unsaved".into())]) + .unwrap(); + editor.execute(&format!("buffer {unnamed}")); + editor.bind("selection", "ctrl+j", None).unwrap(); + editor + .bind("selection", "ctrl+x", Some("core.down".into())) + .unwrap(); + + type_text(&mut editor, "3 bl"); + assert_eq!(editor.count(), None); + assert_eq!(editor.sequence_pending(), None); + type_text(&mut editor, "NOTE ÄLPHA"); + let picker = editor.palette().unwrap(); + assert_eq!(picker.entries.len(), 2); + assert!( + picker + .entries + .iter() + .all(|entry| !entry.label.chars().any(char::is_control)) + ); + editor.handle(Input::Ctrl('j')); + editor.handle(Input::Enter); + assert_eq!(editor.buffer_id(), first_id); + assert_eq!(editor.context().text, "first unsaved"); + assert!(editor.dirty()); + + editor.execute("buffers"); + type_text(&mut editor, "ÄLPHA NOTE"); + editor.handle(Input::Ctrl('x')); + editor.handle(Input::Enter); + assert_eq!(editor.buffer_id(), second_id); + assert_eq!(editor.context().text, "second unsaved"); + let cursor = editor.cursor(); + editor.execute("buffers"); + type_text(&mut editor, ":_|no-match"); + editor.handle(Input::Enter); + assert_eq!(editor.buffer_id(), second_id); + assert_eq!(editor.palette().unwrap().query, ":_|no-match"); + assert_eq!(editor.cursor(), cursor); + editor.handle(Input::Ctrl('u')); + type_text(&mut editor, "e\u{301}👩‍🚀"); + editor.handle(Input::Backspace); + assert_eq!(editor.palette().unwrap().query, "e\u{301}"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "second unsaved"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "second"); + assert!(!editor.dirty()); + + editor.execute("buffers"); + type_text(&mut editor, "no name"); + editor.handle(Input::Enter); + assert_eq!(editor.buffer_id(), unnamed); + editor.execute(&format!("buffer {first_id}")); + assert_eq!(editor.context().text, "first unsaved"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "first"); + assert_eq!(fs::read_to_string(&second).unwrap(), "second"); +} + +#[test] +fn buffer_picker_in_diff_excludes_snapshots_and_switches_only_the_focused_pane() { + let directory = Directory::new(); + let first = directory.path("file.txt"); + let second = directory.path("other.txt"); + fs::write(&first, "live first").unwrap(); + fs::write(&second, "live second").unwrap(); + let mut editor = Editor::open(Some(first), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + let first_id = editor.buffer_id(); + editor.execute("tab.new"); + let other_tab = editor.tab_id(); + editor.execute(&format!("edit {}", second.display())); + let second_id = editor.buffer_id(); + editor + .apply(vec![Effect::Replace("hidden unsaved".into())]) + .unwrap(); + editor.execute("tab.previous"); + let source_tab = editor.tab_id(); + let source_pane = editor.pane_id(); + editor.execute("pane.split_vertical"); + let focused = editor.pane_id(); + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["after".into()], + ); + let snapshot = editor.buffer_id(); + let comparison = editor.diff().unwrap().comparison_id; + let cursor = editor.cursor(); + + editor.execute("buffers"); + type_text(&mut editor, "file.txt"); + assert_eq!(editor.palette().unwrap().entries.len(), 1); + editor.handle(Input::Escape); + assert_eq!(editor.buffer_id(), snapshot); + assert_eq!(editor.diff().unwrap().comparison_id, comparison); + assert_eq!(editor.cursor(), cursor); + editor.execute("buffers"); + type_text(&mut editor, "other.txt"); + editor.handle(Input::Enter); + assert_eq!(editor.buffer_id(), second_id); + assert_eq!(editor.context().text, "hidden unsaved"); + assert_eq!(editor.tab_id(), source_tab); + assert_eq!(editor.pane_id(), focused); + assert!(editor.diff().is_none()); + assert_eq!(editor.panes[&source_pane].buffer_id, first_id); + editor.execute("tab.next"); + assert_eq!(editor.tab_id(), other_tab); + assert_eq!(editor.buffer_id(), second_id); + assert_eq!(editor.context().text, "hidden unsaved"); +} + +#[test] +fn default_leader_routes_save_switch_list_delete_and_open_palette() { + let directory = Directory::new(); + let path = directory.path("first"); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + type_text(&mut editor, "ihello"); + editor.handle(Input::Escape); + type_text(&mut editor, " w"); + assert_eq!(fs::read_to_string(&path).unwrap(), "hello"); + assert!(!editor.dirty()); + editor.execute(&format!("edit {}", directory.path("second").display())); + type_text(&mut editor, " b"); + assert_eq!(editor.sequence_pending(), Some("Space b")); + editor.handle(Input::Char('n')); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.sequence_pending(), None); + type_text(&mut editor, " bp"); + assert_eq!(editor.buffer_id(), 2); + type_text(&mut editor, " bd"); + assert!(!editor.has_buffer(2)); + assert_eq!(editor.buffer_id(), 1); + for mode in [Mode::Normal, Mode::Visual] { + if mode == Mode::Visual { + editor.handle(Input::Char('v')); + } + editor.handle(Input::Char(' ')); + assert_eq!(editor.sequence_pending(), Some("Space")); + editor.handle(Input::Char(' ')); + assert!(editor.palette().is_some()); + assert_eq!(editor.mode(), mode); + assert_eq!(editor.sequence_pending(), None); + editor.handle(Input::Escape); + } +} + +#[test] +fn nested_leader_dispatches_plugin_once_with_count_and_multiple_carets() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("aa\nbb".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.register(plugin("fixture", &["greet"])).unwrap(); + editor + .bind("normal", "LeAdEr g ctrl+r 7 shift+i", Some("greet".into())) + .unwrap(); + type_text(&mut editor, "8 g"); + editor.handle(Input::Ctrl('R')); + assert_eq!(editor.sequence_pending(), Some("Space g Ctrl+r")); + editor.handle(Input::Char('7')); + assert_eq!(editor.count(), Some(8)); + assert_eq!(editor.sequence_pending(), Some("Space g Ctrl+r 7")); + assert_eq!( + editor.handle(Input::Char('I')), + Some(Invocation { + plugin: "fixture".into(), + command: "greet".into(), + args: String::new(), + }) + ); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.context().text, "aa\nbb"); + assert_eq!(editor.count(), None); + assert_eq!(editor.sequence_pending(), None); + editor.handle(Input::Ctrl('p')); + assert_eq!( + palette_entry(&editor, "greet").bindings, + vec!["Space g Ctrl+r 7 I"] + ); +} + +#[test] +fn custom_leader_is_symbolic_and_ordinary_trigger_bindings_preempt_by_mode() { + let mut editor = editor(); + type_text(&mut editor, "3 b"); + editor.set_leader("comma").unwrap(); + assert_eq!(editor.sequence_pending(), None); + assert_eq!(editor.count(), None); + editor.handle(Input::Ctrl('p')); + assert_eq!( + palette_entry(&editor, "core.buffer_next").bindings, + vec![", b n"] + ); + editor + .bind("normal", ",", Some("core.command".into())) + .unwrap(); + assert!( + palette_entry(&editor, "core.buffer_next") + .bindings + .is_empty() + ); + editor.set_leader(",").unwrap(); + editor.handle(Input::Escape); + editor.handle(Input::Char(',')); + assert_eq!(editor.mode(), Mode::Command); + assert_eq!(editor.sequence_pending(), None); + editor.handle(Input::Escape); + editor.handle(Input::Char('v')); + type_text(&mut editor, ", "); + assert!(editor.palette().is_some()); + assert_eq!(editor.mode(), Mode::Visual); + editor.handle(Input::Escape); + editor.handle(Input::Escape); + editor.bind("normal", ",", None).unwrap(); + type_text(&mut editor, "4,"); + assert_eq!(editor.count(), None); + assert_eq!(editor.sequence_pending(), None); + editor.set_leader("\\").unwrap(); + type_text(&mut editor, "\\b"); + assert_eq!(editor.sequence_pending(), Some("\\ b")); + editor.handle(Input::Char('n')); + assert_eq!(editor.sequence_pending(), None); + editor.handle(Input::Ctrl('p')); + assert_eq!( + palette_entry(&editor, "core.buffer_next").bindings, + vec!["\\ b n"] + ); +} + +#[test] +fn modified_key_sequences_preserve_counts_and_cancel_without_edits() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) + .unwrap(); + editor + .bind("normal", "ctrl+w ctrl+j 0", Some("core.down".into())) + .unwrap(); + editor + .bind("visual", "ctrl+w j", Some("core.down".into())) + .unwrap(); + editor.handle(Input::Char('2')); + editor.handle(Input::Ctrl('w')); + editor.handle(Input::Ctrl('j')); + editor.handle(Input::Char('0')); + assert_eq!(editor.cursor().row, 2); + assert_eq!(editor.count(), None); + editor.handle(Input::Ctrl('w')); + editor.handle(Input::Char('i')); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.cursor().row, 2); + assert_eq!(editor.sequence_pending(), None); + editor.handle(Input::Char('v')); + let selections = editor.selections().to_vec(); + editor.handle(Input::Char('3')); + editor.handle(Input::Ctrl('w')); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.count(), None); + editor.handle(Input::Ctrl('w')); + editor.handle(Input::Paste("ignored".into())); + assert_eq!(editor.sequence_pending(), None); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.lines(), &["a", "b", "c", "d"]); +} + +#[test] +fn sequence_prefix_conflicts_and_invalid_replacements_are_atomic() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\nb\nc".into())]) + .unwrap(); + editor + .bind("normal", "ctrl+w j", Some("core.down".into())) + .unwrap(); + for (key, target) in [ + ("ctrl+w", Some("core.up")), + ("ctrl+w j k", None), + ("ctrl+w j", Some("missing")), + ("esc j", Some("core.down")), + ("2 j", Some("core.down")), + ("space j", Some("core.down")), + ("ctrl+i j", Some("core.down")), + ] { + assert!( + editor + .bind("normal", key, target.map(str::to_owned)) + .is_err(), + "{key}" + ); + } + assert!(editor.set_leader("ctrl+w").is_err()); + editor.handle(Input::Ctrl('w')); + editor.handle(Input::Char('j')); + assert_eq!(editor.cursor().row, 1); + editor.bind("normal", "ctrl+w j", None).unwrap(); + assert!( + editor + .bind("normal", "ctrl+w j k", Some("core.down".into())) + .is_err() + ); + editor.handle(Input::Ctrl('w')); + editor.handle(Input::Char('j')); + assert_eq!(editor.cursor().row, 1); + assert_eq!(editor.sequence_pending(), None); + editor.bind("normal", "ctrl+x", None).unwrap(); + assert!( + editor + .bind("normal", "ctrl+x j", Some("core.down".into())) + .is_err() + ); +} + +#[test] +fn modified_key_sequences_focus_panes_without_replacing_the_leader() { + let directory = Directory::new(); + let mut editor = editor(); + editor.project_root = directory.0.clone(); + editor + .apply(vec![Effect::Replace("first\nsecond".into())]) + .unwrap(); + editor + .bind("normal", "ctrl+w j", Some("core.pane.focus_down".into())) + .unwrap(); + editor + .bind("normal", "ctrl+w h", Some("core.pane.focus_left".into())) + .unwrap(); + editor + .bind("normal", "leader z", Some("core.down".into())) + .unwrap(); + editor.handle(Input::Char('|')); + editor.handle(Input::Char('_')); + editor.handle(Input::Alt('k')); + assert_eq!(editor.pane_id(), 2); + editor.handle(Input::Ctrl('w')); + assert_eq!(editor.pane_id(), 2); + assert_eq!(editor.sequence_pending(), Some("Ctrl+w")); + editor.handle(Input::Char('j')); + assert_eq!(editor.pane_id(), 3); + assert_eq!(editor.sequence_pending(), None); + type_text(&mut editor, " z"); + assert_eq!(editor.cursor().row, 1); + editor.open_file_picker(FilePresentation::Docked); + editor.handle(Input::Ctrl('w')); + editor.handle(Input::Char('h')); + assert_eq!(editor.pane_id(), 1); + assert!(!editor.file_picker().unwrap().focused); + assert_eq!(editor.lines(), &["first", "second"]); +} + +#[test] +fn leader_prefix_conflicts_and_invalid_replacements_are_atomic() { + let mut editor = editor(); + assert!(editor.bind("normal", "leader b", None).is_err()); + editor + .bind("normal", "leader z", Some("core.down".into())) + .unwrap(); + assert!(editor.bind("normal", "leader z n", None).is_err()); + assert!( + editor + .bind("normal", "leader z", Some("missing".into())) + .is_err() + ); + editor + .apply(vec![Effect::Replace("a\nb\nc".into())]) + .unwrap(); + type_text(&mut editor, " z"); + assert_eq!(editor.cursor().row, 1); + editor.bind("normal", "leader z", None).unwrap(); + assert!( + editor + .bind("normal", "leader z n", Some("core.down".into())) + .is_err() + ); + type_text(&mut editor, "2 z"); + assert_eq!(editor.cursor().row, 1); + assert_eq!(editor.count(), None); + editor.bind("normal", "leader g n", None).unwrap(); + assert!(editor.bind("normal", "leader g", None).is_err()); + for key in ["leader", "leader ctrl+", "leader b ctrl++r", "leader "] { + assert!(editor.bind("normal", key, None).is_err(), "{key}"); + } + editor + .bind("normal", " LEADER\tz ", Some("core.up".into())) + .unwrap(); + type_text(&mut editor, " z"); + assert_eq!(editor.cursor().row, 0); +} + +#[test] +fn leader_validation_preserves_choice_and_reuses_action_mode_eligibility() { + let mut editor = editor(); + editor.register(plugin("fixture", &["greet"])).unwrap(); + for mode in ["insert", "command", "search", "unknown"] { + assert!(editor.bind(mode, "leader z", None).is_err(), "{mode}"); + } + assert!( + editor + .bind("visual", "leader z", Some("core.insert".into())) + .is_err() + ); + assert!( + editor + .bind("visual", "leader z", Some("greet".into())) + .is_err() + ); + editor + .bind("visual", "leader z", Some("core.right".into())) + .unwrap(); + editor.bind("normal", "comma", None).unwrap(); + editor.bind("visual", "\\", None).unwrap(); + for key in [ + "", "esc", "ESC", "0", "9", "i", "ctrl+p", ",", "\\", "leader b", "alt+j", + ] { + assert!(editor.set_leader(key).is_err(), "{key}"); + assert_eq!(editor.leader, Input::Char(' ')); + } + editor.bind("insert", "界", None).unwrap(); + editor.set_leader("界").unwrap(); + editor.handle(Input::Char('界')); + assert_eq!(editor.sequence_pending(), Some("界")); + editor.handle(Input::Escape); + editor.set_leader("space").unwrap(); + editor.bind("normal", "space", None).unwrap(); + editor.set_leader("SPACE").unwrap(); + assert_eq!(editor.leader, Input::Char(' ')); +} + +#[test] +fn leader_escape_unknown_and_paste_cancel_count_without_changing_selections() { + for visual in [false, true] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcd\nabcd".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 1), (1, 1)], 1); + if visual { + editor + .bind("visual", "leader b n", Some("core.down".into())) + .unwrap(); + type_text(&mut editor, "vl"); + } + let mode = editor.mode(); + let carets = editor.carets(); + let selections = editor.selections(); + for prefix in [" b", "3 b"] { + type_text(&mut editor, prefix); + assert_eq!(editor.sequence_pending(), Some("Space b")); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), mode); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.count(), None); + assert_eq!(editor.sequence_pending(), None); + } + type_text(&mut editor, "4 b"); + editor.handle(Input::Char('x')); + assert_eq!(editor.context().text, "abcd\nabcd"); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.mode(), mode); + assert_eq!(editor.count(), None); + assert_eq!(editor.sequence_pending(), None); + type_text(&mut editor, "5 b"); + editor.handle(Input::Paste("n".into())); + assert_eq!(editor.context().text, "abcd\nabcd"); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.count(), None); + assert_eq!(editor.sequence_pending(), None); + editor.handle(Input::Escape); + if !visual { + assert!(!editor.notification_visible()); + assert_eq!(editor.selection_count(), 2); + editor.handle(Input::Escape); + } + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.selection_count(), if visual { 2 } else { 1 }); + } +} + +#[test] +fn leader_digit_suffix_uses_pending_count_for_remapped_motion_not_new_count() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace(["abcd"; 12].join("\n"))]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor + .bind("normal", "leader g 0 2", Some("core.down".into())) + .unwrap(); + type_text(&mut editor, "3 g0"); + assert_eq!(editor.count(), Some(3)); + assert_eq!(editor.sequence_pending(), Some("Space g 0")); + editor.handle(Input::Char('2')); + assert_eq!( + editor.carets(), + vec![Cursor { row: 3, column: 0 }, Cursor { row: 4, column: 0 }] + ); + assert_eq!(editor.count(), None); + assert_eq!(editor.sequence_pending(), None); + editor + .bind("visual", "leader 7", Some("core.right".into())) + .unwrap(); + editor.handle(Input::Char('v')); + type_text(&mut editor, "2 7"); + assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.count(), None); +} + +#[test] +fn leader_keys_are_literal_in_insert_prompts_and_palette_queries() { + for (key, ch) in [("space", ' '), ("comma", ',')] { + for mode_key in ['i', ':', '/'] { + let mut editor = editor(); + editor.set_leader(key).unwrap(); + editor.handle(Input::Char(mode_key)); + let text = format!("{ch}b n{ch}"); + type_text(&mut editor, &text); + assert_eq!(editor.sequence_pending(), None); + assert_eq!(editor.count(), None); + if mode_key == 'i' { + assert_eq!(editor.context().text, text); + assert_eq!(editor.mode(), Mode::Insert); + } else { + assert_eq!(editor.command_line(), text); + assert_eq!( + editor.mode(), + if mode_key == ':' { + Mode::Command + } else { + Mode::Search + } + ); + } + let mode = editor.mode(); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core word next "); + assert_eq!(editor.palette().unwrap().query, "core word next "); + assert_eq!(editor.sequence_pending(), None); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), mode); + } + } +} + +#[test] +fn leader_routes_retire_with_plugins_and_binding_changes_cancel_pending() { + let mut editor = editor(); + editor.register(plugin("fixture", &["greet"])).unwrap(); + editor.bind("normal", "g", Some("greet".into())).unwrap(); + editor + .bind("normal", "leader g n", Some("greet".into())) + .unwrap(); + type_text(&mut editor, "3 g"); + editor + .bind("normal", "leader g n", Some("core.down".into())) + .unwrap(); + assert_eq!(editor.sequence_pending(), None); + assert_eq!(editor.count(), None); + type_text(&mut editor, "2 g"); + editor.bind("normal", "z", None).unwrap(); + assert_eq!(editor.sequence_pending(), None); + assert_eq!(editor.count(), None); + editor + .bind("normal", "leader g n", Some("greet".into())) + .unwrap(); + type_text(&mut editor, "4 g"); + editor.disable_plugin("fixture", "retired"); + assert_eq!(editor.sequence_pending(), None); + assert_eq!(editor.count(), None); + assert_eq!(editor.handle(Input::Char('g')), None); + type_text(&mut editor, " g"); + assert_eq!(editor.sequence_pending(), None); + assert!( + editor + .bind("normal", "leader g n", Some("greet".into())) + .is_err() + ); + editor.handle(Input::Ctrl('p')); + assert!( + !editor + .palette() + .unwrap() + .entries + .iter() + .any(|entry| entry.name == "greet") + ); +} + +#[test] +fn pending_leader_never_survives_external_commands_or_buffer_transitions() { + let directory = Directory::new(); + let mut editor = editor(); + editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); + editor.execute(&format!("edit {}", directory.path("other").display())); + editor.set_leader(",").unwrap(); + for command in ["buffer 1", "buffer 1", "bnext", "bprev", "buffer 999"] { + type_text(&mut editor, "3,b"); + assert_eq!(editor.sequence_pending(), Some(", b")); + editor.execute(command); + assert_eq!(editor.sequence_pending(), None); + assert_eq!(editor.count(), None); + editor.handle(Input::Char(',')); + assert_eq!(editor.sequence_pending(), Some(",")); + editor.handle(Input::Escape); + } + editor.execute("buffer 1"); + type_text(&mut editor, "vl"); + let selections = editor.selections(); + type_text(&mut editor, "2,"); + editor.execute("buffer 2"); + editor.execute("buffer 1"); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.sequence_pending(), None); + assert_eq!(editor.count(), None); + editor.handle(Input::Char(',')); + editor + .apply(vec![Effect::Replace("replacement".into())]) + .unwrap(); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.sequence_pending(), None); + editor + .bind("normal", "leader i", Some("core.insert".into())) + .unwrap(); + type_text(&mut editor, ",i"); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.sequence_pending(), None); +} + +#[test] +fn counted_cursor_motions_apply_to_all_carets_and_clamp_through_short_rows() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace( + "e\u{301}界👩‍🚀z\n\nlong\nfour\naa\nbbbb\ncccc".into(), + )]) + .unwrap(); + set_carets(&mut editor, &[(0, 3), (4, 1)], 1); + let undo = editor.active.undo.len(); + type_text(&mut editor, "2j"); + assert_eq!( + editor.carets(), + vec![Cursor { row: 2, column: 0 }, Cursor { row: 6, column: 1 }] + ); + assert_eq!(editor.cursor(), Cursor { row: 6, column: 1 }); + type_text(&mut editor, "100k"); + assert_eq!(editor.carets(), vec![Cursor::default()]); + type_text(&mut editor, "2l"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); + type_text(&mut editor, "9h"); + assert_eq!(editor.cursor(), Cursor::default()); + type_text(&mut editor, "999999999999999999999999999999999999"); + assert_eq!(editor.count(), Some(usize::MAX)); + editor.handle(Input::Down); + assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 }); + assert_eq!(editor.count(), None); + assert_eq!(editor.active.undo.len(), undo); + assert_eq!( + editor.lines(), + &["e\u{301}界👩‍🚀z", "", "long", "four", "aa", "bbbb", "cccc"] + ); +} + +#[test] +fn counted_words_match_repeated_unicode_motions_without_intermediate_selection_merging() { + let text = "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail\n"; + for key in ['w', 'b', 'e', 'W', 'B', 'E'] { + for positions in [&[(0, 0), (3, 0)][..], &[(0, 8), (3, 8)][..]] { + let mut counted = editor(); + counted.apply(vec![Effect::Replace(text.into())]).unwrap(); + set_carets(&mut counted, positions, 1); + let mut repeated = editor(); + repeated.apply(vec![Effect::Replace(text.into())]).unwrap(); + set_carets(&mut repeated, positions, 1); + type_text(&mut counted, &format!("3{key}")); + for _ in 0..3 { + repeated.handle(Input::Char(key)); + } + assert_eq!(counted.carets(), repeated.carets(), "{key} {positions:?}"); + assert_eq!(counted.cursor(), repeated.cursor(), "{key} {positions:?}"); + } + } + let mut editor = editor(); + editor + .apply(vec![Effect::Replace( + "one two three\none longer third".into(), + )]) + .unwrap(); + editor.handle(Input::Ctrl('e')); + editor.handle(Input::Char('v')); + type_text(&mut editor, "2e"); + assert_eq!( + editor.selections(), + vec![ + Selection { + start: Cursor { row: 0, column: 0 }, + end: Cursor { row: 0, column: 7 } + }, + Selection { + start: Cursor { row: 1, column: 0 }, + end: Cursor { row: 1, column: 10 } + }, + ] + ); + editor.handle(Input::Char('c')); + type_text(&mut editor, "item"); + editor.handle(Input::Escape); + assert_eq!(editor.lines(), &["item three", "item third"]); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), &["one two three", "one longer third"]); + assert_eq!(editor.selection_count(), 2); + type_text(&mut editor, &format!("{}w", usize::MAX)); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 15 }); +} + +#[test] +fn count_digits_zero_and_escape_preserve_modal_editing_and_custom_bindings() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace(["abc"; 12].join("\n"))]) + .unwrap(); + type_text(&mut editor, "10j"); + assert_eq!(editor.cursor(), Cursor { row: 10, column: 0 }); + editor.handle(Input::End); + type_text(&mut editor, "0"); + assert_eq!(editor.cursor().column, 0); + editor.handle(Input::Ctrl('k')); + editor.handle(Input::Char('v')); + let selected = editor.selections(); + type_text(&mut editor, "4"); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), selected); + assert_eq!(editor.count(), None); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.selection_count(), 2); + editor.bind("normal", "2", None).unwrap(); + type_text(&mut editor, "42"); + assert_eq!(editor.count(), None); + editor + .bind("normal", "3", Some("core.down".into())) + .unwrap(); + type_text(&mut editor, "3"); + assert_eq!(editor.cursor().row, 10); + assert_eq!(editor.count(), None); + type_text(&mut editor, "5i12"); + editor.handle(Input::Escape); + assert_eq!(editor.lines()[10], "12abc"); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines()[10], "abc"); + editor.handle(Input::Char('/')); + type_text(&mut editor, "123"); + assert_eq!(editor.command_line(), "123"); + assert_eq!(editor.count(), None); +} + +#[test] +fn counted_search_wraps_per_target_and_unrelated_actions_execute_once() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("cat cat cat\ncat cat cat".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + submit_search(&mut editor, "cat"); + editor.handle(Input::Char('n')); + type_text(&mut editor, "2n"); + assert_eq!( + editor.carets(), + vec![Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 0 }] + ); + type_text(&mut editor, "2N"); + assert_eq!( + editor.carets(), + vec![Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 4 }] + ); + editor.register(plugin("once", &["once"])).unwrap(); + editor.bind("normal", "g", Some("once".into())).unwrap(); + type_text(&mut editor, "8"); + let invocation = editor.handle(Input::Char('g')).unwrap(); + assert_eq!(invocation.command, "once"); + assert_eq!(editor.count(), None); + type_text(&mut editor, "7"); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.word_next"); + editor.handle(Input::Enter); + assert_eq!( + editor.carets(), + vec![Cursor { row: 0, column: 8 }, Cursor { row: 1, column: 8 }] + ); + assert_eq!(editor.count(), None); + type_text(&mut editor, "9"); + editor.handle(Input::Paste("ignored".into())); + editor.handle(Input::Char('h')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 7 }); +} +fn submit_search(editor: &mut Editor, query: &str) { + editor.handle(Input::Char('/')); + type_text(editor, query); + editor.handle(Input::Enter); +} +fn plugin(name: &str, commands: &[&str]) -> Feature { + Feature { + name: name.into(), + required_capabilities: vec!["text".into()], + commands: commands.iter().map(|name| (*name).into()).collect(), + core: false, + } +} + +#[test] +fn character_seek_counts_are_strict_line_local_and_atomic_when_missing() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("xaxaax\nx\n\nx".into())]) + .unwrap(); + let undo = editor.active.undo.len(); + for (column, keys, expected) in [ + (0, "fx", 2), + (0, "2fx", 5), + (0, "3fx", 0), + (5, "Fx", 2), + (5, "2Fx", 0), + (5, "3Fx", 5), + (2, "fz", 2), + (0, "999999999999999999999999fx", 0), + ] { + set_carets(&mut editor, &[(0, column)], 0); + type_text(&mut editor, keys); + assert_eq!( + editor.cursor(), + Cursor { + row: 0, + column: expected + } + ); + assert_eq!(editor.count(), None); + assert_eq!(editor.seek_pending(), None); + } + for row in [1, 2, 3] { + set_carets(&mut editor, &[(row, 0)], 0); + type_text(&mut editor, "fxFx"); + assert_eq!(editor.cursor(), Cursor { row, column: 0 }); + } + assert_eq!(editor.active.undo.len(), undo); +} + +#[test] +fn character_seek_consumes_literal_targets_before_bindings_counts_and_prefixes() { + for target in ['0', '2', 'g', 'f', 'F', '[', ']', ' ', '\t'] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace(format!("a{target}b{target}"))]) + .unwrap(); + let key = display_key(&Input::Char(target)); + editor + .bind("normal", &key, Some("core.right".into())) + .unwrap(); + editor + .bind("normal", "z", Some("core.seek_forward".into())) + .unwrap(); + type_text(&mut editor, "2z"); + editor.handle(Input::Char(target)); + assert_eq!(editor.cursor().column, 3); + assert_eq!(editor.count(), None); + assert!(!editor.g_pending()); + assert!(editor.sequence_pending().is_none()); + assert!(editor.seek_pending().is_none()); + } + let mut editor = editor(); + editor.apply(vec![Effect::Replace("axbx".into())]).unwrap(); + editor + .bind("normal", "f", Some("core.right".into())) + .unwrap(); + type_text(&mut editor, "f"); + assert_eq!(editor.cursor().column, 1); + assert!(editor.seek_pending().is_none()); + editor + .bind("normal", "leader f", Some("core.seek_forward".into())) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + type_text(&mut editor, "2 fx"); + assert_eq!(editor.cursor().column, 3); + for mode in ["insert", "command", "search", "selection", "tree"] { + assert!( + editor + .bind(mode, "z", Some("core.seek_forward".into())) + .is_err() + ); + assert!( + editor + .bind(mode, "z", Some("core.seek_backward".into())) + .is_err() + ); + } + editor.apply(vec![Effect::Replace(String::new())]).unwrap(); + type_text(&mut editor, "ifFgf"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "fFgf"); +} + +#[test] +fn character_seek_unicode_scalars_land_on_graphemes_for_all_carets_and_visual_shapes() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace( + "界e\u{301}👩‍🚀e\u{301}x\n\naxax".into(), + )]) + .unwrap(); + type_text(&mut editor, "f\u{301}"); + assert_eq!(editor.cursor().column, 1); + type_text(&mut editor, "f🚀"); + assert_eq!(editor.cursor().column, 2); + type_text(&mut editor, "F\u{301}"); + assert_eq!(editor.cursor().column, 1); + type_text(&mut editor, "F\u{301}"); + assert_eq!(editor.cursor().column, 1); // The current grapheme is excluded. + for selector in ["", "v", "V"] { + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); + type_text(&mut editor, selector); + type_text(&mut editor, "2fx"); + assert_eq!( + editor.carets(), + vec![ + Cursor { row: 0, column: 0 }, + Cursor { row: 1, column: 0 }, + Cursor { row: 2, column: 3 }, + ] + ); + assert_eq!(editor.visual_linewise(), selector == "V"); + } +} + +#[test] +fn character_seek_escape_and_lifecycle_cancel_pending_without_changing_selection() { + let directory = Directory::new(); + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("axbx\nnext".into())]) + .unwrap(); + for selector in ["", "v", "V"] { + set_carets(&mut editor, &[(0, 0)], 0); + type_text(&mut editor, selector); + let mode = editor.mode(); + let targets = editor.active.view.targets.clone(); + type_text(&mut editor, "2f"); + assert_eq!(editor.seek_pending(), Some('f')); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), mode); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.count(), None); + assert_eq!(editor.seek_pending(), None); + } + set_carets(&mut editor, &[(0, 0)], 0); + type_text(&mut editor, "2f"); + editor.handle(Input::Right); + assert_eq!(editor.cursor().column, 1); + assert_eq!(editor.seek_pending(), None); + type_text(&mut editor, "2f"); + editor.handle(Input::Paste("x".into())); + assert_eq!(editor.cursor().column, 1); + assert_eq!(editor.seek_pending(), None); + for command in ["plugins", "bnext", "bprev", "buffer 1"] { + type_text(&mut editor, "f"); + editor.execute(command); + assert_eq!(editor.seek_pending(), None); + } + type_text(&mut editor, "f"); + editor + .bind("normal", "z", Some("core.right".into())) + .unwrap(); + assert_eq!(editor.seek_pending(), None); + type_text(&mut editor, "f"); + editor + .bind("selection", "z", Some("core.down".into())) + .unwrap(); + assert_eq!(editor.seek_pending(), None); + type_text(&mut editor, "f"); + editor.set_leader(",").unwrap(); + assert_eq!(editor.seek_pending(), None); + type_text(&mut editor, "f"); + editor + .navigate_to( + directory.path("missing"), + TextPosition { row: 0, column: 0 }, + ) + .unwrap_err(); + assert_eq!(editor.seek_pending(), None); + type_text(&mut editor, "f"); + editor.execute("palette"); + assert_eq!(editor.seek_pending(), None); + editor.handle(Input::Escape); + type_text(&mut editor, "f"); + editor + .apply(vec![Effect::Replace("replacement".into())]) + .unwrap(); + assert_eq!(editor.seek_pending(), None); +} + +#[test] +fn character_seek_visual_edit_replay_resolves_target_direction_and_count_fresh() { + for (seek, original, replacement, column) in [ + ("2fx", "axbxTAIL", "aLONGxbLONGxTAIL", 0), + ("2Fx", "xaxbTAIL", "xLONGxaLONGbTAIL", 11), + ] { + for change in [false, true] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace(original.into())]) + .unwrap(); + let start = if seek == "2Fx" { 3 } else { 0 }; + set_carets(&mut editor, &[(0, start)], 0); + type_text( + &mut editor, + &format!("v{seek}{}", if change { "c!" } else { "d" }), + ); + if change { + editor.handle(Input::Escape); + } + editor + .bind("visual", "f", Some("core.left".into())) + .unwrap(); + editor + .bind("visual", "F", Some("core.right".into())) + .unwrap(); + editor + .bind("visual", "x", Some("core.line_end".into())) + .unwrap(); + editor + .apply(vec![Effect::Replace(replacement.into())]) + .unwrap(); + set_carets(&mut editor, &[(0, column)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, if change { "!TAIL" } else { "TAIL" }); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.seek_pending(), None); + } + } + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("ax\nkeep".into())]) + .unwrap(); + type_text(&mut editor, "Vfxd"); + editor + .apply(vec![Effect::Replace("alongx\nkeep".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "keep"); + assert!(matches!( + &editor.last_edit.as_ref().unwrap().start, + EditStart::Selection { + selector: SelectionIntent::Motions { linewise: true, .. }, + .. + } + )); +} + +#[test] +fn readonly_diff_character_seek_consumes_brackets_and_tab_before_pane_dispatch() { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["a[b]\tc".into()], + vec!["a[b]\td".into()], + ); + for focus in [Focus::Before, Focus::After] { + if editor.diff().unwrap().focus != focus { + editor.handle(Input::Char('\t')); + } + let text = editor.context().text; + let rows = editor.diff().unwrap().display_rows.clone(); + type_text(&mut editor, "f["); + assert_eq!(editor.cursor().column, 1); + type_text(&mut editor, "f]"); + assert_eq!(editor.cursor().column, 3); + type_text(&mut editor, "f\t"); + assert_eq!(editor.cursor().column, 4); + type_text(&mut editor, "F["); + assert_eq!(editor.cursor().column, 1); + assert_eq!(editor.diff().unwrap().focus, focus); + assert_eq!(editor.diff().unwrap().display_rows, rows); + assert_eq!(editor.context().text, text); + assert!(editor.active.undo.is_empty()); + } +} + +#[test] +fn normal_edits_merge_overlapping_ranges_and_replay_without_source_keys() { + for (keys, positions, expected) in [ + ("C", [(0, 0), (0, 4)], "!\nkeep"), + ("D", [(0, 0), (0, 4)], "\nkeep"), + ("4s", [(0, 0), (0, 2)], "!o three four\nkeep"), + ("d2w", [(0, 0), (0, 4)], "four\nkeep"), + ("d2b", [(0, 8), (0, 14)], "four\nkeep"), + ("c2w", [(0, 0), (0, 4)], "!four\nkeep"), + ("c2b", [(0, 8), (0, 14)], "!four\nkeep"), + ("4rX", [(0, 0), (0, 2)], "XXXXXXo three four\nkeep"), + ] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("one two three four\nkeep".into())]) + .unwrap(); + set_carets(&mut editor, &positions, 1); + let original = editor.active.view.targets.clone(); + let undo = editor.active.undo.len(); + type_text(&mut editor, keys); + if editor.mode() == Mode::Insert { + type_text(&mut editor, "!"); + editor.handle(Input::Escape); + } + assert_eq!(editor.context().text, expected, "{keys}"); + assert_eq!(editor.active.undo.len(), undo + 1, "{keys}"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "one two three four\nkeep", "{keys}"); + assert_eq!(editor.active.view.targets, original, "{keys}"); + assert_eq!(editor.active.view.primary, 1, "{keys}"); + for key in ["C", "D", "s", "r", "w", "b"] { + editor + .bind("normal", key, Some("core.right".into())) + .unwrap(); + } + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, expected, "{keys}"); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.selection_count(), 1); + } +} + +#[test] +fn normal_yank_merges_suffix_fragments_without_merging_carets_or_history() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcdef\nkeep".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 1), (0, 3)], 1); + let targets = editor.active.view.targets.clone(); + let undo = editor.active.undo.len(); + editor.handle(Input::Char('Y')); + assert_eq!(editor.clipboard, ["bcdef"]); + assert!(!editor.clipboard_linewise); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, 1); + assert_eq!(editor.active.undo.len(), undo); + assert!(editor.last_edit.is_none()); +} + +#[test] +fn readonly_normal_edit_actions_and_pending_replace_preserve_document_and_history() { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["one two three".into()], + vec!["four five six".into()], + ); + for focus in [Focus::Before, Focus::After] { + if editor.diff().unwrap().focus != focus { + editor.handle(Input::Char('\t')); + } + let text = editor.context().text; + let targets = editor.active.view.targets.clone(); + let undo = editor.active.undo.len(); + let clipboard = editor.clipboard.clone(); + for keys in ["C", "D", "s", "2r", "d2w", "d2b", "c2w", "c2b"] { + type_text(&mut editor, keys); + assert_eq!(editor.context().text, text, "{keys}"); + assert_eq!(editor.active.view.targets, targets, "{keys}"); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.count(), None); + assert_eq!(editor.line_operator_pending(), None); + assert_eq!(editor.clipboard, clipboard); + assert_eq!(editor.active.undo.len(), undo); + } + // A pending replacement must still guard mutation if editability changed. + editor.line_pending = Some(('r', 2)); + editor.handle(Input::Char('X')); + assert_eq!(editor.context().text, text); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.undo.len(), undo); + assert_eq!(editor.line_operator_pending(), None); + editor.handle(Input::Char('Y')); + assert_eq!(editor.clipboard, [text]); + assert!(!editor.clipboard_linewise); + } +} + +#[test] +fn readonly_visual_replacement_preserves_snapshot_selections_and_history() { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["one two three".into()], + vec!["four five six".into()], + ); + for focus in [Focus::Before, Focus::After] { + if editor.diff().unwrap().focus != focus { + editor.handle(Input::Char('\t')); + } + type_text(&mut editor, "vl"); + let text = editor.context().text; + let targets = editor.active.view.targets.clone(); + let undo = editor.active.undo.len(); + let clipboard = editor.clipboard.clone(); + for pending in [false, true] { + if pending { + editor.line_pending = Some(('r', 1)); + } else { + editor.handle(Input::Char('r')); + } + editor.handle(Input::Char('X')); + assert_eq!(editor.context().text, text); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.line_operator_pending(), None); + assert_eq!(editor.clipboard, clipboard); + assert_eq!(editor.active.undo.len(), undo); + } + editor.handle(Input::Escape); + } +} + +fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) { + editor.active.view.mode = Mode::Normal; + editor.active.view.visual_motions = None; + editor.active.view.targets = positions + .iter() + .map(|&(row, column)| Target::caret(Cursor { row, column })) + .collect(); + editor.active.view.primary = primary; + editor.normalize_targets(); +} + +fn set_ranges(editor: &mut Editor, ranges: &[(Cursor, Cursor)], primary: usize) { + editor.active.view.mode = Mode::Visual; + editor.active.view.visual_motions = None; + editor.active.view.targets = ranges + .iter() + .map(|&(start, end)| editor.range_target(Selection { start, end }, true)) + .collect(); + editor.active.view.primary = primary; + editor.normalize_targets(); +} + +#[test] +fn linewise_selection_preserves_bidirectional_endpoints_and_selector_toggles() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a界z\nb\nccc\n".into())]) + .unwrap(); + type_text(&mut editor, "jjlVkh"); + assert!(editor.visual_linewise()); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + assert_eq!(editor.linewise_rows().collect::>(), [1..=2]); + assert_eq!( + editor.selections(), + [Selection { + start: Cursor { row: 1, column: 0 }, + end: Cursor { row: 3, column: 0 }, + }] + ); + editor.handle(Input::Char('v')); + assert!(!editor.visual_linewise()); + assert_eq!(editor.selections()[0].end, Cursor { row: 2, column: 2 }); + editor.handle(Input::Char('V')); + assert_eq!(editor.linewise_rows().collect::>(), [1..=2]); + editor.handle(Input::Char('V')); + assert_eq!(editor.mode(), Mode::Normal); + assert!(!editor.visual_linewise()); + type_text(&mut editor, "Vj"); + assert_eq!(editor.linewise_rows().collect::>(), [1..=2]); + editor.handle(Input::Escape); + assert!(editor.linewise_rows().next().is_none()); + type_text(&mut editor, "vv"); + assert_eq!(editor.mode(), Mode::Normal); +} + +#[test] +fn linewise_parking_cloning_merging_and_row_insertions_preserve_primary() { + let directory = Directory::new(); + let other = directory.path("other"); + fs::write(&other, "other").unwrap(); + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abc\ndef\nghi\n".into())]) + .unwrap(); + type_text(&mut editor, "lV"); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.linewise_rows().collect::>(), [0..=0, 1..=1]); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); + editor.execute(&format!("edit {}", other.display())); + assert!(!editor.visual_linewise()); + editor.execute("buffer 1"); + assert!(editor.visual_linewise()); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); + editor.handle(Input::Char('j')); + assert_eq!(editor.selection_count(), 1); + assert_eq!(editor.linewise_rows().collect::>(), [0..=2]); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 }); + editor.handle(Input::Char('v')); + assert_eq!(editor.selections()[0].start, Cursor { row: 0, column: 1 }); + editor.handle(Input::Char('V')); + editor.handle(Input::Char('A')); + type_text(&mut editor, "!"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "abc!\ndef!\nghi!\n"); + assert_eq!(editor.selection_count(), 3); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "abc\ndef\nghi\n"); + set_carets(&mut editor, &[(2, 0)], 0); + type_text(&mut editor, "Vj"); + editor.handle(Input::Ctrl('l')); + assert_eq!( + editor.carets(), + [Cursor { row: 2, column: 0 }, Cursor { row: 3, column: 0 }] + ); + assert!(!editor.visual_linewise()); +} + +#[test] +fn linewise_paste_inserts_above_or_below_rows_across_eof_and_empty_rows() { + for (text, row, key, expected) in [ + ("x\ny", 0, 'p', "x\n a\n\ny"), + ("x\ny", 0, 'P', " a\n\nx\ny"), + ("x\ny", 1, 'p', "x\ny\n a\n"), + ("x\ny", 1, 'P', "x\n a\n\ny"), + ("x\n", 1, 'p', "x\n\n a\n"), + ("x\n", 1, 'P', "x\n a\n\n"), + ("", 0, 'p', "\n a\n"), + ("", 0, 'P', " a\n\n"), + ] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace(" a\n\nlast".into())]) + .unwrap(); + type_text(&mut editor, "Vjy"); + editor.apply(vec![Effect::Replace(text.into())]).unwrap(); + set_carets(&mut editor, &[(row, 1)], 0); + editor.handle(Input::Char(key)); + assert_eq!(editor.context().text, expected, "{text:?} {key}"); + let first = if key == 'p' { row + 1 } else { row }; + assert_eq!( + editor.cursor(), + Cursor { + row: first, + column: 1 + } + ); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, text); + assert_eq!(editor.mode(), Mode::Normal); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, expected); + } +} + +#[test] +fn linewise_empty_final_row_is_a_real_clipboard_row_and_cut_target() { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("a\n".into())]).unwrap(); + type_text(&mut editor, "jVy"); + editor.apply(vec![Effect::Replace("z".into())]).unwrap(); + editor.handle(Input::Char('p')); + assert_eq!(editor.context().text, "z\n"); + editor.handle(Input::Char('u')); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, "\nz"); + editor.apply(vec![Effect::Replace("a\n".into())]).unwrap(); + type_text(&mut editor, "jVd"); + assert_eq!(editor.context().text, "a"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "a\n"); + assert!(!editor.visual_linewise()); + type_text(&mut editor, "VcQ"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "a\nQ"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "a\n"); +} + +#[test] +fn linewise_cut_and_change_handle_eof_all_rows_and_group_typing_history() { + for (text, keys, expected) in [ + ("a\nbb\nccc", "jVjd", "a"), + ("a\nbb\nccc", "jVjcQ", "a\nQ"), + ("a\nbb\nccc", "V2jd", ""), + ("a\nbb\nccc", "V2jcQ", "Q"), + ("a\nbb\n", "V2jd", ""), + ("a\nbb\n", "V2jcQ", "Q"), + ("a\nbb\nccc", "VjcQ", "Q\nccc"), + ("", "VcQ", "Q"), + ] { + let mut editor = editor(); + editor.apply(vec![Effect::Replace(text.into())]).unwrap(); + type_text(&mut editor, keys); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, expected, "{keys}"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, text); + assert_eq!(editor.mode(), Mode::Normal); + assert!(!editor.visual_linewise()); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, expected); + } +} + +#[test] +fn linewise_multitarget_cut_change_and_paste_share_one_transaction() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\nbb\nccc".into())]) + .unwrap(); + set_carets(&mut editor, &[(1, 1), (2, 1)], 1); + type_text(&mut editor, "Vd"); + assert_eq!(editor.context().text, "a"); + assert_eq!(editor.selection_count(), 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "a\nbb\nccc"); + assert_eq!(editor.cursor().row, 2); + type_text(&mut editor, "VcQ"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "a\nQ\nQ"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "a\nbb\nccc"); + editor.apply(vec![Effect::Replace("x\ny".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, "bb\nx\nccc\ny"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "x\ny"); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('p')); + assert_eq!(editor.context().text, "x\ny"); + assert!(editor.message().contains("count")); +} + +#[test] +fn linewise_paste_merges_identical_same_row_targets_and_refuses_conflicts() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("one\ntwo".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + type_text(&mut editor, "Vy"); + editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0), (0, 2)], 1); + let carets = editor.carets(); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, "abcd"); + assert_eq!(editor.carets(), carets); + assert!(editor.message().contains("Conflicting")); + set_carets(&mut editor, &[(0, 0)], 0); + type_text(&mut editor, "Vy"); + set_carets(&mut editor, &[(0, 0), (0, 2)], 1); + editor.handle(Input::Char('p')); + assert_eq!(editor.context().text, "abcd\nabcd"); + assert_eq!(editor.selection_count(), 1); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "abcd"); + assert_eq!(editor.carets(), carets); +} + +#[test] +fn linewise_counted_search_and_occurrences_keep_shape_rules() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("hit a\nz\nhit b\nz\nhit c\n".into())]) + .unwrap(); + submit_search(&mut editor, "hit"); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor().row, 2); + type_text(&mut editor, "Vn"); + assert_eq!(editor.linewise_rows().collect::>(), [2..=4]); + type_text(&mut editor, "2N"); + assert_eq!(editor.linewise_rows().collect::>(), [0..=2]); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("same\nsame\nlast".into())]) + .unwrap(); + editor.handle(Input::Char('V')); + editor.handle(Input::Ctrl('n')); + assert_eq!(editor.mode(), Mode::Visual); + assert!(!editor.visual_linewise()); + assert_eq!(editor.selection_count(), 2); + assert_eq!( + editor.selections(), + [ + Selection { + start: Cursor { row: 0, column: 0 }, + end: Cursor { row: 1, column: 0 } + }, + Selection { + start: Cursor { row: 1, column: 0 }, + end: Cursor { row: 2, column: 0 } + }, + ] + ); +} + +#[test] +fn linewise_dot_retargets_row_spans_and_captures_paste_shape() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\nbb\nccc\nlast".into())]) + .unwrap(); + type_text(&mut editor, "Vjd"); + assert_eq!(editor.context().text, "ccc\nlast"); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "ccc\nlast"); + editor + .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) + .unwrap(); + type_text(&mut editor, "jVkcX"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "X\nc\nd"); + type_text(&mut editor, "jj."); + assert_eq!(editor.context().text, "X\nX"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "X\nc\nd"); + type_text(&mut editor, "V."); + assert_eq!(editor.context().text, "X\nc\nX"); + editor.apply(vec![Effect::Replace("row".into())]).unwrap(); + type_text(&mut editor, "Vyp"); + assert_eq!(editor.context().text, "row\nrow"); + editor.handle(Input::Char('u')); + type_text(&mut editor, "vy"); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "row\nrow"); +} + +#[test] +fn repeat_visual_word_change_resolves_fresh_words_for_every_caret_and_ignores_remaps() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("foo\nlonger".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + type_text(&mut editor, "vecitem"); + editor.handle(Input::Escape); + editor + .bind("visual", "e", Some("core.right".into())) + .unwrap(); + editor + .apply(vec![Effect::Replace("tiny\nextraordinary".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + let undo = editor.active.undo.len(); + type_text(&mut editor, "9."); + assert_eq!(editor.context().text, "item\nitem"); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.cursor().row, 1); + assert_eq!(editor.count(), None); + assert_eq!(editor.clipboard, vec!["tiny", "extraordinary"]); + assert_eq!(editor.active.undo.len(), undo + 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "tiny\nextraordinary"); + assert_eq!( + editor.carets(), + vec![Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 0 }] + ); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "item\nitem"); + editor.handle(Input::Char('u')); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "item\nitem"); +} + +#[test] +fn repeat_visual_current_ranges_supersede_unequal_recorded_spans_and_cardinality() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcd\nlonger".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[ + (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), + (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 5 }), + ], + 1, + ); + type_text(&mut editor, "cQ"); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("alpha beta\nsecond\nthird".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[ + (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 10 }), + (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 6 }), + (Cursor { row: 2, column: 0 }, Cursor { row: 2, column: 5 }), + ], + 2, + ); + let undo = editor.active.undo.len(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Q\nQ\nQ"); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.selection_count(), 3); + assert_eq!(editor.cursor().row, 2); + assert_eq!(editor.active.undo.len(), undo + 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "alpha beta\nsecond\nthird"); +} + +#[test] +fn repeat_cut_retargets_fresh_unequal_words_without_recreating_cloned_carets() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("cat\nlongword".into())]) + .unwrap(); + type_text(&mut editor, "ve"); + editor.handle(Input::Ctrl('e')); + editor.handle(Input::Char('d')); + editor + .apply(vec![Effect::Replace("small\nhugeword\nlast".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); + // Cloning invalidated the selector; both recorded spans have length three. + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "ll\neword\nt"); + assert_eq!(editor.selection_count(), 3); + editor + .apply(vec![Effect::Replace("cat\nlongword".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + type_text(&mut editor, "ved"); + editor + .apply(vec![Effect::Replace("tiny\nenormous".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "\n"); + assert_eq!(editor.clipboard, vec!["tiny", "enormous"]); +} + +#[test] +fn repeat_relative_spans_count_graphemes_and_newlines_and_keep_zero_spans_empty() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a界\n👩‍🚀x".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[(Cursor { row: 0, column: 1 }, Cursor { row: 1, column: 1 })], + 0, + ); + editor.handle(Input::Char('d')); + editor + .apply(vec![Effect::Replace("e\u{301}Z\n\n👨‍💻tail".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "\n👨‍💻tail"); + assert_eq!(editor.clipboard, vec!["e\u{301}Z\n"]); + editor.apply(vec![Effect::Replace("".into())]).unwrap(); + set_ranges(&mut editor, &[(Cursor::default(), Cursor::default())], 0); + type_text(&mut editor, "cQ"); + editor.handle(Input::Escape); + editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Qabc"); +} + +#[test] +fn repeat_unequal_span_distribution_refuses_atomically_and_recipe_survives() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcd\nlonger".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[ + (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), + (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), + ], + 1, + ); + editor.handle(Input::Char('d')); + editor + .apply(vec![Effect::Replace("1234\n56789".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 1)], 0); + let undo = editor.active.undo.len(); + let redo = editor.active.redo.len(); + let clipboard = editor.clipboard.clone(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "1234\n56789"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.active.undo.len(), undo); + assert_eq!(editor.active.redo.len(), redo); + assert_eq!(editor.clipboard, clipboard); + assert!(editor.message().contains("count")); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "34\n89"); +} + +#[test] +fn repeat_insert_placements_recalculate_for_current_multi_caret_lines() { + for key in ['i', 'I', 'a', 'A', 'o', 'O'] { + let mut recorded = editor(); + recorded + .apply(vec![Effect::Replace("short".into())]) + .unwrap(); + recorded.handle(Input::Char(key)); + type_text(&mut recorded, "X\nY"); + recorded.handle(Input::Escape); + let text = " alpha\n\tbeta\nz"; + recorded.apply(vec![Effect::Replace(text.into())]).unwrap(); + set_carets(&mut recorded, &[(0, 2), (1, 1), (2, 0)], 1); + let mut expected = editor(); + expected.apply(vec![Effect::Replace(text.into())]).unwrap(); + set_carets(&mut expected, &[(0, 2), (1, 1), (2, 0)], 1); + expected.handle(Input::Char(key)); + type_text(&mut expected, "X\nY"); + expected.handle(Input::Escape); + let undo = recorded.active.undo.len(); + recorded.handle(Input::Char('.')); + assert_eq!(recorded.context().text, expected.context().text, "{key}"); + assert_eq!(recorded.carets(), expected.carets(), "{key}"); + assert_eq!(recorded.cursor(), expected.cursor(), "{key}"); + assert_eq!(recorded.mode(), Mode::Normal); + assert_eq!(recorded.active.undo.len(), undo + 1); + recorded.handle(Input::Char('u')); + assert_eq!(recorded.context().text, text, "{key}"); + assert_eq!(recorded.selection_count(), 3); + } +} + +#[test] +fn repeat_visual_line_insert_uses_current_carets_without_rebuilding_old_row_targets() { + for (key, expected) in [('I', " !alpha\n !beta"), ('A', " alpha!\n beta!")] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\nb\nc".into())]) + .unwrap(); + type_text(&mut editor, "v2j"); + editor.handle(Input::Char(key)); + type_text(&mut editor, "!"); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 3); + editor + .apply(vec![Effect::Replace(" alpha\n beta".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 2), (1, 2)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, expected); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.cursor().row, 1); + } +} + +#[test] +fn repeat_counted_visual_motion_uses_resolved_action_and_normalizes_overlaps() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("one two three".into())]) + .unwrap(); + type_text(&mut editor, "v2ecQ"); + editor.handle(Input::Escape); + editor + .bind("visual", "e", Some("core.left".into())) + .unwrap(); + editor + .apply(vec![Effect::Replace("a enormous tail".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (0, 2)], 1); + let undo = editor.active.undo.len(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Q"); + assert_eq!(editor.selection_count(), 1); + assert_eq!(editor.active.undo.len(), undo + 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "a enormous tail"); + assert_eq!( + editor.carets(), + vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }] + ); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); +} + +#[test] +fn repeat_insert_native_motion_delete_backspace_and_newline_are_one_edit() { + fn edit(editor: &mut Editor) { + editor.handle(Input::Char('i')); + editor.handle(Input::Right); + type_text(editor, "XY"); + editor.handle(Input::Left); + editor.handle(Input::Backspace); + editor.handle(Input::Delete); + editor.handle(Input::Enter); + type_text(editor, "Z"); + editor.handle(Input::Right); // Trailing navigation is not part of the edit. + editor.handle(Input::Escape); + } + let mut recorded = editor(); + recorded + .apply(vec![Effect::Replace("abcdef".into())]) + .unwrap(); + edit(&mut recorded); + recorded + .apply(vec![Effect::Replace("123456\nlongertext".into())]) + .unwrap(); + set_carets(&mut recorded, &[(0, 1), (1, 2)], 1); + let mut expected = editor(); + expected + .apply(vec![Effect::Replace("123456\nlongertext".into())]) + .unwrap(); + set_carets(&mut expected, &[(0, 1), (1, 2)], 1); + edit(&mut expected); + let undo = recorded.active.undo.len(); + recorded.handle(Input::Char('.')); + assert_eq!(recorded.context().text, expected.context().text); + assert_eq!(recorded.active.undo.len(), undo + 1); + recorded.handle(Input::Char('u')); + assert_eq!(recorded.context().text, "123456\nlongertext"); +} + +#[test] +fn repeat_omits_noop_insert_deletions_and_preserves_partial_noop_carets() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + editor.handle(Input::Backspace); + editor.handle(Input::Delete); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); + set_carets(&mut editor, &[(0, 2)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "abXcd"); + editor.apply(vec![Effect::Replace("a\nab".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 1)], 1); + editor.handle(Input::Char('i')); + editor.handle(Input::Backspace); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("cde\nfgh".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 2), (1, 2)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "cdXe\nfXh"); +} + +#[test] +fn repeat_partial_deletion_mismatch_rolls_back_visual_change_and_clipboard() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("ab\ncd".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + type_text(&mut editor, "vc"); + editor.handle(Input::Backspace); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); + editor.handle(Input::Char('v')); + let ranges = editor.selections(); + let targets = editor.active.view.targets.clone(); + let primary = editor.active.view.primary; + let clipboard = editor.clipboard.clone(); + let undo = editor.active.undo.len(); + let redo = editor.active.redo.len(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "aa\nbb\ncc"); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), ranges); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, primary); + assert_eq!(editor.clipboard, clipboard); + assert_eq!(editor.active.undo.len(), undo); + assert_eq!(editor.active.redo.len(), redo); + assert!(editor.message().contains("count")); + editor.handle(Input::Escape); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "XaXb\ncc"); +} + +#[test] +fn repeat_typed_scalars_keep_right_neighbor_combining_and_zwj_boundaries() { + for (typed, neighbor, result) in [("ab", "\u{301}", "a\u{301}b"), ("👩x", "\u{200d}🚀", "👩‍🚀x")] + { + let mut editor = editor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, typed); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace(neighbor.into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + let undo = editor.active.undo.len(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, result); + assert_eq!(editor.active.undo.len(), undo + 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, neighbor); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, result); + } +} + +#[test] +fn repeat_literal_paste_is_bulk_literal_and_keeps_original_typing_groups() { + let mut editor = editor(); + type_text(&mut editor, "iX"); + editor.handle(Input::Paste("ab\r\n:q!".into())); + type_text(&mut editor, "Y"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "Xab\n:q!Y"); + for expected in ["Xab\n:q!", "X", ""] { + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, expected); + } + editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Yfresh"); + type_text(&mut editor, "i"); + editor.handle(Input::Paste("ab".into())); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("\u{301}".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "ab\u{301}"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "\u{301}"); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste(":q!\r\n".into())); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("target".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, ":q!\ntarget"); + assert!(!editor.should_quit()); +} + +#[test] +fn repeat_clipboard_captures_fragments_pairs_broadcasts_and_refuses_mismatch() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("ab\nxyz".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[ + (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), + (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), + ], + 1, + ); + editor.handle(Input::Char('y')); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('P')); + editor.apply(vec![Effect::Replace("OTHER".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + type_text(&mut editor, "vey"); + assert_eq!(editor.clipboard, vec!["OTHER"]); + editor + .apply(vec![Effect::Replace("1\n2\n3".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); + let undo = editor.active.undo.len(); + let targets = editor.active.view.targets.clone(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "1\n2\n3"); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.undo.len(), undo); + assert_eq!(editor.clipboard, vec!["OTHER"]); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "ab1\nxyz2\n3"); + assert_eq!(editor.clipboard, vec!["OTHER"]); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "1\n2\n3"); + editor.clipboard = vec!["!".into()]; + editor.handle(Input::Char('p')); + editor + .apply(vec![Effect::Replace("a\nb\nc".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "a!\nb!\nc!"); +} + +#[test] +fn repeat_join_and_normal_delete_are_native_and_noops_keep_the_previous_recipe() { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("a\n b".into())]).unwrap(); + editor.handle(Input::Char('J')); + editor + .apply(vec![Effect::Replace("Done.\n again\nlast".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + let undo = editor.active.undo.len(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Done. again\nlast"); + assert_eq!(editor.active.undo.len(), undo + 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "Done.\n again\nlast"); + editor.apply(vec![Effect::Replace("".into())]).unwrap(); + editor.handle(Input::Char('J')); + editor.handle(Input::Char('x')); + editor + .apply(vec![Effect::Replace("one\n two".into())]) + .unwrap(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "one two"); + editor.handle(Input::Char('x')); + editor + .apply(vec![Effect::Replace("界e\u{301}\n👩‍🚀z".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "e\u{301}\nz"); + assert_eq!(editor.selection_count(), 2); +} + +#[test] +fn repeat_identical_change_empty_insert_rejected_paste_and_history_keep_recipe_and_redo() { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + type_text(&mut editor, "vecabc"); + editor.handle(Input::Escape); + set_carets(&mut editor, &[(0, 0)], 0); + type_text(&mut editor, "i"); + editor.handle(Input::Backspace); + editor.handle(Input::Escape); + let redo = editor.active.redo.len(); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste("bad\0".into())); + editor.handle(Input::Escape); + assert_eq!(editor.active.redo.len(), redo); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "Xabc"); + editor.handle(Input::Char('u')); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Xabc"); +} + +#[test] +fn repeat_is_session_wide_and_plugin_effects_commit_but_never_record_native_intent() { + let directory = Directory::new(); + let other = directory.path("other"); + fs::write(&other, "base").unwrap(); + let mut editor = editor(); + type_text(&mut editor, "iN"); + editor.execute(&format!("edit {}", other.display())); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Nbase"); + editor.register(plugin("fixture", &["greet"])).unwrap(); + assert_eq!(editor.execute("greet").unwrap().command, "greet"); + editor + .apply(vec![ + Effect::Replace("plugin".into()), + Effect::Insert("!".into()), + ]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + assert_eq!(editor.handle(Input::Char('.')), None); + assert_eq!(editor.context().text, "N!plugin"); + editor.handle(Input::Char('i')); + type_text(&mut editor, "T"); + editor.apply(vec![Effect::Insert("P".into())]).unwrap(); + editor.handle(Input::Escape); + editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Tfresh"); + editor.execute("buffer 1"); + assert_eq!(editor.context().text, "N"); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "TN"); +} + +#[test] +fn repeat_action_no_recipe_remaps_and_literal_queries_do_not_record_or_execute_edits() { + let directory = Directory::new(); + fs::write(directory.path("file.txt"), "file").unwrap(); + let mut editor = project_editor(&directory); + editor.handle(Input::Char('.')); + assert_eq!(editor.message(), "Nothing to repeat"); + assert_eq!(editor.context().text, ""); + assert!(editor.active.undo.is_empty()); + editor + .bind("normal", "r", Some("core.repeat_last_edit".into())) + .unwrap(); + editor + .bind("visual", "r", Some("core.repeat_last_edit".into())) + .unwrap(); + assert!( + editor + .bind("insert", "r", Some("core.repeat_last_edit".into())) + .is_err() + ); + type_text(&mut editor, "iX"); + editor.open_file_picker(FilePresentation::Docked); + editor.handle(Input::Char('i')); + type_text(&mut editor, "."); + assert_eq!(editor.file_picker().unwrap().query, "."); + assert_eq!(editor.context().text, "X"); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "."); + assert_eq!(editor.palette().unwrap().query, "."); + editor.handle(Input::Escape); + type_text(&mut editor, "Y"); + editor.handle(Input::Escape); + editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('r')); + assert_eq!(editor.context().text, "XYfresh"); + type_text(&mut editor, "i."); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("target".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('r')); + assert_eq!(editor.context().text, ".target"); + type_text(&mut editor, "/."); + assert_eq!(editor.command_line(), "."); + editor.handle(Input::Escape); + type_text(&mut editor, ":."); + assert_eq!(editor.command_line(), "."); + editor.handle(Input::Escape); + editor.apply(vec![Effect::Replace("again".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('r')); + assert_eq!(editor.context().text, ".again"); +} + +#[test] +fn buffers_keep_independent_history_saved_text_carets_primary_and_search() { + let directory = Directory::new(); + let a = directory.path("a"); + let b = directory.path("b"); + fs::write(&a, "cat cat\ncat cat").unwrap(); + fs::write(&b, "dog dog\ndog dog").unwrap(); + let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('i')); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + submit_search(&mut editor, "cat"); + let a_carets = editor.carets(); + let a_primary = editor.cursor(); + let a_match = editor.search_match(); + editor.execute("w"); + assert!(!editor.dirty()); + + editor.execute(&format!("edit {}", b.display())); + assert_eq!(editor.buffer_id(), 2); + set_carets(&mut editor, &[(0, 0), (1, 0)], 0); + submit_search(&mut editor, "dog"); + editor.handle(Input::Char('n')); + let b_carets = editor.carets(); + let b_primary = editor.cursor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "Y"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "dog dog\ndog dog"); + assert_eq!(editor.carets(), b_carets); + assert_eq!(editor.cursor(), b_primary); + assert!(!editor.dirty()); + + editor.execute("buffer 1"); + assert_eq!(editor.context().text, "Xcat cat\nXcat cat"); + assert_eq!(editor.carets(), a_carets); + assert_eq!(editor.cursor(), a_primary); + assert_eq!(editor.search_match(), a_match); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 5 }); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "cat cat\ncat cat"); + assert!(editor.dirty()); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "Xcat cat\nXcat cat"); + assert!(!editor.dirty()); + editor.execute("buffer 2"); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "dog Ydog\ndog Ydog"); + assert!(editor.dirty()); + editor.execute("w"); + assert!(!editor.dirty()); + assert_eq!(fs::read_to_string(&a).unwrap(), "Xcat cat\nXcat cat"); + assert_eq!(fs::read_to_string(&b).unwrap(), "dog Ydog\ndog Ydog"); + editor.execute("buffer 1"); + assert!(!editor.dirty()); +} + +#[test] +fn explicit_switch_preserves_directed_visual_ranges_and_bound_switch_restores_them() { + let directory = Directory::new(); + let a = directory.path("a"); + let b = directory.path("b"); + fs::write(&a, "abcdef\nabcdef").unwrap(); + fs::write(&b, "other").unwrap(); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + set_carets(&mut editor, &[(0, 4), (1, 4)], 1); + type_text(&mut editor, "vhh"); + let carets = editor.carets(); + let primary = editor.cursor(); + let ranges = editor.selections(); + editor.execute(&format!("edit {}", b.display())); + assert_eq!(editor.mode(), Mode::Normal); + editor + .bind("normal", "g", Some("core.buffer_previous".into())) + .unwrap(); + editor.handle(Input::Char('g')); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.cursor(), primary); + assert_eq!(editor.selections(), ranges); + editor.execute("bnext"); + editor.execute("buffer 1"); + assert_eq!(editor.mode(), Mode::Visual); + editor.handle(Input::Char('h')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); + assert_eq!(editor.selections()[1].end.column, 5); +} + +#[test] +fn switching_commits_insert_groups_and_never_restores_transient_prompts() { + let directory = Directory::new(); + let a = directory.path("a"); + let b = directory.path("b"); + fs::write(&a, "base").unwrap(); + fs::write(&b, "other").unwrap(); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "XY"); + editor.execute(&format!("edit {}", b.display())); + editor.execute("buffer 1"); + assert_eq!(editor.mode(), Mode::Normal); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "base"); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "XYbase"); + for prompt in [':', '/'] { + editor.handle(Input::Char(prompt)); + type_text(&mut editor, "unfinished"); + editor.execute("buffer 2"); + assert_eq!(editor.command_line(), ""); + editor.execute("buffer 1"); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.command_line(), ""); + } +} + +#[test] +fn failed_opens_and_invalid_buffer_arguments_leave_editing_state_untouched() { + let directory = Directory::new(); + let bad = directory.path("bad"); + fs::write(&bad, b"\xff").unwrap(); + let controls = directory.path("controls"); + fs::write(&controls, "bad\0").unwrap(); + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcdef".into())]) + .unwrap(); + type_text(&mut editor, "llvhh"); + let ranges = editor.selections(); + let carets = editor.carets(); + let cursor = editor.cursor(); + let undo = editor.active.undo.len(); + let commands = [ + format!("edit {}", bad.display()), + format!("edit {}", controls.display()), + format!("edit {}", directory.0.display()), + "edit".into(), + "buffer".into(), + "buffer 0".into(), + "buffer -1".into(), + "buffer 99".into(), + "buffer 1 2".into(), + "bnext 1".into(), + "bprev 1".into(), + "buffers 1".into(), + "bdelete nope".into(), + "bdelete! 99".into(), + "bdelete 999999999999999999999999999999".into(), + ]; + for command in commands { + editor.execute(&command); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.context().text, "abcdef"); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), ranges); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.active.undo.len(), undo); + } + editor.handle(Input::Escape); + editor.handle(Input::Char('i')); + type_text(&mut editor, "X"); + editor.execute(&format!("edit {}", bad.display())); + assert_eq!(editor.mode(), Mode::Insert); + type_text(&mut editor, "Y"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "abcdef"); +} + +#[test] +fn stable_buffer_ids_wrap_close_inactive_and_replace_the_last_buffer() { + let directory = Directory::new(); + let mut editor = editor(); + let a = directory.path("new a"); + let b = directory.path("new b"); + editor.execute(&format!("edit {}", a.display())); + editor.execute(&format!("edit {}", b.display())); + assert_eq!( + editor.buffers().map(|buffer| buffer.id).collect::>(), + vec![1, 2, 3], + ); + let info = editor.buffers().find(|buffer| buffer.id == 3).unwrap(); + assert_eq!(info.path, Some(b.as_path())); + assert!(info.active); + assert!(!info.dirty); + editor.execute("bdelete 2"); + assert_eq!(editor.buffer_id(), 3); + assert!(!editor.has_buffer(2)); + assert_eq!(editor.buffer_count(), 2); + editor.execute("bnext"); + assert_eq!(editor.buffer_id(), 1); + editor.execute("bprev"); + assert_eq!(editor.buffer_id(), 3); + editor.execute("bdelete"); + assert_eq!(editor.buffer_id(), 1); + editor.execute("bdelete"); + assert_eq!(editor.buffer_id(), 4); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.path(), None); + assert_eq!(editor.context().text, ""); + assert!(!editor.dirty()); + assert!(!editor.should_quit()); + editor.execute("bnext"); + editor.execute("bprev"); + assert_eq!(editor.buffer_id(), 4); + editor.execute(&format!("edit {}", a.display())); + assert_eq!(editor.buffer_id(), 5); + assert_eq!(editor.buffer_count(), 2); + editor.execute("buffer 2"); + assert_eq!(editor.buffer_id(), 5); +} + +#[test] +fn hidden_dirty_buffers_block_quit_write_quit_and_unforced_deletion() { + let directory = Directory::new(); + let a = directory.path("a"); + let b = directory.path("b"); + fs::write(&a, "a").unwrap(); + fs::write(&b, "b").unwrap(); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + editor + .apply(vec![Effect::Replace("dirty a".into())]) + .unwrap(); + editor.execute(&format!("edit {}", b.display())); + assert!(!editor.dirty()); + editor.execute("q"); + assert!(!editor.should_quit()); + editor + .apply(vec![Effect::Replace("saved b".into())]) + .unwrap(); + editor.execute("wq"); + assert!(!editor.should_quit()); + assert!(!editor.dirty()); + assert_eq!(fs::read_to_string(&b).unwrap(), "saved b"); + editor.execute("bdelete 1"); + assert!(editor.has_buffer(1)); + assert_eq!(editor.buffer_id(), 2); + editor.execute("buffer 1"); + assert_eq!(editor.context().text, "dirty a"); + editor.execute("bdelete"); + assert_eq!(editor.buffer_id(), 1); + editor.execute("bdelete!"); + assert_eq!(editor.buffer_id(), 2); + editor.execute("q"); + assert!(editor.should_quit()); + + let mut forced = Editor::open(Some(b), vec![]).unwrap(); + forced.set_clipboard_mode("separate").unwrap(); + forced.apply(vec![Effect::Replace("dirty".into())]).unwrap(); + forced.execute(&format!("edit {}", directory.path("new").display())); + forced.execute("q! arguments"); + assert!(!forced.should_quit()); + forced.execute("bdelete! 1"); + assert_eq!(forced.buffer_id(), 2); + assert!(!forced.has_buffer(1)); + forced + .apply(vec![Effect::Replace("also dirty".into())]) + .unwrap(); + forced.execute("bdelete!"); + assert_eq!(forced.buffer_id(), 3); + assert!(!forced.dirty()); + forced + .apply(vec![Effect::Replace("quit dirty".into())]) + .unwrap(); + forced.execute(&format!("edit {}", directory.path("another").display())); + forced.execute("q!"); + assert!(forced.should_quit()); +} + +#[test] +fn loaded_paths_refuse_save_collisions_before_touching_files_or_saved_state() { + let directory = Directory::new(); + let a = directory.path("a"); + let b = directory.path("b"); + fs::write(&a, "a").unwrap(); + fs::write(&b, "b").unwrap(); + let mut editor = Editor::open(Some(a.clone()), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + editor.execute(&format!("edit {}", b.display())); + editor + .apply(vec![Effect::Replace("dirty b".into())]) + .unwrap(); + for command in ["w", "w!", "wq", "wq!"] { + editor.execute(&format!("{command} {}", directory.0.join("./a").display())); + assert!(editor.message().contains("belongs to buffer 1")); + assert_eq!(editor.path(), Some(b.as_path())); + assert!(editor.dirty()); + assert!(!editor.should_quit()); + assert_eq!(fs::read_to_string(&a).unwrap(), "a"); + assert_eq!(fs::read_to_string(&b).unwrap(), "b"); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2); + } + editor.execute(&format!("w {}", directory.0.join("./b").display())); + assert!(!editor.dirty()); + assert_eq!(fs::read_to_string(&b).unwrap(), "dirty b"); + let new = directory.path("missing"); + editor.execute(&format!("edit {}", new.display())); + editor.execute("buffer 2"); + editor.execute(&format!("w {}", new.display())); + assert!(editor.message().contains("belongs to buffer 3")); + assert!(!new.exists()); + assert!(!editor.dirty()); +} + +#[test] +fn clipboard_bindings_plugins_and_palette_catalog_are_session_wide() { + let directory = Directory::new(); + let mut editor = editor(); + editor.register(plugin("fixture", &["greet"])).unwrap(); + editor.bind("normal", "g", Some("greet".into())).unwrap(); + editor + .bind("normal", "z", Some("core.buffer_next".into())) + .unwrap(); + editor + .apply(vec![Effect::Replace("copied".into())]) + .unwrap(); + type_text(&mut editor, "v$y"); + editor.execute(&format!("edit {}", directory.path("new").display())); + editor.handle(Input::Char('p')); + assert_eq!(editor.context().text, "copied"); + assert_eq!(editor.handle(Input::Char('g')).unwrap().plugin, "fixture"); + editor.handle(Input::Char('z')); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.handle(Input::Char('g')).unwrap().command, "greet"); + editor.handle(Input::Ctrl('p')); + assert_eq!( + palette_entry(&editor, "core.buffer_next").bindings, + vec!["Space b n", "z"] + ); + editor.disable_plugin("fixture", "failure"); + assert!( + !editor + .palette() + .unwrap() + .entries + .iter() + .any(|entry| entry.name == "greet") + ); + editor.handle(Input::Escape); + editor.execute("buffer 2"); + assert_eq!(editor.handle(Input::Char('g')), None); + editor.execute("plugins"); + assert!(!editor.message().contains("fixture")); + assert!(editor.message().contains("bdelete!")); +} + +#[cfg(unix)] +#[test] +fn canonical_aliases_reuse_dirty_buffers_and_new_paths_are_normalized() { + use std::os::unix::fs::symlink; + let directory = Directory::new(); + let path = directory.path("file"); + let link = directory.path("file link"); + fs::write(&path, "disk").unwrap(); + symlink(&path, &link).unwrap(); + let mut editor = Editor::open(Some(link.clone()), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + editor + .apply(vec![Effect::Replace("dirty memory".into())]) + .unwrap(); + editor.execute(&format!("edit {}", directory.path("other").display())); + fs::write(&path, "changed externally").unwrap(); + editor.execute(&format!("edit {}", path.display())); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.buffer_count(), 2); + assert_eq!(editor.path(), Some(link.as_path())); + assert_eq!(editor.context().text, "dirty memory"); + assert!(editor.dirty()); + editor.execute("w"); + assert!(editor.message().contains("symbolic link")); + assert!(editor.dirty()); + assert_eq!(fs::read_to_string(&path).unwrap(), "changed externally"); + editor.execute(&format!("w {}", path.display())); + assert!(editor.dirty()); + assert_eq!(editor.path(), Some(link.as_path())); + assert_eq!(fs::read_to_string(&path).unwrap(), "changed externally"); + editor.execute(&format!("w! {}", path.display())); + assert!(!editor.dirty()); + assert_eq!(editor.path(), Some(path.as_path())); + + let alias = directory.path("directory link"); + symlink(&directory.0, &alias).unwrap(); + let new = alias.join("new"); + editor.execute(&format!("edit {}", new.display())); + assert_eq!(editor.buffer_id(), 3); + editor + .apply(vec![Effect::Replace("new memory".into())]) + .unwrap(); + editor.execute("buffer 1"); + editor.execute(&format!( + "edit {}", + directory.0.join("absent/../new").display() + )); + assert_eq!(editor.buffer_id(), 3); + assert_eq!(editor.buffer_count(), 3); + assert_eq!(editor.context().text, "new memory"); + assert_eq!(editor.path(), Some(new.as_path())); + editor.execute(&format!("w {}", link.display())); + assert!(editor.message().contains("belongs to buffer 1")); + assert!(editor.dirty()); + assert_eq!(fs::read_to_string(&path).unwrap(), "dirty memory"); + assert!(!new.exists()); +} + +fn palette_entry<'a>(editor: &'a Editor, name: &str) -> &'a PaletteEntry { + editor + .palette() + .unwrap() + .entries + .iter() + .find(|entry| entry.name == name) + .unwrap() +} + +#[test] +fn palette_cancellation_preserves_every_underlying_mode_and_insert_group() { + for mode in [ + Mode::Normal, + Mode::Insert, + Mode::Command, + Mode::Search, + Mode::Visual, + ] { + for cancel in [Input::Escape, Input::Ctrl('p')] { + let mut editor = editor(); + let original = "alpha beta\nalpha beta"; + editor + .apply(vec![Effect::Replace(original.into())]) + .unwrap(); + editor.apply(vec![Effect::Insert("extra".into())]).unwrap(); + editor.undo(); + submit_search(&mut editor, "alpha"); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + match mode { + Mode::Normal => {} + Mode::Insert => { + editor.handle(Input::Char('i')); + type_text(&mut editor, "X"); + } + Mode::Command => { + editor.handle(Input::Char(':')); + type_text(&mut editor, "w output"); + } + Mode::Search => { + editor.handle(Input::Char('/')); + type_text(&mut editor, "alpha e\u{301}"); + } + Mode::Visual => { + editor.handle(Input::Char('v')); + editor.handle(Input::Right); + } + } + let snapshot = + |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); + let lines = editor.active.lines.clone(); + let targets = editor.active.view.targets.clone(); + let primary = editor.active.view.primary; + let selections = editor.selections(); + let prompt = editor.active.view.command_line.clone(); + let search = editor.active.view.search_query.clone(); + let found = editor.active.view.search_match; + let message = editor.message.clone(); + let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); + let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); + let insert = editor.active.view.insert_start.as_ref().map(snapshot); + + editor.handle(Input::Ctrl('p')); + assert!(editor.palette().is_some()); + editor.handle(Input::Ctrl('j')); + editor.handle(Input::Ctrl('k')); + type_text(&mut editor, "query Δe\u{301}👩‍🚀 "); + editor.handle(Input::Backspace); + editor.handle(Input::Backspace); + assert_eq!(editor.palette().unwrap().query, "query Δe\u{301}"); + editor.handle(Input::Paste("ignored\n:q!".into())); + editor.handle(Input::Down); + editor.handle(Input::Ctrl('k')); + editor.handle(cancel); + + assert!(editor.palette().is_none()); + assert_eq!(editor.mode(), mode); + assert_eq!(editor.active.lines, lines); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, primary); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.active.view.command_line, prompt); + assert_eq!(editor.active.view.search_query, search); + assert_eq!(editor.active.view.search_match, found); + assert_eq!(editor.message, message); + assert_eq!( + editor.active.undo.iter().map(&snapshot).collect::>(), + undo + ); + assert_eq!( + editor.active.redo.iter().map(&snapshot).collect::>(), + redo + ); + assert_eq!( + editor.active.view.insert_start.as_ref().map(snapshot), + insert + ); + if mode == Mode::Insert { + type_text(&mut editor, "Y"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, original); + } + } + } +} + +#[test] +fn palette_filters_all_tokens_and_wraps_without_executing_empty_results() { + let mut editor = editor(); + editor + .register(plugin("Älpha Tools", &["greet_one", "greet_two"])) + .unwrap(); + editor.handle(Input::Ctrl('p')); + let order: Vec<_> = editor + .palette() + .unwrap() + .entries + .iter() + .map(|entry| (entry.label.clone(), entry.name.clone())) + .collect(); + assert!(order.windows(2).all(|pair| pair[0] <= pair[1])); + assert!( + !editor + .palette() + .unwrap() + .entries + .iter() + .any(|entry| entry.name == "core.palette") + ); + type_text(&mut editor, "GREET ÄLPHA"); + assert_eq!(editor.palette().unwrap().entries.len(), 2); + assert_eq!(editor.palette().unwrap().entries[0].name, "greet_one"); + editor.handle(Input::Up); + assert_eq!(editor.palette().unwrap().selected, 1); + editor.handle(Input::Ctrl('n')); + assert_eq!(editor.palette().unwrap().selected, 0); + editor.handle(Input::Down); + assert_eq!(editor.palette().unwrap().selected, 1); + editor.handle(Input::Ctrl('k')); + assert_eq!(editor.palette().unwrap().selected, 0); + editor.handle(Input::Down); + editor.handle(Input::Char(' ')); + assert_eq!(editor.palette().unwrap().selected, 0); + type_text(&mut editor, "missing"); + assert!(editor.palette().unwrap().entries.is_empty()); + for input in [ + Input::Enter, + Input::Up, + Input::Down, + Input::Ctrl('j'), + Input::Ctrl('k'), + Input::Ctrl('n'), + ] { + assert_eq!(editor.handle(input), None); + assert_eq!(editor.palette().unwrap().selected, 0); + } + while !editor.palette().unwrap().query.is_empty() { + editor.handle(Input::Backspace); + } + type_text(&mut editor, "e\u{301}👩‍🚀"); + editor.handle(Input::Backspace); + assert_eq!(editor.palette().unwrap().query, "e\u{301}"); + editor.handle(Input::Backspace); + assert_eq!(editor.palette().unwrap().query, ""); + assert!(!editor.palette().unwrap().entries.is_empty()); + editor.handle(Input::Ctrl('i')); + assert_eq!(editor.palette().unwrap().query, ""); +} + +#[test] +fn palette_catalog_shares_binding_eligibility_and_keeps_disabled_rows() { + for mode in [Mode::Insert, Mode::Command, Mode::Search, Mode::Visual] { + let mut editor = editor(); + editor.active.view.mode = mode; + editor.active.view.command_line = "untouched".into(); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.undo"); + let entry = palette_entry(&editor, "core.undo"); + assert!(!entry.enabled); + assert_eq!(entry.modes, vec![Mode::Normal]); + assert!(entry.bindings.is_empty()); + editor.handle(Input::Ctrl('j')); + editor.handle(Input::Ctrl('k')); + assert_eq!(editor.palette().unwrap().query, "core.undo"); + assert_eq!(editor.handle(Input::Enter), None); + assert!(editor.palette().is_some()); + assert_eq!(editor.mode(), mode); + assert_eq!(editor.command_line(), "untouched"); + while !editor.palette().unwrap().query.is_empty() { + editor.handle(Input::Backspace); + } + type_text(&mut editor, "q!"); + assert!(!palette_entry(&editor, "core.force_quit").enabled); + editor.handle(Input::Enter); + assert!(!editor.should_quit()); + assert!(editor.palette().is_some()); + } +} + +#[test] +fn palette_effective_bindings_refresh_with_canonical_terminal_names() { + let mut editor = editor(); + editor.handle(Input::Ctrl('p')); + assert_eq!(palette_entry(&editor, "core.insert").bindings, vec!["i"]); + editor.bind("normal", "i", None).unwrap(); + assert!(palette_entry(&editor, "core.insert").bindings.is_empty()); + for key in [ + "shift+i", "ctrl+m", "ctrl+h", "ctrl+i", "space", "ctrl+r", "界", + ] { + editor + .bind("normal", key, Some("core.insert".into())) + .unwrap(); + } + editor + .bind("normal", "ctrl+j", Some("core.insert".into())) + .unwrap(); + assert!( + palette_entry(&editor, "core.insert_first_nonblank") + .bindings + .is_empty() + ); + editor.bind("normal", "ENTER", None).unwrap(); + assert!( + !palette_entry(&editor, "core.insert") + .bindings + .iter() + .any(|key| key == "Enter") + ); + editor + .bind("normal", "界", Some("core.undo".into())) + .unwrap(); + assert_eq!( + palette_entry(&editor, "core.undo").bindings, + vec!["u", "界"] + ); + assert!( + !palette_entry(&editor, "core.insert") + .bindings + .iter() + .any(|key| key == "界") + ); + assert!( + editor + .bind("insert", "g", Some("core.undo".into())) + .is_err() + ); + editor.handle(Input::Escape); + editor.bind("normal", "ctrl+p", None).unwrap(); + editor + .bind("normal", "ctrl+f", Some("core.palette".into())) + .unwrap(); + editor.handle(Input::Ctrl('p')); + assert!(editor.palette().is_none()); + editor.handle(Input::Ctrl('f')); + assert!(editor.palette().is_some()); + editor.handle(Input::Ctrl('p')); + assert!(editor.palette().is_none()); +} + +#[test] +fn palette_registered_commands_refresh_and_retire_with_plugins() { + let mut editor = editor(); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "fixture"); + assert!(editor.palette().unwrap().entries.is_empty()); + editor.register(plugin("fixture", &["greet"])).unwrap(); + assert_eq!(editor.palette().unwrap().entries.len(), 1); + assert_eq!(palette_entry(&editor, "greet").owner, "fixture"); + assert!(palette_entry(&editor, "greet").enabled); + editor.bind("normal", "g", Some("greet".into())).unwrap(); + assert_eq!(palette_entry(&editor, "greet").bindings, vec!["g"]); + editor.disable_plugin("fixture", "retired"); + assert!(editor.palette().unwrap().entries.is_empty()); + editor.handle(Input::Enter); + assert!(editor.palette().is_some()); + editor.handle(Input::Escape); + assert_eq!(editor.handle(Input::Char('g')), None); + assert!(editor.bind("normal", "g", Some("greet".into())).is_err()); +} + +#[test] +fn palette_commands_require_prompt_submission_and_dispatch_arguments_once() { + let mut editor = editor(); + editor.register(plugin("fixture", &["greet"])).unwrap(); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "greet"); + assert_eq!(editor.handle(Input::Enter), None); + assert!(editor.palette().is_none()); + assert_eq!(editor.mode(), Mode::Command); + assert_eq!(editor.command_line(), "greet "); + type_text(&mut editor, "界!"); + assert_eq!( + editor.handle(Input::Enter), + Some(Invocation { + plugin: "fixture".into(), + command: "greet".into(), + args: "界!".into(), + }) + ); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.handle(Input::Enter), None); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "q!"); + editor.handle(Input::Enter); + assert!(!editor.should_quit()); + editor.handle(Input::Enter); + assert!(editor.should_quit()); +} + +#[test] +fn palette_actions_dispatch_visual_change_insert_newline_and_original_prompts() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("alpha beta".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[(Cursor::default(), Cursor { row: 0, column: 5 })], + 0, + ); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.change"); + assert!(palette_entry(&editor, "core.change").enabled); + editor.handle(Input::Enter); + assert!(editor.palette().is_none()); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.context().text, " beta"); + type_text(&mut editor, "Ω"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "Ω beta"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "alpha beta"); + + let mut insertion = self::editor(); + insertion.handle(Input::Char('i')); + type_text(&mut insertion, "A"); + insertion.handle(Input::Ctrl('p')); + type_text(&mut insertion, "core.newline"); + insertion.handle(Input::Enter); + assert_eq!(insertion.mode(), Mode::Insert); + assert_eq!(insertion.lines(), &["A", ""]); + type_text(&mut insertion, "B"); + insertion.handle(Input::Escape); + insertion.handle(Input::Char('u')); + assert_eq!(insertion.context().text, ""); + insertion.handle(Input::Char('U')); + assert_eq!(insertion.context().text, "A\nB"); + + editor.handle(Input::Char(':')); + type_text(&mut editor, "plugins"); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.submit"); + editor.handle(Input::Enter); + assert_eq!(editor.mode(), Mode::Normal); + assert!(editor.message().starts_with("Capabilities: ")); + assert_eq!(editor.command_line(), ""); + editor.handle(Input::Char('/')); + type_text(&mut editor, "beta"); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.submit"); + editor.handle(Input::Enter); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.active.view.search_query, "beta"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 6 }); +} + +#[test] +fn palette_survives_rejected_effects_but_external_execution_closes_it() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("alpha beta".into())]) + .unwrap(); + editor.handle(Input::Char('v')); + editor.handle(Input::Right); + let targets = editor.active.view.targets.clone(); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.change"); + let undo = editor.active.undo.len(); + assert!( + editor + .apply(vec![ + Effect::Message("changed".into()), + Effect::Insert("\0".into()) + ]) + .is_err() + ); + assert_eq!(editor.palette().unwrap().query, "core.change"); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.undo.len(), undo); + editor.apply(vec![Effect::Message("ok".into())]).unwrap(); + assert_eq!(editor.palette().unwrap().query, "core.change"); + assert_eq!(editor.active.view.targets, targets); + editor.execute("plugins"); + assert!(editor.palette().is_none()); + assert_eq!(editor.mode(), Mode::Normal); + + editor.handle(Input::Char('v')); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.change"); + editor.apply(vec![Effect::Insert("!".into())]).unwrap(); + assert_eq!(editor.mode(), Mode::Normal); + assert!(!palette_entry(&editor, "core.change").enabled); +} + +#[test] +fn visual_change_replaces_reversed_multiline_graphemes_in_one_undo_group() { + let mut editor = editor(); + let original = "pre界e\u{301}\n\n👩‍🚀tail\npost"; + editor + .apply(vec![Effect::Replace(original.into())]) + .unwrap(); + submit_search(&mut editor, "👩‍🚀"); + editor.handle(Input::Char('v')); + editor.handle(Input::Up); + editor.handle(Input::Up); + for _ in 0..3 { + editor.handle(Input::Right); + } + editor.handle(Input::Char('c')); + assert_eq!(editor.mode(), Mode::Insert); + assert!(editor.selections().is_empty()); + assert_eq!(editor.search_match(), None); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); + assert_eq!(editor.context().text, "pretail\npost"); + editor.handle(Input::Paste("\0".into())); + type_text(&mut editor, "α\nβ"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "preα\nβtail\npost"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, original); + assert_eq!(editor.mode(), Mode::Normal); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "preα\nβtail\npost"); +} + +#[test] +fn visual_change_at_eof_and_empty_ranges_preserves_clipboard() { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("abX".into())]).unwrap(); + editor.handle(Input::End); + editor.handle(Input::Char('v')); + editor.handle(Input::Char('c')); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); + type_text(&mut editor, "YZ"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "abYZ"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "abX"); + editor.apply(vec![Effect::Replace("".into())]).unwrap(); + editor.handle(Input::Char('v')); + assert_eq!(editor.selections()[0].start, editor.selections()[0].end); + editor.handle(Input::Char('c')); + type_text(&mut editor, "new"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Char('p')); + assert_eq!(editor.context().text, "X"); +} + +#[test] +fn visual_change_with_identical_replacement_preserves_redo() { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + editor.handle(Input::Char('v')); + editor.handle(Input::Char('e')); + editor.handle(Input::Char('c')); + type_text(&mut editor, "abc"); + editor.handle(Input::Escape); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "Xabc"); +} + +#[test] +fn visual_ranges_copy_both_directions_and_cut_and_paste_multiline_graphemes() { + let mut editor = editor(); + let text = "a界e\u{301}z\n\n👩‍🚀tail\n"; + editor.apply(vec![Effect::Replace(text.into())]).unwrap(); + editor.handle(Input::Right); + editor.handle(Input::Char('v')); + editor.handle(Input::Right); + assert_eq!( + editor.selections(), + vec![Selection { + start: Cursor { row: 0, column: 1 }, + end: Cursor { row: 0, column: 3 }, + }] + ); + editor.handle(Input::Left); + editor.handle(Input::Left); + assert_eq!( + editor.selections()[0], + Selection { + start: Cursor { row: 0, column: 0 }, + end: Cursor { row: 0, column: 2 }, + } + ); + editor.handle(Input::Down); + assert_eq!(editor.selections()[0].end, Cursor { row: 2, column: 0 }); + let selected = editor.selections(); + editor.handle(Input::Paste(":q!\n".into())); + assert_eq!(editor.selections(), selected); + editor.handle(Input::Down); + editor.handle(Input::Char('d')); + assert!(editor.selections().is_empty()); + assert_eq!(editor.context().text, "atail\n"); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, text); + editor.handle(Input::Char('u')); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, text); + assert_eq!(editor.mode(), Mode::Normal); +} + +#[test] +fn clipboard_copy_preserves_redo_and_survives_history_and_grapheme_merging() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("\u{301}".into())]) + .unwrap(); + editor.handle(Input::Char('v')); + editor.handle(Input::Char('y')); + editor.handle(Input::Char('u')); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "\u{301}"); + editor.apply(vec![Effect::Replace("a".into())]).unwrap(); + editor.handle(Input::Char('p')); + assert_eq!(editor.context().text, "a\u{301}"); + assert_eq!(editor.cursor(), Cursor::default()); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "a"); +} + +#[test] +fn visual_empty_lines_include_real_newlines_and_row_split_excludes_exclusive_end() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("ab\n\ncd\n".into())]) + .unwrap(); + editor.handle(Input::Down); + editor.handle(Input::Char('v')); + assert_eq!( + editor.selections(), + vec![Selection { + start: Cursor { row: 1, column: 0 }, + end: Cursor { row: 2, column: 0 }, + }] + ); + editor.handle(Input::Ctrl('l')); + assert_eq!(editor.carets(), vec![Cursor { row: 1, column: 0 }]); + editor.handle(Input::Char('v')); + editor.handle(Input::Delete); + assert_eq!(editor.context().text, "ab\ncd\n"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "ab\n\ncd\n"); +} + +#[test] +fn batch_insert_newline_backspace_and_delete_use_prebuffer_offsets() { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0), (0, 2)], 1); + editor.handle(Input::Char('i')); + type_text(&mut editor, "X"); + assert_eq!(editor.context().text, "XabXcd"); + editor.handle(Input::Enter); + assert_eq!(editor.context().text, "X\nabX\ncd"); + editor.handle(Input::Backspace); + assert_eq!(editor.context().text, "XabXcd"); + editor.handle(Input::Delete); + assert_eq!(editor.context().text, "XbXd"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "abcd"); + assert_eq!( + editor.carets(), + vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }] + ); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); +} + +#[test] +fn visual_insert_and_append_split_ragged_rows_without_padding() { + for (key, expected) in [('I', " !aa\n!\n !zzz"), ('A', " aa!\n!\n zzz!")] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace(" aa\n\n zzz".into())]) + .unwrap(); + editor.handle(Input::Char('v')); + editor.handle(Input::Down); + editor.handle(Input::Down); + editor.handle(Input::Char(key)); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.selection_count(), 3); + type_text(&mut editor, "!"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, expected); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, " aa\n\n zzz"); + } +} + +#[test] +fn unequal_word_changes_and_typing_share_undo_and_restore_primary_carets() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("foo\nlonger".into())]) + .unwrap(); + editor.handle(Input::Ctrl('e')); + editor.handle(Input::Char('v')); + editor.handle(Input::Char('e')); + assert_eq!( + editor + .selections() + .iter() + .map(|range| range.end.column) + .collect::>(), + vec![3, 6] + ); + let before = editor.carets(); + let primary = editor.cursor(); + editor.handle(Input::Char('c')); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "X\nX"); + assert_eq!(editor.clipboard, vec!["foo", "longer"]); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "foo\nlonger"); + assert_eq!(editor.carets(), before); + assert_eq!(editor.cursor(), primary); + assert_eq!(editor.mode(), Mode::Normal); + assert!(editor.selections().is_empty()); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "X\nX"); +} + +#[test] +fn unicode_overlap_merges_but_adjacency_preserves_distinct_fragments() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\u{301}👩‍🚀界z".into())]) + .unwrap(); + editor.active.view.mode = Mode::Visual; + editor.active.view.targets = vec![ + editor.range_target( + Selection { + start: Cursor { row: 0, column: 0 }, + end: Cursor { row: 0, column: 2 }, + }, + true, + ), + editor.range_target( + Selection { + start: Cursor { row: 0, column: 1 }, + end: Cursor { row: 0, column: 3 }, + }, + false, + ), + editor.range_target( + Selection { + start: Cursor { row: 0, column: 3 }, + end: Cursor { row: 0, column: 4 }, + }, + true, + ), + ]; + editor.active.view.primary = 1; + editor.normalize_targets(); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.selections()[0].end.column, 3); + assert_eq!(editor.cursor().column, 0); + editor.handle(Input::Char('d')); + assert_eq!(editor.context().text, ""); + assert_eq!(editor.clipboard, vec!["a\u{301}👩‍🚀界", "z"]); + assert_eq!(editor.selection_count(), 1); + editor.handle(Input::Char('u')); + assert_eq!( + editor.carets(), + vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 3 }] + ); +} + +#[test] +fn clipboard_pairs_in_document_order_broadcasts_one_and_refuses_mismatch_atomically() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("ab\nxyzq".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[ + (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 1 }), + (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), + ], + 1, + ); + editor.handle(Input::Char('d')); + assert_eq!(editor.context().text, "b\nq"); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, "ab\nxyzq"); + editor.handle(Input::Char('u')); + editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + assert_eq!(editor.selection_count(), 2); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 1); + let before = editor.snapshot(); + let undo = editor.active.undo.len(); + let redo = editor.active.redo.len(); + editor.handle(Input::Char('p')); + assert_eq!(editor.active.lines, before.lines); + assert_eq!(editor.active.view.targets, before.targets); + assert_eq!(editor.active.view.primary, before.primary); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + (undo, redo) + ); + assert!(editor.message().contains("fragment count")); + editor.clipboard = vec!["!".into()]; + set_carets(&mut editor, &[(0, 0), (1, 0)], 0); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, "!b\n!q"); +} + +#[test] +fn literal_multiline_occurrences_skip_wrap_select_all_and_escape_in_two_stages() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("α\nβ α\nβ α\nβ".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[(Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 1 })], + 0, + ); + editor.handle(Input::Ctrl('n')); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + editor.handle(Input::Ctrl('s')); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.cursor(), Cursor { row: 3, column: 0 }); + editor.handle(Input::Ctrl('n')); + assert_eq!(editor.selection_count(), 3); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + editor.handle(Input::Ctrl('a')); + assert_eq!(editor.selection_count(), 3); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.selection_count(), 3); + let primary = editor.cursor(); + editor.handle(Input::Escape); + assert_eq!(editor.carets(), vec![primary]); +} + +#[test] +fn occurrences_reject_grapheme_intersections_and_gather_nonoverlapping_literals() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("aa aa aaa a\u{301}a".into())]) + .unwrap(); + editor.handle(Input::Char('v')); + editor.handle(Input::Right); + editor.handle(Input::Ctrl('a')); + assert_eq!( + editor + .selections() + .iter() + .map(|range| range.start.column) + .collect::>(), + vec![0, 3, 6] + ); + assert_eq!(editor.cursor().column, 1); + editor.handle(Input::Ctrl('n')); + assert_eq!(editor.selection_count(), 3); +} + +#[test] +fn vertical_clones_keep_display_columns_through_tabs_wide_and_short_rows() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\t界z\nx\n123456z".into())]) + .unwrap(); + editor.handle(Input::End); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.selection_count(), 3); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 6 }); + editor.handle(Input::Char('i')); + type_text(&mut editor, "!"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "a\t界!z\n!x\n123456!z"); + assert_eq!(editor.selection_count(), 3); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 1); +} + +#[test] +fn visual_clones_preserve_directed_ranges_and_motion_targets_every_range() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abc\nabcd\nabcde".into())]) + .unwrap(); + editor.handle(Input::Right); + editor.handle(Input::Char('v')); + editor.handle(Input::Right); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.selection_count(), 2); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.selection_count(), 3); + editor.handle(Input::Left); + assert!( + editor + .selections() + .iter() + .all(|range| range.start.column == 1 && range.end.column == 2) + ); + editor.handle(Input::Char('c')); + type_text(&mut editor, "!"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "a!c\na!cd\na!cde"); +} + +#[test] +fn batch_open_deduplicates_rows_and_join_uses_original_neighbor_rows() { + for (key, expected) in [('o', "abc\n\nxyz\n"), ('O', "\nabc\n\nxyz")] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abc\nxyz".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (0, 2), (1, 1)], 2); + editor.handle(Input::Char(key)); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, expected); + assert_eq!(editor.selection_count(), 2); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "abc\nxyz"); + assert_eq!(editor.selection_count(), 3); + } + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\n b\n c".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 1)], 1); + editor.handle(Input::Char('J')); + assert_eq!(editor.context().text, "a b c"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "a\n b\n c"); + assert_eq!(editor.selection_count(), 2); +} + +#[test] +fn plugin_text_effects_collapse_selection_but_presentation_effects_do_not() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) + .unwrap(); + editor.register(plugin("sample", &["sample"])).unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 1); + assert_eq!(editor.execute("sample").unwrap().command, "sample"); + assert_eq!(editor.selection_count(), 3); + editor.handle(Input::Char('v')); + let ranges = editor.selections(); + assert!(editor.apply(vec![Effect::Insert("\0".into())]).is_err()); + editor + .apply(vec![ + Effect::Message("ok".into()), + Effect::Label { + name: "battery".into(), + text: "ready".into(), + }, + ]) + .unwrap(); + assert_eq!(editor.label("battery"), "ready"); + assert_eq!(editor.selections(), ranges); + assert_eq!(editor.mode(), Mode::Visual); + editor.apply(vec![Effect::Insert("!".into())]).unwrap(); + assert_eq!(editor.context().text, "aa\n!bb\ncc"); + assert_eq!(editor.selection_count(), 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "aa\nbb\ncc"); + assert_eq!(editor.selection_count(), 3); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + assert_eq!(editor.mode(), Mode::Normal); +} + +#[test] +fn literal_paste_broadcasts_and_retains_separate_typing_history_for_all_carets() { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("a\nb".into())]).unwrap(); + editor.handle(Input::Ctrl('e')); + editor.handle(Input::Char('i')); + type_text(&mut editor, "x"); + editor.handle(Input::Paste("界e\u{301}\r\n".into())); + type_text(&mut editor, "z"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "x界e\u{301}\nza\nx界e\u{301}\nzb"); + for expected in ["x界e\u{301}\na\nx界e\u{301}\nb", "xa\nxb", "a\nb"] { + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, expected); + assert_eq!(editor.selection_count(), 2); + } +} + +#[test] +fn caret_motions_and_search_apply_to_all_targets_and_collapse_duplicates() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("cat cat\ncat cat".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (1, 0)], 2); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + submit_search(&mut editor, "cat"); + editor.handle(Input::Char('n')); + assert_eq!( + editor.carets(), + vec![Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 4 }] + ); + editor.handle(Input::Char('b')); + assert!(editor.carets().iter().all(|cursor| cursor.column == 0)); + editor.handle(Input::Up); + assert_eq!(editor.carets(), vec![Cursor::default()]); +} + +#[test] +fn word_motions_distinguish_punctuation_cross_empty_lines_and_preserve_history() { + let mut editor = editor(); + let text = "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail"; + editor.apply(vec![Effect::Replace(text.into())]).unwrap(); + for (key, row, column) in [ + ('w', 0, 7), + ('w', 0, 10), + ('w', 0, 15), + ('w', 1, 0), + ('w', 3, 0), + ('w', 3, 5), + ('w', 3, 9), + ('w', 3, 12), + ('w', 3, 12), + ('b', 3, 9), + ('b', 3, 5), + ('b', 3, 0), + ('b', 1, 0), + ('b', 0, 15), + ('b', 0, 10), + ('b', 0, 7), + ('b', 0, 0), + ('b', 0, 0), + ('e', 0, 6), + ('e', 0, 9), + ('e', 0, 12), + ('e', 0, 17), + ('e', 3, 3), + ('e', 3, 7), + ('e', 3, 12), + ('e', 3, 12), + ] { + editor.handle(Input::Char(key)); + assert_eq!(editor.cursor(), Cursor { row, column }, "{key}"); + } + assert_eq!(editor.context().text, text); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, text); +} + +#[test] +fn big_word_motions_use_only_whitespace_and_handle_buffer_edges() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace( + "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail".into(), + )]) + .unwrap(); + for (key, row, column) in [ + ('W', 0, 15), + ('W', 1, 0), + ('W', 3, 0), + ('W', 3, 5), + ('W', 3, 9), + ('W', 3, 12), + ('B', 3, 9), + ('B', 3, 5), + ('B', 3, 0), + ('B', 1, 0), + ('B', 0, 15), + ('B', 0, 0), + ('B', 0, 0), + ('E', 0, 12), + ('E', 0, 17), + ('E', 3, 3), + ('E', 3, 7), + ('E', 3, 12), + ('E', 3, 12), + ] { + editor.handle(Input::Char(key)); + assert_eq!(editor.cursor(), Cursor { row, column }, "{key}"); + } + for (text, next, end) in [("", 0, 0), (" ", 2, 2), ("word ", 6, 3)] { + editor.apply(vec![Effect::Replace(text.into())]).unwrap(); + editor.handle(Input::Home); + editor.handle(Input::Char('e')); + assert_eq!( + editor.cursor(), + Cursor { + row: 0, + column: end + } + ); + editor.handle(Input::Char('w')); + assert_eq!( + editor.cursor(), + Cursor { + row: 0, + column: next + } + ); + editor.handle(Input::Char('b')); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 }); + } + editor + .apply(vec![Effect::Replace("one\u{2003}two\u{a0}three\n".into())]) + .unwrap(); + editor.handle(Input::Home); + editor.handle(Input::Char('w')); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); + editor.handle(Input::Char('w')); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 }); + editor.handle(Input::Char('w')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + editor.handle(Input::Char('e')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + editor.handle(Input::Char('b')); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 }); +} + +#[test] +fn search_wraps_both_ways_and_cancel_failure_and_navigation_preserve_edits() { + let mut editor = editor(); + let text = "zero cat\ncat cat\nCAT\n"; + editor.apply(vec![Effect::Replace(text.into())]).unwrap(); + submit_search(&mut editor, "cat"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); + assert_eq!(editor.search_match().unwrap().end_column, 8); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 }); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); + editor.handle(Input::Char('N')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 }); + editor.handle(Input::Char('N')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + + let cursor = editor.cursor(); + let active = editor.search_match(); + editor.handle(Input::Char('/')); + type_text(&mut editor, "other界e\u{301}"); + editor.handle(Input::Backspace); + assert_eq!(editor.command_line(), "other界"); + editor.handle(Input::Paste(":q!\n".into())); + assert_eq!(editor.command_line(), "other界"); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.search_match(), active); + assert!(editor.command_line().is_empty()); + submit_search(&mut editor, ""); + assert_eq!(editor.cursor(), cursor); + + let cursor = editor.cursor(); + submit_search(&mut editor, "missing"); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.search_match(), None); + editor.handle(Input::Char('N')); + assert_eq!(editor.cursor(), cursor); + assert!(!editor.should_quit()); + assert_eq!(editor.context().text, text); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, text); +} + +#[test] +fn search_is_literal_overlapping_and_grapheme_safe_and_rechecks_after_edits() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("e\u{301} e 👩‍🚀 👩 aa... aaa".into())]) + .unwrap(); + submit_search(&mut editor, "e"); + assert_eq!(editor.cursor().column, 2); + submit_search(&mut editor, "\u{301}"); + assert_eq!(editor.cursor().column, 2); + assert_eq!(editor.search_match(), None); + submit_search(&mut editor, "👩"); + assert_eq!(editor.cursor().column, 6); + submit_search(&mut editor, "👩‍🚀"); + assert_eq!(editor.cursor().column, 4); + assert_eq!(editor.search_match().unwrap().end_column, 5); + submit_search(&mut editor, "."); + assert_eq!(editor.cursor().column, 10); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor().column, 11); + submit_search(&mut editor, "aa"); + assert_eq!(editor.cursor().column, 14); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor().column, 15); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor().column, 8); + submit_search(&mut editor, "e\u{301}"); + assert_eq!(editor.cursor().column, 0); + assert_eq!(editor.search_match().unwrap().end_column, 1); + + let active = editor.search_match(); + assert!(editor.apply(vec![Effect::Replace("\0".into())]).is_err()); + assert_eq!(editor.search_match(), active); + editor.handle(Input::Char('x')); + assert_eq!(editor.search_match(), None); + editor.handle(Input::Char('n')); + assert_eq!(editor.search_match(), None); + editor.handle(Input::Char('u')); + assert_eq!(editor.search_match(), None); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor().column, 0); + assert_eq!(editor.search_match().unwrap().end_column, 1); + editor + .apply(vec![Effect::Replace("e\u{301}".into())]) + .unwrap(); + assert_eq!(editor.search_match(), None); + editor.handle(Input::Char('n')); + assert_eq!(editor.cursor().column, 0); + assert_eq!(editor.search_match().unwrap().end_column, 1); +} + +#[test] +fn join_lines_matches_vim_spacing_and_grapheme_cursor_positions() { + for (original, expected, column) in [ + ("alpha\n \tbeta\ntail", "alpha beta\ntail", 5), + ("alpha \n\tbeta", "alpha beta", 7), + ("alpha\t\n beta", "alpha\tbeta", 6), + ("\n beta", "beta", 0), + ("alpha\n \t", "alpha", 4), + ("\n", "", 0), + ("alpha\n )tail", "alpha)tail", 5), + ("Sentence.\n next", "Sentence. next", 9), + ("界e\u{301}\n next", "界e\u{301} next", 2), + ("x \n\u{301}y", "x \u{301}y", 1), + ] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace(original.into())]) + .unwrap(); + editor.handle(Input::Char('J')); + assert_eq!(editor.context().text, expected); + assert_eq!(editor.cursor(), Cursor { row: 0, column }); + assert_eq!(editor.mode(), Mode::Normal); + } +} + +#[test] +fn join_is_one_undo_action_on_the_current_row_and_restores_cursor() { + let mut editor = editor(); + let original = "above\nalpha\n beta\nbelow"; + editor + .apply(vec![Effect::Replace(original.into())]) + .unwrap(); + editor.handle(Input::Down); + editor.handle(Input::Right); + let before = editor.cursor(); + editor.handle(Input::Char('J')); + assert_eq!(editor.context().text, "above\nalpha beta\nbelow"); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 5 }); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, original); + assert_eq!(editor.cursor(), before); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "above\nalpha beta\nbelow"); + editor.handle(Input::Char('x')); + assert_eq!(editor.context().text, "above\nalphabeta\nbelow"); +} + +#[test] +fn join_at_eof_preserves_history_and_insert_mode_j_is_literal() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("single".into())]) + .unwrap(); + editor.handle(Input::Char('u')); + editor.handle(Input::Char('J')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "single"); + editor.handle(Input::Char('J')); + assert_eq!(editor.context().text, "single"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Char('U')); + editor.handle(Input::Char('i')); + editor.handle(Input::Char('J')); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "Jsingle"); +} + +#[test] +fn uppercase_u_redoes_and_control_r_no_longer_does() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "changed"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Ctrl('r')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "changed"); +} + +#[test] +fn uppercase_insert_keys_target_line_positions_and_preserve_undo_cursor() { + for (original, key, expected) in [ + (" \t界e\u{301} tail", 'I', " \tX界e\u{301} tail"), + (" \t ", 'I', " \t X"), + ("", 'I', "X"), + (" \t界e\u{301} tail", 'A', " \t界e\u{301} tailX"), + ("", 'A', "X"), + ] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace(original.into())]) + .unwrap(); + editor.handle(if key == 'I' { Input::End } else { Input::Right }); + let before = editor.cursor(); + editor.handle(Input::Char(key)); + assert_eq!(editor.mode(), Mode::Insert); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, expected); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, original); + assert_eq!(editor.cursor(), before); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, expected); + } +} + +#[test] +fn paste_is_ignored_in_normal_and_command_modes_without_dispatching() { + let mut editor = editor(); + assert_eq!(editor.handle(Input::Paste(":q!\r\n".into())), None); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.context().text, ""); + assert!(!editor.dirty()); + assert!(!editor.should_quit()); + + editor.handle(Input::Char(':')); + type_text(&mut editor, "q"); + assert_eq!(editor.handle(Input::Paste("\rq!\n\0".into())), None); + assert_eq!(editor.mode(), Mode::Command); + assert_eq!(editor.command_line(), "q"); + assert_eq!(editor.context().text, ""); + assert!(!editor.should_quit()); + editor.handle(Input::Enter); + assert!(editor.should_quit()); +} + +#[test] +fn pasted_unicode_and_newlines_are_literal_and_a_separate_undo_action() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "a"); + assert_eq!( + editor.handle(Input::Paste("界e\u{301}\t\r\n:q!\r".into())), + None + ); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + type_text(&mut editor, "z"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "a界e\u{301}\t\n:q!\nz"); + assert!(!editor.should_quit()); + + for expected in ["a界e\u{301}\t\n:q!\n", "a", ""] { + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, expected); + } + for expected in ["a", "a界e\u{301}\t\n:q!\n", "a界e\u{301}\t\n:q!\nz"] { + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, expected); + } +} + +#[test] +fn rejected_paste_preserves_text_cursor_mode_and_pending_insert_history() { + for text in [ + "valid prefix\ninvalid\0".to_owned(), + "\u{1b}[31m".to_owned(), + "x".repeat(MAX_EDIT_BYTES + 1), + ] { + let mut editor = editor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "before"); + let cursor = editor.cursor(); + editor.handle(Input::Paste(text)); + assert_eq!(editor.context().text, "before"); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.mode(), Mode::Insert); + assert!( + editor.message().contains("Unsupported control character") + || editor.message().contains("Paste exceeds 4 MiB") + ); + type_text(&mut editor, "after"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "beforeafter"); + } +} + +#[test] +fn insert_sessions_and_open_lines_are_single_undo_actions() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "hello\n世界"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "hello\n世界"); + assert!(editor.dirty()); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + assert!(!editor.dirty()); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "hello\n世界"); + editor.handle(Input::Char('O')); + type_text(&mut editor, "above"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "hello\nabove\n世界"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "hello\n世界"); + editor.handle(Input::Char('o')); + type_text(&mut editor, "below"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "hello\n世界\nbelow"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "hello\n世界"); +} + +#[test] +fn grapheme_navigation_and_deletion_do_not_split_unicode() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\u{301}👩‍💻界".into())]) + .unwrap(); + editor.handle(Input::Char('l')); + assert_eq!(editor.cursor().column, 1); + editor.handle(Input::Char('x')); + assert_eq!(editor.context().text, "a\u{301}界"); + editor.handle(Input::Char('0')); + editor.handle(Input::Delete); + assert_eq!(editor.context().text, "界"); + editor.handle(Input::Char('a')); + type_text(&mut editor, "👨‍👩‍👧"); + editor.handle(Input::Backspace); + assert_eq!(editor.context().text, "界"); + editor.handle(Input::Escape); + assert_eq!(editor.cursor().column, 0); + assert_eq!(editor.context().column, 1); +} + +#[test] +fn insertion_merges_combining_graphemes_and_joins_lines() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "a\u{301}\nb"); + editor.handle(Input::Home); + editor.handle(Input::Backspace); + assert_eq!(editor.context().text, "a\u{301}b"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); + editor.handle(Input::Enter); + editor.handle(Input::Up); + editor.handle(Input::End); + editor.handle(Input::Delete); + assert_eq!(editor.context().text, "a\u{301}b"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); +} + +#[test] +fn cursor_is_clamped_on_short_and_empty_lines() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("long\nx\n".into())]) + .unwrap(); + editor.handle(Input::End); + assert_eq!(editor.cursor().column, 3); + editor.handle(Input::Down); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + editor.handle(Input::Char('j')); + editor.handle(Input::Char('l')); + editor.handle(Input::Char('x')); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.context().row, 3); + editor.handle(Input::Char('i')); + editor.handle(Input::Left); + editor.handle(Input::Delete); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "long\nx\n"); +} + +#[test] +fn registry_rejects_incompatible_duplicate_and_forged_core_atomically() { + let mut editor = editor(); + let mut feature = plugin("graphics", &["draw"]); + feature.required_capabilities.push("graphics".into()); + assert!(editor.register(feature).is_err()); + assert!(editor.bind("normal", "g", Some("draw".into())).is_err()); + assert!( + editor + .register(plugin("duplicate", &["newcmd", "w"])) + .is_err() + ); + assert!(editor.bind("normal", "g", Some("newcmd".into())).is_err()); + assert!( + editor + .register(plugin("repeat", &["same", "same"])) + .is_err() + ); + assert!(editor.register(plugin("invalid", &["bad name"])).is_err()); + for name in [ + ".hover", + "lsp.", + "lsp..hover", + "lsp.3hover", + "lsp.hover!extra", + ] { + assert!( + editor + .register(plugin("invalid", &["newcmd", name])) + .is_err() + ); + assert!(editor.bind("normal", "g", Some("newcmd".into())).is_err()); + } + assert!(editor.register(plugin("", &["emptyname"])).is_err()); + let mut feature = plugin("forged", &["override"]); + feature.core = true; + assert!(editor.register(feature).is_err()); + editor.register(plugin("working", &["greet"])).unwrap(); + assert!(editor.register(plugin("working", &["different"])).is_err()); + assert!(editor.register(plugin("other", &["greet"])).is_err()); + assert!(editor.register(plugin("collision", &["plugins"])).is_err()); + editor.disable_plugin("core", "attempt"); + editor.execute("q"); + assert!(editor.should_quit()); +} + +#[test] +fn readable_key_aliases_share_bindings_and_invalid_chords_leave_them_intact() { + let mut editor = editor(); + editor.bind("normal", "shift+i", None).unwrap(); + editor.handle(Input::Char('I')); + assert_eq!(editor.mode(), Mode::Normal); + editor + .bind("normal", "I", Some("core.insert".into())) + .unwrap(); + editor.handle(Input::Char('I')); + assert_eq!(editor.mode(), Mode::Insert); + + editor + .bind("insert", "plus", Some("core.newline".into())) + .unwrap(); + editor.bind("insert", "+", None).unwrap(); + editor.handle(Input::Char('+')); + type_text(&mut editor, "界"); + assert_eq!(editor.lines(), &["界"]); + editor + .bind("insert", "plus", Some("core.newline".into())) + .unwrap(); + editor.handle(Input::Char('+')); + assert_eq!(editor.lines(), &["界", ""]); + + editor + .bind("insert", "shift+j", Some("core.normal".into())) + .unwrap(); + for key in [ + "", + "", + "", + "ctrl+", + "+r", + "ctrl++r", + "ctrl+shift+j", + "shift+1", + "cmd+j", + "ctrl+界", + ] { + assert!( + editor + .bind("insert", key, Some("core.newline".into())) + .is_err(), + "{key}" + ); + } + editor.handle(Input::Char('J')); + assert_eq!(editor.mode(), Mode::Normal); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), &[String::new()]); +} + +#[test] +fn plugin_bindings_dispatch_only_normal_mode_and_retire_with_owner() { + let mut editor = editor(); + editor.register(plugin("greeting", &["greet"])).unwrap(); + assert!(editor.bind("insert", "g", Some("greet".into())).is_err()); + editor.bind("normal", "g", Some("greet".into())).unwrap(); + assert_eq!( + editor.handle(Input::Char('g')), + Some(Invocation { + plugin: "greeting".into(), + command: "greet".into(), + args: String::new(), + }) + ); + editor.handle(Input::Char('i')); + assert_eq!(editor.handle(Input::Char('g')), None); + assert_eq!(editor.context().text, "g"); + editor.handle(Input::Escape); + editor.disable_plugin("greeting", "crashed\u{1b}[31m"); + assert_eq!(editor.handle(Input::Char('g')), None); + assert!(editor.bind("normal", "g", Some("greet".into())).is_err()); + assert!(!editor.message().contains('\u{1b}')); +} + +#[test] +fn command_mode_dispatches_arguments_and_backspaces_whole_graphemes() { + let mut editor = editor(); + editor.register(plugin("greeting", &["greet"])).unwrap(); + editor.handle(Input::Char(':')); + type_text(&mut editor, "greet a\u{301}"); + editor.handle(Input::Backspace); + assert_eq!(editor.command_line(), "greet "); + type_text(&mut editor, "world"); + assert_eq!( + editor.handle(Input::Enter), + Some(Invocation { + plugin: "greeting".into(), + command: "greet".into(), + args: "world".into(), + }) + ); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.command_line(), ""); + editor.handle(Input::Char(':')); + type_text(&mut editor, "q!"); + editor.handle(Input::Escape); + assert!(!editor.should_quit()); + assert_eq!(editor.command_line(), ""); +} + +#[test] +fn chrome_installation_and_invalid_label_batches_are_atomic() { + let mut editor = editor(); + editor.set_label("battery".into(), "ready".into()).unwrap(); + let mut top = ChromeLine::top(); + top.left = vec!["mode".into(), "language".into()]; + top.center.push("workspace".into()); + let mut bottom = ChromeLine::bottom(); + bottom.left = vec!["tabs".into()]; + editor + .set_chrome_config( + top.clone(), + bottom.clone(), + BTreeMap::from([ + ("battery".into(), "ready".into()), + ("workspace".into(), "tools\n".into()), + ]), + ) + .unwrap(); + assert_eq!(editor.label("workspace"), "tools�"); + assert_eq!( + editor.top_bar_config().segment("workspace"), + &crate::chrome::SegmentConfig::default() + ); + let installed_top = editor.top_bar_config().clone(); + let installed_bottom = editor.bottom_bar_config().clone(); + assert_eq!(installed_top.left, ["mode", "language"]); + assert_eq!(installed_bottom.left, ["tabs"]); + top.right.push("uninstalled".into()); + bottom.center.push("uninstalled".into()); + let mut invalid_top = top.clone(); + invalid_top.segments.get_mut("tabs").unwrap().title = "{missing}".into(); + let mut invalid_bottom = bottom.clone(); + invalid_bottom.segments.get_mut("mode").unwrap().padding = 65; + for (top, bottom, labels) in [ + (invalid_top, bottom.clone(), BTreeMap::new()), + (top.clone(), invalid_bottom, BTreeMap::new()), + ( + top, + bottom, + BTreeMap::from([("bad-name".into(), "invalid".into())]), + ), + ] { + assert!(editor.set_chrome_config(top, bottom, labels).is_err()); + assert_eq!(editor.top_bar_config(), &installed_top); + assert_eq!(editor.bottom_bar_config(), &installed_bottom); + assert_eq!(editor.label("battery"), "ready"); + assert_eq!(editor.label("workspace"), "tools�"); + } + assert!( + editor + .apply(vec![ + Effect::Message("must not leak".into()), + Effect::Label { + name: "battery".into(), + text: "changed".into() + }, + Effect::Insert("must not edit".into()), + Effect::Label { + name: "bad-name".into(), + text: "invalid".into() + }, + ]) + .is_err() + ); + assert_eq!(editor.label("battery"), "ready"); + assert_eq!(editor.message(), ""); + assert_eq!(editor.context().text, ""); + assert_eq!(editor.text_revision(), 0); +} + +#[test] +fn native_battery_cache_validates_changes_without_editing_or_replacing_labels() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste("document".into())); + editor.handle(Input::Escape); + editor.handle(Input::Char('v')); + let text = editor.context().text; + let selection = editor.selections(); + let revision = editor.text_revision(); + let history = (editor.active.undo.len(), editor.active.redo.len()); + assert_eq!(editor.battery_label(), ""); + assert!(!editor.set_battery_charge(None)); + assert!(!editor.set_battery_charge(Some(101))); + assert!(editor.set_battery_charge(Some(27))); + assert_eq!(editor.battery_label(), "bat 27%"); + assert!(!editor.set_battery_charge(Some(27))); + assert_eq!(editor.label("battery"), ""); + + editor.set_label("battery".into(), "custom".into()).unwrap(); + assert!(editor.set_battery_charge(Some(0))); + assert_eq!(editor.battery_label(), "bat 0%"); + assert_eq!(editor.label("battery"), "custom"); + assert!(editor.set_battery_charge(Some(100))); + assert_eq!(editor.battery_label(), "bat 100%"); + assert!(editor.set_battery_charge(Some(255))); + assert_eq!(editor.battery_label(), ""); + assert!(!editor.set_battery_charge(None)); + assert_eq!(editor.label("battery"), "custom"); + assert_eq!(editor.context().text, text); + assert_eq!(editor.selections(), selection); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.text_revision(), revision); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + history + ); + + assert!(editor.set_battery_charge(Some(28))); + editor.execute("tab.new"); + assert_eq!(editor.battery_label(), "bat 28%"); + editor.set_label("battery".into(), String::new()).unwrap(); + assert_eq!(editor.battery_label(), "bat 28%"); + assert_eq!(editor.label("battery"), ""); + assert!(editor.set_battery_charge(None)); + assert_eq!(editor.battery_label(), ""); +} + +#[test] +fn named_labels_sanitize_and_survive_tabs_until_independently_cleared() { + let mut editor = editor(); + assert_eq!(editor.label("battery"), ""); + assert!( + editor + .set_label("battery".into(), "界 e\u{301} 👩‍🚀".into()) + .unwrap() + ); + assert!( + !editor + .set_label("battery".into(), "界 e\u{301} 👩‍🚀".into()) + .unwrap() + ); + assert!( + editor + .set_label("battery".into(), "界\0\t\r\n\u{1b}\u{85}👩‍🚀".into()) + .unwrap() + ); + editor + .set_label("workspace".into(), "tools".into()) + .unwrap(); + assert_eq!(editor.label("battery"), "界������👩‍🚀"); + editor.set_message("transient"); + editor.execute("tab.new"); + assert_eq!(editor.label("battery"), "界������👩‍🚀"); + editor.execute("tab.previous"); + assert_eq!(editor.label("workspace"), "tools"); + assert!(editor.set_label("battery".into(), String::new()).unwrap()); + editor.execute("tab.next"); + assert_eq!(editor.label("battery"), ""); + assert!(!editor.set_label("battery".into(), String::new()).unwrap()); + assert_eq!(editor.label("workspace"), "tools"); +} + +#[test] +fn readonly_diff_accepts_atomic_label_updates_without_editing_state() { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["base".into()], + vec!["head".into()], + ); + editor.handle(Input::Char('v')); + let selection = editor.selections(); + let revision = editor.text_revision(); + let text = editor.context().text; + let history = (editor.active.undo.len(), editor.active.redo.len()); + editor + .apply(vec![ + Effect::Label { + name: "battery".into(), + text: "界\n\u{1b}".into(), + }, + Effect::Message("read only".into()), + ]) + .unwrap(); + assert_eq!(editor.label("battery"), "界��"); + assert_eq!(editor.message(), "read only"); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), selection); + assert_eq!(editor.text_revision(), revision); + assert_eq!(editor.context().text, text); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + history + ); + assert!( + editor + .apply(vec![ + Effect::Label { + name: "battery".into(), + text: "must not leak".into() + }, + Effect::Insert("rejected".into()), + ]) + .is_err() + ); + assert_eq!(editor.label("battery"), "界��"); + editor + .apply(vec![Effect::Label { + name: "battery".into(), + text: String::new(), + }]) + .unwrap(); + assert_eq!(editor.label("battery"), ""); +} + +#[test] +fn plugin_effects_validate_before_any_mutation_and_group_undo() { + let mut editor = editor(); + editor.set_message("unchanged"); + editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + editor + .set_label("battery".into(), "unchanged".into()) + .unwrap(); + assert!( + editor + .apply(vec![ + Effect::Label { + name: "battery".into(), + text: "must not leak".into() + }, + Effect::Insert("good".into()), + Effect::Message("bad\u{1b}".into()) + ]) + .is_err() + ); + assert_eq!(editor.context().text, ""); + assert_eq!(editor.message(), "unchanged"); + assert!(!editor.notification_visible()); + assert_eq!(editor.label("battery"), "unchanged"); + assert!(!editor.dirty()); + assert!( + editor + .apply(vec![ + Effect::Label { + name: "battery".into(), + text: "x".into() + }, + Effect::Insert("a".repeat(MAX_EDIT_BYTES)) + ]) + .is_err() + ); + assert_eq!(editor.context().text, ""); + assert_eq!(editor.label("battery"), "unchanged"); + editor + .apply(vec![ + Effect::Insert("one".into()), + Effect::Insert("\ntwo".into()), + Effect::Message("ok".into()), + Effect::Label { + name: "battery".into(), + text: "updated".into(), + }, + ]) + .unwrap(); + assert_eq!(editor.context().text, "one\ntwo"); + assert_eq!(editor.message(), "ok"); + assert!(editor.notification_visible()); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + assert_eq!(editor.label("battery"), "updated"); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "one\ntwo"); + editor + .apply(vec![Effect::Replace("replacement".into())]) + .unwrap(); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "one\ntwo"); +} + +#[test] +fn plugin_effect_transactions_preserve_insert_sessions() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "before"); + let cursor = editor.cursor(); + let undo_len = editor.active.undo.len(); + editor + .apply(vec![ + Effect::Label { + name: "battery".into(), + text: "typing".into(), + }, + Effect::Message("notice".into()), + ]) + .unwrap(); + assert_eq!(editor.active.undo.len(), undo_len); + assert!( + editor + .apply(vec![Effect::Replace("invalid\0".into())]) + .is_err() + ); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.cursor(), cursor); + type_text(&mut editor, "after"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Char('i')); + type_text(&mut editor, "a"); + editor + .apply(vec![ + Effect::Insert("b".into()), + Effect::Message("plugin".into()), + ]) + .unwrap(); + type_text(&mut editor, "c"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "abc"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "ab"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "a"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); +} + +#[test] +fn no_op_edits_preserve_redo_but_new_edits_clear_it() { + let mut editor = editor(); + editor.apply(vec![Effect::Insert("a".into())]).unwrap(); + editor.handle(Input::Char('u')); + editor.handle(Input::Char('i')); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Message("notice".into())]) + .unwrap(); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "a"); + editor.handle(Input::Char('u')); + editor.apply(vec![Effect::Insert("b".into())]).unwrap(); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "b"); +} + +#[test] +fn saves_preserve_empty_files_lf_trailing_newlines_and_dirty_history() { + let directory = Directory::new(); + for (name, text) in [ + ("empty", ""), + ("no-newline", "hello"), + ("newline", "hello\n"), + ("two-newlines", "hello\n\n"), + ] { + let path = directory.path(name); + fs::write(&path, text).unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + editor.execute("w"); + assert_eq!(fs::read_to_string(&path).unwrap(), text); + assert!(!editor.dirty()); + } + let path = directory.path("new file"); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "saved\n"); + editor.handle(Input::Escape); + editor.execute("q"); + assert!(!editor.should_quit()); + editor.execute("w"); + assert_eq!(fs::read_to_string(&path).unwrap(), "saved\n"); + assert!(!editor.dirty()); + editor.handle(Input::Char('u')); + assert!(editor.dirty()); + editor.handle(Input::Char('U')); + assert!(!editor.dirty()); + editor.execute("q"); + assert!(editor.should_quit()); +} + +#[test] +fn save_as_and_failed_wq_keep_path_dirty_state_and_quit_correct() { + let directory = Directory::new(); + let mut editor = editor(); + editor + .apply(vec![Effect::Insert("unsaved".into())]) + .unwrap(); + editor.execute("w"); + assert!(editor.dirty()); + let bad = directory.path("absent/file"); + editor.execute(&format!("wq {}", bad.display())); + assert!(editor.dirty()); + assert!(!editor.should_quit()); + assert_eq!(editor.path(), None); + let path = directory.path("save as.txt"); + editor.execute(&format!("w {}", path.display())); + assert_eq!(editor.path(), Some(path.as_path())); + assert!(!editor.dirty()); + editor.apply(vec![Effect::Insert("new".into())]).unwrap(); + editor.execute("q!"); + assert!(editor.should_quit()); + assert_eq!(fs::read_to_string(path).unwrap(), "unsaved"); +} + +#[test] +fn loaded_controls_are_rejected_and_cr_line_endings_are_normalized() { + let directory = Directory::new(); + let path = directory.path("control"); + for text in ["nul\0", "escape\u{1b}[31m", "backspace\u{8}", "c1\u{85}"] { + fs::write(&path, text).unwrap(); + assert!( + matches!(Editor::open(Some(path.clone()), vec![]), Err(error) if error.kind() == io::ErrorKind::InvalidData) + ); + } + fs::write(&path, "a\t界\r\nb\rc\n").unwrap(); + let editor = Editor::open(Some(path), vec![]).unwrap(); + assert_eq!(editor.context().text, "a\t界\nb\nc\n"); + assert!(!editor.dirty()); + assert!(Editor::open(Some(directory.0.clone()), vec![]).is_err()); +} + +#[cfg(unix)] +#[test] +fn saves_preserve_permissions_and_reject_symlinks_without_replacing_them() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let directory = Directory::new(); + let path = directory.path("original"); + fs::write(&path, "original").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + editor + .apply(vec![Effect::Replace("changed".into())]) + .unwrap(); + editor.execute("w"); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640 + ); + let link = directory.path("link"); + symlink(&path, &link).unwrap(); + let mut editor = Editor::open(Some(link.clone()), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + editor + .apply(vec![Effect::Replace("not saved".into())]) + .unwrap(); + for command in ["wq", "wq!"] { + editor.execute(command); + assert!(editor.dirty()); + assert!(!editor.should_quit()); + } + assert!( + fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!(fs::read_to_string(&path).unwrap(), "changed"); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2); +} + +fn annotation(start: (usize, usize), end: (usize, usize), message: &str) -> Diagnostic { + Diagnostic { + start: TextPosition { + row: start.0, + column: start.1, + }, + end: TextPosition { + row: end.0, + column: end.1, + }, + severity: crate::plugin::DiagnosticSeverity::Warning, + message: message.into(), + } +} + +#[test] +fn annotations_are_owner_scoped_atomic_and_grapheme_bounded() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a😀e\u{301}\n".into())]) + .unwrap(); + let id = editor.buffer_id(); + let revision = editor.text_revision(); + let first = annotation((0, 1), (0, 3), "first"); + editor + .replace_diagnostics("one", id, revision, vec![first.clone()]) + .unwrap(); + editor + .replace_diagnostics("two", id, revision, vec![annotation((1, 0), (1, 0), "end")]) + .unwrap(); + for invalid in [ + annotation((0, 0), (0, 4), "scalar column"), + annotation((0, 3), (0, 2), "reversed"), + annotation((2, 0), (2, 0), "missing row"), + annotation((0, 0), (0, 1), "control\u{1b}"), + ] { + assert!( + editor + .replace_diagnostics("one", id, revision, vec![first.clone(), invalid]) + .is_err() + ); + assert_eq!(editor.diagnostics().count(), 2); + assert_eq!( + editor.active.diagnostics["one"].items.as_slice(), + std::slice::from_ref(&first) + ); + } + assert!( + !editor + .replace_diagnostics( + "one", + id, + revision - 1, + vec![annotation((99, 0), (99, 0), "stale")] + ) + .unwrap() + ); + assert!( + !editor + .replace_diagnostics("one", 999, revision, vec![first.clone()]) + .unwrap() + ); + editor.active.view.targets = vec![Target::caret(Cursor { row: 0, column: 1 })]; + assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "first"); + editor.active.view.targets = vec![Target::caret(Cursor { row: 1, column: 0 })]; + assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "end"); + editor.clear_plugin_diagnostics("one"); + assert_eq!(editor.diagnostics().count(), 1); +} + +#[test] +fn annotations_do_not_split_typing_undo_and_clear_on_text_change() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + editor.handle(Input::Char('a')); + let revision = editor.text_revision(); + editor + .replace_diagnostics( + "one", + editor.buffer_id(), + revision, + vec![annotation((0, 0), (0, 1), "typed")], + ) + .unwrap(); + assert!(editor.active.view.insert_start.is_some()); + assert!(editor.active.undo.is_empty()); + editor.handle(Input::Char('b')); + assert_eq!(editor.diagnostics().count(), 0); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), [""]); + assert!(editor.active.undo.is_empty()); +} + +#[test] +fn navigation_reuses_dirty_hidden_text_and_failure_preserves_source() { + let directory = Directory::new(); + let source = directory.path("source"); + let target = directory.path("target"); + fs::write(&source, "source").unwrap(); + fs::write(&target, "a😀e\u{301}\n").unwrap(); + let mut editor = Editor::open(Some(source.clone()), vec!["text".into()]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + editor.execute(&format!("edit {}", target.display())); + editor + .apply(vec![Effect::Replace("dirty😀e\u{301}\n".into())]) + .unwrap(); + let target_id = editor.buffer_id(); + editor.execute("buffer 1"); + editor.handle(Input::Char('i')); + editor.handle(Input::Char('!')); + let before = editor.context().text; + let cursor = editor.cursor(); + let undo = editor.active.undo.len(); + assert!( + editor + .navigate_to( + directory.path("missing"), + TextPosition { row: 0, column: 0 } + ) + .is_err() + ); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.context().text, before); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.active.undo.len(), undo); + assert!(editor.active.view.insert_start.is_some()); + editor + .navigate_to( + target, + TextPosition { + row: 99, + column: 99, + }, + ) + .unwrap(); + assert_eq!(editor.buffer_id(), target_id); + assert_eq!(editor.lines(), ["dirty😀e\u{301}", ""]); + assert_eq!(editor.buffer_count(), 2); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(fs::read_to_string(source).unwrap(), "source"); +} +fn completion_item(start: (usize, usize), end: (usize, usize), text: &str) -> CompletionItem { + CompletionItem { + label: text.into(), + detail: None, + primary_edit: 0, + snippet: None, + edits: vec![CompletionEdit { + start: TextPosition { + row: start.0, + column: start.1, + }, + end: TextPosition { + row: end.0, + column: end.1, + }, + text: text.into(), + }], + } +} + +#[test] +fn completion_cycles_from_original_spans_and_restores_pending_typing() { + let mut editor = editor(); + type_text(&mut editor, "iabcd"); + let revision = editor.text_revision(); + let steps = editor + .active + .view + .pending_edit + .as_ref() + .unwrap() + .steps + .len(); + editor + .show_completion(vec![ + completion_item((0, 2), (0, 4), "X"), + completion_item((0, 0), (0, 1), "Q\nZ"), + ]) + .unwrap(); + assert_eq!(editor.context().text, "abX"); + assert_eq!(editor.completion().unwrap().selected, 1); + editor.handle(Input::Ctrl('n')); + assert_eq!(editor.context().text, "Q\nZbcd"); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); + editor.handle(Input::Ctrl('j')); + assert_eq!(editor.context().text, "abcd"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); + assert_eq!(editor.completion().unwrap().selected, 0); + assert_eq!( + editor + .active + .view + .pending_edit + .as_ref() + .unwrap() + .steps + .len(), + steps + ); + assert!(editor.text_revision() > revision); + editor.handle(Input::Ctrl('k')); + assert_eq!(editor.context().text, "Q\nZbcd"); + editor.handle(Input::Char('!')); + assert!(editor.completion().is_none()); + assert_eq!(editor.context().text, "Q\nZ!bcd"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); +} + +#[test] +fn completion_same_span_noop_preview_and_original_preserve_redo() { + let mut editor = editor(); + type_text(&mut editor, "iold"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.active.redo.len(), 1); + editor.handle(Input::Char('i')); + editor + .show_completion(vec![ + completion_item((0, 0), (0, 0), "one"), + completion_item((0, 0), (0, 0), "one"), + ]) + .unwrap(); + let revision = editor.text_revision(); + editor.handle(Input::Ctrl('n')); + assert_eq!(editor.context().text, "one"); + assert_eq!(editor.text_revision(), revision); + editor.handle(Input::Ctrl('n')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Escape); + assert_eq!(editor.active.redo.len(), 1); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "old"); +} + +#[test] +fn completion_noncycle_keys_keep_standard_insert_semantics() { + for (key, expected, mode) in [ + (Input::Char('!'), "completed!", Mode::Insert), + (Input::Enter, "completed\n", Mode::Insert), + (Input::Char('\t'), "completed\t", Mode::Insert), + (Input::Escape, "completed", Mode::Normal), + (Input::Left, "completed", Mode::Insert), + (Input::Ctrl('z'), "completed", Mode::Insert), + ] { + let mut editor = editor(); + type_text(&mut editor, "iprefix"); + editor + .show_completion(vec![completion_item((0, 0), (0, 6), "completed")]) + .unwrap(); + editor.handle(key); + assert!(editor.completion().is_none()); + assert_eq!(editor.context().text, expected); + assert_eq!(editor.mode(), mode); + if mode == Mode::Insert { + editor.handle(Input::Escape); + } + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + } +} + +#[test] +fn completion_request_and_cycle_use_effective_insert_binding() { + let mut editor = editor(); + type_text(&mut editor, "iab"); + let steps = editor + .active + .view + .pending_edit + .as_ref() + .unwrap() + .steps + .len(); + assert_eq!( + editor.handle(Input::Ctrl('n')), + Some(Invocation { + plugin: "core".into(), + command: "lsp.completion".into(), + args: String::new(), + }) + ); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!( + editor + .active + .view + .pending_edit + .as_ref() + .unwrap() + .steps + .len(), + steps + ); + assert!(editor.active.undo.is_empty()); + editor + .show_completion(vec![ + completion_item((0, 0), (0, 2), "first"), + completion_item((0, 0), (0, 2), "second"), + ]) + .unwrap(); + editor.bind("insert", "Ctrl+n", None).unwrap(); + editor + .bind("insert", "Ctrl+x", Some("core.lsp.completion".into())) + .unwrap(); + editor.handle(Input::Ctrl('x')); + assert_eq!(editor.context().text, "second"); + editor.handle(Input::Ctrl('n')); + assert!(editor.completion().is_none()); + assert_eq!(editor.context().text, "second"); +} + +#[test] +fn completion_dot_rebases_only_final_choice_and_refuses_out_of_bounds_atomically() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("left ab".into())]) + .unwrap(); + editor.handle(Input::Char('A')); + editor + .show_completion(vec![ + completion_item((0, 5), (0, 7), "wrong"), + completion_item((0, 5), (0, 7), "final"), + ]) + .unwrap(); + editor.handle(Input::Ctrl('n')); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("different zz".into())]) + .unwrap(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "different final"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "different zz"); + editor.apply(vec![Effect::Replace("x".into())]).unwrap(); + let revision = editor.text_revision(); + let cursor = editor.cursor(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "x"); + assert_eq!(editor.cursor(), cursor); + assert_eq!(editor.text_revision(), revision); + assert!(editor.message().contains("outside")); + editor + .apply(vec![Effect::Replace("new yy".into())]) + .unwrap(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "new final"); +} + +#[test] +fn completion_additional_edits_translate_unicode_secondary_carets() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("e\u{301}x\n👩‍🚀z".into())]) + .unwrap(); + editor.handle(Input::Ctrl('e')); + let undo_carets = editor.carets(); + editor.handle(Input::Char('A')); + let original = editor.carets(); + let mut item = completion_item((1, 0), (1, 2), "界\n好"); + item.edits.push(CompletionEdit { + start: TextPosition { row: 0, column: 0 }, + end: TextPosition { row: 0, column: 1 }, + text: "prefix".into(), + }); + editor.show_completion(vec![item]).unwrap(); + assert_eq!(editor.context().text, "prefixx\n界\n好"); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 }); + assert_eq!( + editor.carets(), + [Cursor { row: 0, column: 7 }, Cursor { row: 2, column: 1 },] + ); + editor.handle(Input::Ctrl('n')); + assert_eq!(editor.context().text, "e\u{301}x\n👩‍🚀z"); + assert_eq!(editor.carets(), original); + editor.handle(Input::Ctrl('k')); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "e\u{301}x\n👩‍🚀z"); + assert_eq!(editor.carets(), undo_carets); +} + +#[test] +fn completion_validation_rejects_entire_set_before_any_mutation() { + let mut editor = editor(); + type_text(&mut editor, "iabc"); + let good = completion_item((0, 0), (0, 3), "valid"); + let mut bad_primary = good.clone(); + bad_primary.primary_edit = 1; + let mut overlap = good.clone(); + overlap.edits.push(overlap.edits[0].clone()); + for invalid in [ + bad_primary, + overlap, + completion_item((1, 0), (1, 0), "bad"), + completion_item((0, 4), (0, 4), "bad"), + completion_item((0, 2), (0, 1), "bad"), + completion_item((0, 0), (0, 3), "\0"), + completion_item((0, 0), (0, 3), &"x".repeat(MAX_EDIT_BYTES + 1)), + ] { + let revision = editor.text_revision(); + let steps = editor + .active + .view + .pending_edit + .as_ref() + .unwrap() + .steps + .len(); + assert!(editor.show_completion(vec![good.clone(), invalid]).is_err()); + assert_eq!(editor.context().text, "abc"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); + assert_eq!(editor.text_revision(), revision); + assert_eq!( + editor + .active + .view + .pending_edit + .as_ref() + .unwrap() + .steps + .len(), + steps + ); + assert!(editor.completion().is_none()); + assert!(editor.active.undo.is_empty()); + } + let revision = editor.text_revision(); + editor.show_completion(Vec::new()).unwrap(); + assert_eq!(editor.context().text, "abc"); + assert_eq!(editor.text_revision(), revision); + assert_eq!(editor.mode(), Mode::Insert); + assert!(editor.completion().is_none()); + assert_eq!(editor.message(), "No completions"); +} + +#[test] +fn completion_overlay_command_buffer_and_paste_boundaries_commit_displayed_choice() { + for boundary in ["palette", "startup", "command", "paste", "rejected_paste"] { + let mut editor = editor(); + type_text(&mut editor, "iab"); + editor + .show_completion(vec![completion_item((0, 0), (0, 2), "chosen")]) + .unwrap(); + match boundary { + "palette" => { + editor.handle(Input::Ctrl('p')); + } + "startup" => editor.show_startup_diagnostic("notice".into()), + "command" => { + editor.execute("plugins"); + } + "paste" => { + editor.handle(Input::Paste("!".into())); + } + _ => { + editor.handle(Input::Paste("\0".into())); + } + } + assert!(editor.completion().is_none()); + assert_eq!( + editor.context().text, + if boundary == "paste" { + "chosen!" + } else { + "chosen" + } + ); + if boundary == "palette" || boundary == "startup" { + editor.handle(Input::Escape); + } + if editor.mode() == Mode::Insert { + editor.handle(Input::Escape); + } + if boundary == "paste" { + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "chosen"); + } + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + } + let directory = Directory::new(); + let other = directory.path("other"); + fs::write(&other, "other").unwrap(); + let mut editor = editor(); + type_text(&mut editor, "iab"); + editor + .show_completion(vec![completion_item((0, 0), (0, 2), "chosen")]) + .unwrap(); + editor.execute(&format!("edit {}", other.display())); + assert!(editor.completion().is_none()); + editor.execute("buffer 1"); + assert_eq!(editor.context().text, "chosen"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); +} +#[test] +fn completion_dot_additional_edits_require_compatible_context_and_replay_atomically() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("use\nab".into())]) + .unwrap(); + type_text(&mut editor, "jA"); + let mut item = completion_item((1, 0), (1, 2), "done"); + item.edits.push(CompletionEdit { + start: TextPosition { row: 0, column: 0 }, + end: TextPosition { row: 0, column: 3 }, + text: "import".into(), + }); + editor.show_completion(vec![item]).unwrap(); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("use\nzz".into())]) + .unwrap(); + type_text(&mut editor, "j."); + assert_eq!(editor.context().text, "import\ndone"); + editor + .apply(vec![Effect::Replace("bad\nzz".into())]) + .unwrap(); + type_text(&mut editor, "j"); + let before = editor.context().text; + let carets = editor.carets(); + let revision = editor.text_revision(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, before); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.text_revision(), revision); + assert!(editor.message().contains("context changed")); +} + +fn snippet_item( + start: (usize, usize), + end: (usize, usize), + text: &str, + stops: Vec<(u32, Vec>)>, +) -> CompletionItem { + let mut item = completion_item(start, end, text); + item.snippet = Some(crate::plugin::CompletionSnippet { + tabstops: stops + .into_iter() + .map(|(index, ranges)| SnippetTabstop { + index, + ancestors: vec![Vec::new(); ranges.len()], + ranges, + }) + .collect(), + }); + item +} + +fn nested_stop(mut item: CompletionItem, index: u32, ancestors: Vec>) -> CompletionItem { + item.snippet + .as_mut() + .unwrap() + .tabstops + .iter_mut() + .find(|stop| stop.index == index) + .unwrap() + .ancestors = ancestors; + item +} + +#[test] +fn snippet_arguments_navigation_undo_redo_and_dot_use_resolved_text() { + let directory = Directory::new(); + let mut editor = editor(); + editor.set_tab_spaces(NonZeroU8::new(2), BTreeMap::new()); + type_text(&mut editor, "ica"); + editor + .show_completion(vec![snippet_item( + (0, 0), + (0, 2), + "call(arg, count)", + vec![(1, vec![5..8]), (2, vec![10..15]), (0, vec![16..16])], + )]) + .unwrap(); + assert_eq!(editor.context().text, "call(arg, count)"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); + assert_eq!( + editor.snippet_selections(), + [Selection { + start: Cursor { row: 0, column: 5 }, + end: Cursor { row: 0, column: 8 }, + }] + ); + type_text(&mut editor, "item"); + assert!(editor.completion().is_none()); + assert_eq!(editor.context().text, "call(item, count)"); + editor.handle(Input::Char('\t')); + type_text(&mut editor, "3"); + editor.handle(Input::BackTab); + type_text(&mut editor, "foo"); + editor.handle(Input::Char('\t')); + type_text(&mut editor, "5"); + editor.handle(Input::Char('\t')); + assert!(editor.snippet_selections().is_empty()); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 12 }); + type_text(&mut editor, ";\n\ttail"); + editor.handle(Input::Escape); + let resolved = "call(foo, 5);\n tail"; + let caret = editor.cursor(); + assert_eq!(editor.context().text, resolved); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, resolved); + assert_eq!(editor.cursor(), caret); + editor.execute(&format!("edit {}", directory.path("repeat").display())); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, resolved); + assert_eq!(editor.cursor(), caret); + assert!(editor.completion().is_none()); + assert!(editor.snippet_selections().is_empty()); +} + +#[test] +fn snippet_empty_stops_unicode_multiline_and_mirrors_track_argument_edits() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + editor + .show_completion(vec![snippet_item( + (0, 0), + (0, 0), + "f(, 😀,\ne\u{301}, 😀)", + vec![ + (1, vec![2..2]), + (2, vec![4..8, 15..19]), + (3, vec![10..13]), + (0, vec![20..20]), + ], + )]) + .unwrap(); + assert_eq!( + editor.snippet_selections()[0].start, + editor.snippet_selections()[0].end + ); + type_text(&mut editor, "中"); + editor.handle(Input::Char('\t')); + type_text(&mut editor, "🦀x"); + editor.handle(Input::Backspace); + assert_eq!(editor.context().text, "f(中, 🦀,\ne\u{301}, 🦀)"); + assert_eq!(editor.snippet_selections().len(), 2); + editor.handle(Input::Char('\t')); + type_text(&mut editor, "a\u{301}"); + assert_eq!(editor.context().text, "f(中, 🦀,\na\u{301}, 🦀)"); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); + editor.handle(Input::BackTab); + editor.handle(Input::Delete); + assert_eq!(editor.context().text, "f(中, ,\na\u{301}, )"); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Normal); + assert!(editor.snippet_selections().is_empty()); +} + +#[test] +fn modified_backspace_snippets_stay_inside_arguments_and_update_mirrors() { + let mut editor = editor(); + editor + .bind( + "insert", + "alt+backspace", + Some("core.backspace_word".into()), + ) + .unwrap(); + editor + .bind( + "insert", + "cmd+backspace", + Some("core.backspace_line".into()), + ) + .unwrap(); + editor.handle(Input::Char('i')); + editor + .show_completion(vec![snippet_item( + (0, 0), + (0, 0), + "f(one, one)", + vec![(1, vec![2..5, 7..10]), (0, vec![11..11])], + )]) + .unwrap(); + type_text(&mut editor, "alpha βeta"); + editor.handle(Input::AltBackspace); + assert_eq!(editor.context().text, "f(alpha , alpha )"); + editor.handle(Input::SuperBackspace); + assert_eq!(editor.context().text, "f(, )"); + editor.handle(Input::SuperBackspace); + editor.handle(Input::AltBackspace); + assert_eq!(editor.context().text, "f(, )"); + type_text(&mut editor, "keep"); + assert_eq!(editor.context().text, "f(keep, keep)"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); +} + +#[test] +fn snippet_nested_mirrors_update_parents_and_replacing_parent_retires_children() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + editor + .show_completion(vec![nested_stop( + snippet_item( + (0, 0), + (0, 0), + "f(outer inner, outer inner, last)", + vec![ + (1, vec![2..13, 15..26]), + (2, vec![8..13, 21..26]), + (3, vec![28..32]), + (0, vec![33..33]), + ], + ), + 2, + vec![vec![1]; 2], + )]) + .unwrap(); + editor.handle(Input::Char('\t')); + type_text(&mut editor, "word"); + assert_eq!(editor.context().text, "f(outer word, outer word, last)"); + editor.handle(Input::BackTab); + type_text(&mut editor, "body"); + editor.handle(Input::Char('\t')); + type_text(&mut editor, "end"); + editor.handle(Input::Char('\t')); + assert_eq!(editor.context().text, "f(body, body, end)"); + assert!(editor.snippet_selections().is_empty()); + + let mut editor = self::editor(); + editor.handle(Input::Char('i')); + editor + .show_completion(vec![nested_stop( + snippet_item( + (0, 0), + (0, 0), + "f(foo, last)", + vec![ + (1, vec![2..5]), + (2, vec![2..5]), + (3, vec![7..11]), + (0, vec![12..12]), + ], + ), + 2, + vec![vec![1]], + )]) + .unwrap(); + type_text(&mut editor, "value"); + editor.handle(Input::Char('\t')); + type_text(&mut editor, "end"); + assert_eq!(editor.context().text, "f(value, end)"); +} + +#[test] +fn snippet_cycling_restores_carets_and_argument_edits_never_broadcast_to_secondaries() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("ab\nzz".into())]) + .unwrap(); + editor.handle(Input::Ctrl('e')); + let undo_carets = editor.carets(); + editor.handle(Input::Char('A')); + let original = editor.carets(); + let mut item = snippet_item( + (1, 0), + (1, 2), + "f(arg)", + vec![(1, vec![2..5]), (0, vec![6..6])], + ); + item.edits.push(CompletionEdit { + start: TextPosition { row: 0, column: 0 }, + end: TextPosition { row: 0, column: 1 }, + text: "prefix".into(), + }); + editor.show_completion(vec![item]).unwrap(); + assert_eq!( + editor.carets(), + [Cursor { row: 0, column: 7 }, Cursor { row: 1, column: 2 },] + ); + editor.handle(Input::Ctrl('n')); + assert_eq!(editor.context().text, "ab\nzz"); + assert_eq!(editor.carets(), original); + assert!(editor.snippet_selections().is_empty()); + editor.handle(Input::Ctrl('k')); + type_text(&mut editor, "long"); + assert_eq!(editor.context().text, "prefixb\nf(long)"); + assert_eq!(editor.carets()[0], Cursor { row: 0, column: 7 }); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "ab\nzz"); + assert_eq!(editor.carets(), undo_carets); +} + +#[test] +fn snippet_keys_respect_disabled_remapped_bindings_and_tab_remains_literal_outside() { + let mut editor = editor(); + type_text(&mut editor, "i\t"); + assert_eq!(editor.context().text, "\t"); + editor.bind("insert", "Tab", None).unwrap(); + editor + .show_completion(vec![snippet_item( + (0, 0), + (0, 1), + "f(one, two)", + vec![(1, vec![2..5]), (2, vec![7..10]), (0, vec![11..11])], + )]) + .unwrap(); + editor.handle(Input::Char('\t')); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); + editor + .bind("insert", "Ctrl+f", Some("core.snippet.next".into())) + .unwrap(); + editor + .bind("insert", "Shift+Tab", Some("core.snippet.previous".into())) + .unwrap(); + editor.handle(Input::Ctrl('f')); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 7 }); + editor.handle(Input::BackTab); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); + editor + .bind("insert", "Tab", Some("core.right".into())) + .unwrap(); + editor.handle(Input::Char('\t')); + assert!(editor.snippet_selections().is_empty()); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); + type_text(&mut editor, "x"); + assert_eq!(editor.context().text, "f(oxne, two)"); +} + +#[test] +fn snippet_validation_is_atomic_and_newline_offsets_are_normalized() { + let mut editor = editor(); + type_text(&mut editor, "iab"); + for stops in [ + vec![(1, vec![2..3]), (0, vec![8..8])], + vec![(2, vec![1..5]), (1, vec![5..7]), (0, vec![8..8])], + vec![(1, vec![1..6]), (2, vec![5..7]), (0, vec![8..8])], + vec![(1, vec![1..5, 5..7]), (0, vec![8..8])], + vec![(1, vec![1..5])], + ] { + let before = editor.context().text; + let carets = editor.carets(); + let item = snippet_item((0, 0), (0, 2), "(😀ab)", stops); + assert!(editor.show_completion(vec![item]).is_err()); + assert_eq!(editor.context().text, before); + assert_eq!(editor.carets(), carets); + assert!(editor.completion().is_none()); + } + let before = editor.context().text; + let carets = editor.carets(); + assert!( + editor + .show_completion(vec![snippet_item( + (0, 0), + (0, 2), + "f(\r\narg)", + vec![(1, vec![3..6]), (0, vec![7..7])], + )]) + .is_err() + ); + assert_eq!(editor.context().text, before); + assert_eq!(editor.carets(), carets); + editor + .show_completion(vec![snippet_item( + (0, 0), + (0, 2), + "f(\narg)", + vec![(1, vec![3..6]), (0, vec![7..7])], + )]) + .unwrap(); + assert_eq!(editor.context().text, "f(\narg)"); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + type_text(&mut editor, "界"); + assert_eq!(editor.context().text, "f(\n界)"); +} + +#[test] +fn snippet_motion_overlay_command_plugin_and_paste_boundaries_end_navigation() { + for boundary in [ + "motion", + "overlay", + "command", + "plugin", + "paste", + "invalid_paste", + ] { + let mut editor = editor(); + editor.handle(Input::Char('i')); + editor + .show_completion(vec![snippet_item( + (0, 0), + (0, 0), + "f(arg)", + vec![(1, vec![2..5]), (0, vec![6..6])], + )]) + .unwrap(); + match boundary { + "motion" => { + editor.handle(Input::Right); + } + "overlay" => { + editor.handle(Input::Ctrl('p')); + } + "command" => { + editor.execute("plugins"); + } + "plugin" => { + editor + .apply(vec![Effect::Replace("replacement".into())]) + .unwrap(); + } + "paste" => { + editor.handle(Input::Paste("literal".into())); + } + "invalid_paste" => { + editor.handle(Input::Paste("\0".into())); + } + _ => unreachable!(), + } + assert!(editor.snippet_selections().is_empty(), "{boundary}"); + assert!(editor.completion().is_none(), "{boundary}"); + } +} + +#[test] +fn snippet_final_caret_in_middle_replays_before_later_ordinary_typing() { + let directory = Directory::new(); + let mut editor = editor(); + editor.handle(Input::Char('i')); + editor + .show_completion(vec![snippet_item( + (0, 0), + (0, 0), + "f(arg) tail", + vec![(1, vec![2..5]), (0, vec![6..6])], + )]) + .unwrap(); + type_text(&mut editor, "X"); + editor.handle(Input::Char('\t')); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); + type_text(&mut editor, "!"); + editor.handle(Input::Escape); + let caret = editor.cursor(); + assert_eq!(editor.context().text, "f(X)! tail"); + editor.execute(&format!("edit {}", directory.path("repeat").display())); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "f(X)! tail"); + assert_eq!(editor.cursor(), caret); +} + +#[test] +fn snippet_adjacent_empty_stops_remain_independent_when_typed() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + editor + .show_completion(vec![snippet_item( + (0, 0), + (0, 0), + "f()", + vec![(1, vec![2..2]), (2, vec![2..2]), (0, vec![3..3])], + )]) + .unwrap(); + type_text(&mut editor, "one"); + editor.handle(Input::Char('\t')); + type_text(&mut editor, "two"); + editor.handle(Input::BackTab); + type_text(&mut editor, "first"); + editor.handle(Input::Char('\t')); + type_text(&mut editor, "second"); + assert_eq!(editor.context().text, "f(firstsecond)"); +} + +#[test] +fn snippet_ancestry_distinguishes_nested_empty_boundary_from_adjacent_root() { + for nested in [false, true] { + let mut editor = editor(); + editor.handle(Input::Char('i')); + let item = snippet_item( + (0, 0), + (0, 0), + "f(foo)", + vec![(1, vec![2..5]), (2, vec![5..5]), (0, vec![6..6])], + ); + let item = if nested { + nested_stop(item, 2, vec![vec![1]]) + } else { + item + }; + editor.show_completion(vec![item]).unwrap(); + type_text(&mut editor, "X"); + editor.handle(Input::Char('\t')); + if nested { + assert!(editor.snippet_selections().is_empty()); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); + } else { + assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); + assert_eq!(editor.snippet_selections().len(), 1); + type_text(&mut editor, "Y"); + editor.handle(Input::BackTab); + type_text(&mut editor, "Z"); + assert_eq!(editor.context().text, "f(ZY)"); + } + } + let mut editor = editor(); + editor.handle(Input::Char('i')); + editor + .show_completion(vec![nested_stop( + snippet_item( + (0, 0), + (0, 0), + "f(foo)", + vec![(1, vec![2..5]), (2, vec![5..5]), (0, vec![6..6])], + ), + 2, + vec![vec![1]], + )]) + .unwrap(); + editor.handle(Input::Char('\t')); + type_text(&mut editor, "X"); + editor.handle(Input::BackTab); + type_text(&mut editor, "Y"); + editor.handle(Input::Char('\t')); + assert_eq!(editor.context().text, "f(Y)"); + assert!(editor.snippet_selections().is_empty()); +} + +#[test] +fn snippet_replacing_parent_preserves_unrelated_child_mirror_occurrences() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + editor + .show_completion(vec![nested_stop( + snippet_item( + (0, 0), + (0, 0), + "f(foo, foo)", + vec![(1, vec![2..5]), (2, vec![2..5, 7..10]), (0, vec![11..11])], + ), + 2, + vec![vec![1], vec![]], + )]) + .unwrap(); + type_text(&mut editor, "X"); + editor.handle(Input::Char('\t')); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); + type_text(&mut editor, "Y"); + assert_eq!(editor.context().text, "f(X, Y)"); +} + +#[test] +fn snippet_coincident_empty_mirror_multiplicity_survives_typing_and_deletion() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + editor + .show_completion(vec![snippet_item( + (0, 0), + (0, 0), + "f()", + vec![(1, vec![2..2, 2..2]), (0, vec![3..3])], + )]) + .unwrap(); + type_text(&mut editor, "xy"); + assert_eq!(editor.context().text, "f(xyxy)"); + editor.handle(Input::Backspace); + assert_eq!(editor.context().text, "f(xx)"); + editor.handle(Input::Backspace); + assert_eq!(editor.context().text, "f()"); + type_text(&mut editor, "z"); + assert_eq!(editor.context().text, "f(zz)"); + editor.handle(Input::Char('\t')); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 }); + assert!(editor.snippet_selections().is_empty()); + editor.handle(Input::Char('\t')); + assert_eq!(editor.context().text, "f(zz)\t"); +} + +#[test] +fn snippet_ancestry_validation_rejects_misalignment_unknown_ids_cycles_and_false_containment() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + let item = || { + snippet_item( + (0, 0), + (0, 0), + "f(foo, bar)", + vec![(1, vec![2..5]), (2, vec![7..10]), (0, vec![11..11])], + ) + }; + let mut misaligned = item(); + misaligned.snippet.as_mut().unwrap().tabstops[0] + .ancestors + .clear(); + let unknown = nested_stop(item(), 2, vec![vec![999]]); + let containment = nested_stop(item(), 2, vec![vec![1]]); + let cycle = nested_stop(nested_stop(item(), 1, vec![vec![2]]), 2, vec![vec![1]]); + for candidate in [misaligned, unknown, containment, cycle] { + assert!(editor.show_completion(vec![candidate]).is_err()); + assert_eq!(editor.context().text, ""); + assert_eq!(editor.cursor(), Cursor::default()); + assert!(editor.completion().is_none()); + } +} diff --git a/tests/unit/files.rs b/tests/unit/files.rs new file mode 100644 index 0000000..0384ff1 --- /dev/null +++ b/tests/unit/files.rs @@ -0,0 +1,982 @@ +use super::*; + +#[test] +fn virtual_catalog_compacts_and_retains_file_directory_collisions() { + let mut picker = FilePicker::from_paths( + PathBuf::new(), + ["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"] + .map(PathBuf::from) + .into_iter() + .enumerate(), + ); + assert_eq!( + picker + .entries() + .map(|row| row.label.to_owned()) + .collect::>(), + ["src/deep", "x", "x"] + ); + picker.reveal_path(Path::new("x")); + assert!(!picker.entries().nth(picker.selected).unwrap().is_dir); + assert_eq!(picker.accept(), Some(PathBuf::from("x"))); + picker.reveal_path(Path::new("x/y")); + assert_eq!(picker.accept(), Some(PathBuf::from("x/y"))); + picker.reveal_path(Path::new("src/deep/deleted.rs")); + assert_eq!(picker.accept(), Some(PathBuf::from("src/deep/deleted.rs"))); + assert_eq!(picker.entries().filter(|row| !row.is_dir).count(), 3); + let expected_index = |row: &FileRow<'_>| { + if row.is_dir { + None + } else { + Some(match row.path.to_str().unwrap() { + "x" => 0, + "x/y" => 1, + "src/deep/deleted.rs" => 2, + path => panic!("unexpected path {path}"), + }) + } + }; + for view in [FileView::List, FileView::Tree] { + picker.set_view(view); + for query in ["", "x", "deleted"] { + picker.query = query.into(); + picker.refilter(); + let rows: Vec<_> = picker.entries().collect(); + assert!(!rows.is_empty()); + for row in rows { + assert_eq!(row.catalog_index, expected_index(&row)); + assert!(row.status.is_none() && row.old_path.is_none()); + } + } + } +} +use std::sync::atomic::{AtomicU64, Ordering}; + +static TEMP_ID: AtomicU64 = AtomicU64::new(0); +struct Directory(PathBuf); +impl Directory { + fn new() -> Self { + let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("vamp-files-test-{}-{id}", std::process::id())); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn put(&self, name: &str, text: &str) { + let path = self.0.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); + } + fn scan(&self) -> FilePicker { + FilePicker::scan(self.0.clone(), FilePresentation::Floating, FileView::List).unwrap() + } +} +impl Drop for Directory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn paths(picker: &FilePicker) -> Vec { + picker + .entries() + .map(|entry| entry.path.to_owned()) + .collect() +} + +#[test] +fn fuzzy_path_tokens_preserve_tree_context_selection_and_open_paths() { + let directory = Directory::new(); + let names = [ + "src/fuzzy_parser.rs", + "src/über_parser.rs", + "src/other.rs", + "tests/fuzzy_parser.rs", + ]; + for name in names { + directory.put(name, ""); + } + for mut picker in [ + directory.scan(), + FilePicker::from_paths( + directory.0.clone(), + names.map(PathBuf::from).into_iter().enumerate(), + ), + ] { + for view in [FileView::Tree, FileView::List] { + picker.set_view(view); + for (query, expected) in [ + ("sr/fp RS", "src/fuzzy_parser.rs"), + ("ÜPR SRC", "src/über_parser.rs"), + ] { + picker.query = query.into(); + picker.refilter(); + assert_eq!( + picker + .entries() + .filter(|row| !row.is_dir) + .map(|row| row.path) + .collect::>(), + [Path::new(expected)] + ); + assert_eq!(selected_path(&picker), Path::new(expected)); + assert_eq!(picker.accept(), Some(directory.0.join(expected))); + if view == FileView::Tree { + assert_eq!(paths(&picker), [Path::new("src"), Path::new(expected)]); + } + } + picker.query = "rs/ü".into(); + picker.refilter(); + assert!(picker.entries().next().is_none()); + picker.clear_filter(); + assert_eq!(selected_path(&picker), Path::new("src/über_parser.rs")); + } + } +} + +#[test] +fn scanned_files_and_directories_have_no_catalog_index() { + let directory = Directory::new(); + directory.put("nested/file.txt", ""); + let mut picker = directory.scan(); + for view in [FileView::List, FileView::Tree] { + picker.set_view(view); + picker.reveal_path(Path::new("nested/file.txt")); + assert!(picker.entries().all(|row| row.catalog_index.is_none())); + assert!( + picker + .entries() + .all(|row| row.status.is_none() && row.old_path.is_none()) + ); + } +} + +fn repository_change(path: &str, status: FileStatus, old_path: Option<&str>) -> WorkingFileChange { + WorkingFileChange { + path: path.into(), + status, + old_path: old_path.map(PathBuf::from), + } +} + +#[test] +fn repository_metadata_keeps_clean_files_selection_and_filtered_views() { + let directory = Directory::new(); + for name in ["src/clean.rs", "src/modified.rs", "added.rs", "renamed.rs"] { + directory.put(name, ""); + } + let mut picker = directory.scan(); + picker.presentation = FilePresentation::Docked; + picker.set_view(FileView::Tree); + picker.reveal_path(Path::new("src/clean.rs")); + picker.query = "src".into(); + picker.refilter(); + picker.focused = false; + let changes = vec![ + repository_change("src/modified.rs", FileStatus::Modified, None), + repository_change("added.rs", FileStatus::Added, None), + repository_change("renamed.rs", FileStatus::Renamed, Some("old.rs")), + repository_change("vanished/deep/deleted.rs", FileStatus::Removed, None), + ]; + assert!(picker.set_repository_changes(changes.clone())); + assert!(!picker.set_repository_changes(changes)); + assert_eq!(selected_path(&picker), Path::new("src/clean.rs")); + assert_eq!(picker.query, "src"); + assert!(!picker.focused); + assert_eq!(picker.presentation, FilePresentation::Docked); + for view in [FileView::List, FileView::Tree] { + picker.set_view(view); + for query in ["", "src", "modified.rs", "deleted.rs"] { + picker.query = query.into(); + picker.refilter(); + let rows: Vec<_> = picker.entries().collect(); + assert!(!rows.is_empty()); + for row in rows { + assert!(row.catalog_index.is_none()); + if row.is_dir || row.path == Path::new("src/clean.rs") { + assert!(row.status.is_none() && row.old_path.is_none()); + } else { + let status = match row.path.to_str().unwrap() { + "src/modified.rs" => FileStatus::Modified, + "added.rs" => FileStatus::Added, + "renamed.rs" => FileStatus::Renamed, + "vanished/deep/deleted.rs" => FileStatus::Removed, + other => panic!("unexpected {other}"), + }; + assert_eq!(row.status, Some(status)); + assert_eq!( + row.old_path, + (status == FileStatus::Renamed).then(|| Path::new("old.rs")) + ); + } + } + if query.is_empty() || query == "src" { + assert!( + picker + .entries() + .any(|row| row.path == Path::new("src/clean.rs")) + ); + } + } + } + picker.set_view(FileView::List); + picker.clear_filter(); + picker.reveal_path(Path::new("src/clean.rs")); + assert!(picker.set_repository_changes(Vec::new())); + assert_eq!(selected_path(&picker), Path::new("src/clean.rs")); + assert_eq!(picker.entries().len(), 4); + assert!(picker.nodes.iter().all(|node| !node.virtual_entry)); + assert!( + picker + .entries() + .all(|row| row.status.is_none() && row.old_path.is_none()) + ); + assert!(!picker.set_repository_changes(Vec::new())); +} + +#[test] +fn deleted_catalog_leaves_survive_refresh_and_restored_files_survive_clear() { + let directory = Directory::new(); + directory.put("keep.txt", ""); + let mut picker = directory.scan(); + let deleted = Path::new("lost/deep/deleted.txt"); + picker.set_repository_changes(vec![repository_change( + "lost/deep/deleted.txt", + FileStatus::Removed, + None, + )]); + picker.set_view(FileView::Tree); + assert_eq!( + picker.entries().find(|row| row.is_dir).unwrap().label, + "lost/deep" + ); + picker.reveal_path(deleted); + picker.query = "deleted".into(); + picker.refilter(); + picker.start_search(); + picker.focused = false; + picker.refresh(); + assert_eq!(selected_path(&picker), deleted); + assert!(picker.searching && !picker.focused); + assert_eq!(picker.query, "deleted"); + assert_eq!(picker.accept(), Some(directory.0.join(deleted))); + assert!(!directory.0.join(deleted).exists()); + assert_eq!( + picker.entries().find(|row| !row.is_dir).unwrap().status, + Some(FileStatus::Removed) + ); + directory.put("lost/deep/deleted.txt", "restored"); + assert!(picker.set_repository_changes(vec![repository_change( + "lost/deep/deleted.txt", + FileStatus::Removed, + None, + )])); + picker.refresh(); + assert_eq!(selected_path(&picker), deleted); + assert!(picker.nodes.iter().all(|node| !node.virtual_entry)); + assert!(picker.set_repository_changes(Vec::new())); + assert_eq!(selected_path(&picker), deleted); + assert_eq!(picker.accept(), Some(directory.0.join(deleted))); + assert_eq!( + fs::read_to_string(directory.0.join(deleted)).unwrap(), + "restored" + ); + assert!(picker.entries().all(|row| row.status.is_none())); + fs::remove_dir_all(directory.0.join("lost")).unwrap(); + picker.refresh(); + picker.set_repository_changes(vec![repository_change( + "lost/deep/deleted.txt", + FileStatus::Removed, + None, + )]); + picker.reveal_path(deleted); + picker.set_repository_changes(Vec::new()); + assert!(picker.entries().len() == 0); + picker.clear_filter(); + assert_eq!(paths(&picker), [PathBuf::from("keep.txt")]); + assert!(picker.nodes.iter().all(|node| !node.virtual_entry)); +} + +#[test] +fn repository_metadata_cannot_introduce_unsafe_or_existing_excluded_paths() { + let directory = Directory::new(); + directory.put(".gitignore", "ignored/\n"); + directory.put("ignored/hidden.txt", ""); + directory.put("directory/keep.txt", ""); + let mut picker = directory.scan(); + let mut changes: Vec<_> = [ + "", + ".", + "../outside.txt", + "directory/../../outside.txt", + ".git/secret", + "nested/.jj/secret", + "/outside.txt", + "directory", + "ignored/hidden.txt", + ] + .into_iter() + .map(|path| repository_change(path, FileStatus::Removed, None)) + .collect(); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&directory.0, directory.0.join("link")).unwrap(); + changes.push(repository_change( + "link/missing.txt", + FileStatus::Removed, + None, + )); + } + changes.push(repository_change("missing.txt", FileStatus::Removed, None)); + picker.set_repository_changes(changes); + assert_eq!( + paths(&picker), + [".gitignore", "directory/keep.txt", "missing.txt"].map(PathBuf::from) + ); + assert_eq!( + picker.entries().filter(|row| row.status.is_some()).count(), + 1 + ); + assert_eq!( + picker.entries().last().unwrap().status, + Some(FileStatus::Removed) + ); + assert!(!directory.0.join("missing.txt").exists()); +} + +#[test] +fn nested_ignore_negation_hidden_files_and_vcs_metadata() { + let directory = Directory::new(); + directory.put(".gitignore", "*.log\nignored/\n"); + directory.put("nested/.gitignore", "!keep.log\nlocal.txt\n"); + for name in [ + "gone.log", + "nested/gone.log", + "nested/keep.log", + "nested/local.txt", + "nested/ok.txt", + "ignored/hidden.txt", + ".hidden", + ".git/objects/secret", + "nested/.git/secret", + ".jj/secret", + "nested/.jj/secret", + ] { + directory.put(name, "text"); + } + let picker = directory.scan(); + assert_eq!( + paths(&picker), + [ + ".gitignore", + ".hidden", + "nested/.gitignore", + "nested/keep.log", + "nested/ok.txt" + ] + .map(PathBuf::from) + ); + assert!(picker.error.is_none()); +} + +#[test] +fn literal_unicode_tokens_grapheme_deletion_and_navigation() { + let directory = Directory::new(); + for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] { + directory.put(name, "text"); + } + let mut picker = directory.scan(); + picker.navigate(false); + assert_eq!(picker.selected, 2); + picker.navigate(true); + assert_eq!(picker.selected, 0); + picker.query = "界 SPACE [E\u{301}]".into(); + picker.refilter(); + assert_eq!(paths(&picker), [PathBuf::from("a space 界[e\u{301}].txt")]); + picker.query = "e\u{301}👩‍💻".into(); + assert!(picker.handle_filter_input(&crate::editor::Input::Backspace)); + assert_eq!(picker.query, "e\u{301}"); + assert!(picker.handle_filter_input(&crate::editor::Input::Backspace)); + assert_eq!(picker.query, ""); + assert!(picker.handle_filter_input(&crate::editor::Input::Backspace)); + assert_eq!(picker.entries().len(), 3); + picker.query = "no match".into(); + picker.refilter(); + for forward in [true, false] { + picker.navigate(forward); + assert_eq!(picker.selected, 0); + assert_eq!(picker.entries().len(), 0); + } +} + +#[test] +fn word_and_line_deletion_refilter_only_editable_queries() { + use crate::editor::Input; + + for view in [FileView::List, FileView::Tree] { + let mut picker = FilePicker::from_paths( + PathBuf::new(), + ["alpha one.txt", "alpha two.txt", "beta.txt"] + .map(PathBuf::from) + .into_iter() + .enumerate(), + ); + picker.set_view(view); + picker.query = "alpha missing".into(); + picker.refilter(); + assert_eq!(picker.entries().len(), 0); + if view == FileView::Tree { + for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] { + assert!(!picker.handle_filter_input(&input)); + assert_eq!(picker.query, "alpha missing"); + } + picker.start_search(); + } + assert!(picker.handle_filter_input(&Input::AltBackspace)); + assert_eq!(picker.query, "alpha "); + assert_eq!(picker.entries().len(), 2); + assert!(picker.handle_filter_input(&Input::SuperBackspace)); + assert_eq!(picker.query, ""); + assert_eq!(picker.entries().len(), 3); + for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] { + assert!(picker.handle_filter_input(&input)); + assert_eq!(picker.entries().len(), 3); + } + } +} + +#[test] +fn empty_scan_refresh_and_failure_preserve_reachable_state() { + let directory = Directory::new(); + let mut picker = directory.scan(); + picker.navigate(false); + assert!(picker.handle_filter_input(&crate::editor::Input::Backspace)); + assert_eq!(picker.entries().len(), 0); + directory.put("new.txt", "text"); + picker.query = "new".into(); + picker.refresh(); + assert_eq!(picker.query, "new"); + assert_eq!(paths(&picker), [PathBuf::from("new.txt")]); + fs::remove_dir_all(&directory.0).unwrap(); + picker.refresh(); + assert_eq!(paths(&picker), [PathBuf::from("new.txt")]); + assert!(picker.error.as_ref().unwrap().contains("Refresh failed")); + assert!( + FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::Tree + ) + .is_err() + ); +} + +#[test] +fn malformed_ignore_warning_survives_query_and_navigation() { + let directory = Directory::new(); + directory.put(".gitignore", "[z-a]\n"); + directory.put("ok.txt", "text"); + let mut picker = directory.scan(); + assert!(picker.error.as_ref().unwrap().contains("Incomplete scan")); + picker.query = "ok".into(); + picker.refilter(); + assert_eq!(paths(&picker), [PathBuf::from("ok.txt")]); + picker.navigate(true); + assert!(picker.error.as_ref().unwrap().contains("Incomplete scan")); + directory.put(".gitignore", ""); + picker.refresh(); + assert!(picker.error.is_none()); +} + +#[test] +fn only_project_ignore_rules_apply_and_non_directory_roots_fail() { + let directory = Directory::new(); + directory.put(".gitignore", "*.txt\n"); + directory.put("project/visible.txt", "text"); + let root = directory.0.join("project"); + let picker = FilePicker::scan(root, FilePresentation::Floating, FileView::List).unwrap(); + assert_eq!(paths(&picker), [PathBuf::from("visible.txt")]); + assert!( + FilePicker::scan( + directory.0.join("project/visible.txt"), + FilePresentation::Docked, + FileView::Tree, + ) + .is_err() + ); +} + +#[cfg(unix)] +#[test] +fn symlink_loops_and_file_links_are_not_followed_and_raw_paths_survive() { + use std::os::unix::fs::symlink; + let directory = Directory::new(); + directory.put("real.txt", "text"); + symlink(&directory.0, directory.0.join("loop")).unwrap(); + symlink(directory.0.join("real.txt"), directory.0.join("alias.txt")).unwrap(); + let raw = PathBuf::from("raw界\n.txt"); + fs::write(directory.0.join(&raw), "text").unwrap(); + let picker = directory.scan(); + assert_eq!(picker.entries().len(), 2); + let entry = picker + .entries() + .find(|entry| entry.path == raw.as_path()) + .unwrap(); + assert!(!entry.label.chars().any(char::is_control)); + assert_eq!( + fs::read_to_string(directory.0.join(entry.path)).unwrap(), + "text" + ); +} + +fn tree(directory: &Directory) -> FilePicker { + FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::Tree, + ) + .unwrap() +} + +fn select(picker: &mut FilePicker, path: &str) { + let selected = picker + .entries() + .position(|row| row.path == Path::new(path)) + .unwrap(); + picker.selected = selected; +} + +fn selected_path(picker: &FilePicker) -> &Path { + picker.entries().nth(picker.selected).unwrap().path +} + +#[test] +fn tree_orders_folders_first_keeps_empty_dirs_and_compacts_only_directory_chains() { + let directory = Directory::new(); + directory.put("a.txt", ""); + directory.put("folder/only.txt", ""); + directory.put("chain/one/two/a.txt", ""); + directory.put("chain/one/two/z.txt", ""); + fs::create_dir(directory.0.join("empty")).unwrap(); + let mut picker = tree(&directory); + assert_eq!(picker.view, FileView::Tree); + assert!(!picker.searching); + assert_eq!( + picker + .entries() + .map(|row| (row.label, row.is_dir, row.depth)) + .collect::>(), + [ + ("chain/one/two", true, 0), + ("empty", true, 0), + ("folder", true, 0), + ("a.txt", false, 0) + ] + ); + assert_eq!(selected_path(&picker), Path::new("chain/one/two")); + picker.right(); + assert_eq!( + picker + .entries() + .map(|row| (row.label, row.depth)) + .collect::>(), + [ + ("chain/one/two", 0), + ("a.txt", 1), + ("z.txt", 1), + ("empty", 0), + ("folder", 0), + ("a.txt", 0) + ] + ); + picker.right(); + assert_eq!(selected_path(&picker), Path::new("chain/one/two/a.txt")); + let rows = paths(&picker); + picker.right(); + assert_eq!(paths(&picker), rows); + picker.left(); + assert_eq!(selected_path(&picker), Path::new("chain/one/two")); + picker.left(); + assert!(!picker.entries().next().unwrap().expanded); + picker.left(); + assert_eq!(picker.selected, 0); + select(&mut picker, "folder"); + assert!(picker.accept().is_none()); + assert_eq!( + picker.entries().nth(picker.selected + 1).unwrap().label, + "only.txt" + ); + picker.right(); + assert_eq!(picker.accept(), Some(directory.0.join("folder/only.txt"))); + select(&mut picker, "empty"); + picker.right(); + assert!(picker.entries().nth(picker.selected).unwrap().expanded); + picker.right(); + assert_eq!(selected_path(&picker), Path::new("empty")); +} + +#[test] +fn tree_search_reveals_ancestors_without_mutating_saved_expansion_or_compaction() { + let directory = Directory::new(); + directory.put("src/module/deep/needle.txt", ""); + directory.put("src/module/sibling.txt", ""); + directory.put("src/root.txt", ""); + directory.put("other/keep.txt", ""); + let mut picker = tree(&directory); + select(&mut picker, "other"); + picker.right(); + select(&mut picker, "src"); + let expanded = picker.expanded.clone(); + picker.start_search(); + picker.query = "NEEDLE".into(); + picker.refilter(); + assert_eq!( + picker + .entries() + .map(|row| (row.label, row.depth)) + .collect::>(), + [("src", 0), ("module", 1), ("deep", 2), ("needle.txt", 3)] + ); + assert_eq!( + selected_path(&picker), + Path::new("src/module/deep/needle.txt") + ); + assert_eq!(picker.expanded, expanded); + picker.left(); + let filtered = paths(&picker); + picker.finish_search(); + assert_eq!(picker.query, "NEEDLE"); + assert_eq!(paths(&picker), filtered); + assert_eq!(selected_path(&picker), Path::new("src/module/deep")); + assert_eq!(picker.expanded, expanded); + picker.start_search(); + picker.query = "deep".into(); + picker.refilter(); + select(&mut picker, "src/module/deep"); + assert!(picker.accept().is_none()); + assert!(!picker.searching); + assert_eq!(picker.query, "deep"); + assert_eq!(selected_path(&picker), Path::new("src/module/deep")); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + assert_eq!(picker.expanded, expanded); + picker.right(); + assert!(picker.entries().nth(picker.selected).unwrap().expanded); + assert_eq!(picker.query, "deep"); + + let chain = Directory::new(); + chain.put("one/two/three/a.txt", ""); + chain.put("one/two/three/b.txt", ""); + let mut picker = tree(&chain); + picker.start_search(); + picker.query = "a.txt".into(); + picker.refilter(); + assert_eq!( + picker.entries().map(|row| row.label).collect::>(), + ["one/two/three", "a.txt"] + ); +} + +#[test] +fn view_switches_restore_directory_choices_and_reveal_explicit_file_choices() { + let directory = Directory::new(); + directory.put("src/a.txt", ""); + directory.put("src/b.txt", ""); + directory.put("z.txt", ""); + let mut picker = tree(&directory); + picker.right(); + let open = paths(&picker); + picker.set_view(FileView::List); + assert_eq!( + paths(&picker), + ["src/a.txt", "src/b.txt", "z.txt"].map(PathBuf::from) + ); + picker.set_view(FileView::Tree); + assert_eq!(selected_path(&picker), Path::new("src")); + assert_eq!(paths(&picker), open); + picker.left(); + picker.toggle_view(); + picker.navigate(true); + assert_eq!(selected_path(&picker), Path::new("src/b.txt")); + picker.toggle_view(); + assert_eq!(selected_path(&picker), Path::new("src/b.txt")); + assert!(picker.entries().next().unwrap().expanded); + picker.left(); + picker.toggle_view(); + picker.toggle_view(); + assert_eq!(selected_path(&picker), Path::new("src")); +} + +#[test] +fn filtering_and_refresh_preserve_raw_selection_and_surviving_expansion() { + let directory = Directory::new(); + directory.put("src/a.txt", ""); + directory.put("src/b.txt", ""); + directory.put("z.txt", ""); + let mut picker = directory.scan(); + select(&mut picker, "src/b.txt"); + picker.query = "absent".into(); + picker.refilter(); + assert_eq!(picker.entries().len(), 0); + picker.query.clear(); + picker.refilter(); + assert_eq!(selected_path(&picker), Path::new("src/b.txt")); + picker.set_view(FileView::Tree); + assert_eq!(selected_path(&picker), Path::new("src/b.txt")); + fs::remove_file(directory.0.join("src/b.txt")).unwrap(); + directory.put("src/c.txt", ""); + picker.refresh(); + assert_eq!(selected_path(&picker), Path::new("src")); + assert!(picker.entries().next().unwrap().expanded); + assert!(paths(&picker).contains(&PathBuf::from("src/c.txt"))); + fs::remove_dir_all(directory.0.join("src")).unwrap(); + picker.refresh(); + assert_eq!(selected_path(&picker), Path::new("z.txt")); + assert!(picker.expanded.is_empty()); + picker.toggle_view(); + picker.toggle_view(); + assert_eq!(selected_path(&picker), Path::new("z.txt")); +} + +#[test] +fn tree_search_subfocus_and_expansion_survive_view_toggles_and_refresh() { + let directory = Directory::new(); + directory.put("src/deep/a.txt", ""); + directory.put("src/other.txt", ""); + let mut picker = tree(&directory); + picker.start_search(); + assert!(picker.searching); + assert_eq!(picker.entries().len(), 4); + assert!(picker.expanded.is_empty()); + picker.toggle_view(); + assert_eq!(picker.view, FileView::List); + picker.toggle_view(); + assert!(picker.searching); + assert!(picker.expanded.is_empty()); + picker.query = "a.txt".into(); + picker.refilter(); + assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); + picker.refresh(); + assert!(picker.searching); + assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); + let filtered = paths(&picker); + picker.finish_search(); + assert!(!picker.searching); + assert_eq!(picker.query, "a.txt"); + assert_eq!(paths(&picker), filtered); + assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); + assert!(picker.expanded.is_empty()); + picker.toggle_view(); + picker.refresh(); + picker.toggle_view(); + assert!(!picker.searching); + assert_eq!(picker.query, "a.txt"); + assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); + picker.start_search(); + assert!(picker.searching); + assert_eq!(picker.query, "a.txt"); + assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt")); +} + +#[test] +fn clearing_filter_preserves_mode_focus_current_path_and_empty_result_fallback() { + let directory = Directory::new(); + directory.put("src/deep/needle.txt", ""); + directory.put("src/other.txt", ""); + directory.put("other/keep.txt", ""); + for (view, searching) in [ + (FileView::Tree, false), + (FileView::Tree, true), + (FileView::List, false), + ] { + let mut picker = tree(&directory); + select(&mut picker, "other"); + picker.right(); + let expanded = picker.expanded.clone(); + picker.set_view(view); + if view == FileView::Tree { + picker.start_search(); + } + picker.query = "needle".into(); + picker.refilter(); + if !searching && view == FileView::Tree { + picker.finish_search(); + } + let expected = Path::new("src/deep/needle.txt"); + assert_eq!(selected_path(&picker), expected); + picker.clear_filter(); + assert!(picker.query.is_empty()); + assert_eq!(picker.searching, searching); + assert!(picker.focused); + assert_eq!(selected_path(&picker), expected); + assert!(picker.expanded.is_superset(&expanded)); + assert!(paths(&picker).contains(&PathBuf::from("other/keep.txt"))); + let rows = paths(&picker); + let expanded = picker.expanded.clone(); + picker.clear_filter(); + assert_eq!(paths(&picker), rows); + assert_eq!(picker.expanded, expanded); + assert_eq!(selected_path(&picker), expected); + assert_eq!(picker.searching, searching); + + picker.query = "absent".into(); + picker.refilter(); + picker.navigate(true); + picker.navigate(false); + assert!(picker.rows.is_empty()); + picker.refresh(); + assert!(picker.rows.is_empty()); + picker.clear_filter(); + assert_eq!(selected_path(&picker), expected); + assert_eq!(picker.searching, searching); + assert!(picker.focused); + } + + let empty = Directory::new(); + let mut picker = tree(&empty); + picker.start_search(); + picker.query = "absent".into(); + picker.refilter(); + picker.finish_search(); + picker.clear_filter(); + assert!(picker.rows.is_empty()); + assert_eq!(picker.selected, 0); + assert!(!picker.searching); + assert!(picker.focused); +} + +#[test] +fn filtered_navigation_toggles_context_folders_and_query_edits_reveal_matches() { + let directory = Directory::new(); + directory.put("src/deep/needle.txt", ""); + directory.put("src/other.txt", ""); + let mut picker = tree(&directory); + picker.start_search(); + picker.query = "needle".into(); + picker.refilter(); + let expanded = picker.expanded.clone(); + picker.left(); + assert_eq!(selected_path(&picker), Path::new("src/deep")); + picker.right(); + assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt")); + assert_eq!(picker.expanded, expanded); + picker.finish_search(); + picker.left(); + picker.left(); + assert_eq!(selected_path(&picker), Path::new("src/deep")); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + assert_eq!(picker.query, "needle"); + assert!(!paths(&picker).contains(&PathBuf::from("src/deep/needle.txt"))); + picker.start_search(); + picker.finish_search(); + assert_eq!(selected_path(&picker), Path::new("src/deep")); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + picker.right(); + assert!(picker.entries().nth(picker.selected).unwrap().expanded); + picker.right(); + assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt")); + picker.left(); + assert!(picker.accept().is_none()); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + assert!(picker.accept().is_none()); + assert!(picker.entries().nth(picker.selected).unwrap().expanded); + picker.start_search(); + assert!(picker.accept().is_none()); + assert!(!picker.searching); + assert_eq!(picker.query, "needle"); + assert_eq!(selected_path(&picker), Path::new("src/deep")); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + picker.start_search(); + picker.query.push_str(".txt"); + picker.refilter(); + assert!(picker.filter_collapsed.is_empty()); + assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt")); + picker.query.clear(); + picker.refilter(); + picker.finish_search(); + assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt")); +} + +#[test] +fn filtered_collapse_survives_refresh_compaction_and_view_switching_by_raw_path() { + let directory = Directory::new(); + directory.put("one/a.txt", ""); + directory.put("one/two/needle.txt", ""); + let mut picker = tree(&directory); + picker.start_search(); + picker.query = "needle".into(); + picker.refilter(); + picker.finish_search(); + picker.left(); + picker.left(); + picker.left(); + picker.left(); + assert_eq!(selected_path(&picker), Path::new("one")); + assert_eq!(paths(&picker), [PathBuf::from("one")]); + picker.toggle_view(); + assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt")); + picker.toggle_view(); + assert_eq!(paths(&picker), [PathBuf::from("one")]); + assert!(!picker.searching); + fs::remove_file(directory.0.join("one/a.txt")).unwrap(); + picker.focused = false; + picker.refresh(); + assert_eq!(selected_path(&picker), Path::new("one/two")); + assert_eq!(paths(&picker), [PathBuf::from("one/two")]); + assert!(!picker.searching && !picker.focused); + assert_eq!(picker.query, "needle"); + directory.put("one/a.txt", ""); + picker.refresh(); + assert_eq!(selected_path(&picker), Path::new("one")); + assert_eq!(paths(&picker), [PathBuf::from("one")]); + picker.right(); + picker.right(); + assert_eq!(selected_path(&picker), Path::new("one/two")); + assert!(!picker.entries().nth(picker.selected).unwrap().expanded); + picker.right(); + assert!(picker.entries().nth(picker.selected).unwrap().expanded); + picker.right(); + assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt")); + fs::remove_file(directory.0.join("one/two/needle.txt")).unwrap(); + picker.refresh(); + assert!(picker.entries().len() == 0); + picker.clear_filter(); + assert_eq!(selected_path(&picker), Path::new("one/two")); + assert!(!picker.searching && !picker.focused); +} + +#[test] +fn refresh_preserves_expansion_when_directory_chains_merge_and_split() { + let directory = Directory::new(); + directory.put("one/a.txt", ""); + directory.put("one/two/b.txt", ""); + let mut picker = tree(&directory); + picker.right(); + assert_eq!( + paths(&picker), + ["one", "one/two", "one/a.txt"].map(PathBuf::from) + ); + fs::remove_file(directory.0.join("one/a.txt")).unwrap(); + picker.refresh(); + assert_eq!(selected_path(&picker), Path::new("one/two")); + assert_eq!( + paths(&picker), + ["one/two", "one/two/b.txt"].map(PathBuf::from) + ); + assert!(picker.entries().next().unwrap().expanded); + directory.put("one/a.txt", ""); + picker.refresh(); + assert_eq!(selected_path(&picker), Path::new("one/two")); + assert_eq!( + paths(&picker), + ["one", "one/two", "one/a.txt"].map(PathBuf::from) + ); + assert!(picker.entries().next().unwrap().expanded); + assert!(!picker.entries().nth(1).unwrap().expanded); + fs::remove_file(directory.0.join("one/a.txt")).unwrap(); + picker.refresh(); + picker.left(); + assert_eq!(paths(&picker), [PathBuf::from("one/two")]); + directory.put("one/a.txt", ""); + picker.refresh(); + assert_eq!(paths(&picker), [PathBuf::from("one")]); + assert!(!picker.entries().next().unwrap().expanded); +} diff --git a/tests/unit/forge.rs b/tests/unit/forge.rs new file mode 100644 index 0000000..99f3bcd --- /dev/null +++ b/tests/unit/forge.rs @@ -0,0 +1,425 @@ +use super::*; +use serde_json::json; + +fn parse(url: &str, provider: Option<&str>) -> Result { + Forge::parse(url, provider, |destination, _| { + Ok(destination.rsplit('@').next().unwrap().to_owned()) + }) +} + +#[test] +fn pages_are_bounded_newest_first_summaries_without_historical_metadata() { + for (provider, url, query) in [ + ( + Provider::Github, + "https://github.com/owner/repo", + "state=open&per_page=50&sort=created&direction=desc&page=3", + ), + ( + Provider::Gitlab, + "https://gitlab.com/owner/repo", + "state=opened&scope=all&per_page=50&order_by=created_at&sort=desc&page=3", + ), + ( + Provider::Gitea, + "https://codeberg.org/owner/repo", + "state=open&limit=50&page=3", + ), + ] { + let forge = parse(url, None).unwrap(); + let mut calls = 0; + let page = forge.page_with(false, 3, |url| { + calls += 1; + assert_eq!(url, format!("{}?{query}", forge.endpoint)); + Ok(json!([ + {"number": 1, "iid": 1, "title": "Older", "state": if provider == Provider::Gitlab { "opened" } else { "open" }, + "user": null, "author": null, "merged_at": null, "merged": false, "created_at": "2024-01-01T01:00:00+01:00"}, + {"number": 2, "iid": 2, "title": "Newer", "state": if provider == Provider::Gitlab { "opened" } else { "open" }, + "user": {"login": "author"}, "author": {"username": "author"}, "merged_at": null, "merged": false, + "created_at": "2024-01-01T00:00:00.001Z", "diff_refs": null, "head": {"sha": "not-a-commit"}} + ])) + }).unwrap(); + assert_eq!(calls, 1); + assert_eq!( + page.entries.iter().map(|p| p.number).collect::>(), + [2, 1] + ); + assert_eq!(page.entries[0].description, "author · open"); + assert!(page.entries[1].description.contains("deleted user")); + assert!( + page.has_more, + "A short, server-capped page is not exhaustion" + ); + let empty = forge.page_with(false, 4, |_| Ok(json!([]))).unwrap(); + assert!(empty.entries.is_empty() && !empty.has_more); + assert!( + forge + .page_with(false, 0, |_| panic!("Page zero must not request")) + .is_err() + ); + } +} + +#[test] +fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() { + let forge = parse("https://gitlab.com/group/repo", None).unwrap(); + let mut all = Vec::new(); + for page in 1..=3 { + let mut states = Vec::new(); + let result = forge + .page_with(true, page, |url| { + assert!(url.ends_with(&format!("&page={page}"))); + let state = if url.contains("?state=closed&") { + "closed" + } else { + assert!(url.contains("?state=merged&")); + "merged" + }; + states.push(state); + let (id, date) = match (state, page) { + ("closed", 1) => (1, "2024-01-01T00:00:00Z"), + ("merged", 1) => (3, "2024-03-01T00:00:00Z"), + ("merged", 2) => (2, "2024-02-01T00:00:00Z"), + _ => return Ok(json!([])), + }; + Ok(json!([{"iid": id, "title": state, "state": state, + "author": null, "created_at": date, "diff_refs": null}])) + }) + .unwrap(); + assert_eq!(states, ["closed", "merged"]); + assert_eq!(result.has_more, page != 3); + assert!(result.entries.iter().all(|p| p.closed)); + if page == 1 { + assert_eq!( + result.entries.iter().map(|p| p.number).collect::>(), + [3, 1] + ); + } + // The second merged page still precedes the first closed page. + all.extend(result.entries); + } + all.sort_unstable_by(|a, b| b.created_at.cmp(&a.created_at)); + assert_eq!(all.iter().map(|p| p.number).collect::>(), [3, 2, 1]); + let raced = forge.page_with(true, 1, |url| { + Ok(json!([{"iid": 7, "title": "Raced merge", "state": if url.contains("?state=merged&") { "merged" } else { "closed" }, + "author": null, "created_at": "2024-01-01T00:00:00Z"}])) + }).unwrap(); + assert_eq!(raced.entries.len(), 1); + assert!(raced.entries[0].description.contains("merged")); +} + +#[test] +fn timestamps_normalize_offsets_precision_and_calendar_boundaries() { + for (input, expected) in [ + ( + "2024-03-01T00:30:12.5+01:00", + "2024-02-29T23:30:12.500000000Z", + ), + ( + "2023-12-31T23:30:00-01:00", + "2024-01-01T00:30:00.000000000Z", + ), + ( + "2024-01-01T00:00:00.000000001Z", + "2024-01-01T00:00:00.000000001Z", + ), + ] { + assert_eq!(created_at(&json!({"created_at": input})).unwrap(), expected); + } + for input in [ + "", + "not a date", + "2023-02-29T00:00:00Z", + "2024-13-01T00:00:00Z", + "2024-01-01T24:00:00Z", + "2024-01-01T00:00:00", + "2024-01-01T00:00:00+24:00", + "2024-01-01T00:00:00.Z", + "2024-01-01T00:00:00.1234567890Z", + "0001-01-01T00:00:00+01:00", + "9999-12-31T23:00:00-02:00", + ] { + assert!( + created_at(&json!({"created_at": input})).is_err(), + "{input}" + ); + } +} + +#[test] +fn resolution_reads_only_selected_detail_and_exact_preserved_head() { + for (url, provider) in [ + ("https://github.com/owner/repo", Provider::Github), + ("https://gitlab.com/group/repo", Provider::Gitlab), + ("https://codeberg.org/owner/repo", Provider::Gitea), + ] { + let forge = parse(url, None).unwrap(); + let mut calls = Vec::new(); + let pr = forge.resolve_with(7, |url| { + calls.push(url.to_owned()); + if url == format!("{}/7", forge.endpoint) { + Ok(json!({"number": 7, "iid": 7, "title": "Selected", "state": if provider == Provider::Gitlab { "merged" } else { "closed" }, + "merged_at": "2024-01-01T00:00:00Z", "merged": true, "user": null, "author": null, + "base": {"ref": "main", "sha": "1".repeat(40)}, "head": {"ref": "topic", "sha": "2".repeat(40)}, + "merge_base": "3".repeat(40), "target_branch": "main", "source_branch": "topic", + "diff_refs": {"start_sha": null, "base_sha": "4".repeat(40), "head_sha": "5".repeat(40)}, + "url": "https://evil.example/detail", "references": "https://evil.example/refs"})) + } else { + assert_eq!(url, format!("{}/git/refs/pull/7/head", forge.endpoint.strip_suffix("/pulls").unwrap())); + Ok(json!([{"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "6".repeat(40)}}])) + } + }).unwrap(); + assert_eq!(calls.len(), if provider == Provider::Gitea { 2 } else { 1 }); + let revisions = pr.revisions.unwrap(); + assert_eq!( + revisions.base_sha, + match provider { + Provider::Github => "1", + Provider::Gitlab => "4", + Provider::Gitea => "3", + } + .repeat(40) + ); + assert_eq!( + revisions.head_sha, + match provider { + Provider::Github => "2", + Provider::Gitlab => "5", + Provider::Gitea => "6", + } + .repeat(40) + ); + assert!( + forge + .resolve_with(0, |_| panic!("Zero must not request")) + .is_err() + ); + let mut calls = 0; + assert!( + forge + .resolve_with(7, |_| { + calls += 1; + Ok(json!({"number": 8, "iid": 8})) + }) + .is_err() + ); + assert_eq!( + calls, 1, + "Reject wrong detail identity before reading historical refs" + ); + } +} + +#[test] +fn gitea_preserved_heads_reject_missing_ambiguous_or_foreign_refs() { + let reference = + json!({"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "a".repeat(40)}}); + assert_eq!(gitea_head(&reference, 7).unwrap(), "a".repeat(40)); + assert_eq!( + gitea_head(&json!([reference.clone()]), 7).unwrap(), + "a".repeat(40) + ); + for bad in [ + json!([]), + json!([reference.clone(), reference.clone()]), + json!({"ref": "refs/pull/8/head", "object": reference["object"]}), + json!({"ref": "refs/pull/7/head", "object": {"type": "tag", "sha": "a".repeat(40)}}), + json!({"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "0".repeat(40)}}), + ] { + assert!(gitea_head(&bad, 7).is_err()); + } +} + +#[test] +fn ssh_aliases_use_effective_hostname_and_preserve_user_matching() { + use std::{fs, io::Write, path::PathBuf}; + struct Config(PathBuf); + impl Drop for Config { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } + } + let path = std::env::temp_dir().join(format!("vamp-forge-ssh-{}.conf", std::process::id())); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + let config = Config(path); + file.write_all(b"Host gitea-personal\n HostName gitea.example.org\n Port 2222\nHost github-personal\n HostName ssh.github.com\nHost user-match\n Port 2222\nMatch originalhost user-match user git\n HostName gitlab.example.org\n").unwrap(); + for (url, expected) in [ + ( + "git@gitea-personal:owner/repo.git", + "https://gitea.example.org/api/v1/repos/owner/repo/pulls", + ), + ( + "ssh://git@gitea-personal:2200/owner/repo.git", + "https://gitea.example.org/api/v1/repos/owner/repo/pulls", + ), + ( + "git@github-personal:owner/repo.git", + "https://api.github.com/repos/owner/repo/pulls", + ), + ( + "git@user-match:group/sub/repo.git", + "https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests", + ), + ] { + let forge = Forge::parse(url, None, |destination, port| { + let mut command = Command::new("ssh"); + command.arg("-F").arg(&config.0); + ssh_hostname(command, destination, port, &AtomicBool::new(false)) + }) + .unwrap(); + assert_eq!(forge.endpoint, expected); + } + assert!( + Forge::parse("git@alias:owner/repo", Some("gitea"), |_, _| Ok( + "evil.example/path".into() + )) + .is_err() + ); + assert!( + Forge::parse( + "ssh://user:secret@alias/owner/repo", + Some("gitea"), + |_, _| { panic!("Password-bearing SSH URLs must be rejected before starting SSH") } + ) + .is_err() + ); + assert_eq!( + Forge::parse("https://github.com/owner/repo", None, |_, _| panic!( + "HTTPS must not invoke SSH" + )) + .unwrap() + .host, + "github.com" + ); +} + +#[test] +fn remote_urls_preserve_namespaces_and_keep_ssh_ports_out_of_https() { + let gitlab = parse("ssh://git@gitlab.example.org:2222/group/sub/repo.git", None).unwrap(); + assert_eq!( + gitlab.endpoint, + "https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests" + ); + assert_eq!( + parse("git@github.com:owner/repo.git", None) + .unwrap() + .endpoint, + "https://api.github.com/repos/owner/repo/pulls" + ); + assert_eq!( + parse( + "https://user:secret@forge.example:8443/owner/repo.git", + Some("gitea") + ) + .unwrap() + .endpoint, + "https://forge.example:8443/api/v1/repos/owner/repo/pulls" + ); + assert!( + parse("https://forge.example/owner/repo", None) + .err() + .unwrap() + .contains("git config remote..vamp-forge github") + ); + for url in [ + "https://github.com/owner/repo?token=secret", + "https://github.com/owner/%2e%2e", + "https://github.com/owner/repo%0a", + "https://github.com:0/owner/repo", + "file:///owner/repo", + ] { + assert!(parse(url, Some("github")).is_err(), "{url}"); + } +} + +#[test] +fn fork_pull_requests_pin_historical_shas_not_merge_commits_or_response_urls() { + let value = json!({"number": 7, "title": "Merged fork", "state": "closed", "merged_at": "2026-01-01T00:00:00Z", "merged": true, + "user": {"login": "author"}, "base": {"ref": "release/next", "sha": "1".repeat(40)}, + "head": {"ref": "feature", "label": "fork:topic", "sha": "2".repeat(40), "repo": null}, "merge_base": "4".repeat(40), "merge_commit_sha": "3".repeat(40), "url": "https://evil.example/token"}); + for provider in [Provider::Github, Provider::Gitea] { + let pr = pull_request(provider, &value, Some("5".repeat(40))).unwrap(); + let revisions = pr.revisions.as_ref().unwrap(); + assert_eq!( + (revisions.base_ref.as_str(), revisions.head_ref.as_str()), + ("refs/heads/release/next", "refs/pull/7/head") + ); + assert_eq!( + revisions.base_sha, + if provider == Provider::Gitea { + "4" + } else { + "1" + } + .repeat(40) + ); + assert_eq!( + revisions.head_sha, + if provider == Provider::Gitea { + "5" + } else { + "2" + } + .repeat(40) + ); + if provider == Provider::Gitea { + let mut deleted = value.clone(); + deleted["head"]["sha"] = Value::String(String::new()); + assert_eq!( + pull_request(provider, &deleted, Some("5".repeat(40))) + .unwrap() + .revisions + .unwrap() + .head_sha, + "5".repeat(40) + ); + let unavailable = pull_request(provider, &deleted, None).unwrap(); + assert!(unavailable.revisions.is_err()); + } + } +} + +#[test] +fn gitlab_uses_target_start_sha_and_rejects_unprepared_or_unsafe_metadata() { + let mut value = json!({"iid": 9, "title": "MR", "state": "merged", "author": {"username": "author"}, + "target_branch": "main", "source_branch": "topic", "diff_refs": {"base_sha": "1".repeat(40), "start_sha": "2".repeat(40), "head_sha": "3".repeat(40)}}); + let pr = pull_request(Provider::Gitlab, &value, None).unwrap(); + let revisions = pr.revisions.unwrap(); + assert_eq!(revisions.base_sha, "2".repeat(40)); + assert_eq!(revisions.head_ref, "refs/merge-requests/9/head"); + value["diff_refs"]["start_sha"] = Value::Null; + assert_eq!( + pull_request(Provider::Gitlab, &value, None) + .unwrap() + .revisions + .unwrap() + .base_sha, + "1".repeat(40) + ); + value["target_branch"] = json!("main:refs/heads/injected"); + assert!( + pull_request(Provider::Gitlab, &value, None) + .unwrap() + .revisions + .is_err() + ); + value["target_branch"] = json!("main"); + value["diff_refs"]["head_sha"] = json!("HEAD"); + assert!( + pull_request(Provider::Gitlab, &value, None) + .unwrap() + .revisions + .is_err() + ); + value["diff_refs"] = Value::Null; + assert!( + pull_request(Provider::Gitlab, &value, None) + .unwrap() + .revisions + .is_err() + ); +} diff --git a/tests/unit/gutter.rs b/tests/unit/gutter.rs new file mode 100644 index 0000000..e27a917 --- /dev/null +++ b/tests/unit/gutter.rs @@ -0,0 +1,425 @@ +use super::*; +use std::ffi::OsString; + +static NEXT_TEST: AtomicU64 = AtomicU64::new(0); +struct Temporary(PathBuf); +impl Temporary { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "vamp-gutter-test-{}-{}", + std::process::id(), + NEXT_TEST.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } +} +impl Drop for Temporary { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn command(root: &Path, program: &str, args: &[&str]) -> String { + let mut command = Command::new(program); + if program == "jj" { + command.args([ + "--config=user.name=\"Gutter Author\"", + "--config=user.email=\"gutter@example.test\"", + "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", + "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", + ]); + } + let output = command + .args(args) + .current_dir(root) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z") + .env("JJ_CONFIG", "/dev/null") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().into() +} +fn git_fixture() -> (Temporary, PathBuf) { + let temp = Temporary::new(); + command(&temp.0, "git", &["init", "--quiet", "--template="]); + command(&temp.0, "git", &["config", "user.name", "Gutter Author"]); + command( + &temp.0, + "git", + &["config", "user.email", "gutter@example.test"], + ); + let path = temp.0.join("-literal [file]\n\t\".txt"); + fs::write(&path, "first\nsecond\nthird\n").unwrap(); + command( + &temp.0, + "git", + &["add", "--", path.file_name().unwrap().to_str().unwrap()], + ); + command(&temp.0, "git", &["commit", "--quiet", "-m", "Baseline"]); + (temp, path) +} + +#[test] +fn maps_replacements_surplus_deletions_empty_and_eof_rows() { + use Change::*; + for (before, after, expected) in [ + ("first\nsecond\n", "first\nsecond\n", vec![None, None, None]), + ( + "first\r\nsecond\r\n", + "first\nsecond\n", + vec![None, None, None], + ), + ("first\rsecond\r", "first\nsecond\n", vec![None, None, None]), + ( + "first\r\nsecond\r\nthird\r\n", + "first\nchanged\nthird\n", + vec![None, Some(Modified), None, None], + ), + ( + "first\nsecond\nthird\n", + "first\rchanged\rthird\r", + vec![None, Some(Modified), None, None], + ), + ("first\n", "first\nnew\n", vec![None, Some(Added), None]), + ( + "old\n", + "changed\nsurplus\n", + vec![Some(Modified), Some(Added), None], + ), + ( + "old\nextra\nlast\n", + "changed\nlast\n", + vec![Some(Modified), None, None], + ), + ( + "first\nremoved\nlast\n", + "first\nlast\n", + vec![None, Some(Removed), None], + ), + ("first\nremoved\n", "first\n", vec![None, Some(Removed)]), + ("first\nremoved", "first", vec![Some(Modified)]), + ("first\nremoved", "first\n", vec![None, Some(Removed)]), + ("removed\n", "", vec![Some(Removed)]), + ("", "new\n", vec![Some(Added), None]), + ("", "", vec![None]), + ("same", "same\n", vec![Some(Modified), None]), + ] { + assert_eq!(changes(before, after), expected, "{before:?} -> {after:?}"); + } +} + +#[test] +fn git_crlf_baseline_matches_normalized_editor_rows() { + let (temp, _) = git_fixture(); + let path = temp.0.join("crlf"); + fs::write(&path, "first\r\nsecond\r\nthird\r\n").unwrap(); + command(&temp.0, "git", &["add", "--", "crlf"]); + command( + &temp.0, + "git", + &["commit", "--quiet", "-m", "CRLF baseline"], + ); + assert_eq!( + read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(), + vec![None, None, None, None] + ); + assert_eq!( + read_file(&path, "first\nchanged\nthird\n", &|| false).unwrap(), + vec![None, Some(Change::Modified), None, None] + ); +} + +#[test] +fn git_head_includes_staged_disk_and_unsaved_edits_without_writes() { + let (temp, path) = git_fixture(); + fs::write(&path, "staged\nsecond\nthird\n").unwrap(); + command( + &temp.0, + "git", + &["add", "--", path.file_name().unwrap().to_str().unwrap()], + ); + fs::write(&path, "staged\ndisk\nthird\n").unwrap(); + let index = fs::read(temp.0.join(".git/index")).unwrap(); + let head = command(&temp.0, "git", &["rev-parse", "HEAD"]); + assert_eq!( + read_file(&path, "staged\ndisk\nunsaved\n", &|| false).unwrap(), + vec![ + Some(Change::Modified), + Some(Change::Modified), + Some(Change::Modified), + None + ] + ); + assert_eq!(fs::read(temp.0.join(".git/index")).unwrap(), index); + assert_eq!(command(&temp.0, "git", &["rev-parse", "HEAD"]), head); + assert_eq!(fs::read_to_string(&path).unwrap(), "staged\ndisk\nthird\n"); + fs::remove_file(&path).unwrap(); + assert_eq!( + read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(), + vec![None, None, None, None] + ); + assert_eq!( + read_file(&temp.0.join("new/named"), "unsaved\n", &|| false).unwrap(), + vec![Some(Change::Added), None] + ); + let untracked = temp.0.join("untracked"); + fs::write(&untracked, "unsaved\n").unwrap(); + assert_eq!( + read_file(&untracked, "unsaved\n", &|| false).unwrap(), + vec![Some(Change::Added), None] + ); +} + +#[test] +fn unborn_untracked_missing_ignored_metadata_binary_and_symlink_paths() { + let temp = Temporary::new(); + let path = temp.0.join("new/named"); + assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty()); + command(&temp.0, "git", &["init", "--quiet", "--template="]); + assert_eq!( + read_file(&path, "unsaved\n", &|| false).unwrap(), + vec![Some(Change::Added), None] + ); + let disk = temp.0.join("untracked"); + fs::write(&disk, "disk\n").unwrap(); + assert_eq!( + read_file(&disk, "unsaved\n", &|| false).unwrap(), + vec![Some(Change::Added), None] + ); + fs::write(temp.0.join(".gitignore"), "new/\nuntracked\n").unwrap(); + assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty()); + assert!(read_file(&disk, "unsaved\n", &|| false).unwrap().is_empty()); + assert!( + read_file(&temp.0.join(".git/config"), "text\n", &|| false) + .unwrap() + .is_empty() + ); + assert!( + read_file(&disk, "binary\0text", &|| false) + .unwrap() + .is_empty() + ); + std::os::unix::fs::symlink(&disk, temp.0.join("link")).unwrap(); + assert!( + read_file(&temp.0.join("link"), "text\n", &|| false) + .unwrap() + .is_empty() + ); + std::os::unix::fs::symlink(&temp.0, temp.0.join("linked")).unwrap(); + assert!( + read_file(&temp.0.join("linked/untracked"), "text\n", &|| false) + .unwrap() + .is_empty() + ); +} + +#[test] +fn git_non_utf8_filename_is_literal() { + use std::os::unix::ffi::OsStringExt; + let (temp, _) = git_fixture(); + let name = OsString::from_vec(b"non-utf8-\xff".to_vec()); + let path = temp.0.join(&name); + match fs::write(&path, "old\n") { + Ok(()) => {} + // APFS rejects non-UTF-8 names before repository code can read them. + Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => return, + Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"), + } + let mut add = git(&temp.0); + add.args(["add", "--"]).arg(&name); + checked(add, &|| false).unwrap(); + command(&temp.0, "git", &["commit", "--quiet", "-m", "Byte path"]); + assert_eq!( + read_file(&path, "changed\n", &|| false).unwrap(), + vec![Some(Change::Modified), None] + ); +} + +#[test] +fn jj_parent_root_colocation_ignore_and_merge_queries_are_read_only() { + for colocate in ["--colocate", "--no-colocate"] { + let temp = Temporary::new(); + command(&temp.0, "jj", &["git", "init", colocate, "."]); + let name = "-literal [file]\n\t\".txt"; + let path = temp.0.join(name); + fs::write(&path, "original\n").unwrap(); + assert_eq!( + read_file(&path, "unsaved\n", &|| false).unwrap(), + vec![Some(Change::Added), None] + ); + fs::write(temp.0.join(".gitignore"), "ignored\n").unwrap(); + assert!( + read_file(&temp.0.join("ignored"), "text\n", &|| false) + .unwrap() + .is_empty() + ); + command(&temp.0, "jj", &["describe", "-m", "Baseline"]); + command(&temp.0, "jj", &["new"]); + let operation = command( + &temp.0, + "jj", + &[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id", + ], + ); + let store = store_for_jj(&temp.0).unwrap(); + let refs = fs::read(store.join("HEAD")).unwrap(); + fs::write(&path, "disk edit\n").unwrap(); + assert_eq!( + read_file(&path, "inserted\noriginal\n", &|| false).unwrap(), + vec![Some(Change::Added), None, None] + ); + assert_eq!(fs::read(store.join("HEAD")).unwrap(), refs); + assert_eq!( + command( + &temp.0, + "jj", + &[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id", + ] + ), + operation + ); + let parent = command( + &temp.0, + "jj", + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@-", + "-T", + "commit_id", + ], + ); + command(&temp.0, "jj", &["new", "root()"]); + fs::write(temp.0.join("sibling"), "sibling\n").unwrap(); + command(&temp.0, "jj", &["describe", "-m", "Sibling"]); + command(&temp.0, "jj", &["new", &parent, "@"]); + assert!( + read_file(&path, "original\n", &|| false) + .unwrap() + .is_empty() + ); + } +} + +#[test] +fn worker_retains_only_latest_batch_and_file_errors_keep_good_siblings() { + let (_temp, path) = git_fixture(); + let mut worker = Worker::start().unwrap(); + let oversized: Arc = "x".repeat(FILE_LIMIT + 1).into(); + for id in 0..20 { + worker + .request( + id, + vec![ + (5, path.clone(), "first\nchanged\nthird\n".into()), + (8, path.clone(), Arc::clone(&oversized)), + ], + None, + ) + .unwrap(); + } + loop { + if let Some((id, result, picker)) = worker.poll() { + assert!(picker.is_none()); + assert_eq!(id, 19); + assert_eq!(result[0].0, 5); + assert_eq!( + result[0].1.as_ref().unwrap(), + &vec![None, Some(Change::Modified), None, None] + ); + assert_eq!(result[1].0, 8); + assert_eq!( + result[1].1.as_ref().unwrap_err(), + "Gutter file exceeds 16 MiB" + ); + break; + } + thread::yield_now(); + } + assert_eq!( + worker + .request( + 20, + vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())], + None + ) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); +} + +#[test] +fn picker_status_is_optional_and_errors_do_not_discard_line_results() { + let (temp, path) = git_fixture(); + let mut worker = Worker::start().unwrap(); + worker + .request( + 1, + vec![], + Some(( + temp.0.clone(), + vec![(path.clone(), "dirty buffer\n".into())], + )), + ) + .unwrap(); + loop { + if let Some((id, lines, picker)) = worker.poll() { + assert_eq!(id, 1); + assert!(lines.is_empty()); + let picker = picker.unwrap(); + assert_eq!(picker.root, temp.0); + let changes = picker.result.unwrap(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].path, path.strip_prefix(&temp.0).unwrap()); + assert_eq!(changes[0].status, crate::diff::FileStatus::Modified); + break; + } + thread::yield_now(); + } + worker + .request( + 2, + vec![(4, path.clone(), "first\nchanged\nthird\n".into())], + Some(( + temp.0.clone(), + vec![(path, "x".repeat(FILE_LIMIT + 1).into())], + )), + ) + .unwrap(); + loop { + if let Some((id, lines, picker)) = worker.poll() { + assert_eq!(id, 2); + assert_eq!(lines[0].0, 4); + assert!(lines[0].1.is_ok()); + assert!(picker.unwrap().result.unwrap_err().contains("16 MiB")); + break; + } + thread::yield_now(); + } +} diff --git a/tests/unit/lsp.rs b/tests/unit/lsp.rs new file mode 100644 index 0000000..74cc7f2 --- /dev/null +++ b/tests/unit/lsp.rs @@ -0,0 +1,242 @@ +use super::*; + +#[test] +fn native_definitions_preserve_snapshot_identity_and_utf16_until_navigation() { + let source = Source { + buffer_id: 7, + revision: 3, + row: 0, + column: 0, + request_id: 11, + }; + let path = Path::new("/vamp-immutable-snapshot/Before/renamed/λ.rs"); + let span = json!({ + "start": {"line": 4, "character": 5}, + "end": {"line": 4, "character": 8} + }); + for location in [ + json!({"uri": file_uri(path).unwrap(), "range": span}), + json!({"targetUri": file_uri(path).unwrap(), + "targetRange": span, "targetSelectionRange": span}), + ] { + let effects = Runtime::definition_effects(&source, &location).unwrap(); + let expected_target_range = location.get("targetUri").map(|_| Utf16Range { + start: (4, 5), + end: (4, 8), + }); + assert!(matches!( + effects.as_slice(), + [UpdateEffect::NavigateUtf16 { + buffer_id: 7, revision: 3, path: target, + range: Utf16Range { start: (4, 5), end: (4, 8) }, target_range, + }] if target == path && *target_range == expected_target_range + )); + } + for invalid in [ + json!({"start": {"line": 4, "character": 8}, + "end": {"line": 4, "character": 5}}), + json!({"start": {"line": -1, "character": 0}, + "end": {"line": 0, "character": 0}}), + ] { + for location in [ + json!({"uri": file_uri(path).unwrap(), "range": invalid}), + json!({"targetUri": file_uri(path).unwrap(), + "targetRange": invalid, "targetSelectionRange": span}), + json!({"targetUri": file_uri(path).unwrap(), + "targetRange": span, "targetSelectionRange": invalid}), + ] { + assert!(Runtime::definition_effects(&source, &location).is_err()); + } + } + assert!( + serde_json::from_value::(json!({ + "type": "navigate_utf16", "buffer_id": 7, "revision": 3, + "path": path, "range": {"start": [4,5], "end": [4,8]}, + "target_range": null + })) + .is_err() + ); +} + +#[test] +fn semantic_decoder_validates_raw_coordinates_before_grapheme_rounding() { + let options = semantic_options(Some(&json!({ + "full": true, + "legend": {"tokenTypes": ["custom", "function"], + "tokenModifiers": ["custom", "readonly", "declaration", "async"]} + }))) + .unwrap() + .unwrap(); + let tokens = decode_semantic_tokens( + "A😀e\u{301}Z\r\n\r\nx", + &json!({"data": [ + 0, 1, 2, 1, 2, + 0, 2, 1, 1, 12, + 0, 1, 1, 1, 0, + 0, 1, 999, 0, 0, + 1, 0, 1, 1, 0, + 1, 0, 0, 1, 0, + 0, 0, 100, 1, 0 + ]}), + &options, + ) + .unwrap() + .unwrap(); + assert_eq!(tokens.len(), 4); + assert_eq!(tokens[0].start, TextPosition { row: 0, column: 1 }); + assert_eq!(tokens[0].end, TextPosition { row: 0, column: 2 }); + assert_eq!(tokens[0].modifiers, SemanticTokenModifier::Readonly as u16); + assert_eq!(tokens[1].start, TextPosition { row: 0, column: 2 }); + assert_eq!(tokens[1].end, TextPosition { row: 0, column: 3 }); + assert_eq!( + tokens[1].modifiers, + SemanticTokenModifier::Declaration as u16 | SemanticTokenModifier::Async as u16 + ); + assert_eq!(tokens[2].start, tokens[1].start); + assert_eq!(tokens[3].end, TextPosition { row: 2, column: 1 }); + for data in [ + json!([0, 0, 1, 1]), + json!([-1, 0, 1, 1, 0]), + json!([0, 0.5, 1, 1, 0]), + json!([0, 0, 1, 1, 2147483648u64]), + json!([0, 0, 1, 65536, 0]), + json!([0, 0, 1, 2, 0]), + json!([0, 0, 1, 1, 16]), + json!([0, 2, 1, 0, 0]), + json!([0, 1, 1, 1, 0]), + json!([0, 7, 1, 1, 0]), + json!([2, 0, 1, 1, 0]), + json!([0, 1, 2, 1, 0, 0, 1, 1, 1, 0]), + ] { + assert!( + decode_semantic_tokens("A😀e\u{301}Z\n", &json!({"data": data}), &options).is_err(), + "{data}" + ); + } + let count = MAX_BODY / std::mem::size_of::() + 1; + assert!(decode_semantic_tokens("x", &json!({"data": vec![0; count * 5]}), &options).is_err()); +} + +#[test] +fn unicode_positions_and_local_uri_boundaries() { + let text = "A😀e\u{301}Z\n"; + let (start, end) = range( + text, + &json!({"start": {"line": 0, "character": 2}, "end": {"line": 0, "character": 4}}), + ) + .unwrap(); + assert_eq!(start, TextPosition { row: 0, column: 1 }); + assert_eq!(end, TextPosition { row: 0, column: 3 }); + assert_eq!( + outbound_position(text, 0, 3).unwrap(), + json!({"line": 0, "character": 5}) + ); + assert_eq!( + position(text, &json!({"line": 1, "character": 999}), false).unwrap(), + TextPosition { row: 1, column: 0 } + ); + assert!( + range( + text, + &json!({"start": {"line": 0, "character": 3}, "end": {"line": 0, "character": 2}}) + ) + .is_err() + ); + let path = Path::new("/tmp/source space λ😀.rs"); + assert_eq!(uri_path(&file_uri(path).unwrap()).unwrap(), path); + assert_eq!( + uri_path("file://localhost/tmp/a%23b").unwrap(), + Path::new("/tmp/a#b") + ); + for uri in [ + "https://example.invalid/a", + "file://remote/tmp/a", + "file:///tmp/%", + "file:///tmp/%00", + "file:///tmp/%FF", + "file:///tmp/a?query", + ] { + assert!(uri_path(uri).is_err(), "{uri}"); + } +} + +#[test] +fn hover_contents_keep_lines_and_reject_malformed_values() { + let source = "A😀e\u{301}Z\r\n"; + for kind in ["plaintext", "markdown"] { + assert_eq!( + hover_text( + &json!({ + "contents": {"kind": kind, "value": "😀e\u{301}\r\n\tcode\rnext\u{1b}\u{85}"}, + "range": {"start": {"line": 0, "character": 2}, + "end": {"line": 0, "character": 4}} + }), + source, + ) + .unwrap(), + Some("😀e\u{301}\n code\nnext ".into()), + ); + } + for contents in [ + json!("line\ncode"), + json!({"language": "rust", "value": "line\ncode"}), + ] { + assert_eq!( + hover_text(&json!({"contents": contents}), source).unwrap(), + Some("line\ncode".into()), + ); + } + assert_eq!( + hover_text( + &json!({"contents": ["first", {"language": "rust", "value": "fn main() {\n}"}]}), + source, + ) + .unwrap(), + Some("first\n\nfn main() {\n}".into()), + ); + for value in [ + Value::Null, + json!({"contents": ""}), + json!({"contents": []}), + ] { + assert_eq!(hover_text(&value, source).unwrap(), None); + } + for value in [ + json!({}), + json!({"contents": {"kind": "html", "value": "x"}}), + json!({"contents": {"language": "rust", "value": false}}), + json!({"contents": [{"kind": "markdown", "value": "x"}]}), + json!({"contents": "x", "range": {"start": {"line": 1, "character": 0}, + "end": {"line": 0, "character": 0}}}), + json!({"contents": "\t".repeat(MAX_BODY / 4 + 1)}), + ] { + assert!(hover_text(&value, source).is_err()); + } +} + +#[test] +fn completion_text_limit_counts_the_atomic_batch_not_sort_metadata() { + let source = Source { + buffer_id: 1, + revision: 0, + row: 0, + column: 0, + request_id: 1, + }; + let at_limit = json!([{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}]); + assert_eq!( + decode_completion("", &at_limit, &source, None).unwrap()[0].edits[0] + .text + .len(), + MAX_BODY - 1 + ); + let over_limit = json!([ + {"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}, + {"label": "y"} + ]); + assert!( + decode_completion("", &over_limit, &source, None) + .unwrap_err() + .contains("4 MiB") + ); +} diff --git a/tests/unit/markdown.rs b/tests/unit/markdown.rs new file mode 100644 index 0000000..e894fd0 --- /dev/null +++ b/tests/unit/markdown.rs @@ -0,0 +1,143 @@ +use super::*; + +fn at(text: &str, needle: &str) -> Result { + let byte = text.find(needle).unwrap(); + let prefix = &text[..byte]; + let row = prefix.bytes().filter(|byte| *byte == b'\n').count(); + let column = prefix.rsplit('\n').next().unwrap().graphemes(true).count(); + link_at(text, TextPosition { row, column }) +} + +fn target(path: Option<&str>, fragment: Option<&str>) -> LinkTarget { + LinkTarget { + path: path.map(PathBuf::from), + fragment: fragment.map(str::to_owned), + } +} + +#[test] +fn follows_inline_labels_destinations_and_grapheme_columns() { + let text = "👩‍💻 [go **there**](<../My%20Notes/%E6%97%A5%E6%9C%AC\\(1\\).md#caf%C3%A9>)"; + let expected = target(Some("../My Notes/日本(1).md"), Some("café")); + assert_eq!(at(text, "there").unwrap(), expected); + assert_eq!(at(text, "My%20").unwrap(), expected); + assert_eq!( + at("[absolute](/Tmp/Note.md)", "absolute").unwrap(), + target(Some("/Tmp/Note.md"), None) + ); + assert_eq!( + at("[here](#title)", "here").unwrap(), + target(None, Some("title")) + ); + assert_eq!(at("[top]()", "top").unwrap(), target(None, None)); + assert_eq!( + at("[here](#title)", "[here").unwrap(), + target(None, Some("title")) + ); + assert_eq!( + at("[file](literal%23name.md)", "file").unwrap(), + target(Some("literal#name.md"), None) + ); + assert_eq!( + at("[file](My\\[Notes\\].md)", "file").unwrap(), + target(Some("My[Notes].md"), None) + ); +} + +#[test] +fn resolves_all_reference_forms_and_multiline_block_definitions() { + let text = "[full][ A B ] [a b][] [A B]\n\n> [a b]:\n> \n> \"A title\"\n\n[a b]: ignored.md\n"; + let expected = target(Some("My Notes.md"), Some("title")); + for needle in ["full", " A B ", "a b][]", "A B]"] { + assert_eq!(at(text, needle).unwrap(), expected); + } + assert_eq!( + at("[Straße]\n\n[STRASSE]: file.md", "Straße").unwrap(), + target(Some("file.md"), None) + ); + let text = "> [multi\n> line][target]\n\n[target]: other.md\n"; + assert_eq!(at(text, "line]").unwrap(), target(Some("other.md"), None)); +} + +#[test] +fn rejects_images_code_plaintext_and_external_urls() { + for (text, needle) in [ + ("![image](file.md)", "image"), + ("`[code](file.md)`", "code"), + ("```md\n[code](file.md)\n```", "file"), + (" [code](file.md)", "code"), + ("plain file.md text", "file"), + ("[web](https://example.com)", "web"), + ("[web](//example.com/a)", "web"), + ("[web](mailto:a@example.com)", "web"), + ("", "example"), + ] { + assert!(at(text, needle).is_err(), "{text}"); + } + assert!( + at("[missing]", "missing") + .unwrap_err() + .contains("not defined") + ); + assert!( + at("[bad](file%ZZ.md)", "bad") + .unwrap_err() + .contains("percent") + ); + assert!( + at("[bad](file%FF.md)", "bad") + .unwrap_err() + .contains("UTF-8") + ); + assert!(at("[bad](#)", "bad").unwrap_err().contains("empty")); +} + +#[test] +fn locates_visible_unicode_headings_setext_and_unique_slugs() { + let text = "# **Café** [日本](destination.md) `code` ###\n\n> ## Café 日本 code\n\nCafé 日本 code\n====\n\n# Café 日本 code-1\n\n# Cafe\u{301} _name_ ![alt](hidden.md)\n\n```md\n# Hidden\n```\n"; + assert_eq!(heading_row(text, "café-日本-code").unwrap(), 0); + assert_eq!(heading_row(text, "café-日本-code-1").unwrap(), 2); + assert_eq!(heading_row(text, "café-日本-code-2").unwrap(), 4); + assert_eq!(heading_row(text, "café-日本-code-1-1").unwrap(), 7); + assert_eq!(heading_row(text, "cafe\u{301}-name-alt").unwrap(), 9); + assert!(heading_row(text, "hidden").is_err()); + assert!(heading_row(text, "destinationmd").is_err()); + assert!(heading_row(text, "").is_err()); + assert_eq!( + heading_row("# [Heading][ref]\n\n[ref]: ignored.md", "heading").unwrap(), + 0 + ); + assert_eq!( + heading_row("# ![Alt][ref]\n\n[ref]: ignored.md", "alt").unwrap(), + 0 + ); + assert_eq!(heading_row("# ![Alt][missing]", "altmissing").unwrap(), 0); +} + +#[test] +fn renders_entities_or_reports_the_explicit_entity_ceiling() { + assert_eq!( + at("[file](a&b c.md)", "file").unwrap(), + target(Some("a&b c.md"), None) + ); + assert_eq!( + at("[file](<name>)", "file").unwrap(), + target(Some(""), None) + ); + assert!( + at("[file](a©b.md)", "file") + .unwrap_err() + .contains("Unsupported Markdown HTML entity") + ); + assert_eq!(heading_row("# A & B é\n", "a--b-é").unwrap(), 0); + assert!( + heading_row("# ©\n", "copy") + .unwrap_err() + .contains("Unsupported Markdown HTML entity") + ); + assert_eq!(heading_row("# ©\n# Valid\n", "valid").unwrap(), 1); + assert_eq!( + heading_row("> First\n> Second\n> ------\n", "first-second").unwrap(), + 0 + ); +} diff --git a/tests/unit/panes.rs b/tests/unit/panes.rs new file mode 100644 index 0000000..041f9d4 --- /dev/null +++ b/tests/unit/panes.rs @@ -0,0 +1,123 @@ +use super::*; + +fn rect(x: u16, y: u16, width: u16, height: u16) -> PaneRect { + PaneRect { + x, + y, + width, + height, + } +} + +fn leaves(tree: &PaneTree, bounds: PaneRect) -> Vec<(usize, PaneRect)> { + let mut leaves = Vec::new(); + tree.visit_rects(bounds, |id, rect| leaves.push((id, rect))); + leaves +} + +#[test] +fn nested_layout_partitions_bounds_and_preserves_ids() { + let bounds = rect(3, 5, 11, 9); + let mut tree = PaneTree::new(10); + assert!(tree.split(10, 20, SplitAxis::Vertical)); + assert!(tree.split(20, 30, SplitAxis::Horizontal)); + assert!(!tree.split(99, 40, SplitAxis::Horizontal)); + assert!(!tree.split(10, 30, SplitAxis::Horizontal)); + assert!(!tree.split(10, 10, SplitAxis::Horizontal)); + assert_eq!(tree.len(), 3); + let panes = leaves(&tree, bounds); + assert_eq!( + panes, + vec![ + (10, rect(3, 5, 5, 9)), + (20, rect(8, 5, 6, 4)), + (30, rect(8, 9, 6, 5)) + ] + ); + let mut cells = std::collections::HashSet::new(); + for (_, pane) in panes { + for y in pane.y..pane.y + pane.height { + for x in pane.x..pane.x + pane.width { + assert!(cells.insert((x, y)), "overlapping pane cell"); + } + } + } + assert_eq!( + cells.len(), + usize::from(bounds.width) * usize::from(bounds.height) + ); + assert!(leaves(&tree, rect(0, 0, 0, 10)).is_empty()); + assert!(leaves(&tree, rect(0, 0, 10, 0)).is_empty()); +} + +#[test] +fn closing_expands_nested_sibling_and_refuses_last() { + let bounds = rect(0, 0, 20, 12); + let mut tree = PaneTree::new(1); + tree.split(1, 2, SplitAxis::Vertical); + tree.split(2, 3, SplitAxis::Horizontal); + assert_eq!(tree.close(1), Some(2)); + assert_eq!( + leaves(&tree, bounds), + vec![(2, rect(0, 0, 20, 6)), (3, rect(0, 6, 20, 6))] + ); + assert_eq!(tree.close(99), None); + assert_eq!(tree.close(2), Some(3)); + assert_eq!(leaves(&tree, bounds), vec![(3, bounds)]); + assert_eq!(tree.close(3), None); + assert_eq!(tree.len(), 1); + assert!(!tree.contains(1)); + assert!(tree.contains(3)); +} + +#[test] +fn directional_focus_handles_unequal_nested_regions() { + let bounds = rect(0, 0, 20, 12); + let mut tree = PaneTree::new(1); + tree.split(1, 2, SplitAxis::Vertical); + tree.split(2, 3, SplitAxis::Horizontal); + tree.split(3, 4, SplitAxis::Horizontal); + assert_eq!(tree.neighbor(1, PaneDirection::Right, bounds), Some(3)); + assert_eq!(tree.neighbor(2, PaneDirection::Down, bounds), Some(3)); + assert_eq!(tree.neighbor(3, PaneDirection::Down, bounds), Some(4)); + assert_eq!(tree.neighbor(4, PaneDirection::Up, bounds), Some(3)); + assert_eq!(tree.neighbor(4, PaneDirection::Left, bounds), Some(1)); + assert_eq!(tree.neighbor(1, PaneDirection::Left, bounds), None); + assert_eq!(tree.neighbor(99, PaneDirection::Right, bounds), None); + assert_eq!( + tree.neighbor(1, PaneDirection::Right, rect(0, 0, 0, 0)), + None + ); +} + +#[test] +fn navigation_prefers_overlap_then_edge_and_breaks_ties_by_id() { + let current = rect(10, 10, 10, 10); + let candidates = [ + (1, rect(20, 0, 5, 5)), + (2, rect(25, 10, 5, 10)), + (4, rect(22, 10, 5, 10)), + (3, rect(22, 10, 5, 10)), + (0, rect(20, 10, 0, 10)), + ]; + assert_eq!( + directional_neighbor(current, PaneDirection::Right, candidates.into_iter()), + Some(3) + ); + assert_eq!( + directional_neighbor(current, PaneDirection::Right, candidates.into_iter().rev()), + Some(3) + ); + assert_eq!( + directional_neighbor( + current, + PaneDirection::Up, + [(7, rect(10, 0, 10, 5))].into_iter() + ), + Some(7) + ); + assert_eq!( + directional_neighbor(current, PaneDirection::Left, candidates.into_iter()), + None + ); +} diff --git a/tests/unit/repository.rs b/tests/unit/repository.rs new file mode 100644 index 0000000..26259b4 --- /dev/null +++ b/tests/unit/repository.rs @@ -0,0 +1,503 @@ +fn working_file_changes( + root: &Path, + buffers: &[(PathBuf, Arc)], + cancelled: &dyn Fn() -> bool, +) -> Result, String> { + WorkingFileReader::default().read(root, buffers, cancelled) +} + +use super::*; + +fn fixture_command(root: &Path, program: &str, args: &[&str]) -> String { + let mut command = Command::new(program); + if program == "jj" { + command.args([ + "--config=user.name=\"Picker Author\"", + "--config=user.email=\"picker@example.test\"", + "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", + "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", + ]); + } + let output = command + .args(args) + .current_dir(root) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z") + .env("JJ_CONFIG", "/dev/null") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().into() +} + +fn picker_git_fixture() -> Temporary { + let temp = Temporary::new().unwrap(); + fixture_command(&temp.0, "git", &["init", "--quiet", "--template="]); + fixture_command(&temp.0, "git", &["config", "user.name", "Picker Author"]); + fixture_command( + &temp.0, + "git", + &["config", "user.email", "picker@example.test"], + ); + temp +} + +fn changes_by_path( + root: &Path, + buffers: &[(PathBuf, Arc)], +) -> BTreeMap { + working_file_changes(root, buffers, &|| false) + .unwrap() + .into_iter() + .map(|change| (change.path.clone(), change)) + .collect() +} + +#[test] +fn working_git_changes_cover_disk_index_buffers_binary_exact_renames_and_read_only_queries() { + use std::os::unix::{ffi::OsStringExt, fs::PermissionsExt}; + let temp = picker_git_fixture(); + let root = &temp.0; + for (name, contents) in [ + (".gitignore", "ignored*\n"), + ("modified", "original\r\n"), + ("unchanged", "same\n"), + ("removed", "deleted\n"), + ("old", "rename content\n"), + ("duplicate-one", "duplicate\n"), + ("duplicate-two", "duplicate\n"), + ("mode", "mode\n"), + ("binary", "before\0binary"), + ] { + fs::write(root.join(name), contents).unwrap(); + } + let non_utf8 = PathBuf::from(OsString::from_vec(b"non-utf8-\xff".to_vec())); + let non_utf8 = match fs::write(root.join(&non_utf8), "before\n") { + Ok(()) => Some(non_utf8), + Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => None, + Err(error) => panic!("cannot create byte filename: {error}"), + }; + fixture_command(root, "git", &["add", "--all"]); + fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); + fs::write(root.join("modified"), "staged\n").unwrap(); + fixture_command(root, "git", &["add", "--", "modified"]); + fs::write(root.join("modified"), "unstaged\n").unwrap(); + fs::write(root.join("binary"), "after\0binary").unwrap(); + if let Some(name) = &non_utf8 { + fs::write(root.join(name), "after\n").unwrap(); + } + fs::remove_file(root.join("removed")).unwrap(); + let renamed = "-new [file]\n\t\".txt"; + fs::rename(root.join("old"), root.join(renamed)).unwrap(); + fs::remove_file(root.join("duplicate-one")).unwrap(); + fs::remove_file(root.join("duplicate-two")).unwrap(); + fs::write(root.join("new-duplicate-one"), "duplicate\n").unwrap(); + fs::write(root.join("new-duplicate-two"), "duplicate\n").unwrap(); + fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap(); + fs::write(root.join("added"), "added\n").unwrap(); + fs::write(root.join("ignored"), "ignored\n").unwrap(); + fs::write(root.join("ignored-tracked"), "forced\n").unwrap(); + fixture_command(root, "git", &["add", "-f", "--", "ignored-tracked"]); + std::os::unix::fs::symlink(root.join("modified"), root.join("link")).unwrap(); + let index = fs::read(root.join(".git/index")).unwrap(); + let head = fs::read(root.join(".git/HEAD")).unwrap(); + let disk = changes_by_path(root, &[]); + for name in ["modified", "binary", "mode"] { + assert_eq!(disk[Path::new(name)].status, FileStatus::Modified); + } + if let Some(name) = &non_utf8 { + assert_eq!(disk[name].status, FileStatus::Modified); + } + assert_eq!(disk[Path::new("removed")].status, FileStatus::Removed); + assert_eq!(disk[Path::new(renamed)].status, FileStatus::Renamed); + assert_eq!( + disk[Path::new(renamed)].old_path.as_deref(), + Some(Path::new("old")) + ); + for name in [ + "added", + "ignored-tracked", + "new-duplicate-one", + "new-duplicate-two", + ] { + assert_eq!(disk[Path::new(name)].status, FileStatus::Added); + } + for name in ["duplicate-one", "duplicate-two"] { + assert_eq!(disk[Path::new(name)].status, FileStatus::Removed); + } + for name in ["unchanged", "old", "ignored", "link"] { + assert!(!disk.contains_key(Path::new(name)), "{name}"); + } + let overlays = changes_by_path( + root, + &[ + (root.join("modified"), "original\n".into()), + (root.join("unchanged"), "dirty\n".into()), + (root.join("removed"), "deleted\n".into()), + (root.join(renamed), "dirty renamed\n".into()), + (root.join("mode"), "mode\n".into()), + (root.join("ignored-buffer"), "ignored\n".into()), + ], + ); + assert!(!overlays.contains_key(Path::new("modified"))); + assert!(!overlays.contains_key(Path::new("ignored-buffer"))); + assert_eq!( + overlays[Path::new("unchanged")].status, + FileStatus::Modified + ); + assert_eq!(overlays[Path::new("removed")].status, FileStatus::Removed); + assert_eq!(overlays[Path::new(renamed)].status, FileStatus::Renamed); + assert_eq!(overlays[Path::new("mode")].status, FileStatus::Modified); + let aliases = Temporary::new().unwrap(); + let alias = aliases.0.join("checkout"); + std::os::unix::fs::symlink(root, &alias).unwrap(); + let aliased = changes_by_path(root, &[(alias.join("unchanged"), "dirty\n".into())]); + assert_eq!(aliased[Path::new("unchanged")].status, FileStatus::Modified); + fixture_command(root, "git", &["config", "core.filemode", "0"]); + assert!( + !changes_by_path(root, &[(root.join("mode"), "mode\n".into())]) + .contains_key(Path::new("mode")) + ); + fixture_command(root, "git", &["config", "core.autocrlf", "1"]); + fs::write(root.join("unchanged"), "same\r\n").unwrap(); + assert!(!changes_by_path(root, &[]).contains_key(Path::new("unchanged"))); + fixture_command(root, "git", &["config", "--unset", "core.autocrlf"]); + fixture_command(root, "git", &["config", "core.filemode", "true"]); + fs::write(root.join("unchanged"), "same\n").unwrap(); + assert_eq!(fs::read(root.join(".git/index")).unwrap(), index); + assert_eq!(fs::read(root.join(".git/HEAD")).unwrap(), head); + assert_eq!( + fs::read_to_string(root.join("modified")).unwrap(), + "unstaged\n" + ); + assert!(!root.join("removed").exists()); +} + +#[test] +fn working_git_subdirectory_cross_boundary_moves_worktrees_and_unsafe_roots() { + let temp = picker_git_fixture(); + let root = &temp.0; + fs::create_dir(root.join("sub")).unwrap(); + fs::write(root.join("sub/outgoing"), "out\n").unwrap(); + fs::write(root.join("incoming"), "in\n").unwrap(); + fixture_command(root, "git", &["add", "--all"]); + fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); + fs::rename(root.join("sub/outgoing"), root.join("outgoing")).unwrap(); + fs::rename(root.join("incoming"), root.join("sub/incoming")).unwrap(); + let changes = changes_by_path(&root.join("sub"), &[]); + assert_eq!(changes.len(), 2); + assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Removed); + assert_eq!(changes[Path::new("incoming")].status, FileStatus::Added); + assert_eq!(changes[Path::new("incoming")].old_path, None); + std::os::unix::fs::symlink(root.join("sub"), root.join("linked")).unwrap(); + assert!(changes_by_path(&root.join("linked"), &[]).is_empty()); + assert!(changes_by_path(&root.join(".git"), &[]).is_empty()); + let unrelated = Temporary::new().unwrap(); + assert!(changes_by_path(&unrelated.0, &[]).is_empty()); + let worktree = temp.0.join("worktree"); + fixture_command( + root, + "git", + &["worktree", "add", "--quiet", "--detach", "worktree", "HEAD"], + ); + fs::write(worktree.join("sub/outgoing"), "worktree edit\n").unwrap(); + let changes = changes_by_path(&worktree.join("sub"), &[]); + assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Modified); +} + +#[test] +fn working_unborn_git_never_runs_filters_external_diff_textconv_or_hooks() { + let temp = picker_git_fixture(); + let root = &temp.0; + fs::write(root.join(".gitignore"), "ignored\n").unwrap(); + fs::write(root.join("tracked"), "tracked\n").unwrap(); + fs::write(root.join("untracked"), "untracked\n").unwrap(); + fs::write(root.join("ignored"), "ignored\n").unwrap(); + fixture_command(root, "git", &["add", "--", ".gitignore", "tracked"]); + let changes = changes_by_path(root, &[(root.join("named"), "not saved\n".into())]); + for name in [".gitignore", "tracked", "untracked", "named"] { + assert_eq!(changes[Path::new(name)].status, FileStatus::Added); + } + assert!(!changes.contains_key(Path::new("ignored"))); + assert!(!root.join("named").exists()); + fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); + fs::write( + root.join(".gitattributes"), + "tracked filter=evil diff=evil\n", + ) + .unwrap(); + let evil = "sh -c 'touch executed; cat'"; + for key in [ + "filter.evil.clean", + "filter.evil.process", + "diff.evil.command", + "diff.evil.textconv", + ] { + fixture_command(root, "git", &["config", key, evil]); + } + fixture_command(root, "git", &["config", "filter.evil.required", "true"]); + fs::write(root.join("tracked"), "changed\n").unwrap(); + assert_eq!( + changes_by_path(root, &[])[Path::new("tracked")].status, + FileStatus::Modified + ); + assert_eq!( + changes_by_path(root, &[(root.join("tracked"), "tracked\n".into())]) + .get(Path::new("tracked")), + None + ); + assert!(!root.join("executed").exists()); + assert!( + working_file_changes(root, &[], &|| true) + .unwrap_err() + .contains("cancelled") + ); + assert!( + working_file_changes( + root, + &[(root.join("tracked"), "x".repeat(FILE_LIMIT + 1).into())], + &|| false + ) + .unwrap_err() + .contains("16 MiB") + ); +} + +#[test] +fn working_jj_root_parent_colocation_and_merge_queries_do_not_snapshot() { + for colocate in ["--colocate", "--no-colocate"] { + let temp = Temporary::new().unwrap(); + let root = &temp.0; + fixture_command(root, "jj", &["git", "init", colocate, "."]); + fs::write(root.join("file"), "baseline\n").unwrap(); + assert_eq!( + changes_by_path(root, &[])[Path::new("file")].status, + FileStatus::Added + ); + fs::write(root.join(".gitignore"), "ignored\n").unwrap(); + fixture_command(root, "jj", &["describe", "-m", "Baseline"]); + fixture_command(root, "jj", &["new"]); + let operation = fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id", + ], + ); + let parent = fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@-", + "-T", + "commit_id", + ], + ); + let store = store_for_jj(root).unwrap(); + let head = fs::read(store.join("HEAD")).unwrap(); + let index = fs::read(store.join("index")).ok(); + fs::write(root.join("file"), "disk\n").unwrap(); + fs::write(root.join("new"), "new\n").unwrap(); + fs::write(root.join("ignored"), "ignored\n").unwrap(); + let changes = changes_by_path(root, &[]); + assert_eq!(changes[Path::new("file")].status, FileStatus::Modified); + assert_eq!(changes[Path::new("new")].status, FileStatus::Added); + assert!(!changes.contains_key(Path::new("ignored"))); + assert!( + !changes_by_path(root, &[(root.join("file"), "baseline\n".into())]) + .contains_key(Path::new("file")) + ); + assert_eq!(fs::read(store.join("HEAD")).unwrap(), head); + assert_eq!(fs::read(store.join("index")).ok(), index); + assert_eq!( + fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id" + ] + ), + operation + ); + fixture_command(root, "jj", &["new", "root()"]); + fs::write(root.join("sibling"), "sibling\n").unwrap(); + fixture_command(root, "jj", &["describe", "-m", "Sibling"]); + fixture_command(root, "jj", &["new", &parent, "@"]); + assert!(changes_by_path(root, &[]).is_empty()); + } +} + +#[test] +fn working_jj_conflicted_parent_uses_cli_projection_without_snapshotting() { + for colocate in ["--colocate", "--no-colocate"] { + let temp = Temporary::new().unwrap(); + let root = &temp.0; + fixture_command(root, "jj", &["git", "init", colocate, "."]); + fs::write(root.join("conflicted"), "base\n").unwrap(); + fixture_command(root, "jj", &["describe", "-m", "Base"]); + let base = fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@", + "-T", + "commit_id", + ], + ); + fixture_command(root, "jj", &["new"]); + fs::write(root.join("conflicted"), "left\n").unwrap(); + fixture_command(root, "jj", &["describe", "-m", "Left"]); + let left = fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@", + "-T", + "commit_id", + ], + ); + fixture_command(root, "jj", &["new", &base]); + fs::write(root.join("conflicted"), "right\n").unwrap(); + fixture_command(root, "jj", &["describe", "-m", "Right"]); + let right = fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@", + "-T", + "commit_id", + ], + ); + fixture_command(root, "jj", &["new", &left, &right]); + fixture_command(root, "jj", &["new"]); + let projected = fs::read_to_string(root.join("conflicted")).unwrap(); + assert!(projected.contains("<<<<<<<")); + let operation = fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id", + ], + ); + let mut reader = WorkingFileReader::default(); + assert!(reader.read(root, &[], &|| false).unwrap().is_empty()); + fs::write(root.join("conflicted"), "resolved in working file\n").unwrap(); + let changes = reader.read(root, &[], &|| false).unwrap(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].path, Path::new("conflicted")); + assert_eq!(changes[0].status, FileStatus::Modified); + assert!( + reader + .read( + root, + &[(root.join("conflicted"), projected.into())], + &|| false + ) + .unwrap() + .is_empty() + ); + assert_eq!( + fs::read_to_string(root.join("conflicted")).unwrap(), + "resolved in working file\n" + ); + assert_eq!( + fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id" + ] + ), + operation + ); + } +} + +#[test] +fn cancellation_kills_pipe_holding_children_and_drains_both_streams() { + let temp = Temporary::new().unwrap(); + let ready = temp.0.join("ready"); + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = Arc::clone(&cancelled); + let gate = ready.clone(); + let (sent, received) = mpsc::channel(); + let worker = thread::spawn(move || { + let mut command = Command::new("sh"); + command + .args([ + "-c", + "sleep 600 & printf ready > \"$1\"; wait", + "vamp-diff-test", + ]) + .arg(gate); + let result = run(command, vec![], &worker_cancelled).map(|_| ()); + sent.send(result).unwrap(); + }); + // File handshake, not a wall-clock scheduling assumption. + while !ready.exists() { + if let Ok(result) = received.try_recv() { + panic!("Process exited before the child was ready: {result:?}"); + } + thread::yield_now(); + } + cancelled.store(true, Ordering::Release); + assert!(received.recv().unwrap().unwrap_err().contains("cancelled")); + worker.join().unwrap(); + + let mut command = Command::new("sh"); + command.args(["-c", "i=0; while [ \"$i\" -lt 10000 ]; do printf 'line\\n'; printf 'error\\n' >&2; i=$((i+1)); done"]); + let result = run(command, vec![], &AtomicBool::new(false)).unwrap(); + assert_eq!(result.code, Some(0)); + assert_eq!(result.stdout.len(), 50_000); + assert_eq!(result.stderr.len(), 60_000); +} diff --git a/tests/unit/resolve.rs b/tests/unit/resolve.rs new file mode 100644 index 0000000..bf02671 --- /dev/null +++ b/tests/unit/resolve.rs @@ -0,0 +1,325 @@ +use super::*; +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "vamp-resolver-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn jj(&self, args: &[&str]) -> String { + let output = Command::new("jj") + .current_dir(&self.0) + .env("JJ_CONFIG", "/dev/null") + .args([ + "--config=user.name=Resolver", + "--config=user.email=resolver@example.test", + "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", + "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", + ]) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() + } + fn conflict(&self) { + self.jj(&["git", "init", "--no-colocate", "."]); + self.jj(&["config", "set", "--repo", "user.name", "Resolver"]); + self.jj(&[ + "config", + "set", + "--repo", + "user.email", + "resolver@example.test", + ]); + self.jj(&[ + "config", + "set", + "--repo", + "debug.commit-timestamp", + "\"2001-01-01T00:00:00Z\"", + ]); + self.jj(&[ + "config", + "set", + "--repo", + "debug.operation-timestamp", + "\"2001-01-01T00:00:00Z\"", + ]); + fs::write(self.0.join("file"), "base\n").unwrap(); + self.jj(&["bookmark", "create", "base"]); + self.jj(&["new", "base"]); + fs::write(self.0.join("file"), "left\n").unwrap(); + self.jj(&["bookmark", "create", "left"]); + self.jj(&["new", "base"]); + fs::write(self.0.join("file"), "right\n").unwrap(); + self.jj(&["bookmark", "create", "right"]); + self.jj(&["new", "left", "right"]); + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +#[test] +fn discovery_is_readonly_and_save_uses_jj_authority_and_stale_guards() { + let fixture = Fixture::new(); + fixture.conflict(); + let operation = fixture.jj(&[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id", + ]); + let workspace = load(&fixture.0, &|| false).unwrap(); + assert_eq!(workspace.files.len(), 1); + let file = &workspace.files[0]; + assert!(file.sources.iter().any(|s| s.base && s.bytes == b"base\n")); + assert!(file.sources.iter().any(|s| !s.base && s.bytes == b"left\n")); + assert!( + file.sources + .iter() + .any(|s| !s.base && s.bytes == b"right\n") + ); + for bookmark in ["base", "left", "right"] { + assert!( + file.sources + .iter() + .any(|s| s.label.contains(&format!("[{bookmark}]"))) + ); + } + assert_eq!( + operation, + fixture.jj(&[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id" + ]) + ); + let blocks = regions(file.result.as_ref().unwrap()); + assert_eq!(blocks.len(), 1); + for source in &file.sources { + assert_eq!(blocks[0].choose(source).unwrap().as_bytes(), source.bytes); + } + let mut changed = blocks[0].clone(); + changed.sides.pop(); + assert!(changed.choose(&file.sources[0]).is_err()); + fs::write(fixture.0.join("file"), "external\n").unwrap(); + assert!( + save(&workspace, 0, Resolution::Text("lost\n".into()), &|| false) + .unwrap_err() + .contains("externally") + ); + fs::write(fixture.0.join("file"), file.result.as_ref().unwrap()).unwrap(); + fixture.jj(&["new", "base"]); + let before = fs::read(fixture.0.join("file")).unwrap(); + assert!( + save( + &workspace, + 0, + Resolution::Text("wrong checkout\n".into()), + &|| false + ) + .unwrap_err() + .contains("checkout changed") + ); + assert_eq!(fs::read(fixture.0.join("file")).unwrap(), before); +} +#[test] +fn text_choice_is_confirmed_by_jj() { + let fixture = Fixture::new(); + fixture.conflict(); + let mut workspace = load(&fixture.0, &|| false).unwrap(); + let cancelled_after_write = + || fs::read(fixture.0.join("file")).is_ok_and(|bytes| bytes == b"draft\n"); + let saved = save( + &workspace, + 0, + Resolution::Text("draft\n".into()), + &cancelled_after_write, + ) + .unwrap(); + assert_eq!(saved.file.status, Status::Unconfirmed); + assert!(saved.confirmation.is_some()); + assert_eq!(fs::read(fixture.0.join("file")).unwrap(), b"draft\n"); + assert_eq!( + entries(&fixture.0, &workspace.checkout.commit, &|| false).unwrap(), + [PathBuf::from("file")] + ); + workspace.checkout = saved.checkout; + workspace.files[0] = saved.file; + let outcome = save( + &workspace, + 0, + Resolution::Text("resolved\n".into()), + &|| false, + ) + .unwrap(); + assert_eq!( + outcome.file.status, + Status::Resolved, + "{:?}", + outcome.confirmation + ); + assert_eq!(fs::read(fixture.0.join("file")).unwrap(), b"resolved\n"); + assert!( + entries(&fixture.0, &outcome.checkout.commit, &|| false) + .unwrap() + .is_empty() + ); +} +#[test] +fn all_three_sides_and_bases_are_loaded_and_directory_children_are_protected() { + let fixture = Fixture::new(); + fixture.conflict(); + fixture.jj(&["new", "base"]); + fs::write(fixture.0.join("file"), "third\n").unwrap(); + fixture.jj(&["bookmark", "create", "third"]); + fixture.jj(&["new", "left", "right", "third"]); + let workspace = load(&fixture.0, &|| false).unwrap(); + let file = &workspace.files[0]; + assert_eq!(file.sources.iter().filter(|s| !s.base).count(), 3); + assert_eq!(file.sources.iter().filter(|s| s.base).count(), 2); + assert!(file.sources.iter().any(|s| s.bytes == b"third\n")); + let blocks = regions(file.result.as_ref().unwrap()); + for source in &file.sources { + assert_eq!(blocks[0].choose(source).unwrap().as_bytes(), source.bytes); + } + fs::remove_file(fixture.0.join("file")).unwrap(); + fs::create_dir(fixture.0.join("file")).unwrap(); + fs::write(fixture.0.join("file/untracked"), b"keep my draft").unwrap(); + let workspace = load(&fixture.0, &|| false).unwrap(); + assert!( + save(&workspace, 0, Resolution::Source(0), &|| false) + .unwrap_err() + .contains("untracked or modified children") + ); + assert_eq!( + fs::read(fixture.0.join("file/untracked")).unwrap(), + b"keep my draft" + ); +} +#[test] +fn binary_symlink_and_deleted_file_choices_preserve_types_and_bytes() { + let fixture = Fixture::new(); + fixture.conflict(); + fixture.jj(&["edit", "left"]); + fs::write(fixture.0.join("binary"), b"left\0\xff").unwrap(); + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(fixture.0.join("binary"), fs::Permissions::from_mode(0o755)).unwrap(); + } + std::os::unix::fs::symlink("left-target", fixture.0.join("link")).unwrap(); + fixture.jj(&["new", "right"]); + fs::write(fixture.0.join("binary"), b"right\0\xfe").unwrap(); + std::os::unix::fs::symlink("right-target", fixture.0.join("link")).unwrap(); + fixture.jj(&["bookmark", "create", "right-extra"]); + fixture.jj(&["new", "left", "right-extra"]); + let mut workspace = load(&fixture.0, &|| false).unwrap(); + for (name, kind, bytes) in [ + ("binary", FileKind::Binary, b"left\0\xff".as_slice()), + ("link", FileKind::Symlink, b"left-target".as_slice()), + ] { + let file = workspace + .files + .iter() + .position(|f| f.path == Path::new(name)) + .unwrap(); + assert!(workspace.files[file].result.is_none()); + let source = workspace.files[file] + .sources + .iter() + .position(|s| !s.base && s.kind == kind && s.bytes == bytes) + .unwrap(); + assert!( + save( + &workspace, + file, + Resolution::Text("not allowed".into()), + &|| false + ) + .is_err() + ); + let outcome = save(&workspace, file, Resolution::Source(source), &|| false).unwrap(); + assert_eq!( + outcome.file.status, + Status::Resolved, + "{:?}", + outcome.confirmation + ); + workspace.checkout = outcome.checkout; + workspace.files[file] = outcome.file; + if kind == FileKind::Symlink { + assert_eq!( + fs::read_link(fixture.0.join(name)).unwrap(), + Path::new("left-target") + ); + } else { + assert_eq!(fs::read(fixture.0.join(name)).unwrap(), bytes); + } + if kind == FileKind::Binary { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(fixture.0.join(name)) + .unwrap() + .permissions() + .mode() + & 0o111, + 0o111 + ); + } + } + // add/add conflicts retain an absent base as an explicit deletion choice. + let file = workspace + .files + .iter() + .position(|f| f.path == Path::new("binary")) + .unwrap(); + let source = workspace.files[file] + .sources + .iter() + .position(|s| s.kind == FileKind::Deleted) + .unwrap(); + let outcome = save(&workspace, file, Resolution::Source(source), &|| false).unwrap(); + assert_eq!( + outcome.file.status, + Status::Resolved, + "{:?}", + outcome.confirmation + ); + assert!(!fixture.0.join("binary").exists()); +} +#[test] +fn marker_styles_widths_multisides_and_missing_eol() { + let snapshot = "before\n<<<<<<<<<<< conflict 1 of 1\n+++++++++++ a\nleft\n----------- base\nold\n+++++++++++ b\nright\n----------- base2\nold2\n+++++++++++ c\nthird\n>>>>>>>>>>> conflict 1 of 1 ends\nafter\n"; + let parsed = regions(snapshot); + assert_eq!(parsed[0].lines, 1..13); + assert_eq!(parsed[0].sides, ["left\n", "right\n", "third\n"]); + assert_eq!(parsed[0].bases, ["old\n", "old2\n"]); + let prefixed = format!("<<<<<<< literal marker\n{snapshot}"); + assert_eq!(regions(&prefixed).len(), 1); + assert_eq!(regions(&prefixed)[0].lines, 2..14); + let diff = "<<<<<<< conflict 1 of 1\n%%%%%%% diff from: base\n^^^^^^^ to: left\n-old\n+left\n+++++++ right\nright\n>>>>>>> conflict 1 of 1 ends\n"; + assert_eq!(regions(diff)[0].sides, ["left\n", "right\n"]); + let git = "<<<<<<< left\nleft\n||||||| base\nold\n=======\nright\n>>>>>>> right"; + assert_eq!(regions(git)[0].sides, ["left", "right"]); + assert!(regions("<<<<<<< unfinished\nleft\n").is_empty()); +} diff --git a/tests/unit/resolve_projection.rs b/tests/unit/resolve_projection.rs new file mode 100644 index 0000000..5be11c4 --- /dev/null +++ b/tests/unit/resolve_projection.rs @@ -0,0 +1,143 @@ +use super::*; + +fn test_sources(sides: usize) -> Vec { + (0..sides * 2 - 1) + .map(|index| Source { + label: format!("source {index}"), + base: index % 2 == 1, + kind: FileKind::Text, + bytes: b"historical full file; must never replace working text\n".to_vec(), + tree_id: String::new(), + term: index / 2, + sides, + bases: sides - 1, + node: Node::Absent, + }) + .collect() +} + +fn snapshot(left: &str, base: &str, right: &str) -> String { + format!( + "<<<<<<< conflict\n+++++++ side 1\n{left}------- base\n{base}+++++++ side 2\n{right}>>>>>>> conflict ends\n" + ) +} + +#[test] +fn current_working_projections_keep_partial_resolutions_and_multiple_regions() { + let sources = test_sources(2); + let raw = format!( + "unsaved heading\nalready manually resolved\n{}auto-merged middle\n<<<<<<< conflict\n%%%%%%% Changes from base to side 1\n\\\\\\\\\\\\\\ continuation\n-old two\n+left two\n+++++++ side 2\nright two\n>>>>>>> conflict ends\nunsaved tail", + snapshot("left one\n", "old one\n", "right one\n") + ); + for (index, first, second) in [ + (0, "left one", "left two"), + (1, "old one", "old two"), + (2, "right one", "right two"), + ] { + assert_eq!( + project(&raw, &sources[index]).unwrap(), + format!( + "unsaved heading\nalready manually resolved\n{first}\nauto-merged middle\n{second}\nunsaved tail" + ) + ); + } +} + +#[test] +fn alternatives_are_frozen_from_the_same_working_text_as_the_seed() { + let sources = test_sources(2); + let raw = format!( + "unsaved heading\n{}auto-merged middle\n{}unsaved tail", + snapshot("left-one\n", "old-one\n", "right-one\n"), + snapshot("left-two\n", "old-two\n", "right-two\n") + ); + let projection = Projection::new(&raw, &sources, 0).unwrap(); + let mut draft = projection.seed().to_owned(); + draft = draft.replace("unsaved heading", "later draft edit"); + draft.push_str("\nnew draft ending\n"); + assert_eq!( + projection.alternative(2).unwrap(), + "unsaved heading\nright-one\nauto-merged middle\nright-two\nunsaved tail" + ); + assert_eq!( + projection.alternative(1).unwrap(), + "unsaved heading\nold-one\nauto-merged middle\nold-two\nunsaved tail" + ); + assert_eq!( + projection.seed(), + "unsaved heading\nleft-one\nauto-merged middle\nleft-two\nunsaved tail" + ); + assert_eq!( + draft, + "later draft edit\nleft-one\nauto-merged middle\nleft-two\nunsaved tail\nnew draft ending\n" + ); +} + +#[test] +fn all_multiway_sides_and_bases_project_the_same_current_context() { + let sources = test_sources(3); + let raw = "unsaved α\n<<<<<<<<<<< conflict\n+++++++++++ side 1\none\n----------- base 1\nold one\n+++++++++++ side 2\ntwo\n----------- base 2\nold two\n+++++++++++ side 3\nthree\n>>>>>>>>>>> conflict ends\nunsaved ω"; + let projection = Projection::new(raw, &sources, 0).unwrap(); + for (index, choice) in ["one", "old one", "two", "old two", "three"] + .into_iter() + .enumerate() + { + let expected = format!("unsaved α\n{choice}\nunsaved ω"); + assert_eq!(project(raw, &sources[index]).unwrap(), expected); + assert_eq!(projection.alternative(index).unwrap(), expected); + } + assert_eq!( + Projection::new(raw, &sources, 4).unwrap().seed(), + "unsaved α\nthree\nunsaved ω" + ); +} + +#[test] +fn projections_preserve_mixed_eof_and_crlf_terms() { + let sources = test_sources(2); + for eol in ["\n", "\r\n"] { + let choices = [ + format!("left{eol}"), + "old".to_owned(), + format!("right{eol}{eol}"), + ]; + // jj appends one separator EOL to *each* term when any lacks an EOL, + // and omits the EOL on the final marker. Existing term EOLs remain. + let raw = format!( + "heading{eol}<<<<<<< conflict{eol}+++++++ side 1{eol}{}{eol}------- base{eol}{}{eol}+++++++ side 2{eol}{}{eol}>>>>>>> conflict ends", + choices[0], choices[1], choices[2] + ); + let projection = Projection::new(&raw, &sources, 0).unwrap(); + for (index, choice) in choices.iter().enumerate() { + assert_eq!( + project(&raw, &sources[index]).unwrap(), + format!("heading{eol}{choice}") + ); + assert_eq!( + projection.alternative(index).unwrap(), + format!("heading{eol}{choice}") + ); + } + } +} + +#[test] +fn empty_choices_markerfree_text_and_invalid_choices_are_handled() { + let mut sources = test_sources(2); + sources[1].kind = FileKind::Deleted; + let raw = format!("before\n{}after\n", snapshot("", "", "new\n")); + let projection = Projection::new(&raw, &sources, 0).unwrap(); + assert_eq!(projection.seed(), "before\nafter\n"); + assert_eq!(projection.alternative(1).unwrap(), "before\nafter\n"); + assert_eq!(projection.alternative(2).unwrap(), "before\nnew\nafter\n"); + let resolved = Projection::new("manually resolved, no final EOL", &sources, 0).unwrap(); + assert_eq!( + resolved.alternative(2).unwrap(), + "manually resolved, no final EOL" + ); + assert!(resolved.alternative(sources.len()).is_err()); + assert!(Projection::new(&raw, &sources, sources.len()).is_err()); + assert!(Projection::new(&raw, &test_sources(3), 0).is_err()); + sources[0].kind = FileKind::Binary; + assert!(project(&raw, &sources[0]).is_err()); +} diff --git a/tests/unit/review.rs b/tests/unit/review.rs new file mode 100644 index 0000000..67edc1d --- /dev/null +++ b/tests/unit/review.rs @@ -0,0 +1,518 @@ +use super::*; + +struct Directory(PathBuf); +impl Drop for Directory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn directory() -> Directory { + let directory = Directory(std::env::temp_dir().join(format!( + "vamp-review-test-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed), + ))); + fs::create_dir(&directory.0).unwrap(); + directory +} + +#[test] +fn database_restarts_preserve_identity_and_exact_stale_anchors() { + let directory = directory(); + let root = directory.0.canonicalize().unwrap(); + let storage = root.join("storage"); + let mut review = Review::new(root.clone()); + review.summary = "Unicode review\nMore context".into(); + review.verdict = Some(Verdict::ChangesRequested); + review.comments.push(Comment { + id: 7, + anchor: Anchor { + revision: "working:old-snapshot".into(), + path: "日本語.rs".into(), + side: Side::Before, + start: crate::plugin::TextPosition { row: 0, column: 1 }, + end: crate::plugin::TextPosition { row: 1, column: 1 }, + content: "a👩‍🔬e\u{301}\r\n界x\n".into(), + }, + body: "Please preserve 👩‍🔬".into(), + resolved: true, + }); + { + let mut store = Store::open(&storage).unwrap(); + store.save(&mut review).unwrap(); + assert!(review.storage_id.is_some()); + assert_eq!(review.storage_revision, 1); + } + let mut store = Store::open(&storage).unwrap(); + let id = review.storage_id.unwrap(); + assert_eq!( + store.list(&root).unwrap(), + vec![(id, "Unicode review".into())] + ); + let mut loaded = store.load(&root, id).unwrap(); + assert_eq!(loaded.storage_id, Some(id)); + assert_eq!(loaded.storage_revision, 1); + assert_eq!(loaded.comments, review.comments); + assert_eq!(loaded.verdict, review.verdict); + assert!(!loaded.comments[0].anchor.matches( + "working:new-snapshot", + Path::new("日本語.rs"), + Side::Before, + "changed\n", + )); + let path = root.join("export.json"); + loaded.save(&path).unwrap(); + let exported = fs::read(&path).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&exported).unwrap(); + assert_eq!(json["comments"][0]["resolved"], true); + assert_eq!(json["version"], 3); + assert!(json.get("storage_id").is_none()); + assert!(json.get("storage_revision").is_none()); + loaded.summary = "Database only".into(); + store.save(&mut loaded).unwrap(); + assert_eq!(loaded.storage_id, Some(id)); + assert_eq!(loaded.storage_revision, 2); + assert_eq!(fs::read(&path).unwrap(), exported); + let mut imported = Review::load(&path, &root).unwrap(); + assert_eq!(imported.storage_id, None); + assert_eq!(imported.storage_revision, 0); + store.save(&mut imported).unwrap(); + assert_ne!(imported.storage_id, Some(id)); + assert_eq!(store.list(&root).unwrap().len(), 2); +} + +#[test] +fn database_repository_identity_is_canonical_and_independent() { + let first = directory(); + let second = directory(); + let first_root = first.0.canonicalize().unwrap(); + let second_root = second.0.canonicalize().unwrap(); + let mut store = Store::open(&first_root.join("storage")).unwrap(); + let mut a = Review::new(first_root.clone()); + let mut b = Review::new(second_root.clone()); + store.save(&mut a).unwrap(); + store.save(&mut b).unwrap(); + assert_ne!(a.storage_id, b.storage_id); + assert_eq!(store.list(&first_root).unwrap().len(), 1); + assert_eq!(store.list(&second_root).unwrap().len(), 1); + assert!(store.load(&second_root, a.storage_id.unwrap()).is_err()); + let id = a.storage_id; + let revision = a.storage_revision; + a.root = second_root; + assert!(store.save(&mut a).is_err()); + assert_eq!((a.storage_id, a.storage_revision), (id, revision)); + #[cfg(unix)] + { + let alias = first_root.join("alias"); + std::os::unix::fs::symlink(&first_root, &alias).unwrap(); + assert_eq!( + store.list(&alias).unwrap(), + store.list(&first_root).unwrap() + ); + } +} + +#[test] +fn database_conflicts_and_locked_writes_keep_drafts_and_identity() { + let directory = directory(); + let root = directory.0.canonicalize().unwrap(); + let storage = root.join("storage"); + let mut first = Store::open(&storage).unwrap(); + let mut second = Store::open(&storage).unwrap(); + let mut review = Review::new(root.clone()); + first.save(&mut review).unwrap(); + let id = review.storage_id.unwrap(); + let mut draft = second.load(&root, id).unwrap(); + review.summary = "First writer".into(); + first.save(&mut review).unwrap(); + draft.summary = "Second writer draft".into(); + assert!( + second + .save(&mut draft) + .unwrap_err() + .contains("another session") + ); + assert_eq!(draft.summary, "Second writer draft"); + assert_eq!((draft.storage_id, draft.storage_revision), (Some(id), 1)); + assert_eq!(second.load(&root, id).unwrap().summary, "First writer"); + first.connection.execute_batch("BEGIN IMMEDIATE").unwrap(); + let mut fresh = Review::new(root.clone()); + assert!(second.save(&mut fresh).unwrap_err().contains("locked")); + assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0)); + first.connection.execute_batch("ROLLBACK").unwrap(); + second.save(&mut fresh).unwrap(); + assert!(fresh.storage_id.is_some()); +} + +#[test] +fn incompatible_database_schemas_are_preserved() { + for schema in [ + "CREATE TABLE other (value TEXT); PRAGMA user_version = 42", + "CREATE TABLE other (value TEXT); PRAGMA user_version = 1", + "", + ] { + let directory = directory(); + let path = directory.0.join("reviews.sqlite3"); + let connection = Connection::open(&path).unwrap(); + connection.execute_batch(schema).unwrap(); + drop(connection); + let original = fs::read(&path).unwrap(); + assert!(Store::open(&directory.0).err().unwrap().contains("schema")); + assert_eq!(fs::read(&path).unwrap(), original); + } +} + +#[test] +fn malformed_or_mismatched_database_documents_are_rejected() { + let other = directory(); + let directory = directory(); + let root = directory.0.canonicalize().unwrap(); + let mut store = Store::open(&root.join("storage")).unwrap(); + let mut review = Review::new(root.clone()); + store.save(&mut review).unwrap(); + let id = review.storage_id.unwrap(); + let mut wrong_root = serde_json::to_value(&review).unwrap(); + wrong_root["root"] = serde_json::json!(other.0.canonicalize().unwrap()); + let mut invalid_version = serde_json::to_value(&review).unwrap(); + invalid_version["version"] = 1.into(); + let mut inherited_identity = serde_json::to_value(&review).unwrap(); + inherited_identity["storage_id"] = id.into(); + for document in [ + "{".to_owned(), + serde_json::to_string(&wrong_root).unwrap(), + serde_json::to_string(&invalid_version).unwrap(), + serde_json::to_string(&inherited_identity).unwrap(), + ] { + store + .connection + .execute( + "UPDATE reviews SET document = ?1 WHERE id = ?2", + params![document, id], + ) + .unwrap(); + assert!(store.load(&root, id).is_err()); + assert!(store.list(&root).is_err()); + } + store + .connection + .execute_batch("PRAGMA ignore_check_constraints = ON") + .unwrap(); + store + .connection + .execute( + "UPDATE reviews SET document = CAST(zeroblob(?1) AS TEXT) WHERE id = ?2", + params![LIMIT as i64 + 1, id], + ) + .unwrap(); + assert!(store.load(&root, id).unwrap_err().contains("64 MiB")); + assert!(store.list(&root).unwrap_err().contains("64 MiB")); +} + +#[test] +fn failed_database_and_export_writes_preserve_metadata_and_documents() { + let directory = directory(); + let root = directory.0.canonicalize().unwrap(); + let mut store = Store::open(&root.join("storage")).unwrap(); + let mut review = Review::new(root.clone()); + store.save(&mut review).unwrap(); + let identity = (review.storage_id, review.storage_revision); + store + .connection + .execute_batch("PRAGMA query_only = ON") + .unwrap(); + review.summary = "Unsaved draft".into(); + assert!(store.save(&mut review).is_err()); + assert_eq!((review.storage_id, review.storage_revision), identity); + assert_eq!(review.summary, "Unsaved draft"); + assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, ""); + let mut fresh = Review::new(root.clone()); + assert!(store.save(&mut fresh).is_err()); + assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0)); + store + .connection + .execute_batch("PRAGMA query_only = OFF") + .unwrap(); + review.summary = "x".repeat(LIMIT); + assert!(store.save(&mut review).unwrap_err().contains("64 MiB")); + assert!( + review + .save(&root.join("oversized.json")) + .unwrap_err() + .contains("64 MiB") + ); + assert!(!root.join("oversized.json").exists()); + assert_eq!((review.storage_id, review.storage_revision), identity); + assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, ""); + let blocked = root.join("not-a-directory"); + fs::write(&blocked, b"preserve").unwrap(); + assert!(Store::open(&blocked).is_err()); + assert_eq!(fs::read(&blocked).unwrap(), b"preserve"); +} + +#[cfg(unix)] +#[test] +fn database_files_are_private_and_target_symlinks_are_refused() { + use std::os::unix::fs::PermissionsExt; + let directory = directory(); + let storage = directory.0.join("storage"); + drop(Store::open(&storage).unwrap()); + let path = storage.join("reviews.sqlite3"); + assert_eq!( + fs::metadata(&storage).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let original = fs::read(&path).unwrap(); + let links = directory.0.join("links"); + fs::create_dir(&links).unwrap(); + std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap(); + assert!(Store::open(&links).err().unwrap().contains("symbolic link")); + assert_eq!(fs::read(&path).unwrap(), original); +} + +#[test] +fn default_directory_uses_absolute_xdg_then_absolute_home() { + let resolve = |xdg: Option<&str>, home: Option<&str>| { + directory_from(xdg.map(OsStr::new), home.map(OsStr::new)) + }; + assert_eq!( + resolve(Some("/data"), Some("/home/me")).unwrap(), + Path::new("/data/vamp/reviews") + ); + for xdg in [None, Some("relative"), Some("")] { + assert_eq!( + resolve(xdg, Some("/home/me")).unwrap(), + Path::new("/home/me/.local/share/vamp/reviews") + ); + } + assert_eq!( + resolve(Some("/data"), None).unwrap(), + Path::new("/data/vamp/reviews") + ); + assert!(resolve(None, None).is_err()); + assert!(resolve(None, Some("relative")).is_err()); + assert!(resolve(None, Some("")).is_err()); +} + +#[test] +fn persistence_validates_anchors_and_preserves_files_on_failure() { + let directory = directory(); + let root = directory.0.canonicalize().unwrap(); + let path = root.join("review.json"); + let mut review = Review::new(root.clone()); + review.summary = "Local review".into(); + review.verdict = Some(Verdict::ChangesRequested); + review.comments.push(Comment { + id: 1, + anchor: Anchor { + revision: "working:immutable".into(), + path: "source.rs".into(), + side: Side::After, + start: crate::plugin::TextPosition { row: 0, column: 0 }, + end: crate::plugin::TextPosition { row: 2, column: 0 }, + content: "first\nlast\n".into(), + }, + body: "Please adjust this".into(), + resolved: false, + }); + review.save(&path).unwrap(); + let mut loaded = Review::load(&path, &root).unwrap(); + assert_eq!(loaded.comments, review.comments); + assert_eq!(loaded.verdict, review.verdict); + let anchor = &loaded.comments[0].anchor; + assert!(anchor.matches( + "working:immutable", + Path::new("source.rs"), + Side::After, + "first\nlast\n" + )); + assert!(!anchor.matches( + "working:other", + Path::new("source.rs"), + Side::After, + "first\nlast\n" + )); + assert!(anchor.matches_lines( + "working:immutable", + Path::new("source.rs"), + Side::After, + &["first".into(), "last".into()], + true + )); + assert!(!anchor.matches_lines( + "working:immutable", + Path::new("source.rs"), + Side::After, + &["first".into(), "last".into()], + false + )); + loaded.summary = "Updated".into(); + loaded.save(&path).unwrap(); + assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated"); + assert!(Review::new(root.clone()).save(&path).is_err()); + let valid = fs::read(&path).unwrap(); + for (field, value) in [ + ("version", serde_json::json!(2)), + ("root", serde_json::json!(root.join("other"))), + ( + "comments", + serde_json::json!([loaded.comments[0], loaded.comments[0]]), + ), + ] { + let mut invalid = serde_json::to_value(&loaded).unwrap(); + invalid[field] = value; + fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap(); + assert!(Review::load(&path, &root).is_err()); + } + for (field, value) in [ + ("path", serde_json::json!("../outside")), + ("path", serde_json::json!("/absolute")), + ("path", serde_json::json!(".git/config")), + ("start", serde_json::json!({"row": 2, "column": 1})), + ("start", serde_json::json!({"row": 3, "column": 0})), + ("end", serde_json::json!({"row": 3, "column": 0})), + ("revision", serde_json::json!("")), + ] { + let mut invalid = serde_json::to_value(&loaded).unwrap(); + invalid["comments"][0]["anchor"][field] = value; + fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap(); + assert!(Review::load(&path, &root).is_err()); + } + fs::create_dir(root.join("other")).unwrap(); + fs::write(&path, &valid).unwrap(); + assert!(Review::load(&path, &root.join("other")).is_err()); + fs::write(&path, b"external edit").unwrap(); + assert!(loaded.save(&path).is_err()); + assert_eq!(fs::read(&path).unwrap(), b"external edit"); + assert_eq!(loaded.summary, "Updated"); + let invalid = root.join("invalid.json"); + loaded.comments[0].anchor.start.row = usize::MAX; + assert!(loaded.save(&invalid).is_err()); + assert!(!invalid.exists()); + let large = root.join("large.json"); + fs::File::create(&large) + .unwrap() + .set_len(LIMIT as u64 + 1) + .unwrap(); + assert!(Review::load(&large, &root).is_err()); + #[cfg(unix)] + { + let link = root.join("link.json"); + std::os::unix::fs::symlink(&path, &link).unwrap(); + assert!(Review::load(&link, &root).is_err()); + assert!(review.save(&link).is_err()); + assert_eq!(fs::read(&path).unwrap(), b"external edit"); + } +} + +#[test] +fn stored_version_two_is_rejected_without_rewriting_it() { + let directory = directory(); + let root = directory.0.canonicalize().unwrap(); + let mut store = Store::open(&root.join("storage")).unwrap(); + let mut review = Review::new(root.clone()); + store.save(&mut review).unwrap(); + let mut old = serde_json::to_value(&review).unwrap(); + old["version"] = 2.into(); + let document = serde_json::to_string(&old).unwrap(); + store + .connection + .execute( + "UPDATE reviews SET document = ?1 WHERE id = ?2", + params![document, review.storage_id.unwrap()], + ) + .unwrap(); + assert!( + store + .load(&root, review.storage_id.unwrap()) + .unwrap_err() + .contains("Unsupported review version 2") + ); + assert!( + store + .list(&root) + .unwrap_err() + .contains("Unsupported review version 2") + ); + let preserved: String = store + .connection + .query_row( + "SELECT document FROM reviews WHERE id = ?1", + [review.storage_id.unwrap()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(preserved, document); +} + +#[test] +fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() { + use crate::plugin::TextPosition; + let directory = directory(); + let root = directory.0.canonicalize().unwrap(); + let path = root.join("review.json"); + let mut review = Review::new(root.clone()); + review.comments.push(Comment { + id: 1, + anchor: Anchor { + revision: "working:immutable".into(), + path: "source.rs".into(), + side: Side::After, + start: TextPosition { row: 0, column: 1 }, + end: TextPosition { row: 1, column: 1 }, + content: "a👩‍🔬e\u{301}\n界x\n".into(), + }, + body: "Exact text".into(), + resolved: false, + }); + review.save(&path).unwrap(); + let mut loaded = Review::load(&path, &root).unwrap(); + assert_eq!(loaded.comments, review.comments); + assert_eq!(loaded.comments[0].anchor.rows(), 0..=1); + let valid = serde_json::to_value(&review).unwrap(); + let mut missing_resolution = valid.clone(); + missing_resolution["comments"][0] + .as_object_mut() + .unwrap() + .remove("resolved"); + for version in [2, 3] { + missing_resolution["version"] = version.into(); + let bytes = serde_json::to_vec(&missing_resolution).unwrap(); + fs::write(&path, &bytes).unwrap(); + let error = Review::load(&path, &root).unwrap_err(); + assert!(error.contains(if version == 2 { + "Unsupported review version 2" + } else { + "missing field `resolved`" + })); + assert_eq!(fs::read(&path).unwrap(), bytes); + } + for (field, value) in [ + ("start", serde_json::json!({"row": 0, "column": 4})), + ("end", serde_json::json!({"row": 1, "column": 3})), + ("end", serde_json::json!({"row": 2, "column": 1})), + ("end", serde_json::json!({"row": 3, "column": 0})), + ("end", serde_json::json!({"row": 0, "column": 0})), + ("start", serde_json::json!({"row": -1, "column": 0})), + ( + "start", + serde_json::json!({"row": 0, "column": 0, "extra": 0}), + ), + ("start_line", serde_json::json!(1)), + ] { + let mut invalid = valid.clone(); + invalid["comments"][0]["anchor"][field] = value; + fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap(); + assert!(Review::load(&path, &root).is_err(), "{field}"); + } + let mut wrong_version = valid; + wrong_version["version"] = 1.into(); + fs::write(&path, serde_json::to_vec(&wrong_version).unwrap()).unwrap(); + assert!(Review::load(&path, &root).is_err()); + loaded.comments[0].anchor.end = TextPosition { row: 0, column: 0 }; + assert!(loaded.save(&root.join("backwards.json")).is_err()); +} diff --git a/tests/unit/review_cli.rs b/tests/unit/review_cli.rs new file mode 100644 index 0000000..e458b2a --- /dev/null +++ b/tests/unit/review_cli.rs @@ -0,0 +1,107 @@ +use super::*; +use vamp::{ + plugin::TextPosition, + review::{Comment, Side}, +}; + +#[test] +fn output_filters_resolution_and_extracts_exact_graphemes() { + let mut review = Review::new(PathBuf::new()); + review.storage_id = Some(7); + review.storage_revision = 2; + review.comments = [false, true] + .into_iter() + .enumerate() + .map(|(id, resolved)| Comment { + id: id as u64 + 1, + body: "fix".into(), + resolved, + anchor: Anchor { + revision: "commit".into(), + path: "a.rs".into(), + side: Side::After, + start: TextPosition { row: 0, column: 1 }, + end: TextPosition { row: 1, column: 1 }, + content: "xe\u{301}👩‍💻\n界z\n".into(), + }, + }) + .collect(); + let output = envelope(&review, false, true); + assert_eq!(output["comments"].as_array().unwrap().len(), 1); + assert_eq!(output["comments"][0]["excerpt"], "e\u{301}👩‍💻\n界"); + assert!(output["comments"][0]["anchor"].get("content").is_none()); + assert_eq!(output["counts"]["resolved"], 1); + assert_eq!( + envelope(&review, true, true)["comments"] + .as_array() + .unwrap() + .len(), + 2 + ); +} + +#[test] +fn absent_storage_is_not_created_and_incompatible_storage_is_preserved() { + let directory = std::env::temp_dir().join(format!("vamp-review-cli-{}", std::process::id())); + std::fs::create_dir(&directory).unwrap(); + let absent = directory.join("absent"); + assert!(Store::open_read_only(&absent).unwrap().is_none()); + assert!(!absent.exists()); + let path = directory.join("reviews.sqlite3"); + std::fs::write(&path, b"incompatible").unwrap(); + assert!(Store::open_read_only(&directory).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"incompatible"); + std::fs::remove_file(&path).unwrap(); + let connection = rusqlite::Connection::open(&path).unwrap(); + connection + .execute_batch("CREATE TABLE incompatible (value TEXT); PRAGMA user_version = 42") + .unwrap(); + drop(connection); + let before = std::fs::read(&path).unwrap(); + assert!( + Store::open_read_only(&directory) + .err() + .unwrap() + .contains("schema") + ); + assert_eq!(std::fs::read(&path).unwrap(), before); + std::fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn existing_storage_is_read_only_and_unchanged() { + let directory = + std::env::temp_dir().join(format!("vamp-review-cli-existing-{}", std::process::id())); + let mut store = Store::open(&directory).unwrap(); + let mut review = Review::new(directory.clone()); + review.summary = "stored".into(); + store.save(&mut review).unwrap(); + drop(store); + let path = directory.join("reviews.sqlite3"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); + } + let before = std::fs::read(&path).unwrap(); + let mut store = Store::open_read_only(&directory).unwrap().unwrap(); + let mut loaded = store.load(&directory, review.storage_id.unwrap()).unwrap(); + assert_eq!(loaded.summary, "stored"); + loaded.summary = "changed".into(); + assert!(store.save(&mut loaded).is_err()); + drop(store); + assert_eq!(std::fs::read(&path).unwrap(), before); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640 + ); + let links = directory.join("links"); + std::fs::create_dir(&links).unwrap(); + std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap(); + assert!(Store::open_read_only(&links).is_err()); + } + std::fs::remove_dir_all(directory).unwrap(); +} diff --git a/tests/unit/ruby.rs b/tests/unit/ruby.rs new file mode 100644 index 0000000..d167bb4 --- /dev/null +++ b/tests/unit/ruby.rs @@ -0,0 +1,2280 @@ +use crate::{editor::Input, session::EditorSession}; + +use super::*; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1); + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let path = env::temp_dir().join(format!( + "vamp-ruby-{}-{}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + + fn file(&self, name: &str, text: &str) -> PathBuf { + let path = self.0.join(name); + std::fs::write(&path, text).unwrap(); + path + } + + fn plugin(&self, text: &str) -> (RubyPlugin, Feature) { + self.plugin_with_options(text, &serde_json::json!({})) + } + + fn plugin_with_options( + &self, + text: &str, + options: &serde_json::Value, + ) -> (RubyPlugin, Feature) { + RubyPlugin::start(&self.file("plugin.rb", text), options).unwrap_or_else(|error| { + panic!("Actual Ruby runtime required (VAMP_RUBY or ruby): {error}") + }) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn context() -> Context { + Context { + buffer_id: 7, + revision: 11, + root: "/project".into(), + text: "α\nhello".into(), + row: 2, + column: 3, + mode: "NORMAL".into(), + path: Some("buffer.txt".into()), + } +} + +#[test] +fn review_directory_resolves_config_paths_and_keeps_session_startup_lazy() { + let fixture = Fixture::new(); + let absolute = fixture.0.join("absolute-reviews"); + for (value, expected) in [ + ( + r#""relative-reviews""#.to_owned(), + Some(fixture.0.join("relative-reviews")), + ), + (serde_json::to_string(&absolute).unwrap(), Some(absolute)), + ("nil".to_owned(), None), + ] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.review_directory = {value} }}"), + ); + assert_eq!(load_config(&path).unwrap().review_directory, expected); + let session = EditorSession::open(None, vec![], Some(&path)).unwrap(); + assert!(session.editor().startup_diagnostic().is_none()); + if let Some(directory) = expected { + assert!( + !directory.exists(), + "startup created {}", + directory.display() + ); + } + } + let path = fixture.file("config.rb", "Vamp.configure { |_| }"); + assert_eq!(load_config(&path).unwrap().review_directory, None); + for value in [ + "7", + "false", + "[]", + "{}", + ":reviews", + r#""""#, + r#""bad\0path""#, + ] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.review_directory = {value} }}"), + ); + let error = load_config(&path).unwrap_err(); + assert!(error.contains("review_directory"), "{value}: {error}"); + } +} + +#[test] +fn review_directory_wire_field_is_required_nullable_and_validated() { + let mut frame = serde_json::json!({ + "version": 1, "type": "config", "plugins": [], "lsp": {}, "bindings": [], + "review_directory": null, "leader": "space", + "file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree", + "clipboard_mode": "system", "git_blame": true, "git_gutter": "bar", + "top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}}, "labels": {}, + "scroll_distance": 0, "scroll_step_lines": null, "highlight_trailing_whitespace": false, + "whitespace_characters": {}, "whitespace_scope": "all", + "tab_spaces": null, "language_tab_spaces": {}, + "diff_context_lines": 10, "diff_expand_lines": 15, + "diff_view_mode": "split", "diff_split_orientation": "vertical", + "resolve_history_layout": "columns", + "diff_follow_working_copy": true + }); + for value in [serde_json::Value::Null, serde_json::json!("reviews")] { + frame["review_directory"] = value; + assert!(serde_json::from_value::(frame.clone()).is_ok()); + } + for value in [ + serde_json::json!(""), + serde_json::json!("bad\0path"), + serde_json::json!(7), + serde_json::json!(false), + serde_json::json!([]), + serde_json::json!({}), + ] { + frame["review_directory"] = value; + assert!(serde_json::from_value::(frame.clone()).is_err()); + } + frame.as_object_mut().unwrap().remove("review_directory"); + assert!(serde_json::from_value::(frame).is_err()); +} + +#[test] +fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() { + let fixture = Fixture::new(); + let config = fixture.file( + "config.rb", + r#" +Vamp.configure do |c| + c.tab_spaces = 4 + c.language_tab_spaces.merge!("ruby" => 2, "makefile" => nil, "json" => 1, "yaml" => 255, "typescriptreact" => 5) + c.language_tab_spaces["cpp"] = 3 +end +"#, + ); + let widest = " ".repeat(255); + for (name, prefix, inserted) in [ + ("default.rs", "", "\t"), + ("buffer.rs", "", " "), + ("buffer.rb", "", " "), + ("Gemfile", "", " "), + ("script", "#!/usr/bin/env ruby\n", " "), + ("Makefile", "", "\t"), + ("plain.txt", "", " "), + ("buffer.json", "", " "), + ("buffer.yaml", "", widest.as_str()), + ("buffer.cpp", "", " "), + ("buffer.tsx", "", " "), + ] { + let source = format!("{prefix}αx\nβy"); + let path = fixture.file(name, &source); + let config = (name != "default.rs").then_some(config.as_path()); + let mut session = EditorSession::open(Some(path), vec![], config).unwrap(); + assert!(session.editor().startup_diagnostic().is_none()); + if !prefix.is_empty() { + session.handle(Input::Char('j')); + } + session.handle(Input::Ctrl('e')); + for input in [ + Input::Char('l'), + Input::Char('i'), + Input::Char('\t'), + Input::Ctrl('i'), + Input::Escape, + ] { + session.handle(input); + } + let expected = format!("{prefix}α{inserted}{inserted}x\nβ{inserted}{inserted}y"); + assert_eq!(session.editor().context().text, expected, "{name}"); + session.handle(Input::Char('u')); + assert_eq!(session.editor().context().text, source, "{name}"); + session.handle(Input::Char('U')); + assert_eq!(session.editor().context().text, expected, "{name}"); + session.handle(Input::Char('u')); + session.handle(Input::Char('.')); + assert_eq!(session.editor().context().text, expected, "{name}"); + session.handle(Input::Char('i')); + session.handle(Input::End); + session.handle(Input::Paste("\t".into())); + assert_eq!( + session.editor().context().text, + format!("{prefix}α{inserted}{inserted}x\t\nβ{inserted}{inserted}y\t"), + "{name}" + ); + } +} + +#[test] +fn tab_spaces_reject_invalid_widths_and_language_maps() { + let fixture = Fixture::new(); + for value in ["0", "-1", "256", "1.5", "true", r#""4""#, "[]", "{}"] { + for assignment in [ + format!("c.tab_spaces = {value}"), + format!(r#"c.language_tab_spaces = {{ "ruby" => {value} }}"#), + format!(r#"c.language_tab_spaces.merge!("ruby" => {value})"#), + format!(r#"c.language_tab_spaces["ruby"] = {value}"#), + ] { + let config = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |c| {assignment} }}"), + ); + let error = load_config(&config).unwrap_err(); + assert!( + error.contains("tab_spaces must be nil or an Integer"), + "{assignment}: {error}" + ); + } + } + for value in [ + "nil", + "[]", + r#"{ "" => 2 }"#, + r#"{ :ruby => 2 }"#, + r#"{ "\n" => 2 }"#, + ] { + let config = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |c| c.language_tab_spaces = {value} }}"), + ); + assert!(load_config(&config).is_err(), "{value}"); + } +} + +#[test] +fn language_tab_space_mutations_reject_invalid_keys_before_serialization() { + let fixture = Fixture::new(); + for key in [r#""""#, ":ruby", r#""\n""#, "7"] { + for assignment in [ + format!("c.language_tab_spaces.merge!({key} => 2)"), + format!("c.language_tab_spaces[{key}] = 2"), + ] { + let config = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |c| {assignment} }}"), + ); + let error = load_config(&config).unwrap_err(); + assert!(error.contains("Tab language"), "{assignment}: {error}"); + } + } +} + +#[test] +fn actual_ruby_dsl_context_queued_effects_and_output_isolation() { + let fixture = Fixture::new(); + let (mut plugin, feature) = fixture.plugin(r#" +puts "startup noise" +STDOUT.write "direct startup noise\n" +warn "warning" +Vamp.plugin "greeting", requires: ["text"] +Vamp.command "greet" do |editor, args| + puts "invocation noise" + STDOUT.write "direct invocation noise\n" + raise "bad context" unless editor.text == "α\nhello" && editor.row == 2 && editor.column == 3 && editor.mode == "NORMAL" && editor.path == "buffer.txt" + raise "bad source" unless editor.buffer_id == 7 && editor.revision == 11 && editor.root == "/project" && editor.request_id > 0 + editor.insert "Hello #{args}!" + editor.replace editor.text + editor.message "Greeting inserted" + editor.label :battery, "界 e\u0301 👩‍🚀\t\n\e" + editor.label "battery", "" +end +"#); + assert_eq!(feature.name, "greeting"); + assert_eq!(feature.required_capabilities, ["text"]); + assert_eq!(feature.commands, ["greet"]); + assert!(!feature.core); + assert!(!plugin.is_async()); + for _ in 0..2 { + let effects = plugin.invoke("greet", "friend", &context()).unwrap(); + assert!( + matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message), Effect::Label { name, text: label }, Effect::Label { name: cleared_name, text: cleared }] + if text == "Hello friend!" && replacement == "α\nhello" && message == "Greeting inserted" + && name == "battery" && cleared_name == "battery" + && label == "界 e\u{301} 👩‍🚀\t\n\u{1b}" && cleared.is_empty()) + ); + } + plugin.disable(); + plugin.disable(); + assert!( + plugin + .invoke("greet", "", &context()) + .unwrap_err() + .contains("disabled") + ); + assert!( + plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none() + ); +} + +#[test] +fn generated_core_actions_execute_through_ruby_configuration() { + use crate::{ + editor::{Input, Mode}, + session::EditorSession, + }; + + let fixture = Fixture::new(); + let config = fixture.file( + "config.rb", + r#"Vamp.configure do |config| + config.clipboard_mode = "separate" + config.bind "Q", config.core.quit + config.bind "F", config.core.force_quit + config.bind "W", config.core.save + config.bind "X", config.core.save_and_quit + config.bind "E", config.core.edit + config.bind "B", config.core.buffer + config.bind "P", config.core.plugins + config.bind "N", config.core.buffer_next + config.bind "V", config.core.buffer_previous + config.bind "D", config.core.buffer_delete + config.bind "Z", config.core.buffer_force_delete + config.bind "R", config.core.diff + config.bind "ctrl+t", config.core.diff.close + config.bind "H", config.core.word_wrap.toggle + raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? && + config.core.diff.close.frozen? && config.core.diff.name.frozen? && + config.core.diff.close.name.frozen? +end"#, + ); + let mut review = EditorSession::open(None, vec!["text".into()], Some(&config)).unwrap(); + assert!(!review.editor().word_wrap()); + review.handle(Input::Char('H')); + assert!(review.editor().word_wrap()); + review.execute("word_wrap.toggle unexpected"); + assert!(review.editor().word_wrap()); + review.execute("word_wrap.toggle"); + assert!(!review.editor().word_wrap()); + review.handle(Input::Char('R')); + review.handle(Input::Escape); + assert!(review.editor().diff().is_none()); + review.handle(Input::Char('R')); + assert!(review.editor().diff().is_some()); + review.handle(Input::Ctrl('t')); + assert!(review.editor().diff().is_none()); + for (key, dirty, quit, saved) in [ + ('Q', false, true, false), + ('Q', true, false, false), + ('F', true, true, false), + ('W', true, false, true), + ('X', true, true, true), + ] { + let buffer = fixture.file("buffer.txt", "base"); + let mut session = + EditorSession::open(Some(buffer.clone()), vec!["text".into()], Some(&config)).unwrap(); + assert!(session.editor().startup_diagnostic().is_none()); + if dirty { + for input in [Input::Char('i'), Input::Char('!'), Input::Escape] { + session.handle(input); + } + } + session.handle(Input::Char(key)); + assert_eq!(session.editor().should_quit(), quit, "{key}"); + assert_eq!( + std::fs::read_to_string(&buffer).unwrap(), + if saved { "!base" } else { "base" }, + "{key}" + ); + assert_eq!(session.editor().dirty(), dirty && !saved, "{key}"); + } + + let buffer = fixture.file("buffer.txt", "base"); + let second = fixture.file("second.txt", "second"); + let mut session = + EditorSession::open(Some(buffer), vec!["text".into()], Some(&config)).unwrap(); + assert!(session.editor().startup_diagnostic().is_none()); + session.handle(Input::Char('E')); + assert_eq!(session.editor().mode(), Mode::Command); + assert_eq!(session.editor().command_line(), "edit "); + for ch in second.to_string_lossy().chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Enter); + assert_eq!(session.editor().lines(), &["second"]); + session.handle(Input::Char('B')); + assert_eq!(session.editor().mode(), Mode::Command); + assert_eq!(session.editor().command_line(), "buffer "); + session.handle(Input::Char('1')); + session.handle(Input::Enter); + assert_eq!(session.editor().buffer_id(), 1); + assert_eq!(session.editor().lines(), &["base"]); + session.handle(Input::Char('P')); + assert!(session.editor().message().contains("text")); + session.handle(Input::Char('N')); + assert_eq!(session.editor().buffer_id(), 2); + session.handle(Input::Char('V')); + assert_eq!(session.editor().buffer_id(), 1); + session.handle(Input::Char('D')); + assert!(!session.editor().has_buffer(1)); + assert_eq!(session.editor().buffer_id(), 2); + for input in [Input::Char('i'), Input::Char('!'), Input::Escape] { + session.handle(input); + } + session.handle(Input::Char('D')); + assert!(session.editor().has_buffer(2)); + session.handle(Input::Char('Z')); + assert!(!session.editor().has_buffer(2)); + assert_eq!(std::fs::read_to_string(second).unwrap(), "second"); +} + +#[test] +fn raw_builtin_aliases_are_not_external_binding_commands() { + let fixture = Fixture::new(); + for name in [ + "quit", + "save", + "buffer_next", + "q", + "w", + "bnext", + "q!", + "wq", + "bprev", + "bdelete", + "bdelete!", + "core.quit", + ] { + let config = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.bind 'K', '{name}' }}"), + ); + assert!(load_config(&config).is_err(), "{name}"); + } +} + +#[test] +fn plugin_commands_cannot_shadow_canonical_commands_or_aliases() { + let fixture = Fixture::new(); + for name in [ + "quit", + "save", + "buffer_next", + "q", + "w", + "bnext", + "q!", + "wq", + "bprev", + "bdelete", + "bdelete!", + ] { + let path = fixture.file( + "plugin.rb", + &format!("Vamp.plugin 'shadow'\nVamp.command '{name}' do |editor, args|\nend"), + ); + assert!( + RubyPlugin::start(&path, &serde_json::json!({})).is_err(), + "plugin shadowed built-in command {name}" + ); + } +} + +#[test] +fn config_resolves_relative_paths_and_rejects_invalid_types_and_core() { + let fixture = Fixture::new(); + let config = fixture.file( + "config.rb", + r#" +puts "configuration noise" +Vamp.configure do |config| + config.plugin "plugins/greeting.rb" + config.bind "g", "greet" +end +"#, + ); + let loaded = load_config(&config).unwrap(); + assert_eq!( + loaded.plugins[0].path, + fixture.0.join("plugins/greeting.rb") + ); + assert_eq!(loaded.plugins[0].options, serde_json::json!({})); + let invalid = fixture.file("invalid.rb", "Vamp.configure { |config| config.plugin 7 }"); + assert!( + load_config(&invalid) + .unwrap_err() + .contains("must be a String") + ); + for target in [ + "7", + "{}", + "config.core.lsp", + "config.core.lsp.hover.tap { |action| action.name = 'core.delete' }", + "config.core.lsp.tap { |namespace| namespace.define_singleton_method(:bogus) { namespace.hover } }.bogus", + "config.core.diff.tap { |action| action.name = 'core.delete' }", + "config.core.diff.tap { |action| action.define_singleton_method(:bogus) { action.close } }.bogus", + "config.core.diff.close.tap { |action| action.name = 'core.delete' }", + ] { + let invalid = fixture.file( + "invalid_action.rb", + &format!("Vamp.configure {{ |config| config.bind 'K', {target} }}"), + ); + assert!(load_config(&invalid).is_err(), "{target}"); + } + let invalid = fixture.file("core.rb", "Vamp.plugin 'evil', core: true"); + assert!(RubyPlugin::start(&invalid, &serde_json::json!({})).is_err()); + let invalid = fixture.file("syntax.rb", "this is not valid ruby ("); + assert!(RubyPlugin::start(&invalid, &serde_json::json!({})).is_err()); +} + +#[test] +fn config_leader_defaults_custom_keys_validation_and_required_wire_field() { + let fixture = Fixture::new(); + let defaults = Config::default(); + assert_eq!(defaults.leader, "space"); + assert!(defaults.plugins.is_empty() && defaults.bindings.is_empty()); + assert!(defaults.lsp.is_empty()); + let path = fixture.file("config.rb", "Vamp.configure { |_| }"); + assert_eq!(load_config(&path).unwrap().leader, "space"); + for (source, key) in [ + (r#"Vamp.configure { |config| config.leader = "," }"#, ","), + (r#"Vamp.configure { |config| config.leader = "\\" }"#, "\\"), + ] { + let path = fixture.file("config.rb", source); + assert_eq!(load_config(&path).unwrap().leader, key); + } + for (value, error) in [ + ("7", "leader key must be a String"), + ("nil", "leader key must be a String"), + (r#""""#, "leader key must not be empty"), + ] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.leader = {value} }}"), + ); + assert!(load_config(&path).unwrap_err().contains(error), "{value}"); + } + for frame in [ + r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#, + r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#, + r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#, + ] { + assert!(serde_json::from_str::(frame).is_err(), "{frame}"); + } +} + +#[test] +fn git_gutter_config_modes_default_and_rejection() { + use crate::gutter::GutterStyle; + let fixture = Fixture::new(); + let path = fixture.file("config.rb", "Vamp.configure { |_config| }"); + assert_eq!(load_config(&path).unwrap().git_gutter, GutterStyle::Bar); + for (name, expected) in [ + ("bar", GutterStyle::Bar), + ("symbols", GutterStyle::Symbols), + ("line_numbers", GutterStyle::LineNumbers), + ("none", GutterStyle::None), + ] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.git_gutter = {name:?} }}"), + ); + assert_eq!(load_config(&path).unwrap().git_gutter, expected); + } + for value in ["\"unsupported\"", "\"\"", "false", "7", ":bar"] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.git_gutter = {value} }}"), + ); + assert!(load_config(&path).is_err(), "{value}"); + } +} + +#[test] +fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() { + let fixture = Fixture::new(); + let config = fixture.file("config.rb", "Vamp.configure { |_config| }"); + assert!(load_config(&config).unwrap().git_blame); + let config = fixture.file("config.rb", r#" +Vamp.configure do |config| + config.git_blame = false + config.bind "G", config.core.git_blame.toggle + raise "mutable blame action" unless config.core.git_blame.frozen? && config.core.git_blame.toggle.frozen? +end +"#); + let mut session = EditorSession::open(None, vec![], Some(&config)).unwrap(); + assert!(!session.editor().git_blame()); + session.handle(Input::Char('G')); + assert!(session.editor().git_blame()); + session.execute("git_blame.toggle extra"); + assert!(session.editor().git_blame()); + session.execute("git_blame.toggle"); + assert!(!session.editor().git_blame()); + session.handle(Input::Ctrl('p')); + for ch in "git blame".chars() { + session.handle(Input::Char(ch)); + } + assert_eq!(session.editor().palette().unwrap().entries.len(), 1); + session.handle(Input::Enter); + assert!(session.editor().git_blame()); + for value in ["nil", "0", r#""false""#, "[]", "{}"] { + let config = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.git_blame = {value} }}"), + ); + assert!( + load_config(&config) + .unwrap_err() + .contains("git_blame must be true or false") + ); + } + let mut frame = serde_json::json!({ + "type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [], + "review_directory": null, + "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", + "file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true, + "git_gutter": "bar", + "scroll_distance": 0, + "highlight_trailing_whitespace": false, + "whitespace_characters": {}, + "whitespace_scope": "all", + "tab_spaces": null, "language_tab_spaces": {}, + "top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}}, + "labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15, + "diff_view_mode": "split", "diff_split_orientation": "vertical", + "resolve_history_layout": "columns", + "diff_follow_working_copy": true + }); + assert!(serde_json::from_value::(frame.clone()).is_ok()); + for value in [ + serde_json::Value::Null, + serde_json::json!(0), + serde_json::json!("false"), + ] { + frame["git_blame"] = value; + assert!(serde_json::from_value::(frame.clone()).is_err()); + } + frame.as_object_mut().unwrap().remove("git_blame"); + assert!(serde_json::from_value::(frame).is_err()); +} + +#[test] +fn trailing_whitespace_configuration_rejects_non_booleans() { + let fixture = Fixture::new(); + for value in ["nil", "0", "\"true\"", "[]", "{}"] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |c| c.highlight_trailing_whitespace = {value} }}"), + ); + assert!( + load_config(&path) + .unwrap_err() + .contains("highlight_trailing_whitespace must be true or false") + ); + } +} + +#[test] +fn whitespace_characters_validate_real_ruby_configuration() { + let fixture = Fixture::new(); + for value in [ + "nil", + "false", + "[]", + r#"{ space: "·" }"#, + r#"{ " " => 1 }"#, + r#"{ "" => "." }"#, + r#"{ " " => "." }"#, + r#"{ " " => ".." }"#, + r#"{ "x" => "." }"#, + r#"{ "\u000b" => "." }"#, + r#"{ "\r" => "." }"#, + r#"{ "\u000c" => "." }"#, + r#"{ "\u0085" => "." }"#, + r#"{ "\u200b" => "." }"#, + r#"{ " " => "\t" }"#, + r#"{ " " => "\n" }"#, + r#"{ " " => "\u0301" }"#, + r#"{ " " => "界" }"#, + ] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |c| c.whitespace_characters = {value} }}"), + ); + assert!(load_config(&path).is_err(), "{value}"); + } + let path = fixture.file( + "config.rb", + r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\n" => "↵", "\u00a0" => "⍽", "\u3000" => "_" } }"#, + ); + assert_eq!( + load_config(&path).unwrap().whitespace_characters, + BTreeMap::from([ + (' ', '·'), + ('\t', '→'), + ('\n', '↵'), + ('\u{a0}', '⍽'), + ('\u{3000}', '_'), + ]), + ); +} + +#[test] +fn whitespace_scope_validates_ruby_atomically_and_requires_valid_wire_values() { + use crate::editor::WhitespaceScope; + + let fixture = Fixture::new(); + let path = fixture.file("config.rb", "Vamp.configure {}"); + assert_eq!( + load_config(&path).unwrap().whitespace_scope, + WhitespaceScope::All + ); + for (value, expected) in [ + ("all", WhitespaceScope::All), + ("leading", WhitespaceScope::Leading), + ] { + let path = fixture.file( + "config.rb", + &format!(r#"Vamp.configure {{ |c| c.whitespace_scope = "{value}" }}"#), + ); + assert_eq!(load_config(&path).unwrap().whitespace_scope, expected); + } + for value in [ + "nil", + "false", + "0", + "[]", + "{}", + ":all", + r#""""#, + r#""unknown""#, + ] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |c| c.whitespace_scope = {value} }}"), + ); + assert!(load_config(&path).is_err(), "{value}"); + let path = fixture.file( + "config.rb", + &format!( + r#"Vamp.configure do |c| + c.whitespace_scope = "leading" + begin + c.whitespace_scope = {value} + raise "invalid scope accepted" + rescue TypeError, ArgumentError + end +end"#, + ), + ); + assert_eq!( + load_config(&path).unwrap().whitespace_scope, + WhitespaceScope::Leading + ); + } + let mut frame = serde_json::json!({ + "type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [], + "review_directory": null, + "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", + "file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true, + "git_gutter": "bar", "scroll_distance": 0, "highlight_trailing_whitespace": false, + "whitespace_characters": {}, "whitespace_scope": "all", + "tab_spaces": null, "language_tab_spaces": {}, + "top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}}, + "labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15, + "diff_view_mode": "split", "diff_split_orientation": "vertical", + "resolve_history_layout": "columns", + "diff_follow_working_copy": true + }); + for value in ["all", "leading"] { + frame["whitespace_scope"] = serde_json::json!(value); + assert!(serde_json::from_value::(frame.clone()).is_ok()); + } + for value in [ + serde_json::Value::Null, + serde_json::json!(false), + serde_json::json!(0), + serde_json::json!([]), + serde_json::json!({}), + serde_json::json!(""), + serde_json::json!("unknown"), + ] { + frame["whitespace_scope"] = value; + assert!(serde_json::from_value::(frame.clone()).is_err()); + } + frame.as_object_mut().unwrap().remove("whitespace_scope"); + assert!(serde_json::from_value::(frame).is_err()); +} + +#[test] +fn working_diff_tracking_rejects_non_boolean_configuration() { + let fixture = Fixture::new(); + for value in ["nil", "0", r#""false""#, "[]", "{}"] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.diff_follow_working_copy = {value} }}"), + ); + assert!( + load_config(&path) + .unwrap_err() + .contains("diff_follow_working_copy must be true or false") + ); + } +} + +#[test] +fn chrome_configuration_normalizes_ruby_values_and_retains_unspecified_defaults() { + let fixture = Fixture::new(); + let path = fixture.file( + "config.rb", + r#" +Vamp.configure do |config| + config.bar.top = {left: [:tabs], center: ["workspace"], right: [:battery], + tabs: {title: "T{index}:{path}{modified}", padding: 0, first_leading_edge: ""}, + battery: {leading_edge: "界", trailing_edge: ""}} + config.bar.bottom = {"left" => [], center: [:workspace]} + config.labels = {battery: "bat 38%", "workspace" => "界 e\u0301 👩‍🚀\t\n\e"} +end +"#, + ); + let loaded = load_config(&path).unwrap(); + assert_eq!(loaded.top_bar.left, ["tabs"]); + assert_eq!(loaded.top_bar.center, ["workspace"]); + assert_eq!(loaded.top_bar.right, ["battery"]); + assert_eq!( + loaded.top_bar.segment("tabs").title, + "T{index}:{path}{modified}" + ); + assert_eq!(loaded.top_bar.segment("tabs").padding, 0); + assert_eq!(loaded.top_bar.segment("battery").leading_edge, "界"); + assert_eq!( + loaded.top_bar.segment("workspace"), + &crate::chrome::SegmentConfig::default() + ); + assert!(loaded.bottom_bar.left.is_empty()); + assert_eq!(loaded.labels["battery"], "bat 38%"); + assert_eq!(loaded.labels["workspace"], "界 e\u{301} 👩‍🚀\t\n\u{1b}"); +} + +#[test] +fn popup_configuration_accepts_ruby_symbol_and_string_anchors_independently() { + use crate::chrome::PopupAnchor; + + let fixture = Fixture::new(); + let anchors = [ + ("top_left", PopupAnchor::TopLeft), + ("top_center", PopupAnchor::TopCenter), + ("top_right", PopupAnchor::TopRight), + ("center_left", PopupAnchor::CenterLeft), + ("center", PopupAnchor::Center), + ("center_right", PopupAnchor::CenterRight), + ("bottom_left", PopupAnchor::BottomLeft), + ("bottom_center", PopupAnchor::BottomCenter), + ("bottom_right", PopupAnchor::BottomRight), + ]; + for (index, (command_name, command_anchor)) in anchors.iter().enumerate() { + let (search_name, search_anchor) = anchors[(index + 1) % anchors.len()]; + let path = fixture.file( + "config.rb", + &format!( + "Vamp.configure do |config|\n\ + config.popup.command = {{anchor: :{command_name}}}\n\ + config.popup.search = {{'anchor' => '{search_name}'}}\n\ + end" + ), + ); + let loaded = load_config(&path).unwrap(); + assert_eq!(loaded.popup.command.anchor, *command_anchor); + assert_eq!(loaded.popup.search.anchor, search_anchor); + } +} + +#[test] +fn popup_configuration_rejects_unknown_options_anchors_callbacks_and_types() { + let fixture = Fixture::new(); + for (value, expected) in [ + ("[]", "must be a Hash"), + ("nil", "must be a Hash"), + ("7", "must be a Hash"), + ("{width: 72}", "unknown popup option"), + ("{callback: -> { 'center' }}", "unknown popup option"), + ("{anchor: :middle}", "unknown popup anchor"), + ("{anchor: 'TopLeft'}", "unknown popup anchor"), + ("{anchor: nil}", "must be a String"), + ("{anchor: 7}", "must be a String"), + ("{anchor: true}", "must be a String"), + ("{anchor: []}", "must be a String"), + ("{anchor: {}}", "must be a String"), + ("{anchor: -> { :center }}", "must be a String"), + ( + "{anchor: :center, 'anchor' => :top_left}", + "duplicate chrome key", + ), + ("{7 => :center}", "chrome key must be a String"), + ] { + for mode in ["command", "search"] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.popup.{mode} = {value} }}"), + ); + let error = load_config(&path).unwrap_err(); + assert!(error.contains(expected), "{mode}={value}: {error}"); + } + } + let path = fixture.file("config.rb", "Vamp.configure { |config| config.popup = {} }"); + assert!(load_config(&path).unwrap_err().contains("undefined method")); +} + +#[test] +fn bar_namespace_is_stable_and_accepts_swapped_builtins() { + let fixture = Fixture::new(); + let path = fixture.file( + "config.rb", + r#" +Vamp.configure do |config| + bar = config.bar + raise "bar namespace must be stable" unless bar.equal?(config.bar) + bar.top = {left: [:mode, :language], right: [], mode: {padding: 0}} + config.bar.bottom = {left: [:tabs], right: [], tabs: {title: "{name}{modified}"}} +end +"#, + ); + let loaded = load_config(&path).unwrap(); + assert_eq!(loaded.top_bar.left, ["mode", "language"]); + assert_eq!(loaded.top_bar.segment("mode").padding, 0); + assert_eq!(loaded.top_bar.segment("language").padding, 1); + assert_eq!(loaded.bottom_bar.left, ["tabs"]); + assert_eq!(loaded.bottom_bar.segment("tabs").title, "{name}{modified}"); + + for setter in ["bar", "tabline", "statusline"] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.{setter} = {{}} }}"), + ); + let error = load_config(&path).unwrap_err(); + assert!(error.contains("undefined method"), "{setter}: {error}"); + } +} + +#[test] +fn chrome_configuration_rejects_invalid_types_names_and_templates() { + let fixture = Fixture::new(); + for (setting, value, expected) in [ + ("bar.top", "[]", "must be a Hash"), + ("bar.bottom", "[]", "must be a Hash"), + ("bar.top", "{left: :tabs}", "must be an Array"), + ("bar.top", "{left: [7]}", "must be a String"), + ("bar.top", "{left: [:bad_name!]}", "must match"), + ( + "bar.bottom", + "{tabs: {title: '{unknown}'}}", + "unknown title placeholder", + ), + ("bar.top", r#"{mode: {leading_edge: "\n"}}"#, "single-line"), + ("bar.top", "{language: {padding: 65}}", "between 0 and 64"), + ("bar.bottom", "{tabs: {padding: '1'}}", "must be an Integer"), + ("bar.top", "{lsp: {margin: 65}}", "between 0 and 64"), + ("bar.bottom", "{lsp: {margin: -1}}", "between 0 and 64"), + ("bar.top", "{lsp: {margin: '1'}}", "must be an Integer"), + ("bar.bottom", "{lsp: {margin: 1.5}}", "must be an Integer"), + ("bar.top", "{lsp: {margin: nil}}", "must be an Integer"), + ("bar.top", "{mode: {title: nil}}", "must be a String"), + ( + "bar.bottom", + "{tabs: {color: 'red'}}", + "unknown chrome style option", + ), + ( + "bar.top", + "{left: [], 'left' => []}", + "duplicate chrome key", + ), + ("labels", "[]", "must be a Hash"), + ("labels", "{battery: 7}", "label text must be a String"), + ("labels", "{'bad-name' => ''}", "must match"), + ] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.{setting} = {value} }}"), + ); + let error = load_config(&path).unwrap_err(); + assert!(error.contains(expected), "{setting}={value}: {error}"); + } +} + +#[test] +fn label_wire_effects_require_name_and_string_text() { + for frame in [ + serde_json::json!({"type": "label", "text": ""}), + serde_json::json!({"type": "label", "name": 7, "text": ""}), + serde_json::json!({"type": "label", "name": "battery"}), + serde_json::json!({"type": "label", "name": "battery", "text": null}), + serde_json::json!({"type": "label", "name": "battery", "text": 7}), + serde_json::json!({"type": "label", "name": "battery", "text": "", "extra": true}), + ] { + assert!(serde_json::from_value::(frame.clone()).is_err()); + assert!(serde_json::from_value::(frame).is_err()); + } +} + +#[test] +fn config_diff_excerpt_counts_accept_custom_values_and_reject_invalid_values() { + let fixture = Fixture::new(); + for (context, expand) in [(0, 7), (3, 1), (usize::MAX, usize::MAX)] { + let path = fixture.file( + "config.rb", + &format!( + "Vamp.configure {{ |config| config.diff_context_lines = {context}; config.diff_expand_lines = {expand} }}" + ), + ); + let loaded = load_config(&path).unwrap(); + assert_eq!( + (loaded.diff_context_lines, loaded.diff_expand_lines), + (context, expand) + ); + } + let frame = serde_json::json!({ + "version": 1, "type": "config", "plugins": [], "lsp": {}, + "review_directory": null, + "bindings": [], "leader": "space", "file_explorer_side": "right", + "file_picker_view": "tree", "file_explorer_view": "tree", + "top_bar": {}, "bottom_bar": {}, "labels": {}, + "scroll_distance": 0, + "highlight_trailing_whitespace": false, + "whitespace_characters": {}, + "whitespace_scope": "all", + "tab_spaces": null, "language_tab_spaces": {}, + "popup": {"command": {}, "search": {}}, + "clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7, + "diff_view_mode": "split", "diff_split_orientation": "vertical", + "resolve_history_layout": "columns", + "diff_follow_working_copy": true + }); + for setting in ["diff_context_lines", "diff_expand_lines"] { + for value in [ + "-1", + "1.5", + "\"3\"", + "true", + "false", + "nil", + "18446744073709551616", + "0", + ] { + if setting == "diff_context_lines" && value == "0" { + continue; + } + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.{setting} = {value} }}"), + ); + let error = load_config(&path).unwrap_err(); + assert!( + error.contains(setting) && error.contains("must be"), + "{value}: {error}" + ); + + let mut invalid_frame = frame.clone(); + invalid_frame[setting] = + serde_json::from_str(if value == "nil" { "null" } else { value }).unwrap(); + let error = serde_json::from_value::(invalid_frame) + .err() + .unwrap(); + assert!(error.to_string().contains(setting), "{value}: {error}"); + } + } +} + +#[test] +fn config_scroll_step_lines_controls_counted_scrolling_and_rejects_invalid_values() { + let fixture = Fixture::new(); + for (value, expected) in [ + ("nil", 30), + ("1", 3), + ("7", 21), + ("18446744073709551615", isize::MAX), + ] { + let path = fixture.file( + "config.rb", + &format!( + "Vamp.configure do |config| + config.scroll_step_lines = {value} + config.bind \"alt+j\", config.core.scroll_step_down + config.bind \"alt+k\", config.core.scroll_step_up +end" + ), + ); + let mut session = crate::session::EditorSession::open(None, vec![], Some(&path)).unwrap(); + session.handle(crate::editor::Input::Char('3')); + session.handle(crate::editor::Input::Alt('j')); + assert_eq!( + session.editor().take_viewport_scroll(20), + expected, + "{value}" + ); + session.handle(crate::editor::Input::Char('3')); + session.handle(crate::editor::Input::Alt('k')); + let up = if expected == isize::MAX { + isize::MIN + } else { + -expected + }; + assert_eq!(session.editor().take_viewport_scroll(20), up, "{value}"); + } + for value in [ + "0", + "-1", + "1.5", + "\"3\"", + "true", + "false", + "18446744073709551616", + ] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.scroll_step_lines = {value} }}"), + ); + let error = load_config(&path).unwrap_err(); + assert!( + error.contains("scroll_step_lines") && error.contains("must be"), + "{value}: {error}" + ); + } +} + +#[test] +fn config_scroll_distance_accepts_nil_and_counts_and_rejects_invalid_values() { + let fixture = Fixture::new(); + let frame = serde_json::json!({ + "version": 1, "type": "config", "plugins": [], "lsp": {}, + "review_directory": null, + "bindings": [], "leader": "space", "file_explorer_side": "right", + "file_picker_view": "tree", "file_explorer_view": "tree", + "clipboard_mode": "system", "git_blame": true, "git_gutter": "bar", + "highlight_trailing_whitespace": false, + "whitespace_characters": {}, + "whitespace_scope": "all", + "tab_spaces": null, "language_tab_spaces": {}, + "top_bar": {}, "bottom_bar": {}, "labels": {}, + "popup": {"command": {}, "search": {}}, + "diff_context_lines": 10, "diff_expand_lines": 15, + "diff_view_mode": "split", "diff_split_orientation": "vertical", + "resolve_history_layout": "columns", + "diff_follow_working_copy": true, "scroll_distance": 0 + }); + for distance in [None, Some(0), Some(7), Some(usize::MAX)] { + let value = distance.map_or_else(|| "nil".into(), |value| value.to_string()); + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"), + ); + assert_eq!(load_config(&path).unwrap().scroll_distance, distance); + let mut valid_frame = frame.clone(); + valid_frame["scroll_distance"] = serde_json::json!(distance); + let response = serde_json::from_value::(valid_frame).unwrap(); + let Response::Config(config) = response else { + panic!("expected a configuration frame"); + }; + assert_eq!(config.scroll_distance, distance); + } + for value in [ + "-1", + "1.5", + "\"3\"", + "true", + "false", + "18446744073709551616", + ] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"), + ); + let error = load_config(&path).unwrap_err(); + assert!( + error.contains("scroll_distance") && error.contains("must be"), + "{value}: {error}" + ); + let mut invalid_frame = frame.clone(); + invalid_frame["scroll_distance"] = serde_json::from_str(value).unwrap(); + let error = serde_json::from_value::(invalid_frame) + .err() + .unwrap(); + assert!( + error.to_string().contains("scroll_distance"), + "{value}: {error}" + ); + } +} + +#[test] +fn lsp_configuration_is_disabled_unless_explicitly_enabled() { + let fixture = Fixture::new(); + for source in [ + "Vamp.configure { |_| }", + r#"Vamp.configure do |config| + config.lsp["rust"] = false + config.lsp["python"] = false + config.lsp["not-a-preset"] = false +end"#, + ] { + let path = fixture.file("config.rb", source); + let config = load_config(&path).unwrap(); + assert!(config.lsp.is_empty()); + assert!(config.plugins.is_empty()); + } +} + +#[test] +fn lsp_presets_custom_languages_and_overrides_reach_native_configuration() { + let fixture = Fixture::new(); + let path = fixture.file( + "config.rb", + r#"Vamp.configure do |config| + config.lsp["rust"] = true + config.lsp["python"] = true + config.lsp["ruby"] = true +end"#, + ); + let config = load_config(&path).unwrap(); + assert_eq!(config.lsp.len(), 4); + for (language, command, extension) in [ + ("rust", &["rust-analyzer"][..], "rs"), + ("ruby", &["solargraph", "stdio"][..], "rb"), + ] { + let server = config + .lsp + .iter() + .find(|server| server.language_id == language) + .unwrap(); + assert_eq!(server.command, command); + assert_eq!(server.extensions, [extension]); + assert_eq!(server.initialization_options, serde_json::json!({})); + assert_eq!(server.settings, serde_json::json!({})); + } + let python: Vec<_> = config + .lsp + .iter() + .filter(|server| server.language_id == "python") + .collect(); + assert_eq!(python.len(), 2); + for (server, command, initialization_options) in [ + ( + python[0], + ["ty", "server"], + serde_json::json!({"experimental": {"useUv": "off"}}), + ), + (python[1], ["ruff", "server"], serde_json::json!({})), + ] { + assert_eq!(server.command, command); + assert_eq!(server.extensions, ["py"]); + assert_eq!(server.initialization_options, initialization_options); + assert_eq!(server.settings, serde_json::json!({})); + } + + let path = fixture.file( + "config.rb", + r#"Vamp.configure do |config| + config.lsp["rust"] = { + extensions: ["rs", "ron"], + initialization_options: {cargo: {features: ["α"]}}, + settings: {"rust-analyzer" => {check: {command: "clippy"}}} + } + config.lsp["python"] = {command: ["custom-python-server", "--stdio"]} + config.lsp["ruby"] = {command: ["bundle", "exec", "solargraph", "stdio"]} + config.lsp["my-language.c++"] = { + command: ["custom server", "--flag", ""], + extensions: ["custom", "لغة"], + settings: {nested: {enabled: true, limit: 3, unset: nil}} + } +end"#, + ); + let config = load_config(&path).unwrap(); + let rust = config + .lsp + .iter() + .find(|server| server.language_id == "rust") + .unwrap(); + assert_eq!(rust.command, ["rust-analyzer"]); + assert_eq!(rust.extensions, ["rs", "ron"]); + assert_eq!( + rust.initialization_options, + serde_json::json!({"cargo": {"features": ["α"]}}) + ); + assert_eq!( + rust.settings, + serde_json::json!({"rust-analyzer": {"check": {"command": "clippy"}}}) + ); + let python = config + .lsp + .iter() + .find(|server| server.language_id == "python") + .unwrap(); + assert_eq!(python.command, ["custom-python-server", "--stdio"]); + assert_eq!(python.extensions, ["py"]); + assert_eq!(python.initialization_options, serde_json::json!({})); + assert_eq!(python.settings, serde_json::json!({})); + assert_eq!( + config + .lsp + .iter() + .filter(|server| server.language_id == "python") + .count(), + 1 + ); + let ruby = config + .lsp + .iter() + .find(|server| server.language_id == "ruby") + .unwrap(); + assert_eq!(ruby.command, ["bundle", "exec", "solargraph", "stdio"]); + assert_eq!(ruby.extensions, ["rb"]); + let custom = config + .lsp + .iter() + .find(|server| server.language_id == "my-language.c++") + .unwrap(); + assert_eq!(custom.command, ["custom server", "--flag", ""]); + assert_eq!(custom.extensions, ["custom", "لغة"]); + assert_eq!(custom.initialization_options, serde_json::json!({})); + assert_eq!( + custom.settings, + serde_json::json!({"nested": {"enabled": true, "limit": 3, "unset": null}}) + ); +} + +#[test] +fn lsp_python_common_options_and_ordered_server_overrides() { + let fixture = Fixture::new(); + let path = fixture.file( + "config.rb", + r#"Vamp.configure do |config| + config.lsp["python"] = { + extensions: ["py", "pyi"], + initialization_options: {diagnosticMode: "workspace"}, + settings: {shared: true} + } +end"#, + ); + let config = load_config(&path).unwrap(); + assert_eq!(config.lsp.len(), 2); + for (server, command) in config + .lsp + .iter() + .zip([["ty", "server"], ["ruff", "server"]]) + { + assert_eq!(server.language_id, "python"); + assert_eq!(server.command, command); + assert_eq!(server.extensions, ["py", "pyi"]); + assert_eq!( + server.initialization_options, + serde_json::json!({"diagnosticMode": "workspace"}) + ); + assert_eq!(server.settings, serde_json::json!({"shared": true})); + } + let path = fixture.file( + "config.rb", + r#"Vamp.configure do |config| + config.lsp["python"] = {servers: [ + {command: ["type-server", "--stdio"], initialization_options: {experimental: {useUv: "off"}}, settings: {first: true}}, + {command: ["lint-server"], initialization_options: {settings: {lint: {enable: true}}}} + ]} + config.lsp["custom"] = {extensions: ["custom"], servers: [ + {command: ["custom-first"]}, {command: ["custom-second"], settings: {second: true}} + ]} +end"#, + ); + let config = load_config(&path).unwrap(); + for (language, commands, extensions) in [ + ( + "python", + [&["type-server", "--stdio"][..], &["lint-server"][..]], + ["py"], + ), + ( + "custom", + [&["custom-first"][..], &["custom-second"][..]], + ["custom"], + ), + ] { + let servers: Vec<_> = config + .lsp + .iter() + .filter(|server| server.language_id == language) + .collect(); + assert_eq!(servers.len(), 2); + for (server, command) in servers.iter().zip(commands) { + assert_eq!(server.command, command); + assert_eq!(server.extensions, extensions); + } + } + let python: Vec<_> = config + .lsp + .iter() + .filter(|server| server.language_id == "python") + .collect(); + assert_eq!( + python[0].initialization_options, + serde_json::json!({"experimental": {"useUv": "off"}}) + ); + assert_eq!(python[0].settings, serde_json::json!({"first": true})); + assert_eq!( + python[1].initialization_options, + serde_json::json!({"settings": {"lint": {"enable": true}}}) + ); + assert_eq!(python[1].settings, serde_json::json!({})); + let custom: Vec<_> = config + .lsp + .iter() + .filter(|server| server.language_id == "custom") + .collect(); + assert_eq!(custom[0].initialization_options, serde_json::json!({})); + assert_eq!(custom[1].settings, serde_json::json!({"second": true})); + let path = fixture.file( + "config.rb", + r#"Vamp.configure { |config| config.lsp["python"] = {settings: {shared: true}} }"#, + ); + let config = load_config(&path).unwrap(); + assert_eq!( + config.lsp[0].initialization_options, + serde_json::json!({"experimental": {"useUv": "off"}}) + ); + assert_eq!(config.lsp[1].initialization_options, serde_json::json!({})); +} + +#[test] +fn lsp_server_lists_reject_invalid_shapes_and_ambiguous_options_locally() { + let fixture = Fixture::new(); + for (options, error) in [ + ("{servers: nil}", "nonempty array"), + ("{servers: {command: ['server']}}", "nonempty array"), + ("{servers: []}", "nonempty array"), + ( + "{servers: [true]}", + "servers[0] must be a configuration object", + ), + ("{servers: [{}]}", "servers[0] requires command argv"), + ("{servers: [{command: []}]}", "servers[0] command must be"), + ( + "{servers: [{command: ['server'], extensions: ['py']}]}", + "servers[0] has unknown option", + ), + ( + "{servers: [{command: ['server'], servers: []}]}", + "servers[0] has unknown option", + ), + ( + "{servers: [{command: ['server'], initialization_options: []}]}", + "servers[0] initialization_options must be", + ), + ( + "{servers: [{command: ['server']}, {command: ['other'], settings: nil}]}", + "servers[1] settings must be", + ), + ( + "{servers: [{command: ['server']}], command: ['other']}", + "cannot be combined", + ), + ( + "{servers: [{command: ['server']}], initialization_options: {}}", + "cannot be combined", + ), + ( + "{servers: [{command: ['server']}], settings: {}}", + "cannot be combined", + ), + ] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.lsp[\"python\"] = {options} }}"), + ); + let actual = load_config(&path).unwrap_err(); + assert!( + actual.starts_with("Ruby config: LSP language \"python\""), + "{actual}" + ); + assert!(actual.contains(error), "{options}: {actual}"); + } +} + +#[test] +fn lsp_server_limit_is_enforced_at_configuration_startup() { + let fixture = Fixture::new(); + for count in [16, 17] { + let path = fixture.file( + "config.rb", + &format!( + "Vamp.configure {{ |config| config.lsp[\"python\"] = {{servers: Array.new({count}) {{ {{command: [\"server\"]}} }} }} }}" + ), + ); + let result = load_config(&path); + if count == 16 { + assert_eq!(result.unwrap().lsp.len(), 16); + } else { + assert_eq!( + result.unwrap_err(), + "Ruby config: LSP configuration supports at most 16 configured servers" + ); + } + } +} + +#[test] +fn lsp_configuration_rejects_invalid_keys_values_and_json_options() { + let fixture = Fixture::new(); + for assignment in [ + r#"config.lsp[:rust] = true"#, + r#"config.lsp[7] = false"#, + r#"config.lsp[""] = false"#, + r#"config.lsp["bad\nkey"] = false"#, + r#"config.lsp["\xFF".b] = false"#, + r#"config.lsp["rust"] = nil"#, + r#"config.lsp["rust"] = []"#, + r#"config.lsp["rust"] = "yes""#, + r#"config.lsp["custom"] = {}"#, + r#"config.lsp["custom"] = {command: ["server"]}"#, + r#"config.lsp["custom"] = {servers: [{command: ["server"]}]}"#, + r#"config.lsp["custom"] = {extensions: ["custom"]}"#, + r#"config.lsp["rust"] = {command: "server"}"#, + r#"config.lsp["rust"] = {command: []}"#, + r#"config.lsp["rust"] = {command: [""]}"#, + r#"config.lsp["rust"] = {command: ["server", 7]}"#, + r#"config.lsp["rust"] = {command: ["server", "bad\0argument"]}"#, + r#"config.lsp["rust"] = {extensions: "rs"}"#, + r#"config.lsp["rust"] = {extensions: []}"#, + r#"config.lsp["rust"] = {extensions: [""]}"#, + r#"config.lsp["rust"] = {extensions: [7]}"#, + r#"config.lsp["rust"] = {extensions: [".rs"]}"#, + r#"config.lsp["rust"] = {extensions: ["path/rs"]}"#, + r#"config.lsp["rust"] = {extensions: ["path\\rs"]}"#, + r#"config.lsp["rust"] = {extensions: ["r s"]}"#, + r#"config.lsp["rust"] = {extensions: ["r\u0001s"]}"#, + r#"config.lsp["rust"] = {initialization_options: []}"#, + r#"config.lsp["rust"] = {settings: nil}"#, + r#"config.lsp["rust"] = {settings: {bad: Object.new}}"#, + r#"config.lsp["rust"] = {settings: {bad: Float::NAN}}"#, + r#"config.lsp["rust"] = {settings: {7 => true}}"#, + r#"config.lsp["rust"] = {unknown: true}"#, + ] { + let path = fixture.file( + "invalid.rb", + &format!("Vamp.configure {{ |config| {assignment} }}"), + ); + assert!(load_config(&path).is_err(), "{assignment}"); + } + let path = fixture.file( + "invalid.rb", + r#"Vamp.configure { |config| config.lsp["unsupported-language"] = true }"#, + ); + let error = load_config(&path).unwrap_err(); + assert!(!error.contains("Malformed Ruby protocol frame"), "{error}"); + assert!( + error.contains("\"unsupported-language\"") + && error.contains("command") + && error.contains("extensions"), + "{error}" + ); +} + +#[test] +fn lsp_wire_configuration_is_resolved_after_decoding() { + let mut frame = serde_json::json!({ + "version": 1, "type": "config", "plugins": [], "lsp": {}, + "review_directory": null, + "bindings": [], "leader": "space", "file_explorer_side": "right", + "file_picker_view": "tree", "file_explorer_view": "tree", + "git_blame": true, + "git_gutter": "bar", + "scroll_distance": 0, + "highlight_trailing_whitespace": false, + "whitespace_characters": {}, + "whitespace_scope": "all", + "tab_spaces": null, "language_tab_spaces": {}, + "top_bar": {}, "bottom_bar": {}, "labels": {}, + "popup": {"command": {}, "search": {}}, + "clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15, + "diff_view_mode": "split", "diff_split_orientation": "vertical", + "resolve_history_layout": "columns", + "diff_follow_working_copy": true + }); + let Response::Config(config) = serde_json::from_value::(frame.clone()).unwrap() + else { + panic!("expected config frame"); + }; + assert!(crate::lsp_config::resolve(config.lsp).unwrap().is_empty()); + frame["lsp"] = serde_json::json!({"rust": true}); + let Response::Config(config) = serde_json::from_value::(frame.clone()).unwrap() + else { + panic!("expected config frame"); + }; + assert_eq!( + crate::lsp_config::resolve(config.lsp).unwrap()[0].command, + ["rust-analyzer"] + ); + for value in [ + serde_json::json!(null), + serde_json::json!([]), + serde_json::json!({"": false}), + serde_json::json!({"bad\u{0085}key": false}), + serde_json::json!({"rust": null}), + serde_json::json!({"python": []}), + serde_json::json!({"unknown": true}), + serde_json::json!({"custom": {}}), + serde_json::json!({"rust": {"command": ["tool", "bad\0argument"]}}), + serde_json::json!({"rust": {"extensions": ["bad\nextension"]}}), + serde_json::json!({"rust": {"initialization_options": []}}), + serde_json::json!({"rust": {"settings": "not-an-object"}}), + serde_json::json!({"rust": {"unexpected": true}}), + serde_json::json!({"python": {"servers": []}}), + serde_json::json!({"python": {"servers": [null]}}), + serde_json::json!({"python": {"servers": [{}]}}), + serde_json::json!({"python": {"servers": [{"command": ["server"], "extensions": ["py"]}]}}), + serde_json::json!({"python": {"servers": [{"command": ["server"]}], "settings": {}}}), + ] { + frame["lsp"] = value; + let Response::Config(config) = serde_json::from_value::(frame.clone()).unwrap() + else { + panic!("expected config frame"); + }; + assert!(crate::lsp_config::resolve(config.lsp).is_err(), "{frame}"); + } + let mut languages: serde_json::Map = (0..14) + .map(|index| { + ( + format!("custom-{index}"), + serde_json::json!({"command": ["server"], "extensions": ["custom"]}), + ) + }) + .collect(); + languages.insert("python".into(), serde_json::json!(true)); + frame["lsp"] = serde_json::Value::Object(languages); + let Response::Config(config) = serde_json::from_value::(frame.clone()).unwrap() + else { + panic!("expected config frame"); + }; + assert_eq!(crate::lsp_config::resolve(config.lsp).unwrap().len(), 16); + frame["lsp"]["ruby"] = serde_json::json!(true); + let Response::Config(config) = serde_json::from_value::(frame.clone()).unwrap() + else { + panic!("expected config frame"); + }; + assert_eq!( + crate::lsp_config::resolve(config.lsp).unwrap_err(), + "LSP configuration supports at most 16 configured servers" + ); + frame.as_object_mut().unwrap().remove("lsp"); + assert!(serde_json::from_value::(frame).is_err()); +} + +#[test] +fn malformed_config_wire_remains_a_protocol_error() { + let fixture = Fixture::new(); + for mutation in [ + "return '['", + "frame.delete('lsp')", + "frame.delete('top_bar')", + "frame.delete('bottom_bar')", + "frame.delete('labels')", + "frame.delete('popup')", + "frame['popup'] = nil", + "frame['popup'] = []", + "frame['popup'] = {'selector' => {}}", + "frame['popup'] = {'command' => nil}", + "frame['popup'] = {'search' => 7}", + "frame['popup'] = {'command' => {'anchor' => nil}}", + "frame['popup'] = {'search' => {'anchor' => 'middle'}}", + "frame['popup'] = {'command' => {'width' => 72}}", + "frame['top_bar'] = 7", + "frame['bottom_bar'] = []", + "frame['labels'] = {'battery' => 7}", + "frame['bottom_bar'] = {'tabs' => {'title' => '{unknown}'}}", + "frame['top_bar'] = {'mode' => {'leading_edge' => \"\\n\"}}", + "frame['top_bar'] = {'left' => nil}", + "frame['bottom_bar'] = {'left' => ['bad-name']}", + "frame['bottom_bar'] = {'tabs' => {'padding' => '1'}}", + "frame['top_bar'] = {'language' => {'color' => 'red'}}", + "frame['labels'] = {'bad-name' => ''}", + "frame['leader'] = 7", + "frame['unexpected'] = true", + ] { + let source = format!( + r#" +Vamp.configure {{ |config| config.lsp["ruby"] = true }} +module JSON + class << self + alias_method :original_generate, :generate + def generate(frame) + if frame['type'] == 'config' + {mutation} + end + original_generate(frame) + end + end +end +"# + ); + let path = fixture.file("config.rb", &source); + let error = load_config(&path).unwrap_err(); + assert!( + error.starts_with("Malformed Ruby protocol frame: "), + "{mutation}: {error}" + ); + } +} + +#[test] +fn crash_hang_callback_failure_and_fresh_host_recovery() { + let fixture = Fixture::new(); + for callback in [ + "exit! 19", + "sleep 10", + "editor.insert 'must not escape'; raise 'callback failed'", + "editor.insert 7", + ] { + let (mut plugin, _) = fixture.plugin(&format!( + "Vamp.plugin 'failure'\nVamp.command('fail') {{ |editor, args| {callback} }}" + )); + let error = plugin.invoke("fail", "", &context()).unwrap_err(); + if callback == "sleep 10" { + assert!(error.contains("timeout"), "{error}"); + } + assert!( + plugin.host.child.is_none() + && plugin.host.reader.is_none() + && plugin.host.writer.is_none() + ); + assert!( + plugin + .invoke("fail", "", &context()) + .unwrap_err() + .contains("disabled") + ); + let (mut recovered, _) = fixture.plugin( + "Vamp.plugin 'recovered'\nVamp.command('ok') { |editor, args| editor.message 'alive' }", + ); + assert!( + matches!(&recovered.invoke("ok", "", &context()).unwrap()[..], + [Effect::Message(message)] if message == "alive") + ); + } +} + +#[test] +fn nonstandard_callback_exceptions_are_reported_without_effects() { + let fixture = Fixture::new(); + for (callback, expected) in [ + ("exit 19", "SystemExit: exit"), + ("raise Interrupt, 'interrupted'", "Interrupt: interrupted"), + ] { + let (mut plugin, _) = fixture.plugin(&format!( + "Vamp.plugin 'failure'\nVamp.command('fail') {{ |editor, _| editor.insert 'must not escape'; {callback} }}" + )); + let error = plugin.invoke("fail", "", &context()).unwrap_err(); + assert!(error.contains(expected), "{error}"); + assert!( + plugin + .invoke("fail", "", &context()) + .unwrap_err() + .contains("disabled") + ); + } +} + +#[test] +fn startup_and_config_hangs_are_bounded() { + let fixture = Fixture::new(); + let path = fixture.file("hang.rb", "sleep 10"); + assert!( + RubyPlugin::start(&path, &serde_json::json!({})) + .err() + .unwrap() + .contains("timeout") + ); + assert!(load_config(&path).unwrap_err().contains("timeout")); +} + +#[test] +fn invalid_protocol_version_shape_effect_and_request_id_shutdown() { + let fixture = Fixture::new(); + for mutation in [ + "frame['version'] = 2", + "frame['id'] += 1", + "frame['effects'] = [{'type' => 'delete', 'text' => 'x'}]", + "frame['effects'] = [{'type' => 'insert', 'text' => 7}]", + "frame['unexpected'] = true", + ] { + let source = format!( + r#" +Vamp.plugin 'protocol' +Vamp.command('ok') {{ |editor, args| editor.insert 'x' }} +module JSON + class << self + alias_method :original_generate, :generate + def generate(frame) + if frame['type'] == 'result' + {mutation} + end + original_generate(frame) + end + end +end +"# + ); + let (mut plugin, _) = fixture.plugin(&source); + assert!(plugin.invoke("ok", "", &context()).is_err(), "{mutation}"); + assert!(plugin.host.child.is_none()); + } +} + +#[test] +fn bounded_frames_and_stalled_writes_shutdown() { + let fixture = Fixture::new(); + let (mut plugin, _) = fixture.plugin( + "Vamp.plugin 'large'\nVamp.command('large') { |editor, args| editor.insert 'x' * (4 * 1024 * 1024 + 1) }"); + assert!(plugin.invoke("large", "", &context()).is_err()); + assert!(plugin.host.child.is_none()); + + // Stop Ruby reading the request pipe after it sends the manifest. + let (mut plugin, _) = fixture.plugin( + r#" +Vamp.plugin 'blocked' +Vamp.command('ok') { |editor, args| editor.message 'ok' } +class IO + def gets(*args) + sleep 10 + end +end +"#, + ); + let mut big_context = context(); + big_context.text = "x".repeat(2 * 1024 * 1024); + assert!( + plugin + .invoke("ok", "", &big_context) + .unwrap_err() + .contains("timeout") + ); + assert!(plugin.host.child.is_none()); + + let (mut plugin, _) = fixture + .plugin("Vamp.plugin 'request'\nVamp.command('ok') { |editor, args| editor.message 'ok' }"); + big_context.text = "x".repeat(MAX_FRAME + 1); + assert!( + plugin + .invoke("ok", "", &big_context) + .unwrap_err() + .contains("4 MiB") + ); + assert!(plugin.host.child.is_none()); + + for payload in ["'not json'", "'x' * (4 * 1024 * 1024 + 1)"] { + let source = format!( + r#" +Vamp.plugin 'bad_frame' +Vamp.command('ok') {{ |editor, args| editor.message 'ok' }} +class IO + alias_method :original_write, :write + def write(payload) + if payload.include?('"type":"result"') + original_write({payload}) + else + original_write(payload) + end + end +end +"# + ); + let (mut plugin, _) = fixture.plugin(&source); + assert!(plugin.invoke("ok", "", &context()).is_err()); + assert!(plugin.host.child.is_none()); + } +} + +#[cfg(unix)] +#[test] +fn inherited_pipes_do_not_prevent_drop_or_timeout_cleanup() { + let fixture = Fixture::new(); + let (mut plugin, _) = fixture.plugin( + r#" +Vamp.plugin 'forked' +Vamp.command('hang') do |editor, args| + fork { sleep 10 } + sleep 10 +end +"#, + ); + assert!( + plugin + .invoke("hang", "", &context()) + .unwrap_err() + .contains("timeout") + ); + assert!(plugin.host.reader.is_none() && plugin.host.writer.is_none()); + let (plugin, _) = fixture.plugin("Vamp.plugin 'drop'\nfork { sleep 10 }"); + drop(plugin); +} + +fn document(event: crate::plugin::DocumentEventKind) -> DocumentEvent { + DocumentEvent { + event, + document: crate::plugin::DocumentSnapshot { + id: 7, + revision: 11, + path: Some("buffer.txt".into()), + root: "/project".into(), + text: "α\nhello".into(), + }, + } +} + +fn collect_until( + plugin: &mut RubyPlugin, + ready: impl Fn(&[PluginEvent]) -> bool, +) -> Vec { + let deadline = Instant::now() + STARTUP_TIMEOUT; + let mut events = Vec::new(); + loop { + events.extend(plugin.poll().unwrap()); + if ready(&events) { + return events; + } + assert!( + Instant::now() < deadline, + "Ruby event fixture did not respond" + ); + thread::sleep(Duration::from_millis(1)); + } +} + +#[test] +fn asynchronous_documents_background_updates_and_results_share_framed_transport() { + use crate::plugin::{DiagnosticSeverity, DocumentEventKind}; + let fixture = Fixture::new(); + let (mut plugin, feature) = fixture.plugin_with_options( + r##" +Vamp.plugin "async", requires: ["text", "diagnostics"], async: true +raise "options" unless Vamp.options["nested"]["items"] == ["α", 3, true] +raise "mutable options" unless Vamp.options.frozen? && Vamp.options["nested"].frozen? && + Vamp.options["nested"]["items"].frozen? && Vamp.options["nested"]["items"][0].frozen? +begin + Vamp.options["nested"]["items"] << "bad" + raise "mutation escaped" +rescue FrozenError +end +events = [] +Vamp.on_document do |event| + doc = event["document"] + raise "bad snapshot" unless doc["id"] == 7 && doc["revision"] == 11 && + doc["root"] == "/project" && doc["text"] == "α\nhello" && doc["path"] == "buffer.txt" + raise "mutable document" unless event.frozen? && doc.frozen? && doc["text"].frozen? + events << event["event"] +end +Vamp.command "ask" do |editor, _| + raise "document ordering" unless events == %w[open change save close] + raise "wrong request" unless editor.request_id > 0 + Thread.new do + puts "background output is not protocol" + Vamp.publish [ + {type: "diagnostics", buffer_id: editor.buffer_id, revision: editor.revision, + items: [{start: {row: 1, column: 0}, end: {row: 1, column: 2}, + severity: "warning", message: "actual background diagnostic"}]}, + {type: "navigate", buffer_id: editor.buffer_id, revision: editor.revision, + path: "/project/target α.txt", row: 1, column: 2} + ], request_id: editor.request_id + end + workers = 4.times.map do |worker| + Thread.new { 3.times { |item| Vamp.publish [{type: "message", text: "#{worker}:#{item}:α"}] } } + end + workers.each(&:join) + editor.message "queued result" +end +"##, + &serde_json::json!({"nested": {"items": ["α", 3, true]}}), + ); + assert!(plugin.is_async()); + assert_eq!(feature.required_capabilities, ["text", "diagnostics"]); + for event in [ + DocumentEventKind::Open, + DocumentEventKind::Change, + DocumentEventKind::Save, + DocumentEventKind::Close, + ] { + plugin.notify_document(&document(event)).unwrap(); + } + let id = plugin.queue_invoke("ask", "", &context()).unwrap(); + let events = collect_until(&mut plugin, |events| { + events.iter().any(|event| matches!(event, PluginEvent::Response { id: response_id, .. } if *response_id == id)) && + events.iter().filter(|event| matches!(event, PluginEvent::Update(_))).count() == 13 + }); + assert!(events.iter().any(|event| matches!(event, + PluginEvent::Response { id: response_id, effects } if *response_id == id && + matches!(&effects[..], [Effect::Message(text)] if text == "queued result")))); + let update = events + .iter() + .find_map(|event| match event { + PluginEvent::Update(update) if update.request_id == Some(id) => Some(update), + _ => None, + }) + .unwrap(); + assert!(matches!(&update.effects[..], + [UpdateEffect::Diagnostics { buffer_id: 7, revision: 11, items }, + UpdateEffect::Navigate { buffer_id: 7, revision: 11, path, row: 1, column: 2 }] + if items.len() == 1 && items[0].severity == DiagnosticSeverity::Warning && + items[0].message == "actual background diagnostic" && + path == Path::new("/project/target α.txt"))); + assert!(plugin.pending.is_empty()); +} + +#[test] +fn asynchronous_invocation_does_not_wait_for_callback_or_result() { + let fixture = Fixture::new(); + let (mut plugin, _) = fixture.plugin( + r#" +Vamp.plugin "blocked", async: true +Vamp.command "block" do |editor, _| + Vamp.publish [{type: "message", text: "callback entered"}], request_id: editor.request_id + sleep 10 +end +"#, + ); + let id = plugin.queue_invoke("block", "", &context()).unwrap(); + let events = collect_until(&mut plugin, |events| !events.is_empty()); + assert!(matches!(&events[..], [PluginEvent::Update(update)] + if update.request_id == Some(id))); + assert!(plugin.pending.contains(&id)); + assert!(plugin.poll().unwrap().is_empty()); + plugin.disable(); + assert!( + plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none() + ); +} + +#[test] +fn plugin_options_config_transport_validation_and_required_wire_fields() { + let fixture = Fixture::new(); + let path = fixture.file("config.rb", + r#"Vamp.configure { |config| config.plugin "plugin.rb", options: {nested: {argv: ["tool", "α space"]}} }"#); + let config = load_config(&path).unwrap(); + assert_eq!( + config.plugins[0].options, + serde_json::json!({"nested": {"argv": ["tool", "α space"]}}) + ); + for value in [ + "[]", + "nil", + "{bad: Object.new}", + "{bad: Float::NAN}", + "{7 => 'bad'}", + ] { + let path = fixture.file( + "invalid.rb", + &format!("Vamp.configure {{ |config| config.plugin 'plugin.rb', options: {value} }}"), + ); + assert!(load_config(&path).is_err(), "{value}"); + } + let plugin_path = fixture.file("plugin.rb", "Vamp.plugin 'options'"); + assert!(RubyPlugin::start(&plugin_path, &serde_json::json!([])).is_err()); + for frame in [ + r#"{"path":"p.rb"}"#, + r#"{"path":"p.rb","options":null}"#, + r#"{"path":"p.rb","options":[]}"#, + r#"{"path":7,"options":{}}"#, + r#"{"path":"p.rb","options":{},"extra":true}"#, + ] { + assert!( + serde_json::from_str::(frame).is_err(), + "{frame}" + ); + } + for frame in [ + r#"{"type":"manifest","version":1,"name":"p","requires":[],"commands":[]}"#, + r#"{"type":"manifest","version":1,"name":"p","requires":[],"commands":[],"async":"yes"}"#, + ] { + assert!(serde_json::from_str::(frame).is_err(), "{frame}"); + } +} + +#[test] +fn malformed_async_updates_and_unknown_response_ids_retire_host() { + let fixture = Fixture::new(); + for mutation in [ + "frame['version'] = 2", + "frame['request_id'] = 'bad'", + "frame['request_id'] = 0", + "frame['request_id'] = 999", + "frame['effects'] = [{'type' => 'message', 'text' => 7}]", + "frame['effects'] = [{'type' => 'navigate', 'buffer_id' => 7, 'revision' => 11, 'path' => '/x', 'row' => -1, 'column' => 0}]", + "frame['effects'] = [{'type' => 'diagnostics', 'buffer_id' => 7, 'revision' => 11, 'items' => [{'start' => {'row' => 0, 'column' => 0}, 'end' => {'row' => 0, 'column' => 1}, 'severity' => 'fatal', 'message' => 'x'}]}]", + "frame['unexpected'] = true", + "frame.replace({'type' => 'result', 'version' => 1, 'id' => 999, 'effects' => []})", + ] { + let (mut plugin, _) = fixture.plugin(&format!(r#" +Vamp.plugin 'protocol', async: true +Vamp.command('ask') {{ |editor, _| Vamp.publish [{{type: 'message', text: 'valid'}}], request_id: editor.request_id }} +module JSON + class << self + alias_method :original_generate, :generate + def generate(frame) + {mutation} if frame['type'] == 'update' + original_generate(frame) + end + end +end +"#)); + plugin.queue_invoke("ask", "", &context()).unwrap(); + let deadline = Instant::now() + STARTUP_TIMEOUT; + loop { + if plugin.poll().is_err() { + break; + } + assert!(Instant::now() < deadline, "{mutation}"); + thread::sleep(Duration::from_millis(1)); + } + assert!( + plugin.host.child.is_none() + && plugin.host.reader.is_none() + && plugin.host.writer.is_none(), + "{mutation}" + ); + } +} + +#[test] +fn asynchronous_queue_overflow_and_crash_are_explicit_and_cleaned_up() { + let fixture = Fixture::new(); + let (mut plugin, _) = fixture.plugin( + r#" +Vamp.plugin 'blocked', async: true +class IO + def gets(*args) + sleep 10 + end +end +"#, + ); + let mut event = document(crate::plugin::DocumentEventKind::Open); + event.document.text = "x".repeat(128 * 1024); + let error = (0..MAX_QUEUE + 2) + .find_map(|_| plugin.notify_document(&event).err()) + .unwrap(); + assert!(error.contains("queue is full"), "{error}"); + assert!( + plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none() + ); + + let (mut plugin, _) = fixture.plugin( + r#" +Vamp.plugin 'flood', async: true +Vamp.command('flood') { |_, _| 256.times { Vamp.publish [{type: 'message', text: 'flood'}] } } +"#, + ); + plugin.queue_invoke("flood", "", &context()).unwrap(); + let deadline = Instant::now() + STARTUP_TIMEOUT; + while plugin.host.failure.get().is_none() { + assert!( + Instant::now() < deadline, + "flood did not overflow bounded reader" + ); + thread::sleep(Duration::from_millis(1)); + } + assert!(plugin.poll().unwrap_err().contains("queue is full")); + assert!(plugin.host.child.is_none()); + + let (mut plugin, _) = fixture + .plugin("Vamp.plugin 'crash', async: true\nVamp.command('crash') { |_, _| exit! 19 }"); + plugin.queue_invoke("crash", "", &context()).unwrap(); + let deadline = Instant::now() + STARTUP_TIMEOUT; + loop { + if plugin.poll().is_err() { + break; + } + assert!(Instant::now() < deadline); + thread::sleep(Duration::from_millis(1)); + } + assert!( + plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none() + ); +} + +#[test] +fn shutdown_hook_runs_for_shutdown_frame_without_protocol_corruption() { + let fixture = Fixture::new(); + let marker = fixture.0.join("shutdown"); + let (mut plugin, _) = fixture.plugin_with_options( + "Vamp.plugin 'shutdown', async: true\nVamp.on_shutdown { File.write(Vamp.options['marker'], 'stopped') }", + &serde_json::json!({"marker": marker}), + ); + plugin + .host + .queue(&serde_json::json!({"type": "shutdown", "version": 1})) + .unwrap(); + let deadline = Instant::now() + STARTUP_TIMEOUT; + while plugin + .host + .child + .as_mut() + .unwrap() + .try_wait() + .unwrap() + .is_none() + { + assert!(Instant::now() < deadline); + thread::sleep(Duration::from_millis(1)); + } + assert_eq!(std::fs::read_to_string(marker).unwrap(), "stopped"); + plugin.disable(); + assert!( + plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none() + ); +} + +#[test] +fn ruby_rejects_invalid_document_context_and_sync_async_dsl_usage() { + let fixture = Fixture::new(); + for declaration in [ + "Vamp.plugin 'bad', async: 'yes'", + "Vamp.plugin 'bad'\nVamp.on_document { |_| }", + "Vamp.plugin 'bad'\nVamp.publish []", + ] { + let path = fixture.file("bad.rb", declaration); + assert!(RubyPlugin::start(&path, &serde_json::json!({})).is_err()); + } + for (field, value) in [ + ("root", serde_json::json!(7)), + ("buffer_id", serde_json::json!(-1)), + ("revision", serde_json::json!("old")), + ] { + let (mut plugin, _) = fixture.plugin( + "Vamp.plugin 'context', async: true\nVamp.command('ask') { |editor, _| editor.message 'must not escape' }"); + let mut frame = serde_json::json!({ + "version": 1, "type": "invoke", "id": 1, "command": "ask", "args": "", + "context": context(), + }); + frame["context"][field] = value; + plugin.pending.insert(1); + plugin.next_id = 2; + plugin.host.queue(&frame).unwrap(); + let deadline = Instant::now() + STARTUP_TIMEOUT; + loop { + match plugin.poll() { + Err(error) => { + assert!(error.contains("context"), "{error}"); + break; + } + Ok(events) => assert!(events.is_empty()), + } + assert!(Instant::now() < deadline); + thread::sleep(Duration::from_millis(1)); + } + assert!(plugin.host.child.is_none()); + } + let (mut plugin, _) = fixture.plugin( + "Vamp.plugin 'document', async: true\nVamp.on_document { |_| raise 'must not invoke invalid snapshot' }"); + plugin.host.queue(&serde_json::json!({ + "version": 1, "type": "document", "event": "open", + "document": {"id": 7, "revision": "old", "path": null, "root": "/project", "text": "text"}, + })).unwrap(); + let deadline = Instant::now() + STARTUP_TIMEOUT; + loop { + match plugin.poll() { + Err(error) => { + assert!(error.contains("invalid document snapshot"), "{error}"); + break; + } + Ok(events) => assert!(events.is_empty()), + } + assert!(Instant::now() < deadline); + thread::sleep(Duration::from_millis(1)); + } + assert!( + plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none() + ); +} diff --git a/tests/unit/session.rs b/tests/unit/session.rs new file mode 100644 index 0000000..ce8164f --- /dev/null +++ b/tests/unit/session.rs @@ -0,0 +1,3650 @@ +use super::*; +use std::{ + fs, + sync::atomic::{AtomicU64, Ordering}, +}; + +#[test] +fn resolver_load_errors_return_through_real_session_without_touching_the_draft() { + let fixture = Fixture::new(); + let path = fixture.file("buffer.txt", "disk\n"); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + session + .editor + .apply(vec![Effect::Replace("draft\n".into())]) + .unwrap(); + let buffer = session.editor.buffer_id(); + session.execute("resolve"); + assert!(session.editor.resolver().unwrap().loading); + let outcome = session + .resolver_worker + .as_ref() + .unwrap() + .results + .recv_timeout(Duration::from_secs(5)) + .unwrap(); + session.editor.apply_resolver_outcome(outcome); + assert!(!session.editor.resolver().unwrap().loading); + assert!(session.editor.resolver().unwrap().error.is_some()); + session.execute("resolve.close"); + assert!( + session + .editor + .documents() + .any(|document| document.id == buffer + && document.dirty + && document.lines == ["draft", ""]) + ); + assert_eq!( + fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), + "disk\n" + ); +} + +#[test] +fn pull_request_pages_stream_concurrently_and_retry_only_the_failed_category() { + use crate::diff::{PullRequestPage, RepoKind}; + let (outgoing, results) = mpsc::channel(); + let (started, starts) = mpsc::channel(); + let (open, open_gate) = mpsc::channel::>(); + let (closed, closed_gate) = mpsc::channel::>(); + let mut gates = [Some(open_gate), Some(closed_gate)]; + let cancelled = Arc::new(AtomicBool::new(false)); + let fetches = std::array::from_fn(|index| { + let gate = gates[index].take().unwrap(); + let started = started.clone(); + let cancelled = Arc::clone(&cancelled); + move |page| { + started.send((index, page)).unwrap(); + let has_more = loop { + match gate.try_recv() { + Ok(result) => break result?, + Err(mpsc::TryRecvError::Empty) if !cancelled.load(Ordering::Acquire) => { + thread::yield_now() + } + Err(_) => return Err("cancelled".into()), + } + }; + Ok(PullRequestPage { + kind: RepoKind::Git, + remote: "origin".into(), + entries: vec![], + has_more, + }) + } + }); + let mut worker = PullRequestWorker::start_with(7, outgoing, cancelled, fetches).unwrap(); + let mut first = [ + starts.recv_timeout(Duration::from_secs(2)).unwrap(), + starts.recv_timeout(Duration::from_secs(2)).unwrap(), + ]; + first.sort_unstable(); + assert_eq!(first, [(0, 1), (1, 1)]); + let receive = |worker: &mut PullRequestWorker| { + let (id, result) = results.recv_timeout(Duration::from_secs(2)).unwrap(); + assert_eq!(id, 7); + let Ok(DiffOutcome::PullRequestPage { closed, result }) = result else { + panic!("expected independently published page"); + }; + worker.complete(closed, &result); + (closed, result) + }; + closed.send(Ok(true)).unwrap(); + let (category, page) = receive(&mut worker); + assert!(category && page.unwrap().has_more); + // Open's first request remains blocked while the active Closed category advances. + worker.update(1, [0, 0]).unwrap(); + assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), (1, 2)); + closed.send(Err("late failure".into())).unwrap(); + assert_eq!(receive(&mut worker).1.unwrap_err(), "late failure"); + worker.update(1, [0, 0]).unwrap(); + assert!(starts.try_recv().is_err()); + worker.update(1, [0, 1]).unwrap(); + assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), (1, 2)); + closed.send(Ok(false)).unwrap(); + assert!(!receive(&mut worker).1.unwrap().has_more); + open.send(Ok(false)).unwrap(); + assert!(!receive(&mut worker).0); + worker.update(2, [0, 1]).unwrap(); + assert!(starts.try_recv().is_err()); +} + +#[test] +fn closing_pull_request_listing_cancels_both_outstanding_reads() { + let (outgoing, _results) = mpsc::channel(); + let (started, starts) = mpsc::channel(); + let cancelled = Arc::new(AtomicBool::new(false)); + let fetches = std::array::from_fn(|index| { + let started = started.clone(); + let cancelled = Arc::clone(&cancelled); + move |_| { + started.send(index).unwrap(); + while !cancelled.load(Ordering::Acquire) { + thread::yield_now(); + } + Err("cancelled".into()) + } + }); + let worker = + PullRequestWorker::start_with(1, outgoing, Arc::clone(&cancelled), fetches).unwrap(); + starts.recv_timeout(Duration::from_secs(2)).unwrap(); + starts.recv_timeout(Duration::from_secs(2)).unwrap(); + drop(worker); + assert!(cancelled.load(Ordering::Acquire)); +} + +#[test] +fn normal_picker_tracks_unsaved_text_and_head_with_line_gutter_disabled() { + use crate::diff::FileStatus; + let fixture = Fixture::new(); + fixture.file("buffer.txt", "base\n"); + fixture.file("clean.txt", "clean\n"); + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .args(args) + .current_dir(&fixture.0) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_AUTHOR_NAME", "Author") + .env("GIT_AUTHOR_EMAIL", "author@example.com") + .env("GIT_COMMITTER_NAME", "Author") + .env("GIT_COMMITTER_EMAIL", "author@example.com") + .env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&["init", "-q", "--template="]); + git(&["add", "buffer.txt", "clean.txt"]); + git(&["commit", "-qm", "baseline"]); + fixture.file("buffer.txt", "disk\n"); + fixture.file("added.txt", "new\n"); + let mut session = fixture.open( + "Vamp.configure do |config|\nconfig.git_gutter = \"none\"\nconfig.git_blame = false\nend\n", + ); + session.editor.set_project_root(fixture.0.clone()); + let now = session.last_file_refresh; + let open = |session: &mut EditorSession| { + session.handle(Input::Char(' ')); + session.handle(Input::Char('o')); + }; + let status = |session: &EditorSession| { + session + .editor + .normal_file_picker() + .unwrap() + .entries() + .find(|row| row.path == Path::new("buffer.txt")) + .unwrap() + .status + }; + let ready = |session: &mut EditorSession, clock: Instant, expected| { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + session.poll_gutter_at(clock); + let picker = session.editor.normal_file_picker().unwrap(); + let status = picker + .entries() + .find(|row| row.path == Path::new("buffer.txt")) + .expect("normal picker retained the buffer file") + .status; + if !session.gutter_pending && status == expected { + assert!( + picker + .entries() + .any(|row| { row.path == Path::new("clean.txt") && row.status.is_none() }) + ); + assert!(picker.entries().any(|row| { + row.path == Path::new("added.txt") && row.status == Some(FileStatus::Added) + })); + break; + } + assert!( + Instant::now() < deadline, + "normal picker status did not settle: {}", + session.editor.message() + ); + thread::sleep(Duration::from_millis(5)); + } + }; + let settle = |session: &mut EditorSession| { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + session.poll_gutter_at(now); + if !session.gutter_pending { + break; + } + assert!( + Instant::now() < deadline, + "background file status did not settle" + ); + thread::sleep(Duration::from_millis(5)); + } + }; + settle(&mut session); + open(&mut session); + assert_eq!( + status(&session), + Some(FileStatus::Modified), + "first open must reuse status loaded while the picker was closed", + ); + ready(&mut session, now, Some(FileStatus::Modified)); + session.handle(Input::Ctrl('w')); + settle(&mut session); + open(&mut session); + assert_eq!( + status(&session), + Some(FileStatus::Modified), + "reopening must retain completed status", + ); + let request = session.gutter_request_id; + session.poll_gutter_at(now); + assert_eq!( + session.gutter_request_id, request, + "opening must not restart status work" + ); + session.handle(Input::Ctrl('w')); + session.poll_gutter_at(now); + session + .editor + .apply(vec![Effect::Replace("base\n".into())]) + .unwrap(); + assert!(session.editor.dirty()); + settle(&mut session); + open(&mut session); + assert_eq!( + status(&session), + None, + "closed-picker edits must refresh the cache" + ); + ready(&mut session, now, None); + assert_eq!( + fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), + "disk\n" + ); + session.handle(Input::Ctrl('w')); + session.poll_gutter_at(now); + session.handle(Input::Char('u')); + settle(&mut session); + open(&mut session); + assert_eq!(status(&session), Some(FileStatus::Modified)); + ready(&mut session, now, Some(FileStatus::Modified)); + git(&["add", "buffer.txt"]); + git(&["commit", "-qm", "new baseline"]); + ready(&mut session, now + Duration::from_secs(1), None); + assert!(!session.apply_picker_result( + session.gutter_request_id.wrapping_sub(1), + crate::gutter::PickerChanges { + root: fixture.0.clone(), + result: Ok(Vec::new()), + }, + )); + assert!(!session.apply_picker_result( + session.gutter_request_id, + crate::gutter::PickerChanges { + root: fixture.0.join("elsewhere"), + result: Ok(Vec::new()), + }, + )); + session.handle(Input::Ctrl('w')); + session.execute("tab.new"); + open(&mut session); + assert_eq!(status(&session), None); + assert!( + session + .editor + .normal_file_picker() + .unwrap() + .entries() + .any(|row| row.path == Path::new("added.txt") && row.status == Some(FileStatus::Added)) + ); +} + +#[test] +fn gutter_results_follow_buffer_identity_and_reject_stale_edits() { + use crate::gutter::Change; + let fixture = Fixture::new(); + let first_path = fixture.file("first.txt", "first\nsecond"); + let second_path = fixture.file("second.txt", "other"); + let mut session = EditorSession::open(Some(first_path), vec![], None).unwrap(); + let now = session.last_gutter_refresh.unwrap(); + let first_buffer = session.editor.buffer_id(); + let first_request = session.gutter_request_id; + assert!(session.apply_gutter_result( + first_request, + vec![(first_buffer, Ok(vec![None, Some(Change::Modified)]))], + )); + session.editor.handle(Input::Down); + assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); + session + .editor + .execute(&format!("edit {}", second_path.display())); + session.poll_gutter_at(now); + let second_buffer = session.editor.buffer_id(); + let second_request = session.gutter_request_id; + assert_ne!(second_request, first_request); + assert!(!session.apply_gutter_result( + first_request, + vec![(first_buffer, Ok(vec![Some(Change::Removed), None]))], + )); + assert!(session.apply_gutter_result( + second_request, + vec![ + (first_buffer, Ok(vec![None, Some(Change::Modified)])), + (second_buffer, Ok(vec![Some(Change::Added)])), + ], + )); + assert_eq!(session.editor.gutter_change(0), Some(Change::Added)); + session.editor.execute(&format!("buffer {first_buffer}")); + assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); + session.editor.handle(Input::Char('i')); + session.editor.handle(Input::Char('x')); + assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); + session.editor.handle(Input::Char('y')); + assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); + session.editor.handle(Input::Backspace); + assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); + session.editor.handle(Input::Escape); + session.editor.handle(Input::Char('u')); + assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); + session.editor.handle(Input::Char('U')); + assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); + assert!(!session.apply_gutter_result( + second_request, + vec![(first_buffer, Ok(vec![None, Some(Change::Removed)]))], + )); + assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); + session.poll_gutter_at(now); + assert_eq!(session.editor.gutter_change(1), Some(Change::Modified)); + assert!(session.apply_gutter_result( + session.gutter_request_id, + vec![(first_buffer, Ok(vec![None, Some(Change::Added)]))], + )); + assert_eq!(session.editor.gutter_change(1), Some(Change::Added)); + session.poll_gutter_at(now + Duration::from_secs(1)); + assert!(session.apply_gutter_result( + session.gutter_request_id, + vec![(first_buffer, Ok(vec![None, None]))], + )); + assert_eq!(session.editor.gutter_change(1), None); + session.editor.execute(&format!("buffer {second_buffer}")); + assert_eq!(session.editor.gutter_change(0), Some(Change::Added)); +} + +#[test] +fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_following() { + use crate::diff::{Catalog, Category, Comparison, RepoKind, Revision, RevisionTarget}; + let fixture = Fixture::new(); + fixture.file("buffer.txt", "base"); + for (working, bookmark) in [(true, false), (false, false), (true, true), (false, true)] { + let mut session = fixture.open(""); + session.editor.execute("diff"); + let (id, _) = session.editor.take_diff_request().unwrap(); + session.editor.apply_diff_result( + id, + Ok(DiffOutcome::Catalog(Catalog { + kind: RepoKind::Git, + notice: None, + entries: [ + RevisionTarget::Working, + if bookmark { + RevisionTarget::Bookmark { + name: "base".into(), + remote: None, + } + } else { + RevisionTarget::Commit("base".into()) + }, + ] + .into_iter() + .map(|target| Revision { + target, + label: "revision".into(), + description: String::new(), + category: Category::Commits, + }) + .collect(), + })), + ); + session.editor.handle(Input::Down); + session.editor.handle(Input::Enter); + if !working { + session.editor.handle(Input::Down); + } + session.editor.handle(Input::Enter); + let (id, _) = session.editor.take_diff_request().unwrap(); + session.editor.apply_diff_result( + id, + Ok(DiffOutcome::Comparison(Comparison { + revisions: ["base".into(), "after".into()], + root: fixture.0.clone(), + before: "base".into(), + after: "after".into(), + files: Vec::new(), + editable_paths: Default::default(), + })), + ); + let now = session.last_diff_refresh; + for (tick, follow) in [(1, false), (2, true)] { + session.diff_follow_working_copy = follow; + session.refresh_checkout_diffs_at(now + Duration::from_millis(tick * 1000 - 1)); + assert!(session.editor.take_next_diff_request().is_none()); + session.refresh_checkout_diffs_at(now + Duration::from_secs(tick)); + let request = session.editor.take_next_diff_request(); + assert_eq!(request.is_some(), !working || follow || bookmark); + if let Some((_, refresh, operation)) = request { + assert!( + matches!(operation, DiffOperation::RefreshCheckout { comparison_id, follow_working, .. } + if comparison_id == id && follow_working == follow) + ); + assert!( + !session + .editor + .apply_diff_result(refresh, Ok(DiffOutcome::CheckoutRefresh(None))) + ); + } + } + } +} + +#[test] +fn blame_sources_are_deduplicated_and_stale_results_cannot_reappear() { + let fixture = Fixture::new(); + let path = fixture.file("source.txt", "first\nsecond\nthird"); + let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); + assert!(session.editor.git_blame()); + let first = session.blame_request_id; + let original = Arc::new(crate::blame::LineBlame { + commit: "0123456789".into(), + author: "Author".into(), + summary: "Original".into(), + uncommitted: false, + }); + let second = Arc::new(crate::blame::LineBlame { + commit: "0123456789".into(), + author: "Author".into(), + summary: "Second".into(), + uncommitted: false, + }); + let blame = || { + Ok(Some(vec![ + Some(original.clone()), + Some(second.clone()), + Some(second.clone()), + ])) + }; + session.handle(Input::Down); + assert_eq!(session.blame_request_id, first); + session.blame_worker = None; + assert!(session.apply_blame_result(first, blame())); + assert_eq!( + session.editor.line_blame(), + Some("Author · Second (01234567)") + ); + let label = session.editor.line_blame().unwrap().as_ptr(); + session.handle(Input::Down); + assert_eq!(session.editor.line_blame().unwrap().as_ptr(), label); + assert!(!session.poll_blame()); + session.handle(Input::Up); + session.handle(Input::Up); + assert_eq!( + session.editor.line_blame(), + Some("Author · Original (01234567)") + ); + assert_eq!(session.blame_request_id, first); + session.execute("tab.new"); + assert_eq!(session.blame_request_id, first); + assert_eq!( + session.editor.line_blame(), + Some("Author · Original (01234567)") + ); + let other = fixture.file("other.txt", "other"); + session.execute(&format!("edit {}", other.display())); + assert!(session.editor.line_blame().is_none()); + assert!(!session.apply_blame_result(first, blame())); + session.execute("buffer 1"); + assert!(!session.apply_blame_result(first, blame())); + let source = session.editor.blame_source().unwrap(); + let before_edit = session.blame_request_id; + session.handle(Input::Char('i')); + session.handle(Input::Char('x')); + assert_ne!(session.blame_request_id, before_edit); + assert!(!session.apply_blame_result(before_edit, blame())); + assert!(!session.editor.set_line_blame(source, original.clone())); + assert!(session.editor.line_blame().is_none()); + session.handle(Input::Escape); + session.execute("git_blame.toggle"); + assert!(!session.editor.git_blame()); + assert!(session.blame_worker.is_none()); + assert!(session.blame_source.is_none()); + assert!(session.editor.line_blame().is_none()); + assert!(!session.apply_blame_result(session.blame_request_id, blame())); + session.poll_blame(); + assert!(session.blame_worker.is_none()); + let config = fixture.file( + "config.rb", + "Vamp.configure { |config| config.git_blame = false }", + ); + let mut disabled = EditorSession::open(Some(path), vec![], Some(&config)).unwrap(); + assert!(!disabled.editor.git_blame()); + assert!(disabled.blame_worker.is_none()); + disabled.poll_blame(); + assert!(disabled.blame_worker.is_none()); +} + +#[test] +fn blame_navigation_uses_one_real_file_result_and_refreshes_its_baseline() { + let fixture = Fixture::new(); + let path = fixture.file("source.txt", "first\nsecond\n"); + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .args(args) + .current_dir(&fixture.0) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_AUTHOR_NAME", "Author") + .env("GIT_AUTHOR_EMAIL", "author@example.com") + .env("GIT_COMMITTER_NAME", "Author") + .env("GIT_COMMITTER_EMAIL", "author@example.com") + .env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&["init", "-q"]); + git(&["add", "source.txt"]); + git(&["commit", "-qm", "Original"]); + fixture.file("source.txt", "first\nchanged\n"); + git(&["add", "source.txt"]); + git(&["commit", "-qm", "Second"]); + let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); + let ready = |session: &mut EditorSession| { + let deadline = Instant::now() + Duration::from_secs(10); + while session.blame_lines.is_none() { + assert!(Instant::now() < deadline, "file attribution did not finish"); + session.poll(); + thread::sleep(Duration::from_millis(5)); + } + }; + let first = session.blame_request_id; + session.handle(Input::Down); + assert_eq!(session.blame_request_id, first); + ready(&mut session); + assert!(session.editor.line_blame().unwrap().contains("Second")); + session.handle(Input::Up); + assert!(session.editor.line_blame().unwrap().contains("Original")); + session.handle(Input::Down); + assert!(session.editor.line_blame().unwrap().contains("Second")); + session.handle(Input::Down); + assert!(session.editor.line_blame().is_none()); + assert_eq!(session.blame_request_id, first); + session.handle(Input::Up); + session.handle(Input::Char('i')); + session.handle(Input::Char('x')); + assert_ne!(session.blame_request_id, first); + ready(&mut session); + assert_eq!(session.editor.line_blame(), Some("Uncommitted")); + session.handle(Input::Escape); + let edited = session.blame_request_id; + session.execute("save"); + assert_ne!(session.blame_request_id, edited); + ready(&mut session); + git(&["add", "source.txt"]); + git(&["commit", "-qm", "Saved"]); + let saved = session.blame_request_id; + session.refresh_blame(); + assert_ne!(session.blame_request_id, saved); + ready(&mut session); + assert!(session.editor.line_blame().unwrap().contains("Saved")); + fixture.file("source.txt", "first\nxchanged\n"); + let refreshed = session.blame_request_id; + assert!(!session.editor.refresh_files()); + session.synchronize_documents(); + assert_ne!(session.blame_request_id, refreshed); + ready(&mut session); + session.handle(Input::Char('i')); + session.handle(Input::Char('x')); + ready(&mut session); + assert!(session.editor.dirty()); + fs::remove_file(path).unwrap(); + assert!(session.editor.refresh_files()); + session.synchronize_documents(); + assert!(session.blame_source.is_none()); + assert!(session.editor.line_blame().is_none()); +} + +#[test] +fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() { + use crate::plugin::Utf16Range; + + let fixture = Fixture::new(); + let source = fixture.file("buffer.txt", "source"); + let target = fixture.file("target.txt", "A😀e\u{301}Z\n"); + let mut session = EditorSession::open(Some(source.clone()), vec![], None).unwrap(); + session.dispatch(Invocation { + plugin: "core".into(), + command: "lsp.definition".into(), + args: String::new(), + }); + let buffer_id = session.editor.buffer_id(); + let revision = session.editor.text_revision(); + let request_id = session.lsp_request_id; + let span = Utf16Range { + start: (0, 5), + end: (0, 6), + }; + let invalid = Utf16Range { + end: (9, 0), + ..span + }; + let update = |range, target_range| PluginUpdate { + request_id: Some(request_id), + effects: vec![UpdateEffect::NavigateUtf16 { + buffer_id, + revision, + path: target.clone(), + range, + target_range, + }], + }; + for (range, target_range) in [(invalid, None), (span, Some(invalid))] { + assert!( + session + .apply_update("core", update(range, target_range)) + .unwrap() + ); + assert_eq!(session.editor.buffer_id(), buffer_id); + assert_eq!(session.editor.path(), Some(source.as_path())); + assert_eq!(session.editor.buffer_count(), 1); + assert_eq!(session.editor.tab_count(), 1); + assert_eq!(session.editor.cursor(), Cursor::default()); + assert!(session.editor.message().contains("definition failed")); + } + assert!(session.apply_update("fixture", update(span, None)).is_err()); + assert_eq!(session.editor.buffer_id(), buffer_id); + assert!( + session + .apply_update("core", update(span, Some(span))) + .unwrap() + ); + assert_eq!(session.editor.path(), Some(target.as_path())); + assert_eq!(session.editor.cursor(), Cursor { row: 0, column: 3 }); + assert_eq!(session.editor.tab_count(), 1); + session.handle(Input::Ctrl('o')); + assert_eq!(session.editor.path(), Some(source.as_path())); + assert_eq!(session.editor.cursor(), Cursor::default()); +} + +static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); + +#[test] +fn render_swaps_preserve_session_sources_and_pending_effects_do_not_cross_panes() { + let fixture = Fixture::new(); + let path = fixture.file("buffer.txt", "source"); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + let source = InvocationSource { + pane_id: session.editor.pane_id(), + buffer_id: session.editor.buffer_id(), + revision: session.editor.text_revision(), + }; + session + .requests + .entry("fixture".into()) + .or_default() + .pending + .insert(1, source); + session.requests.entry("fixture".into()).or_default().latest = Some((1, source)); + session.handle(Input::Char('|')); + assert_eq!(session.editor.buffer_id(), source.buffer_id); + assert_eq!(session.editor.cursor(), Cursor::default()); + assert!(!session.request_is_current("fixture", Some(1), source)); + assert!( + !session + .apply_response("fixture", 1, vec![Effect::Insert("wrong pane".into())]) + .unwrap() + ); + session.requests.entry("core".into()).or_default().latest = Some(( + 2, + InvocationSource { + pane_id: session.editor.pane_id(), + ..source + }, + )); + session.lsp_source_cursor = Some(session.editor.cursor()); + session.lsp_source_mode = session.editor.mode(); + session.lsp_source_view = Some(( + session.editor.tab_id(), + session.editor.diff().and_then(|diff| diff.comparison_id), + )); + session.editor_mut().with_render_pane(1, |pane| { + assert_eq!(pane.pane_id(), 1); + assert_eq!(pane.lines(), ["source"]); + }); + session.synchronize_documents(); + assert!(session.requests["core"].latest.is_some()); + assert_eq!(session.editor.pane_id(), 2); + session.handle(Input::Alt('h')); + assert!(session.requests["core"].latest.is_none()); + assert_eq!(session.editor.lines(), ["source"]); +} + +#[test] +fn native_semantic_batches_are_atomic_and_malformed_updates_clear_hidden_overlays() { + use crate::plugin::{SemanticToken, SemanticTokenKind}; + use std::sync::Arc; + let fixture = Fixture::new(); + fixture.file("buffer.txt", "source"); + let target = fixture.file("target.txt", "target"); + let mut session = fixture.open(""); + let token = SemanticToken { + start: TextPosition { row: 0, column: 0 }, + end: TextPosition { row: 0, column: 1 }, + kind: SemanticTokenKind::Variable, + modifiers: 0, + }; + let tokens: Arc<[SemanticToken]> = Arc::from([token]); + let semantic = |buffer_id, revision, tokens| UpdateEffect::SemanticTokens { + buffer_id, + revision, + tokens, + }; + let update = |effects| PluginUpdate { + request_id: None, + effects, + }; + session + .apply_update("core", update(vec![semantic(1, 0, Some(tokens.clone()))])) + .unwrap(); + session.execute(&format!("edit {}", target.display())); + session + .apply_update("core", update(vec![semantic(2, 0, Some(tokens.clone()))])) + .unwrap(); + let diagnostic = crate::plugin::Diagnostic { + start: token.start, + end: token.end, + severity: crate::plugin::DiagnosticSeverity::Warning, + message: "unchanged".into(), + }; + session + .editor + .replace_diagnostics("plugin", 2, 0, vec![diagnostic.clone()]) + .unwrap(); + let malformed: Arc<[SemanticToken]> = Arc::from([SemanticToken { + end: TextPosition { row: 99, column: 0 }, + ..token + }]); + let before_message = session.editor.message().to_owned(); + let result = session.apply_update( + "core", + update(vec![ + UpdateEffect::Message { + text: "must not apply".into(), + }, + UpdateEffect::Diagnostics { + buffer_id: 2, + revision: 0, + items: vec![diagnostic.clone()], + }, + semantic(1, 0, Some(tokens.clone())), + semantic(2, 0, Some(malformed.clone())), + ]), + ); + assert!(result.is_err()); + assert_eq!(session.editor.message(), before_message); + assert!(session.editor.semantic_tokens().is_empty()); + assert_eq!(session.editor.diagnostics().count(), 1); + session.execute("buffer 1"); + assert!(session.editor.semantic_tokens().is_empty()); + assert!( + session + .apply_update( + "core", + update(vec![ + semantic(999, 0, Some(malformed.clone())), + semantic(1, 99, Some(malformed)), + semantic(1, 0, Some(tokens.clone())), + ]) + ) + .unwrap() + ); + assert!(std::ptr::eq( + session.editor.semantic_tokens(), + tokens.as_ref() + )); + assert_eq!(session.editor.lines(), ["source"]); + assert_eq!(session.editor.text_revision(), 0); + assert!(!session.editor.dirty()); + assert!( + session + .apply_update("core", update(vec![semantic(1, 0, None)])) + .unwrap() + ); + assert!(session.editor.semantic_tokens().is_empty()); +} + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "vamp-session-{}-{}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + + fn file(&self, name: &str, text: &str) -> PathBuf { + let path = self.0.join(name); + fs::write(&path, text).unwrap(); + path + } + + fn open(&self, config: &str) -> EditorSession { + let config = self.file("config.rb", config); + let mut session = EditorSession::open( + Some(self.0.join("buffer.txt")), + vec!["text".into()], + Some(&config), + ) + .unwrap(); + session.editor.set_review_directory(self.0.join("reviews")); + // Legacy editing scenarios exercise the internal register, never the user's desktop. + session.editor.set_clipboard_mode("separate").unwrap(); + session + } + + #[cfg(unix)] + fn system_session(&self, config: &str) -> EditorSession { + use std::os::unix::fs::PermissionsExt; + let config = self.file("config.rb", config); + let mut session = EditorSession::open( + Some(self.0.join("buffer.txt")), + vec!["text".into()], + Some(&config), + ) + .unwrap(); + self.file("system.txt", ""); + let reader = self.file("clip-read", "#!/bin/sh\n[ -e \"${0%/*}/read.fail\" ] && exit 13\nexec /bin/cat \"${0%/*}/system.txt\"\n"); + let writer = self.file("clip-write", "#!/bin/sh\n[ -e \"${0%/*}/write.fail\" ] && exit 13\nexec /bin/cat > \"${0%/*}/system.txt\"\n"); + fs::set_permissions(&reader, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(&writer, fs::Permissions::from_mode(0o700)).unwrap(); + session + .editor + .set_system_clipboard(crate::clipboard::SystemClipboard::with_commands( + reader, writer, + )); + session + } + + fn healthy(&self) { + self.file( + "healthy.rb", + r#" +Vamp.plugin "healthy", requires: ["text"] +Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" } +"#, + ); + } + fn asynchronous(&self, file: Option) -> EditorSession { + self.file( + "generic.rb", + r#" +Vamp.plugin "generic_fixture", requires: ["text", "diagnostics"], async: true +log_mutex = Mutex.new +record = ->(value) { log_mutex.synchronize { File.open(Vamp.options.fetch("log"), "a") { |file| file.puts JSON.generate(value) } } } +Vamp.on_document { |event| record.call(event) } +Vamp.command("fixture_barrier") { |editor, args| editor.message args } +Vamp.command("fixture_replace") { |editor, args| editor.replace args } +Vamp.command("fixture_crash") { |_editor, _args| Process.exit!(7) } +["fixture_publish", "fixture_hold"].each do |name| + Vamp.command(name) do |editor, args| + payload = JSON.parse(args) + effects = payload.fetch("effects") + effects.each do |effect| + next unless ["diagnostics", "navigate"].include?(effect["type"]) + effect["buffer_id"] ||= editor.buffer_id + effect["revision"] ||= editor.revision + end + record.call({"invoke" => name, "buffer_id" => editor.buffer_id, "revision" => editor.revision, + "row" => editor.row, "column" => editor.column, "request_id" => editor.request_id}) + if name == "fixture_hold" + Thread.new do + gate = payload.fetch("gate") + sleep 0.002 until File.exist?(gate) + Vamp.publish(effects + [{"type" => "message", "text" => "delivered #{gate}"}], request_id: editor.request_id) + end + else + Vamp.publish(effects, request_id: editor.request_id) + end + end +end +"#, + ); + let log = self.0.join("events.jsonl"); + self.file("events.jsonl", ""); + let config = self.file( + "async-config.rb", + &format!( + "Vamp.configure {{ |config| config.clipboard_mode = \"separate\"; config.plugin \"generic.rb\", options: {{log: {}}} }}", + serde_json::to_string(&log.to_string_lossy()).unwrap(), + ), + ); + EditorSession::open( + file, + vec!["text".into(), "diagnostics".into()], + Some(&config), + ) + .unwrap() + } + + fn events(&self) -> Vec { + fs::read_to_string(self.0.join("events.jsonl")) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[cfg(unix)] +#[test] +fn invalid_clipboard_policy_retains_system_default_and_valid_bindings() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "abc"); + let mut session = fixture.system_session( + r#" +Vamp.configure do |config| + config.clipboard_mode = "combined" + config.bind "n", config.core.right +end +"#, + ); + assert_eq!( + session.editor().clipboard_mode(), + crate::clipboard::ClipboardMode::System + ); + session.handle(Input::Char('n')); + session.handle(Input::Char('v')); + session.handle(Input::Char('y')); + assert_eq!( + fs::read_to_string(fixture.0.join("system.txt")).unwrap(), + "b" + ); + assert_eq!(session.editor().lines(), &["abc"]); +} + +#[cfg(unix)] +#[test] +fn default_system_clipboard_tracks_external_text_and_preserves_cut_failures() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "alpha beta"); + let mut session = fixture.system_session(""); + assert_eq!( + session.editor().clipboard_mode(), + crate::clipboard::ClipboardMode::System + ); + fixture.file("system.txt", "external"); + session.handle(Input::Char('P')); + assert_eq!(session.editor().lines(), &["externalalpha beta"]); + session.handle(Input::Char('u')); + for ch in "vey".chars() { + session.handle(Input::Char(ch)); + } + assert_eq!( + fs::read_to_string(fixture.0.join("system.txt")).unwrap(), + "alpha" + ); + fixture.file("system.txt", "outside"); + session.handle(Input::Home); + session.handle(Input::Char('P')); + assert_eq!(session.editor().lines(), &["outsidealpha beta"]); + fixture.file("system.txt", "fresh"); + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["outsideoutsidealpha beta"]); + session.handle(Input::Char('u')); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["alpha beta"]); + session.handle(Input::Home); + session.handle(Input::Char('v')); + session.handle(Input::Char('e')); + let selection = session.editor().selections(); + fixture.file("write.fail", ""); + session.handle(Input::Char('c')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); + assert_eq!(session.editor().selections(), selection); + assert_eq!(session.editor().lines(), &["alpha beta"]); + assert_eq!( + fs::read_to_string(fixture.0.join("system.txt")).unwrap(), + "fresh" + ); + fs::remove_file(fixture.0.join("write.fail")).unwrap(); + session.handle(Input::Escape); + session.handle(Input::Home); + fixture.file("system.txt", "\u{1b}"); + session.handle(Input::Char('P')); + assert_eq!(session.editor().lines(), &["alpha beta"]); + assert_eq!(session.editor().cursor(), crate::editor::Cursor::default()); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); +} + +#[cfg(unix)] +#[test] +fn separate_clipboards_keep_internal_and_explicit_system_routes_independent() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "alpha beta"); + let mut session = fixture.system_session( + r#" +Vamp.configure do |config| + config.clipboard_mode = "separate" + config.bind "ctrl+s", config.core.copy_system, mode: config.modes.visual + config.bind "ctrl+g", config.core.paste_system_before +end +"#, + ); + fixture.file("system.txt", "outside"); + for ch in "vey".chars() { + session.handle(Input::Char(ch)); + } + assert_eq!( + fs::read_to_string(fixture.0.join("system.txt")).unwrap(), + "outside" + ); + for ch in "wve".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Ctrl('s')); + assert_eq!( + fs::read_to_string(fixture.0.join("system.txt")).unwrap(), + "beta" + ); + session.handle(Input::Home); + session.handle(Input::Char('P')); + assert_eq!(session.editor().lines(), &["alphaalpha beta"]); + session.handle(Input::Char('u')); + session.handle(Input::Home); + session.handle(Input::Ctrl('g')); + assert_eq!(session.editor().lines(), &["betaalpha beta"]); + session.handle(Input::Char('u')); + session.handle(Input::Home); + session.handle(Input::Char('v')); + session.handle(Input::Char('e')); + fixture.file("write.fail", ""); + session.handle(Input::Char('d')); + assert_eq!(session.editor().lines(), &[" beta"]); + assert_eq!( + fs::read_to_string(fixture.0.join("system.txt")).unwrap(), + "beta" + ); +} + +#[test] +fn selection_bindings_remap_palette_navigation_without_changing_editing_bindings() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "one two three\none two three"); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.bind "ctrl+j", nil, mode: config.modes.selection + config.bind "ctrl+f", config.core.down, mode: config.modes.selection + config.bind "ctrl+b", config.core.up, mode: config.modes.selection +end +"#, + ); + session.handle(Input::Down); + session.handle(Input::Ctrl('p')); + for ch in "core.word".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Ctrl('j')); + assert_eq!(session.editor().palette().unwrap().selected, 0); + assert_eq!( + session.editor().cursor(), + crate::editor::Cursor { row: 1, column: 0 } + ); + session.handle(Input::Ctrl('f')); + assert_eq!( + session.editor().palette().unwrap().entries[1].name, + "core.word_next" + ); + assert_eq!(session.editor().palette().unwrap().selected, 1); + session.handle(Input::Ctrl('b')); + assert_eq!(session.editor().palette().unwrap().selected, 0); + session.handle(Input::Ctrl('f')); + session.handle(Input::Enter); + assert!(session.editor().palette().is_none()); + assert_eq!( + session.editor().cursor(), + crate::editor::Cursor { row: 1, column: 4 } + ); + session.handle(Input::Ctrl('k')); + assert_eq!(session.editor().selection_count(), 2); + assert_eq!( + session.editor().lines(), + &["one two three", "one two three"] + ); +} + +#[test] +fn configured_picker_presentations_preserve_editing_and_docked_focus() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "alpha"); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.file_explorer_side = "left" + config.bind "ctrl+o", config.core.file_picker + config.bind "ctrl+e", config.core.file_explorer +end +"#, + ); + assert_eq!( + session.editor().file_explorer_side(), + crate::files::DockSide::Left + ); + session.handle(Input::Ctrl('e')); + assert_eq!( + session.editor().file_picker().unwrap().presentation, + crate::files::FilePresentation::Docked + ); + assert_eq!( + session.editor().file_picker().unwrap().view, + crate::files::FileView::Tree + ); + assert!(!session.editor().file_picker().unwrap().searching); + session.handle(Input::Char('i')); + for ch in "dir e\u{301}".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Backspace); + assert_eq!(session.editor().file_picker().unwrap().query, "dir "); + session.handle(Input::Ctrl('h')); + assert_eq!(session.editor().file_picker().unwrap().query, "dir "); + session.handle(Input::Escape); + assert!(!session.editor().file_picker().unwrap().searching); + assert!(session.editor().file_picker().unwrap().focused); + assert_eq!(session.editor().file_picker().unwrap().query, "dir "); + session.handle(Input::Ctrl('u')); + assert_eq!(session.editor().file_picker().unwrap().query, ""); + assert!(!session.editor().file_picker().unwrap().searching); + session.handle(Input::Char('i')); + for ch in "dir ".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Ctrl('u')); + assert_eq!(session.editor().file_picker().unwrap().query, ""); + assert!(session.editor().file_picker().unwrap().searching); + for ch in "dir ".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Escape); + assert_eq!(session.editor().file_picker().unwrap().query, "dir "); + session.handle(Input::Escape); + assert!(!session.editor().file_picker().unwrap().focused); + session.handle(Input::Char('i')); + for ch in " a b ".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &[" a b alpha"]); + session.handle(Input::Ctrl('o')); + assert_eq!( + session.editor().file_picker().unwrap().presentation, + crate::files::FilePresentation::Floating + ); + assert_eq!( + session.editor().file_picker().unwrap().view, + crate::files::FileView::Tree + ); + assert_eq!(session.editor().file_picker().unwrap().query, "dir "); + assert!(session.editor().file_picker().unwrap().searching); + session.handle(Input::Escape); + assert!(session.editor().file_picker().unwrap().focused); + session.handle(Input::Escape); + assert!(session.editor().file_picker().is_none()); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["alpha"]); +} + +#[test] +fn configured_flat_dock_and_tree_picker_keep_control_h_distinct_from_backspace() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "alpha"); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.file_explorer_view = "list" + config.file_picker_view = "tree" + config.bind "ctrl+f", config.core.right, mode: config.modes.tree +end +"#, + ); + session.handle(Input::Char(' ')); + session.handle(Input::Char('e')); + assert_eq!( + session.editor().file_picker().unwrap().view, + crate::files::FileView::List + ); + for ch in "e\u{301}".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Ctrl('h')); + assert_eq!(session.editor().file_picker().unwrap().query, "e\u{301}"); + session.handle(Input::Backspace); + assert_eq!(session.editor().file_picker().unwrap().query, ""); + session.handle(Input::Ctrl('w')); + session.handle(Input::Char(' ')); + session.handle(Input::Char('o')); + assert_eq!( + session.editor().file_picker().unwrap().view, + crate::files::FileView::Tree + ); + assert!(session.editor().file_picker().unwrap().searching); + session.handle(Input::Char('x')); + session.handle(Input::Ctrl('h')); + assert_eq!(session.editor().file_picker().unwrap().query, "x"); + session.handle(Input::Escape); + assert!(!session.editor().file_picker().unwrap().searching); + assert!(session.editor().file_picker().unwrap().focused); + assert_eq!(session.editor().file_picker().unwrap().query, "x"); + session.handle(Input::Ctrl('t')); + assert_eq!( + session.editor().file_picker().unwrap().view, + crate::files::FileView::List + ); + session.handle(Input::Escape); + assert!(session.editor().file_picker().is_none()); + session.handle(Input::Char('i')); + session.handle(Input::Char('x')); + session.handle(Input::Ctrl('h')); + assert_eq!(session.editor().lines(), &["xalpha"]); + session.handle(Input::Backspace); + assert_eq!(session.editor().lines(), &["alpha"]); +} + +#[test] +fn invalid_explorer_preferences_preserve_defaults_and_valid_bindings() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "abc"); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.file_explorer_side = "diagonal" + config.file_explorer_view = "grid" + config.file_picker_view = "grid" + config.bind "n", config.core.right +end +"#, + ); + assert_eq!( + session.editor().file_explorer_side(), + crate::files::DockSide::Right + ); + assert_eq!( + session.editor().file_explorer_view(), + crate::files::FileView::Tree + ); + session.handle(Input::Char('n')); + assert_eq!( + session.editor().cursor(), + crate::editor::Cursor { row: 0, column: 1 } + ); + session.handle(Input::Char('i')); + session.handle(Input::Char('!')); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &["a!bc"]); +} + +#[test] +fn diff_display_preferences_survive_reopening() { + use crate::{diff::ViewMode, panes::SplitAxis}; + let fixture = Fixture::new(); + fixture.file("buffer.txt", "abc"); + for (mode, orientation, expected_mode, expected_axis) in [ + ( + "unified", + "horizontal", + ViewMode::Unified, + SplitAxis::Horizontal, + ), + ( + "split", + "horizontal", + ViewMode::Split, + SplitAxis::Horizontal, + ), + ("split", "vertical", ViewMode::Split, SplitAxis::Vertical), + ] { + let mut session = fixture.open(&format!( + r#"Vamp.configure do |config| + config.diff_view_mode = "{mode}" + config.diff_split_orientation = "{orientation}" + config.bind "n", config.core.right +end"# + )); + session.handle(Input::Char('n')); + assert_eq!(session.editor().cursor().column, 1); + for _ in 0..2 { + session.execute("diff"); + let diff = session.editor().diff().unwrap(); + assert_eq!(diff.view_mode, expected_mode); + assert_eq!(diff.split_axis, expected_axis); + session.execute("diff.close"); + assert!(session.editor().diff().is_none()); + assert_eq!(session.editor().lines(), &["abc"]); + } + } +} + +#[test] +fn no_config_uses_space_leader_for_palette_and_write() { + let fixture = Fixture::new(); + let buffer = fixture.file("buffer.txt", "base"); + let mut session = EditorSession::open(Some(buffer.clone()), vec!["text".into()], None).unwrap(); + session.handle(Input::Char(' ')); + assert_eq!(session.editor().sequence_pending(), Some("Space")); + session.handle(Input::Char(' ')); + assert!(session.editor().palette().is_some()); + assert!(session.editor().sequence_pending().is_none()); + session.handle(Input::Escape); + session.handle(Input::Char('i')); + session.handle(Input::Char('!')); + session.handle(Input::Escape); + session.handle(Input::Char(' ')); + session.handle(Input::Char('w')); + assert_eq!(fs::read_to_string(buffer).unwrap(), "!base"); + assert!(!session.editor().dirty()); +} + +#[test] +fn ruby_leader_sequences_share_one_config_and_plugin_host_across_buffers() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "alpha"); + let second = fixture.file("second.txt", "beta"); + fixture.file( + "shared.rb", + r##" +Vamp.plugin "shared", requires: ["text"] +calls = 0 +Vamp.command("stamp") do |editor, _| + calls += 1 + editor.insert "#{calls}:#{File.basename(editor.path)}:" +end +Vamp.command("fail") { |_, _| raise "shared host failure" } +"##, + ); + let mut session = fixture.open( + r#" +File.open("loads.txt", "a") { |file| file.puts "loaded" } +Vamp.configure do |config| + config.leader = "," + config.plugin "shared.rb" + config.bind "leader z n", "stamp" + config.bind "leader z f", "fail" +end +"#, + ); + session.handle(Input::Char(',')); + session.handle(Input::Char('z')); + assert_eq!(session.editor().sequence_pending(), Some(", z")); + assert_eq!(session.editor().lines(), &["alpha"]); + session.handle(Input::Char('n')); + assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]); + assert!(session.editor().sequence_pending().is_none()); + session.handle(Input::Char(',')); + session.handle(Input::Char('z')); + session.execute(&format!("edit {}", second.display())); + assert!(session.editor().sequence_pending().is_none()); + for key in [',', 'z', 'n'] { + session.handle(Input::Char(key)); + } + assert_eq!(session.editor().lines(), &["2:second.txt:beta"]); + for key in [',', 'b', 'p'] { + session.handle(Input::Char(key)); + } + assert_eq!(session.editor().buffer_id(), 1); + session.handle(Input::Home); + for key in [',', 'z', 'n'] { + session.handle(Input::Char(key)); + } + assert_eq!( + session.editor().lines(), + &["3:buffer.txt:1:buffer.txt:alpha"] + ); + assert_eq!( + fs::read_to_string(fixture.0.join("loads.txt")).unwrap(), + "loaded\n" + ); + for key in [',', 'z', 'f'] { + session.handle(Input::Char(key)); + } + assert!( + session + .editor() + .message() + .contains("Plugin shared disabled:") + ); + for key in [',', 'z', 'n'] { + session.handle(Input::Char(key)); + } + assert_eq!( + session.editor().lines(), + &["3:buffer.txt:1:buffer.txt:alpha"] + ); + session.handle(Input::Ctrl('p')); + assert!( + !session + .editor() + .palette() + .unwrap() + .entries + .iter() + .any(|entry| entry.owner == "shared") + ); +} + +#[test] +fn ruby_disabled_leader_suffix_and_cancellation_keep_counts_and_selections_safe() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "abcdef"); + fixture.healthy(); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.leader = "," + config.plugin "healthy.rb" + config.bind "leader z n", "healthy" + config.bind "leader z x", nil + config.bind "leader z h", config.core.left, mode: config.modes.visual +end +"#, + ); + for last in ['x', 'd'] { + for key in ['3', ',', 'z', last] { + session.handle(Input::Char(key)); + } + assert!(session.editor().sequence_pending().is_none()); + assert_eq!(session.editor().lines(), &["abcdef"]); + session.handle(Input::Char('l')); + } + assert_eq!(session.editor().cursor().column, 2); + for key in ['3', ',', 'z'] { + session.handle(Input::Char(key)); + } + session.handle(Input::Paste("n:q!\n".into())); + assert!(session.editor().sequence_pending().is_none()); + session.handle(Input::Char('l')); + assert_eq!(session.editor().cursor().column, 3); + assert_eq!(session.editor().lines(), &["abcdef"]); + session.handle(Input::Char('v')); + let selected = session.editor().selections(); + for key in ['3', ',', 'z'] { + session.handle(Input::Char(key)); + } + session.handle(Input::Escape); + assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); + assert_eq!(session.editor().selections(), selected); + assert!(session.editor().sequence_pending().is_none()); + session.handle(Input::Char('h')); + assert_eq!(session.editor().cursor().column, 2); + assert_eq!(session.editor().lines(), &["abcdef"]); + assert!(!session.editor().should_quit()); +} + +#[test] +fn invalid_backend_leader_retains_space_and_installs_valid_config_bindings() { + for key in ["not-a-key", "esc", "7", "i"] { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "base"); + fixture.healthy(); + let mut session = fixture.open(&format!( + r#" +Vamp.configure do |config| + config.leader = "{key}" + config.plugin "healthy.rb" + config.bind "ctrl+f", config.core.right + config.bind "leader z n", "healthy" +end +"#, + )); + assert!( + session + .editor() + .message() + .contains(&format!("leader {key:?}:")) + ); + assert!(!session.editor().message().contains("config disabled")); + session.handle(Input::Ctrl('f')); + assert_eq!(session.editor().cursor().column, 1); + session.handle(Input::Char(' ')); + assert_eq!(session.editor().sequence_pending(), Some("Space")); + session.handle(Input::Char('z')); + session.handle(Input::Char('n')); + assert_eq!(session.editor().lines(), &["healthy "]); + } +} + +#[test] +fn buffers_share_config_and_plugin_host_but_keep_history_and_protect_hidden_edits() { + let fixture = Fixture::new(); + let first = fixture.file("buffer.txt", "alpha"); + let second = fixture.file("second.txt", "beta"); + fixture.file( + "shared.rb", + r##" +Vamp.plugin "shared", requires: ["text"] +calls = 0 +Vamp.command("stamp") do |editor, _| + calls += 1 + editor.insert "#{calls}:#{File.basename(editor.path)}:" +end +Vamp.command("fail") { |_, _| raise "shared host failure" } +"##, + ); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.plugin "shared.rb" + config.bind "g", "stamp" +end +"#, + ); + session.handle(Input::Char('g')); + assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]); + session.execute(&format!("edit {}", second.display())); + assert_eq!(session.editor().buffer_id(), 2); + session.handle(Input::Char('g')); + assert_eq!(session.editor().lines(), &["2:second.txt:beta"]); + session.execute("bdelete"); + assert_eq!(session.editor().buffer_id(), 2); + assert_eq!(session.editor().buffer_count(), 2); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["beta"]); + session.execute("q"); + assert!(!session.editor().should_quit()); + session.execute("wq"); + assert!(!session.editor().should_quit()); + assert_eq!(fs::read_to_string(&first).unwrap(), "alpha"); + assert_eq!(fs::read_to_string(&second).unwrap(), "beta"); + session.execute("buffer 1"); + assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["alpha"]); + session.handle(Input::Char('g')); + assert_eq!(session.editor().lines(), &["3:buffer.txt:alpha"]); + session.execute("w"); + assert_eq!(fs::read_to_string(&first).unwrap(), "3:buffer.txt:alpha"); + session.execute("bnext"); + assert_eq!(session.editor().lines(), &["beta"]); + session.execute("fail"); + assert!(session.editor().message().contains("disabled")); + session.execute("buffer 1"); + session.handle(Input::Char('g')); + assert_eq!(session.editor().lines(), &["3:buffer.txt:alpha"]); + session.execute("q"); + assert!(session.editor().should_quit()); +} + +#[test] +fn typed_palette_binding_prepares_arguments_and_retires_failed_plugin_commands() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "base"); + fixture.file( + "palette.rb", + r#" +Vamp.plugin "palette_fixture", requires: ["text"] +Vamp.command("palette_insert") { |editor, args| editor.insert args } +Vamp.command("palette_fail") { |_, _| raise "palette fixture failure" } +"#, + ); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.plugin "palette.rb" + config.bind "ctrl+p", nil + config.bind "ctrl+f", config.core.palette +end +"#, + ); + session.handle(Input::Ctrl('p')); + assert!(session.editor().palette().is_none()); + session.handle(Input::Ctrl('f')); + for ch in "palette_insert".chars() { + session.handle(Input::Char(ch)); + } + assert_eq!( + session.editor().palette().unwrap().entries[0].owner, + "palette_fixture" + ); + session.handle(Input::Enter); + assert_eq!(session.editor().mode(), crate::editor::Mode::Command); + assert_eq!(session.editor().command_line(), "palette_insert "); + assert_eq!(session.editor().lines(), &["base"]); + for ch in "界!".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Enter); + assert_eq!(session.editor().lines(), &["界!base"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["base"]); + session.handle(Input::Ctrl('f')); + for ch in "palette_fail".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Enter); + session.handle(Input::Enter); + assert!(session.editor().message().contains("disabled")); + session.handle(Input::Ctrl('f')); + assert!( + !session + .editor() + .palette() + .unwrap() + .entries + .iter() + .any(|entry| entry.owner == "palette_fixture") + ); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &["base"]); +} + +#[test] +fn configured_linewise_change_and_paste_preserve_rows_and_one_replacement_history() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", " alpha\n界beta\ntail"); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.clipboard_mode = "separate" + config.bind "V", nil + config.bind "s", config.core.select_linewise + config.bind "s", config.core.select_linewise, mode: config.modes.visual + config.bind "r", config.core.change, mode: config.modes.visual +end +"#, + ); + for key in "llsjrnew".chars() { + session.handle(Input::Char(key)); + } + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &["new", "tail"]); + session.handle(Input::Char('p')); + assert_eq!( + session.editor().lines(), + &["new", " alpha", "界beta", "tail"] + ); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["new", "tail"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &[" alpha", "界beta", "tail"]); + session.handle(Input::Char('U')); + assert_eq!(session.editor().lines(), &["new", "tail"]); +} + +#[test] +fn configured_selection_actions_edit_together_and_invoke_plugins_once() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", " alpha\nb\n gamma"); + fixture.file( + "once.rb", + r#" +Vamp.plugin "once", requires: ["text"] +Vamp.command("once") { |editor, _| editor.insert "!" } +"#, + ); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.plugin "once.rb" + config.bind "r", config.core.select_lines, mode: config.modes.visual + config.bind "z", config.core.clone_below + config.bind "f", "once" +end +"#, + ); + session.handle(Input::Char('v')); + session.handle(Input::Char('j')); + session.handle(Input::Char('j')); + session.handle(Input::Char('r')); + assert_eq!(session.editor().selection_count(), 3); + session.handle(Input::Char('A')); + session.handle(Input::Char('?')); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &[" alpha?", "b?", " gamma?"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &[" alpha", "b", " gamma"]); + assert_eq!(session.editor().selection_count(), 3); + session.handle(Input::Char('f')); + assert_eq!(session.editor().selection_count(), 1); + assert_eq!( + session + .editor() + .lines() + .iter() + .map(|line| line.matches('!').count()) + .sum::(), + 1 + ); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &[" alpha", "b", " gamma"]); + assert_eq!(session.editor().selection_count(), 3); +} + +#[test] +fn startup_rejection_installs_no_commands_and_keeps_other_plugins_and_core() { + let fixture = Fixture::new(); + fixture.healthy(); + fixture.file( + "graphics.rb", + r#" +Vamp.plugin "graphics", requires: ["graphics"] +Vamp.command("draw") { |editor, args| editor.insert "incompatible" } +"#, + ); + fixture.file( + "collision.rb", + r#" +Vamp.plugin "collision", requires: ["text"] +Vamp.command("leaked") { |editor, args| editor.insert "collision" } +Vamp.command("q") { |editor, args| editor.insert "override" } +"#, + ); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.plugin "graphics.rb" + config.plugin "collision.rb" + config.plugin "missing.rb" + config.plugin "healthy.rb" + config.bind "g", "healthy" +end +"#, + ); + for command in ["draw", "leaked"] { + session.execute(command); + assert_eq!(session.editor().lines(), &[String::new()]); + } + session.handle(Input::Char('g')); + assert_eq!(session.editor().lines(), &["healthy "]); + session.execute("w"); + assert_eq!( + fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), + "healthy " + ); + session.execute("q"); + assert!(session.editor().should_quit()); +} + +#[test] +fn rejected_effects_discard_entire_transaction_and_retire_commands_and_bindings() { + let fixture = Fixture::new(); + fixture.healthy(); + fixture.file( + "invalid.rb", + r#" +Vamp.plugin "invalid", requires: ["text"] +Vamp.command "invalid" do |editor, args| + editor.insert "partial" + editor.message "\e" +end +Vamp.command("sibling") { |editor, args| editor.insert "must be retired too" } +"#, + ); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.plugin "invalid.rb" + config.plugin "healthy.rb" + config.bind "g", "invalid" +end +"#, + ); + session.handle(Input::Char('g')); + assert_eq!(session.editor().lines(), &[String::new()]); + assert!(!session.editor().dirty()); + assert!( + session + .editor() + .message() + .contains("Plugin invalid disabled: Unsupported control character") + ); + let message = session.editor().message().to_owned(); + session.handle(Input::Char('g')); + assert_eq!(session.editor().message(), message); + for command in ["invalid", "sibling"] { + session.execute(command); + assert_eq!( + session.editor().message(), + format!("Unknown command: {command}") + ); + } + session.execute("healthy survivor"); + assert_eq!(session.editor().lines(), &["healthy survivor"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &[String::new()]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().message(), "Nothing to undo"); +} + +#[test] +fn callback_crash_and_timeout_retire_plugin_without_losing_core_editing() { + for failure in ["raise 'callback failed'", "Process.exit!(7)", "sleep 30"] { + let fixture = Fixture::new(); + fixture.healthy(); + fixture.file( + "failing.rb", + &format!( + r#" +Vamp.plugin "failing", requires: ["text"] +Vamp.command "fail" do |editor, args| + editor.insert "never commit" + {failure} +end +"# + ), + ); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.plugin "failing.rb" + config.plugin "healthy.rb" + config.bind "g", "fail" +end +"#, + ); + session.execute("fail"); + assert_eq!(session.editor().lines(), &[String::new()]); + assert!( + session + .editor() + .message() + .contains("Plugin failing disabled:") + ); + session.execute("fail"); + assert_eq!(session.editor().message(), "Unknown command: fail"); + session.handle(Input::Char('g')); + assert_eq!(session.editor().lines(), &[String::new()]); + session.execute("healthy survivor"); + session.execute("w"); + session.handle(Input::Char('i')); + session.handle(Input::Char('x')); + session.handle(Input::Escape); + session.execute("q"); + assert!(!session.editor().should_quit()); + session.execute("wq"); + assert!(session.editor().should_quit()); + assert_eq!( + fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), + "xhealthy survivor" + ); + } +} + +#[test] +fn configured_bindings_preserve_modal_editing_history_and_literal_paste() { + let fixture = Fixture::new(); + fixture.healthy(); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.plugin "healthy.rb" + config.bind "n", config.core.insert_first_nonblank + config.bind "i", nil + config.bind "ctrl+r", config.core.redo + config.bind "ctrl+c", config.core.normal, mode: config.modes.insert + config.bind "esc", nil, mode: config.modes.insert + config.bind "!", config.core.newline, mode: config.modes.insert + config.bind "shift+x", nil, mode: config.modes.insert + config.bind ":", nil + config.bind "space", config.core.command + config.bind "enter", nil, mode: config.modes.command + config.bind "ctrl+x", config.core.submit, mode: config.modes.command + config.bind "ctrl+c", config.core.normal, mode: config.modes.command + config.bind "ctrl+h", config.core.backspace, mode: config.modes.command + config.bind "g", config.core.undo + config.bind "g", "healthy" + config.bind "n", config.core.newline + config.bind "unknown", config.core.undo + config.bind "ctrl+f", "healthy", mode: config.modes.insert +end +"#, + ); + session.handle(Input::Char('i')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + session.handle(Input::Char('n')); + for ch in "界Xab!".chars() { + session.handle(Input::Char(ch)); + } + assert_eq!(session.editor().lines(), &["界ab", ""]); + session.handle(Input::Escape); + assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); + session.handle(Input::Paste("X!".into())); + assert_eq!(session.editor().lines(), &["界ab", "X!"]); + session.handle(Input::Ctrl('C')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["界ab", ""]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &[String::new()]); + session.handle(Input::Ctrl('r')); + session.handle(Input::Ctrl('r')); + assert_eq!(session.editor().lines(), &["界ab", "X!"]); + session.handle(Input::Char(':')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + session.handle(Input::Char(' ')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Command); + assert!(session.editor().sequence_pending().is_none()); + for ch in "w界".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Backspace); + assert_eq!(session.editor().command_line(), "w"); + session.handle(Input::Enter); + assert_eq!(session.editor().mode(), crate::editor::Mode::Command); + session.handle(Input::Ctrl('x')); + assert_eq!( + fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), + "界ab\nX!" + ); + session.handle(Input::Char(' ')); + session.handle(Input::Char('q')); + session.handle(Input::Ctrl('c')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert!(session.editor().command_line().is_empty()); + assert!(!session.editor().should_quit()); + session.handle(Input::Char('g')); + assert_eq!(session.editor().lines(), &["healthy "]); + session.handle(Input::Char(' ')); + session.handle(Input::Char('q')); + session.handle(Input::Ctrl('x')); + assert!(!session.editor().should_quit()); + session.handle(Input::Char(' ')); + session.handle(Input::Char('q')); + session.handle(Input::Char('!')); + session.handle(Input::Ctrl('x')); + assert!(session.editor().should_quit()); +} + +#[test] +fn default_dot_replaces_fresh_words_and_each_repeat_has_one_undo() { + let fixture = Fixture::new(); + let buffer = fixture.file("buffer.txt", "old elephant zebra"); + let mut session = EditorSession::open(Some(buffer), vec!["text".into()], None).unwrap(); + session.editor.set_clipboard_mode("separate").unwrap(); + for key in ['v', 'e', 'c'] { + session.handle(Input::Char(key)); + } + for ch in "item".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &["item elephant zebra"]); + for key in ['w', '3', '.'] { + session.handle(Input::Char(key)); + } + assert_eq!(session.editor().lines(), &["item item zebra"]); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["item elephant zebra"]); + session.handle(Input::Home); + for key in ['w', 'w', 'v', 'e', '.'] { + session.handle(Input::Char(key)); + } + assert_eq!(session.editor().lines(), &["item elephant item"]); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["item elephant zebra"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["old elephant zebra"]); + assert!(!session.editor().dirty()); +} + +#[test] +fn typed_repeat_remaps_use_resolved_motions_and_current_visual_ranges() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "old enormous tail end"); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.bind ".", nil + config.bind ".", nil, mode: config.modes.visual + config.bind "r", config.core.repeat_last_edit + config.bind "r", config.core.repeat_last_edit, mode: config.modes.visual + config.bind "e", config.core.left, mode: config.modes.visual + config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual +end +"#, + ); + session.handle(Input::Char('v')); + session.handle(Input::Ctrl('f')); + session.handle(Input::Char('c')); + for ch in "item".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Escape); + session.handle(Input::Char('w')); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["item enormous tail end"]); + session.handle(Input::Char('r')); + assert_eq!(session.editor().lines(), &["item item tail end"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["item enormous tail end"]); + session.handle(Input::Home); + for key in ['w', 'v', '2'] { + session.handle(Input::Char(key)); + } + session.handle(Input::Ctrl('f')); + session.handle(Input::Char('.')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); + assert_eq!(session.editor().lines(), &["item enormous tail end"]); + session.handle(Input::Char('r')); + assert_eq!(session.editor().lines(), &["item item end"]); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["item enormous tail end"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["old enormous tail end"]); +} + +#[test] +fn repeat_recipe_crosses_buffers_but_pending_insert_and_undo_stay_per_buffer() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "alpha"); + let second = fixture.file("second.txt", "beta"); + let mut session = fixture.open(""); + session.handle(Input::Char('i')); + for ch in "one".chars() { + session.handle(Input::Char(ch)); + } + session.execute(&format!("edit {}", second.display())); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert_eq!(session.editor().lines(), &["beta"]); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["onebeta"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["beta"]); + session.execute("buffer 1"); + assert_eq!(session.editor().lines(), &["onealpha"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["alpha"]); + session.handle(Input::Char('U')); + assert_eq!(session.editor().lines(), &["onealpha"]); + session.execute("buffer 2"); + assert_eq!(session.editor().lines(), &["beta"]); + session.handle(Input::Char('U')); + assert_eq!(session.editor().lines(), &["onebeta"]); + session.handle(Input::Home); + session.handle(Input::Char('i')); + for ch in "two".chars() { + session.handle(Input::Char(ch)); + } + session.execute("buffer 1"); + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["twoonealpha"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["onealpha"]); + session.execute("buffer 2"); + assert_eq!(session.editor().lines(), &["twoonebeta"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["onebeta"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["beta"]); +} + +#[test] +fn plugin_effects_commit_pending_typing_without_replacing_or_reinvoking_native_recipe() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "base"); + fixture.file( + "effects.rb", + r##" +Vamp.plugin "effects", requires: ["text"] +calls = 0 +Vamp.command("effect") do |editor, args| + calls += 1 + File.open("calls.txt", "a") { |file| file.puts "#{calls} #{args}" } + case args + when "replace" + editor.replace "plugin" + when "insert" + editor.insert "?" + end + editor.message "calls=#{calls}" +end +"##, + ); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.plugin "effects.rb" +end +"#, + ); + session.handle(Input::Char('i')); + session.handle(Input::Char('!')); + session.execute("effect replace"); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert_eq!(session.editor().lines(), &["plugin"]); + assert_eq!(session.editor().message(), "calls=1"); + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["!plugin"]); + assert_eq!( + fs::read_to_string(fixture.0.join("calls.txt")).unwrap(), + "1 replace\n" + ); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["plugin"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["!base"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["base"]); + session.handle(Input::Home); + session.execute("effect insert"); + assert_eq!(session.editor().lines(), &["?base"]); + assert_eq!(session.editor().message(), "calls=2"); + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["!?base"]); + assert_eq!( + fs::read_to_string(fixture.0.join("calls.txt")).unwrap(), + "1 replace\n2 insert\n" + ); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["?base"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["base"]); + session.execute("effect message"); + assert_eq!(session.editor().message(), "calls=3"); + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["!base"]); + assert_eq!( + fs::read_to_string(fixture.0.join("calls.txt")).unwrap(), + "1 replace\n2 insert\n3 message\n" + ); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["base"]); +} + +#[test] +fn explicit_save_commits_pending_change_and_repeat_does_not_save_again() { + let fixture = Fixture::new(); + let buffer = fixture.file("buffer.txt", "old elephant"); + let mut session = fixture.open(""); + for key in ['v', 'e', 'c'] { + session.handle(Input::Char(key)); + } + for ch in "item".chars() { + session.handle(Input::Char(ch)); + } + session.execute("w"); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert_eq!(fs::read_to_string(&buffer).unwrap(), "item elephant"); + session.handle(Input::Home); + session.handle(Input::Char('w')); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["item item"]); + assert!(session.editor().dirty()); + assert_eq!(fs::read_to_string(&buffer).unwrap(), "item elephant"); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["item elephant"]); + assert!(!session.editor().dirty()); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["old elephant"]); +} + +#[test] +fn dots_in_insert_and_focused_queries_are_literal_and_excluded_from_recipe() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "alpha"); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.file_explorer_view = "list" + config.file_picker_view = "list" + config.bind "ctrl+f", config.core.file_explorer + config.bind "ctrl+o", config.core.file_picker +end +"#, + ); + session.handle(Input::Char('i')); + session.handle(Input::Char('X')); + session.handle(Input::Escape); + session.handle(Input::Home); + session.handle(Input::Ctrl('f')); + for ch in ".query".chars() { + session.handle(Input::Char(ch)); + } + assert_eq!(session.editor().file_picker().unwrap().query, ".query"); + assert_eq!(session.editor().lines(), &["Xalpha"]); + session.handle(Input::Escape); + assert!(!session.editor().file_picker().unwrap().focused); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + session.handle(Input::Ctrl('o')); + session.handle(Input::Ctrl('u')); + session.handle(Input::Char('.')); + assert_eq!(session.editor().file_picker().unwrap().query, "."); + assert_eq!(session.editor().lines(), &["Xalpha"]); + session.handle(Input::Escape); + assert!(session.editor().file_picker().is_none()); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["XXalpha"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["Xalpha"]); + session.handle(Input::Home); + session.handle(Input::Char('i')); + session.handle(Input::Char('.')); + session.handle(Input::Ctrl('p')); + assert!( + session + .editor() + .palette() + .unwrap() + .entries + .iter() + .any(|entry| entry.name == "core.repeat_last_edit") + ); + session.handle(Input::Char('.')); + assert_eq!(session.editor().palette().unwrap().query, "."); + assert_eq!(session.editor().lines(), &[".Xalpha"]); + session.handle(Input::Escape); + session.handle(Input::Char('!')); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &[".!Xalpha"]); + for prompt in [':', '/'] { + session.handle(Input::Char(prompt)); + session.handle(Input::Char('.')); + assert_eq!(session.editor().command_line(), "."); + assert_eq!(session.editor().lines(), &[".!Xalpha"]); + session.handle(Input::Escape); + } + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &[".!.!Xalpha"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &[".!Xalpha"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["Xalpha"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["alpha"]); +} + +#[test] +fn repeated_clipboard_paste_uses_captured_text_after_a_different_yank() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "one two three"); + let mut session = fixture.open(""); + for key in ['v', 'e', 'y'] { + session.handle(Input::Char(key)); + } + session.handle(Input::Home); + session.handle(Input::Char('P')); + assert_eq!(session.editor().lines(), &["oneone two three"]); + for key in ['w', 'v', 'e', 'y'] { + session.handle(Input::Char(key)); + } + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["oneoneone two three"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["oneone two three"]); + session.handle(Input::Home); + session.handle(Input::Char('P')); + assert_eq!(session.editor().lines(), &["twooneone two three"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["oneone two three"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["one two three"]); +} + +#[test] +fn literal_paste_repeats_without_dispatch_and_preserves_native_input_groups() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "seed"); + let mut session = fixture.open(""); + session.handle(Input::Char('i')); + session.handle(Input::Char('a')); + session.handle(Input::Paste(".:q!\r\n界".into())); + session.handle(Input::Char('b')); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &["a.:q!", "界bseed"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["a.:q!", "界seed"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["aseed"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["seed"]); + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["bseed"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["seed"]); + session.handle(Input::Home); + session.handle(Input::Char('i')); + session.handle(Input::Paste(".:q!\r\n界".into())); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &[".:q!", "界seed"]); + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &[".:q!", ".:q!", "界界seed"]); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert!(!session.editor().should_quit()); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &[".:q!", "界seed"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["seed"]); +} + +#[test] +fn typed_visual_change_remaps_c_and_command_execution_commits_one_replacement() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "abc DEF tail"); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.bind "c", nil, mode: config.modes.visual + config.bind "ctrl+r", config.core.change, mode: config.modes.visual + config.bind "ctrl+v", config.core.select + config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual +end +"#, + ); + session.handle(Input::Char('c')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert!(!session.editor().dirty()); + session.handle(Input::Ctrl('v')); + session.handle(Input::Ctrl('f')); + session.handle(Input::Char('c')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); + assert_eq!(session.editor().lines(), &["abc DEF tail"]); + session.handle(Input::Ctrl('r')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); + assert_eq!(session.editor().cursor().column, 0); + assert_eq!(session.editor().lines(), &[" DEF tail"]); + for ch in "cZ".chars() { + session.handle(Input::Char(ch)); + } + session.execute("w"); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert_eq!( + fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), + "cZ DEF tail" + ); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["abc DEF tail"]); + session.handle(Input::Char('P')); + assert_eq!(session.editor().lines(), &["ababcc DEF tail"]); +} + +#[test] +fn typed_visual_actions_copy_cut_paste_and_cancel_without_splitting_history() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "one two"); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.bind "v", nil + config.bind "ctrl+v", config.core.select + config.bind "ctrl+c", config.core.copy, mode: config.modes.visual + config.bind "ctrl+x", config.core.cut, mode: config.modes.visual + config.bind "ctrl+g", config.core.normal, mode: config.modes.visual + config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual + config.bind "ctrl+b", config.core.paste_before + config.bind "ctrl+p", config.core.paste_after +end +"#, + ); + session.handle(Input::Char('v')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + session.handle(Input::Ctrl('v')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); + session.handle(Input::Ctrl('f')); + assert_eq!(session.editor().selections()[0].end.column, 3); + session.handle(Input::Ctrl('c')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert!(!session.editor().dirty()); + session.handle(Input::Ctrl('p')); + assert_eq!(session.editor().lines(), &["oonene two"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["one two"]); + session.handle(Input::Ctrl('v')); + session.handle(Input::Ctrl('f')); + session.handle(Input::Ctrl('x')); + assert_eq!(session.editor().lines(), &[" two"]); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + session.handle(Input::Ctrl('b')); + assert_eq!(session.editor().lines(), &["one two"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &[" two"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["one two"]); + assert!(!session.editor().dirty()); + session.handle(Input::Ctrl('v')); + session.handle(Input::Ctrl('g')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert!(session.editor().selections().is_empty()); +} + +#[test] +fn typed_word_motions_remap_keys_and_keep_insert_typing_in_one_undo_group() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "foo...bar baz"); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.bind "w", nil + config.bind "ctrl+f", config.core.word_next + config.bind "ctrl+p", config.core.word_previous + config.bind "ctrl+e", config.core.word_end + config.bind "ctrl+a", config.core.big_word_next + config.bind "ctrl+z", config.core.big_word_previous + config.bind "ctrl+t", config.core.big_word_end + config.bind "ctrl+b", config.core.word_previous, mode: config.modes.insert +end +"#, + ); + session.handle(Input::Char('w')); + assert_eq!(session.editor().cursor().column, 0); + for (key, column) in [ + ('f', 3), + ('e', 5), + ('f', 6), + ('p', 3), + ('z', 0), + ('a', 10), + ('t', 12), + ('z', 10), + ('z', 0), + ] { + session.handle(Input::Ctrl(key)); + assert_eq!(session.editor().cursor().column, column, "{key}"); + } + assert!(!session.editor().dirty()); + session.handle(Input::Char('i')); + for ch in "wbeWBE".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Ctrl('b')); + assert_eq!(session.editor().cursor().column, 0); + session.handle(Input::Char('X')); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &["XwbeWBEfoo...bar baz"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["foo...bar baz"]); + assert!(!session.editor().dirty()); +} + +#[test] +fn typed_search_bindings_drive_navigation_and_the_search_prompt_only() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "cat\ncat\ncat"); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.bind "ctrl+f", config.core.search + config.bind "ctrl+n", config.core.search_next + config.bind "ctrl+p", config.core.search_previous + config.bind "ctrl+x", config.core.submit, mode: config.modes.search + config.bind "ctrl+c", config.core.normal, mode: config.modes.search + config.bind "ctrl+h", config.core.backspace, mode: config.modes.search + config.bind "enter", nil, mode: config.modes.search + config.bind "/", nil +end +"#, + ); + session.handle(Input::Char('/')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + session.handle(Input::Ctrl('f')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Search); + for ch in "cat界".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Backspace); + session.handle(Input::Enter); + assert_eq!(session.editor().mode(), crate::editor::Mode::Search); + session.handle(Input::Ctrl('x')); + assert_eq!(session.editor().cursor().row, 0); + session.handle(Input::Ctrl('n')); + assert_eq!(session.editor().cursor().row, 1); + session.handle(Input::Ctrl('n')); + assert_eq!(session.editor().cursor().row, 2); + session.handle(Input::Ctrl('p')); + assert_eq!(session.editor().cursor().row, 1); + session.handle(Input::Ctrl('f')); + session.handle(Input::Char('q')); + session.handle(Input::Ctrl('c')); + session.handle(Input::Ctrl('n')); + assert_eq!(session.editor().cursor().row, 2); + assert!(!session.editor().dirty()); + assert!(!session.editor().should_quit()); +} + +#[test] +fn typed_config_errors_discard_partial_bindings_and_leave_default_editing_available() { + let fixture = Fixture::new(); + for (expression, error) in [ + ("config.core.reod", "NoMethodError"), + ("config.modes.insret", "NoMethodError"), + ("config.leader = 7", "leader key must be a String"), + ("config.leader = nil", "leader key must be a String"), + (r#"config.leader = """#, "leader key must not be empty"), + ( + r#"config.resolve_history_layout = "invalid""#, + "resolve history layout must be columns or base_below", + ), + ( + "config.resolve_history_layout = 7", + "resolve history layout must be a String", + ), + ( + "config.resolve_history_layout = nil", + "resolve history layout must be a String", + ), + ( + r#"config.resolve_history_layout = """#, + "resolve history layout must not be empty", + ), + ( + r#"config.diff_view_mode = "invalid""#, + "diff view mode must be split or unified", + ), + ( + r#"config.diff_split_orientation = "invalid""#, + "diff split orientation must be vertical or horizontal", + ), + ( + r#"config.diff_split_orientation = "left_right""#, + "diff split orientation must be vertical or horizontal", + ), + ( + r#"config.diff_split_orientation = "above_below""#, + "diff split orientation must be vertical or horizontal", + ), + ( + "config.diff_view_mode = 7", + "diff view mode must be a String", + ), + ( + "config.diff_view_mode = nil", + "diff view mode must be a String", + ), + ( + r#"config.diff_view_mode = """#, + "diff view mode must not be empty", + ), + ( + "config.diff_split_orientation = 7", + "diff split orientation must be a String", + ), + ( + "config.diff_split_orientation = nil", + "diff split orientation must be a String", + ), + ( + r#"config.diff_split_orientation = """#, + "diff split orientation must not be empty", + ), + ( + "config.file_explorer_side = 7", + "file explorer side must be a String", + ), + ( + "config.file_explorer_side = nil", + "file explorer side must be a String", + ), + ( + r#"config.file_explorer_side = """#, + "file explorer side must not be empty", + ), + ( + "config.file_picker_view = 7", + "file picker view must be a String", + ), + ( + "config.file_picker_view = nil", + "file picker view must be a String", + ), + ( + r#"config.file_picker_view = """#, + "file picker view must not be empty", + ), + ( + "config.file_explorer_view = 7", + "file explorer view must be a String", + ), + ( + "config.file_explorer_view = nil", + "file explorer view must be a String", + ), + ( + r#"config.file_explorer_view = """#, + "file explorer view must not be empty", + ), + ( + "config.clipboard_mode = 7", + "clipboard mode must be a String", + ), + ( + "config.clipboard_mode = nil", + "clipboard mode must be a String", + ), + ( + r#"config.clipboard_mode = """#, + "clipboard mode must not be empty", + ), + (r#"config.bind "n", "core.insert""#, "TypeError"), + (r#"config.bind "n", nil, mode: "insert""#, "TypeError"), + (r#"config.bind "n", config.modes.insert"#, "TypeError"), + ( + r#"config.bind "n", nil, mode: config.core.insert"#, + "TypeError", + ), + (r#"config.core.redo.name = "core.undo""#, "FrozenError"), + ( + r#"config.core.redo.name.replace("core.undo")"#, + "FrozenError", + ), + (r#"config.modes.insert.name = "normal""#, "FrozenError"), + ( + r#"config.modes.insert.name.replace("normal")"#, + "FrozenError", + ), + ] { + let mut session = fixture.open(&format!( + "Vamp.configure do |config|\n config.leader = \",\"\n config.bind \"i\", nil\n {expression}\nend" + )); + assert!( + session + .editor() + .startup_diagnostic() + .unwrap() + .lines + .iter() + .any(|line| line.contains(error)), + "{expression}" + ); + session.handle(Input::Enter); + session.handle(Input::Char('i')); + session.handle(Input::Char('界')); + session.handle(Input::Escape); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert_eq!(session.editor().lines(), &["界"], "{expression}"); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &[String::new()], "{expression}"); + session.handle(Input::Char(' ')); + assert_eq!( + session.editor().sequence_pending(), + Some("Space"), + "{expression}" + ); + session.handle(Input::Char(' ')); + assert!(session.editor().palette().is_some(), "{expression}"); + session.handle(Input::Escape); + } +} + +#[test] +fn rejected_config_diagnostic_persists_until_dismissed_and_default_editing_survives() { + for (config, reason, dismiss) in [ + ("raise 'broken config'", "broken config", Input::Enter), + ( + "Vamp.configure { |config| config.lsp['unsupported-language'] = true }", + "unsupported-language", + Input::Escape, + ), + ] { + let fixture = Fixture::new(); + let mut session = fixture.open(config); + let diagnostic = session + .editor() + .startup_diagnostic() + .expect("config failure must be visible"); + let text = diagnostic.lines.join("\n"); + assert!(text.contains(&fixture.0.join("config.rb").display().to_string())); + assert!(text.contains(reason)); + session.editor.set_message("unrelated background message"); + session.poll(); + for input in [ + Input::Char('i'), + Input::Paste("accidental edit".into()), + Input::Ctrl('p'), + ] { + session.handle(input); + } + assert_eq!( + session + .editor() + .startup_diagnostic() + .unwrap() + .lines + .join("\n"), + text + ); + assert_eq!(session.editor().mode(), Mode::Normal); + assert_eq!(session.editor().lines(), &[""]); + session.handle(dismiss); + assert!(session.editor().startup_diagnostic().is_none()); + session.handle(Input::Char('i')); + session.handle(Input::Char('x')); + session.handle(Input::Escape); + session.execute("wq"); + assert!(session.editor().should_quit()); + assert_eq!( + fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), + "x" + ); + } +} + +fn drain_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) { + // Process-I/O timeout, not a date-dependent assertion. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + session.poll(); + if ready(session) { + return; + } + assert!( + std::time::Instant::now() < deadline, + "plugin did not settle: {}", + session.editor().message() + ); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +fn barrier(session: &mut EditorSession, message: &str) { + session.execute(&format!("fixture_barrier {message}")); + drain_until(session, |session| session.editor().message() == message); +} + +fn diagnostic_effect(id: usize, revision: u64, message: &str) -> serde_json::Value { + serde_json::json!({ + "type": "diagnostics", "buffer_id": id, "revision": revision, + "items": [{"start": {"row": 0, "column": 0}, "end": {"row": 0, "column": 1}, + "severity": "warning", "message": message}] + }) +} + +fn publish(session: &mut EditorSession, effects: Vec) { + session.execute(&format!( + "fixture_publish {}", + serde_json::json!({"effects": effects}) + )); +} + +fn hold(session: &mut EditorSession, gate: &Path, effects: Vec) { + session.execute(&format!( + "fixture_hold {}", + serde_json::json!({"gate": gate, "effects": effects}) + )); +} + +fn release(session: &mut EditorSession, gate: &Path) { + fs::write(gate, "").unwrap(); + let delivered = format!("delivered {}", gate.display()); + drain_until(session, |session| session.editor().message() == delivered); +} + +#[test] +fn startup_chrome_installs_named_values_without_editing_the_document() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "source"); + let session = fixture.open( + r#" +Vamp.configure do |config| + config.bar.top = {left: [:mode, :language], right: [:battery]} + config.bar.bottom = {left: [:tabs], center: [:workspace]} + config.labels = {battery: "bat 38%", workspace: "tools\n"} +end +"#, + ); + assert_eq!(session.editor.label("battery"), "bat 38%"); + assert_eq!(session.editor.label("workspace"), "tools�"); + assert_eq!(session.editor.top_bar_config().left, ["mode", "language"]); + assert_eq!(session.editor.top_bar_config().right, ["battery"]); + assert_eq!(session.editor.bottom_bar_config().left, ["tabs"]); + assert_eq!(session.editor.bottom_bar_config().center, ["workspace"]); + assert_eq!(session.editor.lines(), ["source"]); + assert_eq!(session.editor.text_revision(), 0); + assert!(!session.editor.dirty()); +} + +#[test] +fn asynchronous_result_labels_outlive_the_source_without_applying_stale_edits() { + let fixture = Fixture::new(); + let target = fixture.file("other.txt", ""); + let mut session = EditorSession::open(None, vec![], None).unwrap(); + let source = InvocationSource { + buffer_id: session.editor.buffer_id(), + pane_id: session.editor.pane_id(), + revision: session.editor.text_revision(), + }; + session.editor.execute("tab.new"); + session + .editor + .execute(&format!("edit {}", target.display())); + assert_ne!(session.editor.buffer_id(), source.buffer_id); + session.editor.handle(Input::Char('i')); + let message = session.editor.message().to_owned(); + for (id, effects) in [ + ( + 1, + vec![ + Effect::Insert("stale".into()), + Effect::Message("stale message".into()), + Effect::Label { + name: "battery".into(), + text: "bat 38%".into(), + }, + ], + ), + ( + 2, + vec![ + Effect::Label { + name: "battery".into(), + text: "must not leak".into(), + }, + Effect::Label { + name: "bad-name".into(), + text: "invalid".into(), + }, + ], + ), + ] { + session + .requests + .entry("fixture".into()) + .or_default() + .pending + .insert(id, source); + let result = session.apply_response("fixture", id, effects); + if id == 1 { + assert!(result.unwrap()); + } else { + assert!(result.is_err()); + } + assert_eq!(session.editor.label("battery"), "bat 38%"); + assert_eq!(session.editor.lines(), [""]); + assert_eq!(session.editor.message(), message); + assert_eq!(session.editor.mode(), Mode::Insert); + assert_eq!(session.editor.text_revision(), 0); + assert!(!session.editor.dirty()); + } + session.editor.handle(Input::Char('x')); + session.editor.handle(Input::Escape); + session.editor.handle(Input::Char('u')); + assert_eq!(session.editor.lines(), [""]); + assert_eq!(session.editor.label("battery"), "bat 38%"); +} + +#[test] +fn named_labels_survive_tab_switches_and_remain_atomic_in_readonly_views() { + let fixture = Fixture::new(); + let mut session = fixture.asynchronous(None); + publish( + &mut session, + vec![serde_json::json!({ + "type": "label", "name": "battery", "text": "bat 37%" + })], + ); + drain_until(&mut session, |session| { + session.editor().label("battery") == "bat 37%" + }); + let gate = fixture.0.join("label.gate"); + hold( + &mut session, + &gate, + vec![serde_json::json!({ + "type": "label", "name": "battery", "text": "bat 38%" + })], + ); + barrier(&mut session, "label request received"); + session.execute("tab.new"); + release(&mut session, &gate); + assert_eq!(session.editor().label("battery"), "bat 38%"); + session.execute("tab.previous"); + assert_eq!(session.editor().label("battery"), "bat 38%"); + let update = |text: &str| PluginUpdate { + request_id: None, + effects: vec![UpdateEffect::Label { + name: "battery".into(), + text: text.into(), + }], + }; + assert!( + !session + .apply_update("generic_fixture", update("bat 38%")) + .unwrap() + ); + let rejected = PluginUpdate { + request_id: None, + effects: vec![ + UpdateEffect::Label { + name: "battery".into(), + text: "must not leak".into(), + }, + UpdateEffect::SemanticTokens { + buffer_id: session.editor().buffer_id(), + revision: session.editor().text_revision(), + tokens: None, + }, + ], + }; + assert!(session.apply_update("generic_fixture", rejected).is_err()); + assert_eq!(session.editor().label("battery"), "bat 38%"); + let message = session.editor.message().to_owned(); + for effects in [ + vec![ + UpdateEffect::Message { + text: "must not leak".into(), + }, + UpdateEffect::Label { + name: "battery".into(), + text: "changed".into(), + }, + UpdateEffect::Label { + name: "bad-name".into(), + text: "invalid".into(), + }, + ], + vec![ + UpdateEffect::Label { + name: "battery".into(), + text: "x".repeat(2 * 1024 * 1024), + }, + UpdateEffect::Label { + name: "workspace".into(), + text: "x".repeat(2 * 1024 * 1024), + }, + ], + ] { + assert!( + session + .apply_update( + "generic_fixture", + PluginUpdate { + request_id: None, + effects + } + ) + .is_err() + ); + assert_eq!(session.editor.label("battery"), "bat 38%"); + assert_eq!(session.editor.label("workspace"), ""); + assert_eq!(session.editor.message(), message); + } + session.editor.execute("diff"); + assert!(session.editor.mutation_blocked()); + assert!( + session + .apply_update("generic_fixture", update("bat\t39%\n")) + .unwrap() + ); + assert_eq!(session.editor().label("battery"), "bat�39%�"); + assert_eq!(session.editor().lines(), [""]); + assert!(session.apply_update("generic_fixture", update("")).unwrap()); + assert_eq!(session.editor().label("battery"), ""); +} + +#[test] +fn idle_poll_refreshes_clean_and_parked_files_on_the_interval_but_keeps_dirty_text() { + let fixture = Fixture::new(); + let active = fixture.file("buffer.txt", "old"); + let parked = fixture.file("parked.txt", "parked"); + let mut session = fixture.open(""); + session.execute(&format!("edit {}", parked.display())); + session.execute("buffer 1"); + let now = session.last_file_refresh; + let revision = session.editor().text_revision(); + fs::write(&active, "external replacement").unwrap(); + fs::write(&parked, "external parked replacement").unwrap(); + + session.poll_at(now + Duration::from_millis(249)); + assert_eq!(session.editor().lines(), &["old"]); + session.dispatch(Invocation { + plugin: "core".into(), + command: "lsp.definition".into(), + args: String::new(), + }); + assert!(session.requests["core"].latest.is_some()); + assert!(session.poll_at(now + Duration::from_millis(250))); + assert!(session.requests["core"].latest.is_none()); + assert_eq!(session.editor().lines(), &["external replacement"]); + assert!(session.editor().text_revision() > revision); + let documents: Vec<_> = session.editor().documents().collect(); + assert_eq!(documents[1].lines, ["external parked replacement"]); + + session.handle(Input::Char('x')); + let dirty = session.editor().lines().to_vec(); + let revision = session.editor().text_revision(); + fs::write(&active, "second external replacement").unwrap(); + session.poll_at(now + Duration::from_millis(500)); + assert_eq!(session.editor().lines(), dirty.as_slice()); + assert_eq!(session.editor().text_revision(), revision); + assert_eq!( + fs::read_to_string(&active).unwrap(), + "second external replacement" + ); +} + +#[test] +fn clean_external_reload_notifies_async_plugin_with_change_without_save() { + let fixture = Fixture::new(); + let file = fixture.file("buffer.txt", "old"); + let mut session = fixture.asynchronous(Some(file.clone())); + barrier(&mut session, "opened"); + let now = session.last_file_refresh; + let revision = session.editor().text_revision(); + fs::write(&file, "updated external text").unwrap(); + assert!(session.poll_at(now + Duration::from_millis(250))); + assert_eq!(session.editor().lines(), &["updated external text"]); + let updated_revision = session.editor().text_revision(); + assert!(updated_revision > revision); + assert_eq!( + session.documents[&session.editor().buffer_id()] + .snapshot + .revision, + updated_revision + ); + barrier(&mut session, "reload delivered"); + + let events = fixture.events(); + let changes: Vec<_> = events + .iter() + .filter(|event| event["event"] == "change") + .collect(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0]["document"]["text"], "updated external text"); + assert_eq!(changes[0]["document"]["revision"], updated_revision); + assert!(!events.iter().any(|event| event["event"] == "save")); +} + +#[test] +fn async_documents_include_unnamed_hidden_versions_real_saves_renames_and_closes() { + let fixture = Fixture::new(); + let mut session = fixture.asynchronous(None); + barrier(&mut session, "initial"); + let initial = fixture.events(); + assert_eq!(initial[0]["event"], "open"); + assert_eq!(initial[0]["document"]["id"], 1); + assert_eq!(initial[0]["document"]["revision"], 0); + assert_eq!(initial[0]["document"]["path"], serde_json::Value::Null); + assert_eq!(initial[0]["document"]["text"], ""); + assert_eq!( + initial[0]["document"]["root"], + session.editor().project_root().to_string_lossy().as_ref() + ); + session.handle(Input::Char('i')); + for input in [ + Input::Char('😀'), + Input::Char('e'), + Input::Char('\u{301}'), + Input::Enter, + ] { + session.handle(input); + } + session.handle(Input::Escape); + let revision = session.editor().text_revision(); + session.handle(Input::Char('h')); + session.handle(Input::Char('l')); + let renamed = fixture.0.join("saved unicode 😀.txt"); + session.execute(&format!("w {}", renamed.display())); + session.execute("w"); + session.execute(&format!("w {}", fixture.0.join("missing/failed").display())); + barrier(&mut session, "saved"); + assert_eq!(session.editor().text_revision(), revision); + assert_eq!( + session.editor().documents().next().unwrap().save_revision, + 2 + ); + assert_eq!(fs::read_to_string(&renamed).unwrap(), "😀e\u{301}\n"); + let events = fixture.events(); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "change") + .count(), + revision as usize + ); + let kinds: Vec<_> = events + .iter() + .filter_map(|event| event["event"].as_str()) + .collect(); + assert_eq!(&kinds[kinds.len() - 4..], ["close", "open", "save", "save"]); + let reopened = events + .iter() + .find(|event| event["event"] == "open" && !event["document"]["path"].is_null()) + .unwrap(); + assert_eq!(reopened["document"]["revision"], revision); + assert_eq!(reopened["document"]["text"], "😀e\u{301}\n"); + let target = fixture.file("target.txt", "target"); + session.execute(&format!("edit {}", target.display())); + session.handle(Input::Char('x')); + session.execute("buffer 1"); + let documents: Vec<_> = session.editor().documents().collect(); + assert_eq!( + documents + .iter() + .map(|document| document.id) + .collect::>(), + [1, 2] + ); + assert_eq!(documents[1].lines, ["arget"]); + assert_eq!(documents[1].revision, 1); + assert_eq!(documents[1].save_revision, 0); + session.execute("bdelete! 2"); + barrier(&mut session, "closed"); + let events = fixture.events(); + let closed = events + .iter() + .find(|event| event["event"] == "close" && event["document"]["id"] == 2) + .unwrap(); + assert_eq!(closed["document"]["text"], "arget"); + assert_eq!(closed["document"]["revision"], 1); + session.execute("q!"); + barrier(&mut session, "quit flushed"); + assert!(session.editor().should_quit()); + let events = fixture.events(); + let final_close = events + .iter() + .rev() + .find(|event| event["event"] == "close") + .unwrap(); + assert_eq!(final_close["document"]["id"], 1); + assert_eq!( + final_close["document"]["path"], + renamed.to_string_lossy().as_ref() + ); +} + +#[test] +fn async_annotations_and_messages_preserve_insert_group_and_ignore_stale_closed_sources() { + let fixture = Fixture::new(); + let file = fixture.file("buffer.txt", "abc"); + let mut session = fixture.asynchronous(Some(file)); + let gate = fixture.0.join("annotations-ready"); + hold(&mut session, &gate, vec![diagnostic_effect(1, 1, "live")]); + session.handle(Input::Char('i')); + session.handle(Input::Char('x')); + release(&mut session, &gate); + assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); + assert_eq!( + session + .editor() + .diagnostic_at_cursor() + .map(|diagnostic| diagnostic.message.as_str()), + None + ); + assert_eq!(session.editor().diagnostics().count(), 0); + session.handle(Input::Char('y')); + assert_eq!(session.editor().diagnostics().count(), 0); + session.handle(Input::Escape); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), ["abc"]); + let current = session.editor().text_revision(); + let mut stale = diagnostic_effect(1, 0, "stale"); + stale["items"][0]["end"]["row"] = serde_json::json!(999); + publish( + &mut session, + vec![ + stale, + diagnostic_effect(999, current, "closed"), + serde_json::json!({"type":"message","text":"stale checked"}), + ], + ); + drain_until(&mut session, |session| { + session.editor().message() == "stale checked" + }); + assert_eq!(session.editor().diagnostics().count(), 0); + assert!(session.plugins.contains_key("generic_fixture")); + let target = fixture.file("closed.txt", "closed"); + session.execute(&format!("edit {}", target.display())); + let gate = fixture.0.join("closed-ready"); + hold( + &mut session, + &gate, + vec![diagnostic_effect(2, 0, "late close")], + ); + session.execute("bdelete 2"); + release(&mut session, &gate); + assert_eq!(session.editor().buffer_id(), 1); + assert_eq!(session.editor().diagnostics().count(), 0); + assert!(session.plugins.contains_key("generic_fixture")); +} + +#[test] +fn invalid_annotation_batches_retire_owner_and_clear_hidden_annotations_only() { + let fixture = Fixture::new(); + let file = fixture.file("buffer.txt", "source"); + let target = fixture.file("target.txt", "target"); + let mut session = fixture.asynchronous(Some(file)); + session.execute(&format!("edit {}", target.display())); + session.execute("buffer 1"); + publish( + &mut session, + vec![ + diagnostic_effect(1, 0, "source"), + diagnostic_effect(2, 0, "hidden"), + ], + ); + drain_until(&mut session, |session| { + session.editor().diagnostics().count() == 1 + }); + let other = crate::plugin::Diagnostic { + start: TextPosition { row: 0, column: 0 }, + end: TextPosition { row: 0, column: 1 }, + severity: crate::plugin::DiagnosticSeverity::Hint, + message: "other owner".into(), + }; + session + .editor + .replace_diagnostics("other", 1, 0, vec![other]) + .unwrap(); + let mut invalid = diagnostic_effect(1, 0, "bad range"); + invalid["items"][0]["end"]["column"] = serde_json::json!(999); + publish( + &mut session, + vec![diagnostic_effect(2, 0, "must not commit"), invalid], + ); + drain_until(&mut session, |session| { + !session.plugins.contains_key("generic_fixture") + }); + assert!( + session + .editor() + .message() + .contains("Diagnostic position is outside") + ); + assert_eq!(session.editor().diagnostics().count(), 1); + assert_eq!( + session.editor().diagnostic_at_cursor().unwrap().message, + "other owner" + ); + session.execute("buffer 2"); + assert_eq!(session.editor().diagnostics().count(), 0); + session.execute("fixture_publish {}"); + assert!(session.editor().message().contains("Unknown command")); + session.handle(Input::Char('x')); + assert_eq!(session.editor().lines(), ["arget"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), ["target"]); +} + +fn navigation_effect(path: &Path) -> serde_json::Value { + serde_json::json!({"type":"navigate", "path":path, "row":0, "column":1}) +} + +#[test] +fn async_navigation_reuses_dirty_target_and_reports_open_failure_without_retiring() { + let fixture = Fixture::new(); + let source = fixture.file("buffer.txt", "source"); + let target = fixture.file("dirty 😀 target.txt", "a😀e\u{301}"); + let mut session = fixture.asynchronous(Some(source)); + session.execute(&format!("edit {}", target.display())); + session.handle(Input::Char('i')); + session.handle(Input::Char('!')); + session.handle(Input::Escape); + session.execute("buffer 1"); + publish(&mut session, vec![navigation_effect(&target)]); + drain_until(&mut session, |session| session.editor().buffer_id() == 2); + assert_eq!(session.editor().lines(), ["!a😀e\u{301}"]); + assert_eq!( + session.editor().cursor(), + crate::editor::Cursor { row: 0, column: 1 } + ); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert_eq!(session.editor().buffer_count(), 2); + assert_eq!(fs::read_to_string(&target).unwrap(), "a😀e\u{301}"); + let gate = fixture.0.join("missing-ready"); + hold( + &mut session, + &gate, + vec![navigation_effect(&fixture.0.join("missing"))], + ); + fs::write(&gate, "").unwrap(); + drain_until(&mut session, |session| { + session + .requests + .get("generic_fixture") + .is_some_and(|requests| requests.pending.is_empty()) + && session.editor().message().starts_with("delivered ") + }); + assert_eq!(session.editor().buffer_id(), 2); + assert_eq!(session.editor().lines(), ["!a😀e\u{301}"]); + assert!(session.plugins.contains_key("generic_fixture")); + // A standalone failure message remains visible when no later plugin message overwrites it. + publish( + &mut session, + vec![navigation_effect(&fixture.0.join("missing"))], + ); + drain_until(&mut session, |session| { + session.editor().message().starts_with("Navigation failed:") + }); + assert_eq!(session.editor().buffer_id(), 2); + assert_eq!(session.editor().buffer_count(), 2); + assert!(session.plugins.contains_key("generic_fixture")); +} + +#[test] +fn async_navigation_drops_superseded_changed_or_inactive_source_replies() { + for guard in ["superseded", "changed", "inactive", "closed"] { + let fixture = Fixture::new(); + let source = fixture.file("buffer.txt", "source"); + let target = fixture.file("target.txt", "target"); + let mut session = fixture.asynchronous(Some(source)); + let gate = fixture.0.join("navigation-ready"); + hold(&mut session, &gate, vec![navigation_effect(&target)]); + match guard { + "superseded" => barrier(&mut session, "new request"), + "changed" => { + session.handle(Input::Char('i')); + session.handle(Input::Char('x')); + } + "inactive" => { + let other = fixture.file("other.txt", "other"); + session.execute(&format!("edit {}", other.display())); + } + "closed" => session.execute("bdelete 1"), + _ => unreachable!(), + } + let active = session.editor().buffer_id(); + let text = session.editor().context().text; + release(&mut session, &gate); + assert_eq!(session.editor().buffer_id(), active, "{guard}"); + assert_eq!(session.editor().context().text, text, "{guard}"); + assert!(session.plugins.contains_key("generic_fixture"), "{guard}"); + assert!( + !session + .editor() + .buffers() + .any(|buffer| buffer.path == Some(target.as_path())), + "{guard}" + ); + let invokes: Vec<_> = fixture + .events() + .into_iter() + .filter(|event| event["invoke"] == "fixture_hold") + .collect(); + assert_eq!(invokes.len(), 1); + assert_eq!(invokes[0]["buffer_id"], 1); + assert_eq!(invokes[0]["revision"], 0); + assert_eq!(invokes[0]["row"], 1); + assert_eq!(invokes[0]["column"], 1); + } +} + +#[test] +fn stale_async_text_response_and_dead_host_never_break_native_typing() { + let fixture = Fixture::new(); + let file = fixture.file("buffer.txt", "source"); + let mut session = fixture.asynchronous(Some(file)); + session.execute("fixture_replace BAD"); + session.handle(Input::Char('i')); + session.handle(Input::Char('x')); + drain_until(&mut session, |session| { + session + .requests + .get("generic_fixture") + .is_some_and(|requests| requests.pending.is_empty()) + }); + assert_eq!(session.editor().lines(), ["xsource"]); + assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); + session.handle(Input::Char('y')); + session.handle(Input::Escape); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), ["source"]); + let revision = session.editor().text_revision(); + publish( + &mut session, + vec![diagnostic_effect(1, revision, "before crash")], + ); + drain_until(&mut session, |session| { + session.editor().diagnostics().count() == 1 + }); + session.execute("fixture_crash"); + session.handle(Input::Char('i')); + session.handle(Input::Char('z')); + drain_until(&mut session, |session| { + !session.plugins.contains_key("generic_fixture") + }); + assert_eq!(session.editor().diagnostics().count(), 0); + assert_eq!(session.editor().mode(), crate::editor::Mode::Insert); + session.handle(Input::Char('q')); + session.handle(Input::Escape); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), ["source"]); + assert!(!session.editor().should_quit()); + session.execute("plugins"); + assert!(session.editor().message().contains("core [core]")); +} + +#[test] +fn async_navigation_invokes_once_at_primary_with_multiple_carets() { + let fixture = Fixture::new(); + let source = fixture.file("buffer.txt", "abc\nabc"); + let target = fixture.file("target.txt", "target"); + let mut session = fixture.asynchronous(Some(source)); + session.handle(Input::Char('l')); + session.handle(Input::Ctrl('e')); + assert_eq!(session.editor().selection_count(), 2); + assert_eq!( + session.editor().cursor(), + crate::editor::Cursor { row: 1, column: 1 } + ); + let gate = fixture.0.join("primary-ready"); + hold(&mut session, &gate, vec![navigation_effect(&target)]); + release(&mut session, &gate); + assert_eq!(session.editor().path(), Some(target.as_path())); + assert_eq!(session.editor().selection_count(), 1); + let invokes: Vec<_> = fixture + .events() + .into_iter() + .filter(|event| event["invoke"] == "fixture_hold") + .collect(); + assert_eq!(invokes.len(), 1); + assert_eq!(invokes[0]["row"], 2); + assert_eq!(invokes[0]["column"], 2); + assert_eq!(invokes[0]["revision"], 0); +} + +#[test] +fn pending_plugin_edits_do_not_modify_diff_buffers_or_disable_the_plugin() { + let fixture = Fixture::new(); + let source = fixture.file("buffer.txt", "original"); + let started = fixture.0.join("started"); + let release = fixture.0.join("release"); + fixture.file( + "delayed.rb", + r#"Vamp.plugin "delayed", requires: ["text"], async: true +Vamp.command "slow" do |editor, _| + File.write(Vamp.options.fetch("started"), "ready") + sleep 0.001 until File.exist?(Vamp.options.fetch("release")) + editor.insert "late edit" +end +Vamp.command "ping" do |editor, _| + editor.message "still alive" +end +"#, + ); + let config = fixture.file( + "config.rb", + &format!( + "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.plugin 'delayed.rb', options: {{started: {}, release: {}}}\nend\n", + serde_json::to_string(&started.to_string_lossy()).unwrap(), + serde_json::to_string(&release.to_string_lossy()).unwrap(), + ), + ); + let mut session = + EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap(); + session.execute("slow"); + drain_until(&mut session, |_| started.exists()); + session.execute("diff"); + assert!(session.editor().diff().is_some()); + fs::write(&release, "release").unwrap(); + // Wait for transport consumption, then assert the consumer-visible result. + drain_until(&mut session, |session| { + session + .requests + .get("delayed") + .is_none_or(|requests| requests.pending.is_empty()) + }); + assert_eq!(session.editor().context().text, "original"); + assert_eq!(fs::read_to_string(&source).unwrap(), "original"); + session.execute("diff.close"); + session.execute("ping"); + drain_until(&mut session, |session| { + session.editor().message() == "still alive" + }); + assert_eq!(session.editor().context().text, "original"); +} + +#[test] +fn async_responses_edit_only_current_bound_working_after_and_refresh_projection() { + use crate::diff::{ + Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, RevisionTarget, + }; + let fixture = Fixture::new(); + fixture.file("buffer.txt", "base"); + let mut session = fixture.open(""); + session.editor.execute("diff"); + let (id, _) = session.editor.take_diff_request().unwrap(); + let revision = |target, label: &str| Revision { + target, + label: label.into(), + description: String::new(), + category: Category::Commits, + }; + session.editor.apply_diff_result( + id, + Ok(DiffOutcome::Catalog(Catalog { + kind: RepoKind::Git, + notice: None, + entries: vec![ + revision(RevisionTarget::Commit("base".into()), "base"), + revision(RevisionTarget::Working, "Working"), + ], + })), + ); + for input in [Input::Down, Input::Enter, Input::Enter] { + session.editor.handle(input); + } + let (id, _) = session.editor.take_diff_request().unwrap(); + session.editor.apply_diff_result( + id, + Ok(DiffOutcome::Comparison(Comparison { + revisions: ["base".into(), "working:snapshot".into()], + root: fixture.0.canonicalize().unwrap(), + before: "base".into(), + after: "Working".into(), + editable_paths: [PathBuf::from("buffer.txt")].into_iter().collect(), + files: vec![ChangedFile { + path: PathBuf::from("buffer.txt"), + old_path: None, + status: FileStatus::Modified, + loaded: true, + before: vec!["base".into()], + after: vec!["base".into()], + before_eof: false, + after_eof: false, + hunks: Vec::new(), + notice: None, + }], + })), + ); + session.editor.handle(Input::BackTab); + let source = InvocationSource { + buffer_id: session.editor.buffer_id(), + pane_id: session.editor.pane_id(), + revision: session.editor.text_revision(), + }; + session + .requests + .entry("fixture".into()) + .or_default() + .pending + .insert(1, source); + assert!( + session + .apply_response("fixture", 1, vec![Effect::Insert("accepted".into())]) + .unwrap() + ); + assert_eq!(session.editor.lines(), ["acceptedbase"]); + let diff = session.editor.diff().unwrap(); + assert_eq!(diff.live_revision, Some(session.editor.text_revision())); + assert_eq!( + diff.comparison.as_ref().unwrap().files[0].after, + session.editor.lines() + ); + + session + .requests + .get_mut("fixture") + .unwrap() + .pending + .insert(2, source); + assert!( + !session + .apply_response("fixture", 2, vec![Effect::Insert("stale".into())]) + .unwrap() + ); + session.editor.scroll_diff(Focus::Before, false); + let readonly = InvocationSource { + buffer_id: session.editor.buffer_id(), + pane_id: session.editor.pane_id(), + revision: session.editor.text_revision(), + }; + session + .requests + .get_mut("fixture") + .unwrap() + .pending + .insert(3, readonly); + assert!( + !session + .apply_response("fixture", 3, vec![Effect::Insert("readonly".into())]) + .unwrap() + ); + assert_eq!(session.editor.lines(), ["base"]); + assert!( + session + .editor + .documents() + .any(|document| document.lines == ["acceptedbase"]) + ); + session + .requests + .get_mut("fixture") + .unwrap() + .pending + .insert(4, readonly); + assert!( + session + .apply_response("fixture", 4, vec![Effect::Message("allowed".into())]) + .unwrap() + ); + assert_eq!(session.editor.message(), "allowed"); + session.editor.execute("diff.close"); + assert!(session.editor.dirty()); +} diff --git a/tests/unit/snippet.rs b/tests/unit/snippet.rs new file mode 100644 index 0000000..df8a98c --- /dev/null +++ b/tests/unit/snippet.rs @@ -0,0 +1,119 @@ +use super::*; + +#[test] +fn grammar_expands_nested_mirrors_choices_variables_and_normalized_offsets() { + let (text, snippet) = expand( + "call($1, ${1:λ${2:x}}, ${3|a\\,b,c|}, ${UNKNOWN:${TM_LINE_NUMBER}})\r\n$0\\$", + |name| (name == "TM_LINE_NUMBER").then(|| "4".to_owned()), + ) + .unwrap(); + assert_eq!(text, "call(λx, λx, a,b, 4)\n$"); + assert_eq!( + snippet.tabstops, + vec![ + SnippetTabstop { + index: 1, + ranges: vec![5..8, 10..13], + ancestors: vec![vec![], vec![]] + }, + SnippetTabstop { + index: 2, + ranges: vec![7..8, 12..13], + ancestors: vec![vec![1], vec![1]] + }, + SnippetTabstop { + index: 3, + ranges: vec![15..18], + ancestors: vec![vec![]] + }, + SnippetTabstop { + index: 0, + ranges: vec![23..23], + ancestors: vec![vec![]] + }, + ] + ); + let (text, snippet) = expand("call($1, ${2})", |_| None).unwrap(); + assert_eq!(text, "call(, )"); + assert_eq!( + snippet.tabstops, + vec![ + SnippetTabstop { + index: 1, + ranges: vec![5..5], + ancestors: vec![vec![]] + }, + SnippetTabstop { + index: 2, + ranges: vec![7..7], + ancestors: vec![vec![]] + }, + SnippetTabstop { + index: 0, + ranges: vec![8..8], + ancestors: vec![vec![]] + }, + ] + ); + assert_eq!( + expand("${KNOWN:${1:hidden}}$1", |name| { + (name == "KNOWN").then(|| "shown".to_owned()) + }) + .unwrap() + .0, + "shown" + ); + let (text, snippet) = expand("call(${CTX})$1", |name| { + (name == "CTX").then(|| "a\r\nb\rc".to_owned()) + }) + .unwrap(); + assert_eq!(text, "call(a\nb\nc)"); + assert_eq!(snippet.tabstops[0].ranges, [11..11]); + assert_eq!( + expand("${1|a\\|b,c|}\\}\\\\", |_| None).unwrap().0, + "a|b}\\" + ); + for input in ["${1", "${1:$1}", "${1:a}${1:b}$1", "${1:e}\u{301}"] { + if input == "${1:a}${1:b}$1" { + assert_eq!(expand(input, |_| None).unwrap().0, "aaa"); + } else { + assert_eq!(expand(input, |_| None), Err(Error::Invalid)); + } + } + let (text, snippet) = expand("${1}$1", |_| None).unwrap(); + assert!(text.is_empty()); + assert_eq!( + snippet.tabstops[0], + SnippetTabstop { + index: 1, + ranges: vec![0..0, 0..0], + ancestors: vec![vec![], vec![]], + } + ); + for input in [ + "$UNKNOWN", + "${TM_FILENAME/(.*)/$1/}", + "${1/a/b/}", + "${1:a$0b}", + "$0$0", + "${0:arg}", + "${0:$1}", + ] { + assert_eq!(expand(input, |_| None), Err(Error::Unsupported)); + } + assert_eq!( + expand(&"$1".repeat(MAX_STOPS + 1), |_| None), + Err(Error::Limit) + ); + assert_eq!( + expand( + &format!( + "{}x{}", + "${1:".repeat(MAX_DEPTH + 1), + "}".repeat(MAX_DEPTH + 1) + ), + |_| None + ), + Err(Error::Limit) + ); +} diff --git a/tests/unit/syntax.rs b/tests/unit/syntax.rs new file mode 100644 index 0000000..eadd50c --- /dev/null +++ b/tests/unit/syntax.rs @@ -0,0 +1,1125 @@ +use super::*; + +fn lines(source: &str) -> Vec { + source.split('\n').map(str::to_owned).collect() +} + +#[test] +fn scopes_follow_real_declarations_and_each_sources_own_tree() { + let original = + "impl State {\n fn old() {\n call();\n }\n}\n\nfn next() {\n call();\n}\n"; + let before = cache(original, "old.rs"); + let mut live = cache(&original.replace("old()", "renamed()"), "new.rs"); + assert_eq!( + before.scope_at(2), + Some(ScopeContext { + start_row: 1, + end_row: 4, + name: Point::new(1, 7), + label: "impl State › old", + }) + ); + assert_eq!(before.scope_at(3).unwrap().label, "impl State › old"); + assert_eq!(before.scope_at(4).unwrap().label, "impl State"); + assert_eq!(before.scope_at(5), None); + assert_eq!( + before.scope_at(6), + Some(ScopeContext { + start_row: 6, + end_row: 9, + name: Point::new(6, 3), + label: "next", + }) + ); + assert_eq!(before.scope_at(9), None); + assert_eq!(before.scope_at(usize::MAX), None); + let changed = lines(&original.replace("old()", "updated()")); + live.update_visible(&changed, Some(Path::new("new.rs")), 1, &[2..3]); + assert_eq!(live.scope_at(2).unwrap().label, "impl State › updated"); + assert_eq!(before.scope_at(2).unwrap().label, "impl State › old"); + let counts = live.parse_counts(); + live.update_visible(&changed, Some(Path::new("new.rs")), 2, &[6..9]); + assert_eq!(live.parse_counts(), counts); + live.update_visible(&changed, Some(Path::new("renamed.txt")), 2, &[0..1]); + assert_eq!(live.scope_at(2), None); + assert_eq!(before.scope_at(2).unwrap().label, "impl State › old"); + let incomplete = cache("impl State {\n fn run() {\n let = ;\n }\n}", "a.rs"); + assert_eq!(incomplete.scope_at(2).unwrap().label, "impl State › run"); + + let python = cache( + "class State:\n @decorator\n def run(self):\n work()\n\n def next(self):\n work()\n", + "a.py", + ); + assert_eq!( + python.scope_at(1), + Some(ScopeContext { + start_row: 1, + end_row: 4, + name: Point::new(2, 8), + label: "State › run", + }) + ); + assert_eq!(python.scope_at(4).unwrap().label, "State"); + assert_eq!(python.scope_at(5).unwrap().label, "State › next"); + assert_eq!(python.scope_at(7), None); + + for (path, source, expected) in [ + ( + "a.rs", + "trait State {\n fn run(&self) {\n work();\n }\n}", + Some("State › run"), + ), + ( + "a.rb", + "class State\n def run\n work\n end\nend", + Some("State › run"), + ), + ( + "a.js", + "function outer() {\n function inner() {\n work();\n }\n}", + Some("outer › inner"), + ), + ( + "a.ts", + "class State {\n run = () => {\n work();\n };\n}", + Some("State › run"), + ), + ( + "a.tsx", + "const View = () => {\n work();\n return
;\n};", + Some("View"), + ), + ( + "a.js", + "const handlers = { run: () => {\n work();\n} };", + Some("run"), + ), + ( + "a.js", + "State.run = () => {\n work();\n};", + Some("State.run"), + ), + ( + "a.go", + "package main\nfunc (s *State) run() {\n work()\n}", + Some("*State › run"), + ), + ( + "a.go", + "package main\nfunc run() {\n work()\n}", + Some("run"), + ), + ( + "a.c", + "int run(void) {\n work();\n return 7;\n}", + Some("run"), + ), + ( + "a.cpp", + "class State {\n void run() {\n work();\n }\n};", + Some("State › run"), + ), + ( + "a.cpp", + "void State::run() {\n work();\n}", + Some("State::run"), + ), + ("a.sh", "run() {\n work\n}", Some("run")), + ( + "a.zig", + "const State = struct {\n pub fn run() void {\n work();\n }\n};", + Some("State › run"), + ), + ("a.md", "```rust\nfn run() {\n work();\n}\n```", Some("run")), + ( + "a.html", + "", + Some("run"), + ), + ("a.js", "use(() => {\n work();\n});", None), + ( + "a.js", + "const handlers = { [key]: () => {\n work();\n} };", + None, + ), + ("a.txt", "fn run() {\n work();\n}", None), + ("a.unknown", "fn run() {\n work();\n}", None), + ("a.json", "{\"work\": \"function run() {}\"}", None), + ("a.yaml", "work: function run() {}", None), + ] { + let syntax = cache(source, path); + let row = source[..source.find("work").unwrap()] + .bytes() + .filter(|byte| *byte == b'\n') + .count(); + assert_eq!( + syntax.scope_at(row).map(|scope| scope.label), + expected, + "{path}: {source}" + ); + if let Some(scope) = syntax.scope_at(row) { + assert!(scope.start_row <= row && row < scope.end_row, "{path}"); + } + } +} + +fn source_range(source: &str) -> Range { + Range { + start_byte: 0, + end_byte: source.len(), + start_point: Point::new(0, 0), + end_point: point_at(source, source.len()), + } +} + +#[test] +fn string_newlines_remain_literal_across_visible_row_caching_and_edits() { + let source = "text = \"\"\"a\n\nb\"\"\"\nplain\n"; + let input = lines(source); + let path = Path::new("strings.py"); + let mut cache = SyntaxCache::new(); + let includes_newline = |cache: &SyntaxCache, row: usize, length: usize| { + cache + .string_spans(row) + .iter() + .any(|span| span.start_byte <= length && span.end_byte > length) + }; + cache.update_visible(&input, Some(path), 0, std::slice::from_ref(&(0..1))); + assert!(includes_newline(&cache, 0, input[0].len())); + cache.update_visible(&input, Some(path), 0, std::slice::from_ref(&(1..4))); + assert!(includes_newline(&cache, 0, input[0].len())); + assert!(includes_newline(&cache, 1, input[1].len())); + assert!(!includes_newline(&cache, 2, input[2].len())); + assert!(!includes_newline(&cache, 3, input[3].len())); + for (row, line) in input.iter().enumerate() { + assert!( + cache + .line(row) + .iter() + .all(|span| span.end_byte <= line.len()) + ); + } + cache.update(&lines("# a\n\nb\nplain\n"), Some(path), 1); + assert!(!includes_newline(&cache, 0, 3)); + assert!(!includes_newline(&cache, 1, 0)); +} + +#[test] +fn visible_windows_match_full_colors_after_edits_and_scrolling_without_reparsing() { + let cases = [ + ( + "a.rs", + "fn main() {\n/* start\ninside é 👩‍💻\nend */\nlet n = 42;\nlet text = \"unfinished", + ), + ("a.py", "text = '''first\ninside é\nlast'''\nanswer = 42"), + ( + "a.js", + "const action = () => 42;\nfunction hello(require) {\n require('local');\n action;\n}\nrequire('global');\naction;", + ), + ( + "a.md", + "# Heading **bold**\n\n```rust\nfn main() {\n let n = 42;\n}\n```\n\n```python\ndef hello():\n return 7\n```\n\n```unknown\nfn plain() {}\n```", + ), + ( + "a.md", + "> # Heading **bold**\n>\n> ```rust\n> fn main() {\n> let n = 42;\n> }\n> ```", + ), + ( + "a.md", + "```html\n\n```", + ), + ( + "a.html", + "
é
\n\n", + ), + ( + "a.js", + "const page = html`
${value}
\n`;", + ), + ]; + for (path, original) in cases { + let mut visible = SyntaxCache::new(); + for (revision, source) in [ + original.to_owned(), + original.replace("42", "123"), + format!("\n{original}"), + original.replace("() => 42", "42"), + ] + .iter() + .enumerate() + { + let input = lines(source); + let middle = input.len() / 2; + visible.update_visible( + &input, + Some(Path::new(path)), + revision as u64, + std::slice::from_ref(&(middle..middle + 1)), + ); + let full = cache(source, path); + assert_eq!(visible.error(), None, "{path}"); + assert_eq!( + visible.line(middle), + full.line(middle), + "{path} revision {revision}" + ); + assert!( + visible + .lines + .iter() + .enumerate() + .all(|(row, spans)| row == middle || spans.is_empty()) + ); + let parses = visible.parse_counts(); + let context = visible.context.as_ref().unwrap().captures.as_ptr(); + // Unsorted, overlapping and out-of-bounds windows use original source rows. + for row in (0..input.len()).rev() { + visible.update_visible( + &input, + Some(Path::new(path)), + revision as u64, + &[ + row..row + 1, + middle..middle + 1, + row..row + 1, + input.len()..usize::MAX, + ], + ); + assert_eq!( + visible.line(row), + full.line(row), + "{path} revision {revision} row {row}" + ); + assert_eq!( + visible.parse_counts(), + parses, + "{path}: scrolling reparsed a tree" + ); + assert_eq!(visible.context.as_ref().unwrap().captures.as_ptr(), context); + } + } + } +} + +#[test] +fn visible_highlight_collection_stays_bounded_by_the_requested_rows() { + let mut input: Vec<_> = (0..1000) + .map(|row| format!("fn function_{row}() {{ let value = 42; }}")) + .collect(); + let path = Path::new("large.rs"); + let mut visible = SyntaxCache::new(); + visible.update_visible(&input, Some(path), 0, std::slice::from_ref(&(500..502))); + assert_eq!(visible.parses, 1); + assert!(visible.captures.len() < 50); + assert!(visible.highlights.len() < 30); + assert!(visible.lines[..500].iter().all(Vec::is_empty)); + assert!(visible.lines[502..].iter().all(Vec::is_empty)); + input[500] = "fn changed() { let value = \"é 👩‍💻\"; }".to_owned(); + visible.update_visible(&input, Some(path), 1, std::slice::from_ref(&(500..502))); + assert_eq!(visible.incremental_parses, 1); + assert!(visible.captures.len() < 50); + let mut full = SyntaxCache::new(); + full.update(&input, Some(path), 1); + assert!(full.captures.len() > 1000); + for row in 500..502 { + assert_eq!(visible.line(row), full.line(row)); + } + let parses = visible.parse_counts(); + visible.update_visible(&input, Some(path), 1, std::slice::from_ref(&(950..952))); + assert_eq!(visible.parse_counts(), parses); + assert!(visible.captures.len() < 50); + for row in 950..952 { + assert_eq!(visible.line(row), full.line(row)); + } +} + +#[test] +fn visible_cache_supports_full_updates_and_same_revision_path_changes() { + let input = lines("const element =
;\nconst answer = 42;"); + let mut visible = SyntaxCache::new(); + visible.update_visible( + &input, + Some(Path::new("a.ts")), + 0, + std::slice::from_ref(&(1..2)), + ); + let parses = visible.parse_counts(); + visible.update(&input, Some(Path::new("a.ts")), 0); + assert_eq!(visible.parse_counts(), parses); + assert_fresh(&visible, &input.join("\n"), "a.ts"); + visible.update_visible( + &input, + Some(Path::new("a.tsx")), + 0, + std::slice::from_ref(&(0..1)), + ); + assert_eq!(visible.parses, parses.0 + 1); + assert_eq!(visible.line(0), cache(&input.join("\n"), "a.tsx").line(0)); + assert!(visible.line(1).is_empty()); + visible.update_text(&input.join("\n"), Path::new("a.tsx")); + assert_fresh(&visible, &input.join("\n"), "a.tsx"); + visible.update_visible( + &input, + Some(Path::new("a.txt")), + 0, + std::slice::from_ref(&(0..2)), + ); + assert_eq!(visible.language(), None); + assert!(visible.lines.iter().all(Vec::is_empty)); +} + +fn cache(source: &str, path: &str) -> SyntaxCache { + let mut cache = SyntaxCache::new(); + cache.update(&lines(source), Some(Path::new(path)), 0); + assert_eq!(cache.error(), None, "{path}"); + cache +} + +fn kind_at(cache: &SyntaxCache, row: usize, byte: usize) -> Option { + cache + .line(row) + .iter() + .find_map(|span| (span.start_byte <= byte && byte < span.end_byte).then_some(span.kind)) +} + +fn assert_token(cache: &SyntaxCache, source: &str, row: usize, token: &str, kind: SyntaxKind) { + let line = source.split('\n').nth(row).unwrap(); + let start = line.find(token).unwrap(); + for (byte, _) in line[start..start + token.len()].char_indices() { + assert_eq!( + kind_at(cache, row, start + byte), + Some(kind), + "{token:?} in {}", + cache.language().unwrap() + ); + } +} + +fn assert_fresh(cache: &SyntaxCache, source: &str, path: &str) { + let fresh = self::cache(source, path); + assert_eq!(cache.language(), fresh.language()); + assert_eq!(cache.error(), fresh.error()); + assert_eq!( + cache.tree.as_ref().map(|tree| tree.root_node().to_sexp()), + fresh.tree.as_ref().map(|tree| tree.root_node().to_sexp()) + ); + let source = lines(source); + for (row, line) in source.iter().enumerate() { + assert_eq!(cache.line(row), fresh.line(row), "row {row}"); + let mut previous = 0; + for span in cache.line(row) { + assert!(previous <= span.start_byte && span.start_byte < span.end_byte); + assert!(span.end_byte <= line.len()); + assert!(line.is_char_boundary(span.start_byte) && line.is_char_boundary(span.end_byte)); + previous = span.end_byte; + } + } +} + +#[test] +fn every_bundled_query_compiles_and_common_languages_color_real_tokens() { + for language in languages::all() { + configuration(language).unwrap_or_else(|error| panic!("{}: {error}", language.name)); + } + let cases = [ + ( + "a.rs", + "Rust", + "fn main() { let x = 42; let s = \"hi\"; /* note */ }", + "fn", + SyntaxKind::Keyword, + ), + ( + "a.rb", + "Ruby", + "def hello\n 42\nend", + "def", + SyntaxKind::Keyword, + ), + ( + "a.py", + "Python", + "def hello():\n return 42", + "def", + SyntaxKind::Keyword, + ), + ( + "a.json", + "JSON", + "{\"n\":42,\"yes\":true}", + "42", + SyntaxKind::Number, + ), + ( + "a.sh", + "Bash", + "echo \"hi\" # note", + "hi", + SyntaxKind::String, + ), + ( + "Makefile", + "Makefile", + "# note\nall:\n\techo hi", + "note", + SyntaxKind::Comment, + ), + ( + "a.md", + "Markdown", + "# Heading\n\n**bold**", + "Heading", + SyntaxKind::Markup, + ), + ( + "a.js", + "JavaScript", + "function hello() { return 42; }", + "function", + SyntaxKind::Keyword, + ), + ( + "a.ts", + "TypeScript", + "function hello(): number { return 42; }", + "function", + SyntaxKind::Keyword, + ), + ( + "a.html", + "HTML", + "
text
", + "div", + SyntaxKind::Markup, + ), + ( + "a.css", + "CSS", + "body { width: 42px; }", + "42", + SyntaxKind::Number, + ), + ( + "a.yaml", + "YAML", + "message: \"hi\"\ncount: 42", + "hi", + SyntaxKind::String, + ), + ( + "Cargo.toml", + "TOML", + "message = \"hi\"\ncount = 42", + "hi", + SyntaxKind::String, + ), + ( + "a.c", + "C", + "int main(void) { return 42; }", + "return", + SyntaxKind::Keyword, + ), + ( + "a.cpp", + "C++", + "int main() { return 42; }", + "return", + SyntaxKind::Keyword, + ), + ( + "a.go", + "Go", + "package main\nfunc main() { return }", + "package", + SyntaxKind::Keyword, + ), + ]; + for (path, language, source, token, kind) in cases { + let cache = self::cache(source, path); + assert_eq!(cache.language(), Some(language)); + assert_token(&cache, source, 0, token, kind); + assert_fresh(&cache, source, path); + } +} + +#[test] +fn semantic_colors_and_text_predicates_preserve_the_ui_contract() { + let source = + "fn main() { let n = 42; let f = 3.25; let b = true; let s = \"hello\"; /* note */ }"; + let cache = self::cache(source, "a.rs"); + for (token, kind) in [ + ("fn", SyntaxKind::Keyword), + ("let", SyntaxKind::Keyword), + ("main", SyntaxKind::Function), + ("42", SyntaxKind::Number), + ("3.25", SyntaxKind::Number), + ("true", SyntaxKind::Keyword), + ("hello", SyntaxKind::String), + ("note", SyntaxKind::Comment), + ] { + assert_token(&cache, source, 0, token, kind); + } + let source = "const foo = 42; const UPPER = 7; foo; UPPER;"; + let cache = self::cache(source, "a.js"); + assert_eq!(kind_at(&cache, 0, source.find("foo").unwrap()), None); + // JavaScript's bundled constant predicate must not color every identifier. + assert_ne!( + kind_at(&cache, 0, source.find("foo;").unwrap()), + Some(SyntaxKind::Keyword) + ); +} + +#[test] +fn zig_highlights_literals_builtins_and_predicate_matched_identifiers() { + let source = r#"const std = @import("std"); +const MAX_COUNT: u32 = 42; +pub fn main() void { + const local = @as(u32, 7); + const value = Point; + const text = + \\first é + \\second + ; + // note +}"#; + let cache = self::cache(source, "main.zig"); + for (row, token, kind) in [ + (0, "const", SyntaxKind::Keyword), + (0, "@import", SyntaxKind::Keyword), + (0, "\"std\"", SyntaxKind::String), + (1, "MAX_COUNT", SyntaxKind::Keyword), + (1, "u32", SyntaxKind::Type), + (1, "42", SyntaxKind::Number), + (2, "pub", SyntaxKind::Keyword), + (2, "fn", SyntaxKind::Keyword), + (2, "main", SyntaxKind::Function), + (2, "void", SyntaxKind::Type), + (3, "@as", SyntaxKind::Function), + (4, "Point", SyntaxKind::Type), + (6, "first é", SyntaxKind::String), + (7, "second", SyntaxKind::String), + (9, "note", SyntaxKind::Comment), + ] { + assert_token(&cache, source, row, token, kind); + } + let local = source.lines().nth(3).unwrap().find("local").unwrap(); + assert_eq!(kind_at(&cache, 3, local), None); +} + +#[test] +fn filenames_shebang_save_as_and_tsx_use_actual_grammar_identity() { + let source = lines("\u{feff}#!/usr/bin/env python3\nreturn 42"); + let mut cache = SyntaxCache::new(); + cache.update(&source, None, 0); + assert_eq!(cache.language(), Some("Python")); + cache.update(&source, Some(Path::new("a.RS")), 0); + assert_eq!(cache.language(), Some("Rust")); + assert_eq!(cache.parses, 2); + cache.update(&source, Some(Path::new("renamed.rs")), 0); + assert_eq!(cache.parses, 2); + cache.update(&lines("all:\n\techo hi"), Some(Path::new("GNUmakefile")), 1); + assert_eq!(cache.language(), Some("Makefile")); + let source = "const element =
;"; + cache.update(&lines(source), Some(Path::new("a.ts")), 2); + let typescript = cache.syntax.unwrap(); + let parses = cache.parses; + cache.update(&lines(source), Some(Path::new("a.tsx")), 2); + assert_eq!(cache.language(), Some("TypeScript")); + assert!(!std::ptr::eq(typescript, cache.syntax.unwrap())); + assert_eq!(cache.parses, parses + 1); + assert_token(&cache, source, 0, "div", SyntaxKind::Markup); + assert_fresh(&cache, source, "a.tsx"); + let mut script = SyntaxCache::new(); + script.update(&lines("#!/usr/bin/env python3\nreturn 42"), None, 0); + assert_eq!(script.language(), Some("Python")); + script.update(&lines("#!/usr/bin/env bash\necho \"hi\""), None, 1); + assert_eq!(script.language(), Some("Bash")); + assert_token( + &script, + "#!/usr/bin/env bash\necho \"hi\"", + 1, + "hi", + SyntaxKind::String, + ); +} + +#[test] +fn unknown_and_former_non_common_languages_are_honest_plain_text() { + for path in [ + "a.unknown", + "a.txt", + "a.hs", + "a.java", + "a.php", + "a.swift", + "a.sql", + ] { + let cache = self::cache("fn main() { \"hi\"; 42; }", path); + assert_eq!(cache.language(), None, "{path}"); + assert!(cache.lines.iter().all(Vec::is_empty)); + assert_eq!(cache.parses, 0); + } +} + +#[test] +fn unchanged_revision_and_same_source_do_not_allocate_or_parse() { + let mut cache = self::cache("fn main() {}\n", "a.rs"); + let source = lines("fn main() {}\n"); + let source_pointer = cache.source.as_ptr(); + let scratch_pointer = cache.next_source.as_ptr(); + let tree_id = cache.tree.as_ref().unwrap().root_node().id(); + cache.update(&source, Some(Path::new("a.rs")), 0); + assert_eq!(cache.source.as_ptr(), source_pointer); + assert_eq!(cache.next_source.as_ptr(), scratch_pointer); + assert_eq!(cache.tree.as_ref().unwrap().root_node().id(), tree_id); + cache.update(&source, Some(Path::new("a.rs")), 1); + cache.update(&source, Some(Path::new("b.rs")), 1); + assert_eq!(cache.parses, 1); + assert_eq!(cache.incremental_parses, 0); + assert_eq!(cache.tree.as_ref().unwrap().root_node().id(), tree_id); + assert!(cache.line(1).is_empty()); + cache.update(&source, Some(Path::new("b.txt")), 1); + assert_eq!(cache.language(), None); + assert!(cache.tree.is_none()); + assert!(cache.lines.iter().all(Vec::is_empty)); + cache.update(&source, Some(Path::new("b.rs")), 1); + assert_eq!(cache.parses, 2); + assert_eq!(cache.incremental_parses, 0); +} + +#[test] +fn unicode_minimal_edits_use_byte_points_and_never_split_codepoints() { + let old = "x\n\t\"é 👩‍💻\"\n"; + let new = "x\n\t\"ê 👩‍💻\"\n"; + let edit = minimal_edit(old, new); + assert_eq!(edit.start_byte, 4); + assert_eq!(edit.start_position, Point::new(1, 2)); + assert_eq!(edit.old_end_byte, 6); + assert_eq!(edit.new_end_byte, 6); + assert_eq!(edit.old_end_position, Point::new(1, 4)); + let edit = minimal_edit("a\nβ\n", "a\n\nβ\n"); + assert_eq!(edit.start_position, Point::new(1, 0)); + assert_eq!(edit.old_end_position, Point::new(1, 0)); + assert_eq!(edit.new_end_position, Point::new(2, 0)); + let edit = minimal_edit("α", ""); + assert_eq!( + (edit.start_byte, edit.old_end_byte, edit.new_end_byte), + (0, 2, 0) + ); + let edit = minimal_edit("xα", "xѱ"); + assert_eq!( + (edit.start_byte, edit.old_end_byte, edit.new_end_byte), + (1, 3, 3) + ); + assert_eq!(edit.new_end_position, Point::new(0, 3)); +} + +#[test] +fn incremental_unicode_newline_insert_remove_and_undo_match_fresh_trees() { + let original = "fn main() {\n\tlet text = \"é é 👩‍💻\";\n\t/* nested /* inner */ note */\n}\n"; + let mut cache = self::cache(original, "a.rs"); + let edits = [ + original.replace("é", "ê"), + original.replace("let text", "\nlet text"), + original.replace("\n\t/* nested /* inner */ note */", ""), + original.replace("👩‍💻", "👩‍🚀"), + original.replace("\";", "\";\n\tlet n = 42;"), + original.to_owned(), + String::new(), + original.to_owned(), + ]; + for (revision, source) in edits.iter().enumerate() { + cache.update(&lines(source), Some(Path::new("a.rs")), revision as u64 + 1); + assert_fresh(&cache, source, "a.rs"); + } + assert_eq!(cache.parses, edits.len() + 1); + assert_eq!(cache.incremental_parses, edits.len()); +} + +#[test] +fn unfinished_source_and_multiline_offscreen_state_remain_highlighted() { + let source = "fn main() {\n/* start\ninside 👩‍💻\nend */\nlet n = 42;\nlet text = \"unfinished"; + let cache = self::cache(source, "a.rs"); + assert!(cache.tree.as_ref().unwrap().root_node().has_error()); + assert_token(&cache, source, 0, "fn", SyntaxKind::Keyword); + assert_token(&cache, source, 2, "inside", SyntaxKind::Comment); + assert_token(&cache, source, 4, "42", SyntaxKind::Number); + assert_eq!(cache.error(), None); + let source = "text = '''first\ninside\nlast'''\nanswer = 42"; + let cache = self::cache(source, "a.py"); + assert_token(&cache, source, 1, "inside", SyntaxKind::String); + assert_token(&cache, source, 3, "42", SyntaxKind::Number); +} + +#[test] +fn markdown_heading_markers_share_title_colors_without_coloring_plain_hashes() { + let source = "# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six\n\nSetext\n======\n\nplain # text"; + let cache = self::cache(source, "headings.md"); + for row in 0..6 { + assert_token( + &cache, + source, + row, + &"#".repeat(row + 1), + SyntaxKind::Markup, + ); + } + assert_token(&cache, source, 0, "One", SyntaxKind::Markup); + assert_token(&cache, source, 7, "Setext", SyntaxKind::Markup); + assert_token(&cache, source, 8, "======", SyntaxKind::Markup); + assert_eq!(kind_at(&cache, 10, 6), None); +} + +#[test] +fn markdown_list_markers_color_bullets_and_numbers_without_coloring_plain_text() { + let source = "- item\n - nested\n 12. ordered\n\n+ item\n\n* item\n\n1. ordered\n2. next\n\n3) ordered\n\n> - quoted\n> 4. ordered\n\nplain - text and 1. text\n\n\\- escaped"; + let mut cache = self::cache(source, "lists.md"); + for (row, marker) in [ + (0, "-"), + (1, "-"), + (2, "12."), + (4, "+"), + (6, "*"), + (8, "1."), + (9, "2."), + (11, "3)"), + (13, "-"), + (14, "4."), + ] { + assert_token(&cache, source, row, marker, SyntaxKind::Markup); + } + assert_eq!(kind_at(&cache, 0, 2), None); + assert_eq!(kind_at(&cache, 16, 6), None); + assert_eq!(kind_at(&cache, 16, 17), None); + assert_ne!(kind_at(&cache, 18, 1), Some(SyntaxKind::Markup)); + + let edited = source.replacen("- item", "plain - item", 1); + cache.update(&lines(&edited), Some(Path::new("lists.md")), 1); + assert_eq!(kind_at(&cache, 0, 6), None); + assert_fresh(&cache, &edited, "lists.md"); +} + +#[test] +fn markdown_inline_and_fences_inject_known_languages_and_update_incrementally() { + let original = "A **bold** and `code` link.\n\n```rust\nfn main() { let n = 42; }\n```\n\n```python\ndef hello():\n return 7\n```\n\n```not-a-language\nfn not_rust() { 99; }\n```"; + let mut cache = self::cache(original, "a.md"); + assert_token(&cache, original, 0, "bold", SyntaxKind::Markup); + assert_token(&cache, original, 0, "code", SyntaxKind::Markup); + assert_token(&cache, original, 3, "fn", SyntaxKind::Keyword); + assert_token(&cache, original, 3, "main", SyntaxKind::Function); + assert_token(&cache, original, 3, "42", SyntaxKind::Number); + assert_token(&cache, original, 7, "def", SyntaxKind::Keyword); + assert_ne!(kind_at(&cache, 12, 0), Some(SyntaxKind::Keyword)); + let parses = cache.injection_parses; + let source = original.replace("42", "123"); + cache.update(&lines(&source), Some(Path::new("a.md")), 1); + assert_token(&cache, &source, 3, "123", SyntaxKind::Number); + assert_fresh(&cache, &source, "a.md"); + assert!(cache.injection_parses > parses); + let source = source.replace("```rust", "```unknown"); + cache.update(&lines(&source), Some(Path::new("a.md")), 2); + assert_ne!(kind_at(&cache, 3, 0), Some(SyntaxKind::Keyword)); + assert_fresh(&cache, &source, "a.md"); +} + +#[test] +fn markdown_injections_keep_anonymous_syntax_but_not_named_block_continuations() { + let source = "> # Heading **bold**\n>\n> ```rust\n> fn main() {\n> let n = 3.25;\n> }\n> ```"; + let cache = self::cache(source, "a.md"); + assert_token(&cache, source, 0, "Heading", SyntaxKind::Markup); + assert_token(&cache, source, 0, "bold", SyntaxKind::Markup); + assert_token(&cache, source, 3, "main", SyntaxKind::Function); + assert_token(&cache, source, 4, "3.25", SyntaxKind::Number); + let rust = languages::by_name("rust").unwrap(); + let layer = cache + .injections + .iter() + .find(|layer| std::ptr::eq(layer.syntax, rust)) + .unwrap(); + for range in &layer.ranges { + assert!(!source[range.start_byte..range.end_byte].contains('>')); + } + let braces = [source.find('{').unwrap(), source.find('}').unwrap()]; + for byte in braces { + assert!( + layer + .ranges + .iter() + .any(|range| range.start_byte <= byte && byte < range.end_byte) + ); + } + assert_fresh(&cache, source, "a.md"); +} + +#[test] +fn html_script_style_injections_override_host_and_reuse_unchanged_trees() { + let original = "
hello
\n\n"; + let mut cache = self::cache(original, "a.html"); + assert_token(&cache, original, 0, "div", SyntaxKind::Markup); + assert_token(&cache, original, 1, "function", SyntaxKind::Keyword); + assert_token(&cache, original, 1, "hello", SyntaxKind::Function); + assert_token(&cache, original, 1, "42", SyntaxKind::Number); + assert_token(&cache, original, 2, "7", SyntaxKind::Number); + let injection_parses = cache.injection_parses; + let source = original.replace("
hello", "
world"); + cache.update(&lines(&source), Some(Path::new("a.html")), 1); + assert_eq!(cache.injection_parses, injection_parses); + assert_fresh(&cache, &source, "a.html"); + let source = source.replace("42", "123"); + cache.update(&lines(&source), Some(Path::new("a.html")), 2); + assert_token(&cache, &source, 1, "123", SyntaxKind::Number); + assert_fresh(&cache, &source, "a.html"); + // Inserting at an included range's exact end must reparse, not merely edit its old tree. + let source = source.replace("return 123; }", "return 123; } function added() {}"); + cache.update(&lines(&source), Some(Path::new("a.html")), 3); + assert_token(&cache, &source, 1, "added", SyntaxKind::Function); + assert_fresh(&cache, &source, "a.html"); +} + +#[test] +fn local_definitions_disable_builtin_captures_and_propagate_highlights() { + let source = "function hello(require) { require('local'); }\nrequire('global');"; + let cache = self::cache(source, "a.js"); + let local = source.find("require('local')").unwrap(); + assert_eq!(kind_at(&cache, 0, local), None); + assert_token(&cache, source, 1, "require", SyntaxKind::Function); + let source = "const action = () => 42;\naction;"; + let cache = self::cache(source, "a.js"); + assert_token(&cache, source, 0, "action", SyntaxKind::Function); + assert_token(&cache, source, 1, "action", SyntaxKind::Function); +} + +#[test] +fn overlapping_capture_specificity_priority_and_plain_injection_are_nonoverlapping() { + let source = "αβγ"; + let mut spans = vec![Vec::new()]; + let mut events = Vec::new(); + let highlights = vec![ + Highlight { + start: 0, + end: 6, + kind: Some(SyntaxKind::String), + depth: 0, + priority: 0, + pattern: 99, + }, + Highlight { + start: 2, + end: 4, + kind: Some(SyntaxKind::Function), + depth: 0, + priority: 0, + pattern: 1, + }, + Highlight { + start: 4, + end: 6, + kind: None, + depth: 1, + priority: i32::MIN, + pattern: 0, + }, + Highlight { + start: 0, + end: 2, + kind: Some(SyntaxKind::Keyword), + depth: 0, + priority: 10, + pattern: 0, + }, + ]; + render_spans( + source, + &[0], + &mut spans, + &mut vec![Vec::new(); 1], + &highlights, + &mut events, + &[source_range(source)], + ); + assert_eq!( + spans[0], + vec![ + SyntaxSpan { + start_byte: 0, + end_byte: 2, + kind: SyntaxKind::Keyword + }, + SyntaxSpan { + start_byte: 2, + end_byte: 4, + kind: SyntaxKind::Function + }, + ] + ); +} + +#[test] +fn local_initializer_visibility_positive_predicates_and_noninheriting_scopes() { + let syntax = languages::by_name("javascript").unwrap(); + let locals = r#" + (program) @local.scope + ((function_declaration) @local.scope (#set! local.scope-inherits false)) + (variable_declarator name: (identifier) @local.definition value: (_) @local.definition-value) + (identifier) @local.reference + "#; + let source_query = format!( + "{locals}\n{}", + r#" + ((identifier) @function (#is-not? local)) + ((identifier) @string (#is? local)) + (variable_declarator name: (identifier) @type) + "# + ); + let query = Query::new(&syntax.language, &source_query).unwrap(); + let highlights_start = (0..query.pattern_count()) + .filter(|pattern| query.start_byte_for_pattern(*pattern) < locals.len() + 1) + .count(); + let configuration = Configuration::new(syntax, query, 0, highlights_start); + let source = "const value = value;\nvalue;\nfunction inner() { value; }"; + let cache = self::cache(source, "a.js"); + let mut highlights = Vec::new(); + collect_layer( + &configuration, + cache.tree.as_ref().unwrap(), + source, + &[source_range(source)], + &[source_range(source)], + &mut None, + 0, + None, + &mut QueryCursor::new(), + &mut Vec::new(), + &mut highlights, + ) + .unwrap(); + let mut spans = vec![Vec::new(); 3]; + let starts = [ + 0, + source.find("\nvalue;").unwrap() + 1, + source.find("\nfunction").unwrap() + 1, + ]; + render_spans( + source, + &starts, + &mut spans, + &mut vec![Vec::new(); 3], + &highlights, + &mut Vec::new(), + &[source_range(source)], + ); + let kind = |row: usize, byte: usize| { + spans[row] + .iter() + .find_map(|span| (span.start_byte <= byte && byte < span.end_byte).then_some(span.kind)) + }; + assert_eq!(kind(0, 6), Some(SyntaxKind::Type)); + assert_eq!(kind(0, 14), Some(SyntaxKind::Function)); + assert_eq!(kind(1, 0), Some(SyntaxKind::Type)); + assert_eq!(kind(2, 19), Some(SyntaxKind::Function)); + let mut context = None; + for row in [1, 2] { + let window = [Range { + start_byte: starts[row], + end_byte: starts.get(row + 1).copied().unwrap_or(source.len()), + start_point: Point::new(row, 0), + end_point: point_at(source, starts.get(row + 1).copied().unwrap_or(source.len())), + }]; + let mut visible_highlights = Vec::new(); + collect_layer( + &configuration, + cache.tree.as_ref().unwrap(), + source, + &[source_range(source)], + &window, + &mut context, + 0, + None, + &mut QueryCursor::new(), + &mut Vec::new(), + &mut visible_highlights, + ) + .unwrap(); + let mut visible_spans = vec![Vec::new(); 3]; + render_spans( + source, + &starts, + &mut visible_spans, + &mut vec![Vec::new(); 3], + &visible_highlights, + &mut Vec::new(), + &window, + ); + assert_eq!(visible_spans[row], spans[row]); + assert!( + visible_spans + .iter() + .enumerate() + .all(|(other, spans)| other == row || spans.is_empty()) + ); + } +} + +#[test] +fn grammar_and_unsupported_predicate_errors_are_not_silent_empty_queries() { + static INVALID: LazyLock<[LanguageSpec; 2]> = LazyLock::new(|| { + let language = languages::by_name("rust").unwrap(); + [ + "(not_a_rust_node) @keyword", + "((identifier) @function (#unsupported? @function))", + ] + .map(|query| LanguageSpec { + id: language.id, + name: "invalid test grammar query", + language: language.language.clone(), + highlights: query, + injections: "", + locals: "", + }) + }); + for syntax in INVALID.iter() { + assert!(compile_configuration(syntax).is_err()); + } +} + +#[test] +fn combined_tagged_templates_and_nested_markdown_html_keep_real_injections() { + let source = + "const page = html`
${value}
`;"; + let cache = self::cache(source, "a.js"); + assert_token(&cache, source, 0, "div", SyntaxKind::Markup); + assert_token(&cache, source, 0, "inside", SyntaxKind::Function); + assert_token(&cache, source, 0, "42", SyntaxKind::Number); + assert_fresh(&cache, source, "a.js"); + let source = "```html\n\n```"; + let cache = self::cache(source, "a.md"); + assert_token(&cache, source, 1, "nested", SyntaxKind::Function); + assert_token(&cache, source, 1, "7", SyntaxKind::Number); + assert_fresh(&cache, source, "a.md"); + let source = "fn main() { println!(\"{}\", 42); }"; + let cache = self::cache(source, "a.rs"); + assert_token(&cache, source, 0, "42", SyntaxKind::Number); + assert!(cache.injections.len() <= 16); + assert_fresh(&cache, source, "a.rs"); +} + +#[test] +fn parser_state_is_isolated_between_buffers() { + let mut first = self::cache("/* open\ninside", "a.rs"); + let second = self::cache("fn main() { let n = 42; }", "b.rs"); + first.update( + &lines("/* open */\nfn other() {}"), + Some(Path::new("a.rs")), + 1, + ); + assert_token( + &second, + "fn main() { let n = 42; }", + 0, + "fn", + SyntaxKind::Keyword, + ); + assert_eq!(second.parses, 1); + assert_eq!(first.incremental_parses, 1); +} diff --git a/tests/unit/syntax_languages.rs b/tests/unit/syntax_languages.rs new file mode 100644 index 0000000..1ebb159 --- /dev/null +++ b/tests/unit/syntax_languages.rs @@ -0,0 +1,73 @@ +use super::*; + +#[test] +fn selected_files_and_auxiliary_grammars_are_registered() { + for (filename, language) in [ + ("main.rs", "Rust"), + ("Gemfile", "Ruby"), + ("main.py", "Python"), + ("package.json", "JSON"), + (".bashrc", "Bash"), + ("GNUmakefile", "Makefile"), + ("README.md", "Markdown"), + ("main.jsx", "JavaScript"), + ("main.ts", "TypeScript"), + ("index.html", "HTML"), + ("style.css", "CSS"), + ("config.yml", "YAML"), + ("Cargo.toml", "TOML"), + ("Cargo.lock", "TOML"), + ("main.c", "C"), + ("main.cpp", "C++"), + ("main.C", "C++"), + ("main.go", "Go"), + ("main.zig", "Zig"), + ] { + assert_eq!( + detect(Some(Path::new(filename)), None).map(|spec| spec.name), + Some(language) + ); + } + let ts = detect(Some(Path::new("main.ts")), None).unwrap(); + let tsx = detect(Some(Path::new("main.tsx")), None).unwrap(); + assert_eq!(ts.name, tsx.name); + assert!(!std::ptr::eq(ts, tsx)); + assert_ne!(ts.language, tsx.language); + assert!(std::ptr::eq(tsx, by_name("TSX").unwrap())); + assert!(by_name("markdown_inline").is_some()); + for filename in [ + "notes.txt", + "main.hs", + "main.scala", + "main.lua", + "Dockerfile", + ] { + assert!(detect(Some(Path::new(filename)), None).is_none()); + } +} + +#[test] +fn shebangs_support_bom_env_options_and_filename_precedence() { + for (line, language) in [ + ("#!/usr/bin/python3", "Python"), + ("\u{feff}#!/usr/bin/env -S python3.12 -u", "Python"), + ("#!/usr/bin/env -u PYTHONHOME FOO=bar python3", "Python"), + ("#!/usr/bin/ruby3.3", "Ruby"), + ("#!/bin/bash", "Bash"), + ("#! /usr/bin/env node", "JavaScript"), + ] { + assert_eq!( + detect(None, Some(line)).map(|spec| spec.name), + Some(language) + ); + } + assert_eq!( + detect(Some(Path::new("main.rs")), Some("#!/bin/bash")) + .unwrap() + .name, + "Rust" + ); + assert!(detect(None, Some("#!/usr/bin/python-not-real")).is_none()); + assert!(detect(None, Some("#!/usr/bin/perl")).is_none()); + assert!(detect(None, Some("plain text")).is_none()); +} diff --git a/tests/unit/terminal_colors.rs b/tests/unit/terminal_colors.rs new file mode 100644 index 0000000..ef67aec --- /dev/null +++ b/tests/unit/terminal_colors.rs @@ -0,0 +1,273 @@ +use super::*; +use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind}; + +fn key(code: KeyCode, modifiers: KeyModifiers) -> Event { + Event::Key(KeyEvent::new(code, modifiers)) +} + +fn bytes(collector: &mut TerminalColors, text: &str, now: Instant) { + for ch in text.chars() { + let modifiers = if ch.is_ascii_uppercase() { + KeyModifiers::SHIFT + } else { + KeyModifiers::NONE + }; + collector.push(key(KeyCode::Char(ch), modifiers), now); + } +} + +fn report(collector: &mut TerminalColors, body: &str, now: Instant) { + collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), now); + bytes(collector, body, now); + collector.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL), now); +} + +fn drain(collector: &mut TerminalColors) -> Vec { + std::iter::from_fn(|| collector.pop()).collect() +} + +#[test] +fn fragmented_triple_accepts_bel_and_both_st_encodings() { + let now = Instant::now(); + let mut collector = TerminalColors::default(); + collector.query(&mut Vec::new(), now).unwrap(); + collector.push(key(KeyCode::Esc, KeyModifiers::NONE), now); + collector.push( + key(KeyCode::Char(']'), KeyModifiers::NONE), + now + Duration::from_millis(1), + ); + bytes( + &mut collector, + "11;rgb:0/12/345", + now + Duration::from_millis(2), + ); + collector.push( + key(KeyCode::Esc, KeyModifiers::NONE), + now + Duration::from_millis(3), + ); + collector.push( + key(KeyCode::Char('\\'), KeyModifiers::NONE), + now + Duration::from_millis(4), + ); + assert_eq!(collector.colors, None); + report( + &mut collector, + "4;2;rgb:f/ffff/ABCD", + now + Duration::from_millis(5), + ); + collector.push( + key(KeyCode::Char(']'), KeyModifiers::ALT), + now + Duration::from_millis(6), + ); + bytes( + &mut collector, + "4;1;#FF1234", + now + Duration::from_millis(7), + ); + collector.push( + key(KeyCode::Char('\\'), KeyModifiers::ALT), + now + Duration::from_millis(8), + ); + assert_eq!( + collector.colors, + Some(ThemeColors { + background: [0, 18, 52], + red: [255, 18, 52], + green: [255, 255, 171], + }) + ); + assert!(drain(&mut collector).is_empty()); +} + +#[test] +fn focus_refresh_atomically_replaces_light_and_dark_triples() { + let now = Instant::now(); + let mut collector = TerminalColors::default(); + let palettes = [ + ("11;#FDF6E3", [253, 246, 227]), + ("11;rgb:0000/0000/0000", [0, 0, 0]), + ]; + for (cycle, (background, expected)) in palettes.into_iter().enumerate() { + let time = now + Duration::from_secs(cycle as u64); + let focus = Event::FocusGained; + collector.push(focus.clone(), time); + assert_eq!(collector.pop(), Some(focus)); + let previous = collector.colors; + collector.query(&mut Vec::new(), time).unwrap(); + report(&mut collector, "4;1;rgb:ff/00/00", time); + report(&mut collector, background, time); + assert_eq!(collector.colors, previous); + report(&mut collector, "4;2;rgb:00/ff/00", time); + assert_eq!( + collector.colors, + Some(ThemeColors { + background: expected, + red: [255, 0, 0], + green: [0, 255, 0], + }) + ); + assert!(drain(&mut collector).is_empty()); + } +} + +#[test] +fn periodic_refresh_detects_silent_change_without_overlapping_queries() { + let now = Instant::now(); + let mut collector = TerminalColors::default(); + let mut output = Vec::new(); + assert!(collector.refresh_due(now)); + collector.query(&mut output, now).unwrap(); + report(&mut collector, "11;#101010", now); + report(&mut collector, "4;1;#FF0000", now); + report(&mut collector, "4;2;#00FF00", now); + let previous = collector.colors; + assert!(previous.is_some()); + + // The terminal changes its palette without emitting a notification. + let refresh = now + Duration::from_secs(1); + assert!(!collector.refresh_due(refresh - Duration::from_nanos(1))); + assert!(collector.refresh_due(refresh)); + collector.query(&mut output, refresh).unwrap(); + report(&mut collector, "11;#EEEEEE", refresh); + report(&mut collector, "4;1;#AA1122", refresh); + assert_eq!(collector.colors, previous); + let typed = key(KeyCode::Char('i'), KeyModifiers::NONE); + collector.push(typed.clone(), refresh); + assert_eq!(drain(&mut collector), vec![typed]); + + // Even an overdue probe must wait until the incomplete query expires. + let retry = refresh + Duration::from_secs(1); + assert!(!collector.refresh_due(retry)); + collector.expire(retry); + assert_eq!(collector.colors, None); + assert!(collector.refresh_due(retry)); + collector.query(&mut output, retry).unwrap(); + report(&mut collector, "11;#EEEEEE", retry); + report(&mut collector, "4;1;#AA1122", retry); + assert_eq!(collector.colors, None); + report(&mut collector, "4;2;#22BB33", retry); + assert_eq!( + collector.colors, + Some(ThemeColors { + background: [238, 238, 238], + red: [170, 17, 34], + green: [34, 187, 51], + }) + ); + assert!(drain(&mut collector).is_empty()); + assert!(!collector.refresh_due(retry + Duration::from_millis(999))); + assert!(collector.refresh_due(retry + Duration::from_secs(1))); +} + +#[test] +fn failed_refresh_clears_stale_colors_and_consumes_late_partial_reply() { + let now = Instant::now(); + let mut collector = TerminalColors::default(); + collector.query(&mut Vec::new(), now).unwrap(); + report(&mut collector, "11;#101010", now); + report(&mut collector, "4;1;#FF0000", now); + report(&mut collector, "4;2;#00FF00", now); + assert!(collector.colors.is_some()); + let refresh = now + Duration::from_secs(1); + collector.query(&mut Vec::new(), refresh).unwrap(); + report(&mut collector, "11;#EEEEEE", refresh); + collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), refresh); + bytes(&mut collector, "4;1;rgb:ffff/", refresh); + collector.expire(refresh + QUERY_TIMEOUT); + assert_eq!(collector.colors, None); + let late = refresh + Duration::from_secs(1); + bytes(&mut collector, "0000/0000", late); + collector.push(key(KeyCode::Esc, KeyModifiers::NONE), late); + collector.expire(late + Duration::from_secs(1)); + collector.push( + key(KeyCode::Char('\\'), KeyModifiers::NONE), + late + Duration::from_secs(1), + ); + report(&mut collector, "4;2;#00FF00", late + Duration::from_secs(1)); + assert_eq!(collector.colors, None); + assert!(drain(&mut collector).is_empty()); + let typed = key(KeyCode::Char('i'), KeyModifiers::NONE); + collector.push(typed.clone(), late + Duration::from_secs(2)); + assert_eq!(collector.pop(), Some(typed)); +} + +#[test] +fn unrelated_events_and_rejected_candidates_replay_exactly() { + let now = Instant::now(); + let mut collector = TerminalColors::default(); + collector.query(&mut Vec::new(), now).unwrap(); + let events = vec![ + key(KeyCode::Char(']'), KeyModifiers::ALT), + key(KeyCode::Char('4'), KeyModifiers::NONE), + key(KeyCode::Char(';'), KeyModifiers::NONE), + key(KeyCode::Char('3'), KeyModifiers::NONE), + key(KeyCode::Char('g'), KeyModifiers::CONTROL), + key(KeyCode::Char(']'), KeyModifiers::ALT | KeyModifiers::SHIFT), + Event::Key(KeyEvent::new_with_kind( + KeyCode::Char(']'), + KeyModifiers::ALT, + KeyEventKind::Release, + )), + Event::Key(KeyEvent::new_with_kind( + KeyCode::Char('A'), + KeyModifiers::SHIFT, + KeyEventKind::Repeat, + )), + Event::Key(KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::CAPS_LOCK, + }), + Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + column: 2, + row: 3, + modifiers: KeyModifiers::CONTROL, + }), + Event::FocusLost, + Event::Resize(80, 24), + key(KeyCode::Char(']'), KeyModifiers::ALT), + Event::Paste("\x1b]11;rgb:ff/ff/ff\x07".into()), + Event::FocusGained, + ]; + for event in &events { + collector.push(event.clone(), now); + } + assert_eq!(drain(&mut collector), events); + assert_eq!(collector.colors, None); +} + +#[test] +fn malformed_or_oversized_reports_and_lone_escape_do_not_eat_input() { + let now = Instant::now(); + let mut collector = TerminalColors::default(); + for body in [ + "11;rgb:12345/0/0", + "11;rgb:1//3", + "11;#12345", + "11;rgb:1/2/3/4", + ] { + let mut expected = vec![key(KeyCode::Char(']'), KeyModifiers::ALT)]; + expected.extend( + body.chars() + .map(|ch| key(KeyCode::Char(ch), KeyModifiers::NONE)), + ); + expected.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL)); + for event in &expected { + collector.push(event.clone(), now); + } + assert_eq!(drain(&mut collector), expected); + } + let escape = key(KeyCode::Esc, KeyModifiers::NONE); + collector.push(escape.clone(), now); + assert_eq!(collector.pop(), None); + collector.expire(now + ESCAPE_TIMEOUT); + assert_eq!(collector.pop(), Some(escape)); + assert_eq!(collector.pop(), None); + let start = key(KeyCode::Char(']'), KeyModifiers::ALT); + collector.push(start.clone(), now + Duration::from_secs(1)); + collector.expire(now + Duration::from_secs(1) + QUERY_TIMEOUT); + assert_eq!(collector.pop(), Some(start)); + assert_eq!(collector.pop(), None); +} diff --git a/tests/unit/tui.rs b/tests/unit/tui.rs new file mode 100644 index 0000000..d405a9c --- /dev/null +++ b/tests/unit/tui.rs @@ -0,0 +1,15226 @@ +use super::*; +use crate::plugin::TextPosition; +use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, +}; + +struct Directory(PathBuf); + +impl Directory { + fn new() -> Self { + static NEXT_ID: AtomicUsize = AtomicUsize::new(0); + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("vamp-tui-test-{}-{id}", std::process::id())); + fs::create_dir(&path).unwrap(); + Self(path) + } + + fn file(&self, name: &str, text: &str) -> PathBuf { + let path = self.0.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, text).unwrap(); + path + } +} + +impl Drop for Directory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[allow(clippy::too_many_arguments)] +fn rendered_whitespace_line( + source: &str, + left: usize, + width: u16, + characters: &BTreeMap, + scope: WhitespaceScope, + strings: &[SyntaxSpan], + newline: bool, + sanitize_controls: bool, +) -> ratatui::buffer::Buffer { + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 1)).unwrap(); + terminal + .draw(|frame| { + let area = frame.area(); + frame.render_widget( + Paragraph::new(visible_text( + source, + left, + usize::from(width), + sanitize_controls, + )) + .style( + Style::default() + .fg(Color::Green) + .bg(Color::Yellow) + .add_modifier(Modifier::UNDERLINED), + ), + area, + ); + paint_whitespace( + frame, + source, + area, + left, + characters, + scope, + strings, + newline, + sanitize_controls, + ); + }) + .unwrap(); + terminal.backend().buffer().clone() +} + +#[test] +fn whitespace_scope_uses_the_source_prefix_and_dims_only_marker_cells() { + let characters = BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{3000}', '_'), ('\n', '↵')]); + for (scope, source, left, width, expected) in [ + (WhitespaceScope::All, "\t x \t ", 0, 10, "→ ·x·→·↵"), + (WhitespaceScope::Leading, "\t x \t ", 0, 10, "→ ·x ↵"), + (WhitespaceScope::Leading, "\t \u{3000}", 0, 8, "→ ·_ ↵"), + (WhitespaceScope::Leading, " x ", 5, 4, "·x ↵"), + (WhitespaceScope::Leading, "x ", 1, 3, " ↵"), + (WhitespaceScope::Leading, "", 0, 1, "↵"), + ] { + let buffer = + rendered_whitespace_line(source, left, width, &characters, scope, &[], true, false); + for (x, symbol) in expected.chars().enumerate() { + let cell = &buffer[(x as u16, 0)]; + assert_eq!( + cell.symbol(), + symbol.to_string(), + "{scope:?}, {source:?}, left={left}" + ); + let marker = ['·', '→', '_', '↵'].contains(&symbol); + assert_eq!(cell.modifier.contains(Modifier::DIM), marker); + assert_eq!(cell.fg, if marker { Color::Reset } else { Color::Green }); + assert_eq!(cell.bg, Color::Yellow); + assert!(cell.modifier.contains(Modifier::UNDERLINED)); + } + } + for (source, left, newline, expected) in [ + ("x", 0, false, "x "), + ("x", 1, true, "↵ "), + ("x", 2, true, " "), + ] { + let buffer = rendered_whitespace_line( + source, + left, + 2, + &characters, + WhitespaceScope::All, + &[], + newline, + false, + ); + assert_eq!( + buffer + .content + .iter() + .map(|cell| cell.symbol()) + .collect::(), + expected, + ); + } +} + +#[test] +fn buffer_newline_markers_use_final_wrap_caret_cells_not_soft_wrap_or_eof() { + let directory = Directory::new(); + for tail in ["last", ""] { + for scope in [WhitespaceScope::All, WhitespaceScope::Leading] { + let source = format!("abcd\n\n x \n{tail}"); + let path = directory.file("newlines.txt", &source); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')])); + editor.set_whitespace_scope(scope); + editor.set_highlight_trailing_whitespace(true); + editor.execute("word_wrap.toggle"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(6, 12)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let area = views.document.geometry.unwrap().1; + let area = Rect::new( + area.right() - views.document.text_width as u16, + area.y, + views.document.text_width as u16, + area.height, + ); + assert_eq!(area.width, 4); + let buffer = terminal.backend().buffer(); + for row in [1, 2, 4] { + let cell = &buffer[(area.x, area.y + row)]; + assert_eq!(cell.symbol(), "↵"); + assert!(cell.modifier.contains(Modifier::DIM)); + assert_eq!(cell.fg, Color::Reset); + assert_ne!(cell.bg, Color::Red); + } + assert_eq!( + buffer + .content + .iter() + .filter(|cell| cell.symbol() == "↵") + .count(), + 3, + ); + assert_eq!(buffer[(area.x + 3, area.y)].symbol(), "d"); + assert!( + !buffer[(area.x + 3, area.y)] + .modifier + .contains(Modifier::DIM) + ); + assert_eq!( + buffer[(area.x, area.y + 5)].symbol(), + if tail.is_empty() { " " } else { "l" } + ); + for offset in [0, 1] { + assert_eq!(buffer[(area.x + offset, area.y + 3)].symbol(), "·"); + } + let trailing = &buffer[(area.x + 3, area.y + 3)]; + assert_eq!( + trailing.symbol(), + if scope == WhitespaceScope::All { + "·" + } else { + " " + } + ); + assert_eq!(trailing.bg, Color::Red); + assert_eq!( + trailing.modifier.contains(Modifier::DIM), + scope == WhitespaceScope::All + ); + editor.handle(Input::Char('V')); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let newline = &terminal.backend().buffer()[(area.x, area.y + 1)]; + assert_eq!(newline.symbol(), "↵"); + assert_eq!(newline.bg, Color::Blue); + assert!(newline.modifier.contains(Modifier::DIM)); + assert_eq!(editor.lines().join("\n"), source); + assert!(!editor.dirty()); + assert_eq!(fs::read_to_string(path).unwrap(), source); + } + } +} + +#[test] +fn historical_buffer_markers_retain_real_final_newlines_and_scope() { + let mut file = syntax_diff_file("history.txt", "old\n tail ", "new\n tail "); + file.before_eof = true; + file.after_eof = true; + file.hunks = crate::diff::line_hunks(&file.before, true, &file.after, true); + let mut editor = readonly_diff_editor(file); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')])); + editor.set_whitespace_scope(WhitespaceScope::Leading); + editor.set_highlight_trailing_whitespace(true); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); + let mut views = BufferViewports::default(); + render_native_diff(&mut terminal, &mut views, &mut editor); + let path = editor + .interaction_documents() + .find(|document| document.id == editor.buffer_id()) + .unwrap() + .path + .unwrap() + .to_owned(); + editor + .navigate_lsp_to(path, definition_point(1, 0), None) + .unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(editor.diff().is_none()); + assert!(editor.mutation_blocked()); + let area = views.document.geometry.unwrap().1; + let area = Rect::new( + area.right() - views.document.text_width as u16, + area.y, + views.document.text_width as u16, + area.height, + ); + let buffer = terminal.backend().buffer(); + for (x, y, symbol) in [(3, 0, "↵"), (0, 1, "·"), (1, 1, "·"), (7, 1, "↵")] { + let cell = &buffer[(area.x + x, area.y + y)]; + assert_eq!(cell.symbol(), symbol); + assert!(cell.modifier.contains(Modifier::DIM)); + } + assert_eq!(buffer[(area.x + 6, area.y + 1)].symbol(), " "); + assert_eq!(buffer[(area.x + 6, area.y + 1)].bg, Color::Red); + assert!( + !buffer[(area.x + 6, area.y + 1)] + .modifier + .contains(Modifier::DIM) + ); + assert_eq!(buffer[(area.x, area.y + 2)].symbol(), " "); + assert_eq!(editor.lines().join("\n"), "old\n tail \n"); + assert!(!editor.dirty()); +} + +#[test] +fn diff_newline_markers_cover_both_sides_without_hatch_or_soft_wrap_markers() { + for mode in [ViewMode::Split, ViewMode::Unified] { + for wrapped in [false, true] { + for (before_eof, after_eof) in [(false, true), (true, false)] { + for scope in [WhitespaceScope::All, WhitespaceScope::Leading] { + let length = if wrapped { 220 } else { 60 }; + let before = format!(" old{} \t", "x".repeat(length)); + let after = format!("\0 new{} \t\n \nlast", "y".repeat(length)); + let mut file = syntax_diff_file("newlines.txt", &before, &after); + file.before_eof = before_eof; + file.after_eof = after_eof; + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_diff_display_options(mode, SplitAxis::Vertical); + install_readonly_diff(&mut editor, file); + editor.set_whitespace_characters(BTreeMap::from([ + (' ', '␣'), + ('\t', '→'), + ('\n', '↵'), + ])); + editor.set_whitespace_scope(scope); + editor.set_highlight_trailing_whitespace(true); + if wrapped { + editor.execute("word_wrap.toggle"); + } + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(240, 40)).unwrap(); + let mut views = BufferViewports::default(); + render_native_diff(&mut terminal, &mut views, &mut editor); + let buffer = terminal.backend().buffer(); + let geometry = views.panels.diff.as_ref().unwrap(); + let mut text_cells = Vec::new(); + for (region, &width) in geometry.regions[1..].iter().zip(&geometry.text_widths) + { + let inner = diff_inner(region.0); + for y in inner.y..inner.bottom() { + for x in inner.right() - width..inner.right() { + text_cells.push(&buffer[(x, y)]); + } + } + } + // The unwrapped pane must expose each line end too. + if !wrapped { + assert!( + views + .panels + .diff + .as_ref() + .unwrap() + .text_widths + .iter() + .filter(|&&width| width > 0) + .all(|&width| width >= 72) + ); + } + assert_eq!( + text_cells + .iter() + .filter(|cell| cell.symbol() == "↵") + .count(), + 2 + usize::from(before_eof) + usize::from(after_eof), + "{mode:?}, wrap={wrapped}, before={before_eof}, after={after_eof}", + ); + for cell in text_cells + .iter() + .filter(|cell| ["␣", "→", "↵"].contains(&cell.symbol())) + { + assert!(cell.modifier.contains(Modifier::DIM)); + assert_eq!(cell.fg, Color::Reset); + } + for cell in text_cells.iter().filter(|cell| cell.symbol() == "↵") { + assert_ne!(cell.bg, Color::Red); + } + assert!(text_cells.iter().any(|cell| cell.symbol() == "�")); + assert_eq!( + text_cells + .iter() + .filter(|cell| cell.symbol() == "→") + .count(), + if scope == WhitespaceScope::All { 2 } else { 0 }, + ); + assert_eq!( + text_cells + .iter() + .filter(|cell| cell.symbol() == "␣") + .count(), + if scope == WhitespaceScope::All { 8 } else { 4 }, + ); + if mode == ViewMode::Split { + assert!(buffer.content.iter().any(|cell| cell.symbol() == "╱")); + } + } + } + } + } +} + +#[test] +fn exact_width_diff_newlines_use_the_existing_extra_caret_segment() { + for mode in [ViewMode::Split, ViewMode::Unified] { + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap(); + let mut reference = Editor::open(None, vec![]).unwrap(); + reference.set_diff_display_options(mode, SplitAxis::Vertical); + install_readonly_diff(&mut reference, syntax_diff_file("exact.txt", "old", "new")); + let mut views = BufferViewports::default(); + render_native_diff(&mut terminal, &mut views, &mut reference); + let mut widths = views.panels.diff.as_ref().unwrap().text_widths; + if mode == ViewMode::Unified { + let width = widths[0].max(widths[1]); + widths.fill(width); + } + let mut file = syntax_diff_file( + "exact.txt", + &"o".repeat(usize::from(widths[0])), + &"n".repeat(usize::from(widths[1])), + ); + file.before_eof = true; + file.after_eof = true; + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_diff_display_options(mode, SplitAxis::Vertical); + install_readonly_diff(&mut editor, file); + editor.set_whitespace_characters(BTreeMap::from([('\n', '↵')])); + editor.execute("word_wrap.toggle"); + let mut views = BufferViewports::default(); + render_native_diff(&mut terminal, &mut views, &mut editor); + let buffer = terminal.backend().buffer(); + let newlines: Vec<_> = buffer + .content + .iter() + .enumerate() + .filter_map(|(index, cell)| (cell.symbol() == "↵").then_some(index)) + .collect(); + assert_eq!(newlines.len(), 2, "{mode:?}: {}", screen_text(&terminal)); + for index in newlines { + let x = (index % 80) as u16; + let y = (index / 80) as u16; + let symbol = buffer[(x, y - 1)].symbol(); + assert!(["o", "n"].contains(&symbol)); + let width = widths[usize::from(symbol == "n")]; + for offset in 0..width { + assert_eq!(buffer[(x + offset, y - 1)].symbol(), symbol); + } + assert!(buffer.content[index].modifier.contains(Modifier::DIM)); + } + } +} + +#[test] +fn literal_newlines_stay_unmarked_in_buffers_and_each_diff_source() { + let directory = Directory::new(); + let closed = "text = \"\"\"a b\nc d\"\"\""; + let second = "text = \"\"\"e f\ng h\"\"\""; + for source in [closed, second] { + let mut editor = Editor::open( + Some(directory.file("literal.py", &format!("{source}\n"))), + vec![], + ) + .unwrap(); + editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')])); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!( + terminal + .backend() + .buffer() + .content + .iter() + .filter(|cell| cell.symbol() == "↵") + .count(), + 1, + ); + assert!(screen_text(&terminal).contains(if source == closed { + "c d\"\"\"↵" + } else { + "g h\"\"\"↵" + })); + } + for mode in [ViewMode::Split, ViewMode::Unified] { + let mut file = syntax_diff_file("literal.py", closed, second); + file.before_eof = true; + file.after_eof = true; + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_diff_display_options(mode, SplitAxis::Vertical); + install_readonly_diff(&mut editor, file); + editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')])); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap(); + let mut views = BufferViewports::default(); + render_native_diff(&mut terminal, &mut views, &mut editor); + assert_eq!( + terminal + .backend() + .buffer() + .content + .iter() + .filter(|cell| cell.symbol() == "↵") + .count(), + 2, + "{mode:?}: {}", + screen_text(&terminal), + ); + for expected in ["a b", "c d\"\"\"↵", "e f", "g h\"\"\"↵"] { + assert!(screen_text(&terminal).contains(expected)); + } + } +} + +#[test] +fn whitespace_markers_preserve_multiline_raw_and_injected_strings() { + let directory = Directory::new(); + for (path, source, expected) in [ + ( + "raw.rs", + "let text = r#\"a b\"#; ", + &["let·text·=·r#\"a b\"#;·"][..], + ), + ( + "unicode.rs", + "let text = \"α β\"; ", + &["let·text·=·\"α β\";·"][..], + ), + ( + "multiline.py", + "text = \"\"\"a b\nc d\"\"\" ", + &["text·=·\"\"\"a b", "c d\"\"\"·"][..], + ), + ( + "template.js", + "const text = `a b ${ x + y }`; ", + &["const·text·=·`a b ${ x + y }`;·"][..], + ), + ( + "injected.cpp", + "auto text = R\"cpp(int x = 1;)cpp\"; ", + &["auto·text·=·R\"cpp(int x = 1;)cpp\";·"][..], + ), + ( + "heredoc.rb", + "text = <<~TEXT\n a b\nTEXT\n", + &["text·=·<<~TEXT", " a b"][..], + ), + ] { + let mut editor = Editor::open(Some(directory.file(path, source)), vec![]).unwrap(); + editor.set_whitespace_characters(BTreeMap::from([(' ', '·')])); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let rendered: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + for expected in expected { + assert!(rendered.contains(expected), "{path}: {rendered}"); + } + } + let characters = BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{a0}', '⍽'), ('\u{3000}', '_')]); + for (source, left, width, cells) in [ + ( + "let text = \"α β\"; ", + 12, + 3, + vec![(0, "α"), (1, " "), (2, "β")], + ), + ( + "let s = \"\t \u{3000}\u{a0} \"; ", + 0, + 20, + vec![ + (3, "·"), + (5, "·"), + (7, "·"), + (9, " "), + (10, " "), + (11, " "), + (12, " "), + (13, "\u{3000}"), + (15, "\u{a0}"), + (16, " "), + (19, "·"), + ], + ), + ( + "let s = \"\t \u{3000}\u{a0} \"; ", + 10, + 7, + vec![ + (0, " "), + (1, " "), + (2, " "), + (3, "\u{3000}"), + (5, "\u{a0}"), + (6, " "), + ], + ), + ] { + let mut syntax = SyntaxCache::new(); + syntax.update_text(source, std::path::Path::new("strings.rs")); + let buffer = rendered_whitespace_line( + source, + left, + width, + &characters, + WhitespaceScope::All, + syntax.string_spans(0), + false, + false, + ); + for (x, symbol) in cells { + assert_eq!( + buffer[(x, 0)].symbol(), + symbol, + "{source:?}, left={left}, x={x}" + ); + assert_eq!( + buffer[(x, 0)].modifier.contains(Modifier::DIM), + symbol == "·" + ); + } + } +} + +#[test] +fn whitespace_markers_leave_string_contents_literal_in_buffers_and_diffs() { + let directory = Directory::new(); + let source = "let text = \"a b\"; "; + let mut editor = Editor::open(Some(directory.file("strings.rs", source)), vec![]).unwrap(); + editor.set_whitespace_characters(BTreeMap::from([(' ', '·')])); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let rendered: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(rendered.contains("let·text·=·\"a b\";·"), "{rendered}"); + let area = views.document.geometry.unwrap().1; + let area = Rect::new( + area.right() - views.document.text_width as u16, + area.y, + views.document.text_width as u16, + area.height, + ); + for column in 11..16 { + let cell = &terminal.backend().buffer()[(area.x + column, area.y)]; + assert_eq!(cell.fg, Color::Green); + assert!(!cell.modifier.contains(Modifier::DIM)); + } + let marker = &terminal.backend().buffer()[(area.x + 3, area.y)]; + assert_eq!(marker.fg, Color::Reset); + assert!(marker.modifier.contains(Modifier::DIM)); + editor + .apply(vec![crate::editor::Effect::Replace( + "let text = a + b; ".into(), + )]) + .unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let rendered: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(rendered.contains("let·text·=·a·+·b;·"), "{rendered}"); + + for mode in [ViewMode::Split, ViewMode::Unified] { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_diff_display_options(mode, SplitAxis::Vertical); + install_readonly_diff( + &mut editor, + syntax_diff_file("strings.rs", source, "\0let text = \"c d\"; "), + ); + editor.set_whitespace_characters(BTreeMap::from([(' ', '·')])); + let mut views = BufferViewports::default(); + render_native_diff(&mut terminal, &mut views, &mut editor); + let rendered: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + for expected in ["let·text·=·\"a b\";·", "�let·text·=·\"c d\";·"] { + assert!(rendered.contains(expected), "{mode:?}: {rendered}"); + } + for (index, cell) in terminal.backend().buffer().content.iter().enumerate() { + if cell.symbol() == "\"" { + let literal_space = &terminal.backend().buffer().content[index + 2]; + // Opening quotes are followed by a letter and its literal space. + if literal_space.symbol() == " " { + assert_eq!(literal_space.fg, Color::Green); + assert!(!literal_space.modifier.contains(Modifier::DIM)); + } + } + } + } +} + +#[test] +fn whitespace_characters_preserve_source_geometry_and_overlay_styles() { + let characters = BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{a0}', '⍽'), ('\u{3000}', '_')]); + let source = "a \tb\u{a0}\u{3000} c"; + for (source, left, width, expected) in [ + ( + source, + 0, + 10, + vec!["a", "·", "→", " ", "b", "⍽", "_", " ", "·", "c"], + ), + (source, 3, 6, vec![" ", "b", "⍽", "_", " ", "·"]), + (source, 7, 2, vec![" ", "·"]), + (source, 6, 1, vec!["_"]), + (" \u{301}", 0, 1, vec![" \u{301}"]), + ("", 0, 1, vec![" "]), + ] { + let buffer = rendered_whitespace_line( + source, + left, + width, + &characters, + WhitespaceScope::All, + &[], + false, + false, + ); + for (x, symbol) in expected.into_iter().enumerate() { + let cell = &buffer[(x as u16, 0)]; + assert_eq!(cell.symbol(), symbol, "{source:?}, left={left}, x={x}"); + let marker = ["·", "→", "⍽", "_"].contains(&symbol); + assert_eq!(cell.modifier.contains(Modifier::DIM), marker); + assert!(cell.modifier.contains(Modifier::UNDERLINED)); + assert_eq!(cell.fg, if marker { Color::Reset } else { Color::Green }); + assert_eq!(cell.bg, Color::Yellow); + } + } + + let directory = Directory::new(); + let source = "a b\t \u{3000}\n \t\n\nabcdefg "; + let path = directory.file("markers.txt", source); + let config = directory.file( + "config.rb", + r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\u3000" => "_" }; c.highlight_trailing_whitespace = true; c.git_gutter = "none" }"#, + ); + let mut session = EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap(); + session.execute("word_wrap.toggle"); + let token = semantic_token( + 0, + 0, + 6, + SemanticTokenKind::Type, + SemanticTokenModifier::Deprecated as u16, + ); + let editor = session.editor_mut(); + editor + .replace_semantic_tokens( + editor.buffer_id(), + editor.text_revision(), + Some(Arc::from([token])), + ) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 10)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, session.editor_mut())) + .unwrap(); + let buffer = terminal.backend().buffer(); + for (x, y, marker) in [ + (3, 0, "·"), + (5, 0, "→"), + (6, 0, "·"), + (7, 0, "_"), + (2, 1, "·"), + (3, 1, "→"), + (9, 3, "·"), + (2, 4, "·"), + ] { + assert_eq!(buffer[(x, y)].symbol(), marker, "({x}, {y})"); + assert!(buffer[(x, y)].modifier.contains(Modifier::DIM)); + if y == 0 { + assert_eq!(buffer[(x, y)].fg, Color::Reset); + assert!(buffer[(x, y)].modifier.contains(Modifier::CROSSED_OUT)); + } + } + assert_eq!(buffer[(8, 0)].symbol(), " "); + assert!(!buffer[(8, 0)].modifier.contains(Modifier::DIM)); + assert!(!buffer[(4, 0)].modifier.contains(Modifier::DIM)); + assert_eq!(buffer[(4, 0)].fg, semantic_style(&token).fg.unwrap()); + assert!(buffer[(4, 0)].modifier.contains(Modifier::CROSSED_OUT)); + assert!(!buffer[(2, 2)].modifier.contains(Modifier::DIM)); + assert_eq!(buffer[(7, 0)].bg, Color::Red); + assert_eq!(buffer[(2, 2)].symbol(), " "); + session.handle(Input::End); + session.handle(Input::Char('v')); + session.handle(Input::Left); + terminal + .draw(|frame| views.draw(frame, session.editor_mut())) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(6, 0)].symbol(), "·"); + assert_eq!(terminal.backend().buffer()[(6, 0)].bg, Color::Blue); + assert!( + terminal.backend().buffer()[(6, 0)] + .modifier + .contains(Modifier::DIM) + ); + assert_eq!(session.editor().lines().join("\n"), source); + assert!(!session.editor().dirty()); + assert_eq!(fs::read_to_string(path).unwrap(), source); + + session + .editor_mut() + .set_whitespace_characters(BTreeMap::new()); + terminal + .draw(|frame| views.draw(frame, session.editor_mut())) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(6, 0)].symbol(), " "); + assert_eq!(terminal.backend().buffer()[(7, 0)].symbol(), "\u{3000}"); +} + +#[test] +fn trailing_whitespace_is_opt_in_and_preserves_wrapping_and_selection() { + let directory = Directory::new(); + let source = "a b\t \u{3000}\n \t\n\nabcdefg "; + let path = directory.file("whitespace.txt", source); + let config = directory.file( + "config.rb", + "Vamp.configure { |c| c.highlight_trailing_whitespace = true; c.git_gutter = 'none' }", + ); + let mut session = EditorSession::open(Some(path), vec![], Some(&config)).unwrap(); + session.execute("word_wrap.toggle"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 10)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, session.editor_mut())) + .unwrap(); + let buffer = terminal.backend().buffer(); + for (y, range) in [(0, 5..9), (1, 2..6), (3, 9..10), (4, 2..3)] { + for x in 2..10 { + // The backend skips the covered cell of a wide grapheme. + if (x, y) == (8, 0) { + continue; + } + assert_eq!( + buffer[(x, y)].bg == Color::Red, + range.contains(&x), + "({x}, {y})" + ); + } + } + assert_eq!(buffer[(2, 2)].bg, Color::Reset); + session.handle(Input::End); + session.handle(Input::Char('v')); + session.handle(Input::Left); + terminal + .draw(|frame| views.draw(frame, session.editor_mut())) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(6, 0)].bg, Color::Blue); + assert_eq!(session.editor().lines().join("\n"), source); + assert!(!session.editor().dirty()); + + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace("x \t".into())]) + .unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_ne!(terminal.backend().buffer()[(7, 0)].bg, Color::Red); +} + +#[test] +fn trailing_whitespace_covers_both_diff_sides_and_unified_rows() { + for mode in [ViewMode::Split, ViewMode::Unified] { + for wrapped in [false, true] { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_diff_display_options(mode, crate::panes::SplitAxis::Vertical); + install_readonly_diff( + &mut editor, + syntax_diff_file("whitespace.txt", "old \t", "new \t"), + ); + editor.set_highlight_trailing_whitespace(true); + editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\t', '→')])); + if wrapped { + editor.execute("word_wrap.toggle"); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); + let mut views = BufferViewports::default(); + render_native_diff(&mut terminal, &mut views, &mut editor); + let buffer = terminal.backend().buffer(); + for word in ["old", "new"] { + let start = buffer + .content + .iter() + .enumerate() + .find_map(|(index, cell)| { + (cell.symbol() == &word[..1] + && buffer + .content + .get(index + 1) + .is_some_and(|cell| cell.symbol() == &word[1..2]) + && buffer + .content + .get(index + 2) + .is_some_and(|cell| cell.symbol() == &word[2..3])) + .then_some(index) + }) + .unwrap(); + for offset in 3..8 { + assert_eq!( + buffer.content[start + offset].bg, + Color::Red, + "{mode:?} {wrapped} {word}" + ); + } + assert_ne!(buffer.content[start + 8].bg, Color::Red); + assert_eq!(buffer.content[start + 3].symbol(), "·"); + assert_eq!(buffer.content[start + 4].symbol(), "→"); + } + } + } +} + +#[test] +fn scroll_distance_follows_document_and_diff_visual_rows_and_columns() { + let directory = Directory::new(); + let text = (0..80) + .map(|row| format!("row-{row:02} {}", "x".repeat(160))) + .collect::>() + .join("\n"); + for diff in [false, true] { + for wrapped in [false, true] { + for distance in [Some(0), Some(2), None, Some(usize::MAX)] { + let mut editor = if diff { + let mut editor = readonly_diff_editor(syntax_diff_file( + "history.txt", + &text, + &text.replace('x', "y"), + )); + editor.handle(Input::Char('\t')); + editor + } else { + Editor::open(Some(directory.file("document.txt", &text)), vec![]).unwrap() + }; + editor.set_scroll_distance(distance); + if wrapped { + editor.execute("word_wrap.toggle"); + } + let mut views = BufferViewports::default(); + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(80, 16)).unwrap(); + let render = |terminal: &mut Terminal, + views: &mut BufferViewports, + editor: &mut Editor| { + if diff { + render_native_diff(terminal, views, editor) + } else { + terminal.draw(|frame| views.draw(frame, editor)).unwrap(); + terminal.get_cursor_position().unwrap() + } + }; + render(&mut terminal, &mut views, &mut editor); + for _ in 0..30 { + editor.handle(Input::Down); + render(&mut terminal, &mut views, &mut editor); + } + let viewport = if diff { + diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0) + } else { + views.document.geometry.unwrap().1 + }; + let height = usize::from(viewport.height); + let expected = if distance.is_none_or(|distance| distance >= height / 2) { + height / 2 + } else { + height - distance.unwrap() - 1 + }; + let caret = terminal.get_cursor_position().unwrap(); + assert_eq!( + usize::from(caret.y - viewport.y), + expected, + "diff={diff}, wrap={wrapped}, distance={distance:?}" + ); + if !wrapped && distance == Some(2) { + editor.handle(Input::Up); + let reversed = render(&mut terminal, &mut views, &mut editor); + assert_eq!(reversed.y + 1, caret.y); + } + if !wrapped { + for _ in 0..120 { + editor.handle(Input::Right); + render(&mut terminal, &mut views, &mut editor); + } + let left = if diff { + editor.diff().unwrap().left_before + } else { + views.offsets[&editor.buffer_id()].1 + }; + let width = if diff { + usize::from(views.panels.diff.as_ref().unwrap().text_widths[0]) + } else { + views.document.text_width + }; + let expected = if distance.is_none_or(|distance| distance >= width / 2) { + width / 2 + } else { + width - distance.unwrap() - 1 + }; + assert_eq!(editor.cursor().column - left, expected); + } + if distance == Some(usize::MAX) { + terminal.backend_mut().resize(60, 12); + terminal.resize(Rect::new(0, 0, 60, 12)).unwrap(); + let caret = render(&mut terminal, &mut views, &mut editor); + let viewport = if diff { + diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0) + } else { + views.document.geometry.unwrap().1 + }; + assert_eq!(caret.y - viewport.y, viewport.height / 2); + } + } + } + } +} + +fn screen_text(terminal: &Terminal) -> String { + let buffer = terminal.backend().buffer(); + let area = buffer.area; + (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") +} + +#[test] +fn resolver_renders_aligned_native_draft_alternative_and_separate_history() { + let directory = Directory::new(); + let jj = |args: &[&str]| { + let output = std::process::Command::new("jj") + .current_dir(&directory.0) + .env("JJ_CONFIG", "/dev/null") + .args([ + "--config=user.name=Resolver", + "--config=user.email=resolver@example.test", + "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", + "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", + ]) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() + }; + let text = |side: &str| { + format!( + "value = {side};\n{}{}last = {side};\n", + if side == "third" { + "third-only third-only third-only third-only third-only third-only third-only\n" + } else { + "" + }, + (0..60) + .map(|row| format!("context {row:02}\n")) + .collect::() + ) + }; + jj(&["git", "init", "--no-colocate", "."]); + jj(&["config", "set", "--repo", "user.name", "Resolver"]); + jj(&[ + "config", + "set", + "--repo", + "user.email", + "resolver@example.test", + ]); + for path in ["first.txt", "second.txt"] { + directory.file(path, &text("base")); + } + fs::write(directory.0.join("binary.bin"), b"base\0").unwrap(); + jj(&["describe", "-m", "base"]); + let base = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); + jj(&["new", &base]); + for path in ["first.txt", "second.txt"] { + directory.file(path, &text("left")); + } + fs::write(directory.0.join("binary.bin"), b"left\0").unwrap(); + jj(&["describe", "-m", "left"]); + let left = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); + jj(&["new", &base]); + for path in ["first.txt", "second.txt"] { + directory.file(path, &text("right")); + } + fs::write(directory.0.join("binary.bin"), b"right\0").unwrap(); + jj(&["describe", "-m", "right"]); + let right = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); + jj(&["new", &base]); + for path in ["first.txt", "second.txt"] { + directory.file(path, &text("third")); + } + fs::write(directory.0.join("binary.bin"), b"third\0").unwrap(); + jj(&["describe", "-m", "third"]); + let third = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); + jj(&["new", &left, &right, &third, "-m", "conflicts"]); + let operation = || { + jj(&[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id", + ]) + }; + let before_browsing = operation(); + let config = directory.file( + "config.rb", + "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n", + ); + let mut session = EditorSession::open( + Some(directory.0.join("first.txt")), + vec!["text".into()], + Some(&config), + ) + .unwrap(); + let ready = |session: &mut EditorSession| { + for _ in 0..15_000 { + session.poll(); + if session.editor().resolver().is_some_and(|resolver| { + !resolver.loading + && !resolver + .files + .iter() + .any(|file| file.status == ResolverStatus::Checking) + }) { + assert!( + session.editor().resolver().unwrap().error.is_none(), + "{}", + session.editor().message() + ); + return; + } + std::thread::sleep(Duration::from_millis(1)); + } + panic!("Resolver did not settle: {}", session.editor().message()); + }; + session.handle(Input::Char('/')); + for ch in "context 00".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Enter); + session.handle(Input::Home); + session.handle(Input::Char('i')); + session.handle(Input::Paste("opening ".into())); + session.handle(Input::Escape); + session.execute("resolve"); + ready(&mut session); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + let mut views = BufferViewports::default(); + views.set_theme(Some(DARK_DIFF_THEME)); + let render = |terminal: &mut Terminal, + views: &mut BufferViewports, + session: &mut EditorSession| { + terminal + .draw(|frame| views.draw_session(frame, session)) + .unwrap(); + terminal.get_cursor_position().unwrap() + }; + let workspace = |terminal: &Terminal, + views: &BufferViewports, + editor: &Editor| { + let syntax = &views.syntax[&editor.buffer_id()]; + let visible = [editor.top_bar_config(), editor.bottom_bar_config()] + .map(|line| chrome_visible(editor, &chrome_groups(editor, line, syntax))); + document_layout(terminal.backend().buffer().area, visible)[1] + }; + let caret = render(&mut terminal, &mut views, &mut session); + let resolver = session.editor().resolver().unwrap(); + assert_eq!(resolver.files.len(), 3); + let current_file = resolver.current_file; + let draft_pane = resolver.draft_pane; + let alternative_pane = resolver.alternative_pane.unwrap(); + let alternative_index = resolver.selected_source; + let original_draft = session.editor().lines().to_vec(); + let geometry = views.panels.diff.as_ref().unwrap(); + let draft = geometry.regions[1].0; + let alternative = geometry.regions[2].0; + assert!(draft.contains(caret)); + assert_eq!(views.rects.len(), 2); + assert_eq!((draft.y, draft.height), (alternative.y, alternative.height)); + assert_eq!(draft.right(), alternative.x); + assert!(draft.height > 20); + assert!(views.panels.files.is_none()); + let screen = screen_text(&terminal); + assert!(screen.contains("unchanged lines hidden")); + assert!(!screen.contains("context 30")); + assert!(!screen.contains("<<<<<<<") && !screen.contains("%%%%%%%")); + let line_at = |terminal: &Terminal, area: Rect, y: u16| { + (area.x..area.right()) + .map(|x| terminal.backend().buffer()[(x, y)].symbol()) + .collect::() + }; + let left_heading = line_at(&terminal, draft, draft.y); + let right_heading = line_at(&terminal, alternative, alternative.y); + assert!(left_heading.contains("Resolution") && left_heading.contains("editable")); + assert!(right_heading.contains("Other version") && right_heading.contains("read-only")); + for heading in [&left_heading, &right_heading] { + assert!(!heading.contains("Branch changes")); + assert!(!heading.contains(resolver.files[current_file].path.to_string_lossy().as_ref())); + assert!(!heading.split_whitespace().any(|word| word == "M")); + } + assert!( + line_at(&terminal, alternative, alternative.bottom() - 1) + .contains(&resolver.files[current_file].sources[alternative_index].label) + ); + assert!( + line_at(&terminal, draft, draft.bottom() - 1) + .chars() + .all(|ch| !ch.is_alphanumeric()), + "The output must not repeat its seed source metadata" + ); + for (side, area) in [draft, alternative].into_iter().enumerate() { + let content = diff_inner(area); + let x = content.right() - geometry.text_widths[side]; + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(x, content.y)].symbol(), "v"); + assert_eq!( + buffer[(x, content.y)].bg, + views.panels.diff_colors.row[side] + ); + assert_eq!( + buffer[(x + 8, content.y)].bg, + views.panels.diff_colors.character[side] + ); + } + assert_ne!( + line_at(&terminal, diff_inner(draft), draft.y + 1), + line_at(&terminal, diff_inner(alternative), alternative.y + 1) + ); + for area in [draft, alternative] { + assert!( + line_at(&terminal, diff_inner(area), area.y + 2).contains("opening context 00"), + "Preexisting unsaved working-buffer edits must appear in both projections" + ); + } + let diff = session.editor().diff().unwrap(); + let file = &diff.comparison.as_ref().unwrap().files[diff.file]; + let mut reference_file = syntax_diff_file( + &file.path.to_string_lossy(), + &file.before.join("\n"), + &file.after.join("\n"), + ); + reference_file.before_eof = file.before_eof; + reference_file.after_eof = file.after_eof; + reference_file.hunks = crate::diff::line_hunks( + &reference_file.before, + reference_file.before_eof, + &reference_file.after, + reference_file.after_eof, + ); + let mut reference = Editor::open(None, vec![]).unwrap(); + for _ in 1..session.editor().tab_count() { + reference.execute("tab.new"); + } + reference + .set_chrome_config( + session.editor().top_bar_config().clone(), + session.editor().bottom_bar_config().clone(), + BTreeMap::new(), + ) + .unwrap(); + install_readonly_diff(&mut reference, reference_file); + reference.handle(Input::Ctrl('w')); + let cursor = session.editor().cursor(); + for ch in format!("{}g", cursor.row + 1).chars() { + reference.handle(Input::Char(ch)); + } + reference.handle(Input::Home); + for _ in 0..cursor.column { + reference.handle(Input::Right); + } + assert_eq!(reference.cursor(), cursor); + assert_eq!( + reference.diff_buffer_focus(), + session.editor().diff_buffer_focus() + ); + let mut reference_terminal = + Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + let mut reference_views = BufferViewports::default(); + reference_views.set_theme(Some(DARK_DIFF_THEME)); + for command in [ + None, + Some("diff.orientation.toggle"), + Some("diff.view.toggle"), + Some("diff.view.toggle"), + Some("diff.orientation.toggle"), + ] { + if let Some(command) = command { + session.execute(command); + reference.execute(command); + } + render(&mut terminal, &mut views, &mut session); + render_native_diff( + &mut reference_terminal, + &mut reference_views, + &mut reference, + ); + let actual = views.panels.diff.as_ref().unwrap(); + let expected = reference_views.panels.diff.as_ref().unwrap(); + assert_eq!(actual.view_mode, expected.view_mode); + assert_eq!(actual.split_axis, expected.split_axis); + assert_eq!(actual.text_widths, expected.text_widths); + for (side, ((actual_pane, _), (expected_pane, _))) in actual.regions[1..] + .iter() + .zip(&expected.regions[1..]) + .enumerate() + { + assert_eq!( + ( + actual_pane.x, + actual_pane.y, + actual_pane.width, + actual_pane.height + ), + ( + expected_pane.x, + expected_pane.y, + expected_pane.width, + expected_pane.height + ) + ); + let actual_text = Rect { + x: diff_inner(*actual_pane).right() - actual.text_widths[side], + width: actual.text_widths[side], + ..diff_inner(*actual_pane) + }; + let expected_text = Rect { + x: diff_inner(*expected_pane).right() - expected.text_widths[side], + width: expected.text_widths[side], + ..diff_inner(*expected_pane) + }; + for row in 0..actual_text.height { + assert_eq!( + line_at(&terminal, actual_text, actual_text.y + row), + line_at(&reference_terminal, expected_text, expected_text.y + row,), + "{command:?}, row {row}, full rows: {:?} / {:?}", + line_at(&terminal, diff_inner(*actual_pane), actual_text.y + row), + line_at( + &reference_terminal, + diff_inner(*expected_pane), + expected_text.y + row, + ), + ); + } + } + } + let folded_context_rows = screen_text(&terminal).matches("context").count(); + session.handle(Input::Char(']')); + reference.handle(Input::Char(']')); + render(&mut terminal, &mut views, &mut session); + render_native_diff( + &mut reference_terminal, + &mut reference_views, + &mut reference, + ); + let folds = |editor: &Editor| { + editor + .diff() + .unwrap() + .display_rows + .iter() + .filter_map(|row| match row { + DisplayRow::Fold { start, end } => Some((*start, *end)), + _ => None, + }) + .collect::>() + }; + assert_eq!(folds(session.editor()), folds(&reference)); + let expanded_context_rows = screen_text(&terminal).matches("context").count(); + assert!(expanded_context_rows > folded_context_rows); + assert_eq!( + expanded_context_rows, + screen_text(&reference_terminal).matches("context").count() + ); + let third_source = session.editor().resolver().unwrap().files[current_file] + .sources + .iter() + .position(|source| !source.base && source.text.starts_with("value = third;")) + .unwrap(); + session.execute(&format!("resolve.source {}", third_source + 1)); + render(&mut terminal, &mut views, &mut session); + assert!( + line_at(&terminal, diff_inner(alternative), alternative.y + 1).contains("value = third;") + ); + let right_content = diff_inner(alternative); + assert!( + (right_content.y..right_content.bottom()) + .any(|y| { line_at(&terminal, right_content, y).contains("opening context 00") }) + ); + session.execute("resolve.draft"); + assert_eq!(session.editor().lines(), original_draft); + session.execute(&format!("resolve.source {}", alternative_index + 1)); + session.execute("resolve.draft"); + + session.handle(Input::Char('\t')); + let caret = render(&mut terminal, &mut views, &mut session); + assert_eq!(session.editor().pane_id(), alternative_pane); + assert!(alternative.contains(caret)); + let readonly = session.editor().lines().to_vec(); + session.handle(Input::Char('i')); + session.handle(Input::Char('X')); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), readonly); + session.execute("resolve.draft"); + session.handle(Input::Home); + for _ in 0..8 { + session.handle(Input::Right); + } + session.handle(Input::Char('i')); + session.handle(Input::Paste("\ndraft-only".into())); + session.handle(Input::Escape); + render(&mut terminal, &mut views, &mut session); + let geometry = views.panels.diff.as_ref().unwrap(); + let right = diff_inner(geometry.regions[2].0); + assert!( + (right.y..right.bottom()) + .any(|y| { line_at(&terminal, right, y).chars().all(|ch| ch == '╱') }), + "The shorter alternative must render an aligned gap" + ); + let context_rows: Vec<_> = geometry.regions[1..] + .iter() + .map(|(area, _)| { + let content = diff_inner(*area); + (content.y..content.bottom()) + .find(|&y| line_at(&terminal, content, y).contains("context 00")) + .unwrap() + }) + .collect(); + assert_eq!(context_rows[0], context_rows[1]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), original_draft); + session.handle(Input::Down); + session.handle(Input::Home); + session.handle(Input::Char('i')); + session.handle(Input::Paste("manual ".into())); + session.handle(Input::Escape); + render(&mut terminal, &mut views, &mut session); + let geometry = views.panels.diff.as_ref().unwrap(); + let left = diff_inner(geometry.regions[1].0); + let right = diff_inner(geometry.regions[2].0); + let left_heading = line_at(&terminal, geometry.regions[1].0, geometry.regions[1].0.y); + let right_heading = line_at(&terminal, geometry.regions[2].0, geometry.regions[2].0.y); + assert!(left_heading.contains("unsaved")); + assert!(!right_heading.contains("unsaved")); + assert!(right_heading.contains("read-only")); + assert!(line_at(&terminal, left, left.y + 1).contains("manual opening context 00")); + assert!(line_at(&terminal, right, right.y + 1).contains("opening context 00")); + assert!(!line_at(&terminal, right, right.y + 1).contains("manual")); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), original_draft); + session.handle(Input::Up); + session.execute(&format!("resolve.use_source {}", alternative_index + 1)); + render(&mut terminal, &mut views, &mut session); + let taken = session.editor().lines().to_vec(); + assert_ne!(taken[0], original_draft[0]); + assert_eq!( + taken[61], original_draft[61], + "Taking a decision must not replace another hunk" + ); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), original_draft); + + session.handle(Input::Char(']')); + render(&mut terminal, &mut views, &mut session); + assert!(screen_text(&terminal).contains("context 10")); + let cursor = session.editor().cursor(); + let before_scroll = views.panels.diff.as_ref().unwrap().viewport.unwrap().2; + assert!(views.scroll( + &mut session, + document_wheel( + MouseEventKind::ScrollDown, + alternative.x + 1, + alternative.y + 1, + ) + )); + render(&mut terminal, &mut views, &mut session); + assert!(views.panels.diff.as_ref().unwrap().viewport.unwrap().2 > before_scroll); + assert_eq!(session.editor().pane_id(), alternative_pane); + assert_eq!(session.editor().diff_buffer_focus(), Some(Focus::After)); + assert_eq!(session.editor().cursor(), cursor); + let geometry = views.panels.diff.as_ref().unwrap(); + assert!(line_at(&terminal, diff_inner(geometry.regions[1].0), draft.y + 1).contains("context")); + assert!( + line_at( + &terminal, + diff_inner(geometry.regions[2].0), + alternative.y + 1 + ) + .contains("context") + ); + + session.execute("resolve.draft"); + let search_row = session + .editor() + .lines() + .iter() + .position(|line| line == "context 30") + .unwrap(); + for ch in format!("{}g", search_row + 1).chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Char('/')); + for ch in "context 30".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Enter); + let searched_caret = render(&mut terminal, &mut views, &mut session); + assert!(screen_text(&terminal).contains("context 30")); + assert_eq!( + terminal.backend().buffer()[(searched_caret.x, searched_caret.y)].bg, + Color::Yellow, + "source {:?} ({:?}), terminal {searched_caret:?}, caret {:?}, viewport {:?}, focus {:?}", + session.editor().cursor(), + session.editor().lines()[session.editor().cursor().row], + views.panels.caret, + views.panels.diff.as_ref().unwrap().viewport, + session.editor().diff_buffer_focus(), + ); + session.handle(Input::Char('v')); + session.handle(Input::Right); + render(&mut terminal, &mut views, &mut session); + assert_eq!( + terminal.backend().buffer()[(searched_caret.x, searched_caret.y)].bg, + Color::Blue + ); + session.handle(Input::Escape); + session.handle(Input::Left); + let searched_caret = render(&mut terminal, &mut views, &mut session); + let draft_cursor = session.editor().cursor(); + let draft_viewport = views.panels.diff.as_ref().unwrap().viewport; + session.execute("resolve.history"); + render(&mut terminal, &mut views, &mut session); + assert_eq!(views.rects.len(), 3); + for (key, title) in [ + ('e', "Explorer · Tree NORMAL"), + ('o', "Files · Tree INSERT"), + ] { + session.handle(Input::Char(' ')); + session.handle(Input::Char(key)); + let picker_caret = render(&mut terminal, &mut views, &mut session); + let panel = views.panels.files.unwrap().0; + assert!(panel.contains(picker_caret)); + assert!(screen_text(&terminal).contains(title)); + if key == 'e' { + assert_eq!(views.panels.diff.as_ref().unwrap().regions[0].0, panel); + assert!(views.rects.iter().all(|(_, area)| area.right() <= panel.x)); + } else { + assert_eq!( + panel, + floating_panel(workspace(&terminal, &views, session.editor())) + ); + } + session.handle(Input::Ctrl('w')); + render(&mut terminal, &mut views, &mut session); + assert!(views.panels.files.is_none()); + assert_eq!(views.rects.len(), 3); + } + assert!( + views + .rects + .iter() + .all(|(pane, _)| *pane != draft_pane && *pane != alternative_pane) + ); + let source_rows: Vec<_> = views + .rects + .iter() + .map(|(_, area)| line_at(&terminal, *area, area.y)) + .collect(); + assert!(source_rows.iter().any(|row| row.contains("base"))); + assert!(source_rows.iter().any(|row| row.contains("left"))); + assert!(source_rows.iter().any(|row| row.contains("right"))); + let history = &views.history[&views.history_key.unwrap()]; + for (column, (_, content)) in views.rects.iter().enumerate() { + let x = content.right() - history.widths[column] as u16; + let side = usize::from(column != 1); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(x, content.y)].symbol(), "v"); + assert_eq!( + buffer[(x, content.y)].bg, + views.panels.diff_colors.row[side] + ); + assert_eq!( + buffer[(x + 8, content.y)].bg, + views.panels.diff_colors.character[side] + ); + assert_eq!( + buffer[(x, content.y + 1)].bg, + Color::Reset, + "Shared historical context must not be highlighted as changed" + ); + } + let readonly = session.editor().lines().to_vec(); + session.handle(Input::Char('i')); + session.handle(Input::Char('X')); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), readonly); + let resolver = session.editor().resolver().unwrap(); + let right_history = resolver.history_panes[resolver.history_sources[1]]; + session.editor_mut().focus_resolver_pane(right_history); + session.execute(&format!("resolve.source {}", third_source + 1)); + render(&mut terminal, &mut views, &mut session); + assert!( + views + .rects + .iter() + .any(|(_, area)| { line_at(&terminal, *area, area.y).contains("value = third;") }) + ); + let history_areas = [views.rects[0].1, views.rects[1].1, views.rects[2].1]; + for content in history_areas.into_iter().take(2) { + assert!( + line_at(&terminal, content, content.y + 1) + .chars() + .all(|ch| ch == '╱'), + "The historical input insertion must leave aligned gaps in input/base" + ); + } + assert!(line_at(&terminal, history_areas[2], history_areas[2].y + 1).contains("third-only")); + let context_rows: Vec<_> = history_areas + .into_iter() + .map(|content| { + (content.y..content.bottom()) + .find(|&y| line_at(&terminal, content, y).contains("context 00")) + .unwrap() + }) + .collect(); + assert!(context_rows.iter().all(|row| *row == context_rows[0])); + session.handle(Input::Down); + session.handle(Input::End); + session.execute("word_wrap.toggle"); + let history_caret = render(&mut terminal, &mut views, &mut session); + assert!(history_areas[2].contains(history_caret)); + let history = &views.history[&views.history_key.unwrap()]; + assert!( + history.visible[2] + .iter() + .any(|row| row.position.continuation > 0) + ); + let continuation = history.visible[2] + .iter() + .position(|row| row.position.continuation > 0) + .unwrap(); + for content in history_areas.into_iter().take(2) { + assert!( + line_at(&terminal, content, content.y + continuation as u16) + .chars() + .all(|ch| ch == '╱') + ); + } + session.execute("word_wrap.toggle"); + session.handle(Input::Home); + session.handle(Input::Up); + render(&mut terminal, &mut views, &mut session); + let history_pane = session.editor().pane_id(); + let history_cursor = session.editor().cursor(); + assert!(views.scroll( + &mut session, + document_wheel( + MouseEventKind::ScrollDown, + history_areas[1].x, + history_areas[1].y + ) + )); + render(&mut terminal, &mut views, &mut session); + for content in history_areas { + assert!(line_at(&terminal, content, content.y).contains("context 01")); + } + assert_eq!(session.editor().pane_id(), history_pane); + assert_eq!(session.editor().cursor(), history_cursor); + assert!(views.scroll( + &mut session, + document_wheel( + MouseEventKind::ScrollUp, + history_areas[1].x, + history_areas[1].y + ) + )); + session.execute("resolve.base"); + terminal.backend_mut().resize(44, 20); + let caret = render(&mut terminal, &mut views, &mut session); + assert_eq!(views.rects.len(), 1); + assert!(views.rects[0].1.contains(caret)); + assert!(screen_text(&terminal).contains("value = base;")); + terminal.backend_mut().resize(120, 30); + session.execute("resolve.draft"); + let restored_caret = render(&mut terminal, &mut views, &mut session); + assert_eq!(session.editor().cursor(), draft_cursor); + assert_eq!(views.panels.diff.as_ref().unwrap().viewport, draft_viewport); + assert_eq!(restored_caret, searched_caret); + { + let config = directory.file( + "base-below.rb", + "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.resolve_history_layout = 'base_below'\nend\n", + ); + let mut bottom = EditorSession::open( + Some(directory.0.join("first.txt")), + vec!["text".into()], + Some(&config), + ) + .unwrap(); + bottom.execute("resolve"); + ready(&mut bottom); + let draft = bottom.editor().context().text; + bottom.execute("resolve.history"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + let mut views = BufferViewports::default(); + render(&mut terminal, &mut views, &mut bottom); + let resolver = bottom.editor().resolver().unwrap(); + let left = resolver.history_panes[resolver.history_sources[0]]; + let right = resolver.history_panes[resolver.history_sources[1]]; + let base = resolver.history_panes[resolver.selected_base.unwrap()]; + assert_eq!(views.rects.len(), 3); + assert_eq!( + views + .rects + .iter() + .map(|(pane, _)| *pane) + .collect::>(), + [left, right, base] + ); + let [left_area, right_area, base_area] = std::array::from_fn(|index| views.rects[index].1); + assert_eq!(left_area.right(), right_area.x); + assert_eq!(left_area.y, right_area.y); + assert_eq!(base_area.x, left_area.x); + assert_eq!(base_area.right(), right_area.right()); + assert!(base_area.y >= left_area.bottom()); + for (area, value) in [ + (left_area, "value = left;"), + (right_area, "value = right;"), + (base_area, "value = base;"), + ] { + assert!(line_at(&terminal, area, area.y).contains(value)); + } + bottom.handle(Input::Char('\t')); + assert_eq!(bottom.editor().pane_id(), right); + bottom.handle(Input::Char('\t')); + assert_eq!(bottom.editor().pane_id(), base); + bottom.handle(Input::BackTab); + assert_eq!(bottom.editor().pane_id(), right); + bottom.execute("pane.focus_down"); + let caret = render(&mut terminal, &mut views, &mut bottom); + assert_eq!(bottom.editor().pane_id(), base); + assert!(base_area.contains(caret)); + assert!(views.scroll( + &mut bottom, + document_wheel(MouseEventKind::ScrollDown, base_area.x, base_area.y) + )); + render(&mut terminal, &mut views, &mut bottom); + for area in [left_area, right_area, base_area] { + assert!(line_at(&terminal, area, area.y).contains("context 02")); + } + let readonly = bottom.editor().context().text; + bottom.handle(Input::Char('i')); + bottom.handle(Input::Paste("must not edit base".into())); + bottom.handle(Input::Escape); + assert_eq!(bottom.editor().context().text, readonly); + terminal.backend_mut().resize(44, 20); + render(&mut terminal, &mut views, &mut bottom); + assert_eq!(views.rects.len(), 1); + assert_eq!(views.rects[0].0, base); + terminal.backend_mut().resize(120, 6); + render(&mut terminal, &mut views, &mut bottom); + assert_eq!(views.rects.len(), 1); + assert_eq!(views.rects[0].0, base); + bottom.execute("resolve.draft"); + assert_eq!(bottom.editor().context().text, draft); + } + + for (key, action) in [ + ("ctrl+e", "core.file_explorer"), + ("ctrl+o", "core.file_picker"), + ] { + session + .editor_mut() + .bind("normal", key, Some(action.into())) + .unwrap(); + } + session.editor_mut().set_file_explorer_view("list").unwrap(); + for key in ["ctrl+j", "ctrl+n", "down"] { + session.editor_mut().bind("selection", key, None).unwrap(); + } + session + .editor_mut() + .bind("selection", "ctrl+t", Some("core.down".into())) + .unwrap(); + session.handle(Input::Ctrl('e')); + let picker_caret = render(&mut terminal, &mut views, &mut session); + let dock = views.panels.files.unwrap().0; + assert_eq!( + dock, + dock_layout( + workspace(&terminal, &views, session.editor()), + DockSide::Right, + true + ) + .1 + .unwrap() + ); + assert_eq!(views.panels.diff.as_ref().unwrap().regions[0].0, dock); + assert!(dock.contains(picker_caret)); + assert!(screen_text(&terminal).contains("Explorer · List")); + let selected = session.editor().file_picker().unwrap().selected; + assert_eq!( + session + .editor() + .file_picker() + .unwrap() + .entries() + .nth(selected) + .unwrap() + .path, + session.editor().resolver().unwrap().files[current_file] + .path + .as_path(), + ); + session.handle(Input::Ctrl('t')); + let moved_caret = render(&mut terminal, &mut views, &mut session); + assert_ne!(session.editor().file_picker().unwrap().selected, selected); + assert_eq!(moved_caret, picker_caret); + let selected_row = (dock.y..dock.bottom()) + .find(|&row| line_at(&terminal, dock, row).contains("second.txt")) + .unwrap(); + assert_eq!( + terminal.backend().buffer()[(dock.x + 1, selected_row)].bg, + Color::Blue + ); + for ch in "second".chars() { + session.handle(Input::Char(ch)); + } + render(&mut terminal, &mut views, &mut session); + assert!(screen_text(&terminal).contains("second.txt")); + assert!(!screen_text(&terminal).contains("binary.bin")); + session.handle(Input::Enter); + render(&mut terminal, &mut views, &mut session); + let resolver = session.editor().resolver().unwrap(); + assert_eq!( + resolver.files[resolver.current_file].path, + std::path::Path::new("second.txt") + ); + session.handle(Input::Ctrl('o')); + session.handle(Input::Ctrl('u')); + for ch in "first".chars() { + session.handle(Input::Char(ch)); + } + render(&mut terminal, &mut views, &mut session); + let floating = views.panels.files.unwrap().0; + assert_eq!( + floating, + floating_panel(workspace(&terminal, &views, session.editor())) + ); + assert!(screen_text(&terminal).contains("Files · Tree INSERT")); + session.handle(Input::Enter); + session.execute("resolve.draft"); + render(&mut terminal, &mut views, &mut session); + assert_eq!( + session.editor().resolver().unwrap().current_file, + current_file + ); + assert!(views.panels.files.is_none()); + assert_eq!( + operation(), + before_browsing, + "Rendering/navigation must not snapshot jj" + ); + + session.handle(Input::Home); + session.handle(Input::Char('i')); + session.handle(Input::Paste("wrapped ".repeat(12))); + session.handle(Input::Escape); + session.execute("word_wrap.toggle"); + terminal.backend_mut().resize(44, 20); + let caret = render(&mut terminal, &mut views, &mut session); + let geometry = views.panels.diff.as_ref().unwrap(); + assert!(geometry.regions[1].0.contains(caret)); + assert!( + views + .panels + .diff_wrap + .as_ref() + .unwrap() + .rows + .iter() + .any(|row| row.position.continuation > 0) + ); + session.handle(Input::Home); + let caret = render(&mut terminal, &mut views, &mut session); + let geometry = views.panels.diff.as_ref().unwrap(); + assert!(geometry.regions[1].0.contains(caret)); + let content = diff_inner(geometry.regions[1].0); + assert!( + (content.y..content.bottom()) + .filter(|&y| { line_at(&terminal, content, y).contains("wrapped") }) + .count() + > 1, + "cursor {:?}, source {:?}, wrapped frame:\n{}", + session.editor().cursor(), + session.editor().lines()[session.editor().cursor().row], + screen_text(&terminal), + ); + let right = diff_inner(geometry.regions[2].0); + assert!((right.y..right.bottom()).all(|y| !line_at(&terminal, right, y).contains("wrapped"))); + session.handle(Input::Char('u')); + session.execute("word_wrap.toggle"); + session.execute("w"); + ready(&mut session); + terminal.backend_mut().resize(120, 30); + render(&mut terminal, &mut views, &mut session); + let resolver = session.editor().resolver().unwrap(); + assert_eq!( + resolver.files[current_file].status, + ResolverStatus::Resolved + ); + let binary = resolver + .files + .iter() + .position(|file| file.path == std::path::Path::new("binary.bin")) + .unwrap(); + for (width, height) in [(19, 12), (4, 4), (1, 1), (0, 0)] { + terminal.backend_mut().resize(width, height); + render(&mut terminal, &mut views, &mut session); + for (_, area) in &views.rects { + assert!(area.right() <= width && area.bottom() <= height); + } + } + terminal.backend_mut().resize(120, 30); + session.editor_mut().select_resolver_file(binary); + render(&mut terminal, &mut views, &mut session); + assert!(screen_text(&terminal).contains("Whole-file conflict")); + assert!(views.rects.is_empty()); + let resolver = session.editor().resolver().unwrap(); + let side = resolver.files[binary] + .sources + .iter() + .position(|source| !source.base) + .unwrap() + + 1; + session.execute(&format!("resolve.use_source {side}")); + render(&mut terminal, &mut views, &mut session); + assert!(screen_text(&terminal).contains("Selected:")); + for (width, height) in [(4, 4), (1, 1), (0, 0)] { + terminal.backend_mut().resize(width, height); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + } +} + +fn diff_catalog() -> crate::diff::Catalog { + use crate::diff::{Catalog, Category, RepoKind, Revision, RevisionTarget}; + let mut entries = vec![Revision { + target: RevisionTarget::Working, + label: "Working checkout".into(), + description: "On-disk files".into(), + category: Category::Commits, + }]; + entries.extend((0..20).map(|index| Revision { + target: RevisionTarget::Commit(format!("commit-{index:02}")), + label: format!("commit-{index:02}"), + description: if index == 19 { + "needle description".into() + } else { + "ordinary change".into() + }, + category: Category::Commits, + })); + entries.push(Revision { + target: RevisionTarget::Commit("branch-tip".into()), + label: "feature-branch".into(), + description: "branch description".into(), + category: Category::Branches, + }); + Catalog { + kind: RepoKind::Git, + entries, + notice: None, + } +} + +fn syntax_diff_file(path: &str, before: &str, after: &str) -> ChangedFile { + let before: Vec = before.split('\n').map(str::to_owned).collect(); + let after: Vec = after.split('\n').map(str::to_owned).collect(); + ChangedFile { + loaded: true, + before_eof: false, + after_eof: false, + path: path.into(), + old_path: None, + status: FileStatus::Modified, + hunks: vec![crate::diff::Hunk { + before_start: 0, + before_len: before.len(), + after_start: 0, + after_len: after.len(), + }], + before, + after, + notice: None, + } +} + +#[test] +fn diff_scope_anchor_keeps_nearest_top_when_declarations_reclaim_rows() { + let before = (0..4) + .map(|index| { + format!( + "impl Container{index} {{\n fn run(&self) {{\n old_call();\n keep();\n }}\n}}\n" + ) + }) + .collect::(); + let after = before + .replace("Container", "Renamed") + .replace("old_call", "new_call"); + let mut diff = diff_comparison(vec![syntax_diff_file("scope.rs", &before, &after)]); + diff.focus = Focus::After; + let file = &diff.comparison.as_ref().unwrap().files[0]; + let areas = + split_rect(pane_rect(Rect::new(0, 0, 120, 35)), SplitAxis::Horizontal).map(terminal_rect); + let mut syntax = DiffSyntaxCache::default(); + syntax.update( + diff.comparison_id.unwrap(), + 0, + file, + &file.after, + None, + None, + ); + let mut viewport = DiffWrapViewport::default(); + let cursor = Cursor { row: 15, column: 7 }; + viewport.update( + &diff, + file, + areas, + Some((Focus::After, cursor)), + Some(15), + Some(0), + &syntax, + ); + let position = viewport + .caret_position(&diff, file, Focus::After, cursor) + .unwrap(); + assert_eq!(viewport.screen_row(position), Some(15)); + assert_eq!( + diff.display_source_row(Focus::After, viewport.top.display), + Some(2), + "Keep the nearest enclosing function's sticky context, not earlier declarations" + ); +} + +#[test] +fn diff_scope_anchor_stops_at_first_target_across_unified_visibility_changes() { + let before = (0..12) + .map(|index| { + format!( + "impl Container{index} {{\n fn run(&self) {{\n old_call();\n keep();\n }}\n}}\n" + ) + }) + .collect::(); + let after = before + .replace("Container", "Renamed") + .replace("old_call", "new_call"); + let mut file = syntax_diff_file("scope.rs", &before, &after); + file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); + let mut diff = diff_comparison(vec![file]); + diff.set_context_options(3, 15); + diff.set_view_mode(ViewMode::Unified); + diff.focus = Focus::Before; + let file = &diff.comparison.as_ref().unwrap().files[0]; + let mut syntax = DiffSyntaxCache::default(); + syntax.update( + diff.comparison_id.unwrap(), + 0, + file, + &file.after, + None, + None, + ); + let mut viewport = DiffWrapViewport::default(); + viewport.update( + &diff, + file, + [Rect::new(0, 0, 120, 35); 2], + Some((Focus::Before, Cursor { row: 12, column: 7 })), + Some(16), + Some(0), + &syntax, + ); + assert_eq!( + diff.display_source_row(Focus::After, viewport.top.display), + Some(2), + "Do not move past the first anchor when an earlier declaration hides two labels" + ); + assert_eq!(viewport.rows[0].scope, [false, true]); +} + +#[test] +fn diff_scope_horizontal_scroll_keeps_source_rows_fixed() { + let before = + "keep_previous_code();\nfn review() {\n old_call_with_a_long_argument_name();\n}\n"; + let after = before.replace("old_call", "new_call"); + for (mode, axis, focus) in [ + (ViewMode::Split, SplitAxis::Vertical, Focus::After), + (ViewMode::Split, SplitAxis::Horizontal, Focus::After), + (ViewMode::Unified, SplitAxis::Vertical, Focus::Before), + (ViewMode::Unified, SplitAxis::Vertical, Focus::After), + ] { + let mut file = syntax_diff_file("scope.rs", before, &after); + file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); + let mut diff = diff_comparison(vec![file]); + diff.files_visible = false; + diff.split_axis = axis; + diff.set_view_mode(mode); + diff.focus = focus; + let declaration = diff.source_display_row(Focus::After, 1).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + let mut panels = WheelPanels::default(); + // A declaration at the top has no room for an overlay; leave its border intact. + for top in [0, declaration] { + diff.top = top; + let mut original = None; + let mut borders = [None, None]; + for lefts in [[0, 0], [8, 0], [0, 8], [8, 8], [0, 0]] { + [diff.left_before, diff.left_after] = lefts; + render_diff(&mut terminal, &diff, &mut panels); + let viewport = panels.diff_wrap.as_ref().unwrap(); + let source_rows = viewport + .rows + .iter() + .enumerate() + .filter(|(_, row)| row.scope == [false; 2]) + .map(|(screen, row)| (screen, row.position)) + .collect::>(); + if let Some(original) = &original { + assert_eq!( + &source_rows, original, + "Horizontal scrolling must not move source rows: {mode:?}, {axis:?}" + ); + } else { + original = Some(source_rows); + } + let screen = viewport + .screen_row(DiffWrapPosition { + display: declaration, + continuation: 0, + }) + .unwrap(); + for (pane, left) in lefts.into_iter().enumerate() { + if mode == ViewMode::Unified && (focus == Focus::Before) != (pane == 0) { + continue; + } + let area = diff_inner(panels.diff.as_ref().unwrap().regions[pane + 1].0); + let above = pane_screen( + &terminal, + Rect::new(area.x, area.y + screen as u16 - 1, area.width, 1), + ); + assert_eq!( + above.contains("review"), + left > 0 && screen > 0, + "Clipped names belong above the declaration, without adding a row" + ); + if screen == 0 { + let border = borders[pane].get_or_insert_with(|| above.clone()); + assert_eq!( + &above, border, + "Horizontal scrolling must preserve the border" + ); + } else if left > 0 { + assert_eq!( + above.trim(), + if mode == ViewMode::Unified { + "± review" + } else { + "› review" + }, + "The overlay must clear source text and its line number" + ); + } + } + } + } + } +} + +#[test] +fn diff_scope_overlay_preserves_visible_declaration_above() { + let before = "pub fn old_name() {\n shared_call_with_a_long_argument_name();\n}\n"; + let after = "fn new_name() {\n shared_call_with_a_long_argument_name();\n}\n"; + for focus in [Focus::Before, Focus::After] { + let mut file = syntax_diff_file("scope.rs", before, after); + file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); + let mut diff = diff_comparison(vec![file]); + diff.files_visible = false; + diff.set_view_mode(ViewMode::Unified); + diff.focus = focus; + diff.top = diff.source_display_row(Focus::Before, 0).unwrap(); + diff.left_before = 4; + diff.left_after = 4; + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let pane = usize::from(focus == Focus::After); + let area = diff_inner(panels.diff.as_ref().unwrap().regions[pane + 1].0); + let first = pane_screen(&terminal, Rect::new(area.x, area.y, area.width, 1)); + assert!( + first.contains("fn old_name()"), + "Visible declaration was covered: {first}" + ); + assert!(!first.contains("+ new_name")); + } +} + +#[test] +fn diff_scope_sticky_row_keeps_adjacent_declaration_numbered() { + let after = "\n#[allow(clippy::too_many_arguments)]\nfn draw_diff_scope_labels(\n frame: &mut Frame,\n diff: &DiffState,\n) {\n work();\n}\n"; + for (mode, axis, wrap) in [ + (ViewMode::Split, SplitAxis::Vertical, false), + (ViewMode::Split, SplitAxis::Horizontal, false), + (ViewMode::Split, SplitAxis::Vertical, true), + (ViewMode::Unified, SplitAxis::Vertical, false), + (ViewMode::Unified, SplitAxis::Vertical, true), + ] { + let mut file = syntax_diff_file("tui.rs", "", after); + file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); + let mut diff = diff_comparison(vec![file]); + diff.files_visible = false; + diff.focus = Focus::After; + diff.split_axis = axis; + diff.word_wrap = wrap; + diff.set_view_mode(mode); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 24)).unwrap(); + let mut panels = WheelPanels::default(); + for source in [0, 1, 2, 3, 4, 3, 2] { + diff.top = diff.source_display_row(Focus::After, source).unwrap(); + render_diff(&mut terminal, &diff, &mut panels); + let area = diff_inner(panels.diff.as_ref().unwrap().regions[2].0); + let text = pane_screen(&terminal, area); + if source <= 3 { + let declaration = text + .lines() + .find(|line| line.contains("fn draw_diff_scope_labels(")); + assert!( + declaration.is_some_and(|line| line.contains("3+")), + "The declaration must keep its source number, not become an unnumbered sticky label at top source {source}:\n{text}" + ); + } else { + assert!(text.lines().any(|line| { + line.contains("draw_diff_scope_labels") && !line.contains("fn ") + })); + } + } + } +} + +#[test] +fn diff_scope_labels_follow_declaration_visibility() { + let before = "@decorate()\nclass PolicyActiveStatusEnum(BaseEnum):\n ACTIVE = 1\n EXPIRED = 2\n FUTURE = 3\n UNKNOWN = 4\n"; + let after = before.replace(" FUTURE", " CANCELLED = 5\n FUTURE"); + for (mode, axis, wrap) in [ + (ViewMode::Split, SplitAxis::Vertical, false), + (ViewMode::Split, SplitAxis::Horizontal, true), + (ViewMode::Unified, SplitAxis::Vertical, true), + ] { + let mut file = syntax_diff_file("status.py", before, &after); + file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); + let mut diff = diff_comparison(vec![file]); + diff.files_visible = false; + diff.split_axis = axis; + diff.word_wrap = wrap; + diff.set_view_mode(mode); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 32)).unwrap(); + let mut panels = WheelPanels::default(); + for (source, visible) in [(0, false), (2, false), (3, true), (1, false), (2, false)] { + diff.top = diff.source_display_row(Focus::After, source).unwrap(); + render_diff(&mut terminal, &diff, &mut panels); + let text = screen_text(&terminal); + let count = ["› PolicyActiveStatusEnum", "± PolicyActiveStatusEnum"] + .iter() + .map(|label| text.matches(label).count()) + .sum::(); + assert_eq!( + count, + if visible { + if mode == ViewMode::Split { 2 } else { 1 } + } else { + 0 + } + ); + assert!(text.contains("EXPIRED = 2")); + } + } + let before = "def old_name():\n shared()\n tail()\n"; + let after = before.replace("old_name", "new_name"); + let mut file = syntax_diff_file("renamed.py", before, &after); + file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); + let mut diff = diff_comparison(vec![file]); + diff.files_visible = false; + diff.set_view_mode(ViewMode::Unified); + diff.top = diff.source_display_row(Focus::After, 0).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let text = screen_text(&terminal); + assert!(text.contains("def new_name():")); + assert!(text.contains("− old_name")); + assert!(!text.contains("+ new_name")); +} + +#[test] +fn diff_scope_labels_do_not_repeat_across_hunks_in_one_excerpt() { + let before = "def retry_cancellation_email():\n first()\n shared_one()\n old()\n shared_two()\n shared_three()\n tail()\n"; + let after = "def retry_cancellation_email():\n first()\n inserted_one()\n inserted_two()\n shared_one()\n shared_two()\n inserted_three()\n shared_three()\n tail()\n"; + for mode in [ViewMode::Split, ViewMode::Unified] { + let mut file = syntax_diff_file("retry.py", before, after); + file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); + let mut diff = diff_comparison(vec![file]); + diff.files_visible = false; + diff.set_view_mode(mode); + diff.top = diff.source_display_row(Focus::After, 4).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 40)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + if mode == ViewMode::Split { + for top in [diff.top, diff.source_display_row(Focus::After, 3).unwrap()] { + diff.top = top; + render_diff(&mut terminal, &diff, &mut panels); + for side in [1, 2] { + let area = panels.diff.as_ref().unwrap().regions[side].0; + assert_eq!( + pane_screen(&terminal, area) + .matches("› retry_cancellation_email") + .count(), + 1 + ); + } + } + diff.set_context_options(0, 15); + render_diff(&mut terminal, &diff, &mut panels); + for (side, expected) in [(1, 1), (2, 2)] { + let area = panels.diff.as_ref().unwrap().regions[side].0; + assert_eq!( + pane_screen(&terminal, area) + .matches("› retry_cancellation_email") + .count(), + expected + ); + } + } else { + assert_eq!( + screen_text(&terminal) + .matches("± retry_cancellation_email") + .count(), + 1 + ); + assert!(!screen_text(&terminal).contains("+ retry_cancellation_email")); + assert!(!screen_text(&terminal).contains("− retry_cancellation_email")); + } + } +} + +#[test] +fn diff_scope_labels_identify_multiple_scopes_and_sticky_renamed_sides() { + let before = "impl Review {\n fn old_name(&self) {\n let first = 1;\n let second = 2;\n }\n fn next(&self) {\n let third = 3;\n }\n}"; + let after = before + .replace("old_name", "new_name") + .replace("first = 1", "first = 9"); + for axis in [SplitAxis::Vertical, SplitAxis::Horizontal] { + for wrapping in [false, true] { + let mut diff = diff_comparison(vec![syntax_diff_file("scope.rs", before, &after)]); + diff.files_visible = false; + diff.split_axis = axis; + diff.word_wrap = wrapping; + diff.top = diff.source_display_row(Focus::Before, 3).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 48)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let rows = &panels.diff_wrap.as_ref().unwrap().rows; + for (side, name, other) in [(0, "old_name", "new_name"), (1, "new_name", "old_name")] { + let area = diff_inner(panels.diff.as_ref().unwrap().regions[side + 1].0); + let labels = rows + .iter() + .enumerate() + .filter(|(_, row)| row.scope[side]) + .map(|(screen, _)| { + (area.x..area.right()) + .map(|x| { + terminal.backend().buffer()[(x, area.y + screen as u16)].symbol() + }) + .collect::() + }) + .collect::>(); + assert!(labels.iter().any(|label| label.contains(name))); + assert!(!labels.iter().any(|label| label.contains("next"))); + assert!(pane_screen(&terminal, area).contains("fn next")); + assert!(!labels.iter().any(|label| label.contains(other))); + } + let syntax = panels.diff_syntax.as_ref().unwrap(); + let parses = (syntax.before.parse_counts(), syntax.after.parse_counts()); + diff.top = diff.source_display_row(Focus::Before, 3).unwrap(); + render_diff(&mut terminal, &diff, &mut panels); + let rows = &panels.diff_wrap.as_ref().unwrap().rows; + assert_eq!(rows[0].scope, [true; 2]); + assert_eq!(rows[1].scope, [false; 2]); + assert_eq!(rows[0].position, rows[1].position); + let first = diff_screen_rows(&diff, diff.top, 48, Some(rows)) + .next() + .unwrap(); + assert_eq!((first.0, first.1), (1, diff.top)); + let syntax = panels.diff_syntax.as_ref().unwrap(); + assert_eq!( + (syntax.before.parse_counts(), syntax.after.parse_counts()), + parses + ); + } + } + let mut narrow = diff_comparison(vec![syntax_diff_file("scope.rs", before, &after)]); + narrow.files_visible = false; + narrow.top = narrow.source_display_row(Focus::Before, 3).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 30)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &narrow, &mut panels); + assert!(screen_text(&terminal).contains("› old_name")); + assert!(screen_text(&terminal).contains("› new_name")); + let diff = diff_comparison(vec![syntax_diff_file("scope.txt", before, &after)]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + assert!( + panels + .diff_wrap + .as_ref() + .unwrap() + .rows + .iter() + .all(|row| row.scope == [false; 2]) + ); +} + +#[test] +fn diff_syntax_refreshes_same_path_working_snapshots_without_live_buffer_styles() { + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + let mut panels = WheelPanels::default(); + for (source, foreground) in [ + ("fn main() {}", Color::Magenta), + ("// current snapshot", Color::DarkGray), + ("const VALUE: u8 = 3;", Color::Magenta), + ] { + let mut diff = diff_comparison(vec![syntax_diff_file("working.rs", "fn old() {}", source)]); + diff.files_visible = false; + render_diff(&mut terminal, &diff, &mut panels); + let source_row = panels + .diff_wrap + .as_ref() + .unwrap() + .rows + .iter() + .position(|row| row.scope == [false; 2]) + .unwrap() as u16; + let after = panels.diff.as_ref().unwrap().regions[2].0; + let cell = &terminal.backend().buffer()[(after.x + 4, after.y + 1 + source_row)]; + assert_eq!(cell.fg, foreground, "{source}"); + let before = panels.diff.as_ref().unwrap().regions[1].0; + let cell = &terminal.backend().buffer()[(before.x + 4, before.y + 1 + source_row)]; + assert_eq!(cell.fg, Color::Magenta); + } +} + +#[test] +fn diff_syntax_uses_each_historic_path_shebang_and_injected_language() { + let mut file = syntax_diff_file("new.rs", "return 42", "fn main() {}"); + file.old_path = Some("old.py".into()); + let mut syntax = DiffSyntaxCache::default(); + syntax.update(1, 0, &file, &file.after, None, None); + assert_eq!(syntax.before.language(), Some("Python")); + assert_eq!(syntax.after.language(), Some("Rust")); + assert_eq!(syntax.before.line(0)[0].kind, SyntaxKind::Keyword); + assert_eq!(syntax.after.line(0)[0].kind, SyntaxKind::Keyword); + file.path = "unknown.extension".into(); + syntax.update(1, 0, &file, &file.after, None, None); + assert_eq!(syntax.after.language(), None); + assert!(syntax.after.line(0).is_empty()); + + let mut file = syntax_diff_file( + "script", + "```rust\nfn old() {}\n```", + "#!/usr/bin/env python3\nreturn 42", + ); + file.old_path = Some("history.md".into()); + let mut diff = diff_comparison(vec![file]); + diff.files_visible = false; + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let syntax = panels.diff_syntax.as_ref().unwrap(); + assert_eq!(syntax.before.language(), Some("Markdown")); + assert_eq!(syntax.after.language(), Some("Python")); + assert!(syntax.before.parse_counts().1 > 0); + assert_eq!(syntax.before.line(1)[0].kind, SyntaxKind::Keyword); + assert_eq!(syntax.after.line(1)[0].kind, SyntaxKind::Keyword); + for (side, region) in [ + panels.diff.as_ref().unwrap().regions[1].0, + panels.diff.as_ref().unwrap().regions[2].0, + ] + .into_iter() + .enumerate() + { + let focus = if side == 0 { + Focus::Before + } else { + Focus::After + }; + let screen = panels + .diff_wrap + .as_ref() + .unwrap() + .rows + .iter() + .position(|row| { + row.scope == [false; 2] + && diff.display_source_row(focus, row.position.display) == Some(1) + }) + .unwrap(); + let cell = &terminal.backend().buffer()[(region.x + 4, region.y + 1 + screen as u16)]; + assert_eq!(cell.fg, Color::Magenta); + } +} + +#[test] +fn diff_syntax_parses_selected_snapshots_not_scroll_focus_or_redraw() { + let first = syntax_diff_file("a.rs", "fn old() {}\n// old", "fn main() {}\n// new"); + let second = syntax_diff_file("b.rs", "// prior file", "// selected file"); + let mut diff = diff_comparison(vec![first, second]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let syntax = panels.diff_syntax.as_ref().unwrap(); + let counts = (syntax.before.parse_counts(), syntax.after.parse_counts()); + assert_eq!(counts, ((1, 0), (1, 0))); + for focus in [Focus::Before, Focus::After, Focus::Files] { + diff.focus = focus; + diff.top = 1; + diff.left_before = 1; + diff.left_after = 2; + render_diff(&mut terminal, &diff, &mut panels); + let syntax = panels.diff_syntax.as_ref().unwrap(); + assert_eq!( + (syntax.before.parse_counts(), syntax.after.parse_counts()), + counts + ); + let after = panels.diff.as_ref().unwrap().regions[2].0; + assert_eq!( + terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg, + Color::DarkGray, + ); + } + diff.files + .as_mut() + .unwrap() + .reveal_path(std::path::Path::new("b.rs")); + diff.handle(Input::Enter); + render_diff(&mut terminal, &diff, &mut panels); + let syntax = panels.diff_syntax.as_ref().unwrap(); + assert_eq!(syntax.before.parse_counts(), (2, 0)); + assert_eq!(syntax.after.parse_counts(), (2, 0)); + let after = panels.diff.as_ref().unwrap().regions[2].0; + assert_eq!( + terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg, + Color::DarkGray, + ); + let mut editor = Editor::open(None, vec![]).unwrap(); + let mut views = BufferViewports { + panels, + ..BufferViewports::default() + }; + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(views.panels.diff_syntax.is_none()); +} + +fn diff_comparison(files: Vec) -> DiffState { + static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut diff = DiffState::new(); + diff.take_request(); + diff.apply_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + diff.handle(Input::Enter); + diff.handle(Input::Enter); + diff.take_request(); + diff.apply_result( + id, + Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { + root: std::path::PathBuf::new(), + editable_paths: Default::default(), + before: "old revision".into(), + after: "Working checkout".into(), + revisions: ["before-snapshot".into(), "after-snapshot".into()], + files, + })), + ); + diff +} + +fn deletion_file() -> ChangedFile { + ChangedFile { + loaded: true, + before_eof: false, + after_eof: false, + path: "deleted-lines.txt".into(), + old_path: None, + status: FileStatus::Modified, + before: ["keep", "gone", "tail"].map(String::from).to_vec(), + after: ["keep", "tail"].map(String::from).to_vec(), + hunks: vec![crate::diff::Hunk { + before_start: 1, + before_len: 1, + after_start: 1, + after_len: 0, + }], + notice: None, + } +} + +#[test] +fn diff_borders_keep_full_revisions_and_each_panes_own_editability() { + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap(); + let mut panels = WheelPanels::default(); + for case in ["working", "checked_out", "unlisted", "historical", "binary"] { + let mut file = syntax_diff_file("new.rs", "fn old() {}", "fn new() {}"); + file.status = FileStatus::Renamed; + file.old_path = Some("old.rs".into()); + if case == "binary" { + file.notice = Some("Binary or non-UTF-8 file; text comparison unavailable".into()); + } + let mut diff = diff_comparison(vec![file]); + diff.files_visible = false; + let comparison = diff.comparison.as_mut().unwrap(); + comparison.before = "abcdef0123456789 (baseline)".into(); + comparison.after = "fedcba9876543210 (destination)".into(); + if matches!(case, "working" | "checked_out" | "binary") { + comparison.editable_paths.insert("new.rs".into()); + } + if matches!(case, "historical" | "checked_out") { + diff.after.as_mut().unwrap().target = + crate::diff::RevisionTarget::Commit("fedcba9876543210".into()); + } + let writable = matches!(case, "working" | "checked_out"); + assert_eq!(diff.after_editable(), writable, "{case}"); + for focus in [Focus::Before, Focus::After] { + diff.focus = focus; + render_diff(&mut terminal, &diff, &mut panels); + let regions = panels.diff.as_ref().unwrap().regions; + for (index, label, path, state) in [ + (1, "abcdef0123456789 (baseline)", "old.rs", "read-only"), + ( + 2, + "fedcba9876543210 (destination)", + "new.rs", + if writable { "editable" } else { "read-only" }, + ), + ] { + let pane = regions[index].0; + let top = pane_screen(&terminal, Rect::new(pane.x, pane.y, pane.width, 1)); + let bottom = pane_screen( + &terminal, + Rect::new(pane.x, pane.bottom() - 1, pane.width, 1), + ); + assert!(top.contains(path), "{case}: {top}"); + assert!( + pane_screen(&terminal, Rect::new(pane.right() - 13, pane.y, 12, 1),) + .contains(state), + "{case}: {top}", + ); + assert!( + bottom + .find(label) + .is_some_and(|offset| { UnicodeWidthStr::width(&bottom[..offset]) <= 2 }), + "{bottom}", + ); + assert!(!top.contains(label)); + assert!(!bottom.contains('→')); + assert_eq!(screen_text(&terminal).matches(label).count(), 1); + assert_eq!( + terminal.backend().buffer()[(pane.x, pane.y)].fg, + if regions[index].1 == focus { + Color::Cyan + } else { + Color::DarkGray + }, + ); + } + assert!(!screen_text(&terminal).contains("Native buffer bindings")); + assert!(!screen_text(&terminal).contains("focus ·")); + } + } +} + +#[test] +fn diff_workspace_reclaims_header_and_footer_without_hiding_notices() { + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); + let mut panels = WheelPanels::default(); + let mut diff = diff_comparison(vec![deletion_file()]); + for notice in [None, Some("comparison failed"), Some("loading")] { + diff.error = notice + .filter(|notice| *notice != "loading") + .map(str::to_owned); + diff.loading = notice == Some("loading"); + render_diff(&mut terminal, &diff, &mut panels); + let area = terminal.backend().buffer().area; + let regions = panels.diff.as_ref().unwrap().regions; + for (pane, _) in regions { + assert_eq!(pane.y, area.y + u16::from(notice.is_some())); + assert_eq!(pane.bottom(), area.bottom()); + } + if let Some(notice) = notice { + let row = pane_screen(&terminal, Rect::new(area.x, area.y, area.width, 1)); + assert!(row.contains(if notice == "loading" { + "Loading file" + } else { + notice + })); + } + let [_, before, after] = regions.map(|(pane, _)| pane); + assert_eq!(before.right(), after.x); + assert_eq!( + diff_inner(before).height, + area.height - 2 - u16::from(notice.is_some()), + ); + } +} + +#[test] +fn diff_border_titles_clip_unicode_without_overwriting_state_or_text() { + let mut file = syntax_diff_file( + &format!("界e\u{301}👩‍🚀\n\t{}", "long-path".repeat(12)), + "old", + "new", + ); + file.old_path = Some(format!("e\u{301}界👩‍🚀\u{1b}{}", "old-path".repeat(12)).into()); + let mut diff = diff_comparison(vec![file]); + diff.files_visible = false; + let comparison = diff.comparison.as_mut().unwrap(); + comparison.before = "before 界e\u{301}👩‍🚀\n\t\u{1b}".into(); + comparison.after = "after e\u{301}界👩‍🚀\r\u{7f}".into(); + let mut panels = WheelPanels::default(); + for (width, height) in [(180, 10), (64, 8), (36, 6), (8, 4), (3, 3), (1, 1)] { + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + render_diff(&mut terminal, &diff, &mut panels); + for (pane, _) in panels.diff.as_ref().unwrap().regions.into_iter().skip(1) { + assert!(pane.right() <= width && pane.bottom() <= height); + let buffer = terminal.backend().buffer(); + for y in pane.y..pane.bottom() { + for x in pane.x..pane.right() { + let symbol = buffer[(x, y)].symbol(); + assert!(!symbol.chars().any(char::is_control), "{symbol:?}"); + assert!(!symbol.starts_with('\u{301}'), "{symbol:?}"); + assert!(!symbol.starts_with('\u{200d}'), "{symbol:?}"); + if symbol.contains('👩') || symbol.contains('🚀') { + assert_eq!(symbol, "👩‍🚀"); + } + } + } + if width >= 36 { + assert!( + pane_screen(&terminal, Rect::new(pane.right() - 13, pane.y, 12, 1)) + .contains("read-only") + ); + let inner = diff_inner(pane); + assert_eq!(buffer[(inner.x, inner.y)].symbol(), "1"); + assert_eq!( + buffer[(inner.x + 3, inner.y)].symbol(), + if pane.x == 0 { "o" } else { "n" }, + ); + } + if width == 180 { + let bottom = pane_screen( + &terminal, + Rect::new(pane.x, pane.bottom() - 1, pane.width, 1), + ); + for grapheme in [ + if pane.x == 0 { "before" } else { "after" }, + "界", + "e\u{301}", + "👩‍🚀", + ] { + assert!(bottom.contains(grapheme), "{bottom}"); + } + assert_eq!(bottom.matches('�').count(), if pane.x == 0 { 3 } else { 2 }); + } + } + } +} + +#[test] +fn diff_status_layouts_hide_absent_panes_and_leave_unchanged_renames_blank() { + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 12)).unwrap(); + let mut panels = WheelPanels { + diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)), + ..WheelPanels::default() + }; + for status in [FileStatus::Added, FileStatus::Removed] { + let added = status == FileStatus::Added; + let mut file = deletion_file(); + file.status = status; + file.before = if added { + vec![] + } else { + vec!["removed content".into()] + }; + file.after = if added { + vec!["added content".into()] + } else { + vec![] + }; + file.hunks = vec![crate::diff::Hunk { + before_start: 0, + before_len: usize::from(!added), + after_start: 0, + after_len: usize::from(added), + }]; + let mut diff = diff_comparison(vec![file]); + diff.files_visible = false; + diff.comparison + .as_mut() + .unwrap() + .editable_paths + .insert("deleted-lines.txt".into()); + diff.focus = if added { Focus::After } else { Focus::Before }; + diff.left_after = 2; + render_diff(&mut terminal, &diff, &mut panels); + let geometry = panels.diff.as_ref().unwrap(); + let present = geometry.regions[if added { 2 } else { 1 }].0; + assert_eq!( + geometry.regions[if added { 1 } else { 2 }].0, + Rect::default() + ); + assert_eq!(present.x, 0); + assert_eq!(present.width, 160); + assert_eq!(geometry.viewport.unwrap().3, 0); + assert_eq!(geometry.viewport.unwrap().4, 2); + assert_eq!(geometry.regions[if added { 2 } else { 1 }].1, diff.focus); + assert_eq!( + terminal.backend().buffer()[(present.x, present.y)].fg, + Color::Cyan + ); + for x in present.x + 1..present.right() - 1 { + assert_eq!( + terminal.backend().buffer()[(x, present.y + 1)].bg, + Color::Reset + ); + } + let text = screen_text(&terminal); + assert!(text.contains(if added { + "ded content" + } else { + "removed content" + })); + assert!(text.contains(if added { "After · A" } else { "Before · D" })); + assert!(!text.contains(if added { "Before ·" } else { "After ·" })); + let bottom = pane_screen( + &terminal, + Rect::new(present.x, present.bottom() - 1, present.width, 1), + ); + assert!(bottom.contains("old revision → Working checkout")); + assert_eq!(text.matches("old revision").count(), 1); + assert_eq!(text.matches("Working checkout").count(), 1); + assert!( + pane_screen(&terminal, Rect::new(present.right() - 13, present.y, 12, 1),) + .contains(if added { "editable" } else { "read-only" }) + ); + assert!(!text.contains('╱')); + } + let mut empty_added = deletion_file(); + empty_added.status = FileStatus::Added; + empty_added.before.clear(); + empty_added.after.clear(); + empty_added.hunks.clear(); + empty_added.notice = Some("metadata-only addition".into()); + let mut diff = diff_comparison(vec![empty_added]); + diff.files_visible = false; + diff.focus = Focus::After; + render_diff(&mut terminal, &diff, &mut panels); + let geometry = panels.diff.as_ref().unwrap(); + assert_eq!(geometry.regions[1].0, Rect::default()); + assert_eq!(geometry.regions[2].0.width, 160); + assert!(screen_text(&terminal).contains("metadata-only addition")); + let pane = geometry.regions[2].0; + assert!( + pane_screen( + &terminal, + Rect::new(pane.x, pane.bottom() - 1, pane.width, 1), + ) + .contains("old revision → Working checkout") + ); + + let mut renamed = deletion_file(); + renamed.status = FileStatus::Renamed; + renamed.old_path = Some("old/path.txt".into()); + renamed.path = "new/path.txt".into(); + renamed.before = vec!["unchanged rename content".into()]; + renamed.after = renamed.before.clone(); + renamed.hunks.clear(); + let mut diff = diff_comparison(vec![renamed]); + render_diff(&mut terminal, &diff, &mut panels); + assert!(screen_text(&terminal).contains("old/path.txt -> new/path.txt")); + assert_eq!( + diff.files.as_mut().unwrap().accept(), + Some("new/path.txt".into()) + ); + diff.files_visible = false; + render_diff(&mut terminal, &diff, &mut panels); + let geometry = panels.diff.as_ref().unwrap(); + let blank = geometry.regions[1].0; + assert_eq!(blank.width, 160); + assert_eq!(geometry.regions[2].0, Rect::default()); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(blank.x, blank.y)].symbol(), "┌"); + for y in blank.y + 1..blank.bottom() - 1 { + for x in blank.x + 1..blank.right() - 1 { + assert_eq!(buffer[(x, y)].symbol(), " "); + assert_eq!(buffer[(x, y)].bg, Color::Reset); + } + } + let text = screen_text(&terminal); + assert!( + pane_screen( + &terminal, + Rect::new(blank.x, blank.bottom() - 1, blank.width, 1), + ) + .contains("old revision → Working checkout") + ); + assert!(!text.contains("unchanged rename content")); + assert!(!text.contains('╱')); + + let mut modified = diff_comparison(vec![deletion_file()]); + modified.files_visible = false; + render_diff(&mut terminal, &modified, &mut panels); + let regions = panels.diff.as_ref().unwrap().regions; + assert_eq!(regions[1].0.width, 80); + assert_eq!(regions[2].0.width, 80); + let text = screen_text(&terminal); + assert!(text.contains("Before · M")); + assert!(text.contains("After · M")); +} + +fn render_diff( + terminal: &mut Terminal, + diff: &DiffState, + panels: &mut WheelPanels, +) { + terminal + .draw(|frame| { + panels.begin_frame(); + draw_diff(frame, diff, frame.area(), "↑/↓", "h/j/k/l", panels, None); + }) + .unwrap(); +} + +fn commented_diff() -> DiffState { + use crate::review::{Anchor, Comment, Side}; + let before = format!( + "{}\nold e\u{301}界 second\nkeep", + "old e\u{301}界 ".repeat(12) + ); + let after = format!( + "{}\nnew e\u{301}界 second\nkeep", + "new e\u{301}界 ".repeat(12) + ); + let mut file = syntax_diff_file("new.txt", &before, &after); + file.hunks = + crate::diff::line_hunks(&file.before, file.before_eof, &file.after, file.after_eof); + file.old_path = Some("old.txt".into()); + let mut diff = diff_comparison(vec![file]); + diff.files_visible = false; + diff.local_review.document.root = diff.comparison.as_ref().unwrap().root.clone(); + diff.local_review.document.comments = vec![ + Comment { + id: 1, + anchor: Anchor { + revision: "before-snapshot".into(), + path: "old.txt".into(), + side: Side::Before, + start: crate::plugin::TextPosition { row: 0, column: 0 }, + end: crate::plugin::TextPosition { row: 2, column: 0 }, + content: before, + }, + body: "Before range".into(), + resolved: false, + }, + Comment { + id: 2, + anchor: Anchor { + revision: "after-snapshot".into(), + path: "new.txt".into(), + side: Side::After, + start: crate::plugin::TextPosition { row: 0, column: 0 }, + end: crate::plugin::TextPosition { row: 1, column: 0 }, + content: after, + }, + body: "After line".into(), + resolved: false, + }, + ]; + diff.local_review.selected = Some(1); + diff +} + +#[test] +fn local_review_highlights_exact_graphemes_newlines_clips_and_wrap_continuations() { + use crate::plugin::TextPosition; + use crate::review::Side; + + for mode in [ViewMode::Split, ViewMode::Unified] { + for side in [Side::Before, Side::After] { + for (wrapping, horizontal) in [(false, 0), (false, 6), (true, 0)] { + for (start, end) in [((0, 4), (0, 6)), ((0, 5), (1, 5)), ((0, 84), (1, 0))] { + let mut diff = commented_diff(); + diff.set_view_mode(mode); + diff.top = 0; + diff.word_wrap = wrapping; + diff.left_before = horizontal; + diff.left_after = horizontal; + let comment = diff + .local_review + .document + .comments + .iter_mut() + .find(|comment| comment.anchor.side == side) + .unwrap(); + comment.anchor.start = TextPosition { + row: start.0, + column: start.1, + }; + comment.anchor.end = TextPosition { + row: end.0, + column: end.1, + }; + let anchor = comment.anchor.clone(); + diff.local_review.selected = Some(comment.id); + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(90, 40)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let file = &diff.comparison.as_ref().unwrap().files[0]; + let wrapped = panels + .diff_wrap + .as_ref() + .map(|viewport| viewport.rows.as_slice()); + let buffer = terminal.backend().buffer(); + for (area, focus) in panels.diff.as_ref().unwrap().regions.into_iter().skip(1) { + let content = diff_inner(area); + let gutter = diff_gutter(&diff, file, content.width as usize, focus); + let width = content.width as usize - gutter; + for (screen, display, segments) in + diff_screen_rows(&diff, 0, content.height, wrapped) + { + let mut expected = vec![false; width]; + if let DisplayRow::Line(aligned) = diff.display_rows[display] { + let row = &diff.rows[aligned]; + let actual_side = if mode == ViewMode::Unified { + diff.display_focus(display).unwrap_or(focus) + } else { + focus + }; + let anchor_side = if side == Side::Before { + Focus::Before + } else { + Focus::After + }; + if actual_side == anchor_side + || (mode == ViewMode::Unified && row.kind == RowKind::Equal) + { + let source_row = if side == Side::Before { + row.before + } else { + row.after + }; + if let Some(source_row) = source_row { + let line = if side == Side::Before { + &file.before[source_row] + } else { + &file.after[source_row] + }; + let segment = segments.and_then(|segments| { + segments[usize::from(actual_side == Focus::After)] + }); + let left = + segment.map_or(horizontal, |segment| segment.start); + let paint_width = segment.map_or(width, |segment| { + segment_paint_width( + segment, + wrapped.unwrap()[screen].final_segments + [usize::from(actual_side == Focus::After)], + width, + ) + }); + for column in 0..=line.graphemes(true).count() { + let position = TextPosition { + row: source_row, + column, + }; + let cell_start = display_column(line, column); + let cell_end = if column == line.graphemes(true).count() + { + cell_start + 1 + } else { + display_column(line, column + 1) + }; + if anchor.start <= position + && position < anchor.end + && cell_start >= left + && cell_end <= left + paint_width + { + expected[cell_start - left..cell_end - left] + .fill(true); + } + } + } + } + } + let mut continuation = 0; + for (column, expected) in expected.into_iter().enumerate() { + if continuation > 0 { + continuation -= 1; + continue; + } + continuation = UnicodeWidthStr::width( + buffer[( + content.x + gutter as u16 + column as u16, + content.y + screen as u16, + )] + .symbol(), + ) + .saturating_sub(1); + assert_eq!( + buffer[( + content.x + gutter as u16 + column as u16, + content.y + screen as u16, + )] + .bg + == Color::Magenta, + expected, + "{mode:?} {side:?} wrap={wrapping} left={horizontal} \ + {start:?}..{end:?}, screen={screen} column={column}" + ); + } + } + } + } + } + } + } +} + +#[test] +fn local_review_markers_preserve_diff_signs_ranges_and_wrapped_geometry() { + for mode in [ViewMode::Split, ViewMode::Unified] { + for wrapping in [false, true] { + let mut diff = commented_diff(); + diff.set_view_mode(mode); + diff.top = 0; + diff.word_wrap = wrapping; + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 40)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let buffer = terminal.backend().buffer(); + let markers: Vec<_> = buffer + .content + .iter() + .enumerate() + .filter(|(_, cell)| matches!(cell.symbol(), "●" | "○")) + .collect(); + assert_eq!(markers.len(), 3, "{mode:?} {wrapping}"); + assert_eq!( + markers + .iter() + .filter(|(_, cell)| cell.symbol() == "●") + .count(), + 2 + ); + for &(offset, cell) in &markers { + assert_eq!(cell.fg, Color::Magenta); + assert_eq!(cell.modifier.contains(Modifier::BOLD), cell.symbol() == "●"); + assert_eq!( + buffer.content[offset - 1].symbol(), + if cell.symbol() == "●" { "-" } else { "+" } + ); + } + if wrapping { + let rows = &panels.diff_wrap.as_ref().unwrap().rows; + assert!(rows.iter().any(|row| row.position.continuation > 0)); + for (area, _) in panels.diff.as_ref().unwrap().regions.into_iter().skip(1) { + let area = diff_inner(area); + for (screen, row) in rows.iter().take(area.height as usize).enumerate() { + if row.position.continuation > 0 { + for x in area.x..area.right() { + assert!(!matches!( + buffer[(x, area.y + screen as u16)].symbol(), + "●" | "○" + )); + } + } + } + } + } + } + } +} + +#[test] +fn local_review_markers_reject_stale_identity_text_and_rename_paths() { + for mode in [ViewMode::Split, ViewMode::Unified] { + for mismatch in ["revision", "content", "path", "side", "root"] { + let mut diff = commented_diff(); + diff.set_view_mode(mode); + diff.local_review.document.comments.truncate(1); + let anchor = &mut diff.local_review.document.comments[0].anchor; + match mismatch { + "revision" => anchor.revision.push_str("-newer"), + "content" => anchor.content.push('\n'), + "path" => anchor.path = "new.txt".into(), + "side" => anchor.side = crate::review::Side::After, + "root" => diff.local_review.document.root = "another-repository".into(), + _ => unreachable!(), + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap(); + render_diff(&mut terminal, &diff, &mut WheelPanels::default()); + assert!( + terminal + .backend() + .buffer() + .content + .iter() + .all(|cell| !matches!(cell.symbol(), "●" | "○")), + "{mode:?} {mismatch}" + ); + assert!( + terminal + .backend() + .buffer() + .content + .iter() + .all(|cell| cell.bg != Color::Magenta), + "{mode:?} {mismatch}" + ); + } + } +} + +#[test] +fn unified_context_shows_comments_from_either_snapshot_side() { + for side in [crate::review::Side::Before, crate::review::Side::After] { + let mut diff = commented_diff(); + diff.set_view_mode(ViewMode::Unified); + diff.local_review + .document + .comments + .retain(|comment| comment.anchor.side == side); + let comment = &mut diff.local_review.document.comments[0]; + comment.anchor.start = crate::plugin::TextPosition { row: 2, column: 1 }; + comment.anchor.end = crate::plugin::TextPosition { row: 2, column: 3 }; + diff.local_review.selected = Some(comment.id); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap(); + render_diff(&mut terminal, &diff, &mut WheelPanels::default()); + assert!( + screen_text(&terminal).contains("●keep"), + "{side:?}: {}", + screen_text(&terminal) + ); + let buffer = terminal.backend().buffer(); + let marker = buffer + .content + .iter() + .position(|cell| cell.symbol() == "●") + .unwrap(); + for offset in 1..=4 { + assert_eq!( + buffer.content[marker + offset].bg == Color::Magenta, + (2..=3).contains(&offset), + "{side:?}: source column {}", + offset - 1 + ); + } + } +} + +#[test] +fn local_review_markers_yield_to_text_on_narrow_surfaces() { + for mode in [ViewMode::Split, ViewMode::Unified] { + for (width, height) in [(1, 1), (2, 2), (3, 3), (8, 4), (16, 8), (30, 12)] { + let mut diff = commented_diff(); + diff.set_view_mode(mode); + diff.word_wrap = true; + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + for (area, focus) in panels + .diff + .as_ref() + .into_iter() + .flat_map(|geometry| geometry.regions.into_iter().skip(1)) + { + assert!(area.right() <= width && area.bottom() <= height); + let content = diff_inner(area); + let file = &diff.comparison.as_ref().unwrap().files[0]; + let gutter = diff_gutter(&diff, file, content.width as usize, focus); + for y in content.y..content.bottom() { + for x in content.x..content.right() { + if matches!(terminal.backend().buffer()[(x, y)].symbol(), "●" | "○") { + assert!(gutter > 0); + assert_eq!(x, content.x + gutter as u16 - 1); + } + } + } + } + } + } +} + +const LIGHT_DIFF_THEME: ThemeColors = ThemeColors { + background: [250, 245, 240], + red: [210, 30, 50], + green: [20, 150, 60], +}; +const DARK_DIFF_THEME: ThemeColors = ThemeColors { + background: [20, 25, 30], + red: [220, 60, 80], + green: [40, 180, 100], +}; + +#[test] +fn character_diff_backgrounds_preserve_common_unicode_and_syntax_across_viewports() { + for theme in [Some(LIGHT_DIFF_THEME), Some(DARK_DIFF_THEME), None] { + let mut diff = diff_comparison(vec![syntax_diff_file( + "changes.rs", + "let 界e\u{301} = old + old;", + "let 界e\u{301} = new + new;", + )]); + diff.files_visible = false; + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); + let mut panels = WheelPanels { + diff_colors: DiffColors::new(theme), + ..WheelPanels::default() + }; + render_diff(&mut terminal, &diff, &mut panels); + let row = panels.diff_colors.row; + let character = panels.diff_colors.character; + if let Some(theme) = theme { + for (index, accent) in [theme.red, theme.green].into_iter().enumerate() { + let Color::Rgb(r, g, b) = row[index] else { + panic!("theme row background must be RGB"); + }; + let Color::Rgb(cr, cg, cb) = character[index] else { + panic!("theme character background must be RGB"); + }; + for ((common, changed), (background, accent)) in [r, g, b] + .into_iter() + .zip([cr, cg, cb]) + .zip(theme.background.into_iter().zip(accent)) + { + assert!((background.min(accent)..=background.max(accent)).contains(&common)); + assert!((background.min(accent)..=background.max(accent)).contains(&changed)); + if background != accent { + assert!(changed.abs_diff(accent) < common.abs_diff(accent)); + } + } + } + } else { + assert_eq!(row, [Color::Reset; 2]); + assert_eq!(character, [Color::Reset; 2]); + } + for index in 0..2 { + let pane = panels.diff.as_ref().unwrap().regions[index + 1].0; + let buffer = terminal.backend().buffer(); + for column in [0, 3, 4, 6, 8, 14, 19] { + assert_eq!(buffer[(pane.x + 4 + column, pane.y + 1)].bg, row[index]); + } + for column in [10, 11, 12, 16, 17, 18] { + let cell = &buffer[(pane.x + 4 + column, pane.y + 1)]; + assert_eq!(cell.bg, character[index]); + assert_eq!(cell.fg, Color::Reset); + } + assert_eq!(buffer[(pane.x + 4, pane.y + 1)].fg, Color::Magenta); + assert_eq!(buffer[(pane.x + 8, pane.y + 1)].symbol(), "界"); + assert_eq!(buffer[(pane.x + 10, pane.y + 1)].symbol(), "e\u{301}"); + } + diff.left_before = 11; + diff.left_after = 11; + render_diff(&mut terminal, &diff, &mut panels); + let after = panels.diff.as_ref().unwrap().regions[2].0; + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), "e"); + assert_eq!(buffer[(after.x + 4, after.y + 1)].bg, character[1]); + assert_eq!(buffer[(after.x + 7, after.y + 1)].bg, row[1]); + diff.word_wrap = true; + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 12)).unwrap(); + render_diff(&mut terminal, &diff, &mut panels); + let after = panels.diff.as_ref().unwrap().regions[2].0; + let buffer = terminal.backend().buffer(); + let mut words = 0; + for y in after.y + 1..after.bottom() - 1 { + for x in after.x + 4..after.right().saturating_sub(3) { + if buffer[(x, y)].symbol() == "n" + && buffer[(x + 1, y)].symbol() == "e" + && buffer[(x + 2, y)].symbol() == "w" + { + words += 1; + for column in x..x + 3 { + assert_eq!(buffer[(column, y)].bg, character[1]); + assert_eq!(buffer[(column, y)].fg, Color::Reset); + } + } + } + } + assert_eq!(words, 2); + } +} + +#[test] +fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage() { + let mut diff = DiffState::new(); + diff.take_request(); + diff.apply_result(1, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap(); + let mut panels = WheelPanels::default(); + diff.handle(Input::End); + render_diff(&mut terminal, &diff, &mut panels); + let text = screen_text(&terminal); + assert!(text.contains("Choose Before")); + assert!(text.contains("Tab categories")); + assert!(text.contains("Enter select")); + assert!(text.contains("Working checkout · pinned")); + assert!(text.contains("> commit-19")); + assert!(!text.contains("commit-00")); + for ch in "needle".chars() { + diff.handle(Input::Char(ch)); + } + diff.handle(Input::Down); + render_diff(&mut terminal, &diff, &mut panels); + let text = screen_text(&terminal); + assert!(text.contains("> needle")); + assert!(text.contains("Working checkout · pinned")); + assert!(text.contains("> commit-19")); + diff.handle(Input::Enter); + render_diff(&mut terminal, &diff, &mut panels); + let text = screen_text(&terminal); + assert!(text.contains("Choose After")); + assert!(text.contains("Branch changes · Ctrl+b toggle · Before: commit-19")); + assert!(screen_text(&terminal).contains("Tab categories")); + assert!(screen_text(&terminal).contains("Enter select")); + diff.handle(Input::Ctrl('u')); + diff.handle(Input::Char('\t')); + render_diff(&mut terminal, &diff, &mut panels); + let text = screen_text(&terminal); + assert!(text.contains("feature-branch")); + assert!(!text.contains("ordinary change")); + assert!(text.contains("Working checkout · pinned")); + let buffer = terminal.backend().buffer(); + assert!( + buffer + .content + .iter() + .any(|cell| cell.symbol() == "B" && cell.bg == Color::Cyan) + ); +} + +#[test] +fn pull_request_picker_renders_all_rows_without_a_pinned_offset() { + use crate::diff::{Category, PullRequestPage, RepoKind, Revision, RevisionTarget}; + for (width, height) in [(100, 16), (44, 12)] { + let mut diff = DiffState::pull_requests("origin".into()); + diff.take_request(); + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + assert!(screen_text(&terminal).contains("loading")); + assert!(screen_text(&terminal).contains("[partial]")); + diff.apply_result(1, Err("offline".into())); + render_diff(&mut terminal, &diff, &mut panels); + assert!(screen_text(&terminal).contains("offline")); + assert!(!screen_text(&terminal).contains("No matching pull requests")); + diff.handle(Input::Enter); + diff.take_request(); + diff.apply_result( + 2, + Ok(crate::diff::Outcome::PullRequestPage { + closed: false, + result: Ok(PullRequestPage { + kind: RepoKind::Git, + remote: "origin".into(), + entries: (0..20) + .map(|index| { + ( + Revision { + target: RevisionTarget::PullRequest { + remote: "origin".into(), + number: index, + }, + label: format!("#{index:02} Pull request"), + description: "open · main ← topic".into(), + category: Category::Open, + }, + format!("2026-01-{:02}T00:00:00Z", 20 - index), + ) + }) + .collect(), + has_more: true, + }), + }), + ); + render_diff(&mut terminal, &diff, &mut panels); + let text = screen_text(&terminal); + assert!(text.contains("Pull requests")); + assert!(text.contains("Open") && text.contains("Closed") && text.contains("All")); + assert!(text.contains("> #00 Pull request")); + assert!(!text.contains("pinned")); + assert!(text.contains("[partial]")); + diff.handle(Input::End); + render_diff(&mut terminal, &diff, &mut panels); + let text = screen_text(&terminal); + assert!(text.contains("> #19 Pull request")); + assert!(!text.contains("#00 Pull request")); + diff.handle(Input::Home); + render_diff(&mut terminal, &diff, &mut panels); + assert!(screen_text(&terminal).contains("> #00 Pull request")); + diff.handle(Input::Char('\t')); + diff.apply_result( + 2, + Ok(crate::diff::Outcome::PullRequestPage { + closed: true, + result: Ok(PullRequestPage { + kind: RepoKind::Git, + remote: "origin".into(), + entries: vec![], + has_more: false, + }), + }), + ); + render_diff(&mut terminal, &diff, &mut panels); + assert!(screen_text(&terminal).contains("No matching pull requests")); + } +} + +#[test] +fn diff_excerpts_render_original_lines_and_expand_without_losing_changes() { + let before: Vec<_> = (0..80) + .map(|index| format!("let row_{index:03} = {index};")) + .collect(); + let mut after = before.clone(); + after[30] = "let first_change = 900;".into(); + after[60] = "let second_change = 901;".into(); + let mut diff = diff_comparison(vec![ChangedFile { + loaded: true, + before_eof: false, + after_eof: false, + path: "excerpts.rs".into(), + old_path: None, + status: FileStatus::Modified, + before, + after, + hunks: [30, 60] + .map(|index| crate::diff::Hunk { + before_start: index, + before_len: 1, + after_start: index, + after_len: 1, + }) + .into(), + notice: None, + }]); + diff.set_context_options(1, 3); + diff.files_visible = false; + diff.focus = Focus::Before; + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 24)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let text = screen_text(&terminal); + for visible in [ + "30 let row_029", + "31- let row_030", + "31+ let first_change", + "61+ let second_change", + ] { + assert!(text.contains(visible), "missing {visible}: {text}"); + } + for hidden in ["row_028", "row_032", "row_058", "row_062"] { + assert!(!text.contains(hidden), "unexpected {hidden}: {text}"); + } + let before_area = panels.diff.as_ref().unwrap().regions[1].0; + let buffer = terminal.backend().buffer(); + assert_eq!( + buffer[(before_area.x + 5, before_area.y + 3)].fg, + Color::Magenta + ); + diff.handle(Input::Char('[')); + diff.handle(Input::Home); + render_diff(&mut terminal, &diff, &mut panels); + let text = screen_text(&terminal); + assert!(text.contains("row_026")); + assert!(!text.contains("row_025")); + assert!(text.contains("first_change")); + assert!(text.contains("second_change")); +} + +#[test] +fn diff_view_aligns_deletion_holes_numbers_colors_and_shared_vertical_scroll() { + let mut diff = diff_comparison(vec![deletion_file()]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); + let mut panels = WheelPanels { + diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)), + ..WheelPanels::default() + }; + render_diff(&mut terminal, &diff, &mut panels); + let geometry = panels.diff.as_ref().unwrap(); + let before = geometry.regions[1].0; + let after = geometry.regions[2].0; + let buffer = terminal.backend().buffer(); + let y = before.y + 2; + assert_eq!(buffer[(before.x + 4, y)].symbol(), "g"); + assert_eq!(buffer[(before.x + 4, y)].fg, Color::Reset); + assert_eq!(buffer[(before.x + 2, y)].symbol(), "-"); + for x in before.x + 1..before.right() - 1 { + assert_eq!(buffer[(x, y + 1)].bg, Color::Reset); + } + let gap_symbols: Vec<_> = (after.x + 1..after.right() - 1) + .map(|x| { + let cell = &buffer[(x, y)]; + assert_eq!(cell.symbol(), "╱"); + assert_eq!(cell.bg, Color::Reset); + assert_eq!(cell.fg, buffer[(after.x + 1, y + 1)].fg); + cell.symbol().to_owned() + }) + .collect(); + assert_eq!(buffer[(before.x + 1, y + 1)].symbol(), "3"); + assert_eq!(buffer[(after.x + 1, y + 1)].symbol(), "2"); + assert_eq!(buffer[(before.x + 4, y + 1)].symbol(), "t"); + assert_eq!(buffer[(after.x + 4, y + 1)].symbol(), "t"); + diff.handle(Input::Char('\t')); + diff.handle(Input::Down); + render_diff(&mut terminal, &diff, &mut panels); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "g"); + for (x, symbol) in (after.x + 1..after.right() - 1).zip(gap_symbols) { + assert_eq!(buffer[(x, after.y + 1)].symbol(), symbol); + } + assert_eq!(buffer[(before.x, before.y)].fg, Color::Cyan); + assert_eq!(buffer[(after.x, after.y)].fg, Color::DarkGray); + assert_eq!(diff.focus, Focus::Before); +} + +#[test] +fn diff_row_tint_covers_blank_lines_and_wrapped_gutters() { + for wrapped in [false, true] { + let mut diff = diff_comparison(vec![syntax_diff_file( + "changed.txt", + "old old old old old old old old old old old old old old old old old old old old\n", + "new\n", + )]); + diff.files_visible = false; + diff.word_wrap = wrapped; + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 20)).unwrap(); + let mut panels = WheelPanels { + diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)), + ..WheelPanels::default() + }; + render_diff(&mut terminal, &diff, &mut panels); + for side in 0..2 { + let area = diff_inner(panels.diff.as_ref().unwrap().regions[side + 1].0); + let buffer = terminal.backend().buffer(); + let empty = (area.y..area.bottom()) + .find(|&y| buffer[(area.x, y)].symbol() == "2") + .unwrap(); + for y in area.y..=empty { + for x in area.x..area.x + 3 { + assert_eq!( + buffer[(x, y)].bg, + panels.diff_colors.row[side], + "{wrapped} {side} {x} {y}" + ); + } + } + for x in area.x..area.right() { + assert_eq!(buffer[(x, empty)].bg, panels.diff_colors.row[side]); + } + } + } +} + +#[test] +fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() { + let mut diff = diff_comparison(vec![ChangedFile { + loaded: true, + before_eof: false, + after_eof: false, + path: "added.txt".into(), + old_path: None, + status: FileStatus::Modified, + before: vec![], + after: vec!["first addition".into(), String::new()], + hunks: vec![crate::diff::Hunk { + before_start: 0, + before_len: 0, + after_start: 0, + after_len: 2, + }], + notice: None, + }]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); + let mut panels = WheelPanels { + diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)), + ..WheelPanels::default() + }; + render_diff(&mut terminal, &diff, &mut panels); + let geometry = panels.diff.as_ref().unwrap(); + let before = geometry.regions[1].0; + let after = geometry.regions[2].0; + let buffer = terminal.backend().buffer(); + for offset in 0..2 { + let y = before.y + 1 + offset; + for x in before.x + 1..before.right() - 1 { + let cell = &buffer[(x, y)]; + assert_eq!(cell.symbol(), "╱"); + assert_eq!(cell.bg, Color::Reset); + } + assert_eq!(buffer[(after.x + 1, y)].symbol(), (offset + 1).to_string()); + assert_eq!(buffer[(after.x + 2, y)].symbol(), "+"); + assert_eq!(buffer[(after.x + 2, y)].fg, Color::Green); + assert_eq!(buffer[(after.x + 4, y)].fg, Color::Reset); + for x in after.x + 1..after.right() - 1 { + if offset == 1 && x >= after.x + 4 { + assert_eq!(buffer[(x, y)].symbol(), " "); + } + } + } + diff.top = 1; + diff.left_before = 1; + render_diff(&mut terminal, &diff, &mut panels); + let buffer = terminal.backend().buffer(); + for x in before.x + 1..before.right() - 1 { + assert_eq!(buffer[(x, before.y + 1)].symbol(), "╱"); + } + assert_eq!(buffer[(before.x, before.y + 1)].symbol(), "│"); + assert_eq!(buffer[(before.right() - 1, before.y + 1)].symbol(), "│"); +} + +#[test] +fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() { + let mut diff = diff_comparison(vec![ChangedFile { + loaded: true, + before_eof: false, + after_eof: false, + path: "changed.rs".into(), + old_path: None, + status: FileStatus::Modified, + before: [ + "fn old() {", + "\tlet word = \"界e\u{301}old\";", + "\tlet count = 42; //\u{1b}note", + "", + "}", + ] + .map(String::from) + .to_vec(), + after: [ + "fn new() {", + "\tlet word = \"界e\u{301}new\";", + "\tlet count = 24; //\u{1b}note", + "", + "}", + ] + .map(String::from) + .to_vec(), + hunks: vec![crate::diff::Hunk { + before_start: 0, + before_len: 5, + after_start: 0, + after_len: 5, + }], + notice: None, + }]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 12)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let geometry = panels.diff.as_ref().unwrap(); + let before = geometry.regions[1].0; + let after = geometry.regions[2].0; + let source_row = panels + .diff_wrap + .as_ref() + .unwrap() + .rows + .iter() + .position(|row| row.scope == [false; 2]) + .unwrap() as u16; + let buffer = terminal.backend().buffer(); + for pane in [before, after] { + for (column, row, foreground) in [ + (0, 0, Color::Magenta), + (3, 0, Color::LightBlue), + (4, 1, Color::Magenta), + (15, 1, Color::Green), + (16, 1, Color::Green), + (18, 1, Color::Green), + (16, 2, Color::Yellow), + (22, 2, Color::DarkGray), + (23, 2, Color::DarkGray), + ] { + assert_eq!( + buffer[(pane.x + 4 + column, pane.y + 1 + source_row + row)].fg, + foreground + ); + } + assert_eq!( + buffer[(pane.x + 20, pane.y + 2 + source_row)].symbol(), + "界" + ); + assert_eq!( + buffer[(pane.x + 22, pane.y + 2 + source_row)].symbol(), + "e\u{301}" + ); + assert_eq!(buffer[(pane.x + 26, pane.y + 3 + source_row)].symbol(), "�"); + assert_eq!(buffer[(pane.x + 27, pane.y + 3 + source_row)].symbol(), "n"); + for x in pane.x + 4..pane.right() - 1 { + assert_eq!(buffer[(x, pane.y + 4 + source_row)].symbol(), " "); + assert_eq!(buffer[(x, pane.y + 4 + source_row)].fg, Color::Reset); + } + } + diff.top = 1; + diff.left_before = 17; + diff.left_after = 16; + render_diff(&mut terminal, &diff, &mut panels); + let source_row = panels + .diff_wrap + .as_ref() + .unwrap() + .rows + .iter() + .position(|row| row.scope == [false; 2]) + .unwrap() as u16; + let buffer = terminal.backend().buffer(); + assert_eq!( + buffer[(before.x + 4, before.y + 1 + source_row)].symbol(), + " " + ); + assert_eq!( + buffer[(before.x + 4, before.y + 1 + source_row)].fg, + Color::Reset + ); + assert_eq!( + buffer[(before.x + 5, before.y + 1 + source_row)].symbol(), + "e\u{301}" + ); + assert_eq!( + buffer[(before.x + 5, before.y + 1 + source_row)].fg, + Color::Green + ); + assert_eq!( + buffer[(after.x + 4, after.y + 1 + source_row)].symbol(), + "界" + ); + assert_eq!( + buffer[(after.x + 4, after.y + 1 + source_row)].fg, + Color::Green + ); + assert_eq!( + buffer[(after.x + 6, after.y + 1 + source_row)].symbol(), + "e\u{301}" + ); + assert_eq!( + buffer[(after.x + 6, after.y + 1 + source_row)].fg, + Color::Green + ); +} + +#[test] +fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() { + let added = ChangedFile { + loaded: true, + before_eof: false, + after_eof: false, + path: "new-name.txt".into(), + old_path: Some("old-name.txt".into()), + status: FileStatus::Renamed, + before: vec!["\t界e\u{301}OLD".into()], + after: vec!["\t界e\u{301}NEW".into()], + hunks: vec![crate::diff::Hunk { + before_start: 0, + before_len: 1, + after_start: 0, + after_len: 1, + }], + notice: None, + }; + let mut diff = diff_comparison(vec![deletion_file(), added]); + diff.handle(Input::Down); + diff.handle(Input::Enter); + diff.handle(Input::BackTab); + for _ in 0..4 { + diff.handle(Input::Right); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let geometry = panels.diff.as_ref().unwrap(); + let before = geometry.regions[1].0; + let after = geometry.regions[2].0; + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "界"); + assert_eq!(buffer[(before.x + 6, before.y + 1)].symbol(), "e\u{301}"); + assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), " "); + assert_eq!(buffer[(after.x + 8, after.y + 1)].symbol(), "界"); + assert_eq!(buffer[(after.x + 8, after.y + 1)].fg, Color::Reset); + diff.handle(Input::Char('\t')); + render_diff(&mut terminal, &diff, &mut panels); + assert_eq!(diff.focus, Focus::After); + assert_eq!( + terminal.backend().buffer()[(after.x, after.y)].fg, + Color::Cyan + ); +} + +#[test] +fn diff_reuses_right_explorer_compact_tree_and_tracks_browse_focus() { + let mut leaf = deletion_file(); + leaf.path = "chain/a/b/leaf.txt".into(); + let mut root = deletion_file(); + root.path = "root.txt".into(); + let mut diff = diff_comparison(vec![leaf, root]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let regions = panels.diff.as_ref().unwrap().regions; + let navigator = regions[0].0; + let before = regions[1].0; + let after = regions[2].0; + let workspace = terminal.backend().buffer().area; + let (panes, dock) = dock_layout(workspace, DockSide::Right, true); + assert_eq!(Some(navigator), dock); + assert_eq!(before.x, panes.x); + assert_eq!(before.right(), after.x); + assert_eq!(after.right(), navigator.x); + assert_eq!(navigator.right(), workspace.right()); + assert_eq!(panels.files.unwrap().0, navigator); + let tree_text = |terminal: &Terminal| { + let buffer = terminal.backend().buffer(); + (navigator.y..navigator.bottom()) + .map(|y| { + (navigator.x..navigator.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") + }; + let text = tree_text(&terminal); + assert!(text.contains("Explorer · Tree")); + assert!(text.contains("chain/a/b/")); + assert!(text.contains("leaf.txt")); + assert!(!text.contains("chain/a/b/leaf.txt")); + let cursor = terminal.get_cursor_position().unwrap(); + assert!(navigator.contains(cursor)); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].bg, + Color::Blue + ); + assert_eq!( + terminal.backend().buffer()[(navigator.x, navigator.y)].fg, + Color::Cyan + ); + diff.handle(Input::Left); + diff.handle(Input::Enter); + render_diff(&mut terminal, &diff, &mut panels); + let text = tree_text(&terminal); + assert!(!text.contains("leaf.txt")); + assert!(text.contains("chain/a/b/")); + assert_eq!(diff.file, 0); + diff.handle(Input::Char('\t')); + render_diff(&mut terminal, &diff, &mut panels); + assert!(!diff.files.as_ref().unwrap().focused); + assert_eq!( + terminal.backend().buffer()[(navigator.x, navigator.y)].fg, + Color::DarkGray + ); + assert_eq!( + terminal.backend().buffer()[(before.x, before.y)].fg, + Color::Cyan + ); +} + +#[test] +fn changed_file_markers_follow_catalog_indices_in_every_explorer_view() { + let files: Vec<_> = [ + ("z.txt", FileStatus::Added), + ("chain/a/m.txt", FileStatus::Modified), + ("d.txt", FileStatus::Removed), + ("r.txt", FileStatus::Renamed), + ] + .into_iter() + .map(|(path, status)| { + let mut file = deletion_file(); + file.path = path.into(); + file.status = status; + file.old_path = (status == FileStatus::Renamed).then(|| "old\nname.txt".into()); + file + }) + .collect(); + let mut picker = FilePicker::from_paths( + PathBuf::new(), + files + .iter() + .enumerate() + .map(|(index, file)| (index, file.path.clone())), + ); + picker.reveal_path(std::path::Path::new("chain/a/m.txt")); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap(); + for view in [FileView::Tree, FileView::List] { + picker.set_view(view); + for focused in [true, false] { + picker.focused = focused; + for index in 0..files.len() { + picker.reveal_path(&files[index].path); + let reviewed = BTreeSet::from([index]); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + frame.area(), + "", + &mut WheelPanels::default(), + Some((&files, &reviewed)), + ); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + for (catalog_index, (file, color)) in files + .iter() + .zip([Color::Green, Color::Yellow, Color::Red, Color::Cyan]) + .enumerate() + { + let path = if view == FileView::Tree { + std::path::Path::new(file.path.file_name().unwrap()) + } else { + file.path.as_path() + }; + let expected = if file.status == FileStatus::Renamed { + format!("R old�name.txt -> {}", file.path.to_string_lossy()) + } else { + format!("{} {}", diff_status(file.status), path.to_string_lossy()) + }; + let (x, y) = (0..16) + .find_map(|y| { + let row = (0..100) + .map(|x| buffer[(x, y)].symbol()) + .collect::(); + row.find(&expected) + .map(|offset| (UnicodeWidthStr::width(&row[..offset]) as u16, y)) + }) + .unwrap_or_else(|| panic!("missing {expected}")); + assert_eq!(buffer[(x, y)].fg, color); + assert_eq!( + buffer[(x - 2, y)].symbol(), + if catalog_index == index { "✓" } else { " " } + ); + assert_eq!(buffer[(x - 1, y)].symbol(), " "); + let depth = picker + .entries() + .find(|entry| entry.catalog_index == Some(catalog_index)) + .unwrap() + .depth; + assert_eq!( + x, + 3 + if view == FileView::Tree { + depth as u16 * 2 + } else { + 0 + } + ); + if catalog_index == index { + assert_eq!(buffer[(x - 2, y)].fg, Color::Green); + assert_eq!( + buffer[(x - 2, y)].bg, + if focused { + Color::Blue + } else { + Color::DarkGray + } + ); + } + } + for row in screen_text(&terminal) + .lines() + .filter(|row| row.contains("chain/a/")) + { + if view == FileView::Tree { + assert!(!row.contains('✓')); + } + } + assert_eq!(picker.accept(), Some(files[index].path.clone())); + assert!(picker.query.is_empty()); + } + } + } + // A reverted first file disappears without shifting the remaining status markers. + picker.replace_paths( + files + .iter() + .enumerate() + .skip(1) + .map(|(index, file)| (index, file.path.clone())), + ); + for view in [FileView::Tree, FileView::List] { + picker.set_view(view); + picker.query = "m.txt".into(); + picker.refilter(); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + frame.area(), + "", + &mut WheelPanels::default(), + Some((&files, &BTreeSet::from([1]))), + ); + }) + .unwrap(); + let text = screen_text(&terminal); + assert_eq!(text.matches('✓').count(), 1); + assert!(!text.contains("R r.txt")); + assert_eq!( + picker + .entries() + .find(|entry| !entry.is_dir) + .unwrap() + .catalog_index, + Some(1) + ); + } + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + frame.area(), + "", + &mut WheelPanels::default(), + None, + ); + }) + .unwrap(); + assert!(!screen_text(&terminal).contains("M chain/a/m.txt")); + assert!(!screen_text(&terminal).contains('✓')); +} + +#[test] +fn collapsed_diff_folders_reflect_all_catalog_descendants() { + let files: Vec<_> = [ + "src/top.rs", + "src/deep/leaf.rs", + "src/deep/other.rs", + "srcish/unreviewed.rs", + "src", + "compact/a/b/only.rs", + ] + .into_iter() + .map(|path| { + let mut file = deletion_file(); + file.path = path.into(); + file + }) + .collect(); + let paths = || { + files + .iter() + .enumerate() + .map(|(index, file)| (index, file.path.clone())) + }; + let mut picker = FilePicker::from_paths(PathBuf::new(), paths()); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 18)).unwrap(); + let mut check = |picker: &FilePicker, reviewed: &BTreeSet, label: &str, marked| { + terminal + .draw(|frame| { + draw_file_list( + frame, + picker, + frame.area(), + "", + &mut WheelPanels::default(), + Some((&files, reviewed)), + ); + }) + .unwrap(); + let text = screen_text(&terminal); + let row = text.lines().find(|row| row.contains(label)).unwrap(); + assert_eq!(row.contains('✓'), marked, "{row}"); + if marked { + let buffer = terminal.backend().buffer(); + let y = text.lines().position(|row| row.contains(label)).unwrap() as u16; + let x = (0..100).find(|&x| buffer[(x, y)].symbol() == "✓").unwrap(); + assert_eq!(buffer[(x, y)].fg, Color::Green); + } + }; + let mut reviewed = BTreeSet::from([0, 1]); + check(&picker, &reviewed, "src/", false); + reviewed.insert(2); + check(&picker, &reviewed, "src/", true); + check(&picker, &reviewed, "srcish/", false); + check(&picker, &reviewed, "compact/a/b/", false); + reviewed.insert(5); + check(&picker, &reviewed, "compact/a/b/", true); + + let selected = picker + .entries() + .position(|row| row.is_dir && row.path == std::path::Path::new("src")) + .unwrap(); + picker.selected = selected; + picker.right(); + check(&picker, &reviewed, "src/", false); + check(&picker, &reviewed, "deep/", true); + + reviewed.remove(&2); + picker.query = "leaf.rs".into(); + picker.refilter(); + let selected = picker + .entries() + .position(|row| row.is_dir && row.path == std::path::Path::new("src/deep")) + .unwrap(); + picker.selected = selected; + picker.accept(); + check(&picker, &reviewed, "deep/", false); + reviewed.insert(2); + check(&picker, &reviewed, "deep/", true); + reviewed.remove(&2); + check(&picker, &reviewed, "deep/", false); + + // A reverted file no longer in the picker must not block its folder's mark. + picker.replace_paths(paths().filter(|(index, _)| *index != 2)); + check(&picker, &reviewed, "deep/", true); + picker.accept(); + check(&picker, &reviewed, "deep/", false); +} + +#[test] +fn normal_file_markers_keep_clean_rows_and_match_diff_colors_in_every_view() { + use crate::repository::WorkingFileChange; + + let directory = Directory::new(); + let changes: Vec<_> = [ + ("z.txt", FileStatus::Added), + ("chain/a/m.txt", FileStatus::Modified), + ("gone/deep/d.txt", FileStatus::Removed), + ("r.txt", FileStatus::Renamed), + ] + .into_iter() + .map(|(path, status)| { + if status != FileStatus::Removed { + directory.file(path, ""); + } + WorkingFileChange { + path: path.into(), + status, + old_path: (status == FileStatus::Renamed).then(|| "old\nname.txt".into()), + } + }) + .collect(); + directory.file("clean.txt", ""); + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Floating, + FileView::List, + ) + .unwrap(); + picker.set_repository_changes(changes.clone()); + for change in &changes { + picker.reveal_path(&change.path); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + picker.presentation = presentation; + for view in [FileView::Tree, FileView::List] { + picker.set_view(view); + for focused in [true, false] { + picker.focused = focused; + for selected in changes + .iter() + .map(|change| change.path.as_path()) + .chain(std::iter::once(std::path::Path::new("clean.txt"))) + { + picker.reveal_path(selected); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + frame.area(), + "", + &mut WheelPanels::default(), + None, + ); + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("clean.txt")); + assert!(!text.contains('✓')); + assert!(!text.contains("old\nname.txt")); + let buffer = terminal.backend().buffer(); + for (change, color) in + changes + .iter() + .zip([Color::Green, Color::Yellow, Color::Red, Color::Cyan]) + { + let path = if view == FileView::Tree { + std::path::Path::new(change.path.file_name().unwrap()) + } else { + change.path.as_path() + }; + let expected = if change.status == FileStatus::Renamed { + "R old�name.txt -> r.txt".to_owned() + } else { + format!("{} {}", diff_status(change.status), path.to_string_lossy()) + }; + let (x, y) = (0..24) + .find_map(|y| { + let row = (0..100) + .map(|x| buffer[(x, y)].symbol()) + .collect::(); + row.find(&expected).map(|offset| { + (UnicodeWidthStr::width(&row[..offset]) as u16, y) + }) + }) + .unwrap_or_else(|| panic!("missing {expected}")); + assert_eq!(buffer[(x, y)].fg, color); + assert_eq!( + buffer[(x, y)].bg, + if change.path == selected { + if focused { + Color::Blue + } else { + Color::DarkGray + } + } else { + Color::Reset + } + ); + } + let clean_row = text.lines().find(|row| row.contains("clean.txt")).unwrap(); + assert!( + clean_row.contains(if selected == std::path::Path::new("clean.txt") { + "> clean.txt" + } else { + " clean.txt" + }) + ); + } + for query in ["m.txt", "clean"] { + picker.query = query.into(); + picker.refilter(); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + frame.area(), + "", + &mut WheelPanels::default(), + None, + ); + }) + .unwrap(); + let text = screen_text(&terminal); + if query == "m.txt" { + assert!(text.contains("M ")); + assert!(!text.contains("clean.txt")); + } else { + assert!(text.contains("> clean.txt")); + assert!(!text.contains("M ")); + } + assert!(!text.contains('✓')); + picker.clear_filter(); + } + } + } + } + picker.refresh(); + assert!( + picker + .entries() + .any(|row| row.status == Some(FileStatus::Renamed)) + ); + picker.set_repository_changes(Vec::new()); + picker.set_view(FileView::List); + picker.reveal_path(std::path::Path::new("clean.txt")); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + frame.area(), + "", + &mut WheelPanels::default(), + None, + ); + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("r.txt")); + assert!(!text.contains("old�name.txt")); + assert!(!text.contains("d.txt")); + assert!(!text.contains('✓')); +} + +#[test] +fn native_explorer_marks_only_confirmed_conflicts() { + let directory = Directory::new(); + directory.file("file.txt", ""); + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Floating, + FileView::List, + ) + .unwrap(); + picker.set_repository_changes(vec![crate::repository::WorkingFileChange { + path: "file.txt".into(), + status: FileStatus::Modified, + old_path: None, + }]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + for view in [FileView::List, FileView::Tree] { + picker.set_view(view); + for conflicted in [true, false] { + picker.set_conflicted(std::path::Path::new("file.txt"), conflicted); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + frame.area(), + "", + &mut WheelPanels::default(), + None, + ); + }) + .unwrap(); + let text = screen_text(&terminal); + let row = text.lines().find(|row| row.contains("file.txt")).unwrap(); + assert!(row.contains(if conflicted { + "C file.txt" + } else { + "M file.txt" + })); + if conflicted { + let buffer = terminal.backend().buffer(); + let (x, y) = (0..buffer.area.height) + .find_map(|y| { + (0..buffer.area.width) + .find(|&x| buffer[(x, y)].symbol() == "C") + .map(|x| (x, y)) + }) + .unwrap(); + assert_eq!(buffer[(x, y)].fg, Color::Yellow); + } + } + } +} + +#[test] +fn normal_file_names_stay_put_when_status_arrives_or_clears() { + let directory = Directory::new(); + directory.file("file.txt", ""); + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Floating, + FileView::List, + ) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + for view in [FileView::List, FileView::Tree] { + picker.set_view(view); + let mut columns = Vec::new(); + for status in [None, Some(FileStatus::Modified), None] { + picker.set_repository_changes( + status + .into_iter() + .map(|status| crate::repository::WorkingFileChange { + path: "file.txt".into(), + status, + old_path: None, + }) + .collect(), + ); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + frame.area(), + "", + &mut WheelPanels::default(), + None, + ); + }) + .unwrap(); + let text = screen_text(&terminal); + columns.push(text.lines().find_map(|row| row.find("file.txt")).unwrap()); + } + assert_eq!(columns[0], columns[1], "status arrival moved the filename"); + assert_eq!(columns[1], columns[2], "clearing status moved the filename"); + } +} + +#[test] +fn normal_status_prefix_clips_without_review_columns_or_painting_outside_tiny_bounds() { + let directory = Directory::new(); + directory.file("界.txt", ""); + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Floating, + FileView::List, + ) + .unwrap(); + picker.set_repository_changes(vec![crate::repository::WorkingFileChange { + path: "界.txt".into(), + status: FileStatus::Modified, + old_path: None, + }]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 4)).unwrap(); + for focused in [true, false] { + picker.focused = focused; + for height in 0..=3 { + for width in 0..=8 { + terminal + .draw(|frame| { + frame.render_widget( + Paragraph::new( + "XXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX", + ), + frame.area(), + ); + draw_file_list( + frame, + &picker, + Rect::new(0, 0, width, height), + "", + &mut WheelPanels::default(), + None, + ); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + for y in 0..4 { + for x in 0..12 { + if x >= width || y >= height { + assert_eq!(buffer[(x, y)].symbol(), "X"); + } + } + } + if height < 2 { + continue; + } + for (x, symbol) in "> M ".chars().take(usize::from(width)).enumerate() { + assert_eq!(buffer[(x as u16, 1)].symbol(), symbol.to_string()); + } + if width >= 3 { + assert_eq!(buffer[(2, 1)].fg, Color::Yellow); + assert_eq!( + buffer[(2, 1)].bg, + if focused { + Color::Blue + } else { + Color::DarkGray + } + ); + } + if width >= 6 { + assert_eq!(buffer[(4, 1)].symbol(), "界"); + } + } + } + } +} + +#[test] +fn hiding_diff_explorer_reclaims_workspace_and_restores_its_geometry() { + let mut diff = diff_comparison(vec![deletion_file()]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let original = panels.diff.as_ref().unwrap().regions; + diff.files_visible = false; + diff.focus = Focus::Before; + diff.files.as_mut().unwrap().focused = false; + render_diff(&mut terminal, &diff, &mut panels); + let hidden = panels.diff.as_ref().unwrap().regions; + assert_eq!(hidden[0].0, Rect::default()); + assert!(panels.files.is_none()); + assert_eq!(hidden[1].0.x, 0); + assert_eq!(hidden[1].0.right(), hidden[2].0.x); + assert_eq!(hidden[2].0.right(), 120); + assert!(hidden[1].0.width + hidden[2].0.width > original[1].0.width + original[2].0.width); + assert!(!screen_text(&terminal).contains("Explorer")); + diff.files_visible = true; + diff.focus = Focus::Files; + diff.files.as_mut().unwrap().focused = true; + render_diff(&mut terminal, &diff, &mut panels); + assert_eq!(panels.diff.as_ref().unwrap().regions, original); + assert_eq!(panels.files.unwrap().0, original[0].0); +} + +#[test] +fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() { + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); + let mut panels = WheelPanels::default(); + let mut diff = DiffState::new(); + render_diff(&mut terminal, &diff, &mut panels); + assert!(screen_text(&terminal).contains("Loading repository")); + diff.take_request(); + diff.apply_result(1, Err("permission denied".into())); + render_diff(&mut terminal, &diff, &mut panels); + assert!(screen_text(&terminal).contains("permission denied")); + let empty = diff_comparison(vec![]); + render_diff(&mut terminal, &empty, &mut panels); + assert!(screen_text(&terminal).contains("No changed files")); + assert!(screen_text(&terminal).contains("Explorer · Tree")); + let binary = diff_comparison(vec![ChangedFile { + loaded: true, + before_eof: false, + after_eof: false, + path: "image.bin".into(), + old_path: None, + status: FileStatus::Added, + before: vec![], + after: vec![], + hunks: vec![], + notice: Some("Binary file; text comparison unavailable".into()), + }]); + render_diff(&mut terminal, &binary, &mut panels); + assert!(screen_text(&terminal).contains("Binary file")); + let blank = diff_comparison(vec![ChangedFile { + loaded: true, + before_eof: false, + after_eof: false, + path: "empty.txt".into(), + old_path: None, + status: FileStatus::Added, + before: vec![], + after: vec![], + hunks: vec![], + notice: None, + }]); + render_diff(&mut terminal, &blank, &mut panels); + assert!(screen_text(&terminal).contains("Empty file")); + let mut unloaded_file = deletion_file(); + unloaded_file.loaded = false; + let mut unloaded = diff_comparison(vec![unloaded_file]); + unloaded.loading = false; + render_diff(&mut terminal, &unloaded, &mut panels); + assert!(screen_text(&terminal).contains("File details unavailable")); + for state in [&empty, &binary, &blank, &unloaded] { + render_diff(&mut terminal, state, &mut panels); + let pane = panels + .diff + .as_ref() + .unwrap() + .regions + .into_iter() + .skip(1) + .find(|(pane, _)| *pane != Rect::default()) + .unwrap() + .0; + assert!( + pane_screen( + &terminal, + Rect::new(pane.x, pane.bottom() - 1, pane.width, 1), + ) + .contains("old revision → Working checkout") + ); + assert!( + pane_screen(&terminal, Rect::new(pane.right() - 13, pane.y, 12, 1),) + .contains("read-only") + ); + } + for (width, height) in [(1, 1), (2, 2), (3, 3), (8, 4), (39, 5)] { + let mut tiny = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + for state in [&diff, &empty, &binary, &blank, &unloaded] { + render_diff(&mut tiny, state, &mut panels); + if let Some(geometry) = &panels.diff { + for (rect, _) in geometry.regions { + assert!(rect.right() <= width && rect.bottom() <= height); + } + let [navigator, before, after] = geometry.regions.map(|(rect, _)| rect); + if before.width > 0 && after.width > 0 { + assert!(before.right() <= after.x); + } + if navigator.width > 0 { + assert!(before.right().max(after.right()) <= navigator.x); + } + } + } + } +} + +#[test] +fn immutable_diff_draw_path_preserves_document_until_explicit_close() { + let directory = Directory::new(); + let mut editor = Editor::open( + Some(directory.file("source.txt", "original\nsecond")), + vec![], + ) + .unwrap(); + let original = editor.lines().to_vec(); + let cursor = editor.cursor(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert!(screen_text(&terminal).contains("Choose Before")); + assert!(viewports.document.geometry.is_none()); + editor.handle(Input::Enter); + editor.handle(Input::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result( + id, + Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { + root: std::path::PathBuf::new(), + editable_paths: Default::default(), + before: "old revision".into(), + after: "Working checkout".into(), + revisions: ["before-snapshot".into(), "after-snapshot".into()], + files: vec![deletion_file()], + })), + ); + editor.handle(Input::Paste("must not edit".into())); + editor.handle(Input::Char('i')); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert!(screen_text(&terminal).contains("deleted-lines.txt")); + assert!(viewports.document.geometry.is_none()); + editor.handle(Input::Escape); + assert!(editor.diff().is_some()); + editor.execute("diff.close"); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert!(screen_text(&terminal).contains("original")); + assert!(viewports.document.geometry.is_some()); + assert_eq!(editor.lines(), original); + assert_eq!(editor.cursor(), cursor); +} + +fn pane_screen(terminal: &Terminal, area: Rect) -> String { + let buffer = terminal.backend().buffer(); + (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") +} + +#[test] +fn tab_bar_shows_shared_dirty_titles_and_reserves_document_and_overlay_rows() { + let directory = Directory::new(); + let mut editor = Editor::open(Some(directory.file("a\tname.txt", "DOCUMENT")), vec![]).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(views.document.geometry.unwrap().1.y, 0); + assert!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)).contains("DOCUMENT")); + + editor.execute("tab.new"); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste("changed ".into())); + editor.handle(Input::Escape); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let titles = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); + assert!(titles.contains("1 a�name.txt [+]"), "{titles}"); + assert!(titles.contains("2 a�name.txt [+]"), "{titles}"); + assert_eq!(views.document.geometry.unwrap().1.y, 1); + assert!(terminal.get_cursor_position().unwrap().y >= 1); + assert!(pane_screen(&terminal, views.rects[0].1).contains("changed DOCUMENT")); + + editor.handle(Input::Ctrl('p')); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)), titles); + assert!(terminal.get_cursor_position().unwrap().y >= 1); + assert!(screen_text(&terminal).contains("Commands")); +} + +fn configure_chrome(editor: &mut Editor, update: impl FnOnce(&mut ChromeLine, &mut ChromeLine)) { + let mut top = editor.top_bar_config().clone(); + let mut bottom = editor.bottom_bar_config().clone(); + update(&mut top, &mut bottom); + editor + .set_chrome_config(top, bottom, BTreeMap::new()) + .unwrap(); +} + +#[test] +fn home_abbreviation_matches_components_and_only_changes_display() { + use std::path::Path; + let home = Path::new("/users/λ"); + for (path, expected) in [ + ("/users/λ", "~"), + ("/users/λ/", "~"), + ("/users/λ/src/file.rs", "~/src/file.rs"), + ("/users/λ-other/file.rs", "/users/λ-other/file.rs"), + ("/else/users/λ/file.rs", "/else/users/λ/file.rs"), + ("users/λ/file.rs", "users/λ/file.rs"), + ] { + assert_eq!(abbreviate_home(Path::new(path), Some(home)), expected); + assert_eq!(abbreviate_home(Path::new(path), None), path); + } + let directory = Directory::new(); + let path = directory.file("λ\t{index}.rs", "source"); + let editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + let groups = chrome_groups(&editor, editor.bottom_bar_config(), &SyntaxCache::new()); + let title = chrome_title("{path}", 2, Some(&path), false, None); + assert_eq!(title, display_text(&display_path(&path))); + assert!(groups.iter().flatten().any(|item| item.text == title)); + assert_eq!(editor.path(), Some(path.as_path())); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "source"); +} + +#[test] +fn native_lsp_names_are_borrowed_in_either_bar_and_ignore_configured_labels() { + let mut editor = Editor::open(None, vec![]).unwrap(); + configure_chrome(&mut editor, |top, bottom| { + for line in [top, bottom] { + line.left = vec!["lsp".into()]; + line.center.clear(); + line.right.clear(); + } + }); + editor + .set_label("lsp".into(), "not a running server".into()) + .unwrap(); + for line in [editor.top_bar_config(), editor.bottom_bar_config()] { + assert!(chrome_groups(&editor, line, &SyntaxCache::new())[0].is_empty()); + } + editor.set_lsp_names(vec!["server one".into(), "server two".into()]); + for line in [editor.top_bar_config(), editor.bottom_bar_config()] { + let groups = chrome_groups(&editor, line, &SyntaxCache::new()); + assert_eq!(groups[0].len(), 1); + assert!(matches!( + groups[0][0].text, + Cow::Borrowed("server one + server two") + )); + } +} + +#[test] +fn bottom_position_has_only_a_leading_margin_and_lsp_has_none() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_lsp_names(vec!["server".into()]); + configure_chrome(&mut editor, |top, bottom| { + top.left.clear(); + top.center.clear(); + top.right = vec!["position".into()]; + bottom.left.clear(); + bottom.center.clear(); + bottom.right = vec!["lsp".into(), "position".into()]; + }); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 2)).unwrap(); + terminal + .draw(|frame| { + for (line, y) in [ + (editor.top_bar_config(), 0), + (editor.bottom_bar_config(), 1), + ] { + let groups = chrome_groups(&editor, line, &SyntaxCache::new()); + let start = 40 - chrome_group_width(&groups[2]) as u16; + if y == 1 { + let gap = start + groups[2][0].width as u16; + draw_chrome(frame, groups, Rect::new(0, y, 40, 1)); + assert_eq!(frame.buffer_mut()[(start, y)].bg, Color::Cyan); + assert_eq!(frame.buffer_mut()[(gap, y)].symbol(), " "); + assert_eq!(frame.buffer_mut()[(gap, y)].bg, Color::Reset); + assert_eq!(frame.buffer_mut()[(gap + 1, y)].symbol(), "░"); + } else { + draw_chrome(frame, groups, Rect::new(0, y, 40, 1)); + assert_eq!(frame.buffer_mut()[(start, y)].symbol(), "░"); + } + assert_eq!(frame.buffer_mut()[(39, y)].bg, Color::Cyan); + } + }) + .unwrap(); +} + +#[test] +fn bar_margins_surround_styling_and_keep_anchors_and_narrow_content() { + let directory = Directory::new(); + let config = directory.file( + "margins.rb", + r#" +Vamp.configure do |config| + options = {left: [:badge], center: [:lsp], right: [:badge], + badge: {padding: 0, margin: 2, leading_edge: "<", trailing_edge: ">"}, lsp: {padding: 0, margin: 1}} + config.bar.top = options + config.bar.bottom = options +end +"#, + ); + let config = crate::ruby::load_config(&config).unwrap(); + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .set_chrome_config(config.top_bar, config.bottom_bar, config.labels) + .unwrap(); + editor.set_label("badge".into(), "界".into()).unwrap(); + editor.set_lsp_names(vec!["server".into()]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(41, 3)).unwrap(); + terminal + .draw(|frame| { + for (line, y) in [ + (editor.top_bar_config(), 0), + (editor.bottom_bar_config(), 2), + ] { + draw_chrome( + frame, + chrome_groups(&editor, line, &SyntaxCache::new()), + Rect::new(0, y, 41, 1), + ); + } + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + for y in [0, 2] { + for x in [0, 1, 6, 7, 16, 23, 33, 34, 39, 40] { + assert_eq!(buffer[(x, y)].symbol(), " "); + assert_eq!(buffer[(x, y)].bg, Color::Reset); + } + for x in [3, 36] { + assert_eq!(buffer[(x, y)].symbol(), "界"); + assert_eq!(buffer[(x, y)].bg, Color::Cyan); + } + for (x, symbol) in [(2, "<"), (5, ">"), (35, "<"), (38, ">")] { + assert_eq!(buffer[(x, y)].symbol(), symbol); + assert_eq!(buffer[(x, y)].fg, Color::Cyan); + } + for (offset, ch) in "server".chars().enumerate() { + assert_eq!(buffer[(17 + offset as u16, y)].symbol(), ch.to_string()); + assert_eq!(buffer[(17 + offset as u16, y)].bg, Color::Cyan); + } + } + configure_chrome(&mut editor, |top, bottom| { + for line in [top, bottom] { + line.left = vec!["lsp".into()]; + line.center.clear(); + line.right.clear(); + let lsp = line.segments.get_mut("lsp").unwrap(); + lsp.margin = [64; 2]; + lsp.padding = 64; + } + }); + let mut narrow = Terminal::new(ratatui::backend::TestBackend::new(6, 2)).unwrap(); + narrow + .draw(|frame| { + for (line, y) in [ + (editor.top_bar_config(), 0), + (editor.bottom_bar_config(), 1), + ] { + draw_chrome( + frame, + chrome_groups(&editor, line, &SyntaxCache::new()), + Rect::new(0, y, 6, 1), + ); + } + }) + .unwrap(); + for y in [0, 1] { + assert_eq!(pane_screen(&narrow, Rect::new(0, y, 6, 1)), "server"); + } + editor.set_lsp_names(Vec::new()); + for line in [editor.top_bar_config(), editor.bottom_bar_config()] { + assert!(chrome_groups(&editor, line, &SyntaxCache::new())[0].is_empty()); + } +} + +#[test] +fn floating_fields_use_border_titles_and_inner_horizontal_padding() { + for (prefix, title) in [(':', "Command"), ('/', "Search")] { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.handle(Input::Char(prefix)); + for ch in "needle".chars() { + editor.handle(Input::Char(ch)); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + let workspace = Rect::new(3, 2, 74, 8); + let mut panel = Rect::default(); + terminal + .draw(|frame| { + panel = draw_floating_input(frame, &editor, workspace).unwrap(); + }) + .unwrap(); + assert_eq!(panel.x, workspace.x); + assert_eq!(panel.bottom(), workspace.bottom()); + assert!( + pane_screen(&terminal, Rect::new(panel.x, panel.y, panel.width, 1)).contains(title) + ); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(panel.x + 1, panel.y + 1)].symbol(), " "); + assert_eq!(buffer[(panel.x + 2, panel.y + 1)].symbol(), "n"); + assert_eq!(buffer[(panel.right() - 2, panel.y + 1)].symbol(), " "); + assert_eq!( + terminal.get_cursor_position().unwrap(), + Position::new(panel.x + 8, panel.y + 1) + ); + assert_eq!( + pane_screen( + &terminal, + Rect::new(panel.x + 1, panel.y + 1, panel.width - 2, 1) + ) + .trim(), + "needle" + ); + } + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_message("operation complete"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + let mut panel = Rect::default(); + terminal + .draw(|frame| { + panel = draw_notification(frame, &editor, frame.area()).unwrap(); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(panel.x + 1, panel.y + 1)].symbol(), " "); + assert_eq!(buffer[(panel.x + 2, panel.y + 1)].symbol(), "o"); + assert_eq!(buffer[(panel.right() - 2, panel.y + 1)].symbol(), " "); + assert_eq!(panel.right(), 80); + assert_eq!(panel.bottom(), 12); +} + +#[test] +fn configured_floating_inputs_paint_each_anchor_independently_inside_the_workspace() { + let anchors = [ + (PopupAnchor::TopLeft, 0, 0), + (PopupAnchor::TopCenter, 14, 0), + (PopupAnchor::TopRight, 29, 0), + (PopupAnchor::CenterLeft, 0, 5), + (PopupAnchor::Center, 14, 5), + (PopupAnchor::CenterRight, 29, 5), + (PopupAnchor::BottomLeft, 0, 10), + (PopupAnchor::BottomCenter, 14, 10), + (PopupAnchor::BottomRight, 29, 10), + ]; + for (index, &(command_anchor, command_x, command_y)) in anchors.iter().enumerate() { + let (search_anchor, search_x, search_y) = anchors[(index + 1) % anchors.len()]; + for (prefix, title, x, y) in [ + (':', "Command", command_x, command_y), + ('/', "Search", search_x, search_y), + ] { + let mut editor = Editor::open(None, vec![]).unwrap(); + let mut popup = *editor.popup_config(); + popup.command.anchor = command_anchor; + popup.search.anchor = search_anchor; + editor.set_popup_config(popup); + editor.set_message("hidden notification"); + editor.handle(Input::Char(prefix)); + for ch in "needle".chars() { + editor.handle(Input::Char(ch)); + } + for (width, height) in [ + (101, 13), + (12, 3), + (3, 2), + (2, 1), + (1, 1), + (0, 1), + (1, 0), + (0, 0), + ] { + let workspace = Rect::new(5, 4, width, height); + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap(); + let mut panels = WheelPanels::default(); + terminal + .draw(|frame| { + frame.render_widget( + Paragraph::new(vec![Line::from(".".repeat(120)); 24]), + frame.area(), + ); + draw_overlays(frame, &editor, workspace, None, &mut panels); + }) + .unwrap(); + assert!(panels.notification.is_none()); + if width == 0 || height == 0 { + assert!(panels.input.is_none()); + assert_eq!( + terminal.backend().buffer()[(workspace.x, workspace.y)].symbol(), + "." + ); + continue; + } + let expected = if width == 101 { + Rect::new(workspace.x + x, workspace.y + y, 72, 3) + } else { + workspace + }; + let panel = panels.input.unwrap(); + assert_eq!( + panel, expected, + "{prefix} {command_anchor:?} {search_anchor:?}" + ); + assert_eq!(panel.intersection(workspace), panel); + let cursor = terminal.get_cursor_position().unwrap(); + assert!(panel.contains(cursor)); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(0, 0)].symbol(), "."); + assert_eq!(buffer[(cursor.x, cursor.y)].symbol(), " "); + if width >= 12 { + assert_eq!(buffer[(panel.x, panel.y)].symbol(), "┌"); + assert_eq!(buffer[(panel.right() - 1, panel.y)].symbol(), "┐"); + assert_eq!(buffer[(panel.x, panel.bottom() - 1)].symbol(), "└"); + assert_eq!( + buffer[(panel.right() - 1, panel.bottom() - 1)].symbol(), + "┘" + ); + assert!( + pane_screen(&terminal, Rect::new(panel.x, panel.y, panel.width, 1)) + .contains(title) + ); + assert_eq!(buffer[(panel.x + 1, panel.y + 1)].symbol(), " "); + assert_eq!(buffer[(panel.x + 2, panel.y + 1)].symbol(), "n"); + assert_eq!( + pane_screen( + &terminal, + Rect::new(panel.x + 1, panel.y + 1, panel.width - 2, 1) + ) + .trim(), + "needle" + ); + assert_eq!(cursor, Position::new(panel.x + 8, panel.y + 1)); + } else { + let cursor_x = u16::from(width > 1); + assert_eq!(cursor, Position::new(panel.x + cursor_x, panel.y)); + if width == 2 { + assert_eq!(buffer[(panel.x, panel.y)].symbol(), "e"); + } + } + } + } + } +} + +#[test] +fn floating_inputs_share_workspace_geometry_across_documents_splits_and_diffs() { + for context in ["document", "split", "diff", "split-diff", "selector"] { + for prefix in [':', '/'] { + if context == "selector" && prefix == '/' { + continue; + } + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace("DOCUMENT".into())]) + .unwrap(); + if context.starts_with("split") { + editor.handle(Input::Char('|')); + } + if context.ends_with("diff") { + install_readonly_diff(&mut editor, deletion_file()); + editor.handle(Input::Char('\t')); + assert!(editor.diff_interacting()); + } else if context == "selector" { + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + } + editor.set_message("saved notification"); + if context == "selector" { + editor + .bind("normal", "ctrl+t", Some("core.command".into())) + .unwrap(); + editor.handle(Input::Ctrl('t')); + } else { + editor.handle(Input::Char(prefix)); + } + let query = format!("{}TAIL", "界e\u{301}👩‍🚀".repeat(30)); + for ch in query.chars() { + editor.handle(Input::Char(ch)); + } + assert!(matches!(editor.mode(), Mode::Command | Mode::Search)); + assert_eq!(editor.command_line(), query); + let before = (editor.lines().to_vec(), editor.cursor(), editor.carets()); + let mut views = BufferViewports::default(); + for (width, height) in [(100, 9), (12, 3), (3, 2), (1, 1), (0, 1), (1, 0)] { + let area = Rect::new(3, 2, width, height); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(110, 16), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(views.panels.notification.is_none()); + if width == 0 || height == 0 { + assert!(views.panels.input.is_none()); + assert!(views.document.geometry.is_none()); + continue; + } + let panel = views.panels.input.unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!(panel.intersection(area), panel); + assert!( + panel.contains(cursor), + "{context} {area:?} {panel:?} {cursor:?}" + ); + assert!(panel.width <= 72 && panel.height <= 3); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + " " + ); + if width >= 12 { + assert!(pane_screen(&terminal, panel).contains("TAIL")); + } + assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " "); + assert_eq!( + before, + (editor.lines().to_vec(), editor.cursor(), editor.carets()) + ); + } + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.message(), "saved notification"); + } + } +} + +#[test] +fn cancelling_floating_inputs_restores_obscured_document_cells() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace( + vec!["DOCUMENT".repeat(15); 20].join("\n"), + )]) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let baseline = screen_text(&terminal); + let geometry = views.document.geometry; + let caret = views.panels.caret; + for prefix in [':', '/'] { + editor.handle(Input::Char(prefix)); + for ch in "long input 界e\u{301}👩‍🚀".chars() { + editor.handle(Input::Char(ch)); + } + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(views.panels.input.is_some()); + assert_eq!(views.document.geometry, geometry); + assert_eq!(views.panels.caret, caret); + editor.handle(Input::Escape); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(views.panels.input.is_none()); + assert_eq!(screen_text(&terminal), baseline); + assert_eq!(views.document.geometry, geometry); + assert_eq!(views.panels.caret, caret); + } +} + +#[test] +fn notification_popup_persists_blocks_wheels_and_restores_real_document_carets() { + let mut session = EditorSession::open(None, vec![], None).unwrap(); + session.editor_mut().set_message(""); + session + .editor_mut() + .apply(vec![crate::editor::Effect::Replace( + "DOCUMENT\n".repeat(20), + )]) + .unwrap(); + session.handle(Input::Ctrl('e')); + session.handle(Input::Ctrl('e')); + assert_eq!(session.editor().selection_count(), 3); + let area = Rect::new(3, 2, 100, 12); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(110, 18), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let baseline = screen_text(&terminal); + let geometry = views.document.geometry; + let caret = terminal.get_cursor_position().unwrap(); + let carets = session.editor().carets(); + let offsets = views.offsets.clone(); + let pane = session.editor().pane_id(); + session + .editor_mut() + .set_message("write failed: permission denied"); + for _ in 0..3 { + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let popup = views.panels.notification.unwrap(); + assert_eq!(popup.right(), area.right()); + assert_eq!(popup.bottom(), views.rects[0].1.bottom()); + assert!(popup.bottom() < area.bottom()); + assert!(popup.x > geometry.unwrap().1.x); + assert!(pane_screen(&terminal, popup).contains("write failed: permission denied")); + assert_eq!(views.document.geometry, geometry); + assert_eq!(terminal.get_cursor_position().unwrap(), caret); + for kind in [ + MouseEventKind::ScrollUp, + MouseEventKind::ScrollDown, + MouseEventKind::ScrollLeft, + MouseEventKind::ScrollRight, + ] { + assert!(views.scroll(&mut session, document_wheel(kind, popup.x, popup.y))); + } + assert_eq!(views.offsets, offsets); + assert_eq!(session.editor().pane_id(), pane); + assert_eq!(session.editor().carets(), carets); + } + session.handle(Input::Escape); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(views.panels.notification.is_none()); + assert_eq!(session.editor().carets(), carets); + assert_eq!( + session.editor().message(), + "write failed: permission denied" + ); + assert_eq!(screen_text(&terminal), baseline); + session.handle(Input::Escape); + assert_eq!(session.editor().selection_count(), 1); + session.editor_mut().set_message("new operation"); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(views.panels.notification.is_some()); + + for prefix in [':', '/'] { + session.handle(Input::Char(prefix)); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let input = views.panels.input.unwrap(); + assert!(views.panels.notification.is_none()); + let caret = terminal.get_cursor_position().unwrap(); + let mode = session.editor().mode(); + for kind in [ + MouseEventKind::ScrollUp, + MouseEventKind::ScrollDown, + MouseEventKind::ScrollLeft, + MouseEventKind::ScrollRight, + ] { + assert!(views.scroll(&mut session, document_wheel(kind, input.x, input.y))); + } + assert_eq!(views.offsets, offsets); + assert_eq!(session.editor().mode(), mode); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), caret); + session.handle(Input::Escape); + } +} + +#[test] +fn notification_hides_covered_hardware_cursor_without_changing_its_document_geometry() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace("DOCUMENT".into())]) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 4)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let caret = views.panels.caret; + let geometry = views.document.geometry; + assert!(terminal.backend().cursor_visible()); + editor.set_message("A long operation notification wraps over the document"); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!( + views + .panels + .notification + .unwrap() + .contains(caret.unwrap().1) + ); + assert!(!terminal.backend().cursor_visible()); + assert_eq!(views.panels.caret, caret); + assert_eq!(views.document.geometry, geometry); + editor.handle(Input::Escape); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(terminal.backend().cursor_visible()); + assert_eq!(terminal.get_cursor_position().unwrap(), caret.unwrap().1); + assert_eq!(views.document.geometry, geometry); +} + +#[test] +fn notifications_yield_to_existing_overlays_and_stay_bounded_when_tiny() { + for overlay in [ + "palette", + "hover", + "completion", + "files", + "startup", + "selector", + ] { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_message("LAST OPERATION"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(views.panels.notification.is_some()); + match overlay { + "palette" => { + editor.handle(Input::Ctrl('p')); + } + "hover" => { + editor.show_hover("Hover details".into()).unwrap(); + } + "completion" => { + use crate::plugin::{CompletionEdit, CompletionItem, TextPosition}; + editor.handle(Input::Char('i')); + editor + .show_completion(vec![CompletionItem { + label: "completion".into(), + detail: None, + primary_edit: 0, + snippet: None, + edits: vec![CompletionEdit { + start: TextPosition { row: 0, column: 0 }, + end: TextPosition { row: 0, column: 0 }, + text: "completion".into(), + }], + }]) + .unwrap(); + } + "files" => { + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('o')); + } + "startup" => editor.show_startup_diagnostic("Configuration error".into()), + _ => { + editor.execute("diff"); + } + } + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(views.panels.notification.is_none(), "{overlay}"); + assert!( + !screen_text(&terminal).contains("LAST OPERATION"), + "{overlay}" + ); + } + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_message(format!("{}\tEND", "界e\u{301} operation ".repeat(50))); + for (width, height) in [(100, 20), (12, 3), (3, 2), (1, 1), (0, 1), (1, 0)] { + let area = Rect::new(3, 2, width, height); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(110, 26), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + terminal + .draw(|frame| { + let popup = draw_notification(frame, &editor, area); + if width == 0 || height == 0 { + assert!(popup.is_none()); + } else { + let popup = popup.unwrap(); + assert_eq!(popup.intersection(area), popup); + assert_eq!(popup.right(), area.right()); + assert_eq!(popup.bottom(), area.bottom()); + assert!(popup.width <= 60 && popup.height <= 8); + } + }) + .unwrap(); + assert!(!screen_text(&terminal).contains("END")); + } +} + +#[test] +fn comparison_titles_follow_loaded_counts_and_each_tabs_context() { + let directory = Directory::new(); + let path = directory.file("source.rs", "fn main() {}"); + for phase in ["loading", "before", "after", "unloaded", "view"] { + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.execute("tab.new"); + editor.execute("diff"); + if phase != "loading" { + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + } + if matches!(phase, "after" | "unloaded" | "view") { + editor.handle(Input::Down); + editor.handle(Input::Enter); + } + if matches!(phase, "unloaded" | "view") { + editor.handle(Input::Down); + editor.handle(Input::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + let mut file = syntax_diff_file( + "source.rs", + "old\ncontext\nremove", + "new\ncontext\nextra\nmore", + ); + file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); + if phase == "unloaded" { + file.loaded = false; + file.before.clear(); + file.after.clear(); + file.hunks.clear(); + } + editor.apply_diff_result( + id, + Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { + root: directory.0.clone(), + editable_paths: Default::default(), + before: "baseline".into(), + after: "historic".into(), + revisions: ["before-snapshot".into(), "after-snapshot".into()], + files: vec![file], + })), + ); + } + let view = if phase == "view" { + "Diff · +3 -2 · " + } else { + "Diff · " + }; + let default_tabs = editor.top_bar_config().segment("tabs").clone(); + for (template, ordinary, prefix, uses_view) in [ + (None, "1 source.rs", "2 ", true), + (Some("{index} {name}{modified}"), "1 source.rs", "2 ", false), + ( + Some("T{index}:{view}{name}{modified}"), + "T1:source.rs", + "T2:", + true, + ), + ] { + configure_chrome(&mut editor, |top, bottom| { + top.segments.insert("tabs".into(), default_tabs.clone()); + if let Some(template) = template { + top.segments.get_mut("tabs").unwrap().title = template.into(); + } + bottom.left = vec!["mode".into()]; + bottom.center.clear(); + bottom.right.clear(); + bottom.segments.get_mut("mode").unwrap().title = "Current:{view}{name}".into(); + }); + for comparison_active in [true, false] { + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(160, 2)).unwrap(); + terminal + .draw(|frame| { + for (line, y) in [ + (editor.top_bar_config(), 0), + (editor.bottom_bar_config(), 1), + ] { + draw_chrome( + frame, + chrome_groups(&editor, line, &SyntaxCache::new()), + Rect::new(0, y, 160, 1), + ); + } + }) + .unwrap(); + let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1)); + assert!(titles.contains(ordinary), "{phase}: {titles}"); + let comparison = format!("{prefix}{}source.rs", if uses_view { view } else { "" }); + assert!(titles.contains(&comparison), "{phase}: {titles}"); + assert_eq!( + titles.matches("Diff · ").count(), + usize::from(uses_view), + "{phase}: {titles}" + ); + let current = pane_screen(&terminal, Rect::new(0, 1, 160, 1)); + let expected = if comparison_active { + format!("Current:{view}source.rs") + } else { + "Current:source.rs".into() + }; + assert!(current.contains(&expected), "{phase}: {current}"); + editor.execute(if comparison_active { + "tab.previous" + } else { + "tab.next" + }); + } + } + } +} + +#[test] +fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() { + let directory = Directory::new(); + let path = directory.file("live.rs", "new"); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + editor.handle(Input::Down); + editor.handle(Input::Enter); + editor.handle(Input::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result( + id, + Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { + root: directory.0.clone(), + editable_paths: ["live.rs".into()].into(), + before: "baseline".into(), + after: "Working checkout".into(), + revisions: ["before-snapshot".into(), "after-snapshot".into()], + files: vec![syntax_diff_file("live.rs", "old", "new")], + })), + ); + editor.handle(Input::Char('\t')); + editor.handle(Input::Char('\t')); + assert!(editor.diff_editing()); + editor.execute("tab.new"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); + let mut views = BufferViewports::default(); + for (inputs, counts) in [ + (vec![], "+1 -1"), + ( + vec![ + Input::Char('i'), + Input::Paste("extra\n".into()), + Input::Escape, + ], + "+2 -1", + ), + (vec![Input::Char('u')], "+1 -1"), + (vec![Input::Char('U')], "+2 -1"), + ] { + for input in inputs { + editor.handle(input); + } + editor.refresh_live_diff(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let titles = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); + assert!( + titles.contains(&format!("1 Diff · {counts} · live.rs")), + "{titles}" + ); + assert!(titles.contains("2 live.rs"), "{titles}"); + } +} + +#[test] +fn tab_titles_update_templates_without_reinterpreting_file_names() { + let directory = Directory::new(); + let path = directory.file("λ{index}\t.rs", "source"); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.execute("tab.new"); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste("changed ".into())); + editor.handle(Input::Escape); + configure_chrome(&mut editor, |top, _| { + let tabs = top.segments.get_mut("tabs").unwrap(); + tabs.title = "T{index}:{name}{modified}".into(); + tabs.padding = 0; + tabs.leading_edge.clear(); + tabs.trailing_edge.clear(); + }); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 3)).unwrap(); + terminal + .draw(|frame| { + draw_chrome( + frame, + chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), + frame.area(), + ); + }) + .unwrap(); + let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1)); + assert!(titles.contains("T1:λ{index}�.rs [+]"), "{titles}"); + assert!(titles.contains("T2:λ{index}�.rs [+]"), "{titles}"); + + configure_chrome(&mut editor, |top, _| { + top.segments.get_mut("tabs").unwrap().title = "{index}:{path}{modified}".into(); + }); + terminal + .draw(|frame| { + draw_chrome( + frame, + chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), + frame.area(), + ); + }) + .unwrap(); + let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1)); + let expected = display_text(&format!("2:{} [+]", path.display())); + assert!(titles.contains(&expected), "{titles}"); + assert!(!titles.contains("T2:"), "{titles}"); +} + +#[test] +fn chrome_anchors_center_on_the_full_row_and_preserve_right_order() { + let mut editor = Editor::open(None, vec![]).unwrap(); + configure_chrome(&mut editor, |top, bottom| { + top.left = vec!["tabs".into(), "left".into()]; + top.center = vec!["middle".into()]; + top.right = vec!["one".into(), "two".into()]; + bottom.left = vec!["left".into()]; + bottom.center = top.center.clone(); + bottom.right = top.right.clone(); + for line in [top, bottom] { + for name in ["left", "middle", "one", "two"] { + line.segments.insert( + name.into(), + SegmentConfig { + title: String::new(), + padding: 0, + margin: [0; 2], + leading_edge: String::new(), + trailing_edge: String::new(), + first_leading_edge: None, + }, + ); + } + } + }); + for (name, text) in [ + ("left", "LEFTEND"), + ("middle", "MID"), + ("one", "A"), + ("two", "BC"), + ] { + editor.set_label(name.into(), text.into()).unwrap(); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(51, 4)).unwrap(); + let syntax = SyntaxCache::new(); + terminal + .draw(|frame| { + draw_chrome( + frame, + chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), + frame.area(), + ); + draw_chrome( + frame, + chrome_groups(&editor, editor.bottom_bar_config(), &syntax), + Rect::new(3, 2, 45, 1), + ); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + for y in [0, 2] { + assert_eq!(buffer[(24, y)].symbol(), "M"); + assert_eq!(buffer[(25, y)].symbol(), "I"); + assert_eq!(buffer[(26, y)].symbol(), "D"); + } + assert!(pane_screen(&terminal, Rect::new(0, 0, 51, 1)).ends_with("ABC")); + assert!(pane_screen(&terminal, Rect::new(3, 2, 45, 1)).ends_with("ABC")); + + editor.set_label("left".into(), "L".repeat(30)).unwrap(); + terminal + .draw(|frame| { + draw_chrome( + frame, + chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), + frame.area(), + ); + draw_chrome( + frame, + chrome_groups(&editor, editor.bottom_bar_config(), &syntax), + Rect::new(3, 2, 45, 1), + ); + }) + .unwrap(); + for (y, x, width) in [(0, 0, 51), (2, 3, 45)] { + let row = pane_screen(&terminal, Rect::new(x, y, width, 1)); + assert!(!row.contains("MID"), "{row}"); + assert!(row.ends_with("ABC"), "{row}"); + } +} + +#[test] +fn active_tabs_keep_their_anchor_and_text_when_optional_groups_collide() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.execute("tab.new"); + configure_chrome(&mut editor, |top, bottom| { + top.left = vec!["prefix".into()]; + top.center = vec!["tabs".into()]; + top.right = vec!["suffix".into()]; + let tabs = top.segments.get_mut("tabs").unwrap(); + tabs.title = "{index}:active".into(); + tabs.padding = 64; + tabs.leading_edge = "界".repeat(8); + tabs.trailing_edge = "界".repeat(8); + *bottom = top.clone(); + }); + editor + .set_label("prefix".into(), "LEFT".repeat(32)) + .unwrap(); + editor + .set_label("suffix".into(), "RIGHT".repeat(32)) + .unwrap(); + for width in [40, 8, 1, 0] { + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 2)).unwrap(); + terminal + .draw(|frame| { + for (y, line) in [editor.top_bar_config(), editor.bottom_bar_config()] + .into_iter() + .enumerate() + { + draw_chrome( + frame, + chrome_groups(&editor, line, &SyntaxCache::new()), + Rect::new(0, y as u16, width, 1), + ); + } + }) + .unwrap(); + for y in 0..2 { + if width == 0 { + continue; + } + let row = pane_screen(&terminal, Rect::new(0, y, width, 1)); + assert!( + row.trim() + .starts_with(if width == 1 { "2" } else { "2:active" }), + "{row}" + ); + assert_eq!( + terminal.backend().buffer()[((width - width.min(8)) / 2, y)].symbol(), + "2" + ); + assert!(!row.contains("LEFT") && !row.contains("RIGHT"), "{row}"); + assert!(!row.contains('界'), "{row}"); + } + } + configure_chrome(&mut editor, |top, bottom| { + let tabs = top.segments.get_mut("tabs").unwrap(); + tabs.padding = 0; + tabs.leading_edge.clear(); + tabs.trailing_edge.clear(); + *bottom = top.clone(); + }); + editor + .set_label("prefix".into(), "LEFT".repeat(32)) + .unwrap(); + editor + .set_label("suffix".into(), "RIGHT".repeat(32)) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 2)).unwrap(); + terminal + .draw(|frame| { + for (y, line) in [editor.top_bar_config(), editor.bottom_bar_config()] + .into_iter() + .enumerate() + { + draw_chrome( + frame, + chrome_groups(&editor, line, &SyntaxCache::new()), + Rect::new(0, y as u16, 40, 1), + ); + } + }) + .unwrap(); + for y in 0..2 { + let row = pane_screen(&terminal, Rect::new(0, y, 40, 1)); + assert_eq!(&row[16..24], "2:active"); + } +} + +#[test] +fn chrome_uses_cell_widths_for_independent_edges_and_internal_padding() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.execute("tab.new"); + configure_chrome(&mut editor, |top, bottom| { + top.right = vec!["badge".into()]; + top.segments.insert( + "tabs".into(), + SegmentConfig { + title: "{index}".into(), + padding: 2, + margin: [0; 2], + leading_edge: "界▌".into(), + trailing_edge: "▐界".into(), + first_leading_edge: Some("<".into()), + }, + ); + top.segments.insert( + "badge".into(), + SegmentConfig { + title: String::new(), + padding: 2, + margin: [0; 2], + leading_edge: "界(".into(), + trailing_edge: ")界".into(), + first_leading_edge: None, + }, + ); + bottom.left.clear(); + bottom.center.clear(); + bottom.right = vec!["badge".into()]; + bottom + .segments + .insert("badge".into(), top.segments["badge"].clone()); + }); + editor.set_label("badge".into(), "e\u{301}".into()).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 3)).unwrap(); + let syntax = SyntaxCache::new(); + terminal + .draw(|frame| { + draw_chrome( + frame, + chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), + frame.area(), + ); + draw_chrome( + frame, + chrome_groups(&editor, editor.bottom_bar_config(), &syntax), + Rect::new(0, 2, 40, 1), + ); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + for (x, text) in [ + (2, "1"), + (5, "界"), + (7, "▌"), + (10, "2"), + (13, "▐"), + (14, "界"), + (29, "界"), + (31, "("), + (34, "e\u{301}"), + (37, ")"), + (38, "界"), + ] { + assert_eq!(buffer[(x, 0)].symbol(), text, "cell {x}"); + } + for (x, text) in [ + (29, "界"), + (31, "("), + (34, "e\u{301}"), + (37, ")"), + (38, "界"), + ] { + assert_eq!(buffer[(x, 2)].symbol(), text, "status cell {x}"); + } + for x in [32, 33, 35, 36] { + assert_eq!(buffer[(x, 2)].symbol(), " "); + assert_eq!(buffer[(x, 2)].bg, Color::Cyan); + } + assert_eq!(buffer[(2, 0)].bg, Color::Reset); + assert_eq!(buffer[(10, 0)].bg, Color::Cyan); + assert_eq!(buffer[(5, 0)].bg, Color::Reset); + for x in [8, 9, 11, 12, 32, 33, 35, 36] { + assert_eq!(buffer[(x, 0)].symbol(), " "); + assert_eq!(buffer[(x, 0)].bg, Color::Cyan); + } + editor.execute("tab.previous"); + terminal + .draw(|frame| { + draw_chrome( + frame, + chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), + frame.area(), + ); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(0, 0)].symbol(), "<"); + assert_eq!(buffer[(3, 0)].symbol(), "1"); + assert_eq!(buffer[(3, 0)].bg, Color::Cyan); + assert_eq!(buffer[(11, 0)].symbol(), "2"); + assert_eq!(buffer[(11, 0)].bg, Color::Reset); +} + +#[test] +fn empty_bar_content_reclaims_both_rows_even_while_prompting() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.execute("tab.new"); + configure_chrome(&mut editor, |top, bottom| { + top.left.clear(); + top.center = vec!["empty".into()]; + top.right.clear(); + top.segments.insert( + "empty".into(), + SegmentConfig { + title: "Not visible without a value".into(), + padding: 64, + margin: [0; 2], + leading_edge: "░▒▓".into(), + trailing_edge: "▓▒░".into(), + first_leading_edge: None, + }, + ); + *bottom = top.clone(); + }); + let mut syntax = SyntaxCache::new(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); + terminal + .draw(|frame| { + let workspace = draw_bars(frame, &editor, frame.area(), &mut syntax); + assert_eq!(workspace, frame.area()); + }) + .unwrap(); + editor.set_label("empty".into(), "present".into()).unwrap(); + terminal + .draw(|frame| { + let workspace = draw_bars(frame, &editor, frame.area(), &mut syntax); + assert_eq!(workspace, Rect::new(0, 1, 40, 3)); + }) + .unwrap(); + for y in [0, 4] { + let title = pane_screen(&terminal, Rect::new(0, y, 40, 1)); + assert!(title.contains("Not visible without a value"), "{title}"); + assert!(!title.contains('▓'), "{title}"); + } + editor.handle(Input::Char(':')); + terminal + .draw(|frame| { + let workspace = draw_bars(frame, &editor, frame.area(), &mut syntax); + assert_eq!(workspace, Rect::new(0, 1, 40, 3)); + }) + .unwrap(); +} + +#[test] +fn tiny_bars_yield_to_document_and_prompt_identically_in_either_position() { + let directory = Directory::new(); + let path = directory.file("source.txt", "DOCUMENT"); + for segment in ["tabs", "mode"] { + for at_top in [false, true] { + for prompting in [false, true] { + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.execute("tab.new"); + configure_chrome(&mut editor, |top, bottom| { + for line in [&mut *top, &mut *bottom] { + line.left.clear(); + line.center.clear(); + line.right.clear(); + } + let line = if at_top { top } else { bottom }; + line.left = vec![segment.into()]; + }); + if prompting { + editor.handle(Input::Char(':')); + } + for height in 1..=4 { + let area = Rect::new(3, 2, 80, height); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(100, 10), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let bar_text = match segment { + "tabs" => "2 source.txt", + _ if prompting => "COMMAND", + _ => "NORMAL", + }; + let visible = height >= 2; + assert_eq!(screen_text(&terminal).contains(bar_text), visible); + let cursor = terminal.get_cursor_position().unwrap(); + assert!( + area.contains(cursor), + "{segment} {at_top} prompting={prompting} {area:?}: {cursor:?}" + ); + let viewport = views.document.geometry.unwrap().1; + assert_eq!(viewport.height, height - u16::from(visible)); + if prompting { + let input = views.panels.input.unwrap(); + assert!(input.contains(cursor)); + } else { + assert!(viewport.contains(cursor)); + assert!(pane_screen(&terminal, viewport).contains("DOCUMENT")); + } + if visible { + let y = if at_top { area.y } else { area.bottom() - 1 }; + assert!( + pane_screen(&terminal, Rect::new(area.x, y, area.width, 1)) + .contains(bar_text) + ); + } + } + } + } + } +} + +#[test] +fn tabs_only_singletons_hide_in_either_bar_and_reappear_with_another_tab() { + let mut editor = Editor::open(None, vec![]).unwrap(); + configure_chrome(&mut editor, |top, bottom| *bottom = top.clone()); + let mut views = BufferViewports::default(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(views.rects[0].1, Rect::new(0, 0, 80, 12)); + assert!(views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, 0),)); + + editor.execute("tab.new"); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(views.rects[0].1, Rect::new(0, 1, 80, 10)); + for y in [0, 11] { + assert!(!views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, y),)); + } + let caret = terminal.get_cursor_position().unwrap(); + assert!(views.rects[0].1.contains(caret)); + for y in [0, 11] { + let bar = pane_screen(&terminal, Rect::new(0, y, 80, 1)); + assert!( + bar.contains("1 [No Name]") && bar.contains("2 [No Name]"), + "{bar}" + ); + } + editor.execute("tab.close"); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(views.rects[0].1, Rect::new(0, 0, 80, 12)); + assert!(!screen_text(&terminal).contains("[No Name]")); + for y in [0, 11] { + assert!(views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, y),)); + } +} + +#[test] +fn swapped_bars_render_active_metadata_and_language_on_the_first_paint() { + let directory = Directory::new(); + let path = directory.file("active.rs", "fn main() {}"); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.execute("tab.new"); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste("// changed\n".into())); + editor.handle(Input::Escape); + configure_chrome(&mut editor, std::mem::swap); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 12)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let top = pane_screen(&terminal, Rect::new(0, 0, 240, 1)); + assert!(top.contains("NORMAL"), "{top}"); + assert!(top.contains(&path.display().to_string()), "{top}"); + assert!(top.contains("[+]") && top.contains("Rust"), "{top}"); + let cursor = editor.cursor(); + assert!( + top.contains(&format!("{}:{}", cursor.row + 1, cursor.column + 1)), + "{top}" + ); + let bottom = pane_screen(&terminal, Rect::new(0, 11, 240, 1)); + assert!( + bottom.contains("1 active.rs [+]") && bottom.contains("2 active.rs [+]"), + "{bottom}" + ); + assert_eq!(views.document.geometry.unwrap().1.y, 1); + let before = views.rects[0].1; + editor.handle(Input::Char(':')); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(views.rects[0].1, before); + let input = views.panels.input.unwrap(); + assert!(input.contains(terminal.get_cursor_position().unwrap())); + assert_eq!(pane_screen(&terminal, Rect::new(0, 11, 240, 1)), bottom); +} + +#[test] +fn both_bars_bound_palettes_diff_selectors_docks_and_diff_prompts() { + let directory = Directory::new(); + let mut editor = + Editor::open(Some(directory.file("source.rs", "fn main() {}")), vec![]).unwrap(); + editor.execute("tab.new"); + configure_chrome(&mut editor, |top, bottom| { + std::mem::swap(top, bottom); + top.left = vec!["mode".into(), "language".into()]; + top.center = vec!["workspace".into()]; + top.right = vec!["position".into()]; + bottom.right = vec!["battery".into()]; + }); + editor + .set_label("workspace".into(), "WORKSPACE".into()) + .unwrap(); + editor.set_label("battery".into(), "BOTTOM".into()).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + let mut views = BufferViewports::default(); + let assert_bars = |terminal: &Terminal, + views: &BufferViewports| { + assert!(pane_screen(terminal, Rect::new(0, 0, 120, 1)).contains("WORKSPACE")); + let bottom_y = 29; + assert!(pane_screen(terminal, Rect::new(0, bottom_y, 120, 1)).contains("BOTTOM")); + for (_, rect) in &views.rects { + assert!(rect.y >= 1 && rect.bottom() <= bottom_y); + } + if let Some(geometry) = &views.panels.diff { + for (rect, _) in geometry.regions { + if rect.height > 0 { + assert!(rect.y >= 1 && rect.bottom() <= bottom_y); + } + } + } + if let Some((rect, _, _)) = views.panels.files { + assert!(rect.y >= 1 && rect.bottom() <= bottom_y); + } + }; + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)).contains("Rust")); + assert_bars(&terminal, &views); + editor.handle(Input::Ctrl('p')); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(screen_text(&terminal).contains("Commands")); + assert_bars(&terminal, &views); + editor.handle(Input::Escape); + + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(screen_text(&terminal).contains("Choose Before")); + assert!(views.document.geometry.is_none()); + assert_bars(&terminal, &views); + editor.handle(Input::Enter); + editor.handle(Input::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result( + id, + Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { + root: directory.0.clone(), + editable_paths: Default::default(), + before: "baseline".into(), + after: "Working checkout".into(), + revisions: ["before-snapshot".into(), "after-snapshot".into()], + files: vec![deletion_file()], + })), + ); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(views.panels.files.is_some()); + assert_bars(&terminal, &views); + editor.handle(Input::Char(':')); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert!(Rect::new(0, 1, 120, 28).contains(cursor), "{cursor:?}"); + assert_bars(&terminal, &views); + editor.handle(Input::Escape); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('o')); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!( + editor.file_picker().unwrap().presentation, + FilePresentation::Floating + ); + assert_bars(&terminal, &views); +} + +#[test] +fn narrow_tab_bar_keeps_the_active_title_visible_and_handles_empty_areas() { + let directory = Directory::new(); + let mut editor = Editor::open( + Some(directory.file("a-very-long-inactive-title.txt", "first")), + vec![], + ) + .unwrap(); + editor.execute("tab.new"); + editor.execute("tab.new"); + editor.execute(&format!( + "edit {}", + directory.file("active.txt", "ACTIVE").display() + )); + editor.set_battery_charge(Some(27)); + let mut views = BufferViewports::default(); + for (width, height) in [(12, 9), (7, 9), (1, 9), (0, 9), (12, 1), (12, 0)] { + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + if width > 0 && height >= 3 { + let title = pane_screen(&terminal, Rect::new(0, 0, width, 1)); + assert!(title.trim_start().starts_with('3'), "{title}"); + assert!(!title.contains("bat 27%"), "{title}"); + } + if width > 0 && height == 1 { + let (_, viewport) = views.document.geometry.unwrap(); + assert!(viewport.contains(terminal.get_cursor_position().unwrap())); + assert!(pane_screen(&terminal, viewport).contains("ACTIVE")); + } + } + + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.execute("tab.new"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let titles = pane_screen(&terminal, Rect::new(0, 0, 40, 1)); + assert!(titles.contains("1 [No Name]"), "{titles}"); + assert!(titles.contains("2 [No Name]"), "{titles}"); +} + +#[test] +fn inline_blame_sanitizes_metadata_preserves_text_and_hides_stale_labels() { + let directory = Directory::new(); + let mut editor = Editor::open( + Some(directory.file("source.txt", "界 e\u{301}\nsecond")), + vec![], + ) + .unwrap(); + let source = editor.blame_source().unwrap(); + assert!(editor.set_line_blame( + source, + Arc::new(crate::blame::LineBlame { + commit: "0123456789abcdef".into(), + author: "A\tuthor".into(), + summary: "Summary\n\u{1b}done".into(), + uncommitted: false, + }) + )); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 8)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let screen = screen_text(&terminal); + assert!( + screen.contains("界") && screen.contains("e\u{301}"), + "{screen}" + ); + assert!( + screen.contains("A�uthor · Summary��done (01234567)"), + "{screen}" + ); + let caret = terminal.get_cursor_position().unwrap(); + let cell = terminal.backend().buffer()[(caret.x, caret.y)].clone(); + editor.handle(Input::Down); + assert!(editor.line_blame().is_none()); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(!screen_text(&terminal).contains("Summary")); + editor.handle(Input::Up); + assert_eq!( + editor.line_blame(), + Some("A�uthor · Summary��done (01234567)") + ); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(screen_text(&terminal).contains("A�uthor · Summary��done (01234567)")); + assert_eq!(terminal.get_cursor_position().unwrap(), caret); + assert_eq!(terminal.backend().buffer()[(caret.x, caret.y)], cell); +} + +#[test] +fn inline_blame_uses_last_wrap_continuation_and_never_overflows_narrow_panes() { + let directory = Directory::new(); + let mut editor = + Editor::open(Some(directory.file("source.txt", &"x".repeat(60))), vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor.execute("word_wrap.toggle"); + let source = editor.blame_source().unwrap(); + editor.set_line_blame( + source, + Arc::new(crate::blame::LineBlame { + commit: String::new(), + author: String::new(), + summary: String::new(), + uncommitted: true, + }), + ); + let mut views = BufferViewports::default(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 8)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let caret = terminal.get_cursor_position().unwrap(); + assert!(!pane_screen(&terminal, Rect::new(0, caret.y, 50, 1)).contains("Uncommitted")); + assert!(pane_screen(&terminal, Rect::new(0, caret.y + 1, 50, 1)).contains("Uncommitted")); + let cell = terminal.backend().buffer()[(16, caret.y + 1)].clone(); + assert_eq!(cell.symbol(), "U"); + assert_eq!(cell.fg, Color::DarkGray); + let mut narrow = Terminal::new(ratatui::backend::TestBackend::new(12, 8)).unwrap(); + narrow.draw(|frame| views.draw(frame, &mut editor)).unwrap(); + assert!(!screen_text(&narrow).contains("Uncommitted")); + assert!(screen_text(&narrow).contains("xxx")); + editor.execute("git_blame.toggle"); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(!screen_text(&terminal).contains("Uncommitted")); + let mut historical = readonly_diff_editor(deletion_file()); + assert!(historical.blame_source().is_none()); + historical.execute("tab.new"); + assert!(historical.diff().is_none()); + assert!(historical.blame_source().is_none()); +} + +#[test] +fn inline_blame_disappears_when_wheel_scroll_hides_the_primary_caret() { + let directory = Directory::new(); + let text = (0..40) + .map(|row| format!("row {row}")) + .collect::>() + .join("\n"); + let mut session = + EditorSession::open(Some(directory.file("source.txt", &text)), vec![], None).unwrap(); + let source = session.editor().blame_source().unwrap(); + session.editor_mut().set_line_blame( + source, + Arc::new(crate::blame::LineBlame { + commit: String::new(), + author: String::new(), + summary: String::new(), + uncommitted: true, + }), + ); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 8)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(screen_text(&terminal).contains("Uncommitted")); + let area = views.document.geometry.unwrap().1; + assert!(views.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, area.x + 2, area.y + 2) + )); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(!screen_text(&terminal).contains("Uncommitted")); + assert_eq!(session.editor().cursor().row, 0); +} + +#[test] +fn default_native_battery_and_custom_override_redraw_through_clear() { + let directory = Directory::new(); + let mut editor = Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let original = screen_text(&terminal); + assert_eq!(views.document.geometry.unwrap().1.y, 0); + assert_eq!(editor.tab_count(), 1); + assert!(!screen_text(&terminal).contains("bat ")); + assert!(editor.set_battery_charge(Some(27))); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); + assert!(title.trim_end().ends_with("bat 27%"), "{title}"); + assert_eq!(views.document.geometry.unwrap().1.y, 1); + + editor + .set_label("battery".into(), "電池 e\u{301}\t90%".into()) + .unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); + assert!(title.contains("1 source.txt"), "{title}"); + assert!(title.contains("電") && title.contains("池"), "{title}"); + assert!(title.contains("e\u{301}�90%"), "{title}"); + assert!(!title.contains("bat 27%"), "{title}"); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(78, 0)].symbol(), "%"); + assert_eq!(buffer[(79, 0)].symbol(), " "); + assert_eq!(views.document.geometry.unwrap().1.y, 1); + assert!(pane_screen(&terminal, views.rects[0].1).contains("DOCUMENT")); + assert!(terminal.get_cursor_position().unwrap().y >= 1); + + editor.set_label("battery".into(), "live".into()).unwrap(); + assert!(editor.set_battery_charge(Some(28))); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!( + pane_screen(&terminal, Rect::new(0, 0, 80, 1)) + .trim_end() + .ends_with("live") + ); + assert!(!screen_text(&terminal).contains("90%")); + editor.set_label("battery".into(), String::new()).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); + assert!(title.trim_end().ends_with("bat 28%"), "{title}"); + assert!(!title.contains("live"), "{title}"); + assert_eq!(views.document.geometry.unwrap().1.y, 1); + assert!(editor.set_battery_charge(None)); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(views.document.geometry.unwrap().1.y, 0); + assert_eq!(screen_text(&terminal), original); +} + +#[test] +fn native_battery_renders_at_each_configured_anchor_in_either_bar() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_battery_charge(Some(27)); + for at_top in [true, false] { + for anchor in 0..3 { + configure_chrome(&mut editor, |top, bottom| { + for line in [&mut *top, &mut *bottom] { + line.left.clear(); + line.center.clear(); + line.right.clear(); + } + let line = if at_top { top } else { bottom }; + [&mut line.left, &mut line.center, &mut line.right][anchor].push("battery".into()); + line.segments.insert( + "battery".into(), + SegmentConfig { + padding: 0, + ..SegmentConfig::default() + }, + ); + }); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 6)).unwrap(); + terminal + .draw(|frame| { + draw_bars(frame, &editor, frame.area(), &mut SyntaxCache::new()); + }) + .unwrap(); + let y = if at_top { 0 } else { 5 }; + let row = pane_screen(&terminal, Rect::new(0, y, 48, 1)); + assert_eq!(row.trim(), "bat 27%"); + let x = [0, 20, 41][anchor]; + assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), "b"); + } + } + configure_chrome(&mut editor, |top, bottom| { + top.right.clear(); + bottom.right.clear(); + }); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 6)).unwrap(); + terminal + .draw(|frame| { + let workspace = draw_bars(frame, &editor, frame.area(), &mut SyntaxCache::new()); + assert_eq!(workspace, frame.area()); + }) + .unwrap(); + assert!(!screen_text(&terminal).contains("bat 27%")); +} + +#[test] +fn custom_value_keeps_a_gap_and_yields_to_the_full_active_title() { + let directory = Directory::new(); + let mut editor = Editor::open( + Some(directory.file("a-very-long-inactive-title.txt", "first")), + vec![], + ) + .unwrap(); + editor.execute("tab.new"); + editor.execute("tab.new"); + editor.execute(&format!( + "edit {}", + directory.file("active.txt", "ACTIVE").display() + )); + configure_chrome(&mut editor, |top, _| { + let tabs = top.segments.get_mut("tabs").unwrap(); + tabs.leading_edge = "<<<".into(); + tabs.trailing_edge = ">>>".into(); + top.segments.get_mut("battery").unwrap().leading_edge = "<<<".into(); + }); + editor.set_label("battery".into(), "90%".into()).unwrap(); + for width in [28, 29, 80] { + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 2)).unwrap(); + terminal + .draw(|frame| { + let area = frame.area(); + draw_chrome( + frame, + chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), + area, + ); + }) + .unwrap(); + let title = pane_screen(&terminal, Rect::new(0, 0, width, 1)); + assert!(title.contains("3 active.txt"), "{title}"); + if width == 28 { + assert!(!title.contains("90%"), "{title}"); + } else { + assert!(title.ends_with(" <<< 90% "), "{title}"); + } + } + editor + .set_label("battery".into(), "界".repeat(1024)) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 2)).unwrap(); + terminal + .draw(|frame| { + let area = frame.area(); + draw_chrome( + frame, + chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), + area, + ); + }) + .unwrap(); + let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); + assert!(title.contains("3 active.txt"), "{title}"); + assert!(!title.contains('界'), "{title}"); +} + +#[test] +fn custom_value_stays_above_split_overlays_and_diff_geometry() { + let directory = Directory::new(); + let mut editor = Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap(); + editor.execute("pane.split_vertical"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let original = views.rects.clone(); + editor.set_label("battery".into(), "live".into()).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + for ((id, before), (after_id, after)) in original.iter().zip(&views.rects) { + assert_eq!(id, after_id); + assert_eq!( + *after, + Rect::new(before.x, before.y + 1, before.width, before.height - 1) + ); + } + let title = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); + editor.handle(Input::Ctrl('p')); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(screen_text(&terminal).contains("Commands")); + assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title); + + let mut editor = readonly_diff_editor(deletion_file()); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let regions = views.panels.diff.as_ref().unwrap().regions; + editor.set_label("battery".into(), "live".into()).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + for ((before, focus), (after, after_focus)) in regions + .into_iter() + .zip(views.panels.diff.as_ref().unwrap().regions) + { + assert_eq!(focus, after_focus); + if before == Rect::default() { + assert_eq!(after, before); + } else { + assert_eq!( + after, + Rect::new(before.x, before.y + 1, before.width, before.height - 1) + ); + } + } + assert_eq!(views.panels.files.unwrap().0.y, views.rects[0].1.y); + let title = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('o')); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title); +} + +#[test] +fn tab_switches_restore_independent_pane_viewports_and_retire_only_closed_panes() { + let directory = Directory::new(); + let text = (0..80) + .map(|row| format!("row-{row:02} {}", "long ".repeat(30))) + .collect::>() + .join("\n"); + let mut session = + EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap(); + let buffer = session.editor().buffer_id(); + session.execute("tab.new"); + let other = session.editor().pane_id(); + session.execute("tab.previous"); + session.execute("pane.split_vertical"); + let split = session.editor().pane_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let area = views.rects.iter().find(|(id, _)| *id == split).unwrap().1; + assert_eq!(area.y, 1); + assert!(!views.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 2, 0) + )); + for _ in 0..2 { + for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollRight] { + assert!(views.scroll(&mut session, document_wheel(kind, area.x + 2, area.y + 2))); + } + } + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let split_offset = views.offsets[&buffer]; + assert!(split_offset.0 > 0 && split_offset.1 > 0); + + session.execute("tab.next"); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(views.rects.len(), 1); + assert_eq!(views.parked[&split].offsets[&buffer], split_offset); + for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollRight] { + assert!(views.scroll(&mut session, document_wheel(kind, 2, 2))); + } + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let other_offset = views.offsets[&buffer]; + assert_ne!(other_offset, split_offset); + + session.execute("tab.previous"); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(session.editor().pane_id(), split); + assert_eq!(views.rects.len(), 2); + assert_eq!(views.offsets[&buffer], split_offset); + assert_eq!(views.parked[&other].offsets[&buffer], other_offset); + session.execute("tab.next"); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(views.offsets[&buffer], other_offset); + session.execute("tab.close"); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(!views.parked.contains_key(&other)); + assert_eq!(views.offsets[&buffer], split_offset); +} + +#[test] +fn hidden_tab_diff_caches_do_not_poll_colors_or_report_stale_viewports() { + let directory = Directory::new(); + let mut session = EditorSession::open( + Some(directory.file("source.txt", "WORKSPACE")), + vec![], + None, + ) + .unwrap(); + let source = (0..80) + .map(|row| format!("old-{row:02} {}", "x ".repeat(30))) + .collect::>() + .join("\n"); + install_readonly_diff( + session.editor_mut(), + syntax_diff_file("history.txt", &source, &source.replace("old", "new")), + ); + let owner = session.editor().pane_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(views.diff_visible); + let (id, file, _, _, _) = views.panels.diff.as_ref().unwrap().viewport.unwrap(); + session.set_diff_viewport(id, file, 24, 7, 8); + session.execute("tab.new"); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(!views.diff_visible); + assert!(views.parked[&owner].panels.diff.is_some()); + assert!(views.rects.iter().all(|(id, _)| *id != owner)); + + session.execute("tab.previous"); + let diff = session.editor().diff().unwrap(); + assert_eq!((diff.top, diff.left_before, diff.left_after), (24, 7, 8)); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(views.diff_visible); + + session.execute("diff"); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(views.panels.diff.as_ref().unwrap().regions[0].0.y >= 1); +} + +#[test] +fn explorer_dock_reserves_space_outside_all_workspace_panes() { + let directory = Directory::new(); + let mut editor = + Editor::open(Some(directory.file("shared.txt", "first\nsecond")), vec![]).unwrap(); + editor.set_project_root(directory.0.clone()); + editor.handle(Input::Char('|')); + editor.handle(Input::Char('_')); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('e')); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let dock = views.panels.files.unwrap().0; + assert!(views.rects.iter().all(|(_, pane)| pane.right() <= dock.x)); + assert_eq!(dock.right(), 120); + assert_eq!(dock.y, 0); + assert_eq!( + Some(dock.bottom()), + views.rects.iter().map(|(_, pane)| pane.bottom()).max() + ); + assert_eq!(dock.height + 1, terminal.backend().buffer().area.height); +} + +#[test] +fn native_diff_dock_and_panes_fill_the_workspace_below_only_configured_chrome() { + for (width, height) in [(120, 30), (32, 9)] { + for chrome in [false, true] { + let mut editor = readonly_diff_editor(deletion_file()); + if chrome { + editor.set_label("battery".into(), "live".into()).unwrap(); + } + let mut views = BufferViewports::default(); + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let dock = views.panels.files.unwrap().0; + let workspace = views.rects[0].1; + let regions = views.panels.diff.as_ref().unwrap().regions; + assert_eq!(dock.y, workspace.y); + assert_eq!(dock.right(), width); + assert_eq!(dock.bottom(), workspace.bottom()); + for (pane, _) in regions.into_iter().skip(1) { + assert_eq!(pane.y, workspace.y); + assert_eq!(pane.bottom(), workspace.bottom()); + assert_eq!(pane.y, u16::from(chrome)); + } + } + } +} + +#[test] +fn revision_selector_overlays_and_clears_workspace_pane_borders() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.handle(Input::Char('|')); + editor.execute("diff"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(screen_text(&terminal).contains("Choose Before")); + let results = views.panels.diff.as_ref().unwrap().regions[0].0; + assert!(results.x < 60 && results.right() > 60); + for x in [59, 60] { + assert_eq!(terminal.backend().buffer()[(x, 20)].symbol(), " "); + } +} + +#[test] +fn diff_floating_picker_overlays_the_whole_workspace() { + let directory = Directory::new(); + let mut editor = Editor::open(Some(directory.file("source.txt", "workspace")), vec![]).unwrap(); + editor.handle(Input::Char('|')); + install_readonly_diff(&mut editor, syntax_diff_file("history.txt", "old", "new")); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('o')); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let panel = views.panels.files.unwrap().0; + assert!(panel.x < 60 && panel.right() > 60); + assert!(panel.y >= views.rects[0].1.y); + assert!(panel.bottom() <= views.rects[0].1.bottom()); +} + +#[test] +fn nested_workspace_panes_share_edits_and_undo_but_not_carets() { + let directory = Directory::new(); + let mut editor = Editor::open( + Some(directory.file("shared.rs", "fn original() {}\nsecond")), + vec![], + ) + .unwrap(); + let root = editor.pane_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 22)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + editor.handle(Input::Char('|')); + let right = editor.pane_id(); + editor.handle(Input::Char('_')); + let bottom = editor.pane_id(); + assert_ne!(root, right); + assert_ne!(right, bottom); + editor.handle(Input::Right); + assert_eq!( + editor.with_render_pane(root, |view| view.cursor()), + Cursor::default() + ); + assert_eq!( + editor.with_render_pane(right, |view| view.cursor()), + Cursor::default() + ); + assert_eq!(editor.cursor().column, 1); + editor.handle(Input::Home); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste("SHARED ".into())); + editor.handle(Input::Escape); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(views.rects.len(), 3); + for (_, area) in &views.rects { + assert!(pane_screen(&terminal, *area).contains("SHARED")); + } + let focused = views.rects.iter().find(|(id, _)| *id == bottom).unwrap().1; + assert!(focused.contains(terminal.get_cursor_position().unwrap())); + assert_eq!( + terminal.backend().buffer()[(focused.x, focused.y)].fg, + Color::Cyan + ); + assert_eq!(views.syntax.len(), 1); + editor.handle(Input::Char('u')); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(!screen_text(&terminal).contains("SHARED")); + editor.execute("pane.close"); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(views.rects.len(), 2); + assert!(!views.parked.contains_key(&bottom)); +} + +#[test] +fn keyboard_viewport_scroll_preserves_targets_and_reveals_on_motion() { + let directory = Directory::new(); + let text = (0..80) + .map(|row| format!("row-{row:02} {}", "word ".repeat(24))) + .collect::>() + .join("\n"); + for diff in [false, true] { + for wrapped in [false, true] { + for visual in [false, true] { + let mut editor = if diff { + readonly_diff_editor(syntax_diff_file( + "rows.txt", + &text, + &text.replace("word", "text"), + )) + } else { + Editor::open(Some(directory.file("rows.txt", &text)), vec![]).unwrap() + }; + if wrapped { + editor.execute("word_wrap.toggle"); + } + editor + .bind("normal", "alt+j", Some("core.scroll_down".into())) + .unwrap(); + editor + .bind("normal", "alt+k", Some("core.scroll_up".into())) + .unwrap(); + if diff { + editor.handle(Input::Char('\t')); + } + editor.handle(Input::Ctrl('e')); + if visual { + editor.handle(Input::Char('v')); + editor.handle(Input::Right); + } + let buffer = editor.buffer_id(); + let carets = editor.carets(); + let selections = editor.selections(); + let cursor = editor.cursor(); + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(80, 14)).unwrap(); + let mut views = BufferViewports::default(); + let render = |terminal: &mut Terminal, + views: &mut BufferViewports, + editor: &mut Editor| { + terminal.draw(|frame| views.draw(frame, editor)).unwrap(); + if diff { + let (id, file, top, before, after) = + views.panels.diff.as_ref().unwrap().viewport.unwrap(); + editor.set_diff_viewport(id, file, top, before, after); + } + }; + let offset = |views: &BufferViewports| { + if diff { + let top = views.panels.diff_wrap.as_ref().unwrap().top; + (top.display, top.continuation) + } else { + ( + views.offsets[&buffer].0, + *views.document.continuations.get(&buffer).unwrap_or(&0), + ) + } + }; + render(&mut terminal, &mut views, &mut editor); + let initial = offset(&views); + let height = if diff { + views.panels.diff_wrap.as_ref().unwrap().heights[0] + } else { + usize::from(views.document.geometry.unwrap().1.height) + }; + editor.handle(Input::Ctrl('d')); + render(&mut terminal, &mut views, &mut editor); + let scrolled = offset(&views); + assert!( + scrolled > initial, + "diff={diff}, wrap={wrapped}, visual={visual}" + ); + if !wrapped { + assert_eq!(scrolled, (initial.0 + height / 2, 0)); + } + assert_eq!(editor.carets(), carets); + assert_eq!(editor.selections(), selections); + render(&mut terminal, &mut views, &mut editor); + assert_eq!(offset(&views), scrolled); + editor.handle(Input::Ctrl('u')); + render(&mut terminal, &mut views, &mut editor); + assert_eq!(offset(&views), initial); + editor.handle(Input::Ctrl('u')); + render(&mut terminal, &mut views, &mut editor); + assert_eq!(offset(&views), initial); + editor.set_scroll_step_lines(std::num::NonZeroUsize::new(3)); + editor.handle(Input::Char('2')); + editor.handle(Input::Ctrl('d')); + render(&mut terminal, &mut views, &mut editor); + if !wrapped { + assert_eq!(offset(&views), (initial.0 + 6, 0)); + } + assert_eq!(editor.carets(), carets); + assert_eq!(editor.selections(), selections); + editor.handle(Input::Char('2')); + editor.handle(Input::Ctrl('u')); + render(&mut terminal, &mut views, &mut editor); + assert_eq!(offset(&views), initial); + if !visual { + editor.handle(Input::Char('3')); + editor.handle(Input::Alt('j')); + render(&mut terminal, &mut views, &mut editor); + assert!(offset(&views) > initial); + if !wrapped { + assert_eq!(offset(&views), (initial.0 + 3, 0)); + } + editor.handle(Input::Char('3')); + editor.handle(Input::Alt('k')); + render(&mut terminal, &mut views, &mut editor); + assert_eq!(offset(&views), initial); + } + editor.handle(Input::Ctrl('d')); + render(&mut terminal, &mut views, &mut editor); + editor.handle(Input::Home); + render(&mut terminal, &mut views, &mut editor); + assert!(views.panels.caret.is_some()); + assert_eq!(editor.cursor().row, cursor.row); + } + } + } +} + +#[test] +fn wheel_targets_inactive_workspace_pane_and_split_inherits_offsets() { + let directory = Directory::new(); + let text = (0..80) + .map(|row| format!("row-{row:02} {}", "long ".repeat(30))) + .collect::>() + .join("\n"); + let mut session = + EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap(); + let root = session.editor().pane_id(); + let buffer = session.editor().buffer_id(); + for _ in 0..3 { + session.handle(Input::Right); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(views.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 2, 1) + )); + session.handle(Input::Char('|')); + let focused = session.editor().pane_id(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(views.offsets[&buffer], views.parked[&root].offsets[&buffer]); + let focused_offset = views.offsets[&buffer]; + let root_area = views.rects.iter().find(|(id, _)| *id == root).unwrap().1; + let cursor = session.editor().cursor(); + assert!(views.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, root_area.x + 2, root_area.y + 2) + )); + assert!(views.scroll( + &mut session, + document_wheel( + MouseEventKind::ScrollRight, + root_area.x + 2, + root_area.y + 2 + ) + )); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(session.editor().pane_id(), focused); + assert_eq!(session.editor().cursor(), cursor); + assert_eq!(views.offsets[&buffer], focused_offset); + assert!(views.parked[&root].offsets[&buffer].0 > focused_offset.0); + assert!(views.parked[&root].offsets[&buffer].1 > focused_offset.1); + let inactive_offset = views.parked[&root].offsets[&buffer]; + let inactive_cursor = session + .editor_mut() + .with_render_pane(root, |view| view.cursor()); + // Even a motion that leaves the focused caret in place requests reveal. + session.handle(Input::Home); + session.handle(Input::Home); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset); + session.handle(Input::Down); + session.handle(Input::End); + session.handle(Input::Char('i')); + session.handle(Input::Paste(" typed".into())); + session.handle(Input::Escape); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!( + session + .editor_mut() + .with_render_pane(root, |view| view.cursor()), + inactive_cursor + ); + assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset); + session.handle(Input::Up); + session.handle(Input::Home); + session.handle(Input::Char('i')); + session.handle(Input::Paste("PREFIX ".into())); + session.handle(Input::Escape); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let rebased = session + .editor_mut() + .with_render_pane(root, |view| view.cursor()); + assert_eq!(rebased.row, inactive_cursor.row); + assert!(rebased.column > inactive_cursor.column); + assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset); + let focused_after_typing = views.offsets[&buffer]; + session.handle(Input::Alt('h')); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(session.editor().pane_id(), root); + assert!(views.offsets[&buffer].0 > focused_offset.0); + assert_eq!( + views.parked[&focused].offsets[&buffer], + focused_after_typing + ); +} + +#[test] +fn wrapped_workspace_panes_scroll_independently_and_keep_toggle_anchor() { + let directory = Directory::new(); + let text = (0..40) + .map(|row| format!("row-{row:02} {}", "word ".repeat(35))) + .collect::>() + .join("\n"); + let mut session = + EditorSession::open(Some(directory.file("wrap.txt", &text)), vec![], None).unwrap(); + session.execute("word_wrap.toggle"); + let root = session.editor().pane_id(); + let buffer = session.editor().buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(70, 24)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(views.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 2, 1) + )); + session.handle(Input::Char('|')); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let inherited = ( + views.offsets[&buffer].0, + views.document.continuations[&buffer], + ); + assert_eq!( + inherited, + ( + views.parked[&root].offsets[&buffer].0, + views.parked[&root].document.continuations[&buffer] + ) + ); + let root_area = views.rects.iter().find(|(id, _)| *id == root).unwrap().1; + assert!(views.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, root_area.x + 2, root_area.y + 2) + )); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!( + ( + views.offsets[&buffer].0, + views.document.continuations[&buffer] + ), + inherited + ); + assert_ne!( + ( + views.parked[&root].offsets[&buffer].0, + views.parked[&root].document.continuations[&buffer] + ), + inherited + ); + session.handle(Input::Right); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let caret = terminal.get_cursor_position().unwrap(); + session.execute("word_wrap.toggle"); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap().y, caret.y); +} + +#[test] +fn comparison_owner_coexists_with_workspace_and_inactive_diff_wheel_keeps_focus() { + let directory = Directory::new(); + let mut session = EditorSession::open( + Some(directory.file("source.txt", "WORKSPACE\nsecond")), + vec![], + None, + ) + .unwrap(); + let source = (0..80) + .map(|row| format!("old-{row:02} {}", "x ".repeat(30))) + .collect::>() + .join("\n"); + let after = source.replace("old", "new"); + let workspace = session.editor().buffer_id(); + install_readonly_diff( + session.editor_mut(), + syntax_diff_file("history.txt", &source, &after), + ); + let owner = session.editor().pane_id(); + session.execute("pane.split_horizontal"); + let focused = session.editor().pane_id(); + session.execute(&format!("buffer {workspace}")); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 34)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(session.editor().diff().is_none()); + assert!(screen_text(&terminal).contains("WORKSPACE")); + assert!(screen_text(&terminal).contains("Before")); + assert!(screen_text(&terminal).contains("After")); + let geometry = views.parked[&owner].panels.diff.as_ref().unwrap(); + let before = geometry.regions[1].0; + let cursor = session.editor().cursor(); + let offsets = views.offsets.clone(); + assert!(views.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1) + )); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(session.editor().pane_id(), focused); + assert_eq!(session.editor().cursor(), cursor); + assert_eq!(views.offsets, offsets); + assert!( + session + .editor_mut() + .with_render_pane(owner, |view| view.diff().unwrap().top > 0) + ); + let area = views.rects.iter().find(|(id, _)| *id == focused).unwrap().1; + assert!(area.contains(terminal.get_cursor_position().unwrap())); +} + +fn definition_point(row: usize, column: usize) -> crate::plugin::Utf16Range { + crate::plugin::Utf16Range { + start: (row, column), + end: (row, column), + } +} + +#[test] +fn lsp_definitions_center_in_actual_document_panes_once_and_preserve_dirty_targets() { + for (layout, target_kind, row) in [ + ("single", "same", 0), + ("single", "same", 2), + ("single", "new", 50), + ("split", "same", 50), + ("dock", "dirty", 50), + ("single", "same", 99), + ] { + let directory = Directory::new(); + let text = (0..100) + .map(|row| format!("definition_{row:03}")) + .collect::>() + .join("\n"); + let source = directory.file("source.txt", &text); + let target = if target_kind == "same" { + source.clone() + } else { + directory.file("target.txt", &text) + }; + let mut editor = Editor::open(Some(source.clone()), vec![]).unwrap(); + if target_kind == "dirty" { + editor + .navigate_to(target.clone(), TextPosition { row: 0, column: 0 }) + .unwrap(); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste("UNSAVED ".into())); + editor.handle(Input::Escape); + editor + .navigate_to(source, TextPosition { row: 0, column: 0 }) + .unwrap(); + } + if layout == "split" { + editor.execute("pane.split_horizontal"); + } else if layout == "dock" { + editor.set_project_root(directory.0.clone()); + editor.set_file_explorer_side("bottom").unwrap(); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('e')); + editor.handle(Input::Escape); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + editor + .navigate_lsp_to(target.clone(), definition_point(row, 0), None) + .unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let viewport = views.document.geometry.unwrap().1; + if layout != "single" { + assert!(viewport.height < 22, "{layout}"); + } + let centered = viewport.y + (row as u16).min(viewport.height / 2); + assert_eq!( + terminal.get_cursor_position().unwrap().y, + centered, + "{layout}/{target_kind}/{row}" + ); + assert_eq!(editor.cursor().row, row); + assert_eq!(editor.path(), Some(target.as_path())); + if target_kind == "dirty" { + assert!(editor.lines()[0].starts_with("UNSAVED ")); + assert_eq!(fs::read_to_string(&target).unwrap(), text); + } + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap().y, centered); + if row == 50 { + editor.handle(Input::Down); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap().y, centered + 1); + let before = ( + views.offsets.clone(), + screen_text(&terminal), + editor.cursor(), + ); + for (path, range) in [ + (target.clone(), definition_point(1000, 0)), + (directory.0.join("missing.txt"), definition_point(0, 0)), + ] { + assert!(editor.navigate_lsp_to(path, range, None).is_err()); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(views.offsets, before.0); + assert_eq!(screen_text(&terminal), before.1); + assert_eq!(editor.cursor(), before.2); + assert_eq!(terminal.get_cursor_position().unwrap().y, centered + 1); + } + } + } +} + +#[test] +fn lsp_definition_centers_wrapped_utf16_grapheme_destination_visually() { + let directory = Directory::new(); + let mut lines = (0..80) + .map(|row| format!("row_{row:03}")) + .collect::>(); + lines[40] = format!("{}😀e\u{301}ZTAIL", "a".repeat(80)); + let path = directory.file("wrapped.txt", &lines.join("\n")); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.execute("word_wrap.toggle"); + let mut views = BufferViewports::default(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 12)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + editor + .navigate_lsp_to(path, definition_point(40, 84), None) + .unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let viewport = views.document.geometry.unwrap().1; + let caret = terminal.get_cursor_position().unwrap(); + assert_eq!( + editor.cursor(), + Cursor { + row: 40, + column: 82 + } + ); + assert_eq!(caret.y, viewport.y + viewport.height / 2); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].symbol(), + "Z" + ); + assert_eq!(editor.lines(), lines); +} + +#[test] +fn markdown_search_and_plugin_navigation_keep_ordinary_caret_following() { + for navigation in ["markdown", "search", "plugin"] { + let directory = Directory::new(); + let mut lines = (0..80) + .map(|row| format!("row_{row:03}")) + .collect::>(); + lines[0] = "[destination](#destination)".into(); + lines[50] = "# Destination".into(); + let path = directory.file("links.md", &lines.join("\n")); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + let mut views = BufferViewports::default(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 16)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + match navigation { + "markdown" => { + editor.execute("follow_link"); + } + "search" => { + editor.handle(Input::Char('/')); + for ch in "Destination".chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Enter); + } + "plugin" => editor + .navigate_to(path, TextPosition { row: 50, column: 0 }) + .unwrap(), + _ => unreachable!(), + } + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let viewport = views.document.geometry.unwrap().1; + assert_eq!(editor.cursor().row, 50, "{navigation}"); + assert_eq!( + terminal.get_cursor_position().unwrap().y, + viewport.bottom() - 1, + "{navigation}" + ); + } +} + +#[test] +fn lsp_definition_centers_revealed_diff_symbol_in_each_actual_focused_pane() { + for (mode, axis, focus) in [ + ( + ViewMode::Split, + crate::panes::SplitAxis::Vertical, + Focus::Before, + ), + ( + ViewMode::Split, + crate::panes::SplitAxis::Horizontal, + Focus::After, + ), + ( + ViewMode::Unified, + crate::panes::SplitAxis::Vertical, + Focus::After, + ), + ] { + let before = (0..90) + .map(|row| format!("line_{row:03}")) + .collect::>(); + let mut after = before.clone(); + for row in 10..31 { + after[row] = format!("changed_{row:03}"); + } + after[70] = "changed_body".into(); + let mut file = syntax_diff_file("symbol.txt", &before.join("\n"), &after.join("\n")); + file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_diff_display_options(mode, axis); + editor.set_diff_context_options(1, 3); + install_readonly_diff(&mut editor, file); + editor.handle(Input::Char('\t')); + if focus == Focus::After { + editor.handle(Input::Char('\t')); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + let mut views = BufferViewports::default(); + render_native_diff(&mut terminal, &mut views, &mut editor); + let diff = editor.diff().unwrap(); + let comparison = diff.comparison_id; + let side = usize::from(focus == Focus::After); + let path = diff.snapshot_roots[side] + .as_ref() + .unwrap() + .join("symbol.txt"); + assert!(diff.source_display_row(focus, 40).is_none()); + editor + .navigate_lsp_to( + path.clone(), + definition_point(40, 2), + Some(crate::plugin::Utf16Range { + start: (40, 0), + end: (71, 0), + }), + ) + .unwrap(); + let caret = render_native_diff(&mut terminal, &mut views, &mut editor); + let geometry = views.panels.diff.as_ref().unwrap(); + let pane = diff_inner(geometry.regions[if focus == Focus::Before { 1 } else { 2 }].0); + assert_eq!( + caret.y, + pane.y + pane.height / 2, + "{mode:?}/{axis:?}/{focus:?}" + ); + assert!(pane.contains(caret)); + assert_eq!(editor.cursor(), Cursor { row: 40, column: 2 }); + assert_eq!(editor.diff().unwrap().comparison_id, comparison); + assert!( + editor + .diff() + .unwrap() + .source_display_row(focus, 40) + .is_some() + ); + assert!(editor.mutation_blocked()); + editor.handle(Input::Down); + assert!(render_native_diff(&mut terminal, &mut views, &mut editor).y > caret.y); + editor + .navigate_lsp_to(path, definition_point(80, 1), None) + .unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let viewport = views.document.geometry.unwrap().1; + assert!(editor.diff().is_none()); + assert_eq!(editor.tab_count(), 2); + assert_eq!( + terminal.get_cursor_position().unwrap().y, + viewport.y + viewport.height / 2 + ); + assert!(editor.mutation_blocked()); + } +} + +#[test] +fn deferred_lsp_definition_centers_only_successful_current_diff_results() { + for outcome in ["success", "stale", "failed"] { + let directory = Directory::new(); + let before = (0..90) + .map(|row| format!("line_{row:03}")) + .collect::>(); + let mut after = before.clone(); + for row in 10..31 { + after[row] = format!("changed_{row:03}"); + } + after[70] = "changed_body".into(); + let mut source = syntax_diff_file("source.txt", &before.join("\n"), &after.join("\n")); + source.hunks = crate::diff::line_hunks(&source.before, false, &source.after, false); + let mut target = syntax_diff_file("target.txt", &before.join("\n"), &after.join("\n")); + target.hunks = crate::diff::line_hunks(&target.before, false, &target.after, false); + directory.file("target.txt", &after.join("\n")); + let mut unloaded = syntax_diff_file("target.txt", "", ""); + unloaded.loaded = false; + unloaded.before.clear(); + unloaded.after.clear(); + unloaded.hunks.clear(); + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_diff_context_options(1, 3); + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + editor.handle(Input::Down); + editor.handle(Input::Enter); + editor.handle(Input::Down); + editor.handle(Input::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result( + id, + Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { + root: directory.0.clone(), + editable_paths: Default::default(), + before: "baseline".into(), + after: "historic".into(), + revisions: ["before-snapshot".into(), "after-snapshot".into()], + files: vec![source, unloaded], + })), + ); + editor.handle(Input::Char('\t')); + let mut views = BufferViewports::default(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + render_native_diff(&mut terminal, &mut views, &mut editor); + let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); + editor + .navigate_lsp_to( + directory.0.join("target.txt"), + definition_point(40, 2), + Some(crate::plugin::Utf16Range { + start: (40, 0), + end: (71, 0), + }), + ) + .unwrap(); + let (id, _) = editor.take_diff_request().unwrap(); + if outcome == "stale" { + editor.handle(Input::Right); + editor.handle(Input::Left); + } + let before_caret = render_native_diff(&mut terminal, &mut views, &mut editor); + let before_pane = diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0); + let before_viewport = views.panels.diff.as_ref().unwrap().viewport; + let before_buffer = editor.buffer_id(); + let before_source = editor.cursor(); + editor.apply_diff_result( + id, + if outcome == "failed" { + Err("definition file unavailable".into()) + } else { + Ok(crate::diff::Outcome::File { + comparison_id, + file_index: 1, + file: target, + }) + }, + ); + let caret = render_native_diff(&mut terminal, &mut views, &mut editor); + if outcome == "success" { + let pane = diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0); + assert_eq!(editor.cursor(), Cursor { row: 40, column: 2 }); + assert_eq!(editor.diff().unwrap().file, 1); + assert_eq!(caret.y, pane.y + pane.height / 2); + editor.handle(Input::Down); + assert!(render_native_diff(&mut terminal, &mut views, &mut editor).y > caret.y); + } else { + assert_eq!(editor.buffer_id(), before_buffer, "{outcome}"); + assert_eq!(editor.cursor(), before_source, "{outcome}"); + assert_eq!(editor.diff().unwrap().file, 0, "{outcome}"); + assert_eq!( + views.panels.diff.as_ref().unwrap().viewport, + before_viewport, + "{outcome}" + ); + let pane = diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0); + assert_eq!( + (caret.x - pane.x, caret.y - pane.y), + ( + before_caret.x - before_pane.x, + before_caret.y - before_pane.y + ), + "{outcome}" + ); + assert_eq!( + render_native_diff(&mut terminal, &mut views, &mut editor), + caret + ); + } + } +} + +fn readonly_diff_editor(file: ChangedFile) -> Editor { + let mut editor = Editor::open(None, vec![]).unwrap(); + install_readonly_diff(&mut editor, file); + editor +} + +fn install_readonly_diff(editor: &mut Editor, file: ChangedFile) { + editor.set_clipboard_mode("separate").unwrap(); + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + editor.handle(Input::Down); + editor.handle(Input::Enter); + editor.handle(Input::Down); + editor.handle(Input::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result( + id, + Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { + root: editor.project_root().to_path_buf(), + editable_paths: Default::default(), + before: "baseline".into(), + after: "historic".into(), + revisions: ["before-snapshot".into(), "after-snapshot".into()], + files: vec![file], + })), + ); +} + +fn render_native_diff( + terminal: &mut Terminal, + views: &mut BufferViewports, + editor: &mut Editor, +) -> Position { + terminal.draw(|frame| views.draw(frame, editor)).unwrap(); + let (id, file, top, before, after) = views.panels.diff.as_ref().unwrap().viewport.unwrap(); + editor.set_diff_viewport(id, file, top, before, after); + terminal.get_cursor_position().unwrap() +} + +#[test] +fn command_input_keeps_characterwise_and_linewise_selection_highlights() { + for context in ["document", "split", "unified"] { + for selector in ['v', 'V'] { + let mut editor = if context == "document" { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace( + "alpha beta\nsecond line".into(), + )]) + .unwrap(); + editor + } else { + let mut editor = readonly_diff_editor(syntax_diff_file( + "file.txt", + "alpha beta\nold line", + "alpha beta\nnew line", + )); + if context == "unified" { + editor.execute("diff.view.toggle"); + } + editor.handle(Input::Char('\t')); + editor + }; + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(140, 24)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let source = terminal.get_cursor_position().unwrap(); + editor.handle(Input::Char(selector)); + editor.handle(Input::Right); + let ranges = editor.selections(); + editor.handle(Input::Char(':')); + for ch in "diff.comment".chars() { + editor.handle(Input::Char(ch)); + } + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(editor.mode(), Mode::Command); + assert_eq!(editor.selections(), ranges); + let buffer = terminal.backend().buffer(); + assert_eq!( + buffer[(source.x, source.y)].bg, + Color::Blue, + "{context} {selector}" + ); + assert_eq!( + buffer[(source.x + if selector == 'V' { 9 } else { 1 }, source.y)].bg, + Color::Blue, + "{context} {selector}" + ); + editor.handle(Input::Escape); + assert_eq!(editor.selections(), ranges); + } + } +} + +#[test] +fn native_visual_selection_overrides_selected_review_range_background() { + for unified in [false, true] { + let mut editor = readonly_diff_editor(syntax_diff_file( + "file.txt", + "old e\u{301}界 text", + "new e\u{301}界 text", + )); + if unified { + editor.execute("diff.view.toggle"); + } + editor.handle(Input::Char('\t')); + editor.execute("diff.comment"); + editor.handle(Input::Escape); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap(); + let mut views = BufferViewports::default(); + let source = render_native_diff(&mut terminal, &mut views, &mut editor); + assert_eq!( + terminal.backend().buffer()[(source.x, source.y)].bg, + Color::Magenta + ); + editor.handle(Input::Char('v')); + editor.handle(Input::Right); + render_native_diff(&mut terminal, &mut views, &mut editor); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(source.x, source.y)].bg, Color::Blue); + assert!(buffer.content.iter().any(|cell| cell.bg == Color::Magenta)); + } +} + +#[test] +fn review_editor_floats_over_the_diff_without_changing_workspace_layout() { + for unified in [false, true] { + let mut editor = readonly_diff_editor(syntax_diff_file( + "file.txt", + "old first\nold last", + "new first\nnew last", + )); + if unified { + editor.execute("diff.view.toggle"); + } + editor.handle(Input::Char('\t')); + let owner = editor.pane_id(); + let source_text = editor.context().text; + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 30)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let source_area = views + .rects + .iter() + .find(|(pane, _)| *pane == owner) + .unwrap() + .1; + editor.execute("diff.comment"); + let panel = editor.review_panel().expect("focused floating review"); + assert_eq!(editor.pane_layout().len(), 1); + assert!(!editor.pane_layout().contains(panel)); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(views.rects[0], (panel, floating_panel(source_area))); + assert_eq!( + views + .rects + .iter() + .find(|(pane, _)| *pane == owner) + .unwrap() + .1, + source_area + ); + let area = views.rects[0].1; + let cursor = terminal.get_cursor_position().unwrap(); + assert!(area.contains(cursor)); + for input in [ + Input::Char('i'), + Input::Paste("Retained draft".into()), + Input::Escape, + ] { + editor.handle(input); + } + assert_eq!(editor.context().text, "Retained draft"); + assert_eq!(editor.review_panel(), Some(panel)); + editor.handle(Input::Escape); + assert!(editor.review_panel().is_none()); + assert_eq!(editor.pane_id(), owner); + assert_eq!(editor.context().text, source_text); + editor.execute("diff.review.edit 1"); + assert_eq!( + editor.context().text, + "Retained draft", + "{}", + editor.message() + ); + assert_eq!(editor.review_panel(), Some(panel)); + editor.execute("tab.new"); + editor.execute("tab.previous"); + editor.execute("diff.review.edit 1"); + assert_eq!(editor.review_panel(), Some(panel)); + assert_eq!(editor.context().text, "Retained draft"); + editor.execute("pane.close"); + assert!(editor.review_panel().is_none()); + assert_eq!(editor.pane_layout().len(), 1); + } +} + +#[test] +fn scope_projection_keeps_unified_wrapped_caret_and_selection_source_only() { + let before = format!( + "fn old_name() {{\n // {}\n}}\nfn second() {{}}\n", + "word ".repeat(45) + ); + let after = before.replace("old_name", "new_name"); + for axis in [SplitAxis::Vertical, SplitAxis::Horizontal] { + let mut editor = readonly_diff_editor(syntax_diff_file("scope.rs", &before, &after)); + if axis == SplitAxis::Horizontal { + editor.execute("diff.orientation.toggle"); + } + editor.handle(Input::Char('\t')); + editor.handle(Input::Down); + editor.handle(Input::Home); + editor.handle(Input::Char('v')); + editor.handle(Input::End); + let source = editor.cursor(); + let selections = editor.selections(); + let text = editor.lines().to_vec(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap(); + let mut views = BufferViewports::default(); + for command in [ + "", + "word_wrap.toggle", + "diff.view.toggle", + "word_wrap.toggle", + "diff.view.toggle", + ] { + if !command.is_empty() { + editor.execute(command); + } + let caret = render_native_diff(&mut terminal, &mut views, &mut editor); + assert_eq!(editor.cursor(), source); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.lines(), text); + let geometry = views.panels.diff.as_ref().unwrap(); + let diff = editor.diff().unwrap(); + let active = usize::from(diff.focus == Focus::After); + let area = diff_inner(geometry.regions[active + 1].0); + assert!(area.contains(caret)); + let rows = &views.panels.diff_wrap.as_ref().unwrap().rows; + let caret_row = usize::from(caret.y - area.y); + assert_eq!(rows[caret_row].scope, [false; 2]); + assert_eq!( + diff.display_source_row(diff.focus, rows[caret_row].position.display), + Some(source.row) + ); + for (screen, row) in rows.iter().take(usize::from(area.height)).enumerate() { + if row.scope == [false; 2] { + continue; + } + let y = area.y + screen as u16; + for x in area.x..area.right() { + assert_ne!(terminal.backend().buffer()[(x, y)].bg, Color::Blue); + } + for x in area.x..area.right() - geometry.text_widths[active] { + assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), " "); + } + } + } + } +} + +#[test] +fn tiny_horizontal_scope_diff_keeps_active_source_visible() { + let mut editor = readonly_diff_editor(syntax_diff_file( + "small.rs", + "fn old_name() {}", + "fn new_name() {}", + )); + editor.execute("diff.orientation.toggle"); + editor.handle(Input::Char('\t')); + editor.handle(Input::Char('\t')); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 9)).unwrap(); + let mut views = BufferViewports::default(); + let caret = render_native_diff(&mut terminal, &mut views, &mut editor); + let area = diff_inner(views.panels.diff.as_ref().unwrap().regions[2].0); + assert!( + area.contains(caret), + "source caret {caret:?} outside {area:?}" + ); + assert_eq!( + views.panels.diff_wrap.as_ref().unwrap().rows[usize::from(caret.y - area.y)].scope, + [false; 2] + ); +} + +#[test] +fn modified_sequences_navigate_diff_files_and_readonly_panes() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.handle(Input::Char('|')); + install_readonly_diff( + &mut editor, + syntax_diff_file("history.txt", "before", "after"), + ); + editor + .bind("normal", "ctrl+w h", Some("core.pane.focus_left".into())) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + let mut views = BufferViewports::default(); + render_native_diff(&mut terminal, &mut views, &mut editor); + assert_eq!(editor.diff().unwrap().focus, Focus::Files); + editor.handle(Input::Ctrl('w')); + assert_eq!(editor.sequence_pending(), Some("Ctrl+w")); + assert!(editor.diff().unwrap().files_visible); + editor.handle(Input::Char('h')); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + render_native_diff(&mut terminal, &mut views, &mut editor); + editor.handle(Input::Ctrl('w')); + editor.handle(Input::Char('h')); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + render_native_diff(&mut terminal, &mut views, &mut editor); + editor.handle(Input::Ctrl('w')); + editor.handle(Input::Char('h')); + assert_eq!(editor.pane_id(), 1); + assert_eq!(editor.sequence_pending(), None); +} + +#[test] +fn wrap_toggle_preserves_caret_screen_row_in_normal_buffers() { + let directory = Directory::new(); + let text = (0..35) + .map(|row| format!("line{row} {}", "word ".repeat(15))) + .collect::>() + .join("\n"); + let mut editor = Editor::open(Some(directory.file("anchor.txt", &text)), vec![]).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 10)).unwrap(); + let mut views = BufferViewports::default(); + for _ in 0..12 { + editor.handle(Input::Down); + } + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + for _ in 0..3 { + editor.handle(Input::Up); + } + for _ in 0..30 { + editor.handle(Input::Right); + } + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let row = terminal.get_cursor_position().unwrap().y; + let source = editor.cursor(); + for _ in 0..4 { + editor.execute("word_wrap.toggle"); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap().y, row); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap().y, row); + assert_eq!(editor.cursor(), source); + assert_eq!(editor.lines().join("\n"), text); + } + for _ in 0..35 { + editor.handle(Input::Down); + } + editor.handle(Input::Home); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + editor.execute("word_wrap.toggle"); + editor.handle(Input::Right); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let near_eof_row = terminal.get_cursor_position().unwrap().y; + let near_eof_source = editor.cursor(); + editor.execute("word_wrap.toggle"); + for _ in 0..2 { + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap().y, near_eof_row); + assert_eq!(editor.cursor(), near_eof_source); + } +} + +#[test] +fn wrap_toggle_preserves_caret_screen_row_in_diff_buffers() { + let before = (0..35) + .map(|row| format!("old{row} {}", "word ".repeat(15))) + .collect::>() + .join("\n"); + let after = before.replace("old", "new"); + let mut editor = readonly_diff_editor(syntax_diff_file("anchor.txt", &before, &after)); + editor.handle(Input::Char('\t')); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); + let mut views = BufferViewports::default(); + for _ in 0..12 { + editor.handle(Input::Down); + } + render_native_diff(&mut terminal, &mut views, &mut editor); + for _ in 0..3 { + editor.handle(Input::Up); + } + for _ in 0..30 { + editor.handle(Input::Right); + } + let row = render_native_diff(&mut terminal, &mut views, &mut editor).y; + let source = editor.cursor(); + for _ in 0..4 { + editor.execute("word_wrap.toggle"); + assert_eq!( + render_native_diff(&mut terminal, &mut views, &mut editor).y, + row + ); + assert_eq!( + render_native_diff(&mut terminal, &mut views, &mut editor).y, + row + ); + assert_eq!(editor.cursor(), source); + } +} + +#[test] +fn horizontal_diff_split_keeps_after_caret_in_smaller_bottom_pane() { + let before = (0..35) + .map(|row| format!("old{row}")) + .collect::>() + .join("\n"); + let after = before.replace("old", "new"); + let mut editor = readonly_diff_editor(syntax_diff_file("axis.txt", &before, &after)); + editor.handle(Input::Char('\t')); + editor.handle(Input::Char('\t')); + for _ in 0..20 { + editor.handle(Input::Down); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 17)).unwrap(); + let mut views = BufferViewports::default(); + render_native_diff(&mut terminal, &mut views, &mut editor); + editor.execute("diff.orientation.toggle"); + let caret = render_native_diff(&mut terminal, &mut views, &mut editor); + let geometry = views.panels.diff.as_ref().unwrap(); + assert_eq!(geometry.regions[1].0.x, geometry.regions[2].0.x); + assert!(geometry.regions[1].0.bottom() <= geometry.regions[2].0.y); + assert!(diff_inner(geometry.regions[2].0).contains(caret)); + editor.execute("word_wrap.toggle"); + let caret = render_native_diff(&mut terminal, &mut views, &mut editor); + assert!(diff_inner(views.panels.diff.as_ref().unwrap().regions[2].0).contains(caret)); +} + +#[test] +fn unified_diff_renders_ordered_coordinates_and_switches_logical_focus() { + let mut file = syntax_diff_file("unified.txt", "same\nold\nend", "same\nnew\nend"); + file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); + let mut editor = readonly_diff_editor(file); + editor.execute("diff.view.toggle"); + editor.handle(Input::Char('\t')); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap(); + let mut views = BufferViewports::default(); + render_native_diff(&mut terminal, &mut views, &mut editor); + let geometry = views.panels.diff.as_ref().unwrap(); + assert_eq!(geometry.regions[2].0, Rect::default()); + let area = diff_inner(geometry.regions[1].0); + let buffer = terminal.backend().buffer(); + let lines = (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>(); + let tokens: Vec> = lines[..4] + .iter() + .map(|line| line.split_whitespace().collect()) + .collect(); + assert_eq!( + tokens, + [ + vec!["1", "1", "same"], + vec!["2", "-", "old"], + vec!["2+", "new"], + vec!["3", "3", "end"] + ] + ); + assert!(!lines.iter().any(|line| line.contains('╱'))); + editor.handle(Input::Char('\t')); + render_native_diff(&mut terminal, &mut views, &mut editor); + let geometry = views.panels.diff.as_ref().unwrap(); + assert_eq!(geometry.regions[1].0, Rect::default()); + assert_eq!(diff_inner(geometry.regions[2].0), area); +} + +#[test] +fn unified_wrapping_uses_only_the_display_rows_source_side() { + let before = format!("{}\nend", "old ".repeat(40)); + let mut file = syntax_diff_file("wrap.txt", &before, "new\nend"); + file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); + let mut editor = readonly_diff_editor(file); + editor.execute("diff.view.toggle"); + editor.execute("word_wrap.toggle"); + editor.handle(Input::Char('\t')); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap(); + let mut views = BufferViewports::default(); + render_native_diff(&mut terminal, &mut views, &mut editor); + let wrap = views.panels.diff_wrap.as_ref().unwrap(); + let removed = wrap + .rows + .iter() + .filter(|row| row.position.display == 0) + .collect::>(); + assert!(removed.len() > 1); + assert!( + removed + .iter() + .all(|row| row.segments[0].is_some() && row.segments[1].is_none()) + ); + let added = wrap + .rows + .iter() + .filter(|row| row.position.display == 1) + .collect::>(); + assert_eq!(added.len(), 1); + assert!(added[0].segments[0].is_none()); + assert!(added[0].segments[1].is_some()); +} + +#[test] +fn wrapped_diff_aligns_continuations_and_preserves_native_selection_and_caret() { + let before = format!("// {}BEFORE_END\nsentinel", "old ".repeat(70)); + let after = format!("// {}AFTER_END\nsentinel", "new ".repeat(20)); + let mut editor = readonly_diff_editor(syntax_diff_file("history.rs", &before, &after)); + editor.execute("word_wrap.toggle"); + editor.handle(Input::Char('\t')); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(140, 20)).unwrap(); + let mut views = BufferViewports::default(); + render_native_diff(&mut terminal, &mut views, &mut editor); + let geometry = views.panels.diff.as_ref().unwrap(); + let before_area = diff_inner(geometry.regions[1].0); + let after_area = diff_inner(geometry.regions[2].0); + let buffer = terminal.backend().buffer(); + let find_sentinel = |area: Rect| { + (area.y..area.bottom()) + .find(|y| { + (area.x..area.right()) + .map(|x| buffer[(x, *y)].symbol()) + .collect::() + .contains("sentinel") + }) + .unwrap() + }; + assert_eq!(find_sentinel(before_area), find_sentinel(after_area)); + editor.handle(Input::End); + let caret = render_native_diff(&mut terminal, &mut views, &mut editor); + assert!(before_area.contains(caret)); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].symbol(), + "D" + ); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].fg, + Color::DarkGray + ); + editor.handle(Input::Char('v')); + editor.handle(Input::Home); + render_native_diff(&mut terminal, &mut views, &mut editor); + let wrap = views.panels.diff_wrap.as_ref().unwrap(); + let continuation = wrap + .rows + .iter() + .position(|row| row.position.display == 0 && row.position.continuation == 1) + .unwrap(); + let x = + before_area.x + (before_area.width - views.panels.diff.as_ref().unwrap().text_widths[0]); + assert_eq!( + terminal.backend().buffer()[(x, before_area.y + continuation as u16)].bg, + Color::Blue + ); + let selection = editor.selections(); + let text = editor.lines().to_vec(); + editor.execute("word_wrap.toggle"); + render_native_diff(&mut terminal, &mut views, &mut editor); + assert!(!editor.word_wrap()); + assert!( + views + .panels + .diff_wrap + .as_ref() + .unwrap() + .rows + .iter() + .all(|row| row.position.continuation == 0) + ); + assert_eq!(editor.selections(), selection); + assert_eq!(editor.lines(), text); + assert_eq!(editor.mode(), Mode::Visual); +} + +#[test] +fn readonly_diff_panes_render_native_caret_visual_search_and_paired_focus() { + let mut editor = readonly_diff_editor(syntax_diff_file( + "history.rs", + "fn old() {}\nlet value = \"old\";", + "fn new() {}\nlet value = \"new\";", + )); + editor.handle(Input::Char('\t')); + assert!(editor.diff_interacting()); + assert!(!editor.diff_editing()); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); + let mut views = BufferViewports::default(); + views.set_theme(Some(LIGHT_DIFF_THEME)); + let caret = render_native_diff(&mut terminal, &mut views, &mut editor); + let before = views.panels.diff.as_ref().unwrap().regions[1].0; + assert!(before.contains(caret)); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].symbol(), + "f" + ); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].fg, + Color::Magenta + ); + editor.handle(Input::Char('v')); + editor.handle(Input::Right); + render_native_diff(&mut terminal, &mut views, &mut editor); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].bg, + Color::Blue + ); + editor.handle(Input::Escape); + editor.handle(Input::Down); + editor.handle(Input::Home); + editor.handle(Input::Char('V')); + render_native_diff(&mut terminal, &mut views, &mut editor); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y + 1)].bg, + Color::Blue + ); + assert_eq!( + terminal.backend().buffer()[(caret.x + 16, caret.y + 1)].bg, + Color::Blue + ); + editor.handle(Input::Escape); + editor.handle(Input::Char('/')); + for ch in "value".chars() { + editor.handle(Input::Char(ch)); + } + render_native_diff(&mut terminal, &mut views, &mut editor); + editor.handle(Input::Enter); + let searched = render_native_diff(&mut terminal, &mut views, &mut editor); + assert!(before.contains(searched)); + assert_eq!( + terminal.backend().buffer()[(searched.x, searched.y)].bg, + Color::Yellow + ); + assert_eq!( + terminal.backend().buffer()[(searched.x, searched.y)].fg, + Color::Black + ); + let before_row = editor + .diff() + .unwrap() + .source_display_row(Focus::Before, editor.cursor().row) + .unwrap(); + let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column); + editor.handle(Input::Char('\t')); + assert!(editor.diff_interacting()); + assert!(!editor.diff_editing()); + let switched = render_native_diff(&mut terminal, &mut views, &mut editor); + let after = views.panels.diff.as_ref().unwrap().regions[2].0; + assert!(after.contains(switched)); + assert_eq!(switched.y, searched.y); + assert_eq!(switched.x - after.x, searched.x - before.x); + assert_eq!( + editor + .diff() + .unwrap() + .source_display_row(Focus::After, editor.cursor().row), + Some(before_row) + ); + assert_eq!( + display_column(&editor.lines()[editor.cursor().row], editor.cursor().column), + column + ); + editor.handle(Input::Right); + let moved = render_native_diff(&mut terminal, &mut views, &mut editor); + editor.handle(Input::Char('\t')); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + let returned = render_native_diff(&mut terminal, &mut views, &mut editor); + assert!(before.contains(returned)); + assert_eq!(returned.y, moved.y); + assert_eq!(returned.x - before.x, moved.x - after.x); + assert_eq!(returned.x, searched.x + 1); +} + +#[test] +fn readonly_diff_viewport_retains_both_offsets_on_direction_and_focus_reversal() { + let before = (0..80) + .map(|row| format!("old row {row:03} {}", "x".repeat(100))) + .collect::>() + .join("\n"); + let after = before.replace("old row", "new row"); + let mut editor = readonly_diff_editor(syntax_diff_file("history.txt", &before, &after)); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); + let mut views = BufferViewports::default(); + let mut retained_before_left = 0; + for focus in [Focus::Before, Focus::After] { + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff().unwrap().focus, focus); + assert!(editor.diff_interacting()); + assert!(!editor.diff_editing()); + render_native_diff(&mut terminal, &mut views, &mut editor); + for _ in 0..40 { + editor.handle(Input::Down); + render_native_diff(&mut terminal, &mut views, &mut editor); + } + let top = editor.diff().unwrap().top; + assert!(top > 0); + let bottom = render_native_diff(&mut terminal, &mut views, &mut editor); + editor.handle(Input::Up); + let reversed = render_native_diff(&mut terminal, &mut views, &mut editor); + assert_eq!(editor.diff().unwrap().top, top); + assert_eq!(reversed.y + 1, bottom.y); + while editor + .diff() + .unwrap() + .source_display_row(focus, editor.cursor().row) + .unwrap() + > top + { + editor.handle(Input::Up); + render_native_diff(&mut terminal, &mut views, &mut editor); + assert_eq!(editor.diff().unwrap().top, top); + } + editor.handle(Input::Up); + render_native_diff(&mut terminal, &mut views, &mut editor); + assert!(editor.diff().unwrap().top < top); + for _ in 0..80 { + editor.handle(Input::Right); + render_native_diff(&mut terminal, &mut views, &mut editor); + } + let left = if focus == Focus::Before { + editor.diff().unwrap().left_before + } else { + editor.diff().unwrap().left_after + }; + assert!(left > 0); + let right = render_native_diff(&mut terminal, &mut views, &mut editor); + editor.handle(Input::Left); + let reversed = render_native_diff(&mut terminal, &mut views, &mut editor); + let offsets = views.panels.diff.as_ref().unwrap().viewport.unwrap(); + assert_eq!( + if focus == Focus::Before { + offsets.3 + } else { + offsets.4 + }, + left + ); + assert_eq!(reversed.x + 1, right.x); + while editor.cursor().column > left { + editor.handle(Input::Left); + render_native_diff(&mut terminal, &mut views, &mut editor); + let diff = editor.diff().unwrap(); + assert_eq!( + if focus == Focus::Before { + diff.left_before + } else { + diff.left_after + }, + left + ); + } + editor.handle(Input::Left); + render_native_diff(&mut terminal, &mut views, &mut editor); + if focus == Focus::Before { + retained_before_left = editor.diff().unwrap().left_before; + assert!(retained_before_left < left); + } else { + assert!(editor.diff().unwrap().left_after < left); + assert_eq!(editor.diff().unwrap().left_before, retained_before_left); + } + } +} + +#[test] +fn diff_semantic_colors_stay_buffer_local_across_focus_changes() { + for writable in [false, true] { + let directory = Directory::new(); + let path = directory.file("live.rs", "let value = 2;"); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + editor.handle(Input::Down); + editor.handle(Input::Enter); + editor.handle(Input::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result( + id, + Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { + root: directory.0.clone(), + editable_paths: if writable { + [std::path::PathBuf::from("live.rs")].into() + } else { + Default::default() + }, + before: "baseline".into(), + after: "Working checkout".into(), + revisions: ["before-snapshot".into(), "after-snapshot".into()], + files: vec![syntax_diff_file( + "live.rs", + "let value = 1;", + "let value = 2;", + )], + })), + ); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff_buffer_focus(), Some(Focus::Before)); + editor + .replace_semantic_tokens( + editor.buffer_id(), + editor.text_revision(), + Some(Arc::from([semantic_token( + 0, + 4, + 9, + SemanticTokenKind::Parameter, + 0, + )])), + ) + .unwrap(); + editor.handle(Input::Char('\t')); + editor + .replace_semantic_tokens( + editor.buffer_id(), + editor.text_revision(), + Some(Arc::from([semantic_token( + 0, + 4, + 9, + SemanticTokenKind::Function, + SemanticTokenModifier::Declaration as u16, + )])), + ) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); + let mut views = BufferViewports::default(); + views.set_theme(Some(LIGHT_DIFF_THEME)); + for _ in 0..4 { + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let regions = views.panels.diff.as_ref().unwrap().regions; + for (index, color, bold) in [(1, Color::LightCyan, false), (2, Color::LightBlue, true)] + { + let pane = regions[index].0; + let cell = &terminal.backend().buffer()[(pane.x + 8, pane.y + 1)]; + assert_eq!(cell.symbol(), "v"); + assert_eq!(cell.fg, color); + assert_eq!(cell.modifier.contains(ratatui::style::Modifier::BOLD), bold); + } + editor.handle(Input::Char('\t')); + } + if writable { + while editor.diff_buffer_focus() != Some(Focus::After) + || editor.diff().unwrap().focus != Focus::After + { + editor.handle(Input::Char('\t')); + } + assert!(editor.diff_editing()); + editor.handle(Input::Char('A')); + assert_eq!(editor.mode(), Mode::Insert); + for input in " // typing".chars().map(Input::Char).chain([ + Input::Escape, + Input::Char('u'), + Input::Char('U'), + ]) { + editor.handle(input.clone()); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let regions = views.panels.diff.as_ref().unwrap().regions; + for (index, color) in [(1, Color::LightCyan), (2, Color::LightBlue)] { + let pane = regions[index].0; + let cell = &terminal.backend().buffer()[(pane.x + 8, pane.y + 1)]; + assert_eq!(cell.symbol(), "v"); + assert_eq!(cell.fg, color, "semantic color flashed after {input:?}"); + } + } + assert_eq!(editor.lines(), ["let value = 2; // typing"]); + } + editor + .replace_semantic_tokens(editor.buffer_id(), editor.text_revision(), None) + .unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let regions = views.panels.diff.as_ref().unwrap().regions; + let before = regions[1].0; + let after = regions[2].0; + assert_eq!( + terminal.backend().buffer()[(before.x + 8, before.y + 1)].fg, + Color::LightCyan + ); + let value = &terminal.backend().buffer()[(after.x + 8, after.y + 1)]; + assert_eq!(value.fg, Color::Reset); + assert!(!value.modifier.contains(ratatui::style::Modifier::BOLD)); + assert_eq!( + terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg, + Color::Magenta + ); + } +} + +#[test] +fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt() { + let directory = Directory::new(); + let path = directory.file("live.rs", "let value = \"new\";"); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + editor.handle(Input::Down); + editor.handle(Input::Enter); + editor.handle(Input::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result( + id, + Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { + root: directory.0.clone(), + editable_paths: [std::path::PathBuf::from("live.rs")].into(), + before: "baseline".into(), + after: "Working checkout".into(), + revisions: ["before-snapshot".into(), "after-snapshot".into()], + files: vec![syntax_diff_file( + "live.rs", + "let value = \"old\";", + "let value = \"new\";", + )], + })), + ); + editor.handle(Input::Char('\t')); + editor.handle(Input::Char('\t')); + assert!(editor.diff_editing()); + editor.handle(Input::Char('c')); + editor.handle(Input::Char('c')); + editor.handle(Input::Paste("// live comment".into())); + editor.handle(Input::Escape); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let after = views.panels.diff.as_ref().unwrap().regions[2].0; + let caret = terminal.get_cursor_position().unwrap(); + assert!(after.contains(caret)); + let text_x = after.x + 4; + assert_eq!(terminal.backend().buffer()[(text_x, caret.y)].symbol(), "/"); + assert_eq!( + terminal.backend().buffer()[(text_x, caret.y)].fg, + Color::DarkGray + ); + editor.handle(Input::Home); + editor.handle(Input::Char('v')); + editor.handle(Input::Right); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!( + terminal.backend().buffer()[(text_x, caret.y)].bg, + Color::Blue + ); + editor.handle(Input::Escape); + editor.handle(Input::Char(':')); + for ch in "diff.close".chars() { + editor.handle(Input::Char(ch)); + } + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + editor.handle(Input::Enter); + assert!(editor.diff().is_none()); + assert_eq!(editor.lines(), ["// live comment"]); + assert!(editor.dirty()); +} + +#[test] +fn session_diff_draw_retains_rendered_offsets_until_caret_crosses_edges() { + let directory = Directory::new(); + let original = (0..80) + .map(|row| format!("old row {row:03} {}", "x".repeat(100))) + .collect::>() + .join("\n"); + let path = directory.file("source.txt", &original); + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .args(args) + .current_dir(&directory.0) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_AUTHOR_NAME", "Diff fixture") + .env("GIT_AUTHOR_EMAIL", "fixture@example.invalid") + .env("GIT_COMMITTER_NAME", "Diff fixture") + .env("GIT_COMMITTER_EMAIL", "fixture@example.invalid") + .env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z") + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + }; + git(&["init", "-q"]); + git(&["add", "."]); + git(&["commit", "-qm", "base"]); + directory.file("source.txt", &original.replace("old row", "new row")); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + let ready = |session: &mut EditorSession, phase| { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + session.poll(); + let diff = session.editor().diff().unwrap(); + assert!(diff.error.is_none(), "{:?}", diff.error); + if diff.phase == phase && !diff.loading { + break; + } + assert!(std::time::Instant::now() < deadline, "{diff:?}"); + std::thread::sleep(Duration::from_millis(1)); + } + }; + session.execute("diff"); + ready(&mut session, Phase::Before); + session.handle(Input::Down); + session.handle(Input::Enter); + session.handle(Input::Enter); + ready(&mut session, Phase::View); + session.handle(Input::Char('\t')); + session.handle(Input::Char('\t')); + assert!(session.editor().diff_editing()); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); + let mut views = BufferViewports::default(); + let mut render = |session: &mut EditorSession| { + terminal + .draw(|frame| views.draw_session(frame, session)) + .unwrap(); + terminal.get_cursor_position().unwrap() + }; + render(&mut session); + for _ in 0..40 { + session.handle(Input::Down); + render(&mut session); + } + let top = session.editor().diff().unwrap().top; + assert!(top > 0); + let bottom_caret = render(&mut session); + session.handle(Input::Up); + let reversed_caret = render(&mut session); + assert_eq!(session.editor().diff().unwrap().top, top); + assert_eq!(reversed_caret.y + 1, bottom_caret.y); + while session + .editor() + .diff() + .unwrap() + .after_display_row(session.editor().cursor().row) + .unwrap() + > top + { + session.handle(Input::Up); + render(&mut session); + assert_eq!(session.editor().diff().unwrap().top, top); + } + session.handle(Input::Up); + render(&mut session); + assert!(session.editor().diff().unwrap().top < top); + for _ in 0..80 { + session.handle(Input::Right); + render(&mut session); + } + let left = session.editor().diff().unwrap().left_after; + assert!(left > 0); + let right_caret = render(&mut session); + session.handle(Input::Left); + let reversed_caret = render(&mut session); + assert_eq!(session.editor().diff().unwrap().left_after, left); + assert_eq!(reversed_caret.x + 1, right_caret.x); + while session.editor().cursor().column > left { + session.handle(Input::Left); + render(&mut session); + assert_eq!(session.editor().diff().unwrap().left_after, left); + } + session.handle(Input::Left); + render(&mut session); + assert!(session.editor().diff().unwrap().left_after < left); +} + +#[test] +fn diff_wheel_routes_right_tree_and_both_panes_without_touching_document() { + let directory = Directory::new(); + let path = directory.file("source.txt", &["original"; 60].join("\n")); + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .args(args) + .current_dir(&directory.0) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_AUTHOR_NAME", "Diff fixture") + .env("GIT_AUTHOR_EMAIL", "fixture@example.invalid") + .env("GIT_COMMITTER_NAME", "Diff fixture") + .env("GIT_COMMITTER_EMAIL", "fixture@example.invalid") + .env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z") + .output() + .unwrap(); + assert!(output.status.success(), "{:?}", output); + }; + git(&["init", "-q"]); + for index in 0..8 { + directory.file( + &format!("file-{index}.txt"), + &format!("{}\nsecond\nthird\n", "before.".repeat(30)), + ); + } + git(&["add", "."]); + git(&["commit", "-qm", "base"]); + git(&["commit", "--allow-empty", "-qm", "unmatched checkout"]); + for index in 0..8 { + directory.file( + &format!("file-{index}.txt"), + &format!("{}\nsecond\nthird\n", "after.".repeat(30)), + ); + } + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + let original = session.editor().lines().to_vec(); + let cursor = session.editor().cursor(); + let ready = |session: &mut EditorSession, phase| { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + session.poll(); + let diff = session.editor().diff().unwrap(); + assert!(diff.error.is_none(), "{:?}", diff.error); + if diff.phase == phase && !diff.loading { + break; + } + assert!(std::time::Instant::now() < deadline, "{diff:?}"); + std::thread::sleep(Duration::from_millis(1)); + } + }; + session.execute("diff"); + ready(&mut session, Phase::Before); + session.handle(Input::Enter); + session.handle(Input::Down); + session.handle(Input::Down); + session.handle(Input::Enter); + ready(&mut session, Phase::View); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + let regions = viewports.panels.diff.as_ref().unwrap().regions; + let tree = regions[0].0; + let before = regions[1].0; + let after = regions[2].0; + let initial_focus = session.editor().diff().unwrap().focus; + let initial_cursor = session.editor().cursor(); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollRight, before.x + 1, before.y + 1) + )); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert!(screen_text(&terminal).contains("ore.before.")); + assert_eq!(session.editor().diff().unwrap().left_before, 3); + assert_eq!(session.editor().diff().unwrap().left_after, 3); + let mut shift = document_wheel(MouseEventKind::ScrollDown, after.x + 1, after.y + 1); + shift.modifiers = KeyModifiers::SHIFT; + assert!(viewports.scroll(&mut session, shift)); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert!(screen_text(&terminal).contains("after.after.")); + assert_eq!(session.editor().diff().unwrap().left_before, 6); + assert_eq!(session.editor().diff().unwrap().left_after, 6); + assert_eq!(session.editor().diff().unwrap().top, 0); + assert_eq!(session.editor().diff().unwrap().focus, initial_focus); + assert_eq!(session.editor().cursor(), initial_cursor); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, tree.x + 1, tree.y + 1), + )); + let diff = session.editor().diff().unwrap(); + assert_eq!(diff.focus, Focus::Files); + assert_eq!(diff.files.as_ref().unwrap().selected, 3); + assert_eq!(diff.file, 0); + session.handle(Input::Enter); + assert_eq!(session.editor().diff().unwrap().file, 3); + ready(&mut session, Phase::View); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1), + )); + let diff = session.editor().diff().unwrap(); + assert_eq!(diff.focus, Focus::Before); + assert_eq!(diff.top, 2); + assert_eq!(diff.file, 3); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollUp, after.x + 1, after.y + 1), + )); + let diff = session.editor().diff().unwrap(); + assert_eq!(diff.focus, Focus::After); + assert_eq!(diff.top, 0); + assert_eq!(diff.file, 3); + let selected = diff.files.as_ref().unwrap().selected; + session.handle(Input::Char(' ')); + session.handle(Input::Char('e')); + session.handle(Input::Ctrl('w')); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, tree.x + 1, tree.y + 1), + )); + let diff = session.editor().diff().unwrap(); + assert_eq!(diff.focus, Focus::After); + assert_eq!(diff.files.as_ref().unwrap().selected, selected); + assert!(!diff.files.as_ref().unwrap().focused); + assert!(viewports.panels.files.is_none()); + assert!(viewports.document.geometry.is_none()); + session.execute("diff.close"); + assert_eq!(session.editor().lines(), original); + assert_eq!(session.editor().cursor(), cursor); +} + +#[test] +fn diff_wheel_cannot_scroll_the_hidden_document_even_outside_the_picker() { + let directory = Directory::new(); + let path = directory.file("rows.txt", &["original"; 60].join("\n")); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + let id = session.editor().buffer_id(); + let offset = viewports.offsets[&id]; + let cursor = session.editor().cursor(); + session.execute("diff"); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 0, 0) + )); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 60, 15) + )); + assert_eq!(viewports.offsets[&id], offset); + assert_eq!(session.editor().cursor(), cursor); + assert!(viewports.document.geometry.is_none()); + session.execute("diff.close"); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(viewports.document.geometry.is_some()); +} + +#[test] +fn completion_windows_selected_unicode_without_overwriting_gutter_dock_or_cursor() { + let mut completion = Completion { + items: (0..20) + .map(|index| crate::plugin::CompletionItem { + label: if index == 19 { + "界e\u{301}👩‍🚀\u{1b}".into() + } else { + format!("item-{index:02}") + }, + detail: Some("\tTAIL".into()), + primary_edit: 0, + edits: vec![], + snippet: None, + }) + .collect(), + selected: 20, + }; + for (width, height) in [(14, 6), (9, 1), (5, 1)] { + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + let workspace = Rect::new(2, 0, width - 4, height); + let caret = Position::new(2, 0); + let mut panel = None; + completion.selected = 20; + terminal + .draw(|frame| { + let rows: Vec = (0..height) + .map(|_| Line::from(format!("GG{}EE", ".".repeat(usize::from(width - 4))))) + .collect(); + frame.render_widget(Paragraph::new(rows), frame.area()); + frame.set_cursor_position(caret); + panel = draw_completion( + frame, + &completion, + workspace, + caret, + &mut WheelPanels::default(), + ); + }) + .unwrap(); + let mut panel = panel.unwrap(); + let selected_y = panel.bottom() - 1; + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(panel.x, selected_y)].bg, Color::Cyan); + assert!(!screen_text(&terminal).contains("item-00")); + for y in 0..height { + for x in [0, 1] { + assert_eq!(buffer[(x, y)].symbol(), "G"); + } + for x in [width - 2, width - 1] { + assert_eq!(buffer[(x, y)].symbol(), "E"); + } + } + if workspace.width >= 5 { + assert_eq!(buffer[(panel.x, selected_y)].symbol(), "界"); + assert_eq!(buffer[(panel.x + 2, selected_y)].symbol(), "e\u{301}"); + assert_eq!(buffer[(panel.x + 3, selected_y)].symbol(), "👩‍🚀"); + } else { + assert_eq!(buffer[(panel.x, selected_y)].symbol(), " "); + } + if workspace.width >= 6 { + assert_eq!(buffer[(panel.x + 5, selected_y)].symbol(), "�"); + } + assert_eq!(terminal.get_cursor_position().unwrap(), caret); + completion.selected = 0; + terminal + .draw(|frame| { + frame.set_cursor_position(caret); + panel = draw_completion( + frame, + &completion, + workspace, + caret, + &mut WheelPanels::default(), + ) + .unwrap(); + }) + .unwrap(); + assert_eq!( + terminal.backend().buffer()[(panel.x, panel.y)].symbol(), + "O" + ); + assert_eq!( + terminal.backend().buffer()[(panel.x, panel.y)].bg, + Color::Cyan + ); + assert_eq!(terminal.get_cursor_position().unwrap(), caret); + } +} + +#[test] +fn snippet_highlight_tracks_argument_graphemes_without_filling_the_row() { + use crate::plugin::{ + CompletionEdit, CompletionItem, CompletionSnippet, SnippetTabstop, TextPosition, + }; + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor.handle(Input::Char('i')); + editor + .show_completion(vec![CompletionItem { + label: "call".into(), + detail: None, + primary_edit: 0, + edits: vec![CompletionEdit { + start: TextPosition { row: 0, column: 0 }, + end: TextPosition { row: 0, column: 0 }, + text: "call(界e\u{301}, tail)".into(), + }], + snippet: Some(CompletionSnippet { + tabstops: vec![ + SnippetTabstop { + index: 1, + ranges: vec![5..11], + ancestors: vec![vec![]], + }, + SnippetTabstop { + index: 2, + ranges: vec![13..17], + ancestors: vec![vec![]], + }, + SnippetTabstop { + index: 0, + ranges: vec![18..18], + ancestors: vec![vec![]], + }, + ], + }), + }]) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(28, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + for (key, selected, caret) in [ + (None, 7..10, Position::new(7, 0)), + (Some(Input::Char('\t')), 12..16, Position::new(12, 0)), + ] { + if let Some(key) = key { + editor.handle(key); + } + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let buffer = terminal.backend().buffer(); + // A wide glyph's leading cell owns its terminal style. + for x in (0..28).filter(|x| *x != 8) { + assert_eq!( + buffer[(x, 0)].bg == Color::Blue, + selected.contains(&x), + "column {x}, selection {:?}, screen:\n{}", + editor.snippet_selections(), + screen_text(&terminal), + ); + } + assert_eq!(terminal.get_cursor_position().unwrap(), caret); + } + editor.handle(Input::Escape); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + for x in 0..28 { + assert_ne!(terminal.backend().buffer()[(x, 0)].bg, Color::Blue); + } +} + +#[test] +fn hover_shows_more_documentation_with_markdown_and_fenced_code_colors() { + let mut editor = Editor::open(None, vec![]).unwrap(); + let title = format!("# {}TAIL", "Heading ".repeat(11)); + let details = (5..60) + .map(|row| format!("detail-{row:02}")) + .collect::>() + .join("\n"); + editor + .show_hover(format!( + "{title}\n\n```rust\nfn answer() -> i32 {{ 42 }}\n```\n{details}" + )) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 48)).unwrap(); + let mut panels = WheelPanels::default(); + terminal + .draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels)) + .unwrap(); + assert!(screen_text(&terminal).contains("TAIL")); + assert!(screen_text(&terminal).contains("detail-36")); + assert!(!screen_text(&terminal).contains("detail-37")); + let (panel, _) = panels.hover.unwrap(); + let (x, y) = (panel.x + 1, panel.y + 1); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(x, y)].fg, Color::Cyan); + assert_eq!(buffer[(x, y + 3)].symbol(), "f"); + assert_eq!(buffer[(x, y + 3)].fg, Color::Magenta); + assert_eq!(buffer[(x + 6, y + 3)].fg, Color::LightBlue); + assert_eq!(buffer[(x + 21, y + 3)].fg, Color::Yellow); + + for _ in 0..3 { + editor.handle(Input::Down); + } + editor.handle(Input::Right); + terminal + .draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels)) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(x, y)].symbol(), "n"); + assert_eq!(buffer[(x, y)].fg, Color::Magenta); + assert_eq!(buffer[(x + 20, y)].fg, Color::Yellow); + + editor.show_hover("# Replacement".into()).unwrap(); + terminal + .draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels)) + .unwrap(); + assert!(screen_text(&terminal).contains("# Replacement")); + assert!(!screen_text(&terminal).contains("answer")); +} + +#[test] +fn hover_scroll_renders_whole_graphemes_in_tiny_workspace() { + let mut editor = Editor::open(None, vec![]).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(6, 1)).unwrap(); + let mut syntax = SyntaxCache::new(); + let (mut top, mut left) = (0, 0); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ); + }) + .unwrap(); + let document_cursor = terminal.get_cursor_position().unwrap(); + editor.show_hover("x界e\u{301}👩‍🚀z\nlast".into()).unwrap(); + for _ in 0..2 { + editor.handle(Input::Right); + } + let mut panels = WheelPanels::default(); + let rendered = terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut panels, + ) + }) + .unwrap(); + let origin = panels.hover.unwrap().0; + assert_eq!(rendered.buffer[(origin.x, origin.y)].symbol(), "e\u{301}"); + assert_eq!(rendered.buffer[(origin.x + 1, origin.y)].symbol(), "👩‍🚀"); + assert_eq!(rendered.buffer[(origin.x + 3, origin.y)].symbol(), "z"); + assert_eq!(terminal.get_cursor_position().unwrap(), document_cursor); + assert_eq!((top, left), (0, 0)); + editor.handle(Input::Down); + editor.handle(Input::Home); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert!(screen_text(&terminal).trim().ends_with("last")); + for (width, height) in [(1, 1), (2, 2), (3, 3), (4, 4)] { + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + } + assert_eq!(editor.cursor(), crate::editor::Cursor::default()); + assert_eq!(editor.lines(), &[""]); +} + +fn annotation( + start: (usize, usize), + end: (usize, usize), + severity: DiagnosticSeverity, + message: &str, +) -> Diagnostic { + Diagnostic { + start: crate::plugin::TextPosition { + row: start.0, + column: start.1, + }, + end: crate::plugin::TextPosition { + row: end.0, + column: end.1, + }, + severity, + message: message.into(), + } +} + +fn semantic_token( + row: usize, + start: usize, + end: usize, + kind: SemanticTokenKind, + modifiers: u16, +) -> SemanticToken { + SemanticToken { + start: crate::plugin::TextPosition { row, column: start }, + end: crate::plugin::TextPosition { row, column: end }, + kind, + modifiers, + } +} + +#[test] +fn diagnostic_underlines_preserve_syntax_and_whole_graphemes_at_viewport_edges() { + let directory = Directory::new(); + let source = "#a\t界e\u{301}👩‍🚀z"; + let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor + .replace_diagnostics( + "fixture", + editor.buffer_id(), + editor.text_revision(), + vec![ + annotation((0, 2), (0, 6), DiagnosticSeverity::Warning, "range"), + annotation((0, 4), (0, 5), DiagnosticSeverity::Error, "combining"), + ], + ) + .unwrap(); + let mut syntax = SyntaxCache::new(); + for (offset, width, caret, underlines) in [ + ( + 2, + 5, + 4, + vec![ + Some(Color::Yellow), + Some(Color::Yellow), + Some(Color::Yellow), + Some(Color::Yellow), + Some(Color::Red), + ], + ), + (5, 2, 4, vec![None, Some(Color::Red)]), + (7, 2, 5, vec![Some(Color::Yellow), Some(Color::Yellow)]), + (8, 2, 6, vec![None, None]), + ( + 0, + 5, + 2, + vec![None, None, Some(Color::Yellow), Some(Color::Yellow), None], + ), + ] { + editor.handle(Input::Home); + for _ in 0..caret { + editor.handle(Input::Right); + } + if editor.notification_visible() && editor.diagnostic_at_cursor().is_some() { + editor.handle(Input::Escape); + } + let area = Rect::new(3, 2, width + 2, 4); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(20, 8), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + let (mut top, mut left) = (0, offset); + let rendered = terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap() + .buffer + .clone(); + assert_eq!(left, offset); + for (cell, underline) in underlines.into_iter().enumerate() { + let position = (area.x + 2 + cell as u16, area.y); + assert_eq!( + rendered[position].modifier.contains(Modifier::UNDERLINED), + underline.is_some(), + "left={offset}, cell={cell}", + ); + if let Some(color) = underline { + assert_eq!(rendered[position].underline_color, color); + assert_eq!(rendered[position].fg, Color::DarkGray); + } + assert_eq!(rendered[position].bg, Color::Reset); + } + assert_eq!(rendered[(area.x, area.y)].fg, Color::Red); + assert_eq!( + terminal.backend().buffer()[(0, 0)].modifier, + Modifier::empty() + ); + assert_eq!(editor.lines(), &[source]); + } +} + +#[test] +fn diagnostics_render_newlines_anchors_counts_and_messages_without_hiding_core_errors() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor + .apply(vec![crate::editor::Effect::Replace( + "abc\n\nxy\nlast".into(), + )]) + .unwrap(); + editor + .replace_diagnostics( + "fixture", + editor.buffer_id(), + editor.text_revision(), + vec![ + annotation((0, 2), (0, 2), DiagnosticSeverity::Error, "point"), + annotation((0, 1), (3, 0), DiagnosticSeverity::Warning, "multiline"), + annotation((3, 4), (3, 4), DiagnosticSeverity::Information, "end"), + annotation((3, 0), (3, 4), DiagnosticSeverity::Hint, "hint"), + ], + ) + .unwrap(); + editor.handle(Input::Home); + editor.handle(Input::Right); + editor.handle(Input::Right); + editor.set_message("write failed: permission denied"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 7)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let buffer = terminal.backend().buffer(); + for (position, color) in [ + ((3, 0), Color::Yellow), + ((4, 0), Color::Red), + ((5, 0), Color::Yellow), + ((2, 1), Color::Yellow), + ((2, 2), Color::Yellow), + ((3, 2), Color::Yellow), + ((4, 2), Color::Yellow), + ((2, 3), Color::DarkGray), + ((6, 3), Color::Cyan), + ] { + assert!(buffer[position].modifier.contains(Modifier::UNDERLINED)); + assert_eq!(buffer[position].underline_color, color); + } + for (row, color) in [ + (0, Color::Red), + (1, Color::Yellow), + (2, Color::Yellow), + (3, Color::Cyan), + ] { + assert_eq!(buffer[(0, row)].fg, color); + } + assert!(!buffer[(6, 0)].modifier.contains(Modifier::UNDERLINED)); + let text = screen_text(&terminal); + assert!(text.contains("E:1 W:1 I:1 H:1"), "{text}"); + assert!( + text.contains("write failed: permission denied | E: point"), + "{text}" + ); + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new(4, 0), + ); + + editor.handle(Input::Char('i')); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(editor.diagnostics().count(), 0); + assert!(!screen_text(&terminal).contains("E:1")); + assert!(!screen_text(&terminal).contains("E: point")); + assert_ne!(terminal.backend().buffer()[(0, 0)].fg, Color::Red); + assert!( + !terminal.backend().buffer()[(4, 0)] + .modifier + .contains(Modifier::UNDERLINED) + ); + for message in ["intermediate", "latest"] { + editor + .replace_diagnostics( + "fixture", + editor.buffer_id(), + editor.text_revision(), + vec![annotation( + (0, 0), + (0, 3), + DiagnosticSeverity::Error, + message, + )], + ) + .unwrap(); + } + editor.handle(Input::Escape); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert!(screen_text(&terminal).contains("E:1")); + assert!(screen_text(&terminal).contains("E: latest")); + assert!(!screen_text(&terminal).contains("intermediate")); + assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Red); + assert!( + terminal.backend().buffer()[(3, 0)] + .modifier + .contains(Modifier::UNDERLINED) + ); + editor.handle(Input::Char('i')); + editor.handle(Input::Char('!')); + assert_eq!(editor.mode(), Mode::Insert); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(editor.diagnostics().count(), 0); + assert!(!screen_text(&terminal).contains("E:1")); + assert!( + !terminal.backend().buffer()[(4, 0)] + .modifier + .contains(Modifier::UNDERLINED) + ); + editor.handle(Input::Escape); + assert_eq!(editor.diagnostics().count(), 0); +} + +#[test] +fn idle_session_updates_redraw_annotations_and_navigation_while_native_editing_continues() { + let directory = Directory::new(); + let source = directory.file("source.txt", "broken TARGET"); + let target_text = format!("{}{}TARGET", "# line\n".repeat(15), "#".repeat(40)); + let target = directory.file("target.py", &target_text); + directory.file( + "semantic.rb", + r#" +Vamp.plugin "semantic", requires: ["text", "diagnostics"], async: true +work = Queue.new +Vamp.on_document { |event| work << ["document", event] } +Vamp.command "fixture_definition" do |editor, _| + raise "No target reference" unless editor.text.include?("TARGET") + work << ["definition", editor.buffer_id, editor.revision, editor.request_id] +end +Thread.new do + documents = {} + loop do + kind, *args = work.pop + if kind == "document" + event = args[0] + document = event.fetch("document") + if event.fetch("event") == "close" + documents.delete(document.fetch("id")) + next + end + documents[document.fetch("id")] = document + next unless ["open", "change"].include?(event.fetch("event")) + items = [] + document.fetch("text").split("\n", -1).each_with_index do |line, row| + graphemes = line.each_grapheme_cluster.to_a + graphemes.each_cons(6).with_index do |token, column| + next unless token.join == "broken" + items << {start: {row: row, column: column}, + end: {row: row, column: column + 6}, + severity: "error", message: "Unresolved token"} + end + end + Vamp.publish([{type: "diagnostics", buffer_id: document.fetch("id"), + revision: document.fetch("revision"), items: items}, + {type: "message", text: "diagnostic revision #{document.fetch('revision')}"}]) + else + target = documents.values.find { |document| document["path"] == Vamp.options.fetch("target") } + raise "Target document is not open" unless target + lines = target.fetch("text").split("\n", -1) + row = lines.index { |line| line.include?("TARGET") } + raise "Target definition missing" unless row + column = lines[row].each_grapheme_cluster.to_a.each_cons(6).find_index { |token| token.join == "TARGET" } + Vamp.publish([{type: "navigate", buffer_id: args[0], revision: args[1], + path: target.fetch("path"), row: row, column: column}], + request_id: args[2]) + end + end +end +"#, + ); + let config = directory.file( + "config.rb", + &format!( + "Vamp.configure do |config|\n config.clipboard_mode = \"separate\"\n config.plugin \"semantic.rb\", options: {{target: {}}}\nend\n", + serde_json::to_string(&target.to_string_lossy()).unwrap(), + ), + ); + let mut session = EditorSession::open( + Some(source), + CAPABILITIES + .iter() + .map(|capability| (*capability).into()) + .collect(), + Some(&config), + ) + .unwrap(); + session + .editor_mut() + .set_gutter_style(crate::gutter::GutterStyle::None); + let source_id = session.editor().buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(18, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + + fn poll_until( + session: &mut EditorSession, + terminal: &mut Terminal, + viewports: &mut BufferViewports, + ready: impl Fn(&Editor) -> bool, + ) { + for _ in 0..2_000 { + if session.poll() { + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + } + if ready(session.editor()) { + return; + } + std::thread::sleep(Duration::from_millis(1)); + } + panic!( + "async fixture did not update editor: {}", + session.editor().message() + ); + } + + session.handle(Input::Char('A')); + session.handle(Input::Char('!')); + assert_eq!(session.editor().mode(), Mode::Insert); + assert_eq!(session.editor().lines(), &["broken TARGET!"]); + poll_until(&mut session, &mut terminal, &mut viewports, |editor| { + editor.message() == "diagnostic revision 1" + }); + assert_eq!(session.editor().diagnostics().count(), 0); + assert!(!screen_text(&terminal).contains("E:1")); + assert!( + !terminal.backend().buffer()[(2, 0)] + .modifier + .contains(Modifier::UNDERLINED) + ); + assert_eq!(session.editor().mode(), Mode::Insert); + session.handle(Input::Escape); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(screen_text(&terminal).contains("E:1")); + assert!( + terminal.backend().buffer()[(2, 0)] + .modifier + .contains(Modifier::UNDERLINED) + ); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["broken TARGET"]); + assert_eq!(session.editor().diagnostics().count(), 0); + + session.execute(&format!("edit {}", target.display())); + let target_id = session.editor().buffer_id(); + session.handle(Input::Char('i')); + session.handle(Input::Char('!')); + session.handle(Input::Escape); + session.execute(&format!("buffer {source_id}")); + session.execute("fixture_definition"); + session.handle(Input::Char('l')); + assert_eq!(session.editor().buffer_id(), source_id); + poll_until(&mut session, &mut terminal, &mut viewports, |editor| { + editor.buffer_id() == target_id + }); + assert_eq!(session.editor().buffer_count(), 2); + assert!(session.editor().dirty()); + assert_eq!(session.editor().lines()[0], "!# line"); + assert_eq!(session.editor().cursor().row, 15); + assert_eq!(session.editor().cursor().column, 40); + assert_eq!(session.editor().mode(), Mode::Normal); + if session.editor().notification_visible() && !session.editor().message().is_empty() { + session.handle(Input::Escape); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + } + let text_viewport = viewports.panels.caret.unwrap().0; + let offsets = viewports.offsets[&target_id]; + assert_eq!( + offsets.0, + session.editor().cursor().row + 1 - usize::from(text_viewport.height) + ); + assert!(offsets.1 > 0); + assert!(text_viewport.contains(terminal.get_cursor_position().unwrap())); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + "T" + ); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].fg, + Color::DarkGray + ); + assert_eq!(fs::read_to_string(target).unwrap(), target_text); +} + +#[test] +fn syntax_tokens_refresh_after_native_edits_and_undo_without_changing_text_on_draw() { + let directory = Directory::new(); + let path = directory.file("number.rs", "42"); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor.set_clipboard_mode("separate").unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); + let revision = editor.text_revision(); + editor.handle(Input::Right); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(editor.text_revision(), revision); + assert_eq!(editor.lines(), &["42"]); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); + + editor.handle(Input::Home); + editor.handle(Input::Char('v')); + editor.handle(Input::Right); + editor.handle(Input::Char('c')); + for ch in "\"ok\"".chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Escape); + assert_eq!(editor.lines(), &["\"ok\""]); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); + assert!(editor.text_revision() > revision); + editor.handle(Input::Char('u')); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(editor.lines(), &["42"]); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); + + editor + .apply(vec![crate::editor::Effect::Replace( + "fn main() { let n = 42; let s = \"ok\"; } // note".into(), + )]) + .unwrap(); + editor.handle(Input::Home); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let source = &editor.lines()[0]; + for (token, color) in [ + ("fn", Color::Magenta), + ("main", Color::LightBlue), + ("let", Color::Magenta), + ("42", Color::Yellow), + ("\"ok\"", Color::Green), + ] { + let x = 2 + source.find(token).unwrap() as u16; + assert_eq!(buffer[(x, 0)].fg, color, "{token}"); + assert_eq!(buffer[(x, 0)].bg, Color::Reset); + } +} + +#[test] +fn syntax_continuations_above_viewport_refresh_when_the_opening_line_changes() { + let directory = Directory::new(); + for (name, before, after, color) in [ + ( + "comment.rs", + "/*\nhidden\nvisible\n*/", + "//\nhidden\nvisible\n//", + Color::DarkGray, + ), + ( + "string.py", + "text = \"\"\"\nhidden\nvisible\n\"\"\"", + "text = \"\"\nhidden\nvisible\n\"\"", + Color::Green, + ), + ] { + let path = directory.file(name, before); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.handle(Input::Down); + editor.handle(Input::Down); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 3)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let (viewport, caret) = viewports.panels.caret.unwrap(); + assert!(viewports.offsets[&editor.buffer_id()].0 > 0); + assert!(viewport.contains(caret)); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].symbol(), + "v" + ); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].fg, + color, + "{name}" + ); + editor + .apply(vec![crate::editor::Effect::Replace(after.into())]) + .unwrap(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].symbol(), + "v" + ); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].fg, + Color::Reset, + "{name}" + ); + editor.handle(Input::Char('u')); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].fg, + color, + "{name}" + ); + } +} + +#[test] +fn semantic_roles_remain_distinct_when_values_and_fields_are_readonly() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor + .apply(vec![crate::editor::Effect::Replace( + "ns Type local frozen field fixed param frozenparam".into(), + )]) + .unwrap(); + let readonly = SemanticTokenModifier::Readonly as u16; + editor + .replace_semantic_tokens( + editor.buffer_id(), + editor.text_revision(), + Some(Arc::from([ + semantic_token(0, 0, 2, SemanticTokenKind::Namespace, 0), + semantic_token(0, 3, 7, SemanticTokenKind::Type, 0), + semantic_token(0, 8, 13, SemanticTokenKind::Variable, 0), + semantic_token(0, 14, 20, SemanticTokenKind::Variable, readonly), + semantic_token(0, 21, 26, SemanticTokenKind::Property, 0), + semantic_token(0, 27, 32, SemanticTokenKind::Property, readonly), + semantic_token(0, 33, 38, SemanticTokenKind::Parameter, 0), + semantic_token(0, 39, 50, SemanticTokenKind::Parameter, readonly), + ])), + ) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 4)).unwrap(); + terminal + .draw(|frame| BufferViewports::default().draw(frame, &mut editor)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let foregrounds = [0, 3, 8, 14, 21, 27, 33, 39].map(|column| buffer[(column + 2, 0)].fg); + assert_ne!(foregrounds[0], foregrounds[1]); + assert_ne!(foregrounds[1], foregrounds[2]); + assert_ne!(foregrounds[2], foregrounds[4]); + assert_eq!(foregrounds[2], foregrounds[3]); + assert_eq!(foregrounds[4], foregrounds[5]); + assert_eq!(foregrounds[6], foregrounds[7]); + for (column, is_readonly) in [ + (0, false), + (3, false), + (8, false), + (14, true), + (21, false), + (27, true), + (33, false), + (39, true), + ] { + assert_eq!( + buffer[(column + 2, 0)].modifier.contains(Modifier::ITALIC), + is_readonly, + ); + } +} + +#[test] +fn semantic_colors_modifiers_and_syntax_fallback_cover_whole_graphemes_and_clipped_tabs() { + let directory = Directory::new(); + let source = "#a\t界e\u{301}👩‍🚀z"; + let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor.set_clipboard_mode("separate").unwrap(); + editor + .replace_semantic_tokens( + editor.buffer_id(), + editor.text_revision(), + Some(Arc::from([ + semantic_token( + 0, + 0, + 1, + SemanticTokenKind::Comment, + SemanticTokenModifier::Documentation as u16, + ), + semantic_token( + 0, + 2, + 6, + SemanticTokenKind::Property, + SemanticTokenModifier::Readonly as u16 + | SemanticTokenModifier::Definition as u16 + | SemanticTokenModifier::Deprecated as u16, + ), + semantic_token(0, 2, 3, SemanticTokenKind::Variable, 0), + ])), + ) + .unwrap(); + editor + .replace_diagnostics( + "fixture", + editor.buffer_id(), + editor.text_revision(), + vec![annotation( + (0, 2), + (0, 6), + DiagnosticSeverity::Warning, + "semantic", + )], + ) + .unwrap(); + let mut syntax = SyntaxCache::new(); + for (offset, width, caret, coverage) in [ + (2, 5, 4, vec![true; 5]), + (5, 2, 4, vec![false, true]), + (7, 2, 5, vec![true; 2]), + (8, 2, 6, vec![false; 2]), + (0, 5, 2, vec![false, false, true, true, false]), + ] { + editor.handle(Input::Home); + for _ in 0..caret { + editor.handle(Input::Right); + } + if editor.notification_visible() && editor.diagnostic_at_cursor().is_some() { + editor.handle(Input::Escape); + } + let area = Rect::new(3, 2, width + 2, 4); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(20, 8), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + let (mut top, mut left) = (0, offset); + let rendered = terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap() + .buffer + .clone(); + assert_eq!(left, offset); + let text_area = Rect::new(area.x + 2, area.y, width, 1); + let mut expected = ratatui::buffer::Buffer::empty(text_area); + use ratatui::widgets::Widget; + Paragraph::new(visible_line(source, offset, usize::from(width))) + .render(text_area, &mut expected); + let buffer = terminal.backend().buffer(); + for (cell, semantic_cell) in coverage.into_iter().enumerate() { + let position = (text_area.x + cell as u16, text_area.y); + assert_eq!(buffer[position].symbol(), expected[position].symbol()); + assert_eq!(rendered[position].bg, Color::Reset); + assert_eq!( + rendered[position].modifier.contains(Modifier::BOLD), + semantic_cell + ); + assert_eq!( + rendered[position].modifier.contains(Modifier::CROSSED_OUT), + semantic_cell + ); + assert_eq!( + rendered[position].modifier.contains(Modifier::UNDERLINED), + semantic_cell + ); + if semantic_cell { + assert_eq!(rendered[position].underline_color, Color::Yellow); + } + assert_eq!( + rendered[position].modifier.contains(Modifier::ITALIC), + semantic_cell || (offset == 0 && cell == 0) + ); + } + assert_eq!(buffer[(area.x, area.y)].fg, Color::Yellow); + assert_eq!(buffer[(0, 0)].fg, Color::Reset); + assert_eq!(editor.lines(), &[source]); + } + editor + .replace_semantic_tokens(editor.buffer_id(), editor.text_revision(), None) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 4)).unwrap(); + let (mut top, mut left) = (0, 0); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::DarkGray); + assert!( + !terminal.backend().buffer()[(2, 0)] + .modifier + .contains(Modifier::ITALIC) + ); +} + +#[test] +fn search_visual_selection_and_secondary_carets_override_semantic_foreground() { + let directory = Directory::new(); + let mut editor = Editor::open( + Some(directory.file("overlays.py", "# word\n# word\n# word")), + vec![], + ) + .unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor + .replace_semantic_tokens( + editor.buffer_id(), + editor.text_revision(), + Some(Arc::from( + (0..3) + .map(|row| { + semantic_token( + row, + 2, + 6, + SemanticTokenKind::Function, + SemanticTokenModifier::Declaration as u16, + ) + }) + .collect::>(), + )), + ) + .unwrap(); + editor + .replace_diagnostics( + "fixture", + editor.buffer_id(), + editor.text_revision(), + vec![annotation( + (0, 2), + (2, 6), + DiagnosticSeverity::Error, + "overlays", + )], + ) + .unwrap(); + editor.handle(Input::Char('/')); + for ch in "word".chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Enter); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let found = editor.search_match().unwrap(); + let y = found.start.row as u16; + for x in 4..8 { + assert_eq!(terminal.backend().buffer()[(x, y)].fg, Color::Black); + assert_eq!(terminal.backend().buffer()[(x, y)].bg, Color::Yellow); + assert!( + terminal.backend().buffer()[(x, y)] + .modifier + .contains(Modifier::UNDERLINED) + ); + } + editor.handle(Input::Char('v')); + editor.handle(Input::Char('e')); + editor.handle(Input::Ctrl('a')); + assert_eq!(editor.selection_count(), 3); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let buffer = terminal.backend().buffer(); + for selection in editor.selections() { + let y = selection.start.row as u16; + let start = display_column(&editor.lines()[selection.start.row], selection.start.column); + let end = display_column(&editor.lines()[selection.end.row], selection.end.column); + for column in start..end { + let x = 2 + column as u16; + if buffer[(x, y)].bg == Color::Magenta { + assert_eq!(buffer[(x, y)].fg, Color::Black); + } else { + assert_eq!(buffer[(x, y)].fg, Color::White); + assert_eq!(buffer[(x, y)].bg, Color::Blue); + } + assert!(buffer[(x, y)].modifier.contains(Modifier::UNDERLINED)); + } + assert_eq!(buffer[(2, y)].fg, Color::DarkGray); + } + for caret in editor + .carets() + .into_iter() + .filter(|caret| *caret != editor.cursor()) + { + let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16; + assert_eq!(buffer[(x, caret.row as u16)].fg, Color::Black); + assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta); + assert!( + buffer[(x, caret.row as u16)] + .modifier + .contains(Modifier::UNDERLINED) + ); + } +} + +#[test] +fn syntax_redetects_saved_paths_without_text_changes_and_plain_text_keeps_default_color() { + let directory = Directory::new(); + let mut editor = + Editor::open(Some(directory.file("source.rs", "let value = 42;")), vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta); + let revision = editor.text_revision(); + for name in ["source.vampunknown", "source.txt", "again.rs"] { + editor.execute(&format!("w {}", directory.0.join(name).display())); + assert_eq!(editor.text_revision(), revision); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + if name.ends_with(".rs") { + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta); + assert_eq!( + viewports.syntax[&editor.buffer_id()].language(), + Some("Rust") + ); + } else { + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Reset); + assert!(viewports.syntax[&editor.buffer_id()].line(0).is_empty()); + assert!(viewports.syntax[&editor.buffer_id()].language().is_none()); + } + assert_eq!(editor.lines(), &["let value = 42;"]); + } +} + +#[test] +fn syntax_caches_stay_independent_across_buffer_edits_and_retire_closed_ids() { + let directory = Directory::new(); + let mut editor = Editor::open(Some(directory.file("a.rs", "42")), vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + let a_id = editor.buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); + editor.execute(&format!( + "edit {}", + directory.file("b.py", "# note").display() + )); + let b_id = editor.buffer_id(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::DarkGray); + editor + .apply(vec![crate::editor::Effect::Replace("\"ok\"".into())]) + .unwrap(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); + editor.execute(&format!("buffer {a_id}")); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); + assert_eq!(viewports.syntax[&a_id].language(), Some("Rust")); + assert_eq!(viewports.syntax[&b_id].language(), Some("Python")); + editor.execute(&format!("buffer {b_id}")); + editor.execute(&format!("bdelete {a_id}")); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); + assert!(!viewports.syntax.contains_key(&a_id)); + assert!(!viewports.offsets.contains_key(&a_id)); + assert_eq!(viewports.syntax.len(), 1); +} + +#[test] +fn diff_control_i_restores_jump_location_without_cycling_panes() { + for unified in [false, true] { + for focus in [Focus::Before, Focus::After] { + for code in [KeyCode::Char('i'), KeyCode::Tab] { + let mut editor = readonly_diff_editor(syntax_diff_file( + "jump.txt", + "old\nmiddle\ntarget", + "new\nmiddle\ntarget", + )); + if unified { + editor.execute("diff.view.toggle"); + } + for _ in 0..3 { + if editor.diff().unwrap().focus == focus { + break; + } + editor.handle(Input::Char('\t')); + } + assert_eq!(editor.diff().unwrap().focus, focus); + let buffer = editor.buffer_id(); + for ch in "/target".chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Enter); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor(), Cursor::default()); + editor.handle(translate(KeyEvent::new(code, KeyModifiers::CONTROL)).unwrap()); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.diff().unwrap().focus, focus); + assert_eq!(editor.buffer_id(), buffer); + assert!(!editor.dirty()); + editor.handle(translate(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)).unwrap()); + assert_ne!(editor.diff().unwrap().focus, focus); + for _ in 0..3 { + if editor.diff().unwrap().focus == Focus::Files { + break; + } + editor.handle(Input::Char('\t')); + } + assert_eq!(editor.diff().unwrap().focus, Focus::Files); + editor.handle(Input::Ctrl('o')); + assert_eq!(editor.cursor(), Cursor::default()); + assert_eq!(editor.diff().unwrap().focus, focus); + for _ in 0..3 { + if editor.diff().unwrap().focus == Focus::Files { + break; + } + editor.handle(Input::Char('\t')); + } + assert_eq!(editor.diff().unwrap().focus, Focus::Files); + editor.handle(translate(KeyEvent::new(code, KeyModifiers::CONTROL)).unwrap()); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.diff().unwrap().focus, Focus::Files); + } + } + } +} + +#[test] +fn translate_distinguishes_control_j_from_enter_and_normalizes_control_letters() { + for (code, modifiers, expected) in [ + ( + KeyCode::Char('j'), + KeyModifiers::CONTROL, + Some(Input::Ctrl('j')), + ), + ( + KeyCode::Char('J'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + Some(Input::Ctrl('j')), + ), + ( + KeyCode::Char('k'), + KeyModifiers::CONTROL, + Some(Input::Ctrl('k')), + ), + ( + KeyCode::Char('h'), + KeyModifiers::CONTROL, + Some(Input::Ctrl('h')), + ), + ( + KeyCode::Char('l'), + KeyModifiers::CONTROL, + Some(Input::Ctrl('l')), + ), + ( + KeyCode::Backspace, + KeyModifiers::NONE, + Some(Input::Backspace), + ), + (KeyCode::Enter, KeyModifiers::NONE, Some(Input::Enter)), + (KeyCode::Enter, KeyModifiers::CONTROL, Some(Input::Enter)), + ( + KeyCode::Char('J'), + KeyModifiers::SHIFT, + Some(Input::Char('J')), + ), + (KeyCode::Char('j'), KeyModifiers::ALT, Some(Input::Alt('j'))), + ( + KeyCode::Char('H'), + KeyModifiers::ALT | KeyModifiers::SHIFT, + Some(Input::Alt('h')), + ), + ( + KeyCode::Char('j'), + KeyModifiers::ALT | KeyModifiers::CONTROL, + Some(Input::CtrlAlt('j')), + ), + ( + KeyCode::Char('H'), + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, + Some(Input::CtrlAlt('h')), + ), + ( + KeyCode::Char('L'), + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, + Some(Input::CtrlAlt('l')), + ), + ( + KeyCode::Char('j'), + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER, + None, + ), + ( + KeyCode::Left, + KeyModifiers::CONTROL | KeyModifiers::ALT, + None, + ), + (KeyCode::Char('j'), KeyModifiers::SUPER, None), + (KeyCode::Left, KeyModifiers::ALT, None), + ] { + assert_eq!(translate(KeyEvent::new(code, modifiers)), expected); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_modified_backspace_edits_words_and_line_prefix_from_terminal_events() { + let directory = Directory::new(); + let path = directory.file("input.txt", "before\nalpha βeta SUFFIX\nlast"); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + session.handle(Input::Down); + session.handle(Input::Home); + session.handle(Input::Char('i')); + for _ in 0..13 { + session.handle(Input::Right); + } + session.handle(translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)).unwrap()); + assert_eq!(session.editor().lines(), ["before", "alpha SUFFIX", "last"]); + session.handle(translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::SUPER)).unwrap()); + assert_eq!(session.editor().lines(), ["before", "SUFFIX", "last"]); + + for modifiers in [ + KeyModifiers::META, + KeyModifiers::HYPER, + KeyModifiers::ALT | KeyModifiers::CONTROL, + KeyModifiers::ALT | KeyModifiers::SHIFT, + KeyModifiers::SUPER | KeyModifiers::CONTROL, + KeyModifiers::SUPER | KeyModifiers::SHIFT, + KeyModifiers::SUPER | KeyModifiers::ALT, + KeyModifiers::SUPER | KeyModifiers::META, + ] { + assert_eq!( + translate(KeyEvent::new(KeyCode::Backspace, modifiers)), + None + ); + } + assert_eq!( + translate(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER)), + None + ); +} + +#[cfg(not(target_os = "macos"))] +#[test] +fn modified_backspace_keeps_non_macos_terminal_defaults() { + for modifiers in [KeyModifiers::ALT, KeyModifiers::SUPER] { + assert_eq!( + translate(KeyEvent::new(KeyCode::Backspace, modifiers)), + None + ); + } + assert_eq!( + translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL)), + Some(Input::Backspace) + ); +} + +#[test] +fn wheel_routes_to_floating_and_unfocused_docked_files_without_changing_selection() { + let directory = Directory::new(); + let config = directory.file( + "config.rb", + "Vamp.configure do |config|\n config.file_picker_view = 'list'\n config.file_explorer_view = 'list'\nend\n", + ); + for (key, focused) in [('o', true), ('e', true), ('e', false)] { + let mut session = EditorSession::open(None, vec![], Some(&config)).unwrap(); + session.handle(Input::Char(' ')); + session.handle(Input::Char(key)); + if !focused { + session.handle(Input::Char('\t')); + } + let picker = session.editor().file_picker().unwrap(); + assert!(picker.entries().len() > 5); + let selected = picker.selected; + let selected_path = picker.entries().nth(selected).unwrap().path.to_owned(); + let query = picker.query.clone(); + let carets = session.editor().carets(); + let revision = session.editor().text_revision(); + let mut panels = WheelPanels::default(); + let area = Rect::new(3, 2, 40, 6); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 12)).unwrap(); + terminal + .draw(|frame| draw_file_list(frame, picker, area, "", &mut panels, None)) + .unwrap(); + let mouse = MouseEvent { + kind: MouseEventKind::ScrollDown, + column: area.x, + row: area.y, + modifiers: KeyModifiers::NONE, + }; + assert!(!panels.scroll( + &mut session, + MouseEvent { + column: 0, + row: 0, + ..mouse + } + )); + assert!(panels.scroll(&mut session, mouse)); + assert_eq!(panels.manual_files.as_ref().unwrap().top, 3); + terminal + .draw(|frame| { + panels.begin_frame(); + draw_file_list( + frame, + session.editor().file_picker().unwrap(), + area, + "", + &mut panels, + None, + ); + }) + .unwrap(); + let picker = session.editor().file_picker().unwrap(); + let text = screen_text(&terminal); + assert!( + text.contains(picker.entries().nth(3).unwrap().label), + "{text}" + ); + assert!( + !text.contains(&format!( + "> {}", + picker.entries().nth(selected).unwrap().label + )), + "{text}" + ); + assert_eq!(picker.selected, selected); + assert_eq!(picker.entries().nth(selected).unwrap().path, selected_path); + assert_eq!(picker.query, query); + assert_eq!(picker.focused, focused); + assert_eq!(session.editor().carets(), carets); + assert_eq!(session.editor().text_revision(), revision); + let entry_count = picker.entries().len(); + assert!(!panels.scroll( + &mut session, + MouseEvent { + kind: MouseEventKind::Moved, + ..mouse + } + )); + for _ in 0..entry_count { + assert!(panels.scroll(&mut session, mouse)); + } + let (_, height, _) = panels.files.unwrap(); + assert_eq!( + panels.manual_files.as_ref().unwrap().top, + session.editor().file_picker().unwrap().entries().len() - height + ); + terminal + .draw(|frame| { + draw_file_list( + frame, + session.editor().file_picker().unwrap(), + area, + "", + &mut panels, + None, + ) + }) + .unwrap(); + assert_eq!(panels.files.unwrap().2, entry_count - height); + panels.follow_files(); + session.handle(Input::Ctrl('p')); + assert!(session.editor().palette().is_some()); + assert!(panels.scroll(&mut session, mouse)); + assert!(panels.manual_files.is_none()); + session.handle(Input::Escape); + session.handle(Input::Char(' ')); + session.handle(Input::Char(key)); + session.handle(Input::Ctrl('w')); + assert!(session.editor().file_picker().is_none()); + assert!(!panels.scroll(&mut session, mouse)); + } +} + +#[test] +fn manually_scrolled_tree_does_not_place_cursor_or_highlight_offscreen_selection() { + let directory = Directory::new(); + for index in 0..12 { + directory.file(&format!("file-{index:02}"), ""); + } + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::Tree, + ) + .unwrap(); + picker.selected = 11; + let mut panels = WheelPanels { + manual_files: Some(FileWheelOffset { + top: 0, + root: picker.root.clone(), + query: picker.query.clone(), + state: FileWheelOffset::state(&picker), + }), + ..WheelPanels::default() + }; + let area = Rect::new(3, 2, 40, 6); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 12)).unwrap(); + terminal + .draw(|frame| { + frame.set_cursor_position((59, 11)); + draw_file_list(frame, &picker, area, "", &mut panels, None); + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("file-00"), "{text}"); + assert!(!text.contains("file-11"), "{text}"); + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new(59, 11) + ); + for y in area.y..area.bottom() { + for x in area.x..area.right() { + assert_ne!(terminal.backend().buffer()[(x, y)].bg, Color::Blue); + } + } + picker.navigate(false); + terminal + .draw(|frame| draw_file_list(frame, &picker, area, "", &mut panels, None)) + .unwrap(); + assert!(panels.manual_files.is_none()); + assert!(screen_text(&terminal).contains("file-10")); +} + +#[test] +fn picker_viewport_preserves_direction_and_uses_visual_row_margins() { + for row_height in [1, 2] { + let mut viewport = None; + let height = 4 * row_height; + for selected in 0..8 { + PickerViewport::follow(&mut viewport, 1, selected, height, row_height, Some(0)); + } + for selected in (4..8).rev() { + assert_eq!( + PickerViewport::follow(&mut viewport, 1, selected, height, row_height, Some(0)), + 4, + ); + } + assert_eq!( + PickerViewport::follow(&mut viewport, 1, 3, height, row_height, Some(0)), + 3, + ); + assert_eq!( + PickerViewport::follow(&mut viewport, 2, 3, height, row_height, Some(0)), + 0, + ); + for distance in [None, Some(usize::MAX)] { + assert_eq!( + PickerViewport::follow(&mut viewport, 2, 7, height, row_height, distance), + 5, + ); + assert_eq!( + PickerViewport::follow(&mut viewport, 2, 0, height, row_height, distance), + 0, + ); + } + } + let mut viewport = None; + assert_eq!( + PickerViewport::follow(&mut viewport, 1, 7, 8, 2, Some(1)), + 5 + ); + assert_eq!( + PickerViewport::follow(&mut viewport, 1, 6, 8, 2, Some(1)), + 5 + ); + assert_eq!( + PickerViewport::follow(&mut viewport, 1, 7, 8, 2, Some(3)), + 5 + ); + assert_eq!(PickerViewport::follow(&mut viewport, 1, 7, 7, 2, None), 6); + assert_eq!(PickerViewport::follow(&mut viewport, 1, 7, 3, 1, None), 6); + assert_eq!(PickerViewport::follow(&mut viewport, 1, 7, 0, 1, None), 6); +} + +#[test] +fn file_viewport_keeps_manual_wheel_until_selection_or_catalog_changes() { + let directory = Directory::new(); + for index in 0..12 { + directory.file(&format!("file-{index:02}"), ""); + } + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::List, + ) + .unwrap(); + let mut panels = WheelPanels::default(); + let area = Rect::new(0, 0, 40, 8); + picker.selected = 7; + assert_eq!(panels.file_top(&picker, area, 5), 3); + picker.selected = 6; + assert_eq!(panels.file_top(&picker, area, 5), 3); + panels.picker_scroll.distance = None; + panels.manual_files = Some(FileWheelOffset { + top: 0, + root: picker.root.clone(), + query: picker.query.clone(), + state: FileWheelOffset::state(&picker), + }); + picker.focused = !picker.focused; + assert_eq!(panels.file_top(&picker, area, 3), 0); + assert!(panels.manual_files.is_some()); + picker.selected = 7; + assert_eq!(panels.file_top(&picker, area, 5), 5); + assert!(panels.manual_files.is_none()); + panels.manual_files = Some(FileWheelOffset { + top: 0, + root: picker.root.clone(), + query: picker.query.clone(), + state: FileWheelOffset::state(&picker), + }); + std::fs::rename(directory.0.join("file-00"), directory.0.join("new-file")).unwrap(); + picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::List, + ) + .unwrap(); + picker.selected = 7; + assert_eq!(picker.entries().len(), 12); + assert_eq!(panels.file_top(&picker, area, 5), 5); + assert!(panels.manual_files.is_none()); + panels.begin_frame(); + panels.begin_frame(); + assert!(panels.picker_scroll.files.is_none()); +} + +#[test] +fn file_list_window_follows_control_j_k_selection() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_file_picker_view("list").unwrap(); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('o')); + let count = editor.file_picker().unwrap().entries().len(); + assert!(count > 1); + for _ in 0..count - 1 { + editor.handle(translate(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)).unwrap()); + } + assert_eq!(editor.file_picker().unwrap().selected, count - 1); + editor.handle(translate(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)).unwrap()); + assert_eq!(editor.file_picker().unwrap().selected, count - 2); + let picker = editor.file_picker().unwrap(); + let label = picker.entries().nth(picker.selected).unwrap().label; + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 9)).unwrap(); + terminal + .draw(|frame| { + draw_file_list( + frame, + picker, + frame.area(), + editor.file_navigation_hint(), + &mut WheelPanels::default(), + None, + ) + }) + .unwrap(); + let text = screen_text(&terminal); + assert!( + text.lines() + .any(|row| row.contains(label) && row.contains('>')), + "{text}" + ); + let buffer = terminal.backend().buffer(); + let selected_y = (3..8).find(|&y| buffer[(1, y)].symbol() == ">").unwrap(); + assert_eq!(buffer[(1, selected_y)].bg, Color::Blue); +} + +#[test] +fn explorer_wraps_only_root_path_and_reserves_file_rows_in_short_panes() { + let root = "/a/very/long/path/to/project/root"; + let picker = FilePicker::from_paths(root.into(), [(0, "entry.txt".into())]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 12)).unwrap(); + let mut panels = WheelPanels::default(); + terminal + .draw(|frame| draw_file_list(frame, &picker, frame.area(), "", &mut panels, None)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let path = (2..4) + .map(|y| (1..19).map(|x| buffer[(x, y)].symbol()).collect::()) + .collect::(); + assert_eq!(path.trim_end(), root); + assert!( + screen_text(&terminal) + .lines() + .nth(4) + .unwrap() + .contains("entry.txt") + ); + terminal.backend_mut().resize(20, 6); + terminal + .draw(|frame| draw_file_list(frame, &picker, frame.area(), "", &mut panels, None)) + .unwrap(); + assert!(screen_text(&terminal).contains("entry.txt")); +} + +#[test] +fn list_footers_show_remapped_and_disabled_selection_keys() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_file_explorer_view("list").unwrap(); + editor.set_file_picker_view("list").unwrap(); + for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] { + editor.bind("selection", key, None).unwrap(); + } + editor + .bind("selection", "x", Some("core.down".into())) + .unwrap(); + let hint = editor.selection_navigation_hint().to_owned(); + assert_eq!(hint, "x next · unbound previous"); + editor.set_file_explorer_side("bottom").unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + for key in ['o', 'e'] { + editor.handle(Input::Char(' ')); + editor.handle(Input::Char(key)); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains(&hint), "{text}"); + assert!(!text.contains("Ctrl+j"), "{text}"); + editor.handle(Input::Escape); + } + editor.handle(Input::Ctrl('p')); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains(&hint), "{text}"); + assert!(!text.contains("Ctrl+j"), "{text}"); +} + +#[test] +fn file_list_scrolls_selection_and_sanitizes_display_without_losing_paths() { + let directory = Directory::new(); + for index in 0..18 { + directory.file(&format!("file-{index:02}"), "text"); + } + let special = directory.file("界e\u{301} space\nname", "text"); + let editor = Editor::open(None, vec![]).unwrap(); + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Floating, + FileView::List, + ) + .unwrap(); + assert!( + picker + .entries() + .any(|entry| directory.0.join(entry.path) == special) + ); + let count = picker.entries().len(); + picker.selected = count - 1; + let label = picker.entries().last().unwrap().label; + assert_eq!(label, "界e\u{301} space�name"); + picker.query = "long\t界e\u{301}z".into(); + let area = Rect::new(3, 2, 24, 9); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(40, 15), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.selection_navigation_hint(), + &mut WheelPanels::default(), + None, + ) + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(!text.contains("file-00"), "{text}"); + let cursor = terminal.get_cursor_position().unwrap(); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(area.x + 7, area.y + 1)].symbol(), "�"); + assert_eq!(buffer[(area.x + 8, area.y + 1)].symbol(), "界"); + assert_eq!(buffer[(area.x + 10, area.y + 1)].symbol(), "e\u{301}"); + let selected_y = (area.y + 2..area.bottom() - 1) + .find(|&y| buffer[(area.x + 1, y)].symbol() == ">") + .unwrap(); + let selected_x = (area.x + 2..area.right()) + .find(|&x| buffer[(x, selected_y)].symbol() == "界") + .unwrap(); + assert_eq!(buffer[(selected_x + 2, selected_y)].symbol(), "e\u{301}"); + assert_eq!(buffer[(area.x + 1, selected_y)].bg, Color::Blue); + assert!( + buffer[(area.x + 1, selected_y)] + .modifier + .contains(Modifier::BOLD) + ); + assert_eq!(cursor.y, area.y + 1); + assert_eq!(buffer[(0, 0)].symbol(), " "); +} + +#[test] +fn file_list_renders_root_empty_filter_errors_and_idle_focus() { + let directory = Directory::new(); + let editor = Editor::open(None, vec![]).unwrap(); + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::List, + ) + .unwrap(); + let area = Rect::new(0, 0, 100, 12); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.selection_navigation_hint(), + &mut WheelPanels::default(), + None, + ) + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("Explorer"), "{text}"); + assert!( + text.contains(&directory.0.to_string_lossy().to_string()), + "{text}" + ); + assert!(text.contains("No project files"), "{text}"); + assert!(text.contains("Esc/Tab"), "{text}"); + picker.query = "missing".into(); + picker.error = Some("Unreadable\npath\u{1b}".into()); + picker.focused = false; + terminal + .draw(|frame| { + frame.set_cursor_position((99, 11)); + draw_file_list( + frame, + &picker, + area, + editor.selection_navigation_hint(), + &mut WheelPanels::default(), + None, + ); + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("No matching files"), "{text}"); + assert!(text.contains("Unreadable�path�"), "{text}"); + assert!(text.contains("Editor focus"), "{text}"); + assert_eq!(terminal.backend().buffer()[(1, 3)].fg, Color::Red); + assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::DarkGray); + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new(99, 11) + ); + picker.presentation = FilePresentation::Floating; + picker.focused = true; + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.selection_navigation_hint(), + &mut WheelPanels::default(), + None, + ) + }) + .unwrap(); + assert!(screen_text(&terminal).contains("Files")); + assert!(screen_text(&terminal).contains("Esc/^W")); + assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Cyan); + picker.set_view(FileView::Tree); + picker.finish_search(); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.file_navigation_hint(), + &mut WheelPanels::default(), + None, + ) + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("No matching files"), "{text}"); + assert!(text.contains("filter: missing"), "{text}"); + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new(1, 1) + ); + picker.focused = false; + terminal + .draw(|frame| { + frame.set_cursor_position((99, 11)); + draw_file_list( + frame, + &picker, + area, + editor.file_navigation_hint(), + &mut WheelPanels::default(), + None, + ); + }) + .unwrap(); + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new(99, 11) + ); +} + +#[test] +fn file_query_cursor_and_dock_layout_survive_tiny_offset_resizes() { + let directory = Directory::new(); + let editor = Editor::open(None, vec![]).unwrap(); + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::List, + ) + .unwrap(); + picker.query = "long界e\u{301}z".into(); + for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] { + let area = Rect::new(3, 2, width, height); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(24, 12), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.selection_navigation_hint(), + &mut WheelPanels::default(), + None, + ) + }) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert!(area.contains(cursor), "{area:?}: {cursor:?}"); + let bordered = width >= 4 && height >= 4; + let query_x = area.x + u16::from(bordered); + let query_y = area.y + u16::from(bordered); + let query_width = width - 2 * u16::from(bordered); + assert_eq!(cursor.x, query_x + query_width - 1); + assert_eq!(cursor.y, query_y); + let query = format!("> {}", picker.query); + let offset = + display_column(&query, usize::MAX).saturating_sub(usize::from(query_width) - 1); + let mut expected = + ratatui::buffer::Buffer::empty(Rect::new(query_x, query_y, query_width, 1)); + use ratatui::widgets::Widget; + Paragraph::new(visible_line(&query, offset, usize::from(query_width))) + .render(expected.area, &mut expected); + for x in query_x..query_x + query_width { + assert_eq!( + terminal.backend().buffer()[(x, query_y)].symbol(), + expected[(x, query_y)].symbol() + ); + } + assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " "); + for side in [ + DockSide::Left, + DockSide::Right, + DockSide::Top, + DockSide::Bottom, + ] { + for focused in [false, true] { + let (editor, panel) = dock_layout(area, side, focused); + for rect in [Some(editor), panel].into_iter().flatten() { + assert!(rect.x >= area.x && rect.y >= area.y); + assert!(rect.right() <= area.right() && rect.bottom() <= area.bottom()); + } + if focused { + assert!(panel.is_some()); + } else { + assert!(panel.is_none()); + } + } + } + } + for (width, height) in [(0, 1), (1, 0), (0, 0)] { + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + frame.area(), + editor.selection_navigation_hint(), + &mut WheelPanels::default(), + None, + ) + }) + .unwrap(); + assert!( + dock_layout(Rect::new(3, 2, width, height), DockSide::Right, true) + .1 + .is_none() + ); + } +} + +#[test] +fn tree_rows_render_directory_chains_hierarchy_and_basename_files_in_both_presentations() { + use std::path::Path; + let directory = Directory::new(); + directory.file("chain/a/b/leaf.txt", ""); + directory.file("mixed/nested/inner.txt", ""); + directory.file("mixed/sibling.txt", ""); + directory.file("single/only.txt", ""); + directory.file("root.txt", ""); + fs::create_dir(directory.0.join("empty")).unwrap(); + let editor = Editor::open(None, vec![]).unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + let mut picker = + FilePicker::scan(directory.0.clone(), presentation, FileView::Tree).unwrap(); + assert_eq!(picker.entries().next().unwrap().label, "chain/a/b"); + for path in ["chain/a/b", "mixed", "mixed/nested", "single"] { + let selected = picker + .entries() + .position(|row| row.path == Path::new(path)) + .unwrap(); + picker.selected = selected; + picker.right(); + } + let area = Rect::new(3, 2, 100, 22); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(110, 28), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.file_navigation_hint(), + &mut WheelPanels::default(), + None, + ) + }) + .unwrap(); + let text = screen_text(&terminal); + for label in [ + " chain/a/b/", + " leaf.txt", + " empty/", + " mixed/", + " nested/", + " inner.txt", + " sibling.txt", + " only.txt", + " root.txt", + ] { + assert!(text.contains(label), "{label}: {text}"); + } + assert!(!text.contains('▸') && !text.contains('▾'), "{text}"); + let buffer = terminal.backend().buffer(); + for (index, entry) in picker.entries().enumerate() { + let y = area.y + 3 + index as u16; + assert_eq!( + buffer[(area.x + 1, y)].fg, + if entry.is_dir { + Color::Cyan + } else { + Color::Reset + }, + ); + } + assert!(!text.contains("single/only.txt"), "{text}"); + assert!(!text.contains("mixed/nested/inner.txt"), "{text}"); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + ">" + ); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].bg, + Color::Blue + ); + picker.set_view(FileView::List); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.file_navigation_hint(), + &mut WheelPanels::default(), + None, + ) + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("chain/a/b/leaf.txt"), "{text}"); + assert!(text.contains("single/only.txt"), "{text}"); + assert!(!text.contains("▸"), "{text}"); + assert_eq!(terminal.get_cursor_position().unwrap().y, area.y + 1); + } +} + +#[test] +fn tree_browse_cursor_tracks_window_and_bounds_empty_search_and_tiny_offset_panels() { + let directory = Directory::new(); + for index in 0..18 { + directory.file(&format!("dir-{index:02}/file.txt"), ""); + } + let editor = Editor::open(None, vec![]).unwrap(); + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Docked, + FileView::Tree, + ) + .unwrap(); + for _ in 0..17 { + picker.navigate(true); + } + picker.right(); + picker.right(); + let long_query = "long界e\u{301}z".repeat(20); + for (width, height) in [(100, 9), (12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (100, 9)] { + let area = Rect::new(3, 2, width, height); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(110, 16), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.file_navigation_hint(), + &mut WheelPanels::default(), + None, + ) + }) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert!(area.contains(cursor), "{area:?}: {cursor:?}"); + let has_results = height > if width >= 4 && height >= 4 { 3 } else { 1 }; + if has_results { + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + ">" + ); + } else { + assert_eq!(cursor, ratatui::layout::Position::new(area.x, area.y)); + } + if width == 100 { + let text = screen_text(&terminal); + assert!(text.contains("file.txt"), "{text}"); + assert!(!text.contains("dir-00"), "{text}"); + } + for query in ["missing", long_query.as_str()] { + picker.start_search(); + picker.query = query.into(); + picker.refilter(); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.file_navigation_hint(), + &mut WheelPanels::default(), + None, + ) + }) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert!(area.contains(cursor), "{area:?}: {cursor:?}"); + assert_eq!(cursor.y, area.y + u16::from(width >= 4 && height >= 4)); + picker.finish_search(); + assert_eq!(picker.query, query); + assert_eq!(picker.entries().len(), 0); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + area, + editor.file_navigation_hint(), + &mut WheelPanels::default(), + None, + ) + }) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert!(area.contains(cursor), "{area:?}: {cursor:?}"); + let query_x = area.x + u16::from(width >= 4 && height >= 4); + assert_eq!(cursor.x, query_x); + let query_y = area.y + u16::from(width >= 4 && height >= 4); + let query_width = width - 2 * u16::from(width >= 4 && height >= 4); + let header = format!("filter: {query} · ^U clear"); + let mut expected = + ratatui::buffer::Buffer::empty(Rect::new(query_x, query_y, query_width, 1)); + use ratatui::widgets::Widget; + Paragraph::new(visible_line(&header, 0, usize::from(query_width))) + .render(expected.area, &mut expected); + let mut continuation = 0; + for x in query_x..query_x + query_width { + if continuation > 0 { + continuation -= 1; + continue; + } + continuation = expected[(x, query_y)].symbol().width().saturating_sub(1); + assert_eq!( + terminal.backend().buffer()[(x, query_y)].symbol(), + expected[(x, query_y)].symbol() + ); + } + if width == 100 { + assert!(screen_text(&terminal).contains("filter: "), "{query}"); + assert!( + screen_text(&terminal).contains("No matching files"), + "{query}" + ); + } + picker.clear_filter(); + assert!(!picker.searching); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + std::path::Path::new("dir-17/file.txt") + ); + } + } +} + +#[test] +fn tree_filter_escape_resume_and_clear_keep_selection_and_cursor_ownership() { + use std::path::Path; + let directory = Directory::new(); + directory.file("src/inner/needle.txt", ""); + directory.file("src/other.txt", ""); + directory.file("root.txt", ""); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_project_root(directory.0.clone()); + editor.set_file_picker_view("tree").unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace("unchanged".into())]) + .unwrap(); + editor.handle(Input::Char('v')); + let mode = editor.mode(); + let selections = editor.selections(); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char(if presentation == FilePresentation::Floating { + 'o' + } else { + 'e' + })); + if !editor.file_picker().unwrap().searching { + editor.handle(Input::Char('i')); + } + let query = "src/inner/needle.txt"; + for ch in query.chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Ctrl('h')); + let picker = editor.file_picker().unwrap(); + assert_eq!(picker.query, query); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new("src/inner") + ); + editor.handle(Input::Ctrl('l')); + let picker = editor.file_picker().unwrap(); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new(query) + ); + let filtered: Vec<_> = picker + .entries() + .map(|row| (row.path.to_owned(), row.expanded)) + .collect(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let query_cursor = terminal.get_cursor_position().unwrap(); + assert!(screen_text(&terminal).contains(&format!("> {query}"))); + assert!(screen_text(&terminal).contains("^U clear")); + editor.handle(Input::Escape); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused && !picker.searching); + assert_eq!(picker.query, query); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new(query) + ); + assert_eq!( + picker + .entries() + .map(|row| (row.path.to_owned(), row.expanded)) + .collect::>(), + filtered + ); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains(&format!("filter: {query}")), "{text}"); + assert!( + text.contains("needle.txt") && !text.contains("other.txt"), + "{text}" + ); + let selected_cursor = terminal.get_cursor_position().unwrap(); + assert_ne!(selected_cursor.y, query_cursor.y); + assert_eq!( + terminal.backend().buffer()[(selected_cursor.x, selected_cursor.y)].symbol(), + ">" + ); + assert_eq!( + terminal.backend().buffer()[(selected_cursor.x, selected_cursor.y)].bg, + Color::Blue + ); + editor.handle(Input::Char('i')); + assert_eq!(editor.file_picker().unwrap().query, query); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), query_cursor); + editor.handle(Input::Escape); + editor.handle(Input::Ctrl('u')); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused && !picker.searching && picker.query.is_empty()); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new(query) + ); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + ">" + ); + assert!(screen_text(&terminal).contains("other.txt")); + editor.handle(Input::Char('i')); + for ch in query.chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Ctrl('u')); + let picker = editor.file_picker().unwrap(); + assert!(picker.focused && picker.searching && picker.query.is_empty()); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new(query) + ); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!(cursor.y, query_cursor.y); + assert_eq!(cursor.x, query_cursor.x - query.len() as u16); + editor.handle(Input::Ctrl('t')); + for ch in query.chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Ctrl('u')); + let picker = editor.file_picker().unwrap(); + assert_eq!(picker.view, FileView::List); + assert!(picker.focused && picker.query.is_empty()); + assert_eq!( + picker.entries().nth(picker.selected).unwrap().path, + Path::new(query) + ); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), cursor); + editor.handle(Input::Ctrl('t')); + editor.handle(Input::Escape); + editor.handle(Input::Escape); + if presentation == FilePresentation::Floating { + assert!(editor.file_picker().is_none()); + } else { + assert!(!editor.file_picker().unwrap().focused); + } + assert_eq!(editor.mode(), mode); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.lines(), ["unchanged"]); + } +} + +#[test] +fn explorer_retains_insert_filter_through_focus_and_view_switches() { + let directory = Directory::new(); + directory.file("src/inner/needle.txt", ""); + directory.file("src/other.txt", ""); + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_project_root(directory.0.clone()); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('e')); + editor.handle(Input::Char('i')); + editor.handle(Input::Char('n')); + editor.handle(Input::Char('\t')); + assert!(!editor.file_picker().unwrap().focused); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('e')); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "n"); + editor.handle(Input::Ctrl('t')); + assert_eq!(editor.file_picker().unwrap().view, FileView::List); + assert_eq!(editor.file_picker().unwrap().query, "n"); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert!(screen_text(&terminal).contains("List")); + assert_eq!(terminal.get_cursor_position().unwrap().y, 1); + editor.handle(Input::Ctrl('t')); + assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); + assert!(editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "n"); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap().y, 1); + editor.handle(Input::Escape); + assert!(editor.file_picker().unwrap().focused); + assert!(!editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "n"); + editor.handle(Input::Ctrl('t')); + editor.handle(Input::Ctrl('t')); + assert!(!editor.file_picker().unwrap().searching); + assert_eq!(editor.file_picker().unwrap().query, "n"); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert!(screen_text(&terminal).contains("filter: n")); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + ">" + ); +} + +#[test] +fn configured_views_and_view_toggle_render_in_both_presentations_with_scoped_hints() { + let directory = Directory::new(); + directory.file("folder/file.txt", ""); + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_project_root(directory.0.clone()); + editor.set_file_picker_view("tree").unwrap(); + editor.set_file_explorer_view("list").unwrap(); + editor.set_file_explorer_side("bottom").unwrap(); + editor.bind("tree", "ctrl+l", None).unwrap(); + editor.bind("tree", "right", None).unwrap(); + editor.bind("tree", "x", Some("core.right".into())).unwrap(); + for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] { + editor.bind("selection", key, None).unwrap(); + } + editor + .bind("selection", "j", Some("core.down".into())) + .unwrap(); + editor + .bind("selection", "k", Some("core.up".into())) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(200, 30)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + for (key, expected) in [('o', FileView::Tree), ('e', FileView::List)] { + editor.handle(Input::Char(' ')); + editor.handle(Input::Char(key)); + assert_eq!(editor.file_picker().unwrap().view, expected); + if expected == FileView::Tree { + assert!(editor.file_picker().unwrap().searching); + editor.handle(Input::Escape); + } + for view in [ + expected, + if expected == FileView::Tree { + FileView::List + } else { + FileView::Tree + }, + ] { + assert_eq!(editor.file_picker().unwrap().view, view); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains(editor.file_navigation_hint()), "{text}"); + if view == FileView::Tree { + assert_ne!( + editor.file_navigation_hint(), + editor.selection_navigation_hint() + ); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + ">" + ); + editor.handle(Input::Char('x')); + assert_eq!(editor.file_picker().unwrap().query, ""); + assert!( + editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + let picker = editor.file_picker().unwrap(); + let was_file = !picker.entries().nth(picker.selected).unwrap().is_dir; + editor.handle(Input::Ctrl('h')); + if was_file { + let picker = editor.file_picker().unwrap(); + assert!(picker.entries().nth(picker.selected).unwrap().is_dir); + assert!(picker.entries().next().unwrap().expanded); + editor.handle(Input::Ctrl('h')); + } + assert!( + !editor + .file_picker() + .unwrap() + .entries() + .next() + .unwrap() + .expanded + ); + } else { + assert_eq!( + editor.file_navigation_hint(), + editor.selection_navigation_hint() + ); + assert!(text.contains("folder/file.txt"), "{text}"); + } + editor.handle(Input::Ctrl('t')); + } + editor.handle(Input::Escape); + } + editor.handle(Input::Ctrl('p')); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert!(screen_text(&terminal).contains(editor.selection_navigation_hint())); +} + +const PANE_CLOSE_CONFIG: &str = r#" +Vamp.configure do |config| + config.file_explorer_view = "list" + config.file_explorer_side = "right" + config.file_picker_view = "tree" + config.bind "ctrl+o", config.core.file_picker + config.bind "ctrl+x", config.core.pane.close + config.bind "ctrl+y", config.core.pane.force_close +end +"#; + +#[test] +fn bound_pane_close_reclaims_focused_dock_without_losing_dirty_source_or_diff() { + let directory = Directory::new(); + let path = directory.file("source.txt", "SOURCE\nsecond"); + let config = directory.file("config.rb", PANE_CLOSE_CONFIG); + for comparison in [false, true] { + for key in ['x', 'y'] { + let mut session = + EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap(); + session.editor_mut().set_project_root(directory.0.clone()); + let source = session.editor().buffer_id(); + session.handle(Input::Char('i')); + session.handle(Input::Paste("DIRTY ".into())); + session.handle(Input::Escape); + let text = session.editor().lines().to_vec(); + if comparison { + install_readonly_diff( + session.editor_mut(), + syntax_diff_file("history.txt", "OLD", "NEW"), + ); + } else { + session.handle(Input::Char(' ')); + session.handle(Input::Char('e')); + } + let pane = session.editor().pane_id(); + let tab = session.editor().tab_id(); + let buffers = session.editor().buffer_count(); + let comparison_id = session.editor().diff().and_then(|diff| diff.comparison_id); + let picker = session.editor().file_picker().unwrap(); + assert!(picker.focused); + assert_eq!(picker.presentation, FilePresentation::Docked); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 20)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let dock = views.panels.files.unwrap().0; + let workspace = views.rects[0].1; + let regions = views.panels.diff.as_ref().map(|geometry| geometry.regions); + assert_eq!(workspace.width + dock.width, 120); + assert!(dock.contains(terminal.get_cursor_position().unwrap())); + + session.handle( + translate(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL)).unwrap(), + ); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(!session.editor().should_quit(), "{comparison} Ctrl+{key}"); + assert_eq!(session.editor().pane_id(), pane); + assert_eq!(session.editor().tab_id(), tab); + assert_eq!(session.editor().pane_layout().len(), 1); + assert_eq!(session.editor().tab_count(), 1); + assert_eq!(session.editor().buffer_count(), buffers); + assert!(session.editor().file_picker().is_none()); + assert!(views.panels.files.is_none()); + assert_eq!(views.rects[0].1.width, 120); + assert!(!screen_text(&terminal).contains("Explorer")); + assert!( + session + .editor() + .buffers() + .find(|buffer| buffer.id == source) + .unwrap() + .dirty + ); + assert_eq!( + session + .editor() + .documents() + .find(|document| document.id == source) + .unwrap() + .lines, + text + ); + let (caret_area, caret) = views.panels.caret.unwrap(); + assert!(caret_area.contains(caret)); + assert_eq!(terminal.get_cursor_position().unwrap(), caret); + if comparison { + let diff = session.editor().diff().unwrap(); + assert_eq!(diff.comparison_id, comparison_id); + assert!(!diff.files_visible); + assert!(!diff.files.as_ref().unwrap().focused); + let expanded = views.panels.diff.as_ref().unwrap().regions; + assert_eq!(expanded[0].0, Rect::default()); + for index in [1, 2] { + assert!(expanded[index].0.width > regions.unwrap()[index].0.width); + assert_eq!( + terminal.backend().buffer()[(expanded[index].0.x, expanded[index].0.y)] + .symbol(), + "┌" + ); + } + let screen = screen_text(&terminal); + assert!(screen.contains("Before") && screen.contains("After")); + assert!(screen.contains("OLD") && screen.contains("NEW")); + session.execute("diff.close"); + } + assert_eq!(session.editor().buffer_id(), source); + assert_eq!(session.editor().lines(), text); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), ["SOURCE", "second"]); + assert!(!session.editor().dirty()); + } + } +} + +#[test] +fn bound_pane_close_with_unfocused_dock_removes_editor_leaf_not_the_list() { + let directory = Directory::new(); + let path = directory.file("source.txt", "SOURCE"); + let config = directory.file("config.rb", PANE_CLOSE_CONFIG); + for comparison in [false, true] { + let mut session = EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap(); + session.editor_mut().set_project_root(directory.0.clone()); + let root = session.editor().pane_id(); + let source = session.editor().buffer_id(); + session.handle(Input::Char('i')); + session.handle(Input::Paste("DIRTY ".into())); + session.handle(Input::Escape); + session.execute("pane.split_vertical"); + let closing = session.editor().pane_id(); + if comparison { + install_readonly_diff( + session.editor_mut(), + syntax_diff_file("history.txt", "OLD", "NEW"), + ); + } else { + session.handle(Input::Char(' ')); + session.handle(Input::Char('e')); + } + session.handle(Input::Char('\t')); + assert!(!session.editor().file_picker().unwrap().focused); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 20)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(views.rects.len(), 2); + let dock = views.panels.files.unwrap().0; + session.handle(Input::Ctrl('x')); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(!session.editor().should_quit()); + assert_eq!(session.editor().pane_id(), root); + assert!(!session.editor().pane_layout().contains(closing)); + assert_eq!(views.rects.len(), 1); + assert_eq!(session.editor().buffer_id(), source); + assert_eq!(session.editor().lines(), ["DIRTY SOURCE"]); + assert!(session.editor().dirty()); + assert!(session.editor().diff().is_none()); + assert!(!screen_text(&terminal).contains("Before")); + if comparison { + assert!(views.panels.files.is_none()); + assert_eq!(views.rects[0].1.width, 120); + } else { + assert!(!session.editor().file_picker().unwrap().focused); + assert_eq!(views.panels.files.unwrap().0, dock); + assert_eq!(views.rects[0].1.width + dock.width, 120); + assert!(screen_text(&terminal).contains("Explorer")); + } + assert!( + views.rects[0] + .1 + .contains(terminal.get_cursor_position().unwrap()) + ); + } +} + +#[test] +fn bound_pane_close_under_floating_picker_obeys_dirty_guard_force_and_escape() { + let directory = Directory::new(); + let source = directory.file("source.txt", "SURVIVING DOCUMENT"); + let owner = directory.file("owner.txt", "POPUP OWNER"); + let config = directory.file("config.rb", PANE_CLOSE_CONFIG); + for comparison in [false, true] { + for dirty in [false, true] { + let mut session = + EditorSession::open(Some(source.clone()), vec![], Some(&config)).unwrap(); + session.editor_mut().set_project_root(directory.0.clone()); + let root = session.editor().pane_id(); + let source_buffer = session.editor().buffer_id(); + session.execute("tab.new"); + session.execute(&format!("edit {}", owner.display())); + let owner_buffer = session.editor().buffer_id(); + let owner_pane = session.editor().pane_id(); + let owner_tab = session.editor().tab_id(); + if dirty { + session.handle(Input::Char('i')); + session.handle(Input::Paste("DIRTY ".into())); + session.handle(Input::Escape); + } + let text = session.editor().lines().to_vec(); + if comparison { + install_readonly_diff( + session.editor_mut(), + syntax_diff_file("history.txt", "OLD", "NEW"), + ); + } + let comparison_id = session.editor().diff().and_then(|diff| diff.comparison_id); + session.handle(Input::Ctrl('o')); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 20)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let popup = views.panels.files.unwrap().0; + assert_eq!( + session.editor().file_picker().unwrap().presentation, + FilePresentation::Floating + ); + assert!(popup.contains(terminal.get_cursor_position().unwrap())); + session.handle(Input::Ctrl('x')); + if dirty { + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(session.editor().tab_count(), 2); + assert_eq!(session.editor().tab_id(), owner_tab); + assert_eq!(session.editor().pane_id(), owner_pane); + assert!(!session.editor().should_quit()); + assert!(session.editor().message().contains("Unsaved changes")); + assert!(session.editor().file_picker().unwrap().focused); + assert_eq!(views.panels.files.unwrap().0, popup); + assert!(popup.contains(terminal.get_cursor_position().unwrap())); + assert_eq!( + session + .editor() + .documents() + .find(|document| document.id == owner_buffer) + .unwrap() + .lines, + text + ); + session.handle(Input::Escape); + assert!(session.editor().file_picker().unwrap().focused); + assert!(!session.editor().file_picker().unwrap().searching); + session.handle(Input::Escape); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(session.editor().file_picker().is_none()); + assert!(views.panels.files.is_none()); + assert_eq!(session.editor().pane_id(), owner_pane); + assert_eq!(session.editor().tab_count(), 2); + assert_eq!( + session.editor().diff().and_then(|diff| diff.comparison_id), + comparison_id + ); + if comparison { + assert!(screen_text(&terminal).contains("Before")); + } else { + assert!(screen_text(&terminal).contains("DIRTY POPUP OWNER")); + } + session.handle(Input::Ctrl('o')); + session.handle(Input::Ctrl('y')); + } + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(!session.editor().should_quit()); + assert_eq!(session.editor().tab_count(), 1); + assert_eq!(session.editor().pane_id(), root); + assert_eq!(session.editor().buffer_id(), source_buffer); + assert!(!session.editor().has_buffer(owner_buffer)); + assert_eq!(session.editor().lines(), ["SURVIVING DOCUMENT"]); + assert!(session.editor().diff().is_none()); + assert!(session.editor().file_picker().is_none()); + assert!(views.panels.files.is_none()); + assert_eq!(views.rects.len(), 1); + assert_eq!(views.rects[0].1.width, 120); + assert!(screen_text(&terminal).contains("SURVIVING DOCUMENT")); + let (caret_area, caret) = views.panels.caret.unwrap(); + assert!(caret_area.contains(caret)); + assert_eq!(terminal.get_cursor_position().unwrap(), caret); + } + } +} + +#[test] +fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor + .apply(vec![crate::editor::Effect::Replace( + vec!["word ".repeat(40); 20].join("\n"), + )]) + .unwrap(); + editor.handle(Input::Char('v')); + editor.handle(Input::Ctrl('a')); + assert!(editor.selection_count() > 1); + let area = Rect::new(3, 2, 90, 26); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(100, 32), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('e')); + assert!(editor.file_picker().unwrap().focused); + for (name, side) in [ + ("left", DockSide::Left), + ("right", DockSide::Right), + ("top", DockSide::Top), + ("bottom", DockSide::Bottom), + ] { + editor.set_file_explorer_side(name).unwrap(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let workspace = Rect::new(area.x, area.y, area.width, area.height - 1); + let (editor_area, panel) = dock_layout(workspace, side, true); + let panel = panel.unwrap(); + assert_eq!(editor_area.area() + panel.area(), workspace.area()); + assert_eq!(editor_area.intersection(panel).area(), 0); + match side { + DockSide::Left => assert_eq!(panel.right(), editor_area.x), + DockSide::Right => assert_eq!(editor_area.right(), panel.x), + DockSide::Top => assert_eq!(panel.bottom(), editor_area.y), + DockSide::Bottom => assert_eq!(editor_area.bottom(), panel.y), + } + assert!(panel.contains(terminal.get_cursor_position().unwrap())); + editor.handle(Input::Escape); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert!(editor_area.contains(terminal.get_cursor_position().unwrap())); + let buffer = terminal.backend().buffer(); + for y in panel.y..panel.bottom() { + for x in panel.x..panel.right() { + assert!( + !matches!( + buffer[(x, y)].bg, + Color::Blue | Color::Magenta | Color::Yellow + ), + "{side:?}: {x},{y}" + ); + } + } + assert_eq!(buffer[(editor_area.x + 3, editor_area.y)].symbol(), "w"); + assert!(matches!( + buffer[(editor_area.x + 3, editor_area.y)].bg, + Color::Blue | Color::Magenta + )); + for y in editor_area.y..editor_area.bottom() { + for x in editor_area.x..editor_area.x + 3 { + assert_eq!(buffer[(x, y)].bg, Color::Reset); + } + } + assert_eq!(buffer[(0, 0)].symbol(), " "); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('e')); + assert!(editor.file_picker().unwrap().focused); + } +} + +#[test] +fn floating_picker_clears_a_centered_overlay_without_moving_editor_regions() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor + .apply(vec![crate::editor::Effect::Replace( + vec!["x".repeat(120); 30].join("\n"), + )]) + .unwrap(); + let area = Rect::new(3, 2, 110, 26); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(120, 32), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let before = (top, left); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('o')); + let mut panels = WheelPanels::default(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut panels, + ) + }) + .unwrap(); + let panel = panels.files.unwrap().0; + assert_eq!(panel.x - area.x, area.right() - panel.right()); + assert!(panel.y >= area.y && panel.bottom() < area.bottom()); + assert_eq!((top, left), before); + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new(panel.x + 3, panel.y + 1) + ); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(panel.x, panel.y)].fg, Color::Cyan); + assert_eq!(buffer[(panel.right() - 2, panel.y + 1)].symbol(), " "); + assert_eq!(buffer[(area.x + 3, area.y)].symbol(), "x"); + assert_eq!(buffer[(0, 0)].symbol(), " "); +} + +#[test] +fn startup_diagnostic_scrolls_long_unicode_reasons_and_dismisses_without_editing() { + let directory = Directory::new(); + let source = directory.file("buffer.txt", "keep"); + let config = directory.file("config.rb", "raise \"#{'x' * 160}界END\\nlast reason\""); + let mut session = + EditorSession::open(Some(source), vec!["text".into()], Some(&config)).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(110, 26)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("Configuration error")); + assert!(text.contains("config.rb")); + assert!(text.contains("default settings and bindings")); + assert!(text.contains("last reason")); + assert!(!text.contains("界END")); + for _ in 0..100 { + session.handle(Input::Right); + } + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + let scrolled = screen_text(&terminal); + assert!( + scrolled.contains("界") && scrolled.contains("END"), + "{scrolled}" + ); + for (width, height) in [(4, 4), (1, 1)] { + let mut tiny = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + tiny.draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + } + session.handle(Input::Enter); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(!screen_text(&terminal).contains("Configuration error")); + assert_eq!(session.editor().lines(), &["keep"]); + assert_eq!(session.editor().cursor(), crate::editor::Cursor::default()); +} + +#[test] +fn picker_conversion_focus_palette_and_closure_preserve_buffer_viewport_cache() { + let directory = Directory::new(); + let a = directory.file("a", &vec!["a".repeat(80); 30].join("\n")); + let b = directory.file("b", "other"); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + let a_id = editor.buffer_id(); + for _ in 0..20 { + editor.handle(Input::Down); + } + editor.handle(Input::End); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 10)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let before = viewports.offsets[&a_id]; + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('o')); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(viewports.offsets[&a_id], before); + assert!(screen_text(&terminal).contains("Files")); + editor.handle(Input::Escape); + assert!(editor.file_picker().unwrap().focused); + editor.handle(Input::Escape); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(viewports.offsets[&a_id], before); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('e')); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert!(screen_text(&terminal).contains("Explorer")); + editor.handle(Input::Escape); + let docked = editor.file_picker().unwrap(); + assert!(!docked.focused); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let dock_offsets = viewports.offsets[&a_id]; + editor.handle(Input::Ctrl('p')); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert!(screen_text(&terminal).contains("Commands")); + assert_eq!(terminal.get_cursor_position().unwrap().y, 1); + assert_eq!(viewports.offsets[&a_id], dock_offsets); + editor.handle(Input::Escape); + editor.execute(&format!("edit {}", b.display())); + let b_id = editor.buffer_id(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert!(editor.file_picker().is_some()); + assert_eq!(viewports.offsets[&b_id], (0, 0)); + editor.execute(&format!("buffer {a_id}")); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(viewports.offsets[&a_id], dock_offsets); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('o')); + assert_eq!( + editor.file_picker().unwrap().presentation, + FilePresentation::Floating + ); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert!(screen_text(&terminal).contains("Files")); + editor.handle(Input::Ctrl('w')); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert!(editor.file_picker().is_none()); + assert_eq!(viewports.offsets[&b_id], (0, 0)); + assert!(screen_text(&terminal).contains("a")); +} + +#[test] +fn docked_search_highlight_and_cursor_survive_focus_and_offset_resizes() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace( + "0123456789界e\u{301}z\nsecond".into(), + )]) + .unwrap(); + editor.handle(Input::Char('/')); + editor.handle(Input::Char('界')); + editor.handle(Input::Enter); + assert!(editor.search_match().is_some()); + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('e')); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(100, 32), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(Rect::new(3, 2, 90, 26)), + }, + ) + .unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + for (name, side) in [ + ("left", DockSide::Left), + ("right", DockSide::Right), + ("top", DockSide::Top), + ("bottom", DockSide::Bottom), + ] { + editor.set_file_explorer_side(name).unwrap(); + for (width, height) in [ + (90, 26), + (12, 6), + (8, 2), + (1, 1), + (1, 2), + (0, 1), + (1, 0), + (90, 26), + ] { + let area = Rect::new(3, 2, width, height); + terminal.resize(area).unwrap(); + let area = terminal.get_frame().area(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + if width > 0 && height > 0 { + let workspace = Rect::new(area.x, area.y, width, height - u16::from(height >= 2)); + let (_, panel) = dock_layout(workspace, side, true); + let cursor = terminal.get_cursor_position().unwrap(); + assert!( + panel.unwrap().contains(cursor), + "{side:?} {area:?} {panel:?}: {cursor:?}" + ); + } + editor.handle(Input::Escape); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + if width > 0 && height > 0 { + let cursor = terminal.get_cursor_position().unwrap(); + assert!(area.contains(cursor), "{side:?} {area:?}: {cursor:?}"); + let workspace = Rect::new(area.x, area.y, width, height - u16::from(height >= 2)); + let (editor_area, panel) = dock_layout(workspace, side, false); + assert!(editor_area.contains(cursor), "{side:?}: {cursor:?}"); + let buffer = terminal.backend().buffer(); + if width == 90 { + let mut yellow = 0; + for y in editor_area.y..editor_area.bottom() { + for x in editor_area.x..editor_area.right() { + yellow += usize::from(buffer[(x, y)].bg == Color::Yellow); + } + } + assert!(yellow > 0); + } + if let Some(panel) = panel { + for y in panel.y..panel.bottom() { + for x in panel.x..panel.right() { + assert_ne!(buffer[(x, y)].bg, Color::Yellow); + } + } + } + assert_eq!(buffer[(0, 0)].symbol(), " "); + } + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('e')); + assert!(editor.file_picker().unwrap().focused); + } + } +} + +#[test] +fn palette_advertises_effective_leader_sequences_after_remapping() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_leader(",").unwrap(); + editor.handle(Input::Ctrl('p')); + for ch in "bnext".chars() { + editor.handle(Input::Char(ch)); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert!(screen_text(&terminal).contains(", b n")); + assert!(!screen_text(&terminal).contains("Space b n")); + editor + .bind("normal", ",", Some("core.command".into())) + .unwrap(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert!(screen_text(&terminal).contains("unbound")); + assert!(!screen_text(&terminal).contains(", b n")); +} + +fn document_wheel(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + } +} + +#[test] +fn horizontal_wheel_preserves_selection_bounds_display_cells_and_survives_redraw() { + let directory = Directory::new(); + let text = format!("abc\t界e\u{301}{}", "xyz".repeat(20)); + let path = directory.file("wide.txt", &text); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + session + .editor_mut() + .set_gutter_style(crate::gutter::GutterStyle::None); + session.handle(Input::Char('v')); + session.handle(Input::Char('l')); + let cursor = session.editor().cursor(); + let selections = session.editor().selections(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollRight, 0, 0) + )); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(3, 0)].symbol(), "界"); + assert_eq!(terminal.backend().buffer()[(5, 0)].symbol(), "e\u{301}"); + let mut shift = document_wheel(MouseEventKind::ScrollDown, 0, 0); + shift.modifiers = KeyModifiers::SHIFT; + assert!(viewports.scroll(&mut session, shift)); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(viewports.offsets[&session.editor().buffer_id()], (0, 6)); + for _ in 0..100 { + viewports.scroll(&mut session, shift); + } + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!( + viewports.offsets[&session.editor().buffer_id()].1, + display_column(&text, usize::MAX) + 1 - viewports.document.text_width + ); + assert_eq!(session.editor().cursor(), cursor); + assert_eq!(session.editor().selections(), selections); + assert_eq!(session.editor().lines(), std::slice::from_ref(&text)); + terminal.backend_mut().resize(100, 6); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(viewports.offsets[&session.editor().buffer_id()], (0, 0)); + assert!(screen_text(&terminal).contains("abc")); + session.handle(Input::Escape); + session.handle(Input::Char('l')); + terminal + .draw(|frame| viewports.draw_session(frame, &mut session)) + .unwrap(); + assert!(terminal.backend().cursor_visible()); +} + +#[test] +fn wheel_scrolls_rendered_document_three_rows_without_changing_any_selection() { + let directory = Directory::new(); + let path = directory.file( + "rows.txt", + &(0..12) + .map(|row| format!("abc row-{row:02}")) + .collect::>() + .join("\n"), + ); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + session.handle(Input::Char('v')); + session.handle(Input::Char('e')); + session.handle(Input::Ctrl('a')); + assert_eq!(session.editor().selection_count(), 12); + let carets = session.editor().carets(); + let selections = session.editor().selections(); + let cursor = session.editor().cursor(); + let revision = session.editor().text_revision(); + let text = session.editor().lines().to_vec(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(terminal.backend().cursor_visible()); + let height = usize::from(viewports.document.geometry.unwrap().1.height); + let limit = session.editor().lines().len().saturating_sub(height); + let mut top = 0usize; + for kind in [ + MouseEventKind::ScrollDown, + MouseEventKind::ScrollDown, + MouseEventKind::ScrollDown, + MouseEventKind::ScrollDown, + MouseEventKind::ScrollUp, + MouseEventKind::ScrollUp, + MouseEventKind::ScrollUp, + MouseEventKind::ScrollUp, + ] { + top = if kind == MouseEventKind::ScrollDown { + top.saturating_add(3).min(limit) + } else { + top.saturating_sub(3) + }; + // The gutter belongs to the rendered document hit region too. + assert!(viewports.scroll(&mut session, document_wheel(kind, 0, 0))); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, top); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains(&format!("row-{top:02}")) + ); + assert_eq!( + terminal.backend().cursor_visible(), + cursor.row >= top && cursor.row < top + height + ); + assert_eq!(session.editor().cursor(), cursor); + assert_eq!(session.editor().carets(), carets); + assert_eq!(session.editor().selections(), selections); + assert_eq!(session.editor().text_revision(), revision); + assert_eq!(session.editor().lines(), text); + } + assert!(!viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 0, 5) + )); + assert!(!viewports.scroll(&mut session, document_wheel(MouseEventKind::Moved, 0, 0))); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 0, 0) + )); + session.handle(Input::Escape); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("row-00") + ); + assert!(terminal.backend().cursor_visible()); +} + +#[test] +fn queued_scrolls_coalesce_but_preserve_reversals_and_motion_barriers() { + let directory = Directory::new(); + let text = (0..100) + .map(|row| format!("row-{row}\n")) + .collect::(); + for (first, repeated, barrier) in [ + ( + Input::Ctrl('u'), + 'u', + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), + ), + ( + Input::Ctrl('d'), + 'd', + KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), + ), + ( + Input::Down, + 'j', + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), + ), + ] { + let mut session = + EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap(); + let mut views = BufferViewports::default(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let modifiers = if repeated == 'j' { + KeyModifiers::NONE + } else { + KeyModifiers::CONTROL + }; + let mut events = std::iter::repeat_n( + Event::Key(KeyEvent::new(KeyCode::Char(repeated), modifiers)), + 7, + ) + .chain([Event::Key(barrier)]); + assert_eq!( + keys_ready(&mut session, first, || Ok(events.next())).unwrap(), + Some(Event::Key(barrier)) + ); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + if repeated == 'u' { + assert_eq!(views.offsets[&session.editor().buffer_id()].0, 0); + session.handle(Input::Ctrl('d')); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!( + views.offsets[&session.editor().buffer_id()].0, + usize::from(views.document.geometry.unwrap().1.height) / 2 + ); + } else if repeated == 'd' { + assert_eq!(views.offsets[&session.editor().buffer_id()].0, 40); + session.handle(Input::Home); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(views.offsets[&session.editor().buffer_id()].0, 0); + } else { + assert_eq!(session.editor().cursor().row, 8); + } + } +} + +#[test] +fn queued_navigation_preserves_counted_motions_visual_ranges_and_remaps() { + let directory = Directory::new(); + let mut rows = (0..100) + .map(|row| format!("row-{row:03}")) + .collect::>(); + rows[1].clear(); + let text = rows.join("\n"); + let path = directory.file("rows.txt", &text); + for mode in [Mode::Normal, Mode::Visual] { + let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); + session + .editor_mut() + .bind("normal", "z", Some("core.down".into())) + .unwrap(); + session + .editor_mut() + .bind("visual", "z", Some("core.down".into())) + .unwrap(); + session.handle(Input::End); + if mode == Mode::Visual { + session.handle(Input::Char('v')); + } + session.handle(Input::Char('3')); + let key = |code| Event::Key(KeyEvent::new(code, KeyModifiers::NONE)); + let mut events = std::iter::once(key(KeyCode::Right)) + .chain(std::iter::repeat_n(key(KeyCode::Down), 18)) + .chain([ + key(KeyCode::Up), + Event::Key(KeyEvent::new_with_kind( + KeyCode::Char('z'), + KeyModifiers::NONE, + KeyEventKind::Repeat, + )), + key(KeyCode::Char('i')), + key(KeyCode::Char('x')), + ]); + let pending = keys_ready(&mut session, Input::Down, || Ok(events.next())).unwrap(); + assert_eq!(session.editor().cursor(), Cursor { row: 21, column: 1 }); + assert_eq!(session.editor().mode(), mode); + assert_eq!(session.editor().lines(), rows); + if mode == Mode::Visual { + assert_eq!( + session.editor().selections(), + vec![Selection { + start: Cursor { row: 0, column: 6 }, + end: Cursor { row: 21, column: 2 }, + }] + ); + } + assert_eq!(pending, Some(key(KeyCode::Char('i')))); + assert_eq!(events.next(), Some(key(KeyCode::Char('x')))); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 8)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(screen_text(&terminal).contains("row-021")); + assert!(terminal.backend().cursor_visible()); + } +} + +#[test] +fn queued_navigation_stops_at_event_binding_and_batch_boundaries() { + let directory = Directory::new(); + let text = (0..100) + .map(|row| format!("row-{row:03}\n")) + .collect::(); + let path = directory.file("rows.txt", &text); + let down = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + for barrier in [ + Event::Resize(40, 12), + Event::Paste("literal".into()), + Event::Mouse(document_wheel(MouseEventKind::ScrollDown, 1, 1)), + Event::Key(KeyEvent::new_with_kind( + KeyCode::Down, + KeyModifiers::NONE, + KeyEventKind::Release, + )), + Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)), + Event::Key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)), + Event::Key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)), + ] { + let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); + session + .editor_mut() + .bind("normal", "j", Some("core.insert".into())) + .unwrap(); + session + .editor_mut() + .bind("normal", "z", Some("core.pane.split_horizontal".into())) + .unwrap(); + let mut events = [barrier.clone(), down.clone()].into_iter(); + let pending = keys_ready(&mut session, Input::Down, || Ok(events.next())).unwrap(); + assert_eq!(pending, Some(barrier)); + assert_eq!(events.next(), Some(down.clone())); + assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 }); + assert_eq!(session.editor().mode(), Mode::Normal); + assert_eq!(session.editor().pane_layout().len(), 1); + assert_eq!(session.editor().lines().join("\n"), text); + } + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + let mut events = std::iter::repeat_n(down, 80); + assert_eq!( + keys_ready(&mut session, Input::Down, || Ok(events.next())).unwrap(), + None + ); + assert_eq!(session.editor().cursor(), Cursor { row: 64, column: 0 }); + assert_eq!(events.count(), 17); +} + +#[test] +fn queued_typing_preserves_graphemes_history_and_mode_barriers() { + let directory = Directory::new(); + let path = directory.file("input.txt", "seed"); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + session.handle(Input::Char('i')); + let mut events = [ + Event::Key(KeyEvent::new(KeyCode::Char('界'), KeyModifiers::NONE)), + Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + ] + .into_iter(); + let pending = keys_ready(&mut session, Input::Char('a'), || Ok(events.next())).unwrap(); + assert_eq!(session.editor().lines(), ["a界seed"]); + assert_eq!(session.editor().mode(), Mode::Insert); + assert!(matches!(pending, Some(Event::Key(key)) if key.code == KeyCode::Esc)); + session.handle(Input::Escape); + let Some(Event::Key(key)) = events.next() else { + panic!("remaining command") + }; + session.handle(translate(key).unwrap()); + assert_eq!(session.editor().lines(), ["aseed"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), ["a界seed"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), ["seed"]); +} + +#[test] +fn queued_wheel_input_renders_latest_view_without_crossing_key_or_resize_barriers() { + let directory = Directory::new(); + let text = (0..24) + .map(|row| format!("row-{row:03}\n")) + .collect::(); + let path = directory.file("rows.txt", &text); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + let down = Event::Mouse(document_wheel(MouseEventKind::ScrollDown, 1, 1)); + let up = Event::Mouse(document_wheel(MouseEventKind::ScrollUp, 1, 1)); + for barrier in [ + Event::Key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)), + Event::Resize(40, 12), + ] { + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + let mut queued = [ + down.clone(), + up.clone(), + down.clone(), + barrier.clone(), + down.clone(), + ] + .into_iter(); + let (redraw, pending) = viewports + .scroll_ready( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 1, 1), + || Ok(queued.next()), + ) + .unwrap(); + assert!(redraw); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("row-006") + ); + assert_eq!(pending, Some(barrier)); + assert_eq!(queued.next(), Some(down.clone())); + assert_eq!(session.editor().cursor(), Cursor::default()); + assert_eq!(session.editor().mode(), Mode::Normal); + assert!(!terminal.backend().cursor_visible()); + session.handle(Input::Up); + } + + assert_eq!(session.editor().lines().join("\n"), text); +} + +#[test] +fn no_op_navigation_restores_caret_following_through_core_dispatch() { + let directory = Directory::new(); + let path = directory.file("rows.txt", &["row"; 12].join("\n")); + let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + for motion in [Input::Char('k'), Input::Up, Input::Home] { + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 2, 0) + )); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(!terminal.backend().cursor_visible()); + session.handle(motion); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert_eq!(session.editor().cursor(), Cursor::default()); + assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0); + assert!(terminal.backend().cursor_visible()); + } + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + // A remapped motion and a disabled default demonstrate that intent is + // emitted by the resolved core action, not frontend key spelling. + editor.bind("normal", "x", Some("core.up".into())).unwrap(); + editor.bind("normal", "k", None).unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + for navigation in 0..3 { + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let buffer_id = editor.buffer_id(); + viewports.offsets.get_mut(&buffer_id).unwrap().0 = 3; + viewports.document.manual = Some(ManualViewport { + buffer_id, + revision: editor.text_revision(), + caret_reveal_revision: editor.caret_reveal_revision(), + mode: editor.mode(), + cursor: editor.cursor(), + carets: editor.carets(), + selections: editor.selections(), + }); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert!(!terminal.backend().cursor_visible()); + editor.handle(Input::Char('k')); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert!(!terminal.backend().cursor_visible()); + match navigation { + 0 => { + editor.handle(Input::Char('x')); + } + 1 => editor + .navigate_to( + path.clone(), + crate::plugin::TextPosition { row: 0, column: 0 }, + ) + .unwrap(), + 2 => { + editor.execute(&format!("buffer {buffer_id}")); + } + _ => unreachable!(), + } + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(editor.cursor(), Cursor::default()); + assert_eq!(viewports.offsets[&buffer_id].0, 0); + assert!(terminal.backend().cursor_visible()); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("row") + ); + } +} + +#[test] +fn manual_document_view_survives_idle_resize_and_palette_then_follows_navigation_and_edits() { + let directory = Directory::new(); + let path = directory.file( + "rows.txt", + &(0..20) + .map(|row| format!("row-{row:02}")) + .collect::>() + .join("\n"), + ); + let other = directory.file("other.txt", "other"); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + session + .editor_mut() + .set_gutter_style(crate::gutter::GutterStyle::None); + let buffer_id = session.editor().buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + for _ in 0..2 { + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 4, 0) + )); + } + session.poll(); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("row-06") + ); + assert!(!terminal.backend().cursor_visible()); + terminal.backend_mut().resize(30, 8); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("row-06") + ); + session.handle(Input::Ctrl('p')); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 4, 0) + )); + session.handle(Input::Escape); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("row-06") + ); + assert_eq!(session.editor().cursor(), Cursor::default()); + session.handle(Input::Down); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("row-01") + ); + assert!(terminal.backend().cursor_visible()); + + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 4, 0) + )); + session.handle(Input::Char('i')); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(terminal.backend().cursor_visible()); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 4, 0) + )); + session.handle(Input::Char('z')); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(terminal.backend().cursor_visible()); + session.handle(Input::Escape); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 4, 0) + )); + session.execute(&format!("edit {}", other.display())); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(screen_text(&terminal).starts_with("1 other")); + session.execute(&format!("buffer {buffer_id}")); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(terminal.backend().cursor_visible()); + assert!( + screen_text(&terminal) + .lines() + .next() + .unwrap() + .contains("zrow-01") + ); +} + +#[test] +fn manual_document_view_hides_horizontal_offscreen_caret_and_clamps_tiny_viewports() { + let directory = Directory::new(); + let path = directory.file("long.txt", "abcdefghijk"); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + session + .editor_mut() + .set_gutter_style(crate::gutter::GutterStyle::None); + for _ in 0..8 { + session.handle(Input::Right); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 2, 0) + )); + terminal.backend_mut().resize(8, 6); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(!terminal.backend().cursor_visible()); + assert!(screen_text(&terminal).starts_with("1 abcdef")); + assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 8 }); + session.handle(Input::Left); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(terminal.backend().cursor_visible()); + assert_eq!(terminal.backend().cursor_position().x, 7); + for (width, height) in [(1, 1), (2, 2), (0, 0)] { + terminal.backend_mut().resize(width, height); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert_eq!( + viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 0, 0) + ), + width > 0 && height > 0, + ); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0); + } +} + +#[test] +fn wrapped_document_renders_continuations_selection_and_insertion_caret() { + let directory = Directory::new(); + let source = "abcdefghijklmnop\ntail"; + let path = directory.file("wrapped.txt", source); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor.execute("word_wrap.toggle"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let screen = screen_text(&terminal); + let rows: Vec<_> = screen.lines().collect(); + assert_eq!(rows[0], "1 abcdefgh"); + assert_eq!(rows[1], " ijklmnop"); + assert_eq!(rows[2], " "); + assert_eq!(rows[3], "1 tail "); + editor.handle(Input::Char('v')); + for _ in 0..10 { + editor.handle(Input::Right); + } + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(4, 1)); + assert_eq!(terminal.backend().buffer()[(2, 1)].bg, Color::Blue); + assert_eq!(terminal.backend().buffer()[(3, 1)].bg, Color::Blue); + editor.handle(Input::Escape); + editor.handle(Input::End); + editor.handle(Input::Char('a')); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(2, 2)); + assert_eq!(editor.lines().join("\n"), source); + assert!(!editor.dirty()); +} + +#[test] +fn wrapped_wheel_moves_visual_rows_and_resize_and_toggle_restore_following() { + let directory = Directory::new(); + let source = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let path = directory.file("wrapped.txt", source); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + let id = session.editor().buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + for _ in 0..12 { + session.handle(Input::Right); + } + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + let original_left = viewports.offsets[&id].1; + assert!(original_left > 0); + session.execute("word_wrap.toggle"); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollRight, 4, 0) + )); + assert_eq!(viewports.offsets[&id].1, original_left); + assert!(viewports.document.manual.is_none()); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 4, 0) + )); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(!terminal.backend().cursor_visible()); + assert!(viewports.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 4, 0) + )); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + terminal.backend_mut().resize(14, 6); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert!(viewports.document.manual.is_none()); + assert!(terminal.backend().cursor_visible()); + assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 12 }); + session.execute("word_wrap.toggle"); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + assert_eq!(viewports.offsets[&id].1, original_left); + assert_eq!(session.editor().lines().join("\n"), source); +} + +#[test] +fn wrapped_unicode_uses_original_tab_stops_and_queries_visible_source_rows() { + let directory = Directory::new(); + let source = format!("#a\t界e\u{301} z\n{}", vec!["# unseen"; 100].join("\n")); + let path = directory.file("wrapped.py", &source); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor + .replace_semantic_tokens( + editor.buffer_id(), + editor.text_revision(), + Some(Arc::from([semantic_token( + 0, + 3, + 5, + SemanticTokenKind::Property, + 0, + )])), + ) + .unwrap(); + editor + .replace_diagnostics( + "fixture", + editor.buffer_id(), + editor.text_revision(), + vec![annotation( + (0, 2), + (0, 5), + DiagnosticSeverity::Warning, + "wrapped range", + )], + ) + .unwrap(); + editor.execute("word_wrap.toggle"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(viewports.document.text_width, 4); + let visible = viewports + .document + .visible + .iter() + .map(|&(row, part, _)| (row, part)) + .collect::>(); + assert!(visible.starts_with(&[(0, 0), (0, 1), (0, 2), (1, 0)])); + assert_eq!( + visible.len(), + usize::from(viewports.document.geometry.unwrap().1.height) + ); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(4, 0)].symbol(), "#"); + assert_eq!(buffer[(6, 0)].symbol(), " "); + assert_eq!(buffer[(4, 1)].symbol(), "界"); + assert_eq!(buffer[(6, 1)].symbol(), "e\u{301}"); + for x in [4, 6] { + assert_eq!(buffer[(x, 1)].fg, Color::Blue); + assert!(buffer[(x, 1)].modifier.contains(Modifier::UNDERLINED)); + assert_eq!(buffer[(x, 1)].underline_color, Color::Yellow); + } + assert_eq!(buffer[(4, 2)].fg, Color::DarkGray); + assert!(viewports.syntax[&editor.buffer_id()].line(2).is_empty()); + assert_eq!(buffer[(4, 2)].symbol(), "z"); + assert_eq!(editor.lines().join("\n"), source); + editor.handle(Input::Down); + assert_eq!(editor.cursor().row, 1); +} + +#[test] +fn buffer_switches_restore_independent_vertical_and_horizontal_viewports() { + let directory = Directory::new(); + let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n")); + let b = directory.file("b", &vec!["b".repeat(40); 20].join("\n")); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + let a_id = editor.buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + for _ in 0..14 { + editor.handle(Input::Down); + } + editor.handle(Input::End); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let initial = viewports.offsets[&a_id]; + assert_eq!( + initial.0, + editor.cursor().row + 1 - usize::from(viewports.document.geometry.unwrap().1.height) + ); + assert_eq!( + initial.1, + display_column(&editor.lines()[editor.cursor().row], editor.cursor().column) + 1 + - viewports.document.text_width + ); + for _ in 0..2 { + editor.handle(Input::Up); + } + for _ in 0..3 { + editor.handle(Input::Left); + } + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let a_viewport = viewports.offsets[&a_id]; + let a_cursor = terminal.get_cursor_position().unwrap(); + assert_eq!(a_viewport, initial); + + editor.execute(&format!("edit {}", b.display())); + let b_id = editor.buffer_id(); + assert_ne!(a_id, b_id); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(viewports.offsets[&b_id], (0, 0)); + for _ in 0..8 { + editor.handle(Input::Down); + } + editor.handle(Input::End); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + editor.handle(Input::Up); + editor.handle(Input::Left); + editor.handle(Input::Left); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let b_viewport = viewports.offsets[&b_id]; + let b_cursor = terminal.get_cursor_position().unwrap(); + + editor.execute(&format!("buffer {a_id}")); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(viewports.offsets[&a_id], a_viewport); + assert_eq!(terminal.get_cursor_position().unwrap(), a_cursor); + assert_eq!( + terminal.backend().buffer()[(a_cursor.x, a_cursor.y)].symbol(), + "a" + ); + editor.execute("bnext"); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(editor.buffer_id(), b_id); + assert_eq!(viewports.offsets[&b_id], b_viewport); + assert_eq!(terminal.get_cursor_position().unwrap(), b_cursor); +} + +#[test] +fn closing_inactive_buffer_retires_its_viewport_without_moving_active_buffer() { + let directory = Directory::new(); + let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n")); + let b = directory.file("b", &vec!["b".repeat(40); 20].join("\n")); + let c = directory.file("c", "new buffer"); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + let a_id = editor.buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + for _ in 0..14 { + editor.handle(Input::Down); + } + editor.handle(Input::End); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + editor.execute(&format!("edit {}", b.display())); + let b_id = editor.buffer_id(); + for _ in 0..8 { + editor.handle(Input::Down); + } + editor.handle(Input::End); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let b_viewport = viewports.offsets[&b_id]; + let b_cursor = terminal.get_cursor_position().unwrap(); + assert_eq!(viewports.offsets.len(), 2); + + editor.execute(&format!("bdelete {a_id}")); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(editor.buffer_id(), b_id); + assert!(!editor.has_buffer(a_id)); + assert!(!viewports.offsets.contains_key(&a_id)); + assert_eq!(viewports.offsets.len(), 1); + assert_eq!(viewports.offsets[&b_id], b_viewport); + assert_eq!(terminal.get_cursor_position().unwrap(), b_cursor); + editor.execute(&format!("edit {}", c.display())); + let c_id = editor.buffer_id(); + assert!(c_id > b_id); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(viewports.offsets[&c_id], (0, 0)); + assert!(!viewports.offsets.contains_key(&a_id)); +} + +#[test] +fn restoring_buffer_after_narrow_resize_clamps_caret_inside_text_region() { + let directory = Directory::new(); + let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n")); + let b = directory.file("b", "other"); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + let a_id = editor.buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 8)).unwrap(); + let mut viewports = BufferViewports::default(); + for _ in 0..14 { + editor.handle(Input::Down); + } + editor.handle(Input::End); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert_eq!( + viewports.offsets[&a_id].0, + editor.cursor().row + 1 - usize::from(viewports.document.geometry.unwrap().1.height) + ); + editor.execute(&format!("edit {}", b.display())); + let b_id = editor.buffer_id(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + terminal.backend_mut().resize(4, 4); + terminal + .resize(ratatui::layout::Rect::new(0, 0, 4, 4)) + .unwrap(); + editor.execute(&format!("buffer {a_id}")); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let viewport = viewports.document.geometry.unwrap().1; + assert_eq!( + viewports.offsets[&a_id], + (editor.cursor().row + 1 - usize::from(viewport.height), 49,) + ); + assert_eq!(viewports.offsets[&b_id], (0, 0)); + assert!(viewport.contains(terminal.get_cursor_position().unwrap())); + + for (width, height) in [(1, 1), (24, 8)] { + let area = ratatui::layout::Rect::new(0, 0, width, height); + terminal.backend_mut().resize(width, height); + terminal.resize(area).unwrap(); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert!(area.contains(cursor), "{area:?}: {cursor:?}"); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + "a" + ); + assert_eq!(viewports.offsets[&b_id], (0, 0)); + } +} + +#[test] +fn status_identifies_active_buffer_and_restored_visual_selection_count() { + let directory = Directory::new(); + let a = directory.file("a", "word\nword\nword"); + let b = directory.file("b", "other"); + let mut editor = Editor::open(Some(a), vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + let a_id = editor.buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 6)).unwrap(); + let mut viewports = BufferViewports::default(); + editor.handle(Input::Char('v')); + editor.handle(Input::Ctrl('a')); + assert_eq!(editor.selection_count(), 3); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + assert!(!screen_text(&terminal).contains("[buffer")); + editor.execute(&format!("edit {}", b.display())); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let text = screen_text(&terminal); + assert!(!text.contains("3 selections"), "{text}"); + editor.execute(&format!("buffer {a_id}")); + terminal + .draw(|frame| viewports.draw(frame, &mut editor)) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("3 selections"), "{text}"); + let (top, left) = viewports.offsets[&a_id]; + for selection in editor.selections() { + let x = 2 + (selection.start.column - left) as u16; + let y = (selection.start.row - top) as u16; + assert!(matches!( + terminal.backend().buffer()[(x, y)].bg, + Color::Blue | Color::Magenta + )); + } +} + +#[test] +fn pending_motion_count_is_visible_and_escape_cancels_without_moving() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor + .apply(vec![crate::editor::Effect::Replace(["row"; 12].join("\n"))]) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 6)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + editor.handle(Input::Char('1')); + editor.handle(Input::Char('0')); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert!(screen_text(&terminal).contains("count: 10")); + editor.handle(Input::Escape); + editor.handle(Input::Down); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert!(!screen_text(&terminal).contains("count:")); + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new(3, 1) + ); +} + +#[test] +fn git_gutter_modes_colors_geometry_wrap_scroll_and_tiny_panes() { + use crate::gutter::{Change, GutterStyle}; + let directory = Directory::new(); + let path = directory.file("gutter.txt", "abcdefghijklmnop\nsecond\nthird\nfourth"); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + assert!(editor.set_gutter_changes( + editor.buffer_id(), + editor.text_revision(), + &path, + vec![ + Some(Change::Added), + Some(Change::Removed), + Some(Change::Modified), + None + ], + )); + for (mode, marker) in [ + (GutterStyle::Bar, "▌"), + (GutterStyle::Symbols, "+"), + (GutterStyle::LineNumbers, ""), + (GutterStyle::None, ""), + ] { + editor.set_gutter_style(mode); + let mut views = BufferViewports::default(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let viewport = views.panels.caret.unwrap().0; + let markers = matches!(mode, GutterStyle::Bar | GutterStyle::Symbols); + assert_eq!(viewport.x, if markers { 4 } else { 2 }); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(viewport.x - 1, viewport.y)].symbol(), " "); + if markers { + assert_eq!(buffer[(1, viewport.y)].symbol(), " "); + assert_eq!(buffer[(2, viewport.y)].symbol(), marker); + for (offset, color) in [Color::Green, Color::Red, Color::Yellow] + .into_iter() + .enumerate() + { + assert_eq!(buffer[(1, viewport.y + offset as u16)].symbol(), " "); + assert_eq!(buffer[(2, viewport.y + offset as u16)].fg, color); + } + if mode == GutterStyle::Symbols { + assert_eq!(buffer[(2, viewport.y + 1)].symbol(), "-"); + assert_eq!(buffer[(2, viewport.y + 2)].symbol(), "~"); + } + } else if mode == GutterStyle::LineNumbers { + assert_eq!(buffer[(0, viewport.y)].fg, Color::Cyan); + assert_eq!(buffer[(0, viewport.y + 1)].fg, Color::Red); + assert_eq!(buffer[(0, viewport.y + 2)].fg, Color::Yellow); + } + assert_eq!(buffer[(viewport.x, viewport.y)].symbol(), "a"); + } + editor + .replace_diagnostics( + "fixture", + editor.buffer_id(), + editor.text_revision(), + vec![ + annotation((0, 0), (0, 1), DiagnosticSeverity::Error, "current"), + annotation((1, 0), (1, 1), DiagnosticSeverity::Warning, "warning"), + annotation((2, 0), (2, 1), DiagnosticSeverity::Error, "error"), + ], + ) + .unwrap(); + editor.set_gutter_style(GutterStyle::LineNumbers); + let mut views = BufferViewports::default(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let viewport = views.panels.caret.unwrap().0; + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(0, viewport.y)].fg, Color::Cyan); + assert!(buffer[(0, viewport.y)].modifier.contains(Modifier::BOLD)); + assert_eq!(buffer[(0, viewport.y + 1)].fg, Color::Yellow); + assert_eq!(buffer[(0, viewport.y + 2)].fg, Color::Red); + assert_eq!(buffer[(0, viewport.y + 3)].fg, Color::DarkGray); + editor + .replace_diagnostics( + "fixture", + editor.buffer_id(), + editor.text_revision(), + vec![], + ) + .unwrap(); + editor.set_gutter_style(GutterStyle::Symbols); + editor.handle(Input::End); + let mut scrolled_views = BufferViewports::default(); + let mut scrolled_terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 4)).unwrap(); + scrolled_terminal + .draw(|frame| scrolled_views.draw(frame, &mut editor)) + .unwrap(); + assert!(scrolled_views.offsets[&editor.buffer_id()].1 > 0); + let text_area = scrolled_views.panels.caret.unwrap().0; + assert_eq!( + scrolled_terminal.backend().buffer()[(2, text_area.y)].symbol(), + "+" + ); + assert_eq!(text_area.x, 4); + editor.handle(Input::Home); + editor.execute("word_wrap.toggle"); + let mut views = BufferViewports::default(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 8)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let viewport = views.panels.caret.unwrap().0; + for (screen, &(_, continuation, _)) in views.document.visible.iter().enumerate() { + if continuation > 0 { + for x in 0..viewport.x { + assert_eq!( + terminal.backend().buffer()[(x, viewport.y + screen as u16)].symbol(), + " " + ); + } + } + } + editor.execute("word_wrap.toggle"); + editor.handle(Input::Down); + editor.handle(Input::Down); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 3)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let viewport = views.panels.caret.unwrap().0; + for (screen, &(row, _, _)) in views.document.visible.iter().enumerate() { + let expected = match row { + 0 => "+", + 1 => "-", + 2 => "~", + _ => " ", + }; + assert_eq!( + terminal.backend().buffer()[(2, viewport.y + screen as u16)].symbol(), + expected + ); + } + for width in [1, 2, 3, 4] { + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 3)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(views.panels.caret.unwrap().0.x, 0); + } + editor.execute("pane.split_vertical"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 8)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(screen_text(&terminal).contains('~')); +} + +#[test] +fn gutter_tracks_scrolled_rows_without_scrolling_with_text() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor + .apply(vec![crate::editor::Effect::Replace( + "0123456789界z\n".repeat(11), + )]) + .unwrap(); + for _ in 0..10 { + editor.handle(Input::Down); + } + editor.handle(Input::End); + let area = ratatui::layout::Rect::new(3, 2, 8, 5); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(20, 10), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + let mut views = BufferViewports::default(); + for input in [ + None, + Some(Input::Down), + Some(Input::Up), + Some(Input::Up), + Some(Input::Ctrl('e')), + ] { + if let Some(input) = input { + editor.handle(input); + } + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let viewport = views.panels.caret.unwrap().0; + assert_eq!(viewport.height, area.height - 1); + assert_eq!(viewport.x, area.x + 3); + let (top, left) = views.offsets[&editor.buffer_id()]; + assert_eq!(views.document.visible[0].0, top); + let buffer = terminal.backend().buffer(); + for (screen_row, &(row, _, _)) in views.document.visible.iter().enumerate() { + let y = viewport.y + screen_row as u16; + let number: String = (area.x..viewport.x) + .map(|x| buffer[(x, y)].symbol()) + .collect(); + let expected = if row == editor.cursor().row { + row + 1 + } else { + row.abs_diff(editor.cursor().row) + }; + assert_eq!(number, format!("{expected:>2} ")); + assert_eq!(buffer[(area.x, y)].bg, Color::Reset); + assert_eq!( + buffer[(area.x, y)].modifier.contains(Modifier::BOLD), + row == editor.cursor().row + ); + } + let cursor = terminal.get_cursor_position().unwrap(); + assert!(viewport.contains(cursor)); + assert_eq!( + cursor.x, + viewport.x + + (display_column(&editor.lines()[editor.cursor().row], editor.cursor().column) + - left) as u16 + ); + assert_eq!(cursor.y, viewport.y + (editor.cursor().row - top) as u16); + } + assert_eq!(editor.selection_count(), 2); +} + +#[test] +fn gutter_resizes_at_digit_boundaries_and_yields_to_narrow_text() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + for count in [9usize, 10, 100] { + editor + .apply(vec![crate::editor::Effect::Replace( + vec!["x"; count].join("\n"), + )]) + .unwrap(); + let digits = count.ilog10() as u16 + 1; + let gutter_width = digits + 1; + for width in [12, gutter_width, gutter_width + 1, 1] { + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 4)).unwrap(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + let buffer = terminal.backend().buffer(); + let text_x = if width > gutter_width { + gutter_width + } else { + 0 + }; + assert_eq!(buffer[(text_x, 0)].symbol(), "x"); + assert_eq!(cursor, ratatui::layout::Position::new(text_x, 0)); + if text_x > 0 { + let number: String = (0..text_x).map(|x| buffer[(x, 0)].symbol()).collect(); + assert_eq!( + number, + format!("{:>digits$} ", 1, digits = usize::from(digits)) + ); + } + } + } + editor + .apply(vec![crate::editor::Effect::Replace(String::new())]) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 5)).unwrap(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(0, 0)].symbol(), "1"); + assert_eq!(buffer[(0, 1)].symbol(), " "); + assert_eq!(buffer[(0, 2)].symbol(), " "); + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new(2, 0) + ); +} + +#[test] +fn palette_renders_query_no_matches_and_restores_underlying_prompt() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.handle(Input::Char('/')); + for ch in "saved search".chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Ctrl('p')); + for ch in "no-such-command".chars() { + editor.handle(Input::Char(ch)); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("> no-such-command"), "{text}"); + assert!(text.contains("No matching commands"), "{text}"); + assert!(text.contains("SEARCH"), "{text}"); + assert_eq!(terminal.get_cursor_position().unwrap().y, 1); + editor.handle(Input::Enter); + assert!(editor.palette().is_some()); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Search); + assert_eq!(editor.command_line(), "saved search"); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); +} + +#[test] +fn palette_window_follows_keyboard_selection() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.handle(Input::Ctrl('p')); + for _ in 0..20 { + editor.handle(Input::Ctrl('j')); + } + editor.handle(Input::Ctrl('k')); + editor.handle(Input::Ctrl('n')); + let palette = editor.palette().unwrap(); + assert_eq!(palette.selected, 20); + let selected = &palette.entries[palette.selected]; + let first_name = palette.entries[0].name.clone(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains(&selected.name), "{text}"); + assert!(!text.contains(&format!("({first_name})")), "{text}"); + let buffer = terminal.backend().buffer(); + let selected_y = (2..10).find(|&y| buffer[(1, y)].symbol() == ">").unwrap(); + assert_eq!(buffer[(1, selected_y)].bg, Color::Blue); + assert!(buffer[(1, selected_y)].modifier.contains(Modifier::BOLD)); + assert_eq!(terminal.get_cursor_position().unwrap().y, 1); +} + +#[test] +fn palette_shows_effective_bindings_and_dimmed_mode_ineligible_commands() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .register(crate::editor::Feature { + name: "sample".into(), + required_capabilities: vec![], + commands: vec!["greet".into()], + core: false, + }) + .unwrap(); + editor.bind("normal", "g", Some("greet".into())).unwrap(); + editor.bind("normal", "g", None).unwrap(); + editor + .bind("normal", "ctrl+x", Some("greet".into())) + .unwrap(); + editor.handle(Input::Ctrl('p')); + for ch in "greet".chars() { + editor.handle(Input::Char(ch)); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("sample | Ctrl+x | NORMAL"), "{text}"); + assert!(!text.contains("unbound"), "{text}"); + editor.handle(Input::Escape); + editor.handle(Input::Char('i')); + editor.handle(Input::Ctrl('p')); + for ch in "greet".chars() { + editor.handle(Input::Char(ch)); + } + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("sample | unbound | NORMAL"), "{text}"); + assert!(text.contains("INSERT"), "{text}"); + let cell = &terminal.backend().buffer()[(1, 2)]; + assert!(cell.modifier.contains(Modifier::DIM)); + assert_eq!(cell.bg, Color::Blue); + editor.handle(Input::Enter); + assert!(editor.palette().is_some()); + assert_eq!(editor.mode(), Mode::Insert); +} + +#[test] +fn buffer_picker_keeps_selected_status_and_paths_visible_after_resize_and_filter() { + let mut editor = Editor::open(None, vec![]).unwrap(); + for id in 2..=12 { + editor.execute(&format!("edit picker-{id}.txt")); + } + editor + .apply(vec![crate::editor::Effect::Replace("unsaved".into())]) + .unwrap(); + editor.execute("buffers"); + editor.handle(Input::Ctrl('k')); + for (width, height) in [(80, 12), (32, 6), (1, 1), (4, 4), (80, 12)] { + let area = Rect::new(3, 2, width, height); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(90, 30), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + terminal + .draw(|frame| { + draw_palette( + frame, + editor.palette().unwrap(), + area, + editor.mode(), + editor.selection_navigation_hint(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert!(area.contains(terminal.get_cursor_position().unwrap())); + if width >= 32 { + let buffer = terminal.backend().buffer(); + let selected_y = (area.y..area.bottom()) + .find(|&y| { + pane_screen(&terminal, Rect::new(area.x, y, width, 1)).contains("picker-12.txt") + }) + .unwrap(); + assert_eq!(buffer[(area.x + 4, selected_y)].fg, Color::Cyan); + assert_eq!(buffer[(area.x + 8, selected_y)].symbol(), "+"); + assert_eq!(buffer[(area.x + 8, selected_y)].fg, Color::Yellow); + assert_eq!(buffer[(area.x + 10, selected_y)].bg, Color::Blue); + assert_eq!( + buffer[(area.x + 6, selected_y)].fg, + buffer[(area.x + 10, selected_y)].fg, + ); + assert!( + pane_screen(&terminal, Rect::new(area.x, selected_y, width, 1)) + .contains("picker-12.txt") + ); + } + } + editor.handle(Input::Char('2')); + editor.handle(Input::Ctrl('j')); + editor.handle(Input::Enter); + assert_eq!(editor.buffer_id(), 12); + assert_eq!(editor.context().text, "unsaved"); + assert!(editor.dirty()); +} + +#[test] +fn palette_query_scrolls_graphemes_tabs_and_wide_cells_on_tiny_offset_areas() { + let editor = Editor::open(None, vec![]).unwrap(); + let palette = Palette { + query: "long\t界e\u{301}z".into(), + entries: vec![], + selected: 0, + buffers: false, + }; + for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] { + let area = ratatui::layout::Rect::new(3, 2, width, height); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(24, 12), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + terminal + .draw(|frame| { + draw_palette( + frame, + &palette, + area, + Mode::Visual, + editor.selection_navigation_hint(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert!(area.contains(cursor), "{area:?}: {cursor:?}"); + let bordered = height >= 4 && width >= 4; + let query_x = area.x + u16::from(bordered); + let query_y = area.y + u16::from(bordered); + let query_width = width - 2 * u16::from(bordered); + assert_eq!(cursor.x, query_x + query_width - 1); + assert_eq!(cursor.y, query_y); + let query = format!("> {}", palette.query); + let offset = + display_column(&query, usize::MAX).saturating_sub(usize::from(query_width) - 1); + let mut expected = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new( + query_x, + query_y, + query_width, + 1, + )); + use ratatui::widgets::Widget; + Paragraph::new(visible_line(&query, offset, usize::from(query_width))) + .render(expected.area, &mut expected); + for x in query_x..query_x + query_width { + assert_eq!( + terminal.backend().buffer()[(x, query_y)].symbol(), + expected[(x, query_y)].symbol() + ); + } + assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " "); + } + for (width, height) in [(0, 1), (1, 0), (0, 0)] { + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| { + let area = frame.area(); + draw_palette( + frame, + &palette, + area, + Mode::Normal, + editor.selection_navigation_hint(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + } +} + +#[test] +fn opening_palette_does_not_scroll_underlying_editor_viewport() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace( + "first\nsecond\n0123456789界z".into(), + )]) + .unwrap(); + editor.handle(Input::Down); + editor.handle(Input::Down); + editor.handle(Input::End); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 4)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let viewport = (top, left); + editor.handle(Input::Ctrl('p')); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert_eq!((top, left), viewport); + editor.handle(Input::Ctrl('p')); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert_eq!((top, left), viewport); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + "z" + ); +} + +#[test] +fn layout_keeps_scrolled_content_and_prompts_inside_offset_regions() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace( + "first\nsecond\n0123456789界z".into(), + )]) + .unwrap(); + editor.handle(Input::Down); + editor.handle(Input::Down); + editor.handle(Input::End); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + for prompting in [false, true] { + if prompting { + editor.handle(Input::Char('/')); + for ch in "long query".chars() { + editor.handle(Input::Char(ch)); + } + } + for height in [4, 2, 1, 4] { + let area = ratatui::layout::Rect::new(3, 2, 8, height); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(20, 10), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert!(area.contains(cursor), "{area:?}: {cursor:?}"); + let buffer = terminal.backend().buffer(); + if prompting { + assert_eq!(buffer[(cursor.x, cursor.y)].symbol(), " "); + } else { + assert_eq!(buffer[(cursor.x, cursor.y)].symbol(), "z"); + } + assert_eq!(buffer[(0, 0)].symbol(), " "); + } + } +} + +#[test] +fn tabs_and_wide_graphemes_scroll_in_terminal_cells() { + let text = "a\t界e\u{301}z"; + assert_eq!(display_column(text, 4), 7); + assert_eq!(visible_line(text, 2, 5), " 界e\u{301}"); + assert_eq!(visible_line(text, 5, 2), " e\u{301}"); +} + +#[test] +fn selection_set_renders_all_ranges_and_counts_offscreen_targets() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor + .apply(vec![crate::editor::Effect::Replace( + "界\n界\n界\n界\n界".into(), + )]) + .unwrap(); + editor.handle(Input::Home); + editor.handle(Input::Char('v')); + editor.handle(Input::Ctrl('a')); + assert_eq!(editor.selection_count(), 5); + assert_eq!(editor.selections().len(), 5); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 4)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + for selection in editor.selections() { + if selection.start.row >= top && selection.start.row < top + 2 { + let y = (selection.start.row - top) as u16; + assert!(matches!(buffer[(2, y)].bg, Color::Blue | Color::Magenta)); + } + } + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new(2, (editor.cursor().row - top) as u16) + ); +} + +#[test] +fn secondary_carets_render_on_empty_lines_and_at_line_end() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor + .apply(vec![crate::editor::Effect::Replace("a\n\n界".into())]) + .unwrap(); + editor.handle(Input::Home); + editor.handle(Input::Ctrl('e')); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.selection_count(), 3); + editor.handle(Input::Char('A')); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 5)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + for caret in editor.carets() { + let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16; + assert_eq!(buffer[(x, caret.row as u16)].symbol(), " "); + if caret != editor.cursor() { + assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta); + } + } + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new( + 2 + display_column(&editor.lines()[editor.cursor().row], editor.cursor().column) as u16, + editor.cursor().row as u16 + ) + ); +} + +#[test] +fn linewise_selection_highlights_copied_text_and_newlines_without_filling_rows() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor + .apply(vec![crate::editor::Effect::Replace( + "\t界e\u{301} \n\n".into(), + )]) + .unwrap(); + editor.handle(Input::Char('V')); + editor.handle(Input::Down); + editor.handle(Input::Down); + let before = (editor.lines().to_vec(), editor.cursor(), editor.carets()); + for width in [16, 3, 1] { + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 6)).unwrap(); + let (mut top, mut left) = (0, 0); + let frame = terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut SyntaxCache::new(), + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let buffer = frame.buffer; + let gutter = if width > 2 { 2 } else { 0 }; + for row in 0..3 { + for x in 0..gutter { + assert_ne!(buffer[(x, row)].bg, Color::Blue); + } + let selected_width = if row == 0 { 10 } else { 1 }; + for x in gutter..width { + if x - gutter < selected_width { + assert_eq!( + buffer[(x, row)].bg, + Color::Blue, + "width {width}, row {row}, x {x}" + ); + } else { + assert_ne!(buffer[(x, row)].bg, Color::Blue); + } + } + } + for x in 0..width { + assert_ne!(buffer[(x, 3)].bg, Color::Blue); + } + } + assert_eq!( + before, + (editor.lines().to_vec(), editor.cursor(), editor.carets()) + ); + editor.handle(Input::Up); + editor.handle(Input::Up); + editor.handle(Input::End); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(5, 3)).unwrap(); + let (mut top, mut left) = (0, 0); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut SyntaxCache::new(), + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + assert!(left > 0); + let buffer = terminal.backend().buffer(); + for x in 0..2 { + assert_ne!(buffer[(x, 0)].bg, Color::Blue); + } + for x in 2..5 { + assert_eq!(buffer[(x, 0)].bg, Color::Blue); + } +} + +#[test] +fn visual_selection_highlights_graphemes_newlines_and_clipped_ranges_over_search() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor + .apply(vec![crate::editor::Effect::Replace( + "a\t界e\u{301}z\n\nxy".into(), + )]) + .unwrap(); + editor.handle(Input::Char('/')); + for ch in "界e\u{301}".chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Enter); + editor.handle(Input::Char('v')); + editor.handle(Input::Left); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 6)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + for x in [1, 2, 3, 4] { + assert_eq!(buffer[(2 + x, 0)].bg, Color::Blue); + } + assert_eq!(buffer[(6, 0)].symbol(), "界"); + assert_eq!(buffer[(8, 0)].bg, Color::Yellow); + editor.handle(Input::Down); + editor.handle(Input::Down); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(10, 0)].symbol(), " "); + assert_eq!(buffer[(10, 0)].bg, Color::Blue); + assert_eq!(buffer[(2, 1)].bg, Color::Blue); + assert_eq!(buffer[(2, 2)].bg, Color::Blue); + assert_ne!(buffer[(3, 2)].bg, Color::Blue); + + editor.handle(Input::Up); + editor.handle(Input::Up); + editor.handle(Input::End); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(7, 6)).unwrap(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert_eq!(left, 4); + assert_eq!(buffer[(2, 0)].symbol(), "界"); + assert_eq!(buffer[(4, 0)].symbol(), "e\u{301}"); + assert_eq!(buffer[(6, 0)].symbol(), " "); + for x in [0, 2, 3, 4] { + assert_eq!(buffer[(2 + x, 0)].bg, Color::Blue); + } + assert_ne!(buffer[(2, 1)].bg, Color::Blue); + editor.handle(Input::Escape); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(2, 0)].bg, Color::Yellow); + assert_eq!(buffer[(4, 0)].bg, Color::Yellow); + assert_ne!(buffer[(5, 0)].bg, Color::Blue); + assert_ne!(buffer[(6, 0)].bg, Color::Blue); +} + +#[test] +fn search_highlights_all_occurrences_without_moving_on_first_star() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + configure_chrome(&mut editor, |top, bottom| { + for bar in [top, bottom] { + bar.left.clear(); + bar.center.clear(); + bar.right.clear(); + } + }); + editor + .apply(vec![crate::editor::Effect::Replace( + "cat cat_cat cat\ncat".into(), + )]) + .unwrap(); + editor.handle(Input::Char('*')); + assert_eq!(editor.cursor(), Cursor::default()); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 6)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + for (row, start, end) in [(0, 0, 3), (0, 12, 15), (1, 0, 3)] { + for column in start..end { + assert_eq!(buffer[(column + 2, row)].bg, Color::Yellow); + } + } + assert_ne!(buffer[(6, 0)].bg, Color::Yellow); + editor.handle(Input::Char('/')); + for ch in "cat".chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Enter); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ); + }) + .unwrap(); + for column in [0, 4, 8, 12] { + assert_eq!( + terminal.backend().buffer()[(column + 2, 0)].bg, + Color::Yellow + ); + } +} + +#[test] +fn active_search_highlight_uses_display_cells_and_clips_with_horizontal_scroll() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_gutter_style(crate::gutter::GutterStyle::None); + editor + .apply(vec![crate::editor::Effect::Replace( + "a\t界e\u{301}zz".into(), + )]) + .unwrap(); + editor.handle(Input::Char('/')); + for ch in "\t界e\u{301}".chars() { + editor.handle(Input::Char(ch)); + } + editor.handle(Input::Enter); + editor.handle(Input::End); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(7, 5)).unwrap(); + let (mut top, mut left) = (0, 0); + let mut syntax = SyntaxCache::new(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut WheelPanels::default(), + ) + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert_eq!(left, 4); + assert_eq!(buffer[(2, 0)].symbol(), "界"); + assert_eq!(buffer[(4, 0)].symbol(), "e\u{301}"); + for x in [0, 2] { + assert_eq!(buffer[(2 + x, 0)].bg, Color::Yellow); + } + for x in 3..5 { + assert_ne!(buffer[(2 + x, 0)].bg, Color::Yellow); + } + editor.handle(Input::Char('/')); + for ch in "界界".chars() { + editor.handle(Input::Char(ch)); + } + let mut panels = WheelPanels::default(); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut panels, + ) + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + let input = panels.input.unwrap(); + let content = Rect::new(input.x + 2, input.y + 1, input.width - 4, 1); + assert_eq!(buffer[(content.x, content.y)].symbol(), "界"); + editor.handle(Input::Backspace); + editor.handle(Input::Backspace); + editor.handle(Input::Char('\t')); + terminal + .draw(|frame| { + draw( + frame, + &editor, + &mut top, + &mut left, + &mut syntax, + &mut DocumentViewport::default(), + &mut panels, + ) + }) + .unwrap(); + assert!( + panels + .input + .unwrap() + .contains(terminal.get_cursor_position().unwrap()) + ); + assert!(content.contains(terminal.get_cursor_position().unwrap())); +} + +#[test] +fn terminal_can_present_previous_frame_while_next_frame_is_computed() { + use std::{cell::Cell, rc::Rc}; + + struct Output { + bytes: Vec, + synchronized: Rc>, + } + impl io::Write for Output { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + for command in self.bytes.split(|byte| *byte == b'\x1b') { + if command.starts_with(b"[?2026h") { + self.synchronized.set(true); + } else if command.starts_with(b"[?2026l") { + self.synchronized.set(false); + } + } + self.bytes.clear(); + Ok(()) + } + } + let synchronized = Rc::new(Cell::new(false)); + let mut output = Output { + bytes: Vec::new(), + synchronized: Rc::clone(&synchronized), + }; + let mut terminal = Terminal::with_options( + CrosstermBackend::new(Vec::new()), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)), + }, + ) + .unwrap(); + for text in ["first frame", "second frame", "third frame"] { + draw_frame(&mut terminal, &mut output, false, |frame| { + assert!( + !synchronized.get(), + "terminal presentation suspended during frame computation" + ); + frame.render_widget(Paragraph::new(text), frame.area()); + }) + .unwrap(); + assert!(!synchronized.get()); + } +} + +#[test] +fn scrolling_never_paints_with_an_exposed_cursor_even_without_synchronized_updates() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace( + (0..40).map(|row| format!("row {row}\n")).collect(), + )]) + .unwrap(); + let mut output = Vec::new(); + let mut terminal = Terminal::with_options( + CrosstermBackend::new(Vec::new()), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)), + }, + ) + .unwrap(); + let mut viewports = BufferViewports::default(); + for key in std::iter::repeat_n('j', 20).chain(std::iter::repeat_n('k', 20)) { + editor.handle(Input::Char(key)); + draw_frame(&mut terminal, &mut output, false, |frame| { + viewports.draw(frame, &mut editor) + }) + .unwrap(); + } + drop(terminal); + let output = std::str::from_utf8(&output).unwrap(); + for supports_sync in [false, true] { + let (mut visible, mut synchronized) = (true, false); + let mut screen = [[b' '; 20]; 5]; + let (mut x, mut y) = (0, 0); + for escape in output.split('\x1b').skip(1) { + let rest = escape.strip_prefix('[').unwrap(); + let end = rest.find(|ch| ('@'..='~').contains(&ch)).unwrap(); + let (command, text) = rest.split_at(end + 1); + match command { + "?25h" => visible = true, + "?25l" => visible = false, + "?2026h" => synchronized = supports_sync, + "?2026l" => synchronized = false, + _ if command.ends_with('H') => { + let (row, column) = command[..command.len() - 1].split_once(';').unwrap(); + y = row.parse::().unwrap() - 1; + x = column.parse::().unwrap() - 1; + } + _ => {} + } + for byte in text.bytes().filter(|&byte| byte >= b' ' && byte != 127) { + assert!( + if supports_sync { + synchronized + } else { + !visible + }, + "partial frame exposed during redraw (sync={supports_sync}): {text:?}", + ); + if y < screen.len() && x < screen[y].len() { + screen[y][x] = byte; + } + x += 1; + } + } + assert!(visible); + assert!(!synchronized); + for (row, expected) in [(0, "row 0"), (1, "row 1"), (2, "row 2")] { + let line = std::str::from_utf8(&screen[row]).unwrap().trim_end(); + assert!(line.ends_with(expected), "{line:?}"); + } + assert_eq!(screen[y][x], b'r', "caret was not restored to the source"); + } +} + +#[cfg(unix)] +#[test] +fn terminal_session_scopes_the_callers_panic_hook() { + use std::io::Write; + const CHILD: &str = "VAMP_TERMINAL_HOOK_PROBE"; + if let Ok(mode) = std::env::var(CHILD) { + std::panic::set_hook(Box::new(|info| { + assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap()); + println!("CALLER_HOOK"); + eprintln!("{info}"); + })); + if mode == "entry" { + let mut editor = EditorSession::open(None, vec![], None).unwrap(); + assert!(TerminalSession::run(false, || run(&mut editor, false)).is_err()); + } else { + let result = panic::catch_unwind(AssertUnwindSafe(|| { + TerminalSession::run(false, || { + assert!(crossterm::terminal::is_raw_mode_enabled().unwrap()); + match mode.as_str() { + "normal" => Ok(()), + "error" => Err(io::Error::from(io::ErrorKind::BrokenPipe)), + "panic" => { + let mut terminal = Terminal::with_options( + CrosstermBackend::new(Vec::new()), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)), + }, + )?; + draw_frame(&mut terminal, &mut io::stdout(), false, |_| { + panic::panic_any(73_u64) + }) + } + "keys" => { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.handle(Input::Ctrl('p')); + let original = editor.lines().to_vec(); + println!("READING_KEYS"); + io::stdout().flush()?; + for (index, expected) in [ + Input::Ctrl('j'), + Input::Ctrl('k'), + Input::Ctrl('j'), + Input::Ctrl('k'), + Input::Ctrl('h'), + Input::Ctrl('l'), + Input::Backspace, + Input::Backspace, + Input::Enter, + Input::Enter, + ] + .into_iter() + .enumerate() + { + let Event::Key(key) = event::read()? else { + panic!("expected a keyboard event"); + }; + let input = translate(key).unwrap(); + assert_eq!(input, expected); + if index == 4 { + for ch in "ab".chars() { + editor.handle(Input::Char(ch)); + } + } else if index == 8 { + for ch in "q!".chars() { + editor.handle(Input::Char(ch)); + } + } + editor.handle(input); + match index { + 0 | 2 => assert_eq!(editor.palette().unwrap().selected, 1), + 1 | 3 => assert_eq!(editor.palette().unwrap().selected, 0), + 4 | 5 => assert_eq!(editor.palette().unwrap().query, "ab"), + 6 => assert_eq!(editor.palette().unwrap().query, "a"), + 7 => assert_eq!(editor.palette().unwrap().query, ""), + 8 => { + assert!(editor.palette().is_none()); + assert_eq!(editor.mode(), Mode::Command); + assert!(!editor.should_quit()); + } + 9 => assert!(editor.should_quit()), + _ => unreachable!(), + } + assert_eq!(editor.lines(), original); + } + Ok(()) + } + _ => unreachable!(), + } + }) + })); + match mode.as_str() { + "normal" | "keys" => result.unwrap().unwrap(), + "error" => assert_eq!( + result.unwrap().unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ), + "panic" => assert_eq!(result.unwrap_err().downcast_ref::(), Some(&73)), + _ => unreachable!(), + } + } + assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap()); + println!("AFTER_TERMINAL_RETURN"); + let _ = panic::catch_unwind(|| panic!("outside the terminal session")); + return; + } + + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + unsafe extern "C" { + fn setsid() -> i32; + } + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "tui::tests::terminal_session_scopes_the_callers_panic_hook", + "--nocapture", + "--color", + "never", + ]) + .env(CHILD, "entry") + .stdin(Stdio::null()); + // SAFETY: setsid is async-signal-safe and detaches the child from any + // controlling terminal without touching inherited Rust state. + unsafe { + command.pre_exec(|| { + if setsid() == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } + let check = |output: std::process::Output, mode: &str| { + assert!(output.status.success(), "{mode}: {output:?}"); + let stdout = String::from_utf8(output.stdout).unwrap(); + let (before, after) = stdout.split_once("AFTER_TERMINAL_RETURN").unwrap(); + let entered = usize::from(mode != "entry"); + assert_eq!( + before.matches("\x1b[?1049h").count(), + entered, + "{mode}: {before:?}" + ); + assert_eq!( + before.matches("\x1b[?1049l").count(), + entered, + "{mode}: {before:?}" + ); + for sequence in ["\x1b[>1u", "\x1b[<1u"] { + assert_eq!( + before.matches(sequence).count(), + entered, + "{mode}: {before:?}" + ); + } + for protocol in [1000, 1002, 1003, 1015, 1006] { + assert_eq!( + before.matches(&format!("\x1b[?{protocol}h")).count(), + entered, + "{mode}: mouse capture was not enabled: {before:?}", + ); + assert_eq!( + before.matches(&format!("\x1b[?{protocol}l")).count(), + entered, + "{mode}: mouse capture was not restored: {before:?}", + ); + } + if mode == "panic" { + let end = before.find("\x1b[?2026l").unwrap(); + assert!(end < before.find("CALLER_HOOK").unwrap()); + assert!( + before.find("\x1b[?1049l").unwrap() < before.find("CALLER_HOOK").unwrap(), + "{before:?}", + ); + assert!( + before.find("\x1b[?1006l").unwrap() < before.find("CALLER_HOOK").unwrap(), + "{before:?}", + ); + } + assert!(after.contains("CALLER_HOOK"), "{after:?}"); + assert!( + !after.contains('\u{1b}'), + "terminal hook outlived its session: {after:?}" + ); + }; + check(command.output().unwrap(), "entry"); + + // Ruby's standard-library PTY gives each positive-entry scenario a real + // terminal, keeping process-global hooks isolated from the test runner. + for mode in ["normal", "error", "panic", "keys"] { + let output = Command::new(std::env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into())) + .args([ + "-rpty", + "-rtimeout", + "-e", + r#" +PTY.spawn(*ARGV) do |output, input, pid| + sent_keys = false + captured = +"" + begin + Timeout.timeout(10) do + loop do + chunk = output.readpartial(4096) + STDOUT.write(chunk) + captured << chunk + if !sent_keys && captured.include?("READING_KEYS") + input.write("\e[106;5u\e[107;5u\n\v\b\f\x7f\x7f\e[13u\r") + input.flush + sent_keys = true + end + end + end + rescue EOFError, Errno::EIO + rescue Timeout::Error + Process.kill("TERM", pid) + Process.wait(pid) + abort "terminal lifecycle probe timed out" + ensure + input.close + end + Process.wait(pid) + exit($?.exitstatus || 1) +end +"#, + "--", + ]) + .arg(std::env::current_exe().unwrap()) + .args([ + "--exact", + "tui::tests::terminal_session_scopes_the_callers_panic_hook", + "--nocapture", + "--color", + "never", + ]) + .env(CHILD, mode) + .output() + .unwrap(); + check(output, mode); + } +} + +#[cfg(test)] +fn draw( + frame: &mut Frame, + editor: &Editor, + top: &mut usize, + left: &mut usize, + syntax: &mut SyntaxCache, + document: &mut DocumentViewport, + panels: &mut WheelPanels, +) { + let area = frame.area(); + let workspace = draw_bars(frame, editor, area, syntax); + draw_document( + frame, editor, workspace, workspace, top, left, syntax, document, panels, + ); +} diff --git a/tests/unit/wrap.rs b/tests/unit/wrap.rs new file mode 100644 index 0000000..d9eeaa9 --- /dev/null +++ b/tests/unit/wrap.rs @@ -0,0 +1,133 @@ +use super::*; + +fn layout(text: &str, width: usize) -> Vec { + let mut cache = WrapCache::default(); + cache.prepare((1, 0), width); + cache.segments(0, text).to_vec() +} + +#[test] +fn words_and_long_words_preserve_every_source_cell() { + assert_eq!( + layout("one two three", 8), + vec![Segment { start: 0, end: 8 }, Segment { start: 8, end: 13 },] + ); + let segments = layout("abcdefgh", 3); + assert_eq!( + segments, + vec![ + Segment { start: 0, end: 3 }, + Segment { start: 3, end: 6 }, + Segment { start: 6, end: 8 }, + ] + ); + assert_eq!(caret_segment(&segments, 3), 1); + assert_eq!(caret_segment(&segments, 8), 2); +} + +#[test] +fn long_words_do_not_wrap_indentation_onto_an_empty_visual_row() { + for source in [ + " abcdefghijk", + "\tabcdefghijk", + "head abcdefghijk", + ] { + let expanded = source.replace('\t', " "); + let segments = layout(source, 8); + assert_eq!(segments.first().unwrap().start, 0); + assert_eq!(segments.last().unwrap().end, expanded.len()); + assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start)); + for segment in segments + .iter() + .filter(|segment| segment.start < segment.end) + { + assert!( + !expanded[segment.start..segment.end].trim().is_empty(), + "whitespace-only visual row {segment:?} in {source:?}" + ); + } + } +} + +#[test] +fn tabs_use_source_stops_and_unicode_graphemes_remain_intact() { + assert_eq!( + layout("ab\t界e\u{301} z", 4), + vec![ + Segment { start: 0, end: 4 }, + Segment { start: 4, end: 8 }, + Segment { start: 8, end: 9 }, + ] + ); + assert_eq!( + layout("界界界", 3), + vec![ + Segment { start: 0, end: 2 }, + Segment { start: 2, end: 4 }, + Segment { start: 4, end: 6 }, + ] + ); + assert_eq!( + layout(" \t ", 3), + vec![Segment { start: 0, end: 3 }, Segment { start: 3, end: 5 },] + ); +} + +#[test] +fn narrow_tabs_preserve_all_expanded_cells_at_original_stops() { + assert_eq!( + layout("\tX", 2), + [ + Segment { start: 0, end: 2 }, + Segment { start: 2, end: 4 }, + Segment { start: 4, end: 5 }, + ] + ); + assert_eq!( + layout("a\tX", 2), + [ + Segment { start: 0, end: 1 }, + Segment { start: 1, end: 3 }, + Segment { start: 3, end: 5 }, + Segment { start: 5, end: 5 }, + ] + ); + for source in ["\t", "a\t", "ab\tX", "\t\tX"] { + let segments = layout(source, 1); + assert!( + segments + .iter() + .all(|segment| segment.end - segment.start <= 1) + ); + assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start)); + let end = source.graphemes(true).fold(0, |column, grapheme| { + column + + if grapheme == "\t" { + TAB_WIDTH - column % TAB_WIDTH + } else { + 1 + } + }); + assert_eq!(segments.last().unwrap(), &Segment { start: end, end }); + } +} + +#[test] +fn full_lines_have_an_insertion_cell_and_cache_invalidation_is_lazy() { + let mut cache = WrapCache::default(); + cache.prepare((7, 2), 4); + assert_eq!( + cache.segments(90, "abcd"), + [Segment { start: 0, end: 4 }, Segment { start: 4, end: 4 },] + ); + assert_eq!(caret_segment(cache.segments(90, "abcd"), 4), 1); + assert_eq!(cache.rows.len(), 1); + cache.prepare((7, 2), 4); + assert_eq!(cache.rows.len(), 1); + cache.prepare((7, 2), 8); + assert!(cache.rows.is_empty()); + cache.segments(90, "abcd"); + cache.prepare((7, 3), 8); + assert!(cache.rows.is_empty()); + assert_eq!(layout("", 0), [Segment { start: 0, end: 0 }]); +}