//! Абстрактное дерево для блоков панелей. //! //! Редактор ничего не знает о файловой системе: он хранит узлы дерева с //! идентификатором, подписью, иконкой и флагом «раскрываемый», рисует их и //! сообщает 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()); } }