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), (&draft, true)], ); 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_in(Focus::Before, 0, usize::MAX) .count(); let cursor = editor.cursor(); editor.handle(Input::Char(']')); assert!( editor .diff() .unwrap() .source_rows_in(Focus::Before, 0, usize::MAX) .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!(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.active.dirty_cache.set(None); 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(); 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.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 command_completion_cycles_core_alias_and_live_plugin_names_without_executing() { let mut editor = editor(); editor.register(plugin("fixture", &["save_note"])).unwrap(); let document = editor.context().text; type_text(&mut editor, ":sav"); for expected in ["save", "save_and_quit", "save_note", "save"] { assert_eq!(editor.handle(Input::Char('\t')), None); assert_eq!(editor.command_line(), expected); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.context().text, document); assert!(!editor.should_quit()); } for expected in ["save_note", "save_and_quit", "save", "save_note"] { assert_eq!(editor.handle(Input::BackTab), None); assert_eq!(editor.command_line(), expected); } let invocation = editor.handle(Input::Enter).unwrap(); assert_eq!(invocation.plugin, "fixture"); assert_eq!(invocation.command, "save_note"); assert_eq!(invocation.args, ""); type_text(&mut editor, ":q"); for expected in ["q", "q!", "quit", "q"] { editor.handle(Input::Char('\t')); assert_eq!(editor.command_line(), expected); assert!(!editor.should_quit()); } editor.handle(Input::Escape); type_text(&mut editor, ":sav"); editor.handle(Input::Char('\t')); editor.disable_plugin("fixture", "test"); for expected in ["save_and_quit", "save"] { editor.handle(Input::Char('\t')); assert_eq!(editor.command_line(), expected); } } #[test] fn command_completion_reverse_starts_at_last_match_and_retains_prefix_on_direction_changes() { let mut editor = editor(); type_text(&mut editor, ":tab."); for (key, expected) in [ (Input::BackTab, "tab.previous"), (Input::BackTab, "tab.next"), (Input::Char('\t'), "tab.previous"), (Input::Char('\t'), "tab.close"), (Input::BackTab, "tab.previous"), ] { editor.handle(key); assert_eq!(editor.command_line(), expected); assert_eq!(editor.tab_count(), 1); } editor.handle(Input::Escape); for draft in ["plugins", "unknown", "界", "edit file name"] { type_text(&mut editor, ":"); type_text(&mut editor, draft); for _ in 0..2 { editor.handle(Input::BackTab); assert_eq!(editor.command_line(), draft); } editor.handle(Input::Escape); } editor.bind("command", "shift+tab", None).unwrap(); editor .bind( "command", "ctrl+b", Some("core.command_complete_previous".into()), ) .unwrap(); type_text(&mut editor, ":tab."); editor.handle(Input::BackTab); assert_eq!(editor.command_line(), "tab."); editor.handle(Input::Ctrl('b')); assert_eq!(editor.command_line(), "tab.previous"); editor.handle(Input::Char('\t')); assert_eq!(editor.command_line(), "tab.close"); editor.handle(Input::Ctrl('b')); assert_eq!(editor.command_line(), "tab.previous"); } #[test] fn command_completion_restarts_after_edits_history_and_cancellation_and_ignores_arguments() { let mut editor = editor(); type_text(&mut editor, ":plugins\n:sav"); editor.handle(Input::Char('\t')); type_text(&mut editor, "_"); editor.handle(Input::Char('\t')); assert_eq!(editor.command_line(), "save_and_quit"); editor.handle(Input::Ctrl('u')); type_text(&mut editor, "q!"); editor.handle(Input::Backspace); editor.handle(Input::Char('\t')); assert_eq!(editor.command_line(), "q"); editor.handle(Input::Char('\t')); assert_eq!(editor.command_line(), "q!"); editor.handle(Input::Up); editor.handle(Input::Char('\t')); assert_eq!(editor.command_line(), "plugins"); editor.handle(Input::Down); editor.handle(Input::Char('\t')); assert_eq!(editor.command_line(), "q!"); editor.handle(Input::Escape); for (draft, expected) in [ ("", "bdelete"), (" :sav", " :save"), ("no_such_command", "no_such_command"), ("界e\u{301}", "界e\u{301}"), ("edit ", "edit "), ("save 界 file.txt", "save 界 file.txt"), ] { type_text(&mut editor, ":"); type_text(&mut editor, draft); editor.handle(Input::Char('\t')); assert_eq!(editor.command_line(), expected); editor.handle(Input::Escape); } } #[test] fn command_completion_respects_bindings_and_preserves_visual_diff_and_search_input() { let directory = Directory::new(); let mut editor = editor(); editor .bind("command", "ctrl+n", Some("core.command_complete".into())) .unwrap(); editor.bind("command", "tab", None).unwrap(); historical_diff( &mut editor, &directory.0, vec!["needle before".into()], vec!["needle after".into()], ); editor.handle(Input::Char('V')); let selections = editor.selections(); let cursor = editor.cursor(); type_text(&mut editor, ":comm"); editor.handle(Input::Char('\t')); assert_eq!(editor.command_line(), "comm"); editor.handle(Input::Ctrl('n')); assert_eq!(editor.command_line(), "comment"); assert_eq!(editor.selections(), selections); assert_eq!(editor.cursor(), cursor); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selections); editor.handle(Input::Escape); type_text(&mut editor, "/sav"); editor.handle(Input::Char('\t')); assert_eq!(editor.command_line(), "sav\t"); editor.handle(Input::Escape); editor .bind("command", "tab", Some("core.up".into())) .unwrap(); type_text(&mut editor, ":plugins\n:sav"); editor.handle(Input::Char('\t')); assert_eq!(editor.command_line(), "plugins"); } #[test] fn prompt_history_browses_without_executing_and_restores_unicode_drafts() { for prefix in [':', '/'] { let mut editor = editor(); editor.register(plugin("fixture", &["greet"])).unwrap(); editor .apply(vec![Effect::Replace( "greet first\ngreet 界e\u{301}\nend".into(), )]) .unwrap(); for text in ["greet first", "greet 界e\u{301}"] { editor.handle(Input::Char(prefix)); type_text(&mut editor, text); editor.handle(Input::Enter); } let cursor = editor.cursor(); let document = editor.context().text; editor.handle(Input::Char(prefix)); type_text(&mut editor, "draft 👩‍💻"); for (key, expected) in [ (Input::Down, "draft 👩‍💻"), (Input::Up, "greet 界e\u{301}"), (Input::Ctrl('k'), "greet first"), (Input::Up, "greet first"), (Input::Down, "greet 界e\u{301}"), ] { assert_eq!(editor.handle(key), None); assert_eq!(editor.command_line(), expected); assert_eq!(editor.cursor(), cursor); assert_eq!(editor.context().text, document); } type_text(&mut editor, " edited"); editor.handle(Input::Ctrl('j')); assert_eq!(editor.command_line(), "draft 👩‍💻"); editor.handle(Input::Ctrl('k')); assert_eq!(editor.command_line(), "greet 界e\u{301}"); editor.handle(Input::Escape); editor.handle(Input::Char(prefix)); editor.handle(Input::Up); editor.handle(Input::Down); assert_eq!(editor.command_line(), ""); editor.handle(Input::Up); editor.handle(Input::Up); let invocation = editor.handle(Input::Enter); if prefix == ':' { let invocation = invocation.unwrap(); assert_eq!(invocation.command, "greet"); assert_eq!(invocation.args, "first"); } else { assert_eq!(editor.active.view.search_query, "greet first"); assert_eq!(editor.cursor().row, 0); } assert_eq!(editor.mode(), Mode::Normal); } } #[test] fn prompt_history_is_separate_bounded_and_skips_empty_cancelled_and_consecutive_duplicates() { let mut editor = editor(); for prefix in [':', '/'] { editor.handle(Input::Char(prefix)); type_text(&mut editor, "draft"); editor.handle(Input::Up); editor.handle(Input::Down); assert_eq!(editor.command_line(), "draft"); editor.handle(Input::Escape); } for command in [":plugins", ":plugins", ":", "/needle", "/needle", "/"] { type_text(&mut editor, command); editor.handle(Input::Enter); } for (prefix, expected) in [(':', "plugins"), ('/', "needle")] { editor.handle(Input::Char(prefix)); type_text(&mut editor, "cancelled"); editor.handle(Input::Escape); editor.handle(Input::Char(prefix)); editor.handle(Input::Up); editor.handle(Input::Up); assert_eq!(editor.command_line(), expected); editor.handle(Input::Down); assert_eq!(editor.command_line(), ""); editor.handle(Input::Escape); } for index in 0..=PROMPT_HISTORY_LIMIT { type_text(&mut editor, &format!("/query {index}")); editor.handle(Input::Enter); } editor.handle(Input::Char('/')); for _ in 0..PROMPT_HISTORY_LIMIT + 2 { editor.handle(Input::Up); } assert_eq!(editor.command_line(), "query 1"); for _ in 0..PROMPT_HISTORY_LIMIT { editor.handle(Input::Down); } assert_eq!(editor.command_line(), ""); } #[test] fn prompt_history_is_session_wide_remappable_and_preserves_visual_diff_targets() { let directory = Directory::new(); let mut editor = editor(); type_text(&mut editor, ":plugins\n/needle\n"); editor.execute("tab.new"); editor .bind("command", "ctrl+b", Some("core.up".into())) .unwrap(); editor .bind("search", "ctrl+f", Some("core.down".into())) .unwrap(); editor.bind("command", "up", None).unwrap(); historical_diff( &mut editor, &directory.0, vec!["needle before".into()], vec!["needle after".into()], ); editor.handle(Input::Char('v')); let selections = editor.selections(); let cursor = editor.cursor(); editor.handle(Input::Char(':')); editor.handle(Input::Up); assert_eq!(editor.command_line(), ""); editor.handle(Input::Ctrl('b')); assert_eq!(editor.command_line(), "plugins"); assert_eq!(editor.selections(), selections); assert_eq!(editor.cursor(), cursor); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), selections); editor.handle(Input::Escape); editor.handle(Input::Char('/')); type_text(&mut editor, "draft"); editor.handle(Input::Up); assert_eq!(editor.command_line(), "needle"); editor.handle(Input::Ctrl('f')); assert_eq!(editor.command_line(), "draft"); editor.handle(Input::Escape); assert_eq!(editor.cursor(), cursor); } #[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 { blame: Default::default(), 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 { review_revisions: None, stats: None, 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 { review_revisions: None, stats: None, 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 { blame: Default::default(), 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 { blame: Default::default(), root: root.to_owned(), revisions: ["base".into(), "head".into()], before: "base".into(), after: "head".into(), editable_paths: Default::default(), files: vec![ChangedFile { review_revisions: None, stats: None, 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()); } fn source_review_editor(directory: &Directory, text: &str) -> (Editor, PathBuf, PathBuf) { let root = directory.0.canonicalize().unwrap(); assert!( std::process::Command::new("git") .args(["init", "-q", "--template="]) .current_dir(&root) .status() .unwrap() .success() ); let path = root.join("source.txt"); fs::write(&path, text).unwrap(); let mut editor = Editor::open(Some(path.clone()), vec!["text".into()]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor.set_review_directory(root.join("reviews")); (editor, root, path) } #[test] fn source_review_drafts_guard_source_pane_close_and_saved_panels_retarget() { let directory = Directory::new(); let (mut editor, _, _) = source_review_editor(&directory, "source\n"); let source = editor.pane_id; editor.split_pane(SplitAxis::Vertical); let other = editor.pane_id; editor.select_pane(source); editor.execute("comment"); type_text(&mut editor, "iDraft"); editor.handle(Input::Escape); editor.select_pane(source); editor.close_pane(false); assert!(editor.panes.contains_key(&source)); editor.execute("review.save"); editor.close_pane(false); assert!(!editor.panes.contains_key(&source)); assert_eq!(editor.pane_id, other); editor.execute("review.edit 1"); assert_eq!(editor.context().text, "Draft"); editor.handle(Input::Escape); assert_eq!(editor.pane_id, other); assert_eq!(editor.context().text, "source\n"); } #[test] fn source_comments_capture_unsaved_exact_unicode_and_reopen_without_a_diff() { let directory = Directory::new(); let (mut editor, root, path) = source_review_editor(&directory, "disk\n"); assert!(!root.join("reviews").exists()); let source = "first\nprefix e\u{301}界 suffix\nlast\n"; editor.apply(vec![Effect::Replace(source.into())]).unwrap(); set_carets(&mut editor, &[(1, 7)], 0); type_text(&mut editor, "vl"); editor.execute("comment"); assert!(editor.review_panel().is_some()); assert!(editor.workspace_diff().is_none()); type_text(&mut editor, "iExact feedback"); editor.handle(Input::Escape); editor.execute("w"); let review = editor.local_review.document.clone(); let anchor = review.comments[0].anchor.clone(); assert_eq!(anchor.excerpt(), "e\u{301}界"); assert_eq!(anchor.content, source); assert!(anchor.revision.starts_with("working:")); assert_eq!(fs::read_to_string(&path).unwrap(), "disk\n"); editor.handle(Input::Escape); assert_eq!(editor.context().text, source); editor.execute("w"); drop(editor); let mut reopened = Editor::open(Some(path), vec!["text".into()]).unwrap(); reopened.set_review_directory(root.join("reviews")); assert!(reopened.workspace_diff().is_none()); assert_eq!(reopened.local_review.document.storage_id, review.storage_id); reopened.execute("review.next"); assert_eq!(reopened.context().text, "Exact feedback"); assert!(reopened.review_panel_title().unwrap().contains("current")); reopened.handle(Input::Escape); assert_eq!(reopened.cursor(), Cursor { row: 1, column: 7 }); assert_eq!(reopened.local_review.document.comments[0].anchor, anchor); } #[test] fn source_comment_dismissal_conflicts_tabs_and_stale_snapshots_preserve_text() { let directory = Directory::new(); let (mut editor, root, path) = source_review_editor(&directory, "original line\n"); editor.execute("comment"); type_text(&mut editor, "iDiscarded"); editor.handle(Input::Escape); editor.handle(Input::Escape); assert!(editor.local_review.document.comments.is_empty()); editor.execute("comment"); type_text(&mut editor, "iSaved feedback"); editor.handle(Input::Escape); editor.execute("w"); editor.handle(Input::Escape); let anchor = editor.local_review.document.comments[0].anchor.clone(); assert_eq!(anchor.excerpt(), "original line\n"); editor.execute("tab.new"); editor.execute("review.edit 1"); assert_eq!(editor.context().text, "Saved feedback"); editor.handle(Input::Escape); editor.execute("tab.previous"); editor .apply(vec![Effect::Replace("entirely changed\n".into())]) .unwrap(); editor.execute("review.edit 1"); assert!(editor.message().contains("stale")); assert!( editor .review_panel_title() .unwrap() .contains("original snapshot") ); editor.handle(Input::Escape); assert_eq!(editor.context().text, "original line\n"); assert!(editor.active.resolver_readonly); assert_eq!(editor.local_review.document.comments[0].anchor, anchor); assert_eq!(fs::read_to_string(&path).unwrap(), "original line\n"); let mut concurrent = Editor::open(Some(path), vec!["text".into()]).unwrap(); concurrent.set_review_directory(root.join("reviews")); concurrent.execute("review.verdict looks_good"); editor.execute("review.edit 1"); type_text(&mut editor, "A unsaved"); editor.handle(Input::Escape); editor.execute("w"); assert!(editor.message().contains("changed in another session")); assert_eq!(editor.context().text, "Saved feedback unsaved"); assert!(editor.current_review_dirty()); } #[test] fn source_comments_reject_multiple_carets_and_roundtrip_json_with_empty_lines() { let directory = Directory::new(); let (mut editor, root, _) = source_review_editor(&directory, "one\n\n"); set_carets(&mut editor, &[(0, 0), (1, 0)], 0); editor.execute("comment"); assert!(editor.message().contains("one caret")); assert!(editor.review_panel().is_none()); set_carets(&mut editor, &[(1, 0)], 0); editor.execute("comment"); type_text(&mut editor, "iEmpty line"); editor.handle(Input::Escape); let export = root.join("review.json"); editor.execute(&format!("review.save {}", export.display())); let exported = crate::review::Review::load(&export, &root).unwrap(); assert_eq!(exported.comments[0].anchor.excerpt(), "\n"); assert_eq!(exported.comments[0].body, "Empty line"); editor.handle(Input::Escape); editor.execute(&format!("review.open {}", export.display())); assert_eq!( editor.local_review.document.comments[0].anchor, exported.comments[0].anchor ); } #[test] fn unsaved_review_comments_are_discarded_on_escape() { let directory = Directory::new(); let root = directory.0.canonicalize().unwrap(); let storage = root.join("data"); let mut editor = editor(); editor.set_review_directory(storage.clone()); historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]); editor.execute("comment"); type_text(&mut editor, "iDiscard me"); editor.handle(Input::Escape); editor.handle(Input::Escape); assert!(editor.local_review.document.comments.is_empty()); assert!( crate::review::Store::open(&storage) .unwrap() .list(&root) .unwrap() .is_empty() ); editor.execute("comment"); assert_eq!(editor.context().text, ""); type_text(&mut editor, "iKeep me"); editor.handle(Input::Escape); editor.execute("w"); editor.handle(Input::Escape); editor.execute("review.edit 1"); type_text(&mut editor, "A discarded"); editor.handle(Input::Escape); editor.handle(Input::Escape); editor.execute("review.edit 1"); assert_eq!(editor.context().text, "Keep me"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "Keep me"); let reviews = crate::review::Store::open(&storage) .unwrap() .list(&root) .unwrap(); assert_eq!(reviews[0].comments[0].body, "Keep me"); } #[test] fn review_metadata_autosaves_do_not_commit_hidden_comment_or_summary_drafts() { let directory = Directory::new(); let root = directory.0.canonicalize().unwrap(); let storage = root.join("data"); let mut editor = editor(); editor.set_review_directory(storage.clone()); historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]); editor.execute("comment"); type_text(&mut editor, "iHidden comment"); editor.handle(Input::Escape); editor.execute("review.summary"); type_text(&mut editor, "iHidden summary"); editor.handle(Input::Escape); editor.execute("review.verdict looks_good"); let store = crate::review::Store::open(&storage).unwrap(); let saved = store.list(&root).unwrap().pop().unwrap(); assert!(saved.comments.is_empty()); assert_eq!(saved.summary, ""); assert!(editor.local_reviews_dirty()); editor.execute("w"); let saved = store.load(&root, saved.storage_id.unwrap()).unwrap(); assert_eq!(saved.comments[0].body, "Hidden comment"); assert_eq!(saved.summary, "Hidden summary"); assert!(!editor.local_reviews_dirty()); } #[test] fn reviewed_file_marks_autosave_restore_and_stay_with_the_selected_review() { 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.review.toggle"); let store = Store::open(&storage).unwrap(); let saved = store.list(&root).unwrap().pop().unwrap(); let id = saved.storage_id.unwrap(); assert_eq!(saved.reviewed_files[0].path, Path::new("file.txt")); let export = root.join("review.json"); first.execute(&format!("review.save {}", export.display())); assert_eq!( Review::load(&export, &root).unwrap().reviewed_files, saved.reviewed_files ); first.execute("review.summary"); type_text(&mut first, "iUnsaved summary"); first.handle(Input::Escape); first.execute("diff.review.toggle"); assert!(store.load(&root, id).unwrap().reviewed_files.is_empty()); assert_eq!(store.load(&root, id).unwrap().summary, ""); first.execute("diff.review.toggle"); drop(first); let mut reopened = editor(); reopened.set_review_directory(storage.clone()); historical_diff(&mut reopened, &root, vec!["old".into()], vec!["new".into()]); assert_eq!( reopened.workspace_diff().unwrap().reviewed_files, BTreeSet::from([0]) ); reopened.execute("review.new"); assert!(reopened.workspace_diff().unwrap().reviewed_files.is_empty()); reopened.execute(&format!("review.open {id}")); assert_eq!( reopened.workspace_diff().unwrap().reviewed_files, BTreeSet::from([0]) ); reopened.handle(Input::Escape); reopened.execute("diff.review.toggle"); assert!(store.load(&root, id).unwrap().reviewed_files.is_empty()); } #[test] fn editing_a_reviewed_working_file_persists_its_invalidation() { let directory = Directory::new(); let root = directory.0.canonicalize().unwrap(); fs::write(root.join("file.txt"), "original\n").unwrap(); let storage = root.join("data"); let mut editor = editor(); editor.set_review_directory(storage.clone()); working_diff(&mut editor, &root, &["file.txt"]); editor.handle(Input::Enter); editor.execute("diff.review.toggle"); let store = crate::review::Store::open(&storage).unwrap(); let id = store.list(&root).unwrap()[0].storage_id.unwrap(); assert_eq!(store.load(&root, id).unwrap().reviewed_files.len(), 1); type_text(&mut editor, "iEdited "); editor.handle(Input::Escape); assert!(editor.workspace_diff().unwrap().reviewed_files.is_empty()); assert!(store.load(&root, id).unwrap().reviewed_files.is_empty()); } #[test] fn review_navigation_maps_working_ranges_and_preserves_stale_snapshots() { use crate::review::{Anchor, Comment, Side}; let directory = Directory::new(); let root = directory.0.canonicalize().unwrap(); fs::write(root.join("file.txt"), "header\nunique target\ntail\n").unwrap(); let mut editor = editor(); working_diff(&mut editor, &root, &["file.txt"]); editor.handle(Input::Enter); editor .diff .as_mut() .unwrap() .comparison .as_mut() .unwrap() .revisions[1] = "working:old".into(); editor.local_review.document = crate::review::Review::new(root); let anchor = Anchor { revision: "working:old".into(), path: "file.txt".into(), side: Side::After, start: TextPosition { row: 1, column: 7 }, end: TextPosition { row: 1, column: 13 }, content: editor.lines().join("\n"), }; editor.local_review.document.comments.push(Comment { id: 1, anchor: anchor.clone(), body: "Feedback".into(), resolved: false, }); editor .apply(vec![Effect::Replace( "inserted\nheader\nprefix unique target\ntail\n".into(), )]) .unwrap(); editor.refresh_live_diff(); editor .diff .as_mut() .unwrap() .comparison .as_mut() .unwrap() .revisions[1] = "working:new".into(); editor.execute("review.edit 1"); let owner = editor.diff_owner.unwrap(); editor.select_pane(owner); assert_eq!(editor.cursor(), Cursor { row: 2, column: 14 }); assert_eq!(editor.local_review.document.comments[0].anchor, anchor); editor .apply(vec![Effect::Replace( "inserted\nheader\nprefix unique changed\ntail\n".into(), )]) .unwrap(); editor.refresh_live_diff(); set_carets(&mut editor, &[(0, 0)], 0); editor.execute("review.edit 1"); assert!(editor.message().contains("stale")); editor.select_pane(owner); assert_eq!(editor.cursor(), Cursor::default()); assert_eq!(editor.local_review.document.comments[0].anchor, anchor); } #[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: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.execute("w"); first.handle(Input::Escape); let id = first.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("review.summary"); type_text(&mut first, "iSummary"); first.handle(Input::Escape); first.execute("w"); first.execute("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!("review.save {}", exported.display())); first.execute("review.edit 1"); type_text(&mut first, "A revised"); first.handle(Input::Escape); first.execute("w"); 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.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_range(&restored.comments[0].anchor, &reopened.local_review) .is_none() ); reopened.execute("review.new"); let second_id = reopened.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()]); choosing.handle(Input::Enter); assert_eq!(choosing.local_review.document.storage_id, Some(second_id)); choosing.execute(&format!("review.open {id}")); assert_eq!(choosing.local_review.document.storage_id, Some(id)); choosing.execute("review.delete 1"); assert!(store.load(&root, id).unwrap().comments.is_empty()); choosing.execute(&format!("review.open {}", exported.display())); let imported_id = choosing.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("comment"); type_text(&mut first, "iFeedback"); first.handle(Input::Escape); first.execute("w"); assert!( first .review_panel_title() .unwrap() .contains(" · unresolved · ") ); first.execute("review.resolve"); assert!( first .context() .text .contains("Comment #1 · resolved · current") ); let store = Store::open(&storage).unwrap(); let document = &first.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("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!("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("review.overview"); assert!( stale .context() .text .contains("Comment #1 · resolved · stale") ); first.execute("review.unresolve 1"); assert!(!store.load(&root, id).unwrap().comments[0].resolved); stale.execute("review.unresolve 1"); assert!(stale.current_review_dirty()); assert!(!stale.local_review.document.comments[0].resolved); assert_eq!(stale.local_review.document.comments[0].anchor, anchor); assert!(stale.message().contains("changed in another session")); first.execute("review.resolve"); assert!( first .context() .text .contains("Comment #1 · resolved · current") ); first.execute("review.unresolve"); assert!( first .context() .text .contains("Comment #1 · unresolved · current") ); first.execute(&format!("review.open {}", export.display())); let imported = &first.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("comment"); type_text(&mut first, "iStored feedback"); first.handle(Input::Escape); first.execute("w"); first.handle(Input::Escape); let id = first.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.local_review.document.storage_revision; first.execute("review.verdict looks_good"); stale.execute("review.verdict changes_requested"); assert!(stale.current_review_dirty()); assert_eq!(stale.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("review.edit 1"); type_text(&mut first, "A unsaved"); first.handle(Input::Escape); first.execute("w"); assert!(first.current_review_dirty()); assert_eq!(first.context().text, "Stored feedback unsaved"); first.handle(Input::Escape); assert!(!first.current_review_dirty()); assert_eq!( first.local_review.document.comments[0].body, "Stored feedback" ); 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("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("review.summary"); type_text(&mut editor, "iPlease simplify the branch"); editor.handle(Input::Escape); editor.execute("review.verdict changes_requested"); assert!(editor.context().text.contains("Changes requested")); editor.execute(&format!("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("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!("review.open {}", path.display())); assert!(editor.context().text.contains("Explain the removed branch")); editor.execute("review.next"); assert_eq!(editor.context().text, "Explain the removed branch"); editor.execute("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 review_identity_changes_retire_drafts_before_reusing_comment_ids() { for sqlite in [false, true] { let directory = Directory::new(); let root = directory.0.canonicalize().unwrap(); let mut editor = editor(); if sqlite { editor.set_review_directory(root.join("data")); } historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]); editor.execute("comment"); type_text(&mut editor, "iOriginal feedback"); editor.handle(Input::Escape); editor.execute("review.summary"); type_text(&mut editor, "iKeep summary"); editor.handle(Input::Escape); let original = root.join("original.json"); editor.execute(&format!("review.save {}", original.display())); editor.execute("review.delete 1"); editor.execute("comment"); assert_eq!(editor.context().text, ""); type_text(&mut editor, "iFresh feedback"); editor.handle(Input::Escape); let fresh = root.join("fresh.json"); editor.execute(&format!("review.save {}", fresh.display())); let document = crate::review::Review::load(&fresh, &root).unwrap(); assert_eq!(document.comments[0].id, 1); assert_eq!(document.comments[0].body, "Fresh feedback"); assert_eq!(document.summary, "Keep summary"); editor.execute(&format!("review.open {}", original.display())); editor.execute("review.edit 1"); assert_eq!(editor.context().text, "Original feedback"); editor.execute("review.summary"); assert_eq!(editor.context().text, "Keep summary"); let restored = root.join("restored.json"); editor.execute(&format!("review.save {}", restored.display())); assert_eq!( crate::review::Review::load(&restored, &root) .unwrap() .comments[0] .body, "Original feedback" ); editor.execute("review.new"); editor.execute("comment"); assert_eq!(editor.context().text, ""); type_text(&mut editor, "iSeparate review"); editor.handle(Input::Escape); let separate = root.join("separate.json"); editor.execute(&format!("review.save {}", separate.display())); let document = crate::review::Review::load(&separate, &root).unwrap(); assert_eq!(document.comments[0].body, "Separate review"); assert_eq!(document.summary, ""); } } #[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, ":comment"); editor.handle(Input::Enter); editor.execute(&format!( "review.save {}", root.join("range.json").display() )); let diff = editor.workspace_diff().unwrap(); let anchor = &editor.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_range(anchor, &editor.local_review) .is_some() ); 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("comment"); editor.execute(&format!( "review.save {}", root.join("range.json").display() )); let anchor = &editor.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, "comment"); } else { type_text(&mut editor, ":comment"); } editor.handle(Input::Enter); editor.execute(&format!( "review.save {}", root.join("range.json").display() )); let diff = editor.workspace_diff().unwrap(); let anchor = editor.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_range(&anchor, &editor.local_review) .is_some() ); 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("review.overview"); assert!(editor.context().text.contains("stale")); editor.execute("review.edit 1"); assert_eq!(editor.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("comment"); type_text(&mut editor, "iUnsaved feedback"); editor.handle(Input::Escape); editor.execute("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!("review.save {}", invalid.display())); assert!(editor.local_reviews_dirty()); assert!(editor.local_review.document.comments.is_empty()); assert!( editor .parked .values() .any(|buffer| buffer.lines == ["Unsaved feedback"]) ); let path = root.join("review.json"); editor.execute(&format!("review.save {}", path.display())); assert!(!editor.local_reviews_dirty()); fs::write(&path, "external change").unwrap(); editor.execute("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 { review_revisions: None, stats: None, 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 { blame: Default::default(), 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 { blame: Default::default(), 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 { review_revisions: None, stats: None, 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 { review_revisions: None, stats: None, 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 { review_revisions: None, stats: None, 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_in(Focus::After, 0, usize::MAX) .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(&format!( "review.save {}", directory.path("review.json").display() )); 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 review_copy_uses_cli_text_and_configured_clipboard_without_saving_drafts() { use crate::review::{Anchor, Comment, Review, Side, Store, Verdict}; for mode in ["system", "separate"] { let fixture = Fixture::new(); let root = fixture.0.0.canonicalize().unwrap(); let storage = root.join("reviews"); let mut review = Review::new(root.clone()); review.summary = "Review summary".into(); review.verdict = Some(Verdict::ChangesRequested); review.comments = vec![ Comment { id: 1, body: "First\nfeedback".into(), resolved: false, anchor: Anchor { revision: "base".into(), path: "file.txt".into(), side: Side::Before, start: TextPosition { row: 0, column: 1 }, end: TextPosition { row: 1, column: 1 }, content: "xe\u{301}👩‍💻\n界z".into(), }, }, Comment { id: 2, body: "Resolved feedback".into(), resolved: true, anchor: Anchor { revision: "stale".into(), path: "other.rs".into(), side: Side::After, start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 3 }, content: "old".into(), }, }, ]; let mut store = Store::open(&storage).unwrap(); store.save(&mut review).unwrap(); let id = review.storage_id.unwrap(); let expected = review.format_text(true); let mut editor = fixture.editor(); editor.set_clipboard_mode(mode).unwrap(); editor.clipboard = vec!["internal".into()]; fixture.external("external"); editor.execute("review.copy"); assert_eq!(editor.clipboard, ["internal"]); assert_eq!(fixture.bytes("state"), b"external"); editor.set_review_directory(storage); historical_diff( &mut editor, &root, vec!["xe\u{301}👩‍💻".into(), "界z".into()], vec!["new".into()], ); editor.execute("review.edit 1"); type_text(&mut editor, "A UNSAVED"); editor.handle(Input::Escape); let draft = editor.context().text; editor.execute("review.copy"); assert_eq!(editor.context().text, draft); assert!(editor.local_reviews_dirty()); let stored = store.load(&root, id).unwrap(); assert_eq!(stored.storage_revision, review.storage_revision); assert_eq!(stored.comments, review.comments); if mode == "system" { assert_eq!(fixture.bytes("state"), expected.as_bytes()); assert_eq!(editor.clipboard, ["internal"]); let owned = editor.owned_system_clipboard.clone(); fixture.fail("fail-write", true); editor.execute("review.copy"); assert_eq!(fixture.bytes("state"), expected.as_bytes()); assert_eq!(editor.owned_system_clipboard, owned); } else { assert_eq!(editor.clipboard, [expected.clone()]); assert!(!editor.clipboard_linewise); assert_eq!(fixture.bytes("state"), b"external"); assert!(fixture.bytes("writes").is_empty()); } editor.execute("review.copy unexpected"); editor.with_pane(editor.diff_owner.unwrap(), |editor| { editor.local_review.document.comments.clear(); }); editor.execute("review.copy"); if mode == "system" { assert_eq!(fixture.bytes("state"), expected.as_bytes()); } else { assert_eq!(editor.clipboard, [expected]); } } } #[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 [ "x", "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 normal_x_publishes_graphemes_and_repeat_failure_is_atomic() { let fixture = Fixture::new(); let mut editor = fixture.editor(); editor .apply(vec![Effect::Replace("e\u{301}👩‍🚀z".into())]) .unwrap(); type_text(&mut editor, "x"); assert_eq!(editor.context().text, "👩‍🚀z"); assert_eq!(fixture.bytes("state"), "e\u{301}".as_bytes()); fixture.fail("fail-write", true); let before = state(&editor); type_text(&mut editor, "."); assert_eq!(state(&editor), before); assert_eq!(fixture.bytes("state"), "e\u{301}".as_bytes()); fixture.fail("fail-write", false); type_text(&mut editor, "."); assert_eq!(editor.context().text, "z"); assert_eq!(fixture.bytes("state"), "👩‍🚀".as_bytes()); type_text(&mut editor, "uUP"); assert_eq!(editor.context().text, "👩‍🚀z"); } #[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(); editor.active.saved_lines.clone_from(&editor.active.lines); editor.active.dirty_cache.set(Some(false)); assert!(!editor.dirty()); type_text(&mut editor, "Vj"); fixture.fail("fail-write", true); let before = state(&editor); editor.handle(Input::Char('.')); assert_eq!(state(&editor), before); assert!(!editor.dirty()); 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"); assert!(editor.dirty()); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "aa\nbb\ncc"); assert!(!editor.dirty()); 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, "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 history = |entry: &HistoryEntry| (entry.start, entry.end, snapshot(&entry.before)); 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(&history).collect(); let redo: Vec<_> = editor.active.redo.iter().map(&history).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(&history).collect::>(), undo ); assert_eq!( editor.active.redo.iter().map(&history).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 history = |entry: &HistoryEntry| (entry.start, entry.end, snapshot(&entry.before)); 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(&history).collect(); let redo: Vec<_> = editor.active.redo.iter().map(&history).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(&history).collect::>(), undo ); assert_eq!( editor.active.redo.iter().map(&history).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"); assert_eq!(editor.handle(Input::Ctrl('j')), None); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.command_line(), "greet arguments"); let invocation = editor.handle(Input::Enter).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::Search); editor.handle(Input::Enter); 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 history = |entry: &HistoryEntry| (entry.start, entry.end, snapshot(&entry.before)); 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(&history).collect(); let redo: Vec<_> = editor.active.redo.iter().map(&history).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(&history).collect::>(), undo ); assert_eq!( editor.active.redo.iter().map(&history).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 large_buffer_history_retains_changed_rows_and_round_trips_all_groups() { let directory = Directory::new(); let path = directory.path("large.txt"); let source = format!("head\n{}tail\n", "unchanged contents\n".repeat(10_000)); fs::write(&path, &source).unwrap(); let mut editor = Editor::open(Some(path), vec![]).unwrap(); for _ in 0..12 { editor.handle(Input::Char('i')); editor.handle(Input::Char('界')); editor.handle(Input::Escape); } assert_eq!(editor.lines()[0], format!("{}head", "界".repeat(12))); let retained_bytes: usize = editor .active .undo .iter() .flat_map(|entry| &entry.before.lines) .map(String::len) .sum(); assert!( retained_bytes < 4096, "small edits retained {retained_bytes} bytes of history" ); for _ in 0..12 { editor.handle(Input::Char('u')); } assert_eq!(editor.lines().join("\n"), source); assert!(!editor.dirty()); for _ in 0..12 { editor.handle(Input::Char('U')); } assert_eq!(editor.lines()[0], format!("{}head", "界".repeat(12))); assert_eq!(&editor.lines()[1..], &split_lines(&source)[1..]); assert!(editor.dirty()); editor.handle(Input::Char('u')); editor.handle(Input::Char('i')); editor.handle(Input::Enter); editor.handle(Input::Escape); assert!(editor.active.redo.is_empty()); editor.handle(Input::Char('u')); assert_eq!(editor.lines()[0], format!("{}head", "界".repeat(11))); editor.execute("w"); assert!(!editor.dirty()); assert_eq!( fs::read_to_string(editor.path().unwrap()).unwrap(), format!("{}{source}", "界".repeat(11)) ); } #[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 dirty_cache_tracks_saved_text_across_edits_undo_restore_and_reload() { let directory = Directory::new(); let path = directory.path("file.txt"); fs::write(&path, "saved\n").unwrap(); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); assert!(!editor.dirty()); editor .apply(vec![Effect::Insert("changed".into())]) .unwrap(); assert!(editor.dirty()); for _ in 0..3 { assert!(editor.dirty()); assert!(editor.active.info(true).dirty); assert!(editor.active.document().dirty); assert!(!editor.active.refresh_file().unwrap()); } editor.handle(Input::Char('u')); assert!(!editor.dirty()); editor.handle(Input::Char('U')); assert!(editor.dirty()); editor .apply(vec![Effect::Replace("saved\n".into())]) .unwrap(); assert!(!editor.dirty()); editor.apply(vec![Effect::Insert("new ".into())]).unwrap(); assert!(editor.dirty()); let revision = editor.text_revision(); editor.execute("w"); assert_eq!(editor.text_revision(), revision); assert!(!editor.dirty()); editor.handle(Input::Char('u')); assert!(editor.dirty()); editor.handle(Input::Char('U')); assert!(!editor.dirty()); fs::write(&path, "reloaded content\n").unwrap(); assert!(editor.active.refresh_file().unwrap()); assert_eq!(editor.lines(), ["reloaded content", ""]); assert!(!editor.dirty()); assert!(!editor.active.refresh_file().unwrap()); } #[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()); } }