-- Стартовый сценарий приложения 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 -- -- "" C при зажатом Shift -- -- "" Z при зажатом Ctrl -- -- "" отпускание R при зажатом Alt -- -- "" 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, поэтому -- -- работает и на русской раскладке -- 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("", function() app:save() end) -- app:map_key("", function() app:close() end) -- Убрать встроенную привязку можно так: -- app:map_key("", nil) -- app:map_completion_key("", "completion_next") -- app:map_completion_key("", "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("", 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("", function() app:close() end) app:map_key("", "toggle_left_panel") app:map_key("", "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)