refactoring
This commit is contained in:
@@ -1,128 +1,104 @@
|
||||
# AGENTS.md
|
||||
|
||||
Instructions for AI coding agents working on this repository. Read this file
|
||||
before changing code. The rules below are architectural requirements, not
|
||||
suggestions.
|
||||
`qode` — настольный редактор кода на GPUI (`gpui-kit`) с расширением поведения
|
||||
через Lua и клиентом LSP по stdio. Этот файл описывает архитектуру и принципы,
|
||||
которые нужно сохранять при изменениях.
|
||||
|
||||
## Project shape
|
||||
## Модули
|
||||
|
||||
| Path | Responsibility |
|
||||
| Путь | Ответственность |
|
||||
| --- | --- |
|
||||
| `src/api.rs` | `AppApi` / `PanelApi` — the single control surface of the application |
|
||||
| `src/script.rs` | Lua engine; publishes `app`, `include`, and `require` |
|
||||
| `src/keymap.rs` | Key binding parsing (`KeyBinding`) and dispatch (`KeyMap`) |
|
||||
| `src/app.rs` | GPUI view, rendering, event wiring |
|
||||
| `src/editor.rs` | `Workspace`, documents, tabs |
|
||||
| `src/panel.rs` | `PanelState` for left/right/bottom panels |
|
||||
| `src/lsp.rs` | LSP server manager |
|
||||
| `src/constants.rs` | CLI arguments and script directory |
|
||||
| `init.lua` | Example startup script (also a usage reference) |
|
||||
| `DOCS.md` | User-facing Lua API documentation |
|
||||
| `src/main.rs` | Точка входа, окно, аргументы командной строки |
|
||||
| `src/constants.rs` | Константы окна, CLI и каталог сценариев |
|
||||
| `src/editor.rs` | Доменная модель: документы, вкладки, языки |
|
||||
| `src/api.rs` | `AppApi` / `PanelApi` / `TreeApi` — единственная поверхность управления |
|
||||
| `src/script.rs` | Lua-движок, `app`, `include`, `require` |
|
||||
| `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики |
|
||||
| `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение |
|
||||
| `src/completion.rs` | Всплывающий список, `CaretPopup`, преобразования LSP-элементов |
|
||||
| `src/panel.rs` | `PanelState` и блоки панелей |
|
||||
| `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
|
||||
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.
|
||||
### 1. `AppApi` — центр приложения
|
||||
|
||||
## 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
|
||||
`#[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`.
|
||||
### 2. Lua повторяет возможности API
|
||||
|
||||
## 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
|
||||
manipulate state directly. Examples:
|
||||
- window close button → `app:close()` (i.e. `AppApi::close`)
|
||||
- minimize button → `app:minimize()`
|
||||
- maximize / restore → `app:toggle_maximize()` or
|
||||
`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`.
|
||||
- Встроенные привязки (`Ctrl/Cmd+S`, `Ctrl/Cmd+Space`, навигация по списку
|
||||
автодополнения) регистрируются в `KeyMap` и могут быть заменены или удалены
|
||||
из Lua через `app:map_key` / `app:map_completion_key`.
|
||||
- `KeyMap` имеет два слоя: глобальный и `completion`. Обработчик может быть
|
||||
Lua-функцией или встроенной командой.
|
||||
- Перехватчик клавиш выполняется раньше действий редактора и используется
|
||||
только для слоя автодополнения; глобальные привязки обрабатываются на корне
|
||||
вида. Этот порядок менять нельзя.
|
||||
- Текстовый ввод редактора — не привязка клавиши.
|
||||
|
||||
### Keyboard shortcuts
|
||||
### 4. Модель отделена от GPUI
|
||||
|
||||
- Every key binding must be registered through `AppApi::map_key` /
|
||||
`KeyMap`. There must be no hard-coded shortcuts that scripts cannot inspect,
|
||||
override, or remove.
|
||||
- Existing hard-coded checks such as `is_save_shortcut` and
|
||||
`is_completion_shortcut` in `src/app.rs` are technical debt: when touching
|
||||
this area, move those defaults into the `KeyMap` mechanism so they can be
|
||||
rebound from 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.
|
||||
- `editor.rs`, `panel.rs` и `tree.rs` не знают о `Window` и `Context`.
|
||||
- Вид переносит изменения модели в виджет по `Workspace::revision`; набор
|
||||
текста в редакторе ревизию не трогает, чтобы не сбрасывать курсор.
|
||||
- Инвариант `Workspace`: открыт хотя бы один документ. Поэтому `active()`
|
||||
возвращает документ без `Option`.
|
||||
- Встроенный старт — одна пустая вкладка. Примеры и демонстрационные
|
||||
документы не должны попадать в код приложения; `init.lua` — единственное
|
||||
место для примеров.
|
||||
|
||||
## 4. Keep `DOCS.md` in sync
|
||||
### 5. LSP работает в фоне
|
||||
|
||||
`DOCS.md` is the source of truth for the Lua API. In the **same change** that
|
||||
modifies the public surface, update the docs:
|
||||
- `LspManager` общий (`Arc`) для вида, API и фоновых задач.
|
||||
- Процессы, чтение 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
|
||||
aspirational APIs.
|
||||
- Код, комментарии и пользовательская документация — на русском.
|
||||
- Публичные элементы Rust документируются `///`.
|
||||
- Новые зависимости добавляются только при реальной необходимости; сначала
|
||||
используются возможности `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
@@ -2177,20 +2177,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "gpui-fast"
|
||||
version = "0.1.12"
|
||||
@@ -5307,6 +5293,20 @@ version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "qode"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"globset",
|
||||
"gpui-kit",
|
||||
"lsp-types",
|
||||
"mlua",
|
||||
"notify",
|
||||
"parking_lot",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qoi"
|
||||
version = "0.4.1"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "gpui-editor-test"
|
||||
name = "qode"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# Lua API
|
||||
|
||||
Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект `app`
|
||||
для управления окном, вкладками, панелями, файловой системой, подсветкой и
|
||||
LSP-серверами.
|
||||
Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект
|
||||
`app` для управления окном, вкладками, панелями, файловой системой, подсветкой
|
||||
и LSP-серверами.
|
||||
|
||||
Сам редактор не содержит демонстрационных документов: при запуске открыта
|
||||
одна пустая вкладка `untitled-1.txt`. Всё остальное — файлы, деревья, серверы —
|
||||
настраивается сценарием.
|
||||
|
||||
## Запуск и каталог сценариев
|
||||
|
||||
@@ -10,7 +14,7 @@ LSP-серверами.
|
||||
(по умолчанию — текущий каталог). Его можно переопределить аргументом командной строки:
|
||||
|
||||
```sh
|
||||
gpui-editor-test --config /path/to/project main.go
|
||||
qode --config /path/to/project main.go
|
||||
cargo run -- --config ./config
|
||||
cargo run -- main.go
|
||||
```
|
||||
@@ -18,10 +22,14 @@ cargo run -- main.go
|
||||
- В каталоге `--config` должны лежать `init.lua` и подключаемые им модули.
|
||||
- Первый позиционный аргумент открывается после выполнения `init.lua`
|
||||
через `app:open(path)` — как `vim main.go` или `nano main.go`.
|
||||
- `--config` меняет только каталог сценариев. Рабочий каталог процесса, а
|
||||
вместе с ним пути `app:open`, `app:save` и `root` у LSP не меняются.
|
||||
|
||||
## Объект `app`
|
||||
|
||||
Номера вкладок во всех методах нумеруются с нуля.
|
||||
Номера вкладок во всех методах нумеруются с нуля. Массив, который возвращает
|
||||
`app:tabs()`, остаётся обычной Lua-таблицей с индексами от 1, поэтому поле
|
||||
`index` — единственный надёжный способ сослаться на вкладку.
|
||||
|
||||
### Вкладки
|
||||
|
||||
@@ -33,12 +41,21 @@ cargo run -- main.go
|
||||
| `app:next_tab()` | Переключить на следующую вкладку, вернуть индекс |
|
||||
| `app:previous_tab()` | Переключить на предыдущую вкладку, вернуть индекс |
|
||||
| `app:open_tab()` | Открыть пустую вкладку, вернуть индекс |
|
||||
| `app:open(path)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем |
|
||||
| `app:open(path, position)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем. Без второго аргумента фокус не меняется; `{}` — перевести фокус в редактор, оставив курсор; `{line, column}` — ещё и поставить курсор (с нуля) |
|
||||
| `app:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта |
|
||||
| `app:set_tab_icon(index, name)` | Назначить иконку вкладке; `false`, если вкладки нет или иконка неизвестна |
|
||||
| `app:clear_tab_icon(index)` | Вернуть иконку по умолчанию (`file`) |
|
||||
| `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`.
|
||||
@@ -46,8 +63,8 @@ cargo run -- main.go
|
||||
Например:
|
||||
|
||||
```lua
|
||||
app:set_tab_icon(0, "file-code") -- main.rs
|
||||
app:set_tab_icon(1, "book-open") -- notes.md
|
||||
app:set_tab_icon(0, "file-code")
|
||||
app:set_tab_icon(1, "book-open")
|
||||
app:clear_tab_icon(1) -- снова "file"
|
||||
```
|
||||
|
||||
@@ -103,8 +120,14 @@ end
|
||||
|
||||
### Привязка клавиш
|
||||
|
||||
`app:map_key(keys, fn)` назначает обработчик на нажатие или отпускание клавиши
|
||||
(синтаксис в духе nvim):
|
||||
Все привязки проходят через один `KeyMap`. Встроенные сочетания можно заменить
|
||||
или удалить: вторым аргументом принимается Lua-функция, имя встроенной команды
|
||||
или `nil`.
|
||||
|
||||
`app:map_key(keys, handler)` назначает привязку для обычного режима,
|
||||
`app:map_completion_key(keys, handler)` — для списка автодополнения.
|
||||
|
||||
Запись клавиши в духе nvim:
|
||||
|
||||
| Запись | Значение |
|
||||
| --- | --- |
|
||||
@@ -114,19 +137,63 @@ end
|
||||
| `"<S-c>"` | C при зажатом Shift |
|
||||
| `"<C-z>"` | Z при зажатом Ctrl |
|
||||
| `"<A-!r>"` | отпускание R при зажатом Alt |
|
||||
| `"<D-s>"` | S при зажатом Cmd на macOS или Super на Linux |
|
||||
|
||||
Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux
|
||||
и 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
|
||||
-- Переопределить сохранение своей функцией.
|
||||
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-t>", function() app.bottom_panel:toggle() end)
|
||||
|
||||
-- Убрать встроенную привязку.
|
||||
app:map_key("<C-s>", nil)
|
||||
|
||||
-- Назначить встроенную команду на другую клавишу.
|
||||
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:stop_lsp("gopls")` | Остановить сервер; `true`, если сервер был запущен |
|
||||
| `app:restart_lsp("gopls")` | Перезапустить сервер с прежними настройками |
|
||||
| `app:on_lsp_diagnostics(delay_ms, fn)` | Обработчик диагностики всех серверов; `nil` вместо `fn` удаляет его |
|
||||
|
||||
Поле `watcher` принимает строку или таблицу:
|
||||
`cmd` обязателен. Если `root` не задан или относителен, он отсчитывается от
|
||||
текущего рабочего каталога процесса. Поле `watcher` принимает строку, таблицу
|
||||
или boolean:
|
||||
|
||||
```lua
|
||||
watcher = "**/*.go"
|
||||
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } }
|
||||
watcher = true -- все файлы, игнорируя только стандартные каталоги сборки
|
||||
```
|
||||
|
||||
Автодополнение включается само при вводе идентификатора; `Ctrl+Space` открывает
|
||||
список вручную. Пока список открыт, `↑`/`↓` и `PageUp`/`PageDown` перемещают
|
||||
выделение (курсор при этом стоит на месте), `Enter` или `Tab` вставляют выбранный
|
||||
элемент, `Esc` закрывает список. Горизонтальные стрелки, `Home` и `End` закрывают
|
||||
список и работают с курсором как обычно. Документация выбранного элемента
|
||||
показывается второй всплывающей панелью справа от списка.
|
||||
Автодополнение включается само при вводе идентификатора; `Ctrl/Cmd+Space`
|
||||
открывает список вручную. Поведение клавиш описано в разделе «Привязка
|
||||
клавиш» и может быть переопределено через `app:map_completion_key`.
|
||||
Документация выбранного элемента показывается второй всплывающей панелью
|
||||
справа от списка.
|
||||
|
||||
Диагностику, которую сервер присылает после правок, редактор подчёркивает
|
||||
волнистой линией: ошибки — красной, предупреждения и остальные сообщения —
|
||||
жёлтой. Подчёркивание работает и при выключенной подсветке синтаксиса.
|
||||
|
||||
### Обработчик диагностики
|
||||
|
||||
`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()
|
||||
```
|
||||
|
||||
Если у правой панели нет блоков, она показывает состояние LSP-серверов.
|
||||
Блоки нижней панели показываются вкладками, как вкладки редактора; боковые
|
||||
панели рисуют все свои блоки подряд и заголовки не показывают.
|
||||
|
||||
### Блоки панелей
|
||||
|
||||
Панель может содержать блоки. Блок создаётся через API панели и живёт вместе
|
||||
@@ -235,8 +354,14 @@ app.bottom_panel:close()
|
||||
|
||||
```lua
|
||||
local tree = app.left_panel:add_tree()
|
||||
local diagnostics = app.bottom_panel:add_tree("Диагностика")
|
||||
```
|
||||
|
||||
Необязательный аргумент `add_tree` — заголовок вкладки. В нижней панели он
|
||||
показывается на вкладке; в боковых панелях заголовок сохраняется, но не
|
||||
рисуется. Без заголовка используется «Дерево». Новый блок становится
|
||||
активной вкладкой нижней панели.
|
||||
|
||||
Редактор отвечает только за отрисовку дерева. Что означает каждый узел,
|
||||
решает сценарий: это могут быть файлы проекта, задачи, настройки — что угодно.
|
||||
Файловое дерево проекта в `init.lua` собирается из `app:list_dir`, но сам
|
||||
@@ -245,6 +370,7 @@ local tree = app.left_panel:add_tree()
|
||||
| Метод | Описание |
|
||||
| --- | --- |
|
||||
| `tree:append(nodes, parent)` | Добавить узлы к `parent`; `parent = ""` — корень |
|
||||
| `tree:clear()` | Удалить все узлы и сбросить выбор |
|
||||
| `tree:remove(id)` | Удалить узел и всех потомков рекурсивно; `false`, если узла нет |
|
||||
| `tree:on_toggle(fn)` | Обработчик раскрытия: `fn(expanded, id, loaded)` |
|
||||
| `tree:on_click(fn)` | Обработчик клика по конечной ноде: `fn(id)` |
|
||||
@@ -264,7 +390,9 @@ local tree = app.left_panel:add_tree()
|
||||
```
|
||||
|
||||
`append` добавляет узлы, а не заменяет дерево. Повторная передача того же `id`
|
||||
обновляет описание узла, но не создаёт копию.
|
||||
обновляет описание узла, но не создаёт копию. `clear` удаляет всё дерево;
|
||||
обработчики `on_toggle` и `on_click` продолжают действовать, поэтому дерево
|
||||
удобно строить заново на каждое обновление данных.
|
||||
|
||||
Дети каждого уровня сортируются по `label` без учёта регистра. Сначала идут
|
||||
раскрываемые узлы (`expandable = true`), затем конечные; внутри каждой группы —
|
||||
@@ -305,7 +433,8 @@ tree:on_toggle(function(expanded, id, loaded)
|
||||
end)
|
||||
|
||||
tree:on_click(function(id)
|
||||
app:open(id)
|
||||
-- Фокус в редактор, курсор остаётся на прежнем месте.
|
||||
app:open(id, {})
|
||||
end)
|
||||
|
||||
app.left_panel:open()
|
||||
@@ -322,6 +451,89 @@ app:map_key("<C-b>", function()
|
||||
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-файлов
|
||||
|
||||
Файлы подключаются из каталога сценариев:
|
||||
@@ -340,14 +552,16 @@ end)
|
||||
## Полный пример `init.lua`
|
||||
|
||||
```lua
|
||||
print("[lua] init.lua выполнен")
|
||||
|
||||
app:set_language(0, "rust")
|
||||
app:next_tab()
|
||||
print("[qode] init.lua выполнен")
|
||||
|
||||
app:map_key("<C-q>", function() app:close() end)
|
||||
app:map_key("<C-e>", function() app.right_panel:toggle() end)
|
||||
app:map_key("<C-t>", function() app.bottom_panel:toggle() end)
|
||||
app:map_key("<C-e>", "toggle_left_panel")
|
||||
app:map_key("<C-t>", "toggle_bottom_panel")
|
||||
|
||||
app:open_tab()
|
||||
app:run_lsp({
|
||||
name = "rust-analyzer",
|
||||
cmd = "rust-analyzer",
|
||||
languages = { "rust" },
|
||||
watcher = "**/*.rs",
|
||||
})
|
||||
```
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
-- Стартовый сценарий приложения.
|
||||
-- Стартовый сценарий приложения qode.
|
||||
--
|
||||
-- Приложение выполняет этот файл при запуске. Каталог поиска задаётся
|
||||
-- константой 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 -- main.go
|
||||
--
|
||||
@@ -21,8 +21,12 @@ print("[lua] init.lua выполнен")
|
||||
-- app:next_tab() -- переключить на следующую, вернуть индекс
|
||||
-- app:previous_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",
|
||||
-- -- is_dir = false }, ...}; пустой путь —
|
||||
-- -- текущий каталог, id — путь от него
|
||||
@@ -39,17 +43,28 @@ print("[lua] init.lua выполнен")
|
||||
-- app:language(i) -- язык подсветки вкладки или nil
|
||||
-- app:clear_language(i) -- выключить подсветку для вкладки
|
||||
-- app:save() -- сохранить активную вкладку в её файл
|
||||
-- app:map_key(keys, fn) -- обработчик клавиши/сочетания
|
||||
-- app:map_key(keys, handler) -- обработчик клавиши/сочетания:
|
||||
-- -- функция, имя команды или nil (удалить)
|
||||
-- -- "a" нажатие A
|
||||
-- -- "1" нажатие 1
|
||||
-- -- "!a" отпускание A
|
||||
-- -- "<S-c>" C при зажатом Shift
|
||||
-- -- "<C-z>" Z при зажатом Ctrl
|
||||
-- -- "<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:
|
||||
-- -- Caps Lock не важен; на Linux и Windows буквы
|
||||
-- -- берутся по аппаратному keycode, поэтому
|
||||
-- -- <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:toggle_maximize()
|
||||
-- app:set_maximized(true/false)
|
||||
@@ -70,12 +85,32 @@ print("[lua] init.lua выполнен")
|
||||
-- app:lsp_servers() -- { "gopls", ... }
|
||||
-- app:stop_lsp("gopls") -- true, если сервер был запущен
|
||||
-- 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 = { pattern = "**/*.go", ignore = { "vendor/**" } }
|
||||
-- watcher = true
|
||||
--
|
||||
-- Автодополнение включается само при вводе идентификатора; Ctrl+Space
|
||||
-- Автодополнение включается само при вводе идентификатора; Ctrl/Cmd+Space
|
||||
-- открывает список вручную. Пока список открыт, ↑/↓ и PageUp/PageDown
|
||||
-- перемещают выделение, Enter или Tab вставляют элемент, Esc закрывает
|
||||
-- список; горизонтальные стрелки закрывают его и двигают курсор.
|
||||
@@ -103,9 +138,13 @@ print("[lua] init.lua выполнен")
|
||||
-- Блоки панелей. Сценарий сам решает, что показывает блок: редактор умеет
|
||||
-- рисовать только абстрактное дерево.
|
||||
-- local tree = app.left_panel:add_tree()
|
||||
-- local tab = app.bottom_panel:add_tree("Диагностика")
|
||||
-- -- title показывается вкладкой в нижней
|
||||
-- -- панели; боковые панели его не рисуют
|
||||
-- tree:append(nodes, parent_id) -- nodes = { { id=..., label=..., icon=...,
|
||||
-- -- expandable=..., expanded=... }, ... };
|
||||
-- -- parent_id = "" — корень дерева
|
||||
-- tree:clear() -- удалить все узлы и сбросить выбор
|
||||
-- tree:remove(id) -- удалить ноду и всех потомков
|
||||
-- tree:on_toggle(fn) -- fn(expanded, id, loaded)
|
||||
-- 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:next_tab()
|
||||
|
||||
-- gopls запускается автоматически при старте редактора.
|
||||
-- Сервер обслуживает только документы с languageId "go" и получает изменения
|
||||
-- всех .go-файлов проекта (кроме vendor/) через файловый вотчер.
|
||||
@@ -155,6 +191,10 @@ end
|
||||
-- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно.
|
||||
-- app:map_key("<C-s>", function() app:save() 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)
|
||||
|
||||
project_tree:on_click(function(id)
|
||||
app:open(id)
|
||||
-- Пустая таблица: фокус уходит в редактор, курсор остаётся на месте.
|
||||
app:open(id, {})
|
||||
end)
|
||||
|
||||
app.left_panel:open()
|
||||
@@ -205,7 +246,140 @@ 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-t>", function() app.bottom_panel:toggle() end)
|
||||
app:map_key("<C-e>", "toggle_left_panel")
|
||||
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
@@ -13,14 +13,18 @@ use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use mlua::IntoLua;
|
||||
use serde_json::Number;
|
||||
|
||||
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::keymap::{KeyBinding, KeyMap};
|
||||
use crate::lsp::{LspConfig, LspManager, WatcherConfig, document_uri};
|
||||
use crate::keymap::{AppCommand, CompletionCommand, KeyBinding, KeyContext, KeyHandler, KeyMap};
|
||||
use crate::lsp::{
|
||||
DocumentDiagnostics, LspConfig, LspManager, WatcherConfig, document_uri, position_to_offset,
|
||||
};
|
||||
use crate::panel::{PanelHandle, PanelState};
|
||||
use crate::tree::{TreeHandle, TreeNode};
|
||||
|
||||
@@ -88,6 +92,16 @@ pub struct DirEntryInfo {
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
/// Зарегистрированный Lua-обработчик диагностики.
|
||||
///
|
||||
/// `delay` — пауза тишины, после которой обработчик вызывается. Если сервер
|
||||
/// присылает сообщения чаще, вызов откладывается до последнего изменения.
|
||||
#[derive(Clone)]
|
||||
struct DiagnosticsHandler {
|
||||
delay: Duration,
|
||||
callback: mlua::Function,
|
||||
}
|
||||
|
||||
/// Глобальный объект с функциями управления приложением.
|
||||
///
|
||||
/// Использование: `AppApi::global().close()`, `AppApi::global().tabs()`.
|
||||
@@ -111,6 +125,8 @@ pub struct AppApi {
|
||||
bottom_panel: PanelHandle,
|
||||
#[lua(skip)]
|
||||
lsp: Arc<LspManager>,
|
||||
#[lua(skip)]
|
||||
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
|
||||
}
|
||||
|
||||
/// API одной панели окна.
|
||||
@@ -183,6 +199,16 @@ impl TreeApi {
|
||||
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` — новая
|
||||
@@ -246,13 +272,18 @@ impl PanelApi {
|
||||
|
||||
/// Добавляет в панель блок дерева и возвращает его API.
|
||||
///
|
||||
/// `title` — заголовок вкладки нижней панели. Боковые панели заголовок
|
||||
/// сохраняют, но не рисуют.
|
||||
///
|
||||
/// ```lua
|
||||
/// local tree = app.left_panel:add_tree()
|
||||
/// tree:append({ { id = "a", label = "a.txt" } }, "")
|
||||
///
|
||||
/// local diagnostics = app.bottom_panel:add_tree("Диагностика")
|
||||
/// ```
|
||||
#[lua(infallible, name = "add_tree")]
|
||||
pub fn add_tree(&self) -> TreeApi {
|
||||
TreeApi::new(self.state.borrow_mut().add_tree())
|
||||
pub fn add_tree(&self, title: Option<String>) -> TreeApi {
|
||||
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))),
|
||||
bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))),
|
||||
lsp: Arc::new(LspManager::new()),
|
||||
diagnostics_handler: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
|
||||
api
|
||||
@@ -324,10 +356,7 @@ impl AppApi {
|
||||
.map(|(index, doc)| TabInfo {
|
||||
index,
|
||||
title: doc.name.clone(),
|
||||
icon: doc
|
||||
.icon
|
||||
.clone()
|
||||
.unwrap_or_else(|| icons::DEFAULT_TAB_ICON.to_owned()),
|
||||
icon: doc.icon_name().to_owned(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -345,10 +374,7 @@ impl AppApi {
|
||||
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
||||
#[lua(infallible)]
|
||||
pub fn next_tab(&self) -> usize {
|
||||
let mut workspace = self.workspace.borrow_mut();
|
||||
let next = (workspace.active_index() + 1) % workspace.documents().len();
|
||||
workspace.set_active(next);
|
||||
next
|
||||
self.workspace.borrow_mut().cycle_active(1)
|
||||
}
|
||||
|
||||
/// Переключает на предыдущую вкладку (по кругу) и возвращает её индекс.
|
||||
@@ -356,11 +382,7 @@ impl AppApi {
|
||||
// Часть публичного API приложения: вызывается внешним кодом и тестами.
|
||||
#[lua(infallible)]
|
||||
pub fn previous_tab(&self) -> usize {
|
||||
let mut workspace = self.workspace.borrow_mut();
|
||||
let count = workspace.documents().len();
|
||||
let previous = (workspace.active_index() + count - 1) % count;
|
||||
workspace.set_active(previous);
|
||||
previous
|
||||
self.workspace.borrow_mut().cycle_active(-1)
|
||||
}
|
||||
|
||||
/// Закрывает вкладку по её номеру в списке [`AppApi::tabs`].
|
||||
@@ -414,17 +436,41 @@ impl AppApi {
|
||||
/// `keys` записывается в духе nvim:
|
||||
///
|
||||
/// * `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-!r>` — отпускание клавиши при зажатом Alt.
|
||||
///
|
||||
/// Повторное назначение той же клавиши заменяет обработчик.
|
||||
/// Второй аргумент — Lua-функция, имя встроенной команды (`save`,
|
||||
/// `next_tab`, `toggle_left_panel` и т. п.) или `nil`, чтобы удалить
|
||||
/// привязку. Повторное назначение той же клавиши заменяет обработчик.
|
||||
///
|
||||
/// # Errors
|
||||
/// Возвращает ошибку Lua, если запись привязки некорректна.
|
||||
pub fn map_key(&self, keys: String, callback: mlua::Function) -> mlua::Result<()> {
|
||||
let binding = KeyBinding::parse(&keys).map_err(mlua::Error::RuntimeError)?;
|
||||
self.keymap.borrow_mut().bind(binding, callback);
|
||||
/// Возвращает ошибку Lua, если запись привязки или имя команды некорректны.
|
||||
pub fn map_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
|
||||
self.bind_key(KeyContext::Global, &keys, handler)
|
||||
}
|
||||
|
||||
/// Назначает обработчик клавиши, пока открыт список автодополнения.
|
||||
///
|
||||
/// Синтаксис совпадает с [`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(())
|
||||
}
|
||||
|
||||
@@ -458,8 +504,34 @@ impl AppApi {
|
||||
///
|
||||
/// Если файла нет, создаётся пустая вкладка с таким именем — как в vim.
|
||||
/// Язык подсветки определяется по расширению, если для него есть грамматика.
|
||||
/// Фокус редактора не меняется: для этого есть [`AppApi::open_focused`]
|
||||
/// и Lua-форма `app:open(path, {...})`.
|
||||
#[lua(skip)]
|
||||
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 content = match std::fs::read_to_string(path) {
|
||||
Ok(content) => content,
|
||||
@@ -467,19 +539,39 @@ impl AppApi {
|
||||
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 language = Language::from_path(&name);
|
||||
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")`.
|
||||
///
|
||||
/// Второй аргумент необязателен:
|
||||
///
|
||||
/// * `nil`/нет аргумента — открыть вкладку, не трогая фокус;
|
||||
/// * `{}` — перевести фокус в редактор, курсор оставить на месте;
|
||||
/// * `{line, column}` — перевести фокус и поставить курсор (с нуля).
|
||||
///
|
||||
/// Возвращает номер вкладки (с нуля). Ошибка чтения становится ошибкой Lua.
|
||||
#[lua(name = "open")]
|
||||
fn lua_open(&self, path: String) -> mlua::Result<usize> {
|
||||
self.open(&path)
|
||||
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
|
||||
fn lua_open(&self, path: String, position: mlua::Value) -> mlua::Result<usize> {
|
||||
match parse_open_focus(position)? {
|
||||
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`).
|
||||
@@ -490,7 +582,7 @@ impl AppApi {
|
||||
#[lua(skip)]
|
||||
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 root = raw.is_empty() || raw.as_str() == ".";
|
||||
let relative_to_cwd = raw.is_empty() || raw == ".";
|
||||
let directory = if raw.is_empty() {
|
||||
Path::new(".")
|
||||
} else {
|
||||
@@ -502,7 +594,7 @@ impl AppApi {
|
||||
let entry = entry?;
|
||||
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 id = if root {
|
||||
let id = if relative_to_cwd {
|
||||
name.clone()
|
||||
} else {
|
||||
Path::new(&raw)
|
||||
@@ -583,14 +675,10 @@ impl AppApi {
|
||||
/// `file` по умолчанию. `nil`, если вкладки нет.
|
||||
#[lua(infallible)]
|
||||
pub fn tab_icon(&self, index: usize) -> Option<String> {
|
||||
let workspace = self.workspace.borrow();
|
||||
workspace.document(index)?;
|
||||
Some(
|
||||
workspace
|
||||
.tab_icon(index)
|
||||
.unwrap_or(icons::DEFAULT_TAB_ICON)
|
||||
.to_owned(),
|
||||
)
|
||||
self.workspace
|
||||
.borrow()
|
||||
.document(index)
|
||||
.map(|doc| doc.icon_name().to_owned())
|
||||
}
|
||||
|
||||
/// Включает подсветку синтаксиса для вкладки `index`.
|
||||
@@ -746,6 +834,108 @@ impl AppApi {
|
||||
pub fn restart_lsp(&self, name: String) -> bool {
|
||||
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`.
|
||||
@@ -754,14 +944,9 @@ fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
|
||||
.get("name")
|
||||
.map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый name".to_owned()))?;
|
||||
|
||||
let command = ["cmd", "command", "path", "binary"]
|
||||
.iter()
|
||||
.find_map(|key| table.get::<String>(*key).ok())
|
||||
.ok_or_else(|| {
|
||||
mlua::Error::RuntimeError(
|
||||
"в run_lsp нужен путь к серверу: cmd, command, path или binary".to_owned(),
|
||||
)
|
||||
})?;
|
||||
let command: String = table
|
||||
.get("cmd")
|
||||
.map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый cmd".to_owned()))?;
|
||||
|
||||
let args = match table.get::<mlua::Table>("args") {
|
||||
Ok(args) => args
|
||||
@@ -777,32 +962,22 @@ fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
let root = table
|
||||
.get::<String>("root")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| 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)
|
||||
// Корень нормализуется в `LspManager::start`: относительный путь там
|
||||
// отсчитывается от текущего каталога процесса.
|
||||
let root = match table.get::<String>("root") {
|
||||
Ok(root) => PathBuf::from(root),
|
||||
Err(_) => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
};
|
||||
let root = std::fs::canonicalize(&root).unwrap_or(root);
|
||||
|
||||
let languages = match table.get::<mlua::Value>("languages") {
|
||||
Ok(value) => lua_strings(value)?,
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
let initialization_options = ["initialization_options", "init_options"]
|
||||
.iter()
|
||||
.find_map(|key| match table.get::<mlua::Value>(*key) {
|
||||
Ok(mlua::Value::Nil) | Err(_) => None,
|
||||
Ok(value) => Some(lua_to_json(value)),
|
||||
})
|
||||
.transpose()?;
|
||||
let initialization_options = match table.get::<mlua::Value>("initialization_options") {
|
||||
Ok(mlua::Value::Nil) | Err(_) => None,
|
||||
Ok(value) => Some(lua_to_json(value)?),
|
||||
};
|
||||
|
||||
Ok(LspConfig {
|
||||
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>> {
|
||||
let value = ["watcher", "file_watcher", "watch"].iter().find_map(|key| {
|
||||
match table.get::<mlua::Value>(*key) {
|
||||
Ok(mlua::Value::Nil) | Err(_) => None,
|
||||
Ok(value) => Some(value),
|
||||
}
|
||||
});
|
||||
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
let value = match table.get::<mlua::Value>("watcher") {
|
||||
Ok(mlua::Value::Nil) | Err(_) => return Ok(None),
|
||||
Ok(value) => 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::Table(config) => {
|
||||
let patterns = ["pattern", "patterns"]
|
||||
.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();
|
||||
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();
|
||||
let patterns = match config.get::<mlua::Value>("pattern") {
|
||||
Ok(mlua::Value::Nil) | Err(_) => Vec::new(),
|
||||
Ok(value) => lua_strings(value)?,
|
||||
};
|
||||
let ignore = match config.get::<mlua::Value>("ignore") {
|
||||
Ok(mlua::Value::Nil) | Err(_) => Vec::new(),
|
||||
Ok(value) => lua_strings(value)?,
|
||||
};
|
||||
|
||||
if patterns.is_empty() {
|
||||
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`.
|
||||
fn parse_tree_nodes(nodes: &mlua::Table) -> mlua::Result<Vec<TreeNode>> {
|
||||
let mut parsed = Vec::new();
|
||||
|
||||
+506
-173
@@ -7,13 +7,19 @@
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::ops::Range;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::task::{Context as TaskContext, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui_kit::assets::IconName as AssetIcon;
|
||||
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::tab::{Tab, TabBar};
|
||||
use gpui_kit::component::text::TextView;
|
||||
@@ -31,9 +37,9 @@ use crate::completion::{
|
||||
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::editor::Workspace;
|
||||
use crate::editor::{FocusAfterSync, Workspace};
|
||||
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::panel::{PanelBlock, PanelHandle};
|
||||
use crate::script::ScriptEngine;
|
||||
@@ -78,6 +84,13 @@ struct CompletionUi {
|
||||
scroll: ScrollHandle,
|
||||
}
|
||||
|
||||
impl CompletionUi {
|
||||
/// Сколько строк списка реально рисуется и доступно для выбора.
|
||||
fn visible_len(&self) -> usize {
|
||||
self.items.len().min(MAX_COMPLETION_ROWS)
|
||||
}
|
||||
}
|
||||
|
||||
/// Документация выбранного элемента для второй всплывающей панели.
|
||||
#[derive(Default)]
|
||||
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 {
|
||||
/// Открытые документы, вкладки и активная вкладка.
|
||||
@@ -156,10 +198,15 @@ pub struct MyApp {
|
||||
displayed_id: u64,
|
||||
/// Курсор редактора ещё ни разу не получал фокус.
|
||||
focused_once: bool,
|
||||
/// Явный запрос `app:open(path, ...)` на фокус в редакторе.
|
||||
///
|
||||
/// Применяется повторно после отрисовки панелей: дерево могло отложить
|
||||
/// собственный запрос фокуса и перехватить его в этом же кадре.
|
||||
focus_editor_after_render: bool,
|
||||
/// Подписка на изменения редактора; держим её, пока жив вид.
|
||||
_editor_subscription: Subscription,
|
||||
/// Глобальный перехватчик клавиш: он срабатывает раньше действий
|
||||
/// редактора и закрывает автодополнение на стрелках, не мешая курсору.
|
||||
/// Глобальный перехватчик клавиш: слой автодополнения срабатывает
|
||||
/// раньше действий редактора и не мешает движению курсора.
|
||||
_key_interceptor: Subscription,
|
||||
/// Движок Lua, созданный при старте.
|
||||
///
|
||||
@@ -181,6 +228,11 @@ pub struct MyApp {
|
||||
/// (их может быть несколько из-за `additionalTextEdits`) не должны
|
||||
/// переоткрывать список. Очищается после финального текста или в `defer`.
|
||||
suppress_completion_for_text: Option<String>,
|
||||
/// Коллекция подчёркиваний диагностики в редакторе.
|
||||
///
|
||||
/// Рисуется независимо от подсветки синтаксиса, поэтому ошибки
|
||||
/// видны и в файлах без tree-sitter грамматики.
|
||||
diagnostics_decorations: TextDecorationCollection,
|
||||
}
|
||||
|
||||
impl MyApp {
|
||||
@@ -191,6 +243,7 @@ impl MyApp {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
let keymap = Rc::new(RefCell::new(KeyMap::default()));
|
||||
keymap.borrow_mut().install_defaults();
|
||||
let api = AppApi::install(workspace.clone(), keymap.clone());
|
||||
let lsp = api.lsp_handle();
|
||||
|
||||
@@ -253,13 +306,82 @@ impl MyApp {
|
||||
);
|
||||
|
||||
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 right_panel = api.right_panel_handle();
|
||||
let bottom_panel = api.bottom_panel_handle();
|
||||
|
||||
// `capture_key_down` на корне вызывается уже после обработки action,
|
||||
// поэтому для стрелок он не успевал. Interceptor срабатывает до
|
||||
// key bindings: можно закрыть список и не мешать движению курсора.
|
||||
// key bindings: слой автодополнения может закрыть список и не мешать
|
||||
// движению курсора.
|
||||
let view = cx.weak_entity();
|
||||
let _key_interceptor = cx.intercept_keystrokes(move |event, window, cx| {
|
||||
let handled = view
|
||||
@@ -283,6 +405,7 @@ impl MyApp {
|
||||
synced_revision: 0,
|
||||
displayed_id,
|
||||
focused_once: false,
|
||||
focus_editor_after_render: false,
|
||||
_editor_subscription,
|
||||
_key_interceptor,
|
||||
scripts: None,
|
||||
@@ -292,6 +415,7 @@ impl MyApp {
|
||||
docs_cache: HashMap::new(),
|
||||
docs_generation: 0,
|
||||
suppress_completion_for_text: None,
|
||||
diagnostics_decorations,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,20 +467,14 @@ impl MyApp {
|
||||
#[cfg(test)]
|
||||
pub fn open_test_completion_with_count(&mut self, count: usize, cx: &mut Context<Self>) {
|
||||
let cursor = self.editor.read(cx).cursor();
|
||||
self.completion.open = true;
|
||||
self.completion.items = (0..count)
|
||||
let items = (0..count)
|
||||
.map(|index| CompletionItem {
|
||||
label: format!("test_{index}"),
|
||||
detail: Some("test".to_owned()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
self.completion.selected = 0;
|
||||
self.completion.start_offset = cursor;
|
||||
self.completion.query.clear();
|
||||
self.completion.scroll.scroll_to_item(0);
|
||||
self.update_docs_for_selection(cx);
|
||||
cx.notify();
|
||||
self.show_completion(items, cursor, String::new(), 0, cx);
|
||||
}
|
||||
|
||||
/// Прокрутка списка автодополнения (доступ для тестов).
|
||||
@@ -377,6 +495,18 @@ impl MyApp {
|
||||
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-серверам, которые его обслуживают.
|
||||
fn sync_lsp_document(&self) {
|
||||
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(
|
||||
&mut self,
|
||||
@@ -490,12 +665,26 @@ impl MyApp {
|
||||
.unwrap_or(0)
|
||||
.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.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.query = query;
|
||||
self.completion.scroll.scroll_to_item(selected);
|
||||
self.completion
|
||||
.scroll
|
||||
.scroll_to_item(self.completion.selected);
|
||||
|
||||
self.update_docs_for_selection(cx);
|
||||
cx.notify();
|
||||
@@ -517,8 +706,7 @@ impl MyApp {
|
||||
}
|
||||
|
||||
fn select_completion(&mut self, selected: usize, cx: &mut Context<Self>) {
|
||||
let visible = self.completion.items.len().min(MAX_COMPLETION_ROWS);
|
||||
if selected >= visible || selected == self.completion.selected {
|
||||
if selected >= self.completion.visible_len() || selected == self.completion.selected {
|
||||
return;
|
||||
}
|
||||
self.completion.selected = selected;
|
||||
@@ -529,7 +717,7 @@ impl MyApp {
|
||||
|
||||
/// Сдвигает выделение на `delta` строк, не выходя за пределы списка.
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
@@ -610,70 +798,86 @@ impl MyApp {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Обрабатывает клавиши, пока открыт список автодополнения.
|
||||
/// Обрабатывает клавиши слоя автодополнения.
|
||||
///
|
||||
/// Вызывается из перехватчика клавиш, который срабатывает раньше
|
||||
/// действий редактора. Пока список открыт, `↑`/`↓` и `PageUp`/`PageDown`
|
||||
/// двигают выделение, а не курсор; `Enter`/`Tab` вставляют элемент,
|
||||
/// `Esc` закрывает список. Горизонтальные стрелки, `Home` и `End`
|
||||
/// закрывают список и работают с курсором как обычно.
|
||||
///
|
||||
/// Все привязки этого слоя приходят из [`KeyMap`], поэтому их можно
|
||||
/// заменить через `app:map_completion_key`.
|
||||
fn handle_completion_key(
|
||||
&mut self,
|
||||
keystroke: &Keystroke,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
if is_completion_shortcut(keystroke) {
|
||||
self.request_completions(window, cx);
|
||||
return true;
|
||||
let handlers = self
|
||||
.keymap
|
||||
.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;
|
||||
}
|
||||
|
||||
// Сочетания с модификаторами — это не навигация по списку: закрываем
|
||||
// его и отдаём клавишу редактору (Shift+↑ выделяет текст, Shift+Enter
|
||||
// переносит строку и т. п.).
|
||||
let plain = !(keystroke.modifiers.control
|
||||
|| keystroke.modifiers.platform
|
||||
|| 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" => {
|
||||
match command {
|
||||
CompletionCommand::Trigger => self.request_completions(window, cx),
|
||||
CompletionCommand::Next => self.move_completion_selection(1, cx),
|
||||
CompletionCommand::Previous => self.move_completion_selection(-1, cx),
|
||||
CompletionCommand::PageDown => {
|
||||
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);
|
||||
false
|
||||
return false;
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Вставляет выбранный элемент в редактор.
|
||||
@@ -1022,7 +1226,7 @@ impl MyApp {
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(
|
||||
"Серверы LSP не запущены. Запустите их из init.lua через app:run_lsp{...}.",
|
||||
"Серверы LSP не запущены. Запустите их из init.lua через app:run_lsp(...).",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -1065,16 +1269,24 @@ impl MyApp {
|
||||
|
||||
/// Содержимое панели: блоки в порядке добавления.
|
||||
///
|
||||
/// Боковые панели рисуют все блоки друг под другом и не показывают их
|
||||
/// заголовки. `tabs = true` (нижняя панель) рисует вкладки блоков и
|
||||
/// показывает только активный блок.
|
||||
///
|
||||
/// Если блоков нет, правая панель показывает список LSP-серверов, как и
|
||||
/// раньше, а остальные панели остаются пустыми.
|
||||
fn render_panel_content(
|
||||
&self,
|
||||
panel: &PanelHandle,
|
||||
lsp_fallback: bool,
|
||||
tabs: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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() {
|
||||
return if lsp_fallback {
|
||||
self.render_right_panel(cx)
|
||||
@@ -1084,15 +1296,73 @@ impl MyApp {
|
||||
}
|
||||
|
||||
let mut content = div().size_full().flex().flex_col().min_h_0();
|
||||
for block in blocks {
|
||||
let element = match block {
|
||||
PanelBlock::Tree(tree) => self.render_tree_block(&tree, window, cx),
|
||||
};
|
||||
content = content.child(div().w_full().flex_1().min_h_0().child(element));
|
||||
if tabs {
|
||||
content = content.child(
|
||||
div()
|
||||
.w_full()
|
||||
.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()
|
||||
}
|
||||
|
||||
/// Полоса вкладок блоков нижней панели.
|
||||
///
|
||||
/// Выглядит как вкладки редактора, но без крестика: блоки не удаляются.
|
||||
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; вид только сортирует видимые
|
||||
@@ -1248,25 +1518,34 @@ impl MyApp {
|
||||
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>) {
|
||||
let previous = self.panel_open_state.get();
|
||||
let panels = [&self.left_panel, &self.right_panel, &self.bottom_panel];
|
||||
for (index, panel) in panels.into_iter().enumerate() {
|
||||
let panels = [
|
||||
(&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] {
|
||||
continue;
|
||||
}
|
||||
let tree = panel
|
||||
.borrow()
|
||||
.blocks()
|
||||
.iter()
|
||||
.find_map(|block| match block {
|
||||
PanelBlock::Tree(tree) => Some(tree.clone()),
|
||||
});
|
||||
let tree = {
|
||||
let state = panel.borrow();
|
||||
let block = if tabs {
|
||||
state.blocks().get(state.active())
|
||||
} else {
|
||||
state.blocks().first()
|
||||
};
|
||||
block.map(|block| {
|
||||
let PanelBlock::Tree { tree, .. } = block;
|
||||
tree.clone()
|
||||
})
|
||||
};
|
||||
let Some(tree) = tree else {
|
||||
continue;
|
||||
};
|
||||
@@ -1293,12 +1572,30 @@ impl MyApp {
|
||||
}
|
||||
self.synced_revision = revision;
|
||||
|
||||
let (active_id, content, language, cursor) = {
|
||||
let workspace = self.workspace.borrow();
|
||||
let (active_id, content, language, cursor, focus_after_sync) = {
|
||||
let mut workspace = self.workspace.borrow_mut();
|
||||
let focus_after_sync = workspace.take_focus_after_sync();
|
||||
let doc = workspace.active();
|
||||
(doc.id, doc.content.clone(), doc.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;
|
||||
if switching {
|
||||
// Запоминаем курсор покидаемой вкладки до того, как индекс
|
||||
@@ -1315,7 +1612,7 @@ impl MyApp {
|
||||
self.displayed_id = active_id;
|
||||
|
||||
let highlight = language
|
||||
.map(|language| language.highlight_name())
|
||||
.map(|language| language.syntax_name())
|
||||
.unwrap_or("");
|
||||
self.editor.update(cx, |state, cx| {
|
||||
if state.value().as_ref() != content.as_str() {
|
||||
@@ -1324,16 +1621,20 @@ impl MyApp {
|
||||
if state.language_name().as_ref() != highlight {
|
||||
state.set_highlighter(highlight, cx);
|
||||
}
|
||||
if switching {
|
||||
let cursor = cursor.min(state.value().len());
|
||||
if switching || focus_cursor.is_some() {
|
||||
let cursor = focus_cursor.unwrap_or(cursor).min(state.value().len());
|
||||
state.set_selected_range(cursor..cursor, cx);
|
||||
}
|
||||
state.focus(window, cx);
|
||||
if focus_editor {
|
||||
state.focus(window, cx);
|
||||
}
|
||||
});
|
||||
|
||||
if switching {
|
||||
self.close_completion(cx);
|
||||
self.sync_lsp_document();
|
||||
// У другой вкладки своя диагностика, уже пришедшая от сервера.
|
||||
self.apply_diagnostics(cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1371,19 +1672,16 @@ impl MyApp {
|
||||
|
||||
// Нижняя панель занимает ширину редактора, а правая тянется на всю
|
||||
// высоту — как в большинстве редакторов кода.
|
||||
let editor_area =
|
||||
v_resizable("editor-area")
|
||||
.child(
|
||||
resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full()),
|
||||
)
|
||||
.child(
|
||||
resizable_panel()
|
||||
.size(px(bottom_size))
|
||||
.visible(bottom_open)
|
||||
.child(div().size_full().bg(cx.theme().sidebar).child(
|
||||
self.render_panel_content(&self.bottom_panel, false, window, cx),
|
||||
)),
|
||||
);
|
||||
let editor_area = v_resizable("editor-area")
|
||||
.child(resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full()))
|
||||
.child(
|
||||
resizable_panel()
|
||||
.size(px(bottom_size))
|
||||
.visible(bottom_open)
|
||||
.child(div().size_full().bg(cx.theme().sidebar).child(
|
||||
self.render_panel_content(&self.bottom_panel, false, true, window, cx),
|
||||
)),
|
||||
);
|
||||
|
||||
let area = h_resizable("main-area")
|
||||
.child(resizable_panel().child(editor_area))
|
||||
@@ -1391,12 +1689,9 @@ impl MyApp {
|
||||
resizable_panel()
|
||||
.size(px(right_size))
|
||||
.visible(right_open)
|
||||
.child(
|
||||
div()
|
||||
.size_full()
|
||||
.bg(cx.theme().sidebar)
|
||||
.child(self.render_panel_content(&self.right_panel, true, window, cx)),
|
||||
),
|
||||
.child(div().size_full().bg(cx.theme().sidebar).child(
|
||||
self.render_panel_content(&self.right_panel, true, false, window, cx),
|
||||
)),
|
||||
);
|
||||
|
||||
let mut row = div().size_full().flex().flex_row();
|
||||
@@ -1408,7 +1703,7 @@ impl MyApp {
|
||||
.flex_shrink_0()
|
||||
.relative()
|
||||
.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(
|
||||
resize_handle("left-panel-handle", Axis::Horizontal)
|
||||
.inside(HandleEdge::Trailing)
|
||||
@@ -1445,7 +1740,7 @@ impl MyApp {
|
||||
(
|
||||
doc.name.clone(),
|
||||
doc.is_dirty(),
|
||||
icons::tab_icon(doc.icon.as_deref()),
|
||||
icons::tab_icon(Some(doc.icon_name())),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -1547,6 +1842,14 @@ impl Render for MyApp {
|
||||
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 workspace = self.workspace.borrow();
|
||||
format!("{} — {WINDOW_TITLE}", workspace.active().name)
|
||||
@@ -1566,12 +1869,7 @@ impl Render for MyApp {
|
||||
let keymap = keymap.clone();
|
||||
let entity = entity.clone();
|
||||
move |event, window, cx| {
|
||||
let mut handled = false;
|
||||
if is_save_shortcut(&event.keystroke) {
|
||||
save_active_tab();
|
||||
handled = true;
|
||||
}
|
||||
handled |= dispatch_keymap(&keymap, &event.keystroke, true);
|
||||
let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, true);
|
||||
// Обработчик мог поставить команду окна или изменить документы.
|
||||
apply_window_commands(window);
|
||||
if handled {
|
||||
@@ -1583,7 +1881,7 @@ impl Render for MyApp {
|
||||
let keymap = keymap.clone();
|
||||
let entity = entity.clone();
|
||||
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);
|
||||
if handled {
|
||||
entity.update(cx, |_, cx| cx.notify());
|
||||
@@ -1591,32 +1889,30 @@ impl Render for MyApp {
|
||||
}
|
||||
})
|
||||
.child(
|
||||
TitleBar::new().child(
|
||||
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"),
|
||||
),
|
||||
),
|
||||
TitleBar::new().child(div().w_full().pr_2().text_sm().font_medium().child(title)),
|
||||
)
|
||||
.child(self.render_tabs(cx))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.child(self.render_workspace(window, cx)),
|
||||
)
|
||||
.child(div().flex_1().w_full().min_h_0().child(workspace))
|
||||
.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) {
|
||||
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 не должна ломать отрисовку: она печатается в stderr, как и
|
||||
@@ -1660,21 +1942,19 @@ fn run_tree_action(action: TreeAction) {
|
||||
id,
|
||||
expanded,
|
||||
loaded,
|
||||
callback,
|
||||
} => {
|
||||
if let Some(callback) = callback
|
||||
&& let Err(err) = callback.call::<()>((expanded, id, loaded))
|
||||
{
|
||||
eprintln!("Lua: ошибка в обработчике дерева: {err}");
|
||||
}
|
||||
}
|
||||
TreeAction::Click { id, callback } => {
|
||||
if let Some(callback) = callback
|
||||
&& let Err(err) = callback.call::<()>(id)
|
||||
{
|
||||
eprintln!("Lua: ошибка в обработчике дерева: {err}");
|
||||
}
|
||||
}
|
||||
callback: Some(callback),
|
||||
} => report_tree_error(callback.call::<()>((expanded, id, loaded))),
|
||||
TreeAction::Click {
|
||||
id,
|
||||
callback: Some(callback),
|
||||
} => report_tree_error(callback.call::<()>(id)),
|
||||
TreeAction::Toggle { callback: None, .. } | TreeAction::Click { callback: None, .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn report_tree_error(result: mlua::Result<()>) {
|
||||
if let Err(err) = result {
|
||||
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` и изменить привязки.
|
||||
fn dispatch_keymap(keymap: &Rc<RefCell<KeyMap>>, keystroke: &Keystroke, pressed: bool) -> bool {
|
||||
let callbacks = keymap.borrow().callbacks_for(keystroke, pressed);
|
||||
let handled = !callbacks.is_empty();
|
||||
for callback in callbacks {
|
||||
if let Err(err) = callback.call::<()>(()) {
|
||||
eprintln!("Lua: ошибка в обработчике клавиши: {err}");
|
||||
fn run_keymap(
|
||||
keymap: &Rc<RefCell<KeyMap>>,
|
||||
context: KeyContext,
|
||||
keystroke: &Keystroke,
|
||||
pressed: bool,
|
||||
) -> 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
|
||||
|
||||
+70
-43
@@ -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, а не компактный набор
|
||||
@@ -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 {
|
||||
use gpui_kit::assets::IconName as Icon;
|
||||
|
||||
match kind {
|
||||
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => Icon::SquareFunction,
|
||||
Some(
|
||||
CompletionItemKind::STRUCT
|
||||
| CompletionItemKind::CLASS
|
||||
| CompletionItemKind::INTERFACE
|
||||
| CompletionItemKind::ENUM
|
||||
| CompletionItemKind::CONSTRUCTOR,
|
||||
) => Icon::Box,
|
||||
Some(
|
||||
CompletionItemKind::VARIABLE
|
||||
| CompletionItemKind::FIELD
|
||||
| CompletionItemKind::PROPERTY
|
||||
| CompletionItemKind::CONSTANT
|
||||
| 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,
|
||||
match kind_group(kind) {
|
||||
CompletionKindGroup::Function => Icon::SquareFunction,
|
||||
CompletionKindGroup::Type => Icon::Box,
|
||||
CompletionKindGroup::Variable => Icon::Variable,
|
||||
CompletionKindGroup::Keyword => Icon::Type,
|
||||
CompletionKindGroup::Module => Icon::Braces,
|
||||
CompletionKindGroup::File => Icon::FileText,
|
||||
CompletionKindGroup::Folder => Icon::Folder,
|
||||
CompletionKindGroup::Snippet => Icon::Component,
|
||||
CompletionKindGroup::Event => Icon::Activity,
|
||||
CompletionKindGroup::TypeParameter => Icon::TypeOutline,
|
||||
CompletionKindGroup::Operator => Icon::CaseSensitive,
|
||||
CompletionKindGroup::Other => Icon::Circle,
|
||||
CompletionKindGroup::Unknown => Icon::Sparkles,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,23 +361,12 @@ pub fn completion_icon_color(
|
||||
kind: Option<CompletionItemKind>,
|
||||
theme: &gpui_kit::component::Theme,
|
||||
) -> Hsla {
|
||||
match kind {
|
||||
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => theme.blue,
|
||||
Some(
|
||||
CompletionItemKind::STRUCT
|
||||
| CompletionItemKind::CLASS
|
||||
| CompletionItemKind::INTERFACE
|
||||
| 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,
|
||||
match kind_group(kind) {
|
||||
CompletionKindGroup::Function => theme.blue,
|
||||
CompletionKindGroup::Type => theme.yellow,
|
||||
CompletionKindGroup::Variable => theme.green,
|
||||
CompletionKindGroup::Keyword => theme.magenta,
|
||||
CompletionKindGroup::Module => theme.cyan,
|
||||
_ => theme.muted_foreground,
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ pub const MIN_WINDOW_WIDTH: f32 = 420.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;
|
||||
|
||||
+53
-62
@@ -25,17 +25,6 @@ pub enum Language {
|
||||
}
|
||||
|
||||
impl Language {
|
||||
/// Все поддерживаемые языки.
|
||||
#[allow(dead_code)]
|
||||
// Часть публичного интерфейса модуля: удобно для перебора языков снаружи.
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Rust,
|
||||
Self::Go,
|
||||
Self::JavaScript,
|
||||
Self::Json,
|
||||
Self::Python,
|
||||
];
|
||||
|
||||
/// Разбирает название языка из API/сценария.
|
||||
///
|
||||
/// Основные имена — `rust`, `go`, `js`, `json`, `python`; короткие
|
||||
@@ -62,25 +51,12 @@ impl Language {
|
||||
}
|
||||
}
|
||||
|
||||
/// Имя языка для `EditorState::set_highlighter`.
|
||||
/// Полное имя языка для tree-sitter и LSP.
|
||||
///
|
||||
/// Здесь `js` разворачивается в `javascript`: так грамматика находится
|
||||
/// без дополнительных псевдонимов на нашей стороне.
|
||||
pub fn highlight_name(self) -> &'static str {
|
||||
match self {
|
||||
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 {
|
||||
/// Отличается от [`Self::name`] только для JavaScript: грамматика и
|
||||
/// `languageId` используют `javascript`, а короткое `js` остаётся именем
|
||||
/// в Lua-API.
|
||||
pub fn syntax_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rust => "rust",
|
||||
Self::Go => "go",
|
||||
@@ -188,17 +164,35 @@ impl Document {
|
||||
/// по расширению имени вкладки — так LSP работает и без `set_language`.
|
||||
pub fn lsp_language_id(&self) -> String {
|
||||
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()
|
||||
}
|
||||
|
||||
/// Имя иконки вкладки с учётом иконки по умолчанию.
|
||||
pub fn icon_name(&self) -> &str {
|
||||
self.icon
|
||||
.as_deref()
|
||||
.unwrap_or(crate::icons::DEFAULT_TAB_ICON)
|
||||
}
|
||||
|
||||
/// Помечает текущее содержимое сохранённым.
|
||||
pub fn mark_saved(&mut self) {
|
||||
self.saved_content = self.content.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Что сделать с фокусом редактора после синхронизации модели с видом.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FocusAfterSync {
|
||||
/// Оставить фокус там, где он был (`app:open` без позиции).
|
||||
Keep,
|
||||
/// Перевести фокус в редактор и, если задано, поставить курсор.
|
||||
///
|
||||
/// Смещение задаётся в байтах; `None` — оставить текущую позицию.
|
||||
Editor(Option<usize>),
|
||||
}
|
||||
|
||||
/// Открытые документы и активная вкладка.
|
||||
///
|
||||
/// Инвариант: `documents` никогда не пуст — редактор всегда показывает хотя бы
|
||||
@@ -207,6 +201,8 @@ pub struct Workspace {
|
||||
documents: Vec<Document>,
|
||||
active: usize,
|
||||
next_untitled: u32,
|
||||
/// Действие с фокусом, ожидающее ближайшей синхронизации вида.
|
||||
focus_after_sync: Option<FocusAfterSync>,
|
||||
/// Номер «поколения» состояния.
|
||||
///
|
||||
/// Вид перечитывает активный документ в редактор только когда ревизия
|
||||
@@ -218,12 +214,10 @@ pub struct Workspace {
|
||||
impl Default for Workspace {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
documents: vec![
|
||||
Document::new("main.rs", SAMPLE_RUST),
|
||||
Document::new("notes.md", SAMPLE_NOTES),
|
||||
],
|
||||
documents: vec![Document::new("untitled-1.txt", "")],
|
||||
active: 0,
|
||||
next_untitled: 0,
|
||||
next_untitled: 1,
|
||||
focus_after_sync: None,
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
@@ -261,6 +255,16 @@ impl Workspace {
|
||||
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) {
|
||||
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) {
|
||||
self.next_untitled += 1;
|
||||
@@ -333,7 +348,9 @@ impl Workspace {
|
||||
|
||||
if self.documents.is_empty() {
|
||||
self.open_new();
|
||||
} else if index < self.active {
|
||||
return;
|
||||
}
|
||||
if index < self.active {
|
||||
self.active -= 1;
|
||||
} else if index == self.active {
|
||||
self.active = self.active.min(self.documents.len() - 1);
|
||||
@@ -417,30 +434,4 @@ impl Workspace {
|
||||
}
|
||||
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
@@ -1,12 +1,17 @@
|
||||
//! Привязки клавиш к Lua-обработчикам.
|
||||
//! Привязки клавиш к Lua-обработчикам и встроенным командам.
|
||||
//!
|
||||
//! Запись привязки немного напоминает nvim:
|
||||
//!
|
||||
//! * `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-!r>` — отпускание клавиши при зажатом Alt.
|
||||
//!
|
||||
//! Привязка действует в одном из двух слоёв: глобальном или в списке
|
||||
//! автодополнения. Оба слоя проходят через одну [`KeyMap`], поэтому сценарий
|
||||
//! может переопределить и удалить любую встроенную привязку.
|
||||
//!
|
||||
//! Клавиша сравнивается с `Keystroke.key` из GPUI. Это не результат ввода, а
|
||||
//! имя клавиши, которое платформа уже нормализует:
|
||||
//!
|
||||
@@ -29,19 +34,21 @@ pub struct Modifiers {
|
||||
pub shift: bool,
|
||||
pub ctrl: bool,
|
||||
pub alt: bool,
|
||||
/// Cmd на macOS, Super на Linux и Windows.
|
||||
pub platform: bool,
|
||||
}
|
||||
|
||||
impl Modifiers {
|
||||
/// Все три модификатора должны совпасть точно: `<C-z>` не сработает,
|
||||
/// если вместе с Ctrl зажат ещё и Shift.
|
||||
/// Все модификаторы должны совпасть точно: `<C-z>` не сработает, если
|
||||
/// вместе с Ctrl зажат ещё и Shift.
|
||||
///
|
||||
/// `platform` (Cmd/Super) и `function` в записи привязки не выражаются,
|
||||
/// поэтому события с ними не совпадают ни с одной привязкой.
|
||||
/// `function` в записи привязки не выражается, поэтому события с ним не
|
||||
/// совпадают ни с одной привязкой.
|
||||
fn matches(self, pressed: &gpui_kit::Modifiers) -> bool {
|
||||
self.shift == pressed.shift
|
||||
&& self.ctrl == pressed.control
|
||||
&& self.alt == pressed.alt
|
||||
&& !pressed.platform
|
||||
&& self.platform == pressed.platform
|
||||
&& !pressed.function
|
||||
}
|
||||
}
|
||||
@@ -101,6 +108,7 @@ impl KeyBinding {
|
||||
"S" | "s" => modifiers.shift = true,
|
||||
"C" | "c" => modifiers.ctrl = true,
|
||||
"A" | "a" => modifiers.alt = true,
|
||||
"D" | "d" | "Cmd" | "cmd" | "Super" | "super" => modifiers.platform = true,
|
||||
other => return Err(format!("неизвестный модификатор: {other}")),
|
||||
}
|
||||
}
|
||||
@@ -206,10 +214,146 @@ fn canonical_key(key: &str) -> String {
|
||||
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 {
|
||||
context: KeyContext,
|
||||
binding: KeyBinding,
|
||||
callback: mlua::Function,
|
||||
handler: KeyHandler,
|
||||
}
|
||||
|
||||
/// Все назначенные привязки клавиш.
|
||||
@@ -219,32 +363,98 @@ pub struct KeyMap {
|
||||
}
|
||||
|
||||
impl KeyMap {
|
||||
/// Добавляет привязку. Повторное назначение той же клавиши заменяет
|
||||
/// предыдущий обработчик.
|
||||
pub fn bind(&mut self, binding: KeyBinding, callback: mlua::Function) {
|
||||
/// Назначает или заменяет обработчик в указанном слое.
|
||||
pub fn set(&mut self, context: KeyContext, binding: KeyBinding, handler: KeyHandler) {
|
||||
if let Some(existing) = self
|
||||
.bindings
|
||||
.iter_mut()
|
||||
.find(|existing| existing.binding == binding)
|
||||
.find(|existing| existing.context == context && existing.binding == binding)
|
||||
{
|
||||
existing.callback = callback;
|
||||
existing.handler = handler;
|
||||
} 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`.
|
||||
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
|
||||
.iter()
|
||||
.filter(|binding| binding.binding.matches(keystroke, pressed))
|
||||
.map(|binding| binding.callback.clone())
|
||||
.filter(|binding| {
|
||||
binding.context == context && binding.binding.matches(keystroke, pressed)
|
||||
})
|
||||
.map(|binding| binding.handler.clone())
|
||||
.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)]
|
||||
@@ -335,6 +545,18 @@ mod tests {
|
||||
shift: true,
|
||||
ctrl: 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,
|
||||
}
|
||||
@@ -373,9 +595,15 @@ mod tests {
|
||||
control: true,
|
||||
..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_ctrl), true));
|
||||
assert!(!binding.matches(&stroke("c", shift_platform), true));
|
||||
assert!(!binding.matches(&stroke("c", shift), false));
|
||||
assert!(!binding.matches(&stroke("v", shift), true));
|
||||
}
|
||||
@@ -439,4 +667,55 @@ mod tests {
|
||||
// А привязка вида `<A-!a>` — совпадает.
|
||||
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
@@ -6,6 +6,7 @@
|
||||
//! * JSON-RPC 2.0 с кадрами `Content-Length`;
|
||||
//! * синхронизацию открытого документа (`didOpen`/`didChange`/`didSave`);
|
||||
//! * запросы автодополнения, подсказок и `completionItem/resolve`;
|
||||
//! * хранение диагностики `textDocument/publishDiagnostics` по документам;
|
||||
//! * файловый вотчер, который пересылает серверу `workspace/didChangeWatchedFiles`.
|
||||
//!
|
||||
//! Всё остальное — какие серверы запускать, с какими аргументами и когда —
|
||||
@@ -26,11 +27,108 @@ use std::sync::{Arc, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
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 parking_lot::Mutex;
|
||||
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-сервера.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct WatcherConfig {
|
||||
@@ -75,6 +173,135 @@ struct ActiveDocument {
|
||||
type PendingMap = Mutex<HashMap<u64, mpsc::Sender<Result<Value, 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-серверов.
|
||||
///
|
||||
/// Живёт в `Arc`, потому что его одновременно читают UI, Lua-API и фоновые
|
||||
@@ -83,6 +310,7 @@ pub struct LspManager {
|
||||
servers: Mutex<BTreeMap<String, Arc<LspServer>>>,
|
||||
active: Mutex<Option<ActiveDocument>>,
|
||||
log: LogBuffer,
|
||||
diagnostics: Arc<DiagnosticsStore>,
|
||||
}
|
||||
|
||||
impl Default for LspManager {
|
||||
@@ -97,6 +325,7 @@ impl LspManager {
|
||||
servers: Mutex::new(BTreeMap::new()),
|
||||
active: Mutex::new(None),
|
||||
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);
|
||||
|
||||
let server = LspServer::spawn(config.clone())?;
|
||||
let server = LspServer::spawn(config.clone(), self.diagnostics.clone())?;
|
||||
|
||||
// `initialize` — единственный запрос, который ждём прямо здесь:
|
||||
// без ответа сервер нельзя считать рабочим.
|
||||
@@ -170,6 +399,7 @@ impl LspManager {
|
||||
return false;
|
||||
};
|
||||
server.shutdown();
|
||||
self.diagnostics.clear_server(name);
|
||||
self.log(format!("остановлен LSP-сервер «{name}»"));
|
||||
true
|
||||
}
|
||||
@@ -209,33 +439,33 @@ impl LspManager {
|
||||
/// Первый вызов для документа отправляет `didOpen`, последующие с
|
||||
/// изменившимся текстом — `didChange` с новой версией.
|
||||
pub fn sync_document(&self, uri: Uri, language_id: String, text: &str) {
|
||||
let version;
|
||||
{
|
||||
let version = {
|
||||
let mut active = self.active.lock();
|
||||
if let Some(document) = active.as_mut()
|
||||
&& document.uri == uri
|
||||
&& document.text == text
|
||||
{
|
||||
if document.language_id != language_id {
|
||||
document.language_id = language_id;
|
||||
match active.as_mut() {
|
||||
Some(document) if document.uri == uri => {
|
||||
if document.text == text {
|
||||
if document.language_id != language_id {
|
||||
document.language_id = language_id;
|
||||
}
|
||||
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() {
|
||||
if !server.serves(&language_id) {
|
||||
@@ -263,6 +493,9 @@ impl LspManager {
|
||||
for server in self.servers.lock().values() {
|
||||
server.did_close(uri);
|
||||
}
|
||||
// Сервер обычно присылает пустой список сам, но закрытая вкладка
|
||||
// не должна оставлять подчёркивания при повторном открытии файла.
|
||||
self.diagnostics.clear_uri(uri.as_str());
|
||||
}
|
||||
|
||||
/// Отправляет `textDocument/completion` в подходящий сервер.
|
||||
@@ -327,6 +560,46 @@ impl LspManager {
|
||||
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> {
|
||||
self.log.lock().iter().cloned().collect()
|
||||
@@ -340,17 +613,11 @@ impl LspManager {
|
||||
let servers = self.servers.lock();
|
||||
let mut fallback = None;
|
||||
for server in servers.values() {
|
||||
if server
|
||||
.config
|
||||
.languages
|
||||
.iter()
|
||||
.any(|language| language.eq_ignore_ascii_case(language_id))
|
||||
{
|
||||
if server.config.languages.is_empty() {
|
||||
fallback.get_or_insert_with(|| server.clone());
|
||||
} else if server.serves(language_id) {
|
||||
return Some(server.clone());
|
||||
}
|
||||
if server.config.languages.is_empty() && fallback.is_none() {
|
||||
fallback = Some(server.clone());
|
||||
}
|
||||
}
|
||||
fallback
|
||||
}
|
||||
@@ -369,10 +636,11 @@ struct LspServer {
|
||||
watcher: Mutex<Option<RecommendedWatcher>>,
|
||||
alive: AtomicBool,
|
||||
log: LogBuffer,
|
||||
diagnostics: Arc<DiagnosticsStore>,
|
||||
}
|
||||
|
||||
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);
|
||||
command
|
||||
.args(&config.args)
|
||||
@@ -418,6 +686,7 @@ impl LspServer {
|
||||
watcher: Mutex::new(None),
|
||||
alive: AtomicBool::new(true),
|
||||
log: log.clone(),
|
||||
diagnostics,
|
||||
});
|
||||
|
||||
spawn_stdout_reader(Arc::downgrade(&server), stdout);
|
||||
@@ -583,24 +852,25 @@ impl LspServer {
|
||||
fn handle_notification(&self, method: &str, message: &Value) {
|
||||
match method {
|
||||
"textDocument/publishDiagnostics" => {
|
||||
let uri = message
|
||||
.pointer("/params/uri")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("?");
|
||||
let count = message
|
||||
let Some(uri) = message.pointer("/params/uri").and_then(Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
let diagnostics = message
|
||||
.pointer("/params/diagnostics")
|
||||
.and_then(Value::as_array)
|
||||
.map_or(0, Vec::len);
|
||||
if count > 0 {
|
||||
self.log(format!("диагностика {uri}: {count}"));
|
||||
.map(|items| {
|
||||
items
|
||||
.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" => {
|
||||
if let Some(text) = message.pointer("/params/message").and_then(Value::as_str) {
|
||||
self.log(text.to_owned());
|
||||
}
|
||||
}
|
||||
"window/logTrace" => {
|
||||
"window/logMessage" | "window/showMessage" | "window/logTrace" => {
|
||||
if let Some(text) = message.pointer("/params/message").and_then(Value::as_str) {
|
||||
self.log(text.to_owned());
|
||||
}
|
||||
@@ -894,7 +1164,7 @@ fn initialize_params(config: &LspConfig) -> Value {
|
||||
params.insert(
|
||||
"clientInfo".into(),
|
||||
json!({
|
||||
"name": "gpui-editor",
|
||||
"name": "qode",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
}),
|
||||
);
|
||||
@@ -979,6 +1249,59 @@ pub fn path_to_uri(path: &Path) -> 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 документа по его имени во вкладке редактора.
|
||||
///
|
||||
/// Относительные имена считаются от текущего рабочего каталога — именно
|
||||
@@ -1068,6 +1391,36 @@ mod tests {
|
||||
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]
|
||||
fn positions_use_utf16_columns() {
|
||||
let text = "a\nβγ\n🙂x";
|
||||
|
||||
+2
-2
@@ -62,8 +62,8 @@ fn apply_command_line() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Параметры окна в стиле исходного приложения: размер 900×600, минимум
|
||||
/// 420×280 и собственный заголовок вместо системного.
|
||||
/// Параметры окна: размер 900×600, минимум 420×280 и собственный
|
||||
/// заголовок вместо системного.
|
||||
fn window_options() -> WindowOptions {
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(Bounds::new(
|
||||
|
||||
+53
-3
@@ -7,6 +7,10 @@
|
||||
//!
|
||||
//! Панель состоит из блоков. Блок — это независимый виджет, который сценарий
|
||||
//! создаёт через API панели (сейчас поддерживается [`PanelBlock::Tree`]).
|
||||
//!
|
||||
//! У блока есть заголовок. Боковые панели показывают все блоки подряд и
|
||||
//! заголовки не рисуют, а нижняя панель выводит блоки вкладками и показывает
|
||||
//! только активный блок.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
@@ -20,7 +24,21 @@ pub type PanelHandle = Rc<RefCell<PanelState>>;
|
||||
#[derive(Clone)]
|
||||
pub enum PanelBlock {
|
||||
/// Абстрактное дерево, наполняемое из 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,
|
||||
size: f32,
|
||||
blocks: Vec<PanelBlock>,
|
||||
/// Активный блок — вкладка нижней панели. Боковые панели рисуют все блоки
|
||||
/// подряд и это поле не используют.
|
||||
active: usize,
|
||||
}
|
||||
|
||||
impl PanelState {
|
||||
@@ -41,6 +62,7 @@ impl PanelState {
|
||||
visible: false,
|
||||
size: size.max(0.0),
|
||||
blocks: Vec::new(),
|
||||
active: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,10 +102,38 @@ impl PanelState {
|
||||
&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();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use mlua::{Lua, Table};
|
||||
|
||||
use crate::api::{AppApi, DirEntryInfo, TabInfo};
|
||||
use crate::constants::{STARTUP_SCRIPT, script_dir};
|
||||
use crate::lsp::{DiagnosticInfo, DocumentDiagnostics};
|
||||
|
||||
/// Вкладка превращается в Lua-таблицу `{ index = 0, title = "main.rs", icon = "file" }`.
|
||||
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 и состояние его глобального окружения.
|
||||
pub struct ScriptEngine {
|
||||
lua: Lua,
|
||||
|
||||
+400
-28
@@ -5,14 +5,16 @@
|
||||
//! фиксируют поведение самого приложения: документы, API, Lua и KeyMap.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui_kit::{Keystroke, Modifiers, TestAppContext, px};
|
||||
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
|
||||
|
||||
use crate::api::{AppApi, WindowCommand, drain_window_commands};
|
||||
use crate::app::MyApp;
|
||||
use crate::editor::{Language, Workspace};
|
||||
use crate::keymap::{KeyBinding, KeyMap, Modifiers as KeyModifiers};
|
||||
use crate::editor::{FocusAfterSync, Language, Workspace};
|
||||
use crate::keymap::{KeyBinding, KeyContext, KeyHandler, KeyMap, Modifiers as KeyModifiers};
|
||||
use crate::panel::PanelBlock;
|
||||
use crate::script::ScriptEngine;
|
||||
use crate::tree::TreeNode;
|
||||
@@ -55,17 +57,17 @@ fn lua_lsp_api_lists_no_servers_and_validates_config() {
|
||||
#[test]
|
||||
fn workspace_keeps_at_least_one_tab() {
|
||||
let mut state = Workspace::default();
|
||||
assert_eq!(state.documents().len(), 2);
|
||||
assert_eq!(state.active().name, "main.rs");
|
||||
assert_eq!(state.documents().len(), 1);
|
||||
assert_eq!(state.active().name, "untitled-1.txt");
|
||||
|
||||
// Открытие и закрытие новых вкладок.
|
||||
state.open_new();
|
||||
assert_eq!(state.documents().len(), 3);
|
||||
assert_eq!(state.active_index(), 2);
|
||||
|
||||
state.close(2);
|
||||
assert_eq!(state.documents().len(), 2);
|
||||
assert_eq!(state.active_index(), 1);
|
||||
|
||||
state.close(1);
|
||||
assert_eq!(state.active_index(), 0);
|
||||
|
||||
// Редактор не должен оставаться пустым.
|
||||
while state.documents().len() > 1 {
|
||||
state.close(0);
|
||||
@@ -83,7 +85,7 @@ fn closing_tab_before_active_keeps_the_same_document_active() {
|
||||
|
||||
state.close(0);
|
||||
assert_eq!(state.active().id, id);
|
||||
assert_eq!(state.active_index(), 1);
|
||||
assert_eq!(state.active_index(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -112,6 +114,8 @@ fn revision_changes_only_for_external_edits() {
|
||||
assert_ne!(state.revision(), revision);
|
||||
|
||||
// Переключение вкладки — тоже.
|
||||
state.open_new();
|
||||
state.set_active(0);
|
||||
let before_switch = state.revision();
|
||||
state.set_active(1);
|
||||
assert_ne!(state.revision(), before_switch);
|
||||
@@ -127,13 +131,15 @@ fn api_lists_tabs_and_closes_them_by_number() {
|
||||
tabs.iter()
|
||||
.map(|tab| (tab.index, tab.title.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(0, "main.rs"), (1, "notes.md")]
|
||||
vec![(0, "untitled-1.txt")]
|
||||
);
|
||||
|
||||
// Несуществующий номер ничего не закрывает.
|
||||
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_eq!(api.tabs().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:set_tab_icon(0, "book-open"))
|
||||
assert(app:tab_icon(0) == "book-open")
|
||||
app:open_tab()
|
||||
assert(app:tabs()[2].icon == "file")
|
||||
assert(not app:set_tab_icon(0, "no-such-icon"))
|
||||
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 path = std::env::temp_dir().join(format!(
|
||||
"gpui-editor-test-save-{}-{}.rs",
|
||||
"qode-save-{}-{}.rs",
|
||||
std::process::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 (api, _keymap) = install_api(workspace.clone());
|
||||
|
||||
api.open_tab();
|
||||
api.set_active_tab(0);
|
||||
assert_eq!(api.active_tab_index(), 0);
|
||||
|
||||
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 = 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();
|
||||
assert_eq!(
|
||||
rows.iter()
|
||||
@@ -414,6 +424,124 @@ fn lua_tree_block_appends_sorts_and_removes_nodes() {
|
||||
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]
|
||||
fn lua_list_dir_returns_sorted_entries_with_paths() {
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
@@ -455,7 +583,7 @@ fn lua_list_dir_returns_sorted_entries_with_paths() {
|
||||
/// Временный каталог с уникальным именем для Lua-сценариев.
|
||||
fn temp_script_dir(prefix: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"gpui-editor-test-{prefix}-{}-{}",
|
||||
"qode-{prefix}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -482,7 +610,7 @@ fn lua_script_can_include_files_and_control_the_app() {
|
||||
std::fs::write(
|
||||
&main,
|
||||
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()
|
||||
),
|
||||
)
|
||||
@@ -500,7 +628,7 @@ fn lua_script_can_include_files_and_control_the_app() {
|
||||
assert_eq!(
|
||||
state.active_index(),
|
||||
1,
|
||||
"app:next_tab() не переключил вкладку"
|
||||
"app:open_tab() не переключил вкладку"
|
||||
);
|
||||
assert_eq!(
|
||||
state.documents()[1].language,
|
||||
@@ -538,6 +666,55 @@ fn api_opens_file_from_disk_and_detects_language() {
|
||||
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]
|
||||
fn api_open_missing_file_creates_an_empty_buffer() {
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
@@ -647,8 +824,13 @@ fn lua_key_bindings_match_press_release_and_modifiers() {
|
||||
key_char: None,
|
||||
};
|
||||
let fire = |keystroke: &Keystroke, pressed: bool| {
|
||||
for callback in keymap.borrow().callbacks_for(keystroke, pressed) {
|
||||
callback.call::<()>(()).expect("Lua-обработчик");
|
||||
for handler in keymap
|
||||
.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);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn language_names_cover_all_supported_grammars() {
|
||||
for (name, expected) in [
|
||||
@@ -737,6 +994,19 @@ fn language_names_cover_all_supported_grammars() {
|
||||
assert_eq!(Language::Rust.name(), "rust");
|
||||
assert_eq!(Language::JavaScript.name(), "js");
|
||||
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]
|
||||
@@ -782,15 +1052,18 @@ fn keymap_bindings_are_parsed_and_replaced() {
|
||||
.load("return function() end")
|
||||
.eval::<mlua::Function>()
|
||||
.unwrap();
|
||||
keymap.bind(plain.clone(), first);
|
||||
keymap.bind(plain, second);
|
||||
keymap.set(KeyContext::Global, plain.clone(), KeyHandler::Lua(first));
|
||||
keymap.set(KeyContext::Global, plain, KeyHandler::Lua(second));
|
||||
|
||||
let stroke = Keystroke {
|
||||
modifiers: Modifiers::default(),
|
||||
key: "a".to_owned(),
|
||||
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]
|
||||
@@ -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) {
|
||||
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();
|
||||
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) {
|
||||
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 tree = api.left_panel_handle().borrow_mut().add_tree();
|
||||
let tree = api.left_panel_handle().borrow_mut().add_tree(None);
|
||||
tree.borrow_mut()
|
||||
.append(
|
||||
"",
|
||||
@@ -898,14 +1171,54 @@ fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
|
||||
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]
|
||||
fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) {
|
||||
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 tree = api.left_panel_handle().borrow_mut().add_tree();
|
||||
let tree = api.left_panel_handle().borrow_mut().add_tree(None);
|
||||
tree.borrow_mut()
|
||||
.append(
|
||||
"",
|
||||
@@ -953,6 +1266,62 @@ fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) {
|
||||
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]
|
||||
fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
|
||||
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| {
|
||||
app.update(cx, |this, cx| {
|
||||
this.editor_handle()
|
||||
.update(cx, |state, cx| state.focus(window, cx));
|
||||
this.editor_handle().update(cx, |state, cx| {
|
||||
state.set_value("abc", window, cx);
|
||||
state.set_selected_range(0..0, cx);
|
||||
state.focus(window, cx);
|
||||
});
|
||||
this.open_test_completion(cx);
|
||||
this.editor_handle().read(cx).cursor()
|
||||
})
|
||||
|
||||
+32
-34
@@ -92,6 +92,7 @@ pub enum TreeKeyResult {
|
||||
}
|
||||
|
||||
/// Состояние абстрактного дерева.
|
||||
#[derive(Default)]
|
||||
pub struct TreeState {
|
||||
nodes: HashMap<String, NodeRecord>,
|
||||
roots: Vec<String>,
|
||||
@@ -108,21 +109,6 @@ pub struct TreeState {
|
||||
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 {
|
||||
/// Создаёт пустое дерево.
|
||||
pub fn new() -> Self {
|
||||
@@ -153,11 +139,8 @@ impl TreeState {
|
||||
return false;
|
||||
}
|
||||
self.expand_ancestors(id);
|
||||
self.selected = Some(id.to_owned());
|
||||
self.focus_requested = true;
|
||||
if let Some(index) = self.rows().iter().position(|row| row.id == id) {
|
||||
self.scroll.scroll_to_item(index);
|
||||
}
|
||||
self.select_and_scroll(id);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -259,6 +242,16 @@ impl TreeState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Удаляет все узлы и сбрасывает выбор.
|
||||
///
|
||||
/// Обработчики раскрытия и клика сохраняются: сценарий может наполнять
|
||||
/// дерево заново, например при обновлении списка диагностики.
|
||||
pub fn clear(&mut self) {
|
||||
self.nodes.clear();
|
||||
self.roots.clear();
|
||||
self.selected = None;
|
||||
}
|
||||
|
||||
/// Удаляет узел и все его дочерние узлы рекурсивно.
|
||||
///
|
||||
/// Возвращает `false`, если узла с таким `id` нет.
|
||||
@@ -340,6 +333,14 @@ impl TreeState {
|
||||
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) {
|
||||
let rows = self.rows();
|
||||
@@ -357,8 +358,8 @@ impl TreeState {
|
||||
None if delta < 0 => rows.len() - 1,
|
||||
None => 0,
|
||||
};
|
||||
self.selected = Some(rows[next].id.clone());
|
||||
self.scroll.scroll_to_item(next);
|
||||
let id = rows[next].id.clone();
|
||||
self.select_and_scroll(&id);
|
||||
}
|
||||
|
||||
/// Enter: раскрывает папку или вызывает обработчик клика конечной ноды.
|
||||
@@ -383,10 +384,7 @@ impl TreeState {
|
||||
};
|
||||
}
|
||||
if let Some(parent) = self.nodes.get(&id).and_then(|record| record.parent.clone()) {
|
||||
self.selected = Some(parent.clone());
|
||||
if let Some(index) = self.rows().iter().position(|row| row.id == parent) {
|
||||
self.scroll.scroll_to_item(index);
|
||||
}
|
||||
self.select_and_scroll(&parent);
|
||||
}
|
||||
TreeKeyResult::Handled
|
||||
}
|
||||
@@ -399,20 +397,20 @@ impl TreeState {
|
||||
let Some(record) = self.nodes.get(&id) else {
|
||||
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) {
|
||||
Some(action) => TreeKeyResult::Action(action),
|
||||
None => TreeKeyResult::Handled,
|
||||
};
|
||||
}
|
||||
if record.node.expanded
|
||||
&& let Some(child) = self.sorted(&record.children).first().cloned()
|
||||
{
|
||||
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);
|
||||
}
|
||||
if let Some(child) = first_child {
|
||||
self.select_and_scroll(&child);
|
||||
}
|
||||
TreeKeyResult::Handled
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user