This commit is contained in:
2026-10-30 23:11:51 +03:00
parent 55c3955897
commit 27385a3edb
11 changed files with 1898 additions and 63 deletions
+241
View File
@@ -18,9 +18,11 @@ use serde_json::Number;
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
use crate::editor::{Language, Workspace};
use crate::icons;
use crate::keymap::{KeyBinding, KeyMap};
use crate::lsp::{LspConfig, LspManager, WatcherConfig, document_uri};
use crate::panel::{PanelHandle, PanelState};
use crate::tree::{TreeHandle, TreeNode};
thread_local! {
/// Текущий экземпляр API. `thread_local`, потому что UI GPUI привязан к
@@ -68,6 +70,22 @@ pub struct TabInfo {
pub index: usize,
/// Название — то же, что показано в заголовке вкладки.
pub title: String,
/// Имя иконки вкладки; по умолчанию — `file`.
pub icon: String,
}
/// Элемент каталога, возвращаемый `app:list_dir(path)`.
///
/// `id` — путь относительно текущего каталога процесса: его можно сразу
/// положить в `id` узла дерева и передать в `app:open(id)`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirEntryInfo {
/// Путь элемента относительно текущего каталога.
pub id: String,
/// Имя файла или каталога без пути.
pub name: String,
/// Является ли элемент каталогом.
pub is_dir: bool,
}
/// Глобальный объект с функциями управления приложением.
@@ -113,6 +131,93 @@ impl PanelApi {
}
}
/// API одного блока дерева.
///
/// Объект возвращает `app.left_panel:add_tree()`. Редактор не знает, что
/// означают узлы: сценарий сам решает, будет это файловое дерево проекта или
/// что-то ещё, и передаёт готовый список узлов в [`TreeApi::append`].
#[derive(mlua::UserData)]
pub struct TreeApi {
#[lua(skip)]
state: TreeHandle,
}
impl TreeApi {
fn new(state: TreeHandle) -> Self {
Self { state }
}
}
#[mlua::userdata_impl]
impl TreeApi {
/// Добавляет узлы к родительскому узлу `parent`.
///
/// `nodes` — массив таблиц:
///
/// ```lua
/// {
/// id = "src/main.rs", -- строковый идентификатор (путь файла)
/// label = "main.rs", -- отображаемый текст
/// icon = "file-code", -- имя иконки Lucide (необязательно)
/// expandable = false, -- раскрываемая ли нода (папка)
/// expanded = false, -- раскрыта ли она сейчас
/// }
/// ```
///
/// Пустая строка в `parent` означает корень дерева. Существующие узлы не
/// удаляются: повторная передача того же `id` обновляет узел.
pub fn append(&self, nodes: mlua::Table, parent: Option<String>) -> mlua::Result<()> {
let nodes = parse_tree_nodes(&nodes)?;
let parent = parent.unwrap_or_default();
self.state
.borrow_mut()
.append(&parent, nodes)
.map_err(mlua::Error::RuntimeError)
}
/// Удаляет узел по `id` вместе со всеми дочерними узлами рекурсивно.
///
/// Возвращает `false`, если узла с таким идентификатором нет.
#[lua(infallible)]
pub fn remove(&self, id: String) -> bool {
self.state.borrow_mut().remove(&id)
}
/// Назначает обработчик раскрытия/сворачивания узла.
///
/// Обработчик получает `(expanded, id, loaded)`, где `expanded` — новая
/// нода раскрыта, `id` — идентификатор узла, `loaded` — были ли в узел уже
/// загружены дочерние элементы. После первого добавления детей всегда
/// приходит `true`.
#[lua(infallible)]
pub fn on_toggle(&self, callback: mlua::Function) {
self.state.borrow_mut().set_toggle_callback(Some(callback));
}
/// Назначает обработчик клика по конечной ноде.
///
/// Обработчик получает `(id)` — идентификатор узла.
#[lua(infallible)]
pub fn on_click(&self, callback: mlua::Function) {
self.state.borrow_mut().set_click_callback(Some(callback));
}
/// Идентификатор выбранной строки или `nil`.
#[lua(infallible)]
pub fn selected(&self) -> Option<String> {
self.state.borrow().selected().map(str::to_owned)
}
/// Раскрывает всех закрытых родителей узла `id`, выбирает его и передаёт
/// фокус дереву.
///
/// Возвращает `false`, если узла с таким идентификатором нет.
#[lua(infallible)]
pub fn focus(&self, id: String) -> bool {
self.state.borrow_mut().focus(&id)
}
}
#[mlua::userdata_impl]
impl PanelApi {
/// Открывает панель.
@@ -138,6 +243,17 @@ impl PanelApi {
pub fn is_open(&self) -> bool {
self.state.borrow().is_open()
}
/// Добавляет в панель блок дерева и возвращает его API.
///
/// ```lua
/// local tree = app.left_panel:add_tree()
/// tree:append({ { id = "a", label = "a.txt" } }, "")
/// ```
#[lua(infallible, name = "add_tree")]
pub fn add_tree(&self) -> TreeApi {
TreeApi::new(self.state.borrow_mut().add_tree())
}
}
#[mlua::userdata_impl]
@@ -208,6 +324,10 @@ impl AppApi {
.map(|(index, doc)| TabInfo {
index,
title: doc.name.clone(),
icon: doc
.icon
.clone()
.unwrap_or_else(|| icons::DEFAULT_TAB_ICON.to_owned()),
})
.collect()
}
@@ -362,6 +482,60 @@ impl AppApi {
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
}
/// Читает содержимое каталога (Rust-версия `app:list_dir`).
///
/// Пустой путь или `"."` означают текущий рабочий каталог процесса.
/// Элементы сортируются по имени без учёта регистра. В `id` попадает путь
/// относительно текущего каталога, если `path` тоже относительный.
#[lua(skip)]
pub fn list_dir(&self, path: impl AsRef<Path>) -> std::io::Result<Vec<DirEntryInfo>> {
let raw = path.as_ref().as_os_str().to_string_lossy().into_owned();
let root = raw.is_empty() || raw.as_str() == ".";
let directory = if raw.is_empty() {
Path::new(".")
} else {
path.as_ref()
};
let mut entries = Vec::new();
for entry in std::fs::read_dir(directory)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
let is_dir = entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false);
let id = if root {
name.clone()
} else {
Path::new(&raw)
.join(&name)
.to_string_lossy()
.replace('\\', "/")
};
entries.push(DirEntryInfo { id, name, is_dir });
}
entries.sort_by(|a, b| {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then_with(|| a.name.cmp(&b.name))
});
Ok(entries)
}
/// Список содержимого каталога `path` относительно текущего каталога.
///
/// Каждый элемент — таблица `{ id = "src/main.rs", name = "main.rs",
/// is_dir = false }`. Пустая строка или `"."` — текущий каталог. Метод
/// предназначен для построения деревьев и списков из Lua.
#[lua(name = "list_dir")]
fn lua_list_dir(&self, path: Option<String>) -> mlua::Result<Vec<DirEntryInfo>> {
let path = path.unwrap_or_default();
self.list_dir(&path).map_err(|err| {
let shown = if path.is_empty() { "." } else { path.as_str() };
mlua::Error::RuntimeError(format!("не удалось прочитать каталог {shown}: {err}"))
})
}
/// Делает вкладку активной по её номеру в списке [`AppApi::tabs`].
///
/// Возвращает `false`, если вкладки с таким номером нет.
@@ -375,6 +549,50 @@ impl AppApi {
true
}
/// Назначает иконку вкладке `index`.
///
/// `icon` — имя иконки Lucide: `file`, `file-code`, `book-open`, `settings`
/// и т. п. Регистр не важен; `_` и пробелы равны дефису, префикс `icons/`
/// и расширение `.svg` можно не писать.
///
/// Возвращает `false`, если вкладки нет или иконка с таким именем не входит
/// в каталог.
#[lua(skip)]
pub fn set_tab_icon(&self, index: usize, icon: &str) -> bool {
let Some(name) = icons::canonical_name(icon) else {
return false;
};
self.workspace.borrow_mut().set_tab_icon(index, name)
}
/// Lua-версия [`AppApi::set_tab_icon`].
#[lua(infallible, name = "set_tab_icon")]
fn lua_set_tab_icon(&self, index: usize, icon: String) -> bool {
self.set_tab_icon(index, &icon)
}
/// Возвращает вкладке иконку по умолчанию (`file`).
///
/// Возвращает `false`, если вкладки с таким номером нет.
#[lua(infallible)]
pub fn clear_tab_icon(&self, index: usize) -> bool {
self.workspace.borrow_mut().clear_tab_icon(index)
}
/// Имя иконки вкладки: назначенное через [`AppApi::set_tab_icon`] или
/// `file` по умолчанию. `nil`, если вкладки нет.
#[lua(infallible)]
pub fn tab_icon(&self, index: usize) -> Option<String> {
let workspace = self.workspace.borrow();
workspace.document(index)?;
Some(
workspace
.tab_icon(index)
.unwrap_or(icons::DEFAULT_TAB_ICON)
.to_owned(),
)
}
/// Включает подсветку синтаксиса для вкладки `index`.
///
/// `language` — название языка: `rust`, `go`, `js`, `json` или `python`
@@ -650,6 +868,29 @@ fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
}
}
/// Разбирает массив узлов дерева, переданный в `TreeApi::append`.
fn parse_tree_nodes(nodes: &mlua::Table) -> mlua::Result<Vec<TreeNode>> {
let mut parsed = Vec::new();
for pair in nodes.sequence_values::<mlua::Table>() {
let table = pair?;
let id: String = table.get("id")?;
let label = table.get::<String>("label").unwrap_or_else(|_| id.clone());
let icon = table
.get::<Option<String>>("icon")?
.and_then(|name| icons::canonical_name(&name).map(str::to_owned));
let expandable = table.get::<Option<bool>>("expandable")?.unwrap_or(false);
let expanded = table.get::<Option<bool>>("expanded")?.unwrap_or(false);
parsed.push(TreeNode {
id,
label,
icon,
expandable,
expanded,
});
}
Ok(parsed)
}
fn lua_strings(value: mlua::Value) -> mlua::Result<Vec<String>> {
match value {
mlua::Value::String(value) => Ok(vec![value.to_string_lossy()]),