gutter refactoring

This commit is contained in:
2026-11-01 01:13:30 +03:00
parent cf1505eca5
commit 51cfc86084
12 changed files with 1264 additions and 199 deletions
+6 -2
View File
@@ -10,7 +10,7 @@
| --- | --- | | --- | --- |
| `src/main.rs` | Точка входа, окно, аргументы командной строки | | `src/main.rs` | Точка входа, окно, аргументы командной строки |
| `src/constants.rs` | Константы окна, CLI и каталог сценариев | | `src/constants.rs` | Константы окна, CLI и каталог сценариев |
| `src/editor.rs` | Доменная модель: документы, вкладки, языки | | `src/editor.rs` | Доменная модель: документы (`Rope`), вкладки, языки |
| `src/api.rs` | `AppApi` / `EditorApi` / `PanelsApi` / `PanelApi` / `ToolbarApi` / `TreeApi` / `PathApi` / `WindowApi` / `KeymapApi` — единственная поверхность управления | | `src/api.rs` | `AppApi` / `EditorApi` / `PanelsApi` / `PanelApi` / `ToolbarApi` / `TreeApi` / `PathApi` / `WindowApi` / `KeymapApi` — единственная поверхность управления |
| `src/script.rs` | Lua-движок, `api`, `include`, `require` | | `src/script.rs` | Lua-движок, `api`, `include`, `require` |
| `src/prompt.rs` | Плавающее поле ввода: запрос из Lua и открытое окно | | `src/prompt.rs` | Плавающее поле ввода: запрос из Lua и открытое окно |
@@ -20,7 +20,7 @@
| `src/panel.rs` | `PanelState` и блоки панелей | | `src/panel.rs` | `PanelState` и блоки панелей |
| `src/toolbar.rs` | Состояние верхней панели: лейблы и кнопки | | `src/toolbar.rs` | Состояние верхней панели: лейблы и кнопки |
| `src/tree.rs` | Абстрактное дерево для панелей | | `src/tree.rs` | Абстрактное дерево для панелей |
| `src/gutter.rs` | Иконки на полях слева от редактора | | `src/gutter.rs` | Иконки на полях слева от редактора и их сдвиг по `InputEdit` |
| `src/icons.rs` | Каталог иконок Lucide | | `src/icons.rs` | Каталог иконок Lucide |
| `src/lsp.rs` | Менеджер LSP-серверов, JSON-RPC, файловый вотчер | | `src/lsp.rs` | Менеджер LSP-серверов, JSON-RPC, файловый вотчер |
| `docs/*.md`, `init.lua` | Пользовательская документация и пример конфигурации | | `docs/*.md`, `init.lua` | Пользовательская документация и пример конфигурации |
@@ -124,6 +124,10 @@ qode — не готовый редактор с фиксированным на
кадре UI. кадре UI.
- Какие серверы запускать, решает Lua; менеджер отвечает только за жизненный - Какие серверы запускать, решает Lua; менеджер отвечает только за жизненный
цикл, JSON-RPC и синхронизацию документов. цикл, JSON-RPC и синхронизацию документов.
- Текст документа хранится как `Rope`, поэтому правка из редактора копируется
за O(1). LSP получает инкрементальный `didChange` с диапазонами из `InputEdit`;
полный текст материализуется только для серверов без инкрементальной
синхронизации и на границах (сохранение, Lua).
## Соглашения ## Соглашения
Generated
+5 -10
View File
@@ -2072,8 +2072,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-base" name = "gpui-base"
version = "0.8.1" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/dedalqq/gpui-kit?rev=fc9488fb4433c0ee99aad36123ebbbd49928044a#fc9488fb4433c0ee99aad36123ebbbd49928044a"
checksum = "d862bc8f2e27710039013d075ac4995892e9687ff207fa530f880cbcd513936f"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"anyhow", "anyhow",
@@ -2114,8 +2113,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-component" name = "gpui-component"
version = "0.8.1" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/dedalqq/gpui-kit?rev=fc9488fb4433c0ee99aad36123ebbbd49928044a#fc9488fb4433c0ee99aad36123ebbbd49928044a"
checksum = "46ce53430332e30f303364eaabfa10ed2aa23dfaf2a147e04370e0e9c423578b"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -2178,8 +2176,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-component-macros" name = "gpui-component-macros"
version = "0.8.1" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/dedalqq/gpui-kit?rev=fc9488fb4433c0ee99aad36123ebbbd49928044a#fc9488fb4433c0ee99aad36123ebbbd49928044a"
checksum = "a512c830553e7dddc43c3f8faffc2d2d7bd24db21fe3dc1326dde9c470013768"
dependencies = [ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
@@ -2502,8 +2499,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-kit" name = "gpui-kit"
version = "0.8.1" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/dedalqq/gpui-kit?rev=fc9488fb4433c0ee99aad36123ebbbd49928044a#fc9488fb4433c0ee99aad36123ebbbd49928044a"
checksum = "25210f6e59ea9ffac3a033dbce9edafe5ff0dd2900f894fd1316f54da2fe2b89"
dependencies = [ dependencies = [
"gpui-base", "gpui-base",
"gpui-component", "gpui-component",
@@ -2519,8 +2515,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui-kit-assets" name = "gpui-kit-assets"
version = "0.8.1" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/dedalqq/gpui-kit?rev=fc9488fb4433c0ee99aad36123ebbbd49928044a#fc9488fb4433c0ee99aad36123ebbbd49928044a"
checksum = "c214017b45e5654665f18faa551f58a2c2c8d25efe6632b1a443ba8edf365e96"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"gpui-pre", "gpui-pre",
+2 -2
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
gpui-kit = { version = "0.8.1", features = [ gpui-kit = { git = "https://github.com/dedalqq/gpui-kit", rev = "fc9488fb4433c0ee99aad36123ebbbd49928044a", features = [
"tree-sitter", "tree-sitter",
"tree-sitter-rust", "tree-sitter-rust",
"tree-sitter-go", "tree-sitter-go",
@@ -36,7 +36,7 @@ parking_lot = "0.12"
serde_json = "1" serde_json = "1"
[dev-dependencies] [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] [profile.dev]
opt-level = 1 opt-level = 1
+13 -3
View File
@@ -83,6 +83,7 @@ api.editor:clear_tab_icon(1) -- снова "file"
| `api.editor:clear_gutter_kind(kind)` | Убрать иконки типа во всех строках активной вкладки; возвращает число удалённых | | `api.editor:clear_gutter_kind(kind)` | Убрать иконки типа во всех строках активной вкладки; возвращает число удалённых |
| `api.editor:clear_gutter()` | Убрать все иконки активной вкладки | | `api.editor:clear_gutter()` | Убрать все иконки активной вкладки |
| `api.editor:on_gutter_click(kind, callback)` | Назначить обработчик клика по иконке типа `kind`; `callback(line, icon, kind)`. `nil` вместо функции снимает обработчик | | `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 ```lua
api.editor:set_gutter_icon(4, "circle", "diagnostics", { color = "#e5484d" }) 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) print("клик", line, icon, kind)
end) 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_icon(4, "diagnostics") -- только один тип строки
api.editor:clear_gutter_kind("diagnostics") -- перерисовать только диагностику api.editor:clear_gutter_kind("diagnostics") -- перерисовать только диагностику
api.editor:clear_gutter_line(4) -- все иконки строки api.editor:clear_gutter_line(4) -- все иконки строки
api.editor:clear_gutter() -- все иконки вкладки api.editor:clear_gutter() -- все иконки вкладки
``` ```
Иконки привязаны к номерам строк и автоматически не сдвигаются при правке Иконки привязаны к началу своей строки и едут вместе с ней: если правка
текста: сценарий сам решает, когда их переставить. При прокрутке колонка вставила или удалила строки выше иконки, она сдвигается на столько же строк.
двигается вместе с редактором. Правка внутри строки без изменения их числа номера не меняет. Вставка ровно
в начало строки относится к её содержимому — иконка едет вместе с текстом.
Чтобы узнать о переносе, сценарий назначает `on_gutter_shift`. При прокрутке
колонка двигается вместе с редактором. Внешняя замена всего текста через
`api.editor:set_active_text` сбрасывает иконки активной вкладки: их номера
строк относятся к старому тексту.
## Текст ## Текст
+5 -1
View File
@@ -115,6 +115,10 @@ print("[lua] init.lua выполнен")
-- api.editor:on_gutter_click(kind, callback) -- api.editor:on_gutter_click(kind, callback)
-- -- клик по иконке типа; callback(line, icon, kind), -- -- клик по иконке типа; callback(line, icon, kind),
-- -- nil снимает обработчик -- -- nil снимает обработчик
-- api.editor:on_gutter_shift(kind, callback)
-- -- перенос иконки при правке текста;
-- -- callback(kind, old_line, new_line),
-- -- nil снимает обработчик
-- api.editor:active_text() -- текст активной вкладки -- api.editor:active_text() -- текст активной вкладки
-- api.editor:set_active_text(text) -- заменить текст активной вкладки -- api.editor:set_active_text(text) -- заменить текст активной вкладки
-- api.editor:set_language(i, "rust") -- включить подсветку -- api.editor:set_language(i, "rust") -- включить подсветку
@@ -665,4 +669,4 @@ api.panels.toolbar:add_button("Запустить", "play", nil, "run", "right",
end) end)
api.editor:set_gutter_icon(2, "play", "demo", { color = "#efefe6" }) 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")
+61 -17
View File
@@ -13,7 +13,7 @@
//! вычитывает её в начале кадра. //! вычитывает её в начале кадра.
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::collections::VecDeque; use std::collections::{HashMap, VecDeque};
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc; 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::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
use crate::editor::{FocusAfterSync, Language, Workspace}; 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::icons;
use crate::keymap::{AppCommand, CompletionCommand, KeyBinding, KeyContext, KeyHandler, KeyMap}; use crate::keymap::{AppCommand, CompletionCommand, KeyBinding, KeyContext, KeyHandler, KeyMap};
use crate::lsp::{ use crate::lsp::{
@@ -171,6 +173,11 @@ pub struct AppApi {
/// для всех вкладок. /// для всех вкладок.
#[lua(skip)] #[lua(skip)]
gutter_clicks: GutterClickHandlers, gutter_clicks: GutterClickHandlers,
/// Обработчики переноса иконок gutter при правке текста.
///
/// Как и обработчики клика, живут вне документов и общие для всех вкладок.
#[lua(skip)]
gutter_shifts: GutterShiftHandlers,
/// Запрос из Lua на открытие плавающего поля ввода. /// Запрос из Lua на открытие плавающего поля ввода.
#[lua(skip)] #[lua(skip)]
prompt_request: PromptRequestHandle, prompt_request: PromptRequestHandle,
@@ -194,6 +201,8 @@ pub struct EditorApi {
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>, diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
#[lua(skip)] #[lua(skip)]
gutter_clicks: GutterClickHandlers, gutter_clicks: GutterClickHandlers,
#[lua(skip)]
gutter_shifts: GutterShiftHandlers,
} }
impl EditorApi { impl EditorApi {
@@ -203,6 +212,7 @@ impl EditorApi {
lsp: Arc<LspManager>, lsp: Arc<LspManager>,
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>, diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
gutter_clicks: GutterClickHandlers, gutter_clicks: GutterClickHandlers,
gutter_shifts: GutterShiftHandlers,
) -> Self { ) -> Self {
Self { Self {
workspace, workspace,
@@ -210,6 +220,7 @@ impl EditorApi {
lsp, lsp,
diagnostics_handler, diagnostics_handler,
gutter_clicks, gutter_clicks,
gutter_shifts,
} }
} }
} }
@@ -696,6 +707,7 @@ impl PathApi {
let file = std::fs::OpenOptions::new() let file = std::fs::OpenOptions::new()
.create(true) .create(true)
.truncate(false)
.write(true) .write(true)
.open(&path) .open(&path)
.map_err(|err| { .map_err(|err| {
@@ -965,6 +977,7 @@ impl AppApi {
lsp: Arc::new(LspManager::new()), lsp: Arc::new(LspManager::new()),
diagnostics_handler: Rc::new(RefCell::new(None)), diagnostics_handler: Rc::new(RefCell::new(None)),
gutter_clicks: new_gutter_click_handlers(), gutter_clicks: new_gutter_click_handlers(),
gutter_shifts: new_gutter_shift_handlers(),
prompt_request: Rc::new(RefCell::new(None)), prompt_request: Rc::new(RefCell::new(None)),
}; };
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone())); INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
@@ -1032,6 +1045,12 @@ impl AppApi {
self.gutter_clicks.clone() self.gutter_clicks.clone()
} }
/// Обработчики переноса иконок gutter (для вида).
#[lua(skip)]
pub fn gutter_shift_handle(&self) -> GutterShiftHandlers {
self.gutter_shifts.clone()
}
/// API редактора. /// API редактора.
/// ///
/// В Lua доступно как `api.editor`: `api.editor:open_tab()`, /// В Lua доступно как `api.editor`: `api.editor:open_tab()`,
@@ -1045,6 +1064,7 @@ impl AppApi {
self.lsp.clone(), self.lsp.clone(),
self.diagnostics_handler.clone(), self.diagnostics_handler.clone(),
self.gutter_clicks.clone(), self.gutter_clicks.clone(),
self.gutter_shifts.clone(),
) )
} }
@@ -1163,7 +1183,7 @@ impl EditorApi {
let (path, content) = { let (path, content) = {
let workspace = self.workspace.borrow(); let workspace = self.workspace.borrow();
let doc = workspace.active(); let doc = workspace.active();
(doc.name.clone(), doc.content.clone()) (doc.name.clone(), doc.content_string())
}; };
std::fs::write(&path, &content)?; std::fs::write(&path, &content)?;
if let Some(uri) = document_uri(&path) { if let Some(uri) = document_uri(&path) {
@@ -1236,7 +1256,7 @@ impl EditorApi {
/// Содержимое активной вкладки. /// Содержимое активной вкладки.
#[lua(infallible)] #[lua(infallible)]
pub fn active_text(&self) -> String { 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)] #[lua(infallible)]
pub fn on_gutter_click(&self, kind: String, callback: Option<mlua::Function>) { pub fn on_gutter_click(&self, kind: String, callback: Option<mlua::Function>) {
let kind = kind.trim(); set_gutter_handler(&self.gutter_clicks, kind, callback);
if kind.is_empty() { }
return;
} /// Назначает обработчик переноса иконки типа `kind`.
let mut handlers = self.gutter_clicks.borrow_mut(); ///
match callback { /// Обработчик получает `(kind, old_line, new_line)`, где номера строк
Some(callback) => { /// нумеруются с нуля. Вызывается после правки текста для каждой иконки
handlers.insert(kind.to_owned(), callback); /// этого типа, чья строка съехала. `callback = nil` удаляет обработчик
} /// этого типа. Обработчики общие для всех вкладок: тип иконки задаёт
None => { /// сценарий.
handlers.remove(kind); #[lua(infallible)]
} pub fn on_gutter_shift(&self, kind: String, callback: Option<mlua::Function>) {
} set_gutter_handler(&self.gutter_shifts, kind, callback);
} }
/// Включает подсветку синтаксиса для вкладки `index`. /// Включает подсветку синтаксиса для вкладки `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`. /// Переводит необязательный цвет gutter в `Hsla`.
/// ///
/// `None`, пустая строка и `transparent` означают «цвет не задан», а /// `None`, пустая строка и `transparent` означают «цвет не задан», а
+216 -77
View File
@@ -15,6 +15,7 @@ use std::task::{Context as TaskContext, Poll};
use std::time::Duration; use std::time::Duration;
use gpui_kit::assets::IconName as AssetIcon; use gpui_kit::assets::IconName as AssetIcon;
use gpui_kit::base::input::InputEdit;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{ use gpui_kit::component::input::{
Editor, EditorState, HoverProvider, Input, InputEvent, InputState, Rope, TabSize, 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::api::{self, AppApi, WindowCommand};
use crate::completion::{ 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, 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::{ use crate::constants::{
GUTTER_ICON_GAP, GUTTER_ICON_PADDING, GUTTER_ICON_SIZE, GUTTER_WIDTH, MIN_LEFT_PANEL_WIDTH, 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, MIN_MAIN_AREA_WIDTH, PROMPT_CENTER_OFFSET, PROMPT_WIDTH, TAB_SIZE, WINDOW_TITLE,
}; };
use crate::editor::{FocusAfterSync, Workspace}; use crate::editor::{FocusAfterSync, Workspace};
use crate::gutter::{GutterClickHandlers, GutterMarker, GutterMarkerKey}; use crate::gutter::{
GutterClickHandlers, GutterLineEdit, GutterMarkerKey, GutterShift, GutterShiftHandlers,
};
use crate::icons; use crate::icons;
use crate::keymap::{AppCommand, CompletionCommand, KeyContext, KeyHandler, KeyMap}; 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::panel::{PanelBlock, PanelHandle};
use crate::prompt::{PromptRequestHandle, PromptUi}; use crate::prompt::{PromptRequestHandle, PromptUi};
use crate::script::ScriptEngine; use crate::script::ScriptEngine;
@@ -206,6 +209,11 @@ pub struct MyApp {
editor: Entity<EditorState>, editor: Entity<EditorState>,
/// Ревизия [`Workspace`], уже перенесённая в `editor`. /// Ревизия [`Workspace`], уже перенесённая в `editor`.
synced_revision: u64, synced_revision: u64,
/// Версия текста, уже показанная в `editor`.
///
/// Позволяет не сравнивать весь rope при ревизии, которая меняет только
/// флаг сохранения, иконку вкладки или язык.
synced_content_version: u64,
/// Идентификатор документа, который сейчас показан в редакторе. /// Идентификатор документа, который сейчас показан в редакторе.
/// ///
/// Нужен, чтобы отличить переключение вкладки от изменения её текста и /// Нужен, чтобы отличить переключение вкладки от изменения её текста и
@@ -230,6 +238,11 @@ pub struct MyApp {
/// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения) /// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения)
/// уничтожились раньше самого Lua-движка. /// уничтожились раньше самого Lua-движка.
gutter_clicks: GutterClickHandlers, gutter_clicks: GutterClickHandlers,
/// Обработчики переноса иконок gutter, общие с `api.editor`.
///
/// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения)
/// уничтожились раньше самого Lua-движка.
gutter_shifts: GutterShiftHandlers,
/// Иконка gutter, над которой сейчас находится мышь. /// Иконка gutter, над которой сейчас находится мышь.
/// ///
/// Вид хранит его отдельно от модели иконок: при наведении иконка /// Вид хранит его отдельно от модели иконок: при наведении иконка
@@ -281,8 +294,9 @@ impl MyApp {
let editor_keymap = api.editor_keymap_handle(); let editor_keymap = api.editor_keymap_handle();
let prompt_request = api.prompt_request_handle(); let prompt_request = api.prompt_request_handle();
let gutter_clicks = api.gutter_click_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| { let editor = cx.new(|cx| {
EditorState::new(window, cx) EditorState::new(window, cx)
.line_number(true) .line_number(true)
@@ -313,27 +327,39 @@ impl MyApp {
window, window,
|this, _editor, event: &InputEvent, window, cx| { |this, _editor, event: &InputEvent, window, cx| {
if matches!(event, InputEvent::Change) { if matches!(event, InputEvent::Change) {
let text = this.editor.read(cx).value().to_string(); let (text, cursor, editor_edits) = {
let cursor = this.editor.read(cx).cursor(); let editor = this.editor.read(cx);
let mut workspace = this.workspace.borrow_mut(); (
let was_dirty = workspace.active().is_dirty(); editor.text().clone(),
workspace.replace_active_content(text.clone()); editor.cursor(),
// Индикатор на вкладке меняется только при переходе editor.last_input_edits().to_vec(),
// «чисто → грязно» (или обратно после отмены правки). )
let dirty_changed = was_dirty != workspace.active().is_dirty(); };
drop(workspace); 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() { // LSP получает правки и во время вставки автодополнения.
if expected == text { 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; this.suppress_completion_for_text = None;
} }
if gutter_shifted {
cx.notify();
}
return; return;
} }
this.sync_lsp_document();
this.on_editor_changed(&text, cursor, window, cx); this.on_editor_changed(&text, cursor, window, cx);
if dirty_changed { if dirty_changed || gutter_shifted {
cx.notify(); cx.notify();
} }
} }
@@ -365,10 +391,7 @@ impl MyApp {
// каждое новое обновление перезапускает ожидание, поэтому // каждое новое обновление перезапускает ожидание, поэтому
// промежуточные снимки пропускаются. // промежуточные снимки пропускаются.
if diagnostics_api.diagnostics_delay().is_some() { if diagnostics_api.diagnostics_delay().is_some() {
loop { while let Some(delay) = diagnostics_api.diagnostics_delay() {
let Some(delay) = diagnostics_api.diagnostics_delay() else {
break;
};
if !delay.is_zero() { if !delay.is_zero() {
cx.background_executor().timer(delay).await; cx.background_executor().timer(delay).await;
} }
@@ -445,6 +468,7 @@ impl MyApp {
panel_open_state: Cell::new([false; 3]), panel_open_state: Cell::new([false; 3]),
editor, editor,
synced_revision: 0, synced_revision: 0,
synced_content_version: 0,
displayed_id, displayed_id,
focused_once: false, focused_once: false,
focus_editor_after_render: false, focus_editor_after_render: false,
@@ -452,6 +476,7 @@ impl MyApp {
_key_interceptor, _key_interceptor,
prompt: None, prompt: None,
gutter_clicks, gutter_clicks,
gutter_shifts,
gutter_hover: None, gutter_hover: None,
prompt_request, prompt_request,
scripts: None, scripts: None,
@@ -560,13 +585,27 @@ impl MyApp {
} }
/// Отдаёт активный документ LSP-серверам, которые его обслуживают. /// Отдаёт активный документ LSP-серверам, которые его обслуживают.
fn sync_lsp_document(&self) { ///
let workspace = self.workspace.borrow(); /// Правки из `InputEvent::Change` уходят инкрементально; при внешней
let document = workspace.active(); /// замене или смене вкладки сервер получает полный текст.
if let Some(uri) = document_uri(&document.name) { fn sync_lsp_document(&self, text: &Rope, edits: &[(InputEdit, Rope)]) {
self.lsp if self.lsp.is_empty() {
.sync_document(uri, document.lsp_language_id(), &document.content); 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 активного документа в редактор. /// Переносит диагностику LSP активного документа в редактор.
@@ -584,7 +623,7 @@ impl MyApp {
.as_ref() .as_ref()
.map(|uri| self.lsp.diagnostics(uri.as_str())) .map(|uri| self.lsp.diagnostics(uri.as_str()))
.unwrap_or_default(); .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 error = cx.theme().highlight_theme.style.status.error(cx);
let warning = cx.theme().highlight_theme.style.status.warning(cx); let warning = cx.theme().highlight_theme.style.status.warning(cx);
@@ -617,13 +656,13 @@ impl MyApp {
/// Решает, нужно ли после правки запросить автодополнение. /// Решает, нужно ли после правки запросить автодополнение.
fn on_editor_changed( fn on_editor_changed(
&mut self, &mut self,
text: &str, text: &Rope,
cursor: usize, cursor: usize,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let cursor = cursor.min(text.len()); 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| { let triggers = last.is_some_and(|ch| {
ch.is_alphanumeric() || matches!(ch, '_' | '.' | ':' | '/' | '#' | '@') ch.is_alphanumeric() || matches!(ch, '_' | '.' | ':' | '/' | '#' | '@')
}); });
@@ -647,13 +686,14 @@ impl MyApp {
return; return;
} }
let (text, cursor) = { let (cursor, start, query) = {
let editor = self.editor.read(cx); 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 = { let generation = {
self.completion.generation = self.completion.generation.wrapping_add(1); self.completion.generation = self.completion.generation.wrapping_add(1);
@@ -1153,21 +1193,19 @@ impl MyApp {
self.editor.update(cx, |state, cx| { self.editor.update(cx, |state, cx| {
state.apply_lsp_edits(&edits, window, 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 = (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.set_selected_range(end..end, cx);
state.focus(window, cx); state.focus(window, cx);
}); });
// `InputEvent::Change` доставляются эффектами после текущего update. // `InputEvent::Change` доставляются эффектами после текущего update.
// `defer` стоит в очереди после них и снимает подавление в любом // Подписка на редактор сама перенесёт новый текст в модель, сдвинет
// случае, даже если финальный текст почему-то не совпал. // gutter и отправит LSP-изменения. `defer` стоит в очереди после них и
// снимает подавление в любом случае, даже если финальный текст почему-то
// не совпал.
cx.defer_in(window, |this, _window, _cx| { cx.defer_in(window, |this, _window, _cx| {
this.suppress_completion_for_text = None; 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(); cx.notify();
} }
@@ -1796,7 +1834,7 @@ impl MyApp {
self.focus_editor_after_render = true; self.focus_editor_after_render = true;
self.editor.update(cx, |state, cx| { self.editor.update(cx, |state, cx| {
if let Some(cursor) = cursor { 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.set_selected_range(cursor..cursor, cx);
} }
state.focus(window, cx); state.focus(window, cx);
@@ -1806,13 +1844,14 @@ impl MyApp {
} }
self.synced_revision = revision; 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 mut workspace = self.workspace.borrow_mut();
let focus_after_sync = workspace.take_focus_after_sync(); let focus_after_sync = workspace.take_focus_after_sync();
let doc = workspace.active(); let doc = workspace.active();
( (
doc.id, doc.id,
doc.content.clone(), doc.content().clone(),
doc.content_version(),
doc.language, doc.language,
doc.cursor, doc.cursor,
focus_after_sync, focus_after_sync,
@@ -1848,25 +1887,28 @@ impl MyApp {
let highlight = language let highlight = language
.map(|language| language.syntax_name()) .map(|language| language.syntax_name())
.unwrap_or(""); .unwrap_or("");
let replace_content = switching || content_version != self.synced_content_version;
self.editor.update(cx, |state, cx| { self.editor.update(cx, |state, cx| {
if state.value().as_ref() != content.as_str() { if replace_content {
state.set_value(content, window, cx); state.set_value(content.to_string(), window, cx);
} }
if state.language_name().as_ref() != highlight { if state.language_name().as_ref() != highlight {
state.set_highlighter(highlight, cx); state.set_highlighter(highlight, cx);
} }
if switching || focus_cursor.is_some() { 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); state.set_selected_range(cursor..cursor, cx);
} }
if focus_editor { if focus_editor {
state.focus(window, cx); state.focus(window, cx);
} }
}); });
self.synced_content_version = content_version;
if switching { if switching {
self.close_completion(cx); 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); self.apply_diagnostics(cx);
} }
@@ -2007,16 +2049,10 @@ impl MyApp {
/// фиксируется именно в его prepaint, и если считать `top` здесь, в /// фиксируется именно в его prepaint, и если считать `top` здесь, в
/// `render`, иконки отстают от текста на кадр. /// `render`, иконки отстают от текста на кадр.
fn render_gutter(&self, cx: &mut Context<Self>) -> impl IntoElement { fn render_gutter(&self, cx: &mut Context<Self>) -> impl IntoElement {
let (markers, columns) = { let (document_id, columns) = {
let workspace = self.workspace.borrow(); let workspace = self.workspace.borrow();
let gutter = &workspace.active().gutter; let document = workspace.active();
( (document.id, document.gutter.max_per_line())
gutter
.iter()
.map(|(line, marker)| (line, marker.clone()))
.collect::<Vec<_>>(),
gutter.max_per_line(),
)
}; };
// Колонка растёт под число иконок на самой насыщенной строке, чтобы // Колонка растёт под число иконок на самой насыщенной строке, чтобы
// иконки разных типов не накладывались друг на друга. Одиночная // иконки разных типов не накладывались друг на друга. Одиночная
@@ -2028,7 +2064,8 @@ impl MyApp {
GutterElement { GutterElement {
editor: self.editor.clone(), editor: self.editor.clone(),
entity: cx.entity(), entity: cx.entity(),
markers, workspace: self.workspace.clone(),
document_id,
hovered: self.gutter_hover.clone(), hovered: self.gutter_hover.clone(),
width, width,
background: cx 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. /// Вызывает Lua-обработчик клика по иконке gutter.
/// ///
/// Обработчик получает `(line, icon, kind)`. Если для типа обработчик не /// Обработчик получает `(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`. Левые элементы /// Элементы добавляются сценарием через `api.panels.toolbar`. Левые элементы
@@ -2299,7 +2394,8 @@ impl Render for MyApp {
self.sync_prompt(window, cx); self.sync_prompt(window, cx);
// Документ мог открыться до запуска сервера (init.lua выполняется // Документ мог открыться до запуска сервера (init.lua выполняется
// сразу после создания окна) — отдаём его LSP при первом кадре. // сразу после создания окна) — отдаём его LSP при первом кадре.
self.sync_lsp_document(); let text = self.editor.read(cx).text().clone();
self.sync_lsp_document(&text, &[]);
if !self.focused_once { if !self.focused_once {
self.focused_once = true; self.focused_once = true;
// Открытое поле ввода уже забрало фокус; не отбираем его. // Открытое поле ввода уже забрало фокус; не отбираем его.
@@ -2423,7 +2519,13 @@ struct GutterElement {
/// Вид, которому принадлежит gutter: через него обновляются наведение /// Вид, которому принадлежит gutter: через него обновляются наведение
/// и клик по иконкам. /// и клик по иконкам.
entity: Entity<MyApp>, entity: Entity<MyApp>,
markers: Vec<(usize, GutterMarker)>, /// Документы, из которых берутся иконки активной вкладки.
///
/// Gutter читает модель в `prepaint`, поэтому вид не копирует все
/// маркеры документа на каждый кадр.
workspace: Rc<RefCell<Workspace>>,
/// Документ, для которого построена колонка.
document_id: u64,
/// Иконка, на которой сейчас находится мышь, если она известна. /// Иконка, на которой сейчас находится мышь, если она известна.
hovered: Option<GutterMarkerKey>, hovered: Option<GutterMarkerKey>,
width: Pixels, width: Pixels,
@@ -2515,24 +2617,61 @@ impl Element for GutterElement {
}; };
let display_map = state.display_map(); let display_map = state.display_map();
let line_count = display_map.buffer_line_count(); 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 icon_size = px(GUTTER_ICON_SIZE).min(line_height);
let gap = px(GUTTER_ICON_GAP); let gap = px(GUTTER_ICON_GAP);
let mut icons = Vec::new(); let mut icons = Vec::new();
let mut index = 0; let mut index = 0;
while index < self.markers.len() { while index < visible_markers.len() {
let line = self.markers[index].0; let line = visible_markers[index].0;
let start = index; 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; 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); 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>> { fn diagnostic_range(text: &Rope, range: lsp_types::Range) -> Option<Range<usize>> {
let start = position_to_offset(text, range.start).min(text.len()); let start = position_to_offset_rope(text, range.start).min(text.len());
let mut end = position_to_offset(text, range.end).min(text.len()); let mut end = position_to_offset_rope(text, range.end).min(text.len());
if end <= start { if end <= start {
end = text[start..] end = text
.chars() .get_char(start)
.next() .ok()
.map_or(start, |ch| start + ch.len_utf8()); .map_or(start, |ch| start + ch.len_utf8());
} }
(start < end).then_some(start..end) (start < end).then_some(start..end)
+47 -1
View File
@@ -7,7 +7,7 @@
use std::ops::Range; use std::ops::Range;
use gpui_kit::component::input::{EditorState, PrepaintCaretGeometry}; use gpui_kit::component::input::{EditorState, PrepaintCaretGeometry, Rope};
use gpui_kit::{ use gpui_kit::{
AnyElement, App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId, Hsla, AnyElement, App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId, Hsla,
InspectorElementId, IntoElement, LayoutId, Pixels, Point, Size, Style, WeakEntity, Window, px, 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 { pub fn word_start(text: &str, cursor: usize) -> usize {
let cursor = clamp_offset(text, cursor); let cursor = clamp_offset(text, cursor);
let mut start = cursor; let mut start = cursor;
@@ -209,6 +213,36 @@ pub fn word_start(text: &str, cursor: usize) -> usize {
start 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`. /// Разбирает ответ `textDocument/completion`.
pub fn parse_completion_response(value: serde_json::Value) -> Vec<CompletionItem> { pub fn parse_completion_response(value: serde_json::Value) -> Vec<CompletionItem> {
if value.is_null() { if value.is_null() {
@@ -501,6 +535,18 @@ mod tests {
assert_eq!(word_start("привет", 6), 0); 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] #[test]
fn snippets_lose_placeholders() { fn snippets_lose_placeholders() {
assert_eq!(strip_snippet("println!(${1:value})"), "println!(value)"); assert_eq!(strip_snippet("println!(${1:value})"), "println!(value)");
+76 -13
View File
@@ -7,7 +7,9 @@
use std::sync::atomic::{AtomicU64, Ordering}; 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); static NEXT_ID: AtomicU64 = AtomicU64::new(1);
@@ -216,7 +218,12 @@ pub struct Document {
/// индексом» после закрытия соседей. /// индексом» после закрытия соседей.
pub id: u64, pub id: u64,
pub name: String, pub name: String,
pub content: String, /// Текст документа в виде rope: копия из редактора стоит O(1).
content: Rope,
/// Версия содержимого: увеличивается на каждую правку.
content_version: u64,
/// Есть несохранённые изменения.
dirty: bool,
/// Имя иконки вкладки из каталога Lucide (см. `crate::icons`). /// Имя иконки вкладки из каталога Lucide (см. `crate::icons`).
/// ///
/// `None` — показывается иконка по умолчанию. Назначается через /// `None` — показывается иконка по умолчанию. Назначается через
@@ -231,18 +238,16 @@ pub struct Document {
pub cursor: usize, pub cursor: usize,
/// Иконки на поле редактора для этой вкладки. /// Иконки на поле редактора для этой вкладки.
pub gutter: GutterState, pub gutter: GutterState,
/// Содержимое на момент последнего «сохранения».
saved_content: String,
} }
impl Document { impl Document {
pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self { pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
let content = content.into();
Self { Self {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed), id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
name: name.into(), name: name.into(),
saved_content: content.clone(), content: Rope::from(content.into()),
content, content_version: 0,
dirty: false,
icon: None, icon: None,
language: None, language: None,
cursor: 0, 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 { 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. /// Идентификатор языка для LSP.
@@ -275,7 +317,7 @@ impl Document {
/// Помечает текущее содержимое сохранённым. /// Помечает текущее содержимое сохранённым.
pub fn mark_saved(&mut self) { 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()) .position(|document| document.name == name && !document.is_dirty())
{ {
let document = &mut self.documents[index]; 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.mark_saved();
document.language = language; document.language = language;
document.gutter.clear();
self.active = index; self.active = index;
self.bump(); self.bump();
return index; return index;
@@ -459,13 +503,32 @@ impl Workspace {
/// ///
/// Используется видом после ввода в редакторе: сам виджет уже показывает /// Используется видом после ввода в редакторе: сам виджет уже показывает
/// этот текст, поэтому повторная синхронизация только сбросила бы курсор. /// этот текст, поэтому повторная синхронизация только сбросила бы курсор.
pub fn replace_active_content(&mut self, content: String) { /// Иконки gutter пересчитываются по точным правкам из `InputEvent::Change`.
self.active_mut().content = content; ///
/// Возвращает иконки, которые съехали.
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, меню, тесты). /// Заменяет текст активной вкладки извне (Lua, меню, тесты).
pub fn set_active_text(&mut self, text: impl Into<String>) { 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(); self.bump();
} }
+366 -10
View File
@@ -11,8 +11,10 @@
//! //!
//! Состояние принадлежит документу: у каждой вкладки свои иконки, и при //! Состояние принадлежит документу: у каждой вкладки свои иконки, и при
//! переключении вкладок показываются иконки активного документа. Номера строк //! переключении вкладок показываются иконки активного документа. Номера строк
//! нумеруются с нуля и не сдвигаются автоматически при правке текста: //! нумеруются с нуля. При правке текста вид пересчитывает строки иконок
//! сценарий сам решает, когда переставить иконку. //! ([`GutterState::apply_line_edits`]) по точным `InputEdit`, которые редактор
//! отдаёт вместе с `InputEvent::Change`. Сценарий узнаёт о переносе через
//! обработчик [`GutterShiftHandlers`].
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
@@ -20,6 +22,7 @@ use std::rc::Rc;
use mlua::Function; use mlua::Function;
use gpui_kit::base::input::{InputEdit, Rope, RopeExt};
use gpui_kit::{Hsla, Pixels}; use gpui_kit::{Hsla, Pixels};
/// Обработчики клика по иконкам gutter, ключ — тип иконки. /// Обработчики клика по иконкам gutter, ключ — тип иконки.
@@ -30,6 +33,67 @@ pub fn new_gutter_click_handlers() -> GutterClickHandlers {
Rc::new(RefCell::new(HashMap::new())) 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct GutterMarkerKey { pub struct GutterMarkerKey {
@@ -73,6 +137,11 @@ struct GutterLine {
#[derive(Clone, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, PartialEq)]
pub struct GutterState { pub struct GutterState {
lines: BTreeMap<usize, GutterLine>, lines: BTreeMap<usize, GutterLine>,
/// Самое большое число иконок на одной строке.
///
/// Поддерживается при изменении иконок, чтобы вид не перебирал все строки
/// на каждый кадр.
max_per_line: usize,
} }
impl GutterState { impl GutterState {
@@ -100,18 +169,23 @@ impl GutterState {
color, color,
hover_color, hover_color,
}; };
self.lines let count = {
.entry(line) let line_markers = self.lines.entry(line).or_default();
.or_default() line_markers.markers.insert(kind, marker);
.markers line_markers.markers.len()
.insert(kind, marker); };
self.max_per_line = self.max_per_line.max(count);
} }
/// Убирает все иконки строки `line`. /// Убирает все иконки строки `line`.
/// ///
/// Возвращает `false`, если на этой строке иконок не было. /// Возвращает `false`, если на этой строке иконок не было.
pub fn remove(&mut self, line: usize) -> bool { 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`. /// Убирает иконку типа `kind` со строки `line`.
@@ -125,6 +199,9 @@ impl GutterState {
if line_markers.markers.is_empty() { if line_markers.markers.is_empty() {
self.lines.remove(&line); self.lines.remove(&line);
} }
if removed {
self.recompute_max_per_line();
}
removed removed
} }
@@ -139,12 +216,113 @@ impl GutterState {
} }
!line_markers.markers.is_empty() !line_markers.markers.is_empty()
}); });
if removed > 0 {
self.recompute_max_per_line();
}
removed removed
} }
/// Убирает все иконки. /// Убирает все иконки.
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.lines.clear(); 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`, если она есть. /// Иконка типа `kind` на строке `line`, если она есть.
@@ -153,6 +331,7 @@ impl GutterState {
} }
/// Иконки в порядке возрастания номеров строк, внутри строки — по типу. /// Иконки в порядке возрастания номеров строк, внутри строки — по типу.
#[cfg(test)]
pub fn iter(&self) -> impl Iterator<Item = (usize, &GutterMarker)> { pub fn iter(&self) -> impl Iterator<Item = (usize, &GutterMarker)> {
self.lines.iter().flat_map(|(line, line_markers)| { self.lines.iter().flat_map(|(line, line_markers)| {
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 вмещала все иконки строки. /// Вид использует его, чтобы колонка gutter вмещала все иконки строки.
pub fn max_per_line(&self) -> usize { 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() .values()
.map(|line_markers| line_markers.markers.len()) .map(|line_markers| line_markers.markers.len())
.max() .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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use gpui_kit::base::input::Point;
use gpui_kit::{hsla, px}; use gpui_kit::{hsla, px};
#[test] #[test]
@@ -266,6 +466,162 @@ mod tests {
assert_eq!(marker.hover_color, Some(hover)); 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] #[test]
fn row_offset_follows_scroll_and_line_height() { fn row_offset_follows_scroll_and_line_height() {
assert_eq!(row_offset(0, px(20.), px(0.)), px(0.)); assert_eq!(row_offset(0, px(20.), px(0.)), px(0.));
+361 -46
View File
@@ -21,12 +21,13 @@ use std::io::{BufRead, BufReader, Write};
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::str::FromStr; 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::mpsc::{self, Receiver, RecvTimeoutError};
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
use std::time::Duration; use std::time::Duration;
use globset::{Glob, GlobSet, GlobSetBuilder}; use globset::{Glob, GlobSet, GlobSetBuilder};
use gpui_kit::base::input::{InputEdit, Rope, RopeExt};
use lsp_types::{ use lsp_types::{
CompletionItem, Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString, Position, Uri, CompletionItem, Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString, Position, Uri,
}; };
@@ -167,18 +168,66 @@ struct ActiveDocument {
uri: Uri, uri: Uri,
language_id: String, language_id: String,
version: i32, 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 PendingMap = Mutex<HashMap<u64, mpsc::Sender<Result<Value, String>>>>;
type LogBuffer = Arc<Mutex<VecDeque<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 { struct DiagnosticsStore {
by_uri: Mutex<HashMap<String, HashMap<String, Vec<lsp_types::Diagnostic>>>>, by_uri: Mutex<HashMap<String, HashMap<String, ServerDiagnostics>>>,
/// Номер поколения: растёт при каждом изменении. /// Номер поколения: растёт при каждом изменении.
revision: AtomicU64, 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(); let mut by_uri = self.by_uri.lock();
if diagnostics.is_empty() { if diagnostics.is_empty() {
let Some(by_server) = by_uri.get_mut(uri) else { let Some(by_server) = by_uri.get_mut(uri) else {
return; return;
}; };
if by_server.remove(server).is_none() { let Some(previous) = by_server.get(server) else {
return;
};
if is_stale(version, previous.version) {
return; return;
} }
by_server.remove(server);
if by_server.is_empty() { if by_server.is_empty() {
by_uri.remove(uri); by_uri.remove(uri);
} }
true
} else { } else {
by_uri let by_server = by_uri.entry(uri.to_owned()).or_default();
.entry(uri.to_owned()) let previous = by_server.get(server);
.or_default() if previous.is_some_and(|previous| is_stale(version, previous.version)) {
.insert(server.to_owned(), diagnostics); 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 self.by_uri
.lock() .lock()
.get(uri) .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() .unwrap_or_default()
} }
@@ -265,15 +342,19 @@ impl DiagnosticsStore {
let by_uri = self.by_uri.lock(); let by_uri = self.by_uri.lock();
let mut entries = Vec::new(); let mut entries = Vec::new();
for (uri, by_server) in by_uri.iter() { for (uri, by_server) in by_uri.iter() {
for (server, diagnostics) in by_server.iter() { for (server, entry) in by_server.iter() {
if diagnostics.is_empty() { if entry.diagnostics.is_empty() {
continue; continue;
} }
entries.push(DocumentDiagnostics { entries.push(DocumentDiagnostics {
server: server.clone(), server: server.clone(),
uri: uri.clone(), uri: uri.clone(),
path: uri_to_path(uri).map(|path| path.to_string_lossy().into_owned()), 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-серверов. /// Менеджер всех запущенных LSP-серверов.
/// ///
/// Живёт в `Arc`, потому что его одновременно читают UI, Lua-API и фоновые /// Живёт в `Arc`, потому что его одновременно читают UI, Lua-API и фоновые
@@ -353,10 +438,17 @@ impl LspManager {
// `initialize` — единственный запрос, который ждём прямо здесь: // `initialize` — единственный запрос, который ждём прямо здесь:
// без ответа сервер нельзя считать рабочим. // без ответа сервер нельзя считать рабочим.
let initialize = server.request("initialize", initialize_params(&config))?; let initialize = server.request("initialize", initialize_params(&config))?;
if let Err(err) = wait_response(initialize, Duration::from_secs(30)) { let initialize_result = match wait_response(initialize, Duration::from_secs(30)) {
server.shutdown(); Ok(result) => result,
return Err(format!("initialize «{}»: {err}", config.name)); 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!({})) { if let Err(err) = server.notify("initialized", json!({})) {
server.shutdown(); server.shutdown();
@@ -371,7 +463,7 @@ impl LspManager {
&document.uri, &document.uri,
&document.language_id, &document.language_id,
document.version, document.version,
&document.text, &document.text.to_string(),
); );
} }
@@ -434,47 +526,117 @@ impl LspManager {
self.servers.lock().is_empty() self.servers.lock().is_empty()
} }
/// Синхронизирует активный документ со всеми подходящими серверами. /// Синхронизирует документ со всеми подходящими серверами.
/// ///
/// Первый вызов для документа отправляет `didOpen`, последующие с /// Если `changes` описывают ровно следующий шаг (`content_version` на
/// изменившимся текстом — `didChange` с новой версией. /// единицу новее сохранённой), серверам с инкрементальной синхронизацией
pub fn sync_document(&self, uri: Uri, language_id: String, text: &str) { /// отправляются диапазоны; остальные получают полный текст.
let 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(); let mut active = self.active.lock();
match active.as_mut() { match active.as_mut() {
Some(document) if document.uri == uri => { Some(document) if document.uri == uri => {
if document.text == text { if document.content_version == content_version
if document.language_id != language_id { && document.language_id == language_id
document.language_id = language_id; {
}
return; 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.version = document.version.wrapping_add(1);
document.text = text.to_owned();
document.language_id = language_id.clone(); 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 { *active = Some(ActiveDocument {
uri: uri.clone(), uri: uri.clone(),
language_id: language_id.clone(), language_id: language_id.clone(),
version: 1, version,
text: text.to_owned(), 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() { for server in self.servers.lock().values() {
if !server.serves(&language_id) { if !server.serves(language_id) {
continue; continue;
} }
if server.is_opened(&uri) { if !server.is_opened(uri) {
server.did_change(&uri, version, text); 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 { } 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", "textDocument/completion",
json!({ json!({
"textDocument": { "uri": document.uri.as_str() }, "textDocument": { "uri": document.uri.as_str() },
"position": offset_to_position(&document.text, offset), "position": offset_to_position_rope(&document.text, offset),
"context": { "triggerKind": 1 }, "context": { "triggerKind": 1 },
}), }),
) )
@@ -537,7 +699,7 @@ impl LspManager {
"textDocument/hover", "textDocument/hover",
json!({ json!({
"textDocument": { "uri": document.uri.as_str() }, "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)] #[cfg(test)]
pub fn set_diagnostics_for_test(&self, uri: &str, diagnostics: Vec<lsp_types::Diagnostic>) { 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>>, opened: Mutex<HashSet<String>>,
watcher: Mutex<Option<RecommendedWatcher>>, watcher: Mutex<Option<RecommendedWatcher>>,
alive: AtomicBool, alive: AtomicBool,
/// Как сервер просит синхронизировать документ (`textDocumentSync.change`).
sync_kind: AtomicU8,
log: LogBuffer, log: LogBuffer,
diagnostics: Arc<DiagnosticsStore>, diagnostics: Arc<DiagnosticsStore>,
} }
@@ -685,6 +849,7 @@ impl LspServer {
opened: Mutex::new(HashSet::new()), opened: Mutex::new(HashSet::new()),
watcher: Mutex::new(None), watcher: Mutex::new(None),
alive: AtomicBool::new(true), alive: AtomicBool::new(true),
sync_kind: AtomicU8::new(TextSyncKind::Full.as_u8()),
log: log.clone(), log: log.clone(),
diagnostics, diagnostics,
}); });
@@ -707,6 +872,10 @@ impl LspServer {
self.opened.lock().contains(uri.as_str()) 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) { fn did_open(&self, uri: &Uri, language_id: &str, version: i32, text: &str) {
let params = json!({ let params = json!({
"textDocument": { "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!({ let params = json!({
"textDocument": { "uri": uri.as_str(), "version": version }, "textDocument": { "uri": uri.as_str(), "version": version },
"contentChanges": [{ "text": text }], "contentChanges": [{ "text": text }],
@@ -729,6 +898,14 @@ impl LspServer {
let _ = self.notify("textDocument/didChange", params); 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) { fn did_save(&self, uri: &Uri, text: &str) {
let params = json!({ let params = json!({
"textDocument": { "uri": uri.as_str() }, "textDocument": { "uri": uri.as_str() },
@@ -865,10 +1042,15 @@ impl LspServer {
.collect::<Vec<lsp_types::Diagnostic>>() .collect::<Vec<lsp_types::Diagnostic>>()
}) })
.unwrap_or_default(); .unwrap_or_default();
let version = message
.pointer("/params/version")
.and_then(Value::as_i64)
.map(|version| version as i32);
if !diagnostics.is_empty() { if !diagnostics.is_empty() {
self.log(format!("диагностика {uri}: {}", diagnostics.len())); 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" => { "window/logMessage" | "window/showMessage" | "window/logTrace" => {
if let Some(text) = message.pointer("/params/message").and_then(Value::as_str) { 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)) 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). /// Переводит байтовое смещение в LSP-позицию (`line`/`character` в UTF-16).
pub fn offset_to_position(text: &str, offset: usize) -> Position { pub fn offset_to_position(text: &str, offset: usize) -> Position {
let mut offset = offset.min(text.len()); let mut offset = offset.min(text.len());
@@ -1380,6 +1604,66 @@ mod tests {
use super::*; use super::*;
use std::io::Cursor; 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] #[test]
fn reads_content_length_framed_message() { fn reads_content_length_framed_message() {
let input = b"Content-Length: 13\r\n\r\n{\"jsonrpc\":1}"; let input = b"Content-Length: 13\r\n\r\n{\"jsonrpc\":1}";
@@ -1403,16 +1687,21 @@ mod tests {
let revision = store.revision(); let revision = store.revision();
assert_eq!(revision, 0); 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!(store.revision() > revision);
assert_eq!(store.diagnostics("file:///a.rs").len(), 1); 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); 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"); let left = store.diagnostics("file:///a.rs");
assert_eq!(left.len(), 1); assert_eq!(left.len(), 1);
assert_eq!(left[0].message, "warning"); assert_eq!(left[0].message, "warning");
@@ -1421,6 +1710,32 @@ mod tests {
assert!(store.diagnostics("file:///a.rs").is_empty()); 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] #[test]
fn positions_use_utf16_columns() { fn positions_use_utf16_columns() {
let text = "a\nβγ\n🙂x"; let text = "a\nβγ\n🙂x";
+106 -17
View File
@@ -8,6 +8,7 @@ use std::cell::RefCell;
use std::path::Path; use std::path::Path;
use std::rc::Rc; use std::rc::Rc;
use gpui_kit::base::input::Rope;
use gpui_kit::{Keystroke, Modifiers, TestAppContext, point, px}; use gpui_kit::{Keystroke, Modifiers, TestAppContext, point, px};
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range}; use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
@@ -115,7 +116,7 @@ fn revision_changes_only_for_external_edits() {
let revision = state.revision(); let revision = state.revision();
// Набор текста в виджете ревизию не двигает: редактор уже показывает текст. // Набор текста в виджете ревизию не двигает: редактор уже показывает текст.
state.replace_active_content("typed".to_owned()); state.replace_active_content(&Rope::from("typed"), &[], false);
assert_eq!(state.revision(), revision); assert_eq!(state.revision(), revision);
// Внешняя замена (Lua) — двигает. // Внешняя замена (Lua) — двигает.
@@ -410,7 +411,7 @@ fn api_save_writes_active_tab_to_file_named_after_it() {
let mut state = workspace.borrow_mut(); let mut state = workspace.borrow_mut();
let doc = state.active_mut(); let doc = state.active_mut();
doc.name = path.to_string_lossy().into_owned(); 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()); assert!(workspace.borrow().active().is_dirty());
@@ -1059,7 +1060,7 @@ fn lua_script_can_include_files_and_control_the_app() {
let state = workspace.borrow(); let state = workspace.borrow();
assert_eq!( assert_eq!(
state.documents()[0].content, state.documents()[0].content_string(),
"from lua", "from lua",
"include должен выполнить подключённый файл в общем окружении" "include должен выполнить подключённый файл в общем окружении"
); );
@@ -1091,7 +1092,7 @@ fn api_opens_file_from_disk_and_detects_language() {
let state = workspace.borrow(); let state = workspace.borrow();
assert_eq!(index, state.active_index()); assert_eq!(index, state.active_index());
assert_eq!(state.active().name, path.to_string_lossy()); 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_eq!(state.active().language, Some(Language::Go));
assert!(!state.active().is_dirty()); 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(), workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Editor(None)) Some(FocusAfterSync::Editor(None))
); );
assert_eq!(workspace.borrow().active().content, "unchanged"); assert_eq!(workspace.borrow().active().content_string(), "unchanged");
drop(engine); drop(engine);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
@@ -1211,7 +1212,7 @@ fn api_open_missing_file_creates_an_empty_buffer() {
let state = workspace.borrow(); let state = workspace.borrow();
assert_eq!(index, state.active_index()); assert_eq!(index, state.active_index());
assert_eq!(state.active().name, path.to_string_lossy()); 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_eq!(state.active().language, Some(Language::Go));
assert!(!state.active().is_dirty()); assert!(!state.active().is_dirty());
drop(state); drop(state);
@@ -1242,7 +1243,7 @@ fn lua_app_open_opens_a_file_and_returns_its_tab() {
let state = workspace.borrow(); let state = workspace.borrow();
assert_eq!(state.active().name, path.to_string_lossy()); 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_eq!(state.active().language, Some(Language::Go));
drop(state); 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"); let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
engine.run_startup().expect("init.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); drop(engine);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
@@ -1301,7 +1305,7 @@ fn lua_key_bindings_match_press_release_and_modifiers() {
.run_file(&script) .run_file(&script)
.expect("привязки должны назначиться"); .expect("привязки должны назначиться");
let text = || workspace.borrow().active().content.clone(); let text = || workspace.borrow().active().content_string();
let stroke = |key: &str, modifiers: gpui_kit::Modifiers| Keystroke { let stroke = |key: &str, modifiers: gpui_kit::Modifiers| Keystroke {
modifiers, modifiers,
key: key.to_owned(), 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.simulate_keystrokes("alt-r");
visual.run_until_parked(); 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); drop(engine);
let _ = std::fs::remove_dir_all(&dir); 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" и "" — действуют. // Глобальная карта в режиме i: "-" не действует, "i" и "" — действуют.
fire(&keymap, "a"); fire(&keymap, "a");
@@ -2314,13 +2321,95 @@ fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
let state = workspace.borrow(); let state = workspace.borrow();
let doc = state.active(); let doc = state.active();
assert!( assert!(
doc.content.contains("ZZZ"), doc.content_string().contains("ZZZ"),
"введённый текст не попал в документ: {:?}", "введённый текст не попал в документ: {:?}",
doc.content doc.content_string()
); );
assert!(doc.is_dirty(), "правка должна помечать документ изменённым"); 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] #[gpui_kit::test]
fn floating_input_blocks_keys_and_submits_on_enter(cx: &mut TestAppContext) { fn floating_input_blocks_keys_and_submits_on_enter(cx: &mut TestAppContext) {
cx.update(gpui_kit::init); 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.simulate_keystrokes("alt-r");
visual.run_until_parked(); visual.run_until_parked();
assert_eq!(workspace.borrow().active().content, ""); assert_eq!(workspace.borrow().active().content_string(), "");
// Esc закрывает окно без колбэка. // Esc закрывает окно без колбэка.
visual.simulate_keystrokes("escape"); visual.simulate_keystrokes("escape");
visual.run_until_parked(); visual.run_until_parked();
assert!(!prompt_open(visual), "Esc должен закрыть поле ввода"); assert!(!prompt_open(visual), "Esc должен закрыть поле ввода");
assert_eq!(workspace.borrow().active().content, ""); assert_eq!(workspace.borrow().active().content_string(), "");
// Клик вне окна тоже закрывает его без колбэка. // Клик вне окна тоже закрывает его без колбэка.
engine engine
@@ -2393,7 +2482,7 @@ fn floating_input_blocks_keys_and_submits_on_enter(cx: &mut TestAppContext) {
!prompt_open(visual), !prompt_open(visual),
"клик вне окна должен закрыть поле ввода" "клик вне окна должен закрыть поле ввода"
); );
assert_eq!(workspace.borrow().active().content, ""); assert_eq!(workspace.borrow().active().content_string(), "");
// Enter подтверждает ввод и вызывает колбэк уже после закрытия окна. // Enter подтверждает ввод и вызывает колбэк уже после закрытия окна.
engine engine
@@ -2407,7 +2496,7 @@ fn floating_input_blocks_keys_and_submits_on_enter(cx: &mut TestAppContext) {
visual.run_until_parked(); visual.run_until_parked();
assert!(!prompt_open(visual)); assert!(!prompt_open(visual));
assert_eq!(workspace.borrow().active().content, "main.go"); assert_eq!(workspace.borrow().active().content_string(), "main.go");
drop(engine); drop(engine);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);