# 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 | | `""` | 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("", 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+↑` продолжает выделять текст. ### Окно | Метод | Описание | | --- | --- | | `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.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("", 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("", function() app:close() end) app:map_key("", "toggle_left_panel") app:map_key("", "toggle_bottom_panel") app:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer", languages = { "rust" }, watcher = "**/*.rs", }) ```