# APP-4106: Tech Spec Linear: https://linear.app/warpdotdev/issue/APP-4106/group-by-shared-root-in-file-tree Product spec: `specs/APP-4106/PRODUCT.md` ## Problem `FileTreeView` treats every working-directory path emitted by `WorkingDirectoriesModel` as a separate top-level root, even when one path is a strict ancestor of another. Upstream deduplication (`deduplicate_by_directory_name` in `left_panel.rs`) only removes exact duplicates; the view itself has no ancestor-awareness. `GlobalSearchView` already does this with a private `deduplicate_search_roots`, so the two panels disagree on what the active roots are for the same pane group. The implementation needs to (a) collapse descendants into their ancestor in the file tree's displayed-root set, (b) auto-expand the ancestor chain down to each absorbed descendant while respecting explicit collapses, (c) migrate per-root state (expansion, explicit collapse, selection) when a previously top-level root gets absorbed, and (d) share the core ancestor-dedup logic between file tree and global search. ## Relevant code - `app/src/code/file_tree/view.rs` — `FileTreeView`; owns `displayed_directories` and per-root state. - `set_root_directories` (~763–792) — entry point from `LeftPanelView`. - `update_directory_contents` (~805–884) — per-path init, lazy-load registration, auto-expand of the "last" directory. - `expand_ancestors_to_path` (~698–739) — expands ancestors of a path, short-circuits on explicit collapse. Reusable as-is. - `auto_expand_to_most_recent_directory` (~1615–1660) — today selects and scrolls to the first displayed root; we'll generalize the scroll target to a specific descendant path when absorption happened. - `scroll_to_file` (~667–694) — scrolls to a `FileTreeIdentifier`; we'll reuse the lookup logic to select a directory header by path. - `find_deepest_root_for_file` (~657–663) — already picks the deepest containing root for editor focus. Works unchanged once roots are ancestor-deduped. - `insert_or_update_remote_root` (~372–426) — remote path's own ancestor/descendant sweep. Untouched by this change; we gate the new grouping to `!is_remote()` roots. - `explicitly_collapsed` (~282) — per-root `HashSet`. Must be migrated when a root is absorbed. - `registered_lazy_loaded_paths` (~287) — per-view `HashSet` of paths registered with `RepoMetadataModel`. Absorbed descendants that were lazy-registered must be unregistered. - `app/src/workspace/view/global_search/view.rs:147` — `deduplicate_search_roots`. Will be removed; caller at `:1004` will use the shared helper. - `app/src/workspace/view/left_panel.rs:1213` — `deduplicate_by_directory_name` stays. It feeds both file tree and global search the full path-deduplicated list; ancestor dedup happens inside each view (the file tree needs the absorbed descendants to drive auto-expand, so it can't happen upstream). - `crates/warp_util/src/path.rs` + `path_test.rs` — target location for the shared `group_roots_by_common_ancestor` helper. - `crates/warp_util/src/standardized_path.rs` — `StandardizedPath` impls `AsRef` and `starts_with(&StandardizedPath)`; usable directly with the shared helper. ## Current state `WorkingDirectoriesModel::refresh_working_directories_for_pane_group` (in `app/src/pane_group/working_directories.rs`) resolves each terminal cwd and each code-view file path to its git repo root (or the path itself / the file's parent if there is no repo) and maintains an ordered `IndexSet` per pane group. The model emits `WorkingDirectoriesEvent::DirectoriesChanged` with those paths in most-recent-first order. `LeftPanelView` subscribes to that event (and also reads state in `set_active_pane_group`), runs `deduplicate_by_directory_name`, then calls `FileTreeView::set_root_directories(paths)` and `GlobalSearchView::set_root_directories(paths)`. `FileTreeView::set_root_directories` stores the full list in `displayed_directories`, retains matching entries in `root_directories`, and calls `update_directory_contents` which lazily registers non-repo paths through `RepoMetadataModel` and optionally expands the "last" directory. `GlobalSearchView::set_root_directories` runs `deduplicate_search_roots` over the incoming paths (ancestor-dedup) and stores the result as `search_roots`. This is the behavior we want to reuse in the file tree. ## Proposed changes ### 1. Shared helper in `warp_util::path` Add a generic ancestor-dedup helper usable by both views. ```rust path=null start=null // crates/warp_util/src/path.rs use std::collections::HashMap; use std::path::{Path, PathBuf}; /// Result of grouping a set of root paths by ancestor/descendant relationship. #[derive(Debug, Clone)] pub struct RootGrouping

