From 8451dba6a77a6e2dd17670bb665ff11b112e5802 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 12 Jun 2024 23:22:52 +0300 Subject: [PATCH] Introduce an outline panel (#12637) Adds a new panel: `OutlinePanel` which looks very close to project panel: Screenshot 2024-06-10 at 23 19 05 has similar settings and keymap (actions work in the `OutlinePanel` context and are under `outline_panel::` namespace), with two notable differences: * no "edit" actions such as cut/copy/paste/delete/etc. * directory auto folding is enabled by default Empty view: Screenshot 2024-06-10 at 23 19 11 When editor gets active, the panel displays all related files in a tree (similar to what the project panel does) and all related excerpts' outlines under each file. Same as in the project panel, directories can be expanded or collapsed, unfolded or folded; clicking file entries or outlines scrolls the buffer to the corresponding excerpt; changing editor's selection reveals the corresponding outline in the panel. The panel is applicable to any singleton buffer: Screenshot 2024-06-10 at 23 19 35 image or any multi buffer: (search multi buffer) Screenshot 2024-06-10 at 23 19 41 (diagnostics multi buffer) image Release Notes: - Added an outline panel to show a "map" of the active editor --- Cargo.lock | 27 +- Cargo.toml | 2 + assets/icons/list_tree.svg | 1 + assets/keymaps/default-linux.json | 18 +- assets/keymaps/default-macos.json | 13 + assets/settings/default.json | 23 + crates/editor/src/editor.rs | 28 +- crates/fuzzy/src/char_bag.rs | 2 +- crates/git/src/repository.rs | 2 +- crates/gpui/src/color.rs | 14 +- crates/gpui/src/style.rs | 24 +- crates/language/src/buffer.rs | 5 +- crates/language/src/language.rs | 2 +- crates/language/src/outline.rs | 40 +- crates/multi_buffer/src/multi_buffer.rs | 7 +- crates/outline/Cargo.toml | 1 - crates/outline/src/outline.rs | 41 +- crates/outline_panel/Cargo.toml | 37 + crates/outline_panel/LICENSE-GPL | 1 + crates/outline_panel/src/outline_panel.rs | 2515 +++++++++++++++++ .../src/outline_panel_settings.rs | 81 + crates/ui/src/components/icon.rs | 2 + crates/worktree/src/worktree.rs | 14 +- crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 1 + crates/zed/src/zed.rs | 14 +- crates/zed/src/zed/app_menus.rs | 1 + 27 files changed, 2860 insertions(+), 57 deletions(-) create mode 100644 assets/icons/list_tree.svg create mode 100644 crates/outline_panel/Cargo.toml create mode 120000 crates/outline_panel/LICENSE-GPL create mode 100644 crates/outline_panel/src/outline_panel.rs create mode 100644 crates/outline_panel/src/outline_panel_settings.rs diff --git a/Cargo.lock b/Cargo.lock index 4c01ef2462c311c3c3c1b8e649f10ffc68ac41c2..b1849fe63b2e7ceabbfef8ae9dc58052c73863e7 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 336d5d855905b5fb932b1a481f845e2dea8d8803..21dfd5c9f2f0a2dc34059db103f2ab1603da9dfb 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 0000000000000000000000000000000000000000..8cf157ec135d13395fc8ac66d8f8086f0d199a2e --- /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 34b5cd5caf56d33645decdd12aaa760fd44ad3f6..c735060364d10c3c0d66e26fcbb8f5531c4b2ccd 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 8f20a4383403484cbb8509740f16b084b01f4a4b..2ce2e4ea8950201100918e952efe89cf90aca012 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 0ab3b1d9779efb4e0d5971c9b5ae331f6f23b554..a64208834c112f695cb42c7484cbe9d90ea812a5 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 a1aff7cbe86a736bfe31a91bcb717aa627fa1372..5339643f521e5105bde1fb11908505a137584c22 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 ca40d730fbc4e1d6c7daa9d03f2f65f02fd868f6..13b00816ed0141117fb6d5ac9265e4b82c7aa57d 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 fc598e0c9d8696eebc3ea314f15da30b819abe1a..ecd130176a7215a12f63b9c01aee88672896778b 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 7a466d2e79fc72c9bf60bd9dba01259e96646474..2cf2ad55f2a1bb4597c6db5c647786553d709616 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 49111f48f87bfbb117e9380635a2277e380524a2..0ca021f2b2b725bf027d68269724c1dab2736b29 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 31b6cb573e030d7394a149ec89fcbf6b940dd208..ba3a961b1e497a22606e168036edbf6a5aaa28fd 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 5ad8e6031509e8d1f508d15013c73c1d660c91ad..51e5773d81a1929ed3449d2f1519319cbf02884a 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 bf807bfc757300702235fe13c84a1ba9361b871c..7621280d7fdc66b242a2bc4a6267697adc61ce05 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 cdba2fe6cff64703c4b06f8b893f0d82a1eeb6c0..68514dfd3f817fc558dcb278a93c4f770231aaf5 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 6f385f5d8d0821ea9f879793d029ca8eba8bc03c..66ee78895e9190cb7a95b78648f8351a8fb9fa0b 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 18d9446f4b56c6a328ae2934ee207dbaf3ccaa8a..b3647361e611cea4c4d86df40a5ccae50901e155 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 0000000000000000000000000000000000000000..e074710c28e5b029d028d07d1bd7cc527e5f10bb --- /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 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /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 0000000000000000000000000000000000000000..1c5cc47fda14f3e2d042a44fdbffd16dfabde09a --- /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 0000000000000000000000000000000000000000..0b5467dd0590742c4ce6946edb95191747b420d9 --- /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 8103390eacfa9cf818bd6e7b164d194e7ae36382..1433e6069a04e3ad81f51200d331b89b761f9644 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 2a03e4be59513214ea33f413a4fc5d602df926fc..2ac4bb9676c97b358025bd57556992b70251583a 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 ee07acc8ebe2f1cf6cba42e91d97abbc5c002ada..7ac8bd64b9dda4b0e092576fbdb63c894bb3ca4c 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 0ef0659ad4fa8fc6f017817fc5dcb813806123ae..c3a39031fa32f9ac1585638bbaeff2fec3c6aa3d 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 5a80e99466a3072f8b197eb0c2d5270fa9ac96d1..a39320277648e10e0c19af528d2edcadfa67b516 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 963948d207f442de128f4bd4a383d34edfd512a1..8a12df90cb0b3edd92e95cedb382f285cd659207 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(),