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

406 lines
21 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:open(path) — как `vim main.go` или `nano main.go`.
print("[lua] init.lua выполнен")
-- Доступные методы объекта app (номера вкладок — с нуля):
-- 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) -- открыть файл с диска, вернуть индекс вкладки;
-- -- если файла нет — пустой буфер с таким именем
-- app:open(path, {}) -- открыть и перевести фокус в редактор,
-- -- курсор оставить на месте
-- app:open(path, {m, n}) -- открыть, перевести фокус и поставить курсор
-- -- на строку m и столбец n (с нуля)
-- app:list_dir(path) -- {{ id = "src/main.rs", name = "main.rs",
-- -- is_dir = false }, ...}; пустой путь —
-- -- текущий каталог, id — путь от него
-- app:close_tab(index) -- true, если вкладка закрыта
-- app:set_tab_icon(i, name) -- иконка вкладки из каталога Lucide
-- -- (file, file-code, book-open, ...);
-- -- false — нет вкладки или иконки
-- app:clear_tab_icon(i) -- вернуть иконку по умолчанию (file)
-- app:tab_icon(i) -- имя иконки вкладки или nil
-- app:active_text() -- текст активной вкладки
-- app:set_active_text(text) -- заменить текст активной вкладки
-- app:set_language(i, "rust") -- включить подсветку (rust|go|js|json|python)
-- -- для вкладки i; false — нет вкладки/языка
-- app:language(i) -- язык подсветки вкладки или nil
-- app:clear_language(i) -- выключить подсветку для вкладки
-- app:save() -- сохранить активную вкладку в её файл
-- app:map_key(keys, handler) -- обработчик клавиши/сочетания:
-- -- функция, имя команды или nil (удалить)
-- -- "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:close() -- закрыть окно
-- app:toggle_maximize()
-- app:set_maximized(true/false)
-- app:minimize()
-- app:is_maximized()
--
-- LSP-серверы (запуск целиком настраивается этим сценарием):
-- 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:lsp_servers() -- { "gopls", ... }
-- app:stop_lsp("gopls") -- true, если сервер был запущен
-- app:restart_lsp("gopls") -- перезапуск с прежними настройками
-- app: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: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" })
--
-- Панели окна (по умолчанию закрыты, размер меняется перетаскиванием):
-- 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) -- раскрыть родителей, выбрать и дать фокус
--
-- Методы добавляются в src/api.rs: всё, что есть в impl AppApi, помеченном
-- #[mlua::userdata_impl], автоматически доступно из Lua.
--
-- Другие Lua-файлы можно подключать из каталога сценариев:
-- include("tools.lua") -- выполнить файл сразу
-- require("tools") -- выполнить один раз (как обычный Lua-модуль)
--
-- Подсветка синтаксиса по умолчанию выключена. Включить её можно так:
-- app:set_language(0, "rust")
-- gopls запускается автоматически при старте редактора.
-- Сервер обслуживает только документы с languageId "go" и получает изменения
-- всех .go-файлов проекта (кроме vendor/) через файловый вотчер.
local gopls_ok, gopls_err = pcall(function()
app: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: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: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)
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
app:on_lsp_diagnostics(100, function(servers)
diagnostics_tree:clear()
-- Сначала собираем диагностику по файлам: одинаковые проблемы от разных
-- серверов попадают в одну запись.
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
local errors = {}
for index, key in ipairs(group_order) do
local group = groups[key]
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 = severity_icons[group.severity] or "circle-alert",
}
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:open(path, { tonumber(line), tonumber(character) })
else
-- Клик по файлу: фокус в редактор, курсор не переставляем.
app: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)