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 4c01ef2462..b1849fe63b 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 336d5d8559..21dfd5c9f2 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 0000000000..8cf157ec13 --- /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 34b5cd5caf..c735060364 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 8f20a43834..2ce2e4ea89 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 0ab3b1d977..a64208834c 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 a1aff7cbe8..5339643f52 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 ca40d730fb..13b00816ed 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 fc598e0c9d..ecd130176a 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 7a466d2e79..2cf2ad55f2 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 49111f48f8..0ca021f2b2 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 31b6cb573e..ba3a961b1e 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 5ad8e60315..51e5773d81 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 bf807bfc75..7621280d7f 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 cdba2fe6cf..68514dfd3f 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 6f385f5d8d..66ee78895e 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 18d9446f4b..b3647361e6 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 0000000000..e074710c28 --- /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 0000000000..89e542f750 --- /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 0000000000..1c5cc47fda --- /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 0000000000..0b5467dd05 --- /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 8103390eac..1433e6069a 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 2a03e4be59..2ac4bb9676 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 ee07acc8eb..7ac8bd64b9 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 0ef0659ad4..c3a39031fa 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 5a80e99466..a393202776 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 963948d207..8a12df90cb 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(),