diff --git a/AGENTS.md b/AGENTS.md index afb6e6c..1053fea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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>` 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>` / `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`, ``, ``, ``, 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). +Если какую-то команду выполнить нельзя, это нужно явно указать в ответе. diff --git a/Cargo.lock b/Cargo.lock index d1af83f..ebd8dbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 6fa3329..c63ff3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "gpui-editor-test" +name = "qode" version = "0.1.0" edition = "2024" diff --git a/DOCS.md b/DOCS.md index a83138f..982a639 100644 --- a/DOCS.md +++ b/DOCS.md @@ -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 | `""` | C при зажатом Shift | | `""` | Z при зажатом Ctrl | | `""` | отпускание R при зажатом Alt | +| `""` | S при зажатом Cmd на macOS или Super на Linux | Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux и Windows буквы берутся по аппаратному keycode, поэтому `` работает и на русской раскладке. +Встроенные команды обычного режима: + +| Имя | Действие | +| --- | --- | +| `"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("", function() app:save() end) -app:map_key("", function() app:close() end) -app:map_key("", function() app:close() end) -app:map_key("", function() app.right_panel:toggle() end) -app:map_key("", function() app.bottom_panel:toggle() end) + +-- Убрать встроенную привязку. +app:map_key("", nil) + +-- Назначить встроенную команду на другую клавишу. +app:map_key("", "next_tab") +app:map_key("", "toggle_left_panel") + +-- Навигация по списку автодополнения на Ctrl+N / Ctrl+P. +app:map_completion_key("", "completion_next") +app:map_completion_key("", "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("", 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("", function() app:close() end) -app:map_key("", function() app.right_panel:toggle() end) -app:map_key("", function() app.bottom_panel:toggle() end) +app:map_key("", "toggle_left_panel") +app:map_key("", "toggle_bottom_panel") -app:open_tab() +app:run_lsp({ + name = "rust-analyzer", + cmd = "rust-analyzer", + languages = { "rust" }, + watcher = "**/*.rs", +}) ``` diff --git a/init.lua b/init.lua index a8067ea..2fb12a4 100644 --- a/init.lua +++ b/init.lua @@ -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 -- -- "" C при зажатом Shift -- -- "" Z при зажатом Ctrl -- -- "" отпускание R при зажатом Alt +-- -- "" 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, поэтому -- -- работает и на русской раскладке +-- 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("", function() app:save() end) -- app:map_key("", function() app:close() end) +-- Убрать встроенную привязку можно так: +-- app:map_key("", nil) +-- app:map_completion_key("", "completion_next") +-- app:map_completion_key("", "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("", function() app:close() end) -app:map_key("", function() app.left_panel:toggle() end) -app:map_key("", function() app.bottom_panel:toggle() end) +app:map_key("", "toggle_left_panel") +app:map_key("", "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() diff --git a/src/api.rs b/src/api.rs index 1cc0358..c4f96f9 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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, + #[lua(skip)] + diagnostics_handler: Rc>>, } /// 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) -> 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` — нажатие клавиши без модификаторов; - /// * ``, ``, `` — нажатие с Shift, Ctrl или Alt; + /// * ``, ``, ``, `` — нажатие с Shift, Ctrl, Alt или + /// платформенным модификатором (Cmd/Super); /// * `!a` — отпускание клавиши; /// * `` — отпускание клавиши при зажатом 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) -> std::io::Result { + 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, + position: Option<(u32, u32)>, + ) -> std::io::Result { + self.open_impl(path, position, true) + } + + #[lua(skip)] + fn open_impl( + &self, + path: impl AsRef, + position: Option<(u32, u32)>, + focus_editor: bool, + ) -> std::io::Result { 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 { - self.open(&path) - .map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}"))) + fn lua_open(&self, path: String, position: mlua::Value) -> mlua::Result { + 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) -> std::io::Result> { 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 { - 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) { + 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 { + self.diagnostics_handler + .borrow() + .as_ref() + .map(|handler| handler.delay) + } + + /// Вызывает Lua-обработчик диагностики со свежим снимком. + /// + /// Возвращает `false`, если обработчик не зарегистрирован. + #[lua(skip)] + pub fn emit_diagnostics(&self, report: Vec) -> 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 { + 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::>(1)?; + let column = table.get::>(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 { .get("name") .map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый name".to_owned()))?; - let command = ["cmd", "command", "path", "binary"] - .iter() - .find_map(|key| table.get::(*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::("args") { Ok(args) => args @@ -777,32 +962,22 @@ fn parse_lsp_config(table: &mlua::Table) -> mlua::Result { Err(_) => Vec::new(), }; - let root = table - .get::("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::("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::("languages") { Ok(value) => lua_strings(value)?, Err(_) => Vec::new(), }; - let initialization_options = ["initialization_options", "init_options"] - .iter() - .find_map(|key| match table.get::(*key) { - Ok(mlua::Value::Nil) | Err(_) => None, - Ok(value) => Some(lua_to_json(value)), - }) - .transpose()?; + let initialization_options = match table.get::("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 { } fn parse_watcher(table: &mlua::Table) -> mlua::Result> { - let value = ["watcher", "file_watcher", "watch"].iter().find_map(|key| { - match table.get::(*key) { - Ok(mlua::Value::Nil) | Err(_) => None, - Ok(value) => Some(value), - } - }); - - let Some(value) = value else { - return Ok(None); + let value = match table.get::("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> { })), mlua::Value::Boolean(false) | mlua::Value::Nil => Ok(None), mlua::Value::Table(config) => { - let patterns = ["pattern", "patterns"] - .iter() - .find_map(|key| match config.get::(*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::(*key) { - Ok(mlua::Value::Nil) | Err(_) => None, - Ok(value) => Some(lua_strings(value)), - }) - .transpose()? - .unwrap_or_default(); + let patterns = match config.get::("pattern") { + Ok(mlua::Value::Nil) | Err(_) => Vec::new(), + Ok(value) => lua_strings(value)?, + }; + let ignore = match config.get::("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> { } } +/// Разбирает обработчик, переданный в `app:map_key` / `app:map_completion_key`. +/// +/// `nil` означает «удалить привязку». +fn parse_key_handler( + context: KeyContext, + handler: mlua::Value, +) -> mlua::Result> { + 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::>(), + KeyContext::Completion => CompletionCommand::ALL + .iter() + .map(|command| command.name()) + .collect::>(), + }; + 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> { let mut parsed = Vec::new(); diff --git a/src/app.rs b/src/app.rs index c4db22c..77a6cff 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, + revision: u64, +} + +impl Future for DiagnosticsChanged { + type Output = u64; + + fn poll(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll { + 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, + /// Коллекция подчёркиваний диагностики в редакторе. + /// + /// Рисуется независимо от подсветки синтаксиса, поэтому ошибки + /// видны и в файлах без tree-sitter грамматики. + diagnostics_decorations: TextDecorationCollection, } impl MyApp { @@ -191,6 +243,7 @@ impl MyApp { pub fn new(window: &mut Window, cx: &mut Context) -> 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) { 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.apply_diagnostics(cx); + } + + /// Байтовые диапазоны подчёркиваний диагностики (доступ для тестов). + #[cfg(test)] + pub fn diagnostic_ranges(&self, cx: &App) -> Vec> { + 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) { + 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::>(); + + 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, + start: usize, + query: String, + selected: usize, + cx: &mut Context, + ) { 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) { - 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) { - 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, ) -> 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, + ) -> 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, ) -> 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, + ) -> 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) { 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::>(), @@ -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> { + 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>, 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>, + 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 diff --git a/src/completion.rs b/src/completion.rs index edf054f..51a77cf 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -281,6 +281,56 @@ pub fn completion_kind_name(kind: Option) -> &'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) -> 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) -> &'static str { pub fn completion_icon(kind: Option) -> 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, 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, } } diff --git a/src/constants.rs b/src/constants.rs index ba2d6f6..c042f9b 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -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; diff --git a/src/editor.rs b/src/editor.rs index 7d0e02b..cc63031 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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), +} + /// Открытые документы и активная вкладка. /// /// Инвариант: `documents` никогда не пуст — редактор всегда показывает хотя бы @@ -207,6 +201,8 @@ pub struct Workspace { documents: Vec, active: usize, next_untitled: u32, + /// Действие с фокусом, ожидающее ближайшей синхронизации вида. + focus_after_sync: Option, /// Номер «поколения» состояния. /// /// Вид перечитывает активный документ в редактор только когда ревизия @@ -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 { + 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 сохраняет вкладку в файл с её названием -"#; diff --git a/src/keymap.rs b/src/keymap.rs index 186e837..a01c824 100644 --- a/src/keymap.rs +++ b/src/keymap.rs @@ -1,12 +1,17 @@ -//! Привязки клавиш к Lua-обработчикам. +//! Привязки клавиш к Lua-обработчикам и встроенным командам. //! //! Запись привязки немного напоминает nvim: //! //! * `a`, `1` — нажатие клавиши без модификаторов; -//! * ``, ``, `` — нажатие с Shift, Ctrl или Alt; +//! * ``, ``, ``, `` — нажатие с Shift, Ctrl, Alt или +//! платформенным модификатором (Cmd на macOS, Super на Linux); //! * `!a` — отпускание клавиши; //! * `` — отпускание клавиши при зажатом 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 { - /// Все три модификатора должны совпасть точно: `` не сработает, - /// если вместе с Ctrl зажат ещё и Shift. + /// Все модификаторы должны совпасть точно: `` не сработает, если + /// вместе с 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 { + 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 { + 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 { + pub fn handlers_for( + &self, + context: KeyContext, + keystroke: &Keystroke, + pressed: bool, + ) -> Vec { 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 ["", ""] { + self.set( + KeyContext::Global, + parse_default(spec), + KeyHandler::App(AppCommand::Save), + ); + } + for spec in ["", ""] { + 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(""), + 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 { // А привязка вида `` — совпадает. assert!(parse("").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(""); + assert!(keymap.unbind(KeyContext::Global, &binding)); + assert!( + keymap + .handlers_for(KeyContext::Global, &stroke("s", ctrl), true) + .is_empty() + ); + } } diff --git a/src/lsp.rs b/src/lsp.rs index 9d7bca5..29c7e0e 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -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, + /// Сообщения диагностики в порядке, присланном сервером. + pub diagnostics: Vec, +} + +/// Одно сообщение диагностики. +/// +/// Позиции, как и в 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, + /// Имя уровня: `error`, `warning`, `information` или `hint`. + pub severity_name: Option, + /// Текст сообщения. + pub message: String, + /// Источник сообщения (обычно имя сервера). + pub source: Option, + /// Код ошибки, если сервер его прислал. + pub code: Option, + /// Теги: `unnecessary`, `deprecated`. + pub tags: Vec, +} + +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>>>; type LogBuffer = Arc>>; +/// Диагностика, опубликованная серверами, сгруппированная по документу. +/// +/// Внутренняя карта хранит сообщения по имени сервера: так остановка одного +/// сервера убирает только его сообщения, а не всю диагностику документа. +struct DiagnosticsStore { + by_uri: Mutex>>>, + /// Номер поколения: растёт при каждом изменении. + revision: AtomicU64, + /// Задача вида, которую надо разбудить после изменения. + /// + /// Диагностика приходит в фоновом потоке, поэтому вместо блокирующего + /// ожидания канала UI подписывается на `Waker`. + waker: Mutex>, +} + +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) { + { + 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 { + self.by_uri + .lock() + .get(uri) + .map(|by_server| by_server.values().flatten().cloned().collect::>()) + .unwrap_or_default() + } + + /// Снимок всей диагностики: по одной записи на пару «документ, сервер». + /// + /// Записи сортируются по пути и имени сервера, чтобы Lua-сценарий получал + /// стабильный порядок независимо от порядка `HashMap`. + fn snapshot(&self) -> Vec { + 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>>, active: Mutex>, log: LogBuffer, + diagnostics: Arc, } 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 { + self.diagnostics.diagnostics(uri) + } + + /// Снимок всей диагностики всех серверов. + /// + /// Используется обработчиком `app:on_lsp_diagnostics(...)`: он получает + /// файлы, серверы и сообщения целиком, а не только для активной вкладки. + pub fn diagnostics_snapshot(&self) -> Vec { + 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) { + self.diagnostics.replace("test", uri, diagnostics); + } + /// Последние строки журнала серверов. pub fn log_lines(&self) -> Vec { 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>, alive: AtomicBool, log: LogBuffer, + diagnostics: Arc, } impl LspServer { - fn spawn(config: LspConfig) -> Result, String> { + fn spawn(config: LspConfig, diagnostics: Arc) -> Result, 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::>() + }) + .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 { + 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 { + 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"; diff --git a/src/main.rs b/src/main.rs index aea4043..a85c60e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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( diff --git a/src/panel.rs b/src/panel.rs index cfb88b0..6191ac1 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -7,6 +7,10 @@ //! //! Панель состоит из блоков. Блок — это независимый виджет, который сценарий //! создаёт через API панели (сейчас поддерживается [`PanelBlock::Tree`]). +//! +//! У блока есть заголовок. Боковые панели показывают все блоки подряд и +//! заголовки не рисуют, а нижняя панель выводит блоки вкладками и показывает +//! только активный блок. use std::cell::RefCell; use std::rc::Rc; @@ -20,7 +24,21 @@ pub type PanelHandle = Rc>; #[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, + /// Активный блок — вкладка нижней панели. Боковые панели рисуют все блоки + /// подряд и это поле не используют. + 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) -> 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 } } diff --git a/src/script.rs b/src/script.rs index c0cf133..e80145e 100644 --- a/src/script.rs +++ b/src/script.rs @@ -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 { + 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 { + 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, diff --git a/src/tests.rs b/src/tests.rs index 110902f..30010a6 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -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![(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("", "next_tab") + app:map_completion_key("", "completion_next") + + local ok, err = pcall(function() + app:map_key("", "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(\"\", nil)\napp:map_completion_key(\"\", 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::() .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() }) diff --git a/src/tree.rs b/src/tree.rs index 482af58..282d4d9 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -92,6 +92,7 @@ pub enum TreeKeyResult { } /// Состояние абстрактного дерева. +#[derive(Default)] pub struct TreeState { nodes: HashMap, roots: Vec, @@ -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 }