use crossterm::event::{Event, KeyEvent, MouseEvent};
use super::*;
use crate::core::diff::DiffHunk;
use crate::core::graph::Theme;
use crate::tui::theme::TuiTheme;
fn make_hunk(text: &str) -> DiffHunk {
DiffHunk {
text: text.to_string(),
modified_lines: vec![],
}
}
fn make_theme() -> TuiTheme {
TuiTheme::from_graph_theme(&Theme::dark())
}
fn make_files() -> Vec<FileEntry> {
vec![
FileEntry {
path: "main.rs".to_string(),
hunks: vec![
HunkEntry {
hunk: make_hunk("@@ -1,3 +1,4 @@\n context\n-old\n+new\n"),
selected: true,
origin: HunkOrigin::Staged,
},
HunkEntry {
hunk: make_hunk("@@ -10,2 +11,3 @@\n context\n+added\n"),
selected: false,
origin: HunkOrigin::Unstaged,
},
],
index_status: 'M',
worktree_status: 'M',
binary: false,
},
FileEntry {
path: "lib.rs".to_string(),
hunks: vec![HunkEntry {
hunk: make_hunk("@@ -5,2 +5,2 @@\n-old line\n+new line\n"),
selected: false,
origin: HunkOrigin::Unstaged,
}],
index_status: ' ',
worktree_status: 'M',
binary: false,
},
]
}
fn make_files_in_dir() -> Vec<FileEntry> {
vec![
FileEntry {
path: "src/main.rs".to_string(),
hunks: vec![HunkEntry {
hunk: make_hunk("@@ -1,1 +1,1 @@\n-a\n+b\n"),
selected: true,
origin: HunkOrigin::Staged,
}],
index_status: 'M',
worktree_status: ' ',
binary: false,
},
FileEntry {
path: "src/lib.rs".to_string(),
hunks: vec![HunkEntry {
hunk: make_hunk("@@ -1,1 +1,1 @@\n-x\n+y\n"),
selected: false,
origin: HunkOrigin::Unstaged,
}],
index_status: ' ',
worktree_status: 'M',
binary: false,
},
]
}
fn make_files_mixed() -> Vec<FileEntry> {
vec![
FileEntry {
path: "README.md".to_string(),
hunks: vec![HunkEntry {
hunk: make_hunk("@@ -1,1 +1,1 @@\n-a\n+b\n"),
selected: false,
origin: HunkOrigin::Unstaged,
}],
index_status: ' ',
worktree_status: 'M',
binary: false,
},
FileEntry {
path: "src/main.rs".to_string(),
hunks: vec![HunkEntry {
hunk: make_hunk("@@ -1,1 +1,1 @@\n-a\n+b\n"),
selected: true,
origin: HunkOrigin::Staged,
}],
index_status: 'M',
worktree_status: ' ',
binary: false,
},
FileEntry {
path: "src/lib.rs".to_string(),
hunks: vec![HunkEntry {
hunk: make_hunk("@@ -1,1 +1,1 @@\n-x\n+y\n"),
selected: false,
origin: HunkOrigin::Unstaged,
}],
index_status: ' ',
worktree_status: 'M',
binary: false,
},
]
}
#[test]
fn new_initializes_correctly() {
let files = make_files();
let app = HunkSelectorApp::new(files, make_theme());
assert_eq!(app.list.cursor(), 0);
assert_eq!(app.hunks.index(), 0);
assert_eq!(app.hunks.scroll(), 0);
}
#[test]
fn navigate_files_in_left_pane() {
let mut app = HunkSelectorApp::new(make_files(), make_theme());
assert_eq!(app.list.cursor(), 0);
app.navigate_down(PaneId::Left);
assert_eq!(app.list.cursor(), 1);
app.navigate_down(PaneId::Left);
assert_eq!(app.list.cursor(), 1);
app.navigate_up(PaneId::Left);
assert_eq!(app.list.cursor(), 0);
app.navigate_up(PaneId::Left);
assert_eq!(app.list.cursor(), 0);
}
#[test]
fn navigate_hunks_in_right_pane() {
let mut app = HunkSelectorApp::new(make_files(), make_theme());
assert_eq!(app.list.cursor(), 0);
assert_eq!(app.hunks.index(), 0);
app.navigate_down(PaneId::Right);
assert_eq!(app.hunks.index(), 1);
app.navigate_down(PaneId::Right);
assert_eq!(app.list.cursor(), 1);
assert_eq!(app.hunks.index(), 0);
app.navigate_down(PaneId::Right);
assert_eq!(app.list.cursor(), 1);
assert_eq!(app.hunks.index(), 0);
app.navigate_up(PaneId::Right);
assert_eq!(app.list.cursor(), 0);
assert_eq!(app.hunks.index(), 1);
app.navigate_up(PaneId::Right);
assert_eq!(app.hunks.index(), 0);
app.navigate_up(PaneId::Right);
assert_eq!(app.list.cursor(), 0);
assert_eq!(app.hunks.index(), 0);
}
#[test]
fn navigate_hunks_cross_file_with_dir_headers() {
let mut app = HunkSelectorApp::new(make_files_in_dir(), make_theme());
assert_eq!(app.list.cursor(), 0);
assert!(app.current_file_index().is_none());
app.navigate_down(PaneId::Left);
assert_eq!(app.list.cursor(), 1);
app.navigate_down(PaneId::Right);
assert_eq!(app.list.cursor(), 2);
assert_eq!(app.current_file_index(), Some(1));
assert_eq!(app.hunks.index(), 0);
app.navigate_up(PaneId::Right);
assert_eq!(app.list.cursor(), 1);
assert_eq!(app.current_file_index(), Some(0));
assert_eq!(app.hunks.index(), 0); }
#[test]
fn toggle_hunk_in_right_pane() {
let mut app = HunkSelectorApp::new(make_files(), make_theme());
assert!(app.files[0].hunks[0].selected);
app.toggle(PaneId::Right);
assert!(!app.files[0].hunks[0].selected);
app.toggle(PaneId::Right);
assert!(app.files[0].hunks[0].selected);
}
#[test]
fn toggle_file_in_left_pane() {
let mut app = HunkSelectorApp::new(make_files(), make_theme());
assert!(app.files[0].hunks[0].selected);
assert!(!app.files[0].hunks[1].selected);
app.toggle(PaneId::Left); assert!(app.files[0].hunks.iter().all(|h| !h.selected));
app.toggle(PaneId::Left); assert!(app.files[0].hunks.iter().all(|h| h.selected));
}
#[test]
fn quit_key_cancels_via_shell() {
let mut shell = Shell::new(HunkSelectorApp::new(make_files(), make_theme()));
let exit = shell.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::NONE,
)));
assert!(matches!(exit, Some(Verdict::Cancel)));
}
#[test]
fn c_does_not_confirm() {
let mut app = HunkSelectorApp::new(make_files(), make_theme());
let result = app.handle_key(PaneId::Left, KeyCode::Char('c'), KeyModifiers::NONE);
assert!(matches!(result, KeyResult::Handled));
}
#[test]
fn help_popup_owns_the_keyboard_until_closed() {
let mut app = HunkSelectorApp::new(make_files(), make_theme());
app.handle_key(PaneId::Left, KeyCode::Char('?'), KeyModifiers::NONE);
assert!(app.modal_active());
let result = app.handle_key(PaneId::Left, KeyCode::Enter, KeyModifiers::NONE);
assert!(
matches!(result, KeyResult::Handled),
"Enter does not confirm under the help"
);
let result = app.handle_key(PaneId::Left, KeyCode::Char('q'), KeyModifiers::NONE);
assert!(
matches!(result, KeyResult::Handled),
"q closes the help, not the selector"
);
assert!(!app.modal_active());
let mut shell = Shell::new(HunkSelectorApp::new(make_files(), make_theme()));
shell.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('?'),
KeyModifiers::NONE,
)));
let exit = shell.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL,
)));
assert!(
matches!(exit, Some(Verdict::Cancel)),
"Ctrl-C still leaves under the help"
);
}
#[test]
fn enter_confirms() {
let mut app = HunkSelectorApp::new(make_files(), make_theme());
let result = app.handle_key(PaneId::Left, KeyCode::Enter, KeyModifiers::NONE);
assert!(matches!(result, KeyResult::Exit(Verdict::Confirm)));
}
#[test]
fn switching_file_resets_hunk_and_scroll() {
let mut app = HunkSelectorApp::new(make_files(), make_theme());
app.navigate_down(PaneId::Right); assert_eq!(app.hunks.index(), 1);
assert!(app.hunks.scroll() > 0);
app.navigate_down(PaneId::Left);
assert_eq!(app.list.cursor(), 1);
assert_eq!(app.hunks.index(), 0);
assert_eq!(app.hunks.scroll(), 0);
}
#[test]
fn empty_files_returns_none() {
let result = run_hunk_selector(vec![], make_theme()).unwrap();
assert!(result.is_none());
}
#[test]
fn ctrl_c_cancels_via_shell() {
let mut shell = Shell::new(HunkSelectorApp::new(make_files(), make_theme()));
let exit = shell.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL,
)));
assert!(matches!(exit, Some(Verdict::Cancel)));
}
#[test]
fn esc_cancels() {
let mut app = HunkSelectorApp::new(make_files(), make_theme());
let result = app.handle_key(PaneId::Left, KeyCode::Esc, KeyModifiers::NONE);
assert!(matches!(result, KeyResult::Exit(Verdict::Cancel)));
}
#[test]
fn hunk_origin_preserved_through_toggle() {
let mut app = HunkSelectorApp::new(make_files(), make_theme());
assert_eq!(app.files[0].hunks[0].origin, HunkOrigin::Staged);
app.toggle(PaneId::Right);
assert_eq!(app.files[0].hunks[0].origin, HunkOrigin::Staged);
assert!(!app.files[0].hunks[0].selected);
}
#[test]
fn effective_status_staged_only_deselect_some() {
let file = FileEntry {
path: "f.rs".into(),
hunks: vec![
HunkEntry {
hunk: make_hunk("@@ -1,1 +1,1 @@\n-a\n+b\n"),
selected: true,
origin: HunkOrigin::Staged,
},
HunkEntry {
hunk: make_hunk("@@ -10,1 +10,1 @@\n-c\n+d\n"),
selected: false, origin: HunkOrigin::Staged,
},
],
index_status: 'M',
worktree_status: ' ',
binary: false,
};
assert_eq!(file.effective_status(), ('M', 'M'));
}
#[test]
fn effective_status_staged_only_deselect_all() {
let file = FileEntry {
path: "f.rs".into(),
hunks: vec![HunkEntry {
hunk: make_hunk("@@ -1,1 +1,1 @@\n-a\n+b\n"),
selected: false,
origin: HunkOrigin::Staged,
}],
index_status: 'M',
worktree_status: ' ',
binary: false,
};
assert_eq!(file.effective_status(), (' ', 'M'));
}
#[test]
fn effective_status_unstaged_only_select_all() {
let file = FileEntry {
path: "f.rs".into(),
hunks: vec![HunkEntry {
hunk: make_hunk("@@ -1,1 +1,1 @@\n-a\n+b\n"),
selected: true,
origin: HunkOrigin::Unstaged,
}],
index_status: ' ',
worktree_status: 'M',
binary: false,
};
assert_eq!(file.effective_status(), ('M', ' '));
}
#[test]
fn effective_status_untracked_select() {
let file = FileEntry {
path: "new.rs".into(),
hunks: vec![HunkEntry {
hunk: make_hunk("@@ -0,0 +1,1 @@\n+new\n"),
selected: true,
origin: HunkOrigin::Unstaged,
}],
index_status: '?',
worktree_status: '?',
binary: false,
};
assert_eq!(file.effective_status(), ('A', ' '));
}
#[test]
fn effective_status_untracked_no_select() {
let file = FileEntry {
path: "new.rs".into(),
hunks: vec![HunkEntry {
hunk: make_hunk("@@ -0,0 +1,1 @@\n+new\n"),
selected: false,
origin: HunkOrigin::Unstaged,
}],
index_status: '?',
worktree_status: '?',
binary: false,
};
assert_eq!(file.effective_status(), ('?', '?'));
}
#[test]
fn effective_status_new_file_deselect() {
let file = FileEntry {
path: "new.rs".into(),
hunks: vec![HunkEntry {
hunk: make_hunk("@@ -0,0 +1,1 @@\n+new\n"),
selected: false,
origin: HunkOrigin::Staged,
}],
index_status: 'A',
worktree_status: ' ',
binary: false,
};
assert_eq!(file.effective_status(), ('?', '?'));
}
#[test]
fn effective_status_deletion_deselect() {
let file = FileEntry {
path: "old.rs".into(),
hunks: vec![HunkEntry {
hunk: make_hunk("(file deleted)"),
selected: false,
origin: HunkOrigin::Staged,
}],
index_status: 'D',
worktree_status: ' ',
binary: false,
};
assert_eq!(file.effective_status(), (' ', 'D'));
}
#[test]
fn display_rows_root_files_no_headers() {
let files = make_files();
let rows = build_display_rows(&files);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0], DisplayRow::File(0));
assert_eq!(rows[1], DisplayRow::File(1));
}
#[test]
fn display_rows_dir_files_have_header() {
let files = make_files_in_dir();
let rows = build_display_rows(&files);
assert_eq!(rows.len(), 3);
assert_eq!(
rows[0],
DisplayRow::Directory {
dir_start: 0,
dir_end: 1
}
);
assert_eq!(rows[1], DisplayRow::File(0));
assert_eq!(rows[2], DisplayRow::File(1));
}
#[test]
fn display_rows_mixed_root_and_dir() {
let files = make_files_mixed();
let rows = build_display_rows(&files);
assert_eq!(rows.len(), 4);
assert_eq!(rows[0], DisplayRow::File(0)); assert_eq!(
rows[1],
DisplayRow::Directory {
dir_start: 1,
dir_end: 2
}
);
assert_eq!(rows[2], DisplayRow::File(1)); assert_eq!(rows[3], DisplayRow::File(2)); }
#[test]
fn navigate_through_dir_header() {
let mut app = HunkSelectorApp::new(make_files_in_dir(), make_theme());
assert_eq!(app.list.cursor(), 0);
assert!(app.current_file_index().is_none());
app.navigate_down(PaneId::Left);
assert_eq!(app.list.cursor(), 1);
assert_eq!(app.current_file_index(), Some(0));
app.navigate_down(PaneId::Left);
assert_eq!(app.list.cursor(), 2);
assert_eq!(app.current_file_index(), Some(1));
app.navigate_down(PaneId::Left);
assert_eq!(app.list.cursor(), 2);
}
#[test]
fn toggle_directory_toggles_all_files() {
let mut app = HunkSelectorApp::new(make_files_in_dir(), make_theme());
assert!(app.files[0].hunks[0].selected);
assert!(!app.files[1].hunks[0].selected);
app.toggle(PaneId::Left);
assert!(!app.files[0].hunks[0].selected);
assert!(!app.files[1].hunks[0].selected);
app.toggle(PaneId::Left);
assert!(app.files[0].hunks[0].selected);
assert!(app.files[1].hunks[0].selected);
}
#[test]
fn right_pane_noop_on_dir_header() {
let mut app = HunkSelectorApp::new(make_files_in_dir(), make_theme());
assert_eq!(app.hunks.index(), 0);
app.navigate_down(PaneId::Right);
assert_eq!(app.hunks.index(), 0);
app.toggle(PaneId::Right);
assert!(app.files[0].hunks[0].selected); }
fn make_many_files(n: usize) -> Vec<FileEntry> {
(0..n)
.map(|i| FileEntry {
path: format!("file{:02}.rs", i),
hunks: vec![HunkEntry {
hunk: make_hunk("@@ -1,1 +1,1 @@\n-a\n+b\n"),
selected: false,
origin: HunkOrigin::Unstaged,
}],
index_status: ' ',
worktree_status: 'M',
binary: false,
})
.collect()
}
fn render_shell(shell: &mut Shell<HunkSelectorApp>, width: u16, height: u16) {
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| shell.render(f)).unwrap();
}
fn click(shell: &mut Shell<HunkSelectorApp>, column: u16, row: u16) {
shell.handle_event(Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
}));
}
#[test]
fn mouse_click_accounts_for_list_scroll() {
let mut shell = Shell::new(HunkSelectorApp::new(make_many_files(30), make_theme()));
shell.app.list.set_cursor(29); render_shell(&mut shell, 80, 12);
let offset = shell.app.list.offset();
assert!(offset > 0, "list should be scrolled");
let left = shell.areas()[0];
click(&mut shell, left.x + 1, left.y + 1);
assert_eq!(shell.app.list.cursor(), offset);
}
#[test]
fn mouse_click_on_bottom_border_is_ignored() {
let mut shell = Shell::new(HunkSelectorApp::new(make_many_files(30), make_theme()));
render_shell(&mut shell, 80, 12);
let left = shell.areas()[0];
click(&mut shell, left.x + 1, left.y + left.height - 1);
assert_eq!(shell.app.list.cursor(), 0);
}
#[test]
fn directory_of_extracts_parent() {
assert_eq!(directory_of("src/main.rs"), "src");
assert_eq!(directory_of("a/b/c.rs"), "a/b");
assert_eq!(directory_of("file.rs"), "");
}
#[test]
fn filename_of_extracts_name() {
assert_eq!(filename_of("src/main.rs"), "main.rs");
assert_eq!(filename_of("a/b/c.rs"), "c.rs");
assert_eq!(filename_of("file.rs"), "file.rs");
}
fn title_line(app: HunkSelectorApp, width: u16) -> String {
let mut shell = Shell::new(app);
let backend = ratatui::backend::TestBackend::new(width, 10);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| shell.render(f)).unwrap();
let buffer = terminal.backend().buffer();
(0..width).map(|x| buffer[(x, 0)].symbol()).collect()
}
#[test]
fn the_file_pane_is_titled_with_the_action_the_pick_is_for() {
let mut app = HunkSelectorApp::new(make_files(), make_theme());
app.action = Some("FOLD");
let line = title_line(app, 100);
assert!(line.contains(" FOLD "), "{line}");
assert!(!line.contains(" Files "), "{line}");
let cli = title_line(HunkSelectorApp::new(make_files(), make_theme()), 100);
assert!(cli.contains(" Files "), "{cli}");
}