Files
qode/DOCS.md
T
2026-10-31 01:50:35 +03:00

601 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Lua API
Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект
`app` для управления окном, вкладками, панелями, файловой системой, подсветкой
и LSP-серверами.
Сам редактор не содержит демонстрационных документов: при запуске открыта
одна пустая вкладка `untitled-1.txt`. Всё остальное — файлы, деревья, серверы —
настраивается сценарием.
## Запуск и каталог сценариев
Каталог поиска `init.lua` задаётся константой `SCRIPT_DIR` в `src/constants.rs`
(по умолчанию — текущий каталог). Его можно переопределить аргументом командной строки:
```sh
qode --config /path/to/project main.go
cargo run -- --config ./config
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` — единственный надёжный способ сослаться на вкладку.
### Вкладки
| Метод | Описание |
| --- | --- |
| `app:tabs()` | Список вкладок: `{{ index = 0, title = "main.rs", icon = "file" }, ...}` |
| `app:active_tab()` | Индекс активной вкладки |
| `app:set_active_tab(index)` | Сделать вкладку активной; `true`, если вкладка существует |
| `app:next_tab()` | Переключить на следующую вкладку, вернуть индекс |
| `app:previous_tab()` | Переключить на предыдущую вкладку, вернуть индекс |
| `app:open_tab()` | Открыть пустую вкладку, вернуть индекс |
| `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`.
Имя — название иконки [Lucide](https://lucide.dev/icons/), регистр не важен.
Например:
```lua
app:set_tab_icon(0, "file-code")
app:set_tab_icon(1, "book-open")
app:clear_tab_icon(1) -- снова "file"
```
Подчёркивания и пробелы считаются дефисом, а `icons/` и `.svg` можно
не писать: `file_code`, `file-code` и `icons/file-code.svg` равнозначны.
Неизвестное имя не назначается — метод вернёт `false`.
### Текст
| Метод | Описание |
| --- | --- |
| `app:active_text()` | Текст активной вкладки |
| `app:set_active_text(text)` | Заменить текст активной вкладки |
### Подсветка синтаксиса
Подсветка по умолчанию выключена. Поддерживаемые языки: `rust`, `go`, `js`, `json`,
`python`.
| Метод | Описание |
| --- | --- |
| `app:set_language(index, "rust")` | Включить подсветку для вкладки; `false`, если вкладки нет или язык не поддерживается |
| `app:language(index)` | Язык подсветки вкладки или `nil` |
| `app:clear_language(index)` | Выключить подсветку для вкладки |
```lua
app:set_language(0, "rust")
```
### Сохранение
| Метод | Описание |
| --- | --- |
| `app:save()` | Сохранить активную вкладку в её файл |
### Файловая система
| Метод | Описание |
| --- | --- |
| `app:list_dir(path)` | Содержимое каталога: `{{ id = "src/main.rs", name = "main.rs", is_dir = false }, ...}` |
Путь отсчитывается от текущего рабочего каталога процесса. Пустая строка или
`"."` означают сам текущий каталог. Поле `id` — путь элемента относительно
текущего каталога: его можно передать в `app:open(id)` и использовать как
идентификатор узла дерева. Элементы уже отсортированы по имени без учёта
регистра.
```lua
for _, entry in ipairs(app:list_dir("src")) do
print(entry.name, entry.is_dir)
end
```
### Привязка клавиш
Все привязки проходят через один `KeyMap`. Встроенные сочетания можно заменить
или удалить: вторым аргументом принимается Lua-функция, имя встроенной команды
или `nil`.
`app:map_key(keys, handler)` назначает привязку для обычного режима,
`app:map_completion_key(keys, handler)` — для списка автодополнения.
Запись клавиши в духе nvim:
| Запись | Значение |
| --- | --- |
| `"a"` | нажатие A |
| `"1"` | нажатие 1 |
| `"!a"` | отпускание A |
| `"<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("<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+↑` продолжает выделять текст.
### Окно
| Метод | Описание |
| --- | --- |
| `app:close()` | Закрыть окно |
| `app:toggle_maximize()` | Развернуть окно / вернуть прежний размер |
| `app:set_maximized(true/false)` | Развернуть (`true`) или восстановить (`false`) окно |
| `app:minimize()` | Свернуть окно |
| `app:is_maximized()` | Развёрнуто ли окно прямо сейчас |
## LSP-серверы
Запуск серверов целиком настраивается Lua-сценарием.
```lua
app:run_lsp({
name = "gopls", -- условное имя для stop/restart
cmd = "gopls", -- путь к бинарнику или имя в PATH
args = { "serve" }, -- аргументы процесса
env = { GOFLAGS = "-mod=mod" }, -- переменные окружения
languages = { "go" }, -- какие languageId обслуживает
watcher = "**/*.go", -- шаблон файлового вотчера
root = ".", -- корень workspace (по умолчанию cwd)
initialization_options = {}, -- initializationOptions сервера
})
```
| Метод | Описание |
| --- | --- |
| `app:run_lsp(config)` | Запустить сервер по описанию |
| `app:lsp_servers()` | Имена запущенных серверов: `{ "gopls", ... }` |
| `app:stop_lsp("gopls")` | Остановить сервер; `true`, если сервер был запущен |
| `app:restart_lsp("gopls")` | Перезапустить сервер с прежними настройками |
| `app:on_lsp_diagnostics(delay_ms, fn)` | Обработчик диагностики всех серверов; `nil` вместо `fn` удаляет его |
`cmd` обязателен. Если `root` не задан или относителен, он отсчитывается от
текущего рабочего каталога процесса. Поле `watcher` принимает строку, таблицу
или boolean:
```lua
watcher = "**/*.go"
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } }
watcher = true -- все файлы, игнорируя только стандартные каталоги сборки
```
Автодополнение включается само при вводе идентификатора; `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`. Остановка сервера удаляет его сообщения из следующих снимков.
Примеры запуска:
```lua
app:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" },
languages = { "go" }, watcher = "**/*.go" })
app:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer",
languages = { "rust" }, watcher = "**/*.rs" })
```
Пример из `init.lua` с защитой от ошибки запуска:
```lua
local gopls_ok, gopls_err = pcall(function()
app:run_lsp({
name = "gopls",
cmd = "gopls",
args = { "serve" },
root = ".",
languages = { "go" },
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } },
initialization_options = {
usePlaceholders = true,
completeUnimported = true,
},
})
end)
if not gopls_ok then
print("[lsp] gopls не запущен: " .. tostring(gopls_err))
end
```
> `--config` меняет только каталог `init.lua`, поэтому `root = "."` указывает
> на текущий каталог запуска — проект должен быть текущим рабочим каталогом.
## Верхняя панель редактора
Полоса между вкладками и редактором. У панели две области: элементы левой
выстраиваются от левого края вправо, элементы правой — от правого края влево.
| Метод | Описание |
| --- | --- |
| `app.toolbar:add_label(text, icon, background, id, side)` | Добавить лейбл |
| `app.toolbar:add_button(text, icon, background, id, side, fn)` | Добавить кнопку |
| `app.toolbar:hide(id)` | Скрыть элемент; `false`, если элемента с таким `id` нет |
| `app.toolbar:show(id)` | Снова показать скрытый элемент |
| `app.toolbar:clear()` | Удалить все элементы панели |
- `icon` — имя иконки Lucide (`play`, `settings`, `file`, ...) или `nil`.
- `background` — `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA` или `nil` (прозрачный
фон). Цвет текста подбирается автоматически: тёмный на светлом фоне, светлый
на тёмном.
- `id` — идентификатор элемента для `hide` и `show`. Повторное добавление с тем
же `id` заменяет элемент, а не создаёт дубликат.
- `side` — `"left"` или `"right"`.
- `fn` — обработчик нажатия; вызывается без аргументов.
```lua
app.toolbar:add_label("Готово", nil, nil, "status", "left")
app.toolbar:add_label("UTF-8", nil, "#3f3f46", "encoding", "right")
app.toolbar:add_button("Запустить", "play", nil, "run", "right", function()
print("Запуск")
end)
app.toolbar:hide("run")
app.toolbar:show("run")
```
## Панели окна
По умолчанию панели закрыты, размер меняется перетаскиванием.
Для `app.left_panel`, `app.right_panel` и `app.bottom_panel` доступны одинаковые методы:
| Метод | Описание |
| --- | --- |
| `app.left_panel:open()` | Открыть панель |
| `app.left_panel:close()` | Закрыть панель |
| `app.left_panel:toggle()` | Переключить панель; вернуть `true`, если панель открыта |
| `app.left_panel:is_open()` | Открыта ли панель |
```lua
app.left_panel:open()
app.right_panel:toggle()
app.bottom_panel:close()
```
Если у правой панели нет блоков, она показывает состояние LSP-серверов.
Блоки нижней панели показываются вкладками, как вкладки редактора; боковые
панели рисуют все свои блоки подряд и заголовки не показывают.
### Блоки панелей
Панель может содержать блоки. Блок создаётся через API панели и живёт вместе
с ней; сейчас поддерживается один вид блока — абстрактное дерево.
```lua
local tree = app.left_panel:add_tree()
local diagnostics = app.bottom_panel:add_tree("Диагностика")
```
Необязательный аргумент `add_tree` — заголовок вкладки. В нижней панели он
показывается на вкладке; в боковых панелях заголовок сохраняется, но не
рисуется. Без заголовка используется «Дерево». Новый блок становится
активной вкладкой нижней панели.
Редактор отвечает только за отрисовку дерева. Что означает каждый узел,
решает сценарий: это могут быть файлы проекта, задачи, настройки — что угодно.
Файловое дерево проекта в `init.lua` собирается из `app:list_dir`, но сам
редактор о файловой системе в блоке ничего не знает.
| Метод | Описание |
| --- | --- |
| `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)` |
| `tree:selected()` | Идентификатор выбранной строки или `nil` |
| `tree:focus(id)` | Раскрыть всех родителей, выбрать узел и передать фокус дереву |
Узел — таблица:
```lua
{
id = "src/main.rs", -- строковый идентификатор (путь файла)
label = "main.rs", -- отображаемый текст
icon = "file-code", -- имя иконки Lucide (необязательно)
expandable = false, -- раскрываемая ли нода (папка)
expanded = false, -- раскрыта ли она сейчас
}
```
`append` добавляет узлы, а не заменяет дерево. Повторная передача того же `id`
обновляет описание узла, но не создаёт копию. `clear` удаляет всё дерево;
обработчики `on_toggle` и `on_click` продолжают действовать, поэтому дерево
удобно строить заново на каждое обновление данных.
Дети каждого уровня сортируются по `label` без учёта регистра. Сначала идут
раскрываемые узлы (`expandable = true`), затем конечные; внутри каждой группы —
по алфавиту.
В обработчик `on_toggle` приходит `loaded = true`, если дочерние узлы уже
добавляли в эту ноду; при первом раскрытии — `false`.
Клик по строке выбирает её. Выбранную строку можно перемещать стрелками
`↑`/`↓`; `↑`/`↓` двигают выбор, `→` раскрывает папку или переходит к первому
ребёнку, `←` сворачивает папку или выбирает родителя, `Enter` раскрывает папку
или вызывает `on_click` для конечной ноды. Выбор сохраняется, пока панель
закрыта; при повторном открытии панели фокус возвращается на выбранную строку.
Пример отложенной загрузки дерева:
```lua
local tree = app.left_panel:add_tree()
local function load_dir(path)
local nodes = {}
for _, entry in ipairs(app:list_dir(path)) do
nodes[#nodes + 1] = {
id = entry.id,
label = entry.name,
icon = entry.is_dir and "folder" or "file",
expandable = entry.is_dir,
expanded = false,
}
end
tree:append(nodes, path)
end
tree:on_toggle(function(expanded, id, loaded)
if expanded and not loaded then
load_dir(id)
end
end)
tree:on_click(function(id)
-- Фокус в редактор, курсор остаётся на прежнем месте.
app:open(id, {})
end)
app.left_panel:open()
load_dir("")
-- При открытии панели вернуть фокус на последнюю выбранную строку.
app:map_key("<C-b>", function()
if app.left_panel:toggle() then
local selected = tree:selected()
if selected then
tree:focus(selected)
end
end
end)
```
Пример дерева диагностики в нижней панели. На первом уровне — файлы с
полным путём, на втором — сообщения с иконкой уровня. Сообщения с одинаковыми
началом и концом диапазона объединяются в одну запись; клик открывает файл
на нужной позиции.
```lua
local diagnostics = app.bottom_panel:add_tree("Диагностика")
local icons = {
error = "circle-x",
warning = "triangle-alert",
information = "circle-alert",
hint = "lightbulb",
}
app:on_lsp_diagnostics(1000, function(servers)
diagnostics:clear()
local files = {}
local order = {}
for _, entry in ipairs(servers) do
local path = entry.path or entry.uri
if not files[path] then
files[path] = {}
order[#order + 1] = path
end
for _, item in ipairs(entry.diagnostics) do
table.insert(files[path], item)
end
end
for _, path in ipairs(order) do
local groups = {}
local keys = {}
for _, item in ipairs(files[path]) do
local key = string.format("%d:%d:%d:%d", item.line, item.character,
item.end_line, item.end_character)
if not groups[key] then
groups[key] = { item = item, messages = {}, seen = {} }
keys[#keys + 1] = key
end
local group = groups[key]
if not group.seen[item.message] then
group.seen[item.message] = true
group.messages[#group.messages + 1] = item.message
end
end
diagnostics:append({
{ id = path, label = path, icon = "file",
expandable = true, expanded = true },
}, "")
local errors = {}
for index, key in ipairs(keys) do
local group = groups[key]
local item = group.item
local severity = item.severity_name or "error"
errors[#errors + 1] = {
id = string.format("diag:%s:%d:%d:%d", path, item.line,
item.character, index),
label = string.format("%d:%d %s", item.line + 1,
item.character + 1,
table.concat(group.messages, "; ")),
icon = icons[severity] or "circle-alert",
}
end
diagnostics:append(errors, path)
end
end)
diagnostics:on_click(function(id)
local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$")
if path then
app:open(path, { tonumber(line), tonumber(character) })
else
app:open(id, {})
end
end)
app.bottom_panel:open()
```
## Подключение других Lua-файлов
Файлы подключаются из каталога сценариев:
| Способ | Описание |
| --- | --- |
| `include("tools.lua")` | Выполнить файл сразу |
| `require("tools")` | Выполнить один раз (как обычный Lua-модуль) |
## Добавление новых методов
Методы добавляются в `src/api.rs`: всё, что есть в `impl AppApi`, помеченном
`#[mlua::userdata_impl]`, автоматически доступно из Lua.
Чтобы не экспортировать метод, его помечают `#[lua(skip)]`.
## Полный пример `init.lua`
```lua
print("[qode] init.lua выполнен")
app:map_key("<C-q>", function() app:close() end)
app:map_key("<C-e>", "toggle_left_panel")
app:map_key("<C-t>", "toggle_bottom_panel")
app:run_lsp({
name = "rust-analyzer",
cmd = "rust-analyzer",
languages = { "rust" },
watcher = "**/*.rs",
})
```