diff options
Diffstat (limited to '0011-Add-file-explorer-and-tree-helper.patch')
-rw-r--r-- | 0011-Add-file-explorer-and-tree-helper.patch | 4729 |
1 files changed, 4729 insertions, 0 deletions
diff --git a/0011-Add-file-explorer-and-tree-helper.patch b/0011-Add-file-explorer-and-tree-helper.patch new file mode 100644 index 00000000..15cf1743 --- /dev/null +++ b/0011-Add-file-explorer-and-tree-helper.patch @@ -0,0 +1,4729 @@ +From b03e6400a70c691720044c477968b89239ee5f89 Mon Sep 17 00:00:00 2001 +From: JJ <git@toki.la> +Date: Sat, 15 Jul 2023 18:46:53 -0700 +Subject: [PATCH 1/2] Add file explorer and tree helper + +ref: https://github.com/helix-editor/helix/pull/5768 +--- + book/src/configuration.md | 9 + + book/src/keymap.md | 5 + + helix-term/src/commands.rs | 45 ++ + helix-term/src/keymap/default.rs | 1 + + helix-term/src/ui/editor.rs | 70 +- + helix-term/src/ui/explorer.rs | 751 +++++++++++++++++++ + helix-term/src/ui/mod.rs | 4 + + helix-term/src/ui/overlay.rs | 21 +- + helix-term/src/ui/prompt.rs | 4 + + helix-term/src/ui/tree.rs | 1209 ++++++++++++++++++++++++++++++ + helix-view/src/editor.rs | 39 + + helix-view/src/graphics.rs | 28 + + 12 files changed, 2159 insertions(+), 27 deletions(-) + create mode 100644 helix-term/src/ui/explorer.rs + create mode 100644 helix-term/src/ui/tree.rs + +diff --git a/book/src/configuration.md b/book/src/configuration.md +index b69bb486..bed20b28 100644 +--- a/book/src/configuration.md ++++ b/book/src/configuration.md +@@ -348,3 +348,12 @@ ### `[editor.soft-wrap]` Section + max-indent-retain = 0 + wrap-indicator = "" # set wrap-indicator to "" to hide it + ``` ++ ++### `[editor.explorer]` Section ++ ++Sets explorer side width and style. ++ ++| Key | Description | Default | ++| -------------- | ------------------------------------------- | ------- | ++| `column-width` | explorer side width | 30 | ++| `position` | explorer widget position, `left` or `right` | `left` | +diff --git a/book/src/keymap.md b/book/src/keymap.md +index 153f3b64..4e6e878d 100644 +--- a/book/src/keymap.md ++++ b/book/src/keymap.md +@@ -296,6 +296,7 @@ #### Space mode + | `R` | Replace selections by clipboard contents | `replace_selections_with_clipboard` | + | `/` | Global search in workspace folder | `global_search` | + | `?` | Open command palette | `command_palette` | ++| `e` | Reveal current file in explorer | `reveal_current_file` | + + > 💡 Global search displays results in a fuzzy picker, use `Space + '` to bring it back up after opening a file. + +@@ -451,3 +452,7 @@ ## Prompt + | `Tab` | Select next completion item | + | `BackTab` | Select previous completion item | + | `Enter` | Open selected | ++ ++## File explorer ++ ++Press `?` to see keymaps. Remapping currently not supported. +diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs +index dc8b5b5c..edeb419b 100644 +--- a/helix-term/src/commands.rs ++++ b/helix-term/src/commands.rs +@@ -488,6 +488,8 @@ pub fn doc(&self) -> &str { + record_macro, "Record macro", + replay_macro, "Replay macro", + command_palette, "Open command palette", ++ open_or_focus_explorer, "Open or focus explorer", ++ reveal_current_file, "Reveal current file in explorer", + ); + } + +@@ -2580,6 +2582,49 @@ fn file_picker_in_current_directory(cx: &mut Context) { + cx.push_layer(Box::new(overlaid(picker))); + } + ++fn open_or_focus_explorer(cx: &mut Context) { ++ cx.callback = Some(Box::new( ++ |compositor: &mut Compositor, cx: &mut compositor::Context| { ++ if let Some(editor) = compositor.find::<ui::EditorView>() { ++ match editor.explorer.as_mut() { ++ Some(explore) => explore.focus(), ++ None => match ui::Explorer::new(cx) { ++ Ok(explore) => editor.explorer = Some(explore), ++ Err(err) => cx.editor.set_error(format!("{}", err)), ++ }, ++ } ++ } ++ }, ++ )); ++} ++ ++fn reveal_file_in_explorer(cx: &mut Context, path: Option<PathBuf>) { ++ cx.callback = Some(Box::new( ++ |compositor: &mut Compositor, cx: &mut compositor::Context| { ++ if let Some(editor) = compositor.find::<ui::EditorView>() { ++ (|| match editor.explorer.as_mut() { ++ Some(explorer) => match path { ++ Some(path) => explorer.reveal_file(path), ++ None => explorer.reveal_current_file(cx), ++ }, ++ None => { ++ editor.explorer = Some(ui::Explorer::new(cx)?); ++ if let Some(explorer) = editor.explorer.as_mut() { ++ explorer.reveal_current_file(cx)?; ++ } ++ Ok(()) ++ } ++ })() ++ .unwrap_or_else(|err| cx.editor.set_error(err.to_string())) ++ } ++ }, ++ )); ++} ++ ++fn reveal_current_file(cx: &mut Context) { ++ reveal_file_in_explorer(cx, None) ++} ++ + fn buffer_picker(cx: &mut Context) { + let current = view!(cx.editor).doc; + +diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs +index f384c868..43d101fe 100644 +--- a/helix-term/src/keymap/default.rs ++++ b/helix-term/src/keymap/default.rs +@@ -275,6 +275,7 @@ pub fn default() -> HashMap<Mode, KeyTrie> { + "r" => rename_symbol, + "h" => select_references_to_symbol_under_cursor, + "?" => command_palette, ++ "e" => reveal_current_file, + }, + "z" => { "View" + "z" | "c" => align_view_center, +diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs +index 252aff0d..81f8fe22 100644 +--- a/helix-term/src/ui/editor.rs ++++ b/helix-term/src/ui/editor.rs +@@ -6,7 +6,7 @@ + keymap::{KeymapResult, Keymaps}, + ui::{ + document::{render_document, LinePos, TextRenderer, TranslatedPosition}, +- Completion, ProgressSpinners, ++ Completion, Explorer, ProgressSpinners, + }, + }; + +@@ -23,7 +23,7 @@ + }; + use helix_view::{ + document::{Mode, SavePoint, SCRATCH_BUFFER_NAME}, +- editor::{CompleteAction, CursorShapeConfig}, ++ editor::{CompleteAction, CursorShapeConfig, ExplorerPosition}, + graphics::{Color, CursorKind, Modifier, Rect, Style}, + input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}, + keyboard::{KeyCode, KeyModifiers}, +@@ -43,6 +43,7 @@ pub struct EditorView { + pub(crate) last_insert: (commands::MappableCommand, Vec<InsertEvent>), + pub(crate) completion: Option<Completion>, + spinners: ProgressSpinners, ++ pub(crate) explorer: Option<Explorer>, + } + + #[derive(Debug, Clone)] +@@ -71,6 +72,7 @@ pub fn new(keymaps: Keymaps) -> Self { + last_insert: (commands::MappableCommand::normal_mode, Vec::new()), + completion: None, + spinners: ProgressSpinners::default(), ++ explorer: None, + } + } + +@@ -1224,6 +1226,11 @@ fn handle_event( + event: &Event, + context: &mut crate::compositor::Context, + ) -> EventResult { ++ if let Some(explore) = self.explorer.as_mut() { ++ if let EventResult::Consumed(callback) = explore.handle_event(event, context) { ++ return EventResult::Consumed(callback); ++ } ++ } + let mut cx = commands::Context { + editor: context.editor, + count: None, +@@ -1380,6 +1387,8 @@ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + surface.set_style(area, cx.editor.theme.get("ui.background")); + let config = cx.editor.config(); + ++ let editor_area = area.clip_bottom(1); ++ + // check if bufferline should be rendered + use helix_view::editor::BufferLine; + let use_bufferline = match config.bufferline { +@@ -1388,15 +1397,43 @@ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + _ => false, + }; + +- // -1 for commandline and -1 for bufferline +- let mut editor_area = area.clip_bottom(1); +- if use_bufferline { +- editor_area = editor_area.clip_top(1); +- } ++ let editor_area = if use_bufferline { ++ editor_area.clip_top(1) ++ } else { ++ editor_area ++ }; ++ ++ let editor_area = if let Some(explorer) = &self.explorer { ++ let explorer_column_width = if explorer.is_opened() { ++ explorer.column_width().saturating_add(2) ++ } else { ++ 0 ++ }; ++ // For future developer: ++ // We should have a Dock trait that allows a component to dock to the top/left/bottom/right ++ // of another component. ++ match config.explorer.position { ++ ExplorerPosition::Left => editor_area.clip_left(explorer_column_width), ++ ExplorerPosition::Right => editor_area.clip_right(explorer_column_width), ++ } ++ } else { ++ editor_area ++ }; + + // if the terminal size suddenly changed, we need to trigger a resize + cx.editor.resize(editor_area); + ++ if let Some(explorer) = self.explorer.as_mut() { ++ if !explorer.is_focus() { ++ let area = if use_bufferline { ++ area.clip_top(1) ++ } else { ++ area ++ }; ++ explorer.render(area, surface, cx); ++ } ++ } ++ + if use_bufferline { + Self::render_bufferline(cx.editor, area.with_height(1), surface); + } +@@ -1475,9 +1512,28 @@ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + if let Some(completion) = self.completion.as_mut() { + completion.render(area, surface, cx); + } ++ ++ if let Some(explore) = self.explorer.as_mut() { ++ if explore.is_focus() { ++ let area = if use_bufferline { ++ area.clip_top(1) ++ } else { ++ area ++ }; ++ explore.render(area, surface, cx); ++ } ++ } + } + + fn cursor(&self, _area: Rect, editor: &Editor) -> (Option<Position>, CursorKind) { ++ if let Some(explore) = &self.explorer { ++ if explore.is_focus() { ++ let cursor = explore.cursor(_area, editor); ++ if cursor.0.is_some() { ++ return cursor; ++ } ++ } ++ } + match editor.cursor() { + // All block cursors are drawn manually + (pos, CursorKind::Block) => (pos, CursorKind::Hidden), +diff --git a/helix-term/src/ui/explorer.rs b/helix-term/src/ui/explorer.rs +new file mode 100644 +index 00000000..6df059b1 +--- /dev/null ++++ b/helix-term/src/ui/explorer.rs +@@ -0,0 +1,751 @@ ++use super::{Prompt, TreeOp, TreeView, TreeViewItem}; ++use crate::{ ++ compositor::{Component, Context, EventResult}, ++ ctrl, key, shift, ui, ++}; ++use anyhow::{bail, ensure, Result}; ++use helix_core::Position; ++use helix_view::{ ++ editor::{Action, ExplorerPosition}, ++ graphics::{CursorKind, Rect}, ++ info::Info, ++ input::{Event, KeyEvent}, ++ theme::Modifier, ++ Editor, ++}; ++use std::cmp::Ordering; ++use std::path::{Path, PathBuf}; ++use std::{borrow::Cow, fs::DirEntry}; ++use tui::{ ++ buffer::Buffer as Surface, ++ widgets::{Block, Borders, Widget}, ++}; ++ ++#[derive(PartialEq, Eq, PartialOrd, Ord, Debug, Clone, Copy)] ++enum FileType { ++ File, ++ Folder, ++ Root, ++} ++ ++#[derive(PartialEq, Eq, Debug, Clone)] ++struct FileInfo { ++ file_type: FileType, ++ path: PathBuf, ++} ++ ++impl FileInfo { ++ fn root(path: PathBuf) -> Self { ++ Self { ++ file_type: FileType::Root, ++ path, ++ } ++ } ++ ++ fn get_text(&self) -> Cow<'static, str> { ++ let text = match self.file_type { ++ FileType::Root => self.path.display().to_string(), ++ FileType::File | FileType::Folder => self ++ .path ++ .file_name() ++ .map_or("/".into(), |p| p.to_string_lossy().into_owned()), ++ }; ++ ++ #[cfg(test)] ++ let text = text.replace(std::path::MAIN_SEPARATOR, "/"); ++ ++ text.into() ++ } ++} ++ ++impl PartialOrd for FileInfo { ++ fn partial_cmp(&self, other: &Self) -> Option<Ordering> { ++ Some(self.cmp(other)) ++ } ++} ++ ++impl Ord for FileInfo { ++ fn cmp(&self, other: &Self) -> Ordering { ++ use FileType::*; ++ match (self.file_type, other.file_type) { ++ (Root, _) => return Ordering::Less, ++ (_, Root) => return Ordering::Greater, ++ _ => {} ++ }; ++ ++ if let (Some(p1), Some(p2)) = (self.path.parent(), other.path.parent()) { ++ if p1 == p2 { ++ match (self.file_type, other.file_type) { ++ (Folder, File) => return Ordering::Less, ++ (File, Folder) => return Ordering::Greater, ++ _ => {} ++ }; ++ } ++ } ++ self.path.cmp(&other.path) ++ } ++} ++ ++impl TreeViewItem for FileInfo { ++ type Params = State; ++ ++ fn get_children(&self) -> Result<Vec<Self>> { ++ match self.file_type { ++ FileType::Root | FileType::Folder => {} ++ _ => return Ok(vec![]), ++ }; ++ let ret: Vec<_> = std::fs::read_dir(&self.path)? ++ .filter_map(|entry| entry.ok()) ++ .filter_map(|entry| dir_entry_to_file_info(entry, &self.path)) ++ .collect(); ++ Ok(ret) ++ } ++ ++ fn name(&self) -> String { ++ self.get_text().to_string() ++ } ++ ++ fn is_parent(&self) -> bool { ++ matches!(self.file_type, FileType::Folder | FileType::Root) ++ } ++} ++ ++fn dir_entry_to_file_info(entry: DirEntry, path: &Path) -> Option<FileInfo> { ++ entry.metadata().ok().map(|meta| { ++ let file_type = match meta.is_dir() { ++ true => FileType::Folder, ++ false => FileType::File, ++ }; ++ FileInfo { ++ file_type, ++ path: path.join(entry.file_name()), ++ } ++ }) ++} ++ ++#[derive(Clone, Debug)] ++enum PromptAction { ++ CreateFileOrFolder, ++ RemoveFolder, ++ RemoveFile, ++ RenameFile, ++} ++ ++#[derive(Clone, Debug, Default)] ++struct State { ++ focus: bool, ++ open: bool, ++ current_root: PathBuf, ++ area_width: u16, ++} ++ ++impl State { ++ fn new(focus: bool, current_root: PathBuf) -> Self { ++ Self { ++ focus, ++ current_root, ++ open: true, ++ area_width: 0, ++ } ++ } ++} ++ ++struct ExplorerHistory { ++ tree: TreeView<FileInfo>, ++ current_root: PathBuf, ++} ++ ++pub struct Explorer { ++ tree: TreeView<FileInfo>, ++ history: Vec<ExplorerHistory>, ++ show_help: bool, ++ state: State, ++ prompt: Option<(PromptAction, Prompt)>, ++ #[allow(clippy::type_complexity)] ++ on_next_key: Option<Box<dyn FnMut(&mut Context, &mut Self, &KeyEvent) -> EventResult>>, ++ column_width: u16, ++} ++ ++impl Explorer { ++ pub fn new(cx: &mut Context) -> Result<Self> { ++ let current_root = std::env::current_dir() ++ .unwrap_or_else(|_| "./".into()) ++ .canonicalize()?; ++ Ok(Self { ++ tree: Self::new_tree_view(current_root.clone())?, ++ history: vec![], ++ show_help: false, ++ state: State::new(true, current_root), ++ prompt: None, ++ on_next_key: None, ++ column_width: cx.editor.config().explorer.column_width as u16, ++ }) ++ } ++ ++ #[cfg(test)] ++ fn from_path(root: PathBuf, column_width: u16) -> Result<Self> { ++ Ok(Self { ++ tree: Self::new_tree_view(root.clone())?, ++ history: vec![], ++ show_help: false, ++ state: State::new(true, root), ++ prompt: None, ++ on_next_key: None, ++ column_width, ++ }) ++ } ++ ++ fn new_tree_view(root: PathBuf) -> Result<TreeView<FileInfo>> { ++ let root = FileInfo::root(root); ++ Ok(TreeView::build_tree(root)?.with_enter_fn(Self::toggle_current)) ++ } ++ ++ fn push_history(&mut self, tree_view: TreeView<FileInfo>, current_root: PathBuf) { ++ self.history.push(ExplorerHistory { ++ tree: tree_view, ++ current_root, ++ }); ++ const MAX_HISTORY_SIZE: usize = 20; ++ Vec::truncate(&mut self.history, MAX_HISTORY_SIZE) ++ } ++ ++ fn change_root(&mut self, root: PathBuf) -> Result<()> { ++ if self.state.current_root.eq(&root) { ++ return Ok(()); ++ } ++ let tree = Self::new_tree_view(root.clone())?; ++ let old_tree = std::mem::replace(&mut self.tree, tree); ++ self.push_history(old_tree, self.state.current_root.clone()); ++ self.state.current_root = root; ++ Ok(()) ++ } ++ ++ pub fn reveal_file(&mut self, path: PathBuf) -> Result<()> { ++ let current_root = &self.state.current_root.canonicalize()?; ++ let current_path = &path.canonicalize()?; ++ let segments = { ++ let stripped = match current_path.strip_prefix(current_root) { ++ Ok(stripped) => Ok(stripped), ++ Err(_) => { ++ let parent = path.parent().ok_or_else(|| { ++ anyhow::anyhow!("Failed get parent of '{}'", current_path.to_string_lossy()) ++ })?; ++ self.change_root(parent.into())?; ++ current_path ++ .strip_prefix(parent.canonicalize()?) ++ .map_err(|_| { ++ anyhow::anyhow!( ++ "Failed to strip prefix (parent) '{}' from '{}'", ++ parent.to_string_lossy(), ++ current_path.to_string_lossy() ++ ) ++ }) ++ } ++ }?; ++ ++ stripped ++ .components() ++ .map(|c| c.as_os_str().to_string_lossy().to_string()) ++ .collect::<Vec<_>>() ++ }; ++ self.tree.reveal_item(segments)?; ++ Ok(()) ++ } ++ ++ pub fn reveal_current_file(&mut self, cx: &mut Context) -> Result<()> { ++ self.focus(); ++ let current_document_path = doc!(cx.editor).path().cloned(); ++ match current_document_path { ++ None => Ok(()), ++ Some(current_path) => self.reveal_file(current_path), ++ } ++ } ++ ++ pub fn focus(&mut self) { ++ self.state.focus = true; ++ self.state.open = true; ++ } ++ ++ fn unfocus(&mut self) { ++ self.state.focus = false; ++ } ++ ++ fn close(&mut self) { ++ self.state.focus = false; ++ self.state.open = false; ++ } ++ ++ pub fn is_focus(&self) -> bool { ++ self.state.focus ++ } ++ ++ fn new_create_file_or_folder_prompt(&mut self, cx: &mut Context) -> Result<()> { ++ let folder_path = self.nearest_folder()?; ++ self.prompt = Some(( ++ PromptAction::CreateFileOrFolder, ++ Prompt::new( ++ format!( ++ " New file or folder (ends with '{}'): ", ++ std::path::MAIN_SEPARATOR ++ ) ++ .into(), ++ None, ++ ui::completers::none, ++ |_, _, _| {}, ++ ) ++ .with_line(format!("{}/", folder_path.to_string_lossy()), cx.editor), ++ )); ++ Ok(()) ++ } ++ ++ fn nearest_folder(&self) -> Result<PathBuf> { ++ let current = self.tree.current()?.item(); ++ if current.is_parent() { ++ Ok(current.path.to_path_buf()) ++ } else { ++ let parent_path = current.path.parent().ok_or_else(|| { ++ anyhow::anyhow!(format!( ++ "Unable to get parent path of '{}'", ++ current.path.to_string_lossy() ++ )) ++ })?; ++ Ok(parent_path.to_path_buf()) ++ } ++ } ++ ++ fn new_remove_prompt(&mut self) -> Result<()> { ++ let item = self.tree.current()?.item(); ++ match item.file_type { ++ FileType::Folder => self.new_remove_folder_prompt(), ++ FileType::File => self.new_remove_file_prompt(), ++ FileType::Root => bail!("Root is not removable"), ++ } ++ } ++ ++ fn new_rename_prompt(&mut self, cx: &mut Context) -> Result<()> { ++ let path = self.tree.current_item()?.path.clone(); ++ self.prompt = Some(( ++ PromptAction::RenameFile, ++ Prompt::new( ++ " Rename to ".into(), ++ None, ++ ui::completers::none, ++ |_, _, _| {}, ++ ) ++ .with_line(path.to_string_lossy().to_string(), cx.editor), ++ )); ++ Ok(()) ++ } ++ ++ fn new_remove_file_prompt(&mut self) -> Result<()> { ++ let item = self.tree.current_item()?; ++ ensure!( ++ item.path.is_file(), ++ "The path '{}' is not a file", ++ item.path.to_string_lossy() ++ ); ++ self.prompt = Some(( ++ PromptAction::RemoveFile, ++ Prompt::new( ++ format!(" Delete file: '{}'? y/N: ", item.path.display()).into(), ++ None, ++ ui::completers::none, ++ |_, _, _| {}, ++ ), ++ )); ++ Ok(()) ++ } ++ ++ fn new_remove_folder_prompt(&mut self) -> Result<()> { ++ let item = self.tree.current_item()?; ++ ensure!( ++ item.path.is_dir(), ++ "The path '{}' is not a folder", ++ item.path.to_string_lossy() ++ ); ++ ++ self.prompt = Some(( ++ PromptAction::RemoveFolder, ++ Prompt::new( ++ format!(" Delete folder: '{}'? y/N: ", item.path.display()).into(), ++ None, ++ ui::completers::none, ++ |_, _, _| {}, ++ ), ++ )); ++ Ok(()) ++ } ++ ++ fn toggle_current(item: &mut FileInfo, cx: &mut Context, state: &mut State) -> TreeOp { ++ (|| -> Result<TreeOp> { ++ if item.path == Path::new("") { ++ return Ok(TreeOp::Noop); ++ } ++ let meta = std::fs::metadata(&item.path)?; ++ if meta.is_file() { ++ cx.editor.open(&item.path, Action::Replace)?; ++ state.focus = false; ++ return Ok(TreeOp::Noop); ++ } ++ ++ if item.path.is_dir() { ++ return Ok(TreeOp::GetChildsAndInsert); ++ } ++ ++ Err(anyhow::anyhow!("Unknown file type: {:?}", meta.file_type())) ++ })() ++ .unwrap_or_else(|err| { ++ cx.editor.set_error(format!("{err}")); ++ TreeOp::Noop ++ }) ++ } ++ ++ fn render_tree( ++ &mut self, ++ area: Rect, ++ prompt_area: Rect, ++ surface: &mut Surface, ++ cx: &mut Context, ++ ) { ++ self.tree.render(area, prompt_area, surface, cx); ++ } ++ ++ fn render_embed( ++ &mut self, ++ area: Rect, ++ surface: &mut Surface, ++ cx: &mut Context, ++ position: &ExplorerPosition, ++ ) { ++ if !self.state.open { ++ return; ++ } ++ let width = area.width.min(self.column_width + 2); ++ ++ self.state.area_width = area.width; ++ ++ let side_area = match position { ++ ExplorerPosition::Left => Rect { width, ..area }, ++ ExplorerPosition::Right => Rect { ++ x: area.width - width, ++ width, ++ ..area ++ }, ++ } ++ .clip_bottom(1); ++ let background = cx.editor.theme.get("ui.background"); ++ surface.clear_with(side_area, background); ++ ++ let prompt_area = area.clip_top(side_area.height); ++ ++ let list_area = match position { ++ ExplorerPosition::Left => { ++ render_block(side_area.clip_left(1), surface, Borders::RIGHT).clip_bottom(1) ++ } ++ ExplorerPosition::Right => { ++ render_block(side_area.clip_right(1), surface, Borders::LEFT).clip_bottom(1) ++ } ++ }; ++ self.render_tree(list_area, prompt_area, surface, cx); ++ ++ { ++ let statusline = if self.is_focus() { ++ cx.editor.theme.get("ui.statusline") ++ } else { ++ cx.editor.theme.get("ui.statusline.inactive") ++ }; ++ let area = side_area.clip_top(list_area.height); ++ let area = match position { ++ ExplorerPosition::Left => area.clip_right(1), ++ ExplorerPosition::Right => area.clip_left(1), ++ }; ++ surface.clear_with(area, statusline); ++ ++ let title_style = cx.editor.theme.get("ui.text"); ++ let title_style = if self.is_focus() { ++ title_style.add_modifier(Modifier::BOLD) ++ } else { ++ title_style ++ }; ++ surface.set_stringn( ++ area.x, ++ area.y, ++ if self.is_focus() { ++ " EXPLORER: press ? for help" ++ } else { ++ " EXPLORER" ++ }, ++ area.width.into(), ++ title_style, ++ ); ++ } ++ ++ if self.is_focus() && self.show_help { ++ let help_area = match position { ++ ExplorerPosition::Left => area, ++ ExplorerPosition::Right => area.clip_right(list_area.width.saturating_add(2)), ++ }; ++ self.render_help(help_area, surface, cx); ++ } ++ ++ if let Some((_, prompt)) = self.prompt.as_mut() { ++ prompt.render_prompt(prompt_area, surface, cx) ++ } ++ } ++ ++ fn render_help(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { ++ Info::new( ++ "Explorer", ++ &[ ++ ("?", "Toggle help"), ++ ("a", "Add file/folder"), ++ ("r", "Rename file/folder"), ++ ("d", "Delete file"), ++ ("B", "Change root to parent folder"), ++ ("]", "Change root to current folder"), ++ ("[", "Go to previous root"), ++ ("+, =", "Increase size"), ++ ("-, _", "Decrease size"), ++ ("q", "Close"), ++ ] ++ .into_iter() ++ .chain(ui::tree::tree_view_help().into_iter()) ++ .collect::<Vec<_>>(), ++ ) ++ .render(area, surface, cx) ++ } ++ ++ fn handle_prompt_event(&mut self, event: &KeyEvent, cx: &mut Context) -> EventResult { ++ let result = (|| -> Result<EventResult> { ++ let (action, mut prompt) = match self.prompt.take() { ++ Some((action, p)) => (action, p), ++ _ => return Ok(EventResult::Ignored(None)), ++ }; ++ let line = prompt.line(); ++ ++ let current_item_path = self.tree.current_item()?.path.clone(); ++ match (&action, event) { ++ (PromptAction::CreateFileOrFolder, key!(Enter)) => { ++ if line.ends_with(std::path::MAIN_SEPARATOR) { ++ self.new_folder(line)? ++ } else { ++ self.new_file(line)? ++ } ++ } ++ (PromptAction::RemoveFolder, key) => { ++ if let key!('y') = key { ++ close_documents(current_item_path, cx)?; ++ self.remove_folder()?; ++ } ++ } ++ (PromptAction::RemoveFile, key) => { ++ if let key!('y') = key { ++ close_documents(current_item_path, cx)?; ++ self.remove_file()?; ++ } ++ } ++ (PromptAction::RenameFile, key!(Enter)) => { ++ close_documents(current_item_path, cx)?; ++ self.rename_current(line)?; ++ } ++ (_, key!(Esc) | ctrl!('c')) => {} ++ _ => { ++ prompt.handle_event(&Event::Key(*event), cx); ++ self.prompt = Some((action, prompt)); ++ } ++ } ++ Ok(EventResult::Consumed(None)) ++ })(); ++ match result { ++ Ok(event_result) => event_result, ++ Err(err) => { ++ cx.editor.set_error(err.to_string()); ++ EventResult::Consumed(None) ++ } ++ } ++ } ++ ++ fn new_file(&mut self, path: &str) -> Result<()> { ++ let path = helix_core::path::get_normalized_path(&PathBuf::from(path)); ++ if let Some(parent) = path.parent() { ++ std::fs::create_dir_all(parent)?; ++ } ++ let mut fd = std::fs::OpenOptions::new(); ++ fd.create_new(true).write(true).open(&path)?; ++ self.tree.refresh()?; ++ self.reveal_file(path) ++ } ++ ++ fn new_folder(&mut self, path: &str) -> Result<()> { ++ let path = helix_core::path::get_normalized_path(&PathBuf::from(path)); ++ std::fs::create_dir_all(&path)?; ++ self.tree.refresh()?; ++ self.reveal_file(path) ++ } ++ ++ fn toggle_help(&mut self) { ++ self.show_help = !self.show_help ++ } ++ ++ fn go_to_previous_root(&mut self) { ++ if let Some(history) = self.history.pop() { ++ self.tree = history.tree; ++ self.state.current_root = history.current_root ++ } ++ } ++ ++ fn change_root_to_current_folder(&mut self) -> Result<()> { ++ self.change_root(self.tree.current_item()?.path.clone()) ++ } ++ ++ fn change_root_parent_folder(&mut self) -> Result<()> { ++ if let Some(parent) = self.state.current_root.parent() { ++ let path = parent.to_path_buf(); ++ self.change_root(path) ++ } else { ++ Ok(()) ++ } ++ } ++ ++ pub fn is_opened(&self) -> bool { ++ self.state.open ++ } ++ ++ pub fn column_width(&self) -> u16 { ++ self.column_width ++ } ++ ++ fn increase_size(&mut self) { ++ const EDITOR_MIN_WIDTH: u16 = 10; ++ self.column_width = std::cmp::min( ++ self.state.area_width.saturating_sub(EDITOR_MIN_WIDTH), ++ self.column_width.saturating_add(1), ++ ) ++ } ++ ++ fn decrease_size(&mut self) { ++ self.column_width = self.column_width.saturating_sub(1) ++ } ++ ++ fn rename_current(&mut self, line: &String) -> Result<()> { ++ let item = self.tree.current_item()?; ++ let path = PathBuf::from(line); ++ if let Some(parent) = path.parent() { ++ std::fs::create_dir_all(parent)?; ++ } ++ std::fs::rename(&item.path, &path)?; ++ self.tree.refresh()?; ++ self.reveal_file(path) ++ } ++ ++ fn remove_folder(&mut self) -> Result<()> { ++ let item = self.tree.current_item()?; ++ std::fs::remove_dir_all(&item.path)?; ++ self.tree.refresh() ++ } ++ ++ fn remove_file(&mut self) -> Result<()> { ++ let item = self.tree.current_item()?; ++ std::fs::remove_file(&item.path)?; ++ self.tree.refresh() ++ } ++} ++ ++fn close_documents(current_item_path: PathBuf, cx: &mut Context) -> Result<()> { ++ let ids = cx ++ .editor ++ .documents ++ .iter() ++ .filter_map(|(id, doc)| { ++ if doc.path()?.starts_with(¤t_item_path) { ++ Some(*id) ++ } else { ++ None ++ } ++ }) ++ .collect::<Vec<_>>(); ++ ++ for id in ids { ++ cx.editor.close_document(id, true)?; ++ } ++ Ok(()) ++} ++ ++impl Component for Explorer { ++ /// Process input events, return true if handled. ++ fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { ++ if self.tree.prompting() { ++ return self.tree.handle_event(event, cx, &mut self.state); ++ } ++ let key_event = match event { ++ Event::Key(event) => event, ++ Event::Resize(..) => return EventResult::Consumed(None), ++ _ => return EventResult::Ignored(None), ++ }; ++ if !self.is_focus() { ++ return EventResult::Ignored(None); ++ } ++ if let Some(mut on_next_key) = self.on_next_key.take() { ++ return on_next_key(cx, self, key_event); ++ } ++ ++ if let EventResult::Consumed(c) = self.handle_prompt_event(key_event, cx) { ++ return EventResult::Consumed(c); ++ } ++ ++ (|| -> Result<()> { ++ match key_event { ++ key!(Esc) => self.unfocus(), ++ key!('q') => self.close(), ++ key!('?') => self.toggle_help(), ++ key!('a') => self.new_create_file_or_folder_prompt(cx)?, ++ shift!('B') => self.change_root_parent_folder()?, ++ key!(']') => self.change_root_to_current_folder()?, ++ key!('[') => self.go_to_previous_root(), ++ key!('d') => self.new_remove_prompt()?, ++ key!('r') => self.new_rename_prompt(cx)?, ++ key!('-') | key!('_') => self.decrease_size(), ++ key!('+') | key!('=') => self.increase_size(), ++ _ => { ++ self.tree ++ .handle_event(&Event::Key(*key_event), cx, &mut self.state); ++ } ++ }; ++ Ok(()) ++ })() ++ .unwrap_or_else(|err| cx.editor.set_error(format!("{err}"))); ++ ++ EventResult::Consumed(None) ++ } ++ ++ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { ++ if area.width < 10 || area.height < 5 { ++ cx.editor.set_error("explorer render area is too small"); ++ return; ++ } ++ let config = &cx.editor.config().explorer; ++ let position = config.position; ++ self.render_embed(area, surface, cx, &position); ++ } ++ ++ fn cursor(&self, area: Rect, editor: &Editor) -> (Option<Position>, CursorKind) { ++ if let Some(prompt) = self ++ .prompt ++ .as_ref() ++ .map(|(_, prompt)| prompt) ++ .or_else(|| self.tree.prompt()) ++ { ++ let (x, y) = (area.x, area.y + area.height.saturating_sub(1)); ++ prompt.cursor(Rect::new(x, y, area.width, 1), editor) ++ } else { ++ (None, CursorKind::Hidden) ++ } ++ } ++} ++ ++fn render_block(area: Rect, surface: &mut Surface, borders: Borders) -> Rect { ++ let block = Block::default().borders(borders); ++ let inner = block.inner(area); ++ block.render(area, surface); ++ inner ++} +diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs +index 3359155d..eb6464f9 100644 +--- a/helix-term/src/ui/mod.rs ++++ b/helix-term/src/ui/mod.rs +@@ -1,6 +1,7 @@ + mod completion; + mod document; + pub(crate) mod editor; ++mod explorer; + mod fuzzy_match; + mod info; + pub mod lsp; +@@ -13,12 +14,14 @@ + mod spinner; + mod statusline; + mod text; ++mod tree; + + use crate::compositor::{Component, Compositor}; + use crate::filter_picker_entry; + use crate::job::{self, Callback}; + pub use completion::{Completion, CompletionItem}; + pub use editor::EditorView; ++pub use explorer::Explorer; + pub use markdown::Markdown; + pub use menu::Menu; + pub use picker::{DynamicPicker, FileLocation, Picker}; +@@ -26,6 +29,7 @@ + pub use prompt::{Prompt, PromptEvent}; + pub use spinner::{ProgressSpinners, Spinner}; + pub use text::Text; ++pub use tree::{TreeOp, TreeView, TreeViewItem}; + + use helix_core::regex::Regex; + use helix_core::regex::RegexBuilder; +diff --git a/helix-term/src/ui/overlay.rs b/helix-term/src/ui/overlay.rs +index ff184d40..7b9c0391 100644 +--- a/helix-term/src/ui/overlay.rs ++++ b/helix-term/src/ui/overlay.rs +@@ -19,26 +19,7 @@ pub struct Overlay<T> { + pub fn overlaid<T>(content: T) -> Overlay<T> { + Overlay { + content, +- calc_child_size: Box::new(|rect: Rect| clip_rect_relative(rect.clip_bottom(2), 90, 90)), +- } +-} +- +-fn clip_rect_relative(rect: Rect, percent_horizontal: u8, percent_vertical: u8) -> Rect { +- fn mul_and_cast(size: u16, factor: u8) -> u16 { +- ((size as u32) * (factor as u32) / 100).try_into().unwrap() +- } +- +- let inner_w = mul_and_cast(rect.width, percent_horizontal); +- let inner_h = mul_and_cast(rect.height, percent_vertical); +- +- let offset_x = rect.width.saturating_sub(inner_w) / 2; +- let offset_y = rect.height.saturating_sub(inner_h) / 2; +- +- Rect { +- x: rect.x + offset_x, +- y: rect.y + offset_y, +- width: inner_w, +- height: inner_h, ++ calc_child_size: Box::new(|rect: Rect| rect.overlaid()), + } + } + +diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs +index a9ccfb73..e033112d 100644 +--- a/helix-term/src/ui/prompt.rs ++++ b/helix-term/src/ui/prompt.rs +@@ -94,6 +94,10 @@ pub fn with_line(mut self, line: String, editor: &Editor) -> Self { + self + } + ++ pub fn prompt(&self) -> &str { ++ &self.prompt.as_ref() ++ } ++ + pub fn line(&self) -> &String { + &self.line + } +diff --git a/helix-term/src/ui/tree.rs b/helix-term/src/ui/tree.rs +new file mode 100644 +index 00000000..68783be8 +--- /dev/null ++++ b/helix-term/src/ui/tree.rs +@@ -0,0 +1,1209 @@ ++use std::cmp::Ordering; ++ ++use anyhow::Result; ++use helix_view::theme::Modifier; ++ ++use crate::{ ++ compositor::{Component, Context, EventResult}, ++ ctrl, key, shift, ui, ++}; ++use helix_core::movement::Direction; ++use helix_view::{ ++ graphics::Rect, ++ input::{Event, KeyEvent}, ++}; ++use tui::buffer::Buffer as Surface; ++ ++use super::Prompt; ++ ++pub trait TreeViewItem: Sized + Ord { ++ type Params: Default; ++ ++ fn name(&self) -> String; ++ fn is_parent(&self) -> bool; ++ ++ fn filter(&self, s: &str) -> bool { ++ self.name().to_lowercase().contains(&s.to_lowercase()) ++ } ++ ++ fn get_children(&self) -> Result<Vec<Self>>; ++} ++ ++fn tree_item_cmp<T: TreeViewItem>(item1: &T, item2: &T) -> Ordering { ++ T::cmp(item1, item2) ++} ++ ++fn vec_to_tree<T: TreeViewItem>(mut items: Vec<T>) -> Vec<Tree<T>> { ++ items.sort(); ++ index_elems( ++ 0, ++ items ++ .into_iter() ++ .map(|item| Tree::new(item, vec![])) ++ .collect(), ++ ) ++} ++ ++pub enum TreeOp { ++ Noop, ++ GetChildsAndInsert, ++} ++ ++#[derive(Debug, PartialEq, Eq)] ++pub struct Tree<T> { ++ item: T, ++ parent_index: Option<usize>, ++ index: usize, ++ children: Vec<Self>, ++ ++ /// Why do we need this property? ++ /// Can't we just use `!children.is_empty()`? ++ /// ++ /// Because we might have for example an open folder that is empty, ++ /// and user just added a new file under that folder, ++ /// and the user refreshes the whole tree. ++ /// ++ /// Without `open`, we will not refresh any node without children, ++ /// and thus the folder still appears empty after refreshing. ++ is_opened: bool, ++} ++ ++impl<T: Clone> Clone for Tree<T> { ++ fn clone(&self) -> Self { ++ Self { ++ item: self.item.clone(), ++ index: self.index, ++ children: self.children.clone(), ++ is_opened: self.is_opened, ++ parent_index: self.parent_index, ++ } ++ } ++} ++ ++#[derive(Clone)] ++struct TreeIter<'a, T> { ++ current_index_forward: usize, ++ current_index_reverse: isize, ++ tree: &'a Tree<T>, ++} ++ ++impl<'a, T> Iterator for TreeIter<'a, T> { ++ type Item = &'a Tree<T>; ++ ++ fn next(&mut self) -> Option<Self::Item> { ++ let index = self.current_index_forward; ++ if index > self.tree.len().saturating_sub(1) { ++ None ++ } else { ++ self.current_index_forward = self.current_index_forward.saturating_add(1); ++ self.tree.get(index) ++ } ++ } ++ ++ fn size_hint(&self) -> (usize, Option<usize>) { ++ (self.tree.len(), Some(self.tree.len())) ++ } ++} ++ ++impl<'a, T> DoubleEndedIterator for TreeIter<'a, T> { ++ fn next_back(&mut self) -> Option<Self::Item> { ++ let index = self.current_index_reverse; ++ if index < 0 { ++ None ++ } else { ++ self.current_index_reverse = self.current_index_reverse.saturating_sub(1); ++ self.tree.get(index as usize) ++ } ++ } ++} ++ ++impl<'a, T> ExactSizeIterator for TreeIter<'a, T> {} ++ ++impl<T: TreeViewItem> Tree<T> { ++ fn open(&mut self) -> Result<()> { ++ if self.item.is_parent() { ++ self.children = self.get_children()?; ++ self.is_opened = true; ++ } ++ Ok(()) ++ } ++ ++ fn close(&mut self) { ++ self.is_opened = false; ++ self.children = vec![]; ++ } ++ ++ fn refresh(&mut self) -> Result<()> { ++ if !self.is_opened { ++ return Ok(()); ++ } ++ let latest_children = self.get_children()?; ++ let filtered = std::mem::take(&mut self.children) ++ .into_iter() ++ // Remove children that does not exists in latest_children ++ .filter(|tree| { ++ latest_children ++ .iter() ++ .any(|child| tree.item.name().eq(&child.item.name())) ++ }) ++ .map(|mut tree| { ++ tree.refresh()?; ++ Ok(tree) ++ }) ++ .collect::<Result<Vec<_>>>()?; ++ ++ // Add new children ++ let new_nodes = latest_children ++ .into_iter() ++ .filter(|child| { ++ !filtered ++ .iter() ++ .any(|child_| child.item.name().eq(&child_.item.name())) ++ }) ++ .collect::<Vec<_>>(); ++ ++ self.children = filtered.into_iter().chain(new_nodes).collect(); ++ ++ self.sort(); ++ ++ self.regenerate_index(); ++ ++ Ok(()) ++ } ++ ++ fn get_children(&self) -> Result<Vec<Tree<T>>> { ++ Ok(vec_to_tree(self.item.get_children()?)) ++ } ++ ++ fn sort(&mut self) { ++ self.children ++ .sort_by(|a, b| tree_item_cmp(&a.item, &b.item)) ++ } ++} ++ ++impl<T> Tree<T> { ++ pub fn new(item: T, children: Vec<Tree<T>>) -> Self { ++ let is_opened = !children.is_empty(); ++ Self { ++ item, ++ index: 0, ++ parent_index: None, ++ children: index_elems(0, children), ++ is_opened, ++ } ++ } ++ ++ fn iter(&self) -> TreeIter<T> { ++ TreeIter { ++ tree: self, ++ current_index_forward: 0, ++ current_index_reverse: (self.len() - 1) as isize, ++ } ++ } ++ ++ /// Find an element in the tree with given `predicate`. ++ /// `start_index` is inclusive if direction is `Forward`. ++ /// `start_index` is exclusive if direction is `Backward`. ++ fn find<F>(&self, start_index: usize, direction: Direction, predicate: F) -> Option<usize> ++ where ++ F: Clone + FnMut(&Tree<T>) -> bool, ++ { ++ match direction { ++ Direction::Forward => match self ++ .iter() ++ .skip(start_index) ++ .position(predicate.clone()) ++ .map(|index| index + start_index) ++ { ++ Some(index) => Some(index), ++ None => self.iter().position(predicate), ++ }, ++ ++ Direction::Backward => match self.iter().take(start_index).rposition(predicate.clone()) ++ { ++ Some(index) => Some(index), ++ None => self.iter().rposition(predicate), ++ }, ++ } ++ } ++ ++ pub fn item(&self) -> &T { ++ &self.item ++ } ++ ++ fn get(&self, index: usize) -> Option<&Tree<T>> { ++ if self.index == index { ++ Some(self) ++ } else { ++ self.children.iter().find_map(|elem| elem.get(index)) ++ } ++ } ++ ++ fn get_mut(&mut self, index: usize) -> Option<&mut Tree<T>> { ++ if self.index == index { ++ Some(self) ++ } else { ++ self.children ++ .iter_mut() ++ .find_map(|elem| elem.get_mut(index)) ++ } ++ } ++ ++ fn len(&self) -> usize { ++ (1_usize).saturating_add(self.children.iter().map(|elem| elem.len()).sum()) ++ } ++ ++ fn regenerate_index(&mut self) { ++ let items = std::mem::take(&mut self.children); ++ self.children = index_elems(0, items); ++ } ++} ++ ++#[derive(Clone, Debug)] ++struct SavedView { ++ selected: usize, ++ winline: usize, ++} ++ ++pub struct TreeView<T: TreeViewItem> { ++ tree: Tree<T>, ++ ++ search_prompt: Option<(Direction, Prompt)>, ++ ++ search_str: String, ++ ++ /// Selected item idex ++ selected: usize, ++ ++ backward_jumps: Vec<usize>, ++ forward_jumps: Vec<usize>, ++ ++ saved_view: Option<SavedView>, ++ ++ /// For implementing vertical scroll ++ winline: usize, ++ ++ /// For implementing horizontal scoll ++ column: usize, ++ ++ /// For implementing horizontal scoll ++ max_len: usize, ++ count: usize, ++ tree_symbol_style: String, ++ ++ #[allow(clippy::type_complexity)] ++ pre_render: Option<Box<dyn Fn(&mut Self, Rect) + 'static>>, ++ ++ #[allow(clippy::type_complexity)] ++ on_opened_fn: Option<Box<dyn FnMut(&mut T, &mut Context, &mut T::Params) -> TreeOp + 'static>>, ++ ++ #[allow(clippy::type_complexity)] ++ on_folded_fn: Option<Box<dyn FnMut(&mut T, &mut Context, &mut T::Params) + 'static>>, ++ ++ #[allow(clippy::type_complexity)] ++ on_next_key: Option<Box<dyn FnMut(&mut Context, &mut Self, &KeyEvent) -> Result<()>>>, ++} ++ ++impl<T: TreeViewItem> TreeView<T> { ++ pub fn build_tree(root: T) -> Result<Self> { ++ let children = root.get_children()?; ++ let items = vec_to_tree(children); ++ Ok(Self { ++ tree: Tree::new(root, items), ++ selected: 0, ++ backward_jumps: vec![], ++ forward_jumps: vec![], ++ saved_view: None, ++ winline: 0, ++ column: 0, ++ max_len: 0, ++ count: 0, ++ tree_symbol_style: "ui.text".into(), ++ pre_render: None, ++ on_opened_fn: None, ++ on_folded_fn: None, ++ on_next_key: None, ++ search_prompt: None, ++ search_str: "".into(), ++ }) ++ } ++ ++ pub fn with_enter_fn<F>(mut self, f: F) -> Self ++ where ++ F: FnMut(&mut T, &mut Context, &mut T::Params) -> TreeOp + 'static, ++ { ++ self.on_opened_fn = Some(Box::new(f)); ++ self ++ } ++ ++ pub fn with_folded_fn<F>(mut self, f: F) -> Self ++ where ++ F: FnMut(&mut T, &mut Context, &mut T::Params) + 'static, ++ { ++ self.on_folded_fn = Some(Box::new(f)); ++ self ++ } ++ ++ pub fn tree_symbol_style(mut self, style: String) -> Self { ++ self.tree_symbol_style = style; ++ self ++ } ++ ++ /// Reveal item in the tree based on the given `segments`. ++ /// ++ /// The name of the root should be excluded. ++ /// ++ /// Example `segments`: ++ /// ++ /// vec!["helix-term", "src", "ui", "tree.rs"] ++ /// ++ pub fn reveal_item(&mut self, segments: Vec<String>) -> Result<()> { ++ // Expand the tree ++ let root = self.tree.item.name(); ++ segments.iter().fold( ++ Ok(&mut self.tree), ++ |current_tree, segment| match current_tree { ++ Err(err) => Err(err), ++ Ok(current_tree) => { ++ match current_tree ++ .children ++ .iter_mut() ++ .find(|tree| tree.item.name().eq(segment)) ++ { ++ Some(tree) => { ++ if !tree.is_opened { ++ tree.open()?; ++ } ++ Ok(tree) ++ } ++ None => Err(anyhow::anyhow!(format!( ++ "Unable to find path: '{}'. current_segment = '{segment}'. current_root = '{root}'", ++ segments.join("/"), ++ ))), ++ } ++ } ++ }, ++ )?; ++ ++ // Locate the item ++ self.regenerate_index(); ++ self.set_selected( ++ segments ++ .iter() ++ .fold(&self.tree, |tree, segment| { ++ tree.children ++ .iter() ++ .find(|tree| tree.item.name().eq(segment)) ++ .expect("Should be unreachable") ++ }) ++ .index, ++ ); ++ ++ self.align_view_center(); ++ Ok(()) ++ } ++ ++ fn align_view_center(&mut self) { ++ self.pre_render = Some(Box::new(|tree, area| { ++ tree.winline = area.height as usize / 2 ++ })) ++ } ++ ++ fn align_view_top(&mut self) { ++ self.winline = 0 ++ } ++ ++ fn align_view_bottom(&mut self) { ++ self.pre_render = Some(Box::new(|tree, area| tree.winline = area.height as usize)) ++ } ++ ++ fn regenerate_index(&mut self) { ++ self.tree.regenerate_index(); ++ } ++ ++ fn move_to_parent(&mut self) -> Result<()> { ++ if let Some(parent) = self.current_parent()? { ++ let index = parent.index; ++ self.set_selected(index) ++ } ++ Ok(()) ++ } ++ ++ fn move_to_children(&mut self) -> Result<()> { ++ let current = self.current_mut()?; ++ if current.is_opened { ++ self.set_selected(self.selected + 1); ++ Ok(()) ++ } else { ++ current.open()?; ++ if !current.children.is_empty() { ++ self.set_selected(self.selected + 1); ++ self.regenerate_index(); ++ } ++ Ok(()) ++ } ++ } ++ ++ pub fn refresh(&mut self) -> Result<()> { ++ self.tree.refresh()?; ++ self.set_selected(self.selected); ++ Ok(()) ++ } ++ ++ fn move_to_first_line(&mut self) { ++ self.move_up(usize::MAX / 2) ++ } ++ ++ fn move_to_last_line(&mut self) { ++ self.move_down(usize::MAX / 2) ++ } ++ ++ fn move_leftmost(&mut self) { ++ self.move_left(usize::MAX / 2); ++ } ++ ++ fn move_rightmost(&mut self) { ++ self.move_right(usize::MAX / 2) ++ } ++ ++ fn restore_saved_view(&mut self) -> Result<()> { ++ if let Some(saved_view) = self.saved_view.take() { ++ self.selected = saved_view.selected; ++ self.winline = saved_view.winline; ++ self.refresh() ++ } else { ++ Ok(()) ++ } ++ } ++ ++ pub fn prompt(&self) -> Option<&Prompt> { ++ if let Some((_, prompt)) = self.search_prompt.as_ref() { ++ Some(prompt) ++ } else { ++ None ++ } ++ } ++} ++ ++pub fn tree_view_help() -> Vec<(&'static str, &'static str)> { ++ vec![ ++ ("o, Enter", "Open/Close"), ++ ("j, down, C-n", "Down"), ++ ("k, up, C-p", "Up"), ++ ("h, left", "Go to parent"), ++ ("l, right", "Expand"), ++ ("J", "Go to next sibling"), ++ ("K", "Go to previous sibling"), ++ ("H", "Go to first child"), ++ ("L", "Go to last child"), ++ ("R", "Refresh"), ++ ("/", "Search"), ++ ("n", "Go to next search match"), ++ ("N", "Go to previous search match"), ++ ("gh, Home", "Scroll to the leftmost"), ++ ("gl, End", "Scroll to the rightmost"), ++ ("C-o", "Jump backward"), ++ ("C-i, Tab", "Jump forward"), ++ ("C-d", "Half page down"), ++ ("C-u", "Half page up"), ++ ("PageDown", "Full page down"), ++ ("PageUp", "Full page up"), ++ ("zt", "Align view top"), ++ ("zz", "Align view center"), ++ ("zb", "Align view bottom"), ++ ("gg", "Go to first line"), ++ ("ge", "Go to last line"), ++ ] ++} ++ ++impl<T: TreeViewItem> TreeView<T> { ++ pub fn on_enter( ++ &mut self, ++ cx: &mut Context, ++ params: &mut T::Params, ++ selected_index: usize, ++ ) -> Result<()> { ++ let selected_item = self.get_mut(selected_index)?; ++ if selected_item.is_opened { ++ selected_item.close(); ++ self.regenerate_index(); ++ return Ok(()); ++ } ++ ++ if let Some(mut on_open_fn) = self.on_opened_fn.take() { ++ let mut f = || -> Result<()> { ++ let current = self.current_mut()?; ++ match on_open_fn(&mut current.item, cx, params) { ++ TreeOp::GetChildsAndInsert => { ++ if let Err(err) = current.open() { ++ cx.editor.set_error(format!("{err}")) ++ } ++ } ++ TreeOp::Noop => {} ++ }; ++ Ok(()) ++ }; ++ f()?; ++ self.regenerate_index(); ++ self.on_opened_fn = Some(on_open_fn); ++ }; ++ Ok(()) ++ } ++ ++ fn set_search_str(&mut self, s: String) { ++ self.search_str = s; ++ self.saved_view = None; ++ } ++ ++ fn saved_view(&self) -> SavedView { ++ self.saved_view.clone().unwrap_or(SavedView { ++ selected: self.selected, ++ winline: self.winline, ++ }) ++ } ++ ++ fn search_next(&mut self, s: &str) { ++ let saved_view = self.saved_view(); ++ let skip = std::cmp::max(2, saved_view.selected + 1); ++ self.set_selected( ++ self.tree ++ .find(skip, Direction::Forward, |e| e.item.filter(s)) ++ .unwrap_or(saved_view.selected), ++ ); ++ } ++ ++ fn search_previous(&mut self, s: &str) { ++ let saved_view = self.saved_view(); ++ let take = saved_view.selected; ++ self.set_selected( ++ self.tree ++ .find(take, Direction::Backward, |e| e.item.filter(s)) ++ .unwrap_or(saved_view.selected), ++ ); ++ } ++ ++ fn move_to_next_search_match(&mut self) { ++ self.search_next(&self.search_str.clone()) ++ } ++ ++ fn move_to_previous_next_match(&mut self) { ++ self.search_previous(&self.search_str.clone()) ++ } ++ ++ pub fn move_down(&mut self, rows: usize) { ++ self.set_selected(self.selected.saturating_add(rows)) ++ } ++ ++ fn set_selected(&mut self, selected: usize) { ++ let previous_selected = self.selected; ++ self.set_selected_without_history(selected); ++ if previous_selected.abs_diff(selected) > 1 { ++ self.backward_jumps.push(previous_selected) ++ } ++ } ++ ++ fn set_selected_without_history(&mut self, selected: usize) { ++ let selected = selected.clamp(0, self.tree.len().saturating_sub(1)); ++ if selected > self.selected { ++ // Move down ++ self.winline = selected.min( ++ self.winline ++ .saturating_add(selected.saturating_sub(self.selected)), ++ ); ++ } else { ++ // Move up ++ self.winline = selected.min( ++ self.winline ++ .saturating_sub(self.selected.saturating_sub(selected)), ++ ); ++ } ++ self.selected = selected ++ } ++ ++ fn jump_backward(&mut self) { ++ if let Some(index) = self.backward_jumps.pop() { ++ self.forward_jumps.push(self.selected); ++ self.set_selected_without_history(index); ++ } ++ } ++ ++ fn jump_forward(&mut self) { ++ if let Some(index) = self.forward_jumps.pop() { ++ self.set_selected(index) ++ } ++ } ++ ++ pub fn move_up(&mut self, rows: usize) { ++ self.set_selected(self.selected.saturating_sub(rows)) ++ } ++ ++ fn move_to_next_sibling(&mut self) -> Result<()> { ++ if let Some(parent) = self.current_parent()? { ++ if let Some(local_index) = parent ++ .children ++ .iter() ++ .position(|child| child.index == self.selected) ++ { ++ if let Some(next_sibling) = parent.children.get(local_index.saturating_add(1)) { ++ self.set_selected(next_sibling.index) ++ } ++ } ++ } ++ Ok(()) ++ } ++ ++ fn move_to_previous_sibling(&mut self) -> Result<()> { ++ if let Some(parent) = self.current_parent()? { ++ if let Some(local_index) = parent ++ .children ++ .iter() ++ .position(|child| child.index == self.selected) ++ { ++ if let Some(next_sibling) = parent.children.get(local_index.saturating_sub(1)) { ++ self.set_selected(next_sibling.index) ++ } ++ } ++ } ++ Ok(()) ++ } ++ ++ fn move_to_last_sibling(&mut self) -> Result<()> { ++ if let Some(parent) = self.current_parent()? { ++ if let Some(last) = parent.children.last() { ++ self.set_selected(last.index) ++ } ++ } ++ Ok(()) ++ } ++ ++ fn move_to_first_sibling(&mut self) -> Result<()> { ++ if let Some(parent) = self.current_parent()? { ++ if let Some(last) = parent.children.first() { ++ self.set_selected(last.index) ++ } ++ } ++ Ok(()) ++ } ++ ++ fn move_left(&mut self, cols: usize) { ++ self.column = self.column.saturating_sub(cols); ++ } ++ ++ fn move_right(&mut self, cols: usize) { ++ self.pre_render = Some(Box::new(move |tree, area| { ++ let max_scroll = tree ++ .max_len ++ .saturating_sub(area.width as usize) ++ .saturating_add(1); ++ tree.column = max_scroll.min(tree.column + cols); ++ })); ++ } ++ ++ fn move_down_half_page(&mut self) { ++ self.pre_render = Some(Box::new(|tree, area| { ++ tree.move_down((area.height / 2) as usize); ++ })); ++ } ++ ++ fn move_up_half_page(&mut self) { ++ self.pre_render = Some(Box::new(|tree, area| { ++ tree.move_up((area.height / 2) as usize); ++ })); ++ } ++ ++ fn move_down_page(&mut self) { ++ self.pre_render = Some(Box::new(|tree, area| { ++ tree.move_down((area.height) as usize); ++ })); ++ } ++ ++ fn move_up_page(&mut self) { ++ self.pre_render = Some(Box::new(|tree, area| { ++ tree.move_up((area.height) as usize); ++ })); ++ } ++ ++ fn save_view(&mut self) { ++ self.saved_view = Some(SavedView { ++ selected: self.selected, ++ winline: self.winline, ++ }) ++ } ++ ++ fn get(&self, index: usize) -> Result<&Tree<T>> { ++ self.tree.get(index).ok_or_else(|| { ++ anyhow::anyhow!("Programming error: TreeView.get: index {index} is out of bound") ++ }) ++ } ++ ++ fn get_mut(&mut self, index: usize) -> Result<&mut Tree<T>> { ++ self.tree.get_mut(index).ok_or_else(|| { ++ anyhow::anyhow!("Programming error: TreeView.get_mut: index {index} is out of bound") ++ }) ++ } ++ ++ pub fn current(&self) -> Result<&Tree<T>> { ++ self.get(self.selected) ++ } ++ ++ pub fn current_mut(&mut self) -> Result<&mut Tree<T>> { ++ self.get_mut(self.selected) ++ } ++ ++ fn current_parent(&self) -> Result<Option<&Tree<T>>> { ++ if let Some(parent_index) = self.current()?.parent_index { ++ Ok(Some(self.get(parent_index)?)) ++ } else { ++ Ok(None) ++ } ++ } ++ ++ pub fn current_item(&self) -> Result<&T> { ++ Ok(&self.current()?.item) ++ } ++ ++ pub fn winline(&self) -> usize { ++ self.winline ++ } ++} ++ ++#[derive(Clone)] ++struct RenderedLine { ++ indent: String, ++ content: String, ++ selected: bool, ++ is_ancestor_of_current_item: bool, ++} ++struct RenderTreeParams<'a, T> { ++ tree: &'a Tree<T>, ++ prefix: &'a String, ++ level: usize, ++ selected: usize, ++} ++ ++fn render_tree<T: TreeViewItem>( ++ RenderTreeParams { ++ tree, ++ prefix, ++ level, ++ selected, ++ }: RenderTreeParams<T>, ++) -> Vec<RenderedLine> { ++ let indent = if level > 0 { ++ let indicator = if tree.item().is_parent() { ++ if tree.is_opened { ++ "⏷" ++ } else { ++ "⏵" ++ } ++ } else { ++ " " ++ }; ++ format!("{}{} ", prefix, indicator) ++ } else { ++ "".to_string() ++ }; ++ let name = tree.item.name(); ++ let head = RenderedLine { ++ indent, ++ selected: selected == tree.index, ++ is_ancestor_of_current_item: selected != tree.index && tree.get(selected).is_some(), ++ content: name, ++ }; ++ let prefix = format!("{}{}", prefix, if level == 0 { "" } else { " " }); ++ vec![head] ++ .into_iter() ++ .chain(tree.children.iter().flat_map(|elem| { ++ render_tree(RenderTreeParams { ++ tree: elem, ++ prefix: &prefix, ++ level: level + 1, ++ selected, ++ }) ++ })) ++ .collect() ++} ++ ++impl<T: TreeViewItem + Clone> TreeView<T> { ++ pub fn render( ++ &mut self, ++ area: Rect, ++ prompt_area: Rect, ++ surface: &mut Surface, ++ cx: &mut Context, ++ ) { ++ let style = cx.editor.theme.get(&self.tree_symbol_style); ++ if let Some((_, prompt)) = self.search_prompt.as_mut() { ++ prompt.render_prompt(prompt_area, surface, cx) ++ } ++ ++ let ancestor_style = { ++ let style = cx.editor.theme.get("ui.selection"); ++ let fg = cx.editor.theme.get("ui.text").fg; ++ match (style.fg, fg) { ++ (None, Some(fg)) => style.fg(fg), ++ _ => style, ++ } ++ }; ++ ++ let iter = self.render_lines(area).into_iter().enumerate(); ++ ++ for (index, line) in iter { ++ let area = Rect::new(area.x, area.y.saturating_add(index as u16), area.width, 1); ++ let indent_len = line.indent.chars().count() as u16; ++ surface.set_stringn( ++ area.x, ++ area.y, ++ line.indent.clone(), ++ indent_len as usize, ++ style, ++ ); ++ ++ let style = if line.selected { ++ style.add_modifier(Modifier::REVERSED) ++ } else { ++ style ++ }; ++ let x = area.x.saturating_add(indent_len); ++ surface.set_stringn( ++ x, ++ area.y, ++ line.content.clone(), ++ area.width ++ .saturating_sub(indent_len) ++ .saturating_sub(1) ++ .into(), ++ if line.is_ancestor_of_current_item { ++ ancestor_style ++ } else { ++ style ++ }, ++ ); ++ } ++ } ++ ++ #[cfg(test)] ++ pub fn render_to_string(&mut self, area: Rect) -> String { ++ let lines = self.render_lines(area); ++ lines ++ .into_iter() ++ .map(|line| { ++ let name = if line.selected { ++ format!("({})", line.content) ++ } else if line.is_ancestor_of_current_item { ++ format!("[{}]", line.content) ++ } else { ++ line.content ++ }; ++ format!("{}{}", line.indent, name) ++ }) ++ .collect::<Vec<_>>() ++ .join("\n") ++ } ++ ++ fn render_lines(&mut self, area: Rect) -> Vec<RenderedLine> { ++ if let Some(pre_render) = self.pre_render.take() { ++ pre_render(self, area); ++ } ++ ++ self.winline = self.winline.min(area.height.saturating_sub(1) as usize); ++ let skip = self.selected.saturating_sub(self.winline); ++ let params = RenderTreeParams { ++ tree: &self.tree, ++ prefix: &"".to_string(), ++ level: 0, ++ selected: self.selected, ++ }; ++ ++ let lines = render_tree(params); ++ ++ self.max_len = lines ++ .iter() ++ .map(|line| { ++ line.indent ++ .chars() ++ .count() ++ .saturating_add(line.content.chars().count()) ++ }) ++ .max() ++ .unwrap_or(0); ++ ++ let max_width = area.width as usize; ++ ++ let take = area.height as usize; ++ ++ struct RetainAncestorResult { ++ skipped_ancestors: Vec<RenderedLine>, ++ remaining_lines: Vec<RenderedLine>, ++ } ++ fn retain_ancestors(lines: Vec<RenderedLine>, skip: usize) -> RetainAncestorResult { ++ if skip == 0 { ++ return RetainAncestorResult { ++ skipped_ancestors: vec![], ++ remaining_lines: lines, ++ }; ++ } ++ if let Some(line) = lines.get(0) { ++ if line.selected { ++ return RetainAncestorResult { ++ skipped_ancestors: vec![], ++ remaining_lines: lines, ++ }; ++ } ++ } ++ ++ let selected_index = lines.iter().position(|line| line.selected); ++ let skip = match selected_index { ++ None => skip, ++ Some(selected_index) => skip.min(selected_index), ++ }; ++ let (skipped, remaining) = lines.split_at(skip.min(lines.len().saturating_sub(1))); ++ ++ let skipped_ancestors = skipped ++ .iter() ++ .cloned() ++ .filter(|line| line.is_ancestor_of_current_item) ++ .collect::<Vec<_>>(); ++ ++ let result = retain_ancestors(remaining.to_vec(), skipped_ancestors.len()); ++ RetainAncestorResult { ++ skipped_ancestors: skipped_ancestors ++ .into_iter() ++ .chain(result.skipped_ancestors.into_iter()) ++ .collect(), ++ remaining_lines: result.remaining_lines, ++ } ++ } ++ ++ let RetainAncestorResult { ++ skipped_ancestors, ++ remaining_lines, ++ } = retain_ancestors(lines, skip); ++ ++ let max_ancestors_len = take.saturating_sub(1); ++ ++ // Skip furthest ancestors ++ let skipped_ancestors = skipped_ancestors ++ .into_iter() ++ .rev() ++ .take(max_ancestors_len) ++ .rev() ++ .collect::<Vec<_>>(); ++ ++ let skipped_ancestors_len = skipped_ancestors.len(); ++ ++ skipped_ancestors ++ .into_iter() ++ .chain( ++ remaining_lines ++ .into_iter() ++ .take(take.saturating_sub(skipped_ancestors_len)), ++ ) ++ // Horizontal scroll ++ .map(|line| { ++ let skip = self.column; ++ let indent_len = line.indent.chars().count(); ++ RenderedLine { ++ indent: if line.indent.is_empty() { ++ "".to_string() ++ } else { ++ line.indent ++ .chars() ++ .skip(skip) ++ .take(max_width) ++ .collect::<String>() ++ }, ++ content: line ++ .content ++ .chars() ++ .skip(skip.saturating_sub(indent_len)) ++ .take((max_width.saturating_sub(indent_len)).clamp(0, line.content.len())) ++ .collect::<String>(), ++ ..line ++ } ++ }) ++ .collect() ++ } ++ ++ #[cfg(test)] ++ pub fn handle_events( ++ &mut self, ++ events: &str, ++ cx: &mut Context, ++ params: &mut T::Params, ++ ) -> Result<()> { ++ use helix_view::input::parse_macro; ++ ++ for event in parse_macro(events)? { ++ self.handle_event(&Event::Key(event), cx, params); ++ } ++ Ok(()) ++ } ++ ++ pub fn handle_event( ++ &mut self, ++ event: &Event, ++ cx: &mut Context, ++ params: &mut T::Params, ++ ) -> EventResult { ++ let key_event = match event { ++ Event::Key(event) => event, ++ Event::Resize(..) => return EventResult::Consumed(None), ++ _ => return EventResult::Ignored(None), ++ }; ++ (|| -> Result<EventResult> { ++ if let Some(mut on_next_key) = self.on_next_key.take() { ++ on_next_key(cx, self, key_event)?; ++ return Ok(EventResult::Consumed(None)); ++ } ++ ++ if let EventResult::Consumed(c) = self.handle_search_event(key_event, cx) { ++ return Ok(EventResult::Consumed(c)); ++ } ++ ++ let count = std::mem::replace(&mut self.count, 0); ++ ++ match key_event { ++ key!(i @ '0'..='9') => { ++ self.count = i.to_digit(10).unwrap_or(0) as usize + count * 10 ++ } ++ shift!('J') => self.move_to_next_sibling()?, ++ shift!('K') => self.move_to_previous_sibling()?, ++ shift!('H') => self.move_to_first_sibling()?, ++ shift!('L') => self.move_to_last_sibling()?, ++ key!('j') | key!(Down) | ctrl!('n') => self.move_down(1.max(count)), ++ key!('k') | key!(Up) | ctrl!('p') => self.move_up(1.max(count)), ++ key!('h') | key!(Left) => self.move_to_parent()?, ++ key!('l') | key!(Right) => self.move_to_children()?, ++ key!(Enter) | key!('o') => self.on_enter(cx, params, self.selected)?, ++ ctrl!('d') => self.move_down_half_page(), ++ ctrl!('u') => self.move_up_half_page(), ++ key!('z') => { ++ self.on_next_key = Some(Box::new(|_, tree, event| { ++ match event { ++ key!('z') => tree.align_view_center(), ++ key!('t') => tree.align_view_top(), ++ key!('b') => tree.align_view_bottom(), ++ _ => {} ++ }; ++ Ok(()) ++ })); ++ } ++ key!('g') => { ++ self.on_next_key = Some(Box::new(|_, tree, event| { ++ match event { ++ key!('g') => tree.move_to_first_line(), ++ key!('e') => tree.move_to_last_line(), ++ key!('h') => tree.move_leftmost(), ++ key!('l') => tree.move_rightmost(), ++ _ => {} ++ }; ++ Ok(()) ++ })); ++ } ++ key!('/') => self.new_search_prompt(Direction::Forward), ++ key!('n') => self.move_to_next_search_match(), ++ shift!('N') => self.move_to_previous_next_match(), ++ key!(PageDown) => self.move_down_page(), ++ key!(PageUp) => self.move_up_page(), ++ shift!('R') => { ++ if let Err(error) = self.refresh() { ++ cx.editor.set_error(error.to_string()) ++ } ++ } ++ key!(Home) => self.move_leftmost(), ++ key!(End) => self.move_rightmost(), ++ ctrl!('o') => self.jump_backward(), ++ ctrl!('i') | key!(Tab) => self.jump_forward(), ++ _ => return Ok(EventResult::Ignored(None)), ++ }; ++ Ok(EventResult::Consumed(None)) ++ })() ++ .unwrap_or_else(|err| { ++ cx.editor.set_error(format!("{err}")); ++ EventResult::Consumed(None) ++ }) ++ } ++ ++ fn handle_search_event(&mut self, event: &KeyEvent, cx: &mut Context) -> EventResult { ++ if let Some((direction, mut prompt)) = self.search_prompt.take() { ++ match event { ++ key!(Enter) => { ++ self.set_search_str(prompt.line().clone()); ++ EventResult::Consumed(None) ++ } ++ key!(Esc) => { ++ if let Err(err) = self.restore_saved_view() { ++ cx.editor.set_error(format!("{err}")) ++ } ++ EventResult::Consumed(None) ++ } ++ _ => { ++ let event = prompt.handle_event(&Event::Key(*event), cx); ++ let line = prompt.line(); ++ match direction { ++ Direction::Forward => { ++ self.search_next(line); ++ } ++ Direction::Backward => self.search_previous(line), ++ } ++ self.search_prompt = Some((direction, prompt)); ++ event ++ } ++ } ++ } else { ++ EventResult::Ignored(None) ++ } ++ } ++ ++ fn new_search_prompt(&mut self, direction: Direction) { ++ self.save_view(); ++ self.search_prompt = Some(( ++ direction, ++ Prompt::new("search: ".into(), None, ui::completers::none, |_, _, _| {}), ++ )) ++ } ++ ++ pub fn prompting(&self) -> bool { ++ self.search_prompt.is_some() || self.on_next_key.is_some() ++ } ++} ++ ++/// Recalculate the index of each item of a tree. ++/// ++/// For example: ++/// ++/// ```txt ++/// foo (0) ++/// bar (1) ++/// spam (2) ++/// jar (3) ++/// yo (4) ++/// ``` ++fn index_elems<T>(parent_index: usize, elems: Vec<Tree<T>>) -> Vec<Tree<T>> { ++ fn index_elems<T>( ++ current_index: usize, ++ elems: Vec<Tree<T>>, ++ parent_index: usize, ++ ) -> (usize, Vec<Tree<T>>) { ++ elems ++ .into_iter() ++ .fold((current_index, vec![]), |(current_index, trees), elem| { ++ let index = current_index; ++ let item = elem.item; ++ let (current_index, folded) = index_elems(current_index + 1, elem.children, index); ++ let tree = Tree { ++ item, ++ children: folded, ++ index, ++ is_opened: elem.is_opened, ++ parent_index: Some(parent_index), ++ }; ++ ( ++ current_index, ++ trees.into_iter().chain(vec![tree].into_iter()).collect(), ++ ) ++ }) ++ } ++ index_elems(parent_index + 1, elems, parent_index).1 ++} +diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs +index 3c00a61e..1ab5f976 100644 +--- a/helix-view/src/editor.rs ++++ b/helix-view/src/editor.rs +@@ -211,6 +211,30 @@ fn default() -> Self { + } + } + ++#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] ++#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] ++pub struct ExplorerConfig { ++ pub position: ExplorerPosition, ++ /// explorer column width ++ pub column_width: usize, ++} ++ ++#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] ++#[serde(rename_all = "kebab-case")] ++pub enum ExplorerPosition { ++ Left, ++ Right, ++} ++ ++impl Default for ExplorerConfig { ++ fn default() -> Self { ++ Self { ++ position: ExplorerPosition::Left, ++ column_width: 36, ++ } ++ } ++} ++ + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] + pub struct Config { +@@ -285,6 +309,8 @@ pub struct Config { + pub initial_mode: Mode, + /// Whether to color modes with different colors. Defaults to `false`. + pub color_modes: bool, ++ /// explore config ++ pub explorer: ExplorerConfig, + pub soft_wrap: SoftWrap, + /// Workspace specific lsp ceiling dirs + pub workspace_lsp_roots: Vec<PathBuf>, +@@ -816,6 +842,7 @@ fn default() -> Self { + indent_guides: IndentGuidesConfig::default(), + initial_mode: Mode::Normal, + color_modes: false, ++ explorer: ExplorerConfig::default(), + soft_wrap: SoftWrap { + enable: Some(false), + ..SoftWrap::default() +@@ -988,6 +1015,18 @@ pub enum CloseError { + SaveError(anyhow::Error), + } + ++impl From<CloseError> for anyhow::Error { ++ fn from(error: CloseError) -> Self { ++ match error { ++ CloseError::DoesNotExist => anyhow::anyhow!("Document doesn't exist"), ++ CloseError::BufferModified(error) => { ++ anyhow::anyhow!(format!("Buffer modified: '{error}'")) ++ } ++ CloseError::SaveError(error) => anyhow::anyhow!(format!("Save error: {error}")), ++ } ++ } ++} ++ + impl Editor { + pub fn new( + mut area: Rect, +diff --git a/helix-view/src/graphics.rs b/helix-view/src/graphics.rs +index 046db86a..fbfde635 100644 +--- a/helix-view/src/graphics.rs ++++ b/helix-view/src/graphics.rs +@@ -248,6 +248,34 @@ pub fn intersects(self, other: Rect) -> bool { + && self.y < other.y + other.height + && self.y + self.height > other.y + } ++ ++ /// Returns a smaller `Rect` with a margin of 5% on each side, and an additional 2 rows at the bottom ++ pub fn overlaid(self) -> Rect { ++ self.clip_bottom(2).clip_relative(90, 90) ++ } ++ ++ /// Returns a smaller `Rect` with width and height clipped to the given `percent_horizontal` ++ /// and `percent_vertical`. ++ /// ++ /// Value of `percent_horizontal` and `percent_vertical` is from 0 to 100. ++ pub fn clip_relative(self, percent_horizontal: u8, percent_vertical: u8) -> Rect { ++ fn mul_and_cast(size: u16, factor: u8) -> u16 { ++ ((size as u32) * (factor as u32) / 100).try_into().unwrap() ++ } ++ ++ let inner_w = mul_and_cast(self.width, percent_horizontal); ++ let inner_h = mul_and_cast(self.height, percent_vertical); ++ ++ let offset_x = self.width.saturating_sub(inner_w) / 2; ++ let offset_y = self.height.saturating_sub(inner_h) / 2; ++ ++ Rect { ++ x: self.x + offset_x, ++ y: self.y + offset_y, ++ width: inner_w, ++ height: inner_h, ++ } ++ } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] +-- +2.41.0 + + +From 3476811ef07346ccfa4ea0757306bc0df99be6b0 Mon Sep 17 00:00:00 2001 +From: JJ <git@toki.la> +Date: Sat, 15 Jul 2023 18:48:07 -0700 +Subject: [PATCH 2/2] Unit test file explorer and tree view + +--- + helix-term/src/compositor.rs | 56 ++ + helix-term/src/ui/explorer.rs | 713 ++++++++++++++++ + helix-term/src/ui/tree.rs | 1472 +++++++++++++++++++++++++++++++++ + 3 files changed, 2241 insertions(+) + +diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs +index bcb3e449..35009a25 100644 +--- a/helix-term/src/compositor.rs ++++ b/helix-term/src/compositor.rs +@@ -34,6 +34,47 @@ pub fn block_try_flush_writes(&mut self) -> anyhow::Result<()> { + tokio::task::block_in_place(|| helix_lsp::block_on(self.editor.flush_writes()))?; + Ok(()) + } ++ ++ /// Purpose: to test `handle_event` without escalating the test case to integration test ++ /// Usage: ++ /// ``` ++ /// let mut editor = Context::dummy_editor(); ++ /// let mut jobs = Context::dummy_jobs(); ++ /// let mut cx = Context::dummy(&mut jobs, &mut editor); ++ /// ``` ++ #[cfg(test)] ++ pub fn dummy(jobs: &'a mut Jobs, editor: &'a mut helix_view::Editor) -> Context<'a> { ++ Context { ++ jobs, ++ scroll: None, ++ editor, ++ } ++ } ++ ++ #[cfg(test)] ++ pub fn dummy_jobs() -> Jobs { ++ Jobs::new() ++ } ++ ++ #[cfg(test)] ++ pub fn dummy_editor() -> Editor { ++ use crate::config::Config; ++ use arc_swap::{access::Map, ArcSwap}; ++ use helix_core::syntax::{self, Configuration}; ++ use helix_view::theme; ++ use std::{sync::Arc, collections::HashMap}; ++ ++ let config = Arc::new(ArcSwap::from_pointee(Config::default())); ++ Editor::new( ++ Rect::new(0, 0, 60, 120), ++ Arc::new(theme::Loader::new(&[])), ++ Arc::new(syntax::Loader::new(Configuration { language: vec![], language_server: HashMap::new() })), ++ Arc::new(Arc::new(Map::new( ++ Arc::clone(&config), ++ |config: &Config| &config.editor, ++ ))), ++ ) ++ } + } + + pub trait Component: Any + AnyComponent { +@@ -72,6 +113,21 @@ fn type_name(&self) -> &'static str { + fn id(&self) -> Option<&'static str> { + None + } ++ ++ #[cfg(test)] ++ /// Utility method for testing `handle_event` without using integration test. ++ /// Especially useful for testing helper components such as `Prompt`, `TreeView` etc ++ fn handle_events(&mut self, events: &str) -> anyhow::Result<()> { ++ use helix_view::input::parse_macro; ++ ++ let mut editor = Context::dummy_editor(); ++ let mut jobs = Context::dummy_jobs(); ++ let mut cx = Context::dummy(&mut jobs, &mut editor); ++ for event in parse_macro(events)? { ++ self.handle_event(&Event::Key(event), &mut cx); ++ } ++ Ok(()) ++ } + } + + pub struct Compositor { +diff --git a/helix-term/src/ui/explorer.rs b/helix-term/src/ui/explorer.rs +index 6df059b1..4ad8dee7 100644 +--- a/helix-term/src/ui/explorer.rs ++++ b/helix-term/src/ui/explorer.rs +@@ -749,3 +749,716 @@ fn render_block(area: Rect, surface: &mut Surface, borders: Borders) -> Rect { + block.render(area, surface); + inner + } ++ ++#[cfg(test)] ++mod test_explorer { ++ use crate::compositor::Component; ++ ++ use super::Explorer; ++ use helix_view::graphics::Rect; ++ use std::{fs, path::PathBuf}; ++ ++ /// This code should create the following file tree: ++ /// ++ /// <temp_path> ++ /// ├── index.html ++ /// ├── .gitignore ++ /// ├── scripts ++ /// │ └── main.js ++ /// └── styles ++ /// ├── style.css ++ /// └── public ++ /// └── file ++ /// ++ fn dummy_file_tree() -> PathBuf { ++ let path = tempfile::tempdir().unwrap().path().to_path_buf(); ++ if path.exists() { ++ fs::remove_dir_all(path.clone()).unwrap(); ++ } ++ fs::create_dir_all(path.clone()).unwrap(); ++ fs::write(path.join("index.html"), "").unwrap(); ++ fs::write(path.join(".gitignore"), "").unwrap(); ++ ++ fs::create_dir_all(path.join("scripts")).unwrap(); ++ fs::write(path.join("scripts").join("main.js"), "").unwrap(); ++ ++ fs::create_dir_all(path.join("styles")).unwrap(); ++ fs::write(path.join("styles").join("style.css"), "").unwrap(); ++ ++ fs::create_dir_all(path.join("styles").join("public")).unwrap(); ++ fs::write(path.join("styles").join("public").join("file"), "").unwrap(); ++ ++ path ++ } ++ ++ fn render(explorer: &mut Explorer) -> String { ++ explorer.tree.render_to_string(Rect::new(0, 0, 100, 10)) ++ } ++ ++ fn new_explorer() -> (PathBuf, Explorer) { ++ let path = dummy_file_tree(); ++ (path.clone(), Explorer::from_path(path, 100).unwrap()) ++ } ++ ++ #[test] ++ fn test_reveal_file() { ++ let (path, mut explorer) = new_explorer(); ++ ++ let path_str = path.display().to_string(); ++ ++ // 0a. Expect the "scripts" folder is not opened ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++({path_str}) ++⏵ scripts ++⏵ styles ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ // 1. Reveal "scripts/main.js" ++ explorer.reveal_file(path.join("scripts/main.js")).unwrap(); ++ ++ // 1a. Expect the "scripts" folder is opened, and "main.js" is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏷ [scripts] ++ (main.js) ++⏵ styles ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ // 2. Change root to "scripts" ++ explorer.tree.move_up(1); ++ explorer.change_root_to_current_folder().unwrap(); ++ ++ // 2a. Expect the current root is "scripts" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++({path_str}/scripts) ++ main.js ++" ++ ) ++ .trim() ++ ); ++ ++ // 3. Reveal "styles/public/file", which is outside of the current root ++ explorer ++ .reveal_file(path.join("styles/public/file")) ++ .unwrap(); ++ ++ // 3a. Expect the current root is "public", and "file" is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}/styles/public] ++ (file) ++" ++ ) ++ .trim() ++ ); ++ } ++ ++ #[tokio::test(flavor = "multi_thread")] ++ async fn test_rename() { ++ let (path, mut explorer) = new_explorer(); ++ let path_str = path.display().to_string(); ++ ++ explorer.handle_events("jjj").unwrap(); ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏵ styles ++ (.gitignore) ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ // 0. Open the rename file prompt ++ explorer.handle_events("r").unwrap(); ++ ++ // 0.1 Expect the current prompt to be related to file renaming ++ let prompt = &explorer.prompt.as_ref().unwrap().1; ++ assert_eq!(prompt.prompt(), " Rename to "); ++ assert_eq!( ++ prompt.line().replace(std::path::MAIN_SEPARATOR, "/"), ++ format!("{path_str}/.gitignore") ++ ); ++ ++ // 1. Rename the current file to a name that is lexicographically greater than "index.html" ++ explorer.handle_events("<C-w>who.is<ret>").unwrap(); ++ ++ // 1a. Expect the file is renamed, and is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏵ styles ++ index.html ++ (who.is) ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(path.join("who.is").exists()); ++ ++ // 2. Rename the current file into an existing folder ++ explorer ++ .handle_events(&format!( ++ "r<C-w>styles{}lol<ret>", ++ std::path::MAIN_SEPARATOR ++ )) ++ .unwrap(); ++ ++ // 2a. Expect the file is moved to the folder, and is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏷ [styles] ++ ⏵ public ++ (lol) ++ style.css ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(path.join("styles/lol").exists()); ++ ++ // 3. Rename the current file into a non-existent folder ++ explorer ++ .handle_events(&format!( ++ "r<C-u>{}<ret>", ++ path.join("new_folder/sponge/bob").display() ++ )) ++ .unwrap(); ++ ++ // 3a. Expect the non-existent folder to be created, ++ // and the file is moved into it, ++ // and the renamed file is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏷ [new_folder] ++ ⏷ [sponge] ++ (bob) ++⏵ scripts ++⏷ styles ++ ⏵ public ++ style.css ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(path.join("new_folder/sponge/bob").exists()); ++ ++ // 4. Change current root to "new_folder/sponge" ++ explorer.handle_events("k]").unwrap(); ++ ++ // 4a. Expect the current root to be "sponge" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++({path_str}/new_folder/sponge) ++ bob ++" ++ ) ++ .trim() ++ ); ++ ++ // 5. Move cursor to "bob", and rename it outside of the current root ++ explorer.handle_events("j").unwrap(); ++ explorer ++ .handle_events(&format!( ++ "r<C-u>{}<ret>", ++ path.join("scripts/bob").display() ++ )) ++ .unwrap(); ++ ++ // 5a. Expect the current root to be "scripts" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}/scripts] ++ (bob) ++ main.js ++" ++ ) ++ .trim() ++ ); ++ } ++ ++ #[tokio::test(flavor = "multi_thread")] ++ async fn test_new_folder() { ++ let (path, mut explorer) = new_explorer(); ++ let path_str = path.display().to_string(); ++ ++ // 0. Open the add file/folder prompt ++ explorer.handle_events("a").unwrap(); ++ let prompt = &explorer.prompt.as_ref().unwrap().1; ++ fn to_forward_slash(s: &str) -> String { ++ s.replace(std::path::MAIN_SEPARATOR, "/") ++ } ++ fn to_os_main_separator(s: &str) -> String { ++ s.replace('/', format!("{}", std::path::MAIN_SEPARATOR).as_str()) ++ } ++ assert_eq!( ++ to_forward_slash(prompt.prompt()), ++ " New file or folder (ends with '/'): " ++ ); ++ assert_eq!(to_forward_slash(prompt.line()), format!("{path_str}/")); ++ ++ // 1. Add a new folder at the root ++ explorer ++ .handle_events(&to_os_main_separator("yoyo/<ret>")) ++ .unwrap(); ++ ++ // 1a. Expect the new folder is added, and is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏵ styles ++⏷ (yoyo) ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_dir(path.join("yoyo")).is_ok()); ++ ++ // 2. Move up to "styles" ++ explorer.handle_events("k").unwrap(); ++ ++ // 3. Add a new folder ++ explorer ++ .handle_events(&to_os_main_separator("asus.sass/<ret>")) ++ .unwrap(); ++ ++ // 3a. Expect the new folder is added under "styles", although "styles" is not opened ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏷ [styles] ++ ⏵ public ++ ⏷ (sus.sass) ++ style.css ++⏷ yoyo ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_dir(path.join("styles/sus.sass")).is_ok()); ++ ++ // 4. Add a new folder with non-existent parents ++ explorer ++ .handle_events(&to_os_main_separator("aa/b/c/<ret>")) ++ .unwrap(); ++ ++ // 4a. Expect the non-existent parents are created, ++ // and the new folder is created, ++ // and is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏷ [styles] ++ ⏷ [sus.sass] ++ ⏷ [a] ++ ⏷ [b] ++ ⏷ (c) ++ style.css ++⏷ yoyo ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_dir(path.join("styles/sus.sass/a/b/c")).is_ok()); ++ ++ // 5. Move to "style.css" ++ explorer.handle_events("j").unwrap(); ++ ++ // 6. Add a new folder here ++ explorer ++ .handle_events(&to_os_main_separator("afoobar/<ret>")) ++ .unwrap(); ++ ++ // 6a. Expect the folder is added under "styles", ++ // because the folder of the current item, "style.css" is "styles/" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏷ [styles] ++ ⏷ (foobar) ++ ⏵ public ++ ⏷ sus.sass ++ ⏷ a ++ ⏷ b ++ ⏷ c ++ style.css ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_dir(path.join("styles/foobar")).is_ok()); ++ } ++ ++ #[tokio::test(flavor = "multi_thread")] ++ async fn test_new_file() { ++ let (path, mut explorer) = new_explorer(); ++ let path_str = path.display().to_string(); ++ ++ // 1. Add a new file at the root ++ explorer.handle_events("ayoyo<ret>").unwrap(); ++ ++ // 1a. Expect the new file is added, and is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏵ styles ++ .gitignore ++ index.html ++ (yoyo) ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_to_string(path.join("yoyo")).is_ok()); ++ ++ // 2. Move up to "styles" ++ explorer.tree.move_up(3); ++ ++ // 3. Add a new file ++ explorer.handle_events("asus.sass<ret>").unwrap(); ++ ++ // 3a. Expect the new file is added under "styles", although "styles" is not opened ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏷ [styles] ++ ⏵ public ++ style.css ++ (sus.sass) ++ .gitignore ++ index.html ++ yoyo ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_to_string(path.join("styles/sus.sass")).is_ok()); ++ ++ // 4. Add a new file with non-existent parents ++ explorer.handle_events("aa/b/c<ret>").unwrap(); ++ ++ // 4a. Expect the non-existent parents are created, ++ // and the new file is created, ++ // and is focused ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏷ [styles] ++ ⏷ [a] ++ ⏷ [b] ++ (c) ++ ⏵ public ++ style.css ++ sus.sass ++ .gitignore ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_to_string(path.join("styles/a/b/c")).is_ok()); ++ ++ // 5. Move to "style.css" ++ explorer.handle_events("jj").unwrap(); ++ ++ // 6. Add a new file here ++ explorer.handle_events("afoobar<ret>").unwrap(); ++ ++ // 6a. Expect the file is added under "styles", ++ // because the folder of the current item, "style.css" is "styles/" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏷ [styles] ++ ⏷ b ++ c ++ ⏵ public ++ (foobar) ++ style.css ++ sus.sass ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_to_string(path.join("styles/foobar")).is_ok()); ++ } ++ ++ #[tokio::test(flavor = "multi_thread")] ++ async fn test_remove_file() { ++ let (path, mut explorer) = new_explorer(); ++ let path_str = path.display().to_string(); ++ ++ // 1. Move to ".gitignore" ++ explorer.handle_events("/.gitignore<ret>").unwrap(); ++ ++ // 1a. Expect the cursor is at ".gitignore" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏵ styles ++ (.gitignore) ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_to_string(path.join(".gitignore")).is_ok()); ++ ++ // 2. Remove the current file ++ explorer.handle_events("dy").unwrap(); ++ ++ // 3. Expect ".gitignore" is deleted, and the cursor moved down ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏵ styles ++ (index.html) ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_to_string(path.join(".gitignore")).is_err()); ++ ++ // 3a. Expect "index.html" exists ++ assert!(fs::read_to_string(path.join("index.html")).is_ok()); ++ ++ // 4. Remove the current file ++ explorer.handle_events("dy").unwrap(); ++ ++ // 4a. Expect "index.html" is deleted, at the cursor moved up ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏵ (styles) ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_to_string(path.join("index.html")).is_err()); ++ } ++ ++ #[tokio::test(flavor = "multi_thread")] ++ async fn test_remove_folder() { ++ let (path, mut explorer) = new_explorer(); ++ let path_str = path.display().to_string(); ++ ++ // 1. Move to "styles/" ++ explorer.handle_events("/styles<ret>o").unwrap(); ++ ++ // 1a. Expect the cursor is at "styles" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏷ (styles) ++ ⏵ public ++ style.css ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_dir(path.join("styles")).is_ok()); ++ ++ // 2. Remove the current folder ++ explorer.handle_events("dy").unwrap(); ++ ++ // 3. Expect "styles" is deleted, and the cursor moved down ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++ (.gitignore) ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ assert!(fs::read_dir(path.join("styles")).is_err()); ++ } ++ ++ #[test] ++ fn test_change_root() { ++ let (path, mut explorer) = new_explorer(); ++ let path_str = path.display().to_string(); ++ ++ // 1. Move cursor to "styles" ++ explorer.reveal_file(path.join("styles")).unwrap(); ++ ++ // 2. Change root to current folder, and move cursor down ++ explorer.change_root_to_current_folder().unwrap(); ++ explorer.tree.move_down(1); ++ ++ // 2a. Expect the current root to be "styles", and the cursor is at "public" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}/styles] ++⏵ (public) ++ style.css ++" ++ ) ++ .trim() ++ ); ++ ++ let current_root = explorer.state.current_root.clone(); ++ ++ // 3. Change root to the parent of current folder ++ explorer.change_root_parent_folder().unwrap(); ++ ++ // 3a. Expect the current root to be "change_root" ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++({path_str}) ++⏵ scripts ++⏵ styles ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ ++ // 4. Go back to previous root ++ explorer.go_to_previous_root(); ++ ++ // 4a. Expect the root te become "styles", and the cursor position is not forgotten ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}/styles] ++⏵ (public) ++ style.css ++" ++ ) ++ .trim() ++ ); ++ assert_eq!(explorer.state.current_root, current_root); ++ ++ // 5. Go back to previous root again ++ explorer.go_to_previous_root(); ++ ++ // 5a. Expect the current root to be "change_root" again, ++ // but this time the "styles" folder is opened, ++ // because it was opened before any change of root ++ assert_eq!( ++ render(&mut explorer), ++ format!( ++ " ++[{path_str}] ++⏵ scripts ++⏷ (styles) ++ ⏵ public ++ style.css ++ .gitignore ++ index.html ++" ++ ) ++ .trim() ++ ); ++ } ++} +diff --git a/helix-term/src/ui/tree.rs b/helix-term/src/ui/tree.rs +index 68783be8..d0a9af5b 100644 +--- a/helix-term/src/ui/tree.rs ++++ b/helix-term/src/ui/tree.rs +@@ -1207,3 +1207,1475 @@ fn index_elems<T>( + } + index_elems(parent_index + 1, elems, parent_index).1 + } ++ ++#[cfg(test)] ++mod test_tree_view { ++ ++ use helix_view::graphics::Rect; ++ ++ use crate::compositor::Context; ++ ++ use super::{TreeView, TreeViewItem}; ++ ++ #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] ++ /// The children of DivisibleItem is the division of itself. ++ /// This is used to ease the creation of a dummy tree without having to specify so many things. ++ struct DivisibleItem<'a> { ++ name: &'a str, ++ } ++ ++ fn item(name: &str) -> DivisibleItem { ++ DivisibleItem { name } ++ } ++ ++ impl<'a> TreeViewItem for DivisibleItem<'a> { ++ type Params = (); ++ ++ fn name(&self) -> String { ++ self.name.to_string() ++ } ++ ++ fn is_parent(&self) -> bool { ++ self.name.len() > 2 ++ } ++ ++ fn get_children(&self) -> anyhow::Result<Vec<Self>> { ++ if self.name.eq("who_lives_in_a_pineapple_under_the_sea") { ++ Ok(vec![ ++ item("gary_the_snail"), ++ item("krabby_patty"), ++ item("larry_the_lobster"), ++ item("patrick_star"), ++ item("sandy_cheeks"), ++ item("spongebob_squarepants"), ++ item("mrs_puff"), ++ item("king_neptune"), ++ item("karen"), ++ item("plankton"), ++ ]) ++ } else if self.is_parent() { ++ let (left, right) = self.name.split_at(self.name.len() / 2); ++ Ok(vec![item(left), item(right)]) ++ } else { ++ Ok(vec![]) ++ } ++ } ++ } ++ ++ fn dummy_tree_view<'a>() -> TreeView<DivisibleItem<'a>> { ++ TreeView::build_tree(item("who_lives_in_a_pineapple_under_the_sea")).unwrap() ++ } ++ ++ fn dummy_area() -> Rect { ++ Rect::new(0, 0, 50, 5) ++ } ++ ++ fn render(view: &mut TreeView<DivisibleItem>) -> String { ++ view.render_to_string(dummy_area()) ++ } ++ ++ #[test] ++ fn test_init() { ++ let mut view = dummy_tree_view(); ++ ++ // Expect the items to be sorted ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_up_down() { ++ let mut view = dummy_tree_view(); ++ view.move_down(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ (gary_the_snail) ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_down(3); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++" ++ .trim() ++ ); ++ ++ view.move_down(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++⏵ (larry_the_lobster) ++" ++ .trim() ++ ); ++ ++ view.move_up(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++⏵ larry_the_lobster ++" ++ .trim() ++ ); ++ ++ view.move_up(3); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ (gary_the_snail) ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_up(1); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_to_first_line(); ++ view.move_up(1); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_to_last_line(); ++ view.move_down(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ patrick_star ++⏵ plankton ++⏵ sandy_cheeks ++⏵ (spongebob_squarepants) ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_to_first_last_sibling() { ++ let mut view = dummy_tree_view(); ++ view.move_to_children().unwrap(); ++ view.move_to_children().unwrap(); ++ view.move_to_parent().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ (gary_the_snail) ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_last_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ patrick_star ++⏵ plankton ++⏵ sandy_cheeks ++⏵ (spongebob_squarepants) ++" ++ .trim() ++ ); ++ ++ view.move_to_first_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ (gary_the_snail) ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_to_previous_next_sibling() { ++ let mut view = dummy_tree_view(); ++ view.move_to_children().unwrap(); ++ view.move_to_children().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ (e_snail) ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_next_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ e_snail ++ ⏵ (gary_th) ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_next_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ e_snail ++ ⏵ (gary_th) ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_previous_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ (e_snail) ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_previous_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ (e_snail) ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_parent().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ (gary_the_snail) ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_next_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ gary_the_snail ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ (karen) ++" ++ .trim() ++ ); ++ ++ view.move_to_previous_sibling().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ (gary_the_snail) ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_align_view() { ++ let mut view = dummy_tree_view(); ++ view.move_down(5); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++⏵ (larry_the_lobster) ++" ++ .trim() ++ ); ++ ++ view.align_view_center(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ krabby_patty ++⏵ (larry_the_lobster) ++⏵ mrs_puff ++⏵ patrick_star ++" ++ .trim() ++ ); ++ ++ view.align_view_bottom(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++⏵ (larry_the_lobster) ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_to_first_last() { ++ let mut view = dummy_tree_view(); ++ ++ view.move_to_last_line(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ patrick_star ++⏵ plankton ++⏵ sandy_cheeks ++⏵ (spongebob_squarepants) ++" ++ .trim() ++ ); ++ ++ view.move_to_first_line(); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_half() { ++ let mut view = dummy_tree_view(); ++ view.move_down_half_page(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ (karen) ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_down_half_page(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++" ++ .trim() ++ ); ++ ++ view.move_down_half_page(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ king_neptune ++⏵ krabby_patty ++⏵ larry_the_lobster ++⏵ (mrs_puff) ++" ++ .trim() ++ ); ++ ++ view.move_up_half_page(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ king_neptune ++⏵ (krabby_patty) ++⏵ larry_the_lobster ++⏵ mrs_puff ++" ++ .trim() ++ ); ++ ++ view.move_up_half_page(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ (karen) ++⏵ king_neptune ++⏵ krabby_patty ++⏵ larry_the_lobster ++" ++ .trim() ++ ); ++ ++ view.move_up_half_page(); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn move_to_children_parent() { ++ let mut view = dummy_tree_view(); ++ view.move_down(1); ++ view.move_to_children().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ (e_snail) ++ ⏵ gary_th ++⏵ karen ++ " ++ .trim() ++ ); ++ ++ view.move_down(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ e_snail ++ ⏵ (gary_th) ++⏵ karen ++ " ++ .trim() ++ ); ++ ++ view.move_to_parent().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ (gary_the_snail) ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++ " ++ .trim() ++ ); ++ ++ view.move_to_last_line(); ++ view.move_to_parent().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏷ gary_the_snail ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++ " ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_left_right() { ++ let mut view = dummy_tree_view(); ++ ++ fn render(view: &mut TreeView<DivisibleItem>) -> String { ++ view.render_to_string(dummy_area().with_width(20)) ++ } ++ ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pinea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_right(1); ++ assert_eq!( ++ render(&mut view), ++ " ++(ho_lives_in_a_pineap) ++ gary_the_snail ++ karen ++ king_neptune ++ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_right(1); ++ assert_eq!( ++ render(&mut view), ++ " ++(o_lives_in_a_pineapp) ++gary_the_snail ++karen ++king_neptune ++krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_right(1); ++ assert_eq!( ++ render(&mut view), ++ " ++(_lives_in_a_pineappl) ++ary_the_snail ++aren ++ing_neptune ++rabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_left(1); ++ assert_eq!( ++ render(&mut view), ++ " ++(o_lives_in_a_pineapp) ++gary_the_snail ++karen ++king_neptune ++krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_leftmost(); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pinea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_left(1); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pinea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_rightmost(); ++ assert_eq!(render(&mut view), "(apple_under_the_sea)\n\n\n\n"); ++ } ++ ++ #[test] ++ fn test_move_to_parent_child() { ++ let mut view = dummy_tree_view(); ++ ++ view.move_to_children().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ (gary_the_snail) ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ ++ view.move_to_children().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ (e_snail) ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_down(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ [gary_the_snail] ++ ⏵ e_snail ++ ⏵ (gary_th) ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_parent().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏷ (gary_the_snail) ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_parent().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏷ gary_the_snail ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ); ++ ++ view.move_to_parent().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏷ gary_the_snail ++ ⏵ e_snail ++ ⏵ gary_th ++⏵ karen ++" ++ .trim() ++ ) ++ } ++ ++ #[test] ++ fn test_search_next() { ++ let mut view = dummy_tree_view(); ++ ++ view.search_next("pat"); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++" ++ .trim() ++ ); ++ ++ view.search_next("larr"); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++⏵ (larry_the_lobster) ++" ++ .trim() ++ ); ++ ++ view.move_to_last_line(); ++ view.search_next("who_lives"); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_search_previous() { ++ let mut view = dummy_tree_view(); ++ ++ view.search_previous("larry"); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++⏵ (larry_the_lobster) ++" ++ .trim() ++ ); ++ ++ view.move_to_last_line(); ++ view.search_previous("krab"); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++⏵ larry_the_lobster ++" ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_to_next_search_match() { ++ let mut view = dummy_tree_view(); ++ view.set_search_str("pat".to_string()); ++ view.move_to_next_search_match(); ++ ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++ " ++ .trim() ++ ); ++ ++ view.move_to_next_search_match(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ krabby_patty ++⏵ larry_the_lobster ++⏵ mrs_puff ++⏵ (patrick_star) ++ " ++ .trim() ++ ); ++ ++ view.move_to_next_search_match(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ (krabby_patty) ++⏵ larry_the_lobster ++⏵ mrs_puff ++⏵ patrick_star ++ " ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_move_to_previous_search_match() { ++ let mut view = dummy_tree_view(); ++ view.set_search_str("pat".to_string()); ++ view.move_to_previous_next_match(); ++ ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ krabby_patty ++⏵ larry_the_lobster ++⏵ mrs_puff ++⏵ (patrick_star) ++ " ++ .trim() ++ ); ++ ++ view.move_to_previous_next_match(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ (krabby_patty) ++⏵ larry_the_lobster ++⏵ mrs_puff ++⏵ patrick_star ++ " ++ .trim() ++ ); ++ ++ view.move_to_previous_next_match(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ krabby_patty ++⏵ larry_the_lobster ++⏵ mrs_puff ++⏵ (patrick_star) ++ " ++ .trim() ++ ); ++ } ++ ++ #[test] ++ fn test_jump_backward_forward() { ++ let mut view = dummy_tree_view(); ++ view.move_down_half_page(); ++ render(&mut view); ++ ++ view.move_down_half_page(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++ " ++ .trim() ++ ); ++ ++ view.jump_backward(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ (karen) ++⏵ king_neptune ++⏵ krabby_patty ++ " ++ .trim() ++ ); ++ ++ view.jump_backward(); ++ assert_eq!( ++ render(&mut view), ++ " ++(who_lives_in_a_pineapple_under_the_sea) ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++ " ++ .trim() ++ ); ++ ++ view.jump_forward(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ (karen) ++⏵ king_neptune ++⏵ krabby_patty ++ " ++ .trim() ++ ); ++ ++ view.jump_forward(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ karen ++⏵ king_neptune ++⏵ (krabby_patty) ++ " ++ .trim() ++ ); ++ ++ view.jump_backward(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ gary_the_snail ++⏵ (karen) ++⏵ king_neptune ++⏵ krabby_patty ++ " ++ .trim() ++ ); ++ } ++ ++ mod static_tree { ++ use crate::ui::{TreeView, TreeViewItem}; ++ ++ use super::dummy_area; ++ ++ #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] ++ /// This is used for test cases where the structure of the tree has to be known upfront ++ pub struct StaticItem<'a> { ++ pub name: &'a str, ++ pub children: Option<Vec<StaticItem<'a>>>, ++ } ++ ++ pub fn parent<'a>(name: &'a str, children: Vec<StaticItem<'a>>) -> StaticItem<'a> { ++ StaticItem { ++ name, ++ children: Some(children), ++ } ++ } ++ ++ pub fn child(name: &str) -> StaticItem { ++ StaticItem { ++ name, ++ children: None, ++ } ++ } ++ ++ impl<'a> TreeViewItem for StaticItem<'a> { ++ type Params = (); ++ ++ fn name(&self) -> String { ++ self.name.to_string() ++ } ++ ++ fn is_parent(&self) -> bool { ++ self.children.is_some() ++ } ++ ++ fn get_children(&self) -> anyhow::Result<Vec<Self>> { ++ match &self.children { ++ Some(children) => Ok(children.clone()), ++ None => Ok(vec![]), ++ } ++ } ++ } ++ ++ pub fn render(view: &mut TreeView<StaticItem<'_>>) -> String { ++ view.render_to_string(dummy_area().with_height(3)) ++ } ++ } ++ ++ #[test] ++ fn test_sticky_ancestors() { ++ // The ancestors of the current item should always be visible ++ // However, if there's not enough space, the current item will take precedence, ++ // and the nearest ancestor has higher precedence than further ancestors ++ use static_tree::*; ++ ++ let mut view = TreeView::build_tree(parent( ++ "root", ++ vec![ ++ parent("a", vec![child("aa"), child("ab")]), ++ parent( ++ "b", ++ vec![parent( ++ "ba", ++ vec![parent("baa", vec![child("baaa"), child("baab")])], ++ )], ++ ), ++ ], ++ )) ++ .unwrap(); ++ ++ assert_eq!( ++ render(&mut view), ++ " ++(root) ++⏵ a ++⏵ b ++ " ++ .trim() ++ ); ++ ++ // 1. Move down to "a", and expand it ++ view.move_down(1); ++ view.move_to_children().unwrap(); ++ ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++⏷ [a] ++ (aa) ++ " ++ .trim() ++ ); ++ ++ // 2. Move down by 1 ++ view.move_down(1); ++ ++ // 2a. Expect all ancestors (i.e. "root" and "a") are visible, ++ // and the cursor is at "ab" ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++⏷ [a] ++ (ab) ++ " ++ .trim() ++ ); ++ ++ // 3. Move down by 1 ++ view.move_down(1); ++ ++ // 3a. Expect "a" is out of view, because it is no longer the ancestor of the current item ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++ ab ++⏵ (b) ++ " ++ .trim() ++ ); ++ ++ // 4. Move to the children of "b", which is "ba" ++ view.move_to_children().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++⏷ [b] ++ ⏵ (ba) ++ " ++ .trim() ++ ); ++ ++ // 5. Move to the children of "ba", which is "baa" ++ view.move_to_children().unwrap(); ++ ++ // 5a. Expect the furthest ancestor "root" is out of view, ++ // because when there's no enough space, the nearest ancestor takes precedence ++ assert_eq!( ++ render(&mut view), ++ " ++⏷ [b] ++ ⏷ [ba] ++ ⏵ (baa) ++ " ++ .trim() ++ ); ++ ++ // 5.1 Move to child ++ view.move_to_children().unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++ ⏷ [ba] ++ ⏷ [baa] ++ (baaa) ++" ++ .trim_matches('\n') ++ ); ++ ++ // 5.2 Move down ++ view.move_down(1); ++ assert_eq!( ++ render(&mut view), ++ " ++ ⏷ [ba] ++ ⏷ [baa] ++ (baab) ++" ++ .trim_matches('\n') ++ ); ++ ++ // 5.3 Move up ++ view.move_up(1); ++ assert_eq!(view.current_item().unwrap().name, "baaa"); ++ assert_eq!( ++ render(&mut view), ++ " ++ ⏷ [ba] ++ ⏷ [baa] ++ (baaa) ++" ++ .trim_matches('\n') ++ ); ++ ++ // 5.4 Move up ++ view.move_up(1); ++ assert_eq!( ++ render(&mut view), ++ " ++⏷ [b] ++ ⏷ [ba] ++ ⏷ (baa) ++ " ++ .trim() ++ ); ++ ++ // 6. Move up by one ++ view.move_up(1); ++ ++ // 6a. Expect "root" is visible again, because now there's enough space to render all ++ // ancestors ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++⏷ [b] ++ ⏷ (ba) ++ " ++ .trim() ++ ); ++ ++ // 7. Move up by one ++ view.move_up(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++⏷ (b) ++ ⏷ ba ++ " ++ .trim() ++ ); ++ ++ // 8. Move up by one ++ view.move_up(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++⏷ [a] ++ (ab) ++ " ++ .trim() ++ ); ++ ++ // 9. Move up by one ++ view.move_up(1); ++ assert_eq!( ++ render(&mut view), ++ " ++[root] ++⏷ [a] ++ (aa) ++ " ++ .trim() ++ ); ++ } ++ ++ #[tokio::test(flavor = "multi_thread")] ++ async fn test_search_prompt() { ++ let mut editor = Context::dummy_editor(); ++ let mut jobs = Context::dummy_jobs(); ++ let mut cx = Context::dummy(&mut jobs, &mut editor); ++ let mut view = dummy_tree_view(); ++ ++ view.handle_events("/an", &mut cx, &mut ()).unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ larry_the_lobster ++⏵ mrs_puff ++⏵ patrick_star ++⏵ (plankton) ++ " ++ .trim() ++ ); ++ ++ view.handle_events("t<ret>", &mut cx, &mut ()).unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ patrick_star ++⏵ plankton ++⏵ sandy_cheeks ++⏵ (spongebob_squarepants) ++ " ++ .trim() ++ ); ++ ++ view.handle_events("/larry", &mut cx, &mut ()).unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ karen ++⏵ king_neptune ++⏵ krabby_patty ++⏵ (larry_the_lobster) ++ " ++ .trim() ++ ); ++ ++ view.handle_events("<esc>", &mut cx, &mut ()).unwrap(); ++ assert_eq!( ++ render(&mut view), ++ " ++[who_lives_in_a_pineapple_under_the_sea] ++⏵ patrick_star ++⏵ plankton ++⏵ sandy_cheeks ++⏵ (spongebob_squarepants) ++ " ++ .trim() ++ ); ++ } ++} ++ ++#[cfg(test)] ++mod test_tree { ++ use helix_core::movement::Direction; ++ ++ use super::Tree; ++ ++ #[test] ++ fn test_get() { ++ let result = Tree::new( ++ "root", ++ vec![ ++ Tree::new("foo", vec![Tree::new("bar", vec![])]), ++ Tree::new( ++ "spam", ++ vec![Tree::new("jar", vec![Tree::new("yo", vec![])])], ++ ), ++ ], ++ ); ++ assert_eq!(result.get(0).unwrap().item, "root"); ++ assert_eq!(result.get(1).unwrap().item, "foo"); ++ assert_eq!(result.get(2).unwrap().item, "bar"); ++ assert_eq!(result.get(3).unwrap().item, "spam"); ++ assert_eq!(result.get(4).unwrap().item, "jar"); ++ assert_eq!(result.get(5).unwrap().item, "yo"); ++ } ++ ++ #[test] ++ fn test_iter() { ++ let tree = Tree::new( ++ "spam", ++ vec![ ++ Tree::new("jar", vec![Tree::new("yo", vec![])]), ++ Tree::new("foo", vec![Tree::new("bar", vec![])]), ++ ], ++ ); ++ ++ let mut iter = tree.iter(); ++ assert_eq!(iter.next().map(|tree| tree.item), Some("spam")); ++ assert_eq!(iter.next().map(|tree| tree.item), Some("jar")); ++ assert_eq!(iter.next().map(|tree| tree.item), Some("yo")); ++ assert_eq!(iter.next().map(|tree| tree.item), Some("foo")); ++ assert_eq!(iter.next().map(|tree| tree.item), Some("bar")); ++ ++ assert_eq!(iter.next().map(|tree| tree.item), None) ++ } ++ ++ #[test] ++ fn test_iter_double_ended() { ++ let tree = Tree::new( ++ "spam", ++ vec![ ++ Tree::new("jar", vec![Tree::new("yo", vec![])]), ++ Tree::new("foo", vec![Tree::new("bar", vec![])]), ++ ], ++ ); ++ ++ let mut iter = tree.iter(); ++ assert_eq!(iter.next_back().map(|tree| tree.item), Some("bar")); ++ assert_eq!(iter.next_back().map(|tree| tree.item), Some("foo")); ++ assert_eq!(iter.next_back().map(|tree| tree.item), Some("yo")); ++ assert_eq!(iter.next_back().map(|tree| tree.item), Some("jar")); ++ assert_eq!(iter.next_back().map(|tree| tree.item), Some("spam")); ++ assert_eq!(iter.next_back().map(|tree| tree.item), None) ++ } ++ ++ #[test] ++ fn test_len() { ++ let tree = Tree::new( ++ "spam", ++ vec![ ++ Tree::new("jar", vec![Tree::new("yo", vec![])]), ++ Tree::new("foo", vec![Tree::new("bar", vec![])]), ++ ], ++ ); ++ ++ assert_eq!(tree.len(), 5) ++ } ++ ++ #[test] ++ fn test_find_forward() { ++ let tree = Tree::new( ++ ".cargo", ++ vec![ ++ Tree::new("jar", vec![Tree::new("Cargo.toml", vec![])]), ++ Tree::new("Cargo.toml", vec![Tree::new("bar", vec![])]), ++ ], ++ ); ++ let result = tree.find(0, Direction::Forward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(0)); ++ ++ let result = tree.find(1, Direction::Forward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(2)); ++ ++ let result = tree.find(2, Direction::Forward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(2)); ++ ++ let result = tree.find(3, Direction::Forward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(3)); ++ ++ let result = tree.find(4, Direction::Forward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(0)); ++ } ++ ++ #[test] ++ fn test_find_backward() { ++ let tree = Tree::new( ++ ".cargo", ++ vec![ ++ Tree::new("jar", vec![Tree::new("Cargo.toml", vec![])]), ++ Tree::new("Cargo.toml", vec![Tree::new("bar", vec![])]), ++ ], ++ ); ++ let result = tree.find(0, Direction::Backward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(3)); ++ ++ let result = tree.find(1, Direction::Backward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(0)); ++ ++ let result = tree.find(2, Direction::Backward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(0)); ++ ++ let result = tree.find(3, Direction::Backward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(2)); ++ ++ let result = tree.find(4, Direction::Backward, |tree| { ++ tree.item.to_lowercase().contains(&"cargo".to_lowercase()) ++ }); ++ ++ assert_eq!(result, Some(3)); ++ } ++} +-- +2.41.0 + |