inspector: Add to support auto complete. (#1272)

<img width="1273" height="1138" alt="image"
src="https://github.com/user-attachments/assets/52a474c3-b936-468e-b56d-e3f0a4e3104d"
/>
This commit is contained in:
Jason Lee 2025-09-22 18:36:46 +08:00 committed by GitHub
parent f93e9a9475
commit 62e3f8126a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 253 additions and 120 deletions

View file

@ -259,7 +259,8 @@ impl DiagnosticSet {
self.clear(); self.clear();
} }
pub fn push(&mut self, diagnostic: Diagnostic) { pub fn push(&mut self, diagnostic: impl Into<Diagnostic>) {
let diagnostic = diagnostic.into();
let start = self.text.position_to_offset(&diagnostic.range.start); let start = self.text.position_to_offset(&diagnostic.range.start);
let end = self.text.position_to_offset(&diagnostic.range.end); let end = self.text.position_to_offset(&diagnostic.range.end);
@ -272,12 +273,13 @@ impl DiagnosticSet {
); );
} }
pub fn extend<I>(&mut self, diagnostics: I) pub fn extend<D, I>(&mut self, diagnostics: D)
where where
I: IntoIterator<Item = Diagnostic>, D: IntoIterator<Item = I>,
I: Into<Diagnostic>,
{ {
for diagnostic in diagnostics { for diagnostic in diagnostics {
self.push(diagnostic); self.push(diagnostic.into());
} }
} }

View file

