Files
qode/init.lua
T
2026-10-31 12:18:07 +03:00

519 lines
28 KiB
Lua
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.
-- Стартовый сценарий приложения qode.
--
-- Приложение выполняет этот файл при запуске. Каталог поиска задаётся
-- константой SCRIPT_DIR в src/constants.rs (по умолчанию — текущий каталог),
-- но его можно переопределить аргументом командной строки:
--
-- qode --config /path/to/project main.go
-- cargo run -- --config ./config
-- cargo run -- main.go
--
-- В каталоге --config должны лежать init.lua и подключаемые им модули.
-- Первый позиционный аргумент открывается после выполнения этого файла
-- через app.editor:open(path) — как `vim main.go` или `nano main.go`.
print("[lua] init.lua выполнен")
-- Объект app: окно, панели и глобальные привязки клавиш:
-- app:list_dir(path) -- {{ id = "src/main.rs", name = "main.rs",
-- -- is_dir = false }, ...}; пустой путь —
-- -- текущий каталог, id — путь от него
-- app:map_key(modes, keys, handler)
-- -- глобальный обработчик клавиши/сочетания:
-- -- функция, имя команды или nil (удалить)
-- -- modes — строка режимов:
-- -- "" — все режимы
-- -- "-" — только режим по умолчанию
-- -- "-iv" — режим по умолчанию, i и v
-- -- keys — сочетание:
-- -- "a" нажатие A
-- -- "1" нажатие 1
-- -- "!a" отпускание A
-- -- "<S-c>" C при зажатом Shift
-- -- "<C-z>" Z при зажатом Ctrl
-- -- "<A-!r>" отпускание R при зажатом Alt
-- -- "<D-s>" 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, поэтому
-- -- <C-q> работает и на русской раскладке
-- 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:set_mode(mode) -- режим глобальных привязок (один символ)
-- app:mode() -- текущий режим глобальных привязок
-- app:close() -- закрыть окно
-- app:toggle_maximize()
-- app:set_maximized(true/false)
-- app:minimize()
-- app:is_maximized()
-- app:open_input(options) -- плавающее поле ввода чуть выше центра;
-- -- options = { title = "...",
-- -- placeholder = "...",
-- -- callback = function(text) ... end };
-- -- Enter подтверждает и вызывает callback,
-- -- Esc или клик вне окна закрывают без него;
-- -- пока окно открыто, привязки клавиш молчат
--
-- Редактор app.editor (номера вкладок — с нуля):
-- app.editor:tabs() -- {{ index = 0, title = "main.rs",
-- -- icon = "file" }, ...}
-- app.editor:active_tab() -- индекс активной вкладки
-- app.editor:set_active_tab(index) -- true, если вкладка существует
-- app.editor:next_tab() -- переключить на следующую, вернуть индекс
-- app.editor:previous_tab() -- переключить на предыдущую, вернуть индекс
-- app.editor:open_tab() -- открыть пустую вкладку, вернуть индекс
-- app.editor:open(path) -- открыть файл с диска, вернуть индекс вкладки;
-- -- если файла нет — пустой буфер с таким именем
-- app.editor:open(path, {}) -- открыть и перевести фокус в редактор,
-- -- курсор оставить на месте
-- app.editor:open(path, {m, n}) -- открыть, перевести фокус и поставить
-- -- курсор на строку m и столбец n (с нуля)
-- app.editor:close_tab(index) -- true, если вкладка закрыта
-- app.editor:set_tab_icon(i, name) -- иконка вкладки из каталога Lucide
-- -- (file, file-code, book-open, ...);
-- -- false — нет вкладки или иконки
-- app.editor:clear_tab_icon(i) -- вернуть иконку по умолчанию (file)
-- app.editor:tab_icon(i) -- имя иконки вкладки или nil
-- app.editor:set_gutter_icon(line, name[, color])
-- -- иконка на поле слева от редактора
-- -- (активная вкладка, строка с нуля);
-- -- color — #RGB/#RGBA/#RRGGBB/#RRGGBBAA;
-- -- false — неизвестная иконка или цвет
-- app.editor:gutter_icon(line) -- имя иконки gutter или nil
-- app.editor:gutter_color(line) -- цвет иконки gutter или nil
-- app.editor:clear_gutter_icon(line) -- убрать иконку со строки
-- app.editor:clear_gutter() -- убрать все иконки активной вкладки
-- app.editor:active_text() -- текст активной вкладки
-- app.editor:set_active_text(text) -- заменить текст активной вкладки
-- app.editor:set_language(i, "rust") -- включить подсветку
-- -- (rust|go|js|json|python) для вкладки i;
-- -- false — нет вкладки/языка
-- app.editor:language(i) -- язык подсветки вкладки или nil
-- app.editor:clear_language(i) -- выключить подсветку для вкладки
-- app.editor:save() -- сохранить активную вкладку в её файл
-- app.editor:map_key(modes, keys, handler)
-- -- привязка, которая работает дополнительно
-- -- к глобальной, когда фокус в редакторе;
-- -- синтаксис modes/keys/handler как у app:map_key
-- app.editor:set_mode(mode) -- режим привязок редактора
-- app.editor:mode() -- текущий режим привязок редактора
--
-- LSP-серверы (запуск целиком настраивается этим сценарием):
-- app.editor: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.editor:lsp_servers() -- { "gopls", ... }
-- app.editor:stop_lsp("gopls") -- true, если сервер был запущен
-- app.editor:restart_lsp("gopls") -- перезапуск с прежними настройками
-- app.editor: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 принимает строку, таблицу или boolean:
-- watcher = "**/*.go"
-- watcher = { pattern = "**/*.go", ignore = { "vendor/**" } }
-- watcher = true
--
-- Автодополнение включается само при вводе идентификатора; Ctrl/Cmd+Space
-- открывает список вручную. Пока список открыт, ↑/↓ и PageUp/PageDown
-- перемещают выделение, Enter или Tab вставляют элемент, Esc закрывает
-- список; горизонтальные стрелки закрывают его и двигают курсор.
-- Документация выбранного элемента показывается второй всплывающей панелью
-- справа от списка. Примеры запуска:
-- app.editor:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" },
-- languages = { "go" }, watcher = "**/*.go" })
-- app.editor:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer",
-- languages = { "rust" }, watcher = "**/*.rs" })
--
-- Панели окна (по умолчанию закрыты, размер меняется перетаскиванием):
-- app.left_panel:open()
-- app.left_panel:close()
-- app.left_panel:toggle() -- вернуть true, если панель открыта
-- app.left_panel:is_open()
-- app.right_panel:open() -- те же методы, что у левой панели
-- app.right_panel:close()
-- app.right_panel:toggle()
-- app.right_panel:is_open()
-- app.bottom_panel:open() -- те же методы, что у левой панели
-- app.bottom_panel:close()
-- app.bottom_panel:toggle()
-- app.bottom_panel:is_open()
--
-- Верхняя панель редактора (полоса между вкладками и редактором).
-- Элементы левой области идут от левого края вправо, правой — от правого
-- края влево. id нужен, чтобы скрыть или снова показать элемент.
-- app.toolbar:add_label(text, icon, background, id, side)
-- app.toolbar:add_button(text, icon, background, id, side, fn)
-- -- fn() вызывается при нажатии;
-- -- background — "#RRGGBB", "#RRGGBBAA" или nil;
-- -- icon — имя иконки Lucide или nil;
-- -- side — "left" или "right"
-- app.toolbar:hide(id) -- true, если элемент найден
-- app.toolbar:show(id) -- снова показать скрытый элемент
-- app.toolbar:clear() -- удалить все элементы
--
-- Блоки панелей. Сценарий сам решает, что показывает блок: редактор умеет
-- рисовать только абстрактное дерево.
-- 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) — клик по конечной ноде
-- tree:selected() -- id выбранной строки или nil
-- tree:focus(id) -- раскрыть родителей, выбрать и дать фокус
-- tree:map_key(modes, keys, handler)
-- -- привязка, которая работает дополнительно
-- -- к глобальной, когда фокус на этом дереве
-- tree:set_mode(mode) -- режим привязок блока (один символ)
-- tree:mode() -- текущий режим привязок блока
--
-- Методы добавляются в src/api.rs: всё, что есть в impl AppApi или
-- impl EditorApi, помеченном #[mlua::userdata_impl], автоматически доступно
-- из Lua как app:method(...) или app.editor:method(...).
--
-- Другие Lua-файлы можно подключать из каталога сценариев:
-- include("tools.lua") -- выполнить файл сразу
-- require("tools") -- выполнить один раз (как обычный Lua-модуль)
--
-- Подсветка синтаксиса по умолчанию выключена. Включить её можно так:
-- app.editor:set_language(0, "rust")
-- gopls запускается автоматически при старте редактора.
-- Сервер обслуживает только документы с languageId "go" и получает изменения
-- всех .go-файлов проекта (кроме vendor/) через файловый вотчер.
local gopls_ok, gopls_err = pcall(function()
app.editor:run_lsp({
name = "gopls",
cmd = "gopls",
args = { "serve" },
-- Рабочий каталог gopls. --config меняет только каталог init.lua,
-- поэтому проект должен быть текущим каталогом запуска.
root = ".",
languages = { "go" },
watcher = {
pattern = "**/*.go",
ignore = { "vendor/**" },
},
-- gopls читает эти параметры из initializationOptions.
initialization_options = {
usePlaceholders = true,
completeUnimported = true,
},
})
end)
if not gopls_ok then
print("[lsp] gopls не запущен: " .. tostring(gopls_err))
end
-- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно.
-- app:map_key("", "<C-s>", function() app.editor:save() end)
-- app:map_key("", "<!Esc>", function() app:close() end)
-- Убрать встроенную привязку можно так:
-- app:map_key("", "<C-s>", nil)
-- app:map_completion_key("<C-n>", "completion_next")
-- app:map_completion_key("<C-p>", "completion_previous")
-- Файловое дерево проекта.
--
-- Редактор рисует абстрактное дерево; смысл узлов (файлы, папки, что угодно)
-- задаёт этот сценарий. Здесь корень — текущий каталог, а каждая папка
-- дозагружается при первом раскрытии.
local project_tree = app.left_panel:add_tree()
local function load_dir(tree, 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
project_tree:on_toggle(function(expanded, id, loaded)
-- loaded == false только при первом раскрытии: позже дети уже в дереве.
if expanded and not loaded then
load_dir(project_tree, id)
end
end)
project_tree:on_click(function(id)
-- Пустая таблица: фокус уходит в редактор, курсор остаётся на месте.
app.editor:open(id, {})
end)
app.left_panel:open()
load_dir(project_tree, "")
-- Открытие/закрытие панели с деревом. При открытии возвращаем фокус на
-- последнюю выбранную строку, чтобы можно было сразу продолжить навигацию.
app:map_key("", "<C-b>", function()
local opened = app.left_panel:toggle()
if opened then
local selected = project_tree:selected()
if selected then
project_tree:focus(selected)
end
end
end)
app:map_key("", "<C-q>", function() app:close() end)
-- Плавающее поле ввода. Пока оно открыто, сочетания клавиш приложения
-- заблокированы; Enter вызывает callback, Esc и клик вне окна — отменяют.
app:map_key("", "<C-g>", function()
app:open_input({
-- title = "Открыть файл",
placeholder = "src/main.rs",
callback = function(text) app.editor:open(text, {}) end,
})
end)
app:map_key("", "<C-e>", "toggle_left_panel")
app:map_key("", "<C-t>", "toggle_bottom_panel")
-- Дерево диагностики в нижней панели.
--
-- Обработчик один на все 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
-- Строки активной вкладки, занятые иконками диагностики. Gutter действует
-- только на активную вкладку, поэтому запоминаем поставленные строки, чтобы
-- при следующем обновлении снять устаревшие иконки и не трогать остальные.
local diagnostic_gutter = {}
local function active_tab_title()
local active = app.editor:active_tab()
for _, tab in ipairs(app.editor:tabs()) do
if tab.index == active then
return tab.title
end
end
return nil
end
-- Заголовок вкладки — путь, которым открыли файл (обычно относительный), а LSP
-- присылает абсолютный путь. Сверяем полный путь с хвостом заголовка.
local function path_matches_title(path, title)
if not path or not title then
return false
end
path = path:gsub("\\", "/")
title = title:gsub("\\", "/")
if path == title then
return true
end
local suffix = "/" .. title
return #path > #suffix and path:sub(-#suffix) == suffix
end
app.editor:on_lsp_diagnostics(100, function(servers)
diagnostics_tree:clear()
-- Иконки прошлого обновления активной вкладки могли устареть.
local title = active_tab_title()
if title and diagnostic_gutter[title] then
for line in pairs(diagnostic_gutter[title]) do
app.editor:clear_gutter_icon(line)
end
diagnostic_gutter[title] = nil
end
-- Сначала собираем диагностику по файлам: одинаковые проблемы от разных
-- серверов попадают в одну запись.
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
-- Подсветка и gutter в редакторе относятся к активной вкладке, поэтому
-- иконки ставим только для её файла.
local active = title ~= nil and path_matches_title(path, title)
local gutter_lines = active and {} or nil
local errors = {}
for index, key in ipairs(group_order) do
local group = groups[key]
local icon = severity_icons[group.severity] or "circle-alert"
if active then
app.editor:set_gutter_icon(group.line, icon)
gutter_lines[group.line] = true
end
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 = icon,
}
end
if active and next(gutter_lines) then
local stored = diagnostic_gutter[title]
if not stored then
stored = {}
diagnostic_gutter[title] = stored
end
for line in pairs(gutter_lines) do
stored[line] = true
end
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.editor:open(path, { tonumber(line), tonumber(character) })
else
-- Клик по файлу: фокус в редактор, курсор не переставляем.
app.editor:open(id, {})
end
end)
app.bottom_panel:open()
-- Верхняя панель: пример лейблов и кнопок.
-- app.toolbar:add_label("Готово", nil, nil, "status", "left")
app.toolbar:add_label("UTF-8", nil, "#efefe6", "encoding", "right")
app.toolbar:add_button("Запустить", "play", nil, "run", "right", function()
print("[toolbar] кнопка «Запустить» нажата")
end)
app.editor:set_gutter_icon(2, "play", "#efefe6")
app.editor:set_gutter_icon(90, "play")