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();
}
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 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
I: IntoIterator<Item = Diagnostic>,
D: IntoIterator<Item = I>,
I: Into<Diagnostic>,
{
for diagnostic in diagnostics {
self.push(diagnostic);
self.push(diagnostic.into());
}
}

View file

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

View file

@ -24,7 +24,7 @@ pub trait RopeExt {
/// 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;
/// 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::{
actions, div, inspector_reflection::FunctionReflection, prelude::FluentBuilder, px, AnyElement,
App, AppContext, Context, DivInspectorState, Entity, Inspector, InspectorElementId,
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::{
alert::Alert,
button::{Button, ButtonVariants},
clipboard::Clipboard,
description_list::DescriptionList,
dropdown::{Dropdown, DropdownState, SearchableVec},
h_flex,
input::{InputEvent, InputState, TabSize, TextInput},
input::{CompletionProvider, InputEvent, InputState, RopeExt, TabSize, TextInput},
link::Link,
v_flex, ActiveTheme, IconName, Selectable, Sizable, TITLE_BAR_HEIGHT,
};
@ -66,7 +71,6 @@ struct EditorState {
pub struct DivInspector {
inspector_id: Option<InspectorElementId>,
inspector_state: Option<DivInspectorState>,
rust_dropdown: Entity<DropdownState<SearchableVec<SharedString>>>,
rust_state: EditorState,
json_state: EditorState,
/// Initial style before any edits
@ -78,6 +82,8 @@ pub struct DivInspector {
impl DivInspector {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let lsp_provider = Rc::new(LspProvider {});
let json_input_state = cx.new(|cx| {
InputState::new(window, cx)
.code_editor("json")
@ -85,30 +91,16 @@ impl DivInspector {
});
let rust_input_state = cx.new(|cx| {
InputState::new(window, cx)
let mut editor = InputState::new(window, cx)
.code_editor("rust")
.line_number(false)
.tab_size(TabSize {
tab_size: 4,
hard_tabs: false,
})
});
});
let rust_dropdown = cx.new(|cx| {
DropdownState::new(
SearchableVec::new({
let mut methods: Vec<_> = StyleMethods::get()
.table
.iter()
.map(|(_, method)| method.name.into())
.collect();
methods.sort();
methods
}),
None,
window,
cx,
)
editor.lsp.completion_provider = Some(lsp_provider.clone());
editor
});
let _subscriptions = vec![
@ -151,7 +143,6 @@ impl DivInspector {
Self {
inspector_id: None,
inspector_state: None,
rust_dropdown,
rust_state,
json_state,
initial_style: Default::default(),
@ -212,8 +203,14 @@ impl DivInspector {
return;
}
let (new_style, err) = rust_to_style(self.unconvertible_style.clone(), code);
self.rust_state.error = err;
let (new_style, diagnostics) = rust_to_style(self.unconvertible_style.clone(), code);
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.editing = false;
self.update_json_from_style(&new_style, window, cx);
@ -272,24 +269,6 @@ impl DivInspector {
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 {
@ -348,40 +327,77 @@ fn style_to_rust(input_style: &StyleRefinement) -> (String, StyleRefinement) {
(code, style)
}
fn rust_to_style(
mut style: StyleRefinement,
rust_code: &str,
) -> (StyleRefinement, Option<SharedString>) {
// remove line comments
let rust_code = rust_code
.lines()
.map(|line| line.find("//").map_or(line, |i| &line[..i]).trim())
.collect::<Vec<_>>()
.concat();
fn rust_to_style(mut style: StyleRefinement, source: &str) -> (StyleRefinement, Vec<Diagnostic>) {
let rope = Rope::from(source);
let Some(begin) = source.find("div()").map(|i| i + "div()".len()) else {
let start_pos = Position::new(0, 0);
let end_pos = rope.offset_to_position(rope.len());
let Some(begin) = rust_code.find("div()").map(|i| i + "div()".len()) else {
return (style, Some("Expected `div()`".into()));
return (
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 methods = rust_code[begin..]
.split(&['.', '(', ')', '{', '}'])
.map(str::trim)
.filter(|s| !s.is_empty());
let mut methods = vec![];
let mut offset = 0;
let mut method_offset = 0;
let mut method = String::new();
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();
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),
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() {
None
} else {
Some(err.trim_end().to_string().into())
};
(style, err)
(style, diagnostics)
}
impl Render for DivInspector {
@ -401,31 +417,18 @@ impl Render for DivInspector {
.child(
v_flex()
.flex_1()
.h_2_5()
.gap_y_3()
.child(
v_flex().gap_y_2().child("Rust Styles").child(
h_flex()
.gap_x_2()
.child(
Dropdown::new(&self.rust_dropdown)
.icon(IconName::Search)
.small()
.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);
}),
),
),
),
h_flex()
.justify_between()
.gap_x_2()
.child("Rust Styles")
.child(Button::new("rust-reset").label("Reset").small().on_click(
cx.listener(|this, _, window, cx| {
this.reset_style(window, cx);
}),
)),
)
.child(
v_flex()
@ -441,8 +444,9 @@ impl Render for DivInspector {
)
.child(
v_flex()
.flex_1()
.gap_y_3()
.h_3_5()
.h_2_5()
.flex_shrink_0()
.child(
h_flex()
@ -554,3 +558,128 @@ fn render_inspector(
)
.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));
}
}