From e30f45cf64dcac1943cf726fad2ff55f8018057b Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Fri, 5 Sep 2025 14:22:32 +0200 Subject: [PATCH] Syntax tree view improvements (#37570) In an effort to improve the experience while developing extensions and improving themes, this PR updates the syntax tree views behavior slightly. Before, the view would always update to the current active editor whilst being used. This was quite painful for improving extension scheme files, as you would always have to change back and forth between editors to have a view at the relevant syntax tree. With this PR, the syntax tree view will now stay attached to the editor it was opened in, similar to preview views. Once the view is shown, the `UseActiveEditor` will become available in the command palette and enable the user to update the view to the last focused editor. On file close, the view will also be updated accordingly. https://github.com/user-attachments/assets/922075e5-9da0-4c1d-9e1a-51e024bf41ea A button is also shown whenever switching is possible. Futhermore, improved the empty state of the view. Lastly, a drive-by cleanup of the `show_action_types` method so there is no need to call `iter()` when calling the method. Release Notes: - The syntax tree view will now stay attached to the buffer it was opened in, similar to the Markdown preview. Use the `UseActiveEditor` action when the view is shown to change it to the last focused editor. --- Cargo.lock | 1 + crates/agent_ui/src/agent_ui.rs | 3 +- .../src/command_palette_hooks.rs | 4 +- crates/copilot/src/copilot.rs | 2 +- crates/language_tools/Cargo.toml | 1 + crates/language_tools/src/syntax_tree_view.rs | 395 +++++++++++++----- crates/settings_ui/src/settings_ui.rs | 2 +- crates/workspace/src/workspace.rs | 6 + crates/zed/src/zed.rs | 3 +- crates/zeta/src/init.rs | 4 +- 10 files changed, 302 insertions(+), 119 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c5e6c8588137b87e00b15e0655a53cdefc518d4f..b0fb3b6f49a90bc92d4dff35a6e76574625cc531 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9247,6 +9247,7 @@ dependencies = [ "anyhow", "client", "collections", + "command_palette_hooks", "copilot", "editor", "futures 0.3.31", diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 93a4a8f748eefc933f809669af841f443888f7ed..e60c0baff99d1f615cbe439aed754a35f2a5c8db 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -337,8 +337,7 @@ fn update_command_palette_filter(cx: &mut App) { ]; filter.show_action_types(edit_prediction_actions.iter()); - filter - .show_action_types([TypeId::of::()].iter()); + filter.show_action_types(&[TypeId::of::()]); } }); } diff --git a/crates/command_palette_hooks/src/command_palette_hooks.rs b/crates/command_palette_hooks/src/command_palette_hooks.rs index df64d53874b4907b3bf586ee7935302c2e6979ae..f1344c5ba6d46fce966ace60d483e3c0fc717f80 100644 --- a/crates/command_palette_hooks/src/command_palette_hooks.rs +++ b/crates/command_palette_hooks/src/command_palette_hooks.rs @@ -76,7 +76,7 @@ impl CommandPaletteFilter { } /// Hides all actions with the given types. - pub fn hide_action_types(&mut self, action_types: &[TypeId]) { + pub fn hide_action_types<'a>(&mut self, action_types: impl IntoIterator) { for action_type in action_types { self.hidden_action_types.insert(*action_type); self.shown_action_types.remove(action_type); @@ -84,7 +84,7 @@ impl CommandPaletteFilter { } /// Shows all actions with the given types. - pub fn show_action_types<'a>(&mut self, action_types: impl Iterator) { + pub fn show_action_types<'a>(&mut self, action_types: impl IntoIterator) { for action_type in action_types { self.shown_action_types.insert(*action_type); self.hidden_action_types.remove(action_type); diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index d0a57735ab5a0342b245aa8db72e6b021b3943de..61b7a4e18e4e679c29e26185735352737983c4d1 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1095,7 +1095,7 @@ impl Copilot { _ => { filter.hide_action_types(&signed_in_actions); filter.hide_action_types(&auth_actions); - filter.show_action_types(no_auth_actions.iter()); + filter.show_action_types(&no_auth_actions); } } } diff --git a/crates/language_tools/Cargo.toml b/crates/language_tools/Cargo.toml index b8f85d8d90068be9ad6849528f28522a96206cc8..bbac900cded75e9ca680a1813734f57423ce0ee9 100644 --- a/crates/language_tools/Cargo.toml +++ b/crates/language_tools/Cargo.toml @@ -16,6 +16,7 @@ doctest = false anyhow.workspace = true client.workspace = true collections.workspace = true +command_palette_hooks.workspace = true copilot.workspace = true editor.workspace = true futures.workspace = true diff --git a/crates/language_tools/src/syntax_tree_view.rs b/crates/language_tools/src/syntax_tree_view.rs index cf84ac34c4af6d04895ba5d1e22c262a1ef8f03c..5700d8d487e990937597295fb5bab761a46f2ba3 100644 --- a/crates/language_tools/src/syntax_tree_view.rs +++ b/crates/language_tools/src/syntax_tree_view.rs @@ -1,17 +1,22 @@ +use command_palette_hooks::CommandPaletteFilter; use editor::{Anchor, Editor, ExcerptId, SelectionEffects, scroll::Autoscroll}; use gpui::{ - App, AppContext as _, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, Hsla, - InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, - Render, ScrollStrategy, SharedString, Styled, UniformListScrollHandle, WeakEntity, Window, - actions, div, rems, uniform_list, + App, AppContext as _, Context, Div, Entity, EntityId, EventEmitter, FocusHandle, Focusable, + Hsla, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, + ParentElement, Render, ScrollStrategy, SharedString, Styled, UniformListScrollHandle, + WeakEntity, Window, actions, div, rems, uniform_list, }; use language::{Buffer, OwnedSyntaxLayer}; -use std::{mem, ops::Range}; +use std::{any::TypeId, mem, ops::Range}; use theme::ActiveTheme; use tree_sitter::{Node, TreeCursor}; -use ui::{ButtonLike, Color, ContextMenu, Label, LabelCommon, PopoverMenu, h_flex}; +use ui::{ + ButtonCommon, ButtonLike, Clickable, Color, ContextMenu, FluentBuilder as _, IconButton, + IconName, Label, LabelCommon, LabelSize, PopoverMenu, StyledExt, Tooltip, h_flex, v_flex, +}; use workspace::{ - SplitDirection, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, + Event as WorkspaceEvent, SplitDirection, ToolbarItemEvent, ToolbarItemLocation, + ToolbarItemView, Workspace, item::{Item, ItemHandle}, }; @@ -19,17 +24,51 @@ actions!( dev, [ /// Opens the syntax tree view for the current file. - OpenSyntaxTreeView + OpenSyntaxTreeView, + ] +); + +actions!( + syntax_tree_view, + [ + /// Update the syntax tree view to show the last focused file. + UseActiveEditor ] ); pub fn init(cx: &mut App) { - cx.observe_new(|workspace: &mut Workspace, _, _| { - workspace.register_action(|workspace, _: &OpenSyntaxTreeView, window, cx| { + let syntax_tree_actions = [TypeId::of::()]; + + CommandPaletteFilter::update_global(cx, |this, _| { + this.hide_action_types(&syntax_tree_actions); + }); + + cx.observe_new(move |workspace: &mut Workspace, _, _| { + workspace.register_action(move |workspace, _: &OpenSyntaxTreeView, window, cx| { + CommandPaletteFilter::update_global(cx, |this, _| { + this.show_action_types(&syntax_tree_actions); + }); + let active_item = workspace.active_item(cx); let workspace_handle = workspace.weak_handle(); - let syntax_tree_view = - cx.new(|cx| SyntaxTreeView::new(workspace_handle, active_item, window, cx)); + let syntax_tree_view = cx.new(|cx| { + cx.on_release(move |view: &mut SyntaxTreeView, cx| { + if view + .workspace_handle + .read_with(cx, |workspace, cx| { + workspace.item_of_type::(cx).is_none() + }) + .unwrap_or_default() + { + CommandPaletteFilter::update_global(cx, |this, _| { + this.hide_action_types(&syntax_tree_actions); + }); + } + }) + .detach(); + + SyntaxTreeView::new(workspace_handle, active_item, window, cx) + }); workspace.split_item( SplitDirection::Right, Box::new(syntax_tree_view), @@ -37,6 +76,13 @@ pub fn init(cx: &mut App) { cx, ) }); + workspace.register_action(|workspace, _: &UseActiveEditor, window, cx| { + if let Some(tree_view) = workspace.item_of_type::(cx) { + tree_view.update(cx, |view, cx| { + view.update_active_editor(&Default::default(), window, cx) + }) + } + }); }) .detach(); } @@ -45,6 +91,9 @@ pub struct SyntaxTreeView { workspace_handle: WeakEntity, editor: Option, list_scroll_handle: UniformListScrollHandle, + /// The last active editor in the workspace. Note that this is specifically not the + /// currently shown editor. + last_active_editor: Option>, selected_descendant_ix: Option, hovered_descendant_ix: Option, focus_handle: FocusHandle, @@ -61,6 +110,14 @@ struct EditorState { _subscription: gpui::Subscription, } +impl EditorState { + fn has_language(&self) -> bool { + self.active_buffer + .as_ref() + .is_some_and(|buffer| buffer.active_layer.is_some()) + } +} + #[derive(Clone)] struct BufferState { buffer: Entity, @@ -79,17 +136,25 @@ impl SyntaxTreeView { workspace_handle: workspace_handle.clone(), list_scroll_handle: UniformListScrollHandle::new(), editor: None, + last_active_editor: None, hovered_descendant_ix: None, selected_descendant_ix: None, focus_handle: cx.focus_handle(), }; - this.workspace_updated(active_item, window, cx); - cx.observe_in( + this.handle_item_updated(active_item, window, cx); + + cx.subscribe_in( &workspace_handle.upgrade().unwrap(), window, - |this, workspace, window, cx| { - this.workspace_updated(workspace.read(cx).active_item(cx), window, cx); + move |this, workspace, event, window, cx| match event { + WorkspaceEvent::ItemAdded { .. } | WorkspaceEvent::ActiveItemChanged => { + this.handle_item_updated(workspace.read(cx).active_item(cx), window, cx) + } + WorkspaceEvent::ItemRemoved { item_id } => { + this.handle_item_removed(item_id, window, cx); + } + _ => {} }, ) .detach(); @@ -97,20 +162,56 @@ impl SyntaxTreeView { this } - fn workspace_updated( + fn handle_item_updated( &mut self, active_item: Option>, window: &mut Window, cx: &mut Context, ) { - if let Some(item) = active_item - && item.item_id() != cx.entity_id() - && let Some(editor) = item.act_as::(cx) - { + let Some(editor) = active_item + .filter(|item| item.item_id() != cx.entity_id()) + .and_then(|item| item.act_as::(cx)) + else { + return; + }; + + if let Some(editor_state) = self.editor.as_ref().filter(|state| state.has_language()) { + self.last_active_editor = (editor_state.editor != editor).then_some(editor); + } else { self.set_editor(editor, window, cx); } } + fn handle_item_removed( + &mut self, + item_id: &EntityId, + window: &mut Window, + cx: &mut Context, + ) { + if self + .editor + .as_ref() + .is_some_and(|state| state.editor.entity_id() == *item_id) + { + self.editor = None; + // Try activating the last active editor if there is one + self.update_active_editor(&Default::default(), window, cx); + cx.notify(); + } + } + + fn update_active_editor( + &mut self, + _: &UseActiveEditor, + window: &mut Window, + cx: &mut Context, + ) { + let Some(editor) = self.last_active_editor.take() else { + return; + }; + self.set_editor(editor, window, cx); + } + fn set_editor(&mut self, editor: Entity, window: &mut Window, cx: &mut Context) { if let Some(state) = &self.editor { if state.editor == editor { @@ -294,101 +395,153 @@ impl SyntaxTreeView { .pl(rems(depth as f32)) .hover(|style| style.bg(colors.element_hover)) } -} - -impl Render for SyntaxTreeView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let mut rendered = div().flex_1().bg(cx.theme().colors().editor_background); - if let Some(layer) = self - .editor - .as_ref() - .and_then(|editor| editor.active_buffer.as_ref()) - .and_then(|buffer| buffer.active_layer.as_ref()) - { - let layer = layer.clone(); - rendered = rendered.child(uniform_list( - "SyntaxTreeView", - layer.node().descendant_count(), - cx.processor(move |this, range: Range, _, cx| { - let mut items = Vec::new(); - let mut cursor = layer.node().walk(); - let mut descendant_ix = range.start; - cursor.goto_descendant(descendant_ix); - let mut depth = cursor.depth(); - let mut visited_children = false; - while descendant_ix < range.end { - if visited_children { - if cursor.goto_next_sibling() { - visited_children = false; - } else if cursor.goto_parent() { - depth -= 1; - } else { - break; - } - } else { - items.push( - Self::render_node( - &cursor, - depth, - Some(descendant_ix) == this.selected_descendant_ix, + fn compute_items( + &mut self, + layer: &OwnedSyntaxLayer, + range: Range, + cx: &Context, + ) -> Vec
{ + let mut items = Vec::new(); + let mut cursor = layer.node().walk(); + let mut descendant_ix = range.start; + cursor.goto_descendant(descendant_ix); + let mut depth = cursor.depth(); + let mut visited_children = false; + while descendant_ix < range.end { + if visited_children { + if cursor.goto_next_sibling() { + visited_children = false; + } else if cursor.goto_parent() { + depth -= 1; + } else { + break; + } + } else { + items.push( + Self::render_node( + &cursor, + depth, + Some(descendant_ix) == self.selected_descendant_ix, + cx, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |tree_view, _: &MouseDownEvent, window, cx| { + tree_view.update_editor_with_range_for_descendant_ix( + descendant_ix, + window, + cx, + |editor, mut range, window, cx| { + // Put the cursor at the beginning of the node. + mem::swap(&mut range.start, &mut range.end); + + editor.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |selections| { + selections.select_ranges(vec![range]); + }, + ); + }, + ); + }), + ) + .on_mouse_move(cx.listener( + move |tree_view, _: &MouseMoveEvent, window, cx| { + if tree_view.hovered_descendant_ix != Some(descendant_ix) { + tree_view.hovered_descendant_ix = Some(descendant_ix); + tree_view.update_editor_with_range_for_descendant_ix( + descendant_ix, + window, cx, - ) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |tree_view, _: &MouseDownEvent, window, cx| { - tree_view.update_editor_with_range_for_descendant_ix( - descendant_ix, - window, cx, - |editor, mut range, window, cx| { - // Put the cursor at the beginning of the node. - mem::swap(&mut range.start, &mut range.end); - - editor.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, cx, - |selections| { - selections.select_ranges(vec![range]); - }, - ); + |editor, range, _, cx| { + editor.clear_background_highlights::(cx); + editor.highlight_background::( + &[range], + |theme| { + theme + .colors() + .editor_document_highlight_write_background }, + cx, ); - }), - ) - .on_mouse_move(cx.listener( - move |tree_view, _: &MouseMoveEvent, window, cx| { - if tree_view.hovered_descendant_ix != Some(descendant_ix) { - tree_view.hovered_descendant_ix = Some(descendant_ix); - tree_view.update_editor_with_range_for_descendant_ix(descendant_ix, window, cx, |editor, range, _, cx| { - editor.clear_background_highlights::( cx); - editor.highlight_background::( - &[range], - |theme| theme.colors().editor_document_highlight_write_background, - cx, - ); - }); - cx.notify(); - } }, - )), - ); - descendant_ix += 1; - if cursor.goto_first_child() { - depth += 1; - } else { - visited_children = true; + ); + cx.notify(); } - } - } - items - }), - ) - .size_full() - .track_scroll(self.list_scroll_handle.clone()) - .text_bg(cx.theme().colors().background).into_any_element()); + }, + )), + ); + descendant_ix += 1; + if cursor.goto_first_child() { + depth += 1; + } else { + visited_children = true; + } + } } + items + } +} - rendered +impl Render for SyntaxTreeView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .flex_1() + .bg(cx.theme().colors().editor_background) + .map(|this| { + let editor_state = self.editor.as_ref(); + + if let Some(layer) = editor_state + .and_then(|editor| editor.active_buffer.as_ref()) + .and_then(|buffer| buffer.active_layer.as_ref()) + { + let layer = layer.clone(); + this.child( + uniform_list( + "SyntaxTreeView", + layer.node().descendant_count(), + cx.processor(move |this, range: Range, _, cx| { + this.compute_items(&layer, range, cx) + }), + ) + .size_full() + .track_scroll(self.list_scroll_handle.clone()) + .text_bg(cx.theme().colors().background) + .into_any_element(), + ) + } else { + let inner_content = v_flex() + .items_center() + .text_center() + .gap_2() + .max_w_3_5() + .map(|this| { + if editor_state.is_some_and(|state| !state.has_language()) { + this.child(Label::new("Current editor has no associated language")) + .child( + Label::new(concat!( + "Try assigning a language or", + "switching to a different buffer" + )) + .size(LabelSize::Small), + ) + } else { + this.child(Label::new("Not attached to an editor")).child( + Label::new("Focus an editor to show a new tree view") + .size(LabelSize::Small), + ) + } + }); + + this.h_flex() + .size_full() + .justify_center() + .child(inner_content) + } + }) } } @@ -506,6 +659,26 @@ impl SyntaxTreeToolbarItemView { .child(Label::new(active_layer.language.name())) .child(Label::new(format_node_range(active_layer.node()))) } + + fn render_update_button(&mut self, cx: &mut Context) -> Option { + self.tree_view.as_ref().and_then(|view| { + view.update(cx, |view, cx| { + view.last_active_editor.as_ref().map(|editor| { + IconButton::new("syntax-view-update", IconName::RotateCw) + .tooltip({ + let active_tab_name = editor.read_with(cx, |editor, cx| { + editor.tab_content_text(Default::default(), cx) + }); + + Tooltip::text(format!("Update view to '{active_tab_name}'")) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.update_active_editor(&Default::default(), window, cx); + })) + }) + }) + }) + } } fn format_node_range(node: Node) -> String { @@ -522,8 +695,10 @@ fn format_node_range(node: Node) -> String { impl Render for SyntaxTreeToolbarItemView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - self.render_menu(cx) - .unwrap_or_else(|| PopoverMenu::new("Empty Syntax Tree")) + h_flex() + .gap_1() + .children(self.render_menu(cx)) + .children(self.render_update_button(cx)) } } diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index d736f0e174ba13d368794d8f5b623a44845d561b..5fea6dfcebb21be4351172ed4d8a17452b5601ba 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -70,7 +70,7 @@ pub fn init(cx: &mut App) { move |is_enabled, _workspace, _, cx| { if is_enabled { CommandPaletteFilter::update_global(cx, |filter, _cx| { - filter.show_action_types(settings_ui_actions.iter()); + filter.show_action_types(&settings_ui_actions); }); } else { CommandPaletteFilter::update_global(cx, |filter, _cx| { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index af86517bb452c1cea77a72f2cf2350ef1e2eb030..0bfcaaf593eca73baa2a6a57def5af17b6ee93b3 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1031,6 +1031,9 @@ pub enum Event { item: Box, }, ActiveItemChanged, + ItemRemoved { + item_id: EntityId, + }, UserSavedItem { pane: WeakEntity, item: Box, @@ -3945,6 +3948,9 @@ impl Workspace { { entry.remove(); } + cx.emit(Event::ItemRemoved { + item_id: item.item_id(), + }); } pane::Event::Focus => { window.invalidate_character_coordinates(); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 96f0f261dcce9268976f92ec028f0581fb648913..864f6badeb6941aa2d6bd17a43977f84a77461b1 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4502,6 +4502,7 @@ mod tests { "snippets", "supermaven", "svg", + "syntax_tree_view", "tab_switcher", "task", "terminal", @@ -4511,11 +4512,11 @@ mod tests { "toolchain", "variable_list", "vim", + "window", "workspace", "zed", "zed_predict_onboarding", "zeta", - "window", ]; assert_eq!( all_namespaces, diff --git a/crates/zeta/src/init.rs b/crates/zeta/src/init.rs index 6e5b31f99a76cb0e066348150e962396cf1ad9c6..f27667de6332bf4c3b8d2d705f281c9e3ba96a83 100644 --- a/crates/zeta/src/init.rs +++ b/crates/zeta/src/init.rs @@ -86,7 +86,7 @@ fn feature_gate_predict_edits_actions(cx: &mut App) { if is_ai_disabled { filter.hide_action_types(&zeta_all_action_types); } else if has_feature_flag { - filter.show_action_types(rate_completion_action_types.iter()); + filter.show_action_types(&rate_completion_action_types); } else { filter.hide_action_types(&rate_completion_action_types); } @@ -98,7 +98,7 @@ fn feature_gate_predict_edits_actions(cx: &mut App) { if !DisableAiSettings::get_global(cx).disable_ai { if is_enabled { CommandPaletteFilter::update_global(cx, |filter, _cx| { - filter.show_action_types(rate_completion_action_types.iter()); + filter.show_action_types(&rate_completion_action_types); }); } else { CommandPaletteFilter::update_global(cx, |filter, _cx| {