From 27385a3edb72cfc44662242e7cbe8d6a4b9e2781 Mon Sep 17 00:00:00 2001 From: "dedal.qq" Date: Fri, 30 Oct 2026 23:11:51 +0300 Subject: [PATCH] files --- DOCS.md | 136 +++++++++- init.lua | 71 +++++- src/api.rs | 241 ++++++++++++++++++ src/app.rs | 393 +++++++++++++++++++++++++---- src/editor.rs | 43 ++++ src/icons.rs | 91 +++++++ src/main.rs | 2 + src/panel.rs | 30 ++- src/script.rs | 17 +- src/tests.rs | 253 +++++++++++++++++++ src/tree.rs | 684 ++++++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 1898 insertions(+), 63 deletions(-) create mode 100644 src/icons.rs create mode 100644 src/tree.rs diff --git a/DOCS.md b/DOCS.md index a031f0b..a83138f 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1,7 +1,8 @@ # Lua API Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект `app` -для управления окном, вкладками, подсветкой и LSP-серверами. +для управления окном, вкладками, панелями, файловой системой, подсветкой и +LSP-серверами. ## Запуск и каталог сценариев @@ -26,7 +27,7 @@ cargo run -- main.go | Метод | Описание | | --- | --- | -| `app:tabs()` | Список вкладок: `{{ index = 0, title = "main.rs" }, ...}` | +| `app:tabs()` | Список вкладок: `{{ index = 0, title = "main.rs", icon = "file" }, ...}` | | `app:active_tab()` | Индекс активной вкладки | | `app:set_active_tab(index)` | Сделать вкладку активной; `true`, если вкладка существует | | `app:next_tab()` | Переключить на следующую вкладку, вернуть индекс | @@ -34,6 +35,25 @@ cargo run -- main.go | `app:open_tab()` | Открыть пустую вкладку, вернуть индекс | | `app:open(path)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем | | `app:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта | +| `app:set_tab_icon(index, name)` | Назначить иконку вкладке; `false`, если вкладки нет или иконка неизвестна | +| `app:clear_tab_icon(index)` | Вернуть иконку по умолчанию (`file`) | +| `app:tab_icon(index)` | Имя иконки вкладки или `nil`, если вкладки нет | + +### Иконки вкладок + +Слева от названия вкладки показывается иконка; по умолчанию это `file`. +Имя — название иконки [Lucide](https://lucide.dev/icons/), регистр не важен. +Например: + +```lua +app:set_tab_icon(0, "file-code") -- main.rs +app:set_tab_icon(1, "book-open") -- notes.md +app:clear_tab_icon(1) -- снова "file" +``` + +Подчёркивания и пробелы считаются дефисом, а `icons/` и `.svg` можно +не писать: `file_code`, `file-code` и `icons/file-code.svg` равнозначны. +Неизвестное имя не назначается — метод вернёт `false`. ### Текст @@ -63,6 +83,24 @@ app:set_language(0, "rust") | --- | --- | | `app:save()` | Сохранить активную вкладку в её файл | +### Файловая система + +| Метод | Описание | +| --- | --- | +| `app:list_dir(path)` | Содержимое каталога: `{{ id = "src/main.rs", name = "main.rs", is_dir = false }, ...}` | + +Путь отсчитывается от текущего рабочего каталога процесса. Пустая строка или +`"."` означают сам текущий каталог. Поле `id` — путь элемента относительно +текущего каталога: его можно передать в `app:open(id)` и использовать как +идентификатор узла дерева. Элементы уже отсортированы по имени без учёта +регистра. + +```lua +for _, entry in ipairs(app:list_dir("src")) do + print(entry.name, entry.is_dir) +end +``` + ### Привязка клавиш `app:map_key(keys, fn)` назначает обработчик на нажатие или отпускание клавиши @@ -190,6 +228,100 @@ app.right_panel:toggle() app.bottom_panel:close() ``` +### Блоки панелей + +Панель может содержать блоки. Блок создаётся через API панели и живёт вместе +с ней; сейчас поддерживается один вид блока — абстрактное дерево. + +```lua +local tree = app.left_panel:add_tree() +``` + +Редактор отвечает только за отрисовку дерева. Что означает каждый узел, +решает сценарий: это могут быть файлы проекта, задачи, настройки — что угодно. +Файловое дерево проекта в `init.lua` собирается из `app:list_dir`, но сам +редактор о файловой системе в блоке ничего не знает. + +| Метод | Описание | +| --- | --- | +| `tree:append(nodes, parent)` | Добавить узлы к `parent`; `parent = ""` — корень | +| `tree:remove(id)` | Удалить узел и всех потомков рекурсивно; `false`, если узла нет | +| `tree:on_toggle(fn)` | Обработчик раскрытия: `fn(expanded, id, loaded)` | +| `tree:on_click(fn)` | Обработчик клика по конечной ноде: `fn(id)` | +| `tree:selected()` | Идентификатор выбранной строки или `nil` | +| `tree:focus(id)` | Раскрыть всех родителей, выбрать узел и передать фокус дереву | + +Узел — таблица: + +```lua +{ + id = "src/main.rs", -- строковый идентификатор (путь файла) + label = "main.rs", -- отображаемый текст + icon = "file-code", -- имя иконки Lucide (необязательно) + expandable = false, -- раскрываемая ли нода (папка) + expanded = false, -- раскрыта ли она сейчас +} +``` + +`append` добавляет узлы, а не заменяет дерево. Повторная передача того же `id` +обновляет описание узла, но не создаёт копию. + +Дети каждого уровня сортируются по `label` без учёта регистра. Сначала идут +раскрываемые узлы (`expandable = true`), затем конечные; внутри каждой группы — +по алфавиту. + +В обработчик `on_toggle` приходит `loaded = true`, если дочерние узлы уже +добавляли в эту ноду; при первом раскрытии — `false`. + +Клик по строке выбирает её. Выбранную строку можно перемещать стрелками +`↑`/`↓`; `↑`/`↓` двигают выбор, `→` раскрывает папку или переходит к первому +ребёнку, `←` сворачивает папку или выбирает родителя, `Enter` раскрывает папку +или вызывает `on_click` для конечной ноды. Выбор сохраняется, пока панель +закрыта; при повторном открытии панели фокус возвращается на выбранную строку. + +Пример отложенной загрузки дерева: + +```lua +local tree = app.left_panel:add_tree() + +local function load_dir(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 + +tree:on_toggle(function(expanded, id, loaded) + if expanded and not loaded then + load_dir(id) + end +end) + +tree:on_click(function(id) + app:open(id) +end) + +app.left_panel:open() +load_dir("") + +-- При открытии панели вернуть фокус на последнюю выбранную строку. +app:map_key("", function() + if app.left_panel:toggle() then + local selected = tree:selected() + if selected then + tree:focus(selected) + end + end +end) +``` + ## Подключение других Lua-файлов Файлы подключаются из каталога сценариев: diff --git a/init.lua b/init.lua index c1c91ee..a8067ea 100644 --- a/init.lua +++ b/init.lua @@ -15,7 +15,7 @@ print("[lua] init.lua выполнен") -- Доступные методы объекта app (номера вкладок — с нуля): --- app:tabs() -- {{ index = 0, title = "main.rs" }, ...} +-- app:tabs() -- {{ index = 0, title = "main.rs", icon = "file" }, ...} -- app:active_tab() -- индекс активной вкладки -- app:set_active_tab(index) -- true, если вкладка существует -- app:next_tab() -- переключить на следующую, вернуть индекс @@ -23,7 +23,15 @@ print("[lua] init.lua выполнен") -- app:open_tab() -- открыть пустую вкладку, вернуть индекс -- app:open(path) -- открыть файл с диска, вернуть индекс вкладки; -- -- если файла нет — пустой буфер с таким именем +-- 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) @@ -92,6 +100,18 @@ print("[lua] init.lua выполнен") -- app.bottom_panel:toggle() -- app.bottom_panel:is_open() -- +-- Блоки панелей. Сценарий сам решает, что показывает блок: редактор умеет +-- рисовать только абстрактное дерево. +-- local tree = app.left_panel:add_tree() +-- tree:append(nodes, parent_id) -- nodes = { { id=..., label=..., icon=..., +-- -- expandable=..., expanded=... }, ... }; +-- -- parent_id = "" — корень дерева +-- 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. -- @@ -136,9 +156,56 @@ end -- app:map_key("", function() app:save() end) -- app:map_key("", function() app:close() end) +-- Файловое дерево проекта. +-- +-- Редактор рисует абстрактное дерево; смысл узлов (файлы, папки, что угодно) +-- задаёт этот сценарий. Здесь корень — текущий каталог, а каждая папка +-- дозагружается при первом раскрытии. +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("", function() app.right_panel:toggle() end) +app:map_key("", function() app.left_panel:toggle() end) app:map_key("", function() app.bottom_panel:toggle() end) app:open_tab() diff --git a/src/api.rs b/src/api.rs index b8ccdbf..1cc0358 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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) -> 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 { + 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) -> std::io::Result> { + 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) -> mlua::Result> { + 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 { + 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> { } } +/// Разбирает массив узлов дерева, переданный в `TreeApi::append`. +fn parse_tree_nodes(nodes: &mlua::Table) -> mlua::Result> { + let mut parsed = Vec::new(); + for pair in nodes.sequence_values::() { + let table = pair?; + let id: String = table.get("id")?; + let label = table.get::("label").unwrap_or_else(|_| id.clone()); + let icon = table + .get::>("icon")? + .and_then(|name| icons::canonical_name(&name).map(str::to_owned)); + let expandable = table.get::>("expandable")?.unwrap_or(false); + let expanded = table.get::>("expanded")?.unwrap_or(false); + parsed.push(TreeNode { + id, + label, + icon, + expandable, + expanded, + }); + } + Ok(parsed) +} + fn lua_strings(value: mlua::Value) -> mlua::Result> { match value { mlua::Value::String(value) => Ok(vec![value.to_string_lossy()]), diff --git a/src/app.rs b/src/app.rs index dc83657..c4db22c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,7 @@ //! а `Editor` показывает активный документ; синхронизация выполняется в //! `render`, когда меняется [`Workspace::revision`]. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::ops::Range; use std::rc::Rc; @@ -18,8 +18,8 @@ use gpui_kit::component::resizable::HandleEdge; use gpui_kit::component::tab::{Tab, TabBar}; use gpui_kit::component::text::TextView; use gpui_kit::component::{ - ActiveTheme, Icon, IconName, Sizable, StyledExt, TitleBar, h_flex, h_resizable, - resizable_panel, resize_handle, v_resizable, + ActiveTheme, Icon, Sizable, StyledExt, TitleBar, h_flex, h_resizable, resizable_panel, + resize_handle, v_resizable, }; use gpui_kit::*; use lsp_types::{CompletionItem, Hover, TextEdit}; @@ -32,10 +32,12 @@ use crate::completion::{ }; use crate::constants::{MIN_LEFT_PANEL_WIDTH, MIN_MAIN_AREA_WIDTH, TAB_SIZE, WINDOW_TITLE}; use crate::editor::Workspace; +use crate::icons; use crate::keymap::KeyMap; use crate::lsp::{LspManager, document_uri, position_to_offset}; -use crate::panel::PanelHandle; +use crate::panel::{PanelBlock, PanelHandle}; use crate::script::ScriptEngine; +use crate::tree::{TreeAction, TreeHandle, TreeKeyResult}; /// Payload перетаскивания разделителя левой панели. /// @@ -140,6 +142,9 @@ pub struct MyApp { right_panel: PanelHandle, /// Состояние нижней панели, общее с `app.bottom_panel` в Lua. bottom_panel: PanelHandle, + /// Открытость панелей в предыдущем кадре: по переходу «закрыта → + /// открыта» вид возвращает фокус в дерево, которое было выбрано раньше. + panel_open_state: Cell<[bool; 3]>, /// Виджет редактора, показывающий активный документ. editor: Entity, /// Ревизия [`Workspace`], уже перенесённая в `editor`. @@ -273,6 +278,7 @@ impl MyApp { left_panel, right_panel, bottom_panel, + panel_open_state: Cell::new([false; 3]), editor, synced_revision: 0, displayed_id, @@ -1057,6 +1063,225 @@ impl MyApp { .into_any_element() } + /// Содержимое панели: блоки в порядке добавления. + /// + /// Если блоков нет, правая панель показывает список LSP-серверов, как и + /// раньше, а остальные панели остаются пустыми. + fn render_panel_content( + &self, + panel: &PanelHandle, + lsp_fallback: bool, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let blocks = panel.borrow().blocks().to_vec(); + if blocks.is_empty() { + return if lsp_fallback { + self.render_right_panel(cx) + } else { + div().size_full().into_any_element() + }; + } + + let mut content = div().size_full().flex().flex_col().min_h_0(); + for block in blocks { + let element = match block { + PanelBlock::Tree(tree) => self.render_tree_block(&tree, window, cx), + }; + content = content.child(div().w_full().flex_1().min_h_0().child(element)); + } + content.into_any_element() + } + + /// Рисует один блок абстрактного дерева. + /// + /// Узлы и их иерархию целиком задаёт Lua; вид только сортирует видимые + /// строки (это делает [`crate::tree::TreeState::rows`]) и превращает + /// клики в вызовы обработчиков. + fn render_tree_block( + &self, + tree: &TreeHandle, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let (rows, selected, focus_handle, focus_requested, scroll) = { + let mut state = tree.borrow_mut(); + let focus_handle = state.focus_handle(cx); + let scroll = state.scroll_handle(); + let focus_requested = state.take_focus_request(); + let rows = state.rows(); + let selected = state.selected().map(str::to_owned); + (rows, selected, focus_handle, focus_requested, scroll) + }; + if focus_requested { + focus_handle.focus(window, cx); + } + + let entity = cx.entity(); + let tree_ptr = Rc::as_ptr(tree) as usize; + let mut list = div() + .id(ElementId::Name(format!("tree-block-{tree_ptr}").into())) + .track_focus(&focus_handle) + .track_scroll(&scroll) + .on_key_down({ + let handle = tree.clone(); + let entity = entity.clone(); + move |event, _window, cx| { + let result = handle.borrow_mut().handle_key(&event.keystroke.key); + let handled = match result { + TreeKeyResult::Ignored => false, + TreeKeyResult::Handled => true, + TreeKeyResult::Action(action) => { + run_tree_action(action); + true + } + }; + if handled { + cx.stop_propagation(); + entity.update(cx, |_, cx| cx.notify()); + } + } + }) + .flex() + .flex_col() + .size_full() + .overflow_y_scroll() + .py_1(); + + if rows.is_empty() { + list = list.child( + div() + .p_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Дерево пусто"), + ); + } + + for row in rows { + let handle = tree.clone(); + let entity = entity.clone(); + let id = row.id.clone(); + let indent = 8.0 + row.depth as f32 * 14.0; + let icon = icons::from_name(row.icon.as_deref().unwrap_or(if row.expandable { + "folder" + } else { + "file" + })) + .unwrap_or(if row.expandable { + AssetIcon::Folder + } else { + AssetIcon::File + }); + let chevron = row.expandable.then_some(if row.expanded { + AssetIcon::ChevronDown + } else { + AssetIcon::ChevronRight + }); + let is_selected = selected.as_deref() == Some(row.id.as_str()); + let hover_bg = cx.theme().list_hover; + let selected_bg = cx.theme().list_active; + + let mut line = h_flex() + .id(ElementId::Name( + format!("tree-row-{tree_ptr}-{}", row.id).into(), + )) + .w_full() + .items_center() + .gap_1() + .pl(px(indent)) + .pr_2() + .py_1() + .rounded_sm() + .cursor_pointer() + .hover(move |style| { + if is_selected { + style + } else { + style.bg(hover_bg) + } + }) + .on_click({ + let focus_handle = focus_handle.clone(); + move |_, window, cx| { + let action = { + let mut state = handle.borrow_mut(); + state.focus(&id); + state.activate(&id) + }; + // Фокус применяем сразу, а отложенный запрос из + // `TreeState::focus` переносит его в следующий кадр, + // если синхронизация редактора успеет забрать фокус. + focus_handle.focus(window, cx); + if let Some(action) = action { + run_tree_action(action); + } + entity.update(cx, |_, cx| cx.notify()); + } + }); + if is_selected { + line = line.bg(selected_bg); + } + + line = match chevron { + Some(chevron) => line.child( + div().size(px(14.)).flex_shrink_0().child( + Icon::new(chevron) + .xsmall() + .text_color(cx.theme().muted_foreground), + ), + ), + None => line.child(div().w(px(14.)).flex_shrink_0()), + }; + line = line + .child( + Icon::new(icon) + .small() + .flex_shrink_0() + .text_color(cx.theme().sidebar_foreground), + ) + .child(div().text_sm().min_w_0().truncate().child(row.label)); + + list = list.child(line); + } + + div().size_full().min_h_0().child(list).into_any_element() + } + + /// Возвращает фокус в дерево, когда его панель открывается. + /// + /// Фокус получает только панель, которая была закрыта в прошлом кадре и + /// открылась сейчас; внутри неё выбирается первое дерево с выбранной + /// строкой. Если строку ещё ни разу не выбирали, фокус не забирается. + fn sync_panel_focus(&self, open: [bool; 3], window: &mut Window, cx: &mut Context) { + let previous = self.panel_open_state.get(); + let panels = [&self.left_panel, &self.right_panel, &self.bottom_panel]; + for (index, panel) in panels.into_iter().enumerate() { + if !open[index] || previous[index] { + continue; + } + let tree = panel + .borrow() + .blocks() + .iter() + .find_map(|block| match block { + PanelBlock::Tree(tree) => Some(tree.clone()), + }); + let Some(tree) = tree else { + continue; + }; + let handle = { + let mut state = tree.borrow_mut(); + if state.selected().is_none() { + continue; + } + state.focus_handle(cx) + }; + handle.focus(window, cx); + } + self.panel_open_state.set(open); + } + /// Переносит изменения модели в виджет редактора. /// /// `set_value` сбрасывает выделение и прокрутку, поэтому вызывается только @@ -1115,8 +1340,13 @@ impl MyApp { /// Редактор с левой, правой и нижней панелями. /// /// Все панели меняют размер перетаскиванием разделителя, а закрытые - /// панели не занимают места. Правая панель показывает статус LSP. - fn render_workspace(&self, window: &Window, cx: &mut Context) -> impl IntoElement { + /// панели не занимают места. В каждой панели рисуются добавленные + /// сценарием блоки; правая панель без блоков показывает статус LSP. + fn render_workspace( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { let (left_open, left_size) = { let panel = self.left_panel.borrow(); (panel.is_open(), panel.size()) @@ -1135,16 +1365,25 @@ impl MyApp { (panel.is_open(), panel.size()) }; + // Панель могли открыть из Lua или кнопкой: если до этого в её дереве + // была выбрана строка, возвращаем туда фокус. + self.sync_panel_focus([left_open, right_open, bottom_open], window, cx); + // Нижняя панель занимает ширину редактора, а правая тянется на всю // высоту — как в большинстве редакторов кода. - let editor_area = v_resizable("editor-area") - .child(resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full())) - .child( - resizable_panel() - .size(px(bottom_size)) - .visible(bottom_open) - .child(div().size_full().bg(cx.theme().sidebar)), - ); + let editor_area = + v_resizable("editor-area") + .child( + resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full()), + ) + .child( + resizable_panel() + .size(px(bottom_size)) + .visible(bottom_open) + .child(div().size_full().bg(cx.theme().sidebar).child( + self.render_panel_content(&self.bottom_panel, false, window, cx), + )), + ); let area = h_resizable("main-area") .child(resizable_panel().child(editor_area)) @@ -1156,7 +1395,7 @@ impl MyApp { div() .size_full() .bg(cx.theme().sidebar) - .child(self.render_right_panel(cx)), + .child(self.render_panel_content(&self.right_panel, true, window, cx)), ), ); @@ -1169,6 +1408,7 @@ impl MyApp { .flex_shrink_0() .relative() .bg(cx.theme().sidebar) + .child(self.render_panel_content(&self.left_panel, false, window, cx)) .child( resize_handle("left-panel-handle", Axis::Horizontal) .inside(HandleEdge::Trailing) @@ -1192,7 +1432,8 @@ impl MyApp { }) } - /// Полоса вкладок: активная вкладка, маркер несохранённых изменений и «+». + /// Полоса вкладок в стиле folder tabs: иконка, название, маркер + /// несохранённых изменений и крестик закрытия, а также «+». fn render_tabs(&self, cx: &mut Context) -> impl IntoElement { let (tabs, active) = { let workspace = self.workspace.borrow(); @@ -1200,65 +1441,79 @@ impl MyApp { workspace .documents() .iter() - .map(|doc| (doc.name.clone(), doc.is_dirty())) + .map(|doc| { + ( + doc.name.clone(), + doc.is_dirty(), + icons::tab_icon(doc.icon.as_deref()), + ) + }) .collect::>(), workspace.active_index(), ) }; let entity = cx.entity(); - let mut bar = TabBar::new("tabs").selected_index(active); + let mut bar = TabBar::new("tabs").selected_index(active).folder(); - for (index, (name, dirty)) in tabs.into_iter().enumerate() { - // У несохранённого документа вместо крестика горит точка — как в - // исходном приложении. - let suffix: AnyElement = if dirty { - div() - .size(px(6.)) - .rounded_full() - .bg(cx.theme().primary) - .into_any_element() - } else { - let entity = entity.clone(); + for (index, (name, dirty, icon)) in tabs.into_iter().enumerate() { + // Точка и крестик живут рядом: у несохранённой вкладки закрытие + // тоже должно оставаться доступным. + let mut suffix = h_flex().items_center().gap_1(); + if dirty { + suffix = suffix.child( + div() + .size(px(6.)) + .rounded_full() + .bg(cx.theme().primary) + .flex_shrink_0(), + ); + } + suffix = suffix.child( Button::new(format!("close-tab-{index}")) .xsmall() .ghost() - .icon(IconName::Close) + // Полный каталог Lucide, а не компактный набор компонентов. + .icon(AssetIcon::X) .tooltip("Закрыть вкладку") - .on_click(move |_, _, cx| { - // Иначе клик закроет вкладку и тут же переключит на неё - // соседнюю по уже устаревшему индексу. - cx.stop_propagation(); - entity.update(cx, |this, cx| { - let uri = this - .workspace - .borrow() - .document(index) - .and_then(|doc| document_uri(&doc.name)); - this.workspace.borrow_mut().close(index); - if let Some(uri) = uri { - this.lsp.close_document(&uri); - } - cx.notify(); - }); - }) - .into_any_element() - }; + .on_click({ + let entity = entity.clone(); + move |_, _, cx| { + // Иначе клик закроет вкладку и тут же переключит + // на соседнюю по устаревшему индексу. + cx.stop_propagation(); + entity.update(cx, |_, cx| { + AppApi::global().close_tab(index); + cx.notify(); + }); + } + }), + ); - bar = bar.child(Tab::new().label(name).suffix(suffix)); + bar = bar.child( + Tab::new() + .prefix( + Icon::new(icon) + .small() + .flex_shrink_0() + .text_color(cx.theme().tab_foreground), + ) + .label(name) + .suffix(suffix), + ); } bar.suffix( Button::new("new-tab") .xsmall() .ghost() - .icon(IconName::Plus) + .icon(AssetIcon::Plus) .tooltip("Новая вкладка") .on_click({ let entity = entity.clone(); move |_, _, cx| { - entity.update(cx, |this, cx| { - this.workspace.borrow_mut().open_new(); + entity.update(cx, |_, cx| { + AppApi::global().open_tab(); cx.notify(); }); } @@ -1267,8 +1522,8 @@ impl MyApp { .on_click({ let entity = entity.clone(); move |index, _, cx| { - entity.update(cx, |this, cx| { - this.workspace.borrow_mut().set_active(*index); + entity.update(cx, |_, cx| { + AppApi::global().set_active_tab(*index); cx.notify(); }); } @@ -1395,6 +1650,34 @@ fn is_completion_shortcut(keystroke: &Keystroke) -> bool { && !keystroke.modifiers.alt } +/// Вызывает Lua-обработчик, назначенный блоку дерева. +/// +/// Ошибка Lua не должна ломать отрисовку: она печатается в stderr, как и +/// ошибки обработчиков клавиш. +fn run_tree_action(action: TreeAction) { + match action { + TreeAction::Toggle { + id, + expanded, + loaded, + callback, + } => { + if let Some(callback) = callback + && let Err(err) = callback.call::<()>((expanded, id, loaded)) + { + eprintln!("Lua: ошибка в обработчике дерева: {err}"); + } + } + TreeAction::Click { id, callback } => { + if let Some(callback) = callback + && let Err(err) = callback.call::<()>(id) + { + eprintln!("Lua: ошибка в обработчике дерева: {err}"); + } + } + } +} + /// Сохраняет активную вкладку, печатая ошибку в stderr. fn save_active_tab() { if let Err(err) = AppApi::global().save() { diff --git a/src/editor.rs b/src/editor.rs index d37e8ba..7d0e02b 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -147,6 +147,11 @@ pub struct Document { pub id: u64, pub name: String, pub content: String, + /// Имя иконки вкладки из каталога Lucide (см. `crate::icons`). + /// + /// `None` — показывается иконка по умолчанию. Назначается через + /// `AppApi::set_tab_icon`. + pub icon: Option, /// Язык подсветки синтаксиса. /// /// `None` — подсветка выключена (поведение по умолчанию). Включается @@ -166,6 +171,7 @@ impl Document { name: name.into(), saved_content: content.clone(), content, + icon: None, language: None, cursor: 0, } @@ -380,6 +386,43 @@ impl Workspace { self.active_mut().mark_saved(); self.bump(); } + + /// Назначает вкладке иконку из каталога; возвращает `false`, если вкладки нет. + pub fn set_tab_icon(&mut self, index: usize, icon: impl Into) -> bool { + let icon = icon.into(); + let changed = match self.documents.get_mut(index) { + Some(document) => { + let changed = document.icon.as_deref() != Some(icon.as_str()); + if changed { + document.icon = Some(icon); + } + changed + } + None => return false, + }; + if changed { + self.bump(); + } + true + } + + /// Возвращает вкладке иконку по умолчанию; `false`, если вкладки нет. + pub fn clear_tab_icon(&mut self, index: usize) -> bool { + let changed = match self.documents.get_mut(index) { + Some(document) => document.icon.take().is_some(), + None => return false, + }; + if changed { + self.bump(); + } + true + } + + /// Имя иконки вкладки, если оно назначено. + pub fn tab_icon(&self, index: usize) -> Option<&str> { + self.document(index) + .and_then(|document| document.icon.as_deref()) + } } const SAMPLE_RUST: &str = r#"//! Демонстрационный файл. diff --git a/src/icons.rs b/src/icons.rs new file mode 100644 index 0000000..12efe42 --- /dev/null +++ b/src/icons.rs @@ -0,0 +1,91 @@ +//! Иконки вкладок. +//! +//! Имена совпадают с названиями Lucide (`file`, `file-code`, `book-open`) — +//! именно их принимает `app:set_tab_icon(index, name)`. Неизвестное имя не +//! подменяется молча: API возвращает `false`, а вкладка продолжает показывать +//! иконку по умолчанию. + +use std::collections::HashMap; +use std::sync::LazyLock; + +use gpui_kit::assets::IconName; + +/// Иконка вкладки, пока сценарий не назначил другую. +pub const DEFAULT_TAB_ICON: &str = "file"; + +/// Каталог иконок по каноническому имени. +static ICONS: LazyLock> = LazyLock::new(|| { + IconName::ALL + .iter() + .map(|icon| (asset_name(*icon), *icon)) + .collect() +}); + +/// Имя иконки в каталоге: `icons/file-code.svg` → `file-code`. +fn asset_name(icon: IconName) -> String { + let path = icon.path(); + let path = path.as_str(); + let stem = path.strip_prefix("icons/").unwrap_or(path); + stem.strip_suffix(".svg").unwrap_or(stem).to_owned() +} + +/// Приводит имя из API к виду каталога. +/// +/// Регистр не важен; `_` и пробелы считаются дефисом, префикс `icons/` и +/// расширение `.svg` можно не писать. +fn normalize(name: &str) -> String { + let mut normalized = name.trim().to_ascii_lowercase().replace(['_', ' '], "-"); + if let Some(rest) = normalized.strip_prefix("icons/") { + normalized = rest.to_owned(); + } + if let Some(rest) = normalized.strip_suffix(".svg") { + normalized = rest.to_owned(); + } + normalized +} + +/// Каноническое имя известной иконки. +pub fn canonical_name(name: &str) -> Option<&'static str> { + let normalized = normalize(name); + ICONS + .get_key_value(&normalized) + .map(|(name, _)| name.as_str()) +} + +/// Иконка по имени из API; `None`, если такого имени нет в каталоге. +pub fn from_name(name: &str) -> Option { + let normalized = normalize(name); + ICONS.get(&normalized).copied() +} + +/// Иконка вкладки по умолчанию. +pub fn default_icon() -> IconName { + IconName::File +} + +/// Иконка вкладки: назначенная сценарием или стандартная. +pub fn tab_icon(name: Option<&str>) -> IconName { + name.and_then(from_name).unwrap_or_else(default_icon) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_are_normalized_before_lookup() { + assert_eq!(canonical_name("file"), Some("file")); + assert_eq!(canonical_name(" File "), Some("file")); + assert_eq!(canonical_name("file_code"), Some("file-code")); + assert_eq!(canonical_name("icons/file-code.svg"), Some("file-code")); + assert_eq!(canonical_name("not-an-icon"), None); + } + + #[test] + fn default_icon_is_in_the_catalog() { + let icon = default_icon(); + assert_eq!(from_name(DEFAULT_TAB_ICON), Some(icon)); + assert_eq!(tab_icon(None), icon); + assert_eq!(tab_icon(Some("unknown")), icon); + } +} diff --git a/src/main.rs b/src/main.rs index 18b37a2..aea4043 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,10 +10,12 @@ mod app; mod completion; mod constants; mod editor; +mod icons; mod keymap; mod lsp; mod panel; mod script; +mod tree; #[cfg(test)] mod tests; diff --git a/src/panel.rs b/src/panel.rs index eeaa68d..cfb88b0 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -4,21 +4,34 @@ //! и `app.bottom_panel`, а вид [`MyApp`](crate::app::MyApp) читает его при //! отрисовке. Как и документы, состояние лежит в `Rc>`, чтобы вид и //! API видели одни и те же панели. +//! +//! Панель состоит из блоков. Блок — это независимый виджет, который сценарий +//! создаёт через API панели (сейчас поддерживается [`PanelBlock::Tree`]). use std::cell::RefCell; use std::rc::Rc; +use crate::tree::{TreeHandle, new_tree}; + /// Общая ссылка на состояние панели. pub type PanelHandle = Rc>; -/// Открыта ли панель и каким размером она раскрывается. +/// Блок, добавленный в панель. +#[derive(Clone)] +pub enum PanelBlock { + /// Абстрактное дерево, наполняемое из Lua. + Tree(TreeHandle), +} + +/// Открыта ли панель, каким размером она раскрывается и какие блоки содержит. /// /// Размер — начальный: после перетаскивания разделителя виджет /// `ResizablePanelGroup` запоминает фактический размер панели сам. -#[derive(Clone, Copy, Debug)] +#[derive(Clone)] pub struct PanelState { visible: bool, size: f32, + blocks: Vec, } impl PanelState { @@ -27,6 +40,7 @@ impl PanelState { Self { visible: false, size: size.max(0.0), + blocks: Vec::new(), } } @@ -60,4 +74,16 @@ impl PanelState { self.visible = !self.visible; self.visible } + + /// Блоки панели в порядке добавления. + pub fn blocks(&self) -> &[PanelBlock] { + &self.blocks + } + + /// Добавляет блок дерева и возвращает доступ к нему. + pub fn add_tree(&mut self) -> TreeHandle { + let tree = new_tree(); + self.blocks.push(PanelBlock::Tree(tree.clone())); + tree + } } diff --git a/src/script.rs b/src/script.rs index 07b7302..c0cf133 100644 --- a/src/script.rs +++ b/src/script.rs @@ -17,19 +17,32 @@ use std::path::{Path, PathBuf}; use mlua::{Lua, Table}; -use crate::api::{AppApi, TabInfo}; +use crate::api::{AppApi, DirEntryInfo, TabInfo}; use crate::constants::{STARTUP_SCRIPT, script_dir}; -/// Вкладка превращается в Lua-таблицу `{ index = 0, title = "main.rs" }`. +/// Вкладка превращается в Lua-таблицу `{ index = 0, title = "main.rs", icon = "file" }`. impl mlua::IntoLua for TabInfo { fn into_lua(self, lua: &Lua) -> mlua::Result { let tab = lua.create_table()?; tab.set("index", self.index)?; tab.set("title", self.title)?; + tab.set("icon", self.icon)?; Ok(mlua::Value::Table(tab)) } } +/// Элемент каталога превращается в Lua-таблицу +/// `{ id = "src/main.rs", name = "main.rs", is_dir = false }`. +impl mlua::IntoLua for DirEntryInfo { + fn into_lua(self, lua: &Lua) -> mlua::Result { + let entry = lua.create_table()?; + entry.set("id", self.id)?; + entry.set("name", self.name)?; + entry.set("is_dir", self.is_dir)?; + Ok(mlua::Value::Table(entry)) + } +} + /// Движок Lua и состояние его глобального окружения. pub struct ScriptEngine { lua: Lua, diff --git a/src/tests.rs b/src/tests.rs index 800bc81..110902f 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -13,7 +13,9 @@ use crate::api::{AppApi, WindowCommand, drain_window_commands}; use crate::app::MyApp; use crate::editor::{Language, Workspace}; use crate::keymap::{KeyBinding, KeyMap, Modifiers as KeyModifiers}; +use crate::panel::PanelBlock; use crate::script::ScriptEngine; +use crate::tree::TreeNode; fn install_api(workspace: Rc>) -> (AppApi, Rc>) { let keymap = Rc::new(RefCell::new(KeyMap::default())); @@ -137,6 +139,62 @@ fn api_lists_tabs_and_closes_them_by_number() { assert_eq!(workspace.borrow().documents().len(), 1); } +#[test] +fn api_sets_and_clears_tab_icons() { + let workspace = Rc::new(RefCell::new(Workspace::default())); + let (api, _keymap) = install_api(workspace); + + // По умолчанию у всех вкладок иконка file. + assert_eq!(api.tabs()[0].icon, "file"); + assert_eq!(api.tab_icon(0).as_deref(), Some("file")); + + assert!(api.set_tab_icon(0, "file-code")); + assert_eq!(api.tabs()[0].icon, "file-code"); + assert_eq!(api.tab_icon(0).as_deref(), Some("file-code")); + + // Имя нормализуется, а вкладка возвращает канонический вариант. + assert!(api.set_tab_icon(0, "icons/book-open.svg")); + assert_eq!(api.tab_icon(0).as_deref(), Some("book-open")); + + // Неизвестная иконка и несуществующая вкладка ничего не меняют. + assert!(!api.set_tab_icon(0, "definitely-not-an-icon")); + assert_eq!(api.tab_icon(0).as_deref(), Some("book-open")); + assert!(!api.set_tab_icon(99, "file")); + + assert!(api.clear_tab_icon(0)); + assert_eq!(api.tab_icon(0).as_deref(), Some("file")); + assert!(!api.clear_tab_icon(99)); + assert_eq!(api.tab_icon(99), None); +} + +#[test] +fn lua_script_can_set_tab_icons() { + let workspace = Rc::new(RefCell::new(Workspace::default())); + let (api, _keymap) = install_api(workspace.clone()); + + let dir = temp_script_dir("tab-icons"); + let script = dir.join("icons.lua"); + std::fs::write( + &script, + r#" + assert(app:tab_icon(0) == "file") + assert(app:set_tab_icon(0, "book-open")) + assert(app:tab_icon(0) == "book-open") + assert(app:tabs()[2].icon == "file") + assert(not app:set_tab_icon(0, "no-such-icon")) + assert(app:clear_tab_icon(0)) + assert(app:tab_icon(0) == "file") + "#, + ) + .unwrap(); + + let engine = ScriptEngine::new(api).expect("движок Lua"); + engine.run_file(&script).expect("скрипт иконок"); + + drop(engine); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn api_save_writes_active_tab_to_file_named_after_it() { // Тест пишет во временный файл, а не в каталог проекта. @@ -297,6 +355,103 @@ fn lua_script_can_control_panels() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn lua_tree_block_appends_sorts_and_removes_nodes() { + let workspace = Rc::new(RefCell::new(Workspace::default())); + let (api, _keymap) = install_api(workspace); + + let dir = temp_script_dir("tree-block"); + let script = dir.join("tree.lua"); + std::fs::write( + &script, + r#" + local tree = app.left_panel:add_tree() + tree:append({ + { id = "b", label = "banana", expandable = false }, + { id = "src", label = "src", icon = "folder", expandable = true, expanded = true }, + { id = "a", label = "Apple", expandable = false }, + }, "") + tree:append({ + { id = "src/main.rs", label = "main.rs", icon = "file-code" }, + }, "src") + + local toggled, clicked = 0, 0 + tree:on_toggle(function(expanded, id, loaded) toggled = toggled + 1 end) + tree:on_click(function(id) clicked = clicked + 1 end) + assert(toggled == 0 and clicked == 0) + assert(tree:remove("missing") == false) + "#, + ) + .expect("tree.lua"); + + let engine = ScriptEngine::new(api.clone()).expect("движок Lua"); + engine + .run_file(&script) + .expect("дерево должно наполняться из Lua"); + drop(engine); + + let panel = api.left_panel_handle(); + let panel = panel.borrow(); + let PanelBlock::Tree(tree) = &panel.blocks()[0]; + let rows = tree.borrow().rows(); + assert_eq!( + rows.iter() + .map(|row| (row.label.as_str(), row.depth)) + .collect::>(), + vec![("src", 0), ("main.rs", 1), ("Apple", 0), ("banana", 0)] + ); + + assert!(tree.borrow_mut().remove("src")); + assert_eq!( + tree.borrow() + .rows() + .iter() + .map(|row| row.label.as_str()) + .collect::>(), + vec!["Apple", "banana"] + ); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn lua_list_dir_returns_sorted_entries_with_paths() { + let workspace = Rc::new(RefCell::new(Workspace::default())); + let (api, _keymap) = install_api(workspace); + + let dir = temp_script_dir("list-dir"); + std::fs::create_dir(dir.join("sub")).expect("подкаталог"); + std::fs::write(dir.join("b.txt"), "b").expect("b.txt"); + std::fs::write(dir.join("A.txt"), "a").expect("A.txt"); + let path = dir.to_string_lossy().into_owned(); + let quoted = format!("{path:?}"); + + let scripts = temp_script_dir("list-dir-script"); + let script = scripts.join("list.lua"); + std::fs::write( + &script, + format!( + r#" + local entries = app:list_dir({quoted}) + assert(#entries == 3, #entries) + assert(entries[1].name == "A.txt") + assert(entries[2].name == "b.txt") + assert(entries[3].name == "sub") + assert(entries[1].is_dir == false) + assert(entries[3].is_dir == true) + assert(entries[1].id == {quoted} .. "/A.txt") + "# + ), + ) + .expect("list.lua"); + + let engine = ScriptEngine::new(api).expect("движок Lua"); + engine.run_file(&script).expect("app:list_dir"); + + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&scripts); +} + /// Временный каталог с уникальным именем для Lua-сценариев. fn temp_script_dir(prefix: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!( @@ -700,6 +855,104 @@ fn opened_panels_render_without_panicking(cx: &mut TestAppContext) { assert!(api.bottom_panel_handle().borrow().is_open()); } +#[gpui_kit::test] +fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) { + cx.update(gpui_kit::init); + + let (app, visual) = cx.add_window_view(|window, cx| MyApp::new(window, cx)); + + let api = AppApi::global(); + let tree = api.left_panel_handle().borrow_mut().add_tree(); + tree.borrow_mut() + .append( + "", + vec![TreeNode { + id: "src".to_owned(), + label: "src".to_owned(), + icon: Some("folder".to_owned()), + expandable: true, + expanded: true, + }], + ) + .unwrap(); + tree.borrow_mut() + .append( + "src", + vec![TreeNode { + id: "src/main.rs".to_owned(), + label: "main.rs".to_owned(), + icon: Some("file-code".to_owned()), + expandable: false, + expanded: false, + }], + ) + .unwrap(); + api.left_panel().open(); + + visual.update(|_window, cx| { + app.update(cx, |_, cx| cx.notify()); + }); + visual.run_until_parked(); + + assert!(api.left_panel_handle().borrow().is_open()); + assert_eq!(tree.borrow().rows().len(), 2); +} + +#[gpui_kit::test] +fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) { + cx.update(gpui_kit::init); + + let (app, visual) = cx.add_window_view(|window, cx| MyApp::new(window, cx)); + + let api = AppApi::global(); + let tree = api.left_panel_handle().borrow_mut().add_tree(); + tree.borrow_mut() + .append( + "", + vec![ + TreeNode { + id: "src".to_owned(), + label: "src".to_owned(), + icon: Some("folder".to_owned()), + expandable: true, + expanded: false, + }, + TreeNode { + id: "readme".to_owned(), + label: "README.md".to_owned(), + icon: Some("file".to_owned()), + expandable: false, + expanded: false, + }, + ], + ) + .unwrap(); + api.left_panel().open(); + tree.borrow_mut().focus("src"); + + visual.update(|_window, cx| { + app.update(cx, |_, cx| cx.notify()); + }); + visual.run_until_parked(); + assert_eq!(tree.borrow().selected(), Some("src")); + + // Панель закрыли и открыли снова: фокус должен вернуться на выбранную + // строку, иначе стрелка уйдёт в редактор. + api.left_panel().close(); + visual.update(|_window, cx| { + app.update(cx, |_, cx| cx.notify()); + }); + visual.run_until_parked(); + api.left_panel().open(); + visual.update(|_window, cx| { + app.update(cx, |_, cx| cx.notify()); + }); + visual.run_until_parked(); + + visual.simulate_keystrokes("down"); + assert_eq!(tree.borrow().selected(), Some("readme")); +} + #[gpui_kit::test] fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) { cx.update(gpui_kit::init); diff --git a/src/tree.rs b/src/tree.rs new file mode 100644 index 0000000..482af58 --- /dev/null +++ b/src/tree.rs @@ -0,0 +1,684 @@ +//! Абстрактное дерево для блоков панелей. +//! +//! Редактор ничего не знает о файловой системе: он хранит узлы дерева с +//! идентификатором, подписью, иконкой и флагом «раскрываемый», рисует их и +//! сообщает Lua о раскрытии и кликах. Какой именно смысл у узлов — файлы, +//! задачи, настройки — решает сценарий, который наполняет дерево через +//! [`TreeApi`](crate::api::TreeApi). +//! +//! Дерево хранится плоско: у каждого узла есть родитель и список детей. +//! [`TreeState::rows`] разворачивает видимые узлы в отсортированный по +//! подписи список, который и рисует вид. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use mlua::Function; + +use gpui_kit::{App, FocusHandle, ScrollHandle}; + +/// Общая ссылка на состояние блока дерева. +pub type TreeHandle = Rc>; + +/// Создаёт пустое дерево и общую ссылку на него. +pub fn new_tree() -> TreeHandle { + Rc::new(RefCell::new(TreeState::new())) +} + +/// Один узел, переданный из Lua. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TreeNode { + /// Условный идентификатор: путь файла или произвольный ключ. + pub id: String, + /// Текст, который видит пользователь. + pub label: String, + /// Имя иконки Lucide; `None` — иконка по умолчанию. + pub icon: Option, + /// Раскрываемая ли нода (папка или иной контейнер). + pub expandable: bool, + /// Раскрыта ли нода сейчас. + pub expanded: bool, +} + +/// Узел с сохранёнными связями между элементами дерева. +struct NodeRecord { + node: TreeNode, + parent: Option, + children: Vec, + /// В узел уже добавляли дочерние элементы хотя бы один раз. + loaded: bool, +} + +/// Строка, которую вид рисует в текущем кадре. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TreeRow { + pub id: String, + pub label: String, + pub icon: Option, + /// Уровень вложенности: 0 — корень. + pub depth: usize, + /// Нода раскрывается. + pub expandable: bool, + /// Нода раскрыта прямо сейчас. + pub expanded: bool, +} + +/// Что должен сделать вид после клика по узлу. +pub enum TreeAction { + /// Раскрыть или свернуть контейнер. + Toggle { + id: String, + expanded: bool, + /// Были ли в узел уже загружены дочерние элементы до этого клика. + loaded: bool, + callback: Option, + }, + /// Клик по конечной ноде. + Click { + id: String, + callback: Option, + }, +} + +/// Что вернула обработка клавиши дерева. +pub enum TreeKeyResult { + /// Клавиша не относится к навигации по дереву. + Ignored, + /// Клавиша обработана (выделение изменилось или осталось на краю). + Handled, + /// Нужно вызвать обработчик раскрытия или клика. + Action(TreeAction), +} + +/// Состояние абстрактного дерева. +pub struct TreeState { + nodes: HashMap, + roots: Vec, + toggle_callback: Option, + click_callback: Option, + /// Выбранная строка; сохраняется, пока панель закрыта. + selected: Option, + /// Фокус создаётся лениво: состояние дерева может появиться до первого + /// кадра, когда `App` ещё недоступен. + focus_handle: Option, + /// Следующий кадр должен передать фокус этому дереву. + focus_requested: bool, + /// Прокрутка списка: выбранная строка всегда прокручивается в видимую часть. + scroll: ScrollHandle, +} + +impl Default for TreeState { + fn default() -> Self { + Self { + nodes: HashMap::new(), + roots: Vec::new(), + toggle_callback: None, + click_callback: None, + selected: None, + focus_handle: None, + focus_requested: false, + scroll: ScrollHandle::default(), + } + } +} + +impl TreeState { + /// Создаёт пустое дерево. + pub fn new() -> Self { + Self::default() + } + + /// Обработчик раскрытия/сворачивания: `(expanded, id, loaded)`. + pub fn set_toggle_callback(&mut self, callback: Option) { + self.toggle_callback = callback; + } + + /// Обработчик клика по конечной ноде: `(id)`. + pub fn set_click_callback(&mut self, callback: Option) { + self.click_callback = callback; + } + + /// Идентификатор выбранной строки. + pub fn selected(&self) -> Option<&str> { + self.selected.as_deref() + } + + /// Раскрывает всех закрытых родителей `id`, выбирает узел и просит вид + /// передать ему фокус в следующем кадре. + /// + /// Возвращает `false`, если узла с таким `id` нет. + pub fn focus(&mut self, id: &str) -> bool { + if !self.nodes.contains_key(id) { + return false; + } + self.expand_ancestors(id); + self.selected = Some(id.to_owned()); + self.focus_requested = true; + if let Some(index) = self.rows().iter().position(|row| row.id == id) { + self.scroll.scroll_to_item(index); + } + true + } + + /// Обработчик прокрутки списка дерева. + pub fn scroll_handle(&self) -> ScrollHandle { + self.scroll.clone() + } + + /// Фокус-обработчик дерева; создаётся при первом кадре. + pub fn focus_handle(&mut self, cx: &App) -> FocusHandle { + self.focus_handle + .get_or_insert_with(|| cx.focus_handle()) + .clone() + } + + /// Забирает отложенный запрос фокуса. + pub fn take_focus_request(&mut self) -> bool { + std::mem::take(&mut self.focus_requested) + } + + /// Обрабатывает клавишу навигации по дереву. + pub fn handle_key(&mut self, key: &str) -> TreeKeyResult { + match key.trim().to_lowercase().as_str() { + "up" | "arrowup" => { + self.move_selection(-1); + TreeKeyResult::Handled + } + "down" | "arrowdown" => { + self.move_selection(1); + TreeKeyResult::Handled + } + "enter" | "return" | "kp_enter" => match self.activate_selected() { + Some(action) => TreeKeyResult::Action(action), + None => TreeKeyResult::Ignored, + }, + "left" | "arrowleft" => self.collapse_or_select_parent(), + "right" | "arrowright" => self.expand_or_select_child(), + _ => TreeKeyResult::Ignored, + } + } + + /// Добавляет узлы к `parent`; пустой `parent` — корень дерева. + /// + /// Существующие узлы не удаляются и не дублируются: повторная передача + /// того же `id` обновляет описание узла. После вызова контейнер считается + /// загруженным, поэтому при следующем раскрытии в обработчик придёт + /// `loaded = true`. + /// + /// # Errors + /// Возвращает текст ошибки для некорректного `id` (пустой идентификатор), + /// неизвестного родителя или попытки сделать узел потомком самого себя. + pub fn append(&mut self, parent: &str, nodes: Vec) -> Result<(), String> { + if !parent.is_empty() && !self.nodes.contains_key(parent) { + return Err(format!("родительская нода {parent:?} не найдена")); + } + for node in &nodes { + if node.id.is_empty() { + return Err("идентификатор ноды не может быть пустым".to_owned()); + } + if node.id == parent || self.would_cycle(&node.id, parent) { + return Err(format!( + "нода {:?} не может быть потомком самой себя", + node.id + )); + } + } + + for node in nodes { + let requested_parent = (!parent.is_empty()).then(|| parent.to_owned()); + let id = node.id.clone(); + + if self.nodes.contains_key(&id) { + let old_parent = self.nodes.get(&id).and_then(|record| record.parent.clone()); + // Повторный append обновляет описание узла, но не создаёт копию. + if let Some(record) = self.nodes.get_mut(&id) { + record.node = node; + } + if old_parent != requested_parent { + self.detach(&id); + self.attach(&requested_parent, &id); + } + } else { + self.nodes.insert( + id.clone(), + NodeRecord { + node, + parent: requested_parent.clone(), + children: Vec::new(), + loaded: false, + }, + ); + self.attach(&requested_parent, &id); + } + } + + if let Some(record) = self.nodes.get_mut(parent) { + record.loaded = true; + } + Ok(()) + } + + /// Удаляет узел и все его дочерние узлы рекурсивно. + /// + /// Возвращает `false`, если узла с таким `id` нет. + pub fn remove(&mut self, id: &str) -> bool { + if !self.nodes.contains_key(id) { + return false; + } + self.detach(id); + + let mut stack = vec![id.to_owned()]; + while let Some(current) = stack.pop() { + if let Some(record) = self.nodes.remove(¤t) { + stack.extend(record.children); + } + } + true + } + + /// Видимые строки дерева в порядке отрисовки. + /// + /// Дети каждого уровня сортируются по отображаемому тексту без учёта + /// регистра; дети свёрнутых узлов не показываются. + pub fn rows(&self) -> Vec { + let mut rows = Vec::new(); + for id in self.sorted(&self.roots) { + self.push_rows(&id, 0, &mut rows); + } + rows + } + + /// Обрабатывает клик по узлу и возвращает действие для вида. + pub fn activate(&mut self, id: &str) -> Option { + let toggle_callback = self.toggle_callback.clone(); + let click_callback = self.click_callback.clone(); + + // Раскрытие или открытие файла может изменить документы и заставить + // редактор забрать фокус; дерево возвращает его себе в следующем кадре. + self.focus_requested = true; + + let record = self.nodes.get_mut(id)?; + if record.node.expandable { + let loaded = record.loaded; + record.node.expanded = !record.node.expanded; + let expanded = record.node.expanded; + Some(TreeAction::Toggle { + id: id.to_owned(), + expanded, + loaded, + callback: toggle_callback, + }) + } else { + Some(TreeAction::Click { + id: id.to_owned(), + callback: click_callback, + }) + } + } + + /// Дочерние `id`, отсортированные по подписи: сначала раскрываемые узлы + /// (папки), затем конечные; внутри группы — по алфавиту без учёта регистра. + fn sorted(&self, ids: &[String]) -> Vec { + let mut entries = ids + .iter() + .filter_map(|id| { + self.nodes.get(id).map(|record| { + ( + record.node.expandable, + record.node.label.to_lowercase(), + id.clone(), + ) + }) + }) + .collect::>(); + entries.sort_by(|a, b| { + b.0.cmp(&a.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + entries.into_iter().map(|(_, _, id)| id).collect() + } + + /// Сдвигает выборку по видимым строкам. + fn move_selection(&mut self, delta: isize) { + let rows = self.rows(); + if rows.is_empty() { + self.selected = None; + return; + } + let current = self + .selected + .as_deref() + .and_then(|id| rows.iter().position(|row| row.id == id)); + let next = match current { + Some(ix) if delta < 0 => ix.saturating_sub(1), + Some(ix) => (ix + 1).min(rows.len() - 1), + None if delta < 0 => rows.len() - 1, + None => 0, + }; + self.selected = Some(rows[next].id.clone()); + self.scroll.scroll_to_item(next); + } + + /// Enter: раскрывает папку или вызывает обработчик клика конечной ноды. + fn activate_selected(&mut self) -> Option { + let id = self.selected.clone()?; + self.activate(&id) + } + + /// Стрелка влево: сворачивает папку, иначе выбирает родителя. + fn collapse_or_select_parent(&mut self) -> TreeKeyResult { + let Some(id) = self.selected.clone() else { + return TreeKeyResult::Ignored; + }; + let expanded = self + .nodes + .get(&id) + .is_some_and(|record| record.node.expandable && record.node.expanded); + if expanded { + return match self.activate(&id) { + Some(action) => TreeKeyResult::Action(action), + None => TreeKeyResult::Handled, + }; + } + if let Some(parent) = self.nodes.get(&id).and_then(|record| record.parent.clone()) { + self.selected = Some(parent.clone()); + if let Some(index) = self.rows().iter().position(|row| row.id == parent) { + self.scroll.scroll_to_item(index); + } + } + TreeKeyResult::Handled + } + + /// Стрелка вправо: раскрывает папку, иначе выбирает первого ребёнка. + fn expand_or_select_child(&mut self) -> TreeKeyResult { + let Some(id) = self.selected.clone() else { + return TreeKeyResult::Ignored; + }; + let Some(record) = self.nodes.get(&id) else { + return TreeKeyResult::Ignored; + }; + if record.node.expandable && !record.node.expanded { + return match self.activate(&id) { + Some(action) => TreeKeyResult::Action(action), + None => TreeKeyResult::Handled, + }; + } + if record.node.expanded + && let Some(child) = self.sorted(&record.children).first().cloned() + { + let index = self.rows().iter().position(|row| row.id == child); + self.selected = Some(child); + if let Some(index) = index { + self.scroll.scroll_to_item(index); + } + } + TreeKeyResult::Handled + } + + /// Раскрывает всех родителей узла, не трогая его собственное состояние. + fn expand_ancestors(&mut self, id: &str) { + let mut parents = Vec::new(); + let mut current = self.nodes.get(id).and_then(|record| record.parent.clone()); + while let Some(parent) = current { + current = self + .nodes + .get(&parent) + .and_then(|record| record.parent.clone()); + parents.push(parent); + } + for parent in parents { + if let Some(record) = self.nodes.get_mut(&parent) { + record.node.expanded = true; + } + } + } + + /// Добавляет `id` и его раскрытых потомков в плоский список. + fn push_rows(&self, id: &str, depth: usize, rows: &mut Vec) { + let Some(record) = self.nodes.get(id) else { + return; + }; + rows.push(TreeRow { + id: record.node.id.clone(), + label: record.node.label.clone(), + icon: record.node.icon.clone(), + depth, + expandable: record.node.expandable, + expanded: record.node.expanded, + }); + + if record.node.expanded { + for child in self.sorted(&record.children) { + self.push_rows(&child, depth + 1, rows); + } + } + } + + /// Проверяет, окажется ли `parent` внутри поддерева `node_id`. + fn would_cycle(&self, node_id: &str, parent: &str) -> bool { + let mut current = parent; + while !current.is_empty() { + if current == node_id { + return true; + } + match self + .nodes + .get(current) + .and_then(|record| record.parent.as_deref()) + { + Some(next) => current = next, + None => break, + } + } + false + } + + /// Присоединяет узел к родителю или корню. + fn attach(&mut self, parent: &Option, id: &str) { + match parent { + Some(parent) => { + if let Some(record) = self.nodes.get_mut(parent) { + record.children.push(id.to_owned()); + } + } + None => self.roots.push(id.to_owned()), + } + } + + /// Отсоединяет узел от родителя или корня. + fn detach(&mut self, id: &str) { + let Some(parent) = self.nodes.get(id).and_then(|record| record.parent.clone()) else { + self.roots.retain(|root| root != id); + return; + }; + if let Some(record) = self.nodes.get_mut(&parent) { + record.children.retain(|child| child != id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node(id: &str, label: &str, expandable: bool) -> TreeNode { + TreeNode { + id: id.to_owned(), + label: label.to_owned(), + icon: None, + expandable, + expanded: false, + } + } + + #[test] + fn rows_are_sorted_by_label_and_follow_expansion() { + let mut tree = TreeState::new(); + tree.append( + "", + vec![ + node("b", "banana", false), + node("folder", "Alpha", true), + node("a", "apple", false), + ], + ) + .unwrap(); + tree.append( + "folder", + vec![node("z", "zebra", false), node("c", "cat", false)], + ) + .unwrap(); + + // Свёрнутая папка не показывает детей. + let rows = tree.rows(); + assert_eq!( + rows.iter() + .map(|row| row.label.as_str()) + .collect::>(), + vec!["Alpha", "apple", "banana"] + ); + + assert!(matches!( + tree.activate("folder"), + Some(TreeAction::Toggle { + expanded: true, + loaded: true, + .. + }) + )); + let rows = tree.rows(); + assert_eq!( + rows.iter() + .map(|row| (row.label.as_str(), row.depth)) + .collect::>(), + vec![ + ("Alpha", 0), + ("cat", 1), + ("zebra", 1), + ("apple", 0), + ("banana", 0) + ] + ); + } + + #[test] + fn append_does_not_erase_and_marks_the_parent_loaded() { + let mut tree = TreeState::new(); + tree.append("", vec![node("src", "src", true)]).unwrap(); + assert!(matches!( + tree.activate("src"), + Some(TreeAction::Toggle { + expanded: true, + loaded: false, + .. + }) + )); + + // Сворачиваем, чтобы проверить флаг загрузки после append. + tree.activate("src"); + tree.append("src", vec![node("main.rs", "main.rs", false)]) + .unwrap(); + assert!(matches!( + tree.activate("src"), + Some(TreeAction::Toggle { + expanded: true, + loaded: true, + .. + }) + )); + assert_eq!(tree.rows().len(), 2); + } + + #[test] + fn remove_deletes_the_subtree() { + let mut tree = TreeState::new(); + tree.append( + "", + vec![node("src", "src", true), node("readme", "README.md", false)], + ) + .unwrap(); + tree.append("src", vec![node("ui", "ui", true)]).unwrap(); + tree.append("ui", vec![node("tree", "tree.rs", false)]) + .unwrap(); + + assert!(tree.remove("src")); + let rows = tree.rows(); + assert_eq!( + rows.iter() + .map(|row| row.label.as_str()) + .collect::>(), + vec!["README.md"] + ); + assert!(!tree.remove("src")); + } + + #[test] + fn focus_expands_ancestors_and_selects_the_node() { + let mut tree = TreeState::new(); + tree.append("", vec![node("src", "src", true)]).unwrap(); + tree.append("src", vec![node("ui", "ui", true)]).unwrap(); + tree.append("ui", vec![node("tree", "tree.rs", false)]) + .unwrap(); + + // Все родители свёрнуты — видна только корневая папка. + assert_eq!(tree.rows().len(), 1); + assert!(!tree.focus("missing")); + + assert!(tree.focus("tree")); + assert_eq!(tree.selected(), Some("tree")); + assert!(tree.take_focus_request()); + assert_eq!( + tree.rows() + .iter() + .map(|row| row.label.as_str()) + .collect::>(), + vec!["src", "ui", "tree.rs"] + ); + } + + #[test] + fn keyboard_moves_selection_and_activates_nodes() { + let mut tree = TreeState::new(); + tree.append( + "", + vec![node("src", "src", true), node("readme", "README.md", false)], + ) + .unwrap(); + + // Папки идут раньше файлов, поэтому первая строка — src. + assert!(matches!(tree.handle_key("down"), TreeKeyResult::Handled)); + assert_eq!(tree.selected(), Some("src")); + assert!(matches!(tree.handle_key("down"), TreeKeyResult::Handled)); + assert_eq!(tree.selected(), Some("readme")); + + assert!(matches!( + tree.handle_key("enter"), + TreeKeyResult::Action(TreeAction::Click { .. }) + )); + + assert!(matches!(tree.handle_key("up"), TreeKeyResult::Handled)); + assert_eq!(tree.selected(), Some("src")); + assert!(matches!( + tree.handle_key("right"), + TreeKeyResult::Action(TreeAction::Toggle { expanded: true, .. }) + )); + assert!(matches!( + tree.handle_key("left"), + TreeKeyResult::Action(TreeAction::Toggle { + expanded: false, + .. + }) + )); + } + + #[test] + fn invalid_parents_and_cycles_are_rejected() { + let mut tree = TreeState::new(); + assert!(tree.append("missing", vec![node("a", "a", false)]).is_err()); + tree.append("", vec![node("a", "a", true)]).unwrap(); + tree.append("a", vec![node("b", "b", true)]).unwrap(); + assert!(tree.append("b", vec![node("a", "a", true)]).is_err()); + } +}