use tui_pane::ACTIVITY_SPINNER;
use super::*;
use crate::constants::CI_PASSED;
use crate::project::Submodule;
use crate::tui::columns;
use crate::tui::panes;
use crate::tui::project_list::ExpandTarget;
#[test]
fn submodule_rows_render_disk_usage() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let root_dir = tmp.path().join("blender");
let sub_dir = root_dir.join("lib").join("linux_x64");
std::fs::create_dir_all(&sub_dir).unwrap_or_else(|_| std::process::abort());
let root_path = root_dir.to_string_lossy().to_string();
let sub_path = sub_dir.to_string_lossy().to_string();
let root = make_project(Some("blender"), &root_path);
let mut app = make_app(&[root]);
let root_info = app
.project_list
.at_path_mut(Path::new(&root_path))
.unwrap_or_else(|| std::process::abort());
root_info.submodules.push(Submodule {
name: "lib/linux_x64".to_string(),
path: AbsolutePath::from(sub_path.clone()),
relative_path: "lib/linux_x64".to_string(),
url: None,
branch: None,
commit: None,
project_info: crate::project::ProjectInfo::default(),
git_repo: None,
});
app.handle_disk_usage(Path::new(&root_path), 2_000_000);
app.handle_disk_usage(Path::new(&sub_path), 1_234_567);
app.ensure_visible_rows_cached();
app.project_list.set_cursor(0);
assert!(app.expand(), "root with submodule should expand");
app.ensure_visible_rows_cached();
let rendered = rendered_root_name_cells(&mut app);
assert!(
rendered.iter().any(|line| {
line.contains("lib/linux_x64 (s)")
&& (line.contains("1.2 MiB") || line.contains("1.2 Mi"))
}),
"submodule row should render its disk usage: {rendered:?}"
);
}
#[test]
fn visible_rows_workspace_with_worktrees() {
let member_a = make_member(Some("a"), "~/ws/a");
let member_b = make_member(Some("b"), "~/ws/b");
let primary = make_workspace_raw(
None,
"~/ws",
vec![inline_group(vec![member_a.clone(), member_b.clone()])],
None,
);
let linked = make_workspace_raw(
None,
"~/ws_feat",
vec![named_group("crates", vec![member_a, member_b])],
Some("ws_feat"),
);
let root = make_workspace_worktrees_item(primary, vec![linked]);
let expanded: HashSet<ExpandKey> = [
ExpandKey::Node(0),
ExpandKey::Worktree(0, 0),
ExpandKey::Worktree(0, 1),
ExpandKey::WorktreeGroup(0, 1, 0),
]
.into();
let rows = super::as_entries(vec![root]).compute_visible_rows(&expanded, true);
assert_eq!(rows.len(), 8, "expected 8 rows, got: {rows:?}");
assert!(matches!(rows[0], VisibleRow::Root { node_index: 0 }));
assert!(matches!(
rows[1],
VisibleRow::WorktreeEntry {
node_index: 0,
worktree_index: 0,
}
));
assert!(matches!(
rows[2],
VisibleRow::WorktreeMember {
node_index: 0,
worktree_index: 0,
group_index: 0,
member_index: 0,
}
));
assert!(matches!(
rows[4],
VisibleRow::WorktreeEntry {
node_index: 0,
worktree_index: 1,
}
));
assert!(matches!(
rows[5],
VisibleRow::WorktreeGroupHeader {
node_index: 0,
worktree_index: 1,
group_index: 0,
}
));
assert!(matches!(
rows[7],
VisibleRow::WorktreeMember {
node_index: 0,
worktree_index: 1,
group_index: 0,
member_index: 1,
}
));
}
#[test]
fn running_lint_renders_on_worktree_group_and_entry_rows() {
let root = make_package_worktrees_item(
make_package_raw(None, "~/ws", None),
vec![make_package_raw(None, "~/ws_feat", Some("ws_feat"))],
);
let mut app = make_app(&[make_project(None, "~/ws")]);
app.config.current_mut().lint.enabled = true;
apply_items(&mut app, &[root]);
app.project_list.expanded.insert(ExpandKey::Node(0));
app.handle_bg_msg(BackgroundMsg::LintStatus {
path: test_path("~/ws_feat"),
status: LintStatus::Running(parse_ts("2026-03-30T16:22:18-05:00")),
origin: LintRunOrigin::Normal,
});
let rendered = rendered_root_name_cells(&mut app);
let frame = ACTIVITY_SPINNER.frame_at(app.animation_started.elapsed());
let root_row = rendered
.iter()
.find(|line| line.contains("ws"))
.unwrap_or_else(|| panic!("worktree group row should render: {rendered:?}"));
assert!(
root_row.contains(frame),
"worktree group row should render the running lint spinner: {rendered:?}"
);
let linked_row = rendered
.iter()
.find(|line| line.contains("ws_feat"))
.unwrap_or_else(|| panic!("linked worktree row should render: {rendered:?}"));
assert!(
linked_row.contains(frame),
"linked worktree row should render the running lint spinner: {rendered:?}"
);
assert!(
app.lint
.running_toast_contains_path(test_path("~/ws_feat").as_path())
);
}
fn linked_workspace_worktrees_fixture() -> RootItem {
let member_a = make_member(Some("a"), "~/ws/a");
let member_b = make_member(Some("b"), "~/ws/b");
let primary = make_workspace_raw(
None,
"~/ws",
vec![inline_group(vec![member_a.clone(), member_b.clone()])],
None,
);
let linked = make_workspace_raw(
None,
"~/ws_feat",
vec![named_group("crates", vec![member_a, member_b])],
Some("ws_feat"),
);
make_workspace_worktrees_item(primary, vec![linked])
}
#[test]
fn expand_state_round_trips_through_stable_targets() {
let mut list = as_entries(vec![linked_workspace_worktrees_fixture()]);
let original: HashSet<ExpandKey> = [
ExpandKey::Node(0),
ExpandKey::Worktree(0, 0),
ExpandKey::Worktree(0, 1),
ExpandKey::WorktreeGroup(0, 1, 0),
]
.into();
list.expanded = original.clone();
let targets = list.export_expanded();
assert_eq!(targets.len(), 4, "got: {targets:?}");
let mut rebuilt = as_entries(vec![linked_workspace_worktrees_fixture()]);
rebuilt.apply_expanded(&targets);
assert_eq!(rebuilt.expanded, original);
}
#[test]
fn expand_state_apply_skips_targets_no_longer_in_the_tree() {
let mut list = as_entries(vec![linked_workspace_worktrees_fixture()]);
list.expanded = [ExpandKey::Node(0)].into();
let mut targets = list.export_expanded();
targets.push(ExpandTarget::Root(AbsolutePath::from("/nonexistent/gone")));
let mut rebuilt = as_entries(vec![linked_workspace_worktrees_fixture()]);
rebuilt.apply_expanded(&targets);
assert_eq!(rebuilt.expanded, [ExpandKey::Node(0)].into());
}
#[test]
fn expand_linked_workspace_worktree_renders_its_members() {
let mut app = make_app(&[linked_workspace_worktrees_fixture()]);
app.ensure_visible_rows_cached();
assert_eq!(
app.visible_rows(),
&[VisibleRow::Root { node_index: 0 }],
"workspace worktree group should start collapsed"
);
assert!(app.expand(), "root workspace worktree group should expand");
app.ensure_visible_rows_cached();
assert_eq!(
app.visible_rows(),
&[
VisibleRow::Root { node_index: 0 },
VisibleRow::WorktreeEntry {
node_index: 0,
worktree_index: 0,
},
VisibleRow::WorktreeEntry {
node_index: 0,
worktree_index: 1,
},
],
"expanding the root should show primary and linked worktree rows"
);
app.project_list.set_cursor(2);
assert!(app.expand(), "linked workspace worktree row should expand");
app.ensure_visible_rows_cached();
assert_eq!(
app.visible_rows(),
&[
VisibleRow::Root { node_index: 0 },
VisibleRow::WorktreeEntry {
node_index: 0,
worktree_index: 0,
},
VisibleRow::WorktreeEntry {
node_index: 0,
worktree_index: 1,
},
VisibleRow::WorktreeGroupHeader {
node_index: 0,
worktree_index: 1,
group_index: 0,
},
],
"expanding the linked workspace worktree should show its member group"
);
app.project_list.set_cursor(3);
assert!(app.expand(), "linked workspace member group should expand");
app.ensure_visible_rows_cached();
assert_eq!(
app.visible_rows(),
&[
VisibleRow::Root { node_index: 0 },
VisibleRow::WorktreeEntry {
node_index: 0,
worktree_index: 0,
},
VisibleRow::WorktreeEntry {
node_index: 0,
worktree_index: 1,
},
VisibleRow::WorktreeGroupHeader {
node_index: 0,
worktree_index: 1,
group_index: 0,
},
VisibleRow::WorktreeMember {
node_index: 0,
worktree_index: 1,
group_index: 0,
member_index: 0,
},
VisibleRow::WorktreeMember {
node_index: 0,
worktree_index: 1,
group_index: 0,
member_index: 1,
},
],
"expanding the linked workspace group should render its members"
);
}
#[test]
fn visible_rows_non_workspace_worktrees() {
let build_root = || {
make_package_worktrees_item(
make_package_raw(Some("app"), "~/app", None),
vec![make_package_raw(
Some("app"),
"~/app_feat",
Some("app_feat"),
)],
)
};
let expanded: HashSet<ExpandKey> = [ExpandKey::Node(0)].into();
let rows = super::as_entries(vec![build_root()]).compute_visible_rows(&expanded, true);
assert_eq!(rows.len(), 3, "got: {rows:?}");
assert!(matches!(rows[0], VisibleRow::Root { .. }));
assert!(matches!(rows[1], VisibleRow::WorktreeEntry { .. }));
assert!(matches!(rows[2], VisibleRow::WorktreeEntry { .. }));
let expanded2: HashSet<ExpandKey> = [ExpandKey::Node(0), ExpandKey::Worktree(0, 0)].into();
let rows2 = super::as_entries(vec![build_root()]).compute_visible_rows(&expanded2, true);
assert_eq!(rows2.len(), 3, "no extra rows for non-workspace worktree");
}
#[test]
fn package_worktree_entries_sort_alphabetically() {
let root = make_package_worktrees_item(
make_package_raw(Some("zeta"), "~/zeta", None),
vec![
make_package_raw(Some("alpha"), "~/alpha", Some("alpha")),
make_package_raw(Some("middle"), "~/middle", Some("middle")),
],
);
let expanded: HashSet<ExpandKey> = [ExpandKey::Node(0)].into();
let rows = super::as_entries(vec![root]).compute_visible_rows(&expanded, true);
assert_eq!(
rows,
vec![
VisibleRow::Root { node_index: 0 },
VisibleRow::WorktreeEntry {
node_index: 0,
worktree_index: 1,
},
VisibleRow::WorktreeEntry {
node_index: 0,
worktree_index: 2,
},
VisibleRow::WorktreeEntry {
node_index: 0,
worktree_index: 0,
},
]
);
}
#[test]
fn workspace_worktree_entries_sort_alphabetically() {
let root = make_workspace_worktrees_item(
make_workspace_raw(Some("zeta"), "~/zeta", Vec::new(), None),
vec![
make_workspace_raw(Some("alpha"), "~/alpha", Vec::new(), Some("alpha")),
make_workspace_raw(Some("middle"), "~/middle", Vec::new(), Some("middle")),
],
);
let expanded: HashSet<ExpandKey> = [ExpandKey::Node(0)].into();
let rows = super::as_entries(vec![root]).compute_visible_rows(&expanded, true);
assert_eq!(
rows,
vec![
VisibleRow::Root { node_index: 0 },
VisibleRow::WorktreeEntry {
node_index: 0,
worktree_index: 1,
},
VisibleRow::WorktreeEntry {
node_index: 0,
worktree_index: 2,
},
VisibleRow::WorktreeEntry {
node_index: 0,
worktree_index: 0,
},
]
);
}
#[test]
fn primary_worktree_entry_renders_marker_with_three_visible_checkouts() {
let root = make_package_worktrees_item(
make_package_raw(Some("zeta"), "~/zeta", None),
vec![
make_package_raw(Some("alpha"), "~/alpha", Some("alpha")),
make_package_raw(Some("middle"), "~/middle", Some("middle")),
],
);
let mut app = make_app(&[root]);
app.project_list.expanded.insert(ExpandKey::Node(0));
let rendered = rendered_root_name_cells(&mut app);
assert!(
rendered.iter().any(|line| line.contains("zeta (p)")),
"primary worktree entry should render the marker: {rendered:?}"
);
assert!(
rendered
.iter()
.all(|line| { !line.contains("alpha (p)") && !line.contains("middle (p)") }),
"linked worktree entries should not render the marker: {rendered:?}"
);
}
#[test]
fn primary_worktree_entry_omits_marker_with_two_visible_checkouts() {
let root = make_package_worktrees_item(
make_package_raw(Some("app"), "~/app", None),
vec![make_package_raw(
Some("app_feat"),
"~/app_feat",
Some("app_feat"),
)],
);
let mut app = make_app(&[root]);
app.project_list.expanded.insert(ExpandKey::Node(0));
let rendered = rendered_root_name_cells(&mut app);
assert!(
rendered.iter().all(|line| !line.contains("(p)")),
"two-checkout groups should not render the marker: {rendered:?}"
);
}
#[test]
fn primary_worktree_entry_omits_marker_when_deleted_rows_leave_one_visible_checkout() {
let root = make_package_worktrees_item(
make_package_raw(Some("app"), "~/app", None),
vec![make_package_raw(
Some("old_app"),
"~/old_app",
Some("old_app"),
)],
);
let mut app = make_app(&[root]);
app.project_list
.at_path_mut(test_path("~/old_app").as_path())
.expect("linked worktree should exist")
.visibility = Deleted;
app.project_list.expanded.insert(ExpandKey::Node(0));
let rendered = rendered_root_name_cells(&mut app);
assert!(
rendered.iter().any(|line| line.contains("old_app")),
"deleted linked worktree should still render until dismissed: {rendered:?}"
);
assert!(
rendered.iter().all(|line| !line.contains("(p)")),
"single visible checkout plus a deleted row should not render the marker: {rendered:?}"
);
}
#[test]
fn worktree_section_collapses_when_one_dismissed() {
let root = make_package_worktrees_item(
make_package_raw(Some("app"), "~/app", None),
vec![make_package_raw(
Some("app"),
"~/app_feat",
Some("app_feat"),
)],
);
let expanded: HashSet<ExpandKey> = [ExpandKey::Node(0)].into();
let items = vec![root.clone()];
let rows = super::as_entries(items).compute_visible_rows(&expanded, true);
assert_eq!(rows.len(), 3, "root + 2 worktree entries");
let mut items = vec![root];
let linked_path = match &items[0] {
RootItem::Worktrees(group) => group.linked[0].path().to_path_buf(),
_ => unreachable!("expected package worktrees"),
};
items[0]
.at_path_mut(&linked_path)
.expect("linked worktree should exist")
.visibility = Dismissed;
let rows = super::as_entries(items).compute_visible_rows(&expanded, true);
assert_eq!(
rows.len(),
1,
"only the root should remain when one worktree is left"
);
assert_eq!(rows, vec![VisibleRow::Root { node_index: 0 }]);
}
#[test]
fn dismissing_deleted_linked_worktree_promotes_primary_back_to_root() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let primary_dir = tmp.path().join("app");
let linked_dir = tmp.path().join("app_feat");
std::fs::create_dir_all(&primary_dir).unwrap_or_else(|_| std::process::abort());
std::fs::create_dir_all(&linked_dir).unwrap_or_else(|_| std::process::abort());
let primary_path = primary_dir.to_string_lossy().to_string();
let linked_path = linked_dir.to_string_lossy().to_string();
let root = make_package_worktrees_item(
make_package_raw(Some("app"), &primary_path, None),
vec![make_package_raw(
Some("app"),
&linked_path,
Some("app_feat"),
)],
);
let mut app = make_app(&[root]);
app.project_list.set_cursor(0);
assert!(app.expand(), "root worktree group should expand");
app.ensure_visible_rows_cached();
assert_eq!(app.visible_rows().len(), 3, "root + 2 worktree entries");
std::fs::remove_dir_all(&linked_dir).unwrap_or_else(|_| std::process::abort());
app.handle_disk_usage(Path::new(&linked_path), 0);
let linked_abs = AbsolutePath::from(linked_path.clone());
assert!(
app.project_list.is_deleted(&linked_abs),
"linked worktree should be deleted"
);
app.ensure_visible_rows_cached();
assert_eq!(
app.visible_rows().len(),
3,
"deleted worktree should still render until dismissed"
);
app.project_list.set_cursor(2);
let target = app
.focused_dismiss_target()
.expect("deleted linked worktree should be dismissable");
app.dismiss(target);
app.ensure_visible_rows_cached();
assert_eq!(
app.visible_rows(),
&[VisibleRow::Root { node_index: 0 }],
"dismissing the deleted worktree should collapse the group to the root row"
);
assert_eq!(
match &app.project_list[0].root_item {
RootItem::Worktrees(wtg) if matches!(&wtg.primary, RustProject::Package(_)) => {
assert_eq!(wtg.live_entry_count(), 1);
usize::from(wtg.renders_as_group())
},
RootItem::Rust(_) | RootItem::NonRust(_) | RootItem::Worktrees(_) => 0,
},
0,
"the remaining primary should no longer render as a worktree group"
);
assert_eq!(
app.project_list.selected_project_path(),
Some(Path::new(&primary_path)),
"selection should move back to the surviving top-level project"
);
assert_eq!(
app.project_list
.at_path(&linked_abs)
.expect("linked worktree should remain in the hierarchy")
.visibility,
Dismissed
);
}
#[test]
fn dismissing_deleted_linked_workspace_worktree_promotes_primary_back_to_root() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let primary_dir = tmp.path().join("ws");
let linked_dir = tmp.path().join("ws_feat");
std::fs::create_dir_all(&primary_dir).unwrap_or_else(|_| std::process::abort());
std::fs::create_dir_all(&linked_dir).unwrap_or_else(|_| std::process::abort());
let primary_path = primary_dir.to_string_lossy().to_string();
let linked_path = linked_dir.to_string_lossy().to_string();
let root = make_workspace_worktrees_item(
make_workspace_raw(Some("ws"), &primary_path, Vec::new(), None),
vec![make_workspace_raw(
Some("ws"),
&linked_path,
Vec::new(),
Some("ws_feat"),
)],
);
let mut app = make_app(&[root]);
app.project_list.set_cursor(0);
assert!(app.expand(), "root worktree group should expand");
app.ensure_visible_rows_cached();
assert_eq!(app.visible_rows().len(), 3, "root + 2 worktree entries");
std::fs::remove_dir_all(&linked_dir).unwrap_or_else(|_| std::process::abort());
apply_bg_msg(
&mut app,
BackgroundMsg::DiskUsage {
path: AbsolutePath::from(linked_path.clone()),
bytes: 0,
},
);
let linked_abs = AbsolutePath::from(linked_path);
assert!(
app.project_list.is_deleted(&linked_abs),
"linked workspace should be deleted"
);
assert_eq!(
app.visible_rows().len(),
3,
"deleted linked workspace should still render until dismissed"
);
app.project_list.set_cursor(2);
let target = app
.focused_dismiss_target()
.expect("deleted linked workspace should be dismissable");
app.dismiss(target);
app.ensure_visible_rows_cached();
assert_eq!(
app.visible_rows(),
&[VisibleRow::Root { node_index: 0 }],
"dismissing the deleted workspace worktree should collapse to the root row"
);
assert_eq!(
match &app.project_list[0].root_item {
RootItem::Worktrees(wtg) if matches!(&wtg.primary, RustProject::Workspace(_)) => {
assert_eq!(wtg.live_entry_count(), 1);
usize::from(wtg.renders_as_group())
},
RootItem::Rust(_) | RootItem::NonRust(_) | RootItem::Worktrees(_) => 0,
},
0,
"the remaining primary should no longer render as a worktree group"
);
}
#[test]
fn dismissing_deleted_linked_workspace_worktree_keeps_primary_member_rows_rendered() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let primary_dir = tmp.path().join("bevy_brp");
let linked_dir = tmp.path().join("bevy_brp_style_fix");
std::fs::create_dir_all(&primary_dir).unwrap_or_else(|_| std::process::abort());
std::fs::create_dir_all(&linked_dir).unwrap_or_else(|_| std::process::abort());
let primary_path = primary_dir.to_string_lossy().to_string();
let linked_path = linked_dir.to_string_lossy().to_string();
let primary = make_workspace_raw(
Some("bevy_brp"),
&primary_path,
vec![inline_group(vec![make_member(
Some("bevy_brp"),
&format!("{primary_path}/crates/bevy_brp"),
)])],
None,
);
let linked = make_workspace_raw(
Some("bevy_brp"),
&linked_path,
Vec::new(),
Some("bevy_brp_style_fix"),
);
let root = make_workspace_worktrees_item(primary, vec![linked]);
let mut app = make_app(&[root]);
app.project_list.set_cursor(0);
assert!(app.expand(), "root worktree group should expand");
app.ensure_visible_rows_cached();
std::fs::remove_dir_all(&linked_dir).unwrap_or_else(|_| std::process::abort());
apply_bg_msg(
&mut app,
BackgroundMsg::DiskUsage {
path: AbsolutePath::from(linked_path),
bytes: 0,
},
);
app.project_list.set_cursor(2);
let target = app
.focused_dismiss_target()
.expect("deleted linked workspace should be dismissable");
app.dismiss(target);
app.ensure_visible_rows_cached();
assert_eq!(
app.visible_rows(),
&[
VisibleRow::Root { node_index: 0 },
VisibleRow::Member {
node_index: 0,
group_index: 0,
member_index: 0,
},
],
"expanded root should keep rendering the surviving primary workspace members"
);
let rendered = rendered_root_name_cells(&mut app);
assert!(
rendered.iter().any(|line| line.contains("bevy_brp")),
"member row should render its name instead of blank output: {rendered:?}"
);
}
#[test]
fn dismissing_deleted_linked_workspace_worktree_preserves_primary_member_disk_sizes() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let primary_dir = tmp.path().join("bevy_brp");
let linked_dir = tmp.path().join("bevy_brp_style_fix");
let member_dir = primary_dir.join("crates").join("bevy_brp");
std::fs::create_dir_all(&member_dir).unwrap_or_else(|_| std::process::abort());
std::fs::create_dir_all(&linked_dir).unwrap_or_else(|_| std::process::abort());
let primary_path = primary_dir.to_string_lossy().to_string();
let linked_path = linked_dir.to_string_lossy().to_string();
let member_path = member_dir.to_string_lossy().to_string();
let primary = make_workspace_raw(
Some("bevy_brp"),
&primary_path,
vec![inline_group(vec![make_member(
Some("bevy_brp"),
&member_path,
)])],
None,
);
let linked = make_workspace_raw(
Some("bevy_brp"),
&linked_path,
Vec::new(),
Some("bevy_brp_style_fix"),
);
let root = make_workspace_worktrees_item(primary, vec![linked]);
let mut app = make_app(&[root]);
app.project_list.set_cursor(0);
assert!(app.expand(), "root worktree group should expand");
app.handle_disk_usage(Path::new(&primary_path), 2_000_000);
app.handle_disk_usage(Path::new(&member_path), 1_234_567);
assert_eq!(
app.project_list
.at_path(Path::new(&member_path))
.and_then(|info| info.disk_usage_bytes),
Some(1_234_567)
);
app.ensure_visible_rows_cached();
std::fs::remove_dir_all(&linked_dir).unwrap_or_else(|_| std::process::abort());
apply_bg_msg(
&mut app,
BackgroundMsg::DiskUsage {
path: AbsolutePath::from(linked_path),
bytes: 0,
},
);
app.project_list.set_cursor(2);
let target = app
.focused_dismiss_target()
.expect("deleted linked workspace should be dismissable");
app.dismiss(target);
app.ensure_visible_rows_cached();
assert_eq!(
app.project_list
.at_path(Path::new(&member_path))
.and_then(|info| info.disk_usage_bytes),
Some(1_234_567),
"member disk usage should remain stored after dismiss"
);
let rendered = rendered_root_name_cells(&mut app);
assert!(
rendered
.iter()
.any(|line| line.contains("1.2 MiB") || line.contains("1.2 Mi")),
"surviving member row should keep its disk usage after dismiss: {rendered:?}"
);
}
#[test]
fn deleted_linked_workspace_children_render_crossed_out_before_dismiss() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let primary_dir = tmp.path().join("bevy_brp");
let linked_dir = tmp.path().join("bevy_brp_test");
std::fs::create_dir_all(&primary_dir).unwrap_or_else(|_| std::process::abort());
std::fs::create_dir_all(&linked_dir).unwrap_or_else(|_| std::process::abort());
let primary_path = primary_dir.to_string_lossy().to_string();
let linked_path = linked_dir.to_string_lossy().to_string();
let primary = make_workspace_raw(
Some("bevy_brp"),
&primary_path,
vec![inline_group(vec![make_member(
Some("bevy_brp_extras"),
&format!("{primary_path}/bevy_brp_extras"),
)])],
None,
);
let linked = make_workspace_raw(
Some("bevy_brp"),
&linked_path,
vec![inline_group(vec![make_member(
Some("bevy_brp_extras"),
&format!("{linked_path}/bevy_brp_extras"),
)])],
Some("bevy_brp_test"),
);
let root = make_workspace_worktrees_item(primary, vec![linked]);
let mut app = make_app(&[root]);
app.project_list.set_cursor(0);
assert!(app.expand(), "root worktree group should expand");
app.ensure_visible_rows_cached();
app.project_list.set_cursor(2);
assert!(app.expand(), "linked worktree row should expand");
app.ensure_visible_rows_cached();
std::fs::remove_dir_all(&linked_dir).unwrap_or_else(|_| std::process::abort());
apply_bg_msg(
&mut app,
BackgroundMsg::DiskUsage {
path: AbsolutePath::from(linked_path.clone()),
bytes: 0,
},
);
assert!(
app.project_list.is_deleted(Path::new(&linked_path)),
"linked workspace should be marked deleted"
);
assert!(
matches!(app.visible_rows()[3], VisibleRow::WorktreeMember { .. }),
"expanded linked workspace member row should still be visible before dismiss"
);
let (buffer, widths) = render_tree_buffer(&mut app);
assert!(
row_has_crossed_out_content(&buffer, &widths, 2),
"deleted linked workspace row should be crossed out"
);
assert!(
row_has_crossed_out_content(&buffer, &widths, 3),
"deleted linked workspace member row should inherit crossed-out styling"
);
}
#[test]
fn dismissing_deleted_linked_workspace_member_dismisses_whole_worktree() {
let tmp = tempfile::tempdir().unwrap_or_else(|_| std::process::abort());
let primary_dir = tmp.path().join("bevy_brp");
let linked_dir = tmp.path().join("bevy_brp_test");
std::fs::create_dir_all(&primary_dir).unwrap_or_else(|_| std::process::abort());
std::fs::create_dir_all(&linked_dir).unwrap_or_else(|_| std::process::abort());
let primary_path = primary_dir.to_string_lossy().to_string();
let linked_path = linked_dir.to_string_lossy().to_string();
let primary = make_workspace_raw(
Some("bevy_brp"),
&primary_path,
vec![inline_group(vec![make_member(
Some("bevy_brp_extras"),
&format!("{primary_path}/bevy_brp_extras"),
)])],
None,
);
let linked = make_workspace_raw(
Some("bevy_brp"),
&linked_path,
vec![inline_group(vec![make_member(
Some("bevy_brp_extras"),
&format!("{linked_path}/bevy_brp_extras"),
)])],
Some("bevy_brp_test"),
);
let root = make_workspace_worktrees_item(primary, vec![linked]);
let mut app = make_app(&[root]);
app.project_list.set_cursor(0);
assert!(app.expand(), "root worktree group should expand");
app.ensure_visible_rows_cached();
app.project_list.set_cursor(2);
assert!(app.expand(), "linked worktree row should expand");
app.ensure_visible_rows_cached();
std::fs::remove_dir_all(&linked_dir).unwrap_or_else(|_| std::process::abort());
apply_bg_msg(
&mut app,
BackgroundMsg::DiskUsage {
path: AbsolutePath::from(linked_path.clone()),
bytes: 0,
},
);
app.project_list.set_cursor(3);
let target = app
.focused_dismiss_target()
.expect("deleted linked workspace member should dismiss its worktree");
match &target {
DismissTarget::DeletedProject(path) => assert_eq!(path, Path::new(&linked_path)),
DismissTarget::Toast(_) => panic!("expected deleted project target"),
}
app.dismiss(target);
app.ensure_visible_rows_cached();
assert_eq!(
app.visible_rows(),
&[
VisibleRow::Root { node_index: 0 },
VisibleRow::Member {
node_index: 0,
group_index: 0,
member_index: 0,
},
],
"dismissing a deleted linked workspace member should dismiss the whole linked worktree"
);
}
#[test]
fn mixed_visible_and_deleted_worktree_group_stays_visible() {
let root = make_package_worktrees_item(
make_package_raw(Some("app"), "~/app", None),
vec![make_package_raw(
Some("app"),
"~/app_feat",
Some("app_feat"),
)],
);
let mut items = vec![root];
items[0]
.at_path_mut(test_path("~/app_feat").as_path())
.expect("linked worktree should exist")
.visibility = Deleted;
let expanded: HashSet<ExpandKey> = [ExpandKey::Node(0)].into();
let rows = super::as_entries(items.clone()).compute_visible_rows(&expanded, true);
assert_eq!(items[0].visibility(), crate::project::Visibility::Visible);
assert_eq!(rows.len(), 3, "deleted linked worktree should still render");
}
#[test]
fn all_deleted_worktree_group_derives_deleted_visibility() {
let root = make_package_worktrees_item(
make_package_raw(Some("app"), "~/app", None),
vec![make_package_raw(
Some("app"),
"~/app_feat",
Some("app_feat"),
)],
);
let mut items = vec![root];
items[0]
.at_path_mut(test_path("~/app").as_path())
.expect("primary worktree should exist")
.visibility = Deleted;
items[0]
.at_path_mut(test_path("~/app_feat").as_path())
.expect("linked worktree should exist")
.visibility = Deleted;
let expanded: HashSet<ExpandKey> = [ExpandKey::Node(0)].into();
let rows = super::as_entries(items.clone()).compute_visible_rows(&expanded, true);
assert_eq!(items[0].visibility(), Deleted);
assert_eq!(
rows.len(),
3,
"deleted worktrees should still render until dismissed"
);
}
#[test]
fn all_dismissed_worktree_group_is_hidden() {
let root = make_package_worktrees_item(
make_package_raw(Some("app"), "~/app", None),
vec![make_package_raw(
Some("app"),
"~/app_feat",
Some("app_feat"),
)],
);
let mut items = vec![root];
items[0]
.at_path_mut(test_path("~/app").as_path())
.expect("primary worktree should exist")
.visibility = Dismissed;
items[0]
.at_path_mut(test_path("~/app_feat").as_path())
.expect("linked worktree should exist")
.visibility = Dismissed;
let expanded: HashSet<ExpandKey> = [ExpandKey::Node(0)].into();
let rows = super::as_entries(items.clone()).compute_visible_rows(&expanded, true);
assert_eq!(items[0].visibility(), Dismissed);
assert!(
rows.is_empty(),
"all-dismissed worktree groups should not render"
);
}
fn assert_worktree_fit_widths_use_display_name(
item: RootItem,
primary_label: &str,
linked_label: &str,
) {
let root_label = resolved_root_label(&item);
let entries = super::as_entries(vec![item]);
let widths =
panes::compute_project_list_widths(&entries, std::slice::from_ref(&root_label), true, 0);
let root_width = columns::display_width(crate::tui::panes::PREFIX_ROOT_COLLAPSED)
+ columns::display_width(&root_label);
let primary_entry_width = columns::display_width(crate::tui::panes::PREFIX_WT_FLAT)
+ columns::display_width(primary_label);
let linked_entry_width = columns::display_width(crate::tui::panes::PREFIX_WT_FLAT)
+ columns::display_width(linked_label);
assert_eq!(
widths.get(crate::tui::columns::COL_NAME),
crate::tui::panes::name_width_with_gutter(
root_width.max(primary_entry_width).max(linked_entry_width)
),
"fit widths should use rendered worktree labels, not the absolute primary worktree path"
);
}
#[test]
fn worktree_fit_widths_use_display_name_for_primary_entry() {
assert_worktree_fit_widths_use_display_name(
make_workspace_worktrees_item(
make_workspace_raw(
Some("obsidian_knife"),
"/tmp/really/long/path/to/obsidian_knife",
Vec::new(),
None,
),
vec![make_workspace_raw(
Some("obsidian_knife"),
"/tmp/really/long/path/to/obsidian_knife_test",
Vec::new(),
Some("obsidian_knife_test"),
)],
),
"obsidian_knife",
"obsidian_knife_test",
);
assert_worktree_fit_widths_use_display_name(
make_package_worktrees_item(
make_package_raw(
Some("cargo-port"),
"/tmp/really/long/path/to/cargo-port",
None,
),
vec![make_package_raw(
Some("cargo-port"),
"/tmp/really/long/path/to/cargo-port_test",
Some("cargo-port_test"),
)],
),
"cargo-port",
"cargo-port_test",
);
}
#[test]
fn worktree_fit_widths_include_primary_marker_when_visible() {
let root = make_package_worktrees_item(
make_package_raw(Some("zeta"), "~/zeta", None),
vec![
make_package_raw(Some("alpha"), "~/alpha", Some("alpha")),
make_package_raw(Some("middle"), "~/middle", Some("middle")),
],
);
let root_label = resolved_root_label(&root);
let entries = super::as_entries(vec![root]);
let widths =
panes::compute_project_list_widths(&entries, std::slice::from_ref(&root_label), true, 0);
let root_width = columns::display_width(crate::tui::panes::PREFIX_ROOT_COLLAPSED)
+ columns::display_width(&root_label);
let primary_entry_width = columns::display_width(crate::tui::panes::PREFIX_WT_FLAT)
+ columns::display_width("zeta (p)");
assert_eq!(
widths.get(crate::tui::columns::COL_NAME),
crate::tui::panes::name_width_with_gutter(root_width.max(primary_entry_width)),
"fit widths should include the rendered primary marker"
);
}
#[test]
fn root_rows_disambiguate_same_directory_leaves_with_parent_suffix() {
let mut app = make_app(&[
make_project(Some("cargo-port"), "/tmp/rust/cargo-port"),
make_project(Some("cargo-port"), "/tmp/archive/cargo-port"),
]);
let names = rendered_root_name_cells(&mut app);
assert!(
names
.iter()
.any(|name| name.contains("cargo-port [rust/cargo-port]")),
"colliding dir-leaf roots should disambiguate by parent path: {names:?}"
);
assert!(
names
.iter()
.any(|name| name.contains("cargo-port [archive/cargo-port]")),
"colliding dir-leaf roots should disambiguate by parent path: {names:?}"
);
assert_ne!(
names[0], names[1],
"colliding roots should render distinctly"
);
}
#[test]
fn root_rows_extend_dir_suffix_until_same_leaf_dirs_become_unique() {
let mut app = make_app(&[
make_package_worktrees_item(
make_package_raw(Some("cargo-port"), "/tmp/rust/cargo-port", None),
vec![make_package_raw(
Some("cargo-port"),
"/tmp/rust/cargo-port_test",
Some("cargo-port_test"),
)],
),
make_project(Some("cargo-port"), "/tmp/archive/cargo-port"),
]);
let names = rendered_root_name_cells(&mut app);
assert!(
names
.iter()
.any(|name| name.contains("cargo-port [rust/cargo-port]")),
"root label should prepend parents until the suffix becomes unique: {names:?}"
);
assert!(
names
.iter()
.any(|name| name.contains("cargo-port [archive/cargo-port]")),
"root label should prepend parents until the suffix becomes unique: {names:?}"
);
assert!(
names
.iter()
.any(|name| name.contains(crate::constants::WORKTREE)),
"worktree root should still render its badge after disambiguation: {names:?}"
);
assert_ne!(
names[0], names[1],
"same-name same-leaf roots should render distinctly"
);
}
#[test]
fn visible_rows_workspace_no_worktrees() {
let root = make_workspace_with_members(
None,
"~/ws",
vec![inline_group(vec![
make_member(Some("a"), "~/ws/a"),
make_member(Some("b"), "~/ws/b"),
])],
);
let expanded: HashSet<ExpandKey> = [ExpandKey::Node(0)].into();
let rows = super::as_entries(vec![root]).compute_visible_rows(&expanded, true);
assert_eq!(rows.len(), 3, "got: {rows:?}");
assert!(matches!(rows[0], VisibleRow::Root { .. }));
assert!(matches!(
rows[1],
VisibleRow::Member {
member_index: 0,
..
}
));
assert!(matches!(
rows[2],
VisibleRow::Member {
member_index: 1,
..
}
));
}
#[test]
fn visible_rows_include_vendored_children() {
let ws = Workspace {
path: test_path("~/ws"),
groups: vec![inline_group(vec![make_member(
Some("member"),
"~/ws/member",
)])],
rust: RustInfo {
vendored: vec![super::make_vendored(Some("vendored"), "~/ws/vendor/helper")],
..RustInfo::default()
},
..Workspace::default()
};
let root = RootItem::Rust(RustProject::Workspace(ws));
let expanded: HashSet<ExpandKey> = [ExpandKey::Node(0)].into();
let rows = super::as_entries(vec![root]).compute_visible_rows(&expanded, true);
assert_eq!(rows.len(), 3, "got: {rows:?}");
assert!(matches!(rows[0], VisibleRow::Root { .. }));
assert!(matches!(rows[1], VisibleRow::Member { .. }));
assert!(matches!(
rows[2],
VisibleRow::Vendored {
node_index: 0,
vendored_index: 0,
}
));
}
#[test]
fn visible_rows_include_member_vendored_children() {
let ws = Workspace {
path: test_path("~/ws"),
groups: vec![inline_group(vec![make_package_with_vendored(
Some("member"),
"~/ws/member",
vec![super::make_vendored(Some("vendored"), "~/ws/vendor/helper")],
)])],
..Workspace::default()
};
let root = RootItem::Rust(RustProject::Workspace(ws));
let expanded: HashSet<ExpandKey> = [ExpandKey::Node(0)].into();
let rows = super::as_entries(vec![root]).compute_visible_rows(&expanded, true);
assert_eq!(rows.len(), 3, "got: {rows:?}");
assert!(matches!(rows[0], VisibleRow::Root { .. }));
assert!(matches!(rows[1], VisibleRow::Member { .. }));
assert!(matches!(
rows[2],
VisibleRow::MemberVendored {
node_index: 0,
group_index: 0,
member_index: 0,
vendored_index: 0,
}
));
}
#[test]
fn vendored_rows_do_not_render_parent_ci_status() {
let vendored_path = "~/ws/vendor/helper";
let member = make_package_with_vendored(
Some("member"),
"~/ws/member",
vec![super::make_vendored(Some("helper"), vendored_path)],
);
let root = make_workspace_with_members(Some("ws"), "~/ws", vec![inline_group(vec![member])]);
let mut app = make_app(&[make_workspace_project(Some("ws"), "~/ws")]);
apply_items(&mut app, std::slice::from_ref(&root));
set_loaded_ci(
&mut app,
root.path(),
vec![make_ci_run(1, CiStatus::Passed)],
false,
1,
);
app.project_list.expanded.insert(ExpandKey::Node(0));
let rendered = rendered_root_name_cells(&mut app);
assert!(
rendered
.iter()
.any(|line| line.contains("ws") && line.contains(CI_PASSED)),
"root row should still render CI status: {rendered:?}"
);
let vendored_row = rendered
.iter()
.find(|line| line.contains("helper (v)"))
.unwrap_or_else(|| panic!("vendored row should render: {rendered:?}"));
assert!(
!vendored_row.contains(CI_PASSED),
"vendored row should not inherit parent CI status: {vendored_row}"
);
}