Support commands in inlay hints (#63605)

Closes https://github.com/zed-industries/zed/discussions/51582

During testing, spotted that if I toggle off the hints with the modifier
keys, they are resurrected on scrolling — fixed that in a small commit
on top.

Release Notes:

- Supported commands in inlay hints
This commit is contained in:
Kirill Bulatov authored and GitHub committed 2026-09-09 13:47:39 +00:00
1 parent 0690433b60
commit 002161d5ba
9 files changed
+386 -39

No files matched your search

+13
View File
@@ -5664,6 +5664,19 @@ impl EditorElement {
CursorStyle::PointingHand,
&layout.position_map.text_hitbox,
);
} else if !window.modifiers().modified()
&& let Some(hovered_command) = editor.hovered_inlay_hint_command()
&& hovered_command.contains_point(
&layout.position_map.snapshot,
layout
.position_map
.point_for_position(window.mouse_position()),
)
{
window.set_cursor_style(
CursorStyle::PointingHand,
&layout.position_map.text_hitbox,
);
} else {
window.set_cursor_style(CursorStyle::IBeam, &layout.position_map.text_hitbox);
};
+30 -5
View File
@@ -613,6 +613,16 @@ impl EditorElement {
return;
}
if !event.modifiers.modified()
&& text_hitbox.is_hovered(window)
&& editor.hovered_inlay_hint_command().is_some_and(|command| {
command.contains_point(&position_map.snapshot, point_for_position)
})
{
cx.stop_propagation();
return;
}
if EditorSettings::get_global(cx)
.drag_and_drop_selection
.enabled
@@ -943,8 +953,6 @@ impl EditorElement {
if let Some(mouse_position) = event.mouse_position()
&& !pending_nonempty_selections
&& hovered_link_modifier
&& mouse_down_hovered_link_modifier
&& text_hitbox.is_hovered(window)
&& !matches!(
editor.selection_drag_state,
@@ -952,10 +960,27 @@ impl EditorElement {
)
{
let point = position_map.point_for_position(mouse_position);
editor.handle_click_hovered_link(point, event.modifiers(), window, cx);
editor.selection_drag_state = SelectionDragState::None;
if let ClickEvent::Mouse(mouse_event) = event
&& mouse_event.up.click_count == 1
&& !mouse_event.down.modifiers.modified()
&& !mouse_event.up.modifiers.modified()
&& editor.activate_hovered_inlay_hint_command(
&position_map.snapshot,
position_map.point_for_position(mouse_event.down.position),
point,
cx,
)
{
editor.selection_drag_state = SelectionDragState::None;
cx.stop_propagation();
return;
}
cx.stop_propagation();
if hovered_link_modifier && mouse_down_hovered_link_modifier {
editor.handle_click_hovered_link(point, event.modifiers(), window, cx);
editor.selection_drag_state = SelectionDragState::None;
cx.stop_propagation();
}
}
}
+78 -3
View File
@@ -1071,7 +1071,9 @@ mod tests {
test::editor_lsp_test_context::EditorLspTestContext,
};
use futures::StreamExt;
use gpui::{Modifiers, MousePressureEvent, PressureStage};
use gpui::{
Modifiers, MouseButton, MouseDownEvent, MousePressureEvent, MouseUpEvent, PressureStage,
};
use indoc::indoc;
use language::Point;
use lsp::request::{GotoDefinition, GotoTypeDefinition};
@@ -1712,7 +1714,11 @@ mod tests {
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
inlay_hint_provider: Some(lsp::OneOf::Left(true)),
..Default::default()
execute_command_provider: Some(lsp::ExecuteCommandOptions {
commands: vec!["upgrade".to_string()],
..lsp::ExecuteCommandOptions::default()
}),
..lsp::ServerCapabilities::default()
},
cx,
)
@@ -1756,7 +1762,12 @@ mod tests {
uri: params.text_document.uri,
range: target_range,
}),
..Default::default()
command: Some(lsp::Command {
title: "Upgrade".to_string(),
command: "upgrade".to_string(),
arguments: Some(vec![serde_json::json!("3.5.16")]),
}),
..lsp::InlayHintLabelPart::default()
}]),
kind: Some(lsp::InlayHintKind::TYPE),
text_edits: None,
@@ -1770,6 +1781,13 @@ mod tests {
.next()
.await;
cx.background_executor.run_until_parked();
let mut command_requests = cx
.lsp
.set_request_handler::<lsp::request::ExecuteCommand, _, _>(|params, _| async move {
assert_eq!(params.command, "upgrade");
assert_eq!(params.arguments, vec![serde_json::json!("3.5.16")]);
Ok(Some(serde_json::Value::Null))
});
cx.update_editor(|editor, _window, cx| {
let expected_layers = vec![hint_label.to_string()];
assert_eq!(expected_layers, cached_hint_labels(editor, cx));
@@ -1831,6 +1849,63 @@ mod tests {
assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}");
});
cx.update_editor(|editor, _window, cx| {
let hovered_inlay = editor
.display_map
.read(cx)
.current_inlays()
.find(|inlay| inlay.id == InlayId::Hint(0))
.cloned()
.expect("hovered inlay");
let hovered_inlay_id = hovered_inlay.id;
editor.splice_inlays(&[hovered_inlay_id], vec![hovered_inlay], cx);
assert!(
editor.hovered_inlay_hint_command().is_none(),
"replacing the hovered inlay should clear its command"
);
});
cx.simulate_click(hover_point, Modifiers::none());
cx.background_executor.run_until_parked();
assert!(
command_requests.try_recv().is_err(),
"clicking without moving after hint replacement should not run the stale command"
);
cx.simulate_mouse_move(hover_point, None, Modifiers::none());
cx.background_executor.run_until_parked();
cx.simulate_click(hover_point, Modifiers::none());
command_requests
.next()
.await
.expect("execute command request");
cx.background_executor.run_until_parked();
cx.assert_editor_state(indoc! {"
struct TestStruct;
fn main() {
let variableˇ = TestStruct;
}
"});
cx.simulate_event(MouseDownEvent {
position: hover_point,
modifiers: Modifiers::none(),
button: MouseButton::Left,
click_count: 2,
first_mouse: false,
});
cx.simulate_event(MouseUpEvent {
position: hover_point,
modifiers: Modifiers::none(),
button: MouseButton::Left,
click_count: 2,
});
cx.background_executor.run_until_parked();
assert!(
command_requests.try_recv().is_err(),
"Second click of a double click should not re-run the inlay hint command"
);
cx.simulate_modifiers_change(Modifiers::secondary_key());
cx.background_executor.run_until_parked();
cx.simulate_click(hover_point, Modifiers::secondary_key());
+3
View File
@@ -2815,6 +2815,7 @@ mod tests {
value: label.to_string(),
tooltip: None,
location: None,
command: None,
}];
let hint_start = InlayOffset(MultiBufferOffset(100));
@@ -2859,11 +2860,13 @@ mod tests {
value: "→ ".to_string(), // 4 bytes (3 + 1)
tooltip: None,
location: None,
command: None,
},
InlayHintLabelPart {
value: "path".to_string(), // 4 bytes
tooltip: None,
location: None,
command: None,
},
];
+1 -1
View File
@@ -158,7 +158,7 @@ impl Editor {
) {
if let Some(inlay_hints) = &mut self.inlay_hints {
for id_to_remove in to_remove {
inlay_hints.added_hints.remove(id_to_remove);
inlay_hints.remove_inlay(id_to_remove);
}
}
self.display_map.update(cx, |display_map, cx| {
+230 -26
View File
@@ -7,7 +7,7 @@ use std::{
use clock::Global;
use collections::{HashMap, HashSet};
use futures::future::join_all;
use gpui::{App, Entity, Pixels, Task};
use gpui::{App, Entity, Pixels, Task, TaskExt};
use itertools::Itertools;
use language::{
BufferRow,
@@ -16,8 +16,8 @@ use language::{
use lsp::LanguageServerId;
use multi_buffer::{Anchor, MultiBufferSnapshot};
use project::{
HoverBlock, HoverBlockKind, InlayHintLabel, InlayHintLabelPartTooltip, InlayHintTooltip,
InvalidationStrategy, ResolveState,
CodeAction, HoverBlock, HoverBlockKind, InlayHintLabel, InlayHintLabelPartTooltip,
InlayHintTooltip, InvalidationStrategy, LspAction, ResolveState,
lsp_store::{CacheInlayHints, ResolvedHint},
};
use text::{Bias, BufferId};
@@ -33,6 +33,34 @@ use crate::{
inlays::InlaySplice,
};
#[derive(Debug)]
pub(crate) struct HoveredInlayHintCommand {
highlight: InlayHighlight,
buffer_id: BufferId,
action: CodeAction,
}
impl HoveredInlayHintCommand {
pub(crate) fn contains_point(
&self,
snapshot: &EditorSnapshot,
point_for_position: PointForPosition,
) -> bool {
if point_for_position.column_overshoot_after_line_end != 0
|| point_for_position.as_valid().is_some()
|| !snapshot.can_resolve(&self.highlight.inlay_position)
{
return false;
}
let hovered_offset =
snapshot.display_point_to_inlay_offset(point_for_position.exact_unclipped, Bias::Left);
let hint_start = snapshot.anchor_to_inlay_offset(self.highlight.inlay_position);
let part_range = InlayOffset(hint_start.0 + self.highlight.range.start)
..InlayOffset(hint_start.0 + self.highlight.range.end);
part_range.contains(&hovered_offset)
}
}
pub fn inlay_hint_settings(
location: Anchor,
snapshot: &MultiBufferSnapshot,
@@ -53,6 +81,7 @@ pub struct LspInlayHintData {
hint_chunk_fetching: HashMap<BufferId, (Global, HashSet<Range<BufferRow>>)>,
invalidate_hints_for_buffers: HashSet<BufferId>,
pub added_hints: HashMap<InlayId, Option<InlayHintKind>>,
hovered_command: Option<HoveredInlayHintCommand>,
}
impl LspInlayHintData {
@@ -68,6 +97,7 @@ impl LspInlayHintData {
invalidate_debounce: debounce_value(settings.edit_debounce_ms),
append_debounce: debounce_value(settings.scroll_debounce_ms),
allowed_hint_kinds: settings.enabled_inlay_hint_kinds(),
hovered_command: None,
}
}
@@ -76,12 +106,11 @@ impl LspInlayHintData {
return None;
}
self.modifiers_override = new_override;
if (self.enabled && self.modifiers_override) || (!self.enabled && !self.modifiers_override)
{
if self.should_show() {
Some(true)
} else {
self.clear();
Some(false)
} else {
Some(true)
}
}
@@ -101,6 +130,7 @@ impl LspInlayHintData {
self.hint_refresh_tasks.clear();
self.hint_chunk_fetching.clear();
self.added_hints.clear();
self.hovered_command = None;
}
/// Like `clear`, but only wipes tracking state for the given buffer IDs.
@@ -112,6 +142,13 @@ impl LspInlayHintData {
current_hints: impl IntoIterator<Item = Inlay>,
snapshot: &MultiBufferSnapshot,
) {
if self
.hovered_command
.as_ref()
.is_some_and(|command| buffer_ids.contains(&command.buffer_id))
{
self.hovered_command = None;
}
for buffer_id in buffer_ids {
self.hint_refresh_tasks.remove(buffer_id);
self.hint_chunk_fetching.remove(buffer_id);
@@ -224,6 +261,25 @@ impl LspInlayHintData {
self.hint_chunk_fetching.remove(buffer_id);
}
}
pub(super) fn remove_inlay(&mut self, inlay_id: &InlayId) {
self.added_hints.remove(inlay_id);
if self
.hovered_command
.as_ref()
.is_some_and(|command| command.highlight.inlay == *inlay_id)
{
self.hovered_command = None;
}
}
fn should_refresh(&self) -> bool {
self.enabled || self.modifiers_override
}
fn should_show(&self) -> bool {
self.enabled != self.modifiers_override
}
}
#[derive(Debug, Clone)]
@@ -551,15 +607,12 @@ impl Editor {
}
};
match &mut self.inlay_hints {
Some(inlay_hints) => {
if !inlay_hints.enabled
&& !matches!(reason, InlayHintRefreshReason::ModifiersChanged(_))
{
return None;
}
}
None => return None,
if !self
.inlay_hints
.as_ref()
.is_some_and(LspInlayHintData::should_refresh)
{
return None;
}
Some(invalidate_cache)
@@ -595,6 +648,7 @@ impl Editor {
};
let mut go_to_definition_updated = false;
let mut hover_updated = false;
let mut inlay_command_updated = false;
if let Some(hovered_offset) = hovered_offset {
let buffer_snapshot = self.buffer().read(cx).snapshot(cx);
let previous_valid_anchor = buffer_snapshot.anchor_at(
@@ -619,13 +673,12 @@ impl Editor {
})
.max_by_key(|hint| hint.id)
{
if let Some(ResolvedHint::Resolved(cached_hint)) = buffer_snapshot
.anchor_to_buffer_anchor(hovered_hint.position)
.and_then(|(anchor, _)| {
if let Some((buffer_anchor, _)) =
buffer_snapshot.anchor_to_buffer_anchor(hovered_hint.position)
&& let Some(ResolvedHint::Resolved(cached_hint)) =
lsp_store.update(cx, |lsp_store, cx| {
lsp_store.resolved_hint(anchor.buffer_id, hovered_hint.id, cx)
lsp_store.resolved_hint(buffer_anchor.buffer_id, hovered_hint.id, cx)
})
})
{
match cached_hint.resolve_state {
ResolveState::Resolved => {
@@ -693,6 +746,24 @@ impl Editor {
inlay_position: hovered_hint.position,
range: highlight_start..highlight_end,
};
if let Some((server_id, command)) =
hovered_hint_part.command
&& let Some(inlay_hints) = self.inlay_hints.as_mut()
{
inlay_command_updated = true;
inlay_hints.hovered_command =
Some(HoveredInlayHintCommand {
highlight: highlight.clone(),
buffer_id: buffer_anchor.buffer_id,
action: CodeAction {
server_id,
range: cached_hint.position
..cached_hint.position,
lsp_action: LspAction::Command(command),
resolved: true,
},
});
}
if let Some(tooltip) = hovered_hint_part.tooltip {
hover_popover::hover_at_inlay(
self,
@@ -756,6 +827,40 @@ impl Editor {
if !hover_updated {
hover_popover::hover_at(self, None, mouse_position, window, cx);
}
if !inlay_command_updated && let Some(inlay_hints) = self.inlay_hints.as_mut() {
inlay_hints.hovered_command = None;
}
}
pub(crate) fn hovered_inlay_hint_command(&self) -> Option<&HoveredInlayHintCommand> {
self.inlay_hints.as_ref()?.hovered_command.as_ref()
}
pub(crate) fn activate_hovered_inlay_hint_command(
&mut self,
snapshot: &EditorSnapshot,
down: PointForPosition,
up: PointForPosition,
cx: &mut Context<Self>,
) -> bool {
let Some(state) = self.hovered_inlay_hint_command().filter(|state| {
state.contains_point(snapshot, down) && state.contains_point(snapshot, up)
}) else {
return false;
};
let Some(buffer) = self.buffer().read(cx).buffer(state.buffer_id) else {
return false;
};
let Some(project) = self.project().cloned() else {
return false;
};
let action = state.action.clone();
project
.update(cx, |project, cx| {
project.apply_code_action(buffer, action, true, cx)
})
.detach_and_log_err(cx);
true
}
fn inlay_hints_for_buffer(
@@ -807,6 +912,7 @@ impl Editor {
let Some(inlay_hints) = &mut self.inlay_hints else {
return;
};
let should_show = inlay_hints.should_show();
let Some(buffer_snapshot) = self
.buffer
.read(cx)
@@ -901,7 +1007,8 @@ impl Editor {
hints_deduplicated
})
.filter(|(hint_id, lsp_hint)| {
inlay_hints.allowed_hint_kinds.contains(&lsp_hint.kind)
should_show
&& inlay_hints.allowed_hint_kinds.contains(&lsp_hint.kind)
&& inlay_hints
.added_hints
.insert(*hint_id, lsp_hint.kind)
@@ -1002,7 +1109,9 @@ fn spawn_editor_hints_refresh(
#[cfg(test)]
pub mod tests {
use super::{HoveredInlayHintCommand, LspInlayHintData};
use crate::editor_tests::update_test_language_settings;
use crate::hover_links::InlayHighlight;
use crate::inlays::inlay_hints::InlayHintRefreshReason;
use crate::scroll::Autoscroll;
use crate::scroll::ScrollAmount;
@@ -1012,26 +1121,80 @@ pub mod tests {
use futures::{StreamExt, future};
use gpui::{AppContext as _, Context, TestAppContext, WindowHandle};
use itertools::Itertools as _;
use language::language_settings::InlayHintKind;
use language::language_settings::{InlayHintKind, InlayHintSettings};
use language::{Capability, FakeLspAdapter};
use language::{Language, LanguageConfig, LanguageMatcher};
use languages::rust_lang;
use lsp::{DEFAULT_LSP_REQUEST_TIMEOUT, FakeLanguageServer};
use lsp::{DEFAULT_LSP_REQUEST_TIMEOUT, FakeLanguageServer, LanguageServerId};
use multi_buffer::{MultiBuffer, MultiBufferOffset, PathKey};
use parking_lot::Mutex;
use pretty_assertions::assert_eq;
use project::{FakeFs, InvalidationStrategy, Project};
use project::{CodeAction, FakeFs, InlayId, InvalidationStrategy, LspAction, Project};
use serde_json::json;
use settings::{AllLanguageSettingsContent, InlayHintSettingsContent, SettingsStore};
use std::ops::Range;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use std::time::Duration;
use text::{OffsetRangeExt, Point};
use text::{BufferId, OffsetRangeExt, Point};
use ui::App;
use util::path;
use util::paths::natural_sort;
#[gpui::test]
fn test_clearing_buffers_clears_only_matching_hovered_command(cx: &mut TestAppContext) {
let hovered_buffer_id = BufferId::new(1).expect("hovered buffer id");
let other_buffer_id = BufferId::new(2).expect("other buffer id");
let mut inlay_hints = LspInlayHintData::new(InlayHintSettings {
enabled: true,
show_value_hints: true,
show_type_hints: true,
show_parameter_hints: true,
show_other_hints: true,
show_background: false,
edit_debounce_ms: 0,
scroll_debounce_ms: 0,
toggle_on_modifiers_press: None,
});
inlay_hints.hovered_command = Some(HoveredInlayHintCommand {
highlight: InlayHighlight {
inlay: InlayId::Hint(1),
inlay_position: multi_buffer::Anchor::Min,
range: 0..1,
},
buffer_id: hovered_buffer_id,
action: CodeAction {
server_id: LanguageServerId(0),
range: language::Anchor::min_min_range_for_buffer(hovered_buffer_id),
lsp_action: LspAction::Command(lsp::Command {
title: "command".to_string(),
command: "command".to_string(),
arguments: None,
}),
resolved: true,
},
});
let multi_buffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
let snapshot = cx.update(|cx| multi_buffer.read(cx).snapshot(cx));
inlay_hints.remove_inlay(&InlayId::Hint(2));
assert!(inlay_hints.hovered_command.is_some());
inlay_hints.clear_for_buffers(
&HashSet::from_iter([other_buffer_id]),
Vec::new(),
&snapshot,
);
assert!(inlay_hints.hovered_command.is_some());
inlay_hints.clear_for_buffers(
&HashSet::from_iter([hovered_buffer_id]),
Vec::new(),
&snapshot,
);
assert!(inlay_hints.hovered_command.is_none());
}
#[gpui::test]
async fn test_basic_cache_update_with_duplicate_hints(cx: &mut gpui::TestAppContext) {
let allowed_hint_kinds = HashSet::from_iter([None, Some(InlayHintKind::Type)]);
@@ -3741,6 +3904,19 @@ let c = 3;"#
);
})
.unwrap();
editor
.update(cx, |editor, _, cx| {
editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
})
.unwrap();
cx.executor().run_until_parked();
editor
.update(cx, |editor, _, cx| {
assert_eq!(Vec::<String>::new(), visible_hint_labels(editor, cx));
})
.unwrap();
editor
.update(cx, |editor, _, cx| {
editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(true), cx);
@@ -3889,6 +4065,34 @@ let c = 3;"#
);
})
.unwrap();
editor
.update(cx, |editor, window, cx| {
editor.toggle_inlay_hints(&crate::ToggleInlayHints, window, cx);
editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(true), cx);
editor.handle_input("x", window, cx);
})
.unwrap();
cx.executor().run_until_parked();
editor
.update(cx, |editor, _, cx| {
assert_eq!(vec!["2".to_string()], cached_hint_labels(editor, cx));
assert_eq!(Vec::<String>::new(), visible_hint_labels(editor, cx));
})
.unwrap();
editor
.update(cx, |editor, _, cx| {
editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(false), cx);
})
.unwrap();
cx.executor().run_until_parked();
editor
.update(cx, |editor, _, cx| {
assert_eq!(vec!["2".to_string()], cached_hint_labels(editor, cx));
assert_eq!(vec!["2".to_string()], visible_hint_labels(editor, cx));
})
.unwrap();
}
#[gpui::test]
+26 -1
View File
@@ -3989,6 +3989,7 @@ impl InlayHints {
}
}),
location: Some(server_id).zip(lsp_part.location),
command: Some(server_id).zip(lsp_part.command),
});
}
InlayHintLabel::LabelParts(parts)
@@ -3999,6 +4000,7 @@ impl InlayHints {
}
pub fn project_to_proto_hint(response_hint: InlayHint) -> proto::InlayHint {
let position = response_hint.position;
let (state, lsp_resolve_state) = match response_hint.resolve_state {
ResolveState::Resolved => (0, None),
ResolveState::CanResolve(server_id, resolve_data) => (
@@ -4046,6 +4048,12 @@ impl InlayHints {
location_range_start,
location_range_end,
language_server_id: label_part.location.as_ref().map(|(server_id, _)| server_id.0 as u64),
command: label_part.command.map(|(server_id, command)| LspStore::serialize_code_action(&CodeAction {
server_id,
range: position..position,
lsp_action: LspAction::Command(command),
resolved: true,
})),
}}).collect()
})
}
@@ -4164,6 +4172,23 @@ impl InlayHints {
None => None,
}
},
command: match part.command {
Some(command) => {
let action = LspStore::deserialize_code_action(command)
.context("invalid command in inlay hint label part")?;
match action.lsp_action {
LspAction::Command(command) => {
Some((action.server_id, command))
}
LspAction::Action(_) | LspAction::CodeLens(_) => {
anyhow::bail!(
"unexpected non-command action in inlay hint label part"
)
}
}
}
None => None,
},
});
}
@@ -4249,7 +4274,7 @@ impl InlayHints {
})
}),
location: part.location.map(|(_, location)| location),
command: None,
command: part.command.map(|(_, command)| command),
})
.collect(),
),
+4 -3
View File
@@ -519,7 +519,7 @@ impl InlayId {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct InlayHint {
pub position: language::Anchor,
pub label: InlayHintLabel,
@@ -853,17 +853,18 @@ impl InlayHint {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub enum InlayHintLabel {
String(String),
LabelParts(Vec<InlayHintLabelPart>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct InlayHintLabelPart {
pub value: String,
pub tooltip: Option<InlayHintLabelPartTooltip>,
pub location: Option<(LanguageServerId, lsp::Location)>,
pub command: Option<(LanguageServerId, lsp::Command)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
+1
View File
@@ -572,6 +572,7 @@ message InlayHintLabelPart {
PointUtf16 location_range_start = 4;
PointUtf16 location_range_end = 5;
optional uint64 language_server_id = 6;
optional CodeAction command = 7;
}
message InlayHintTooltip {