You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
helix-plus/helix-term/src/ui/explorer.rs

1443 lines
40 KiB
Rust

use super::{tree::TreeIcons, 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::path::{Path, PathBuf};
use std::{borrow::Cow, fs::DirEntry};
use std::{cmp::Ordering, sync::Arc};
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 => format!("{}", self.path.display()),
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(), Self::get_tree_icons(cx))?,
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,
})
}
fn get_tree_icons(cx: &Context) -> TreeIcons {
let icons = cx.editor.icons.clone();
let defaults = TreeIcons::default();
let file_icon = icons
.ui
.as_ref()
.and_then(|s| s.get("file").cloned())
.unwrap_or(defaults.item);
let item = file_icon.clone();
let tree_closed = icons
.ui
.as_ref()
.and_then(|s| s.get("folder").cloned())
.unwrap_or(defaults.tree_closed);
let tree_opened = icons
.ui
.as_ref()
.and_then(|s| s.get("folder_opened").cloned())
.unwrap_or(defaults.tree_opened);
TreeIcons {
tree_closed,
tree_opened,
item,
icon_fn: Some(Arc::new(move |item| {
icons
.icon_from_path(Some(&PathBuf::from(item)))
.cloned()
.unwrap_or_else(|| file_icon.to_owned())
})),
}
}
#[cfg(test)]
fn from_path(root: PathBuf, column_width: u16) -> Result<Self> {
Ok(Self {
tree: Self::new_tree_view(root.clone(), TreeIcons::default())?,
history: vec![],
show_help: false,
state: State::new(true, root),
prompt: None,
on_next_key: None,
column_width,
})
}
fn new_tree_view(root: PathBuf, icons: TreeIcons) -> Result<TreeView<FileInfo>> {
let root = FileInfo::root(root);
Ok(TreeView::build_tree(root)?
.with_enter_fn(Self::toggle_current)
.icons(icons))
}
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(), self.tree.icons.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;
}
pub 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.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.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(())
}
}
/// Returns the current file in the tree view
pub fn current_file(&self) -> Option<&PathBuf> {
self.tree.current_item().ok().map(|c| &c.path)
}
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:
///
/// test_explorer/<name>
/// ├── index.html
/// ├── .gitignore
/// ├── scripts
/// │ └── main.js
/// └── styles
/// ├── style.css
/// └── public
/// └── file
///
fn dummy_file_tree(name: &str) -> PathBuf {
let path: PathBuf = format!("test_explorer{}{}", std::path::MAIN_SEPARATOR, name).into();
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, 50, 10))
}
fn new_explorer(name: &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()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_rename() {
let (path, mut explorer) = new_explorer("rename");
explorer.handle_events("jjj").unwrap();
assert_eq!(
render(&mut explorer),
"
[test_explorer/rename]
⏵ 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, "/"),
"test_explorer/rename/.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),
"
[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
.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),
"
[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
.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),
"
[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.handle_events("k]").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 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),
"
[test_explorer/rename/scripts]
(bob)
main.js
"
.trim()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_new_folder() {
let (path, mut explorer) = new_explorer("new_folder");
// 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()), "test_explorer/new_folder/");
// 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),
"
[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.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),
"
[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
.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),
"
[test_explorer/new_folder]
⏷ [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),
"
[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());
}
#[tokio::test(flavor = "multi_thread")]
async fn test_new_file() {
let (path, mut explorer) = new_explorer("new_file");
// 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),
"
[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.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),
"
[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.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),
"
[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.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),
"
[test_explorer/new_file]
⏷ [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("remove_file");
// 1. Move to ".gitignore"
explorer.handle_events("/.gitignore<ret>").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.handle_events("dy").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.handle_events("dy").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());
}
#[tokio::test(flavor = "multi_thread")]
async fn test_remove_folder() {
let (path, mut explorer) = new_explorer("remove_folder");
// 1. Move to "styles/"
explorer.handle_events("/styles<ret>o").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.handle_events("dy").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()
);
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),
"
(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()
);
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),
"
[test_explorer/change_root]
⏵ scripts
⏷ (styles)
⏵ public
style.css
.gitignore
index.html
"
.trim()
);
}
}