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