From f05265c624ffcb26135d698adaefd303e66125ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Ga=C5=BEo?= Date: Sat, 12 Sep 2026 19:03:48 +0000 Subject: [PATCH] outline_panel: Show deleted files inside the file tree (#63570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective In a branch diff — or any multi-buffer containing deleted files — deleted files render as flat "external file" rows above the outline panel's tree, instead of at the path they used to occupy. They lose all directory context and appear as a bare list of filenames detached from the tree, which is confusing precisely when you are trying to review what a branch changed. The cause is that `File::project_entry_id` returns `None` for `DiskState::Deleted`, while the outline panel's tree is keyed end-to-end by `ProjectEntryId` — depth map, collapse state, children counts. A deleted buffer therefore falls through to the external-file path in `update_fs_entries`, even though its `File` still carries a perfectly good worktree and path. ## Solution Synthesize the missing entries at the point of classification and let the existing pipeline run unchanged. - Entries are synthesized for the deleted file **and** for any ancestor directories that no longer exist, so a wholly-deleted folder still appears nested at its old location. An ancestor is only taken from the worktree if it is a directory; a path that is a file now but was a directory when the deleted file existed is synthesized too, and sorted after the file so the nesting pass keeps the directory on its stack. - Ids come from a per-panel registry that hands them out sequentially downward from just below `ProjectEntryId::MAX`, keyed by path and kind. Real ids count up from zero, so the ranges cannot meet; sequential allocation means two entries can never share an id (the entry maps are keyed by id alone, so a collision would drop a row); and the registry is kept across updates, so collapse state on synthesized rows survives. - Whether a buffer with no worktree entry is a deletion is decided by its `DiskState`: `Deleted` always, `Present` never, `Historic` by its own flag, and `New` only when git status reports a deletion or the excerpt's diff has a base text. The last case is a file deleted in a commit on the branch: it is absent from disk, the working-tree status says nothing about it, and `GitStore::display_status` only reflects branch-diff statuses when `git.diff_base` is `default_branch`. A deletion has a base version; a path that never existed has none. A scan-excluded file, which also has no entry, is `Present` and stays an external row. - Auto-fold stops where deletion state changes, so a surviving `parent/` is never struck through together with its deleted child, and `select_parent` treats a folded row as a valid parent of the folded row beneath it. - Auto-reveal resolves a deleted row's ancestors from the panel's own `fs_entries` rather than re-querying the worktree, and only real directories are ever passed to `Project::expand_entry` (a synthesized id would go out as an `ExpandProjectEntry` a remote host rejects). Opening a deleted row resolves its excerpt from the buffer id, since `path_for_entry` cannot resolve a synthesized entry. Deleted rows are struck through by a `line_through` on the row's label wrapper. `HighlightedLabel` builds its text runs from `window.text_style()`, so an ancestor's style reaches them even though `LabelLike`'s own `strikethrough()` does not; nothing outside `crates/outline_panel` changes. Colour is deliberately left to git status, matching deleted editor tabs — a committed deletion leaves the working tree clean, so strikethrough is the only signal in a branch diff. ## Testing Thirteen new tests, each asserting the rendered tree through `display_entries`, which now prints external files and marks deleted rows: - deleted file nests under its surviving parent; deleted file inside a wholly-deleted directory gets synthesized ancestors at the right depth - a file absent from disk and from git status nests when its excerpt carries a base text, while a buffer on a path that never existed stays an external file, unmarked - a modified, scan-excluded tracked file stays an external file, unmarked - same-path file + synthesized directory: nesting and auto-reveal both pick the directory - auto-fold stops at the deletion boundary; `select_parent` climbs from a folded deleted chain to the folded parent above it - opening a deleted row scrolls the editor to its excerpt - auto-reveal expands the ancestors of a deleted file and ignores an external file on a nonexistent path - buffers with no `File` still become `ExternalFile` - synthetic ids are distinct per path and kind, and stable `./script/clippy`, `cargo fmt --all -- --check`, `cargo test -p outline_panel` (33) are green. Tested manually on macOS against a fixture branch with a committed file deletion, a wholly-deleted directory under a surviving empty one, a directory replaced by a same-path file, an uncommitted deletion, a scan-excluded modified file, and a modified and an added file. ## Suggested .rules additions - `GitStore::display_status` only reflects branch-diff statuses when `git.diff_base` is `default_branch`; the branch diff view can be open while the setting is `head`. Do not use it to detect committed deletions — a commit-deleted file's buffer is `DiskState::New`; check the excerpt's diff for a base text instead. - Outline panel: a directory with mixed children is marked unfolded while `fs_entries` is built, so an auto-fold chain only forms through single-subdirectory links. Tests for folding must use that shape. - `HighlightedLabel` builds its runs from `window.text_style()` at render time, so `LabelLike` modifiers such as `strikethrough()` and `italic()` are no-ops on it; put the style on an ancestor element instead. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase The same branch diff, which deletes `src/utils/format.rs` (its directory survives) and the whole of `src/legacy/` (including a nested `internal/`), alongside a modified and an added file. | Before | After | | --- | --- | | | | | Deleted files sit above the project root as bare filenames, detached from the tree | Each is nested at its original path, with `legacy/` and `internal/` synthesized since they no longer exist on disk | Note that `utils/` renders normally while `legacy/` and `internal/` are struck through: the marking is per-entry, not applied to every ancestor on a deleted file's path. --- Release Notes: - Fixed deleted files appearing outside the file tree in the outline panel when viewing a diff --------- Co-authored-by: Kirill Bulatov --- Cargo.lock | 3 +- crates/outline_panel/Cargo.toml | 3 +- crates/outline_panel/src/outline_panel.rs | 5362 +++++++++++++++++---- docs/src/outline-panel.md | 6 + 4 files changed, 4300 insertions(+), 1074 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4b1f45f77..940ebfa13b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12505,14 +12505,15 @@ version = "0.1.0" dependencies = [ "anyhow", "async-channel 2.5.0", + "buffer_diff", "collections", "db", "editor", "file_icons", "futures 0.3.34", "fuzzy", + "git", "gpui", - "itertools 0.14.0", "language", "log", "lsp", diff --git a/crates/outline_panel/Cargo.toml b/crates/outline_panel/Cargo.toml index 2db006dc15..af3e8bdc43 100644 --- a/crates/outline_panel/Cargo.toml +++ b/crates/outline_panel/Cargo.toml @@ -21,8 +21,8 @@ editor.workspace = true file_icons.workspace = true fuzzy.workspace = true futures.workspace = true +git.workspace = true gpui.workspace = true -itertools.workspace = true language.workspace = true log.workspace = true menu.workspace = true @@ -42,6 +42,7 @@ worktree.workspace = true zed_actions.workspace = true [dev-dependencies] +buffer_diff.workspace = true lsp.workspace = true search = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 4e8b0beb46..083d4fcace 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -1,7 +1,7 @@ mod outline_panel_settings; use anyhow::Context as _; -use collections::{BTreeSet, HashMap, HashSet}; +use collections::{BTreeMap, BTreeSet, HashMap, HashSet, IndexMap}; use db::kvp::KeyValueStore; use editor::{ AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, ExcerptRange, MultiBufferSnapshot, @@ -13,23 +13,22 @@ use editor::{ use file_icons::FileIcons; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; +use git::status::{FileStatus, GitSummary}; use gpui::{ Action, AnyElement, App, AppContext as _, AsyncWindowContext, Bounds, ClipboardItem, Context, DismissEvent, Div, ElementId, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle, InteractiveElement, IntoElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, ScrollStrategy, - SharedString, Stateful, StatefulInteractiveElement as _, Styled, Subscription, Task, TaskExt, + SharedString, Stateful, StatefulInteractiveElement as _, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, div, point, px, size, uniform_list, }; -use itertools::Itertools; -use language::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem}; +use language::{Anchor, BufferId, BufferSnapshot, DiskState, OffsetRangeExt, OutlineItem}; use language::{LanguageAwareStyling, language_settings::LanguageSettings}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use std::{ cmp, - collections::BTreeMap, hash::Hash, ops::Range, path::{Path, PathBuf}, @@ -42,11 +41,11 @@ use std::{ }; use outline_panel_settings::{DockSide, FolderIndicator, OutlinePanelSettings, ShowIndentGuides}; -use project::{File, Fs, GitEntry, GitTraversal, Project, ProjectItem}; +use project::{File, Fs, Project, ProjectPath}; use search::{BufferSearchBar, ProjectSearchView}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; -use theme::SyntaxTheme; +use theme::{GlobalTheme, SyntaxTheme}; use theme_settings::ThemeSettings; use ui::{ ContextMenu, FluentBuilder, HighlightedLabel, IconButton, IconButtonShape, IndentGuideColors, @@ -60,7 +59,7 @@ use workspace::{ item::ItemHandle, searchable::{SearchEvent, SearchableItem}, }; -use worktree::{Entry, ProjectEntryId, WorktreeId}; +use worktree::{PathProgress, PathTarget, WorktreeId}; use crate::outline_panel_settings::OutlinePanelSettingsScrollbarProxy; @@ -110,6 +109,7 @@ const OUTLINE_PANEL_KEY: &str = "OutlinePanel"; const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); type Outline = OutlineItem; +type OutlineChildren = HashMap<(Range, usize), bool>; type HighlightStyleData = Arc, HighlightStyle)>>>; pub struct OutlinePanel { @@ -123,11 +123,11 @@ pub struct OutlinePanel { context_menu: Option<(Entity, Point, Subscription)>, focus_handle: FocusHandle, pending_serialization: Task>, - fs_entries_depth: HashMap<(WorktreeId, ProjectEntryId), usize>, + fs_entries: Vec, fs_children_count: HashMap, FsChildren>>, collapsed_entries: HashSet, - unfolded_dirs: HashMap>, + unfolded_dirs: HashMap>>, selected_entry: SelectedEntry, active_item: Option, _subscriptions: Vec, @@ -135,8 +135,9 @@ pub struct OutlinePanel { fs_entries_update_task: Task<()>, fs_entries_update_pending: bool, cached_entries_update_task: Task<()>, - cached_entries_update_pending: bool, + cached_entries_update_pending: Option, reveal_selection_task: Task>, + lsp_outline_refresh_task: Task<()>, outline_fetch_tasks: HashMap>, buffers: HashMap, cached_entries: Vec, @@ -146,7 +147,7 @@ pub struct OutlinePanel { preserve_selection_on_buffer_fold_toggles: HashSet, pending_default_expansion_depth: Option, default_depth_applied_buffers: HashSet, - outline_children_cache: HashMap, usize), bool>>, + outline_children_cache: HashMap, hide_symbols_override: Option, } @@ -331,6 +332,12 @@ impl FsChildren { } } +#[derive(PartialEq, Eq)] +enum CachedEntriesUpdate { + Entries, + Contents, +} + #[derive(Clone, Debug)] struct CachedEntry { depth: usize, @@ -340,7 +347,7 @@ struct CachedEntry { #[derive(Clone, Debug, PartialEq, Eq, Hash)] enum CollapsedEntry { - Dir(WorktreeId, ProjectEntryId), + Dir(WorktreeId, Arc), File(WorktreeId, BufferId), ExternalFile(BufferId), Excerpt(ExcerptRange), @@ -352,6 +359,34 @@ struct BufferOutlines { outlines: OutlineState, } +struct IndexedOutline<'a> { + outline: &'a Outline, + ranked_range: Range, + collapsed: bool, +} + +struct ExcerptOutlines<'a> { + excerpt: &'a ExcerptRange, + ranked_range: Range, + expanded: bool, + visible: Vec<&'a Outline>, + children_to_cache: Vec, +} + +impl IndexedOutline<'_> { + fn is_hidden_by(&self, ancestor: &Self) -> bool { + self.outline.depth > ancestor.outline.depth + && ancestor.ranked_range.contains(&self.ranked_range.start) + } + + fn cache_children(&self, next: Option<&Self>, children: &mut OutlineChildren) { + children.insert( + (self.outline.range.clone(), self.outline.depth), + next.is_some_and(|next| next.outline.depth > self.outline.depth), + ); + } +} + impl BufferOutlines { fn invalidate_outlines(&mut self) { if let OutlineState::Outlines(valid_outlines) = &mut self.outlines { @@ -374,6 +409,247 @@ impl BufferOutlines { OutlineState::NotFetched => true, } } + + fn assign_outline_cache_owners( + outlines: &[IndexedOutline<'_>], + excerpts: &mut [ExcerptOutlines<'_>], + ) { + let mut outline_order = (0..outlines.len()).collect::>(); + let mut excerpt_order = excerpts + .iter() + .enumerate() + .filter(|(_, excerpt)| excerpt.expanded) + .collect::>(); + outline_order + .sort_unstable_by_key(|&index| cmp::Reverse(outlines[index].ranked_range.start)); + excerpt_order.sort_unstable_by_key(|(_, excerpt)| cmp::Reverse(excerpt.ranked_range.end)); + let mut owners = vec![None; outlines.len()]; + let mut frontier = BTreeMap::new(); + let mut cursor = 0; + for index in outline_order { + let outline = &outlines[index]; + while let Some(&(excerpt_index, excerpt)) = excerpt_order.get(cursor) + && excerpt.ranked_range.end >= outline.ranked_range.start + { + Self::insert_outline_prefix_minimum( + &mut frontier, + excerpt.ranked_range.start, + cmp::Reverse(excerpt_index), + ); + cursor += 1; + } + let last = frontier.range(..=outline.ranked_range.end).next_back(); + owners[index] = last.map(|(_, &cmp::Reverse(owner))| owner); + } + for (index, owner) in owners.into_iter().enumerate() { + if let Some(owner) = owner { + excerpts[owner].children_to_cache.push(index); + } + } + } + + fn outlines_are_well_formed(outlines: &[IndexedOutline<'_>]) -> bool { + if outlines + .windows(2) + .any(|pair| pair[0].ranked_range.start > pair[1].ranked_range.start) + { + return false; + } + let mut ancestors: Vec<&IndexedOutline<'_>> = Vec::new(); + for outline in outlines { + if outline.ranked_range.start > outline.ranked_range.end { + return false; + } + while let Some(&parent) = ancestors.last() + && parent.outline.depth >= outline.outline.depth + { + ancestors.pop(); + if parent.ranked_range.end > outline.ranked_range.start { + return false; + } + } + if ancestors + .last() + .is_some_and(|parent| outline.ranked_range.end > parent.ranked_range.end) + { + return false; + } + ancestors.push(outline); + } + true + } + + fn collect_nested_outlines<'a>( + outlines: &[IndexedOutline<'a>], + excerpts: &mut [ExcerptOutlines<'a>], + children: &mut OutlineChildren, + ) { + let mut collapsed_ancestor = None; + let unfolded = outlines + .iter() + .enumerate() + .filter_map(|(index, outline)| { + if collapsed_ancestor.is_some_and(|ancestor| outline.is_hidden_by(ancestor)) { + return None; + } + collapsed_ancestor = outline.collapsed.then_some(outline); + Some(index) + }) + .collect::>(); + let mut active = BTreeSet::new(); + let mut unfolded_active = BTreeSet::new(); + let mut outline_order = (0..outlines.len()).collect::>(); + let mut excerpt_order = excerpts + .iter_mut() + .filter(|excerpt| excerpt.expanded) + .collect::>(); + outline_order.sort_unstable_by_key(|&index| cmp::Reverse(outlines[index].ranked_range.end)); + excerpt_order.sort_unstable_by_key(|excerpt| cmp::Reverse(excerpt.ranked_range.start)); + let mut cursor = 0; + for excerpt in excerpt_order { + while let Some(&index) = outline_order.get(cursor) + && outlines[index].ranked_range.end >= excerpt.ranked_range.start + { + active.insert(index); + if unfolded.contains(&index) { + unfolded_active.insert(index); + } + cursor += 1; + } + let limit = outlines + .partition_point(|outline| outline.ranked_range.start <= excerpt.ranked_range.end); + excerpt.visible.extend( + unfolded_active + .range(..limit) + .map(|&index| outlines[index].outline), + ); + for &index in &excerpt.children_to_cache { + let next = active + .range(index + 1..limit) + .next() + .map(|&next| &outlines[next]); + outlines[index].cache_children(next, children); + } + } + } + + fn collect_overlapping_outlines<'a>( + outlines: &[IndexedOutline<'a>], + excerpts: &mut [ExcerptOutlines<'a>], + children: &mut OutlineChildren, + ) { + let leaf_count = outlines.len().next_power_of_two(); + let mut starts = vec![BTreeSet::new(); 2 * leaf_count]; + let mut depths = vec![BTreeMap::new(); 2 * leaf_count]; + let mut outline_order = (0..outlines.len()).collect::>(); + let mut excerpt_order = excerpts + .iter_mut() + .filter(|excerpt| excerpt.expanded) + .collect::>(); + outline_order.sort_unstable_by_key(|&index| cmp::Reverse(outlines[index].ranked_range.end)); + excerpt_order.sort_unstable_by_key(|excerpt| cmp::Reverse(excerpt.ranked_range.start)); + let mut cursor = 0; + for excerpt in excerpt_order { + while let Some(&index) = outline_order.get(cursor) + && outlines[index].ranked_range.end >= excerpt.ranked_range.start + { + let outline = &outlines[index]; + let mut node = leaf_count + index; + while node > 0 { + starts[node].insert(outline.ranked_range.start); + Self::insert_outline_prefix_minimum( + &mut depths[node], + outline.ranked_range.start, + outline.outline.depth, + ); + node /= 2; + } + cursor += 1; + } + for &index in &excerpt.children_to_cache { + let next = Self::next_outline(index + 1, leaf_count, |node| { + starts[node] + .first() + .is_some_and(|&start| start <= excerpt.ranked_range.end) + }); + outlines[index].cache_children(next.map(|next| &outlines[next]), children); + } + let mut collapsed_ancestor: Option<&IndexedOutline<'_>> = None; + let mut first = 0; + while let Some(index) = Self::next_outline(first, leaf_count, |node| { + let Some(&first_start) = starts[node] + .first() + .filter(|&&start| start <= excerpt.ranked_range.end) + else { + return false; + }; + let Some(ancestor) = collapsed_ancestor else { + return true; + }; + first_start < ancestor.ranked_range.start + || starts[node] + .range(..=excerpt.ranked_range.end) + .next_back() + .is_some_and(|&start| start >= ancestor.ranked_range.end) + || depths[node] + .range(..=excerpt.ranked_range.end) + .next_back() + .is_some_and(|(_, &depth)| depth <= ancestor.outline.depth) + }) { + let outline = &outlines[index]; + excerpt.visible.push(outline.outline); + collapsed_ancestor = outline.collapsed.then_some(outline); + first = index + 1; + } + } + } + + fn next_outline( + first: usize, + leaf_count: usize, + has_match: impl Fn(usize) -> bool, + ) -> Option { + if first >= leaf_count { + return None; + } + let mut node = leaf_count + first; + loop { + while node.is_multiple_of(2) { + node /= 2; + } + if has_match(node) { + while node < leaf_count { + node *= 2; + if !has_match(node) { + node += 1; + } + } + return Some(node - leaf_count); + } + node += 1; + if node.is_power_of_two() { + return None; + } + } + } + + fn insert_outline_prefix_minimum( + minimums: &mut BTreeMap, + position: usize, + value: T, + ) { + let previous = minimums.range(..=position).next_back(); + if previous.is_some_and(|(_, ¤t)| current <= value) { + return; + } + while let Some((&next_position, &next_value)) = minimums.range(position..).next() { + if next_value < value { + break; + } + minimums.remove(&next_position); + } + minimums.insert(position, value); + } } #[derive(Debug)] @@ -386,7 +662,8 @@ enum OutlineState { #[derive(Clone, Debug, PartialEq, Eq)] struct FoldedDirsEntry { worktree_id: WorktreeId, - entries: Vec, + first_buffer_id: BufferId, + entries: Vec, } // TODO: collapse the inner enums into panel entry @@ -431,16 +708,7 @@ impl PartialEq for PanelEntry { fn eq(&self, other: &Self) -> bool { match (self, other) { (Self::Fs(a), Self::Fs(b)) => a == b, - ( - Self::FoldedDirs(FoldedDirsEntry { - worktree_id: worktree_id_a, - entries: entries_a, - }), - Self::FoldedDirs(FoldedDirsEntry { - worktree_id: worktree_id_b, - entries: entries_b, - }), - ) => worktree_id_a == worktree_id_b && entries_a == entries_b, + (Self::FoldedDirs(left), Self::FoldedDirs(right)) => left == right, (Self::Outline(a), Self::Outline(b)) => a == b, ( Self::Search(SearchEntry { @@ -520,7 +788,7 @@ impl SearchData { && extended_context_left_border < context_left_border && multi_buffer_snapshot .chars_at(extended_context_left_border) - .last() + .next() .is_some_and(|c| !c.is_whitespace()); let truncated_right = entire_context_text .chars() @@ -572,50 +840,65 @@ impl OutlineEntry { } } +#[derive(Debug, Clone, Eq)] +struct FsEntryPath { + path: Arc, + git_summary: GitSummary, + is_ignored: bool, +} + +impl PartialEq for FsEntryPath { + fn eq(&self, other: &Self) -> bool { + self.path == other.path + } +} + #[derive(Debug, Clone, Eq)] struct FsEntryFile { worktree_id: WorktreeId, - entry: GitEntry, + entry: FsEntryPath, buffer_id: BufferId, - excerpts: Vec>, + excerpts: Arc<[ExcerptRange]>, + is_deleted: bool, } impl PartialEq for FsEntryFile { fn eq(&self, other: &Self) -> bool { - self.worktree_id == other.worktree_id - && self.entry.id == other.entry.id - && self.buffer_id == other.buffer_id + self.buffer_id == other.buffer_id } } impl Hash for FsEntryFile { fn hash(&self, state: &mut H) { - (self.buffer_id, self.entry.id, self.worktree_id).hash(state); + self.buffer_id.hash(state); } } #[derive(Debug, Clone, Eq)] struct FsEntryDirectory { worktree_id: WorktreeId, - entry: GitEntry, + first_buffer_id: BufferId, + entry: FsEntryPath, } impl PartialEq for FsEntryDirectory { fn eq(&self, other: &Self) -> bool { - self.worktree_id == other.worktree_id && self.entry.id == other.entry.id + self.worktree_id == other.worktree_id + && self.first_buffer_id == other.first_buffer_id + && self.entry.path == other.entry.path } } impl Hash for FsEntryDirectory { fn hash(&self, state: &mut H) { - (self.worktree_id, self.entry.id).hash(state); + (self.worktree_id, self.first_buffer_id, &self.entry.path).hash(state); } } #[derive(Debug, Clone, Eq)] struct FsEntryExternalFile { buffer_id: BufferId, - excerpts: Vec>, + excerpts: Arc<[ExcerptRange]>, } impl PartialEq for FsEntryExternalFile { @@ -630,13 +913,78 @@ impl Hash for FsEntryExternalFile { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq)] enum FsEntry { ExternalFile(FsEntryExternalFile), Directory(FsEntryDirectory), File(FsEntryFile), } +impl PartialEq for FsEntry { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Directory(left), Self::Directory(right)) => left == right, + _ => self + .buffer_id() + .is_some_and(|id| Some(id) == other.buffer_id()), + } + } +} + +impl FsEntry { + fn buffer_id(&self) -> Option { + match self { + Self::File(file) => Some(file.buffer_id), + Self::ExternalFile(file) => Some(file.buffer_id), + Self::Directory(_) => None, + } + } +} + +fn directory_element_id( + worktree_id: WorktreeId, + first_buffer_id: BufferId, + path: &RelPath, +) -> ElementId { + ElementId::from(( + SharedString::from(format!("directory-{first_buffer_id}-{path}")), + worktree_id.to_proto() as usize, + )) +} + +fn is_parent_row(entry: &PanelEntry, worktree_id: WorktreeId, parent_path: &RelPath) -> bool { + match entry { + PanelEntry::Fs(FsEntry::Directory(directory)) => { + directory.worktree_id == worktree_id && directory.entry.path.as_ref() == parent_path + } + PanelEntry::FoldedDirs(folded_dirs) => { + folded_dirs.worktree_id == worktree_id + && folded_dirs + .entries + .last() + .is_some_and(|dir| dir.path.as_ref() == parent_path) + } + _ => false, + } +} + +fn is_deleted_file(file: &File, status: Option, has_base_text: bool) -> bool { + match file.disk_state { + DiskState::Deleted => true, + DiskState::Historic { was_deleted } => was_deleted, + DiskState::Present { .. } => false, + DiskState::New => status.is_some_and(|status| status.is_deleted()) || has_base_text, + } +} + +struct BufferExcerpts { + is_new: bool, + is_folded: bool, + excerpts: Vec>, + file: Option<(worktree::Snapshot, FsEntryPath)>, + is_deleted: bool, +} + struct ActiveItem { item_handle: Box, active_editor: WeakEntity, @@ -770,6 +1118,13 @@ impl OutlinePanel { cx.notify(); }); + let theme_subscription = + cx.observe_global_in::(window, |panel, window, cx| { + let buffers = panel.buffers.keys().copied().collect::>(); + panel.invalidate_outlines(&buffers); + panel.update_contents(Some(UPDATE_DEBOUNCE), window, cx); + }); + let mut outline_panel_settings = *OutlinePanelSettings::get_global(cx); let mut current_theme = ThemeSettings::get_global(cx).clone(); let mut document_symbols_by_buffer = HashMap::default(); @@ -777,18 +1132,21 @@ impl OutlinePanel { cx.observe_global_in::(window, move |outline_panel, window, cx| { let new_settings = OutlinePanelSettings::get_global(cx); let new_theme = ThemeSettings::get_global(cx); + let auto_fold_dirs_changed = + outline_panel_settings.auto_fold_dirs != new_settings.auto_fold_dirs; let mut outlines_invalidated = false; if ¤t_theme != new_theme { outline_panel_settings = *new_settings; current_theme = new_theme.clone(); + outline_panel.outline_fetch_tasks.clear(); for buffer in outline_panel.buffers.values_mut() { buffer.invalidate_outlines(); } outlines_invalidated = true; - let update_cached_items = outline_panel.update_non_fs_items(window, cx); - if update_cached_items { - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + if matches!(outline_panel.selected_entry(), Some(PanelEntry::Outline(_))) { + outline_panel.selected_entry.invalidate(); } + outline_panel.update_contents(Some(UPDATE_DEBOUNCE), window, cx); } else if &outline_panel_settings != new_settings { let old_expansion_depth = outline_panel_settings.expand_outlines_with_depth; let old_hide_symbols = outline_panel_settings.multi_buffer_hide_symbols; @@ -852,6 +1210,10 @@ impl OutlinePanel { } } + if auto_fold_dirs_changed && let Some(editor) = outline_panel.active_editor() { + outline_panel.update_fs_entries(editor, Some(UPDATE_DEBOUNCE), window, cx); + } + if !outlines_invalidated { let new_document_symbols = outline_panel .buffers @@ -869,17 +1231,17 @@ impl OutlinePanel { .collect(); if new_document_symbols != document_symbols_by_buffer { document_symbols_by_buffer = new_document_symbols; + outline_panel.outline_fetch_tasks.clear(); for buffer in outline_panel.buffers.values_mut() { buffer.invalidate_outlines(); } - let update_cached_items = outline_panel.update_non_fs_items(window, cx); - if update_cached_items { - outline_panel.update_cached_entries( - Some(UPDATE_DEBOUNCE), - window, - cx, - ); + if matches!( + outline_panel.selected_entry(), + Some(PanelEntry::Outline(_)) + ) { + outline_panel.selected_entry.invalidate(); } + outline_panel.update_contents(Some(UPDATE_DEBOUNCE), window, cx); } } }); @@ -899,7 +1261,6 @@ impl OutlinePanel { focus_handle, filter_editor, fs_entries: Vec::new(), - fs_entries_depth: HashMap::default(), fs_children_count: HashMap::default(), collapsed_entries: HashSet::default(), unfolded_dirs: HashMap::default(), @@ -914,12 +1275,14 @@ impl OutlinePanel { fs_entries_update_task: Task::ready(()), fs_entries_update_pending: false, cached_entries_update_task: Task::ready(()), - cached_entries_update_pending: false, + cached_entries_update_pending: None, reveal_selection_task: Task::ready(Ok(())), + lsp_outline_refresh_task: Task::ready(()), outline_fetch_tasks: HashMap::default(), buffers: HashMap::default(), cached_entries: Vec::new(), _subscriptions: vec![ + theme_subscription, settings_subscription, icons_subscription, focus_subscription, @@ -998,7 +1361,7 @@ impl OutlinePanel { self.unfolded_dirs .entry(worktree_id) .or_default() - .extend(entries.iter().map(|entry| entry.id)); + .extend(entries.iter().map(|entry| entry.path.clone())); self.update_cached_entries(None, window, cx); } } @@ -1026,7 +1389,7 @@ impl OutlinePanel { return; }; - unfolded_dirs.remove(&entry.id); + unfolded_dirs.remove(&entry.path); self.update_cached_entries(None, window, cx); } @@ -1126,17 +1489,10 @@ impl OutlinePanel { PanelEntry::Fs(FsEntry::File(file)) => { change_selection = false; scroll_to_buffer = Some(file.buffer_id); - self.project - .update(cx, |project, cx| { - project - .path_for_entry(file.entry.id, cx) - .and_then(|path| project.get_open_buffer(&path, cx)) - }) - .map(|buffer| { - multi_buffer_snapshot.excerpts_for_buffer(buffer.read(cx).remote_id()) - }) - .and_then(|mut excerpts| { - let excerpt_range = excerpts.next()?; + multi_buffer_snapshot + .excerpts_for_buffer(file.buffer_id) + .next() + .and_then(|excerpt_range| { multi_buffer_snapshot.anchor_in_excerpt(excerpt_range.context.start) }) } @@ -1318,23 +1674,8 @@ impl OutlinePanel { | FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. }) => entry.path.parent().and_then(|parent_path| { - previous_entries.find(|entry| match entry { - PanelEntry::Fs(FsEntry::Directory(directory)) => { - directory.worktree_id == *worktree_id - && directory.entry.path.as_ref() == parent_path - } - PanelEntry::FoldedDirs(FoldedDirsEntry { - worktree_id: dirs_worktree_id, - entries: dirs, - .. - }) => { - dirs_worktree_id == worktree_id - && dirs - .last() - .is_some_and(|dir| dir.path.as_ref() == parent_path) - } - _ => false, - }) + previous_entries + .find(|entry| is_parent_row(entry, *worktree_id, parent_path)) }), }, PanelEntry::FoldedDirs(folded_dirs) => folded_dirs @@ -1343,12 +1684,7 @@ impl OutlinePanel { .and_then(|entry| entry.path.parent()) .and_then(|parent_path| { previous_entries.find(|entry| { - if let PanelEntry::Fs(FsEntry::Directory(directory)) = entry { - directory.worktree_id == folded_dirs.worktree_id - && directory.entry.path.as_ref() == parent_path - } else { - false - } + is_parent_row(entry, folded_dirs.worktree_id, parent_path) }) }), PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { @@ -1440,40 +1776,16 @@ impl OutlinePanel { ) { self.select_entry(entry.clone(), true, window, cx); let is_root = match &entry { - PanelEntry::Fs(FsEntry::File(FsEntryFile { - worktree_id, entry, .. - })) - | PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { - worktree_id, entry, .. - })) => self - .project - .read(cx) - .worktree_for_id(*worktree_id, cx) - .map(|worktree| { - worktree.read(cx).root_entry().map(|entry| entry.id) == Some(entry.id) - }) - .unwrap_or(false), - PanelEntry::FoldedDirs(FoldedDirsEntry { - worktree_id, - entries, - .. - }) => entries - .first() - .and_then(|entry| { - self.project - .read(cx) - .worktree_for_id(*worktree_id, cx) - .map(|worktree| { - worktree.read(cx).root_entry().map(|entry| entry.id) == Some(entry.id) - }) - }) - .unwrap_or(false), - PanelEntry::Fs(FsEntry::ExternalFile(..)) => false, - PanelEntry::Outline(..) => { - cx.notify(); - return; + PanelEntry::Fs(FsEntry::File(FsEntryFile { entry, .. })) + | PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { entry, .. })) => { + entry.path.is_empty() } - PanelEntry::Search(_) => { + PanelEntry::FoldedDirs(folded_dirs) => folded_dirs + .entries + .first() + .is_some_and(|entry| entry.path.is_empty()), + PanelEntry::Fs(FsEntry::ExternalFile(_)) => false, + PanelEntry::Outline(_) | PanelEntry::Search(_) => { cx.notify(); return; } @@ -1531,7 +1843,7 @@ impl OutlinePanel { if self .unfolded_dirs .get(&directory_worktree) - .is_none_or(|unfolded_dirs| !unfolded_dirs.contains(&directory_entry.id)) + .is_none_or(|unfolded_dirs| !unfolded_dirs.contains(&directory_entry.path)) { return false; } @@ -1566,13 +1878,13 @@ impl OutlinePanel { .. }) => dir_entries.last().map(|entry| { buffers_to_unfold.extend(self.buffers_inside_directory(*worktree_id, entry)); - CollapsedEntry::Dir(*worktree_id, entry.id) + CollapsedEntry::Dir(*worktree_id, entry.path.clone()) }), PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. })) => { buffers_to_unfold.extend(self.buffers_inside_directory(*worktree_id, entry)); - Some(CollapsedEntry::Dir(*worktree_id, entry.id)) + Some(CollapsedEntry::Dir(*worktree_id, entry.path.clone())) } PanelEntry::Fs(FsEntry::File(FsEntryFile { worktree_id, @@ -1599,15 +1911,6 @@ impl OutlinePanel { }; let expanded = self.collapsed_entries.remove(&collapsed_entry); if expanded { - if let CollapsedEntry::Dir(worktree_id, dir_entry_id) = collapsed_entry { - let task = self.project.update(cx, |project, cx| { - project.expand_entry(worktree_id, dir_entry_id, cx) - }); - if let Some(task) = task { - task.detach_and_log_err(cx); - } - }; - active_editor.update(cx, |editor, cx| { buffers_to_unfold.retain(|buffer_id| editor.is_buffer_folded(*buffer_id, cx)); }); @@ -1643,7 +1946,7 @@ impl OutlinePanel { })) => { if self .collapsed_entries - .insert(CollapsedEntry::Dir(*worktree_id, entry.id)) + .insert(CollapsedEntry::Dir(*worktree_id, entry.path.clone())) { buffers_to_fold.extend(self.buffers_inside_directory(*worktree_id, entry)); true @@ -1680,9 +1983,10 @@ impl OutlinePanel { PanelEntry::FoldedDirs(folded_dirs) => { let mut folded = false; if let Some(dir_entry) = folded_dirs.entries.last() - && self - .collapsed_entries - .insert(CollapsedEntry::Dir(folded_dirs.worktree_id, dir_entry.id)) + && self.collapsed_entries.insert(CollapsedEntry::Dir( + folded_dirs.worktree_id, + dir_entry.path.clone(), + )) { folded = true; buffers_to_fold @@ -1745,7 +2049,7 @@ impl OutlinePanel { FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. }) => { - to_uncollapse.insert(CollapsedEntry::Dir(*worktree_id, entry.id)); + to_uncollapse.insert(CollapsedEntry::Dir(*worktree_id, entry.path.clone())); } } } @@ -1780,7 +2084,7 @@ impl OutlinePanel { }) = &cached.entry { if let Some(last) = entries.last() { - to_uncollapse.insert(CollapsedEntry::Dir(*worktree_id, last.id)); + to_uncollapse.insert(CollapsedEntry::Dir(*worktree_id, last.path.clone())); } } } @@ -1817,7 +2121,7 @@ impl OutlinePanel { worktree_id, entry, .. - })) => Some(CollapsedEntry::Dir(*worktree_id, entry.id)), + })) => Some(CollapsedEntry::Dir(*worktree_id, entry.path.clone())), PanelEntry::Fs(FsEntry::File(FsEntryFile { worktree_id, buffer_id, @@ -1834,7 +2138,10 @@ impl OutlinePanel { worktree_id, entries, .. - }) => Some(CollapsedEntry::Dir(*worktree_id, entries.last()?.id)), + }) => Some(CollapsedEntry::Dir( + *worktree_id, + entries.last()?.path.clone(), + )), PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { Some(CollapsedEntry::Excerpt(excerpt.clone())) } @@ -1868,17 +2175,10 @@ impl OutlinePanel { entry: dir_entry, .. })) => { - let entry_id = dir_entry.id; - let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id); + let directory_path = &dir_entry.path; + let collapsed_entry = CollapsedEntry::Dir(*worktree_id, directory_path.clone()); buffers_to_toggle.extend(self.buffers_inside_directory(*worktree_id, dir_entry)); - if self.collapsed_entries.remove(&collapsed_entry) { - self.project - .update(cx, |project, cx| { - project.expand_entry(*worktree_id, entry_id, cx) - }) - .unwrap_or_else(|| Task::ready(Ok(()))) - .detach_and_log_err(cx); - } else { + if !self.collapsed_entries.remove(&collapsed_entry) { self.collapsed_entries.insert(collapsed_entry); fold = true; } @@ -1909,18 +2209,11 @@ impl OutlinePanel { .. }) => { if let Some(dir_entry) = dir_entries.first() { - let entry_id = dir_entry.id; - let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id); + let directory_path = &dir_entry.path; + let collapsed_entry = CollapsedEntry::Dir(*worktree_id, directory_path.clone()); buffers_to_toggle .extend(self.buffers_inside_directory(*worktree_id, dir_entry)); - if self.collapsed_entries.remove(&collapsed_entry) { - self.project - .update(cx, |project, cx| { - project.expand_entry(*worktree_id, entry_id, cx) - }) - .unwrap_or_else(|| Task::ready(Ok(()))) - .detach_and_log_err(cx); - } else { + if !self.collapsed_entries.remove(&collapsed_entry) { self.collapsed_entries.insert(collapsed_entry); fold = true; } @@ -1938,7 +2231,7 @@ impl OutlinePanel { self.collapsed_entries.insert(collapsed_entry); } } - _ => {} + PanelEntry::Search(_) => return, } active_editor.update(cx, |editor, cx| { @@ -2084,160 +2377,76 @@ impl OutlinePanel { { return; } - let project = self.project.clone(); self.reveal_selection_task = cx.spawn_in(window, async move |outline_panel, cx| { cx.background_executor().timer(UPDATE_DEBOUNCE).await; - let multibuffer_snapshot = - editor.read_with(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx)); - let entry_with_selection = - outline_panel.update_in(cx, |outline_panel, window, cx| { - outline_panel.location_for_editor_selection(&editor, window, cx) - })?; - let Some(entry_with_selection) = entry_with_selection else { - outline_panel.update(cx, |outline_panel, cx| { + outline_panel.update_in(cx, |outline_panel, window, cx| { + let Some(entry) = outline_panel.location_for_editor_selection(&editor, window, cx) + else { outline_panel.selected_entry = SelectedEntry::None; cx.notify(); - })?; - return Ok(()); - }; - let related_buffer_entry = match &entry_with_selection { - PanelEntry::Fs(FsEntry::File(FsEntryFile { - worktree_id, - buffer_id, - .. - })) => project.update(cx, |project, cx| { - let entry_id = project - .buffer_for_id(*buffer_id, cx) - .and_then(|buffer| buffer.read(cx).entry_id(cx)); - project - .worktree_for_id(*worktree_id, cx) - .zip(entry_id) - .and_then(|(worktree, entry_id)| { - let entry = worktree.read(cx).entry_for_id(entry_id)?.clone(); - Some((worktree, entry)) - }) - }), - PanelEntry::Outline(outline_entry) => { - let buffer_id = outline_entry.buffer_id(); - let outline_range = outline_entry.range(); - outline_panel.update(cx, |outline_panel, cx| { + return; + }; + let buffer_id = match &entry { + PanelEntry::Fs(entry) => entry.buffer_id(), + PanelEntry::Outline(outline) => Some(outline.buffer_id()), + PanelEntry::Search(search) => editor + .read(cx) + .buffer() + .read(cx) + .snapshot(cx) + .anchor_to_buffer_anchor(search.match_range.start) + .map(|(anchor, _)| anchor.buffer_id), + PanelEntry::FoldedDirs(_) => None, + }; + let Some(buffer_id) = buffer_id else { return }; + let collapsed_count = outline_panel.collapsed_entries.len(); + if let PanelEntry::Outline(outline) = &entry { + let range = outline.range(); + if let Some(snapshot) = outline_panel.buffer_snapshot_for_id(buffer_id, cx) { + outline_panel.collapsed_entries.retain(|entry| match entry { + CollapsedEntry::Excerpt(excerpt) => { + excerpt.context.start.buffer_id != buffer_id + || !(excerpt.contains(&range.start, &snapshot) + || excerpt.contains(&range.end, &snapshot)) + } + _ => true, + }); + } + } + let file_path = outline_panel + .fs_entries + .iter() + .find_map(|entry| match entry { + FsEntry::File(file) if file.buffer_id == buffer_id => Some(ProjectPath { + worktree_id: file.worktree_id, + path: file.entry.path.clone(), + }), + _ => None, + }); + let reveal_buffer_contents = + matches!(entry, PanelEntry::Outline(_) | PanelEntry::Search(_)); + if reveal_buffer_contents { + outline_panel + .collapsed_entries + .remove(&CollapsedEntry::ExternalFile(buffer_id)); + } + if let Some(path) = file_path { + if reveal_buffer_contents { outline_panel .collapsed_entries - .remove(&CollapsedEntry::ExternalFile(buffer_id)); - if let Some(buffer_snapshot) = - outline_panel.buffer_snapshot_for_id(buffer_id, cx) - { - outline_panel.collapsed_entries.retain(|entry| match entry { - CollapsedEntry::Excerpt(excerpt_range) => { - let intersects = excerpt_range.context.start.buffer_id - == buffer_id - && (excerpt_range - .contains(&outline_range.start, &buffer_snapshot) - || excerpt_range - .contains(&outline_range.end, &buffer_snapshot)); - !intersects - } - _ => true, - }); - } - let project = outline_panel.project.read(cx); - let entry_id = project - .buffer_for_id(buffer_id, cx) - .and_then(|buffer| buffer.read(cx).entry_id(cx)); - - entry_id.and_then(|entry_id| { - project - .worktree_for_entry(entry_id, cx) - .and_then(|worktree| { - let worktree_id = worktree.read(cx).id(); - outline_panel - .collapsed_entries - .remove(&CollapsedEntry::File(worktree_id, buffer_id)); - let entry = worktree.read(cx).entry_for_id(entry_id)?.clone(); - Some((worktree, entry)) - }) - }) - })? + .remove(&CollapsedEntry::File(path.worktree_id, buffer_id)); + } + for ancestor in path.path.ancestors().skip(1) { + outline_panel + .collapsed_entries + .remove(&CollapsedEntry::Dir(path.worktree_id, Arc::from(ancestor))); + } + } + outline_panel.select_entry(entry, false, window, cx); + if outline_panel.collapsed_entries.len() != collapsed_count { + outline_panel.update_cached_entries(None, window, cx); } - PanelEntry::Fs(FsEntry::ExternalFile(..)) => None, - PanelEntry::Search(SearchEntry { match_range, .. }) => multibuffer_snapshot - .anchor_to_buffer_anchor(match_range.start) - .map(|(anchor, _)| anchor.buffer_id) - .map(|buffer_id| { - outline_panel.update(cx, |outline_panel, cx| { - outline_panel - .collapsed_entries - .remove(&CollapsedEntry::ExternalFile(buffer_id)); - let project = project.read(cx); - let entry_id = project - .buffer_for_id(buffer_id, cx) - .and_then(|buffer| buffer.read(cx).entry_id(cx)); - - entry_id.and_then(|entry_id| { - project - .worktree_for_entry(entry_id, cx) - .and_then(|worktree| { - let worktree_id = worktree.read(cx).id(); - outline_panel - .collapsed_entries - .remove(&CollapsedEntry::File(worktree_id, buffer_id)); - let entry = - worktree.read(cx).entry_for_id(entry_id)?.clone(); - Some((worktree, entry)) - }) - }) - }) - }) - .transpose()? - .flatten(), - _ => return anyhow::Ok(()), - }; - if let Some((worktree, buffer_entry)) = related_buffer_entry { - outline_panel.update(cx, |outline_panel, cx| { - let worktree_id = worktree.read(cx).id(); - let mut dirs_to_expand = Vec::new(); - { - let mut traversal = worktree.read(cx).traverse_from_path( - true, - true, - true, - buffer_entry.path.as_ref(), - ); - let mut current_entry = buffer_entry; - loop { - if current_entry.is_dir() - && outline_panel - .collapsed_entries - .remove(&CollapsedEntry::Dir(worktree_id, current_entry.id)) - { - dirs_to_expand.push(current_entry.id); - } - - if traversal.back_to_parent() - && let Some(parent_entry) = traversal.entry() - { - current_entry = parent_entry.clone(); - continue; - } - break; - } - } - for dir_to_expand in dirs_to_expand { - project - .update(cx, |project, cx| { - project.expand_entry(worktree_id, dir_to_expand, cx) - }) - .unwrap_or_else(|| Task::ready(Ok(()))) - .detach_and_log_err(cx) - } - })? - } - - outline_panel.update_in(cx, |outline_panel, window, cx| { - outline_panel.select_entry(entry_with_selection, false, window, cx); - outline_panel.update_cached_entries(None, window, cx); })?; - anyhow::Ok(()) }); } @@ -2384,7 +2593,10 @@ impl OutlinePanel { }; let (item_id, label_element, icon) = match rendered_entry { FsEntry::File(FsEntryFile { - worktree_id, entry, .. + worktree_id, + entry, + buffer_id, + .. }) => { let name = self.entry_name(worktree_id, entry, cx); let color = @@ -2396,7 +2608,7 @@ impl OutlinePanel { None }; ( - ElementId::from(entry.id.to_proto() as usize), + ElementId::from(("buffer", buffer_id.to_proto())), HighlightedLabel::new( name, string_match @@ -2416,7 +2628,7 @@ impl OutlinePanel { let is_expanded = !self.collapsed_entries.contains(&CollapsedEntry::Dir( directory.worktree_id, - directory.entry.id, + directory.entry.path.clone(), )); let color = entry_git_aware_label_color( directory.entry.git_summary, @@ -2431,7 +2643,11 @@ impl OutlinePanel { cx, ); ( - ElementId::from(directory.entry.id.to_proto() as usize), + directory_element_id( + directory.worktree_id, + directory.first_buffer_id, + &directory.entry.path, + ), HighlightedLabel::new( name, string_match @@ -2463,7 +2679,7 @@ impl OutlinePanel { None => (None, "Unknown buffer".to_string()), }; ( - ElementId::from(external_file.buffer_id.to_proto() as usize), + ElementId::from(("buffer", external_file.buffer_id.to_proto())), HighlightedLabel::new( name, string_match @@ -2502,19 +2718,17 @@ impl OutlinePanel { ) -> Stateful
{ let settings = OutlinePanelSettings::get_global(cx); let is_active = match self.selected_entry() { - Some(PanelEntry::FoldedDirs(selected_dirs)) => { - selected_dirs.worktree_id == folded_dir.worktree_id - && selected_dirs.entries == folded_dir.entries - } + Some(PanelEntry::FoldedDirs(selected_dirs)) => selected_dirs == folded_dir, _ => false, }; let (item_id, label_element, icon) = { let name = self.dir_names_string(&folded_dir.entries, folded_dir.worktree_id, cx); let is_expanded = folded_dir.entries.iter().all(|dir| { - !self - .collapsed_entries - .contains(&CollapsedEntry::Dir(folded_dir.worktree_id, dir.id)) + !self.collapsed_entries.contains(&CollapsedEntry::Dir( + folded_dir.worktree_id, + dir.path.clone(), + )) }); let is_ignored = folded_dir.entries.iter().any(|entry| entry.is_ignored); let git_status = folded_dir @@ -2531,14 +2745,19 @@ impl OutlinePanel { cx, ); ( - ElementId::from( - folded_dir - .entries - .last() - .map(|entry| entry.id.to_proto()) - .unwrap_or_else(|| folded_dir.worktree_id.to_proto()) - as usize, - ), + folded_dir + .entries + .last() + .map(|entry| { + directory_element_id( + folded_dir.worktree_id, + folded_dir.first_buffer_id, + &entry.path, + ) + }) + .unwrap_or_else(|| { + ElementId::from(("empty-folded-dirs", folded_dir.worktree_id.to_proto())) + }), HighlightedLabel::new( name, string_match @@ -2668,6 +2887,10 @@ impl OutlinePanel { cx: &mut Context, ) -> Stateful
{ let settings = OutlinePanelSettings::get_global(cx); + let is_deleted = match &rendered_entry { + PanelEntry::Fs(FsEntry::File(file)) => file.is_deleted, + _ => false, + }; div() .text_ui(cx) .id(item_id.clone()) @@ -2699,7 +2922,13 @@ impl OutlinePanel { .child( h_flex() .child(h_flex().min_w(px(16.)).justify_center().child(icon_element)) - .child(h_flex().h_6().child(label_element).ml_1()), + .child( + h_flex() + .h_6() + .child(label_element) + .ml_1() + .when(is_deleted, |this| this.line_through()), + ), ) .on_secondary_mouse_down(cx.listener( move |outline_panel, event: &MouseDownEvent, window, cx| { @@ -2732,25 +2961,9 @@ impl OutlinePanel { ) } - fn entry_name(&self, worktree_id: &WorktreeId, entry: &Entry, cx: &App) -> String { + fn entry_name(&self, worktree_id: &WorktreeId, entry: &FsEntryPath, cx: &App) -> String { match self.project.read(cx).worktree_for_id(*worktree_id, cx) { - Some(worktree) => { - let worktree = worktree.read(cx); - match worktree.snapshot().root_entry() { - Some(root_entry) => { - if root_entry.id == entry.id { - file_name(worktree.abs_path().as_ref()) - } else { - let path = worktree.absolutize(entry.path.as_ref()); - file_name(&path) - } - } - None => { - let path = worktree.absolutize(entry.path.as_ref()); - file_name(&path) - } - } - } + Some(worktree) => file_name(&worktree.read(cx).absolutize(&entry.path)), None => file_name(entry.path.as_std_path()), } } @@ -2782,11 +2995,7 @@ impl OutlinePanel { window: &mut Window, cx: &mut Context, ) { - if !self.active { - return; - } - - if debounce.is_some() && self.fs_entries_update_pending { + if !self.active || (debounce.is_some() && self.fs_entries_update_pending) { return; } self.fs_entries_update_pending = true; @@ -2796,386 +3005,299 @@ impl OutlinePanel { cx.background_executor().timer(debounce).await; } - let mut new_collapsed_entries = HashSet::default(); - let mut new_unfolded_dirs = HashMap::default(); - let mut root_entries = HashSet::default(); let mut new_buffers = HashMap::::default(); - let Ok((buffer_excerpts, auto_fold_dirs, repo_snapshots)) = - outline_panel.update(cx, |outline_panel, cx| { - outline_panel.fs_entries_update_pending = false; - let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; - let active_multi_buffer = active_editor.read(cx).buffer().clone(); - let new_entries = outline_panel.new_entries_for_fs_update.clone(); - let repo_snapshots = outline_panel.project.update(cx, |project, cx| { - project.git_store().read(cx).display_repo_snapshots(cx) - }); - let git_store = outline_panel.project.read(cx).git_store().clone(); - new_collapsed_entries = outline_panel.collapsed_entries.clone(); - new_unfolded_dirs = outline_panel.unfolded_dirs.clone(); - let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx); + let Ok(( + buffer_excerpts, + auto_fold_dirs, + repo_snapshots, + mut collapsed_entries, + mut unfolded_dirs, + )) = outline_panel.update(cx, |outline_panel, cx| { + outline_panel.fs_entries_update_pending = false; + let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; + let git_store = outline_panel.project.read(cx).git_store().clone(); + let repo_snapshots = git_store.read(cx).display_repo_snapshots(cx); + let editor = active_editor.read(cx); + let snapshot = editor.buffer().read(cx).snapshot(cx); + let mut buffer_excerpts = IndexMap::::default(); - let buffer_excerpts = multi_buffer_snapshot.excerpts().fold( - HashMap::default(), - |mut buffer_excerpts, excerpt_range| { - let Some(buffer_snapshot) = multi_buffer_snapshot - .buffer_for_id(excerpt_range.context.start.buffer_id) - else { - return buffer_excerpts; - }; - let buffer_id = buffer_snapshot.remote_id(); - let file = File::from_dyn(buffer_snapshot.file()); - let entry_id = file.and_then(|file| file.project_entry_id()); - let worktree = file.map(|file| file.worktree.read(cx).snapshot()); - let is_new = new_entries.contains(&buffer_id) - || !outline_panel.buffers.contains_key(&buffer_id); - let is_folded = active_editor.read(cx).is_buffer_folded(buffer_id, cx); + for excerpt in snapshot.excerpts() { + let buffer_id = excerpt.context.start.buffer_id; + let Some(buffer) = snapshot.buffer_for_id(buffer_id) else { + continue; + }; + buffer_excerpts + .entry(buffer_id) + .or_insert_with(|| { let status = git_store .read(cx) .display_status_for_buffer_id(buffer_id, cx); - buffer_excerpts - .entry(buffer_id) - .or_insert_with(|| { - (is_new, is_folded, Vec::new(), entry_id, worktree, status) - }) - .2 - .push(excerpt_range.clone()); + let file = File::from_dyn(buffer.file()); + let is_deleted = file.is_some_and(|file| { + is_deleted_file( + file, + status, + snapshot + .diff_for_buffer_id(buffer_id) + .is_some_and(|diff| diff.base_text_exists()), + ) + }); + let file = file.and_then(|file| { + let worktree = file.worktree.read(cx).snapshot(); + let entry = file + .project_entry_id() + .and_then(|id| worktree.entry_for_id(id)); + if entry.is_none() && !is_deleted { + return None; + } + let path_entry = FsEntryPath { + path: file.path.clone(), + git_summary: status.map(GitSummary::from).unwrap_or_default(), + is_ignored: entry.is_some_and(|entry| entry.is_ignored), + }; + Some((worktree, path_entry)) + }); + BufferExcerpts { + is_new: outline_panel + .new_entries_for_fs_update + .contains(&buffer_id) + || !outline_panel.buffers.contains_key(&buffer_id), + is_folded: editor.is_buffer_folded(buffer_id, cx), + excerpts: Vec::new(), + file, + is_deleted, + } + }) + .excerpts + .push(excerpt.clone()); - new_buffers - .entry(buffer_id) - .or_insert_with(|| { - let outlines = match outline_panel.buffers.get(&buffer_id) { - Some(old_buffer) => match &old_buffer.outlines { - OutlineState::Outlines(outlines) => { - OutlineState::Outlines(outlines.clone()) - } - OutlineState::Invalidated(_) => { - OutlineState::NotFetched - } - OutlineState::NotFetched => OutlineState::NotFetched, - }, - None => OutlineState::NotFetched, - }; - BufferOutlines { - outlines, - excerpts: Vec::new(), - } - }) - .excerpts - .push(excerpt_range); - buffer_excerpts - }, - ); - (buffer_excerpts, auto_fold_dirs, repo_snapshots) - }) + new_buffers + .entry(buffer_id) + .or_insert_with(|| { + let outlines = match outline_panel.buffers.get(&buffer_id) { + Some(BufferOutlines { + outlines: OutlineState::Outlines(outlines), + .. + }) => OutlineState::Outlines(outlines.clone()), + _ => OutlineState::NotFetched, + }; + BufferOutlines { + outlines, + excerpts: Vec::new(), + } + }) + .excerpts + .push(excerpt); + } + ( + buffer_excerpts, + auto_fold_dirs, + repo_snapshots, + outline_panel.collapsed_entries.clone(), + outline_panel.unfolded_dirs.clone(), + ) + }) else { return; }; - let Some(( - new_collapsed_entries, - new_unfolded_dirs, - new_fs_entries, - new_depth_map, - new_children_count, - )) = cx + let (fs_entries, children_count, collapsed_entries, unfolded_dirs) = cx .background_spawn(async move { - let mut processed_external_buffers = HashSet::default(); - let mut new_worktree_entries = - BTreeMap::>::default(); - let mut worktree_excerpts = HashMap::< - WorktreeId, - HashMap>)>, - >::default(); - let mut external_excerpts = HashMap::default(); - - for (buffer_id, (is_new, is_folded, excerpts, entry_id, worktree, status)) in - buffer_excerpts - { - if is_folded { - match &worktree { - Some(worktree) => { - new_collapsed_entries - .insert(CollapsedEntry::File(worktree.id(), buffer_id)); - } - None => { - new_collapsed_entries - .insert(CollapsedEntry::ExternalFile(buffer_id)); - } - } - } else if is_new { - match &worktree { - Some(worktree) => { - new_collapsed_entries - .remove(&CollapsedEntry::File(worktree.id(), buffer_id)); - } - None => { - new_collapsed_entries - .remove(&CollapsedEntry::ExternalFile(buffer_id)); - } - } + let mut fs_entries = Vec::new(); + let mut directories = HashMap::::default(); + let mut parent_paths = Vec::::new(); + let repositories_by_root = repo_snapshots + .values() + .map(|snapshot| (snapshot.work_directory_abs_path.as_ref(), snapshot)) + .collect::>(); + for (buffer_id, buffer) in buffer_excerpts { + let collapsed_entry = match &buffer.file { + Some((worktree, _)) => CollapsedEntry::File(worktree.id(), buffer_id), + None => CollapsedEntry::ExternalFile(buffer_id), + }; + if buffer.is_folded { + collapsed_entries.insert(collapsed_entry); + } else if buffer.is_new { + collapsed_entries.remove(&collapsed_entry); } - - if let Some(worktree) = worktree { - let worktree_id = worktree.id(); - let unfolded_dirs = new_unfolded_dirs.entry(worktree_id).or_default(); - - match entry_id.and_then(|id| worktree.entry_for_id(id)).cloned() { - Some(entry) => { - let entry = GitEntry { - git_summary: status - .map(|status| status.summary()) - .unwrap_or_default(), - entry, - }; - let mut traversal = GitTraversal::new( - &repo_snapshots, - worktree.traverse_from_path( - true, - true, - true, - entry.path.as_ref(), - ), - ); - - let mut entries_to_add = HashMap::default(); - worktree_excerpts - .entry(worktree_id) - .or_default() - .insert(entry.id, (buffer_id, excerpts)); - let mut current_entry = entry; - loop { - if current_entry.is_dir() { - let is_root = - worktree.root_entry().map(|entry| entry.id) - == Some(current_entry.id); - if is_root { - root_entries.insert(current_entry.id); - if auto_fold_dirs { - unfolded_dirs.insert(current_entry.id); - } - } - if is_new { - new_collapsed_entries.remove(&CollapsedEntry::Dir( - worktree_id, - current_entry.id, + let Some((worktree, entry)) = buffer.file else { + parent_paths.clear(); + fs_entries.push(FsEntry::ExternalFile(FsEntryExternalFile { + buffer_id, + excerpts: Arc::from(buffer.excerpts), + })); + continue; + }; + let worktree_id = worktree.id(); + let mut ancestors = entry.path.ancestors().skip(1).collect::>(); + ancestors.reverse(); + let shared_depth = parent_paths + .last() + .filter(|parent| parent.worktree_id == worktree_id) + .zip(ancestors.last()) + .map(|(parent, ancestor)| { + parent + .path + .components() + .zip(ancestor.components()) + .take_while(|(left, right)| left == right) + .count() + + 1 + }) + .unwrap_or(0); + parent_paths.truncate(shared_depth); + for (depth, path) in ancestors.into_iter().enumerate() { + let ancestor = + parent_paths + .get(depth) + .cloned() + .unwrap_or_else(|| ProjectPath { + worktree_id, + path: Arc::from(path), + }); + if buffer.is_new { + collapsed_entries.remove(&CollapsedEntry::Dir( + worktree_id, + ancestor.path.clone(), + )); + } + if depth < shared_depth { + continue; + } + let directory = + directories.entry(ancestor.clone()).or_insert_with(|| { + let indexed_entry = worktree + .entry_for_path(&ancestor.path) + .filter(|entry| entry.is_dir()); + let git_summary = indexed_entry + .and_then(|_| { + let abs_path = worktree.absolutize(&ancestor.path); + for query in abs_path.ancestors() { + let (_, repository) = repositories_by_root + .range(Path::new("")..=query) + .next_back()?; + let Some(path) = + repository.abs_path_to_repo_path(&abs_path) + else { + continue; + }; + let mut statuses = repository + .statuses_by_path + .cursor::(()); + statuses.seek_forward( + &PathTarget::Path(&path), + Bias::Left, + ); + return Some(statuses.summary( + &PathTarget::Successor(&path), + Bias::Left, )); } - } - - let new_entry_added = entries_to_add - .insert(current_entry.id, current_entry) - .is_none(); - if new_entry_added - && traversal.back_to_parent() - && let Some(parent_entry) = traversal.entry() - { - current_entry = parent_entry.to_owned(); - continue; - } - break; + None + }) + .unwrap_or_default(); + FsEntryPath { + path: ancestor.path.clone(), + git_summary, + is_ignored: indexed_entry + .is_some_and(|entry| entry.is_ignored), } - new_worktree_entries - .entry(worktree_id) - .or_insert_with(HashMap::default) - .extend(entries_to_add); - } - None => { - if processed_external_buffers.insert(buffer_id) { - external_excerpts - .entry(buffer_id) - .or_insert_with(Vec::new) - .extend(excerpts); - } - } - } - } else if processed_external_buffers.insert(buffer_id) { - external_excerpts - .entry(buffer_id) - .or_insert_with(Vec::new) - .extend(excerpts); + }); + fs_entries.push(FsEntry::Directory(FsEntryDirectory { + worktree_id, + first_buffer_id: buffer_id, + entry: directory.clone(), + })); + parent_paths.push(ancestor); + } + fs_entries.push(FsEntry::File(FsEntryFile { + worktree_id, + entry, + buffer_id, + excerpts: Arc::from(buffer.excerpts), + is_deleted: buffer.is_deleted, + })); + } + collapsed_entries.retain(|entry| match entry { + CollapsedEntry::Dir(worktree_id, path) => { + directories.contains_key(&ProjectPath { + worktree_id: *worktree_id, + path: path.clone(), + }) + } + _ => true, + }); + unfolded_dirs.retain(|worktree_id, paths| { + paths.retain(|path| { + directories.contains_key(&ProjectPath { + worktree_id: *worktree_id, + path: path.clone(), + }) + }); + !paths.is_empty() + }); + let mut children_count = + HashMap::, FsChildren>>::default(); + for directory in directories.keys() { + if let Some(parent) = directory.path.parent() { + children_count + .entry(directory.worktree_id) + .or_default() + .entry(Arc::from(parent)) + .or_default() + .dirs += 1; } } - - let mut new_children_count = - HashMap::, FsChildren>>::default(); - - let worktree_entries = new_worktree_entries - .into_iter() - .map(|(worktree_id, entries)| { - let mut entries = entries.into_values().collect::>(); - entries.sort_by(|a, b| a.path.as_ref().cmp(b.path.as_ref())); - (worktree_id, entries) - }) - .flat_map(|(worktree_id, entries)| { + for entry in &fs_entries { + if let FsEntry::File(file) = entry + && let Some(parent) = file.entry.path.parent() + { + children_count + .entry(file.worktree_id) + .or_default() + .entry(Arc::from(parent)) + .or_default() + .files += 1; + } + } + if auto_fold_dirs { + for entry in &fs_entries { + let FsEntry::Directory(directory) = entry else { + continue; + }; + let unfolded = unfolded_dirs.entry(directory.worktree_id).or_default(); + let path = &directory.entry.path; + let children = children_count + .get(&directory.worktree_id) + .and_then(|children| children.get(path)) + .copied() + .unwrap_or_default(); + let parent_unfolded = + path.parent().is_none_or(|parent| unfolded.contains(parent)); + if path.is_empty() + || !children.may_be_fold_part() + || (children.dirs == 0 && parent_unfolded) { - entries - .into_iter() - .filter_map(|entry| { - if auto_fold_dirs && let Some(parent) = entry.path.parent() - { - let children = new_children_count - .entry(worktree_id) - .or_default() - .entry(Arc::from(parent)) - .or_default(); - if entry.is_dir() { - children.dirs += 1; - } else { - children.files += 1; - } - } - - if entry.is_dir() { - Some(FsEntry::Directory(FsEntryDirectory { - worktree_id, - entry, - })) - } else { - let (buffer_id, excerpts) = worktree_excerpts - .get_mut(&worktree_id) - .and_then(|worktree_excerpts| { - worktree_excerpts.remove(&entry.id) - })?; - Some(FsEntry::File(FsEntryFile { - worktree_id, - buffer_id, - entry, - excerpts, - })) - } - }) - .collect::>() + unfolded.insert(path.clone()); } - }) - .collect::>(); - - let mut visited_dirs = Vec::new(); - let mut new_depth_map = HashMap::default(); - let new_visible_entries = external_excerpts - .into_iter() - .sorted_by_key(|(id, _)| *id) - .map(|(buffer_id, excerpts)| { - FsEntry::ExternalFile(FsEntryExternalFile { - buffer_id, - excerpts, - }) - }) - .chain(worktree_entries) - .filter(|visible_item| { - match visible_item { - FsEntry::Directory(directory) => { - let parent_id = back_to_common_visited_parent( - &mut visited_dirs, - &directory.worktree_id, - &directory.entry, - ); - - let mut depth = 0; - if !root_entries.contains(&directory.entry.id) { - if auto_fold_dirs { - let children = new_children_count - .get(&directory.worktree_id) - .and_then(|children_count| { - children_count.get(&directory.entry.path) - }) - .copied() - .unwrap_or_default(); - - if !children.may_be_fold_part() - || (children.dirs == 0 - && visited_dirs - .last() - .map(|(parent_dir_id, _)| { - new_unfolded_dirs - .get(&directory.worktree_id) - .is_none_or(|unfolded_dirs| { - unfolded_dirs - .contains(parent_dir_id) - }) - }) - .unwrap_or(true)) - { - new_unfolded_dirs - .entry(directory.worktree_id) - .or_default() - .insert(directory.entry.id); - } - } - - depth = parent_id - .and_then(|(worktree_id, id)| { - new_depth_map.get(&(worktree_id, id)).copied() - }) - .unwrap_or(0) - + 1; - }; - visited_dirs - .push((directory.entry.id, directory.entry.path.clone())); - new_depth_map - .insert((directory.worktree_id, directory.entry.id), depth); - } - FsEntry::File(FsEntryFile { - worktree_id, - entry: file_entry, - .. - }) => { - let parent_id = back_to_common_visited_parent( - &mut visited_dirs, - worktree_id, - file_entry, - ); - let depth = if root_entries.contains(&file_entry.id) { - 0 - } else { - parent_id - .and_then(|(worktree_id, id)| { - new_depth_map.get(&(worktree_id, id)).copied() - }) - .unwrap_or(0) - + 1 - }; - new_depth_map.insert((*worktree_id, file_entry.id), depth); - } - FsEntry::ExternalFile(..) => { - visited_dirs.clear(); - } - } - - true - }) - .collect::>(); - - anyhow::Ok(( - new_collapsed_entries, - new_unfolded_dirs, - new_visible_entries, - new_depth_map, - new_children_count, - )) + } + } + (fs_entries, children_count, collapsed_entries, unfolded_dirs) }) - .await - .log_err() - else { - return; - }; + .await; outline_panel .update_in(cx, |outline_panel, window, cx| { outline_panel.new_entries_for_fs_update.clear(); outline_panel.buffers = new_buffers; - outline_panel.collapsed_entries = new_collapsed_entries; - outline_panel.unfolded_dirs = new_unfolded_dirs; - outline_panel.fs_entries = new_fs_entries; - outline_panel.fs_entries_depth = new_depth_map; - outline_panel.fs_children_count = new_children_count; + outline_panel.collapsed_entries = collapsed_entries; + outline_panel.unfolded_dirs = unfolded_dirs; + outline_panel.fs_entries = fs_entries; + outline_panel.fs_children_count = children_count; outline_panel.update_non_fs_items(window, cx); - - // Only update cached entries if we don't have outlines to fetch - // If we do have outlines to fetch, let fetch_outdated_outlines handle the update if outline_panel.buffers_to_fetch(cx).is_empty() { outline_panel.update_cached_entries(debounce, window, cx); } - cx.notify(); }) .ok(); @@ -3204,11 +3326,8 @@ impl OutlinePanel { e: &SearchEvent, window: &mut Window, cx: &mut Context| { - if matches!(e, SearchEvent::MatchesInvalidated) - && outline_panel.update_search_matches(window, cx) - { - outline_panel.selected_entry.invalidate(); - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + if matches!(e, SearchEvent::MatchesInvalidated) { + outline_panel.update_contents(Some(UPDATE_DEBOUNCE), window, cx); } }, ); @@ -3234,9 +3353,10 @@ impl OutlinePanel { fn clear_previous(&mut self, window: &mut Window, cx: &mut App) { self.fs_entries_update_task = Task::ready(()); self.fs_entries_update_pending = false; + self.lsp_outline_refresh_task = Task::ready(()); self.outline_fetch_tasks.clear(); self.cached_entries_update_task = Task::ready(()); - self.cached_entries_update_pending = false; + self.cached_entries_update_pending = None; self.reveal_selection_task = Task::ready(Ok(())); self.filter_editor .update(cx, |editor, cx| editor.clear(window, cx)); @@ -3244,7 +3364,6 @@ impl OutlinePanel { self.unfolded_dirs.clear(); self.active_item = None; self.fs_entries.clear(); - self.fs_entries_depth.clear(); self.fs_children_count.clear(); self.buffers.clear(); self.cached_entries = Vec::new(); @@ -3340,14 +3459,48 @@ impl OutlinePanel { selection_display_point: DisplayPoint, cx: &App, ) -> Option { + let buffer_snapshot = multi_buffer_snapshot.buffer_for_id(selection_anchor.buffer_id); + let mut excerpt_ranges = multi_buffer_snapshot + .excerpts_for_buffer(selection_anchor.buffer_id) + .map(|excerpt| excerpt.context) + .collect::>(); + if let Some(snapshot) = buffer_snapshot { + excerpt_ranges.sort_unstable_by(|left, right| left.start.cmp(&right.start, snapshot)); + let mut maximum_end = None; + for range in &mut excerpt_ranges { + if let Some(end) = maximum_end + && range.end.cmp(&end, snapshot).is_lt() + { + range.end = end; + } + maximum_end = Some(range.end); + } + } + let excerpt_outlines = self .buffers .get(&selection_anchor.buffer_id) .into_iter() .flat_map(|buffer| buffer.iter_outlines()) .flat_map(|outline| { - let range = multi_buffer_snapshot - .buffer_anchor_range_to_anchor_range(outline.range.clone())?; + let snapshot = buffer_snapshot?; + let (start, end) = if outline + .range + .start + .cmp(&outline.range.end, snapshot) + .is_gt() + { + (outline.range.end, outline.range.start) + } else { + (outline.range.start, outline.range.end) + }; + let index = excerpt_ranges + .partition_point(|excerpt| excerpt.start.cmp(&start, snapshot).is_le()) + .checked_sub(1)?; + if excerpt_ranges.get(index)?.end.cmp(&end, snapshot).is_lt() { + return None; + } + let range = multi_buffer_snapshot.anchor_range_in_buffer(outline.range.clone())?; Some(( range.start.to_display_point(&editor_snapshot) ..range.end.to_display_point(&editor_snapshot), @@ -3482,7 +3635,12 @@ impl OutlinePanel { Some(closest_container) } - fn fetch_outdated_outlines(&mut self, window: &mut Window, cx: &mut Context) { + fn fetch_outdated_outlines( + &mut self, + debounce: Option, + window: &mut Window, + cx: &mut Context, + ) { let buffers_to_fetch = self.buffers_to_fetch(cx); if buffers_to_fetch.is_empty() { return; @@ -3490,29 +3648,34 @@ impl OutlinePanel { let first_update = Arc::new(AtomicBool::new(true)); for buffer_id in buffers_to_fetch { - let outline_task = self.active_editor().map(|editor| { - editor.update(cx, |editor, cx| editor.buffer_outline_items(buffer_id, cx)) - }); - + if self + .outline_fetch_tasks + .get(&buffer_id) + .is_some_and(|task| !task.is_ready()) + { + continue; + } let first_update = first_update.clone(); self.outline_fetch_tasks.insert( buffer_id, cx.spawn_in(window, async move |outline_panel, cx| { - let Some(outline_task) = outline_task else { + if let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; + } + let Some(outline_task) = outline_panel + .update(cx, |panel, cx| { + let editor = panel.active_editor()?; + Some(editor.update(cx, |editor, cx| { + editor.buffer_outline_items(buffer_id, cx) + })) + }) + .ok() + .flatten() + else { return; }; let fetched_outlines = outline_task.await; - let outlines_with_children = fetched_outlines - .array_windows::<2>() - .filter_map(|[current, next]| { - if next.depth > current.depth { - Some((current.range.clone(), current.depth)) - } else { - None - } - }) - .collect::>(); outline_panel .update_in(cx, |outline_panel, window, cx| { @@ -3523,19 +3686,31 @@ impl OutlinePanel { .insert(buffer_id) }); - let debounce = - if first_update.fetch_and(false, atomic::Ordering::AcqRel) { - None - } else { - Some(UPDATE_DEBOUNCE) - }; - + let mut entries_changed = false; if let Some(buffer) = outline_panel.buffers.get_mut(&buffer_id) { - buffer.outlines = OutlineState::Outlines(fetched_outlines.clone()); + entries_changed = pending_default_depth.is_some() + || match &buffer.outlines { + OutlineState::Outlines(outlines) + | OutlineState::Invalidated(outlines) => { + outlines != &fetched_outlines + } + OutlineState::NotFetched => true, + }; + buffer.outlines = OutlineState::Outlines(fetched_outlines); if let Some(default_depth) = pending_default_depth && let OutlineState::Outlines(outlines) = &buffer.outlines { + let outlines_with_children = outlines + .array_windows::<2>() + .filter_map(|[current, next]| { + if next.depth > current.depth { + Some((current.range.clone(), current.depth)) + } else { + None + } + }) + .collect::>(); outlines .iter() .filter(|outline| { @@ -3553,7 +3728,27 @@ impl OutlinePanel { } } - outline_panel.update_cached_entries(debounce, window, cx); + if entries_changed + || matches!( + &outline_panel.selected_entry, + SelectedEntry::Invalidated(Some(PanelEntry::Outline(outline))) + if outline.buffer_id() == buffer_id + ) + { + let debounce = + if first_update.fetch_and(false, atomic::Ordering::AcqRel) { + None + } else { + Some(UPDATE_DEBOUNCE) + }; + if let Some(PanelEntry::Outline(outline)) = + outline_panel.selected_entry() + && outline.buffer_id() == buffer_id + { + outline_panel.selected_entry.invalidate(); + } + outline_panel.update_cached_entries(debounce, window, cx); + } }) .ok(); }), @@ -3622,15 +3817,16 @@ impl OutlinePanel { } fn invalidate_outlines(&mut self, ids: &[BufferId]) { - self.outline_fetch_tasks.clear(); - let mut ids = ids.iter().collect::>(); - for (buffer_id, buffer) in self.buffers.iter_mut() { - if ids.remove(&buffer_id) { + if let Some(PanelEntry::Outline(outline)) = self.selected_entry() + && ids.contains(&outline.buffer_id()) + { + self.selected_entry.invalidate(); + } + for buffer_id in ids { + self.outline_fetch_tasks.remove(buffer_id); + if let Some(buffer) = self.buffers.get_mut(buffer_id) { buffer.invalidate_outlines(); } - if ids.is_empty() { - break; - } } } @@ -3717,6 +3913,19 @@ impl OutlinePanel { } } + fn update_contents( + &mut self, + debounce: Option, + window: &mut Window, + cx: &mut Context, + ) { + if !self.active { + return; + } + self.cached_entries_update_pending = Some(CachedEntriesUpdate::Contents); + self.update_cached_entries(debounce, window, cx); + } + fn update_cached_entries( &mut self, debounce: Option, @@ -3727,54 +3936,150 @@ impl OutlinePanel { return; } - // A pending debounced update will read the latest state when it fires, - // so we don't need to reschedule. Constantly rescheduling under a steady stream - // of events (e.g. project search streaming results) would starve the task forever. - if debounce.is_some() && self.cached_entries_update_pending { + self.cached_entries_update_pending + .get_or_insert(CachedEntriesUpdate::Entries); + if !self.cached_entries_update_task.is_ready() { return; } - self.cached_entries_update_pending = true; self.cached_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| { - if let Some(debounce) = debounce { - cx.background_executor().timer(debounce).await; - } - let Some(new_cached_entries) = outline_panel - .update_in(cx, |outline_panel, window, cx| { - outline_panel.cached_entries_update_pending = false; - let is_singleton = outline_panel.is_singleton_active(cx); - let query = outline_panel.query(cx); - outline_panel.generate_cached_entries(is_singleton, query, window, cx) - }) - .ok() - else { - return; - }; - let (new_cached_entries, max_width_item_index) = new_cached_entries.await; - outline_panel - .update_in(cx, |outline_panel, window, cx| { - outline_panel.cached_entries = new_cached_entries; - outline_panel.max_width_item_index = max_width_item_index; - if (outline_panel.selected_entry.is_invalidated() - || matches!(outline_panel.selected_entry, SelectedEntry::None)) - && let Some(new_selected_entry) = - outline_panel.active_editor().and_then(|active_editor| { - outline_panel.location_for_editor_selection( - &active_editor, - window, - cx, - ) - }) - { - outline_panel.select_entry(new_selected_entry, false, window, cx); - } + let mut debounce = debounce; + loop { + if let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; + } + let Some(new_cached_entries) = outline_panel + .update_in(cx, |outline_panel, window, cx| { + if outline_panel.cached_entries_update_pending.take() + == Some(CachedEntriesUpdate::Contents) + { + outline_panel.update_non_fs_items(window, cx); + } + let is_singleton = outline_panel.is_singleton_active(cx); + let query = outline_panel.query(cx); + outline_panel.generate_cached_entries(is_singleton, query, window, cx) + }) + .ok() + else { + return; + }; + let (new_cached_entries, max_width_item_index) = new_cached_entries.await; + outline_panel + .update_in(cx, |outline_panel, window, cx| { + outline_panel.cached_entries = new_cached_entries; + outline_panel.max_width_item_index = max_width_item_index; + if let SelectedEntry::Valid(selected, _) = &outline_panel.selected_entry + && let Some((index, cached)) = + outline_panel.cached_entry_for_selection(selected) + { + outline_panel.selected_entry = + SelectedEntry::Valid(cached.entry.clone(), index); + } + if (outline_panel.selected_entry.is_invalidated() + || matches!(outline_panel.selected_entry, SelectedEntry::None)) + && let Some(new_selected_entry) = + outline_panel.active_editor().and_then(|active_editor| { + outline_panel.location_for_editor_selection( + &active_editor, + window, + cx, + ) + }) + { + let awaiting_outlines = match &new_selected_entry { + PanelEntry::Outline(outline) => outline_panel + .buffers + .get(&outline.buffer_id()) + .is_some_and(BufferOutlines::should_fetch_outlines), + _ => false, + }; + outline_panel.select_entry(new_selected_entry, false, window, cx); + if awaiting_outlines { + outline_panel.selected_entry.invalidate(); + } + } - cx.notify(); - }) - .ok(); + cx.notify(); + }) + .ok(); + if !outline_panel + .read_with(cx, |panel, _| panel.cached_entries_update_pending.is_some()) + .unwrap_or(false) + { + break; + } + debounce = Some(UPDATE_DEBOUNCE); + } }); } + fn cached_entry_for_selection(&self, selected: &PanelEntry) -> Option<(usize, &CachedEntry)> { + if let Some(entry) = self + .cached_entries + .iter() + .enumerate() + .find(|(_, cached)| &cached.entry == selected) + { + return Some(entry); + } + let (worktree_id, path, first_buffer_id) = match selected { + PanelEntry::Fs(FsEntry::Directory(directory)) => ( + directory.worktree_id, + &directory.entry.path, + directory.first_buffer_id, + ), + PanelEntry::FoldedDirs(group) => ( + group.worktree_id, + &group.entries.last()?.path, + group.first_buffer_id, + ), + _ => return None, + }; + let mut first_section = None; + let mut current_section = None; + let mut selected_section = None; + for entry in &self.fs_entries { + match entry { + FsEntry::Directory(directory) + if directory.worktree_id == worktree_id && directory.entry.path == *path => + { + first_section.get_or_insert(directory.first_buffer_id); + current_section = Some(directory.first_buffer_id); + } + FsEntry::File(file) if file.buffer_id == first_buffer_id => { + if file.worktree_id == worktree_id + && file + .entry + .path + .parent() + .is_some_and(|parent| parent.starts_with(path)) + { + selected_section = current_section; + break; + } + } + _ => {} + } + } + let selected_section = selected_section.or(first_section)?; + self.cached_entries + .iter() + .enumerate() + .find(|(_, cached)| match &cached.entry { + PanelEntry::Fs(FsEntry::Directory(directory)) => { + directory.worktree_id == worktree_id + && directory.first_buffer_id == selected_section + && directory.entry.path == *path + } + PanelEntry::FoldedDirs(group) => { + group.worktree_id == worktree_id + && group.first_buffer_id == selected_section + && group.entries.iter().any(|entry| entry.path == *path) + } + _ => false, + }) + } + fn generate_cached_entries( &self, is_singleton: bool, @@ -3782,7 +4087,6 @@ impl OutlinePanel { window: &mut Window, cx: &mut Context, ) -> Task<(Vec, Option)> { - let project = self.project.clone(); let Some(active_editor) = self.active_editor() else { return Task::ready((Vec::new(), None)); }; @@ -3801,6 +4105,7 @@ impl OutlinePanel { folded: bool, expanded: bool, depth: usize, + worktree_id: WorktreeId, } let search_precomputed = if hide_symbols { @@ -3863,27 +4168,20 @@ impl OutlinePanel { let (depth, should_add) = match &entry { FsEntry::Directory(directory_entry) => { let mut should_add = true; - let is_root = project - .read(cx) - .worktree_for_id(directory_entry.worktree_id, cx) - .is_some_and(|worktree| { - worktree.read(cx).root_entry() == Some(&directory_entry.entry) - }); + let is_root = directory_entry.entry.path.is_empty(); let folded = auto_fold_dirs && !is_root && outline_panel .unfolded_dirs .get(&directory_entry.worktree_id) .is_none_or(|unfolded_dirs| { - !unfolded_dirs.contains(&directory_entry.entry.id) + !unfolded_dirs.contains(&directory_entry.entry.path) }); - let fs_depth = outline_panel - .fs_entries_depth - .get(&(directory_entry.worktree_id, directory_entry.entry.id)) - .copied() - .unwrap_or(0); + let fs_depth = directory_entry.entry.path.components().count(); while let Some(parent) = parent_dirs.last() { - if !is_root && directory_entry.entry.path.starts_with(&parent.path) + if !is_root + && parent.worktree_id == directory_entry.worktree_id + && directory_entry.entry.path.starts_with(&parent.path) { break; } @@ -3917,6 +4215,7 @@ impl OutlinePanel { parent.depth + 1 }; parent_dirs.push(ParentStats { + worktree_id: directory_entry.worktree_id, path: directory_entry.entry.path.clone(), folded, expanded: parent_expanded && is_expanded, @@ -3926,6 +4225,7 @@ impl OutlinePanel { } None => { parent_dirs.push(ParentStats { + worktree_id: directory_entry.worktree_id, path: directory_entry.entry.path.clone(), folded, expanded: is_expanded, @@ -3984,6 +4284,7 @@ impl OutlinePanel { depth, FoldedDirsEntry { worktree_id: directory_entry.worktree_id, + first_buffer_id: directory_entry.first_buffer_id, entries: vec![directory_entry.entry.clone()], }, )) @@ -3994,6 +4295,7 @@ impl OutlinePanel { depth, FoldedDirsEntry { worktree_id: directory_entry.worktree_id, + first_buffer_id: directory_entry.first_buffer_id, entries: vec![directory_entry.entry.clone()], }, )); @@ -4005,15 +4307,15 @@ impl OutlinePanel { } FsEntry::ExternalFile(..) => { if let Some((folded_depth, folded_dir)) = folded_dirs_entry.take() { + let folded_paths = folded_dir + .entries + .iter() + .map(|entry| &entry.path) + .collect::>(); let parent_expanded = parent_dirs .iter() .rev() - .find(|parent| { - folded_dir - .entries - .iter() - .all(|entry| entry.path != parent.path) - }) + .find(|parent| !folded_paths.contains(&parent.path)) .is_none_or(|parent| parent.expanded); if !is_singleton && (parent_expanded || query.is_some()) { outline_panel.push_entry( @@ -4030,15 +4332,15 @@ impl OutlinePanel { } FsEntry::File(file) => { if let Some((folded_depth, folded_dirs)) = folded_dirs_entry.take() { + let folded_paths = folded_dirs + .entries + .iter() + .map(|entry| &entry.path) + .collect::>(); let parent_expanded = parent_dirs .iter() .rev() - .find(|parent| { - folded_dirs - .entries - .iter() - .all(|entry| entry.path != parent.path) - }) + .find(|parent| !folded_paths.contains(&parent.path)) .is_none_or(|parent| parent.expanded); if !is_singleton && (parent_expanded || query.is_some()) { outline_panel.push_entry( @@ -4051,13 +4353,15 @@ impl OutlinePanel { } } - let fs_depth = outline_panel - .fs_entries_depth - .get(&(file.worktree_id, file.entry.id)) - .copied() - .unwrap_or(0); + let fs_depth = file.entry.path.components().count(); while let Some(parent) = parent_dirs.last() { - if file.entry.path.starts_with(&parent.path) { + if parent.worktree_id == file.worktree_id + && file + .entry + .path + .parent() + .is_some_and(|path| path.starts_with(&parent.path)) + { break; } parent_dirs.pop(); @@ -4156,15 +4460,15 @@ impl OutlinePanel { } if let Some((folded_depth, folded_dirs)) = folded_dirs_entry.take() { + let folded_paths = folded_dirs + .entries + .iter() + .map(|entry| &entry.path) + .collect::>(); let parent_expanded = parent_dirs .iter() .rev() - .find(|parent| { - folded_dirs - .entries - .iter() - .all(|entry| entry.path != parent.path) - }) + .find(|parent| !folded_paths.contains(&parent.path)) .is_none_or(|parent| parent.expanded); if parent_expanded || query.is_some() { outline_panel.push_entry( @@ -4241,6 +4545,7 @@ impl OutlinePanel { } 1 => PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id: folded_dirs_entry.worktree_id, + first_buffer_id: folded_dirs_entry.first_buffer_id, entry: folded_dirs_entry.entries[0].clone(), })), _ => entry, @@ -4306,7 +4611,12 @@ impl OutlinePanel { }); } - fn dir_names_string(&self, entries: &[GitEntry], worktree_id: WorktreeId, cx: &App) -> String { + fn dir_names_string( + &self, + entries: &[FsEntryPath], + worktree_id: WorktreeId, + cx: &App, + ) -> String { let dir_names_segment = entries .iter() .map(|entry| self.entry_name(&worktree_id, entry, cx)) @@ -4335,23 +4645,23 @@ impl OutlinePanel { }) => CollapsedEntry::File(*worktree_id, *buffer_id), FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. - }) => CollapsedEntry::Dir(*worktree_id, entry.id), + }) => CollapsedEntry::Dir(*worktree_id, entry.path.clone()), }; !self.collapsed_entries.contains(&entry_to_check) } - fn update_non_fs_items(&mut self, window: &mut Window, cx: &mut Context) -> bool { + fn update_non_fs_items(&mut self, window: &mut Window, cx: &mut Context) { if !self.active { - return false; + return; } - let mut update_cached_items = false; - update_cached_items |= self.update_search_matches(window, cx); - self.fetch_outdated_outlines(window, cx); - if update_cached_items { + if self.cached_entries_update_pending == Some(CachedEntriesUpdate::Contents) { + self.cached_entries_update_pending = Some(CachedEntriesUpdate::Entries); + } + if self.update_search_matches(window, cx) { self.selected_entry.invalidate(); } - update_cached_items + self.fetch_outdated_outlines(None, window, cx); } fn update_search_matches( @@ -4366,31 +4676,21 @@ impl OutlinePanel { let project_search = self .active_item() .and_then(|item| item.downcast::()); - let project_search_matches = project_search - .as_ref() - .map(|project_search| project_search.read(cx).get_matches(cx)) - .unwrap_or_default(); - let buffer_search = self - .active_item() - .as_deref() - .and_then(|active_item| { - self.workspace - .upgrade() - .and_then(|workspace| workspace.read(cx).pane_for(active_item)) - }) - .and_then(|pane| { - pane.read(cx) - .toolbar() - .read(cx) - .item_of_type::() - }); let buffer_search_matches = self .active_editor() .map(|active_editor| { active_editor.update(cx, |editor, cx| editor.get_matches(window, cx).0) }) .unwrap_or_default(); + let project_search_matches = if buffer_search_matches.is_empty() { + project_search + .as_ref() + .map(|project_search| project_search.read(cx).get_matches(cx)) + .unwrap_or_default() + } else { + Vec::new() + }; let mut update_cached_entries = false; if buffer_search_matches.is_empty() && project_search_matches.is_empty() { @@ -4408,6 +4708,20 @@ impl OutlinePanel { .unwrap_or_default(), ) } else { + let buffer_search = self + .active_item() + .as_deref() + .and_then(|active_item| { + self.workspace + .upgrade() + .and_then(|workspace| workspace.read(cx).pane_for(active_item)) + }) + .and_then(|pane| { + pane.read(cx) + .toolbar() + .read(cx) + .item_of_type::() + }); ( SearchKind::Buffer, buffer_search_matches, @@ -4465,119 +4779,142 @@ impl OutlinePanel { let Some(buffer) = self.buffers.get(&buffer_id) else { return; }; - let buffer_snapshot = self.buffer_snapshot_for_id(buffer_id, cx); - - for excerpt in &buffer.excerpts { + let snapshot = buffer_snapshot.as_ref(); + let mut excerpts = buffer + .excerpts + .iter() + .map(|excerpt| ExcerptOutlines { + excerpt, + ranked_range: 0..0, + expanded: is_singleton + || query.is_some() + || !self + .collapsed_entries + .contains(&CollapsedEntry::Excerpt(excerpt.clone())), + visible: Vec::new(), + children_to_cache: Vec::new(), + }) + .collect::>(); + let expanded_excerpts = excerpts.iter().filter(|excerpt| excerpt.expanded).count(); + if expanded_excerpts > 0 { + let children = self.outline_children_cache.entry(buffer_id).or_default(); + let outlines = buffer.iter_outlines().collect::>(); + if let Some(snapshot) = snapshot + && expanded_excerpts > 1 + && !outlines.is_empty() + { + let mut coordinates = Vec::new(); + for outline in &outlines { + coordinates.extend([outline.range.start, outline.range.end]); + } + for excerpt in excerpts.iter().filter(|excerpt| excerpt.expanded) { + coordinates + .extend([excerpt.excerpt.context.start, excerpt.excerpt.context.end]); + } + coordinates.sort_unstable_by(|left, right| left.cmp(right, snapshot)); + coordinates.dedup_by(|left, right| left.cmp(right, snapshot).is_eq()); + let rank = |anchor: Anchor| { + coordinates + .partition_point(|coordinate| coordinate.cmp(&anchor, snapshot).is_lt()) + }; + let outlines = outlines + .iter() + .map(|outline| IndexedOutline { + outline, + ranked_range: rank(outline.range.start)..rank(outline.range.end), + collapsed: self + .collapsed_entries + .contains(&CollapsedEntry::Outline(outline.range.clone())), + }) + .collect::>(); + for excerpt in excerpts.iter_mut().filter(|excerpt| excerpt.expanded) { + excerpt.ranked_range = + rank(excerpt.excerpt.context.start)..rank(excerpt.excerpt.context.end); + } + BufferOutlines::assign_outline_cache_owners(&outlines, &mut excerpts); + if BufferOutlines::outlines_are_well_formed(&outlines) { + BufferOutlines::collect_nested_outlines(&outlines, &mut excerpts, children); + } else { + BufferOutlines::collect_overlapping_outlines( + &outlines, + &mut excerpts, + children, + ); + } + } else { + for excerpt in excerpts.iter_mut().filter(|excerpt| excerpt.expanded) { + let mut matches = outlines + .iter() + .copied() + .filter(|outline| { + snapshot.is_none_or(|snapshot| { + outline + .range + .start + .cmp(&excerpt.excerpt.context.end, snapshot) + .is_le() + && outline + .range + .end + .cmp(&excerpt.excerpt.context.start, snapshot) + .is_ge() + }) + }) + .peekable(); + let mut collapsed_ancestor: Option<&Outline> = None; + while let Some(outline) = matches.next() { + children.insert( + (outline.range.clone(), outline.depth), + matches + .peek() + .is_some_and(|next| next.depth > outline.depth), + ); + if let Some(ancestor) = collapsed_ancestor + && let Some(snapshot) = snapshot + && outline.depth > ancestor.depth + && outline + .range + .start + .cmp(&ancestor.range.start, snapshot) + .is_ge() + && outline + .range + .start + .cmp(&ancestor.range.end, snapshot) + .is_lt() + { + continue; + } + excerpt.visible.push(outline); + collapsed_ancestor = self + .collapsed_entries + .contains(&CollapsedEntry::Outline(outline.range.clone())) + .then_some(outline); + } + } + } + } + for excerpt in excerpts { let excerpt_depth = parent_depth + 1; self.push_entry( state, track_matches, - PanelEntry::Outline(OutlineEntry::Excerpt(excerpt.clone())), + PanelEntry::Outline(OutlineEntry::Excerpt(excerpt.excerpt.clone())), excerpt_depth, cx, ); - - let mut outline_base_depth = excerpt_depth + 1; - if is_singleton { - outline_base_depth = 0; + let outline_base_depth = if is_singleton { state.clear(); - } else if query.is_none() - && self - .collapsed_entries - .contains(&CollapsedEntry::Excerpt(excerpt.clone())) - { - continue; - } - - let mut last_depth_at_level: Vec>> = vec![None; 10]; - - let all_outlines = buffer - .iter_outlines() - .filter(|outline| match &buffer_snapshot { - Some(buffer_snapshot) => { - outline - .range - .start - .cmp(&excerpt.context.end, buffer_snapshot) - .is_le() - && outline - .range - .end - .cmp(&excerpt.context.start, buffer_snapshot) - .is_ge() - } - None => true, - }) - .collect::>(); - - let mut outline_has_children = HashMap::default(); - let mut visible_outlines = Vec::new(); - let mut collapsed_state: Option<(usize, Range)> = None; - - for (i, &outline) in all_outlines.iter().enumerate() { - let has_children = all_outlines - .get(i + 1) - .map(|next| next.depth > outline.depth) - .unwrap_or(false); - - outline_has_children.insert((outline.range.clone(), outline.depth), has_children); - - let mut should_include = true; - - if let Some((collapsed_depth, collapsed_range)) = &collapsed_state { - if outline.depth <= *collapsed_depth { - collapsed_state = None; - } else if let Some(buffer_snapshot) = buffer_snapshot.as_ref() { - let outline_start = outline.range.start; - if outline_start - .cmp(&collapsed_range.start, buffer_snapshot) - .is_ge() - && outline_start - .cmp(&collapsed_range.end, buffer_snapshot) - .is_lt() - { - should_include = false; // Skip - inside collapsed range - } else { - collapsed_state = None; - } - } - } - - // Check if this outline itself is collapsed - if should_include - && self - .collapsed_entries - .contains(&CollapsedEntry::Outline(outline.range.clone())) - { - collapsed_state = Some((outline.depth, outline.range.clone())); - } - - if should_include { - visible_outlines.push(outline); - } - } - - self.outline_children_cache - .entry(buffer_id) - .or_default() - .extend(outline_has_children); - - for outline in visible_outlines { - let outline_entry = outline.clone(); - - if outline.depth < last_depth_at_level.len() { - last_depth_at_level[outline.depth] = Some(outline.range.clone()); - // Clear deeper levels when we go back to a shallower depth - for d in (outline.depth + 1)..last_depth_at_level.len() { - last_depth_at_level[d] = None; - } - } - + 0 + } else { + excerpt_depth + 1 + }; + for outline in excerpt.visible { self.push_entry( state, track_matches, - PanelEntry::Outline(OutlineEntry::Outline(outline_entry)), + PanelEntry::Outline(OutlineEntry::Outline(outline.clone())), outline_base_depth + outline.depth, cx, ); @@ -4613,7 +4950,7 @@ impl OutlinePanel { return; }; - let excerpt_ranges = excerpts + let mut excerpt_ranges = excerpts .iter() .filter_map(|excerpt| { let start = search @@ -4626,11 +4963,25 @@ impl OutlinePanel { }) .collect::>(); + let snapshot = &search.multi_buffer_snapshot; + excerpt_ranges.sort_unstable_by(|left, right| left.start.cmp(&right.start, snapshot)); + let mut maximum_end = None; + for range in &mut excerpt_ranges { + if let Some(end) = maximum_end + && range.end.cmp(&end, snapshot).is_lt() + { + range.end = end; + } + maximum_end = Some(range.end); + } let depth = if is_singleton { 0 } else { parent_depth + 1 }; for (match_range, search_data) in buffer_matches.iter().filter(|(match_range, _)| { - excerpt_ranges.iter().any(|excerpt_range| { - excerpt_range.overlaps(match_range, &search.multi_buffer_snapshot) - }) + let index = excerpt_ranges + .partition_point(|excerpt| excerpt.start.cmp(&match_range.end, snapshot).is_le()); + index + .checked_sub(1) + .and_then(|index| excerpt_ranges.get(index)) + .is_some_and(|excerpt| excerpt.end.cmp(&match_range.start, snapshot).is_ge()) }) { self.push_entry( state, @@ -4820,9 +5171,12 @@ impl OutlinePanel { } else { let list_contents = { let items_len = self.cached_entries.len(); - let multi_buffer_snapshot = self - .active_editor() - .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx)); + let multi_buffer_snapshot = match self.mode { + ItemsDisplayMode::Search(_) => self + .active_editor() + .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx)), + ItemsDisplayMode::Outline => None, + }; uniform_list( "entries", items_len, @@ -5048,35 +5402,19 @@ impl OutlinePanel { fn buffers_inside_directory( &self, - dir_worktree: WorktreeId, - dir_entry: &GitEntry, + worktree_id: WorktreeId, + directory: &FsEntryPath, ) -> HashSet { - if !dir_entry.is_dir() { - debug_panic!("buffers_inside_directory called on a non-directory entry {dir_entry:?}"); - return HashSet::default(); - } - self.fs_entries .iter() - .skip_while(|fs_entry| match fs_entry { - FsEntry::Directory(directory) => { - directory.worktree_id != dir_worktree || &directory.entry != dir_entry + .filter_map(|entry| match entry { + FsEntry::File(file) + if file.worktree_id == worktree_id + && file.entry.path != directory.path + && file.entry.path.starts_with(&directory.path) => + { + Some(file.buffer_id) } - _ => true, - }) - .skip(1) - .take_while(|fs_entry| match fs_entry { - FsEntry::ExternalFile(..) => false, - FsEntry::Directory(directory) => { - directory.worktree_id == dir_worktree - && directory.entry.path.starts_with(&dir_entry.path) - } - FsEntry::File(file) => { - file.worktree_id == dir_worktree && file.entry.path.starts_with(&dir_entry.path) - } - }) - .filter_map(|fs_entry| match fs_entry { - FsEntry::File(file) => Some(file.buffer_id), _ => None, }) .collect() @@ -5094,27 +5432,6 @@ fn workspace_active_editor( Some((active_item, active_editor)) } -fn back_to_common_visited_parent( - visited_dirs: &mut Vec<(ProjectEntryId, Arc)>, - worktree_id: &WorktreeId, - new_entry: &Entry, -) -> Option<(WorktreeId, ProjectEntryId)> { - while let Some((visited_dir_id, visited_path)) = visited_dirs.last() { - match new_entry.path.parent() { - Some(parent_path) => { - if parent_path == visited_path.as_ref() { - return Some((*worktree_id, *visited_dir_id)); - } - } - None => { - break; - } - } - visited_dirs.pop(); - } - None -} - fn file_name(path: &Path) -> String { let mut current_path = path; loop { @@ -5191,6 +5508,8 @@ impl Panel for OutlinePanel { let old_active = outline_panel.active; outline_panel.active = active; if old_active != active { + outline_panel.lsp_outline_refresh_task = Task::ready(()); + outline_panel.outline_fetch_tasks.clear(); if active && let Some((active_item, active_editor)) = outline_panel.workspace.upgrade().and_then(|workspace| { @@ -5380,12 +5699,12 @@ fn subscribe_for_editor_events( match e { EditorEvent::SelectionsChanged { local: true } => { outline_panel.reveal_entry_for_selection(editor.clone(), window, cx); - cx.notify(); } EditorEvent::BuffersRemoved { removed_buffer_ids } => { - outline_panel - .buffers - .retain(|buffer_id, _| !removed_buffer_ids.contains(buffer_id)); + for buffer_id in removed_buffer_ids { + outline_panel.buffers.remove(buffer_id); + outline_panel.outline_fetch_tasks.remove(buffer_id); + } outline_panel.update_fs_entries(editor.clone(), debounce, window, cx); } EditorEvent::BufferRangesUpdated { buffer, .. } => { @@ -5397,10 +5716,7 @@ fn subscribe_for_editor_events( } EditorEvent::BuffersEdited { buffer_ids } => { outline_panel.invalidate_outlines(buffer_ids); - let update_cached_items = outline_panel.update_non_fs_items(window, cx); - if update_cached_items { - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); - } + outline_panel.update_contents(debounce, window, cx); } EditorEvent::BufferFoldToggled { ids, .. } => { outline_panel.invalidate_outlines(ids); @@ -5459,26 +5775,42 @@ fn subscribe_for_editor_events( outline_panel.update_fs_entries(editor.clone(), debounce, window, cx); } EditorEvent::Reparsed(buffer_id) => { - if let Some(buffer) = outline_panel.buffers.get_mut(buffer_id) { - buffer.invalidate_outlines(); - } - let update_cached_items = outline_panel.update_non_fs_items(window, cx); - if update_cached_items { - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); - } + outline_panel.invalidate_outlines(&[*buffer_id]); + outline_panel.update_contents(debounce, window, cx); } EditorEvent::OutlineSymbolsChanged => { - for buffer in outline_panel.buffers.values_mut() { - buffer.invalidate_outlines(); - } - if matches!( - outline_panel.selected_entry(), - Some(PanelEntry::Outline(..)), - ) { - outline_panel.selected_entry.invalidate(); - } - if outline_panel.update_non_fs_items(window, cx) { - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + if outline_panel.lsp_outline_refresh_task.is_ready() { + outline_panel.lsp_outline_refresh_task = + cx.spawn_in(window, async move |outline_panel, cx| { + cx.background_executor().timer(UPDATE_DEBOUNCE).await; + outline_panel + .update_in(cx, |outline_panel, window, cx| { + let Some(editor) = outline_panel.active_editor() else { + return; + }; + let editor_buffer = editor.read(cx).buffer().read(cx); + let mut invalidated = false; + for (buffer_id, outlines) in &mut outline_panel.buffers { + if editor_buffer.buffer(*buffer_id).is_some_and( + |buffer| { + LanguageSettings::for_buffer( + buffer.read(cx), + cx, + ) + .document_symbols + .lsp_enabled() + }, + ) { + outlines.invalidate_outlines(); + invalidated = true; + } + } + if invalidated { + outline_panel.fetch_outdated_outlines(None, window, cx); + } + }) + .ok(); + }); } } EditorEvent::TitleChanged => { @@ -5557,8 +5889,10 @@ impl GenerationState { #[cfg(test)] mod tests { + use buffer_diff::BufferDiff; use db::indoc; - use futures::stream::StreamExt as _; + use editor::{HiddenUnstagedDiffHunkRenderer, PathKey}; + use futures::{FutureExt as _, StreamExt as _, future::poll_fn, task::Poll}; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle}; use language::{self, FakeLspAdapter, markdown_lang, rust_lang}; use pretty_assertions::assert_eq; @@ -5568,13 +5902,97 @@ mod tests { project_search::{self, perform_project_search}, }; use serde_json::json; - use util::path; + use util::{path, rel_path::rel_path}; use workspace::{MultiWorkspace, OpenOptions, OpenVisible, ToolbarItemView}; use super::*; const SELECTED_MARKER: &str = " <==== selected"; + #[gpui::test] + async fn test_outline_fetches_survive_unrelated_updates_but_not_deactivation( + cx: &mut TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = outline_panel(&workspace, cx); + let [requested_id, retained_id, unknown_id] = + [1, 2, 3].map(|id| BufferId::new(id).expect("nonzero buffer ID")); + let (requested_sender, requested_receiver) = futures::channel::oneshot::channel::<()>(); + let (retained_sender, retained_receiver) = futures::channel::oneshot::channel::<()>(); + let (refresh_sender, refresh_receiver) = futures::channel::oneshot::channel::<()>(); + panel.update(cx, |panel, cx| { + panel.lsp_outline_refresh_task = cx.background_spawn(async move { + refresh_receiver + .await + .expect("LSP refresh should be canceled before completion"); + }); + for buffer_id in [requested_id, retained_id] { + panel.buffers.insert( + buffer_id, + BufferOutlines { + excerpts: Vec::new(), + outlines: OutlineState::Outlines(Vec::new()), + }, + ); + } + for (buffer_id, receiver) in [ + (requested_id, requested_receiver), + (retained_id, retained_receiver), + ] { + panel.outline_fetch_tasks.insert( + buffer_id, + cx.background_spawn(async move { + receiver + .await + .expect("outline fetch should be canceled before completion"); + }), + ); + } + panel.invalidate_outlines(&[]); + panel.invalidate_outlines(&[unknown_id]); + assert_eq!(panel.outline_fetch_tasks.len(), 2); + panel.invalidate_outlines(&[requested_id, requested_id, unknown_id]); + assert_eq!( + panel + .outline_fetch_tasks + .keys() + .copied() + .collect::>(), + vec![retained_id] + ); + assert!(matches!( + panel.buffers[&requested_id].outlines, + OutlineState::Invalidated(_) + )); + assert!(matches!( + panel.buffers[&retained_id].outlines, + OutlineState::Outlines(_) + )); + }); + cx.run_until_parked(); + assert!(requested_sender.is_canceled()); + assert!(!retained_sender.is_canceled()); + assert!(!refresh_sender.is_canceled()); + + panel.update_in(cx, |panel, window, cx| { + panel.active = true; + panel.pinned = true; + panel.set_active(false, window, cx); + }); + cx.run_until_parked(); + assert!(retained_sender.is_canceled()); + assert!(refresh_sender.is_canceled()); + panel.read_with(cx, |panel, _| { + assert!(!panel.active); + assert!(panel.outline_fetch_tasks.is_empty()); + assert_eq!(panel.buffers.len(), 2); + }); + } + #[gpui::test(iterations = 10)] async fn test_project_search_results_toggling(cx: &mut TestAppContext) { init_test(cx); @@ -6321,6 +6739,2279 @@ two/ <==== selected }); } + #[gpui::test] + async fn test_file_order_matches_diff_multibuffer(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "crates": { "ide": { + "asdsads": "new file", + "src": { + "interpret.rs": "fn interpret() {}", + "join_lines.rs": "fn join_lines() {}" + } + } } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let added = open_buffer(&project, path!("/root/crates/ide/asdsads"), cx).await; + let deleted = open_buffer(&project, path!("/root/crates/ide/src/join_lines.rs"), cx).await; + let modified = open_buffer(&project, path!("/root/crates/ide/src/interpret.rs"), cx).await; + fs.remove_file( + Path::new(path!("/root/crates/ide/src/join_lines.rs")), + project::RemoveOptions::default(), + ) + .await + .expect("file deletion should succeed"); + deleted.update(cx, |buffer, cx| { + buffer.edit( + [(language::Point::default()..buffer.max_point(), "")], + None, + cx, + ); + }); + let editor = add_diff_editor( + &workspace, + &project, + &[ + (&modified, "old interpret"), + (&deleted, "fn join_lines() {}"), + (&added, ""), + ], + cx, + ); + set_buffer_order(&editor, &[&modified, &deleted, &added], cx); + workspace.update_in(cx, |workspace, window, cx| { + workspace + .focus_panel::(window, cx) + .expect("outline panel should be installed"); + }); + select_file( + &outline_panel, + modified.read_with(cx, |buffer, _| buffer.remote_id()), + cx, + ); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + crates/ide/ + src/ + interpret.rs <==== selected + ~~join_lines.rs~~ + asdsads" + ), + cx, + ); + set_buffer_order(&editor, &[&added, &deleted, &modified], cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + crates/ide/ + asdsads + src/ + ~~join_lines.rs~~ + interpret.rs <==== selected" + ), + cx, + ); + } + + #[gpui::test] + async fn test_file_order_preserves_interleaved_directory_sections(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/one"), + json!({ "é": { "子": { "first.txt": "first", "second.txt": "second" } } }), + ) + .await; + fs.insert_tree(path!("/two"), json!({ "other.txt": "other" })) + .await; + let project = Project::test( + fs.clone(), + [Path::new(path!("/one")), Path::new(path!("/two"))], + cx, + ) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let first = open_buffer(&project, path!("/one/é/子/first.txt"), cx).await; + let second = open_buffer(&project, path!("/one/é/子/second.txt"), cx).await; + let other = open_buffer(&project, path!("/two/other.txt"), cx).await; + let untitled = project.update(cx, |project, cx| { + project.create_local_buffer("untitled", None, true, cx) + }); + fs.remove_dir( + Path::new(path!("/one/é")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .expect("ancestor deletion should succeed"); + fs.insert_file(path!("/one/é"), b"replacement".to_vec()) + .await; + let replacement = open_buffer(&project, path!("/one/é"), cx).await; + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[ + (&first, Vec::new()), + (&second, Vec::new()), + (&other, Vec::new()), + (&untitled, Vec::new()), + (&replacement, Vec::new()), + ], + cx, + ); + set_buffer_order( + &editor, + &[&second, &other, &untitled, &first, &replacement], + cx, + ); + select_file( + &outline_panel, + first.read_with(cx, |buffer, _| buffer.remote_id()), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_parent(&SelectParent, window, cx); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/子/ + ~~second.txt~~ + two/ + other.txt + external: untitled + one/ + é/子/ <==== selected + ~~first.txt~~ + é" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/子/ + two/ + other.txt + external: untitled + one/ + é/子/ <==== selected + é" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/子/ + ~~second.txt~~ + two/ + other.txt + external: untitled + one/ + é/子/ <==== selected + ~~first.txt~~ + é" + ), + cx, + ); + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + select_file( + &outline_panel, + first.read_with(cx, |buffer, _| buffer.remote_id()), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_parent(&SelectParent, window, cx); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ + 子/ + ~~second.txt~~ + two/ + other.txt + external: untitled + one/ + é/ + 子/ <==== selected + ~~first.txt~~ + é" + ), + cx, + ); + } + + #[gpui::test] + async fn test_directory_selection_survives_buffer_reordering(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ "dir": { "sub": { "a.txt": "a", "b.txt": "b" } }, "z.txt": "z" }), + ) + .await; + let project = Project::test(fs, [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer_a = open_buffer(&project, path!("/root/dir/sub/a.txt"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/dir/sub/b.txt"), cx).await; + let buffer_z = open_buffer(&project, path!("/root/z.txt"), cx).await; + let worktree_id = buffer_a.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[ + (&buffer_a, Vec::new()), + (&buffer_b, Vec::new()), + (&buffer_z, Vec::new()), + ], + cx, + ); + set_buffer_order(&editor, &[&buffer_b, &buffer_a, &buffer_z], cx); + workspace.update_in(cx, |workspace, window, cx| { + workspace + .focus_panel::(window, cx) + .expect("outline panel should be installed"); + }); + select_directory(&outline_panel, worktree_id, rel_path("dir/sub"), cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/sub/ <==== selected + b.txt + a.txt + z.txt" + ), + cx, + ); + set_buffer_order(&editor, &[&buffer_a, &buffer_b, &buffer_z], cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/sub/ <==== selected + a.txt + b.txt + z.txt" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_next(&SelectNext, window, cx); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/sub/ + a.txt <==== selected + b.txt + z.txt" + ), + cx, + ); + select_directory(&outline_panel, worktree_id, rel_path("dir/sub"), cx); + rename_buffer(&project, &buffer_a, rel_path("a.txt"), cx).await; + set_buffer_order(&editor, &[&buffer_a, &buffer_b, &buffer_z], cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a.txt + dir/sub/ <==== selected + b.txt + z.txt" + ), + cx, + ); + rename_buffer(&project, &buffer_a, rel_path("dir/sub/a.txt"), cx).await; + set_buffer_order(&editor, &[&buffer_a, &buffer_b, &buffer_z], cx); + let buffer_a_id = buffer_a.read_with(cx, |buffer, _| buffer.remote_id()); + editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |multibuffer, cx| { + multibuffer.remove_excerpts_for_buffer(buffer_a_id, cx); + }); + }); + set_buffer_order(&editor, &[&buffer_b, &buffer_z], cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/sub/ <==== selected + b.txt + z.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_selected_file_survives_rename_and_deletion(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/root"), json!({ "a.txt": "a", "z.txt": "z" })) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer_a = open_buffer(&project, path!("/root/a.txt"), cx).await; + let buffer_z = open_buffer(&project, path!("/root/z.txt"), cx).await; + let buffer_id = buffer_a.read_with(cx, |buffer, _| buffer.remote_id()); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_z, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + workspace.update_in(cx, |workspace, window, cx| { + workspace + .focus_panel::(window, cx) + .expect("outline panel should be installed"); + }); + select_file(&outline_panel, buffer_id, cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a.txt <==== selected + z.txt" + ), + cx, + ); + + fs.rename( + Path::new(path!("/root/a.txt")), + Path::new(path!("/root/b.txt")), + project::RenameOptions::default(), + ) + .await + .expect("file rename should succeed"); + flush_outline_tasks(cx); + buffer_a.read_with(cx, |buffer, _| { + assert_eq!( + File::from_dyn(buffer.file()) + .expect("buffer should retain its file") + .path + .as_ref(), + rel_path("b.txt"), + ); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + b.txt <==== selected + z.txt" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + assert!(panel.focus_handle.is_focused(window)); + match &panel.selected_entry { + SelectedEntry::Valid(PanelEntry::Fs(FsEntry::File(file)), _) => { + assert_eq!( + (file.buffer_id, file.entry.path.as_ref()), + (buffer_id, rel_path("b.txt")) + ); + } + selected => { + panic!("expected the renamed file to remain selected, got {selected:?}") + } + } + panel.select_next(&SelectNext, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + b.txt + z.txt <==== selected" + ), + cx, + ); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_previous(&SelectPrevious, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + b.txt <==== selected + z.txt" + ), + cx, + ); + fs.remove_file( + Path::new(path!("/root/b.txt")), + project::RemoveOptions::default(), + ) + .await + .expect("renamed file deletion should succeed"); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + ~~b.txt~~ <==== selected + z.txt" + ), + cx, + ); + outline_panel.read_with(cx, |panel, _| match &panel.selected_entry { + SelectedEntry::Valid(PanelEntry::Fs(FsEntry::File(file)), _) => { + assert_eq!( + (file.buffer_id, file.entry.path.as_ref()), + (buffer_id, rel_path("b.txt")) + ); + assert!(file.is_deleted); + } + selected => panic!("expected the deleted file to remain selected, got {selected:?}"), + }); + } + + #[gpui::test] + async fn test_directory_state_prunes_paths_after_repeated_renames(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ "d0": { "file.txt": "retained buffer" }, "z.txt": "z" }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer = open_buffer(&project, path!("/root/d0/file.txt"), cx).await; + let buffer_z = open_buffer(&project, path!("/root/z.txt"), cx).await; + let (buffer_id, worktree_id) = buffer.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should have a file"); + (buffer.remote_id(), file.worktree.read(cx).id()) + }); + let buffer_z_id = buffer_z.read_with(cx, |buffer, _| buffer.remote_id()); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer, Vec::new()), (&buffer_z, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + select_file(&outline_panel, buffer_z_id, cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + d0/ + file.txt + z.txt <==== selected" + ), + cx, + ); + assert_directory_paths(&outline_panel, worktree_id, &["", "d0"], &[], cx); + + for (previous, next) in [("d0", "d1"), ("d1", "d2"), ("d2", "d3"), ("d3", "d4")] { + select_directory(&outline_panel, worktree_id, rel_path(previous), cx); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + select_file(&outline_panel, buffer_z_id, cx); + assert_tree( + &outline_panel, + &project, + &format!( + indoc!( + " + root/ + {previous}/ + z.txt <==== selected" + ), + previous = previous, + ), + cx, + ); + assert_directory_paths( + &outline_panel, + worktree_id, + &["", previous], + &[previous], + cx, + ); + + let next_directory = Path::new(path!("/root")).join(next); + fs.create_dir(&next_directory) + .await + .expect("destination directory should be created"); + let entry_id = buffer.read_with(cx, |buffer, _| { + File::from_dyn(buffer.file()) + .and_then(|file| file.project_entry_id()) + .expect("file should have a worktree entry") + }); + project + .update(cx, |project, cx| { + project.rename_entry( + entry_id, + ProjectPath { + worktree_id, + path: Arc::from(rel_path(&format!("{next}/file.txt"))), + }, + cx, + ) + }) + .await + .expect("file rename should succeed"); + cx.run_until_parked(); + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + flush_outline_tasks(cx); + buffer.read_with(cx, |buffer, _| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert_eq!(buffer.remote_id(), buffer_id); + assert_eq!(file.path.as_ref(), rel_path(&format!("{next}/file.txt"))); + }); + assert_tree( + &outline_panel, + &project, + &format!( + indoc!( + " + root/ + {next}/ + file.txt + z.txt <==== selected" + ), + next = next, + ), + cx, + ); + assert_directory_paths(&outline_panel, worktree_id, &["", next], &[], cx); + } + } + + #[gpui::test] + async fn test_ignored_directory_expansion_does_not_scan_worktree(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".gitignore": "ignored\n", + "ignored": { + "a": { "deep": { "open.txt": "first\n" } }, + "b": { "open.txt": "second\n" }, + "c": { "unopened.txt": "unopened\n" } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer_a = open_buffer(&project, path!("/root/ignored/a/deep/open.txt"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/ignored/b/open.txt"), cx).await; + let worktree = buffer_a.read_with(cx, |buffer, _| { + File::from_dyn(buffer.file()).unwrap().worktree.clone() + }); + let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); + let buffer_a_id = buffer_a.read_with(cx, |buffer, _| buffer.remote_id()); + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_b, Vec::new())], + cx, + ); + wait_for_outline_tasks(&outline_panel, cx).await; + let scan_id = worktree.read_with(cx, |worktree, _| worktree.scan_id()); + let read_dir_calls = fs.read_dir_call_count(); + let metadata_calls = fs.metadata_call_count(); + + for auto_fold_dirs in [false, true] { + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(auto_fold_dirs); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + let expanded_tree = if auto_fold_dirs { + indoc!( + " + root/ + ignored/ + a/deep/ <==== selected + open.txt + b/ + open.txt" + ) + } else { + indoc!( + " + root/ + ignored/ + a/ + deep/ <==== selected + open.txt + b/ + open.txt" + ) + }; + select_directory(&outline_panel, worktree_id, rel_path("ignored/a/deep"), cx); + for keyboard in [true, false] { + outline_panel.update_in(cx, |panel, window, cx| { + if keyboard { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + } else { + let entry = panel.selected_entry().unwrap().clone(); + panel.toggle_expanded(&entry, window, cx); + } + }); + wait_for_outline_tasks(&outline_panel, cx).await; + assert!( + editor.read_with(cx, |editor, cx| editor.is_buffer_folded(buffer_a_id, cx)) + ); + outline_panel.update_in(cx, |panel, window, cx| { + if keyboard { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + } else { + let entry = panel.selected_entry().unwrap().clone(); + panel.toggle_expanded(&entry, window, cx); + } + }); + wait_for_outline_tasks(&outline_panel, cx).await; + assert!( + !editor.read_with(cx, |editor, cx| editor.is_buffer_folded(buffer_a_id, cx)) + ); + assert_tree(&outline_panel, &project, expanded_tree, cx); + assert_eq!( + worktree.read_with(cx, |worktree, _| worktree.scan_id()), + scan_id + ); + assert_eq!(fs.read_dir_call_count(), read_dir_calls); + assert_eq!(fs.metadata_call_count(), metadata_calls); + } + + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + select_in_buffer(&editor, buffer_a_id, cx); + wait_for_outline_tasks(&outline_panel, cx).await; + let revealed_tree = if auto_fold_dirs { + indoc!( + " + root/ + ignored/ + a/deep/ + open.txt <==== selected + b/ + open.txt" + ) + } else { + indoc!( + " + root/ + ignored/ + a/ + deep/ + open.txt <==== selected + b/ + open.txt" + ) + }; + assert_tree(&outline_panel, &project, revealed_tree, cx); + assert_eq!( + worktree.read_with(cx, |worktree, _| worktree.scan_id()), + scan_id + ); + assert_eq!(fs.read_dir_call_count(), read_dir_calls); + assert_eq!(fs.metadata_call_count(), metadata_calls); + worktree.read_with(cx, |worktree, _| { + assert_eq!( + worktree + .entry_for_path(rel_path("ignored/c")) + .map(|entry| entry.kind), + Some(worktree::EntryKind::UnloadedDir), + ); + assert_eq!( + worktree.entry_for_path(rel_path("ignored/c/unopened.txt")), + None + ); + }); + outline_panel.read_with(cx, |panel, _| { + assert_eq!( + panel + .fs_entries + .iter() + .map(|entry| match entry { + FsEntry::Directory(directory) => + (directory.entry.path.as_ref(), directory.entry.is_ignored), + FsEntry::File(file) => + (file.entry.path.as_ref(), file.entry.is_ignored), + FsEntry::ExternalFile(_) => + panic!("indexed ignored files should not be external"), + }) + .collect::>(), + vec![ + (rel_path(""), false), + (rel_path("ignored"), true), + (rel_path("ignored/a"), true), + (rel_path("ignored/a/deep"), true), + (rel_path("ignored/a/deep/open.txt"), true), + (rel_path("ignored/b"), true), + (rel_path("ignored/b/open.txt"), true), + ], + ); + }); + } + } + + #[gpui::test] + async fn test_selected_folded_directory_survives_git_metadata_changes(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".git": {}, + "a": { "b": { "file.txt": "modified\n" } }, + "z.txt": "unchanged\n" + }), + ) + .await; + let base_contents = [ + ("a/b/file.txt", "base\n".to_string()), + ("z.txt", "unchanged\n".to_string()), + ]; + let dot_git = Path::new(path!("/root/.git")); + fs.set_head_for_repo(dot_git, &base_contents, "deadbeef"); + fs.set_index_for_repo(dot_git, &base_contents); + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer = open_buffer(&project, path!("/root/a/b/file.txt"), cx).await; + let buffer_z = open_buffer(&project, path!("/root/z.txt"), cx).await; + let worktree_id = buffer.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer, Vec::new()), (&buffer_z, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + select_directory(&outline_panel, worktree_id, rel_path("a/b"), cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/ <==== selected + file.txt + z.txt" + ), + cx, + ); + let metadata_before = outline_panel.read_with(cx, |panel, _| { + let Some(PanelEntry::FoldedDirs(group)) = panel.selected_entry() else { + panic!("folded directory should be selected"); + }; + assert_eq!( + group + .entries + .iter() + .map(|entry| ( + entry.path.as_ref(), + entry.git_summary.worktree.modified, + entry.is_ignored + )) + .collect::>(), + vec![(rel_path("a"), 1, false), (rel_path("a/b"), 1, false)], + ); + group + .entries + .iter() + .map(|entry| entry.git_summary) + .collect::>() + }); + + fs.remove_dir( + Path::new(path!("/root/a")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .expect("ancestor deletion should succeed"); + cx.run_until_parked(); + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + flush_outline_tasks(cx); + buffer.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert_eq!(file.disk_state, DiskState::Deleted); + assert_eq!( + file.worktree + .read(cx) + .entry_for_path(rel_path("a")) + .map(|entry| entry.kind), + None + ); + }); + let metadata_after = outline_panel.read_with(cx, |panel, _| { + let group = panel + .cached_entries + .iter() + .find_map(|cached| match &cached.entry { + PanelEntry::FoldedDirs(group) + if group.worktree_id == worktree_id + && group + .entries + .last() + .is_some_and(|entry| entry.path.as_ref() == rel_path("a/b")) => + { + Some(group) + } + _ => None, + }) + .expect("folded directory should retain its row"); + assert_eq!( + group + .entries + .iter() + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![rel_path("a"), rel_path("a/b")], + ); + group + .entries + .iter() + .map(|entry| entry.git_summary) + .collect::>() + }); + assert_ne!(metadata_before, metadata_after); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/ <==== selected + ~~file.txt~~ + z.txt" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_next(&SelectNext, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/ + ~~file.txt~~ <==== selected + z.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_deleted_file_nests_under_existing_parent(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "dir": { + "a.txt": "hello world", + "b.txt": "hello there", + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_a = open_buffer(&project, path!("/root/dir/a.txt"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/dir/b.txt"), cx).await; + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_b, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + a.txt + b.txt" + ), + cx, + ); + + fs.remove_file(Path::new(path!("/root/dir/a.txt")), Default::default()) + .await + .unwrap(); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.txt~~ + b.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_directory_collapse_survives_ancestor_changes(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "keep.txt": "hello there", + "dir": { "sub": { "a.txt": "hello world" } } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + + let buffer_keep = open_buffer(&project, path!("/root/keep.txt"), cx).await; + let buffer_a = open_buffer(&project, path!("/root/dir/sub/a.txt"), cx).await; + let worktree_id = buffer_a.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_keep, Vec::new()), (&buffer_a, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + sub/ + a.txt + keep.txt" + ), + cx, + ); + + fs.remove_file( + Path::new(path!("/root/dir/sub/a.txt")), + project::RemoveOptions::default(), + ) + .await + .expect("file deletion should succeed"); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + sub/ + ~~a.txt~~ + keep.txt" + ), + cx, + ); + + select_directory(&outline_panel, worktree_id, rel_path("dir"), cx); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + keep.txt" + ), + cx, + ); + + fs.remove_dir( + Path::new(path!("/root/dir")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .expect("ancestor deletion should succeed"); + flush_outline_tasks(cx); + buffer_a.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert_eq!(file.disk_state, DiskState::Deleted); + assert_eq!( + file.worktree + .read(cx) + .entry_for_path(rel_path("dir")) + .map(|entry| entry.kind), + None, + ); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + keep.txt" + ), + cx, + ); + + fs.create_dir(Path::new(path!("/root/dir/sub"))) + .await + .expect("ancestor recreation should succeed"); + flush_outline_tasks(cx); + buffer_a.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert_eq!( + file.worktree + .read(cx) + .entry_for_path(rel_path("dir/sub")) + .map(|entry| entry.is_dir()), + Some(true), + ); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + keep.txt" + ), + cx, + ); + + fs.insert_file(path!("/root/dir/sub/a.txt"), b"hello world".to_vec()) + .await; + flush_outline_tasks(cx); + buffer_a.read_with(cx, |buffer, _| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert!(matches!(file.disk_state, DiskState::Present { .. })); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + keep.txt" + ), + cx, + ); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + sub/ + a.txt + keep.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_only_deleted_files_in_multiple_worktrees(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "one": { "é": { "子": { "a.txt": "one" } } }, + "two": { "é": { "子": { "a.txt": "two" } } } + }), + ) + .await; + let project = Project::test( + fs.clone(), + [Path::new(path!("/root/one")), Path::new(path!("/root/two"))], + cx, + ) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + + let buffer_one = open_buffer(&project, path!("/root/one/é/子/a.txt"), cx).await; + let buffer_two = open_buffer(&project, path!("/root/two/é/子/a.txt"), cx).await; + let worktree_one = buffer_one.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_two, Vec::new()), (&buffer_one, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ + 子/ + a.txt + two/ + é/ + 子/ + a.txt" + ), + cx, + ); + + for path in [path!("/root/one/é"), path!("/root/two/é")] { + fs.remove_dir( + Path::new(path), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .expect("directory deletion should succeed"); + } + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ + 子/ + ~~a.txt~~ + two/ + é/ + 子/ + ~~a.txt~~" + ), + cx, + ); + + select_directory(&outline_panel, worktree_one, rel_path("é"), cx); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ <==== selected + two/ + é/ + 子/ + ~~a.txt~~" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ <==== selected + 子/ + ~~a.txt~~ + two/ + é/ + 子/ + ~~a.txt~~" + ), + cx, + ); + } + + #[gpui::test] + async fn test_untitled_buffer_still_external(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/root"), json!({ "existing.txt": "hello" })) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let untitled_buffer = project.update(cx, |project, cx| { + project.create_local_buffer("untitled content", None, true, cx) + }); + add_multi_buffer_editor(&workspace, &project, &[(&untitled_buffer, Vec::new())], cx); + flush_outline_tasks(cx); + + assert_tree(&outline_panel, &project, "external: untitled", cx); + } + + #[gpui::test] + async fn test_auto_reveal_expands_ancestor_of_deleted_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "dir": { + "a.rs": indoc!( + " + pub fn foo() { + let x = 1; + } + " + ), + "b.rs": "pub fn bar() {}\n", + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_a = open_buffer(&project, path!("/root/dir/a.rs"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/dir/b.rs"), cx).await; + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_b, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + fs.remove_file( + Path::new(path!("/root/dir/a.rs")), + project::RemoveOptions::default(), + ) + .await + .unwrap(); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.rs~~ + outline: pub fn foo <==== selected + b.rs + outline: pub fn bar" + ), + cx, + ); + + let buffer_a_id = buffer_a.read_with(cx, |buffer, _cx| buffer.remote_id()); + let buffer_b_id = buffer_b.read_with(cx, |buffer, _cx| buffer.remote_id()); + select_in_buffer(&editor, buffer_b_id, cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.rs~~ + outline: pub fn foo + b.rs + outline: pub fn bar <==== selected" + ), + cx, + ); + + let worktree_id = buffer_a.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + select_directory(&outline_panel, worktree_id, rel_path("dir"), cx); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected" + ), + cx, + ); + + select_in_buffer(&editor, buffer_a_id, cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.rs~~ <==== selected + b.rs" + ), + cx, + ); + } + + #[gpui::test] + async fn test_open_deleted_file_entry_navigates_editor(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "dir": { + "a.txt": "hello world", + "b.txt": "hello there", + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_a = open_buffer(&project, path!("/root/dir/a.txt"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/dir/b.txt"), cx).await; + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_b, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + let buffer_b_id = buffer_b.read_with(cx, |buffer, _cx| buffer.remote_id()); + select_in_buffer(&editor, buffer_b_id, cx); + + fs.remove_file(Path::new(path!("/root/dir/a.txt")), Default::default()) + .await + .unwrap(); + flush_outline_tasks(cx); + + let deleted_row = outline_panel.read_with(cx, |outline_panel, _cx| { + outline_panel + .cached_entries + .iter() + .find_map(|cached_entry| match &cached_entry.entry { + PanelEntry::Fs(FsEntry::File(file)) + if file.entry.path.as_ref() == rel_path("dir/a.txt") => + { + Some(cached_entry.entry.clone()) + } + _ => None, + }) + .expect("the deleted file should have a row") + }); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.select_entry(deleted_row, true, window, cx); + }); + editor.update_in(cx, |editor, window, cx| { + assert!( + !editor.focus_handle(cx).is_focused(window), + "sanity check: selecting a row focuses the panel, not the editor" + ); + }); + + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx); + }); + cx.run_until_parked(); + + let buffer_a_id = buffer_a.read_with(cx, |buffer, _cx| buffer.remote_id()); + editor.update_in(cx, |editor, window, cx| { + assert_eq!( + editor + .scroll_manager + .shared_scroll_anchor(cx) + .scroll_anchor + .anchor + .buffer_id(), + Some(buffer_a_id), + "opening the deleted file's row should scroll the editor to its excerpt" + ); + assert!(editor.focus_handle(cx).is_focused(window)); + }); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.txt~~ <==== selected + b.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_buffer_for_nonexistent_path_not_treated_as_deleted(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/root"), json!({ "existing.txt": "hello" })) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let new_buffer = project + .update(cx, |project, cx| { + let path = project + .find_project_path(path!("/root/missing_dir/new.txt"), cx) + .expect("path inside the worktree should resolve"); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + new_buffer.read_with(cx, |buffer, _cx| { + assert_eq!( + buffer.file().map(|file| file.disk_state()), + Some(language::DiskState::New), + "sanity check: a buffer opened on a nonexistent path should be DiskState::New" + ); + }); + add_multi_buffer_editor(&workspace, &project, &[(&new_buffer, Vec::new())], cx); + flush_outline_tasks(cx); + + assert_tree(&outline_panel, &project, "external: new.txt", cx); + } + + #[gpui::test] + async fn test_deleted_file_ancestor_replaced_by_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "éx": "hello there", + "é": { + "a.rs": indoc!( + " + pub fn foo() { + let x = 1; + } + " + ) + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + + let buffer_keep = open_buffer(&project, path!("/root/éx"), cx).await; + let buffer_a = open_buffer(&project, path!("/root/é/a.rs"), cx).await; + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_keep, Vec::new()), (&buffer_a, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + fs.remove_dir( + Path::new(path!("/root/é")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .unwrap(); + fs.insert_file(path!("/root/é"), b"not a directory anymore".to_vec()) + .await; + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + é/ + ~~a.rs~~ + outline: pub fn foo <==== selected + éx" + ), + cx, + ); + + let buffer_replacement = open_buffer(&project, path!("/root/é"), cx).await; + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[ + (&buffer_keep, Vec::new()), + (&buffer_a, Vec::new()), + (&buffer_replacement, Vec::new()), + ], + cx, + ); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + é + é/ + ~~a.rs~~ + outline: pub fn foo + éx" + ), + cx, + ); + + let worktree_id = buffer_a.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + select_directory(&outline_panel, worktree_id, rel_path("é"), cx); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + é + é/ <==== selected + éx" + ), + cx, + ); + + let buffer_a_id = buffer_a.read_with(cx, |buffer, _cx| buffer.remote_id()); + select_in_buffer(&editor, buffer_a_id, cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + é + é/ + ~~a.rs~~ <==== selected + éx" + ), + cx, + ); + } + + #[gpui::test] + async fn test_folded_directories_survive_ancestor_changes(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "keep.txt": "hello there", + "a": { + "b": { + "c": { + "d": { + "gone.txt": "hello world" + } + } + } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + }); + + let buffer_keep = open_buffer(&project, path!("/root/keep.txt"), cx).await; + let buffer_gone = open_buffer(&project, path!("/root/a/b/c/d/gone.txt"), cx).await; + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_keep, Vec::new()), (&buffer_gone, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ + gone.txt + keep.txt" + ), + cx, + ); + + fs.remove_dir( + Path::new(path!("/root/a/b/c")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .unwrap(); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ + ~~gone.txt~~ + keep.txt" + ), + cx, + ); + fs.create_dir(Path::new(path!("/root/a/b/c/d"))) + .await + .expect("ancestor recreation should succeed"); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ + ~~gone.txt~~ + keep.txt" + ), + cx, + ); + + let deleted_file_row = outline_panel.read_with(cx, |outline_panel, _cx| { + outline_panel + .cached_entries + .iter() + .find_map(|cached_entry| match &cached_entry.entry { + PanelEntry::Fs(FsEntry::File(file)) + if file.entry.path.as_ref() == rel_path("a/b/c/d/gone.txt") => + { + Some(cached_entry.entry.clone()) + } + _ => None, + }) + .expect("the deleted file should have a row") + }); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.select_entry(deleted_file_row, true, window, cx); + }); + + for expected in [ + indoc!( + " + root/ + a/b/c/d/ <==== selected + ~~gone.txt~~ + keep.txt" + ), + indoc!( + " + root/ <==== selected + a/b/c/d/ + ~~gone.txt~~ + keep.txt" + ), + ] { + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.select_parent(&SelectParent, window, cx); + }); + assert_tree(&outline_panel, &project, expected, cx); + } + + let worktree_id = buffer_gone.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + select_directory(&outline_panel, worktree_id, rel_path("a/b/c/d"), cx); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ <==== selected + keep.txt" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ <==== selected + ~~gone.txt~~ + keep.txt" + ), + cx, + ); + for expected in [ + indoc!( + " + root/ + a/b/c/d/ <==== selected + keep.txt" + ), + indoc!( + " + root/ + a/b/c/d/ <==== selected + ~~gone.txt~~ + keep.txt" + ), + ] { + outline_panel.update_in(cx, |panel, window, cx| { + let entry = panel + .selected_entry() + .expect("folded directory should be selected") + .clone(); + panel.toggle_expanded(&entry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree(&outline_panel, &project, expected, cx); + } + } + + #[gpui::test] + async fn test_scan_excluded_modified_file_stays_external(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = + Some(settings::SplicingVec::from(vec![ + "**/excluded.txt".to_string(), + ])); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".git": {}, + "keep.txt": "hello there", + "excluded.txt": "new", + }), + ) + .await; + let dot_git = Path::new(path!("/root/.git")); + fs.set_head_for_repo( + dot_git, + &[ + ("keep.txt", "hello there".into()), + ("excluded.txt", "old".into()), + ], + "deadbeef", + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_keep = open_buffer(&project, path!("/root/keep.txt"), cx).await; + let buffer_excluded = open_buffer(&project, path!("/root/excluded.txt"), cx).await; + buffer_excluded.read_with(cx, |buffer, _cx| { + let file = File::from_dyn(buffer.file()).expect("excluded buffer should have a file"); + assert_eq!( + file.project_entry_id(), + None, + "sanity check: a scan-excluded file has no worktree entry" + ); + assert!( + matches!(file.disk_state, language::DiskState::Present { .. }), + "sanity check: a scan-excluded file is present on disk, got {:?}", + file.disk_state + ); + }); + + add_diff_editor( + &workspace, + &project, + &[ + (&buffer_keep, "previous contents\n"), + (&buffer_excluded, "old\n"), + ], + cx, + ); + assert_tree( + &outline_panel, + &project, + indoc!( + " + external: excluded.txt + root/ + keep.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_diff_deletions_group_under_scanned_and_excluded_parents(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = + Some(settings::SplicingVec::from(vec![ + "**/excluded".to_string(), + "**/missing".to_string(), + ])); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".git": {}, + "scanned": { "keep.txt": "still here" }, + "excluded": { "keep.txt": "still here" } + }), + ) + .await; + let dot_git = Path::new(path!("/root/.git")); + fs.set_branch_name(dot_git, Some("feature")); + fs.insert_branches(dot_git, &["main"]); + fs.set_head_for_repo( + dot_git, + &[ + ("scanned/keep.txt", "still here".to_string()), + ("excluded/keep.txt", "still here".to_string()), + ], + "deadbeef", + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + + let buffer_scanned = open_buffer(&project, path!("/root/scanned/gone.txt"), cx).await; + let buffer_excluded = open_buffer(&project, path!("/root/excluded/gone.txt"), cx).await; + let buffer_missing = open_buffer(&project, path!("/root/missing/gone.txt"), cx).await; + for buffer in [&buffer_scanned, &buffer_excluded, &buffer_missing] { + buffer.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should have a file"); + assert_eq!(file.disk_state, DiskState::New); + assert_eq!(file.project_entry_id(), None); + assert_eq!( + project + .read(cx) + .git_store() + .read(cx) + .display_status_for_buffer_id(buffer.remote_id(), cx), + None, + ); + }); + } + buffer_excluded.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should have a file"); + let worktree = file.worktree.read(cx); + assert_eq!( + worktree + .entry_for_path(rel_path("excluded")) + .map(|entry| entry.kind), + None + ); + assert_eq!( + worktree + .entry_for_path(rel_path("missing")) + .map(|entry| entry.kind), + None + ); + assert_eq!( + worktree + .entry_for_path(rel_path("scanned")) + .map(|entry| entry.is_dir()), + Some(true), + ); + }); + assert_eq!( + fs.metadata(Path::new(path!("/root/excluded"))) + .await + .expect("metadata should be readable") + .map(|metadata| metadata.is_dir), + Some(true), + ); + assert_eq!( + fs.metadata(Path::new(path!("/root/missing"))) + .await + .expect("metadata should be readable") + .map(|metadata| metadata.is_dir), + None, + ); + add_diff_editor( + &workspace, + &project, + &[ + (&buffer_scanned, "scanned base\n"), + (&buffer_excluded, "excluded base\n"), + (&buffer_missing, "missing base\n"), + ], + cx, + ); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + excluded/ + ~~gone.txt~~ + missing/ + ~~gone.txt~~ + scanned/ + ~~gone.txt~~" + ), + cx, + ); + } + + #[gpui::test] + async fn test_auto_reveal_ignores_external_file_on_nonexistent_path(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "dir": { + "real.rs": "pub fn real() {}\n", + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_real = open_buffer(&project, path!("/root/dir/real.rs"), cx).await; + let buffer_new = open_buffer(&project, path!("/root/dir/never_existed.rs"), cx).await; + buffer_new.update(cx, |buffer, cx| { + buffer.edit( + [( + 0..0, + indoc!( + " + pub fn phantom() { + let x = 1; + } + " + ), + )], + None, + cx, + ); + }); + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_real, Vec::new()), (&buffer_new, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + external: never_existed.rs + outline: pub fn phantom <==== selected + root/ + dir/ + real.rs + outline: pub fn real" + ), + cx, + ); + + let worktree_id = buffer_real.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + select_directory(&outline_panel, worktree_id, rel_path("dir"), cx); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + external: never_existed.rs + outline: pub fn phantom + root/ + dir/ <==== selected" + ), + cx, + ); + + let buffer_new_id = buffer_new.read_with(cx, |buffer, _cx| buffer.remote_id()); + select_in_buffer(&editor, buffer_new_id, cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + external: never_existed.rs + outline: pub fn phantom <==== selected + root/ + dir/" + ), + cx, + ); + } + #[gpui::test] async fn test_navigating_in_singleton(cx: &mut TestAppContext) { init_test(cx); @@ -7009,6 +9700,39 @@ outline: struct OutlineEntryExcerpt .unwrap() } + async fn rename_buffer( + project: &Entity, + buffer: &Entity, + path: &RelPath, + cx: &mut VisualTestContext, + ) { + let (entry_id, worktree_id) = buffer.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should have a file"); + ( + file.project_entry_id() + .expect("file should have a worktree entry"), + file.worktree.read(cx).id(), + ) + }); + project + .update(cx, |project, cx| { + project.rename_entry( + entry_id, + ProjectPath { + worktree_id, + path: Arc::from(path), + }, + cx, + ) + }) + .await + .expect("file rename should succeed"); + cx.run_until_parked(); + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + } + fn add_multi_buffer_editor( workspace: &Entity, project: &Entity, @@ -7038,17 +9762,87 @@ outline: struct OutlineEntryExcerpt }) } - fn settle_outline_panel(outline_panel: &Entity, cx: &mut VisualTestContext) { - cx.executor() - .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); - cx.run_until_parked(); - outline_panel.update_in(cx, |panel, window, cx| { - panel.update_non_fs_items(window, cx); - panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + fn set_buffer_order( + editor: &Entity, + buffers: &[&Entity], + cx: &mut VisualTestContext, + ) { + editor.update_in(cx, |editor, window, cx| { + editor.buffer().update(cx, |multibuffer, cx| { + for (index, buffer) in buffers.iter().enumerate() { + let mut path = PathKey::for_buffer(buffer, cx); + path.sort_prefix = Some(index as u64); + let range = language::Point::default()..buffer.read(cx).max_point(); + multibuffer.set_excerpts_for_path(path, (*buffer).clone(), [range], 0, cx); + } + assert_eq!( + multibuffer + .snapshot(cx) + .excerpts() + .map(|excerpt| excerpt.context.start.buffer_id) + .collect::>(), + buffers + .iter() + .map(|buffer| buffer.read(cx).remote_id()) + .collect::>(), + ); + }); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([language::Point::default()..language::Point::default()]); + }); }); - cx.executor() - .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + flush_outline_tasks(cx); + } + + fn select_in_buffer(editor: &Entity, buffer_id: BufferId, cx: &mut VisualTestContext) { + let anchor = editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let excerpt = snapshot + .excerpts_for_buffer(buffer_id) + .next() + .expect("buffer should have an excerpt in the multi-buffer"); + snapshot + .anchor_in_excerpt(excerpt.context.start) + .expect("the excerpt's start should resolve") + }); + cx.update(|window, cx| { + window.focus(&editor.focus_handle(cx), cx); + editor.update(cx, |editor, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges(Some(anchor..anchor)) + }); + }); + }); + flush_outline_tasks(cx); + } + + async fn wait_for_outline_tasks( + outline_panel: &Entity, + cx: &mut VisualTestContext, + ) { cx.run_until_parked(); + poll_fn(|task_context| { + outline_panel.update(cx, |panel, _| { + for task in [ + &mut panel.fs_entries_update_task, + &mut panel.lsp_outline_refresh_task, + &mut panel.cached_entries_update_task, + ] + .into_iter() + .chain(panel.outline_fetch_tasks.values_mut()) + { + if !task.is_ready() { + futures::ready!(task.poll_unpin(task_context)); + } + } + if !panel.reveal_selection_task.is_ready() { + futures::ready!(panel.reveal_selection_task.poll_unpin(task_context)) + .expect("selection reveal should complete"); + } + Poll::Ready(()) + }) + }) + .await; } fn update_outline_panel_settings( @@ -7064,6 +9858,34 @@ outline: struct OutlineEntryExcerpt }); } + fn assert_tree( + outline_panel: &Entity, + project: &Entity, + expected: &str, + cx: &mut VisualTestContext, + ) { + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + project, + &snapshot(outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + expected + ); + }); + } + + fn mark_deleted(name: String, is_deleted: bool) -> String { + if is_deleted { + format!("~~{name}~~") + } else { + name + } + } + fn display_entries( project: &Entity, multi_buffer_snapshot: &MultiBufferSnapshot, @@ -7085,15 +9907,19 @@ outline: struct OutlineEntryExcerpt } display_string += &match &entry.entry { PanelEntry::Fs(entry) => match entry { - FsEntry::ExternalFile(_) => { - panic!("Did not cover external files with tests") + FsEntry::ExternalFile(external_file) => { + let name = multi_buffer_snapshot + .buffer_for_id(external_file.buffer_id) + .and_then(|buffer| buffer.file()) + .map(|file| file.file_name(cx).to_string()) + .unwrap_or_else(|| "untitled".to_string()); + format!("external: {name}") } FsEntry::Directory(directory) => { let path = if let Some(worktree) = project .worktree_for_id(directory.worktree_id, cx) - .filter(|worktree| { - worktree.read(cx).root_entry() == Some(&directory.entry.entry) - }) { + .filter(|_| directory.entry.path.is_empty()) + { worktree .read(cx) .root_name() @@ -7110,12 +9936,14 @@ outline: struct OutlineEntryExcerpt }; format!("{path}/") } - FsEntry::File(file) => file - .entry - .path - .file_name() - .map(|name| name.to_string()) - .unwrap_or_default(), + FsEntry::File(file) => mark_deleted( + file.entry + .path + .file_name() + .map(|name| name.to_string()) + .unwrap_or_default(), + file.is_deleted, + ), }, PanelEntry::FoldedDirs(folded_dirs) => folded_dirs .entries @@ -7385,6 +10213,174 @@ outline: struct OutlineEntryExcerpt }) } + async fn active_outline_panel( + project: &Entity, + cx: &mut TestAppContext, + ) -> (Entity, Entity, VisualTestContext) { + let (window, workspace) = add_outline_panel(project, cx).await; + let mut cx = VisualTestContext::from_window(window.into(), cx); + let panel = outline_panel(&workspace, &mut cx); + panel.update_in(&mut cx, |panel, window, cx| { + panel.set_active(true, window, cx); + }); + (workspace, panel, cx) + } + + fn add_diff_editor( + workspace: &Entity, + project: &Entity, + entries: &[(&Entity, &str)], + cx: &mut VisualTestContext, + ) -> Entity { + let (editor, diffs) = workspace.update_in(cx, |workspace, window, cx| { + let mut diffs = Vec::new(); + let multibuffer = cx.new(|cx| { + let mut multibuffer = editor::MultiBuffer::new(language::Capability::ReadWrite); + for (buffer, base_text) in entries { + let diff = cx.new(|cx| { + BufferDiff::new_with_base_text( + base_text, + &buffer.read(cx).text_snapshot(), + cx, + ) + }); + let buffer_snapshot = buffer.read(cx).snapshot(); + let ranges = diff + .read(cx) + .snapshot(cx) + .hunks(&buffer_snapshot) + .map(|hunk| hunk.buffer_range.to_point(&buffer_snapshot)) + .collect::>(); + assert!( + !ranges.is_empty(), + "diff fixture must have at least one hunk" + ); + multibuffer.set_excerpts_for_buffer((*buffer).clone(), ranges, 0, cx); + multibuffer.add_diff(diff.clone(), cx); + diffs.push(diff); + } + multibuffer + }); + let editor = cx.new(|cx| { + let mut editor = + Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx); + editor.set_diff_hunk_renderer(Some(Arc::new(HiddenUnstagedDiffHunkRenderer)), cx); + editor.set_expand_all_diff_hunks(cx); + editor + }); + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + (editor, diffs) + }); + flush_outline_tasks(cx); + editor.read_with(cx, |editor, cx| { + let multibuffer = editor.buffer().read(cx); + let snapshot = multibuffer.snapshot(cx); + for ((buffer, base_text), diff) in entries.iter().zip(&diffs) { + let buffer_id = buffer.read(cx).remote_id(); + assert_eq!( + multibuffer.diff_for(buffer_id).map(|diff| diff.entity_id()), + Some(diff.entity_id()), + ); + let diff = snapshot + .diff_for_buffer_id(buffer_id) + .expect("fixture diff should remain attached"); + assert!(diff.base_text_exists()); + assert_eq!(diff.base_text().text(), *base_text); + } + }); + editor + } + + fn flush_outline_tasks(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.executor().advance_clock(UPDATE_DEBOUNCE * 3); + cx.run_until_parked(); + } + + fn select_directory( + panel: &Entity, + worktree_id: WorktreeId, + path: &RelPath, + cx: &mut VisualTestContext, + ) { + panel.update_in(cx, |panel, window, cx| { + let mut entries = panel + .cached_entries + .iter() + .filter(|cached| match &cached.entry { + PanelEntry::Fs(FsEntry::Directory(directory)) => { + directory.worktree_id == worktree_id + && directory.entry.path.as_ref() == path + } + PanelEntry::FoldedDirs(directories) => { + directories.worktree_id == worktree_id + && directories + .entries + .last() + .is_some_and(|entry| entry.path.as_ref() == path) + } + _ => false, + }); + let entry = entries + .next() + .expect("directory should have a row") + .entry + .clone(); + assert!( + entries.next().is_none(), + "directory should have exactly one row" + ); + panel.select_entry(entry, true, window, cx); + }); + } + + fn select_file(panel: &Entity, buffer_id: BufferId, cx: &mut VisualTestContext) { + panel.update_in(cx, |panel, window, cx| { + let mut entries = panel.cached_entries.iter().filter(|cached| { + matches!(&cached.entry, PanelEntry::Fs(FsEntry::File(file)) if file.buffer_id == buffer_id) + }); + let entry = entries.next().expect("file should have a row").entry.clone(); + assert_eq!(entries.count(), 0, "file should have exactly one row"); + panel.select_entry(entry, true, window, cx); + }); + } + + fn assert_directory_paths( + panel: &Entity, + worktree_id: WorktreeId, + expected_unfolded: &[&str], + expected_collapsed: &[&str], + cx: &mut VisualTestContext, + ) { + panel.read_with(cx, |panel, _| { + assert_eq!( + panel.unfolded_dirs, + HashMap::from_iter([( + worktree_id, + expected_unfolded + .iter() + .map(|path| Arc::::from(rel_path(path))) + .collect::>(), + )]), + ); + assert_eq!( + panel + .collapsed_entries + .iter() + .filter_map(|entry| match entry { + CollapsedEntry::Dir(worktree_id, path) => + Some((*worktree_id, path.clone())), + _ => None, + }) + .collect::>(), + expected_collapsed + .iter() + .map(|path| (worktree_id, Arc::::from(rel_path(path)))) + .collect::>(), + ); + }); + } + #[gpui::test] async fn test_outline_keyboard_expand_collapse(cx: &mut TestAppContext) { init_test(cx); @@ -8139,6 +11135,52 @@ search: | Field | Meaning « » | search: | Field | Meaning « »|" ); }); + + let entries_capacity = outline_panel.update_in(cx, |panel, window, cx| { + panel + .cached_entries + .reserve(panel.cached_entries.capacity() + 1); + let entry = panel + .cached_entries + .get(1) + .expect("second search result") + .entry + .clone(); + panel.toggle_expanded(&entry, window, cx); + panel.scroll_editor_to_entry(&entry, true, false, window, cx); + panel.cached_entries.capacity() + }); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.read_with(cx, |panel, _| { + assert_eq!(panel.cached_entries.capacity(), entries_capacity); + assert_eq!( + panel.selected_entry(), + panel.cached_entries.get(1).map(|cached| &cached.entry) + ); + }); + + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("Field", None, true, window, cx) + }) + .await + .expect("search should complete"); + outline_panel.update_in(cx, |panel, window, cx| { + panel.update_cached_entries(None, window, cx); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.update(cx, |panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(panel, cx), + &panel.cached_entries, + panel.selected_entry(), + cx + ), + "search: | «Field» | Meaning | <==== selected" + ); + }); } #[gpui::test] @@ -8151,7 +11193,14 @@ search: | Field | Meaning « »|" root, json!({ "src": { - "lib.rs": "struct Foo {\n bar: u32,\n baz: String,\n}\n", + "lib.rs": indoc!( + " + struct Foo { + bar: u32, + baz: String, + } + " + ), } }), ) @@ -8242,7 +11291,7 @@ search: | Field | Meaning « »|" }); }); - let _editor = workspace + let editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/root/src/lib.rs")), @@ -8295,9 +11344,7 @@ outline: struct Foo <==== selected }, ); }); - cx.executor() - .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); - cx.run_until_parked(); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |outline_panel, cx| { assert_eq!( @@ -8318,6 +11365,67 @@ outline: struct Foo <==== selected ); }); + for _ in 0..2 { + outline_panel.update(cx, |panel, _| panel.outline_fetch_tasks.clear()); + editor.update(cx, |_, cx| cx.emit(EditorEvent::OutlineSymbolsChanged)); + cx.run_until_parked(); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE - Duration::from_millis(1)); + editor.update(cx, |_, cx| { + for _ in 0..8 { + cx.emit(EditorEvent::OutlineSymbolsChanged); + } + }); + cx.run_until_parked(); + outline_panel.read_with(cx, |panel, _| { + assert!(panel.outline_fetch_tasks.is_empty()); + assert!( + panel + .buffers + .values() + .all(|buffer| { matches!(buffer.outlines, OutlineState::Outlines(_)) }) + ); + }); + cx.executor().advance_clock(Duration::from_millis(1)); + cx.run_until_parked(); + outline_panel.read_with(cx, |panel, _| { + assert_eq!(panel.outline_fetch_tasks.len(), 1); + assert!(panel.outline_fetch_tasks.values().all(Task::is_ready)); + assert!( + panel + .buffers + .values() + .all(|buffer| { matches!(buffer.outlines, OutlineState::Outlines(_)) }) + ); + }); + } + + let entries_capacity = outline_panel.update(cx, |panel, _| { + panel + .cached_entries + .reserve(panel.cached_entries.capacity() + 1); + panel.cached_entries.capacity() + }); + for (row, expected_symbol) in [(1, "bar"), (2, "lsp_only_field"), (0, "struct Foo")] { + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + let point = language::Point::new(row, 4); + selections.select_ranges([point..point]); + }); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.read_with(cx, |panel, _| { + assert_eq!(panel.cached_entries.capacity(), entries_capacity); + let selected_symbol = panel.selected_entry().and_then(|entry| match entry { + PanelEntry::Outline(OutlineEntry::Outline(outline)) => { + Some(outline.text.as_ref()) + } + _ => None, + }); + assert_eq!(selected_symbol, Some(expected_symbol)); + }); + } + // Step 3: Switch back to tree-sitter cx.update(|_, cx| { settings::SettingsStore::update_global( @@ -8330,9 +11438,7 @@ outline: struct Foo <==== selected }, ); }); - cx.executor() - .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); - cx.run_until_parked(); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |outline_panel, cx| { assert_eq!( @@ -8352,6 +11458,14 @@ outline: struct Foo <==== selected "Step 3: tree-sitter outlines should be restored" ); }); + + editor.update(cx, |_, cx| cx.emit(EditorEvent::OutlineSymbolsChanged)); + cx.run_until_parked(); + outline_panel.update_in(cx, |panel, window, cx| { + assert!(!panel.lsp_outline_refresh_task.is_ready()); + panel.clear_previous(window, cx); + assert!(panel.lsp_outline_refresh_task.is_ready()); + }); } #[gpui::test] @@ -8402,17 +11516,19 @@ outline: struct Foo <==== selected .downcast::() .unwrap(); - cx.run_until_parked(); + wait_for_outline_tasks(&outline_panel, cx).await; - outline_panel.update_in(cx, |panel, window, cx| { - panel.update_non_fs_items(window, cx); - panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + let entries_capacity = outline_panel.update(cx, |panel, _| { + panel + .cached_entries + .reserve(panel.cached_entries.capacity() + 1); + panel.cached_entries.capacity() }); // Helper function to move the cursor to the first column of a given row // and return the selected outline entry's text. let move_cursor_and_get_selection = - |row: u32, cx: &mut VisualTestContext| -> Option { + async |row: u32, cx: &mut VisualTestContext| -> Option { cx.update(|window, cx| { editor.update(cx, |editor, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { @@ -8423,9 +11539,10 @@ outline: struct Foo <==== selected }); }); - cx.run_until_parked(); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.read_with(cx, |panel, _cx| { + assert_eq!(panel.cached_entries.capacity(), entries_capacity); panel.selected_entry().and_then(|entry| match entry { PanelEntry::Outline(OutlineEntry::Outline(outline)) => { Some(outline.text.clone()) @@ -8436,28 +11553,48 @@ outline: struct Foo <==== selected }; assert_eq!( - move_cursor_and_get_selection(0, cx).as_deref(), + move_cursor_and_get_selection(0, cx).await.as_deref(), Some("# Section A"), "Cursor at row 0 should select '# Section A'" ); assert_eq!( - move_cursor_and_get_selection(2, cx).as_deref(), + move_cursor_and_get_selection(2, cx).await.as_deref(), Some("## Sub Section A"), "Cursor at row 2 should select '## Sub Section A'" ); assert_eq!( - move_cursor_and_get_selection(4, cx).as_deref(), + move_cursor_and_get_selection(4, cx).await.as_deref(), Some("## Sub Section B"), "Cursor at row 4 should select '## Sub Section B'" ); assert_eq!( - move_cursor_and_get_selection(6, cx).as_deref(), + move_cursor_and_get_selection(6, cx).await.as_deref(), Some("# Section B"), "Cursor at row 6 should select '# Section B'" ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .outline_panel + .get_or_insert_default() + .auto_reveal_entries = Some(false); + }); + }); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + let mut notifications = cx.notifications(&outline_panel); + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([language::Point::new(0, 0)..language::Point::new(0, 0)]); + }); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + assert_eq!(notifications.next().now_or_never(), None); } #[gpui::test] @@ -8501,7 +11638,7 @@ outline: struct Foo <==== selected &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, _cx| { assert!( @@ -9041,7 +12178,7 @@ rust-analyzer/ ], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |outline_panel, cx| { assert_eq!( @@ -9071,6 +12208,87 @@ test/ and without outlines that lie outside every excerpt" ); }); + + let count = 256_u32; + let text = (0..count) + .map(|index| format!("fn needle_{index:04}() {{}}\n\n\n\n\n\n\n\n")) + .collect::(); + buffer_one.update(cx, |buffer, cx| { + buffer.edit([(0..buffer.len(), text)], None, cx); + }); + let excerpts = (0..count) + .step_by(2) + .map(|index| language::Point::new(index * 8, 0)..language::Point::new(index * 8 + 1, 0)) + .collect::>(); + let editor = add_multi_buffer_editor(&workspace, &project, &[(&buffer_one, excerpts)], cx); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.read_with(cx, |panel, _| { + assert_eq!( + panel + .cached_entries + .iter() + .filter_map(|cached| match &cached.entry { + PanelEntry::Outline(OutlineEntry::Outline(outline)) => + Some(outline.text.to_string()), + _ => None, + }) + .collect::>(), + (0..count) + .step_by(2) + .map(|index| format!("fn needle_{index:04}")) + .collect::>(), + ); + }); + let search_bar = cx.update(|window, cx| { + cx.new(|cx| { + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor), window, cx); + search_bar.show(window, cx); + search_bar + }) + }); + for dense in [false, true] { + if dense { + editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_buffer( + buffer_one.clone(), + [language::Point::default()..buffer_one.read(cx).max_point()], + 0, + cx, + ); + }); + }); + } + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("needle", None, true, window, cx) + }) + .await + .unwrap(); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.update(cx, |panel, cx| { + let snapshot = snapshot(panel, cx); + assert_eq!( + snapshot.excerpts().count(), + if dense { 1 } else { count as usize / 2 } + ); + let mut expected = indoc!( + " + test/ + src/ + one.rs" + ) + .to_string(); + for index in (0..count).step_by(if dense { 1 } else { 2 }) { + expected.push_str(&format!("\n search: fn «needle»_{index:04}() {{}}")); + } + assert_eq!( + display_entries(&project, &snapshot, &panel.cached_entries, None, cx), + expected + ); + }); + } } #[gpui::test] @@ -9114,7 +12332,7 @@ test/ &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, _cx| { assert!( @@ -9236,7 +12454,7 @@ test/ &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update_in(cx, |panel, window, cx| { panel.filter_editor.update(cx, |filter_editor, cx| { @@ -9372,7 +12590,7 @@ test/ &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, cx| { assert_eq!( @@ -9458,7 +12676,7 @@ test/ &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, _cx| { assert!( @@ -9474,7 +12692,7 @@ test/ settings.multi_buffer_hide_symbols = Some(false); settings.expand_outlines_with_depth = Some(0); }); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, cx| { assert_eq!( diff --git a/docs/src/outline-panel.md b/docs/src/outline-panel.md index aa4c193a5e..f6c3dc59b2 100644 --- a/docs/src/outline-panel.md +++ b/docs/src/outline-panel.md @@ -13,6 +13,12 @@ When viewing a "singleton" buffer (i.e., a single file on a tab), the outline pa ## Usage with multibuffers +File rows follow the order of the files in the multibuffer. +If files from a folder appear in separate parts of the multibuffer, the outline panel repeats that folder's path group to preserve their order. + +When a multibuffer includes deleted project files, they remain grouped under their original paths with struck-through file names. +Folder rows group files by path and are not struck through, even when the folders no longer exist on disk. + The outline panel truly excels when used with multi-buffers. Here are some examples of its versatility: ### Project Search Results