@ -9,7 +9,7 @@ use gpui::{
use lsp_types::{CompletionItem, CompletionTextEdit}; use lsp_types::{CompletionItem, CompletionTextEdit};
const MAX_MENU_WIDTH: Pixels = px(320.); const MAX_MENU_WIDTH: Pixels = px(320.);
const MAX_MENU_HEIGHT: Pixels = px(480.); const MAX_MENU_HEIGHT: Pixels = px(240.);
const POPOVER_GAP: Pixels = px(4.); const POPOVER_GAP: Pixels = px(4.);
use crate::{ use crate::{
@ -171,7 +171,7 @@ impl ListDelegate for ContextMenuDelegate {
/// A context menu for code completions and code actions. /// A context menu for code completions and code actions.
pub struct CompletionMenu { pub struct CompletionMenu {
offset: usize, offset: usize,
state: Entity<InputState>, editor: Entity<InputState>,
list: Entity<List<ContextMenuDelegate>>, list: Entity<List<ContextMenuDelegate>>,
open: bool, open: bool,
bounds: Bounds<Pixels>, bounds: Bounds<Pixels>,
@ -187,7 +187,7 @@ impl CompletionMenu {
/// ///
/// NOTE: This element should not call from InputState::new, unless that will stack overflow. /// NOTE: This element should not call from InputState::new, unless that will stack overflow.
pub(crate) fn new( pub(crate) fn new(
state: Entity<InputState>, editor: Entity<InputState>,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Entity<Self> { ) -> Entity<Self> {
@ -221,7 +221,7 @@ impl CompletionMenu {
Self { Self {
offset: 0, offset: 0,
state, editor,
list, list,
open: false, open: false,
trigger_start_offset: None, trigger_start_offset: None,
@ -237,24 +237,24 @@ impl CompletionMenu {
let item = item.clone(); let item = item.clone();
let mut range = self.trigger_start_offset.unwrap_or(self.offset)..self.offset; let mut range = self.trigger_start_offset.unwrap_or(self.offset)..self.offset;
let state = self.state.clone(); let editor = self.editor.clone();
cx.spawn_in(window, async move |_, cx| { cx.spawn_in(window, async move |_, cx| {
state.update_in(cx, |state, window, cx| { editor.update_in(cx, |editor, window, cx| {
state.completion_inserting = true; editor.completion_inserting = true;
let mut new_text = item.label.clone(); let mut new_text = item.label.clone();
if let Some(text_edit) = item.text_edit.as_ref() { if let Some(text_edit) = item.text_edit.as_ref() {
match text_edit { match text_edit {
CompletionTextEdit::Edit(edit) => { CompletionTextEdit::Edit(edit) => {
new_text = edit.new_text.clone(); new_text = edit.new_text.clone();
range.start = state.text.position_to_offset(&edit.range.start); range.start = editor.text.position_to_offset(&edit.range.start);
range.end = state.text.position_to_offset(&edit.range.end); range.end = editor.text.position_to_offset(&edit.range.end);
} }
CompletionTextEdit::InsertAndReplace(edit) => { CompletionTextEdit::InsertAndReplace(edit) => {
new_text = edit.new_text.clone(); new_text = edit.new_text.clone();
range.start = state.text.position_to_offset(&edit.replace.start); range.start = editor.text.position_to_offset(&edit.replace.start);
range.end = state.text.position_to_offset(&edit.replace.end); range.end = editor.text.position_to_offset(&edit.replace.end);
} }
} }
} else if let Some(insert_text) = item.insert_text.clone() { } else if let Some(insert_text) = item.insert_text.clone() {
@ -262,15 +262,15 @@ impl CompletionMenu {
range = offset..offset; range = offset..offset;
} }
state.replace_text_in_range( editor.replace_text_in_range(
Some(state.range_to_utf16(&range)), Some(editor.range_to_utf16(&range)),
&new_text, &new_text,
window, window,
cx, cx,
); );
state.completion_inserting = false; editor.completion_inserting = false;
// FIXME: Input not get the focus // FIXME: Input not get the focus
state.focus(window, cx); editor.focus(window, cx);
}) })
}) })
.detach(); .detach();
@ -374,18 +374,18 @@ impl CompletionMenu {
} }
fn origin(&self, cx: &App) -> Option<Point<Pixels>> { fn origin(&self, cx: &App) -> Option<Point<Pixels>> {
let state = self.state.read(cx); let editor = self.editor.read(cx);
let Some(last_layout) = state.last_layout.as_ref() else { let Some(last_layout) = editor.last_layout.as_ref() else {
return None; return None;
}; };
let Some(cursor_origin) = last_layout.cursor_bounds.map(|b| b.origin) else { let Some(cursor_origin) = last_layout.cursor_bounds.map(|b| b.origin) else {
return None; return None;
}; };
let scroll_origin = self.state.read(cx).scroll_handle.offset(); let scroll_origin = self.editor.read(cx).scroll_handle.offset();
Some( Some(
scroll_origin + cursor_origin - state.input_bounds.origin scroll_origin + cursor_origin - editor.input_bounds.origin
+ Point::new(-px(4.), last_layout.line_height + px(4.)), + Point::new(-px(4.), last_layout.line_height + px(4.)),
) )
} }
@ -416,8 +416,10 @@ impl Render for CompletionMenu {
.and_then(|item| item.documentation.clone()); .and_then(|item| item.documentation.clone());
let max_width = MAX_MENU_WIDTH.min(window.bounds().size.width - pos.x); let max_width = MAX_MENU_WIDTH.min(window.bounds().size.width - pos.x);
let vertical_layout = pos.x + MAX_MENU_WIDTH + POPOVER_GAP + MAX_MENU_WIDTH + POPOVER_GAP let abs_pos = self.editor.read(cx).input_bounds.origin + pos;
> window.bounds().size.width; let vertical_layout =
abs_pos.x + MAX_MENU_WIDTH + POPOVER_GAP + MAX_MENU_WIDTH + POPOVER_GAP
> window.bounds().size.width;
deferred( deferred(
div() div()

View file

@ -24,7 +24,7 @@ pub trait RopeExt {
/// Return the lines iterator. /// Return the lines iterator.
/// ///
/// Each line is including the `\n` at the end, but not `\n`. /// Each line is including the `\r` at the end, but not `\n`.
fn lines(&self) -> RopeLines; fn lines(&self) -> RopeLines;
/// Check is equal to another rope. /// Check is equal to another rope.

View file

@ -1,20 +1,25 @@
use std::{cell::OnceCell, collections::HashMap, fmt::Write as _, sync::OnceLock}; use std::{cell::OnceCell, collections::HashMap, fmt::Write as _, rc::Rc, sync::OnceLock};
use anyhow::Result;
use gpui::{ use gpui::{
actions, div, inspector_reflection::FunctionReflection, prelude::FluentBuilder, px, AnyElement, actions, div, inspector_reflection::FunctionReflection, prelude::FluentBuilder, px, AnyElement,
App, AppContext, Context, DivInspectorState, Entity, Inspector, InspectorElementId, App, AppContext, Context, DivInspectorState, Entity, Inspector, InspectorElementId,
InteractiveElement as _, IntoElement, KeyBinding, ParentElement as _, Refineable as _, Render, InteractiveElement as _, IntoElement, KeyBinding, ParentElement as _, Refineable as _, Render,
SharedString, StyleRefinement, Styled, Subscription, Window, SharedString, StyleRefinement, Styled, Subscription, Task, Window,
}; };
use lsp_types::{
CompletionItem, CompletionItemKind, CompletionResponse, CompletionTextEdit, Diagnostic,
DiagnosticSeverity, Position, TextEdit,
};
use rope::Rope;
use crate::{ use crate::{
alert::Alert, alert::Alert,
button::{Button, ButtonVariants}, button::{Button, ButtonVariants},
clipboard::Clipboard, clipboard::Clipboard,
description_list::DescriptionList, description_list::DescriptionList,
dropdown::{Dropdown, DropdownState, SearchableVec},
h_flex, h_flex,
input::{InputEvent, InputState, TabSize, TextInput}, input::{CompletionProvider, InputEvent, InputState, RopeExt, TabSize, TextInput},
link::Link, link::Link,
v_flex, ActiveTheme, IconName, Selectable, Sizable, TITLE_BAR_HEIGHT, v_flex, ActiveTheme, IconName, Selectable, Sizable, TITLE_BAR_HEIGHT,
}; };
@ -66,7 +71,6 @@ struct EditorState {
pub struct DivInspector { pub struct DivInspector {
inspector_id: Option<InspectorElementId>, inspector_id: Option<InspectorElementId>,
inspector_state: Option<DivInspectorState>, inspector_state: Option<DivInspectorState>,
rust_dropdown: Entity<DropdownState<SearchableVec<SharedString>>>,
rust_state: EditorState, rust_state: EditorState,
json_state: EditorState, json_state: EditorState,
/// Initial style before any edits /// Initial style before any edits
@ -78,6 +82,8 @@ pub struct DivInspector {
impl DivInspector { impl DivInspector {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let lsp_provider = Rc::new(LspProvider {});
let json_input_state = cx.new(|cx| { let json_input_state = cx.new(|cx| {
InputState::new(window, cx) InputState::new(window, cx)
.code_editor("json") .code_editor("json")
@ -85,30 +91,16 @@ impl DivInspector {
}); });
let rust_input_state = cx.new(|cx| { let rust_input_state = cx.new(|cx| {
InputState::new(window, cx) let mut editor = InputState::new(window, cx)
.code_editor("rust") .code_editor("rust")
.line_number(false) .line_number(false)
.tab_size(TabSize { .tab_size(TabSize {
tab_size: 4, tab_size: 4,
hard_tabs: false, hard_tabs: false,
}) });
});
let rust_dropdown = cx.new(|cx| { editor.lsp.completion_provider = Some(lsp_provider.clone());
DropdownState::new( editor
SearchableVec::new({
let mut methods: Vec<_> = StyleMethods::get()
.table
.iter()
.map(|(_, method)| method.name.into())
.collect();
methods.sort();
methods
}),
None,
window,
cx,
)
}); });
let _subscriptions = vec![ let _subscriptions = vec![
@ -151,7 +143,6 @@ impl DivInspector {
Self { Self {
inspector_id: None, inspector_id: None,
inspector_state: None, inspector_state: None,
rust_dropdown,
rust_state, rust_state,
json_state, json_state,
initial_style: Default::default(), initial_style: Default::default(),
@ -212,8 +203,14 @@ impl DivInspector {
return; return;
} }
let (new_style, err) = rust_to_style(self.unconvertible_style.clone(), code); let (new_style, diagnostics) = rust_to_style(self.unconvertible_style.clone(), code);
self.rust_state.error = err; self.rust_state.state.update(cx, |state, cx| {
if let Some(set) = state.diagnostics_mut() {
set.clear();
set.extend(diagnostics);
}
cx.notify();
});
self.json_state.error = None; self.json_state.error = None;
self.json_state.editing = false; self.json_state.editing = false;
self.update_json_from_style(&new_style, window, cx); self.update_json_from_style(&new_style, window, cx);
@ -272,24 +269,6 @@ impl DivInspector {
rust_style rust_style
}) })
} }
fn rust_add_style(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(method) = self.rust_dropdown.read(cx).selected_value() {
let code = self.rust_state.state.read(cx).value();
let new_code = format!(" .{method}()\n");
let Some(insert_pos) = code.rfind('}') else {
self.rust_state.error = Some("Failed to add method: Could not find `}`".into());
return;
};
let code = format!("{}{}{}", &code[..insert_pos], new_code, &code[insert_pos..]);
self.rust_state.editing = true;
self.rust_state.state.update(cx, |state, cx| {
state.set_value(code, window, cx);
// an edit event will be triggered, the style will be updated there
});
}
}
} }
fn style_to_json(style: &StyleRefinement) -> String { fn style_to_json(style: &StyleRefinement) -> String {
@ -348,40 +327,77 @@ fn style_to_rust(input_style: &StyleRefinement) -> (String, StyleRefinement) {
(code, style) (code, style)
} }
fn rust_to_style( fn rust_to_style(mut style: StyleRefinement, source: &str) -> (StyleRefinement, Vec<Diagnostic>) {
mut style: StyleRefinement, let rope = Rope::from(source);
rust_code: &str, let Some(begin) = source.find("div()").map(|i| i + "div()".len()) else {
) -> (StyleRefinement, Option<SharedString>) { let start_pos = Position::new(0, 0);
// remove line comments let end_pos = rope.offset_to_position(rope.len());
let rust_code = rust_code
.lines()
.map(|line| line.find("//").map_or(line, |i| &line[..i]).trim())
.collect::<Vec<_>>()
.concat();
let Some(begin) = rust_code.find("div()").map(|i| i + "div()".len()) else { return (
return (style, Some("Expected `div()`".into())); style,
vec![Diagnostic {
range: lsp_types::Range::new(start_pos, end_pos),
severity: Some(DiagnosticSeverity::ERROR),
message: "expected `div()`".into(),
..Default::default()
}],
);
}; };
let mut err = String::new(); let mut methods = vec![];
let methods = rust_code[begin..] let mut offset = 0;
.split(&['.', '(', ')', '{', '}']) let mut method_offset = 0;
.map(str::trim) let mut method = String::new();
.filter(|s| !s.is_empty()); for line in rope.lines() {
if line.to_string().trim().starts_with("//") {
offset += line.len() + 1;
continue;
}
for c in line.chars() {
offset += c.len_utf8();
if offset < begin {
continue;
}
if c.is_ascii_alphanumeric() || c == '_' {
method.push(c);
method_offset = offset;
} else {
if !method.is_empty() {
methods.push((method_offset, method.clone()));
}
method.clear();
}
}
// +1 \n
offset += 1;
}
let mut diagnostics = vec![];
let style_methods = StyleMethods::get(); let style_methods = StyleMethods::get();
for method in methods {
match style_methods.map.get(method) { for (offset, method) in methods {
match style_methods.map.get(method.as_str()) {
Some(method_reflection) => style = method_reflection.invoke(style), Some(method_reflection) => style = method_reflection.invoke(style),
None => _ = writeln!(err, "Unknown method: {method}"), None => {
let message = format!("unknown method `{}`", method);
let start = rope.offset_to_position(offset.saturating_sub(method.len()));
let end = rope.offset_to_position(offset);
let diagnostic = lsp_types::Diagnostic {
range: lsp_types::Range::new(start, end),
severity: Some(DiagnosticSeverity::ERROR),
message,
..Default::default()
};
diagnostics.push(diagnostic);
}
} }
} }
let err = if err.is_empty() { (style, diagnostics)
None
} else {
Some(err.trim_end().to_string().into())
};
(style, err)
} }
impl Render for DivInspector { impl Render for DivInspector {
@ -401,31 +417,18 @@ impl Render for DivInspector {
.child( .child(
v_flex() v_flex()
.flex_1() .flex_1()
.h_2_5()
.gap_y_3() .gap_y_3()
.child( .child(
v_flex().gap_y_2().child("Rust Styles").child( h_flex()
h_flex() .justify_between()
.gap_x_2() .gap_x_2()
.child( .child("Rust Styles")
Dropdown::new(&self.rust_dropdown) .child(Button::new("rust-reset").label("Reset").small().on_click(
.icon(IconName::Search) cx.listener(|this, _, window, cx| {
.small() this.reset_style(window, cx);
.cleanable() }),
.flex_1(), )),
)
.child(Button::new("rust-add").label("Add").small().on_click(
cx.listener(|this, _, window, cx| {
this.rust_add_style(window, cx);
}),
))
.child(
Button::new("rust-reset").label("Reset").small().on_click(
cx.listener(|this, _, window, cx| {
this.reset_style(window, cx);
}),
),
),
),
) )
.child( .child(
v_flex() v_flex()
@ -441,8 +444,9 @@ impl Render for DivInspector {
) )
.child( .child(
v_flex() v_flex()
.flex_1()
.gap_y_3() .gap_y_3()
.h_3_5() .h_2_5()
.flex_shrink_0() .flex_shrink_0()
.child( .child(
h_flex() h_flex()
@ -554,3 +558,128 @@ fn render_inspector(
) )
.into_any_element() .into_any_element()
} }
struct LspProvider {}
impl CompletionProvider for LspProvider {
fn completions(
&self,
rope: &rope::Rope,
offset: usize,
_: lsp_types::CompletionContext,
_: &mut Window,
cx: &mut Context<InputState>,
) -> Task<Result<CompletionResponse>> {
let mut left_offset = 0;
while left_offset < 100 {
match rope.char_at(offset.saturating_sub(left_offset)) {
Some('.') => {
break;
}
None => break,
_ => {}
}
left_offset += 1;
}
let start = offset.saturating_sub(left_offset);
let trigger_character = rope.slice(start..offset).to_string();
if !trigger_character.starts_with('.') {
return Task::ready(Ok(CompletionResponse::Array(vec![])));
}
let start_pos = rope.offset_to_position(start);
let end_pos = rope.offset_to_position(offset);
cx.background_spawn(async move {
let styles = StyleMethods::get()
.map
.iter()
.filter_map(|(name, method)| {
let prefix = &trigger_character[1..];
if name.starts_with(&prefix) {
Some(CompletionItem {
label: name.to_string(),
filter_text: Some(prefix.to_string()),
kind: Some(CompletionItemKind::METHOD),
detail: Some("()".to_string()),
documentation: method
.documentation
.as_ref()
.map(|doc| lsp_types::Documentation::String(doc.to_string())),
text_edit: Some(CompletionTextEdit::Edit(TextEdit {
range: lsp_types::Range {
start: start_pos,
end: end_pos,
},
new_text: format!(".{}()", name),
})),
..Default::default()
})
} else {
None
}
})
.collect::<Vec<_>>();
Ok(CompletionResponse::Array(styles))
})
}
fn is_completion_trigger(&self, _: usize, _: &str, _: &mut Context<InputState>) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use gpui::{rems, AbsoluteLength, DefiniteLength, Length};
use indoc::indoc;
use lsp_types::Position;
#[test]
fn test_rust_to_style() {
let (style, diagnostics) = super::rust_to_style(
Default::default(),
indoc! {r#"
fn build() -> Div {
div()
.p_1()
// This is a comment
.mx_2()
}
"#},
);
assert_eq!(diagnostics, vec![]);
assert_eq!(
style.padding.left,
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.25))))
);
assert_eq!(
style.margin.left,
Some(Length::Definite(DefiniteLength::Absolute(
AbsoluteLength::Rems(rems(0.5))
)))
);
let (_, diagnostics) = super::rust_to_style(
Default::default(),
indoc! {r#"
fn build() -> Div {
div()
.p_1()
// This is a comment
.unknown_method
.bad_method()
}
"#},
);
assert_eq!(diagnostics.len(), 2);
assert_eq!(diagnostics[0].message, "unknown method `unknown_method`");
assert_eq!(diagnostics[0].range.start, Position::new(4, 9));
assert_eq!(diagnostics[0].range.end, Position::new(4, 23));
assert_eq!(diagnostics[1].message, "unknown method `bad_method`");
assert_eq!(diagnostics[1].range.start, Position::new(5, 9));
assert_eq!(diagnostics[1].range.end, Position::new(5, 19));
}
}