diff --git a/Cargo.lock b/Cargo.lock index 4c01ef2462c31..b1849fe63b2e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7132,7 +7132,6 @@ dependencies = [ "project", "rope", "serde_json", - "settings", "smol", "theme", "tree-sitter-rust", @@ -7142,6 +7141,31 @@ dependencies = [ "workspace", ] +[[package]] +name = "outline_panel" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "db", + "editor", + "file_icons", + "git", + "gpui", + "language", + "log", + "menu", + "project", + "schemars", + "serde", + "serde_json", + "settings", + "unicase", + "util", + "workspace", + "worktree", +] + [[package]] name = "outref" version = "0.5.1" @@ -13255,6 +13279,7 @@ dependencies = [ "node_runtime", "notifications", "outline", + "outline_panel", "parking_lot", "profiling", "project", diff --git a/Cargo.toml b/Cargo.toml index 336d5d855905b..21dfd5c9f2f0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ members = [ "crates/ollama", "crates/open_ai", "crates/outline", + "crates/outline_panel", "crates/picker", "crates/prettier", "crates/project", @@ -212,6 +213,7 @@ notifications = { path = "crates/notifications" } ollama = { path = "crates/ollama" } open_ai = { path = "crates/open_ai" } outline = { path = "crates/outline" } +outline_panel = { path = "crates/outline_panel" } picker = { path = "crates/picker" } plugin = { path = "crates/plugin" } plugin_macros = { path = "crates/plugin_macros" } diff --git a/assets/icons/list_tree.svg b/assets/icons/list_tree.svg new file mode 100644 index 0000000000000..8cf157ec135d1 --- /dev/null +++ b/assets/icons/list_tree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 34b5cd5caf56d..c735060364d10 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -439,6 +439,7 @@ "ctrl-shift-p": "command_palette::Toggle", "ctrl-shift-m": "diagnostics::Deploy", "ctrl-shift-e": "project_panel::ToggleFocus", + "ctrl-shift-b": "outline_panel::ToggleFocus", "ctrl-?": "assistant::ToggleFocus", "ctrl-alt-s": "workspace::SaveAll", "ctrl-k m": "language_selector::Toggle", @@ -562,6 +563,18 @@ "ctrl-enter": "project_search::SearchInNew" } }, + { + "context": "OutlinePanel", + "bindings": { + "left": "project_panel::CollapseSelectedEntry", + "right": "project_panel::ExpandSelectedEntry", + "ctrl-alt-c": "project_panel::CopyPath", + "alt-ctrl-shift-c": "project_panel::CopyRelativePath", + "alt-ctrl-r": "project_panel::RevealInFinder", + "shift-down": "menu::SelectNext", + "shift-up": "menu::SelectPrev" + } + }, { "context": "ProjectPanel", "bindings": { @@ -583,7 +596,10 @@ "ctrl-backspace": ["project_panel::Delete", { "skip_prompt": false }], "ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }], "alt-ctrl-r": "project_panel::RevealInFinder", - "alt-shift-f": "project_panel::NewSearchInDirectory" + "alt-shift-f": "project_panel::NewSearchInDirectory", + "shift-down": "menu::SelectNext", + "shift-up": "menu::SelectPrev", + "escape": "menu::Cancel" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 8f20a43834034..2ce2e4ea89502 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -475,6 +475,7 @@ "cmd-shift-p": "command_palette::Toggle", "cmd-shift-m": "diagnostics::Deploy", "cmd-shift-e": "project_panel::ToggleFocus", + "cmd-shift-b": "outline_panel::ToggleFocus", "cmd-?": "assistant::ToggleFocus", "cmd-alt-s": "workspace::SaveAll", "cmd-k m": "language_selector::Toggle", @@ -584,6 +585,18 @@ "cmd-enter": "project_search::SearchInNew" } }, + { + "context": "OutlinePanel", + "bindings": { + "left": "outline_panel::CollapseSelectedEntry", + "right": "outline_panel::ExpandSelectedEntry", + "cmd-alt-c": "outline_panel::CopyPath", + "alt-cmd-shift-c": "outline_panel::CopyRelativePath", + "alt-cmd-r": "outline_panel::RevealInFinder", + "shift-down": "menu::SelectNext", + "shift-up": "menu::SelectPrev" + } + }, { "context": "ProjectPanel", "bindings": { diff --git a/assets/settings/default.json b/assets/settings/default.json index 0ab3b1d9779ef..a64208834c112 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -302,6 +302,29 @@ /// when a directory has only one directory inside. "auto_fold_dirs": false }, + "outline_panel": { + // Whether to show the outline panel button in the status bar + "button": true, + // Default width of the outline panel. + "default_width": 240, + // Where to dock the outline panel. Can be 'left' or 'right'. + "dock": "left", + // Whether to show file icons in the outline panel. + "file_icons": true, + // Whether to show folder icons or chevrons for directories in the outline panel. + "folder_icons": true, + // Whether to show the git status in the outline panel. + "git_status": true, + // Amount of indentation for nested items. + "indent_size": 20, + // Whether to reveal it in the outline panel automatically, + // when a corresponding outline entry becomes active. + // Gitignored entries are never auto revealed. + "auto_reveal_entries": true, + /// Whether to fold directories automatically + /// when a directory has only one directory inside. + "auto_fold_dirs": true + }, "collaboration_panel": { // Whether to show the collaboration panel button in the status bar. "button": true, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a1aff7cbe86a7..5339643f521e5 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -149,6 +149,9 @@ use workspace::{OpenInTerminal, OpenTerminal, TabBarSettings, Toast}; use crate::hover_links::find_url; +pub const FILE_HEADER_HEIGHT: u8 = 1; +pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u8 = 1; +pub const MULTI_BUFFER_EXCERPT_FOOTER_HEIGHT: u8 = 1; pub const DEFAULT_MULTIBUFFER_CONTEXT: u32 = 2; const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); const MAX_LINE_LEN: usize = 1024; @@ -529,6 +532,7 @@ pub struct Editor { tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>, tasks_update_task: Option>, previous_search_ranges: Option]>>, + file_header_size: u8, } #[derive(Clone)] @@ -1651,9 +1655,8 @@ impl Editor { }), merge_adjacent: true, }; + let file_header_size = if show_excerpt_controls { 3 } else { 2 }; let display_map = cx.new_model(|cx| { - let file_header_size = if show_excerpt_controls { 3 } else { 2 }; - DisplayMap::new( buffer.clone(), style.font(), @@ -1661,8 +1664,8 @@ impl Editor { None, show_excerpt_controls, file_header_size, - 1, - 1, + MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, + MULTI_BUFFER_EXCERPT_FOOTER_HEIGHT, fold_placeholder, cx, ) @@ -1812,6 +1815,7 @@ impl Editor { git_blame_inline_enabled: ProjectSettings::get_global(cx).git.inline_blame_enabled(), blame: None, blame_subscription: None, + file_header_size, tasks: Default::default(), _subscriptions: vec![ cx.observe(&buffer, Self::on_buffer_changed), @@ -10829,6 +10833,12 @@ impl Editor { self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx); cx.emit(EditorEvent::ExcerptsRemoved { ids: ids.clone() }) } + multi_buffer::Event::ExcerptsEdited { ids } => { + cx.emit(EditorEvent::ExcerptsEdited { ids: ids.clone() }) + } + multi_buffer::Event::ExcerptsExpanded { ids } => { + cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() }) + } multi_buffer::Event::Reparsed => { self.tasks_update_task = Some(self.refresh_runnables(cx)); @@ -11299,6 +11309,10 @@ impl Editor { })); self } + + pub fn file_header_size(&self) -> u8 { + self.file_header_size + } } fn hunks_for_selections( @@ -11743,6 +11757,12 @@ pub enum EditorEvent { ExcerptsRemoved { ids: Vec, }, + ExcerptsEdited { + ids: Vec, + }, + ExcerptsExpanded { + ids: Vec, + }, BufferEdited, Edited, Reparsed, diff --git a/crates/fuzzy/src/char_bag.rs b/crates/fuzzy/src/char_bag.rs index ca40d730fbc4e..13b00816ed014 100644 --- a/crates/fuzzy/src/char_bag.rs +++ b/crates/fuzzy/src/char_bag.rs @@ -1,6 +1,6 @@ use std::iter::FromIterator; -#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] pub struct CharBag(u64); impl CharBag { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index fc598e0c9d869..ecd130176a721 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -316,7 +316,7 @@ fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum GitFileStatus { Added, Modified, diff --git a/crates/gpui/src/color.rs b/crates/gpui/src/color.rs index 7a466d2e79fc7..2cf2ad55f2a1b 100644 --- a/crates/gpui/src/color.rs +++ b/crates/gpui/src/color.rs @@ -1,6 +1,9 @@ use anyhow::{bail, Context}; use serde::de::{self, Deserialize, Deserializer, Visitor}; -use std::fmt; +use std::{ + fmt, + hash::{Hash, Hasher}, +}; /// Convert an RGB hex color code number to a color type pub fn rgb(hex: u32) -> Rgba { @@ -267,6 +270,15 @@ impl Ord for Hsla { impl Eq for Hsla {} +impl Hash for Hsla { + fn hash(&self, state: &mut H) { + state.write_u32(u32::from_be_bytes(self.h.to_be_bytes())); + state.write_u32(u32::from_be_bytes(self.s.to_be_bytes())); + state.write_u32(u32::from_be_bytes(self.l.to_be_bytes())); + state.write_u32(u32::from_be_bytes(self.a.to_be_bytes())); + } +} + /// Construct an [`Hsla`] object from plain values pub fn hsla(h: f32, s: f32, l: f32, a: f32) -> Hsla { Hsla { diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 49111f48f87bf..0ca021f2b2b72 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -1,4 +1,8 @@ -use std::{iter, mem, ops::Range}; +use std::{ + hash::{Hash, Hasher}, + iter, mem, + ops::Range, +}; use crate::{ black, phi, point, quad, rems, AbsoluteLength, Bounds, ContentMask, Corners, CornersRefinement, @@ -319,6 +323,20 @@ pub struct HighlightStyle { impl Eq for HighlightStyle {} +impl Hash for HighlightStyle { + fn hash(&self, state: &mut H) { + self.color.hash(state); + self.font_weight.hash(state); + self.font_style.hash(state); + self.background_color.hash(state); + self.underline.hash(state); + self.strikethrough.hash(state); + state.write_u32(u32::from_be_bytes( + self.fade_out.map(|f| f.to_be_bytes()).unwrap_or_default(), + )); + } +} + impl Style { /// Returns true if the style is visible and the background is opaque. pub fn has_opaque_background(&self) -> bool { @@ -549,7 +567,7 @@ impl Default for Style { } /// The properties that can be applied to an underline. -#[derive(Refineable, Copy, Clone, Default, Debug, PartialEq, Eq)] +#[derive(Refineable, Copy, Clone, Default, Debug, PartialEq, Eq, Hash)] #[refineable(Debug)] pub struct UnderlineStyle { /// The thickness of the underline. @@ -563,7 +581,7 @@ pub struct UnderlineStyle { } /// The properties that can be applied to a strikethrough. -#[derive(Refineable, Copy, Clone, Default, Debug, PartialEq, Eq)] +#[derive(Refineable, Copy, Clone, Default, Debug, PartialEq, Eq, Hash)] #[refineable(Debug)] pub struct StrikethroughStyle { /// The thickness of the strikethrough. diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 31b6cb573e030..ba3a961b1e497 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -2738,12 +2738,13 @@ impl BufferSnapshot { Some(items) } - fn outline_items_containing( + pub fn outline_items_containing( &self, - range: Range, + range: Range, include_extra_context: bool, theme: Option<&SyntaxTheme>, ) -> Option>> { + let range = range.to_offset(self); let mut matches = self.syntax.matches(range.clone(), &self.text, |grammar| { grammar.outline_config.as_ref().map(|c| &c.query) }); diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 5ad8e6031509e..51e5773d81a19 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -70,7 +70,7 @@ pub use language_registry::{ PendingLanguageServer, QUERY_FILENAME_PREFIXES, }; pub use lsp::LanguageServerId; -pub use outline::{Outline, OutlineItem}; +pub use outline::{render_item, Outline, OutlineItem}; pub use syntax_map::{OwnedSyntaxLayer, SyntaxLayer}; pub use text::{AnchorRangeExt, LineEnding}; pub use tree_sitter::{Node, Parser, Tree, TreeCursor}; diff --git a/crates/language/src/outline.rs b/crates/language/src/outline.rs index bf807bfc75730..7621280d7fdc6 100644 --- a/crates/language/src/outline.rs +++ b/crates/language/src/outline.rs @@ -1,6 +1,11 @@ use fuzzy::{StringMatch, StringMatchCandidate}; -use gpui::{BackgroundExecutor, HighlightStyle}; +use gpui::{ + relative, AppContext, BackgroundExecutor, FontStyle, FontWeight, HighlightStyle, StyledText, + TextStyle, WhiteSpace, +}; +use settings::Settings; use std::ops::Range; +use theme::{ActiveTheme, ThemeSettings}; /// An outline of all the symbols contained in a buffer. #[derive(Debug)] @@ -11,7 +16,7 @@ pub struct Outline { path_candidate_prefixes: Vec, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct OutlineItem { pub depth: usize, pub range: Range, @@ -138,3 +143,34 @@ impl Outline { tree_matches } } + +pub fn render_item( + outline_item: &OutlineItem, + custom_highlights: impl IntoIterator, HighlightStyle)>, + cx: &AppContext, +) -> StyledText { + let settings = ThemeSettings::get_global(cx); + + // TODO: We probably shouldn't need to build a whole new text style here + // but I'm not sure how to get the current one and modify it. + // Before this change TextStyle::default() was used here, which was giving us the wrong font and text color. + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: settings.buffer_font_size(cx).into(), + font_weight: FontWeight::NORMAL, + font_style: FontStyle::Normal, + line_height: relative(1.), + background_color: None, + underline: None, + strikethrough: None, + white_space: WhiteSpace::Normal, + }; + let highlights = gpui::combine_highlights( + custom_highlights, + outline_item.highlight_ranges.iter().cloned(), + ); + + StyledText::new(outline_item.text.clone()).with_highlights(&text_style, highlights) +} diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index cdba2fe6cff64..68514dfd3f817 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -77,6 +77,9 @@ pub enum Event { ExcerptsRemoved { ids: Vec, }, + ExcerptsExpanded { + ids: Vec, + }, ExcerptsEdited { ids: Vec, }, @@ -1666,8 +1669,9 @@ impl MultiBuffer { } self.sync(cx); + let ids = ids.into_iter().collect::>(); let snapshot = self.snapshot(cx); - let locators = snapshot.excerpt_locators_for_ids(ids); + let locators = snapshot.excerpt_locators_for_ids(ids.iter().copied()); let mut new_excerpts = SumTree::new(); let mut cursor = snapshot.excerpts.cursor::<(Option<&Locator>, usize)>(); let mut edits = Vec::>::new(); @@ -1746,6 +1750,7 @@ impl MultiBuffer { cx.emit(Event::Edited { singleton_buffer_edited: false, }); + cx.emit(Event::ExcerptsExpanded { ids }); cx.notify(); } diff --git a/crates/outline/Cargo.toml b/crates/outline/Cargo.toml index 6f385f5d8d082..66ee78895e919 100644 --- a/crates/outline/Cargo.toml +++ b/crates/outline/Cargo.toml @@ -19,7 +19,6 @@ gpui.workspace = true language.workspace = true ordered-float.workspace = true picker.workspace = true -settings.workspace = true smol.workspace = true theme.workspace = true ui.workspace = true diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 18d9446f4b56c..b3647361e611c 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -2,19 +2,18 @@ use editor::{scroll::Autoscroll, Anchor, AnchorRangeExt, Editor, EditorMode}; use fuzzy::StringMatch; use gpui::{ actions, div, rems, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, - FontStyle, FontWeight, HighlightStyle, ParentElement, Point, Render, Styled, StyledText, Task, - TextStyle, View, ViewContext, VisualContext, WeakView, WhiteSpace, WindowContext, + HighlightStyle, ParentElement, Point, Render, Styled, Task, View, ViewContext, VisualContext, + WeakView, WindowContext, }; use language::Outline; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; -use settings::Settings; use std::{ cmp::{self, Reverse}, sync::Arc, }; -use theme::{color_alpha, ActiveTheme, ThemeSettings}; +use theme::{color_alpha, ActiveTheme}; use ui::{prelude::*, ListItem, ListItemSpacing}; use util::ResultExt; use workspace::{DismissDecision, ModalView}; @@ -268,38 +267,12 @@ impl PickerDelegate for OutlineViewDelegate { selected: bool, cx: &mut ViewContext>, ) -> Option { - let settings = ThemeSettings::get_global(cx); - - // TODO: We probably shouldn't need to build a whole new text style here - // but I'm not sure how to get the current one and modify it. - // Before this change TextStyle::default() was used here, which was giving us the wrong font and text color. - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features.clone(), - font_size: settings.buffer_font_size(cx).into(), - font_weight: FontWeight::NORMAL, - font_style: FontStyle::Normal, - line_height: relative(1.), - background_color: None, - underline: None, - strikethrough: None, - white_space: WhiteSpace::Normal, - }; + let mat = self.matches.get(ix)?; + let outline_item = self.outline.items.get(mat.candidate_id)?; let mut highlight_style = HighlightStyle::default(); highlight_style.background_color = Some(color_alpha(cx.theme().colors().text_accent, 0.3)); - - let mat = &self.matches[ix]; - let outline_item = &self.outline.items[mat.candidate_id]; - - let highlights = gpui::combine_highlights( - mat.ranges().map(|range| (range, highlight_style)), - outline_item.highlight_ranges.iter().cloned(), - ); - - let styled_text = - StyledText::new(outline_item.text.clone()).with_highlights(&text_style, highlights); + let custom_highlights = mat.ranges().map(|range| (range, highlight_style)); Some( ListItem::new(ix) @@ -310,7 +283,7 @@ impl PickerDelegate for OutlineViewDelegate { div() .text_ui(cx) .pl(rems(outline_item.depth as f32)) - .child(styled_text), + .child(language::render_item(outline_item, custom_highlights, cx)), ), ) } diff --git a/crates/outline_panel/Cargo.toml b/crates/outline_panel/Cargo.toml new file mode 100644 index 0000000000000..e074710c28e5b --- /dev/null +++ b/crates/outline_panel/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "outline_panel" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/outline_panel.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +collections.workspace = true +db.workspace = true +editor.workspace = true +file_icons.workspace = true +git.workspace = true +gpui.workspace = true +language.workspace = true +log.workspace = true +menu.workspace = true +project.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +unicase.workspace = true +util.workspace = true +worktree.workspace = true +workspace.workspace = true + +[package.metadata.cargo-machete] +ignored = ["log"] diff --git a/crates/outline_panel/LICENSE-GPL b/crates/outline_panel/LICENSE-GPL new file mode 120000 index 0000000000000..89e542f750cd3 --- /dev/null +++ b/crates/outline_panel/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs new file mode 100644 index 0000000000000..1c5cc47fda14f --- /dev/null +++ b/crates/outline_panel/src/outline_panel.rs @@ -0,0 +1,2515 @@ +mod outline_panel_settings; + +use std::{ + cmp, + hash::Hash, + ops::Range, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +use anyhow::Context; +use collections::{hash_map, BTreeSet, HashMap, HashSet}; +use db::kvp::KEY_VALUE_STORE; +use editor::{ + items::{entry_git_aware_label_color, entry_label_color}, + scroll::ScrollAnchor, + Editor, EditorEvent, ExcerptId, +}; +use file_icons::FileIcons; +use git::repository::GitFileStatus; +use gpui::{ + actions, anchored, deferred, div, px, uniform_list, Action, AppContext, AssetSource, + AsyncWindowContext, ClipboardItem, DismissEvent, Div, ElementId, EntityId, EventEmitter, + FocusHandle, FocusableView, InteractiveElement, IntoElement, KeyContext, Model, MouseButton, + MouseDownEvent, ParentElement, Pixels, Point, Render, SharedString, Stateful, Styled, + Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, + WindowContext, +}; +use language::{BufferId, OffsetRangeExt, OutlineItem}; +use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev}; + +use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings}; +use project::{EntryKind, File, Fs, Project}; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsStore}; +use unicase::UniCase; +use util::{maybe, NumericPrefixWithSuffix, ResultExt, TryFutureExt}; +use workspace::{ + dock::{DockPosition, Panel, PanelEvent}, + item::ItemHandle, + ui::{ + h_flex, v_flex, ActiveTheme, Color, ContextMenu, FluentBuilder, Icon, IconName, IconSize, + Label, LabelCommon, ListItem, Selectable, + }, + OpenInTerminal, Workspace, +}; +use worktree::{Entry, ProjectEntryId, WorktreeId}; + +actions!( + outline_panel, + [ + ExpandSelectedEntry, + CollapseSelectedEntry, + CollapseAllEntries, + CopyPath, + CopyRelativePath, + RevealInFinder, + Open, + ToggleFocus, + UnfoldDirectory, + FoldDirectory, + SelectParent, + ] +); + +const OUTLINE_PANEL_KEY: &str = "OutlinePanel"; +const UPDATE_DEBOUNCE_MILLIS: u64 = 80; + +type Outline = OutlineItem; + +pub struct OutlinePanel { + fs: Arc, + width: Option, + project: Model, + active: bool, + scroll_handle: UniformListScrollHandle, + context_menu: Option<(View, Point, Subscription)>, + focus_handle: FocusHandle, + pending_serialization: Task>, + fs_entries_depth: HashMap<(WorktreeId, ProjectEntryId), (bool, usize)>, + fs_entries: Vec, + collapsed_dirs: HashMap>, + unfolded_dirs: HashMap>, + last_visible_range: Range, + selected_entry: Option, + active_item: Option, + _subscriptions: Vec, + update_task: Task<()>, + outline_fetch_tasks: Vec>, + outlines: HashMap>, + cached_entries_with_depth: Option>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum EntryOwned { + Entry(FsEntry), + FoldedDirs(WorktreeId, Vec), + Outline(OutlinesContainer, Outline), +} + +impl EntryOwned { + fn to_ref_entry(&self) -> EntryRef<'_> { + match self { + Self::Entry(entry) => EntryRef::Entry(entry), + Self::FoldedDirs(worktree_id, dirs) => EntryRef::FoldedDirs(*worktree_id, dirs), + Self::Outline(container, outline) => EntryRef::Outline(*container, outline), + } + } + + fn abs_path(&self, project: &Model, cx: &AppContext) -> Option { + match self { + Self::Entry(entry) => entry.abs_path(project, cx), + Self::FoldedDirs(worktree_id, dirs) => dirs.last().and_then(|entry| { + project + .read(cx) + .worktree_for_id(*worktree_id, cx) + .and_then(|worktree| worktree.read(cx).absolutize(&entry.path).ok()) + }), + Self::Outline(..) => None, + } + } + + fn outlines_container(&self) -> Option { + match self { + Self::Entry(entry) => entry.outlines_container(), + Self::FoldedDirs(..) => None, + Self::Outline(container, _) => Some(*container), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EntryRef<'a> { + Entry(&'a FsEntry), + FoldedDirs(WorktreeId, &'a [Entry]), + Outline(OutlinesContainer, &'a Outline), +} + +impl EntryRef<'_> { + fn to_owned_entry(&self) -> EntryOwned { + match self { + &Self::Entry(entry) => EntryOwned::Entry(entry.clone()), + &Self::FoldedDirs(worktree_id, dirs) => { + EntryOwned::FoldedDirs(worktree_id, dirs.to_vec()) + } + &Self::Outline(container, outline) => EntryOwned::Outline(container, outline.clone()), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum OutlinesContainer { + ExternalFile(BufferId), + File(WorktreeId, ProjectEntryId), +} + +#[derive(Clone, Debug, Eq)] +enum FsEntry { + ExternalFile(BufferId), + Directory(WorktreeId, Entry), + File(WorktreeId, Entry), +} + +impl PartialEq for FsEntry { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::ExternalFile(id_a), Self::ExternalFile(id_b)) => id_a == id_b, + (Self::Directory(id_a, entry_a), Self::Directory(id_b, entry_b)) => { + id_a == id_b && entry_a.id == entry_b.id + } + (Self::File(worktree_a, entry_a), Self::File(worktree_b, entry_b)) => { + worktree_a == worktree_b && entry_a.id == entry_b.id + } + _ => false, + } + } +} + +impl FsEntry { + fn abs_path(&self, project: &Model, cx: &AppContext) -> Option { + match self { + Self::ExternalFile(buffer_id) => project + .read(cx) + .buffer_for_id(*buffer_id) + .and_then(|buffer| File::from_dyn(buffer.read(cx).file())) + .and_then(|file| file.worktree.read(cx).absolutize(&file.path).ok()), + Self::Directory(worktree_id, entry) => project + .read(cx) + .worktree_for_id(*worktree_id, cx)? + .read(cx) + .absolutize(&entry.path) + .ok(), + Self::File(worktree_id, entry) => project + .read(cx) + .worktree_for_id(*worktree_id, cx)? + .read(cx) + .absolutize(&entry.path) + .ok(), + } + } + + fn relative_path<'a>( + &'a self, + project: &Model, + cx: &'a AppContext, + ) -> Option<&'a Path> { + match self { + Self::ExternalFile(buffer_id) => project + .read(cx) + .buffer_for_id(*buffer_id) + .and_then(|buffer| buffer.read(cx).file()) + .map(|file| file.path().as_ref()), + Self::Directory(_, entry) => Some(entry.path.as_ref()), + Self::File(_, entry) => Some(entry.path.as_ref()), + } + } + + fn outlines_container(&self) -> Option { + match self { + Self::ExternalFile(buffer_id) => Some(OutlinesContainer::ExternalFile(*buffer_id)), + Self::File(worktree_id, entry) => Some(OutlinesContainer::File(*worktree_id, entry.id)), + Self::Directory(..) => None, + } + } +} + +struct ActiveItem { + item_id: EntityId, + active_editor: WeakView, + _editor_subscrpiption: Option, +} + +#[derive(Debug)] +pub enum Event { + Focus, +} + +#[derive(Serialize, Deserialize)] +struct SerializedOutlinePanel { + width: Option, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct EntryDetails { + filename: String, + icon: Option>, + path: Arc, + depth: usize, + kind: EntryKind, + is_ignored: bool, + is_expanded: bool, + is_selected: bool, + git_status: Option, + is_private: bool, + worktree_id: WorktreeId, + canonical_path: Option, +} + +pub fn init_settings(cx: &mut AppContext) { + OutlinePanelSettings::register(cx); +} + +pub fn init(assets: impl AssetSource, cx: &mut AppContext) { + init_settings(cx); + file_icons::init(assets, cx); + + cx.observe_new_views(|workspace: &mut Workspace, _| { + workspace.register_action(|workspace, _: &ToggleFocus, cx| { + workspace.toggle_panel_focus::(cx); + }); + }) + .detach(); +} + +impl OutlinePanel { + pub async fn load( + workspace: WeakView, + mut cx: AsyncWindowContext, + ) -> anyhow::Result> { + let serialized_panel = cx + .background_executor() + .spawn(async move { KEY_VALUE_STORE.read_kvp(OUTLINE_PANEL_KEY) }) + .await + .context("loading outline panel") + .log_err() + .flatten() + .map(|panel| serde_json::from_str::(&panel)) + .transpose() + .log_err() + .flatten(); + + workspace.update(&mut cx, |workspace, cx| { + let panel = Self::new(workspace, cx); + if let Some(serialized_panel) = serialized_panel { + panel.update(cx, |panel, cx| { + panel.width = serialized_panel.width.map(|px| px.round()); + cx.notify(); + }); + } + panel + }) + } + + fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> View { + let project = workspace.project().clone(); + let outline_panel = cx.new_view(|cx| { + let focus_handle = cx.focus_handle(); + let focus_subscription = cx.on_focus(&focus_handle, Self::focus_in); + let workspace_subscription = cx.subscribe( + &workspace + .weak_handle() + .upgrade() + .expect("have a &mut Workspace"), + move |outline_panel, workspace, event, cx| { + if let workspace::Event::ActiveItemChanged = event { + if let Some(new_active_editor) = workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + { + let active_editor_updated = outline_panel + .active_item + .as_ref() + .map_or(true, |active_item| { + active_item.item_id != new_active_editor.item_id() + }); + if active_editor_updated { + outline_panel.replace_visible_entries(new_active_editor, cx); + } + } else { + outline_panel.clear_previous(); + cx.notify(); + } + } + }, + ); + + let icons_subscription = cx.observe_global::(|_, cx| { + cx.notify(); + }); + + let mut outline_panel_settings = *OutlinePanelSettings::get_global(cx); + let settings_subscription = cx.observe_global::(move |_, cx| { + let new_settings = *OutlinePanelSettings::get_global(cx); + if outline_panel_settings != new_settings { + outline_panel_settings = new_settings; + cx.notify(); + } + }); + + let mut outline_panel = Self { + active: false, + project: project.clone(), + fs: workspace.app_state().fs.clone(), + scroll_handle: UniformListScrollHandle::new(), + focus_handle, + fs_entries: Vec::new(), + fs_entries_depth: HashMap::default(), + collapsed_dirs: HashMap::default(), + unfolded_dirs: HashMap::default(), + selected_entry: None, + context_menu: None, + width: None, + active_item: None, + pending_serialization: Task::ready(None), + update_task: Task::ready(()), + outline_fetch_tasks: Vec::new(), + outlines: HashMap::default(), + last_visible_range: 0..0, + cached_entries_with_depth: None, + _subscriptions: vec![ + settings_subscription, + icons_subscription, + focus_subscription, + workspace_subscription, + ], + }; + if let Some(editor) = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + { + outline_panel.replace_visible_entries(editor, cx); + } + outline_panel + }); + + outline_panel + } + + fn serialize(&mut self, cx: &mut ViewContext) { + let width = self.width; + self.pending_serialization = cx.background_executor().spawn( + async move { + KEY_VALUE_STORE + .write_kvp( + OUTLINE_PANEL_KEY.into(), + serde_json::to_string(&SerializedOutlinePanel { width })?, + ) + .await?; + anyhow::Ok(()) + } + .log_err(), + ); + } + + fn dispatch_context(&self, _: &ViewContext) -> KeyContext { + let mut dispatch_context = KeyContext::new_with_defaults(); + dispatch_context.add("OutlinePanel"); + dispatch_context.add("menu"); + dispatch_context + } + + fn unfold_directory(&mut self, _: &UnfoldDirectory, cx: &mut ViewContext) { + let Some(editor) = self + .active_item + .as_ref() + .and_then(|item| item.active_editor.upgrade()) + else { + return; + }; + if let Some(EntryOwned::FoldedDirs(worktree_id, entries)) = &self.selected_entry { + self.unfolded_dirs + .entry(*worktree_id) + .or_default() + .extend(entries.iter().map(|entry| entry.id)); + self.update_fs_entries(&editor, HashSet::default(), None, None, false, cx); + } + } + + fn fold_directory(&mut self, _: &FoldDirectory, cx: &mut ViewContext) { + let Some(editor) = self + .active_item + .as_ref() + .and_then(|item| item.active_editor.upgrade()) + else { + return; + }; + + let (worktree_id, entry) = match &self.selected_entry { + Some(EntryOwned::Entry(FsEntry::Directory(worktree_id, entry))) => { + (worktree_id, Some(entry)) + } + Some(EntryOwned::FoldedDirs(worktree_id, entries)) => (worktree_id, entries.last()), + _ => return, + }; + let Some(entry) = entry else { + return; + }; + let unfolded_dirs = self.unfolded_dirs.get_mut(worktree_id); + let worktree = self + .project + .read(cx) + .worktree_for_id(*worktree_id, cx) + .map(|w| w.read(cx).snapshot()); + let Some((worktree, unfolded_dirs)) = worktree.zip(unfolded_dirs) else { + return; + }; + + unfolded_dirs.remove(&entry.id); + let mut parent = entry.path.parent(); + while let Some(parent_path) = parent { + let removed = worktree.entry_for_path(parent_path).map_or(false, |entry| { + if worktree.root_entry().map(|entry| entry.id) == Some(entry.id) { + false + } else { + unfolded_dirs.remove(&entry.id) + } + }); + + if removed { + parent = parent_path.parent(); + } else { + break; + } + } + for child_dir in worktree + .child_entries(&entry.path) + .filter(|entry| entry.is_dir()) + { + let removed = unfolded_dirs.remove(&child_dir.id); + if !removed { + break; + } + } + + self.update_fs_entries(&editor, HashSet::default(), None, None, false, cx); + } + + fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + if let Some(selected_entry) = &self.selected_entry { + let outline_to_select = match selected_entry { + EntryOwned::Entry(entry) => entry.outlines_container().and_then(|container| { + let next_outline = self.outlines.get(&container)?.first()?.clone(); + Some((container, next_outline)) + }), + EntryOwned::FoldedDirs(..) => None, + EntryOwned::Outline(container, outline) => self + .outlines + .get(container) + .and_then(|outlines| { + outlines.iter().skip_while(|o| o != &outline).skip(1).next() + }) + .map(|outline| (*container, outline.clone())), + } + .map(|(container, outline)| EntryOwned::Outline(container, outline)); + + let entry_to_select = outline_to_select.or_else(|| { + match selected_entry { + EntryOwned::Entry(entry) => self + .fs_entries + .iter() + .skip_while(|e| e != &entry) + .skip(1) + .next(), + EntryOwned::FoldedDirs(worktree_id, dirs) => self + .fs_entries + .iter() + .skip_while(|e| { + if let FsEntry::Directory(dir_worktree_id, dir_entry) = e { + dir_worktree_id != worktree_id || dirs.last() != Some(dir_entry) + } else { + true + } + }) + .skip(1) + .next(), + EntryOwned::Outline(container, _) => self + .fs_entries + .iter() + .skip_while(|entry| entry.outlines_container().as_ref() != Some(container)) + .skip(1) + .next(), + } + .cloned() + .map(EntryOwned::Entry) + }); + + if let Some(entry_to_select) = entry_to_select { + self.selected_entry = Some(entry_to_select); + self.autoscroll(cx); + cx.notify(); + } + } else { + self.select_first(&SelectFirst {}, cx) + } + } + + fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { + if let Some(selected_entry) = &self.selected_entry { + let outline_to_select = match selected_entry { + EntryOwned::Entry(entry) => { + let previous_entry = self + .fs_entries + .iter() + .rev() + .skip_while(|e| e != &entry) + .skip(1) + .next(); + previous_entry + .and_then(|entry| entry.outlines_container()) + .and_then(|container| { + let previous_outline = self.outlines.get(&container)?.last()?.clone(); + Some((container, previous_outline)) + }) + } + EntryOwned::FoldedDirs(worktree_id, dirs) => { + let previous_entry = self + .fs_entries + .iter() + .rev() + .skip_while(|e| { + if let FsEntry::Directory(dir_worktree_id, dir_entry) = e { + dir_worktree_id != worktree_id || dirs.first() != Some(dir_entry) + } else { + true + } + }) + .skip(1) + .next(); + previous_entry + .and_then(|entry| entry.outlines_container()) + .and_then(|container| { + let previous_outline = self.outlines.get(&container)?.last()?.clone(); + Some((container, previous_outline)) + }) + } + EntryOwned::Outline(container, outline) => self + .outlines + .get(container) + .and_then(|outlines| { + outlines + .iter() + .rev() + .skip_while(|o| o != &outline) + .skip(1) + .next() + }) + .map(|outline| (*container, outline.clone())), + } + .map(|(container, outline)| EntryOwned::Outline(container, outline)); + + let entry_to_select = outline_to_select.or_else(|| { + match selected_entry { + EntryOwned::Entry(entry) => self + .fs_entries + .iter() + .rev() + .skip_while(|e| e != &entry) + .skip(1) + .next(), + EntryOwned::FoldedDirs(worktree_id, dirs) => self + .fs_entries + .iter() + .rev() + .skip_while(|e| { + if let FsEntry::Directory(dir_worktree_id, dir_entry) = e { + dir_worktree_id != worktree_id || dirs.first() != Some(dir_entry) + } else { + true + } + }) + .skip(1) + .next(), + EntryOwned::Outline(container, _) => self + .fs_entries + .iter() + .rev() + .find(|entry| entry.outlines_container().as_ref() == Some(container)), + } + .cloned() + .map(EntryOwned::Entry) + }); + + if let Some(entry_to_select) = entry_to_select { + self.selected_entry = Some(entry_to_select); + self.autoscroll(cx); + cx.notify(); + } + } else { + self.select_first(&SelectFirst {}, cx); + } + } + + fn select_parent(&mut self, _: &SelectParent, cx: &mut ViewContext) { + if let Some(selected_entry) = &self.selected_entry { + let parent_entry = match selected_entry { + EntryOwned::Entry(entry) => self + .fs_entries + .iter() + .rev() + .skip_while(|e| e != &entry) + .skip(1) + .find(|entry_before_current| match (entry, entry_before_current) { + ( + FsEntry::File(worktree_id, entry) + | FsEntry::Directory(worktree_id, entry), + FsEntry::Directory(parent_worktree_id, parent_entry), + ) => { + parent_worktree_id == worktree_id + && directory_contains(parent_entry, entry) + } + _ => false, + }), + EntryOwned::FoldedDirs(worktree_id, dirs) => self + .fs_entries + .iter() + .rev() + .skip_while(|e| { + if let FsEntry::Directory(dir_worktree_id, dir_entry) = e { + dir_worktree_id != worktree_id || dirs.first() != Some(dir_entry) + } else { + true + } + }) + .skip(1) + .find( + |entry_before_current| match (dirs.first(), entry_before_current) { + (Some(entry), FsEntry::Directory(parent_worktree_id, parent_entry)) => { + parent_worktree_id == worktree_id + && directory_contains(parent_entry, entry) + } + _ => false, + }, + ), + EntryOwned::Outline(container, _) => self + .fs_entries + .iter() + .find(|entry| entry.outlines_container().as_ref() == Some(container)), + } + .cloned() + .map(EntryOwned::Entry); + if let Some(parent_entry) = parent_entry { + self.selected_entry = Some(parent_entry); + self.autoscroll(cx); + cx.notify(); + } + } else { + self.select_first(&SelectFirst {}, cx); + } + } + + fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext) { + if let Some(first_entry) = self.fs_entries.first().cloned().map(EntryOwned::Entry) { + self.selected_entry = Some(first_entry); + self.autoscroll(cx); + cx.notify(); + } + } + + fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext) { + if let Some(new_selection) = self.fs_entries.last().map(|last_entry| { + last_entry + .outlines_container() + .and_then(|container| { + let outline = self.outlines.get(&container)?.last()?; + Some((container, outline.clone())) + }) + .map(|(container, outline)| EntryOwned::Outline(container, outline)) + .unwrap_or_else(|| EntryOwned::Entry(last_entry.clone())) + }) { + self.selected_entry = Some(new_selection); + self.autoscroll(cx); + cx.notify(); + } + } + + fn autoscroll(&mut self, cx: &mut ViewContext) { + if let Some(selected_entry) = self.selected_entry.clone() { + let index = self + .entries_with_depths(cx) + .iter() + .position(|(_, entry)| entry == &selected_entry); + if let Some(index) = index { + self.scroll_handle.scroll_to_item(index); + cx.notify(); + } + } + } + + fn focus_in(&mut self, cx: &mut ViewContext) { + if !self.focus_handle.contains_focused(cx) { + cx.emit(Event::Focus); + } + } + + fn deploy_context_menu( + &mut self, + position: Point, + entry: EntryRef<'_>, + cx: &mut ViewContext, + ) { + self.selected_entry = Some(entry.to_owned_entry()); + let is_root = match entry { + EntryRef::Entry(FsEntry::File(worktree_id, entry)) + | EntryRef::Entry(FsEntry::Directory(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), + EntryRef::FoldedDirs(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), + EntryRef::Entry(FsEntry::ExternalFile(..)) => false, + EntryRef::Outline(_, _) => { + cx.notify(); + return; + } + }; + let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; + let is_foldable = auto_fold_dirs && !is_root && self.is_foldable(entry); + let is_unfoldable = auto_fold_dirs && !is_root && self.is_unfoldable(entry); + + let context_menu = ContextMenu::build(cx, |menu, _| { + menu.context(self.focus_handle.clone()) + .action("Copy Relative Path", Box::new(CopyRelativePath)) + .action("Reveal in Finder", Box::new(RevealInFinder)) + .action("Open in Terminal", Box::new(OpenInTerminal)) + .when(is_unfoldable, |menu| { + menu.action("Unfold Directory", Box::new(UnfoldDirectory)) + }) + .when(is_foldable, |menu| { + menu.action("Fold Directory", Box::new(FoldDirectory)) + }) + .separator() + .action("Copy Path", Box::new(CopyPath)) + .action("Copy Relative Path", Box::new(CopyRelativePath)) + }); + cx.focus_view(&context_menu); + let subscription = cx.subscribe(&context_menu, |outline_panel, _, _: &DismissEvent, cx| { + outline_panel.context_menu.take(); + cx.notify(); + }); + self.context_menu = Some((context_menu, position, subscription)); + cx.notify(); + } + + fn is_unfoldable(&self, entry: EntryRef) -> bool { + matches!(entry, EntryRef::FoldedDirs(..)) + } + + fn is_foldable(&self, entry: EntryRef) -> bool { + let (directory_worktree, directory_entry) = match entry { + EntryRef::Entry(FsEntry::Directory(directory_worktree, directory_entry)) => { + (*directory_worktree, Some(directory_entry)) + } + EntryRef::FoldedDirs(directory_worktree, entries) => { + (directory_worktree, entries.last()) + } + _ => return false, + }; + let Some(directory_entry) = directory_entry else { + return false; + }; + + if self + .unfolded_dirs + .get(&directory_worktree) + .map_or(false, |unfolded_dirs| { + unfolded_dirs.contains(&directory_entry.id) + }) + { + return true; + } + + let child_entries = self + .fs_entries + .iter() + .skip_while(|entry| { + if let FsEntry::Directory(worktree_id, entry) = entry { + worktree_id != &directory_worktree || entry.id != directory_entry.id + } else { + true + } + }) + .skip(1) + .filter(|next_entry| match next_entry { + FsEntry::ExternalFile(_) => false, + FsEntry::Directory(worktree_id, entry) | FsEntry::File(worktree_id, entry) => { + worktree_id == &directory_worktree + && entry.path.parent() == Some(directory_entry.path.as_ref()) + } + }) + .collect::>(); + + child_entries.len() == 1 && matches!(child_entries.first(), Some(FsEntry::Directory(..))) + } + + fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext) { + let Some(editor) = self + .active_item + .as_ref() + .and_then(|item| item.active_editor.upgrade()) + else { + return; + }; + if let Some(EntryOwned::Entry(FsEntry::Directory(worktree_id, selected_dir_entry))) = + &self.selected_entry + { + let expanded = self + .collapsed_dirs + .get_mut(worktree_id) + .map_or(false, |hidden_dirs| { + hidden_dirs.remove(&selected_dir_entry.id) + }); + if expanded { + self.project.update(cx, |project, cx| { + project.expand_entry(*worktree_id, selected_dir_entry.id, cx); + }); + self.update_fs_entries(&editor, HashSet::default(), None, None, false, cx); + } else { + self.select_next(&SelectNext, cx) + } + } + } + + fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext) { + let Some(editor) = self + .active_item + .as_ref() + .and_then(|item| item.active_editor.upgrade()) + else { + return; + }; + if let Some( + dir_entry @ EntryOwned::Entry(FsEntry::Directory(worktree_id, selected_dir_entry)), + ) = &self.selected_entry + { + self.collapsed_dirs + .entry(*worktree_id) + .or_default() + .insert(selected_dir_entry.id); + self.update_fs_entries( + &editor, + HashSet::default(), + Some(dir_entry.clone()), + None, + false, + cx, + ); + } + } + + pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext) { + let Some(editor) = self + .active_item + .as_ref() + .and_then(|item| item.active_editor.upgrade()) + else { + return; + }; + + self.fs_entries_depth + .iter() + .filter(|(_, &(is_dir, depth))| is_dir && depth == 0) + .for_each(|(&(worktree_id, entry_id), _)| { + self.collapsed_dirs + .entry(worktree_id) + .or_default() + .insert(entry_id); + }); + self.update_fs_entries(&editor, HashSet::default(), None, None, false, cx); + } + + fn toggle_expanded(&mut self, entry: &EntryOwned, cx: &mut ViewContext) { + let Some(editor) = self + .active_item + .as_ref() + .and_then(|item| item.active_editor.upgrade()) + else { + return; + }; + + match entry { + EntryOwned::Entry(FsEntry::Directory(worktree_id, dir_entry)) => { + let entry_id = dir_entry.id; + match self.collapsed_dirs.entry(*worktree_id) { + hash_map::Entry::Occupied(mut o) => { + let collapsed_dir_ids = o.get_mut(); + if collapsed_dir_ids.remove(&entry_id) { + 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 { + collapsed_dir_ids.insert(entry_id); + } + } + hash_map::Entry::Vacant(v) => { + v.insert(BTreeSet::new()).insert(entry_id); + } + } + } + EntryOwned::FoldedDirs(worktree_id, dir_entries) => { + if let Some(entry_id) = dir_entries.first().map(|entry| entry.id) { + match self.collapsed_dirs.entry(*worktree_id) { + hash_map::Entry::Occupied(mut o) => { + let collapsed_dir_ids = o.get_mut(); + if collapsed_dir_ids.remove(&entry_id) { + 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 { + collapsed_dir_ids.insert(entry_id); + } + } + hash_map::Entry::Vacant(v) => { + v.insert(BTreeSet::new()).insert(entry_id); + } + } + } + } + _ => return, + } + + self.update_fs_entries( + &editor, + HashSet::default(), + Some(entry.clone()), + None, + false, + cx, + ); + } + + fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext) { + if let Some(clipboard_text) = self + .selected_entry + .as_ref() + .and_then(|entry| entry.abs_path(&self.project, cx)) + .map(|p| p.to_string_lossy().to_string()) + { + cx.write_to_clipboard(ClipboardItem::new(clipboard_text)); + } + } + + fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext) { + if let Some(clipboard_text) = self + .selected_entry + .as_ref() + .and_then(|entry| match entry { + EntryOwned::Entry(entry) => entry.relative_path(&self.project, cx), + EntryOwned::FoldedDirs(_, dirs) => dirs.last().map(|entry| entry.path.as_ref()), + EntryOwned::Outline(..) => None, + }) + .map(|p| p.to_string_lossy().to_string()) + { + cx.write_to_clipboard(ClipboardItem::new(clipboard_text)); + } + } + + fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext) { + if let Some(abs_path) = self + .selected_entry + .as_ref() + .and_then(|entry| entry.abs_path(&self.project, cx)) + { + cx.reveal_path(&abs_path); + } + } + + fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext) { + let selected_entry = self.selected_entry.as_ref(); + let abs_path = selected_entry.and_then(|entry| entry.abs_path(&self.project, cx)); + let working_directory = if let ( + Some(abs_path), + Some(EntryOwned::Entry(FsEntry::File(..) | FsEntry::ExternalFile(..))), + ) = (&abs_path, selected_entry) + { + abs_path.parent().map(|p| p.to_owned()) + } else { + abs_path + }; + + if let Some(working_directory) = working_directory { + cx.dispatch_action(workspace::OpenTerminal { working_directory }.boxed_clone()) + } + } + + fn reveal_entry_for_selection( + &mut self, + editor: &View, + cx: &mut ViewContext<'_, Self>, + ) { + let Some((container, outline_item)) = self.location_for_editor_selection(editor, cx) else { + return; + }; + + let file_entry_to_expand = self + .fs_entries + .iter() + .find(|entry| match (entry, &container) { + ( + FsEntry::ExternalFile(buffer_id), + OutlinesContainer::ExternalFile(container_buffer_id), + ) => buffer_id == container_buffer_id, + ( + FsEntry::File(file_worktree_id, file_entry), + OutlinesContainer::File(worktree_id, id), + ) => file_worktree_id == worktree_id && &file_entry.id == id, + _ => false, + }); + let Some(entry_to_select) = outline_item + .map(|outline| EntryOwned::Outline(container, outline)) + .or_else(|| Some(EntryOwned::Entry(file_entry_to_expand.cloned()?))) + else { + return; + }; + + if self.selected_entry.as_ref() == Some(&entry_to_select) { + return; + } + + if let Some(FsEntry::File(file_worktree_id, file_entry)) = file_entry_to_expand { + if let Some(worktree) = self.project.read(cx).worktree_for_id(*file_worktree_id, cx) { + let parent_entry = { + let mut traversal = worktree.read(cx).traverse_from_path( + true, + true, + true, + file_entry.path.as_ref(), + ); + if traversal.back_to_parent() { + traversal.entry() + } else { + None + } + .cloned() + }; + if let Some(directory_entry) = parent_entry { + self.expand_entry(worktree.read(cx).id(), directory_entry.id, cx); + } + } + } + + self.update_fs_entries( + &editor, + HashSet::default(), + Some(entry_to_select), + None, + false, + cx, + ); + } + + fn expand_entry( + &mut self, + worktree_id: WorktreeId, + entry_id: ProjectEntryId, + cx: &mut AppContext, + ) { + if let Some(collapsed_dir_ids) = self.collapsed_dirs.get_mut(&worktree_id) { + if collapsed_dir_ids.remove(&entry_id) { + 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) + } + } + } + + fn render_outline( + &self, + container: OutlinesContainer, + rendered_outline: &Outline, + depth: usize, + cx: &mut ViewContext, + ) -> Stateful
{ + let (item_id, label_element) = ( + ElementId::from(SharedString::from(format!( + "{:?}|{:?}", + rendered_outline.range, &rendered_outline.text, + ))), + language::render_item(&rendered_outline, None, cx).into_any_element(), + ); + let is_active = match &self.selected_entry { + Some(EntryOwned::Outline(selected_container, selected_entry)) => { + selected_container == &container && selected_entry == rendered_outline + } + _ => false, + }; + + self.entry_element( + EntryRef::Outline(container, rendered_outline), + item_id, + depth, + None, + is_active, + label_element, + cx, + ) + } + + fn render_entry( + &self, + rendered_entry: &FsEntry, + depth: usize, + cx: &mut ViewContext, + ) -> Stateful
{ + let settings = OutlinePanelSettings::get_global(cx); + let is_active = match &self.selected_entry { + Some(EntryOwned::Entry(selected_entry)) => selected_entry == rendered_entry, + _ => false, + }; + let (item_id, label_element, icon) = match rendered_entry { + FsEntry::File(worktree_id, entry) => { + let name = self.entry_name(worktree_id, entry, cx); + let color = + entry_git_aware_label_color(entry.git_status, entry.is_ignored, is_active); + let icon = if settings.file_icons { + FileIcons::get_icon(&entry.path, cx) + } else { + None + } + .map(Icon::from_path) + .map(|icon| icon.color(color)); + ( + ElementId::from(entry.id.to_proto() as usize), + Label::new(name) + .single_line() + .color(color) + .into_any_element(), + icon, + ) + } + FsEntry::Directory(worktree_id, entry) => { + let name = self.entry_name(worktree_id, entry, cx); + + let is_expanded = self + .collapsed_dirs + .get(worktree_id) + .map_or(true, |ids| !ids.contains(&entry.id)); + let color = + entry_git_aware_label_color(entry.git_status, entry.is_ignored, is_active); + let icon = if settings.folder_icons { + FileIcons::get_folder_icon(is_expanded, cx) + } else { + FileIcons::get_chevron_icon(is_expanded, cx) + } + .map(Icon::from_path) + .map(|icon| icon.color(color)); + ( + ElementId::from(entry.id.to_proto() as usize), + Label::new(name) + .single_line() + .color(color) + .into_any_element(), + icon, + ) + } + FsEntry::ExternalFile(buffer_id) => { + let color = entry_label_color(is_active); + let (icon, name) = match self.project.read(cx).buffer_for_id(*buffer_id) { + Some(buffer) => match buffer.read(cx).file() { + Some(file) => { + let path = file.path(); + let icon = if settings.file_icons { + FileIcons::get_icon(path.as_ref(), cx) + } else { + None + } + .map(Icon::from_path) + .map(|icon| icon.color(color)); + (icon, file_name(path.as_ref())) + } + None => (None, "Untitled".to_string()), + }, + None => (None, "Unknown buffer".to_string()), + }; + ( + ElementId::from(buffer_id.to_proto() as usize), + Label::new(name) + .single_line() + .color(color) + .into_any_element(), + icon, + ) + } + }; + + self.entry_element( + EntryRef::Entry(rendered_entry), + item_id, + depth, + icon, + is_active, + label_element, + cx, + ) + } + + fn render_folded_dirs( + &self, + worktree_id: WorktreeId, + dir_entries: &[Entry], + depth: usize, + cx: &mut ViewContext, + ) -> Stateful
{ + let settings = OutlinePanelSettings::get_global(cx); + let is_active = match &self.selected_entry { + Some(EntryOwned::FoldedDirs(selected_worktree_id, selected_entries)) => { + selected_worktree_id == &worktree_id && selected_entries == dir_entries + } + _ => false, + }; + let (item_id, label_element, icon) = { + let name = dir_entries.iter().fold(String::new(), |mut name, entry| { + if !name.is_empty() { + name.push(std::path::MAIN_SEPARATOR) + } + name.push_str(&self.entry_name(&worktree_id, entry, cx)); + name + }); + + let is_expanded = + self.collapsed_dirs + .get(&worktree_id) + .map_or(true, |collapsed_dirs| { + dir_entries + .iter() + .all(|dir| !collapsed_dirs.contains(&dir.id)) + }); + let is_ignored = dir_entries.iter().any(|entry| entry.is_ignored); + let git_status = dir_entries.first().and_then(|entry| entry.git_status); + let color = entry_git_aware_label_color(git_status, is_ignored, is_active); + let icon = if settings.folder_icons { + FileIcons::get_folder_icon(is_expanded, cx) + } else { + FileIcons::get_chevron_icon(is_expanded, cx) + } + .map(Icon::from_path) + .map(|icon| icon.color(color)); + ( + ElementId::from( + dir_entries + .last() + .map(|entry| entry.id.to_proto()) + .unwrap_or_else(|| worktree_id.to_proto()) as usize, + ), + Label::new(name) + .single_line() + .color(color) + .into_any_element(), + icon, + ) + }; + + self.entry_element( + EntryRef::FoldedDirs(worktree_id, dir_entries), + item_id, + depth, + icon, + is_active, + label_element, + cx, + ) + } + + #[allow(clippy::too_many_arguments)] + fn entry_element( + &self, + rendered_entry: EntryRef<'_>, + item_id: ElementId, + depth: usize, + icon: Option, + is_active: bool, + label_element: gpui::AnyElement, + cx: &mut ViewContext, + ) -> Stateful
{ + let settings = OutlinePanelSettings::get_global(cx); + let rendered_entry = rendered_entry.to_owned_entry(); + div() + .id(item_id.clone()) + .child( + ListItem::new(item_id) + .indent_level(depth) + .indent_step_size(px(settings.indent_size)) + .selected(is_active) + .child(if let Some(icon) = icon { + h_flex().child(icon) + } else { + h_flex() + .size(IconSize::default().rems()) + .invisible() + .flex_none() + }) + .child(h_flex().h_6().child(label_element).ml_1()) + .on_click({ + let clicked_entry = rendered_entry.clone(); + cx.listener(move |outline_panel, event: &gpui::ClickEvent, cx| { + if event.down.button == MouseButton::Right || event.down.first_mouse { + return; + } + + let Some(active_editor) = outline_panel + .active_item + .as_ref() + .and_then(|item| item.active_editor.upgrade()) + else { + return; + }; + let active_multi_buffer = active_editor.read(cx).buffer().clone(); + let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx); + + match &clicked_entry { + EntryOwned::Entry(FsEntry::ExternalFile(buffer_id)) => { + let scroll_target = multi_buffer_snapshot.excerpts().find_map( + |(excerpt_id, buffer_snapshot, excerpt_range)| { + if &buffer_snapshot.remote_id() == buffer_id { + multi_buffer_snapshot.anchor_in_excerpt( + excerpt_id, + excerpt_range.context.start, + ) + } else { + None + } + }, + ); + if let Some(anchor) = scroll_target { + outline_panel.selected_entry = Some(clicked_entry.clone()); + active_editor.update(cx, |editor, cx| { + editor.set_scroll_anchor( + ScrollAnchor { + offset: Point::new( + 0.0, + -(editor.file_header_size() as f32), + ), + anchor, + }, + cx, + ); + }) + } + } + entry @ EntryOwned::Entry(FsEntry::Directory(..)) => { + outline_panel.toggle_expanded(entry, cx); + } + entry @ EntryOwned::FoldedDirs(..) => { + outline_panel.toggle_expanded(entry, cx); + } + EntryOwned::Entry(FsEntry::File(_, file_entry)) => { + let scroll_target = outline_panel + .project + .update(cx, |project, cx| { + project + .path_for_entry(file_entry.id, cx) + .and_then(|path| project.get_open_buffer(&path, cx)) + }) + .map(|buffer| { + active_multi_buffer + .read(cx) + .excerpts_for_buffer(&buffer, cx) + }) + .and_then(|excerpts| { + let (excerpt_id, excerpt_range) = excerpts.first()?; + multi_buffer_snapshot.anchor_in_excerpt( + *excerpt_id, + excerpt_range.context.start, + ) + }); + if let Some(anchor) = scroll_target { + outline_panel.selected_entry = Some(clicked_entry.clone()); + active_editor.update(cx, |editor, cx| { + editor.set_scroll_anchor( + ScrollAnchor { + offset: Point::new( + 0.0, + -(editor.file_header_size() as f32), + ), + anchor, + }, + cx, + ); + }) + } + } + EntryOwned::Outline(_, outline) => { + let Some(full_buffer_snapshot) = outline + .range + .start + .buffer_id + .and_then(|buffer_id| { + active_multi_buffer.read(cx).buffer(buffer_id) + }) + .or_else(|| { + outline.range.end.buffer_id.and_then(|buffer_id| { + active_multi_buffer.read(cx).buffer(buffer_id) + }) + }) + .map(|buffer| buffer.read(cx).snapshot()) + else { + return; + }; + let outline_offset_range = + outline.range.to_offset(&full_buffer_snapshot); + let scroll_target = multi_buffer_snapshot + .excerpts() + .filter(|(_, buffer_snapshot, _)| { + let buffer_id = buffer_snapshot.remote_id(); + Some(buffer_id) == outline.range.start.buffer_id + || Some(buffer_id) == outline.range.end.buffer_id + }) + .min_by_key(|(_, _, excerpt_range)| { + let excerpt_offeset_range = excerpt_range + .context + .to_offset(&full_buffer_snapshot); + ((outline_offset_range.start / 2 + + outline_offset_range.end / 2) + as isize + - (excerpt_offeset_range.start / 2 + + excerpt_offeset_range.end / 2) + as isize) + .abs() + }) + .and_then( + |(excerpt_id, excerpt_snapshot, excerpt_range)| { + let location = if outline + .range + .start + .is_valid(excerpt_snapshot) + { + outline.range.start + } else { + excerpt_range.context.start + }; + multi_buffer_snapshot + .anchor_in_excerpt(excerpt_id, location) + }, + ); + if let Some(anchor) = scroll_target { + outline_panel.selected_entry = Some(clicked_entry.clone()); + active_editor.update(cx, |editor, cx| { + editor.set_scroll_anchor( + ScrollAnchor { + offset: Point::default(), + anchor, + }, + cx, + ); + }) + } + } + } + }) + }) + .on_secondary_mouse_down(cx.listener( + move |outline_panel, event: &MouseDownEvent, cx| { + // Stop propagation to prevent the catch-all context menu for the project + // panel from being deployed. + cx.stop_propagation(); + outline_panel.deploy_context_menu( + event.position, + rendered_entry.to_ref_entry(), + cx, + ) + }, + )), + ) + .border_1() + .border_r_2() + .rounded_none() + .hover(|style| { + if is_active { + style + } else { + let hover_color = cx.theme().colors().ghost_element_hover; + style.bg(hover_color).border_color(hover_color) + } + }) + .when(is_active && self.focus_handle.contains_focused(cx), |div| { + div.border_color(Color::Selected.color(cx)) + }) + } + + fn entry_name( + &self, + worktree_id: &WorktreeId, + entry: &Entry, + cx: &ViewContext, + ) -> String { + let name = 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()).ok(); + let path = path.as_deref().unwrap_or_else(|| entry.path.as_ref()); + file_name(path) + } + } + None => { + let path = worktree.absolutize(entry.path.as_ref()).ok(); + let path = path.as_deref().unwrap_or_else(|| entry.path.as_ref()); + file_name(path) + } + } + } + None => file_name(entry.path.as_ref()), + }; + name + } + + fn update_fs_entries( + &mut self, + active_editor: &View, + new_entries: HashSet, + new_selected_entry: Option, + debounce: Option, + prefetch: bool, + cx: &mut ViewContext, + ) { + if !self.active { + return; + } + + let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; + let active_multi_buffer = active_editor.read(cx).buffer().clone(); + let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx); + let mut new_collapsed_dirs = self.collapsed_dirs.clone(); + let mut new_unfolded_dirs = self.unfolded_dirs.clone(); + let mut root_entries = HashSet::default(); + let excerpts = multi_buffer_snapshot + .excerpts() + .map(|(excerpt_id, buffer_snapshot, _)| { + let file = File::from_dyn(buffer_snapshot.file()); + let entry_id = file.and_then(|file| file.project_entry_id(cx)); + let worktree = file.map(|file| file.worktree.read(cx).snapshot()); + (excerpt_id, buffer_snapshot.remote_id(), entry_id, worktree) + }) + .collect::>(); + + self.update_task = cx.spawn(|outline_panel, mut cx| async move { + if let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; + } + let Some((new_collapsed_dirs, new_unfolded_dirs, new_fs_entries, new_depth_map)) = cx + .background_executor() + .spawn(async move { + let mut processed_external_buffers = HashSet::default(); + let mut new_worktree_entries = + HashMap::)>::default(); + let mut external_entries = Vec::default(); + + for (excerpt_id, buffer_id, file_entry_id, worktree) in excerpts { + let is_new = new_entries.contains(&excerpt_id); + if let Some(worktree) = worktree { + let collapsed_dirs = + new_collapsed_dirs.entry(worktree.id()).or_default(); + let unfolded_dirs = new_unfolded_dirs.entry(worktree.id()).or_default(); + + match file_entry_id + .and_then(|id| worktree.entry_for_id(id)) + .cloned() + { + Some(entry) => { + let mut traversal = worktree.traverse_from_path( + true, + true, + true, + entry.path.as_ref(), + ); + + let mut entries_to_add = HashSet::default(); + 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 { + collapsed_dirs.remove(¤t_entry.id); + } else if collapsed_dirs.contains(¤t_entry.id) { + entries_to_add.clear(); + } + } + + let new_entry_added = entries_to_add.insert(current_entry); + if new_entry_added && traversal.back_to_parent() { + if let Some(parent_entry) = traversal.entry() { + current_entry = parent_entry.clone(); + continue; + } + } + break; + } + new_worktree_entries + .entry(worktree.id()) + .or_insert_with(|| (worktree.clone(), HashSet::default())) + .1 + .extend(entries_to_add); + } + None => { + if processed_external_buffers.insert(buffer_id) { + external_entries.push(FsEntry::ExternalFile(buffer_id)); + } + } + } + } else if processed_external_buffers.insert(buffer_id) { + external_entries.push(FsEntry::ExternalFile(buffer_id)); + } + } + + external_entries.sort_by(|entry_a, entry_b| match (entry_a, entry_b) { + ( + FsEntry::ExternalFile(buffer_id_a), + FsEntry::ExternalFile(buffer_id_b), + ) => buffer_id_a.cmp(&buffer_id_b), + (FsEntry::ExternalFile(..), _) => cmp::Ordering::Less, + (_, FsEntry::ExternalFile(..)) => cmp::Ordering::Greater, + _ => cmp::Ordering::Equal, + }); + + #[derive(Clone, Copy, Default)] + struct Children { + files: usize, + dirs: usize, + } + let mut children_count = + HashMap::>::default(); + + let worktree_entries = new_worktree_entries + .into_iter() + .map(|(worktree_id, (worktree_snapshot, entries))| { + let mut entries = entries.into_iter().collect::>(); + sort_worktree_entries(&mut entries); + worktree_snapshot.propagate_git_statuses(&mut entries); + (worktree_id, entries) + }) + .flat_map(|(worktree_id, entries)| { + { + entries + .into_iter() + .map(|entry| { + if auto_fold_dirs { + if let Some(parent) = entry.path.parent() { + let children = children_count + .entry(worktree_id) + .or_default() + .entry(parent.to_path_buf()) + .or_default(); + if entry.is_dir() { + children.dirs += 1; + } else { + children.files += 1; + } + } + } + + if entry.is_dir() { + FsEntry::Directory(worktree_id, entry) + } else { + FsEntry::File(worktree_id, entry) + } + }) + .collect::>() + } + }) + .collect::>(); + + let mut visited_dirs = Vec::new(); + let mut new_depth_map = HashMap::default(); + let new_visible_entries = external_entries + .into_iter() + .chain(worktree_entries) + .filter(|visible_item| { + match visible_item { + FsEntry::Directory(worktree_id, dir_entry) => { + let parent_id = back_to_common_visited_parent( + &mut visited_dirs, + worktree_id, + dir_entry, + ); + + visited_dirs.push((dir_entry.id, dir_entry.path.clone())); + let depth = if root_entries.contains(&dir_entry.id) { + 0 + } else if auto_fold_dirs { + let (parent_folded, parent_depth) = match parent_id { + Some((worktree_id, id)) => ( + new_unfolded_dirs + .get(&worktree_id) + .map_or(true, |unfolded_dirs| { + !unfolded_dirs.contains(&id) + }), + new_depth_map + .get(&(worktree_id, id)) + .map(|&(_, depth)| depth) + .unwrap_or(0), + ), + + None => (false, 0), + }; + + let children = children_count + .get(&worktree_id) + .and_then(|children_count| { + children_count.get(&dir_entry.path.to_path_buf()) + }) + .copied() + .unwrap_or_default(); + let folded = if children.dirs > 1 + || (children.dirs == 1 && children.files > 0) + || (children.dirs == 0 + && visited_dirs + .last() + .map(|(parent_dir_id, _)| { + root_entries.contains(parent_dir_id) + }) + .unwrap_or(true)) + { + new_unfolded_dirs + .entry(*worktree_id) + .or_default() + .insert(dir_entry.id); + false + } else { + new_unfolded_dirs.get(&worktree_id).map_or( + true, + |unfolded_dirs| { + !unfolded_dirs.contains(&dir_entry.id) + }, + ) + }; + + if parent_folded && folded { + parent_depth + } else { + parent_depth + 1 + } + } else { + parent_id + .and_then(|(worktree_id, id)| { + new_depth_map + .get(&(worktree_id, id)) + .map(|&(_, depth)| depth) + }) + .unwrap_or(0) + + 1 + }; + new_depth_map + .insert((*worktree_id, dir_entry.id), (true, depth)); + } + FsEntry::File(worktree_id, 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)) + .map(|&(_, depth)| depth) + }) + .unwrap_or(0) + + 1 + }; + new_depth_map + .insert((*worktree_id, file_entry.id), (false, depth)); + } + FsEntry::ExternalFile(..) => { + visited_dirs.clear(); + } + } + + true + }) + .collect::>(); + + anyhow::Ok(( + new_collapsed_dirs, + new_unfolded_dirs, + new_visible_entries, + new_depth_map, + )) + }) + .await + .log_err() + else { + return; + }; + + outline_panel + .update(&mut cx, |outline_panel, cx| { + outline_panel.collapsed_dirs = new_collapsed_dirs; + outline_panel.unfolded_dirs = new_unfolded_dirs; + outline_panel.fs_entries = new_fs_entries; + outline_panel.fs_entries_depth = new_depth_map; + outline_panel.cached_entries_with_depth = None; + if new_selected_entry.is_some() { + outline_panel.selected_entry = new_selected_entry; + } + if prefetch { + let range = if outline_panel.last_visible_range.is_empty() { + 0..(outline_panel.entries_with_depths(cx).len() / 4).min(50) + } else { + outline_panel.last_visible_range.clone() + }; + outline_panel.fetch_outlines(&range, cx); + } + + outline_panel.autoscroll(cx); + cx.notify(); + }) + .ok(); + }); + } + + fn replace_visible_entries( + &mut self, + new_active_editor: View, + cx: &mut ViewContext, + ) { + self.clear_previous(); + self.active_item = Some(ActiveItem { + item_id: new_active_editor.item_id(), + _editor_subscrpiption: subscribe_for_editor_events(&new_active_editor, cx), + active_editor: new_active_editor.downgrade(), + }); + let new_entries = + HashSet::from_iter(new_active_editor.read(cx).buffer().read(cx).excerpt_ids()); + self.update_fs_entries(&new_active_editor, new_entries, None, None, true, cx); + } + + fn clear_previous(&mut self) { + self.collapsed_dirs.clear(); + self.unfolded_dirs.clear(); + self.last_visible_range = 0..0; + self.selected_entry = None; + self.update_task = Task::ready(()); + self.active_item = None; + self.fs_entries.clear(); + self.fs_entries_depth.clear(); + self.outline_fetch_tasks.clear(); + self.outlines.clear(); + self.cached_entries_with_depth = None; + } + + fn location_for_editor_selection( + &self, + editor: &View, + cx: &mut ViewContext, + ) -> Option<(OutlinesContainer, Option)> { + let selection = editor + .read(cx) + .selections + .newest::(cx) + .head(); + let multi_buffer = editor.read(cx).buffer(); + let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx); + let selection = multi_buffer_snapshot.anchor_before(selection); + let buffer_snapshot = multi_buffer_snapshot.buffer_for_excerpt(selection.excerpt_id)?; + + let container = match File::from_dyn(buffer_snapshot.file()) + .and_then(|file| Some(file.worktree.read(cx).id()).zip(file.entry_id)) + { + Some((worktree_id, id)) => OutlinesContainer::File(worktree_id, id), + None => OutlinesContainer::ExternalFile(buffer_snapshot.remote_id()), + }; + + let outline_item = self + .outlines + .get(&container) + .into_iter() + .flatten() + .filter(|outline| { + outline.range.start.buffer_id == selection.buffer_id + || outline.range.end.buffer_id == selection.buffer_id + }) + .filter(|outline_item| { + range_contains(&outline_item.range, selection.text_anchor, buffer_snapshot) + }) + .min_by_key(|outline| { + let range = outline.range.start.offset..outline.range.end.offset; + let cursor_offset = selection.text_anchor.offset as isize; + let distance_to_closest_endpoint = cmp::min( + (range.start as isize - cursor_offset).abs(), + (range.end as isize - cursor_offset).abs(), + ); + distance_to_closest_endpoint + }) + .cloned(); + + Some((container, outline_item)) + } + + fn fetch_outlines(&mut self, range: &Range, cx: &mut ViewContext) { + let Some(editor) = self + .active_item + .as_ref() + .and_then(|item| item.active_editor.upgrade()) + else { + return; + }; + + let range_len = range.len(); + let half_range = range_len / 2; + let entries = self.entries_with_depths(cx); + let expanded_range = + range.start.saturating_sub(half_range)..(range.end + half_range).min(entries.len()); + let containers = entries + .get(expanded_range) + .into_iter() + .flatten() + .flat_map(|(_, entry)| entry.outlines_container()) + .collect::>(); + let fetch_outlines_for = containers + .into_iter() + .filter(|container| match self.outlines.entry(*container) { + hash_map::Entry::Occupied(_) => false, + hash_map::Entry::Vacant(v) => { + v.insert(Vec::new()); + true + } + }) + .collect::>(); + + let outlines_to_fetch = editor + .read(cx) + .buffer() + .read(cx) + .snapshot(cx) + .excerpts() + .filter_map(|(_, buffer_snapshot, excerpt_range)| { + let container = match File::from_dyn(buffer_snapshot.file()) { + Some(file) => { + let entry_id = file.project_entry_id(cx); + let worktree_id = file.worktree.read(cx).id(); + entry_id.map(|entry_id| OutlinesContainer::File(worktree_id, entry_id)) + } + None => Some(OutlinesContainer::ExternalFile(buffer_snapshot.remote_id())), + }?; + Some((container, (buffer_snapshot.clone(), excerpt_range))) + }) + .filter(|(container, _)| fetch_outlines_for.contains(container)) + .collect::>(); + if outlines_to_fetch.is_empty() { + return; + } + + let syntax_theme = cx.theme().syntax().clone(); + self.outline_fetch_tasks + .push(cx.spawn(|outline_panel, mut cx| async move { + let mut processed_outlines = + HashMap::>::default(); + let fetched_outlines = cx + .background_executor() + .spawn(async move { + outlines_to_fetch + .into_iter() + .map(|(container, (buffer_snapshot, excerpt_range))| { + ( + container, + buffer_snapshot + .outline_items_containing( + excerpt_range.context, + false, + Some(&syntax_theme), + ) + .unwrap_or_default(), + ) + }) + .fold( + HashMap::default(), + |mut outlines, (container, new_outlines)| { + outlines + .entry(container) + .or_insert_with(Vec::new) + .extend(new_outlines); + outlines + }, + ) + }) + .await; + outline_panel + .update(&mut cx, |outline_panel, cx| { + for (container, fetched_outlines) in fetched_outlines { + let existing_outlines = + outline_panel.outlines.entry(container).or_default(); + let processed_outlines = + processed_outlines.entry(container).or_default(); + processed_outlines.extend(existing_outlines.iter().cloned()); + for fetched_outline in fetched_outlines { + if processed_outlines.insert(fetched_outline.clone()) { + existing_outlines.push(fetched_outline); + } + } + } + outline_panel.cached_entries_with_depth = None; + cx.notify(); + }) + .ok(); + })); + } + + fn entries_with_depths(&mut self, cx: &AppContext) -> &[(usize, EntryOwned)] { + self.cached_entries_with_depth.get_or_insert_with(|| { + let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; + let mut folded_dirs_entry = None::<(usize, WorktreeId, Vec)>; + let mut entries = Vec::new(); + + for entry in &self.fs_entries { + let mut depth = match entry { + FsEntry::Directory(worktree_id, dir_entry) => { + let depth = self + .fs_entries_depth + .get(&(*worktree_id, dir_entry.id)) + .map(|&(_, depth)| depth) + .unwrap_or(0); + if auto_fold_dirs { + let folded = self + .unfolded_dirs + .get(worktree_id) + .map_or(true, |unfolded_dirs| { + !unfolded_dirs.contains(&dir_entry.id) + }); + if folded { + if let Some((folded_depth, folded_worktree_id, mut folded_dirs)) = + folded_dirs_entry.take() + { + if worktree_id == &folded_worktree_id + && dir_entry.path.parent() + == folded_dirs.last().map(|entry| entry.path.as_ref()) + { + folded_dirs.push(dir_entry.clone()); + folded_dirs_entry = + Some((folded_depth, folded_worktree_id, folded_dirs)) + } else { + entries.push(( + folded_depth, + EntryOwned::FoldedDirs(folded_worktree_id, folded_dirs), + )); + folded_dirs_entry = + Some((depth, *worktree_id, vec![dir_entry.clone()])) + } + } else { + folded_dirs_entry = + Some((depth, *worktree_id, vec![dir_entry.clone()])) + } + + continue; + } + } + depth + } + FsEntry::ExternalFile(_) => 0, + FsEntry::File(worktree_id, file_entry) => self + .fs_entries_depth + .get(&(*worktree_id, file_entry.id)) + .map(|&(_, depth)| depth) + .unwrap_or(0), + }; + if let Some((folded_depth, worktree_id, folded_dirs)) = folded_dirs_entry.take() { + entries.push(( + folded_depth, + EntryOwned::FoldedDirs(worktree_id, folded_dirs), + )); + } + + entries.push((depth, EntryOwned::Entry(entry.clone()))); + let mut outline_depth = None::; + entries.extend( + entry + .outlines_container() + .and_then(|container| Some((container, self.outlines.get(&container)?))) + .into_iter() + .flat_map(|(container, outlines)| { + outlines.iter().map(move |outline| (container, outline)) + }) + .map(move |(container, outline)| { + if let Some(outline_depth) = outline_depth { + match outline_depth.cmp(&outline.depth) { + cmp::Ordering::Less => depth += 1, + cmp::Ordering::Equal => {} + cmp::Ordering::Greater => depth -= 1, + }; + } + outline_depth = Some(outline.depth); + (depth, EntryOwned::Outline(container, outline.clone())) + }), + ) + } + if let Some((folded_depth, worktree_id, folded_dirs)) = folded_dirs_entry.take() { + entries.push(( + folded_depth, + EntryOwned::FoldedDirs(worktree_id, folded_dirs), + )); + } + entries + }) + } +} + +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 sort_worktree_entries(entries: &mut Vec) { + entries.sort_by(|entry_a, entry_b| { + let mut components_a = entry_a.path.components().peekable(); + let mut components_b = entry_b.path.components().peekable(); + loop { + match (components_a.next(), components_b.next()) { + (Some(component_a), Some(component_b)) => { + let a_is_file = components_a.peek().is_none() && entry_a.is_file(); + let b_is_file = components_b.peek().is_none() && entry_b.is_file(); + let ordering = a_is_file.cmp(&b_is_file).then_with(|| { + let maybe_numeric_ordering = maybe!({ + let num_and_remainder_a = Path::new(component_a.as_os_str()) + .file_stem() + .and_then(|s| s.to_str()) + .and_then(NumericPrefixWithSuffix::from_numeric_prefixed_str)?; + let num_and_remainder_b = Path::new(component_b.as_os_str()) + .file_stem() + .and_then(|s| s.to_str()) + .and_then(NumericPrefixWithSuffix::from_numeric_prefixed_str)?; + + num_and_remainder_a.partial_cmp(&num_and_remainder_b) + }); + + maybe_numeric_ordering.unwrap_or_else(|| { + let name_a = UniCase::new(component_a.as_os_str().to_string_lossy()); + let name_b = UniCase::new(component_b.as_os_str().to_string_lossy()); + + name_a.cmp(&name_b) + }) + }); + if !ordering.is_eq() { + return ordering; + } + } + (Some(_), None) => break cmp::Ordering::Greater, + (None, Some(_)) => break cmp::Ordering::Less, + (None, None) => break cmp::Ordering::Equal, + } + } + }); +} + +fn file_name(path: &Path) -> String { + let mut current_path = path; + loop { + if let Some(file_name) = current_path.file_name() { + return file_name.to_string_lossy().into_owned(); + } + match current_path.parent() { + Some(parent) => current_path = parent, + None => return path.to_string_lossy().into_owned(), + } + } +} + +fn directory_contains(directory_entry: &Entry, child_entry: &Entry) -> bool { + debug_assert!(directory_entry.is_dir()); + let Some(relative_path) = child_entry.path.strip_prefix(&directory_entry.path).ok() else { + return false; + }; + relative_path.iter().count() == 1 +} + +impl Panel for OutlinePanel { + fn persistent_name() -> &'static str { + "Outline Panel" + } + + fn position(&self, cx: &WindowContext) -> DockPosition { + match OutlinePanelSettings::get_global(cx).dock { + OutlinePanelDockPosition::Left => DockPosition::Left, + OutlinePanelDockPosition::Right => DockPosition::Right, + } + } + + fn position_is_valid(&self, position: DockPosition) -> bool { + matches!(position, DockPosition::Left | DockPosition::Right) + } + + fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext) { + settings::update_settings_file::( + self.fs.clone(), + cx, + move |settings| { + let dock = match position { + DockPosition::Left | DockPosition::Bottom => OutlinePanelDockPosition::Left, + DockPosition::Right => OutlinePanelDockPosition::Right, + }; + settings.dock = Some(dock); + }, + ); + } + + fn size(&self, cx: &WindowContext) -> Pixels { + self.width + .unwrap_or_else(|| OutlinePanelSettings::get_global(cx).default_width) + } + + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { + self.width = size; + self.serialize(cx); + cx.notify(); + } + + fn icon(&self, cx: &WindowContext) -> Option { + OutlinePanelSettings::get_global(cx) + .button + .then(|| IconName::ListTree) + } + + fn icon_tooltip(&self, _: &WindowContext) -> Option<&'static str> { + Some("Outline Panel") + } + + fn toggle_action(&self) -> Box { + Box::new(ToggleFocus) + } + + fn starts_open(&self, _: &WindowContext) -> bool { + self.active_item.is_some() + } + + fn set_active(&mut self, active: bool, cx: &mut ViewContext) { + let old_active = self.active; + self.active = active; + if active && old_active != active { + if let Some(active_editor) = self + .active_item + .as_ref() + .and_then(|item| item.active_editor.upgrade()) + { + self.replace_visible_entries(active_editor, cx); + } + } + } +} + +impl FocusableView for OutlinePanel { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for OutlinePanel {} + +impl EventEmitter for OutlinePanel {} + +impl Render for OutlinePanel { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let project = self.project.read(cx); + if self.fs_entries.is_empty() { + v_flex() + .id("empty-outline_panel") + .size_full() + .p_4() + .track_focus(&self.focus_handle) + .child(Label::new("No editor outlines available")) + } else { + h_flex() + .id("outline-panel") + .size_full() + .relative() + .key_context(self.dispatch_context(cx)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::select_parent)) + .on_action(cx.listener(Self::expand_selected_entry)) + .on_action(cx.listener(Self::collapse_selected_entry)) + .on_action(cx.listener(Self::collapse_all_entries)) + .on_action(cx.listener(Self::copy_path)) + .on_action(cx.listener(Self::copy_relative_path)) + .on_action(cx.listener(Self::unfold_directory)) + .on_action(cx.listener(Self::fold_directory)) + .when(project.is_local(), |el| { + el.on_action(cx.listener(Self::reveal_in_finder)) + .on_action(cx.listener(Self::open_in_terminal)) + }) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |outline_panel, event: &MouseDownEvent, cx| { + if let Some(entry) = outline_panel.selected_entry.clone() { + outline_panel.deploy_context_menu( + event.position, + entry.to_ref_entry(), + cx, + ) + } else if let Some(entry) = outline_panel.fs_entries.first().cloned() { + outline_panel.deploy_context_menu( + event.position, + EntryRef::Entry(&entry), + cx, + ) + } + }), + ) + .track_focus(&self.focus_handle) + .child({ + let items_len = self.entries_with_depths(cx).len(); + uniform_list(cx.view().clone(), "entries", items_len, { + move |outline_panel, range, cx| { + outline_panel.last_visible_range = range.clone(); + outline_panel.fetch_outlines(&range, cx); + outline_panel + .entries_with_depths(cx) + .get(range) + .map(|entries| entries.to_vec()) + .into_iter() + .flatten() + .map(|(depth, dipslayed_item)| match dipslayed_item { + EntryOwned::Entry(entry) => { + outline_panel.render_entry(&entry, depth, cx) + } + EntryOwned::FoldedDirs(worktree_id, entries) => outline_panel + .render_folded_dirs(worktree_id, &entries, depth, cx), + EntryOwned::Outline(container, outline) => { + outline_panel.render_outline(container, &outline, depth, cx) + } + }) + .collect() + } + }) + .size_full() + .track_scroll(self.scroll_handle.clone()) + }) + .children(self.context_menu.as_ref().map(|(menu, position, _)| { + deferred( + anchored() + .position(*position) + .anchor(gpui::AnchorCorner::TopLeft) + .child(menu.clone()), + ) + .with_priority(1) + })) + } + } +} + +fn subscribe_for_editor_events( + editor: &View, + cx: &mut ViewContext, +) -> Option { + if OutlinePanelSettings::get_global(cx).auto_reveal_entries { + let debounce = Some(Duration::from_millis(UPDATE_DEBOUNCE_MILLIS)); + Some(cx.subscribe( + editor, + move |outline_panel, editor, e: &EditorEvent, cx| match e { + EditorEvent::SelectionsChanged { local: true } => { + outline_panel.reveal_entry_for_selection(&editor, cx); + cx.notify(); + } + EditorEvent::ExcerptsAdded { excerpts, .. } => { + outline_panel.update_fs_entries( + &editor, + excerpts.iter().map(|&(excerpt_id, _)| excerpt_id).collect(), + None, + debounce, + false, + cx, + ); + } + EditorEvent::ExcerptsRemoved { .. } => { + outline_panel.update_fs_entries( + &editor, + HashSet::default(), + None, + debounce, + false, + cx, + ); + } + EditorEvent::ExcerptsExpanded { .. } => { + outline_panel.update_fs_entries( + &editor, + HashSet::default(), + None, + debounce, + true, + cx, + ); + } + EditorEvent::Reparsed => { + outline_panel.outline_fetch_tasks.clear(); + outline_panel.outlines.clear(); + outline_panel.update_fs_entries( + &editor, + HashSet::default(), + None, + debounce, + true, + cx, + ); + } + _ => {} + }, + )) + } else { + None + } +} + +fn range_contains( + range: &Range, + anchor: language::Anchor, + buffer_snapshot: &language::BufferSnapshot, +) -> bool { + range.start.cmp(&anchor, buffer_snapshot).is_le() + && range.end.cmp(&anchor, buffer_snapshot).is_ge() +} diff --git a/crates/outline_panel/src/outline_panel_settings.rs b/crates/outline_panel/src/outline_panel_settings.rs new file mode 100644 index 0000000000000..0b5467dd05907 --- /dev/null +++ b/crates/outline_panel/src/outline_panel_settings.rs @@ -0,0 +1,81 @@ +use anyhow; +use gpui::Pixels; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsSources}; + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Copy, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum OutlinePanelDockPosition { + Left, + Right, +} + +#[derive(Deserialize, Debug, Clone, Copy, PartialEq)] +pub struct OutlinePanelSettings { + pub button: bool, + pub default_width: Pixels, + pub dock: OutlinePanelDockPosition, + pub file_icons: bool, + pub folder_icons: bool, + pub git_status: bool, + pub indent_size: f32, + pub auto_reveal_entries: bool, + pub auto_fold_dirs: bool, +} + +#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] +pub struct OutlinePanelSettingsContent { + /// Whether to show the outline panel button in the status bar. + /// + /// Default: true + pub button: Option, + /// Customise default width (in pixels) taken by outline panel + /// + /// Default: 240 + pub default_width: Option, + /// The position of outline panel + /// + /// Default: left + pub dock: Option, + /// Whether to show file icons in the outline panel. + /// + /// Default: true + pub file_icons: Option, + /// Whether to show folder icons or chevrons for directories in the outline panel. + /// + /// Default: true + pub folder_icons: Option, + /// Whether to show the git status in the outline panel. + /// + /// Default: true + pub git_status: Option, + /// Amount of indentation (in pixels) for nested items. + /// + /// Default: 20 + pub indent_size: Option, + /// Whether to reveal it in the outline panel automatically, + /// when a corresponding project entry becomes active. + /// Gitignored entries are never auto revealed. + /// + /// Default: true + pub auto_reveal_entries: Option, + /// Whether to fold directories automatically + /// when directory has only one directory inside. + /// + /// Default: true + pub auto_fold_dirs: Option, +} + +impl Settings for OutlinePanelSettings { + const KEY: Option<&'static str> = Some("outline_panel"); + + type FileContent = OutlinePanelSettingsContent; + + fn load( + sources: SettingsSources, + _: &mut gpui::AppContext, + ) -> anyhow::Result { + sources.json_merge() + } +} diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 8103390eacfa9..1433e6069a04e 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -144,6 +144,7 @@ pub enum IconName { InlayHint, Library, Link, + ListTree, MagicWand, MagnifyingGlass, MailOpen, @@ -274,6 +275,7 @@ impl IconName { IconName::InlayHint => "icons/inlay_hint.svg", IconName::Library => "icons/library.svg", IconName::Link => "icons/link.svg", + IconName::ListTree => "icons/list_tree.svg", IconName::MagicWand => "icons/magic_wand.svg", IconName::MagnifyingGlass => "icons/magnifying_glass.svg", IconName::MailOpen => "icons/mail_open.svg", diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 2a03e4be59513..2ac4bb9676c97 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -2000,7 +2000,7 @@ impl Snapshot { } } - fn traverse_from_path( + pub fn traverse_from_path( &self, include_files: bool, include_dirs: bool, @@ -2991,7 +2991,7 @@ impl File { } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct Entry { pub id: ProjectEntryId, pub kind: EntryKind, @@ -3020,7 +3020,7 @@ pub struct Entry { pub is_private: bool, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum EntryKind { UnloadedDir, PendingDir, @@ -4818,6 +4818,14 @@ impl<'a> Traversal<'a> { false } + pub fn back_to_parent(&mut self) -> bool { + let Some(parent_path) = self.cursor.item().and_then(|entry| entry.path.parent()) else { + return false; + }; + self.cursor + .seek(&TraversalTarget::Path(parent_path), Bias::Left, &()) + } + pub fn entry(&self) -> Option<&'a Entry> { self.cursor.item() } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index ee07acc8ebe2f..7ac8bd64b9dda 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -68,6 +68,7 @@ nix = {workspace = true, features = ["pthread", "signal"] } node_runtime.workspace = true notifications.workspace = true outline.workspace = true +outline_panel.workspace = true parking_lot.workspace = true profiling.workspace = true project.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 0ef0659ad4fa8..c3a39031fa32f 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -185,6 +185,7 @@ fn init_ui(app_state: Arc, cx: &mut AppContext) -> Result<()> { outline::init(cx); project_symbols::init(cx); project_panel::init(Assets, cx); + outline_panel::init(Assets, cx); tasks_ui::init(cx); channel::init(&app_state.client.clone(), app_state.user_store.clone(), cx); search::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 5a80e99466a30..a39320277648e 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -18,6 +18,7 @@ pub use open_listener::*; use anyhow::Context as _; use assets::Assets; use futures::{channel::mpsc, select_biased, StreamExt}; +use outline_panel::OutlinePanel; use project::TaskSourceKind; use project_panel::ProjectPanel; use quick_action_bar::QuickActionBar; @@ -190,6 +191,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { let assistant_panel = assistant::AssistantPanel::load(workspace_handle.clone(), cx.clone()); let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); + let outline_panel = OutlinePanel::load(workspace_handle.clone(), cx.clone()); let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone()); let channels_panel = collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone()); @@ -202,6 +204,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { let ( project_panel, + outline_panel, terminal_panel, assistant_panel, channels_panel, @@ -209,6 +212,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { notification_panel, ) = futures::try_join!( project_panel, + outline_panel, terminal_panel, assistant_panel, channels_panel, @@ -219,6 +223,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { workspace_handle.update(&mut cx, |workspace, cx| { workspace.add_panel(assistant_panel, cx); workspace.add_panel(project_panel, cx); + workspace.add_panel(outline_panel, cx); workspace.add_panel(terminal_panel, cx); workspace.add_panel(channels_panel, cx); workspace.add_panel(chat_panel, cx); @@ -377,6 +382,13 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { workspace.toggle_panel_focus::(cx); }, ) + .register_action( + |workspace: &mut Workspace, + _: &outline_panel::ToggleFocus, + cx: &mut ViewContext| { + workspace.toggle_panel_focus::(cx); + }, + ) .register_action( |workspace: &mut Workspace, _: &collab_ui::collab_panel::ToggleFocus, @@ -3093,9 +3105,9 @@ mod tests { command_palette::init(cx); language::init(cx); editor::init(cx); - project_panel::init_settings(cx); collab_ui::init(&app_state, cx); project_panel::init((), cx); + outline_panel::init((), cx); terminal_view::init(cx); assistant::init(app_state.client.clone(), cx); tasks_ui::init(cx); diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index 963948d207f44..8a12df90cb0b3 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -123,6 +123,7 @@ pub fn app_menus() -> Vec> { }), MenuItem::separator(), MenuItem::action("Project Panel", project_panel::ToggleFocus), + MenuItem::action("Outline Panel", outline_panel::ToggleFocus), MenuItem::action("Collab Panel", collab_panel::ToggleFocus), MenuItem::action("Terminal Panel", terminal_panel::ToggleFocus), MenuItem::separator(),