refactoring

This commit is contained in:
2026-10-31 01:06:21 +03:00
parent 27385a3edb
commit 19e05e7ed0
17 changed files with 2673 additions and 671 deletions
+82 -106
View File
@@ -1,128 +1,104 @@
# AGENTS.md # AGENTS.md
Instructions for AI coding agents working on this repository. Read this file `qode` — настольный редактор кода на GPUI (`gpui-kit`) с расширением поведения
before changing code. The rules below are architectural requirements, not через Lua и клиентом LSP по stdio. Этот файл описывает архитектуру и принципы,
suggestions. которые нужно сохранять при изменениях.
## Project shape ## Модули
| Path | Responsibility | | Путь | Ответственность |
| --- | --- | | --- | --- |
| `src/api.rs` | `AppApi` / `PanelApi` — the single control surface of the application | | `src/main.rs` | Точка входа, окно, аргументы командной строки |
| `src/script.rs` | Lua engine; publishes `app`, `include`, and `require` | | `src/constants.rs` | Константы окна, CLI и каталог сценариев |
| `src/keymap.rs` | Key binding parsing (`KeyBinding`) and dispatch (`KeyMap`) | | `src/editor.rs` | Доменная модель: документы, вкладки, языки |
| `src/app.rs` | GPUI view, rendering, event wiring | | `src/api.rs` | `AppApi` / `PanelApi` / `TreeApi` — единственная поверхность управления |
| `src/editor.rs` | `Workspace`, documents, tabs | | `src/script.rs` | Lua-движок, `app`, `include`, `require` |
| `src/panel.rs` | `PanelState` for left/right/bottom panels | | `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики |
| `src/lsp.rs` | LSP server manager | | `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение |
| `src/constants.rs` | CLI arguments and script directory | | `src/completion.rs` | Всплывающий список, `CaretPopup`, преобразования LSP-элементов |
| `init.lua` | Example startup script (also a usage reference) | | `src/panel.rs` | `PanelState` и блоки панелей |
| `DOCS.md` | User-facing Lua API documentation | | `src/tree.rs` | Абстрактное дерево для панелей |
| `src/icons.rs` | Каталог иконок Lucide |
| `src/lsp.rs` | Менеджер LSP-серверов, JSON-RPC, файловый вотчер |
| `DOCS.md`, `init.lua` | Пользовательская документация и пример конфигурации |
## 1. `AppApi` is the center of the application ## Главные принципы
- `AppApi` (in `src/api.rs`) is the one object through which the application is ### 1. `AppApi` — центр приложения
controlled. All operations on documents, tabs, panels, the window, syntax
highlighting, key bindings, and LSP go through it.
- Shared state is held as `Rc<RefCell<_>>` handles (workspace, keymap, panels,
LSP manager). The UI and `AppApi` must observe the *same* instances; never
create a second copy of the state.
- Rust code that needs to act on the application should call
`AppApi::global()` (or an `AppApi` instance passed in) instead of mutating
`Workspace`, `PanelState`, `KeyMap`, or `LspManager` directly.
- Methods that are implementation details of `AppApi` stay private or are
marked `#[lua(skip)]`. Do not add a second, parallel control path.
## 2. Every `AppApi` capability is available from Lua - Документы, вкладки, панели, дерево, окно, подсветка, LSP и привязки клавиш
меняются только через `AppApi` (или `PanelApi` / `TreeApi`).
- Состояние живёт в общих `Rc<RefCell<_>>` / `Arc`, чтобы UI, `AppApi` и Lua
видели одни и те же объекты. Второй копии состояния быть не должно.
- Команды окна складываются в очередь и применяются в начале кадра
(`apply_window_commands`); UI и Lua не вызывают `Window` напрямую.
- Если действие пользователя меняет состояние, оно выражается методом
`AppApi`, а не логикой внутри обработчика UI. Отрисовка состояние только
читает.
- Исключение — системные кнопки окна внутри `gpui-kit::TitleBar`: их
поведение принадлежит библиотеке.
- Everything in the `impl AppApi` block annotated with ### 2. Lua повторяет возможности API
`#[mlua::userdata_impl]` is automatically exposed as `app:method(...)`.
- When adding functionality, add it to `AppApi` and expose it to Lua at the same
time. An operation that a user can trigger in the UI must also be callable
from a Lua script.
- Use `#[lua(infallible)]` where the method cannot fail, and return
`mlua::Result` / convert errors to `mlua::Error::RuntimeError` otherwise.
- Generic Rust methods are not exported. Add a thin non-generic Lua wrapper,
following the existing pattern: `save` / `lua_save`, `open` / `lua_open`,
`set_active_text` / `lua_set_active_text`, `set_language` /
`lua_set_language`.
- Use `#[lua(name = "...")]` when the Lua name should differ from the Rust
name (for example `active_tab_index` → `active_tab`).
- Lua API naming: lower `snake_case`, tabs and indices are zero-based, panels
are accessed as `app.left_panel`, `app.right_panel`, `app.bottom_panel`.
## 3. All UI actions and keyboard shortcuts go through the API - Публичные методы `impl AppApi` с `#[mlua::userdata_impl]` доступны как
`app:method(...)`. Добавляя возможность, сразу добавляйте и Lua-метод.
- Имена — `snake_case`; вкладки и индексы нумеруются с нуля; панели —
`app.left_panel`, `app.right_panel`, `app.bottom_panel`.
- Для методов, которые не должны попадать в Lua, используется `#[lua(skip)]`.
Для generic-методов добавляется тонкая не-generic Lua-обёртка.
- `DOCS.md` и комментарии в `init.lua` обновляются в том же изменении, что и
публичный API. Документация описывает фактическое поведение, без
«запланированных» методов.
### UI actions ### 3. Клавиатура — только через `KeyMap`
- UI event handlers and rendered controls must invoke `AppApi` methods, not - Встроенные привязки (`Ctrl/Cmd+S`, `Ctrl/Cmd+Space`, навигация по списку
manipulate state directly. Examples: автодополнения) регистрируются в `KeyMap` и могут быть заменены или удалены
- window close button → `app:close()` (i.e. `AppApi::close`) из Lua через `app:map_key` / `app:map_completion_key`.
- minimize button → `app:minimize()` - `KeyMap` имеет два слоя: глобальный и `completion`. Обработчик может быть
- maximize / restore → `app:toggle_maximize()` or Lua-функцией или встроенной командой.
`app:set_maximized(bool)`; read the current state with `app:is_maximized()` - Перехватчик клавиш выполняется раньше действий редактора и используется
- tab close button → `app:close_tab(index)` только для слоя автодополнения; глобальные привязки обрабатываются на корне
- "new tab" button → `app:open_tab()` вида. Этот порядок менять нельзя.
- panel toggles → `app.right_panel:toggle()` and friends - Текстовый ввод редактора — не привязка клавиши.
- Rendering code may *read* state (for example `workspace.borrow()` to draw the
active document), but must not mutate it. Mutations belong to `AppApi`.
- Window operations are queued as `WindowCommand`s and applied by
`apply_window_commands`; keep this mechanism as the only way the UI and Lua
request window changes.
- If an external component (for example `gpui_kit::component::TitleBar`)
performs an action internally, do not leave it as a separate control path:
intercept the action or replace the component so that it calls `AppApi`.
### Keyboard shortcuts ### 4. Модель отделена от GPUI
- Every key binding must be registered through `AppApi::map_key` / - `editor.rs`, `panel.rs` и `tree.rs` не знают о `Window` и `Context`.
`KeyMap`. There must be no hard-coded shortcuts that scripts cannot inspect, - Вид переносит изменения модели в виджет по `Workspace::revision`; набор
override, or remove. текста в редакторе ревизию не трогает, чтобы не сбрасывать курсор.
- Existing hard-coded checks such as `is_save_shortcut` and - Инвариант `Workspace`: открыт хотя бы один документ. Поэтому `active()`
`is_completion_shortcut` in `src/app.rs` are technical debt: when touching возвращает документ без `Option`.
this area, move those defaults into the `KeyMap` mechanism so they can be - Встроенный старт — одна пустая вкладка. Примеры и демонстрационные
rebound from Lua. документы не должны попадать в код приложения; `init.lua` — единственное
- Default bindings should be installed through the same mechanism that scripts место для примеров.
use (or a documented equivalent), so that `app:map_key(...)` can replace them.
Rebinding the same chord must replace the previous handler.
- Dispatch order matters: the key interceptor runs before key bindings, and
`dispatch_keymap` is used for both key down and key up (`<...>` vs `!key`).
Preserve this behavior.
- Any `KeyMap` change must keep the documented chord syntax working:
`a`, `1`, `!a`, `<S-c>`, `<C-z>`, `<A-!r>`, named keys (case-insensitive).
- Editor text input itself is not a shortcut, but all app-level commands
(save, close, panels, completion popup navigation, etc.) must be remappable.
## 4. Keep `DOCS.md` in sync ### 5. LSP работает в фоне
`DOCS.md` is the source of truth for the Lua API. In the **same change** that - `LspManager` общий (`Arc`) для вида, API и фоновых задач.
modifies the public surface, update the docs: - Процессы, чтение stdout/stderr и файловый вотчер живут в отдельных потоках;
ответы приходят через каналы и ожидаются в `background_executor`, а не в
кадре UI.
- Какие серверы запускать, решает Lua; менеджер отвечает только за жизненный
цикл, JSON-RPC и синхронизацию документов.
- added/removed/renamed `AppApi` or `PanelApi` methods; ## Соглашения
- changed arguments, return values, or error behavior;
- new supported languages, LSP configuration fields, CLI flags, or
`include`/`require` behavior;
- changed default shortcuts or their syntax.
Also update the usage comments in `init.lua` when they describe behavior that - Код, комментарии и пользовательская документация — на русском.
changed. Documentation must describe the code as it actually behaves — no - Публичные элементы Rust документируются `///`.
aspirational APIs. - Новые зависимости добавляются только при реальной необходимости; сначала
используются возможности `gpui-kit`, `mlua` и уже подключённых крейтов.
- Изменения должны быть локальными и в стиле окружающего кода. Не добавляйте
параллельные пути управления и не оставляйте мёртвый код.
## Conventions ## Проверка
- Keep changes focused and consistent with the existing style; comments and Перед завершением изменения:
user-facing documentation are written in Russian.
- Rust API docs use `///`; write them for every new public method.
- Validate with `cargo fmt` and `cargo test`, then `cargo build` if behavior
changed. Do not commit unless explicitly asked.
## Checklist ```sh
cargo fmt
cargo test
cargo build
```
Before finishing a change, verify: Если какую-то команду выполнить нельзя, это нужно явно указать в ответе.
- [ ] The new behavior is implemented in `AppApi`, not in a UI handler.
- [ ] The operation is callable from Lua and has a documented signature.
- [ ] No new hard-coded keyboard shortcut bypasses `KeyMap`.
- [ ] UI controls call the corresponding `AppApi` method.
- [ ] `DOCS.md` (and `init.lua` comments if needed) are updated.
- [ ] `cargo fmt` and `cargo test` pass (or explain why they could not run).
Generated
+14 -14
View File
@@ -2177,20 +2177,6 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "gpui-editor-test"
version = "0.1.0"
dependencies = [
"anyhow",
"globset",
"gpui-kit",
"lsp-types",
"mlua",
"notify",
"parking_lot",
"serde_json",
]
[[package]] [[package]]
name = "gpui-fast" name = "gpui-fast"
version = "0.1.12" version = "0.1.12"
@@ -5307,6 +5293,20 @@ version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "qode"
version = "0.1.0"
dependencies = [
"anyhow",
"globset",
"gpui-kit",
"lsp-types",
"mlua",
"notify",
"parking_lot",
"serde_json",
]
[[package]] [[package]]
name = "qoi" name = "qoi"
version = "0.4.1" version = "0.4.1"
+1 -1
View File
@@ -1,5 +1,5 @@
[package] [package]
name = "gpui-editor-test" name = "qode"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
+244 -30
View File
@@ -1,8 +1,12 @@
# Lua API # Lua API
Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект `app` Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект
для управления окном, вкладками, панелями, файловой системой, подсветкой и `app` для управления окном, вкладками, панелями, файловой системой, подсветкой
LSP-серверами. и LSP-серверами.
Сам редактор не содержит демонстрационных документов: при запуске открыта
одна пустая вкладка `untitled-1.txt`. Всё остальное — файлы, деревья, серверы —
настраивается сценарием.
## Запуск и каталог сценариев ## Запуск и каталог сценариев
@@ -10,7 +14,7 @@ LSP-серверами.
(по умолчанию — текущий каталог). Его можно переопределить аргументом командной строки: (по умолчанию — текущий каталог). Его можно переопределить аргументом командной строки:
```sh ```sh
gpui-editor-test --config /path/to/project main.go qode --config /path/to/project main.go
cargo run -- --config ./config cargo run -- --config ./config
cargo run -- main.go cargo run -- main.go
``` ```
@@ -18,10 +22,14 @@ cargo run -- main.go
- В каталоге `--config` должны лежать `init.lua` и подключаемые им модули. - В каталоге `--config` должны лежать `init.lua` и подключаемые им модули.
- Первый позиционный аргумент открывается после выполнения `init.lua` - Первый позиционный аргумент открывается после выполнения `init.lua`
через `app:open(path)` — как `vim main.go` или `nano main.go`. через `app:open(path)` — как `vim main.go` или `nano main.go`.
- `--config` меняет только каталог сценариев. Рабочий каталог процесса, а
вместе с ним пути `app:open`, `app:save` и `root` у LSP не меняются.
## Объект `app` ## Объект `app`
Номера вкладок во всех методах нумеруются с нуля. Номера вкладок во всех методах нумеруются с нуля. Массив, который возвращает
`app:tabs()`, остаётся обычной Lua-таблицей с индексами от 1, поэтому поле
`index` — единственный надёжный способ сослаться на вкладку.
### Вкладки ### Вкладки
@@ -33,12 +41,21 @@ cargo run -- main.go
| `app:next_tab()` | Переключить на следующую вкладку, вернуть индекс | | `app:next_tab()` | Переключить на следующую вкладку, вернуть индекс |
| `app:previous_tab()` | Переключить на предыдущую вкладку, вернуть индекс | | `app:previous_tab()` | Переключить на предыдущую вкладку, вернуть индекс |
| `app:open_tab()` | Открыть пустую вкладку, вернуть индекс | | `app:open_tab()` | Открыть пустую вкладку, вернуть индекс |
| `app:open(path)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем | | `app:open(path, position)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем. Без второго аргумента фокус не меняется; `{}` — перевести фокус в редактор, оставив курсор; `{line, column}` — ещё и поставить курсор (с нуля) |
| `app:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта | | `app:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта |
| `app:set_tab_icon(index, name)` | Назначить иконку вкладке; `false`, если вкладки нет или иконка неизвестна | | `app:set_tab_icon(index, name)` | Назначить иконку вкладке; `false`, если вкладки нет или иконка неизвестна |
| `app:clear_tab_icon(index)` | Вернуть иконку по умолчанию (`file`) | | `app:clear_tab_icon(index)` | Вернуть иконку по умолчанию (`file`) |
| `app:tab_icon(index)` | Имя иконки вкладки или `nil`, если вкладки нет | | `app:tab_icon(index)` | Имя иконки вкладки или `nil`, если вкладки нет |
```lua
app:open("src/main.rs") -- открыть, не трогая фокус
app:open("src/main.rs", {}) -- фокус в редактор, курсор на месте
app:open("src/main.rs", {4, 2}) -- фокус в редактор, курсор на строку 4, столбец 2
```
Строка и столбец нумеруются с нуля, как в диагностике LSP; столбец считается
в UTF-16.
### Иконки вкладок ### Иконки вкладок
Слева от названия вкладки показывается иконка; по умолчанию это `file`. Слева от названия вкладки показывается иконка; по умолчанию это `file`.
@@ -46,8 +63,8 @@ cargo run -- main.go
Например: Например:
```lua ```lua
app:set_tab_icon(0, "file-code") -- main.rs app:set_tab_icon(0, "file-code")
app:set_tab_icon(1, "book-open") -- notes.md app:set_tab_icon(1, "book-open")
app:clear_tab_icon(1) -- снова "file" app:clear_tab_icon(1) -- снова "file"
``` ```
@@ -103,8 +120,14 @@ end
### Привязка клавиш ### Привязка клавиш
`app:map_key(keys, fn)` назначает обработчик на нажатие или отпускание клавиши Все привязки проходят через один `KeyMap`. Встроенные сочетания можно заменить
(синтаксис в духе nvim): или удалить: вторым аргументом принимается Lua-функция, имя встроенной команды
или `nil`.
`app:map_key(keys, handler)` назначает привязку для обычного режима,
`app:map_completion_key(keys, handler)` — для списка автодополнения.
Запись клавиши в духе nvim:
| Запись | Значение | | Запись | Значение |
| --- | --- | | --- | --- |
@@ -114,19 +137,63 @@ end
| `"<S-c>"` | C при зажатом Shift | | `"<S-c>"` | C при зажатом Shift |
| `"<C-z>"` | Z при зажатом Ctrl | | `"<C-z>"` | Z при зажатом Ctrl |
| `"<A-!r>"` | отпускание R при зажатом Alt | | `"<A-!r>"` | отпускание R при зажатом Alt |
| `"<D-s>"` | S при зажатом Cmd на macOS или Super на Linux |
Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux
и Windows буквы берутся по аппаратному keycode, поэтому `<C-q>` работает и Windows буквы берутся по аппаратному keycode, поэтому `<C-q>` работает
и на русской раскладке. и на русской раскладке.
Встроенные команды обычного режима:
| Имя | Действие |
| --- | --- |
| `"save"` | Сохранить активную вкладку |
| `"next_tab"` | Следующая вкладка |
| `"previous_tab"` | Предыдущая вкладка |
| `"open_tab"` | Новая пустая вкладка |
| `"close_tab"` | Закрыть активную вкладку |
| `"toggle_left_panel"` | Переключить левую панель |
| `"toggle_right_panel"` | Переключить правую панель |
| `"toggle_bottom_panel"` | Переключить нижнюю панель |
Встроенные команды списка автодополнения:
| Имя | Действие |
| --- | --- |
| `"completion_trigger"` | Открыть список |
| `"completion_next"` / `"completion_previous"` | Сдвинуть выделение |
| `"completion_page_down"` / `"completion_page_up"` | Пролистать список |
| `"completion_accept"` | Вставить выбранный элемент |
| `"completion_cancel"` | Закрыть список |
| `"completion_dismiss"` | Закрыть список и передать клавишу редактору |
При запуске уже назначены:
- `Ctrl/Cmd+S` → `save`;
- `Ctrl/Cmd+Space` → `completion_trigger`;
- `Enter`/`Tab`, `Esc`, `↑`/`↓`, `PageUp`/`PageDown`, `←`/`→`, `Home`/`End`
→ команды слоя автодополнения.
```lua ```lua
-- Переопределить сохранение своей функцией.
app:map_key("<C-s>", function() app:save() end) app:map_key("<C-s>", function() app:save() end)
app:map_key("<!Esc>", function() app:close() end)
app:map_key("<C-q>", function() app:close() end) -- Убрать встроенную привязку.
app:map_key("<C-e>", function() app.right_panel:toggle() end) app:map_key("<C-s>", nil)
app:map_key("<C-t>", function() app.bottom_panel:toggle() end)
-- Назначить встроенную команду на другую клавишу.
app:map_key("<C-n>", "next_tab")
app:map_key("<C-b>", "toggle_left_panel")
-- Навигация по списку автодополнения на Ctrl+N / Ctrl+P.
app:map_completion_key("<C-n>", "completion_next")
app:map_completion_key("<C-p>", "completion_previous")
``` ```
Пока список открыт, его слой проверяется раньше действий редактора. Сочетания
с модификаторами и горизонтальные стрелки закрывают список, даже если для них
нет своей привязки, — так `Shift+↑` продолжает выделять текст.
### Окно ### Окно
| Метод | Описание | | Метод | Описание |
@@ -160,20 +227,68 @@ app:run_lsp({
| `app:lsp_servers()` | Имена запущенных серверов: `{ "gopls", ... }` | | `app:lsp_servers()` | Имена запущенных серверов: `{ "gopls", ... }` |
| `app:stop_lsp("gopls")` | Остановить сервер; `true`, если сервер был запущен | | `app:stop_lsp("gopls")` | Остановить сервер; `true`, если сервер был запущен |
| `app:restart_lsp("gopls")` | Перезапустить сервер с прежними настройками | | `app:restart_lsp("gopls")` | Перезапустить сервер с прежними настройками |
| `app:on_lsp_diagnostics(delay_ms, fn)` | Обработчик диагностики всех серверов; `nil` вместо `fn` удаляет его |
Поле `watcher` принимает строку или таблицу: `cmd` обязателен. Если `root` не задан или относителен, он отсчитывается от
текущего рабочего каталога процесса. Поле `watcher` принимает строку, таблицу
или boolean:
```lua ```lua
watcher = "**/*.go" watcher = "**/*.go"
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } } watcher = { pattern = "**/*.go", ignore = { "vendor/**" } }
watcher = true -- все файлы, игнорируя только стандартные каталоги сборки
``` ```
Автодополнение включается само при вводе идентификатора; `Ctrl+Space` открывает Автодополнение включается само при вводе идентификатора; `Ctrl/Cmd+Space`
список вручную. Пока список открыт, `↑`/`↓` и `PageUp`/`PageDown` перемещают открывает список вручную. Поведение клавиш описано в разделе «Привязка
выделение (курсор при этом стоит на месте), `Enter` или `Tab` вставляют выбранный клавиш» и может быть переопределено через `app:map_completion_key`.
элемент, `Esc` закрывает список. Горизонтальные стрелки, `Home` и `End` закрывают Документация выбранного элемента показывается второй всплывающей панелью
список и работают с курсором как обычно. Документация выбранного элемента справа от списка.
показывается второй всплывающей панелью справа от списка.
Диагностику, которую сервер присылает после правок, редактор подчёркивает
волнистой линией: ошибки — красной, предупреждения и остальные сообщения —
жёлтой. Подчёркивание работает и при выключенной подсветке синтаксиса.
### Обработчик диагностики
`app:on_lsp_diagnostics(delay_ms, fn)` регистрирует один обработчик на все
LSP-серверы. `delay_ms` — пауза тишины: если сообщения приходят чаще, вызовы
пропускаются, а обработчик получает последний снимок после того, как
обновления прекратились. Если зарегистрировать обработчик, когда диагностика
уже накоплена, он сработает сразу после этой паузы.
```lua
app:on_lsp_diagnostics(1000, function(servers)
for _, entry in ipairs(servers) do
print(entry.server, entry.path, #entry.diagnostics)
end
end)
```
`servers` — массив записей по одной на пару «документ, сервер»:
```lua
{
server = "gopls", -- имя сервера, приславшего диагностику
uri = "file:///project/main.go", -- URI документа
path = "/project/main.go", -- путь файла; nil для не-file URI
diagnostics = {
{
line = 0, character = 2, -- начало (с нуля, как в LSP)
end_line = 0, end_character = 8,
severity = 1, -- 1 ошибка, 2 предупреждение, 3 info, 4 hint
severity_name = "error", -- error | warning | information | hint
message = "undefined: foo",
source = "gopls",
code = "UndeclaredName",
tags = {}, -- unnecessary, deprecated
},
},
}
```
Позиции нумеруются с нуля; для отображения прибавляйте единицу к `line` и
`character`. Остановка сервера удаляет его сообщения из следующих снимков.
Примеры запуска: Примеры запуска:
@@ -228,6 +343,10 @@ app.right_panel:toggle()
app.bottom_panel:close() app.bottom_panel:close()
``` ```
Если у правой панели нет блоков, она показывает состояние LSP-серверов.
Блоки нижней панели показываются вкладками, как вкладки редактора; боковые
панели рисуют все свои блоки подряд и заголовки не показывают.
### Блоки панелей ### Блоки панелей
Панель может содержать блоки. Блок создаётся через API панели и живёт вместе Панель может содержать блоки. Блок создаётся через API панели и живёт вместе
@@ -235,8 +354,14 @@ app.bottom_panel:close()
```lua ```lua
local tree = app.left_panel:add_tree() local tree = app.left_panel:add_tree()
local diagnostics = app.bottom_panel:add_tree("Диагностика")
``` ```
Необязательный аргумент `add_tree` — заголовок вкладки. В нижней панели он
показывается на вкладке; в боковых панелях заголовок сохраняется, но не
рисуется. Без заголовка используется «Дерево». Новый блок становится
активной вкладкой нижней панели.
Редактор отвечает только за отрисовку дерева. Что означает каждый узел, Редактор отвечает только за отрисовку дерева. Что означает каждый узел,
решает сценарий: это могут быть файлы проекта, задачи, настройки — что угодно. решает сценарий: это могут быть файлы проекта, задачи, настройки — что угодно.
Файловое дерево проекта в `init.lua` собирается из `app:list_dir`, но сам Файловое дерево проекта в `init.lua` собирается из `app:list_dir`, но сам
@@ -245,6 +370,7 @@ local tree = app.left_panel:add_tree()
| Метод | Описание | | Метод | Описание |
| --- | --- | | --- | --- |
| `tree:append(nodes, parent)` | Добавить узлы к `parent`; `parent = ""` — корень | | `tree:append(nodes, parent)` | Добавить узлы к `parent`; `parent = ""` — корень |
| `tree:clear()` | Удалить все узлы и сбросить выбор |
| `tree:remove(id)` | Удалить узел и всех потомков рекурсивно; `false`, если узла нет | | `tree:remove(id)` | Удалить узел и всех потомков рекурсивно; `false`, если узла нет |
| `tree:on_toggle(fn)` | Обработчик раскрытия: `fn(expanded, id, loaded)` | | `tree:on_toggle(fn)` | Обработчик раскрытия: `fn(expanded, id, loaded)` |
| `tree:on_click(fn)` | Обработчик клика по конечной ноде: `fn(id)` | | `tree:on_click(fn)` | Обработчик клика по конечной ноде: `fn(id)` |
@@ -264,7 +390,9 @@ local tree = app.left_panel:add_tree()
``` ```
`append` добавляет узлы, а не заменяет дерево. Повторная передача того же `id` `append` добавляет узлы, а не заменяет дерево. Повторная передача того же `id`
обновляет описание узла, но не создаёт копию. обновляет описание узла, но не создаёт копию. `clear` удаляет всё дерево;
обработчики `on_toggle` и `on_click` продолжают действовать, поэтому дерево
удобно строить заново на каждое обновление данных.
Дети каждого уровня сортируются по `label` без учёта регистра. Сначала идут Дети каждого уровня сортируются по `label` без учёта регистра. Сначала идут
раскрываемые узлы (`expandable = true`), затем конечные; внутри каждой группы — раскрываемые узлы (`expandable = true`), затем конечные; внутри каждой группы —
@@ -305,7 +433,8 @@ tree:on_toggle(function(expanded, id, loaded)
end) end)
tree:on_click(function(id) tree:on_click(function(id)
app:open(id) -- Фокус в редактор, курсор остаётся на прежнем месте.
app:open(id, {})
end) end)
app.left_panel:open() app.left_panel:open()
@@ -322,6 +451,89 @@ app:map_key("<C-b>", function()
end) end)
``` ```
Пример дерева диагностики в нижней панели. На первом уровне — файлы с
полным путём, на втором — сообщения с иконкой уровня. Сообщения с одинаковыми
началом и концом диапазона объединяются в одну запись; клик открывает файл
на нужной позиции.
```lua
local diagnostics = app.bottom_panel:add_tree("Диагностика")
local icons = {
error = "circle-x",
warning = "triangle-alert",
information = "circle-alert",
hint = "lightbulb",
}
app:on_lsp_diagnostics(1000, function(servers)
diagnostics:clear()
local files = {}
local order = {}
for _, entry in ipairs(servers) do
local path = entry.path or entry.uri
if not files[path] then
files[path] = {}
order[#order + 1] = path
end
for _, item in ipairs(entry.diagnostics) do
table.insert(files[path], item)
end
end
for _, path in ipairs(order) do
local groups = {}
local keys = {}
for _, item in ipairs(files[path]) do
local key = string.format("%d:%d:%d:%d", item.line, item.character,
item.end_line, item.end_character)
if not groups[key] then
groups[key] = { item = item, messages = {}, seen = {} }
keys[#keys + 1] = key
end
local group = groups[key]
if not group.seen[item.message] then
group.seen[item.message] = true
group.messages[#group.messages + 1] = item.message
end
end
diagnostics:append({
{ id = path, label = path, icon = "file",
expandable = true, expanded = true },
}, "")
local errors = {}
for index, key in ipairs(keys) do
local group = groups[key]
local item = group.item
local severity = item.severity_name or "error"
errors[#errors + 1] = {
id = string.format("diag:%s:%d:%d:%d", path, item.line,
item.character, index),
label = string.format("%d:%d %s", item.line + 1,
item.character + 1,
table.concat(group.messages, "; ")),
icon = icons[severity] or "circle-alert",
}
end
diagnostics:append(errors, path)
end
end)
diagnostics:on_click(function(id)
local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$")
if path then
app:open(path, { tonumber(line), tonumber(character) })
else
app:open(id, {})
end
end)
app.bottom_panel:open()
```
## Подключение других Lua-файлов ## Подключение других Lua-файлов
Файлы подключаются из каталога сценариев: Файлы подключаются из каталога сценариев:
@@ -340,14 +552,16 @@ end)
## Полный пример `init.lua` ## Полный пример `init.lua`
```lua ```lua
print("[lua] init.lua выполнен") print("[qode] init.lua выполнен")
app:set_language(0, "rust")
app:next_tab()
app:map_key("<C-q>", function() app:close() end) app:map_key("<C-q>", function() app:close() end)
app:map_key("<C-e>", function() app.right_panel:toggle() end) app:map_key("<C-e>", "toggle_left_panel")
app:map_key("<C-t>", function() app.bottom_panel:toggle() end) app:map_key("<C-t>", "toggle_bottom_panel")
app:open_tab() app:run_lsp({
name = "rust-analyzer",
cmd = "rust-analyzer",
languages = { "rust" },
watcher = "**/*.rs",
})
``` ```
+187 -13
View File
@@ -1,10 +1,10 @@
-- Стартовый сценарий приложения. -- Стартовый сценарий приложения qode.
-- --
-- Приложение выполняет этот файл при запуске. Каталог поиска задаётся -- Приложение выполняет этот файл при запуске. Каталог поиска задаётся
-- константой SCRIPT_DIR в src/constants.rs (по умолчанию — текущий каталог), -- константой SCRIPT_DIR в src/constants.rs (по умолчанию — текущий каталог),
-- но его можно переопределить аргументом командной строки: -- но его можно переопределить аргументом командной строки:
-- --
-- gpui-editor-test --config /path/to/project main.go -- qode --config /path/to/project main.go
-- cargo run -- --config ./config -- cargo run -- --config ./config
-- cargo run -- main.go -- cargo run -- main.go
-- --
@@ -21,8 +21,12 @@ print("[lua] init.lua выполнен")
-- app:next_tab() -- переключить на следующую, вернуть индекс -- app:next_tab() -- переключить на следующую, вернуть индекс
-- app:previous_tab() -- переключить на предыдущую, вернуть индекс -- app:previous_tab() -- переключить на предыдущую, вернуть индекс
-- app:open_tab() -- открыть пустую вкладку, вернуть индекс -- app:open_tab() -- открыть пустую вкладку, вернуть индекс
-- app:open(path) -- открыть файл с диска, вернуть индекс вкладки; -- app:open(path) -- открыть файл с диска, вернуть индекс вкладки;
-- -- если файла нет — пустой буфер с таким именем -- -- если файла нет — пустой буфер с таким именем
-- app:open(path, {}) -- открыть и перевести фокус в редактор,
-- -- курсор оставить на месте
-- app:open(path, {m, n}) -- открыть, перевести фокус и поставить курсор
-- -- на строку m и столбец n (с нуля)
-- app:list_dir(path) -- {{ id = "src/main.rs", name = "main.rs", -- app:list_dir(path) -- {{ id = "src/main.rs", name = "main.rs",
-- -- is_dir = false }, ...}; пустой путь — -- -- is_dir = false }, ...}; пустой путь —
-- -- текущий каталог, id — путь от него -- -- текущий каталог, id — путь от него
@@ -39,17 +43,28 @@ print("[lua] init.lua выполнен")
-- app:language(i) -- язык подсветки вкладки или nil -- app:language(i) -- язык подсветки вкладки или nil
-- app:clear_language(i) -- выключить подсветку для вкладки -- app:clear_language(i) -- выключить подсветку для вкладки
-- app:save() -- сохранить активную вкладку в её файл -- app:save() -- сохранить активную вкладку в её файл
-- app:map_key(keys, fn) -- обработчик клавиши/сочетания -- app:map_key(keys, handler) -- обработчик клавиши/сочетания:
-- -- функция, имя команды или nil (удалить)
-- -- "a" нажатие A -- -- "a" нажатие A
-- -- "1" нажатие 1 -- -- "1" нажатие 1
-- -- "!a" отпускание A -- -- "!a" отпускание A
-- -- "<S-c>" C при зажатом Shift -- -- "<S-c>" C при зажатом Shift
-- -- "<C-z>" Z при зажатом Ctrl -- -- "<C-z>" Z при зажатом Ctrl
-- -- "<A-!r>" отпускание R при зажатом Alt -- -- "<A-!r>" отпускание R при зажатом Alt
-- -- "<D-s>" S при Cmd/Super
-- -- команды: save, next_tab, previous_tab,
-- -- open_tab, close_tab, toggle_left_panel,
-- -- toggle_right_panel, toggle_bottom_panel
-- -- клавиша сравнивается с именем клавиши от GPUI: -- -- клавиша сравнивается с именем клавиши от GPUI:
-- -- Caps Lock не важен; на Linux и Windows буквы -- -- Caps Lock не важен; на Linux и Windows буквы
-- -- берутся по аппаратному keycode, поэтому -- -- берутся по аппаратному keycode, поэтому
-- -- <C-q> работает и на русской раскладке -- -- <C-q> работает и на русской раскладке
-- app:map_completion_key(keys, handler)
-- -- то же для списка автодополнения; команды:
-- -- completion_trigger, completion_next,
-- -- completion_previous, completion_page_up,
-- -- completion_page_down, completion_accept,
-- -- completion_cancel, completion_dismiss
-- app:close() -- закрыть окно -- app:close() -- закрыть окно
-- app:toggle_maximize() -- app:toggle_maximize()
-- app:set_maximized(true/false) -- app:set_maximized(true/false)
@@ -70,12 +85,32 @@ print("[lua] init.lua выполнен")
-- app:lsp_servers() -- { "gopls", ... } -- app:lsp_servers() -- { "gopls", ... }
-- app:stop_lsp("gopls") -- true, если сервер был запущен -- app:stop_lsp("gopls") -- true, если сервер был запущен
-- app:restart_lsp("gopls") -- перезапуск с прежними настройками -- app:restart_lsp("gopls") -- перезапуск с прежними настройками
-- app:on_lsp_diagnostics(delay_ms, fn)
-- -- fn(servers) вызывается один раз
-- -- после затишья delay_ms; nil вместо
-- -- функции удаляет обработчик.
-- -- servers =
-- -- { { server = "gopls",
-- -- uri = "file:///...",
-- -- path = "/...",
-- -- diagnostics = {
-- -- { line = 0, character = 2,
-- -- end_line = 0, end_character = 8,
-- -- severity = 1,
-- -- severity_name = "error",
-- -- message = "...",
-- -- source = "gopls",
-- -- code = "...",
-- -- tags = { "unnecessary" } },
-- -- } }, ... }
-- -- Позиции нумеруются с нуля, как в LSP.
-- --
-- Поле watcher принимает строку или таблицу: -- Поле watcher принимает строку, таблицу или boolean:
-- watcher = "**/*.go" -- watcher = "**/*.go"
-- watcher = { pattern = "**/*.go", ignore = { "vendor/**" } } -- watcher = { pattern = "**/*.go", ignore = { "vendor/**" } }
-- watcher = true
-- --
-- Автодополнение включается само при вводе идентификатора; Ctrl+Space -- Автодополнение включается само при вводе идентификатора; Ctrl/Cmd+Space
-- открывает список вручную. Пока список открыт, ↑/↓ и PageUp/PageDown -- открывает список вручную. Пока список открыт, ↑/↓ и PageUp/PageDown
-- перемещают выделение, Enter или Tab вставляют элемент, Esc закрывает -- перемещают выделение, Enter или Tab вставляют элемент, Esc закрывает
-- список; горизонтальные стрелки закрывают его и двигают курсор. -- список; горизонтальные стрелки закрывают его и двигают курсор.
@@ -103,9 +138,13 @@ print("[lua] init.lua выполнен")
-- Блоки панелей. Сценарий сам решает, что показывает блок: редактор умеет -- Блоки панелей. Сценарий сам решает, что показывает блок: редактор умеет
-- рисовать только абстрактное дерево. -- рисовать только абстрактное дерево.
-- local tree = app.left_panel:add_tree() -- local tree = app.left_panel:add_tree()
-- local tab = app.bottom_panel:add_tree("Диагностика")
-- -- title показывается вкладкой в нижней
-- -- панели; боковые панели его не рисуют
-- tree:append(nodes, parent_id) -- nodes = { { id=..., label=..., icon=..., -- tree:append(nodes, parent_id) -- nodes = { { id=..., label=..., icon=...,
-- -- expandable=..., expanded=... }, ... }; -- -- expandable=..., expanded=... }, ... };
-- -- parent_id = "" — корень дерева -- -- parent_id = "" — корень дерева
-- tree:clear() -- удалить все узлы и сбросить выбор
-- tree:remove(id) -- удалить ноду и всех потомков -- tree:remove(id) -- удалить ноду и всех потомков
-- tree:on_toggle(fn) -- fn(expanded, id, loaded) -- tree:on_toggle(fn) -- fn(expanded, id, loaded)
-- tree:on_click(fn) -- fn(id) — клик по конечной ноде -- tree:on_click(fn) -- fn(id) — клик по конечной ноде
@@ -122,9 +161,6 @@ print("[lua] init.lua выполнен")
-- Подсветка синтаксиса по умолчанию выключена. Включить её можно так: -- Подсветка синтаксиса по умолчанию выключена. Включить её можно так:
-- app:set_language(0, "rust") -- app:set_language(0, "rust")
app:set_language(0, "rust")
app:next_tab()
-- gopls запускается автоматически при старте редактора. -- gopls запускается автоматически при старте редактора.
-- Сервер обслуживает только документы с languageId "go" и получает изменения -- Сервер обслуживает только документы с languageId "go" и получает изменения
-- всех .go-файлов проекта (кроме vendor/) через файловый вотчер. -- всех .go-файлов проекта (кроме vendor/) через файловый вотчер.
@@ -155,6 +191,10 @@ end
-- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно. -- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно.
-- app:map_key("<C-s>", function() app:save() end) -- app:map_key("<C-s>", function() app:save() end)
-- app:map_key("<!Esc>", function() app:close() end) -- app:map_key("<!Esc>", function() app:close() end)
-- Убрать встроенную привязку можно так:
-- app:map_key("<C-s>", nil)
-- app:map_completion_key("<C-n>", "completion_next")
-- app:map_completion_key("<C-p>", "completion_previous")
-- Файловое дерево проекта. -- Файловое дерево проекта.
-- --
@@ -185,7 +225,8 @@ project_tree:on_toggle(function(expanded, id, loaded)
end) end)
project_tree:on_click(function(id) project_tree:on_click(function(id)
app:open(id) -- Пустая таблица: фокус уходит в редактор, курсор остаётся на месте.
app:open(id, {})
end) end)
app.left_panel:open() app.left_panel:open()
@@ -205,7 +246,140 @@ end)
app:map_key("<C-q>", function() app:close() end) app:map_key("<C-q>", function() app:close() end)
app:map_key("<C-e>", function() app.left_panel:toggle() end) app:map_key("<C-e>", "toggle_left_panel")
app:map_key("<C-t>", function() app.bottom_panel:toggle() end) app:map_key("<C-t>", "toggle_bottom_panel")
app:open_tab() -- Дерево диагностики в нижней панели.
--
-- Обработчик один на все LSP-серверы. Второй аргумент регистрации — пауза
-- тишины: если серверы присылают диагностику чаще, промежуточные вызовы
-- пропускаются, а последняя остаётся и обрабатывается после затишья.
-- При каждом вызове дерево очищается целиком и строится заново.
local diagnostics_tree = app.bottom_panel:add_tree("Диагностика")
local severity_icons = {
error = "circle-x",
warning = "triangle-alert",
information = "circle-alert",
hint = "lightbulb",
}
local severity_rank = {
error = 1,
warning = 2,
information = 3,
hint = 4,
}
local function diagnostic_rank(diagnostic)
return diagnostic.severity
or severity_rank[diagnostic.severity_name or "error"]
or 1
end
app:on_lsp_diagnostics(1000, function(servers)
diagnostics_tree:clear()
-- Сначала собираем диагностику по файлам: одинаковые проблемы от разных
-- серверов попадают в одну запись.
local files = {}
local file_order = {}
for _, entry in ipairs(servers) do
local path = entry.path or entry.uri
local file = files[path]
if not file then
file = { diagnostics = {} }
files[path] = file
file_order[#file_order + 1] = path
end
for _, diagnostic in ipairs(entry.diagnostics) do
file.diagnostics[#file.diagnostics + 1] = diagnostic
end
end
for _, path in ipairs(file_order) do
local file = files[path]
-- Ключ группировки — начало и конец диапазона: у таких проблем
-- совпадают и позиция, и длина.
local groups = {}
local group_order = {}
for _, diagnostic in ipairs(file.diagnostics) do
local key = string.format(
"%d:%d:%d:%d",
diagnostic.line,
diagnostic.character,
diagnostic.end_line,
diagnostic.end_character
)
local group = groups[key]
if not group then
group = {
line = diagnostic.line,
character = diagnostic.character,
rank = diagnostic_rank(diagnostic),
severity = diagnostic.severity_name or "error",
messages = {},
seen = {},
}
groups[key] = group
group_order[#group_order + 1] = key
else
local rank = diagnostic_rank(diagnostic)
if rank < group.rank then
group.rank = rank
group.severity = diagnostic.severity_name or "error"
end
end
if not group.seen[diagnostic.message] then
group.seen[diagnostic.message] = true
group.messages[#group.messages + 1] = diagnostic.message
end
end
local errors = {}
for index, key in ipairs(group_order) do
local group = groups[key]
errors[#errors + 1] = {
-- Префикс отделяет позицию от id файла, даже если в пути есть ':'.
id = string.format(
"diag:%s:%d:%d:%d",
path,
group.line,
group.character,
index
),
label = string.format(
"%d:%d %s",
group.line + 1,
group.character + 1,
table.concat(group.messages, "; ")
),
icon = severity_icons[group.severity] or "circle-alert",
}
end
diagnostics_tree:append({
{
id = path,
label = path,
icon = "file",
expandable = true,
expanded = true,
},
}, "")
diagnostics_tree:append(errors, path)
end
end)
diagnostics_tree:on_click(function(id)
local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$")
if path then
app:open(path, { tonumber(line), tonumber(character) })
else
-- Клик по файлу: фокус в редактор, курсор не переставляем.
app:open(id, {})
end
end)
app.bottom_panel:open()
+292 -90
View File
@@ -13,14 +13,18 @@ use std::collections::VecDeque;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use mlua::IntoLua;
use serde_json::Number; use serde_json::Number;
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::{Language, Workspace}; use crate::editor::{FocusAfterSync, Language, Workspace};
use crate::icons; use crate::icons;
use crate::keymap::{KeyBinding, KeyMap}; use crate::keymap::{AppCommand, CompletionCommand, KeyBinding, KeyContext, KeyHandler, KeyMap};
use crate::lsp::{LspConfig, LspManager, WatcherConfig, document_uri}; use crate::lsp::{
DocumentDiagnostics, LspConfig, LspManager, WatcherConfig, document_uri, position_to_offset,
};
use crate::panel::{PanelHandle, PanelState}; use crate::panel::{PanelHandle, PanelState};
use crate::tree::{TreeHandle, TreeNode}; use crate::tree::{TreeHandle, TreeNode};
@@ -88,6 +92,16 @@ pub struct DirEntryInfo {
pub is_dir: bool, pub is_dir: bool,
} }
/// Зарегистрированный Lua-обработчик диагностики.
///
/// `delay` — пауза тишины, после которой обработчик вызывается. Если сервер
/// присылает сообщения чаще, вызов откладывается до последнего изменения.
#[derive(Clone)]
struct DiagnosticsHandler {
delay: Duration,
callback: mlua::Function,
}
/// Глобальный объект с функциями управления приложением. /// Глобальный объект с функциями управления приложением.
/// ///
/// Использование: `AppApi::global().close()`, `AppApi::global().tabs()`. /// Использование: `AppApi::global().close()`, `AppApi::global().tabs()`.
@@ -111,6 +125,8 @@ pub struct AppApi {
bottom_panel: PanelHandle, bottom_panel: PanelHandle,
#[lua(skip)] #[lua(skip)]
lsp: Arc<LspManager>, lsp: Arc<LspManager>,
#[lua(skip)]
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
} }
/// API одной панели окна. /// API одной панели окна.
@@ -183,6 +199,16 @@ impl TreeApi {
self.state.borrow_mut().remove(&id) self.state.borrow_mut().remove(&id)
} }
/// Удаляет все узлы дерева и сбрасывает выбор.
///
/// Обработчики `on_toggle` и `on_click` продолжают действовать: дерево
/// можно наполнить заново. Удобно для списков, которые перерисовываются
/// целиком, например для диагностики.
#[lua(infallible)]
pub fn clear(&self) {
self.state.borrow_mut().clear();
}
/// Назначает обработчик раскрытия/сворачивания узла. /// Назначает обработчик раскрытия/сворачивания узла.
/// ///
/// Обработчик получает `(expanded, id, loaded)`, где `expanded` — новая /// Обработчик получает `(expanded, id, loaded)`, где `expanded` — новая
@@ -246,13 +272,18 @@ impl PanelApi {
/// Добавляет в панель блок дерева и возвращает его API. /// Добавляет в панель блок дерева и возвращает его API.
/// ///
/// `title` — заголовок вкладки нижней панели. Боковые панели заголовок
/// сохраняют, но не рисуют.
///
/// ```lua /// ```lua
/// local tree = app.left_panel:add_tree() /// local tree = app.left_panel:add_tree()
/// tree:append({ { id = "a", label = "a.txt" } }, "") /// tree:append({ { id = "a", label = "a.txt" } }, "")
///
/// local diagnostics = app.bottom_panel:add_tree("Диагностика")
/// ``` /// ```
#[lua(infallible, name = "add_tree")] #[lua(infallible, name = "add_tree")]
pub fn add_tree(&self) -> TreeApi { pub fn add_tree(&self, title: Option<String>) -> TreeApi {
TreeApi::new(self.state.borrow_mut().add_tree()) TreeApi::new(self.state.borrow_mut().add_tree(title))
} }
} }
@@ -269,6 +300,7 @@ impl AppApi {
right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))), right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))),
bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))), bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))),
lsp: Arc::new(LspManager::new()), lsp: Arc::new(LspManager::new()),
diagnostics_handler: Rc::new(RefCell::new(None)),
}; };
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone())); INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
api api
@@ -324,10 +356,7 @@ impl AppApi {
.map(|(index, doc)| TabInfo { .map(|(index, doc)| TabInfo {
index, index,
title: doc.name.clone(), title: doc.name.clone(),
icon: doc icon: doc.icon_name().to_owned(),
.icon
.clone()
.unwrap_or_else(|| icons::DEFAULT_TAB_ICON.to_owned()),
}) })
.collect() .collect()
} }
@@ -345,10 +374,7 @@ impl AppApi {
// Часть публичного API приложения: вызывается внешним кодом и тестами. // Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)] #[lua(infallible)]
pub fn next_tab(&self) -> usize { pub fn next_tab(&self) -> usize {
let mut workspace = self.workspace.borrow_mut(); self.workspace.borrow_mut().cycle_active(1)
let next = (workspace.active_index() + 1) % workspace.documents().len();
workspace.set_active(next);
next
} }
/// Переключает на предыдущую вкладку (по кругу) и возвращает её индекс. /// Переключает на предыдущую вкладку (по кругу) и возвращает её индекс.
@@ -356,11 +382,7 @@ impl AppApi {
// Часть публичного API приложения: вызывается внешним кодом и тестами. // Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)] #[lua(infallible)]
pub fn previous_tab(&self) -> usize { pub fn previous_tab(&self) -> usize {
let mut workspace = self.workspace.borrow_mut(); self.workspace.borrow_mut().cycle_active(-1)
let count = workspace.documents().len();
let previous = (workspace.active_index() + count - 1) % count;
workspace.set_active(previous);
previous
} }
/// Закрывает вкладку по её номеру в списке [`AppApi::tabs`]. /// Закрывает вкладку по её номеру в списке [`AppApi::tabs`].
@@ -414,17 +436,41 @@ impl AppApi {
/// `keys` записывается в духе nvim: /// `keys` записывается в духе nvim:
/// ///
/// * `a`, `1` — нажатие клавиши без модификаторов; /// * `a`, `1` — нажатие клавиши без модификаторов;
/// * `<S-c>`, `<C-z>`, `<A-r>` — нажатие с Shift, Ctrl или Alt; /// * `<S-c>`, `<C-z>`, `<A-r>`, `<D-s>` — нажатие с Shift, Ctrl, Alt или
/// платформенным модификатором (Cmd/Super);
/// * `!a` — отпускание клавиши; /// * `!a` — отпускание клавиши;
/// * `<A-!r>` — отпускание клавиши при зажатом Alt. /// * `<A-!r>` — отпускание клавиши при зажатом Alt.
/// ///
/// Повторное назначение той же клавиши заменяет обработчик. /// Второй аргумент — Lua-функция, имя встроенной команды (`save`,
/// `next_tab`, `toggle_left_panel` и т. п.) или `nil`, чтобы удалить
/// привязку. Повторное назначение той же клавиши заменяет обработчик.
/// ///
/// # Errors /// # Errors
/// Возвращает ошибку Lua, если запись привязки некорректна. /// Возвращает ошибку Lua, если запись привязки или имя команды некорректны.
pub fn map_key(&self, keys: String, callback: mlua::Function) -> mlua::Result<()> { pub fn map_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
let binding = KeyBinding::parse(&keys).map_err(mlua::Error::RuntimeError)?; self.bind_key(KeyContext::Global, &keys, handler)
self.keymap.borrow_mut().bind(binding, callback); }
/// Назначает обработчик клавиши, пока открыт список автодополнения.
///
/// Синтаксис совпадает с [`AppApi::map_key`]. Встроенные команды этого
/// слоя: `completion_trigger`, `completion_next`, `completion_previous`,
/// `completion_page_up`, `completion_page_down`, `completion_accept`,
/// `completion_cancel`, `completion_dismiss`.
#[lua(name = "map_completion_key")]
pub fn map_completion_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
self.bind_key(KeyContext::Completion, &keys, handler)
}
#[lua(skip)]
fn bind_key(&self, context: KeyContext, keys: &str, handler: mlua::Value) -> mlua::Result<()> {
let binding = KeyBinding::parse(keys).map_err(mlua::Error::RuntimeError)?;
match parse_key_handler(context, handler)? {
Some(handler) => self.keymap.borrow_mut().set(context, binding, handler),
None => {
self.keymap.borrow_mut().unbind(context, &binding);
}
}
Ok(()) Ok(())
} }
@@ -458,8 +504,34 @@ impl AppApi {
/// ///
/// Если файла нет, создаётся пустая вкладка с таким именем — как в vim. /// Если файла нет, создаётся пустая вкладка с таким именем — как в vim.
/// Язык подсветки определяется по расширению, если для него есть грамматика. /// Язык подсветки определяется по расширению, если для него есть грамматика.
/// Фокус редактора не меняется: для этого есть [`AppApi::open_focused`]
/// и Lua-форма `app:open(path, {...})`.
#[lua(skip)] #[lua(skip)]
pub fn open(&self, path: impl AsRef<Path>) -> std::io::Result<usize> { pub fn open(&self, path: impl AsRef<Path>) -> std::io::Result<usize> {
self.open_impl(path, None, false)
}
/// Открывает файл и переводит фокус в редактор (Rust-версия
/// `app:open(path, position)`).
///
/// `position` — строка и столбец с нуля, как в диагностике LSP; столбец
/// считается в UTF-16. `None` оставляет курсор там, где он был.
#[lua(skip)]
pub fn open_focused(
&self,
path: impl AsRef<Path>,
position: Option<(u32, u32)>,
) -> std::io::Result<usize> {
self.open_impl(path, position, true)
}
#[lua(skip)]
fn open_impl(
&self,
path: impl AsRef<Path>,
position: Option<(u32, u32)>,
focus_editor: bool,
) -> std::io::Result<usize> {
let path = path.as_ref(); let path = path.as_ref();
let content = match std::fs::read_to_string(path) { let content = match std::fs::read_to_string(path) {
Ok(content) => content, Ok(content) => content,
@@ -467,19 +539,39 @@ impl AppApi {
Err(err) => return Err(err), Err(err) => return Err(err),
}; };
let cursor = position.map(|(line, column)| {
position_to_offset(&content, lsp_types::Position::new(line, column))
});
let focus = if focus_editor {
FocusAfterSync::Editor(cursor)
} else {
FocusAfterSync::Keep
};
let name = path.to_string_lossy().into_owned(); let name = path.to_string_lossy().into_owned();
let language = Language::from_path(&name); let language = Language::from_path(&name);
let mut workspace = self.workspace.borrow_mut(); let mut workspace = self.workspace.borrow_mut();
Ok(workspace.open_file(name, content, language)) let index = workspace.open_file(name, content, language);
workspace.set_focus_after_sync(focus);
Ok(index)
} }
/// Lua-версия [`AppApi::open`]: `app:open("main.go")`. /// Lua-версия [`AppApi::open`]: `app:open("main.go")`.
/// ///
/// Второй аргумент необязателен:
///
/// * `nil`/нет аргумента — открыть вкладку, не трогая фокус;
/// * `{}` — перевести фокус в редактор, курсор оставить на месте;
/// * `{line, column}` — перевести фокус и поставить курсор (с нуля).
///
/// Возвращает номер вкладки (с нуля). Ошибка чтения становится ошибкой Lua. /// Возвращает номер вкладки (с нуля). Ошибка чтения становится ошибкой Lua.
#[lua(name = "open")] #[lua(name = "open")]
fn lua_open(&self, path: String) -> mlua::Result<usize> { fn lua_open(&self, path: String, position: mlua::Value) -> mlua::Result<usize> {
self.open(&path) match parse_open_focus(position)? {
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}"))) OpenFocus::Keep => self.open(&path),
OpenFocus::Editor(position) => self.open_focused(&path, position),
}
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
} }
/// Читает содержимое каталога (Rust-версия `app:list_dir`). /// Читает содержимое каталога (Rust-версия `app:list_dir`).
@@ -490,7 +582,7 @@ impl AppApi {
#[lua(skip)] #[lua(skip)]
pub fn list_dir(&self, path: impl AsRef<Path>) -> std::io::Result<Vec<DirEntryInfo>> { pub fn list_dir(&self, path: impl AsRef<Path>) -> std::io::Result<Vec<DirEntryInfo>> {
let raw = path.as_ref().as_os_str().to_string_lossy().into_owned(); let raw = path.as_ref().as_os_str().to_string_lossy().into_owned();
let root = raw.is_empty() || raw.as_str() == "."; let relative_to_cwd = raw.is_empty() || raw == ".";
let directory = if raw.is_empty() { let directory = if raw.is_empty() {
Path::new(".") Path::new(".")
} else { } else {
@@ -502,7 +594,7 @@ impl AppApi {
let entry = entry?; let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned(); let name = entry.file_name().to_string_lossy().into_owned();
let is_dir = entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false); let is_dir = entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false);
let id = if root { let id = if relative_to_cwd {
name.clone() name.clone()
} else { } else {
Path::new(&raw) Path::new(&raw)
@@ -583,14 +675,10 @@ impl AppApi {
/// `file` по умолчанию. `nil`, если вкладки нет. /// `file` по умолчанию. `nil`, если вкладки нет.
#[lua(infallible)] #[lua(infallible)]
pub fn tab_icon(&self, index: usize) -> Option<String> { pub fn tab_icon(&self, index: usize) -> Option<String> {
let workspace = self.workspace.borrow(); self.workspace
workspace.document(index)?; .borrow()
Some( .document(index)
workspace .map(|doc| doc.icon_name().to_owned())
.tab_icon(index)
.unwrap_or(icons::DEFAULT_TAB_ICON)
.to_owned(),
)
} }
/// Включает подсветку синтаксиса для вкладки `index`. /// Включает подсветку синтаксиса для вкладки `index`.
@@ -746,6 +834,108 @@ impl AppApi {
pub fn restart_lsp(&self, name: String) -> bool { pub fn restart_lsp(&self, name: String) -> bool {
self.lsp.restart(&name) self.lsp.restart(&name)
} }
/// Регистрирует обработчик диагностики всех LSP-серверов.
///
/// `delay_ms` — пауза тишины в миллисекундах: обработчик вызывается после
/// последнего обновления диагностики. Если серверы присылают сообщения
/// чаще задержки, промежуточные вызовы пропускаются, а последний вызов
/// выполняется после того, как обновления прекратились.
///
/// Обработчик получает один аргумент — массив записей
/// `{ server, uri, path, diagnostics = { ... } }`. У сообщения есть
/// `line`, `character`, `end_line`, `end_character` (с нуля, как в LSP),
/// `severity` (1–4), `severity_name`, `message`, `source`, `code`, `tags`.
/// Передача `nil` вместо функции удаляет обработчик.
///
/// ```lua
/// app:on_lsp_diagnostics(1000, function(servers)
/// for _, entry in ipairs(servers) do
/// print(entry.server, entry.path, #entry.diagnostics)
/// end
/// end)
/// ```
#[lua(infallible, name = "on_lsp_diagnostics")]
pub fn on_lsp_diagnostics(&self, delay_ms: u64, callback: Option<mlua::Function>) {
let delay = Duration::from_millis(delay_ms);
*self.diagnostics_handler.borrow_mut() =
callback.map(|callback| DiagnosticsHandler { delay, callback });
// Обработчик должен получить уже накопленную диагностику, а не ждать
// следующего ответа LSP-сервера.
self.lsp.refresh_diagnostics();
}
/// Задержка зарегистрированного обработчика диагностики, если он есть.
#[lua(skip)]
pub fn diagnostics_delay(&self) -> Option<Duration> {
self.diagnostics_handler
.borrow()
.as_ref()
.map(|handler| handler.delay)
}
/// Вызывает Lua-обработчик диагностики со свежим снимком.
///
/// Возвращает `false`, если обработчик не зарегистрирован.
#[lua(skip)]
pub fn emit_diagnostics(&self, report: Vec<DocumentDiagnostics>) -> bool {
let Some(callback) = self
.diagnostics_handler
.borrow()
.as_ref()
.map(|handler| handler.callback.clone())
else {
return false;
};
let Some(lua) = callback.weak_lua().try_upgrade() else {
return false;
};
let value = match report.into_lua(&lua) {
Ok(value) => value,
Err(err) => {
eprintln!("Lua: не удалось передать диагностику: {err}");
return false;
}
};
if let Err(err) = callback.call::<()>(value) {
eprintln!("Lua: ошибка в обработчике диагностики: {err}");
}
true
}
}
/// Второй аргумент `app:open`.
enum OpenFocus {
/// Фокус не меняется.
Keep,
/// Фокус переходит в редактор; `None` сохраняет текущий курсор.
Editor(Option<(u32, u32)>),
}
/// Разбирает необязательный второй аргумент `app:open`.
fn parse_open_focus(value: mlua::Value) -> mlua::Result<OpenFocus> {
let table = match value {
mlua::Value::Nil => return Ok(OpenFocus::Keep),
mlua::Value::Table(table) => table,
_ => {
return Err(mlua::Error::RuntimeError(
"во втором аргументе app:open нужна таблица {строка, столбец} или {}".to_owned(),
));
}
};
let line = table.get::<Option<i64>>(1)?;
let column = table.get::<Option<i64>>(2)?;
match (line, column) {
(None, None) => Ok(OpenFocus::Editor(None)),
(Some(line), Some(column)) if line >= 0 && column >= 0 => {
Ok(OpenFocus::Editor(Some((line as u32, column as u32))))
}
_ => Err(mlua::Error::RuntimeError(
"позиция в app:open — два неотрицательных числа: {строка, столбец}".to_owned(),
)),
}
} }
/// Разбирает Lua-таблицу, переданную в `app:run_lsp`. /// Разбирает Lua-таблицу, переданную в `app:run_lsp`.
@@ -754,14 +944,9 @@ fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
.get("name") .get("name")
.map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый name".to_owned()))?; .map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый name".to_owned()))?;
let command = ["cmd", "command", "path", "binary"] let command: String = table
.iter() .get("cmd")
.find_map(|key| table.get::<String>(*key).ok()) .map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый cmd".to_owned()))?;
.ok_or_else(|| {
mlua::Error::RuntimeError(
"в run_lsp нужен путь к серверу: cmd, command, path или binary".to_owned(),
)
})?;
let args = match table.get::<mlua::Table>("args") { let args = match table.get::<mlua::Table>("args") {
Ok(args) => args Ok(args) => args
@@ -777,32 +962,22 @@ fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
Err(_) => Vec::new(), Err(_) => Vec::new(),
}; };
let root = table // Корень нормализуется в `LspManager::start`: относительный путь там
.get::<String>("root") // отсчитывается от текущего каталога процесса.
.ok() let root = match table.get::<String>("root") {
.map(PathBuf::from) Ok(root) => PathBuf::from(root),
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); Err(_) => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
let root = if root.is_absolute() {
root
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(root)
}; };
let root = std::fs::canonicalize(&root).unwrap_or(root);
let languages = match table.get::<mlua::Value>("languages") { let languages = match table.get::<mlua::Value>("languages") {
Ok(value) => lua_strings(value)?, Ok(value) => lua_strings(value)?,
Err(_) => Vec::new(), Err(_) => Vec::new(),
}; };
let initialization_options = ["initialization_options", "init_options"] let initialization_options = match table.get::<mlua::Value>("initialization_options") {
.iter() Ok(mlua::Value::Nil) | Err(_) => None,
.find_map(|key| match table.get::<mlua::Value>(*key) { Ok(value) => Some(lua_to_json(value)?),
Ok(mlua::Value::Nil) | Err(_) => None, };
Ok(value) => Some(lua_to_json(value)),
})
.transpose()?;
Ok(LspConfig { Ok(LspConfig {
name, name,
@@ -817,15 +992,9 @@ fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
} }
fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> { fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
let value = ["watcher", "file_watcher", "watch"].iter().find_map(|key| { let value = match table.get::<mlua::Value>("watcher") {
match table.get::<mlua::Value>(*key) { Ok(mlua::Value::Nil) | Err(_) => return Ok(None),
Ok(mlua::Value::Nil) | Err(_) => None, Ok(value) => value,
Ok(value) => Some(value),
}
});
let Some(value) = value else {
return Ok(None);
}; };
match value { match value {
@@ -839,22 +1008,14 @@ fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
})), })),
mlua::Value::Boolean(false) | mlua::Value::Nil => Ok(None), mlua::Value::Boolean(false) | mlua::Value::Nil => Ok(None),
mlua::Value::Table(config) => { mlua::Value::Table(config) => {
let patterns = ["pattern", "patterns"] let patterns = match config.get::<mlua::Value>("pattern") {
.iter() Ok(mlua::Value::Nil) | Err(_) => Vec::new(),
.find_map(|key| match config.get::<mlua::Value>(*key) { Ok(value) => lua_strings(value)?,
Ok(mlua::Value::Nil) | Err(_) => None, };
Ok(value) => Some(lua_strings(value)), let ignore = match config.get::<mlua::Value>("ignore") {
}) Ok(mlua::Value::Nil) | Err(_) => Vec::new(),
.transpose()? Ok(value) => lua_strings(value)?,
.unwrap_or_default(); };
let ignore = ["ignore", "ignored"]
.iter()
.find_map(|key| match config.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_strings(value)),
})
.transpose()?
.unwrap_or_default();
if patterns.is_empty() { if patterns.is_empty() {
Ok(None) Ok(None)
@@ -868,6 +1029,47 @@ fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
} }
} }
/// Разбирает обработчик, переданный в `app:map_key` / `app:map_completion_key`.
///
/// `nil` означает «удалить привязку».
fn parse_key_handler(
context: KeyContext,
handler: mlua::Value,
) -> mlua::Result<Option<KeyHandler>> {
match handler {
mlua::Value::Nil => Ok(None),
mlua::Value::Function(callback) => Ok(Some(KeyHandler::Lua(callback))),
mlua::Value::String(name) => {
let name = name.to_string_lossy();
let parsed = match context {
KeyContext::Global => AppCommand::parse(&name).map(KeyHandler::App),
KeyContext::Completion => {
CompletionCommand::parse(&name).map(KeyHandler::Completion)
}
};
parsed.map(Some).ok_or_else(|| {
let available = match context {
KeyContext::Global => AppCommand::ALL
.iter()
.map(|command| command.name())
.collect::<Vec<_>>(),
KeyContext::Completion => CompletionCommand::ALL
.iter()
.map(|command| command.name())
.collect::<Vec<_>>(),
};
mlua::Error::RuntimeError(format!(
"неизвестная команда клавиши: {name}; доступны: {}",
available.join(", ")
))
})
}
_ => Err(mlua::Error::RuntimeError(
"обработчик должен быть функцией, именем команды или nil".to_owned(),
)),
}
}
/// Разбирает массив узлов дерева, переданный в `TreeApi::append`. /// Разбирает массив узлов дерева, переданный в `TreeApi::append`.
fn parse_tree_nodes(nodes: &mlua::Table) -> mlua::Result<Vec<TreeNode>> { fn parse_tree_nodes(nodes: &mlua::Table) -> mlua::Result<Vec<TreeNode>> {
let mut parsed = Vec::new(); let mut parsed = Vec::new();
+506 -173
View File
@@ -7,13 +7,19 @@
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::collections::HashMap; use std::collections::HashMap;
use std::future::Future;
use std::ops::Range; use std::ops::Range;
use std::pin::Pin;
use std::rc::Rc; use std::rc::Rc;
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::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Editor, EditorState, HoverProvider, InputEvent, Rope, TabSize}; use gpui_kit::component::input::{
Editor, EditorState, HoverProvider, InputEvent, Rope, TabSize, TextDecoration,
TextDecorationCollection,
};
use gpui_kit::component::resizable::HandleEdge; use gpui_kit::component::resizable::HandleEdge;
use gpui_kit::component::tab::{Tab, TabBar}; use gpui_kit::component::tab::{Tab, TabBar};
use gpui_kit::component::text::TextView; use gpui_kit::component::text::TextView;
@@ -31,9 +37,9 @@ use crate::completion::{
parse_completion_response, range_for_lsp, word_start, parse_completion_response, range_for_lsp, word_start,
}; };
use crate::constants::{MIN_LEFT_PANEL_WIDTH, MIN_MAIN_AREA_WIDTH, TAB_SIZE, WINDOW_TITLE}; use crate::constants::{MIN_LEFT_PANEL_WIDTH, MIN_MAIN_AREA_WIDTH, TAB_SIZE, WINDOW_TITLE};
use crate::editor::Workspace; use crate::editor::{FocusAfterSync, Workspace};
use crate::icons; use crate::icons;
use crate::keymap::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};
use crate::panel::{PanelBlock, PanelHandle}; use crate::panel::{PanelBlock, PanelHandle};
use crate::script::ScriptEngine; use crate::script::ScriptEngine;
@@ -78,6 +84,13 @@ struct CompletionUi {
scroll: ScrollHandle, scroll: ScrollHandle,
} }
impl CompletionUi {
/// Сколько строк списка реально рисуется и доступно для выбора.
fn visible_len(&self) -> usize {
self.items.len().min(MAX_COMPLETION_ROWS)
}
}
/// Документация выбранного элемента для второй всплывающей панели. /// Документация выбранного элемента для второй всплывающей панели.
#[derive(Default)] #[derive(Default)]
struct DocsUi { struct DocsUi {
@@ -124,6 +137,35 @@ impl HoverProvider for LspHoverProvider {
} }
} }
/// Ожидание следующего обновления диагностики LSP.
///
/// Не блокирует поток: сервер будит `Waker` из своего потока чтения,
/// а задача вида просыпается в обычном цикле GPUI.
struct DiagnosticsChanged {
manager: std::sync::Arc<LspManager>,
revision: u64,
}
impl Future for DiagnosticsChanged {
type Output = u64;
fn poll(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<u64> {
let current = self.manager.diagnostics_revision();
if current != self.revision {
return Poll::Ready(current);
}
self.manager.register_diagnostics_waker(cx.waker().clone());
// Повторная проверка закрывает гонку между проверкой и регистрацией:
// сервер мог обновить диагностику уже после первой проверки.
let current = self.manager.diagnostics_revision();
if current != self.revision {
Poll::Ready(current)
} else {
Poll::Pending
}
}
}
/// Приложение текстового редактора. /// Приложение текстового редактора.
pub struct MyApp { pub struct MyApp {
/// Открытые документы, вкладки и активная вкладка. /// Открытые документы, вкладки и активная вкладка.
@@ -156,10 +198,15 @@ pub struct MyApp {
displayed_id: u64, displayed_id: u64,
/// Курсор редактора ещё ни разу не получал фокус. /// Курсор редактора ещё ни разу не получал фокус.
focused_once: bool, focused_once: bool,
/// Явный запрос `app:open(path, ...)` на фокус в редакторе.
///
/// Применяется повторно после отрисовки панелей: дерево могло отложить
/// собственный запрос фокуса и перехватить его в этом же кадре.
focus_editor_after_render: bool,
/// Подписка на изменения редактора; держим её, пока жив вид. /// Подписка на изменения редактора; держим её, пока жив вид.
_editor_subscription: Subscription, _editor_subscription: Subscription,
/// Глобальный перехватчик клавиш: он срабатывает раньше действий /// Глобальный перехватчик клавиш: слой автодополнения срабатывает
/// редактора и закрывает автодополнение на стрелках, не мешая курсору. /// раньше действий редактора и не мешает движению курсора.
_key_interceptor: Subscription, _key_interceptor: Subscription,
/// Движок Lua, созданный при старте. /// Движок Lua, созданный при старте.
/// ///
@@ -181,6 +228,11 @@ pub struct MyApp {
/// (их может быть несколько из-за `additionalTextEdits`) не должны /// (их может быть несколько из-за `additionalTextEdits`) не должны
/// переоткрывать список. Очищается после финального текста или в `defer`. /// переоткрывать список. Очищается после финального текста или в `defer`.
suppress_completion_for_text: Option<String>, suppress_completion_for_text: Option<String>,
/// Коллекция подчёркиваний диагностики в редакторе.
///
/// Рисуется независимо от подсветки синтаксиса, поэтому ошибки
/// видны и в файлах без tree-sitter грамматики.
diagnostics_decorations: TextDecorationCollection,
} }
impl MyApp { impl MyApp {
@@ -191,6 +243,7 @@ impl MyApp {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let workspace = Rc::new(RefCell::new(Workspace::default())); let workspace = Rc::new(RefCell::new(Workspace::default()));
let keymap = Rc::new(RefCell::new(KeyMap::default())); let keymap = Rc::new(RefCell::new(KeyMap::default()));
keymap.borrow_mut().install_defaults();
let api = AppApi::install(workspace.clone(), keymap.clone()); let api = AppApi::install(workspace.clone(), keymap.clone());
let lsp = api.lsp_handle(); let lsp = api.lsp_handle();
@@ -253,13 +306,82 @@ impl MyApp {
); );
let displayed_id = workspace.borrow().active().id; let displayed_id = workspace.borrow().active().id;
let diagnostics_decorations = editor.update(cx, |state, cx| {
state.create_decorations_collection(Vec::new(), cx)
});
// Диагностика приходит уведомлениями сервера, а не ответом на запрос.
// Одна задача применяет уже известную диагностику к редактору и вызывает
// Lua-обработчик; у хранилища один `Waker`, поэтому подписчик один.
let diagnostics_api = api.clone();
let diagnostics_manager = lsp.clone();
cx.spawn(async move |this, cx| {
let mut revision = diagnostics_manager.diagnostics_revision();
loop {
// Подчёркивания в активной вкладке обновляются сразу.
let alive = this
.update(cx, |this, cx| this.apply_diagnostics(cx))
.is_ok();
if !alive {
break;
}
// Lua-обработчик вызывается только после паузы тишины:
// каждое новое обновление перезапускает ожидание, поэтому
// промежуточные снимки пропускаются.
if diagnostics_api.diagnostics_delay().is_some() {
loop {
let Some(delay) = diagnostics_api.diagnostics_delay() else {
break;
};
if !delay.is_zero() {
cx.background_executor().timer(delay).await;
}
let current = diagnostics_manager.diagnostics_revision();
if current == revision {
break;
}
revision = current;
let alive = this
.update(cx, |this, cx| this.apply_diagnostics(cx))
.is_ok();
if !alive {
return;
}
}
if diagnostics_api.diagnostics_delay().is_some() {
let report = diagnostics_manager.diagnostics_snapshot();
let alive = this
.update(cx, |_this, cx| {
diagnostics_api.emit_diagnostics(report);
cx.notify();
})
.is_ok();
if !alive {
break;
}
}
}
revision = diagnostics_manager.diagnostics_revision();
revision = DiagnosticsChanged {
manager: diagnostics_manager.clone(),
revision,
}
.await;
}
})
.detach();
let left_panel = api.left_panel_handle(); let left_panel = api.left_panel_handle();
let right_panel = api.right_panel_handle(); let right_panel = api.right_panel_handle();
let bottom_panel = api.bottom_panel_handle(); let bottom_panel = api.bottom_panel_handle();
// `capture_key_down` на корне вызывается уже после обработки action, // `capture_key_down` на корне вызывается уже после обработки action,
// поэтому для стрелок он не успевал. Interceptor срабатывает до // поэтому для стрелок он не успевал. Interceptor срабатывает до
// key bindings: можно закрыть список и не мешать движению курсора. // key bindings: слой автодополнения может закрыть список и не мешать
// движению курсора.
let view = cx.weak_entity(); let view = cx.weak_entity();
let _key_interceptor = cx.intercept_keystrokes(move |event, window, cx| { let _key_interceptor = cx.intercept_keystrokes(move |event, window, cx| {
let handled = view let handled = view
@@ -283,6 +405,7 @@ impl MyApp {
synced_revision: 0, synced_revision: 0,
displayed_id, displayed_id,
focused_once: false, focused_once: false,
focus_editor_after_render: false,
_editor_subscription, _editor_subscription,
_key_interceptor, _key_interceptor,
scripts: None, scripts: None,
@@ -292,6 +415,7 @@ impl MyApp {
docs_cache: HashMap::new(), docs_cache: HashMap::new(),
docs_generation: 0, docs_generation: 0,
suppress_completion_for_text: None, suppress_completion_for_text: None,
diagnostics_decorations,
} }
} }
@@ -343,20 +467,14 @@ impl MyApp {
#[cfg(test)] #[cfg(test)]
pub fn open_test_completion_with_count(&mut self, count: usize, cx: &mut Context<Self>) { pub fn open_test_completion_with_count(&mut self, count: usize, cx: &mut Context<Self>) {
let cursor = self.editor.read(cx).cursor(); let cursor = self.editor.read(cx).cursor();
self.completion.open = true; let items = (0..count)
self.completion.items = (0..count)
.map(|index| CompletionItem { .map(|index| CompletionItem {
label: format!("test_{index}"), label: format!("test_{index}"),
detail: Some("test".to_owned()), detail: Some("test".to_owned()),
..Default::default() ..Default::default()
}) })
.collect(); .collect();
self.completion.selected = 0; self.show_completion(items, cursor, String::new(), 0, cx);
self.completion.start_offset = cursor;
self.completion.query.clear();
self.completion.scroll.scroll_to_item(0);
self.update_docs_for_selection(cx);
cx.notify();
} }
/// Прокрутка списка автодополнения (доступ для тестов). /// Прокрутка списка автодополнения (доступ для тестов).
@@ -377,6 +495,18 @@ impl MyApp {
self.completion.selected self.completion.selected
} }
/// Применяет известную LSP-диагностику к редактору (доступ для тестов).
#[cfg(test)]
pub fn refresh_diagnostics(&mut self, cx: &mut Context<Self>) {
self.apply_diagnostics(cx);
}
/// Байтовые диапазоны подчёркиваний диагностики (доступ для тестов).
#[cfg(test)]
pub fn diagnostic_ranges(&self, cx: &App) -> Vec<Range<usize>> {
self.diagnostics_decorations.get_ranges(cx)
}
/// Отдаёт активный документ LSP-серверам, которые его обслуживают. /// Отдаёт активный документ LSP-серверам, которые его обслуживают.
fn sync_lsp_document(&self) { fn sync_lsp_document(&self) {
let workspace = self.workspace.borrow(); let workspace = self.workspace.borrow();
@@ -387,6 +517,51 @@ impl MyApp {
} }
} }
/// Переносит диагностику LSP активного документа в редактор.
///
/// Ошибки подчёркиваются красным, предупреждения и остальные сообщения —
/// жёлтым. Подчёркивания — отдельные декорации, а не `DiagnosticSet`:
/// тот рисуется только вместе с tree-sitter подсветкой, которой может
/// не быть у файла.
fn apply_diagnostics(&mut self, cx: &mut Context<Self>) {
let uri = {
let workspace = self.workspace.borrow();
document_uri(&workspace.active().name)
};
let diagnostics = uri
.as_ref()
.map(|uri| self.lsp.diagnostics(uri.as_str()))
.unwrap_or_default();
let text = self.editor.read(cx).value().to_string();
let error = cx.theme().highlight_theme.style.status.error(cx);
let warning = cx.theme().highlight_theme.style.status.warning(cx);
let decorations = diagnostics
.into_iter()
.filter_map(|diagnostic| {
let range = diagnostic_range(&text, diagnostic.range)?;
let color = if diagnostic.severity == Some(lsp_types::DiagnosticSeverity::ERROR) {
error
} else {
warning
};
Some(TextDecoration::new(
range,
HighlightStyle {
underline: Some(UnderlineStyle {
color: Some(color),
thickness: px(1.),
wavy: true,
}),
..Default::default()
},
))
})
.collect::<Vec<_>>();
self.diagnostics_decorations.set(decorations, cx);
}
/// Решает, нужно ли после правки запросить автодополнение. /// Решает, нужно ли после правки запросить автодополнение.
fn on_editor_changed( fn on_editor_changed(
&mut self, &mut self,
@@ -490,12 +665,26 @@ impl MyApp {
.unwrap_or(0) .unwrap_or(0)
.min(max_selected); .min(max_selected);
self.show_completion(items, start, query, selected, cx);
}
/// Показывает список с выбранным элементом и обновляет документацию.
fn show_completion(
&mut self,
items: Vec<CompletionItem>,
start: usize,
query: String,
selected: usize,
cx: &mut Context<Self>,
) {
self.completion.open = true; self.completion.open = true;
self.completion.items = items; self.completion.items = items;
self.completion.selected = selected; self.completion.selected = selected.min(self.completion.visible_len().saturating_sub(1));
self.completion.start_offset = start; self.completion.start_offset = start;
self.completion.query = query; self.completion.query = query;
self.completion.scroll.scroll_to_item(selected); self.completion
.scroll
.scroll_to_item(self.completion.selected);
self.update_docs_for_selection(cx); self.update_docs_for_selection(cx);
cx.notify(); cx.notify();
@@ -517,8 +706,7 @@ impl MyApp {
} }
fn select_completion(&mut self, selected: usize, cx: &mut Context<Self>) { fn select_completion(&mut self, selected: usize, cx: &mut Context<Self>) {
let visible = self.completion.items.len().min(MAX_COMPLETION_ROWS); if selected >= self.completion.visible_len() || selected == self.completion.selected {
if selected >= visible || selected == self.completion.selected {
return; return;
} }
self.completion.selected = selected; self.completion.selected = selected;
@@ -529,7 +717,7 @@ impl MyApp {
/// Сдвигает выделение на `delta` строк, не выходя за пределы списка. /// Сдвигает выделение на `delta` строк, не выходя за пределы списка.
fn move_completion_selection(&mut self, delta: isize, cx: &mut Context<Self>) { fn move_completion_selection(&mut self, delta: isize, cx: &mut Context<Self>) {
let visible = self.completion.items.len().min(MAX_COMPLETION_ROWS); let visible = self.completion.visible_len();
if visible == 0 { if visible == 0 {
return; return;
} }
@@ -610,70 +798,86 @@ impl MyApp {
.detach(); .detach();
} }
/// Обрабатывает клавиши, пока открыт список автодополнения. /// Обрабатывает клавиши слоя автодополнения.
/// ///
/// Вызывается из перехватчика клавиш, который срабатывает раньше /// Вызывается из перехватчика клавиш, который срабатывает раньше
/// действий редактора. Пока список открыт, `↑`/`↓` и `PageUp`/`PageDown` /// действий редактора. Пока список открыт, `↑`/`↓` и `PageUp`/`PageDown`
/// двигают выделение, а не курсор; `Enter`/`Tab` вставляют элемент, /// двигают выделение, а не курсор; `Enter`/`Tab` вставляют элемент,
/// `Esc` закрывает список. Горизонтальные стрелки, `Home` и `End` /// `Esc` закрывает список. Горизонтальные стрелки, `Home` и `End`
/// закрывают список и работают с курсором как обычно. /// закрывают список и работают с курсором как обычно.
///
/// Все привязки этого слоя приходят из [`KeyMap`], поэтому их можно
/// заменить через `app:map_completion_key`.
fn handle_completion_key( fn handle_completion_key(
&mut self, &mut self,
keystroke: &Keystroke, keystroke: &Keystroke,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> bool { ) -> bool {
if is_completion_shortcut(keystroke) { let handlers = self
self.request_completions(window, cx); .keymap
return true; .borrow()
.handlers_for(KeyContext::Completion, keystroke, true);
let mut handled = false;
for handler in handlers {
match handler {
KeyHandler::Completion(command) => {
handled |= self.run_completion_command(command, window, cx);
}
KeyHandler::Lua(callback) => {
if self.completion.open {
if let Err(err) = callback.call::<()>(()) {
eprintln!("Lua: ошибка в обработчике клавиши: {err}");
}
handled = true;
}
}
// Привязки другого слоя здесь не выполняются.
KeyHandler::App(_) => {}
}
} }
if !self.completion.open {
// Сочетания с модификаторами и горизонтальная навигация закрывают
// список, даже если сценарий не назначил для них привязку.
if !handled && self.completion.open && dismisses_completion(keystroke) {
self.close_completion(cx);
}
handled
}
/// Выполняет встроенную команду списка автодополнения.
///
/// Возвращает `true`, если клавиша обработана и не должна доходить до
/// редактора.
fn run_completion_command(
&mut self,
command: CompletionCommand,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if command != CompletionCommand::Trigger && !self.completion.open {
return false; return false;
} }
// Сочетания с модификаторами — это не навигация по списку: закрываем match command {
// его и отдаём клавишу редактору (Shift+↑ выделяет текст, Shift+Enter CompletionCommand::Trigger => self.request_completions(window, cx),
// переносит строку и т. п.). CompletionCommand::Next => self.move_completion_selection(1, cx),
let plain = !(keystroke.modifiers.control CompletionCommand::Previous => self.move_completion_selection(-1, cx),
|| keystroke.modifiers.platform CompletionCommand::PageDown => {
|| keystroke.modifiers.alt
|| keystroke.modifiers.shift);
match keystroke.key.as_str() {
_ if !plain => {
self.close_completion(cx);
false
}
"enter" | "tab" => {
self.accept_completion(window, cx);
true
}
"escape" => {
self.close_completion(cx);
true
}
"up" => {
self.move_completion_selection(-1, cx);
true
}
"down" => {
self.move_completion_selection(1, cx);
true
}
"pageup" => {
self.move_completion_selection(-COMPLETION_PAGE_ROWS, cx);
true
}
"pagedown" => {
self.move_completion_selection(COMPLETION_PAGE_ROWS, cx); self.move_completion_selection(COMPLETION_PAGE_ROWS, cx);
true
} }
"left" | "right" | "home" | "end" => { CompletionCommand::PageUp => {
self.move_completion_selection(-COMPLETION_PAGE_ROWS, cx);
}
CompletionCommand::Accept => self.accept_completion(window, cx),
CompletionCommand::Cancel => self.close_completion(cx),
CompletionCommand::Dismiss => {
self.close_completion(cx); self.close_completion(cx);
false return false;
} }
_ => false,
} }
true
} }
/// Вставляет выбранный элемент в редактор. /// Вставляет выбранный элемент в редактор.
@@ -1022,7 +1226,7 @@ impl MyApp {
.text_xs() .text_xs()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child( .child(
"Серверы LSP не запущены. Запустите их из init.lua через app:run_lsp{...}.", "Серверы LSP не запущены. Запустите их из init.lua через app:run_lsp(...).",
), ),
); );
} else { } else {
@@ -1065,16 +1269,24 @@ impl MyApp {
/// Содержимое панели: блоки в порядке добавления. /// Содержимое панели: блоки в порядке добавления.
/// ///
/// Боковые панели рисуют все блоки друг под другом и не показывают их
/// заголовки. `tabs = true` (нижняя панель) рисует вкладки блоков и
/// показывает только активный блок.
///
/// Если блоков нет, правая панель показывает список LSP-серверов, как и /// Если блоков нет, правая панель показывает список LSP-серверов, как и
/// раньше, а остальные панели остаются пустыми. /// раньше, а остальные панели остаются пустыми.
fn render_panel_content( fn render_panel_content(
&self, &self,
panel: &PanelHandle, panel: &PanelHandle,
lsp_fallback: bool, lsp_fallback: bool,
tabs: bool,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
let blocks = panel.borrow().blocks().to_vec(); let (blocks, active) = {
let state = panel.borrow();
(state.blocks().to_vec(), state.active())
};
if blocks.is_empty() { if blocks.is_empty() {
return if lsp_fallback { return if lsp_fallback {
self.render_right_panel(cx) self.render_right_panel(cx)
@@ -1084,15 +1296,73 @@ impl MyApp {
} }
let mut content = div().size_full().flex().flex_col().min_h_0(); let mut content = div().size_full().flex().flex_col().min_h_0();
for block in blocks { if tabs {
let element = match block { content = content.child(
PanelBlock::Tree(tree) => self.render_tree_block(&tree, window, cx), div()
}; .w_full()
content = content.child(div().w_full().flex_1().min_h_0().child(element)); .flex_shrink_0()
.child(self.render_panel_tabs(panel, &blocks, active, cx)),
);
if let Some(block) = blocks.get(active).or_else(|| blocks.first()) {
let PanelBlock::Tree { tree, .. } = block;
content = content.child(
div()
.w_full()
.flex_1()
.min_h_0()
.child(self.render_tree_block(tree, window, cx)),
);
}
} else {
for block in blocks {
let PanelBlock::Tree { tree, .. } = block;
content = content.child(
div()
.w_full()
.flex_1()
.min_h_0()
.child(self.render_tree_block(&tree, window, cx)),
);
}
} }
content.into_any_element() content.into_any_element()
} }
/// Полоса вкладок блоков нижней панели.
///
/// Выглядит как вкладки редактора, но без крестика: блоки не удаляются.
fn render_panel_tabs(
&self,
panel: &PanelHandle,
blocks: &[PanelBlock],
active: usize,
cx: &mut Context<Self>,
) -> impl IntoElement {
let entity = cx.entity();
let panel = panel.clone();
let mut bar = TabBar::new("bottom-panel-tabs")
.selected_index(active)
.folder();
for block in blocks {
bar = bar.child(
Tab::new()
.prefix(
Icon::new(icons::tab_icon(Some("list-tree")))
.small()
.flex_shrink_0()
.text_color(cx.theme().tab_foreground),
)
.label(block.title().to_owned()),
);
}
bar.on_click(move |index, _, cx| {
panel.borrow_mut().set_active(*index);
entity.update(cx, |_, cx| cx.notify());
})
}
/// Рисует один блок абстрактного дерева. /// Рисует один блок абстрактного дерева.
/// ///
/// Узлы и их иерархию целиком задаёт Lua; вид только сортирует видимые /// Узлы и их иерархию целиком задаёт Lua; вид только сортирует видимые
@@ -1248,25 +1518,34 @@ impl MyApp {
div().size_full().min_h_0().child(list).into_any_element() div().size_full().min_h_0().child(list).into_any_element()
} }
/// Возвращает фокус в дерево, когда его панель открывается. /// Возвращает фокус в активное дерево, когда его панель открывается.
/// ///
/// Фокус получает только панель, которая была закрыта в прошлом кадре и /// Фокус получает только панель, которая была закрыта в прошлом кадре и
/// открылась сейчас; внутри неё выбирается первое дерево с выбранной /// открылась сейчас; внутри неё выбирается дерево с выбранной строкой.
/// строкой. Если строку ещё ни разу не выбирали, фокус не забирается. /// В нижней панели это дерево активной вкладки, в боковых — первый блок.
fn sync_panel_focus(&self, open: [bool; 3], window: &mut Window, cx: &mut Context<Self>) { fn sync_panel_focus(&self, open: [bool; 3], window: &mut Window, cx: &mut Context<Self>) {
let previous = self.panel_open_state.get(); let previous = self.panel_open_state.get();
let panels = [&self.left_panel, &self.right_panel, &self.bottom_panel]; let panels = [
for (index, panel) in panels.into_iter().enumerate() { (&self.left_panel, false),
(&self.right_panel, false),
(&self.bottom_panel, true),
];
for (index, (panel, tabs)) in panels.into_iter().enumerate() {
if !open[index] || previous[index] { if !open[index] || previous[index] {
continue; continue;
} }
let tree = panel let tree = {
.borrow() let state = panel.borrow();
.blocks() let block = if tabs {
.iter() state.blocks().get(state.active())
.find_map(|block| match block { } else {
PanelBlock::Tree(tree) => Some(tree.clone()), state.blocks().first()
}); };
block.map(|block| {
let PanelBlock::Tree { tree, .. } = block;
tree.clone()
})
};
let Some(tree) = tree else { let Some(tree) = tree else {
continue; continue;
}; };
@@ -1293,12 +1572,30 @@ impl MyApp {
} }
self.synced_revision = revision; self.synced_revision = revision;
let (active_id, content, language, cursor) = { let (active_id, content, language, cursor, focus_after_sync) = {
let workspace = self.workspace.borrow(); let mut workspace = self.workspace.borrow_mut();
let focus_after_sync = workspace.take_focus_after_sync();
let doc = workspace.active(); let doc = workspace.active();
(doc.id, doc.content.clone(), doc.language, doc.cursor) (
doc.id,
doc.content.clone(),
doc.language,
doc.cursor,
focus_after_sync,
)
}; };
// `None` сохраняет прежнее поведение: любое внешнее изменение само
// возвращает фокус редактору. `app:open` без позиции это отменяет.
let (focus_editor, focus_cursor) = match focus_after_sync {
Some(FocusAfterSync::Keep) => (false, None),
Some(FocusAfterSync::Editor(cursor)) => (true, cursor),
None => (true, None),
};
// Только явный запрос должен победить отложенный фокус дерева.
self.focus_editor_after_render =
matches!(focus_after_sync, Some(FocusAfterSync::Editor(_)));
let switching = active_id != self.displayed_id; let switching = active_id != self.displayed_id;
if switching { if switching {
// Запоминаем курсор покидаемой вкладки до того, как индекс // Запоминаем курсор покидаемой вкладки до того, как индекс
@@ -1315,7 +1612,7 @@ impl MyApp {
self.displayed_id = active_id; self.displayed_id = active_id;
let highlight = language let highlight = language
.map(|language| language.highlight_name()) .map(|language| language.syntax_name())
.unwrap_or(""); .unwrap_or("");
self.editor.update(cx, |state, cx| { self.editor.update(cx, |state, cx| {
if state.value().as_ref() != content.as_str() { if state.value().as_ref() != content.as_str() {
@@ -1324,16 +1621,20 @@ impl MyApp {
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 { if switching || focus_cursor.is_some() {
let cursor = cursor.min(state.value().len()); let cursor = focus_cursor.unwrap_or(cursor).min(state.value().len());
state.set_selected_range(cursor..cursor, cx); state.set_selected_range(cursor..cursor, cx);
} }
state.focus(window, cx); if focus_editor {
state.focus(window, cx);
}
}); });
if switching { if switching {
self.close_completion(cx); self.close_completion(cx);
self.sync_lsp_document(); self.sync_lsp_document();
// У другой вкладки своя диагностика, уже пришедшая от сервера.
self.apply_diagnostics(cx);
} }
} }
@@ -1371,19 +1672,16 @@ impl MyApp {
// Нижняя панель занимает ширину редактора, а правая тянется на всю // Нижняя панель занимает ширину редактора, а правая тянется на всю
// высоту — как в большинстве редакторов кода. // высоту — как в большинстве редакторов кода.
let editor_area = let editor_area = v_resizable("editor-area")
v_resizable("editor-area") .child(resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full()))
.child( .child(
resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full()), resizable_panel()
) .size(px(bottom_size))
.child( .visible(bottom_open)
resizable_panel() .child(div().size_full().bg(cx.theme().sidebar).child(
.size(px(bottom_size)) self.render_panel_content(&self.bottom_panel, false, true, window, cx),
.visible(bottom_open) )),
.child(div().size_full().bg(cx.theme().sidebar).child( );
self.render_panel_content(&self.bottom_panel, false, window, cx),
)),
);
let area = h_resizable("main-area") let area = h_resizable("main-area")
.child(resizable_panel().child(editor_area)) .child(resizable_panel().child(editor_area))
@@ -1391,12 +1689,9 @@ impl MyApp {
resizable_panel() resizable_panel()
.size(px(right_size)) .size(px(right_size))
.visible(right_open) .visible(right_open)
.child( .child(div().size_full().bg(cx.theme().sidebar).child(
div() self.render_panel_content(&self.right_panel, true, false, window, cx),
.size_full() )),
.bg(cx.theme().sidebar)
.child(self.render_panel_content(&self.right_panel, true, window, cx)),
),
); );
let mut row = div().size_full().flex().flex_row(); let mut row = div().size_full().flex().flex_row();
@@ -1408,7 +1703,7 @@ impl MyApp {
.flex_shrink_0() .flex_shrink_0()
.relative() .relative()
.bg(cx.theme().sidebar) .bg(cx.theme().sidebar)
.child(self.render_panel_content(&self.left_panel, false, window, cx)) .child(self.render_panel_content(&self.left_panel, false, false, window, cx))
.child( .child(
resize_handle("left-panel-handle", Axis::Horizontal) resize_handle("left-panel-handle", Axis::Horizontal)
.inside(HandleEdge::Trailing) .inside(HandleEdge::Trailing)
@@ -1445,7 +1740,7 @@ impl MyApp {
( (
doc.name.clone(), doc.name.clone(),
doc.is_dirty(), doc.is_dirty(),
icons::tab_icon(doc.icon.as_deref()), icons::tab_icon(Some(doc.icon_name())),
) )
}) })
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
@@ -1547,6 +1842,14 @@ impl Render for MyApp {
self.editor.update(cx, |state, cx| state.focus(window, cx)); self.editor.update(cx, |state, cx| state.focus(window, cx));
} }
// Панели могут отложить возврат фокуса своему дереву — явный запрос
// `app:open(path, ...)` применяем ещё раз уже после их отрисовки.
let workspace = self.render_workspace(window, cx).into_any_element();
if self.focus_editor_after_render {
self.focus_editor_after_render = false;
self.editor.update(cx, |state, cx| state.focus(window, cx));
}
let title = { let title = {
let workspace = self.workspace.borrow(); let workspace = self.workspace.borrow();
format!("{} — {WINDOW_TITLE}", workspace.active().name) format!("{} — {WINDOW_TITLE}", workspace.active().name)
@@ -1566,12 +1869,7 @@ impl Render for MyApp {
let keymap = keymap.clone(); let keymap = keymap.clone();
let entity = entity.clone(); let entity = entity.clone();
move |event, window, cx| { move |event, window, cx| {
let mut handled = false; let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, true);
if is_save_shortcut(&event.keystroke) {
save_active_tab();
handled = true;
}
handled |= dispatch_keymap(&keymap, &event.keystroke, true);
// Обработчик мог поставить команду окна или изменить документы. // Обработчик мог поставить команду окна или изменить документы.
apply_window_commands(window); apply_window_commands(window);
if handled { if handled {
@@ -1583,7 +1881,7 @@ impl Render for MyApp {
let keymap = keymap.clone(); let keymap = keymap.clone();
let entity = entity.clone(); let entity = entity.clone();
move |event, window, cx| { move |event, window, cx| {
let handled = dispatch_keymap(&keymap, &event.keystroke, false); let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, false);
apply_window_commands(window); apply_window_commands(window);
if handled { if handled {
entity.update(cx, |_, cx| cx.notify()); entity.update(cx, |_, cx| cx.notify());
@@ -1591,32 +1889,30 @@ impl Render for MyApp {
} }
}) })
.child( .child(
TitleBar::new().child( TitleBar::new().child(div().w_full().pr_2().text_sm().font_medium().child(title)),
h_flex()
.w_full()
.pr_2()
.justify_between()
.child(div().text_sm().font_medium().child(title))
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("gpui-kit · tree-sitter"),
),
),
) )
.child(self.render_tabs(cx)) .child(self.render_tabs(cx))
.child( .child(div().flex_1().w_full().min_h_0().child(workspace))
div()
.flex_1()
.w_full()
.min_h_0()
.child(self.render_workspace(window, cx)),
)
.child(self.render_completion_popup(cx)) .child(self.render_completion_popup(cx))
} }
} }
/// Переводит диапазон LSP в байтовый диапазон текста.
///
/// Пустой диапазон (например, «ожидалось `;`») расширяется на следующий
/// символ: иначе подчёркивать было бы нечего.
fn diagnostic_range(text: &str, range: lsp_types::Range) -> Option<Range<usize>> {
let start = position_to_offset(text, range.start).min(text.len());
let mut end = position_to_offset(text, range.end).min(text.len());
if end <= start {
end = text[start..]
.chars()
.next()
.map_or(start, |ch| start + ch.len_utf8());
}
(start < end).then_some(start..end)
}
/// Выполняет накопленные команды окна. /// Выполняет накопленные команды окна.
fn apply_window_commands(window: &mut Window) { fn apply_window_commands(window: &mut Window) {
for command in api::drain_window_commands() { for command in api::drain_window_commands() {
@@ -1636,20 +1932,6 @@ fn apply_window_commands(window: &mut Window) {
} }
} }
/// Ctrl+S (Cmd+S на macOS) сохраняет активную вкладку.
fn is_save_shortcut(keystroke: &Keystroke) -> bool {
keystroke.key.eq_ignore_ascii_case("s")
&& (keystroke.modifiers.control || keystroke.modifiers.platform)
&& !keystroke.modifiers.alt
}
/// Ctrl+Space открывает автодополнение вручную.
fn is_completion_shortcut(keystroke: &Keystroke) -> bool {
keystroke.key.eq_ignore_ascii_case("space")
&& (keystroke.modifiers.control || keystroke.modifiers.platform)
&& !keystroke.modifiers.alt
}
/// Вызывает Lua-обработчик, назначенный блоку дерева. /// Вызывает Lua-обработчик, назначенный блоку дерева.
/// ///
/// Ошибка Lua не должна ломать отрисовку: она печатается в stderr, как и /// Ошибка Lua не должна ломать отрисовку: она печатается в stderr, как и
@@ -1660,21 +1942,19 @@ fn run_tree_action(action: TreeAction) {
id, id,
expanded, expanded,
loaded, loaded,
callback, callback: Some(callback),
} => { } => report_tree_error(callback.call::<()>((expanded, id, loaded))),
if let Some(callback) = callback TreeAction::Click {
&& let Err(err) = callback.call::<()>((expanded, id, loaded)) id,
{ callback: Some(callback),
eprintln!("Lua: ошибка в обработчике дерева: {err}"); } => report_tree_error(callback.call::<()>(id)),
} TreeAction::Toggle { callback: None, .. } | TreeAction::Click { callback: None, .. } => {}
} }
TreeAction::Click { id, callback } => { }
if let Some(callback) = callback
&& let Err(err) = callback.call::<()>(id) fn report_tree_error(result: mlua::Result<()>) {
{ if let Err(err) = result {
eprintln!("Lua: ошибка в обработчике дерева: {err}"); eprintln!("Lua: ошибка в обработчике дерева: {err}");
}
}
} }
} }
@@ -1685,17 +1965,70 @@ fn save_active_tab() {
} }
} }
/// Вызывает Lua-обработчики, совпавшие с событием клавиатуры. /// Выполняет встроенную команду приложения.
fn run_app_command(command: AppCommand) {
let app = AppApi::global();
match command {
AppCommand::Save => save_active_tab(),
AppCommand::NextTab => {
app.next_tab();
}
AppCommand::PreviousTab => {
app.previous_tab();
}
AppCommand::OpenTab => {
app.open_tab();
}
AppCommand::CloseTab => {
app.close_tab(app.active_tab_index());
}
AppCommand::ToggleLeftPanel => {
app.left_panel().toggle();
}
AppCommand::ToggleRightPanel => {
app.right_panel().toggle();
}
AppCommand::ToggleBottomPanel => {
app.bottom_panel().toggle();
}
}
}
/// Клавиша закрывает список автодополнения, даже если привязки не назначено.
/// ///
/// Возвращает `true`, если хотя бы один обработчик вызван. Заём карты /// Это сохраняет привычное поведение: Shift+↑ выделяет текст, а не листает
/// список, а стрелки влево/вправо и Home/End уводят курсор из-под списка.
fn dismisses_completion(keystroke: &Keystroke) -> bool {
let modified = keystroke.modifiers.control
|| keystroke.modifiers.platform
|| keystroke.modifiers.alt
|| keystroke.modifiers.shift;
modified || matches!(keystroke.key.as_str(), "left" | "right" | "home" | "end")
}
/// Вызывает обработчики одного слоя `KeyMap` для события клавиатуры.
///
/// Возвращает `true`, если хотя бы одна привязка сработала. Заём карты
/// заканчивается до вызова колбэков: обработчик может сам вызвать /// заканчивается до вызова колбэков: обработчик может сам вызвать
/// `app:map_key` и изменить привязки. /// `app:map_key` и изменить привязки.
fn dispatch_keymap(keymap: &Rc<RefCell<KeyMap>>, keystroke: &Keystroke, pressed: bool) -> bool { fn run_keymap(
let callbacks = keymap.borrow().callbacks_for(keystroke, pressed); keymap: &Rc<RefCell<KeyMap>>,
let handled = !callbacks.is_empty(); context: KeyContext,
for callback in callbacks { keystroke: &Keystroke,
if let Err(err) = callback.call::<()>(()) { pressed: bool,
eprintln!("Lua: ошибка в обработчике клавиши: {err}"); ) -> bool {
let handlers = keymap.borrow().handlers_for(context, keystroke, pressed);
let handled = !handlers.is_empty();
for handler in handlers {
match handler {
KeyHandler::Lua(callback) => {
if let Err(err) = callback.call::<()>(()) {
eprintln!("Lua: ошибка в обработчике клавиши: {err}");
}
}
KeyHandler::App(command) => run_app_command(command),
// Другой слой обрабатывается отдельно.
KeyHandler::Completion(_) => {}
} }
} }
handled handled
+70 -43
View File
@@ -281,6 +281,56 @@ pub fn completion_kind_name(kind: Option<CompletionItemKind>) -> &'static str {
} }
} }
/// Группа элемента автодополнения: по ней выбираются иконка и цвет.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CompletionKindGroup {
Function,
Type,
Variable,
Keyword,
Module,
File,
Folder,
Snippet,
Event,
TypeParameter,
Operator,
Other,
Unknown,
}
fn kind_group(kind: Option<CompletionItemKind>) -> CompletionKindGroup {
match kind {
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => {
CompletionKindGroup::Function
}
Some(
CompletionItemKind::STRUCT
| CompletionItemKind::CLASS
| CompletionItemKind::INTERFACE
| CompletionItemKind::ENUM
| CompletionItemKind::CONSTRUCTOR,
) => CompletionKindGroup::Type,
Some(
CompletionItemKind::VARIABLE
| CompletionItemKind::FIELD
| CompletionItemKind::PROPERTY
| CompletionItemKind::CONSTANT
| CompletionItemKind::ENUM_MEMBER,
) => CompletionKindGroup::Variable,
Some(CompletionItemKind::KEYWORD) => CompletionKindGroup::Keyword,
Some(CompletionItemKind::MODULE) => CompletionKindGroup::Module,
Some(CompletionItemKind::FILE) => CompletionKindGroup::File,
Some(CompletionItemKind::FOLDER) => CompletionKindGroup::Folder,
Some(CompletionItemKind::SNIPPET) => CompletionKindGroup::Snippet,
Some(CompletionItemKind::EVENT) => CompletionKindGroup::Event,
Some(CompletionItemKind::TYPE_PARAMETER) => CompletionKindGroup::TypeParameter,
Some(CompletionItemKind::OPERATOR) => CompletionKindGroup::Operator,
Some(_) => CompletionKindGroup::Other,
None => CompletionKindGroup::Unknown,
}
}
/// Иконка для типа элемента автодополнения. /// Иконка для типа элемента автодополнения.
/// ///
/// Используется полный каталог иконок gpui-kit-assets, а не компактный набор /// Используется полный каталог иконок gpui-kit-assets, а не компактный набор
@@ -289,32 +339,20 @@ pub fn completion_kind_name(kind: Option<CompletionItemKind>) -> &'static str {
pub fn completion_icon(kind: Option<CompletionItemKind>) -> gpui_kit::assets::IconName { pub fn completion_icon(kind: Option<CompletionItemKind>) -> gpui_kit::assets::IconName {
use gpui_kit::assets::IconName as Icon; use gpui_kit::assets::IconName as Icon;
match kind { match kind_group(kind) {
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => Icon::SquareFunction, CompletionKindGroup::Function => Icon::SquareFunction,
Some( CompletionKindGroup::Type => Icon::Box,
CompletionItemKind::STRUCT CompletionKindGroup::Variable => Icon::Variable,
| CompletionItemKind::CLASS CompletionKindGroup::Keyword => Icon::Type,
| CompletionItemKind::INTERFACE CompletionKindGroup::Module => Icon::Braces,
| CompletionItemKind::ENUM CompletionKindGroup::File => Icon::FileText,
| CompletionItemKind::CONSTRUCTOR, CompletionKindGroup::Folder => Icon::Folder,
) => Icon::Box, CompletionKindGroup::Snippet => Icon::Component,
Some( CompletionKindGroup::Event => Icon::Activity,
CompletionItemKind::VARIABLE CompletionKindGroup::TypeParameter => Icon::TypeOutline,
| CompletionItemKind::FIELD CompletionKindGroup::Operator => Icon::CaseSensitive,
| CompletionItemKind::PROPERTY CompletionKindGroup::Other => Icon::Circle,
| CompletionItemKind::CONSTANT CompletionKindGroup::Unknown => Icon::Sparkles,
| CompletionItemKind::ENUM_MEMBER,
) => Icon::Variable,
Some(CompletionItemKind::KEYWORD) => Icon::Type,
Some(CompletionItemKind::MODULE) => Icon::Braces,
Some(CompletionItemKind::FILE) => Icon::FileText,
Some(CompletionItemKind::FOLDER) => Icon::Folder,
Some(CompletionItemKind::SNIPPET) => Icon::Component,
Some(CompletionItemKind::EVENT) => Icon::Activity,
Some(CompletionItemKind::TYPE_PARAMETER) => Icon::TypeOutline,
Some(CompletionItemKind::OPERATOR) => Icon::CaseSensitive,
Some(_) => Icon::Circle,
None => Icon::Sparkles,
} }
} }
@@ -323,23 +361,12 @@ pub fn completion_icon_color(
kind: Option<CompletionItemKind>, kind: Option<CompletionItemKind>,
theme: &gpui_kit::component::Theme, theme: &gpui_kit::component::Theme,
) -> Hsla { ) -> Hsla {
match kind { match kind_group(kind) {
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => theme.blue, CompletionKindGroup::Function => theme.blue,
Some( CompletionKindGroup::Type => theme.yellow,
CompletionItemKind::STRUCT CompletionKindGroup::Variable => theme.green,
| CompletionItemKind::CLASS CompletionKindGroup::Keyword => theme.magenta,
| CompletionItemKind::INTERFACE CompletionKindGroup::Module => theme.cyan,
| CompletionItemKind::ENUM,
) => theme.yellow,
Some(
CompletionItemKind::VARIABLE
| CompletionItemKind::FIELD
| CompletionItemKind::PROPERTY
| CompletionItemKind::CONSTANT
| CompletionItemKind::ENUM_MEMBER,
) => theme.green,
Some(CompletionItemKind::KEYWORD) => theme.magenta,
Some(CompletionItemKind::MODULE) => theme.cyan,
_ => theme.muted_foreground, _ => theme.muted_foreground,
} }
} }
+1 -1
View File
@@ -17,7 +17,7 @@ pub const MIN_WINDOW_WIDTH: f32 = 420.0;
pub const MIN_WINDOW_HEIGHT: f32 = 280.0; pub const MIN_WINDOW_HEIGHT: f32 = 280.0;
/// Подпись окна в панели задач и в заголовке. /// Подпись окна в панели задач и в заголовке.
pub const WINDOW_TITLE: &str = "Custom Window App"; pub const WINDOW_TITLE: &str = "qode";
/// Ширина одного уровня отступа в редакторе. /// Ширина одного уровня отступа в редакторе.
pub const TAB_SIZE: usize = 4; pub const TAB_SIZE: usize = 4;
+53 -62
View File
@@ -25,17 +25,6 @@ pub enum Language {
} }
impl Language { impl Language {
/// Все поддерживаемые языки.
#[allow(dead_code)]
// Часть публичного интерфейса модуля: удобно для перебора языков снаружи.
pub const ALL: [Self; 5] = [
Self::Rust,
Self::Go,
Self::JavaScript,
Self::Json,
Self::Python,
];
/// Разбирает название языка из API/сценария. /// Разбирает название языка из API/сценария.
/// ///
/// Основные имена — `rust`, `go`, `js`, `json`, `python`; короткие /// Основные имена — `rust`, `go`, `js`, `json`, `python`; короткие
@@ -62,25 +51,12 @@ impl Language {
} }
} }
/// Имя языка для `EditorState::set_highlighter`. /// Полное имя языка для tree-sitter и LSP.
/// ///
/// Здесь `js` разворачивается в `javascript`: так грамматика находится /// Отличается от [`Self::name`] только для JavaScript: грамматика и
/// без дополнительных псевдонимов на нашей стороне. /// `languageId` используют `javascript`, а короткое `js` остаётся именем
pub fn highlight_name(self) -> &'static str { /// в Lua-API.
match self { pub fn syntax_name(self) -> &'static str {
Self::Rust => "rust",
Self::Go => "go",
Self::JavaScript => "javascript",
Self::Json => "json",
Self::Python => "python",
}
}
/// Идентификатор языка для LSP (`languageId` в `textDocument/didOpen`).
///
/// Отличается от [`Self::name`] тем же, чем `highlight_name`: LSP ждёт
/// `javascript`, а не `js`.
pub fn lsp_name(self) -> &'static str {
match self { match self {
Self::Rust => "rust", Self::Rust => "rust",
Self::Go => "go", Self::Go => "go",
@@ -188,17 +164,35 @@ impl Document {
/// по расширению имени вкладки — так LSP работает и без `set_language`. /// по расширению имени вкладки — так LSP работает и без `set_language`.
pub fn lsp_language_id(&self) -> String { pub fn lsp_language_id(&self) -> String {
if let Some(language) = self.language { if let Some(language) = self.language {
return language.lsp_name().to_owned(); return language.syntax_name().to_owned();
} }
language_id_from_path(&self.name).to_owned() language_id_from_path(&self.name).to_owned()
} }
/// Имя иконки вкладки с учётом иконки по умолчанию.
pub fn icon_name(&self) -> &str {
self.icon
.as_deref()
.unwrap_or(crate::icons::DEFAULT_TAB_ICON)
}
/// Помечает текущее содержимое сохранённым. /// Помечает текущее содержимое сохранённым.
pub fn mark_saved(&mut self) { pub fn mark_saved(&mut self) {
self.saved_content = self.content.clone(); self.saved_content = self.content.clone();
} }
} }
/// Что сделать с фокусом редактора после синхронизации модели с видом.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FocusAfterSync {
/// Оставить фокус там, где он был (`app:open` без позиции).
Keep,
/// Перевести фокус в редактор и, если задано, поставить курсор.
///
/// Смещение задаётся в байтах; `None` — оставить текущую позицию.
Editor(Option<usize>),
}
/// Открытые документы и активная вкладка. /// Открытые документы и активная вкладка.
/// ///
/// Инвариант: `documents` никогда не пуст — редактор всегда показывает хотя бы /// Инвариант: `documents` никогда не пуст — редактор всегда показывает хотя бы
@@ -207,6 +201,8 @@ pub struct Workspace {
documents: Vec<Document>, documents: Vec<Document>,
active: usize, active: usize,
next_untitled: u32, next_untitled: u32,
/// Действие с фокусом, ожидающее ближайшей синхронизации вида.
focus_after_sync: Option<FocusAfterSync>,
/// Номер «поколения» состояния. /// Номер «поколения» состояния.
/// ///
/// Вид перечитывает активный документ в редактор только когда ревизия /// Вид перечитывает активный документ в редактор только когда ревизия
@@ -218,12 +214,10 @@ pub struct Workspace {
impl Default for Workspace { impl Default for Workspace {
fn default() -> Self { fn default() -> Self {
Self { Self {
documents: vec![ documents: vec![Document::new("untitled-1.txt", "")],
Document::new("main.rs", SAMPLE_RUST),
Document::new("notes.md", SAMPLE_NOTES),
],
active: 0, active: 0,
next_untitled: 0, next_untitled: 1,
focus_after_sync: None,
revision: 0, revision: 0,
} }
} }
@@ -261,6 +255,16 @@ impl Workspace {
self.revision self.revision
} }
/// Откладывает действие с фокусом до ближайшей синхронизации вида.
pub fn set_focus_after_sync(&mut self, focus: FocusAfterSync) {
self.focus_after_sync = Some(focus);
}
/// Забирает отложенное действие с фокусом.
pub fn take_focus_after_sync(&mut self) -> Option<FocusAfterSync> {
self.focus_after_sync.take()
}
/// Сообщает виду, что документы нужно перечитать. /// Сообщает виду, что документы нужно перечитать.
fn bump(&mut self) { fn bump(&mut self) {
self.revision = self.revision.wrapping_add(1); self.revision = self.revision.wrapping_add(1);
@@ -274,6 +278,17 @@ impl Workspace {
} }
} }
/// Переключает активную вкладку на `delta` позиций по кругу.
///
/// Возвращает новый индекс. Инвариант «хотя бы один документ» делает
/// деление по модулю безопасным.
pub fn cycle_active(&mut self, delta: isize) -> usize {
let count = self.documents.len() as isize;
let next = (self.active as isize + delta).rem_euclid(count) as usize;
self.set_active(next);
next
}
/// Открывает пустую вкладку и делает её активной. /// Открывает пустую вкладку и делает её активной.
pub fn open_new(&mut self) { pub fn open_new(&mut self) {
self.next_untitled += 1; self.next_untitled += 1;
@@ -333,7 +348,9 @@ impl Workspace {
if self.documents.is_empty() { if self.documents.is_empty() {
self.open_new(); self.open_new();
} else if index < self.active { return;
}
if index < self.active {
self.active -= 1; self.active -= 1;
} else if index == self.active { } else if index == self.active {
self.active = self.active.min(self.documents.len() - 1); self.active = self.active.min(self.documents.len() - 1);
@@ -417,30 +434,4 @@ impl Workspace {
} }
true true
} }
/// Имя иконки вкладки, если оно назначено.
pub fn tab_icon(&self, index: usize) -> Option<&str> {
self.document(index)
.and_then(|document| document.icon.as_deref())
}
} }
const SAMPLE_RUST: &str = r#"//! Демонстрационный файл.
// Правьте код прямо в редакторе: вкладки и нумерация строк уже здесь.
fn main() {
let mut numbers = vec![3, 1, 2];
numbers.sort();
for (i, n) in numbers.iter().enumerate() {
println!("{i}: {n}");
}
}
"#;
const SAMPLE_NOTES: &str = r#"# Заметки
- «+» на панели вкладок открывает новый файл
- крестик на вкладке закрывает её
- Ctrl+S сохраняет вкладку в файл с её названием
"#;
+299 -20
View File
@@ -1,12 +1,17 @@
//! Привязки клавиш к Lua-обработчикам. //! Привязки клавиш к Lua-обработчикам и встроенным командам.
//! //!
//! Запись привязки немного напоминает nvim: //! Запись привязки немного напоминает nvim:
//! //!
//! * `a`, `1` — нажатие клавиши без модификаторов; //! * `a`, `1` — нажатие клавиши без модификаторов;
//! * `<S-c>`, `<C-z>`, `<A-r>` — нажатие с Shift, Ctrl или Alt; //! * `<S-c>`, `<C-z>`, `<A-r>`, `<D-s>` — нажатие с Shift, Ctrl, Alt или
//! платформенным модификатором (Cmd на macOS, Super на Linux);
//! * `!a` — отпускание клавиши; //! * `!a` — отпускание клавиши;
//! * `<A-!r>` — отпускание клавиши при зажатом Alt. //! * `<A-!r>` — отпускание клавиши при зажатом Alt.
//! //!
//! Привязка действует в одном из двух слоёв: глобальном или в списке
//! автодополнения. Оба слоя проходят через одну [`KeyMap`], поэтому сценарий
//! может переопределить и удалить любую встроенную привязку.
//!
//! Клавиша сравнивается с `Keystroke.key` из GPUI. Это не результат ввода, а //! Клавиша сравнивается с `Keystroke.key` из GPUI. Это не результат ввода, а
//! имя клавиши, которое платформа уже нормализует: //! имя клавиши, которое платформа уже нормализует:
//! //!
@@ -29,19 +34,21 @@ pub struct Modifiers {
pub shift: bool, pub shift: bool,
pub ctrl: bool, pub ctrl: bool,
pub alt: bool, pub alt: bool,
/// Cmd на macOS, Super на Linux и Windows.
pub platform: bool,
} }
impl Modifiers { impl Modifiers {
/// Все три модификатора должны совпасть точно: `<C-z>` не сработает, /// Все модификаторы должны совпасть точно: `<C-z>` не сработает, если
/// если вместе с Ctrl зажат ещё и Shift. /// вместе с Ctrl зажат ещё и Shift.
/// ///
/// `platform` (Cmd/Super) и `function` в записи привязки не выражаются, /// `function` в записи привязки не выражается, поэтому события с ним не
/// поэтому события с ними не совпадают ни с одной привязкой. /// совпадают ни с одной привязкой.
fn matches(self, pressed: &gpui_kit::Modifiers) -> bool { fn matches(self, pressed: &gpui_kit::Modifiers) -> bool {
self.shift == pressed.shift self.shift == pressed.shift
&& self.ctrl == pressed.control && self.ctrl == pressed.control
&& self.alt == pressed.alt && self.alt == pressed.alt
&& !pressed.platform && self.platform == pressed.platform
&& !pressed.function && !pressed.function
} }
} }
@@ -101,6 +108,7 @@ impl KeyBinding {
"S" | "s" => modifiers.shift = true, "S" | "s" => modifiers.shift = true,
"C" | "c" => modifiers.ctrl = true, "C" | "c" => modifiers.ctrl = true,
"A" | "a" => modifiers.alt = true, "A" | "a" => modifiers.alt = true,
"D" | "d" | "Cmd" | "cmd" | "Super" | "super" => modifiers.platform = true,
other => return Err(format!("неизвестный модификатор: {other}")), other => return Err(format!("неизвестный модификатор: {other}")),
} }
} }
@@ -206,10 +214,146 @@ fn canonical_key(key: &str) -> String {
lower lower
} }
/// Привязка вместе с Lua-функцией, которую нужно выполнить. /// Слой, в котором действует привязка.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum KeyContext {
/// Обычные команды приложения и редактора.
#[default]
Global,
/// Навигация, пока открыт список автодополнения.
Completion,
}
/// Встроенная команда приложения, которую можно назначить на клавишу.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AppCommand {
Save,
NextTab,
PreviousTab,
OpenTab,
CloseTab,
ToggleLeftPanel,
ToggleRightPanel,
ToggleBottomPanel,
}
impl AppCommand {
/// Все встроенные команды приложения.
pub const ALL: [Self; 8] = [
Self::Save,
Self::NextTab,
Self::PreviousTab,
Self::OpenTab,
Self::CloseTab,
Self::ToggleLeftPanel,
Self::ToggleRightPanel,
Self::ToggleBottomPanel,
];
/// Имя команды в Lua-API.
pub fn name(self) -> &'static str {
match self {
Self::Save => "save",
Self::NextTab => "next_tab",
Self::PreviousTab => "previous_tab",
Self::OpenTab => "open_tab",
Self::CloseTab => "close_tab",
Self::ToggleLeftPanel => "toggle_left_panel",
Self::ToggleRightPanel => "toggle_right_panel",
Self::ToggleBottomPanel => "toggle_bottom_panel",
}
}
/// Разбирает имя команды из Lua-обработчика.
pub fn parse(name: &str) -> Option<Self> {
match name {
"save" => Some(Self::Save),
"next_tab" => Some(Self::NextTab),
"previous_tab" => Some(Self::PreviousTab),
"open_tab" => Some(Self::OpenTab),
"close_tab" => Some(Self::CloseTab),
"toggle_left_panel" => Some(Self::ToggleLeftPanel),
"toggle_right_panel" => Some(Self::ToggleRightPanel),
"toggle_bottom_panel" => Some(Self::ToggleBottomPanel),
_ => None,
}
}
}
/// Встроенная команда списка автодополнения.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CompletionCommand {
Trigger,
Next,
Previous,
PageUp,
PageDown,
Accept,
Cancel,
/// Закрыть список и отдать клавишу редактору.
Dismiss,
}
impl CompletionCommand {
/// Все встроенные команды слоя автодополнения.
pub const ALL: [Self; 8] = [
Self::Trigger,
Self::Next,
Self::Previous,
Self::PageUp,
Self::PageDown,
Self::Accept,
Self::Cancel,
Self::Dismiss,
];
/// Имя команды в Lua-API.
pub fn name(self) -> &'static str {
match self {
Self::Trigger => "completion_trigger",
Self::Next => "completion_next",
Self::Previous => "completion_previous",
Self::PageUp => "completion_page_up",
Self::PageDown => "completion_page_down",
Self::Accept => "completion_accept",
Self::Cancel => "completion_cancel",
Self::Dismiss => "completion_dismiss",
}
}
/// Разбирает имя команды из Lua-обработчика.
pub fn parse(name: &str) -> Option<Self> {
match name {
"completion_trigger" => Some(Self::Trigger),
"completion_next" => Some(Self::Next),
"completion_previous" => Some(Self::Previous),
"completion_page_up" => Some(Self::PageUp),
"completion_page_down" => Some(Self::PageDown),
"completion_accept" => Some(Self::Accept),
"completion_cancel" => Some(Self::Cancel),
"completion_dismiss" => Some(Self::Dismiss),
_ => None,
}
}
}
/// Что нужно выполнить для совпавшей привязки.
#[derive(Clone)]
pub enum KeyHandler {
/// Функция из Lua-сценария.
Lua(mlua::Function),
/// Встроенная команда приложения.
App(AppCommand),
/// Встроенная команда автодополнения.
Completion(CompletionCommand),
}
/// Привязка вместе с обработчиком.
#[derive(Clone)]
struct Binding { struct Binding {
context: KeyContext,
binding: KeyBinding, binding: KeyBinding,
callback: mlua::Function, handler: KeyHandler,
} }
/// Все назначенные привязки клавиш. /// Все назначенные привязки клавиш.
@@ -219,32 +363,98 @@ pub struct KeyMap {
} }
impl KeyMap { impl KeyMap {
/// Добавляет привязку. Повторное назначение той же клавиши заменяет /// Назначает или заменяет обработчик в указанном слое.
/// предыдущий обработчик. pub fn set(&mut self, context: KeyContext, binding: KeyBinding, handler: KeyHandler) {
pub fn bind(&mut self, binding: KeyBinding, callback: mlua::Function) {
if let Some(existing) = self if let Some(existing) = self
.bindings .bindings
.iter_mut() .iter_mut()
.find(|existing| existing.binding == binding) .find(|existing| existing.context == context && existing.binding == binding)
{ {
existing.callback = callback; existing.handler = handler;
} else { } else {
self.bindings.push(Binding { binding, callback }); self.bindings.push(Binding {
context,
binding,
handler,
});
} }
} }
/// Собирает обработчики, которые нужно вызвать для одного события. /// Удаляет привязку из указанного слоя.
/// ///
/// Заём `KeyMap` здесь заканчивается: вызывать колбэки нужно уже после /// Возвращает `true`, если привязка была.
pub fn unbind(&mut self, context: KeyContext, binding: &KeyBinding) -> bool {
let before = self.bindings.len();
self.bindings
.retain(|existing| !(existing.context == context && &existing.binding == binding));
before != self.bindings.len()
}
/// Собирает обработчики, которые нужно выполнить для одного события.
///
/// Заём `KeyMap` здесь заканчивается: вызывать Lua нужно уже после
/// возврата из метода, потому что обработчик может сам менять привязки /// возврата из метода, потому что обработчик может сам менять привязки
/// через `app:map_key`. /// через `app:map_key`.
pub fn callbacks_for(&self, keystroke: &Keystroke, pressed: bool) -> Vec<mlua::Function> { pub fn handlers_for(
&self,
context: KeyContext,
keystroke: &Keystroke,
pressed: bool,
) -> Vec<KeyHandler> {
self.bindings self.bindings
.iter() .iter()
.filter(|binding| binding.binding.matches(keystroke, pressed)) .filter(|binding| {
.map(|binding| binding.callback.clone()) binding.context == context && binding.binding.matches(keystroke, pressed)
})
.map(|binding| binding.handler.clone())
.collect() .collect()
} }
/// Назначает встроенные привязки приложения.
///
/// Они проходят через тот же `KeyMap`, поэтому `app:map_key` и
/// `app:map_completion_key` могут их заменить или удалить.
pub fn install_defaults(&mut self) {
for spec in ["<C-s>", "<D-s>"] {
self.set(
KeyContext::Global,
parse_default(spec),
KeyHandler::App(AppCommand::Save),
);
}
for spec in ["<C-space>", "<D-space>"] {
self.set(
KeyContext::Completion,
parse_default(spec),
KeyHandler::Completion(CompletionCommand::Trigger),
);
}
for (spec, command) in [
("enter", CompletionCommand::Accept),
("tab", CompletionCommand::Accept),
("escape", CompletionCommand::Cancel),
("up", CompletionCommand::Previous),
("down", CompletionCommand::Next),
("pageup", CompletionCommand::PageUp),
("pagedown", CompletionCommand::PageDown),
("left", CompletionCommand::Dismiss),
("right", CompletionCommand::Dismiss),
("home", CompletionCommand::Dismiss),
("end", CompletionCommand::Dismiss),
] {
self.set(
KeyContext::Completion,
parse_default(spec),
KeyHandler::Completion(command),
);
}
}
}
/// Привязка из встроенного списка: ошибка здесь была бы опечаткой в коде.
fn parse_default(spec: &str) -> KeyBinding {
KeyBinding::parse(spec).expect("встроенная привязка клавиши должна разбираться")
} }
#[cfg(test)] #[cfg(test)]
@@ -335,6 +545,18 @@ mod tests {
shift: true, shift: true,
ctrl: true, ctrl: true,
alt: true, alt: true,
platform: false,
},
on_release: false,
}
);
assert_eq!(
parse("<D-s>"),
KeyBinding {
key: "s".to_owned(),
modifiers: Modifiers {
platform: true,
..Default::default()
}, },
on_release: false, on_release: false,
} }
@@ -373,9 +595,15 @@ mod tests {
control: true, control: true,
..Default::default() ..Default::default()
}; };
let shift_platform = gpui_kit::Modifiers {
shift: true,
platform: true,
..Default::default()
};
assert!(binding.matches(&stroke("c", shift), true)); assert!(binding.matches(&stroke("c", shift), true));
assert!(!binding.matches(&stroke("c", shift_ctrl), true)); assert!(!binding.matches(&stroke("c", shift_ctrl), true));
assert!(!binding.matches(&stroke("c", shift_platform), true));
assert!(!binding.matches(&stroke("c", shift), false)); assert!(!binding.matches(&stroke("c", shift), false));
assert!(!binding.matches(&stroke("v", shift), true)); assert!(!binding.matches(&stroke("v", shift), true));
} }
@@ -439,4 +667,55 @@ mod tests {
// А привязка вида `<A-!a>` — совпадает. // А привязка вида `<A-!a>` — совпадает.
assert!(parse("<A-!a>").matches(&stroke("a", alt), false)); assert!(parse("<A-!a>").matches(&stroke("a", alt), false));
} }
#[test]
fn default_bindings_are_scoped_and_can_be_removed() {
let mut keymap = KeyMap::default();
keymap.install_defaults();
let ctrl = gpui_kit::Modifiers {
control: true,
..Default::default()
};
let save = keymap.handlers_for(KeyContext::Global, &stroke("s", ctrl), true);
assert!(matches!(
save.as_slice(),
[KeyHandler::App(AppCommand::Save)]
));
let trigger = keymap.handlers_for(KeyContext::Completion, &stroke("space", ctrl), true);
assert!(matches!(
trigger.as_slice(),
[KeyHandler::Completion(CompletionCommand::Trigger)]
));
let down = keymap.handlers_for(
KeyContext::Completion,
&stroke("down", gpui_kit::Modifiers::default()),
true,
);
assert!(matches!(
down.as_slice(),
[KeyHandler::Completion(CompletionCommand::Next)]
));
// Стрелка вниз в обычном режиме принадлежит редактору.
assert!(
keymap
.handlers_for(
KeyContext::Global,
&stroke("down", gpui_kit::Modifiers::default()),
true,
)
.is_empty()
);
let binding = parse("<C-s>");
assert!(keymap.unbind(KeyContext::Global, &binding));
assert!(
keymap
.handlers_for(KeyContext::Global, &stroke("s", ctrl), true)
.is_empty()
);
}
} }
+404 -51
View File
@@ -6,6 +6,7 @@
//! * JSON-RPC 2.0 с кадрами `Content-Length`; //! * JSON-RPC 2.0 с кадрами `Content-Length`;
//! * синхронизацию открытого документа (`didOpen`/`didChange`/`didSave`); //! * синхронизацию открытого документа (`didOpen`/`didChange`/`didSave`);
//! * запросы автодополнения, подсказок и `completionItem/resolve`; //! * запросы автодополнения, подсказок и `completionItem/resolve`;
//! * хранение диагностики `textDocument/publishDiagnostics` по документам;
//! * файловый вотчер, который пересылает серверу `workspace/didChangeWatchedFiles`. //! * файловый вотчер, который пересылает серверу `workspace/didChangeWatchedFiles`.
//! //!
//! Всё остальное — какие серверы запускать, с какими аргументами и когда — //! Всё остальное — какие серверы запускать, с какими аргументами и когда —
@@ -26,11 +27,108 @@ use std::sync::{Arc, Weak};
use std::time::Duration; use std::time::Duration;
use globset::{Glob, GlobSet, GlobSetBuilder}; use globset::{Glob, GlobSet, GlobSetBuilder};
use lsp_types::{CompletionItem, Position, Uri}; use lsp_types::{
CompletionItem, Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString, Position, Uri,
};
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use parking_lot::Mutex; use parking_lot::Mutex;
use serde_json::{Value, json}; use serde_json::{Value, json};
/// Диагностика одного сервера для одного документа.
///
/// Эту структуру Lua получает в обработчике `app:on_lsp_diagnostics(...)`:
/// она содержит имя приславшего сервера, URI и путь файла, а также список
/// сообщений.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DocumentDiagnostics {
/// Имя LSP-сервера, который прислал диагностику.
pub server: String,
/// URI документа в виде, в котором его прислал сервер.
pub uri: String,
/// Абсолютный путь файла для `file://`-URI; `None` для остальных схем.
pub path: Option<String>,
/// Сообщения диагностики в порядке, присланном сервером.
pub diagnostics: Vec<DiagnosticInfo>,
}
/// Одно сообщение диагностики.
///
/// Позиции, как и в LSP, нумеруются с нуля; `line` — строка, `character` —
/// столбец в UTF-16. Для человекочитаемого номера строки прибавьте единицу.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiagnosticInfo {
/// Начальная строка (с нуля).
pub line: u32,
/// Начальный столбец в UTF-16 (с нуля).
pub character: u32,
/// Конечная строка (с нуля).
pub end_line: u32,
/// Конечный столбец в UTF-16 (с нуля).
pub end_character: u32,
/// Числовой уровень: 1 — ошибка, 2 — предупреждение, 3 — информация,
/// 4 — подсказка.
pub severity: Option<i32>,
/// Имя уровня: `error`, `warning`, `information` или `hint`.
pub severity_name: Option<String>,
/// Текст сообщения.
pub message: String,
/// Источник сообщения (обычно имя сервера).
pub source: Option<String>,
/// Код ошибки, если сервер его прислал.
pub code: Option<String>,
/// Теги: `unnecessary`, `deprecated`.
pub tags: Vec<String>,
}
impl DiagnosticInfo {
/// Переводит сообщение протокола в структуру для Lua.
pub fn from_lsp(diagnostic: &Diagnostic) -> Self {
let (severity, severity_name) = match diagnostic.severity {
Some(value) if value == DiagnosticSeverity::ERROR => (Some(1), Some("error")),
Some(value) if value == DiagnosticSeverity::WARNING => (Some(2), Some("warning")),
Some(value) if value == DiagnosticSeverity::INFORMATION => {
(Some(3), Some("information"))
}
Some(value) if value == DiagnosticSeverity::HINT => (Some(4), Some("hint")),
_ => (None, None),
};
let code = diagnostic.code.as_ref().map(|code| match code {
NumberOrString::Number(code) => code.to_string(),
NumberOrString::String(code) => code.clone(),
});
let tags = diagnostic
.tags
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|tag| {
if tag == &DiagnosticTag::UNNECESSARY {
Some("unnecessary".to_owned())
} else if tag == &DiagnosticTag::DEPRECATED {
Some("deprecated".to_owned())
} else {
None
}
})
.collect();
Self {
line: diagnostic.range.start.line,
character: diagnostic.range.start.character,
end_line: diagnostic.range.end.line,
end_character: diagnostic.range.end.character,
severity,
severity_name: severity_name.map(str::to_owned),
message: diagnostic.message.clone(),
source: diagnostic.source.clone(),
code,
tags,
}
}
}
/// Настройки файлового вотчера для одного LSP-сервера. /// Настройки файлового вотчера для одного LSP-сервера.
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct WatcherConfig { pub struct WatcherConfig {
@@ -75,6 +173,135 @@ struct ActiveDocument {
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>>>;
/// Диагностика, опубликованная серверами, сгруппированная по документу.
///
/// Внутренняя карта хранит сообщения по имени сервера: так остановка одного
/// сервера убирает только его сообщения, а не всю диагностику документа.
struct DiagnosticsStore {
by_uri: Mutex<HashMap<String, HashMap<String, Vec<lsp_types::Diagnostic>>>>,
/// Номер поколения: растёт при каждом изменении.
revision: AtomicU64,
/// Задача вида, которую надо разбудить после изменения.
///
/// Диагностика приходит в фоновом потоке, поэтому вместо блокирующего
/// ожидания канала UI подписывается на `Waker`.
waker: Mutex<Option<std::task::Waker>>,
}
impl DiagnosticsStore {
fn new() -> Self {
Self {
by_uri: Mutex::new(HashMap::new()),
revision: AtomicU64::new(0),
waker: Mutex::new(None),
}
}
/// Заменяет набор диагностики сервера для документа.
///
/// Пустой список (сервер прислал `[]`) удаляет сообщения сервера.
fn replace(&self, server: &str, uri: &str, diagnostics: Vec<lsp_types::Diagnostic>) {
{
let mut by_uri = self.by_uri.lock();
if diagnostics.is_empty() {
let Some(by_server) = by_uri.get_mut(uri) else {
return;
};
if by_server.remove(server).is_none() {
return;
}
if by_server.is_empty() {
by_uri.remove(uri);
}
} else {
by_uri
.entry(uri.to_owned())
.or_default()
.insert(server.to_owned(), diagnostics);
}
}
self.notify();
}
/// Удаляет диагностику документа целиком (например, при закрытии вкладки).
fn clear_uri(&self, uri: &str) {
if self.by_uri.lock().remove(uri).is_some() {
self.notify();
}
}
/// Удаляет диагностику остановленного сервера во всех документах.
fn clear_server(&self, server: &str) {
let changed = {
let mut by_uri = self.by_uri.lock();
let mut changed = false;
by_uri.retain(|_, by_server| {
if by_server.remove(server).is_some() {
changed = true;
}
!by_server.is_empty()
});
changed
};
if changed {
self.notify();
}
}
/// Все известные сообщения по документу, от всех серверов.
fn diagnostics(&self, uri: &str) -> Vec<lsp_types::Diagnostic> {
self.by_uri
.lock()
.get(uri)
.map(|by_server| by_server.values().flatten().cloned().collect::<Vec<_>>())
.unwrap_or_default()
}
/// Снимок всей диагностики: по одной записи на пару «документ, сервер».
///
/// Записи сортируются по пути и имени сервера, чтобы Lua-сценарий получал
/// стабильный порядок независимо от порядка `HashMap`.
fn snapshot(&self) -> Vec<DocumentDiagnostics> {
let by_uri = self.by_uri.lock();
let mut entries = Vec::new();
for (uri, by_server) in by_uri.iter() {
for (server, diagnostics) in by_server.iter() {
if diagnostics.is_empty() {
continue;
}
entries.push(DocumentDiagnostics {
server: server.clone(),
uri: uri.clone(),
path: uri_to_path(uri).map(|path| path.to_string_lossy().into_owned()),
diagnostics: diagnostics.iter().map(DiagnosticInfo::from_lsp).collect(),
});
}
}
entries.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| a.uri.cmp(&b.uri))
.then_with(|| a.server.cmp(&b.server))
});
entries
}
fn notify(&self) {
self.revision.fetch_add(1, Ordering::SeqCst);
if let Some(waker) = self.waker.lock().take() {
waker.wake();
}
}
fn revision(&self) -> u64 {
self.revision.load(Ordering::SeqCst)
}
fn register_waker(&self, waker: std::task::Waker) {
*self.waker.lock() = Some(waker);
}
}
/// Менеджер всех запущенных LSP-серверов. /// Менеджер всех запущенных LSP-серверов.
/// ///
/// Живёт в `Arc`, потому что его одновременно читают UI, Lua-API и фоновые /// Живёт в `Arc`, потому что его одновременно читают UI, Lua-API и фоновые
@@ -83,6 +310,7 @@ pub struct LspManager {
servers: Mutex<BTreeMap<String, Arc<LspServer>>>, servers: Mutex<BTreeMap<String, Arc<LspServer>>>,
active: Mutex<Option<ActiveDocument>>, active: Mutex<Option<ActiveDocument>>,
log: LogBuffer, log: LogBuffer,
diagnostics: Arc<DiagnosticsStore>,
} }
impl Default for LspManager { impl Default for LspManager {
@@ -97,6 +325,7 @@ impl LspManager {
servers: Mutex::new(BTreeMap::new()), servers: Mutex::new(BTreeMap::new()),
active: Mutex::new(None), active: Mutex::new(None),
log: Arc::new(Mutex::new(VecDeque::new())), log: Arc::new(Mutex::new(VecDeque::new())),
diagnostics: Arc::new(DiagnosticsStore::new()),
} }
} }
@@ -119,7 +348,7 @@ impl LspManager {
} }
config.root = std::fs::canonicalize(&config.root).unwrap_or(config.root); config.root = std::fs::canonicalize(&config.root).unwrap_or(config.root);
let server = LspServer::spawn(config.clone())?; let server = LspServer::spawn(config.clone(), self.diagnostics.clone())?;
// `initialize` — единственный запрос, который ждём прямо здесь: // `initialize` — единственный запрос, который ждём прямо здесь:
// без ответа сервер нельзя считать рабочим. // без ответа сервер нельзя считать рабочим.
@@ -170,6 +399,7 @@ impl LspManager {
return false; return false;
}; };
server.shutdown(); server.shutdown();
self.diagnostics.clear_server(name);
self.log(format!("остановлен LSP-сервер «{name}»")); self.log(format!("остановлен LSP-сервер «{name}»"));
true true
} }
@@ -209,33 +439,33 @@ impl LspManager {
/// Первый вызов для документа отправляет `didOpen`, последующие с /// Первый вызов для документа отправляет `didOpen`, последующие с
/// изменившимся текстом — `didChange` с новой версией. /// изменившимся текстом — `didChange` с новой версией.
pub fn sync_document(&self, uri: Uri, language_id: String, text: &str) { pub fn sync_document(&self, uri: Uri, language_id: String, text: &str) {
let version; let version = {
{
let mut active = self.active.lock(); let mut active = self.active.lock();
if let Some(document) = active.as_mut() match active.as_mut() {
&& document.uri == uri Some(document) if document.uri == uri => {
&& document.text == text if document.text == text {
{ if document.language_id != language_id {
if document.language_id != language_id { document.language_id = language_id;
document.language_id = language_id; }
return;
}
document.version = document.version.wrapping_add(1);
document.text = text.to_owned();
document.language_id = language_id.clone();
document.version
}
_ => {
*active = Some(ActiveDocument {
uri: uri.clone(),
language_id: language_id.clone(),
version: 1,
text: text.to_owned(),
});
1
} }
return;
} }
};
let same_uri = active.as_ref().is_some_and(|document| document.uri == uri);
version = if same_uri {
active.as_ref().map_or(1, |document| document.version + 1)
} else {
1
};
*active = Some(ActiveDocument {
uri: uri.clone(),
language_id: language_id.clone(),
version,
text: text.to_owned(),
});
}
for server in self.servers.lock().values() { for server in self.servers.lock().values() {
if !server.serves(&language_id) { if !server.serves(&language_id) {
@@ -263,6 +493,9 @@ impl LspManager {
for server in self.servers.lock().values() { for server in self.servers.lock().values() {
server.did_close(uri); server.did_close(uri);
} }
// Сервер обычно присылает пустой список сам, но закрытая вкладка
// не должна оставлять подчёркивания при повторном открытии файла.
self.diagnostics.clear_uri(uri.as_str());
} }
/// Отправляет `textDocument/completion` в подходящий сервер. /// Отправляет `textDocument/completion` в подходящий сервер.
@@ -327,6 +560,46 @@ impl LspManager {
server.request("completionItem/resolve", params) server.request("completionItem/resolve", params)
} }
/// Номер поколения диагностики: меняется при каждом обновлении.
///
/// Вид запоминает его и повторно проверяет после регистрации `Waker`,
/// чтобы не потерять обновление между проверкой и ожиданием.
pub fn diagnostics_revision(&self) -> u64 {
self.diagnostics.revision()
}
/// Запоминает задачу, которую надо разбудить при обновлении диагностики.
pub fn register_diagnostics_waker(&self, waker: std::task::Waker) {
self.diagnostics.register_waker(waker);
}
/// Диагностика, известная для документа с указанным URI.
pub fn diagnostics(&self, uri: &str) -> Vec<lsp_types::Diagnostic> {
self.diagnostics.diagnostics(uri)
}
/// Снимок всей диагностики всех серверов.
///
/// Используется обработчиком `app:on_lsp_diagnostics(...)`: он получает
/// файлы, серверы и сообщения целиком, а не только для активной вкладки.
pub fn diagnostics_snapshot(&self) -> Vec<DocumentDiagnostics> {
self.diagnostics.snapshot()
}
/// Будит подписчиков диагностики, даже если новых сообщений не было.
///
/// Нужен при регистрации Lua-обработчика: он должен получить уже
/// накопленную диагностику, не дожидаясь следующего ответа сервера.
pub fn refresh_diagnostics(&self) {
self.diagnostics.notify();
}
/// Кладёт диагностику в хранилище от имени тестового сервера.
#[cfg(test)]
pub fn set_diagnostics_for_test(&self, uri: &str, diagnostics: Vec<lsp_types::Diagnostic>) {
self.diagnostics.replace("test", uri, diagnostics);
}
/// Последние строки журнала серверов. /// Последние строки журнала серверов.
pub fn log_lines(&self) -> Vec<String> { pub fn log_lines(&self) -> Vec<String> {
self.log.lock().iter().cloned().collect() self.log.lock().iter().cloned().collect()
@@ -340,17 +613,11 @@ impl LspManager {
let servers = self.servers.lock(); let servers = self.servers.lock();
let mut fallback = None; let mut fallback = None;
for server in servers.values() { for server in servers.values() {
if server if server.config.languages.is_empty() {
.config fallback.get_or_insert_with(|| server.clone());
.languages } else if server.serves(language_id) {
.iter()
.any(|language| language.eq_ignore_ascii_case(language_id))
{
return Some(server.clone()); return Some(server.clone());
} }
if server.config.languages.is_empty() && fallback.is_none() {
fallback = Some(server.clone());
}
} }
fallback fallback
} }
@@ -369,10 +636,11 @@ struct LspServer {
watcher: Mutex<Option<RecommendedWatcher>>, watcher: Mutex<Option<RecommendedWatcher>>,
alive: AtomicBool, alive: AtomicBool,
log: LogBuffer, log: LogBuffer,
diagnostics: Arc<DiagnosticsStore>,
} }
impl LspServer { impl LspServer {
fn spawn(config: LspConfig) -> Result<Arc<Self>, String> { fn spawn(config: LspConfig, diagnostics: Arc<DiagnosticsStore>) -> Result<Arc<Self>, String> {
let mut command = Command::new(&config.command); let mut command = Command::new(&config.command);
command command
.args(&config.args) .args(&config.args)
@@ -418,6 +686,7 @@ impl LspServer {
watcher: Mutex::new(None), watcher: Mutex::new(None),
alive: AtomicBool::new(true), alive: AtomicBool::new(true),
log: log.clone(), log: log.clone(),
diagnostics,
}); });
spawn_stdout_reader(Arc::downgrade(&server), stdout); spawn_stdout_reader(Arc::downgrade(&server), stdout);
@@ -583,24 +852,25 @@ impl LspServer {
fn handle_notification(&self, method: &str, message: &Value) { fn handle_notification(&self, method: &str, message: &Value) {
match method { match method {
"textDocument/publishDiagnostics" => { "textDocument/publishDiagnostics" => {
let uri = message let Some(uri) = message.pointer("/params/uri").and_then(Value::as_str) else {
.pointer("/params/uri") return;
.and_then(Value::as_str) };
.unwrap_or("?"); let diagnostics = message
let count = message
.pointer("/params/diagnostics") .pointer("/params/diagnostics")
.and_then(Value::as_array) .and_then(Value::as_array)
.map_or(0, Vec::len); .map(|items| {
if count > 0 { items
self.log(format!("диагностика {uri}: {count}")); .iter()
.filter_map(|item| serde_json::from_value(item.clone()).ok())
.collect::<Vec<lsp_types::Diagnostic>>()
})
.unwrap_or_default();
if !diagnostics.is_empty() {
self.log(format!("диагностика {uri}: {}", diagnostics.len()));
} }
self.diagnostics.replace(&self.name, uri, diagnostics);
} }
"window/logMessage" | "window/showMessage" => { "window/logMessage" | "window/showMessage" | "window/logTrace" => {
if let Some(text) = message.pointer("/params/message").and_then(Value::as_str) {
self.log(text.to_owned());
}
}
"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) {
self.log(text.to_owned()); self.log(text.to_owned());
} }
@@ -894,7 +1164,7 @@ fn initialize_params(config: &LspConfig) -> Value {
params.insert( params.insert(
"clientInfo".into(), "clientInfo".into(),
json!({ json!({
"name": "gpui-editor", "name": "qode",
"version": env!("CARGO_PKG_VERSION"), "version": env!("CARGO_PKG_VERSION"),
}), }),
); );
@@ -979,6 +1249,59 @@ pub fn path_to_uri(path: &Path) -> Uri {
Uri::from_str(&encoded).expect("путь превращается в корректный file:// URI") Uri::from_str(&encoded).expect("путь превращается в корректный file:// URI")
} }
/// Абсолютный путь из `file://`-URI с раскодированием процентов.
///
/// Для URI других схем возвращает `None`: диагностика может относиться к
/// виртуальному документу, у которого нет файла на диске.
fn uri_to_path(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://")?;
let mut path = percent_decode(rest);
// На Windows file:///C:/dir после снятия схемы выглядит как /C:/dir.
if cfg!(windows) {
let bytes = path.as_bytes();
if bytes.len() >= 3
&& bytes[0] == b'/'
&& bytes[1].is_ascii_alphabetic()
&& bytes[2] == b':'
{
path.remove(0);
}
}
Some(PathBuf::from(path))
}
/// Раскодирует `%XX` в строке URI.
fn percent_decode(input: &str) -> String {
fn hex(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
let bytes = input.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
let high = bytes.get(index + 1).copied().and_then(hex);
let low = bytes.get(index + 2).copied().and_then(hex);
if let (Some(high), Some(low)) = (high, low) {
decoded.push(high * 16 + low);
index += 3;
continue;
}
}
decoded.push(bytes[index]);
index += 1;
}
String::from_utf8_lossy(&decoded).into_owned()
}
/// URI документа по его имени во вкладке редактора. /// URI документа по его имени во вкладке редактора.
/// ///
/// Относительные имена считаются от текущего рабочего каталога — именно /// Относительные имена считаются от текущего рабочего каталога — именно
@@ -1068,6 +1391,36 @@ mod tests {
assert!(read_message(&mut empty).unwrap().is_none()); assert!(read_message(&mut empty).unwrap().is_none());
} }
#[test]
fn diagnostics_store_keeps_servers_separate_and_signals() {
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()
};
let revision = store.revision();
assert_eq!(revision, 0);
store.replace("rust-analyzer", "file:///a.rs", vec![diagnostic("error")]);
assert!(store.revision() > revision);
assert_eq!(store.diagnostics("file:///a.rs").len(), 1);
// Второй сервер добавляет своё, не затирая чужое.
store.replace("other", "file:///a.rs", vec![diagnostic("warning")]);
assert_eq!(store.diagnostics("file:///a.rs").len(), 2);
// Пустой список удаляет только сообщения этого сервера.
store.replace("rust-analyzer", "file:///a.rs", Vec::new());
let left = store.diagnostics("file:///a.rs");
assert_eq!(left.len(), 1);
assert_eq!(left[0].message, "warning");
store.clear_server("other");
assert!(store.diagnostics("file:///a.rs").is_empty());
}
#[test] #[test]
fn positions_use_utf16_columns() { fn positions_use_utf16_columns() {
let text = "a\nβγ\n🙂x"; let text = "a\nβγ\n🙂x";
+2 -2
View File
@@ -62,8 +62,8 @@ fn apply_command_line() -> Result<(), String> {
Ok(()) Ok(())
} }
/// Параметры окна в стиле исходного приложения: размер 900×600, минимум /// Параметры окна: размер 900×600, минимум 420×280 и собственный
/// 420×280 и собственный заголовок вместо системного. /// заголовок вместо системного.
fn window_options() -> WindowOptions { fn window_options() -> WindowOptions {
WindowOptions { WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::new( window_bounds: Some(WindowBounds::Windowed(Bounds::new(
+53 -3
View File
@@ -7,6 +7,10 @@
//! //!
//! Панель состоит из блоков. Блок — это независимый виджет, который сценарий //! Панель состоит из блоков. Блок — это независимый виджет, который сценарий
//! создаёт через API панели (сейчас поддерживается [`PanelBlock::Tree`]). //! создаёт через API панели (сейчас поддерживается [`PanelBlock::Tree`]).
//!
//! У блока есть заголовок. Боковые панели показывают все блоки подряд и
//! заголовки не рисуют, а нижняя панель выводит блоки вкладками и показывает
//! только активный блок.
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
@@ -20,7 +24,21 @@ pub type PanelHandle = Rc<RefCell<PanelState>>;
#[derive(Clone)] #[derive(Clone)]
pub enum PanelBlock { pub enum PanelBlock {
/// Абстрактное дерево, наполняемое из Lua. /// Абстрактное дерево, наполняемое из Lua.
Tree(TreeHandle), Tree {
/// Дерево блока.
tree: TreeHandle,
/// Заголовок вкладки; боковые панели его не показывают.
title: String,
},
}
impl PanelBlock {
/// Заголовок блока — он же заголовок вкладки нижней панели.
pub fn title(&self) -> &str {
match self {
Self::Tree { title, .. } => title,
}
}
} }
/// Открыта ли панель, каким размером она раскрывается и какие блоки содержит. /// Открыта ли панель, каким размером она раскрывается и какие блоки содержит.
@@ -32,6 +50,9 @@ pub struct PanelState {
visible: bool, visible: bool,
size: f32, size: f32,
blocks: Vec<PanelBlock>, blocks: Vec<PanelBlock>,
/// Активный блок — вкладка нижней панели. Боковые панели рисуют все блоки
/// подряд и это поле не используют.
active: usize,
} }
impl PanelState { impl PanelState {
@@ -41,6 +62,7 @@ impl PanelState {
visible: false, visible: false,
size: size.max(0.0), size: size.max(0.0),
blocks: Vec::new(), blocks: Vec::new(),
active: 0,
} }
} }
@@ -80,10 +102,38 @@ impl PanelState {
&self.blocks &self.blocks
} }
/// Индекс активного блока (вкладки нижней панели).
pub fn active(&self) -> usize {
self.active
}
/// Делает блок активным.
///
/// Возвращает `false`, если блока с таким индексом нет.
pub fn set_active(&mut self, index: usize) -> bool {
if index < self.blocks.len() {
self.active = index;
true
} else {
false
}
}
/// Добавляет блок дерева и возвращает доступ к нему. /// Добавляет блок дерева и возвращает доступ к нему.
pub fn add_tree(&mut self) -> TreeHandle { ///
/// `title` — заголовок вкладки нижней панели; для боковых панелей он
/// сохраняется, но не рисуется. Пустое название заменяется на «Дерево».
/// Новый блок сразу становится активным.
pub fn add_tree(&mut self, title: Option<String>) -> TreeHandle {
let tree = new_tree(); let tree = new_tree();
self.blocks.push(PanelBlock::Tree(tree.clone())); let title = title
.filter(|title| !title.trim().is_empty())
.unwrap_or_else(|| "Дерево".to_owned());
self.blocks.push(PanelBlock::Tree {
tree: tree.clone(),
title,
});
self.active = self.blocks.len() - 1;
tree tree
} }
} }
+33
View File
@@ -19,6 +19,7 @@ use mlua::{Lua, Table};
use crate::api::{AppApi, DirEntryInfo, TabInfo}; use crate::api::{AppApi, DirEntryInfo, TabInfo};
use crate::constants::{STARTUP_SCRIPT, script_dir}; use crate::constants::{STARTUP_SCRIPT, script_dir};
use crate::lsp::{DiagnosticInfo, DocumentDiagnostics};
/// Вкладка превращается в Lua-таблицу `{ index = 0, title = "main.rs", icon = "file" }`. /// Вкладка превращается в Lua-таблицу `{ index = 0, title = "main.rs", icon = "file" }`.
impl mlua::IntoLua for TabInfo { impl mlua::IntoLua for TabInfo {
@@ -43,6 +44,38 @@ impl mlua::IntoLua for DirEntryInfo {
} }
} }
/// Диагностика сервера для документа превращается в Lua-таблицу
/// `{ server = "gopls", uri = "file:///...", path = "/...", diagnostics = {...} }`.
impl mlua::IntoLua for DocumentDiagnostics {
fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
let entry = lua.create_table()?;
entry.set("server", self.server)?;
entry.set("uri", self.uri)?;
entry.set("path", self.path)?;
entry.set("diagnostics", self.diagnostics)?;
Ok(mlua::Value::Table(entry))
}
}
/// Сообщение диагностики превращается в Lua-таблицу с позицией (с нуля),
/// уровнем, текстом, источником, кодом и тегами.
impl mlua::IntoLua for DiagnosticInfo {
fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
let entry = lua.create_table()?;
entry.set("line", self.line)?;
entry.set("character", self.character)?;
entry.set("end_line", self.end_line)?;
entry.set("end_character", self.end_character)?;
entry.set("severity", self.severity)?;
entry.set("severity_name", self.severity_name)?;
entry.set("message", self.message)?;
entry.set("source", self.source)?;
entry.set("code", self.code)?;
entry.set("tags", self.tags)?;
Ok(mlua::Value::Table(entry))
}
}
/// Движок Lua и состояние его глобального окружения. /// Движок Lua и состояние его глобального окружения.
pub struct ScriptEngine { pub struct ScriptEngine {
lua: Lua, lua: Lua,
+400 -28
View File
@@ -5,14 +5,16 @@
//! фиксируют поведение самого приложения: документы, API, Lua и KeyMap. //! фиксируют поведение самого приложения: документы, API, Lua и KeyMap.
use std::cell::RefCell; use std::cell::RefCell;
use std::path::Path;
use std::rc::Rc; use std::rc::Rc;
use gpui_kit::{Keystroke, Modifiers, TestAppContext, px}; use gpui_kit::{Keystroke, Modifiers, TestAppContext, px};
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
use crate::api::{AppApi, WindowCommand, drain_window_commands}; use crate::api::{AppApi, WindowCommand, drain_window_commands};
use crate::app::MyApp; use crate::app::MyApp;
use crate::editor::{Language, Workspace}; use crate::editor::{FocusAfterSync, Language, Workspace};
use crate::keymap::{KeyBinding, KeyMap, Modifiers as KeyModifiers}; use crate::keymap::{KeyBinding, KeyContext, KeyHandler, KeyMap, Modifiers as KeyModifiers};
use crate::panel::PanelBlock; use crate::panel::PanelBlock;
use crate::script::ScriptEngine; use crate::script::ScriptEngine;
use crate::tree::TreeNode; use crate::tree::TreeNode;
@@ -55,17 +57,17 @@ fn lua_lsp_api_lists_no_servers_and_validates_config() {
#[test] #[test]
fn workspace_keeps_at_least_one_tab() { fn workspace_keeps_at_least_one_tab() {
let mut state = Workspace::default(); let mut state = Workspace::default();
assert_eq!(state.documents().len(), 2); assert_eq!(state.documents().len(), 1);
assert_eq!(state.active().name, "main.rs"); assert_eq!(state.active().name, "untitled-1.txt");
// Открытие и закрытие новых вкладок. // Открытие и закрытие новых вкладок.
state.open_new(); state.open_new();
assert_eq!(state.documents().len(), 3); assert_eq!(state.documents().len(), 2);
assert_eq!(state.active_index(), 2);
state.close(2);
assert_eq!(state.active_index(), 1); assert_eq!(state.active_index(), 1);
state.close(1);
assert_eq!(state.active_index(), 0);
// Редактор не должен оставаться пустым. // Редактор не должен оставаться пустым.
while state.documents().len() > 1 { while state.documents().len() > 1 {
state.close(0); state.close(0);
@@ -83,7 +85,7 @@ fn closing_tab_before_active_keeps_the_same_document_active() {
state.close(0); state.close(0);
assert_eq!(state.active().id, id); assert_eq!(state.active().id, id);
assert_eq!(state.active_index(), 1); assert_eq!(state.active_index(), 0);
} }
#[test] #[test]
@@ -112,6 +114,8 @@ fn revision_changes_only_for_external_edits() {
assert_ne!(state.revision(), revision); assert_ne!(state.revision(), revision);
// Переключение вкладки — тоже. // Переключение вкладки — тоже.
state.open_new();
state.set_active(0);
let before_switch = state.revision(); let before_switch = state.revision();
state.set_active(1); state.set_active(1);
assert_ne!(state.revision(), before_switch); assert_ne!(state.revision(), before_switch);
@@ -127,13 +131,15 @@ fn api_lists_tabs_and_closes_them_by_number() {
tabs.iter() tabs.iter()
.map(|tab| (tab.index, tab.title.as_str())) .map(|tab| (tab.index, tab.title.as_str()))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
vec![(0, "main.rs"), (1, "notes.md")] vec![(0, "untitled-1.txt")]
); );
// Несуществующий номер ничего не закрывает. // Несуществующий номер ничего не закрывает.
assert!(!api.close_tab(99)); assert!(!api.close_tab(99));
assert_eq!(api.tabs().len(), 2); assert_eq!(api.tabs().len(), 1);
api.open_tab();
assert_eq!(api.tabs().len(), 2);
assert!(api.close_tab(1)); assert!(api.close_tab(1));
assert_eq!(api.tabs().len(), 1); assert_eq!(api.tabs().len(), 1);
assert_eq!(workspace.borrow().documents().len(), 1); assert_eq!(workspace.borrow().documents().len(), 1);
@@ -180,6 +186,7 @@ fn lua_script_can_set_tab_icons() {
assert(app:tab_icon(0) == "file") assert(app:tab_icon(0) == "file")
assert(app:set_tab_icon(0, "book-open")) assert(app:set_tab_icon(0, "book-open"))
assert(app:tab_icon(0) == "book-open") assert(app:tab_icon(0) == "book-open")
app:open_tab()
assert(app:tabs()[2].icon == "file") assert(app:tabs()[2].icon == "file")
assert(not app:set_tab_icon(0, "no-such-icon")) assert(not app:set_tab_icon(0, "no-such-icon"))
assert(app:clear_tab_icon(0)) assert(app:clear_tab_icon(0))
@@ -200,7 +207,7 @@ fn api_save_writes_active_tab_to_file_named_after_it() {
// Тест пишет во временный файл, а не в каталог проекта. // Тест пишет во временный файл, а не в каталог проекта.
let workspace = Rc::new(RefCell::new(Workspace::default())); let workspace = Rc::new(RefCell::new(Workspace::default()));
let path = std::env::temp_dir().join(format!( let path = std::env::temp_dir().join(format!(
"gpui-editor-test-save-{}-{}.rs", "qode-save-{}-{}.rs",
std::process::id(), std::process::id(),
workspace.borrow().active().id workspace.borrow().active().id
)); ));
@@ -231,6 +238,8 @@ fn api_switches_tabs_forward_and_back_in_a_cycle() {
let workspace = Rc::new(RefCell::new(Workspace::default())); let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone()); let (api, _keymap) = install_api(workspace.clone());
api.open_tab();
api.set_active_tab(0);
assert_eq!(api.active_tab_index(), 0); assert_eq!(api.active_tab_index(), 0);
assert_eq!(api.next_tab(), 1); assert_eq!(api.next_tab(), 1);
@@ -392,7 +401,8 @@ fn lua_tree_block_appends_sorts_and_removes_nodes() {
let panel = api.left_panel_handle(); let panel = api.left_panel_handle();
let panel = panel.borrow(); let panel = panel.borrow();
let PanelBlock::Tree(tree) = &panel.blocks()[0]; let PanelBlock::Tree { tree, title } = &panel.blocks()[0];
assert_eq!(title, "Дерево");
let rows = tree.borrow().rows(); let rows = tree.borrow().rows();
assert_eq!( assert_eq!(
rows.iter() rows.iter()
@@ -414,6 +424,124 @@ fn lua_tree_block_appends_sorts_and_removes_nodes() {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn lua_bottom_panel_tree_has_a_tab_title_and_clears() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
let bottom = api.bottom_panel_handle();
let dir = temp_script_dir("bottom-tree");
let script = dir.join("bottom.lua");
std::fs::write(
&script,
r#"
diag_tree = app.bottom_panel:add_tree("Диагностика")
diag_tree:append({
{ id = "a", label = "a", expandable = true, expanded = true },
}, "")
diag_tree:append({
{ id = "a:1", label = "ошибка" },
}, "a")
"#,
)
.expect("bottom.lua");
let clear = dir.join("clear.lua");
std::fs::write(&clear, "diag_tree:clear()\n").expect("clear.lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine.run_file(&script).expect("блок нижней панели");
{
let panel = bottom.borrow();
assert_eq!(panel.blocks().len(), 1);
assert_eq!(panel.active(), 0);
assert_eq!(panel.blocks()[0].title(), "Диагностика");
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
assert_eq!(tree.borrow().rows().len(), 2);
}
engine.run_file(&clear).expect("tree:clear");
let panel = bottom.borrow();
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
assert!(tree.borrow().rows().is_empty());
drop(panel);
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_diagnostics_handler_receives_every_server_and_file() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
let dir = temp_script_dir("diagnostics-handler");
let script = dir.join("diagnostics.lua");
std::fs::write(
&script,
r#"
app:on_lsp_diagnostics(0, function(servers)
assert(#servers == 1, #servers)
local entry = servers[1]
assert(entry.server == "test")
assert(entry.path ~= nil)
assert(#entry.diagnostics == 2, #entry.diagnostics)
local first = entry.diagnostics[1]
assert(first.line == 2)
assert(first.character == 3)
assert(first.end_line == 2)
assert(first.end_character == 5)
assert(first.severity == 1)
assert(first.severity_name == "error")
assert(first.message == "плохо")
assert(entry.diagnostics[2].severity_name == "warning")
app:open_tab()
end)
"#,
)
.expect("diagnostics.lua");
let engine = ScriptEngine::new(api.clone()).expect("движок Lua");
engine.run_file(&script).expect("регистрация обработчика");
assert_eq!(api.diagnostics_delay(), Some(std::time::Duration::ZERO));
let uri = crate::lsp::path_to_uri(Path::new("/tmp/qode-diagnostics.go"));
let diagnostics = vec![
Diagnostic {
range: Range::new(Position::new(2, 3), Position::new(2, 5)),
severity: Some(DiagnosticSeverity::ERROR),
message: "плохо".to_owned(),
..Default::default()
},
Diagnostic {
range: Range::new(Position::new(4, 0), Position::new(4, 1)),
severity: Some(DiagnosticSeverity::WARNING),
message: "осторожно".to_owned(),
..Default::default()
},
];
api.lsp_handle()
.set_diagnostics_for_test(uri.as_str(), diagnostics);
let report = api.lsp_handle().diagnostics_snapshot();
assert_eq!(report.len(), 1);
assert_eq!(report[0].server, "test");
assert_eq!(report[0].path.as_deref(), Some("/tmp/qode-diagnostics.go"));
assert_eq!(report[0].diagnostics.len(), 2);
assert!(api.emit_diagnostics(report));
assert_eq!(
workspace.borrow().documents().len(),
2,
"обработчик должен был открыть новую вкладку"
);
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn lua_list_dir_returns_sorted_entries_with_paths() { fn lua_list_dir_returns_sorted_entries_with_paths() {
let workspace = Rc::new(RefCell::new(Workspace::default())); let workspace = Rc::new(RefCell::new(Workspace::default()));
@@ -455,7 +583,7 @@ fn lua_list_dir_returns_sorted_entries_with_paths() {
/// Временный каталог с уникальным именем для Lua-сценариев. /// Временный каталог с уникальным именем для Lua-сценариев.
fn temp_script_dir(prefix: &str) -> std::path::PathBuf { fn temp_script_dir(prefix: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!( let dir = std::env::temp_dir().join(format!(
"gpui-editor-test-{prefix}-{}-{}", "qode-{prefix}-{}-{}",
std::process::id(), std::process::id(),
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -482,7 +610,7 @@ fn lua_script_can_include_files_and_control_the_app() {
std::fs::write( std::fs::write(
&main, &main,
format!( format!(
"include({:?})\nlua_config()\napp:next_tab()\napp:set_language(1, \"python\")\n", "include({:?})\nlua_config()\napp:open_tab()\napp:set_language(1, \"python\")\n",
helper.to_string_lossy() helper.to_string_lossy()
), ),
) )
@@ -500,7 +628,7 @@ fn lua_script_can_include_files_and_control_the_app() {
assert_eq!( assert_eq!(
state.active_index(), state.active_index(),
1, 1,
"app:next_tab() не переключил вкладку" "app:open_tab() не переключил вкладку"
); );
assert_eq!( assert_eq!(
state.documents()[1].language, state.documents()[1].language,
@@ -538,6 +666,55 @@ fn api_opens_file_from_disk_and_detects_language() {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn lua_open_controls_focus_and_cursor() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
let dir = temp_script_dir("lua-open-focus");
let path = dir.join("main.go");
std::fs::write(&path, "package main\n\nfunc main() {}\n").unwrap();
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
// Без второго аргумента фокус остаётся прежним.
let plain = dir.join("plain.lua");
std::fs::write(&plain, format!("app:open({:?})\n", path.to_string_lossy())).unwrap();
engine.run_file(&plain).expect("app:open без позиции");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Keep)
);
// Пустая таблица — фокус в редактор, курсор на месте.
let focus = dir.join("focus.lua");
std::fs::write(
&focus,
format!("app:open({:?}, {{}})\n", path.to_string_lossy()),
)
.unwrap();
engine.run_file(&focus).expect("app:open с пустой таблицей");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Editor(None))
);
// Строка 2, столбец 5 — байтовое смещение 19.
let at = dir.join("at.lua");
std::fs::write(
&at,
format!("app:open({:?}, {{2, 5}})\n", path.to_string_lossy()),
)
.unwrap();
engine.run_file(&at).expect("app:open с позицией");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Editor(Some(19)))
);
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn api_open_missing_file_creates_an_empty_buffer() { fn api_open_missing_file_creates_an_empty_buffer() {
let workspace = Rc::new(RefCell::new(Workspace::default())); let workspace = Rc::new(RefCell::new(Workspace::default()));
@@ -647,8 +824,13 @@ fn lua_key_bindings_match_press_release_and_modifiers() {
key_char: None, key_char: None,
}; };
let fire = |keystroke: &Keystroke, pressed: bool| { let fire = |keystroke: &Keystroke, pressed: bool| {
for callback in keymap.borrow().callbacks_for(keystroke, pressed) { for handler in keymap
callback.call::<()>(()).expect("Lua-обработчик"); .borrow()
.handlers_for(KeyContext::Global, keystroke, pressed)
{
if let KeyHandler::Lua(callback) = handler {
callback.call::<()>(()).expect("Lua-обработчик");
}
} }
}; };
@@ -713,6 +895,81 @@ fn lua_key_bindings_match_press_release_and_modifiers() {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn lua_keymap_accepts_command_names_and_nil() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, keymap) = install_api(workspace);
let dir = temp_script_dir("key-commands");
let script = dir.join("keys.lua");
std::fs::write(
&script,
r#"
app:map_key("<C-n>", "next_tab")
app:map_completion_key("<C-n>", "completion_next")
local ok, err = pcall(function()
app:map_key("<C-x>", "no_such_command")
end)
assert(not ok)
assert(tostring(err):find("доступны", 1, true), tostring(err))
"#,
)
.expect("keys.lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine
.run_file(&script)
.expect("команды должны назначиться");
let stroke = Keystroke {
modifiers: Modifiers {
control: true,
..Default::default()
},
key: "n".to_owned(),
key_char: None,
};
assert!(matches!(
keymap
.borrow()
.handlers_for(KeyContext::Global, &stroke, true)
.as_slice(),
[KeyHandler::App(crate::keymap::AppCommand::NextTab)]
));
assert!(matches!(
keymap
.borrow()
.handlers_for(KeyContext::Completion, &stroke, true)
.as_slice(),
[KeyHandler::Completion(
crate::keymap::CompletionCommand::Next
)]
));
// nil удаляет привязку, не оставляя заглушку.
std::fs::write(
&script,
"app:map_key(\"<C-n>\", nil)\napp:map_completion_key(\"<C-n>\", nil)\n",
)
.expect("keys.lua");
engine.run_file(&script).expect("привязки должны удалиться");
assert!(
keymap
.borrow()
.handlers_for(KeyContext::Global, &stroke, true)
.is_empty()
);
assert!(
keymap
.borrow()
.handlers_for(KeyContext::Completion, &stroke, true)
.is_empty()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn language_names_cover_all_supported_grammars() { fn language_names_cover_all_supported_grammars() {
for (name, expected) in [ for (name, expected) in [
@@ -737,6 +994,19 @@ fn language_names_cover_all_supported_grammars() {
assert_eq!(Language::Rust.name(), "rust"); assert_eq!(Language::Rust.name(), "rust");
assert_eq!(Language::JavaScript.name(), "js"); assert_eq!(Language::JavaScript.name(), "js");
assert_eq!(Language::Python.name(), "python"); assert_eq!(Language::Python.name(), "python");
// Каждый язык перечитывается по имени и по полному имени для
// tree-sitter/LSP.
for language in [
Language::Rust,
Language::Go,
Language::JavaScript,
Language::Json,
Language::Python,
] {
assert_eq!(Language::from_name(language.name()), Some(language));
assert_eq!(Language::from_name(language.syntax_name()), Some(language));
}
} }
#[test] #[test]
@@ -782,15 +1052,18 @@ fn keymap_bindings_are_parsed_and_replaced() {
.load("return function() end") .load("return function() end")
.eval::<mlua::Function>() .eval::<mlua::Function>()
.unwrap(); .unwrap();
keymap.bind(plain.clone(), first); keymap.set(KeyContext::Global, plain.clone(), KeyHandler::Lua(first));
keymap.bind(plain, second); keymap.set(KeyContext::Global, plain, KeyHandler::Lua(second));
let stroke = Keystroke { let stroke = Keystroke {
modifiers: Modifiers::default(), modifiers: Modifiers::default(),
key: "a".to_owned(), key: "a".to_owned(),
key_char: None, key_char: None,
}; };
assert_eq!(keymap.callbacks_for(&stroke, true).len(), 1); assert_eq!(
keymap.handlers_for(KeyContext::Global, &stroke, true).len(),
1
);
} }
#[gpui_kit::test] #[gpui_kit::test]
@@ -838,7 +1111,7 @@ fn lua_key_bindings_receive_keys_while_editor_is_focused(cx: &mut TestAppContext
fn opened_panels_render_without_panicking(cx: &mut TestAppContext) { fn opened_panels_render_without_panicking(cx: &mut TestAppContext) {
cx.update(gpui_kit::init); cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(|window, cx| MyApp::new(window, cx)); let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global(); let api = AppApi::global();
api.left_panel().open(); api.left_panel().open();
@@ -859,10 +1132,10 @@ fn opened_panels_render_without_panicking(cx: &mut TestAppContext) {
fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) { fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
cx.update(gpui_kit::init); cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(|window, cx| MyApp::new(window, cx)); let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global(); let api = AppApi::global();
let tree = api.left_panel_handle().borrow_mut().add_tree(); let tree = api.left_panel_handle().borrow_mut().add_tree(None);
tree.borrow_mut() tree.borrow_mut()
.append( .append(
"", "",
@@ -898,14 +1171,54 @@ fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
assert_eq!(tree.borrow().rows().len(), 2); assert_eq!(tree.borrow().rows().len(), 2);
} }
#[gpui_kit::test]
fn bottom_panel_renders_blocks_as_tabs(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global();
let panel = api.bottom_panel_handle();
panel
.borrow_mut()
.add_tree(Some("Диагностика".to_owned()))
.borrow_mut()
.append(
"",
vec![TreeNode {
id: "a".to_owned(),
label: "a".to_owned(),
icon: Some("file".to_owned()),
expandable: false,
expanded: false,
}],
)
.unwrap();
panel.borrow_mut().add_tree(Some("Тесты".to_owned()));
assert_eq!(panel.borrow().active(), 1);
assert_eq!(panel.borrow().blocks()[0].title(), "Диагностика");
assert_eq!(panel.borrow().blocks()[1].title(), "Тесты");
assert!(panel.borrow_mut().set_active(0));
assert!(!panel.borrow_mut().set_active(2));
api.bottom_panel().open();
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
});
visual.run_until_parked();
assert!(api.bottom_panel_handle().borrow().is_open());
}
#[gpui_kit::test] #[gpui_kit::test]
fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) { fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) {
cx.update(gpui_kit::init); cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(|window, cx| MyApp::new(window, cx)); let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global(); let api = AppApi::global();
let tree = api.left_panel_handle().borrow_mut().add_tree(); let tree = api.left_panel_handle().borrow_mut().add_tree(None);
tree.borrow_mut() tree.borrow_mut()
.append( .append(
"", "",
@@ -953,6 +1266,62 @@ fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) {
assert_eq!(tree.borrow().selected(), Some("readme")); assert_eq!(tree.borrow().selected(), Some("readme"));
} }
#[gpui_kit::test]
fn lsp_diagnostics_are_underlined_in_editor(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
let uri = visual.update(|window, cx| {
app.update(cx, |this, cx| {
this.editor_handle().update(cx, |state, cx| {
state.set_value("abc\n", window, cx);
});
let name = this.workspace_handle().borrow().active().name.clone();
crate::lsp::document_uri(&name).expect("у документа есть URI")
})
});
let diagnostic = |range: lsp_types::Range, severity| lsp_types::Diagnostic {
range,
severity: Some(severity),
message: "сообщение".to_owned(),
..Default::default()
};
let diagnostics = vec![
diagnostic(
lsp_types::Range::new(
lsp_types::Position::new(0, 0),
lsp_types::Position::new(0, 2),
),
lsp_types::DiagnosticSeverity::ERROR,
),
diagnostic(
lsp_types::Range::new(
lsp_types::Position::new(0, 2),
lsp_types::Position::new(0, 3),
),
lsp_types::DiagnosticSeverity::WARNING,
),
];
AppApi::global()
.lsp_handle()
.set_diagnostics_for_test(uri.as_str(), diagnostics);
let ranges = visual.update(|_window, cx| {
app.update(cx, |this, cx| {
this.refresh_diagnostics(cx);
this.diagnostic_ranges(cx)
})
});
visual.run_until_parked();
assert_eq!(
ranges,
vec![0..2, 2..3],
"ошибка и предупреждение должны стать подчёркнутыми диапазонами"
);
}
#[gpui_kit::test] #[gpui_kit::test]
fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) { fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
cx.update(gpui_kit::init); cx.update(gpui_kit::init);
@@ -1117,8 +1486,11 @@ fn horizontal_arrows_close_completion_and_move_cursor(cx: &mut TestAppContext) {
let before = visual.update(|window, cx| { let before = visual.update(|window, cx| {
app.update(cx, |this, cx| { app.update(cx, |this, cx| {
this.editor_handle() this.editor_handle().update(cx, |state, cx| {
.update(cx, |state, cx| state.focus(window, cx)); state.set_value("abc", window, cx);
state.set_selected_range(0..0, cx);
state.focus(window, cx);
});
this.open_test_completion(cx); this.open_test_completion(cx);
this.editor_handle().read(cx).cursor() this.editor_handle().read(cx).cursor()
}) })
+32 -34
View File
@@ -92,6 +92,7 @@ pub enum TreeKeyResult {
} }
/// Состояние абстрактного дерева. /// Состояние абстрактного дерева.
#[derive(Default)]
pub struct TreeState { pub struct TreeState {
nodes: HashMap<String, NodeRecord>, nodes: HashMap<String, NodeRecord>,
roots: Vec<String>, roots: Vec<String>,
@@ -108,21 +109,6 @@ pub struct TreeState {
scroll: ScrollHandle, scroll: ScrollHandle,
} }
impl Default for TreeState {
fn default() -> Self {
Self {
nodes: HashMap::new(),
roots: Vec::new(),
toggle_callback: None,
click_callback: None,
selected: None,
focus_handle: None,
focus_requested: false,
scroll: ScrollHandle::default(),
}
}
}
impl TreeState { impl TreeState {
/// Создаёт пустое дерево. /// Создаёт пустое дерево.
pub fn new() -> Self { pub fn new() -> Self {
@@ -153,11 +139,8 @@ impl TreeState {
return false; return false;
} }
self.expand_ancestors(id); self.expand_ancestors(id);
self.selected = Some(id.to_owned());
self.focus_requested = true; self.focus_requested = true;
if let Some(index) = self.rows().iter().position(|row| row.id == id) { self.select_and_scroll(id);
self.scroll.scroll_to_item(index);
}
true true
} }
@@ -259,6 +242,16 @@ impl TreeState {
Ok(()) Ok(())
} }
/// Удаляет все узлы и сбрасывает выбор.
///
/// Обработчики раскрытия и клика сохраняются: сценарий может наполнять
/// дерево заново, например при обновлении списка диагностики.
pub fn clear(&mut self) {
self.nodes.clear();
self.roots.clear();
self.selected = None;
}
/// Удаляет узел и все его дочерние узлы рекурсивно. /// Удаляет узел и все его дочерние узлы рекурсивно.
/// ///
/// Возвращает `false`, если узла с таким `id` нет. /// Возвращает `false`, если узла с таким `id` нет.
@@ -340,6 +333,14 @@ impl TreeState {
entries.into_iter().map(|(_, _, id)| id).collect() entries.into_iter().map(|(_, _, id)| id).collect()
} }
/// Делает строку выбранной и прокручивает её в видимую часть.
fn select_and_scroll(&mut self, id: &str) {
self.selected = Some(id.to_owned());
if let Some(index) = self.rows().iter().position(|row| row.id == id) {
self.scroll.scroll_to_item(index);
}
}
/// Сдвигает выборку по видимым строкам. /// Сдвигает выборку по видимым строкам.
fn move_selection(&mut self, delta: isize) { fn move_selection(&mut self, delta: isize) {
let rows = self.rows(); let rows = self.rows();
@@ -357,8 +358,8 @@ impl TreeState {
None if delta < 0 => rows.len() - 1, None if delta < 0 => rows.len() - 1,
None => 0, None => 0,
}; };
self.selected = Some(rows[next].id.clone()); let id = rows[next].id.clone();
self.scroll.scroll_to_item(next); self.select_and_scroll(&id);
} }
/// Enter: раскрывает папку или вызывает обработчик клика конечной ноды. /// Enter: раскрывает папку или вызывает обработчик клика конечной ноды.
@@ -383,10 +384,7 @@ impl TreeState {
}; };
} }
if let Some(parent) = self.nodes.get(&id).and_then(|record| record.parent.clone()) { if let Some(parent) = self.nodes.get(&id).and_then(|record| record.parent.clone()) {
self.selected = Some(parent.clone()); self.select_and_scroll(&parent);
if let Some(index) = self.rows().iter().position(|row| row.id == parent) {
self.scroll.scroll_to_item(index);
}
} }
TreeKeyResult::Handled TreeKeyResult::Handled
} }
@@ -399,20 +397,20 @@ impl TreeState {
let Some(record) = self.nodes.get(&id) else { let Some(record) = self.nodes.get(&id) else {
return TreeKeyResult::Ignored; return TreeKeyResult::Ignored;
}; };
if record.node.expandable && !record.node.expanded { let expandable = record.node.expandable;
let expanded = record.node.expanded;
let first_child = expanded
.then(|| self.sorted(&record.children).first().cloned())
.flatten();
if expandable && !expanded {
return match self.activate(&id) { return match self.activate(&id) {
Some(action) => TreeKeyResult::Action(action), Some(action) => TreeKeyResult::Action(action),
None => TreeKeyResult::Handled, None => TreeKeyResult::Handled,
}; };
} }
if record.node.expanded if let Some(child) = first_child {
&& let Some(child) = self.sorted(&record.children).first().cloned() self.select_and_scroll(&child);
{
let index = self.rows().iter().position(|row| row.id == child);
self.selected = Some(child);
if let Some(index) = index {
self.scroll.scroll_to_item(index);
}
} }
TreeKeyResult::Handled TreeKeyResult::Handled
} }