Files

627 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` (~763792) — entry point from `LeftPanelView`.
- `update_directory_contents` (~805884) — per-path init, lazy-load
registration, auto-expand of the "last" directory.
- `expand_ancestors_to_path` (~698739) — expands ancestors of a path,
short-circuits on explicit collapse. Reusable as-is.
- `auto_expand_to_most_recent_directory` (~16151660) — 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` (~667694) — scrolls to a `FileTreeIdentifier`; we'll
reuse the lookup logic to select a directory header by path.
- `find_deepest_root_for_file` (~657663) — already picks the deepest
containing root for editor focus. Works unchanged once roots are
ancestor-deduped.
- `insert_or_update_remote_root` (~372426) — 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<StandardizedPath>`.
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<Path>` 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<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`:
```rust path=null start=null
// 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:
```rust path=null start=null
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) 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<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:
```rust path=null start=null
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:
```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.