use std::{ fs, path::{Path, PathBuf}, process::Command, sync::atomic::{AtomicU64, Ordering}, thread, time::{Duration, Instant}, }; use vamp::{ diff::{ComparisonMode, Phase}, editor::Input, session::EditorSession, }; static NEXT: AtomicU64 = AtomicU64::new(0); struct Fixture(PathBuf); impl Fixture { fn new() -> Self { let root = std::env::temp_dir().join(format!( "vamp-diff-session-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed), )); fs::create_dir_all(root.join("repo")).unwrap(); Self(root) } fn git(&self, args: &[&str]) -> String { let output = Command::new("git") .args(args) .current_dir(self.0.join("repo")) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_AUTHOR_NAME", "Diff Fixture") .env("GIT_AUTHOR_EMAIL", "fixture@example.invalid") .env("GIT_COMMITTER_NAME", "Diff Fixture") .env("GIT_COMMITTER_EMAIL", "fixture@example.invalid") .env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z") .env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z") .output() .unwrap(); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap().trim().to_owned() } } impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) { let deadline = Instant::now() + Duration::from_secs(10); while !ready(session) { session.poll(); assert!( Instant::now() < deadline, "diff state: {:?}", session.editor().diff() ); if let Some(diff) = session.editor().diff() { assert!(diff.error.is_none(), "{:?}", diff.error); } thread::sleep(Duration::from_millis(1)); } } fn type_query(session: &mut EditorSession, text: &str) { for ch in text.chars() { session.handle(Input::Char(ch)); } } #[test] fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close() { let fixture = Fixture::new(); fixture.git(&["init", "-q"]); let source = fixture.0.join("repo/buffer.rs"); fs::write(&source, "before\n").unwrap(); fixture.git(&["add", "buffer.rs"]); fixture.git(&["commit", "-qm", "base"]); let base = fixture.git(&["rev-parse", "HEAD"]); fs::write(&source, "after\n").unwrap(); let config = fixture.0.join("config.rb"); fs::write( &config, "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'leader d', config.core.diff\nend\n", ) .unwrap(); let mut session = EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap(); assert!(session.editor().startup_diagnostic().is_none()); for input in [Input::Char('i'), Input::Char('u'), Input::Escape] { session.handle(input); } let cursor = session.editor().cursor(); session.handle(Input::Char(' ')); session.handle(Input::Char('d')); wait_until(&mut session, |session| { session .editor() .diff() .is_some_and(|diff| diff.catalog.is_some() && !diff.loading) }); type_query(&mut session, &base); session.handle(Input::Down); session.handle(Input::Enter); assert_eq!(session.editor().diff().unwrap().phase, Phase::After); session.handle(Input::Enter); assert_eq!( session.editor().diff().unwrap().comparison_mode, ComparisonMode::Snapshots ); wait_until(&mut session, |session| { session .editor() .diff() .is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded()) }); let comparison = session .editor() .diff() .unwrap() .comparison .as_ref() .unwrap(); assert_eq!(comparison.files.len(), 1); let file = &comparison.files[0]; assert_eq!(file.path, Path::new("buffer.rs")); assert_eq!(file.before, ["before"]); assert_eq!(file.after, ["uafter", ""]); session.handle(Input::Char('\t')); assert_eq!( session.editor().diff().unwrap().focus, vamp::diff::Focus::Before ); for input in [ Input::Char('i'), Input::Char('x'), Input::Paste("overwrite".into()), ] { session.handle(input); } session.handle(Input::Escape); assert!(session.editor().diff().is_some()); assert_eq!(session.editor().context().text, "before\n"); assert_eq!(fs::read_to_string(&source).unwrap(), "after\n"); session.handle(Input::Char('\t')); assert_eq!( session.editor().diff().unwrap().focus, vamp::diff::Focus::After ); assert_eq!(session.editor().context().text, "uafter\n"); assert_eq!(session.editor().cursor(), cursor); for input in [Input::Char('i'), Input::Char('x'), Input::Escape] { session.handle(input); } assert_eq!(session.editor().context().text, "xuafter\n"); assert!(session.editor().diff().is_some()); session.handle(Input::Char('u')); assert_eq!(session.editor().context().text, "uafter\n"); session.handle(Input::Char('U')); assert_eq!(session.editor().context().text, "xuafter\n"); session.execute("w"); assert_eq!(fs::read_to_string(&source).unwrap(), "xuafter\n"); assert!(!session.editor().dirty()); session.handle(Input::Char('u')); assert_eq!(session.editor().context().text, "uafter\n"); assert!(session.editor().dirty()); session.execute("diff.close"); assert!(session.editor().diff().is_none()); assert_eq!(session.editor().context().text, "uafter\n"); session.handle(Input::Char('u')); assert_eq!(session.editor().context().text, "after\n"); assert_eq!(fs::read_to_string(&source).unwrap(), "xuafter\n"); session.execute(":diff"); assert!(session.editor().diff().is_some()); session.execute("diff.close"); session.poll(); assert!(session.editor().diff().is_none()); assert_eq!(fixture.git(&["rev-parse", "HEAD"]), base); assert_eq!(fixture.git(&["status", "--porcelain"]), "M buffer.rs"); } #[test] fn tabs_keep_repository_workers_and_hidden_comparisons_independent() { let first = Fixture::new(); let second = Fixture::new(); for (fixture, before, after) in [ (&first, "first before\n", "first after\n"), (&second, "second before\n", "second after\n"), ] { fixture.git(&["init", "-q"]); fs::write(fixture.0.join("repo/source.txt"), before).unwrap(); fixture.git(&["add", "source.txt"]); fixture.git(&["commit", "-qm", "base"]); fs::write(fixture.0.join("repo/source.txt"), after).unwrap(); } let first_path = first.0.join("repo/source.txt"); let second_path = second.0.join("repo/source.txt"); let config = first.0.join("config.rb"); fs::write( &config, "Vamp.configure do |config|\n config.bind 'ctrl+option+n', config.core.tab.new\n config.bind 'ctrl+option+w', config.core.tab.close\nend\n", ) .unwrap(); let mut session = EditorSession::open(Some(first_path.clone()), vec!["text".into()], Some(&config)).unwrap(); assert!(session.editor().startup_diagnostic().is_none()); session.execute("diff"); session.handle(Input::CtrlAlt('n')); session.execute(&format!("edit {}", second_path.display())); session.execute("diff"); wait_until(&mut session, |session| { session .editor() .diff() .is_some_and(|diff| diff.catalog.is_some() && !diff.loading) }); session.handle(Input::CtrlAlt('h')); assert_eq!(session.editor().tab_index(), 0); wait_until(&mut session, |session| { session .editor() .diff() .is_some_and(|diff| diff.catalog.is_some() && !diff.loading) }); type_query(&mut session, &first.git(&["rev-parse", "HEAD"])); session.handle(Input::Down); session.handle(Input::Enter); session.handle(Input::Enter); session.handle(Input::CtrlAlt('l')); assert_eq!(session.editor().tab_index(), 1); type_query(&mut session, &second.git(&["rev-parse", "HEAD"])); session.handle(Input::Down); session.handle(Input::Enter); session.handle(Input::Enter); wait_until(&mut session, |session| { session .editor() .diff() .is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded()) }); let comparison = session .editor() .diff() .unwrap() .comparison .as_ref() .unwrap(); assert_eq!( comparison.root, second.0.join("repo").canonicalize().unwrap() ); assert_eq!(comparison.files[0].before, ["second before"]); assert_eq!(comparison.files[0].after, ["second after", ""]); session.handle(Input::CtrlAlt('h')); wait_until(&mut session, |session| { session .editor() .diff() .is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded()) }); let comparison = session .editor() .diff() .unwrap() .comparison .as_ref() .unwrap(); assert_eq!( comparison.root, first.0.join("repo").canonicalize().unwrap() ); assert_eq!(comparison.files[0].before, ["first before"]); assert_eq!(comparison.files[0].after, ["first after", ""]); session.handle(Input::CtrlAlt('l')); session.handle(Input::CtrlAlt('w')); assert_eq!(session.editor().tab_count(), 1); assert!(session.editor().diff().is_some()); session.execute("diff.close"); assert_eq!(session.editor().path(), Some(first_path.as_path())); assert_eq!(session.editor().lines(), ["first after", ""]); } #[test] fn editable_diff_navigation_skips_omitted_lines_without_expanding_excerpts() { use vamp::diff::DisplayRow; let fixture = Fixture::new(); fixture.git(&["init", "-q"]); let source = fixture.0.join("repo/context.txt"); let mut lines = (1..=100) .map(|line| format!("line_{line:03} words")) .collect::>(); lines[49].clear(); // Hidden short lines must not clamp the visible caret column. fs::write(&source, format!("{}\n", lines.join("\n"))).unwrap(); fixture.git(&["add", "context.txt"]); fixture.git(&["commit", "-qm", "base"]); let base = fixture.git(&["rev-parse", "HEAD"]); lines[19] = "changed_020 words".into(); lines[79] = "changed_080 words".into(); let text = format!("{}\n", lines.join("\n")); fs::write(&source, &text).unwrap(); let config = fixture.0.join("config.rb"); fs::write(&config, "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\n config.bind 'ctrl+f', config.core.down\n config.bind 'ctrl+b', config.core.up\nend\n").unwrap(); let mut session = EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap(); session.execute("diff"); wait_until(&mut session, |session| { session .editor() .diff() .is_some_and(|diff| diff.catalog.is_some() && !diff.loading) }); type_query(&mut session, &base); session.handle(Input::Down); session.handle(Input::Enter); session.handle(Input::Enter); wait_until(&mut session, |session| { session .editor() .diff() .is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded()) }); session.handle(Input::BackTab); let shown = |session: &EditorSession| { let diff = session.editor().diff().unwrap(); diff.display_rows .iter() .filter_map(|row| match *row { DisplayRow::Line(index) => diff.rows[index].after, DisplayRow::Fold { .. } => None, }) .collect::>() }; let excerpts = vec![18, 19, 20, 78, 79, 80]; assert_eq!(shown(&session), excerpts); assert_eq!(session.editor().cursor().row, 18); type_query(&mut session, "5l"); for (keys, row) in [("2j", 20), ("j", 78), ("k", 20), ("3j", 80), ("5k", 18)] { type_query(&mut session, keys); assert_eq!(session.editor().cursor().row, row, "{keys}"); assert_eq!(session.editor().cursor().column, 5, "{keys}"); assert_eq!(shown(&session), excerpts, "{keys}"); } for (keys, row) in [ ("gb", 80), ("gt", 18), ("2j$w", 78), ("b", 20), ("$2w", 78), ("0B", 20), ] { type_query(&mut session, keys); assert_eq!(session.editor().cursor().row, row, "{keys}"); assert_eq!(shown(&session), excerpts, "{keys}"); } session.handle(Input::Ctrl('f')); assert_eq!(session.editor().cursor().row, 78); session.handle(Input::Ctrl('b')); assert_eq!(session.editor().cursor().row, 20); type_query(&mut session, "Vj"); assert_eq!(session.editor().cursor().row, 78); session.handle(Input::Escape); session.handle(Input::Ctrl('k')); assert_eq!( session .editor() .carets() .iter() .map(|caret| caret.row) .collect::>(), [20, 78] ); type_query(&mut session, "j"); assert_eq!( session .editor() .carets() .iter() .map(|caret| caret.row) .collect::>(), [78, 79] ); session.handle(Input::Escape); type_query(&mut session, "gt2j$v"); session.handle(Input::Ctrl('a')); assert_eq!( session .editor() .carets() .iter() .map(|caret| caret.row) .collect::>(), excerpts ); assert_eq!(shown(&session), excerpts); session.handle(Input::Escape); session.handle(Input::Escape); type_query(&mut session, "/line_040"); session.handle(Input::Enter); assert_eq!(session.editor().cursor().row, 20); type_query(&mut session, "/line_079"); session.handle(Input::Enter); assert_eq!(session.editor().cursor().row, 78); assert_eq!(shown(&session), excerpts); type_query(&mut session, "gt2ji"); session.handle(Input::Down); assert_eq!(session.editor().cursor().row, 78); session.handle(Input::Up); assert_eq!(session.editor().cursor().row, 20); session.handle(Input::Escape); assert_eq!(shown(&session), excerpts); assert_eq!(session.editor().context().text, text); assert!(!session.editor().dirty()); assert_eq!(fs::read_to_string(&source).unwrap(), text); type_query(&mut session, "["); assert_eq!(shown(&session), [16, 17, 18, 19, 20, 78, 79, 80]); type_query(&mut session, "]"); assert_eq!(shown(&session), [16, 17, 18, 19, 20, 21, 22, 78, 79, 80]); } #[test] fn readonly_jump_history_restores_the_comparison_file_and_source_cursor() { let fixture = Fixture::new(); fixture.git(&["init", "-q"]); let root = fixture.0.join("repo"); for name in ["first", "second"] { let mut lines = (1..=40) .map(|row| format!("// {name}_stable_{row:03}")) .collect::>(); lines[19] = format!("// {name}_before"); fs::write(root.join(format!("{name}.rs")), lines.join("\n") + "\n").unwrap(); } fixture.git(&["add", "."]); fixture.git(&["commit", "-qm", "base"]); for name in ["first", "second"] { let path = root.join(format!("{name}.rs")); let text = fs::read_to_string(&path).unwrap(); fs::write(path, text.replace("_before", "_after")).unwrap(); } let mut session = EditorSession::open(Some(root.join("first.rs")), vec![], None).unwrap(); session.execute("diff"); wait_until(&mut session, |session| { !session.editor().diff().unwrap().loading }); for input in [Input::Down, Input::Enter, Input::Enter] { session.handle(input); } wait_until(&mut session, |session| { session.editor().diff().unwrap().selected_file_loaded() }); session.handle(Input::Char('\t')); let first_origin = session.editor().cursor(); type_query(&mut session, "/first_before"); session.handle(Input::Enter); for input in [ Input::Char(' '), Input::Char('e'), Input::Home, Input::Down, Input::Enter, ] { session.handle(input); } wait_until(&mut session, |session| { session.editor().diff().unwrap().selected_file_loaded() }); let second_origin = session.editor().cursor(); type_query(&mut session, "/second_before"); session.handle(Input::Enter); session.handle(Input::Ctrl('o')); assert_eq!(session.editor().cursor(), second_origin); session.handle(Input::Ctrl('o')); assert_eq!(session.editor().diff().unwrap().file, 0); assert_eq!(session.editor().cursor(), first_origin); assert!(session.editor().context().text.contains("first_before")); assert!(!session.editor().context().text.contains("second_before")); session.execute("diff.close"); assert_eq!( session.editor().context().text, fs::read_to_string(root.join("first.rs")).unwrap() ); } #[test] fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips() { let fixture = Fixture::new(); let root = fixture.0.join("repo"); fixture.git(&["init", "-q"]); fs::write(root.join("common"), "common\n").unwrap(); fixture.git(&["add", "."]); fixture.git(&["commit", "-qm", "common"]); let base = fixture.git(&["rev-parse", "HEAD"]); fs::write(root.join("base-only"), "base branch\n").unwrap(); fixture.git(&["add", "."]); fixture.git(&["commit", "-qm", "base only"]); let before = fixture.git(&["rev-parse", "HEAD"]); fixture.git(&["checkout", "-qb", "feature", &base]); fs::write(root.join("feature-only"), "feature branch\n").unwrap(); fixture.git(&["add", "."]); fixture.git(&["commit", "-qm", "feature only"]); let after = fixture.git(&["rev-parse", "HEAD"]); let config = fixture.0.join("config.rb"); fs::write(&config, "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'ctrl+b', config.core.diff.mode.toggle\nend\n").unwrap(); let mut session = EditorSession::open(Some(root.join("common")), vec![], Some(&config)).unwrap(); session.execute("diff"); wait_until(&mut session, |session| { !session.editor().diff().unwrap().loading }); assert_eq!( session.editor().diff().unwrap().comparison_mode, ComparisonMode::BranchChanges ); session.handle(Input::Ctrl('b')); assert_eq!( session.editor().diff().unwrap().comparison_mode, ComparisonMode::Snapshots ); session.handle(Input::Ctrl('p')); type_query(&mut session, "diff.mode.toggle"); session.handle(Input::Enter); assert_eq!( session.editor().diff().unwrap().comparison_mode, ComparisonMode::BranchChanges ); for id in [&before, &after] { type_query(&mut session, id); session.handle(Input::Down); session.handle(Input::Enter); } wait_until(&mut session, |session| { session.editor().diff().unwrap().selected_file_loaded() }); let diff = session.editor().diff().unwrap(); let comparison = diff.comparison.as_ref().unwrap(); assert_eq!(comparison.files.len(), 1); assert_eq!(comparison.files[0].path, Path::new("feature-only")); assert_eq!(session.editor().context().text, "feature branch\n"); assert!(diff.after_editable()); assert_eq!( comparison.before, format!("merge base of {before} and {after} ยท {base}") ); assert_eq!(diff.before.as_ref().unwrap().label, before); session.execute("diff.mode.toggle"); wait_until(&mut session, |session| { session.editor().diff().unwrap().selected_file_loaded() }); let comparison = session .editor() .diff() .unwrap() .comparison .as_ref() .unwrap(); assert_eq!(comparison.before, before); assert_eq!(comparison.after, after); assert_eq!( comparison .files .iter() .map(|file| file.path.as_path()) .collect::>(), [Path::new("base-only"), Path::new("feature-only")] ); // Superseding an in-flight recomparison must not restore the old mode's result. session.execute("diff.mode.toggle"); session.execute("diff.mode.toggle"); session.execute("diff.mode.toggle"); wait_until(&mut session, |session| { session.editor().diff().unwrap().selected_file_loaded() }); let diff = session.editor().diff().unwrap(); assert_eq!(diff.comparison_mode, ComparisonMode::BranchChanges); assert_eq!(diff.comparison.as_ref().unwrap().files.len(), 1); assert_eq!(diff.before.as_ref().unwrap().label, before); assert_eq!(diff.after.as_ref().unwrap().label, after); session.execute("diff.close"); session.execute("diff"); wait_until(&mut session, |session| { !session.editor().diff().unwrap().loading }); session.handle(Input::Enter); let diff = session.editor().diff().unwrap(); assert_eq!(diff.phase, Phase::After); assert_eq!(diff.comparison_mode, ComparisonMode::Snapshots); session.execute("diff.mode.toggle"); assert_eq!( session.editor().diff().unwrap().comparison_mode, ComparisonMode::Snapshots ); assert_eq!( session.editor().message(), "Working checkout requires Snapshots" ); }