complition
This commit is contained in:
+277
-3
@@ -10,11 +10,16 @@
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Number;
|
||||
|
||||
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
|
||||
use crate::editor::{Language, Workspace};
|
||||
use crate::keymap::{KeyBinding, KeyMap};
|
||||
use crate::lsp::{LspConfig, LspManager, WatcherConfig, document_uri};
|
||||
use crate::panel::{PanelHandle, PanelState};
|
||||
|
||||
thread_local! {
|
||||
@@ -86,6 +91,8 @@ pub struct AppApi {
|
||||
right_panel: PanelHandle,
|
||||
#[lua(skip)]
|
||||
bottom_panel: PanelHandle,
|
||||
#[lua(skip)]
|
||||
lsp: Arc<LspManager>,
|
||||
}
|
||||
|
||||
/// API одной панели окна.
|
||||
@@ -145,6 +152,7 @@ impl AppApi {
|
||||
left_panel: Rc::new(RefCell::new(PanelState::new(LEFT_PANEL_WIDTH))),
|
||||
right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))),
|
||||
bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))),
|
||||
lsp: Arc::new(LspManager::new()),
|
||||
};
|
||||
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
|
||||
api
|
||||
@@ -181,6 +189,12 @@ impl AppApi {
|
||||
self.bottom_panel.clone()
|
||||
}
|
||||
|
||||
/// Менеджер LSP-серверов (для вида и тестов).
|
||||
#[lua(skip)]
|
||||
pub fn lsp_handle(&self) -> Arc<LspManager> {
|
||||
self.lsp.clone()
|
||||
}
|
||||
|
||||
/// Все открытые вкладки в порядке слева направо.
|
||||
#[allow(dead_code)]
|
||||
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
||||
@@ -236,10 +250,16 @@ impl AppApi {
|
||||
#[lua(infallible)]
|
||||
pub fn close_tab(&self, index: usize) -> bool {
|
||||
let mut workspace = self.workspace.borrow_mut();
|
||||
if index >= workspace.documents().len() {
|
||||
let Some(document) = workspace.document(index) else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let uri = document_uri(&document.name);
|
||||
workspace.close(index);
|
||||
drop(workspace);
|
||||
|
||||
if let Some(uri) = uri {
|
||||
self.lsp.close_document(&uri);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -254,7 +274,10 @@ impl AppApi {
|
||||
let doc = workspace.active();
|
||||
(doc.name.clone(), doc.content.clone())
|
||||
};
|
||||
std::fs::write(&path, content)?;
|
||||
std::fs::write(&path, &content)?;
|
||||
if let Some(uri) = document_uri(&path) {
|
||||
self.lsp.did_save(&uri, &content);
|
||||
}
|
||||
self.workspace.borrow_mut().mark_active_saved();
|
||||
Ok(())
|
||||
}
|
||||
@@ -311,6 +334,34 @@ impl AppApi {
|
||||
workspace.active_index()
|
||||
}
|
||||
|
||||
/// Открывает файл с диска на редактирование (Rust-версия `app:open`).
|
||||
///
|
||||
/// Если файла нет, создаётся пустая вкладка с таким именем — как в vim.
|
||||
/// Язык подсветки определяется по расширению, если для него есть грамматика.
|
||||
#[lua(skip)]
|
||||
pub fn open(&self, path: impl AsRef<Path>) -> std::io::Result<usize> {
|
||||
let path = path.as_ref();
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(content) => content,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
let name = path.to_string_lossy().into_owned();
|
||||
let language = Language::from_path(&name);
|
||||
let mut workspace = self.workspace.borrow_mut();
|
||||
Ok(workspace.open_file(name, content, language))
|
||||
}
|
||||
|
||||
/// Lua-версия [`AppApi::open`]: `app:open("main.go")`.
|
||||
///
|
||||
/// Возвращает номер вкладки (с нуля). Ошибка чтения становится ошибкой Lua.
|
||||
#[lua(name = "open")]
|
||||
fn lua_open(&self, path: String) -> mlua::Result<usize> {
|
||||
self.open(&path)
|
||||
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
|
||||
}
|
||||
|
||||
/// Делает вкладку активной по её номеру в списке [`AppApi::tabs`].
|
||||
///
|
||||
/// Возвращает `false`, если вкладки с таким номером нет.
|
||||
@@ -423,4 +474,227 @@ impl AppApi {
|
||||
pub fn is_maximized(&self) -> bool {
|
||||
WINDOW_MAXIMIZED.with(Cell::get)
|
||||
}
|
||||
|
||||
/// Запускает LSP-сервер по описанию (Rust-версия Lua-метода).
|
||||
#[lua(skip)]
|
||||
pub fn run_lsp(&self, config: LspConfig) -> Result<(), String> {
|
||||
self.lsp.start(config)
|
||||
}
|
||||
|
||||
/// Запускает LSP-сервер.
|
||||
///
|
||||
/// Пример из Lua:
|
||||
///
|
||||
/// ```lua
|
||||
/// app:run_lsp({
|
||||
/// name = "gopls",
|
||||
/// cmd = "/usr/local/bin/gopls",
|
||||
/// args = { "serve" },
|
||||
/// env = { GOFLAGS = "-mod=mod" },
|
||||
/// languages = { "go" },
|
||||
/// watcher = "**/*.go",
|
||||
/// initialization_options = { usePlaceholders = true },
|
||||
/// })
|
||||
/// ```
|
||||
///
|
||||
/// Возвращает `true`, если сервер запущен. Ошибка запуска или таймаут
|
||||
/// `initialize` превращается в ошибку Lua.
|
||||
#[lua(name = "run_lsp")]
|
||||
fn lua_run_lsp(&self, config: mlua::Table) -> mlua::Result<bool> {
|
||||
let config = parse_lsp_config(&config)?;
|
||||
self.run_lsp(config)
|
||||
.map(|()| true)
|
||||
.map_err(mlua::Error::RuntimeError)
|
||||
}
|
||||
|
||||
/// Имена всех запущенных LSP-серверов.
|
||||
#[lua(infallible)]
|
||||
pub fn lsp_servers(&self) -> Vec<String> {
|
||||
self.lsp.server_names()
|
||||
}
|
||||
|
||||
/// Останавливает LSP-сервер по имени.
|
||||
///
|
||||
/// Возвращает `false`, если сервера с таким именем не было.
|
||||
#[lua(infallible)]
|
||||
pub fn stop_lsp(&self, name: String) -> bool {
|
||||
self.lsp.stop(&name)
|
||||
}
|
||||
|
||||
/// Перезапускает LSP-сервер с прежними настройками.
|
||||
///
|
||||
/// Возвращает `false`, если сервера не было или новый процесс не поднялся.
|
||||
#[lua(infallible)]
|
||||
pub fn restart_lsp(&self, name: String) -> bool {
|
||||
self.lsp.restart(&name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Разбирает Lua-таблицу, переданную в `app:run_lsp`.
|
||||
fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
|
||||
let name: String = table
|
||||
.get("name")
|
||||
.map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый name".to_owned()))?;
|
||||
|
||||
let command = ["cmd", "command", "path", "binary"]
|
||||
.iter()
|
||||
.find_map(|key| table.get::<String>(*key).ok())
|
||||
.ok_or_else(|| {
|
||||
mlua::Error::RuntimeError(
|
||||
"в run_lsp нужен путь к серверу: cmd, command, path или binary".to_owned(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let args = match table.get::<mlua::Table>("args") {
|
||||
Ok(args) => args
|
||||
.sequence_values::<String>()
|
||||
.collect::<mlua::Result<Vec<_>>>()?,
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
let env = match table.get::<mlua::Table>("env") {
|
||||
Ok(env) => env
|
||||
.pairs::<String, String>()
|
||||
.collect::<mlua::Result<Vec<_>>>()?,
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
let root = table
|
||||
.get::<String>("root")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
let root = if root.is_absolute() {
|
||||
root
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(root)
|
||||
};
|
||||
let root = std::fs::canonicalize(&root).unwrap_or(root);
|
||||
|
||||
let languages = match table.get::<mlua::Value>("languages") {
|
||||
Ok(value) => lua_strings(value)?,
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
let initialization_options = ["initialization_options", "init_options"]
|
||||
.iter()
|
||||
.find_map(|key| match table.get::<mlua::Value>(*key) {
|
||||
Ok(mlua::Value::Nil) | Err(_) => None,
|
||||
Ok(value) => Some(lua_to_json(value)),
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok(LspConfig {
|
||||
name,
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
root,
|
||||
languages,
|
||||
watcher: parse_watcher(table)?,
|
||||
initialization_options,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
|
||||
let value = ["watcher", "file_watcher", "watch"].iter().find_map(|key| {
|
||||
match table.get::<mlua::Value>(*key) {
|
||||
Ok(mlua::Value::Nil) | Err(_) => None,
|
||||
Ok(value) => Some(value),
|
||||
}
|
||||
});
|
||||
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match value {
|
||||
mlua::Value::String(pattern) => Ok(Some(WatcherConfig {
|
||||
patterns: vec![pattern.to_string_lossy()],
|
||||
ignore: Vec::new(),
|
||||
})),
|
||||
mlua::Value::Boolean(true) => Ok(Some(WatcherConfig {
|
||||
patterns: vec!["**/*".to_owned()],
|
||||
ignore: Vec::new(),
|
||||
})),
|
||||
mlua::Value::Boolean(false) | mlua::Value::Nil => Ok(None),
|
||||
mlua::Value::Table(config) => {
|
||||
let patterns = ["pattern", "patterns"]
|
||||
.iter()
|
||||
.find_map(|key| match config.get::<mlua::Value>(*key) {
|
||||
Ok(mlua::Value::Nil) | Err(_) => None,
|
||||
Ok(value) => Some(lua_strings(value)),
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let ignore = ["ignore", "ignored"]
|
||||
.iter()
|
||||
.find_map(|key| match config.get::<mlua::Value>(*key) {
|
||||
Ok(mlua::Value::Nil) | Err(_) => None,
|
||||
Ok(value) => Some(lua_strings(value)),
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
if patterns.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(WatcherConfig { patterns, ignore }))
|
||||
}
|
||||
}
|
||||
_ => Err(mlua::Error::RuntimeError(
|
||||
"watcher должен быть строкой, таблицей или boolean".to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn lua_strings(value: mlua::Value) -> mlua::Result<Vec<String>> {
|
||||
match value {
|
||||
mlua::Value::String(value) => Ok(vec![value.to_string_lossy()]),
|
||||
mlua::Value::Table(table) => table
|
||||
.sequence_values::<String>()
|
||||
.collect::<mlua::Result<Vec<_>>>(),
|
||||
mlua::Value::Nil => Ok(Vec::new()),
|
||||
_ => Err(mlua::Error::RuntimeError(
|
||||
"ожидалась строка или массив строк".to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn lua_to_json(value: mlua::Value) -> mlua::Result<serde_json::Value> {
|
||||
match value {
|
||||
mlua::Value::Nil => Ok(serde_json::Value::Null),
|
||||
mlua::Value::Boolean(value) => Ok(serde_json::Value::Bool(value)),
|
||||
mlua::Value::Integer(value) => Ok(serde_json::Value::Number(value.into())),
|
||||
mlua::Value::Number(value) => Ok(Number::from_f64(value)
|
||||
.map(serde_json::Value::Number)
|
||||
.unwrap_or(serde_json::Value::Null)),
|
||||
mlua::Value::String(value) => Ok(serde_json::Value::String(value.to_string_lossy())),
|
||||
mlua::Value::Table(table) => {
|
||||
let length = table.raw_len();
|
||||
if length > 0 {
|
||||
let mut items = Vec::with_capacity(length);
|
||||
for value in table.sequence_values::<mlua::Value>() {
|
||||
items.push(lua_to_json(value?)?);
|
||||
}
|
||||
Ok(serde_json::Value::Array(items))
|
||||
} else {
|
||||
let mut map = serde_json::Map::new();
|
||||
for pair in table.pairs::<mlua::Value, mlua::Value>() {
|
||||
let (key, value) = pair?;
|
||||
let key = match key {
|
||||
mlua::Value::String(key) => key.to_string_lossy(),
|
||||
mlua::Value::Integer(key) => key.to_string(),
|
||||
mlua::Value::Number(key) => key.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
map.insert(key, lua_to_json(value)?);
|
||||
}
|
||||
Ok(serde_json::Value::Object(map))
|
||||
}
|
||||
}
|
||||
_ => Ok(serde_json::Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
+923
-10
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,598 @@
|
||||
//! Элементы интерфейса автодополнения.
|
||||
//!
|
||||
//! Здесь живёт всплывающий список, который привязывается к карете редактора
|
||||
//! через свежую геометрию prepaint. Логика запросов к LSP и состояние выбора
|
||||
//! находятся в [`MyApp`](crate::app::MyApp): этот модуль занимается только
|
||||
//! отображением и небольшими преобразованиями элементов `CompletionItem`.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui_kit::component::input::{EditorState, PrepaintCaretGeometry};
|
||||
use gpui_kit::{
|
||||
AnyElement, App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId, Hsla,
|
||||
InspectorElementId, IntoElement, LayoutId, Pixels, Point, Size, Style, WeakEntity, Window, px,
|
||||
};
|
||||
use lsp_types::{
|
||||
CompletionItem, CompletionItemKind, CompletionResponse, CompletionTextEdit, Documentation,
|
||||
InsertTextFormat,
|
||||
};
|
||||
|
||||
use crate::lsp::{offset_to_position, position_to_offset};
|
||||
|
||||
/// Функция, которая строит содержимое поповера в его `prepaint`.
|
||||
///
|
||||
/// Второй аргумент — признак того, что поповер будет нарисован над строкой
|
||||
/// с каретой: в этом случае содержимое выравнивается по нижнему краю.
|
||||
type PopupBuilder = Box<dyn Fn(Point<Pixels>, bool, &mut Window, &mut App) -> AnyElement>;
|
||||
|
||||
/// Отступ от краёв окна, в который поповер не заходит.
|
||||
pub(crate) const POPUP_MARGIN: f32 = 8.;
|
||||
|
||||
/// Зазор между каретой и краем поповера.
|
||||
const POPUP_GAP: f32 = 4.;
|
||||
|
||||
/// Всплывающий список, привязанный к карете редактора.
|
||||
///
|
||||
/// Как и встроенные поповеры gpui-component, элемент разрешает позицию в
|
||||
/// `prepaint`, то есть по геометрии текущего кадра, а не предыдущего.
|
||||
///
|
||||
/// Если снизу от кареты нет места, поповер раскрывается вверх; если он не
|
||||
/// помещается справа, он сдвигается влево, оставаясь внутри окна.
|
||||
pub struct CaretPopup {
|
||||
editor: WeakEntity<EditorState>,
|
||||
build: PopupBuilder,
|
||||
content: Option<AnyElement>,
|
||||
}
|
||||
|
||||
impl CaretPopup {
|
||||
pub fn new(
|
||||
editor: WeakEntity<EditorState>,
|
||||
build: impl Fn(Point<Pixels>, bool, &mut Window, &mut App) -> AnyElement + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
editor,
|
||||
build: Box::new(build),
|
||||
content: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for CaretPopup {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let layout = window.request_layout(Style::default(), [], cx);
|
||||
(layout, ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(editor) = self.editor.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(geometry) = editor.read(cx).prepaint_caret_geometry() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let anchor = caret_anchor(geometry);
|
||||
let viewport = window.viewport_size();
|
||||
let available = Size {
|
||||
width: AvailableSpace::Definite(viewport.width),
|
||||
height: AvailableSpace::Definite(viewport.height),
|
||||
};
|
||||
|
||||
// Сначала меряем содержимое, чтобы решить, помещается ли оно снизу.
|
||||
let mut content = (self.build)(anchor, false, window, cx);
|
||||
let size = content.layout_as_root(available, window, cx);
|
||||
let placement = popup_origin(anchor, caret_top(geometry), size, viewport);
|
||||
|
||||
// Над строкой поповеры выравниваются по нижнему краю. Это свойство
|
||||
// их общего контейнера, поэтому для верхнего варианта собираем его
|
||||
// заново с другим выравниванием.
|
||||
if placement.above {
|
||||
content = (self.build)(anchor, true, window, cx);
|
||||
content.layout_as_root(available, window, cx);
|
||||
}
|
||||
|
||||
content.prepaint_at(placement.origin, window, cx);
|
||||
self.content = Some(content);
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
_prepaint: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if let Some(content) = self.content.as_mut() {
|
||||
content.paint(window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for CaretPopup {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn caret_anchor(geometry: PrepaintCaretGeometry) -> Point<Pixels> {
|
||||
Point::new(
|
||||
geometry.cursor_bounds().origin.x - px(POPUP_GAP),
|
||||
caret_top(geometry) + geometry.line_height() + px(POPUP_GAP),
|
||||
)
|
||||
}
|
||||
|
||||
/// Верхняя граница строки с каретой в координатах окна.
|
||||
fn caret_top(geometry: PrepaintCaretGeometry) -> Pixels {
|
||||
geometry.cursor_bounds().origin.y + geometry.scroll_offset().y
|
||||
}
|
||||
|
||||
/// Положение поповера и то, нарисован ли он над строкой с каретой.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct PopupPlacement {
|
||||
origin: Point<Pixels>,
|
||||
above: bool,
|
||||
}
|
||||
|
||||
/// Выбирает положение поповера так, чтобы он целиком помещался в окне.
|
||||
///
|
||||
/// По умолчанию поповер стоит под каретой. Если снизу места мало, он
|
||||
/// переносится над строкой; если не помещается справа, сдвигается влево.
|
||||
fn popup_origin(
|
||||
anchor: Point<Pixels>,
|
||||
caret_top: Pixels,
|
||||
size: Size<Pixels>,
|
||||
viewport: Size<Pixels>,
|
||||
) -> PopupPlacement {
|
||||
let margin = px(POPUP_MARGIN);
|
||||
let mut origin = anchor;
|
||||
let mut above = false;
|
||||
|
||||
if origin.y + size.height > viewport.height - margin {
|
||||
above = true;
|
||||
let above_y = caret_top - size.height - px(POPUP_GAP);
|
||||
origin.y = if above_y >= margin {
|
||||
above_y
|
||||
} else {
|
||||
(viewport.height - margin - size.height).max(margin)
|
||||
};
|
||||
}
|
||||
|
||||
origin.x = origin.x.max(margin);
|
||||
if origin.x + size.width > viewport.width - margin {
|
||||
origin.x = (viewport.width - margin - size.width).max(margin);
|
||||
}
|
||||
|
||||
PopupPlacement { origin, above }
|
||||
}
|
||||
|
||||
/// Начало слова под каретой (для замены префикса при вставке).
|
||||
pub fn word_start(text: &str, cursor: usize) -> usize {
|
||||
let cursor = clamp_offset(text, cursor);
|
||||
let mut start = cursor;
|
||||
for (index, ch) in text[..cursor].char_indices().rev() {
|
||||
if ch.is_alphanumeric() || ch == '_' {
|
||||
start = index;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
/// Разбирает ответ `textDocument/completion`.
|
||||
pub fn parse_completion_response(value: serde_json::Value) -> Vec<CompletionItem> {
|
||||
if value.is_null() {
|
||||
return Vec::new();
|
||||
}
|
||||
serde_json::from_value::<CompletionResponse>(value)
|
||||
.map(|response| match response {
|
||||
CompletionResponse::Array(items) => items,
|
||||
CompletionResponse::List(list) => list.items,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Текст документации элемента в виде Markdown.
|
||||
pub fn documentation_text(documentation: &Documentation) -> String {
|
||||
match documentation {
|
||||
Documentation::String(text) => text.clone(),
|
||||
Documentation::MarkupContent(content) => content.value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Запасной текст, если сервер не прислал документацию.
|
||||
pub fn fallback_documentation(item: &CompletionItem) -> String {
|
||||
let kind = completion_kind_name(item.kind);
|
||||
match item.detail.as_ref() {
|
||||
Some(detail) if !detail.is_empty() => format!("`{detail}`\n\n{kind}"),
|
||||
_ => format!("**{}**\n\n{kind}", item.label),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ключ кэша документации: разные элементы с одинаковым именем в разных
|
||||
/// контекстах (например, `len` у разных типов) не должны перепутаться.
|
||||
pub fn item_key(item: &CompletionItem) -> String {
|
||||
format!(
|
||||
"{}\u{1}{}\u{1}{}",
|
||||
item.label,
|
||||
item.detail.as_deref().unwrap_or_default(),
|
||||
item.kind
|
||||
.map(|kind| format!("{kind:?}"))
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Человекочитаемый тип элемента.
|
||||
pub fn completion_kind_name(kind: Option<CompletionItemKind>) -> &'static str {
|
||||
match kind {
|
||||
Some(CompletionItemKind::METHOD) => "метод",
|
||||
Some(CompletionItemKind::FUNCTION) => "функция",
|
||||
Some(CompletionItemKind::CONSTRUCTOR) => "конструктор",
|
||||
Some(CompletionItemKind::FIELD) => "поле",
|
||||
Some(CompletionItemKind::VARIABLE) => "переменная",
|
||||
Some(CompletionItemKind::CONSTANT) => "константа",
|
||||
Some(CompletionItemKind::STRUCT) => "структура",
|
||||
Some(CompletionItemKind::CLASS) => "класс",
|
||||
Some(CompletionItemKind::INTERFACE) => "интерфейс",
|
||||
Some(CompletionItemKind::ENUM) => "перечисление",
|
||||
Some(CompletionItemKind::ENUM_MEMBER) => "элемент перечисления",
|
||||
Some(CompletionItemKind::MODULE) => "модуль",
|
||||
Some(CompletionItemKind::PROPERTY) => "свойство",
|
||||
Some(CompletionItemKind::KEYWORD) => "ключевое слово",
|
||||
Some(CompletionItemKind::SNIPPET) => "шаблон",
|
||||
Some(CompletionItemKind::TYPE_PARAMETER) => "параметр типа",
|
||||
Some(CompletionItemKind::OPERATOR) => "оператор",
|
||||
Some(CompletionItemKind::EVENT) => "событие",
|
||||
Some(CompletionItemKind::FILE) => "файл",
|
||||
Some(CompletionItemKind::FOLDER) => "каталог",
|
||||
Some(CompletionItemKind::TEXT) => "текст",
|
||||
Some(_) => "значение",
|
||||
None => "элемент",
|
||||
}
|
||||
}
|
||||
|
||||
/// Иконка для типа элемента автодополнения.
|
||||
///
|
||||
/// Используется полный каталог иконок gpui-kit-assets, а не компактный набор
|
||||
/// компонента: в нём есть `square-function`, `variable`, `braces` и другие
|
||||
/// привычные символы IDE.
|
||||
pub fn completion_icon(kind: Option<CompletionItemKind>) -> gpui_kit::assets::IconName {
|
||||
use gpui_kit::assets::IconName as Icon;
|
||||
|
||||
match kind {
|
||||
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => Icon::SquareFunction,
|
||||
Some(
|
||||
CompletionItemKind::STRUCT
|
||||
| CompletionItemKind::CLASS
|
||||
| CompletionItemKind::INTERFACE
|
||||
| CompletionItemKind::ENUM
|
||||
| CompletionItemKind::CONSTRUCTOR,
|
||||
) => Icon::Box,
|
||||
Some(
|
||||
CompletionItemKind::VARIABLE
|
||||
| CompletionItemKind::FIELD
|
||||
| CompletionItemKind::PROPERTY
|
||||
| CompletionItemKind::CONSTANT
|
||||
| CompletionItemKind::ENUM_MEMBER,
|
||||
) => Icon::Variable,
|
||||
Some(CompletionItemKind::KEYWORD) => Icon::Type,
|
||||
Some(CompletionItemKind::MODULE) => Icon::Braces,
|
||||
Some(CompletionItemKind::FILE) => Icon::FileText,
|
||||
Some(CompletionItemKind::FOLDER) => Icon::Folder,
|
||||
Some(CompletionItemKind::SNIPPET) => Icon::Component,
|
||||
Some(CompletionItemKind::EVENT) => Icon::Activity,
|
||||
Some(CompletionItemKind::TYPE_PARAMETER) => Icon::TypeOutline,
|
||||
Some(CompletionItemKind::OPERATOR) => Icon::CaseSensitive,
|
||||
Some(_) => Icon::Circle,
|
||||
None => Icon::Sparkles,
|
||||
}
|
||||
}
|
||||
|
||||
/// Цвет иконки для типа элемента.
|
||||
pub fn completion_icon_color(
|
||||
kind: Option<CompletionItemKind>,
|
||||
theme: &gpui_kit::component::Theme,
|
||||
) -> Hsla {
|
||||
match kind {
|
||||
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => theme.blue,
|
||||
Some(
|
||||
CompletionItemKind::STRUCT
|
||||
| CompletionItemKind::CLASS
|
||||
| CompletionItemKind::INTERFACE
|
||||
| CompletionItemKind::ENUM,
|
||||
) => theme.yellow,
|
||||
Some(
|
||||
CompletionItemKind::VARIABLE
|
||||
| CompletionItemKind::FIELD
|
||||
| CompletionItemKind::PROPERTY
|
||||
| CompletionItemKind::CONSTANT
|
||||
| CompletionItemKind::ENUM_MEMBER,
|
||||
) => theme.green,
|
||||
Some(CompletionItemKind::KEYWORD) => theme.magenta,
|
||||
Some(CompletionItemKind::MODULE) => theme.cyan,
|
||||
_ => theme.muted_foreground,
|
||||
}
|
||||
}
|
||||
|
||||
/// Диапазон подсветки совпадения в подписи элемента.
|
||||
pub fn highlight_range(query: &str, label: &str) -> Option<Range<usize>> {
|
||||
if query.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let query = query.to_lowercase();
|
||||
let lowercase = label.to_lowercase();
|
||||
let start = lowercase.find(&query)?;
|
||||
|
||||
// Нижний регистр иногда меняет длину строки, поэтому диапазон
|
||||
// проверяем по исходной метке и не отдаём разрезанный символ.
|
||||
let end = (start + query.len()).min(label.len());
|
||||
if start < end && label.is_char_boundary(start) && label.is_char_boundary(end) {
|
||||
Some(start..end)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Подготавливает замену текста при выборе элемента.
|
||||
///
|
||||
/// Возвращает байтовый диапазон в документе и вставляемый текст. Если сервер
|
||||
/// прислал `textEdit`, используются его границы; иначе заменяется слово под
|
||||
/// каретой. Сниппеты раскрываются в плоский текст: табов-заглушек в нашем
|
||||
/// редакторе пока нет.
|
||||
pub fn completion_edit(
|
||||
item: &CompletionItem,
|
||||
text: &str,
|
||||
start: usize,
|
||||
cursor: usize,
|
||||
) -> (Range<usize>, String) {
|
||||
let mut range = clamp_offset(text, start)..clamp_offset(text, cursor);
|
||||
let mut new_text = item
|
||||
.insert_text
|
||||
.clone()
|
||||
.unwrap_or_else(|| item.label.clone());
|
||||
|
||||
if let Some(edit) = &item.text_edit {
|
||||
match edit {
|
||||
CompletionTextEdit::Edit(edit) => {
|
||||
range = position_to_offset(text, edit.range.start)
|
||||
..position_to_offset(text, edit.range.end);
|
||||
new_text.clone_from(&edit.new_text);
|
||||
}
|
||||
CompletionTextEdit::InsertAndReplace(edit) => {
|
||||
range = position_to_offset(text, edit.replace.start)
|
||||
..position_to_offset(text, edit.replace.end);
|
||||
new_text.clone_from(&edit.new_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if item.insert_text_format == Some(InsertTextFormat::SNIPPET) {
|
||||
new_text = strip_snippet(&new_text);
|
||||
}
|
||||
|
||||
let start = clamp_offset(text, range.start).min(text.len());
|
||||
let end = clamp_offset(text, range.end).max(start).min(text.len());
|
||||
(start..end, new_text)
|
||||
}
|
||||
|
||||
/// Разворачивает LSP-сниппет в обычный текст.
|
||||
pub fn strip_snippet(snippet: &str) -> String {
|
||||
let chars: Vec<char> = snippet.chars().collect();
|
||||
let mut output = String::new();
|
||||
let mut index = 0;
|
||||
|
||||
while index < chars.len() {
|
||||
if chars[index] == '$' {
|
||||
if index + 1 < chars.len() && chars[index + 1] == '{' {
|
||||
if let Some(end) = (index + 2..chars.len()).find(|&pos| chars[pos] == '}') {
|
||||
let inner: String = chars[index + 2..end].iter().collect();
|
||||
if let Some((_, default)) = inner.split_once(':') {
|
||||
output.push_str(default);
|
||||
}
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
} else if index + 1 < chars.len() && chars[index + 1].is_ascii_digit() {
|
||||
index += 2;
|
||||
continue;
|
||||
} else if index + 1 < chars.len() && chars[index + 1] == '$' {
|
||||
output.push('$');
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
output.push(chars[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn clamp_offset(text: &str, offset: usize) -> usize {
|
||||
let mut offset = offset.min(text.len());
|
||||
while offset > 0 && !text.is_char_boundary(offset) {
|
||||
offset -= 1;
|
||||
}
|
||||
offset
|
||||
}
|
||||
|
||||
/// Позиция LSP для диапазона замены (удобно для `apply_lsp_edits`).
|
||||
pub fn range_for_lsp(text: &str, range: &Range<usize>) -> lsp_types::Range {
|
||||
lsp_types::Range {
|
||||
start: offset_to_position(text, range.start),
|
||||
end: offset_to_position(text, range.end),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn item(label: &str) -> CompletionItem {
|
||||
CompletionItem {
|
||||
label: label.to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_start_stops_at_non_identifier_characters() {
|
||||
assert_eq!(word_start("let value", 9), 4);
|
||||
assert_eq!(word_start("obj.field", 9), 4);
|
||||
assert_eq!(word_start("obj.", 4), 4);
|
||||
assert_eq!(word_start("привет", 6), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippets_lose_placeholders() {
|
||||
assert_eq!(strip_snippet("println!(${1:value})"), "println!(value)");
|
||||
assert_eq!(strip_snippet("foo($1, $0)"), "foo(, )");
|
||||
assert_eq!(strip_snippet("cost $$5"), "cost $5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_drops_below_the_caret_when_there_is_room() {
|
||||
let viewport = Size {
|
||||
width: px(800.),
|
||||
height: px(600.),
|
||||
};
|
||||
let anchor = Point::new(px(100.), px(200.));
|
||||
let size = Size {
|
||||
width: px(300.),
|
||||
height: px(200.),
|
||||
};
|
||||
|
||||
let placement = popup_origin(anchor, px(180.), size, viewport);
|
||||
assert_eq!(placement.origin, Point::new(px(100.), px(200.)));
|
||||
assert!(!placement.above);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_flips_above_when_bottom_space_is_short() {
|
||||
let viewport = Size {
|
||||
width: px(800.),
|
||||
height: px(600.),
|
||||
};
|
||||
let caret_top = px(480.);
|
||||
let anchor = Point::new(px(100.), px(500.));
|
||||
let size = Size {
|
||||
width: px(300.),
|
||||
height: px(200.),
|
||||
};
|
||||
|
||||
let placement = popup_origin(anchor, caret_top, size, viewport);
|
||||
assert_eq!(placement.origin.y, caret_top - px(200.) - px(POPUP_GAP));
|
||||
assert_eq!(placement.origin.x, px(100.));
|
||||
assert!(placement.above);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_shifts_left_when_it_does_not_fit_on_the_right() {
|
||||
let viewport = Size {
|
||||
width: px(800.),
|
||||
height: px(600.),
|
||||
};
|
||||
let anchor = Point::new(px(700.), px(100.));
|
||||
let size = Size {
|
||||
width: px(300.),
|
||||
height: px(200.),
|
||||
};
|
||||
|
||||
let placement = popup_origin(anchor, px(80.), size, viewport);
|
||||
assert_eq!(placement.origin.x, px(800.) - px(POPUP_MARGIN) - px(300.));
|
||||
assert_eq!(placement.origin.y, px(100.));
|
||||
assert!(!placement.above);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_icons_exist_in_the_bundled_asset_catalog() {
|
||||
use gpui_kit::AssetSource;
|
||||
use gpui_kit::assets::AllAssets;
|
||||
|
||||
// `Assets` — компактный набор компонента, и иконок автодополнения в
|
||||
// нём нет. Приложение подключает `AllAssets`; тест следит за тем,
|
||||
// чтобы каждая иконка списка действительно там была.
|
||||
let kinds = [
|
||||
None,
|
||||
Some(CompletionItemKind::FUNCTION),
|
||||
Some(CompletionItemKind::METHOD),
|
||||
Some(CompletionItemKind::STRUCT),
|
||||
Some(CompletionItemKind::CLASS),
|
||||
Some(CompletionItemKind::INTERFACE),
|
||||
Some(CompletionItemKind::ENUM),
|
||||
Some(CompletionItemKind::CONSTRUCTOR),
|
||||
Some(CompletionItemKind::VARIABLE),
|
||||
Some(CompletionItemKind::FIELD),
|
||||
Some(CompletionItemKind::PROPERTY),
|
||||
Some(CompletionItemKind::CONSTANT),
|
||||
Some(CompletionItemKind::ENUM_MEMBER),
|
||||
Some(CompletionItemKind::KEYWORD),
|
||||
Some(CompletionItemKind::MODULE),
|
||||
Some(CompletionItemKind::FILE),
|
||||
Some(CompletionItemKind::FOLDER),
|
||||
Some(CompletionItemKind::SNIPPET),
|
||||
Some(CompletionItemKind::EVENT),
|
||||
Some(CompletionItemKind::TYPE_PARAMETER),
|
||||
Some(CompletionItemKind::OPERATOR),
|
||||
Some(CompletionItemKind::TEXT),
|
||||
];
|
||||
|
||||
for kind in kinds {
|
||||
let path = completion_icon(kind).path();
|
||||
assert!(
|
||||
AllAssets
|
||||
.load(&path)
|
||||
.expect("ошибка чтения ассетов")
|
||||
.is_some(),
|
||||
"в бандле нет иконки {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_edit_uses_the_lsp_range() {
|
||||
let text = "let value";
|
||||
let mut item = item("value");
|
||||
item.text_edit = Some(CompletionTextEdit::Edit(lsp_types::TextEdit {
|
||||
range: lsp_types::Range {
|
||||
start: lsp_types::Position::new(0, 4),
|
||||
end: lsp_types::Position::new(0, 9),
|
||||
},
|
||||
new_text: "answer".to_owned(),
|
||||
}));
|
||||
let (range, replacement) = completion_edit(&item, text, 4, 9);
|
||||
assert_eq!(range, 4..9);
|
||||
assert_eq!(replacement, "answer");
|
||||
}
|
||||
}
|
||||
+179
-3
@@ -1,5 +1,9 @@
|
||||
//! Константы оформления окна и сценариев.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Ширина окна при запуске.
|
||||
pub const WINDOW_WIDTH: f32 = 900.0;
|
||||
|
||||
@@ -33,11 +37,183 @@ pub const MIN_MAIN_AREA_WIDTH: f32 = 240.0;
|
||||
/// Высота нижней панели при открытии.
|
||||
pub const BOTTOM_PANEL_HEIGHT: f32 = 160.0;
|
||||
|
||||
/// Каталог, в котором приложение ищет Lua-сценарии.
|
||||
/// Каталог, в котором приложение ищет Lua-сценарии по умолчанию.
|
||||
///
|
||||
/// По умолчанию это текущий рабочий каталог процесса — именно там приложение
|
||||
/// ожидает `init.lua`.
|
||||
/// Это текущий рабочий каталог процесса — именно там приложение ожидает
|
||||
/// `init.lua`, если не передан аргумент `--config`. Конкретный каталог всегда
|
||||
/// возвращает [`script_dir`].
|
||||
pub const SCRIPT_DIR: &str = ".";
|
||||
|
||||
/// Каталог сценариев, заданный через `--config`.
|
||||
static SCRIPT_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Файл, который нужно открыть при старте (первый позиционный аргумент).
|
||||
static STARTUP_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Разобранные аргументы командной строки.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CommandLine {
|
||||
/// Каталог, из которого брать `init.lua`.
|
||||
pub config_dir: Option<PathBuf>,
|
||||
/// Файл, открываемый при старте как `vim file.go`.
|
||||
pub file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Каталог, из которого загружаются `init.lua`, `include` и `require`.
|
||||
///
|
||||
/// Возвращает значение `--config`, если оно было задано, иначе текущий
|
||||
/// рабочий каталог.
|
||||
pub fn script_dir() -> &'static Path {
|
||||
SCRIPT_DIR_OVERRIDE
|
||||
.get()
|
||||
.map_or_else(|| Path::new(SCRIPT_DIR), PathBuf::as_path)
|
||||
}
|
||||
|
||||
/// Файл, который надо открыть при старте редактора.
|
||||
pub fn startup_file() -> Option<&'static Path> {
|
||||
STARTUP_FILE.get().map(PathBuf::as_path)
|
||||
}
|
||||
|
||||
/// Запоминает каталог сценариев из аргумента `--config`.
|
||||
///
|
||||
/// Путь канонизируется, поэтому относительный `--config ./scripts` работает
|
||||
/// относительно каталога запуска, а `include`/`require` видят абсолютные пути.
|
||||
pub fn set_script_dir(path: PathBuf) -> Result<(), String> {
|
||||
let canonical = std::fs::canonicalize(&path).map_err(|err| {
|
||||
format!(
|
||||
"не удалось открыть каталог конфигурации {}: {err}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
if !canonical.is_dir() {
|
||||
return Err(format!(
|
||||
"--config: {} не является каталогом",
|
||||
canonical.display()
|
||||
));
|
||||
}
|
||||
|
||||
SCRIPT_DIR_OVERRIDE
|
||||
.set(canonical)
|
||||
.map_err(|_| "каталог конфигурации уже задан".to_owned())
|
||||
}
|
||||
|
||||
/// Запоминает файл, открываемый при старте.
|
||||
pub fn set_startup_file(path: PathBuf) -> Result<(), String> {
|
||||
STARTUP_FILE
|
||||
.set(path)
|
||||
.map_err(|_| "файл для открытия уже задан".to_owned())
|
||||
}
|
||||
|
||||
/// Разбирает аргументы командной строки.
|
||||
///
|
||||
/// Поддерживаются:
|
||||
/// * `файл` — первый позиционный аргумент, как в `vim file.go`;
|
||||
/// * `--config <каталог>` и `--config=<каталог>`;
|
||||
/// * `--` — всё после него считается позиционным (для имён, начинающихся с `-`).
|
||||
///
|
||||
/// Неизвестные флаги игнорируются, чтобы запуск не ломался от лишних
|
||||
/// аргументов окружения/обёрток.
|
||||
pub fn parse_command_line<I>(args: I) -> Result<CommandLine, String>
|
||||
where
|
||||
I: IntoIterator<Item = OsString>,
|
||||
{
|
||||
let mut command_line = CommandLine::default();
|
||||
let mut args = args.into_iter();
|
||||
let mut positional_only = false;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
let text = arg.to_string_lossy();
|
||||
|
||||
if positional_only {
|
||||
if command_line.file.is_none() {
|
||||
command_line.file = Some(PathBuf::from(arg));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if text == "--" {
|
||||
positional_only = true;
|
||||
} else if text == "--config" {
|
||||
let value = args
|
||||
.next()
|
||||
.ok_or_else(|| "--config требует путь к каталогу".to_owned())?;
|
||||
command_line.config_dir = Some(PathBuf::from(value));
|
||||
} else if let Some(value) = text.strip_prefix("--config=") {
|
||||
if value.is_empty() {
|
||||
return Err("--config требует путь к каталогу".to_owned());
|
||||
}
|
||||
command_line.config_dir = Some(PathBuf::from(value));
|
||||
} else if !text.starts_with('-') && command_line.file.is_none() {
|
||||
command_line.file = Some(PathBuf::from(arg));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(command_line)
|
||||
}
|
||||
|
||||
/// Имя стартового сценария, который выполняется при запуске приложения.
|
||||
pub const STARTUP_SCRIPT: &str = "init.lua";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(values: &[&str]) -> Vec<OsString> {
|
||||
values.iter().map(OsString::from).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_dir_is_read_from_both_argument_forms() {
|
||||
assert_eq!(
|
||||
parse_command_line(args(&["--config", "/tmp/project"]))
|
||||
.unwrap()
|
||||
.config_dir,
|
||||
Some(PathBuf::from("/tmp/project"))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_command_line(args(&["--config=/tmp/project"]))
|
||||
.unwrap()
|
||||
.config_dir,
|
||||
Some(PathBuf::from("/tmp/project"))
|
||||
);
|
||||
assert!(parse_command_line(args(&[])).unwrap().config_dir.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_dir_requires_a_value() {
|
||||
assert!(parse_command_line(args(&["--config"])).is_err());
|
||||
assert!(parse_command_line(args(&["--config="])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_arguments_do_not_hide_the_config_dir() {
|
||||
let parsed = parse_command_line(args(&["--verbose", "--config", "cfg"])).unwrap();
|
||||
assert_eq!(parsed.config_dir, Some(PathBuf::from("cfg")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_positional_argument_is_the_file_to_open() {
|
||||
let parsed = parse_command_line(args(&["main.go"])).unwrap();
|
||||
assert_eq!(parsed.file, Some(PathBuf::from("main.go")));
|
||||
|
||||
let parsed = parse_command_line(args(&["--config", "cfg", "main.go"])).unwrap();
|
||||
assert_eq!(parsed.config_dir, Some(PathBuf::from("cfg")));
|
||||
assert_eq!(parsed.file, Some(PathBuf::from("main.go")));
|
||||
|
||||
let parsed = parse_command_line(args(&["main.go", "--config=cfg"])).unwrap();
|
||||
assert_eq!(parsed.config_dir, Some(PathBuf::from("cfg")));
|
||||
assert_eq!(parsed.file, Some(PathBuf::from("main.go")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_dash_allows_file_names_starting_with_dash() {
|
||||
let parsed = parse_command_line(args(&["--", "-weird.go"])).unwrap();
|
||||
assert_eq!(parsed.file, Some(PathBuf::from("-weird.go")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_first_positional_argument_is_opened() {
|
||||
let parsed = parse_command_line(args(&["first.go", "second.go"])).unwrap();
|
||||
assert_eq!(parsed.file, Some(PathBuf::from("first.go")));
|
||||
}
|
||||
}
|
||||
|
||||
+108
@@ -75,6 +75,67 @@ impl Language {
|
||||
Self::Python => "python",
|
||||
}
|
||||
}
|
||||
|
||||
/// Идентификатор языка для LSP (`languageId` в `textDocument/didOpen`).
|
||||
///
|
||||
/// Отличается от [`Self::name`] тем же, чем `highlight_name`: LSP ждёт
|
||||
/// `javascript`, а не `js`.
|
||||
pub fn lsp_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rust => "rust",
|
||||
Self::Go => "go",
|
||||
Self::JavaScript => "javascript",
|
||||
Self::Json => "json",
|
||||
Self::Python => "python",
|
||||
}
|
||||
}
|
||||
|
||||
/// Определяет язык подсветки по имени файла.
|
||||
///
|
||||
/// Используется при открытии файла с диска (`app:open(path)`), чтобы
|
||||
/// подсветка включалась автоматически там, где для неё есть грамматика.
|
||||
pub fn from_path(path: &str) -> Option<Self> {
|
||||
let extension = path.rsplit_once('.')?.1.to_ascii_lowercase();
|
||||
match extension.as_str() {
|
||||
"rs" => Some(Self::Rust),
|
||||
"go" => Some(Self::Go),
|
||||
"js" | "mjs" | "cjs" | "jsx" => Some(Self::JavaScript),
|
||||
"json" => Some(Self::Json),
|
||||
"py" => Some(Self::Python),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Язык LSP по расширению имени файла.
|
||||
///
|
||||
/// Нужен для документов, у которых подсветка ещё не включена: LSP-сервер
|
||||
/// выбирается и получает `languageId` независимо от tree-sitter.
|
||||
pub fn language_id_from_path(name: &str) -> &'static str {
|
||||
let extension = name
|
||||
.rsplit_once('.')
|
||||
.map(|(_, extension)| extension.to_ascii_lowercase());
|
||||
|
||||
match extension.as_deref() {
|
||||
Some("rs") => "rust",
|
||||
Some("go") => "go",
|
||||
Some("js" | "mjs" | "cjs") => "javascript",
|
||||
Some("jsx") => "javascriptreact",
|
||||
Some("ts") => "typescript",
|
||||
Some("tsx") => "typescriptreact",
|
||||
Some("json") => "json",
|
||||
Some("py") => "python",
|
||||
Some("md" | "markdown") => "markdown",
|
||||
Some("toml") => "toml",
|
||||
Some("yaml" | "yml") => "yaml",
|
||||
Some("lua") => "lua",
|
||||
Some("c" | "h") => "c",
|
||||
Some("cc" | "cpp" | "cxx" | "hpp" | "hh") => "cpp",
|
||||
Some("sh" | "bash") => "shellscript",
|
||||
Some("html" | "htm") => "html",
|
||||
Some("css") => "css",
|
||||
_ => "plaintext",
|
||||
}
|
||||
}
|
||||
|
||||
/// Открытый в редакторе документ.
|
||||
@@ -115,6 +176,17 @@ impl Document {
|
||||
self.content != self.saved_content
|
||||
}
|
||||
|
||||
/// Идентификатор языка для LSP.
|
||||
///
|
||||
/// Если подсветка уже включена, берётся её язык. Иначе язык угадывается
|
||||
/// по расширению имени вкладки — так LSP работает и без `set_language`.
|
||||
pub fn lsp_language_id(&self) -> String {
|
||||
if let Some(language) = self.language {
|
||||
return language.lsp_name().to_owned();
|
||||
}
|
||||
language_id_from_path(&self.name).to_owned()
|
||||
}
|
||||
|
||||
/// Помечает текущее содержимое сохранённым.
|
||||
pub fn mark_saved(&mut self) {
|
||||
self.saved_content = self.content.clone();
|
||||
@@ -207,6 +279,42 @@ impl Workspace {
|
||||
self.bump();
|
||||
}
|
||||
|
||||
/// Открывает файл с диска как вкладку и делает её активной.
|
||||
///
|
||||
/// Если вкладка с таким именем уже открыта и не содержит несохранённых
|
||||
/// изменений, её содержимое обновляется из файла — повторный `app:open`
|
||||
/// не создаёт дубликат. Грязная вкладка не трогается: открывается новая.
|
||||
pub fn open_file(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
language: Option<Language>,
|
||||
) -> usize {
|
||||
let name = name.into();
|
||||
let content = content.into();
|
||||
|
||||
if let Some(index) = self
|
||||
.documents
|
||||
.iter()
|
||||
.position(|document| document.name == name && !document.is_dirty())
|
||||
{
|
||||
let document = &mut self.documents[index];
|
||||
document.content = content;
|
||||
document.mark_saved();
|
||||
document.language = language;
|
||||
self.active = index;
|
||||
self.bump();
|
||||
return index;
|
||||
}
|
||||
|
||||
let mut document = Document::new(name, content);
|
||||
document.language = language;
|
||||
self.documents.push(document);
|
||||
self.active = self.documents.len() - 1;
|
||||
self.bump();
|
||||
self.active
|
||||
}
|
||||
|
||||
/// Закрывает вкладку и выбирает соседнюю.
|
||||
///
|
||||
/// Если закрывается последняя вкладка, вместо неё открывается пустой документ.
|
||||
|
||||
+1227
File diff suppressed because it is too large
Load Diff
+27
-1
@@ -7,9 +7,11 @@
|
||||
|
||||
mod api;
|
||||
mod app;
|
||||
mod completion;
|
||||
mod constants;
|
||||
mod editor;
|
||||
mod keymap;
|
||||
mod lsp;
|
||||
mod panel;
|
||||
mod script;
|
||||
|
||||
@@ -23,7 +25,15 @@ use app::MyApp;
|
||||
use constants::{MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE, WINDOW_WIDTH};
|
||||
|
||||
fn main() {
|
||||
application().with_assets(assets::Assets).run(|cx| {
|
||||
if let Err(err) = apply_command_line() {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
// `AllAssets` содержит полный каталог Lucide: автодополнение использует
|
||||
// иконки (`square-function`, `variable`, `braces` и т. п.), которых нет
|
||||
// в компактном наборе `Assets`.
|
||||
application().with_assets(assets::AllAssets).run(|cx| {
|
||||
// Инициализация темы, шрифтов и компонентов обязательна до создания окон.
|
||||
init(cx);
|
||||
|
||||
@@ -34,6 +44,22 @@ fn main() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Применяет аргументы запуска до создания окна.
|
||||
///
|
||||
/// `--config` задаёт каталог `init.lua`, а первый позиционный аргумент — файл,
|
||||
/// который [`MyApp`] откроет через `app:open(path)` после выполнения сценария.
|
||||
/// Рабочий каталог процесса и пути сохранения файлов не меняются.
|
||||
fn apply_command_line() -> Result<(), String> {
|
||||
let command_line = constants::parse_command_line(std::env::args_os().skip(1))?;
|
||||
if let Some(directory) = command_line.config_dir {
|
||||
constants::set_script_dir(directory)?;
|
||||
}
|
||||
if let Some(file) = command_line.file {
|
||||
constants::set_startup_file(file)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Параметры окна в стиле исходного приложения: размер 900×600, минимум
|
||||
/// 420×280 и собственный заголовок вместо системного.
|
||||
fn window_options() -> WindowOptions {
|
||||
|
||||
+35
-13
@@ -1,20 +1,24 @@
|
||||
//! Выполнение Lua-сценариев приложения.
|
||||
//!
|
||||
//! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`SCRIPT_DIR`]
|
||||
//! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`script_dir`]
|
||||
//! (см. [`ScriptEngine::run_startup`]). Скриптам доступны:
|
||||
//!
|
||||
//! * объект `app` — управление окном и редактором (методы [`AppApi`], вызываются
|
||||
//! через двоеточие: `app:next_tab()`);
|
||||
//! * функция `include(path)` — выполняет другой Lua-файл относительно [`SCRIPT_DIR`]
|
||||
//! * функция `include(path)` — выполняет другой Lua-файл относительно [`script_dir`]
|
||||
//! (или по абсолютному пути) в общем глобальном окружении;
|
||||
//! * стандартный `require`, который также ищет модули в [`SCRIPT_DIR`].
|
||||
//! * стандартный `require`, который также ищет модули в [`script_dir`].
|
||||
//!
|
||||
//! Каталог по умолчанию — текущий рабочий каталог. Его можно переопределить
|
||||
//! аргументом `--config <каталог>` при запуске приложения (см.
|
||||
//! [`parse_command_line`](crate::constants::parse_command_line)).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use mlua::{Lua, Table};
|
||||
|
||||
use crate::api::{AppApi, TabInfo};
|
||||
use crate::constants::{SCRIPT_DIR, STARTUP_SCRIPT};
|
||||
use crate::constants::{STARTUP_SCRIPT, script_dir};
|
||||
|
||||
/// Вкладка превращается в Lua-таблицу `{ index = 0, title = "main.rs" }`.
|
||||
impl mlua::IntoLua for TabInfo {
|
||||
@@ -29,24 +33,40 @@ impl mlua::IntoLua for TabInfo {
|
||||
/// Движок Lua и состояние его глобального окружения.
|
||||
pub struct ScriptEngine {
|
||||
lua: Lua,
|
||||
/// Каталог, относительно которого ищутся `init.lua`, `include` и `require`.
|
||||
script_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ScriptEngine {
|
||||
/// Создаёт движок и публикует в нём API приложения.
|
||||
///
|
||||
/// Каталог сценариев берётся из [`script_dir`] (то есть из `--config`
|
||||
/// или текущего рабочего каталога).
|
||||
pub fn new(api: AppApi) -> mlua::Result<Self> {
|
||||
let engine = Self { lua: Lua::new() };
|
||||
Self::new_in(api, script_dir())
|
||||
}
|
||||
|
||||
/// Создаёт движок с явно заданным каталогом сценариев.
|
||||
///
|
||||
/// Полезно тестам и будущим режимам, где каталог конфигурации приходит
|
||||
/// не из глобального `--config`.
|
||||
pub fn new_in(api: AppApi, directory: impl AsRef<Path>) -> mlua::Result<Self> {
|
||||
let engine = Self {
|
||||
lua: Lua::new(),
|
||||
script_dir: directory.as_ref().to_path_buf(),
|
||||
};
|
||||
engine.expose_app_api(api)?;
|
||||
engine.configure_require()?;
|
||||
Ok(engine)
|
||||
}
|
||||
|
||||
/// Выполняет стартовый сценарий из [`SCRIPT_DIR`].
|
||||
/// Выполняет стартовый сценарий из каталога движка.
|
||||
///
|
||||
/// # Errors
|
||||
/// Возвращает ошибку, если файла нет, его не удалось прочитать или в нём
|
||||
/// есть синтаксическая/исполняемая ошибка.
|
||||
pub fn run_startup(&self) -> mlua::Result<()> {
|
||||
self.run_file(&script_path(STARTUP_SCRIPT))
|
||||
self.run_file(&script_path(&self.script_dir, STARTUP_SCRIPT))
|
||||
}
|
||||
|
||||
/// Выполняет Lua-файл в глобальном окружении движка.
|
||||
@@ -73,7 +93,7 @@ impl ScriptEngine {
|
||||
|
||||
if let Ok(package) = globals.get::<Table>("package") {
|
||||
let current: String = package.get("path").unwrap_or_default();
|
||||
let dir = SCRIPT_DIR;
|
||||
let dir = self.script_dir.display().to_string().replace('\\', "/");
|
||||
let extra = format!("{dir}/?.lua;{dir}/?/init.lua");
|
||||
let path = if current.is_empty() {
|
||||
extra
|
||||
@@ -85,10 +105,12 @@ impl ScriptEngine {
|
||||
|
||||
// include выполняет файл сразу, как dofile, но путь по умолчанию
|
||||
// отсчитывается от каталога сценариев, а не от cwd.
|
||||
let directory = self.script_dir.clone();
|
||||
globals.set(
|
||||
"include",
|
||||
self.lua
|
||||
.create_function(|lua, path: String| run_file(lua, &script_path(&path)))?,
|
||||
self.lua.create_function(move |lua, path: String| {
|
||||
run_file(lua, &script_path(&directory, &path))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
@@ -96,13 +118,13 @@ impl ScriptEngine {
|
||||
}
|
||||
|
||||
/// Путь к файлу сценария: абсолютные пути используются как есть, остальные
|
||||
/// отсчитываются от [`SCRIPT_DIR`].
|
||||
fn script_path(path: &str) -> PathBuf {
|
||||
/// отсчитываются от каталога сценариев.
|
||||
fn script_path(directory: &Path, path: &str) -> PathBuf {
|
||||
let path = Path::new(path);
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
Path::new(SCRIPT_DIR).join(path)
|
||||
directory.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+294
-1
@@ -7,7 +7,7 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui_kit::{Keystroke, Modifiers, TestAppContext};
|
||||
use gpui_kit::{Keystroke, Modifiers, TestAppContext, px};
|
||||
|
||||
use crate::api::{AppApi, WindowCommand, drain_window_commands};
|
||||
use crate::app::MyApp;
|
||||
@@ -21,6 +21,35 @@ fn install_api(workspace: Rc<RefCell<Workspace>>) -> (AppApi, Rc<RefCell<KeyMap>
|
||||
(api, keymap)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_lsp_api_lists_no_servers_and_validates_config() {
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
let (api, _keymap) = install_api(workspace);
|
||||
|
||||
let dir = temp_script_dir("lsp-api");
|
||||
let script = dir.join("lsp.lua");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"
|
||||
assert(#app:lsp_servers() == 0)
|
||||
assert(app:stop_lsp("missing") == false)
|
||||
assert(app:restart_lsp("missing") == false)
|
||||
|
||||
-- Без cmd сервер не запускается, а ошибка объясняет, чего не хватает.
|
||||
local ok, err = pcall(function() app:run_lsp({ name = "broken" }) end)
|
||||
assert(not ok)
|
||||
assert(tostring(err):find("cmd", 1, true), tostring(err))
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||||
engine.run_file(&script).expect("скрипт LSP API");
|
||||
|
||||
drop(engine);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_keeps_at_least_one_tab() {
|
||||
let mut state = Workspace::default();
|
||||
@@ -327,6 +356,111 @@ fn lua_script_can_include_files_and_control_the_app() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_opens_file_from_disk_and_detects_language() {
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
let (api, _keymap) = install_api(workspace.clone());
|
||||
|
||||
let dir = temp_script_dir("open-file");
|
||||
let path = dir.join("main.go");
|
||||
std::fs::write(&path, "package main\n").unwrap();
|
||||
|
||||
let index = api.open(&path).expect("файл открыт");
|
||||
{
|
||||
let state = workspace.borrow();
|
||||
assert_eq!(index, state.active_index());
|
||||
assert_eq!(state.active().name, path.to_string_lossy());
|
||||
assert_eq!(state.active().content, "package main\n");
|
||||
assert_eq!(state.active().language, Some(Language::Go));
|
||||
assert!(!state.active().is_dirty());
|
||||
}
|
||||
|
||||
// Повторное открытие чистого файла переиспользует вкладку.
|
||||
let tabs = workspace.borrow().documents().len();
|
||||
assert_eq!(api.open(&path).unwrap(), index);
|
||||
assert_eq!(workspace.borrow().documents().len(), tabs);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_open_missing_file_creates_an_empty_buffer() {
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
let (api, _keymap) = install_api(workspace.clone());
|
||||
|
||||
let dir = temp_script_dir("open-missing");
|
||||
let path = dir.join("new.go");
|
||||
|
||||
let index = api.open(&path).expect("новый буфер");
|
||||
let state = workspace.borrow();
|
||||
assert_eq!(index, state.active_index());
|
||||
assert_eq!(state.active().name, path.to_string_lossy());
|
||||
assert!(state.active().content.is_empty());
|
||||
assert_eq!(state.active().language, Some(Language::Go));
|
||||
assert!(!state.active().is_dirty());
|
||||
drop(state);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_app_open_opens_a_file_and_returns_its_tab() {
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
let (api, _keymap) = install_api(workspace.clone());
|
||||
|
||||
let dir = temp_script_dir("lua-open");
|
||||
let path = dir.join("main.go");
|
||||
std::fs::write(&path, "package main\n").unwrap();
|
||||
let script = dir.join("open.lua");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!(
|
||||
"local index = app:open({:?})\nassert(index == app:active_tab())\n",
|
||||
path.to_string_lossy()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
|
||||
engine.run_file(&script).expect("app:open из Lua");
|
||||
|
||||
let state = workspace.borrow();
|
||||
assert_eq!(state.active().name, path.to_string_lossy());
|
||||
assert_eq!(state.active().content, "package main\n");
|
||||
assert_eq!(state.active().language, Some(Language::Go));
|
||||
drop(state);
|
||||
|
||||
drop(engine);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_engine_loads_init_from_a_custom_config_dir() {
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
let (api, _keymap) = install_api(workspace.clone());
|
||||
|
||||
let dir = temp_script_dir("config-dir");
|
||||
std::fs::write(
|
||||
dir.join("helper.lua"),
|
||||
"function config_value() app:set_active_text(\"custom config\") end\n",
|
||||
)
|
||||
.expect("helper.lua");
|
||||
std::fs::write(
|
||||
dir.join("init.lua"),
|
||||
"include(\"helper.lua\")\nconfig_value()\n",
|
||||
)
|
||||
.expect("init.lua");
|
||||
|
||||
// --config меняет именно каталог поиска init.lua/include, а не cwd.
|
||||
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
|
||||
engine.run_startup().expect("init.lua из своего каталога");
|
||||
|
||||
assert_eq!(workspace.borrow().active().content, "custom config");
|
||||
|
||||
drop(engine);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_key_bindings_match_press_release_and_modifiers() {
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
@@ -597,3 +731,162 @@ fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
|
||||
);
|
||||
assert!(doc.is_dirty(), "правка должна помечать документ изменённым");
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn arrows_move_completion_selection_without_moving_cursor(cx: &mut TestAppContext) {
|
||||
cx.update(gpui_kit::init);
|
||||
|
||||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||||
|
||||
let before = visual.update(|window, cx| {
|
||||
app.update(cx, |this, cx| {
|
||||
this.editor_handle()
|
||||
.update(cx, |state, cx| state.focus(window, cx));
|
||||
this.open_test_completion(cx);
|
||||
this.editor_handle().read(cx).cursor()
|
||||
})
|
||||
});
|
||||
visual.run_until_parked();
|
||||
|
||||
visual.simulate_keystrokes("down");
|
||||
visual.run_until_parked();
|
||||
|
||||
let (cursor, selected, open) = visual.update(|_window, cx| {
|
||||
app.update(cx, |this, cx| {
|
||||
(
|
||||
this.editor_handle().read(cx).cursor(),
|
||||
this.completion_selected(),
|
||||
this.completion_is_open(),
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
assert_eq!(selected, 1, "стрелка вниз должна выбрать второй элемент");
|
||||
assert_eq!(cursor, before, "пока список открыт, курсор не двигается");
|
||||
assert!(open, "список должен остаться открытым");
|
||||
|
||||
// На последнем элементе выделение стоит на месте, но клавиша всё равно
|
||||
// принадлежит списку и не должна двигать курсор.
|
||||
visual.simulate_keystrokes("down");
|
||||
visual.run_until_parked();
|
||||
visual.simulate_keystrokes("up");
|
||||
visual.run_until_parked();
|
||||
|
||||
let (cursor, selected, open) = visual.update(|_window, cx| {
|
||||
app.update(cx, |this, cx| {
|
||||
(
|
||||
this.editor_handle().read(cx).cursor(),
|
||||
this.completion_selected(),
|
||||
this.completion_is_open(),
|
||||
)
|
||||
})
|
||||
});
|
||||
assert_eq!(selected, 0, "стрелка вверх должна вернуть первый элемент");
|
||||
assert_eq!(cursor, before);
|
||||
assert!(open);
|
||||
|
||||
visual.simulate_keystrokes("escape");
|
||||
visual.run_until_parked();
|
||||
|
||||
let (cursor, open) = visual.update(|_window, cx| {
|
||||
app.update(cx, |this, cx| {
|
||||
(
|
||||
this.editor_handle().read(cx).cursor(),
|
||||
this.completion_is_open(),
|
||||
)
|
||||
})
|
||||
});
|
||||
assert!(!open, "Esc должен закрыть список");
|
||||
assert_eq!(cursor, before);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn long_completion_list_scrolls_instead_of_shrinking_rows(cx: &mut TestAppContext) {
|
||||
cx.update(gpui_kit::init);
|
||||
|
||||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||||
|
||||
visual.update(|window, cx| {
|
||||
app.update(cx, |this, cx| {
|
||||
this.editor_handle()
|
||||
.update(cx, |state, cx| state.focus(window, cx));
|
||||
this.open_test_completion_with_count(60, cx);
|
||||
});
|
||||
});
|
||||
visual.run_until_parked();
|
||||
|
||||
let scroll =
|
||||
visual.update(|_window, cx| app.update(cx, |this, _cx| this.completion_scroll_handle()));
|
||||
|
||||
assert!(
|
||||
scroll.max_offset().y > px(0.),
|
||||
"длинный список должен прокручиваться, а не сжимать строки"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn completion_popup_above_the_caret_renders(cx: &mut TestAppContext) {
|
||||
cx.update(gpui_kit::init);
|
||||
|
||||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||||
|
||||
visual.update(|window, cx| {
|
||||
app.update(cx, |this, cx| {
|
||||
let text = (0..200)
|
||||
.map(|index| format!("let value_{index} = {index};\n"))
|
||||
.collect::<String>();
|
||||
this.editor_handle().update(cx, |state, cx| {
|
||||
state.set_value(text.clone(), window, cx);
|
||||
let end = text.len();
|
||||
state.set_selected_range(end..end, cx);
|
||||
state.focus(window, cx);
|
||||
});
|
||||
this.open_test_completion(cx);
|
||||
});
|
||||
});
|
||||
|
||||
// Первый кадр обновляет прокрутку редактора, второй — положение поповера,
|
||||
// который должен оказаться над каретой.
|
||||
visual.run_until_parked();
|
||||
visual.run_until_parked();
|
||||
|
||||
assert!(
|
||||
visual.update(|_window, cx| app.update(cx, |this, _cx| this.completion_is_open())),
|
||||
"поповер над каретой должен отрисоваться"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn horizontal_arrows_close_completion_and_move_cursor(cx: &mut TestAppContext) {
|
||||
cx.update(gpui_kit::init);
|
||||
|
||||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||||
|
||||
let before = visual.update(|window, cx| {
|
||||
app.update(cx, |this, cx| {
|
||||
this.editor_handle()
|
||||
.update(cx, |state, cx| state.focus(window, cx));
|
||||
this.open_test_completion(cx);
|
||||
this.editor_handle().read(cx).cursor()
|
||||
})
|
||||
});
|
||||
visual.run_until_parked();
|
||||
|
||||
visual.simulate_keystrokes("right");
|
||||
visual.run_until_parked();
|
||||
|
||||
let (after, open) = visual.update(|_window, cx| {
|
||||
app.update(cx, |this, cx| {
|
||||
(
|
||||
this.editor_handle().read(cx).cursor(),
|
||||
this.completion_is_open(),
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
assert!(
|
||||
after > before,
|
||||
"горизонтальная стрелка двигает курсор как обычно"
|
||||
);
|
||||
assert!(!open, "горизонтальная стрелка закрывает список");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user