From 5c371208692df2727d02a37646b7829f011680a8 Mon Sep 17 00:00:00 2001 From: JJ Date: Tue, 31 Oct 2023 17:37:26 -0700 Subject: 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 Co-authored-by: wongjiahau --- helix-term/src/ui/explorer.rs | 1464 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1464 insertions(+) create mode 100644 helix-term/src/ui/explorer.rs (limited to 'helix-term/src/ui/explorer.rs') 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 { + 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> { + 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 { + 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, + current_root: PathBuf, +} + +pub struct Explorer { + tree: TreeView, + history: Vec, + show_help: bool, + state: State, + prompt: Option<(PromptAction, Prompt)>, + #[allow(clippy::type_complexity)] + on_next_key: Option EventResult>>, + column_width: u16, +} + +impl Explorer { + pub fn new(cx: &mut Context) -> Result { + 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 { + 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> { + let root = FileInfo::root(root); + Ok(TreeView::build_tree(root)?.with_enter_fn(Self::toggle_current)) + } + + fn push_history(&mut self, tree_view: TreeView, 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::>() + }; + 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 { + 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 { + 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::>(), + ) + .render(area, surface, cx) + } + + fn handle_prompt_event(&mut self, event: &KeyEvent, cx: &mut Context) -> EventResult { + let result = (|| -> Result { + let (action, mut prompt) = match self.prompt.take() { + Some((action, p)) => (action, p), + _ => return Ok(EventResult::Ignored(None)), + }; + let line = prompt.line(); + + let current_item_path = self.tree.current_item()?.path.clone(); + match (&action, event) { + (PromptAction::CreateFileOrFolder, key!(Enter)) => { + if line.ends_with(std::path::MAIN_SEPARATOR) { + self.new_folder(line)? + } else { + self.new_file(line)? + } + } + (PromptAction::RemoveFolder, key) => { + if let key!('y') = key { + close_documents(current_item_path, cx)?; + self.remove_folder()?; + } + } + (PromptAction::RemoveFile, key) => { + if let key!('y') = key { + close_documents(current_item_path, cx)?; + self.remove_file()?; + } + } + (PromptAction::RenameFile, key!(Enter)) => { + close_documents(current_item_path, cx)?; + self.rename_current(line)?; + } + (_, key!(Esc) | ctrl!('c')) => {} + _ => { + prompt.handle_event(&Event::Key(*event), cx); + self.prompt = Some((action, prompt)); + } + } + Ok(EventResult::Consumed(None)) + })(); + match result { + Ok(event_result) => event_result, + Err(err) => { + cx.editor.set_error(err.to_string()); + EventResult::Consumed(None) + } + } + } + + fn new_file(&mut self, path: &str) -> Result<()> { + let path = helix_core::path::get_normalized_path(&PathBuf::from(path)); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut fd = std::fs::OpenOptions::new(); + fd.create_new(true).write(true).open(&path)?; + self.tree.refresh()?; + self.reveal_file(path) + } + + fn new_folder(&mut self, path: &str) -> Result<()> { + let path = helix_core::path::get_normalized_path(&PathBuf::from(path)); + std::fs::create_dir_all(&path)?; + self.tree.refresh()?; + self.reveal_file(path) + } + + fn toggle_help(&mut self) { + self.show_help = !self.show_help + } + + fn go_to_previous_root(&mut self) { + if let Some(history) = self.history.pop() { + self.tree = history.tree; + self.state.current_root = history.current_root + } + } + + fn change_root_to_current_folder(&mut self) -> Result<()> { + self.change_root(self.tree.current_item()?.path.clone()) + } + + fn change_root_parent_folder(&mut self) -> Result<()> { + if let Some(parent) = self.state.current_root.parent() { + let path = parent.to_path_buf(); + self.change_root(path) + } else { + Ok(()) + } + } + + pub fn is_opened(&self) -> bool { + self.state.open + } + + pub fn column_width(&self) -> u16 { + self.column_width + } + + fn increase_size(&mut self) { + const EDITOR_MIN_WIDTH: u16 = 10; + self.column_width = std::cmp::min( + self.state.area_width.saturating_sub(EDITOR_MIN_WIDTH), + self.column_width.saturating_add(1), + ) + } + + fn decrease_size(&mut self) { + self.column_width = self.column_width.saturating_sub(1) + } + + fn rename_current(&mut self, line: &String) -> Result<()> { + let item = self.tree.current_item()?; + let path = PathBuf::from(line); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::rename(&item.path, &path)?; + self.tree.refresh()?; + self.reveal_file(path) + } + + fn remove_folder(&mut self) -> Result<()> { + let item = self.tree.current_item()?; + std::fs::remove_dir_all(&item.path)?; + self.tree.refresh() + } + + fn remove_file(&mut self) -> Result<()> { + let item = self.tree.current_item()?; + std::fs::remove_file(&item.path)?; + self.tree.refresh() + } +} + +fn close_documents(current_item_path: PathBuf, cx: &mut Context) -> Result<()> { + let ids = cx + .editor + .documents + .iter() + .filter_map(|(id, doc)| { + if doc.path()?.starts_with(¤t_item_path) { + Some(*id) + } else { + None + } + }) + .collect::>(); + + 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, 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: + /// + /// + /// ├── 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("who.is").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!( + "rstyles{}lol", + 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{}", + 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{}", + 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/")) + .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/")) + .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/")) + .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/")) + .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").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").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").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").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").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("/styleso").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() + ); + } +} -- cgit v1.2.3-70-g09d2