13 KiB
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
- 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.
- Keep unrelated siblings as distinct roots — do not synthesize a shared ancestor that is not already one of the active paths.
- Preserve the user's explicit collapse state. If the user manually collapsed a folder, auto-expand should not silently re-open it.
- 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/bmust 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
WorkingDirectoriesModelfor 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.
~/codeis an ancestor of~/code/a;/ais 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;~/codeis expanded inside it. - Three-deep chain:
[~/code, ~/code/a, ~/code/a/z]→ one root (~/code), withaandzauto-expanded. - Mixed ancestor + unrelated sibling:
[~/code, ~/code/a, ~/other]→ two roots (~/code,~/other);~/code/aabsorbed and expanded. - Same prefix, different directory names:
[/foo/a, /foo/abc]→ two roots (/foo/ais 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/ais explicitly collapsed and the user cds into~/code/a/z/inner, the tree leaves~/code/acollapsed.~/codestays expanded,~/code/astays collapsed,~/code/a/zand deeper are NOT auto-expanded because expansion halted at the collapsed link.
Success criteria
- Given active paths
[~/code/foo.ts-parent, ~/code/a, ~/code/a/z], the Project Explorer shows exactly one root~/code, withaandzexpanded, and~/code/a/zselected and scrolled to the top of the viewport. - Given active paths
[~/code/a, ~/code/b], the Project Explorer shows two roots~/code/aand~/code/bin most-recent-first order, matching today's behavior. - cd-ing from
~/codeto~/code/a/zinside the same terminal does not add a second root; the existing~/coderoot auto-expands and selects~/code/a/z, and~/code/a/zis scrolled to the top. - A folder the user has explicitly collapsed stays collapsed across subsequent absorption events and cd navigations.
- 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).
- 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).
- Remote-backed roots continue to behave as they do today, including the existing "new root wins" sweep.
- Tree identifiers remain valid after absorption — keyboard navigation, context menus, and drag/drop continue to work without errors after a root change.
- 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.
- Clicking a file in the tree leaves that file as the selected item;
the
DirectoriesChangedside effect of opening the file does not override the selection with the file's parent directory. - Cd-ing into a brand-new root moves selection to the new root's header and scrolls there.
Validation
- Rust unit tests for the shared
group_roots_by_common_ancestorhelper (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. FileTreeViewview tests (app/src/code/file_tree/view/view_tests.rs,VirtualFS::testharness):- 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.
- Global Search parity test: assert that
GlobalSearchViewandFileTreeViewcompute the same surviving-root set given the same input. - Manual verification (Dogfood) using
verify-ui-change-in-cloud:- Open
~/code/foo.tsin the code editor, then cd the terminal from~/code→~/code/a→~/code/a/z. Confirm the file tree shows one~/coderoot with the chain expanded, andzis selected and scrolled to the top of the viewport. - Collapse
~/code/aexplicitly, 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
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_filebehavior is sufficient. - Auto-expansion order across multiple absorbed descendants is not a product concern; implementation may choose any order.