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

28 KiB
Raw Blame History

Lua API

Приложение выполняет init.lua при запуске и предоставляет сценариям объект app для управления окном, вкладками, панелями, файловой системой, подсветкой и LSP-серверами.

Сам редактор не содержит демонстрационных документов: при запуске открыта одна пустая вкладка untitled-1.txt. Всё остальное — файлы, деревья, серверы — настраивается сценарием.

Запуск и каталог сценариев

Каталог поиска init.lua задаётся константой SCRIPT_DIR в src/constants.rs (по умолчанию — текущий каталог). Его можно переопределить аргументом командной строки:

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, если вкладки нет
app:open("src/main.rs")          -- открыть, не трогая фокус
app:open("src/main.rs", {})      -- фокус в редактор, курсор на месте
app:open("src/main.rs", {4, 2})  -- фокус в редактор, курсор на строку 4, столбец 2

Строка и столбец нумеруются с нуля, как в диагностике LSP; столбец считается в UTF-16.

Иконки вкладок

Слева от названия вкладки показывается иконка; по умолчанию это file. Имя — название иконки Lucide, регистр не важен. Например:

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) Выключить подсветку для вкладки
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) и использовать как идентификатор узла дерева. Элементы уже отсортированы по имени без учёта регистра.

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 → команды слоя автодополнения.
-- Переопределить сохранение своей функцией.
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-сценарием.

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:

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 — пауза тишины: если сообщения приходят чаще, вызовы пропускаются, а обработчик получает последний снимок после того, как обновления прекратились. Если зарегистрировать обработчик, когда диагностика уже накоплена, он сработает сразу после этой паузы.

app:on_lsp_diagnostics(1000, function(servers)
  for _, entry in ipairs(servers) do
    print(entry.server, entry.path, #entry.diagnostics)
  end
end)

servers — массив записей по одной на пару «документ, сервер»:

{
  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. Остановка сервера удаляет его сообщения из следующих снимков.

Примеры запуска:

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 с защитой от ошибки запуска:

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 — обработчик нажатия; вызывается без аргументов.
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() Открыта ли панель
app.left_panel:open()
app.right_panel:toggle()
app.bottom_panel:close()

Если у правой панели нет блоков, она показывает состояние LSP-серверов. Блоки нижней панели показываются вкладками, как вкладки редактора; боковые панели рисуют все свои блоки подряд и заголовки не показывают.

Блоки панелей

Панель может содержать блоки. Блок создаётся через API панели и живёт вместе с ней; сейчас поддерживается один вид блока — абстрактное дерево.

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) Раскрыть всех родителей, выбрать узел и передать фокус дереву

Узел — таблица:

{
  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 для конечной ноды. Выбор сохраняется, пока панель закрыта; при повторном открытии панели фокус возвращается на выбранную строку.

Пример отложенной загрузки дерева:

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)

Пример дерева диагностики в нижней панели. На первом уровне — файлы с полным путём, на втором — сообщения с иконкой уровня. Сообщения с одинаковыми началом и концом диапазона объединяются в одну запись; клик открывает файл на нужной позиции.

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

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",
})