Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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 1–2 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 1–5.
|
||||
- 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.
|
||||
@@ -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` (~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<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.
|
||||
Reference in New Issue
Block a user