refactoring

This commit is contained in:
2026-10-31 01:06:21 +03:00
parent 27385a3edb
commit 19e05e7ed0
17 changed files with 2673 additions and 671 deletions
+244 -30
View File
@@ -1,8 +1,12 @@
# Lua API
Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект `app`
для управления окном, вкладками, панелями, файловой системой, подсветкой и
LSP-серверами.
Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект
`app` для управления окном, вкладками, панелями, файловой системой, подсветкой
и LSP-серверами.
Сам редактор не содержит демонстрационных документов: при запуске открыта
одна пустая вкладка `untitled-1.txt`. Всё остальное — файлы, деревья, серверы —
настраивается сценарием.
## Запуск и каталог сценариев
@@ -10,7 +14,7 @@ LSP-серверами.
(по умолчанию — текущий каталог). Его можно переопределить аргументом командной строки:
```sh
gpui-editor-test --config /path/to/project main.go
qode --config /path/to/project main.go
cargo run -- --config ./config
cargo run -- main.go
```
@@ -18,10 +22,14 @@ cargo run -- main.go
- В каталоге `--config` должны лежать `init.lua` и подключаемые им модули.
- Первый позиционный аргумент открывается после выполнения `init.lua`
через `app:open(path)` — как `vim main.go` или `nano main.go`.
- `--config` меняет только каталог сценариев. Рабочий каталог процесса, а
вместе с ним пути `app:open`, `app:save` и `root` у LSP не меняются.
## Объект `app`
Номера вкладок во всех методах нумеруются с нуля.
Номера вкладок во всех методах нумеруются с нуля. Массив, который возвращает
`app:tabs()`, остаётся обычной Lua-таблицей с индексами от 1, поэтому поле
`index` — единственный надёжный способ сослаться на вкладку.
### Вкладки
@@ -33,12 +41,21 @@ cargo run -- main.go
| `app:next_tab()` | Переключить на следующую вкладку, вернуть индекс |
| `app:previous_tab()` | Переключить на предыдущую вкладку, вернуть индекс |
| `app:open_tab()` | Открыть пустую вкладку, вернуть индекс |
| `app:open(path)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем |
| `app:open(path, position)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем. Без второго аргумента фокус не меняется; `{}` — перевести фокус в редактор, оставив курсор; `{line, column}` — ещё и поставить курсор (с нуля) |
| `app:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта |
| `app:set_tab_icon(index, name)` | Назначить иконку вкладке; `false`, если вкладки нет или иконка неизвестна |
| `app:clear_tab_icon(index)` | Вернуть иконку по умолчанию (`file`) |
| `app:tab_icon(index)` | Имя иконки вкладки или `nil`, если вкладки нет |
```lua
app:open("src/main.rs") -- открыть, не трогая фокус
app:open("src/main.rs", {}) -- фокус в редактор, курсор на месте
app:open("src/main.rs", {4, 2}) -- фокус в редактор, курсор на строку 4, столбец 2
```
Строка и столбец нумеруются с нуля, как в диагностике LSP; столбец считается
в UTF-16.
### Иконки вкладок
Слева от названия вкладки показывается иконка; по умолчанию это `file`.
@@ -46,8 +63,8 @@ cargo run -- main.go
Например:
```lua
app:set_tab_icon(0, "file-code") -- main.rs
app:set_tab_icon(1, "book-open") -- notes.md
app:set_tab_icon(0, "file-code")
app:set_tab_icon(1, "book-open")
app:clear_tab_icon(1) -- снова "file"
```
@@ -103,8 +120,14 @@ end
### Привязка клавиш
`app:map_key(keys, fn)` назначает обработчик на нажатие или отпускание клавиши
(синтаксис в духе nvim):
Все привязки проходят через один `KeyMap`. Встроенные сочетания можно заменить
или удалить: вторым аргументом принимается Lua-функция, имя встроенной команды
или `nil`.
`app:map_key(keys, handler)` назначает привязку для обычного режима,
`app:map_completion_key(keys, handler)` — для списка автодополнения.
Запись клавиши в духе nvim:
| Запись | Значение |
| --- | --- |
@@ -114,19 +137,63 @@ end
| `"<S-c>"` | C при зажатом Shift |
| `"<C-z>"` | Z при зажатом Ctrl |
| `"<A-!r>"` | отпускание R при зажатом Alt |
| `"<D-s>"` | S при зажатом Cmd на macOS или Super на Linux |
Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux
и Windows буквы берутся по аппаратному keycode, поэтому `<C-q>` работает
и на русской раскладке.
Встроенные команды обычного режима:
| Имя | Действие |
| --- | --- |
| `"save"` | Сохранить активную вкладку |
| `"next_tab"` | Следующая вкладка |
| `"previous_tab"` | Предыдущая вкладка |
| `"open_tab"` | Новая пустая вкладка |
| `"close_tab"` | Закрыть активную вкладку |
| `"toggle_left_panel"` | Переключить левую панель |
| `"toggle_right_panel"` | Переключить правую панель |
| `"toggle_bottom_panel"` | Переключить нижнюю панель |
Встроенные команды списка автодополнения:
| Имя | Действие |
| --- | --- |
| `"completion_trigger"` | Открыть список |
| `"completion_next"` / `"completion_previous"` | Сдвинуть выделение |
| `"completion_page_down"` / `"completion_page_up"` | Пролистать список |
| `"completion_accept"` | Вставить выбранный элемент |
| `"completion_cancel"` | Закрыть список |
| `"completion_dismiss"` | Закрыть список и передать клавишу редактору |
При запуске уже назначены:
- `Ctrl/Cmd+S` → `save`;
- `Ctrl/Cmd+Space` → `completion_trigger`;
- `Enter`/`Tab`, `Esc`, `↑`/`↓`, `PageUp`/`PageDown`, `←`/`→`, `Home`/`End`
→ команды слоя автодополнения.
```lua
-- Переопределить сохранение своей функцией.
app:map_key("<C-s>", function() app:save() end)
app:map_key("<!Esc>", function() app:close() end)
app:map_key("<C-q>", function() app:close() end)
app:map_key("<C-e>", function() app.right_panel:toggle() end)
app:map_key("<C-t>", function() app.bottom_panel:toggle() end)
-- Убрать встроенную привязку.
app:map_key("<C-s>", nil)
-- Назначить встроенную команду на другую клавишу.
app:map_key("<C-n>", "next_tab")
app:map_key("<C-b>", "toggle_left_panel")
-- Навигация по списку автодополнения на Ctrl+N / Ctrl+P.
app:map_completion_key("<C-n>", "completion_next")
app:map_completion_key("<C-p>", "completion_previous")
```
Пока список открыт, его слой проверяется раньше действий редактора. Сочетания
с модификаторами и горизонтальные стрелки закрывают список, даже если для них
нет своей привязки, — так `Shift+↑` продолжает выделять текст.
### Окно
| Метод | Описание |
@@ -160,20 +227,68 @@ app:run_lsp({
| `app:lsp_servers()` | Имена запущенных серверов: `{ "gopls", ... }` |
| `app:stop_lsp("gopls")` | Остановить сервер; `true`, если сервер был запущен |
| `app:restart_lsp("gopls")` | Перезапустить сервер с прежними настройками |
| `app:on_lsp_diagnostics(delay_ms, fn)` | Обработчик диагностики всех серверов; `nil` вместо `fn` удаляет его |
Поле `watcher` принимает строку или таблицу:
`cmd` обязателен. Если `root` не задан или относителен, он отсчитывается от
текущего рабочего каталога процесса. Поле `watcher` принимает строку, таблицу
или boolean:
```lua
watcher = "**/*.go"
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } }
watcher = true -- все файлы, игнорируя только стандартные каталоги сборки
```
Автодополнение включается само при вводе идентификатора; `Ctrl+Space` открывает
список вручную. Пока список открыт, `↑`/`↓` и `PageUp`/`PageDown` перемещают
выделение (курсор при этом стоит на месте), `Enter` или `Tab` вставляют выбранный
элемент, `Esc` закрывает список. Горизонтальные стрелки, `Home` и `End` закрывают
список и работают с курсором как обычно. Документация выбранного элемента
показывается второй всплывающей панелью справа от списка.
Автодополнение включается само при вводе идентификатора; `Ctrl/Cmd+Space`
открывает список вручную. Поведение клавиш описано в разделе «Привязка
клавиш» и может быть переопределено через `app:map_completion_key`.
Документация выбранного элемента показывается второй всплывающей панелью
справа от списка.
Диагностику, которую сервер присылает после правок, редактор подчёркивает
волнистой линией: ошибки — красной, предупреждения и остальные сообщения —
жёлтой. Подчёркивание работает и при выключенной подсветке синтаксиса.
### Обработчик диагностики
`app:on_lsp_diagnostics(delay_ms, fn)` регистрирует один обработчик на все
LSP-серверы. `delay_ms` — пауза тишины: если сообщения приходят чаще, вызовы
пропускаются, а обработчик получает последний снимок после того, как
обновления прекратились. Если зарегистрировать обработчик, когда диагностика
уже накоплена, он сработает сразу после этой паузы.
```lua
app:on_lsp_diagnostics(1000, function(servers)
for _, entry in ipairs(servers) do
print(entry.server, entry.path, #entry.diagnostics)
end
end)
```
`servers` — массив записей по одной на пару «документ, сервер»:
```lua
{
server = "gopls", -- имя сервера, приславшего диагностику
uri = "file:///project/main.go", -- URI документа
path = "/project/main.go", -- путь файла; nil для не-file URI
diagnostics = {
{
line = 0, character = 2, -- начало (с нуля, как в LSP)
end_line = 0, end_character = 8,
severity = 1, -- 1 ошибка, 2 предупреждение, 3 info, 4 hint
severity_name = "error", -- error | warning | information | hint
message = "undefined: foo",
source = "gopls",
code = "UndeclaredName",
tags = {}, -- unnecessary, deprecated
},
},
}
```
Позиции нумеруются с нуля; для отображения прибавляйте единицу к `line` и
`character`. Остановка сервера удаляет его сообщения из следующих снимков.
Примеры запуска:
@@ -228,6 +343,10 @@ app.right_panel:toggle()
app.bottom_panel:close()
```
Если у правой панели нет блоков, она показывает состояние LSP-серверов.
Блоки нижней панели показываются вкладками, как вкладки редактора; боковые
панели рисуют все свои блоки подряд и заголовки не показывают.
### Блоки панелей
Панель может содержать блоки. Блок создаётся через API панели и живёт вместе
@@ -235,8 +354,14 @@ app.bottom_panel:close()
```lua
local tree = app.left_panel:add_tree()
local diagnostics = app.bottom_panel:add_tree("Диагностика")
```
Необязательный аргумент `add_tree` — заголовок вкладки. В нижней панели он
показывается на вкладке; в боковых панелях заголовок сохраняется, но не
рисуется. Без заголовка используется «Дерево». Новый блок становится
активной вкладкой нижней панели.
Редактор отвечает только за отрисовку дерева. Что означает каждый узел,
решает сценарий: это могут быть файлы проекта, задачи, настройки — что угодно.
Файловое дерево проекта в `init.lua` собирается из `app:list_dir`, но сам
@@ -245,6 +370,7 @@ local tree = app.left_panel:add_tree()
| Метод | Описание |
| --- | --- |
| `tree:append(nodes, parent)` | Добавить узлы к `parent`; `parent = ""` — корень |
| `tree:clear()` | Удалить все узлы и сбросить выбор |
| `tree:remove(id)` | Удалить узел и всех потомков рекурсивно; `false`, если узла нет |
| `tree:on_toggle(fn)` | Обработчик раскрытия: `fn(expanded, id, loaded)` |
| `tree:on_click(fn)` | Обработчик клика по конечной ноде: `fn(id)` |
@@ -264,7 +390,9 @@ local tree = app.left_panel:add_tree()
```
`append` добавляет узлы, а не заменяет дерево. Повторная передача того же `id`
обновляет описание узла, но не создаёт копию.
обновляет описание узла, но не создаёт копию. `clear` удаляет всё дерево;
обработчики `on_toggle` и `on_click` продолжают действовать, поэтому дерево
удобно строить заново на каждое обновление данных.
Дети каждого уровня сортируются по `label` без учёта регистра. Сначала идут
раскрываемые узлы (`expandable = true`), затем конечные; внутри каждой группы —
@@ -305,7 +433,8 @@ tree:on_toggle(function(expanded, id, loaded)
end)
tree:on_click(function(id)
app:open(id)
-- Фокус в редактор, курсор остаётся на прежнем месте.
app:open(id, {})
end)
app.left_panel:open()
@@ -322,6 +451,89 @@ app:map_key("<C-b>", function()
end)
```
Пример дерева диагностики в нижней панели. На первом уровне — файлы с
полным путём, на втором — сообщения с иконкой уровня. Сообщения с одинаковыми
началом и концом диапазона объединяются в одну запись; клик открывает файл
на нужной позиции.
```lua
local diagnostics = app.bottom_panel:add_tree("Диагностика")
local icons = {
error = "circle-x",
warning = "triangle-alert",
information = "circle-alert",
hint = "lightbulb",
}
app:on_lsp_diagnostics(1000, function(servers)
diagnostics:clear()
local files = {}
local order = {}
for _, entry in ipairs(servers) do
local path = entry.path or entry.uri
if not files[path] then
files[path] = {}
order[#order + 1] = path
end
for _, item in ipairs(entry.diagnostics) do
table.insert(files[path], item)
end
end
for _, path in ipairs(order) do
local groups = {}
local keys = {}
for _, item in ipairs(files[path]) do
local key = string.format("%d:%d:%d:%d", item.line, item.character,
item.end_line, item.end_character)
if not groups[key] then
groups[key] = { item = item, messages = {}, seen = {} }
keys[#keys + 1] = key
end
local group = groups[key]
if not group.seen[item.message] then
group.seen[item.message] = true
group.messages[#group.messages + 1] = item.message
end
end
diagnostics:append({
{ id = path, label = path, icon = "file",
expandable = true, expanded = true },
}, "")
local errors = {}
for index, key in ipairs(keys) do
local group = groups[key]
local item = group.item
local severity = item.severity_name or "error"
errors[#errors + 1] = {
id = string.format("diag:%s:%d:%d:%d", path, item.line,
item.character, index),
label = string.format("%d:%d %s", item.line + 1,
item.character + 1,
table.concat(group.messages, "; ")),
icon = icons[severity] or "circle-alert",
}
end
diagnostics:append(errors, path)
end
end)
diagnostics:on_click(function(id)
local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$")
if path then
app:open(path, { tonumber(line), tonumber(character) })
else
app:open(id, {})
end
end)
app.bottom_panel:open()
```
## Подключение других Lua-файлов
Файлы подключаются из каталога сценариев:
@@ -340,14 +552,16 @@ end)
## Полный пример `init.lua`
```lua
print("[lua] init.lua выполнен")
app:set_language(0, "rust")
app:next_tab()
print("[qode] init.lua выполнен")
app:map_key("<C-q>", function() app:close() end)
app:map_key("<C-e>", function() app.right_panel:toggle() end)
app:map_key("<C-t>", function() app.bottom_panel:toggle() end)
app:map_key("<C-e>", "toggle_left_panel")
app:map_key("<C-t>", "toggle_bottom_panel")
app:open_tab()
app:run_lsp({
name = "rust-analyzer",
cmd = "rust-analyzer",
languages = { "rust" },
watcher = "**/*.rs",
})
```