From 7b63fda7d2f7e6e7ed63e85300a7d7435f79648b Mon Sep 17 00:00:00 2001 From: wongjiahau Date: Thu, 23 Feb 2023 10:28:33 +0800 Subject: [PATCH] test(explorer): add integration tests --- Cargo.lock | 80 +++ changes | 4 +- helix-term/.gitignore | 1 + helix-term/Cargo.toml | 1 + helix-term/src/ui/explorer.rs | 981 ++++++++++++++++++++++++++++------ helix-term/src/ui/tree.rs | 93 ++-- 6 files changed, 952 insertions(+), 208 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7be16699..5129c135 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,20 @@ dependencies = [ "num-traits", ] +[[package]] +name = "build-fs-tree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85199b032e7d08f84570a62dc4b59d4ef37e094939d634e9dddd161515ec3ba9" +dependencies = [ + "derive_more", + "pipe-trait", + "serde", + "serde_yaml", + "text-block-macros", + "thiserror", +] + [[package]] name = "bumpalo" version = "3.11.1" @@ -235,6 +249,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "core-foundation-sys" version = "0.8.3" @@ -352,6 +372,19 @@ dependencies = [ "parking_lot_core 0.9.4", ] +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "diff" version = "0.1.13" @@ -1214,6 +1247,7 @@ version = "0.6.0" dependencies = [ "anyhow", "arc-swap", + "build-fs-tree", "chrono", "content_inspector", "crossterm", @@ -1701,6 +1735,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pipe-trait" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1be1ec9e59f0360aefe84efa6f699198b685ab0d5718081e9f72aa2344289e2" + [[package]] name = "pretty_assertions" version = "1.3.0" @@ -1849,6 +1889,15 @@ dependencies = [ "str_indices", ] +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.9" @@ -1882,6 +1931,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8132065adcfd6e02db789d9285a0deb2f3fcb04002865ab67d5fb103533898" +[[package]] +name = "semver" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58bc9567378fc7690d6b2addae4e60ac2eeea07becb2c64b9f218b53865cba2a" + [[package]] name = "serde" version = "1.0.152" @@ -1933,6 +1988,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb06d4b6cdaef0e0c51fa881acb721bed3c924cfaa71d9c94a3b771dfdf6567" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1_smol" version = "1.0.0" @@ -2093,6 +2161,12 @@ dependencies = [ "dirs-next", ] +[[package]] +name = "text-block-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8b59b4da1c1717deaf1de80f0179a9d8b4ac91c986d5fd9f4a8ff177b84049" + [[package]] name = "textwrap" version = "0.16.0" @@ -2336,6 +2410,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" +[[package]] +name = "unsafe-libyaml" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7ed8ba44ca06be78ea1ad2c3682a43349126c8818054231ee6f4748012aed2" + [[package]] name = "url" version = "2.3.1" diff --git a/changes b/changes index c833831d..ccb65caa 100644 --- a/changes +++ b/changes @@ -41,7 +41,7 @@ New: - [x] fix(filter): crash - [x] fix(explorer/preview): panic if not tall enough - [x] explorer(preview): content not sorted -- [] add integration test for Explorer +- [x] add integration test for Explorer - [] search highlight matching word - [] Error didn't clear - [] bind "o" to open/close file/folder @@ -50,3 +50,5 @@ New: - [] Sticky ancestors - [] explorer(preview): overflow where bufferline is there - [] explorer(preview): implement scrolling C-j/C-k +- [] symlink not showing +- [] remove unwrap and expect diff --git a/helix-term/.gitignore b/helix-term/.gitignore index ea8c4bf7..3a070c39 100644 --- a/helix-term/.gitignore +++ b/helix-term/.gitignore @@ -1 +1,2 @@ /target +test-explorer diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 4204e4dc..823568b6 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -81,3 +81,4 @@ smallvec = "1.10" indoc = "2.0.0" tempfile = "3.3.0" pretty_assertions = "1.3.0" +build-fs-tree = "0.4.1" diff --git a/helix-term/src/ui/explorer.rs b/helix-term/src/ui/explorer.rs index fae77db6..b297c610 100644 --- a/helix-term/src/ui/explorer.rs +++ b/helix-term/src/ui/explorer.rs @@ -3,7 +3,7 @@ use crate::{ compositor::{Component, Context, EventResult}, ctrl, key, shift, ui, }; -use anyhow::{ensure, Result}; +use anyhow::{bail, ensure, Result}; use helix_core::Position; use helix_view::{ editor::{Action, ExplorerPositionEmbed}, @@ -11,7 +11,7 @@ use helix_view::{ info::Info, input::{Event, KeyEvent}, theme::Modifier, - DocumentId, Editor, + Editor, }; use std::cmp::Ordering; use std::path::{Path, PathBuf}; @@ -129,11 +129,11 @@ fn dir_entry_to_file_info(entry: DirEntry, path: &PathBuf) -> Option { #[derive(Clone, Debug)] enum PromptAction { - CreateFolder { folder_path: PathBuf }, - CreateFile { folder_path: PathBuf }, - RemoveDir, - RemoveFile(Option), - RenameFile(Option), + CreateFolder, + CreateFile, + RemoveFolder, + RemoveFile, + RenameFile, } #[derive(Clone, Debug)] @@ -174,7 +174,7 @@ impl Explorer { Ok(Self { tree: Self::new_tree_view(current_root.clone())?, history: vec![], - show_help: true, + show_help: false, state: State::new(true, current_root), prompt: None, on_next_key: None, @@ -182,6 +182,19 @@ impl Explorer { }) } + #[cfg(test)] + fn from_path(root: PathBuf, column_width: u16) -> Result { + Ok(Self { + tree: Self::new_tree_view(root.clone())?, + history: vec![], + show_help: true, + state: State::new(true, root), + prompt: None, + on_next_key: None, + column_width, + }) + } + fn new_tree_view(root: PathBuf) -> Result> { let root = FileInfo::root(root.clone()); let children = root.get_children()?; @@ -194,35 +207,46 @@ impl Explorer { Vec::truncate(&mut self.history, MAX_HISTORY_SIZE) } - fn change_root(&mut self, cx: &mut Context, root: PathBuf) { + fn change_root(&mut self, root: PathBuf) -> Result<()> { if self.state.current_root.eq(&root) { - return; - } - match Self::new_tree_view(root.clone()) { - Ok(tree) => { - let old_tree = std::mem::replace(&mut self.tree, tree); - self.push_history(old_tree); - self.state.current_root = root; - } - Err(e) => cx.editor.set_error(format!("{e}")), + 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 = root; + Ok(()) } fn reveal_file(&mut self, path: PathBuf) -> Result<()> { let current_root = &self.state.current_root; let current_path = path.as_path().to_string_lossy().to_string(); let current_root = current_root.as_path().to_string_lossy().to_string() + "/"; - let segments = current_path - .strip_prefix(current_root.as_str()) - .expect( - format!( - "Failed to strip prefix '{}' from '{}'", - current_root, current_path - ) - .as_str(), - ) - .split(std::path::MAIN_SEPARATOR) - .collect::>(); + let segments = { + let stripped = match current_path.strip_prefix(current_root.as_str()) { + Some(stripped) => Ok(stripped), + None => { + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("Failed get parent of '{current_path}'"))?; + self.change_root(parent.into())?; + current_path + .strip_prefix((parent.to_string_lossy().to_string() + "/").as_str()) + .ok_or_else(|| { + anyhow::anyhow!( + "Failed to strip prefix (parent) '{}' from '{}'", + parent.to_string_lossy(), + current_path + ) + }) + } + }?; + + stripped + .split(std::path::MAIN_SEPARATOR) + .map(|s| s.to_string()) + .collect::>() + }; self.tree.reveal_item(segments, &self.state.filter)?; Ok(()) } @@ -241,11 +265,11 @@ impl Explorer { self.state.open = true; } - pub fn unfocus(&mut self) { + fn unfocus(&mut self) { self.state.focus = false; } - pub fn close(&mut self) { + fn close(&mut self) { self.state.focus = false; self.state.open = false; } @@ -288,9 +312,7 @@ impl Explorer { fn new_create_folder_prompt(&mut self) -> Result<()> { let folder_path = self.nearest_folder()?; self.prompt = Some(( - PromptAction::CreateFolder { - folder_path: folder_path.clone(), - }, + PromptAction::CreateFolder, Prompt::new( format!(" New folder: {}/", folder_path.to_string_lossy()).into(), None, @@ -304,9 +326,7 @@ impl Explorer { fn new_create_file_prompt(&mut self) -> Result<()> { let folder_path = self.nearest_folder()?; self.prompt = Some(( - PromptAction::CreateFile { - folder_path: folder_path.clone(), - }, + PromptAction::CreateFile, Prompt::new( format!(" New file: {}/", folder_path.to_string_lossy()).into(), None, @@ -332,19 +352,19 @@ impl Explorer { } } - fn new_remove_prompt(&mut self, cx: &mut Context) { + fn new_remove_prompt(&mut self) -> Result<()> { let item = self.tree.current().item(); match item.file_type { - FileType::Folder => self.new_remove_dir_prompt(cx), - FileType::File => self.new_remove_file_prompt(cx), - FileType::Root => cx.editor.set_error("Root is not removable"), + 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) { let path = self.tree.current_item().path.clone(); self.prompt = Some(( - PromptAction::RenameFile(cx.editor.document_by_path(&path).map(|doc| doc.id())), + PromptAction::RenameFile, Prompt::new( format!(" Rename to ").into(), None, @@ -355,72 +375,67 @@ impl Explorer { )); } - fn new_remove_file_prompt(&mut self, cx: &mut Context) { + fn new_remove_file_prompt(&mut self) -> Result<()> { let item = self.tree.current_item(); - let check = || { - ensure!(item.path.is_file(), "The path is not a file"); - let doc = cx.editor.document_by_path(&item.path); - Ok(doc.map(|doc| doc.id())) - }; - match check() { - Err(err) => cx.editor.set_error(format!("{err}")), - Ok(document_id) => { - let p = format!(" Delete file: '{}'? y/n: ", item.path.display()); - self.prompt = Some(( - PromptAction::RemoveFile(document_id), - Prompt::new(p.into(), None, ui::completers::none, |_, _, _| {}), - )); - } - } + 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_dir_prompt(&mut self, cx: &mut Context) { + fn new_remove_folder_prompt(&mut self) -> Result<()> { let item = self.tree.current_item(); - let check = || { - ensure!(item.path.is_dir(), "The path is not a dir"); - let doc = cx.editor.documents().find(|doc| { - doc.path() - .map(|p| p.starts_with(&item.path)) - .unwrap_or(false) - }); - ensure!(doc.is_none(), "There are files opened under the dir"); - Ok(()) - }; - if let Err(e) = check() { - cx.editor.set_error(format!("{e}")); - return; - } - let p = format!(" Delete folder: '{}'? y/n: ", item.path.display()); + ensure!( + item.path.is_dir(), + "The path '{}' is not a folder", + item.path.to_string_lossy() + ); + self.prompt = Some(( - PromptAction::RemoveDir, - Prompt::new(p.into(), None, ui::completers::none, |_, _, _| {}), + 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 { - if item.path == Path::new("") { - return TreeOp::Noop; - } - let meta = match std::fs::metadata(&item.path) { - Ok(meta) => meta, - Err(e) => { - cx.editor.set_error(format!("{e}")); - return TreeOp::Noop; + (|| -> Result { + if item.path == Path::new("") { + return Ok(TreeOp::Noop); } - }; - if meta.is_file() { - if let Err(e) = cx.editor.open(&item.path, Action::Replace) { - cx.editor.set_error(format!("{e}")); + 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); } - state.focus = false; - return TreeOp::Noop; - } - if item.path.is_dir() { - return TreeOp::GetChildsAndInsert; - } - cx.editor.set_error("unkonw file type"); - 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_float(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { @@ -547,12 +562,13 @@ impl Explorer { if preview_area.width < 30 || preview_area.height < 3 { return; } - let y = self.tree.winline().saturating_sub(1) as u16; + let y = self.tree.winline() as u16; let y = if (preview_area_height + y) > preview_area.height { preview_area.height.saturating_sub(preview_area_height) } else { y - }; + } + .saturating_add(1); let area = Rect::new(preview_area.x, y, preview_area_width, preview_area_height); surface.clear_with(area, background); let area = render_block(area, surface, Borders::all()); @@ -599,38 +615,26 @@ impl Explorer { _ => return Ok(EventResult::Ignored(None)), }; let line = prompt.line(); + + let current_item_path = explorer.tree.current_item().path.clone(); match (&action, event) { - (PromptAction::CreateFolder { folder_path }, key!(Enter)) => { - explorer.new_path(folder_path.clone(), line, true)? - } - (PromptAction::CreateFile { folder_path }, key!(Enter)) => { - explorer.new_path(folder_path.clone(), line, false)? - } - (PromptAction::RemoveDir, key!(Enter)) => { + (PromptAction::CreateFolder, key!(Enter)) => explorer.new_folder(line)?, + (PromptAction::CreateFile, key!(Enter)) => explorer.new_file(line)?, + (PromptAction::RemoveFolder, key!(Enter)) => { if line == "y" { - let item = explorer.tree.current_item(); - std::fs::remove_dir_all(&item.path)?; - explorer.tree.refresh()?; + close_documents(current_item_path, cx)?; + explorer.remove_folder()?; } } - (PromptAction::RemoveFile(document_id), key!(Enter)) => { + (PromptAction::RemoveFile, key!(Enter)) => { if line == "y" { - let item = explorer.tree.current_item(); - std::fs::remove_file(&item.path).map_err(anyhow::Error::from)?; - explorer.tree.refresh()?; - if let Some(id) = document_id { - cx.editor.close_document(*id, true)? - } + close_documents(current_item_path, cx)?; + explorer.remove_file()?; } } - (PromptAction::RenameFile(document_id), key!(Enter)) => { - let item = explorer.tree.current_item(); - std::fs::rename(&item.path, line)?; - explorer.tree.refresh()?; - explorer.reveal_file(PathBuf::from(line))?; - if let Some(id) = document_id { - cx.editor.close_document(*id, true)? - } + (PromptAction::RenameFile, key!(Enter)) => { + close_documents(current_item_path, cx)?; + explorer.rename_current(line)?; } (_, key!(Esc) | ctrl!('c')) => {} _ => { @@ -649,18 +653,21 @@ impl Explorer { } } - fn new_path(&mut self, current_parent: PathBuf, file_name: &str, is_dir: bool) -> Result<()> { + fn new_file(&mut self, file_name: &str) -> Result<()> { + let current_parent = self.nearest_folder()?; let path = helix_core::path::get_normalized_path(¤t_parent.join(file_name)); + 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.reveal_file(path) + } - if is_dir { - std::fs::create_dir_all(&path)?; - } else { - 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)?; - }; + fn new_folder(&mut self, file_name: &str) -> Result<()> { + let current_parent = self.nearest_folder()?; + let path = helix_core::path::get_normalized_path(¤t_parent.join(file_name)); + std::fs::create_dir_all(&path)?; self.reveal_file(path) } @@ -674,6 +681,19 @@ impl Explorer { } } + 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().clone() { + let path = parent.to_path_buf(); + self.change_root(path) + } else { + Ok(()) + } + } + pub fn is_opened(&self) -> bool { self.state.open } @@ -693,6 +713,53 @@ impl Explorer { 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.into()) + } + + 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() + .map(|p| p.starts_with(¤t_item_path)) + .unwrap_or(false) + { + Some(id.clone()) + } else { + None + } + }) + .collect::>(); + + for id in ids { + cx.editor.close_document(id, true)?; + } + Ok(()) } impl Component for Explorer { @@ -718,37 +785,28 @@ impl Component for Explorer { return EventResult::Consumed(c); } - match key_event { - key!(Esc) => self.unfocus(), - key!('q') => self.close(), - key!('?') => self.toggle_help(), - key!('a') => { - if let Err(error) = self.new_create_file_prompt() { - cx.editor.set_error(error.to_string()) - } - } - shift!('A') => { - if let Err(error) = self.new_create_folder_prompt() { - cx.editor.set_error(error.to_string()) - } - } - key!('b') => { - if let Some(parent) = self.state.current_root.parent().clone() { - let path = parent.to_path_buf(); - self.change_root(cx, path) + (|| -> Result<()> { + match key_event { + key!(Esc) => self.unfocus(), + key!('q') => self.close(), + key!('?') => self.toggle_help(), + key!('a') => self.new_create_file_prompt()?, + shift!('A') => self.new_create_folder_prompt()?, + key!('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!('-') => self.decrease_size(), + key!('+') => self.increase_size(), + _ => { + self.tree + .handle_event(&Event::Key(*key_event), cx, &mut self.state, &filter); } - } - key!(']') => self.change_root(cx, self.tree.current_item().path.clone()), - key!('[') => self.go_to_previous_root(), - key!('d') => self.new_remove_prompt(cx), - key!('r') => self.new_rename_prompt(cx), - key!('-') => self.decrease_size(), - key!('+') => self.increase_size(), - _ => { - self.tree - .handle_event(&Event::Key(*key_event), cx, &mut self.state, &filter); - } - } + }; + Ok(()) + })() + .unwrap_or_else(|err| cx.editor.set_error(format!("{err}"))); EventResult::Consumed(None) } @@ -833,4 +891,599 @@ fn render_block(area: Rect, surface: &mut Surface, borders: Borders) -> Rect { } #[cfg(test)] -mod test_explore {} +mod test_explorer { + use super::Explorer; + use helix_view::graphics::Rect; + use pretty_assertions::assert_eq; + use std::{fs, path::PathBuf}; + + fn dummy_file_tree<'a>(name: &'a str) -> PathBuf { + use build_fs_tree::{dir, file, Build, MergeableFileSystemTree}; + let tree = MergeableFileSystemTree::<&str, &str>::from(dir! { + "index.html" => file!("") + "scripts" => dir! { + "main.js" => file!("") + } + "styles" => dir! { + "style.css" => file!("") + "public" => dir! { + "file" => file!("") + } + } + ".gitignore" => file!("") + }); + let path: PathBuf = format!("test-explorer/{}", name).into(); + if path.exists() { + fs::remove_dir_all(path.clone()).unwrap(); + } + tree.build(&path).unwrap(); + path + } + + fn render<'a>(explorer: &mut Explorer) -> String { + explorer + .tree + .render_to_string(Rect::new(0, 0, 50, 10), &"".to_string()) + } + + fn new_explorer<'a>(name: &'a str) -> (PathBuf, Explorer) { + let path = dummy_file_tree(name); + (path.clone(), Explorer::from_path(path, 30).unwrap()) + } + + #[test] + fn test_reveal_file() { + let (path, mut explorer) = new_explorer("reveal_file"); + + // 0a. Expect the "scripts" folder is not opened + assert_eq!( + render(&mut explorer), + " +(test-explorer/reveal_file) + 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), + " +[test-explorer/reveal_file] + [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), + " +(test-explorer/reveal_file/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), + " +[test-explorer/reveal_file/styles/public] + (file) +" + .trim() + ); + } + + #[test] + fn test_rename() { + let (path, mut explorer) = new_explorer("rename"); + + explorer.tree.move_down(3); + assert_eq!( + render(&mut explorer), + " +[test-explorer/rename] + scripts + styles + (.gitignore) + index.html +" + .trim() + ); + + // 1. Rename the current file to a name that is lexicographically greater than "index.html" + explorer + .rename_current(&path.join("who.is").to_string_lossy().into()) + .unwrap(); + + // 1a. Expect the file is renamed, and is focused + assert_eq!( + render(&mut explorer), + " +[test-explorer/rename] + scripts + styles + index.html + (who.is) +" + .trim() + ); + + assert!(path.join("who.is").exists()); + + // 2. Rename the current file into an existing folder + explorer + .rename_current(&path.join("styles/lol").to_string_lossy().into()) + .unwrap(); + + // 2a. Expect the file is moved to the folder, and is focused + assert_eq!( + render(&mut explorer), + " +[test-explorer/rename] + 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 + .rename_current(&path.join("new_folder/sponge/bob").to_string_lossy().into()) + .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), + " +[test-explorer/rename] + [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.tree.move_up(1); + explorer.change_root_to_current_folder().unwrap(); + + // 4a. Expect the current root to be "sponge" + assert_eq!( + render(&mut explorer), + " +(test-explorer/rename/new_folder/sponge) + bob +" + .trim() + ); + + // 5. Move cursor to "bob", and move it outside of the current root + explorer.tree.move_down(1); + explorer + .rename_current(&path.join("scripts/bob").to_string_lossy().into()) + .unwrap(); + + // 5a. Expect the current root to be "scripts" + assert_eq!( + render(&mut explorer), + " +[test-explorer/rename/scripts] + (bob) + main.js +" + .trim() + ); + } + + #[test] + fn test_new_folder() { + let (path, mut explorer) = new_explorer("new_folder"); + + // 1. Add a new folder at the root + explorer.new_folder("yoyo").unwrap(); + + // 1a. Expect the new folder is added, and is focused + assert_eq!( + render(&mut explorer), + " +[test-explorer/new_folder] + scripts + styles + (yoyo) + .gitignore + index.html +" + .trim() + ); + + assert!(fs::read_dir(path.join("yoyo")).is_ok()); + + // 2. Move up to "styles" + explorer.tree.move_up(1); + + // 3. Add a new folder + explorer.new_folder("sus.sass").unwrap(); + + // 3a. Expect the new folder is added under "styles", although "styles" is not opened + assert_eq!( + render(&mut explorer), + " +[test-explorer/new_folder] + 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.new_folder("a/b/c").unwrap(); + + // 4a. Expect the non-existent parents are created, + // and the new folder is created, + // and is focused + assert_eq!( + render(&mut explorer), + " + [styles] +  public +  [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.tree.move_down(1); + + // 6. Add a new folder here + explorer.new_folder("foobar").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), + " +[test-explorer/new_folder] + scripts + [styles] +  (foobar) +  public +  sus.sass +  a +  b +  c + style.css +" + .trim() + ); + + assert!(fs::read_dir(path.join("styles/foobar")).is_ok()); + } + + #[test] + fn test_new_file() { + let (path, mut explorer) = new_explorer("new_file"); + // 1. Add a new file at the root + explorer.new_file("yoyo").unwrap(); + + // 1a. Expect the new file is added, and is focused + assert_eq!( + render(&mut explorer), + " +[test-explorer/new_file] + 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.new_file("sus.sass").unwrap(); + + // 3a. Expect the new file is added under "styles", although "styles" is not opened + assert_eq!( + render(&mut explorer), + " +[test-explorer/new_file] + 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.new_file("a/b/c").unwrap(); + + // 4a. Expect the non-existent parents are created, + // and the new file is created, + // and is focused + assert_eq!( + render(&mut explorer), + " +[test-explorer/new_file] + 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.tree.move_down(2); + + // 6. Add a new file here + explorer.new_file("foobar").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), + " + [styles] +  a +  b + c +  public + (foobar) + style.css + sus.sass + .gitignore + index.html +" + .trim() + ); + + assert!(fs::read_to_string(path.join("styles/foobar")).is_ok()); + } + + #[test] + fn test_remove_file() { + let (path, mut explorer) = new_explorer("remove_file"); + + // 1. Move to ".gitignore" + explorer.reveal_file(path.join(".gitignore")).unwrap(); + + // 1a. Expect the cursor is at ".gitignore" + assert_eq!( + render(&mut explorer), + " +[test-explorer/remove_file] + scripts + styles + (.gitignore) + index.html +" + .trim() + ); + + assert!(fs::read_to_string(path.join(".gitignore")).is_ok()); + + // 2. Remove the current file + explorer.remove_file().unwrap(); + + // 3. Expect ".gitignore" is deleted, and the cursor moved down + assert_eq!( + render(&mut explorer), + " +[test-explorer/remove_file] + 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.remove_file().unwrap(); + + // 4a. Expect "index.html" is deleted, at the cursor moved up + assert_eq!( + render(&mut explorer), + " +[test-explorer/remove_file] + scripts + (styles) +" + .trim() + ); + + assert!(fs::read_to_string(path.join("index.html")).is_err()); + } + + #[test] + fn test_remove_folder() { + let (path, mut explorer) = new_explorer("remove_folder"); + + // 1. Move to "styles/" + explorer.reveal_file(path.join("styles")).unwrap(); + + // 1a. Expect the cursor is at "styles" + assert_eq!( + render(&mut explorer), + " +[test-explorer/remove_folder] + scripts + (styles) +  public + style.css + .gitignore + index.html +" + .trim() + ); + + assert!(fs::read_dir(path.join("styles")).is_ok()); + + // 2. Remove the current folder + explorer.remove_folder().unwrap(); + + // 3. Expect "styles" is deleted, and the cursor moved down + assert_eq!( + render(&mut explorer), + " +[test-explorer/remove_folder] + 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("change_root"); + + // 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), + " +[test-explorer/change_root/styles] + (public) + style.css +" + .trim() + ); + + // 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), + " +(test-explorer/change_root) + 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), + " +[test-explorer/change_root/styles] + (public) + style.css +" + .trim() + ); + + // 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), + " +[test-explorer/change_root] + scripts + (styles) +  public + style.css + .gitignore + index.html +" + .trim() + ); + } +} diff --git a/helix-term/src/ui/tree.rs b/helix-term/src/ui/tree.rs index d83577d4..486580c0 100644 --- a/helix-term/src/ui/tree.rs +++ b/helix-term/src/ui/tree.rs @@ -315,8 +315,6 @@ pub struct TreeView { /// For implementing vertical scroll winline: usize, - previous_area: Rect, - /// For implementing horizontal scoll column: usize, @@ -324,10 +322,16 @@ pub struct TreeView { max_len: usize, count: usize, tree_symbol_style: String, + + #[allow(clippy::type_complexity)] + pre_render: Option>, + #[allow(clippy::type_complexity)] on_opened_fn: Option TreeOp + 'static>>, + #[allow(clippy::type_complexity)] on_folded_fn: Option>, + #[allow(clippy::type_complexity)] on_next_key: Option>, } @@ -343,8 +347,8 @@ impl TreeView { column: 0, max_len: 0, count: 0, - previous_area: Rect::new(0, 0, 0, 0), tree_symbol_style: "ui.text".into(), + pre_render: None, on_opened_fn: None, on_folded_fn: None, on_next_key: None, @@ -388,10 +392,11 @@ impl TreeView { /// ``` /// vec!["helix-term", "src", "ui", "tree.rs"] /// ``` - pub fn reveal_item(&mut self, segments: Vec<&str>, filter: &String) -> Result<()> { + pub fn reveal_item(&mut self, segments: Vec, filter: &String) -> Result<()> { self.refresh_with_filter(filter)?; // Expand the tree + let root = self.tree.item.name(); segments.iter().fold( Ok(&mut self.tree), |current_tree, segment| match current_tree { @@ -409,9 +414,8 @@ impl TreeView { Ok(tree) } None => Err(anyhow::anyhow!(format!( - "Unable to find path: '{}'. current_segment = {}", + "Unable to find path: '{}'. current_segment = '{segment}'. current_root = '{root}'", segments.join("/"), - segment ))), } } @@ -437,7 +441,9 @@ impl TreeView { } fn align_view_center(&mut self) { - self.winline = self.previous_area.height as usize / 2 + self.pre_render = Some(Box::new(|tree, area| { + tree.winline = area.height as usize / 2 + })) } fn align_view_top(&mut self) { @@ -445,7 +451,7 @@ impl TreeView { } fn align_view_bottom(&mut self) { - self.winline = self.previous_area.height as usize + self.pre_render = Some(Box::new(|tree, area| tree.winline = area.height as usize)) } fn regenerate_index(&mut self) { @@ -492,11 +498,6 @@ impl TreeView { self.move_down(usize::MAX / 2) } - #[cfg(test)] - fn set_previous_area(&mut self, area: Rect) { - self.previous_area = area - } - fn move_leftmost(&mut self) { self.move_left(usize::MAX / 2); } @@ -608,7 +609,7 @@ impl TreeView { self.search_previous(&self.search_str.clone()) } - fn move_down(&mut self, rows: usize) { + pub fn move_down(&mut self, rows: usize) { let len = self.tree.len(); if len > 0 { self.set_selected(std::cmp::min(self.selected + rows, len.saturating_sub(1))) @@ -647,7 +648,7 @@ impl TreeView { } } - fn move_up(&mut self, rows: usize) { + pub fn move_up(&mut self, rows: usize) { let len = self.tree.len(); if len > 0 { self.set_selected(self.selected.saturating_sub(rows).max(0)) @@ -659,27 +660,34 @@ impl TreeView { } fn move_right(&mut self, cols: usize) { - let max_scroll = self - .max_len - .saturating_sub(self.previous_area.width as usize) - .saturating_add(1); - self.column = max_scroll.min(self.column + cols); + self.pre_render = Some(Box::new(move |tree, area| { + let max_scroll = tree.max_len.saturating_sub(area.width as usize); + tree.column = max_scroll.min(tree.column + cols); + })); } fn move_down_half_page(&mut self) { - self.move_down(self.previous_area.height as usize / 2) + self.pre_render = Some(Box::new(|tree, area| { + tree.move_down((area.height / 2) as usize); + })); } fn move_up_half_page(&mut self) { - self.move_up(self.previous_area.height as usize / 2); + self.pre_render = Some(Box::new(|tree, area| { + tree.move_up((area.height / 2) as usize); + })); } fn move_down_page(&mut self) { - self.move_down(self.previous_area.height as usize); + self.pre_render = Some(Box::new(|tree, area| { + tree.move_down((area.height) as usize); + })); } fn move_up_page(&mut self) { - self.move_up(self.previous_area.height as usize); + self.pre_render = Some(Box::new(|tree, area| { + tree.move_up((area.height) as usize); + })); } fn save_view(&mut self) { @@ -856,8 +864,7 @@ impl TreeView { } #[cfg(test)] - fn render_to_string(&mut self, filter: &String) -> String { - let area = self.previous_area; + pub fn render_to_string(&mut self, area: Rect, filter: &String) -> String { let lines = self.render_lines(area, filter); lines .into_iter() @@ -876,10 +883,11 @@ impl TreeView { } fn render_lines(&mut self, area: Rect, filter: &String) -> Vec { - self.previous_area = area; - self.winline = self - .winline - .min(self.previous_area.height.saturating_sub(1) as usize); + 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, @@ -902,7 +910,7 @@ impl TreeView { .max() .unwrap_or(0); - let max_width = self.previous_area.width as usize; + let max_width = area.width as usize; lines .into_iter() @@ -1185,9 +1193,8 @@ mod test_tree_view { } fn dummy_tree_view<'a>() -> TreeView> { - let root = item("who_lives_in_a_pineapple_under_the_sea"); - let mut view = TreeView::new( - root, + TreeView::new( + item("who_lives_in_a_pineapple_under_the_sea"), vec_to_tree(vec![ item("gary_the_snail"), item("krabby_patty"), @@ -1200,11 +1207,7 @@ mod test_tree_view { item("karen"), item("plankton"), ]), - ); - - view.set_previous_area(dummy_area()); - - view + ) } fn dummy_area() -> Rect { @@ -1212,7 +1215,7 @@ mod test_tree_view { } fn render<'a>(view: &mut TreeView>) -> String { - view.render_to_string(&"".to_string()) + view.render_to_string(dummy_area(), &"".to_string()) } #[test] @@ -1393,7 +1396,6 @@ mod test_tree_view { fn test_move_half() { let mut view = dummy_tree_view(); view.move_down_half_page(); - assert_eq!(view.selected, 2); assert_eq!( render(&mut view), " @@ -1534,7 +1536,10 @@ mod test_tree_view { #[test] fn test_move_left_right() { let mut view = dummy_tree_view(); - view.set_previous_area(dummy_area().with_width(20)); + + fn render<'a>(view: &mut TreeView>) -> String { + view.render_to_string(dummy_area().with_width(20), &"".to_string()) + } assert_eq!( render(&mut view), @@ -1627,7 +1632,7 @@ krabby_patty ); view.move_rightmost(); - assert_eq!(render(&mut view), "(apple_under_the_sea)\n\n\n\n"); + assert_eq!(render(&mut view), "(eapple_under_the_sea)\n\n\n\n"); } #[test] @@ -1921,6 +1926,8 @@ krabby_patty fn test_jump_backward() { let mut view = dummy_tree_view(); view.move_down_half_page(); + render(&mut view); + view.move_down_half_page(); assert_eq!( render(&mut view),