-- Стартовый сценарий приложения qode. -- -- Приложение выполняет этот файл при запуске. Каталог поиска задаётся -- константой SCRIPT_DIR в src/constants.rs (по умолчанию — текущий каталог), -- но его можно переопределить аргументом командной строки: -- -- qode --config /path/to/project main.go -- cargo run -- --config ./config -- cargo run -- main.go -- -- В каталоге --config должны лежать init.lua и подключаемые им модули. -- Первый позиционный аргумент открывается после выполнения этого файла -- через api.editor:open(path) — как `vim main.go` или `nano main.go`. print("[lua] init.lua выполнен") -- qode — конструктор IDE: Rust даёт универсальные примитивы (окно, вкладки, -- редактор, панели, дерево, LSP, файловая система, клавиатура), а вся -- бизнес-логика IDE живёт в Lua. Подробная документация — в docs/. -- -- Объект api разбит на группы: api.editor (редактор), api.panels (панели), -- api.path (файловая система), api.window (окно и поле ввода) и -- api.keymap (глобальные привязки). Все методы api.path принимают путь -- строкой "foo/bar" или массивом сегментов { "foo", "bar" }. -- -- api.path:list(path) -- {{ id = "src/main.rs", name = "main.rs", -- -- is_dir = false }, ...}; пустой путь — -- -- текущий каталог, id — путь от него -- api.path:mkdir(path) -- создать каталоги, вернуть созданный путь -- api.path:remove(path) -- удалить файл или каталог рекурсивно; -- -- false, если пути не было -- api.path:touch(path) -- создать пустой файл, вернуть путь -- api.path:read(path) -- содержимое файла строкой -- api.path:write(path, text, append) -- -- записать текст; append = true — дописать, -- -- false/nil — перезаписать с нуля -- api.keymap:map_key(modes, keys, handler) -- -- глобальный обработчик клавиши/сочетания: -- -- функция, имя команды или nil (удалить) -- -- modes — строка режимов: -- -- "" — все режимы -- -- "-" — только режим по умолчанию -- -- "-iv" — режим по умолчанию, i и v -- -- keys — сочетание: -- -- "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, поэтому -- -- работает и на русской раскладке -- api.keymap:map_completion_key(keys, handler) -- -- то же для списка автодополнения; команды: -- -- completion_trigger, completion_next, -- -- completion_previous, completion_page_up, -- -- completion_page_down, completion_accept, -- -- completion_cancel, completion_dismiss -- api.keymap:set_mode(mode) -- режим глобальных привязок (один символ) -- api.keymap:mode() -- текущий режим глобальных привязок -- api.window:close() -- закрыть окно -- api.window:toggle_maximize() -- api.window:set_maximized(true/false) -- api.window:minimize() -- api.window:is_maximized() -- api.window:open_input(options) -- плавающее поле ввода чуть выше центра; -- -- options = { title = "...", -- -- placeholder = "...", -- -- callback = function(text) ... end }; -- -- Enter подтверждает и вызывает callback, -- -- Esc или клик вне окна закрывают без него; -- -- пока окно открыто, привязки клавиш молчат -- -- Редактор api.editor (номера вкладок — с нуля): -- api.editor:tabs() -- {{ index = 0, title = "main.rs", -- -- icon = "file" }, ...} -- api.editor:active_tab() -- индекс активной вкладки -- api.editor:set_active_tab(index) -- true, если вкладка существует -- api.editor:next_tab() -- переключить на следующую, вернуть индекс -- api.editor:previous_tab() -- переключить на предыдущую, вернуть индекс -- api.editor:open_tab() -- открыть пустую вкладку, вернуть индекс -- api.editor:open(path) -- открыть файл с диска, вернуть индекс вкладки; -- -- если файла нет — пустой буфер с таким именем -- api.editor:open(path, {}) -- открыть и перевести фокус в редактор, -- -- курсор оставить на месте -- api.editor:open(path, {m, n}) -- открыть, перевести фокус и поставить -- -- курсор на строку m и столбец n (с нуля) -- api.editor:close_tab(index) -- true, если вкладка закрыта -- api.editor:set_tab_icon(i, name) -- иконка вкладки из каталога Lucide -- -- (file, file-code, book-open, ...); -- -- false — нет вкладки или иконки -- api.editor:clear_tab_icon(i) -- вернуть иконку по умолчанию (file) -- api.editor:tab_icon(i) -- имя иконки вкладки или nil -- api.editor:set_gutter_icon(line, name, kind[, options]) -- -- иконка на поле слева от редактора -- -- (активная вкладка, строка с нуля); -- -- kind — тип иконки, на строке может быть -- -- по одной иконке каждого типа; -- -- options = { color = "#RRGGBB", -- -- hover_color = "#RRGGBB" }; -- -- false — неизвестная иконка или цвет -- api.editor:gutter_icon(line, kind) -- имя иконки gutter или nil -- api.editor:gutter_color(line, kind) -- цвет иконки gutter или nil -- api.editor:gutter_hover_color(line, kind) -- цвет при наведении или nil -- api.editor:clear_gutter_icon(line, kind) -- убрать иконку этого типа -- api.editor:clear_gutter_line(line) -- убрать все иконки строки -- api.editor:clear_gutter_kind(kind) -- убрать иконки типа во всей вкладке -- api.editor:clear_gutter() -- убрать все иконки активной вкладки -- api.editor:on_gutter_click(kind, callback) -- -- клик по иконке типа; callback(line, icon, kind), -- -- nil снимает обработчик -- api.editor:on_gutter_shift(kind, callback) -- -- перенос иконки при правке текста; -- -- callback(kind, old_line, new_line), -- -- nil снимает обработчик -- api.editor:active_text() -- текст активной вкладки -- api.editor:set_active_text(text) -- заменить текст активной вкладки -- api.editor:set_language(i, "rust") -- включить подсветку -- -- (rust|go|js|json|python|lua|bash|yaml| -- -- make|dockerfile|sql|markdown|css|c| -- -- cpp|html|xml) для вкладки i; -- -- false — нет вкладки/языка -- api.editor:language(i) -- язык подсветки вкладки или nil -- api.editor:clear_language(i) -- выключить подсветку для вкладки -- api.editor:save() -- сохранить активную вкладку в её файл -- api.editor:map_key(modes, keys, handler) -- -- привязка, которая работает дополнительно -- -- к глобальной, когда фокус в редакторе; -- -- синтаксис modes/keys/handler как у api.keymap:map_key -- api.editor:set_mode(mode) -- режим привязок редактора -- api.editor:mode() -- текущий режим привязок редактора -- api.editor:focus() -- перевести фокус в редактор, не меняя -- -- активную вкладку и курсор -- -- LSP-серверы (запуск целиком настраивается этим сценарием): -- api.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 сервера -- }) -- api.editor:lsp_servers() -- { "gopls", ... } -- api.editor:stop_lsp("gopls") -- true, если сервер был запущен -- api.editor:restart_lsp("gopls") -- перезапуск с прежними настройками -- api.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 закрывает -- список; горизонтальные стрелки закрывают его и двигают курсор. -- Документация выбранного элемента показывается второй всплывающей панелью -- справа от списка. Примеры запуска: -- api.editor:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" }, -- languages = { "go" }, watcher = "**/*.go" }) -- api.editor:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer", -- languages = { "rust" }, watcher = "**/*.rs" }) -- -- Панели окна (по умолчанию закрыты, размер меняется перетаскиванием): -- api.panels.left:open() -- api.panels.left:close() -- api.panels.left:toggle() -- вернуть true, если панель открыта -- api.panels.left:is_open() -- api.panels.right:open() -- те же методы, что у левой панели -- api.panels.right:close() -- api.panels.right:toggle() -- api.panels.right:is_open() -- api.panels.bottom:open() -- те же методы, что у левой панели -- api.panels.bottom:close() -- api.panels.bottom:toggle() -- api.panels.bottom:is_open() -- -- Верхняя панель редактора (полоса между вкладками и редактором). -- Элементы левой области идут от левого края вправо, правой — от правого -- края влево. id нужен, чтобы скрыть или снова показать элемент. -- api.panels.toolbar:add_label(text, icon, background, id, side) -- api.panels.toolbar:add_button(text, icon, background, id, side, fn) -- -- fn() вызывается при нажатии; -- -- background — "#RRGGBB", "#RRGGBBAA" или nil; -- -- icon — имя иконки Lucide или nil; -- -- side — "left" или "right" -- api.panels.toolbar:hide(id) -- true, если элемент найден -- api.panels.toolbar:show(id) -- снова показать скрытый элемент -- api.panels.toolbar:clear() -- удалить все элементы -- -- Блоки панелей. Сценарий сам решает, что показывает блок: редактор умеет -- рисовать только абстрактное дерево. -- local tree = api.panels.left:add_tree() -- local tab = api.panels.bottom: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:focus() -- только дать фокус, не меняя выбор -- tree:has_focus() -- владеет ли дерево фокусом сейчас -- tree:map_key(modes, keys, handler) -- -- привязка, которая работает дополнительно -- -- к глобальной, когда фокус на этом дереве -- tree:set_mode(mode) -- режим привязок блока (один символ) -- tree:mode() -- текущий режим привязок блока -- -- Методы добавляются в src/api.rs. Публичные методы impl-блока с -- #[mlua::userdata_impl] автоматически доступны из Lua: методы AppApi — у api, -- EditorApi — у api.editor, PanelApi — у api.panels.left/.right/.bottom, -- ToolbarApi — у api.panels.toolbar, PathApi — у api.path. -- Чтобы не экспортировать метод, его помечают #[lua(skip)]. -- -- Другие Lua-файлы можно подключать из каталога сценариев: -- include("tools.lua") -- выполнить файл сразу -- require("tools") -- выполнить один раз (как обычный Lua-модуль) -- -- Подсветка синтаксиса по умолчанию выключена. Включить её можно так: -- api.editor:set_language(0, "rust") -- gopls запускается автоматически при старте редактора. -- Сервер обслуживает только документы с languageId "go" и получает изменения -- всех .go-файлов проекта (кроме vendor/) через файловый вотчер. local gopls_ok, gopls_err = pcall(function() api.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 закрывает окно. -- api.keymap:map_key("", "", function() api.editor:save() end) -- api.keymap:map_key("", "", function() api.window:close() end) -- Убрать встроенную привязку можно так: -- api.keymap:map_key("", "", nil) -- api.keymap:map_completion_key("", "completion_next") -- api.keymap:map_completion_key("", "completion_previous") -- Файловое дерево проекта. -- -- Редактор рисует абстрактное дерево; смысл узлов (файлы, папки, что угодно) -- задаёт этот сценарий. Здесь корень — текущий каталог, а каждая папка -- дозагружается при первом раскрытии. local project_tree = api.panels.left:add_tree() -- Какие узлы являются каталогами. Нужно при создании пути: новый файл -- появляется внутри выбранной папки или рядом с выбранным файлом. local node_is_dir = {} -- Какие папки раскрыты. `load_dir` обновляет узлы, и без этого списка -- повторная загрузка каталога сворачивала бы уже открытые папки. local expanded_nodes = {} local function split_path(path) local parts = {} for part in path:gmatch("[^/]+") do parts[#parts + 1] = part end return parts end local function parent_path(path) return path:match("^(.*)/[^/]*$") or "" end local function merge_path(base, parts) local segments = {} for _, part in ipairs(base) do segments[#segments + 1] = part end for _, part in ipairs(parts) do segments[#segments + 1] = part end return segments end local function join_path(segments) return table.concat(segments, "/") end local function load_dir(tree, path) local nodes = {} for _, entry in ipairs(api.path:list(path)) do node_is_dir[entry.id] = entry.is_dir nodes[#nodes + 1] = { id = entry.id, label = entry.name, icon = entry.is_dir and "folder" or "file", expandable = entry.is_dir, expanded = entry.is_dir and expanded_nodes[entry.id] == true or false, } end tree:append(nodes, path) end project_tree:on_toggle(function(expanded, id, loaded) expanded_nodes[id] = expanded or nil -- loaded == false только при первом раскрытии: позже дети уже в дереве. if expanded and not loaded then load_dir(project_tree, id) end end) -- Раскрывает папку и сохраняет это в `expanded_nodes`, чтобы последующая -- загрузка соседних каталогов не свернула её обратно. local function expand_dir(path, label) expanded_nodes[path] = true project_tree:append({ { id = path, label = label, icon = "folder", expandable = true, expanded = true, }, }, parent_path(path)) end project_tree:on_click(function(id) -- Пустая таблица: фокус уходит в редактор, курсор остаётся на месте. api.editor:open(id, {}) end) api.panels.left:open() load_dir(project_tree, "") -- Каталог, от которого отсчитывается новый путь: выбранная папка или -- родитель выбранного файла. local function creation_base() local id = project_tree:selected() if not id or id == "" then return {} end if node_is_dir[id] then return split_path(id) end return split_path(parent_path(id)) end -- Создаёт путь, введённый в поле. Последний сегмент — файл, если строка не -- заканчивается на "/"; иначе создаётся папка. Все промежуточные сегменты — -- папки. Путь отсчитывается от текущей позиции в дереве. local function create_path(text) text = text:match("^%s*(.-)%s*$") if text == "" then return end local base = creation_base() local parts = split_path(text) if #parts == 0 then return end local is_directory = text:sub(-1) == "/" local parent_parts = {} local last_index = is_directory and #parts or #parts - 1 for index = 1, last_index do parent_parts[#parent_parts + 1] = parts[index] end local parent_segments = merge_path(base, parent_parts) if #parent_segments > 0 then api.path:mkdir(parent_segments) end local path = join_path(merge_path(base, parts)) if not is_directory then -- Создаём файл на диске, затем открываем его вкладкой. `open` без -- второго аргумента не забирает фокус у дерева. api.path:touch(path) api.editor:open(path) end -- Раскрываем всё созданное поддерево и загружаем содержимое каждого -- каталога, чтобы конечный элемент был виден в дереве. local current = {} for _, part in ipairs(base) do current[#current + 1] = part end for _, part in ipairs(parent_parts) do load_dir(project_tree, join_path(current)) current[#current + 1] = part local dir_path = join_path(current) expand_dir(dir_path, part) load_dir(project_tree, dir_path) end -- Загружаем каталог, в котором появился конечный элемент (для пути без -- промежуточных папок это базовый каталог). load_dir(project_tree, join_path(current)) project_tree:focus(path) if not is_directory then -- Созданный файл уже открыт вкладкой; переводим туда фокус, выбор -- в дереве при этом остаётся на нём. api.editor:focus() end end project_tree:map_key("", "a", function() api.window:open_input({ title = "Новый файл или папка", placeholder = "foo/bar/omg.txt", callback = create_path, }) end) -- Ctrl+E открывает панель и переводит фокус на дерево, а если панель уже -- открыта — только переводит фокус. На дереве Ctrl+Q закрывает панель, -- в остальных местах — по-прежнему закрывает приложение. api.keymap:map_key("", "", function() api.panels.left:open() project_tree:focus() end) api.keymap:map_key("", "", function() if project_tree:has_focus() then api.panels.left:close() api.editor:focus() else api.window:close() end end) -- Плавающее поле ввода. Пока оно открыто, сочетания клавиш приложения -- заблокированы; Enter вызывает callback, Esc и клик вне окна — отменяют. api.keymap:map_key("", "", function() api.window:open_input({ -- title = "Открыть файл", placeholder = "src/main.rs", callback = function(text) api.editor:open(text, {}) end, }) end) api.keymap:map_key("", "", "toggle_bottom_panel") -- Дерево диагностики в нижней панели. -- -- Обработчик один на все LSP-серверы. Второй аргумент регистрации — пауза -- тишины: если серверы присылают диагностику чаще, промежуточные вызовы -- пропускаются, а последняя остаётся и обрабатывается после затишья. -- При каждом вызове дерево очищается целиком и строится заново. local diagnostics_tree = api.panels.bottom: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 -- Строки активной вкладки, занятые иконками диагностики, больше не нужно -- запоминать: иконки имеют тип "diagnostics", и перед перерисовкой сценарий -- снимает только их, не трогая иконки других типов. local function active_tab_title() local active = api.editor:active_tab() for _, tab in ipairs(api.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 api.editor:on_lsp_diagnostics(100, function(servers) diagnostics_tree:clear() -- Иконки прошлого обновления активной вкладки могли устареть. Снимаем -- только иконки диагностики: иконки других типов (например, точки -- останова) остаются на месте. local title = active_tab_title() api.editor:clear_gutter_kind("diagnostics") -- Сначала собираем диагностику по файлам: одинаковые проблемы от разных -- серверов попадают в одну запись. 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 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 api.editor:set_gutter_icon(group.line, icon, "diagnostics") 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 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 api.editor:open(path, { tonumber(line), tonumber(character) }) else -- Клик по файлу: фокус в редактор, курсор не переставляем. api.editor:open(id, {}) end end) api.panels.bottom:open() -- Верхняя панель: пример лейблов и кнопок. -- api.panels.toolbar:add_label("Готово", nil, nil, "status", "left") api.panels.toolbar:add_label("UTF-8", nil, "#efefe6", "encoding", "right") api.panels.toolbar:add_button("Запустить", "play", nil, "run", "right", function() print("[toolbar] кнопка «Запустить» нажата") end) api.editor:set_gutter_icon(2, "play", "demo", { color = "#efefe6" }) api.editor:set_gutter_icon(90, "play", "demo")