use std::time::Instant;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::*;
use crate::app::{AppEffect, RepositoryTabDrag, RepositoryTabMenu};
use crate::integration::Client;
use crate::ssh::SshMachine;
use crate::tabs::{RepositoryTabs, TabId};
fn app_with_tabs(count: usize, active: usize) -> (App, Vec<TabId>) {
let mut tabs = RepositoryTabs::new("project-0", "/repo-0", ());
let mut ids = vec![tabs.active_id().expect("initial tab")];
for index in 1..count {
ids.push(tabs.append(format!("project-{index}"), format!("/repo-{index}"), ()));
}
assert!(tabs.activate(ids[active]));
let mut app = App::new("/repo-0", "project-0");
app.set_repository_tabs(tabs.infos());
(app, ids)
}
#[test]
fn repository_tab_hits_follow_repository_order() {
let (mut app, ids) = app_with_tabs(3, 1);
let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &mut app, &Theme::default()))
.unwrap();
let hits = &app.geometry.repository_tab_hits;
assert_eq!(hits.iter().map(|hit| hit.id).collect::<Vec<_>>(), ids);
assert!(
hits.iter()
.all(|hit| hit.area.y == 0 && hit.area.height == 1)
);
assert!(hits.iter().all(|hit| {
hit.close.width == 3
&& hit.close.right() == hit.area.right().saturating_sub(1)
&& hit.area.contains((hit.close.x, hit.close.y).into())
}));
assert!(hits.windows(2).all(|pair| {
let [left, right] = pair else {
return false;
};
left.area.right() <= right.area.x
}));
assert_eq!(app.geometry.repository_tab_open.right(), 120);
assert!(
hits.last()
.is_some_and(|hit| hit.area.right() <= app.geometry.repository_tab_open.x)
);
}
#[test]
fn repository_tabs_render_close_icons_and_a_drag_destination() {
let (mut app, ids) = app_with_tabs(3, 0);
app.repository_tab_drag = Some(RepositoryTabDrag {
id: ids[0],
target: Some(ids[2]),
});
let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &mut app, &Theme::default()))
.unwrap();
let row = terminal.backend().buffer().content()[..120]
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert_eq!(row.matches('×').count(), 3);
assert!(row.contains('▏'));
}
#[test]
fn edith_client_hides_repository_tab_close_icons() {
let (mut app, _) = app_with_tabs(1, 0);
app.set_host_client(Some(Client::Edith));
let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &mut app, &Theme::default()))
.unwrap();
let row = terminal.backend().buffer().content()[..120]
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert!(!row.contains('×'));
assert!(
app.geometry
.repository_tab_hits
.iter()
.all(|hit| hit.close.is_empty())
);
}
#[test]
fn mixed_repository_tabs_show_their_owning_machine() {
let mut app = App::new("/repo", "one");
app.set_repository_tabs(vec![
crate::tabs::TabInfo {
id: TabId::new(0),
title: "one".to_owned(),
root: "/local/one".into(),
machine: Some("macbook.local".to_owned()),
active: true,
},
crate::tabs::TabInfo {
id: TabId::new(1),
title: "two".to_owned(),
root: "/remote/two".into(),
machine: Some("tof".to_owned()),
active: false,
},
]);
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &mut app, &Theme::default()))
.unwrap();
let row = terminal.backend().buffer().content()[..80]
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert!(row.contains("one @macbook"));
assert!(row.contains("two @tof"));
}
#[test]
fn repository_tab_strip_has_a_continuous_bottom_separator() {
let (mut app, _) = app_with_tabs(2, 1);
let theme = Theme::default();
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &mut app, &theme))
.unwrap();
for column in 0..80 {
let cell = &terminal.backend().buffer()[(column, 1)];
assert_eq!(cell.symbol(), "─");
assert_eq!(cell.fg, theme.border);
}
assert_eq!(app.geometry.changes_tab.y, 2);
}
#[test]
fn repository_tab_strip_appears_only_for_multiple_tabs() {
let (mut single, _) = app_with_tabs(1, 0);
let mut single_terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
single_terminal
.draw(|frame| draw(frame, &mut single, &Theme::default()))
.unwrap();
assert_eq!(single.geometry.repository_tab_hits, Vec::new());
assert_eq!(single.geometry.repository_tab_open, Rect::default());
assert_eq!(single.geometry.repository_tab_previous, Rect::default());
assert_eq!(single.geometry.repository_tab_next, Rect::default());
assert_eq!(single.geometry.changes_tab.y, 0);
let (mut multiple, ids) = app_with_tabs(2, 1);
let mut multiple_terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
multiple_terminal
.draw(|frame| draw(frame, &mut multiple, &Theme::default()))
.unwrap();
assert_eq!(
multiple
.geometry
.repository_tab_hits
.iter()
.map(|hit| hit.id)
.collect::<Vec<_>>(),
ids
);
assert_eq!(
multiple.geometry.repository_tab_open,
Rect::new(75, 0, 5, 1)
);
assert_eq!(multiple.geometry.repository_tab_previous, Rect::default());
assert_eq!(multiple.geometry.repository_tab_next, Rect::default());
assert_eq!(multiple.geometry.changes_tab.y, 2);
}
#[test]
fn overflowing_repository_tabs_keep_the_active_tab_visible() {
let (mut app, ids) = app_with_tabs(10, 8);
let mut terminal = Terminal::new(TestBackend::new(72, 18)).unwrap();
terminal
.draw(|frame| draw(frame, &mut app, &Theme::default()))
.unwrap();
let visible = app
.geometry
.repository_tab_hits
.iter()
.map(|hit| hit.id)
.collect::<Vec<_>>();
assert_eq!(visible, ids[4..9]);
assert!(visible.contains(&ids[8]));
assert!(!visible.contains(&ids[9]));
assert_eq!(app.geometry.repository_tab_previous, Rect::new(0, 0, 3, 1));
assert_eq!(app.geometry.repository_tab_next, Rect::new(64, 0, 3, 1));
}
#[test]
fn repository_tab_menu_registers_four_clamped_actions() {
let (mut app, ids) = app_with_tabs(2, 0);
app.repository_tab_menu = Some(RepositoryTabMenu {
id: ids[0],
column: 79,
row: 23,
selected: 2,
});
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &mut app, &Theme::default()))
.unwrap();
let hits = &app.geometry.repository_tab_menu_hits;
assert_eq!(hits.len(), RepositoryTabAction::ALL.len());
assert_eq!(
hits.iter().map(|(_, action)| *action).collect::<Vec<_>>(),
RepositoryTabAction::ALL.to_vec()
);
assert_eq!(
hits.first().map(|(area, _)| *area),
Some(Rect::new(59, 19, 20, 1))
);
assert_eq!(
hits.last().map(|(area, _)| *area),
Some(Rect::new(59, 22, 20, 1))
);
assert!(
hits.iter()
.all(|(area, _)| area.right() <= 80 && area.bottom() <= 24)
);
}
#[test]
fn new_tab_picker_names_its_destination() {
let mut app = App::new("/repo", "project");
drop(app.open_projects_on_launch(ProjectOpenMode::NewTab));
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &mut app, &Theme::default()))
.unwrap();
let rendered = terminal
.backend()
.buffer()
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert!(rendered.contains("Open in new tab"));
}
#[test]
fn project_picker_keeps_host_and_ssh_navigation_in_one_layout() {
let mut app = App::new("/repo", "project");
app.ssh_context = Some(SshContext {
current: "current-host".to_owned(),
machines: vec![
SshMachine {
target: "Pulkits-MacBook-Pro.local".to_owned(),
folder: "/host".into(),
accessible: true,
uses: 0,
local: true,
},
SshMachine {
target: "busy-host".to_owned(),
folder: "/busy".into(),
accessible: true,
uses: 14,
local: false,
},
SshMachine {
target: "current-host".to_owned(),
folder: "/current".into(),
accessible: true,
uses: 3,
local: false,
},
],
tabs: crate::ssh::SshTabs::default(),
probing: false,
});
let now = Instant::now();
let mut terminal = Terminal::new(TestBackend::new(120, 30)).unwrap();
drop(app.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE), now));
terminal
.draw(|frame| draw(frame, &mut app, &Theme::default()))
.unwrap();
let rendered = terminal.backend().to_string();
assert!(rendered.contains("Switch project"));
assert!(rendered.contains("Machine"));
assert!(rendered.contains("Pulkits-MacBook-Pro.local"));
assert!(rendered.contains("busy-host"));
assert!(rendered.contains("current-host"));
assert!(rendered.contains("Tab machine"));
assert!(rendered.contains("Esc close"));
assert!(!rendered.contains("choose machine"));
assert!(
app.geometry
.modal_action_hits
.iter()
.any(|(_, action)| *action == ModalAction::SwitchSshMachine(1))
);
drop(app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), now));
drop(app.open_projects_on_launch(ProjectOpenMode::NewTab));
terminal
.draw(|frame| draw(frame, &mut app, &Theme::default()))
.unwrap();
let rendered = terminal.backend().to_string();
assert!(rendered.contains("Open in new tab"));
assert!(rendered.contains("Pulkits-MacBook-Pro.local"));
assert!(rendered.contains("current-host"));
let button = app
.geometry
.modal_action_hits
.iter()
.find(|(_, action)| *action == ModalAction::SwitchSshMachine(1))
.map(|(area, _)| *area)
.expect("SSH machine chip");
let effects = app.handle_mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: button.x,
row: button.y,
modifiers: KeyModifiers::NONE,
},
now,
);
assert!(matches!(
effects.as_slice(),
[AppEffect::SwitchSshMachine(crate::ssh::SshSwitch {
index: 1,
mode: crate::ssh::SshProjectOpenMode::New,
})]
));
assert!(matches!(app.modal, Some(Modal::Projects { .. })));
}
#[test]
fn project_picker_shows_pending_machine_checks_without_hiding_the_footer() {
let mut app = App::new("/repo", "project");
app.ssh_context = Some(SshContext {
current: "host".to_owned(),
machines: vec![
SshMachine {
target: "host".to_owned(),
folder: "/repo".into(),
accessible: true,
uses: 0,
local: true,
},
SshMachine {
target: "remote".to_owned(),
folder: "/remote".into(),
accessible: false,
uses: 1,
local: false,
},
],
tabs: crate::ssh::SshTabs::default(),
probing: true,
});
drop(app.open_projects_on_launch(ProjectOpenMode::NewTab));
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &mut app, &Theme::default()))
.unwrap();
let rendered = terminal.backend().to_string();
assert!(rendered.contains("◌ remote"));
assert!(rendered.contains("Esc close"));
}
#[test]
fn project_picker_shows_the_destination_while_opening() {
let mut app = App::new("/repo", "project");
app.modal = Some(Modal::Projects {
groups: Vec::new(),
selected: 0,
query: crate::app::TextBuffer::default(),
collapsed: HashSet::new(),
loading: false,
opening: Some("/Users/pulkit/scripts/quinjet-feature".into()),
mode: ProjectOpenMode::CurrentTab,
});
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
terminal
.draw(|frame| draw(frame, &mut app, &Theme::default()))
.unwrap();
let rendered = terminal.backend().to_string();
assert!(rendered.contains("Opening project…"));
assert!(rendered.contains("/Users/pulkit/scripts/quinjet-feature"));
assert!(!rendered.contains("Enter switch tab"));
}