gutter refactoring
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
| --- | --- |
|
||||
| `src/main.rs` | Точка входа, окно, аргументы командной строки |
|
||||
| `src/constants.rs` | Константы окна, CLI и каталог сценариев |
|
||||
| `src/editor.rs` | Доменная модель: документы, вкладки, языки |
|
||||
| `src/editor.rs` | Доменная модель: документы (`Rope`), вкладки, языки |
|
||||
| `src/api.rs` | `AppApi` / `EditorApi` / `PanelsApi` / `PanelApi` / `ToolbarApi` / `TreeApi` / `PathApi` / `WindowApi` / `KeymapApi` — единственная поверхность управления |
|
||||
| `src/script.rs` | Lua-движок, `api`, `include`, `require` |
|
||||
| `src/prompt.rs` | Плавающее поле ввода: запрос из Lua и открытое окно |
|
||||
@@ -20,7 +20,7 @@
|
||||
| `src/panel.rs` | `PanelState` и блоки панелей |
|
||||
| `src/toolbar.rs` | Состояние верхней панели: лейблы и кнопки |
|
||||
| `src/tree.rs` | Абстрактное дерево для панелей |
|
||||
| `src/gutter.rs` | Иконки на полях слева от редактора |
|
||||
| `src/gutter.rs` | Иконки на полях слева от редактора и их сдвиг по `InputEdit` |
|
||||
| `src/icons.rs` | Каталог иконок Lucide |
|
||||
| `src/lsp.rs` | Менеджер LSP-серверов, JSON-RPC, файловый вотчер |
|
||||
| `docs/*.md`, `init.lua` | Пользовательская документация и пример конфигурации |
|
||||
@@ -124,6 +124,10 @@ qode — не готовый редактор с фиксированным на
|
||||
кадре UI.
|
||||
- Какие серверы запускать, решает Lua; менеджер отвечает только за жизненный
|
||||
цикл, JSON-RPC и синхронизацию документов.
|
||||
- Текст документа хранится как `Rope`, поэтому правка из редактора копируется
|
||||
за O(1). LSP получает инкрементальный `didChange` с диапазонами из `InputEdit`;
|
||||
полный текст материализуется только для серверов без инкрементальной
|
||||
синхронизации и на границах (сохранение, Lua).
|
||||
|
||||
## Соглашения
|
||||
|
||||
|
||||
Generated
+5
-10
@@ -2072,8 +2072,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-base"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d862bc8f2e27710039013d075ac4995892e9687ff207fa530f880cbcd513936f"
|
||||
source = "git+https://github.com/dedalqq/gpui-kit?rev=fc9488fb4433c0ee99aad36123ebbbd49928044a#fc9488fb4433c0ee99aad36123ebbbd49928044a"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -2114,8 +2113,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-component"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46ce53430332e30f303364eaabfa10ed2aa23dfaf2a147e04370e0e9c423578b"
|
||||
source = "git+https://github.com/dedalqq/gpui-kit?rev=fc9488fb4433c0ee99aad36123ebbbd49928044a#fc9488fb4433c0ee99aad36123ebbbd49928044a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -2178,8 +2176,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-component-macros"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a512c830553e7dddc43c3f8faffc2d2d7bd24db21fe3dc1326dde9c470013768"
|
||||
source = "git+https://github.com/dedalqq/gpui-kit?rev=fc9488fb4433c0ee99aad36123ebbbd49928044a#fc9488fb4433c0ee99aad36123ebbbd49928044a"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
@@ -2502,8 +2499,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-kit"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25210f6e59ea9ffac3a033dbce9edafe5ff0dd2900f894fd1316f54da2fe2b89"
|
||||
source = "git+https://github.com/dedalqq/gpui-kit?rev=fc9488fb4433c0ee99aad36123ebbbd49928044a#fc9488fb4433c0ee99aad36123ebbbd49928044a"
|
||||
dependencies = [
|
||||
"gpui-base",
|
||||
"gpui-component",
|
||||
@@ -2519,8 +2515,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-kit-assets"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c214017b45e5654665f18faa551f58a2c2c8d25efe6632b1a443ba8edf365e96"
|
||||
source = "git+https://github.com/dedalqq/gpui-kit?rev=fc9488fb4433c0ee99aad36123ebbbd49928044a#fc9488fb4433c0ee99aad36123ebbbd49928044a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gpui-pre",
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
gpui-kit = { version = "0.8.1", features = [
|
||||
gpui-kit = { git = "https://github.com/dedalqq/gpui-kit", rev = "fc9488fb4433c0ee99aad36123ebbbd49928044a", features = [
|
||||
"tree-sitter",
|
||||
"tree-sitter-rust",
|
||||
"tree-sitter-go",
|
||||
@@ -36,7 +36,7 @@ parking_lot = "0.12"
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
gpui-kit = { version = "0.8.1", features = ["test-support"] }
|
||||
gpui-kit = { git = "https://github.com/dedalqq/gpui-kit", rev = "fc9488fb4433c0ee99aad36123ebbbd49928044a", features = ["test-support"] }
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
+13
-3
@@ -83,6 +83,7 @@ api.editor:clear_tab_icon(1) -- снова "file"
|
||||
| `api.editor:clear_gutter_kind(kind)` | Убрать иконки типа во всех строках активной вкладки; возвращает число удалённых |
|
||||
| `api.editor:clear_gutter()` | Убрать все иконки активной вкладки |
|
||||
| `api.editor:on_gutter_click(kind, callback)` | Назначить обработчик клика по иконке типа `kind`; `callback(line, icon, kind)`. `nil` вместо функции снимает обработчик |
|
||||
| `api.editor:on_gutter_shift(kind, callback)` | Назначить обработчик переноса иконки типа `kind`; `callback(kind, old_line, new_line)`. Вызывается для каждой иконки, чья строка съехала после правки текста. `nil` снимает обработчик |
|
||||
|
||||
```lua
|
||||
api.editor:set_gutter_icon(4, "circle", "diagnostics", { color = "#e5484d" })
|
||||
@@ -96,15 +97,24 @@ api.editor:on_gutter_click("breakpoint", function(line, icon, kind)
|
||||
print("клик", line, icon, kind)
|
||||
end)
|
||||
|
||||
api.editor:on_gutter_shift("breakpoint", function(kind, old_line, new_line)
|
||||
print("иконка уехала", kind, old_line, new_line)
|
||||
end)
|
||||
|
||||
api.editor:clear_gutter_icon(4, "diagnostics") -- только один тип строки
|
||||
api.editor:clear_gutter_kind("diagnostics") -- перерисовать только диагностику
|
||||
api.editor:clear_gutter_line(4) -- все иконки строки
|
||||
api.editor:clear_gutter() -- все иконки вкладки
|
||||
```
|
||||
|
||||
Иконки привязаны к номерам строк и автоматически не сдвигаются при правке
|
||||
текста: сценарий сам решает, когда их переставить. При прокрутке колонка
|
||||
двигается вместе с редактором.
|
||||
Иконки привязаны к началу своей строки и едут вместе с ней: если правка
|
||||
вставила или удалила строки выше иконки, она сдвигается на столько же строк.
|
||||
Правка внутри строки без изменения их числа номера не меняет. Вставка ровно
|
||||
в начало строки относится к её содержимому — иконка едет вместе с текстом.
|
||||
Чтобы узнать о переносе, сценарий назначает `on_gutter_shift`. При прокрутке
|
||||
колонка двигается вместе с редактором. Внешняя замена всего текста через
|
||||
`api.editor:set_active_text` сбрасывает иконки активной вкладки: их номера
|
||||
строк относятся к старому тексту.
|
||||
|
||||
## Текст
|
||||
|
||||
|
||||
@@ -115,6 +115,10 @@ print("[lua] init.lua выполнен")
|
||||
-- api.editor:on_gutter_click(kind, callback)
|
||||
-- -- клик по иконке типа; callback(line, icon, kind),
|
||||
-- -- nil снимает обработчик
|
||||
-- api.editor:on_gutter_shift(kind, callback)
|
||||
-- -- перенос иконки при правке текста;
|
||||
-- -- callback(kind, old_line, new_line),
|
||||
-- -- nil снимает обработчик
|
||||
-- api.editor:active_text() -- текст активной вкладки
|
||||
-- api.editor:set_active_text(text) -- заменить текст активной вкладки
|
||||
-- api.editor:set_language(i, "rust") -- включить подсветку
|
||||
|
||||
+61
-17
@@ -13,7 +13,7 @@
|
||||
//! вычитывает её в начале кадра.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
@@ -27,7 +27,9 @@ use gpui_kit::{Hsla, Rgba};
|
||||
|
||||
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
|
||||
use crate::editor::{FocusAfterSync, Language, Workspace};
|
||||
use crate::gutter::{GutterClickHandlers, new_gutter_click_handlers};
|
||||
use crate::gutter::{
|
||||
GutterClickHandlers, GutterShiftHandlers, new_gutter_click_handlers, new_gutter_shift_handlers,
|
||||
};
|
||||
use crate::icons;
|
||||
use crate::keymap::{AppCommand, CompletionCommand, KeyBinding, KeyContext, KeyHandler, KeyMap};
|
||||
use crate::lsp::{
|
||||
@@ -171,6 +173,11 @@ pub struct AppApi {
|
||||
/// для всех вкладок.
|
||||
#[lua(skip)]
|
||||
gutter_clicks: GutterClickHandlers,
|
||||
/// Обработчики переноса иконок gutter при правке текста.
|
||||
///
|
||||
/// Как и обработчики клика, живут вне документов и общие для всех вкладок.
|
||||
#[lua(skip)]
|
||||
gutter_shifts: GutterShiftHandlers,
|
||||
/// Запрос из Lua на открытие плавающего поля ввода.
|
||||
#[lua(skip)]
|
||||
prompt_request: PromptRequestHandle,
|
||||
@@ -194,6 +201,8 @@ pub struct EditorApi {
|
||||
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
|
||||
#[lua(skip)]
|
||||
gutter_clicks: GutterClickHandlers,
|
||||
#[lua(skip)]
|
||||
gutter_shifts: GutterShiftHandlers,
|
||||
}
|
||||
|
||||
impl EditorApi {
|
||||
@@ -203,6 +212,7 @@ impl EditorApi {
|
||||
lsp: Arc<LspManager>,
|
||||
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
|
||||
gutter_clicks: GutterClickHandlers,
|
||||
gutter_shifts: GutterShiftHandlers,
|
||||
) -> Self {
|
||||
Self {
|
||||
workspace,
|
||||
@@ -210,6 +220,7 @@ impl EditorApi {
|
||||
lsp,
|
||||
diagnostics_handler,
|
||||
gutter_clicks,
|
||||
gutter_shifts,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,6 +707,7 @@ impl PathApi {
|
||||
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.map_err(|err| {
|
||||
@@ -965,6 +977,7 @@ impl AppApi {
|
||||
lsp: Arc::new(LspManager::new()),
|
||||
diagnostics_handler: Rc::new(RefCell::new(None)),
|
||||
gutter_clicks: new_gutter_click_handlers(),
|
||||
gutter_shifts: new_gutter_shift_handlers(),
|
||||
prompt_request: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
|
||||
@@ -1032,6 +1045,12 @@ impl AppApi {
|
||||
self.gutter_clicks.clone()
|
||||
}
|
||||
|
||||
/// Обработчики переноса иконок gutter (для вида).
|
||||
#[lua(skip)]
|
||||
pub fn gutter_shift_handle(&self) -> GutterShiftHandlers {
|
||||
self.gutter_shifts.clone()
|
||||
}
|
||||
|
||||
/// API редактора.
|
||||
///
|
||||
/// В Lua доступно как `api.editor`: `api.editor:open_tab()`,
|
||||
@@ -1045,6 +1064,7 @@ impl AppApi {
|
||||
self.lsp.clone(),
|
||||
self.diagnostics_handler.clone(),
|
||||
self.gutter_clicks.clone(),
|
||||
self.gutter_shifts.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1163,7 +1183,7 @@ impl EditorApi {
|
||||
let (path, content) = {
|
||||
let workspace = self.workspace.borrow();
|
||||
let doc = workspace.active();
|
||||
(doc.name.clone(), doc.content.clone())
|
||||
(doc.name.clone(), doc.content_string())
|
||||
};
|
||||
std::fs::write(&path, &content)?;
|
||||
if let Some(uri) = document_uri(&path) {
|
||||
@@ -1236,7 +1256,7 @@ impl EditorApi {
|
||||
/// Содержимое активной вкладки.
|
||||
#[lua(infallible)]
|
||||
pub fn active_text(&self) -> String {
|
||||
self.workspace.borrow().active().content.clone()
|
||||
self.workspace.borrow().active().content_string()
|
||||
}
|
||||
|
||||
/// Заменяет содержимое активной вкладки.
|
||||
@@ -1571,19 +1591,19 @@ impl EditorApi {
|
||||
/// общие для всех вкладок: тип иконки задаёт сценарий.
|
||||
#[lua(infallible)]
|
||||
pub fn on_gutter_click(&self, kind: String, callback: Option<mlua::Function>) {
|
||||
let kind = kind.trim();
|
||||
if kind.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut handlers = self.gutter_clicks.borrow_mut();
|
||||
match callback {
|
||||
Some(callback) => {
|
||||
handlers.insert(kind.to_owned(), callback);
|
||||
}
|
||||
None => {
|
||||
handlers.remove(kind);
|
||||
}
|
||||
}
|
||||
set_gutter_handler(&self.gutter_clicks, kind, callback);
|
||||
}
|
||||
|
||||
/// Назначает обработчик переноса иконки типа `kind`.
|
||||
///
|
||||
/// Обработчик получает `(kind, old_line, new_line)`, где номера строк
|
||||
/// нумеруются с нуля. Вызывается после правки текста для каждой иконки
|
||||
/// этого типа, чья строка съехала. `callback = nil` удаляет обработчик
|
||||
/// этого типа. Обработчики общие для всех вкладок: тип иконки задаёт
|
||||
/// сценарий.
|
||||
#[lua(infallible)]
|
||||
pub fn on_gutter_shift(&self, kind: String, callback: Option<mlua::Function>) {
|
||||
set_gutter_handler(&self.gutter_shifts, kind, callback);
|
||||
}
|
||||
|
||||
/// Включает подсветку синтаксиса для вкладки `index`.
|
||||
@@ -2038,6 +2058,30 @@ fn parse_toolbar_color(color: Option<String>) -> mlua::Result<Option<Hsla>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Устанавливает или снимает обработчик gutter в реестре по типу иконки.
|
||||
///
|
||||
/// Реестры клика и переноса имеют одинаковую форму, поэтому используют один
|
||||
/// помощник.
|
||||
fn set_gutter_handler(
|
||||
handlers: &Rc<RefCell<HashMap<String, mlua::Function>>>,
|
||||
kind: String,
|
||||
callback: Option<mlua::Function>,
|
||||
) {
|
||||
let kind = kind.trim();
|
||||
if kind.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut handlers = handlers.borrow_mut();
|
||||
match callback {
|
||||
Some(callback) => {
|
||||
handlers.insert(kind.to_owned(), callback);
|
||||
}
|
||||
None => {
|
||||
handlers.remove(kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Переводит необязательный цвет gutter в `Hsla`.
|
||||
///
|
||||
/// `None`, пустая строка и `transparent` означают «цвет не задан», а
|
||||
|
||||
+216
-77
@@ -15,6 +15,7 @@ use std::task::{Context as TaskContext, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui_kit::assets::IconName as AssetIcon;
|
||||
use gpui_kit::base::input::InputEdit;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::input::{
|
||||
Editor, EditorState, HoverProvider, Input, InputEvent, InputState, Rope, TabSize,
|
||||
@@ -36,19 +37,21 @@ use lsp_types::{CompletionItem, Hover, TextEdit};
|
||||
|
||||
use crate::api::{self, AppApi, WindowCommand};
|
||||
use crate::completion::{
|
||||
CaretPopup, POPUP_MARGIN, completion_edit, completion_icon, completion_icon_color,
|
||||
CaretPopup, POPUP_MARGIN, char_before, completion_edit, completion_icon, completion_icon_color,
|
||||
completion_kind_name, documentation_text, fallback_documentation, highlight_range, item_key,
|
||||
parse_completion_response, range_for_lsp, word_start,
|
||||
parse_completion_response, range_for_lsp, word_start_rope,
|
||||
};
|
||||
use crate::constants::{
|
||||
GUTTER_ICON_GAP, GUTTER_ICON_PADDING, GUTTER_ICON_SIZE, GUTTER_WIDTH, MIN_LEFT_PANEL_WIDTH,
|
||||
MIN_MAIN_AREA_WIDTH, PROMPT_CENTER_OFFSET, PROMPT_WIDTH, TAB_SIZE, WINDOW_TITLE,
|
||||
};
|
||||
use crate::editor::{FocusAfterSync, Workspace};
|
||||
use crate::gutter::{GutterClickHandlers, GutterMarker, GutterMarkerKey};
|
||||
use crate::gutter::{
|
||||
GutterClickHandlers, GutterLineEdit, GutterMarkerKey, GutterShift, GutterShiftHandlers,
|
||||
};
|
||||
use crate::icons;
|
||||
use crate::keymap::{AppCommand, CompletionCommand, KeyContext, KeyHandler, KeyMap};
|
||||
use crate::lsp::{LspManager, document_uri, position_to_offset};
|
||||
use crate::lsp::{LspManager, document_uri, position_to_offset, position_to_offset_rope};
|
||||
use crate::panel::{PanelBlock, PanelHandle};
|
||||
use crate::prompt::{PromptRequestHandle, PromptUi};
|
||||
use crate::script::ScriptEngine;
|
||||
@@ -206,6 +209,11 @@ pub struct MyApp {
|
||||
editor: Entity<EditorState>,
|
||||
/// Ревизия [`Workspace`], уже перенесённая в `editor`.
|
||||
synced_revision: u64,
|
||||
/// Версия текста, уже показанная в `editor`.
|
||||
///
|
||||
/// Позволяет не сравнивать весь rope при ревизии, которая меняет только
|
||||
/// флаг сохранения, иконку вкладки или язык.
|
||||
synced_content_version: u64,
|
||||
/// Идентификатор документа, который сейчас показан в редакторе.
|
||||
///
|
||||
/// Нужен, чтобы отличить переключение вкладки от изменения её текста и
|
||||
@@ -230,6 +238,11 @@ pub struct MyApp {
|
||||
/// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения)
|
||||
/// уничтожились раньше самого Lua-движка.
|
||||
gutter_clicks: GutterClickHandlers,
|
||||
/// Обработчики переноса иконок gutter, общие с `api.editor`.
|
||||
///
|
||||
/// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения)
|
||||
/// уничтожились раньше самого Lua-движка.
|
||||
gutter_shifts: GutterShiftHandlers,
|
||||
/// Иконка gutter, над которой сейчас находится мышь.
|
||||
///
|
||||
/// Вид хранит его отдельно от модели иконок: при наведении иконка
|
||||
@@ -281,8 +294,9 @@ impl MyApp {
|
||||
let editor_keymap = api.editor_keymap_handle();
|
||||
let prompt_request = api.prompt_request_handle();
|
||||
let gutter_clicks = api.gutter_click_handle();
|
||||
let gutter_shifts = api.gutter_shift_handle();
|
||||
|
||||
let initial_text = workspace.borrow().active().content.clone();
|
||||
let initial_text = workspace.borrow().active().content_string();
|
||||
let editor = cx.new(|cx| {
|
||||
EditorState::new(window, cx)
|
||||
.line_number(true)
|
||||
@@ -313,27 +327,39 @@ impl MyApp {
|
||||
window,
|
||||
|this, _editor, event: &InputEvent, window, cx| {
|
||||
if matches!(event, InputEvent::Change) {
|
||||
let text = this.editor.read(cx).value().to_string();
|
||||
let cursor = this.editor.read(cx).cursor();
|
||||
let mut workspace = this.workspace.borrow_mut();
|
||||
let was_dirty = workspace.active().is_dirty();
|
||||
workspace.replace_active_content(text.clone());
|
||||
// Индикатор на вкладке меняется только при переходе
|
||||
// «чисто → грязно» (или обратно после отмены правки).
|
||||
let dirty_changed = was_dirty != workspace.active().is_dirty();
|
||||
drop(workspace);
|
||||
let (text, cursor, editor_edits) = {
|
||||
let editor = this.editor.read(cx);
|
||||
(
|
||||
editor.text().clone(),
|
||||
editor.cursor(),
|
||||
editor.last_input_edits().to_vec(),
|
||||
)
|
||||
};
|
||||
let (dirty_changed, gutter_shifted) =
|
||||
this.apply_editor_text(&text, &editor_edits);
|
||||
this.synced_content_version =
|
||||
this.workspace.borrow().active().content_version();
|
||||
|
||||
if let Some(expected) = this.suppress_completion_for_text.as_deref() {
|
||||
if expected == text {
|
||||
// LSP получает правки и во время вставки автодополнения.
|
||||
this.sync_lsp_document(&text, &editor_edits);
|
||||
|
||||
let suppressed = this
|
||||
.suppress_completion_for_text
|
||||
.as_deref()
|
||||
.is_some_and(|expected| text.chars().eq(expected.chars()));
|
||||
if this.suppress_completion_for_text.is_some() {
|
||||
if suppressed {
|
||||
this.suppress_completion_for_text = None;
|
||||
}
|
||||
if gutter_shifted {
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.sync_lsp_document();
|
||||
this.on_editor_changed(&text, cursor, window, cx);
|
||||
|
||||
if dirty_changed {
|
||||
if dirty_changed || gutter_shifted {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -365,10 +391,7 @@ impl MyApp {
|
||||
// каждое новое обновление перезапускает ожидание, поэтому
|
||||
// промежуточные снимки пропускаются.
|
||||
if diagnostics_api.diagnostics_delay().is_some() {
|
||||
loop {
|
||||
let Some(delay) = diagnostics_api.diagnostics_delay() else {
|
||||
break;
|
||||
};
|
||||
while let Some(delay) = diagnostics_api.diagnostics_delay() {
|
||||
if !delay.is_zero() {
|
||||
cx.background_executor().timer(delay).await;
|
||||
}
|
||||
@@ -445,6 +468,7 @@ impl MyApp {
|
||||
panel_open_state: Cell::new([false; 3]),
|
||||
editor,
|
||||
synced_revision: 0,
|
||||
synced_content_version: 0,
|
||||
displayed_id,
|
||||
focused_once: false,
|
||||
focus_editor_after_render: false,
|
||||
@@ -452,6 +476,7 @@ impl MyApp {
|
||||
_key_interceptor,
|
||||
prompt: None,
|
||||
gutter_clicks,
|
||||
gutter_shifts,
|
||||
gutter_hover: None,
|
||||
prompt_request,
|
||||
scripts: None,
|
||||
@@ -560,13 +585,27 @@ impl MyApp {
|
||||
}
|
||||
|
||||
/// Отдаёт активный документ LSP-серверам, которые его обслуживают.
|
||||
fn sync_lsp_document(&self) {
|
||||
let workspace = self.workspace.borrow();
|
||||
let document = workspace.active();
|
||||
if let Some(uri) = document_uri(&document.name) {
|
||||
self.lsp
|
||||
.sync_document(uri, document.lsp_language_id(), &document.content);
|
||||
///
|
||||
/// Правки из `InputEvent::Change` уходят инкрементально; при внешней
|
||||
/// замене или смене вкладки сервер получает полный текст.
|
||||
fn sync_lsp_document(&self, text: &Rope, edits: &[(InputEdit, Rope)]) {
|
||||
if self.lsp.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (uri, language_id, content_version) = {
|
||||
let workspace = self.workspace.borrow();
|
||||
let document = workspace.active();
|
||||
(
|
||||
document_uri(&document.name),
|
||||
document.lsp_language_id(),
|
||||
document.content_version(),
|
||||
)
|
||||
};
|
||||
let Some(uri) = uri else {
|
||||
return;
|
||||
};
|
||||
self.lsp
|
||||
.sync_document_changes(uri, language_id, content_version, text, edits);
|
||||
}
|
||||
|
||||
/// Переносит диагностику LSP активного документа в редактор.
|
||||
@@ -584,7 +623,7 @@ impl MyApp {
|
||||
.as_ref()
|
||||
.map(|uri| self.lsp.diagnostics(uri.as_str()))
|
||||
.unwrap_or_default();
|
||||
let text = self.editor.read(cx).value().to_string();
|
||||
let text = self.editor.read(cx).text().clone();
|
||||
|
||||
let error = cx.theme().highlight_theme.style.status.error(cx);
|
||||
let warning = cx.theme().highlight_theme.style.status.warning(cx);
|
||||
@@ -617,13 +656,13 @@ impl MyApp {
|
||||
/// Решает, нужно ли после правки запросить автодополнение.
|
||||
fn on_editor_changed(
|
||||
&mut self,
|
||||
text: &str,
|
||||
text: &Rope,
|
||||
cursor: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let cursor = cursor.min(text.len());
|
||||
let last = text[..cursor].chars().next_back();
|
||||
let last = char_before(text, cursor);
|
||||
let triggers = last.is_some_and(|ch| {
|
||||
ch.is_alphanumeric() || matches!(ch, '_' | '.' | ':' | '/' | '#' | '@')
|
||||
});
|
||||
@@ -647,13 +686,14 @@ impl MyApp {
|
||||
return;
|
||||
}
|
||||
|
||||
let (text, cursor) = {
|
||||
let (cursor, start, query) = {
|
||||
let editor = self.editor.read(cx);
|
||||
(editor.value().to_string(), editor.cursor())
|
||||
let text = editor.text();
|
||||
let cursor = editor.cursor().min(text.len());
|
||||
let start = word_start_rope(text, cursor);
|
||||
let query = text.slice(start..cursor).to_string();
|
||||
(cursor, start, query)
|
||||
};
|
||||
let cursor = cursor.min(text.len());
|
||||
let start = word_start(&text, cursor);
|
||||
let query = text[start..cursor].to_string();
|
||||
|
||||
let generation = {
|
||||
self.completion.generation = self.completion.generation.wrapping_add(1);
|
||||
@@ -1153,21 +1193,19 @@ impl MyApp {
|
||||
self.editor.update(cx, |state, cx| {
|
||||
state.apply_lsp_edits(&edits, window, cx);
|
||||
let end = (completion_start as isize + inserted_len as isize + shift).max(0) as usize;
|
||||
let end = end.min(state.value().len());
|
||||
let end = end.min(state.text().len());
|
||||
state.set_selected_range(end..end, cx);
|
||||
state.focus(window, cx);
|
||||
});
|
||||
|
||||
// `InputEvent::Change` доставляются эффектами после текущего update.
|
||||
// `defer` стоит в очереди после них и снимает подавление в любом
|
||||
// случае, даже если финальный текст почему-то не совпал.
|
||||
// Подписка на редактор сама перенесёт новый текст в модель, сдвинет
|
||||
// gutter и отправит LSP-изменения. `defer` стоит в очереди после них и
|
||||
// снимает подавление в любом случае, даже если финальный текст почему-то
|
||||
// не совпал.
|
||||
cx.defer_in(window, |this, _window, _cx| {
|
||||
this.suppress_completion_for_text = None;
|
||||
});
|
||||
|
||||
let new_text = self.editor.read(cx).value().to_string();
|
||||
self.workspace.borrow_mut().replace_active_content(new_text);
|
||||
self.sync_lsp_document();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -1796,7 +1834,7 @@ impl MyApp {
|
||||
self.focus_editor_after_render = true;
|
||||
self.editor.update(cx, |state, cx| {
|
||||
if let Some(cursor) = cursor {
|
||||
let cursor = cursor.min(state.value().len());
|
||||
let cursor = cursor.min(state.text().len());
|
||||
state.set_selected_range(cursor..cursor, cx);
|
||||
}
|
||||
state.focus(window, cx);
|
||||
@@ -1806,13 +1844,14 @@ impl MyApp {
|
||||
}
|
||||
self.synced_revision = revision;
|
||||
|
||||
let (active_id, content, language, cursor, focus_after_sync) = {
|
||||
let (active_id, content, content_version, language, cursor, focus_after_sync) = {
|
||||
let mut workspace = self.workspace.borrow_mut();
|
||||
let focus_after_sync = workspace.take_focus_after_sync();
|
||||
let doc = workspace.active();
|
||||
(
|
||||
doc.id,
|
||||
doc.content.clone(),
|
||||
doc.content().clone(),
|
||||
doc.content_version(),
|
||||
doc.language,
|
||||
doc.cursor,
|
||||
focus_after_sync,
|
||||
@@ -1848,25 +1887,28 @@ impl MyApp {
|
||||
let highlight = language
|
||||
.map(|language| language.syntax_name())
|
||||
.unwrap_or("");
|
||||
let replace_content = switching || content_version != self.synced_content_version;
|
||||
self.editor.update(cx, |state, cx| {
|
||||
if state.value().as_ref() != content.as_str() {
|
||||
state.set_value(content, window, cx);
|
||||
if replace_content {
|
||||
state.set_value(content.to_string(), window, cx);
|
||||
}
|
||||
if state.language_name().as_ref() != highlight {
|
||||
state.set_highlighter(highlight, cx);
|
||||
}
|
||||
if switching || focus_cursor.is_some() {
|
||||
let cursor = focus_cursor.unwrap_or(cursor).min(state.value().len());
|
||||
let cursor = focus_cursor.unwrap_or(cursor).min(state.text().len());
|
||||
state.set_selected_range(cursor..cursor, cx);
|
||||
}
|
||||
if focus_editor {
|
||||
state.focus(window, cx);
|
||||
}
|
||||
});
|
||||
self.synced_content_version = content_version;
|
||||
|
||||
if switching {
|
||||
self.close_completion(cx);
|
||||
self.sync_lsp_document();
|
||||
let text = self.editor.read(cx).text().clone();
|
||||
self.sync_lsp_document(&text, &[]);
|
||||
// У другой вкладки своя диагностика, уже пришедшая от сервера.
|
||||
self.apply_diagnostics(cx);
|
||||
}
|
||||
@@ -2007,16 +2049,10 @@ impl MyApp {
|
||||
/// фиксируется именно в его prepaint, и если считать `top` здесь, в
|
||||
/// `render`, иконки отстают от текста на кадр.
|
||||
fn render_gutter(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (markers, columns) = {
|
||||
let (document_id, columns) = {
|
||||
let workspace = self.workspace.borrow();
|
||||
let gutter = &workspace.active().gutter;
|
||||
(
|
||||
gutter
|
||||
.iter()
|
||||
.map(|(line, marker)| (line, marker.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
gutter.max_per_line(),
|
||||
)
|
||||
let document = workspace.active();
|
||||
(document.id, document.gutter.max_per_line())
|
||||
};
|
||||
// Колонка растёт под число иконок на самой насыщенной строке, чтобы
|
||||
// иконки разных типов не накладывались друг на друга. Одиночная
|
||||
@@ -2028,7 +2064,8 @@ impl MyApp {
|
||||
GutterElement {
|
||||
editor: self.editor.clone(),
|
||||
entity: cx.entity(),
|
||||
markers,
|
||||
workspace: self.workspace.clone(),
|
||||
document_id,
|
||||
hovered: self.gutter_hover.clone(),
|
||||
width,
|
||||
background: cx
|
||||
@@ -2041,6 +2078,46 @@ impl MyApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Переносит текст из виджета редактора в модель и сдвигает иконки gutter.
|
||||
///
|
||||
/// Текст копируется как rope за O(1), а номера строк берутся из точных
|
||||
/// `InputEdit`, которые редактор отдаёт при `InputEvent::Change`.
|
||||
///
|
||||
/// Возвращает `(изменился ли признак несохранённости, сдвинулись ли
|
||||
/// иконки)`. Иконки пересчитываются до вызова Lua-обработчиков, чтобы
|
||||
/// сценарий видел уже новое состояние.
|
||||
fn apply_editor_text(
|
||||
&mut self,
|
||||
text: &Rope,
|
||||
editor_edits: &[(InputEdit, Rope)],
|
||||
) -> (bool, bool) {
|
||||
let line_edits = {
|
||||
let workspace = self.workspace.borrow();
|
||||
let mut before = workspace.active().content();
|
||||
let mut line_edits = Vec::with_capacity(editor_edits.len());
|
||||
for (edit, after) in editor_edits {
|
||||
line_edits.push(GutterLineEdit::from_input_edit(before, edit));
|
||||
before = after;
|
||||
}
|
||||
line_edits
|
||||
};
|
||||
let collect_shifts = !self.gutter_shifts.borrow().is_empty();
|
||||
let (dirty_changed, shifts) = {
|
||||
let mut workspace = self.workspace.borrow_mut();
|
||||
let was_dirty = workspace.active().is_dirty();
|
||||
let shifts = workspace.replace_active_content(text, &line_edits, collect_shifts);
|
||||
// Индикатор на вкладке меняется только при переходе «чисто → грязно»
|
||||
// (или обратно после отмены правки).
|
||||
let dirty_changed = was_dirty != workspace.active().is_dirty();
|
||||
(dirty_changed, shifts)
|
||||
};
|
||||
let shifted = !shifts.is_empty();
|
||||
if shifted {
|
||||
self.run_gutter_shifts(&shifts);
|
||||
}
|
||||
(dirty_changed, shifted)
|
||||
}
|
||||
|
||||
/// Вызывает Lua-обработчик клика по иконке gutter.
|
||||
///
|
||||
/// Обработчик получает `(line, icon, kind)`. Если для типа обработчик не
|
||||
@@ -2055,6 +2132,24 @@ impl MyApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Вызывает Lua-обработчики переноса иконок gutter.
|
||||
///
|
||||
/// Для каждой сдвинутой иконки вызывается обработчик её типа с
|
||||
/// аргументами `(kind, old_line, new_line)`.
|
||||
fn run_gutter_shifts(&self, shifts: &[GutterShift]) {
|
||||
for shift in shifts {
|
||||
let callback = self.gutter_shifts.borrow().get(&shift.kind).cloned();
|
||||
let Some(callback) = callback else {
|
||||
continue;
|
||||
};
|
||||
if let Err(err) =
|
||||
callback.call::<()>((shift.kind.as_str(), shift.old_line, shift.new_line))
|
||||
{
|
||||
eprintln!("Lua: ошибка в обработчике переноса gutter: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Полоса между вкладками и редактором.
|
||||
///
|
||||
/// Элементы добавляются сценарием через `api.panels.toolbar`. Левые элементы
|
||||
@@ -2299,7 +2394,8 @@ impl Render for MyApp {
|
||||
self.sync_prompt(window, cx);
|
||||
// Документ мог открыться до запуска сервера (init.lua выполняется
|
||||
// сразу после создания окна) — отдаём его LSP при первом кадре.
|
||||
self.sync_lsp_document();
|
||||
let text = self.editor.read(cx).text().clone();
|
||||
self.sync_lsp_document(&text, &[]);
|
||||
if !self.focused_once {
|
||||
self.focused_once = true;
|
||||
// Открытое поле ввода уже забрало фокус; не отбираем его.
|
||||
@@ -2423,7 +2519,13 @@ struct GutterElement {
|
||||
/// Вид, которому принадлежит gutter: через него обновляются наведение
|
||||
/// и клик по иконкам.
|
||||
entity: Entity<MyApp>,
|
||||
markers: Vec<(usize, GutterMarker)>,
|
||||
/// Документы, из которых берутся иконки активной вкладки.
|
||||
///
|
||||
/// Gutter читает модель в `prepaint`, поэтому вид не копирует все
|
||||
/// маркеры документа на каждый кадр.
|
||||
workspace: Rc<RefCell<Workspace>>,
|
||||
/// Документ, для которого построена колонка.
|
||||
document_id: u64,
|
||||
/// Иконка, на которой сейчас находится мышь, если она известна.
|
||||
hovered: Option<GutterMarkerKey>,
|
||||
width: Pixels,
|
||||
@@ -2515,24 +2617,61 @@ impl Element for GutterElement {
|
||||
};
|
||||
let display_map = state.display_map();
|
||||
let line_count = display_map.buffer_line_count();
|
||||
if line_count == 0 {
|
||||
return GutterPrepaint { icons: Vec::new() };
|
||||
}
|
||||
|
||||
// Строки, которые могут попасть в кадр, плюс запас на частично
|
||||
// видимые сверху и снизу строки.
|
||||
let display_rows = display_map.display_row_count().max(1);
|
||||
let first_row = (((bounds.top() - text_top - scroll_y) / line_height)
|
||||
.floor()
|
||||
.max(0.0) as usize)
|
||||
.min(display_rows - 1);
|
||||
let last_row = (((bounds.bottom() - text_top - scroll_y) / line_height)
|
||||
.ceil()
|
||||
.max(0.0) as usize)
|
||||
.min(display_rows - 1);
|
||||
let mut start_line = display_map
|
||||
.display_row_to_buffer_line(first_row.saturating_sub(1))
|
||||
.min(line_count - 1);
|
||||
let mut end_line = display_map
|
||||
.display_row_to_buffer_line((last_row + 1).min(display_rows - 1))
|
||||
.min(line_count - 1);
|
||||
|
||||
// Свёрнутые строки не имеют своих display row: иконки из них рисуются
|
||||
// на видимой строке заголовка. Поэтому видимый диапазон расширяется
|
||||
// до границ пересекающихся свёрнутых участков.
|
||||
for fold in display_map.folded_ranges() {
|
||||
if fold.start_line <= end_line && fold.end_line >= start_line {
|
||||
start_line = start_line.min(fold.start_line);
|
||||
end_line = end_line.max(fold.end_line);
|
||||
}
|
||||
}
|
||||
end_line = end_line.min(line_count - 1);
|
||||
|
||||
let workspace = self.workspace.borrow();
|
||||
let document = workspace.active();
|
||||
if document.id != self.document_id {
|
||||
return GutterPrepaint { icons: Vec::new() };
|
||||
}
|
||||
let visible_markers = document
|
||||
.gutter
|
||||
.iter_range(start_line..=end_line)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let icon_size = px(GUTTER_ICON_SIZE).min(line_height);
|
||||
let gap = px(GUTTER_ICON_GAP);
|
||||
let mut icons = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < self.markers.len() {
|
||||
let line = self.markers[index].0;
|
||||
while index < visible_markers.len() {
|
||||
let line = visible_markers[index].0;
|
||||
let start = index;
|
||||
while index < self.markers.len() && self.markers[index].0 == line {
|
||||
while index < visible_markers.len() && visible_markers[index].0 == line {
|
||||
index += 1;
|
||||
}
|
||||
let line_markers = &self.markers[start..index];
|
||||
let line_markers = &visible_markers[start..index];
|
||||
|
||||
// Иконки для несуществующей строки не рисуются: иначе редактор
|
||||
// прижал бы их к последней строке документа.
|
||||
if line >= line_count {
|
||||
continue;
|
||||
}
|
||||
// Номер строки буфера → номер видимой строки: свёрнутый код и
|
||||
// перенос строк не сдвигают иконки.
|
||||
let row = display_map.buffer_line_to_display_row(line);
|
||||
@@ -2690,13 +2829,13 @@ impl Element for GutterElement {
|
||||
///
|
||||
/// Пустой диапазон (например, «ожидалось `;`») расширяется на следующий
|
||||
/// символ: иначе подчёркивать было бы нечего.
|
||||
fn diagnostic_range(text: &str, range: lsp_types::Range) -> Option<Range<usize>> {
|
||||
let start = position_to_offset(text, range.start).min(text.len());
|
||||
let mut end = position_to_offset(text, range.end).min(text.len());
|
||||
fn diagnostic_range(text: &Rope, range: lsp_types::Range) -> Option<Range<usize>> {
|
||||
let start = position_to_offset_rope(text, range.start).min(text.len());
|
||||
let mut end = position_to_offset_rope(text, range.end).min(text.len());
|
||||
if end <= start {
|
||||
end = text[start..]
|
||||
.chars()
|
||||
.next()
|
||||
end = text
|
||||
.get_char(start)
|
||||
.ok()
|
||||
.map_or(start, |ch| start + ch.len_utf8());
|
||||
}
|
||||
(start < end).then_some(start..end)
|
||||
|
||||
+47
-1
@@ -7,7 +7,7 @@
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui_kit::component::input::{EditorState, PrepaintCaretGeometry};
|
||||
use gpui_kit::component::input::{EditorState, PrepaintCaretGeometry, Rope};
|
||||
use gpui_kit::{
|
||||
AnyElement, App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId, Hsla,
|
||||
InspectorElementId, IntoElement, LayoutId, Pixels, Point, Size, Style, WeakEntity, Window, px,
|
||||
@@ -196,6 +196,10 @@ fn popup_origin(
|
||||
}
|
||||
|
||||
/// Начало слова под каретой (для замены префикса при вставке).
|
||||
/// Находит начало слова под курсором в обычной строке.
|
||||
///
|
||||
/// Нужен только для тестов: редактор работает с [`word_start_rope`].
|
||||
#[cfg(test)]
|
||||
pub fn word_start(text: &str, cursor: usize) -> usize {
|
||||
let cursor = clamp_offset(text, cursor);
|
||||
let mut start = cursor;
|
||||
@@ -209,6 +213,36 @@ pub fn word_start(text: &str, cursor: usize) -> usize {
|
||||
start
|
||||
}
|
||||
|
||||
/// То же для rope: обходит только символы слова, а не весь документ.
|
||||
pub fn word_start_rope(text: &Rope, cursor: usize) -> usize {
|
||||
let cursor = cursor.min(text.len());
|
||||
let mut start = cursor;
|
||||
let mut char_index = text.byte_to_char_idx(cursor);
|
||||
while char_index > 0 {
|
||||
char_index -= 1;
|
||||
let byte = text.char_to_byte_idx(char_index);
|
||||
let ch = text.char(byte);
|
||||
if ch.is_alphanumeric() || ch == '_' {
|
||||
start = byte;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
/// Символ перед байтовым смещением `cursor`.
|
||||
pub fn char_before(text: &Rope, cursor: usize) -> Option<char> {
|
||||
let cursor = cursor.min(text.len());
|
||||
let mut char_index = text.byte_to_char_idx(cursor);
|
||||
if char_index == 0 {
|
||||
return None;
|
||||
}
|
||||
char_index -= 1;
|
||||
let byte = text.char_to_byte_idx(char_index);
|
||||
text.get_char(byte).ok()
|
||||
}
|
||||
|
||||
/// Разбирает ответ `textDocument/completion`.
|
||||
pub fn parse_completion_response(value: serde_json::Value) -> Vec<CompletionItem> {
|
||||
if value.is_null() {
|
||||
@@ -501,6 +535,18 @@ mod tests {
|
||||
assert_eq!(word_start("привет", 6), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rope_word_start_matches_string_version() {
|
||||
for text in ["let value", "obj.field", "obj.", "привет"] {
|
||||
let rope = Rope::from(text);
|
||||
for cursor in 0..=text.len() {
|
||||
if text.is_char_boundary(cursor) {
|
||||
assert_eq!(word_start_rope(&rope, cursor), word_start(text, cursor));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippets_lose_placeholders() {
|
||||
assert_eq!(strip_snippet("println!(${1:value})"), "println!(value)");
|
||||
|
||||
+76
-13
@@ -7,7 +7,9 @@
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::gutter::GutterState;
|
||||
use gpui_kit::base::input::Rope;
|
||||
|
||||
use crate::gutter::{GutterLineEdit, GutterShift, GutterState};
|
||||
|
||||
/// Счётчик идентификаторов документов.
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
|
||||
@@ -216,7 +218,12 @@ pub struct Document {
|
||||
/// индексом» после закрытия соседей.
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub content: String,
|
||||
/// Текст документа в виде rope: копия из редактора стоит O(1).
|
||||
content: Rope,
|
||||
/// Версия содержимого: увеличивается на каждую правку.
|
||||
content_version: u64,
|
||||
/// Есть несохранённые изменения.
|
||||
dirty: bool,
|
||||
/// Имя иконки вкладки из каталога Lucide (см. `crate::icons`).
|
||||
///
|
||||
/// `None` — показывается иконка по умолчанию. Назначается через
|
||||
@@ -231,18 +238,16 @@ pub struct Document {
|
||||
pub cursor: usize,
|
||||
/// Иконки на поле редактора для этой вкладки.
|
||||
pub gutter: GutterState,
|
||||
/// Содержимое на момент последнего «сохранения».
|
||||
saved_content: String,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
let content = content.into();
|
||||
Self {
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
name: name.into(),
|
||||
saved_content: content.clone(),
|
||||
content,
|
||||
content: Rope::from(content.into()),
|
||||
content_version: 0,
|
||||
dirty: false,
|
||||
icon: None,
|
||||
language: None,
|
||||
cursor: 0,
|
||||
@@ -250,9 +255,46 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Текст документа.
|
||||
pub fn content(&self) -> &Rope {
|
||||
&self.content
|
||||
}
|
||||
|
||||
/// Материализованный текст для границ (сохранение, LSP, Lua).
|
||||
pub fn content_string(&self) -> String {
|
||||
self.content.to_string()
|
||||
}
|
||||
|
||||
/// Версия содержимого, увеличивается на каждую правку.
|
||||
pub fn content_version(&self) -> u64 {
|
||||
self.content_version
|
||||
}
|
||||
|
||||
/// Есть ли изменения, которые ещё не сохранены.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.content != self.saved_content
|
||||
self.dirty
|
||||
}
|
||||
|
||||
/// Заменяет текст и отмечает документ изменённым.
|
||||
///
|
||||
/// Это внешняя замена целого текста: иконки gutter привязаны к номерам
|
||||
/// строк старого текста, поэтому они сбрасываются. Сценарий может
|
||||
/// расставить их заново по новому тексту.
|
||||
pub fn set_content(&mut self, content: Rope) {
|
||||
if self.content == content {
|
||||
return;
|
||||
}
|
||||
self.content = content;
|
||||
self.content_version = self.content_version.wrapping_add(1);
|
||||
self.dirty = true;
|
||||
self.gutter.clear();
|
||||
}
|
||||
|
||||
/// Обновляет текст после правки в редакторе: копия rope стоит O(1).
|
||||
pub fn replace_content(&mut self, content: &Rope) {
|
||||
self.content = content.clone();
|
||||
self.content_version = self.content_version.wrapping_add(1);
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Идентификатор языка для LSP.
|
||||
@@ -275,7 +317,7 @@ impl Document {
|
||||
|
||||
/// Помечает текущее содержимое сохранённым.
|
||||
pub fn mark_saved(&mut self) {
|
||||
self.saved_content = self.content.clone();
|
||||
self.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,9 +459,11 @@ impl Workspace {
|
||||
.position(|document| document.name == name && !document.is_dirty())
|
||||
{
|
||||
let document = &mut self.documents[index];
|
||||
document.content = content;
|
||||
document.content = Rope::from(content);
|
||||
document.content_version = document.content_version.wrapping_add(1);
|
||||
document.mark_saved();
|
||||
document.language = language;
|
||||
document.gutter.clear();
|
||||
self.active = index;
|
||||
self.bump();
|
||||
return index;
|
||||
@@ -459,13 +503,32 @@ impl Workspace {
|
||||
///
|
||||
/// Используется видом после ввода в редакторе: сам виджет уже показывает
|
||||
/// этот текст, поэтому повторная синхронизация только сбросила бы курсор.
|
||||
pub fn replace_active_content(&mut self, content: String) {
|
||||
self.active_mut().content = content;
|
||||
/// Иконки gutter пересчитываются по точным правкам из `InputEvent::Change`.
|
||||
///
|
||||
/// Возвращает иконки, которые съехали.
|
||||
pub fn replace_active_content(
|
||||
&mut self,
|
||||
content: &Rope,
|
||||
line_edits: &[GutterLineEdit],
|
||||
collect_shifts: bool,
|
||||
) -> Vec<GutterShift> {
|
||||
let document = self.active_mut();
|
||||
if line_edits.is_empty() && document.content.eq(content) {
|
||||
return Vec::new();
|
||||
}
|
||||
let shifts = if collect_shifts {
|
||||
document.gutter.apply_line_edits(line_edits)
|
||||
} else {
|
||||
document.gutter.apply_line_edits_silently(line_edits);
|
||||
Vec::new()
|
||||
};
|
||||
document.replace_content(content);
|
||||
shifts
|
||||
}
|
||||
|
||||
/// Заменяет текст активной вкладки извне (Lua, меню, тесты).
|
||||
pub fn set_active_text(&mut self, text: impl Into<String>) {
|
||||
self.active_mut().content = text.into();
|
||||
self.active_mut().set_content(Rope::from(text.into()));
|
||||
self.bump();
|
||||
}
|
||||
|
||||
|
||||
+366
-10
@@ -11,8 +11,10 @@
|
||||
//!
|
||||
//! Состояние принадлежит документу: у каждой вкладки свои иконки, и при
|
||||
//! переключении вкладок показываются иконки активного документа. Номера строк
|
||||
//! нумеруются с нуля и не сдвигаются автоматически при правке текста:
|
||||
//! сценарий сам решает, когда переставить иконку.
|
||||
//! нумеруются с нуля. При правке текста вид пересчитывает строки иконок
|
||||
//! ([`GutterState::apply_line_edits`]) по точным `InputEdit`, которые редактор
|
||||
//! отдаёт вместе с `InputEvent::Change`. Сценарий узнаёт о переносе через
|
||||
//! обработчик [`GutterShiftHandlers`].
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
@@ -20,6 +22,7 @@ use std::rc::Rc;
|
||||
|
||||
use mlua::Function;
|
||||
|
||||
use gpui_kit::base::input::{InputEdit, Rope, RopeExt};
|
||||
use gpui_kit::{Hsla, Pixels};
|
||||
|
||||
/// Обработчики клика по иконкам gutter, ключ — тип иконки.
|
||||
@@ -30,6 +33,67 @@ pub fn new_gutter_click_handlers() -> GutterClickHandlers {
|
||||
Rc::new(RefCell::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Обработчики переноса иконок gutter, ключ — тип иконки.
|
||||
///
|
||||
/// Вызываются после правки текста для каждой иконки, чья строка изменилась.
|
||||
pub type GutterShiftHandlers = Rc<RefCell<HashMap<String, Function>>>;
|
||||
|
||||
/// Создаёт пустой реестр обработчиков переноса.
|
||||
pub fn new_gutter_shift_handlers() -> GutterShiftHandlers {
|
||||
Rc::new(RefCell::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Перенос иконки после правки текста: тип и старый/новый номер строки.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GutterShift {
|
||||
pub kind: String,
|
||||
pub old_line: usize,
|
||||
pub new_line: usize,
|
||||
}
|
||||
|
||||
/// Строки, затронутые одной правкой текста.
|
||||
///
|
||||
/// Повторяет поля `InputEdit` из gpui-base, но без байтовых смещений: модели
|
||||
/// gutter нужны только номера строк. `start_line_empty` позволяет отличить
|
||||
/// Enter в начале непустой строки (иконка едет с содержимым) от Enter
|
||||
/// в пустой строке (иконка остаётся).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct GutterLineEdit {
|
||||
/// Строка, в которой началась правка.
|
||||
pub start_line: usize,
|
||||
/// Строка конца заменённого участка в старом тексте.
|
||||
pub old_end_line: usize,
|
||||
/// Строка конца вставки в новом тексте.
|
||||
pub new_end_line: usize,
|
||||
/// Правка начинается с первого символа строки.
|
||||
pub starts_at_line_start: bool,
|
||||
/// Строка `start_line` в старом тексте была пустой.
|
||||
pub start_line_empty: bool,
|
||||
}
|
||||
impl GutterLineEdit {
|
||||
/// Строки, затронутые правкой `edit` в тексте `before`.
|
||||
///
|
||||
/// `before` — текст до правки: по нему определяется, была ли начальная
|
||||
/// строка пустой.
|
||||
pub fn from_input_edit(before: &Rope, edit: &InputEdit) -> Self {
|
||||
Self {
|
||||
start_line: edit.start_position.row,
|
||||
old_end_line: edit.old_end_position.row,
|
||||
new_end_line: edit.new_end_position.row,
|
||||
starts_at_line_start: edit.start_position.column == 0,
|
||||
start_line_empty: line_is_empty(before, edit.start_position.row),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Пустая ли строка `row` в тексте `text`.
|
||||
///
|
||||
/// `RopeExt::slice_line` сохраняет `\r`, поэтому строка из одного `\r` в
|
||||
/// CRLF-документе тоже считается пустой.
|
||||
fn line_is_empty(text: &Rope, row: usize) -> bool {
|
||||
text.slice_line(row).chars().all(|ch| ch == '\r')
|
||||
}
|
||||
|
||||
/// Адрес иконки: строка и тип.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GutterMarkerKey {
|
||||
@@ -73,6 +137,11 @@ struct GutterLine {
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct GutterState {
|
||||
lines: BTreeMap<usize, GutterLine>,
|
||||
/// Самое большое число иконок на одной строке.
|
||||
///
|
||||
/// Поддерживается при изменении иконок, чтобы вид не перебирал все строки
|
||||
/// на каждый кадр.
|
||||
max_per_line: usize,
|
||||
}
|
||||
|
||||
impl GutterState {
|
||||
@@ -100,18 +169,23 @@ impl GutterState {
|
||||
color,
|
||||
hover_color,
|
||||
};
|
||||
self.lines
|
||||
.entry(line)
|
||||
.or_default()
|
||||
.markers
|
||||
.insert(kind, marker);
|
||||
let count = {
|
||||
let line_markers = self.lines.entry(line).or_default();
|
||||
line_markers.markers.insert(kind, marker);
|
||||
line_markers.markers.len()
|
||||
};
|
||||
self.max_per_line = self.max_per_line.max(count);
|
||||
}
|
||||
|
||||
/// Убирает все иконки строки `line`.
|
||||
///
|
||||
/// Возвращает `false`, если на этой строке иконок не было.
|
||||
pub fn remove(&mut self, line: usize) -> bool {
|
||||
self.lines.remove(&line).is_some()
|
||||
let removed = self.lines.remove(&line).is_some();
|
||||
if removed {
|
||||
self.recompute_max_per_line();
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Убирает иконку типа `kind` со строки `line`.
|
||||
@@ -125,6 +199,9 @@ impl GutterState {
|
||||
if line_markers.markers.is_empty() {
|
||||
self.lines.remove(&line);
|
||||
}
|
||||
if removed {
|
||||
self.recompute_max_per_line();
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
@@ -139,12 +216,113 @@ impl GutterState {
|
||||
}
|
||||
!line_markers.markers.is_empty()
|
||||
});
|
||||
if removed > 0 {
|
||||
self.recompute_max_per_line();
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Убирает все иконки.
|
||||
pub fn clear(&mut self) {
|
||||
self.lines.clear();
|
||||
self.max_per_line = 0;
|
||||
}
|
||||
|
||||
/// Пересчитывает строки иконок по последовательности правок.
|
||||
///
|
||||
/// Правки применяются по порядку, как они были сделаны в редакторе.
|
||||
pub fn apply_line_edits(&mut self, edits: &[GutterLineEdit]) -> Vec<GutterShift> {
|
||||
self.apply_line_edits_with_shifts(edits, true)
|
||||
}
|
||||
|
||||
/// Пересчитывает строки иконок, не собирая события переноса.
|
||||
///
|
||||
/// Нужен виду, когда для типов иконок не назначено ни одного обработчика:
|
||||
/// переносы всё равно применяются, но вектор [`GutterShift`] не строится.
|
||||
pub fn apply_line_edits_silently(&mut self, edits: &[GutterLineEdit]) {
|
||||
self.apply_line_edits_with_shifts(edits, false);
|
||||
}
|
||||
|
||||
fn apply_line_edits_with_shifts(
|
||||
&mut self,
|
||||
edits: &[GutterLineEdit],
|
||||
collect_shifts: bool,
|
||||
) -> Vec<GutterShift> {
|
||||
let mut shifts = Vec::new();
|
||||
for edit in edits {
|
||||
shifts.extend(self.apply_edit_with_shifts(*edit, collect_shifts));
|
||||
}
|
||||
shifts
|
||||
}
|
||||
|
||||
/// Пересчитывает строки иконок по точной правке из редактора.
|
||||
///
|
||||
/// Строки выше `start_line` не меняются. Строка `start_line` остаётся на
|
||||
/// месте, если правка началась не с её начала или если это была пустая
|
||||
/// строка; иначе её содержимое уезжает вместе с вставленными переводами
|
||||
/// строк. Строки внутри заменённого участка схлопываются в его начало, а
|
||||
/// строки ниже сдвигаются на разницу числа строк.
|
||||
///
|
||||
/// Возвращает перенесённые иконки в порядке строк.
|
||||
/// Пересчитывает строки иконок по одной правке.
|
||||
///
|
||||
/// Используется тестами; вид применяет пачку правок через
|
||||
/// [`Self::apply_line_edits`].
|
||||
#[cfg(test)]
|
||||
pub fn apply_edit(&mut self, edit: GutterLineEdit) -> Vec<GutterShift> {
|
||||
self.apply_edit_with_shifts(edit, true)
|
||||
}
|
||||
|
||||
fn apply_edit_with_shifts(
|
||||
&mut self,
|
||||
edit: GutterLineEdit,
|
||||
collect_shifts: bool,
|
||||
) -> Vec<GutterShift> {
|
||||
if self.lines.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let delta = edit.new_end_line as isize - edit.old_end_line as isize;
|
||||
if delta == 0 && edit.old_end_line == edit.start_line {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let start_line = edit.start_line;
|
||||
let old_end_line = edit.old_end_line;
|
||||
// Строки выше правки не трогаем вообще.
|
||||
let moved_lines = self.lines.split_off(&start_line);
|
||||
let mut shifts = Vec::new();
|
||||
for (old_line, line_markers) in moved_lines {
|
||||
let new_line = if old_line == start_line {
|
||||
let content_moved = edit.starts_at_line_start
|
||||
&& !edit.start_line_empty
|
||||
&& old_end_line == start_line
|
||||
&& edit.new_end_line > start_line;
|
||||
if content_moved {
|
||||
edit.new_end_line
|
||||
} else {
|
||||
start_line
|
||||
}
|
||||
} else if old_line <= old_end_line {
|
||||
start_line
|
||||
} else {
|
||||
old_line.saturating_add_signed(delta)
|
||||
};
|
||||
|
||||
if collect_shifts && new_line != old_line {
|
||||
for marker in line_markers.markers.values() {
|
||||
shifts.push(GutterShift {
|
||||
kind: marker.kind.clone(),
|
||||
old_line,
|
||||
new_line,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let target = self.lines.entry(new_line).or_default();
|
||||
target.markers.extend(line_markers.markers);
|
||||
self.max_per_line = self.max_per_line.max(target.markers.len());
|
||||
}
|
||||
shifts
|
||||
}
|
||||
|
||||
/// Иконка типа `kind` на строке `line`, если она есть.
|
||||
@@ -153,6 +331,7 @@ impl GutterState {
|
||||
}
|
||||
|
||||
/// Иконки в порядке возрастания номеров строк, внутри строки — по типу.
|
||||
#[cfg(test)]
|
||||
pub fn iter(&self) -> impl Iterator<Item = (usize, &GutterMarker)> {
|
||||
self.lines.iter().flat_map(|(line, line_markers)| {
|
||||
line_markers
|
||||
@@ -162,15 +341,35 @@ impl GutterState {
|
||||
})
|
||||
}
|
||||
|
||||
/// Иконки строк из диапазона `lines`.
|
||||
///
|
||||
/// Нужен виду, чтобы обходить только видимые строки, а не весь документ.
|
||||
pub fn iter_range(
|
||||
&self,
|
||||
lines: std::ops::RangeInclusive<usize>,
|
||||
) -> impl Iterator<Item = (usize, &GutterMarker)> {
|
||||
self.lines.range(lines).flat_map(|(line, line_markers)| {
|
||||
line_markers
|
||||
.markers
|
||||
.values()
|
||||
.map(move |marker| (*line, marker))
|
||||
})
|
||||
}
|
||||
|
||||
/// Максимальное число иконок на одной строке.
|
||||
///
|
||||
/// Вид использует его, чтобы колонка gutter вмещала все иконки строки.
|
||||
pub fn max_per_line(&self) -> usize {
|
||||
self.lines
|
||||
self.max_per_line
|
||||
}
|
||||
|
||||
fn recompute_max_per_line(&mut self) {
|
||||
self.max_per_line = self
|
||||
.lines
|
||||
.values()
|
||||
.map(|line_markers| line_markers.markers.len())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +386,7 @@ pub fn row_offset(row: usize, line_height: Pixels, scroll_y: Pixels) -> Pixels {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui_kit::base::input::Point;
|
||||
use gpui_kit::{hsla, px};
|
||||
|
||||
#[test]
|
||||
@@ -266,6 +466,162 @@ mod tests {
|
||||
assert_eq!(marker.hover_color, Some(hover));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_an_edit_shifts_lines_by_its_line_delta() {
|
||||
let mut gutter = GutterState::new();
|
||||
gutter.set(1, "diagnostics", "circle", None, None);
|
||||
gutter.set(4, "breakpoint", "play", None, None);
|
||||
|
||||
let shifts = gutter.apply_edit(GutterLineEdit {
|
||||
start_line: 2,
|
||||
old_end_line: 2,
|
||||
new_end_line: 3,
|
||||
starts_at_line_start: true,
|
||||
start_line_empty: false,
|
||||
});
|
||||
|
||||
assert!(gutter.get(1, "diagnostics").is_some());
|
||||
assert!(gutter.get(5, "breakpoint").is_some());
|
||||
assert_eq!(
|
||||
shifts,
|
||||
[GutterShift {
|
||||
kind: "breakpoint".to_owned(),
|
||||
old_line: 4,
|
||||
new_line: 5,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_inside_a_line_keeps_that_line_and_shifts_the_rest() {
|
||||
let mut gutter = GutterState::new();
|
||||
gutter.set(2, "breakpoint", "play", None, None);
|
||||
gutter.set(3, "diagnostics", "circle", None, None);
|
||||
|
||||
gutter.apply_edit(GutterLineEdit {
|
||||
start_line: 2,
|
||||
old_end_line: 2,
|
||||
new_end_line: 3,
|
||||
starts_at_line_start: false,
|
||||
start_line_empty: false,
|
||||
});
|
||||
|
||||
assert!(gutter.get(2, "breakpoint").is_some());
|
||||
assert!(gutter.get(4, "diagnostics").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_on_an_empty_line_keeps_its_marker() {
|
||||
let mut gutter = GutterState::new();
|
||||
gutter.set(0, "breakpoint", "play", None, None);
|
||||
|
||||
let shifts = gutter.apply_edit(GutterLineEdit {
|
||||
start_line: 0,
|
||||
old_end_line: 0,
|
||||
new_end_line: 1,
|
||||
starts_at_line_start: true,
|
||||
start_line_empty: true,
|
||||
});
|
||||
|
||||
assert!(shifts.is_empty());
|
||||
assert!(gutter.get(0, "breakpoint").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_deletion_collapses_removed_lines_to_the_start() {
|
||||
let mut gutter = GutterState::new();
|
||||
gutter.set(2, "breakpoint", "play", None, None);
|
||||
gutter.set(4, "breakpoint", "circle", None, None);
|
||||
|
||||
gutter.apply_edit(GutterLineEdit {
|
||||
start_line: 2,
|
||||
old_end_line: 3,
|
||||
new_end_line: 2,
|
||||
starts_at_line_start: true,
|
||||
start_line_empty: false,
|
||||
});
|
||||
|
||||
assert!(gutter.get(2, "breakpoint").is_some());
|
||||
assert!(gutter.get(3, "breakpoint").is_some());
|
||||
assert_eq!(
|
||||
gutter.iter().map(|(line, _)| line).collect::<Vec<_>>(),
|
||||
[2, 3]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_empty_lines_are_detected() {
|
||||
let text = Rope::from("a\r\n\r\nb");
|
||||
assert!(!line_is_empty(&text, 0));
|
||||
assert!(line_is_empty(&text, 1));
|
||||
assert!(!line_is_empty(&text, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_max_per_line_follows_set_and_remove() {
|
||||
let mut gutter = GutterState::new();
|
||||
gutter.set(1, "diagnostics", "circle", None, None);
|
||||
assert_eq!(gutter.max_per_line(), 1);
|
||||
gutter.set(1, "breakpoint", "play", None, None);
|
||||
assert_eq!(gutter.max_per_line(), 2);
|
||||
|
||||
assert!(gutter.remove_kind(1, "breakpoint"));
|
||||
assert_eq!(gutter.max_per_line(), 1);
|
||||
assert!(gutter.remove(1));
|
||||
assert_eq!(gutter.max_per_line(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_lines_updates_the_cached_maximum() {
|
||||
let mut gutter = GutterState::new();
|
||||
gutter.set(2, "diagnostics", "circle", None, None);
|
||||
gutter.set(4, "breakpoint", "play", None, None);
|
||||
|
||||
gutter.apply_edit(GutterLineEdit {
|
||||
start_line: 2,
|
||||
old_end_line: 4,
|
||||
new_end_line: 2,
|
||||
starts_at_line_start: true,
|
||||
start_line_empty: false,
|
||||
});
|
||||
|
||||
// Строка 4 схлопнулась в 2, и там теперь два разных типа.
|
||||
assert_eq!(gutter.max_per_line(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_iterator_returns_only_requested_lines() {
|
||||
let mut gutter = GutterState::new();
|
||||
gutter.set(1, "diagnostics", "circle", None, None);
|
||||
gutter.set(5, "breakpoint", "play", None, None);
|
||||
|
||||
let lines = gutter
|
||||
.iter_range(0..=2)
|
||||
.map(|(line, _)| line)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(lines, [1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_edit_uses_input_rows_and_crlf_empty_line() {
|
||||
let before = Rope::from("a\r\n\r\nb");
|
||||
let edit = InputEdit {
|
||||
start_byte: 3,
|
||||
old_end_byte: 3,
|
||||
new_end_byte: 4,
|
||||
start_position: Point::new(1, 0),
|
||||
old_end_position: Point::new(1, 0),
|
||||
new_end_position: Point::new(1, 1),
|
||||
};
|
||||
|
||||
let line = GutterLineEdit::from_input_edit(&before, &edit);
|
||||
assert_eq!(line.start_line, 1);
|
||||
assert_eq!(line.old_end_line, 1);
|
||||
assert_eq!(line.new_end_line, 1);
|
||||
assert!(line.starts_at_line_start);
|
||||
assert!(line.start_line_empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_offset_follows_scroll_and_line_height() {
|
||||
assert_eq!(row_offset(0, px(20.), px(0.)), px(0.));
|
||||
|
||||
+361
-46
@@ -21,12 +21,13 @@ use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
use globset::{Glob, GlobSet, GlobSetBuilder};
|
||||
use gpui_kit::base::input::{InputEdit, Rope, RopeExt};
|
||||
use lsp_types::{
|
||||
CompletionItem, Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString, Position, Uri,
|
||||
};
|
||||
@@ -167,18 +168,66 @@ struct ActiveDocument {
|
||||
uri: Uri,
|
||||
language_id: String,
|
||||
version: i32,
|
||||
text: String,
|
||||
/// Версия содержимого из [`crate::editor::Document`].
|
||||
content_version: u64,
|
||||
text: Rope,
|
||||
}
|
||||
|
||||
/// Как сервер просит присылать изменения документа.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum TextSyncKind {
|
||||
Full,
|
||||
Incremental,
|
||||
}
|
||||
|
||||
impl TextSyncKind {
|
||||
fn from_initialize(result: &Value) -> Self {
|
||||
let change = result
|
||||
.pointer("/capabilities/textDocumentSync")
|
||||
.and_then(|sync| match sync {
|
||||
Value::Number(number) => number.as_u64(),
|
||||
Value::Object(object) => object.get("change").and_then(Value::as_u64),
|
||||
_ => None,
|
||||
});
|
||||
match change {
|
||||
Some(2) => Self::Incremental,
|
||||
_ => Self::Full,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_u8(self) -> u8 {
|
||||
match self {
|
||||
Self::Full => 1,
|
||||
Self::Incremental => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_u8(value: u8) -> Self {
|
||||
if value == 2 {
|
||||
Self::Incremental
|
||||
} else {
|
||||
Self::Full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PendingMap = Mutex<HashMap<u64, mpsc::Sender<Result<Value, String>>>>;
|
||||
type LogBuffer = Arc<Mutex<VecDeque<String>>>;
|
||||
|
||||
/// Диагностика, опубликованная одним сервером для документа.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct ServerDiagnostics {
|
||||
/// Версия документа из `publishDiagnostics`, если сервер её прислал.
|
||||
version: Option<i32>,
|
||||
diagnostics: Vec<lsp_types::Diagnostic>,
|
||||
}
|
||||
|
||||
/// Диагностика, опубликованная серверами, сгруппированная по документу.
|
||||
///
|
||||
/// Внутренняя карта хранит сообщения по имени сервера: так остановка одного
|
||||
/// сервера убирает только его сообщения, а не всю диагностику документа.
|
||||
struct DiagnosticsStore {
|
||||
by_uri: Mutex<HashMap<String, HashMap<String, Vec<lsp_types::Diagnostic>>>>,
|
||||
by_uri: Mutex<HashMap<String, HashMap<String, ServerDiagnostics>>>,
|
||||
/// Номер поколения: растёт при каждом изменении.
|
||||
revision: AtomicU64,
|
||||
/// Задача вида, которую надо разбудить после изменения.
|
||||
@@ -200,27 +249,50 @@ impl DiagnosticsStore {
|
||||
/// Заменяет набор диагностики сервера для документа.
|
||||
///
|
||||
/// Пустой список (сервер прислал `[]`) удаляет сообщения сервера.
|
||||
fn replace(&self, server: &str, uri: &str, diagnostics: Vec<lsp_types::Diagnostic>) {
|
||||
{
|
||||
/// Сообщение с версией старше уже сохранённой игнорируется: ответ
|
||||
/// сервера мог прийти после более нового `didChange`.
|
||||
fn replace(
|
||||
&self,
|
||||
server: &str,
|
||||
uri: &str,
|
||||
version: Option<i32>,
|
||||
diagnostics: Vec<lsp_types::Diagnostic>,
|
||||
) {
|
||||
let changed = {
|
||||
let mut by_uri = self.by_uri.lock();
|
||||
if diagnostics.is_empty() {
|
||||
let Some(by_server) = by_uri.get_mut(uri) else {
|
||||
return;
|
||||
};
|
||||
if by_server.remove(server).is_none() {
|
||||
let Some(previous) = by_server.get(server) else {
|
||||
return;
|
||||
};
|
||||
if is_stale(version, previous.version) {
|
||||
return;
|
||||
}
|
||||
by_server.remove(server);
|
||||
if by_server.is_empty() {
|
||||
by_uri.remove(uri);
|
||||
}
|
||||
true
|
||||
} else {
|
||||
by_uri
|
||||
.entry(uri.to_owned())
|
||||
.or_default()
|
||||
.insert(server.to_owned(), diagnostics);
|
||||
let by_server = by_uri.entry(uri.to_owned()).or_default();
|
||||
let previous = by_server.get(server);
|
||||
if previous.is_some_and(|previous| is_stale(version, previous.version)) {
|
||||
return;
|
||||
}
|
||||
let next = ServerDiagnostics {
|
||||
version,
|
||||
diagnostics,
|
||||
};
|
||||
let changed = previous != Some(&next);
|
||||
by_server.insert(server.to_owned(), next);
|
||||
changed
|
||||
}
|
||||
};
|
||||
if changed {
|
||||
self.notify();
|
||||
}
|
||||
self.notify();
|
||||
}
|
||||
|
||||
/// Удаляет диагностику документа целиком (например, при закрытии вкладки).
|
||||
@@ -253,7 +325,12 @@ impl DiagnosticsStore {
|
||||
self.by_uri
|
||||
.lock()
|
||||
.get(uri)
|
||||
.map(|by_server| by_server.values().flatten().cloned().collect::<Vec<_>>())
|
||||
.map(|by_server| {
|
||||
by_server
|
||||
.values()
|
||||
.flat_map(|entry| entry.diagnostics.iter().cloned())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -265,15 +342,19 @@ impl DiagnosticsStore {
|
||||
let by_uri = self.by_uri.lock();
|
||||
let mut entries = Vec::new();
|
||||
for (uri, by_server) in by_uri.iter() {
|
||||
for (server, diagnostics) in by_server.iter() {
|
||||
if diagnostics.is_empty() {
|
||||
for (server, entry) in by_server.iter() {
|
||||
if entry.diagnostics.is_empty() {
|
||||
continue;
|
||||
}
|
||||
entries.push(DocumentDiagnostics {
|
||||
server: server.clone(),
|
||||
uri: uri.clone(),
|
||||
path: uri_to_path(uri).map(|path| path.to_string_lossy().into_owned()),
|
||||
diagnostics: diagnostics.iter().map(DiagnosticInfo::from_lsp).collect(),
|
||||
diagnostics: entry
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(DiagnosticInfo::from_lsp)
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -302,6 +383,10 @@ impl DiagnosticsStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_stale(incoming: Option<i32>, stored: Option<i32>) -> bool {
|
||||
matches!((incoming, stored), (Some(incoming), Some(stored)) if incoming < stored)
|
||||
}
|
||||
|
||||
/// Менеджер всех запущенных LSP-серверов.
|
||||
///
|
||||
/// Живёт в `Arc`, потому что его одновременно читают UI, Lua-API и фоновые
|
||||
@@ -353,10 +438,17 @@ impl LspManager {
|
||||
// `initialize` — единственный запрос, который ждём прямо здесь:
|
||||
// без ответа сервер нельзя считать рабочим.
|
||||
let initialize = server.request("initialize", initialize_params(&config))?;
|
||||
if let Err(err) = wait_response(initialize, Duration::from_secs(30)) {
|
||||
server.shutdown();
|
||||
return Err(format!("initialize «{}»: {err}", config.name));
|
||||
}
|
||||
let initialize_result = match wait_response(initialize, Duration::from_secs(30)) {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
server.shutdown();
|
||||
return Err(format!("initialize «{}»: {err}", config.name));
|
||||
}
|
||||
};
|
||||
server.sync_kind.store(
|
||||
TextSyncKind::from_initialize(&initialize_result).as_u8(),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
|
||||
if let Err(err) = server.notify("initialized", json!({})) {
|
||||
server.shutdown();
|
||||
@@ -371,7 +463,7 @@ impl LspManager {
|
||||
&document.uri,
|
||||
&document.language_id,
|
||||
document.version,
|
||||
&document.text,
|
||||
&document.text.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -434,47 +526,117 @@ impl LspManager {
|
||||
self.servers.lock().is_empty()
|
||||
}
|
||||
|
||||
/// Синхронизирует активный документ со всеми подходящими серверами.
|
||||
/// Синхронизирует документ со всеми подходящими серверами.
|
||||
///
|
||||
/// Первый вызов для документа отправляет `didOpen`, последующие с
|
||||
/// изменившимся текстом — `didChange` с новой версией.
|
||||
pub fn sync_document(&self, uri: Uri, language_id: String, text: &str) {
|
||||
let version = {
|
||||
/// Если `changes` описывают ровно следующий шаг (`content_version` на
|
||||
/// единицу новее сохранённой), серверам с инкрементальной синхронизацией
|
||||
/// отправляются диапазоны; остальные получают полный текст.
|
||||
pub fn sync_document_changes(
|
||||
&self,
|
||||
uri: Uri,
|
||||
language_id: String,
|
||||
content_version: u64,
|
||||
text: &Rope,
|
||||
changes: &[(InputEdit, Rope)],
|
||||
) {
|
||||
let (version, incremental) = {
|
||||
let mut active = self.active.lock();
|
||||
match active.as_mut() {
|
||||
Some(document) if document.uri == uri => {
|
||||
if document.text == text {
|
||||
if document.language_id != language_id {
|
||||
document.language_id = language_id;
|
||||
}
|
||||
if document.content_version == content_version
|
||||
&& document.language_id == language_id
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let incremental = !changes.is_empty()
|
||||
&& document.content_version.wrapping_add(1) == content_version;
|
||||
let pending = if incremental {
|
||||
Some(build_content_changes(&document.text, changes))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
document.version = document.version.wrapping_add(1);
|
||||
document.text = text.to_owned();
|
||||
document.language_id = language_id.clone();
|
||||
document.version
|
||||
document.content_version = content_version;
|
||||
document.text = text.clone();
|
||||
(document.version, pending)
|
||||
}
|
||||
_ => {
|
||||
let version = active
|
||||
.as_ref()
|
||||
.map(|document| document.version.wrapping_add(1))
|
||||
.unwrap_or(1);
|
||||
*active = Some(ActiveDocument {
|
||||
uri: uri.clone(),
|
||||
language_id: language_id.clone(),
|
||||
version: 1,
|
||||
text: text.to_owned(),
|
||||
version,
|
||||
content_version,
|
||||
text: text.clone(),
|
||||
});
|
||||
1
|
||||
(version, None)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.send_sync(&uri, &language_id, version, text, incremental.as_deref());
|
||||
}
|
||||
|
||||
/// Синхронизирует документ целиком.
|
||||
///
|
||||
/// Используется тестами и запуском сервера: инкрементальные правки
|
||||
/// приходят только из редактора.
|
||||
#[cfg(test)]
|
||||
pub fn sync_document(&self, uri: Uri, language_id: String, text: &str) {
|
||||
let text = Rope::from(text);
|
||||
let version = {
|
||||
let mut active = self.active.lock();
|
||||
let version = active
|
||||
.as_ref()
|
||||
.map(|document| document.version.wrapping_add(1))
|
||||
.unwrap_or(1);
|
||||
let content_version = active
|
||||
.as_ref()
|
||||
.map(|document| document.content_version.wrapping_add(1))
|
||||
.unwrap_or(0);
|
||||
*active = Some(ActiveDocument {
|
||||
uri: uri.clone(),
|
||||
language_id: language_id.clone(),
|
||||
version,
|
||||
content_version,
|
||||
text: text.clone(),
|
||||
});
|
||||
version
|
||||
};
|
||||
self.send_sync(&uri, &language_id, version, &text, None);
|
||||
}
|
||||
|
||||
fn send_sync(
|
||||
&self,
|
||||
uri: &Uri,
|
||||
language_id: &str,
|
||||
version: i32,
|
||||
text: &Rope,
|
||||
incremental: Option<&[Value]>,
|
||||
) {
|
||||
let mut full_text = None;
|
||||
for server in self.servers.lock().values() {
|
||||
if !server.serves(&language_id) {
|
||||
if !server.serves(language_id) {
|
||||
continue;
|
||||
}
|
||||
if server.is_opened(&uri) {
|
||||
server.did_change(&uri, version, text);
|
||||
if !server.is_opened(uri) {
|
||||
let text = full_text.get_or_insert_with(|| text.to_string());
|
||||
server.did_open(uri, language_id, version, text);
|
||||
} else if server.sync_kind() == TextSyncKind::Incremental {
|
||||
if let Some(changes) = incremental {
|
||||
server.did_change_ranges(uri, version, changes);
|
||||
} else {
|
||||
let text = full_text.get_or_insert_with(|| text.to_string());
|
||||
server.did_change_full(uri, version, text);
|
||||
}
|
||||
} else {
|
||||
server.did_open(&uri, &language_id, version, text);
|
||||
let text = full_text.get_or_insert_with(|| text.to_string());
|
||||
server.did_change_full(uri, version, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,7 +678,7 @@ impl LspManager {
|
||||
"textDocument/completion",
|
||||
json!({
|
||||
"textDocument": { "uri": document.uri.as_str() },
|
||||
"position": offset_to_position(&document.text, offset),
|
||||
"position": offset_to_position_rope(&document.text, offset),
|
||||
"context": { "triggerKind": 1 },
|
||||
}),
|
||||
)
|
||||
@@ -537,7 +699,7 @@ impl LspManager {
|
||||
"textDocument/hover",
|
||||
json!({
|
||||
"textDocument": { "uri": document.uri.as_str() },
|
||||
"position": offset_to_position(&document.text, offset),
|
||||
"position": offset_to_position_rope(&document.text, offset),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -597,7 +759,7 @@ impl LspManager {
|
||||
/// Кладёт диагностику в хранилище от имени тестового сервера.
|
||||
#[cfg(test)]
|
||||
pub fn set_diagnostics_for_test(&self, uri: &str, diagnostics: Vec<lsp_types::Diagnostic>) {
|
||||
self.diagnostics.replace("test", uri, diagnostics);
|
||||
self.diagnostics.replace("test", uri, None, diagnostics);
|
||||
}
|
||||
|
||||
/// Последние строки журнала серверов.
|
||||
@@ -635,6 +797,8 @@ struct LspServer {
|
||||
opened: Mutex<HashSet<String>>,
|
||||
watcher: Mutex<Option<RecommendedWatcher>>,
|
||||
alive: AtomicBool,
|
||||
/// Как сервер просит синхронизировать документ (`textDocumentSync.change`).
|
||||
sync_kind: AtomicU8,
|
||||
log: LogBuffer,
|
||||
diagnostics: Arc<DiagnosticsStore>,
|
||||
}
|
||||
@@ -685,6 +849,7 @@ impl LspServer {
|
||||
opened: Mutex::new(HashSet::new()),
|
||||
watcher: Mutex::new(None),
|
||||
alive: AtomicBool::new(true),
|
||||
sync_kind: AtomicU8::new(TextSyncKind::Full.as_u8()),
|
||||
log: log.clone(),
|
||||
diagnostics,
|
||||
});
|
||||
@@ -707,6 +872,10 @@ impl LspServer {
|
||||
self.opened.lock().contains(uri.as_str())
|
||||
}
|
||||
|
||||
fn sync_kind(&self) -> TextSyncKind {
|
||||
TextSyncKind::from_u8(self.sync_kind.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn did_open(&self, uri: &Uri, language_id: &str, version: i32, text: &str) {
|
||||
let params = json!({
|
||||
"textDocument": {
|
||||
@@ -721,7 +890,7 @@ impl LspServer {
|
||||
}
|
||||
}
|
||||
|
||||
fn did_change(&self, uri: &Uri, version: i32, text: &str) {
|
||||
fn did_change_full(&self, uri: &Uri, version: i32, text: &str) {
|
||||
let params = json!({
|
||||
"textDocument": { "uri": uri.as_str(), "version": version },
|
||||
"contentChanges": [{ "text": text }],
|
||||
@@ -729,6 +898,14 @@ impl LspServer {
|
||||
let _ = self.notify("textDocument/didChange", params);
|
||||
}
|
||||
|
||||
fn did_change_ranges(&self, uri: &Uri, version: i32, changes: &[Value]) {
|
||||
let params = json!({
|
||||
"textDocument": { "uri": uri.as_str(), "version": version },
|
||||
"contentChanges": changes,
|
||||
});
|
||||
let _ = self.notify("textDocument/didChange", params);
|
||||
}
|
||||
|
||||
fn did_save(&self, uri: &Uri, text: &str) {
|
||||
let params = json!({
|
||||
"textDocument": { "uri": uri.as_str() },
|
||||
@@ -865,10 +1042,15 @@ impl LspServer {
|
||||
.collect::<Vec<lsp_types::Diagnostic>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let version = message
|
||||
.pointer("/params/version")
|
||||
.and_then(Value::as_i64)
|
||||
.map(|version| version as i32);
|
||||
if !diagnostics.is_empty() {
|
||||
self.log(format!("диагностика {uri}: {}", diagnostics.len()));
|
||||
}
|
||||
self.diagnostics.replace(&self.name, uri, diagnostics);
|
||||
self.diagnostics
|
||||
.replace(&self.name, uri, version, diagnostics);
|
||||
}
|
||||
"window/logMessage" | "window/showMessage" | "window/logTrace" => {
|
||||
if let Some(text) = message.pointer("/params/message").and_then(Value::as_str) {
|
||||
@@ -1316,6 +1498,48 @@ pub fn document_uri(name: &str) -> Option<Uri> {
|
||||
Some(path_to_uri(&path))
|
||||
}
|
||||
|
||||
/// Собирает `contentChanges` для инкрементального `didChange`.
|
||||
///
|
||||
/// Позиции каждой правки считаются по тексту до неё: для первой правки это
|
||||
/// сохранённый текст, для следующих — rope после предыдущей правки.
|
||||
fn build_content_changes(before: &Rope, changes: &[(InputEdit, Rope)]) -> Vec<Value> {
|
||||
let mut result = Vec::with_capacity(changes.len());
|
||||
for (index, (change, after)) in changes.iter().enumerate() {
|
||||
let text_before = if index == 0 {
|
||||
before
|
||||
} else {
|
||||
&changes[index - 1].1
|
||||
};
|
||||
let start = offset_to_position_rope(text_before, change.start_byte);
|
||||
let end = offset_to_position_rope(text_before, change.old_end_byte);
|
||||
let inserted = after
|
||||
.slice(change.start_byte..change.new_end_byte)
|
||||
.to_string();
|
||||
result.push(json!({
|
||||
"range": { "start": start, "end": end },
|
||||
"text": inserted,
|
||||
}));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Переводит байтовое смещение в LSP-позицию по rope.
|
||||
fn offset_to_position_rope(text: &Rope, offset: usize) -> Position {
|
||||
let point = text.offset_to_point(offset);
|
||||
let line = text.slice_line(point.row);
|
||||
let character = line.byte_to_utf16_idx(point.column.min(line.len()));
|
||||
Position::new(point.row as u32, character as u32)
|
||||
}
|
||||
|
||||
/// Переводит LSP-позицию в байтовое смещение по rope.
|
||||
pub fn position_to_offset_rope(text: &Rope, position: Position) -> usize {
|
||||
let line_index = position.line as usize;
|
||||
let line = text.slice_line(line_index);
|
||||
let line_start = text.line_start_offset(line_index);
|
||||
let byte_in_line = line.utf16_to_byte_idx((position.character as usize).min(line.len_utf16()));
|
||||
line_start + byte_in_line
|
||||
}
|
||||
|
||||
/// Переводит байтовое смещение в LSP-позицию (`line`/`character` в UTF-16).
|
||||
pub fn offset_to_position(text: &str, offset: usize) -> Position {
|
||||
let mut offset = offset.min(text.len());
|
||||
@@ -1380,6 +1604,66 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
use gpui_kit::base::input::Point;
|
||||
|
||||
#[test]
|
||||
fn incremental_changes_use_positions_before_each_edit() {
|
||||
let before = Rope::from("a\nbc\nd");
|
||||
let after_first = Rope::from("a\nbcX\nd");
|
||||
let after_second = Rope::from("a\nbcX\nde");
|
||||
let changes = [
|
||||
(
|
||||
InputEdit {
|
||||
start_byte: 4,
|
||||
old_end_byte: 4,
|
||||
new_end_byte: 5,
|
||||
start_position: Point::new(1, 2),
|
||||
old_end_position: Point::new(1, 2),
|
||||
new_end_position: Point::new(1, 3),
|
||||
},
|
||||
after_first.clone(),
|
||||
),
|
||||
(
|
||||
InputEdit {
|
||||
start_byte: 6,
|
||||
old_end_byte: 7,
|
||||
new_end_byte: 8,
|
||||
start_position: Point::new(2, 0),
|
||||
old_end_position: Point::new(2, 1),
|
||||
new_end_position: Point::new(2, 2),
|
||||
},
|
||||
after_second,
|
||||
),
|
||||
];
|
||||
|
||||
let json = build_content_changes(&before, &changes);
|
||||
assert_eq!(
|
||||
json[0]["range"]["start"],
|
||||
json!({"line": 1, "character": 2})
|
||||
);
|
||||
assert_eq!(json[0]["range"]["end"], json!({"line": 1, "character": 2}));
|
||||
assert_eq!(json[0]["text"], "X");
|
||||
assert_eq!(
|
||||
json[1]["range"]["start"],
|
||||
json!({"line": 2, "character": 0})
|
||||
);
|
||||
assert_eq!(json[1]["range"]["end"], json!({"line": 2, "character": 1}));
|
||||
assert_eq!(json[1]["text"], "de");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rope_positions_use_utf16_columns() {
|
||||
let text = Rope::from("a😀b\nпривет");
|
||||
let byte_after_emoji = 5;
|
||||
let position = offset_to_position_rope(&text, byte_after_emoji);
|
||||
assert_eq!(position, Position::new(0, 3));
|
||||
assert_eq!(position_to_offset_rope(&text, position), byte_after_emoji);
|
||||
|
||||
let pos = Position::new(1, 3);
|
||||
let offset = position_to_offset_rope(&text, pos);
|
||||
assert_eq!(offset_to_position_rope(&text, offset), pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_content_length_framed_message() {
|
||||
let input = b"Content-Length: 13\r\n\r\n{\"jsonrpc\":1}";
|
||||
@@ -1403,16 +1687,21 @@ mod tests {
|
||||
let revision = store.revision();
|
||||
assert_eq!(revision, 0);
|
||||
|
||||
store.replace("rust-analyzer", "file:///a.rs", vec![diagnostic("error")]);
|
||||
store.replace(
|
||||
"rust-analyzer",
|
||||
"file:///a.rs",
|
||||
None,
|
||||
vec![diagnostic("error")],
|
||||
);
|
||||
assert!(store.revision() > revision);
|
||||
assert_eq!(store.diagnostics("file:///a.rs").len(), 1);
|
||||
|
||||
// Второй сервер добавляет своё, не затирая чужое.
|
||||
store.replace("other", "file:///a.rs", vec![diagnostic("warning")]);
|
||||
store.replace("other", "file:///a.rs", None, vec![diagnostic("warning")]);
|
||||
assert_eq!(store.diagnostics("file:///a.rs").len(), 2);
|
||||
|
||||
// Пустой список удаляет только сообщения этого сервера.
|
||||
store.replace("rust-analyzer", "file:///a.rs", Vec::new());
|
||||
store.replace("rust-analyzer", "file:///a.rs", None, Vec::new());
|
||||
let left = store.diagnostics("file:///a.rs");
|
||||
assert_eq!(left.len(), 1);
|
||||
assert_eq!(left[0].message, "warning");
|
||||
@@ -1421,6 +1710,32 @@ mod tests {
|
||||
assert!(store.diagnostics("file:///a.rs").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_store_ignores_stale_and_identical_publishes() {
|
||||
let store = DiagnosticsStore::new();
|
||||
let diagnostic = |message: &str| lsp_types::Diagnostic {
|
||||
range: lsp_types::Range::new(Position::new(0, 0), Position::new(0, 1)),
|
||||
message: message.to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
store.replace("server", "file:///a.rs", Some(2), vec![diagnostic("new")]);
|
||||
let revision = store.revision();
|
||||
|
||||
// Та же диагностика не должна будить вид.
|
||||
store.replace("server", "file:///a.rs", Some(2), vec![diagnostic("new")]);
|
||||
assert_eq!(store.revision(), revision);
|
||||
|
||||
// Ответ для более старой версии документа игнорируется.
|
||||
store.replace("server", "file:///a.rs", Some(1), vec![diagnostic("old")]);
|
||||
assert_eq!(store.revision(), revision);
|
||||
assert_eq!(store.diagnostics("file:///a.rs")[0].message, "new");
|
||||
|
||||
// Пустая диагностика старой версии тоже не должна стирать новую.
|
||||
store.replace("server", "file:///a.rs", Some(1), Vec::new());
|
||||
assert_eq!(store.diagnostics("file:///a.rs").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_use_utf16_columns() {
|
||||
let text = "a\nβγ\n🙂x";
|
||||
|
||||
+106
-17
@@ -8,6 +8,7 @@ use std::cell::RefCell;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui_kit::base::input::Rope;
|
||||
use gpui_kit::{Keystroke, Modifiers, TestAppContext, point, px};
|
||||
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
|
||||
|
||||
@@ -115,7 +116,7 @@ fn revision_changes_only_for_external_edits() {
|
||||
let revision = state.revision();
|
||||
|
||||
// Набор текста в виджете ревизию не двигает: редактор уже показывает текст.
|
||||
state.replace_active_content("typed".to_owned());
|
||||
state.replace_active_content(&Rope::from("typed"), &[], false);
|
||||
assert_eq!(state.revision(), revision);
|
||||
|
||||
// Внешняя замена (Lua) — двигает.
|
||||
@@ -410,7 +411,7 @@ fn api_save_writes_active_tab_to_file_named_after_it() {
|
||||
let mut state = workspace.borrow_mut();
|
||||
let doc = state.active_mut();
|
||||
doc.name = path.to_string_lossy().into_owned();
|
||||
doc.content = "fn main() {}\n".to_owned();
|
||||
doc.set_content(Rope::from("fn main() {}\n"));
|
||||
}
|
||||
assert!(workspace.borrow().active().is_dirty());
|
||||
|
||||
@@ -1059,7 +1060,7 @@ fn lua_script_can_include_files_and_control_the_app() {
|
||||
|
||||
let state = workspace.borrow();
|
||||
assert_eq!(
|
||||
state.documents()[0].content,
|
||||
state.documents()[0].content_string(),
|
||||
"from lua",
|
||||
"include должен выполнить подключённый файл в общем окружении"
|
||||
);
|
||||
@@ -1091,7 +1092,7 @@ fn api_opens_file_from_disk_and_detects_language() {
|
||||
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().content_string(), "package main\n");
|
||||
assert_eq!(state.active().language, Some(Language::Go));
|
||||
assert!(!state.active().is_dirty());
|
||||
}
|
||||
@@ -1193,7 +1194,7 @@ fn lua_editor_focus_requests_focus_without_changing_documents() {
|
||||
workspace.borrow_mut().take_focus_after_sync(),
|
||||
Some(FocusAfterSync::Editor(None))
|
||||
);
|
||||
assert_eq!(workspace.borrow().active().content, "unchanged");
|
||||
assert_eq!(workspace.borrow().active().content_string(), "unchanged");
|
||||
|
||||
drop(engine);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -1211,7 +1212,7 @@ fn api_open_missing_file_creates_an_empty_buffer() {
|
||||
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!(state.active().content().len() == 0);
|
||||
assert_eq!(state.active().language, Some(Language::Go));
|
||||
assert!(!state.active().is_dirty());
|
||||
drop(state);
|
||||
@@ -1242,7 +1243,7 @@ fn lua_app_open_opens_a_file_and_returns_its_tab() {
|
||||
|
||||
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().content_string(), "package main\n");
|
||||
assert_eq!(state.active().language, Some(Language::Go));
|
||||
drop(state);
|
||||
|
||||
@@ -1271,7 +1272,10 @@ fn lua_engine_loads_init_from_a_custom_config_dir() {
|
||||
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
|
||||
engine.run_startup().expect("init.lua из своего каталога");
|
||||
|
||||
assert_eq!(workspace.borrow().active().content, "custom config");
|
||||
assert_eq!(
|
||||
workspace.borrow().active().content_string(),
|
||||
"custom config"
|
||||
);
|
||||
|
||||
drop(engine);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -1301,7 +1305,7 @@ fn lua_key_bindings_match_press_release_and_modifiers() {
|
||||
.run_file(&script)
|
||||
.expect("привязки должны назначиться");
|
||||
|
||||
let text = || workspace.borrow().active().content.clone();
|
||||
let text = || workspace.borrow().active().content_string();
|
||||
let stroke = |key: &str, modifiers: gpui_kit::Modifiers| Keystroke {
|
||||
modifiers,
|
||||
key: key.to_owned(),
|
||||
@@ -1672,7 +1676,10 @@ fn lua_key_bindings_receive_keys_while_editor_is_focused(cx: &mut TestAppContext
|
||||
visual.simulate_keystrokes("alt-r");
|
||||
visual.run_until_parked();
|
||||
|
||||
assert_eq!(workspace.borrow().active().content, "alt r pressed");
|
||||
assert_eq!(
|
||||
workspace.borrow().active().content_string(),
|
||||
"alt r pressed"
|
||||
);
|
||||
|
||||
drop(engine);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -1930,7 +1937,7 @@ fn lua_key_modes_are_per_object_and_dynamic() {
|
||||
}
|
||||
}
|
||||
};
|
||||
let text = || workspace.borrow().active().content.clone();
|
||||
let text = || workspace.borrow().active().content_string();
|
||||
|
||||
// Глобальная карта в режиме i: "-" не действует, "i" и "" — действуют.
|
||||
fire(&keymap, "a");
|
||||
@@ -2314,13 +2321,95 @@ fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
|
||||
let state = workspace.borrow();
|
||||
let doc = state.active();
|
||||
assert!(
|
||||
doc.content.contains("ZZZ"),
|
||||
doc.content_string().contains("ZZZ"),
|
||||
"введённый текст не попал в документ: {:?}",
|
||||
doc.content
|
||||
doc.content_string()
|
||||
);
|
||||
assert!(doc.is_dirty(), "правка должна помечать документ изменённым");
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn new_line_in_editor_shifts_gutter_markers(cx: &mut TestAppContext) {
|
||||
cx.update(gpui_kit::init);
|
||||
|
||||
let mut workspace = None;
|
||||
let (app, visual) = cx.add_window_view(|window, cx| {
|
||||
let app = MyApp::new(window, cx);
|
||||
workspace = Some(app.workspace_handle());
|
||||
app
|
||||
});
|
||||
let workspace = workspace.expect("вид создан");
|
||||
|
||||
let text = "a\nb\nc\nd";
|
||||
visual.update(|window, cx| {
|
||||
app.update(cx, |this, cx| {
|
||||
{
|
||||
let workspace = this.workspace_handle();
|
||||
let mut workspace = workspace.borrow_mut();
|
||||
workspace.replace_active_content(&Rope::from(text), &[], false);
|
||||
workspace
|
||||
.active_mut()
|
||||
.gutter
|
||||
.set(1, "diagnostics", "circle", None, None);
|
||||
workspace
|
||||
.active_mut()
|
||||
.gutter
|
||||
.set(3, "breakpoint", "play", None, None);
|
||||
}
|
||||
this.editor_handle().update(cx, |state, cx| {
|
||||
state.set_value(text, window, cx);
|
||||
// Курсор в конце второй строки (после «b»).
|
||||
state.set_selected_range(3..3, cx);
|
||||
state.focus(window, cx);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчик переноса получает тип и номера строк и ставит новую иконку.
|
||||
let dir = temp_script_dir("gutter-shift-callback");
|
||||
let script = dir.join("shift.lua");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"
|
||||
api.editor:on_gutter_shift("breakpoint", function(kind, old_line, new_line)
|
||||
assert(kind == "breakpoint")
|
||||
assert(old_line == 3)
|
||||
assert(new_line == 4)
|
||||
api.editor:set_gutter_icon(new_line, "circle", "shifted")
|
||||
end)
|
||||
"#,
|
||||
)
|
||||
.expect("shift.lua");
|
||||
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
|
||||
engine.run_file(&script).expect("on_gutter_shift");
|
||||
|
||||
visual.simulate_keystrokes("enter");
|
||||
visual.run_until_parked();
|
||||
|
||||
let workspace = workspace.borrow();
|
||||
let gutter = &workspace.active().gutter;
|
||||
assert!(
|
||||
gutter.get(1, "diagnostics").is_some(),
|
||||
"иконка выше вставки осталась на месте"
|
||||
);
|
||||
assert!(
|
||||
gutter.get(3, "breakpoint").is_none(),
|
||||
"иконка ниже вставки должна уехать"
|
||||
);
|
||||
assert!(
|
||||
gutter.get(4, "breakpoint").is_some(),
|
||||
"иконка ниже вставки должна уехать вниз"
|
||||
);
|
||||
assert!(
|
||||
gutter.get(4, "shifted").is_some(),
|
||||
"Lua-обработчик переноса должен получить новую строку"
|
||||
);
|
||||
|
||||
drop(workspace);
|
||||
drop(engine);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn floating_input_blocks_keys_and_submits_on_enter(cx: &mut TestAppContext) {
|
||||
cx.update(gpui_kit::init);
|
||||
@@ -2373,13 +2462,13 @@ fn floating_input_blocks_keys_and_submits_on_enter(cx: &mut TestAppContext) {
|
||||
// Пока окно открыто, привязки приложения не срабатывают.
|
||||
visual.simulate_keystrokes("alt-r");
|
||||
visual.run_until_parked();
|
||||
assert_eq!(workspace.borrow().active().content, "");
|
||||
assert_eq!(workspace.borrow().active().content_string(), "");
|
||||
|
||||
// Esc закрывает окно без колбэка.
|
||||
visual.simulate_keystrokes("escape");
|
||||
visual.run_until_parked();
|
||||
assert!(!prompt_open(visual), "Esc должен закрыть поле ввода");
|
||||
assert_eq!(workspace.borrow().active().content, "");
|
||||
assert_eq!(workspace.borrow().active().content_string(), "");
|
||||
|
||||
// Клик вне окна тоже закрывает его без колбэка.
|
||||
engine
|
||||
@@ -2393,7 +2482,7 @@ fn floating_input_blocks_keys_and_submits_on_enter(cx: &mut TestAppContext) {
|
||||
!prompt_open(visual),
|
||||
"клик вне окна должен закрыть поле ввода"
|
||||
);
|
||||
assert_eq!(workspace.borrow().active().content, "");
|
||||
assert_eq!(workspace.borrow().active().content_string(), "");
|
||||
|
||||
// Enter подтверждает ввод и вызывает колбэк уже после закрытия окна.
|
||||
engine
|
||||
@@ -2407,7 +2496,7 @@ fn floating_input_blocks_keys_and_submits_on_enter(cx: &mut TestAppContext) {
|
||||
visual.run_until_parked();
|
||||
|
||||
assert!(!prompt_open(visual));
|
||||
assert_eq!(workspace.borrow().active().content, "main.go");
|
||||
assert_eq!(workspace.borrow().active().content_string(), "main.go");
|
||||
|
||||
drop(engine);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
Reference in New Issue
Block a user