26 KiB
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; ownsdisplayed_directoriesand per-root state.set_root_directories(~763–792) — entry point fromLeftPanelView.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 aFileTreeIdentifier; 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-rootHashSet<StandardizedPath>. Must be migrated when a root is absorbed.registered_lazy_loaded_paths(~287) — per-viewHashSetof paths registered withRepoMetadataModel. 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:1004will use the shared helper.app/src/workspace/view/left_panel.rs:1213—deduplicate_by_directory_namestays. 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 sharedgroup_roots_by_common_ancestorhelper.crates/warp_util/src/standardized_path.rs—StandardizedPathimplsAsRef<Path>andstarts_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.
// 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<P> {
/// Ancestor-deduped set of roots, preserving the input order of
/// surviving entries.
pub roots: Vec<P>,
/// 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<PathBuf, Vec<P>>,
}
/// 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<P>(
roots_most_recent_first: &[P],
) -> RootGrouping<P>
where
P: AsRef<Path> + 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<Path>, 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:
// app/src/code/file_tree/view.rs
fn group_std_roots_by_common_ancestor(
roots: &[StandardizedPath],
) -> RootGrouping<StandardizedPath> {
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<StandardizedPath>.
3. FileTreeView::set_root_directories (core change)
Pseudocode for the new flow:
pub fn set_root_directories(
&mut self,
paths: Vec<PathBuf>,
ctx: &mut ViewContext<Self>,
) {
let incoming: Vec<StandardizedPath> = 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<StandardizedPath> = 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<StandardizedPath> = 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) mergesexpanded_folders,item_states, andexplicitly_collapsedfrom each absorbed descendant's previousRootDirectory(if one existed) into the surviving ancestor'sRootDirectory. It also remapsself.selected_itemwhen the selection'srootmatches an absorbed path.select_directory_header_by_path(new helper) finds the item index for a given directory path inside a surviving root's flatteneditems(after rebuild) and callsselect_idwith the resultingFileTreeIdentifier. This reuses the selection-scroll plumbing already used byscroll_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 ingrouping.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 setself.selected_itemaccordingly. 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
fn migrate_absorbed_root_state(
&mut self,
ancestor: &StandardizedPath,
absorbed: &[StandardizedPath],
ctx: &mut ViewContext<Self>,
) {
// 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:
pub fn set_root_directories(&mut self, roots: Vec<PathBuf>, _ctx: &mut ViewContext<Self>) {
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<PendingFocusTarget>
field:
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
DirectoriesChangedfires 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:
- Drops the target if its
rootis no longer indisplayed_directories. - 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), returnsfalseand leaves the target in place for a later retry. - Sets
selected_itemto the resolvedFileTreeIdentifier. - On the first successful apply only (
!target.scrolled), callsperform_scroll_to_top(id)to place the descendant's directory header at the top of the viewport, then setsscrolled = 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 existingauto_expand_to_most_recent_directorycall, 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_idandtoggle_folder_expansionclear the target. - A subsequent
set_root_directoriescall either sets a new target or (due to one of the short-circuits above) resets it toNone.
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_directorieslanded selection on the cwd subdirectory, the left panel's subsequent call toauto_expand_to_most_recent_directoryclobbered it back to the root header for a frame untilActiveFileChanged/scroll_to_filere-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_itemisNone, ORselected_item.rootis 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
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
-
Selection / identifier churn after absorption.
FileTreeIdentifierholds{ 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. Existingrebuild_flatten_items_and_select_pathalready handles this pattern for in-place rebuilds; we extend the capture point to run across rebuilds that change the surviving-root set. -
Lazy-loaded watcher leaks for absorbed descendants that were previously registered standalone with
RepoMetadataModel. Mitigation: explicitlyremove_lazy_loaded_entryfor every path inregistered_lazy_loaded_pathsthat is not in the newdisplayed_directories. Already covered by an equivalent filter today for non-absorbed path removal; extend it to cover the absorbed case. -
Explicit-collapse regression.
expand_ancestors_to_pathalready halts at the first explicitly-collapsed parent. We rely on that; new tests pin the behavior. -
Remote vs. local collision. We only run grouping on local roots.
insert_or_update_remote_rootcontinues to mutatedisplayed_directoriesdirectly; 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. -
Ancestor helper correctness on
/avs/ab.Path::starts_withalready operates on path components, not string prefix, so/ais not considered an ancestor of/ab. Unit tests in the shared helper pin this. -
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.scrolledensures scroll happens exactly once per cd target; selection is preserved on later applies but scroll is not re-touched. -
Click-then-open clobbering the click selection. When the user clicks a file in the tree, the code view opens it, which emits
DirectoriesChangedwith 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_directoriessuppresses the pending focus target when the current selection is already at or under the would-be descendant target. -
Stale selection after cd to unrelated root. The
auto_expand_to_most_recent_directoryoverride policy must still move selection when the most-recent root changes. We checkselected_item.root != most_recent_dirbefore 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]. Assertdisplayed_directories == [~/code],~/code/a'sexpanded_foldersandexplicitly_collapsedare merged into~/code, and the previously-registered lazy-loaded entry for~/code/ais unregistered. - Auto-expand chain: set roots
[~/code], then set roots[~/code/a/z, ~/code]. Assert~/code/aand~/code/a/zare inexpanded_folders. - Respect explicit collapse: set roots
[~/code]with~/code/aexplicitly collapsed, then set roots[~/code/a/z, ~/code]. Assert~/code/astays inexplicitly_collapsedand is NOT expanded, and~/code/a/zis 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-serverwith~/codeas the ancestor. Assertselected_itemlands on~/code/warp-server, andpending_focus_target.scrolledistrue. After a subsequentselect_idon 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.scrolledstaystrue(no re-scroll). - Click preserves file selection: seed
~/code, expand~/code/warp-server, selectmain.rs. Simulate aDirectoriesChangedemitting[warp-server, code]. Assert selection stays onmain.rsand nopending_focus_targetis set. - Cd to new unrelated root: with
~/codeselected, callset_root_directorieswith[~/other, ~/code]and invokeauto_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/aregistered 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
RootGroupingthrough theWorkingDirectoriesModelitself 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_withon 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.