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
+134 -2
View File
@@ -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("<C-b>", function()
if app.left_panel:toggle() then
local selected = tree:selected()
if selected then
tree:focus(selected)
end
end
end)
```
## Подключение других Lua-файлов
Файлы подключаются из каталога сценариев:
+69 -2
View File
@@ -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("<C-s>", function() app:save() end)
-- app:map_key("<!Esc>", 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("<C-b>", 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("<C-q>", function() app:close() end)
app:map_key("<C-e>", function() app.right_panel:toggle() end)
app:map_key("<C-e>", function() app.left_panel:toggle() end)
app:map_key("<C-t>", function() app.bottom_panel:toggle() end)
app:open_tab()
+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()]),
+326 -43
View File
@@ -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<EditorState>,
/// Ревизия [`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<Self>,
) -> 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<Self>,
) -> 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<Self>) {
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<Self>) -> impl IntoElement {
/// панели не занимают места. В каждой панели рисуются добавленные
/// сценарием блоки; правая панель без блоков показывает статус LSP.
fn render_workspace(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let (left_open, left_size) = {
let panel = self.left_panel.borrow();
(panel.is_open(), panel.size())
@@ -1135,15 +1365,24 @@ 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()))
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(div().size_full().bg(cx.theme().sidebar).child(
self.render_panel_content(&self.bottom_panel, false, window, cx),
)),
);
let area = h_resizable("main-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<Self>) -> 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::<Vec<_>>(),
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 {
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)
.into_any_element()
} else {
let entity = entity.clone();
.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| {
// Иначе клик закроет вкладку и тут же переключит на неё
// соседнюю по уже устаревшему индексу.
.on_click({
let entity = entity.clone();
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);
}
entity.update(cx, |_, cx| {
AppApi::global().close_tab(index);
cx.notify();
});
})
.into_any_element()
};
}
}),
);
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() {
+43
View File
@@ -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<String>,
/// Язык подсветки синтаксиса.
///
/// `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<String>) -> 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#"//! Демонстрационный файл.
+91
View File
@@ -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<HashMap<String, IconName>> = 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<IconName> {
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);
}
}
+2
View File
@@ -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;
+28 -2
View File
@@ -4,21 +4,34 @@
//! и `app.bottom_panel`, а вид [`MyApp`](crate::app::MyApp) читает его при
//! отрисовке. Как и документы, состояние лежит в `Rc<RefCell<_>>`, чтобы вид и
//! API видели одни и те же панели.
//!
//! Панель состоит из блоков. Блок — это независимый виджет, который сценарий
//! создаёт через API панели (сейчас поддерживается [`PanelBlock::Tree`]).
use std::cell::RefCell;
use std::rc::Rc;
use crate::tree::{TreeHandle, new_tree};
/// Общая ссылка на состояние панели.
pub type PanelHandle = Rc<RefCell<PanelState>>;
/// Открыта ли панель и каким размером она раскрывается.
/// Блок, добавленный в панель.
#[derive(Clone)]
pub enum PanelBlock {
/// Абстрактное дерево, наполняемое из Lua.
Tree(TreeHandle),
}
/// Открыта ли панель, каким размером она раскрывается и какие блоки содержит.
///
/// Размер — начальный: после перетаскивания разделителя виджет
/// `ResizablePanelGroup` запоминает фактический размер панели сам.
#[derive(Clone, Copy, Debug)]
#[derive(Clone)]
pub struct PanelState {
visible: bool,
size: f32,
blocks: Vec<PanelBlock>,
}
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
}
}
+15 -2
View File
@@ -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<mlua::Value> {
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<mlua::Value> {
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,
+253
View File
@@ -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<RefCell<Workspace>>) -> (AppApi, Rc<RefCell<KeyMap>>) {
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<_>>(),
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<_>>(),
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);
+684
View File
@@ -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<RefCell<TreeState>>;
/// Создаёт пустое дерево и общую ссылку на него.
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<String>,
/// Раскрываемая ли нода (папка или иной контейнер).
pub expandable: bool,
/// Раскрыта ли нода сейчас.
pub expanded: bool,
}
/// Узел с сохранёнными связями между элементами дерева.
struct NodeRecord {
node: TreeNode,
parent: Option<String>,
children: Vec<String>,
/// В узел уже добавляли дочерние элементы хотя бы один раз.
loaded: bool,
}
/// Строка, которую вид рисует в текущем кадре.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeRow {
pub id: String,
pub label: String,
pub icon: Option<String>,
/// Уровень вложенности: 0 — корень.
pub depth: usize,
/// Нода раскрывается.
pub expandable: bool,
/// Нода раскрыта прямо сейчас.
pub expanded: bool,
}
/// Что должен сделать вид после клика по узлу.
pub enum TreeAction {
/// Раскрыть или свернуть контейнер.
Toggle {
id: String,
expanded: bool,
/// Были ли в узел уже загружены дочерние элементы до этого клика.
loaded: bool,
callback: Option<Function>,
},
/// Клик по конечной ноде.
Click {
id: String,
callback: Option<Function>,
},
}
/// Что вернула обработка клавиши дерева.
pub enum TreeKeyResult {
/// Клавиша не относится к навигации по дереву.
Ignored,
/// Клавиша обработана (выделение изменилось или осталось на краю).
Handled,
/// Нужно вызвать обработчик раскрытия или клика.
Action(TreeAction),
}
/// Состояние абстрактного дерева.
pub struct TreeState {
nodes: HashMap<String, NodeRecord>,
roots: Vec<String>,
toggle_callback: Option<Function>,
click_callback: Option<Function>,
/// Выбранная строка; сохраняется, пока панель закрыта.
selected: Option<String>,
/// Фокус создаётся лениво: состояние дерева может появиться до первого
/// кадра, когда `App` ещё недоступен.
focus_handle: Option<FocusHandle>,
/// Следующий кадр должен передать фокус этому дереву.
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<Function>) {
self.toggle_callback = callback;
}
/// Обработчик клика по конечной ноде: `(id)`.
pub fn set_click_callback(&mut self, callback: Option<Function>) {
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<TreeNode>) -> 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(&current) {
stack.extend(record.children);
}
}
true
}
/// Видимые строки дерева в порядке отрисовки.
///
/// Дети каждого уровня сортируются по отображаемому тексту без учёта
/// регистра; дети свёрнутых узлов не показываются.
pub fn rows(&self) -> Vec<TreeRow> {
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<TreeAction> {
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<String> {
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::<Vec<_>>();
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<TreeAction> {
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<TreeRow>) {
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<String>, 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<_>>(),
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<_>>(),
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<_>>(),
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<_>>(),
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());
}
}