732 lines
38 KiB
Lua
732 lines
38 KiB
Lua
-- Стартовый сценарий приложения 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
|
||
-- -- "<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> работает и на русской раскладке
|
||
-- 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.window:open_select(options) -- плавающий список выбора чуть выше центра;
|
||
-- -- options = { title = "...",
|
||
-- -- selected = "id",
|
||
-- -- items = {{ id = "...", label = "...",
|
||
-- -- icon = "file", description = "..." }},
|
||
-- -- callback = function(id) ... end };
|
||
-- -- стрелки/Home/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("", "<C-s>", function() api.editor:save() end)
|
||
-- api.keymap:map_key("", "<!Esc>", function() api.window:close() end)
|
||
-- Убрать встроенную привязку можно так:
|
||
-- api.keymap:map_key("", "<C-s>", nil)
|
||
-- api.keymap:map_completion_key("<C-n>", "completion_next")
|
||
-- api.keymap:map_completion_key("<C-p>", "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("", "<C-e>", function()
|
||
api.panels.left:open()
|
||
project_tree:focus()
|
||
end)
|
||
|
||
api.keymap:map_key("", "<C-q>", 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("", "<C-g>", function()
|
||
api.window:open_input({
|
||
-- title = "Открыть файл",
|
||
placeholder = "src/main.rs",
|
||
callback = function(text) api.editor:open(text, {}) end,
|
||
})
|
||
end)
|
||
|
||
-- Демонстрация списка выбора. Ctrl+J открывает пункты с иконками и
|
||
-- описаниями: title отделён разделителем, selected заранее выделяет пункт,
|
||
-- у части пунктов иконки нет (их текст остаётся выровненным), длинное
|
||
-- описание обрезается троеточием и не перетирает название. Enter или клик
|
||
-- передаёт в callback id выбранного пункта.
|
||
api.keymap:map_key("", "<C-j>", function()
|
||
api.window:open_select({
|
||
title = "Демонстрация списка выбора",
|
||
selected = "info",
|
||
items = {
|
||
{
|
||
id = "info",
|
||
label = "Информация",
|
||
icon = "info",
|
||
description = "короткое описание",
|
||
},
|
||
{
|
||
id = "file",
|
||
label = "Открыть файл",
|
||
icon = "file",
|
||
description = "Ctrl+G",
|
||
},
|
||
{
|
||
id = "folder",
|
||
label = "Папка",
|
||
icon = "folder",
|
||
description = "длинное описание, которое обрезается троеточием, не перетирая название",
|
||
},
|
||
{
|
||
id = "plain",
|
||
label = "Без иконки",
|
||
description = "место иконки остаётся пустым для выравнивания",
|
||
},
|
||
{
|
||
id = "note",
|
||
label = "Без описания",
|
||
icon = "notebook",
|
||
},
|
||
},
|
||
callback = function(id)
|
||
-- Колбэк получает id уже после закрытия списка. Он может открыть
|
||
-- новое окно — здесь это поле ввода с итогом выбора.
|
||
api.window:open_input({
|
||
title = "Выбрано: " .. id,
|
||
placeholder = "Enter закроет окно",
|
||
})
|
||
end,
|
||
})
|
||
end)
|
||
|
||
api.keymap:map_key("", "<C-t>", "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") |