aboutsummaryrefslogtreecommitdiff
path: root/0011-Add-file-explorer-and-tree-helper.patch
diff options
context:
space:
mode:
authorJJ2023-07-17 17:00:43 +0000
committerJJ2023-07-17 17:00:43 +0000
commit765a9e4595dc5c6127b9242c4888b89856e65a72 (patch)
treeb67920fb0cdd468c0f5cb25c75bc5097f1ef3401 /0011-Add-file-explorer-and-tree-helper.patch
parentb0b9194075dc72938e38f3b7c9ef465b893244e3 (diff)
Add patches from helix/staging
Diffstat (limited to '0011-Add-file-explorer-and-tree-helper.patch')
-rw-r--r--0011-Add-file-explorer-and-tree-helper.patch4729
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(&current_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
+