use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Group {
Standard,
Volume,
Workspace,
Recent,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Place {
pub label: String,
pub path: PathBuf,
pub group: Group,
pub glyph: &'static str,
}
fn home() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
pub fn standard_dirs() -> Vec<Place> {
let Some(h) = home() else {
return Vec::new();
};
let rows: [(&str, &str, &'static str); 7] = [
("Home", "", "\u{f015}"),
("Desktop", "Desktop", "\u{f108}"),
("Downloads", "Downloads", "\u{f019}"),
("Documents", "Documents", "\u{f0f6}"),
("Pictures", "Pictures", "\u{f03e}"),
("Music", "Music", "\u{f001}"),
("Videos", "Movies", "\u{f03d}"),
];
rows.iter()
.filter_map(|(label, sub, glyph)| {
let p = if sub.is_empty() {
h.clone()
} else {
h.join(sub)
};
p.is_dir().then(|| Place {
label: (*label).to_string(),
path: p,
group: Group::Standard,
glyph,
})
})
.collect()
}
pub fn volumes() -> Vec<Place> {
let mut roots: Vec<PathBuf> = vec![PathBuf::from("/Volumes")];
if let Some(user) = std::env::var_os("USER") {
roots.push(PathBuf::from("/media").join(&user));
roots.push(PathBuf::from("/run/media").join(&user));
}
let mut out = Vec::new();
for root in roots {
let Ok(rd) = std::fs::read_dir(&root) else {
continue;
};
for ent in rd.flatten() {
let p = ent.path();
if !p.is_dir() {
continue;
}
let name = ent.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
out.push(Place {
label: name,
path: p,
group: Group::Volume,
glyph: "\u{f0a0}",
});
}
}
out.sort_by_key(|a| a.label.to_lowercase());
out
}
pub fn all(workspaces: &[(String, PathBuf)], recents: &[PathBuf]) -> Vec<Place> {
let mut out = standard_dirs();
out.extend(volumes());
out.extend(
workspaces
.iter()
.filter(|(_, p)| p.is_dir())
.map(|(label, p)| Place {
label: label.clone(),
path: p.clone(),
group: Group::Workspace,
glyph: "\u{f4d5}",
}),
);
let mut seen: Vec<&Path> = out.iter().map(|p| p.path.as_path()).collect();
let mut recent_rows = Vec::new();
for r in recents {
if !r.is_dir() || seen.contains(&r.as_path()) {
continue;
}
seen.push(r.as_path());
recent_rows.push(Place {
label: r
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| r.display().to_string()),
path: r.clone(),
group: Group::Recent,
glyph: "\u{f017}",
});
}
out.extend(recent_rows);
out
}
pub fn breadcrumb(dir: &Path) -> Vec<(String, PathBuf)> {
let mut chain: Vec<(String, PathBuf)> = Vec::new();
let mut cur = Some(dir);
while let Some(p) = cur {
let label = p
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| p.display().to_string());
chain.push((label, p.to_path_buf()));
cur = p.parent();
}
chain.reverse();
chain
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standard_dirs_only_lists_directories_that_exist() {
let d = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _h = crate::EnvGuard::set("HOME", d.path());
std::fs::create_dir(d.path().join("Downloads")).unwrap();
let places = standard_dirs();
let labels: Vec<&str> = places.iter().map(|p| p.label.as_str()).collect();
assert!(labels.contains(&"Home"), "Home always exists: {labels:?}");
assert!(labels.contains(&"Downloads"), "{labels:?}");
assert!(
!labels.contains(&"Pictures"),
"listed a directory that does not exist — a dead click: {labels:?}"
);
}
#[test]
fn standard_dirs_are_in_menu_order_not_alphabetical() {
let d = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _h = crate::EnvGuard::set("HOME", d.path());
for n in ["Desktop", "Downloads", "Documents"] {
std::fs::create_dir(d.path().join(n)).unwrap();
}
let labels: Vec<String> = standard_dirs().into_iter().map(|p| p.label).collect();
assert_eq!(
labels,
vec!["Home", "Desktop", "Downloads", "Documents"],
"alphabetical order would put Documents before Downloads"
);
}
#[test]
fn breadcrumb_is_root_first_and_includes_the_directory_itself() {
let chain = breadcrumb(Path::new("/a/b/c"));
let labels: Vec<&str> = chain.iter().map(|(l, _)| l.as_str()).collect();
assert_eq!(labels, vec!["/", "a", "b", "c"]);
assert_eq!(chain.last().unwrap().1, PathBuf::from("/a/b/c"));
assert_eq!(chain[1].1, PathBuf::from("/a"));
assert_eq!(chain[2].1, PathBuf::from("/a/b"));
}
#[test]
fn breadcrumb_of_the_root_is_just_the_root() {
let chain = breadcrumb(Path::new("/"));
assert_eq!(chain.len(), 1);
assert_eq!(chain[0].0, "/");
}
#[test]
fn recents_are_deduplicated_against_the_other_groups() {
let d = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _h = crate::EnvGuard::set("HOME", d.path());
let dl = d.path().join("Downloads");
std::fs::create_dir(&dl).unwrap();
let places = all(&[], std::slice::from_ref(&dl));
let hits = places.iter().filter(|p| p.path == dl).count();
assert_eq!(hits, 1, "Downloads appeared {hits} times");
}
#[test]
fn a_workspace_that_no_longer_exists_is_dropped() {
let d = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _h = crate::EnvGuard::set("HOME", d.path());
let gone = d.path().join("deleted-project");
let places = all(&[("Gone".into(), gone.clone())], &[]);
assert!(
!places.iter().any(|p| p.path == gone),
"listed a workspace directory that has been deleted"
);
}
}