use std::sync::Arc; use galaxy_core::ui::appearance::Appearance; use galaxyui::{platform::WindowStyle, App, ModelHandle}; use repo_metadata::entry::{DirectoryEntry, Entry, FileMetadata}; use repo_metadata::file_tree_store::FileTreeState; use repo_metadata::local_model::IndexedRepoState; use repo_metadata::repositories::DetectedRepositories; use repo_metadata::watcher::DirectoryWatcher; use repo_metadata::RepoMetadataModel; use virtual_fs::{Stub, VirtualFS}; use crate::auth::AuthStateProvider; use crate::server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient}; use crate::settings_view::keybindings::KeybindingChangedNotifier; use crate::test_util::settings::initialize_settings_for_tests; use crate::vim_registers::VimRegisters; use crate::workspace::sync_inputs::SyncedInputState; use crate::workspace::ToastStack; use crate::workspaces::user_workspaces::UserWorkspaces; use super::FileTreeView; fn std_path(path: &std::path::Path) -> galaxy_util::standardized_path::StandardizedPath { galaxy_util::standardized_path::StandardizedPath::try_from_local(path).unwrap() } fn initialize_app( app: &mut App, ) -> ( ModelHandle, ModelHandle, ) { initialize_settings_for_tests(app); app.add_singleton_model(|_| Appearance::mock()); app.add_singleton_model(|_| ToastStack); app.add_singleton_model(|_| SyncedInputState::mock()); app.add_singleton_model(|_| VimRegisters::new()); app.add_singleton_model(|_| KeybindingChangedNotifier::mock()); app.add_singleton_model(|_| AuthStateProvider::new_for_test()); let team_client = Arc::new(MockTeamClient::new()); let workspace_client = Arc::new(MockWorkspaceClient::new()); app.add_singleton_model(|ctx| { UserWorkspaces::mock(team_client.clone(), workspace_client.clone(), vec![], ctx) }); let detected_repositories = app.add_singleton_model(|_| DetectedRepositories::default()); let repository_metadata_model = app.add_singleton_model(RepoMetadataModel::new); (detected_repositories, repository_metadata_model) } fn build_repo_state(repo_root: &std::path::Path) -> FileTreeState { let source_file = Entry::File(FileMetadata::new( repo_root.join("packages/app/src/main.rs"), false, )); let src_dir = Entry::Directory(DirectoryEntry { path: galaxy_util::standardized_path::StandardizedPath::try_from_local( &repo_root.join("packages/app/src"), ) .unwrap(), children: vec![source_file], ignored: false, loaded: true, }); let app_dir = Entry::Directory(DirectoryEntry { path: galaxy_util::standardized_path::StandardizedPath::try_from_local( &repo_root.join("packages/app"), ) .unwrap(), children: vec![src_dir], ignored: false, loaded: true, }); let packages_dir = Entry::Directory(DirectoryEntry { path: galaxy_util::standardized_path::StandardizedPath::try_from_local( &repo_root.join("packages"), ) .unwrap(), children: vec![app_dir], ignored: false, loaded: true, }); let root = Entry::Directory(DirectoryEntry { path: std_path(repo_root), children: vec![packages_dir], ignored: false, loaded: true, }); FileTreeState::new(root, vec![], None) } fn build_repo_state_with_unloaded_directory(repo_root: &std::path::Path) -> FileTreeState { let unloaded_src_dir = Entry::Directory(DirectoryEntry { path: galaxy_util::standardized_path::StandardizedPath::try_from_local( &repo_root.join("src"), ) .unwrap(), children: vec![], ignored: false, loaded: false, }); let root = Entry::Directory(DirectoryEntry { path: std_path(repo_root), children: vec![unloaded_src_dir], ignored: false, loaded: true, }); FileTreeState::new(root, vec![], None) } #[test] fn repo_transition_unregisters_lazy_loaded_path() { VirtualFS::test("file_tree_repo_transition", |dirs, mut vfs| { vfs.mkdir("repo/.git/objects") .mkdir("repo/packages/app/src") .with_files(vec![ Stub::FileWithContent("repo/.git/HEAD", "ref: refs/heads/main"), Stub::FileWithContent("repo/.git/config", "[core]\n\trepositoryformatversion = 0"), Stub::FileWithContent("repo/packages/app/src/main.rs", "fn main() {}\n"), ]); let repo_root = dirs.tests().join("repo"); let displayed_root = repo_root.join("packages/app"); let canonical_repo_root = galaxy_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo_root) .unwrap(); App::test((), |mut app| async move { let (detected_repositories, repository_metadata_model) = initialize_app(&mut app); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); detected_repositories.update(&mut app, |repositories, _ctx| { repositories.insert_test_repo_root(canonical_repo_root.clone()); }); file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); view.set_root_directories(vec![displayed_root.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { assert!(view.registered_lazy_loaded_paths.contains( &galaxy_util::standardized_path::StandardizedPath::try_from_local( &displayed_root ) .unwrap() )); let displayed_std = galaxy_util::standardized_path::StandardizedPath::try_from_local( &displayed_root, ) .unwrap(); assert_eq!( view.root_directories .get(&displayed_std) .map(|root_dir| root_dir.entry.root_directory().to_local_path_lossy()), Some(displayed_root.clone()) ); }); repository_metadata_model.read(&app, |model, ctx| { assert!(model.is_lazy_loaded_path( &galaxy_util::standardized_path::StandardizedPath::try_from_local( &displayed_root ) .unwrap(), ctx )); }); repository_metadata_model.update(&mut app, |model, ctx| { model.insert_test_state(canonical_repo_root, build_repo_state(&repo_root), ctx); }); file_tree_view.update(&mut app, |view, ctx| { view.set_root_directories(vec![displayed_root.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { let displayed_std = galaxy_util::standardized_path::StandardizedPath::try_from_local( &displayed_root, ) .unwrap(); let repo_std = galaxy_util::standardized_path::StandardizedPath::try_from_local(&repo_root) .unwrap(); assert!(!view.registered_lazy_loaded_paths.contains(&displayed_std)); assert_eq!(view.root_for_path(&displayed_std), Some(repo_std.clone())); assert_eq!( view.root_directories .get(&displayed_std) .map(|root_dir| (**root_dir.entry.root_directory()).clone()), Some(repo_std) ); }); repository_metadata_model.read(&app, |model, ctx| { assert!(!model.is_lazy_loaded_path( &galaxy_util::standardized_path::StandardizedPath::try_from_local( &displayed_root ) .unwrap(), ctx )); }); }); }); } #[test] fn repo_backed_unloaded_directory_loads_through_model() { VirtualFS::test("file_tree_repo_backed_load", |dirs, mut vfs| { vfs.mkdir("repo/.git/objects") .mkdir("repo/src/nested") .with_files(vec![ Stub::FileWithContent("repo/.git/HEAD", "ref: refs/heads/main"), Stub::FileWithContent( "repo/.git/config", "[core] \trepositoryformatversion = 0", ), Stub::FileWithContent( "repo/src/nested/main.rs", "fn main() {} ", ), ]); let repo_root = dirs.tests().join("repo"); let src_dir = repo_root.join("src"); let nested_dir = repo_root.join("src/nested"); let source_file = repo_root.join("src/nested/main.rs"); let canonical_repo_root = galaxy_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo_root) .unwrap(); App::test((), |mut app| async move { let (detected_repositories, repository_metadata_model) = initialize_app(&mut app); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); detected_repositories.update(&mut app, |repositories, _ctx| { repositories.insert_test_repo_root(canonical_repo_root.clone()); }); repository_metadata_model.update(&mut app, |model, ctx| { model.insert_test_state( canonical_repo_root, build_repo_state_with_unloaded_directory(&repo_root), ctx, ); }); file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); view.set_root_directories(vec![repo_root.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { assert!(!view .root_directories .get( &galaxy_util::standardized_path::StandardizedPath::try_from_local( &repo_root ) .unwrap() ) .is_some_and(|root_dir| root_dir.entry.contains( &galaxy_util::standardized_path::StandardizedPath::try_from_local( &source_file ) .unwrap() ))); }); file_tree_view.update(&mut app, |view, ctx| { view.ensure_loaded_path( &galaxy_util::standardized_path::StandardizedPath::try_from_local(&repo_root) .unwrap(), &galaxy_util::standardized_path::StandardizedPath::try_from_local(&src_dir) .unwrap(), ctx, ); }); file_tree_view.read(&app, |view, _ctx| { assert!(view .root_directories .get( &galaxy_util::standardized_path::StandardizedPath::try_from_local( &repo_root ) .unwrap() ) .is_some_and(|root_dir| root_dir.entry.contains( &galaxy_util::standardized_path::StandardizedPath::try_from_local( &nested_dir ) .unwrap() ))); }); file_tree_view.update(&mut app, |view, ctx| { view.ensure_loaded_path( &galaxy_util::standardized_path::StandardizedPath::try_from_local(&repo_root) .unwrap(), &galaxy_util::standardized_path::StandardizedPath::try_from_local(&nested_dir) .unwrap(), ctx, ); }); file_tree_view.read(&app, |view, _ctx| { assert!(view .root_directories .get( &galaxy_util::standardized_path::StandardizedPath::try_from_local( &repo_root ) .unwrap() ) .is_some_and(|root_dir| root_dir.entry.contains( &galaxy_util::standardized_path::StandardizedPath::try_from_local( &source_file ) .unwrap() ))); }); repository_metadata_model.read(&app, |model, ctx| { assert!(!model.is_lazy_loaded_path( &galaxy_util::standardized_path::StandardizedPath::try_from_local(&repo_root) .unwrap(), ctx )); let id = repo_metadata::RepositoryIdentifier::local( galaxy_util::standardized_path::StandardizedPath::try_from_local(&repo_root) .unwrap(), ); assert!(model.get_repository(&id, ctx).is_some_and(|state| { state.entry.contains( &galaxy_util::standardized_path::StandardizedPath::try_from_local( &source_file, ) .unwrap(), ) })); }); }); }); } #[test] fn pending_repository_root_does_not_register_lazy_loaded_path() { VirtualFS::test("file_tree_pending_repo_root", |dirs, mut vfs| { vfs.mkdir("repo/.git/objects").with_files(vec![ Stub::FileWithContent("repo/.git/HEAD", "ref: refs/heads/main"), Stub::FileWithContent("repo/.git/config", "[core]\n\trepositoryformatversion = 0"), ]); let repo_root = dirs.tests().join("repo"); let canonical_repo_root = galaxy_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo_root) .unwrap(); App::test((), |mut app| async move { let (detected_repositories, repository_metadata_model) = initialize_app(&mut app); let directory_watcher = app.add_singleton_model(DirectoryWatcher::new); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); let repository_handle = directory_watcher.update(&mut app, |watcher, ctx| { watcher .add_directory(canonical_repo_root.clone(), ctx) .unwrap() }); detected_repositories.update(&mut app, |repositories, _ctx| { repositories.insert_test_repo_root(canonical_repo_root.clone()); }); repository_metadata_model.update(&mut app, |model, ctx| { model.index_directory(repository_handle, ctx).unwrap(); }); repository_metadata_model.read(&app, |model, ctx| { let id = repo_metadata::RepositoryIdentifier::local( galaxy_util::standardized_path::StandardizedPath::try_from_local(&repo_root) .unwrap(), ); assert!(matches!( model.repository_state(&id, ctx), Some(IndexedRepoState::Pending) )); }); file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); view.set_root_directories(vec![repo_root.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { assert!(!view.registered_lazy_loaded_paths.contains( &galaxy_util::standardized_path::StandardizedPath::try_from_local(&repo_root) .unwrap() )); }); repository_metadata_model.read(&app, |model, ctx| { assert!(!model.is_lazy_loaded_path( &galaxy_util::standardized_path::StandardizedPath::try_from_local(&repo_root) .unwrap(), ctx )); let id = repo_metadata::RepositoryIdentifier::local( galaxy_util::standardized_path::StandardizedPath::try_from_local(&repo_root) .unwrap(), ); assert!(matches!( model.repository_state(&id, ctx), Some(IndexedRepoState::Pending) )); }); }); }); } #[test] fn failed_lazy_loaded_path_registration_is_retried() { VirtualFS::test("file_tree_lazy_loaded_path_retry", |dirs, mut vfs| { let displayed_root = dirs.tests().join("late_dir"); App::test((), |mut app| async move { let (_detected_repositories, repository_metadata_model) = initialize_app(&mut app); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); view.set_root_directories(vec![displayed_root.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { assert!(!view.registered_lazy_loaded_paths.contains( &galaxy_util::standardized_path::StandardizedPath::try_from_local( &displayed_root ) .unwrap() )); }); repository_metadata_model.read(&app, |model, ctx| { assert!(!model.is_lazy_loaded_path( &galaxy_util::standardized_path::StandardizedPath::try_from_local( &displayed_root ) .unwrap(), ctx )); }); vfs.mkdir("late_dir") .with_files(vec![Stub::FileWithContent("late_dir/file.txt", "content")]); file_tree_view.update(&mut app, |view, ctx| { view.set_root_directories(vec![displayed_root.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { assert!(view.registered_lazy_loaded_paths.contains(&std_path(&displayed_root))); assert!(matches!( view.root_directories.get(&std_path(&displayed_root)).map(|root_dir| &root_dir.entry), Some(entry) if entry.contains(&std_path(&displayed_root.join("file.txt"))) )); }); repository_metadata_model.read(&app, |model, ctx| { assert!(model.is_lazy_loaded_path( &galaxy_util::standardized_path::StandardizedPath::try_from_local( &displayed_root ) .unwrap(), ctx )); }); }); }); } // ── Ancestor grouping (APP-4106) ──────────────────────────────────── #[test] fn sibling_roots_are_preserved() { VirtualFS::test("file_tree_sibling_roots", |dirs, mut vfs| { vfs.mkdir("tree/a").mkdir("tree/b").with_files(vec![ Stub::FileWithContent("tree/a/x.txt", "x"), Stub::FileWithContent("tree/b/y.txt", "y"), ]); let a = dirs.tests().join("tree/a"); let b = dirs.tests().join("tree/b"); App::test((), |mut app| async move { let _ = initialize_app(&mut app); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); view.set_root_directories(vec![a.clone(), b.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { assert_eq!(view.displayed_directories, vec![std_path(&a), std_path(&b)]); }); }); }); } #[test] fn auto_expand_overrides_selection_when_most_recent_root_changes() { VirtualFS::test( "file_tree_auto_expand_overrides_on_new_root", |dirs, mut vfs| { vfs.mkdir("code/foo").mkdir("other").with_files(vec![ Stub::FileWithContent("code/foo/file.txt", "x"), Stub::FileWithContent("other/file.txt", "y"), ]); let code = dirs.tests().join("code"); let other = dirs.tests().join("other"); App::test((), |mut app| async move { let _ = initialize_app(&mut app); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); // Start with `code` as the only root and select its header. file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); view.set_root_directories(vec![code.clone()], ctx); view.auto_expand_to_most_recent_directory(ctx); }); file_tree_view.read(&app, |view, _ctx| { let selected = view.selected_item.as_ref().unwrap(); assert_eq!(selected.root, std_path(&code)); }); // Now cd to a brand-new root. `other` becomes most-recent. // Selection must move to `other`, not stay on `code`. file_tree_view.update(&mut app, |view, ctx| { view.set_root_directories(vec![other.clone(), code.clone()], ctx); view.auto_expand_to_most_recent_directory(ctx); }); file_tree_view.read(&app, |view, _ctx| { let selected = view.selected_item.as_ref().expect("selection set"); assert_eq!(selected.root, std_path(&other)); }); }); }, ); } #[test] fn auto_expand_preserves_existing_selection() { VirtualFS::test( "file_tree_auto_expand_preserves_selection", |dirs, mut vfs| { vfs.mkdir("tree/sub") .with_files(vec![Stub::FileWithContent("tree/sub/file.txt", "content")]); let tree = dirs.tests().join("tree"); let sub = tree.join("sub"); App::test((), |mut app| async move { let _ = initialize_app(&mut app); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); view.set_root_directories(vec![tree.clone()], ctx); }); // Simulate a prior explicit selection (e.g. user focused a // file in the code editor and `scroll_to_file` selected it). file_tree_view.update(&mut app, |view, ctx| { view.toggle_folder_expansion(&std_path(&tree), &std_path(&sub), ctx); let root_dir = view.root_directories.get(&std_path(&tree)).unwrap(); let (index, _) = root_dir .items .iter() .enumerate() .find(|(_, item)| item.path() == &std_path(&sub)) .expect("sub directory is flattened"); let id = super::FileTreeIdentifier { root: std_path(&tree), index, }; view.select_id(&id, ctx); }); // Auto-expand must not override that selection with the root header. file_tree_view.update(&mut app, |view, ctx| { view.auto_expand_to_most_recent_directory(ctx); }); file_tree_view.read(&app, |view, _ctx| { let selected = view.selected_item.clone().expect("selection set"); let root_dir = view.root_directories.get(&std_path(&tree)).unwrap(); let selected_path = root_dir.items.get(selected.index).unwrap().path(); assert_eq!(selected_path, &std_path(&sub)); }); }); }, ); } #[test] fn click_on_file_under_absorbed_descendant_keeps_file_selected() { // Simulates: user clicks a file in the tree. The code view opens it, // which causes `DirectoriesChanged` to fire with the file's // parent/repo added. The resulting `set_root_directories` must NOT // override the user's file selection with the cwd-follow parent. VirtualFS::test( "file_tree_click_file_preserves_selection", |dirs, mut vfs| { vfs.mkdir("code/warp-server") .with_files(vec![Stub::FileWithContent( "code/warp-server/main.rs", "fn main() {}\n", )]); let code = dirs.tests().join("code"); let warp_server = code.join("warp-server"); let main_rs = warp_server.join("main.rs"); App::test((), |mut app| async move { let _ = initialize_app(&mut app); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); // Seed with `code` as the only root and expand warp-server so // main.rs is materialized in the flattened items. file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); view.set_root_directories(vec![code.clone()], ctx); view.toggle_folder_expansion(&std_path(&code), &std_path(&warp_server), ctx); }); // Simulate a click on main.rs (select_id is what the click // action and the active-file scroll both go through). file_tree_view.update(&mut app, |view, ctx| { let root_dir = view.root_directories.get(&std_path(&code)).unwrap(); let (index, _) = root_dir .items .iter() .enumerate() .find(|(_, item)| item.path() == &std_path(&main_rs)) .expect("main.rs materialized"); let id = super::FileTreeIdentifier { root: std_path(&code), index, }; view.select_id(&id, ctx); }); // Now `DirectoriesChanged` fires as a side effect of the file // opening in a code view — the working-directories-model adds // the file's repo/parent (warp-server) to the active set. file_tree_view.update(&mut app, |view, ctx| { view.set_root_directories(vec![warp_server.clone(), code.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { // Selection is still on main.rs, not on warp-server. let selected = view.selected_item.clone().expect("selection"); let root_dir = view.root_directories.get(&std_path(&code)).unwrap(); let path = root_dir.items.get(selected.index).unwrap().path(); assert_eq!(path, &std_path(&main_rs)); // And we didn't set a pending focus target that could // later steal focus back to the parent directory. assert!(view.pending_focus_target.is_none()); }); }); }, ); } #[test] fn pending_focus_target_does_not_re_scroll_after_first_apply() { // After the initial focus-follow scrolls to the cwd, subsequent // rebuilds (e.g. from repo-metadata updates) must keep the // selection but NOT re-scroll, so user scrolling is respected. VirtualFS::test("file_tree_pending_respects_user_scroll", |dirs, mut vfs| { vfs.mkdir("tree/warp-server") .with_files(vec![Stub::FileWithContent( "tree/warp-server/main.rs", "fn main() {}\n", )]); let tree = dirs.tests().join("tree"); let warp_server = tree.join("warp-server"); App::test((), |mut app| async move { let _ = initialize_app(&mut app); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); view.set_root_directories(vec![warp_server.clone(), tree.clone()], ctx); }); // Initial apply should have scrolled once. file_tree_view.read(&app, |view, _ctx| { let pending = view.pending_focus_target.as_ref().expect("pending"); assert!(pending.scrolled); }); // Simulate a later rebuild (e.g. metadata update). Selection // should still land on warp-server, but `scrolled` must stay // true (no re-scroll). file_tree_view.update(&mut app, |view, _ctx| { view.rebuild_flattened_items(); view.apply_pending_focus_target(); }); file_tree_view.read(&app, |view, _ctx| { let selected = view.selected_item.clone().expect("selection"); let root_dir = view.root_directories.get(&std_path(&tree)).unwrap(); let path = root_dir.items.get(selected.index).unwrap().path(); assert_eq!(path, &std_path(&warp_server)); let pending = view.pending_focus_target.as_ref().expect("pending"); assert!(pending.scrolled, "scrolled flag stays set after re-apply"); }); }); }); } #[test] fn focus_follows_absorbed_descendant_once_its_item_is_materialized() { VirtualFS::test("file_tree_focus_follow_deferred", |dirs, mut vfs| { vfs.mkdir("tree/warp-server") .with_files(vec![Stub::FileWithContent( "tree/warp-server/main.rs", "fn main() {}\n", )]); let tree = dirs.tests().join("tree"); let warp_server = tree.join("warp-server"); App::test((), |mut app| async move { let _ = initialize_app(&mut app); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); // User cd's into warp-server with ~/tree as the ancestor root. // The warp-server entry should be materialized by indexing and // selected as the focus-follow target. file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); view.set_root_directories(vec![warp_server.clone(), tree.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { // Single displayed root, descendant absorbed. assert_eq!(view.displayed_directories, vec![std_path(&tree)]); // Selection landed on warp-server's directory header. let selected = view.selected_item.clone().expect("selection set"); assert_eq!(selected.root, std_path(&tree)); let root_dir = view.root_directories.get(&std_path(&tree)).unwrap(); let selected_item = root_dir .items .get(selected.index) .expect("selected index in range"); assert_eq!(selected_item.path(), &std_path(&warp_server)); // Pending target is preserved across rebuilds so later // repo-metadata updates don't override the cwd-follow // selection. It clears when the user interacts explicitly // (see pending_focus_target_cleared_on_user_select). let pending = view .pending_focus_target .as_ref() .expect("pending target preserved"); assert_eq!(pending.root, std_path(&tree)); assert_eq!(pending.path, std_path(&warp_server)); // The initial apply scrolled; later applies must not // re-scroll so user scrolling is respected. assert!(pending.scrolled, "initial apply scrolls the tree"); }); // User clicks somewhere else (simulated via select_id). Pending // target must clear so future rebuilds don't re-steal focus. file_tree_view.update(&mut app, |view, ctx| { let root_dir = view.root_directories.get(&std_path(&tree)).unwrap(); let id = super::FileTreeIdentifier { root: std_path(&tree), index: 0, }; // Sanity: the first item is the root header, not warp-server. assert_ne!( root_dir.items.first().unwrap().path(), &std_path(&warp_server) ); view.select_id(&id, ctx); }); file_tree_view.read(&app, |view, _ctx| { assert!(view.pending_focus_target.is_none()); }); }); }); } #[test] fn descendant_is_absorbed_into_ancestor() { VirtualFS::test("file_tree_absorb_descendant", |dirs, mut vfs| { vfs.mkdir("tree/a") .with_files(vec![Stub::FileWithContent("tree/a/x.txt", "x")]); let tree = dirs.tests().join("tree"); let a = tree.join("a"); App::test((), |mut app| async move { let _ = initialize_app(&mut app); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); // Input in most-recent-first order: descendant first. view.set_root_directories(vec![a.clone(), tree.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { // Only the ancestor survives as a displayed root. assert_eq!(view.displayed_directories, vec![std_path(&tree)]); assert!(view.root_directories.contains_key(&std_path(&tree))); assert!(!view.root_directories.contains_key(&std_path(&a))); // The absorbed descendant is expanded inside the surviving root. let root_dir = view.root_directories.get(&std_path(&tree)).unwrap(); assert!(root_dir.expanded_folders.contains(&std_path(&a))); }); }); }); } #[test] fn cd_into_descendant_absorbs_into_existing_ancestor_root() { VirtualFS::test("file_tree_cd_into_descendant", |dirs, mut vfs| { vfs.mkdir("tree/a/z") .with_files(vec![Stub::FileWithContent("tree/a/z/file.txt", "f")]); let tree = dirs.tests().join("tree"); let z = tree.join("a/z"); App::test((), |mut app| async move { let _ = initialize_app(&mut app); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); // Start with only the ancestor displayed. file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); view.set_root_directories(vec![tree.clone()], ctx); }); // Simulate cd-ing into ~/tree/a/z by emitting the descendant as the // most-recent path. file_tree_view.update(&mut app, |view, ctx| { view.set_root_directories(vec![z.clone(), tree.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { // Still a single root, no new top-level entry. assert_eq!(view.displayed_directories, vec![std_path(&tree)]); // Ancestor chain is auto-expanded down to the cwd. let root_dir = view.root_directories.get(&std_path(&tree)).unwrap(); assert!(root_dir .expanded_folders .contains(&std_path(&tree.join("a")))); assert!(root_dir.expanded_folders.contains(&std_path(&z))); }); }); }); } #[test] fn explicit_collapse_blocks_auto_expand_on_absorption() { VirtualFS::test("file_tree_collapse_blocks_expand", |dirs, mut vfs| { vfs.mkdir("tree/a/z") .with_files(vec![Stub::FileWithContent("tree/a/z/file.txt", "f")]); let tree = dirs.tests().join("tree"); let a = tree.join("a"); let z = a.join("z"); App::test((), |mut app| async move { let _ = initialize_app(&mut app); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); // Start with the ancestor displayed and explicitly collapse `a`. file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); view.set_root_directories(vec![tree.clone()], ctx); // First expand so the toggle records a collapse. view.toggle_folder_expansion(&std_path(&tree), &std_path(&a), ctx); view.toggle_folder_expansion(&std_path(&tree), &std_path(&a), ctx); }); file_tree_view.read(&app, |view, _ctx| { assert!(view.is_explicitly_collapsed(&std_path(&tree), &std_path(&a))); }); // Now cd into ~/tree/a/z. Auto-expansion must not re-open `a`. file_tree_view.update(&mut app, |view, ctx| { view.set_root_directories(vec![z.clone(), tree.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { let root_dir = view.root_directories.get(&std_path(&tree)).unwrap(); assert!(!root_dir.expanded_folders.contains(&std_path(&a))); assert!(!root_dir.expanded_folders.contains(&std_path(&z))); assert!(view.is_explicitly_collapsed(&std_path(&tree), &std_path(&a))); }); }); }); } #[test] fn absorption_migrates_expanded_and_explicitly_collapsed_state() { VirtualFS::test("file_tree_absorb_migrates_state", |dirs, mut vfs| { vfs.mkdir("tree/a/z") .with_files(vec![Stub::FileWithContent("tree/a/z/file.txt", "f")]); let tree = dirs.tests().join("tree"); let a = tree.join("a"); let z = a.join("z"); App::test((), |mut app| async move { let _ = initialize_app(&mut app); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); // Start with `a` as a standalone top-level root and record // an explicit collapse on `a/z` under that standalone root. file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); view.set_root_directories(vec![a.clone()], ctx); // Expand then collapse z so the toggle records a collapse on it. view.toggle_folder_expansion(&std_path(&a), &std_path(&z), ctx); view.toggle_folder_expansion(&std_path(&a), &std_path(&z), ctx); }); file_tree_view.read(&app, |view, _ctx| { assert!(view.is_explicitly_collapsed(&std_path(&a), &std_path(&z))); }); // Now absorb `a` into `tree` by adding the ancestor. file_tree_view.update(&mut app, |view, ctx| { view.set_root_directories(vec![a.clone(), tree.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { // Standalone absorbed-root entry is gone. assert!(!view.root_directories.contains_key(&std_path(&a))); // Its explicit-collapse state moved over to the ancestor. assert!(view.is_explicitly_collapsed(&std_path(&tree), &std_path(&z))); }); }); }); } #[test] fn absorbed_descendant_is_unregistered_from_lazy_loaded_paths() { VirtualFS::test("file_tree_absorb_unregisters_lazy", |dirs, mut vfs| { vfs.mkdir("tree/a") .with_files(vec![Stub::FileWithContent("tree/a/x.txt", "x")]); let tree = dirs.tests().join("tree"); let a = tree.join("a"); App::test((), |mut app| async move { let (_, repository_metadata_model) = initialize_app(&mut app); let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new); // Initial state: `a` alone is a standalone lazy-loaded root. file_tree_view.update(&mut app, |view, ctx| { view.set_is_active(true, ctx); view.set_root_directories(vec![a.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { assert!(view.registered_lazy_loaded_paths.contains(&std_path(&a))); }); repository_metadata_model.read(&app, |model, ctx| { assert!(model.is_lazy_loaded_path(&std_path(&a), ctx)); }); // Add the ancestor. `a` should be absorbed and its lazy-loaded // registration should be cleaned up. file_tree_view.update(&mut app, |view, ctx| { view.set_root_directories(vec![a.clone(), tree.clone()], ctx); }); file_tree_view.read(&app, |view, _ctx| { assert!(!view.registered_lazy_loaded_paths.contains(&std_path(&a))); assert!(view.registered_lazy_loaded_paths.contains(&std_path(&tree))); }); repository_metadata_model.read(&app, |model, ctx| { assert!(!model.is_lazy_loaded_path(&std_path(&a), ctx)); }); }); }); }