Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
# APP-4106: Group by shared root in file tree
Linear: https://linear.app/warpdotdev/issue/APP-4106/group-by-shared-root-in-file-tree
## Summary
The Project Explorer file tree currently shows every "working root" the workspace
tracks as a separate top-level entry. When one working path is an ancestor of
another (e.g., a file open at `~/code/foo.ts` and a terminal cwd at
`~/code/a`), this produces redundant, nested-looking roots. This feature makes
the tree collapse those cases into a single shared root and auto-expand the
absorbed descendant path so the user's current focus is still visible.
## Problem
Today's display logic deduplicates only by exact path equality
(`deduplicate_by_directory_name` in `left_panel.rs`). As a result:
- Opening a file outside any repo and having a terminal cwd under the same
directory tree produces two separate roots, one of which visually contains
the other.
- cd-ing deeper into an already-displayed directory produces a second root
rather than navigating inside the existing one.
- Users see a cluttered multi-root tree that does not match how they think
about their workspace.
Global Search already dedupes descendant roots via `deduplicate_search_roots`;
the file tree does not. That inconsistency is part of the bug.
## Goals
1. When one displayed root is a strict ancestor of another, collapse to a
single root (the ancestor) and auto-expand the chain down to each absorbed
descendant so the user can see where their focus is.
2. Keep unrelated siblings as distinct roots — do not synthesize a shared
ancestor that is not already one of the active paths.
3. Preserve the user's explicit collapse state. If the user manually collapsed
a folder, auto-expand should not silently re-open it.
4. Keep the tree's behavior consistent with Global Search's existing
ancestor-dedup logic (same rule, one source of truth in code).
## Non-goals
- Computing a synthetic greatest common ancestor for unrelated sibling paths
(e.g., `~/code/a` + `~/code/b` must NOT collapse to `~/code`).
- Changing how Warp detects git repositories or resolves terminal cwds to
repo roots upstream in `WorkingDirectoriesModel`.
- Changing the remote-repo root insertion policy
(`insert_or_update_remote_root`) — remote pushes continue to use their
existing "new root wins" sweep.
- New UI affordances such as badges or breadcrumbs indicating where the
user's cwd is inside a collapsed root.
## Figma / design references
Figma: none provided. This is a behavior change in an existing panel; no new
visual components are introduced.
## User experience
### Definitions
- "Active path" = a path reported by `WorkingDirectoriesModel` for the active
pane group. These are terminal cwds (resolved to their repo root when one
exists) and code-editor file-parent paths (resolved to their repo root when
one exists).
- "Displayed root" = a top-level entry in the Project Explorer file tree.
- Ancestor comparison uses path-prefix semantics on the active path strings.
`~/code` is an ancestor of `~/code/a`; `/a` is NOT an ancestor of `/ab`.
### Invariant 1: collapse descendants into their ancestor
If the set of active paths contains both path `A` and path `B` where `A` is a
strict ancestor of `B`, only `A` appears as a displayed root. `B` is treated
as "absorbed by `A`" and is no longer a top-level entry. This applies
transitively: with `[~/code, ~/code/a, ~/code/a/z]`, only `~/code` is
displayed.
### Invariant 2: unrelated siblings remain separate
If no active path is an ancestor of another, every active path is shown as its
own displayed root, in the same most-recent-first order as today. Examples
that stay as two roots:
- `~/code/a` + `~/code/b`
- `~/code/a` + `~/other`
- `/a` + `/ab`
Warp does not synthesize a new common root (like `~/code` or `/`) that is not
already an active path.
### Invariant 3: auto-expand the chain to each absorbed descendant
When a descendant is absorbed into an ancestor root, the tree expands each
intermediate directory on the path from the ancestor down to the absorbed
descendant so that the absorbed descendant's folder is visible in the tree.
Example: with active paths `[~/code, ~/code/a/z]`, the tree shows `~/code`
expanded, `a` expanded, and `z` expanded (contents visible).
Auto-expansion stops at the first directory that the user has explicitly
collapsed (see Invariant 4).
### Invariant 4: explicit collapse is respected
If the user has explicitly collapsed a directory (via the chevron or the
keyboard `Collapse` action), a later absorption event must NOT re-expand that
directory.
Example: user has `~/code` as a root with `~/code/a` explicitly collapsed.
User cds into `~/code/a/z`. The tree leaves `~/code/a` collapsed and does
not expand `z`. The displayed-roots invariant (Invariant 1) still holds:
only `~/code` is a displayed root, and the cwd change does not add `~/code/a`
or `~/code/a/z` as new roots.
### Invariant 5: focus-follow on cd into a descendant
When the user cds into a path that is a descendant of an existing displayed
root, the tree treats the new cwd as the "most recent" focus:
- Auto-expansion runs to reveal the new cwd (subject to Invariant 4).
- The cwd's directory header is selected in the tree.
- On the first apply for a given cd, the tree scrolls the cwd's directory
header to the top of the viewport (not merely into visibility) so a
fresh cd looks like the cwd took over the top of the tree. Subsequent
rebuilds that re-apply the same focus (e.g. repo-metadata updates) must
NOT re-scroll — see Invariant 9.
### Invariant 6: state migration when an existing root is absorbed
If a directory that was previously a top-level root gets absorbed into a new
ancestor root, the tree preserves the user's per-root state:
- Expanded folders under the absorbed root remain expanded under the
surviving root.
- Explicitly collapsed paths under the absorbed root remain explicitly
collapsed under the surviving root.
- If the user's selection lived inside the absorbed root, the selection
remains on the same file/directory path after absorption. If that path is
no longer reachable (e.g., because a parent got explicitly collapsed
during migration), the selection clears.
- Item-level interaction state (mouse hover, drag state) is not guaranteed to
persist through a root-shape change; losing a transient hover state is
acceptable.
### Invariant 7: remote roots are unaffected
Remote-backed roots (pushed by `insert_or_update_remote_root`) continue to
use their existing ancestor/descendant sweep (new root wins). The new
local-root grouping does not run on remote roots and does not reorder them.
### Invariant 8: Global Search stays consistent
Global Search's existing behavior — "drop descendants when an ancestor is
present" — continues to apply, using the same underlying path-deduplication
helper as the file tree. Whatever the file tree displays as its set of
ancestor roots matches the set Global Search will search over for the same
active pane group.
### Invariant 9: user scrolling is respected
After the initial cd-follow scroll lands the cwd at the top of the
viewport, subsequent events that would have targeted the same cwd (e.g.
late repo-metadata updates, file-watcher rebuilds) must NOT re-scroll the
tree back to the cwd. The selection marker may be preserved across those
rebuilds, but scroll position is the user's to control once the initial
placement has happened.
### Invariant 10: explicit user focus wins over cwd-follow
If the user clicks a file in the tree (or the active code editor focuses a
file), that explicit selection must not be overridden by a cwd-follow
triggered as a side effect of the same action. Concretely: when a
`DirectoriesChanged` event fires because a code view just opened a file,
and the current selection is already at or under the path the cd-follow
would target, no cd-follow is recorded. The user's file-level selection is
more specific than the generic directory-level target.
### Invariant 11: new root takes focus on cd
When a cd introduces a brand-new top-level root (one that isn't an
ancestor or descendant of any existing displayed root), the file tree
moves selection to that new root's header. An existing selection under a
different (now non-most-recent) root is replaced. This matches today's
behavior for cd-ing into a fresh directory that isn't under any existing
root.
### Edge cases
- **Root and its own ancestor are both active**: covered by Invariant 1.
Example: `[~, ~/code]``~` is the only displayed root; `~/code` is
expanded inside it.
- **Three-deep chain**: `[~/code, ~/code/a, ~/code/a/z]` → one root
(`~/code`), with `a` and `z` auto-expanded.
- **Mixed ancestor + unrelated sibling**: `[~/code, ~/code/a, ~/other]`
two roots (`~/code`, `~/other`); `~/code/a` absorbed and expanded.
- **Same prefix, different directory names**: `[/foo/a, /foo/abc]` → two
roots (`/foo/a` is not an ancestor of `/foo/abc`).
- **Reverse-order input**: `[~/code/a, ~/code]` (descendant listed first) →
one root (`~/code`); absorption is symmetric in input order.
- **Active path equals itself**: duplicate input paths dedupe as today (they
don't trigger absorption of "self").
- **cd into a path outside any existing root**: behaves as today — a new
displayed root is added. Grouping only collapses ancestor/descendant
relationships among the active path set.
- **Explicit collapse on an ancestor chain link blocks further auto-expand**:
if `~/code/a` is explicitly collapsed and the user cds into
`~/code/a/z/inner`, the tree leaves `~/code/a` collapsed. `~/code` stays
expanded, `~/code/a` stays collapsed, `~/code/a/z` and deeper are NOT
auto-expanded because expansion halted at the collapsed link.
## Success criteria
1. Given active paths `[~/code/foo.ts-parent, ~/code/a, ~/code/a/z]`, the
Project Explorer shows exactly one root `~/code`, with `a` and `z`
expanded, and `~/code/a/z` selected and scrolled to the top of the
viewport.
2. Given active paths `[~/code/a, ~/code/b]`, the Project Explorer shows two
roots `~/code/a` and `~/code/b` in most-recent-first order, matching
today's behavior.
3. cd-ing from `~/code` to `~/code/a/z` inside the same terminal does not
add a second root; the existing `~/code` root auto-expands and selects
`~/code/a/z`, and `~/code/a/z` is scrolled to the top.
4. A folder the user has explicitly collapsed stays collapsed across
subsequent absorption events and cd navigations.
5. When a previously top-level displayed root is absorbed, the user's
previous expansion and explicit-collapse state survives the migration
(visible in the resulting tree shape).
6. The set of roots Global Search searches for the same pane group matches
the set of roots the file tree displays (no divergence between the two
panels).
7. Remote-backed roots continue to behave as they do today, including the
existing "new root wins" sweep.
8. Tree identifiers remain valid after absorption — keyboard navigation,
context menus, and drag/drop continue to work without errors after a
root change.
9. After the initial cd-follow scroll, the user can scroll freely and
subsequent repo-metadata-driven rebuilds (for the same cd target) do
NOT snap the scroll position back.
10. Clicking a file in the tree leaves that file as the selected item;
the `DirectoriesChanged` side effect of opening the file does not
override the selection with the file's parent directory.
11. Cd-ing into a brand-new root moves selection to the new root's header
and scrolls there.
## Validation
1. **Rust unit tests** for the shared `group_roots_by_common_ancestor`
helper (`crates/warp_util`): verify each invariant 12 and every edge
case above, using concrete path inputs and asserting the resulting
surviving-root list and absorbed-descendant map.
2. **`FileTreeView` view tests** (`app/src/code/file_tree/view/view_tests.rs`,
`VirtualFS::test` harness):
- Assert displayed roots and expanded folders for each scenario from
Success Criteria 15.
- Assert selection remains on the same path after an absorption event.
- Assert explicit-collapse state survives migration.
3. **Global Search parity test**: assert that `GlobalSearchView` and
`FileTreeView` compute the same surviving-root set given the same input.
4. **Manual verification (Dogfood)** using `verify-ui-change-in-cloud`:
- Open `~/code/foo.ts` in the code editor, then cd the terminal from
`~/code``~/code/a``~/code/a/z`. Confirm the file tree shows one
`~/code` root with the chain expanded, and `z` is selected and
scrolled to the top of the viewport.
- Collapse `~/code/a` explicitly, then cd deeper. Confirm the tree does
not re-expand `~/code/a`.
- Open two terminals, one at `~/code/a`, one at `~/code/b`. Confirm two
separate roots.
- Cd to trigger a cd-follow, then scroll the file tree manually.
Trigger a repo-metadata rebuild (e.g. edit a file the watcher sees)
and confirm the scroll position is preserved.
- Click a file in the tree and confirm selection stays on the file,
not the file's parent directory.
## Open questions
None. Resolved during spec review:
- No additional UX polish is required when the focused file lives inside
an absorbed descendant; the existing `find_deepest_root_for_file`
behavior is sufficient.
- Auto-expansion order across multiple absorbed descendants is not a
product concern; implementation may choose any order.
+626
View File
@@ -0,0 +1,626 @@
# 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.