- install_desktop_integration_to() writes .desktop and downloads icon - setup_desktop_integration_wizard() prompts and installs to XDG paths - --create-desktop-shortcut flag to re-run at any time - First-run wizard now also offers desktop integration after shortcut setup - 164 tests passing
411 lines
13 KiB
Rust
411 lines
13 KiB
Rust
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::session::SessionConfig;
|
|
use crate::ui;
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
pub struct Config {
|
|
pub paths: Vec<String>,
|
|
#[serde(default = "default_max_depth")]
|
|
pub max_depth: usize,
|
|
#[serde(default = "default_cache_enabled")]
|
|
pub cache_enabled: bool,
|
|
#[serde(default = "default_cache_ttl_hours")]
|
|
pub cache_ttl_hours: u64,
|
|
#[serde(default = "default_update_check_interval_hours")]
|
|
pub update_check_interval_hours: u64,
|
|
#[serde(default = "default_session_config")]
|
|
pub default_session: SessionConfig,
|
|
}
|
|
|
|
fn default_max_depth() -> usize {
|
|
5
|
|
}
|
|
|
|
fn default_cache_enabled() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_cache_ttl_hours() -> u64 {
|
|
24
|
|
}
|
|
|
|
fn default_update_check_interval_hours() -> u64 {
|
|
24
|
|
}
|
|
|
|
fn default_session_config() -> SessionConfig {
|
|
use crate::session::Window;
|
|
|
|
SessionConfig {
|
|
windows: vec![
|
|
Window {
|
|
name: "editor".to_string(),
|
|
panes: vec![],
|
|
layout: None,
|
|
},
|
|
Window {
|
|
name: "terminal".to_string(),
|
|
panes: vec![],
|
|
layout: None,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
pub fn load() -> Result<Self> {
|
|
let config_path = Self::config_path()?;
|
|
|
|
if !config_path.exists() {
|
|
return Ok(Self::default_config());
|
|
}
|
|
|
|
let content = fs::read_to_string(&config_path)
|
|
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
|
|
|
|
let config: Config = toml::from_str(&content)
|
|
.with_context(|| format!("Failed to parse config file: {}", config_path.display()))?;
|
|
|
|
Ok(config)
|
|
}
|
|
|
|
pub fn config_path() -> Result<PathBuf> {
|
|
let config_dir = dirs::config_dir()
|
|
.context("Could not determine config directory")?
|
|
.join("tmuxido");
|
|
|
|
Ok(config_dir.join("tmuxido.toml"))
|
|
}
|
|
|
|
pub fn ensure_config_exists() -> Result<PathBuf> {
|
|
let config_path = Self::config_path()?;
|
|
|
|
if !config_path.exists() {
|
|
let config_dir = config_path
|
|
.parent()
|
|
.context("Could not get parent directory")?;
|
|
|
|
fs::create_dir_all(config_dir).with_context(|| {
|
|
format!(
|
|
"Failed to create config directory: {}",
|
|
config_dir.display()
|
|
)
|
|
})?;
|
|
|
|
// Run interactive configuration wizard
|
|
let paths = Self::prompt_for_paths()?;
|
|
let max_depth = Self::prompt_for_max_depth()?;
|
|
let cache_enabled = Self::prompt_for_cache_enabled()?;
|
|
let cache_ttl_hours = if cache_enabled {
|
|
Self::prompt_for_cache_ttl()?
|
|
} else {
|
|
24
|
|
};
|
|
let windows = Self::prompt_for_windows()?;
|
|
|
|
// Render styled success message before moving windows
|
|
ui::render_config_created(&paths, max_depth, cache_enabled, cache_ttl_hours, &windows);
|
|
|
|
let config = Config {
|
|
paths: paths.clone(),
|
|
max_depth,
|
|
cache_enabled,
|
|
cache_ttl_hours,
|
|
update_check_interval_hours: default_update_check_interval_hours(),
|
|
default_session: SessionConfig { windows },
|
|
};
|
|
|
|
let toml_string =
|
|
toml::to_string_pretty(&config).context("Failed to serialize config")?;
|
|
|
|
fs::write(&config_path, toml_string).with_context(|| {
|
|
format!("Failed to write config file: {}", config_path.display())
|
|
})?;
|
|
|
|
// Offer to set up a keyboard shortcut (best-effort, non-fatal)
|
|
if let Err(e) = crate::shortcut::setup_shortcut_wizard() {
|
|
eprintln!("Warning: shortcut setup failed: {}", e);
|
|
}
|
|
|
|
// Offer to install .desktop entry + icon (best-effort, non-fatal)
|
|
if let Err(e) = crate::shortcut::setup_desktop_integration_wizard() {
|
|
eprintln!("Warning: desktop integration failed: {}", e);
|
|
}
|
|
}
|
|
|
|
Ok(config_path)
|
|
}
|
|
|
|
fn prompt_for_paths() -> Result<Vec<String>> {
|
|
// Render styled welcome banner
|
|
ui::render_welcome_banner();
|
|
|
|
// Get input with styled prompt
|
|
let input = ui::render_paths_prompt()?;
|
|
let paths = Self::parse_paths_input(&input);
|
|
|
|
if paths.is_empty() {
|
|
ui::render_fallback_message();
|
|
Ok(vec![
|
|
dirs::home_dir()
|
|
.unwrap_or_default()
|
|
.join("Projects")
|
|
.to_string_lossy()
|
|
.to_string(),
|
|
])
|
|
} else {
|
|
Ok(paths)
|
|
}
|
|
}
|
|
|
|
fn prompt_for_max_depth() -> Result<usize> {
|
|
ui::render_section_header("Scan Settings");
|
|
let input = ui::render_max_depth_prompt()?;
|
|
Ok(ui::parse_max_depth_input(&input).unwrap_or(5))
|
|
}
|
|
|
|
fn prompt_for_cache_enabled() -> Result<bool> {
|
|
ui::render_section_header("Cache Settings");
|
|
let input = ui::render_cache_enabled_prompt()?;
|
|
Ok(ui::parse_cache_enabled_input(&input).unwrap_or(true))
|
|
}
|
|
|
|
fn prompt_for_cache_ttl() -> Result<u64> {
|
|
let input = ui::render_cache_ttl_prompt()?;
|
|
Ok(ui::parse_cache_ttl_input(&input).unwrap_or(24))
|
|
}
|
|
|
|
fn prompt_for_windows() -> Result<Vec<crate::session::Window>> {
|
|
ui::render_section_header("Default Session");
|
|
let input = ui::render_windows_prompt()?;
|
|
|
|
let window_names = ui::parse_comma_separated_list(&input);
|
|
|
|
let names = if window_names.is_empty() {
|
|
vec!["editor".to_string(), "terminal".to_string()]
|
|
} else {
|
|
window_names
|
|
};
|
|
|
|
// Configure panes and layout for each window
|
|
let mut windows = Vec::new();
|
|
for name in names {
|
|
let panes = Self::prompt_for_panes(&name)?;
|
|
let layout = if panes.len() > 1 {
|
|
ui::render_layout_prompt(&name, panes.len())?
|
|
} else {
|
|
None
|
|
};
|
|
windows.push(crate::session::Window {
|
|
name,
|
|
panes,
|
|
layout,
|
|
});
|
|
}
|
|
|
|
Ok(windows)
|
|
}
|
|
|
|
fn prompt_for_panes(window_name: &str) -> Result<Vec<String>> {
|
|
let input = ui::render_panes_prompt(window_name)?;
|
|
|
|
let pane_names = ui::parse_comma_separated_list(&input);
|
|
|
|
if pane_names.is_empty() {
|
|
// Single pane, no commands
|
|
return Ok(vec![]);
|
|
}
|
|
|
|
// Ask for commands for each pane
|
|
let mut panes = Vec::new();
|
|
for pane_name in pane_names {
|
|
let command = ui::render_pane_command_prompt(&pane_name)?;
|
|
panes.push(command);
|
|
}
|
|
|
|
Ok(panes)
|
|
}
|
|
|
|
fn parse_paths_input(input: &str) -> Vec<String> {
|
|
input
|
|
.trim()
|
|
.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
fn default_config() -> Self {
|
|
Config {
|
|
paths: vec![
|
|
dirs::home_dir()
|
|
.unwrap_or_default()
|
|
.join("Projects")
|
|
.to_string_lossy()
|
|
.to_string(),
|
|
],
|
|
max_depth: 5,
|
|
cache_enabled: true,
|
|
cache_ttl_hours: 24,
|
|
update_check_interval_hours: default_update_check_interval_hours(),
|
|
default_session: default_session_config(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn should_use_defaults_when_optional_fields_missing() {
|
|
let toml_str = r#"paths = ["/home/user/projects"]"#;
|
|
let config: Config = toml::from_str(toml_str).unwrap();
|
|
assert_eq!(config.max_depth, 5);
|
|
assert!(config.cache_enabled);
|
|
assert_eq!(config.cache_ttl_hours, 24);
|
|
assert_eq!(config.update_check_interval_hours, 24);
|
|
}
|
|
|
|
#[test]
|
|
fn should_parse_full_config_correctly() {
|
|
let toml_str = r#"
|
|
paths = ["/foo", "/bar"]
|
|
max_depth = 3
|
|
cache_enabled = false
|
|
cache_ttl_hours = 12
|
|
"#;
|
|
let config: Config = toml::from_str(toml_str).unwrap();
|
|
assert_eq!(config.paths, vec!["/foo", "/bar"]);
|
|
assert_eq!(config.max_depth, 3);
|
|
assert!(!config.cache_enabled);
|
|
assert_eq!(config.cache_ttl_hours, 12);
|
|
}
|
|
|
|
#[test]
|
|
fn should_reject_invalid_toml() {
|
|
let result: Result<Config, _> = toml::from_str("not valid toml ]][[");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn should_parse_single_path() {
|
|
let input = "~/Projects";
|
|
let paths = Config::parse_paths_input(input);
|
|
assert_eq!(paths, vec!["~/Projects"]);
|
|
}
|
|
|
|
#[test]
|
|
fn should_parse_multiple_paths_with_commas() {
|
|
let input = "~/Projects, ~/work, ~/repos";
|
|
let paths = Config::parse_paths_input(input);
|
|
assert_eq!(paths, vec!["~/Projects", "~/work", "~/repos"]);
|
|
}
|
|
|
|
#[test]
|
|
fn should_trim_whitespace_from_paths() {
|
|
let input = " ~/Projects , ~/work ";
|
|
let paths = Config::parse_paths_input(input);
|
|
assert_eq!(paths, vec!["~/Projects", "~/work"]);
|
|
}
|
|
|
|
#[test]
|
|
fn should_return_empty_vec_for_empty_input() {
|
|
let input = "";
|
|
let paths = Config::parse_paths_input(input);
|
|
assert!(paths.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn should_return_empty_vec_for_whitespace_only() {
|
|
let input = " ";
|
|
let paths = Config::parse_paths_input(input);
|
|
assert!(paths.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn should_handle_empty_parts_between_commas() {
|
|
let input = "~/Projects,,~/work";
|
|
let paths = Config::parse_paths_input(input);
|
|
assert_eq!(paths, vec!["~/Projects", "~/work"]);
|
|
}
|
|
|
|
#[test]
|
|
fn should_use_ui_parse_functions_for_max_depth() {
|
|
// Test that our UI parsing produces expected results
|
|
assert_eq!(ui::parse_max_depth_input(""), None);
|
|
assert_eq!(ui::parse_max_depth_input("5"), Some(5));
|
|
assert_eq!(ui::parse_max_depth_input("invalid"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn should_use_ui_parse_functions_for_cache_enabled() {
|
|
assert_eq!(ui::parse_cache_enabled_input(""), None);
|
|
assert_eq!(ui::parse_cache_enabled_input("y"), Some(true));
|
|
assert_eq!(ui::parse_cache_enabled_input("n"), Some(false));
|
|
assert_eq!(ui::parse_cache_enabled_input("maybe"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn should_use_ui_parse_functions_for_cache_ttl() {
|
|
assert_eq!(ui::parse_cache_ttl_input(""), None);
|
|
assert_eq!(ui::parse_cache_ttl_input("24"), Some(24));
|
|
assert_eq!(ui::parse_cache_ttl_input("invalid"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn should_use_ui_parse_functions_for_window_names() {
|
|
let result = ui::parse_comma_separated_list("editor, terminal, server");
|
|
assert_eq!(result, vec!["editor", "terminal", "server"]);
|
|
}
|
|
|
|
#[test]
|
|
fn should_use_ui_parse_functions_for_layout() {
|
|
assert_eq!(ui::parse_layout_input(""), None);
|
|
assert_eq!(
|
|
ui::parse_layout_input("1"),
|
|
Some("main-horizontal".to_string())
|
|
);
|
|
assert_eq!(
|
|
ui::parse_layout_input("main-vertical"),
|
|
Some("main-vertical".to_string())
|
|
);
|
|
assert_eq!(ui::parse_layout_input("invalid"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn should_parse_config_with_windows_and_panes() {
|
|
let toml_str = r#"
|
|
paths = ["/projects"]
|
|
max_depth = 3
|
|
cache_enabled = true
|
|
cache_ttl_hours = 12
|
|
|
|
[default_session]
|
|
[[default_session.windows]]
|
|
name = "editor"
|
|
panes = ["nvim .", "git status"]
|
|
|
|
[[default_session.windows]]
|
|
name = "terminal"
|
|
panes = []
|
|
"#;
|
|
let config: Config = toml::from_str(toml_str).unwrap();
|
|
assert_eq!(config.paths, vec!["/projects"]);
|
|
assert_eq!(config.max_depth, 3);
|
|
assert!(config.cache_enabled);
|
|
assert_eq!(config.cache_ttl_hours, 12);
|
|
assert_eq!(config.default_session.windows.len(), 2);
|
|
assert_eq!(config.default_session.windows[0].name, "editor");
|
|
assert_eq!(config.default_session.windows[0].panes.len(), 2);
|
|
assert_eq!(config.default_session.windows[0].panes[0], "nvim .");
|
|
assert_eq!(config.default_session.windows[0].panes[1], "git status");
|
|
assert_eq!(config.default_session.windows[1].name, "terminal");
|
|
assert!(config.default_session.windows[1].panes.is_empty());
|
|
}
|
|
}
|