use std::path::Path;
use std::rc::Rc;
use std::time::Duration;
use std::time::Instant;
use cargo_metadata::PackageId;
use cargo_metadata::TargetKind;
use cargo_metadata::semver::Version;
use crossterm::event::Event;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Position;
use tempfile::TempDir;
use tui_pane::AppContext;
use tui_pane::ClipboardBackend;
use tui_pane::ClipboardError;
use tui_pane::FocusedPane;
use tui_pane::FrameworkFocusId;
use tui_pane::GlobalAction as FrameworkGlobalAction;
use tui_pane::PaneFocusState;
use tui_pane::PaneSelectionState;
use tui_pane::RenderFocus;
use tui_pane::ToastId;
use tui_pane::ToastStyle;
use tui_pane::Viewport;
use crate::ci::CiJob;
use crate::ci::CiRun;
use crate::ci::CiStatus;
use crate::ci::FetchStatus;
use crate::config::CargoPortConfig;
use crate::config::EdgeScroll;
use crate::config::NavigationKeys;
use crate::lint::LintCommand;
use crate::lint::LintCommandStatus;
use crate::lint::LintRun;
use crate::lint::LintRunStatus;
use crate::project;
use crate::project::AbsolutePath;
use crate::project::Cargo;
use crate::project::CheckoutInfo;
use crate::project::ExampleGroup;
use crate::project::FileStamp;
use crate::project::GitStatus;
use crate::project::HeadState;
use crate::project::ManifestFingerprint;
use crate::project::MemberGroup;
use crate::project::Package;
use crate::project::PackageRecord;
use crate::project::ProjectType;
use crate::project::PublishPolicy;
use crate::project::RemoteInfo;
use crate::project::RemoteKind;
use crate::project::RepoInfo;
use crate::project::RootItem;
use crate::project::RustInfo;
use crate::project::RustProject;
use crate::project::TargetRecord;
use crate::project::Visibility;
use crate::project::WorkflowPresence;
use crate::project::Workspace;
use crate::project::WorkspaceMetadata;
use crate::project::WorktreeGroup;
use crate::project::WorktreeStatus;
use crate::scan::BackgroundMsg;
use crate::scan::DirSizes;
use crate::tui::app::App;
use crate::tui::app::ConfirmAction;
use crate::tui::app::ExpandKey;
use crate::tui::app::HoveredPaneRow;
use crate::tui::app::OverlayRenderInputs;
use crate::tui::finder;
use crate::tui::input;
use crate::tui::integration::AppPaneId;
use crate::tui::integration::NavAction;
use crate::tui::interaction;
use crate::tui::pane::DismissTarget;
use crate::tui::pane::HoverTarget;
use crate::tui::panes;
use crate::tui::panes::LintsData;
use crate::tui::panes::PaneId;
use crate::tui::panes::RunTargetKind;
use crate::tui::panes::SyncedDescriptionHeight;
use crate::tui::panes::TargetsData;
use crate::tui::project_list::ProjectList;
use crate::tui::render;
use crate::tui::running_targets::RunProfile;
use crate::tui::running_targets::RunningInstance;
use crate::tui::running_targets::RunningKey;
use crate::tui::running_targets::RunningTargets;
use crate::tui::settings;
use crate::tui::settings::SettingOption;
use crate::tui::test_support as tui_test_support;
fn open_settings_overlay(app: &mut App) {
let keymap = Rc::clone(&app.framework_keymap);
keymap.dispatch_framework_global(FrameworkGlobalAction::OpenSettings, app);
}
fn open_keymap_overlay(app: &mut App) {
let keymap = Rc::clone(&app.framework_keymap);
keymap.dispatch_framework_global(FrameworkGlobalAction::OpenKeymap, app);
}
fn make_package(name: &str, path: &Path) -> RootItem {
make_package_with_cargo(name, path, Cargo::default())
}
fn make_package_with_cargo(name: &str, path: &Path, cargo: Cargo) -> RootItem {
RootItem::Rust(RustProject::Package(Package {
path: AbsolutePath::from(path),
name: Some(name.to_string()),
rust: RustInfo {
cargo,
..RustInfo::default()
},
..Package::default()
}))
}
fn make_package_worktree(
name: &str,
path: &Path,
is_linked_worktree: bool,
primary_abs_path: Option<&Path>,
) -> Package {
let worktree_status = match (is_linked_worktree, primary_abs_path) {
(true, Some(p)) => WorktreeStatus::Linked {
primary: AbsolutePath::from(p),
},
(false, Some(p)) => WorktreeStatus::Primary {
root: AbsolutePath::from(p),
},
_ => WorktreeStatus::NotGit,
};
Package {
path: AbsolutePath::from(path),
name: Some(name.to_string()),
worktree_status,
..Package::default()
}
}
fn inline_group(members: Vec<Package>) -> MemberGroup { MemberGroup::Inline { members } }
fn make_member(name: &str, path: &Path) -> Package {
Package {
path: AbsolutePath::from(path),
name: Some(name.to_string()),
..Package::default()
}
}
fn make_member_with_cargo(name: &str, path: &Path, cargo: Cargo) -> Package {
Package {
path: AbsolutePath::from(path),
name: Some(name.to_string()),
rust: RustInfo {
cargo,
..RustInfo::default()
},
..Package::default()
}
}
fn make_workspace_with_members(name: &str, path: &Path, groups: Vec<MemberGroup>) -> RootItem {
RootItem::Rust(RustProject::Workspace(Workspace {
path: AbsolutePath::from(path),
name: Some(name.to_string()),
groups,
..Workspace::default()
}))
}
fn make_git_info(url: Option<&str>) -> (CheckoutInfo, RepoInfo) {
let checkout = CheckoutInfo {
status: GitStatus::Clean,
head: HeadState::Branch("main".to_string()),
last_commit: Some("2024-01-02T00:00:00Z".to_string()),
ahead_behind_local: Some((0, 0)),
primary_tracked_ref: Some("origin/main".to_string()),
bisect: None,
};
let repo = RepoInfo {
remotes: vec![RemoteInfo {
name: "origin".to_string(),
url: url.map(str::to_string),
owner: Some("natepiano".to_string()),
repo: Some("demo".to_string()),
tracked_ref: Some("origin/main".to_string()),
ahead_behind: Some((0, 0)),
kind: RemoteKind::Clone,
push: crate::project::PushState::Enabled {
push_url: String::new(),
},
}],
workflows: WorkflowPresence::Present,
first_commit: Some("2024-01-01T00:00:00Z".to_string()),
last_fetched: None,
default_branch: Some("main".to_string()),
local_main_branch: Some("main".to_string()),
};
(checkout, repo)
}
fn make_ci_run(run_id: u64, conclusion: CiStatus) -> CiRun {
CiRun {
run_id,
created_at: "2024-01-01T00:00:00Z".to_string(),
branch: "main".to_string(),
url: format!("https://github.com/natepiano/demo/actions/runs/{run_id}"),
ci_status: conclusion,
jobs: vec![CiJob {
name: "build".to_string(),
ci_status: conclusion,
duration: "1m".to_string(),
duration_secs: Some(60),
}],
wall_clock_secs: Some(60),
commit_title: Some("commit".to_string()),
updated_at: None,
fetched: FetchStatus::Fetched,
}
}
fn make_lint_run(run_id: &str, status: LintRunStatus) -> LintRun {
LintRun {
run_id: run_id.to_string(),
started_at: "2024-01-01T00:00:00Z".to_string(),
finished_at: Some("2024-01-01T00:01:00Z".to_string()),
duration_ms: Some(60_000),
status,
commands: vec![LintCommand {
name: "clippy".to_string(),
command: "cargo clippy".to_string(),
status: LintCommandStatus::Passed,
duration_ms: Some(1_000),
exit_code: Some(0),
log_file: "clippy.log".to_string(),
}],
archive_bytes: 0,
}
}
fn make_app(projects: &[RootItem]) -> App { tui_test_support::make_app(projects) }
fn make_app_vim(projects: &[RootItem]) -> App {
let mut cfg = CargoPortConfig::default();
cfg.tui.navigation_keys = NavigationKeys::ArrowsAndVim;
let mut app = tui_test_support::make_app_with_config(projects, &cfg);
app.config.current_mut().tui.navigation_keys = NavigationKeys::ArrowsAndVim;
app
}
fn output_range(app: &App) -> Option<(usize, usize)> {
app.panes
.output
.selected_range(app.inflight.example_output())
}
fn output_count(app: &App) -> usize {
app.panes
.output
.selection_line_count(app.inflight.example_output())
}
fn render_ui(app: &mut App) {
app.ensure_visible_rows_cached();
app.ensure_detail_cached();
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap_or_else(|_| std::process::abort());
terminal
.draw(|frame| render::ui(frame, app))
.unwrap_or_else(|_| std::process::abort());
}
fn render_lints_panel(app: &mut App, runs: &[LintRun]) {
app.ensure_detail_cached();
app.lint.set_content(LintsData {
runs: runs.to_vec(),
sizes: vec![Some(0); runs.len()],
owner_paths: Vec::new(),
owner_of: Vec::new(),
is_rust: true,
});
let backend = TestBackend::new(120, 20);
let mut terminal = Terminal::new(backend).unwrap_or_else(|_| std::process::abort());
let focus = RenderFocus {
state: app.pane_focus_state(PaneId::Lints),
is_focused: app.focus_is(PaneId::Lints),
};
app.lint.focus = focus;
let animation_elapsed = app.animation_started.elapsed();
let selected_path = app
.selected_project_path_for_render()
.map(std::path::Path::to_path_buf);
let ci_status_lookup = app.ci.status_lookup();
terminal
.draw(|frame| {
let area = frame.area();
let split = app.split_for_render(
selected_path.as_deref(),
animation_elapsed,
&ci_status_lookup,
OverlayRenderInputs::none(),
SyncedDescriptionHeight::default(),
);
tui_pane::Renderable::render(split.registry.lint, frame, area, &split.ctx);
})
.unwrap_or_else(|_| std::process::abort());
}
fn render_ci_panel(app: &mut App, runs: &[CiRun]) {
app.ensure_detail_cached();
app.ci.override_runs_for_test(runs.to_vec());
let backend = TestBackend::new(120, 20);
let mut terminal = Terminal::new(backend).unwrap_or_else(|_| std::process::abort());
let focus = RenderFocus {
state: app.pane_focus_state(PaneId::CiRuns),
is_focused: app.focus_is(PaneId::CiRuns),
};
app.ci.focus = focus;
let animation_elapsed = app.animation_started.elapsed();
let selected_path = app
.selected_project_path_for_render()
.map(std::path::Path::to_path_buf);
let ci_status_lookup = app.ci.status_lookup();
terminal
.draw(|frame| {
let area = frame.area();
let split = app.split_for_render(
selected_path.as_deref(),
animation_elapsed,
&ci_status_lookup,
OverlayRenderInputs::none(),
SyncedDescriptionHeight::default(),
);
tui_pane::Renderable::render(split.registry.ci, frame, area, &split.ctx);
})
.unwrap_or_else(|_| std::process::abort());
}
fn click(app: &mut App, column: u16, row: u16) {
input::handle_event(
app,
&Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
}),
);
}
fn move_mouse(app: &mut App, column: u16, row: u16) {
input::handle_event(
app,
&Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
column,
row,
modifiers: KeyModifiers::NONE,
}),
);
}
fn scroll_down(app: &mut App, column: u16, row: u16) {
input::handle_event(
app,
&Event::Mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
column,
row,
modifiers: KeyModifiers::NONE,
}),
);
}
fn drag(app: &mut App, column: u16, row: u16) {
input::handle_event(
app,
&Event::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
}),
);
}
fn press_key(app: &mut App, code: KeyCode) {
input::handle_event(
app,
&Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}),
);
}
fn press_shift_key(app: &mut App, code: KeyCode) {
input::handle_event(
app,
&Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::SHIFT,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}),
);
}
fn press_ctrl_shift_key(app: &mut App, code: KeyCode) {
input::handle_event(
app,
&Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}),
);
}
fn focus_gained(app: &mut App) { input::handle_event(app, &Event::FocusGained); }
fn row_body_point(app: &App, row_index: usize) -> (u16, u16) {
let area = app.panes.project_list.body_rect;
(
area.x.saturating_add(1),
area.y
.saturating_add(u16::try_from(row_index).unwrap_or(u16::MAX)),
)
}
fn row_dismiss_point(app: &App, row_index: usize) -> (u16, u16) {
let area = app.panes.project_list.body_rect;
(
area.x.saturating_add(area.width.saturating_sub(2)),
area.y
.saturating_add(u16::try_from(row_index).unwrap_or(u16::MAX)),
)
}
fn pane_row_point(pane: &Viewport, row_index: usize) -> (u16, u16) {
let area = pane.content_area();
(
area.x.saturating_add(1),
area.y
.saturating_add(u16::try_from(row_index).unwrap_or(u16::MAX)),
)
}
fn package_metadata_row_point(app: &App, row_index: usize) -> (u16, u16) {
let area = app.panes.package.viewport.content_area();
(
area.x.saturating_add(1),
area.y
.saturating_add(2)
.saturating_add(u16::try_from(row_index).unwrap_or(u16::MAX)),
)
}
fn pane_row_hit_point(app: &App, pane: PaneId, row: usize) -> (u16, u16) {
let area = app
.panes
.tiled_layout
.panes
.iter()
.find_map(|resolved| (resolved.pane == pane).then_some(resolved.area))
.expect("pane must be laid out");
for y in area.y..area.bottom() {
for x in area.x..area.right() {
if interaction::hovered_pane_row_at(app, Position::new(x, y))
== Some(HoveredPaneRow { pane, row })
{
return (x, y);
}
}
}
panic!("row {row} in pane {pane:?} was not hit-testable");
}
fn framework_pane_row_point(pane: &Viewport, row_index: usize) -> (u16, u16) {
let area = pane.content_area();
(
area.x.saturating_add(1),
area.y
.saturating_add(u16::try_from(row_index).unwrap_or(u16::MAX)),
)
}
fn settings_point_for_setting(app: &App, setting: SettingOption) -> (u16, u16) {
let row = settings::selection_index_for_setting_for_test(app, setting)
.expect("setting must be visible");
let pane = &app.framework.settings_pane;
let height = usize::from(pane.viewport().content_area().height);
let line = (0..height)
.find(|line| pane.line_target(*line) == Some(row))
.expect("setting must have a rendered hit target");
framework_pane_row_point(pane.viewport(), line)
}
fn keymap_point_for_row_after(app: &App, min_row: usize) -> (u16, u16, usize) {
let pane = &app.framework.keymap_pane;
let height = usize::from(pane.viewport().content_area().height);
let (line, row) = (0..height)
.filter_map(|line| pane.line_target(line).map(|row| (line, row)))
.find(|(_, row)| *row > min_row)
.expect("keymap row must have a rendered hit target");
let (x, y) = framework_pane_row_point(pane.viewport(), line);
(x, y, row)
}
fn framework_selection_state(
pane: &Viewport,
row: usize,
focus: PaneFocusState,
) -> PaneSelectionState {
if row == pane.pos() && matches!(focus, PaneFocusState::Active) {
PaneSelectionState::Active
} else if pane.hovered() == Some(row) {
PaneSelectionState::Hovered
} else if row == pane.pos() && matches!(focus, PaneFocusState::Remembered) {
PaneSelectionState::Remembered
} else {
PaneSelectionState::Unselected
}
}
fn finder_result_point(app: &App, result_index: usize) -> (u16, u16) {
let area = app.overlays.finder_pane.viewport.content_area();
(
area.x.saturating_add(1),
area.y
.saturating_add(1)
.saturating_add(u16::try_from(result_index).unwrap_or(u16::MAX)),
)
}
fn lint_run_point(app: &App, run_index: usize) -> (u16, u16) {
let area = app.lint.viewport.content_area();
(
area.x.saturating_add(1),
area.y
.saturating_add(1)
.saturating_add(u16::try_from(run_index).unwrap_or(u16::MAX)),
)
}
fn ci_run_point(app: &App, run_index: usize) -> (u16, u16) {
let area = app.ci.viewport.content_area();
(
area.x.saturating_add(1),
area.y
.saturating_add(1)
.saturating_add(u16::try_from(run_index).unwrap_or(u16::MAX)),
)
}
fn output_point(app: &App, row: usize) -> (u16, u16) {
let area = app.panes.output.viewport.content_area();
(
area.x,
area.y
.saturating_add(u16::try_from(row).unwrap_or(u16::MAX)),
)
}
fn toast_close_point(app: &App, toast_id: ToastId) -> (u16, u16) {
let Some(rect) = app
.framework
.toasts
.hits()
.iter()
.find(|h| h.id == toast_id)
.map(|h| h.close_rect)
else {
std::process::abort();
};
(
rect.x.saturating_add(rect.width.saturating_sub(1) / 2),
rect.y.saturating_add(rect.height.saturating_sub(1) / 2),
)
}
fn toast_body_point(app: &App, toast_id: ToastId) -> (u16, u16) {
let Some(rect) = app
.framework
.toasts
.hits()
.iter()
.find(|h| h.id == toast_id)
.map(|h| h.card_rect)
else {
std::process::abort();
};
(
rect.x.saturating_add(rect.width.saturating_sub(1) / 2),
rect.y.saturating_add(rect.height.saturating_sub(1) / 2),
)
}
fn mark_deleted(app: &mut App, path: &Path) {
let project = app
.project_list
.at_path_mut(path)
.unwrap_or_else(|| std::process::abort());
project.disk_usage_bytes = Some(0);
project.visibility = Visibility::Deleted;
}
#[test]
fn deleted_project_row_mouse_click_dismisses_it() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let deleted_dir = tmp.path().join("deleted");
std::fs::create_dir_all(&deleted_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("deleted", &deleted_dir)]);
mark_deleted(&mut app, &deleted_dir);
render_ui(&mut app);
let (x, y) = row_dismiss_point(&app, 0);
click(&mut app, x, y);
render_ui(&mut app);
assert!(
app.visible_rows().is_empty(),
"clicking deleted row [x] should stop rendering that row"
);
}
#[test]
fn mouse_and_keyboard_dismiss_resolve_same_deleted_project_target() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let deleted_dir = tmp.path().join("deleted");
std::fs::create_dir_all(&deleted_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("deleted", &deleted_dir)]);
mark_deleted(&mut app, &deleted_dir);
app.project_list.set_cursor(0);
render_ui(&mut app);
let keyboard_target = app
.focused_dismiss_target()
.unwrap_or_else(|| std::process::abort());
let (x, y) = row_dismiss_point(&app, 0);
let Some(hit) = interaction::hit_test_at(&app, Position::new(x, y)) else {
std::process::abort();
};
let HoverTarget::Dismiss(mouse_target) = hit else {
std::process::abort();
};
let DismissTarget::DeletedProject(lhs) = keyboard_target else {
std::process::abort();
};
let DismissTarget::DeletedProject(rhs) = mouse_target else {
std::process::abort();
};
assert_eq!(lhs, rhs);
}
#[test]
fn row_body_click_selects_clicked_project() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let first = tmp.path().join("first");
let second = tmp.path().join("second");
std::fs::create_dir_all(&first).unwrap_or_else(|_| std::process::abort());
std::fs::create_dir_all(&second).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[
make_package("first", &first),
make_package("second", &second),
]);
render_ui(&mut app);
let (x, y) = row_body_point(&app, 1);
click(&mut app, x, y);
assert_eq!(app.focused_pane_id(), PaneId::ProjectList);
assert_eq!(app.project_list.cursor(), 1);
assert_eq!(
app.project_list
.selected_project_path()
.map(Path::to_path_buf),
Some(second),
);
}
#[test]
fn hovered_pane_row_resolves_project_list_rows() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let first = tmp.path().join("first");
let second = tmp.path().join("second");
std::fs::create_dir_all(&first).unwrap_or_else(|_| std::process::abort());
std::fs::create_dir_all(&second).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[
make_package("first", &first),
make_package("second", &second),
]);
render_ui(&mut app);
let (x, y) = row_body_point(&app, 1);
assert_eq!(
interaction::hovered_pane_row_at(&app, Position::new(x, y)),
Some(HoveredPaneRow {
pane: PaneId::ProjectList,
row: 1,
}),
);
}
#[test]
fn finder_row_click_uses_result_index_not_visual_table_row() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let alpha = tmp.path().join("alpha");
let beta = tmp.path().join("beta");
std::fs::create_dir_all(&alpha).unwrap_or_else(|_| std::process::abort());
std::fs::create_dir_all(&beta).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("alpha", &alpha), make_package("beta", &beta)]);
let (index, col_widths) = finder::build_finder_index(&app.project_list);
let finder = &mut app.project_list.finder;
finder.index = index;
finder.col_widths = col_widths;
finder.results = vec![0, 1];
finder.total = 2;
app.overlays
.set_finder_return(FocusedPane::App(AppPaneId::ProjectList));
app.set_focus(FocusedPane::App(AppPaneId::Finder));
app.overlays.open_finder();
render_ui(&mut app);
let (x, y) = finder_result_point(&app, 1);
click(&mut app, x, y);
assert_eq!(
app.overlays.finder_pane.viewport.pos(),
1,
"clicking the second rendered finder result should select result index 1, not the header-offset visual row"
);
}
#[test]
fn git_hover_uses_owner_backed_pane_surface_for_workspace_member() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let workspace = tmp.path().join("ws");
let member = workspace.join("core");
std::fs::create_dir_all(&member).unwrap_or_else(|_| std::process::abort());
let root = make_workspace_with_members(
"ws",
&workspace,
vec![inline_group(vec![make_member("core", &member)])],
);
let mut app = make_app(&[root]);
app.project_list.expanded.insert(ExpandKey::Node(0));
app.ensure_visible_rows_cached();
app.project_list.move_down();
let (checkout, repo) = make_git_info(Some("https://github.com/natepiano/demo"));
app.handle_repo_info(&workspace, repo);
app.handle_checkout_info(&workspace, checkout);
render_ui(&mut app);
let (x, y) = pane_row_point(&app.panes.git.viewport, 0);
assert_eq!(
interaction::hovered_pane_row_at(&app, Position::new(x, y)),
Some(HoveredPaneRow {
pane: PaneId::Git,
row: 0,
}),
);
}
#[test]
fn settings_row_click_uses_setting_index_not_visual_line() {
let mut app = make_app(&[]);
open_settings_overlay(&mut app);
render_ui(&mut app);
let ci_run_count_row =
settings::selection_index_for_setting_for_test(&app, SettingOption::CiRunCount)
.expect("CI run count row");
let (x, y) = settings_point_for_setting(&app, SettingOption::CiRunCount);
click(&mut app, x, y);
assert_eq!(
app.framework.settings_pane.viewport().pos(),
ci_run_count_row,
"clicking a rendered settings option should select the logical setting, not the visual line index including spacer/header rows"
);
}
#[test]
fn keymap_row_click_uses_keymap_line_targets() {
let mut app = make_app(&[]);
open_keymap_overlay(&mut app);
render_ui(&mut app);
let (x, y, row) = keymap_point_for_row_after(&app, 0);
click(&mut app, x, y);
assert_eq!(
app.framework.keymap_pane.viewport().pos(),
row,
"clicking a keymap row should select the logical keymap entry, not the visual line including spacer/header rows"
);
}
fn assert_overlay_blocks_underlying_project_list_mouse(
overlay_name: &str,
open_overlay: fn(&mut App),
) {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let first = tmp.path().join("first");
let second = tmp.path().join("second");
std::fs::create_dir_all(&first).unwrap_or_else(|_| std::process::abort());
std::fs::create_dir_all(&second).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[
make_package("first", &first),
make_package("second", &second),
]);
render_ui(&mut app);
open_overlay(&mut app);
render_ui(&mut app);
let (x, y) = row_body_point(&app, 1);
click(&mut app, x, y);
scroll_down(&mut app, x, y);
assert_eq!(
app.project_list.cursor(),
0,
"project-list mouse input must not pass through an open {overlay_name} overlay"
);
}
#[test]
fn overlays_block_underlying_project_list_mouse() {
for (overlay_name, open_overlay) in [
("keymap", open_keymap_overlay as fn(&mut App)),
("finder", input::open_finder as fn(&mut App)),
("settings", open_settings_overlay as fn(&mut App)),
] {
assert_overlay_blocks_underlying_project_list_mouse(overlay_name, open_overlay);
}
}
#[test]
fn keyboard_navigation_clears_stale_settings_hover() {
let mut app = make_app(&[]);
open_settings_overlay(&mut app);
render_ui(&mut app);
let hovered_row =
settings::selection_index_for_setting_for_test(&app, SettingOption::CiRunCount)
.expect("CI run count row");
let (x, y) = settings_point_for_setting(&app, SettingOption::CiRunCount);
move_mouse(&mut app, x, y);
render_ui(&mut app);
assert_eq!(
framework_selection_state(
app.framework.settings_pane.viewport(),
hovered_row,
PaneFocusState::Active,
),
PaneSelectionState::Hovered,
);
press_key(&mut app, KeyCode::Down);
render_ui(&mut app);
assert_eq!(app.framework.settings_pane.viewport().pos(), 1);
assert_eq!(
framework_selection_state(
app.framework.settings_pane.viewport(),
hovered_row,
PaneFocusState::Active,
),
PaneSelectionState::Unselected,
);
assert_eq!(
framework_selection_state(
app.framework.settings_pane.viewport(),
1,
PaneFocusState::Active,
),
PaneSelectionState::Active,
);
}
#[test]
fn mouse_move_restores_hover_after_keyboard_navigation() {
let mut app = make_app(&[]);
open_settings_overlay(&mut app);
render_ui(&mut app);
let hovered_row =
settings::selection_index_for_setting_for_test(&app, SettingOption::CiRunCount)
.expect("CI run count row");
let (x, y) = settings_point_for_setting(&app, SettingOption::CiRunCount);
move_mouse(&mut app, x, y);
render_ui(&mut app);
press_key(&mut app, KeyCode::Down);
render_ui(&mut app);
assert_eq!(
framework_selection_state(
app.framework.settings_pane.viewport(),
hovered_row,
PaneFocusState::Active,
),
PaneSelectionState::Unselected,
);
move_mouse(&mut app, x, y);
render_ui(&mut app);
assert_eq!(
framework_selection_state(
app.framework.settings_pane.viewport(),
hovered_row,
PaneFocusState::Active,
),
PaneSelectionState::Hovered,
);
}
#[test]
fn focus_gained_restores_selection_from_last_mouse_position() {
let mut app = make_app(&[]);
open_settings_overlay(&mut app);
render_ui(&mut app);
let hovered_row =
settings::selection_index_for_setting_for_test(&app, SettingOption::CiRunCount)
.expect("CI run count row");
let (x, y) = settings_point_for_setting(&app, SettingOption::CiRunCount);
input::set_last_mouse_pos_for_test(Some((x, y)));
focus_gained(&mut app);
render_ui(&mut app);
assert_eq!(app.framework.settings_pane.viewport().pos(), hovered_row);
assert_eq!(
framework_selection_state(
app.framework.settings_pane.viewport(),
hovered_row,
PaneFocusState::Active,
),
PaneSelectionState::Active,
);
}
#[test]
fn lint_row_click_uses_run_index_not_header_row() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
let runs = vec![
make_lint_run("run-1", LintRunStatus::Passed),
make_lint_run("run-2", LintRunStatus::Failed),
];
render_lints_panel(&mut app, &runs);
let (x, y) = lint_run_point(&app, 1);
click(&mut app, x, y);
assert_eq!(
app.lint.viewport.pos(),
1,
"clicking the second rendered lint run should select run index 1, not the header-offset visual row"
);
}
#[test]
fn ci_row_click_uses_run_index_not_header_row() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package_with_cargo(
"demo",
&project_dir,
Cargo {
types: vec![ProjectType::Binary],
examples: vec![ExampleGroup {
category: String::new(),
names: vec!["example".to_string()],
}],
..Cargo::default()
},
)]);
let runs = vec![
make_ci_run(1, CiStatus::Passed),
make_ci_run(2, CiStatus::Failed),
];
render_ci_panel(&mut app, &runs);
let (x, y) = ci_run_point(&app, 1);
click(&mut app, x, y);
assert_eq!(
app.ci.viewport.pos(),
1,
"clicking the second rendered CI run should select run index 1, not the header-offset visual row"
);
}
#[test]
fn expanded_tree_rebuild_refreshes_clickable_rows() {
let primary: AbsolutePath = "/abs/app".into();
let linked: AbsolutePath = "/abs/app_feat".into();
let mut app = make_app(&[RootItem::Rust(RustProject::Package(make_package_worktree(
"app",
&primary,
false,
Some(primary.as_path()),
)))]);
app.project_list.expanded.insert(ExpandKey::Node(0));
render_ui(&mut app);
app.project_list
.replace_roots_from(ProjectList::new(vec![RootItem::Worktrees(
WorktreeGroup::new(
RustProject::Package(make_package_worktree(
"app",
&primary,
false,
Some(primary.as_path()),
)),
vec![RustProject::Package(make_package_worktree(
"app",
&linked,
true,
Some(primary.as_path()),
))],
),
)]));
render_ui(&mut app);
let (x, y) = row_body_point(&app, 2);
click(&mut app, x, y);
assert_eq!(
app.project_list.selected_project_path(),
Some(linked.as_path()),
"clicking the linked worktree row after regroup should select it"
);
}
#[test]
fn old_dismiss_click_location_does_not_dismiss_surviving_row_after_rerender() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let deleted_dir = tmp.path().join("deleted");
let live_dir = tmp.path().join("live");
std::fs::create_dir_all(&deleted_dir).unwrap_or_else(|_| std::process::abort());
std::fs::create_dir_all(&live_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[
make_package("deleted", &deleted_dir),
make_package("live", &live_dir),
]);
mark_deleted(&mut app, &deleted_dir);
render_ui(&mut app);
let stale_click = row_dismiss_point(&app, 0);
app.project_list.set_cursor(0);
let target = app
.focused_dismiss_target()
.unwrap_or_else(|| std::process::abort());
app.dismiss(target);
render_ui(&mut app);
click(&mut app, stale_click.0, stale_click.1);
render_ui(&mut app);
assert!(
app.project_list
.at_path(&live_dir)
.is_some_and(|info| info.visibility == Visibility::Visible),
"clicking the old dismiss location after rerender must not dismiss the surviving row"
);
assert_eq!(
app.project_list
.selected_project_path()
.map(Path::to_path_buf),
Some(live_dir),
"the surviving row may be selected, but it must not be dismissed by stale geometry"
);
}
#[test]
fn toast_close_click_dismisses_toast() {
let mut app = make_app(&[]);
let toast_id =
app.framework
.toasts
.push_persistent("Error", "toast body", ToastStyle::Error, None, 1);
let toast_len = app.framework.toasts.active_now().len();
app.framework.toasts.viewport.set_len(toast_len);
render_ui(&mut app);
let (x, y) = toast_close_point(&app, toast_id);
click(&mut app, x, y);
let after_exit = Instant::now() + Duration::from_secs(1);
app.framework.toasts.prune(after_exit);
assert!(
app.framework
.toasts
.active_views(after_exit)
.iter()
.all(|toast| toast.id() != toast_id),
"clicking the toast close affordance should start dismissal and let the toast exit"
);
}
#[test]
fn toast_body_click_focuses_toast_over_underlying_content() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
let toast_id =
app.framework
.toasts
.push_persistent("Error", "toast body", ToastStyle::Error, None, 1);
let toast_len = app.framework.toasts.active_now().len();
app.framework.toasts.viewport.set_len(toast_len);
render_ui(&mut app);
let (x, y) = toast_body_point(&app, toast_id);
click(&mut app, x, y);
assert_eq!(
app.focused_pane_id(),
PaneId::Toasts,
"toast body click should focus the toast surface over underlying content"
);
}
#[test]
fn package_pane_row_click_selects_field() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
render_ui(&mut app);
let (x, y) = pane_row_hit_point(&app, PaneId::Package, 1);
click(&mut app, x, y);
assert_eq!(app.focused_pane_id(), PaneId::Package);
assert_eq!(app.panes.package.viewport.pos(), 1);
}
#[test]
fn edge_scroll_down_past_bottom_advances_to_next_pane() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
app.config.current_mut().tui.edge_scroll = EdgeScroll::AdvancesPane;
render_ui(&mut app);
app.set_focus(FocusedPane::App(AppPaneId::ProjectList));
app.project_list.move_to_bottom();
panes::dispatch_navigation_action(
NavAction::Down,
FocusedPane::App(AppPaneId::ProjectList),
&mut app,
);
assert_eq!(
app.focused_pane_id(),
PaneId::Package,
"Down at the bottom row should roll focus to the next pane in tab order",
);
}
#[test]
fn edge_scroll_down_past_last_toast_advances_to_next_pane() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
app.config.current_mut().tui.edge_scroll = EdgeScroll::AdvancesPane;
let _ = app
.framework
.toasts
.push_persistent("First", "", ToastStyle::Normal, None, 1);
let _ = app
.framework
.toasts
.push_persistent("Second", "", ToastStyle::Normal, None, 1);
app.set_focus_to_pane(PaneId::Toasts);
app.framework.toasts.reset_to_last();
panes::dispatch_navigation_action(
NavAction::Down,
FocusedPane::Framework(FrameworkFocusId::Toasts),
&mut app,
);
assert_eq!(
app.focused_pane_id(),
PaneId::ProjectList,
"Down at the last toast should roll focus to the next pane in tab order",
);
}
#[test]
fn edge_scroll_off_holds_focus_at_list_edge() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
render_ui(&mut app);
app.set_focus(FocusedPane::App(AppPaneId::ProjectList));
app.project_list.move_to_bottom();
panes::dispatch_navigation_action(
NavAction::Down,
FocusedPane::App(AppPaneId::ProjectList),
&mut app,
);
assert_eq!(
app.focused_pane_id(),
PaneId::ProjectList,
"with edge scroll off, focus stays at the list edge",
);
}
#[test]
fn package_pane_description_row_click_selects_first_row() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
app.panes.package.viewport.set_pos(1);
render_ui(&mut app);
let (x, y) = pane_row_hit_point(&app, PaneId::Package, 0);
click(&mut app, x, y);
assert_eq!(app.focused_pane_id(), PaneId::Package);
assert_eq!(app.panes.package.viewport.pos(), 0);
}
#[test]
fn package_pane_section_row_click_is_ignored() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let primary = tmp.path().join("demo");
let linked = tmp.path().join("demo_fix");
std::fs::create_dir_all(&primary).unwrap_or_else(|_| std::process::abort());
std::fs::create_dir_all(&linked).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[RootItem::Worktrees(WorktreeGroup::new(
RustProject::Package(make_package_worktree(
"demo",
&primary,
false,
Some(&primary),
)),
vec![RustProject::Package(make_package_worktree(
"demo",
&linked,
true,
Some(&primary),
))],
))]);
app.set_focus_to_pane(PaneId::Package);
app.panes.package.viewport.set_pos(1);
render_ui(&mut app);
let package = app.panes.package.content().expect("package pane content");
assert!(matches!(
panes::package_rows_from_data(package).get(1),
Some(panes::PackageRow::Section(_))
));
let pos_before = app.panes.package.viewport.pos();
let (x, y) = package_metadata_row_point(&app, 0);
assert_eq!(
interaction::hovered_pane_row_at(&app, Position::new(x, y)),
None
);
click(&mut app, x, y);
assert_eq!(app.panes.package.viewport.pos(), pos_before);
}
#[test]
fn package_pane_keyboard_navigation_skips_section_rows() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let primary = tmp.path().join("demo");
let linked = tmp.path().join("demo_fix");
std::fs::create_dir_all(&primary).unwrap_or_else(|_| std::process::abort());
std::fs::create_dir_all(&linked).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[RootItem::Worktrees(WorktreeGroup::new(
RustProject::Package(make_package_worktree(
"demo",
&primary,
false,
Some(&primary),
)),
vec![RustProject::Package(make_package_worktree(
"demo",
&linked,
true,
Some(&primary),
))],
))]);
app.set_focus_to_pane(PaneId::Package);
render_ui(&mut app);
let package = app.panes.package.content().expect("package pane content");
let rows = panes::package_rows_from_data(package);
assert!(matches!(rows.get(1), Some(panes::PackageRow::Section(_))));
assert!(matches!(rows.get(5), Some(panes::PackageRow::Section(_))));
assert_eq!(app.panes.package.viewport.pos(), 0);
press_key(&mut app, KeyCode::Up);
assert_eq!(app.panes.package.viewport.pos(), 0);
press_key(&mut app, KeyCode::Down);
assert_eq!(app.panes.package.viewport.pos(), 2);
for _ in 0..3 {
press_key(&mut app, KeyCode::Down);
}
assert_eq!(app.panes.package.viewport.pos(), 6);
press_key(&mut app, KeyCode::Down);
assert_eq!(app.panes.package.viewport.pos(), 7);
press_key(&mut app, KeyCode::Up);
assert_eq!(app.panes.package.viewport.pos(), 6);
}
#[test]
fn package_pane_structure_rows_are_clickable_after_metadata_rows() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let workspace = tmp.path().join("workspace");
let member = workspace.join("core");
std::fs::create_dir_all(&member).unwrap_or_else(|_| std::process::abort());
let cargo = Cargo {
types: vec![ProjectType::Library],
examples: vec![ExampleGroup {
category: String::new(),
names: vec!["demo".to_string()],
}],
benches: Vec::new(),
publishable: true,
};
let root = make_workspace_with_members(
"workspace",
&workspace,
vec![inline_group(vec![make_member_with_cargo(
"core", &member, cargo,
)])],
);
let mut app = make_app(&[root]);
app.set_focus_to_pane(PaneId::Package);
render_ui(&mut app);
let package = app.panes.package.content().expect("package pane content");
let rows = panes::package_rows_from_data(package);
let structure_row = rows
.iter()
.position(|row| matches!(row, panes::PackageRow::Structure(0)))
.expect("first structure row");
let before_structure =
panes::package_selectable_row_at_or_before(&rows, structure_row.saturating_sub(1))
.expect("selectable row before structure");
app.panes.package.viewport.set_pos(before_structure);
press_key(&mut app, KeyCode::Down);
assert_eq!(app.panes.package.viewport.pos(), structure_row);
let (x, y) = pane_row_hit_point(&app, PaneId::Package, structure_row);
click(&mut app, x, y);
assert_eq!(app.focused_pane_id(), PaneId::Package);
assert_eq!(app.panes.package.viewport.pos(), structure_row);
}
#[test]
fn targets_pane_row_click_selects_target() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
let make_target = |name: &str| TargetRecord {
name: name.to_string(),
kinds: vec![TargetKind::Example],
required_features: vec![],
src_path: AbsolutePath::from(project_dir.join(format!("examples/{name}.rs"))),
};
let pkg_id = PackageId {
repr: "demo-id".into(),
};
let pkg = PackageRecord {
name: "demo".into(),
version: Version::new(0, 1, 0),
edition: "2021".into(),
description: None,
license: None,
homepage: None,
repository: None,
manifest_path: AbsolutePath::from(project_dir.join("Cargo.toml")),
targets: vec![make_target("example_a"), make_target("example_b")],
publish: PublishPolicy::Any,
};
let mut packages = std::collections::HashMap::new();
packages.insert(pkg_id, pkg);
app.scan
.metadata_store_handle()
.lock()
.unwrap_or_else(|_| std::process::abort())
.upsert(WorkspaceMetadata {
workspace_root: AbsolutePath::from(project_dir.clone()),
target_directory: AbsolutePath::from(project_dir.join("target")),
packages,
fingerprint: ManifestFingerprint {
manifest: FileStamp {
content_hash: [0_u8; 32],
},
lockfile: None,
rust_toolchain: None,
configs: std::collections::BTreeMap::new(),
},
out_of_tree_target_bytes: None,
});
render_ui(&mut app);
let (x, y) = pane_row_point(&app.panes.targets.viewport, 1);
click(&mut app, x, y);
assert_eq!(app.focused_pane_id(), PaneId::Targets);
assert_eq!(app.panes.targets.viewport.pos(), 1);
}
#[test]
fn arrow_keys_expand_and_collapse_the_running_cargo_group() {
let mut app = make_app(&[make_package("demo", Path::new("/tmp/demo"))]);
app.panes.targets.set_content(TargetsData {
binaries: vec![panes::TargetEntry {
name: "demo".to_string(),
display_name: "demo".to_string(),
kind: panes::RunTargetKind::Binary,
source: panes::TargetSource::Workspace,
project_path: AbsolutePath::from("/tmp/demo"),
package_name: "demo".to_string(),
src_path: AbsolutePath::from("/tmp/demo/src/main.rs"),
required_features: Vec::new(),
}],
examples: Vec::new(),
benches: Vec::new(),
});
let key = |name: &str| RunningKey {
target_dir: AbsolutePath::from(format!("/tmp/{name}/target")),
kind: RunTargetKind::Binary,
name: name.into(),
};
app.panes
.running_targets
.set_snapshot_for_test(RunningTargets::from_pairs(vec![
(
key("cargo-port"),
vec![
RunningInstance::for_test(7, RunProfile::Installed),
RunningInstance::for_test(8, RunProfile::Installed),
],
),
(
key("worker"),
vec![RunningInstance::for_test(9, RunProfile::Debug)],
),
]));
app.set_focus_to_pane(PaneId::Targets);
app.panes.targets.viewport.set_len(3);
app.panes.targets.viewport.set_pos(1);
press_key(&mut app, KeyCode::Right);
assert_eq!(
app.panes.targets.cargo_group(),
panes::CargoGroup::Expanded,
"Right on the collapsed header expands the group",
);
assert_eq!(
app.panes.targets.viewport.pos(),
1,
"the highlight stays on the header",
);
press_key(&mut app, KeyCode::Right);
assert_eq!(app.panes.targets.viewport.pos(), 2);
assert_eq!(app.panes.targets.running_cursor_pid(), Some(7));
press_key(&mut app, KeyCode::Left);
assert_eq!(
app.panes.targets.cargo_group(),
panes::CargoGroup::Collapsed
);
assert_eq!(app.panes.targets.viewport.pos(), 1);
assert_eq!(app.panes.targets.running_cursor_pid(), None);
press_key(&mut app, KeyCode::Left);
assert_eq!(app.panes.targets.viewport.pos(), 0);
}
#[test]
fn git_pane_row_click_selects_field() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
let (checkout, repo) = make_git_info(Some("https://github.com/natepiano/demo"));
app.handle_repo_info(&project_dir, repo);
app.handle_checkout_info(&project_dir, checkout);
render_ui(&mut app);
let (x, y) = pane_row_point(&app.panes.git.viewport, 1);
click(&mut app, x, y);
assert_eq!(app.focused_pane_id(), PaneId::Git);
assert_eq!(app.panes.git.viewport.pos(), 1);
}
#[test]
fn git_pane_description_row_click_selects_first_row() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
let (checkout, repo) = make_git_info(Some("https://github.com/natepiano/demo"));
app.handle_repo_info(&project_dir, repo);
app.handle_checkout_info(&project_dir, checkout);
app.project_list
.handle_repo_meta(&project_dir, 7, Some("A useful demo repo".to_string()));
app.panes.git.viewport.set_pos(1);
render_ui(&mut app);
let git = app.panes.git.content().expect("git pane content");
assert!(matches!(
panes::git_row_at(git, 0),
Some(panes::GitRow::Description(_))
));
let (x, y) = pane_row_hit_point(&app, PaneId::Git, 0);
click(&mut app, x, y);
assert_eq!(app.focused_pane_id(), PaneId::Git);
assert_eq!(app.panes.git.viewport.pos(), 0);
press_key(&mut app, KeyCode::Down);
assert_eq!(app.panes.git.viewport.pos(), 1);
}
fn buffer_text(app: &mut App) -> String { buffer_text_sized(app, 120, 40) }
fn buffer_text_sized(app: &mut App, width: u16, height: u16) -> String {
app.ensure_visible_rows_cached();
app.ensure_detail_cached();
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap_or_else(|_| std::process::abort());
terminal
.draw(|frame| render::ui(frame, app))
.unwrap_or_else(|_| std::process::abort());
let area = terminal.size().unwrap_or_else(|_| std::process::abort());
let buffer = terminal.backend().buffer();
let mut text = String::new();
for y in 0..area.height {
for x in 0..area.width {
text.push_str(buffer[(x, y)].symbol());
}
text.push('\n');
}
text
}
fn make_many_packages(tmp: &TempDir, count: usize) -> Vec<RootItem> {
(0..count)
.map(|index| {
let name = format!("project-{index:02}");
let dir = tmp.path().join(&name);
std::fs::create_dir_all(&dir).unwrap_or_else(|_| std::process::abort());
make_package(&name, &dir)
})
.collect()
}
#[test]
fn project_list_renders_framework_overflow_affordance() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let projects = make_many_packages(&tmp, 40);
let mut app = make_app(&projects);
let rendered = buffer_text_sized(&mut app, 100, 18);
assert!(
rendered.contains("1 of"),
"project list should render the framework-owned overflow marker"
);
}
#[test]
fn finder_results_render_framework_overflow_affordance() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let projects = make_many_packages(&tmp, 40);
let mut app = make_app(&projects);
input::open_finder(&mut app);
let result_count = app.project_list.finder.index.len();
app.project_list.finder.results = (0..result_count).collect();
app.project_list.finder.total = result_count;
let rendered = buffer_text_sized(&mut app, 100, 20);
assert!(rendered.contains("Find Anything"));
assert!(
rendered.contains("1 of"),
"finder should render the framework-owned overflow marker"
);
}
#[test]
fn settings_popup_renders_framework_overflow_affordance() {
let mut app = make_app(&[]);
open_settings_overlay(&mut app);
let rendered = buffer_text_sized(&mut app, 100, 18);
assert!(rendered.contains("Settings"));
assert!(
rendered.contains("1 of"),
"settings should render the framework-owned overflow marker"
);
}
#[test]
fn clean_confirm_popup_shows_resolved_out_of_tree_target_dir() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
let custom_target = tmp.path().join("out-of-tree-target");
app.scan
.metadata_store_handle()
.lock()
.unwrap_or_else(|_| std::process::abort())
.upsert(WorkspaceMetadata {
workspace_root: AbsolutePath::from(project_dir.clone()),
target_directory: AbsolutePath::from(custom_target.clone()),
packages: std::collections::HashMap::new(),
fingerprint: ManifestFingerprint {
manifest: FileStamp {
content_hash: [0_u8; 32],
},
lockfile: None,
rust_toolchain: None,
configs: std::collections::BTreeMap::new(),
},
out_of_tree_target_bytes: None,
});
app.set_confirm(ConfirmAction::Clean(AbsolutePath::from(project_dir)));
let rendered = buffer_text(&mut app);
assert!(
rendered.contains("Run cargo clean?"),
"prompt line still renders"
);
let expected = project::home_relative_path(custom_target.as_path());
assert!(
rendered.contains(&expected),
"resolved out-of-tree target dir is shown in the popup (expected {expected:?})"
);
}
fn upsert_fake_package_metadata(
app: &App,
project_dir: &Path,
license: Option<&str>,
homepage: Option<&str>,
repository: Option<&str>,
) {
let root = AbsolutePath::from(project_dir);
let manifest = AbsolutePath::from(project_dir.join("Cargo.toml"));
let pkg_id = PackageId {
repr: "demo-id".into(),
};
let pkg = PackageRecord {
name: "demo".into(),
version: Version::new(0, 1, 0),
edition: "2021".into(),
description: None,
license: license.map(String::from),
homepage: homepage.map(String::from),
repository: repository.map(String::from),
manifest_path: manifest,
targets: Vec::new(),
publish: PublishPolicy::Any,
};
let mut packages = std::collections::HashMap::new();
packages.insert(pkg_id, pkg);
let workspace_metadata = WorkspaceMetadata {
workspace_root: root,
target_directory: AbsolutePath::from(project_dir.join("target")),
packages,
fingerprint: ManifestFingerprint {
manifest: FileStamp {
content_hash: [0_u8; 32],
},
lockfile: None,
rust_toolchain: None,
configs: std::collections::BTreeMap::new(),
},
out_of_tree_target_bytes: None,
};
app.scan
.metadata_store_handle()
.lock()
.unwrap_or_else(|_| std::process::abort())
.upsert(workspace_metadata);
}
#[test]
fn package_pane_renders_metadata_edition_license_homepage_repository() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
upsert_fake_package_metadata(
&app,
&project_dir,
Some("MIT"),
Some("a.test/hp"),
Some("a.test/rp"),
);
let rendered = buffer_text_sized(&mut app, 120, 80);
for label in ["Edition", "License", "Homepage", "Repository"] {
assert!(
rendered.contains(label),
"{label} label missing from rendered package pane"
);
}
assert!(rendered.contains("2021"), "edition value (2021) missing");
assert!(rendered.contains("MIT"), "license value missing");
assert!(rendered.contains("a.test/hp"), "homepage value missing");
assert!(rendered.contains("a.test/rp"), "repository value missing");
}
#[test]
fn package_pane_renders_em_dash_for_missing_metadata_fields() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
upsert_fake_package_metadata(&app, &project_dir, None, None, None);
let rendered = buffer_text_sized(&mut app, 120, 80);
let dash_count = rendered.matches('—').count();
assert!(
dash_count >= 3,
"expected at least 3 em-dash placeholders for missing \
license/homepage/repository, got {dash_count}"
);
}
#[test]
fn package_pane_renders_target_and_non_target_disk_breakdown() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
let abs_path = AbsolutePath::from(project_dir);
let sizes = DirSizes {
total: 12 * 1024 * 1024,
in_project_target: 10 * 1024 * 1024,
in_project_non_target: 2 * 1024 * 1024,
};
app.handle_bg_msg(BackgroundMsg::DiskUsageBatch {
root_path: abs_path.clone(),
entries: vec![(abs_path, sizes)],
});
let rendered = buffer_text(&mut app);
assert!(
rendered.contains("target/"),
"detail pane must surface the target/ breakdown label"
);
assert!(
rendered.contains("other"),
"detail pane must surface the non-target (other) breakdown label"
);
assert!(
rendered.contains("10.0 MiB"),
"in-target value renders using format_bytes"
);
assert!(
rendered.contains("2.0 MiB"),
"non-target value renders using format_bytes"
);
}
#[test]
fn package_pane_renders_out_of_tree_target_size_for_sharer() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
let shared_target = tmp.path().join("shared-target");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
let root = AbsolutePath::from(project_dir);
let target = AbsolutePath::from(shared_target);
{
let store = app.scan.metadata_store_handle();
let mut guard = store.lock().unwrap_or_else(|_| std::process::abort());
guard.upsert(WorkspaceMetadata {
workspace_root: root,
target_directory: target,
packages: std::collections::HashMap::new(),
fingerprint: ManifestFingerprint {
manifest: FileStamp {
content_hash: [0_u8; 32],
},
lockfile: None,
rust_toolchain: None,
configs: std::collections::BTreeMap::new(),
},
out_of_tree_target_bytes: Some(42 * 1024 * 1024),
});
}
let rendered = buffer_text(&mut app);
assert!(
rendered.contains("out of tree"),
"sharer detail pane must surface the out-of-tree target label"
);
assert!(
rendered.contains("42.0 MiB"),
"out-of-tree target size renders using format_bytes"
);
}
fn upsert_shared_target_metadata(
app: &mut App,
primary_dir: &Path,
sibling_dirs: &[&Path],
target_dir: &Path,
) {
for dir in std::iter::once(primary_dir).chain(sibling_dirs.iter().copied()) {
let root = AbsolutePath::from(dir);
let manifest = AbsolutePath::from(dir.join("Cargo.toml"));
let pkg_name = dir
.file_name()
.map_or_else(|| "demo".to_string(), |n| n.to_string_lossy().into_owned());
let pkg_id = PackageId {
repr: format!("{pkg_name}-id"),
};
let pkg = PackageRecord {
name: pkg_name,
version: Version::new(0, 1, 0),
edition: "2021".into(),
description: None,
license: None,
homepage: None,
repository: None,
manifest_path: manifest,
targets: Vec::new(),
publish: PublishPolicy::Any,
};
let mut packages = std::collections::HashMap::new();
packages.insert(pkg_id, pkg);
let workspace_metadata = WorkspaceMetadata {
workspace_root: root.clone(),
target_directory: AbsolutePath::from(target_dir),
packages,
fingerprint: ManifestFingerprint {
manifest: FileStamp {
content_hash: [0_u8; 32],
},
lockfile: None,
rust_toolchain: None,
configs: std::collections::BTreeMap::new(),
},
out_of_tree_target_bytes: None,
};
let store = app.scan.metadata_store_handle();
let generation = store
.lock()
.unwrap_or_else(|_| std::process::abort())
.next_generation(&root);
app.handle_bg_msg(BackgroundMsg::CargoMetadata {
workspace_root: root,
generation,
fingerprint: workspace_metadata.fingerprint.clone(),
result: Ok(workspace_metadata),
});
}
}
#[test]
fn clean_confirm_popup_lists_affected_siblings_on_shared_target() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let primary_dir = tmp.path().join("main");
let sibling_dir = tmp.path().join("feat");
let target_dir = tmp.path().join("shared-target");
for dir in [&primary_dir, &sibling_dir] {
std::fs::create_dir_all(dir).unwrap_or_else(|_| std::process::abort());
}
std::fs::create_dir_all(&target_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[
make_package("main", &primary_dir),
make_package("feat", &sibling_dir),
]);
upsert_shared_target_metadata(
&mut app,
&primary_dir,
&[sibling_dir.as_path()],
&target_dir,
);
app.set_confirm(ConfirmAction::Clean(AbsolutePath::from(primary_dir)));
let rendered = buffer_text(&mut app);
assert!(
rendered.contains("Also affects:"),
"shared-target popup should label the collateral list"
);
let sibling_label = project::home_relative_path(sibling_dir.as_path());
assert!(
rendered.contains(&sibling_label),
"sibling path should appear in the affected list (expected {sibling_label:?})"
);
}
#[test]
fn clean_confirm_popup_falls_back_to_in_tree_target_without_metadata() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let project_dir = tmp.path().join("demo");
std::fs::create_dir_all(&project_dir).unwrap_or_else(|_| std::process::abort());
let mut app = make_app(&[make_package("demo", &project_dir)]);
app.set_confirm(ConfirmAction::Clean(AbsolutePath::from(
project_dir.clone(),
)));
let rendered = buffer_text(&mut app);
let fallback_target = project_dir.join("target");
let expected = project::home_relative_path(fallback_target.as_path());
assert!(
rendered.contains(&expected),
"without metadata, popup shows the default <project>/target (expected {expected:?})"
);
}
#[test]
fn clean_group_confirm_popup_lists_all_checkouts() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let primary = tmp.path().join("main");
let linked_a = tmp.path().join("feat-a");
let linked_b = tmp.path().join("feat-b");
for dir in [&primary, &linked_a, &linked_b] {
std::fs::create_dir_all(dir).unwrap_or_else(|_| std::process::abort());
}
let mut app = make_app(&[]);
app.set_confirm(ConfirmAction::CleanGroup {
primary: AbsolutePath::from(primary.clone()),
linked: vec![
AbsolutePath::from(linked_a.clone()),
AbsolutePath::from(linked_b.clone()),
],
});
let rendered = buffer_text_sized(&mut app, 160, 40);
assert!(
rendered.contains("Run cargo clean on all checkouts?"),
"group confirm uses the fan-out prompt"
);
assert!(
rendered.contains("Checkouts:"),
"group confirm labels the checkout list"
);
for dir in [&primary, &linked_a, &linked_b] {
let label = project::home_relative_path(dir.as_path());
assert!(
rendered.contains(&label),
"every checkout appears in the popup (expected {label:?})"
);
}
}
#[derive(Default)]
struct RecordingClipboard {
written: Option<String>,
}
impl ClipboardBackend for RecordingClipboard {
fn write_clipboard(&mut self, text: &str) -> Result<(), ClipboardError> {
self.written = Some(text.to_string());
Ok(())
}
}
fn open_output(app: &mut App, lines: &[&str]) {
app.set_example_output(lines.iter().map(|line| (*line).to_string()).collect());
let _ = buffer_text_sized(app, 120, 40);
}
#[test]
fn output_row_click_selects_clicked_line() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma", "delta", "epsilon"]);
assert_eq!(app.focused_pane_id(), PaneId::Output);
assert!(app.panes.output.is_following());
let (x, y) = output_point(&app, 1);
click(&mut app, x, y);
assert_eq!(app.focused_pane_id(), PaneId::Output);
assert_eq!(
app.panes.output.viewport.pos(),
1,
"clicking the second output line selects row index 1",
);
assert!(
!app.panes.output.is_following(),
"selecting an interior row freezes the view off the tail",
);
}
#[test]
fn output_drag_selects_the_line_range_and_yanks_it() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma", "delta", "epsilon"]);
let (x1, y1) = output_point(&app, 1);
click(&mut app, x1, y1);
let (x3, y3) = output_point(&app, 3);
drag(&mut app, x3, y3);
assert!(app.panes.output.selection().is_visual());
assert_eq!(output_range(&app), Some((1, 3)));
let (x0, y0) = output_point(&app, 0);
drag(&mut app, x0, y0);
assert_eq!(output_range(&app), Some((0, 1)));
drag(&mut app, x3, y3);
assert_eq!(output_range(&app), Some((1, 3)));
let mut clipboard = RecordingClipboard::default();
app.copy_focused_selection_with_backend(&mut clipboard);
assert_eq!(clipboard.written.as_deref(), Some("beta\ngamma\ndelta"));
}
#[test]
fn output_click_after_drag_clears_the_selection_to_the_clicked_line() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma", "delta", "epsilon"]);
let (x1, y1) = output_point(&app, 1);
click(&mut app, x1, y1);
let (x3, y3) = output_point(&app, 3);
drag(&mut app, x3, y3);
assert!(app.panes.output.selection().is_visual());
assert_eq!(output_range(&app), Some((1, 3)));
let (x0, y0) = output_point(&app, 0);
click(&mut app, x0, y0);
assert!(!app.panes.output.selection().is_visual());
assert_eq!(output_range(&app), Some((0, 0)));
let (x2, y2) = output_point(&app, 2);
drag(&mut app, x2, y2);
assert_eq!(output_range(&app), Some((0, 2)));
}
#[test]
fn output_drag_ignored_when_output_not_focused() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma"]);
app.set_focus(FocusedPane::App(AppPaneId::ProjectList));
let (x, y) = output_point(&app, 0);
drag(&mut app, x, y);
assert!(!app.panes.output.selection().is_visual());
}
#[test]
fn output_click_does_not_hit_stale_diagnostics_rect() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
let _ = buffer_text_sized(&mut app, 120, 40);
open_output(&mut app, &["alpha", "beta", "gamma"]);
let (x, y) = output_point(&app, 0);
click(&mut app, x, y);
assert_eq!(
app.focused_pane_id(),
PaneId::Output,
"the click must focus Output, not a hidden diagnostics pane",
);
assert_eq!(app.panes.output.viewport.pos(), 0);
}
#[test]
fn output_toggle_visual_enters_and_leaves_visual_mode() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma"]);
assert_eq!(app.focused_pane_id(), PaneId::Output);
assert!(app.panes.output.is_following());
assert_eq!(output_count(&app), 1);
let live = app.inflight.example_output().to_vec();
app.panes.output.toggle_visual(&live);
assert!(app.panes.output.selection().is_visual());
assert_eq!(output_count(&app), 1);
let live = app.inflight.example_output().to_vec();
app.panes.output.toggle_visual(&live);
assert!(!app.panes.output.selection().is_visual());
assert_eq!(output_count(&app), 1);
}
#[test]
fn output_v_is_inert_without_vim() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma"]);
press_key(&mut app, KeyCode::Char('V'));
assert!(!app.panes.output.selection().is_visual());
assert!(app.panes.output.is_following());
assert_eq!(output_count(&app), 1);
}
#[test]
fn output_selection_extends_and_yanks_against_snapshot() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma", "delta", "epsilon"]);
press_shift_key(&mut app, KeyCode::Up); press_shift_key(&mut app, KeyCode::Up);
assert_eq!(output_range(&app), Some((2, 4)));
app.inflight
.apply_example_progress("epsilon-updated".to_string());
app.inflight.example_output_mut().push("zeta".to_string());
let mut clipboard = RecordingClipboard::default();
app.copy_focused_selection_with_backend(&mut clipboard);
assert_eq!(clipboard.written.as_deref(), Some("gamma\ndelta\nepsilon"));
assert!(
app.panes.output.is_following(),
"a yank collapses back to following the tail",
);
assert_eq!(output_count(&app), 1);
}
#[test]
fn output_ctrl_a_selects_all_lines_and_yanks_them() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma", "delta"]);
input::handle_event(
&mut app,
&Event::Key(KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}),
);
assert_eq!(
output_range(&app),
Some((0, 3)),
"Ctrl-A selects every line",
);
assert_eq!(output_count(&app), 4, "the selection spans every line");
let mut clipboard = RecordingClipboard::default();
app.copy_focused_selection_with_backend(&mut clipboard);
assert_eq!(
clipboard.written.as_deref(),
Some("alpha\nbeta\ngamma\ndelta"),
);
}
#[test]
fn output_esc_collapses_vim_visual_to_the_cursor_row() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app_vim(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma", "delta", "epsilon"]);
press_key(&mut app, KeyCode::Char('V'));
press_key(&mut app, KeyCode::Up);
let _ = buffer_text_sized(&mut app, 120, 40);
assert_eq!(output_range(&app), Some((3, 4)));
press_key(&mut app, KeyCode::Esc);
assert!(!app.panes.output.selection().is_visual());
assert_eq!(output_count(&app), 1);
assert_eq!(
app.panes.output.viewport.pos(),
3,
"collapse leaves the cursor where the visual range ended",
);
assert!(
!app.panes.output.is_following(),
"the view stays where the user was reading, not at the tail",
);
}
#[test]
fn output_shift_arrows_grow_the_selection_from_the_cursor_row() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma", "delta", "epsilon"]);
assert!(app.panes.output.is_following());
assert_eq!(output_count(&app), 1);
press_shift_key(&mut app, KeyCode::Up);
assert_eq!(
output_range(&app),
Some((3, 4)),
"the selection spans the anchor row and the row above",
);
assert!(
!app.panes.output.is_following(),
"extending the selection freezes the view off the tail",
);
press_shift_key(&mut app, KeyCode::Down);
assert_eq!(output_range(&app), Some((4, 4)));
}
#[test]
fn shift_arrows_do_nothing_outside_the_output_pane() {
let mut app = make_app(&[
make_package("first", Path::new("/tmp/first")),
make_package("second", Path::new("/tmp/second")),
]);
app.set_focus(FocusedPane::App(AppPaneId::ProjectList));
let _ = buffer_text_sized(&mut app, 120, 40);
app.project_list.move_down();
assert_eq!(app.project_list.cursor(), 1);
press_shift_key(&mut app, KeyCode::Up);
assert_eq!(
app.project_list.cursor(),
1,
"Shift+Up is inert outside Output",
);
press_shift_key(&mut app, KeyCode::Down);
assert_eq!(
app.project_list.cursor(),
1,
"Shift+Down is inert outside Output",
);
}
#[test]
fn output_ctrl_shift_up_selects_from_the_cursor_to_the_top() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma", "delta", "epsilon"]);
press_key(&mut app, KeyCode::Up);
press_key(&mut app, KeyCode::Up);
assert_eq!(app.panes.output.viewport.pos(), 2);
assert_eq!(output_count(&app), 1);
press_ctrl_shift_key(&mut app, KeyCode::Up);
assert_eq!(output_range(&app), Some((0, 2)));
}
#[test]
fn output_ctrl_shift_down_selects_from_the_cursor_to_the_bottom() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma", "delta", "epsilon"]);
press_key(&mut app, KeyCode::Up);
press_key(&mut app, KeyCode::Up);
assert_eq!(app.panes.output.viewport.pos(), 2);
press_ctrl_shift_key(&mut app, KeyCode::Down);
assert_eq!(output_range(&app), Some((2, 4)));
}
#[test]
fn output_shift_arrows_extend_and_shrink_an_active_selection() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma", "delta", "epsilon"]);
assert_eq!(output_range(&app), Some((4, 4)));
press_shift_key(&mut app, KeyCode::Up);
press_shift_key(&mut app, KeyCode::Up);
assert_eq!(
output_range(&app),
Some((2, 4)),
"Shift+Up extends the selection from the anchor",
);
press_shift_key(&mut app, KeyCode::Down);
assert_eq!(
output_range(&app),
Some((3, 4)),
"Shift+Down shrinks the selection",
);
}
#[test]
fn output_esc_collapses_vim_visual_before_stopping_the_run() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app_vim(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma"]);
app.inflight.set_example_running(Some("demo".to_string()));
press_key(&mut app, KeyCode::Char('V'));
assert!(app.panes.output.selection().is_visual());
press_key(&mut app, KeyCode::Esc);
assert!(!app.panes.output.selection().is_visual());
assert!(
app.inflight.example_running().is_some(),
"leaving visual mode must not stop the running process",
);
press_key(&mut app, KeyCode::Esc);
assert!(app.inflight.example_running().is_none());
assert_eq!(
app.inflight.example_output().last().map(String::as_str),
Some("── killed ──"),
);
}
#[test]
fn output_title_shows_visual_hint_even_while_running() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app_vim(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma"]);
app.inflight.set_example_running(Some("demo".to_string()));
let running = buffer_text_sized(&mut app, 120, 40);
assert!(
running.contains("Running: demo"),
"title shows the running process before entering visual mode",
);
press_key(&mut app, KeyCode::Char('V'));
let visual = buffer_text_sized(&mut app, 120, 40);
assert!(
visual.contains("visual") && visual.contains("y copy"),
"pressing V switches the title to the visual hint even while running",
);
}
#[test]
fn output_yank_strips_ansi_from_selection() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["\u{1b}[31mred line\u{1b}[0m"]);
let mut clipboard = RecordingClipboard::default();
app.copy_focused_selection_with_backend(&mut clipboard);
assert_eq!(clipboard.written.as_deref(), Some("red line"));
}
#[test]
fn output_render_drops_non_sgr_escape_sequences() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(
&mut app,
&["before \u{1b}[6nafter", "start \u{1b}Pignored\u{1b}\\end"],
);
let text = buffer_text_sized(&mut app, 120, 40);
assert!(text.contains("before after"));
assert!(text.contains("start end"));
assert!(!text.contains('\u{1b}'));
assert!(!text.contains("[6n"));
assert!(!text.contains("ignored"));
}
#[test]
fn output_vim_esc_collapses_then_a_second_esc_closes_the_pane() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app_vim(&[project]);
open_output(&mut app, &["alpha", "beta"]);
press_key(&mut app, KeyCode::Char('V'));
press_key(&mut app, KeyCode::Up);
assert!(app.panes.output.selection().is_visual());
press_key(&mut app, KeyCode::Esc);
assert!(!app.panes.output.selection().is_visual());
assert!(
!app.inflight.example_output().is_empty(),
"the first Esc only leaves visual mode, not the pane",
);
assert_eq!(app.focused_pane_id(), PaneId::Output);
press_key(&mut app, KeyCode::Esc);
assert!(
app.inflight.example_output().is_empty(),
"the second Esc closes the pane",
);
assert_eq!(app.focused_pane_id(), PaneId::Targets);
}
#[test]
fn focused_output_selection_row_highlight_fills_full_width() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta", "gamma"]);
assert_eq!(app.focused_pane_id(), PaneId::Output);
assert!(
app.panes.output.is_following(),
"cursor sits on the tail row"
);
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap_or_else(|_| std::process::abort());
terminal
.draw(|frame| render::ui(frame, &mut app))
.unwrap_or_else(|_| std::process::abort());
let buffer = terminal.backend().buffer().clone();
let area = buffer.area;
let mut cursor_row = None;
for y in 0..area.height {
let row: String = (0..area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect();
if let Some(col) = row.find("gamma") {
cursor_row = Some((u16::try_from(col).unwrap_or(0), y));
break;
}
}
let (text_col, y) = cursor_row.expect("the tail row is rendered");
let probe = buffer[(text_col + 30, y)].bg;
assert_eq!(
probe,
tui_pane::finder_match_bg(),
"selection row highlight should fill the full pane width",
);
}
#[test]
fn selection_row_highlight_covers_ansi_colored_log_text() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["\u{1b}[32mINFO\u{1b}[0m starting up"]);
assert_eq!(app.focused_pane_id(), PaneId::Output);
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap_or_else(|_| std::process::abort());
terminal
.draw(|frame| render::ui(frame, &mut app))
.unwrap_or_else(|_| std::process::abort());
let buffer = terminal.backend().buffer().clone();
let area = buffer.area;
let mut info_cell = None;
for y in 0..area.height {
let row: String = (0..area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect();
if let Some(col) = row.find("INFO") {
info_cell = Some((u16::try_from(col).unwrap_or(0), y));
break;
}
}
let (col, y) = info_cell.expect("the colored log line is rendered");
assert_eq!(
buffer[(col, y)].bg,
tui_pane::finder_match_bg(),
"the highlight must cover the ANSI-colored text, not just the padding",
);
assert_eq!(buffer[(col, y)].fg, ratatui::style::Color::Green);
}
#[test]
fn overlaid_output_steals_focus_from_hidden_diagnostics_pane() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta"]);
app.set_focus_to_pane(PaneId::CiRuns);
assert_eq!(app.focused_pane_id(), PaneId::CiRuns);
let _ = buffer_text_sized(&mut app, 120, 40);
assert_eq!(
app.focused_pane_id(),
PaneId::Output,
"focus must not stay on a pane the Output overlay hides",
);
}
#[test]
fn closing_output_releases_focus_to_a_visible_pane() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta"]);
assert_eq!(app.focused_pane_id(), PaneId::Output);
app.inflight.example_output_mut().clear();
let _ = buffer_text_sized(&mut app, 120, 40);
assert_eq!(
app.focused_pane_id(),
PaneId::Targets,
"focus must not stay on the Output pane once it is hidden",
);
}
#[test]
fn output_yank_copies_the_cursor_row_by_default() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["alpha", "beta"]);
let mut clipboard = RecordingClipboard::default();
app.copy_focused_selection_with_backend(&mut clipboard);
assert_eq!(clipboard.written.as_deref(), Some("beta"));
}
#[test]
fn output_scroll_up_freezes_and_end_resumes_follow() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["a", "b", "c", "d", "e"]);
assert!(app.panes.output.is_following());
press_key(&mut app, KeyCode::Up);
assert!(
!app.panes.output.is_following(),
"scrolling up freezes the view",
);
press_key(&mut app, KeyCode::End);
assert!(
app.panes.output.is_following(),
"End resumes following the tail",
);
}
#[test]
fn output_process_exit_holds_a_range_but_resumes_when_collapsed() {
let project = make_package("demo", Path::new("/tmp/demo"));
let mut app = make_app(&[project]);
open_output(&mut app, &["a", "b", "c"]);
press_key(&mut app, KeyCode::Up);
assert!(!app.panes.output.is_following());
app.panes.output.on_process_exit();
assert!(
app.panes.output.is_following(),
"exit resumes follow when the selection is a single collapsed row",
);
let live = app.inflight.example_output().to_vec();
app.panes.output.select_extend_up(&live);
assert_eq!(output_count(&app), 2);
app.panes.output.on_process_exit();
assert!(
output_count(&app) >= 2,
"exit must not collapse a range the user is selecting",
);
assert!(
!app.panes.output.is_following(),
"exit must not resume follow while a range holds the view",
);
}