aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJJ2023-11-01 00:37:26 +0000
committerJJ2023-11-01 04:08:32 +0000
commit5c371208692df2727d02a37646b7829f011680a8 (patch)
tree5f6cce3547e367942746ceb6499018628297a595
parentf6021dd0cdd8cf6795f024e396241cb0af2ca368 (diff)
Add file explorer and tree helper
ref: https://github.com/helix-editor/helix/issues/200 ref: https://github.com/helix-editor/helix/pull/2377 ref: https://github.com/helix-editor/helix/pull/5566 ref: https://github.com/helix-editor/helix/pull/5768 Co-authored-by: cossonleo <cossonleo@foxmail.com> Co-authored-by: wongjiahau <hou32hou@gmail.com>
-rw-r--r--book/src/configuration.md10
-rw-r--r--book/src/keymap.md5
-rw-r--r--helix-term/src/commands.rs45
-rw-r--r--helix-term/src/compositor.rs59
-rw-r--r--helix-term/src/keymap/default.rs1
-rw-r--r--helix-term/src/ui/editor.rs70
-rw-r--r--helix-term/src/ui/explorer.rs1464
-rw-r--r--helix-term/src/ui/mod.rs4
-rw-r--r--helix-term/src/ui/overlay.rs21
-rw-r--r--helix-term/src/ui/prompt.rs4
-rw-r--r--helix-term/src/ui/tree.rs2681
-rw-r--r--helix-view/src/editor.rs39
-rw-r--r--helix-view/src/graphics.rs28
13 files changed, 4404 insertions, 27 deletions
diff --git a/book/src/configuration.md b/book/src/configuration.md
index 3b78481e..d223b6e9 100644
--- a/book/src/configuration.md
+++ b/book/src/configuration.md
@@ -350,6 +350,16 @@ 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` |
+
+
### `[editor.smart-tab]` Section
diff --git a/book/src/keymap.md b/book/src/keymap.md
index 0f41b324..82910fe3 100644
--- a/book/src/keymap.md
+++ b/book/src/keymap.md
@@ -296,6 +296,7 @@ This layer is a kludge of mappings, mostly pickers.
| `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.
@@ -452,3 +453,7 @@ Keys to use within prompt, Remapping currently not supported.
| `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 75df430a..7e15965c 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -490,6 +490,8 @@ impl MappableCommand {
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",
);
}
@@ -2688,6 +2690,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/compositor.rs b/helix-term/src/compositor.rs
index 3dcb5f2b..6a357401 100644
--- a/helix-term/src/compositor.rs
+++ b/helix-term/src/compositor.rs
@@ -35,6 +35,50 @@ impl<'a> Context<'a> {
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::{collections::HashMap, sync::Arc};
+
+ 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 {
@@ -73,6 +117,21 @@ pub trait Component: Any + AnyComponent {
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/keymap/default.rs b/helix-term/src/keymap/default.rs
index 763ed4ae..58e8fdad 100644
--- a/helix-term/src/keymap/default.rs
+++ b/helix-term/src/keymap/default.rs
@@ -277,6 +277,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 31195a4e..a305907b 100644
--- a/helix-term/src/ui/editor.rs
+++ b/helix-term/src/ui/editor.rs
@@ -6,7 +6,7 @@ use crate::{
keymap::{KeymapResult, Keymaps},
ui::{
document::{render_document, LinePos, TextRenderer, TranslatedPosition},
- Completion, ProgressSpinners,
+ Completion, Explorer, ProgressSpinners,
},
};
@@ -23,7 +23,7 @@ use helix_core::{
};
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},
@@ -42,6 +42,7 @@ pub struct EditorView {
pseudo_pending: Vec<KeyEvent>,
pub(crate) last_insert: (commands::MappableCommand, Vec<InsertEvent>),
pub(crate) completion: Option<Completion>,
+ pub(crate) explorer: Option<Explorer>,
spinners: ProgressSpinners,
/// Tracks if the terminal window is focused by reaction to terminal focus events
terminal_focused: bool,
@@ -72,6 +73,7 @@ impl EditorView {
pseudo_pending: Vec::new(),
last_insert: (commands::MappableCommand::normal_mode, Vec::new()),
completion: None,
+ explorer: None,
spinners: ProgressSpinners::default(),
terminal_focused: true,
}
@@ -1235,6 +1237,11 @@ impl Component for EditorView {
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,
@@ -1401,6 +1408,8 @@ impl Component for EditorView {
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 {
@@ -1409,15 +1418,43 @@ impl Component for EditorView {
_ => 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);
}
@@ -1496,9 +1533,28 @@ impl Component for EditorView {
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..4ad8dee7
--- /dev/null
+++ b/helix-term/src/ui/explorer.rs
@@ -0,0 +1,1464 @@
+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
+}
+
+#[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/mod.rs b/helix-term/src/ui/mod.rs
index 660bbfea..c2ba95f5 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 info;
pub mod lsp;
mod markdown;
@@ -12,12 +13,14 @@ mod prompt;
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};
@@ -25,6 +28,7 @@ pub use popup::Popup;
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 702a6e67..de68031b 100644
--- a/helix-term/src/ui/prompt.rs
+++ b/helix-term/src/ui/prompt.rs
@@ -103,6 +103,10 @@ impl Prompt {
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..d0a9af5b
--- /dev/null
+++ b/helix-term/src/ui/tree.rs
@@ -0,0 +1,2681 @@
+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
+}
+
+#[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));
+ }
+}
diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs
index 7af28ccc..f285aa99 100644
--- a/helix-view/src/editor.rs
+++ b/helix-view/src/editor.rs
@@ -212,6 +212,30 @@ impl Default for FilePickerConfig {
#[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 {
/// Padding to keep between the edge of the screen and the cursor when scrolling. Defaults to 5.
pub scrolloff: usize,
@@ -280,6 +304,8 @@ pub struct Config {
pub bufferline: BufferLine,
/// Vertical indent width guides.
pub indent_guides: IndentGuidesConfig,
+ /// Explorer configuration.
+ pub explorer: ExplorerConfig,
/// Whether to color modes with different colors. Defaults to `false`.
pub color_modes: bool,
pub soft_wrap: SoftWrap,
@@ -835,6 +861,7 @@ impl Default for Config {
whitespace: WhitespaceConfig::default(),
bufferline: BufferLine::default(),
indent_guides: IndentGuidesConfig::default(),
+ explorer: ExplorerConfig::default(),
color_modes: false,
soft_wrap: SoftWrap {
enable: Some(false),
@@ -1011,6 +1038,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 @@ impl Rect {
&& 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)]