Files
qode/init.lua
T
2026-10-31 15:33:00 +03:00

677 lines
35 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 и подключаемые им модули.
-- Первый позиционный аргумент открывается после выполнения этого файла
-- через 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.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[, color])
-- -- иконка на поле слева от редактора
-- -- (активная вкладка, строка с нуля);
-- -- color — #RGB/#RGBA/#RRGGBB/#RRGGBBAA;
-- -- false — неизвестная иконка или цвет
-- api.editor:gutter_icon(line) -- имя иконки gutter или nil
-- api.editor:gutter_color(line) -- цвет иконки gutter или nil
-- api.editor:clear_gutter_icon(line) -- убрать иконку со строки
-- api.editor:clear_gutter() -- убрать все иконки активной вкладки
-- 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)
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
-- Строки активной вкладки, занятые иконками диагностики. Gutter действует
-- только на активную вкладку, поэтому запоминаем поставленные строки, чтобы
-- при следующем обновлении снять устаревшие иконки и не трогать остальные.
local diagnostic_gutter = {}
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()
if title and diagnostic_gutter[title] then
for line in pairs(diagnostic_gutter[title]) do
api.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
api.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
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", "#efefe6")
api.editor:set_gutter_icon(90, "play")