diff --git a/AGENTS.md b/AGENTS.md index ca02b28..c819d61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). ## Соглашения diff --git a/Cargo.lock b/Cargo.lock index 6e95cce..3eda626 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index ef73fe0..b3a5797 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/docs/editor.md b/docs/editor.md index 25182c6..53da467 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -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` сбрасывает иконки активной вкладки: их номера +строк относятся к старому тексту. ## Текст diff --git a/init.lua b/init.lua index 058f727..9c037e1 100644 --- a/init.lua +++ b/init.lua @@ -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") -- включить подсветку @@ -665,4 +669,4 @@ api.panels.toolbar:add_button("Запустить", "play", nil, "run", "right", end) api.editor:set_gutter_icon(2, "play", "demo", { color = "#efefe6" }) -api.editor:set_gutter_icon(90, "play", "demo") +api.editor:set_gutter_icon(90, "play", "demo") \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index 5ea4b9d..e1cb222 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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>>, #[lua(skip)] gutter_clicks: GutterClickHandlers, + #[lua(skip)] + gutter_shifts: GutterShiftHandlers, } impl EditorApi { @@ -203,6 +212,7 @@ impl EditorApi { lsp: Arc, diagnostics_handler: Rc>>, 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) { - 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) { + set_gutter_handler(&self.gutter_shifts, kind, callback); } /// Включает подсветку синтаксиса для вкладки `index`. @@ -2038,6 +2058,30 @@ fn parse_toolbar_color(color: Option) -> mlua::Result> { }) } +/// Устанавливает или снимает обработчик gutter в реестре по типу иконки. +/// +/// Реестры клика и переноса имеют одинаковую форму, поэтому используют один +/// помощник. +fn set_gutter_handler( + handlers: &Rc>>, + kind: String, + callback: Option, +) { + 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` означают «цвет не задан», а diff --git a/src/app.rs b/src/app.rs index f76bb99..fe7606e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, /// Ревизия [`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, ) { 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) -> 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::>(), - 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, - markers: Vec<(usize, GutterMarker)>, + /// Документы, из которых берутся иконки активной вкладки. + /// + /// Gutter читает модель в `prepaint`, поэтому вид не копирует все + /// маркеры документа на каждый кадр. + workspace: Rc>, + /// Документ, для которого построена колонка. + document_id: u64, /// Иконка, на которой сейчас находится мышь, если она известна. hovered: Option, 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::>(); 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> { - 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> { + 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) diff --git a/src/completion.rs b/src/completion.rs index 51a77cf..1be85cd 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -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 { + 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 { 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)"); diff --git a/src/editor.rs b/src/editor.rs index 9881b76..e38497f 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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, content: impl Into) -> 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 { + 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) { - self.active_mut().content = text.into(); + self.active_mut().set_content(Rope::from(text.into())); self.bump(); } diff --git a/src/gutter.rs b/src/gutter.rs index 8761d2a..485c8c4 100644 --- a/src/gutter.rs +++ b/src/gutter.rs @@ -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>>; + +/// Создаёт пустой реестр обработчиков переноса. +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, + /// Самое большое число иконок на одной строке. + /// + /// Поддерживается при изменении иконок, чтобы вид не перебирал все строки + /// на каждый кадр. + 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 { + 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 { + 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 { + self.apply_edit_with_shifts(edit, true) + } + + fn apply_edit_with_shifts( + &mut self, + edit: GutterLineEdit, + collect_shifts: bool, + ) -> Vec { + 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 { 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, + ) -> impl Iterator { + 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::>(), + [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::>(); + 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.)); diff --git a/src/lsp.rs b/src/lsp.rs index ab0f417..8839074 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -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>>>; type LogBuffer = Arc>>; +/// Диагностика, опубликованная одним сервером для документа. +#[derive(Clone, Debug, Default, PartialEq)] +struct ServerDiagnostics { + /// Версия документа из `publishDiagnostics`, если сервер её прислал. + version: Option, + diagnostics: Vec, +} + /// Диагностика, опубликованная серверами, сгруппированная по документу. /// /// Внутренняя карта хранит сообщения по имени сервера: так остановка одного /// сервера убирает только его сообщения, а не всю диагностику документа. struct DiagnosticsStore { - by_uri: Mutex>>>, + by_uri: Mutex>>, /// Номер поколения: растёт при каждом изменении. revision: AtomicU64, /// Задача вида, которую надо разбудить после изменения. @@ -200,27 +249,50 @@ impl DiagnosticsStore { /// Заменяет набор диагностики сервера для документа. /// /// Пустой список (сервер прислал `[]`) удаляет сообщения сервера. - fn replace(&self, server: &str, uri: &str, diagnostics: Vec) { - { + /// Сообщение с версией старше уже сохранённой игнорируется: ответ + /// сервера мог прийти после более нового `didChange`. + fn replace( + &self, + server: &str, + uri: &str, + version: Option, + diagnostics: Vec, + ) { + 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::>()) + .map(|by_server| { + by_server + .values() + .flat_map(|entry| entry.diagnostics.iter().cloned()) + .collect::>() + }) .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, stored: Option) -> 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) { - self.diagnostics.replace("test", uri, diagnostics); + self.diagnostics.replace("test", uri, None, diagnostics); } /// Последние строки журнала серверов. @@ -635,6 +797,8 @@ struct LspServer { opened: Mutex>, watcher: Mutex>, alive: AtomicBool, + /// Как сервер просит синхронизировать документ (`textDocumentSync.change`). + sync_kind: AtomicU8, log: LogBuffer, diagnostics: Arc, } @@ -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::>() }) .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 { Some(path_to_uri(&path)) } +/// Собирает `contentChanges` для инкрементального `didChange`. +/// +/// Позиции каждой правки считаются по тексту до неё: для первой правки это +/// сохранённый текст, для следующих — rope после предыдущей правки. +fn build_content_changes(before: &Rope, changes: &[(InputEdit, Rope)]) -> Vec { + 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"; diff --git a/src/tests.rs b/src/tests.rs index 7702d98..0104bf0 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -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);