use std::fs;
use super::{ActionInput, ExplorerMode, ExplorerState};
impl ExplorerState {
pub fn enter_filter_mode(&mut self) {
self.mode = ExplorerMode::Filter;
self.cursor = self.query.chars().count();
self.error = None;
}
pub fn cancel_pending(&mut self) {
self.mode = ExplorerMode::Selection;
self.action = None;
self.error = None;
}
pub fn enter_create_mode(&mut self) {
let seed = self.create_seed();
self.mode = ExplorerMode::PendingCreate;
self.action = Some(ActionInput::new(&seed));
self.error = None;
}
pub fn enter_rename_mode(&mut self) {
let Some(name) = self.selection().map(|n| n.name.clone()) else {
return;
};
self.mode = ExplorerMode::PendingRename;
self.action = Some(ActionInput::new(&name));
self.error = None;
}
pub fn enter_move_mode(&mut self) {
let Some(rel) = self.selection().map(|n| n.rel_path.clone()) else {
return;
};
self.mode = ExplorerMode::PendingMove;
self.action = Some(ActionInput::new(&rel));
self.error = None;
}
pub fn enter_delete_mode(&mut self) {
if self.selection().is_none() {
return;
}
self.mode = ExplorerMode::PendingDelete;
self.action = None;
self.error = None;
}
fn create_seed(&self) -> String {
let Some(node) = self.selection() else {
return String::new();
};
if node.is_dir {
format!("{}/", node.rel_path)
} else if let Some(slash) = node.rel_path.rfind('/') {
format!("{}/", &node.rel_path[..slash])
} else {
String::new()
}
}
pub fn perform_create(&mut self, rel: &str) -> Result<String, String> {
let trimmed = rel.trim();
if trimmed.is_empty() {
return Err("empty path".into());
}
let is_dir = trimmed.ends_with('/');
let clean = trimmed.trim_end_matches('/').to_string();
if clean.is_empty() || clean.starts_with('/') || clean.contains("..") {
return Err(format!("invalid path: {trimmed}"));
}
let target = self.root.join(&clean);
if target.exists() {
return Err(format!("already exists: {clean}"));
}
if is_dir {
fs::create_dir_all(&target).map_err(|e| format!("mkdir: {e}"))?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
}
fs::File::create(&target).map_err(|e| format!("create: {e}"))?;
}
self.refresh();
self.select_by_path(&clean);
Ok(clean)
}
pub fn perform_delete(&mut self, rel: &str) -> Result<(), String> {
if rel.is_empty() {
return Err("nothing to delete".into());
}
let target = self.root.join(rel);
let meta = fs::symlink_metadata(&target).map_err(|e| format!("stat: {e}"))?;
if meta.file_type().is_dir() {
fs::remove_dir_all(&target).map_err(|e| format!("rmdir: {e}"))?;
} else {
fs::remove_file(&target).map_err(|e| format!("rm: {e}"))?;
}
self.refresh();
Ok(())
}
pub fn perform_rename(&mut self, old_rel: &str, new_name: &str) -> Result<String, String> {
let new_name = new_name.trim();
if new_name.is_empty() || new_name.contains('/') {
return Err(format!("invalid name: {new_name}"));
}
let parent_rel = match old_rel.rfind('/') {
Some(i) => &old_rel[..i],
None => "",
};
let new_rel = if parent_rel.is_empty() {
new_name.to_string()
} else {
format!("{parent_rel}/{new_name}")
};
self.fs_rename(old_rel, &new_rel)
}
pub fn perform_move(&mut self, old_rel: &str, new_rel: &str) -> Result<String, String> {
let new_rel = new_rel.trim().trim_end_matches('/');
if new_rel.is_empty() || new_rel.starts_with('/') || new_rel.contains("..") {
return Err(format!("invalid path: {new_rel}"));
}
self.fs_rename(old_rel, new_rel)
}
fn fs_rename(&mut self, old_rel: &str, new_rel: &str) -> Result<String, String> {
if old_rel == new_rel {
return Err("source and destination are the same".into());
}
let src = self.root.join(old_rel);
let dst = self.root.join(new_rel);
if dst.exists() {
return Err(format!("already exists: {new_rel}"));
}
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
}
fs::rename(&src, &dst).map_err(|e| format!("rename: {e}"))?;
self.refresh();
self.select_by_path(new_rel);
Ok(new_rel.to_string())
}
}
#[cfg(test)]
mod tests {
use super::super::tests::default_patterns;
use super::*;
use crate::finder::IgnoreOpts;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn toggle_hidden_surfaces_dotfile_at_root() {
let tmp = std::env::temp_dir().join(format!(
"vorto-explorer-dotfile-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join(".gitignore"), "/target\n").unwrap();
std::fs::write(tmp.join("README.md"), "# test\n").unwrap();
std::fs::create_dir_all(tmp.join("src")).unwrap();
std::fs::write(tmp.join("src/main.rs"), "fn main() {}\n").unwrap();
let mut s = ExplorerState::new(&tmp, IgnoreOpts::DEFAULT, default_patterns(), 5000, false);
let initial_visible: Vec<String> = s
.visible
.iter()
.map(|&i| s.nodes[i].rel_path.clone())
.collect();
assert!(
!initial_visible.iter().any(|p| p == ".gitignore"),
"dotfile should be hidden by default, got {:?}",
initial_visible
);
s.apply_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE));
let after: Vec<String> = s
.visible
.iter()
.map(|&i| s.nodes[i].rel_path.clone())
.collect();
let _ = std::fs::remove_dir_all(&tmp);
assert!(
after.iter().any(|p| p == ".gitignore"),
"after pressing `.`, dotfile should appear, got {:?}",
after
);
}
#[test]
fn toggle_vcs_after_expanding_empty_gitignored_dir() {
let tmp = std::env::temp_dir().join(format!(
"vorto-explorer-expand-then-h-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join(".gitignore"), "/scratch/\n").unwrap();
std::fs::write(tmp.join("README.md"), "# test\n").unwrap();
std::fs::create_dir_all(tmp.join("scratch")).unwrap();
std::fs::write(tmp.join("scratch/hello.txt"), "hi\n").unwrap();
let ok = std::process::Command::new("git")
.args(["-C"])
.arg(&tmp)
.args(["init", "-q"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
let _ = std::fs::remove_dir_all(&tmp);
return;
}
let _ = std::process::Command::new("git")
.args(["-C"])
.arg(&tmp)
.args(["add", "-A"])
.status();
let mut s = ExplorerState::new(&tmp, IgnoreOpts::DEFAULT, default_patterns(), 5000, false);
s.expanded.insert("scratch".into());
s.refilter();
let after_expand: Vec<String> = s
.visible
.iter()
.map(|&i| s.nodes[i].rel_path.clone())
.collect();
eprintln!("expanded but vcs=true: {:?}", after_expand);
s.apply_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
let after_h: Vec<String> = s
.visible
.iter()
.map(|&i| s.nodes[i].rel_path.clone())
.collect();
eprintln!("after h: {:?}", after_h);
let _ = std::fs::remove_dir_all(&tmp);
assert!(
after_h.iter().any(|p| p == "scratch/hello.txt"),
"expected scratch/hello.txt to appear after `h`, got {:?}",
after_h
);
}
#[test]
fn toggle_vcs_surfaces_gitignored_dir_in_git_repo() {
let tmp = std::env::temp_dir().join(format!(
"vorto-explorer-gitignored-dir-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join(".gitignore"), "/scratch/\n").unwrap();
std::fs::write(tmp.join("README.md"), "# test\n").unwrap();
std::fs::create_dir_all(tmp.join("scratch")).unwrap();
std::fs::write(tmp.join("scratch/hello.txt"), "hi\n").unwrap();
let ok = std::process::Command::new("git")
.args(["-C"])
.arg(&tmp)
.args(["init", "-q"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
let _ = std::fs::remove_dir_all(&tmp);
return;
}
let _ = std::process::Command::new("git")
.args(["-C"])
.arg(&tmp)
.args(["add", "-A"])
.status();
let mut s = ExplorerState::new(&tmp, IgnoreOpts::DEFAULT, default_patterns(), 5000, false);
let initial: Vec<String> = s
.visible
.iter()
.map(|&i| s.nodes[i].rel_path.clone())
.collect();
eprintln!("DEFAULT visible: {:?}", initial);
s.apply_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
s.expanded.insert("scratch".into());
s.refilter();
let after: Vec<String> = s
.visible
.iter()
.map(|&i| s.nodes[i].rel_path.clone())
.collect();
eprintln!("AFTER h + expand visible: {:?}", after);
let _ = std::fs::remove_dir_all(&tmp);
assert!(
after.iter().any(|p| p == "scratch/hello.txt"),
"after pressing `h` and expanding scratch, gitignored file should appear, got {:?}",
after
);
}
#[test]
fn toggle_hidden_surfaces_dotfile_in_git_repo() {
let tmp = std::env::temp_dir().join(format!(
"vorto-explorer-dotfile-git-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join(".gitignore"), "/target\n").unwrap();
std::fs::write(tmp.join("README.md"), "# test\n").unwrap();
std::fs::create_dir_all(tmp.join("src")).unwrap();
std::fs::write(tmp.join("src/main.rs"), "fn main() {}\n").unwrap();
let ok = std::process::Command::new("git")
.args(["-C"])
.arg(&tmp)
.args(["init", "-q"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
let _ = std::fs::remove_dir_all(&tmp);
return; }
let _ = std::process::Command::new("git")
.args(["-C"])
.arg(&tmp)
.args(["add", "-A"])
.status();
let mut s = ExplorerState::new(&tmp, IgnoreOpts::DEFAULT, default_patterns(), 5000, false);
s.apply_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE));
let after: Vec<String> = s
.visible
.iter()
.map(|&i| s.nodes[i].rel_path.clone())
.collect();
let _ = std::fs::remove_dir_all(&tmp);
assert!(
after.iter().any(|p| p == ".gitignore"),
"in git repo, after pressing `.`, .gitignore should appear, got {:?}",
after
);
}
}