gutter refactoring
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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` сбрасывает иконки активной вкладки: их номера
|
||||||
|
строк относятся к старому тексту.
|
||||||
|
|
||||||
## Текст
|
## Текст
|
||||||
|
|
||||||
|
|||||||
@@ -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") -- включить подсветку
|
||||||
|
|||||||
+61
-17
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user