use std::env;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use regex::Regex;
use serde::{Deserialize, Serialize};
pub const APP_DIR: &str = "tree-space";
pub const CONFIG_FILE: &str = "config.toml";
pub const STATE_FILE: &str = "state.toml";
pub const STYLE_FILE: &str = "main.css";
const DEFAULT_CONFIG: &str = include_str!("../default-config.toml");
pub const DEFAULT_STYLESHEET: &str = include_str!("../style/main.css");
fn builtin() -> &'static Config {
static ONCE: OnceLock<Config> = OnceLock::new();
ONCE.get_or_init(|| {
let raw: ShippedRaw = toml::from_str(DEFAULT_CONFIG)
.expect("default-config.toml must be valid TOML");
Config {
panel: raw.panel.unwrap_or_default(),
tree: raw.tree.unwrap_or_default(),
startup: raw.startup.unwrap_or_default(),
context_menu: raw.context_menu.unwrap_or_default(),
pane_menu: raw.pane_menu.unwrap_or_default(),
}
})
}
#[derive(Deserialize)]
struct ShippedRaw {
#[serde(default)]
panel: Option<PanelConfig>,
#[serde(default)]
tree: Option<TreeConfig>,
#[serde(default)]
startup: Option<StartupRoot>,
#[serde(default)]
context_menu: Option<ContextMenu>,
#[serde(default)]
pane_menu: Option<PaneMenu>,
}
fn xdg_config_home(xdg: Option<&str>, home: Option<&str>) -> PathBuf {
match xdg.filter(|s| !s.is_empty()) {
Some(dir) => PathBuf::from(dir),
None => home.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")).join(".config"),
}
}
fn xdg_state_home(xdg: Option<&str>, home: Option<&str>) -> PathBuf {
match xdg.filter(|s| !s.is_empty()) {
Some(dir) => PathBuf::from(dir),
None => home.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")).join(".local/state"),
}
}
fn app_path(base: &Path, file: &str) -> PathBuf {
base.join(APP_DIR).join(file)
}
fn config_home_from_env() -> PathBuf {
let xdg = env::var_os("XDG_CONFIG_HOME").map(|v| v.to_string_lossy().into_owned());
let home = env::var_os("HOME").map(|v| v.to_string_lossy().into_owned());
xdg_config_home(xdg.as_deref(), home.as_deref())
}
fn state_home_from_env() -> PathBuf {
let xdg = env::var_os("XDG_STATE_HOME").map(|v| v.to_string_lossy().into_owned());
let home = env::var_os("HOME").map(|v| v.to_string_lossy().into_owned());
xdg_state_home(xdg.as_deref(), home.as_deref())
}
pub fn config_file() -> PathBuf {
app_path(&config_home_from_env(), CONFIG_FILE)
}
pub fn state_file() -> PathBuf {
app_path(&state_home_from_env(), STATE_FILE)
}
pub fn style_file() -> PathBuf {
app_path(&config_home_from_env(), STYLE_FILE)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PanelSide {
#[default]
Left,
Right,
}
impl PanelSide {
pub const ALL: [PanelSide; 2] = [PanelSide::Left, PanelSide::Right];
pub fn opposite(&self) -> PanelSide {
match self {
PanelSide::Left => PanelSide::Right,
PanelSide::Right => PanelSide::Left,
}
}
pub fn name(&self) -> &'static str {
match self {
PanelSide::Left => "left",
PanelSide::Right => "right",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PanelLayer {
Background,
#[default]
Bottom,
Top,
Overlay,
}
pub const PANEL_MIN_WIDTH: u32 = 160;
pub const PANEL_MAX_WIDTH: u32 = 2000;
fn default_panel_side() -> PanelSide {
builtin().panel.side
}
fn default_panel_layer() -> PanelLayer {
builtin().panel.layer
}
fn default_panel_width() -> u32 {
builtin().panel.width
}
fn default_dirs_first() -> bool {
builtin().tree.dirs_first
}
fn default_sort_key() -> String {
builtin().tree.sort_key.clone()
}
fn default_sort_ascending() -> bool {
builtin().tree.sort_ascending
}
fn default_font_size() -> u32 {
builtin().tree.font_size
}
fn default_icon_size() -> u32 {
builtin().tree.icon_size
}
fn default_confirm_drop_move() -> bool {
builtin().tree.confirm_drop_move
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelConfig {
#[serde(default = "default_panel_side")]
pub side: PanelSide,
#[serde(default = "default_panel_layer")]
pub layer: PanelLayer,
#[serde(default = "default_panel_width")]
pub width: u32,
#[serde(default)]
pub margin: u32,
}
impl Default for PanelConfig {
fn default() -> Self {
builtin().panel
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreeConfig {
#[serde(default = "default_dirs_first")]
pub dirs_first: bool,
#[serde(default = "default_sort_key")]
pub sort_key: String,
#[serde(default = "default_sort_ascending")]
pub sort_ascending: bool,
#[serde(default)]
pub show_hidden: bool,
#[serde(default = "default_font_size")]
pub font_size: u32,
#[serde(default = "default_icon_size")]
pub icon_size: u32,
#[serde(default = "default_confirm_drop_move")]
pub confirm_drop_move: bool,
}
impl Default for TreeConfig {
fn default() -> Self {
builtin().tree.clone()
}
}
impl TreeConfig {
pub fn sort_options(&self) -> crate::fs::SortOptions {
crate::fs::SortOptions {
dirs_first: self.dirs_first,
key: crate::fs::SortKey::parse(&self.sort_key).unwrap_or(crate::fs::SortKey::Name),
ascending: self.sort_ascending,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum StartupRoot {
#[default]
Home,
Last,
Path(String),
}
impl StartupRoot {
pub fn resolve(&self, last: Option<PathBuf>) -> Option<PathBuf> {
match self {
StartupRoot::Last => last,
StartupRoot::Home => None,
StartupRoot::Path(path) => expand_tilde(path),
}
}
}
impl Serialize for StartupRoot {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
match self {
StartupRoot::Last => serializer.serialize_str("last"),
StartupRoot::Home => serializer.serialize_str("home"),
StartupRoot::Path(path) => {
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("path", path)?;
map.end()
}
}
}
}
impl<'de> Deserialize<'de> for StartupRoot {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
#[derive(Deserialize)]
#[serde(untagged)]
enum Repr {
Keyword(String),
Path { path: String },
}
match Repr::deserialize(deserializer)? {
Repr::Keyword(word) => match word.to_ascii_lowercase().as_str() {
"last" => Ok(StartupRoot::Last),
"home" => Ok(StartupRoot::Home),
other => Err(D::Error::custom(format!(
"unknown startup root {other:?} (expected \"last\", \"home\", or {{ path = \"...\" }})"
))),
},
Repr::Path { path } => Ok(StartupRoot::Path(path)),
}
}
}
pub fn expand_tilde(path: &str) -> Option<PathBuf> {
if path.is_empty() {
return None;
}
match path.strip_prefix('~') {
Some(rest) => std::env::var_os("HOME").map(|home| {
let mut p = PathBuf::from(home);
p.push(rest.strip_prefix('/').unwrap_or(rest));
p
}),
None => {
let path = PathBuf::from(path);
Some(std::path::absolute(&path).unwrap_or(path))
}
}
}
#[derive(Debug, Clone)]
pub enum ContextMatch {
Dir,
NoExt,
Fallback,
Multi,
Ext(String),
Regex(Regex),
}
impl ContextMatch {
pub fn as_str(&self) -> String {
match self {
ContextMatch::Dir => "dir".to_owned(),
ContextMatch::NoExt => "noext".to_owned(),
ContextMatch::Fallback => "fallback".to_owned(),
ContextMatch::Multi => "multi".to_owned(),
ContextMatch::Ext(ext) => format!("ext:{ext}"),
ContextMatch::Regex(re) => format!("regex:{}", re.as_str()),
}
}
pub fn parse(value: &str) -> Result<Self, String> {
let value = value.trim();
match value {
"dir" | "directory" => Ok(ContextMatch::Dir),
"noext" => Ok(ContextMatch::NoExt),
"fallback" | "*" | "all" => Ok(ContextMatch::Fallback),
"multi" | "multi-select" | "selection" => Ok(ContextMatch::Multi),
_ => {
if let Some(ext) = value.strip_prefix("ext:") {
return Ok(ContextMatch::Ext(ext.to_owned()));
}
if let Some(pattern) = value.strip_prefix("regex:") {
return Regex::new(pattern)
.map(ContextMatch::Regex)
.map_err(|err| {
format!(
"invalid regex {pattern:?} in context menu matcher {value:?}: {err}"
)
});
}
Err(format!("unknown context menu matcher {value:?}"))
}
}
}
pub fn matches(&self, path: &Path) -> bool {
match self {
ContextMatch::Dir => path.is_dir(),
ContextMatch::NoExt => {
let Some(name) = path.file_name() else {
return false;
};
let name = name.to_string_lossy();
!path.is_dir() && !name.ends_with('.') && {
let after_sep = name.rsplit(['/', '\\']).next().unwrap_or("");
!after_sep[1..].contains('.')
}
}
ContextMatch::Fallback => true,
ContextMatch::Multi => false,
ContextMatch::Ext(ext) => {
if path.is_dir() || ext.is_empty() {
return false;
}
let Some(name) = path.file_name() else {
return false;
};
let name = name.to_string_lossy().to_lowercase();
name.trim_start_matches('.').ends_with(&format!(".{}", ext.trim().to_lowercase()))
}
ContextMatch::Regex(re) => re.is_match(&path.to_string_lossy()),
}
}
}
impl PartialEq for ContextMatch {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(ContextMatch::Dir, ContextMatch::Dir) => true,
(ContextMatch::NoExt, ContextMatch::NoExt) => true,
(ContextMatch::Fallback, ContextMatch::Fallback) => true,
(ContextMatch::Multi, ContextMatch::Multi) => true,
(ContextMatch::Ext(a), ContextMatch::Ext(b)) => a == b,
(ContextMatch::Regex(a), ContextMatch::Regex(b)) => a.as_str() == b.as_str(),
_ => false,
}
}
}
impl Eq for ContextMatch {}
impl Serialize for ContextMatch {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.as_str())
}
}
impl<'de> Deserialize<'de> for ContextMatch {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
ContextMatch::parse(&raw).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuiltinAction {
Open,
OpenSplit,
InNewPanel,
InOppositePanel,
OpenWith,
OpenWithDefault,
ViewThumbnail,
NewFile,
NewFolder,
Rename,
Duplicate,
CopyPath,
CopyRelativePath,
Cut,
Copy,
Paste,
CreateLink,
Properties,
Trash,
DeletePermanently,
ToggleHidden,
SortByName,
SortBySize,
SortByModified,
SortByType,
ToggleSortAscending,
OpenFolder,
Filter,
SplitView,
Up,
Back,
Forward,
Collapse,
ClosePane,
Separator,
}
impl BuiltinAction {
pub fn as_str(&self) -> &'static str {
match self {
BuiltinAction::Open => "Open",
BuiltinAction::OpenSplit => "Open in Split View",
BuiltinAction::InNewPanel => "In new panel",
BuiltinAction::InOppositePanel => "In opposite panel",
BuiltinAction::OpenWith => "Open With...",
BuiltinAction::OpenWithDefault => "Open With Default",
BuiltinAction::ViewThumbnail => "View Thumbnail",
BuiltinAction::NewFile => "New File",
BuiltinAction::NewFolder => "New Folder",
BuiltinAction::Rename => "Rename",
BuiltinAction::Duplicate => "Duplicate",
BuiltinAction::CopyPath => "Copy Path",
BuiltinAction::CopyRelativePath => "Copy Relative Path",
BuiltinAction::Cut => "Cut",
BuiltinAction::Copy => "Copy",
BuiltinAction::Paste => "Paste",
BuiltinAction::CreateLink => "Create Link",
BuiltinAction::Properties => "Properties",
BuiltinAction::Trash => "Move to Trash",
BuiltinAction::DeletePermanently => "Delete Permanently",
BuiltinAction::ToggleHidden => "Toggle Hidden Files",
BuiltinAction::SortByName => "Sort by Name",
BuiltinAction::SortBySize => "Sort by Size",
BuiltinAction::SortByModified => "Sort by Modified",
BuiltinAction::SortByType => "Sort by Type",
BuiltinAction::ToggleSortAscending => "Reverse Sort Order",
BuiltinAction::OpenFolder => "Open Folder...",
BuiltinAction::Filter => "Filter...",
BuiltinAction::SplitView => "Split View",
BuiltinAction::Up => "Up One Level",
BuiltinAction::Back => "Back",
BuiltinAction::Forward => "Forward",
BuiltinAction::Collapse => "Collapse",
BuiltinAction::ClosePane => "Close Pane",
BuiltinAction::Separator => "---",
}
}
pub fn default_shortcut(&self) -> Option<&'static str> {
match self {
BuiltinAction::Cut => Some("Ctrl+x"),
BuiltinAction::Copy => Some("Ctrl+c"),
BuiltinAction::Paste => Some("Ctrl+v"),
BuiltinAction::Rename => Some("F2"),
BuiltinAction::Duplicate => Some("Ctrl+d"),
BuiltinAction::NewFile => Some("Ctrl+n"),
BuiltinAction::NewFolder => Some("Ctrl+Shift+n"),
BuiltinAction::CreateLink => Some("Ctrl+Shift+m"),
BuiltinAction::Trash => Some("Delete"),
BuiltinAction::DeletePermanently => Some("Shift+Delete"),
BuiltinAction::ViewThumbnail => Some("Ctrl+t"),
BuiltinAction::ToggleHidden => Some("Ctrl+h"),
BuiltinAction::SortByName => Some("Ctrl+1"),
BuiltinAction::SortBySize => Some("Ctrl+2"),
BuiltinAction::SortByModified => Some("Ctrl+3"),
BuiltinAction::SortByType => Some("Ctrl+4"),
BuiltinAction::ToggleSortAscending => Some("Ctrl+Shift+r"),
BuiltinAction::SplitView => Some("Ctrl+s"),
BuiltinAction::Up => Some("Alt+Up"),
BuiltinAction::Back => Some("Alt+Left"),
BuiltinAction::Forward => Some("Alt+Right"),
BuiltinAction::ClosePane => Some("Ctrl+w"),
_ => None,
}
}
pub fn is_single_row_only(&self) -> bool {
matches!(
self,
BuiltinAction::Open
| BuiltinAction::OpenSplit
| BuiltinAction::InNewPanel
| BuiltinAction::InOppositePanel
| BuiltinAction::OpenWith
| BuiltinAction::OpenWithDefault
| BuiltinAction::ViewThumbnail
| BuiltinAction::NewFile
| BuiltinAction::NewFolder
| BuiltinAction::Rename
| BuiltinAction::Duplicate
| BuiltinAction::CreateLink
)
}
pub fn is_directory_only(&self) -> bool {
matches!(self, BuiltinAction::OpenSplit | BuiltinAction::InNewPanel | BuiltinAction::InOppositePanel)
}
pub fn is_pane_action(&self) -> bool {
matches!(
self,
BuiltinAction::OpenFolder
| BuiltinAction::Filter
| BuiltinAction::SplitView
| BuiltinAction::Up
| BuiltinAction::Back
| BuiltinAction::Forward
| BuiltinAction::Collapse
| BuiltinAction::ClosePane
)
}
pub fn parse(name: &str) -> Option<Self> {
let name = name.trim();
let name = if name.contains('\u{2026}') {
name.replace('\u{2026}', "...")
} else {
name.to_owned()
};
let name = name.as_str();
[
Self::Open,
Self::OpenSplit,
Self::InNewPanel,
Self::InOppositePanel,
Self::OpenWith,
Self::OpenWithDefault,
Self::ViewThumbnail,
Self::NewFile,
Self::NewFolder,
Self::Rename,
Self::Duplicate,
Self::CopyPath,
Self::CopyRelativePath,
Self::Cut,
Self::Copy,
Self::Paste,
Self::CreateLink,
Self::Properties,
Self::Trash,
Self::DeletePermanently,
Self::ToggleHidden,
Self::SortByName,
Self::SortBySize,
Self::SortByModified,
Self::SortByType,
Self::ToggleSortAscending,
Self::OpenFolder,
Self::Filter,
Self::SplitView,
Self::Up,
Self::Back,
Self::Forward,
Self::Collapse,
Self::ClosePane,
Self::Separator,
]
.into_iter()
.find(|a| {
a.as_str().eq_ignore_ascii_case(name)
|| (matches!(a, Self::Separator) && name.eq_ignore_ascii_case("separator"))
})
}
}
impl Serialize for BuiltinAction {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for BuiltinAction {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
BuiltinAction::parse(&raw).ok_or_else(|| {
serde::de::Error::custom(format!("unknown context menu builtin action {raw:?}"))
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandAction {
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shortcut: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub hidden: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuiltinEntry {
pub action: BuiltinAction,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shortcut: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub hidden: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Submenu {
pub label: String,
pub items: Vec<ContextAction>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub hidden: bool,
}
impl Submenu {
pub fn new(label: impl Into<String>, items: Vec<ContextAction>) -> Self {
Self { label: label.into(), items, hidden: false }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ContextAction {
Builtin(BuiltinAction),
Entry(BuiltinEntry),
Command(CommandAction),
Submenu(Submenu),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ShortcutTarget {
Builtin(BuiltinAction),
Command(CommandAction),
}
impl ShortcutTarget {
pub fn from_action(action: &ContextAction) -> Option<Self> {
Some(match action {
ContextAction::Builtin(a) => ShortcutTarget::Builtin(*a),
ContextAction::Entry(e) => ShortcutTarget::Builtin(e.action),
ContextAction::Command(cmd) => ShortcutTarget::Command(cmd.clone()),
ContextAction::Submenu(_) => return None,
})
}
}
impl ContextAction {
pub fn label(&self) -> String {
match self {
ContextAction::Builtin(action) => action.as_str().to_owned(),
ContextAction::Entry(entry) => {
entry.label.clone().unwrap_or_else(|| entry.action.as_str().to_owned())
}
ContextAction::Command(cmd) => cmd.label.clone().unwrap_or_else(|| cmd.command.clone()),
ContextAction::Submenu(sub) => sub.label.clone(),
}
}
pub fn is_submenu(&self) -> bool {
matches!(self, ContextAction::Submenu(_))
}
pub fn is_hidden(&self) -> bool {
match self {
ContextAction::Builtin(_) => false,
ContextAction::Entry(entry) => entry.hidden,
ContextAction::Command(cmd) => cmd.hidden,
ContextAction::Submenu(sub) => sub.hidden,
}
}
pub fn submenu_items(&self) -> Option<&[ContextAction]> {
match self {
ContextAction::Submenu(sub) => Some(&sub.items),
_ => None,
}
}
pub fn shortcut(&self) -> Option<String> {
match self {
ContextAction::Builtin(action) => action
.default_shortcut()
.map(str::to_owned),
ContextAction::Entry(entry) => entry
.shortcut
.clone()
.or_else(|| entry.action.default_shortcut().map(str::to_owned)),
ContextAction::Command(cmd) => cmd.shortcut.clone(),
ContextAction::Submenu(_) => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextRule {
#[serde(default)]
pub matches: Vec<ContextMatch>,
#[serde(default)]
pub items: Vec<ContextAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include: Option<PathBuf>,
}
impl ContextRule {
pub fn rule(matches: Vec<ContextMatch>, items: Vec<ContextAction>) -> Self {
ContextRule { matches, items, include: None }
}
pub fn include(path: PathBuf) -> Self {
ContextRule { matches: Vec::new(), items: Vec::new(), include: Some(path) }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextMenu {
pub rules: Vec<ContextRule>,
}
#[cfg(test)]
macro_rules! builtin_items {
($($name:ident),* $(,)?) => {
vec![
$(ContextAction::Builtin(BuiltinAction::$name),)*
]
};
}
impl ContextMenu {
pub fn fallback_actions(path: &Path) -> Vec<ContextAction> {
let wanted = if path.is_dir() { ContextMatch::Dir } else { ContextMatch::Fallback };
builtin()
.context_menu
.rules
.iter()
.find(|rule| rule.matches.contains(&wanted))
.map(|rule| rule.items.clone())
.unwrap_or_default()
}
pub fn actions_for(&self, path: &Path) -> Vec<ContextAction> {
for rule in &self.rules {
if rule.matches.iter().any(|m| m.matches(path)) {
return rule.items.clone();
}
}
Self::fallback_actions(path)
}
pub fn actions_for_selection(&self, path: &Path, selection_len: usize) -> Vec<ContextAction> {
if selection_len > 1 {
for rule in &self.rules {
if rule.matches.contains(&ContextMatch::Multi) {
return rule.items.clone();
}
}
}
self.actions_for(path)
}
pub fn shortcuts(&self) -> Vec<(String, ShortcutTarget)> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for rule in &self.rules {
collect_shortcuts(&rule.items, &mut seen, &mut out);
}
out
}
}
fn collect_shortcuts(
actions: &[ContextAction],
seen: &mut std::collections::HashSet<String>,
out: &mut Vec<(String, ShortcutTarget)>,
) {
for action in actions {
if let Some(items) = action.submenu_items() {
collect_shortcuts(items, seen, out);
continue;
}
let Some(shortcut) = action.shortcut() else {
continue;
};
if !seen.insert(shortcut.clone()) {
continue;
}
if let Some(target) = ShortcutTarget::from_action(action) {
out.push((shortcut, target));
}
}
}
impl Default for ContextMenu {
fn default() -> Self {
builtin().context_menu.clone()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneMenu {
pub items: Vec<ContextAction>,
}
impl PaneMenu {
pub fn labels(&self) -> Vec<String> {
self.items.iter().map(ContextAction::label).collect()
}
pub fn shortcuts(&self) -> Vec<(String, ShortcutTarget)> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
collect_shortcuts(&self.items, &mut seen, &mut out);
out
}
}
impl Default for PaneMenu {
fn default() -> Self {
builtin().pane_menu.clone()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub panel: PanelConfig,
pub tree: TreeConfig,
pub startup: StartupRoot,
pub context_menu: ContextMenu,
pub pane_menu: PaneMenu,
}
impl Default for Config {
fn default() -> Self {
builtin().clone()
}
}
impl Config {
pub fn load() -> LoadResult {
Self::load_and_ensure(&config_file())
}
fn load_and_ensure(path: &Path) -> LoadResult {
let result = Self::load_from_path(path);
if result.problem.is_none()
&& !path.exists()
&& let Err(problem) = Self::ensure_default_file(path)
{
return LoadResult { config: result.config, problem: Some(problem) };
}
result
}
fn ensure_default_file(path: &Path) -> Result<(), LoadProblem> {
let Some(parent) = path.parent() else {
return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
};
if !parent.exists()
&& let Err(err) = std::fs::create_dir_all(parent)
{
return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
}
if let Ok(metadata) = std::fs::metadata(parent)
&& metadata.permissions().readonly()
{
return Ok(());
}
std::fs::write(path, DEFAULT_CONFIG)
.map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
}
pub fn load_from_path(path: &Path) -> LoadResult {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return LoadResult {
config: Config::default(),
problem: None,
};
}
Err(err) => {
return LoadResult {
config: Config::default(),
problem: Some(LoadProblem::Io(path.to_path_buf(), err.kind())),
};
}
};
Self::parse(&raw, path)
}
fn normalize(mut self) -> Self {
self.panel.width = self
.panel
.width
.clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
self.panel.margin = self.panel.margin.clamp(0, 200);
self.tree.font_size = self.tree.font_size.clamp(6, 40);
self.tree.icon_size = self.tree.icon_size.clamp(12, 128);
self
}
}
#[derive(Debug, Clone)]
pub enum LoadProblem {
Io(PathBuf, std::io::ErrorKind),
Parse(PathBuf, toml::de::Error),
}
#[derive(Debug, Clone)]
pub struct LoadResult {
pub config: Config,
pub problem: Option<LoadProblem>,
}
#[derive(Debug, Clone)]
pub struct Stylesheet {
pub css: String,
pub source: StyleSource,
pub problem: Option<LoadProblem>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StyleSource {
User,
Builtin,
}
pub fn load_stylesheet() -> Stylesheet {
let path = style_file();
let _ = ensure_default_stylesheet(&path);
load_stylesheet_from_path(&path)
}
fn load_stylesheet_from_path(path: &Path) -> Stylesheet {
match std::fs::read_to_string(path) {
Ok(css) => Stylesheet {
css,
source: StyleSource::User,
problem: None,
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Stylesheet {
css: DEFAULT_STYLESHEET.to_owned(),
source: StyleSource::Builtin,
problem: None,
},
Err(err) => Stylesheet {
css: DEFAULT_STYLESHEET.to_owned(),
source: StyleSource::Builtin,
problem: Some(LoadProblem::Io(path.to_path_buf(), err.kind())),
},
}
}
fn ensure_default_stylesheet(path: &Path) -> Result<(), LoadProblem> {
if path.exists() {
return Ok(());
}
let Some(parent) = path.parent() else {
return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
};
if !parent.exists()
&& let Err(err) = std::fs::create_dir_all(parent)
{
return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
}
if let Ok(metadata) = std::fs::metadata(parent)
&& metadata.permissions().readonly()
{
return Ok(());
}
std::fs::write(path, DEFAULT_STYLESHEET)
.map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
}
impl Config {
fn parse(raw: &str, path: &Path) -> LoadResult {
match toml::from_str::<Config>(raw) {
Ok(mut config) => {
let base = path.parent().unwrap_or_else(|| Path::new("."));
let mut problems = Vec::new();
config.context_menu.rules =
expand_includes(&config.context_menu.rules, base, 0, &mut problems);
let config = config.normalize();
LoadResult {
config,
problem: problems.into_iter().next(),
}
}
Err(err) => LoadResult {
config: Config::default(),
problem: Some(LoadProblem::Parse(path.to_path_buf(), err)),
},
}
}
}
const MAX_INCLUDE_DEPTH: usize = 8;
#[derive(Deserialize)]
struct IncludedRules {
#[serde(default)]
context_menu: ContextMenu,
}
fn expand_includes(
rules: &[ContextRule],
base: &Path,
depth: usize,
problems: &mut Vec<LoadProblem>,
) -> Vec<ContextRule> {
let mut out = Vec::new();
for rule in rules {
let Some(include) = &rule.include else {
out.push(rule.clone());
continue;
};
if depth >= MAX_INCLUDE_DEPTH {
continue;
}
let dir = if include.is_absolute() {
include.clone()
} else {
base.join(include)
};
for file in toml_files_in(&dir, problems) {
let nested_base = file.parent().unwrap_or(base);
match std::fs::read_to_string(&file) {
Ok(text) => match toml::from_str::<IncludedRules>(&text) {
Ok(included) => {
out.extend(expand_includes(
&included.context_menu.rules,
nested_base,
depth + 1,
problems,
));
}
Err(err) => problems.push(LoadProblem::Parse(file.clone(), err)),
},
Err(err) => problems.push(LoadProblem::Io(file.clone(), err.kind())),
}
}
}
out
}
fn toml_files_in(dir: &Path, problems: &mut Vec<LoadProblem>) -> Vec<PathBuf> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(err) => {
problems.push(LoadProblem::Io(dir.to_path_buf(), err.kind()));
return Vec::new();
}
};
let mut files: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.is_file()
&& path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
})
.collect();
files.sort();
files
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionState {
pub last_root: Option<PathBuf>,
pub left_width: Option<u32>,
pub right_width: Option<u32>,
}
impl SessionState {
pub fn load() -> Self {
Self::load_from_path(&state_file())
}
pub fn load_from_path(path: &Path) -> Self {
match std::fs::read_to_string(path) {
Ok(raw) => toml::from_str(&raw).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn save(&self) -> std::io::Result<()> {
Self::save_to_path(&state_file(), self)
}
pub fn save_to_path(path: &Path, state: &Self) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string(state).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
})?;
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, path)
}
}
pub fn action_command(template: &str, path: &Path) -> Vec<String> {
let path_s = path.to_string_lossy();
let dir = if path.is_dir() {
path.to_string_lossy()
} else {
path.parent().unwrap_or(path).to_string_lossy()
};
let has_path = template.contains("{path}");
let has_dir = template.contains("{dir}");
let mut tokens: Vec<String> = template
.split_whitespace()
.map(|t| t.replace("{path}", &path_s).replace("{dir}", &dir))
.filter(|t| !t.is_empty())
.collect();
if !has_path && !has_dir {
tokens.push(path_s.into_owned());
}
tokens
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(raw: &str) -> Config {
Config::parse(raw, Path::new("test.toml")).config
}
#[test]
fn defaults_are_sane() {
let config = Config::default();
assert!((PANEL_MIN_WIDTH..=PANEL_MAX_WIDTH).contains(&config.panel.width));
assert!(config.tree.icon_size > 0);
assert!(config.tree.font_size > 0);
assert!(!config.context_menu.rules.is_empty());
assert!(!config.pane_menu.items.is_empty());
}
#[test]
fn shipping_default_file_parses_and_is_current() {
assert_eq!(parse(DEFAULT_CONFIG), Config::default());
let written = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/default-config.toml"
))
.unwrap();
assert_eq!(DEFAULT_CONFIG, written);
}
#[test]
fn empty_document_uses_defaults() {
let config = parse("");
assert_eq!(config, Config::default());
}
#[test]
fn full_document_round_trips() {
let raw = r#"
[panel]
side = "right"
layer = "top"
width = 420
margin = 8
[tree]
dirs_first = false
show_hidden = true
font_size = 14
icon_size = 20
"#;
let config = parse(raw);
assert_eq!(config.panel.side, PanelSide::Right);
assert_eq!(config.panel.layer, PanelLayer::Top);
assert_eq!(config.panel.width, 420);
assert!(!config.tree.dirs_first);
assert!(config.tree.show_hidden);
let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
assert_eq!(config, reparsed);
}
#[test]
fn partial_document_is_merged_with_defaults() {
let config = parse("[panel]\nside = \"right\"\n");
assert_eq!(config.panel.side, PanelSide::Right);
assert_eq!(config.panel.width, Config::default().panel.width);
assert_eq!(config.tree, Config::default().tree);
}
#[test]
fn defaults_come_from_the_shipped_file() {
let shipped = parse(DEFAULT_CONFIG);
assert_eq!(Config::default(), shipped);
assert_eq!(PanelConfig::default(), shipped.panel);
assert_eq!(TreeConfig::default(), shipped.tree);
assert_eq!(ContextMenu::default(), shipped.context_menu);
assert_eq!(PaneMenu::default(), shipped.pane_menu);
let partial = parse("[tree]\nfont_size = 21\n");
assert_eq!(partial.tree.font_size, 21);
assert_eq!(partial.tree.icon_size, shipped.tree.icon_size);
assert_eq!(partial.tree.sort_key, shipped.tree.sort_key);
assert_eq!(partial.tree.confirm_drop_move, shipped.tree.confirm_drop_move);
assert!(!shipped.tree.confirm_drop_move);
}
#[test]
fn out_of_range_values_are_clamped() {
let config = parse(
"[panel]\nwidth = 100000\nmargin = 9000\n\n[tree]\nfont_size = 2\nicon_size = 2\n",
);
assert_eq!(config.panel.width, 2000);
assert_eq!(config.panel.margin, 200);
assert_eq!(config.tree.font_size, 6);
assert_eq!(config.tree.icon_size, 12);
}
#[test]
fn invalid_toml_falls_back_to_defaults_with_problem() {
let result = Config::parse("panel = ", Path::new("broken.toml"));
assert_eq!(result.config, Config::default());
assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
}
#[test]
fn missing_file_yields_defaults_without_problem() {
let result = Config::load_from_path(Path::new("/definitely/not/here/config.toml"));
assert_eq!(result.config, Config::default());
assert!(result.problem.is_none());
}
#[test]
fn first_launch_materializes_default_config_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tree-space/config.toml");
assert!(!path.exists());
let result = Config::load_and_ensure(&path);
assert_eq!(result.config, Config::default());
assert!(result.problem.is_none());
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(written, DEFAULT_CONFIG);
let reloaded = Config::load_and_ensure(&path);
assert_eq!(reloaded.config, Config::default());
assert!(reloaded.problem.is_none());
}
#[test]
fn existing_file_is_never_overwritten() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tree-space/config.toml");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "[panel]\nwidth = 500\n").unwrap();
let result = Config::load_and_ensure(&path);
assert_eq!(result.config.panel.width, 500);
assert!(result.problem.is_none());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "[panel]\nwidth = 500\n");
}
#[test]
fn xdg_config_resolution_follows_spec() {
assert_eq!(
xdg_config_home(Some("/opt/cfg"), Some("/home/u")),
PathBuf::from("/opt/cfg")
);
assert_eq!(
xdg_config_home(None, Some("/home/u")),
PathBuf::from("/home/u/.config")
);
assert_eq!(
xdg_state_home(None, Some("/home/u")),
PathBuf::from("/home/u/.local/state")
);
assert_eq!(xdg_config_home(Some(""), Some("/h")), PathBuf::from("/h/.config"));
}
#[test]
fn app_path_joins_app_dir() {
assert_eq!(
app_path(&PathBuf::from("/cfg"), STATE_FILE),
PathBuf::from("/cfg/tree-space/state.toml")
);
}
#[test]
fn session_state_round_trips_through_disk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.toml");
let state = SessionState {
last_root: Some(PathBuf::from("/home/user/projects/foo")),
left_width: Some(420),
right_width: None,
};
SessionState::save_to_path(&path, &state).unwrap();
assert_eq!(SessionState::load_from_path(&path), state);
}
#[test]
fn missing_or_broken_state_defaults() {
let dir = tempfile::tempdir().unwrap();
let broken = dir.path().join("broken.toml");
std::fs::write(&broken, "not [ valid").unwrap();
assert_eq!(SessionState::load_from_path(&broken), SessionState::default());
assert_eq!(
SessionState::load_from_path(&dir.path().join("missing.toml")),
SessionState::default()
);
}
fn all_labels(actions: &[ContextAction]) -> Vec<String> {
let mut out = Vec::new();
for action in actions {
match action.submenu_items() {
Some(items) => out.extend(all_labels(items)),
None => out.push(action.label()),
}
}
out
}
#[test]
fn default_context_menu_reproduces_classic_items() {
let menu = ContextMenu::default();
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("notes.txt");
std::fs::write(&file, "").unwrap();
std::fs::write(dir.path().join("photo.jpg"), "").unwrap();
let dir_labels = all_labels(&menu.actions_for(dir.path()));
for classic in [
"In same panel",
"In split view",
"Folder",
"File",
"Copy Path",
"Move to Trash",
"Delete Permanently",
] {
assert!(dir_labels.contains(&classic.to_owned()), "missing {classic}: {dir_labels:?}");
}
for parity in ["In opposite panel", "With...", "Cut", "Copy", "Paste", "Create Link", "Rename", "Properties"] {
assert!(
dir_labels.contains(&parity.to_owned()),
"missing {parity}: {dir_labels:?}"
);
}
let file_labels = all_labels(&menu.actions_for(&file));
for dropped in ["In split view", "In opposite panel"] {
assert!(!file_labels.contains(&dropped.to_owned()), "{dropped} present: {file_labels:?}");
}
assert!(file_labels.contains(&"With Default".to_owned()));
assert!(file_labels.contains(&"With...".to_owned()));
assert!(file_labels.len() > 15);
let menus = menu.actions_for(dir.path());
let cut = menus
.iter()
.flat_map(flatten_actions)
.find(|a| a.label() == "Cut")
.expect("Cut present");
assert_eq!(cut.shortcut().as_deref(), Some("Ctrl+x"));
let trash = menus
.iter()
.flat_map(flatten_actions)
.find(|a| a.label() == "Move to Trash")
.expect("Trash present");
assert_eq!(trash.shortcut().as_deref(), Some("Delete"));
}
fn flatten_actions(action: &ContextAction) -> Vec<&ContextAction> {
match action.submenu_items() {
Some(items) => items.iter().flat_map(flatten_actions).collect(),
None => vec![action],
}
}
#[test]
fn default_shortcuts_follow_conventions() {
assert_eq!(BuiltinAction::Cut.default_shortcut(), Some("Ctrl+x"));
assert_eq!(BuiltinAction::Copy.default_shortcut(), Some("Ctrl+c"));
assert_eq!(BuiltinAction::Rename.default_shortcut(), Some("F2"));
assert_eq!(BuiltinAction::CreateLink.default_shortcut(), Some("Ctrl+Shift+m"));
assert_eq!(BuiltinAction::Trash.default_shortcut(), Some("Delete"));
assert_eq!(BuiltinAction::DeletePermanently.default_shortcut(), Some("Shift+Delete"));
assert_eq!(BuiltinAction::ViewThumbnail.default_shortcut(), Some("Ctrl+t"));
assert_eq!(BuiltinAction::Open.default_shortcut(), None);
assert_eq!(BuiltinAction::Separator.default_shortcut(), None);
}
#[test]
fn shortcuts_are_collected_top_down_and_deduplicated() {
let menu = ContextMenu {
rules: vec![
ContextRule::rule(
vec![ContextMatch::Dir],
vec![
ContextAction::Builtin(BuiltinAction::Open),
ContextAction::Entry(BuiltinEntry {
action: BuiltinAction::Rename,
label: None,
shortcut: Some("F2".into()),
hidden: false,
}),
ContextAction::Command(CommandAction {
command: "code {path}".into(),
label: None,
shortcut: Some("Ctrl+Shift+e".into()),
hidden: false,
}),
],
),
ContextRule::rule(
vec![ContextMatch::Fallback],
vec![ContextAction::Builtin(BuiltinAction::Trash)],
),
],
};
let shortcuts = menu.shortcuts();
assert_eq!(
shortcuts,
vec![
("F2".to_owned(), ShortcutTarget::Builtin(BuiltinAction::Rename)),
("Ctrl+Shift+e".to_owned(), ShortcutTarget::Command(CommandAction {
command: "code {path}".into(),
label: None,
shortcut: Some("Ctrl+Shift+e".into()),
hidden: false,
})),
("Delete".to_owned(), ShortcutTarget::Builtin(BuiltinAction::Trash)),
]
);
}
#[test]
fn table_form_spells_out_shortcuts_and_labels() {
let raw = r#"
[[context_menu.rules]]
matches = ["fallback"]
items = [
{ action = "Cut", shortcut = "Ctrl+Alt+x", label = "Cut here" },
{ command = "sh {path}", shortcut = "Ctrl+Alt+s" },
"---",
]
"#;
let config = parse(raw);
let actions = &config.context_menu.rules[0].items;
assert_eq!(actions.len(), 3);
match &actions[0] {
ContextAction::Entry(entry) => {
assert_eq!(entry.action, BuiltinAction::Cut);
assert_eq!(entry.shortcut.as_deref(), Some("Ctrl+Alt+x"));
assert_eq!(entry.label.as_deref(), Some("Cut here"));
assert_eq!(actions[0].label(), "Cut here");
assert_eq!(actions[0].shortcut().as_deref(), Some("Ctrl+Alt+x"));
}
other => panic!("expected Entry, got {other:?}"),
}
match &actions[1] {
ContextAction::Command(cmd) => {
assert_eq!(cmd.shortcut.as_deref(), Some("Ctrl+Alt+s"));
}
other => panic!("expected Command, got {other:?}"),
}
match &actions[2] {
ContextAction::Builtin(BuiltinAction::Separator) => {}
other => panic!("expected Separator, got {other:?}"),
}
let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
assert_eq!(config, reparsed);
}
#[test]
fn pane_actions_are_classified() {
for action in [
BuiltinAction::OpenFolder,
BuiltinAction::Filter,
BuiltinAction::SplitView,
BuiltinAction::Collapse,
BuiltinAction::ClosePane,
] {
assert!(action.is_pane_action(), "{action:?} should be a pane action");
}
for action in [
BuiltinAction::Open,
BuiltinAction::OpenSplit,
BuiltinAction::ToggleHidden,
BuiltinAction::SortByName,
BuiltinAction::Separator,
] {
assert!(!action.is_pane_action(), "{action:?} is not a pane action");
}
}
#[test]
fn separator_matches_both_spellings() {
assert_eq!(BuiltinAction::parse("---"), Some(BuiltinAction::Separator));
assert_eq!(BuiltinAction::parse("separator"), Some(BuiltinAction::Separator));
assert_eq!(BuiltinAction::parse("SEPARATOR"), Some(BuiltinAction::Separator));
assert_eq!(BuiltinAction::Separator.as_str(), "---");
}
#[test]
fn ellipsis_labels_use_ascii_and_accept_the_typographic_form() {
assert_eq!(BuiltinAction::OpenWith.as_str(), "Open With...");
assert_eq!(BuiltinAction::OpenFolder.as_str(), "Open Folder...");
assert_eq!(BuiltinAction::Filter.as_str(), "Filter...");
assert_eq!(BuiltinAction::parse("Open With…"), Some(BuiltinAction::OpenWith));
assert_eq!(BuiltinAction::parse("Open Folder…"), Some(BuiltinAction::OpenFolder));
assert_eq!(BuiltinAction::parse("Filter…"), Some(BuiltinAction::Filter));
assert_eq!(BuiltinAction::parse("open folder..."), Some(BuiltinAction::OpenFolder));
}
#[test]
fn pane_menu_defaults_include_view_and_pane_actions() {
let menu = PaneMenu::default();
let labels = menu.labels();
assert!(labels.contains(&"Open Folder...".to_owned()));
assert!(labels.contains(&"Split View".to_owned()));
assert!(labels.contains(&"Close Pane".to_owned()));
assert!(labels.contains(&"Toggle Hidden Files".to_owned()));
assert!(labels.contains(&"Sort by Size".to_owned()));
let dir_menu = ContextMenu::default();
let dir_labels: Vec<String> = dir_menu
.rules
.iter()
.flat_map(|rule| rule.items.iter())
.map(ContextAction::label)
.collect();
assert!(!dir_labels.contains(&"Toggle Hidden Files".to_owned()));
}
#[test]
fn pane_menu_shortcuts_are_collected() {
let menu = PaneMenu::default();
let shortcuts = menu.shortcuts();
assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+h"));
assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+1"));
assert!(shortcuts.iter().all(|(k, _)| !k.is_empty()));
}
#[test]
fn pane_menu_parses_custom_commands() {
let toml = r#"
items = [
"Split View",
"---",
{ command = "echo hi", label = "Say hi" },
]
"#;
let menu: PaneMenu = toml::from_str(toml).unwrap();
assert_eq!(menu.labels(), vec!["Split View", "---", "Say hi"]);
}
#[test]
fn navigation_builtins_round_trip_and_are_pane_actions() {
for action in [BuiltinAction::Up, BuiltinAction::Back, BuiltinAction::Forward] {
let s = action.as_str();
assert_eq!(BuiltinAction::parse(s), Some(action), "{s} should parse");
assert!(action.is_pane_action(), "{s} should be a pane action");
assert!(action.default_shortcut().is_some(), "{s} needs a default key");
}
assert_eq!(BuiltinAction::Up.default_shortcut(), Some("Alt+Up"));
assert_eq!(BuiltinAction::Back.default_shortcut(), Some("Alt+Left"));
assert_eq!(BuiltinAction::Forward.default_shortcut(), Some("Alt+Right"));
}
#[test]
fn hidden_items_parse_and_keep_their_shortcut() {
let toml = r#"
items = [
{ action = "Up One Level", hidden = true },
{ command = "echo hi", label = "Say hi", shortcut = "Ctrl+g", hidden = true },
{ label = "More", hidden = true, items = [ "Copy Path" ] },
"Open",
]
"#;
let menu: PaneMenu = toml::from_str(toml).unwrap();
assert!(menu.items[0].is_hidden());
assert!(menu.items[1].is_hidden());
assert!(menu.items[2].is_hidden());
assert!(!menu.items[3].is_hidden());
assert_eq!(menu.items[0].label(), "Up One Level");
assert_eq!(menu.items[1].shortcut().as_deref(), Some("Ctrl+g"));
let shortcuts = menu.shortcuts();
assert!(shortcuts.iter().any(|(k, _)| k == "Alt+Up"));
assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+g"));
}
#[test]
fn hidden_submenu_children_keep_their_shortcuts() {
let toml = r#"
items = [
{ label = "More", hidden = true, items = [
{ action = "Rename", shortcut = "Ctrl+Shift+r" },
] },
]
"#;
let menu: PaneMenu = toml::from_str(toml).unwrap();
assert!(menu.items[0].is_hidden());
assert!(menu.shortcuts().iter().any(|(k, _)| k == "Ctrl+Shift+r"));
}
#[test]
fn hidden_defaults_to_false_and_is_not_serialized() {
#[derive(serde::Deserialize)]
struct One {
item: ContextAction,
}
let visible: One = toml::from_str(r#"item = { action = "Cut", shortcut = "Ctrl+x" }"#).unwrap();
assert!(!visible.item.is_hidden());
let plain: One = toml::from_str(r#"item = "Cut""#).unwrap();
assert!(!plain.item.is_hidden());
#[derive(serde::Serialize)]
struct OneOut {
item: ContextAction,
}
let encoded = toml::to_string(&OneOut { item: visible.item }).unwrap();
assert!(!encoded.contains("hidden"), "{encoded}");
}
#[test]
fn menu_items_parse_submenus_recursively() {
let toml = r#"
items = [
"Open",
{ label = "More", items = [
{ action = "Copy Path", shortcut = "Ctrl+Shift+c" },
{ label = "Deeper", items = [ { command = "echo hi", label = "Hi" } ] },
] },
]
"#;
let menu: PaneMenu = toml::from_str(toml).unwrap();
assert_eq!(menu.labels(), vec!["Open", "More"]);
let sub = &menu.items[1];
assert!(sub.is_submenu());
assert!(sub.shortcut().is_none());
let children = sub.submenu_items().unwrap();
assert_eq!(children.len(), 2);
assert_eq!(children[0].label(), "Copy Path");
assert!(children[1].is_submenu());
let shortcuts = menu.shortcuts();
assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+Shift+c"));
}
#[test]
fn context_menu_collects_nested_shortcuts() {
let raw = r#"
[[context_menu.rules]]
matches = ["dir"]
items = [
"Open",
{ label = "More", items = [
{ action = "Copy Path", shortcut = "Ctrl+Shift+c" },
] },
]
[[context_menu.rules]]
matches = ["fallback"]
items = ["Open"]
"#;
let config = parse(raw);
let shortcuts = config.context_menu.shortcuts();
assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+Shift+c"));
}
#[test]
fn startup_last_uses_session_root() {
let root = StartupRoot::Last;
let last = Some(PathBuf::from("/tmp/last"));
assert_eq!(root.resolve(last.clone()), last);
assert_eq!(root.resolve(None), None);
}
#[test]
fn startup_home_ignores_session_root() {
let root = StartupRoot::Home;
assert_eq!(root.resolve(Some(PathBuf::from("/tmp/last"))), None);
}
#[test]
fn startup_path_uses_configured_directory() {
let root = StartupRoot::Path("/tmp/fixed".to_owned());
assert_eq!(
root.resolve(Some(PathBuf::from("/tmp/last"))),
Some(PathBuf::from("/tmp/fixed"))
);
assert_eq!(StartupRoot::Path(String::new()).resolve(None), None);
}
#[test]
fn startup_tilde_expands_to_home() {
unsafe { std::env::set_var("HOME", "/home/tester") };
let root = StartupRoot::Path("~/notes".to_owned());
assert_eq!(root.resolve(None), Some(PathBuf::from("/home/tester/notes")));
assert_eq!(expand_tilde("~"), Some(PathBuf::from("/home/tester")));
assert_eq!(expand_tilde("/abs/path"), Some(PathBuf::from("/abs/path")));
assert_eq!(expand_tilde(""), None);
let rel = expand_tilde("some/relative/dir").unwrap();
assert!(rel.is_absolute(), "{rel:?} should be absolute");
assert!(rel.ends_with("some/relative/dir"), "{rel:?}");
}
#[test]
fn startup_parses_from_toml() {
assert_eq!(parse("startup = \"home\"\n").startup, StartupRoot::Home);
assert_eq!(parse("startup = \"last\"\n").startup, StartupRoot::Last);
assert_eq!(
parse("startup = { path = \"/srv\" }\n").startup,
StartupRoot::Path("/srv".to_owned())
);
assert_eq!(Config::default().startup, StartupRoot::Home);
assert!(
Config::parse("startup = \"bogus\"\n", Path::new("test.toml"))
.problem
.is_some()
);
let cfg = Config { startup: StartupRoot::Path("/srv".to_owned()), ..Config::default() };
let text = toml::to_string(&cfg).unwrap();
assert_eq!(toml::from_str::<Config>(&text).unwrap().startup, cfg.startup);
}
#[test]
fn context_matches_classify_rows() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("report.md");
std::fs::write(&file, "").unwrap();
let noext = dir.path().join("Makefile");
std::fs::write(&noext, "").unwrap();
let tarball = dir.path().join("app.tar.gz");
std::fs::write(&tarball, "").unwrap();
assert!(ContextMatch::parse("dir").unwrap().matches(dir.path()));
assert!(!ContextMatch::parse("dir").unwrap().matches(&file));
assert!(ContextMatch::parse("noext").unwrap().matches(&noext));
assert!(!ContextMatch::parse("noext").unwrap().matches(&file));
assert!(ContextMatch::parse("ext:md").unwrap().matches(&file));
assert!(ContextMatch::parse("ext:MD").unwrap().matches(&file));
assert!(ContextMatch::parse("ext:gz").unwrap().matches(&tarball));
assert!(!ContextMatch::parse("ext:md").unwrap().matches(dir.path()));
assert_eq!(ContextMatch::parse("directory"), Ok(ContextMatch::Dir));
assert_eq!(ContextMatch::parse("*"), Ok(ContextMatch::Fallback));
assert!(ContextMatch::parse("bogus").is_err());
}
#[test]
fn regex_matcher_matches_the_full_path() {
let dir = tempfile::tempdir().unwrap();
let md = dir.path().join("guide.md");
std::fs::write(&md, "").unwrap();
assert!(ContextMatch::parse(r"regex:\.md$").unwrap().matches(&md));
assert!(!ContextMatch::parse(r"regex:\.rs$").unwrap().matches(&md));
let exact = format!("regex:^{}$", regex::escape(dir.path().to_str().unwrap()));
assert!(ContextMatch::parse(&exact).unwrap().matches(dir.path()));
let parsed = ContextMatch::parse(r"regex:\.rs$").unwrap();
assert_eq!(parsed.as_str(), r"regex:\.rs$");
let reparsed = ContextMatch::parse(&parsed.as_str()).unwrap();
assert_eq!(parsed, reparsed);
}
#[test]
fn invalid_regex_is_a_parse_error() {
let err = ContextMatch::parse("regex:(").unwrap_err();
assert!(err.contains("invalid regex"), "got: {err}");
}
#[test]
fn stylesheet_prefers_the_user_file_and_materializes_a_default() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tree-space/main.css");
let first = load_stylesheet_from_path(&path);
assert_eq!(first.source, StyleSource::Builtin);
assert!(first.problem.is_none());
assert_eq!(first.css, DEFAULT_STYLESHEET);
ensure_default_stylesheet(&path).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), DEFAULT_STYLESHEET);
std::fs::write(&path, ".tree-row { color: red; }").unwrap();
let user = load_stylesheet_from_path(&path);
assert_eq!(user.source, StyleSource::User);
assert!(user.problem.is_none());
assert_eq!(user.css, ".tree-row { color: red; }");
}
#[test]
fn materialize_never_overwrites_an_existing_stylesheet() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tree-space/main.css");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "/* mine */").unwrap();
ensure_default_stylesheet(&path).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "/* mine */");
}
#[test]
fn unreadable_stylesheet_falls_back_to_builtin_with_a_problem() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("main.css");
std::fs::create_dir(&path).unwrap();
let stylesheet = load_stylesheet_from_path(&path);
assert_eq!(stylesheet.source, StyleSource::Builtin);
assert_eq!(stylesheet.css, DEFAULT_STYLESHEET);
assert!(matches!(stylesheet.problem, Some(LoadProblem::Io(..))));
}
#[test]
fn include_rule_splices_files_in_place_and_name_sorted() { let dir = tempfile::tempdir().unwrap();
let rules_dir = dir.path().join("rules.d");
std::fs::create_dir_all(&rules_dir).unwrap();
std::fs::write(
rules_dir.join("20-second.toml"),
"[[context_menu.rules]]\nmatches = [\"ext:b\"]\nitems = [\"Properties\"]\n",
)
.unwrap();
std::fs::write(
rules_dir.join("10-first.toml"),
"[[context_menu.rules]]\nmatches = [\"ext:a\"]\nitems = [\"Properties\", \"Copy Path\"]\n",
)
.unwrap();
std::fs::write(rules_dir.join("README.md"), "not config").unwrap();
let main = dir.path().join("config.toml");
std::fs::write(
&main,
r#"
[[context_menu.rules]]
matches = ["dir"]
items = ["Open"]
[[context_menu.rules]]
include = "rules.d"
[[context_menu.rules]]
matches = ["fallback"]
items = ["Move to Trash"]
"#,
)
.unwrap();
let config = Config::load_from_path(&main);
assert!(config.problem.is_none(), "{:?}", config.problem);
let rules = &config.config.context_menu.rules;
assert_eq!(rules.len(), 4);
assert_eq!(rules[0].matches, vec![ContextMatch::Dir]);
assert_eq!(rules[1].matches, vec![ContextMatch::Ext("a".to_owned())]);
assert_eq!(rules[2].matches, vec![ContextMatch::Ext("b".to_owned())]);
assert_eq!(rules[3].matches, vec![ContextMatch::Fallback]);
assert!(rules.iter().all(|r| r.include.is_none()));
}
#[test]
fn include_of_a_missing_directory_contributes_nothing() {
let dir = tempfile::tempdir().unwrap();
let main = dir.path().join("config.toml");
std::fs::write(
&main,
"[[context_menu.rules]]\ninclude = \"nope.d\"\n\n[[context_menu.rules]]\nmatches = [\"fallback\"]\nitems = [\"Move to Trash\"]\n",
)
.unwrap();
let result = Config::load_from_path(&main);
assert!(result.problem.is_none(), "{:?}", result.problem);
assert_eq!(result.config.context_menu.rules.len(), 1);
assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Fallback]);
}
#[test]
fn include_of_an_invalid_file_is_reported_but_others_still_load() {
let dir = tempfile::tempdir().unwrap();
let rules_dir = dir.path().join("rules.d");
std::fs::create_dir_all(&rules_dir).unwrap();
std::fs::write(rules_dir.join("10-good.toml"), "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Open\"]\n").unwrap();
std::fs::write(rules_dir.join("20-bad.toml"), "this is not = valid toml [").unwrap();
let main = dir.path().join("config.toml");
std::fs::write(
&main,
"[[context_menu.rules]]\ninclude = \"rules.d\"\n",
)
.unwrap();
let result = Config::load_from_path(&main);
assert!(matches!(result.problem, Some(LoadProblem::Parse(..))), "{:?}", result.problem);
assert_eq!(result.config.context_menu.rules.len(), 1);
assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Dir]);
}
#[test]
fn included_rules_are_expanded_recursively() {
let dir = tempfile::tempdir().unwrap();
let outer = dir.path().join("outer.d");
let inner = outer.join("inner.d");
std::fs::create_dir_all(&inner).unwrap();
std::fs::write(inner.join("nested.toml"), "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Open\"]\n").unwrap();
std::fs::write(
outer.join("mid.toml"),
"[[context_menu.rules]]\ninclude = \"inner.d\"\n",
)
.unwrap();
let main = dir.path().join("config.toml");
std::fs::write(&main, "[[context_menu.rules]]\ninclude = \"outer.d\"\n").unwrap();
let result = Config::load_from_path(&main);
assert!(result.problem.is_none(), "{:?}", result.problem);
assert_eq!(result.config.context_menu.rules.len(), 1);
assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Dir]);
}
#[test]
fn first_matching_rule_wins() {
let menu = ContextMenu {
rules: vec![
ContextRule::rule(
vec![ContextMatch::Ext("md".to_owned())],
builtin_items![CopyPath],
),
ContextRule::rule(vec![ContextMatch::Fallback], builtin_items![Open]),
],
};
let dir = tempfile::tempdir().unwrap();
let md = dir.path().join("a.md");
std::fs::write(&md, "").unwrap();
let txt = dir.path().join("a.txt");
std::fs::write(&txt, "").unwrap();
assert_eq!(menu.actions_for(&md), builtin_items![CopyPath]);
assert_eq!(menu.actions_for(&txt), builtin_items![Open]);
}
#[test]
fn multi_matcher_parses_and_never_matches_a_single_path() {
assert_eq!(ContextMatch::parse("multi").unwrap(), ContextMatch::Multi);
assert_eq!(ContextMatch::parse("multi-select").unwrap(), ContextMatch::Multi);
assert_eq!(ContextMatch::Multi.as_str(), "multi");
let dir = tempfile::tempdir().unwrap();
assert!(!ContextMatch::Multi.matches(dir.path()));
}
#[test]
fn multi_rule_wins_for_a_multi_selection_only() {
let menu = ContextMenu {
rules: vec![
ContextRule::rule(vec![ContextMatch::Multi], builtin_items![Trash, CopyPath]),
ContextRule::rule(vec![ContextMatch::Dir], builtin_items![Open]),
ContextRule::rule(vec![ContextMatch::Fallback], builtin_items![Open]),
],
};
let dir = tempfile::tempdir().unwrap();
assert_eq!(menu.actions_for_selection(dir.path(), 1), builtin_items![Open]);
assert_eq!(
menu.actions_for_selection(dir.path(), 2),
builtin_items![Trash, CopyPath]
);
assert_eq!(
menu.actions_for_selection(dir.path(), 7),
builtin_items![Trash, CopyPath]
);
}
#[test]
fn shipped_multi_rule_offers_selection_actions() {
let menu = ContextMenu::default();
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("a.txt");
std::fs::write(&file, "").unwrap();
let single = menu.actions_for_selection(&file, 1);
assert_eq!(single, menu.actions_for(&file));
let labels = all_labels(&menu.actions_for_selection(&file, 3));
assert!(labels.contains(&"Move to Trash".to_owned()), "{labels:?}");
assert!(labels.contains(&"Copy Path".to_owned()), "{labels:?}");
assert!(labels.contains(&"Properties".to_owned()), "{labels:?}");
assert!(!labels.contains(&"Rename".to_owned()), "{labels:?}");
assert!(!labels.contains(&"Duplicate".to_owned()), "{labels:?}");
assert!(!labels.contains(&"With...".to_owned()), "{labels:?}");
}
#[test]
fn single_row_only_actions_are_classified() {
assert!(BuiltinAction::Rename.is_single_row_only());
assert!(BuiltinAction::Open.is_single_row_only());
assert!(BuiltinAction::Duplicate.is_single_row_only());
assert!(!BuiltinAction::Trash.is_single_row_only());
assert!(!BuiltinAction::DeletePermanently.is_single_row_only());
assert!(!BuiltinAction::Copy.is_single_row_only());
assert!(!BuiltinAction::Cut.is_single_row_only());
assert!(!BuiltinAction::Properties.is_single_row_only());
assert!(!BuiltinAction::CopyPath.is_single_row_only());
}
#[test]
fn empty_rule_list_falls_back_to_classic_menu() {
let menu = ContextMenu { rules: Vec::new() };
let dir = tempfile::tempdir().unwrap();
assert_eq!(menu.actions_for(dir.path()), ContextMenu::fallback_actions(dir.path()));
}
#[test]
fn custom_context_menu_round_trips() {
let raw = r#"
[[context_menu.rules]]
matches = ["ext:rs", "ext:toml"]
items = ["Open", { command = "cargo fmt", label = "Fmt" }]
[[context_menu.rules]]
matches = ["dir"]
items = [{ command = "foot -D {dir}", label = "Open in Terminal" }, "New Folder"]
[[context_menu.rules]]
matches = ["noext"]
items = [{ command = "sh {path}" }]
[[context_menu.rules]]
matches = ['regex:.*\.lock$']
items = ["Open", { command = "rm {path}", label = "Discard lock" }]
"#;
let config = parse(raw);
assert_eq!(config.context_menu.rules.len(), 4);
let first = &config.context_menu.rules[0];
assert_eq!(
first.matches,
vec![ContextMatch::Ext("rs".to_owned()), ContextMatch::Ext("toml".to_owned())]
);
assert!(matches!(first.items[0], ContextAction::Builtin(BuiltinAction::Open)));
assert!(matches!(
&first.items[1],
ContextAction::Command(cmd) if cmd.command == "cargo fmt" && cmd.label.as_deref() == Some("Fmt")
));
let last = &config.context_menu.rules[3];
assert_eq!(
last.matches,
vec![ContextMatch::parse(r"regex:.*\.lock$").unwrap()]
);
let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
assert_eq!(config, reparsed);
}
#[test]
fn unknown_builtin_action_is_a_parse_problem() {
let result = Config::parse(
"[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Frobnicate\"]\n",
Path::new("bad-menu.toml"),
);
assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
assert_eq!(result.config, Config::default());
}
#[test]
fn unknown_context_matcher_is_a_parse_problem() {
let result = Config::parse(
"[[context_menu.rules]]\nmatches = [\"gibberish\"]\nitems = [\"Open\"]\n",
Path::new("bad-matcher.toml"),
);
assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
assert_eq!(result.config, Config::default());
}
#[test]
fn action_command_substitutes_markers() {
let dir = tempfile::tempdir().unwrap();
let dir_s = dir.path().to_string_lossy().into_owned();
assert_eq!(
action_command("code {path}", dir.path()),
vec!["code".to_owned(), dir_s.clone()]
);
assert_eq!(
action_command("code {dir}", &dir.path().join("x.rs")),
vec!["code".to_owned(), dir_s]
);
}
#[test]
fn action_command_appends_path_without_marker() {
assert_eq!(
action_command("sh", Path::new("/tmp/s.sh")),
vec!["sh".to_owned(), "/tmp/s.sh".to_owned()]
);
}
}