diff --git a/Cargo.lock b/Cargo.lock index fb21a5b0ee..73aa9d0da0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2668,6 +2668,32 @@ dependencies = [ "workspace", ] +[[package]] +name = "call_hierarchy" +version = "0.1.0" +dependencies = [ + "editor", + "futures 0.3.32", + "fuzzy", + "gpui", + "language", + "log", + "lsp", + "menu", + "picker", + "project", + "proto", + "serde_json", + "settings", + "theme", + "theme_settings", + "tree-sitter-rust", + "ui", + "util", + "workspace", + "zed_actions", +] + [[package]] name = "calloop" version = "0.14.3" @@ -9958,7 +9984,6 @@ dependencies = [ "picker", "project", "ui", - "util", "workspace", ] @@ -12169,7 +12194,6 @@ dependencies = [ "gpui", "picker", "project", - "schemars 1.0.4", "serde", "serde_json", "settings", @@ -22721,6 +22745,7 @@ dependencies = [ "auto_update_ui", "breadcrumbs", "call", + "call_hierarchy", "channel", "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 78f7945e30..a496df4a04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ members = [ "crates/breadcrumbs", "crates/buffer_diff", "crates/call", + "crates/call_hierarchy", "crates/channel", "crates/cli", "crates/client", @@ -295,6 +296,7 @@ bedrock = { path = "crates/bedrock" } breadcrumbs = { path = "crates/breadcrumbs" } buffer_diff = { path = "crates/buffer_diff" } call = { path = "crates/call" } +call_hierarchy = { path = "crates/call_hierarchy" } channel = { path = "crates/channel" } cli = { path = "crates/cli" } client = { path = "crates/client" } diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 7321e74a82..941c7f9b40 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -569,6 +569,7 @@ "shift-f12": "editor::GoToImplementation", "alt-ctrl-f12": "editor::GoToTypeDefinitionSplit", "alt-shift-f12": "editor::FindAllReferences", + "ctrl-k ctrl-h": "call_hierarchy::ShowIncomingCalls", "ctrl-m": "editor::MoveToEnclosingBracket", // from jetbrains "ctrl-|": "editor::MoveToEnclosingBracket", "ctrl-{": "editor::Fold", @@ -1184,6 +1185,14 @@ "tab": "channel_modal::ToggleMode", }, }, + { + "context": "CallHierarchyPicker > Picker > Editor", + "bindings": { + "ctrl-k ctrl-h": "call_hierarchy::ToggleDirection", + "ctrl-k right": "menu::SelectChild", + "ctrl-k left": "menu::SelectParent", + }, + }, { "context": "Picker > Editor", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 0164823602..c0ad8d1000 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -608,6 +608,7 @@ "shift-f12": "editor::GoToImplementation", "alt-cmd-f12": "editor::GoToTypeDefinitionSplit", "alt-shift-f12": "editor::FindAllReferences", + "cmd-k cmd-h": "call_hierarchy::ShowIncomingCalls", "cmd-|": "editor::MoveToEnclosingBracket", "ctrl-m": "editor::MoveToEnclosingBracket", // From Jetbrains "alt-cmd-[": "editor::Fold", @@ -1231,6 +1232,15 @@ "tab": "channel_modal::ToggleMode", }, }, + { + "context": "CallHierarchyPicker > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "cmd-k cmd-h": "call_hierarchy::ToggleDirection", + "cmd-k right": "menu::SelectChild", + "cmd-k left": "menu::SelectParent", + }, + }, { "context": "Picker > Editor", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 7a4ef74a71..e653fb3d58 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -568,6 +568,7 @@ "alt-f12": "editor::GoToDefinitionSplit", "ctrl-f12": "editor::GoToImplementation", "shift-alt-f12": "editor::FindAllReferences", + "ctrl-k ctrl-h": "call_hierarchy::ShowIncomingCalls", "ctrl-shift-\\": "editor::MoveToEnclosingBracket", "ctrl-shift-[": "editor::Fold", "ctrl-shift-]": "editor::UnfoldLines", @@ -1187,6 +1188,15 @@ "tab": "channel_modal::ToggleMode", }, }, + { + "context": "CallHierarchyPicker > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-k ctrl-h": "call_hierarchy::ToggleDirection", + "ctrl-k right": "menu::SelectChild", + "ctrl-k left": "menu::SelectParent", + }, + }, { "context": "Picker > Editor", "use_key_equivalents": true, diff --git a/assets/keymaps/linux/cursor.json b/assets/keymaps/linux/cursor.json index 8d5f7b5a76..81d8dbec9c 100644 --- a/assets/keymaps/linux/cursor.json +++ b/assets/keymaps/linux/cursor.json @@ -20,6 +20,7 @@ "ctrl-shift-l": "agent::AddSelectionToThread", // In cursor uses "Ask" mode "ctrl-l": "agent::AddSelectionToThread", // In cursor uses "Agent" mode "ctrl-k": "assistant::InlineAssist", + "shift-alt-h": "call_hierarchy::ShowIncomingCalls", }, }, { @@ -79,4 +80,11 @@ "ctrl-k": "assistant::InlineAssist", }, }, + { + "context": "CallHierarchyPicker > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "shift-alt-h": "call_hierarchy::ToggleDirection", + }, + }, ] diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index eb6b6df180..0243e04030 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -83,6 +83,7 @@ "ctrl-q": "editor::Hover", "ctrl-p": "editor::ShowSignatureHelp", "ctrl-\\": "assistant::InlineAssist", + "ctrl-alt-h": "call_hierarchy::ShowIncomingCalls", }, }, { @@ -218,4 +219,11 @@ "escape": "editor::Cancel", }, }, + { + "context": "CallHierarchyPicker > Picker > Editor", + "bindings": { + "ctrl-alt-h": "call_hierarchy::ToggleDirection", + "ctrl-k": null, + }, + }, ] diff --git a/assets/keymaps/linux/vscode.json b/assets/keymaps/linux/vscode.json index a333e19ff9..4de83db2f0 100644 --- a/assets/keymaps/linux/vscode.json +++ b/assets/keymaps/linux/vscode.json @@ -23,6 +23,7 @@ // In VS Code `ctrl-k z` is Zen Mode; the word wrap toggle moves to `alt-z`. "ctrl-k z": "workspace::ToggleCenteredLayout", "ctrl-i": "assistant::InlineAssist", // Inline Chat + "shift-alt-h": "call_hierarchy::ShowIncomingCalls", }, }, { @@ -69,4 +70,11 @@ "f11": "debugger::StepInto", }, }, + { + "context": "CallHierarchyPicker > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "shift-alt-h": "call_hierarchy::ToggleDirection", + }, + }, ] diff --git a/assets/keymaps/macos/cursor.json b/assets/keymaps/macos/cursor.json index f7cab89fb6..f8764d5cb7 100644 --- a/assets/keymaps/macos/cursor.json +++ b/assets/keymaps/macos/cursor.json @@ -20,6 +20,7 @@ "cmd-shift-l": "agent::AddSelectionToThread", // In cursor uses "Ask" mode "cmd-l": "agent::AddSelectionToThread", // In cursor uses "Agent" mode "cmd-k": "assistant::InlineAssist", + "shift-alt-h": "call_hierarchy::ShowIncomingCalls", }, }, { @@ -80,4 +81,11 @@ "cmd-k": "assistant::InlineAssist", }, }, + { + "context": "CallHierarchyPicker > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "shift-alt-h": "call_hierarchy::ToggleDirection", + }, + }, ] diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 5a2f5db448..38ce4d9853 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -82,6 +82,7 @@ "cmd-j": "editor::Hover", "cmd-p": "editor::ShowSignatureHelp", "cmd-\\": "assistant::InlineAssist", + "ctrl-alt-h": "call_hierarchy::ShowIncomingCalls", }, }, { @@ -222,4 +223,11 @@ "escape": "editor::Cancel", }, }, + { + "context": "CallHierarchyPicker > Picker > Editor", + "bindings": { + "ctrl-alt-h": "call_hierarchy::ToggleDirection", + "cmd-k": null, + }, + }, ] diff --git a/assets/keymaps/macos/vscode.json b/assets/keymaps/macos/vscode.json index 532671e508..69e000a8df 100644 --- a/assets/keymaps/macos/vscode.json +++ b/assets/keymaps/macos/vscode.json @@ -25,6 +25,7 @@ // In VS Code `cmd-k z` is Zen Mode; the word wrap toggle moves to `alt-z`. "cmd-k z": "workspace::ToggleCenteredLayout", "cmd-i": "assistant::InlineAssist", // Inline Chat + "shift-alt-h": "call_hierarchy::ShowIncomingCalls", }, }, { @@ -79,4 +80,11 @@ "f11": "debugger::StepInto", }, }, + { + "context": "CallHierarchyPicker > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "shift-alt-h": "call_hierarchy::ToggleDirection", + }, + }, ] diff --git a/assets/settings/default.json b/assets/settings/default.json index 2a8db0f99b..2d8b811b3b 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1429,6 +1429,13 @@ // If `enable_preview_file_from_code_navigation` or `enable_preview_multibuffer_from_code_navigation` is also true, the new tab may replace the existing one. "enable_keep_preview_on_code_navigation": false, }, + // Settings related to the call hierarchy picker. + "call_hierarchy": { + // Determines how much space the call hierarchy picker can take up in relation to the available window width. + // + // Default: medium + "modal_max_width": "medium", + }, // Settings related to the file finder. "file_finder": { // Whether to show file icons in the file finder. diff --git a/crates/agent_ui/src/agent_configuration/tool_picker.rs b/crates/agent_ui/src/agent_configuration/tool_picker.rs index edb510d6f7..dc6371b0b1 100644 --- a/crates/agent_ui/src/agent_configuration/tool_picker.rs +++ b/crates/agent_ui/src/agent_configuration/tool_picker.rs @@ -327,7 +327,7 @@ impl PickerDelegate for ToolPickerDelegate { fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { self.tool_picker .update(cx, |_this, cx| cx.emit(DismissEvent)) - .log_err(); + .ok(); } fn render_match( diff --git a/crates/call_hierarchy/Cargo.toml b/crates/call_hierarchy/Cargo.toml new file mode 100644 index 0000000000..22ddf2ab49 --- /dev/null +++ b/crates/call_hierarchy/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "call_hierarchy" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/call_hierarchy.rs" +doctest = false + +[dependencies] +editor.workspace = true +fuzzy.workspace = true +gpui.workspace = true +language.workspace = true +log.workspace = true +menu.workspace = true +picker.workspace = true +project.workspace = true +proto.workspace = true +settings.workspace = true +theme.workspace = true +theme_settings.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true +zed_actions.workspace = true + +[dev-dependencies] +futures.workspace = true +gpui = { workspace = true, features = ["test-support"] } +language = { workspace = true, features = ["test-support"] } +lsp = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } +serde_json.workspace = true +theme = { workspace = true, features = ["test-support"] } +tree-sitter-rust.workspace = true diff --git a/crates/call_hierarchy/LICENSE-GPL b/crates/call_hierarchy/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/call_hierarchy/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/call_hierarchy/src/call_hierarchy.rs b/crates/call_hierarchy/src/call_hierarchy.rs new file mode 100644 index 0000000000..fc4043e8e6 --- /dev/null +++ b/crates/call_hierarchy/src/call_hierarchy.rs @@ -0,0 +1,2572 @@ +use std::{ + ops::Range, + path::PathBuf, + sync::{Arc, atomic::AtomicBool}, +}; + +use editor::{ + Editor, RowHighlightOptions, SelectionEffects, + scroll::{Autoscroll, ScrollOffset}, +}; +use fuzzy::{StringMatch, StringMatchCandidate}; +use gpui::{ + AbsoluteLength, Action, AnyView, App, AsyncWindowContext, Context, DismissEvent, Entity, + EventEmitter, FocusHandle, Focusable, HighlightStyle, ParentElement, Point, Render, Styled, + StyledText, Subscription, Task, TextRun, TextStyle, WeakEntity, Window, +}; +use language::{ + Buffer, CodeLabel, File as _, Language, Location, Rope, ToOffset, ToPoint, lsp_to_symbol_kind, +}; +use picker::{Picker, PickerDelegate}; +use project::{CallHierarchyItem, LspStoreEvent, Project}; +use settings::Settings; +use theme::SyntaxTheme; +use theme_settings::ThemeSettings; +use ui::{KeyBinding, ListItem, ListItemSpacing, prelude::*, tooltip_container}; +use util::{ResultExt, paths::PathExt}; +use workspace::{DismissDecision, ModalView, Workspace}; +pub use zed_actions::{ShowIncomingCalls, ShowOutgoingCalls, ToggleDirection}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum CallHierarchyMode { + #[default] + Incoming, + Outgoing, +} + +impl CallHierarchyMode { + fn opposite(self) -> Self { + match self { + CallHierarchyMode::Incoming => CallHierarchyMode::Outgoing, + CallHierarchyMode::Outgoing => CallHierarchyMode::Incoming, + } + } +} + +#[derive(Debug, Clone)] +pub struct Call { + pub item: CallHierarchyItem, + pub target: Location, + pub site_count: usize, + pub label: Option>, + pub display: CallDisplay, +} + +#[derive(Debug, Clone, Default)] +pub struct CallDisplay { + pub name: SharedString, + pub detail: Option, + pub path: Option, + pub full_signature: SharedString, + pub label_text: Option, + pub needs_tooltip: bool, +} + +const BASE_MODAL_WIDTH: Rems = Rems(42.0); + +#[derive(Debug, Clone, Copy, PartialEq, settings::RegisterSetting)] +pub struct CallHierarchySettings { + pub modal_max_width: settings::ModalWidthContent, +} + +impl Settings for CallHierarchySettings { + fn from_settings(content: &settings::SettingsContent) -> Self { + let call_hierarchy = content.call_hierarchy.as_ref().unwrap(); + Self { + modal_max_width: call_hierarchy.modal_max_width.unwrap(), + } + } +} + +pub async fn fetch_calls( + item: &CallHierarchyItem, + project: &Entity, + mode: CallHierarchyMode, + cx: &mut AsyncWindowContext, +) -> Vec { + let raw_calls = match mode { + CallHierarchyMode::Incoming => { + let task = project.update(cx, |project, cx| project.incoming_calls(item.clone(), cx)); + task.await + .log_err() + .flatten() + .unwrap_or_default() + .into_iter() + .map(|call| (call.from, call.from_ranges)) + .collect::>() + } + CallHierarchyMode::Outgoing => { + let task = project.update(cx, |project, cx| project.outgoing_calls(item.clone(), cx)); + task.await + .log_err() + .flatten() + .unwrap_or_default() + .into_iter() + .map(|call| (call.to, call.from_ranges)) + .collect::>() + } + }; + + let mut calls = cx + .update(|_, cx| { + raw_calls + .into_iter() + .map(|(item, mut sites)| { + sites.sort_by_key(|site| { + let buffer = site.buffer.read(cx); + site.range.start.to_offset(buffer) + }); + let site_count = sites.len().max(1); + let target = sites + .into_iter() + .next() + .unwrap_or_else(|| item_location(&item)); + Call { + item, + target, + site_count, + label: None, + display: CallDisplay::default(), + } + }) + .collect::>() + }) + .ok() + .unwrap_or_default(); + + attach_labels(&mut calls, project, cx).await; + cx.update(|_, cx| { + for call in calls.iter_mut() { + call.display = compute_call_display(call, cx); + } + calls.sort_by_cached_key(|call| { + let offset = call + .target + .range + .start + .to_offset(call.target.buffer.read(cx)); + (call.display.path.clone(), offset) + }); + }) + .ok(); + calls +} + +pub fn init(cx: &mut App) { + cx.observe_new(CallHierarchyView::register).detach(); +} + +pub struct CallHierarchyView { + picker: Entity>, + mode: CallHierarchyMode, + _subscriptions: [Subscription; 2], +} + +impl CallHierarchyView { + fn register(editor: &mut Editor, _: Option<&mut Window>, cx: &mut Context) { + if editor.mode().is_full() { + let handle = cx.entity().downgrade(); + editor + .register_action({ + let handle = handle.clone(); + move |_: &ShowIncomingCalls, window, cx| { + if let Some(editor) = handle.upgrade() { + toggle_call_hierarchy(editor, CallHierarchyMode::Incoming, window, cx); + } + } + }) + .detach(); + editor + .register_action(move |_: &ShowOutgoingCalls, window, cx| { + if let Some(editor) = handle.upgrade() { + toggle_call_hierarchy(editor, CallHierarchyMode::Outgoing, window, cx); + } + }) + .detach(); + } + } + + fn new( + editor: Entity, + project: Entity, + workspace: WeakEntity, + mode: CallHierarchyMode, + window: &mut Window, + cx: &mut Context, + ) -> CallHierarchyView { + let prev_scroll_position = editor.update(cx, |editor, cx| Some(editor.scroll_position(cx))); + let project_subscription = + cx.subscribe_in(&project, window, |view, _, event, window, cx| match event { + project::Event::LanguageServerBufferRegistered { buffer_id, .. } => { + view.retry_empty_hierarchy( + |delegate, cx| { + delegate + .queried_buffer + .as_ref() + .is_some_and(|buffer| buffer.read(cx).remote_id() == *buffer_id) + }, + window, + cx, + ); + } + _ => {} + }); + let lsp_store_subscription = cx.subscribe_in( + &project.read(cx).lsp_store(), + window, + |view, _, event, window, cx| match event { + LspStoreEvent::LanguageServerUpdate { + message: proto::update_language_server::Variant::WorkEnd(_), + .. + } => { + view.retry_empty_hierarchy( + |delegate, _| delegate.queried_buffer.is_some(), + window, + cx, + ); + } + _ => {} + }, + ); + let delegate = CallHierarchyDelegate::new( + cx.entity().downgrade(), + editor, + project, + workspace, + mode, + prev_scroll_position, + cx.focus_handle(), + ); + let modal_width = CallHierarchySettings::get_global(cx) + .modal_max_width + .to_pixels( + BASE_MODAL_WIDTH.to_pixels(window.rem_size()), + window.viewport_size().width, + ); + let picker = cx.new(|cx| { + Picker::uniform_list(delegate, window, cx) + .initial_width(Rems::from_pixels(modal_width, window)) + .show_scrollbar(true) + .max_height(Rems::from_pixels( + window.viewport_size().height * 0.75, + window, + )) + }); + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, cx| { + picker.delegate.modal_width = modal_width; + picker.delegate.focus_handle = picker_focus_handle; + picker.delegate.fetch_root(window, cx); + }); + + CallHierarchyView { + picker, + mode, + _subscriptions: [project_subscription, lsp_store_subscription], + } + } + + fn retry_empty_hierarchy( + &mut self, + should_retry: impl FnOnce(&CallHierarchyDelegate, &App) -> bool, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + if picker.delegate.root_item.is_none() && should_retry(&picker.delegate, cx) { + picker.delegate.fetch_root(window, cx); + } + }); + } + + fn set_mode(&mut self, mode: CallHierarchyMode, window: &mut Window, cx: &mut Context) { + if self.mode == mode { + cx.emit(DismissEvent); + return; + } + self.mode = mode; + self.picker.update(cx, |picker, cx| { + picker.delegate.mode = mode; + if let Some(root_item) = picker.delegate.root_item.clone() { + picker.delegate.load_calls(root_item, None, window, cx); + } + picker.set_query("", window, cx); + picker.refresh_placeholder(window, cx); + }); + cx.notify(); + } +} + +impl Focusable for CallHierarchyView { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl EventEmitter for CallHierarchyView {} + +impl ModalView for CallHierarchyView { + fn on_before_dismiss( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> DismissDecision { + self.picker.update(cx, |picker, cx| { + picker.delegate.restore_editor(window, cx); + }); + DismissDecision::Dismiss(true) + } +} + +impl Render for CallHierarchyView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("CallHierarchyPicker") + .on_action(cx.listener(|view, _: &ShowIncomingCalls, window, cx| { + view.set_mode(CallHierarchyMode::Incoming, window, cx); + })) + .on_action(cx.listener(|view, _: &ShowOutgoingCalls, window, cx| { + view.set_mode(CallHierarchyMode::Outgoing, window, cx); + })) + .on_action(cx.listener(|view, _: &ToggleDirection, window, cx| { + view.set_mode(view.mode.opposite(), window, cx); + })) + .child(self.picker.clone()) + } +} + +pub struct CallHierarchyDelegate { + view: WeakEntity, + workspace: WeakEntity, + project: Entity, + editor: Entity, + mode: CallHierarchyMode, + state: FetchState, + fetch_task: Option>, + root_item: Option, + root_stack: Vec, + prev_scroll_position: Option>, + focus_handle: FocusHandle, + queried_buffer: Option>, + modal_width: Pixels, + calls: Vec, + candidates: Arc>, + matches: Vec, + selected_index: usize, +} + +impl CallHierarchyDelegate { + fn new( + view: WeakEntity, + editor: Entity, + project: Entity, + workspace: WeakEntity, + mode: CallHierarchyMode, + prev_scroll_position: Option>, + focus_handle: FocusHandle, + ) -> Self { + Self { + view, + workspace, + project, + editor, + mode, + state: FetchState::Loading, + fetch_task: None, + root_item: None, + root_stack: Vec::new(), + prev_scroll_position, + focus_handle, + queried_buffer: None, + modal_width: Pixels::ZERO, + calls: Vec::new(), + candidates: Arc::new(Vec::new()), + matches: Vec::new(), + selected_index: 0, + } + } + + fn fetch_root(&mut self, window: &mut Window, cx: &mut Context>) { + let editor_anchor = self.editor.update(cx, |editor, cx| { + let selection = editor.selections.newest_anchor().head(); + editor + .buffer() + .read(cx) + .text_anchor_for_position(selection, cx) + }); + let Some((buffer, position)) = editor_anchor else { + self.state = FetchState::NoSymbol; + cx.notify(); + return; + }; + self.queried_buffer = Some(buffer.clone()); + self.state = FetchState::Loading; + cx.notify(); + + let prepare_task = self.project.update(cx, |project, cx| { + project.prepare_call_hierarchy(&buffer, position, cx) + }); + + self.fetch_task = Some(cx.spawn_in(window, async move |picker, mut cx| { + let root_item = match prepare_task.await { + Ok(items) => items.unwrap_or_default().into_iter().next(), + Err(error) => { + log::error!("failed to prepare call hierarchy: {error:#}"); + picker + .update_in(cx, |picker, window, cx| { + picker + .delegate + .workspace + .update(cx, |workspace, cx| workspace.show_error(error, cx)) + .ok(); + picker.delegate.dismissed(window, cx); + }) + .ok(); + return; + } + }; + let Some(root_item) = root_item else { + picker + .update_in(cx, |picker, _, cx| { + picker.delegate.state = FetchState::NoSymbol; + cx.notify(); + }) + .ok(); + return; + }; + picker + .update_in(cx, |picker, window, cx| { + picker.delegate.root_item = Some(root_item.clone()); + picker.refresh_placeholder(window, cx); + }) + .ok(); + load_and_apply_calls(picker, root_item, None, &mut cx).await; + })); + } + + fn load_calls( + &mut self, + root_item: CallHierarchyItem, + select_item: Option, + window: &mut Window, + cx: &mut Context>, + ) { + self.state = FetchState::Loading; + self.calls.clear(); + self.matches.clear(); + self.selected_index = 0; + cx.notify(); + + self.fetch_task = Some(cx.spawn_in(window, async move |picker, mut cx| { + load_and_apply_calls(picker, root_item, select_item, &mut cx).await; + })); + } + + fn selected_call(&self) -> Option<&Call> { + let mat = self.matches.get(self.selected_index)?; + self.calls.get(mat.candidate_id) + } + + fn expand_selected(&mut self, window: &mut Window, cx: &mut Context>) -> bool { + let Some(new_root) = self.selected_call().map(|call| call.item.clone()) else { + return false; + }; + if let Some(previous_root) = self.root_item.replace(new_root.clone()) { + self.root_stack.push(previous_root); + } + self.load_calls(new_root, None, window, cx); + true + } + + fn collapse_to_parent(&mut self, window: &mut Window, cx: &mut Context>) -> bool { + let Some(parent) = self.root_stack.pop() else { + return false; + }; + let child = self.root_item.replace(parent.clone()); + self.load_calls(parent, child, window, cx); + true + } + + fn preview_selected(&self, cx: &mut Context>) { + let call = self.selected_call(); + self.editor.update(cx, |editor, cx| { + editor.clear_row_highlights::(); + let Some(call) = call else { + return; + }; + let snapshot = editor.buffer().read(cx).snapshot(cx); + let Some(buffer_snapshot) = snapshot.as_singleton() else { + return; + }; + if buffer_snapshot.remote_id() != call.target.buffer.read(cx).remote_id() { + return; + } + let Some(start) = snapshot.anchor_in_buffer(call.target.range.start) else { + return; + }; + let Some(end) = snapshot.anchor_in_buffer(call.target.range.end) else { + return; + }; + editor.highlight_rows::( + start..end, + |cx| cx.theme().colors().editor_highlighted_line_background, + RowHighlightOptions { + autoscroll: true, + ..RowHighlightOptions::default() + }, + cx, + ); + editor.request_autoscroll(Autoscroll::center(), cx); + }); + } + + fn restore_editor(&self, window: &mut Window, cx: &mut App) { + let scroll_position = self.prev_scroll_position; + self.editor.update(cx, |editor, cx| { + editor.clear_row_highlights::(); + if let Some(scroll_position) = scroll_position { + editor.set_scroll_position(scroll_position, window, cx); + } + }); + } +} + +impl PickerDelegate for CallHierarchyDelegate { + type ListItem = ListItem; + + fn name() -> &'static str { + "call hierarchy" + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + match (&self.root_item, self.mode) { + (Some(root), CallHierarchyMode::Incoming) => { + Arc::from(format!("Search calls to `{}`...", root.name)) + } + (Some(root), CallHierarchyMode::Outgoing) => { + Arc::from(format!("Search calls from `{}`...", root.name)) + } + (None, CallHierarchyMode::Incoming) => Arc::from("Search incoming calls..."), + (None, CallHierarchyMode::Outgoing) => Arc::from("Search outgoing calls..."), + } + } + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + Some(SharedString::new_static(match self.state { + FetchState::Loading => "Fetching call hierarchy…", + FetchState::NoSymbol => "No callable symbol under the cursor", + FetchState::Loaded => { + if self.calls.is_empty() { + match self.mode { + CallHierarchyMode::Incoming => "No incoming calls found", + CallHierarchyMode::Outgoing => "No outgoing calls found", + } + } else { + "No matches" + } + } + })) + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + cx: &mut Context>, + ) { + self.selected_index = ix; + self.preview_selected(cx); + } + + fn update_matches( + &mut self, + query: String, + _window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + if query.is_empty() { + self.matches = identity_matches(self.calls.len()); + self.selected_index = 0; + return Task::ready(()); + } + + let candidates = self.candidates.clone(); + let executor = cx.background_executor().clone(); + cx.spawn(async move |picker, cx| { + let matches = fuzzy::match_strings( + candidates.as_slice(), + &query, + true, + true, + 100, + &AtomicBool::new(false), + executor, + ) + .await; + + picker + .update(cx, |picker, cx| { + picker.delegate.matches = matches; + picker.delegate.selected_index = 0; + cx.notify(); + }) + .ok(); + }) + } + + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { + let Some(call) = self.selected_call() else { + return; + }; + + let buffer = call.target.buffer.clone(); + let target = call.target.range.start; + self.prev_scroll_position = None; + + self.workspace + .update(cx, |workspace, cx| { + let position = target.to_point(&buffer.read(cx).snapshot()); + let pane = if secondary { + workspace.adjacent_pane(window, cx) + } else { + workspace.active_pane().clone() + }; + + let editor = workspace + .open_project_item::(pane, buffer, true, true, true, true, window, cx); + + editor.update(cx, |editor, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::center()), + window, + cx, + |s| s.select_ranges([position..position]), + ); + }); + }) + .ok(); + + self.dismissed(window, cx); + } + + fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { + self.restore_editor(window, cx); + self.view.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); + } + + fn select_child( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + self.expand_selected(window, cx).then(String::new) + } + + fn select_parent( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + self.collapse_to_parent(window, cx).then(String::new) + } + + fn render_footer( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + if self.calls.is_empty() && self.root_stack.is_empty() { + return None; + } + let focus_handle = self.focus_handle.clone(); + let expand_label = match self.mode { + CallHierarchyMode::Incoming => "Show Callers", + CallHierarchyMode::Outgoing => "Show Callees", + }; + Some( + h_flex() + .w_full() + .p_1p5() + .gap_0p5() + .justify_end() + .flex_wrap() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .when(!self.root_stack.is_empty(), |this| { + this.child( + Button::new("collapse-call", "Back") + .key_binding( + KeyBinding::for_action_in(&menu::SelectParent, &focus_handle, cx) + .map(|key_binding| key_binding.size(rems_from_px(12_f32))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SelectParent.boxed_clone(), cx); + }), + ) + }) + .when(!self.calls.is_empty(), |this| { + this.child( + Button::new("expand-call", expand_label) + .key_binding( + KeyBinding::for_action_in(&menu::SelectChild, &focus_handle, cx) + .map(|key_binding| key_binding.size(rems_from_px(12_f32))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SelectChild.boxed_clone(), cx); + }), + ) + }) + .child( + Button::new("toggle-direction", "Switch Direction") + .key_binding( + KeyBinding::for_action_in(&ToggleDirection, &focus_handle, cx) + .map(|key_binding| key_binding.size(rems_from_px(12_f32))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleDirection.boxed_clone(), cx); + }), + ) + .into_any_element(), + ) + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let mat = self.matches.get(ix)?; + let call = self.calls.get(mat.candidate_id)?; + + let (name_styled, detail_styled) = render_item(call, mat.ranges(), cx); + let tooltip_label = call.label.clone().zip(call.display.label_text.clone()); + let tooltip_max_width = self.modal_width; + let full_signature = call.display.full_signature.clone(); + let full_path = call.display.path.clone(); + + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child( + v_flex() + .text_ui(cx) + .child( + h_flex() + .overflow_x_hidden() + .text_size(ThemeSettings::get_global(cx).buffer_font_size(cx)) + .child(name_styled) + .children(detail_styled), + ) + .children(call.display.path.clone().map(|path| { + Label::new(path) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate() + })), + ) + .when(call.display.needs_tooltip, |this| { + this.tooltip(move |_window, cx| { + AnyView::from(cx.new(|_| CallSignatureTooltip { + label: tooltip_label.clone(), + full_signature: full_signature.clone(), + path: full_path.clone(), + max_width: tooltip_max_width, + })) + }) + }), + ) + } +} + +struct CallSignatureTooltip { + label: Option<(Arc, SharedString)>, + full_signature: SharedString, + path: Option, + max_width: Pixels, +} + +impl Render for CallSignatureTooltip { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let mut signature_style = buffer_text_style(cx); + signature_style.line_height = + relative(ThemeSettings::get_global(cx).buffer_line_height.value()); + let signature = match &self.label { + Some((label, label_text)) => StyledText::new(label_text.clone()) + .with_default_highlights( + &signature_style, + label_syntax_runs(label, cx.theme().syntax()), + ) + .into_any_element(), + None => div().child(self.full_signature.clone()).into_any_element(), + }; + let buffer_font_size = ThemeSettings::get_global(cx).buffer_font_size(cx); + let max_width = self.max_width; + let path = self.path.clone(); + tooltip_container(cx, move |el, _| { + el.max_w(max_width) + .gap_0p5() + .child(div().text_size(buffer_font_size).child(signature)) + .children( + path.map(|path| Label::new(path).size(LabelSize::Small).color(Color::Muted)), + ) + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FetchState { + Loading, + NoSymbol, + Loaded, +} + +struct CallHierarchyPreview; + +fn toggle_call_hierarchy( + editor: Entity, + mode: CallHierarchyMode, + window: &mut Window, + cx: &mut App, +) { + let Some(workspace) = editor.read(cx).workspace() else { + log::error!("call hierarchy: editor has no workspace"); + return; + }; + + let project = workspace.read(cx).project().clone(); + let workspace_weak = workspace.downgrade(); + + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + CallHierarchyView::new(editor, project, workspace_weak, mode, window, cx) + }); + }); +} + +fn item_location(item: &CallHierarchyItem) -> Location { + Location { + buffer: item.buffer.clone(), + range: item.selection_range.clone(), + } +} + +async fn attach_labels(calls: &mut [Call], project: &Entity, cx: &mut AsyncWindowContext) { + let mut fallback_groups = Vec::<(Arc, Vec)>::new(); + for (index, call) in calls.iter_mut().enumerate() { + let Some(language) = call + .item + .buffer + .read_with(cx, |buffer, _| buffer.language().cloned()) + else { + continue; + }; + if let Some(label) = signature_label(&call.item, &language) { + call.label = Some(Arc::new(label)); + continue; + } + match fallback_groups + .iter_mut() + .find(|(group_language, _)| group_language.name() == language.name()) + { + Some((_, indices)) => indices.push(index), + None => fallback_groups.push((language, vec![index])), + } + } + if fallback_groups.is_empty() { + return; + } + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + for (language, indices) in fallback_groups { + let Some(lsp_adapter) = language_registry + .lsp_adapters(&language.name()) + .first() + .cloned() + else { + continue; + }; + let symbols = indices + .iter() + .map(|&index| language::Symbol { + name: calls[index].item.name.clone(), + kind: lsp_to_symbol_kind(calls[index].item.kind), + container_name: None, + }) + .collect::>(); + let Some(labels) = lsp_adapter + .labels_for_symbols(&symbols, &language) + .await + .log_err() + else { + continue; + }; + for (index, label) in indices.into_iter().zip(labels) { + calls[index].label = label.map(Arc::new); + } + } +} + +async fn load_and_apply_calls( + picker: WeakEntity>, + root_item: CallHierarchyItem, + select_item: Option, + cx: &mut AsyncWindowContext, +) { + let Ok((project, mode)) = picker.read_with(cx, |picker, _| { + (picker.delegate.project.clone(), picker.delegate.mode) + }) else { + return; + }; + let mut calls = fetch_calls(&root_item, &project, mode, cx).await; + picker + .update_in(cx, |picker, window, cx| { + let row_insets = (DynamicSpacing::Base04.rems(cx).to_pixels(window.rem_size()) + + DynamicSpacing::Base06.rems(cx).to_pixels(window.rem_size())) + * 2.; + let available_width = picker.delegate.modal_width - row_insets; + let settings = ThemeSettings::get_global(cx); + let buffer_font_size = settings.buffer_font_size(cx); + let path_font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size()); + for call in calls.iter_mut() { + let signature = call + .display + .label_text + .clone() + .unwrap_or_else(|| call.display.full_signature.clone()); + let signature_width = shaped_width( + signature, + settings.buffer_font.clone(), + buffer_font_size, + window, + ); + let path_width = call + .display + .path + .clone() + .map(|path| { + shaped_width(path, settings.ui_font.clone(), path_font_size, window) + }) + .unwrap_or_default(); + call.display.needs_tooltip = signature_width.max(path_width) > available_width; + } + picker.delegate.state = FetchState::Loaded; + picker.delegate.calls = calls; + picker.delegate.candidates = Arc::new( + picker + .delegate + .calls + .iter() + .enumerate() + .map(|(id, call)| StringMatchCandidate::new(id, &call.item.name)) + .collect(), + ); + picker.delegate.matches = identity_matches(picker.delegate.calls.len()); + picker.delegate.selected_index = 0; + let restored_index = select_item.and_then(|target| { + picker.delegate.calls.iter().position(|call| { + call.item.buffer == target.buffer + && call.item.selection_range == target.selection_range + && call.item.name == target.name + }) + }); + if let Some(restored_index) = restored_index { + picker.set_selected_index(restored_index, None, true, window, cx); + } + picker.refresh_placeholder(window, cx); + cx.notify(); + }) + .ok(); +} + +fn call_display_path(buffer: &Buffer, cx: &App) -> Option { + let file = buffer.file()?; + Some( + if let Some(project_file) = project::File::from_dyn(Some(file)) + && project_file.worktree.read(cx).is_visible() + { + project_file.full_path(cx) + } else if let Some(local_file) = file.as_local() { + local_file.abs_path(cx).compact() + } else { + file.full_path(cx) + }, + ) +} + +fn signature_label(item: &CallHierarchyItem, language: &Arc) -> Option { + let detail = item + .detail + .as_deref() + .map(str::trim) + .filter(|detail| !detail.is_empty())?; + let signature = collapse_whitespace(detail); + let name_start = find_symbol_name(&signature, &item.name)?; + let filter_range = name_start..name_start + item.name.len(); + let source = Rope::from_iter([signature.as_str(), " {}"]); + let runs = language.highlight_text(&source, 0..signature.len()); + Some(CodeLabel::new(signature, filter_range, runs)) +} + +fn collapse_whitespace(text: &str) -> String { + fn is_word(ch: char) -> bool { + ch.is_alphanumeric() || ch == '_' + } + + let mut result = String::new(); + let mut previous_indent = 0; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let indent = line.chars().take_while(|ch| ch.is_whitespace()).count(); + let fragment = trimmed.split_whitespace().collect::>().join(" "); + if result.is_empty() { + result = fragment; + } else { + let opens_block = indent > previous_indent + && result.chars().last().is_some_and(|last| !is_word(last)); + let closes_block = indent < previous_indent + && fragment.chars().next().is_some_and(|first| !is_word(first)); + if closes_block && result.ends_with(',') { + result.pop(); + } + if !opens_block && !closes_block { + result.push(' '); + } + result.push_str(&fragment); + } + previous_indent = indent; + } + if result.ends_with(',') { + result.pop(); + } + result +} + +fn find_symbol_name(text: &str, symbol_name: &str) -> Option { + if symbol_name.is_empty() { + return None; + } + text.match_indices(symbol_name) + .map(|(start, _)| start) + .find(|&start| { + let boundary_before = text[..start] + .chars() + .next_back() + .is_none_or(|c| !c.is_alphanumeric() && c != '_'); + let boundary_after = text[start + symbol_name.len()..] + .chars() + .next() + .is_none_or(|c| !c.is_alphanumeric() && c != '_'); + boundary_before && boundary_after + }) +} + +fn identity_matches(count: usize) -> Vec { + (0..count) + .map(|index| StringMatch { + candidate_id: index, + score: 0.0, + positions: Vec::new(), + string: String::new(), + }) + .collect() +} + +/// Extracts display information from a `Call` for rendering in UI. +fn compute_call_display(call: &Call, cx: &App) -> CallDisplay { + let buffer = call.target.buffer.read(cx); + let line_number = call.target.range.start.to_point(&buffer.snapshot()).row + 1; + let path = call_display_path(buffer, cx).map(|path| { + let path = path.to_string_lossy(); + SharedString::from(if call.site_count > 1 { + format!("{path}:{line_number} ({} calls)", call.site_count) + } else { + format!("{path}:{line_number}") + }) + }); + + let detail = extract_call_detail(call).map(SharedString::from); + let full_signature = SharedString::from(match &detail { + Some(detail) => format!("{}{detail}", call.item.name), + None => call.item.name.clone(), + }); + let label_text = call + .label + .as_ref() + .map(|label| SharedString::from(label.text.clone())); + + CallDisplay { + name: SharedString::from(call.item.name.clone()), + detail, + path, + full_signature, + label_text, + needs_tooltip: false, + } +} + +fn extract_call_detail(call: &Call) -> Option { + let detail = if let Some(detail) = call.label.as_deref().and_then(detail_from_label_suffix) { + Some(detail) + } else { + call.item + .detail + .as_deref() + .map(str::trim) + .filter(|detail| !detail.is_empty()) + .and_then(|detail| { + trim_detail_after_symbol_name(detail, &call.item.name) + .or_else(|| Some(detail.to_owned())) + }) + }; + detail.map(|detail| collapse_whitespace(&detail)) +} + +fn detail_from_label_suffix(label: &CodeLabel) -> Option { + label + .text + .get(label.filter_range.end..) + .map(str::trim) + .filter(|suffix| !suffix.is_empty()) + .map(ToOwned::to_owned) +} + +fn trim_detail_after_symbol_name(detail: &str, symbol_name: &str) -> Option { + let name_start = find_symbol_name(detail, symbol_name)?; + let suffix = detail.get(name_start + symbol_name.len()..)?.trim(); + (!suffix.is_empty()).then(|| suffix.to_owned()) +} + +fn shaped_width( + text: SharedString, + font: gpui::Font, + font_size: Pixels, + window: &Window, +) -> Pixels { + let run = TextRun { + len: text.len(), + font, + color: gpui::Hsla::default(), + background_color: None, + underline: None, + strikethrough: None, + }; + window + .text_system() + .shape_line(text, font_size, &[run], None) + .width +} + +fn buffer_text_style(cx: &App) -> TextStyle { + let settings = ThemeSettings::get_global(cx); + TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_size: AbsoluteLength::from(settings.buffer_font_size(cx)), + font_weight: settings.buffer_font.weight, + line_height: relative(1.), + ..TextStyle::default() + } +} + +fn label_syntax_runs<'a>( + label: &'a CodeLabel, + syntax_theme: &'a SyntaxTheme, +) -> impl Iterator, HighlightStyle)> + 'a { + label.runs.iter().filter_map(|(range, highlight_id)| { + Some((range.clone(), *syntax_theme.get(*highlight_id)?)) + }) +} + +fn render_item( + call_item: &Call, + match_ranges: impl IntoIterator>, + cx: &App, +) -> (StyledText, Option) { + let mut base_text_style = buffer_text_style(cx); + base_text_style.text_overflow = Some(gpui::TextOverflow::Truncate(SharedString::from("…"))); + + let highlight_style = HighlightStyle { + background_color: Some(cx.theme().colors().text_accent.alpha(0.3)), + ..HighlightStyle::default() + }; + + let label_carries_detail = call_item + .label + .as_ref() + .is_some_and(|label| detail_from_label_suffix(label).is_some()); + let detail_styled = if label_carries_detail { + None + } else { + call_item.display.detail.clone().map(|detail| { + let mut detail_style = base_text_style.clone(); + detail_style.color = cx.theme().colors().text_muted; + StyledText::new(detail).with_default_highlights(&detail_style, std::iter::empty()) + }) + }; + + let name_styled = if let Some((label, label_text)) = call_item + .label + .as_ref() + .zip(call_item.display.label_text.clone()) + { + let syntax_runs = label_syntax_runs(label, cx.theme().syntax()); + let custom_highlights = match_ranges.into_iter().map(|range| { + let start = label.filter_range.start + range.start; + let end = label.filter_range.start + range.end; + (start..end, highlight_style) + }); + + StyledText::new(label_text).with_default_highlights( + &base_text_style, + gpui::combine_highlights(custom_highlights, syntax_runs), + ) + } else { + StyledText::new(call_item.display.name.clone()).with_default_highlights( + &base_text_style, + match_ranges.into_iter().map(|r| (r, highlight_style)), + ) + }; + + (name_styled, detail_styled) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::StreamExt as _; + use gpui::{Hsla, TestAppContext, VisualTestContext}; + use language::{FakeLspAdapter, LanguageConfig, LanguageMatcher, LanguageName}; + use project::{CallHierarchyItem, FakeFs}; + use serde_json::json; + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + use theme::SyntaxTheme; + use util::{path, rel_path::rel_path}; + use workspace::{AppState, MultiWorkspace}; + + #[gpui::test] + async fn test_call_display_basic(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({"src": {"main.rs": source()}})) + .await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + + let call = make_call( + "my_function", + path!("/test/src/main.rs").as_ref(), + 10, + None, + &project, + cx, + ) + .await; + let display = cx.update(|cx| compute_call_display(&call, cx)); + + #[cfg(not(windows))] + let expected_path = "test/src/main.rs:11"; + #[cfg(windows)] + let expected_path = "test\\src\\main.rs:11"; + + assert_eq!(display.name, "my_function"); + assert_eq!(display.detail, None); + assert_eq!(display.path.as_deref(), Some(expected_path)); + } + + #[gpui::test] + async fn test_call_display_external_path_compacted(cx: &mut TestAppContext) { + init_test(cx); + + let home_dir = util::paths::home_dir(); + let external_path = home_dir + .join("projects") + .join("app") + .join("src") + .join("main.rs"); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({"main.rs": source()})) + .await; + fs.insert_tree( + &home_dir, + json!({"projects": {"app": {"src": {"main.rs": source()}}}}), + ) + .await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + + let call = make_call("my_function", &external_path, 10, None, &project, cx).await; + let display = cx.update(|cx| compute_call_display(&call, cx)); + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] + let expected_path = "~/projects/app/src/main.rs:11".to_string(); + #[cfg(windows)] + let expected_path = format!("{}:11", external_path.to_string_lossy()); + + assert_eq!(display.name, "my_function"); + assert_eq!(display.detail, None); + assert_eq!(display.path.as_deref(), Some(expected_path.as_str())); + } + + #[gpui::test] + async fn test_call_display_with_detail(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({"src": {"lib.rs": source()}})) + .await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + + let call = make_call( + "helper", + path!("/test/src/lib.rs").as_ref(), + 5, + Some("fn helper() -> i32".to_string()), + &project, + cx, + ) + .await; + let display = cx.update(|cx| compute_call_display(&call, cx)); + + #[cfg(not(windows))] + let expected_path = "test/src/lib.rs:6"; + #[cfg(windows)] + let expected_path = "test\\src\\lib.rs:6"; + + assert_eq!(display.name, "helper"); + assert_eq!(display.detail.as_deref(), Some("() -> i32")); + assert_eq!(display.path.as_deref(), Some(expected_path)); + } + + #[gpui::test] + async fn test_call_display_prefers_label_suffix(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({"src": {"lib.rs": source()}})) + .await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + + let mut call = make_call( + "helper", + path!("/test/src/lib.rs").as_ref(), + 5, + Some("fn helper(&self) -> i32".to_string()), + &project, + cx, + ) + .await; + call.label = Some(Arc::new(CodeLabel::new( + "fn helper(&self) -> i32".to_string(), + 3..9, + Vec::new(), + ))); + + let display = cx.update(|cx| compute_call_display(&call, cx)); + assert_eq!(display.detail.as_deref(), Some("(&self) -> i32")); + } + + #[gpui::test] + async fn test_call_display_trims_raw_detail_after_symbol_name(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({"src": {"lib.rs": source()}})) + .await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + + let call = make_call( + "helper", + path!("/test/src/lib.rs").as_ref(), + 5, + Some("fn helper(&self, arg: i32) -> i32".to_string()), + &project, + cx, + ) + .await; + + let display = cx.update(|cx| compute_call_display(&call, cx)); + assert_eq!(display.detail.as_deref(), Some("(&self, arg: i32) -> i32")); + } + + #[gpui::test] + async fn test_call_display_multiline_detail(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({"main.rs": source()})) + .await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + + let call = make_call( + "foo", + path!("/test/main.rs").as_ref(), + 0, + Some("line1\nline2\nline3".to_string()), + &project, + cx, + ) + .await; + let display = cx.update(|cx| compute_call_display(&call, cx)); + + assert_eq!(display.name, "foo"); + assert_eq!(display.detail.as_deref(), Some("line1 line2 line3")); + } + + #[gpui::test] + async fn test_call_display_multiline_where_clause(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({"main.rs": source()})) + .await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + + let call = make_call( + "contains", + path!("/test/main.rs").as_ref(), + 0, + Some( + "fn contains(&self, value: &Q) -> bool\nwhere\n Q: ?Sized,\n T: Borrow," + .to_string(), + ), + &project, + cx, + ) + .await; + let display = cx.update(|cx| compute_call_display(&call, cx)); + + assert_eq!( + display.detail.as_deref(), + Some("(&self, value: &Q) -> bool where Q: ?Sized, T: Borrow") + ); + } + + #[gpui::test] + async fn test_call_display_multiline_params(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({"main.rs": source()})) + .await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + + let call = make_call( + "hints", + path!("/test/main.rs").as_ref(), + 0, + Some( + "fn hints(\n hints: &mut Vec,\n ctx: &mut InlayHintCtx,\n)" + .to_string(), + ), + &project, + cx, + ) + .await; + let display = cx.update(|cx| compute_call_display(&call, cx)); + + assert_eq!( + display.detail.as_deref(), + Some("(hints: &mut Vec, ctx: &mut InlayHintCtx)") + ); + } + + #[gpui::test] + async fn test_call_display_single_letter_name(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({"main.rs": source()})) + .await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + + let call = make_call( + "f", + path!("/test/main.rs").as_ref(), + 0, + Some("fn f()".to_string()), + &project, + cx, + ) + .await; + let display = cx.update(|cx| compute_call_display(&call, cx)); + + assert_eq!(display.detail.as_deref(), Some("()")); + } + + #[gpui::test] + async fn test_signature_label_highlights(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({"main.rs": source()})) + .await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + + let call = make_call( + "handle_event", + path!("/test/main.rs").as_ref(), + 0, + Some("fn handle_event(\n ctx: &mut InlayHintCtx<'_>,\n) -> bool".to_string()), + &project, + cx, + ) + .await; + + let language = rust_lang_with_highlights(); + let label = signature_label(&call.item, &language).expect("signature label"); + assert_eq!( + label.text, + "fn handle_event(ctx: &mut InlayHintCtx<'_>) -> bool" + ); + assert_eq!(label.filter_range, 3..15); + assert_eq!(&label.text[label.filter_range.clone()], "handle_event"); + + let grammar = language.grammar().expect("grammar"); + let keyword = grammar.highlight_id_for_name("keyword").expect("keyword"); + let function = grammar.highlight_id_for_name("function").expect("function"); + let type_id = grammar.highlight_id_for_name("type").expect("type"); + let lifetime = grammar.highlight_id_for_name("lifetime").expect("lifetime"); + assert_eq!( + label.runs, + vec![ + (0..2, keyword), + (3..15, function), + (22..25, keyword), + (26..38, type_id), + (39..41, lifetime), + ] + ); + } + + #[test] + fn test_find_symbol_name() { + assert_eq!(find_symbol_name("fn f()", "f"), Some(3)); + assert_eq!(find_symbol_name("static void a(int a)", "a"), Some(12)); + assert_eq!( + find_symbol_name("fn contains(q: &Q)", "contains"), + Some(3) + ); + assert_eq!(find_symbol_name("fn foo_bar(foo: Foo)", "foo"), Some(11)); + assert_eq!(find_symbol_name("namespace::Class", "foo"), None); + assert_eq!(find_symbol_name("fn foo()", ""), None); + } + + #[gpui::test] + async fn test_call_hierarchy_modal_basic(cx: &mut TestAppContext) { + init_test(cx); + let (workspace, fake_server, test_uri, cx) = + setup_modal_test("fn main() { helper(); }\nfn helper() {}\n", cx).await; + + fake_server.set_request_handler::({ + let uri = test_uri.clone(); + move |_, _| { + let uri = uri.clone(); + async move { Ok(Some(vec![make_lsp_call_hierarchy_item("main", uri, 0)])) } + } + }); + + fake_server.set_request_handler::({ + move |_, _| { + let uri = test_uri.clone(); + async move { + Ok(Some(vec![lsp::CallHierarchyOutgoingCall { + to: make_lsp_call_hierarchy_item("helper", uri, 1), + from_ranges: Vec::new(), + }])) + } + } + }); + + cx.dispatch_action(ShowOutgoingCalls); + cx.executor().run_until_parked(); + + let modal = workspace + .update(cx, |workspace, cx| { + workspace.active_modal::(cx) + }) + .expect("Call hierarchy modal should be open"); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!(delegate.state, FetchState::Loaded); + assert_eq!( + delegate + .root_item + .as_ref() + .map(|root_item| root_item.name.as_str()), + Some("main") + ); + assert_eq!(delegate.calls.len(), 1); + assert_eq!(delegate.calls[0].item.name, "helper"); + assert_eq!(delegate.calls[0].site_count, 1); + }); + } + + #[gpui::test] + async fn test_call_hierarchy_modal_incoming_mode(cx: &mut TestAppContext) { + init_test(cx); + let (workspace, fake_server, test_uri, cx) = + setup_modal_test("fn main() { helper(); }\nfn helper() {}\n", cx).await; + + fake_server.set_request_handler::({ + let uri = test_uri.clone(); + move |_, _| { + let uri = uri.clone(); + async move { Ok(Some(vec![make_lsp_call_hierarchy_item("helper", uri, 1)])) } + } + }); + + fake_server.set_request_handler::({ + move |_, _| { + let uri = test_uri.clone(); + async move { + Ok(Some(vec![lsp::CallHierarchyIncomingCall { + from: make_lsp_call_hierarchy_item("main", uri, 0), + from_ranges: vec![lsp::Range { + start: lsp::Position { + line: 0, + character: 12, + }, + end: lsp::Position { + line: 0, + character: 18, + }, + }], + }])) + } + } + }); + + cx.dispatch_action(ShowIncomingCalls); + cx.executor().run_until_parked(); + + let modal = workspace + .update(cx, |workspace, cx| { + workspace.active_modal::(cx) + }) + .expect("Call hierarchy modal should be open in incoming mode"); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!(delegate.mode, CallHierarchyMode::Incoming); + assert_eq!(delegate.calls.len(), 1); + assert_eq!(delegate.calls[0].item.name, "main"); + let target_start = delegate.calls[0] + .target + .range + .start + .to_point(&delegate.calls[0].target.buffer.read(cx).snapshot()); + assert_eq!(target_start, language::Point::new(0, 12)); + }); + + let editor = workspace + .update(cx, |workspace, cx| workspace.active_item_as::(cx)) + .expect("editor should be open"); + cx.executor() + .advance_clock(std::time::Duration::from_millis(200)); + cx.dispatch_action(menu::SelectNext); + let highlighted_rows = highlighted_display_rows(&editor, cx); + assert_eq!( + highlighted_rows, + vec![0], + "selecting a call should preview its call site in the editor" + ); + + cx.dispatch_action(menu::Cancel); + let highlighted_rows = highlighted_display_rows(&editor, cx); + assert_eq!( + highlighted_rows, + Vec::::new(), + "dismissing the modal should clear the preview highlights" + ); + } + + #[gpui::test] + async fn test_call_hierarchy_modal_filtering(cx: &mut TestAppContext) { + init_test(cx); + let (workspace, fake_server, test_uri, cx) = setup_modal_test( + "fn main() { foo(); bar(); baz(); }\nfn foo() {}\nfn bar() {}\nfn baz() {}\n", + cx, + ) + .await; + + fake_server.set_request_handler::({ + let uri = test_uri.clone(); + move |_, _| { + let uri = uri.clone(); + async move { Ok(Some(vec![make_lsp_call_hierarchy_item("main", uri, 0)])) } + } + }); + + fake_server.set_request_handler::({ + let uri = test_uri.clone(); + move |_, _| { + let uri = uri.clone(); + async move { + Ok(Some(vec![ + lsp::CallHierarchyOutgoingCall { + to: make_lsp_call_hierarchy_item("foo", uri.clone(), 1), + from_ranges: Vec::new(), + }, + lsp::CallHierarchyOutgoingCall { + to: make_lsp_call_hierarchy_item("bar_1", uri.clone(), 2), + from_ranges: Vec::new(), + }, + lsp::CallHierarchyOutgoingCall { + to: make_lsp_call_hierarchy_item("bar_2", uri.clone(), 2), + from_ranges: Vec::new(), + }, + lsp::CallHierarchyOutgoingCall { + to: make_lsp_call_hierarchy_item("baz", uri, 3), + from_ranges: Vec::new(), + }, + ])) + } + } + }); + + cx.dispatch_action(ShowOutgoingCalls); + cx.executor().run_until_parked(); + + let modal = workspace.update(cx, |workspace, cx| { + workspace.active_modal::(cx) + }); + assert!(modal.is_some(), "Modal should be open"); + + let modal = modal.unwrap(); + let match_count_before = + modal.read_with(cx, |view, cx| view.picker.read(cx).delegate.matches.len()); + assert_eq!(match_count_before, 4, "Should have 4 matches initially"); + + let task = modal.update_in(cx, |view, window, cx| { + view.picker.update(cx, |picker, cx| { + picker + .delegate + .update_matches("bar".to_string(), window, cx) + }) + }); + task.await; + cx.executor().run_until_parked(); + + let match_count_after = + modal.read_with(cx, |view, cx| view.picker.read(cx).delegate.matches.len()); + assert_eq!( + match_count_after, 2, + "Filter 'bar' should match 2 entries (bar, baz)" + ); + } + + #[gpui::test] + async fn test_call_hierarchy_modal_direction_toggle(cx: &mut TestAppContext) { + init_test(cx); + let (workspace, fake_server, test_uri, cx) = + setup_modal_test("fn main() { helper(); }\nfn helper() {}\n", cx).await; + + fake_server.set_request_handler::({ + let uri = test_uri.clone(); + move |_, _| { + let uri = uri.clone(); + async move { Ok(Some(vec![make_lsp_call_hierarchy_item("helper", uri, 1)])) } + } + }); + fake_server.set_request_handler::({ + let uri = test_uri.clone(); + move |_, _| { + let uri = uri.clone(); + async move { + Ok(Some(vec![lsp::CallHierarchyOutgoingCall { + to: make_lsp_call_hierarchy_item("callee", uri, 1), + from_ranges: Vec::new(), + }])) + } + } + }); + fake_server.set_request_handler::({ + move |_, _| { + let uri = test_uri.clone(); + async move { + Ok(Some(vec![lsp::CallHierarchyIncomingCall { + from: make_lsp_call_hierarchy_item("caller", uri, 0), + from_ranges: Vec::new(), + }])) + } + } + }); + + cx.dispatch_action(ShowOutgoingCalls); + cx.executor() + .advance_clock(std::time::Duration::from_millis(200)); + + let modal = workspace + .update(cx, |workspace, cx| { + workspace.active_modal::(cx) + }) + .expect("modal should be open"); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!(delegate.mode, CallHierarchyMode::Outgoing); + assert_eq!(delegate.calls[0].item.name, "callee"); + }); + + let picker_focused = cx.update(|window, cx| { + modal + .read(cx) + .picker + .focus_handle(cx) + .contains_focused(window, cx) + }); + assert!(picker_focused, "opening the modal should focus the picker"); + + cx.dispatch_action(ToggleDirection); + cx.executor() + .advance_clock(std::time::Duration::from_millis(200)); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!(delegate.mode, CallHierarchyMode::Incoming); + assert_eq!( + delegate + .root_item + .as_ref() + .map(|root_item| root_item.name.as_str()), + Some("helper"), + "toggling direction should keep the same root" + ); + assert_eq!(delegate.calls[0].item.name, "caller"); + }); + + cx.dispatch_action(ShowOutgoingCalls); + cx.executor() + .advance_clock(std::time::Duration::from_millis(200)); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!(delegate.mode, CallHierarchyMode::Outgoing); + assert_eq!(delegate.calls[0].item.name, "callee"); + }); + + cx.dispatch_action(ShowOutgoingCalls); + cx.executor() + .advance_clock(std::time::Duration::from_millis(200)); + let has_modal = workspace.update(cx, |workspace, cx| { + workspace.active_modal::(cx).is_some() + }); + assert!( + !has_modal, + "dispatching the current mode's action should dismiss the modal" + ); + } + + #[gpui::test] + async fn test_call_hierarchy_modal_expand_and_back(cx: &mut TestAppContext) { + init_test(cx); + let (workspace, fake_server, test_uri, cx) = setup_modal_test( + "fn main() { alpha(); helper(); zeta(); }\nfn alpha() {}\nfn helper() { leaf(); }\nfn zeta() {}\nfn leaf() {}\n", + cx, + ) + .await; + + fake_server.set_request_handler::({ + let uri = test_uri.clone(); + move |_, _| { + let uri = uri.clone(); + async move { Ok(Some(vec![make_lsp_call_hierarchy_item("main", uri, 0)])) } + } + }); + fake_server.set_request_handler::({ + move |params, _| { + let uri = test_uri.clone(); + async move { + let callees = match params.item.name.as_str() { + "main" => vec![ + make_lsp_call_hierarchy_item("alpha", uri.clone(), 1), + make_lsp_call_hierarchy_item("helper", uri.clone(), 2), + make_lsp_call_hierarchy_item("zeta", uri, 3), + ], + "helper" => vec![make_lsp_call_hierarchy_item("leaf", uri, 4)], + _ => Vec::new(), + }; + Ok(Some( + callees + .into_iter() + .map(|to| lsp::CallHierarchyOutgoingCall { + to, + from_ranges: Vec::new(), + }) + .collect(), + )) + } + } + }); + + cx.dispatch_action(ShowOutgoingCalls); + cx.executor().run_until_parked(); + + let modal = workspace + .update(cx, |workspace, cx| { + workspace.active_modal::(cx) + }) + .expect("modal should be open"); + let picker = modal.read_with(cx, |view, _| view.picker.clone()); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!( + delegate + .calls + .iter() + .map(|call| call.item.name.as_str()) + .collect::>(), + vec!["alpha", "helper", "zeta"] + ); + }); + + cx.update(|_, cx| { + cx.bind_keys([ + gpui::KeyBinding::new("right", editor::actions::MoveRight, Some("Editor")), + gpui::KeyBinding::new("left", editor::actions::MoveLeft, Some("Editor")), + gpui::KeyBinding::new("down", menu::SelectNext, Some("Picker > Editor")), + gpui::KeyBinding::new( + "cmd-k right", + menu::SelectChild, + Some("CallHierarchyPicker > Picker > Editor"), + ), + gpui::KeyBinding::new( + "cmd-k left", + menu::SelectParent, + Some("CallHierarchyPicker > Picker > Editor"), + ), + ]); + }); + + cx.simulate_keystrokes("down cmd-k right"); + cx.executor().run_until_parked(); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!( + delegate + .root_item + .as_ref() + .map(|root_item| root_item.name.as_str()), + Some("helper"), + "the expand chord should re-root the hierarchy at the selected call" + ); + assert_eq!( + delegate + .root_stack + .iter() + .map(|item| item.name.as_str()) + .collect::>(), + vec!["main"] + ); + assert_eq!(delegate.calls[0].item.name, "leaf"); + assert_eq!(view.picker.read(cx).query(cx), ""); + }); + + cx.simulate_keystrokes("cmd-k right"); + cx.executor().run_until_parked(); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!( + delegate + .root_item + .as_ref() + .map(|root_item| root_item.name.as_str()), + Some("leaf") + ); + assert_eq!(delegate.state, FetchState::Loaded); + assert_eq!( + delegate.calls.len(), + 0, + "leaf function should have no callees" + ); + }); + + cx.simulate_keystrokes("cmd-k left"); + cx.executor().run_until_parked(); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!( + delegate + .root_item + .as_ref() + .map(|root_item| root_item.name.as_str()), + Some("helper"), + "the back chord should step back up from the empty leaf state" + ); + assert_eq!(delegate.selected_index, 0); + assert_eq!( + delegate.selected_call().map(|call| call.item.name.as_str()), + Some("leaf"), + "stepping back should select the item we drilled into" + ); + }); + + cx.simulate_keystrokes("cmd-k left"); + cx.executor().run_until_parked(); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!( + delegate + .root_item + .as_ref() + .map(|root_item| root_item.name.as_str()), + Some("main") + ); + assert_eq!(delegate.root_stack.len(), 0); + assert_eq!( + delegate.selected_index, 1, + "stepping back should reselect the item we drilled into, not the first row" + ); + assert_eq!( + delegate.selected_call().map(|call| call.item.name.as_str()), + Some("helper") + ); + }); + + let collapsed_again = picker.update_in(cx, |picker, window, cx| { + picker.delegate.select_parent(window, cx) + }); + assert_eq!(collapsed_again, None); + + cx.simulate_input("he"); + cx.simulate_keystrokes("right"); + cx.executor().run_until_parked(); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!( + delegate + .root_item + .as_ref() + .map(|root_item| root_item.name.as_str()), + Some("main"), + "a bare arrow must not expand the hierarchy" + ); + assert_eq!(view.picker.read(cx).query(cx), "he"); + }); + + cx.simulate_keystrokes("cmd-k right"); + cx.executor().run_until_parked(); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!( + delegate + .root_item + .as_ref() + .map(|root_item| root_item.name.as_str()), + Some("helper"), + "expanding a filtered selection should drill into it" + ); + assert_eq!(view.picker.read(cx).query(cx), ""); + }); + } + + #[gpui::test] + async fn test_call_hierarchy_modal_refreshes_on_buffer_registration(cx: &mut TestAppContext) { + init_test(cx); + let (workspace, fake_server, test_uri, cx) = + setup_modal_test("fn main() { helper(); }\nfn helper() {}\n", cx).await; + + let server_ready = Arc::new(AtomicBool::new(false)); + fake_server.set_request_handler::({ + let uri = test_uri.clone(); + let server_ready = server_ready.clone(); + move |_, _| { + let uri = uri.clone(); + let server_ready = server_ready.clone(); + async move { + if server_ready.load(Ordering::Acquire) { + Ok(Some(vec![make_lsp_call_hierarchy_item("helper", uri, 1)])) + } else { + Ok(Some(Vec::new())) + } + } + } + }); + fake_server.set_request_handler::({ + let uri = test_uri.clone(); + move |_, _| { + let uri = uri.clone(); + async move { + Ok(Some(vec![lsp::CallHierarchyIncomingCall { + from: make_lsp_call_hierarchy_item("main", uri, 0), + from_ranges: Vec::new(), + }])) + } + } + }); + + cx.dispatch_action(ShowIncomingCalls); + cx.executor().run_until_parked(); + + let modal = workspace + .update(cx, |workspace, cx| { + workspace.active_modal::(cx) + }) + .expect("modal should be open"); + let (project, buffer_id) = modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!( + delegate.state, + FetchState::NoSymbol, + "prepare against a not-yet-ready server should find nothing" + ); + assert_eq!( + delegate.root_item.as_ref().map(|item| item.name.as_str()), + None + ); + let buffer = delegate + .queried_buffer + .clone() + .expect("queried buffer should be recorded"); + (delegate.project.clone(), buffer.read(cx).remote_id()) + }); + + let emit_registered = |cx: &mut VisualTestContext| { + project.update(cx, |_, cx| { + cx.emit(project::Event::LanguageServerBufferRegistered { + server_id: lsp::LanguageServerId(0), + buffer_id, + buffer_abs_path: std::path::PathBuf::from(path!("/test/src/main.rs")), + name: None, + }); + }); + }; + + server_ready.store(true, Ordering::Release); + emit_registered(cx); + cx.executor().run_until_parked(); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!( + delegate.root_item.as_ref().map(|item| item.name.as_str()), + Some("helper"), + "buffer registration should retry the preparation" + ); + assert_eq!(delegate.calls[0].item.name, "main"); + }); + + fake_server.set_request_handler::({ + move |_, _| { + let uri = test_uri.clone(); + async move { + Ok(Some(vec![lsp::CallHierarchyIncomingCall { + from: make_lsp_call_hierarchy_item("other", uri, 0), + from_ranges: Vec::new(), + }])) + } + } + }); + emit_registered(cx); + emit_work_end(&project, cx); + cx.executor().run_until_parked(); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!( + delegate.calls[0].item.name, "main", + "server events after results arrived should not refetch" + ); + }); + } + + #[gpui::test] + async fn test_call_hierarchy_modal_refreshes_on_lsp_work_end(cx: &mut TestAppContext) { + init_test(cx); + let (workspace, fake_server, test_uri, cx) = + setup_modal_test("fn main() { helper(); }\nfn helper() {}\n", cx).await; + + let server_ready = Arc::new(AtomicBool::new(false)); + fake_server.set_request_handler::({ + let uri = test_uri.clone(); + let server_ready = server_ready.clone(); + move |_, _| { + let uri = uri.clone(); + let server_ready = server_ready.clone(); + async move { + if server_ready.load(Ordering::Acquire) { + Ok(Some(vec![make_lsp_call_hierarchy_item("helper", uri, 1)])) + } else { + Ok(Some(Vec::new())) + } + } + } + }); + fake_server.set_request_handler::({ + move |_, _| { + let uri = test_uri.clone(); + async move { + Ok(Some(vec![lsp::CallHierarchyIncomingCall { + from: make_lsp_call_hierarchy_item("main", uri, 0), + from_ranges: Vec::new(), + }])) + } + } + }); + + cx.dispatch_action(ShowIncomingCalls); + cx.executor().run_until_parked(); + + let modal = workspace + .update(cx, |workspace, cx| { + workspace.active_modal::(cx) + }) + .expect("modal should be open"); + let project = modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!(delegate.state, FetchState::NoSymbol); + delegate.project.clone() + }); + + emit_work_end(&project, cx); + cx.executor().run_until_parked(); + modal.read_with(cx, |view, cx| { + assert_eq!( + view.picker.read(cx).delegate.state, + FetchState::NoSymbol, + "a retry against a still-empty server should stay empty" + ); + }); + + server_ready.store(true, Ordering::Release); + emit_work_end(&project, cx); + cx.executor().run_until_parked(); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!( + delegate.root_item.as_ref().map(|item| item.name.as_str()), + Some("helper"), + "finished language server work should retry the preparation" + ); + assert_eq!(delegate.calls[0].item.name, "main"); + }); + } + + #[gpui::test] + async fn test_call_hierarchy_modal_no_symbol(cx: &mut TestAppContext) { + init_test(cx); + let (workspace, fake_server, _test_uri, cx) = setup_modal_test("fn main() {}\n", cx).await; + + fake_server.set_request_handler::( + move |_, _| async move { Ok(Some(Vec::new())) }, + ); + + cx.dispatch_action(ShowIncomingCalls); + cx.executor().run_until_parked(); + + let modal = workspace + .update(cx, |workspace, cx| { + workspace.active_modal::(cx) + }) + .expect("modal should be open"); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!(delegate.state, FetchState::NoSymbol); + assert_eq!(delegate.calls.len(), 0); + }); + } + + #[gpui::test] + async fn test_call_hierarchy_modal_groups_call_sites(cx: &mut TestAppContext) { + init_test(cx); + let (workspace, fake_server, test_uri, cx) = setup_modal_test( + "fn main() { helper(); helper(); helper(); }\nfn helper() {}\n", + cx, + ) + .await; + + fake_server.set_request_handler::({ + let uri = test_uri.clone(); + move |_, _| { + let uri = uri.clone(); + async move { Ok(Some(vec![make_lsp_call_hierarchy_item("helper", uri, 1)])) } + } + }); + fake_server.set_request_handler::({ + move |_, _| { + let uri = test_uri.clone(); + let site = |start: u32, end: u32| lsp::Range { + start: lsp::Position { + line: 0, + character: start, + }, + end: lsp::Position { + line: 0, + character: end, + }, + }; + async move { + Ok(Some(vec![lsp::CallHierarchyIncomingCall { + from: make_lsp_call_hierarchy_item("main", uri, 0), + from_ranges: vec![site(22, 28), site(12, 18), site(32, 38)], + }])) + } + } + }); + + cx.dispatch_action(ShowIncomingCalls); + cx.executor().run_until_parked(); + + let modal = workspace + .update(cx, |workspace, cx| { + workspace.active_modal::(cx) + }) + .expect("modal should be open"); + modal.read_with(cx, |view, cx| { + let delegate = &view.picker.read(cx).delegate; + assert_eq!( + delegate.calls.len(), + 1, + "call sites should be grouped per caller" + ); + let call = &delegate.calls[0]; + assert_eq!(call.item.name, "main"); + assert_eq!(call.site_count, 3); + let target_start = call + .target + .range + .start + .to_point(&call.target.buffer.read(cx).snapshot()); + assert_eq!( + target_start, + language::Point::new(0, 12), + "target should be the earliest call site" + ); + let path = compute_call_display(call, cx) + .path + .expect("path should be present"); + assert!( + path.ends_with(":1 (3 calls)"), + "path should mention the call site count, got {path:?}" + ); + }); + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let _state = AppState::test(cx); + init(cx); + editor::init(cx); + }); + } + + fn rust_lang() -> Arc { + Arc::new(Language::new( + LanguageConfig { + name: LanguageName::new("Rust"), + matcher: Arc::new(LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..LanguageMatcher::default() + }), + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )) + } + + fn rust_lang_with_highlights() -> Arc { + let language = Language::new( + LanguageConfig { + name: LanguageName::new("Rust"), + matcher: Arc::new(LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..LanguageMatcher::default() + }), + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_highlights_query( + r#" + (function_item name: (identifier) @function) + (function_signature_item name: (identifier) @function) + (type_identifier) @type + (lifetime) @lifetime + "fn" @keyword + (mutable_specifier) @keyword + "#, + ) + .unwrap(); + language.set_theme(&SyntaxTheme::new_test([ + ("function", Hsla::default()), + ("keyword", Hsla::default()), + ("type", Hsla::default()), + ("lifetime", Hsla::default()), + ])); + Arc::new(language) + } + + fn make_lsp_call_hierarchy_item( + name: &str, + uri: lsp::Uri, + line: u32, + ) -> lsp::CallHierarchyItem { + lsp::CallHierarchyItem { + name: name.to_string(), + kind: lsp::SymbolKind::FUNCTION, + tags: None, + detail: Some(format!("fn {name}()")), + uri, + range: lsp::Range { + start: lsp::Position { line, character: 0 }, + end: lsp::Position { + line, + character: 10, + }, + }, + selection_range: lsp::Range { + start: lsp::Position { line, character: 3 }, + end: lsp::Position { + line, + character: 3 + name.len() as u32, + }, + }, + data: None, + } + } + + fn source() -> String { + format!("{}\n", "x".repeat(30)).repeat(20) + } + + async fn make_call( + name: &str, + abs_path: &std::path::Path, + line: u32, + detail: Option, + project: &Entity, + cx: &mut TestAppContext, + ) -> Call { + let buffer = project + .update(cx, |project, cx| project.open_local_buffer(abs_path, cx)) + .await + .unwrap(); + let (range, selection_range) = buffer.read_with(cx, |buffer, _| { + ( + buffer.anchor_after(language::Point::new(line, 0)) + ..buffer.anchor_before(language::Point::new(line, 10)), + buffer.anchor_after(language::Point::new(line, 3)) + ..buffer.anchor_before(language::Point::new(line, 3 + name.len() as u32)), + ) + }); + let target = Location { + buffer: buffer.clone(), + range: selection_range.clone(), + }; + Call { + item: CallHierarchyItem { + buffer, + server_id: lsp::LanguageServerId(0), + name: name.to_string(), + kind: lsp::SymbolKind::FUNCTION, + detail, + range, + selection_range, + data: None, + }, + target, + site_count: 1, + label: None, + display: CallDisplay::default(), + } + } + + fn emit_work_end(project: &Entity, cx: &mut VisualTestContext) { + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + lsp_store.update(cx, |_, cx| { + cx.emit(LspStoreEvent::LanguageServerUpdate { + language_server_id: lsp::LanguageServerId(0), + name: None, + message: proto::update_language_server::Variant::WorkEnd(proto::LspWorkEnd { + token: None, + }), + }); + }); + } + + fn highlighted_display_rows(editor: &Entity, cx: &mut VisualTestContext) -> Vec { + editor.update_in(cx, |editor, window, cx| { + editor + .highlighted_display_rows(window, cx) + .into_keys() + .map(|row| row.0) + .collect() + }) + } + + async fn setup_modal_test<'a>( + source_text: &str, + cx: &'a mut TestAppContext, + ) -> ( + Entity, + lsp::FakeLanguageServer, + lsp::Uri, + &'a mut VisualTestContext, + ) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({"src": {"main.rs": source_text}})) + .await; + + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + + let mut fake_servers = project.read_with(cx, |project, _| { + project.languages().register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + call_hierarchy_provider: Some(lsp::CallHierarchyServerCapability::Simple( + true, + )), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ) + }); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/main.rs")), + None, + true, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + let test_uri = lsp::Uri::from_file_path(path!("/test/src/main.rs")).unwrap(); + (workspace, fake_server, test_uri, cx) + } +} diff --git a/crates/collab_ui/src/collab_panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs index 5349a79cd6..9967543a50 100644 --- a/crates/collab_ui/src/collab_panel/contact_finder.rs +++ b/crates/collab_ui/src/collab_panel/contact_finder.rs @@ -6,7 +6,7 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use std::sync::Arc; use ui::{Avatar, ListItem, ListItemSpacing, prelude::*}; -use util::{ResultExt as _, TryFutureExt}; +use util::TryFutureExt; use workspace::ModalView; pub struct ContactFinder { @@ -133,9 +133,7 @@ impl PickerDelegate for ContactFinderDelegate { } fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { - self.parent - .update(cx, |_, cx| cx.emit(DismissEvent)) - .log_err(); + self.parent.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); } fn render_match( diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 616e4101b3..fbd7e5e02e 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -270,6 +270,14 @@ pub fn deploy_context_menu( "Find All References", Box::new(FindAllReferences::default()), ) + .action( + "Show Incoming Calls", + Box::new(zed_actions::ShowIncomingCalls), + ) + .action( + "Show Outgoing Calls", + Box::new(zed_actions::ShowOutgoingCalls), + ) .separator() .action("Rename Symbol", Box::new(Rename)) .action("Format Buffer", Box::new(Format)) diff --git a/crates/encoding_selector/src/encoding_selector.rs b/crates/encoding_selector/src/encoding_selector.rs index 9aa97a2a4c..d11153ed6d 100644 --- a/crates/encoding_selector/src/encoding_selector.rs +++ b/crates/encoding_selector/src/encoding_selector.rs @@ -305,7 +305,7 @@ impl PickerDelegate for EncodingSelectorDelegate { fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { self.encoding_selector .update(cx, |_, cx| cx.emit(DismissEvent)) - .log_err(); + .ok(); } fn render_match( diff --git a/crates/extensions_ui/src/extension_version_selector.rs b/crates/extensions_ui/src/extension_version_selector.rs index 1ab1bd659f..694a9b46aa 100644 --- a/crates/extensions_ui/src/extension_version_selector.rs +++ b/crates/extensions_ui/src/extension_version_selector.rs @@ -202,9 +202,7 @@ impl PickerDelegate for ExtensionVersionSelectorDelegate { } fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { - self.selector - .update(cx, |_, cx| cx.emit(DismissEvent)) - .log_err(); + self.selector.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); } fn render_match( diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 2bc51326b5..a1a25b94ac 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -16,19 +16,16 @@ use fuzzy_nucleo::{PathMatch, PathMatchCandidate}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, TaskExt, - WeakEntity, Window, actions, rems, + WeakEntity, Window, actions, }; use language::{BufferSnapshot, Point}; -use open_path_prompt::{ - OpenPathPrompt, - file_finder_settings::{FileFinderSettings, FileFinderWidth}, -}; +use open_path_prompt::{OpenPathPrompt, file_finder_settings::FileFinderSettings}; use picker::{Picker, PickerDelegate}; use project::{ PathMatchCandidateSet, Project, ProjectPath, WorktreeId, worktree_store::WorktreeStore, }; -use settings::{Settings, SettingsStore}; +use settings::{ModalWidthContent, Settings, SettingsStore}; use std::{ borrow::Cow, cmp, mem, @@ -325,17 +322,9 @@ impl FileFinder { }); } - pub fn modal_max_width(width_setting: FileFinderWidth, window: &mut Window) -> Pixels { - let window_width = window.viewport_size().width; - let small_width = rems(34.).to_pixels(window.rem_size()); - - match width_setting { - FileFinderWidth::Small => small_width, - FileFinderWidth::Full => window_width, - FileFinderWidth::XLarge => (window_width - px(512.)).max(small_width), - FileFinderWidth::Large => (window_width - px(768.)).max(small_width), - FileFinderWidth::Medium => (window_width - px(1024.)).max(small_width), - } + pub fn modal_max_width(width_setting: ModalWidthContent, window: &mut Window) -> Pixels { + let small_width = picker::DEFAULT_MODAL_WIDTH.to_pixels(window.rem_size()); + width_setting.to_pixels(small_width, window.viewport_size().width) } } @@ -2059,7 +2048,7 @@ impl PickerDelegate for FileFinderDelegate { fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { self.file_finder .update(cx, |_, cx| cx.emit(DismissEvent)) - .log_err(); + .ok(); } fn try_get_preview_data_for_match(&self, cx: &App) -> Option { diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index 52f41a330b..aaefb68509 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -232,7 +232,7 @@ impl PickerDelegate for LanguageSelectorDelegate { fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { self.language_selector .update(cx, |_, cx| cx.emit(DismissEvent)) - .log_err(); + .ok(); } fn selected_index(&self) -> usize { diff --git a/crates/line_ending_selector/Cargo.toml b/crates/line_ending_selector/Cargo.toml index 462404b150..db67823a6e 100644 --- a/crates/line_ending_selector/Cargo.toml +++ b/crates/line_ending_selector/Cargo.toml @@ -19,5 +19,4 @@ language.workspace = true picker.workspace = true project.workspace = true ui.workspace = true -util.workspace = true workspace.workspace = true diff --git a/crates/line_ending_selector/src/line_ending_selector.rs b/crates/line_ending_selector/src/line_ending_selector.rs index f3298efee3..ef134e693f 100644 --- a/crates/line_ending_selector/src/line_ending_selector.rs +++ b/crates/line_ending_selector/src/line_ending_selector.rs @@ -8,7 +8,6 @@ use picker::{Picker, PickerDelegate}; use project::Project; use std::sync::Arc; use ui::{ListItem, ListItemSpacing, prelude::*}; -use util::ResultExt; use workspace::ModalView; actions!( @@ -146,7 +145,7 @@ impl PickerDelegate for LineEndingSelectorDelegate { fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { self.line_ending_selector .update(cx, |_, cx| cx.emit(DismissEvent)) - .log_err(); + .ok(); } fn selected_index(&self) -> usize { diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 6f7820763d..51317b2a73 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -1012,6 +1012,9 @@ impl LanguageServer { code_lens: Some(CodeLensClientCapabilities { dynamic_registration: Some(true), }), + call_hierarchy: Some(CallHierarchyClientCapabilities { + dynamic_registration: Some(true), + }), document_symbol: Some(DocumentSymbolClientCapabilities { hierarchical_document_symbol_support: Some(true), dynamic_registration: Some(true), diff --git a/crates/onboarding/src/base_keymap_picker.rs b/crates/onboarding/src/base_keymap_picker.rs index 351c87a96b..3b2a69afdb 100644 --- a/crates/onboarding/src/base_keymap_picker.rs +++ b/crates/onboarding/src/base_keymap_picker.rs @@ -207,7 +207,7 @@ impl PickerDelegate for BaseKeymapSelectorDelegate { .update(cx, |_, cx| { cx.emit(DismissEvent); }) - .log_err(); + .ok(); } fn render_match( diff --git a/crates/open_path_prompt/Cargo.toml b/crates/open_path_prompt/Cargo.toml index e635797cfb..bbdba46ea4 100644 --- a/crates/open_path_prompt/Cargo.toml +++ b/crates/open_path_prompt/Cargo.toml @@ -12,7 +12,6 @@ fuzzy.workspace = true gpui.workspace = true picker.workspace = true project.workspace = true -schemars.workspace = true serde.workspace = true settings.workspace = true ui.workspace = true diff --git a/crates/open_path_prompt/src/file_finder_settings.rs b/crates/open_path_prompt/src/file_finder_settings.rs index 56ea60c208..76be55a70e 100644 --- a/crates/open_path_prompt/src/file_finder_settings.rs +++ b/crates/open_path_prompt/src/file_finder_settings.rs @@ -1,11 +1,10 @@ -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{RegisterSetting, Settings}; +use serde::Deserialize; +use settings::{ModalWidthContent, RegisterSetting, Settings}; #[derive(Deserialize, Debug, Clone, Copy, PartialEq, RegisterSetting)] pub struct FileFinderSettings { pub file_icons: bool, - pub modal_max_width: FileFinderWidth, + pub modal_max_width: ModalWidthContent, pub skip_focus_for_active_in_search: bool, pub include_ignored: Option, pub include_channels: bool, @@ -17,7 +16,7 @@ impl Settings for FileFinderSettings { Self { file_icons: file_finder.file_icons.unwrap(), - modal_max_width: file_finder.modal_max_width.unwrap().into(), + modal_max_width: file_finder.modal_max_width.unwrap(), skip_focus_for_active_in_search: file_finder.skip_focus_for_active_in_search.unwrap(), include_ignored: match file_finder.include_ignored.unwrap() { settings::IncludeIgnoredContent::All => Some(true), @@ -28,26 +27,3 @@ impl Settings for FileFinderSettings { } } } - -#[derive(Debug, PartialEq, Eq, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum FileFinderWidth { - #[default] - Small, - Medium, - Large, - XLarge, - Full, -} - -impl From for FileFinderWidth { - fn from(content: settings::FileFinderWidthContent) -> Self { - match content { - settings::FileFinderWidthContent::Small => FileFinderWidth::Small, - settings::FileFinderWidthContent::Medium => FileFinderWidth::Medium, - settings::FileFinderWidthContent::Large => FileFinderWidth::Large, - settings::FileFinderWidthContent::XLarge => FileFinderWidth::XLarge, - settings::FileFinderWidthContent::Full => FileFinderWidth::Full, - } - } -} diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 0f22226f89..ab6570eb7b 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -16,7 +16,6 @@ use settings::Settings; use theme::ActiveTheme; use theme_settings::ThemeSettings; use ui::{ListItem, ListItemSpacing, prelude::*}; -use util::ResultExt; use workspace::{DismissDecision, ModalView}; pub fn init(cx: &mut App) { @@ -434,7 +433,7 @@ impl PickerDelegate for OutlineViewDelegate { fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { self.outline_view .update(cx, |_, cx| cx.emit(DismissEvent)) - .log_err(); + .ok(); self.restore_active_editor(window, cx); } diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 5635996ff8..3f8ba1ab84 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -17,14 +17,14 @@ use futures::future; use gpui::{App, AsyncApp, Entity, SharedString, Task, TaskExt, prelude::FluentBuilder}; use language::{ Anchor, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CharKind, CharScopeContext, - OffsetRangeExt, PointUtf16, ToOffset, ToPointUtf16, Transaction, Unclipped, + OffsetRangeExt, PointUtf16, SymbolKind, ToOffset, ToPointUtf16, Transaction, Unclipped, language_settings::{InlayHintKind, LanguageSettings}, lsp_to_symbol_kind, point_from_lsp, point_to_lsp, proto::{ deserialize_anchor, deserialize_anchor_range, deserialize_markup_kind, deserialize_version, serialize_anchor, serialize_anchor_range, serialize_markup_kind, serialize_version, }, - range_from_lsp, range_to_lsp, + range_from_lsp, range_to_lsp, symbol_kind_to_lsp, }; use lsp::{ AdapterServerCapabilities, CodeActionKind, CodeActionOptions, CodeDescription, @@ -344,6 +344,624 @@ pub struct GetDocumentDiagnostics { pub previous_result_id: Option, } +#[derive(Debug, Clone)] +pub struct CallHierarchyItem { + pub buffer: Entity, + pub server_id: LanguageServerId, + pub name: String, + pub kind: lsp::SymbolKind, + pub detail: Option, + pub range: Range, + pub selection_range: Range, + pub data: Option, +} + +#[derive(Debug, Clone)] +pub struct PrepareCallHierarchy { + pub position: PointUtf16, +} + +async fn call_hierarchy_item_from_lsp( + item: lsp::CallHierarchyItem, + server_id: LanguageServerId, + lsp_store: &Entity, + cx: &mut AsyncApp, +) -> Result { + let buffer = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.open_local_buffer_via_lsp(item.uri, server_id, cx) + }) + .await?; + let (range, selection_range) = buffer.read_with(cx, |buffer, _| { + ( + anchor_range_from_lsp(item.range, buffer), + anchor_range_from_lsp(item.selection_range, buffer), + ) + }); + Ok(CallHierarchyItem { + buffer, + server_id, + name: item.name, + kind: item.kind, + detail: item.detail, + range, + selection_range, + data: item.data, + }) +} + +fn anchor_range_from_lsp(range: lsp::Range, buffer: &Buffer) -> Range { + let start = buffer.clip_point_utf16(point_from_lsp(range.start), Bias::Left); + let end = buffer.clip_point_utf16(point_from_lsp(range.end), Bias::Left); + buffer.anchor_after(start)..buffer.anchor_before(end) +} + +fn call_hierarchy_item_to_lsp( + item: &CallHierarchyItem, + path: &Path, + buffer: &Buffer, +) -> Result { + Ok(lsp::CallHierarchyItem { + name: item.name.clone(), + kind: item.kind, + tags: None, + detail: item.detail.clone(), + uri: file_path_to_lsp_url(path)?, + range: range_to_lsp(item.range.to_point_utf16(buffer))?, + selection_range: range_to_lsp(item.selection_range.to_point_utf16(buffer))?, + data: item.data.clone(), + }) +} + +fn call_hierarchy_item_to_proto( + item: &CallHierarchyItem, + buffer_id: BufferId, +) -> proto::CallHierarchyItem { + proto::CallHierarchyItem { + name: item.name.clone(), + kind: lsp_to_symbol_kind(item.kind).to_proto(), + detail: item.detail.clone(), + buffer_id: buffer_id.into(), + server_id: item.server_id.to_proto(), + range_start: Some(serialize_anchor(&item.range.start)), + range_end: Some(serialize_anchor(&item.range.end)), + selection_range_start: Some(serialize_anchor(&item.selection_range.start)), + selection_range_end: Some(serialize_anchor(&item.selection_range.end)), + data: item.data.as_ref().map(|data| data.to_string().into_bytes()), + } +} + +fn call_hierarchy_item_response_to_proto( + item: &CallHierarchyItem, + lsp_store: &mut LspStore, + peer_id: PeerId, + cx: &mut App, +) -> proto::CallHierarchyItem { + lsp_store + .buffer_store() + .update(cx, |buffer_store, cx| { + buffer_store.create_buffer_for_peer(&item.buffer, peer_id, cx) + }) + .detach_and_log_err(cx); + let buffer_id = item.buffer.read(cx).remote_id(); + call_hierarchy_item_to_proto(item, buffer_id) +} + +fn call_hierarchy_item_fields_from_proto( + item: proto::CallHierarchyItem, + buffer: Entity, +) -> Result { + let range = item + .range_start + .and_then(deserialize_anchor) + .zip(item.range_end.and_then(deserialize_anchor)) + .context("invalid item range")?; + let selection_range = item + .selection_range_start + .and_then(deserialize_anchor) + .zip(item.selection_range_end.and_then(deserialize_anchor)) + .context("invalid item selection range")?; + Ok(CallHierarchyItem { + buffer, + server_id: LanguageServerId::from_proto(item.server_id), + name: item.name, + kind: symbol_kind_to_lsp(SymbolKind::from_proto(item.kind)), + detail: item.detail, + range: range.0..range.1, + selection_range: selection_range.0..selection_range.1, + data: item + .data + .map(|data| serde_json::from_slice(&data)) + .transpose()?, + }) +} + +async fn call_hierarchy_item_from_proto( + item: proto::CallHierarchyItem, + lsp_store: &Entity, + cx: &mut AsyncApp, +) -> Result { + let buffer_id = BufferId::new(item.buffer_id)?; + let buffer = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.wait_for_remote_buffer(buffer_id, cx) + }) + .await?; + let item = call_hierarchy_item_fields_from_proto(item, buffer)?; + item.buffer + .update(cx, |buffer, _| { + buffer.wait_for_anchors([ + item.range.start, + item.range.end, + item.selection_range.start, + item.selection_range.end, + ]) + }) + .await?; + Ok(item) +} + +#[async_trait(?Send)] +impl LspCommand for PrepareCallHierarchy { + type Response = Vec; + type LspRequest = lsp::request::CallHierarchyPrepare; + type ProtoRequest = proto::PrepareCallHierarchy; + + fn display_name(&self) -> &str { + "Prepare call hierarchy" + } + + fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + capabilities + .server_capabilities + .call_hierarchy_provider + .as_ref() + .is_some_and(|capability| match capability { + lsp::CallHierarchyServerCapability::Simple(supported) => *supported, + lsp::CallHierarchyServerCapability::Options(_) => true, + }) + } + + fn to_lsp( + &self, + path: &Path, + _: &Buffer, + _: &Arc, + _: &App, + ) -> Result { + Ok(lsp::CallHierarchyPrepareParams { + text_document_position_params: make_lsp_text_document_position(path, self.position)?, + work_done_progress_params: lsp::WorkDoneProgressParams::default(), + }) + } + + async fn response_from_lsp( + self, + message: Option>, + lsp_store: Entity, + _buffer: Entity, + server_id: LanguageServerId, + mut cx: AsyncApp, + ) -> Result> { + let mut items = Vec::new(); + for item in message.unwrap_or_default() { + items.push(call_hierarchy_item_from_lsp(item, server_id, &lsp_store, &mut cx).await?); + } + Ok(items) + } + + fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::PrepareCallHierarchy { + proto::PrepareCallHierarchy { + project_id, + buffer_id: buffer.remote_id().into(), + position: Some(serialize_anchor(&buffer.anchor_before(self.position))), + version: serialize_version(&buffer.version()), + } + } + + async fn from_proto( + message: proto::PrepareCallHierarchy, + _: Entity, + buffer: Entity, + mut cx: AsyncApp, + ) -> Result { + let position = message + .position + .and_then(deserialize_anchor) + .context("invalid position")?; + buffer + .update(&mut cx, |buffer, _| { + buffer.wait_for_version(deserialize_version(&message.version)) + }) + .await?; + Ok(Self { + position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)), + }) + } + + fn response_to_proto( + response: Vec, + lsp_store: &mut LspStore, + peer_id: PeerId, + _: &clock::Global, + cx: &mut App, + ) -> proto::PrepareCallHierarchyResponse { + proto::PrepareCallHierarchyResponse { + items: response + .iter() + .map(|item| call_hierarchy_item_response_to_proto(item, lsp_store, peer_id, cx)) + .collect(), + } + } + + async fn response_from_proto( + self, + message: proto::PrepareCallHierarchyResponse, + lsp_store: Entity, + _: Entity, + mut cx: AsyncApp, + ) -> Result> { + let mut items = Vec::new(); + for item in message.items { + items.push(call_hierarchy_item_from_proto(item, &lsp_store, &mut cx).await?); + } + Ok(items) + } + + fn buffer_id_from_proto(message: &proto::PrepareCallHierarchy) -> Result { + BufferId::new(message.buffer_id) + } +} + +#[derive(Debug, Clone)] +pub struct IncomingCall { + pub from: CallHierarchyItem, + pub from_ranges: Vec, +} + +#[derive(Debug, Clone)] +pub struct OutgoingCall { + pub to: CallHierarchyItem, + pub from_ranges: Vec, +} + +#[derive(Debug, Clone)] +pub struct GetIncomingCalls { + pub item: CallHierarchyItem, +} + +fn location_to_proto( + location: &Location, + lsp_store: &mut LspStore, + peer_id: PeerId, + cx: &mut App, +) -> proto::Location { + lsp_store + .buffer_store() + .update(cx, |buffer_store, cx| { + buffer_store.create_buffer_for_peer(&location.buffer, peer_id, cx) + }) + .detach_and_log_err(cx); + proto::Location { + buffer_id: location.buffer.read(cx).remote_id().into(), + start: Some(serialize_anchor(&location.range.start)), + end: Some(serialize_anchor(&location.range.end)), + } +} + +async fn location_from_proto( + location: proto::Location, + lsp_store: &Entity, + cx: &mut AsyncApp, +) -> Result { + let buffer_id = BufferId::new(location.buffer_id)?; + let buffer = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.wait_for_remote_buffer(buffer_id, cx) + }) + .await?; + let start = location + .start + .and_then(deserialize_anchor) + .context("missing location start")?; + let end = location + .end + .and_then(deserialize_anchor) + .context("missing location end")?; + buffer + .update(cx, |buffer, _| buffer.wait_for_anchors([start, end])) + .await?; + Ok(Location { + buffer, + range: start..end, + }) +} + +#[async_trait(?Send)] +impl LspCommand for GetIncomingCalls { + type Response = Vec; + type LspRequest = lsp::request::CallHierarchyIncomingCalls; + type ProtoRequest = proto::GetIncomingCalls; + + fn display_name(&self) -> &str { + "Get incoming calls" + } + + fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + capabilities + .server_capabilities + .call_hierarchy_provider + .as_ref() + .is_some_and(|capability| match capability { + lsp::CallHierarchyServerCapability::Simple(supported) => *supported, + lsp::CallHierarchyServerCapability::Options(_) => true, + }) + } + + fn to_lsp( + &self, + path: &Path, + buffer: &Buffer, + _language_server: &Arc, + _cx: &App, + ) -> Result { + Ok(lsp::CallHierarchyIncomingCallsParams { + item: call_hierarchy_item_to_lsp(&self.item, path, buffer)?, + work_done_progress_params: lsp::WorkDoneProgressParams::default(), + partial_result_params: lsp::PartialResultParams::default(), + }) + } + + async fn response_from_lsp( + self, + message: Option>, + lsp_store: Entity, + _buffer: Entity, + server_id: LanguageServerId, + mut cx: AsyncApp, + ) -> Result> { + let mut calls = Vec::new(); + for call in message.unwrap_or_default() { + let from = + call_hierarchy_item_from_lsp(call.from, server_id, &lsp_store, &mut cx).await?; + let from_ranges = from.buffer.read_with(&cx, |buffer, _| { + call.from_ranges + .into_iter() + .map(|range| Location { + buffer: from.buffer.clone(), + range: anchor_range_from_lsp(range, buffer), + }) + .collect() + }); + calls.push(IncomingCall { from, from_ranges }); + } + Ok(calls) + } + + fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetIncomingCalls { + proto::GetIncomingCalls { + project_id, + buffer_id: buffer.remote_id().into(), + item: Some(call_hierarchy_item_to_proto(&self.item, buffer.remote_id())), + version: serialize_version(&buffer.version()), + } + } + + async fn from_proto( + message: proto::GetIncomingCalls, + _lsp_store: Entity, + buffer: Entity, + mut cx: AsyncApp, + ) -> Result { + buffer + .update(&mut cx, |buffer, _| { + buffer.wait_for_version(deserialize_version(&message.version)) + }) + .await?; + Ok(Self { + item: call_hierarchy_item_fields_from_proto( + message.item.context("missing item")?, + buffer, + )?, + }) + } + + fn response_to_proto( + response: Vec, + lsp_store: &mut LspStore, + peer_id: PeerId, + _buffer_version: &clock::Global, + cx: &mut App, + ) -> proto::GetIncomingCallsResponse { + proto::GetIncomingCallsResponse { + calls: response + .into_iter() + .map(|call| proto::CallHierarchyIncomingCall { + from: Some(call_hierarchy_item_response_to_proto( + &call.from, lsp_store, peer_id, cx, + )), + from_ranges: call + .from_ranges + .iter() + .map(|location| location_to_proto(location, lsp_store, peer_id, cx)) + .collect(), + }) + .collect(), + } + } + + async fn response_from_proto( + self, + message: proto::GetIncomingCallsResponse, + lsp_store: Entity, + _buffer: Entity, + mut cx: AsyncApp, + ) -> Result> { + let mut calls = Vec::new(); + for call in message.calls { + let from = call_hierarchy_item_from_proto( + call.from.context("missing from")?, + &lsp_store, + &mut cx, + ) + .await?; + let mut from_ranges = Vec::new(); + for location in call.from_ranges { + from_ranges.push(location_from_proto(location, &lsp_store, &mut cx).await?); + } + calls.push(IncomingCall { from, from_ranges }); + } + Ok(calls) + } + + fn buffer_id_from_proto(message: &proto::GetIncomingCalls) -> Result { + BufferId::new(message.buffer_id) + } +} + +#[derive(Debug, Clone)] +pub struct GetOutgoingCalls { + pub item: CallHierarchyItem, +} + +#[async_trait(?Send)] +impl LspCommand for GetOutgoingCalls { + type Response = Vec; + type LspRequest = lsp::request::CallHierarchyOutgoingCalls; + type ProtoRequest = proto::GetOutgoingCalls; + + fn display_name(&self) -> &str { + "Get outgoing calls" + } + + fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + capabilities + .server_capabilities + .call_hierarchy_provider + .as_ref() + .is_some_and(|capability| match capability { + lsp::CallHierarchyServerCapability::Simple(supported) => *supported, + lsp::CallHierarchyServerCapability::Options(_) => true, + }) + } + + fn to_lsp( + &self, + path: &Path, + buffer: &Buffer, + _language_server: &Arc, + _cx: &App, + ) -> Result { + Ok(lsp::CallHierarchyOutgoingCallsParams { + item: call_hierarchy_item_to_lsp(&self.item, path, buffer)?, + work_done_progress_params: lsp::WorkDoneProgressParams::default(), + partial_result_params: lsp::PartialResultParams::default(), + }) + } + + async fn response_from_lsp( + self, + message: Option>, + lsp_store: Entity, + buffer: Entity, + server_id: LanguageServerId, + mut cx: AsyncApp, + ) -> Result> { + let mut calls = Vec::new(); + for call in message.unwrap_or_default() { + let to = call_hierarchy_item_from_lsp(call.to, server_id, &lsp_store, &mut cx).await?; + let from_ranges = buffer.read_with(&cx, |queried_buffer, _| { + call.from_ranges + .into_iter() + .map(|range| Location { + buffer: buffer.clone(), + range: anchor_range_from_lsp(range, queried_buffer), + }) + .collect() + }); + calls.push(OutgoingCall { to, from_ranges }); + } + Ok(calls) + } + + fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetOutgoingCalls { + proto::GetOutgoingCalls { + project_id, + buffer_id: buffer.remote_id().into(), + item: Some(call_hierarchy_item_to_proto(&self.item, buffer.remote_id())), + version: serialize_version(&buffer.version()), + } + } + + async fn from_proto( + message: proto::GetOutgoingCalls, + _lsp_store: Entity, + buffer: Entity, + mut cx: AsyncApp, + ) -> Result { + buffer + .update(&mut cx, |buffer, _| { + buffer.wait_for_version(deserialize_version(&message.version)) + }) + .await?; + Ok(Self { + item: call_hierarchy_item_fields_from_proto( + message.item.context("missing item")?, + buffer, + )?, + }) + } + + fn response_to_proto( + response: Vec, + lsp_store: &mut LspStore, + peer_id: PeerId, + _buffer_version: &clock::Global, + cx: &mut App, + ) -> proto::GetOutgoingCallsResponse { + proto::GetOutgoingCallsResponse { + calls: response + .into_iter() + .map(|call| proto::CallHierarchyOutgoingCall { + to: Some(call_hierarchy_item_response_to_proto( + &call.to, lsp_store, peer_id, cx, + )), + from_ranges: call + .from_ranges + .iter() + .map(|location| location_to_proto(location, lsp_store, peer_id, cx)) + .collect(), + }) + .collect(), + } + } + + async fn response_from_proto( + self, + message: proto::GetOutgoingCallsResponse, + lsp_store: Entity, + _buffer: Entity, + mut cx: AsyncApp, + ) -> Result> { + let mut calls = Vec::new(); + for call in message.calls { + let to = + call_hierarchy_item_from_proto(call.to.context("missing to")?, &lsp_store, &mut cx) + .await?; + let mut from_ranges = Vec::new(); + for location in call.from_ranges { + from_ranges.push(location_from_proto(location, &lsp_store, &mut cx).await?); + } + calls.push(OutgoingCall { to, from_ranges }); + } + Ok(calls) + } + + fn buffer_id_from_proto(message: &proto::GetOutgoingCalls) -> Result { + BufferId::new(message.buffer_id) + } +} + #[async_trait(?Send)] impl LspCommand for PrepareRename { type Response = PrepareRenameResponse; diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 42240dd7c5..e444f18418 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -6885,6 +6885,189 @@ impl LspStore { } } + pub fn prepare_call_hierarchy( + &mut self, + buffer: &Entity, + position: PointUtf16, + cx: &mut Context, + ) -> Task>>> { + if let Some((upstream_client, project_id)) = self.upstream_client() { + let request = PrepareCallHierarchy { position }; + if !self.is_capable_for_proto_request(buffer, &request, cx) { + return Task::ready(Ok(None)); + } + + let request_timeout = ProjectSettings::get_global(cx) + .global_lsp_settings + .get_request_timeout(); + + let request_task = upstream_client.request_lsp( + project_id, + None, + request_timeout, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); + let buffer = buffer.clone(); + cx.spawn(async move |weak_lsp_store, cx| { + let Some(lsp_store) = weak_lsp_store.upgrade() else { + return Ok(None); + }; + let Some(responses) = request_task.await? else { + return Ok(None); + }; + let items = join_all(responses.payload.into_iter().map(|response| { + PrepareCallHierarchy { position }.response_from_proto( + response.response, + lsp_store.clone(), + buffer.clone(), + cx.clone(), + ) + })) + .await; + Ok(Some(dedup_call_hierarchy_items( + items + .into_iter() + .collect::>>>()? + .into_iter() + .flatten(), + ))) + }) + } else { + let task = self.request_multiple_lsp_locally( + buffer, + Some(position), + PrepareCallHierarchy { position }, + cx, + ); + cx.background_spawn(async move { + Ok(Some(dedup_call_hierarchy_items( + task.await.into_iter().flat_map(|(_, items)| items), + ))) + }) + } + } + + pub fn incoming_calls( + &mut self, + item: CallHierarchyItem, + cx: &mut Context, + ) -> Task>>> { + let buffer = item.buffer.clone(); + let server_id = item.server_id; + if let Some((upstream_client, project_id)) = self.upstream_client() { + let request = GetIncomingCalls { item }; + if !self.is_capable_for_proto_request(&buffer, &request, cx) { + return Task::ready(Ok(None)); + } + + let request_timeout = ProjectSettings::get_global(cx) + .global_lsp_settings + .get_request_timeout(); + + let request_task = upstream_client.request_lsp( + project_id, + Some(server_id.to_proto()), + request_timeout, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); + cx.spawn(async move |weak_lsp_store, cx| { + let Some(lsp_store) = weak_lsp_store.upgrade() else { + return Ok(None); + }; + let Some(responses) = request_task.await? else { + return Ok(None); + }; + let calls = join_all(responses.payload.into_iter().map(|response| { + request.clone().response_from_proto( + response.response, + lsp_store.clone(), + buffer.clone(), + cx.clone(), + ) + })) + .await; + Ok(Some( + calls + .into_iter() + .collect::>>>()? + .into_iter() + .flatten() + .collect(), + )) + }) + } else { + let task = self.request_lsp( + buffer, + LanguageServerToQuery::Other(server_id), + GetIncomingCalls { item }, + cx, + ); + cx.background_spawn(async move { Ok(Some(task.await?)) }) + } + } + + pub fn outgoing_calls( + &mut self, + item: CallHierarchyItem, + cx: &mut Context, + ) -> Task>>> { + let buffer = item.buffer.clone(); + let server_id = item.server_id; + if let Some((upstream_client, project_id)) = self.upstream_client() { + let request = GetOutgoingCalls { item }; + if !self.is_capable_for_proto_request(&buffer, &request, cx) { + return Task::ready(Ok(None)); + } + + let request_timeout = ProjectSettings::get_global(cx) + .global_lsp_settings + .get_request_timeout(); + + let request_task = upstream_client.request_lsp( + project_id, + Some(server_id.to_proto()), + request_timeout, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); + cx.spawn(async move |weak_lsp_store, cx| { + let Some(lsp_store) = weak_lsp_store.upgrade() else { + return Ok(None); + }; + let Some(responses) = request_task.await? else { + return Ok(None); + }; + let calls = join_all(responses.payload.into_iter().map(|response| { + request.clone().response_from_proto( + response.response, + lsp_store.clone(), + buffer.clone(), + cx.clone(), + ) + })) + .await; + Ok(Some( + calls + .into_iter() + .collect::>>>()? + .into_iter() + .flatten() + .collect(), + )) + }) + } else { + let task = self.request_lsp( + buffer, + LanguageServerToQuery::Other(server_id), + GetOutgoingCalls { item }, + cx, + ); + cx.background_spawn(async move { Ok(Some(task.await?)) }) + } + } + pub fn code_actions( &mut self, buffer: &Entity, @@ -10023,6 +10206,43 @@ impl LspStore { .await .context("querying for inlay hints")? } + Request::PrepareCallHierarchy(prepare_call_hierarchy) => { + let position = prepare_call_hierarchy.position.and_then(deserialize_anchor); + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + prepare_call_hierarchy, + position, + &mut cx, + ) + .await?; + } + Request::GetIncomingCalls(get_incoming_calls) => { + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_incoming_calls, + None, + &mut cx, + ) + .await?; + } + Request::GetOutgoingCalls(get_outgoing_calls) => { + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_outgoing_calls, + None, + &mut cx, + ) + .await?; + } ////////////////////////////// // Below are LSP queries that need to fetch more data, // hence cannot just proxy the request to language server with `query_lsp_locally`. @@ -14055,6 +14275,22 @@ fn remove_empty_hover_blocks(mut hover: Hover) -> Option { } } +fn dedup_call_hierarchy_items( + items: impl IntoIterator, +) -> Vec { + let mut seen = HashSet::default(); + items + .into_iter() + .filter(|item| { + seen.insert(( + item.buffer.entity_id(), + item.selection_range.clone(), + item.name.clone(), + )) + }) + .collect() +} + fn deduplicate_hovers(hovers: Vec) -> Vec { let mut unique_hovers = Vec::with_capacity(hovers.len()); for hover in hovers { diff --git a/crates/project/src/lsp_store/dynamic_registration.rs b/crates/project/src/lsp_store/dynamic_registration.rs index 7df7a238e4..9af94842dd 100644 --- a/crates/project/src/lsp_store/dynamic_registration.rs +++ b/crates/project/src/lsp_store/dynamic_registration.rs @@ -78,6 +78,7 @@ pub(super) struct DynamicRegistrations { inlay_hint: CapabilityRegistrations>, code_lens: CapabilityRegistrations, document_symbol: CapabilityRegistrations>, + call_hierarchy: CapabilityRegistrations, } impl LspStore { @@ -435,6 +436,24 @@ impl LspStore { |capabilities| &mut capabilities.code_action_provider, )?; } + "textDocument/prepareCallHierarchy" => { + let options = parse_register_capabilities(reg.register_options)?; + let provider = match options { + OneOf::Left(value) => lsp::CallHierarchyServerCapability::Simple(value), + OneOf::Right(options) => { + lsp::CallHierarchyServerCapability::Options(options) + } + }; + self.register_dynamic_capability( + &server, + ®.method, + reg.id, + provider, + cx, + |registrations| &mut registrations.call_hierarchy, + |capabilities| &mut capabilities.call_hierarchy_provider, + )?; + } "textDocument/definition" => { let options = parse_register_capabilities(reg.register_options)?; self.register_dynamic_capability( @@ -829,6 +848,15 @@ impl LspStore { |capabilities| &mut capabilities.code_action_provider, )?; } + "textDocument/prepareCallHierarchy" => { + self.unregister_dynamic_capability( + &server, + unreg, + cx, + |registrations| &mut registrations.call_hierarchy, + |capabilities| &mut capabilities.call_hierarchy_provider, + )?; + } "textDocument/definition" => { self.unregister_dynamic_capability( &server, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 70820f5f3d..07d4eca30a 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -164,6 +164,7 @@ pub use task_inventory::{ }; pub use buffer_store::ProjectTransaction; +pub use lsp_command::{CallHierarchyItem, IncomingCall, OutgoingCall}; pub use lsp_store::{ DiagnosticSummary, InvalidationStrategy, LanguageServerLogType, LanguageServerProgress, LanguageServerPromptRequest, LanguageServerStatus, LanguageServerToQuery, LspStore, @@ -4451,6 +4452,56 @@ impl Project { }) } + pub fn prepare_call_hierarchy( + &mut self, + buffer: &Entity, + position: T, + cx: &mut Context, + ) -> Task>>> { + let position = position.to_point_utf16(buffer.read(cx)); + let guard = self.retain_remotely_created_models(cx); + let task = self.lsp_store.update(cx, |lsp_store, cx| { + lsp_store.prepare_call_hierarchy(buffer, position, cx) + }); + cx.background_spawn(async move { + let result = task.await; + drop(guard); + result + }) + } + + pub fn incoming_calls( + &mut self, + item: CallHierarchyItem, + cx: &mut Context, + ) -> Task>>> { + let guard = self.retain_remotely_created_models(cx); + let task = self + .lsp_store + .update(cx, |lsp_store, cx| lsp_store.incoming_calls(item, cx)); + cx.background_spawn(async move { + let result = task.await; + drop(guard); + result + }) + } + + pub fn outgoing_calls( + &mut self, + item: CallHierarchyItem, + cx: &mut Context, + ) -> Task>>> { + let guard = self.retain_remotely_created_models(cx); + let task = self + .lsp_store + .update(cx, |lsp_store, cx| lsp_store.outgoing_calls(item, cx)); + cx.background_spawn(async move { + let result = task.await; + drop(guard); + result + }) + } + pub fn document_highlights( &mut self, buffer: &Entity, diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index 5f5877bdb7..c07d33857b 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -117,6 +117,62 @@ message DocumentHighlight { } } +message CallHierarchyItem { + string name = 1; + int32 kind = 2; + optional string detail = 3; + uint64 buffer_id = 4; + uint64 server_id = 5; + Anchor range_start = 6; + Anchor range_end = 7; + Anchor selection_range_start = 8; + Anchor selection_range_end = 9; + optional bytes data = 10; +} + +message CallHierarchyIncomingCall { + CallHierarchyItem from = 1; + repeated Location from_ranges = 2; +} + +message CallHierarchyOutgoingCall { + CallHierarchyItem to = 1; + repeated Location from_ranges = 2; +} + +message PrepareCallHierarchy { + uint64 project_id = 1; + uint64 buffer_id = 2; + Anchor position = 3; + repeated VectorClockEntry version = 4; +} + +message PrepareCallHierarchyResponse { + repeated CallHierarchyItem items = 1; +} + +message GetIncomingCalls { + uint64 project_id = 1; + uint64 buffer_id = 2; + CallHierarchyItem item = 3; + repeated VectorClockEntry version = 4; +} + +message GetIncomingCallsResponse { + repeated CallHierarchyIncomingCall calls = 1; +} + +message GetOutgoingCalls { + uint64 project_id = 1; + uint64 buffer_id = 2; + CallHierarchyItem item = 3; + repeated VectorClockEntry version = 4; +} + +message GetOutgoingCallsResponse { + repeated CallHierarchyOutgoingCall calls = 1; +} + message GetProjectSymbols { uint64 project_id = 1; string query = 2; @@ -946,6 +1002,9 @@ message LspQuery { GetDocumentLinks get_document_links = 19; GetEditPredictionDefinition get_edit_prediction_definition = 20; GetEditPredictionTypeDefinition get_edit_prediction_type_definition = 21; + PrepareCallHierarchy prepare_call_hierarchy = 22; + GetIncomingCalls get_incoming_calls = 23; + GetOutgoingCalls get_outgoing_calls = 24; } } @@ -975,6 +1034,9 @@ message LspResponse { GetDocumentLinksResponse get_document_links_response = 17; GetEditPredictionDefinitionResponse get_edit_prediction_definition_response = 18; GetEditPredictionTypeDefinitionResponse get_edit_prediction_type_definition_response = 19; + PrepareCallHierarchyResponse prepare_call_hierarchy_response = 20; + GetIncomingCallsResponse get_incoming_calls_response = 21; + GetOutgoingCallsResponse get_outgoing_calls_response = 22; } uint64 server_id = 7; } diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index c8048dff2b..c9cf63b4ba 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -509,7 +509,14 @@ message Envelope { BlameBufferAtRevision blame_buffer_at_revision = 478; BlameBufferAtRevisionResponse blame_buffer_at_revision_response = 479; GetFilePermalink get_file_permalink = 480; - GetFilePermalinkResponse get_file_permalink_response = 481; // current max + GetFilePermalinkResponse get_file_permalink_response = 481; + + PrepareCallHierarchy prepare_call_hierarchy = 482; + PrepareCallHierarchyResponse prepare_call_hierarchy_response = 483; + GetIncomingCalls get_incoming_calls = 484; + GetIncomingCallsResponse get_incoming_calls_response = 485; + GetOutgoingCalls get_outgoing_calls = 486; + GetOutgoingCallsResponse get_outgoing_calls_response = 487; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index cbbb7d0eb6..e851c933cd 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -130,6 +130,10 @@ messages!( (GetTypeDefinitionResponse, Background), (GetImplementation, Background), (GetImplementationResponse, Background), + (GetIncomingCalls, Background), + (GetIncomingCallsResponse, Background), + (GetOutgoingCalls, Background), + (GetOutgoingCallsResponse, Background), (OpenUnstagedDiff, Foreground), (OpenUnstagedDiffResponse, Foreground), (OpenUncommittedDiff, Foreground), @@ -203,6 +207,8 @@ messages!( (PerformRename, Background), (PerformRenameResponse, Background), (Ping, Foreground), + (PrepareCallHierarchy, Background), + (PrepareCallHierarchyResponse, Background), (PrepareRename, Background), (PrepareRenameResponse, Background), (ProjectEntryResponse, Foreground), @@ -484,6 +490,9 @@ request_messages!( (OpenNewBuffer, OpenBufferResponse), (PerformRename, PerformRenameResponse), (Ping, Ack), + (PrepareCallHierarchy, PrepareCallHierarchyResponse), + (GetIncomingCalls, GetIncomingCallsResponse), + (GetOutgoingCalls, GetOutgoingCallsResponse), (PrepareRename, PrepareRenameResponse), (RefreshInlayHints, Ack), (RefreshSemanticTokens, Ack), @@ -663,7 +672,10 @@ lsp_messages!( (GetTypeDefinition, GetTypeDefinitionResponse, true), (GetImplementation, GetImplementationResponse, true), (InlayHints, InlayHintsResponse, false), - (SemanticTokens, SemanticTokensResponse, true) + (SemanticTokens, SemanticTokensResponse, true), + (PrepareCallHierarchy, PrepareCallHierarchyResponse, true), + (GetIncomingCalls, GetIncomingCallsResponse, true), + (GetOutgoingCalls, GetOutgoingCallsResponse, true), ); entity_messages!( @@ -1065,6 +1077,9 @@ impl LspQuery { Some(lsp_query::Request::GetDocumentLinks(_)) => ("GetDocumentLinks", false), Some(lsp_query::Request::InlayHints(_)) => ("InlayHints", false), Some(lsp_query::Request::SemanticTokens(_)) => ("SemanticTokens", false), + Some(lsp_query::Request::PrepareCallHierarchy(_)) => ("PrepareCallHierarchy", false), + Some(lsp_query::Request::GetIncomingCalls(_)) => ("GetIncomingCalls", false), + Some(lsp_query::Request::GetOutgoingCalls(_)) => ("GetOutgoingCalls", false), None => ("", true), } } diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 5e46c2f637..bdd0e8616e 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -30,7 +30,8 @@ use gpui::{ }; use http_client::{BlockedHttpClient, FakeHttpClient}; use language::{ - Buffer, FakeLspAdapter, LanguageConfig, LanguageMatcher, LanguageRegistry, LineEnding, Point, + Buffer, FakeLspAdapter, LanguageConfig, LanguageMatcher, LanguageRegistry, LineEnding, + OffsetRangeExt, Point, PointUtf16, language_settings::{AllLanguageSettings, ConfiguredLanguageServer, LanguageSettings}, }; use lsp::{ @@ -1120,6 +1121,182 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext }) } +#[gpui::test] +async fn test_remote_call_hierarchy(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "src": { + "lib.rs": "fn main() { helper(); }\nfn helper() {}\n" + } + }, + }), + ) + .await; + + let (project, headless) = init_test(&fs, cx, server_cx).await; + + fs.insert_tree( + path!("/code/project1/.zed"), + json!({ + "settings.json": r#" + { + "languages": {"Rust":{"language_servers":["rust-analyzer"]}}, + "lsp": { + "rust-analyzer": { + "binary": { + "path": "~/.cargo/bin/rust-analyzer" + } + } + } + }"# + }), + ) + .await; + + cx.update_entity(&project, |project, _| { + project.languages().register_test_language(LanguageConfig { + name: "Rust".into(), + matcher: Arc::new(LanguageMatcher { + path_suffixes: vec!["rs".into()], + ..LanguageMatcher::default() + }), + ..LanguageConfig::default() + }); + project.languages().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "rust-analyzer", + capabilities: lsp::ServerCapabilities { + call_hierarchy_provider: Some(lsp::CallHierarchyServerCapability::Simple(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ) + }); + + let mut fake_lsp = server_cx.update(|cx| { + headless.read(cx).languages.register_fake_lsp_server( + LanguageServerName("rust-analyzer".into()), + lsp::ServerCapabilities { + call_hierarchy_provider: Some(lsp::CallHierarchyServerCapability::Simple(true)), + ..lsp::ServerCapabilities::default() + }, + None, + ) + }); + + cx.run_until_parked(); + + let worktree_id = project + .update(cx, |project, cx| { + project.languages().add(rust_lang()); + project.find_or_create_worktree(path!("/code/project1"), true, cx) + }) + .await + .unwrap() + .0 + .read_with(cx, |worktree, _| worktree.id()); + + cx.run_until_parked(); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("src/lib.rs")), cx) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + let fake_lsp = fake_lsp.next().await.unwrap(); + let test_uri = lsp::Uri::from_file_path(path!("/code/project1/src/lib.rs")).unwrap(); + + fake_lsp.set_request_handler::({ + let uri = test_uri.clone(); + move |_, _| { + let uri = uri.clone(); + async move { + Ok(Some(vec![lsp::CallHierarchyItem { + name: "main".into(), + kind: lsp::SymbolKind::FUNCTION, + tags: None, + detail: Some("fn main()".into()), + uri, + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 21)), + selection_range: lsp::Range::new( + lsp::Position::new(0, 3), + lsp::Position::new(0, 7), + ), + data: None, + }])) + } + } + }); + + fake_lsp.set_request_handler::({ + move |_, _| { + let uri = test_uri.clone(); + async move { + Ok(Some(vec![lsp::CallHierarchyOutgoingCall { + to: lsp::CallHierarchyItem { + name: "helper".into(), + kind: lsp::SymbolKind::FUNCTION, + tags: None, + detail: Some("fn helper()".into()), + uri, + range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 13)), + selection_range: lsp::Range::new( + lsp::Position::new(1, 3), + lsp::Position::new(1, 9), + ), + data: None, + }, + from_ranges: Vec::new(), + }])) + } + } + }); + + let items = project + .update(cx, |project, cx| { + project.prepare_call_hierarchy(&buffer, PointUtf16::new(0, 3), cx) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].name, "main"); + assert_eq!(items[0].buffer, buffer); + items[0].buffer.read_with(cx, |item_buffer, _| { + assert_eq!( + items[0].selection_range.to_point(item_buffer), + Point::new(0, 3)..Point::new(0, 7) + ); + }); + + let outgoing_calls = project + .update(cx, |project, cx| { + project.outgoing_calls(items[0].clone(), cx) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(outgoing_calls.len(), 1); + assert_eq!(outgoing_calls[0].to.name, "helper"); + assert_eq!(outgoing_calls[0].to.buffer, buffer); + outgoing_calls[0].to.buffer.read_with(cx, |item_buffer, _| { + assert_eq!( + outgoing_calls[0].to.selection_range.to_point(item_buffer), + Point::new(1, 3)..Point::new(1, 9) + ); + }); +} + #[gpui::test] async fn test_remote_code_action_resolve(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { cx.update(|cx| { diff --git a/crates/rpc/src/proto_client.rs b/crates/rpc/src/proto_client.rs index d1cdd8d9e5..8644d554f3 100644 --- a/crates/rpc/src/proto_client.rs +++ b/crates/rpc/src/proto_client.rs @@ -426,6 +426,15 @@ impl AnyProtoClient { Response::GetDocumentLinksResponse(response) => { to_any_envelope(&envelope, response) } + Response::PrepareCallHierarchyResponse(response) => { + to_any_envelope(&envelope, response) + } + Response::GetIncomingCallsResponse(response) => { + to_any_envelope(&envelope, response) + } + Response::GetOutgoingCallsResponse(response) => { + to_any_envelope(&envelope, response) + } }; Some(proto::ProtoLspResponse { server_id, diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 1480418946..91eb2bae0f 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -184,6 +184,7 @@ impl VsCodeSettings { diagnostics: None, editor: self.editor_settings_content(), extension: ExtensionSettingsContent::default(), + call_hierarchy: None, file_finder: None, git: self.git_settings_content(), git_panel: self.git_panel_settings_content(), diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 4966ecc344..fc6828a286 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -193,6 +193,8 @@ pub struct SettingsContent { /// Settings related to the file finder. pub file_finder: Option, + pub call_hierarchy: Option, + pub git_panel: Option, pub tabs: Option, @@ -397,8 +399,9 @@ impl SettingsContent { fallible_options::flattened_deserialize!(SettingsContent { sections: { project, theme, extension, workspace, editor, remote }, options: { - file_finder, git_panel, tabs, tab_bar, status_bar, preview_tabs, agent, agent_servers, - audio, auto_update, base_keymap, collaboration_panel, debugger, diagnostics, git, + call_hierarchy, file_finder, git_panel, tabs, tab_bar, status_bar, preview_tabs, agent, + agent_servers, audio, auto_update, base_keymap, collaboration_panel, debugger, diagnostics, + git, global_lsp_settings, image_viewer, markdown_preview, repl, helix_mode, hide_mouse, journal, log, line_indicator_format, language_models, outline_panel, project_panel, node, proxy, reduce_motion, server_url, credentials_url, session, telemetry, terminal, @@ -931,7 +934,7 @@ pub struct FileFinderSettingsContent { /// Determines how much space the file finder can take up in relation to the available window width. /// /// Default: small - pub modal_max_width: Option, + pub modal_max_width: Option, /// Determines whether the file finder should skip focus for the active file in search results. /// /// Default: true @@ -987,7 +990,7 @@ pub enum IncludeIgnoredContent { strum::VariantNames, )] #[serde(rename_all = "lowercase")] -pub enum FileFinderWidthContent { +pub enum ModalWidthContent { #[default] Small, Medium, @@ -996,6 +999,27 @@ pub enum FileFinderWidthContent { Full, } +impl ModalWidthContent { + pub fn to_pixels(self, base_width: gpui::Pixels, window_width: gpui::Pixels) -> gpui::Pixels { + match self { + ModalWidthContent::Small => base_width, + ModalWidthContent::Full => window_width, + ModalWidthContent::XLarge => (window_width - gpui::px(512.)).max(base_width), + ModalWidthContent::Large => (window_width - gpui::px(768.)).max(base_width), + ModalWidthContent::Medium => (window_width - gpui::px(1024.)).max(base_width), + } + } +} + +#[with_fallible_options] +#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, PartialEq)] +pub struct CallHierarchySettingsContent { + /// Determines how much space the call hierarchy picker can take up in relation to the available window width. + /// + /// Default: medium + pub modal_max_width: Option, +} + #[with_fallible_options] #[derive(Clone, Default, Serialize, Deserialize, PartialEq, Debug, JsonSchema, MergeFrom)] pub struct VimSettingsContent { diff --git a/crates/snippets_ui/src/snippets_ui.rs b/crates/snippets_ui/src/snippets_ui.rs index 5f5a26faa4..fddfe7d89d 100644 --- a/crates/snippets_ui/src/snippets_ui.rs +++ b/crates/snippets_ui/src/snippets_ui.rs @@ -250,7 +250,7 @@ impl PickerDelegate for ScopeSelectorDelegate { fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { self.scope_selector .update(cx, |_, cx| cx.emit(DismissEvent)) - .log_err(); + .ok(); } fn selected_index(&self) -> usize { diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index f677ce27da..114a0fe0f5 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -209,9 +209,7 @@ impl PickerDelegate for IconThemeSelectorDelegate { fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { self.revert_theme(cx); - self.selector - .update(cx, |_, cx| cx.emit(DismissEvent)) - .log_err(); + self.selector.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); } fn selected_index(&self) -> usize { diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index a245e95529..e35b48d32e 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -418,9 +418,7 @@ impl PickerDelegate for ThemeSelectorDelegate { fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { self.revert_theme(cx); - self.selector - .update(cx, |_, cx| cx.emit(DismissEvent)) - .log_err(); + self.selector.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); } fn selected_index(&self) -> usize { diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index a85b722a6d..8402701ec8 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -957,7 +957,7 @@ impl PickerDelegate for ToolchainSelectorDelegate { fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { self.toolchain_selector .update(cx, |_, cx| cx.emit(DismissEvent)) - .log_err(); + .ok(); } fn selected_index(&self) -> usize { diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index c58d058c4f..3ea181ca06 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -86,6 +86,7 @@ auto_update.workspace = true auto_update_ui.workspace = true breadcrumbs.workspace = true call.workspace = true +call_hierarchy.workspace = true channel.workspace = true chrono.workspace = true clap.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index e96f97508c..824a6a2fbe 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -748,6 +748,7 @@ fn main() { file_finder::init(cx); tab_switcher::init(cx); outline::init(cx); + call_hierarchy::init(cx); project_symbols::init(cx); project_panel::init(cx); outline_panel::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index bbb11dc176..d177a67800 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5840,6 +5840,7 @@ mod tests { "bedrock", "branches", "buffer_search", + "call_hierarchy", "channel_modal", "cli", "client", diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index 35d104ecc0..bba2532543 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -248,6 +248,8 @@ pub fn app_menus(cx: &mut App) -> Vec { "Find All References", editor::actions::FindAllReferences::default(), ), + MenuItem::action("Show Incoming Calls", call_hierarchy::ShowIncomingCalls), + MenuItem::action("Show Outgoing Calls", call_hierarchy::ShowOutgoingCalls), MenuItem::separator(), MenuItem::action("Next Problem", editor::actions::GoToDiagnostic::default()), MenuItem::action( diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index d7f4074cd9..7f0a14d810 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -816,6 +816,10 @@ actions!( OpenGitIntegrationOnboarding ] ); +actions!( + call_hierarchy, + [ShowIncomingCalls, ShowOutgoingCalls, ToggleDirection] +); pub mod debug_panel { use gpui::actions; diff --git a/docs/src/finding-navigating.md b/docs/src/finding-navigating.md index a801da6131..52a0f2014f 100644 --- a/docs/src/finding-navigating.md +++ b/docs/src/finding-navigating.md @@ -42,6 +42,15 @@ Jump to where a symbol is defined with {#kb editor::GoToDefinition} (or `Cmd+Cli - **Current file:** {#kb outline::Toggle} opens an outline of symbols in the active file - **Entire project:** {#kb project_symbols::Toggle} searches symbols across all files +## Call Hierarchy + +With the cursor on a function, {#action call_hierarchy::ShowIncomingCalls} ({#kb call_hierarchy::ShowIncomingCalls}) lists the functions that call it, and {#action call_hierarchy::ShowOutgoingCalls} lists the functions it calls. +Both open a picker; selecting an entry jumps to the call site, and the secondary confirm opens it in an adjacent pane. +While the picker is open, {#action call_hierarchy::ToggleDirection} switches between callers and callees, {#action menu::SelectChild} re-roots the hierarchy at the selected function to explore one level deeper, and {#action menu::SelectParent} steps back up; the picker's footer shows the corresponding key bindings. +Both actions are also available in the editor's right-click menu. +The modal's width can be changed via the `call_hierarchy.modal_max_width` setting. +This requires the language server to support call hierarchy. + ## Outline Panel The Outline Panel ({#kb outline_panel::ToggleFocus}) shows a persistent tree view of symbols in the current file. It's especially useful with [multibuffers](./multibuffers.md) for navigating search results or diagnostics. diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index f5d527dbe1..69c57315b3 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -3570,6 +3570,14 @@ Examples: `boolean` values +## Call Hierarchy + +### Modal Max Width + +- Description: Max-width of the call hierarchy modal. It can take one of these values: `small`, `medium`, `large`, `xlarge`, and `full`. +- Setting: `modal_max_width` +- Default: `medium` + ## File Finder ### File Icons