From 692057f7992e2a1f5ca6f7cc2f62b3479cb30f9b Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 22 Sep 2025 11:39:50 +0800 Subject: [PATCH] editor: Update completions to support CompletionTextEdit. (#1267) - Fix completion menu width to longest item. --- crates/story/examples/code-editor.rs | 59 +++++++++++++++++-- .../examples/fixtures/completion_items.json | 20 ++++--- .../ui/src/input/popovers/completion_menu.rs | 44 +++++++++++--- crates/ui/src/input/state.rs | 21 +++++++ crates/ui/src/list/cache.rs | 4 ++ crates/ui/src/list/list.rs | 18 +++++- 6 files changed, 144 insertions(+), 22 deletions(-) diff --git a/crates/story/examples/code-editor.rs b/crates/story/examples/code-editor.rs index 174baedc..5b82fbd7 100644 --- a/crates/story/examples/code-editor.rs +++ b/crates/story/examples/code-editor.rs @@ -20,8 +20,8 @@ use gpui_component::{ v_flex, ActiveTheme, ContextModal, IconName, IndexPath, Selectable, Sizable, }; use lsp_types::{ - CodeAction, CodeActionKind, CompletionContext, CompletionItem, CompletionResponse, TextEdit, - WorkspaceEdit, + CodeAction, CodeActionKind, CompletionContext, CompletionItem, CompletionResponse, + CompletionTextEdit, InsertReplaceEdit, TextEdit, WorkspaceEdit, }; use story::Assets; @@ -166,11 +166,31 @@ impl ExampleLspStore { } } +fn completion_item( + range: &lsp_types::Range, + label: &str, + replace_text: &str, + documentation: &str, +) -> CompletionItem { + CompletionItem { + label: label.to_string(), + kind: Some(lsp_types::CompletionItemKind::FUNCTION), + text_edit: Some(CompletionTextEdit::InsertAndReplace(InsertReplaceEdit { + new_text: replace_text.to_string(), + insert: range.clone(), + replace: range.clone(), + })), + documentation: Some(lsp_types::Documentation::String(documentation.to_string())), + insert_text: None, + ..Default::default() + } +} + impl CompletionProvider for ExampleLspStore { fn completions( &self, rope: &Rope, - _offset: usize, + offset: usize, trigger: CompletionContext, _: &mut Window, cx: &mut Context, @@ -181,6 +201,7 @@ impl CompletionProvider for ExampleLspStore { } let _ = rope.to_string(); // Just to use the rope parameter. + let pos = rope.offset_to_position(offset); // Simulate to delay for fetching completions let items = self.completions.clone(); @@ -188,11 +209,41 @@ impl CompletionProvider for ExampleLspStore { // Simulate a slow completion source, to test Editor async handling. smol::Timer::after(Duration::from_millis(20)).await; + let range = lsp_types::Range::new( + pos, + lsp_types::Position { + line: pos.line, + character: pos.character + 1, + }, + ); + + if trigger_character.starts_with("/") { + let items = vec![ + completion_item( + &range, + "/date", + format!("{}", chrono::Local::now().date_naive()).as_str(), + "Insert current date", + ), + completion_item(&range, "/thanks", "Thank you!", "Insert Thank you!"), + completion_item(&range, "/+1", "👍", "Insert 👍"), + completion_item(&range, "/-1", "👎", "Insert 👎"), + completion_item(&range, "/smile", "😊", "Insert 😊"), + completion_item(&range, "/sad", "😢", "Insert 😢"), + completion_item(&range, "/launch", "🚀", "Insert 🚀"), + ]; + return Ok(vec![CompletionResponse::Array(items)]); + } + let items = items .iter() .filter(|item| item.label.starts_with(&trigger_character)) .take(10) - .map(|item| item.clone()) + .map(|item| { + let mut item = item.clone(); + item.insert_text = Some(item.label.replace(&trigger_character, "")); + item + }) .collect::>(); let responses = vec![CompletionResponse::Array(items)]; diff --git a/crates/story/examples/fixtures/completion_items.json b/crates/story/examples/fixtures/completion_items.json index c5c688a9..2bea7477 100644 --- a/crates/story/examples/fixtures/completion_items.json +++ b/crates/story/examples/fixtures/completion_items.json @@ -176,35 +176,39 @@ "documentation": "Standard library for Rust." }, { - "label": "vec", + "label": "vec!", "documentation": "Create a growable array.\n\n**Example:**\n```rust\nlet mut v = Vec::new();\nv.push(1);\nv.push(2);\n```" }, { - "label": "format", + "label": "format!", "documentation": "Format a string." }, { - "label": "println", + "label": "println!", "documentation": "Print to the standard output.\n\n**Example:**\n```rust\nprintln!(\"Hello, world!\");\n```" }, { - "label": "eprintln", + "label": "eprintln!", "documentation": "Print to the standard error." }, { - "label": "dbg", + "label": "dbg!", "documentation": "Debug print a value." }, { - "label": "todo", + "label": "todo!", "documentation": "Mark unfinished code." }, { - "label": "unimplemented", + "label": "unimplemented!", "documentation": "Mark unimplemented code." }, { - "label": "unreachable", + "label": "unreachable!", "documentation": "Mark code that should never be executed." + }, + { + "label": "unimplemented!(\"your format message: {}\", ...)", + "documentation": "Mark unimplemented code with a custom message." } ] diff --git a/crates/ui/src/input/popovers/completion_menu.rs b/crates/ui/src/input/popovers/completion_menu.rs index f78e805b..e9aff30a 100644 --- a/crates/ui/src/input/popovers/completion_menu.rs +++ b/crates/ui/src/input/popovers/completion_menu.rs @@ -6,7 +6,7 @@ use gpui::{ HighlightStyle, InteractiveElement as _, IntoElement, ParentElement, Pixels, Point, Render, RenderOnce, SharedString, Styled, StyledText, Subscription, Window, }; -use lsp_types::CompletionItem; +use lsp_types::{CompletionItem, CompletionTextEdit}; const MAX_MENU_WIDTH: Pixels = px(320.); const MAX_MENU_HEIGHT: Pixels = px(480.); @@ -17,7 +17,7 @@ use crate::{ input::{ self, popovers::{popover, render_markdown}, - InputState, + InputState, RopeExt, }, label::Label, list::{List, ListDelegate, ListEvent}, @@ -229,20 +229,38 @@ impl CompletionMenu { } fn select_item(&mut self, item: &CompletionItem, window: &mut Window, cx: &mut Context) { - let range = self.trigger_start_offset.unwrap_or(self.offset)..self.offset; - let insert_text = item - .insert_text - .as_deref() - .unwrap_or(&item.label) - .to_string(); + let offset = self.offset; + let item = item.clone(); + let mut range = self.trigger_start_offset.unwrap_or(self.offset)..self.offset; + let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { state.update_in(cx, |state, window, cx| { state.completion_inserting = true; + + let mut new_text = item.label.clone(); + if let Some(text_edit) = item.text_edit.as_ref() { + match text_edit { + CompletionTextEdit::Edit(edit) => { + new_text = edit.new_text.clone(); + range.start = state.text.position_to_offset(&edit.range.start); + range.end = state.text.position_to_offset(&edit.range.end); + } + CompletionTextEdit::InsertAndReplace(edit) => { + new_text = edit.new_text.clone(); + range.start = state.text.position_to_offset(&edit.replace.start); + range.end = state.text.position_to_offset(&edit.replace.end); + } + } + } else if let Some(insert_text) = item.insert_text.clone() { + new_text = insert_text; + range = offset..offset; + } + state.replace_text_in_range( Some(state.range_to_utf16(&range)), - &insert_text, + &new_text, window, cx, ); @@ -335,9 +353,17 @@ impl CompletionMenu { self.offset = offset; self.open = true; self.list.update(cx, |this, cx| { + let longest_ix = items + .iter() + .enumerate() + .max_by_key(|(_, item)| item.label.len()) + .map(|(ix, _)| ix) + .unwrap_or(0); + this.delegate_mut().query = self.query.clone(); this.delegate_mut().set_items(items); this.set_selected_index(Some(IndexPath::new(0)), window, cx); + this.set_item_to_measure_index(IndexPath::new(longest_ix), window, cx); }); cx.notify(); diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index b9e515b8..0eb3caab 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -2193,6 +2193,27 @@ impl InputState { last_bounds.origin + end_pos + point(px(0.), last_layout.line_height), )) } + + /// Replace text by [`lsp_types::Range`]. + /// + /// See also: [`EntityInputHandler::replace_text_in_range`] + #[allow(unused)] + pub(crate) fn replace_text_in_lsp_range( + &mut self, + lsp_range: &lsp_types::Range, + new_text: &str, + window: &mut Window, + cx: &mut Context, + ) { + let start = self.text.position_to_offset(&lsp_range.start); + let end = self.text.position_to_offset(&lsp_range.end); + self.replace_text_in_range( + Some(self.range_to_utf16(&(start..end))), + new_text, + window, + cx, + ); + } } impl EntityInputHandler for InputState { diff --git a/crates/ui/src/list/cache.rs b/crates/ui/src/list/cache.rs index f723b132..b271cdac 100644 --- a/crates/ui/src/list/cache.rs +++ b/crates/ui/src/list/cache.rs @@ -143,6 +143,10 @@ impl RowsCache { path } + pub(crate) fn measured_size(&self) -> MeasuredEntrySize { + self.measured_size + } + pub(crate) fn prepare_if_needed( &mut self, sections_count: usize, diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs index c66a464d..2edd6b71 100644 --- a/crates/ui/src/list/list.rs +++ b/crates/ui/src/list/list.rs @@ -60,6 +60,7 @@ pub struct List { pub(crate) size: Size, rows_cache: RowsCache, selected_index: Option, + item_to_measure_index: IndexPath, deferred_scroll_to_index: Option<(IndexPath, ScrollStrategy)>, mouse_right_clicked_index: Option, reset_on_cancel: bool, @@ -86,6 +87,7 @@ where query_input: Some(query_input), last_query: None, selected_index: None, + item_to_measure_index: IndexPath::default(), deferred_scroll_to_index: None, mouse_right_clicked_index: None, scroll_handle: VirtualListScrollHandle::new(), @@ -187,6 +189,17 @@ where self.selected_index } + /// Set a specific list item for measurement. + pub fn set_item_to_measure_index( + &mut self, + ix: IndexPath, + _: &mut Window, + cx: &mut Context, + ) { + self.item_to_measure_index = ix; + cx.notify(); + } + fn render_scrollbar(&self, _: &mut Window, _: &mut Context) -> Option { if !self.scrollbar_visible { return None; @@ -450,10 +463,13 @@ where window: &mut Window, cx: &mut Context, ) -> impl IntoElement { + let measured_size = self.rows_cache.measured_size(); + v_flex() .flex_grow() .relative() .h_full() + .min_w(measured_size.item_size.width) .when_some(self.max_height, |this, h| this.max_h(h)) .overflow_hidden() .when(items_count == 0, |this| { @@ -523,7 +539,7 @@ where // Measure the item_height and section header/footer height. let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent); measured_size.item_size = self - .render_list_item(IndexPath::default(), window, cx) + .render_list_item(self.item_to_measure_index, window, cx) .into_any_element() .layout_as_root(available_space, window, cx);