{ /// Ancestor-deduped set of roots, preserving the input order of /// surviving entries. pub roots: Vec

, /// For each surviving root, the input paths that were absorbed because /// they were strict descendants of that root. Recorded in input order. pub absorbed_by_root: HashMap>, } /// Returns the ancestor-deduped set of `roots`. If any input path has an /// ancestor already present in the set, it is dropped from `roots` and /// recorded in `absorbed_by_root` under its closest surviving ancestor. /// /// Equality is treated as "already present" (not "ancestor"), so duplicate /// inputs collapse to one surviving entry with an empty absorbed list. /// /// Order: `roots` preserves the order of `roots_most_recent_first` for /// surviving entries. pub fn group_roots_by_common_ancestor

( roots_most_recent_first: &[P], ) -> RootGrouping

where P: AsRef + Clone, { // Two-pass algorithm: // // 1. Decide survivors by sorting by component count ascending, then // keep a path only if no already-accepted path is an ancestor. // O(n log n) time. // 2. Re-order survivors to match the input order; bucket absorbed // descendants under their closest surviving ancestor. // ... } ``` Place unit tests next to the impl in `crates/warp_util/src/path_test.rs` (following the existing `_test.rs` convention in that crate). ### 2. Thin `StandardizedPath` wrapper in the file tree `StandardizedPath` implements `AsRef`, so the shared helper accepts it directly. The file tree needs the result back in `StandardizedPath`, so it builds a small adapter inside `view.rs`: ```rust path=null start=null // app/src/code/file_tree/view.rs fn group_std_roots_by_common_ancestor( roots: &[StandardizedPath], ) -> RootGrouping { warp_util::path::group_roots_by_common_ancestor(roots) } ``` The `absorbed_by_root` map is keyed by `PathBuf` in the shared helper; inside the file tree we index into it via `ancestor.to_path_buf()` to get the absorbed `Vec`. ### 3. `FileTreeView::set_root_directories` (core change) Pseudocode for the new flow: ```rust path=null start=null pub fn set_root_directories( &mut self, paths: Vec, ctx: &mut ViewContext, ) { let incoming: Vec = paths .iter() .filter_map(|p| StandardizedPath::try_from_local(p).ok()) .collect(); // Preserve remote roots as-is; grouping only runs on local roots. let (remote_roots, local_inputs): (Vec<_>, Vec<_>) = incoming .into_iter() .partition(|p| self.root_directories.get(p).is_some_and(|r| r.is_remote())); let grouping = group_std_roots_by_common_ancestor(&local_inputs); // Final displayed order: local grouped roots (most-recent-first) then // any remote roots in their original incoming order. let new_displayed: Vec = grouping .roots .iter() .cloned() .chain(remote_roots.iter().cloned()) .collect(); // ---- State migration for local roots that are being absorbed ---- for (ancestor, absorbed) in &grouping.absorbed_by_root { let ancestor_std = StandardizedPath::try_from_path(ancestor).unwrap(); self.migrate_absorbed_root_state(&ancestor_std, absorbed, ctx); } // ---- Unregister lazy-loaded paths that no longer survive ---- #[cfg(feature = "local_fs")] { let removed: Vec = self .registered_lazy_loaded_paths .iter() .filter(|p| !new_displayed.contains(p)) .cloned() .collect(); for path in removed { self.remove_lazy_loaded_entry(&path, ctx); } } // ---- Retain and update displayed state ---- self.root_directories.retain(|root, _| new_displayed.contains(root)); self.displayed_directories = new_displayed.clone(); #[cfg(feature = "local_fs")] { let new_last_directory = paths.last() /* raw-input last */ != self.last_seen_raw_last_directory.as_ref(); self.update_directory_contents(&new_displayed, new_last_directory, ctx); } // ---- Auto-expand absorbed descendants ---- for (ancestor, absorbed) in &grouping.absorbed_by_root { let ancestor_std = StandardizedPath::try_from_path(ancestor).unwrap(); for descendant in absorbed { self.expand_ancestors_to_path(&ancestor_std, descendant, ctx); } } // ---- Focus-follow on the most recent absorbed descendant ---- // `grouping.absorbed_by_root[&first_local_root]` preserves input order; // the most-recent absorbed is the first entry (if any). if let Some(first_local) = grouping.roots.first() { if let Some(absorbed) = grouping .absorbed_by_root .get(first_local.as_ref()) { if let Some(most_recent) = absorbed.first() { self.select_directory_header_by_path(first_local, most_recent, ctx); } } } } ``` Key details: - `migrate_absorbed_root_state` (new helper) merges `expanded_folders`, `item_states`, and `explicitly_collapsed` from each absorbed descendant's previous `RootDirectory` (if one existed) into the surviving ancestor's `RootDirectory`. It also remaps `self.selected_item` when the selection's `root` matches an absorbed path. - `select_directory_header_by_path` (new helper) finds the item index for a given directory path inside a surviving root's flattened `items` (after rebuild) and calls `select_id` with the resulting `FileTreeIdentifier`. This reuses the selection-scroll plumbing already used by `scroll_to_file`. ### 4. Selection remapping `FileTreeIdentifier { root, index }` assumes `root` is stable. After an absorption: - Before `rebuild_flattened_items`, capture the previously-selected item's path: `let prev_selected_path = self.selected_item_std_path();` - Note the absorbed source root: if `self.selected_item.as_ref().map(|id| &id.root)` is in `grouping.absorbed_by_root.values().flat_map(|v| v.iter())`, the selection needs a remap. - After `rebuild_flattened_items`, re-locate the captured path in the new surviving root's items and set `self.selected_item` accordingly. If the path is not found (e.g., it's collapsed under an explicit-collapse link), clear selection. This mirrors the pattern already in `rebuild_flatten_items_and_select_path`, which walks items and remaps an index by path. ### 5. State migration helper ```rust path=null start=null fn migrate_absorbed_root_state( &mut self, ancestor: &StandardizedPath, absorbed: &[StandardizedPath], ctx: &mut ViewContext, ) { // Make sure the ancestor entry exists. self.root_directories.entry(ancestor.clone()).or_insert_with(|| { RootDirectory { entry: Self::create_empty_entry(ancestor), expanded_folders: HashSet::new(), items: Vec::new(), item_states: HashMap::new(), remote_host_id: None, } }); for absorbed_root in absorbed { // Remove the absorbed root's RootDirectory, if any. let Some(old) = self.root_directories.remove(absorbed_root) else { continue; }; // Merge expanded folders. if let Some(ancestor_dir) = self.root_directories.get_mut(ancestor) { ancestor_dir.expanded_folders.extend(old.expanded_folders); for (path, state) in old.item_states { ancestor_dir.item_states.entry(path).or_insert(state); } } // Merge explicit collapses. if let Some(collapsed) = self.explicitly_collapsed.remove(absorbed_root) { self.explicitly_collapsed .entry(ancestor.clone()) .or_default() .extend(collapsed); } } // (selection remap happens after rebuild, in set_root_directories) } ``` ### 6. `GlobalSearchView::set_root_directories` Replace the private `deduplicate_search_roots` call with the shared helper: ```rust path=null start=null pub fn set_root_directories(&mut self, roots: Vec, _ctx: &mut ViewContext) { let grouping = warp_util::path::group_roots_by_common_ancestor(&roots); self.search_roots = grouping.roots; self.root_directories = roots; } ``` Delete the private `deduplicate_search_roots` function. Global Search does not need the `absorbed_by_root` map; discarding it is fine. ### 7. Remote roots Skip grouping for remote roots by partitioning the incoming list in step 3 above. `insert_or_update_remote_root` stays as-is. This preserves the "remote new root wins" sweep without interacting with the new local grouping. ### 8. Focus-follow target and selection preservation When the user cds into an active path that is absorbed into an ancestor, we want that new cwd visible and selected. The "most recent" absorbed descendant for the first surviving local root is defined as the first entry in `absorbed_by_root[first_local_root]` (which preserves input order, and the input is most-recent-first). `FileTreeView` stores a `pending_focus_target: Option` field: ```rust path=null start=null struct PendingFocusTarget { root: StandardizedPath, path: StandardizedPath, /// True after the target has driven a scroll. First apply scrolls /// via `perform_scroll_to_top`; later re-applies preserve selection /// but do NOT scroll, so user scrolling is respected. scrolled: bool, } ``` #### Setting the target At the end of `set_root_directories`, for the first surviving local root that has absorbed descendants, we record a `PendingFocusTarget` pointing at the most-recent absorbed descendant and then call `apply_pending_focus_target` immediately. Two short-circuits suppress recording the target: - **Current selection is already at or under the descendant.** This covers the case where the user just clicked a file in the tree, the code view opened it, and `DirectoriesChanged` fires with the file's parent (or repo root) added. The user's file-level selection is more specific than the generic cwd-follow target; we keep it. - **No absorbed descendants** for the first surviving local root. No focus-follow needed. #### Applying the target `apply_pending_focus_target` does nothing if no target is set. Otherwise it: 1. Drops the target if its `root` is no longer in `displayed_directories`. 2. Looks up the descendant path in the surviving root's flattened items via `find_directory_header_id`. If the item is not yet materialized (e.g. the ancestor is still indexing), returns `false` and leaves the target in place for a later retry. 3. Sets `selected_item` to the resolved `FileTreeIdentifier`. 4. On the first successful apply only (`!target.scrolled`), calls `perform_scroll_to_top(id)` to place the descendant's directory header at the top of the viewport, then sets `scrolled = true`. Scrolling only on the first apply is the mechanism that satisfies PRODUCT.md Invariants 5 and 9: the initial cd lands the cwd at the top; later rebuilds preserve selection but leave the scroll position alone. #### Scroll-to-top implementation `FileTreeView::perform_scroll_to_top(id)` differs from the existing `perform_scroll` (which uses `UniformListState::scroll_to(item_ix)` and only scrolls far enough to make the item visible). It computes the delta between the current `UniformListState::scroll_top()` and the target item index, then calls `add_scroll_top(delta)`, so the target row lands at the top of the viewport. Clamping against the scrollable bounds is handled by the existing state (`add_scroll_top` clamps against zero; layout-time `autoscroll` clamps against `scroll_max`). #### Re-application on metadata events The target is re-applied whenever the tree could newly materialize the descendant's entry: - At the end of `set_root_directories`. - In `handle_repository_metadata_event::RepositoryUpdated` (Local), *after* the existing `auto_expand_to_most_recent_directory` call, so the cwd-follow wins over the default root-header selection. - In `handle_repository_metadata_event::FileTreeEntryUpdated` (Local), after the rebuild. The target is preserved across successful applies (only the `scrolled` flag flips) so a later generic rebuild cannot steal focus back to the root header. It is cleared when: - The target root stops being displayed. - The user takes an explicit focus-changing action — both `select_id` and `toggle_folder_expansion` clear the target. - A subsequent `set_root_directories` call either sets a new target or (due to one of the short-circuits above) resets it to `None`. ### 9. `auto_expand_to_most_recent_directory` override policy The existing `auto_expand_to_most_recent_directory` unconditionally re-selected `{first_displayed_root, index: 0}` on every call. That caused two user-visible glitches under the new cd-follow flow: - After `set_root_directories` landed selection on the cwd subdirectory, the left panel's subsequent call to `auto_expand_to_most_recent_directory` clobbered it back to the root header for a frame until `ActiveFileChanged`/`scroll_to_file` re-selected the opened file. - On repo-metadata updates, the same clobber reset the user's selection on every file-watcher event. `auto_expand_to_most_recent_directory` now only falls back to selecting the root header when: - `selected_item` is `None`, OR - `selected_item.root` is not the current most-recent root (i.e. a brand-new unrelated root just became most-recent on cd). Otherwise it preserves the existing selection. The expand-root step still runs unconditionally. This satisfies PRODUCT.md Invariants 10 and 11: explicit selections are preserved, but cd-ing to a brand-new top-level root moves focus to it. ## End-to-end flow ```mermaid sequenceDiagram participant Term as Terminal/CodeView participant WDM as WorkingDirectoriesModel participant LP as LeftPanelView participant FT as FileTreeView participant GS as GlobalSearchView participant U as warp_util::path Term->>WDM: refresh(cwds, local_paths) WDM->>WDM: resolve to repo roots / parents, IndexSet insert WDM-->>LP: DirectoriesChanged([~/code/a/z, ~/code/a, ~/code]) LP->>LP: deduplicate_by_directory_name LP->>FT: set_root_directories([~/code/a/z, ~/code/a, ~/code]) LP->>GS: set_root_directories([~/code/a/z, ~/code/a, ~/code]) FT->>U: group_roots_by_common_ancestor U-->>FT: roots=[~/code], absorbed={~/code: [~/code/a/z, ~/code/a]} FT->>FT: migrate_absorbed_root_state(~/code, [~/code/a/z, ~/code/a]) FT->>FT: update_directory_contents([~/code]) FT->>FT: expand_ancestors_to_path(~/code, ~/code/a/z) FT->>FT: expand_ancestors_to_path(~/code, ~/code/a) FT->>FT: pending_focus_target = PendingFocusTarget{~/code, ~/code/a/z, scrolled:false} FT->>FT: apply_pending_focus_target -> select + scroll_to_top GS->>U: group_roots_by_common_ancestor U-->>GS: roots=[~/code] GS->>GS: search_roots = [~/code] ``` ## Risks and mitigations 1. **Selection / identifier churn** after absorption. `FileTreeIdentifier` holds `{ root, index }`; both can change when a root is absorbed. Mitigation: capture the selected item's absolute path before rebuild, then re-locate by path after rebuild. Existing `rebuild_flatten_items_and_select_path` already handles this pattern for in-place rebuilds; we extend the capture point to run across rebuilds that change the surviving-root set. 2. **Lazy-loaded watcher leaks** for absorbed descendants that were previously registered standalone with `RepoMetadataModel`. Mitigation: explicitly `remove_lazy_loaded_entry` for every path in `registered_lazy_loaded_paths` that is not in the new `displayed_directories`. Already covered by an equivalent filter today for non-absorbed path removal; extend it to cover the absorbed case. 3. **Explicit-collapse regression**. `expand_ancestors_to_path` already halts at the first explicitly-collapsed parent. We rely on that; new tests pin the behavior. 4. **Remote vs. local collision**. We only run grouping on local roots. `insert_or_update_remote_root` continues to mutate `displayed_directories` directly; if a remote push later inserts a root that happens to be a descendant of a local root (or vice versa), the remote sweep wins, as today. This matches current behavior and PRODUCT.md Invariant 7. 5. **Ancestor helper correctness on `/a` vs `/ab`**. `Path::starts_with` already operates on path components, not string prefix, so `/a` is not considered an ancestor of `/ab`. Unit tests in the shared helper pin this. 6. **Scroll-snap fighting the user.** A naive cd-follow that re-scrolls on every rebuild fights the user's scroll position when repo-metadata events fire. Mitigation: `PendingFocusTarget.scrolled` ensures scroll happens exactly once per cd target; selection is preserved on later applies but scroll is not re-touched. 7. **Click-then-open clobbering the click selection.** When the user clicks a file in the tree, the code view opens it, which emits `DirectoriesChanged` with the file's parent added. A naive implementation would set a cd-follow to the parent and override the user's file selection. Mitigation: `set_root_directories` suppresses the pending focus target when the current selection is already at or under the would-be descendant target. 8. **Stale selection after cd to unrelated root.** The `auto_expand_to_most_recent_directory` override policy must still move selection when the most-recent root changes. We check `selected_item.root != most_recent_dir` before preserving. ## Testing and validation ### Unit tests (`crates/warp_util/src/path_test.rs`) - `group_roots_by_common_ancestor`: - Empty input → empty grouping. - Single path → itself, no absorbed. - Unrelated siblings preserved: `[/a, /b]` → roots `[/a, /b]`, no absorbed. - Ancestor + descendant: `[/a/b, /a]` → roots `[/a]`, absorbed `{/a: [/a/b]}` (absorbed order matches input order). - Three-deep chain: `[/a/b/c, /a/b, /a]` → roots `[/a]`, absorbed `{/a: [/a/b/c, /a/b]}`. - Mixed groups: `[/a, /x, /a/b, /x/y]` → roots `[/a, /x]`, absorbed `{/a: [/a/b], /x: [/x/y]}`. - Same-prefix-different-name: `[/foo/a, /foo/abc]` → both kept. - Duplicate input: `[/a, /a]` → roots `[/a]`, no absorbed. - Surviving-root input order preserved across interleaved descendants. ### View-level tests (`app/src/code/file_tree/view/view_tests.rs`) Using the `VirtualFS::test` harness already used by the existing tests: - **Absorb on second call**: set roots `[~/code/a]`, then set roots `[~/code]`. Assert `displayed_directories == [~/code]`, `~/code/a`'s `expanded_folders` and `explicitly_collapsed` are merged into `~/code`, and the previously-registered lazy-loaded entry for `~/code/a` is unregistered. - **Auto-expand chain**: set roots `[~/code]`, then set roots `[~/code/a/z, ~/code]`. Assert `~/code/a` and `~/code/a/z` are in `expanded_folders`. - **Respect explicit collapse**: set roots `[~/code]` with `~/code/a` explicitly collapsed, then set roots `[~/code/a/z, ~/code]`. Assert `~/code/a` stays in `explicitly_collapsed` and is NOT expanded, and `~/code/a/z` is NOT expanded (blocked by the collapsed link). - **Sibling preservation**: set roots `[~/code/a, ~/code/b]`. Assert both are kept as top-level roots. - **Focus-follow on cd**: simulate cd-ing into `~/code/warp-server` with `~/code` as the ancestor. Assert `selected_item` lands on `~/code/warp-server`, and `pending_focus_target.scrolled` is `true`. After a subsequent `select_id` on an unrelated item, pending clears. - **No re-scroll on rebuild**: after initial apply, trigger a rebuild and re-apply. Assert selection is re-set to the cwd but `pending_focus_target.scrolled` stays `true` (no re-scroll). - **Click preserves file selection**: seed `~/code`, expand `~/code/warp-server`, select `main.rs`. Simulate a `DirectoriesChanged` emitting `[warp-server, code]`. Assert selection stays on `main.rs` and no `pending_focus_target` is set. - **Cd to new unrelated root**: with `~/code` selected, call `set_root_directories` with `[~/other, ~/code]` and invoke `auto_expand_to_most_recent_directory`. Assert selection moves to `~/other`'s root header. - **Auto-expand preserves existing selection**: select a subdirectory, then call `auto_expand_to_most_recent_directory`. Assert the subdirectory selection is preserved (not clobbered to the root header). - **Lazy-loaded cleanup**: previously top-level `~/code/a` registered lazy-loaded gets unregistered after absorption into `~/code`. ### Cross-view parity Because both views now call `warp_util::path::group_roots_by_common_ancestor`, their surviving-root sets agree by construction. The shared helper's unit tests cover the path-shape behavior for both consumers. ### Manual validation `verify-ui-change-in-cloud` runs per the PRODUCT.md validation section, which includes the new scroll-preservation and click-preservation checks. ## Follow-ups - Consider exposing `RootGrouping` through the `WorkingDirectoriesModel` itself so both consumers can read an already-grouped version. Not blocking for this change; keeping it view-local avoids the model knowing about remote-vs-local distinctions. - Consider making the ancestor helper available to other consumers that currently re-implement ancestor checks (search for `starts_with` on path collections) as a follow-up cleanup. - Add a subtle UI affordance (e.g., a label next to the root or a highlight on the cwd directory) when a descendant has been absorbed, if users find the grouping ambiguous post-rollout.